From 8d198d6ec707402d75276f516e0090d71767b7e4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:14:47 +0800 Subject: [PATCH 1/4] fix(javascript): preserve unavailable scope uncertainty in comparisons Classify wholly unknown or unavailable node observations at the version-item owner and retain their limitations across matched and unmatched comparisons. Preserve positive mixed observations and genuine additions/removals. Validated source regressions and public CLI/stdout MCP workflows in the 103-test focused gate; typecheck and lint pass. Fixes #5534. --- .../javascriptApplicationVersionItems.ts | 85 +++++-- ...pplicationVersionUnavailableScopes.test.ts | 207 ++++++++++++++++++ .../applicationVersionMatchingCli.test.ts | 107 +++++++++ .../applicationWorkflowCli.test.ts | 94 +++++++- 4 files changed, 471 insertions(+), 22 deletions(-) create mode 100644 src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts diff --git a/src/domain/javascript/javascriptApplicationVersionItems.ts b/src/domain/javascript/javascriptApplicationVersionItems.ts index 6782c77efb..9e07565f1e 100644 --- a/src/domain/javascript/javascriptApplicationVersionItems.ts +++ b/src/domain/javascript/javascriptApplicationVersionItems.ts @@ -145,20 +145,28 @@ const matchedItem = ( const relationships = compareRelationships(pair, context); const dimensions = changedDimensions(pair, relationships); const complete = graphsComplete(context); + const ownEvidenceUncertain = + ownPresenceUncertain(pair.left) || ownPresenceUncertain(pair.right); const observedChange = - dimensions.includes("content") || dimensions.includes("properties"); + !ownEvidenceUncertain && + (dimensions.includes("content") || dimensions.includes("properties")); // Coordinates remain comparison evidence; relocation alone is not a // content, property, or relationship change. const completeChange = - complete && dimensions.some((dimension) => dimension !== "location"); + !ownEvidenceUncertain && + complete && + dimensions.some((dimension) => dimension !== "location"); const status: ApplicationVersionComparisonItem["status"] = - observedChange || completeChange - ? "changed" - : complete && !relationships.unresolved - ? "unchanged" - : "unknown"; - const finalDimensions = - status === "unknown" && !complete + ownEvidenceUncertain + ? "unknown" + : observedChange || completeChange + ? "changed" + : complete && !relationships.unresolved + ? "unchanged" + : "unknown"; + const finalDimensions = ownEvidenceUncertain + ? [] + : status === "unknown" && !complete ? [...dimensions, "coverage" as const] : dimensions; const semantic: ComparisonItemSemantic = { @@ -170,6 +178,10 @@ const matchedItem = ( dimensions: finalDimensions, evidence_links: itemEvidenceLinks(pair.left, pair.right, context), limitations: [ + ...new Set([ + ...ownAvailabilityLimitations(pair.left), + ...ownAvailabilityLimitations(pair.right), + ]), ...matchLimitations(pair), ...(status === "unknown" && !complete ? [ @@ -218,7 +230,20 @@ const leftOnlyItem = ( candidate_right_node_ids: [firstCandidate, ...remainingCandidates], }, dimensions: ["availability"], - limitations: [AMBIGUOUS_MATCH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + AMBIGUOUS_MATCH_LIMITATION, + ], + }); + if (ownPresenceUncertain(node)) + return itemWithId({ + ...common, + status: "unknown", + left_node_id: node.node_id, + right_node_id: null, + match: unmatchedMatch(), + dimensions: ["availability"], + limitations: ownAvailabilityLimitations(node), }); const absenceObserved = absenceClaimable(context.rightGraph.coverage); return itemWithId({ @@ -230,7 +255,10 @@ const leftOnlyItem = ( dimensions: absenceObserved ? ["availability"] : ["availability", "coverage"], - limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + ...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]), + ], }); }; @@ -258,7 +286,20 @@ const rightOnlyItem = ( candidate_right_node_ids: [], }, dimensions: ["availability"], - limitations: [AMBIGUOUS_MATCH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + AMBIGUOUS_MATCH_LIMITATION, + ], + }); + if (ownPresenceUncertain(node)) + return itemWithId({ + ...common, + status: "unknown", + left_node_id: null, + right_node_id: node.node_id, + match: unmatchedMatch(), + dimensions: ["availability"], + limitations: ownAvailabilityLimitations(node), }); const absenceObserved = absenceClaimable(context.leftGraph.coverage); return itemWithId({ @@ -270,7 +311,10 @@ const rightOnlyItem = ( dimensions: absenceObserved ? ["availability"] : ["availability", "coverage"], - limitations: absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION], + limitations: [ + ...ownAvailabilityLimitations(node), + ...(absenceObserved ? [] : [INCOMPLETE_GRAPH_LIMITATION]), + ], }); }; @@ -331,6 +375,21 @@ const AMBIGUOUS_MATCH_LIMITATION = const INCOMPLETE_GRAPH_LIMITATION = "The opposite graph is incomplete, so an unmatched node cannot be classified as added or removed."; +const ownPresenceUncertain = (node: ApplicationNode): boolean => + node.observations.every( + ({ evidence }) => + evidence.state === "unknown" || evidence.state === "unavailable", + ); + +const ownAvailabilityLimitations = (node: ApplicationNode): string[] => { + const limitations = new Set(); + for (const { evidence } of node.observations) + if (evidence.state === "unknown" || evidence.state === "unavailable") + for (const limitation of evidence.limitations) + limitations.add(limitation); + return [...limitations].sort(compareUnicodeCodePoints); +}; + const changedDimensions = ( pair: ApplicationVersionNodePair, relationships: RelationshipComparison, diff --git a/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts b/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts new file mode 100644 index 0000000000..ff7e3260b7 --- /dev/null +++ b/src/domain/javascript/javascriptApplicationVersionUnavailableScopes.test.ts @@ -0,0 +1,207 @@ +import { expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, +} from "./javascriptApplicationGraph.js"; +import { + artifactEvidence, + completeCoverage, +} from "./javascriptApplicationGraph.fixture.js"; +import { compareJavaScriptApplicationVersions } from "./javascriptApplicationVersionComparison.js"; + +const LEFT_SHA = "a".repeat(64); +const RIGHT_SHA = "b".repeat(64); +const LIMITATION = + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed."; +const evidenceId = (character: string) => `ev_${character.repeat(64)}`; +const node = (sha256: string, path: string) => + createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { strategy: "content-digest", stability: "global-exact", sha256 }, + observations: [ + { + label: path, + properties: { path }, + evidence: artifactEvidence(sha256, path), + }, + ], + }); +const unavailableScope = ( + artifactSha: string, + state: "unknown" | "unavailable" = "unavailable", +) => + createJavaScriptApplicationNode({ + kind: "unknown", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: artifactSha, + namespace: "static-parse-scope", + key: "select-javascript-sources:lib.js", + }, + observations: [ + { + label: "lib.js parse scope", + properties: { path: "lib.js", operation: "select-javascript-sources" }, + evidence: { + ...artifactEvidence(artifactSha, "lib.js"), + state, + confidence: "unknown", + limitations: [LIMITATION], + }, + }, + ], + }); +const observedAssetWithUnavailableFact = () => { + const digest = "e".repeat(64); + return createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: digest, + }, + observations: [ + { + label: "new.js", + properties: { path: "new.js" }, + evidence: artifactEvidence(digest, "new.js"), + }, + { + label: "new.js parse scope", + properties: { operation: "parse-javascript" }, + evidence: { + ...artifactEvidence(digest, "new.js"), + state: "unavailable", + confidence: "unknown", + limitations: [LIMITATION], + }, + }, + ], + }); +}; +const graph = (nodes: ReturnType[]) => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [nodes[0]!.node_id], + nodes, + edges: [], + coverage: completeCoverage, + limitations: [], + }); +const compare = ( + left: ReturnType[], + right: ReturnType[], +) => + compareJavaScriptApplicationVersions({ + left: { + evidenceId: evidenceId("1"), + rootArtifactSha256: LEFT_SHA, + graph: graph(left), + }, + right: { + evidenceId: evidenceId("2"), + rootArtifactSha256: RIGHT_SHA, + graph: graph(right), + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + +it("keeps unmatched unavailable scopes unknown in either direction", () => { + const leftRoot = node("d".repeat(64), "main.js"); + const rightRoot = node("d".repeat(64), "main.js"); + const leftScope = unavailableScope(LEFT_SHA); + const rightScope = unavailableScope(RIGHT_SHA); + const forward = compare([leftRoot], [rightRoot, rightScope]); + const reverse = compare([leftRoot, leftScope], [rightRoot]); + + expect( + forward.items.find( + ({ right_node_id }) => right_node_id === rightScope.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [LIMITATION], + evidence_links: [evidenceId("1"), evidenceId("2")], + }); + expect( + reverse.items.find( + ({ left_node_id }) => left_node_id === leftScope.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [LIMITATION], + }); + expect(forward.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 }); + expect(reverse.summary).toMatchObject({ added: 0, removed: 0, unknown: 1 }); +}); + +it("keeps matched unavailable scopes unknown with their source limitation", () => { + const leftRoot = node("d".repeat(64), "main.js"); + const rightRoot = node("d".repeat(64), "main.js"); + const leftScope = unavailableScope(LEFT_SHA); + const rightScope = unavailableScope(LEFT_SHA); + const result = compare([leftRoot, leftScope], [rightRoot, rightScope]); + + expect( + result.items.find(({ left_node_id }) => left_node_id === leftScope.node_id), + ).toMatchObject({ + right_node_id: rightScope.node_id, + status: "unknown", + dimensions: [], + limitations: [LIMITATION], + }); + expect(result.summary).toMatchObject({ unchanged: 1, unknown: 1 }); +}); + +it("retains unknown scope facts and reports genuine entity changes", () => { + const leftRoot = node("d".repeat(64), "main.js"); + const rightRoot = node("d".repeat(64), "main.js"); + const unknown = unavailableScope(RIGHT_SHA, "unknown"); + const added = node("e".repeat(64), "new.js"); + const removed = node("f".repeat(64), "removed.js"); + const mixedEvidenceAddition = observedAssetWithUnavailableFact(); + const unknownResult = compare([leftRoot], [rightRoot, unknown]); + const addedResult = compare([leftRoot], [rightRoot, added]); + const removedResult = compare([leftRoot, removed], [rightRoot]); + const mixedResult = compare([leftRoot], [rightRoot, mixedEvidenceAddition]); + + expect( + unknownResult.items.find( + ({ right_node_id }) => right_node_id === unknown.node_id, + ), + ).toMatchObject({ status: "unknown", limitations: [LIMITATION] }); + expect(unknownResult.summary).toMatchObject({ added: 0, unknown: 1 }); + expect( + addedResult.items.find( + ({ right_node_id }) => right_node_id === added.node_id, + ), + ).toMatchObject({ status: "added", dimensions: ["availability"] }); + expect(addedResult.summary.added).toBe(1); + expect( + mixedResult.items.find( + ({ right_node_id }) => right_node_id === mixedEvidenceAddition.node_id, + ), + ).toMatchObject({ status: "added", limitations: [LIMITATION] }); + expect(mixedResult.summary.added).toBe(1); + expect( + removedResult.items.find( + ({ left_node_id }) => left_node_id === removed.node_id, + ), + ).toMatchObject({ status: "removed", dimensions: ["availability"] }); + expect(removedResult.summary.removed).toBe(1); + expect( + compare( + [leftRoot, node("c".repeat(64), "same.js")], + [rightRoot, node("c".repeat(64), "same.js")], + ).summary, + ).toMatchObject({ + unchanged: 2, + added: 0, + removed: 0, + }); +}); diff --git a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts index 889c605e04..683ec9a338 100644 --- a/tests/acceptance/applications/applicationVersionMatchingCli.test.ts +++ b/tests/acceptance/applications/applicationVersionMatchingCli.test.ts @@ -9,6 +9,113 @@ import { applicationVersionComparisonResultSchema } from "../../../src/domain/ja import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest( + "keeps unavailable source-selection scopes unknown in version comparisons", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-version-scope-"); + const application = join(root, "application"); + const copy = join(root, "copy"); + await Promise.all([mkdir(application), mkdir(copy)]); + const files = [ + ["lib.js", "export function shared() { return 1; }\n"], + ["main.js", 'export const name = "one";\n'], + ] as const; + for (const [name, contents] of files) { + await Promise.all([ + writeFile(join(application, name), contents), + writeFile(join(copy, name), contents), + ]); + } + + const analyze = async (input: string, selected: boolean) => { + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + input, + ...(selected ? ["--source-path", "main.js"] : []), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return parseEvidence(response.json); + }; + const full = await analyze(application, false); + const selected = await analyze(application, true); + const fullCopy = await analyze(copy, false); + const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse( + selected.normalized_result, + ); + const parseScope = selectedAnalysis.graph.nodes.find( + ({ kind, observations }) => + kind === "unknown" && + observations.some( + ({ properties }) => + properties.path === "lib.js" && + properties.operation === "select-javascript-sources", + ), + ); + expect(parseScope?.observations[0]?.evidence).toMatchObject({ + state: "unavailable", + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + }); + + const compare = async ( + left: ReturnType, + right: ReturnType, + name: string, + ) => { + const inputPath = join(root, `${name}.json`); + await writeFile(inputPath, JSON.stringify({ left, right })); + const response = await cli.run({ + arguments: ["compare-application-versions", inputPath, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + return applicationVersionComparisonResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + }; + const forward = await compare(full, selected, "forward"); + const reverse = await compare(selected, full, "reverse"); + const unchangedCopy = await compare(full, fullCopy, "full-copy"); + + expect( + forward.items.find( + ({ right_node_id }) => right_node_id === parseScope?.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + evidence_links: expect.arrayContaining([ + full.evidence_id, + selected.evidence_id, + ]), + }); + expect( + reverse.items.find( + ({ left_node_id }) => left_node_id === parseScope?.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + }); + expect(forward.summary.added).toBe(0); + expect(forward.summary.removed).toBe(0); + expect(reverse.summary.added).toBe(0); + expect(reverse.summary.removed).toBe(0); + expect(unchangedCopy.summary).toMatchObject({ added: 0, removed: 0 }); + }, +); + cliTest( "uses syntax-aware bundle fingerprints for changed syntax and renamed bindings", async ({ cli }) => { diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 77795085cf..482e6238f8 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -25,6 +25,7 @@ import { javascriptApplicationAnalysisResultSchema, type JavaScriptApplicationAnalysisResult, } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { z } from "zod"; @@ -186,23 +187,31 @@ const expectSelfImportEvidence = ( const analyzeThroughStdioMcp = async ( inputPath: string, ): Promise => { + const evidence = await analyzeEvidenceThroughStdioMcp(inputPath); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + expect(result).toMatchObject({ input_path: inputPath, format: "directory" }); + return result; +}; + +const analyzeEvidenceThroughStdioMcp = async ( + inputPath: string, + sourcePaths?: readonly string[], +) => { const client = await connectedMcpClient(); const response = await client.callTool({ name: "analyze_javascript_application", - arguments: { input_path: inputPath }, + arguments: { + input_path: inputPath, + ...(sourcePaths === undefined ? {} : { source_paths: [...sourcePaths] }), + }, }); expect(response.isError).not.toBe(true); expect(response.content).toEqual( expect.arrayContaining([expect.objectContaining({ type: "text" })]), ); - const result = z - .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) - .parse(response.structuredContent).normalized_result; - expect(result).toMatchObject({ - input_path: inputPath, - format: "directory", - }); - return result; + return parseEvidence(response.structuredContent); }; const expectCliAnalysisParity = async ( @@ -1220,6 +1229,73 @@ describe("application workflow CLI input", () => { }); }); +describe("application version source-scope MCP comparison", () => { + it("keeps unselected parse scopes unknown through stdio MCP", async () => { + const root = await createTestTempDirectory("rea-version-scope-mcp-"); + await Promise.all([ + writeFile( + join(root, "lib.js"), + "export function shared() { return 1; }\n", + ), + writeFile(join(root, "main.js"), 'export const name = "one";\n'), + ]); + const full = await analyzeEvidenceThroughStdioMcp(root); + const selected = await analyzeEvidenceThroughStdioMcp(root, ["main.js"]); + const selectedAnalysis = javascriptApplicationAnalysisResultSchema.parse( + selected.normalized_result, + ); + const scope = selectedAnalysis.graph.nodes.find( + ({ kind, observations }) => + kind === "unknown" && + observations.some( + ({ properties }) => + properties.path === "lib.js" && + properties.operation === "select-javascript-sources", + ), + ); + expect(scope?.observations[0]?.evidence).toMatchObject({ + state: "unavailable", + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + }); + const client = await connectedMcpClient(); + const compare = async (left: unknown, right: unknown) => { + const response = await client.callTool({ + name: "compare_application_versions", + arguments: { left, right }, + }); + expect(response.isError).not.toBe(true); + return applicationVersionComparisonResultSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); + }; + const forward = await compare(full, selected); + const reverse = await compare(selected, full); + + expect( + forward.items.find( + ({ right_node_id }) => right_node_id === scope?.node_id, + ), + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + limitations: [ + "Source is outside the caller-selected source_paths; it was inventoried but not analyzed.", + ], + evidence_links: expect.arrayContaining([ + full.evidence_id, + selected.evidence_id, + ]), + }); + expect( + reverse.items.find(({ left_node_id }) => left_node_id === scope?.node_id), + ).toMatchObject({ status: "unknown", dimensions: ["availability"] }); + expect(forward.summary).toMatchObject({ added: 0, removed: 0 }); + expect(reverse.summary).toMatchObject({ added: 0, removed: 0 }); + }); +}); + describe("application workflow CLI copy boundaries", () => { it("compares source values excluded from escaped rest and spread copies", async () => { const root = await createTestTempDirectory("rea-copy-boundary-cli-"); From 9eec57b469087fe9bff3d29dcc02f47074159e91 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:15:05 +0800 Subject: [PATCH 2/4] fix(native): report record-local Objective-C budget omissions Have method, property, ivar, and adopted-protocol list owners flag real budget omissions on returned class, protocol, and category records. Preserve known empty lists and existing decode reasons. Validated 59 source tests, two built CLI controls, five static inspections of an Apple-clang fixture, and typecheck/lint. The target was never executed. Fixes #5543. --- src/native/AppleDispatchMetadata.test.ts | 182 ++++++++++++++++++ src/native/AppleObjcCategories.ts | 69 ++++--- src/native/AppleObjcDispatchFacets.ts | 54 ++++-- src/native/AppleObjcProperties.ts | 8 +- src/native/AppleObjcProtocols.ts | 59 +++++- .../cli/nativeDispatchRecordBudget.test.ts | 64 ++++++ tests/fixtures/objcRecordBudgetMacho.ts | 72 +++++++ 7 files changed, 460 insertions(+), 48 deletions(-) create mode 100644 tests/boundary/cli/nativeDispatchRecordBudget.test.ts create mode 100644 tests/fixtures/objcRecordBudgetMacho.ts diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index b267b89638..3a51c8a1f2 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js"; +import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js"; import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; const base = 0x100000000n; @@ -119,6 +120,20 @@ const fixture = ( }; const provenance = { path: "/fixture/app", sha256: "a".repeat(64) }; +const withProtocolListSection = (bytes: Buffer): Buffer => { + const pointer = (at: number, offset: number) => + bytes.writeBigUInt64LE(base + BigInt(offset), at); + bytes.writeUInt32LE(232, 20); + bytes.writeUInt32LE(232, 36); + bytes.writeUInt32LE(2, 96); + bytes.write("__objc_protolist", 184); + bytes.write("__DATA", 200); + pointer(216, 0x6c0); + bytes.writeBigUInt64LE(8n, 224); + pointer(0x6c0, 0x740); + return bytes; +}; + const partialSiblingsFixture = ( options: { readonly emptyMiddleClass?: boolean; @@ -469,6 +484,173 @@ describe("Apple dispatch coverage ownership", () => { }); }); +describe("Objective-C declaration-local record-budget coverage", () => { + it.each(["protocol", "class"] as const)( + "preserves the decoded $0 method at a sufficient budget", + (owner) => { + const result = decodeAppleDispatchMetadata( + objcRecordBudgetMacho(owner), + 100, + provenance, + ); + if (owner === "protocol") + expect(result.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [{ selector: "protocolMethod" }], + decode: { status: "decoded", reason: null }, + }, + ]); + else + expect(result.objc_classes).toMatchObject([ + { + name: "FixtureClass", + methods: [{ selector: "classMethod", method_type: "instance" }], + decode: { status: "decoded", reason: null }, + }, + ]); + }, + ); + + it("marks an omitted protocol method list partial while preserving known emptiness", () => { + const truncated = decodeAppleDispatchMetadata( + withProtocolListSection(fixture()), + 1, + provenance, + ); + expect(truncated.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + + const emptyBytes = withProtocolListSection(fixture()); + emptyBytes.writeBigUInt64LE(0n, 0x758); + emptyBytes.writeBigUInt64LE(0n, 0x768); + const empty = decodeAppleDispatchMetadata(emptyBytes, 1, provenance); + expect(empty.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [], + optional_methods: [], + decode: { status: "decoded", reason: null }, + }, + ]); + }); + + it("marks omitted class methods, ivars, properties, and adopted protocols partial", () => { + const methodBytes = fixture(); + methodBytes.writeUInt32LE(2, 0x300); + methodBytes.writeBigUInt64LE(0n, 0x330); + methodBytes.writeBigUInt64LE(0n, 0x340); + const methodLimited = decodeAppleDispatchMetadata( + methodBytes, + 1, + provenance, + ); + expect(methodLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }); + + const ivarBytes = fixture(); + ivarBytes.writeBigUInt64LE(0n, 0x320); + ivarBytes.writeBigUInt64LE(0n, 0x328); + ivarBytes.writeBigUInt64LE(0n, 0x340); + ivarBytes.writeUInt32LE(2, 0x300); + const ivarsLimited = decodeAppleDispatchMetadata(ivarBytes, 1, provenance); + expect(ivarsLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + ivar_count: 1, + decode: { status: "partial", reason: "max_records_reached" }, + }); + expect(ivarsLimited.objc_ivars).toEqual([]); + + const propertyBytes = fixture(); + propertyBytes.writeBigUInt64LE(0n, 0x320); + propertyBytes.writeBigUInt64LE(0n, 0x328); + propertyBytes.writeBigUInt64LE(0n, 0x330); + propertyBytes.writeUInt32LE(2, 0x300); + const propertiesLimited = decodeAppleDispatchMetadata( + propertyBytes, + 1, + provenance, + ); + expect(propertiesLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + properties: [], + decode: { status: "partial", reason: "max_records_reached" }, + }); + + const adoptedBytes = fixture(); + adoptedBytes.writeBigUInt64LE(0n, 0x320); + adoptedBytes.writeBigUInt64LE(0n, 0x330); + adoptedBytes.writeBigUInt64LE(0n, 0x340); + adoptedBytes.writeUInt32LE(2, 0x300); + const adoptedLimited = decodeAppleDispatchMetadata( + adoptedBytes, + 1, + provenance, + ); + expect(adoptedLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + protocols: [], + decode: { status: "partial", reason: "max_records_reached" }, + }); + }); + + it("keeps genuinely empty class method lists decoded at the record limit", () => { + const bytes = fixture(); + bytes.writeBigUInt64LE(0n, 0x320); + bytes.writeBigUInt64LE(0n, 0x328); + bytes.writeBigUInt64LE(0n, 0x330); + bytes.writeBigUInt64LE(0n, 0x340); + bytes.writeUInt32LE(2, 0x300); + const result = decodeAppleDispatchMetadata(bytes, 1, provenance); + expect(result.objc_classes[0]).toMatchObject({ + name: "Fixture", + methods: [], + decode: { status: "decoded", reason: null }, + }); + }); +}); + +describe("Objective-C category-local record-budget coverage", () => { + it.each(["instance", "class"] as const)( + "marks category %s methods partial only when a declared entry is omitted", + (methodType) => { + const bytes = fixture(false, "partial-method"); + const listField = methodType === "instance" ? 0xb50 : 0xb58; + const methodList = bytes.readBigUInt64LE(0xb50); + bytes.writeBigUInt64LE(0n, 0xb50); + bytes.writeBigUInt64LE(methodList, listField); + bytes.writeUInt32LE(2, 0x300); + bytes.writeBigUInt64LE(0n, 0x320); + const truncated = decodeAppleDispatchMetadata(bytes, 11, provenance); + expect(truncated.objc_categories).toMatchObject([ + { + name: "FixtureCategory", + [`${methodType}_methods`]: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + + bytes.writeBigUInt64LE(0n, listField); + const empty = decodeAppleDispatchMetadata(bytes, 11, provenance); + expect(empty.objc_categories).toMatchObject([ + { + name: "FixtureCategory", + [`${methodType}_methods`]: [], + decode: { status: "decoded", reason: null }, + }, + ]); + }, + ); +}); + describe("malformed Objective-C property records", () => { it.each([ ["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"], diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index f81369d4dc..994f34b5d4 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -31,8 +31,9 @@ export const decodeObjcCategories = (input: { className: string, methodType: "instance" | "class", category: string, + onTruncated?: () => void, ) => string[]; - readonly protocols: (list: bigint) => string[]; + readonly protocols: (list: bigint, onTruncated?: () => void) => string[]; /** Name of a class defined in this image, from its class_ro_t. */ readonly localClassName: (address: bigint) => string; }): number => { @@ -71,6 +72,43 @@ export const decodeObjcCategories = (input: { ); } const owner = className ?? `(${name})`; + let budgetTruncated = false; + const onTruncated = () => { + budgetTruncated = true; + }; + const instanceMethods = input.methods( + read.pointer(category + 16n), + owner, + "instance", + name, + onTruncated, + ); + const classMethods = input.methods( + read.pointer(category + 24n), + owner, + "class", + name, + onTruncated, + ); + const protocols = input.protocols( + read.pointer(category + 32n), + onTruncated, + ); + const properties = readObjcPropertiesOf( + read, + read.pointer(category + 40n), + { + owner: `category ${name}`, + admit: input.admit, + issues: input.issues, + location: `0x${read.pointer(category + 40n).toString(16)}`, + onTruncated, + }, + ); + const decodeReasons = [ + ...(className === null ? ["category_class_unresolved"] : []), + ...(budgetTruncated ? ["max_records_reached"] : []), + ]; result.objc_categories.push({ name, class_name: className, @@ -80,30 +118,15 @@ export const decodeObjcCategories = (input: { : className === null ? "unresolved" : "local", - instance_methods: input.methods( - read.pointer(category + 16n), - owner, - "instance", - name, - ), - class_methods: input.methods( - read.pointer(category + 24n), - owner, - "class", - name, - ), - protocols: input.protocols(read.pointer(category + 32n)), - properties: readObjcPropertiesOf(read, read.pointer(category + 40n), { - owner: `category ${name}`, - admit: input.admit, - issues: input.issues, - location: `0x${read.pointer(category + 40n).toString(16)}`, - }), + instance_methods: instanceMethods, + class_methods: classMethods, + protocols, + properties, location: read.location(category), decode: - className === null - ? { status: "partial", reason: "category_class_unresolved" } - : { status: "decoded", reason: null }, + decodeReasons.length === 0 + ? { status: "decoded", reason: null } + : { status: "partial", reason: decodeReasons.join("; ") }, evidence: read.evidence(category, "Objective-C category_t record"), }); } catch (cause: unknown) { diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 089d1906b1..5f507336e8 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -103,6 +103,7 @@ export const decodeObjcDispatchFacets = (input: { className: string, methodType: "instance" | "class", category?: string, + onTruncated?: () => void, ): string[] => { const selectors: string[] = []; if (list === 0n) return selectors; @@ -113,7 +114,10 @@ export const decodeObjcDispatchFacets = (input: { if (stride !== (relative ? 12 : 24)) throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`); for (let index = 0; index < count; index++) { - if (!budget.admit()) break; + if (!budget.admit()) { + onTruncated?.(); + break; + } const entry = list + 8n + BigInt(index * stride); const rel = (field: bigint) => { const displacement = bytes.readInt32LE(read.offset(field, 4)); @@ -279,6 +283,10 @@ export const decodeObjcDispatchFacets = (input: { ); const ivarList = read.pointer(ro + 48n); let ivarCount: number | null = meta ? null : 0; + let classListTruncated = false; + const onTruncated = () => { + classListTruncated = true; + }; if (ivarList !== 0n && !meta) { const stride = read.u32(ivarList), count = read.u32(ivarList + 4n); @@ -286,7 +294,10 @@ export const decodeObjcDispatchFacets = (input: { throw new TypeError("Unsupported Objective-C ivar entry size"); ivarCount = count; for (let index = 0; index < count; index++) { - if (!budget.admit()) break; + if (!budget.admit()) { + classListTruncated = true; + break; + } const entry = ivarList + 8n + BigInt(index * stride); const encodedOffset = read.pointer(entry); let value: number | null = null; @@ -326,7 +337,27 @@ export const decodeObjcDispatchFacets = (input: { }); } } - methods(read.pointer(ro + 32n), name, meta ? "class" : "instance"); + methods( + read.pointer(ro + 32n), + name, + meta ? "class" : "instance", + undefined, + onTruncated, + ); + const properties = readObjcPropertiesOf(read, read.pointer(ro + 64n), { + owner: name, + admit: () => budget.admit(), + issues: categoryIssues, + location: hex(read.pointer(ro + 64n)), + onTruncated, + }); + const protocols = protocolReader.list(read.pointer(ro + 40n), onTruncated); + const classDecodeReasons = [ + ...(superclass.name === null && !root + ? ["superclass_binding_unresolved"] + : []), + ...(classListTruncated ? ["max_records_reached"] : []), + ]; result.objc_classes.push({ name, super_class: superclass.name, @@ -350,22 +381,17 @@ export const decodeObjcDispatchFacets = (input: { is_required: null, is_optional: null, })), - properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), { - owner: name, - admit: () => budget.admit(), - issues: categoryIssues, - location: hex(read.pointer(ro + 64n)), - }), - protocols: protocolReader.list(read.pointer(ro + 40n)), + properties, + protocols, ivar_count: ivarCount, instance_size: read.u32(ro + 8n), location: read.location(address), superclass_address: superclass.address, metaclass_address: meta ? null : hex(read.pointer(address)), decode: - superclass.name === null && !root - ? { status: "partial", reason: "superclass_binding_unresolved" } - : decoded, + classDecodeReasons.length === 0 + ? decoded + : { status: "partial", reason: classDecodeReasons.join("; ") }, evidence: read.evidence( address, "Objective-C class and class_ro_t records", @@ -422,7 +448,7 @@ export const decodeObjcDispatchFacets = (input: { issues: categoryIssues, admit: () => budget.admit(), methods, - protocols: (list) => protocolReader.list(list), + protocols: (list, onTruncated) => protocolReader.list(list, onTruncated), localClassName: (address) => readLocalClass(read, address).name, }); return { diff --git a/src/native/AppleObjcProperties.ts b/src/native/AppleObjcProperties.ts index 266d5ead62..4c820c72f0 100644 --- a/src/native/AppleObjcProperties.ts +++ b/src/native/AppleObjcProperties.ts @@ -107,6 +107,7 @@ export const readObjcPropertyList = ( read: ObjcPropertyReaders, list: bigint, admit: () => boolean, + onTruncated?: () => void, ): { readonly properties: ObjcProperty[]; readonly issues: PropertyListIssue[]; @@ -121,7 +122,10 @@ export const readObjcPropertyList = ( `Unsupported Objective-C property entry size ${entrySize}`, ); for (let index = 0; index < count; index++) { - if (!admit()) break; + if (!admit()) { + onTruncated?.(); + break; + } const entry = list + 8n + BigInt(index * entrySize); const location = `0x${entry.toString(16)}`; try { @@ -158,6 +162,7 @@ export const readObjcPropertiesOf = ( readonly admit: () => boolean; readonly issues: DecodeIssue[]; readonly location?: string; + readonly onTruncated?: () => void; }, ): ObjcProperty[] => { try { @@ -165,6 +170,7 @@ export const readObjcPropertiesOf = ( read, list, context.admit, + context.onTruncated, ); for (const item of issues) context.issues.push( diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 1d130b8685..13bc76f8d2 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -18,7 +18,7 @@ export const createObjcProtocolReader = (input: { const { readers: read, result, failures } = input; const visited = new Set(); const name = (address: bigint) => read.string(read.pointer(address + 8n)); - const list = (address: bigint): string[] => { + const list = (address: bigint, onTruncated?: () => void): string[] => { if (address === 0n) return []; // `count` is plain uintptr_t data, not a pointer fixup. const count = read.u64(address); @@ -26,7 +26,10 @@ export const createObjcProtocolReader = (input: { throw new RangeError("Protocol list exceeds 20000 entries"); const names: string[] = []; for (let index = 0n; index < count; index++) { - if (!read.admit()) break; + if (!read.admit()) { + onTruncated?.(); + break; + } const protocol = read.pointer(address + 8n + index * 8n); names.push(name(protocol)); record(protocol); @@ -39,6 +42,7 @@ export const createObjcProtocolReader = (input: { instance: boolean, /** Entries skipped for an empty selector, reported by the declaring protocol. */ emptySelectors: string[], + onTruncated?: () => void, ) => { const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = []; if (address === 0n) return output; @@ -49,7 +53,10 @@ export const createObjcProtocolReader = (input: { if (stride !== (relative ? 12 : 24)) throw new TypeError("Unsupported Objective-C protocol method entry size"); for (let index = 0; index < count; index++) { - if (!read.admit()) break; + if (!read.admit()) { + onTruncated?.(); + break; + } const entry = address + 8n + BigInt(index * stride); let selector = relative ? entry + BigInt(read.i32(entry)) @@ -84,22 +91,54 @@ export const createObjcProtocolReader = (input: { const protocolName = name(address); // Adopted protocols are independent declarations; decode them before // rejecting a parent whose own name is empty. - const adopted = list(read.pointer(address + 16n)); + let budgetTruncated = false; + const onTruncated = () => { + budgetTruncated = true; + }; + const adopted = list(read.pointer(address + 16n), onTruncated); if (protocolName.length === 0) throw new TypeError("Objective-C protocol has an empty name"); const skipped: string[] = []; const required = [ - ...methods(read.pointer(address + 24n), true, true, skipped), - ...methods(read.pointer(address + 32n), true, false, skipped), + ...methods( + read.pointer(address + 24n), + true, + true, + skipped, + onTruncated, + ), + ...methods( + read.pointer(address + 32n), + true, + false, + skipped, + onTruncated, + ), ]; const optional = [ - ...methods(read.pointer(address + 40n), false, true, skipped), - ...methods(read.pointer(address + 48n), false, false, skipped), + ...methods( + read.pointer(address + 40n), + false, + true, + skipped, + onTruncated, + ), + ...methods( + read.pointer(address + 48n), + false, + false, + skipped, + onTruncated, + ), ]; for (const message of skipped) failures.push( `Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} ${message}`, ); + const decodeReasons = [ + ...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]), + ...(budgetTruncated ? ["max_records_reached"] : []), + ]; result.objc_protocols.push({ name: protocolName, methods: required, @@ -113,9 +152,9 @@ export const createObjcProtocolReader = (input: { optional_methods: optional, location: read.location(address), decode: - skipped.length === 0 + decodeReasons.length === 0 ? { status: "decoded", reason: null } - : { status: "partial", reason: "protocol_method_selector_empty" }, + : { status: "partial", reason: decodeReasons.join("; ") }, evidence: read.evidence( address, "Objective-C protocol declaration and encoded method lists; IMP fields are not handler targets", diff --git a/tests/boundary/cli/nativeDispatchRecordBudget.test.ts b/tests/boundary/cli/nativeDispatchRecordBudget.test.ts new file mode 100644 index 0000000000..2d8b521226 --- /dev/null +++ b/tests/boundary/cli/nativeDispatchRecordBudget.test.ts @@ -0,0 +1,64 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js"; + +import { objcRecordBudgetMacho } from "../../fixtures/objcRecordBudgetMacho.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +describe.skipIf(process.platform !== "darwin")( + "native dispatch declaration-local record budget", + () => { + cliTest.for([["protocol"], ["class"]] as const)( + "marks an omitted $0 method list partial in public CLI output", + async ([owner], { cli }) => { + const directory = await createTestTempDirectory("rea-objc-budget-"); + const path = join(directory, "fixture.macho"); + await writeFile(path, objcRecordBudgetMacho(owner)); + const output = await cli.run({ + arguments: [ + "inspect-native-dispatch-metadata", + path, + "--max-records", + "1", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0); + const result = nativeDispatchMetadataResultSchema.parse( + parseEvidence(output.json).normalized_result, + ).result; + if (owner === "protocol") { + expect(result?.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + } else { + expect(result?.objc_classes).toMatchObject([ + { + name: "FixtureClass", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + } + expect(result?.coverage).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + facet: "objc_class_method_ivar_metadata", + status: "partial", + reason: expect.stringContaining("max_records_reached"), + }), + ]), + ); + }, + ); + }, +); diff --git a/tests/fixtures/objcRecordBudgetMacho.ts b/tests/fixtures/objcRecordBudgetMacho.ts new file mode 100644 index 0000000000..53329226ea --- /dev/null +++ b/tests/fixtures/objcRecordBudgetMacho.ts @@ -0,0 +1,72 @@ +const base = 0x100000000n; + +/** Minimal inert Mach-O with one class instance method and protocol method. */ +export const objcRecordBudgetMacho = (owner: "class" | "protocol"): Buffer => { + const bytes = Buffer.alloc(4096); + const u32 = (offset: number, value: number) => + bytes.writeUInt32LE(value, offset); + const pointer = (offset: number, target: number) => + bytes.writeBigUInt64LE(base + BigInt(target), offset); + const nullPointer = (offset: number) => bytes.writeBigUInt64LE(0n, offset); + + u32(0, 0xfeedfacf); + u32(4, 0x0100000c); + u32(12, 2); + u32(16, 1); + u32(20, owner === "protocol" ? 232 : 152); + u32(32, 0x19); + u32(36, owner === "protocol" ? 232 : 152); + bytes.write("__DATA", 40); + pointer(56, 0); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + u32(88, 7); + u32(92, 5); + u32(96, owner === "protocol" ? 2 : 1); + bytes.write("__objc_classlist", 104); + bytes.write("__DATA", 120); + pointer(136, 0x180); + bytes.writeBigUInt64LE(8n, 144); + u32(152, 0x180); + if (owner === "protocol") { + bytes.write("__objc_protolist", 184); + bytes.write("__DATA", 200); + pointer(216, 0x6c0); + bytes.writeBigUInt64LE(8n, 224); + u32(232, 0x6c0); + pointer(0x6c0, 0x740); + } + + pointer(0x180, 0x200); + // class_t: zero isa/superclass; class_ro_t is root, named and has one method. + nullPointer(0x200); + nullPointer(0x208); + pointer(0x220, 0x300); + u32(0x300, 2); + u32(0x308, 8); + pointer(0x318, 0x900); + pointer(0x320, 0x500); + nullPointer(0x328); + nullPointer(0x330); + nullPointer(0x340); + bytes.write("FixtureClass\0", 0x900); + u32(0x500, 24); + u32(0x504, 1); + pointer(0x508, 0x920); + pointer(0x510, 0x940); + pointer(0x518, 0x700); + bytes.write("classMethod\0", 0x920); + bytes.write("v@:\0", 0x940); + + // Protocol method lists use the same declaration boundary and budget. + pointer(0x6c0, 0x740); + pointer(0x748, 0x980); + pointer(0x758, 0x800); + u32(0x780, 72); + u32(0x800, 24); + u32(0x804, 1); + pointer(0x808, 0x9a0); + bytes.write("FixtureProtocol\0", 0x980); + bytes.write("protocolMethod\0", 0x9a0); + return bytes; +}; From 0ba4d6f43ad610dacc861a1548be1a5c4712cd61 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:31:45 +0800 Subject: [PATCH 3/4] fix(native): bind dispatch snapshots to the local reader profile Retain native-only dispatch workflows without requiring a deep provider profile. Validate exact target, operation, and current reader bindings during open, import, recording, and export; preserve selected deep bindings and caller limits. Validated public CLI and stdio MCP save/reopen, exact CLI replay, changed-limit cache partitioning, baseline failure and candidate success on an Apple-clang fixture, snapshot lifecycle regressions, typecheck and lint. No target or deep engine execution. Fixes #5529. --- src/application/AnalysisProvider.ts | 2 + src/application/DirectAnalysis.ts | 26 +- src/application/WorkflowEvidence.test.ts | 1 + src/application/WorkflowEvidence.ts | 8 +- src/application/binary/BinarySession.ts | 14 +- src/application/binary/BinarySessionOpen.ts | 24 +- .../binary/BinarySessionRecords.ts | 61 ++++- .../binary/BinarySessionStatus.test.ts | 20 ++ .../ProfilelessNativeWorkflowSnapshot.ts | 59 ++++ src/native/AppleDispatchMetadata.ts | 13 +- src/native/NativeMacOSProvider.ts | 2 + src/native/NativeMacOSProviderMetadata.ts | 17 ++ src/server/createServer.ts | 5 +- src/server/registerEnhancedTools.ts | 7 +- .../acceptance/nativeDispatchSnapshot.test.ts | 118 ++++++++ .../directAnalysisSnapshotReplay.test.ts | 259 ++++++++++++++++++ 16 files changed, 613 insertions(+), 23 deletions(-) create mode 100644 src/application/binary/ProfilelessNativeWorkflowSnapshot.ts create mode 100644 tests/acceptance/nativeDispatchSnapshot.test.ts diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 397d5069f0..ea0af2b62d 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -279,6 +279,8 @@ export type CapabilityDescriptor = CapabilityAvailability & { * exclusions apply to every policy. */ readonly cachePolicy?: "snapshot" | "live"; + /** Exact source profile for an operation implemented by a versioned local reader. */ + readonly analysisProfile?: AnalysisProfileCommitment; readonly provider: ProviderIdentity; readonly operation: Exclude; readonly effects: CapabilityEffects; diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 99ce97d8dd..157309c616 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -34,6 +34,7 @@ import { snapshotMatchesTarget, } from "../domain/analysisSnapshot.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; +import { profilelessNativeDispatchProfile } from "./binary/ProfilelessNativeWorkflowSnapshot.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; import type { @@ -285,7 +286,8 @@ const runAnalysis = async ( if (!preview.ok) return cliError(preview.error); resolvedTarget = preview.value; const route = preview.value.route; - const bindingProfile = route.profile ?? undefined; + const bindingProfile = + route.profile ?? nativeDispatchBindingProfile(route, tool); const evidenceProfile = analysisProfileForRoute(route, tool); // A parsed Windows path does not prove Ghidra's native snapshot owner // admitted the selected source; CLI replay must wait for that client. @@ -365,6 +367,7 @@ const runAnalysis = async ( const workflowRecord = workflowSnapshotRecord( evidence, tool, + evidenceProfile, cachePolicy, ); if (workflowRecord !== undefined) { @@ -600,13 +603,30 @@ const analysisProfileForRoute = ( | DirectAnalysisTool, ): AnalysisProfileCommitment | undefined => { const profile = route.profile; - if (profile === null || profile === undefined) return undefined; + const nativeDispatchProfile = + profile === null || profile === undefined + ? nativeDispatchBindingProfile(route, tool) + : undefined; + if (profile === null || profile === undefined) { + if (nativeDispatchProfile === undefined) return undefined; + return isWorkflowEvidenceTool(tool) + ? workflowAnalysisProfile(nativeDispatchProfile, tool) + : nativeDispatchProfile; + } if (isWorkflowEvidenceTool(tool)) return workflowAnalysisProfile(profile, tool); const provider = providerIdentityForRoute(route, tool); return provider.id === profile.provider.id ? profile : undefined; }; +const nativeDispatchBindingProfile = ( + route: SessionProviderRoute, + operation: string, +): AnalysisProfileCommitment | undefined => { + if (operation !== "inspect_native_dispatch_metadata") return undefined; + return profilelessNativeDispatchProfile(route); +}; + /** Mirror BinarySession.providerIdentity for a route not opened yet. */ const providerIdentityForRoute = ( route: SessionProviderRoute, @@ -623,7 +643,7 @@ const analysisProfileForEvidence = ( | DirectAnalysisTool, ): AnalysisProfileCommitment | undefined => { if (!isWorkflowEvidenceTool(tool)) return session.analysisProfile(tool); - return session.analysisProfile(); + return session.analysisProfile() ?? session.analysisProfile(tool); }; const isWorkflowEvidenceTool = ( diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index 8234a82810..333fa33d32 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -59,6 +59,7 @@ it.each([false, true])( workflowSnapshotRecord( evidence, "inspect_native_api", + ANALYSIS_SNAPSHOT_PROFILE, composed.value.cachePolicy, ) === undefined, ).toBe(transient); diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index b01a98bc4b..16d3bc97aa 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -133,12 +133,18 @@ const batchProcedureLocations = (result: JsonValue): EvidenceLocation[] => { export const workflowSnapshotRecord = ( evidence: Evidence, operation: AnalysisOperation, + bindingProfile: AnalysisProfileCommitment | undefined, cachePolicy?: "live", ): WorkflowSnapshotRecordInput | undefined => { - if (evidence.analysis_profile === null || cachePolicy === "live") + if ( + evidence.analysis_profile === null || + bindingProfile === undefined || + cachePolicy === "live" + ) return undefined; return { operation, + bindingProfile, parameters: evidence.parameters, execution: { result: evidence.normalized_result, diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index b6af1d4a7d..6ed47deb65 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -48,6 +48,7 @@ import { } from "./BinarySessionExecution.js"; import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js"; const OFFICIAL_OPERATIONS: ReadonlySet = new Set( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), ); @@ -64,6 +65,7 @@ interface SessionRun { interface SessionBinding extends SessionRun { readonly target: BinaryTarget; readonly profile: AnalysisProfileCommitment | null; + readonly profilelessWorkflowProfile?: AnalysisProfileCommitment; nativeSourceAdmitted: boolean; } @@ -173,7 +175,12 @@ export class BinarySession operation?: AnalysisOperation, ): AnalysisProfileCommitment | undefined { const profile = this.#active?.profile; - if (profile === null || profile === undefined) return undefined; + if (profile === null || profile === undefined) { + if (operation !== "inspect_native_dispatch_metadata") return undefined; + return this.#active?.profilelessWorkflowProfile === undefined + ? undefined + : structuredClone(this.#active.profilelessWorkflowProfile); + } if ( operation !== undefined && this.providerIdentity(operation).id !== profile.provider.id @@ -358,9 +365,14 @@ export class BinarySession ), ); } + const profilelessWorkflowProfile = + profile === null ? profilelessNativeDispatchProfile(route) : undefined; this.#active = { target, profile, + ...(profilelessWorkflowProfile === undefined + ? {} + : { profilelessWorkflowProfile }), ...run, nativeSourceAdmitted: false, }; diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index bb1631f350..2d633b15e1 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -17,6 +17,10 @@ import { import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, type Result } from "../../domain/result.js"; import type { SessionProviderRoute } from "./SessionProviderRouter.js"; +import { + isProfilelessNativeDispatchSnapshot, + profilelessNativeDispatchProfile, +} from "./ProfilelessNativeWorkflowSnapshot.js"; import { SessionProviderRouter } from "./SessionProviderRouter.js"; /** Read a staged snapshot once the open holds its place in the session order. */ @@ -126,6 +130,7 @@ export const validateResolvedSessionTarget = ( snapshot: options.snapshot, target, profile: route.profile, + profilelessWorkflowProfile: profilelessNativeDispatchProfile(route), current, stagedSnapshotMatches, }); @@ -147,6 +152,7 @@ interface ValidateSnapshotInput { readonly snapshot: AnalysisSnapshot | undefined; readonly target: BinaryTarget; readonly profile: AnalysisProfileCommitment | null; + readonly profilelessWorkflowProfile: AnalysisProfileCommitment | undefined; readonly current: CurrentOpenBinding | undefined; readonly stagedSnapshotMatches: ( target: BinaryTarget, @@ -158,17 +164,31 @@ const validateSnapshot = ({ snapshot, target, profile, + profilelessWorkflowProfile, current, stagedSnapshotMatches, }: ValidateSnapshotInput): EvidenceIntegrityError | undefined => { if ( snapshot !== undefined && - (profile === null || !snapshotMatchesBinding(snapshot, target, profile)) + (profile === null + ? profilelessWorkflowProfile === undefined || + !isProfilelessNativeDispatchSnapshot( + snapshot, + target, + profilelessWorkflowProfile, + ) + : !snapshotMatchesBinding(snapshot, target, profile)) ) return new AnalysisSnapshotMismatchError( "Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary", ); - if (current === undefined && !stagedSnapshotMatches(target, profile)) + if ( + current === undefined && + !stagedSnapshotMatches( + target, + profile ?? profilelessWorkflowProfile ?? null, + ) + ) return new AnalysisSnapshotMismatchError( "Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match", ); diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 83f02aa58c..edd9e15cfb 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -1,4 +1,5 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; +import { analysisProfilesEqual } from "../../domain/analysisProfile.js"; import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { Evidence } from "../../domain/evidence.js"; @@ -33,6 +34,11 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { writeAnalysisSnapshot as writeSnapshotFile } from "./AnalysisSnapshotFiles.js"; +import { workflowAnalysisProfile } from "../InvestigationProviders.js"; +import { + hasOnlyProfilelessNativeDispatchEntries, + isProfilelessNativeDispatchSnapshot, +} from "./ProfilelessNativeWorkflowSnapshot.js"; const SNAPSHOT_MUTATION_RECOVERY = "Export session observations through export_evidence_bundle if needed. Close the active target without saving a snapshot, then reopen it before importing or saving a snapshot."; @@ -40,11 +46,14 @@ const SNAPSHOT_MUTATION_RECOVERY = export interface ActiveAnalysisBinding { readonly target: BinaryTarget; readonly profile: AnalysisProfileCommitment | null; + /** Current route profile for the single profileless native dispatch workflow. */ + readonly profilelessWorkflowProfile?: AnalysisProfileCommitment; } /** Validated composed-workflow payload bound to the active target and profile. */ export interface WorkflowSnapshotRecordInput { readonly operation: AnalysisOperation; + readonly bindingProfile: AnalysisProfileCommitment; readonly parameters: Readonly>; readonly execution: Parameters< AnalysisSnapshotCache["recordWorkflow"] @@ -160,13 +169,30 @@ export abstract class BinarySessionRecords { ), ); const target = active?.target; - const profile = active?.profile ?? undefined; + const profile = + active?.profile ?? active?.profilelessWorkflowProfile ?? undefined; if (target !== undefined && profile === undefined) return err( new EvidenceIntegrityError( "Analysis snapshots require a concrete provider analysis profile", ), ); + if ( + active?.profile === null && + (target === undefined || + profile === undefined || + !this.#snapshot.matches(target, profile) || + this.#snapshot.entries().length !== 0 || + !hasOnlyProfilelessNativeDispatchEntries( + this.#snapshot.workflowEntries(), + profile, + )) + ) + return err( + new EvidenceIntegrityError( + "Profileless native snapshots may contain only current dispatch workflow observations", + ), + ); return this.#snapshot.export( target, profile, @@ -192,17 +218,24 @@ export abstract class BinarySessionRecords { }, ), ); - if (active?.profile === null) + const profile = active?.profile ?? active?.profilelessWorkflowProfile; + if ( + active?.profile === null && + (profile === undefined || + !isProfilelessNativeDispatchSnapshot(snapshot, active.target, profile)) + ) return err( new AnalysisSnapshotMismatchError( - "Analysis snapshot profile_mismatch: the active target has no concrete analysis profile", + "Analysis snapshot profile_mismatch: the snapshot is not bound to the active native dispatch reader", ), ); const imported = this.#snapshot.import( snapshot, active === undefined ? undefined - : { target: active.target, profile: active.profile }, + : profile === undefined + ? undefined + : { target: active.target, profile }, (bundle) => { const imported = this.#records.mergeEvidenceBundle(bundle); return imported.ok ? ok(imported.value.recordsAdded) : imported; @@ -252,17 +285,33 @@ export abstract class BinarySessionRecords { input: WorkflowSnapshotRecordInput, ): Result { const active = this.activeAnalysisBinding(); - if (active === undefined || active.profile === null) + if (active === undefined) return err( new EvidenceIntegrityError( "Workflow snapshot entries require an active concrete provider profile", ), ); if (this.#snapshotInvalidated) return ok(null); + const profile = active.profile ?? active.profilelessWorkflowProfile; + if ( + profile === undefined || + !analysisProfilesEqual(profile, input.bindingProfile) || + !analysisProfilesEqual( + input.execution.analysisProfile, + workflowAnalysisProfile(profile, input.operation), + ) || + (active.profile === null && + input.operation !== "inspect_native_dispatch_metadata") + ) + return err( + new EvidenceIntegrityError( + "Workflow snapshot entry does not match the active reader profile", + ), + ); try { this.#snapshot.recordWorkflow({ target: active.target, - profile: active.profile, + profile, ...input, }); this.#emitSnapshotChanged(); diff --git a/src/application/binary/BinarySessionStatus.test.ts b/src/application/binary/BinarySessionStatus.test.ts index 537c3a9669..ba1c451441 100644 --- a/src/application/binary/BinarySessionStatus.test.ts +++ b/src/application/binary/BinarySessionStatus.test.ts @@ -16,6 +16,11 @@ import type { SessionProviderRouter, } from "./SessionProviderRouter.js"; import { binarySessionStatus } from "./BinarySessionStatus.js"; +import { + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + NATIVE_MACOS_PROVIDER_IDENTITY, + nativeMacOSCapabilities, +} from "../../native/NativeMacOSProviderMetadata.js"; const projectedStatusSchema = sessionProvider.pick({ capabilities: true, @@ -326,6 +331,21 @@ const expectedExpandedCandidateCapabilities = [ ]; describe("binary session provider capability projection", () => { + it("keeps the private dispatch reader profile out of public capability status", async () => { + const capabilities = nativeMacOSCapabilities("darwin"); + const projected = await project(capabilities, [ + candidate(NATIVE_MACOS_PROVIDER_IDENTITY, capabilities), + ]); + const serialized = JSON.stringify(projected); + expect(serialized).not.toContain( + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider.version, + ); + expect(serialized).not.toContain( + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest, + ); + expect(projectedStatusSchema.parse(projected)).toEqual(projected); + }); + it("preserves provider scope and route overlays while factoring exact prefixes", async () => { const selectedBefore = structuredClone(selectedCapabilities); const otherBefore = structuredClone(otherCapabilities); diff --git a/src/application/binary/ProfilelessNativeWorkflowSnapshot.ts b/src/application/binary/ProfilelessNativeWorkflowSnapshot.ts new file mode 100644 index 0000000000..ea9b57d456 --- /dev/null +++ b/src/application/binary/ProfilelessNativeWorkflowSnapshot.ts @@ -0,0 +1,59 @@ +import { analysisProfilesEqual } from "../../domain/analysisProfile.js"; +import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; +import { + snapshotMatchesBinding, + type AnalysisSnapshot, +} from "../../domain/analysisSnapshot.js"; +import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import { workflowAnalysisProfile } from "../InvestigationProviders.js"; +import type { SessionProviderRoute } from "./SessionProviderRouter.js"; + +/** Return the exact currently declared native dispatch reader profile, if any. */ +export const profilelessNativeDispatchProfile = ( + route: SessionProviderRoute, +): AnalysisProfileCommitment | undefined => { + const descriptor = route.capabilities.get("inspect_native_dispatch_metadata"); + const profile = descriptor?.analysisProfile; + if ( + descriptor === undefined || + descriptor.operation !== "inspect_native_dispatch_metadata" || + !descriptor.available || + descriptor.provider.id !== "native-macos" || + profile === undefined || + profile.provider.id !== descriptor.provider.id || + profile.provider.name !== descriptor.provider.name || + profile.provider.version.length === 0 + ) + return undefined; + return structuredClone(profile); +}; + +/** Require only exact native dispatch workflow observations for this target/profile. */ +export const isProfilelessNativeDispatchSnapshot = ( + snapshot: AnalysisSnapshot, + target: BinaryTarget, + profile: AnalysisProfileCommitment, +): boolean => + snapshotMatchesBinding(snapshot, target, profile) && + snapshot.entries.length === 0 && + hasOnlyProfilelessNativeDispatchEntries(snapshot.workflow_entries, profile); + +/** Reject mixed operations and entries derived from a different source profile. */ +export const hasOnlyProfilelessNativeDispatchEntries = ( + entries: readonly { + readonly operation: string; + readonly execution: { + readonly analysis_profile: AnalysisProfileCommitment; + }; + }[], + profile: AnalysisProfileCommitment, +): boolean => + entries.length > 0 && + entries.every( + (entry) => + entry.operation === "inspect_native_dispatch_metadata" && + analysisProfilesEqual( + entry.execution.analysis_profile, + workflowAnalysisProfile(profile, entry.operation), + ), + ); diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index ef1d345ec2..3a455c839f 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -26,7 +26,10 @@ import { AnalysisError } from "../domain/analysisErrorBase.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js"; +import { + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY, +} from "./NativeMacOSProviderMetadata.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { pushDispatchCoverage } from "./AppleDispatchCoverage.js"; @@ -249,12 +252,8 @@ export const inspectAppleDispatchMetadata = async ( ); return nativeDispatchMetadataResultSchema.parse({ target_sha256: target.sha256, - provider: { - id: "native-macos", - name: "macOS native inspection utilities", - version: "apple-metadata-reader-1", - }, - analysis_profile_digest: null, + provider: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider, + analysis_profile_digest: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest, result: decodeAppleDispatchMetadata( bytes, maxRecords, diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index bcd886088d..c97fb01f9d 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -8,6 +8,7 @@ import { import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY, nativeMacOSCapabilities, } from "./NativeMacOSProviderMetadata.js"; @@ -237,6 +238,7 @@ class NativeMacOSClient implements AnalysisClient { ); return ok( createAnalysisExecution(result, result.provider, { + analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, locations: [{ kind: "artifact-path", path: this.target.path }], limitations: result.result.coverage.flatMap(({ reason }) => reason === null ? [] : [reason], diff --git a/src/native/NativeMacOSProviderMetadata.ts b/src/native/NativeMacOSProviderMetadata.ts index 18e7a100da..6807c5e0dc 100644 --- a/src/native/NativeMacOSProviderMetadata.ts +++ b/src/native/NativeMacOSProviderMetadata.ts @@ -2,6 +2,7 @@ import type { CapabilityDescriptor, ProviderIdentity, } from "../application/AnalysisProvider.js"; +import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; /** Public identity committed by macOS-native inspection observations. */ @@ -10,6 +11,19 @@ export const NATIVE_MACOS_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ name: "macOS native inspection utilities", version: null, }); +/** Source-owned commitment for the pure Apple dispatch metadata reader. */ +export const APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE = createAnalysisProfile( + { + id: NATIVE_MACOS_PROVIDER_IDENTITY.id, + name: NATIVE_MACOS_PROVIDER_IDENTITY.name, + version: "apple-metadata-reader-1", + }, + { + operation: "inspect_native_dispatch_metadata", + reader: "apple-dispatch-metadata", + revision: 1, + }, +); /** Operations whose target process can show UI, use the network and write files. */ const OWNED_PROCESS_OPERATIONS: ReadonlySet = new Set([ "capture_native_ui_scenario", @@ -36,6 +50,9 @@ export const nativeMacOSCapabilities = ( return Object.freeze({ provider: NATIVE_MACOS_PROVIDER_IDENTITY, operation: contract.name, + ...(contract.name === "inspect_native_dispatch_metadata" + ? { analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } + : {}), ...availability, effects: Object.freeze({ mutatesArtifact: false, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 82fcb033ef..41ec88e2e8 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -905,7 +905,10 @@ const registerBinaryAnalysisTools = ({ registerEnhancedTools(server, { ...analysisOptions, analysisProfile: - session === undefined ? undefined : () => session.analysisProfile(), + session === undefined + ? undefined + : (operation) => + session.analysisProfile() ?? session.analysisProfile(operation), allowsSnapshotReplay: session === undefined ? undefined diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index 4421658b03..6efdcccdbf 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -37,7 +37,9 @@ export interface EnhancedToolRegistration { readonly logger: Logger; readonly activeTarget: (() => BinaryTarget | undefined) | undefined; readonly analysisProfile: - | (() => AnalysisProfileCommitment | undefined) + | (( + operation: ValidatedEnhancedCall["name"], + ) => AnalysisProfileCommitment | undefined) | undefined; readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined; @@ -260,7 +262,7 @@ const executeEnhancedTool = async ( operation: name, parameters, result: result.value.result, - upstreamProfile: registration.analysisProfile?.(), + upstreamProfile: registration.analysisProfile?.(name), upstreamLimitations: result.value.limitations, }); const recorded = registration.recordEvidence?.(evidence); @@ -273,6 +275,7 @@ const executeEnhancedTool = async ( const workflowRecord = workflowSnapshotRecord( evidence, name, + registration.analysisProfile?.(name), result.value.cachePolicy, ); if (workflowRecord !== undefined) { diff --git a/tests/acceptance/nativeDispatchSnapshot.test.ts b/tests/acceptance/nativeDispatchSnapshot.test.ts new file mode 100644 index 0000000000..cb2df2fafe --- /dev/null +++ b/tests/acceptance/nativeDispatchSnapshot.test.ts @@ -0,0 +1,118 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { readAnalysisSnapshot } from "../../src/application/binary/AnalysisSnapshotFiles.js"; +import { APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } from "../../src/native/NativeMacOSProviderMetadata.js"; +import { + CPU, + machoImage, +} from "../../src/artifacts/apple/MachoImage.fixture.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform !== "darwin")( + "retains and reopens profileless native dispatch workflow snapshots through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-native-dispatch-snapshot-"); + const target = join(root, "minimal.macho"); + const cliSnapshot = join(root, "cli-snapshot.json"); + const mcpSnapshot = join(root, "mcp-snapshot.json"); + const bytes = machoImage({ cpu: CPU.arm64, commands: [] }); + await writeFile(target, bytes); + const isolatedProviderEnvironment = { + PATH: process.env.PATH ?? "", + REA_ANALYSIS_PROVIDER: "auto", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", + GHIDRA_INSTALL_DIR: "/rea-unconfigured-deep-provider/ghidra", + JAVA_HOME: "/rea-unconfigured-deep-provider/java", + REA_IDA_MCP_CONFIG: "/rea-unconfigured-deep-provider/ida-mcp.json", + REA_KUNA_COMMAND: "/rea-unconfigured-deep-provider/kuna", + REA_KUNA_CACHE_DIR: "/rea-unconfigured-deep-provider/kuna-cache", + }; + + const cliArguments = [ + "inspect-native-dispatch-metadata", + target, + "--snapshot", + cliSnapshot, + "--json", + ]; + const firstCli = await cli.run({ + arguments: cliArguments, + environment: isolatedProviderEnvironment, + }); + expect(firstCli.exitCode, firstCli.stderr).toBe(0); + const cliEvidence = toolContract( + "inspect_native_dispatch_metadata", + ).outputSchema.parse(firstCli.json); + const cliSaved = await readAnalysisSnapshot(cliSnapshot); + if (!cliSaved.ok) throw cliSaved.error; + expect(cliSaved.value.binding.analysis_profile).toEqual( + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + ); + expect(cliSaved.value.entries).toEqual([]); + expect(cliSaved.value.workflow_entries).toHaveLength(1); + const replayCli = await cli.run({ + arguments: cliArguments, + environment: isolatedProviderEnvironment, + }); + expect(replayCli.exitCode, replayCli.stderr).toBe(0); + expect( + toolContract("inspect_native_dispatch_metadata").outputSchema.parse( + replayCli.json, + ), + ).toEqual(cliEvidence); + const changedLimitCli = await cli.run({ + arguments: [...cliArguments, "--max-records", "1"], + environment: isolatedProviderEnvironment, + }); + expect(changedLimitCli.exitCode, changedLimitCli.stderr).toBe(0); + const changedLimitEvidence = parseEvidence(changedLimitCli.json); + expect(changedLimitEvidence.parameters.max_records).toBe(1); + expect(changedLimitEvidence.evidence_id).not.toBe(cliEvidence.evidence_id); + const changedLimitSnapshot = await readAnalysisSnapshot(cliSnapshot); + if (!changedLimitSnapshot.ok) throw changedLimitSnapshot.error; + expect(changedLimitSnapshot.value.workflow_entries).toHaveLength(2); + + const { client, call } = await connectLocalToolsMcp( + isolatedProviderEnvironment, + ); + const opened = await call("open_binary", { path: target }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const firstMcp = await call("inspect_native_dispatch_metadata", { + max_records: 100, + }); + expect(firstMcp.isError, JSON.stringify(firstMcp)).not.toBe(true); + const mcpEvidence = parseEvidence(firstMcp.structuredContent); + const closed = await call("close_binary", { + snapshot_path: mcpSnapshot, + overwrite: true, + }); + expect(closed.isError, JSON.stringify(closed)).not.toBe(true); + const mcpSaved = await readAnalysisSnapshot(mcpSnapshot); + if (!mcpSaved.ok) throw mcpSaved.error; + expect(mcpSaved.value.binding.analysis_profile).toEqual( + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + ); + expect(mcpSaved.value.entries).toEqual([]); + expect(mcpSaved.value.workflow_entries).toHaveLength(1); + + const reopened = await call("open_binary", { + path: target, + snapshot_path: mcpSnapshot, + }); + expect(reopened.isError, JSON.stringify(reopened)).not.toBe(true); + const reobserved = await call("inspect_native_dispatch_metadata", { + max_records: 100, + }); + expect(reobserved.isError, JSON.stringify(reobserved)).not.toBe(true); + expect(parseEvidence(reobserved.structuredContent)).toEqual(mcpEvidence); + expect(await readFile(target)).toEqual(Buffer.from(bytes)); + await client.ping(); + }, + 30000, +); diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 19bb92eb30..8ccc85e780 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -30,6 +30,7 @@ import { workflowAnalysisProfile, } from "../../../src/application/InvestigationProviders.js"; import { + analysisQueryId, createAnalysisSnapshotWorkflowEntry, type AnalysisSnapshot, } from "../../../src/domain/analysisSnapshot.js"; @@ -39,6 +40,7 @@ import { createTestProviderRouter, } from "../../fixtures/binarySession.js"; import { BinarySession } from "../../../src/application/binary/BinarySession.js"; +import { isProfilelessNativeDispatchSnapshot } from "../../../src/application/binary/ProfilelessNativeWorkflowSnapshot.js"; import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js"; import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -46,6 +48,7 @@ import { CAPABILITIES as GHIDRA_CAPABILITIES, GHIDRA_PROVIDER_IDENTITY, } from "../../../src/ghidra/GhidraProviderCapabilities.js"; +import { APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } from "../../../src/native/NativeMacOSProviderMetadata.js"; const IDENTITY = { id: "snapshot-fixture", @@ -1039,3 +1042,259 @@ describe("workflow snapshot profile and cancellation binding", () => { expect(fixture.starts).toHaveLength(1); }); }); + +const expectProfilelessWorkflowSnapshotRejections = async ( + path: string, + provider: AnalysisProvider, + snapshot: AnalysisSnapshot, +): Promise => { + const session = createTestBinarySession(provider); + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider; + const wrongReader = structuredClone(snapshot); + const otherReaderProfile = createAnalysisProfile( + { ...readerIdentity, version: "apple-metadata-reader-older" }, + { + operation: "inspect_native_dispatch_metadata", + reader: "apple-dispatch-metadata", + revision: 0, + }, + ); + wrongReader.binding = { + provider: otherReaderProfile.provider, + analysis_profile: otherReaderProfile, + }; + expect( + isProfilelessNativeDispatchSnapshot( + wrongReader, + opened.value, + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + ), + ).toBe(false); + const rejectedReader = session.importAnalysisSnapshot(wrongReader); + expect(rejectedReader.ok).toBe(false); + if (!rejectedReader.ok) + expect(rejectedReader.error.message).toContain("profile_mismatch"); + + const wrongOperation = structuredClone(snapshot); + wrongOperation.workflow_entries[0]!.operation = "inspect_native_api"; + wrongOperation.workflow_entries[0]!.query_id = analysisQueryId( + wrongOperation.target, + wrongOperation.binding, + "inspect_native_api", + wrongOperation.workflow_entries[0]!.parameters, + ); + expect( + isProfilelessNativeDispatchSnapshot( + wrongOperation, + opened.value, + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + ), + ).toBe(false); + const rejectedOperation = session.importAnalysisSnapshot(wrongOperation); + expect(rejectedOperation.ok).toBe(false); + if (!rejectedOperation.ok) + expect(rejectedOperation.error.message).toContain("profile_mismatch"); + + const mixedOperation = "inspect_native_api"; + const mixedProfile = workflowAnalysisProfile( + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + mixedOperation, + ); + const mixedSnapshot = { + ...snapshot, + workflow_entries: [ + ...snapshot.workflow_entries, + createAnalysisSnapshotWorkflowEntry({ + target: snapshot.target, + binding: snapshot.binding, + operation: mixedOperation, + parameters: {}, + execution: { + result: null, + rawResult: null, + provider: mixedProfile.provider, + analysisProfile: mixedProfile, + limitations: [], + locations: [], + subject: null, + }, + }), + ], + }; + const rejectedMixed = session.importAnalysisSnapshot(mixedSnapshot); + expect( + isProfilelessNativeDispatchSnapshot( + mixedSnapshot as AnalysisSnapshot, + opened.value, + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + ), + ).toBe(false); + expect(rejectedMixed.ok).toBe(false); + if (!rejectedMixed.ok) + expect(rejectedMixed.error.message).toContain("profile_mismatch"); + + const deepProfile = createAnalysisProfile( + { id: "ghidra", name: "Ghidra", version: "fixture-version" }, + { fixture: "deep-provider-only" }, + ); + const deepWorkflowProfile = workflowAnalysisProfile( + deepProfile, + "inspect_native_dispatch_metadata", + ); + const originalWorkflow = snapshot.workflow_entries[0]!; + const deepSnapshot = { + ...snapshot, + binding: { + provider: deepProfile.provider, + analysis_profile: deepProfile, + }, + workflow_entries: [ + createAnalysisSnapshotWorkflowEntry({ + target: snapshot.target, + binding: { + provider: deepProfile.provider, + analysis_profile: deepProfile, + }, + operation: "inspect_native_dispatch_metadata", + parameters: originalWorkflow.parameters, + execution: { + result: originalWorkflow.execution.result, + rawResult: originalWorkflow.execution.raw_result, + provider: deepWorkflowProfile.provider, + analysisProfile: deepWorkflowProfile, + limitations: originalWorkflow.execution.limitations, + ...(originalWorkflow.execution.relationship_coverage === null + ? {} + : { + relationshipCoverage: + originalWorkflow.execution.relationship_coverage, + }), + locations: originalWorkflow.execution.locations, + subject: + originalWorkflow.execution.subject === null + ? null + : { + path: originalWorkflow.execution.subject.path, + sha256: originalWorkflow.execution.subject.sha256, + format: originalWorkflow.execution.subject.format, + ...(originalWorkflow.execution.subject.architecture === null + ? {} + : { + architecture: + originalWorkflow.execution.subject.architecture, + }), + }, + }, + }), + ], + } as AnalysisSnapshot; + const rejectedDeepOnly = session.importAnalysisSnapshot(deepSnapshot); + expect(rejectedDeepOnly.ok).toBe(false); + if (!rejectedDeepOnly.ok) + expect(rejectedDeepOnly.error.message).toContain("profile_mismatch"); +}; + +it("retains a native dispatch workflow snapshot without a deep provider profile", async () => { + const fixture = await createSnapshotReplayFixture( + "rea-native-dispatch-workflow-", + ); + const identity: ProviderIdentity = { + id: "native-macos", + name: "macOS native inspection utilities", + version: null, + }; + const capabilities: CapabilityDescriptor[] = [ + { + provider: identity, + operation: "inspect_native_dispatch_metadata", + analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + available: true, + reason: null, + cachePolicy: "snapshot", + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [], + }, + ]; + const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider; + let dispatchCalls = 0; + const provider: AnalysisProvider = { + identity: () => identity, + capabilities: () => capabilities, + createClient: () => ({ + execute: async (operation) => + operation === "health" + ? ok(createAnalysisExecution(null, identity)) + : operation === "inspect_native_dispatch_metadata" + ? ok( + (dispatchCalls++, + createAnalysisExecution( + { + target_sha256: null, + provider: { + id: identity.id, + name: readerIdentity.name, + version: readerIdentity.version, + }, + analysis_profile_digest: + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest, + result: { db_save_result: null }, + }, + readerIdentity, + { + analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + }, + )), + ) + : err(new ProviderAdapterError(identity.id, operation)), + close: async () => ok(null), + }), + }; + const dependencies = fixture.dependencies(provider); + + const result = await runDirectAnalysis( + dependencies, + fixture.path, + "inspect_native_dispatch_metadata", + { max_records: 100 }, + { snapshotPath: fixture.snapshotPath }, + ); + + expect(result).toMatchObject({ + operation: "inspect_native_dispatch_metadata", + }); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + expect(saved.value.binding.analysis_profile).toEqual( + APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + ); + expect(saved.value.entries).toEqual([]); + expect(saved.value.workflow_entries).toEqual([ + expect.objectContaining({ operation: "inspect_native_dispatch_metadata" }), + ]); + + const replay = await runDirectAnalysis( + dependencies, + fixture.path, + "inspect_native_dispatch_metadata", + { max_records: 100 }, + { snapshotPath: fixture.snapshotPath }, + ); + expect(replay).toEqual(result); + expect(dispatchCalls).toBe(1); + + await expectProfilelessWorkflowSnapshotRejections( + fixture.path, + provider, + saved.value, + ); +}); From 90e433df06b3f83417176b8db1b0a905f9df1d3a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:41:16 +0800 Subject: [PATCH 4/4] fix(swift): retain uncertainty for unsupported nested identifiers Stop inheriting an outer nominal category when a zero-prefixed identifier cannot be decoded. Preserve exact manglings in unresolved results under class and enum filters while retaining ASCII nominal controls. Validated 39 source and in-memory MCP tests, typecheck/lint, and 22 compiler-produced code symbols from an inert Swift 6.3.2 object. No real Hopper inventory or target execution. Fixes #5553. --- src/domain/swiftSymbolClassification.ts | 8 ++- src/domain/symbolAnalysis.test.ts | 50 ++++++++++++++ .../mcp/enhancedTools.swiftTypes.test.ts | 69 +++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/domain/swiftSymbolClassification.ts b/src/domain/swiftSymbolClassification.ts index 04910e5c58..b04c921bcc 100644 --- a/src/domain/swiftSymbolClassification.ts +++ b/src/domain/swiftSymbolClassification.ts @@ -38,7 +38,13 @@ export const classifySwiftSymbol = ( let category: SwiftTypeCategory | null = null; for (;;) { const end = identifierEnd(mangling, offset); - if (end === undefined) break; + if (end === undefined) { + // A zero-prefixed identifier uses word substitutions or Punycode. We + // cannot tell whether it continues a nested type or names a member, so + // retaining the outer category would be a guess. + if (mangling.charAt(offset) === "0") category = null; + break; + } const kind = mangling.charAt(end); const nominal = NOMINAL_CATEGORIES.get(kind); if (nominal === undefined) { diff --git a/src/domain/symbolAnalysis.test.ts b/src/domain/symbolAnalysis.test.ts index 995b699875..6930a60bff 100644 --- a/src/domain/symbolAnalysis.test.ts +++ b/src/domain/symbolAnalysis.test.ts @@ -52,6 +52,56 @@ describe("modern Swift category evidence", () => { ).toMatchObject({ total: 0, unclassified: [] }); }); + it("preserves unsupported zero-prefixed nested Unicode contexts as unresolved", () => { + const names = [ + "_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF", + "_$s6Nested8EnvelopeV007jvrtDBlCAEycfC", + "_$s6Nested8EnvelopeV007jvrtDBlCMa", + ]; + const procedures = names.map((name, index) => ({ + address: `0x${String(index + 1)}`, + name, + })); + const unresolved = names.map((name) => ({ + name, + mangled_names: [name], + reason: "category_not_decoded", + })); + expect(categorizeSwiftTypes(procedures)).toMatchObject({ + total: 3, + categories: { + other: { + items: names.map((name) => ({ name, mangled_names: [name] })), + }, + }, + unclassified: unresolved, + }); + + for (const category of ["classes", "enums"] as const) + expect(categorizeSwiftTypes(procedures, { category })).toMatchObject({ + total: 0, + categories: { [category]: { count: 0 }, other: { count: 0 } }, + unclassified: unresolved, + }); + }); + + it("keeps outer and ASCII nested nominal categories decoded", () => { + const names = [ + ["_$s6Nested8EnvelopeVMa", "structs"], + ["_$s6Nested8EnvelopeV5InnerCMa", "classes"], + ["_$s6Nested8EnvelopeV5InnerC6methodyyF", "classes"], + ] as const; + for (const [name, category] of names) { + expect( + categorizeSwiftTypes([{ address: "0x1", name }], { category }), + ).toMatchObject({ + total: 1, + categories: { [category]: { count: 1 } }, + unclassified: [], + }); + } + }); + it("deduplicates display names after selecting a category", () => { expect( categorizeSwiftTypes( diff --git a/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts b/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts index 128b9c2818..82fc8d4945 100644 --- a/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts +++ b/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts @@ -158,3 +158,72 @@ describe("Swift classification through MCP", () => { ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); }); }); + +describe("Swift category uncertainty through MCP", () => { + it("keeps unsupported nested Unicode names unresolved through MCP filters", async () => { + const names = [ + "_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF", + "_$s6Nested8EnvelopeV007jvrtDBlCAEycfC", + "_$s6Nested8EnvelopeV007jvrtDBlCMa", + ]; + const procedures = [ + ...names.map((name, index) => ({ + address: `0x${String(index + 1)}`, + value: name, + })), + { address: "0x10", value: "_$s6Nested8EnvelopeVMa" }, + { address: "0x11", value: "_$s6Nested8EnvelopeV5InnerCMa" }, + { address: "0x12", value: "_$s6Nested8EnvelopeV5InnerC6methodyyF" }, + ]; + const client = await connect({ + execute: async (operation) => + ok(operation === "list_procedures" ? procedures : []), + }); + + const all = jsonResult( + await client.callTool({ + name: "analyze_swift_types", + arguments: {}, + }), + ); + expect(all).toMatchObject({ + total: 6, + categories: { + structs: { + items: [{ name: "_$s6Nested8EnvelopeVMa" }], + }, + classes: { + items: [ + { name: "_$s6Nested8EnvelopeV5InnerCMa" }, + { name: "_$s6Nested8EnvelopeV5InnerC6methodyyF" }, + ], + }, + other: { + items: names.map((name) => ({ name, mangled_names: [name] })), + }, + }, + unclassified: names.map((name) => ({ + name, + mangled_names: [name], + reason: "category_not_decoded", + })), + }); + + for (const category of ["classes", "enums"] as const) { + const filtered = jsonResult( + await client.callTool({ + name: "analyze_swift_types", + arguments: { category }, + }), + ); + expect(filtered).toMatchObject({ + categories: { [category]: { count: category === "classes" ? 2 : 0 } }, + unclassified: names.map((name) => ({ + name, + mangled_names: [name], + reason: "category_not_decoded", + })), + }); + } + }); +});