mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 13:56:47 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
015a02d57b | ||
|
|
688dca3e90 | ||
|
|
197a296664 | ||
|
|
aa2c1c61ce | ||
|
|
2d25a09c0f | ||
|
|
9109e6f6a7 | ||
|
|
cc58263ee0 | ||
|
|
56fb5219ce | ||
|
|
f3c21665d8 | ||
|
|
ce45f92219 | ||
|
|
f912a68417 | ||
|
|
6ca4e1368c | ||
|
|
1071ad867c | ||
|
|
c06b53644b | ||
|
|
352a846127 | ||
|
|
5679ed66f4 |
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>${"&".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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in new issue
Block a user