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:
morluto 2026-10-14 08:29:16 +08:00
parent d4bd65c53c
commit 55dd21a5e2
59 changed files with 2602 additions and 499 deletions

View File

@ -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.

View File

@ -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({

View File

@ -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)

View File

@ -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",
}),
);
});
});

View File

@ -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

View File

@ -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(

View File

@ -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,

View File

@ -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 = [

View File

@ -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),

View File

@ -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,

View File

@ -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 = {

View File

@ -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" ||

View 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"] }],
});
});
});

View File

@ -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),
});
};

View File

@ -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. */

View File

@ -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,

View File

@ -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,

View File

@ -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([

View File

@ -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) {

View File

@ -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);

View File

@ -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",
);
};

View File

@ -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);
});
});

View File

@ -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 = (

View File

@ -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"]),

View File

@ -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.",
],
});

View File

@ -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

View File

@ -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,
});
});
});

View File

@ -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"]);

View File

@ -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,
);
});
});

View File

@ -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");

View File

@ -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.",

View File

@ -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", () => {

View File

@ -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")

View File

@ -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. */

View File

@ -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);

View File

@ -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],
};
};

View File

@ -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],
};
};

View File

@ -157,7 +157,7 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
mappings,
zero_fills: zeroFills,
sections,
search,
search: search === null ? [] : [search],
};
};

View File

@ -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(

View File

@ -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");
});
});

View File

@ -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,

View File

@ -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,
};
};

View File

@ -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"],

View File

@ -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");
});
});

View File

@ -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");

View File

@ -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

View File

@ -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 = (

View File

@ -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),
});
};

View File

@ -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;
};

View File

@ -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"],

View File

@ -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" },
});
},
);

View File

@ -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: {},

View File

@ -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: {},

View File

@ -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" }),
]),
});
},
);

View File

@ -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",

View File

@ -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",

View File

@ -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",
]);
});
});

View File

@ -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"',
},
});
});