fix(native): retain partial format facts and explicit coverage
Preserve XML and Interface Builder identity at their parsing boundary, retain usable Objective-C slots with precise coverage, and keep native trace failures distinct from absence. Add caller-selected Interface Builder summaries backed by complete retained Evidence, and preserve comparison limitations. Reviewed fixes: #1969, #2088, #2108, #2513, #2639, #2684, #2731, #2759, #2875, #2888, #2889, #2970, #2971, #3002. Validation: relevant native/domain focused lane passed 142 tests; earlier Interface Builder/MCP checks passed 24 tests. Real native UI and Ghidra producer coverage was not repeated.
This commit is contained in:
parent
d4bd65c53c
commit
55dd21a5e2
|
|
@ -227,6 +227,10 @@ nodes and edges. Node payloads are bounded to 8 MiB and serialized results to
|
|||
32 MiB. Pagination returns edges whose source is on the node page and preserves
|
||||
stable IDs for endpoints outside that page.
|
||||
|
||||
Within one value trace, each exact procedure selector is attempted at most once,
|
||||
including failed analyses. Failures remain unknown observations and do not
|
||||
establish canonical function identity. A new trace can retry those selectors.
|
||||
|
||||
The raw Java `is_dead` flag can remain set after Ghidra decodes a live block;
|
||||
`block_membership` reports actual syntax-tree membership separately.
|
||||
|
||||
|
|
@ -319,7 +323,16 @@ complete observation.
|
|||
digest are preserved; key interpretation follows the pinned plist decoder.
|
||||
- `decode_interface_builder` / `rea decode-interface-builder <app>` reads
|
||||
compiled storyboard and nib resources into objects, outlets, actions,
|
||||
connections and controller/class names. `verify:interface-builder` compiles
|
||||
connections and controller/class names. A full app graph is often several
|
||||
megabytes (XLD.app: 6.7 MB, about 2M tokens, which also exceeds the default
|
||||
MCP response budget), so MCP callers should pass `detail: "summary"`. The
|
||||
summary keeps every decoded document and node/edge/coverage counts and
|
||||
retains the complete Evidence; page rows with `inspect_analysis_view`
|
||||
`view: {kind: "interface-builder", collection: "nodes", document:
|
||||
"Contents/Resources/Base.lproj/MainMenu.nib", offset: 0, limit: 64}`.
|
||||
`document` selects rows whose evidence names that exact
|
||||
`documents[].relative_path` (nodes and edges only); `source_truncated` keeps
|
||||
graph incompleteness visible on every page. `verify:interface-builder` compiles
|
||||
a source-owned AppKit XIB with `ibtool`. Storyboards require an installed
|
||||
iOS platform; unsupported archive forms remain explicit.
|
||||
XML plist archives accept UTF-8 and BOM-marked UTF-16 in either byte order.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { EnhancedTools } from "../EnhancedTools.js";
|
||||
import {
|
||||
|
|
@ -615,6 +616,25 @@ describe("native UI query outcomes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("returns native seed resolution failures instead of reporting not found", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
procedure_address: () =>
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
"procedure_address",
|
||||
"invalid address response",
|
||||
),
|
||||
),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "missing-selector" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError", operation: "procedure_address" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancellation instead of a successful trace during callee expansion", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
|
|
|
|||
|
|
@ -125,6 +125,11 @@ export const traceNativeUiAction = async (
|
|||
);
|
||||
if (!native.ok && native.error._tag === "AnalysisCancelledError")
|
||||
return err(native.error);
|
||||
if (
|
||||
!native.ok &&
|
||||
native.error._tag !== "AnalysisCapabilityUnavailableError"
|
||||
)
|
||||
return err(native.error);
|
||||
if (native.ok) {
|
||||
const address = z.string().safeParse(native.value.result);
|
||||
if (!address.success || native.value.subject?.sha256 !== targetSha256)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
|
|||
import { functionDossierSchema } from "../../domain/hopperValues.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ok, err } from "../../domain/result.js";
|
||||
|
||||
const subject = {
|
||||
|
|
@ -181,52 +184,71 @@ describe("native value parameter-use node membership", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves operation IDs that collide with generated parameter IDs", async () => {
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const address = base.procedure.address;
|
||||
const execution = createAnalysisExecution(
|
||||
{
|
||||
...base,
|
||||
native_value_flow: {
|
||||
available: true,
|
||||
provenance: "ghidra-high-pcode",
|
||||
operations: [
|
||||
node("parameter:0", address, "COPY"),
|
||||
node("use", address, "COPY"),
|
||||
],
|
||||
def_use: [],
|
||||
effects: [],
|
||||
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
||||
parameter_uses: [{ ordinal: 0, use: "use", input_index: 1 }],
|
||||
truncated: false,
|
||||
omitted_operations_lower_bound: 0,
|
||||
known_omitted_inputs: 0,
|
||||
known_omitted_edges: 0,
|
||||
limitations: [],
|
||||
},
|
||||
},
|
||||
provider,
|
||||
{ subject },
|
||||
);
|
||||
const result = await traceNativeValues(
|
||||
{ execute: async () => ok(execution) },
|
||||
{ procedure: address, max_nodes: 2 },
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
const parsedResult = nativeValueTraceSchema.parse(result.value);
|
||||
expect(parsedResult).toMatchObject({
|
||||
total_nodes: 2,
|
||||
total_edges: 1,
|
||||
edges: [
|
||||
it.each([3, 4])(
|
||||
"keeps parameter identity separate from colliding operation IDs with max_nodes=%i",
|
||||
async (max_nodes) => {
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const address = base.procedure.address;
|
||||
const execution = createAnalysisExecution(
|
||||
{
|
||||
source: `${address}/parameter:0`,
|
||||
target: `${address}/use`,
|
||||
kind: "parameter-use",
|
||||
...base,
|
||||
native_value_flow: {
|
||||
available: true,
|
||||
provenance: "ghidra-high-pcode",
|
||||
operations: [
|
||||
node("parameter:0", address, "COPY"),
|
||||
node("parameter:0#1", address, "COPY"),
|
||||
node("use", address, "COPY"),
|
||||
],
|
||||
def_use: [],
|
||||
effects: [],
|
||||
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
||||
parameter_uses: [{ ordinal: 0, use: "use", input_index: 1 }],
|
||||
truncated: false,
|
||||
omitted_operations_lower_bound: 0,
|
||||
known_omitted_inputs: 0,
|
||||
known_omitted_edges: 0,
|
||||
limitations: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
provider,
|
||||
{ subject },
|
||||
);
|
||||
const result = await traceNativeValues(
|
||||
{ execute: async () => ok(execution) },
|
||||
{ procedure: address, max_nodes },
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
const parsedResult = nativeValueTraceSchema.parse(result.value);
|
||||
expect(parsedResult).toMatchObject({
|
||||
total_nodes: max_nodes,
|
||||
total_edges: max_nodes === 3 ? 0 : 1,
|
||||
edges:
|
||||
max_nodes === 3
|
||||
? []
|
||||
: [
|
||||
{
|
||||
source: `${address}/parameter:0#2`,
|
||||
target: `${address}/use`,
|
||||
kind: "parameter-use",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
parsedResult.nodes
|
||||
.filter(({ kind }) => kind === "operation")
|
||||
.map(({ id }) => id),
|
||||
).toEqual([
|
||||
`${address}/parameter:0`,
|
||||
`${address}/parameter:0#1`,
|
||||
`${address}/use`,
|
||||
]);
|
||||
expect(new Set(parsedResult.nodes.map(({ id }) => id)).size).toBe(
|
||||
max_nodes,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const nativeFlowDossier = (
|
||||
|
|
@ -316,6 +338,97 @@ const callBindingAnalysis = (
|
|||
},
|
||||
});
|
||||
|
||||
describe("native value failed-callee traversal", () => {
|
||||
it("attempts a repeated failed selector once and still analyzes a later callee", async () => {
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const caller = nativeFlowDossier(base, {
|
||||
address: "0x1000",
|
||||
name: "caller",
|
||||
operations: [
|
||||
node("bad-1", "0x1100", "CALL"),
|
||||
node("bad-2", "0x1104", "CALL"),
|
||||
node("good", "0x1108", "CALL"),
|
||||
],
|
||||
parameters: [],
|
||||
});
|
||||
const good = nativeFlowDossier(base, {
|
||||
address: "0x3000",
|
||||
name: "good",
|
||||
operations: [],
|
||||
parameters: [],
|
||||
});
|
||||
const attempts: string[] = [];
|
||||
const port: AnalysisOperationPort = {
|
||||
execute: async (operation, parameters) => {
|
||||
if (operation === "resolve_native_call_targets") {
|
||||
const destination =
|
||||
parameters.address === "0x1108" ? "0x3000" : "0x2000";
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
call_site: String(parameters.address),
|
||||
procedure: "0x1000",
|
||||
status: "direct",
|
||||
mechanism: "direct",
|
||||
targets: [
|
||||
{
|
||||
address: destination,
|
||||
procedure: destination,
|
||||
status: "direct",
|
||||
basis: "provider-reference",
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
},
|
||||
provider,
|
||||
{ subject },
|
||||
),
|
||||
);
|
||||
}
|
||||
expect(operation).toBe("analyze_function");
|
||||
const selector = String(parameters.procedure);
|
||||
attempts.push(selector);
|
||||
if (selector === "0x2000")
|
||||
return err(
|
||||
new AnalysisOutputError("analyze_function", "Callee unavailable"),
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
selector === "0x1000" ? caller : good,
|
||||
provider,
|
||||
{ subject },
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
// A new invocation retries the failed selector: failures are not cached
|
||||
// across traces, and successful procedure identity remains separate.
|
||||
for (let run = 0; run < 2; run++) {
|
||||
attempts.length = 0;
|
||||
const result = await traceNativeValues(port, {
|
||||
procedure: "0x1000",
|
||||
max_functions: 3,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeValueTraceSchema.parse(result.value);
|
||||
expect(attempts).toEqual(["0x1000", "0x2000", "0x3000"]);
|
||||
expect(trace.decompilations).toBe(3);
|
||||
expect(trace.procedures.map(({ address }) => address)).toEqual([
|
||||
"0x1000",
|
||||
"0x3000",
|
||||
]);
|
||||
expect(trace.unknowns).toContainEqual({
|
||||
procedure: "0x2000",
|
||||
address: null,
|
||||
reason: expect.stringContaining("Callee unavailable"),
|
||||
});
|
||||
expect(trace.edges.filter(({ kind }) => kind === "call")).toHaveLength(3);
|
||||
expect(trace.truncated).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("native value call-binding fan-out", () => {
|
||||
it("preserves binding order and edge-budget behavior for repeated callees", async () => {
|
||||
const callCount = 128;
|
||||
|
|
@ -558,3 +671,156 @@ describe("native value trace selector origin", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native value node byte budgets", () => {
|
||||
it("stops admitting parameters after an operation exceeds the byte budget", async () => {
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const value = nativeFlowDossier(base, {
|
||||
address: "0x1000",
|
||||
name: "caller",
|
||||
operations: [node("use", "0x1000", "a".repeat(9 * 1024 * 1024))],
|
||||
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
||||
});
|
||||
const result = await traceNativeValues(
|
||||
{
|
||||
execute: async () =>
|
||||
ok(createAnalysisExecution(value, provider, { subject })),
|
||||
},
|
||||
{ procedure: "0x1000" },
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(nativeValueTraceSchema.parse(result.value)).toMatchObject({
|
||||
truncated: true,
|
||||
total_nodes: 0,
|
||||
total_edges: 0,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "plain text", name: "a".repeat(9 * 1024 * 1024) },
|
||||
{ label: "JSON escapes", name: "\0".repeat(2 * 1024 * 1024) },
|
||||
])("rejects an oversized parameter encoded with $label", async ({ name }) => {
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const value = nativeFlowDossier(base, {
|
||||
address: "0x1000",
|
||||
name: "caller",
|
||||
operations: [
|
||||
node("parameter:0", "0x1000", "COPY"),
|
||||
node("use", "0x1000", "MULTIEQUAL"),
|
||||
],
|
||||
parameters: [{ ordinal: 0, name, data_type: "/int" }],
|
||||
});
|
||||
const parsed = functionDossierSchema.parse(value);
|
||||
if (!parsed.native_value_flow?.available)
|
||||
throw new Error("Invalid fixture");
|
||||
parsed.native_value_flow.parameter_uses = [
|
||||
{ ordinal: 0, use: "use", input_index: 0 },
|
||||
];
|
||||
const result = await traceNativeValues(
|
||||
{
|
||||
execute: async () =>
|
||||
ok(createAnalysisExecution(parsed, provider, { subject })),
|
||||
},
|
||||
{ procedure: "0x1000" },
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeValueTraceSchema.parse(result.value);
|
||||
expect(trace).toMatchObject({
|
||||
truncated: true,
|
||||
total_nodes: 2,
|
||||
total_edges: 0,
|
||||
nodes: [
|
||||
expect.objectContaining({ kind: "operation" }),
|
||||
expect.objectContaining({ kind: "operation" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("shares the byte budget across operation and parameter nodes", async () => {
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const value = nativeFlowDossier(base, {
|
||||
address: "0x1000",
|
||||
name: "caller",
|
||||
operations: [node("use", "0x1000", "MULTIEQUAL")],
|
||||
parameters: [0, 1].map((ordinal) => ({
|
||||
ordinal,
|
||||
name: "a".repeat(5 * 1024 * 1024),
|
||||
data_type: "/int",
|
||||
})),
|
||||
});
|
||||
const parsed = functionDossierSchema.parse(value);
|
||||
if (!parsed.native_value_flow?.available)
|
||||
throw new Error("Invalid fixture");
|
||||
parsed.native_value_flow.parameter_uses = [0, 1].map((ordinal) => ({
|
||||
ordinal,
|
||||
use: "use",
|
||||
input_index: ordinal,
|
||||
}));
|
||||
const result = await traceNativeValues(
|
||||
{
|
||||
execute: async () =>
|
||||
ok(createAnalysisExecution(parsed, provider, { subject })),
|
||||
},
|
||||
{ procedure: "0x1000" },
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeValueTraceSchema.parse(result.value);
|
||||
expect(trace).toMatchObject({
|
||||
truncated: true,
|
||||
total_nodes: 2,
|
||||
total_edges: 1,
|
||||
});
|
||||
expect(trace.nodes.map(({ id }) => id)).toEqual([
|
||||
"0x1000/use",
|
||||
"0x1000/parameter:0",
|
||||
]);
|
||||
expect(trace.edges[0]?.source).toBe("0x1000/parameter:0");
|
||||
const retainedBytes = trace.nodes.reduce(
|
||||
(total, value) => total + Buffer.byteLength(JSON.stringify(value)),
|
||||
0,
|
||||
);
|
||||
expect(retainedBytes).toBeLessThanOrEqual(8 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native value byte-bound call bindings", () => {
|
||||
it("omits argument bindings to rejected callee parameter nodes", async () => {
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const caller = nativeFlowDossier(base, {
|
||||
address: "0x1000",
|
||||
name: "caller",
|
||||
operations: [node("call", "0x1000", "CALL")],
|
||||
parameters: [],
|
||||
});
|
||||
const callee = nativeFlowDossier(base, {
|
||||
address: "0x2000",
|
||||
name: "callee",
|
||||
operations: [node("return", "0x2000", "RETURN")],
|
||||
parameters: [
|
||||
{ ordinal: 0, name: "a".repeat(9 * 1024 * 1024), data_type: "/int" },
|
||||
],
|
||||
});
|
||||
const result = await traceNativeValues(
|
||||
callBindingAnalysis("0x1000", "0x2000", caller, callee),
|
||||
{ procedure: "0x1000" },
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeValueTraceSchema.parse(result.value);
|
||||
expect(trace.truncated).toBe(true);
|
||||
expect(trace.nodes.map(({ kind }) => kind)).toEqual([
|
||||
"operation",
|
||||
"operation",
|
||||
]);
|
||||
expect(trace.edges.map(({ kind }) => kind)).toEqual([
|
||||
"call",
|
||||
"return-binding",
|
||||
]);
|
||||
expect(trace.unknowns).toContainEqual(
|
||||
expect.objectContaining({
|
||||
reason: "Parameter bindings for 0x2000 are unavailable or omitted",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import {
|
|||
import { err, ok } from "../../domain/result.js";
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "../EnhancedToolTypes.js";
|
||||
import { jsonParts } from "../../domain/jsonSerialization.js";
|
||||
|
||||
const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
|
||||
export const traceNativeValues = async (
|
||||
|
|
@ -21,7 +24,6 @@ export const traceNativeValues = async (
|
|||
): EnhancedResult => {
|
||||
const input = nativeValueTraceInputSchema.parse(parameters);
|
||||
const nodes: typeof nativeValueTraceSchema._output.nodes = [];
|
||||
const nodeIds = new Set<string>();
|
||||
const edges: typeof nativeValueTraceSchema._output.edges = [];
|
||||
const procedures: typeof nativeValueTraceSchema._output.procedures = [];
|
||||
const unknowns: typeof nativeValueTraceSchema._output.unknowns = [];
|
||||
|
|
@ -34,17 +36,46 @@ export const traceNativeValues = async (
|
|||
evidence_id: string;
|
||||
}[] = [];
|
||||
const pending = [{ procedure: input.procedure, depth: 0 }];
|
||||
const attempted = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
let targetSha256: string | undefined;
|
||||
let truncated = false;
|
||||
let decompilations = 0;
|
||||
let callSites = 0;
|
||||
let bytes = 0;
|
||||
let nodeBudgetReached = false;
|
||||
const retainNode = (value: (typeof nodes)[number]): boolean => {
|
||||
if (nodeBudgetReached) return false;
|
||||
if (nodes.length >= input.max_nodes) {
|
||||
truncated = true;
|
||||
nodeBudgetReached = true;
|
||||
return false;
|
||||
}
|
||||
let size = 0;
|
||||
// Reuse bounded JSON encoding to count escapes and UTF-8 expansion
|
||||
// before allocating a complete serialized node that might not fit.
|
||||
for (const part of jsonParts(value)) {
|
||||
size += Buffer.byteLength(part);
|
||||
if (size > NODE_PAYLOAD_BYTES - bytes) {
|
||||
truncated = true;
|
||||
nodeBudgetReached = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bytes += size;
|
||||
nodes.push(value);
|
||||
return true;
|
||||
};
|
||||
while (pending.length > 0) {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("trace_native_values"));
|
||||
const next = pending.shift();
|
||||
if (next === undefined || visited.has(next.procedure)) continue;
|
||||
if (
|
||||
next === undefined ||
|
||||
attempted.has(next.procedure) ||
|
||||
visited.has(next.procedure)
|
||||
)
|
||||
continue;
|
||||
if (decompilations >= input.max_functions) {
|
||||
truncated = true;
|
||||
unknowns.push({
|
||||
|
|
@ -54,6 +85,9 @@ export const traceNativeValues = async (
|
|||
});
|
||||
break;
|
||||
}
|
||||
// An exact selector is attempted once per trace, even on failure. Only
|
||||
// successful dossiers establish canonical procedure identity in visited.
|
||||
attempted.add(next.procedure);
|
||||
const execution = await analysis.execute(
|
||||
"analyze_function",
|
||||
{ procedure: next.procedure },
|
||||
|
|
@ -135,6 +169,10 @@ export const traceNativeValues = async (
|
|||
});
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
// Keep generated parameter identity independent of which operations fit
|
||||
// the budget, while preserving every provider-supplied operation ID.
|
||||
const operationIds = new Set(flow.operations.map(({ id }) => id));
|
||||
const parameterIds = new Map<number, string>();
|
||||
const qualified = (id: string) => `${address}/${id}`;
|
||||
for (const operation of flow.operations) {
|
||||
if (
|
||||
|
|
@ -150,44 +188,37 @@ export const traceNativeValues = async (
|
|||
kind: "operation" as const,
|
||||
parameter: null,
|
||||
};
|
||||
const size = Buffer.byteLength(JSON.stringify(node));
|
||||
if (nodes.length >= input.max_nodes || bytes + size > 8 * 1024 * 1024) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
bytes += size;
|
||||
if (!retainNode(node)) break;
|
||||
ids.add(operation.id);
|
||||
nodes.push(node);
|
||||
nodeIds.add(node.id);
|
||||
}
|
||||
for (const parameter of flow.parameters) {
|
||||
if (nodes.length >= input.max_nodes) {
|
||||
truncated = true;
|
||||
const parameterId = `parameter:${parameter.ordinal}`;
|
||||
let localId = parameterId;
|
||||
for (let suffix = 1; operationIds.has(localId); suffix++)
|
||||
localId = `${parameterId}#${suffix}`;
|
||||
const id = qualified(localId);
|
||||
if (
|
||||
!retainNode({
|
||||
id,
|
||||
procedure: address,
|
||||
evidence_id: evidenceId,
|
||||
kind: "parameter",
|
||||
operation: null,
|
||||
parameter,
|
||||
})
|
||||
)
|
||||
break;
|
||||
}
|
||||
const id = `${address}/parameter:${parameter.ordinal}`;
|
||||
nodes.push({
|
||||
id,
|
||||
procedure: address,
|
||||
evidence_id: evidenceId,
|
||||
kind: "parameter",
|
||||
operation: null,
|
||||
parameter,
|
||||
});
|
||||
nodeIds.add(id);
|
||||
parameterIds.set(parameter.ordinal, id);
|
||||
}
|
||||
for (const use of flow.parameter_uses) {
|
||||
if (
|
||||
!ids.has(use.use) ||
|
||||
!nodeIds.has(`${address}/parameter:${use.ordinal}`)
|
||||
)
|
||||
continue;
|
||||
const source = parameterIds.get(use.ordinal);
|
||||
if (!ids.has(use.use) || source === undefined) continue;
|
||||
if (edges.length >= input.max_edges) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
edges.push({
|
||||
source: `${address}/parameter:${use.ordinal}`,
|
||||
source,
|
||||
target: qualified(use.use),
|
||||
input_index: use.input_index,
|
||||
kind: "parameter-use",
|
||||
|
|
@ -295,9 +326,10 @@ export const traceNativeValues = async (
|
|||
}
|
||||
if (
|
||||
truncated &&
|
||||
(nodes.length >= input.max_nodes ||
|
||||
(nodeBudgetReached ||
|
||||
nodes.length >= input.max_nodes ||
|
||||
edges.length >= input.max_edges ||
|
||||
bytes >= 8 * 1024 * 1024)
|
||||
bytes >= NODE_PAYLOAD_BYTES)
|
||||
)
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -89,11 +89,17 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
|||
),
|
||||
artifact(
|
||||
"decode_interface_builder",
|
||||
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.",
|
||||
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns. A full app graph is often several megabytes; detail summary retains it and returns documents with node, edge and coverage counts, then page rows, optionally one document's, with inspect_analysis_view kind interface-builder.",
|
||||
z.strictObject({
|
||||
max_documents: z.number().int().min(1).max(64).default(64),
|
||||
max_objects: z.number().int().min(1).max(20_000).default(20_000),
|
||||
max_connections: z.number().int().min(1).max(40_000).default(40_000),
|
||||
detail: analysisDetailSchema
|
||||
.unwrap()
|
||||
.optional()
|
||||
.describe(
|
||||
"Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
artifact(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const FUNCTION_COMPARISON_EVIDENCE = createEvidence(
|
|||
result: jsonValueSchema.parse(comparison),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: comparison.limitations,
|
||||
evidenceLinks: [
|
||||
FUNCTION_COMPARISON_EXAMPLE.left.evidence_id,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right.evidence_id,
|
||||
|
|
|
|||
|
|
@ -66,15 +66,13 @@ const native = <Name extends string, Schema extends z.ZodObject>(
|
|||
};
|
||||
|
||||
/** Ordered single-line symbols; `swift-demangle` ends each result with LF. */
|
||||
export const swiftSymbolsSchema = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[^\n]*$/u, "Each Swift symbol must be one line.")
|
||||
.regex(/^[^\x00]*$/u, "Swift symbols cannot contain NUL."),
|
||||
)
|
||||
.min(1);
|
||||
export const swiftSymbolsSchema = z.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[^\n]*$/u, "Each Swift symbol must be one line.")
|
||||
.regex(/^[^\x00]*$/u, "Swift symbols cannot contain NUL."),
|
||||
);
|
||||
|
||||
/** Provider-neutral semantic operations backed initially by macOS utilities. */
|
||||
export const NATIVE_TOOL_CONTRACTS = [
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||
import { byteSearchResultSchema } from "../domain/native/bytePattern.js";
|
||||
import {
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
interfaceBuilderSummaryViewResultSchema,
|
||||
machoSummaryViewResultSchema,
|
||||
managedArtifactSummaryViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
|
|
@ -360,17 +361,19 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
z.object({
|
||||
document: z.string(),
|
||||
image_base: z.string().min(1).optional(),
|
||||
segments: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
length: z.number().min(0),
|
||||
image_base: z.string().exactOptional(),
|
||||
address_space: z.string().exactOptional(),
|
||||
}),
|
||||
),
|
||||
segment_count: z.number().int().min(0),
|
||||
segments: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
length: z.number().min(0),
|
||||
image_base: z.string().exactOptional(),
|
||||
address_space: z.string().exactOptional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
segment_count: z.number().int().min(0).optional(),
|
||||
procedure_count: z.number().int().min(0),
|
||||
string_count: z.number().int().min(0),
|
||||
}),
|
||||
|
|
@ -404,7 +407,12 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
]),
|
||||
),
|
||||
extract_artifact: evidenceResultOf(artifactExtractionResultSchema),
|
||||
decode_interface_builder: evidenceResultOf(interfaceBuilderAnalysisSchema),
|
||||
decode_interface_builder: evidenceResultOf(
|
||||
z.union([
|
||||
interfaceBuilderAnalysisSchema,
|
||||
interfaceBuilderSummaryViewResultSchema,
|
||||
]),
|
||||
),
|
||||
inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema),
|
||||
inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema),
|
||||
trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { androidResultSchemas } from "../android/androidAnalysis.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
|
|
@ -53,19 +53,8 @@ export const projectAndroidPackageView = (
|
|||
},
|
||||
]),
|
||||
);
|
||||
const parsed = packageSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const parsed = parseAnalysisViewResult(packageSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
// Engine artifact_sha256 identifies the JAR, never the APK. A missing
|
||||
// authenticated APK subject cannot be repaired using the engine identity.
|
||||
if (parent.subject == null)
|
||||
|
|
@ -79,7 +68,7 @@ export const projectAndroidPackageView = (
|
|||
},
|
||||
]),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const report = parsed.value;
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_android_package" as const,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
|
|
@ -59,20 +59,9 @@ export const projectDartAotView = (
|
|||
},
|
||||
]),
|
||||
);
|
||||
const parsed = aotSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(aotSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const { package_uris, dart_uris, class_like_tokens, ...pool } =
|
||||
report.string_pool;
|
||||
const string_pool = {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { z } from "zod";
|
|||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { evmInterfaceSchema } from "../evm/evmInterface.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
|
|
@ -60,20 +60,9 @@ export const projectEvmInterfaceView = (
|
|||
},
|
||||
]),
|
||||
);
|
||||
const parsed = evmInterfaceSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
parent.subject == null ||
|
||||
parent.subject.format !== "file" ||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
projectAnalysisView,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisView.js";
|
||||
|
||||
const target = "a".repeat(64);
|
||||
const evidence = (artifact_path: string) => [
|
||||
{
|
||||
kind: "interface_builder_resource" as const,
|
||||
description: "compiled document",
|
||||
location: { address: null, file_offset: null },
|
||||
artifact_path,
|
||||
artifact_sha256: "b".repeat(64),
|
||||
},
|
||||
];
|
||||
const node = (id: string, kind: string, document: string) => ({
|
||||
id,
|
||||
kind,
|
||||
name: id,
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence: evidence(document),
|
||||
});
|
||||
const main = "Contents/Resources/Main.nib";
|
||||
const second = "Contents/Resources/Second.nib";
|
||||
const document = (relative_path: string) => ({
|
||||
relative_path,
|
||||
archive_sha256: "b".repeat(64),
|
||||
document_kind: "nib" as const,
|
||||
object_count: 2,
|
||||
connection_count: 1,
|
||||
hierarchy_complete: true,
|
||||
});
|
||||
const report = (truncated = false) => ({
|
||||
target_sha256: target,
|
||||
documents: [document(main), document(second)],
|
||||
graph: {
|
||||
target_sha256: target,
|
||||
provider: { id: "rea-ib", version: "1", tool_version: "1" },
|
||||
nodes: [
|
||||
node("main:root", "scene", main),
|
||||
node("main:button", "control", main),
|
||||
node("second:root", "scene", second),
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "main:edge",
|
||||
from: "main:root",
|
||||
to: "main:button",
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
evidence: evidence(main),
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
coverage: [
|
||||
{
|
||||
facet: `objects:${main}`,
|
||||
status: "complete",
|
||||
reason: null,
|
||||
examined: 2,
|
||||
omitted: 0,
|
||||
},
|
||||
],
|
||||
truncated,
|
||||
},
|
||||
limitations: ["Recognized archive fields only."],
|
||||
});
|
||||
const parent = (
|
||||
normalizedResult: unknown = report(),
|
||||
sha256 = target,
|
||||
): AnalysisViewParent => ({
|
||||
evidenceId: `ev_${"c".repeat(64)}`,
|
||||
operation: "decode_interface_builder",
|
||||
normalizedResult,
|
||||
subject: {
|
||||
name: "Fixture",
|
||||
digest: { sha256 },
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
local_path: "/Applications/Fixture.app/Contents/MacOS/Fixture",
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
describe("Interface Builder analysis views", () => {
|
||||
it("summarizes documents and graph counts without rows", () => {
|
||||
const projected = projectAnalysisView(parent(), { kind: "summary" });
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_operation: "decode_interface_builder",
|
||||
artifact: { sha256: target },
|
||||
summary: {
|
||||
documents: report().documents,
|
||||
available_counts: { nodes: 3, edges: 1, coverage: 1 },
|
||||
node_kinds: { control: 1, scene: 2 },
|
||||
edge_relations: { contains: 1 },
|
||||
coverage_statuses: { complete: 1 },
|
||||
truncated: false,
|
||||
},
|
||||
coverage: { examined: 7, total: 7 },
|
||||
});
|
||||
expect(JSON.stringify(projected.value)).not.toContain("main:button");
|
||||
});
|
||||
|
||||
it("pages one document's nodes and keeps decoder truncation visible", () => {
|
||||
const projected = projectAnalysisView(parent(), {
|
||||
kind: "interface-builder",
|
||||
collection: "nodes",
|
||||
document: main,
|
||||
offset: 1,
|
||||
limit: 5,
|
||||
});
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value).toMatchObject({
|
||||
kind: "interface-builder",
|
||||
items: [node("main:button", "control", main)],
|
||||
source_truncated: false,
|
||||
coverage: { status: "page", examined: 1, total: 2, exhausted: true },
|
||||
});
|
||||
const truncated = projectAnalysisView(parent(report(true)), {
|
||||
kind: "interface-builder",
|
||||
collection: "coverage",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (!truncated.ok) throw truncated.error;
|
||||
expect(truncated.value).toMatchObject({
|
||||
items: report().graph.coverage,
|
||||
source_truncated: true,
|
||||
unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("The decoded graph is incomplete"),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses unknown documents, other views and mismatched targets", () => {
|
||||
const unknown = projectAnalysisView(parent(), {
|
||||
kind: "interface-builder",
|
||||
collection: "edges",
|
||||
document: "Contents/Resources/Missing.nib",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (unknown.ok) throw new Error("expected an unknown-document refusal");
|
||||
expect(unknown.error).toMatchObject({
|
||||
issues: [{ path: ["view", "document"] }],
|
||||
});
|
||||
const wrongKind = projectAnalysisView(parent(), {
|
||||
kind: "macho",
|
||||
facet: "imports",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (wrongKind.ok) throw new Error("expected a view-kind refusal");
|
||||
expect(wrongKind.error).toMatchObject({ issues: [{ path: ["view"] }] });
|
||||
const mismatched = projectAnalysisView(parent(report(), "d".repeat(64)), {
|
||||
kind: "summary",
|
||||
});
|
||||
if (mismatched.ok) throw new Error("expected a subject refusal");
|
||||
expect(mismatched.error).toMatchObject({
|
||||
issues: [{ path: ["source", "subject"] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import {
|
||||
interfaceBuilderAnalysisSchema,
|
||||
interfaceBuilderDocumentSchema,
|
||||
} from "../apple/interfaceBuilderGraph.js";
|
||||
import {
|
||||
nativeInvestigationCoverageSchema,
|
||||
nativeInvestigationEdgeSchema,
|
||||
nativeInvestigationNodeSchema,
|
||||
} from "../native/nativeInvestigationGraph.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
type AnalysisViewRequest,
|
||||
type UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
|
||||
const page = {
|
||||
kind: z.literal("interface-builder"),
|
||||
offset: z.number().int().nonnegative().default(0),
|
||||
limit: z.number().int().positive().default(64),
|
||||
} as const;
|
||||
|
||||
/** Original decoded rows; graph rows can be narrowed to one decoded document. */
|
||||
export const interfaceBuilderViewRequestSchema = z.discriminatedUnion(
|
||||
"collection",
|
||||
[
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.enum(["nodes", "edges"]),
|
||||
document: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact documents[].relative_path; selects rows whose evidence names that archive.",
|
||||
),
|
||||
}),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.enum(["documents", "coverage"]),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export const interfaceBuilderViewItemsSchema = z.union([
|
||||
z.array(interfaceBuilderDocumentSchema),
|
||||
z.array(nativeInvestigationNodeSchema),
|
||||
z.array(nativeInvestigationEdgeSchema),
|
||||
z.array(nativeInvestigationCoverageSchema),
|
||||
]);
|
||||
|
||||
/** The decoder's own truncation, independent of page exhaustion. */
|
||||
export const interfaceBuilderViewContextSchema = z.strictObject({
|
||||
source_truncated: z.boolean(),
|
||||
});
|
||||
|
||||
const countsSchema = z.record(z.string(), z.number().int().positive());
|
||||
|
||||
/** Every decoded document with graph counts; rows and evidence stay retained. */
|
||||
export const interfaceBuilderSummarySchema = z.strictObject({
|
||||
target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256,
|
||||
provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider,
|
||||
documents: interfaceBuilderAnalysisSchema.shape.documents,
|
||||
available_counts: z.strictObject({
|
||||
nodes: z.number().int().nonnegative(),
|
||||
edges: z.number().int().nonnegative(),
|
||||
coverage: z.number().int().nonnegative(),
|
||||
}),
|
||||
node_kinds: countsSchema,
|
||||
edge_relations: countsSchema,
|
||||
coverage_statuses: countsSchema,
|
||||
truncated: z.boolean(),
|
||||
limitation_count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const tally = (values: readonly string[]): Record<string, number> => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return Object.fromEntries(
|
||||
[...counts].sort(([left], [right]) => (left < right ? -1 : 1)),
|
||||
);
|
||||
};
|
||||
|
||||
type InterfaceBuilderReport = z.output<typeof interfaceBuilderAnalysisSchema>;
|
||||
|
||||
/** Admit a well-formed report bound to its recorded target, if one exists. */
|
||||
const parseInterfaceBuilderReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<InterfaceBuilderReport, AnalysisError> => {
|
||||
const parsed = parseAnalysisViewResult(
|
||||
interfaceBuilderAnalysisSchema,
|
||||
parent,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
report.graph.target_sha256 !== report.target_sha256 ||
|
||||
(parent.subject != null &&
|
||||
parent.subject.digest.sha256 !== report.target_sha256)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "subject"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Interface Builder parent Evidence subject and graph must identify the report's target SHA-256.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(report);
|
||||
};
|
||||
|
||||
/** Every decoded document with graph tallies; rows stay in the retained parent. */
|
||||
const summarizeInterfaceBuilder = (
|
||||
report: InterfaceBuilderReport,
|
||||
limitationCount: number,
|
||||
) => {
|
||||
const { graph } = report;
|
||||
const total =
|
||||
report.documents.length +
|
||||
graph.nodes.length +
|
||||
graph.edges.length +
|
||||
graph.coverage.length;
|
||||
return {
|
||||
summary: {
|
||||
target_sha256: report.target_sha256,
|
||||
provider: graph.provider,
|
||||
documents: report.documents,
|
||||
available_counts: {
|
||||
nodes: graph.nodes.length,
|
||||
edges: graph.edges.length,
|
||||
coverage: graph.coverage.length,
|
||||
},
|
||||
node_kinds: tally(graph.nodes.map((node) => node.kind)),
|
||||
edge_relations: tally(graph.edges.map((edge) => edge.relation)),
|
||||
coverage_statuses: tally(graph.coverage.map((row) => row.status)),
|
||||
truncated: graph.truncated,
|
||||
limitation_count: limitationCount,
|
||||
},
|
||||
coverage: completeWithinViewCoverage(total, total),
|
||||
};
|
||||
};
|
||||
|
||||
/** Project retained decode_interface_builder Evidence without reopening archives. */
|
||||
export const projectInterfaceBuilderView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary" && view.kind !== "interface-builder")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Interface Builder decoding supports summary and interface-builder views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = parseInterfaceBuilderReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const { graph } = report;
|
||||
const limitations = uniqueSorted([
|
||||
...parent.limitations,
|
||||
...report.limitations,
|
||||
]);
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "decode_interface_builder" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact: {
|
||||
path: parent.subject?.local_path ?? "",
|
||||
sha256: report.target_sha256,
|
||||
},
|
||||
limitations,
|
||||
unknowns: [
|
||||
"Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.",
|
||||
...(graph.truncated
|
||||
? [
|
||||
"The decoded graph is incomplete (see coverage and limitations for the cause); page exhaustion does not establish absence of undecoded objects or connections.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary")
|
||||
return ok({
|
||||
kind: "summary",
|
||||
view,
|
||||
...shared,
|
||||
...summarizeInterfaceBuilder(report, limitations.length),
|
||||
});
|
||||
const document =
|
||||
view.collection === "nodes" || view.collection === "edges"
|
||||
? view.document
|
||||
: undefined;
|
||||
if (
|
||||
document !== undefined &&
|
||||
!report.documents.some((row) => row.relative_path === document)
|
||||
)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view", "document"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Select an exact documents[].relative_path from this decoded report.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inDocument = <
|
||||
Row extends { readonly evidence: readonly { artifact_path: unknown }[] },
|
||||
>(
|
||||
candidates: readonly Row[],
|
||||
): readonly Row[] =>
|
||||
document === undefined
|
||||
? candidates
|
||||
: candidates.filter((row) =>
|
||||
row.evidence.some((evidence) => evidence.artifact_path === document),
|
||||
);
|
||||
const rows =
|
||||
view.collection === "documents"
|
||||
? report.documents
|
||||
: view.collection === "coverage"
|
||||
? graph.coverage
|
||||
: view.collection === "nodes"
|
||||
? inDocument(graph.nodes)
|
||||
: inDocument(graph.edges);
|
||||
const items = rows.slice(view.offset, view.offset + view.limit);
|
||||
return ok({
|
||||
kind: "interface-builder",
|
||||
view,
|
||||
items,
|
||||
...shared,
|
||||
source_truncated: graph.truncated,
|
||||
coverage: pageViewCoverage(view.offset, items.length, rows.length),
|
||||
});
|
||||
};
|
||||
|
|
@ -2,9 +2,9 @@ import { z } from "zod";
|
|||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { isMachOFormat } from "../binaryTargetTypes.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
|
|
@ -93,19 +93,8 @@ type MachoReport = z.output<typeof inspectMachoSchema>;
|
|||
const parseMachoReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<MachoReport, AnalysisError> => {
|
||||
const parsed = inspectMachoSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const parsed = parseAnalysisViewResult(inspectMachoSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (parent.subject != null && !isMachOFormat(parent.subject.format))
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
|
|
@ -117,7 +106,7 @@ const parseMachoReport = (
|
|||
},
|
||||
]),
|
||||
);
|
||||
return ok(parsed.data);
|
||||
return ok(parsed.value);
|
||||
};
|
||||
|
||||
/** Counts replace rows; every collection caveat stays in the limitations. */
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import {
|
||||
managedArtifactInspectionSchema,
|
||||
managedMemberInspectionSchema,
|
||||
} from "../managed/managedArtifact.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
|
|
@ -67,22 +67,12 @@ export const projectManagedArtifactView = (
|
|||
},
|
||||
]),
|
||||
);
|
||||
const parsed = managedArtifactInspectionSchema.safeParse(
|
||||
parent.normalizedResult,
|
||||
const parsed = parseAnalysisViewResult(
|
||||
managedArtifactInspectionSchema,
|
||||
parent,
|
||||
);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const identity_scope = {
|
||||
token_identity: "build-local" as const,
|
||||
requires_artifact_sha256: report.artifact.sha256,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { z } from "zod";
|
|||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { parseAnalysisViewResult } from "./analysisViewParent.js";
|
||||
import {
|
||||
wasmArtifactSchema,
|
||||
wasmArtifactMatchesSubject,
|
||||
} from "../wasm/wasmArtifact.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
|
|
@ -81,20 +81,9 @@ export const projectWasmArtifactView = (
|
|||
},
|
||||
]),
|
||||
);
|
||||
const parsed = wasmArtifactSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const report = parsed.data;
|
||||
const parsed = parseAnalysisViewResult(wasmArtifactSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
if (
|
||||
!wasmArtifactMatchesSubject(
|
||||
report.artifact,
|
||||
|
|
|
|||
|
|
@ -337,6 +337,42 @@ describe("call path topology and capacity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("directional call relationship coverage", () => {
|
||||
it.each([
|
||||
{
|
||||
directions: ["callers"] as ["callers"],
|
||||
status: "not_found",
|
||||
exhaustive: true,
|
||||
},
|
||||
{
|
||||
directions: ["callees"] as ["callees"],
|
||||
status: "unknown",
|
||||
exhaustive: false,
|
||||
},
|
||||
{
|
||||
directions: ["callers", "callees"] as ["callers", "callees"],
|
||||
status: "unknown",
|
||||
exhaustive: false,
|
||||
},
|
||||
])(
|
||||
"uses $directions coverage for the forward search",
|
||||
({ directions, status, exhaustive }) => {
|
||||
const limitation = "The inventory omitted a reference.";
|
||||
const relationshipCoverage: RelationshipCoverage = {
|
||||
status: "partial",
|
||||
limitations: [limitation],
|
||||
limited_directions: directions,
|
||||
};
|
||||
const result = run([
|
||||
observe("0x1000", ["0x2000"], "rea-workflow", { relationshipCoverage }),
|
||||
observe("0x2000", [], "rea-workflow", { relationshipCoverage }),
|
||||
]);
|
||||
expect(result).toMatchObject({ status, search_scope: { exhaustive } });
|
||||
expect(result.limitations).toContain(limitation);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("explicit call relationship coverage", () => {
|
||||
it("accumulates 150,000 partial reasons without argument-stack exhaustion", () => {
|
||||
const result = run([
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
parseFunctionEvidence,
|
||||
type FunctionSnapshot,
|
||||
} from "./functionDossierEvidence.js";
|
||||
import { isCallerOnlyUnknownCoverage } from "./functionCallCoverage.js";
|
||||
import { isCallerOnlyIncompleteCoverage } from "./functionCallCoverage.js";
|
||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||
|
||||
/** Minimal directed caller-to-callee adjacency for call-path search. */
|
||||
|
|
@ -268,7 +268,7 @@ const inspectSearch = ({
|
|||
coverage === null
|
||||
? snapshot.limitations.length > 0
|
||||
: coverage.status !== "complete" &&
|
||||
!isCallerOnlyUnknownCoverage(coverage)
|
||||
!isCallerOnlyIncompleteCoverage(coverage)
|
||||
)
|
||||
incompleteExpandedCoverage = true;
|
||||
} else if (node === goal) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
compositeKey,
|
||||
compareUnicodeCodePoints,
|
||||
} from "./unicodeCodePointOrder.js";
|
||||
import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js";
|
||||
import {
|
||||
assertComparisonFindingLinks,
|
||||
comparisonSourceEvidenceIds,
|
||||
} from "./comparisonSourceEvidence.js";
|
||||
import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparisonSchemas.js";
|
||||
import {
|
||||
|
|
@ -260,7 +263,7 @@ const parseResult = (
|
|||
const result = functionComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assertNestedLinks(
|
||||
assertComparisonFindingLinks(
|
||||
evidence,
|
||||
result.dimensions.flatMap(({ evidence_links: links }) => links),
|
||||
);
|
||||
|
|
@ -269,24 +272,13 @@ const parseResult = (
|
|||
const result = artifactComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assertNestedLinks(
|
||||
assertComparisonFindingLinks(
|
||||
evidence,
|
||||
result.changes.flatMap(({ evidence_links: links }) => links),
|
||||
);
|
||||
return { operation, evidence, result };
|
||||
};
|
||||
|
||||
const assertNestedLinks = (
|
||||
evidence: Evidence,
|
||||
nestedLinks: readonly string[],
|
||||
): void => {
|
||||
const topLevel = new Set(evidence.evidence_links);
|
||||
if (nestedLinks.some((evidenceId) => !topLevel.has(evidenceId)))
|
||||
throw new TypeError(
|
||||
"Comparison finding cites Evidence outside its top-level closure",
|
||||
);
|
||||
};
|
||||
|
||||
const findingsFor = (comparison: ParsedComparison): Finding[] => {
|
||||
if (comparison.operation === "compare_process_captures")
|
||||
return processFindings(comparison);
|
||||
|
|
|
|||
|
|
@ -55,3 +55,15 @@ export const comparisonSourceEvidenceIds = (evidence: Evidence): string[] => {
|
|||
const sides = comparisonSourceEvidenceSides(evidence);
|
||||
return uniqueSorted([...sides.left, ...sides.right]);
|
||||
};
|
||||
|
||||
/** Preserve the source closure when consuming nested comparison findings. */
|
||||
export const assertComparisonFindingLinks = (
|
||||
evidence: Pick<Evidence, "evidence_links">,
|
||||
nestedLinks: readonly string[],
|
||||
): void => {
|
||||
const closure = new Set(evidence.evidence_links);
|
||||
if (nestedLinks.some((id) => !closure.has(id)))
|
||||
throw new TypeError(
|
||||
"Comparison finding cites Evidence outside its top-level closure",
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import {
|
||||
combineFunctionRelationshipCoverage,
|
||||
isCallerOnlyUnknownCoverage,
|
||||
isCallerOnlyIncompleteCoverage,
|
||||
} from "./functionCallCoverage.js";
|
||||
import type { RelationshipCoverage } from "./relationshipCoverage.js";
|
||||
|
||||
|
|
@ -22,14 +22,14 @@ const cappedCallees: RelationshipCoverage = {
|
|||
};
|
||||
const complete: RelationshipCoverage = { status: "complete" };
|
||||
|
||||
describe("isCallerOnlyUnknownCoverage", () => {
|
||||
describe("isCallerOnlyIncompleteCoverage", () => {
|
||||
it("detects unknown callers beside complete callees", () => {
|
||||
const combined = combineFunctionRelationshipCoverage(
|
||||
unknownCallers,
|
||||
complete,
|
||||
);
|
||||
expect(combined.status).toBe("unknown");
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(true);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown callees beside complete callers", () => {
|
||||
|
|
@ -37,7 +37,7 @@ describe("isCallerOnlyUnknownCoverage", () => {
|
|||
complete,
|
||||
unknownCallees,
|
||||
);
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown coverage on both sides", () => {
|
||||
|
|
@ -45,7 +45,7 @@ describe("isCallerOnlyUnknownCoverage", () => {
|
|||
unknownCallers,
|
||||
unknownCallees,
|
||||
);
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown callers beside IDA-capped callees", () => {
|
||||
|
|
@ -54,11 +54,37 @@ describe("isCallerOnlyUnknownCoverage", () => {
|
|||
cappedCallees,
|
||||
);
|
||||
expect(combined.status).toBe("unknown");
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects complete coverage", () => {
|
||||
const combined = combineFunctionRelationshipCoverage(complete, complete);
|
||||
expect(isCallerOnlyUnknownCoverage(combined)).toBe(false);
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains caller-only partial coverage without relying on limitation wording", () => {
|
||||
const combined = combineFunctionRelationshipCoverage(
|
||||
{ status: "partial", limitations: ["Missing incoming references."] },
|
||||
complete,
|
||||
);
|
||||
expect(combined).toMatchObject({ limited_directions: ["callers"] });
|
||||
expect(isCallerOnlyIncompleteCoverage(combined)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses explicit directions even when explanation text contains direction labels", () => {
|
||||
expect(
|
||||
isCallerOnlyIncompleteCoverage({
|
||||
status: "unknown",
|
||||
reason: "Callers: unavailable. Callees: diagnostic text.",
|
||||
limited_directions: ["callers"],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCallerOnlyIncompleteCoverage({
|
||||
status: "unknown",
|
||||
reason: "Callers: unavailable.",
|
||||
limited_directions: ["callees"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,16 @@ export const combineFunctionRelationshipCoverage = (
|
|||
),
|
||||
);
|
||||
const details = diagnostics.length > 0 ? { diagnostics } : {};
|
||||
const callersLimited = callers?.status !== "complete";
|
||||
const calleesLimited = callees?.status !== "complete";
|
||||
const limited_directions: Extract<
|
||||
RelationshipCoverage,
|
||||
{ status: "partial" }
|
||||
>["limited_directions"] = callersLimited
|
||||
? calleesLimited
|
||||
? ["callers", "callees"]
|
||||
: ["callers"]
|
||||
: ["callees"];
|
||||
const reasons = reads.flatMap(({ direction, coverage }) => {
|
||||
const label =
|
||||
direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix;
|
||||
|
|
@ -43,30 +53,37 @@ export const combineFunctionRelationshipCoverage = (
|
|||
return {
|
||||
status: "unknown",
|
||||
reason: uniqueSorted(reasons).join(" "),
|
||||
limited_directions,
|
||||
...details,
|
||||
};
|
||||
if (reasons.length > 0)
|
||||
return {
|
||||
status: "partial",
|
||||
limitations: uniqueSorted(reasons),
|
||||
limited_directions,
|
||||
...details,
|
||||
};
|
||||
return { status: "complete", ...details };
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether combined relationship coverage is unknown only on the caller side.
|
||||
* The combine helper above prefixes each direction's reasons, so a reason
|
||||
* that carries the caller prefix and no callee prefix records observed
|
||||
* callees with unobservable callers. Forward (callee-edge) searches stay
|
||||
* exhaustive under that caveat; the caller reason remains a limitation.
|
||||
* Forward searches depend on callee coverage. Preserve the historical
|
||||
* unknown-reason encoding when reading observations without explicit directions.
|
||||
*/
|
||||
export const isCallerOnlyUnknownCoverage = (
|
||||
export const isCallerOnlyIncompleteCoverage = (
|
||||
coverage: RelationshipCoverage,
|
||||
): boolean =>
|
||||
coverage.status === "unknown" &&
|
||||
coverage.reason.startsWith(callerCoveragePrefix) &&
|
||||
!coverage.reason.includes(calleeCoveragePrefix);
|
||||
): boolean => {
|
||||
if (coverage.status === "complete") return false;
|
||||
if (coverage.limited_directions !== undefined)
|
||||
return !coverage.limited_directions.some(
|
||||
(direction) => direction === "callees",
|
||||
);
|
||||
return (
|
||||
coverage.status === "unknown" &&
|
||||
coverage.reason.startsWith(callerCoveragePrefix) &&
|
||||
!coverage.reason.includes(calleeCoveragePrefix)
|
||||
);
|
||||
};
|
||||
|
||||
/** Explain why two resolved call sets cannot establish call-set equality. */
|
||||
export const functionCallCoverageLimitations = (
|
||||
|
|
|
|||
|
|
@ -625,6 +625,40 @@ describe("function comparison CFG address normalization", () => {
|
|||
});
|
||||
|
||||
describe("function comparison unresolved text", () => {
|
||||
it("qualifies exact text changes caused by relocated provider labels", () => {
|
||||
const left = observe(
|
||||
"2",
|
||||
functionDossierSchema.parse({
|
||||
...dossier("return FUN_00001010(DAT_00001020) % 3;", "0x1000"),
|
||||
assembly: ["0x1000 call FUN_00001010", "0x1004 ret"],
|
||||
}),
|
||||
);
|
||||
const right = observe(
|
||||
"3",
|
||||
functionDossierSchema.parse({
|
||||
...dossier("return FUN_00002010(DAT_00002020) % 3;", "0x2000"),
|
||||
assembly: ["0x2000 call FUN_00002010", "0x2004 ret"],
|
||||
}),
|
||||
);
|
||||
const comparison = compareFunctions(left, right);
|
||||
for (const name of ["pseudocode", "assembly"]) {
|
||||
const dimension = comparison.dimensions.find(
|
||||
({ dimension }) => dimension === name,
|
||||
);
|
||||
expect(dimension).toMatchObject({
|
||||
status: "changed",
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"layout-only differences do not establish a behavior change",
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
expect(
|
||||
comparison.dimensions.find(({ dimension }) => dimension === "cfg"),
|
||||
).toMatchObject({ status: "unchanged" });
|
||||
});
|
||||
|
||||
it("counts Unicode text consistently for unresolved provider comparisons", () => {
|
||||
const left = observe("b", dossier("return '😀';", "0x1000"), "provider-a");
|
||||
const right = observe("c", dossier("return '😀';", "0x2000"), "provider-b");
|
||||
|
|
@ -886,6 +920,31 @@ describe("function comparison Swift string literals", () => {
|
|||
});
|
||||
|
||||
describe("function comparison partial Swift literal coverage", () => {
|
||||
it("keeps literal comparisons unknown at unresolved jump candidates", () => {
|
||||
const unresolved = functionDossierSchema.parse({
|
||||
...withLiterals("0x2000", []),
|
||||
swift_string_literal_omissions: [
|
||||
{
|
||||
source_address: "0x2004",
|
||||
representation: "small",
|
||||
address: null,
|
||||
byte_length: 4,
|
||||
reason: "unresolved_jump",
|
||||
registers: ["x0", "x1"],
|
||||
words: ["0x732f4220", "0xe400000000000000"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
stringsDimension(
|
||||
"8",
|
||||
withLiterals("0x1000", ["SleepDisabled"]),
|
||||
"9",
|
||||
unresolved,
|
||||
),
|
||||
).toMatchObject({ status: "unknown" });
|
||||
});
|
||||
|
||||
it("does not equate a partial literal record with a complete one", () => {
|
||||
const partial = functionDossierSchema.parse({
|
||||
...withLiterals("0x2000", ["SleepDisabled"]),
|
||||
|
|
@ -930,7 +989,9 @@ describe("function comparison partial Swift literal coverage", () => {
|
|||
stringsDimension("d", withLiterals("0x1000", []), "e", partial),
|
||||
).toMatchObject({ status: "changed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("function comparison partial Swift literal omission interpretation", () => {
|
||||
it("does not interpret an unexamined path as a missing literal at a retained site", () => {
|
||||
const partial = functionDossierSchema.parse({
|
||||
...withLiterals("0x2000", ["A"]),
|
||||
|
|
|
|||
|
|
@ -121,12 +121,14 @@ const compareText = (
|
|||
right: string,
|
||||
context: ComparisonContext,
|
||||
): FunctionDimension => {
|
||||
const limitations =
|
||||
dimension === "assembly"
|
||||
const limitations = [
|
||||
"Exact provider text is compared without address or generated-name normalization; layout-only differences do not establish a behavior change.",
|
||||
...(dimension === "assembly"
|
||||
? [
|
||||
"Assembly is opaque provider text; relocation normalization is unavailable.",
|
||||
]
|
||||
: [];
|
||||
: []),
|
||||
];
|
||||
if (context.providersDiffer)
|
||||
return unresolvedDimension({
|
||||
dimension,
|
||||
|
|
@ -149,6 +151,7 @@ const compareText = (
|
|||
leftCount: [...left].length,
|
||||
rightCount: [...right].length,
|
||||
limitations: [
|
||||
...limitations,
|
||||
"Bounded line diff exceeded its time or edit-distance limit.",
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -319,15 +319,15 @@ const swiftStringLiteralOmissionFields = {
|
|||
source_address: swiftStringLiteralFields.source_address,
|
||||
byte_length: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
reason: z
|
||||
.literal("capacity")
|
||||
.enum(["capacity", "unresolved_jump"])
|
||||
.describe(
|
||||
"The record did not fit the provider's memory budget for this dossier; decoding stopped here, so later use sites were not examined.",
|
||||
"Why this candidate is omitted: capacity stopped decoding, or an unresolved jump left its use unknown.",
|
||||
),
|
||||
registers: swiftStringLiteralFields.registers,
|
||||
words: swiftStringLiteralFields.words,
|
||||
};
|
||||
|
||||
/** A Swift literal whose layout matched but whose record did not fit; decoding stopped there. */
|
||||
/** A Swift literal candidate omitted because decoding capacity or control flow left it unknown. */
|
||||
export const swiftStringLiteralOmissionSchema = z.discriminatedUnion(
|
||||
"representation",
|
||||
[
|
||||
|
|
@ -410,7 +410,7 @@ export const functionDossierSchema = z
|
|||
.array(swiftStringLiteralOmissionSchema)
|
||||
.exactOptional()
|
||||
.describe(
|
||||
"The literal at which decoding stopped for capacity. Present exactly when swift_string_literals is present; empty when decoding examined every use site.",
|
||||
"Literal candidates omitted for capacity or unresolved control flow. Present exactly when swift_string_literals is present; empty when all examined use sites were classified.",
|
||||
),
|
||||
basic_blocks: z.array(
|
||||
z
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "plist";
|
||||
|
||||
import { omitPrototypeKeys, parseXmlPropertyList } from "./propertyListKeys.js";
|
||||
|
||||
|
|
@ -234,3 +235,55 @@ it("locates sparse omitted keys across newline-dense source", () => {
|
|||
omittedPrototypeKeys: 2,
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenStep plist nesting preflight", () => {
|
||||
const arrays = (depth: number, leaf = "x") =>
|
||||
"(".repeat(depth) + leaf + ")".repeat(depth);
|
||||
|
||||
it.each([129, 3000])(
|
||||
"rejects %s nested OpenStep values with the shared depth limit",
|
||||
(depth) => {
|
||||
expect(() => parseXmlPropertyList(arrays(depth))).toThrow(
|
||||
"Plist exceeds 128 nesting levels",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["{}", "()"])(
|
||||
"preserves the first %s root despite deeply nested trailing text",
|
||||
(root) => {
|
||||
expect(parseXmlPropertyList(root + arrays(3000))).toEqual({
|
||||
value: root === "{}" ? {} : [],
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts the deepest supported OpenStep value and empty container", () => {
|
||||
expect(() => parseXmlPropertyList(arrays(128))).not.toThrow();
|
||||
expect(() => parseXmlPropertyList(arrays(129, ""))).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"(x, y,)",
|
||||
"{ key = x next = (y,); }",
|
||||
"{ (compound, key) = x; { nested = key; } = y; }",
|
||||
"{ key = (); next = {}; }",
|
||||
])("preserves pinned separator and compound-key semantics for %s", (text) => {
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: parse(text),
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores brackets in quoted strings, escaped quotes, comments, and data", () => {
|
||||
expect(
|
||||
parseXmlPropertyList(
|
||||
'{ quote = "\\\"( )"; /* ((( */ data = <2829>; // (((\n values = (x); }',
|
||||
),
|
||||
).toEqual({
|
||||
value: { quote: '"( )', data: new Uint8Array([40, 41]), values: ["x"] },
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,7 +94,10 @@ export const parseXmlPropertyList = (
|
|||
// Preserve the pinned decoder's existing OpenStep autodetection, also used
|
||||
// by app Info.plist resolution. Only XML needs the key-element substitution.
|
||||
const first = text.trimStart()[0];
|
||||
if (first === "{" || first === "(") return omitPrototypeKeys(parse(text));
|
||||
if (first === "{" || first === "(") {
|
||||
assertOpenStepDepthWithinLimit(text);
|
||||
return omitPrototypeKeys(parse(text));
|
||||
}
|
||||
// Normalize only the literal line endings the pinned decoder normalizes.
|
||||
// Preserve character references in the source: serializing a DOM would turn
|
||||
// e.g. an encoded carriage return into a literal newline on the second parse.
|
||||
|
|
@ -111,6 +114,133 @@ export const parseXmlPropertyList = (
|
|||
return withoutKey(parse(substituted), placeholder);
|
||||
};
|
||||
|
||||
interface OpenStepContainer {
|
||||
end: string;
|
||||
next: "key" | "equals" | "value" | "separator" | "after-value";
|
||||
}
|
||||
|
||||
/** Consume only the punctuation expected between the pinned decoder's values. */
|
||||
const consumeOpenStepSeparator = (
|
||||
parent: OpenStepContainer | undefined,
|
||||
character: string,
|
||||
): "consumed" | "invalid" | "none" => {
|
||||
if (parent?.next === "after-value") {
|
||||
parent.next = "key";
|
||||
return character === ";" ? "consumed" : "none";
|
||||
}
|
||||
if (parent?.next !== "equals" && parent?.next !== "separator") return "none";
|
||||
if (character !== (parent.next === "equals" ? "=" : ",")) return "invalid";
|
||||
parent.next = "value";
|
||||
return "consumed";
|
||||
};
|
||||
|
||||
/** An array may close after a comma; a dictionary still needs its assigned value. */
|
||||
const admitsOpenStepCloser = (
|
||||
parent: OpenStepContainer | undefined,
|
||||
character: string,
|
||||
): boolean =>
|
||||
parent?.end === character &&
|
||||
parent.next !== "equals" &&
|
||||
!(parent.end === "}" && parent.next === "value");
|
||||
|
||||
/** Bound OpenStep value nesting before the pinned decoder recursively reads it. */
|
||||
const assertOpenStepDepthWithinLimit = (text: string): void => {
|
||||
const containers: OpenStepContainer[] = [];
|
||||
const completeValue = (): void => {
|
||||
const parent = containers.at(-1);
|
||||
if (parent !== undefined)
|
||||
parent.next =
|
||||
parent.next === "key"
|
||||
? "equals"
|
||||
: parent.end === "}"
|
||||
? "after-value"
|
||||
: "separator";
|
||||
};
|
||||
const assertDepth = (): void => {
|
||||
if (containers.length > MAX_PLIST_DEPTH)
|
||||
throw new RangeError(
|
||||
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
|
||||
);
|
||||
};
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
const afterTrivia = skipOpenStepTrivia(text, cursor);
|
||||
if (afterTrivia !== cursor) {
|
||||
cursor = afterTrivia;
|
||||
continue;
|
||||
}
|
||||
const character = text[cursor] ?? "";
|
||||
const parent = containers.at(-1);
|
||||
// Stop at the first impossible token: only the pinned decoder owns syntax
|
||||
// diagnostics. A later deep suffix cannot change that failure into a limit.
|
||||
if (character === "}" || character === ")") {
|
||||
if (!admitsOpenStepCloser(parent, character)) return;
|
||||
containers.pop();
|
||||
// parseOpenStep returns the first root value without reading trailing text.
|
||||
if (containers.length === 0) return;
|
||||
completeValue();
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
const separator = consumeOpenStepSeparator(parent, character);
|
||||
if (separator === "invalid") return;
|
||||
if (separator === "consumed") {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "{" || character === "(") {
|
||||
assertDepth();
|
||||
containers.push({
|
||||
end: character === "{" ? "}" : ")",
|
||||
next: character === "{" ? "key" : "value",
|
||||
});
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
const end = skipOpenStepScalar(text, cursor);
|
||||
if (end === cursor) return;
|
||||
if (parent?.next !== "key") assertDepth();
|
||||
cursor = end;
|
||||
completeValue();
|
||||
}
|
||||
};
|
||||
|
||||
/** Skip grammar trivia without interpreting delimiter examples inside comments. */
|
||||
const skipOpenStepTrivia = (text: string, cursor: number): number => {
|
||||
if (/\s/u.test(text[cursor] ?? "")) return cursor + 1;
|
||||
if (!text.startsWith("//", cursor) && !text.startsWith("/*", cursor))
|
||||
return cursor;
|
||||
const line = text[cursor + 1] === "/";
|
||||
const end = text.indexOf(line ? "\n" : "*/", cursor + 2);
|
||||
return end === -1 ? text.length : end + (line ? 1 : 2);
|
||||
};
|
||||
|
||||
/** Read the extent of one opaque scalar; the pinned decoder still owns its value. */
|
||||
const skipOpenStepScalar = (text: string, start: number): number => {
|
||||
let cursor = start;
|
||||
const character = text[cursor];
|
||||
if (character === '"') {
|
||||
cursor += 1;
|
||||
while (cursor < text.length) {
|
||||
const next = text[cursor];
|
||||
cursor += next === "\\" ? 2 : 1;
|
||||
if (next === '"') break;
|
||||
}
|
||||
} else if (character === "<") {
|
||||
const end = text.indexOf(">", cursor + 1);
|
||||
cursor = end === -1 ? text.length : end + 1;
|
||||
} else {
|
||||
// Match the pinned OpenStep decoder's unquoted-string alphabet. Other
|
||||
// syntax remains for the decoder to reject; preflight only owns depth.
|
||||
while (
|
||||
cursor < text.length &&
|
||||
/[a-zA-Z0-9._/$:-]/u.test(text[cursor] ?? "")
|
||||
)
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
};
|
||||
|
||||
/** Elements whose children the pinned plist value decoder visits recursively. */
|
||||
const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -662,7 +662,7 @@ describe("function reconstruction admission controls", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("rejects selected-dimension citations outside comparison closure", () => {
|
||||
it("rejects any comparison finding link outside the top-level closure", () => {
|
||||
const evidence = comparison(singular, {
|
||||
result: jsonValueSchema.parse({
|
||||
...baselineResult,
|
||||
|
|
@ -677,7 +677,7 @@ describe("function reconstruction admission controls", () => {
|
|||
}),
|
||||
});
|
||||
expect(admittedVerification(evidence, [left, right, extra])).toThrow(
|
||||
/dimension cites Evidence outside comparison closure/u,
|
||||
/finding cites Evidence outside its top-level closure/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ const source = (
|
|||
digit: string,
|
||||
authority: "controlled-replay" | "shipped-artifact",
|
||||
confidence: "observed" | "derived" = "observed",
|
||||
captureResult: JsonValue = EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
||||
{
|
||||
captureResult = EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
||||
replayEnvironment = environment,
|
||||
}: {
|
||||
readonly captureResult?: JsonValue;
|
||||
readonly replayEnvironment?: typeof environment | null;
|
||||
} = {},
|
||||
): Evidence =>
|
||||
createEvidence(
|
||||
{
|
||||
|
|
@ -43,7 +49,7 @@ const source = (
|
|||
result: authority === "controlled-replay" ? captureResult : { digit },
|
||||
confidence,
|
||||
authority,
|
||||
environment: authority === "controlled-replay" ? environment : null,
|
||||
environment: authority === "controlled-replay" ? replayEnvironment : null,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -113,6 +119,31 @@ const processComparison = (
|
|||
},
|
||||
);
|
||||
|
||||
const multiSourceProcessComparison = (
|
||||
left: readonly Evidence[],
|
||||
right: readonly Evidence[],
|
||||
): Evidence =>
|
||||
createEvidence(
|
||||
undefined,
|
||||
{
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "3",
|
||||
},
|
||||
{
|
||||
predicateType: "rea.process-comparison",
|
||||
operation: "compare_process_captures",
|
||||
parameters: {
|
||||
left_evidence_ids: left.map(({ evidence_id: id }) => id),
|
||||
right_evidence_ids: right.map(({ evidence_id: id }) => id),
|
||||
},
|
||||
result: processResult(),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: [...left, ...right].map(({ evidence_id: id }) => id),
|
||||
},
|
||||
);
|
||||
|
||||
const behavioralSpec = (comparison: Evidence) => ({
|
||||
name: "CLI compatibility",
|
||||
claims: [
|
||||
|
|
@ -288,12 +319,9 @@ describe("reconstruction verification", () => {
|
|||
|
||||
describe("reconstruction process capture input", () => {
|
||||
it("rejects saved captures without explicit executable identity", () => {
|
||||
const left = source(
|
||||
"1",
|
||||
"controlled-replay",
|
||||
"observed",
|
||||
legacyV3Capture(),
|
||||
);
|
||||
const left = source("1", "controlled-replay", "observed", {
|
||||
captureResult: legacyV3Capture(),
|
||||
});
|
||||
const right = source("2", "controlled-replay");
|
||||
const comparison = processComparison(left, right, processResult());
|
||||
expect(() =>
|
||||
|
|
@ -306,6 +334,49 @@ describe("reconstruction process capture input", () => {
|
|||
});
|
||||
|
||||
describe("reconstruction verification outcomes", () => {
|
||||
it("requires every selected behavioral source to share a replay environment", () => {
|
||||
const replay = (digit: string, id: string | null) =>
|
||||
source(digit, "controlled-replay", "observed", {
|
||||
replayEnvironment: id === null ? null : { ...environment, id },
|
||||
});
|
||||
const verify = (left: readonly Evidence[], right: readonly Evidence[]) => {
|
||||
const comparison = multiSourceProcessComparison(left, right);
|
||||
return verifyReconstruction(
|
||||
behavioralSpec(comparison),
|
||||
createEvidenceBundle([...left, ...right, comparison]),
|
||||
);
|
||||
};
|
||||
const limitation =
|
||||
"Controlled replay environments are unavailable or incompatible.";
|
||||
const differentOnLeft = verify(
|
||||
[replay("1", "env-a"), replay("2", "env-b")],
|
||||
[replay("3", "env-a"), replay("4", "env-a")],
|
||||
);
|
||||
expect(differentOnLeft.status).toBe("unknown");
|
||||
expect(differentOnLeft.claims.items[0]?.limitations).toContain(limitation);
|
||||
|
||||
const differentOnRight = verify(
|
||||
[replay("5", "env-a"), replay("6", "env-a")],
|
||||
[replay("7", "env-a"), replay("8", "env-b")],
|
||||
);
|
||||
expect(differentOnRight.status).toBe("unknown");
|
||||
expect(differentOnRight.claims.items[0]?.limitations).toContain(limitation);
|
||||
|
||||
const unavailable = verify(
|
||||
[replay("9", "env-a"), replay("a", "env-a")],
|
||||
[replay("b", "env-a"), replay("c", null)],
|
||||
);
|
||||
expect(unavailable.status).toBe("unknown");
|
||||
expect(unavailable.claims.items[0]?.limitations).toContain(limitation);
|
||||
|
||||
expect(
|
||||
verify(
|
||||
[replay("d", "env-a"), replay("e", "env-a")],
|
||||
[replay("f", "env-a"), replay("0", "env-a")],
|
||||
).status,
|
||||
).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails an authoritative observed disagreement", () => {
|
||||
const left = source("1", "controlled-replay");
|
||||
const right = source("2", "controlled-replay");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import { artifactComparisonResultSchema } from "./artifactComparison.js";
|
|||
import { artifactInventoryResultSchema } from "./artifactGraph.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||
import { comparisonSourceEvidenceSides } from "./comparisonSourceEvidence.js";
|
||||
import {
|
||||
comparisonSourceEvidenceSides,
|
||||
assertComparisonFindingLinks,
|
||||
} from "./comparisonSourceEvidence.js";
|
||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { parseEvidenceBundle } from "./evidenceBundle.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparisonSchemas.js";
|
||||
|
|
@ -176,6 +179,7 @@ const evaluateClaim = (
|
|||
unknown_ids: displayedUnknowns.map(({ unknown_id: id }) => id),
|
||||
limitations: uniqueSorted([
|
||||
...comparison.limitations,
|
||||
...observation.limitations,
|
||||
...limitations,
|
||||
...displayedUnknowns.map(
|
||||
({ question }) => `Residual unknown: ${question}`,
|
||||
|
|
@ -245,6 +249,7 @@ const comparisonObservation = (
|
|||
evidence: Evidence,
|
||||
): {
|
||||
readonly status: ObservedStatus;
|
||||
readonly limitations: readonly string[];
|
||||
readonly hasIndependentFunctionChange?: boolean;
|
||||
} => {
|
||||
if (claim.kind === "behavioral") {
|
||||
|
|
@ -255,16 +260,26 @@ const comparisonObservation = (
|
|||
return {
|
||||
status:
|
||||
claim.dimension === "overall" ? result.status : result[claim.dimension],
|
||||
limitations: [],
|
||||
};
|
||||
}
|
||||
if (claim.kind === "structural-function") {
|
||||
const result = functionComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assertComparisonFindingLinks(
|
||||
evidence,
|
||||
result.dimensions.flatMap(({ evidence_links }) => evidence_links),
|
||||
);
|
||||
if (claim.dimension === "overall")
|
||||
return {
|
||||
status: result.status,
|
||||
// Historical overall changes may come solely from incomplete call sets.
|
||||
limitations: result.dimensions.flatMap(
|
||||
({ limitations }) => limitations,
|
||||
),
|
||||
// Historical overall changes can be caused only by incomplete call
|
||||
// sets. Ignore that aggregate change when independently checking the
|
||||
// source call-coverage limitations.
|
||||
hasIndependentFunctionChange:
|
||||
result.function_match.status === "mismatched" ||
|
||||
(result.function_match.status === "matched" &&
|
||||
|
|
@ -279,28 +294,16 @@ const comparisonObservation = (
|
|||
);
|
||||
if (dimension === undefined)
|
||||
throw new TypeError("Function comparison dimension missing");
|
||||
if (
|
||||
dimension.evidence_links.some(
|
||||
(id) => !evidence.evidence_links.includes(id),
|
||||
)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Function dimension cites Evidence outside comparison closure",
|
||||
);
|
||||
return { status: dimension.status };
|
||||
return { status: dimension.status, limitations: dimension.limitations };
|
||||
}
|
||||
const result = artifactComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
if (
|
||||
result.changes.some((item) =>
|
||||
item.evidence_links.some((id) => !evidence.evidence_links.includes(id)),
|
||||
)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Artifact change cites Evidence outside comparison closure",
|
||||
);
|
||||
return { status: result.status };
|
||||
assertComparisonFindingLinks(
|
||||
evidence,
|
||||
result.changes.flatMap(({ evidence_links }) => evidence_links),
|
||||
);
|
||||
return { status: result.status, limitations: [] };
|
||||
};
|
||||
|
||||
const validateProcessStatus = (
|
||||
|
|
@ -346,13 +349,15 @@ const authorityLimitations = (
|
|||
)
|
||||
limitations.push("Structural source artifact identity is unavailable.");
|
||||
if (claim.kind === "behavioral") {
|
||||
const byId = new Map(sources.map((source) => [source.evidence_id, source]));
|
||||
const left = byId.get(sides.left[0] ?? "");
|
||||
const right = byId.get(sides.right[0] ?? "");
|
||||
const referenceEnvironment = sources[0]?.environment;
|
||||
if (
|
||||
left?.environment === null ||
|
||||
right?.environment === null ||
|
||||
JSON.stringify(left?.environment) !== JSON.stringify(right?.environment)
|
||||
referenceEnvironment === null ||
|
||||
referenceEnvironment === undefined ||
|
||||
sources.some(
|
||||
({ environment }) =>
|
||||
environment === null ||
|
||||
JSON.stringify(environment) !== JSON.stringify(referenceEnvironment),
|
||||
)
|
||||
)
|
||||
limitations.push(
|
||||
"Controlled replay environments are unavailable or incompatible.",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { INVESTIGATION_EXAMPLES } from "../contracts/investigationExamples.js";
|
||||
import { createEvidence, type Evidence } from "./evidence.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../contracts/functionComparisonExample.js";
|
||||
import { functionDossierSchema } from "./hopperValues.js";
|
||||
import { compareFunctions } from "./functionComparison.js";
|
||||
import {
|
||||
correlateStaticAndRuntime,
|
||||
staticRuntimeCorrelationInputSchema,
|
||||
|
|
@ -113,6 +116,70 @@ const mapping = (
|
|||
],
|
||||
});
|
||||
|
||||
it("keeps text, assembly and unavailable CFG caveats on their selected correlations", () => {
|
||||
const source = FUNCTION_COMPARISON_EXAMPLE.left;
|
||||
const left = createEvidence(
|
||||
{ path: "/tmp/unavailable-cfg", sha256: "a".repeat(64), format: "mach-o" },
|
||||
source.provider,
|
||||
{
|
||||
operation: source.operation,
|
||||
parameters: source.parameters,
|
||||
result: jsonValueSchema.parse({
|
||||
...functionDossierSchema.parse(source.normalized_result),
|
||||
basic_blocks: [{ start: "0x1000", end: "0x1000", successors: [] }],
|
||||
}),
|
||||
confidence: source.confidence,
|
||||
authority: source.authority,
|
||||
},
|
||||
);
|
||||
const right = FUNCTION_COMPARISON_EXAMPLE.right;
|
||||
const computed = compareFunctions(left, right);
|
||||
const comparison = createEvidence(undefined, staticComparison.provider, {
|
||||
predicateType: "rea.function-comparison",
|
||||
operation: "compare_functions",
|
||||
parameters: {
|
||||
left_evidence_id: left.evidence_id,
|
||||
right_evidence_id: right.evidence_id,
|
||||
},
|
||||
result: jsonValueSchema.parse(computed),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: computed.limitations,
|
||||
evidenceLinks: [left.evidence_id, right.evidence_id],
|
||||
});
|
||||
const input = mapping(processComparison("unchanged"), "static_only");
|
||||
const result = correlateStaticAndRuntime({
|
||||
...input,
|
||||
static_comparisons: [comparison],
|
||||
mappings: ["pseudocode", "assembly", "cfg"].map((dimension) => ({
|
||||
...input.mappings[0],
|
||||
static: {
|
||||
comparison_evidence_id: comparison.evidence_id,
|
||||
selector: { kind: "function", dimension },
|
||||
},
|
||||
})),
|
||||
});
|
||||
expect(result.status).toBe("unknown");
|
||||
const caveats = new Map(
|
||||
result.correlations.items.map((item) => [
|
||||
item.static.selector.kind === "function"
|
||||
? item.static.selector.dimension
|
||||
: "artifact",
|
||||
item.limitations.join(" "),
|
||||
]),
|
||||
);
|
||||
expect(caveats.get("pseudocode")).toMatch(/generated-name normalization/u);
|
||||
expect(caveats.get("pseudocode")).not.toMatch(
|
||||
/Assembly is opaque|CFG addresses/u,
|
||||
);
|
||||
expect(caveats.get("assembly")).toMatch(/Assembly is opaque/u);
|
||||
expect(caveats.get("assembly")).not.toMatch(/CFG addresses/u);
|
||||
expect(caveats.get("cfg")).toMatch(/CFG addresses/u);
|
||||
expect(caveats.get("cfg")).not.toMatch(
|
||||
/generated-name normalization|Assembly is opaque/u,
|
||||
);
|
||||
});
|
||||
|
||||
describe("static/runtime correlation", () => {
|
||||
it("accepts real runtime dimensions and rejects removed protocol dimensions", () => {
|
||||
const runtime = processComparison("unchanged");
|
||||
|
|
@ -154,6 +221,9 @@ describe("static/runtime correlation", () => {
|
|||
expect(result.limitations.join(" ")).toMatch(
|
||||
/does not establish causality/u,
|
||||
);
|
||||
expect(result.correlations.items[0]?.limitations.join(" ")).toMatch(
|
||||
/generated-name normalization/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies an explicit expectation mismatch as contradiction", () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { z } from "zod";
|
|||
import { artifactComparisonResultSchema } from "./artifactComparison.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||
import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js";
|
||||
import {
|
||||
comparisonSourceEvidenceIds,
|
||||
assertComparisonFindingLinks,
|
||||
} from "./comparisonSourceEvidence.js";
|
||||
import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparisonSchemas.js";
|
||||
import {
|
||||
|
|
@ -236,7 +239,10 @@ const correlate = (
|
|||
mapping.runtime.comparison_evidence_id,
|
||||
"runtime comparison",
|
||||
);
|
||||
const selectedStatic = selectStatic(staticEvidence, mapping.static.selector);
|
||||
const { limitations: selectedLimitations, ...selectedStatic } = selectStatic(
|
||||
staticEvidence,
|
||||
mapping.static.selector,
|
||||
);
|
||||
const process = processCaptureComparisonSchema.parse(
|
||||
runtimeEvidence.normalized_result,
|
||||
);
|
||||
|
|
@ -270,6 +276,7 @@ const correlate = (
|
|||
]),
|
||||
limitations: uniqueSorted([
|
||||
...staticEvidence.limitations,
|
||||
...selectedLimitations,
|
||||
...runtimeEvidence.limitations,
|
||||
...process.limitations,
|
||||
"This correlation tests an explicit hypothesis and does not establish causality.",
|
||||
|
|
@ -281,7 +288,9 @@ const correlate = (
|
|||
const selectStatic = (
|
||||
evidence: Evidence,
|
||||
selector: z.infer<typeof staticSelectorSchema>,
|
||||
): Pick<CorrelationItem["static"], "status" | "conclusion_kind"> => {
|
||||
): Pick<CorrelationItem["static"], "status" | "conclusion_kind"> & {
|
||||
readonly limitations: readonly string[];
|
||||
} => {
|
||||
if (selector.kind === "function") {
|
||||
if (evidence.operation !== "compare_functions")
|
||||
throw new TypeError(
|
||||
|
|
@ -299,6 +308,7 @@ const selectStatic = (
|
|||
return {
|
||||
status: match.status,
|
||||
conclusion_kind: match.conclusion_kind,
|
||||
limitations: match.limitations,
|
||||
};
|
||||
}
|
||||
if (evidence.operation !== "compare_artifacts")
|
||||
|
|
@ -319,6 +329,7 @@ const selectStatic = (
|
|||
const status = match.classification;
|
||||
return {
|
||||
status,
|
||||
limitations: [],
|
||||
conclusion_kind:
|
||||
status === "contradiction"
|
||||
? "contradiction"
|
||||
|
|
@ -342,7 +353,7 @@ const parseStatic = (input: unknown): Evidence => {
|
|||
const result = artifactComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assertNestedLinks(
|
||||
assertComparisonFindingLinks(
|
||||
evidence,
|
||||
result.changes.flatMap(({ evidence_links: links }) => links),
|
||||
);
|
||||
|
|
@ -350,7 +361,7 @@ const parseStatic = (input: unknown): Evidence => {
|
|||
const result = functionComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
assertNestedLinks(
|
||||
assertComparisonFindingLinks(
|
||||
evidence,
|
||||
result.dimensions.flatMap(({ evidence_links: links }) => links),
|
||||
);
|
||||
|
|
@ -358,17 +369,6 @@ const parseStatic = (input: unknown): Evidence => {
|
|||
return evidence;
|
||||
};
|
||||
|
||||
const assertNestedLinks = (
|
||||
evidence: Evidence,
|
||||
nestedLinks: readonly string[],
|
||||
): void => {
|
||||
const closure = new Set(evidence.evidence_links);
|
||||
if (nestedLinks.some((evidenceId) => !closure.has(evidenceId)))
|
||||
throw new TypeError(
|
||||
"Comparison finding cites Evidence outside its top-level closure",
|
||||
);
|
||||
};
|
||||
|
||||
const parseRuntime = (input: unknown): Evidence => {
|
||||
const evidence = parseEvidence(input);
|
||||
if (evidence.operation !== "compare_process_captures")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export class GoBinaryFormatFailure extends Error {
|
|||
/** Resource boundary exceeded while decoding metadata or native container structure. */
|
||||
export class GoBinaryResourceFailure extends Error {
|
||||
constructor(
|
||||
readonly boundary: "build-info" | "structure",
|
||||
readonly boundary: "build-info" | "structure" | "search",
|
||||
readonly maximum_bytes: number,
|
||||
message: string,
|
||||
) {
|
||||
|
|
@ -44,7 +44,7 @@ export interface GoImageSection {
|
|||
readonly compressed: boolean;
|
||||
}
|
||||
|
||||
/** Native container facts and its producer-defined build-info search area. */
|
||||
/** Native container facts and its ordered producer-defined build-info search ranges. */
|
||||
export interface GoBinaryContainer {
|
||||
readonly format: "elf" | "pe" | "macho";
|
||||
readonly architecture: string;
|
||||
|
|
@ -53,7 +53,7 @@ export interface GoBinaryContainer {
|
|||
readonly mappings: readonly GoFileMapping[];
|
||||
readonly zero_fills: readonly GoZeroFillMapping[];
|
||||
readonly sections: readonly GoImageSection[];
|
||||
readonly search: GoFileMapping | null;
|
||||
readonly search: readonly GoFileMapping[];
|
||||
}
|
||||
|
||||
/** Reader that checks every structural range before decoding numeric fields. */
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const MAGIC = Buffer.from("ff20476f206275696c64696e663a", "hex");
|
|||
const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex");
|
||||
const MODULE_END = Buffer.from("f932433186182072008242104116d8f2", "hex");
|
||||
const MAX_BUILD_INFO_BYTES = 1024 * 1024;
|
||||
const MAX_HEADER_SEARCH_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/** Exact file span of one embedded string's contents, excluding its length/header. */
|
||||
export interface GoStringLocation {
|
||||
|
|
@ -57,10 +58,31 @@ export interface GoBinaryImage {
|
|||
export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
|
||||
const container = readGoBinaryContainer(bytes);
|
||||
const { format, architecture, bits, byte_order } = container;
|
||||
const search = container.search;
|
||||
const header = findHeader(bytes, search);
|
||||
if (header === null || search === null)
|
||||
let selected: { search: GoFileMapping; header: number } | null = null;
|
||||
const searched = new Set<string>();
|
||||
let remainingSearchBytes = MAX_HEADER_SEARCH_BYTES;
|
||||
for (const search of container.search) {
|
||||
// Identical file spans and alignment inspect the same candidate bytes.
|
||||
// Partially overlapping spans still consume a cumulative work budget.
|
||||
const identity = `${search.offset}:${search.size}:${search.address % 16n}`;
|
||||
if (searched.has(identity)) continue;
|
||||
searched.add(identity);
|
||||
if (search.size > remainingSearchBytes)
|
||||
throw new GoBinaryResourceFailure(
|
||||
"search",
|
||||
MAX_HEADER_SEARCH_BYTES,
|
||||
"Go build-info discovery exceeds the cumulative 256 MiB header-search budget",
|
||||
);
|
||||
remainingSearchBytes -= search.size;
|
||||
const header = findHeader(bytes, search);
|
||||
if (header !== null) {
|
||||
selected = { search, header };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected === null)
|
||||
return { format, architecture, bits, byte_order, build_info: null };
|
||||
const { search, header } = selected;
|
||||
const verifyMapping = (size: number): void => {
|
||||
const address = search.address + BigInt(header - search.offset);
|
||||
if (
|
||||
|
|
@ -222,11 +244,8 @@ export const readGoBinaryContainer = (bytes: Buffer): GoBinaryContainer => {
|
|||
);
|
||||
};
|
||||
|
||||
const findHeader = (
|
||||
bytes: Buffer,
|
||||
search: GoFileMapping | null,
|
||||
): number | null => {
|
||||
if (search === null || search.size === 0) return null;
|
||||
const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => {
|
||||
if (search.size === 0) return null;
|
||||
const end = search.offset + search.size;
|
||||
const selected = bytes.subarray(search.offset, end);
|
||||
let offset = Number((16n - (search.address % 16n)) % 16n);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
|
|||
const mappings: GoFileMapping[] = [];
|
||||
const zeroFills: GoZeroFillMapping[] = [];
|
||||
const sections: GoImageSection[] = [];
|
||||
let fallback: GoFileMapping | null = null;
|
||||
const fallback: GoFileMapping[] = [];
|
||||
for (let index = 0; index < phCount; index++) {
|
||||
const offset = phStart + index * phWidth;
|
||||
if (reader.u32(offset) !== 1) continue;
|
||||
|
|
@ -126,7 +126,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
|
|||
if (mapping.size !== 0) mappings.push(mapping);
|
||||
if (memorySize > size)
|
||||
zeroFills.push({ address: address + size, size: memorySize - size });
|
||||
if (fallback === null && (flags & 3) === 2) fallback = mapping;
|
||||
if (mapping.size !== 0 && (flags & 3) === 2) fallback.push(mapping);
|
||||
}
|
||||
let search: GoFileMapping | null = null;
|
||||
if (shCount !== 0) {
|
||||
|
|
@ -226,7 +226,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
|
|||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
sections,
|
||||
search: search ?? fallback,
|
||||
search: search === null ? fallback : [search],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
|
|||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
sections,
|
||||
search: named[0] ?? fallback,
|
||||
search: named.length > 0 ? named : fallback === null ? [] : [fallback],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
|
|||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
sections,
|
||||
search,
|
||||
search: search === null ? [] : [search],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export const pushDispatchCoverage = (input: {
|
|||
readonly examined: number;
|
||||
readonly categoriesExamined: number;
|
||||
readonly truncated: boolean;
|
||||
readonly objcListScanTruncated: boolean;
|
||||
readonly objcClassListScanTruncated: boolean;
|
||||
readonly fixups: PointerFixups;
|
||||
readonly vtables: FacetDecodeFacts;
|
||||
readonly swift: FacetDecodeFacts;
|
||||
|
|
@ -38,6 +40,8 @@ export const pushDispatchCoverage = (input: {
|
|||
examined,
|
||||
categoriesExamined,
|
||||
truncated,
|
||||
objcListScanTruncated,
|
||||
objcClassListScanTruncated,
|
||||
fixups,
|
||||
vtables,
|
||||
swift,
|
||||
|
|
@ -78,7 +82,7 @@ export const pushDispatchCoverage = (input: {
|
|||
const classReasons = [
|
||||
...failures,
|
||||
...classRecords,
|
||||
...(truncated ? ["max_records_reached"] : []),
|
||||
...(truncated || objcListScanTruncated ? ["max_records_reached"] : []),
|
||||
];
|
||||
const categoryMethods = result.objc_dispatch_implementations.filter(
|
||||
({ category }) => category !== undefined,
|
||||
|
|
@ -99,20 +103,18 @@ export const pushDispatchCoverage = (input: {
|
|||
({ decode }) => decode?.status === "decoded",
|
||||
).length,
|
||||
});
|
||||
const relativePointerReasons = [
|
||||
...(truncated || objcClassListScanTruncated ? ["max_records_reached"] : []),
|
||||
...(result.relative_pointers.some(
|
||||
(item) => item.decode.status !== "decoded",
|
||||
)
|
||||
? ["relative_pointer_targets_unresolved"]
|
||||
: []),
|
||||
];
|
||||
result.coverage.push({
|
||||
facet: "binary_relative_pointers",
|
||||
status:
|
||||
truncated ||
|
||||
result.relative_pointers.some((item) => item.decode.status !== "decoded")
|
||||
? "partial"
|
||||
: "complete",
|
||||
reason: truncated
|
||||
? "max_records_reached"
|
||||
: result.relative_pointers.some(
|
||||
(item) => item.decode.status !== "decoded",
|
||||
)
|
||||
? "relative_pointer_targets_unresolved"
|
||||
: null,
|
||||
status: relativePointerReasons.length > 0 ? "partial" : "complete",
|
||||
reason: relativePointerReasons.join("; ") || null,
|
||||
examined: result.relative_pointers.length,
|
||||
decoded: result.relative_pointers.filter(
|
||||
(item) => item.decode.status === "decoded",
|
||||
|
|
@ -126,6 +128,7 @@ export const pushDispatchCoverage = (input: {
|
|||
facet: "objc_properties_categories",
|
||||
status:
|
||||
truncated ||
|
||||
objcClassListScanTruncated ||
|
||||
categoryFailures.length > 0 ||
|
||||
result.objc_categories.some(
|
||||
({ decode }) => decode.status !== "decoded",
|
||||
|
|
@ -137,7 +140,9 @@ export const pushDispatchCoverage = (input: {
|
|||
[
|
||||
...categoryFailures,
|
||||
...categoryMethodReasons,
|
||||
...(truncated ? ["max_records_reached"] : []),
|
||||
...(truncated || objcClassListScanTruncated
|
||||
? ["max_records_reached"]
|
||||
: []),
|
||||
].join("; ") || null,
|
||||
examined: categoriesExamined,
|
||||
decoded: result.objc_categories.filter(
|
||||
|
|
|
|||
|
|
@ -115,6 +115,50 @@ const fixture = (
|
|||
return bytes;
|
||||
};
|
||||
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
|
||||
|
||||
const partialSiblingsFixture = (
|
||||
options: {
|
||||
readonly emptyMiddleClass?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const bytes = Buffer.alloc(4096);
|
||||
const imageBase = 0x100000000n;
|
||||
bytes.writeUInt32LE(0xfeedfacf, 0);
|
||||
bytes.writeUInt32LE(0x0100000c, 4);
|
||||
bytes.writeUInt32LE(2, 12);
|
||||
bytes.writeUInt32LE(1, 16);
|
||||
bytes.writeUInt32LE(232, 20);
|
||||
bytes.writeUInt32LE(0x19, 32);
|
||||
bytes.writeUInt32LE(232, 36);
|
||||
bytes.write("__DATA", 40);
|
||||
bytes.writeBigUInt64LE(imageBase, 56);
|
||||
bytes.writeBigUInt64LE(4096n, 64);
|
||||
bytes.writeBigUInt64LE(4096n, 80);
|
||||
bytes.writeUInt32LE(5, 92);
|
||||
bytes.writeUInt32LE(2, 96);
|
||||
bytes.write("__objc_classlist", 104);
|
||||
bytes.writeBigUInt64LE(imageBase + 0x200n, 136);
|
||||
bytes.writeBigUInt64LE(24n, 144);
|
||||
bytes.write("__objc_protolist", 184);
|
||||
bytes.writeBigUInt64LE(imageBase + 0x300n, 216);
|
||||
bytes.writeBigUInt64LE(8n, 224);
|
||||
for (const [slot, record, ro, name] of [
|
||||
[0x200, 0x400, 0x500, 0x600],
|
||||
[0x208, 0x440, 0x540, 0x620],
|
||||
[0x210, 0x480, 0x580, 0x640],
|
||||
] as const) {
|
||||
bytes.writeBigUInt64LE(imageBase + BigInt(record), slot);
|
||||
bytes.writeBigUInt64LE(imageBase + BigInt(ro), record + 32);
|
||||
bytes.writeUInt32LE(2, ro);
|
||||
bytes.writeUInt32LE(16, ro + 8);
|
||||
bytes.writeBigUInt64LE(imageBase + BigInt(name), ro + 24);
|
||||
}
|
||||
bytes.write("KeptClass\0", 0x600);
|
||||
if (!options.emptyMiddleClass) bytes.write("OtherClass\0", 0x620);
|
||||
bytes.write("LaterClass\0", 0x640);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe("Apple dispatch pointer fixup metadata", () => {
|
||||
it("keeps decoded bindings and their coverage when an independent stream fails", () => {
|
||||
const bytes = fixture();
|
||||
|
|
@ -367,6 +411,23 @@ describe("Apple dispatch coverage ownership", () => {
|
|||
});
|
||||
|
||||
describe("malformed Apple dispatch metadata", () => {
|
||||
it("retains valid class siblings when one class has an empty name", () => {
|
||||
const result = decodeAppleDispatchMetadata(
|
||||
partialSiblingsFixture({ emptyMiddleClass: true }),
|
||||
100,
|
||||
provenance,
|
||||
);
|
||||
expect(result.objc_classes.map(({ name }) => name)).toEqual([
|
||||
"KeptClass",
|
||||
"LaterClass",
|
||||
]);
|
||||
expect(
|
||||
result.coverage.find(
|
||||
({ facet }) => facet === "objc_class_method_ivar_metadata",
|
||||
),
|
||||
).toMatchObject({ status: "partial", examined: 3 });
|
||||
});
|
||||
|
||||
it("rejects malformed command and section boundaries", () => {
|
||||
const bytes = fixture();
|
||||
bytes.writeUInt32LE(7, 36);
|
||||
|
|
@ -530,3 +591,218 @@ describe("byte-swapped universal Apple dispatch metadata", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
const unmappedProtocolSectionBase = 0x100000000n;
|
||||
const unmappedProtocolImage = (bad: "protocol" | "class" | "neither") => {
|
||||
const bytes = Buffer.alloc(4096);
|
||||
bytes.writeUInt32LE(0xfeedfacf, 0);
|
||||
bytes.writeUInt32LE(0x0100000c, 4);
|
||||
bytes.writeUInt32LE(2, 12);
|
||||
bytes.writeUInt32LE(1, 16);
|
||||
bytes.writeUInt32LE(232, 20);
|
||||
bytes.writeUInt32LE(0x19, 32);
|
||||
bytes.writeUInt32LE(232, 36);
|
||||
bytes.write("__DATA", 40);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase, 56);
|
||||
bytes.writeBigUInt64LE(4096n, 64);
|
||||
bytes.writeBigUInt64LE(4096n, 80);
|
||||
bytes.writeUInt32LE(5, 92);
|
||||
bytes.writeUInt32LE(2, 96);
|
||||
bytes.write("__objc_classlist", 104);
|
||||
bytes.writeBigUInt64LE(
|
||||
unmappedProtocolSectionBase + (bad === "class" ? 0x9000n : 0x200n),
|
||||
136,
|
||||
);
|
||||
bytes.writeBigUInt64LE(8n, 144);
|
||||
bytes.write("__objc_protolist", 184);
|
||||
bytes.writeBigUInt64LE(
|
||||
unmappedProtocolSectionBase + (bad === "protocol" ? 0x9000n : 0x300n),
|
||||
216,
|
||||
);
|
||||
bytes.writeBigUInt64LE(8n, 224);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x400n, 0x200);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x500n, 0x420);
|
||||
bytes.writeUInt32LE(2, 0x500);
|
||||
bytes.writeUInt32LE(16, 0x508);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x600n, 0x518);
|
||||
bytes.write("RetainedClass\0", 0x600);
|
||||
return bytes;
|
||||
};
|
||||
const unmappedProtocolClassCoverage = (result: {
|
||||
coverage: { facet: string; status: string; reason: string | null }[];
|
||||
}) =>
|
||||
result.coverage.find(
|
||||
({ facet }) => facet === "objc_class_method_ivar_metadata",
|
||||
);
|
||||
|
||||
describe("Apple dispatch unmapped protocol-list slot", () => {
|
||||
it("returns the class when every list slot is mapped", () => {
|
||||
const result = decodeAppleDispatchMetadata(
|
||||
unmappedProtocolImage("neither"),
|
||||
100,
|
||||
provenance,
|
||||
);
|
||||
expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]);
|
||||
expect(unmappedProtocolClassCoverage(result)).toMatchObject({
|
||||
status: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("records partial coverage when the class-list slot is unmapped", () => {
|
||||
const result = decodeAppleDispatchMetadata(
|
||||
unmappedProtocolImage("class"),
|
||||
100,
|
||||
provenance,
|
||||
);
|
||||
expect(unmappedProtocolClassCoverage(result)).toMatchObject({
|
||||
status: "partial",
|
||||
reason: expect.stringContaining("0x100009000"),
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the class and partial coverage when the protocol-list slot is unmapped", () => {
|
||||
const result = decodeAppleDispatchMetadata(
|
||||
unmappedProtocolImage("protocol"),
|
||||
100,
|
||||
provenance,
|
||||
);
|
||||
expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]);
|
||||
expect(unmappedProtocolClassCoverage(result)).toMatchObject({
|
||||
status: "partial",
|
||||
reason: expect.stringContaining("0x100009000"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apple dispatch list scan budget", () => {
|
||||
it("bounds failed protocol-list slots by the record budget", () => {
|
||||
// The section size is file-declared, so a crafted image can claim an
|
||||
// arbitrarily large unmapped protocol list. Failed slots must spend the
|
||||
// record budget like decoded ones, otherwise the loop runs unbounded.
|
||||
const bytes = unmappedProtocolImage("protocol");
|
||||
bytes.writeBigUInt64LE(80000n, 224);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
|
||||
const coverage = unmappedProtocolClassCoverage(result);
|
||||
expect(coverage).toMatchObject({ status: "partial" });
|
||||
expect(coverage?.reason).toContain("max_records_reached");
|
||||
const failures =
|
||||
coverage?.reason?.match(/Unmapped or ambiguous metadata pointer/g) ?? [];
|
||||
expect(failures.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("keeps independent facets complete when only zero-pointer list scanning is capped", () => {
|
||||
const bytes = unmappedProtocolImage("neither");
|
||||
bytes.writeBigUInt64LE(0n, 144);
|
||||
bytes.writeBigUInt64LE(16n, 224);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 1, provenance);
|
||||
expect(unmappedProtocolClassCoverage(result)).toMatchObject({
|
||||
status: "partial",
|
||||
reason: expect.stringContaining("max_records_reached"),
|
||||
});
|
||||
for (const facet of [
|
||||
"objc_properties_categories",
|
||||
"binary_relative_pointers",
|
||||
])
|
||||
expect(
|
||||
result.coverage.find((item) => item.facet === facet),
|
||||
).toMatchObject({
|
||||
status: "complete",
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks class-dependent coverage partial when zero slots hide a later class", () => {
|
||||
const bytes = unmappedProtocolImage("neither");
|
||||
bytes.writeBigUInt64LE(0n, 224);
|
||||
bytes.writeBigUInt64LE(16n, 144);
|
||||
bytes.writeBigUInt64LE(0n, 0x200);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x400n, 0x208);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 1, provenance);
|
||||
expect(result.objc_classes).toEqual([]);
|
||||
for (const facet of [
|
||||
"objc_class_method_ivar_metadata",
|
||||
"objc_properties_categories",
|
||||
"binary_relative_pointers",
|
||||
])
|
||||
expect(
|
||||
result.coverage.find((item) => item.facet === facet),
|
||||
).toMatchObject({
|
||||
status: "partial",
|
||||
reason: expect.stringContaining("max_records_reached"),
|
||||
});
|
||||
for (const facet of [
|
||||
"swift_class_vtable_descriptors",
|
||||
"swift_conformances_static_witness_slots",
|
||||
])
|
||||
expect(
|
||||
result.coverage.find((item) => item.facet === facet),
|
||||
).toMatchObject({ status: "complete" });
|
||||
});
|
||||
|
||||
it("bounds failed class-list slots by the record budget", () => {
|
||||
// Same file-declared-size hazard as the protocol list: a crafted image
|
||||
// can declare an arbitrarily large unmapped class list. Failed class
|
||||
// slots must spend the record budget like decoded ones, otherwise the
|
||||
// loop runs unbounded (each failed read produced an unbounded failure
|
||||
// string with no budget spend).
|
||||
const bytes = unmappedProtocolImage("class");
|
||||
bytes.writeBigUInt64LE(80000n, 144);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
|
||||
const coverage = unmappedProtocolClassCoverage(result);
|
||||
expect(coverage).toMatchObject({ status: "partial" });
|
||||
expect(coverage?.reason).toContain("max_records_reached");
|
||||
const failures =
|
||||
coverage?.reason?.match(/Unmapped or ambiguous metadata pointer/g) ?? [];
|
||||
expect(failures.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("reports the real failure for a mapped-but-malformed class at the budget boundary", () => {
|
||||
// The class-list slot itself is mapped, so readClass admits the record
|
||||
// budget and only then throws on the unmapped class_ro pointer. The
|
||||
// failure must be recorded as-is: it already spent one budget unit, so
|
||||
// admitting again would break before the reason is appended and report
|
||||
// only max_records_reached.
|
||||
const bytes = unmappedProtocolImage("neither");
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x700n, 0x200);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x9000n, 0x720);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 1, provenance);
|
||||
const coverage = unmappedProtocolClassCoverage(result);
|
||||
expect(coverage).toMatchObject({ status: "partial" });
|
||||
expect(coverage?.reason).toContain(
|
||||
"Unmapped or ambiguous metadata pointer 0x100009018",
|
||||
);
|
||||
expect(coverage?.reason).not.toContain("max_records_reached");
|
||||
});
|
||||
|
||||
it("does not skip a valid class after a mapped-but-malformed class", () => {
|
||||
// With two slots and a budget of two, the corrupt first slot must spend
|
||||
// exactly one unit: admitting twice would exhaust the budget and skip
|
||||
// the valid class behind it.
|
||||
const bytes = unmappedProtocolImage("neither");
|
||||
bytes.writeBigUInt64LE(16n, 144);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x700n, 0x200);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x9000n, 0x720);
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x400n, 0x208);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 2, provenance);
|
||||
expect(result.objc_classes).toMatchObject([{ name: "RetainedClass" }]);
|
||||
expect(unmappedProtocolClassCoverage(result)?.reason).toContain(
|
||||
"Unmapped or ambiguous metadata pointer 0x100009018",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the real failure for a mapped-but-truncated protocol slot", () => {
|
||||
// The protocol slot is mapped, so protocolReader.record admits the
|
||||
// budget itself before throwing on the truncated declaration. The loop
|
||||
// must not admit a second time; the recorded reason stays the
|
||||
// declaration failure, not max_records_reached.
|
||||
const bytes = unmappedProtocolImage("neither");
|
||||
bytes.writeBigUInt64LE(unmappedProtocolSectionBase + 0x700n, 0x300);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
|
||||
const coverage = unmappedProtocolClassCoverage(result);
|
||||
expect(coverage).toMatchObject({ status: "partial" });
|
||||
expect(coverage?.reason).toContain(
|
||||
"Truncated Objective-C protocol declaration",
|
||||
);
|
||||
expect(coverage?.reason).not.toContain("max_records_reached");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,15 +105,22 @@ export const decodeAppleDispatchMetadata = (
|
|||
evidence,
|
||||
offset,
|
||||
};
|
||||
const { failures, categoryIssues, examined, categoriesExamined } =
|
||||
decodeObjcDispatchFacets({
|
||||
bytes,
|
||||
sections,
|
||||
segments,
|
||||
readers,
|
||||
budget,
|
||||
result,
|
||||
});
|
||||
const {
|
||||
failures,
|
||||
categoryIssues,
|
||||
examined,
|
||||
categoriesExamined,
|
||||
objcListScanTruncated,
|
||||
objcClassListScanTruncated,
|
||||
} = decodeObjcDispatchFacets({
|
||||
bytes,
|
||||
sections,
|
||||
segments,
|
||||
readers,
|
||||
budget,
|
||||
maxListSlots: maxRecords,
|
||||
result,
|
||||
});
|
||||
const swiftRelative = createSwiftRelativeReader({
|
||||
bytes,
|
||||
readers,
|
||||
|
|
@ -163,6 +170,8 @@ export const decodeAppleDispatchMetadata = (
|
|||
categoryIssues,
|
||||
examined,
|
||||
categoriesExamined,
|
||||
objcListScanTruncated,
|
||||
objcClassListScanTruncated,
|
||||
truncated: budget.truncated,
|
||||
fixups,
|
||||
vtables,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js";
|
||||
import type { Segment, Section } from "./AppleMachoSelection.js";
|
||||
import { boundClassName, decodeObjcCategories } from "./AppleObjcCategories.js";
|
||||
|
|
@ -67,14 +68,25 @@ export const decodeObjcDispatchFacets = (input: {
|
|||
segments: readonly Segment[];
|
||||
readers: ObjcDispatchReaders;
|
||||
budget: DispatchRecordBudget;
|
||||
maxListSlots: number;
|
||||
result: ObjcSwiftMetadata;
|
||||
}): {
|
||||
failures: string[];
|
||||
categoryIssues: DecodeIssue[];
|
||||
examined: number;
|
||||
categoriesExamined: number;
|
||||
objcListScanTruncated: boolean;
|
||||
objcClassListScanTruncated: boolean;
|
||||
} => {
|
||||
const { bytes, sections, segments, readers: read, budget, result } = input;
|
||||
const {
|
||||
bytes,
|
||||
sections,
|
||||
segments,
|
||||
readers: read,
|
||||
budget,
|
||||
maxListSlots,
|
||||
result,
|
||||
} = input;
|
||||
const failures: string[] = [];
|
||||
const categoryIssues: DecodeIssue[] = [];
|
||||
let examined = 0;
|
||||
|
|
@ -190,6 +202,33 @@ export const decodeObjcDispatchFacets = (input: {
|
|||
result,
|
||||
failures,
|
||||
});
|
||||
// Each list scan has its own slot bound: zero and duplicate pointers do
|
||||
// not produce records, but still consume pointer-reading work. The bound
|
||||
// is per list so one list's slots cannot starve the other list's scan.
|
||||
// Record decoders retain their existing admission ownership.
|
||||
let objcListScanTruncated = false;
|
||||
let objcClassListScanTruncated = false;
|
||||
const createListSlotReader = () => {
|
||||
let listSlotsExamined = 0;
|
||||
return (field: bigint): Result<bigint, "unmapped" | "exhausted"> => {
|
||||
if (listSlotsExamined >= maxListSlots) {
|
||||
objcListScanTruncated = true;
|
||||
return err("exhausted");
|
||||
}
|
||||
listSlotsExamined++;
|
||||
try {
|
||||
return ok(read.pointer(field));
|
||||
} catch (cause: unknown) {
|
||||
const admitted = budget.admit();
|
||||
failures.push(
|
||||
`${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
return err(admitted ? "unmapped" : "exhausted");
|
||||
}
|
||||
};
|
||||
};
|
||||
const readProtocolListSlot = createListSlotReader();
|
||||
const readClassListSlot = createListSlotReader();
|
||||
for (const section of sections.filter(
|
||||
({ name }) => name === "__objc_protolist",
|
||||
)) {
|
||||
|
|
@ -200,7 +239,21 @@ export const decodeObjcDispatchFacets = (input: {
|
|||
budget.markTruncated();
|
||||
break;
|
||||
}
|
||||
protocolReader.record(read.pointer(section.address + BigInt(index * 8)));
|
||||
const field = section.address + BigInt(index * 8);
|
||||
const pointer = readProtocolListSlot(field);
|
||||
if (!pointer.ok) {
|
||||
if (pointer.error === "exhausted") break;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
protocolReader.record(pointer.value);
|
||||
} catch (cause) {
|
||||
// record() admits the slot itself before decoding, so admitting
|
||||
// again here would count one protocol twice.
|
||||
failures.push(
|
||||
`${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const visited = new Set<string>();
|
||||
|
|
@ -209,6 +262,10 @@ export const decodeObjcDispatchFacets = (input: {
|
|||
visited.add(hex(address));
|
||||
const ro = read.pointer(address + 32n) & ~7n;
|
||||
const name = read.string(read.pointer(ro + 24n));
|
||||
if (name.length === 0)
|
||||
throw new TypeError(
|
||||
`Objective-C class at ${hex(address)} has an empty name`,
|
||||
);
|
||||
const root = (read.u32(ro) & 2) !== 0;
|
||||
const superclass = resolveSuperclass(read, address, name, failures);
|
||||
if (superclass.name === null && superclass.address === null && !root)
|
||||
|
|
@ -320,11 +377,24 @@ export const decodeObjcDispatchFacets = (input: {
|
|||
budget.markTruncated();
|
||||
break;
|
||||
}
|
||||
examined++;
|
||||
const field = section.address + BigInt(index * 8);
|
||||
const pointer = readClassListSlot(field);
|
||||
if (pointer.ok || pointer.error === "unmapped") examined++;
|
||||
if (!pointer.ok) {
|
||||
if (pointer.error === "exhausted") {
|
||||
objcClassListScanTruncated = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
readClass(read.pointer(field));
|
||||
readClass(pointer.value);
|
||||
} catch (cause) {
|
||||
// The slot was already admitted inside readClass before anything
|
||||
// could throw (its admit runs before the first pointer read), so
|
||||
// admitting again here would count one class twice: at the budget
|
||||
// boundary it would break before appending the failure reason, and
|
||||
// with larger limits malformed classes would starve valid ones.
|
||||
failures.push(
|
||||
`${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
|
|
@ -341,5 +411,12 @@ export const decodeObjcDispatchFacets = (input: {
|
|||
protocols: (list) => protocolReader.list(list),
|
||||
localClassName: (address) => localClassName(read, address),
|
||||
});
|
||||
return { failures, categoryIssues, examined, categoriesExamined };
|
||||
return {
|
||||
failures,
|
||||
categoryIssues,
|
||||
examined,
|
||||
categoriesExamined,
|
||||
objcListScanTruncated,
|
||||
objcClassListScanTruncated,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => {
|
|||
examined: 0,
|
||||
categoriesExamined: 0,
|
||||
truncated: false,
|
||||
objcListScanTruncated: false,
|
||||
objcClassListScanTruncated: false,
|
||||
fixups: {
|
||||
kind: "none",
|
||||
formats: [],
|
||||
|
|
@ -165,6 +167,8 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () =
|
|||
examined: 0,
|
||||
categoriesExamined: 0,
|
||||
truncated: false,
|
||||
objcListScanTruncated: false,
|
||||
objcClassListScanTruncated: false,
|
||||
fixups: {
|
||||
kind: "chained",
|
||||
formats: ["format-99"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseDemangledSymbols } from "./demangle.js";
|
||||
import { swiftSymbolsSchema } from "../../contracts/native/nativeToolContracts.js";
|
||||
|
||||
describe("parseDemangledSymbols", () => {
|
||||
it("returns an empty array when given an empty input list", () => {
|
||||
expect(swiftSymbolsSchema.safeParse([]).success).toBe(true);
|
||||
expect(parseDemangledSymbols([], "")).toEqual([]);
|
||||
expect(parseDemangledSymbols([], "\n")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects demangler output when the input batch is empty", () => {
|
||||
expect(() => parseDemangledSymbols([], "unexpected\n")).toThrow(
|
||||
"swift-demangle emitted output for an empty input",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses demangled symbols successfully", () => {
|
||||
const inputs = ["$s4main4testyyF"];
|
||||
const output = "main.test() -> ()\n";
|
||||
expect(parseDemangledSymbols(inputs, output)).toEqual([
|
||||
{
|
||||
input: "$s4main4testyyF",
|
||||
output: "main.test() -> ()",
|
||||
status: "demangled",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("identifies unchanged non-Swift symbols", () => {
|
||||
const inputs = ["_printf"];
|
||||
const output = "_printf\n";
|
||||
expect(parseDemangledSymbols(inputs, output)).toEqual([
|
||||
{
|
||||
input: "_printf",
|
||||
output: "_printf",
|
||||
status: "unchanged",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("identifies invalid Swift symbols that fail demangling", () => {
|
||||
const inputs = ["$sInvalidMangle", "_$sAlsoInvalid"];
|
||||
const output = "$sInvalidMangle\n_$sAlsoInvalid\n";
|
||||
expect(parseDemangledSymbols(inputs, output)).toEqual([
|
||||
{
|
||||
input: "$sInvalidMangle",
|
||||
output: "$sInvalidMangle",
|
||||
status: "invalid",
|
||||
},
|
||||
{
|
||||
input: "_$sAlsoInvalid",
|
||||
output: "_$sAlsoInvalid",
|
||||
status: "invalid",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses mixed batches preserving ordering", () => {
|
||||
const inputs = ["$s4main4testyyF", "_printf", "$sInvalidMangle"];
|
||||
const output = "main.test() -> ()\n_printf\n$sInvalidMangle\n";
|
||||
expect(parseDemangledSymbols(inputs, output)).toEqual([
|
||||
{
|
||||
input: "$s4main4testyyF",
|
||||
output: "main.test() -> ()",
|
||||
status: "demangled",
|
||||
},
|
||||
{
|
||||
input: "_printf",
|
||||
output: "_printf",
|
||||
status: "unchanged",
|
||||
},
|
||||
{
|
||||
input: "$sInvalidMangle",
|
||||
output: "$sInvalidMangle",
|
||||
status: "invalid",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws TypeError when output count does not match input count", () => {
|
||||
expect(() =>
|
||||
parseDemangledSymbols(["sym1", "sym2"], "demangled1\n"),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
parseDemangledSymbols(["sym1"], "demangled1\ndemangled2\n"),
|
||||
).toThrow("swift-demangle output count does not match input");
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,8 @@ const resultSchema = z.object({
|
|||
status: z.enum(["demangled", "unchanged", "invalid"]),
|
||||
});
|
||||
|
||||
export type DemangledSymbol = z.infer<typeof resultSchema>;
|
||||
|
||||
/**
|
||||
* Preserve input ordering while parsing `swift-demangle --compact` lines.
|
||||
* The demangler ends each record with LF, so a carriage return belongs to the
|
||||
|
|
@ -14,7 +16,11 @@ const resultSchema = z.object({
|
|||
export const parseDemangledSymbols = (
|
||||
inputs: readonly string[],
|
||||
output: string,
|
||||
) => {
|
||||
): DemangledSymbol[] => {
|
||||
if (inputs.length === 0) {
|
||||
if (output === "" || output === "\n") return [];
|
||||
throw new TypeError("swift-demangle emitted output for an empty input");
|
||||
}
|
||||
const lines = output.replace(/\n$/u, "").split("\n");
|
||||
if (lines.length !== inputs.length)
|
||||
throw new TypeError("swift-demangle output count does not match input");
|
||||
|
|
|
|||
|
|
@ -1,39 +1,36 @@
|
|||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
|
||||
import {
|
||||
ARTIFACT_TOOL_CONTRACTS,
|
||||
artifactInspectionInputSchema,
|
||||
} from "../contracts/artifactToolContracts.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Operations whose MCP delivery may be a retained summary view. */
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
|
||||
"inspect_artifact",
|
||||
"decode_interface_builder",
|
||||
]);
|
||||
/** inspect_artifact keeps an explicit detail in its Evidence parameters. */
|
||||
const decodeWithoutDelivery = withoutDeliveryDetail(
|
||||
new Set(["decode_interface_builder"]),
|
||||
);
|
||||
|
||||
/** Register deterministic artifact inventory and safe extraction operations. */
|
||||
export const registerArtifactTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: (operation, parameters) => {
|
||||
if (operation === "inspect_artifact") {
|
||||
const { detail: _detail, ...execution } =
|
||||
|
|
@ -42,16 +39,9 @@ export const registerArtifactTools = (
|
|||
}
|
||||
return operation === "extract_artifact"
|
||||
? { ...parameters, output_root: createArtifactExtractionDestination() }
|
||||
: parameters;
|
||||
: decodeWithoutDelivery(operation, parameters);
|
||||
},
|
||||
preflight: (operation, parameters) =>
|
||||
operation === "inspect_artifact" && parameters.detail === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null),
|
||||
projectEvidence: (operation, parameters, evidence) =>
|
||||
operation === "inspect_artifact" && parameters.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
parametersForEvidence: decodeWithoutDelivery,
|
||||
sourceEvidence: (operation, result) =>
|
||||
operation === "inspect_artifact"
|
||||
? artifactInspectionResultSchema
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
|
|
@ -8,24 +6,17 @@ import {
|
|||
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
|
||||
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
|
||||
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
|
||||
|
||||
/** Register execution-free managed PE/CLI inspection. */
|
||||
export const registerManagedTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
let selectedManagedPath: string | undefined;
|
||||
const analysisForAdmission = (
|
||||
|
|
|
|||
|
|
@ -1,54 +1,25 @@
|
|||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Logger } from "pino";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Delivery detail selects a response shape; it never reaches the tools or Evidence identity. */
|
||||
const withoutDelivery = (
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): Readonly<Record<string, JsonValue>> => {
|
||||
if (operation !== "inspect_macho") return parameters;
|
||||
const { detail: _detail, ...selected } = parameters;
|
||||
return selected;
|
||||
};
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set(["inspect_macho"]);
|
||||
|
||||
/** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */
|
||||
export const registerNativeTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: import("./analysisAdmission.js").WithAdmittedAnalysis;
|
||||
},
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
|
||||
registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: withoutDelivery,
|
||||
parametersForEvidence: withoutDelivery,
|
||||
preflight: (operation, parameters) =>
|
||||
operation === "inspect_macho" && parameters.detail === "summary"
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null),
|
||||
projectEvidence: (operation, parameters, evidence) =>
|
||||
operation === "inspect_macho" && parameters.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
|
||||
/** Registration inputs shared by provider-backed Evidence tool families. */
|
||||
export interface EvidenceToolFamilyOptions {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceLookup | undefined;
|
||||
readonly withAdmittedAnalysis: WithAdmittedAnalysis;
|
||||
}
|
||||
|
||||
type Parameters = Readonly<Record<string, JsonValue>>;
|
||||
|
||||
/**
|
||||
* Summary delivery for the named operations: refuse it before analysis when
|
||||
* this server retains no Evidence, then return the retained summary view.
|
||||
*/
|
||||
export const retainedSummaryDelivery = (
|
||||
operations: ReadonlySet<string>,
|
||||
options: Pick<EvidenceToolFamilyOptions, "recordEvidence" | "evidenceById">,
|
||||
) => {
|
||||
const retention = {
|
||||
recordEvidence: options.recordEvidence,
|
||||
evidenceById: options.evidenceById,
|
||||
};
|
||||
const selected = (operation: string, parameters: Parameters): boolean =>
|
||||
operations.has(operation) && parameters.detail === "summary";
|
||||
return {
|
||||
preflight: (operation: string, parameters: Parameters) =>
|
||||
selected(operation, parameters)
|
||||
? requireAnalysisViewRetention(operation, retention)
|
||||
: ok(null),
|
||||
projectEvidence: (
|
||||
operation: string,
|
||||
parameters: Parameters,
|
||||
evidence: Evidence,
|
||||
) =>
|
||||
selected(operation, parameters)
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence),
|
||||
};
|
||||
};
|
||||
|
||||
/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */
|
||||
export const withoutDeliveryDetail =
|
||||
(operations: ReadonlySet<string>) =>
|
||||
(operation: string, parameters: Parameters): Parameters => {
|
||||
if (!operations.has(operation)) return parameters;
|
||||
const { detail: _detail, ...selected } = parameters;
|
||||
return selected;
|
||||
};
|
||||
|
|
@ -5,13 +5,113 @@ import {
|
|||
fatImage,
|
||||
machoImage,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js";
|
||||
import {
|
||||
readGoBinaryImage,
|
||||
GoBinaryResourceFailure,
|
||||
} from "../../../src/go/GoBinaryImage.js";
|
||||
import { readGoMachoImage } from "../../../src/go/GoMachoImage.js";
|
||||
import {
|
||||
createGoBinaryFixture,
|
||||
createGoSplitElfFixture,
|
||||
createGoOverlappingElfFixture,
|
||||
GO_MODULE_TEXT,
|
||||
} from "../../fixtures/go/image.js";
|
||||
|
||||
it("bounds cumulative header searches across overlapping ELF file spans", () => {
|
||||
const bytes = createGoOverlappingElfFixture();
|
||||
expect(() => readGoBinaryImage(bytes)).toThrow(GoBinaryResourceFailure);
|
||||
expect(() => readGoBinaryImage(bytes)).toThrow(/256 MiB.*search/);
|
||||
});
|
||||
|
||||
it("does not charge duplicate ELF search spans repeatedly", () => {
|
||||
expect(
|
||||
readGoBinaryImage(createGoOverlappingElfFixture(true)).build_info,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a search exactly at the cumulative byte limit", () => {
|
||||
const bytes = createGoOverlappingElfFixture();
|
||||
bytes.writeUInt16LE(256, 56);
|
||||
expect(readGoBinaryImage(bytes).build_info).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps different virtual alignments distinct when file spans match", () => {
|
||||
const fixture = createGoBinaryFixture({ sectionless: true });
|
||||
fixture.bytes.copy(fixture.bytes, 184, 128, 184);
|
||||
fixture.bytes.writeUInt16LE(2, 56);
|
||||
fixture.bytes.writeBigUInt64LE(0x200001n, 128 + 16);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info?.header_offset).toBe(
|
||||
fixture.headerOffset,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[32, "little", "inline"],
|
||||
[32, "little", "pointer"],
|
||||
[32, "big", "inline"],
|
||||
[32, "big", "pointer"],
|
||||
[64, "little", "inline"],
|
||||
[64, "little", "pointer"],
|
||||
[64, "big", "inline"],
|
||||
[64, "big", "pointer"],
|
||||
] as const)(
|
||||
"retains later-load ELF metadata without sections (%d %s %s)",
|
||||
(bits, byteOrder, encoding) => {
|
||||
const original = createGoSplitElfFixture({ bits, byteOrder, encoding });
|
||||
const sectionless = createGoSplitElfFixture({
|
||||
bits,
|
||||
byteOrder,
|
||||
encoding,
|
||||
sectionless: true,
|
||||
});
|
||||
const expected = readGoBinaryImage(original.bytes).build_info;
|
||||
expect(expected).not.toBeNull();
|
||||
expect(readGoBinaryImage(sectionless.bytes).build_info).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("continues past an empty file-backed ELF data load", () => {
|
||||
const fixture = createGoSplitElfFixture({ sectionless: true });
|
||||
const expected = readGoBinaryImage(fixture.bytes).build_info;
|
||||
fixture.bytes.writeBigUInt64LE(0n, 128 + 32);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([4, 5, 7])(
|
||||
"does not search a later non-data ELF load with flags %d",
|
||||
(flags) => {
|
||||
const fixture = createGoSplitElfFixture({ sectionless: true });
|
||||
fixture.bytes.writeUInt32LE(flags, 184 + 4);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers the named ELF section to an earlier writable-load marker", () => {
|
||||
const fixture = createGoSplitElfFixture();
|
||||
const expected = readGoBinaryImage(fixture.bytes).build_info;
|
||||
fixture.bytes.copy(
|
||||
fixture.bytes,
|
||||
512,
|
||||
fixture.headerOffset,
|
||||
fixture.headerOffset + 32,
|
||||
);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected);
|
||||
});
|
||||
|
||||
it("does not skip malformed earlier metadata to report a later valid record", () => {
|
||||
const fixture = createGoSplitElfFixture({ sectionless: true });
|
||||
fixture.bytes.copy(
|
||||
fixture.bytes,
|
||||
512,
|
||||
fixture.headerOffset,
|
||||
fixture.headerOffset + 32,
|
||||
);
|
||||
fixture.bytes[512 + 15] = 0xff;
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrow(
|
||||
/unsupported format bit/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["elf", 32, "little"],
|
||||
["elf", 32, "big"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.object({
|
||||
evidence_id: z.string(),
|
||||
parameters: z.record(z.string(), z.unknown()),
|
||||
normalized_result: schema,
|
||||
});
|
||||
const summarySchema = z.object({
|
||||
kind: z.literal("summary"),
|
||||
parent_evidence_id: z.string(),
|
||||
summary: z.object({
|
||||
documents: z.array(z.unknown()),
|
||||
available_counts: z.object({
|
||||
nodes: z.number(),
|
||||
edges: z.number(),
|
||||
coverage: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const pageSchema = z.object({
|
||||
kind: z.literal("interface-builder"),
|
||||
items: z.array(z.unknown()),
|
||||
coverage: z.object({ next_offset: z.number().nullable() }),
|
||||
});
|
||||
|
||||
/** A two-document app; each nib is a real rooted UIKit outlet archive. */
|
||||
const fixtureApp = async (): Promise<string> => {
|
||||
const app = join(
|
||||
await createTestTempDirectory("rea-ib-summary-mcp-"),
|
||||
"Fixture.app",
|
||||
);
|
||||
const contents = join(app, "Contents");
|
||||
const resources = join(contents, "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
await mkdir(join(contents, "MacOS"));
|
||||
await writeFile(
|
||||
join(contents, "MacOS", "Fixture"),
|
||||
thinMach(0xfeedfacf, 0x0100000c),
|
||||
);
|
||||
await writeFile(
|
||||
join(contents, "Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
|
||||
);
|
||||
const nib = await readFile(
|
||||
join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"),
|
||||
);
|
||||
await writeFile(join(resources, "Main.nib"), nib);
|
||||
await writeFile(join(resources, "Second.nib"), nib);
|
||||
return app;
|
||||
};
|
||||
|
||||
mcpTest(
|
||||
"retains complete Interface Builder Evidence and pages one document's rows",
|
||||
async ({ mcp, onTestFinished }) => {
|
||||
const app = await fixtureApp();
|
||||
const client = await openProductionTarget(mcp, onTestFinished, app);
|
||||
|
||||
// Summary first: its own retained parent must serve every later page.
|
||||
const summarized = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: { detail: "summary" },
|
||||
});
|
||||
expect(summarized.isError, JSON.stringify(summarized.content)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const summary = evidenceOf(summarySchema).parse(
|
||||
summarized.structuredContent,
|
||||
).normalized_result;
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
};
|
||||
const readAll = async (view: Record<string, unknown>) => {
|
||||
const rows: unknown[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const paged = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: { kind: "interface-builder", ...view, offset, limit: 2 },
|
||||
},
|
||||
});
|
||||
expect(paged.isError, JSON.stringify(paged.content)).not.toBe(true);
|
||||
const page = evidenceOf(pageSchema).parse(
|
||||
paged.structuredContent,
|
||||
).normalized_result;
|
||||
rows.push(...page.items);
|
||||
offset = page.coverage.next_offset;
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
const main = "Contents/Resources/Main.nib";
|
||||
const edges = await readAll({ collection: "edges" });
|
||||
const mainNodes = await readAll({ collection: "nodes", document: main });
|
||||
|
||||
// Only then compare with a complete call made for reference.
|
||||
const complete = await client.callTool({
|
||||
name: "decode_interface_builder",
|
||||
arguments: {},
|
||||
});
|
||||
expect(complete.isError, JSON.stringify(complete.content)).not.toBe(true);
|
||||
const full = evidenceOf(interfaceBuilderAnalysisSchema).parse(
|
||||
complete.structuredContent,
|
||||
);
|
||||
expect(full.parameters).not.toHaveProperty("detail");
|
||||
const report = full.normalized_result;
|
||||
expect(report.documents).toHaveLength(2);
|
||||
// Delivery detail never reaches the decoder or the source Evidence ID.
|
||||
expect(summary.parent_evidence_id).toBe(full.evidence_id);
|
||||
expect(summary.summary.documents).toEqual(report.documents);
|
||||
expect(summary.summary.available_counts).toEqual({
|
||||
nodes: report.graph.nodes.length,
|
||||
edges: report.graph.edges.length,
|
||||
coverage: report.graph.coverage.length,
|
||||
});
|
||||
expect(edges).toEqual(report.graph.edges);
|
||||
const expectedMainNodes = report.graph.nodes.filter(({ evidence }) =>
|
||||
evidence.some(({ artifact_path }) => artifact_path === main),
|
||||
);
|
||||
expect(expectedMainNodes.length).toBeGreaterThan(0);
|
||||
expect(expectedMainNodes.length).toBeLessThan(report.graph.nodes.length);
|
||||
expect(mainNodes).toEqual(expectedMainNodes);
|
||||
|
||||
const unknownDocument = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
source,
|
||||
view: {
|
||||
kind: "interface-builder",
|
||||
collection: "nodes",
|
||||
document: "Contents/Resources/Missing.nib",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(unknownDocument)).toMatchObject({
|
||||
error: { code: "invalid_request" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -1,40 +1,24 @@
|
|||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
archiveNumberMetadataCases,
|
||||
expectKeyedArchiveNumberMetadata,
|
||||
keyedArchiveNumberMetadataFixture,
|
||||
} from "../../fixtures/keyedArchiveNumberMetadata.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
mcpTest.for(archiveNumberMetadataCases)(
|
||||
"MCP preserves numeric meaning for $name",
|
||||
async (item, { mcp, onTestFinished }) => {
|
||||
const fixture = await keyedArchiveNumberMetadataFixture(item);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
const client = await openProductionTarget(
|
||||
mcp,
|
||||
onTestFinished,
|
||||
fixture.path,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
|
|
|
|||
|
|
@ -1,39 +1,23 @@
|
|||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
expectKeyedArchivePrototypeKeys,
|
||||
keyedArchivePrototypeKeyFixture,
|
||||
} from "../../fixtures/keyedArchivePrototypeKeys.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
mcpTest(
|
||||
"MCP retains ordinary evidence with formatted XML prototype keys",
|
||||
async ({ mcp, onTestFinished }) => {
|
||||
const fixture = await keyedArchivePrototypeKeyFixture();
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
const client = await openProductionTarget(
|
||||
mcp,
|
||||
onTestFinished,
|
||||
fixture.path,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { build } from "plist";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
|
||||
mcpTest(
|
||||
"MCP inspects an XML keyed archive with a non-plist suffix",
|
||||
async ({ mcp, onTestFinished }) => {
|
||||
const root = await createTestTempDirectory("rea-keyed-xml-extension-mcp-");
|
||||
const path = join(root, "Model.archive");
|
||||
await writeFile(
|
||||
path,
|
||||
build({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: ["$null", "leaf"],
|
||||
$top: { root: { UID: 1 } },
|
||||
}).replace("?>", `?><?audit run?>${" ".repeat(8192)}`),
|
||||
);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const inventoryReply = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(
|
||||
inventoryReply.isError,
|
||||
JSON.stringify(inventoryReply.content),
|
||||
).not.toBe(true);
|
||||
const inspectionEvidence = parseEvidence(inventoryReply.structuredContent);
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
inspectionEvidence.normalized_result,
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
inspection.substeps[0]?.evidence.normalized_result,
|
||||
);
|
||||
expect(inventory.manifest.root_format).toBe("plist");
|
||||
expect(inventory.occurrences[0]?.artifact_format).toBe("plist");
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "inspect_keyed_archive",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
||||
expect(
|
||||
z
|
||||
.object({ normalized_result: keyedArchiveResultSchema })
|
||||
.parse(result.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
archive_path: "Model.archive",
|
||||
archive_format: "xml-plist",
|
||||
objects: expect.arrayContaining([
|
||||
expect.objectContaining({ value: "leaf" }),
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const demangledSchema = z.object({
|
||||
normalized_result: z.object({
|
||||
|
|
@ -30,24 +27,7 @@ describe.skipIf(process.platform !== "darwin")(
|
|||
"tool",
|
||||
);
|
||||
await copyFile("/usr/bin/true", target);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const client = await openProductionTarget(mcp, onTestFinished, target);
|
||||
|
||||
const called = await client.callTool({
|
||||
name: "demangle_swift",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { copyFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createBinarySession } from "../../../src/composition/binary.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
|
|
@ -16,6 +13,7 @@ import { ok } from "../../../src/domain/result.js";
|
|||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { openProductionTarget } from "../../support/mcp/productionSession.js";
|
||||
|
||||
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.object({
|
||||
|
|
@ -54,24 +52,7 @@ describe.skipIf(process.platform !== "darwin")(
|
|||
"ls",
|
||||
);
|
||||
await copyFile("/bin/ls", target);
|
||||
const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" });
|
||||
if (!configured.ok) throw configured.error;
|
||||
const session = createBinarySession(
|
||||
configured.value,
|
||||
silentLogger,
|
||||
process.env,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await session.close();
|
||||
});
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const client = await openProductionTarget(mcp, onTestFinished, target);
|
||||
|
||||
const complete = await client.callTool({
|
||||
name: "inspect_macho",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js";
|
||||
import { artifactInspectionResultSchema } from "../../../../src/domain/artifactInspection.js";
|
||||
import { parseEvidence } from "../../../../src/domain/evidence.js";
|
||||
|
||||
const { runProviderAnalysis } = createDirectAnalysis({});
|
||||
|
||||
// mach_header_64 for an arm64 MH_EXECUTE image with no load commands.
|
||||
const MACH_O_64 = Buffer.alloc(32);
|
||||
MACH_O_64.writeUInt32LE(0xfeedfacf, 0);
|
||||
MACH_O_64.writeUInt32LE(0x0100000c, 4);
|
||||
MACH_O_64.writeUInt32LE(2, 12);
|
||||
const INFO_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>
|
||||
`;
|
||||
|
||||
const writeTree = async (
|
||||
root: string,
|
||||
files: Readonly<Record<string, string | Buffer>>,
|
||||
): Promise<void> => {
|
||||
for (const [path, contents] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(root, path)), { recursive: true });
|
||||
await writeFile(join(root, path), contents);
|
||||
}
|
||||
};
|
||||
|
||||
const probeOperations = async (path: string): Promise<string[]> => {
|
||||
const evidence = parseEvidence(
|
||||
await runProviderAnalysis(path, "inspect_artifact", {}),
|
||||
);
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(inspection.subject.root_format).toBe("directory");
|
||||
expect(
|
||||
inspection.unexplored_branches.map(({ next_probe }) => next_probe),
|
||||
).toEqual(inspection.next_probes);
|
||||
return inspection.next_probes.map(({ operation }) => operation);
|
||||
};
|
||||
|
||||
describe("directory inspection probes", () => {
|
||||
it("proposes native and plist probes for an application bundle without JavaScript", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-bundle-");
|
||||
const bundle = join(root, "Fixture.app");
|
||||
await writeTree(bundle, {
|
||||
"Contents/MacOS/Fixture": MACH_O_64,
|
||||
"Contents/Info.plist": INFO_PLIST,
|
||||
});
|
||||
|
||||
expect(await probeOperations(bundle)).toEqual([
|
||||
"open_binary",
|
||||
"inspect_plist",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps JavaScript analysis first for an Electron-style bundle", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-electron-");
|
||||
const source = join(root, "source");
|
||||
const bundle = join(root, "Fixture.app");
|
||||
await writeTree(source, { "main.js": "console.log('ok');\n" });
|
||||
await writeTree(bundle, {
|
||||
"Contents/MacOS/Fixture": MACH_O_64,
|
||||
"Contents/Info.plist": INFO_PLIST,
|
||||
});
|
||||
await createPackageWithOptions(
|
||||
source,
|
||||
join(bundle, "Contents/Resources/app.asar"),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(await probeOperations(bundle)).toEqual([
|
||||
"analyze_javascript_application",
|
||||
"open_binary",
|
||||
"inspect_plist",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["cjs", "cts", "mjs", "mts", "js", "jsx", "ts", "tsx"])(
|
||||
"proposes JavaScript analysis for .%s sources the analyzer reads",
|
||||
async (extension) => {
|
||||
const root = await createTestTempDirectory("rea-artifact-typescript-");
|
||||
const bundle = join(root, "Fixture.app");
|
||||
await writeTree(bundle, {
|
||||
"Contents/MacOS/Fixture": MACH_O_64,
|
||||
"Contents/Info.plist": INFO_PLIST,
|
||||
[`Contents/Resources/app/main.${extension}`]:
|
||||
"export const ok = true;\n",
|
||||
});
|
||||
|
||||
expect(await probeOperations(bundle)).toEqual([
|
||||
"analyze_javascript_application",
|
||||
"open_binary",
|
||||
"inspect_plist",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves plist inspection for a readable Info.plist symlink", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-plist-link-");
|
||||
const bundle = join(root, "Fixture.app");
|
||||
await writeTree(bundle, { "Contents/MacOS/Fixture": MACH_O_64 });
|
||||
await writeFile(join(root, "bundle-metadata"), INFO_PLIST);
|
||||
await symlink(
|
||||
join(root, "bundle-metadata"),
|
||||
join(bundle, "Contents/Info.plist"),
|
||||
);
|
||||
expect(await probeOperations(bundle)).toEqual([
|
||||
"open_binary",
|
||||
"inspect_plist",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not propose JavaScript analysis for a symlink the analyzers never follow", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-symlink-");
|
||||
const bundle = join(root, "Fixture.app");
|
||||
await writeTree(bundle, {
|
||||
"Contents/MacOS/Fixture": MACH_O_64,
|
||||
"Contents/Info.plist": INFO_PLIST,
|
||||
});
|
||||
await writeTree(root, { "outside.js": "console.log('outside');\n" });
|
||||
await mkdir(join(bundle, "Contents/Resources"));
|
||||
await symlink(
|
||||
join(root, "outside.js"),
|
||||
join(bundle, "Contents/Resources/main.js"),
|
||||
);
|
||||
|
||||
expect(await probeOperations(bundle)).toEqual([
|
||||
"open_binary",
|
||||
"inspect_plist",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -41,10 +41,32 @@ it("keeps logical names that differ only in case and records the limitation", as
|
|||
}
|
||||
});
|
||||
|
||||
it("keeps literal backslash ZIP names distinct from slash paths", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-detail-");
|
||||
const path = join(root, "fixture.zip");
|
||||
await writeOrderedZip(path, ["a\\b.js", "a/b.js"]);
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw new Error("expected a ZIP target");
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
.createClient(target.value)
|
||||
.execute("inventory_artifact", {});
|
||||
if (!result.ok) throw result.error;
|
||||
const inventory = artifactInventoryResultSchema.parse(result.value.result);
|
||||
const literal = inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "a\\b.js",
|
||||
);
|
||||
const slash = inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "a/b.js",
|
||||
);
|
||||
expect(literal).toBeDefined();
|
||||
expect(slash).toBeDefined();
|
||||
expect(literal?.occurrence_id).not.toBe(slash?.occurrence_id);
|
||||
});
|
||||
|
||||
it("reports an unsafe path as a hostile artifact path", async () => {
|
||||
const root = await createTestTempDirectory("rea-artifact-detail-");
|
||||
const path = join(root, "fixture.zip");
|
||||
await writeOrderedZip(path, ["a\\b.js"]);
|
||||
await writeOrderedZip(path, ["../outside.js"]);
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw new Error("expected a ZIP target");
|
||||
const result = await new ArtifactProvider(process.env)
|
||||
|
|
@ -53,11 +75,11 @@ it("reports an unsafe path as a hostile artifact path", async () => {
|
|||
if (result.ok) throw new Error("expected the path constraint to fail");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "artifact_operation_failed",
|
||||
message: 'Artifact path is absolute or unsafe: "a\\\\b.js"',
|
||||
message: 'Artifact path is not normalized: "../outside.js"',
|
||||
details: {
|
||||
operation: "inventory_artifact",
|
||||
reason: "path",
|
||||
detail: 'Artifact path is absolute or unsafe: "a\\\\b.js"',
|
||||
detail: 'Artifact path is not normalized: "../outside.js"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue