mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(ghidra): reuse existing projects through verified copies (#7236)
Reuse selected Ghidra projects through verified private read-only copies, with shared CLI/MCP configuration, committed project identity, import-SHA verification and explicit historical/unknown analysis semantics. Preserve exact cancellation and platform capability boundaries. Actual Ghidra 12.1.4/JDK21 Linux CLI/MCP investigations matched fresh import on 652-function ls and 14,338-function libcrypto inventories and pseudocode; authenticated complete Evidence, mismatch/staleness refusal, saved historical reports and shared /tmp projects verified. 958 focused tests passed with four platform skips; build/static/docs/format passed. Real macOS/Windows reuse remains unverified; Windows reuse is unavailable. See PR verification for measured whole-task costs and final CI attribution. Co-authored-by: akushonkamen <akushonkamen@163.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
26 files changed
+2215
-136
No files matched your search
@@ -347,6 +347,19 @@ operation's full ordered list. Candidate capabilities, returned with complete
|
|||||||
detail, describe possible providers. Top-level session capabilities describe the current route, and
|
detail, describe possible providers. Top-level session capabilities describe the current route, and
|
||||||
`analysis_provider_binding` identifies the selected provider.
|
`analysis_provider_binding` identifies the selected provider.
|
||||||
|
|
||||||
|
## Reuse caller-selected Ghidra analysis
|
||||||
|
|
||||||
|
When the caller supplies an expanded project, configure `REA_GHIDRA_PROJECT_PATH`
|
||||||
|
(the `.gpr` marker) and optionally `REA_GHIDRA_PROGRAM_NAME` (one root program),
|
||||||
|
then restart/reconnect before `open_binary`. CLI uses the same configuration.
|
||||||
|
REA opens only a fingerprint-bound owned copy with `-process -noanalysis -readOnly`;
|
||||||
|
original project files are not opened by the engine or saved. Database edits,
|
||||||
|
import overrides and repair scripts are unavailable in this mode. Treat original
|
||||||
|
analysis completeness/settings as unknown and prior edits as caller-project state.
|
||||||
|
The program's recorded import digest must match the target; changed project content
|
||||||
|
requires reopening for a fresh profile. See the
|
||||||
|
[setup and preparation guide](https://github.com/morluto/rea/blob/main/docs/installation.md#reuse-an-existing-ghidra-project).
|
||||||
|
|
||||||
## Ghidra startup deadline recovery
|
## Ghidra startup deadline recovery
|
||||||
|
|
||||||
`provider_timeout` during the first Ghidra query can cover import, auto-analysis,
|
`provider_timeout` during the first Ghidra query can cover import, auto-analysis,
|
||||||
@@ -590,6 +603,7 @@ discovery are unsupported. Windows Ghidra P0 admits raw images only for `6502`.
|
|||||||
|
|
||||||
### Function annotations in Ghidra
|
### Function annotations in Ghidra
|
||||||
|
|
||||||
|
Fresh-import sessions admit annotations; existing-project reuse is read-only.
|
||||||
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier.
|
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier.
|
||||||
|
|
||||||
## Offline PE resource inventory
|
## Offline PE resource inventory
|
||||||
|
|||||||
@@ -434,6 +434,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
"Ghidra auto-analysis did not complete for this Program"
|
"Ghidra auto-analysis did not complete for this Program"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (descriptor.existingProject && isMutatingOperation(request.method))
|
||||||
|
throw new RequestFailure("method_unavailable", "Existing Ghidra project sessions are read-only");
|
||||||
return switch (request.method) {
|
return switch (request.method) {
|
||||||
case "read_binary_inventory_summary" -> binaryInventorySummary(request.params);
|
case "read_binary_inventory_summary" -> binaryInventorySummary(request.params);
|
||||||
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
|
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
|
||||||
@@ -486,6 +488,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isMutatingOperation(String method) {
|
||||||
|
return method.equals("annotate_native_function") || method.equals("set_address_name") || method.equals("set_addresses_names");
|
||||||
|
}
|
||||||
|
|
||||||
private JsonObject sessionInfo(SessionDescriptor descriptor) throws Exception {
|
private JsonObject sessionInfo(SessionDescriptor descriptor) throws Exception {
|
||||||
boolean timedOut = analysisTimeoutOccurred();
|
boolean timedOut = analysisTimeoutOccurred();
|
||||||
JsonObject provider = new JsonObject();
|
JsonObject provider = new JsonObject();
|
||||||
@@ -511,14 +517,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
result.addProperty("run_id", descriptor.runId);
|
result.addProperty("run_id", descriptor.runId);
|
||||||
result.addProperty("profile_digest", descriptor.profileDigest);
|
result.addProperty("profile_digest", descriptor.profileDigest);
|
||||||
result.add("provider", provider);
|
result.add("provider", provider);
|
||||||
boolean readOnly = !descriptor.transport.equals("unix-socket");
|
boolean readOnly = descriptor.existingProject || !descriptor.transport.equals("unix-socket");
|
||||||
result.addProperty("read_only", readOnly);
|
result.addProperty("read_only", readOnly);
|
||||||
result.addProperty("analysis_complete", !timedOut);
|
if (descriptor.existingProject) {
|
||||||
|
result.add("analysis_complete", JsonNull.INSTANCE);
|
||||||
|
result.addProperty("existing_project", true);
|
||||||
|
} else result.addProperty("analysis_complete", !timedOut);
|
||||||
result.addProperty("analysis_timed_out", timedOut);
|
result.addProperty("analysis_timed_out", timedOut);
|
||||||
result.add("capabilities", GSON.toJsonTree(
|
result.add("capabilities", GSON.toJsonTree(
|
||||||
java.util.Arrays.stream(CAPABILITIES)
|
java.util.Arrays.stream(CAPABILITIES)
|
||||||
.filter(value -> !readOnly || !(value.equals("annotate_native_function")
|
.filter(value -> !readOnly || !isMutatingOperation(value))
|
||||||
|| value.equals("set_address_name") || value.equals("set_addresses_names")))
|
|
||||||
.toArray(String[]::new)
|
.toArray(String[]::new)
|
||||||
));
|
));
|
||||||
result.add("target", target);
|
result.add("target", target);
|
||||||
@@ -4752,6 +4760,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
keys.add("analysis_extensions");
|
keys.add("analysis_extensions");
|
||||||
if (parsed.isJsonObject() && parsed.getAsJsonObject().has("decompile_timeout_seconds"))
|
if (parsed.isJsonObject() && parsed.getAsJsonObject().has("decompile_timeout_seconds"))
|
||||||
keys.add("decompile_timeout_seconds");
|
keys.add("decompile_timeout_seconds");
|
||||||
|
if (parsed.isJsonObject() && parsed.getAsJsonObject().has("existing_project"))
|
||||||
|
keys.add("existing_project");
|
||||||
JsonObject object = requireObject(parsed, keys);
|
JsonObject object = requireObject(parsed, keys);
|
||||||
int decompileTimeout = 0;
|
int decompileTimeout = 0;
|
||||||
if (object.has("decompile_timeout_seconds")) {
|
if (object.has("decompile_timeout_seconds")) {
|
||||||
@@ -4768,7 +4778,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
requireString(object, "provider_version"),
|
requireString(object, "provider_version"),
|
||||||
requireString(object, "profile_digest"),
|
requireString(object, "profile_digest"),
|
||||||
object.has("analysis_extensions") ? object.getAsJsonArray("analysis_extensions") : new JsonArray(),
|
object.has("analysis_extensions") ? object.getAsJsonArray("analysis_extensions") : new JsonArray(),
|
||||||
decompileTimeout
|
decompileTimeout,
|
||||||
|
object.has("existing_project") && requireBoolean(object, "existing_project")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4985,7 +4996,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
String providerVersion,
|
String providerVersion,
|
||||||
String profileDigest,
|
String profileDigest,
|
||||||
JsonArray analysisExtensions,
|
JsonArray analysisExtensions,
|
||||||
int decompileTimeoutSeconds
|
int decompileTimeoutSeconds,
|
||||||
|
boolean existingProject
|
||||||
) {}
|
) {}
|
||||||
private record Request(int id, String method, JsonObject params, Integer decompileTimeoutSeconds) {}
|
private record Request(int id, String method, JsonObject params, Integer decompileTimeoutSeconds) {}
|
||||||
|
|
||||||
|
|||||||
+62
-4
@@ -1160,9 +1160,10 @@ This coordinate selects only REA's session-private configuration directory, not
|
|||||||
the installation or an existing user configuration directory.
|
the installation or an existing user configuration directory.
|
||||||
|
|
||||||
Each verified session uses an ephemeral temporary project and isolated
|
Each verified session uses an ephemeral temporary project and isolated
|
||||||
home/cache/config/temp paths. REA passes `-readOnly`, `-deleteProject`, uses
|
home/cache/config/temp paths. By default REA passes `-readOnly`, `-deleteProject`,
|
||||||
Ghidra's default analysis settings, and loads its packaged Java
|
uses Ghidra's default analysis settings, and loads its packaged Java
|
||||||
bridge via `-scriptPath`; it never opens an existing user project. Linux and
|
bridge via `-scriptPath` into a new ephemeral project; opt-in
|
||||||
|
[existing-project reuse](#reuse-an-existing-ghidra-project) opens only an owned copy. Linux and
|
||||||
macOS use a current-user-only local bridge socket and descriptor. The
|
macOS use a current-user-only local bridge socket and descriptor. The
|
||||||
project remains under the selected temporary directory. If its Unix socket
|
project remains under the selected temporary directory. If its Unix socket
|
||||||
pathname would exceed the host's byte limit, REA allocates a separate owner-only
|
pathname would exceed the host's byte limit, REA allocates a separate owner-only
|
||||||
@@ -1181,7 +1182,7 @@ original process owner for a later cleanup retry.
|
|||||||
The experimental Windows transport uses authenticated IPv4 loopback with a
|
The experimental Windows transport uses authenticated IPv4 loopback with a
|
||||||
private native-owned bearer descriptor and Job Object process ownership.
|
private native-owned bearer descriptor and Job Object process ownership.
|
||||||
|
|
||||||
Operations begin only after default auto-analysis completes. `open_binary`
|
In the default fresh-import mode, operations begin only after default auto-analysis completes. `open_binary`
|
||||||
selects and validates the target/provider binding; it does not wait for Ghidra
|
selects and validates the target/provider binding; it does not wait for Ghidra
|
||||||
import and auto-analysis. The first Ghidra-backed query starts that work lazily.
|
import and auto-analysis. The first Ghidra-backed query starts that work lazily.
|
||||||
The provider startup deadline is 330,000 ms by default for import, analysis,
|
The provider startup deadline is 330,000 ms by default for import, analysis,
|
||||||
@@ -1312,6 +1313,63 @@ not needed for the make step, only for the Ghidra provider. If your toolchain
|
|||||||
rejects the inherited `-mmacosx-version-min=10.6` flag, retry the make with
|
rejects the inherited `-mmacosx-version-min=10.6` flag, retry the make with
|
||||||
`ADDITIONAL_FLAGS="-w"`.
|
`ADDITIONAL_FLAGS="-w"`.
|
||||||
|
|
||||||
|
### Reuse an existing Ghidra project
|
||||||
|
|
||||||
|
For repeated investigations, select an expanded local project marker with
|
||||||
|
`REA_GHIDRA_PROJECT_PATH=/absolute/projects/app-analysis.gpr`. Both CLI and MCP
|
||||||
|
use this shared selection. `REA_GHIDRA_PROGRAM_NAME` selects one exact program
|
||||||
|
in the project root; when omitted it defaults to the selected target's filename.
|
||||||
|
Paths, globs and command flags are not program names. The matching `.rep`
|
||||||
|
directory must be present; restore `.grez` archives with Ghidra first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export REA_ANALYSIS_PROVIDER=ghidra
|
||||||
|
export REA_GHIDRA_PROJECT_PATH=/absolute/projects/app-analysis.gpr
|
||||||
|
export REA_GHIDRA_PROGRAM_NAME=app
|
||||||
|
rea decompile /absolute/target/app --procedure entry --provider ghidra --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the same variables in the MCP server environment and restart/reconnect;
|
||||||
|
`open_binary` still selects the original target file. `rea setup` retains these
|
||||||
|
explicit settings in a saved registration. Fresh import remains the default
|
||||||
|
when the project setting is absent. This route supports native ELF, PE and
|
||||||
|
Mach-O programs on POSIX without provider-pinned import preparation; Windows,
|
||||||
|
raw/DOS/DOL and special pinned-language import profiles require fresh import.
|
||||||
|
|
||||||
|
REA fingerprints only the selected `.gpr`/`.rep` pair, copies it into its owned
|
||||||
|
runtime, verifies the copied content against the committed analysis profile,
|
||||||
|
and runs `-process <program> -noanalysis -readOnly`. Ghidra never opens or saves
|
||||||
|
the original database; REA reads selected files only for fingerprinting and copying.
|
||||||
|
This mode does not copy unrelated sibling projects, rerun repair scripts, or
|
||||||
|
admit bridge database edits. Cleanup removes the owned copy.
|
||||||
|
The bridge checks the program's recorded import SHA-256 against the admitted
|
||||||
|
target digest; mismatches and project changes fail explicitly. Reopen the target
|
||||||
|
to resolve a new profile after intentionally changing the source project.
|
||||||
|
|
||||||
|
The profile records the caller path, resolved project/name, program and content
|
||||||
|
fingerprint. Original analysis settings and completeness remain **unknown**;
|
||||||
|
prior annotations or edits are caller-project state, not fresh REA discoveries.
|
||||||
|
A matching recorded import digest does not claim that the database is unedited.
|
||||||
|
Saved REA seed/analyzer reports remain historical facts in retained source Evidence;
|
||||||
|
they are not interpreted as overrides applied by this session.
|
||||||
|
Project content participates in snapshot identity, so different project states
|
||||||
|
cannot reuse the same profile's cached observations.
|
||||||
|
|
||||||
|
To prepare a new project explicitly, use your existing Ghidra installation in a
|
||||||
|
new directory you own (this writes that project; REA does not save it for you):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir /absolute/new-projects
|
||||||
|
GHIDRA_HEADLESS_MAXMEM=1536M JAVA_TOOL_OPTIONS=-XX:ActiveProcessorCount=2 \
|
||||||
|
"$GHIDRA_INSTALL_DIR/support/analyzeHeadless" /absolute/new-projects app-analysis \
|
||||||
|
-import /absolute/target/app -analysisTimeoutPerFile 600 -max-cpu 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that preparation completed successfully before selecting the project.
|
||||||
|
Preparation time, disk copy/fingerprinting, engine startup and client deadlines
|
||||||
|
are separate costs; reusing a project avoids import/analysis, not all startup.
|
||||||
|
Use the [heap/CPU controls](#ghidra-heap-and-cpu-controls) appropriate to the target.
|
||||||
|
|
||||||
### Ghidra import language and analysis seeds
|
### Ghidra import language and analysis seeds
|
||||||
|
|
||||||
Ghidra detects the language from the executable header. To set it (for example, Borland C++ PE images):
|
Ghidra detects the language from the executable header. To set it (for example, Borland C++ PE images):
|
||||||
|
|||||||
@@ -1000,6 +1000,12 @@ A successful MCP initialize handshake establishes the REA connection.
|
|||||||
that Ghidra has finished importing the target. The first Ghidra-backed query,
|
that Ghidra has finished importing the target. The first Ghidra-backed query,
|
||||||
such as `binary_overview`, starts the engine and waits for import, default
|
such as `binary_overview`, starts the engine and waits for import, default
|
||||||
auto-analysis, bridge connection, and health readiness before returning analysis.
|
auto-analysis, bridge connection, and health readiness before returning analysis.
|
||||||
|
With `REA_GHIDRA_PROJECT_PATH`, shared CLI/MCP configuration instead selects an
|
||||||
|
[existing project copy](installation.md#reuse-an-existing-ghidra-project), whose
|
||||||
|
content fingerprint is committed into the profile. Import, auto-analysis and
|
||||||
|
repair scripts are skipped; original analysis completeness/settings stay unknown,
|
||||||
|
and bridge database edits are unavailable. The same startup/client deadlines
|
||||||
|
still cover copy verification, engine launch and the authenticated handshake.
|
||||||
|
|
||||||
These deadlines have different owners:
|
These deadlines have different owners:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Existing Ghidra project reuse: real workflow verification
|
||||||
|
|
||||||
|
The task counts the analyzed functions, reads one 32-record inventory window,
|
||||||
|
reports the overview, decompiles two known local non-thunk functions, repeats the
|
||||||
|
inventory query, exports complete Evidence and verifies it offline. Function
|
||||||
|
bodies and the complete inventory are compared by stable function identity.
|
||||||
|
The agent receives inventory summaries and the selected page; the complete
|
||||||
|
inventory remains accessible through retained Evidence and its exported bundle.
|
||||||
|
|
||||||
|
## Executed scope
|
||||||
|
|
||||||
|
Linux x64, Ghidra 12.1.4, JDK 21.0.12.1, Node 24.22.1. Measurements follow main
|
||||||
|
`e3066c170` with the feature core at `9746ec7d9`; receipt revisions distinguish
|
||||||
|
that driver from subsequent `5d237eb0c` / `314b0e038` refinements. Subsequent core
|
||||||
|
change only improves the setting named for an invalid derived program selection.
|
||||||
|
The final source suites and actual CLI/MCP cases use the updated dependency lock.
|
||||||
|
|
||||||
|
- `/usr/bin/ls`: 652 function records; complete facts SHA-256
|
||||||
|
`d2eb1ea6586b8d549663f98089290dbc020bcc9f9c867545f8ba132aa1a13195`.
|
||||||
|
- `/usr/lib/x86_64-linux-gnu/libcrypto.so.3`: 14,338 function records; artifact SHA-256
|
||||||
|
`aa01e614af634bc7c8b678c9250b5c4f613c61989bacf0a5d6aed6570e7dcabd`;
|
||||||
|
complete facts SHA-256 `033eccbb258317b7d05a4d4a48f049f5d2649b276852623cef3c1fb93e9a029a`.
|
||||||
|
|
||||||
|
Both pairs return identical full function records and the same nonempty pseudocode
|
||||||
|
for each selected address. Evidence IDs/profile identities differ intentionally:
|
||||||
|
existing-project observations commit the caller project content and unknown
|
||||||
|
original analysis history. Successful reuse verifies source project content
|
||||||
|
unchanged after closing; original binary digests remain unchanged too.
|
||||||
|
|
||||||
|
## Matched completed tasks
|
||||||
|
|
||||||
|
Each row includes SDK initialization, one discovery, every call, full bundle
|
||||||
|
export/authentication, connection cleanup and receipt publication. Both columns
|
||||||
|
in a target pair use compact output schemas and the same families:
|
||||||
|
`direct,enhanced,native,application,session`.
|
||||||
|
|
||||||
|
| Task | Functions | Wall s | Family CPU s | Peak family GiB | First / warm inventory s | Wire tokens, estimate |
|
||||||
|
| ---------------- | --------: | -----: | -----------: | --------------: | -----------------------: | --------------------: |
|
||||||
|
| current-ls-fresh | 652 | 63.62 | 84.87 | 1.276 | 55.83 / 0.416 | 85,151 |
|
||||||
|
| current-ls-reuse | 652 | 31.69 | 46.61 | 0.938 | 20.58 / 0.151 | 85,448 |
|
||||||
|
| crypto-fresh | 14,338 | 186.42 | 287.01 | 1.857 | 176.71 / 1.535 | 84,162 |
|
||||||
|
| crypto-reuse | 14,338 | 25.45 | 40.23 | 1.258 | 17.29 / 1.256 | 84,516 |
|
||||||
|
|
||||||
|
The first query starts a new provider process; the warm query reuses that session.
|
||||||
|
OS caches were not flushed. These are sequential samples on a shared host, not a
|
||||||
|
general speed guarantee. Earlier completed small-target calibrations vary
|
||||||
|
substantially (fresh 34.8–56.1 seconds; reuse 19.2 seconds), so elapsed time alone
|
||||||
|
should not be treated as a stable ratio.
|
||||||
|
|
||||||
|
CPU is summed process-family user/system time. Memory sums family RSS sampled
|
||||||
|
every 100 ms; shared pages can be double-counted and short peaks missed. Every
|
||||||
|
run used 2-CPU affinity, an 8-GiB family RSS guard and a wall deadline. Fresh and
|
||||||
|
reuse used the same heap request per target: 1,536 MiB for ls, 3,072 MiB for crypto.
|
||||||
|
No measured workflow triggered OOM, a resource guard or a cleanup failure.
|
||||||
|
|
||||||
|
Token estimates encode every captured JSON-RPC message, including initialize,
|
||||||
|
full discovery schemas, requests, result text/structured content and notifications,
|
||||||
|
with `cl100k_base`. They exclude framing newlines and are not actual model usage.
|
||||||
|
The model adapter, model tokenizer, hidden prompts and billing were not measured.
|
||||||
|
Project provenance adds a small response cost; this feature primarily avoids
|
||||||
|
repeated import/analysis rather than reducing tokens. Complete Evidence files
|
||||||
|
are verified locally and are not inserted into the agent transcript.
|
||||||
|
|
||||||
|
## Preparation cost and scope refinement
|
||||||
|
|
||||||
|
The explicit pre-import step saves a new project outside REA; its time is not
|
||||||
|
hidden in a warm result. With the same 2-CPU bound, ls preparation cost 36.17 s
|
||||||
|
wall, 59.54 CPU s and 0.845 GiB peak family RSS. Crypto preparation cost 231.76 s,
|
||||||
|
251.08 CPU s and 1.615 GiB peak. Both used default analysis followed by the same
|
||||||
|
REA no-return repair that a fresh REA import uses, enabling an equivalent-data
|
||||||
|
comparison. Ordinary caller projects may have different analysis histories.
|
||||||
|
Preparation plus the first investigation can cost more than one fresh import;
|
||||||
|
the benefit is repeated sessions or separately paid analysis that exceeds startup.
|
||||||
|
|
||||||
|
The task does not actually need the native family. A narrower executed ls pair
|
||||||
|
uses `direct,enhanced,application,session`, advertises 97 tools, preserves the same
|
||||||
|
complete facts and costs 67,523 / 67,820 estimated wire tokens for fresh / reuse,
|
||||||
|
versus 85,151 / 85,448 above. Its observed wall/CPU/peak values are 37.35 s / 62.63 s /
|
||||||
|
1.089 GiB fresh and 20.17 s / 32.21 s / 0.886 GiB reuse. These separate samples
|
||||||
|
confirm the smaller catalog works; they do not establish a general speedup from
|
||||||
|
scoping. Use the narrow scope when reproducing the task.
|
||||||
|
|
||||||
|
## Negative cases and verification limits
|
||||||
|
|
||||||
|
The actual production MCP path rejected `/usr/bin/true` against the ls project
|
||||||
|
with the recorded-import digest mismatch. A separate owned source clone changed
|
||||||
|
after open was rejected by the first query with its changed-input reason, then
|
||||||
|
cleaned up; the test restores its own intentional marker edit. Successful files,
|
||||||
|
source content and cleanup outcomes are verified separately. The actual compiled
|
||||||
|
CLI `decompile` selected the same project/profile and returned the same pseudocode
|
||||||
|
as MCP for `0x10a420`.
|
||||||
|
|
||||||
|
Final focused validation: 35 files, 958 passed / 4 platform skips; typecheck/lint,
|
||||||
|
build, 9 documentation cases and full formatting passed. No real macOS, Windows,
|
||||||
|
PE or Mach-O reuse claim is made. Windows and special fresh-import profiles remain
|
||||||
|
explicitly unsupported for this mode. Very large project trees and interrupted
|
||||||
|
cross-process database writes were not measured.
|
||||||
|
|
||||||
|
Initial driver calibrations caught external-first inventory ordering and
|
||||||
|
Evidence-ID ordering across different profiles. The driver now selects known
|
||||||
|
local functions from a bounded middle page and compares pseudocode by function
|
||||||
|
identity. Those failed calibration receipts remain in the maintainer workspace;
|
||||||
|
they are not counted as completed task successes.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
Build with the pinned toolchain and locked dependencies. Prepare a new owned
|
||||||
|
project using the documented [pre-import route](../../docs/installation.md#reuse-an-existing-ghidra-project).
|
||||||
|
The measurement used the same no-return repair during preparation for data parity.
|
||||||
|
Run the committed driver first without `--project`, then with it and `--control`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node experiments/analysis-efficiency/ghidra-project-reuse.mjs \
|
||||||
|
--runtime /absolute/rea --target /usr/bin/ls --output /absolute/new-receipt \
|
||||||
|
--profile compact --families direct,enhanced,application,session \
|
||||||
|
--ghidra "$GHIDRA_INSTALL_DIR" --java "$JAVA_HOME" \
|
||||||
|
--project /absolute/projects/ls-analysis.gpr --program ls \
|
||||||
|
--control /absolute/fresh-receipt
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--heap 3072M` for the crypto case. `--expected mismatch` and `--expected stale`
|
||||||
|
exercise negative cases; stale intentionally edits/restores an owned test marker,
|
||||||
|
so use a disposable source copy. The driver retains full wire messages, catalog,
|
||||||
|
report, exported Evidence and normalized comparison facts in its output directory.
|
||||||
|
Resource receipts were collected with an external bounded process-family sampler;
|
||||||
|
its method and limits are described above. Actual model-token usage remains unmeasured.
|
||||||
|
|
||||||
|
## Review boundary regressions
|
||||||
|
|
||||||
|
After the measurement runs, review coverage adds resolved-executable defaults for
|
||||||
|
`.app` inputs (caller bundle paths remain provenance), early unavailable Windows
|
||||||
|
existing-project discovery, exact cancellation reasons from both checkpoints and
|
||||||
|
owned file reads, and selected-pair overlap checks that allow a project directly
|
||||||
|
under `/tmp`. The ambiguous Windows properties fixture cases were removed; the
|
||||||
|
test now covers its actual two distinct release-directory inputs.
|
||||||
|
|
||||||
|
A real Ghidra test copy saved seed and disabled-analyzer reports with REA's actual
|
||||||
|
pre-scripts. Reopening it through production MCP preserved both as one immutable
|
||||||
|
historical Evidence record, linked it from function observations, returned the
|
||||||
|
same function/pseudocode facts, and left source content unchanged. Fresh-import
|
||||||
|
checks still validate fresh requested work. No real macOS/Windows reuse claim is
|
||||||
|
implied by simulated platform boundary tests.
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/** Equivalent bounded Ghidra investigations with fresh import or an existing project. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { finished } from "node:stream/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { parseArgs, promisify } from "node:util";
|
||||||
|
import { Client } from "@modelcontextprotocol/client";
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||||
|
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
|
||||||
|
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: Object.fromEntries(
|
||||||
|
[
|
||||||
|
"runtime",
|
||||||
|
"output",
|
||||||
|
"profile",
|
||||||
|
"target",
|
||||||
|
"receive-bytes",
|
||||||
|
"families",
|
||||||
|
"ghidra",
|
||||||
|
"java",
|
||||||
|
"project",
|
||||||
|
"program",
|
||||||
|
"control",
|
||||||
|
"expected",
|
||||||
|
"heap",
|
||||||
|
"history",
|
||||||
|
].map((key) => [key, { type: "string" }]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
for (const key of ["runtime", "output", "target", "ghidra", "java"]) {
|
||||||
|
assert.ok(values[key], `${key} is required`);
|
||||||
|
values[key] = resolve(values[key]);
|
||||||
|
}
|
||||||
|
assert.ok(["full", "compact"].includes(values.profile));
|
||||||
|
const receiveBytes = Number(values["receive-bytes"] ?? 10 * 1024 * 1024);
|
||||||
|
assert.ok(Number.isSafeInteger(receiveBytes) && receiveBytes > 0);
|
||||||
|
await mkdir(values.output);
|
||||||
|
const report = {
|
||||||
|
profile: values.profile,
|
||||||
|
families: values.families ?? null,
|
||||||
|
receive_bytes: receiveBytes,
|
||||||
|
runtime: values.runtime,
|
||||||
|
target: values.target,
|
||||||
|
project: values.project ?? null,
|
||||||
|
expected: values.expected ?? "success",
|
||||||
|
calls: [],
|
||||||
|
};
|
||||||
|
const transcript = [];
|
||||||
|
let account, client, transport, serverLog, serverLogFinished;
|
||||||
|
let phase = "initialize";
|
||||||
|
const began = performance.now();
|
||||||
|
try {
|
||||||
|
account = await mkdtemp(join(tmpdir(), "rea-output-profile-"));
|
||||||
|
report.target_sha256 = createHash("sha256")
|
||||||
|
.update(await readFile(values.target))
|
||||||
|
.digest("hex");
|
||||||
|
report.revision = (
|
||||||
|
await promisify(execFile)("git", [
|
||||||
|
"-C",
|
||||||
|
values.runtime,
|
||||||
|
"rev-parse",
|
||||||
|
"HEAD",
|
||||||
|
])
|
||||||
|
).stdout.trim();
|
||||||
|
transport = new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
|
||||||
|
cwd: values.runtime,
|
||||||
|
maxBufferSize: receiveBytes,
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
GHIDRA_INSTALL_DIR: values.ghidra,
|
||||||
|
JAVA_HOME: values.java,
|
||||||
|
GHIDRA_HEADLESS_MAXMEM: values.heap ?? "1536M",
|
||||||
|
JAVA_TOOL_OPTIONS: "-XX:ActiveProcessorCount=2",
|
||||||
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "600000",
|
||||||
|
...(values.project === undefined
|
||||||
|
? {}
|
||||||
|
: { REA_GHIDRA_PROJECT_PATH: values.project }),
|
||||||
|
...(values.program === undefined
|
||||||
|
? {}
|
||||||
|
: { REA_GHIDRA_PROGRAM_NAME: values.program }),
|
||||||
|
HOME: account,
|
||||||
|
USERPROFILE: account,
|
||||||
|
NODE_OPTIONS: process.env.NODE_OPTIONS ?? "--max-old-space-size=3072",
|
||||||
|
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
||||||
|
REA_MCP_OUTPUT_SCHEMA_PROFILE: values.profile,
|
||||||
|
...(values.families === undefined
|
||||||
|
? {}
|
||||||
|
: { REA_MCP_TOOL_FAMILIES: values.families }),
|
||||||
|
},
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const start = transport.start.bind(transport);
|
||||||
|
transport.start = async () => {
|
||||||
|
const onerror = transport.onerror;
|
||||||
|
transport.onerror = (cause) => {
|
||||||
|
(report.transport_errors ??= []).push(String(cause));
|
||||||
|
onerror?.(cause);
|
||||||
|
};
|
||||||
|
const receive = transport.onmessage;
|
||||||
|
transport.onmessage = (message) => {
|
||||||
|
transcript.push({ direction: "response", phase, message });
|
||||||
|
receive?.(message);
|
||||||
|
};
|
||||||
|
await start();
|
||||||
|
};
|
||||||
|
const send = transport.send.bind(transport);
|
||||||
|
transport.send = async (message) => {
|
||||||
|
transcript.push({ direction: "request", phase, message });
|
||||||
|
await send(message);
|
||||||
|
};
|
||||||
|
serverLog = createWriteStream(join(values.output, "server.log"));
|
||||||
|
serverLogFinished = finished(serverLog);
|
||||||
|
serverLogFinished.catch(() => {});
|
||||||
|
transport.stderr.pipe(serverLog);
|
||||||
|
client = new Client({
|
||||||
|
name: "rea-output-profile-investigation",
|
||||||
|
version: "1",
|
||||||
|
});
|
||||||
|
await client.connect(transport);
|
||||||
|
phase = "discovery-cold";
|
||||||
|
const discoveryBegan = performance.now();
|
||||||
|
const catalog = await client.listTools({}, { timeout: 15000 });
|
||||||
|
report.discovery_ms = performance.now() - discoveryBegan;
|
||||||
|
report.catalog_bytes = Buffer.byteLength(JSON.stringify(catalog));
|
||||||
|
report.tools = catalog.tools.length;
|
||||||
|
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||||
|
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
||||||
|
const call = async (name, args, label) => {
|
||||||
|
phase = label;
|
||||||
|
const at = performance.now();
|
||||||
|
const result = await client.callTool(
|
||||||
|
{ name, arguments: args },
|
||||||
|
{ timeout: 900000, toolDefinition: definitions.get(name) },
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
result.isError,
|
||||||
|
true,
|
||||||
|
`${name}: ${JSON.stringify(result).slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
report.calls.push({ name, phase, elapsed_ms: performance.now() - at });
|
||||||
|
return result.structuredContent;
|
||||||
|
};
|
||||||
|
let sourceCommitment, projectResources, resolveProject;
|
||||||
|
if (values.project !== undefined) {
|
||||||
|
({ resolveGhidraProject: resolveProject } = await import(
|
||||||
|
pathToFileURL(
|
||||||
|
join(values.runtime, "dist/ghidra/GhidraProjectSnapshot.js"),
|
||||||
|
).href
|
||||||
|
));
|
||||||
|
const { ArtifactResourceScope } = await import(
|
||||||
|
pathToFileURL(
|
||||||
|
join(values.runtime, "dist/artifacts/ArtifactResourceScope.js"),
|
||||||
|
).href
|
||||||
|
);
|
||||||
|
projectResources = new ArtifactResourceScope();
|
||||||
|
try {
|
||||||
|
sourceCommitment = await resolveProject(
|
||||||
|
{
|
||||||
|
path: values.project,
|
||||||
|
...(values.program === undefined
|
||||||
|
? {}
|
||||||
|
: { programName: values.program }),
|
||||||
|
},
|
||||||
|
{ path: values.target },
|
||||||
|
projectResources,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await projectResources.close();
|
||||||
|
}
|
||||||
|
report.project_sha256 = sourceCommitment.sha256;
|
||||||
|
}
|
||||||
|
await call(
|
||||||
|
"open_binary",
|
||||||
|
{ path: values.target, provider_id: "ghidra" },
|
||||||
|
"open",
|
||||||
|
);
|
||||||
|
if (["mismatch", "stale"].includes(values.expected)) {
|
||||||
|
phase = "mismatched-project";
|
||||||
|
const marker =
|
||||||
|
values.expected === "stale" ? await readFile(values.project) : undefined;
|
||||||
|
if (marker !== undefined)
|
||||||
|
await writeFile(
|
||||||
|
values.project,
|
||||||
|
Buffer.concat([marker, Buffer.from("\n")]),
|
||||||
|
);
|
||||||
|
let rejected;
|
||||||
|
try {
|
||||||
|
rejected = await client.callTool(
|
||||||
|
{ name: "list_procedures", arguments: { detail: "summary" } },
|
||||||
|
{ timeout: 900000, toolDefinition: definitions.get("list_procedures") },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (marker !== undefined) await writeFile(values.project, marker);
|
||||||
|
}
|
||||||
|
assert.equal(rejected.isError, true);
|
||||||
|
assert.match(
|
||||||
|
JSON.stringify(rejected),
|
||||||
|
values.expected === "stale"
|
||||||
|
? /changed while being read|artifact.changed/iu
|
||||||
|
: /imported-byte digest does not match/u,
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(values.output, "rejection.json"),
|
||||||
|
JSON.stringify(rejected),
|
||||||
|
);
|
||||||
|
report[`rejected_${values.expected}`] = true;
|
||||||
|
} else {
|
||||||
|
const summary = await call(
|
||||||
|
"list_procedures",
|
||||||
|
{ detail: "summary" },
|
||||||
|
"inventory-cold",
|
||||||
|
);
|
||||||
|
const parentId = summary.normalized_result.parent_evidence_id;
|
||||||
|
const count = summary.normalized_result.summary.row_count;
|
||||||
|
assert.ok(count > 0);
|
||||||
|
report.procedure_count = count;
|
||||||
|
const page = await call(
|
||||||
|
"inspect_analysis_view",
|
||||||
|
{
|
||||||
|
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||||
|
view: {
|
||||||
|
kind: "native-inventory",
|
||||||
|
offset: Math.floor(count / 2),
|
||||||
|
limit: Math.min(count - Math.floor(count / 2), 32),
|
||||||
|
fields: ["value", "procedure"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"selected-procedures",
|
||||||
|
);
|
||||||
|
const selected = page.normalized_result.rows
|
||||||
|
.map((row) => row.record)
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.procedure?.external === false && row.procedure?.thunk === false,
|
||||||
|
)
|
||||||
|
.slice(0, 2);
|
||||||
|
assert.equal(selected.length, 2);
|
||||||
|
await call("binary_overview", {}, "overview");
|
||||||
|
for (const row of selected)
|
||||||
|
await call(
|
||||||
|
"procedure_pseudo_code",
|
||||||
|
{ procedure: row.address },
|
||||||
|
`pseudocode:${row.address}`,
|
||||||
|
);
|
||||||
|
const warm = await call(
|
||||||
|
"list_procedures",
|
||||||
|
{ detail: "summary" },
|
||||||
|
"inventory-warm",
|
||||||
|
);
|
||||||
|
assert.equal(warm.normalized_result.parent_evidence_id, parentId);
|
||||||
|
const bundlePath = join(values.output, "complete-evidence.json");
|
||||||
|
await call(
|
||||||
|
"export_evidence_bundle",
|
||||||
|
{ path: bundlePath },
|
||||||
|
"complete-export",
|
||||||
|
);
|
||||||
|
await call("close_binary", {}, "close");
|
||||||
|
await client.close();
|
||||||
|
await transport.close();
|
||||||
|
const { parseEvidenceBundle } = await import(
|
||||||
|
pathToFileURL(join(values.runtime, "dist/domain/evidenceBundle.js")).href
|
||||||
|
);
|
||||||
|
const bundle = parseEvidenceBundle(
|
||||||
|
JSON.parse(await readFile(bundlePath, "utf8")),
|
||||||
|
);
|
||||||
|
const parent = bundle.records.find(
|
||||||
|
(record) => record.evidence_id === parentId,
|
||||||
|
);
|
||||||
|
assert.ok(parent);
|
||||||
|
if (values.history === "required") {
|
||||||
|
const historical = bundle.records.filter(
|
||||||
|
(record) =>
|
||||||
|
record.operation === "health" &&
|
||||||
|
record.parameters.facet === "historical-project-analysis-reports",
|
||||||
|
);
|
||||||
|
assert.equal(historical.length, 1);
|
||||||
|
assert.equal(historical[0].normalized_result.origin, "caller-project");
|
||||||
|
assert.ok(historical[0].normalized_result.analysis_seeds);
|
||||||
|
assert.ok(historical[0].normalized_result.disabled_analyzers);
|
||||||
|
report.historical_evidence_id = historical[0].evidence_id;
|
||||||
|
report.historical_evidence_linked =
|
||||||
|
parent.evidence_links?.includes(historical[0].evidence_id) ?? false;
|
||||||
|
}
|
||||||
|
assert.equal(parent.normalized_result.length, count);
|
||||||
|
const facts = {
|
||||||
|
procedures: parent.normalized_result,
|
||||||
|
pseudocode: bundle.records
|
||||||
|
.filter((record) => record.operation === "procedure_pseudo_code")
|
||||||
|
.map((record) => ({
|
||||||
|
procedure: record.parameters.procedure,
|
||||||
|
result: record.normalized_result,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
facts.pseudocode.sort((left, right) =>
|
||||||
|
left.procedure < right.procedure
|
||||||
|
? -1
|
||||||
|
: left.procedure > right.procedure
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
report.facts_sha256 = createHash("sha256")
|
||||||
|
.update(JSON.stringify(facts))
|
||||||
|
.digest("hex");
|
||||||
|
await writeFile(join(values.output, "facts.json"), JSON.stringify(facts));
|
||||||
|
report.analysis_profile = parent.analysis_profile;
|
||||||
|
assert.equal(parent.subject.digest.sha256, report.target_sha256);
|
||||||
|
if (sourceCommitment !== undefined) {
|
||||||
|
assert.equal(
|
||||||
|
parent.analysis_profile.parameters.existing_project.sha256,
|
||||||
|
sourceCommitment.sha256,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
parent.limitations.some(
|
||||||
|
(value) =>
|
||||||
|
value.includes("completeness") && value.includes("unknown"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (values.control !== undefined) {
|
||||||
|
const control = JSON.parse(
|
||||||
|
await readFile(join(values.control, "facts.json"), "utf8"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(facts, control);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceCommitment !== undefined) {
|
||||||
|
const { ArtifactResourceScope } = await import(
|
||||||
|
pathToFileURL(
|
||||||
|
join(values.runtime, "dist/artifacts/ArtifactResourceScope.js"),
|
||||||
|
).href
|
||||||
|
);
|
||||||
|
const resources = new ArtifactResourceScope();
|
||||||
|
let after;
|
||||||
|
try {
|
||||||
|
after = await resolveProject(
|
||||||
|
{
|
||||||
|
path: values.project,
|
||||||
|
...(values.program === undefined
|
||||||
|
? {}
|
||||||
|
: { programName: values.program }),
|
||||||
|
},
|
||||||
|
{ path: values.target },
|
||||||
|
resources,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await resources.close();
|
||||||
|
}
|
||||||
|
assert.deepEqual(after, sourceCommitment);
|
||||||
|
report.original_project_unchanged = true;
|
||||||
|
}
|
||||||
|
report.outcome = "passed";
|
||||||
|
} catch (cause) {
|
||||||
|
report.outcome = "failed";
|
||||||
|
report.failure = String(cause);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
report.elapsed_ms = performance.now() - began;
|
||||||
|
const published = await finalizeRetainedQueryReceipt({
|
||||||
|
report,
|
||||||
|
client,
|
||||||
|
transport,
|
||||||
|
account,
|
||||||
|
serverLog,
|
||||||
|
serverLogFinished,
|
||||||
|
output: values.output,
|
||||||
|
transcript,
|
||||||
|
});
|
||||||
|
if (!published) process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ const SELECTED_REGISTRATION_SETTINGS = [
|
|||||||
"REA_GHIDRA_COMPILER_SPEC_ID",
|
"REA_GHIDRA_COMPILER_SPEC_ID",
|
||||||
"REA_GHIDRA_DISABLED_ANALYZERS",
|
"REA_GHIDRA_DISABLED_ANALYZERS",
|
||||||
"REA_GHIDRA_SEED_FILE",
|
"REA_GHIDRA_SEED_FILE",
|
||||||
|
"REA_GHIDRA_PROJECT_PATH",
|
||||||
|
"REA_GHIDRA_PROGRAM_NAME",
|
||||||
"REA_GHIDRA_NATIVEAOT_JAR",
|
"REA_GHIDRA_NATIVEAOT_JAR",
|
||||||
"REA_HOPPER_STARTUP_TIMEOUT_MS",
|
"REA_HOPPER_STARTUP_TIMEOUT_MS",
|
||||||
"REA_JEB_MCP_TIMEOUT_MS",
|
"REA_JEB_MCP_TIMEOUT_MS",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
ghidraProjectPathSchema,
|
||||||
|
ghidraProgramNameSchema,
|
||||||
|
} from "./ghidraExistingProject.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isAbsolute } from "node:path";
|
import { isAbsolute } from "node:path";
|
||||||
|
|
||||||
@@ -83,6 +87,8 @@ const environmentSchema = z.object({
|
|||||||
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
|
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
|
||||||
),
|
),
|
||||||
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: ghidraDecompileTimeoutSchema.optional(),
|
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: ghidraDecompileTimeoutSchema.optional(),
|
||||||
|
REA_GHIDRA_PROJECT_PATH: ghidraProjectPathSchema.optional(),
|
||||||
|
REA_GHIDRA_PROGRAM_NAME: ghidraProgramNameSchema.optional(),
|
||||||
REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(),
|
REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(),
|
||||||
REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
|
REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
|
||||||
REA_GHIDRA_DISABLED_ANALYZERS: ghidraDisabledAnalyzersSchema.optional(),
|
REA_GHIDRA_DISABLED_ANALYZERS: ghidraDisabledAnalyzersSchema.optional(),
|
||||||
@@ -202,6 +208,32 @@ export const parseEnvironment = (
|
|||||||
const parsed = parseEnvironmentValues(environment, unresolvedSettings);
|
const parsed = parseEnvironmentValues(environment, unresolvedSettings);
|
||||||
if (!parsed.ok) return parsed;
|
if (!parsed.ok) return parsed;
|
||||||
const env = parsed.value;
|
const env = parsed.value;
|
||||||
|
const project = env.REA_GHIDRA_PROJECT_PATH;
|
||||||
|
const incompatible =
|
||||||
|
project === undefined
|
||||||
|
? env.REA_GHIDRA_PROGRAM_NAME === undefined ||
|
||||||
|
unresolvedSettings.has("REA_GHIDRA_PROJECT_PATH")
|
||||||
|
? []
|
||||||
|
: ["REA_GHIDRA_PROGRAM_NAME"]
|
||||||
|
: [
|
||||||
|
"REA_GHIDRA_LANGUAGE_ID",
|
||||||
|
"REA_GHIDRA_COMPILER_SPEC_ID",
|
||||||
|
"REA_GHIDRA_SEED_FILE",
|
||||||
|
"REA_GHIDRA_DISABLED_ANALYZERS",
|
||||||
|
"REA_GHIDRA_NATIVEAOT_JAR",
|
||||||
|
].filter((setting) => environment[setting] !== undefined);
|
||||||
|
if (incompatible.length > 0)
|
||||||
|
return err(
|
||||||
|
new ConfigurationError("Invalid existing Ghidra project configuration", {
|
||||||
|
settings: incompatible.map((setting) => ({
|
||||||
|
setting,
|
||||||
|
constraint:
|
||||||
|
project === undefined
|
||||||
|
? "requires REA_GHIDRA_PROJECT_PATH"
|
||||||
|
: "Import and analysis overrides cannot be combined with an existing project",
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
// Ghidra's analyzeHeadless accepts -cspec only together with -processor.
|
// Ghidra's analyzeHeadless accepts -cspec only together with -processor.
|
||||||
if (
|
if (
|
||||||
env.REA_GHIDRA_COMPILER_SPEC_ID !== undefined &&
|
env.REA_GHIDRA_COMPILER_SPEC_ID !== undefined &&
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { isAbsolute, basename } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** A single expanded local project marker; archives must be restored first. */
|
||||||
|
export const ghidraProjectPathSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(isAbsolute, "Ghidra project path must be absolute")
|
||||||
|
.refine(
|
||||||
|
(path) => path.endsWith(".gpr") && basename(path).length > 4,
|
||||||
|
"Select an expanded .gpr project marker; restore .grez archives first",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Headless -process accepts globs and flags; this workflow selects one root program. */
|
||||||
|
export const ghidraProgramNameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(name) =>
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
!name.startsWith("-") &&
|
||||||
|
!/[\\/\0*?]/u.test(name),
|
||||||
|
"Select one exact program name in the project root, without paths, flags or wildcards",
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface GhidraProjectSelection {
|
||||||
|
readonly path: string;
|
||||||
|
readonly programName?: string;
|
||||||
|
}
|
||||||
@@ -116,6 +116,16 @@ export const parseConfig = (
|
|||||||
...(env.REA_IDA_MCP_CONFIG === undefined
|
...(env.REA_IDA_MCP_CONFIG === undefined
|
||||||
? {}
|
? {}
|
||||||
: { idaMcpConfigPath: env.REA_IDA_MCP_CONFIG }),
|
: { idaMcpConfigPath: env.REA_IDA_MCP_CONFIG }),
|
||||||
|
...(env.REA_GHIDRA_PROJECT_PATH === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
ghidraExistingProject: {
|
||||||
|
path: env.REA_GHIDRA_PROJECT_PATH,
|
||||||
|
...(env.REA_GHIDRA_PROGRAM_NAME === undefined
|
||||||
|
? {}
|
||||||
|
: { programName: env.REA_GHIDRA_PROGRAM_NAME }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
ghidraInstallDir: env.GHIDRA_INSTALL_DIR,
|
ghidraInstallDir: env.GHIDRA_INSTALL_DIR,
|
||||||
ghidraJavaHome: env.JAVA_HOME,
|
ghidraJavaHome: env.JAVA_HOME,
|
||||||
ghidraStartupTimeoutMs: env.REA_GHIDRA_STARTUP_TIMEOUT_MS,
|
ghidraStartupTimeoutMs: env.REA_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { GhidraProjectSelection } from "./ghidraExistingProject.js";
|
||||||
import type { McpToolFamilies } from "./mcpToolFamilies.js";
|
import type { McpToolFamilies } from "./mcpToolFamilies.js";
|
||||||
import type { LogLevel } from "./processStart.js";
|
import type { LogLevel } from "./processStart.js";
|
||||||
import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js";
|
import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js";
|
||||||
@@ -25,6 +26,7 @@ export interface AppConfig {
|
|||||||
readonly ghidraLanguageOverride?: GhidraLanguageOverride;
|
readonly ghidraLanguageOverride?: GhidraLanguageOverride;
|
||||||
readonly ghidraDisabledAnalyzers?: readonly string[];
|
readonly ghidraDisabledAnalyzers?: readonly string[];
|
||||||
readonly ghidraSeedFile?: string;
|
readonly ghidraSeedFile?: string;
|
||||||
|
readonly ghidraExistingProject?: GhidraProjectSelection;
|
||||||
readonly ghidraNativeAotJar?: string;
|
readonly ghidraNativeAotJar?: string;
|
||||||
readonly ilspyCmdPath: string | undefined;
|
readonly ilspyCmdPath: string | undefined;
|
||||||
readonly hopperLauncherPath: string;
|
readonly hopperLauncherPath: string;
|
||||||
|
|||||||
@@ -458,3 +458,22 @@ const createGhidraProfile = (
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A caller database supplies analysis state; no import or completion claims are inferred. */
|
||||||
|
export const existingGhidraAnalysisProfile = (
|
||||||
|
target: BinaryTarget,
|
||||||
|
provider: Parameters<typeof createAnalysisProfile>[0],
|
||||||
|
project: JsonValue,
|
||||||
|
) =>
|
||||||
|
createAnalysisProfile(provider, {
|
||||||
|
target_kind: target.kind,
|
||||||
|
target_format: target.format,
|
||||||
|
architecture: target.architecture ?? null,
|
||||||
|
import_mode: "existing-project-private-copy-v1",
|
||||||
|
...sharedAnalysisSemantics,
|
||||||
|
annotation_policy: "read-only-existing-project-v1",
|
||||||
|
existing_project: project,
|
||||||
|
original_analysis: { completeness: "unknown", settings: "unknown" },
|
||||||
|
no_return_repair: "disabled-existing-project",
|
||||||
|
process_launch: "inspected-jvm-launch-support-v1",
|
||||||
|
});
|
||||||
@@ -1091,7 +1091,9 @@ export class GhidraClient {
|
|||||||
const parsed = parseGhidraSessionInfo(value, {
|
const parsed = parseGhidraSessionInfo(value, {
|
||||||
runId,
|
runId,
|
||||||
expectedReadOnly:
|
expectedReadOnly:
|
||||||
|
this.#options.existingProject === true ||
|
||||||
this.#options.transport === "authenticated-loopback-tcp",
|
this.#options.transport === "authenticated-loopback-tcp",
|
||||||
|
expectedExistingProject: this.#options.existingProject === true,
|
||||||
providerVersion: this.#options.providerVersion,
|
providerVersion: this.#options.providerVersion,
|
||||||
profileDigest: this.#options.profileDigest,
|
profileDigest: this.#options.profileDigest,
|
||||||
targetSha256: this.#options.targetSha256,
|
targetSha256: this.#options.targetSha256,
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ export type GhidraDiagnostic =
|
|||||||
}
|
}
|
||||||
| { readonly type: "cleanup-incomplete"; readonly reason: string };
|
| { readonly type: "cleanup-incomplete"; readonly reason: string };
|
||||||
|
|
||||||
/** Dependencies and exact profile commitment for one headless import. */
|
/** Dependencies and exact profile commitment for one headless Program session. */
|
||||||
export interface GhidraClientOptions {
|
export interface GhidraClientOptions {
|
||||||
readonly launcher: GhidraLauncher;
|
readonly launcher: GhidraLauncher;
|
||||||
readonly runId?: string;
|
readonly runId?: string;
|
||||||
readonly targetPath: string;
|
readonly targetPath: string;
|
||||||
readonly targetSha256: string;
|
readonly targetSha256: string;
|
||||||
|
readonly existingProject?: boolean;
|
||||||
/** Installation host shared by runtime allocation and snapshot admission. */
|
/** Installation host shared by runtime allocation and snapshot admission. */
|
||||||
readonly platform?: NodeJS.Platform;
|
readonly platform?: NodeJS.Platform;
|
||||||
readonly transport?: GhidraTransportKind;
|
readonly transport?: GhidraTransportKind;
|
||||||
|
|||||||
+176
-65
@@ -1,3 +1,4 @@
|
|||||||
|
import { copyExistingGhidraProject } from "./GhidraProjectSnapshot.js";
|
||||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||||
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
|
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
|
||||||
import {
|
import {
|
||||||
@@ -6,7 +7,7 @@ import {
|
|||||||
} from "./GhidraAnalysisSeeds.js";
|
} from "./GhidraAnalysisSeeds.js";
|
||||||
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
|
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
|
||||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||||
import { mkdir, realpath } from "node:fs/promises";
|
import { mkdir, readFile, realpath } from "node:fs/promises";
|
||||||
import { basename, dirname, join, posix, win32 } from "node:path";
|
import { basename, dirname, join, posix, win32 } from "node:path";
|
||||||
|
|
||||||
import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js";
|
import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js";
|
||||||
@@ -57,7 +58,6 @@ import {
|
|||||||
type GhidraPspExtension,
|
type GhidraPspExtension,
|
||||||
} from "./GhidraPspExtension.js";
|
} from "./GhidraPspExtension.js";
|
||||||
|
|
||||||
/** Private paths and identity material for one headless Ghidra import. */
|
|
||||||
export interface GhidraLaunchSession {
|
export interface GhidraLaunchSession {
|
||||||
readonly runtimeRoot: string;
|
readonly runtimeRoot: string;
|
||||||
readonly transport: GhidraTransportKind;
|
readonly transport: GhidraTransportKind;
|
||||||
@@ -203,6 +203,16 @@ const ghidraLaunchCommandOutput = (
|
|||||||
return execFileFailureCapturedOutput(failure);
|
return execFileFailureCapturedOutput(failure);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** An expanded caller project opened only through an owned private copy. */
|
||||||
|
export interface GhidraExistingProject {
|
||||||
|
/** Directory holding the selected .gpr marker and .rep data directory. */
|
||||||
|
readonly projectPath: string;
|
||||||
|
readonly projectName?: string;
|
||||||
|
readonly sha256?: string;
|
||||||
|
/** Imported program name selected with -process. */
|
||||||
|
readonly programName: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Static coordinates for an extracted Ghidra release and packaged script. */
|
/** Static coordinates for an extracted Ghidra release and packaged script. */
|
||||||
export interface GhidraHeadlessLauncherOptions {
|
export interface GhidraHeadlessLauncherOptions {
|
||||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||||
@@ -213,6 +223,8 @@ export interface GhidraHeadlessLauncherOptions {
|
|||||||
readonly javaVmName?: string;
|
readonly javaVmName?: string;
|
||||||
readonly bridgeScriptPath: string;
|
readonly bridgeScriptPath: string;
|
||||||
readonly platform?: NodeJS.Platform;
|
readonly platform?: NodeJS.Platform;
|
||||||
|
/** Open a pre-imported project copy instead of importing the target again. */
|
||||||
|
readonly existingProject?: GhidraExistingProject;
|
||||||
readonly comSpec?: string;
|
readonly comSpec?: string;
|
||||||
/** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */
|
/** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */
|
||||||
readonly dosMz?: true;
|
readonly dosMz?: true;
|
||||||
@@ -271,6 +283,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
);
|
);
|
||||||
const paths = ghidraRuntimePaths(session.runtimeRoot);
|
const paths = ghidraRuntimePaths(session.runtimeRoot);
|
||||||
const platform = this.options.platform ?? process.platform;
|
const platform = this.options.platform ?? process.platform;
|
||||||
|
const existingProject = this.options.existingProject;
|
||||||
let started: SpawnedOwnedProviderProcess | undefined;
|
let started: SpawnedOwnedProviderProcess | undefined;
|
||||||
let configuredMaxHeap: string | undefined;
|
let configuredMaxHeap: string | undefined;
|
||||||
const launchSupport: GhidraLaunchSupportDiagnostic[] = [];
|
const launchSupport: GhidraLaunchSupportDiagnostic[] = [];
|
||||||
@@ -280,6 +293,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
"Ghidra configuration directory identity is unresolved; restore application.version and application.release.name in the selected installation's application.properties.",
|
"Ghidra configuration directory identity is unresolved; restore application.version and application.release.name in the selected installation's application.properties.",
|
||||||
);
|
);
|
||||||
const psp = this.options.pspExtension;
|
const psp = this.options.pspExtension;
|
||||||
|
validateExistingProjectOptions({
|
||||||
|
...this.options,
|
||||||
|
platform,
|
||||||
|
...(psp === undefined ? {} : { psp: true as const }),
|
||||||
|
});
|
||||||
if (psp !== undefined) {
|
if (psp !== undefined) {
|
||||||
const installedRoot = await realpath(
|
const installedRoot = await realpath(
|
||||||
join(
|
join(
|
||||||
@@ -314,6 +332,19 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
platform,
|
platform,
|
||||||
this.options.configurationDirectoryName,
|
this.options.configurationDirectoryName,
|
||||||
);
|
);
|
||||||
|
// Copy-first: open the caller's project only through the owned copy so
|
||||||
|
// Ghidra never touches the original project files.
|
||||||
|
if (existingProject !== undefined)
|
||||||
|
await copyExistingGhidraProject(
|
||||||
|
existingProject.projectPath,
|
||||||
|
paths.projectRoot,
|
||||||
|
{
|
||||||
|
signal: options.signal,
|
||||||
|
projectName: existingProject.projectName,
|
||||||
|
expectedSha256: existingProject.sha256,
|
||||||
|
resources: options.resourceScope,
|
||||||
|
},
|
||||||
|
);
|
||||||
let seedPath: string | undefined;
|
let seedPath: string | undefined;
|
||||||
let gamecubeDolSectionPath: string | undefined;
|
let gamecubeDolSectionPath: string | undefined;
|
||||||
let extensions: readonly GhidraExtension[] = [];
|
let extensions: readonly GhidraExtension[] = [];
|
||||||
@@ -349,6 +380,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
target_sha256: session.targetSha256,
|
target_sha256: session.targetSha256,
|
||||||
provider_version: session.providerVersion,
|
provider_version: session.providerVersion,
|
||||||
profile_digest: session.profileDigest,
|
profile_digest: session.profileDigest,
|
||||||
|
...(existingProject === undefined ? {} : { existing_project: true }),
|
||||||
...(this.options.decompileTimeoutSeconds === undefined
|
...(this.options.decompileTimeoutSeconds === undefined
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
@@ -385,6 +417,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
: { mipsSoftFloat: this.options.mipsSoftFloat }),
|
: { mipsSoftFloat: this.options.mipsSoftFloat }),
|
||||||
projectRoot: paths.projectRoot,
|
projectRoot: paths.projectRoot,
|
||||||
targetPath: session.targetPath,
|
targetPath: session.targetPath,
|
||||||
|
...(existingProject === undefined ? {} : { existingProject }),
|
||||||
bridgeScriptPath: this.options.bridgeScriptPath,
|
bridgeScriptPath: this.options.bridgeScriptPath,
|
||||||
descriptorPath: paths.descriptorPath,
|
descriptorPath: paths.descriptorPath,
|
||||||
ghidraLogPath: paths.ghidraLogPath,
|
ghidraLogPath: paths.ghidraLogPath,
|
||||||
@@ -720,6 +753,11 @@ export interface GhidraHeadlessArgumentOptions {
|
|||||||
readonly platform?: NodeJS.Platform;
|
readonly platform?: NodeJS.Platform;
|
||||||
readonly projectRoot: string;
|
readonly projectRoot: string;
|
||||||
readonly targetPath: string;
|
readonly targetPath: string;
|
||||||
|
/** Open this pre-imported program with -process instead of importing. */
|
||||||
|
readonly existingProject?: Pick<
|
||||||
|
GhidraExistingProject,
|
||||||
|
"programName" | "projectName"
|
||||||
|
>;
|
||||||
readonly bridgeScriptPath: string;
|
readonly bridgeScriptPath: string;
|
||||||
readonly descriptorPath: string;
|
readonly descriptorPath: string;
|
||||||
readonly ghidraLogPath: string;
|
readonly ghidraLogPath: string;
|
||||||
@@ -745,6 +783,69 @@ export interface GhidraHeadlessArgumentOptions {
|
|||||||
readonly seedPath?: string;
|
readonly seedPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExistingProjectOptions = Pick<
|
||||||
|
GhidraHeadlessArgumentOptions,
|
||||||
|
| "platform"
|
||||||
|
| "existingProject"
|
||||||
|
| "psp"
|
||||||
|
| "dosMz"
|
||||||
|
| "dosCom"
|
||||||
|
| "rawBinary"
|
||||||
|
| "languageOverride"
|
||||||
|
| "mipsSoftFloat"
|
||||||
|
| "seedPath"
|
||||||
|
| "disabledAnalyzers"
|
||||||
|
> & { readonly analysisSeeds?: GhidraSeedCommitment };
|
||||||
|
|
||||||
|
/** Admit existing-project compatibility before any runtime writes. */
|
||||||
|
const validateExistingProjectOptions = (
|
||||||
|
options: ExistingProjectOptions,
|
||||||
|
): void => {
|
||||||
|
const existing = options.existingProject;
|
||||||
|
const rawBinary = options.rawBinary;
|
||||||
|
const override = options.languageOverride;
|
||||||
|
if (existing !== undefined) {
|
||||||
|
// The pre-imported project carries its own analysis; every import-time
|
||||||
|
// selection is therefore rejected instead of silently ignored.
|
||||||
|
if ((options.platform ?? process.platform) === "win32")
|
||||||
|
throw new Error(
|
||||||
|
"An existing Ghidra project cannot be opened on the Windows launch path.",
|
||||||
|
);
|
||||||
|
if (existing.programName.trim() === "")
|
||||||
|
throw new Error(
|
||||||
|
"An existing Ghidra project requires a program name for -process.",
|
||||||
|
);
|
||||||
|
if (options.psp === true)
|
||||||
|
throw new Error(
|
||||||
|
"An existing Ghidra project cannot be combined with a PSP ELF import.",
|
||||||
|
);
|
||||||
|
if (options.dosMz === true || options.dosCom === true)
|
||||||
|
throw new Error(
|
||||||
|
"An existing Ghidra project cannot be combined with a DOS import.",
|
||||||
|
);
|
||||||
|
if (rawBinary !== undefined)
|
||||||
|
throw new Error(
|
||||||
|
"An existing Ghidra project cannot be combined with a raw-binary import.",
|
||||||
|
);
|
||||||
|
if (override !== undefined)
|
||||||
|
throw new Error(
|
||||||
|
"A Ghidra language override cannot be combined with an existing project.",
|
||||||
|
);
|
||||||
|
if (options.mipsSoftFloat === true)
|
||||||
|
throw new Error(
|
||||||
|
"MIPS soft-float storage applies only to a fresh import.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
options.seedPath !== undefined ||
|
||||||
|
options.analysisSeeds !== undefined ||
|
||||||
|
(options.disabledAnalyzers?.length ?? 0) > 0
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"An existing project opens without analysis; analysis seeds and analyzer overrides do not apply.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Build the complete read-only headless invocation in deterministic order. */
|
/** Build the complete read-only headless invocation in deterministic order. */
|
||||||
export const ghidraHeadlessArguments = (
|
export const ghidraHeadlessArguments = (
|
||||||
options: GhidraHeadlessArgumentOptions,
|
options: GhidraHeadlessArgumentOptions,
|
||||||
@@ -756,7 +857,9 @@ export const ghidraHeadlessArguments = (
|
|||||||
const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath);
|
const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath);
|
||||||
const override = options.languageOverride;
|
const override = options.languageOverride;
|
||||||
const rawBinary = options.rawBinary;
|
const rawBinary = options.rawBinary;
|
||||||
|
const existing = options.existingProject;
|
||||||
const gamecubeDol = options.gamecubeDol;
|
const gamecubeDol = options.gamecubeDol;
|
||||||
|
validateExistingProjectOptions(options);
|
||||||
if (
|
if (
|
||||||
gamecubeDol !== undefined &&
|
gamecubeDol !== undefined &&
|
||||||
(options.psp === true ||
|
(options.psp === true ||
|
||||||
@@ -812,73 +915,80 @@ export const ghidraHeadlessArguments = (
|
|||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
options.projectRoot,
|
options.projectRoot,
|
||||||
"rea-project",
|
existing?.projectName ?? "rea-project",
|
||||||
"-import",
|
...(existing === undefined
|
||||||
options.targetPath,
|
|
||||||
...(gamecubeDol !== undefined
|
|
||||||
? [
|
? [
|
||||||
// One BinaryLoader block at file offset 0; the pre-script rebuilds the DOL section map.
|
"-import",
|
||||||
"-loader",
|
options.targetPath,
|
||||||
"BinaryLoader",
|
...(gamecubeDol !== undefined
|
||||||
"-loader-baseAddr",
|
? [
|
||||||
"0x0",
|
// One BinaryLoader block at file offset 0; the pre-script rebuilds the DOL section map.
|
||||||
"-processor",
|
|
||||||
gamecubeDol.languageId,
|
|
||||||
...(gamecubeDol.compilerSpecId === undefined
|
|
||||||
? []
|
|
||||||
: ["-cspec", gamecubeDol.compilerSpecId]),
|
|
||||||
]
|
|
||||||
: options.dosMz === true
|
|
||||||
? [
|
|
||||||
"-loader",
|
|
||||||
"MzLoader",
|
|
||||||
"-processor",
|
|
||||||
"x86:LE:16:Real Mode",
|
|
||||||
"-cspec",
|
|
||||||
"default",
|
|
||||||
]
|
|
||||||
: options.dosCom === true
|
|
||||||
? [
|
|
||||||
"-loader",
|
|
||||||
"BinaryLoader",
|
|
||||||
"-loader-baseAddr",
|
|
||||||
"1000:0100",
|
|
||||||
"-processor",
|
|
||||||
"x86:LE:16:Real Mode",
|
|
||||||
"-cspec",
|
|
||||||
"default",
|
|
||||||
]
|
|
||||||
: rawBinary === undefined
|
|
||||||
? options.psp === true
|
|
||||||
? [
|
|
||||||
"-loader",
|
|
||||||
"PspElfLoader",
|
|
||||||
"-processor",
|
|
||||||
"Allegrex:LE:32:default",
|
|
||||||
"-cspec",
|
|
||||||
"default",
|
|
||||||
]
|
|
||||||
: override !== undefined
|
|
||||||
? [
|
|
||||||
"-processor",
|
|
||||||
override.languageId,
|
|
||||||
...(override.compilerSpecId === undefined
|
|
||||||
? []
|
|
||||||
: ["-cspec", override.compilerSpecId]),
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
: [
|
|
||||||
"-loader",
|
"-loader",
|
||||||
"BinaryLoader",
|
"BinaryLoader",
|
||||||
"-loader-baseAddr",
|
"-loader-baseAddr",
|
||||||
rawBinary.baseAddr,
|
"0x0",
|
||||||
"-processor",
|
"-processor",
|
||||||
rawBinary.languageId,
|
gamecubeDol.languageId,
|
||||||
"-cspec",
|
...(gamecubeDol.compilerSpecId === undefined
|
||||||
rawBinary.compilerSpecId,
|
? []
|
||||||
]),
|
: ["-cspec", gamecubeDol.compilerSpecId]),
|
||||||
"-readOnly",
|
]
|
||||||
"-deleteProject",
|
: options.dosMz === true
|
||||||
|
? [
|
||||||
|
"-loader",
|
||||||
|
"MzLoader",
|
||||||
|
"-processor",
|
||||||
|
"x86:LE:16:Real Mode",
|
||||||
|
"-cspec",
|
||||||
|
"default",
|
||||||
|
]
|
||||||
|
: options.dosCom === true
|
||||||
|
? [
|
||||||
|
"-loader",
|
||||||
|
"BinaryLoader",
|
||||||
|
"-loader-baseAddr",
|
||||||
|
"1000:0100",
|
||||||
|
"-processor",
|
||||||
|
"x86:LE:16:Real Mode",
|
||||||
|
"-cspec",
|
||||||
|
"default",
|
||||||
|
]
|
||||||
|
: rawBinary === undefined
|
||||||
|
? options.psp === true
|
||||||
|
? [
|
||||||
|
"-loader",
|
||||||
|
"PspElfLoader",
|
||||||
|
"-processor",
|
||||||
|
"Allegrex:LE:32:default",
|
||||||
|
"-cspec",
|
||||||
|
"default",
|
||||||
|
]
|
||||||
|
: override !== undefined
|
||||||
|
? [
|
||||||
|
"-processor",
|
||||||
|
override.languageId,
|
||||||
|
...(override.compilerSpecId === undefined
|
||||||
|
? []
|
||||||
|
: ["-cspec", override.compilerSpecId]),
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
: [
|
||||||
|
"-loader",
|
||||||
|
"BinaryLoader",
|
||||||
|
"-loader-baseAddr",
|
||||||
|
rawBinary.baseAddr,
|
||||||
|
"-processor",
|
||||||
|
rawBinary.languageId,
|
||||||
|
"-cspec",
|
||||||
|
rawBinary.compilerSpecId,
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: ["-process", existing.programName]),
|
||||||
|
// An existing project opens with -noanalysis: its auto-analysis was paid
|
||||||
|
// when the project was imported, outside this session's startup deadline.
|
||||||
|
...(existing === undefined
|
||||||
|
? ["-readOnly", "-deleteProject"]
|
||||||
|
: ["-noanalysis", "-readOnly"]),
|
||||||
"-log",
|
"-log",
|
||||||
options.ghidraLogPath,
|
options.ghidraLogPath,
|
||||||
"-scriptlog",
|
"-scriptlog",
|
||||||
@@ -927,7 +1037,8 @@ export const ghidraHeadlessArguments = (
|
|||||||
),
|
),
|
||||||
options.disabledAnalyzers.join(","),
|
options.disabledAnalyzers.join(","),
|
||||||
]),
|
]),
|
||||||
...((options.platform ?? process.platform) === "win32"
|
...(existing !== undefined ||
|
||||||
|
(options.platform ?? process.platform) === "win32"
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
"-postScript",
|
"-postScript",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
|
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||||
|
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||||
|
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
|
||||||
|
|
||||||
|
/** Preserve saved Program reports once as historical facts, not fresh overrides. */
|
||||||
|
export const ghidraProjectHistoryEvidence = (
|
||||||
|
target: BinaryTarget,
|
||||||
|
profile: AnalysisProfileCommitment,
|
||||||
|
info: GhidraSessionInfo,
|
||||||
|
): Evidence | undefined => {
|
||||||
|
const reports = {
|
||||||
|
...(info.analysis_seeds === undefined
|
||||||
|
? {}
|
||||||
|
: { analysis_seeds: info.analysis_seeds }),
|
||||||
|
...(info.disabled_analyzers === undefined
|
||||||
|
? {}
|
||||||
|
: { disabled_analyzers: info.disabled_analyzers }),
|
||||||
|
...(info.mips_soft_float_storage === undefined
|
||||||
|
? {}
|
||||||
|
: { mips_soft_float_storage: info.mips_soft_float_storage }),
|
||||||
|
};
|
||||||
|
if (Object.keys(reports).length === 0) return undefined;
|
||||||
|
return createEvidence(
|
||||||
|
target,
|
||||||
|
profile.provider,
|
||||||
|
{
|
||||||
|
operation: "health",
|
||||||
|
parameters: { facet: "historical-project-analysis-reports" },
|
||||||
|
analysisProfile: profile,
|
||||||
|
result: { origin: "caller-project", ...reports },
|
||||||
|
confidence: "observed",
|
||||||
|
limitations: [
|
||||||
|
"These reports were already stored in the caller's project. This session did not apply them; original analysis completeness and the remaining settings are unknown.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ immutable: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import { ghidraFileFailure } from "./GhidraFileArtifacts.js";
|
||||||
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { cp, lstat, mkdir, readdir, realpath } from "node:fs/promises";
|
||||||
|
import {
|
||||||
|
basename,
|
||||||
|
dirname,
|
||||||
|
isAbsolute,
|
||||||
|
join,
|
||||||
|
relative,
|
||||||
|
resolve,
|
||||||
|
sep,
|
||||||
|
} from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||||
|
import {
|
||||||
|
assertArtifactReadActive,
|
||||||
|
withArtifactRegularFile,
|
||||||
|
} from "../artifacts/ArtifactFileOperation.js";
|
||||||
|
import {
|
||||||
|
sameRegularFileState,
|
||||||
|
RegularFileChangedError,
|
||||||
|
} from "../filesystem/RegularFile.js";
|
||||||
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||||
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||||
|
import type { GhidraProjectSelection } from "../config/ghidraExistingProject.js";
|
||||||
|
import { ghidraProgramNameSchema } from "../config/ghidraExistingProject.js";
|
||||||
|
|
||||||
|
/** Classify file cancellation/cleanup before the generic provider boundary. */
|
||||||
|
const projectFileOperation = async <Value>(
|
||||||
|
path: string,
|
||||||
|
operation: string,
|
||||||
|
work: () => Promise<Value>,
|
||||||
|
): Promise<Value> => {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof AnalysisError) throw cause;
|
||||||
|
throw ghidraFileFailure(cause, operation, { path });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The selected project content, committed before provider or snapshot admission. */
|
||||||
|
export const ghidraProjectCommitmentSchema = z.strictObject({
|
||||||
|
configured_path: z.string().refine(isAbsolute),
|
||||||
|
project_path: z.string().refine(isAbsolute),
|
||||||
|
project_name: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine((name) => basename(name) === name),
|
||||||
|
program_name: ghidraProgramNameSchema,
|
||||||
|
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||||
|
});
|
||||||
|
export type GhidraProjectCommitment = z.infer<
|
||||||
|
typeof ghidraProjectCommitmentSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
const selectionFailure = (
|
||||||
|
message: string,
|
||||||
|
setting = "REA_GHIDRA_PROJECT_PATH",
|
||||||
|
): ConfigurationError =>
|
||||||
|
new ConfigurationError(message, {
|
||||||
|
settings: [{ setting, constraint: message }],
|
||||||
|
});
|
||||||
|
const entriesFor = (name: string) => [`${name}.gpr`, `${name}.rep`];
|
||||||
|
|
||||||
|
const validateProject = async (root: string, name: string): Promise<void> => {
|
||||||
|
if (!(await lstat(root)).isDirectory())
|
||||||
|
throw selectionFailure(
|
||||||
|
`Existing Ghidra project directory does not exist: ${root}`,
|
||||||
|
);
|
||||||
|
const [marker, data] = await Promise.all(
|
||||||
|
entriesFor(name).map((entry) =>
|
||||||
|
lstat(join(root, entry)).catch(() => undefined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!marker?.isFile() || !data?.isDirectory())
|
||||||
|
throw selectionFailure(
|
||||||
|
`Existing Ghidra project requires a regular ${name}.gpr marker and ${name}.rep directory; restore saved .grez archives before selecting the project: ${root}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One sorted traversal owns the exact selected pair; unrelated sibling projects are ignored. */
|
||||||
|
const visitProject = async (
|
||||||
|
root: string,
|
||||||
|
name: string,
|
||||||
|
visit: (path: string, directory: boolean) => Promise<void>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> => {
|
||||||
|
const walk = async (path: string): Promise<void> => {
|
||||||
|
assertArtifactReadActive(signal);
|
||||||
|
const metadata = await lstat(join(root, path));
|
||||||
|
if (!metadata.isFile() && !metadata.isDirectory())
|
||||||
|
throw selectionFailure(
|
||||||
|
`Existing Ghidra project contains a symbolic link or non-regular entry: ${join(root, path)}`,
|
||||||
|
);
|
||||||
|
await visit(path, metadata.isDirectory());
|
||||||
|
if (metadata.isDirectory())
|
||||||
|
for (const child of (await readdir(join(root, path))).sort(
|
||||||
|
compareUnicodeCodePoints,
|
||||||
|
))
|
||||||
|
await walk(join(path, child));
|
||||||
|
};
|
||||||
|
for (const entry of entriesFor(name)) await walk(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Streaming SHA-256 bounds content buffers to 64 KiB and retains failed descriptor cleanup. */
|
||||||
|
export const fingerprintGhidraProject = async (
|
||||||
|
root: string,
|
||||||
|
name: string,
|
||||||
|
resources: ArtifactResourceScope,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<string> => {
|
||||||
|
assertArtifactReadActive(signal);
|
||||||
|
await validateProject(root, name);
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||||
|
await visitProject(
|
||||||
|
root,
|
||||||
|
name,
|
||||||
|
async (path, directory) => {
|
||||||
|
hash.update(
|
||||||
|
JSON.stringify([
|
||||||
|
path.split(sep).join("/"),
|
||||||
|
directory ? "directory" : "file",
|
||||||
|
]) + "\n",
|
||||||
|
);
|
||||||
|
if (directory) return;
|
||||||
|
const file = join(root, path);
|
||||||
|
await withArtifactRegularFile(
|
||||||
|
file,
|
||||||
|
resources,
|
||||||
|
signal,
|
||||||
|
async ({ handle, initial }) => {
|
||||||
|
hash.update(String(initial.size) + "\n");
|
||||||
|
for (let offset = 0; offset < initial.size;) {
|
||||||
|
assertArtifactReadActive(signal);
|
||||||
|
const { bytesRead } = await handle.read(
|
||||||
|
buffer,
|
||||||
|
0,
|
||||||
|
Math.min(buffer.length, initial.size - offset),
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (bytesRead === 0) throw new RegularFileChangedError(file);
|
||||||
|
hash.update(buffer.subarray(0, bytesRead));
|
||||||
|
offset += bytesRead;
|
||||||
|
}
|
||||||
|
if (!sameRegularFileState(initial, await handle.stat()))
|
||||||
|
throw new RegularFileChangedError(file);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return hash.digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveGhidraProject = async (
|
||||||
|
selection: GhidraProjectSelection,
|
||||||
|
target: Pick<BinaryTarget, "path">,
|
||||||
|
resources: ArtifactResourceScope,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<GhidraProjectCommitment> => {
|
||||||
|
return projectFileOperation(
|
||||||
|
selection.path,
|
||||||
|
"resolve_analysis_profile",
|
||||||
|
async () => {
|
||||||
|
assertArtifactReadActive(signal);
|
||||||
|
const projectName = basename(selection.path, ".gpr");
|
||||||
|
const projectPath = await realpath(dirname(selection.path));
|
||||||
|
const program = ghidraProgramNameSchema.safeParse(
|
||||||
|
selection.programName ?? basename(target.path),
|
||||||
|
);
|
||||||
|
if (!program.success)
|
||||||
|
throw selectionFailure(
|
||||||
|
program.error.message,
|
||||||
|
"REA_GHIDRA_PROGRAM_NAME",
|
||||||
|
);
|
||||||
|
const programName = program.data;
|
||||||
|
return {
|
||||||
|
configured_path: selection.path,
|
||||||
|
project_path: projectPath,
|
||||||
|
project_name: projectName,
|
||||||
|
program_name: programName,
|
||||||
|
sha256: await fingerprintGhidraProject(
|
||||||
|
projectPath,
|
||||||
|
projectName,
|
||||||
|
resources,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathsOverlap = (first: string, second: string): boolean => {
|
||||||
|
const contains = (ancestor: string, descendant: string): boolean => {
|
||||||
|
const rel = relative(ancestor, descendant);
|
||||||
|
return (
|
||||||
|
rel === "" ||
|
||||||
|
(!isAbsolute(rel) &&
|
||||||
|
rel !== ".." &&
|
||||||
|
!rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return contains(first, second) || contains(second, first);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Never open the caller project itself. Copy only the selected pair, then revalidate its committed content. */
|
||||||
|
export const copyExistingGhidraProject = async (
|
||||||
|
sourcePath: string,
|
||||||
|
destinationRoot: string,
|
||||||
|
options: {
|
||||||
|
readonly signal?: AbortSignal | undefined;
|
||||||
|
readonly projectName?: string | undefined;
|
||||||
|
readonly expectedSha256?: string | undefined;
|
||||||
|
readonly resources?: ArtifactResourceScope | undefined;
|
||||||
|
} = {},
|
||||||
|
): Promise<void> => {
|
||||||
|
return projectFileOperation(sourcePath, "open_binary", async () => {
|
||||||
|
const {
|
||||||
|
signal,
|
||||||
|
projectName = "rea-project",
|
||||||
|
expectedSha256,
|
||||||
|
resources,
|
||||||
|
} = options;
|
||||||
|
assertArtifactReadActive(signal);
|
||||||
|
const source = await realpath(sourcePath).catch(() => undefined);
|
||||||
|
const runtime = await realpath(dirname(destinationRoot)).catch(() =>
|
||||||
|
resolve(dirname(destinationRoot)),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
source !== undefined &&
|
||||||
|
entriesFor(projectName).some((entry) =>
|
||||||
|
pathsOverlap(join(source, entry), runtime),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw selectionFailure(
|
||||||
|
`Existing Ghidra project directory overlaps the REA runtime directory; select a project outside the runtime: ${sourcePath}`,
|
||||||
|
);
|
||||||
|
if (source === undefined)
|
||||||
|
throw selectionFailure(
|
||||||
|
`Existing Ghidra project directory does not exist: ${sourcePath}`,
|
||||||
|
);
|
||||||
|
await validateProject(source, projectName);
|
||||||
|
await visitProject(
|
||||||
|
source,
|
||||||
|
projectName,
|
||||||
|
async (path, directory) => {
|
||||||
|
const destination = join(destinationRoot, path);
|
||||||
|
if (directory) await mkdir(destination, { mode: 0o700 });
|
||||||
|
else
|
||||||
|
await cp(join(source, path), destination, {
|
||||||
|
dereference: true,
|
||||||
|
errorOnExist: true,
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
assertArtifactReadActive(signal);
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (expectedSha256 !== undefined) {
|
||||||
|
if (resources === undefined)
|
||||||
|
throw selectionFailure(
|
||||||
|
"Existing project verification requires an owned resource scope.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(await fingerprintGhidraProject(
|
||||||
|
destinationRoot,
|
||||||
|
projectName,
|
||||||
|
resources,
|
||||||
|
signal,
|
||||||
|
)) !== expectedSha256
|
||||||
|
)
|
||||||
|
throw new RegularFileChangedError(sourcePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EXISTING_PROJECT_WINDOWS_UNAVAILABLE } from "./GhidraProviderCapabilities.js";
|
||||||
import { ProcessOutputTokenScanner } from "../process/ProcessOutputTokenScanner.js";
|
import { ProcessOutputTokenScanner } from "../process/ProcessOutputTokenScanner.js";
|
||||||
import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js";
|
import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js";
|
||||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||||
@@ -42,7 +43,10 @@ import type { GhidraTargetSnapshot } from "./GhidraClient.js";
|
|||||||
import type { GhidraProviderClientFactory } from "./GhidraProviderClient.js";
|
import type { GhidraProviderClientFactory } from "./GhidraProviderClient.js";
|
||||||
import type { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
|
import type { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
|
||||||
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
|
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
|
||||||
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
|
import {
|
||||||
|
GHIDRA_MUTATING_OPERATIONS,
|
||||||
|
GHIDRA_SESSION_CAPABILITIES,
|
||||||
|
} from "./GhidraSessionValues.js";
|
||||||
import type { Result } from "../domain/result.js";
|
import type { Result } from "../domain/result.js";
|
||||||
import { GhidraSessionError } from "./GhidraSessionError.js";
|
import { GhidraSessionError } from "./GhidraSessionError.js";
|
||||||
import { silentLogger } from "../logger.js";
|
import { silentLogger } from "../logger.js";
|
||||||
@@ -4583,3 +4587,107 @@ it("session-observation failure clears only its resolved cleanup dependency afte
|
|||||||
});
|
});
|
||||||
await ghidra.closeOwnedResources();
|
await ghidra.closeOwnedResources();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports every Windows existing-project capability unavailable before open", async () => {
|
||||||
|
const ghidra = provider(installationHost("win32"), undefined, {
|
||||||
|
REA_GHIDRA_PROJECT_PATH: join(process.cwd(), "selected.gpr"),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
ghidra
|
||||||
|
.capabilities()
|
||||||
|
.every(
|
||||||
|
(capability) =>
|
||||||
|
!capability.available &&
|
||||||
|
capability.availabilityCode === "unsupported_host",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
const readiness = await ghidra.inspectReadinessSnapshot();
|
||||||
|
expect(readiness.availability).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
code: "unsupported_host",
|
||||||
|
reason: EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
readiness.capabilities.every(
|
||||||
|
(capability) =>
|
||||||
|
!capability.available &&
|
||||||
|
capability.availabilityCode === "unsupported_host",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await ghidra.closeOwnedResources();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps saved project reports as shared historical Evidence without applying fresh overrides", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "rea-history-project-"));
|
||||||
|
await mkdir(join(root, "history.rep"));
|
||||||
|
await writeFile(join(root, "history.gpr"), "marker");
|
||||||
|
const prior = {
|
||||||
|
analysis_seeds: {
|
||||||
|
format: "rea-ghidra-seeds-v1" as const,
|
||||||
|
sha256: "b".repeat(64),
|
||||||
|
entries: 0,
|
||||||
|
function_created: 0,
|
||||||
|
function_existing: 0,
|
||||||
|
function_failed: 0,
|
||||||
|
code_decoded: 0,
|
||||||
|
code_failed: 0,
|
||||||
|
label_applied: 0,
|
||||||
|
label_failed: 0,
|
||||||
|
unmapped: 0,
|
||||||
|
},
|
||||||
|
disabled_analyzers: {
|
||||||
|
format: "rea-ghidra-disabled-analyzers-v1" as const,
|
||||||
|
analyzers: [
|
||||||
|
{ name: "Decompiler Parameter ID", status: "disabled" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const close = vi.fn(async () => ok(null));
|
||||||
|
const ghidra = provider(
|
||||||
|
installationHost(),
|
||||||
|
() => ({
|
||||||
|
start: async () =>
|
||||||
|
ok({
|
||||||
|
...sessionInfo(),
|
||||||
|
read_only: true,
|
||||||
|
existing_project: true as const,
|
||||||
|
analysis_complete: null,
|
||||||
|
...prior,
|
||||||
|
capabilities: GHIDRA_SESSION_CAPABILITIES.filter(
|
||||||
|
(name) => !GHIDRA_MUTATING_OPERATIONS.has(name),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
callTool: async () => ok([]),
|
||||||
|
close,
|
||||||
|
}),
|
||||||
|
{ REA_GHIDRA_PROJECT_PATH: join(root, "history.gpr") },
|
||||||
|
);
|
||||||
|
onTestFinished(async () => {
|
||||||
|
await ghidra.closeOwnedResources();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
const target = executableTarget("elf", "x86_64");
|
||||||
|
const resolved = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!resolved.ok || resolved.value.profile === null)
|
||||||
|
throw new Error("expected existing profile");
|
||||||
|
const client = ghidra.createClient(target, resolved.value.profile);
|
||||||
|
onTestFinished(async () => {
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
const first = await client.execute("list_procedures", {});
|
||||||
|
if (!first.ok) throw first.error;
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
expect(first.value.sourceEvidence).toHaveLength(1);
|
||||||
|
expect(first.value.sourceEvidence?.[0]).toMatchObject({
|
||||||
|
operation: "health",
|
||||||
|
normalized_result: { origin: "caller-project", ...prior },
|
||||||
|
});
|
||||||
|
const repeated = await client.execute("list_procedures", {});
|
||||||
|
if (!repeated.ok) throw repeated.error;
|
||||||
|
expect(repeated.value.sourceEvidence?.[0]).toBe(
|
||||||
|
first.value.sourceEvidence?.[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
existingGhidraAnalysisProfile,
|
||||||
|
ghidraPinnedLanguage,
|
||||||
|
resolveGhidraAnalysisProfile,
|
||||||
|
} from "./GhidraAnalysisProfile.js";
|
||||||
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||||
|
import { resolveGhidraProject } from "./GhidraProjectSnapshot.js";
|
||||||
import { DeferredAnalysisClient } from "../providers/DeferredAnalysisClient.js";
|
import { DeferredAnalysisClient } from "../providers/DeferredAnalysisClient.js";
|
||||||
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||||
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
|
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
|
||||||
@@ -49,7 +56,6 @@ import {
|
|||||||
type GhidraInstallationHost,
|
type GhidraInstallationHost,
|
||||||
type GhidraInstallationInspection,
|
type GhidraInstallationInspection,
|
||||||
} from "./GhidraInstallation.js";
|
} from "./GhidraInstallation.js";
|
||||||
import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js";
|
|
||||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||||
import { resolveGhidraAnalysisSeeds } from "./GhidraAnalysisSeeds.js";
|
import { resolveGhidraAnalysisSeeds } from "./GhidraAnalysisSeeds.js";
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +70,8 @@ import {
|
|||||||
import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js";
|
import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js";
|
||||||
import {
|
import {
|
||||||
CAPABILITIES,
|
CAPABILITIES,
|
||||||
|
existingProjectCapabilities,
|
||||||
|
EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
|
||||||
windowsP0Capabilities,
|
windowsP0Capabilities,
|
||||||
GHIDRA_PROVIDER_IDENTITY,
|
GHIDRA_PROVIDER_IDENTITY,
|
||||||
WINDOWS_P0_NATIVE_PE_ARCHITECTURES,
|
WINDOWS_P0_NATIVE_PE_ARCHITECTURES,
|
||||||
@@ -92,7 +100,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
|
|||||||
"hexagon",
|
"hexagon",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Ghidra candidate backed by an isolated ephemeral headless import. */
|
/** Ghidra candidate backed by an isolated ephemeral headless Program session. */
|
||||||
export class GhidraProvider implements AnalysisProviderCandidate {
|
export class GhidraProvider implements AnalysisProviderCandidate {
|
||||||
readonly #inspectionResources = new ArtifactResourceScope();
|
readonly #inspectionResources = new ArtifactResourceScope();
|
||||||
readonly #inspectionWork = new Set<Promise<unknown>>();
|
readonly #inspectionWork = new Set<Promise<unknown>>();
|
||||||
@@ -138,9 +146,12 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
capabilities(): readonly CapabilityDescriptor[] {
|
capabilities(): readonly CapabilityDescriptor[] {
|
||||||
return (this.installationHost?.platform ?? process.platform) === "win32"
|
const platform = this.installationHost?.platform ?? process.platform;
|
||||||
? windowsP0Capabilities()
|
return this.config.ghidraExistingProject !== undefined
|
||||||
: CAPABILITIES;
|
? existingProjectCapabilities(platform)
|
||||||
|
: platform === "win32"
|
||||||
|
? windowsP0Capabilities()
|
||||||
|
: CAPABILITIES;
|
||||||
}
|
}
|
||||||
|
|
||||||
async inspectAvailability(
|
async inspectAvailability(
|
||||||
@@ -180,9 +191,11 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
|||||||
return {
|
return {
|
||||||
availability,
|
availability,
|
||||||
capabilities:
|
capabilities:
|
||||||
readiness === undefined
|
this.config.ghidraExistingProject !== undefined
|
||||||
? CAPABILITIES
|
? existingProjectCapabilities(installation.platform)
|
||||||
: windowsP0Capabilities(readiness),
|
: readiness === undefined
|
||||||
|
? CAPABILITIES
|
||||||
|
: windowsP0Capabilities(readiness),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +204,16 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
|||||||
readiness: WindowsNativeReadiness | undefined,
|
readiness: WindowsNativeReadiness | undefined,
|
||||||
): ProviderAvailability {
|
): ProviderAvailability {
|
||||||
const diagnostics = ghidraInstallationDiagnostics(installation);
|
const diagnostics = ghidraInstallationDiagnostics(installation);
|
||||||
|
if (
|
||||||
|
this.config.ghidraExistingProject !== undefined &&
|
||||||
|
installation.platform === "win32"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
status: "unavailable",
|
||||||
|
code: "unsupported_host",
|
||||||
|
reason: EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
if (installation.status === "available" && readiness?.available === false)
|
if (installation.status === "available" && readiness?.available === false)
|
||||||
return {
|
return {
|
||||||
status: "unavailable",
|
status: "unavailable",
|
||||||
@@ -382,6 +405,45 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
|||||||
"resolve_analysis_profile",
|
"resolve_analysis_profile",
|
||||||
options?.scope,
|
options?.scope,
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
this.config.ghidraExistingProject !== undefined &&
|
||||||
|
target.kind === "executable" &&
|
||||||
|
installation.status === "available"
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
installation.platform === "win32" ||
|
||||||
|
ghidraPinnedLanguage(target) !== undefined ||
|
||||||
|
!(
|
||||||
|
target.format === "elf" ||
|
||||||
|
target.format === "pe" ||
|
||||||
|
isMachOFormat(target.format)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", "resolve_analysis_profile", {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"Existing-project mode supports native ELF/PE/Mach-O programs on POSIX without fresh-import loader preparation or pinned language overrides; use fresh import for this target/host.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const project = await resolveGhidraProject(
|
||||||
|
this.config.ghidraExistingProject,
|
||||||
|
target,
|
||||||
|
this.#inspectionResources,
|
||||||
|
options?.signal,
|
||||||
|
);
|
||||||
|
return ok({
|
||||||
|
profile: existingGhidraAnalysisProfile(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
...GHIDRA_PROVIDER_IDENTITY,
|
||||||
|
version: installation.providerVersion,
|
||||||
|
},
|
||||||
|
jsonValueSchema.parse(project),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
const resolved = await resolveGhidraAnalysisProfile(
|
const resolved = await resolveGhidraAnalysisProfile(
|
||||||
target,
|
target,
|
||||||
GHIDRA_PROVIDER_IDENTITY,
|
GHIDRA_PROVIDER_IDENTITY,
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ export const windowsP0Limitations = Object.freeze([
|
|||||||
"Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.",
|
"Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Existing databases have their own unknown analysis history; reopening does not complete it. */
|
||||||
|
export const ghidraHealthLimitations = (
|
||||||
|
existingProject = false,
|
||||||
|
): readonly string[] =>
|
||||||
|
existingProject
|
||||||
|
? [
|
||||||
|
"This session opens a caller-selected existing Ghidra project through a private copy, without import, auto-analysis or repair scripts. Original analysis completeness and settings are unknown.",
|
||||||
|
"The original project is never opened by Ghidra or modified. The private copy is read-only to bridge operations and deleted on close; original executable bytes are never written.",
|
||||||
|
"The analysis profile records the selected project content fingerprint and program name. The imported program SHA-256 must match the admitted target; the project may contain caller annotations or earlier edits, which are not fresh REA discoveries.",
|
||||||
|
]
|
||||||
|
: healthLimitations;
|
||||||
|
|
||||||
const stringInventoryLimitation =
|
const stringInventoryLimitation =
|
||||||
"Only Ghidra-defined string Data is observed; packed or undefined string bytes may be absent. Completeness applies to that inventory, and an empty search does not establish that text is absent from the artifact. If an address is known, use read_bytes and xrefs to inspect bytes and references.";
|
"Only Ghidra-defined string Data is observed; packed or undefined string bytes may be absent. Completeness applies to that inventory, and an empty search does not establish that text is absent from the artifact. If an address is known, use read_bytes and xrefs to inspect bytes and references.";
|
||||||
|
|
||||||
@@ -57,9 +69,12 @@ const searchLimitation =
|
|||||||
"Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values.";
|
"Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values.";
|
||||||
|
|
||||||
/** Limitation text for one admitted operation, including the common base. */
|
/** Limitation text for one admitted operation, including the common base. */
|
||||||
export const limitationsFor = (operation: string): readonly string[] => {
|
export const limitationsFor = (
|
||||||
|
operation: string,
|
||||||
|
existingProject = false,
|
||||||
|
): readonly string[] => {
|
||||||
const common = [
|
const common = [
|
||||||
...healthLimitations,
|
...ghidraHealthLimitations(existingProject),
|
||||||
"Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.",
|
"Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.",
|
||||||
];
|
];
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
@@ -216,6 +231,39 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The selected database mode is not part of Windows P0 authority. */
|
||||||
|
export const EXISTING_PROJECT_WINDOWS_UNAVAILABLE =
|
||||||
|
"An existing Ghidra project cannot be opened on the Windows launch path.";
|
||||||
|
|
||||||
|
/** Read-only project mode retains precise unavailable mutation capabilities. */
|
||||||
|
export const existingProjectCapabilities = (
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
): readonly CapabilityDescriptor[] =>
|
||||||
|
CAPABILITIES.map((capability): CapabilityDescriptor => {
|
||||||
|
const limitations = limitationsFor(
|
||||||
|
capability.operation === "binary_overview"
|
||||||
|
? "read_binary_inventory_summary"
|
||||||
|
: capability.operation,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (platform === "win32")
|
||||||
|
return {
|
||||||
|
...capability,
|
||||||
|
limitations,
|
||||||
|
available: false,
|
||||||
|
reason: EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
|
||||||
|
availabilityCode: "unsupported_host",
|
||||||
|
};
|
||||||
|
if (capability.effects.mutatesArtifact)
|
||||||
|
return {
|
||||||
|
...capability,
|
||||||
|
limitations,
|
||||||
|
available: false,
|
||||||
|
reason: "Existing Ghidra project sessions are read-only.",
|
||||||
|
availabilityCode: "open_options_invalid",
|
||||||
|
};
|
||||||
|
return { ...capability, limitations };
|
||||||
|
});
|
||||||
/** Capabilities advertised for the experimental Windows x64 P0 boundary. */
|
/** Capabilities advertised for the experimental Windows x64 P0 boundary. */
|
||||||
export const windowsP0Capabilities = (
|
export const windowsP0Capabilities = (
|
||||||
readiness: WindowsNativeReadiness = inspectWindowsNativeReadiness("win32"),
|
readiness: WindowsNativeReadiness = inspectWindowsNativeReadiness("win32"),
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { ghidraProjectHistoryEvidence } from "./GhidraProjectHistory.js";
|
||||||
|
import type { Evidence, EvidenceLocation } from "../domain/evidence.js";
|
||||||
|
import { ghidraProjectCommitmentSchema } from "./GhidraProjectSnapshot.js";
|
||||||
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
|
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||||
@@ -53,7 +56,6 @@ import {
|
|||||||
resourceConstraintRecovery,
|
resourceConstraintRecovery,
|
||||||
} from "../domain/analysisErrorCore.js";
|
} from "../domain/analysisErrorCore.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
|
||||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||||
import {
|
import {
|
||||||
providerRetryAction,
|
providerRetryAction,
|
||||||
@@ -93,7 +95,7 @@ import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js";
|
|||||||
import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js";
|
import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js";
|
||||||
import {
|
import {
|
||||||
GHIDRA_PROVIDER_IDENTITY,
|
GHIDRA_PROVIDER_IDENTITY,
|
||||||
healthLimitations,
|
ghidraHealthLimitations,
|
||||||
windowsP0Limitations,
|
windowsP0Limitations,
|
||||||
limitationsFor,
|
limitationsFor,
|
||||||
} from "./GhidraProviderCapabilities.js";
|
} from "./GhidraProviderCapabilities.js";
|
||||||
@@ -133,6 +135,7 @@ export type GhidraProviderClientFactory = (
|
|||||||
type GhidraProviderClientInstance = ReturnType<GhidraProviderClientFactory>;
|
type GhidraProviderClientInstance = ReturnType<GhidraProviderClientFactory>;
|
||||||
|
|
||||||
interface GhidraExecutionMetadata {
|
interface GhidraExecutionMetadata {
|
||||||
|
readonly sourceEvidence?: readonly Evidence[];
|
||||||
readonly cachePolicy?: "live";
|
readonly cachePolicy?: "live";
|
||||||
readonly rawResult?: JsonValue;
|
readonly rawResult?: JsonValue;
|
||||||
readonly limitations?: readonly string[];
|
readonly limitations?: readonly string[];
|
||||||
@@ -207,6 +210,29 @@ export const createGhidraProviderClient = (input: {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const extensions = extensionProfile.data;
|
const extensions = extensionProfile.data;
|
||||||
|
const projectProfile = ghidraProjectCommitmentSchema
|
||||||
|
.optional()
|
||||||
|
.safeParse(committedProfile.parameters.existing_project);
|
||||||
|
if (
|
||||||
|
!projectProfile.success ||
|
||||||
|
(config.ghidraExistingProject === undefined) !==
|
||||||
|
(projectProfile.data === undefined) ||
|
||||||
|
(projectProfile.data !== undefined &&
|
||||||
|
(projectProfile.data.configured_path !==
|
||||||
|
config.ghidraExistingProject?.path ||
|
||||||
|
(config.ghidraExistingProject?.programName !== undefined &&
|
||||||
|
config.ghidraExistingProject.programName !==
|
||||||
|
projectProfile.data.program_name)))
|
||||||
|
)
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", "open_binary", {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"Resolve the selected Ghidra project into a fresh analysis profile before opening the session.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const project = projectProfile.data;
|
||||||
const seedProfile = ghidraSeedCommitmentSchema
|
const seedProfile = ghidraSeedCommitmentSchema
|
||||||
.optional()
|
.optional()
|
||||||
.safeParse(committedProfile.parameters.analysis_seeds);
|
.safeParse(committedProfile.parameters.analysis_seeds);
|
||||||
@@ -381,7 +407,18 @@ export const createGhidraProviderClient = (input: {
|
|||||||
readStartupTimeoutMs: () => config.ghidraStartupTimeoutMs,
|
readStartupTimeoutMs: () => config.ghidraStartupTimeoutMs,
|
||||||
bridgeMaxRequestBytes: config.bridgeMaxRequestBytes,
|
bridgeMaxRequestBytes: config.bridgeMaxRequestBytes,
|
||||||
platform: installation.platform,
|
platform: installation.platform,
|
||||||
|
...(project === undefined ? {} : { existingProject: true }),
|
||||||
launcher: new GhidraHeadlessLauncher({
|
launcher: new GhidraHeadlessLauncher({
|
||||||
|
...(project === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
existingProject: {
|
||||||
|
projectPath: project.project_path,
|
||||||
|
projectName: project.project_name,
|
||||||
|
programName: project.program_name,
|
||||||
|
sha256: project.sha256,
|
||||||
|
},
|
||||||
|
}),
|
||||||
environment: input.environment,
|
environment: input.environment,
|
||||||
analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath,
|
analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath,
|
||||||
configurationDirectoryName:
|
configurationDirectoryName:
|
||||||
@@ -474,6 +511,7 @@ export const createGhidraProviderClient = (input: {
|
|||||||
let providerStarted = false;
|
let providerStarted = false;
|
||||||
const lostProviderHealth = (): ProviderOperationHealth | undefined =>
|
const lostProviderHealth = (): ProviderOperationHealth | undefined =>
|
||||||
providerStarted ? client.operationHealthSnapshot?.() : undefined;
|
providerStarted ? client.operationHealthSnapshot?.() : undefined;
|
||||||
|
let projectHistory: Evidence | undefined;
|
||||||
const checkSessionObservations = async (
|
const checkSessionObservations = async (
|
||||||
operation: AnalysisOperation,
|
operation: AnalysisOperation,
|
||||||
info: GhidraSessionInfo,
|
info: GhidraSessionInfo,
|
||||||
@@ -485,48 +523,62 @@ export const createGhidraProviderClient = (input: {
|
|||||||
...ghidraPePdbLimitations(target, info.pdb),
|
...ghidraPePdbLimitations(target, info.pdb),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const softFloatReason = ghidraMipsSoftFloatFailure(
|
if (project !== undefined) {
|
||||||
softFloat,
|
extensionFailure = ghidraExtensionFailure(
|
||||||
info.mips_soft_float_storage,
|
|
||||||
);
|
|
||||||
extensionFailure =
|
|
||||||
ghidraExtensionFailure(
|
|
||||||
extensions,
|
extensions,
|
||||||
info.analysis_extensions ?? [],
|
info.analysis_extensions ?? [],
|
||||||
operation,
|
operation,
|
||||||
) ??
|
|
||||||
ghidraSeedFailure(seeds, info.analysis_seeds, operation) ??
|
|
||||||
ghidraDisabledAnalyzerFailure(
|
|
||||||
disabledAnalyzers,
|
|
||||||
info.disabled_analyzers,
|
|
||||||
operation,
|
|
||||||
) ??
|
|
||||||
(softFloatReason === undefined
|
|
||||||
? undefined
|
|
||||||
: new ProviderAdapterError("ghidra", operation, {
|
|
||||||
diagnostics: { reason: softFloatReason },
|
|
||||||
}));
|
|
||||||
if (extensionFailure === undefined && info.analysis_seeds !== undefined)
|
|
||||||
seedLimitations.splice(
|
|
||||||
0,
|
|
||||||
seedLimitations.length,
|
|
||||||
...ghidraSeedLimitations(seeds, info.analysis_seeds),
|
|
||||||
);
|
);
|
||||||
if (extensionFailure === undefined)
|
if (extensionFailure === undefined)
|
||||||
softFloatLimitations.splice(
|
projectHistory ??= ghidraProjectHistoryEvidence(
|
||||||
0,
|
target,
|
||||||
softFloatLimitations.length,
|
committedProfile,
|
||||||
...ghidraMipsSoftFloatLimitations(
|
info,
|
||||||
softFloat,
|
);
|
||||||
info.mips_soft_float_storage,
|
} else {
|
||||||
),
|
const softFloatReason = ghidraMipsSoftFloatFailure(
|
||||||
);
|
softFloat,
|
||||||
if (extensionFailure === undefined)
|
info.mips_soft_float_storage,
|
||||||
disabledAnalyzerLimitations.splice(
|
|
||||||
0,
|
|
||||||
disabledAnalyzerLimitations.length,
|
|
||||||
...ghidraDisabledAnalyzerLimitations(info.disabled_analyzers),
|
|
||||||
);
|
);
|
||||||
|
extensionFailure =
|
||||||
|
ghidraExtensionFailure(
|
||||||
|
extensions,
|
||||||
|
info.analysis_extensions ?? [],
|
||||||
|
operation,
|
||||||
|
) ??
|
||||||
|
ghidraSeedFailure(seeds, info.analysis_seeds, operation) ??
|
||||||
|
ghidraDisabledAnalyzerFailure(
|
||||||
|
disabledAnalyzers,
|
||||||
|
info.disabled_analyzers,
|
||||||
|
operation,
|
||||||
|
) ??
|
||||||
|
(softFloatReason === undefined
|
||||||
|
? undefined
|
||||||
|
: new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: { reason: softFloatReason },
|
||||||
|
}));
|
||||||
|
if (extensionFailure === undefined && info.analysis_seeds !== undefined)
|
||||||
|
seedLimitations.splice(
|
||||||
|
0,
|
||||||
|
seedLimitations.length,
|
||||||
|
...ghidraSeedLimitations(seeds, info.analysis_seeds),
|
||||||
|
);
|
||||||
|
if (extensionFailure === undefined)
|
||||||
|
softFloatLimitations.splice(
|
||||||
|
0,
|
||||||
|
softFloatLimitations.length,
|
||||||
|
...ghidraMipsSoftFloatLimitations(
|
||||||
|
softFloat,
|
||||||
|
info.mips_soft_float_storage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (extensionFailure === undefined)
|
||||||
|
disabledAnalyzerLimitations.splice(
|
||||||
|
0,
|
||||||
|
disabledAnalyzerLimitations.length,
|
||||||
|
...ghidraDisabledAnalyzerLimitations(info.disabled_analyzers),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (extensionFailure === undefined) return undefined;
|
if (extensionFailure === undefined) return undefined;
|
||||||
const closed = await client.close();
|
const closed = await client.close();
|
||||||
return closed.ok
|
return closed.ok
|
||||||
@@ -586,9 +638,17 @@ export const createGhidraProviderClient = (input: {
|
|||||||
) => {
|
) => {
|
||||||
return createAnalysisExecution(normalized, committedProfile.provider, {
|
return createAnalysisExecution(normalized, committedProfile.provider, {
|
||||||
...observation,
|
...observation,
|
||||||
|
...(projectHistory === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
sourceEvidence: [
|
||||||
|
...(observation.sourceEvidence ?? []),
|
||||||
|
projectHistory,
|
||||||
|
],
|
||||||
|
}),
|
||||||
analysisProfile: committedProfile,
|
analysisProfile: committedProfile,
|
||||||
limitations: [
|
limitations: [
|
||||||
...limitationsFor(operation),
|
...limitationsFor(operation, project !== undefined),
|
||||||
...(observation.limitations ?? []),
|
...(observation.limitations ?? []),
|
||||||
...sessionLimitations(),
|
...sessionLimitations(),
|
||||||
],
|
],
|
||||||
@@ -679,7 +739,10 @@ export const createGhidraProviderClient = (input: {
|
|||||||
return ok(
|
return ok(
|
||||||
createAnalysisExecution(started.value, committedProfile.provider, {
|
createAnalysisExecution(started.value, committedProfile.provider, {
|
||||||
analysisProfile: committedProfile,
|
analysisProfile: committedProfile,
|
||||||
limitations: [...healthLimitations, ...sessionLimitations()],
|
limitations: [
|
||||||
|
...ghidraHealthLimitations(project !== undefined),
|
||||||
|
...sessionLimitations(),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -688,6 +751,7 @@ export const createGhidraProviderClient = (input: {
|
|||||||
: parseGhidraInventoryInput(operation, parameters);
|
: parseGhidraInventoryInput(operation, parameters);
|
||||||
if (!input.ok) return input;
|
if (!input.ok) return input;
|
||||||
if (
|
if (
|
||||||
|
project !== undefined ||
|
||||||
target.format === "pe" ||
|
target.format === "pe" ||
|
||||||
extensions.length > 0 ||
|
extensions.length > 0 ||
|
||||||
seeds !== undefined ||
|
seeds !== undefined ||
|
||||||
|
|||||||
@@ -239,3 +239,32 @@ describe("Ghidra mutation handshake", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("admits unknown analysis completeness only for a committed read-only existing project", () => {
|
||||||
|
const value = {
|
||||||
|
...session(),
|
||||||
|
existing_project: true,
|
||||||
|
read_only: true,
|
||||||
|
analysis_complete: null,
|
||||||
|
capabilities: GHIDRA_SESSION_CAPABILITIES.filter(
|
||||||
|
(name) => !GHIDRA_MUTATING_OPERATIONS.has(name),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const project = {
|
||||||
|
...expected,
|
||||||
|
expectedReadOnly: true,
|
||||||
|
expectedExistingProject: true,
|
||||||
|
};
|
||||||
|
expect(parseGhidraSessionInfo(value, project).ok).toBe(true);
|
||||||
|
expect(parseGhidraSessionInfo(value, expected).ok).toBe(false);
|
||||||
|
for (const analysis_complete of [false, true])
|
||||||
|
expect(
|
||||||
|
parseGhidraSessionInfo({ ...value, analysis_complete }, project).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseGhidraSessionInfo(
|
||||||
|
{ ...value, capabilities: [...GHIDRA_SESSION_CAPABILITIES] },
|
||||||
|
project,
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js";
|
|||||||
import { ghidraExtensionResultSchema } from "./extensions/GhidraExtensions.js";
|
import { ghidraExtensionResultSchema } from "./extensions/GhidraExtensions.js";
|
||||||
import { GHIDRA_MIPS_SOFT_FLOAT_STORAGE } from "./GhidraMipsProfile.js";
|
import { GHIDRA_MIPS_SOFT_FLOAT_STORAGE } from "./GhidraMipsProfile.js";
|
||||||
|
|
||||||
/** Exact methods proved by the bridge handshake after auto-analysis. */
|
/** Exact methods proved by the selected Program bridge handshake. */
|
||||||
export const GHIDRA_SESSION_CAPABILITIES = [
|
export const GHIDRA_SESSION_CAPABILITIES = [
|
||||||
"ping",
|
"ping",
|
||||||
"shutdown",
|
"shutdown",
|
||||||
@@ -47,7 +47,8 @@ const sessionInfoSchema = z
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
read_only: z.boolean(),
|
read_only: z.boolean(),
|
||||||
analysis_complete: z.boolean(),
|
analysis_complete: z.boolean().nullable(),
|
||||||
|
existing_project: z.literal(true).optional(),
|
||||||
analysis_timed_out: z.boolean(),
|
analysis_timed_out: z.boolean(),
|
||||||
/** Program Information attributes; absent load state remains unknown. */
|
/** Program Information attributes; absent load state remains unknown. */
|
||||||
pdb: z
|
pdb: z
|
||||||
@@ -107,7 +108,7 @@ const sessionInfoSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
/** Authenticated metadata produced after Ghidra finishes auto-analysis. */
|
/** Authenticated Program metadata; original analysis can remain unknown. */
|
||||||
export type GhidraSessionInfo = z.infer<typeof sessionInfoSchema>;
|
export type GhidraSessionInfo = z.infer<typeof sessionInfoSchema>;
|
||||||
|
|
||||||
/** Describe missing PDB authority without claiming all names are generated. */
|
/** Describe missing PDB authority without claiming all names are generated. */
|
||||||
@@ -155,6 +156,7 @@ export const parseGhidraSessionInfo = (
|
|||||||
readonly profileDigest: string;
|
readonly profileDigest: string;
|
||||||
readonly targetSha256: string;
|
readonly targetSha256: string;
|
||||||
readonly expectedReadOnly?: boolean;
|
readonly expectedReadOnly?: boolean;
|
||||||
|
readonly expectedExistingProject?: boolean;
|
||||||
readonly expectedLanguageId?: string;
|
readonly expectedLanguageId?: string;
|
||||||
readonly expectedCompilerSpecId?: string;
|
readonly expectedCompilerSpecId?: string;
|
||||||
},
|
},
|
||||||
@@ -181,7 +183,13 @@ export const parseGhidraSessionInfo = (
|
|||||||
expectedCapabilities.some(
|
expectedCapabilities.some(
|
||||||
(capability) => !parsed.data.capabilities.includes(capability),
|
(capability) => !parsed.data.capabilities.includes(capability),
|
||||||
) ||
|
) ||
|
||||||
parsed.data.analysis_complete === parsed.data.analysis_timed_out
|
(expected.expectedExistingProject === true
|
||||||
|
? parsed.data.existing_project !== true ||
|
||||||
|
parsed.data.analysis_complete !== null ||
|
||||||
|
parsed.data.analysis_timed_out
|
||||||
|
: parsed.data.existing_project !== undefined ||
|
||||||
|
parsed.data.analysis_complete === null ||
|
||||||
|
parsed.data.analysis_complete === parsed.data.analysis_timed_out)
|
||||||
)
|
)
|
||||||
return err(new Error("Ghidra bridge handshake identity is invalid"));
|
return err(new Error("Ghidra bridge handshake identity is invalid"));
|
||||||
return ok(parsed.data);
|
return ok(parsed.data);
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { access, chmod, readFile, stat } from "node:fs/promises";
|
import {
|
||||||
|
access,
|
||||||
|
chmod,
|
||||||
|
mkdir,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
stat,
|
||||||
|
symlink,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
import { join, posix, win32 } from "node:path";
|
import { join, posix, win32 } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -8,6 +18,11 @@ import { z } from "zod";
|
|||||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||||
import { createGhidraTestRuntime } from "../../../fixtures/ghidraRuntime.js";
|
import { createGhidraTestRuntime } from "../../../fixtures/ghidraRuntime.js";
|
||||||
import type { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
import type { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
||||||
|
import type {
|
||||||
|
OwnedProviderProcessSpawnOptions,
|
||||||
|
ProviderProcessHandle,
|
||||||
|
SpawnedOwnedProviderProcess,
|
||||||
|
} from "../../../../src/process/ProviderProcess.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ghidraHeadlessArguments,
|
ghidraHeadlessArguments,
|
||||||
@@ -524,6 +539,383 @@ describe("Ghidra disabled analyzers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ghidra existing project", () => {
|
||||||
|
const base = {
|
||||||
|
platform: "darwin" as const,
|
||||||
|
projectRoot: "/tmp/project",
|
||||||
|
targetPath: "/tmp/target",
|
||||||
|
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
|
||||||
|
descriptorPath: "/tmp/session.json",
|
||||||
|
ghidraLogPath: "/tmp/ghidra.log",
|
||||||
|
scriptLogPath: "/tmp/script.log",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("opens a pre-imported program without importing or deleting the project", () => {
|
||||||
|
const arguments_ = ghidraHeadlessArguments({
|
||||||
|
...base,
|
||||||
|
existingProject: { programName: "game.exe" },
|
||||||
|
});
|
||||||
|
// The project location and name stay explicit; nothing is imported.
|
||||||
|
expect(arguments_.slice(0, 2)).toEqual(["/tmp/project", "rea-project"]);
|
||||||
|
expect(arguments_).not.toContain("-import");
|
||||||
|
expect(arguments_).not.toContain("-deleteProject");
|
||||||
|
expect(arguments_).toEqual([
|
||||||
|
"/tmp/project",
|
||||||
|
"rea-project",
|
||||||
|
"-process",
|
||||||
|
"game.exe",
|
||||||
|
"-noanalysis",
|
||||||
|
"-readOnly",
|
||||||
|
"-log",
|
||||||
|
"/tmp/ghidra.log",
|
||||||
|
"-scriptlog",
|
||||||
|
"/tmp/script.log",
|
||||||
|
"-scriptPath",
|
||||||
|
"/package/bridge",
|
||||||
|
"-postScript",
|
||||||
|
"/package/bridge/ReaGhidraBridge.java",
|
||||||
|
"/tmp/session.json",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ dosMz: true as const },
|
||||||
|
{ dosCom: true as const },
|
||||||
|
{
|
||||||
|
rawBinary: {
|
||||||
|
languageId: "ARM:LE:32:v7",
|
||||||
|
compilerSpecId: "default",
|
||||||
|
baseAddr: "0x8000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ languageOverride: { languageId: "x86:LE:32:default" } },
|
||||||
|
{ psp: true as const },
|
||||||
|
{ mipsSoftFloat: true as const },
|
||||||
|
{ seedPath: "/tmp/runtime/seeds/seeds.tsv" },
|
||||||
|
{ disabledAnalyzers: ["Decompiler Parameter ID"] },
|
||||||
|
])(
|
||||||
|
"rejects the import-only option %o with an existing project",
|
||||||
|
(conflict) => {
|
||||||
|
expect(() =>
|
||||||
|
ghidraHeadlessArguments({
|
||||||
|
...base,
|
||||||
|
existingProject: { programName: "game.exe" },
|
||||||
|
...conflict,
|
||||||
|
}),
|
||||||
|
).toThrow(/existing (Ghidra )?project|fresh import/u);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("rejects a Windows existing-project open on the P0 launch path", () => {
|
||||||
|
expect(() =>
|
||||||
|
ghidraHeadlessArguments({
|
||||||
|
...base,
|
||||||
|
platform: "win32",
|
||||||
|
bridgeScriptPath: win32.join(
|
||||||
|
"/package",
|
||||||
|
"bridge",
|
||||||
|
"ReaGhidraBridge.java",
|
||||||
|
),
|
||||||
|
existingProject: { programName: "game.exe" },
|
||||||
|
}),
|
||||||
|
).toThrow(/Windows/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a program name for -process", () => {
|
||||||
|
expect(() =>
|
||||||
|
ghidraHeadlessArguments({
|
||||||
|
...base,
|
||||||
|
existingProject: { programName: " " },
|
||||||
|
}),
|
||||||
|
).toThrow(/program name/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeSpawnedProcess = (runId: string): SpawnedOwnedProviderProcess => {
|
||||||
|
const handle: ProviderProcessHandle = Object.assign(new EventEmitter(), {
|
||||||
|
pid: 4242,
|
||||||
|
stdout: null,
|
||||||
|
stdin: null,
|
||||||
|
stderr: null,
|
||||||
|
exitCode: null,
|
||||||
|
signalCode: null,
|
||||||
|
kill: () => true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
process: handle,
|
||||||
|
ownership: { runId, leaderPid: 4242, processGroupId: 4242 },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFakeJavaHome = async (parent: string): Promise<string> => {
|
||||||
|
const javaHome = join(parent, "jdk");
|
||||||
|
await mkdir(join(javaHome, "bin"), { recursive: true });
|
||||||
|
// LaunchSupport only consults this helper; an empty report is valid.
|
||||||
|
await writeFile(join(javaHome, "bin", "java"), "#!/bin/sh\nexit 0\n", {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
return javaHome;
|
||||||
|
};
|
||||||
|
|
||||||
|
const launchExistingProject = async (
|
||||||
|
runtimeRoot: string,
|
||||||
|
parent: string,
|
||||||
|
projectPath: string,
|
||||||
|
spawned: OwnedProviderProcessSpawnOptions[],
|
||||||
|
) => {
|
||||||
|
const javaHome = await createFakeJavaHome(parent);
|
||||||
|
const launcher = new GhidraHeadlessLauncher({
|
||||||
|
environment: process.env,
|
||||||
|
analyzeHeadlessPath: fixturePath,
|
||||||
|
platform: "linux",
|
||||||
|
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
|
||||||
|
javaHome,
|
||||||
|
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
|
||||||
|
existingProject: { projectPath, programName: "game.exe" },
|
||||||
|
spawnProcess: async (options) => {
|
||||||
|
spawned.push(options);
|
||||||
|
return fakeSpawnedProcess(options.runId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return launcher.launch({
|
||||||
|
runtimeRoot,
|
||||||
|
transport: "unix-socket",
|
||||||
|
endpointPath: join(runtimeRoot, "bridge.sock"),
|
||||||
|
token: "secret-token-that-must-not-leak",
|
||||||
|
runId: "d6fcbb66-e829-4ff6-a535-0035aec63139",
|
||||||
|
targetPath: "/tmp/target",
|
||||||
|
targetSha256: "b".repeat(64),
|
||||||
|
providerVersion: "12.1.4",
|
||||||
|
profileDigest: "a".repeat(64),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Ghidra existing project launcher", () => {
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"copies the caller's project into the owned runtime before launching",
|
||||||
|
async () => {
|
||||||
|
const parent = await createTestTempDirectory("rea-launcher-test-");
|
||||||
|
const runtime = await createGhidraTestRuntime(parent);
|
||||||
|
runtimes.push(runtime);
|
||||||
|
const sourceProject = join(parent, "user-project");
|
||||||
|
await mkdir(join(sourceProject, "rea-project.rep"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
const projectMarker = join(sourceProject, "rea-project.gpr");
|
||||||
|
await writeFile(projectMarker, "pre-imported");
|
||||||
|
await writeFile(
|
||||||
|
join(sourceProject, "rea-project.rep", "marker"),
|
||||||
|
"recursive",
|
||||||
|
);
|
||||||
|
const spawned: OwnedProviderProcessSpawnOptions[] = [];
|
||||||
|
const launched = await launchExistingProject(
|
||||||
|
runtime.path,
|
||||||
|
parent,
|
||||||
|
sourceProject,
|
||||||
|
spawned,
|
||||||
|
);
|
||||||
|
if (!launched.ok) throw launched.error;
|
||||||
|
try {
|
||||||
|
expect(spawned).toHaveLength(1);
|
||||||
|
expect(spawned[0]?.arguments).not.toContain("-import");
|
||||||
|
expect(spawned[0]?.arguments).not.toContain("-deleteProject");
|
||||||
|
expectOptions(spawned[0]?.arguments ?? [], [["-process", "game.exe"]]);
|
||||||
|
expect(spawned[0]?.arguments).toContain("-noanalysis");
|
||||||
|
expect(spawned[0]?.arguments).toContain("-readOnly");
|
||||||
|
// Copy-first: the owned runtime holds the copy before Ghidra starts.
|
||||||
|
expect(
|
||||||
|
await readFile(join(runtime.path, "project", "rea-project.gpr"), {
|
||||||
|
encoding: "utf8",
|
||||||
|
}),
|
||||||
|
).toBe("pre-imported");
|
||||||
|
expect(
|
||||||
|
await readFile(
|
||||||
|
join(runtime.path, "project", "rea-project.rep", "marker"),
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
),
|
||||||
|
).toBe("recursive");
|
||||||
|
// The caller's original project files stay untouched.
|
||||||
|
expect(await readFile(projectMarker, { encoding: "utf8" })).toBe(
|
||||||
|
"pre-imported",
|
||||||
|
);
|
||||||
|
expect((await stat(projectMarker)).isFile()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await launched.value.cleanup?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"rejects a missing project directory before starting Ghidra",
|
||||||
|
async () => {
|
||||||
|
const parent = await createTestTempDirectory("rea-launcher-test-");
|
||||||
|
const runtime = await createGhidraTestRuntime(parent);
|
||||||
|
runtimes.push(runtime);
|
||||||
|
const spawned: OwnedProviderProcessSpawnOptions[] = [];
|
||||||
|
const launched = await launchExistingProject(
|
||||||
|
runtime.path,
|
||||||
|
parent,
|
||||||
|
join(parent, "absent-project"),
|
||||||
|
spawned,
|
||||||
|
);
|
||||||
|
expect(launched.ok).toBe(false);
|
||||||
|
if (launched.ok) throw new Error("launch unexpectedly succeeded");
|
||||||
|
// The launcher wraps the copy failure; the reason stays on the cause.
|
||||||
|
expect(String(launched.error.cause)).toMatch(/does not exist/u);
|
||||||
|
expect(spawned).toHaveLength(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"rejects a project without the fixed rea-project name before copying",
|
||||||
|
async () => {
|
||||||
|
const parent = await createTestTempDirectory("rea-launcher-test-");
|
||||||
|
const runtime = await createGhidraTestRuntime(parent);
|
||||||
|
runtimes.push(runtime);
|
||||||
|
// The headless open selects the project by the fixed name; a caller
|
||||||
|
// directory holding any other project file must fail with its reason
|
||||||
|
// instead of spawning into an opaque Ghidra error.
|
||||||
|
const sourceProject = join(parent, "user-project");
|
||||||
|
await mkdir(sourceProject, { recursive: true });
|
||||||
|
await writeFile(join(sourceProject, "other-project.grez"), "mislabeled");
|
||||||
|
const spawned: OwnedProviderProcessSpawnOptions[] = [];
|
||||||
|
const launched = await launchExistingProject(
|
||||||
|
runtime.path,
|
||||||
|
parent,
|
||||||
|
sourceProject,
|
||||||
|
spawned,
|
||||||
|
);
|
||||||
|
expect(launched.ok).toBe(false);
|
||||||
|
if (launched.ok) throw new Error("launch unexpectedly succeeded");
|
||||||
|
expect(String(launched.error.cause)).toMatch(/rea-project\.gpr/u);
|
||||||
|
expect(spawned).toHaveLength(0);
|
||||||
|
// Nothing from the rejected project was copied into the owned runtime.
|
||||||
|
await expect(
|
||||||
|
access(join(runtime.path, "project")),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(await readdir(join(runtime.path, "project"))).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"rejects a project path that overlaps the runtime before copying",
|
||||||
|
async () => {
|
||||||
|
const parent = await createTestTempDirectory("rea-launcher-test-");
|
||||||
|
const runtime = await createGhidraTestRuntime(parent);
|
||||||
|
runtimes.push(runtime);
|
||||||
|
// Copying the runtime into itself would recurse without bound, so the
|
||||||
|
// launcher must refuse the overlap before touching the file system.
|
||||||
|
const spawned: OwnedProviderProcessSpawnOptions[] = [];
|
||||||
|
const launched = await launchExistingProject(
|
||||||
|
runtime.path,
|
||||||
|
parent,
|
||||||
|
runtime.path,
|
||||||
|
spawned,
|
||||||
|
);
|
||||||
|
expect(launched.ok).toBe(false);
|
||||||
|
if (launched.ok) throw new Error("launch unexpectedly succeeded");
|
||||||
|
expect(String(launched.error.cause)).toMatch(
|
||||||
|
/overlaps the REA runtime directory/u,
|
||||||
|
);
|
||||||
|
expect(spawned).toHaveLength(0);
|
||||||
|
expect(await readdir(join(runtime.path, "project"))).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"rejects a project path inside the runtime before copying",
|
||||||
|
async () => {
|
||||||
|
const parent = await createTestTempDirectory("rea-launcher-test-");
|
||||||
|
const runtime = await createGhidraTestRuntime(parent);
|
||||||
|
runtimes.push(runtime);
|
||||||
|
// A caller-supplied path inside the owned runtime (here the project
|
||||||
|
// root itself) is equally an overlap and must fail before Ghidra runs.
|
||||||
|
const spawned: OwnedProviderProcessSpawnOptions[] = [];
|
||||||
|
const launched = await launchExistingProject(
|
||||||
|
runtime.path,
|
||||||
|
parent,
|
||||||
|
join(runtime.path, "project"),
|
||||||
|
spawned,
|
||||||
|
);
|
||||||
|
expect(launched.ok).toBe(false);
|
||||||
|
if (launched.ok) throw new Error("launch unexpectedly succeeded");
|
||||||
|
expect(String(launched.error.cause)).toMatch(
|
||||||
|
/overlaps the REA runtime directory/u,
|
||||||
|
);
|
||||||
|
expect(spawned).toHaveLength(0);
|
||||||
|
expect(await readdir(join(runtime.path, "project"))).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ghidra existing project symlink overlap", () => {
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"rejects a project path whose symbolic link hides a runtime overlap before copying",
|
||||||
|
async () => {
|
||||||
|
const parent = await createTestTempDirectory("rea-launcher-test-");
|
||||||
|
const runtime = await createGhidraTestRuntime(parent);
|
||||||
|
runtimes.push(runtime);
|
||||||
|
// A valid project directory inside the owned runtime, reached only
|
||||||
|
// through a symbolic link outside it: the lexical path never overlaps
|
||||||
|
// the runtime, so only a realpath-based comparison can refuse the copy.
|
||||||
|
const hidden = join(runtime.path, "link-target");
|
||||||
|
await mkdir(hidden, { recursive: true });
|
||||||
|
await writeFile(join(hidden, "rea-project.gpr"), "", {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
await mkdir(join(hidden, "rea-project.rep"), { recursive: true });
|
||||||
|
const alias = join(parent, "alias");
|
||||||
|
await symlink(hidden, alias);
|
||||||
|
const spawned: OwnedProviderProcessSpawnOptions[] = [];
|
||||||
|
const launched = await launchExistingProject(
|
||||||
|
runtime.path,
|
||||||
|
parent,
|
||||||
|
alias,
|
||||||
|
spawned,
|
||||||
|
);
|
||||||
|
expect(launched.ok).toBe(false);
|
||||||
|
if (launched.ok) throw new Error("launch unexpectedly succeeded");
|
||||||
|
expect(String(launched.error.cause)).toMatch(
|
||||||
|
/overlaps the REA runtime directory/u,
|
||||||
|
);
|
||||||
|
expect(spawned).toHaveLength(0);
|
||||||
|
expect(await readdir(join(runtime.path, "project"))).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ghidra expanded project admission", () => {
|
||||||
|
it.each([
|
||||||
|
{ label: "archive only", marker: false, data: false },
|
||||||
|
{ label: "marker without data", marker: true, data: false },
|
||||||
|
{ label: "data without marker", marker: false, data: true },
|
||||||
|
])("rejects $label before starting Ghidra", async ({ marker, data }) => {
|
||||||
|
const parent = await createTestTempDirectory("rea-launcher-test-");
|
||||||
|
const runtime = await createGhidraTestRuntime(parent);
|
||||||
|
runtimes.push(runtime);
|
||||||
|
const sourceProject = join(parent, "user-project");
|
||||||
|
await mkdir(sourceProject);
|
||||||
|
await writeFile(join(sourceProject, "rea-project.grez"), "saved archive");
|
||||||
|
if (marker)
|
||||||
|
await writeFile(join(sourceProject, "rea-project.gpr"), "marker");
|
||||||
|
if (data) await mkdir(join(sourceProject, "rea-project.rep"));
|
||||||
|
const spawned: OwnedProviderProcessSpawnOptions[] = [];
|
||||||
|
const launched = await launchExistingProject(
|
||||||
|
runtime.path,
|
||||||
|
parent,
|
||||||
|
sourceProject,
|
||||||
|
spawned,
|
||||||
|
);
|
||||||
|
expect(launched.ok).toBe(false);
|
||||||
|
if (launched.ok) throw new Error("launch unexpectedly succeeded");
|
||||||
|
expect(String(launched.error.cause)).toMatch(
|
||||||
|
/restore saved \.grez archives/u,
|
||||||
|
);
|
||||||
|
expect(spawned).toHaveLength(0);
|
||||||
|
expect(await readdir(join(runtime.path, "project"))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Ghidra Windows launcher", () => {
|
describe("Ghidra Windows launcher", () => {
|
||||||
it("keeps analysis repair outside Windows P0 authority", () => {
|
it("keeps analysis repair outside Windows P0 authority", () => {
|
||||||
const args = ghidraHeadlessArguments({
|
const args = ghidraHeadlessArguments({
|
||||||
@@ -538,7 +930,9 @@ describe("Ghidra Windows launcher", () => {
|
|||||||
expect(args).not.toContain("/package/bridge/ReaGhidraNoReturnFix.java");
|
expect(args).not.toContain("/package/bridge/ReaGhidraNoReturnFix.java");
|
||||||
expect(args).toContain("/package/bridge/ReaGhidraBridge.java");
|
expect(args).toContain("/package/bridge/ReaGhidraBridge.java");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ghidra Windows batch command construction", () => {
|
||||||
it("wraps the Windows batch launcher without enabling a Node shell", () => {
|
it("wraps the Windows batch launcher without enabling a Node shell", () => {
|
||||||
const command = ghidraHeadlessCommand({
|
const command = ghidraHeadlessCommand({
|
||||||
environment: {},
|
environment: {},
|
||||||
@@ -584,11 +978,11 @@ describe("Ghidra Windows launcher", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Ghidra Windows launcher runtime", () => {
|
describe("Ghidra Windows release configuration", () => {
|
||||||
it
|
it
|
||||||
.skipIf(process.platform !== "win32")
|
.skipIf(process.platform !== "win32")
|
||||||
.each(["ghidra_12.1.4_PUBLIC", "ghidra_12.1.2_DEV"])(
|
.each(["ghidra_12.1.4_PUBLIC", "renamed-install"])(
|
||||||
"prepares the inspected release directory through the Windows runtime (%s)",
|
"prepares the %s release directory through the Windows runtime",
|
||||||
async (release) => {
|
async (release) => {
|
||||||
const parent = await createTestTempDirectory("rea-launch-config-");
|
const parent = await createTestTempDirectory("rea-launch-config-");
|
||||||
const installRoot = join(parent, "renamed-install");
|
const installRoot = join(parent, "renamed-install");
|
||||||
@@ -637,7 +1031,9 @@ describe("Ghidra Windows launcher runtime", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ghidra Windows launcher process ownership", () => {
|
||||||
it.skipIf(process.platform !== "win32")(
|
it.skipIf(process.platform !== "win32")(
|
||||||
"keeps authority in a private descriptor and isolates Ghidra state through the batch launcher",
|
"keeps authority in a private descriptor and isolates Ghidra state through the batch launcher",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { mkdir, readFile, readdir, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||||
|
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||||
|
import {
|
||||||
|
copyExistingGhidraProject,
|
||||||
|
fingerprintGhidraProject,
|
||||||
|
resolveGhidraProject,
|
||||||
|
} from "../../../../src/ghidra/GhidraProjectSnapshot.js";
|
||||||
|
import { parseConfig } from "../../../../src/config/parseConfig.js";
|
||||||
|
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
|
let resources: ArtifactResourceScope;
|
||||||
|
beforeEach(() => {
|
||||||
|
resources = new ArtifactResourceScope();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await resources.close();
|
||||||
|
});
|
||||||
|
const fixture = async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-ghidra-selected-project-");
|
||||||
|
const source = join(root, "source");
|
||||||
|
const destination = join(root, "runtime", "project");
|
||||||
|
await mkdir(join(source, "selected.rep"), { recursive: true });
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
await writeFile(join(source, "selected.gpr"), "marker");
|
||||||
|
await writeFile(join(source, "selected.rep", "data"), "original program");
|
||||||
|
return { source, destination };
|
||||||
|
};
|
||||||
|
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"copies and fingerprints only the selected project, leaving unrelated siblings untouched",
|
||||||
|
async () => {
|
||||||
|
const { source, destination } = await fixture();
|
||||||
|
await symlink("unrelated-missing-project", join(source, "unrelated.gpr"));
|
||||||
|
const commitment = await resolveGhidraProject(
|
||||||
|
{ path: join(source, "selected.gpr"), programName: "target.elf" },
|
||||||
|
{ path: "/tmp/target.elf" },
|
||||||
|
resources,
|
||||||
|
);
|
||||||
|
await copyExistingGhidraProject(source, destination, {
|
||||||
|
projectName: "selected",
|
||||||
|
expectedSha256: commitment.sha256,
|
||||||
|
resources,
|
||||||
|
});
|
||||||
|
expect((await readdir(destination)).sort()).toEqual([
|
||||||
|
"selected.gpr",
|
||||||
|
"selected.rep",
|
||||||
|
]);
|
||||||
|
expect(await fingerprintGhidraProject(source, "selected", resources)).toBe(
|
||||||
|
commitment.sha256,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await readFile(join(destination, "selected.rep", "data"), "utf8"),
|
||||||
|
).toBe("original program");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("rejects a project whose copied content differs from the committed analysis profile", async () => {
|
||||||
|
const { source, destination } = await fixture();
|
||||||
|
const original = await fingerprintGhidraProject(
|
||||||
|
source,
|
||||||
|
"selected",
|
||||||
|
resources,
|
||||||
|
);
|
||||||
|
await writeFile(join(source, "selected.rep", "data"), "modified program");
|
||||||
|
expect(
|
||||||
|
await fingerprintGhidraProject(source, "selected", resources),
|
||||||
|
).not.toBe(original);
|
||||||
|
await expect(
|
||||||
|
copyExistingGhidraProject(source, destination, {
|
||||||
|
projectName: "selected",
|
||||||
|
expectedSha256: original,
|
||||||
|
resources,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
_tag: "AnalysisArtifactChangedError",
|
||||||
|
cause: { name: "RegularFileChangedError" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses an opt-in project once for shared CLI/MCP configuration and rejects import overrides", () => {
|
||||||
|
const environment = {
|
||||||
|
REA_GHIDRA_PROJECT_PATH: join(process.cwd(), "existing.gpr"),
|
||||||
|
REA_GHIDRA_PROGRAM_NAME: "program.elf",
|
||||||
|
};
|
||||||
|
expect(parseConfig(environment)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
ghidraExistingProject: {
|
||||||
|
path: environment.REA_GHIDRA_PROJECT_PATH,
|
||||||
|
programName: "program.elf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const extra of [
|
||||||
|
{ REA_GHIDRA_LANGUAGE_ID: "x86:LE:64:default" },
|
||||||
|
{ REA_GHIDRA_SEED_FILE: join(process.cwd(), "seeds") },
|
||||||
|
{ REA_GHIDRA_PROGRAM_NAME: "*" },
|
||||||
|
])
|
||||||
|
expect(parseConfig({ ...environment, ...extra }).ok).toBe(false);
|
||||||
|
expect(parseConfig({ REA_GHIDRA_PROGRAM_NAME: "program.elf" }).ok).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an invalid derived program name at its selection boundary", async () => {
|
||||||
|
const { source } = await fixture();
|
||||||
|
await expect(
|
||||||
|
resolveGhidraProject(
|
||||||
|
{ path: join(source, "selected.gpr") },
|
||||||
|
{ path: "/tmp/*" },
|
||||||
|
resources,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
_tag: "ConfigurationError",
|
||||||
|
settings: [{ setting: "REA_GHIDRA_PROGRAM_NAME" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives a bundle program from the resolved executable while preserving explicit names", async () => {
|
||||||
|
const { source } = await fixture();
|
||||||
|
const target = {
|
||||||
|
path: join(source, "Demo.app", "Contents", "MacOS", "ActualExecutable"),
|
||||||
|
sourcePath: join(source, "Demo.app"),
|
||||||
|
};
|
||||||
|
const selection = { path: join(source, "selected.gpr") };
|
||||||
|
expect(
|
||||||
|
(await resolveGhidraProject(selection, target, resources)).program_name,
|
||||||
|
).toBe("ActualExecutable");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await resolveGhidraProject(
|
||||||
|
{ ...selection, programName: "CallerSelected" },
|
||||||
|
target,
|
||||||
|
resources,
|
||||||
|
)
|
||||||
|
).program_name,
|
||||||
|
).toBe("CallerSelected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows selected project files beside an owned runtime under the same parent", async () => {
|
||||||
|
const { source } = await fixture();
|
||||||
|
const destination = join(source, "owned-runtime", "project");
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
const sha256 = await fingerprintGhidraProject(source, "selected", resources);
|
||||||
|
await copyExistingGhidraProject(source, destination, {
|
||||||
|
projectName: "selected",
|
||||||
|
expectedSha256: sha256,
|
||||||
|
resources,
|
||||||
|
});
|
||||||
|
expect((await readdir(destination)).sort()).toEqual([
|
||||||
|
"selected.gpr",
|
||||||
|
"selected.rep",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, "caller stopped", { kind: "stop-project-read" }])(
|
||||||
|
"preserves project cancellation and its exact reason: %j",
|
||||||
|
async (reason) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort(reason);
|
||||||
|
await expect(
|
||||||
|
resolveGhidraProject(
|
||||||
|
{ path: join(process.cwd(), "absent.gpr") },
|
||||||
|
{ path: "/tmp/target" },
|
||||||
|
resources,
|
||||||
|
controller.signal,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
import { ArtifactFileCancelled } from "../../src/artifacts/ArtifactFileOperation.js";
|
||||||
|
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||||
|
import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js";
|
||||||
|
import { resolveGhidraProject } from "../../src/ghidra/GhidraProjectSnapshot.js";
|
||||||
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
|
const fault = vi.hoisted(() => ({ reason: undefined as unknown }));
|
||||||
|
vi.mock(
|
||||||
|
"../../src/artifacts/ArtifactFileOperation.js",
|
||||||
|
async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<
|
||||||
|
typeof import("../../src/artifacts/ArtifactFileOperation.js")
|
||||||
|
>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
withArtifactRegularFile: async () => {
|
||||||
|
throw new ArtifactFileCancelled({
|
||||||
|
reason: fault.reason,
|
||||||
|
outcome: { kind: "completed", value: undefined },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([null, "cancel file read", { source: "caller" }])(
|
||||||
|
"preserves an owned file-read cancellation through project profile resolution: %j",
|
||||||
|
async (reason) => {
|
||||||
|
const root = await createTestTempDirectory("rea-ghidra-read-cancel-");
|
||||||
|
await mkdir(join(root, "selected.rep"));
|
||||||
|
await writeFile(join(root, "selected.gpr"), "marker");
|
||||||
|
const resources = new ArtifactResourceScope();
|
||||||
|
fault.reason = reason;
|
||||||
|
try {
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
await resolveGhidraProject(
|
||||||
|
{ path: join(root, "selected.gpr") },
|
||||||
|
{ path: join(root, "target") },
|
||||||
|
resources,
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
failure = cause;
|
||||||
|
}
|
||||||
|
expect(failure).toBeInstanceOf(AnalysisCancelledError);
|
||||||
|
if (!(failure instanceof AnalysisCancelledError))
|
||||||
|
throw new Error("Expected cancellation");
|
||||||
|
expect(failure.cause).toBe(reason);
|
||||||
|
} finally {
|
||||||
|
await resources.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in new issue
Block a user