Compare commits

...
Author SHA1 Message Date
morluto 015a02d57b feat(ghidra)!: identify containing code and data (#8777)
* feat(ghidra)!: identify containing listed instructions

* fix(ghidra): validate instruction containment status

* fix(ghidra): validate containment coordinates

* feat(ghidra): identify containing defined data

* test(ghidra): correct snapshot and containment controls

* docs(native): distinguish object and dynamic type sizes

* docs(cli): retain incoming reference guidance

* fix(mcp): clarify data object address guidance

* test(ghidra): preserve defined data in reference fixtures
2026-11-07 21:26:52 +00:00
morluto 688dca3e90 fix(mcp): report native value flow support in session status (#8786)
* fix(native)!: preserve value-flow absence semantics

* fix(mcp): report native value flow support in session status

* test(mcp): share schema validation and assert status facts

* chore: preserve unrelated main imports
2026-11-07 21:26:13 +00:00
morlutoandCursor Agent 197a296664 fix(session): guide unsupported provider recovery (#8567)
* fix(session): guide unsupported provider recovery

* fix(session): clarify provider switch state loss

* fix(session): distinguish evidence from provider edits

* fix(session): preserve workflow-specific recovery

* test(session): compare recovery with its owner

* fix(session): guide available entry-comment handoff

* fix(session): condition provider handoff on readiness

* fix(cli): clarify target-free provider discovery

* test(mcp): remove duplicated recovery wording checks

* test(mcp): keep provider-selection binding case within the line limit

The merged unknown-provider checks stay in the same scenario and pass
the workspace path explicitly.

* test(mcp): use one temporary directory owner

* test: avoid freezing retry remediation wording

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 21:14:10 +00:00
morluto aa2c1c61ce feat(mcp): report provider release verification in session (#8485)
* feat(mcp): report provider release verification in session

* test(kuna): verify release guidance at its owner

* fix(kuna): verify release against reported version

* test(mcp): keep temporary fixture cleanup with its owner
2026-11-07 21:13:49 +00:00
morlutoandCursor Agent 2d25a09c0f fix(native)!: recognize identical function evidence (#8442)
* fix(native)!: recognize identical function evidence

* test(native): verify same-evidence dimension citations

* fix(compare): reject impossible same-evidence changes

A comparison of one Evidence record cannot report a changed dimension,
unequal subject hashes, or unequal digests on an unchanged dimension.

* test(compare): admit the partial same-evidence fixture explicitly

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 21:06:39 +00:00
morlutoandCursor Agent 9109e6f6a7 fix(ghidra): guide ambiguous selector recovery (#8768)
* fix(ghidra): guide ambiguous procedure selection

* test(ghidra): distinguish malformed selectors

* fix(ghidra): distinguish ambiguous file mappings

* fix(ghidra): name mapping inspection workflows

* docs(mcp): expose strict Ghidra address selection

* docs(cli): explain Ghidra qualified address selection

* test(ghidra): cover published ambiguous selector codes

MCP and projection tests use procedure_ambiguous and file_offset_ambiguous,
and match the recovery main already publishes for those codes.

* fix(ghidra): name the space-qualified address that avoids offset fallback

file_offset_ambiguous now tells the caller to use a form such as
ram:0x1000. A bare 0x selector repeats the file-offset lookup. The MCP
test requires that route and inspect_native_load_image.

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 20:57:13 +00:00
morluto cc58263ee0 fix(trace): disclose address-only reference scope (#8575)
* fix(trace): disclose address-only reference scope

* test(trace): assert qualified reference facts

* test(contracts): distinguish address and process starts

* fix(trace): clarify procedure interior reference scope
2026-11-07 20:29:36 +00:00
morluto 56fb5219ce fix(cli): retain linked sources in function bundles (#8873)
* fix: preserve source closure during snapshot replay

* fix: revalidate restored snapshot sources lazily

* fix: bound and share source-closure checks

* fix(cli): retain function evidence sources in bundles

* fix(cli): preserve partially captured source closure

* test(ghidra): verify saved project evidence bundles

* fix(cli): retain sources across incomplete finalization

* fix: supervise function bundle verifier processes

* style(test): format CLI help default annotations (#9280)

(cherry picked from commit ac30fbd658)
2026-11-07 20:12:25 +00:00
morlutoandCursor Agent f3c21665d8 docs(mcp): clarify Ghidra edit lifetime on switches (#8484)
* docs(mcp): clarify Ghidra edit lifetime on switches

* test(guidance): expect focused provider switch guidance

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 19:50:51 +00:00
morlutoandCursor Agent ce45f92219 docs(mcp): clarify retained evidence view support (#8480)
* docs(mcp): clarify retained evidence view support

* docs(mcp): describe pageable pseudocode alternative

* docs(mcp): page retained procedure pseudocode in place

procedure_pseudo_code Evidence already supports summary and native
pseudocode views of the original observation. The guide no longer says
that paging requires a new analyze_function dossier.

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 19:42:26 +00:00
morlutoandCursor Agent f912a68417 fix(ghidra): respect Java Unix socket path limits (#8382)
Co-authored-by: Cursor Agent <[email protected]>
2026-11-08 02:15:05 +08:00
morlutoandCursor Agent 6ca4e1368c fix(cli): guide analysts to typed incoming references (#8514)
Co-authored-by: Cursor Agent <[email protected]>
2026-11-08 02:14:55 +08:00
morlutoandCursor Agent 1071ad867c fix(test): report caller target selection coverage (#8406)
* fix(test): report caller target selection coverage

* test(kuna): cover passing caller target selection

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-08 02:04:48 +08:00
morluto c06b53644b fix(ghidra)!: preserve facts in inventory searches (#8376)
* fix(ghidra)!: preserve facts in inventory searches

BREAKING CHANGE: Ghidra snapshots made before the search projection profile revision no longer match. Run fresh analysis and save a new snapshot.

* test(ghidra): update raw profile digest goldens
2026-11-08 02:04:30 +08:00
morluto 352a846127 test: reduce brittle assertions and test-only runtime paths (#9289)
* test: focus assertions on behavioral evidence

Prune redundant and wording-coupled checks while retaining diagnostic, parser, recovery, and cleanup coverage.

* refactor: remove test-only wrappers and duplicate checks

Exercise production operations through test fixtures, remove unused adapters, and keep managed-member uncertainty guidance under its existing owner.

* test: focus process validation on diagnostic facts

* refactor: remove test-only runtime helpers

Move semantic evidence fixture setup out of runtime and remove unused Windows settle recovery helpers while checking configured defaults independently.

* test: focus artifact diagnostics on format facts

* test: verify retained cleanup guidance delivery
2026-11-08 02:02:31 +08:00
morlutoandCursor Agent 5679ed66f4 fix(mcp): surface pageable pseudocode workflow (#8481)
* fix(mcp): surface pageable pseudocode workflow

* fix(mcp): clarify pseudocode paging capabilities

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-08 01:47:14 +08:00
343 changed files with 3798 additions and 1367 deletions

No files matched your search

@@ -325,7 +325,8 @@ on the first MCP call. It retains the complete observation and returns the
original selection, text availability and length without the body. Use
`normalized_result.parent_evidence_id` with `inspect_analysis_view` and
`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 20000}`;
follow `coverage.next_offset` for later pages. Offsets and limits count UTF-16
follow `coverage.next_offset` for later pages. Choose a smaller limit if one
page exceeds the client or transport budget. Offsets and limits count UTF-16
code units, not lines or tokens. Pages reuse the same observation without
repeating decompilation. Null and empty text remain distinct. Omitted or
`complete` detail preserves full inline delivery. Summary delivery requires a
@@ -684,6 +685,11 @@ numbering gaps leave coverage unknown; a DfuSe suffix CRC mismatch follows
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; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits. Normal close saves them when owned Program checkpoint mode is enabled; ephemeral mode discards them. CLI `annotate-native-function` follows the same setting when closing after its updated result. Select the [checkpoint workflow](#continue-editable-ghidra-analysis-across-processes) before edits that must survive a later process. 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. Ghidra rejects batch addresses that request the same name or a name another selected address holds; `raw_result.failures` gives each failure's reason.
A provider switch or different target can retire the active Ghidra database and discard
its edits, even if the new provider fails on a later call. Same-target,
same-profile reopen keeps the active database. Retained annotation Evidence
records what happened; it does not restore the edits.
For `compare_functions`, obtain `analyze_function` Evidence before and after the
edit while the MCP session is open; annotation Evidence is not a comparison
input. Export the needed Evidence before close if comparison will happen later.
+25 -4
View File
@@ -1414,8 +1414,29 @@ public final class ReaGhidraBridge extends HeadlessScript {
flow.addProperty("conditional", false);
flow.addProperty("computed", false);
if (instruction == null) {
result.addProperty("status", !currentProgram.getMemory().contains(address) ? "outside-memory" : currentProgram.getListing().getInstructionContaining(address) != null
? "not-instruction-boundary" : currentProgram.getListing().getDefinedDataContaining(address) != null ? "data" : "not-in-listing");
Instruction containing = currentProgram.getListing().getInstructionContaining(address);
boolean inMemory = currentProgram.getMemory().contains(address);
Data containingData = inMemory && containing == null
? currentProgram.getListing().getDefinedDataContaining(address) : null;
result.addProperty("status", !inMemory ? "outside-memory" : containing != null
? "not-instruction-boundary" : containingData != null ? "data" : "not-in-listing");
if (inMemory && containing == null && containingData != null) {
JsonObject parentData = new JsonObject();
parentData.addProperty("address", canonicalAddress(containingData.getAddress()));
parentData.addProperty("length", containingData.getLength());
parentData.addProperty("type_id", containingData.getDataType().getPathName());
parentData.addProperty("provenance", "ghidra-listing");
result.add("containing_data", parentData);
}
if (containing != null && currentProgram.getMemory().contains(address)) {
JsonObject containingInstruction = new JsonObject();
containingInstruction.addProperty("address", canonicalAddress(containing.getAddress()));
containingInstruction.addProperty("length", containing.getLength());
containingInstruction.addProperty("mnemonic", containing.getMnemonicString());
containingInstruction.addProperty("raw_disassembly", containing.toString());
containingInstruction.addProperty("provenance", "ghidra-listing");
result.add("containing_instruction", containingInstruction);
}
for (String key : List.of("bytes", "length", "mnemonic", "raw_disassembly")) result.add(key, JsonNull.INSTANCE);
} else {
result.addProperty("status", "decoded");
@@ -2376,7 +2397,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!match.matched()) {
continue;
}
JsonObject item = resultItem(entry.item, null);
JsonObject item = resultItem(entry.item, "procedure");
if (!match.aliases().isEmpty()) {
JsonArray aliases = new JsonArray();
for (String alias : match.aliases()) aliases.add(alias);
@@ -2391,7 +2412,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!matcher.matches(item.value)) {
continue;
}
items.add(resultItem(item, null));
items.add(resultItem(item, procedureSearch ? "procedure" : "string"));
}
return items;
}
@@ -1279,6 +1279,11 @@ and slice commitments establish the same canonical entry. The result reports
remain separate dimensions. Equal numeric addresses without applicable coordinate
commitments do not establish same-entry correspondence. Inspect the retained
source Evidence and coverage when correspondence remains ambiguous.
When both inputs authenticate to the same Evidence ID and an applicable entry
comparison is unavailable, the match reports `method: "same-evidence"`.
This identifies one selected observation, not a correspondence between distinct
records or proof of complete analysis. Unavailable facets and unestablished
call coverage remain unknown.
To aggregate already-retained comparison results, pass their exact IDs to
`find_changed_behavior`, for example:
+1 -1
View File
@@ -184,7 +184,7 @@ REA_KUNA_COMMAND=/absolute/path/to/kuna GHIDRA_INSTALL_DIR=/absolute/path/to/ghi
npm run verify:kuna -- --targets DIR --functions inflate,crc32 --compare-ghidra
```
The lane compiles a small source-owned fixture with the host C compiler and verifies the MCP workflow end to end. `--targets DIR --functions a,b` adds caller-built binaries. `--compare-ghidra` (with `GHIDRA_INSTALL_DIR`) runs those same functions through Ghidra; it requires `--targets` and `--functions` and refuses empty matrices or requested function names missing from every target. See [testing](development/testing.md#kuna-lane) for coverage.
The lane compiles a small source-owned fixture with the host C compiler and verifies the MCP workflow end to end. `--targets DIR --functions a,b` adds caller-built binaries. `--compare-ghidra` (with `GHIDRA_INSTALL_DIR`) runs those same functions through Ghidra; it requires `--targets` and `--functions` and refuses empty matrices or requested function names missing from every target. The report lists examined caller binaries and their matching requested symbols, including inputs omitted from analysis. See [testing](development/testing.md#kuna-lane) for coverage.
Failed runs also report process-lineage evidence and exit unsuccessfully. If provider or transport cleanup is unconfirmed, the report says `cleanup: "incomplete"` and records `retained_workspace`; that directory is preserved for investigation and cleanup retries.
+24
View File
@@ -89,6 +89,13 @@ does not prove text is absent. Both list and search Evidence carry this
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
bytes and references.
String searches and `trace_feature` retain each matched row's Ghidra-reported
encoding, termination and byte length. These describe the database's defined
data, not independent proof of the original source encoding. Procedure searches
and trace matches likewise retain Ghidra's external and thunk classification.
The Ghidra analysis profile identifies this search projection, so older
snapshots with unqualified search rows cannot replay under the new profile.
## PE debug-symbol coverage
The post-analysis handshake reports Ghidra's PDB Program Information attributes:
@@ -133,6 +140,11 @@ also accept source-file byte offsets. Existing symbols and analysis addresses
take precedence; a `0x`-prefixed number that resolves to no procedure falls back
to its observed source mapping. Use `--file-offset=0x...` on the CLI or
`procedure: "file-offset:0x..."` through MCP to select file coordinates explicitly.
To select an analysis address without the bare-hex fallback, qualify it with its
Ghidra address space, for example `ram:0x...`. The `inspect-native-load-image`
CLI command or MCP `inspect_native_load_image` reports Ghidra's observed default
address space; use the space appropriate to the address, and retain any
load-image verification limitations.
Multiple mapping candidates fail as ambiguous rather than selecting a bank or
address space. A successful conversion retains the file offset, mapped address
and procedure entry in raw Evidence and `locations`.
@@ -267,6 +279,18 @@ same function can be retried after raising the limit. See the
[settings design](../design/limits-and-recovery.md) for snapshots, precedence,
and provider integration.
When `inspect_native_instruction` selects a byte inside a listed instruction,
`containing_instruction` reports its separate start, length and Ghidra Listing
text, retaining any non-default address space. The requested address remains
unchanged, and a containing function may be absent. For defined data, the same
tool reports its containing object's start, length and type path; use the start
with `inspect_native_data_type` to inspect its layout. This does not select a
field or union member. These are listing observations, not proof of execution or
an alternate decode. Earlier Evidence can
omit the field. The analysis-profile revision prevents older snapshots from
replaying results without it; open the target without the old snapshot and save
a fresh snapshot when needed.
Function extents and instruction completeness refer to Ghidra's analyzed body.
An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, incoming and outgoing
+31 -2
View File
@@ -390,7 +390,12 @@ parent operation, use a discovered ID with a compatible `inspect_analysis_view`;
neither discovery nor retained selection starts a provider. For records without
a supported view, use `detail: "records"` to retrieve selected complete records,
or export the bundle to a caller-selected path. Record and bundle delivery are
subject to the MCP response budget.
subject to the MCP response budget. Summary rows are not complete records.
Retained `procedure_pseudo_code` Evidence supports `summary` and
`native`/`pseudocode` views of the original observation, including
`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 4096}`;
pages do not start a provider or create another decompilation. An
`analyze_function` dossier is a different observation.
`retention: "complete-record"` describes stored Evidence, while
`native_dossier.value_flow` independently reports truncation, unavailable or
unknown metadata. Missing omission counts remain null. Summary output carries
@@ -1009,6 +1014,18 @@ finish without reading relationships, even when neither traversal mode is
available. Discovery does not block that direct invocation or replace its typed
partial/error outcomes.
`trace_native_values` includes `value_flow_support` for the provider routed to
`analyze_function`. `supported` declares that the provider may supply value flow;
it does not guarantee a model for the selected function. `unavailable` includes
the provider's reason, and `unknown` means no declaration. The provider ID is
preserved, or null if no analysis provider is routed. This declaration is separate
from operation readiness and the availability of call-target resolution. When
call-target resolution is missing, a direct trace may still return a local graph
with call-resolution unknowns; even `max_depth: 0` attempts to resolve call sites.
When support is unknown but the declared operation prerequisites are ready,
availability can still be true; it does not promise that value flow is present.
The trace's returned observations and unknowns remain authoritative for that call.
`trace_native_ui_action` reports authored-UI and native-seed modes separately.
Apple dispatch metadata requires a Mach-O target; another provider may supply
the symbol-only alternative on other formats. If neither mode is available,
@@ -1062,7 +1079,19 @@ analysis client. `open_binary.provider_id` accepts a concrete provider ID or
concrete version, selection source, and complete analysis profile through
`analysis_provider_binding`. Ambiguity and unknown, unavailable, or unsupported
choices return typed selection details. A selected provider is never replaced
automatically after a runtime failure.
automatically after a runtime failure. When the bound route does not declare an
operation, its error points to `binary_session` with `detail: "complete"` for
candidate capabilities and an explicit `open_binary` provider choice. A
candidate's availability does not guarantee support for the selected target;
switching may discard provider session edits, and retained Evidence does not restore them.
When the selected provider reports `release_verification` in its binding,
the status compares its reported release version at binding with the provider's
verified baseline. It does not recheck the executable, authenticate its bytes,
or guarantee results.
An unverified release may still be usable; the optional remediation preserves
the caller's choice. Omission means verification was not reported. Target-free
discovery does not probe a version solely to populate this fact.
When an admitted `open_binary` attempt fails, `error.details.selection_at_failure`
records the canonical binary path and SHA-256, resource root (including its
+10 -1
View File
@@ -448,7 +448,16 @@ caller-built functions through Ghidra (it refuses to run without `--targets` and
reports latency, sampled process-tree CPU and peak memory, UTF-8 response size,
and goto and warning-marker counts. Those counts are not a correctness judgement,
and model tokens are not measured. Reference timings and response sizes are
reported separately; reference caches expire when the server closes. `--report PATH` writes the JSON report.
reported separately; reference caches expire when the server closes. The report's
`caller_target_selection` retains the selected directory, the original functions
argument, and each examined binary's requested names matched against `nm` text
symbols. `symbol_scan_complete` means the scan of non-`.stripped` entries in
the immediate flavor directories finished; it does not cover other directory
entries. An `inspection_failed` entry and `symbol_scan_complete: false` retain
partial discovery when symbol inspection fails. A missing symbol is not proof
that code is absent, and selection does not establish that a stripped counterpart
was verified; consult `results` for completed variants. `--report PATH` writes
the JSON report.
The first matrix run used Kuna 1.833 and Ghidra 12.1.4 with JDK 21 on macOS
arm64. It covered zlib 1.3.2 `minigzip`, coreutils 9.9 `sort` and the sqlite
+8
View File
@@ -18,6 +18,7 @@ import {
} from "./mcp-verifier-results.mjs";
import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs";
import { verifyGhidraProjectIdentity } from "./real-ghidra-project-identity.mjs";
import { verifyGhidraSocketBoundary } from "./real-ghidra-socket-boundary.mjs";
const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
const environment = () => ({
@@ -355,6 +356,12 @@ async function verifyNativeValueAdapters(
entrypoint,
env,
});
const socketBoundary = await verifyGhidraSocketBoundary({
target,
procedure,
entrypoint,
env,
});
return {
mocked: false,
cli: true,
@@ -363,6 +370,7 @@ async function verifyNativeValueAdapters(
direct_function_comparison: true,
boundaries,
project_identity: projectIdentity,
socket_boundary: socketBoundary,
nodes: cliGraph.total_nodes,
edges: cliGraph.total_edges,
decompilations: cliGraph.decompilations,
+7 -3
View File
@@ -23,6 +23,7 @@ import { verifyGhidraEntryAliases } from "./real-ghidra-entry-aliases.mjs";
import { verifyGhidraNamespaceAnnotations } from "./real-ghidra-namespace-annotations.mjs";
import { verifyGhidraBatchIdentity } from "./real-ghidra-batch-identity.mjs";
import { verifyGhidraInstructionFlow } from "./real-ghidra-instruction-flow.mjs";
import { verifyGhidraSearchFacts } from "./real-ghidra-search-facts.mjs";
/** Probe real Ghidra location, annotation and error contracts through public adapters. */
export async function verifyGhidraBoundaries(
@@ -410,6 +411,11 @@ export async function verifyGhidraBoundaries(
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
);
assert.ok(marker);
await verifyGhidraSearchFacts(call, {
strings,
procedures,
entryAddress: address,
});
assert.ok(
(
await call("search_strings", { pattern: marker.value.toLowerCase() })
@@ -598,9 +604,7 @@ export async function verifyGhidraBoundaries(
const longString = strings.find((item) => item.value === longLiteral);
assert.ok(longString, "The provider must retain the full regression literal");
const literalMatches = await call("search_strings", { pattern: longLiteral });
assert.deepEqual(literalMatches, [
{ address: longString.address, value: longLiteral },
]);
assert.deepEqual(literalMatches, [longString]);
assert.deepEqual(await cli("search", longLiteral), literalMatches);
await assert.rejects(
cli("search", regexPattern, ["--mode", "regex"]),
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
/** Compare public searches and composed traces with their provider inventory rows. */
export async function verifyGhidraSearchFacts(
call,
{ strings, procedures, entryAddress },
) {
const marker = strings.find(
(row) => row.value === "REA_GHIDRA_INVENTORY_ENTRY",
);
const entry = procedures.find((row) => row.address === entryAddress);
const external = procedures.find((row) => row.procedure.external);
const thunk = procedures.find((row) => row.procedure.thunk);
assert.ok(marker, "Missing source string fixture");
assert.ok(entry, "Missing selected entry procedure");
assert.ok(external, "Missing external procedure fixture");
const cases = [
{ type: "string", operation: "search_strings", row: marker },
...[entry, external, ...(thunk === undefined ? [] : [thunk])].map(
(row) => ({
type: "procedure",
operation: "search_procedures",
row,
}),
),
];
for (const { type, operation, row } of cases) {
const search = await call(operation, {
pattern: row.value,
mode: "literal",
case_sensitive: true,
});
const match = search.find(
(item) => item.address === row.address && item.value === row.value,
);
assert.deepEqual(
match,
row,
`${operation} must preserve inventory facts for ${row.value}`,
);
const trace = await call("trace_feature", {
query: row.value,
case_sensitive: true,
});
const traced = trace.matches.find(
(item) =>
item.type === type &&
item.address === row.address &&
item.value === row.value,
);
assert.deepEqual(
traced,
{ ...row, type },
`trace_feature must preserve ${operation} facts for ${row.value}`,
);
}
return { compared: cases.length, thunk_observed: thunk !== undefined };
}
+200
View File
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
access,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rm,
stat,
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
const exec = promisify(execFile);
/** Exercise the OpenJDK Unix-socket boundary through real Ghidra and REA. */
export async function verifyGhidraSocketBoundary({
target,
procedure,
entrypoint,
env,
}) {
if (process.platform !== "darwin" && process.platform !== "linux")
return {
status: "not-applicable",
reason: "The Ghidra Unix-socket lane is not used on this host.",
};
const limit = process.platform === "darwin" ? 102 : 106;
const before = createHash("sha256")
.update(await readFile(target.path))
.digest("hex");
const root = await realpath(await mkdtemp("/tmp/rea-gsock-"));
const suffixBytes = Buffer.byteLength("/rea-ghidra-XXXXXX/bridge.sock");
let cleanupIncomplete = false;
const samples = [];
try {
for (const { proposedBytes, unicode } of [
{ proposedBytes: limit, unicode: false },
{ proposedBytes: limit + 1, unicode: false },
{ proposedBytes: limit + 2, unicode: false },
{ proposedBytes: limit + 1, unicode: true },
]) {
const character = unicode ? "雪" : "";
const padding =
proposedBytes -
suffixBytes -
Buffer.byteLength(root) -
1 -
Buffer.byteLength(character);
assert.ok(
padding > 0,
"Verifier workspace leaves no room for the boundary fixture",
);
const parent = join(root, "x".repeat(padding) + character);
await mkdir(parent);
assert.equal(Buffer.byteLength(parent) + suffixBytes, proposedBytes);
if (unicode) assert.ok(Buffer.byteLength(parent) > parent.length);
const sessionEnv = { ...env, TMPDIR: parent };
const client = new Client({
name: "rea-ghidra-socket-boundary",
version: "1",
});
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: sessionEnv,
stderr: "pipe",
});
let connected = false;
let opened = false;
let runtimeRoot;
let socketRoot;
let endpoint;
const cleanup = async (task) => {
try {
await task();
} catch (cause) {
cleanupIncomplete = true;
throw cause;
}
};
await withVerifierCleanup(async () => {
await client.connect(transport);
connected = true;
const tools = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
const call = async (name, arguments_ = {}) => {
const reply = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 180000, toolDefinition: tools.get(name) },
);
assert.notEqual(
reply.isError,
true,
`${name}: ${JSON.stringify(reply)}`,
);
return reply.structuredContent ?? JSON.parse(reply.content[0].text);
};
await call("open_binary", { path: target.path, provider_id: "ghidra" });
opened = true;
await call("list_documents");
const roots = (await readdir(parent)).filter((name) =>
name.startsWith("rea-ghidra-"),
);
assert.equal(roots.length, 1);
runtimeRoot = join(parent, roots[0]);
endpoint = JSON.parse(
await readFile(join(runtimeRoot, "ownership.json"), "utf8"),
).endpoint_path;
assert.equal(
Buffer.byteLength(join(runtimeRoot, "bridge.sock")),
proposedBytes,
);
const fallback = dirname(endpoint) !== runtimeRoot;
assert.equal(fallback, proposedBytes > limit);
if (fallback) {
socketRoot = dirname(endpoint);
assert.match(basename(socketRoot), /^rea-ghidra-socket-/u);
assert.equal((await stat(socketRoot)).mode & 0o777, 0o700);
}
samples.push({
proposed_bytes: proposedBytes,
unicode,
endpoint_bytes: Buffer.byteLength(endpoint),
fallback,
});
}, [
() =>
cleanup(async () => {
if (!connected || !opened) return;
const reply = await client.callTool(
{ name: "close_binary", arguments: {} },
{ timeout: 180000 },
);
assert.notEqual(
reply.isError,
true,
`close_binary: ${JSON.stringify(reply)}`,
);
}),
() =>
cleanup(async () => {
await client.close();
}),
() =>
cleanup(async () => {
await transport.close();
}),
]);
await assert.rejects(access(runtimeRoot), { code: "ENOENT" });
if (socketRoot !== undefined)
await assert.rejects(access(socketRoot), { code: "ENOENT" });
if (proposedBytes === limit + 1 && !unicode) {
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"function",
target.path,
procedure,
"--provider",
"ghidra",
"--json",
],
{ env: sessionEnv, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
);
assert.ok(JSON.parse(stdout).normalized_result.procedure);
assert.deepEqual(
(await readdir(parent)).filter((name) =>
name.startsWith("rea-ghidra-"),
),
[],
"CLI left a Ghidra runtime directory",
);
}
}
assert.equal(
createHash("sha256")
.update(await readFile(target.path))
.digest("hex"),
before,
);
return {
status: "verified",
samples,
cli_boundary: true,
target_unchanged: true,
};
} finally {
// A failed cleanup retains its exact workspace for investigation and retry.
if (!cleanupIncomplete) await rm(root, { recursive: true, force: true });
}
}
@@ -0,0 +1,187 @@
#!/usr/bin/env node
// Exercise the public one-shot CLI on a caller-owned saved project. The report
// is intentionally synthetic; it is not a claim about Ghidra's analysis history.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
const installation = process.env.GHIDRA_INSTALL_DIR;
if (!installation || !process.env.JAVA_HOME)
throw new Error(
"Set GHIDRA_INSTALL_DIR and JAVA_HOME to caller-installed Ghidra and JDK 21.",
);
const commands = await createVerifierCommands();
let root;
let commandsClosed = false;
const targetHash = await withVerifierCleanup(async () => {
root = await mkdtemp(join(tmpdir(), "rea-function-bundle-"));
const target = join(root, "fixture");
const projectDir = join(root, "project");
const scriptDir = join(root, "scripts");
const source = join(root, "fixture.c");
const project = join(projectDir, "SourceControl.gpr");
const repo = resolve(process.cwd());
const hash = (data) => createHash("sha256").update(data).digest("hex");
const tree = async (directory, prefix = "") => {
const result = [];
for (const entry of (
await readdir(directory, { withFileTypes: true })
).sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(directory, entry.name);
const relative = join(prefix, entry.name);
if (entry.isDirectory()) result.push(...(await tree(path, relative)));
else if (entry.isFile())
result.push([relative, hash(await readFile(path))]);
else throw new Error(`Unexpected project entry: ${relative}`);
}
return result;
};
const run = async (command, args, options = {}) =>
commands.exec(command, args, {
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
...options,
});
await mkdir(projectDir);
await mkdir(scriptDir);
await writeFile(
source,
"__attribute__((noinline)) int leaf(int x) { return x + 1; }\nint main(void) { return leaf(2); }\n",
);
await writeFile(
join(scriptDir, "SetHistory.java"),
`import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Program;
public class SetHistory extends GhidraScript {
public void run() throws Exception {
currentProgram.getOptions(Program.PROGRAM_INFO).setString("REA disabled analyzers", "{\\"format\\":\\"rea-ghidra-disabled-analyzers-v1\\",\\"analyzers\\":[{\\"name\\":\\"Synthetic historical control\\",\\"status\\":\\"disabled\\"}]}");
}
}
`,
);
await run(process.env.REA_CC ?? "cc", ["-O0", "-g", source, "-o", target]);
await run(join(installation, "support", "analyzeHeadless"), [
projectDir,
"SourceControl",
"-import",
target,
"-scriptPath",
scriptDir,
"-postScript",
"SetHistory.java",
]);
const before = await tree(projectDir);
const targetHash = hash(await readFile(target));
const env = {
...process.env,
REA_GHIDRA_PROJECT_PATH: project,
REA_GHIDRA_PROGRAM_NAME: "fixture",
};
const cli = join(repo, "scripts", "rea.mjs");
for (const detail of ["complete", "summary"]) {
const output = join(root, `${detail}.json`);
const { stdout } = await run(
process.execPath,
[
cli,
"function",
target,
"main",
"--provider",
"ghidra",
"--detail",
detail,
"--evidence_output",
output,
"--format",
"json",
"--full-output",
],
{ env },
);
assert.equal(JSON.parse(stdout).ok, true);
const bundle = JSON.parse(await readFile(output, "utf8"));
const parent = bundle.records.find(
(record) => record.operation === "analyze_function",
);
assert.ok(parent, "function observation missing from bundle");
assert.ok(
parent.evidence_links.length,
"fixture did not produce a linked historical source",
);
const records = new Map(
bundle.records.map((record) => [record.evidence_id, record]),
);
for (const record of bundle.records)
for (const link of record.evidence_links)
assert.ok(records.has(link), `bundle omits linked Evidence ${link}`);
assert.ok(
parent.evidence_links.some(
(id) => records.get(id)?.operation === "health",
),
"historical health source missing",
);
}
const existing = join(root, "existing.json");
await writeFile(existing, "caller-owned\n");
let failure;
try {
await run(
process.execPath,
[
cli,
"function",
target,
"main",
"--provider",
"ghidra",
"--detail",
"summary",
"--evidence_output",
existing,
"--format",
"json",
"--full-output",
],
{ env },
);
assert.fail("existing output unexpectedly overwritten");
} catch (error) {
if (error.code !== 1) throw error;
failure = JSON.parse(error.snapshot.stdout.text);
}
assert.equal(await readFile(existing, "utf8"), "caller-owned\n");
assert.equal(failure.ok, false);
assert.equal(failure.error.details.source_closure.complete, true);
assert.ok(
failure.error.details.source_closure.records.some(
(record) => record.operation === "health",
),
);
assert.equal(hash(await readFile(target)), targetHash);
assert.deepEqual(await tree(projectDir), before);
return targetHash;
}, [
async () => {
await commands.close();
commandsClosed = true;
},
async () => {
if (commandsClosed && root !== undefined)
await rm(root, { recursive: true, force: true });
},
]);
process.stdout.write(
`PASS Ghidra function bundles: complete, summary, no-overwrite; target and project unchanged (${targetHash})\n`,
);
+82 -13
View File
@@ -26,6 +26,11 @@ import { parseArgs, promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js";
import {
KUNA_RELEASE_VERIFICATION_EXPLANATION,
KUNA_UNVERIFIED_VERSION_REMEDIATION,
KUNA_VERIFIED_VERSION,
} from "../dist/kuna/KunaRelease.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import {
@@ -281,6 +286,34 @@ const openServer = async (provider, kunaSettings = {}) => {
path: provider.path,
provider_id: provider.id,
});
if (provider.id === "kuna") {
const status = (await call("binary_session")).value;
const binding = status.analysis_provider_binding;
const verification = binding.release_verification;
assert.equal(verification.basis, "reported_version");
assert.equal(verification.verified_version, KUNA_VERIFIED_VERSION);
assert.equal(
verification.reported_version,
binding.analysis_profile.parameters.kuna_version,
);
assert.equal(
verification.status,
binding.analysis_profile.parameters.kuna_version ===
KUNA_VERIFIED_VERSION
? "verified"
: "unverified",
);
assert.equal(
verification.explanation,
KUNA_RELEASE_VERIFICATION_EXPLANATION,
);
if (verification.status === "unverified")
assert.equal(
verification.remediation,
KUNA_UNVERIFIED_VERSION_REMEDIATION,
);
else assert.equal(verification.remediation, null);
}
} catch (cause) {
// A rejected connection/target still owns its transport and workspace.
await close(cause);
@@ -637,15 +670,37 @@ const measureGhidra = async (path, functions) => {
return answers;
};
/** Symbol-bearing caller binaries with the requested functions, beside their stripped copies. */
const targetPairs = async (root, names) => {
const describeFailure = (cause) => ({
message: cause instanceof Error ? cause.message : String(cause),
...(cause instanceof AggregateError
? { causes: cause.errors.map(describeFailure) }
: {}),
});
/** Inspect symbol-bearing caller binaries, retaining both selected and unmatched inputs. */
const targetPairs = async (root, names, selection) => {
const pairs = [];
for (const flavor of (await readdir(root)).sort()) {
const directory = join(root, flavor);
if (!(await stat(directory)).isDirectory()) continue;
for (const file of (await readdir(directory)).sort()) {
if (file.endsWith(".stripped")) continue;
const symbols = await symbolAddresses(join(directory, file), names);
let symbols;
try {
symbols = await symbolAddresses(join(directory, file), names);
} catch (cause) {
selection.examined.push({
binary: `${flavor}/${file}`,
status: "inspection_failed",
error: describeFailure(cause),
});
throw cause;
}
selection.examined.push({
binary: `${flavor}/${file}`,
status: symbols.size > 0 ? "selected" : "no_matching_text_symbols",
matched_functions: [...symbols.keys()],
});
if (symbols.size > 0) pairs.push({ flavor, directory, file, symbols });
}
}
@@ -666,6 +721,18 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => {
const results = [];
let timeoutRecovery;
const failures = [];
const callerTargetSelection =
values.targets === undefined
? undefined
: {
directory: values.targets,
functions_argument: values.functions ?? null,
requested_functions: (values.functions ?? "")
.split(",")
.filter(Boolean),
symbol_scan_complete: false,
examined: [],
};
let inspectionResourcesClean = true;
try {
const installation = await inspectKunaInstallation(
@@ -678,13 +745,18 @@ try {
);
kunaVersion = `kuna ${installation.value.version}`;
let callerPairs = [];
if (values.targets !== undefined) {
const names = (values.functions ?? "").split(",").filter(Boolean);
if (callerTargetSelection !== undefined) {
const names = callerTargetSelection.requested_functions;
assert.ok(names.length > 0, "--targets requires --functions");
callerPairs = await targetPairs(values.targets, names);
callerPairs = await targetPairs(
callerTargetSelection.directory,
names,
callerTargetSelection,
);
callerTargetSelection.symbol_scan_complete = true;
assert.ok(
callerPairs.length > 0,
`No caller-built targets contain the requested functions in ${values.targets}`,
`No caller-built targets contain the requested functions in ${callerTargetSelection.directory}`,
);
const found = new Set(
callerPairs.flatMap(({ symbols }) => [...symbols.keys()]),
@@ -772,18 +844,15 @@ if (cleanupComplete)
cleanupComplete = false;
failures.push(cause);
}
const describeFailure = (cause) => ({
message: cause instanceof Error ? cause.message : String(cause),
...(cause instanceof AggregateError
? { causes: cause.errors.map(describeFailure) }
: {}),
});
const report = {
lane: "kuna",
status: failures.length === 0 ? "passed" : "failed",
kuna_version: kunaVersion,
platform: `${process.platform}/${process.arch}`,
verifier_run: lifecycle,
...(callerTargetSelection === undefined
? {}
: { caller_target_selection: callerTargetSelection }),
results,
...(timeoutRecovery === undefined
? {}
-2
View File
@@ -233,7 +233,6 @@ it.each([true, false])(
{ timeoutMs: 100, maxOutputBytes: 1024, volatileOutput },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a capture allowance refusal");
const projected = projectAnalysisError(result.error);
expect(projected.retryable).toBe(volatileOutput);
@@ -263,7 +262,6 @@ it("reports the effective fixed adb deadline", async () => {
{ timeoutMs: 600_000, maxOutputBytes: 1024 },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected an adb deadline");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
-7
View File
@@ -87,7 +87,6 @@ const refusedBy = async (
request: AdbRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
@@ -577,7 +576,6 @@ it.each([
await expect(lstat(packageDirectory)).rejects.toThrow();
return;
}
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error.message);
expect(
(
@@ -1463,7 +1461,6 @@ exit 1`,
overwrite: false,
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected snapshot cleanup to be reported");
expect(result.error.cleanupResources).toHaveLength(1);
expect(result.error.partialObservation).toMatchObject({
@@ -1523,7 +1520,6 @@ exit 1`,
overwrite: false,
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a changed-artifact result");
const evidence = parseEvidence(result.error.partialObservation);
expect(evidence).toMatchObject({
@@ -1868,7 +1864,6 @@ it("reads Android 16 focus declarations from displays when windows omits them",
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("dumpsys window displays"),
@@ -1891,7 +1886,6 @@ it("describes missing window focus as unknown after both sections omit declarati
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("focus is unknown"),
@@ -1915,7 +1909,6 @@ it.each(["windows", "displays"])(
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
focused_window: null,
-2
View File
@@ -114,7 +114,6 @@ it("applies the omitted 600000 ms decode deadline and a selected value on the ne
expect(outputBudgets).toEqual([4 * 1024 * 1024]);
const timedOut = await decode(provider, path, 200);
expect(timedOut.ok).toBe(false);
if (timedOut.ok) throw new Error("Expected a decode deadline");
const projected = projectAnalysisError(timedOut.error);
expect(projected).toMatchObject({
@@ -144,7 +143,6 @@ it("applies the omitted 600000 ms decode deadline and a selected value on the ne
]);
const maximum = await decode(provider, path, MAX_APKTOOL_DECODE_TIMEOUT_MS);
expect(maximum.ok).toBe(false);
if (maximum.ok) throw new Error("Expected the maximum decode deadline");
expect(projectAnalysisError(maximum.error).remediation.action).toContain(
"do not increase it further",
-2
View File
@@ -97,7 +97,6 @@ const refusedBy = async (
request: ApktoolRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
@@ -583,7 +582,6 @@ it("still refuses invalid UTF-8 bytes in decoded strings", async () => {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
@@ -276,6 +276,7 @@ const unavailableFacet = (
remediation:
error.retryable ||
error.remediationAction !== undefined ||
error.fallbackRemediationAction !== undefined ||
error.continuingCallerGuidance?.remediationAction !== undefined
? projected.remediation.action
: "Review provider availability in binary_session with detail complete and select another supported provider if appropriate. After switching, if a name is known and search_procedures is available, use it to find candidates and verify their identity and coordinates.",
+8
View File
@@ -31,6 +31,7 @@ import type { ProgressReporter } from "./ProgressReporter.js";
import type { ProviderRejectionCode } from "../domain/providerSelection.js";
import type { ProcessLineageObservation } from "../process/ProcessOwnership.js";
import type { ProviderOperationHealth } from "../domain/providerOperationHealth.js";
import type { ProviderReleaseVerification } from "../domain/providerReleaseVerification.js";
import {
relationshipCoverageSchema,
type RelationshipCoverage,
@@ -291,6 +292,9 @@ export interface ProviderIdentity {
/** Typed analysis profile resolved before provider startup; null means unresolved. */
export interface AnalysisProfileResolution {
readonly profile: AnalysisProfileCommitment | null;
/** Readiness fact for the resolved selection; omitted means not reported. */
readonly releaseVerification?: ProviderReleaseVerification;
/** Closing a client can publish state that invalidates a previewed profile. */
readonly refreshAfterRetirement?: true;
/** Keep provider-private facts bound to this selection without Evidence serialization. */
@@ -360,6 +364,10 @@ export type CapabilityDescriptor = CapabilityAvailability & {
readonly operation: Exclude<AnalysisOperation, "health">;
readonly effects: CapabilityEffects;
readonly limitations: readonly string[];
/** Whether analyze_function can supply value flow for some procedures. */
readonly nativeValueFlow?:
| { readonly status: "supported" }
| { readonly status: "unavailable"; readonly reason: string };
};
/** Factory and capability declaration for an analysis implementation. */
@@ -1,7 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parseOpenStep } from "plist";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
@@ -19,7 +18,6 @@ it.each([
const executable = join(programs, "Fixture");
const infoPlist = join(bundle, "Contents", "Info.plist");
const source = `${prefix}{ CFBundleExecutable = Fixture; }`;
expect(parseOpenStep(source)).toEqual({ CFBundleExecutable: "Fixture" });
await mkdir(programs, { recursive: true });
await writeFile(infoPlist, source);
await writeFile(executable, "fixture - never executed");
@@ -286,7 +286,6 @@ it("retains completed bundle facts as bundle Evidence after cancellation", async
total_analysis_timeout_ms: 1000,
});
const result = await analyzeWebBundle(browser, selected);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
@@ -313,7 +312,6 @@ it("preserves a prior caller reason without admitting browser work", async () =>
input,
{ signal: AbortSignal.abort(reason) },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled inventory");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -48,7 +48,6 @@ const expectRetainedCapture = (
result: Awaited<ReturnType<typeof runScenario>>,
failure: AnalysisError,
) => {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the retained capture");
expect(result.error).toBe(failure);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
@@ -197,7 +196,6 @@ it("rejects a successful capture that is not a completed scenario", async () =>
captureScenario: async () => ok({} as BrowserScenarioCapture),
};
const result = await captureBrowserScenario(provider, scenario);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a completed-capture refusal");
expect(result.error).toBeInstanceOf(AnalysisOutputError);
expect(result.error.message).toContain(
-1
View File
@@ -28,7 +28,6 @@ describe("call-path traversal cancellation ownership", () => {
input,
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
+52 -9
View File
@@ -24,7 +24,6 @@ import {
toolAvailabilitySummaryInventory,
TOOL_ANNOTATION_NAMES,
TOOL_EFFECT_NAMES,
type ProviderCapability,
type ToolAvailabilityInventory,
type ToolAvailabilitySummaryInventory,
type ToolAvailability,
@@ -71,7 +70,7 @@ const statusSchema = z.object({
type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
type ProviderDescriptor = ReturnType<typeof expandProviderCapabilities>[number];
type UnavailableDescriptor = Extract<ProviderDescriptor, { available: false }>;
/** Optional integrations whose verified hosts exclude some platforms. */
@@ -310,6 +309,22 @@ export const buildCapabilityInventory = (
const facts = {
name: contract.name,
surface: contract.kind,
...(contract.name === "trace_native_values"
? {
value_flow_support: (() => {
const source = descriptors.get("analyze_function");
return source?.native_value_flow === undefined
? {
status: "unknown" as const,
provider_id: source?.provider.id ?? null,
}
: {
...source.native_value_flow,
provider_id: source.provider.id,
};
})(),
}
: {}),
...(availability.defaultModeAvailable === undefined
? {}
: { default_mode_available: availability.defaultModeAvailable }),
@@ -377,6 +392,9 @@ export function compactToolAvailability(
available: entry.available,
reason: entry.reason,
remediation: entry.remediation,
...(entry.value_flow_support === undefined
? {}
: { value_flow_support: entry.value_flow_support }),
...(entry.default_mode_available === undefined
? {}
: { default_mode_available: entry.default_mode_available }),
@@ -933,13 +951,38 @@ const composedAvailability = (
reason: "provider_missing",
remediation: "No provider composition is declared for this operation.",
};
return "modes" in requirements
? availabilityForModes(
requirements.modes,
requirements.defaultMode,
descriptors,
)
: composedAvailabilityFor(requirements, descriptors).availability;
const result =
"modes" in requirements
? availabilityForModes(
requirements.modes,
requirements.defaultMode,
descriptors,
)
: composedAvailabilityFor(requirements, descriptors).availability;
if (name !== "trace_native_values") return result;
const source = descriptors.get("analyze_function");
if (source?.available !== true) return result;
if (source.native_value_flow?.status === "unavailable")
return {
reason:
result.reason === "provider_unavailable" ||
result.reason === "unsupported_host"
? result.reason
: "provider_missing",
remediation: `${source.native_value_flow.reason} Select a provider that can report native value-flow observations; cross-function tracing also needs call-target resolution.${(result.reason === "provider_unavailable" || result.reason === "unsupported_host") && result.remediation !== null ? ` ${result.remediation}` : ""}`,
};
if (
result.reason === "provider_missing" &&
descriptors.get("resolve_native_call_targets") === undefined
)
return {
...result,
remediation:
source.native_value_flow?.status === "supported"
? "A direct trace may still return local value-flow observations with call-resolution unknowns. Configure a provider with resolve_native_call_targets for call resolution and cross-function tracing."
: "Value-flow support is undeclared. A direct trace may return partial results or an unknown. Configure a provider with resolve_native_call_targets for call resolution and cross-function tracing.",
};
return result;
};
/** Evaluate source groups once so discovery and missing-operation facts agree. */
@@ -59,7 +59,6 @@ describe("JSON client configuration repeated-member edits", () => {
removeShadowedMembersAt: [path],
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("authorized repeats were refused");
expect(parseStrictJson(result.value)).toEqual({
mcpServers: {
@@ -109,7 +108,6 @@ describe("JSON client configuration repeated-member edits", () => {
"handles %s without losing peers",
(_label, text, expectedServers, authority) => {
const result = removeRea(text, "json", authority);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("non-shadowed removal was refused");
expect(parseStrictJson(result.value)).toEqual({
mcpServers: expectedServers,
@@ -127,7 +125,6 @@ describe("JSON client configuration repeated-member edits", () => {
removeShadowedMembersAt: [["mcpServers", "rea"]],
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("authorized escaped repeat was refused");
expect(result.value).toContain(`"mcpServers":${inactive}`);
expect(parseStrictJson(result.value)).toEqual({
@@ -146,7 +143,6 @@ describe("JSON client configuration repeated-member edits", () => {
removeShadowedMembersAt: [["mcp", "rea"]],
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("authorized JSONC repeats were refused");
expect(result.value).toContain("// top comment");
expect(result.value).toContain("// first");
@@ -42,7 +42,6 @@ for (const format of ["toml", "grok"] as const)
format,
source,
);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("TOML edit was refused");
for (const comment of [
"# server header note",
@@ -85,7 +84,6 @@ it("retains comments from dotted REA assignments and nested inline values", () =
"toml",
source,
);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("TOML removal was refused");
expect(result.value).toContain(
source.includes("# dotted note")
+38 -30
View File
@@ -16,7 +16,10 @@ import {
import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js";
import { silentLogger } from "../logger.js";
import type { Logger } from "pino";
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
import {
tryParseEvidence,
withEvidenceCallerInputPath,
} from "../domain/evidence.js";
import {
createImmutableEvidenceBundle,
type EvidenceBundle,
@@ -26,8 +29,8 @@ import {
type AnalysisResultWithSources,
} from "./investigation/recordEvidenceSources.js";
import {
resolveInvestigationEvidenceClosure,
createInvestigationEvidenceClosureChecker,
collectInvestigationEvidenceClosure,
} from "./investigation/InvestigationEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
@@ -388,12 +391,12 @@ const runAnalysis = async (
readonly evidenceParameters?: Readonly<Record<string, JsonValue>>;
readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void;
readonly createSession?: () => BinarySession | Promise<BinarySession>;
readonly includeEvidenceSources?: boolean;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
readonly includeEvidenceSources?: boolean;
},
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> => {
const { logger, signal, snapshotPath } = options;
@@ -416,23 +419,19 @@ const runAnalysis = async (
evidence: Evidence,
supplied: readonly Evidence[] = [],
): Result<null, AnalysisError> => {
if (options.includeEvidenceSources !== true) return ok(null);
const known = new Map(
if (!options.includeEvidenceSources) return ok(null);
const records = new Map(
supplied.map((record) => [record.evidence_id, record]),
);
const closure = resolveInvestigationEvidenceClosure(
const collected = collectInvestigationEvidenceClosure(
{
evidenceForAnalysis: (id) => {
const record = known.get(id) ?? session.evidenceForAnalysis(id);
if (record !== undefined) known.set(id, record);
return record;
},
evidenceForAnalysis: (id) =>
records.get(id) ?? session.evidenceForAnalysis(id),
},
evidence.evidence_links,
);
if (!closure.ok) return closure;
sourceEvidence = closure.value.map((id) => known.get(id) as Evidence);
return ok(null);
sourceEvidence = collected.records;
return collected.result;
};
const execute = async (): Promise<Result<Evidence, AnalysisError>> => {
let isDirectory = false;
@@ -518,12 +517,12 @@ const runAnalysis = async (
});
if (checkClosure(cached.evidence_links)) {
const evidence = withEvidenceCallerInputPath(cached, path);
const sources = captureSources(
const captured = captureSources(
evidence,
snapshot.evidence_bundle.records,
);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
options.onEvidenceBundle?.(snapshot.evidence_bundle);
return ok(evidence);
}
@@ -554,20 +553,30 @@ const runAnalysis = async (
evidenceProfile,
saveSnapshot: snapshotPath !== undefined,
});
if (!executed.ok) return executed;
if (!executed.ok) {
if (options.includeEvidenceSources) {
const partial = tryParseEvidence(executed.error.partialObservation);
if (partial !== undefined) {
const captured = captureSources(partial);
if (!captured.ok)
executed.error.retainSecondaryFailure(captured.error);
}
}
return executed;
}
const { cachePolicy } = executed.value;
const evidence = withEvidenceCallerInputPath(executed.value.evidence, path);
// Provider execution may have already recorded the same semantic Evidence
// without this invocation's request path. Import replaces that metadata
// while retaining the authenticated result and snapshot binding.
const captured = captureSources(evidence);
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
const recorded = session.importEvidenceBundle(
createImmutableEvidenceBundle([evidence]),
);
if (!recorded.ok)
return err(recorded.error.retainPartialObservation(evidence));
const sources = captureSources(evidence);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
if (isWorkflowEvidenceTool(tool)) {
const unknowns = recordWorkflowUnknowns({
name: tool,
@@ -649,16 +658,15 @@ const runAnalysis = async (
outcome.cleanupError.retainDiagnostics({
analysis_snapshot: { ...publishedSnapshot },
});
return err(
directAnalysisCleanupFailure(result, outcome.cleanupError, path),
);
return {
...err(directAnalysisCleanupFailure(result, outcome.cleanupError, path)),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
}
return result.ok
? {
...ok(result.value),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
}
: err(cliError(result.error, path));
return {
...(result.ok ? ok(result.value) : err(cliError(result.error, path))),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
};
const executeAnalysisTool = async (input: {
@@ -6,7 +6,6 @@ it("preserves field diagnostics for rejected enhanced tool inputs", async () =>
const execute = vi.fn();
const tools = new EnhancedTools({ execute });
const result = await tools.execute("trace_native_values", { procedure: "" });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected an input failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
@@ -18,7 +17,6 @@ it("preserves field diagnostics for rejected enhanced tool inputs", async () =>
expect(execute).not.toHaveBeenCalled();
const sibling = await tools.execute("batch_decompile", { addresses: "bad" });
expect(sibling.ok).toBe(false);
if (sibling.ok) throw new Error("expected an input failure");
expect(projectAnalysisError(sibling.error)).toMatchObject({
details: { issues: [{ path: ["addresses"], reason: "invalid_type" }] },
@@ -444,7 +444,6 @@ describe("EnhancedTools cancellation ownership", () => {
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Pre-cancelled observation was accepted");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -212,7 +212,6 @@ it("projects completed raw capture facts on failure through the caller's selecti
record_ordinals: [0],
sensitive_values: ["private-marker"],
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation must remain the primary result");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -270,7 +269,6 @@ it("keeps a redacted cleanup error linked to its live owner through service clos
format: "har",
sensitive_values: ["private-marker"],
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Retained cleanup must remain a failure");
const returnedError = result.error;
expect(returnedError.cleanupIncomplete).toBe(true);
+1 -1
View File
@@ -21,7 +21,7 @@ export const workflowAnalysisProfile = (
: operation === "batch_decompile"
? { workflow_revision: 4 }
: operation === "trace_feature"
? { workflow_revision: 8 }
? { workflow_revision: 9 }
: operation === "get_call_graph"
? { workflow_revision: 3 }
: operation === "trace_call_path" ||
-1
View File
@@ -703,7 +703,6 @@ it("reports a retained workflow and the committed prefix when Unknown publicatio
? err(failure)
: records.recordUnknown(input),
});
expect(outcome.ok).toBe(false);
if (outcome.ok) throw new Error("expected failure");
const projected = projectAnalysisError(outcome.error);
expect(projected.details?.workflow_unknown_publication).toEqual({
+7
View File
@@ -204,6 +204,10 @@ export const executeComposedWorkflow = async (
);
};
/** Scope of the exact-address reference lookups composed by trace_feature. */
export const TRACE_FEATURE_REFERENCE_LIMITATION =
"Reference queries cover only each matched start address, not interior bytes of strings and procedures. Empty references and truncated: false do not establish complete reference coverage.";
/** Build the shared Evidence record for a successful composed binary workflow. */
export const createWorkflowEvidence = (input: {
readonly target: BinaryTarget | undefined;
@@ -232,6 +236,9 @@ export const createWorkflowEvidence = (input: {
: { evidenceLinks: input.upstreamEvidenceLinks }),
limitations: [
"Derived by an REA composed workflow.",
...(input.operation === "trace_feature"
? [TRACE_FEATURE_REFERENCE_LIMITATION]
: []),
...input.upstreamLimitations,
],
...(input.operation === "batch_decompile"
@@ -53,7 +53,6 @@ it.each([
it("rejects an unknown operation before any effect", async () => {
const service = new AdbDeviceAnalysisService(refusedPort);
const result = await service.execute("unknown_adb_operation" as never, {});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected an unknown operation refusal");
expect(result.error).toBeInstanceOf(AnalysisInputError);
if (!(result.error instanceof AnalysisInputError)) throw result.error;
@@ -211,7 +211,6 @@ it("keeps native JSON source excerpts internal while preserving the failure caus
const resources = new ArtifactResourceScope();
try {
const result = await inspectApiContractEvidence({ path }, resources);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a syntax refusal");
expect(result.error.cause).toBeInstanceOf(SyntaxError);
expect(JSON.stringify(result.error)).not.toContain("u:p@h");
@@ -257,7 +256,6 @@ it.each([
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(controller.signal.aborted).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the parser failure");
expect(result.error._tag).toBe(tag);
expect(result.error.partialObservation).toEqual({
@@ -84,7 +84,6 @@ it.each(["path", "operations_path"] as const)(
);
await withScope(async (resources) => {
const result = await inspectApiContractEvidence(input, resources);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected invalid UTF-8");
expect(result.error._tag).toBe("AnalysisInputError");
expect(projectAnalysisError(result.error)).toMatchObject({
@@ -148,7 +147,6 @@ it("attributes auxiliary file admission failures to operations_path and retains
{ ...input, operations_path: join(input.path, "missing") },
resources,
);
expect(missing.ok).toBe(false);
if (missing.ok) throw new Error("Expected missing operation file");
expect(projectAnalysisError(missing.error)).toMatchObject({
details: { issues: [{ path: ["operations_path"] }] },
@@ -17,6 +17,11 @@ import type {
} from "../AnalysisProvider.js";
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import {
providerReleaseVerificationSchema,
type ProviderReleaseVerification,
} from "../../domain/providerReleaseVerification.js";
import { analysisErrorRemediationAction } from "../../domain/analysisErrorPresentation.js";
/** Profile-resolution truth for one discovered provider candidate. */
@@ -26,6 +31,7 @@ export interface AnalysisProviderCandidateEvaluation {
readonly profile?: AnalysisProfileCommitment;
readonly refreshAfterRetirement?: true;
readonly createClient?: AnalysisProviderBoundClientFactory;
readonly releaseVerification?: ProviderReleaseVerification;
readonly profileError?: AnalysisError;
}
@@ -115,10 +121,29 @@ export const evaluateAnalysisProviderCandidate = async (
remediation: PROFILE_REMEDIATION,
}),
);
const verification =
resolved.value.releaseVerification === undefined
? undefined
: providerReleaseVerificationSchema.safeParse(
resolved.value.releaseVerification,
);
if (verification?.success === false)
return ok(
rejectedProfile(candidate, status, {
reason: "Provider returned invalid release verification",
remediation: PROFILE_REMEDIATION,
}),
);
return ok({
candidate,
status,
profile,
...(verification === undefined
? {}
: {
releaseVerification: verification.data,
}),
...(resolved.value.refreshAfterRetirement === undefined
? {}
: { refreshAfterRetirement: resolved.value.refreshAfterRetirement }),
@@ -13,6 +13,7 @@ import {
type AnalysisProviderSelector,
} from "../../domain/providerSelection.js";
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
import type { ProviderReleaseVerification } from "../../domain/providerReleaseVerification.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import {
AnalysisCancelledError,
@@ -80,6 +81,8 @@ export interface AnalysisProviderBinding {
readonly identity: ProviderIdentity & { readonly version: string };
readonly selectionSource: "request" | "environment" | "auto-single-candidate";
readonly profile: AnalysisProfileCommitment;
readonly releaseVerification?: ProviderReleaseVerification;
readonly refreshAfterRetirement?: true;
readonly createClient?: AnalysisProviderBoundClientFactory;
/** The provider's capabilities, attributed to its resolved identity. */
@@ -832,6 +835,12 @@ const selectedResult = (
identity: structuredClone(identity),
selectionSource: source,
profile: structuredClone(evaluated.profile),
...(evaluated.releaseVerification === undefined
? {}
: {
releaseVerification: structuredClone(evaluated.releaseVerification),
}),
...(evaluated.refreshAfterRetirement === undefined
? {}
: { refreshAfterRetirement: evaluated.refreshAfterRetirement }),
@@ -1595,7 +1595,7 @@ it("revises trace workflow profiles so earlier results are not replayed", () =>
expect(
workflowAnalysisProfile(profile, "trace_feature").parameters
.workflow_revision,
).toBe(8);
).toBe(9);
expect(workflowAnalysisProfile(profile, "trace_feature")).not.toEqual(
workflowAnalysisProfile(profile),
@@ -394,7 +394,6 @@ it.each([false, true])(
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
@@ -480,7 +479,6 @@ it("keeps an earlier resource close failure primary when a later cleanup is canc
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
@@ -569,7 +567,6 @@ it("keeps cancellation primary when binary retirement fails before opening resou
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
@@ -661,7 +658,6 @@ it("keeps an earlier resource close failure primary when binary candidate cleanu
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed binary open");
expect(result.selection).toEqual({ kind: "none" });
expect(result.error).toBe(originalFailure);
@@ -765,7 +761,6 @@ it("keeps an earlier binary close failure primary when candidate cleanup is canc
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
@@ -866,7 +861,6 @@ it("cancels a binary switch during resource retirement and retains null-subject
releaseResourceClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled binary open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
@@ -57,6 +57,19 @@ interface PrepareSessionExecutionInput {
) => AnalysisExecution | undefined;
}
/** Recovery for an operation absent from the selected binary route. */
export const undeclaredOperationGuidance = (
operation: string,
entryCommentAvailable = false,
): string => {
const entryComment =
entryCommentAvailable &&
(operation === "set_comment" || operation === "set_inline_comment")
? `If the address is exactly a function entry and an entry comment is intended, use MCP annotate_native_function with procedure set to that entry and ${operation === "set_comment" ? "comment" : "inline_comment"}, or CLI rea annotate-native-function with the target, entry and ${operation === "set_comment" ? "--comment" : "--inline-comment"}. This edits session metadata, not arbitrary-address comments; whether CLI edits persist after the invocation depends on the provider and selected checkpoint mode. `
: "";
return `${entryComment}In MCP, inspect binary_session with detail: "complete" for candidate availability, target support, and capabilities. If a suitable candidate is available, explicitly call open_binary with the same target and its provider_id. In CLI, use rea providers to inspect readiness and capabilities; target support remains unknown until a target is selected. Select an available candidate with --provider and check the result. Switching may discard provider session edits; retained Evidence does not restore them. Candidate availability does not guarantee support for this target.`;
};
/** Resolve capability, profile, and snapshot state before invoking a provider. */
export const prepareSessionExecution = (
input: PrepareSessionExecutionInput,
@@ -74,19 +87,34 @@ export const prepareSessionExecution = (
if (capability?.available !== true) {
const selectionError = unboundOperationError(operation, active.route);
if (selectionError !== undefined) return err(selectionError);
const entryCommentAvailable =
active.route.binding !== null &&
active.route.capabilities.get("annotate_native_function")?.available ===
true &&
active.route.capabilities.get("annotate_native_function")?.provider.id ===
active.route.binding.identity.id;
const undeclaredGuidance =
capability === undefined
? undeclaredOperationGuidance(operation, entryCommentAvailable)
: undefined;
return err(
new AnalysisCapabilityUnavailableError(
active.route.binding?.identity.id ?? active.route.identity.id,
operation,
capability?.reason ?? "operation is not declared by this provider",
{
...(capability?.userMessage === undefined
? {}
: { userMessage: capability.userMessage }),
...(capability?.remediation === undefined
? {}
: { remediationAction: capability.remediation }),
},
undeclaredGuidance === undefined
? {
...(capability?.userMessage === undefined
? {}
: { userMessage: capability.userMessage }),
...(capability?.remediation === undefined
? {}
: { remediationAction: capability.remediation }),
}
: {
userMessage: `The selected provider does not declare ${operation}.`,
fallbackRemediationAction: undeclaredGuidance,
},
),
);
}
@@ -10,6 +10,7 @@ import {
analysisProviderCandidate,
analysisProviderCandidateSummary,
expandProviderCapabilities,
expandToolAvailability,
sessionProviderFacts,
type ProviderCapabilitySet,
} from "../../contracts/toolOutputSchemaPrimitives.js";
@@ -30,6 +31,7 @@ import {
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../native/NativeMacosProviderIdentity.js";
import {
buildCapabilityInventory,
compactToolAvailability,
MULTI_MODE_REMEDIATION,
} from "../CapabilityInventory.js";
@@ -74,7 +76,9 @@ type DescriptorOptions = {
| {
readonly available: false;
readonly reason?: string;
readonly availabilityCode?: "architecture_unsupported";
readonly availabilityCode?:
| "architecture_unsupported"
| "unsupported_host";
}
);
@@ -850,3 +854,124 @@ it("groups readiness repairs exactly and drops one replaced by a runtime observa
}),
]);
});
it("keeps independent decompilation and value flow in distinct capability groups", async () => {
const status = await projectStatus(
[
{
...descriptor(providerA, "procedure_pseudo_code", []),
independentDecompile: true,
},
{
...descriptor(providerA, "analyze_function", []),
nativeValueFlow: { status: "supported" as const },
},
descriptor(providerA, "xrefs", []),
],
[],
);
const projected = z
.object({ capabilities: sessionProviderFacts.shape.capabilities })
.parse(status);
const capabilities = expandProviderCapabilities(projected.capabilities);
expect(
capabilities.find((item) => item.operation === "procedure_pseudo_code"),
).toMatchObject({ independent_decompile: true });
expect(
capabilities.find((item) => item.operation === "analyze_function"),
).toMatchObject({ native_value_flow: { status: "supported" } });
expect(
capabilities.find((item) => item.operation === "xrefs"),
).not.toHaveProperty("independent_decompile");
expect(
capabilities.find((item) => item.operation === "xrefs"),
).not.toHaveProperty("native_value_flow");
});
it.each([
{ detail: "summary", supported: true },
{ detail: "complete", supported: true },
{ detail: "summary", supported: false },
{ detail: "complete", supported: false },
] as const)(
"retains routed value-flow ownership and runtime failures in $detail discovery when supported=$supported",
async ({ detail, supported }) => {
const source = {
...descriptor(providerA, "analyze_function", []),
nativeValueFlow: supported
? { status: "supported" as const }
: { status: "unavailable" as const, reason: "No value flow." },
};
const resolver = descriptor(providerB, "resolve_native_call_targets", []);
const providerC = { id: "provider-c", name: "Provider C", version: "3" };
const unselected = {
...descriptor(providerC, "analyze_function", []),
nativeValueFlow: { status: "unavailable" as const, reason: "Unselected" },
};
const status = await projectStatus(
[source, resolver],
[{ ...candidate(providerC, [unselected]), selected: false }],
{
target: dispatchTarget("elf"),
detail,
runtimeUnavailability: new Map([
["resolve_native_call_targets", { reason: "Resolver failed." }],
]),
},
);
const projected = z
.object({ capabilities: sessionProviderFacts.shape.capabilities })
.parse(status);
const route = expandProviderCapabilities(projected.capabilities);
expect(
route.find((item) => item.operation === "analyze_function"),
).toMatchObject({
provider: providerA,
native_value_flow: { status: supported ? "supported" : "unavailable" },
});
const inventory = buildCapabilityInventory(status, {
processCaptureEnabled: false,
});
const item = expandToolAvailability(
compactToolAvailability(inventory, detail),
).find((candidate) => candidate.name === "trace_native_values");
expect(item).toMatchObject({
available: false,
reason: "provider_unavailable",
remediation: expect.stringContaining("Resolver failed."),
value_flow_support: {
status: supported ? "supported" : "unavailable",
provider_id: providerA.id,
},
});
if (!supported) expect(item?.remediation).toContain("No value flow.");
},
);
it("preserves an unsupported resolver host alongside unavailable value flow", async () => {
const source = {
...descriptor(providerA, "analyze_function", []),
nativeValueFlow: {
status: "unavailable" as const,
reason: "No value flow.",
},
};
const resolver = {
...descriptor(providerB, "resolve_native_call_targets", [], {
available: false,
reason: "Resolver host is unsupported.",
availabilityCode: "unsupported_host",
}),
remediation: "Use a supported resolver host.",
};
const status = await projectStatus([source, resolver], [], {
target: dispatchTarget("elf"),
detail: "summary",
});
const item = buildCapabilityInventory(status, {
processCaptureEnabled: false,
}).find((entry) => entry.name === "trace_native_values");
expect(item).toMatchObject({ available: false, reason: "unsupported_host" });
expect(item?.remediation).toContain("No value flow.");
expect(item?.remediation).toContain("Use a supported resolver host.");
});
@@ -108,6 +108,13 @@ export const binarySessionStatus = async ({
provider: providerSummary(route.binding.identity),
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
...(route.binding.releaseVerification === undefined
? {}
: {
release_verification: structuredClone(
route.binding.releaseVerification,
),
}),
};
const analysisProviderCandidates =
bundleResourceRoot !== undefined
@@ -417,6 +424,7 @@ export const providerCapabilitySets = (
const key = JSON.stringify([
effects,
limitations,
descriptor.nativeValueFlow ?? null,
descriptor.independentDecompile ?? null,
status.available,
status.reason,
@@ -430,6 +438,9 @@ export const providerCapabilitySets = (
operations: [descriptor.operation],
effects,
limitations,
...(descriptor.nativeValueFlow === undefined
? {}
: { native_value_flow: descriptor.nativeValueFlow }),
...(descriptor.independentDecompile === undefined
? {}
: { independent_decompile: descriptor.independentDecompile }),
@@ -97,7 +97,6 @@ it("reports the first mixed child failure before awaiting the other owner and re
await Promise.all([nativeStarted, failureObserved]);
releaseNativeClose();
const first = await closing;
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Expected mixed close failure");
expect(first.error.cleanupIncomplete).toBe(true);
@@ -113,7 +113,6 @@ it("retains every failed child resource across composite cleanup retries", async
for (let attempt = 0; attempt < 2; attempt += 1) {
const closed = await client.close();
expect(closed.ok).toBe(false);
if (closed.ok) throw new Error("Expected composite cleanup failure");
expect(closed.error).toMatchObject({
cleanupIncomplete: true,
@@ -158,7 +157,6 @@ it("preserves one child's typed cleanup error unchanged", async () => {
const closed = await client.close();
expect(closed.ok).toBe(false);
if (closed.ok) throw new Error("Expected child cleanup failure");
expect(closed.error).toBe(child.cleanupError);
});
@@ -306,7 +304,6 @@ it.each([1, 2])(
release();
}
const first = await closing;
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Expected retained child cleanup");
expect(notifications).toEqual([
...children.map((child) => child.cleanupError),
@@ -318,7 +315,6 @@ it.each([1, 2])(
notifications.length = 0;
const retried = await closeAnalysisClient(client, "composite", options);
expect(retried.ok).toBe(false);
if (retried.ok) throw new Error("Expected failed cleanup retry");
expect(notifications).toEqual([
...children.map((child) => child.cleanupError),
@@ -126,7 +126,6 @@ it.each(["288", "309 "])(
identity,
inspect: () => Promise.resolve(ok(malformed)),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unsupported provider crash kind");
expect(result.error._tag).toBe("AnalysisOutputError");
},
@@ -169,7 +169,6 @@ it.each([
},
});
const result = await service.inspect(null, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancelled admission");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -90,7 +90,6 @@ it("retains provider metadata in a late-cancellation observation", async () => {
const result = await service.inspect(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
@@ -254,7 +254,6 @@ it.each([
},
});
const result = await service.inspect(null, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancelled admission");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -171,7 +171,6 @@ it("preserves source Evidence and requested output choices when publication fail
path: "/output/seeds.tsv",
base: "0x2000",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed publication accepted");
expect(result.error).toBe(failure);
expect(result.error.partialObservation).toMatchObject({
@@ -213,7 +212,6 @@ it("retains a published seed receipt if cancellation arrives after the atomic wr
{ path: receipt.path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation ignored");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -449,7 +449,6 @@ describe("residual unknown registry resolution and history", () => {
).toBe(true);
const before = serializeEvidenceBundle(base.export());
const conflict = base.import(branch.export());
expect(conflict.ok).toBe(false);
if (conflict.ok) throw new Error("Expected divergent history refusal");
expectHistoryConflict(conflict.error, created.value.unknown_id);
expect(base.get(source.evidence_id)?.caller_input_path).toBe("./original");
@@ -7,6 +7,7 @@ import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { InvestigationRecords } from "./InvestigationRecords.js";
import {
createInvestigationEvidenceClosureChecker,
collectInvestigationEvidenceClosure,
resolveInvestigationEvidenceClosure,
} from "./InvestigationEvidence.js";
@@ -158,3 +159,25 @@ it("reuses a complete shared source graph without weakening missing-source check
expect(lookups).toBe(3);
expect(check([source.evidence_id, missing])).toBe(false);
});
it("collects other reachable sources after a missing ancestor", () => {
const missing = `ev_${"0".repeat(64)}`;
const first = observation("/reachable");
const incomplete = observation("/incomplete", [missing]);
const records = new InvestigationRecords();
for (const record of [first, incomplete]) {
const added = records.recordEvidence(record);
if (!added.ok) throw added.error;
}
const result = collectInvestigationEvidenceClosure(records, [
first.evidence_id,
incomplete.evidence_id,
]);
expect(result.result).toMatchObject({
ok: false,
error: { reason: "missing" },
});
expect(result.records.map((record) => record.evidence_id).sort()).toEqual(
[first.evidence_id, incomplete.evidence_id].sort(),
);
});
@@ -3,7 +3,7 @@ import type { EvidenceReader } from "./InvestigationRecordPort.js";
import { uniqueSorted } from "../../domain/canonicalOrdering.js";
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { Evidence } from "../../domain/evidence.js";
import { ok, type Result } from "../../domain/result.js";
import { err, ok, type Result } from "../../domain/result.js";
type Lookup = (id: string) => Evidence | undefined;
@@ -12,8 +12,10 @@ const visitClosure = (
lookup: Lookup,
seedIds: readonly string[],
knownComplete?: ReadonlySet<string>,
collect?: (record: Evidence) => void,
): Result<Set<string>, EvidenceIntegrityError> => {
const visited = new Set<string>();
let failure: EvidenceIntegrityError | undefined;
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
@@ -28,12 +30,36 @@ const visitClosure = (
lookup,
"retained-closure",
);
if (!evidence.ok) return evidence;
if (!evidence.ok) {
if (collect === undefined) return evidence;
failure ??= evidence.error;
visited.add(evidenceId);
continue;
}
visited.add(evidenceId);
collect?.(evidence.value);
for (const linkedId of evidence.value.evidence_links)
pending.push(linkedId);
}
return ok(visited);
return failure === undefined ? ok(visited) : err(failure);
};
/** Gather every reachable authenticated record while retaining the first failure. */
export const collectInvestigationEvidenceClosure = (
reader: Pick<EvidenceReader, "evidenceForAnalysis">,
seedIds: readonly string[],
): {
readonly records: Evidence[];
readonly result: Result<null, EvidenceIntegrityError>;
} => {
const records: Evidence[] = [];
const visited = visitClosure(
(id) => reader.evidenceForAnalysis(id),
seedIds,
undefined,
(record) => records.push(record),
);
return { records, result: visited.ok ? ok(null) : visited };
};
/** Follow exact session references without exporting unrelated records or Unknowns. */
@@ -32,7 +32,6 @@ it.each([
withEvidenceSources(cancelled ? err(primary) : ok("complete"), [source]),
sourceOwner === undefined ? undefined : { sourceOwner },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected recording failure");
expect(result.error).toBe(cancelled ? primary : failure);
expect(primary.secondaryFailures).toEqual(cancelled ? [failure] : []);
@@ -75,7 +75,6 @@ it("preserves failed Inspector cleanup when cancellation occurs during observati
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed cleanup");
expect(result.error).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(true);
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
import { graphForJavaScript as graphFor } from "../../../tests/fixtures/javascriptSemanticGraph.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
import { resolveJavaScriptSemanticEvidence } from "../../domain/javascript/javascriptSemanticGraph.js";
import { resolveJavaScriptSemanticEvidence } from "../../../tests/support/javascriptSemanticGraphFixture.js";
const SHA256 = "a".repeat(64);
const GRAPH_ID = `jag_${"b".repeat(64)}`;
@@ -74,7 +74,6 @@ it("retains transitive provenance without treating dependencies as native observ
undefined,
(id) => (id === dependency.evidence_id ? dependency : undefined),
);
expect(missing.ok).toBe(false);
if (missing.ok) throw new Error("Missing ancestor accepted");
expect(missing.error).toMatchObject({
evidenceId: ancestor.evidence_id,
@@ -933,7 +932,6 @@ it("enforces the caller-resolved representation allowance and names its setting"
const lowered = await matchNativeFunctions(small, {
representationAllowance: { maximumBytes: 64 * 1024, source: "runtime" },
});
expect(lowered.ok).toBe(false);
if (lowered.ok) throw new Error("Lowered allowance admitted the request");
expect(lowered.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
@@ -164,7 +164,6 @@ describe("native value trace seed admission", () => {
},
{ procedure: "missing" },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected seed failure");
expect(result.error).toBe(failure);
expect(attempted).toEqual([["analyze_function", { procedure: "missing" }]]);
@@ -122,7 +122,6 @@ it("retains completed pyc Evidence and the opaque caller reason on late cancella
{ path: report.artifact.path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -218,7 +218,6 @@ it.each([
},
});
const result = await service.inspect(null, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancelled admission");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -83,7 +83,6 @@ it("retains provider metadata in a late-cancellation observation", async () => {
const result = await service.decode(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
@@ -290,7 +290,6 @@ it("refuses two admitted inventories that together exceed the comparison cap", a
right: evidence(right),
scope: "both",
});
expect(combined.ok).toBe(false);
if (combined.ok) throw new Error("Expected comparison allowance refusal");
const projected = projectAnalysisError(combined.error);
expect(projected.retryable).toBe(false);
+2 -2
View File
@@ -84,7 +84,7 @@ it("stops a close hook even when retained cleanup is still failing", async () =>
(cause: unknown) => cause,
);
expect(closeFailure).toMatchObject({
message: "Artifact cleanup remains incomplete: stuck lease, close hook",
message: expect.stringContaining("stuck lease, close hook"),
failedOwners: [owner],
cleanup: {
reason: "still owned; hook still stopping",
@@ -305,7 +305,7 @@ it("retains the same failed reader owner in a retry failure and retries before a
await expect(scope.run(async () => "must not run")).rejects.toMatchObject({
reason: "unavailable",
message: "Artifact cleanup remains incomplete: fixture reader",
message: expect.stringContaining("fixture reader"),
failedOwners: [owner],
});
expect(closeAttempts).toBe(2);
@@ -72,7 +72,6 @@ it.skipIf(process.platform === "win32")(
max_entries: 32,
max_probe_bytes: 4096,
});
expect(invalid.ok).toBe(false);
if (invalid.ok) throw new Error("expected ENOTDIR refusal");
expect(projectAnalysisError(invalid.error)).toMatchObject({
code: "target_unavailable",
@@ -83,7 +82,6 @@ it.skipIf(process.platform === "win32")(
max_entries: 32,
max_probe_bytes: 4096,
});
expect(valid.ok).toBe(true);
if (!valid.ok) throw valid.error;
expect(
desktopApplicationSchema
+1 -1
View File
@@ -295,7 +295,7 @@ it("fails /images followed by a malformed structure, and leaves a plain DTB a fi
scanCanonicalArtifactInventory(await writeFit("broken.itb", bytes)),
).rejects.toMatchObject({
reason: "format",
message: "FIT is malformed: Unknown FDT token 10",
message: expect.stringMatching(/FDT.*token 10/iu),
});
const plain = await scanCanonicalArtifactInventory(
await writeFit("board.dtb", devicetreeFixture()),
+1 -1
View File
@@ -445,7 +445,7 @@ describe("GodotPckArtifactReader carrier truncation", () => {
try {
await expect(text(await reader.open(entry))).rejects.toMatchObject({
reason: "integrity",
message: "Godot PCK entry ended early: member.bin",
message: expect.stringContaining("member.bin"),
});
expect(calls).toBe(2);
} finally {
+3 -4
View File
@@ -308,8 +308,7 @@ it.each([false, true])(
if (sea)
await expect(located).rejects.toMatchObject({
reason: "limit",
message:
"Node SEA PE inspection exceeds the 512 MiB PE parser input ceiling",
message: expect.stringMatching(/PE.*512 MiB/iu),
});
else await expect(located).resolves.toBeUndefined();
} finally {
@@ -659,7 +658,7 @@ it("rejects missing Mach-O load commands after a confirmed SEA section", async (
scanCanonicalArtifactInventory(await writeCarrier("commands", bytes)),
).rejects.toMatchObject({
reason: "format",
message: "Node SEA Mach-O load commands are truncated",
message: expect.stringMatching(/Mach-O.*load commands.*truncated/iu),
});
});
@@ -708,7 +707,7 @@ it("reports a member that ends after the carrier recheck as an integrity failure
try {
await expect(buffer(await reader.open(entry))).rejects.toMatchObject({
reason: "integrity",
message: "Node SEA entry ended early: sea.js",
message: expect.stringContaining("sea.js"),
});
expect(calls).toBe(2);
} finally {
@@ -558,8 +558,7 @@ it.each(["reported carutil", "tab-indented carutil", "observed assetutil"])(
expect(error.cause).toBe(cause);
expect(projectAnalysisError(error)).toMatchObject({
code: "artifact_operation_failed",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
message: expect.stringMatching(/invalid.*changed.*fresh copy/iu),
details: {
operation: "inspect_asset_catalog",
reason: "format",
@@ -8,10 +8,8 @@ import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
import { interfaceBuilderDecodeBudgetFailure } from "./InterfaceBuilderDecodeBudget.js";
import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js";
import { unknownDateLimitation } from "../../domain/apple/plistValue.js";
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { scanBinaryPlistScalars } from "./PlistScalars.js";
import {
readBinaryPlistObjectOffset,
readBinaryPlistObjectTableLayout,
@@ -181,8 +179,13 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a
"com.apple.ibtool.document.classes": {},
}),
);
const date = scanBinaryPlistScalars(bytes).dates[0];
if (date === undefined) throw new Error("fixture lost its date");
// The fixture date is 978307200 seconds before the 2001 plist epoch.
const encodedDate = Buffer.alloc(9);
encodedDate[0] = 0x33;
encodedDate.writeDoubleBE(-978307200, 1);
const dateOffset = bytes.indexOf(encodedDate);
expect(dateOffset).toBeGreaterThanOrEqual(0);
expect(bytes.indexOf(encodedDate, dateOffset + 1)).toBe(-1);
const table = readBinaryPlistObjectTableLayout(bytes);
let dataObjectId: number | undefined;
for (let objectId = 0; objectId < table.objectCount; objectId += 1) {
@@ -192,11 +195,11 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a
if (dataObjectId === undefined)
throw new Error("fixture lost its empty data");
// Keep both objects reachable through a recognized archive while sharing source bytes.
bytes.writeDoubleBE(0.0005, date.offset + 1);
bytes[date.offset + 2] = 0x40;
writeBinaryPlistObjectOffset(bytes, table, dataObjectId, date.offset + 2);
bytes.writeDoubleBE(0.0005, dateOffset + 1);
bytes[dateOffset + 2] = 0x40;
writeBinaryPlistObjectOffset(bytes, table, dataObjectId, dateOffset + 2);
expect(readBinaryPlistObjectOffset(bytes, table, dataObjectId)).toBe(
date.offset + 2,
dateOffset + 2,
);
expect(
preflightPropertyListDecode(bytes, 1024 * 1024, undefined, {
@@ -204,7 +207,7 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a
}),
).toMatchObject({
datePrecisionOverlapCount: 1,
reachableDateOffsets: [date.offset],
reachableDateOffsets: [dateOffset],
});
await writeFile(join(bundle, "Panel.nib"), bytes);
@@ -306,8 +309,10 @@ describe("binary Interface Builder plist dates", () => {
const offset = submillisecond.indexOf(marker);
submillisecond.writeDoubleBE(0.1005, offset + 1);
const unknown = await nib(submillisecond, "subms");
expect(unknown.limitations).toContain(
`Panel.nib: ${unknownDateLimitation(1)}`,
expect(unknown.limitations).toContainEqual(
expect.stringMatching(
/Panel\.nib.*\b1 date value.*decoder could not represent.*timestamps/iu,
),
);
expect(JSON.stringify(unknown)).not.toContain("2001-01-01T00:00:00.100Z");
@@ -95,7 +95,6 @@ it.skipIf(isRoot)(
format: "plist",
})
.execute("inspect_keyed_archive", {});
expect(executed.ok).toBe(false);
if (executed.ok) throw new Error("expected a denied read");
expect(executed.error).toBeInstanceOf(AnalysisAccessDeniedError);
expect(projectAnalysisError(executed.error)).toMatchObject({
+40 -20
View File
@@ -10,13 +10,9 @@ import {
decodeKeyedArchiveBytes,
inspectBundleKeyedArchive,
} from "./KeyedArchiveReader.js";
import {
PlistNestingLimitError,
unknownDateLimitation,
} from "../../domain/apple/plistValue.js";
import { PlistNestingLimitError } from "../../domain/apple/plistValue.js";
import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { scanBinaryPlistScalars } from "./PlistScalars.js";
import { embedSubmillisecondDateInWideIntegerPayload } from "../../../tests/fixtures/binaryPlistScalarAlias.js";
import {
readBinaryPlistObjectOffset,
@@ -53,6 +49,17 @@ const binaryArchiveWithDate = (): Buffer =>
}),
);
// Locate the known binary date independently of the scanner under test.
const fixtureDateOffset = (bytes: Buffer, seconds: number): number => {
const encoded = Buffer.alloc(9);
encoded[0] = 0x33;
encoded.writeDoubleBE(seconds, 1);
const offset = bytes.indexOf(encoded);
if (offset < 0 || bytes.indexOf(encoded, offset + 1) !== -1)
throw new Error("binary archive fixture must contain exactly one date");
return offset;
};
const binaryArchiveWithOverlappingDate = (): Buffer => {
const bytes = Buffer.from(
buildBinary({
@@ -65,19 +72,17 @@ const binaryArchiveWithOverlappingDate = (): Buffer => {
$top: { root: { UID: 1 } },
}),
);
const date = scanBinaryPlistScalars(bytes).dates[0];
if (date === undefined)
throw new Error("binary archive fixture lost its date");
bytes.writeDoubleBE(0.0005, date.payloadOffset);
const dateOffset = fixtureDateOffset(bytes, 0);
bytes.writeDoubleBE(0.0005, dateOffset + 1);
const objectTable = readBinaryPlistObjectTableLayout(bytes);
for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) {
const offset = readBinaryPlistObjectOffset(bytes, objectTable, objectId);
if (offset !== date.offset && bytes[offset] === 0x40) {
if (offset !== dateOffset && bytes[offset] === 0x40) {
writeBinaryPlistObjectOffset(
bytes,
objectTable,
objectId,
date.payloadOffset + 1,
dateOffset + 2,
);
return bytes;
}
@@ -87,14 +92,12 @@ const binaryArchiveWithOverlappingDate = (): Buffer => {
const binaryArchiveDateCrossingOffsetTable = (): Buffer => {
const bytes = Buffer.from(binaryArchiveWithDate());
const date = scanBinaryPlistScalars(bytes).dates[0];
if (date === undefined)
throw new Error("binary archive fixture lost its date");
const dateOffset = fixtureDateOffset(bytes, 0.1);
const objectTable = readBinaryPlistObjectTableLayout(bytes);
let dateObjectId = -1;
for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) {
if (
readBinaryPlistObjectOffset(bytes, objectTable, objectId) === date.offset
readBinaryPlistObjectOffset(bytes, objectTable, objectId) === dateOffset
)
dateObjectId = objectId;
}
@@ -585,6 +588,9 @@ const dateScalar = (iso8601: string | null) => ({
value: { $plist_type: "date", iso8601 },
});
const oneUnknownDate = /\b1 date value.*could not represent.*timestamps/iu;
const twoUnknownDates = /\b2 date value.*could not represent.*timestamps/iu;
describe("binary keyed archive dates", () => {
const selection = { offset: 0, limit: 10 };
@@ -594,7 +600,11 @@ describe("binary keyed archive dates", () => {
expect(graph.objects[1]).toMatchObject(
dateScalar("2001-01-01T00:00:00.100Z"),
);
expect(graph.limitations).not.toContain(unknownDateLimitation(1));
expect(graph.limitations).not.toContainEqual(
expect.stringMatching(
/date value.*decoder could not represent.*timestamps/iu,
),
);
expect(Buffer.compare(bytes, pythonBinaryDateControl)).toBe(0);
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
"39f08db8341bd74220352dfd559485515cce1e6035e08f27a59ab8f856374a30",
@@ -609,7 +619,11 @@ describe("binary keyed archive dates", () => {
expect(inspected.objects[1]).toMatchObject(
dateScalar("2001-01-01T00:00:00.100Z"),
);
expect(inspected.limitations).not.toContain(unknownDateLimitation(1));
expect(inspected.limitations).not.toContainEqual(
expect.stringMatching(
/date value.*decoder could not represent.*timestamps/iu,
),
);
expect(
Buffer.compare(await readFile(join(root, "archive.plist")), bytes),
).toBe(0);
@@ -664,7 +678,9 @@ describe("binary keyed archive dates", () => {
expect(JSON.stringify(graph.objects[1]?.value)).not.toContain(
"2001-01-01T00:00:00.100Z",
);
expect(graph.limitations).toContain(unknownDateLimitation(1));
expect(graph.limitations).toContainEqual(
expect.stringMatching(oneUnknownDate),
);
expect(graph.limitations).not.toContainEqual(
expect.stringMatching(/number metadata.*could not.*observed/iu),
);
@@ -680,7 +696,9 @@ describe("binary keyed archive dates", () => {
parameters: { path: "archive.plist" },
});
expect(inspected.objects[1]).toMatchObject(dateScalar(null));
expect(inspected.limitations).toContain(unknownDateLimitation(1));
expect(inspected.limitations).toContainEqual(
expect.stringMatching(oneUnknownDate),
);
expect(
Buffer.compare(
await readFile(join(root, "archive.plist")),
@@ -720,7 +738,9 @@ describe("binary keyed archive dates", () => {
{ $plist_type: "date", iso8601: null },
{ $plist_type: "date", iso8601: null },
]);
expect(mixedGraph.limitations).toContain(unknownDateLimitation(2));
expect(mixedGraph.limitations).toContainEqual(
expect.stringMatching(twoUnknownDates),
);
expect(Buffer.compare(mixed, mixedOriginal)).toBe(0);
});
});
@@ -352,7 +352,9 @@ describe("Mach-O encryption reader: segment mapping", () => {
range: {
mapping: {
status: "unknown",
reason: "load command 0 LC_SEGMENT_64 appears in a 32-bit image",
reason: expect.stringMatching(
/load command 0\b.*LC_SEGMENT_64.*appears.*32-bit/iu,
),
},
},
});
@@ -486,7 +488,9 @@ describe("Mach-O encryption reader: defects are never absent", () => {
wide: false,
commands: [encryption({ cryptoff: 0, cryptsize: 0, cryptid: 0 })],
}),
reason: "load command 0 LC_ENCRYPTION_INFO_64 appears in a 32-bit image",
reason: expect.stringMatching(
/load command 0\b.*LC_ENCRYPTION_INFO_64.*appears.*32-bit/iu,
),
},
{
name: "an unexpected cmdsize",
@@ -496,8 +500,9 @@ describe("Mach-O encryption reader: defects are never absent", () => {
encryption({ cryptoff: 0, cryptsize: 0, cryptid: 0 }, true, 32),
],
}),
reason:
"load command 0 LC_ENCRYPTION_INFO_64 has cmdsize 32, expected 24",
reason: expect.stringMatching(
/load command 0\b.*LC_ENCRYPTION_INFO_64.*cmdsize 32\b.*expected 24\b/iu,
),
},
{
name: "a range beyond the slice",
@@ -669,12 +674,15 @@ describe("Mach-O encryption reader: universal files", () => {
},
{
status: "malformed",
reason:
"FAT architecture 1 CPU type/subtype disagrees with its Mach-O header",
reason: expect.stringMatching(
/FAT architecture 1\b.*CPU type\/subtype.*disagree.*Mach-O header/iu,
),
},
{
status: "malformed",
reason: "FAT architecture 2 slice range extends beyond the file",
reason: expect.stringMatching(
/FAT architecture 2\b.*slice range.*beyond.*file/iu,
),
},
{ status: "encrypted", reason: null },
]);
@@ -745,7 +753,9 @@ describe("Mach-O encryption reader: universal files", () => {
{
cpu_type: CPU.arm64.type,
status: "malformed",
reason: "FAT architecture 1 slice range extends beyond the file",
reason: expect.stringMatching(
/FAT architecture 1\b.*slice range.*beyond.*file/iu,
),
commands: [],
},
],
@@ -794,7 +804,9 @@ describe("Mach-O encryption reader: large universal files", () => {
{
container_file_offset: size,
status: "malformed",
reason: "FAT architecture 1 slice range extends beyond the file",
reason: expect.stringMatching(
/FAT architecture 1\b.*slice range.*beyond.*file/iu,
),
},
],
});
@@ -802,7 +814,9 @@ describe("Mach-O encryption reader: large universal files", () => {
// unresolved JVM identity.
expect(await readMachoImage(sparseReadAt, size)).toEqual({
status: "malformed",
reason: "FAT architecture 1 slice range extends beyond the file",
reason: expect.stringMatching(
/FAT architecture 1\b.*slice range.*beyond.*file/iu,
),
});
});
});
@@ -909,8 +923,9 @@ describe("Mach-O encryption reader: mapping budget", () => {
]);
expect(facts.slices[1]?.range?.mapping).toEqual({
status: "unknown",
reason:
"mapping needs more than the 5 segment and section records shared by all slices",
reason: expect.stringMatching(
/mapping.*more than.*5 segment and section records.*slices/iu,
),
});
});
});
@@ -1889,7 +1889,9 @@ describe("Mach-O load command reader failures", () => {
view.setUint32(4, 8, true);
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
status: "malformed",
reason: "load command 0 is too short to hold a string offset",
reason: expect.stringMatching(
/load command 0\b.*too short.*string offset/iu,
),
});
view.setUint32(0, LC.CODE_SIGNATURE, true);
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
@@ -1943,7 +1945,9 @@ describe("Mach-O load command reader failures", () => {
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
expect(await read(fat.subarray(0, 4100))).toMatchObject({
status: "malformed",
reason: "FAT architecture 0 slice range extends beyond the file",
reason: expect.stringMatching(
/FAT architecture 0\b.*slice range.*beyond.*file/iu,
),
});
});
@@ -1968,7 +1972,9 @@ describe("Mach-O load command reader defect precedence", () => {
new DataView(unsized.buffer).setUint32(0, LC.RPATH, true);
const decodeFirst = {
status: "malformed",
reason: "load command 0 is too short to hold a string offset",
reason: expect.stringMatching(
/load command 0\b.*too short.*string offset/iu,
),
};
// An earlier command's decode defect wins over a later framing defect,
expect(await read(machoImage({ commands: [shortRpath, unsized] }))).toEqual(
@@ -2019,7 +2025,9 @@ describe("Mach-O load command reader defect precedence", () => {
new DataView(ranged.buffer).setUint32(8 + 20 + 12, ranged.length, false);
expect(await read(ranged)).toEqual({
status: "unsupported",
reason: "FAT architecture 0 alignment exponent 53 is unsupported",
reason: expect.stringMatching(
/FAT architecture 0\b.*alignment exponent 53\b.*unsupported/iu,
),
});
});
});
+6 -2
View File
@@ -540,8 +540,12 @@ describe("binary plist numeric association", () => {
9007199254740992, 9007199254740992,
]);
expect(numbers.limitations()).toEqual([
'1 observed integer literal(s) in the original archive exceed the exact range of a JSON number. Decoded numbers may lose integer precision unless represented as { "$plist_type": "integer", "decimal": "<exact digits>" }; the original archive bytes retain those literals.',
"2 number(s) beyond the exact integer range remain as decoded because their source integer/real type or reference marker cannot be associated unambiguously; integer precision loss remains possible.",
expect.stringMatching(
/\b1 observed integer literal.*original archive.*JSON number.*lose integer precision.*\$plist_type.*integer.*decimal.*original archive bytes retain/iu,
),
expect.stringMatching(
/\b2 number.*integer range.*source integer\/real type.*cannot be associated unambiguously.*precision loss/iu,
),
]);
});
@@ -14,7 +14,6 @@ it("refuses an XML plist whose DOM exceeds the native decode budget", () => {
const parsed = parsePlistXmlSource(
xmlPlist(`<array>\n${"<true/>\n".repeat(100_000)}</array>\n`),
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected a decode budget refusal");
expect(parsed.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(parsed.error.message).toContain("native plist decode budget");
@@ -28,7 +27,6 @@ it.each(["é", "_", ":", "雪", "\u{1d4b3}"])(
const parsed = parsePlistXmlSource(
xmlPlist(`<array>${`<${name}/>`.repeat(100_000)}</array>`),
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected a decode budget refusal");
expect(parsed.error).toBeInstanceOf(AnalysisResourceConstraintError);
},
@@ -38,7 +36,6 @@ it("refuses text whose entity references exceed the decode budget", () => {
const parsed = parsePlistXmlSource(
xmlPlist(`<array><string>${"&amp;".repeat(1_000_000)}</string></array>`),
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected a decode budget refusal");
expect(parsed.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
+2 -2
View File
@@ -3,13 +3,13 @@ import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
locateBunStandaloneGraph,
probeBunStandaloneGraph,
bunModuleCountLimit,
BUN_GRAPH_SUFFIX_BYTES,
} from "./BunModuleGraphLayout.js";
import { decodeBunModuleGraph } from "./BunModuleGraphMembers.js";
import {
locateBunGraphFixture,
bunModuleGraphPayload,
bunPrefixedPayload,
machoBunSection,
@@ -25,7 +25,7 @@ async function locate(bytes: Buffer) {
await writeFile(path, bytes);
const handle = await open(path, "r");
try {
const result = await locateBunStandaloneGraph(handle, bytes.length);
const result = await locateBunGraphFixture(handle, bytes.length);
if (result.status === "present" && result.graph !== undefined) {
const graph = result.graph;
return {
-11
View File
@@ -124,17 +124,6 @@ interface SectionWindow {
readonly truncated: boolean;
}
/** Probe, then read the one claimed payload. */
export const locateBunStandaloneGraph = async (
handle: FileHandle,
fileSize: number,
signal?: AbortSignal,
): Promise<BunStandaloneRecognition> => {
const probed = await probeBunStandaloneGraph(handle, fileSize, signal);
if (probed.status === "absent" || probed.claim === undefined) return probed;
return loadBunStandaloneGraph(handle, probed.claim, signal);
};
/**
* Locate at most one graph claim with bounded reads of headers, length
* prefixes, and suffixes. Payload data is not read. Multiple real claims are
@@ -1,12 +1,26 @@
import type { FileHandle } from "node:fs/promises";
import {
BUN_OFFSETS_BYTES,
BUN_RECORD_BYTES_1_3_3,
BUN_RECORD_BYTES_1_3_9,
BUN_TRAILER,
loadBunStandaloneGraph,
probeBunStandaloneGraph,
type BunLayoutRevision,
type BunStandaloneRecognition,
type BunStringPointer,
} from "./BunModuleGraphLayout.js";
/** Exercise the same two-stage probe and payload read as the artifact reader. */
export const locateBunGraphFixture = async (
handle: FileHandle,
fileSize: number,
): Promise<BunStandaloneRecognition> => {
const probed = await probeBunStandaloneGraph(handle, fileSize);
if (probed.status === "absent" || probed.claim === undefined) return probed;
return loadBunStandaloneGraph(handle, probed.claim);
};
export interface BunFixtureModule {
readonly name?: string | Buffer;
readonly contents?: Buffer;
@@ -194,7 +194,6 @@ it.each(["inventory_artifact", "inspect_artifact"] as const)(
{},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected provider cancellation");
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(result.error.cause).toBe(reason);
@@ -83,7 +83,7 @@ it.each([
ok: false,
error: {
reason: "format",
message: "PYZ marshal string is not valid UTF-8",
message: expect.stringContaining("UTF-8"),
},
});
});
@@ -127,7 +127,7 @@ it.each(["6c0200000003000000", "6cfeffffff03000000"])(
ok: false,
error: {
reason: "format",
message: "PYZ marshal long is not normalized",
message: expect.stringMatching(/long.*not normalized/iu),
},
});
},
@@ -922,7 +922,7 @@ it("retains the descriptor owner when admission cleanup fails", async () => {
reader.entries()[Symbol.asyncIterator]().next(),
).rejects.toMatchObject({
reason: "format",
message: `PyInstaller source is not a regular file: ${directory}`,
message: expect.stringContaining(directory),
cleanup: {
reason: closeFailure.message,
resources: [directory],
@@ -311,7 +311,7 @@ it.each(["ENOENT", "ENOTDIR"] as const)(
);
expect(cause).toMatchObject({
reason: "integrity",
message: `PyInstaller source identity changed: ${path}`,
message: expect.stringContaining(path),
});
expect(
projectAnalysisError(
@@ -321,7 +321,7 @@ it.each(["ENOENT", "ENOTDIR"] as const)(
category: "integrity_mismatch",
details: {
reason: "integrity",
detail: `PyInstaller source identity changed: ${path}`,
detail: expect.stringContaining(path),
},
});
}
@@ -700,7 +700,6 @@ for (const cancel of [false, true]) {
release.resolve();
const result = await reading;
if (failClose || cancel) {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected read failure");
expect(result.cause).toMatchObject(
failClose
@@ -709,7 +708,6 @@ for (const cancel of [false, true]) {
);
if (failClose) expect(owner?.handle.fd).toBeGreaterThanOrEqual(0);
} else {
expect(result.ok).toBe(true);
if (!result.ok) throw result.cause;
expect(
Buffer.compare(result.value.subarray(16), Buffer.from("body")),
@@ -20,7 +20,7 @@ it("classifies a source replacement during admission as an integrity failure", a
await expect(reader.prepareContainer("0".repeat(64))).rejects.toMatchObject(
{
reason: "integrity",
message: `PyInstaller source changed during admission: ${path}`,
message: expect.stringContaining(path),
},
);
expect(openRegularFile).toHaveBeenCalledWith(path, {
+8 -2
View File
@@ -176,7 +176,10 @@ it("reports a truncated table before deferred signature rejection", async () =>
header.writeUInt32BE(12, 8);
expect(await readImage(header, 16)).toMatchObject({
ok: false,
error: { reason: "format", message: "PYZ table is truncated" },
error: {
reason: "format",
message: expect.stringMatching(/table.*truncated/iu),
},
});
});
@@ -185,7 +188,10 @@ it("reports the signature after completing a readable table and image hash", asy
image.write("BAD\0");
expect(await readImage(image)).toMatchObject({
ok: false,
error: { reason: "format", message: "PYZ archive magic does not match" },
error: {
reason: "format",
message: expect.stringMatching(/magic.*match/iu),
},
});
});
@@ -26,7 +26,6 @@ it("shares a capture limit when browser providers use one admission", async () =
const pending = first.analyzeBundle(input);
const blocked = await second.analyzeBundle(input);
try {
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("expected the shared admission to refuse");
expect(projectAnalysisError(blocked.error)).toMatchObject({
code: "resource_constraint",
@@ -52,7 +51,6 @@ it("does not share a capture limit across browser providers", async () => {
const other = second.analyzeBundle(input);
try {
const blocked = await other;
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("expected capture to fail closed");
expect(projectAnalysisError(blocked.error).code).not.toBe(
"resource_constraint",
-1
View File
@@ -203,7 +203,6 @@ it.each(
target_id: "duplicate",
observation_ms: 0,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected ambiguous target rejection");
expect(result.error).toBeInstanceOf(BrowserObservationError);
expect(result.error.userMessage).toContain("ambiguous");
@@ -140,7 +140,6 @@ it("retains Inspector scripts and contexts when final ASAR authorization cannot
};
try {
const control = await observeJavaScriptRuntime(provider, input);
expect(control.ok).toBe(true);
if (!control.ok) throw control.error;
expect(control.value.normalized_result).toMatchObject({
scripts: { observed_total: 1 },
@@ -149,7 +148,6 @@ it("retains Inspector scripts and contexts when final ASAR authorization cannot
await prependAsarJsonWhitespace(archive, " ");
handleState.failCloseCount = 1;
const result = await observeJavaScriptRuntime(provider, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected descriptor close failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.partialObservation).toMatchObject({
@@ -63,7 +63,6 @@ it("preserves completed prefix and suffix facts when one script times out", asyn
expect(calls).toEqual(
captured.scripts.items.map(({ script_key }) => script_key),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(
result.value.observations.chunks.edges.map(({ specifier }) => specifier),
@@ -114,7 +113,6 @@ it("marks the unstarted suffix when the aggregate deadline expires", async () =>
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected total deadline failure");
expect(result.error._tag).toBe("AnalysisTimeoutError");
expect(result.error.partialObservation).toMatchObject({
@@ -176,7 +174,6 @@ it("stops after a middle-script result-capacity failure and retains prefix facts
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity failure");
expect(result.error).toBe(primary);
expect(result.error.partialObservation).toMatchObject({
@@ -234,7 +231,6 @@ it("carries the exact caller cancellation reason and completed prefix facts", as
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(exactReason);
@@ -276,7 +272,6 @@ it("retains all completed facts when cancellation arrives during worker close",
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation during finalization");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.partialObservation).toMatchObject({
@@ -316,7 +311,6 @@ it("retains cleanup ownership after mixed worker/close failure and resolves it o
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected mixed failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary).toBe(primary);
@@ -347,7 +341,6 @@ it("shares one capture reservation when analyzers use one admission", async () =
const second = new IsolatedWebBundleAnalyzer(() => inertWorker(), admission);
expect((await first.reserve()).ok).toBe(true);
const blocked = await second.reserve();
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("Expected shared admission refusal");
expect(projectAnalysisError(blocked.error)).toMatchObject({
code: "resource_constraint",
@@ -318,7 +318,6 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unverified cleanup to fail");
expect(terminateTree).not.toHaveBeenCalled();
expect(result.error).toMatchObject({
@@ -379,7 +378,6 @@ it("retains the initial snapshot when the final state read fails", async () => {
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected final state capture to fail");
const partialObservation = result.error.partialObservation;
expect(partialObservation).toMatchObject({
@@ -412,7 +410,6 @@ it("rejects active capture before launch when EnableNodeCliInspectArguments fuse
application_root: root,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected capture to fail");
expect(launchSpy).not.toHaveBeenCalled();
expect(result.error).toMatchObject({
@@ -509,7 +506,6 @@ it("cancels a windowless wait action and cleans up the owned application", async
expect(application.firstWindow).toHaveBeenCalledOnce();
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -621,7 +617,6 @@ it("cancels a pending initial state read and cleans up the owned application", a
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -666,7 +661,6 @@ it("observes an initial state read started after cancellation and rejected by cl
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(unhandled).toEqual([]);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -703,7 +697,6 @@ it("keeps a failure observed with cancellation as the cancellation cause", async
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -742,7 +735,6 @@ it("reports an expired final state grace period as the caller's cancellation", a
await vi.advanceTimersByTimeAsync(5_000);
const result = await capture;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -795,7 +787,6 @@ it("cancels a completed capture when cancellation arrives during application clo
const result = await captureWithController(controller, application, []);
expect(application.close).toHaveBeenCalledOnce();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -838,7 +829,6 @@ it("cancels a completed capture when cancellation arrives during process cleanup
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -873,7 +863,6 @@ it("keeps cleanup failure precedence over cancellation observed during cleanup",
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup to fail");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
@@ -925,7 +914,6 @@ it.each(["process", "transport"] as const)(
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected incomplete cleanup");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
@@ -1019,7 +1019,6 @@ it("preserves literal replacement characters in valid UTF-8 worker facts", async
makeScript("fetch('/literal-�');"),
10_000,
);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error.error.message);
if (result.value.status !== "analyzed")
throw new Error("Expected analyzed worker facts");
@@ -48,7 +48,6 @@ it("refuses before spawning or reading source when no host result capacity remai
try {
const result = await worker.analyze(script, 10_000);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity refusal");
const refusal = result.error.error;
expect(refusal).toBeInstanceOf(AnalysisResourceConstraintError);
@@ -144,7 +144,6 @@ it("retains completed facts and resolves a result-file lease through another cle
makeScript("fetch('/retained');"),
10_000,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected retained result cleanup failure");
expect(result.error.completed).toMatchObject({
status: "analyzed",
+1 -1
View File
@@ -8,7 +8,7 @@ import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { inspectWebNetworkCaptureInputSchema } from "../../domain/webNetworkCapture.js";
import { historicalCaptureFailure } from "./CaptureFailures.js";
import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "./CaptureRelease.js";
import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../../tests/fixtures/captureDecoderLimits.js";
import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js";
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
-6
View File
@@ -24,10 +24,4 @@ export const captureDecoderProcessMemoryLimitBytes = (
export const NODE_CAPTURE_HEAP_ENFORCED =
javaScriptRuntime(process.versions).id === "node";
/** Commit limit for the default heap. A configured heap uses `captureDecoderProcessMemoryLimitBytes`. */
export const NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES =
captureDecoderProcessMemoryLimitBytes(
NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB,
);
/** Fixed offline interpretation profile, not an authenticated producer claim. */
@@ -217,7 +217,6 @@ it("refuses other declared trace versions with verified archive identity", async
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected version refusal");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(
@@ -239,7 +238,6 @@ it("preserves opaque pre-cancellation without reading or claiming a source snaps
"/unused.zip",
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -307,7 +305,6 @@ it("refuses oversized physical lines before parsing their representation", async
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected line capacity refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
expect(resources.hasPending).toBe(false);
@@ -329,7 +326,6 @@ it("never promotes facts from a stream whose ZIP CRC fails", async () => {
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected CRC refusal");
expect(result.error._tag).toBe("AnalysisInputError");
const partial = playwrightTraceSchema.parse(
@@ -354,7 +350,6 @@ it("rejects a sparse over-budget archive before hashing or expansion", async ()
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected archive admission refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
expect(result.error.partialObservation).toBeUndefined();
@@ -378,7 +373,6 @@ it("bounds JSON node expansion even when many tokens fit inside a small physical
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected node admission refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
expect(resources.hasPending).toBe(false);
@@ -26,7 +26,6 @@ it("preserves only fully verified streams when cancellation arrives before the n
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path }, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -61,7 +60,6 @@ it("retains complete Evidence and the exact failed reader owner when abort arriv
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path }, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected composed failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary?._tag).toBe("AnalysisCancelledError");
@@ -101,7 +99,6 @@ it("keeps completed Evidence on cancellation during the outer descriptor close",
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path }, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -136,7 +133,6 @@ it("keeps an existing unsupported-version failure ahead of cancellation during c
fixture.path,
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected version refusal");
expect(controller.signal.aborted).toBe(true);
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
@@ -166,7 +162,6 @@ it("retains independent reader and file owners until both cleanup retries comple
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(resources.cleanupResources).toHaveLength(2);
@@ -216,7 +211,6 @@ it.skipIf(process.platform === "win32")(
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected source identity refusal");
expect(result.error._tag).toBe("AnalysisArtifactChangedError");
expect((await readFile(held)).equals(original)).toBe(true);
@@ -248,7 +242,6 @@ it("bounds active imports without an unbounded queue and releases admission afte
try {
await reached.promise;
const second = await port.inspect(fixture.path);
expect(second.ok).toBe(false);
if (second.ok) throw new Error("Expected capacity refusal");
expect(second.error._tag).toBe("AnalysisResourceConstraintError");
release.resolve();
-12
View File
@@ -227,18 +227,6 @@ export const delimitedOutputInputError = (
{ path, reason: "invalid_value", message },
]);
/** Render a validated row page, preceded by a versioned metadata record. */
export const formatDelimitedRows = (
rows: unknown,
format: "csv" | "tsv",
options: CliDelimitedOutputOptions,
): Result<string, AnalysisInputError> => {
const prepared = prepareDelimitedOutput(rows, format, options);
return prepared.ok
? ok([...formatDelimitedOutputParts(prepared.value)].join(""))
: prepared;
};
/** Stream a page after its rows and cells have been validated and snapshotted. */
export function* formatDelimitedOutputParts({
encodedHeaders,
+7 -3
View File
@@ -198,7 +198,7 @@ const registerDecompileCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.decompile, {
description: `Read one part of an app as code. ${decompileSelectorGuidance} Equal positional and --procedure values are accepted; conflicts fail.`,
description: `Read one part of an app as code. ${decompileSelectorGuidance} Equal positional and --procedure values are accepted; conflicts fail. With Ghidra, a space-qualified address such as ram:0x... avoids file-offset fallback; inspect-native-load-image reports the observed default space.`,
args: z.object({
path: localPathStringSchema.describe("App or program path"),
address: z
@@ -295,7 +295,7 @@ const registerXrefsCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.xrefs, {
description: `List bounded references to an analyzed address. ${namedAddressGuidance} ${equalSelectorGuidance}`,
description: `List bounded, untyped references to an analyzed address. For typed incoming references, use inspect-native-instruction at the target address where supported. ${namedAddressGuidance} ${equalSelectorGuidance}`,
args: z.object({
path: localPathStringSchema.describe("App or program path"),
address: z
@@ -452,7 +452,11 @@ const registerFunctionCommand = (
"analyze_function",
),
},
{ ...directAnalysisOptionsFromCli(logger, options), signal },
{
...directAnalysisOptionsFromCli(logger, options),
signal,
includeEvidenceSources: options.evidence_output !== undefined,
},
);
return publishCliAnalysisEvidence(complete, delivery);
}),
+1 -1
View File
@@ -312,7 +312,7 @@ export const registerCoreNativeCommands = (
cli.command(command, {
description:
operation === "inspect_native_instruction"
? "Inspect one native instruction with decoded operand facts"
? "Inspect a code or data address, its containing Ghidra listing object, and typed incoming references where supported"
: "Resolve one static native call site",
args: z.object({
path: localPathStringSchema.describe(
-2
View File
@@ -38,7 +38,6 @@ it.each([
REA_GHIDRA_CHECKPOINT_DIR: "/tmp/checkpoints",
[setting]: value,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("conflicting inputs accepted");
expect(result.error.message).toContain("checkpoints");
expect(result.error).toMatchObject({
@@ -70,7 +69,6 @@ it.each([
parseEnvironmentValues(environment),
parseConfig(environment),
]) {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected invalid environment");
expect(result.error.settings).toHaveLength(1);
expect(result.error.settings[0]?.constraint).toContain("NUL");
Loaded 100 of 343 files, more files were not shown because too many files have changed in this diff. Show more