mirror of
https://github.com/morluto/rea.git
synced 2026-11-04 22:06:10 +00:00
chore: merge main into recorder saved values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9f2145609e
87 files changed
+4818
-753
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
|
||||
`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
|
||||
|
||||
`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
|
||||
|
||||
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.
|
||||
|
||||
## Offline PE resource inventory
|
||||
|
||||
@@ -434,6 +434,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"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) {
|
||||
case "read_binary_inventory_summary" -> binaryInventorySummary(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 {
|
||||
boolean timedOut = analysisTimeoutOccurred();
|
||||
JsonObject provider = new JsonObject();
|
||||
@@ -511,14 +517,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.addProperty("run_id", descriptor.runId);
|
||||
result.addProperty("profile_digest", descriptor.profileDigest);
|
||||
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("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.add("capabilities", GSON.toJsonTree(
|
||||
java.util.Arrays.stream(CAPABILITIES)
|
||||
.filter(value -> !readOnly || !(value.equals("annotate_native_function")
|
||||
|| value.equals("set_address_name") || value.equals("set_addresses_names")))
|
||||
.filter(value -> !readOnly || !isMutatingOperation(value))
|
||||
.toArray(String[]::new)
|
||||
));
|
||||
result.add("target", target);
|
||||
@@ -4752,6 +4760,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
keys.add("analysis_extensions");
|
||||
if (parsed.isJsonObject() && parsed.getAsJsonObject().has("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);
|
||||
int decompileTimeout = 0;
|
||||
if (object.has("decompile_timeout_seconds")) {
|
||||
@@ -4768,7 +4778,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
requireString(object, "provider_version"),
|
||||
requireString(object, "profile_digest"),
|
||||
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 profileDigest,
|
||||
JsonArray analysisExtensions,
|
||||
int decompileTimeoutSeconds
|
||||
int decompileTimeoutSeconds,
|
||||
boolean existingProject
|
||||
) {}
|
||||
private record Request(int id, String method, JsonObject params, Integer decompileTimeoutSeconds) {}
|
||||
|
||||
|
||||
@@ -86,8 +86,9 @@ name it `.har`.
|
||||
manifest's, unless the capture's origin is a loopback name or an IP literal:
|
||||
the capture file then names it (`grafana.har` and `grafana.capture.json`
|
||||
become `grafana`) and a `name-from-file` warning says so. A file name that
|
||||
yields no portable label (kebab-case, at most 64 characters, not a Windows
|
||||
device name) is refused; pass `--name`.
|
||||
yields no portable label (kebab-case, at most 63 characters for a DNS service
|
||||
label, not a Windows device name or a label interpreted as an IP address) is
|
||||
refused; pass `--name`.
|
||||
- `--package-tarball` works as for the export, and `--capture-timeout-ms`
|
||||
as for `inspect-web-network-capture`.
|
||||
- `--dry-run` runs every stage and prints the report with `status: "planned"`
|
||||
@@ -985,7 +986,8 @@ serves replay. The complete tool catalog the bundle serves must also fit the
|
||||
server's advertised catalog budget before any files are written. Privately parsed JSON is sealed once so authentication
|
||||
and replay share immutable response trees rather than copying the capture repeatedly.
|
||||
`--name` replaces the manifest's portable kebab-case name as the env folder and env id
|
||||
(Windows device names such as `con` and `lpt1` are refused);
|
||||
(at most 63 characters for its DNS service label; Windows device names and
|
||||
labels interpreted as IP addresses are refused);
|
||||
`--package-tarball <path>` copies a packed `rea-agents` tarball into the env
|
||||
folder and makes the Dockerfile install it instead of the registry package and
|
||||
remove it in the same layer, which is how a checkout rather than a release is
|
||||
@@ -1003,7 +1005,12 @@ Windows inherits ACLs and REA does not establish owner-only access there. Choose
|
||||
an already private Windows destination when captured content needs it.
|
||||
|
||||
The Dockerfile sets `MCP_HOST=0.0.0.0` and `MCP_PORT=18765`, the port the
|
||||
framework maps for an MCP server env; the framework's own deploy supplies
|
||||
framework maps for an MCP server env, and `MCP_ALLOWED_HOSTS` naming the loopback
|
||||
addresses and the environment name, because the container binds outside loopback
|
||||
and the server refuses to serve on such a bind without an operator-declared
|
||||
allowlist: the framework's published port is reached over loopback and its
|
||||
gateway proxies to the env container by the compose service name (the
|
||||
environment name). The framework's own deploy supplies
|
||||
`MCP_HOST`, `MCP_PORT` and `ENVIRONMENT_NAME`, so the served card carries the
|
||||
env's name. It installs Debian's `python3` because the framework's local
|
||||
sandbox probes a container's readiness with `python3` inside it, which the slim
|
||||
|
||||
+23
-4
@@ -738,8 +738,18 @@ rea mcp --http --port 8765
|
||||
```
|
||||
|
||||
`--host` and `--port` default to `MCP_HOST` and `MCP_PORT` from the environment,
|
||||
then to `127.0.0.1` and an ephemeral port. The bound URL is written to stderr as
|
||||
one JSON log line (`"msg":"MCP transport listening"`) whatever `REA_LOG_LEVEL`
|
||||
then to `127.0.0.1` and an ephemeral port. A bind outside loopback requires
|
||||
`MCP_ALLOWED_HOSTS` or `--allowed-hosts`, a comma-separated list of client host
|
||||
names or IP addresses without URLs or ports. For example:
|
||||
|
||||
```sh
|
||||
rea mcp --http --host 0.0.0.0 --allowed-hosts 127.0.0.1,rea.lan
|
||||
```
|
||||
|
||||
Requests with a different `Host` or browser `Origin` receive `403`; a missing
|
||||
`Origin` is accepted. The announced client URL uses the bound hostname when
|
||||
admitted, otherwise the first declared hostname, with the actual bound port.
|
||||
It is written to stderr as one JSON log line (`"msg":"MCP transport listening"`) whatever `REA_LOG_LEVEL`
|
||||
is set to; nothing is written to stdout. `rea mcp --http --help` lists the accepted options. The listener serves
|
||||
one client at a time: a session ends with the client's `DELETE`, and a client
|
||||
that connects while another session is open takes it over, so a client that
|
||||
@@ -777,9 +787,18 @@ rea environment-serve manifest.json --evidence capture.json --port 8765
|
||||
```
|
||||
|
||||
`--host` and `--port` resolve like `rea mcp --http`: flags, then `MCP_HOST` and
|
||||
`MCP_PORT`, then `127.0.0.1` and an ephemeral port. `ENVIRONMENT_NAME` renames
|
||||
`MCP_PORT`, then `127.0.0.1` and an ephemeral port. A non-loopback bind requires
|
||||
`MCP_ALLOWED_HOSTS` with the same hostname rules and Host/Origin gates as above.
|
||||
`environment-serve` uses this environment setting; `--allowed-hosts` belongs to
|
||||
`rea mcp --http`. For example:
|
||||
|
||||
```sh
|
||||
MCP_ALLOWED_HOSTS=127.0.0.1,rea.lan rea environment-serve manifest.json --evidence capture.json --host 0.0.0.0
|
||||
```
|
||||
|
||||
`ENVIRONMENT_NAME` renames
|
||||
the served environment; set but blank, it is refused as `configuration_invalid`
|
||||
like a blank `MCP_HOST`. The bound URL is written to stderr as one JSON log line
|
||||
like a blank `MCP_HOST`. The admitted client URL is written to stderr as one JSON log line
|
||||
(`"msg":"MCP transport listening"`). See
|
||||
[agent environments](agent-environments.md) for what the state and replay
|
||||
return and do not establish.
|
||||
|
||||
+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.
|
||||
|
||||
Each verified session uses an ephemeral temporary project and isolated
|
||||
home/cache/config/temp paths. REA passes `-readOnly`, `-deleteProject`, uses
|
||||
Ghidra's default analysis settings, and loads its packaged Java
|
||||
bridge via `-scriptPath`; it never opens an existing user project. Linux and
|
||||
home/cache/config/temp paths. By default REA passes `-readOnly`, `-deleteProject`,
|
||||
uses Ghidra's default analysis settings, and loads its packaged Java
|
||||
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
|
||||
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
|
||||
@@ -1181,7 +1182,7 @@ original process owner for a later cleanup retry.
|
||||
The experimental Windows transport uses authenticated IPv4 loopback with a
|
||||
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
|
||||
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,
|
||||
@@ -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
|
||||
`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 detects the language from the executable header. To set it (for example, Borland C++ PE images):
|
||||
|
||||
+12
-1
@@ -817,7 +817,12 @@ the bound address is local; names are added only after classifying the actual IP
|
||||
On such a bind, requests whose `Host` header is outside the SDK loopback allowlist plus
|
||||
the configured name and the bound address are refused with `403`, and a browser
|
||||
`Origin` outside that allowlist is refused the same way (a missing `Origin`
|
||||
passes); a non-loopback bind relaxes both checks and logs the relaxation. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, a target containing a raw fragment delimiter (`#`), or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Percent-encoded `%23` remains valid query data. Only an `initialize` object can replace an open session. A sessionless array containing initialize, including a one-element array, is refused with HTTP `400` before session creation or handover. Scoped
|
||||
passes). A non-loopback bind requires `MCP_ALLOWED_HOSTS`, or
|
||||
`--allowed-hosts` for `rea mcp --http`, listing every client host name or IP
|
||||
address. Configuration validates and canonicalizes complete tokens once;
|
||||
URLs, ports and empty entries are refused. Both Host and Origin checks remain
|
||||
active on that declared list. The announced URL uses the bound hostname if
|
||||
admitted, otherwise the first declared hostname, with the actual bound port. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, a target containing a raw fragment delimiter (`#`), or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Percent-encoded `%23` remains valid query data. Only an `initialize` object can replace an open session. A sessionless array containing initialize, including a one-element array, is refused with HTTP `400` before session creation or handover. Scoped
|
||||
IPv6 literals (`fe80::1%eth0`) are refused before binding, because no URL can
|
||||
name them. Listener errors reach the runtime's connection-loss handler only
|
||||
after a successful bind; a bind failure prints the listen-failure message alone. Request bodies are bounded by the SDK's
|
||||
@@ -1000,6 +1005,12 @@ A successful MCP initialize handshake establishes the REA connection.
|
||||
that Ghidra has finished importing the target. The first Ghidra-backed query,
|
||||
such as `binary_overview`, starts the engine and waits for import, default
|
||||
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:
|
||||
|
||||
|
||||
@@ -33,12 +33,14 @@ previous increment.
|
||||
copies of the inputs (mode `0600` on POSIX; Windows retains inherited ACLs
|
||||
and does not provide an owner-only guarantee), `artifacts/check_<name>/verify.py` and
|
||||
`tasks/smoke.json`, where `<name>` defaults to the manifest's kebab-case name
|
||||
and `--name` must be a kebab-case label of at most 64 characters that is not a
|
||||
and `--name` must be a kebab-case label of at most 63 characters for its DNS label that is not a
|
||||
device name Windows reserves (`con`, `prn`, `aux`, `nul`, `com1`–`com9`,
|
||||
`lpt1`–`lpt9`), since the env folder could not be created there.
|
||||
`lpt1`–`lpt9`). Its HTTP hostname identity must remain unchanged, so numeric
|
||||
labels interpreted as IP addresses are refused before planning.
|
||||
- The Dockerfile uses the Node version pinned in `.nvmrc` with the
|
||||
`bookworm-slim` variant, sets
|
||||
`MCP_HOST=0.0.0.0` and `MCP_PORT=18765`, installs Debian's `python3` for the
|
||||
`MCP_HOST=0.0.0.0`, `MCP_PORT=18765`, and
|
||||
`MCP_ALLOWED_HOSTS=127.0.0.1,localhost,<name>`, installs Debian's `python3` for the
|
||||
framework's readiness probe, installs
|
||||
`rea-agents@<PRODUCT_IDENTITY.packageVersion>` from the registry or, with
|
||||
`--package-tarball`, copies the tarball as `rea-agents.tgz`, installs it
|
||||
|
||||
@@ -104,8 +104,10 @@ unreadable path; the process-boundary test asserts a `143` exit with a
|
||||
released port. Replay validates no supplied values, performs no network
|
||||
activity and establishes no authorization, response semantics or state
|
||||
transitions. One MCP client is served at a time, as the shared listener
|
||||
defines. Non-loopback binds relax the `Host` check and are not exercised; a
|
||||
listener `error` after binding is wired but not provoked by a test.
|
||||
defines. Non-loopback binds require `MCP_ALLOWED_HOSTS` and retain both Host
|
||||
and Origin checks. Shared listener regressions exercise a wildcard bind,
|
||||
configuration refusal, port release and admitted/foreign request headers;
|
||||
the original environment-serving fixture runs below used loopback.
|
||||
|
||||
## Resource and cancellation boundaries
|
||||
|
||||
|
||||
@@ -235,8 +235,10 @@ parameters cannot mismatch, and a call without arguments is exact on the
|
||||
tool's first record. Request bodies on write tools are accepted and ignored.
|
||||
The post-bind listener `error` path is wired and type-checked but not
|
||||
provoked by a test, since the shared listener does not expose its
|
||||
`node:http` server. Non-loopback binds and the relaxed `Host` check were not
|
||||
exercised; the Python exchange, curl checks and every test used `127.0.0.1`.
|
||||
`node:http` server. These recorded runs predate the required non-loopback Host/Origin allowlist;
|
||||
the Python exchange, curl checks and tests recorded here used `127.0.0.1`.
|
||||
The current contract and later non-loopback coverage are described in the
|
||||
[transport specification](../http-transport/spec.md).
|
||||
Replay selection and body parsing were measured on the 28-record fixture
|
||||
only; no large-capture timing was taken. Resident-set figures are `ps` samples
|
||||
of the serving process before and after one request each. Token usage and
|
||||
|
||||
@@ -67,8 +67,11 @@ initialize, tools/list, `binary_session` and `DELETE` with the SDK streamable
|
||||
HTTP client, then proves `binary_session` still answers in the next session and
|
||||
in a session that replaced one left open; a process-boundary test asserts
|
||||
`143`/`130` exits and a released port. Concurrent clients are not supported:
|
||||
the newest `initialize` owns the server. The non-loopback relaxation is covered
|
||||
only through its pure allowlist helper; no wide bind runs in tests. Response
|
||||
the newest `initialize` owns the server. Non-loopback binds require the
|
||||
operator's parsed hostname allowlist, retain Host/Origin gates, and announce a
|
||||
client URL admitted by that list. Current listener regressions exercise a
|
||||
wildcard bind, refusal/port release and a direct SDK connection to the
|
||||
announced endpoint. Response
|
||||
budgets keep their stdio-derived floor; registrations, `rea mcp doctor` and
|
||||
`server.json` stay stdio.
|
||||
|
||||
|
||||
@@ -145,9 +145,9 @@ The concurrent-`initialize` case is exercised at the slot with in-memory
|
||||
`Request` objects, where both calls advance in lockstep; over TCP two
|
||||
connections are served in separate event-loop turns, so the race is reachable
|
||||
only in-process and the listener-level behaviour remains "the newest client
|
||||
owns the server, the other sees `400` or `404`". The non-loopback relaxation
|
||||
path is covered only through the pure allowlist helper and the loopback smoke
|
||||
runs; no wide bind was exercised. Sessions are served one at a time;
|
||||
owns the server, the other sees `400` or `404`". Those recorded runs predate the mandatory non-loopback allowlist and did not
|
||||
exercise a wide bind. The current requirement and later listener regression
|
||||
coverage are described in the [specification](spec.md). Sessions are served one at a time;
|
||||
concurrent clients are not supported. The two failures that reproduce on the
|
||||
clean clone are environmental (macOS with Node 22.23.1 and npm 12.0.2) and are
|
||||
left for their owners; the load-induced timeouts were classified by isolated
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,7 +28,12 @@ export const requireMcpToolError = (result) => {
|
||||
return analysisErrorProjectionSchema.parse(jsonValue(result).error);
|
||||
};
|
||||
|
||||
const requireOutput = (result, operation, contract) => {
|
||||
/** Validate the complete output against its exact named contract, retaining inline Evidence. */
|
||||
export const requireMcpOutput = (
|
||||
result,
|
||||
operation,
|
||||
contract = toolContract(operation),
|
||||
) => {
|
||||
if (result.isError === true)
|
||||
throw new Error(`${operation} failed: ${mcpTextValue(result)}`);
|
||||
if (contract.name !== operation)
|
||||
@@ -96,7 +101,7 @@ export const requireMcpEvidenceResult = (result, operation) => {
|
||||
const contract = toolContract(operation);
|
||||
if (operationResultKey(contract) !== "normalized_result")
|
||||
throw new Error(`${operation} requires the lifecycle result extractor`);
|
||||
return requireOutput(result, operation, contract).normalized_result;
|
||||
return requireMcpOutput(result, operation, contract).normalized_result;
|
||||
};
|
||||
|
||||
/** Validate a lifecycle tool output and return its operation result. */
|
||||
@@ -104,7 +109,7 @@ export const requireMcpLifecycleResult = (result, operation) => {
|
||||
const contract = toolContract(operation);
|
||||
if (operationResultKey(contract) !== "result")
|
||||
throw new Error(`${operation} requires the Evidence result extractor`);
|
||||
const projected = requireOutput(result, operation, contract).result;
|
||||
const projected = requireMcpOutput(result, operation, contract).result;
|
||||
return operation === "binary_session"
|
||||
? expandMcpSessionStatus(projected)
|
||||
: projected;
|
||||
@@ -114,7 +119,7 @@ export const requireMcpLifecycleResult = (result, operation) => {
|
||||
export const requireMcpEvidenceBundle = (result) => {
|
||||
const operation = "get_evidence_bundle";
|
||||
const contract = toolContract(operation);
|
||||
const bundle = requireOutput(result, operation, contract).result;
|
||||
const bundle = requireMcpOutput(result, operation, contract).result;
|
||||
if (bundle.kind === "evidence-bundle-summary")
|
||||
throw new Error(
|
||||
"get_evidence_bundle returned summary metadata instead of complete Evidence",
|
||||
@@ -138,7 +143,7 @@ export const requireMcpOperationResult = (
|
||||
contract = toolContract(operation),
|
||||
) => {
|
||||
const key = operationResultKey(contract);
|
||||
const projected = requireOutput(result, operation, contract)[key];
|
||||
const projected = requireMcpOutput(result, operation, contract)[key];
|
||||
return operation === "binary_session"
|
||||
? expandMcpSessionStatus(projected)
|
||||
: projected;
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
runOwnedCommand,
|
||||
} from "../dist/process/OwnedCommand.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
requireMcpOutput,
|
||||
requireMcpToolError,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { sectionNameFixtures } from "./lib/elf-section-name-fixtures.mjs";
|
||||
import { withLargeResultMcp } from "./lib/large-result-mcp.mjs";
|
||||
@@ -1549,14 +1552,12 @@ async function inspect(
|
||||
},
|
||||
{ timeout: 300_000 },
|
||||
);
|
||||
const value = JSON.parse(mcpTextValue(response));
|
||||
if (category !== undefined) {
|
||||
assert.equal(response.isError, true);
|
||||
assert.equal(value.error.category, category, JSON.stringify(value.error));
|
||||
return value.error;
|
||||
const error = requireMcpToolError(response);
|
||||
assert.equal(error.category, category, JSON.stringify(error));
|
||||
return error;
|
||||
}
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
envelope = value;
|
||||
envelope = requireMcpOutput(response, "inspect_binary_layout");
|
||||
} else {
|
||||
let response;
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,10 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
requireMcpOutput,
|
||||
requireMcpToolError,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const solcModule = process.env.REA_VERIFY_SOLC_MODULE;
|
||||
@@ -103,11 +106,10 @@ try {
|
||||
name: "inspect_evm_interface",
|
||||
arguments: { path, encoding },
|
||||
});
|
||||
value = JSON.parse(mcpTextValue(reply));
|
||||
if (expectedCategory !== undefined) {
|
||||
assert.equal(reply.isError, true);
|
||||
value = value.error;
|
||||
} else assert.notEqual(reply.isError, true, mcpTextValue(reply));
|
||||
value =
|
||||
expectedCategory !== undefined
|
||||
? requireMcpToolError(reply)
|
||||
: requireMcpOutput(reply, "inspect_evm_interface");
|
||||
}
|
||||
if (expectedCategory !== undefined) {
|
||||
assert.equal(value.category, expectedCategory, JSON.stringify(value));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import { requireMcpOutput } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
closeVerifierResources,
|
||||
withVerifierCleanup,
|
||||
@@ -234,8 +234,7 @@ async function run(operation, input) {
|
||||
{ name: operation, arguments: input },
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
const mcp = contract.outputSchema.parse(JSON.parse(mcpTextValue(response)));
|
||||
const mcp = requireMcpOutput(response, operation, contract);
|
||||
assert.deepEqual(cli.normalized_result, mcp.normalized_result);
|
||||
assert.deepEqual(cli.subject, mcp.subject);
|
||||
assert.equal(cli.subject.local_path, input.path);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { goBinarySchema } from "../dist/domain/go/goBinary.js";
|
||||
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import { requireMcpOutput } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
@@ -359,8 +359,7 @@ async function inspect(mode, path) {
|
||||
{ name: "inspect_go_binary", arguments: { path } },
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
envelope = JSON.parse(mcpTextValue(response));
|
||||
envelope = requireMcpOutput(response, "inspect_go_binary");
|
||||
}
|
||||
const evidence = parseEvidence(envelope);
|
||||
assert.equal(evidence.subject.local_path, path);
|
||||
|
||||
@@ -6,9 +6,11 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { absoluteCallerPath } from "../dist/filesystem/callerPath.js";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { analysisViewResultSchema } from "../dist/domain/analysisView/analysisView.js";
|
||||
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
requireMcpOutput,
|
||||
requireMcpToolError,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import { hashVerifierFile } from "./lib/verifier-file-resources.mjs";
|
||||
|
||||
@@ -43,8 +45,7 @@ const call = async (name, input) => {
|
||||
{ name, arguments: input },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
return JSON.parse(mcpTextValue(response));
|
||||
return requireMcpOutput(response, name);
|
||||
};
|
||||
const resources = new ArtifactResourceScope();
|
||||
await withVerifierCleanup(async () => {
|
||||
@@ -74,7 +75,7 @@ await withVerifierCleanup(async () => {
|
||||
if (complete.isError === true) {
|
||||
// Large inventories exceed the stdio budget; the result must stay
|
||||
// retained and addressable rather than truncated.
|
||||
const { error } = JSON.parse(mcpTextValue(complete));
|
||||
const error = requireMcpToolError(complete);
|
||||
assert.equal(error.code, "resource_constraint");
|
||||
assert.equal(error.details.reported_limits.boundary, "mcp-response");
|
||||
assert.match(error.details.reported_limits.evidence_id, /^ev_/u);
|
||||
@@ -82,9 +83,7 @@ await withVerifierCleanup(async () => {
|
||||
`[ok] MCP complete delivery refused at the stdio budget (${error.details.reported_limits.response_bytes_at_least} bytes) with retained Evidence`,
|
||||
);
|
||||
} else {
|
||||
const mcp = contract.outputSchema.parse(
|
||||
JSON.parse(mcpTextValue(complete)),
|
||||
);
|
||||
const mcp = requireMcpOutput(complete, operation, contract);
|
||||
assert.deepEqual(mcp.normalized_result, report);
|
||||
console.log("[ok] CLI/MCP complete Evidence parity");
|
||||
}
|
||||
@@ -108,9 +107,7 @@ await withVerifierCleanup(async () => {
|
||||
});
|
||||
console.log("[ok] image type ranges and type method ranges agree");
|
||||
|
||||
const summary = contract.outputSchema.parse(
|
||||
await call(operation, { path }),
|
||||
).normalized_result;
|
||||
const summary = (await call(operation, { path })).normalized_result;
|
||||
assert.equal(summary.summary.magic, report.magic);
|
||||
assert.deepEqual(summary.summary.counts, {
|
||||
sections: report.sections.length,
|
||||
@@ -135,37 +132,33 @@ await withVerifierCleanup(async () => {
|
||||
generic_method_instance_states: null,
|
||||
generic_method_issues: null,
|
||||
});
|
||||
const page = analysisViewResultSchema.parse(
|
||||
(
|
||||
await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
},
|
||||
view: { kind: "il2cpp-metadata", collection: "methods", limit: 16 },
|
||||
})
|
||||
).normalized_result,
|
||||
);
|
||||
const page = (
|
||||
await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
},
|
||||
view: { kind: "il2cpp-metadata", collection: "methods", limit: 16 },
|
||||
})
|
||||
).normalized_result;
|
||||
assert.deepEqual(
|
||||
page.items,
|
||||
report.methods.slice(0, 16).map((row, index) => ({ ...row, index })),
|
||||
);
|
||||
const last = analysisViewResultSchema.parse(
|
||||
(
|
||||
await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "types",
|
||||
offset: Math.max(0, report.types.length - 4),
|
||||
limit: 16,
|
||||
},
|
||||
})
|
||||
).normalized_result,
|
||||
);
|
||||
const last = (
|
||||
await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "types",
|
||||
offset: Math.max(0, report.types.length - 4),
|
||||
limit: 16,
|
||||
},
|
||||
})
|
||||
).normalized_result;
|
||||
const lastOffset = Math.max(0, report.types.length - 4);
|
||||
assert.deepEqual(
|
||||
last.items,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import { requireMcpOutput } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
closeVerifierResources,
|
||||
withVerifierCleanup,
|
||||
@@ -123,8 +123,7 @@ async function run(name, input) {
|
||||
{ name, arguments: input },
|
||||
{ timeout: 360_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
const mcp = contract.outputSchema.parse(JSON.parse(mcpTextValue(response)));
|
||||
const mcp = requireMcpOutput(response, name, contract);
|
||||
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
|
||||
assert.deepEqual(mcp.subject, cli.subject);
|
||||
if (input.path !== undefined) {
|
||||
|
||||
@@ -57,9 +57,10 @@ import { admitGamecubeDol } from "../domain/gamecubeDolAdmission.js";
|
||||
import { DOL_HEADER_SIZE } from "../domain/gamecubeDol.js";
|
||||
import {
|
||||
mzWindowsHeaderLayoutLimitation,
|
||||
observeMzWindowsHeader,
|
||||
parseMzHeaderEvidence,
|
||||
withMzWindowsHeaderSignature,
|
||||
type DosMzHeader,
|
||||
type MzHeaderEvidence,
|
||||
type MzWindowsHeaderObservation,
|
||||
} from "../domain/dosMz.js";
|
||||
import {
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
parseCodeViewData,
|
||||
claimsExecutableHeader,
|
||||
parseExecutableHeader,
|
||||
parseMzExecutableMetadata,
|
||||
parsePeRecordFacts,
|
||||
peDebugDirectoryEntry,
|
||||
peCodeViewDataRanges,
|
||||
@@ -877,7 +879,12 @@ const readExecutableMetadata = async (
|
||||
offset,
|
||||
bytes: signature.subarray(0, observedSignature.bytesRead),
|
||||
};
|
||||
evidence = parseMzHeaderEvidence(bytes, fileSize, signatureEvidence);
|
||||
evidence = withMzWindowsHeaderSignature(
|
||||
bytes,
|
||||
fileSize,
|
||||
evidence,
|
||||
signatureEvidence,
|
||||
);
|
||||
}
|
||||
const { windowsHeader } = evidence;
|
||||
if (windowsHeader.state === "declared") {
|
||||
@@ -890,31 +897,28 @@ const readExecutableMetadata = async (
|
||||
mz_windows_header: { ...windowsHeader },
|
||||
},
|
||||
});
|
||||
return readPeMetadata(
|
||||
handle,
|
||||
windowsHeader.offset,
|
||||
bytes,
|
||||
return readPeMetadata(handle, windowsHeader.offset, {
|
||||
mzPrefix: bytes,
|
||||
fileSize,
|
||||
mzEvidence: evidence,
|
||||
signal,
|
||||
);
|
||||
});
|
||||
}
|
||||
// MZ relocation records may extend past the initial probe; their format
|
||||
// bounds are at most 65535 records inside a 65535-paragraph header.
|
||||
const tableEnd =
|
||||
bytes.length >= 28
|
||||
? bytes.readUInt16LE(24) + bytes.readUInt16LE(6) * 4
|
||||
: 0;
|
||||
const headerBytes = bytes.length >= 28 ? bytes.readUInt16LE(8) * 16 : 0;
|
||||
if (
|
||||
tableEnd > bytes.length &&
|
||||
tableEnd <= headerBytes &&
|
||||
tableEnd <= fileSize
|
||||
) {
|
||||
const table = Buffer.alloc(tableEnd);
|
||||
const observed = await handle.read(table, 0, table.length, 0);
|
||||
const { relocationTableRead } = evidence;
|
||||
if (relocationTableRead !== undefined) {
|
||||
const table = Buffer.alloc(relocationTableRead.byteLength);
|
||||
const observed = await handle.read(
|
||||
table,
|
||||
0,
|
||||
table.length,
|
||||
relocationTableRead.offset,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const observedBytes = table.subarray(0, observed.bytesRead);
|
||||
evidence = parseMzHeaderEvidence(
|
||||
table.subarray(0, observed.bytesRead),
|
||||
observedBytes,
|
||||
fileSize,
|
||||
signatureEvidence,
|
||||
);
|
||||
@@ -922,21 +926,11 @@ const readExecutableMetadata = async (
|
||||
return err(
|
||||
unresolvedMzHeaderFailure(evidence.windowsHeader, evidence.dos),
|
||||
);
|
||||
return parseExecutableHeader(
|
||||
table.subarray(0, observed.bytesRead),
|
||||
hostArchitecture,
|
||||
fileSize,
|
||||
signatureEvidence,
|
||||
);
|
||||
return parseMzExecutableMetadata(observedBytes, evidence);
|
||||
}
|
||||
if (windowsHeader.state === "candidate")
|
||||
return err(unresolvedMzHeaderFailure(windowsHeader, evidence.dos));
|
||||
return parseExecutableHeader(
|
||||
bytes,
|
||||
hostArchitecture,
|
||||
fileSize,
|
||||
signatureEvidence,
|
||||
);
|
||||
return parseMzExecutableMetadata(bytes, evidence);
|
||||
}
|
||||
if (bytes.length >= 4 && bytes.toString("hex", 0, 4) === "7f454c46")
|
||||
return readElfMetadata(handle, bytes, {
|
||||
@@ -1264,9 +1258,17 @@ const readFatSliceHeaderEvidence = async (
|
||||
const readPeMetadata = async (
|
||||
handle: FileHandle,
|
||||
offset: number,
|
||||
mzPrefix: Buffer,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
{
|
||||
mzPrefix,
|
||||
fileSize,
|
||||
mzEvidence,
|
||||
signal,
|
||||
}: {
|
||||
readonly mzPrefix: Buffer;
|
||||
readonly fileSize: number;
|
||||
readonly mzEvidence: MzHeaderEvidence;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
},
|
||||
): Promise<Result<ExecutableMetadata, string>> => {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const fileHeader = Buffer.alloc(24);
|
||||
@@ -1279,10 +1281,12 @@ const readPeMetadata = async (
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (fileHeaderRead.bytesRead !== fileHeader.length)
|
||||
return err("invalid or truncated PE header");
|
||||
const windowsHeader = observeMzWindowsHeader(mzPrefix, fileSize, {
|
||||
offset,
|
||||
bytes: fileHeader.subarray(0, 4),
|
||||
});
|
||||
const { windowsHeader } = withMzWindowsHeaderSignature(
|
||||
mzPrefix,
|
||||
fileSize,
|
||||
mzEvidence,
|
||||
{ offset, bytes: fileHeader.subarray(0, 4) },
|
||||
);
|
||||
if (windowsHeader.state !== "declared")
|
||||
return err("invalid Windows new-header signature in MZ image");
|
||||
if (["NE", "LE", "LX"].includes(windowsHeader.signature))
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLinuxHopperReleases } from "./HopperReleaseFeed.js";
|
||||
import {
|
||||
parseLinuxHopperReleases,
|
||||
parseMacHopperReleases,
|
||||
parseHopperReleaseJson,
|
||||
} from "./HopperReleaseFeed.js";
|
||||
|
||||
const release = {
|
||||
version: "99.2.1",
|
||||
@@ -79,3 +83,41 @@ describe("Linux Hopper release feed boundary", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains complete vendor release rows including own special keys and nested observations", () => {
|
||||
const row: unknown = JSON.parse(
|
||||
JSON.stringify(release).replace(
|
||||
/\}$/u,
|
||||
',"__proto__":{"observed":true},"constructor":"vendor constructor","nested":[{"__proto__":"nested fact","future":null}]}',
|
||||
),
|
||||
);
|
||||
const parsed = parseLinuxHopperReleases({ "Ubuntu / Mint": row });
|
||||
if (!parsed.ok || !parsed.value.deb.ok) throw new Error("Missing release");
|
||||
expect(JSON.stringify(parsed.value.deb.value)).toBe(JSON.stringify(row));
|
||||
expect(Object.hasOwn(parsed.value.deb.value, "__proto__")).toBe(true);
|
||||
expect(Object.hasOwn(parsed.value.deb.value, "constructor")).toBe(true);
|
||||
});
|
||||
|
||||
it("retains the full selected macOS release row", () => {
|
||||
const row: unknown = JSON.parse(
|
||||
'{"filename":"https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg","file_length":"123","file_hash":"' +
|
||||
"a".repeat(40) +
|
||||
'","__proto__":{"observed":true},"constructor":"vendor constructor","nested":[{"__proto__":"nested fact"}]}',
|
||||
);
|
||||
const parsed = parseMacHopperReleases({ "OS X": row });
|
||||
if (!parsed.ok) throw new Error("Missing release");
|
||||
expect(JSON.stringify(parsed.value)).toBe(JSON.stringify(row));
|
||||
expect(Object.hasOwn(parsed.value, "__proto__")).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes malformed UTF-8 from a genuine replacement character and tolerates the initial BOM", () => {
|
||||
expect(
|
||||
parseHopperReleaseJson(
|
||||
new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d]),
|
||||
).ok,
|
||||
).toBe(false);
|
||||
for (const bom of ["", "\uFEFF"])
|
||||
expect(
|
||||
parseHopperReleaseJson(new TextEncoder().encode(bom + '{"x":"雪�"}')),
|
||||
).toMatchObject({ ok: true, value: { x: "雪�" } });
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
safeParseUtf8Json,
|
||||
type SafeJsonParseResult,
|
||||
} from "../domain/safeJson.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { HOPPER_DOWNLOAD_BYTES } from "./HopperDownload.js";
|
||||
import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js";
|
||||
@@ -12,6 +18,66 @@ export const HOPPER_PUBLIC_KEY_URL =
|
||||
export const HOPPER_DOWNLOAD_PREFIX =
|
||||
"https://www.hopperapp.com:443/downloader/public/";
|
||||
|
||||
/** Decode vendor/cache JSON while retaining their established initial UTF-8 BOM tolerance. */
|
||||
export const parseHopperReleaseJson = (
|
||||
bytes: Uint8Array,
|
||||
): SafeJsonParseResult =>
|
||||
safeParseUtf8Json(
|
||||
bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
||||
? bytes.subarray(3)
|
||||
: bytes,
|
||||
);
|
||||
|
||||
/** Package identity is shared by the platform-specific publisher verification workflows. */
|
||||
export const hopperArchiveIntegrityMatches = (
|
||||
bytes: Uint8Array,
|
||||
release: Readonly<{ file_length: string; file_hash: string }>,
|
||||
): boolean =>
|
||||
bytes.byteLength === Number(release.file_length) &&
|
||||
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
|
||||
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
||||
|
||||
const archiveReleaseSchema = z.object({
|
||||
filename: z.string().url(),
|
||||
file_length: z.string().regex(/^\d+$/u),
|
||||
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
});
|
||||
export type MacHopperRelease = z.infer<typeof archiveReleaseSchema> &
|
||||
Record<string, JsonValue>;
|
||||
|
||||
const selectedPlatformsSchema = z.object({
|
||||
"OS X": z.unknown().optional(),
|
||||
"Ubuntu / Mint": z.unknown().optional(),
|
||||
Fedora: z.unknown().optional(),
|
||||
Arch: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/** Admit the selected macOS row without projecting away vendor release metadata. */
|
||||
export const parseMacHopperReleases = (
|
||||
raw: unknown,
|
||||
): Result<MacHopperRelease, HopperReleaseError> => {
|
||||
const root = selectedPlatformsSchema.safeParse(raw);
|
||||
const release = jsonObjectSchema.safeParse(
|
||||
root.success ? root.data["OS X"] : undefined,
|
||||
);
|
||||
if (!release.success || !archiveReleaseSchema.safeParse(release.data).success)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"release_metadata",
|
||||
"The Hopper macOS release metadata is malformed.",
|
||||
),
|
||||
);
|
||||
const retained = release.data as MacHopperRelease;
|
||||
if (!retained.filename.startsWith(HOPPER_DOWNLOAD_PREFIX))
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"release_metadata",
|
||||
"The Hopper macOS release is outside the official download channel.",
|
||||
),
|
||||
);
|
||||
return ok(retained);
|
||||
};
|
||||
|
||||
/** Stable publisher identity; signing subkeys are admitted through this primary. */
|
||||
export const HOPPER_LINUX_PRIMARY_FINGERPRINT =
|
||||
"7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD";
|
||||
@@ -46,32 +112,27 @@ export class HopperReleaseError extends Error {
|
||||
}
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const releaseSchema = z
|
||||
.object({
|
||||
version: z.string().min(1),
|
||||
filename: z.string().url(),
|
||||
file_length: z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.refine((text) => {
|
||||
const count = Number(text);
|
||||
return Number.isSafeInteger(count) && count <= HOPPER_DOWNLOAD_BYTES;
|
||||
}),
|
||||
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
main_exe_hash: sha256,
|
||||
signature: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(text) => Buffer.from(text, "base64").toString("base64") === text,
|
||||
),
|
||||
public: z.literal("1"),
|
||||
beta: z.literal("0"),
|
||||
})
|
||||
.passthrough();
|
||||
const releaseSchema = archiveReleaseSchema.extend({
|
||||
version: z.string().min(1),
|
||||
file_length: z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.refine((text) => {
|
||||
const count = Number(text);
|
||||
return Number.isSafeInteger(count) && count <= HOPPER_DOWNLOAD_BYTES;
|
||||
}),
|
||||
main_exe_hash: sha256,
|
||||
signature: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((text) => Buffer.from(text, "base64").toString("base64") === text),
|
||||
public: z.literal("1"),
|
||||
beta: z.literal("0"),
|
||||
});
|
||||
|
||||
/** Parsed once at the feed boundary; preserve the vendor's complete release row. */
|
||||
export type LinuxHopperRelease = z.infer<typeof releaseSchema>;
|
||||
export type LinuxHopperRelease = z.infer<typeof releaseSchema> &
|
||||
Record<string, JsonValue>;
|
||||
export type LinuxHopperReleaseFeed = Readonly<
|
||||
Record<LinuxPackageFamily, Result<LinuxHopperRelease, HopperReleaseError>>
|
||||
>;
|
||||
@@ -80,8 +141,11 @@ export const parseLinuxHopperRelease = (
|
||||
raw: unknown,
|
||||
family: LinuxPackageFamily,
|
||||
): Result<LinuxHopperRelease, HopperReleaseError> => {
|
||||
const parsed = releaseSchema.safeParse(raw);
|
||||
if (!parsed.success)
|
||||
const retained = jsonObjectSchema.safeParse(raw);
|
||||
const parsed = releaseSchema.safeParse(
|
||||
retained.success ? retained.data : undefined,
|
||||
);
|
||||
if (!retained.success || !parsed.success)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"release_metadata",
|
||||
@@ -103,13 +167,13 @@ export const parseLinuxHopperRelease = (
|
||||
"The Hopper release is outside the official public Linux demo channel.",
|
||||
),
|
||||
);
|
||||
return ok(parsed.data);
|
||||
return ok(retained.data as LinuxHopperRelease);
|
||||
};
|
||||
|
||||
export const parseLinuxHopperReleases = (
|
||||
raw: unknown,
|
||||
): Result<LinuxHopperReleaseFeed, HopperReleaseError> => {
|
||||
const feed = z.record(z.string(), z.unknown()).safeParse(raw);
|
||||
const feed = selectedPlatformsSchema.safeParse(raw);
|
||||
if (!feed.success)
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
HOPPER_PUBLIC_KEY_URL,
|
||||
HOPPER_RELEASES_URL,
|
||||
HopperReleaseError,
|
||||
hopperArchiveIntegrityMatches,
|
||||
parseHopperReleaseJson,
|
||||
parseLinuxHopperRelease,
|
||||
parseLinuxHopperReleases,
|
||||
type LinuxHopperRelease,
|
||||
@@ -126,9 +128,9 @@ export class LinuxHopperPublisher {
|
||||
"release_metadata",
|
||||
"The official Hopper release feed is unavailable.",
|
||||
);
|
||||
this.#feed = parseLinuxHopperReleases(
|
||||
JSON.parse(decodeUtf8(response.bytes)),
|
||||
);
|
||||
const decoded = parseHopperReleaseJson(response.bytes);
|
||||
if (!decoded.ok) throw decoded.cause;
|
||||
this.#feed = parseLinuxHopperReleases(decoded.value);
|
||||
}
|
||||
if (!this.#feed.ok) return this.#feed;
|
||||
return this.#feed.value[family];
|
||||
@@ -171,10 +173,7 @@ export class LinuxHopperPublisher {
|
||||
signal,
|
||||
resources,
|
||||
);
|
||||
if (
|
||||
bytes.length !== Number(release.file_length) ||
|
||||
createHash("sha1").update(bytes).digest("hex") !== release.file_hash
|
||||
)
|
||||
if (!hopperArchiveIntegrityMatches(bytes, release))
|
||||
return err(
|
||||
new HopperReleaseError(
|
||||
"integrity",
|
||||
@@ -730,17 +729,14 @@ export class LinuxHopperPublisher {
|
||||
signal,
|
||||
resources,
|
||||
);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(decodeUtf8(metadata));
|
||||
} catch (cause: unknown) {
|
||||
const decoded = parseHopperReleaseJson(metadata);
|
||||
if (!decoded.ok)
|
||||
throw new HopperReleaseError(
|
||||
"release_metadata",
|
||||
"The retained Hopper package metadata is malformed.",
|
||||
{ cause },
|
||||
{ cause: decoded.cause },
|
||||
);
|
||||
}
|
||||
const parsed = cacheRecordSchema.safeParse(value);
|
||||
const parsed = cacheRecordSchema.safeParse(decoded.value);
|
||||
if (!parsed.success)
|
||||
throw new HopperReleaseError(
|
||||
"release_metadata",
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
readonly archive = new TextEncoder().encode("hopper-dmg");
|
||||
archiveOk = true;
|
||||
feedBytes: Uint8Array | undefined;
|
||||
packageDownloads = 0;
|
||||
mounts = 0;
|
||||
verification: MacHopperBundleVerification = "verified";
|
||||
existing = false;
|
||||
mountSucceeds = true;
|
||||
@@ -25,33 +28,39 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
||||
opens = 0;
|
||||
replaces = false;
|
||||
|
||||
download = (url: string) =>
|
||||
Promise.resolve(
|
||||
download = (url: string) => {
|
||||
if (!url.includes("files-api")) this.packageDownloads += 1;
|
||||
return Promise.resolve(
|
||||
url.includes("files-api")
|
||||
? {
|
||||
ok: true,
|
||||
bytes: new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
"OS X": {
|
||||
filename:
|
||||
"https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
|
||||
file_length: String(this.archive.byteLength),
|
||||
file_hash: this.badHash
|
||||
? "0".repeat(40)
|
||||
: createHash("sha1").update(this.archive).digest("hex"),
|
||||
},
|
||||
}),
|
||||
),
|
||||
bytes:
|
||||
this.feedBytes ??
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
"OS X": {
|
||||
filename:
|
||||
"https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
|
||||
file_length: String(this.archive.byteLength),
|
||||
file_hash: this.badHash
|
||||
? "0".repeat(40)
|
||||
: createHash("sha1").update(this.archive).digest("hex"),
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
: { ok: this.archiveOk, bytes: this.archive },
|
||||
);
|
||||
};
|
||||
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
|
||||
createMountDirectory = () => Promise.resolve();
|
||||
writePackage = () => Promise.resolve();
|
||||
mount = () =>
|
||||
this.mountFailure === undefined
|
||||
mount = () => {
|
||||
this.mounts += 1;
|
||||
return this.mountFailure === undefined
|
||||
? Promise.resolve(this.mountSucceeds)
|
||||
: Promise.reject(this.mountFailure);
|
||||
};
|
||||
unmount = () => {
|
||||
this.unmounts += 1;
|
||||
return Promise.resolve();
|
||||
@@ -261,3 +270,40 @@ it("retains the exact DMG temporary root with its retry capability", async () =>
|
||||
allowCleanup = true;
|
||||
await expect(retained[0]!.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses malformed UTF-8 release metadata before downloading or mounting the package", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
const feed = await host.download("files-api");
|
||||
const text = new TextDecoder().decode(feed.bytes);
|
||||
// Replacement decoding would produce valid JSON and hide the malformed transport byte.
|
||||
host.feedBytes = new Uint8Array([
|
||||
...new TextEncoder().encode('{"vendor_note":"'),
|
||||
0xff,
|
||||
...new TextEncoder().encode('",' + text.slice(1)),
|
||||
]);
|
||||
expect(await installMacHopper({}, host)).toMatchObject({
|
||||
status: "failed",
|
||||
reason: "release_metadata",
|
||||
});
|
||||
expect(host.packageDownloads).toBe(0);
|
||||
expect(host.mounts).toBe(0);
|
||||
expect(host.installs).toBe(0);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"accepts a genuine replacement character in release metadata (BOM=%s)",
|
||||
async (bom) => {
|
||||
const host = new FakeMacHopperHost();
|
||||
const feed = await host.download("files-api");
|
||||
const text = new TextDecoder()
|
||||
.decode(feed.bytes)
|
||||
.replace('{"OS X":{', '{"OS X":{"vendor_note":"雪�",');
|
||||
host.feedBytes = new TextEncoder().encode((bom ? "\uFEFF" : "") + text);
|
||||
expect(await installMacHopper({}, host)).toMatchObject({
|
||||
status: "installed",
|
||||
});
|
||||
expect(host.packageDownloads).toBe(1);
|
||||
expect(host.mounts).toBe(1);
|
||||
expect(host.installs).toBe(1);
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
@@ -13,22 +12,22 @@ import { tmpdir } from "node:os";
|
||||
import { join, posix } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { z } from "zod";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
||||
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
|
||||
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
|
||||
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
|
||||
import { downloadHopperPackage } from "./HopperDownload.js";
|
||||
import {
|
||||
downloadHopperPackage,
|
||||
HOPPER_DOWNLOAD_BYTES,
|
||||
} from "./HopperDownload.js";
|
||||
HOPPER_RELEASES_URL,
|
||||
HOPPER_DOWNLOAD_PREFIX,
|
||||
parseHopperReleaseJson,
|
||||
parseMacHopperReleases,
|
||||
hopperArchiveIntegrityMatches,
|
||||
} from "./HopperReleaseFeed.js";
|
||||
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const RELEASES_URL =
|
||||
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
||||
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
||||
const HOPPER_PUBLISHER_REQUIREMENT = macDeveloperIdRequirement(
|
||||
HOPPER_PUBLISHER.bundleIdentifier,
|
||||
HOPPER_PUBLISHER.teamIdentifier,
|
||||
@@ -75,7 +74,7 @@ export interface MacHopperBundleInstallOperations {
|
||||
|
||||
/** Stable network and command effects disclosed before macOS Hopper setup. */
|
||||
export const macHopperInstallDisclosure = {
|
||||
networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const,
|
||||
networkOrigins: [HOPPER_RELEASES_URL, HOPPER_DOWNLOAD_PREFIX] as const,
|
||||
commands: [
|
||||
"hdiutil attach -readonly -nobrowse -mountpoint <private-mount> <verified-hopper.dmg>",
|
||||
"codesign --verify --deep --strict <mounted-hopper.app>",
|
||||
@@ -143,19 +142,18 @@ export const installMacHopper = async (
|
||||
let result: MacHopperInstallResult;
|
||||
try {
|
||||
result = await (async (): Promise<MacHopperInstallResult> => {
|
||||
const metadata = await host.download(RELEASES_URL);
|
||||
const metadata = await host.download(HOPPER_RELEASES_URL);
|
||||
if (!metadata.ok) return { status: "failed", reason: "release_metadata" };
|
||||
const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
|
||||
if (!parsed.success)
|
||||
return { status: "failed", reason: "release_metadata" };
|
||||
const release = parsed.data["OS X"];
|
||||
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
||||
return { status: "failed", reason: "release_metadata" };
|
||||
const decoded = parseHopperReleaseJson(metadata.bytes);
|
||||
if (!decoded.ok) throw decoded.cause;
|
||||
const parsed = parseMacHopperReleases(decoded.value);
|
||||
if (!parsed.ok) return { status: "failed", reason: "release_metadata" };
|
||||
const release = parsed.value;
|
||||
installStage = "download";
|
||||
const archive = await host.download(release.filename);
|
||||
if (!archive.ok) return { status: "failed", reason: "download" };
|
||||
installStage = "integrity";
|
||||
if (!integrityMatches(archive.bytes, release))
|
||||
if (!hopperArchiveIntegrityMatches(archive.bytes, release))
|
||||
return { status: "failed", reason: "integrity" };
|
||||
|
||||
installStage = "destination_inspection";
|
||||
@@ -471,15 +469,6 @@ export const systemMacHopperInstallHost = (
|
||||
};
|
||||
};
|
||||
|
||||
const parseJson = (bytes: Uint8Array): unknown =>
|
||||
JSON.parse(new TextDecoder().decode(bytes));
|
||||
const integrityMatches = (
|
||||
bytes: Uint8Array,
|
||||
release: z.infer<typeof releaseSchema>,
|
||||
): boolean =>
|
||||
bytes.byteLength === Number(release.file_length) &&
|
||||
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
|
||||
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
||||
const commandSucceeds = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
@@ -524,9 +513,3 @@ const pathExists = async (path: string): Promise<boolean> => {
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
const releaseSchema = z.object({
|
||||
filename: z.string().url(),
|
||||
file_length: z.string().regex(/^\d+$/u),
|
||||
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
});
|
||||
const releasesSchema = z.object({ "OS X": releaseSchema });
|
||||
@@ -61,6 +61,8 @@ const SELECTED_REGISTRATION_SETTINGS = [
|
||||
"REA_GHIDRA_COMPILER_SPEC_ID",
|
||||
"REA_GHIDRA_DISABLED_ANALYZERS",
|
||||
"REA_GHIDRA_SEED_FILE",
|
||||
"REA_GHIDRA_PROJECT_PATH",
|
||||
"REA_GHIDRA_PROGRAM_NAME",
|
||||
"REA_GHIDRA_NATIVEAOT_JAR",
|
||||
"REA_HOPPER_STARTUP_TIMEOUT_MS",
|
||||
"REA_JEB_MCP_TIMEOUT_MS",
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { EnvironmentBuildReport } from "../../domain/environmentBundleExpor
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
|
||||
import {
|
||||
BUNDLE_NAME_CONSTRAINT,
|
||||
ENVIRONMENT_EXPORT_OPERATION,
|
||||
isPortableBundleName,
|
||||
planEnvironmentBundle,
|
||||
@@ -192,7 +193,7 @@ const unnamed = (path: string): AnalysisInputError =>
|
||||
new AnalysisInputError(
|
||||
ENVIRONMENT_BUILD_OPERATION,
|
||||
{
|
||||
userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (a kebab-case label of at most 64 characters that is not a Windows device name)`}; name the environment.`,
|
||||
userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (${BUNDLE_NAME_CONSTRAINT})`}; name the environment.`,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
|
||||
import {
|
||||
BUNDLE_BASE_IMAGE,
|
||||
bundleDockerfile,
|
||||
isPortableBundleName,
|
||||
ENVIRONMENT_EXPORT_OPERATION,
|
||||
planEnvironmentBundle,
|
||||
registryPackageSource,
|
||||
@@ -117,6 +118,9 @@ describe("planEnvironmentBundle", () => {
|
||||
expect(lines[0]).toBe(`FROM ${BUNDLE_BASE_IMAGE}`);
|
||||
expect(lines).toContain("ENV MCP_HOST=0.0.0.0");
|
||||
expect(lines).toContain("ENV MCP_PORT=18765");
|
||||
expect(lines).toContain(
|
||||
`ENV MCP_ALLOWED_HOSTS=127.0.0.1,localhost,grafana-example`,
|
||||
);
|
||||
expect(lines).toContain(
|
||||
"RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*",
|
||||
);
|
||||
@@ -235,6 +239,36 @@ describe("planEnvironmentBundle", () => {
|
||||
});
|
||||
|
||||
describe("planEnvironmentBundle portable names", () => {
|
||||
it.each(["4294967296", "123", "0xffffffff"])(
|
||||
"refuses bundle name %s when HTTP cannot preserve its host identity",
|
||||
(name) => {
|
||||
expect(nameRefusal(name)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "environment-export",
|
||||
issues: [{ path: ["name"], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps addressable numeric prefixes and admits the DNS label boundary", () => {
|
||||
for (const name of ["123-replay", "0xffffffff-replay", "a".repeat(63)]) {
|
||||
expect(isPortableBundleName(name)).toBe(true);
|
||||
const planned = plan({ name });
|
||||
expect(planned.name).toBe(name);
|
||||
expect(file(planned, `envs/${name}/Dockerfile`)).toContain(
|
||||
`ENV MCP_ALLOWED_HOSTS=127.0.0.1,localhost,${name}`,
|
||||
);
|
||||
}
|
||||
expect(isPortableBundleName("a".repeat(64))).toBe(false);
|
||||
expect(nameRefusal("a".repeat(64))).toMatchObject({
|
||||
code: "invalid_request",
|
||||
});
|
||||
for (const name of ["123", "4294967296", "0xffffffff"])
|
||||
expect(isPortableBundleName(name)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a kebab-case name override and refuses any other name as invalid input", () => {
|
||||
const renamed = plan({ name: "replay-2" });
|
||||
expect(renamed.files.map((entry) => entry.path)).toContain(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeHttpHostname } from "../../domain/httpHostname.js";
|
||||
import { PRODUCT_IDENTITY } from "../../identity.js";
|
||||
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
@@ -20,21 +21,23 @@ export const BUNDLE_BASE_IMAGE = `node:${PACKAGE_METADATA.pinnedNodeVersion}-boo
|
||||
|
||||
/** The port the agentenv framework maps for an MCP server env; it sets the host but not the port. */
|
||||
export const BUNDLE_MCP_PORT = 18765;
|
||||
/** Host names the exported env container is addressed by: the published loopback port and the compose service name. */
|
||||
export const BUNDLE_ALLOWED_HOSTS = "127.0.0.1,localhost";
|
||||
|
||||
/** Name of the packed CLI inside the env folder when a tarball is exported. */
|
||||
export const BUNDLE_TARBALL_NAME = "rea-agents.tgz";
|
||||
|
||||
/** Folder and env id rule: a kebab-case label the agentenv framework accepts as a local id. */
|
||||
const BUNDLE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
const BUNDLE_NAME_MAX_LENGTH = 64;
|
||||
// A compose service name is one DNS label, whose wire limit is 63 octets.
|
||||
const BUNDLE_NAME_MAX_LENGTH = 63;
|
||||
export const BUNDLE_NAME_CONSTRAINT = `a kebab-case label of at most ${String(BUNDLE_NAME_MAX_LENGTH)} characters, excluding Windows device names, that remains unchanged as an HTTP service hostname`;
|
||||
/** Win32 reserves these device names for files and folders, so the env folder can never take one. */
|
||||
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u;
|
||||
|
||||
/** Whether a name can be the env folder and env id: the checks the export applies. */
|
||||
export const isPortableBundleName = (name: string): boolean =>
|
||||
BUNDLE_NAME.test(name) &&
|
||||
name.length <= BUNDLE_NAME_MAX_LENGTH &&
|
||||
!WINDOWS_RESERVED_NAME.test(name);
|
||||
validateBundleName(name).ok;
|
||||
|
||||
/** Where the Dockerfile installs the CLI from. */
|
||||
export type EnvironmentPackageSource =
|
||||
@@ -121,6 +124,33 @@ const reservedNameError = (name: string): AnalysisInputError =>
|
||||
],
|
||||
);
|
||||
|
||||
/** One owner admits the same folder, environment id and HTTP service hostname. */
|
||||
const validateBundleName = (
|
||||
name: string,
|
||||
): Result<string, AnalysisInputError> => {
|
||||
if (!BUNDLE_NAME.test(name) || name.length > BUNDLE_NAME_MAX_LENGTH)
|
||||
return err(nameError(name));
|
||||
if (WINDOWS_RESERVED_NAME.test(name)) return err(reservedNameError(name));
|
||||
if (normalizeHttpHostname(name) !== name)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
ENVIRONMENT_EXPORT_OPERATION,
|
||||
{
|
||||
userMessage: `The bundle name ${JSON.stringify(name)} cannot be used unchanged as an HTTP host name. Choose a name such as replay-1.`,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: ["name"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"The service name must remain a hostname rather than be rejected or interpreted as an IP address.",
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
return ok(name);
|
||||
};
|
||||
|
||||
const installLine = (source: EnvironmentPackageSource): string =>
|
||||
source.kind === "registry"
|
||||
? `RUN npm install -g --ignore-scripts --no-fund --no-audit ${source.specifier}`
|
||||
@@ -131,11 +161,19 @@ const READINESS_PROBE_LAYER =
|
||||
"RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*";
|
||||
|
||||
/** The Dockerfile text: pinned base image, readiness probe interpreter, pinned CLI, listener defaults, non-root user. */
|
||||
export const bundleDockerfile = (source: EnvironmentPackageSource): string =>
|
||||
export const bundleDockerfile = (
|
||||
source: EnvironmentPackageSource,
|
||||
name?: string,
|
||||
): string =>
|
||||
[
|
||||
`FROM ${BUNDLE_BASE_IMAGE}`,
|
||||
`ENV MCP_HOST=0.0.0.0`,
|
||||
`ENV MCP_PORT=${String(BUNDLE_MCP_PORT)}`,
|
||||
// The env container binds outside loopback for the framework's published port and
|
||||
// gateway proxy, so it names the hosts they address it by; the gateway reaches it
|
||||
// as the compose service name (the environment name) and host-side clients go
|
||||
// through the published loopback port.
|
||||
`ENV MCP_ALLOWED_HOSTS=${BUNDLE_ALLOWED_HOSTS}${name === undefined ? "" : `,${name}`}`,
|
||||
READINESS_PROBE_LAYER,
|
||||
installLine(source),
|
||||
// Owned by the runtime user, so a private host umask cannot make the inputs unreadable.
|
||||
@@ -332,9 +370,8 @@ export const planEnvironmentBundle = (
|
||||
options: EnvironmentBundleOptions,
|
||||
): Result<EnvironmentBundlePlan, AnalysisError> => {
|
||||
const name = options.name ?? source.manifest.name;
|
||||
if (!BUNDLE_NAME.test(name) || name.length > BUNDLE_NAME_MAX_LENGTH)
|
||||
return err(nameError(name));
|
||||
if (WINDOWS_RESERVED_NAME.test(name)) return err(reservedNameError(name));
|
||||
const admitted = validateBundleName(name);
|
||||
if (!admitted.ok) return admitted;
|
||||
// Prepared only after the name checks, under the name the bundle's server serves.
|
||||
const { state, report } = options.served(name);
|
||||
// The catalog and answers the bundle's server will have, so the verifier and README predict them.
|
||||
@@ -370,7 +407,7 @@ export const planEnvironmentBundle = (
|
||||
},
|
||||
{
|
||||
path: `${envFolder}/Dockerfile`,
|
||||
content: bundleDockerfile(options.packageSource),
|
||||
content: bundleDockerfile(options.packageSource, name),
|
||||
},
|
||||
{
|
||||
path: `artifacts/check_${name}/verify.py`,
|
||||
|
||||
@@ -67,8 +67,11 @@ const untilAborted = (signal: AbortSignal): Promise<undefined> =>
|
||||
export const transportError = (
|
||||
endpoint: McpHttpEndpoint,
|
||||
cause: StreamableHttpListenError,
|
||||
): AnalysisResourceConstraintError =>
|
||||
new AnalysisResourceConstraintError(
|
||||
): AnalysisResourceConstraintError => {
|
||||
const failure = cause.classification.ok
|
||||
? cause.classification.value
|
||||
: cause.classification.error;
|
||||
return new AnalysisResourceConstraintError(
|
||||
CLI_COMMANDS.environmentServe,
|
||||
"transport",
|
||||
cause.message,
|
||||
@@ -77,38 +80,22 @@ export const transportError = (
|
||||
cause,
|
||||
retryable: false,
|
||||
diagnostics: {
|
||||
listener_failure: cause.classification.ok
|
||||
? {
|
||||
kind: cause.classification.value.kind,
|
||||
system_code: cause.classification.value.systemCode,
|
||||
}
|
||||
: {
|
||||
kind: cause.classification.error.kind,
|
||||
...(cause.classification.error.systemCode === undefined
|
||||
? {}
|
||||
: { system_code: cause.classification.error.systemCode }),
|
||||
},
|
||||
listener_failure: {
|
||||
kind: failure.kind,
|
||||
...("systemCode" in failure && failure.systemCode !== undefined
|
||||
? { system_code: failure.systemCode }
|
||||
: {}),
|
||||
...(failure.kind === "host_allowlist"
|
||||
? {
|
||||
reason: failure.reason,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
listener_phase: cause.phase,
|
||||
},
|
||||
remediationAction: listenerRemediation(cause.classification),
|
||||
remediationAction: cause.remediationAction,
|
||||
},
|
||||
);
|
||||
|
||||
const listenerRemediation = (
|
||||
classification: StreamableHttpListenError["classification"],
|
||||
): string => {
|
||||
if (!classification.ok)
|
||||
return "Check the selected listener host, port, and binding permissions, then retry.";
|
||||
switch (classification.value.kind) {
|
||||
case "host_lookup":
|
||||
return "Check --host or MCP_HOST and host name resolution; correct the value or resolve the lookup failure, then retry.";
|
||||
case "host_unavailable":
|
||||
return "Choose an address assigned to this machine with --host or MCP_HOST, then retry.";
|
||||
case "port_in_use":
|
||||
return "Choose another --port or MCP_PORT, or stop the process using it.";
|
||||
case "permission_denied":
|
||||
return "Choose a host and port this process is permitted to bind; if the selected port is restricted, use an allowed port.";
|
||||
}
|
||||
};
|
||||
|
||||
interface ServeArguments {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ghidraProjectPathSchema,
|
||||
ghidraProgramNameSchema,
|
||||
} from "./ghidraExistingProject.js";
|
||||
import { z } from "zod";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
@@ -83,6 +87,8 @@ const environmentSchema = z.object({
|
||||
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
|
||||
),
|
||||
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_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
|
||||
REA_GHIDRA_DISABLED_ANALYZERS: ghidraDisabledAnalyzersSchema.optional(),
|
||||
@@ -202,6 +208,32 @@ export const parseEnvironment = (
|
||||
const parsed = parseEnvironmentValues(environment, unresolvedSettings);
|
||||
if (!parsed.ok) return parsed;
|
||||
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.
|
||||
if (
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
httpHostnameSchema,
|
||||
type HttpHostname,
|
||||
} from "../domain/httpHostname.js";
|
||||
|
||||
import {
|
||||
ConfigurationError,
|
||||
@@ -27,11 +31,34 @@ export const MCP_HTTP_PORT_CONSTRAINT =
|
||||
"MCP_PORT must be a decimal integer between 0 and 65535.";
|
||||
export const MCP_HTTP_HOST_CONSTRAINT =
|
||||
"MCP_HOST must be a non-empty host name or IP address.";
|
||||
export const MCP_ALLOWED_HOSTS_CONSTRAINT =
|
||||
"MCP_ALLOWED_HOSTS must list at least one host name or IP address, separated by commas, without URL components or ports.";
|
||||
export const MCP_ALLOWED_HOSTS_FLAG_CONSTRAINT =
|
||||
"must list at least one host name or IP address, separated by commas, without URL components or ports.";
|
||||
|
||||
/** Comma-separated operator-declared Host/Origin names for a non-loopback bind. */
|
||||
export const mcpAllowedHostsTextSchema = z
|
||||
.string()
|
||||
.transform((text, context) => {
|
||||
const entries = text.split(",").map((entry) => entry.trim());
|
||||
if (entries.some((entry) => entry.length === 0)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: MCP_ALLOWED_HOSTS_CONSTRAINT,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return entries;
|
||||
})
|
||||
.pipe(z.array(httpHostnameSchema))
|
||||
.transform((names) => [...new Set(names)]);
|
||||
|
||||
/** Where the streamable HTTP listener binds. */
|
||||
export interface McpHttpEndpoint {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
/** Operator-declared Host/Origin names; required before a non-loopback bind serves. */
|
||||
readonly allowedHosts?: readonly HttpHostname[];
|
||||
}
|
||||
|
||||
/** Resolve the endpoint: explicit values, then MCP_HOST/MCP_PORT, then loopback defaults. */
|
||||
@@ -42,6 +69,7 @@ export const parseMcpHttpEndpoint = (
|
||||
const issues: ConfigurationSettingIssue[] = [];
|
||||
let host = overrides.host ?? DEFAULT_MCP_HTTP_HOST;
|
||||
let port = overrides.port ?? DEFAULT_MCP_HTTP_PORT;
|
||||
let allowedHosts = overrides.allowedHosts;
|
||||
if (overrides.host === undefined && environment.MCP_HOST !== undefined) {
|
||||
const parsed = mcpHttpHostSchema.safeParse(environment.MCP_HOST);
|
||||
if (parsed.success) host = parsed.data;
|
||||
@@ -60,6 +88,20 @@ export const parseMcpHttpEndpoint = (
|
||||
constraint: MCP_HTTP_PORT_CONSTRAINT,
|
||||
});
|
||||
}
|
||||
if (
|
||||
overrides.allowedHosts === undefined &&
|
||||
environment.MCP_ALLOWED_HOSTS !== undefined
|
||||
) {
|
||||
const parsed = mcpAllowedHostsTextSchema.safeParse(
|
||||
environment.MCP_ALLOWED_HOSTS,
|
||||
);
|
||||
if (parsed.success) allowedHosts = parsed.data;
|
||||
else
|
||||
issues.push({
|
||||
setting: "MCP_ALLOWED_HOSTS",
|
||||
constraint: MCP_ALLOWED_HOSTS_CONSTRAINT,
|
||||
});
|
||||
}
|
||||
if (issues.length > 0)
|
||||
return err(
|
||||
new ConfigurationError(
|
||||
@@ -67,5 +109,9 @@ export const parseMcpHttpEndpoint = (
|
||||
{ settings: issues },
|
||||
),
|
||||
);
|
||||
return ok({ host, port });
|
||||
return ok({
|
||||
host,
|
||||
port,
|
||||
...(allowedHosts === undefined ? {} : { allowedHosts }),
|
||||
});
|
||||
};
|
||||
@@ -116,6 +116,16 @@ export const parseConfig = (
|
||||
...(env.REA_IDA_MCP_CONFIG === undefined
|
||||
? {}
|
||||
: { 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,
|
||||
ghidraJavaHome: env.JAVA_HOME,
|
||||
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 { LogLevel } from "./processStart.js";
|
||||
import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js";
|
||||
@@ -25,6 +26,7 @@ export interface AppConfig {
|
||||
readonly ghidraLanguageOverride?: GhidraLanguageOverride;
|
||||
readonly ghidraDisabledAnalyzers?: readonly string[];
|
||||
readonly ghidraSeedFile?: string;
|
||||
readonly ghidraExistingProject?: GhidraProjectSelection;
|
||||
readonly ghidraNativeAotJar?: string;
|
||||
readonly ilspyCmdPath: string | undefined;
|
||||
readonly hopperLauncherPath: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { readElfFixedHeader } from "./elfFixedHeader.js";
|
||||
import {
|
||||
mzWindowsHeaderLayoutLimitation,
|
||||
parseMzHeaderEvidence,
|
||||
type MzHeaderEvidence,
|
||||
type MzWindowsHeaderSignatureEvidence,
|
||||
} from "./dosMz.js";
|
||||
import { decodePeMachine, readPeOptionalHeader } from "./peMachine.js";
|
||||
@@ -126,7 +127,10 @@ export const parseExecutableHeader = (
|
||||
)
|
||||
return parseElf(bytes, fileSize);
|
||||
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a)
|
||||
return parseMz(bytes, fileSize, mzWindowsHeaderSignature);
|
||||
return parseMzExecutableMetadata(
|
||||
bytes,
|
||||
parseMzHeaderEvidence(bytes, fileSize, mzWindowsHeaderSignature),
|
||||
);
|
||||
if (bytes.length < 8) return err("truncated or unsupported binary header");
|
||||
const magic = bytes.readUInt32BE(0);
|
||||
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
|
||||
@@ -434,12 +438,11 @@ const parseElf = (
|
||||
});
|
||||
};
|
||||
|
||||
const parseMz = (
|
||||
/** Select executable metadata from the reader's completed MZ observations. */
|
||||
export const parseMzExecutableMetadata = (
|
||||
bytes: Buffer,
|
||||
fileSize: number,
|
||||
signatureAtOffset?: MzWindowsHeaderSignatureEvidence,
|
||||
evidence: MzHeaderEvidence,
|
||||
): Result<ExecutableMetadata, string> => {
|
||||
const evidence = parseMzHeaderEvidence(bytes, fileSize, signatureAtOffset);
|
||||
const { windowsHeader } = evidence;
|
||||
if (
|
||||
windowsHeader.state === "header-needed" ||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { dosMz, pe } from "./binaryTarget.fixture.js";
|
||||
import { parseExecutableHeader } from "./binaryTarget.js";
|
||||
import {
|
||||
observeMzWindowsHeader,
|
||||
readDosMzDeclarations,
|
||||
dosMzDeclaredImageByteLength,
|
||||
parseDeclaredMzHeaderEvidence,
|
||||
withMzWindowsHeaderSignature,
|
||||
parseDosMzHeader,
|
||||
parseMzHeaderEvidence,
|
||||
} from "./dosMz.js";
|
||||
@@ -419,3 +423,166 @@ it("retains ordered DOS relocation aliases and their source/module target coordi
|
||||
error: "DOS MZ relocation 1 points outside its load module",
|
||||
});
|
||||
});
|
||||
|
||||
const extendedRelocationImage = (): Buffer => {
|
||||
const bytes = dosMz(8192);
|
||||
bytes.writeUInt16LE(257, 8); // 4112-byte header.
|
||||
bytes.writeUInt16LE(2, 6);
|
||||
bytes.writeUInt16LE(4096, 24);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
it("retains fixed declarations while requesting only a source-bounded relocation prefix", () => {
|
||||
const bytes = extendedRelocationImage();
|
||||
const prefix = bytes.subarray(0, 64);
|
||||
const evidence = parseMzHeaderEvidence(prefix, bytes.length);
|
||||
expect(evidence.declarations).toEqual(readDosMzDeclarations(prefix));
|
||||
expect(evidence.declarations).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
headerBytes: 4112,
|
||||
relocationCount: 2,
|
||||
relocationTableOffset: 4096,
|
||||
relocationTableEnd: 4104,
|
||||
entryOffset: 0,
|
||||
entrySegment: 0,
|
||||
},
|
||||
});
|
||||
expect(evidence.relocationTableRead).toEqual({ offset: 0, byteLength: 4104 });
|
||||
expect(evidence.dos).toEqual({
|
||||
ok: false,
|
||||
error: "truncated DOS MZ relocation table",
|
||||
});
|
||||
const observed = parseMzHeaderEvidence(bytes.subarray(0, 4104), bytes.length);
|
||||
expect(observed.relocationTableRead).toBeUndefined();
|
||||
expect(observed.dos).toMatchObject({
|
||||
ok: true,
|
||||
value: { relocationCount: 2, headerBytes: 4112 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not request absent or out-of-bounds relocation records", () => {
|
||||
const zero = extendedRelocationImage();
|
||||
zero.writeUInt16LE(512, 8);
|
||||
zero.writeUInt16LE(0, 6);
|
||||
zero.writeUInt16LE(6000, 24);
|
||||
expect(
|
||||
parseMzHeaderEvidence(zero.subarray(0, 64), zero.length)
|
||||
.relocationTableRead,
|
||||
).toBeUndefined();
|
||||
expect(parseDosMzHeader(zero).ok).toBe(true);
|
||||
const outsideHeader = extendedRelocationImage();
|
||||
outsideHeader.writeUInt16LE(5000, 24);
|
||||
const headerEvidence = parseMzHeaderEvidence(
|
||||
outsideHeader.subarray(0, 64),
|
||||
outsideHeader.length,
|
||||
);
|
||||
expect(headerEvidence.relocationTableRead).toBeUndefined();
|
||||
expect(headerEvidence.dos).toEqual({
|
||||
ok: false,
|
||||
error: "DOS MZ relocation table lies outside its header",
|
||||
});
|
||||
const outsideFile = extendedRelocationImage();
|
||||
outsideFile.writeUInt16LE(1024, 8);
|
||||
outsideFile.writeUInt16LE(9000, 24);
|
||||
expect(
|
||||
parseMzHeaderEvidence(outsideFile.subarray(0, 64), outsideFile.length)
|
||||
.relocationTableRead,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps independent malformed declarations and the established DOS error precedence", () => {
|
||||
const bytes = extendedRelocationImage();
|
||||
bytes.writeUInt16LE(0, 4);
|
||||
bytes.writeUInt16LE(65535, 20);
|
||||
const evidence = parseMzHeaderEvidence(bytes.subarray(0, 64), bytes.length);
|
||||
expect(evidence.declarations).toMatchObject({
|
||||
ok: true,
|
||||
value: { pages: 0, entryOffset: 65535 },
|
||||
});
|
||||
expect(evidence.relocationTableRead?.byteLength).toBe(4104);
|
||||
expect(evidence.dos).toEqual({
|
||||
ok: false,
|
||||
error: "invalid DOS MZ page count or final-page size",
|
||||
});
|
||||
expect(parseDosMzHeader(bytes, bytes.length - 1)).toEqual({
|
||||
ok: false,
|
||||
error: "invalid DOS MZ file length",
|
||||
});
|
||||
bytes.writeUInt16LE(17, 4);
|
||||
expect(parseDosMzHeader(bytes.subarray(0, 64), bytes.length)).toEqual({
|
||||
ok: false,
|
||||
error: "truncated DOS MZ relocation table",
|
||||
});
|
||||
expect(parseDosMzHeader(bytes)).toEqual({
|
||||
ok: false,
|
||||
error: "DOS MZ entry point lies outside its initialized load module",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps declared Windows signatures independent of incomplete DOS relocations", () => {
|
||||
const bytes = extendedRelocationImage();
|
||||
bytes.writeUInt32LE(128, 60);
|
||||
const evidence = parseMzHeaderEvidence(bytes.subarray(0, 64), bytes.length, {
|
||||
offset: 128,
|
||||
bytes: Buffer.from("PE\0\0"),
|
||||
});
|
||||
expect(evidence.windowsHeader).toEqual({
|
||||
state: "declared",
|
||||
signature: "PE",
|
||||
offset: 128,
|
||||
});
|
||||
expect(evidence.dos).toEqual({
|
||||
ok: false,
|
||||
error: "truncated DOS MZ relocation table",
|
||||
});
|
||||
expect(evidence.relocationTableRead?.byteLength).toBe(4104);
|
||||
});
|
||||
|
||||
it("completes captured evidence from the original admitted fixed declarations", () => {
|
||||
const bytes = extendedRelocationImage();
|
||||
const declarations = readDosMzDeclarations(bytes.subarray(0, 64));
|
||||
if (!declarations.ok) throw new Error(declarations.error);
|
||||
expect(dosMzDeclaredImageByteLength(declarations.value)).toEqual({
|
||||
ok: true,
|
||||
value: bytes.length,
|
||||
});
|
||||
const evidence = parseDeclaredMzHeaderEvidence(
|
||||
bytes.subarray(0, 4104),
|
||||
bytes.length,
|
||||
declarations,
|
||||
);
|
||||
expect(evidence.declarations).toBe(declarations);
|
||||
expect(evidence).toEqual(
|
||||
parseMzHeaderEvidence(bytes.subarray(0, 4104), bytes.length),
|
||||
);
|
||||
expect(evidence.dos.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("adds distant Windows signatures while retaining prior DOS observations", () => {
|
||||
const bytes = extendedRelocationImage();
|
||||
bytes.writeUInt32LE(6000, 60);
|
||||
const prefix = bytes.subarray(0, 4104);
|
||||
const evidence = parseMzHeaderEvidence(prefix, bytes.length);
|
||||
expect(evidence.windowsHeader).toEqual({
|
||||
state: "signature-needed",
|
||||
offset: 6000,
|
||||
});
|
||||
const signature = { offset: 6000, bytes: Buffer.from("PE\0\0") };
|
||||
const completed = withMzWindowsHeaderSignature(
|
||||
prefix,
|
||||
bytes.length,
|
||||
evidence,
|
||||
signature,
|
||||
);
|
||||
expect(completed.dos).toBe(evidence.dos);
|
||||
expect(completed.declarations).toBe(evidence.declarations);
|
||||
expect(completed.windowsHeader).toEqual({
|
||||
state: "declared",
|
||||
signature: "PE",
|
||||
offset: 6000,
|
||||
});
|
||||
expect(completed).toEqual(
|
||||
parseMzHeaderEvidence(prefix, bytes.length, signature),
|
||||
);
|
||||
});
|
||||
+134
-21
@@ -1,11 +1,45 @@
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
|
||||
/** The unsigned 16-bit page count bounds the initialized image, not overlays. */
|
||||
export const dosMzImageByteLength = (bytes: Buffer): Result<number, string> => {
|
||||
if (bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ")
|
||||
return err("invalid or truncated DOS MZ header");
|
||||
const lastPageBytes = bytes.readUInt16LE(2);
|
||||
const pages = bytes.readUInt16LE(4);
|
||||
/** Source declarations in the fixed DOS header, independent of load-module validity. */
|
||||
export interface DosMzDeclarations {
|
||||
readonly lastPageBytes: number;
|
||||
readonly pages: number;
|
||||
readonly headerBytes: number;
|
||||
readonly relocationCount: number;
|
||||
readonly relocationTableOffset: number;
|
||||
readonly relocationTableEnd: number;
|
||||
readonly entrySegment: number;
|
||||
readonly entryOffset: number;
|
||||
}
|
||||
|
||||
const fixedDosMzDeclarations = (bytes: Buffer): DosMzDeclarations => {
|
||||
const relocationCount = bytes.readUInt16LE(6);
|
||||
const relocationTableOffset = bytes.readUInt16LE(24);
|
||||
return {
|
||||
lastPageBytes: bytes.readUInt16LE(2),
|
||||
pages: bytes.readUInt16LE(4),
|
||||
headerBytes: bytes.readUInt16LE(8) * 16,
|
||||
relocationCount,
|
||||
relocationTableOffset,
|
||||
relocationTableEnd: relocationTableOffset + relocationCount * 4,
|
||||
entrySegment: bytes.readUInt16LE(22),
|
||||
entryOffset: bytes.readUInt16LE(20),
|
||||
};
|
||||
};
|
||||
|
||||
/** Admit only the fixed source header; malformed page and load declarations remain observable. */
|
||||
export const readDosMzDeclarations = (
|
||||
bytes: Buffer,
|
||||
): Result<DosMzDeclarations, string> =>
|
||||
bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ"
|
||||
? err("invalid or truncated DOS MZ header")
|
||||
: ok(fixedDosMzDeclarations(bytes));
|
||||
|
||||
/** Derive the initialized source extent from already admitted fixed declarations. */
|
||||
export const dosMzDeclaredImageByteLength = ({
|
||||
pages,
|
||||
lastPageBytes,
|
||||
}: DosMzDeclarations): Result<number, string> => {
|
||||
if (pages === 0 || lastPageBytes > 511)
|
||||
return err("invalid DOS MZ page count or final-page size");
|
||||
return ok((pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes));
|
||||
@@ -75,13 +109,23 @@ export const observeMzWindowsHeader = (
|
||||
bytes: Buffer,
|
||||
fileSize = bytes.length,
|
||||
signatureEvidence?: MzWindowsHeaderSignatureEvidence,
|
||||
): MzWindowsHeaderObservation =>
|
||||
observeWindowsHeader(bytes, fileSize, signatureEvidence);
|
||||
|
||||
const observeWindowsHeader = (
|
||||
bytes: Buffer,
|
||||
fileSize: number,
|
||||
signatureEvidence?: MzWindowsHeaderSignatureEvidence,
|
||||
declarations?: DosMzDeclarations,
|
||||
): MzWindowsHeaderObservation => {
|
||||
if (bytes.length < 64)
|
||||
return fileSize >= 64 ? { state: "header-needed" } : { state: "absent" };
|
||||
const headerBytes = bytes.readUInt16LE(8) * 16;
|
||||
const relocationCount = bytes.readUInt16LE(6);
|
||||
const tableStart = bytes.readUInt16LE(24);
|
||||
const tableEnd = tableStart + relocationCount * 4;
|
||||
const {
|
||||
headerBytes,
|
||||
relocationCount,
|
||||
relocationTableOffset: tableStart,
|
||||
relocationTableEnd: tableEnd,
|
||||
} = declarations ?? fixedDosMzDeclarations(bytes);
|
||||
if (
|
||||
headerBytes !== 0 &&
|
||||
relocationCount > 0 &&
|
||||
@@ -159,22 +203,32 @@ const windowsHeaderSignature = (
|
||||
export const parseDosMzHeader = (
|
||||
bytes: Buffer,
|
||||
fileSize = bytes.length,
|
||||
): Result<DosMzHeader, string> =>
|
||||
parseDeclaredDosMzHeader(bytes, fileSize, readDosMzDeclarations(bytes));
|
||||
|
||||
const parseDeclaredDosMzHeader = (
|
||||
bytes: Buffer,
|
||||
fileSize: number,
|
||||
declarations: Result<DosMzDeclarations, string>,
|
||||
): Result<DosMzHeader, string> => {
|
||||
if (bytes.length < 28 || bytes.toString("latin1", 0, 2) !== "MZ")
|
||||
return err("invalid or truncated DOS MZ header");
|
||||
if (!declarations.ok) return declarations;
|
||||
if (!Number.isSafeInteger(fileSize) || fileSize < bytes.length)
|
||||
return err("invalid DOS MZ file length");
|
||||
// DOS structure is parsed independently from the meaning of bytes at 0x3c.
|
||||
const extent = dosMzImageByteLength(bytes);
|
||||
const extent = dosMzDeclaredImageByteLength(declarations.value);
|
||||
if (!extent.ok) return extent;
|
||||
const imageBytes = extent.value;
|
||||
const headerBytes = bytes.readUInt16LE(8) * 16;
|
||||
const {
|
||||
headerBytes,
|
||||
relocationCount,
|
||||
relocationTableOffset,
|
||||
relocationTableEnd: tableEnd,
|
||||
entryOffset,
|
||||
entrySegment,
|
||||
} = declarations.value;
|
||||
if (headerBytes < 28 || headerBytes >= imageBytes)
|
||||
return err("invalid DOS MZ header size or empty load module");
|
||||
if (imageBytes > fileSize) return err("truncated DOS MZ load module");
|
||||
const relocationCount = bytes.readUInt16LE(6);
|
||||
const relocationTableOffset = bytes.readUInt16LE(24);
|
||||
const tableEnd = relocationTableOffset + relocationCount * 4;
|
||||
if (
|
||||
relocationCount > 0 &&
|
||||
(relocationTableOffset < 28 || tableEnd > headerBytes)
|
||||
@@ -191,8 +245,6 @@ export const parseDosMzHeader = (
|
||||
moduleBytes,
|
||||
);
|
||||
if (!relocations.ok) return relocations;
|
||||
const entryOffset = bytes.readUInt16LE(20);
|
||||
const entrySegment = bytes.readUInt16LE(22);
|
||||
if (entrySegment * 16 + entryOffset >= moduleBytes)
|
||||
return err("DOS MZ entry point lies outside its initialized load module");
|
||||
return ok({
|
||||
@@ -210,6 +262,12 @@ export const parseDosMzHeader = (
|
||||
|
||||
/** Preserve DOS structural facts and Windows-pointer evidence independently. */
|
||||
export interface MzHeaderEvidence {
|
||||
readonly declarations: Result<DosMzDeclarations, string>;
|
||||
/** A source-bounded prefix read, independent of later DOS module validity. */
|
||||
readonly relocationTableRead?: {
|
||||
readonly offset: 0;
|
||||
readonly byteLength: number;
|
||||
};
|
||||
readonly dos: Result<DosMzHeader, string>;
|
||||
readonly windowsHeader: MzWindowsHeaderObservation;
|
||||
}
|
||||
@@ -218,9 +276,64 @@ export const parseMzHeaderEvidence = (
|
||||
bytes: Buffer,
|
||||
fileSize = bytes.length,
|
||||
signatureEvidence?: MzWindowsHeaderSignatureEvidence,
|
||||
): MzHeaderEvidence =>
|
||||
parseDeclaredMzHeaderEvidence(
|
||||
bytes,
|
||||
fileSize,
|
||||
readDosMzDeclarations(bytes),
|
||||
signatureEvidence,
|
||||
);
|
||||
|
||||
/** Complete evidence using fixed declarations from the same captured source. */
|
||||
export const parseDeclaredMzHeaderEvidence = (
|
||||
bytes: Buffer,
|
||||
fileSize: number,
|
||||
declarations: Result<DosMzDeclarations, string>,
|
||||
signatureEvidence?: MzWindowsHeaderSignatureEvidence,
|
||||
): MzHeaderEvidence => {
|
||||
const fields = declarations.ok ? declarations.value : undefined;
|
||||
const needed =
|
||||
fields !== undefined &&
|
||||
Number.isSafeInteger(fileSize) &&
|
||||
fileSize >= bytes.length &&
|
||||
fields.relocationCount > 0 &&
|
||||
fields.relocationTableEnd > bytes.length &&
|
||||
fields.relocationTableEnd <= fields.headerBytes &&
|
||||
fields.relocationTableEnd <= fileSize;
|
||||
return {
|
||||
declarations,
|
||||
...(needed
|
||||
? {
|
||||
relocationTableRead: {
|
||||
offset: 0 as const,
|
||||
byteLength: fields.relocationTableEnd,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
dos: parseDeclaredDosMzHeader(bytes, fileSize, declarations),
|
||||
windowsHeader: observeWindowsHeader(
|
||||
bytes,
|
||||
fileSize,
|
||||
signatureEvidence,
|
||||
fields,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** Add a signature observation without reinterpreting the captured DOS structure. */
|
||||
export const withMzWindowsHeaderSignature = (
|
||||
bytes: Buffer,
|
||||
fileSize: number,
|
||||
evidence: MzHeaderEvidence,
|
||||
signature: MzWindowsHeaderSignatureEvidence,
|
||||
): MzHeaderEvidence => ({
|
||||
dos: parseDosMzHeader(bytes, fileSize),
|
||||
windowsHeader: observeMzWindowsHeader(bytes, fileSize, signatureEvidence),
|
||||
...evidence,
|
||||
windowsHeader: observeWindowsHeader(
|
||||
bytes,
|
||||
fileSize,
|
||||
signature,
|
||||
evidence.declarations.ok ? evidence.declarations.value : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const readRelocations = (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isIPv6 } from "node:net";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Parse a complete hostname/IP, never URL components, into the SDK's hostname identity. */
|
||||
export const normalizeHttpHostname = (host: string): string | undefined => {
|
||||
const unbracketed =
|
||||
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||
const ipv6 = isIPv6(unbracketed);
|
||||
// URL parsing alone discards userinfo, paths and default ports, and decodes escapes.
|
||||
if (
|
||||
/[\s\x00-\x1F\x7F%\x2F\x5C?#@]/u.test(host) ||
|
||||
(!ipv6 && host.includes(":"))
|
||||
)
|
||||
return undefined;
|
||||
try {
|
||||
return new URL(`http://${ipv6 ? `[${unbracketed}]` : host}`).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Canonical HTTP hostname identity, parsed before consumers use it for lookup or matching. */
|
||||
export const httpHostnameSchema = z
|
||||
.string()
|
||||
.transform((source, context) => {
|
||||
const hostname = normalizeHttpHostname(source);
|
||||
if (hostname !== undefined) return hostname;
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Expected a host name or IP address without URL components or ports",
|
||||
});
|
||||
return z.NEVER;
|
||||
})
|
||||
.brand<"HttpHostname">();
|
||||
|
||||
export type HttpHostname = z.infer<typeof httpHostnameSchema>;
|
||||
@@ -1,12 +1,18 @@
|
||||
/** Native mapping facts retained when original-file coordinates cannot be verified. */
|
||||
/** Native mapping facts retained through source verification and finalization. */
|
||||
export interface FileOffsetPartialObservation {
|
||||
readonly operation: "address_to_file_offset";
|
||||
readonly address: string;
|
||||
readonly provider_file_offset: number;
|
||||
readonly provider_source_path: string | null;
|
||||
readonly provider_image_header_hex: string | null;
|
||||
readonly original_file_offset: {
|
||||
readonly available: false;
|
||||
readonly reason: string;
|
||||
};
|
||||
readonly original_file_offset:
|
||||
| {
|
||||
readonly available: false;
|
||||
readonly reason: string;
|
||||
}
|
||||
| {
|
||||
readonly available: true;
|
||||
readonly file_offset: number;
|
||||
readonly source_sha256: 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, {
|
||||
runId,
|
||||
expectedReadOnly:
|
||||
this.#options.existingProject === true ||
|
||||
this.#options.transport === "authenticated-loopback-tcp",
|
||||
expectedExistingProject: this.#options.existingProject === true,
|
||||
providerVersion: this.#options.providerVersion,
|
||||
profileDigest: this.#options.profileDigest,
|
||||
targetSha256: this.#options.targetSha256,
|
||||
|
||||
@@ -33,12 +33,13 @@ export type GhidraDiagnostic =
|
||||
}
|
||||
| { 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 {
|
||||
readonly launcher: GhidraLauncher;
|
||||
readonly runId?: string;
|
||||
readonly targetPath: string;
|
||||
readonly targetSha256: string;
|
||||
readonly existingProject?: boolean;
|
||||
/** Installation host shared by runtime allocation and snapshot admission. */
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly transport?: GhidraTransportKind;
|
||||
|
||||
@@ -189,3 +189,33 @@ it("preserves exact cancellation after an overlay descriptor read", async () =>
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, "invalid DOS MZ page count or final-page size"],
|
||||
[4, "truncated DOS MZ load module"],
|
||||
] as const)(
|
||||
"preserves early extent failure before Windows inspection (pages=%s)",
|
||||
async (pages, error) => {
|
||||
const fixture = fixtureDosLoadImage(Buffer.alloc(1024));
|
||||
fixture.bytes.writeUInt16LE(pages, 4);
|
||||
fixture.bytes.writeUInt32LE(512, 60);
|
||||
fixture.bytes.write("NE", 512, "ascii");
|
||||
const directory = await createTestTempDirectory("rea-mz-extent-");
|
||||
const path = join(directory, "inert.exe");
|
||||
await writeFile(path, fixture.bytes);
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
await expect(
|
||||
attestGhidraDosSnapshot(
|
||||
handle,
|
||||
fixture.bytes.length,
|
||||
path,
|
||||
fixture.sha256,
|
||||
fixture.observation,
|
||||
),
|
||||
).resolves.toEqual({ ok: false, error });
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -3,8 +3,10 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
dosMzImageByteLength,
|
||||
observeMzWindowsHeader,
|
||||
dosMzDeclaredImageByteLength,
|
||||
readDosMzDeclarations,
|
||||
parseDeclaredMzHeaderEvidence,
|
||||
withMzWindowsHeaderSignature,
|
||||
} from "../domain/dosMz.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
|
||||
@@ -37,7 +39,9 @@ export const attestGhidraDosSnapshot = async (
|
||||
});
|
||||
if (header === undefined || header.length !== headerBytes)
|
||||
throw new RegularFileChangedError(path);
|
||||
const extent = dosMzImageByteLength(header);
|
||||
const declarations = readDosMzDeclarations(header);
|
||||
if (!declarations.ok) return declarations;
|
||||
const extent = dosMzDeclaredImageByteLength(declarations.value);
|
||||
if (!extent.ok) return extent;
|
||||
if (extent.value > fileBytes) return err("truncated DOS MZ load module");
|
||||
// Retain the complete initialized image and the Windows-pointer header only.
|
||||
@@ -57,7 +61,8 @@ export const attestGhidraDosSnapshot = async (
|
||||
}
|
||||
}
|
||||
if (captured !== captureBytes) throw new RegularFileChangedError(path);
|
||||
const windowsHeader = observeMzWindowsHeader(prefix, fileBytes);
|
||||
let evidence = parseDeclaredMzHeaderEvidence(prefix, fileBytes, declarations);
|
||||
const { windowsHeader } = evidence;
|
||||
let signature:
|
||||
| { readonly offset: number; readonly bytes: Buffer }
|
||||
| undefined;
|
||||
@@ -81,7 +86,14 @@ export const attestGhidraDosSnapshot = async (
|
||||
bytes: signatureBytes,
|
||||
};
|
||||
}
|
||||
const prepared = prepareGhidraDosLoadImage(prefix, fileBytes, signature);
|
||||
if (signature !== undefined)
|
||||
evidence = withMzWindowsHeaderSignature(
|
||||
prefix,
|
||||
fileBytes,
|
||||
evidence,
|
||||
signature,
|
||||
);
|
||||
const prepared = prepareGhidraDosLoadImage(prefix, evidence);
|
||||
if (!prepared.ok) return prepared;
|
||||
const originalHash = createHash("sha256").update(prefix);
|
||||
const modifiedHash = createHash("sha256")
|
||||
|
||||
+176
-65
@@ -1,3 +1,4 @@
|
||||
import { copyExistingGhidraProject } from "./GhidraProjectSnapshot.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
|
||||
import {
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
} from "./GhidraAnalysisSeeds.js";
|
||||
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.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 { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js";
|
||||
@@ -57,7 +58,6 @@ import {
|
||||
type GhidraPspExtension,
|
||||
} from "./GhidraPspExtension.js";
|
||||
|
||||
/** Private paths and identity material for one headless Ghidra import. */
|
||||
export interface GhidraLaunchSession {
|
||||
readonly runtimeRoot: string;
|
||||
readonly transport: GhidraTransportKind;
|
||||
@@ -203,6 +203,16 @@ const ghidraLaunchCommandOutput = (
|
||||
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. */
|
||||
export interface GhidraHeadlessLauncherOptions {
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
@@ -213,6 +223,8 @@ export interface GhidraHeadlessLauncherOptions {
|
||||
readonly javaVmName?: string;
|
||||
readonly bridgeScriptPath: string;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
/** Open a pre-imported project copy instead of importing the target again. */
|
||||
readonly existingProject?: GhidraExistingProject;
|
||||
readonly comSpec?: string;
|
||||
/** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */
|
||||
readonly dosMz?: true;
|
||||
@@ -271,6 +283,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
);
|
||||
const paths = ghidraRuntimePaths(session.runtimeRoot);
|
||||
const platform = this.options.platform ?? process.platform;
|
||||
const existingProject = this.options.existingProject;
|
||||
let started: SpawnedOwnedProviderProcess | undefined;
|
||||
let configuredMaxHeap: string | undefined;
|
||||
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.",
|
||||
);
|
||||
const psp = this.options.pspExtension;
|
||||
validateExistingProjectOptions({
|
||||
...this.options,
|
||||
platform,
|
||||
...(psp === undefined ? {} : { psp: true as const }),
|
||||
});
|
||||
if (psp !== undefined) {
|
||||
const installedRoot = await realpath(
|
||||
join(
|
||||
@@ -314,6 +332,19 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
platform,
|
||||
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 gamecubeDolSectionPath: string | undefined;
|
||||
let extensions: readonly GhidraExtension[] = [];
|
||||
@@ -349,6 +380,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
target_sha256: session.targetSha256,
|
||||
provider_version: session.providerVersion,
|
||||
profile_digest: session.profileDigest,
|
||||
...(existingProject === undefined ? {} : { existing_project: true }),
|
||||
...(this.options.decompileTimeoutSeconds === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -385,6 +417,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
: { mipsSoftFloat: this.options.mipsSoftFloat }),
|
||||
projectRoot: paths.projectRoot,
|
||||
targetPath: session.targetPath,
|
||||
...(existingProject === undefined ? {} : { existingProject }),
|
||||
bridgeScriptPath: this.options.bridgeScriptPath,
|
||||
descriptorPath: paths.descriptorPath,
|
||||
ghidraLogPath: paths.ghidraLogPath,
|
||||
@@ -720,6 +753,11 @@ export interface GhidraHeadlessArgumentOptions {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly projectRoot: string;
|
||||
readonly targetPath: string;
|
||||
/** Open this pre-imported program with -process instead of importing. */
|
||||
readonly existingProject?: Pick<
|
||||
GhidraExistingProject,
|
||||
"programName" | "projectName"
|
||||
>;
|
||||
readonly bridgeScriptPath: string;
|
||||
readonly descriptorPath: string;
|
||||
readonly ghidraLogPath: string;
|
||||
@@ -745,6 +783,69 @@ export interface GhidraHeadlessArgumentOptions {
|
||||
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. */
|
||||
export const ghidraHeadlessArguments = (
|
||||
options: GhidraHeadlessArgumentOptions,
|
||||
@@ -756,7 +857,9 @@ export const ghidraHeadlessArguments = (
|
||||
const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath);
|
||||
const override = options.languageOverride;
|
||||
const rawBinary = options.rawBinary;
|
||||
const existing = options.existingProject;
|
||||
const gamecubeDol = options.gamecubeDol;
|
||||
validateExistingProjectOptions(options);
|
||||
if (
|
||||
gamecubeDol !== undefined &&
|
||||
(options.psp === true ||
|
||||
@@ -812,73 +915,80 @@ export const ghidraHeadlessArguments = (
|
||||
);
|
||||
return [
|
||||
options.projectRoot,
|
||||
"rea-project",
|
||||
"-import",
|
||||
options.targetPath,
|
||||
...(gamecubeDol !== undefined
|
||||
existing?.projectName ?? "rea-project",
|
||||
...(existing === undefined
|
||||
? [
|
||||
// One BinaryLoader block at file offset 0; the pre-script rebuilds the DOL section map.
|
||||
"-loader",
|
||||
"BinaryLoader",
|
||||
"-loader-baseAddr",
|
||||
"0x0",
|
||||
"-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]),
|
||||
]
|
||||
: []
|
||||
: [
|
||||
"-import",
|
||||
options.targetPath,
|
||||
...(gamecubeDol !== undefined
|
||||
? [
|
||||
// One BinaryLoader block at file offset 0; the pre-script rebuilds the DOL section map.
|
||||
"-loader",
|
||||
"BinaryLoader",
|
||||
"-loader-baseAddr",
|
||||
rawBinary.baseAddr,
|
||||
"0x0",
|
||||
"-processor",
|
||||
rawBinary.languageId,
|
||||
"-cspec",
|
||||
rawBinary.compilerSpecId,
|
||||
]),
|
||||
"-readOnly",
|
||||
"-deleteProject",
|
||||
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",
|
||||
"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",
|
||||
options.ghidraLogPath,
|
||||
"-scriptlog",
|
||||
@@ -927,7 +1037,8 @@ export const ghidraHeadlessArguments = (
|
||||
),
|
||||
options.disabledAnalyzers.join(","),
|
||||
]),
|
||||
...((options.platform ?? process.platform) === "win32"
|
||||
...(existing !== undefined ||
|
||||
(options.platform ?? process.platform) === "win32"
|
||||
? []
|
||||
: [
|
||||
"-postScript",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseMzHeaderEvidence } from "../domain/dosMz.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dosMz } from "../domain/binaryTarget.fixture.js";
|
||||
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
|
||||
@@ -21,7 +22,7 @@ export const compareDosFixture = (
|
||||
return err(
|
||||
"Ghidra load-image snapshot digest does not match the admitted target",
|
||||
);
|
||||
const image = prepareGhidraDosLoadImage(bytes, bytes.length);
|
||||
const image = prepareGhidraDosLoadImage(bytes, parseMzHeaderEvidence(bytes));
|
||||
if (!image.ok) return image;
|
||||
return attestGhidraDosLoadImage(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseMzHeaderEvidence } from "../domain/dosMz.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@@ -22,7 +23,9 @@ it("accepts DOS header padding after a complete relocation table", () => {
|
||||
bytes.writeUInt16LE(4, 8);
|
||||
bytes.writeUInt16LE(28, 24);
|
||||
bytes.write("ABCD", 60, "ascii");
|
||||
expect(prepareGhidraDosLoadImage(bytes, bytes.length)).toMatchObject({
|
||||
expect(
|
||||
prepareGhidraDosLoadImage(bytes, parseMzHeaderEvidence(bytes)),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
header: {
|
||||
@@ -38,20 +41,25 @@ it("accepts DOS header padding after a complete relocation table", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not interpret an unresolved Windows-header candidate as a DOS image", () => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt16LE(128, 2);
|
||||
bytes.writeUInt16LE(1, 4);
|
||||
bytes.writeUInt16LE(0, 6);
|
||||
bytes.writeUInt16LE(4, 8);
|
||||
bytes.writeUInt16LE(28, 24);
|
||||
bytes.write("ABCD", 60, "ascii");
|
||||
expect(prepareGhidraDosLoadImage(bytes, bytes.length)).toEqual({
|
||||
ok: false,
|
||||
error: "MZ Windows new-header candidate is unresolved",
|
||||
});
|
||||
});
|
||||
it.each([0, 1])(
|
||||
"prioritizes an unresolved Windows-header candidate over DOS page validity (pages=%s)",
|
||||
(pages) => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt16LE(128, 2);
|
||||
bytes.writeUInt16LE(pages, 4);
|
||||
bytes.writeUInt16LE(0, 6);
|
||||
bytes.writeUInt16LE(4, 8);
|
||||
bytes.writeUInt16LE(28, 24);
|
||||
bytes.write("ABCD", 60, "ascii");
|
||||
expect(
|
||||
prepareGhidraDosLoadImage(bytes, parseMzHeaderEvidence(bytes)),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "MZ Windows new-header candidate is unresolved",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const damage: ReadonlyArray<
|
||||
readonly [string, (observation: NativeLoadImageObservation) => void]
|
||||
@@ -505,7 +513,10 @@ it("applies duplicate aliases and overlapping DOS fixups in declared order with
|
||||
bytes.writeUInt16LE(0, 42);
|
||||
bytes.set([1, 0, 2], 80);
|
||||
const before = Buffer.from(bytes);
|
||||
const prepared = prepareGhidraDosLoadImage(bytes, bytes.length);
|
||||
const prepared = prepareGhidraDosLoadImage(
|
||||
bytes,
|
||||
parseMzHeaderEvidence(bytes),
|
||||
);
|
||||
if (!prepared.ok) throw new Error(prepared.error);
|
||||
expect(bytes.equals(before)).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
type NativeLoadImageCheck,
|
||||
type NativeLoadImageObservation,
|
||||
} from "../domain/native/nativeLoadImage.js";
|
||||
import {
|
||||
parseMzHeaderEvidence,
|
||||
type DosMzHeader,
|
||||
type MzWindowsHeaderSignatureEvidence,
|
||||
} from "../domain/dosMz.js";
|
||||
import type { DosMzHeader, MzHeaderEvidence } from "../domain/dosMz.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { validateDosComLength } from "../domain/dosCom.js";
|
||||
@@ -58,10 +54,8 @@ export type GhidraDosImage = {
|
||||
/** Prepare initialized bytes and relocation facts without copying overlays. */
|
||||
export const prepareGhidraDosLoadImage = (
|
||||
bytes: Buffer,
|
||||
fileBytes: number,
|
||||
signatureAtOffset?: MzWindowsHeaderSignatureEvidence,
|
||||
evidence: MzHeaderEvidence,
|
||||
): Result<GhidraDosImage, string> => {
|
||||
const evidence = parseMzHeaderEvidence(bytes, fileBytes, signatureAtOffset);
|
||||
if (evidence.windowsHeader.state === "candidate")
|
||||
return err("MZ Windows new-header candidate is unresolved");
|
||||
if (evidence.windowsHeader.state === "header-needed")
|
||||
|
||||
@@ -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 { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.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 { GhidraHeadlessLauncher } from "./GhidraLauncher.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 { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import { silentLogger } from "../logger.js";
|
||||
@@ -4583,3 +4587,107 @@ it("session-observation failure clears only its resolved cleanup dependency afte
|
||||
});
|
||||
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 type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
|
||||
@@ -49,7 +56,6 @@ import {
|
||||
type GhidraInstallationHost,
|
||||
type GhidraInstallationInspection,
|
||||
} from "./GhidraInstallation.js";
|
||||
import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { resolveGhidraAnalysisSeeds } from "./GhidraAnalysisSeeds.js";
|
||||
import {
|
||||
@@ -64,6 +70,8 @@ import {
|
||||
import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
existingProjectCapabilities,
|
||||
EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
|
||||
windowsP0Capabilities,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
WINDOWS_P0_NATIVE_PE_ARCHITECTURES,
|
||||
@@ -92,7 +100,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
|
||||
"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 {
|
||||
readonly #inspectionResources = new ArtifactResourceScope();
|
||||
readonly #inspectionWork = new Set<Promise<unknown>>();
|
||||
@@ -138,9 +146,12 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
}
|
||||
|
||||
capabilities(): readonly CapabilityDescriptor[] {
|
||||
return (this.installationHost?.platform ?? process.platform) === "win32"
|
||||
? windowsP0Capabilities()
|
||||
: CAPABILITIES;
|
||||
const platform = this.installationHost?.platform ?? process.platform;
|
||||
return this.config.ghidraExistingProject !== undefined
|
||||
? existingProjectCapabilities(platform)
|
||||
: platform === "win32"
|
||||
? windowsP0Capabilities()
|
||||
: CAPABILITIES;
|
||||
}
|
||||
|
||||
async inspectAvailability(
|
||||
@@ -180,9 +191,11 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
return {
|
||||
availability,
|
||||
capabilities:
|
||||
readiness === undefined
|
||||
? CAPABILITIES
|
||||
: windowsP0Capabilities(readiness),
|
||||
this.config.ghidraExistingProject !== undefined
|
||||
? existingProjectCapabilities(installation.platform)
|
||||
: readiness === undefined
|
||||
? CAPABILITIES
|
||||
: windowsP0Capabilities(readiness),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,6 +204,16 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
readiness: WindowsNativeReadiness | undefined,
|
||||
): ProviderAvailability {
|
||||
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)
|
||||
return {
|
||||
status: "unavailable",
|
||||
@@ -382,6 +405,45 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
"resolve_analysis_profile",
|
||||
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(
|
||||
target,
|
||||
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.",
|
||||
]);
|
||||
|
||||
/** 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 =
|
||||
"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.";
|
||||
|
||||
/** 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 = [
|
||||
...healthLimitations,
|
||||
...ghidraHealthLimitations(existingProject),
|
||||
"Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.",
|
||||
];
|
||||
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. */
|
||||
export const windowsP0Capabilities = (
|
||||
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 { z } from "zod";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
@@ -53,7 +56,6 @@ import {
|
||||
resourceConstraintRecovery,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
import {
|
||||
providerRetryAction,
|
||||
@@ -93,7 +95,7 @@ import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js";
|
||||
import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js";
|
||||
import {
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
healthLimitations,
|
||||
ghidraHealthLimitations,
|
||||
windowsP0Limitations,
|
||||
limitationsFor,
|
||||
} from "./GhidraProviderCapabilities.js";
|
||||
@@ -133,6 +135,7 @@ export type GhidraProviderClientFactory = (
|
||||
type GhidraProviderClientInstance = ReturnType<GhidraProviderClientFactory>;
|
||||
|
||||
interface GhidraExecutionMetadata {
|
||||
readonly sourceEvidence?: readonly Evidence[];
|
||||
readonly cachePolicy?: "live";
|
||||
readonly rawResult?: JsonValue;
|
||||
readonly limitations?: readonly string[];
|
||||
@@ -207,6 +210,29 @@ export const createGhidraProviderClient = (input: {
|
||||
}),
|
||||
);
|
||||
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
|
||||
.optional()
|
||||
.safeParse(committedProfile.parameters.analysis_seeds);
|
||||
@@ -381,7 +407,18 @@ export const createGhidraProviderClient = (input: {
|
||||
readStartupTimeoutMs: () => config.ghidraStartupTimeoutMs,
|
||||
bridgeMaxRequestBytes: config.bridgeMaxRequestBytes,
|
||||
platform: installation.platform,
|
||||
...(project === undefined ? {} : { existingProject: true }),
|
||||
launcher: new GhidraHeadlessLauncher({
|
||||
...(project === undefined
|
||||
? {}
|
||||
: {
|
||||
existingProject: {
|
||||
projectPath: project.project_path,
|
||||
projectName: project.project_name,
|
||||
programName: project.program_name,
|
||||
sha256: project.sha256,
|
||||
},
|
||||
}),
|
||||
environment: input.environment,
|
||||
analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath,
|
||||
configurationDirectoryName:
|
||||
@@ -474,6 +511,7 @@ export const createGhidraProviderClient = (input: {
|
||||
let providerStarted = false;
|
||||
const lostProviderHealth = (): ProviderOperationHealth | undefined =>
|
||||
providerStarted ? client.operationHealthSnapshot?.() : undefined;
|
||||
let projectHistory: Evidence | undefined;
|
||||
const checkSessionObservations = async (
|
||||
operation: AnalysisOperation,
|
||||
info: GhidraSessionInfo,
|
||||
@@ -485,48 +523,62 @@ export const createGhidraProviderClient = (input: {
|
||||
...ghidraPePdbLimitations(target, info.pdb),
|
||||
);
|
||||
}
|
||||
const softFloatReason = ghidraMipsSoftFloatFailure(
|
||||
softFloat,
|
||||
info.mips_soft_float_storage,
|
||||
);
|
||||
extensionFailure =
|
||||
ghidraExtensionFailure(
|
||||
if (project !== undefined) {
|
||||
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)
|
||||
projectHistory ??= ghidraProjectHistoryEvidence(
|
||||
target,
|
||||
committedProfile,
|
||||
info,
|
||||
);
|
||||
} else {
|
||||
const softFloatReason = ghidraMipsSoftFloatFailure(
|
||||
softFloat,
|
||||
info.mips_soft_float_storage,
|
||||
);
|
||||
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;
|
||||
const closed = await client.close();
|
||||
return closed.ok
|
||||
@@ -586,9 +638,17 @@ export const createGhidraProviderClient = (input: {
|
||||
) => {
|
||||
return createAnalysisExecution(normalized, committedProfile.provider, {
|
||||
...observation,
|
||||
...(projectHistory === undefined
|
||||
? {}
|
||||
: {
|
||||
sourceEvidence: [
|
||||
...(observation.sourceEvidence ?? []),
|
||||
projectHistory,
|
||||
],
|
||||
}),
|
||||
analysisProfile: committedProfile,
|
||||
limitations: [
|
||||
...limitationsFor(operation),
|
||||
...limitationsFor(operation, project !== undefined),
|
||||
...(observation.limitations ?? []),
|
||||
...sessionLimitations(),
|
||||
],
|
||||
@@ -679,7 +739,10 @@ export const createGhidraProviderClient = (input: {
|
||||
return ok(
|
||||
createAnalysisExecution(started.value, committedProfile.provider, {
|
||||
analysisProfile: committedProfile,
|
||||
limitations: [...healthLimitations, ...sessionLimitations()],
|
||||
limitations: [
|
||||
...ghidraHealthLimitations(project !== undefined),
|
||||
...sessionLimitations(),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -688,6 +751,7 @@ export const createGhidraProviderClient = (input: {
|
||||
: parseGhidraInventoryInput(operation, parameters);
|
||||
if (!input.ok) return input;
|
||||
if (
|
||||
project !== undefined ||
|
||||
target.format === "pe" ||
|
||||
extensions.length > 0 ||
|
||||
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 { 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 = [
|
||||
"ping",
|
||||
"shutdown",
|
||||
@@ -47,7 +47,8 @@ const sessionInfoSchema = z
|
||||
})
|
||||
.strict(),
|
||||
read_only: z.boolean(),
|
||||
analysis_complete: z.boolean(),
|
||||
analysis_complete: z.boolean().nullable(),
|
||||
existing_project: z.literal(true).optional(),
|
||||
analysis_timed_out: z.boolean(),
|
||||
/** Program Information attributes; absent load state remains unknown. */
|
||||
pdb: z
|
||||
@@ -107,7 +108,7 @@ const sessionInfoSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Authenticated metadata produced after Ghidra finishes auto-analysis. */
|
||||
/** Authenticated Program metadata; original analysis can remain unknown. */
|
||||
export type GhidraSessionInfo = z.infer<typeof sessionInfoSchema>;
|
||||
|
||||
/** Describe missing PDB authority without claiming all names are generated. */
|
||||
@@ -155,6 +156,7 @@ export const parseGhidraSessionInfo = (
|
||||
readonly profileDigest: string;
|
||||
readonly targetSha256: string;
|
||||
readonly expectedReadOnly?: boolean;
|
||||
readonly expectedExistingProject?: boolean;
|
||||
readonly expectedLanguageId?: string;
|
||||
readonly expectedCompilerSpecId?: string;
|
||||
},
|
||||
@@ -181,7 +183,13 @@ export const parseGhidraSessionInfo = (
|
||||
expectedCapabilities.some(
|
||||
(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 ok(parsed.data);
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { machoTargetSelectionSchema } from "../domain/apple/machoTargetSelection.js";
|
||||
import { createFunctionCoordinateContext } from "../domain/native/functionCoordinateContext.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { open, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
createReadStream: vi.fn(actual.createReadStream),
|
||||
};
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return { ...actual, open: vi.fn(actual.open) };
|
||||
});
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
|
||||
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
@@ -28,6 +24,14 @@ let inspectionResources = createInspectionResources();
|
||||
afterEach(async () => {
|
||||
await inspectionResources.close();
|
||||
inspectionResources = createInspectionResources();
|
||||
vi.mocked(open).mockReset();
|
||||
vi.mocked(open).mockImplementation(
|
||||
(
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
)
|
||||
).open,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes physical universal Mach-O containers through Hopper's FAT loader", () => {
|
||||
@@ -214,51 +218,30 @@ it("commits a thin header selection only for derived Hopper loader arguments", a
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a read failure primary when cancellation arrives with the stream error", async () => {
|
||||
const path = "/fixture/arm64.macho";
|
||||
const target = {
|
||||
path,
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable" as const,
|
||||
format: "mach-o" as const,
|
||||
architecture: "arm64" as const,
|
||||
availableArchitectures: ["arm64" as const],
|
||||
};
|
||||
it("keeps a read failure primary when cancellation arrives during descriptor cleanup", async () => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-profile-read-");
|
||||
const path = join(directory, "arm64.macho");
|
||||
await writeFile(path, Buffer.from([0xfe, 0xed, 0xfa, 0xcf]));
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel-after-read-error" };
|
||||
const physicalFailure = new Error("fixture read failure");
|
||||
const stream = new PassThrough();
|
||||
vi.mocked(createReadStream).mockReturnValueOnce(
|
||||
stream as unknown as ReturnType<typeof createReadStream>,
|
||||
);
|
||||
stream.once("error", () => controller.abort(reason));
|
||||
|
||||
const resolving = resolveHopperAnalysisProfile(target, {
|
||||
launcherPath: "/fixture/hopper",
|
||||
loaderArgsOverride: [],
|
||||
provider: { id: "hopper", name: "Hopper", version: null },
|
||||
resourceScope: inspectionResources.owner,
|
||||
signal: controller.signal,
|
||||
const actualFs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
vi.mocked(open).mockImplementationOnce(async (...args) => {
|
||||
const handle = await actualFs.open(...args);
|
||||
vi.spyOn(handle, "read").mockRejectedValueOnce(physicalFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
controller.abort(reason);
|
||||
await close();
|
||||
});
|
||||
return handle;
|
||||
});
|
||||
stream.destroy(physicalFailure);
|
||||
|
||||
const result = await resolving;
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("A failed header read was accepted");
|
||||
expect(result.error._tag).toBe("BinaryTargetError");
|
||||
expect(result.error.cause).toBe(physicalFailure);
|
||||
});
|
||||
|
||||
it("retains the exact caller reason when the owned header stream is aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel-header-read" };
|
||||
const stream = new PassThrough();
|
||||
vi.mocked(createReadStream).mockReturnValueOnce(
|
||||
stream as unknown as ReturnType<typeof createReadStream>,
|
||||
);
|
||||
const resolving = resolveHopperAnalysisProfile(
|
||||
const result = await resolveHopperAnalysisProfile(
|
||||
{
|
||||
path: "/fixture/arm64.macho",
|
||||
path,
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
@@ -266,20 +249,60 @@ it("retains the exact caller reason when the owned header stream is aborted", as
|
||||
availableArchitectures: ["arm64"],
|
||||
},
|
||||
{
|
||||
launcherPath: "/fixture/hopper",
|
||||
launcherPath: path,
|
||||
loaderArgsOverride: [],
|
||||
provider: { id: "hopper", name: "Hopper", version: null },
|
||||
resourceScope: inspectionResources.owner,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
controller.abort(reason);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BinaryTargetError", cause: physicalFailure },
|
||||
});
|
||||
});
|
||||
|
||||
const result = await resolving;
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("An aborted header read was accepted");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
it("retains the exact caller reason when cancellation arrives during the owned header read", async () => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-profile-abort-");
|
||||
const path = join(directory, "arm64.macho");
|
||||
await writeFile(path, Buffer.from([0xfe, 0xed, 0xfa, 0xcf]));
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel-header-read" };
|
||||
const actualFs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
vi.mocked(open).mockImplementationOnce(async (...args) => {
|
||||
const handle = await actualFs.open(...args);
|
||||
const read = handle.read.bind(handle);
|
||||
vi.spyOn(handle, "read").mockImplementationOnce(async (...readArgs) => {
|
||||
const result = await read(...readArgs);
|
||||
controller.abort(reason);
|
||||
return result;
|
||||
});
|
||||
return handle;
|
||||
});
|
||||
const result = await resolveHopperAnalysisProfile(
|
||||
{
|
||||
path,
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
},
|
||||
{
|
||||
launcherPath: path,
|
||||
loaderArgsOverride: [],
|
||||
provider: { id: "hopper", name: "Hopper", version: null },
|
||||
resourceScope: inspectionResources.owner,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", cause: reason },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner", async () => {
|
||||
@@ -295,10 +318,6 @@ it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
const actualNodeFs =
|
||||
await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
vi.mocked(createReadStream).mockReset();
|
||||
vi.mocked(createReadStream).mockImplementation(actualNodeFs.createReadStream);
|
||||
const closeFailure = new Error("profile preflight close failure");
|
||||
let allowClose = false;
|
||||
const imageFileSystem = {
|
||||
@@ -354,10 +373,7 @@ it("preserves launcher fingerprint filesystem failures instead of an absent prof
|
||||
new Error("EACCES: fixture launcher read denied"),
|
||||
{ code: "EACCES" },
|
||||
);
|
||||
const stream = new PassThrough();
|
||||
vi.mocked(createReadStream).mockReturnValueOnce(
|
||||
stream as unknown as ReturnType<typeof createReadStream>,
|
||||
);
|
||||
vi.mocked(open).mockRejectedValueOnce(failure);
|
||||
const resolving = resolveHopperAnalysisProfile(
|
||||
{
|
||||
path: "/fixture/input",
|
||||
@@ -374,7 +390,6 @@ it("preserves launcher fingerprint filesystem failures instead of an absent prof
|
||||
resourceScope: inspectionResources.owner,
|
||||
},
|
||||
);
|
||||
stream.destroy(failure);
|
||||
const result = await resolving;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
@@ -385,3 +400,198 @@ it("preserves launcher fingerprint filesystem failures instead of an absent prof
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each(["header", "launcher"] as const)(
|
||||
"rejects a real FIFO at the %s profile boundary without a writer",
|
||||
async (kind) => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-profile-fifo-");
|
||||
const path = join(directory, "hopper.fifo");
|
||||
execFileSync("mkfifo", [path]);
|
||||
const result = await resolveHopperAnalysisProfile(
|
||||
{
|
||||
path,
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: kind === "header" ? "mach-o" : "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
},
|
||||
{
|
||||
launcherPath: path,
|
||||
loaderArgsOverride: [],
|
||||
provider: { id: "hopper", name: "Hopper", version: null },
|
||||
resourceScope: inspectionResources.owner,
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: kind === "header" ? "BinaryTargetError" : "ProviderAdapterError",
|
||||
cause: { name: "NonRegularFileReadError", path },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["header", false],
|
||||
["header", true],
|
||||
["launcher", false],
|
||||
["launcher", true],
|
||||
] as const)(
|
||||
"retains the %s descriptor through failed close, late cancellation and retry (read failed: %s)",
|
||||
async (kind, readFails) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-hopper-profile-close-",
|
||||
);
|
||||
const path = join(directory, "selected-file");
|
||||
await writeFile(path, Buffer.from([0xfe, 0xed, 0xfa, 0xcf]));
|
||||
const actualFs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const closeFailure = new Error("profile descriptor close failed");
|
||||
const physicalFailure = new Error("profile descriptor read failed");
|
||||
const reason = { request: "cancel-during-final-close" };
|
||||
let allowClose = false;
|
||||
let closeAttempts = 0;
|
||||
vi.mocked(open).mockImplementationOnce(async (...args) => {
|
||||
const handle = await actualFs.open(...args);
|
||||
if (readFails)
|
||||
vi.spyOn(handle, "read").mockRejectedValueOnce(physicalFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closeAttempts += 1;
|
||||
if (!allowClose) {
|
||||
controller.abort(reason);
|
||||
throw closeFailure;
|
||||
}
|
||||
await close();
|
||||
});
|
||||
return handle;
|
||||
});
|
||||
const target = {
|
||||
path,
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable" as const,
|
||||
format: kind === "header" ? ("mach-o" as const) : ("elf" as const),
|
||||
architecture: "x86_64" as const,
|
||||
availableArchitectures: ["x86_64" as const],
|
||||
};
|
||||
const options = {
|
||||
launcherPath: path,
|
||||
loaderArgsOverride: ["--fixture"],
|
||||
provider: { id: "hopper", name: "Hopper", version: null },
|
||||
resourceScope: inspectionResources.owner,
|
||||
};
|
||||
const first = await resolveHopperAnalysisProfile(target, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(first.ok).toBe(false);
|
||||
if (first.ok) throw new Error("Failed close was accepted");
|
||||
expect(first.error.cleanupIncomplete).toBe(true);
|
||||
expect(first.error.cleanupResources).toEqual([path]);
|
||||
expect(first.error.cause).toBe(readFails ? physicalFailure : reason);
|
||||
const cleanup = first.error.secondaryFailures[0];
|
||||
expect(cleanup?.cause).toBeInstanceOf(RegularFileCleanupFailure);
|
||||
expect(cleanup?.cause).toMatchObject({
|
||||
path,
|
||||
cleanupCause: closeFailure,
|
||||
outcome: { kind: readFails ? "failed" : "completed" },
|
||||
});
|
||||
if (!readFails)
|
||||
expect(first.error.diagnostics).toMatchObject({
|
||||
completed_profile_read:
|
||||
kind === "header"
|
||||
? 0xfeedfacf
|
||||
: {
|
||||
sha256: createHash("sha256")
|
||||
.update(Buffer.from([0xfe, 0xed, 0xfa, 0xcf]))
|
||||
.digest("hex"),
|
||||
bytes: 4,
|
||||
},
|
||||
});
|
||||
expect(first.error._tag).toBe(
|
||||
readFails
|
||||
? kind === "header"
|
||||
? "BinaryTargetError"
|
||||
: "ProviderAdapterError"
|
||||
: "AnalysisCancelledError",
|
||||
);
|
||||
const retry = await resolveHopperAnalysisProfile(target, options);
|
||||
expect(retry.ok).toBe(false);
|
||||
if (retry.ok) throw new Error("Failed cleanup retry was accepted");
|
||||
expect(retry.error.cleanupIncomplete).toBe(true);
|
||||
expect(retry.error.cleanupResources).toEqual([path]);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(closeAttempts).toBe(2);
|
||||
allowClose = true;
|
||||
await inspectionResources.close();
|
||||
expect(closeAttempts).toBe(3);
|
||||
expect(first.error.cleanupIncomplete).toBe(false);
|
||||
expect(retry.error.cleanupIncomplete).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["header", "launcher"] as const)(
|
||||
"retains completed %s facts when cancellation arrives during successful final close",
|
||||
async (kind) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-hopper-profile-late-abort-",
|
||||
);
|
||||
const path = join(directory, "selected-file");
|
||||
const bytes = Buffer.from([0xfe, 0xed, 0xfa, 0xcf]);
|
||||
await writeFile(path, bytes);
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel-on-final-close" };
|
||||
const actualFs =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
vi.mocked(open).mockImplementationOnce(async (...args) => {
|
||||
const handle = await actualFs.open(...args);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
await close();
|
||||
controller.abort(reason);
|
||||
});
|
||||
return handle;
|
||||
});
|
||||
const result = await resolveHopperAnalysisProfile(
|
||||
{
|
||||
path,
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: kind === "header" ? "mach-o" : "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
},
|
||||
{
|
||||
launcherPath: path,
|
||||
loaderArgsOverride: ["--fixture"],
|
||||
provider: { id: "hopper", name: "Hopper", version: null },
|
||||
resourceScope: inspectionResources.owner,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
cleanupIncomplete: false,
|
||||
diagnostics: {
|
||||
completed_profile_read:
|
||||
kind === "header"
|
||||
? 0xfeedfacf
|
||||
: {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
+174
-100
@@ -1,5 +1,19 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactResourceRetryFailure } from "../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
hashStableFile,
|
||||
stableFileHashCause,
|
||||
type StableFileHashOptions,
|
||||
} from "../filesystem/StableFileHash.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
RegularFileCancelled,
|
||||
regularFileCancellation,
|
||||
withOwnedRegularFile,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
|
||||
import { assertRegularFileActive } from "../filesystem/RegularFile.js";
|
||||
|
||||
import type {
|
||||
AnalysisProfileResolution,
|
||||
@@ -22,6 +36,8 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
machoTargetSelectionSchema,
|
||||
type MachoTargetSelection,
|
||||
@@ -54,13 +70,19 @@ export const resolveHopperAnalysisProfile = async (
|
||||
resolveHopperAnalysisProfileInScope(target, options),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError("hopper", "resolve_analysis_profile", {
|
||||
cause,
|
||||
}),
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
const failure = new ProviderAdapterError(
|
||||
"hopper",
|
||||
"resolve_analysis_profile",
|
||||
{
|
||||
cause,
|
||||
...(cause instanceof ArtifactResourceRetryFailure
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, failure);
|
||||
return err(failure);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,7 +94,11 @@ const resolveHopperAnalysisProfileInScope = async (
|
||||
return ok({ profile: null });
|
||||
let machoContainer: "thin" | "fat32" | "fat64" | undefined;
|
||||
if (target.kind === "executable" && isMachOFormat(target.format)) {
|
||||
const header = await readMachoMagic(target.path, options.signal);
|
||||
const header = await readMachoMagic(
|
||||
target.path,
|
||||
options.resourceScope,
|
||||
options.signal,
|
||||
);
|
||||
if (!header.ok) return header;
|
||||
machoContainer = [0xcafebabf, 0xbfbafeca].includes(header.value)
|
||||
? "fat64"
|
||||
@@ -127,7 +153,11 @@ const resolveHopperAnalysisProfileInScope = async (
|
||||
options.loaderArgsOverride.length === 0
|
||||
? derived.value
|
||||
: [...options.loaderArgsOverride];
|
||||
const launcherDigest = await sha256File(options.launcherPath, options.signal);
|
||||
const launcherDigest = await sha256File(
|
||||
options.launcherPath,
|
||||
options.resourceScope,
|
||||
options.signal,
|
||||
);
|
||||
if (!launcherDigest.ok) return launcherDigest;
|
||||
const provider = {
|
||||
id: options.provider.id,
|
||||
@@ -269,102 +299,146 @@ const hopperArchitectureFlag = (architecture: HeaderArchitecture): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const readMachoMagic = async (
|
||||
const readMachoMagic = (
|
||||
path: string,
|
||||
resources: RetainedResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<number, AnalysisError>> => {
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
);
|
||||
const stream = createReadStream(path, { start: 0, end: 3 });
|
||||
const abortError = new Error("Caller cancelled Mach-O header read");
|
||||
const onAbort = (): void => {
|
||||
stream.destroy(abortError);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) onAbort();
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
);
|
||||
const bytes = Buffer.concat(chunks);
|
||||
return bytes.length === 4
|
||||
? ok(bytes.readUInt32BE(0))
|
||||
: err(
|
||||
new BinaryTargetError(
|
||||
path,
|
||||
"Mach-O header became truncated before Hopper loader selection",
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return cause === abortError
|
||||
? err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
)
|
||||
: err(
|
||||
new BinaryTargetError(
|
||||
path,
|
||||
`Cannot read the Mach-O container header: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
): Promise<Result<number, AnalysisError>> =>
|
||||
readHopperProfileFile(
|
||||
{ path, kind: "header" },
|
||||
resources,
|
||||
signal,
|
||||
(options) =>
|
||||
withOwnedRegularFile(path, options, async ({ handle }) => {
|
||||
const bytes = Buffer.alloc(4);
|
||||
let length = 0;
|
||||
while (length < bytes.length) {
|
||||
assertRegularFileActive(signal);
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
length,
|
||||
bytes.length - length,
|
||||
length,
|
||||
);
|
||||
if (bytesRead === 0)
|
||||
throw new BinaryTargetError(
|
||||
path,
|
||||
"Mach-O header became truncated before Hopper loader selection",
|
||||
);
|
||||
length += bytesRead;
|
||||
}
|
||||
return bytes.readUInt32BE(0);
|
||||
}),
|
||||
);
|
||||
|
||||
const sha256File = async (
|
||||
const sha256File = (
|
||||
path: string,
|
||||
resources: RetainedResourceScope,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<string, AnalysisError>> => {
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
);
|
||||
const stream = createReadStream(path);
|
||||
const abortError = new Error("Caller cancelled launcher fingerprint read");
|
||||
const onAbort = (): void => {
|
||||
stream.destroy(abortError);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) onAbort();
|
||||
): Promise<Result<string, AnalysisError>> =>
|
||||
readHopperProfileFile(
|
||||
{ path, kind: "launcher" },
|
||||
resources,
|
||||
signal,
|
||||
async (options) => (await hashStableFile(path, options)).sha256,
|
||||
);
|
||||
|
||||
/** Both profile reads retain the exact descriptor through final close and retry. */
|
||||
const readHopperProfileFile = async <Value>(
|
||||
source: { readonly path: string; readonly kind: "header" | "launcher" },
|
||||
resources: RetainedResourceScope,
|
||||
signal: AbortSignal | undefined,
|
||||
read: (options: StableFileHashOptions) => Promise<Value>,
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
const { path, kind } = source;
|
||||
let closeLease: AnalysisCleanupLease | undefined;
|
||||
try {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of stream) hash.update(chunk);
|
||||
return signalIsAborted(signal)
|
||||
? err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
)
|
||||
: ok(hash.digest("hex"));
|
||||
} catch (cause: unknown) {
|
||||
return cause === abortError
|
||||
? err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
)
|
||||
: err(
|
||||
new ProviderAdapterError("hopper", "resolve_analysis_profile", {
|
||||
cause,
|
||||
userMessage: `Cannot fingerprint the selected Hopper launcher ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
remediationAction:
|
||||
"Restore read access to the selected Hopper launcher, then retry provider selection.",
|
||||
diagnostics: {
|
||||
launcher_path: path,
|
||||
system_code:
|
||||
cause instanceof Error &&
|
||||
typeof Reflect.get(cause, "code") === "string"
|
||||
? (Reflect.get(cause, "code") as string)
|
||||
: null,
|
||||
return ok(
|
||||
await read({
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
retainFailedClose: (owner) => {
|
||||
closeLease = AnalysisCleanupLease.forCloseOwner(owner, path);
|
||||
resources.retain(
|
||||
{
|
||||
close: () => closeLease!.release(),
|
||||
get cleanupResources() {
|
||||
return closeLease!.cleanupResources;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
path,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
const physical = stableFileHashCause(
|
||||
cause instanceof RegularFileCleanupFailure
|
||||
? cause.outcome.kind === "failed"
|
||||
? cause.outcome.cause
|
||||
: cause.cleanupCause
|
||||
: cause,
|
||||
);
|
||||
const cleanup =
|
||||
cause instanceof RegularFileCleanupFailure
|
||||
? {
|
||||
reason:
|
||||
cause.cleanupCause instanceof Error
|
||||
? cause.cleanupCause.message
|
||||
: String(cause.cleanupCause),
|
||||
resources: [path],
|
||||
}
|
||||
: undefined;
|
||||
const cancellation = regularFileCancellation(cause);
|
||||
const completed =
|
||||
(cause instanceof RegularFileCleanupFailure ||
|
||||
cause instanceof RegularFileCancelled) &&
|
||||
cause.outcome.kind === "completed"
|
||||
? { completed_profile_read: jsonValueSchema.parse(cause.outcome.value) }
|
||||
: {};
|
||||
const diagnostics = { profile_file: { kind, path }, ...completed };
|
||||
const failure =
|
||||
cancellation !== undefined
|
||||
? new AnalysisCancelledError("open_binary", {
|
||||
cause: cancellation.reason,
|
||||
cleanup,
|
||||
diagnostics,
|
||||
})
|
||||
: physical instanceof AnalysisError
|
||||
? physical
|
||||
: kind === "header"
|
||||
? new BinaryTargetError(
|
||||
path,
|
||||
`Cannot read the Mach-O container header: ${physical instanceof Error ? physical.message : String(physical)}`,
|
||||
{ cause: physical, cleanup },
|
||||
)
|
||||
: new ProviderAdapterError("hopper", "resolve_analysis_profile", {
|
||||
cause: physical,
|
||||
cleanup,
|
||||
userMessage: `Cannot fingerprint the selected Hopper launcher ${path}: ${physical instanceof Error ? physical.message : String(physical)}`,
|
||||
remediationAction:
|
||||
"Select a readable regular Hopper launcher, then retry provider selection.",
|
||||
diagnostics: {
|
||||
launcher_path: path,
|
||||
...diagnostics,
|
||||
system_code:
|
||||
physical instanceof Error &&
|
||||
typeof Reflect.get(physical, "code") === "string"
|
||||
? (Reflect.get(physical, "code") as string)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const cleanupFailure = new ProviderCleanupError(
|
||||
"hopper",
|
||||
[path],
|
||||
diagnostics,
|
||||
{ cause, operation: "resolve_analysis_profile" },
|
||||
);
|
||||
closeLease?.bindCleanupError(cleanupFailure);
|
||||
failure
|
||||
.retainSecondaryFailure(cleanupFailure)
|
||||
.bindCleanupDependencies([cleanupFailure]);
|
||||
}
|
||||
return err(failure);
|
||||
}
|
||||
};
|
||||
|
||||
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
@@ -16,6 +16,7 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import {
|
||||
RegularFileCancelled,
|
||||
RegularFileCleanupFailure,
|
||||
@@ -99,7 +100,6 @@ export async function mapHopperFileOffset(
|
||||
observation,
|
||||
partial,
|
||||
unavailable,
|
||||
signal,
|
||||
publish,
|
||||
withCleanup: (primary, marker) =>
|
||||
analysisErrorWithCleanupFailure(
|
||||
@@ -125,9 +125,25 @@ export async function mapHopperFileOffset(
|
||||
return err(
|
||||
artifactChanged(target.path, captured.error, partial(captured.error)),
|
||||
);
|
||||
return err(cancelled(partial, cause.reason));
|
||||
const verified = coordinatesForSnapshot(verification, captured);
|
||||
if (!verified.result.ok)
|
||||
return err(
|
||||
retainSnapshot(
|
||||
verified.result.error,
|
||||
captured.value,
|
||||
verification.observation,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
retainSnapshot(
|
||||
cancelled(partial, cause.reason),
|
||||
captured.value,
|
||||
verification.observation,
|
||||
verified.fileOffset,
|
||||
),
|
||||
);
|
||||
}
|
||||
return err(sourceAccessFailure(target.path, cause, partial, signal));
|
||||
return err(sourceAccessFailure(target.path, cause, partial));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,18 +201,9 @@ const sourceAccessFailure = (
|
||||
path: string,
|
||||
cause: unknown,
|
||||
partial: (reason: string) => FileOffsetPartialObservation,
|
||||
signal: AbortSignal | undefined,
|
||||
): AnalysisError => {
|
||||
if (
|
||||
signal?.aborted === true ||
|
||||
cause instanceof RegularFileOperationCancelled
|
||||
)
|
||||
return cancelled(
|
||||
partial,
|
||||
cause instanceof RegularFileOperationCancelled
|
||||
? cause.reason
|
||||
: signal?.reason,
|
||||
);
|
||||
if (cause instanceof RegularFileOperationCancelled)
|
||||
return cancelled(partial, cause.reason);
|
||||
const reason = `Cannot verify original source bytes: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
const code =
|
||||
typeof cause === "object" && cause !== null && "code" in cause
|
||||
@@ -233,7 +240,6 @@ interface OffsetVerification {
|
||||
readonly observation: Observation;
|
||||
readonly partial: (reason: string) => FileOffsetPartialObservation;
|
||||
readonly unavailable: (reason: string) => Result<JsonValue, AnalysisError>;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly withCleanup: (
|
||||
primary: AnalysisError,
|
||||
marker: ProviderCleanupError,
|
||||
@@ -245,17 +251,12 @@ const cleanupAfterSnapshot = (
|
||||
verification: OffsetVerification,
|
||||
failure: RegularFileCleanupFailure<unknown>,
|
||||
): Result<JsonValue, AnalysisError> => {
|
||||
const { target, partial, signal, withCleanup, publish } = verification;
|
||||
const { target, partial, withCleanup, publish } = verification;
|
||||
const marker = descriptorCleanup(failure);
|
||||
if (failure.outcome.kind === "failed")
|
||||
return err(
|
||||
withCleanup(
|
||||
sourceAccessFailure(
|
||||
target.path,
|
||||
failure.outcome.cause,
|
||||
partial,
|
||||
signal,
|
||||
),
|
||||
sourceAccessFailure(target.path, failure.outcome.cause, partial),
|
||||
marker,
|
||||
),
|
||||
);
|
||||
@@ -268,35 +269,63 @@ const cleanupAfterSnapshot = (
|
||||
marker,
|
||||
),
|
||||
);
|
||||
if (signal?.aborted === true || failure.completionCancellation !== undefined)
|
||||
const verified = coordinatesForSnapshot(verification, captured);
|
||||
if (!verified.result.ok)
|
||||
return err(
|
||||
withCleanup(
|
||||
cancelled(
|
||||
partial,
|
||||
failure.completionCancellation?.reason ?? signal?.reason,
|
||||
retainSnapshot(
|
||||
verified.result.error,
|
||||
captured.value,
|
||||
verification.observation,
|
||||
),
|
||||
marker,
|
||||
),
|
||||
);
|
||||
const verified = coordinatesForSnapshot(verification, captured);
|
||||
if (!verified.result.ok)
|
||||
return err(withCleanup(verified.result.error, marker));
|
||||
const published = publish(marker);
|
||||
published.retainPartialObservation(
|
||||
partial(
|
||||
"Source descriptor close failed after the admitted bytes were read",
|
||||
),
|
||||
if (failure.completionCancellation !== undefined)
|
||||
return err(
|
||||
withCleanup(
|
||||
retainSnapshot(
|
||||
cancelled(partial, failure.completionCancellation.reason),
|
||||
captured.value,
|
||||
verification.observation,
|
||||
verified.fileOffset,
|
||||
),
|
||||
marker,
|
||||
),
|
||||
);
|
||||
const published = retainSnapshot(
|
||||
publish(marker),
|
||||
captured.value,
|
||||
verification.observation,
|
||||
verified.fileOffset,
|
||||
);
|
||||
published.retainDiagnostics({
|
||||
source_sha256: captured.value.sha256,
|
||||
...(verified.fileOffset === undefined
|
||||
? {}
|
||||
: { file_offset: verified.fileOffset }),
|
||||
source_identity: "verified",
|
||||
});
|
||||
return err(published);
|
||||
};
|
||||
|
||||
const retainSnapshot = <Failure extends AnalysisError>(
|
||||
failure: Failure,
|
||||
snapshot: SourceSnapshot,
|
||||
observation: Observation,
|
||||
fileOffset?: number,
|
||||
): Failure => {
|
||||
if (fileOffset !== undefined)
|
||||
failure.partialObservation = {
|
||||
operation: OPERATION,
|
||||
...observation,
|
||||
original_file_offset: {
|
||||
available: true,
|
||||
file_offset: fileOffset,
|
||||
source_sha256: snapshot.sha256,
|
||||
},
|
||||
};
|
||||
return failure.retainDiagnostics({
|
||||
source_sha256: snapshot.sha256,
|
||||
...(fileOffset === undefined
|
||||
? {}
|
||||
: { file_offset: fileOffset, source_identity: "verified" }),
|
||||
});
|
||||
};
|
||||
|
||||
const coordinatesForSnapshot = (
|
||||
verification: OffsetVerification,
|
||||
captured: Result<SourceSnapshot, string>,
|
||||
@@ -304,7 +333,7 @@ const coordinatesForSnapshot = (
|
||||
readonly result: Result<JsonValue, AnalysisError>;
|
||||
readonly fileOffset?: number;
|
||||
} => {
|
||||
const { target, observation, partial, unavailable, signal } = verification;
|
||||
const { target, observation, partial, unavailable } = verification;
|
||||
if (!captured.ok)
|
||||
return {
|
||||
result: err(
|
||||
@@ -312,8 +341,6 @@ const coordinatesForSnapshot = (
|
||||
),
|
||||
};
|
||||
const snapshot = captured.value;
|
||||
if (signal?.aborted === true)
|
||||
return { result: err(cancelled(partial, signal.reason)) };
|
||||
if (snapshot.sha256 !== target.sha256) {
|
||||
const reason = `Source ${target.path} differs from the admitted artifact: expected SHA-256 ${target.sha256}, observed ${snapshot.sha256}. Reopen the changed source for a new analysis identity.`;
|
||||
return {
|
||||
@@ -384,10 +411,9 @@ async function readSourceSnapshot(file: FileHandle, signal?: AbortSignal) {
|
||||
);
|
||||
}
|
||||
};
|
||||
for await (const chunk of file.createReadStream({
|
||||
start: 0,
|
||||
autoClose: false,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
for await (const chunk of readFileHandleChunks(file, {
|
||||
signal,
|
||||
cancellationFailure: (reason) => new RegularFileOperationCancelled(reason),
|
||||
})) {
|
||||
const bytes = Buffer.from(chunk);
|
||||
hash.update(bytes);
|
||||
|
||||
@@ -695,3 +695,59 @@ it("keeps error, truncation and native-text precedence ahead of structured JSON
|
||||
),
|
||||
).toEqual({ complete: true });
|
||||
});
|
||||
|
||||
it("requires a valid content array even when IDA supplies structured evidence", () => {
|
||||
for (const content of [
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
[null],
|
||||
[{ type: "text", text: 42 }],
|
||||
])
|
||||
expect(() =>
|
||||
decodeIdaToolResult({ content, structuredContent: { observed: true } }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("selects structured IDA evidence ahead of text while admitting mixed content", () => {
|
||||
const image = { type: "image", data: "AA==", mimeType: "image/png" };
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [image, { type: "text", text: "fallback" }],
|
||||
structuredContent: { observed: true },
|
||||
}),
|
||||
).toEqual({ observed: true });
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [image, { type: "text", text: "fallback" }],
|
||||
}),
|
||||
).toBe("fallback");
|
||||
for (const structuredContent of [null, [], "invalid", { observed: Infinity }])
|
||||
expect(() =>
|
||||
decodeIdaToolResult({
|
||||
content: [{ type: "text", text: "fallback" }],
|
||||
structuredContent,
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() => decodeIdaToolResult({ content: [image] })).toThrow(
|
||||
"empty observation",
|
||||
);
|
||||
expect(() =>
|
||||
decodeIdaToolResult({
|
||||
content: [
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
],
|
||||
}),
|
||||
).toThrow("empty observation");
|
||||
});
|
||||
|
||||
it("requires complete native IDA JSON text despite usable structured content", () => {
|
||||
for (const content of [[], [{ type: "text", text: "plain text" }]])
|
||||
expect(() =>
|
||||
decodeIdaToolResult(
|
||||
{ content, structuredContent: { observed: true } },
|
||||
"native",
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
+10
-18
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
mcpToolResultSchema,
|
||||
mcpTextBlocks,
|
||||
parseMcpJsonOrText,
|
||||
} from "../providers/McpToolResult.js";
|
||||
import { StructuredContentMcpClient } from "../providers/StructuredContentMcpClient.js";
|
||||
import {
|
||||
createIdaDiagnosticRedactor,
|
||||
@@ -71,12 +76,7 @@ export type IdaMcpConnectionDependencies = Readonly<{
|
||||
createDiagnosticRedactor?: typeof createIdaDiagnosticRedactor;
|
||||
}>;
|
||||
|
||||
const toolResultSchema = z.object({
|
||||
isError: z.boolean().optional(),
|
||||
structuredContent: z.record(z.string(), z.unknown()).optional(),
|
||||
content: z.array(
|
||||
z.object({ type: z.string(), text: z.string().optional() }).passthrough(),
|
||||
),
|
||||
const toolResultSchema = mcpToolResultSchema.extend({
|
||||
_meta: z
|
||||
.object({
|
||||
ida_mcp: z
|
||||
@@ -110,11 +110,7 @@ export const decodeIdaToolResult = (
|
||||
diagnostic: IdaDiagnosticRedactor = createIdaDiagnosticRedactor(),
|
||||
): JsonValue => {
|
||||
const result = toolResultSchema.parse(input);
|
||||
// Shape validation's record copy can omit own __proto__ members; JSON owns the original dictionary.
|
||||
const source = input as z.input<typeof toolResultSchema>;
|
||||
const blocks = result.content.flatMap((block) =>
|
||||
block.type === "text" && block.text !== undefined ? [block.text] : [],
|
||||
);
|
||||
const blocks = mcpTextBlocks(result.content);
|
||||
if (result.isError === true) {
|
||||
const reason = diagnostic.blocks(blocks);
|
||||
throw new AnalysisProtocolError(
|
||||
@@ -144,8 +140,8 @@ export const decodeIdaToolResult = (
|
||||
);
|
||||
return parseIdaJsonText(text);
|
||||
}
|
||||
if (source.structuredContent !== undefined) {
|
||||
const value = jsonObjectSchema.parse(source.structuredContent);
|
||||
if (result.structuredContent !== undefined) {
|
||||
const value = jsonObjectSchema.parse(result.structuredContent);
|
||||
return Object.keys(value).length === 1 && Object.hasOwn(value, "result")
|
||||
? value.result!
|
||||
: value;
|
||||
@@ -154,11 +150,7 @@ export const decodeIdaToolResult = (
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA MCP omitted a single structured or text result; unsupported content cannot establish an empty observation.",
|
||||
);
|
||||
try {
|
||||
return jsonValueSchema.parse(JSON.parse(text));
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
return parseMcpJsonOrText(text);
|
||||
};
|
||||
|
||||
// Retain only producer-sanitized JSON diagnostics, never a native parser cause.
|
||||
|
||||
@@ -462,3 +462,47 @@ it.each([false, true])(
|
||||
expect(JSON.stringify(decoded)).toBe(JSON.stringify(structured));
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts omitted JEB content only when structured evidence establishes an observation", () => {
|
||||
expect(
|
||||
decodeJebToolResult({ structuredContent: { result: "ordinary field" } }),
|
||||
).toEqual({ result: "ordinary field" });
|
||||
expect(() => decodeJebToolResult({})).toThrow("empty observation");
|
||||
for (const content of [null, {}, [null], [{ type: "text", text: 42 }]])
|
||||
expect(() =>
|
||||
decodeJebToolResult({ content, structuredContent: { success: true } }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("selects structured JEB evidence ahead of text while admitting mixed content", () => {
|
||||
const image = { type: "image", data: "AA==", mimeType: "image/png" };
|
||||
expect(
|
||||
decodeJebToolResult({
|
||||
content: [image, { type: "text", text: "fallback" }],
|
||||
structuredContent: { observed: true },
|
||||
}),
|
||||
).toEqual({ observed: true });
|
||||
expect(
|
||||
decodeJebToolResult({
|
||||
content: [image, { type: "text", text: "fallback" }],
|
||||
}),
|
||||
).toBe("fallback");
|
||||
for (const structuredContent of [null, [], "invalid", { observed: Infinity }])
|
||||
expect(() =>
|
||||
decodeJebToolResult({
|
||||
content: [{ type: "text", text: "fallback" }],
|
||||
structuredContent,
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() => decodeJebToolResult({ content: [image] })).toThrow(
|
||||
"empty observation",
|
||||
);
|
||||
expect(() =>
|
||||
decodeJebToolResult({
|
||||
content: [
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
],
|
||||
}),
|
||||
).toThrow("empty observation");
|
||||
});
|
||||
+11
-26
@@ -2,12 +2,12 @@ import {
|
||||
type Client,
|
||||
DEFAULT_REQUEST_TIMEOUT_MSEC,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../domain/jsonValue.js";
|
||||
mcpToolResultSchema,
|
||||
mcpTextBlocks,
|
||||
parseMcpJsonOrText,
|
||||
} from "../providers/McpToolResult.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisProtocolError,
|
||||
@@ -48,38 +48,23 @@ export interface JebMcpConnection {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const toolResultSchema = z.object({
|
||||
isError: z.boolean().optional(),
|
||||
structuredContent: z.record(z.string(), z.unknown()).optional(),
|
||||
content: z
|
||||
.array(
|
||||
z.object({ type: z.string(), text: z.string().optional() }).passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
const toolResultSchema = mcpToolResultSchema.extend({
|
||||
content: mcpToolResultSchema.shape.content.default([]),
|
||||
});
|
||||
|
||||
/** Decode a JEB MCP tool result without inventing an empty observation. */
|
||||
export const decodeJebToolResult = (input: unknown): JsonValue => {
|
||||
const result = toolResultSchema.parse(input);
|
||||
// Shape validation's record copy can omit own __proto__ members; JSON owns the original dictionary.
|
||||
const source = input as z.input<typeof toolResultSchema>;
|
||||
const blocks = result.content.flatMap((block) =>
|
||||
block.type === "text" && block.text !== undefined ? [block.text] : [],
|
||||
);
|
||||
const blocks = mcpTextBlocks(result.content);
|
||||
let decoded: JsonValue;
|
||||
if (source.structuredContent !== undefined) {
|
||||
decoded = jsonObjectSchema.parse(source.structuredContent);
|
||||
if (result.structuredContent !== undefined) {
|
||||
decoded = jsonObjectSchema.parse(result.structuredContent);
|
||||
} else {
|
||||
if (blocks.length !== 1)
|
||||
throw new AnalysisProtocolError(
|
||||
"JEB MCP omitted a single structured or text result; unsupported content cannot establish an empty observation.",
|
||||
);
|
||||
const text = blocks[0] ?? "null";
|
||||
try {
|
||||
decoded = jsonValueSchema.parse(JSON.parse(text));
|
||||
} catch {
|
||||
decoded = text;
|
||||
}
|
||||
decoded = parseMcpJsonOrText(blocks[0]!);
|
||||
}
|
||||
if (result.isError === true && !isJebFailureEnvelope(decoded))
|
||||
throw new AnalysisProtocolError(
|
||||
|
||||
@@ -175,6 +175,9 @@ export const runMcpHttpEntrypoint = async (
|
||||
const endpoint = parseMcpHttpEndpoint(snapshotEnvironment(process.env), {
|
||||
...(parsed.host === undefined ? {} : { host: parsed.host }),
|
||||
...(parsed.port === undefined ? {} : { port: parsed.port }),
|
||||
...(parsed.allowedHosts === undefined
|
||||
? {}
|
||||
: { allowedHosts: [...parsed.allowedHosts] }),
|
||||
});
|
||||
if (!endpoint.ok) {
|
||||
io.writeStderr(`${projectAnalysisError(endpoint.error).message}\n`);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Help, z } from "incur";
|
||||
import type { HttpHostname } from "../domain/httpHostname.js";
|
||||
|
||||
import {
|
||||
MCP_ALLOWED_HOSTS_FLAG_CONSTRAINT,
|
||||
MCP_HTTP_PORT_CONSTRAINT,
|
||||
mcpAllowedHostsTextSchema,
|
||||
mcpHttpListenerPortSchema,
|
||||
mcpHttpPortSchema,
|
||||
} from "../config/mcpHttpEndpoint.js";
|
||||
@@ -22,6 +25,12 @@ const MCP_HTTP_OPTIONS = z.object({
|
||||
port: mcpHttpListenerPortSchema
|
||||
.optional()
|
||||
.describe("Listener port; default MCP_PORT, else 0 for an ephemeral port"),
|
||||
allowedHosts: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Host/Origin names a non-loopback bind may serve; default MCP_ALLOWED_HOSTS, else the bind is refused",
|
||||
),
|
||||
help: z.boolean().optional().describe("Show help"),
|
||||
});
|
||||
|
||||
@@ -30,7 +39,11 @@ const MCP_HTTP_COMMAND = "rea mcp --http";
|
||||
const MCP_HTTP_DESCRIPTION =
|
||||
"Serve the MCP server over streamable HTTP for one local client";
|
||||
const HELP_FLAGS: ReadonlySet<string> = new Set(["--help", "-h"]);
|
||||
const VALUED_FLAGS: ReadonlySet<string> = new Set(["--host", "--port"]);
|
||||
const VALUED_FLAGS: ReadonlySet<string> = new Set([
|
||||
"--host",
|
||||
"--port",
|
||||
"--allowed-hosts",
|
||||
]);
|
||||
|
||||
/** Render help for the dispatcher entry, which accepts no Incur global options. */
|
||||
export const renderMcpHttpHelp = (): string =>
|
||||
@@ -44,7 +57,12 @@ export const renderMcpHttpHelp = (): string =>
|
||||
|
||||
/** Parsed `rea mcp --http` arguments; omitted values defer to the environment. */
|
||||
export type McpHttpArguments =
|
||||
| { readonly ok: true; readonly host?: string; readonly port?: number }
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly host?: string;
|
||||
readonly port?: number;
|
||||
readonly allowedHosts?: readonly HttpHostname[];
|
||||
}
|
||||
| { readonly ok: false; readonly help: true }
|
||||
| { readonly ok: false; readonly help: false; readonly message: string };
|
||||
|
||||
@@ -93,9 +111,19 @@ export const parseMcpHttpArguments = (
|
||||
portText === undefined ? undefined : mcpHttpPortSchema.safeParse(portText);
|
||||
if (port !== undefined && !port.success)
|
||||
return invalid(`Invalid --port: ${MCP_HTTP_PORT_CONSTRAINT}`);
|
||||
const allowedHostsText = values.get("--allowed-hosts");
|
||||
const allowedHosts =
|
||||
allowedHostsText === undefined
|
||||
? undefined
|
||||
: mcpAllowedHostsTextSchema.safeParse(allowedHostsText);
|
||||
if (allowedHosts !== undefined && !allowedHosts.success)
|
||||
return invalid(
|
||||
`Invalid --allowed-hosts: ${MCP_ALLOWED_HOSTS_FLAG_CONSTRAINT}`,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
...(host === undefined ? {} : { host }),
|
||||
...(port === undefined ? {} : { port: port.data }),
|
||||
...(allowedHosts === undefined ? {} : { allowedHosts: allowedHosts.data }),
|
||||
};
|
||||
};
|
||||
@@ -170,7 +170,10 @@ export const startMcpTransport = async (
|
||||
if (!cancelled) {
|
||||
const startFailure =
|
||||
cause instanceof StreamableHttpListenError
|
||||
? MCP_HTTP_LISTEN_FAILED
|
||||
? cause.classification.ok &&
|
||||
cause.classification.value.kind === "host_allowlist"
|
||||
? cause.remediationAction
|
||||
: MCP_HTTP_LISTEN_FAILED
|
||||
: MCP_CONNECTION_START_FAILED;
|
||||
serverLogger.error(
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
import { boundedUtf8Text } from "./boundedUtf8Text.js";
|
||||
import { snapshotEnvironment } from "./snapshotEnvironment.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
@@ -407,7 +408,7 @@ export const createDarwinProcessRunTokenReader = (
|
||||
readonly compiler?: DarwinProcessReaderCompiler;
|
||||
/** Deadline for helper compilation, independent of provider execution. */
|
||||
readonly compileTimeoutMs?: number;
|
||||
/** Environment consulted when `compileTimeoutMs` is omitted. */
|
||||
/** Captured compiler/helper environment and default compile deadline. */
|
||||
readonly environment?: Readonly<Record<string, string | undefined>>;
|
||||
/**
|
||||
* Caller-owned directory holding the compiler's module cache. Unset keeps
|
||||
@@ -421,7 +422,11 @@ export const createDarwinProcessRunTokenReader = (
|
||||
kind: "xcrun",
|
||||
executable: "/usr/bin/xcrun",
|
||||
};
|
||||
const compilerTimeoutMs = resolveSwiftcCompileTimeoutMs(options);
|
||||
const environment = snapshotEnvironment(options.environment ?? process.env);
|
||||
const compilerTimeoutMs = resolveSwiftcCompileTimeoutMs({
|
||||
...options,
|
||||
environment,
|
||||
});
|
||||
const moduleCachePath = options.moduleCachePath ?? "";
|
||||
let root: ProcessReaderRoot | undefined;
|
||||
let executable: string | undefined;
|
||||
@@ -496,7 +501,7 @@ export const createDarwinProcessRunTokenReader = (
|
||||
const compilerTemporary = join(operationRoot.path, "tmp");
|
||||
await mkdir(compilerTemporary);
|
||||
const compilerEnvironment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...environment,
|
||||
TMPDIR: compilerTemporary,
|
||||
};
|
||||
const compilerArguments =
|
||||
@@ -640,6 +645,7 @@ export const createDarwinProcessRunTokenReader = (
|
||||
const pending = (async () => {
|
||||
const binary = await compile(signal);
|
||||
const { stdout } = await execFileOutput(binary, arguments_, {
|
||||
env: environment,
|
||||
timeout: 15_000,
|
||||
stopSignal: "SIGTERM",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
|
||||
@@ -981,12 +981,13 @@ export const createSystemProcessOwnershipHost = (
|
||||
const darwinTokens =
|
||||
platform === "darwin"
|
||||
? createDarwinProcessRunTokenReader({
|
||||
environment: hostEnvironment,
|
||||
...(darwinCompiler === undefined ? {} : { compiler: darwinCompiler }),
|
||||
...(darwinModuleCachePath === undefined
|
||||
? {}
|
||||
: { moduleCachePath: darwinModuleCachePath }),
|
||||
...(options.compileTimeoutMs === undefined
|
||||
? { environment: hostEnvironment }
|
||||
? {}
|
||||
: { compileTimeoutMs: options.compileTimeoutMs }),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
/** Borrow the dictionary shape; selected payloads receive JSON validation in the adapter. */
|
||||
const structuredContentSchema = z.custom<Record<string, unknown>>((value) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return false;
|
||||
const prototype: unknown = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
});
|
||||
|
||||
/** IDA/JEB's object-structured envelope; each adapter owns selection and error policy. */
|
||||
export const mcpToolResultSchema = z.object({
|
||||
isError: z.boolean().optional(),
|
||||
structuredContent: structuredContentSchema.optional(),
|
||||
content: z.array(
|
||||
z.object({ type: z.string(), text: z.string().optional() }).passthrough(),
|
||||
),
|
||||
});
|
||||
|
||||
export const mcpTextBlocks = (
|
||||
content: z.infer<typeof mcpToolResultSchema>["content"],
|
||||
): string[] =>
|
||||
content.flatMap((block) =>
|
||||
block.type === "text" && block.text !== undefined ? [block.text] : [],
|
||||
);
|
||||
|
||||
/** Preserve ordinary text when the selected upstream payload is not valid JSON. */
|
||||
export const parseMcpJsonOrText = (text: string): JsonValue => {
|
||||
try {
|
||||
return jsonValueSchema.parse(JSON.parse(text));
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HttpHostname } from "../../domain/httpHostname.js";
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js";
|
||||
import {
|
||||
DEFAULT_MAX_REQUEST_BODY_SIZE,
|
||||
@@ -426,6 +427,8 @@ export interface ServeEnvironmentOptions {
|
||||
readonly source: EnvironmentSource;
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
/** Operator-declared Host/Origin names; a non-loopback bind refuses to serve without them. */
|
||||
readonly allowedHosts?: readonly HttpHostname[];
|
||||
readonly signal?: AbortSignal;
|
||||
/** Served name; defaults to the manifest's environment name. */
|
||||
readonly environmentName?: string;
|
||||
@@ -477,6 +480,9 @@ export const serveEnvironment = async (
|
||||
const served = await serveStreamableHttp({
|
||||
host,
|
||||
port,
|
||||
...(options.allowedHosts === undefined
|
||||
? {}
|
||||
: { allowedHosts: options.allowedHosts }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
createMcpServer: () => createEnvironmentMcpServer(name, tools, answer),
|
||||
routes: environmentRoutes(
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server";
|
||||
import { Agent, request as httpRequest } from "node:http";
|
||||
import { connect as connectTcp } from "node:net";
|
||||
import { connect as connectTcp, createServer } from "node:net";
|
||||
import pino from "pino";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type StreamableHttpServer,
|
||||
} from "./serveStreamableHttp.js";
|
||||
import { StreamableHttpListenError } from "./streamableHttpListenError.js";
|
||||
import { mcpAllowedHostsTextSchema } from "../../config/mcpHttpEndpoint.js";
|
||||
import { RequestTrackingMcpServer } from "../RequestTrackingMcpServer.js";
|
||||
|
||||
const servers: StreamableHttpServer[] = [];
|
||||
@@ -89,6 +90,21 @@ const connect = async (url: string) => {
|
||||
return { transport, client };
|
||||
};
|
||||
|
||||
/** Reserve an ephemeral port, release it, and return the number for a later bind. */
|
||||
const listenEphemeral = async (): Promise<number> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
probe.unref();
|
||||
probe.once("listening", () => {
|
||||
const { port } = probe.address() as { port: number };
|
||||
probe.close(() =>
|
||||
port > 0 ? resolve(port) : reject(new Error("no port")),
|
||||
);
|
||||
});
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1");
|
||||
});
|
||||
|
||||
const echo = async (client: Client, text: string): Promise<unknown> => {
|
||||
const result = await client.callTool({ name: "echo", arguments: { text } });
|
||||
return result.content;
|
||||
@@ -284,6 +300,67 @@ it("refuses foreign Host headers on a loopback bind and frees the port on close"
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a non-loopback bind without an operator host allowlist as a typed listen error and frees the port", async () => {
|
||||
const port = await listenEphemeral();
|
||||
const refused = start({ host: "0.0.0.0", port });
|
||||
await expect(refused).rejects.toBeInstanceOf(StreamableHttpListenError);
|
||||
await expect(refused).rejects.toThrow(/MCP_ALLOWED_HOSTS/u);
|
||||
// The refusal happens after the bind, so the staged close must have released it.
|
||||
// `start` owns the re-bound listener: afterEach closes it even if an assertion above throws.
|
||||
const rebind = start({ host: "127.0.0.1", port });
|
||||
await expect(rebind).resolves.toMatchObject({
|
||||
served: { close: expect.any(Function) },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["REA.LAN", "rea.lan"],
|
||||
["rea.lan.", "rea.lan."],
|
||||
["bücher.example", "xn--bcher-kva.example"],
|
||||
["127.0.0.1", "127.0.0.1"],
|
||||
["::1", "[::1]"],
|
||||
["0:0:0:0:0:0:0:1", "[::1]"],
|
||||
["[0:0:0:0:0:0:0:1]", "[::1]"],
|
||||
])("preserves declared hostname %j as %j", async (name, normalized) => {
|
||||
const { served } = await start({
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: mcpAllowedHostsTextSchema.parse(name),
|
||||
routes: [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/probe",
|
||||
handle: () => Response.json({ allowed: true }),
|
||||
},
|
||||
],
|
||||
});
|
||||
const url = new URL(served.url);
|
||||
url.hostname = "127.0.0.1";
|
||||
url.pathname = "/probe";
|
||||
const response = await rawHttpRequest(url, {
|
||||
headers: {
|
||||
host: `${normalized}:${url.port}`,
|
||||
origin: `http://${normalized}:${url.port}`,
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(response.body)).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("announces an admitted non-loopback URL and gates it on the operator allowlist", async () => {
|
||||
const { served } = await start({
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: mcpAllowedHostsTextSchema.parse("127.0.0.1"),
|
||||
});
|
||||
const url = new URL(served.url);
|
||||
expect(url.hostname).toBe("127.0.0.1");
|
||||
const { client } = await connect(url.toString());
|
||||
await expect(echo(client, "allowed")).resolves.toEqual([
|
||||
{ type: "text", text: "allowed" },
|
||||
]);
|
||||
await client.close();
|
||||
await expectForeignHostRefused(url);
|
||||
});
|
||||
|
||||
it("reports a port that cannot be bound as a typed listen error naming the cause", async () => {
|
||||
const { served } = await start();
|
||||
const failed = serveStreamableHttp({
|
||||
|
||||
@@ -16,8 +16,12 @@ import {
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { isIPv4, isIPv6, type AddressInfo } from "node:net";
|
||||
import { isIPv4, type AddressInfo } from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
normalizeHttpHostname,
|
||||
type HttpHostname,
|
||||
} from "../../domain/httpHostname.js";
|
||||
|
||||
import {
|
||||
toWebRequest,
|
||||
@@ -25,7 +29,10 @@ import {
|
||||
writeWebResponse,
|
||||
} from "./nodeHttpBridge.js";
|
||||
import { listenHttpServer } from "./listenHttpServer.js";
|
||||
import { StreamableHttpListenError } from "./streamableHttpListenError.js";
|
||||
import {
|
||||
StreamableHttpHostAllowlistError,
|
||||
StreamableHttpListenError,
|
||||
} from "./streamableHttpListenError.js";
|
||||
import {
|
||||
NodeHttpRequestOwner,
|
||||
type HttpAdmissionLifetime,
|
||||
@@ -47,6 +54,8 @@ export interface StreamableHttpRoute {
|
||||
export interface ServeStreamableHttpOptions {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
/** Operator-declared Host/Origin names; a non-loopback bind refuses to serve without them. */
|
||||
readonly allowedHosts?: readonly HttpHostname[];
|
||||
/** Cancellation during listener startup; later shutdown remains owned by close(). */
|
||||
readonly signal?: AbortSignal;
|
||||
/** Called once; the instance serves every session in turn on a fresh transport. */
|
||||
@@ -73,14 +82,6 @@ export interface StreamableHttpServer {
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** The bind host as the SDK compares Host headers: lowercase, IPv6 bracketed and canonical. */
|
||||
const normalizedHostname = (host: string): string | undefined => {
|
||||
try {
|
||||
return new URL(`http://${isIPv6(host) ? `[${host}]` : host}`).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const isLoopbackIpv4 = (address: string): boolean =>
|
||||
isIPv4(address) && address.startsWith("127.");
|
||||
/** Classify the native bound IP; configured DNS names are not binding evidence. */
|
||||
@@ -100,9 +101,9 @@ export const hostHeaderAllowlist = (
|
||||
host: string,
|
||||
boundAddress: string,
|
||||
): string[] | undefined => {
|
||||
const bound = normalizedHostname(boundAddress);
|
||||
const bound = normalizeHttpHostname(boundAddress);
|
||||
if (bound === undefined || !isLoopbackAddress(bound)) return undefined;
|
||||
const names = [normalizedHostname(host), bound];
|
||||
const names = [normalizeHttpHostname(host), bound];
|
||||
const known = names.filter((name): name is string => name !== undefined);
|
||||
return [...new Set([...localhostAllowedHostnames(), ...known])];
|
||||
};
|
||||
@@ -485,7 +486,7 @@ export const serveStreamableHttp = async (
|
||||
admissionLifetime: "response" as const,
|
||||
});
|
||||
if (stopped) return local(jsonError(503, -32000, "Server shutting down"));
|
||||
// One loopback allowlist gates Host (DNS rebinding) and Origin (browser pages); a missing Origin passes.
|
||||
// One resolved allowlist gates Host (DNS rebinding) and Origin (browser pages); a missing Origin passes.
|
||||
const refused =
|
||||
allowedHosts === undefined
|
||||
? undefined
|
||||
@@ -550,9 +551,27 @@ export const serveStreamableHttp = async (
|
||||
try {
|
||||
if (bound === null || typeof bound === "string")
|
||||
throw new TypeError("MCP HTTP listener has no TCP address");
|
||||
url = endpointUrl(bound);
|
||||
origin = new URL(url).origin;
|
||||
allowedHosts = hostHeaderAllowlist(host, bound.address);
|
||||
const endpoint = new URL(endpointUrl(bound));
|
||||
const loopbackHosts = hostHeaderAllowlist(host, bound.address);
|
||||
if (loopbackHosts !== undefined) {
|
||||
allowedHosts = loopbackHosts;
|
||||
} else {
|
||||
// Configuration supplies canonical names; the listener does not reinterpret them.
|
||||
if (
|
||||
options.allowedHosts === undefined ||
|
||||
options.allowedHosts.length === 0
|
||||
)
|
||||
throw new StreamableHttpHostAllowlistError(bound.address);
|
||||
allowedHosts = [...options.allowedHosts];
|
||||
}
|
||||
const announcedHost = allowedHosts.includes(endpoint.hostname)
|
||||
? endpoint.hostname
|
||||
: allowedHosts[0];
|
||||
if (announcedHost === undefined)
|
||||
throw new StreamableHttpHostAllowlistError(bound.address);
|
||||
endpoint.hostname = announcedHost;
|
||||
url = endpoint.href;
|
||||
origin = endpoint.origin;
|
||||
} catch (cause: unknown) {
|
||||
await prepareTransportShutdown().catch(() => undefined);
|
||||
throw new StreamableHttpListenError(cause);
|
||||
@@ -598,11 +617,6 @@ export const serveStreamableHttp = async (
|
||||
await close();
|
||||
throw options.signal.reason;
|
||||
}
|
||||
if (allowedHosts === undefined)
|
||||
logger?.warn(
|
||||
{ host },
|
||||
"MCP HTTP Host header validation relaxed for a non-loopback bind",
|
||||
);
|
||||
// The URL is the only way to reach an ephemeral port, so the announcement ignores the log threshold.
|
||||
logger
|
||||
?.child({}, { level: "info" })
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
/** Parsed system failure facts for listener recovery guidance. */
|
||||
/** Parsed listener failure facts for recovery guidance. */
|
||||
export type StreamableHttpListenFailure =
|
||||
| {
|
||||
readonly kind: "host_allowlist";
|
||||
readonly reason: "missing";
|
||||
}
|
||||
| {
|
||||
readonly kind: "host_lookup";
|
||||
readonly systemCode:
|
||||
@@ -36,8 +40,10 @@ const systemCode = (cause: unknown): string | undefined => {
|
||||
return typeof code === "string" ? code : undefined;
|
||||
};
|
||||
|
||||
/** Normalize recognized Node listener system codes once, at the listener boundary. */
|
||||
/** Classify configuration and native listener failures once, at their owning boundary. */
|
||||
const classify = (cause: unknown): StreamableHttpListenClassification => {
|
||||
if (cause instanceof StreamableHttpHostAllowlistError)
|
||||
return ok(cause.failure);
|
||||
const code = systemCode(cause);
|
||||
switch (code) {
|
||||
case "ENOTFOUND":
|
||||
@@ -62,6 +68,41 @@ const classify = (cause: unknown): StreamableHttpListenClassification => {
|
||||
}
|
||||
};
|
||||
|
||||
/** A declaration failure, distinct from an operating-system bind failure. */
|
||||
export class StreamableHttpHostAllowlistError extends Error {
|
||||
readonly failure: Extract<
|
||||
StreamableHttpListenFailure,
|
||||
{ kind: "host_allowlist" }
|
||||
>;
|
||||
|
||||
constructor(address: string) {
|
||||
super(
|
||||
`Refusing to serve ${address} outside loopback without an operator-declared host allowlist; set MCP_ALLOWED_HOSTS (or --allowed-hosts) to every host name clients will use, then retry`,
|
||||
);
|
||||
this.failure = { kind: "host_allowlist", reason: "missing" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Recovery belongs to the parsed failure owner, shared by CLI and MCP. */
|
||||
const listenerRemediation = (
|
||||
classification: StreamableHttpListenClassification,
|
||||
): string => {
|
||||
if (!classification.ok)
|
||||
return "Check the selected listener host, port, and binding permissions, then retry.";
|
||||
switch (classification.value.kind) {
|
||||
case "host_allowlist":
|
||||
return "Set MCP_ALLOWED_HOSTS to the host names or IP addresses clients will use, without URL components or ports, then retry. For rea mcp --http, --allowed-hosts accepts the same comma-separated list.";
|
||||
case "host_lookup":
|
||||
return "Check --host or MCP_HOST and host name resolution; correct the value or resolve the lookup failure, then retry.";
|
||||
case "host_unavailable":
|
||||
return "Choose an address assigned to this machine with --host or MCP_HOST, then retry.";
|
||||
case "port_in_use":
|
||||
return "Choose another --port or MCP_PORT, or stop the process using it.";
|
||||
case "permission_denied":
|
||||
return "Choose a host and port this process is permitted to bind; if the selected port is restricted, use an allowed port.";
|
||||
}
|
||||
};
|
||||
|
||||
export type StreamableHttpListenPhase = "startup" | "serving";
|
||||
|
||||
/** Listener failure retaining its native cause, phase, and parsed recovery facts. */
|
||||
@@ -69,6 +110,7 @@ export class StreamableHttpListenError extends Error {
|
||||
override readonly name = "StreamableHttpListenError";
|
||||
readonly classification: StreamableHttpListenClassification;
|
||||
readonly phase: StreamableHttpListenPhase;
|
||||
readonly remediationAction: string;
|
||||
|
||||
constructor(cause: unknown, phase: StreamableHttpListenPhase = "startup") {
|
||||
super(
|
||||
@@ -79,5 +121,6 @@ export class StreamableHttpListenError extends Error {
|
||||
);
|
||||
this.phase = phase;
|
||||
this.classification = classify(cause);
|
||||
this.remediationAction = listenerRemediation(this.classification);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,39 @@ const BUNDLE_FILES = [
|
||||
"tasks/smoke.json",
|
||||
];
|
||||
|
||||
cliTest(
|
||||
"refuses unaddressable service names before writing a bundle",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-bundle-hostname-cli-");
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
for (const name of ["4294967296", "123", "a".repeat(64)]) {
|
||||
const directory = join(root, `bundle-${name}`);
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"environment-export",
|
||||
files.manifestPath,
|
||||
"--evidence",
|
||||
files.capturePath,
|
||||
directory,
|
||||
"--name",
|
||||
name,
|
||||
"--json",
|
||||
],
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "environment-export",
|
||||
issues: [{ path: ["name"] }],
|
||||
},
|
||||
});
|
||||
await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"writes the bundle folder from the fixture pair and prints the written files with sizes",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -77,6 +77,26 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports the missing host allowlist instead of port advice for a non-loopback bind",
|
||||
async ({ cli }) => {
|
||||
const result = await serve(cli, await files(), "0.0.0.0", 0);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.output).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
diagnostics: {
|
||||
listener_failure: { kind: "host_allowlist", reason: "missing" },
|
||||
},
|
||||
},
|
||||
remediation: { action: expect.stringContaining("Set MCP_ALLOWED_HOSTS") },
|
||||
});
|
||||
expect(JSON.stringify(result.output.remediation)).not.toContain(
|
||||
"Choose another --port",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"retains port advice without closing the loopback listener it does not own",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
open,
|
||||
type FileHandle,
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
@@ -12,7 +13,7 @@ import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { buildBinary } from "plist";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import {
|
||||
@@ -1418,3 +1419,62 @@ describe("binary target I/O: PE CodeView header ranges", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not read a relocation table offset when DOS declares zero records", async () => {
|
||||
const directory = await createTestTempDirectory("rea-empty-dos-relocations-");
|
||||
const path = join(directory, "legacy.exe");
|
||||
const bytes = dosMz(8192);
|
||||
bytes.writeUInt16LE(257, 8);
|
||||
bytes.writeUInt16LE(4104, 24);
|
||||
bytes.writeUInt32LE(0, 60);
|
||||
await writeFile(path, bytes);
|
||||
const probe = await open(path, "r");
|
||||
const prototype = Object.getPrototypeOf(probe) as FileHandle;
|
||||
await probe.close();
|
||||
const reads = vi.spyOn(prototype, "read");
|
||||
try {
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { format: "dos-mz", architecture: "x86" },
|
||||
});
|
||||
const lengths = reads.mock.calls.map(
|
||||
(args) => (args as readonly unknown[])[2],
|
||||
);
|
||||
expect(lengths).toContain(4096);
|
||||
expect(lengths).not.toContain(4104);
|
||||
} finally {
|
||||
reads.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes Windows metadata independently of invalid DOS load declarations", async () => {
|
||||
const directory = await createTestTempDirectory("rea-invalid-dos-valid-pe-");
|
||||
const path = join(directory, "windows.exe");
|
||||
const bytes = pe(0x8664, 8192);
|
||||
bytes.writeUInt16LE(0, 4);
|
||||
bytes.writeUInt16LE(1010, 6);
|
||||
bytes.writeUInt16LE(257, 8);
|
||||
bytes.writeUInt16LE(64, 24);
|
||||
await writeFile(path, bytes);
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { format: "pe", architecture: "x86_64" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a declared DOS relocation header that is not present in the file", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-truncated-dos-relocations-",
|
||||
);
|
||||
const path = join(directory, "legacy.exe");
|
||||
const bytes = dosMz(8192);
|
||||
bytes.writeUInt16LE(1010, 6);
|
||||
bytes.writeUInt16LE(257, 8);
|
||||
bytes.writeUInt16LE(64, 24);
|
||||
bytes.writeUInt32LE(0, 60);
|
||||
await writeFile(path, bytes.subarray(0, 4096));
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "truncated DOS MZ load module" },
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
SERVER_START_FAILED,
|
||||
} from "../../../src/main/messages.js";
|
||||
import type { McpServeOptions } from "../../../src/main/types.js";
|
||||
import { StreamableHttpListenError } from "../../../src/server/http/streamableHttpListenError.js";
|
||||
import {
|
||||
StreamableHttpHostAllowlistError,
|
||||
StreamableHttpListenError,
|
||||
} from "../../../src/server/http/streamableHttpListenError.js";
|
||||
|
||||
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
|
||||
|
||||
@@ -201,6 +204,26 @@ describe("MCP runtime HTTP transport errors", () => {
|
||||
expect(closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("reports host allowlist recovery instead of port advice", async () => {
|
||||
const output: string[] = [];
|
||||
const refused = new StreamableHttpListenError(
|
||||
new StreamableHttpHostAllowlistError("0.0.0.0"),
|
||||
);
|
||||
expect(
|
||||
await run(
|
||||
dependencies({
|
||||
serve: () => Promise.reject(refused),
|
||||
output,
|
||||
shutdown: [],
|
||||
exitCodes: [],
|
||||
}),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(output).toEqual([`${refused.remediationAction}\n`]);
|
||||
expect(output[0]).toContain("Set MCP_ALLOWED_HOSTS");
|
||||
expect(output[0]).not.toContain("Choose another --port");
|
||||
});
|
||||
|
||||
it("reports an HTTP listener that cannot bind without its cause", async () => {
|
||||
const output: string[] = [];
|
||||
expect(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseMcpHttpEndpoint } from "../../../src/config/mcpHttpEndpoint.js";
|
||||
import {
|
||||
parseMcpHttpEndpoint,
|
||||
mcpAllowedHostsTextSchema,
|
||||
} from "../../../src/config/mcpHttpEndpoint.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { runMcpHttpEntrypoint } from "../../../src/main.js";
|
||||
import {
|
||||
@@ -116,3 +119,111 @@ describe("MCP_HOST and MCP_PORT", () => {
|
||||
expect(projected).not.toContain("65536");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP_ALLOWED_HOSTS", () => {
|
||||
it.each([
|
||||
"not a host",
|
||||
"127.0.0.1@evil.example",
|
||||
"rea.lan/path",
|
||||
"rea.lan\\path",
|
||||
"rea.lan?query",
|
||||
"rea.lan#fragment",
|
||||
"rea.lan:80",
|
||||
"https://rea.lan",
|
||||
"rea.\tlan",
|
||||
"rea.%6Can",
|
||||
])(
|
||||
"rejects malformed hostname %j at both configuration inputs",
|
||||
(invalidHost) => {
|
||||
const endpoint = parseMcpHttpEndpoint({
|
||||
MCP_HOST: "0.0.0.0",
|
||||
MCP_ALLOWED_HOSTS: invalidHost,
|
||||
});
|
||||
expect(endpoint.ok).toBe(false);
|
||||
if (endpoint.ok) throw new Error("Expected a configuration refusal");
|
||||
expect(endpoint.error.settings.map(({ setting }) => setting)).toEqual([
|
||||
"MCP_ALLOWED_HOSTS",
|
||||
]);
|
||||
expect(
|
||||
parseMcpHttpArguments(["--http", "--allowed-hosts", invalidHost]),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
message: expect.stringContaining("without URL components or ports"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("passes canonical host identities downstream and deduplicates after normalization", () => {
|
||||
expect(
|
||||
parseMcpHttpEndpoint({
|
||||
MCP_ALLOWED_HOSTS:
|
||||
"REA.LAN,rea.lan,[0:0:0:0:0:0:0:1],::1,bücher.example",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
allowedHosts: ["rea.lan", "[::1]", "xn--bcher-kva.example"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses comma-separated allowlist text and lets an explicit value win", () => {
|
||||
expect(
|
||||
parseMcpHttpEndpoint({ MCP_ALLOWED_HOSTS: "rea.lan, 10.0.0.5" }),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
allowedHosts: ["rea.lan", "10.0.0.5"],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseMcpHttpEndpoint(
|
||||
{ MCP_ALLOWED_HOSTS: "env.example" },
|
||||
{ allowedHosts: mcpAllowedHostsTextSchema.parse("flag.example") },
|
||||
),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { host: "127.0.0.1", port: 0, allowedHosts: ["flag.example"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an empty or blank allowlist by setting name without echoing it", () => {
|
||||
for (const text of ["", " ", "rea.lan,,10.0.0.5"]) {
|
||||
const result = parseMcpHttpEndpoint({ MCP_ALLOWED_HOSTS: text });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) continue;
|
||||
expect(result.error.settings.map(({ setting }) => setting)).toEqual([
|
||||
"MCP_ALLOWED_HOSTS",
|
||||
]);
|
||||
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
|
||||
"MCP_ALLOWED_HOSTS",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts --allowed-hosts with either flag spelling and rejects empty entries", () => {
|
||||
expect(
|
||||
parseMcpHttpArguments(["--http", "--allowed-hosts=rea.lan, 10.0.0.5"]),
|
||||
).toEqual({ ok: true, allowedHosts: ["rea.lan", "10.0.0.5"] });
|
||||
expect(
|
||||
parseMcpHttpArguments(["--http", "--allowed-hosts", "rea.lan"]),
|
||||
).toEqual({ ok: true, allowedHosts: ["rea.lan"] });
|
||||
for (const text of ["", " ", "rea.lan,,10.0.0.5"])
|
||||
expect(
|
||||
parseMcpHttpArguments(["--http", `--allowed-hosts=${text}`]),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
message: expect.stringContaining("--allowed-hosts"),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists --allowed-hosts and MCP_ALLOWED_HOSTS in the rendered help", () => {
|
||||
const help = renderMcpHttpHelp();
|
||||
expect(help).toContain("--allowed-hosts");
|
||||
expect(help).toContain("MCP_ALLOWED_HOSTS");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { _electron as electron } from "playwright-core";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -457,6 +457,84 @@ const existsPath = async (target: string) =>
|
||||
() => false,
|
||||
);
|
||||
|
||||
it
|
||||
.skipIf(process.platform === "win32")
|
||||
.each([
|
||||
"selected environment",
|
||||
"selected environment and deadline",
|
||||
"default environment",
|
||||
] as const)(
|
||||
"captures the compiler and helper environment from %s",
|
||||
async (selection) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-process-token-selected-environment-",
|
||||
);
|
||||
const developerDirectory = join(directory, "selected Xcode.app");
|
||||
const compileState = join(directory, "compile-environment.json");
|
||||
const executable = join(directory, "fixture-xcrun");
|
||||
const helper = `#!${process.execPath}
|
||||
console.log(JSON.stringify({ results: process.argv.slice(2).map(pid => ({
|
||||
pid: Number(pid), state: "readable", run_id: process.env.DEVELOPER_DIR,
|
||||
})) }));
|
||||
`;
|
||||
await writeFile(
|
||||
executable,
|
||||
`#!${process.execPath}
|
||||
const fs = process.getBuiltinModule("node:fs");
|
||||
fs.writeFileSync(${JSON.stringify(compileState)}, JSON.stringify({
|
||||
developerDirectory: process.env.DEVELOPER_DIR ?? null,
|
||||
}));
|
||||
const output = process.argv[process.argv.indexOf("-o") + 1];
|
||||
fs.writeFileSync(output, ${JSON.stringify(helper)});
|
||||
fs.chmodSync(output, 0o755);
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
vi.stubEnv("DEVELOPER_DIR", developerDirectory);
|
||||
const environment = { ...process.env };
|
||||
const compiler = { kind: "xcrun" as const, executable };
|
||||
const reader =
|
||||
selection === "default environment"
|
||||
? createDarwinProcessRunTokenReader({ compiler })
|
||||
: createSystemProcessOwnershipHost("darwin", environment, {
|
||||
darwinCompiler: compiler,
|
||||
...(selection === "selected environment and deadline"
|
||||
? { compileTimeoutMs: 15_000 }
|
||||
: {}),
|
||||
});
|
||||
environment.DEVELOPER_DIR = join(directory, "later caller selection");
|
||||
vi.stubEnv("DEVELOPER_DIR", join(directory, "later ambient selection"));
|
||||
try {
|
||||
await reader.prepare?.(AbortSignal.timeout(15_000));
|
||||
expect(JSON.parse(await readFile(compileState, "utf8"))).toEqual({
|
||||
developerDirectory,
|
||||
});
|
||||
const processes = [
|
||||
{
|
||||
pid: 123,
|
||||
parentPid: 1,
|
||||
processGroupId: 123,
|
||||
state: "S",
|
||||
command: "fixture",
|
||||
},
|
||||
];
|
||||
const observations =
|
||||
"read" in reader
|
||||
? await reader.read(processes)
|
||||
: await reader.runTokens?.(processes);
|
||||
expect(observations?.get(123)).toEqual({
|
||||
state: "readable",
|
||||
runId: developerDirectory,
|
||||
});
|
||||
} finally {
|
||||
await reader.close?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"forwards a caller-owned module cache and keeps it after the helper root is removed",
|
||||
async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import type { ToolContract } from "../../../src/contracts/toolContractTypes.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
@@ -6,6 +8,12 @@ import {
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
interface VerifierResults {
|
||||
readonly requireMcpOutput: (
|
||||
result: unknown,
|
||||
operation: string,
|
||||
contract?: ToolContract,
|
||||
) => unknown;
|
||||
readonly requireMcpToolError: (result: unknown) => unknown;
|
||||
readonly requireMcpEvidenceBundle: (result: unknown) => EvidenceBundle;
|
||||
readonly requireMcpLifecycleResult: (
|
||||
result: unknown,
|
||||
@@ -182,3 +190,79 @@ it("retains summary discovery in the generic lifecycle reader but refuses it as
|
||||
"summary metadata instead of complete Evidence",
|
||||
);
|
||||
});
|
||||
|
||||
const namedEvidence = () =>
|
||||
createEvidence(
|
||||
{
|
||||
path: "/fixture/image.bin",
|
||||
sha256: "a".repeat(64),
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
},
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "address_name",
|
||||
parameters: { address: "0x1000" },
|
||||
result: "entry",
|
||||
rawResult: { engine_name: "entry", source: "symbol table" },
|
||||
},
|
||||
);
|
||||
const textReply = (value: unknown) => ({
|
||||
content: [{ type: "text", text: JSON.stringify(value) }],
|
||||
});
|
||||
|
||||
it("returns complete named Evidence with its subject, provider and analysis provenance", async () => {
|
||||
const { requireMcpOutput } = await verifierResults();
|
||||
const evidence = namedEvidence();
|
||||
expect(requireMcpOutput(textReply(evidence), "address_name")).toEqual(
|
||||
evidence,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mismatched named contracts, error-marked success and malformed normalized results", async () => {
|
||||
const { requireMcpOutput } = await verifierResults();
|
||||
const evidence = namedEvidence();
|
||||
expect(() =>
|
||||
requireMcpOutput(
|
||||
textReply(evidence),
|
||||
"address_name",
|
||||
toolContract("set_address_name"),
|
||||
),
|
||||
).toThrow("received the set_address_name contract");
|
||||
expect(() =>
|
||||
requireMcpOutput({ ...textReply(evidence), isError: true }, "address_name"),
|
||||
).toThrow("address_name failed");
|
||||
expect(() =>
|
||||
requireMcpOutput(
|
||||
textReply({ ...evidence, normalized_result: { name: "entry" } }),
|
||||
"address_name",
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("requires canonical complete tool errors before inspecting their diagnostic fields", async () => {
|
||||
const { requireMcpToolError } = await verifierResults();
|
||||
const error = {
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
message: "The address is invalid",
|
||||
retryable: false,
|
||||
remediation: { action: "Choose an observed address" },
|
||||
details: { operation: "address_name", address: "bad" },
|
||||
};
|
||||
expect(
|
||||
requireMcpToolError({ ...textReply({ error }), isError: true }),
|
||||
).toEqual(error);
|
||||
for (const value of [
|
||||
{ error: { category: "invalid_input" } },
|
||||
error,
|
||||
{ error: { ...error, retryable: "false" } },
|
||||
{ error: { ...error, code: "vendor_failure" } },
|
||||
])
|
||||
expect(() =>
|
||||
requireMcpToolError({ ...textReply(value), isError: true }),
|
||||
).toThrow();
|
||||
expect(() => requireMcpToolError(textReply({ error }))).toThrow(
|
||||
"Expected an MCP tool failure",
|
||||
);
|
||||
});
|
||||
@@ -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 { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -8,6 +18,11 @@ import { z } from "zod";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { createGhidraTestRuntime } from "../../../fixtures/ghidraRuntime.js";
|
||||
import type { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
ProviderProcessHandle,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../../../../src/process/ProviderProcess.js";
|
||||
|
||||
import {
|
||||
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", () => {
|
||||
it("keeps analysis repair outside Windows P0 authority", () => {
|
||||
const args = ghidraHeadlessArguments({
|
||||
@@ -538,7 +930,9 @@ describe("Ghidra Windows launcher", () => {
|
||||
expect(args).not.toContain("/package/bridge/ReaGhidraNoReturnFix.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", () => {
|
||||
const command = ghidraHeadlessCommand({
|
||||
environment: {},
|
||||
@@ -584,11 +978,11 @@ describe("Ghidra Windows launcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra Windows launcher runtime", () => {
|
||||
describe("Ghidra Windows release configuration", () => {
|
||||
it
|
||||
.skipIf(process.platform !== "win32")
|
||||
.each(["ghidra_12.1.4_PUBLIC", "ghidra_12.1.2_DEV"])(
|
||||
"prepares the inspected release directory through the Windows runtime (%s)",
|
||||
.each(["ghidra_12.1.4_PUBLIC", "renamed-install"])(
|
||||
"prepares the %s release directory through the Windows runtime",
|
||||
async (release) => {
|
||||
const parent = await createTestTempDirectory("rea-launch-config-");
|
||||
const installRoot = join(parent, "renamed-install");
|
||||
@@ -637,7 +1031,9 @@ describe("Ghidra Windows launcher runtime", () => {
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Ghidra Windows launcher process ownership", () => {
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"keeps authority in a private descriptor and isolates Ghidra state through the batch launcher",
|
||||
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 });
|
||||
},
|
||||
);
|
||||
@@ -12,8 +12,13 @@ import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { createAnalysisProfile } from "../../../../src/domain/analysisProfile.js";
|
||||
import { AnalysisArtifactChangedError } from "../../../../src/domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
} from "../../../../src/domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { BinaryTargetError } from "../../../../src/domain/configurationErrors.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
import { parseConfig } from "../../../../src/config/parseConfig.js";
|
||||
import { silentLogger } from "../../../../src/logger.js";
|
||||
@@ -27,6 +32,9 @@ const state = vi.hoisted(() => ({
|
||||
code: "EIO",
|
||||
handles: [] as Array<{ handle: FileHandle; close: () => Promise<void> }>,
|
||||
ended: 0,
|
||||
statFailure: undefined as Error | undefined,
|
||||
abortOnClose: undefined as AbortController | undefined,
|
||||
afterRead: undefined as (() => void) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
@@ -37,13 +45,22 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const handle = await actual.open(...args);
|
||||
if (String(args[0]) !== state.path) return handle;
|
||||
const close = handle.close.bind(handle);
|
||||
const stat = handle.stat.bind(handle);
|
||||
handle.stat = ((...options: Parameters<typeof handle.stat>) => {
|
||||
if (state.statFailure !== undefined)
|
||||
return Promise.reject(state.statFailure);
|
||||
return stat(...options);
|
||||
}) as typeof handle.stat;
|
||||
state.handles.push({ handle, close });
|
||||
const stream = handle.createReadStream.bind(handle);
|
||||
handle.createReadStream = (options) =>
|
||||
stream(options).on("end", () => {
|
||||
state.ended += 1;
|
||||
});
|
||||
const read = handle.read.bind(handle);
|
||||
handle.read = (async (...options: Parameters<typeof handle.read>) => {
|
||||
const result = await read(...options);
|
||||
state.afterRead?.();
|
||||
if (result.bytesRead === 0) state.ended += 1;
|
||||
return result;
|
||||
}) as typeof handle.read;
|
||||
handle.close = async () => {
|
||||
state.abortOnClose?.abort("caller cancelled during source close");
|
||||
if (state.fail)
|
||||
throw Object.assign(
|
||||
new Error("controlled descriptor close failure"),
|
||||
@@ -122,6 +139,14 @@ it("preserves file-offset reads and retries the exact descriptor after close fai
|
||||
file_offset: 8,
|
||||
source_identity: "verified",
|
||||
});
|
||||
expect(failed.error.partialObservation).toMatchObject({
|
||||
...observation,
|
||||
original_file_offset: {
|
||||
available: true,
|
||||
file_offset: 8,
|
||||
source_sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
},
|
||||
});
|
||||
expect(projectAnalysisError(failed.error).code).toBe("cleanup_incomplete");
|
||||
const owned = state.handles.at(-1);
|
||||
if (owned === undefined) throw new Error("missing descriptor");
|
||||
@@ -285,3 +310,207 @@ it("clears a combined Hopper close only after the descriptor and session release
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains a source metadata failure when cancellation arrives during close (close failure: %s)",
|
||||
async (closeFails) => {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), "rea-hopper-file-offset-primary-"),
|
||||
);
|
||||
const bytes = Buffer.alloc(64);
|
||||
state.path = join(root, "sample");
|
||||
await writeFile(state.path, bytes);
|
||||
const client = await clientFor(state.path, bytes);
|
||||
const call = vi
|
||||
.spyOn(HopperClient.prototype, "callTool")
|
||||
.mockResolvedValue(ok(observation));
|
||||
const controller = new AbortController();
|
||||
const primary = Object.assign(
|
||||
new Error("controlled source metadata denial"),
|
||||
{ code: "EACCES" },
|
||||
);
|
||||
state.statFailure = primary;
|
||||
state.abortOnClose = controller;
|
||||
state.fail = closeFails;
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"address_to_file_offset",
|
||||
{ address: "0x1000" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("expected source denial");
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
const failure = closeFails ? result.error.cleanupPrimary : result.error;
|
||||
expect(failure).toBeInstanceOf(AnalysisAccessDeniedError);
|
||||
expect(failure?.cause).toBe(primary);
|
||||
expect(failure?.partialObservation).toMatchObject(observation);
|
||||
expect(result.error.cleanupIncomplete).toBe(closeFails);
|
||||
const owned = state.handles.at(-1);
|
||||
if (owned === undefined) throw new Error("missing source descriptor");
|
||||
expect(owned.handle.fd >= 0).toBe(closeFails);
|
||||
state.fail = false;
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
expect(owned.handle.fd).toBe(-1);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
state.fail = false;
|
||||
state.statFailure = undefined;
|
||||
state.abortOnClose = undefined;
|
||||
call.mockRestore();
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"retains verified source coordinates when cancellation arrives during close (close failure: %s)",
|
||||
async (closeFails) => {
|
||||
const root = await mkdtemp(
|
||||
join(tmpdir(), "rea-hopper-file-offset-cancel-"),
|
||||
);
|
||||
const bytes = Buffer.alloc(64);
|
||||
state.path = join(root, "sample");
|
||||
await writeFile(state.path, bytes);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const client = await clientFor(state.path, bytes);
|
||||
const call = vi
|
||||
.spyOn(HopperClient.prototype, "callTool")
|
||||
.mockResolvedValue(ok(observation));
|
||||
const controller = new AbortController();
|
||||
state.abortOnClose = controller;
|
||||
state.fail = closeFails;
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"address_to_file_offset",
|
||||
{ address: "0x1000" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
const cancellation = closeFails
|
||||
? result.error.cleanupPrimary
|
||||
: result.error;
|
||||
expect(cancellation).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(cancellation?.cause).toBe(controller.signal.reason);
|
||||
const partialObservation = {
|
||||
...observation,
|
||||
original_file_offset: {
|
||||
available: true,
|
||||
file_offset: 8,
|
||||
source_sha256: sha256,
|
||||
},
|
||||
};
|
||||
expect(cancellation?.partialObservation).toMatchObject(
|
||||
partialObservation,
|
||||
);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: { partial_observation: partialObservation },
|
||||
});
|
||||
expect(cancellation?.diagnostics).toMatchObject({
|
||||
source_sha256: sha256,
|
||||
file_offset: 8,
|
||||
source_identity: "verified",
|
||||
});
|
||||
expect(result.error.cleanupIncomplete).toBe(closeFails);
|
||||
const owned = state.handles.at(-1);
|
||||
if (owned === undefined) throw new Error("missing source descriptor");
|
||||
expect(owned.handle.fd >= 0).toBe(closeFails);
|
||||
state.fail = false;
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
expect(owned.handle.fd).toBe(-1);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
state.fail = false;
|
||||
state.abortOnClose = undefined;
|
||||
call.mockRestore();
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"uses the descriptor read outcome when cancellation and an I/O result coincide (read failure: %s)",
|
||||
async (readFails) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-hopper-file-offset-read-"));
|
||||
const bytes = Buffer.alloc(64);
|
||||
state.path = join(root, "sample");
|
||||
await writeFile(state.path, bytes);
|
||||
const client = await clientFor(state.path, bytes);
|
||||
const call = vi
|
||||
.spyOn(HopperClient.prototype, "callTool")
|
||||
.mockResolvedValue(ok(observation));
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("caller cancelled during source read");
|
||||
const primary = Object.assign(
|
||||
new Error("controlled descriptor read failure"),
|
||||
{ code: "EIO" },
|
||||
);
|
||||
let readObserved = false;
|
||||
state.afterRead = () => {
|
||||
readObserved = true;
|
||||
controller.abort(reason);
|
||||
if (readFails) throw primary;
|
||||
};
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"address_to_file_offset",
|
||||
{ address: "0x1000" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("expected interrupted source verification");
|
||||
expect(readObserved).toBe(true);
|
||||
expect(result.error).toBeInstanceOf(
|
||||
readFails ? BinaryTargetError : AnalysisCancelledError,
|
||||
);
|
||||
expect(result.error.cause).toBe(readFails ? primary : reason);
|
||||
expect(result.error.partialObservation).toMatchObject(observation);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(state.handles.at(-1)?.handle.fd).toBe(-1);
|
||||
} finally {
|
||||
state.afterRead = undefined;
|
||||
call.mockRestore();
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains a changed source digest without publishing verified coordinates after late cancellation", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-hopper-file-offset-changed-"));
|
||||
const bytes = Buffer.alloc(64);
|
||||
state.path = join(root, "sample");
|
||||
await writeFile(state.path, bytes);
|
||||
const client = await clientFor(state.path, bytes);
|
||||
const changed = Buffer.alloc(64, 1);
|
||||
await writeFile(state.path, changed);
|
||||
const call = vi
|
||||
.spyOn(HopperClient.prototype, "callTool")
|
||||
.mockResolvedValue(ok(observation));
|
||||
const controller = new AbortController();
|
||||
state.abortOnClose = controller;
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"address_to_file_offset",
|
||||
{ address: "0x1000" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("expected changed source identity");
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(result.error).toBeInstanceOf(AnalysisArtifactChangedError);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
...observation,
|
||||
original_file_offset: { available: false },
|
||||
});
|
||||
expect(result.error.diagnostics).toEqual({
|
||||
source_sha256: createHash("sha256").update(changed).digest("hex"),
|
||||
});
|
||||
expect(state.handles.at(-1)?.handle.fd).toBe(-1);
|
||||
} finally {
|
||||
state.abortOnClose = undefined;
|
||||
call.mockRestore();
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
|
||||
import {
|
||||
HOPPER_LINUX_PRIMARY_FINGERPRINT,
|
||||
HOPPER_RELEASES_URL,
|
||||
type LinuxHopperRelease,
|
||||
} from "../../../src/application/HopperReleaseFeed.js";
|
||||
import * as signatures from "../../../src/process/DetachedPgpSignature.js";
|
||||
@@ -152,6 +153,82 @@ it.each([undefined, "", "/selected/cache", "relative/cache"])(
|
||||
},
|
||||
);
|
||||
|
||||
const feedRelease = () => ({
|
||||
version: "1.0",
|
||||
filename:
|
||||
"https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.deb",
|
||||
file_length: "1",
|
||||
file_hash: "a".repeat(40),
|
||||
main_exe_hash: "b".repeat(64),
|
||||
signature: "AQ==",
|
||||
public: "1",
|
||||
beta: "0",
|
||||
vendor_label: "valid � label",
|
||||
...JSON.parse(
|
||||
'{"__proto__":{"constructor":"retained"},"vendor_details":{"__proto__":"nested"}}',
|
||||
),
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains fetched vendor release JSON with initial BOM=%s",
|
||||
async (bom) => {
|
||||
const root = await createTestTempDirectory("rea-linux-feed-json-");
|
||||
const publisher = new LinuxHopperPublisher(
|
||||
{ XDG_CACHE_HOME: root },
|
||||
async () => "deb",
|
||||
);
|
||||
const release = feedRelease();
|
||||
const text = JSON.stringify({ "Ubuntu / Mint": release });
|
||||
const network = vi.fn(
|
||||
async () => new Response(`${bom ? "\uFEFF" : ""}${text}`),
|
||||
);
|
||||
vi.stubGlobal("fetch", network);
|
||||
try {
|
||||
const selected = await publisher.currentRelease("deb");
|
||||
expect(selected).toEqual({ ok: true, value: release });
|
||||
if (!selected.ok) throw selected.error;
|
||||
expect(Object.hasOwn(selected.value, "__proto__")).toBe(true);
|
||||
expect(selected.value.vendor_label).toBe("valid � label");
|
||||
expect(await publisher.currentRelease("deb")).toEqual(selected);
|
||||
expect(network).toHaveBeenCalledOnce();
|
||||
expect(network).toHaveBeenCalledWith(
|
||||
HOPPER_RELEASES_URL,
|
||||
expect.any(Object),
|
||||
);
|
||||
} finally {
|
||||
await publisher.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects malformed UTF-8 fetched release metadata without repairing a vendor value", async () => {
|
||||
const root = await createTestTempDirectory("rea-linux-feed-bytes-");
|
||||
const publisher = new LinuxHopperPublisher(
|
||||
{ XDG_CACHE_HOME: root },
|
||||
async () => "deb",
|
||||
);
|
||||
const bytes = Buffer.from(JSON.stringify({ "Ubuntu / Mint": feedRelease() }));
|
||||
const offset = bytes.indexOf(Buffer.from("�"));
|
||||
expect(offset).toBeGreaterThanOrEqual(0);
|
||||
const invalid = Buffer.concat([
|
||||
bytes.subarray(0, offset),
|
||||
Buffer.from([0xff]),
|
||||
bytes.subarray(offset + 3),
|
||||
]);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(Uint8Array.from(invalid).buffer)),
|
||||
);
|
||||
try {
|
||||
expect(await publisher.currentRelease("deb")).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "release_metadata" },
|
||||
});
|
||||
} finally {
|
||||
await publisher.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Linux signed package retention boundary", () => {
|
||||
linux(
|
||||
"rejects a cryptographically valid package signed by another publisher",
|
||||
@@ -183,6 +260,14 @@ describe("Linux signed package retention boundary", () => {
|
||||
"authenticates a real %s payload and verifies a retained install without network",
|
||||
async (family) => {
|
||||
const f = await fixture(family);
|
||||
const vendor = {
|
||||
label: "valid � publisher",
|
||||
nested: { ["__proto__"]: "retained" },
|
||||
};
|
||||
Object.defineProperty(f.release, "__proto__", {
|
||||
value: vendor,
|
||||
enumerable: true,
|
||||
});
|
||||
admitFixturePublisher(f.fingerprint);
|
||||
const network = vi.fn(() => {
|
||||
throw new Error("Offline");
|
||||
@@ -193,6 +278,11 @@ describe("Linux signed package retention boundary", () => {
|
||||
expect(
|
||||
await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive),
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
const cached = JSON.parse(
|
||||
await readFile(join(f.cache, "release.json"), "utf8"),
|
||||
);
|
||||
expect(Object.hasOwn(cached.release, "__proto__")).toBe(true);
|
||||
expect(cached.release["__proto__"]).toEqual(vendor);
|
||||
await f.publisher.close();
|
||||
// A new instance alone still shares process-local cryptographic proofs.
|
||||
// Reload the owner and assert actual PGP and native payload inspection.
|
||||
@@ -218,6 +308,9 @@ describe("Linux signed package retention boundary", () => {
|
||||
source: "signed_package_cache",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
JSON.parse(await readFile(join(f.cache, "release.json"), "utf8")),
|
||||
).toEqual(cached);
|
||||
expect(network).not.toHaveBeenCalled();
|
||||
expect(signature).toHaveBeenCalledOnce();
|
||||
expect(extraction).toHaveBeenCalledWith(
|
||||
|
||||
@@ -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