From 7f6561706428f4d4d1acb7cdb5c5e1a683645a84 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:21:50 +0800 Subject: [PATCH 01/29] fix(artifacts): resolve historical retained summaries Authenticate exact retained parents against current and documented historical summary projections, including caller-selected display paths. Keep source facts, Evidence commitments and unsupported-projection rejection intact. --- docs/mcp-contracts.md | 4 + .../ArtifactComparisonEvidence.test.ts | 289 +++++++++++++++++- .../artifacts/ArtifactComparisonEvidence.ts | 116 ++++++- .../analysisView/artifactInventoryView.ts | 9 +- .../artifactInventoryViewCaveats.ts | 13 + ...rtifactComparisonHistoricalSummary.test.ts | 143 +++++++++ 6 files changed, 555 insertions(+), 19 deletions(-) create mode 100644 src/domain/analysisView/artifactInventoryViewCaveats.ts create mode 100644 tests/acceptance/artifactComparisonHistoricalSummary.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index b94d46b1b0..a950045411 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -26,6 +26,10 @@ can be passed directly to `compare_functions`, and `inspect_artifact` Evidence to `compare_artifacts`. Use `get_evidence_bundle` when the task needs broader retained session history or an explicit bundle for transfer. +`compare_artifacts` can also resolve an artifact summary to its exact retained +parent in the session. Supported historical summaries keep their original +Evidence identity and omissions; a missing or mismatched parent is rejected. + `analyze_function` does not deliver its complete record by default. Without `detail`, it retains the complete dossier in the session and returns `inspect_analysis_view` summary Evidence whose diff --git a/src/application/artifacts/ArtifactComparisonEvidence.test.ts b/src/application/artifacts/ArtifactComparisonEvidence.test.ts index ea2baf0438..5410cbff0f 100644 --- a/src/application/artifacts/ArtifactComparisonEvidence.test.ts +++ b/src/application/artifacts/ArtifactComparisonEvidence.test.ts @@ -2,10 +2,14 @@ import { expect, it } from "vitest"; import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js"; -import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; +import { + artifactInventorySummaryViewResultSchema, + sealAnalysisView, +} from "../../domain/analysisView/analysisView.js"; import { createEvidence, parseEvidence, + rebindEvidenceToTargetPath, type Evidence, } from "../../domain/evidence.js"; import { @@ -13,6 +17,11 @@ import { EvidenceReferenceError, } from "../../domain/evidenceErrors.js"; import { resolveArtifactComparisonEvidence } from "./ArtifactComparisonEvidence.js"; +import { artifactInventoryResultSchema } from "../../domain/artifactGraph.js"; +import { + artifactGraphDigest, + artifactManifestId, +} from "../../domain/artifactIdentity.js"; const artifactSummary = async () => { const parent = ARTIFACT_COMPARISON_EXAMPLE.left; @@ -164,3 +173,281 @@ it("accepts display-only subject changes excluded from Evidence identity", async ); expect(resolved).toEqual({ ok: true, value: parent }); }); + +const traversalUnknown = + "Producer traversal coverage is unknown; missing matches do not prove source absence."; +const hashesUnknown = + "Content hashes are unknown for some recorded files; missing node IDs do not prove content absence."; + +type SummaryEra = "pre-ce1f4b1bb" | "pre-b395190e4" | "current"; + +/** Recommit an inventory whose two independent unknowns exercise both migrations. */ +const unknownInventory = () => { + const original = ARTIFACT_COMPARISON_EXAMPLE.left; + const inventory = artifactInventoryResultSchema.parse( + original.normalized_result, + ); + inventory.coverage.status = "unknown"; + inventory.occurrences[0]!.hash_status = "unavailable"; + const graphSha256 = artifactGraphDigest({ + nodes: inventory.nodes, + occurrences: inventory.occurrences, + edges: inventory.edges, + contradictions: inventory.integrity_contradictions, + }); + inventory.manifest.graph_sha256 = graphSha256; + inventory.manifest.manifest_id = artifactManifestId( + inventory.manifest.root_artifact_id, + graphSha256, + ); + return createEvidence( + { + path: "historical-inventory", + sha256: inventory.manifest.root_sha256, + format: "file", + }, + original.provider, + { + operation: "inventory_artifact", + parameters: {}, + result: inventory, + confidence: "observed", + authority: "shipped-artifact", + }, + ); +}; + +/** + * Exact non-PCK/non-PyInstaller summary deltas checked against the projectors at + * ce1f4b1bb^ and b395190e4^. The first omitted coverage and both caveats; the + * second included coverage/traversal, but still omitted the hash caveat. + */ +const resealSummary = ( + result: ReturnType, +) => { + const { view_digest: _digest, ...unsigned } = result; + return artifactInventorySummaryViewResultSchema.parse( + sealAnalysisView(unsigned), + ); +}; + +const summaryAtEra = async (era: SummaryEra, aliasPath?: string) => { + const parent = unknownInventory(); + const selectedParent = + aliasPath === undefined + ? parent + : rebindEvidenceToTargetPath(parent, { + path: aliasPath, + sha256: parent.subject!.digest.sha256, + format: parent.subject!.format, + }); + const projected = await inspectAnalysisView({ + source: { kind: "inline", evidence: selectedParent }, + view: { kind: "summary" }, + }); + if (!projected.ok) throw projected.error; + const current = artifactInventorySummaryViewResultSchema.parse( + projected.value.normalized_result, + ); + expect(current.summary.source_coverage).toEqual({ + scope: "root", + status: "unknown", + }); + expect(current.unknowns).toContain(traversalUnknown); + expect(current.unknowns).toContain(hashesUnknown); + const { source_coverage: _coverage, ...withoutCoverage } = current.summary; + const result = { + ...current, + summary: era === "pre-ce1f4b1bb" ? withoutCoverage : current.summary, + unknowns: current.unknowns.filter( + (unknown) => + era === "current" || + (unknown !== hashesUnknown && + (era !== "pre-ce1f4b1bb" || unknown !== traversalUnknown)), + ), + }; + return { + parent, + summary: recreateSummary(projected.value, resealSummary(result)), + }; +}; + +it.each(["pre-ce1f4b1bb", "pre-b395190e4", "current"])( + "authenticates the exact %s summary against its retained inventory", + async (era) => { + const { parent, summary } = await summaryAtEra(era); + expect(resolveArtifactComparisonEvidence(summary, () => parent)).toEqual({ + ok: true, + value: parent, + }); + }, +); + +it.each(["pre-ce1f4b1bb", "pre-b395190e4", "current"])( + "authenticates a %s summary projected from the caller alias against the original retained parent", + async (era) => { + const { parent, summary } = await summaryAtEra(era, "/selected/alias.bin"); + const result = artifactInventorySummaryViewResultSchema.parse( + summary.normalized_result, + ); + expect(result.artifact?.path).toBe("/selected/alias.bin"); + expect(parent.subject?.local_path).toBe("historical-inventory"); + expect(resolveArtifactComparisonEvidence(summary, () => parent)).toEqual({ + ok: true, + value: parent, + }); + }, +); + +it.each(["pre-ce1f4b1bb", "pre-b395190e4", "current"])( + "authenticates a %s summary after its retained parent is rebound to an alias path", + async (era) => { + const { parent, summary } = await summaryAtEra(era); + const rebound = rebindEvidenceToTargetPath(parent, { + path: "/selected/alias.bin", + sha256: parent.subject!.digest.sha256, + format: parent.subject!.format, + }); + expect(rebound.evidence_id).toBe(parent.evidence_id); + expect(resolveArtifactComparisonEvidence(summary, () => rebound)).toEqual({ + ok: true, + value: rebound, + }); + }, +); + +it.each([ + "coverage-only", + "traversal-only", + "hash-only-without-traversal", + "all-unknowns", + "wrong-count", + "wrong-parent-digest", + "wrong-parameters", +] as const)( + "rejects an unsupported historical summary: %s", + async (variant) => { + const { parent, summary } = await summaryAtEra("current"); + const result = artifactInventorySummaryViewResultSchema.parse( + summary.normalized_result, + ); + let parameters = summary.parameters; + switch (variant) { + case "coverage-only": + delete result.summary.source_coverage; + break; + case "traversal-only": + result.unknowns = result.unknowns.filter( + (unknown) => unknown !== traversalUnknown, + ); + break; + case "hash-only-without-traversal": + delete result.summary.source_coverage; + result.unknowns = result.unknowns.filter( + (unknown) => unknown !== traversalUnknown, + ); + break; + case "all-unknowns": + result.unknowns = []; + break; + case "wrong-count": + result.summary.available_counts.occurrences += 1; + break; + case "wrong-parent-digest": + result.parent_digest = "f".repeat(64); + break; + case "wrong-parameters": + parameters = { + ...parameters, + parent_evidence_id: `ev_${"f".repeat(64)}`, + }; + break; + } + const forged = recreateSummary(summary, resealSummary(result), parameters); + const resolved = resolveArtifactComparisonEvidence(forged, () => parent); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError); + }, +); + +it.each(["pre-ce1f4b1bb", "pre-b395190e4"])( + "rejects a %s summary when lookup returns another inventory", + async (era) => { + const { summary } = await summaryAtEra(era); + const resolved = resolveArtifactComparisonEvidence( + summary, + () => ARTIFACT_COMPARISON_EXAMPLE.right, + ); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error).toMatchObject({ reason: "identity_mismatch" }); + }, +); + +it.each(["provider", "authority", "locations", "links"] as const)( + "rejects a validly recommitted historical summary with altered %s", + async (field) => { + const { parent, summary } = await summaryAtEra("pre-ce1f4b1bb"); + const altered: Evidence = { + ...summary, + ...(field === "provider" + ? { provider: { ...summary.provider, version: "altered" } } + : field === "authority" + ? { authority: "analyst-inference" as const } + : field === "locations" + ? { + locations: [ + { kind: "artifact-path" as const, path: "different.bin" }, + ], + } + : { + evidence_links: [ARTIFACT_COMPARISON_EXAMPLE.right.evidence_id], + }), + }; + const forged = recreateSummary(altered, summary.normalized_result); + expect(parseEvidence(forged).evidence_id).toBe(forged.evidence_id); + expect(forged.evidence_id).not.toBe(summary.evidence_id); + const resolved = resolveArtifactComparisonEvidence(forged, () => parent); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError); + }, +); + +it("authenticates a retained parent's content instead of trusting its original ID", async () => { + const { parent, summary } = await summaryAtEra("pre-b395190e4"); + const tampered: Evidence = { + ...parent, + limitations: ["changed without recomputing the Evidence ID"], + }; + expect(tampered.evidence_id).toBe(parent.evidence_id); + const resolved = resolveArtifactComparisonEvidence(summary, () => tampered); + expect(resolved.ok).toBe(false); + if (!resolved.ok) + expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError); +}); + +it.each(["pre-ce1f4b1bb", "pre-b395190e4", "current"])( + "preserves both immutable Evidence commitments when resolving %s", + async (era) => { + const { parent, summary } = await summaryAtEra(era, "/selected/alias.bin"); + const before = { + parentId: parent.evidence_id, + summaryId: summary.evidence_id, + parentResult: JSON.stringify(parent.normalized_result), + summaryResult: JSON.stringify(summary.normalized_result), + }; + const resolved = resolveArtifactComparisonEvidence(summary, () => parent); + expect(resolved.ok).toBe(true); + if (resolved.ok) expect(resolved.value.evidence_id).toBe(before.parentId); + expect(parent.evidence_id).toBe(before.parentId); + expect(summary.evidence_id).toBe(before.summaryId); + expect(JSON.stringify(parent.normalized_result)).toBe(before.parentResult); + expect(JSON.stringify(summary.normalized_result)).toBe( + before.summaryResult, + ); + expect(parseEvidence(parent).evidence_id).toBe(before.parentId); + expect(parseEvidence(summary).evidence_id).toBe(before.summaryId); + }, +); diff --git a/src/application/artifacts/ArtifactComparisonEvidence.ts b/src/application/artifacts/ArtifactComparisonEvidence.ts index 3a2bb33e63..403f11310e 100644 --- a/src/application/artifacts/ArtifactComparisonEvidence.ts +++ b/src/application/artifacts/ArtifactComparisonEvidence.ts @@ -1,16 +1,32 @@ -import type { EvidenceLookup } from "../EvidenceInputResolver.js"; +import { + resolveEvidenceInput, + type EvidenceLookup, +} from "../EvidenceInputResolver.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js"; -import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; +import { + artifactInventorySummaryViewResultSchema, + sealAnalysisView, +} from "../../domain/analysisView/analysisView.js"; import { EvidenceIntegrityError, EvidenceReferenceError, } from "../../domain/evidenceErrors.js"; -import type { Evidence } from "../../domain/evidence.js"; +import { + evidenceSemanticContent, + rebindEvidenceToTargetPath, + type Evidence, +} from "../../domain/evidence.js"; +import { readEvidence } from "../../domain/evidenceValidation.js"; +import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; +import { ARTIFACT_INVENTORY_VIEW_CAVEATS } from "../../domain/analysisView/artifactInventoryViewCaveats.js"; import { err, ok, type Result } from "../../domain/result.js"; -/** Resolve only an artifact summary's exact retained owner before comparison. */ +/** + * Resolve an already-authenticated artifact summary's exact retained owner. + * The caller must authenticate the supplied Evidence before invoking this helper. + */ export const resolveArtifactComparisonEvidence = ( evidence: Evidence, evidenceById: EvidenceLookup | undefined, @@ -43,19 +59,95 @@ export const resolveArtifactComparisonEvidence = ( null, ), ); + const selected = resolveEvidenceInput( + { kind: "retained-evidence", evidence_id: parentId }, + () => parent, + ); + if (!selected.ok) return selected; + const authenticated = readEvidence(selected.value); + if (!authenticated.ok) return authenticated; + const owner = authenticated.value; + const subject = evidence.subject; + if ( + subject === null || + owner.subject === null || + subject.digest.sha256 !== owner.subject.digest.sha256 || + subject.format !== owner.subject.format || + subject.architecture !== owner.subject.architecture + ) + return mismatch(); + // The ledger preserves its original display path; summary delivery may use a + // later caller-selected alias for this same authenticated owner identity. + const presentation = rebindEvidenceToTargetPath(owner, { + path: summary.data.artifact.path, + sha256: subject.digest.sha256, + format: subject.format, + ...(subject.architecture === null + ? {} + : { architecture: subject.architecture }), + }); const reprojected = inspectAnalysisViewValidated( { source: { kind: "retained-evidence", evidence_id: parentId }, view: { kind: "summary" }, }, - (evidenceId) => (evidenceId === parentId ? parent : undefined), + (evidenceId) => (evidenceId === parentId ? presentation : undefined), ); if (!reprojected.ok) return reprojected; - if (reprojected.value.evidence_id !== evidence.evidence_id) - return err( - new EvidenceIntegrityError( - "Artifact summary does not match its retained parent inventory.", - ), - ); - return ok(parent); + if (!summaryProjectionIds(reprojected.value).includes(evidence.evidence_id)) + return mismatch(); + return ok(owner); +}; + +const mismatch = (): Result => + err( + new EvidenceIntegrityError( + "Artifact summary does not match its retained parent inventory.", + ), + ); + +/** Exact shipped projections, rather than arbitrary omission or prose tolerance. */ +const summaryProjectionIds = (current: Evidence): readonly string[] => { + const result = artifactInventorySummaryViewResultSchema.parse( + current.normalized_result, + ); + const format = result.summary.manifest.root_format; + if ( + format === "godot-pck" || + format === "pyinstaller" || + format === "node-sea" + ) + return [current.evidence_id]; + const { view_digest: _digest, ...projection } = result; + // b395190e4 began disclosing unavailable hashes for ordinary root formats. + const beforeHashDisclosure = { + ...projection, + unknowns: projection.unknowns.filter( + (value) => value !== ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownContentHashes, + ), + }; + // ce1f4b1bb began carrying traversal coverage and its caveat for these roots. + const { source_coverage: _coverage, ...summary } = projection.summary; + const beforeTraversalDisclosure = { + ...beforeHashDisclosure, + summary, + unknowns: beforeHashDisclosure.unknowns.filter( + (value) => value !== ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownTraversal, + ), + }; + return [ + current.evidence_id, + ...[beforeHashDisclosure, beforeTraversalDisclosure].map( + (variant) => + `ev_${digestCanonicalValue( + evidenceSemanticContent({ + ...current, + normalized_result: artifactInventorySummaryViewResultSchema.parse( + sealAnalysisView(variant), + ), + }), + "Evidence", + )}`, + ), + ]; }; diff --git a/src/domain/analysisView/artifactInventoryView.ts b/src/domain/analysisView/artifactInventoryView.ts index 90b9a992f6..8fdd0782fd 100644 --- a/src/domain/analysisView/artifactInventoryView.ts +++ b/src/domain/analysisView/artifactInventoryView.ts @@ -37,6 +37,7 @@ import { pageViewCoverage, } from "./analysisViewCoverage.js"; import { analysisViewResultInputError } from "./analysisViewParent.js"; +import { ARTIFACT_INVENTORY_VIEW_CAVEATS } from "./artifactInventoryViewCaveats.js"; const page = { kind: z.literal("inventory"), @@ -284,14 +285,10 @@ export const projectArtifactInventoryView = ( unknowns: [ "Inventory does not establish application runtime behavior or complete format-specific analysis.", ...(inventory.coverage.status === "unknown" - ? [ - "Producer traversal coverage is unknown; missing matches do not prove source absence.", - ] + ? [ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownTraversal] : []), ...(counts.hashes.unavailable > 0 || counts.hashes["not-hashed"] > 0 - ? [ - "Content hashes are unknown for some recorded files; missing node IDs do not prove content absence.", - ] + ? [ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownContentHashes] : []), ...(inventory.complete ? [] diff --git a/src/domain/analysisView/artifactInventoryViewCaveats.ts b/src/domain/analysisView/artifactInventoryViewCaveats.ts new file mode 100644 index 0000000000..7c90a2f220 --- /dev/null +++ b/src/domain/analysisView/artifactInventoryViewCaveats.ts @@ -0,0 +1,13 @@ +/** + * These immutable projection facts also identify exact historical summaries. + * Preserve their shipped wording: changing it requires another explicit + * historical projection variant rather than rewriting retained Evidence. + */ +export const ARTIFACT_INVENTORY_VIEW_CAVEATS = Object.freeze({ + /** Added for ordinary root formats by ce1f4b1bb. */ + unknownTraversal: + "Producer traversal coverage is unknown; missing matches do not prove source absence.", + /** Added for ordinary root formats by b395190e4. */ + unknownContentHashes: + "Content hashes are unknown for some recorded files; missing node IDs do not prove content absence.", +}); diff --git a/tests/acceptance/artifactComparisonHistoricalSummary.test.ts b/tests/acceptance/artifactComparisonHistoricalSummary.test.ts new file mode 100644 index 0000000000..a67f6cb1af --- /dev/null +++ b/tests/acceptance/artifactComparisonHistoricalSummary.test.ts @@ -0,0 +1,143 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { + artifactInventorySummaryViewResultSchema, + sealAnalysisView, +} from "../../src/domain/analysisView/analysisView.js"; +import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js"; +import { digestCanonicalValue } from "../../src/domain/canonicalDigest.js"; +import { + evidenceSemanticContent, + parseEvidence, + rebindEvidenceToTargetPath, + type Evidence, +} from "../../src/domain/evidence.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +const resealSummary = ( + evidence: Evidence, + result: ReturnType, +): Evidence => { + const { view_digest: _digest, ...unsigned } = result; + const revised = { + ...evidence, + normalized_result: artifactInventorySummaryViewResultSchema.parse( + sealAnalysisView(unsigned), + ), + }; + return parseEvidence({ + ...revised, + evidence_id: `ev_${digestCanonicalValue(evidenceSemanticContent(revised), "Evidence")}`, + }); +}; + +it("compares a historical alias summary through its retained complete MCP parent and rejects resealed false facts", async () => { + const root = await createTestTempDirectory("rea-historical-summary-mcp-"); + const path = join(root, "original.plist"); + await writeFile( + path, + 'labelhello', + ); + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await call("inspect_artifact", { detail: "complete" }); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const parent = parseEvidence(inspected.structuredContent); + const originalParent = JSON.stringify(parent); + const alias = join(root, "caller-selected-alias.plist"); + const aliasedParent = rebindEvidenceToTargetPath(parent, { + path: alias, + sha256: parent.subject!.digest.sha256, + format: parent.subject!.format, + }); + expect(aliasedParent.evidence_id).toBe(parent.evidence_id); + const projected = await call("inspect_analysis_view", { + source: { kind: "inline", evidence: aliasedParent }, + view: { kind: "summary" }, + }); + expect(projected.isError, JSON.stringify(projected)).not.toBe(true); + const current = parseEvidence(projected.structuredContent); + const result = artifactInventorySummaryViewResultSchema.parse( + current.normalized_result, + ); + expect(result).toMatchObject({ + parent_evidence_id: parent.evidence_id, + artifact: { path: alias }, + summary: { source_coverage: { scope: "root", status: "complete" } }, + }); + + // Exact ordinary-root projector before ce1f4b1bb: no source_coverage or + // traversal caveat. Before b395190e4 the hash caveat was also absent; this + // fully hashed root has no such caveat in either era. + const { source_coverage: _coverage, ...summary } = result.summary; + const historical = resealSummary(current, { + ...result, + summary, + unknowns: result.unknowns.filter( + (value) => + value !== + "Producer traversal coverage is unknown; missing matches do not prove source absence." && + value !== + "Content hashes are unknown for some recorded files; missing node IDs do not prove content absence.", + ), + }); + expect(historical.evidence_id).not.toBe(current.evidence_id); + const originalHistorical = JSON.stringify(historical); + const compared = await call("compare_artifacts", { + left: historical, + right: parent, + }); + expect(compared.isError, JSON.stringify(compared)).not.toBe(true); + const comparison = parseEvidence(compared.structuredContent); + expect( + artifactComparisonResultSchema.parse(comparison.normalized_result), + ).toMatchObject({ + status: "unchanged", + summary: { unchanged: 1, unknown: 0 }, + changes: [], + }); + expect(comparison.evidence_links).toContain(parent.evidence_id); + expect(comparison.evidence_links).not.toContain(historical.evidence_id); + + const historicalResult = artifactInventorySummaryViewResultSchema.parse( + historical.normalized_result, + ); + const forged = resealSummary(historical, { + ...historicalResult, + summary: { + ...historicalResult.summary, + available_counts: { + ...historicalResult.summary.available_counts, + nodes: historicalResult.summary.available_counts.nodes + 1, + }, + }, + }); + const rejected = await call("compare_artifacts", { + left: forged, + right: parent, + }); + expect(rejected.isError, JSON.stringify(rejected)).toBe(true); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { code: "evidence_integrity_mismatch" }, + }); + expect(JSON.stringify(parent)).toBe(originalParent); + expect(JSON.stringify(historical)).toBe(originalHistorical); + + const retained = await call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "summary" }, + }); + expect(retained.isError, JSON.stringify(retained)).not.toBe(true); + expect( + parseEvidence(retained.structuredContent).normalized_result, + ).toMatchObject({ + parent_evidence_id: parent.evidence_id, + artifact: { path }, + summary: { source_coverage: { scope: "root", status: "complete" } }, + }); +}); From c4cdb7658b0eb5ef16b30bd8127479bce8c21233 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:22:10 +0800 Subject: [PATCH 02/29] fix(jeb)!: preserve engine refusal diagnostics Parse JEB failure envelopes at the provider boundary and retain the original response, recovery facts, and selected inputs. Do not infer an unsupported target from undocumented error codes; preserve completed primary observations when metadata requests fail. BREAKING CHANGE: JEB engine refusals now report execution_failure rather than unsupported_target. Consumers should inspect the retained upstream diagnostics for the engine's reason and recovery facts. --- docs/jeb-analysis.md | 2 +- src/jeb/JebProvider.test.ts | 205 +++++++++++++++++- src/jeb/JebProvider.ts | 116 +++++++--- tests/boundary/cli/jebCli.test.ts | 43 +++- tests/boundary/mcp/jebMcp.test.ts | 42 +++- .../providers/jeb/failureEnvelopes.test.ts | 194 +++++++++++++++++ 6 files changed, 555 insertions(+), 47 deletions(-) create mode 100644 tests/boundary/providers/jeb/failureEnvelopes.test.ts diff --git a/docs/jeb-analysis.md b/docs/jeb-analysis.md index 0757b81b91..ee6ec9846b 100644 --- a/docs/jeb-analysis.md +++ b/docs/jeb-analysis.md @@ -63,7 +63,7 @@ All four preserve target-file bytes. `open_jeb_project` selects or creates an en - The engine caps `list_units` pages at 100; a full page is reported as `coverage: "partial"` because more units may exist. An empty remainder is `complete` only when fewer units than requested were returned. - When `decompile_jeb_item` omits `unit_path`, the engine implicitly selects the project's first code unit; REA reports `unit_path: null` and a limitation instead of guessing the effective unit. - `item_address` is a JEB code-item reference, such as a value returned by the engine's `list_code_items`. Preserve that representation and select its code unit explicitly. For the audited CE SQLite unit, the exported method reference was `sqlite3_libversion_number`; using its raw ELF symbol address did not produce a successful decompilation. File offsets, ELF virtual addresses and engine item references have different semantics. REA currently exposes unit listing, while code-item discovery remains an upstream JEB operation. -- Engine refusals (`success: false` envelopes) are preserved as unsupported-target errors carrying the engine's own message, for example a nonexistent item address. +- Valid engine refusals (`success: false` envelopes) return `execution_failure` with the engine's message when available. Diagnostics preserve the original envelope, including any recovery advice, and the inputs of a refused primary request. If client metadata fails after a primary reply, that reply and the caller's inputs remain in partial Evidence. REA does not infer an unsupported target from a refusal, invoke a recovery tool, or retry the request. - Successfully parsed client information is reused for the lifetime of the MCP connection, including when the engine omits its version. Explicit client inspection and availability probes refresh it. - An unreachable endpoint is a provider-availability failure with recovery guidance; REA does not retry or spawn the engine. diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index c4df83ceed..fbe614c76b 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -193,7 +193,7 @@ describe("JebProvider observations and failures", () => { expect(result.error.message).toContain("ECONNREFUSED"); }); - it("preserves engine refusal reasons as unsupported targets", async () => { + it("preserves engine refusal reasons without inferring unsupported target formats", async () => { const provider = providerWith( scriptedConnection({ get_client_information: { @@ -214,7 +214,9 @@ describe("JebProvider observations and failures", () => { }); expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error.message).toContain("does not reference an existing"); + expect(projectAnalysisError(result.error).message).toContain( + "does not reference an existing", + ); }); it("derives partial coverage only from a full engine page", async () => { @@ -544,10 +546,16 @@ it("project-open refusal retains the selected engine-host artifact path", async expect(result.ok).toBe(false); if (result.ok) throw new Error("Refused artifact accepted"); expect(projectAnalysisError(result.error)).toMatchObject({ - code: "unsupported_target", + code: "execution_failure", + message: "The input file could not be processed", details: { - path: "/targets/Example.apk", - reason: "The input file could not be processed", + diagnostics: { + input: { path: "/targets/Example.apk" }, + upstream_response: { + success: false, + message: "The input file could not be processed", + }, + }, }, }); await provider.close(); @@ -640,3 +648,190 @@ it.each([ await control.close(); }, ); + +// The published JEB 5.47 catalog leaves codes open and describes recovery as +// upstream advice, not permission for REA to invoke another engine operation. +describe("JEB refusal input and response preservation", () => { + const requests = [ + { + operation: "inspect_jeb_client", + input: {}, + tool: "get_client_information", + }, + { + operation: "open_jeb_project", + input: { path: "/targets/input.apk" }, + tool: "open_project", + }, + { + operation: "list_jeb_units", + input: { count: 10, index: 0 }, + tool: "list_units", + }, + { + operation: "decompile_jeb_item", + input: { + item_address: "LExample;->x()V", + item_kind: "method", + unit_path: "0/1", + }, + tool: "decompile_code_item", + }, + ] as const; + + it.each(requests)( + "retains unknown refusal facts for $operation", + async ({ tool, ...request }) => { + const payload = { + success: false, + message: "Engine state needs attention", + error: { + code: "future_vendor_code", + recovery_required_before_retry: false, + recovery_tool: "open_project", + vendor_detail: { phase: "analysis" }, + }, + vendor_trace: ["first", "second"], + }; + const connection = scriptedConnection({ [tool]: payload }); + const provider = providerWith(connection); + const result = await provider.execute(request); + if (result.ok) throw new Error("Engine refusal accepted"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "execution_failure", + message: payload.message, + details: { + diagnostics: { input: request.input, upstream_response: payload }, + }, + }); + expect(connection.calls).toHaveLength(1); + await provider.close(); + }, + ); + + it("omits undefined optional inputs when retaining JSON diagnostics", async () => { + const provider = providerWith( + scriptedConnection({ + list_units: { success: false, message: "Refused" }, + }), + ); + const result = await provider.execute({ + operation: "list_jeb_units", + input: { + count: 10, + index: 0, + filter: undefined, + parent_unit_path: undefined, + }, + }); + if (result.ok) throw new Error("Engine refusal accepted"); + expect(result.error.diagnostics?.input).toEqual({ count: 10, index: 0 }); + expect(result.error.diagnostics?.input).not.toHaveProperty("filter"); + await provider.close(); + }); +}); + +describe("JEB refusal recovery and presentation", () => { + it.each([undefined, false, true])( + "preserves recovery flag %s without invoking recovery", + async (flag) => { + const payload = { + success: false, + error: { + code: "not_documented", + recovery_tool: "run_script", + ...(flag === undefined + ? {} + : { recovery_required_before_retry: flag }), + }, + }; + const connection = scriptedConnection({ + get_client_information: payload, + }); + const provider = providerWith(connection); + const result = await provider.execute({ + operation: "inspect_jeb_client", + input: {}, + }); + if (result.ok) throw new Error("Engine refusal accepted"); + expect(result.error.diagnostics?.upstream_response).toEqual(payload); + expect(connection.calls).toEqual([["get_client_information", {}]]); + await provider.close(); + }, + ); + + it.each([ + { + payload: { + success: false, + message: "outer reason", + error: { message: "", code: "code" }, + }, + expected: "outer reason", + }, + { + payload: { + success: false, + message: " ", + error: { message: "", code: "code" }, + }, + expected: "code", + }, + { payload: { success: false }, expected: "The engine refused the request" }, + ])( + "selects a nonempty refusal message: $expected", + async ({ payload, expected }) => { + const provider = providerWith( + scriptedConnection({ get_client_information: payload }), + ); + const result = await provider.execute({ + operation: "inspect_jeb_client", + input: {}, + }); + if (result.ok) throw new Error("Engine refusal accepted"); + expect(projectAnalysisError(result.error).message).toBe(expected); + expect(result.error.diagnostics?.upstream_response).toEqual(payload); + await provider.close(); + }, + ); + + it("availability retains a reachable engine's refusal rather than prescribing connection setup", async () => { + const payload = { + success: false, + message: "Client initialization pending", + error: { + code: "future_code", + recovery_required_before_retry: true, + recovery_tool: "get_client_information", + }, + }; + const provider = providerWith( + scriptedConnection({ get_client_information: payload }), + ); + expect(await provider.inspectAvailability()).toMatchObject({ + status: "unavailable", + code: "runtime_unavailable", + reason: payload.message, + diagnostics: { upstream_response: payload, input: {} }, + }); + await provider.close(); + }); + + it.each([ + { success: false, error: { recovery_required_before_retry: "false" } }, + { message: "missing success" }, + { success: "false" }, + ])("retains malformed envelopes as protocol failures", async (payload) => { + const provider = providerWith( + scriptedConnection({ get_client_information: payload }), + ); + const result = await provider.execute({ + operation: "inspect_jeb_client", + input: {}, + }); + if (result.ok) throw new Error("Malformed envelope accepted"); + expect(result.error).toBeInstanceOf(AnalysisProtocolError); + expect(result.error.diagnostics?.upstream_response).toEqual(payload); + await provider.close(); + }); +}); diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index a78b400b5c..6ce87b99f0 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -23,9 +23,10 @@ import { AnalysisCapabilityUnavailableError, AnalysisProtocolError, AnalysisTimeoutError, - AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { err, ok, type Result } from "../domain/result.js"; import { parseJebMcpEndpoint, @@ -57,7 +58,7 @@ export interface JebProviderOptions { readonly connectionFactory?: JebMcpConnectionFactory; } -/** JEB's tool envelope: success with typed fields, or a message-only failure. */ +/** Published JEB refusal facts; retain the original JSON alongside parsed facts. */ const failure = z .object({ success: z.literal(false), @@ -67,6 +68,7 @@ const failure = z code: z.string().optional(), message: z.string().optional(), recovery_tool: z.string().optional(), + recovery_required_before_retry: z.boolean().optional(), }) .optional(), }) @@ -103,14 +105,54 @@ const decompiledItem = z .object({ success: z.literal(true), text: z.string() }) .passthrough(); -const refusalReason = (payload: unknown, fallback: string): string => { - const parsed = failure.safeParse(payload); - if (!parsed.success) return fallback; - const envelope = parsed.data; - const reason = - envelope.error?.message ?? envelope.message ?? envelope.error?.code; - if (reason !== undefined && reason.length > 0) return reason; - return fallback; +const envelope = z.discriminatedUnion("success", [ + z.object({ success: z.literal(true) }).passthrough(), + failure, +]); + +/** Parse success/refusal once; operation-specific success data has its own owner. */ +const parseJebEnvelope = ( + payload: JsonValue, + operation: JebOperation, + endpoint: string, + input?: Readonly>, +): Result => { + const diagnostics = { + endpoint, + upstream_response: payload, + ...(input === undefined + ? {} + : { + // Typed optional request fields can carry undefined; JSON omission is + // established here before these selected inputs become diagnostics. + input: Object.fromEntries( + Object.entries(input).filter( + (entry): entry is [string, JsonValue] => entry[1] !== undefined, + ), + ), + }), + }; + const parsed = envelope.safeParse(payload); + if (!parsed.success) + return err( + new AnalysisProtocolError( + "JEB MCP omitted or malformed its observation envelope", + { cause: parsed.error, diagnostics }, + ), + ); + if (parsed.data.success) return ok(payload); + const refusal = parsed.data; + const reason = [ + refusal.error?.message, + refusal.message, + refusal.error?.code, + ].find((value) => value !== undefined && value.trim().length > 0); + return err( + new ProviderAdapterError("jeb", operation, { + userMessage: reason ?? "The engine refused the request", + diagnostics, + }), + ); }; /** Serve REA's JEB operations from a running engine client over MCP. */ @@ -172,6 +214,18 @@ export class JebProvider implements JebAnalysisPort { }; } catch (cause) { if (signal?.aborted === true) throw cause; + if (cause instanceof AnalysisError) { + const projected = projectAnalysisError(cause); + return { + status: "unavailable", + code: "runtime_unavailable", + reason: projected.message, + diagnostics: { + endpoint: this.#endpoint.toString(), + ...cause.diagnostics, + }, + }; + } return { status: "unavailable", code: "not_configured", @@ -299,7 +353,11 @@ export class JebProvider implements JebAnalysisPort { signal, ); const information = clientInformation.parse( - this.#requireSuccess(payload, operation), + this.#requireSuccess( + payload, + operation, + operation === "inspect_jeb_client" ? {} : undefined, + ), ); this.#clientInformation = { version: information.version, @@ -346,7 +404,7 @@ export class JebProvider implements JebAnalysisPort { signal, ); const opened = openProject.parse( - this.#requireSuccess(payload, request.operation, request.input.path), + this.#requireSuccess(payload, request.operation, request.input), ); const engine = await this.#engineForObservation( request, @@ -387,7 +445,7 @@ export class JebProvider implements JebAnalysisPort { signal, ); const listed = listUnits.parse( - this.#requireSuccess(payload, request.operation), + this.#requireSuccess(payload, request.operation, request.input), ); const units = listed.units; const engine = await this.#engineForObservation( @@ -430,7 +488,7 @@ export class JebProvider implements JebAnalysisPort { signal, ); const decompiled = decompiledItem.parse( - this.#requireSuccess(payload, request.operation), + this.#requireSuccess(payload, request.operation, request.input), ); const engine = await this.#engineForObservation( request, @@ -459,26 +517,18 @@ export class JebProvider implements JebAnalysisPort { } #requireSuccess( - payload: unknown, + payload: JsonValue, operation: JebOperation, - targetPath?: string, - ): unknown { - if (typeof payload !== "object" || payload === null) - throw new AnalysisProtocolError( - "JEB MCP returned a non-object envelope; no observation can be established", - ); - const success = (payload as { success?: unknown }).success; - if (success === false) - throw new AnalysisUnsupportedTargetError( - operation, - targetPath ?? this.#endpoint.toString(), - refusalReason(payload, "The engine refused the request"), - ); - if (success !== true) - throw new AnalysisProtocolError( - "JEB MCP omitted its success field; no observation can be established", - ); - return payload; + input?: Readonly>, + ): JsonValue { + const parsed = parseJebEnvelope( + payload, + operation, + this.#endpoint.toString(), + input, + ); + if (!parsed.ok) throw parsed.error; + return parsed.value; } #providerFailure(operation: JebOperation, cause: unknown): AnalysisError { diff --git a/tests/boundary/cli/jebCli.test.ts b/tests/boundary/cli/jebCli.test.ts index ec36adcbc0..545696eef3 100644 --- a/tests/boundary/cli/jebCli.test.ts +++ b/tests/boundary/cli/jebCli.test.ts @@ -67,10 +67,16 @@ it.each( operation: "list_jeb_units", tool: "list_units", argv: ["list-jeb-units", "--count", "2", "--json"], + input: { count: 2, index: 0 }, }, { operation: "decompile_jeb_item", tool: "decompile_code_item", + input: { + item_address: "LSample;->value()I", + item_kind: "method", + unit_path: "sample.dex", + }, argv: [ "decompile-jeb-item", "LSample;->value()I", @@ -84,6 +90,7 @@ it.each( operation: "open_jeb_project", tool: "open_project", argv: ["open-jeb-project", "sample.apk", "--json"], + input: { path: "sample.apk" }, }, ].flatMap((request) => ["metadata-refusal", "primary-refusal", "success"].map((stage) => ({ @@ -93,11 +100,20 @@ it.each( ), )( "preserves $operation attribution and primary payload with $stage", - async ({ operation, tool, argv, stage }) => { + async ({ operation, tool, argv, input, stage }) => { + const refusal = { + success: false, + message: "Selected stage refused", + error: { + code: "future_engine_code", + recovery_required_before_retry: false, + recovery_tool: "get_client_information", + }, + }; const fixture = await startJebMcpFixture((name) => (stage === "metadata-refusal" && name === "get_client_information") || (stage === "primary-refusal" && name === tool) - ? { success: false, message: "Selected stage refused" } + ? refusal : jebPrimaryResponses[name]!, ); const processes = createTestProcessRegistry(); @@ -117,10 +133,27 @@ it.each( } else { expect(result.exitCode).toBe(1); expect(output).toMatchObject({ - code: "unsupported_target", - details: { operation, reason: "Selected stage refused" }, + code: "execution_failure", + message: refusal.message, + details: { + provider_id: "jeb", + operation, + diagnostics: { + endpoint: fixture.endpoint, + upstream_response: refusal, + }, + }, }); - expect(output.message).toContain(operation); + if (stage === "primary-refusal") { + expect(output.details.diagnostics.input).toEqual(input); + expect(output.details).not.toHaveProperty("partial_observation"); + } else { + expect(output.details.diagnostics).not.toHaveProperty("input"); + expect(output.details.partial_observation).toMatchObject({ + operation, + raw_result: jebPrimaryResponses[tool], + }); + } } expect(fixture.calls).toEqual( stage === "primary-refusal" ? [tool] : [tool, "get_client_information"], diff --git a/tests/boundary/mcp/jebMcp.test.ts b/tests/boundary/mcp/jebMcp.test.ts index e845dd97e3..4755de535c 100644 --- a/tests/boundary/mcp/jebMcp.test.ts +++ b/tests/boundary/mcp/jebMcp.test.ts @@ -1,6 +1,7 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; import type { JebAnalysisPort, @@ -326,11 +327,20 @@ it.each( "preserves requested $operation through MCP with metadata $metadata", async ({ operation, tool, input, metadata }) => { let informationCalls = 0; + const refusal = { + success: false, + message: "Client information temporarily refused", + error: { + code: "future_metadata_code", + recovery_required_before_retry: true, + recovery_tool: "get_client_information", + }, + }; const fixture = await startJebMcpFixture((name) => { if (name !== "get_client_information") return jebPrimaryResponses[name]!; informationCalls++; return metadata === "refusal" || informationCalls > 1 - ? { success: false, message: "Client information temporarily refused" } + ? refusal : { success: true, gui_client: false }; }); const provider = new JebProvider({ @@ -376,13 +386,39 @@ it.each( .join(""); expect(JSON.parse(text)).toMatchObject({ error: { - code: "unsupported_target", + code: "execution_failure", + message: refusal.message, details: { + provider_id: "jeb", operation, - reason: "Client information temporarily refused", + diagnostics: { + endpoint: fixture.endpoint, + upstream_response: refusal, + }, + partial_observation: { + kind: "retained-evidence", + evidence_id: expect.any(String), + }, }, }, }); + const partial = z + .object({ + error: z.object({ + details: z.object({ + partial_observation: z.object({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }), + }), + }), + }) + .parse(JSON.parse(text)).error.details.partial_observation; + expect(session.evidenceById(partial.evidence_id)).toMatchObject({ + evidence_id: partial.evidence_id, + operation, + raw_result: jebPrimaryResponses[tool], + }); expect(fixture.calls).toEqual([tool, "get_client_information"]); } } finally { diff --git a/tests/boundary/providers/jeb/failureEnvelopes.test.ts b/tests/boundary/providers/jeb/failureEnvelopes.test.ts new file mode 100644 index 0000000000..ca64c0e9d9 --- /dev/null +++ b/tests/boundary/providers/jeb/failureEnvelopes.test.ts @@ -0,0 +1,194 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { z } from "zod"; +import { createVerifierHttpServer } from "../../../../scripts/lib/verifier-http-server.mjs"; +import { JebProvider } from "../../../../src/jeb/JebProvider.js"; +import { JEB_ENGINE_TOOLS } from "../../../../src/jeb/JebMcpConnection.js"; +import type { JsonValue } from "../../../../src/domain/jsonValue.js"; +import { createServer } from "../../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../../fixtures/binarySession.js"; + +const requestSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string(), + params: z + .object({ name: z.string().optional(), arguments: z.unknown().optional() }) + .passthrough() + .optional(), +}); + +// Exercise the SDK decoder and the public REA tool together. The producer serves +// both documented envelope encodings without mocking either MCP client. +const startProducer = async ( + payload: JsonValue, + encoding: "structured" | "json-text", +) => { + const calls: unknown[] = []; + const { server, close } = createVerifierHttpServer( + async (request, response) => { + if (request.method !== "POST") { + request.resume(); + response.writeHead(request.method === "DELETE" ? 200 : 405).end(); + return; + } + let body = ""; + for await (const chunk of request) body += String(chunk); + const message = requestSchema.parse(JSON.parse(body)); + if (message.id === undefined) { + response.writeHead(202).end(); + return; + } + let result: unknown; + if (message.method === "initialize") { + result = { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "jeb-failure-envelope", version: "1" }, + }; + } else if (message.method === "tools/list") { + result = { + tools: Object.values(JEB_ENGINE_TOOLS).map((name) => ({ + name, + inputSchema: { type: "object", properties: {} }, + })), + }; + } else if (message.method === "tools/call") { + calls.push(message.params); + result = { + isError: true, + ...(encoding === "structured" ? { structuredContent: payload } : {}), + content: [{ type: "text", text: JSON.stringify(payload) }], + }; + } else throw new Error(`Unexpected producer request: ${message.method}`); + response + .writeHead(200, { "Content-Type": "application/json" }) + .end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result })); + }, + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected TCP listener"); + return { endpoint: `http://127.0.0.1:${address.port}/mcp`, calls, close }; +}; + +const failures: { name: string; payload: JsonValue; message: string }[] = [ + { + name: "required recovery", + payload: { + success: false, + error: { + code: "ENGINE_NOT_READY", + message: "Engine requires recovery", + recovery_required_before_retry: true, + recovery_tool: "recover_engine", + }, + message: "Outer status", + metadata: { + selected_artifact: "caller-secret.apk", + details: ["preserve me"], + }, + }, + message: "Engine requires recovery", + }, + { + name: "recovery explicitly unnecessary", + payload: { + success: false, + error: { + code: "INVALID_ITEM", + message: "", + recovery_required_before_retry: false, + recovery_tool: "recover_engine", + }, + message: "Selected item is unavailable", + }, + message: "Selected item is unavailable", + }, + { + name: "omitted recovery and empty messages", + payload: { + success: false, + error: { code: "PROJECT_BUSY", message: "" }, + message: "", + }, + message: "PROJECT_BUSY", + }, + { + name: "outer message without nested error", + payload: { success: false, message: "Project cannot be opened" }, + message: "Project cannot be opened", + }, +]; + +it.each( + failures.flatMap((failure) => + (["structured", "json-text"] as const).map((encoding) => ({ + ...failure, + encoding, + })), + ), +)( + "projects $encoding $name as execution failure with the original envelope", + async ({ payload, message, encoding }) => { + const producer = await startProducer(payload, encoding); + const provider = new JebProvider({ + environment: { REA_JEB_MCP_URL: producer.endpoint }, + }); + const session = createTestBinarySession(() => { + throw new Error("JEB must not start a deep provider"); + }); + const server = createServer( + { kind: "session", session }, + { jebAnalysis: provider, environment: {} }, + ); + const client = new Client({ name: "jeb-failure-contract", version: "1" }); + const input = { path: "./selected target/caller-secret.apk" }; + try { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "open_jeb_project", + arguments: input, + }); + expect(result.isError).toBe(true); + const text = result.content + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + const projected = JSON.parse(text); + expect(projected.error).toMatchObject({ + code: "execution_failure", + message, + }); + expect(projected.error.details.diagnostics).toEqual({ + endpoint: producer.endpoint, + upstream_response: payload, + input, + }); + expect(producer.calls).toEqual([ + { name: "open_project", arguments: { file_path: input.path } }, + ]); + expect(projected.error.code).not.toBe("unsupported_target"); + } finally { + try { + await client.close(); + } finally { + try { + await server.close(); + } finally { + try { + await session.close(); + } finally { + try { + await provider.close(); + } finally { + await producer.close(); + } + } + } + } + } + }, +); From 3515b262218f2d4ba94ce6a7bda9766747b3d84d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:27:10 +0800 Subject: [PATCH 03/29] refactor(ownership)!: remove redundant aliases Use canonical owners for literal matches, tool families, MCP schema presentation and IDA protocol selection; remove unused web-bundle reexports. Remove the duplicate CLI command name and keep the canonical compare workflow. BREAKING CHANGE: rea compare-bundles has been removed. Use rea compare instead. The MCP compare_bundles tool is unchanged. --- docs/cli.md | 3 +++ scripts/verify-real-ida.mjs | 5 ++--- src/application/EnhancedLiteralTracing.ts | 13 +++++-------- src/application/EnhancedToolTypes.ts | 1 - src/cliEvidenceCommands.ts | 1 - src/contracts/toolFamilies.test.ts | 2 +- src/contracts/toolFamilies.ts | 2 -- src/domain/analysisView/analysisView.ts | 7 ------- src/server/EvidenceMcpServer.ts | 8 ++------ src/server/EvidenceMcpServerCore.ts | 2 -- src/server/createServer.ts | 2 +- tests/boundary/cli/bundleComparisonCli.test.ts | 8 ++++++++ tests/boundary/cli/cliToolCoverage.test.ts | 2 +- tests/boundary/filesystem/productCatalog.test.ts | 4 +--- tests/conformance/contracts/mcpToolFamilies.test.ts | 2 +- 15 files changed, 25 insertions(+), 37 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index eabae901c8..ce1cc38fb9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -546,6 +546,9 @@ rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/righ rea compare left.json right.json --record_pairs '[{"left_evidence_id":"ev_","right_evidence_id":"ev_"}]' --json ``` +Migration: replace `rea compare-bundles` with `rea compare`; the former command +has been removed. The MCP tool remains `compare_bundles`. + `--record_pairs` supplies an explicit JSON array of observation pairs, matching MCP `compare_bundles.record_pairs`. Pair IDs must exist on their named sides and be one-to-one. Without pairs, one-sided membership remains unknown; REA does diff --git a/scripts/verify-real-ida.mjs b/scripts/verify-real-ida.mjs index 47a5f63f9d..964f4abecf 100644 --- a/scripts/verify-real-ida.mjs +++ b/scripts/verify-real-ida.mjs @@ -54,7 +54,7 @@ const [ { toolContract }, { evidenceSchema }, { functionDossierSchema }, - { readIdaConfiguration }, + { readIdaConfiguration, idaProtocol }, { createIdaMcpConnection }, { idaAddressSchema }, ] = await Promise.all([ @@ -91,8 +91,7 @@ const environment = Object.fromEntries( const report = { passed: false, mode: config.mode, - protocol: - config.protocol ?? (config.mode === "attached" ? "legacy" : "modern"), + protocol: idaProtocol(config), checks: [], observations: [], unverified: [], diff --git a/src/application/EnhancedLiteralTracing.ts b/src/application/EnhancedLiteralTracing.ts index 0def62ee91..89dd10d1e5 100644 --- a/src/application/EnhancedLiteralTracing.ts +++ b/src/application/EnhancedLiteralTracing.ts @@ -1,3 +1,4 @@ +import type { LiteralFeatureMatch } from "../domain/native/literalFeatureMatch.js"; import { nativeInventoryRowsSchemas } from "../domain/native/nativeInventory.js"; import type { AnalysisExecution, @@ -11,11 +12,7 @@ import { type ContainingProcedureResolution, } from "../domain/hopperValues.js"; import { err, ok, type Result } from "../domain/result.js"; -import type { - EnhancedResult, - TraceMatch, - TraceReference, -} from "./EnhancedToolTypes.js"; +import type { EnhancedResult, TraceReference } from "./EnhancedToolTypes.js"; type AnalysisCall = ( name: AnalysisOperation, @@ -35,7 +32,7 @@ type LiteralSearchSource = readonly [ ]; interface ReferenceTraceRequest { - readonly matches: readonly TraceMatch[]; + readonly matches: readonly LiteralFeatureMatch[]; readonly signal?: AbortSignal; } @@ -83,8 +80,8 @@ const literalMatches = async ( input: LiteralTraceInput, sources: readonly LiteralSearchSource[], signal?: AbortSignal, -): Promise> => { - const matches: TraceMatch[] = []; +): Promise> => { + const matches: LiteralFeatureMatch[] = []; for (const [tool, type] of sources) { const result = await call( tool, diff --git a/src/application/EnhancedToolTypes.ts b/src/application/EnhancedToolTypes.ts index f5cbd713ea..3d4170ff6e 100644 --- a/src/application/EnhancedToolTypes.ts +++ b/src/application/EnhancedToolTypes.ts @@ -11,7 +11,6 @@ import { } from "../contracts/enhancedInputs.js"; export type EnhancedResult = Promise>; -export type { LiteralFeatureMatch as TraceMatch } from "../domain/native/literalFeatureMatch.js"; export type TraceReference = { readonly target_address: string; readonly source_address: string; diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 599e97f468..733bb927b2 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -52,7 +52,6 @@ export const registerEvidenceCommands = ( }), }); cli.command(CLI_COMMANDS.compare, { - aliases: ["compare-bundles"], description: "Compare two canonical Evidence bundles", args: z.object({ left: z.string().describe("Left Evidence bundle JSON path"), diff --git a/src/contracts/toolFamilies.test.ts b/src/contracts/toolFamilies.test.ts index d3068ddc04..a10f40f105 100644 --- a/src/contracts/toolFamilies.test.ts +++ b/src/contracts/toolFamilies.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { TOOL_FAMILY_DEFINITIONS, - TOOL_FAMILY_IDS, contractsForToolFamilies, toolFamilyId, } from "./toolFamilies.js"; +import { TOOL_FAMILY_IDS } from "./toolFamilyIds.js"; import { TOOL_CONTRACTS } from "./toolContracts.js"; describe("tool families", () => { diff --git a/src/contracts/toolFamilies.ts b/src/contracts/toolFamilies.ts index 09521118af..fcb149a8f2 100644 --- a/src/contracts/toolFamilies.ts +++ b/src/contracts/toolFamilies.ts @@ -46,8 +46,6 @@ import { FRIDA_TOOL_CONTRACTS } from "./fridaToolContracts.js"; import { TOOL_CONTRACTS } from "./toolContracts.js"; import { TOOL_FAMILY_IDS, type ToolFamilyId } from "./toolFamilyIds.js"; -export { TOOL_FAMILY_IDS, type ToolFamilyId } from "./toolFamilyIds.js"; - /** One selectable family: a stable selection id and its complete contract set. */ export interface ToolFamilyDefinition { readonly id: ToolFamilyId; diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 52ff16be20..e20f3d3b90 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1432,13 +1432,6 @@ export type InspectAnalysisViewInput = z.output< export type AnalysisViewResult = z.output; export type AnalysisViewCoverage = z.output; -export { - webBundleViewRequestSchema, - webBundleViewItemsSchema, - webBundleSummarySchema, - projectWebBundleView, -}; - /** Parent record already resolved from inline Evidence or a session reference. */ export interface AnalysisViewParent { readonly evidenceId: string; diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 538ccbe253..f79caa9049 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -11,12 +11,8 @@ import { import { toolFamilyId } from "../contracts/toolFamilies.js"; import type { ToolResultDelivery } from "./ToolResultDelivery.js"; import type { McpOutputSchemaProfile } from "../config/mcpOutputSchemaProfile.js"; -import { - EvidenceMcpServerCore, - type CompactInputSchemaPresentation, -} from "./EvidenceMcpServerCore.js"; - -export { type CompactInputSchemaPresentation } from "./EvidenceMcpServerCore.js"; +import { EvidenceMcpServerCore } from "./EvidenceMcpServerCore.js"; +import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; /** Canonical composition retains the constructor's existing identity defaults. */ export class EvidenceMcpServer extends EvidenceMcpServerCore { diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 13700ad8ce..534e2627a5 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -58,8 +58,6 @@ import { } from "./toolResultEncoding.js"; import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js"; -export type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; - const currentRequestSignal = new AsyncLocalStorage(); /** Signal for the completion request currently being dispatched by the SDK. */ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 5ee471a03c..bace9e2ff5 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -109,7 +109,7 @@ import { compactPresentationForMcpInputSchemaProfile, parseMcpInputSchemaProfile, } from "../config/mcpInputSchemaProfile.js"; -import type { CompactInputSchemaPresentation } from "./EvidenceMcpServer.js"; +import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; import { isAbsolute } from "node:path"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; diff --git a/tests/boundary/cli/bundleComparisonCli.test.ts b/tests/boundary/cli/bundleComparisonCli.test.ts index 5fdc1bd606..f5b97810ab 100644 --- a/tests/boundary/cli/bundleComparisonCli.test.ts +++ b/tests/boundary/cli/bundleComparisonCli.test.ts @@ -15,6 +15,14 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest("rejects the removed compare-bundles command", async ({ cli }) => { + const result = await cli.run({ + arguments: ["compare-bundles", "left.json", "right.json", "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("COMMAND_NOT_FOUND"); +}); + cliTest( "compares Evidence bundles without unexplained display-only changes", async ({ cli }) => { diff --git a/tests/boundary/cli/cliToolCoverage.test.ts b/tests/boundary/cli/cliToolCoverage.test.ts index 66b1ec699d..876d144fe6 100644 --- a/tests/boundary/cli/cliToolCoverage.test.ts +++ b/tests/boundary/cli/cliToolCoverage.test.ts @@ -16,7 +16,7 @@ const CLI_COMMAND_TOOL_ALIASES = Object.freeze({ trace: ["trace_feature"], search: ["search_strings", "search_procedures"], "capture-process": ["capture_process_scenario"], - "compare-bundles": ["compare_bundles"], + compare: ["compare_bundles"], "list-objdump-architectures": ["inspect_with_objdump"], "debug-with-gdb": ["gdb_console"], "debug-with-rizin": ["rizin_debug_command"], diff --git a/tests/boundary/filesystem/productCatalog.test.ts b/tests/boundary/filesystem/productCatalog.test.ts index fc34c9985b..a3aad84c34 100644 --- a/tests/boundary/filesystem/productCatalog.test.ts +++ b/tests/boundary/filesystem/productCatalog.test.ts @@ -128,9 +128,7 @@ describe("canonical CLI catalog", () => { it("uses the same primary command names as the actual Incur router", () => { const inventory = createCliInventory(createCli()); expect(inventory.primary).toEqual([...CLI_COMMAND_NAMES].sort()); - expect(inventory.aliases).toEqual([ - { name: "compare-bundles", target: "compare" }, - ]); + expect(inventory.aliases).toEqual([]); }, 30_000); it("describes every primary command argument and option", () => { diff --git a/tests/conformance/contracts/mcpToolFamilies.test.ts b/tests/conformance/contracts/mcpToolFamilies.test.ts index 803100cff4..3c596f5c3a 100644 --- a/tests/conformance/contracts/mcpToolFamilies.test.ts +++ b/tests/conformance/contracts/mcpToolFamilies.test.ts @@ -7,7 +7,7 @@ import { import { TOOL_FAMILY_IDS, type ToolFamilyId, -} from "../../../src/contracts/toolFamilies.js"; +} from "../../../src/contracts/toolFamilyIds.js"; describe("MCP tool families configuration", () => { it("accepts an unset scope and every valid family selection", () => { From c27560e434980615ecbc66fca518398f45002360 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:28:00 +0800 Subject: [PATCH 04/29] fix(artifacts): classify selected package names --- src/application/BinaryTargetResolver.ts | 33 ++--- src/application/binary/BinarySessionOpen.ts | 2 +- .../JavaScriptArtifactReconstruction.ts | 6 +- src/artifacts/ArtifactProvider.ts | 4 +- src/artifacts/inventory/classify.ts | 9 +- src/domain/artifactNameSelection.ts | 26 ++++ src/domain/binaryTargetTypes.ts | 8 +- .../artifactSelectedNameCli.test.ts | 76 +++++++++++ .../cli/artifactCallerSelection.test.ts | 27 +++- .../filesystem/artifactSelectedName.test.ts | 128 ++++++++++++++++++ .../filesystem/asarSelectedCompanion.test.ts | 2 +- 11 files changed, 285 insertions(+), 36 deletions(-) create mode 100644 src/domain/artifactNameSelection.ts create mode 100644 tests/acceptance/artifactSelectedNameCli.test.ts create mode 100644 tests/boundary/filesystem/artifactSelectedName.test.ts diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 07610a7131..f6cc342ed9 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -1,3 +1,7 @@ +import { + artifactNameForClassification, + namedArtifactFormat, +} from "../domain/artifactNameSelection.js"; import { readFileHandleRange } from "../filesystem/readFileHandleRange.js"; import { readPeCoffHeader } from "../domain/peCoffHeader.js"; import { @@ -372,17 +376,11 @@ const readOpenedBinaryTarget = async ( } // Probe WASM content before suffix-only database routing; an explicit // database selection keeps the caller's chosen interpretation. - let artifactFormat = + const classificationPath = artifactNameForClassification(selectedPath, path); + const artifactFormat = targetKind === "database" ? undefined - : await detectArtifactFormat(path, source, signal); - if ( - targetKind !== "database" && - artifactFormat === undefined && - selectedPath.toLowerCase().endsWith(".asar") && - !(await claimsLeadingExecutableHeader(handle, metadata.size, signal)) - ) - artifactFormat = "asar"; + : await detectArtifactFormat(classificationPath, source, signal); if ( targetKind === "database" || (targetKind === undefined && path.toLowerCase().endsWith(".hop")) @@ -398,7 +396,7 @@ const readOpenedBinaryTarget = async ( const identity = { path, sourcePath, - ...(artifactFormat === "asar" ? { asarSelectedPath: selectedPath } : {}), + artifactSelectedPath: selectedPath, sha256: await sha256Handle(handle, path, metadata, signal), }; return isArchiveFormat(artifactFormat) @@ -424,7 +422,8 @@ const readOpenedBinaryTarget = async ( sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), kind: "archive", - format: zipRootPackageFormat(path), + format: zipRootPackageFormat(classificationPath), + artifactSelectedPath: selectedPath, }); const reason = typeof detected.error === "string" @@ -669,7 +668,8 @@ const detectArtifactFormat = async ( // Binary plists, including keyed archives, often use other extensions. if (hasBinaryPlistSignature(magic)) return "plist"; const named = namedArtifactFormat(lower); - if (named !== undefined) return named; + if (named !== undefined && !claimsExecutableHeader(magic, initial.size)) + return named; if ( lower.endsWith(".pkg") && magic.length >= 4 && @@ -717,15 +717,6 @@ const detectArtifactFormat = async ( return undefined; }; -const namedArtifactFormat = ( - lowerPath: string, -): "asar" | "plist" | "source-map" | "javascript" | undefined => { - if (lowerPath.endsWith(".asar")) return "asar"; - if (lowerPath.endsWith(".plist")) return "plist"; - if (lowerPath.endsWith(".map")) return "source-map"; - return /\.(?:m?js|cjs)$/u.test(lowerPath) ? "javascript" : undefined; -}; - const isArchiveFormat = ( format: Exclude< BinaryTarget["format"], diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 43b7dca677..75af7bcbc6 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -152,7 +152,7 @@ const sameSessionTarget = ( current.path === target.path && // Native admission is bound to the requested path, not just its realpath. (current.sourcePath ?? current.path) === (target.sourcePath ?? target.path) && - // ASAR companion lookup depends on the selected archive spelling. + // Artifact name hints and companions depend on the selected spelling. artifactTargetSelectedPath(current) === artifactTargetSelectedPath(target) && snapshotMatchesTarget(snapshotTarget(current), target); diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 47c61ec1b5..8e3bf7ecc7 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,3 +1,4 @@ +import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { AnalysisSettingValues } from "../../domain/analysisSettings.js"; import { absoluteCallerPath } from "../../filesystem/callerPath.js"; import { @@ -500,8 +501,9 @@ const resolveFormat = ( const observed = metadata.isDirectory() ? "directory" : metadata.isFile() && - (selectedPath.toLowerCase().endsWith(".asar") || - path.toLowerCase().endsWith(".asar")) + artifactNameForClassification(selectedPath, path) + .toLowerCase() + .endsWith(".asar") ? "asar" : undefined; if (observed === undefined) diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index d147342676..42712a0b56 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -310,7 +310,7 @@ class ArtifactClient implements AnalysisClient { const result = await extractArtifact( { inputPath: this.target.sourcePath ?? this.target.path, - selectedPath: this.target.asarSelectedPath, + selectedPath: this.target.artifactSelectedPath, inputFormat: this.target.format, outputRoot: parsed.output_root, environment: this.environment, @@ -505,7 +505,7 @@ class ArtifactClient implements AnalysisClient { options?: ExecutionOptions, ) { return inventoryArtifact(this.target.sourcePath ?? this.target.path, { - selectedPath: this.target.asarSelectedPath, + selectedPath: this.target.artifactSelectedPath, resourceScope: this.#resourceScope, environment: this.environment, asarHeaderAdmission: runtimeAsarHeaderAdmission( diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index ef5ad2bdbb..8a31fea340 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { @@ -185,7 +186,11 @@ const classifyRootFormat = async ( const archiveBytes = (await handle.stat()).size; const evidence = probe.finish(archiveBytes); abortIfNeeded(signal); - const lower = path.toLowerCase(); + const classificationPath = artifactNameForClassification( + selectedPath ?? path, + path, + ); + const lower = classificationPath.toLowerCase(); if (hasZipSignature(evidence.prefix)) return zipRootPackageFormat(lower); if ( lower.endsWith(".pkg") && @@ -228,7 +233,7 @@ const classifyRootFormat = async ( ? fatEvidence : { ...fatEvidence, mzWindowsHeaderSignature }; const format = classifyArtifactContent( - selectedPath?.toLowerCase().endsWith(".asar") ? selectedPath : path, + classificationPath, classificationEvidence, archiveBytes, ).format; diff --git a/src/domain/artifactNameSelection.ts b/src/domain/artifactNameSelection.ts new file mode 100644 index 0000000000..5615eb0058 --- /dev/null +++ b/src/domain/artifactNameSelection.ts @@ -0,0 +1,26 @@ +import { zipPackageFormatForPath } from "./zipPackageFormat.js"; + +/** Name-selected root formats; callers establish byte-signature precedence first. */ +export const namedArtifactFormat = ( + path: string, +): "asar" | "plist" | "source-map" | "javascript" | undefined => { + const lower = path.toLowerCase(); + if (lower.endsWith(".asar")) return "asar"; + if (lower.endsWith(".plist")) return "plist"; + if (lower.endsWith(".map")) return "source-map"; + return /\.(?:m?js|cjs)$/u.test(lower) ? "javascript" : undefined; +}; + +/** + * A recognized selected artifact name supplies format hints. Unknown aliases + * retain the canonical name's hints; neither spelling changes packed I/O. + */ +export const artifactNameForClassification = ( + selectedPath: string, + canonicalPath: string, +): string => + namedArtifactFormat(selectedPath) !== undefined || + zipPackageFormatForPath(selectedPath) !== undefined || + /\.(?:app|pkg|dmg)$/iu.test(selectedPath) + ? selectedPath + : canonicalPath; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index fb556f092e..d7fb7fb940 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -128,8 +128,8 @@ export interface UniversalMachOSlice { interface BinaryTargetIdentity { readonly path: string; readonly sourcePath?: string; - /** Selected ASAR spelling determines its external unpacked companion, not packed identity. */ - readonly asarSelectedPath?: string; + /** Selected artifact spelling determines name-based format hints and external companions. */ + readonly artifactSelectedPath?: string; /** Info.plist of the app bundle the target was resolved from. */ readonly bundleInfoPlist?: string; readonly sha256: string; @@ -282,6 +282,6 @@ export const isMachOFormat = ( /** Result-affecting artifact selection; canonical packed identity remains target.path. */ export const artifactTargetSelectedPath = (target: BinaryTarget): string => - target.format === "asar" - ? (target.asarSelectedPath ?? target.sourcePath ?? target.path) + target.kind === "archive" || target.kind === "artifact" + ? (target.artifactSelectedPath ?? target.sourcePath ?? target.path) : (target.sourcePath ?? target.path); diff --git a/tests/acceptance/artifactSelectedNameCli.test.ts b/tests/acceptance/artifactSelectedNameCli.test.ts new file mode 100644 index 0000000000..496fde4dd5 --- /dev/null +++ b/tests/acceptance/artifactSelectedNameCli.test.ts @@ -0,0 +1,76 @@ +import { readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, onTestFinished } from "vitest"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest + .skipIf(process.platform === "win32") + .for(["content-addressed-archive", "stored.ipa"])( + "public APK aliases retain package selection through inspection, projection and extraction from %s", + async (storedName, { cli }) => { + const root = await createTestTempDirectory("rea-selected-apk-cli-"); + const stored = join(root, storedName); + const selected = join(root, "selected.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add( + "AndroidManifest.xml", + new TextReader( + '', + ), + ); + await writer.add("payload.txt", new TextReader("same packed bytes")); + await writeFile(stored, await writer.close()); + await symlink(stored, selected); + const inspected = await cli.run({ + arguments: ["inspect-artifact", selected, "--json"], + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + const inspectedEvidence = parseEvidence(inspected.json); + expect(inspectedEvidence.subject?.local_path).toBe(selected); + const inspection = artifactInspectionResultSchema.parse( + inspectedEvidence.normalized_result, + ); + const inventoryEvidence = inspection.substeps.find( + ({ operation }) => operation === "inventory_artifact", + )?.evidence; + if (inventoryEvidence === undefined) + throw new Error("Missing inventory Evidence"); + expect( + artifactInventoryResultSchema.parse(inventoryEvidence.normalized_result) + .manifest.root_format, + ).toBe("apk"); + const projection = await cli.run({ + arguments: [ + "project-android-application-graph", + JSON.stringify({ inventory_evidence: [inventoryEvidence] }), + "--json", + ], + }); + expect(projection.exitCode, projection.stdout + projection.stderr).toBe(0); + expect(parseEvidence(projection.json).subject?.format).toBe("apk"); + const extracted = await cli.run({ + arguments: ["extract-artifact", selected, "--json"], + }); + expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0); + const extractedEvidence = parseEvidence(extracted.json); + expect(extractedEvidence.subject?.local_path).toBe(selected); + const result = artifactExtractionResultSchema.parse( + extractedEvidence.normalized_result, + ); + onTestFinished(() => + rm(result.output_root, { recursive: true, force: true }), + ); + expect(result.manifest.root_format).toBe("apk"); + expect( + await readFile(join(result.output_root, "payload.txt"), "utf8"), + ).toBe("same packed bytes"); + }, +); diff --git a/tests/boundary/cli/artifactCallerSelection.test.ts b/tests/boundary/cli/artifactCallerSelection.test.ts index ae5f76bbe8..73d774fc75 100644 --- a/tests/boundary/cli/artifactCallerSelection.test.ts +++ b/tests/boundary/cli/artifactCallerSelection.test.ts @@ -1,3 +1,4 @@ +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; import { readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; @@ -7,7 +8,7 @@ import { createSingleEntryZip } from "../../fixtures/zipNames.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; cliTest.skipIf(process.platform === "win32")( - "retains the selected archive alias independently of its canonical subject", + "retains the selected archive alias without changing inventory identity", async ({ cli }) => { const root = await createTestTempDirectory("rea-artifact-selected-alias-"); const target = join(root, "program.zip"); @@ -27,8 +28,28 @@ cliTest.skipIf(process.platform === "win32")( const observed = parseEvidence(selected.json); const direct = parseEvidence(control.json); expect(observed.parameters).toMatchObject({ input_path: alias }); - expect(observed.subject).toEqual(direct.subject); - expect(observed.normalized_result).toEqual(direct.normalized_result); + expect(observed.subject?.local_path).toBe(join(root, alias)); + expect(direct.subject?.local_path).toBe(target); + expect(observed.subject?.digest.sha256).toBe(direct.subject?.digest.sha256); + const selectedInspection = artifactInspectionResultSchema.parse( + observed.normalized_result, + ); + const directInspection = artifactInspectionResultSchema.parse( + direct.normalized_result, + ); + expect(selectedInspection.subject).toEqual(directInspection.subject); + const selectedInventory = selectedInspection.substeps.find( + ({ operation }) => operation === "inventory_artifact", + )?.evidence; + const directInventory = directInspection.substeps.find( + ({ operation }) => operation === "inventory_artifact", + )?.evidence; + if (selectedInventory === undefined || directInventory === undefined) + throw new Error("Missing nested inventory Evidence"); + expect(selectedInventory.subject?.local_path).toBe(join(root, alias)); + expect(selectedInventory.normalized_result).toEqual( + directInventory.normalized_result, + ); }, ); diff --git a/tests/boundary/filesystem/artifactSelectedName.test.ts b/tests/boundary/filesystem/artifactSelectedName.test.ts new file mode 100644 index 0000000000..4b8b4dc6fb --- /dev/null +++ b/tests/boundary/filesystem/artifactSelectedName.test.ts @@ -0,0 +1,128 @@ +import { readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it, vi } from "vitest"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const fixture = async (storedName = "content-addressed-archive") => { + const root = await createTestTempDirectory("rea-artifact-selected-name-"); + const canonical = join(root, storedName); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add( + "AndroidManifest.xml", + new TextReader( + '', + ), + ); + await writer.add("payload.txt", new TextReader("same packed bytes")); + await writeFile(canonical, await writer.close()); + return { root, canonical }; +}; + +it.skipIf(process.platform === "win32").each([ + ["selected.apk", "content", "apk"], + ["selected.ipa", "content", "ipa"], + ["selected.apk", "canonical.ipa", "apk"], + ["selected.ipa", "canonical.apk", "ipa"], + ["selected.unknown", "canonical.apk", "apk"], + ["selected.asar", "content", "zip"], +] as const)( + "keeps selected %s and stored %s consistent across admission, inventory and extraction", + async (name, stored, format) => { + const { root, canonical } = await fixture(stored); + const selected = join(root, name); + await symlink(canonical, selected); + const admitted = await parseBinaryTarget(selected); + if (!admitted.ok) throw admitted.error; + expect(admitted.value.path).toBe(canonical); + expect(admitted.value.format).toBe(format); + const { runProviderAnalysis } = createDirectAnalysis({}); + const observed = parseEvidence( + await runProviderAnalysis(selected, "inventory_artifact", {}), + ); + expect(observed.subject?.local_path).toBe(selected); + expect( + artifactInventoryResultSchema.parse(observed.normalized_result).manifest + .root_format, + ).toBe(format); + const provider = new ArtifactProvider({}); + const client = provider.createClient(admitted.value); + try { + const execution = await client.execute("extract_artifact", { + output_root: join(root, "output"), + integrity_policy: "fail", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.subject?.path).toBe(selected); + const result = artifactExtractionResultSchema.parse( + execution.value.result, + ); + expect(result.manifest.root_format).toBe(format); + expect( + await readFile(join(result.output_root, "payload.txt"), "utf8"), + ).toBe("same packed bytes"); + } finally { + await client.close(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "reopens the same packed file under distinct package selections and reuses unchanged selection", + async () => { + const { root, canonical } = await fixture(); + const apk = join(root, "selected.apk"); + const ipa = join(root, "selected.ipa"); + await symlink(canonical, apk); + await symlink(canonical, ipa); + const provider = new ArtifactProvider({}); + const created = vi.spyOn(provider, "createClient"); + const session = createTestBinarySession(provider); + try { + for (const [path, format, calls] of [ + [apk, "apk", 1], + [apk, "apk", 1], + [ipa, "ipa", 2], + ] as const) { + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + expect(created).toHaveBeenCalledTimes(calls); + const execution = await session.execute("inventory_artifact", {}); + if (!execution.ok) throw execution.error; + expect(execution.value.subject?.path).toBe(path); + expect( + artifactInventoryResultSchema.parse(execution.value.result).manifest + .root_format, + ).toBe(format); + } + } finally { + await session.close(); + created.mockRestore(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "does not reinterpret real executable bytes from an artifact alias suffix", + async () => { + const root = await createTestTempDirectory("rea-artifact-executable-name-"); + const canonical = join(root, "program"); + const selected = join(root, "selected.asar"); + await writeFile(canonical, elf(2, 1, 62)); + await symlink(canonical, selected); + const admitted = await parseBinaryTarget(selected); + if (!admitted.ok) throw admitted.error; + expect(admitted.value.format).toBe("elf"); + expect(admitted.value.kind).toBe("executable"); + }, +); diff --git a/tests/boundary/filesystem/asarSelectedCompanion.test.ts b/tests/boundary/filesystem/asarSelectedCompanion.test.ts index f7e9d4d1eb..cde62c9dd3 100644 --- a/tests/boundary/filesystem/asarSelectedCompanion.test.ts +++ b/tests/boundary/filesystem/asarSelectedCompanion.test.ts @@ -200,7 +200,7 @@ it.skipIf(process.platform === "win32")( const admitted = await parseBinaryTarget(selected); if (!admitted.ok) throw admitted.error; expect(admitted.value.path).toBe(canonical); - expect(admitted.value.asarSelectedPath).toBe(selected); + expect(admitted.value.artifactSelectedPath).toBe(selected); const replacement = join(root, "replacement.asar"); await writeFile(replacement, await readFile(canonical)); await rm(selected); From 7c8eb63fccecb9d8e46c652b7745067a4ad87ea7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:28:05 +0800 Subject: [PATCH 05/29] fix(javascript): retain package entrypoint provenance --- docs/javascript-artifact-reconstruction.md | 9 ++ ...ScriptArtifactAnalysis.packageMain.test.ts | 65 ++++++++++- .../javascript/JavaScriptArtifactAnalysis.ts | 26 ++++- .../JavaScriptArtifactAnalysisTypes.ts | 18 ++- .../JavaScriptArtifactGraphStructure.ts | 26 +++-- .../javascriptModuleRelationships.test.ts | 2 +- ...avascriptPackageRendererProvenance.test.ts | 105 ++++++++++++++++++ ...riptArtifactGraphObservationLookup.test.ts | 3 +- 8 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 9e10c06036..f166814287 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -72,6 +72,15 @@ code-point order. Dependency manifests remain package observations but do not declare Electron main or renderer entries. If only dependency manifests are present, the inventory artifact remains the graph root. +Package observations keep the legacy `renderer` property as a derived entrypoint +candidate: the first nonempty string from `renderer`, `browser`, then `module`. +It does not assert that the source declares a literal `renderer` field. +`renderer_declarations` records the actual present fields: strings, including +empty strings, remain exact; non-string values use `{ "status": "non-string" }` +without copying nested browser mappings. An empty record means no such fields +were present; `null` means package metadata was invalid or unavailable. Renderer +role mechanisms and package load edges name the field that supplied the candidate. + ## Select sources before analysis For a question about known entry sources, supply exact artifact-relative file diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts index 2d97028027..972a4ea767 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts @@ -2,23 +2,26 @@ import { expect, it } from "vitest"; import { analyzeArtifactFiles } from "../../../tests/fixtures/javascriptArtifactAnalysis.js"; -const packageObservation = async (text: string) => { +const packageObservation = async (text: string | null) => { const { analysis } = await analyzeArtifactFiles({ files: [ { path: "app/package.json", container_sha256: "a".repeat(64), sha256: "b".repeat(64), - bytes: Buffer.byteLength(text), + bytes: Buffer.byteLength(text ?? ""), inventory_artifact_id: "package-main-fixture", kind: "package-json", unpacked: false, - text: { included: true, value: text }, + text: + text === null + ? { included: false, reason: "invalid-utf8" } + : { included: true, value: text }, }, ], unavailable_relevant_files: [], containers: [], - text_bytes_read: Buffer.byteLength(text), + text_bytes_read: Buffer.byteLength(text ?? ""), invalid_utf8_files: 0, }); return analysis.packages[0]; @@ -36,7 +39,7 @@ it("retains declared package metadata and normal defaults", async () => { main: null, main_status: "default", main_entrypoint: "", - renderer: null, + renderer_declarations: {}, }); expect(declared).toMatchObject({ status: "included", @@ -45,10 +48,60 @@ it("retains declared package metadata and normal defaults", async () => { main: null, main_status: "invalid", main_entrypoint: "", - renderer: "source-browser.cjs", + renderer_declarations: { browser: "source-browser.cjs" }, + renderer_entrypoint: { field: "browser", path: "source-browser.cjs" }, }); }); +it.each([ + { + fields: { + renderer: "renderer.js", + browser: "browser.js", + module: "module.js", + }, + field: "renderer", + path: "renderer.js", + }, + { + fields: { renderer: false, browser: "browser.js", module: "module.js" }, + field: "browser", + path: "browser.js", + }, + { + fields: { renderer: "", browser: {}, module: "module.js" }, + field: "module", + path: "module.js", + }, +])( + "keeps simultaneous renderer declarations separate from selected $field provenance", + async ({ fields, field, path }) => { + const observation = await packageObservation(JSON.stringify(fields)); + expect(observation).toMatchObject({ renderer_entrypoint: { field, path } }); + for (const [key, value] of Object.entries(fields)) + expect(observation).toMatchObject({ + renderer_declarations: { + [key]: typeof value === "string" ? value : { status: "non-string" }, + }, + }); + }, +); + +it.each([ + ["{}", {}], + ["{", null], + ["null", null], + [null, null], +] as const)( + "keeps absent/invalid/unavailable renderer origins distinct for %s", + async (text, declarations) => { + expect(await packageObservation(text)).toMatchObject({ + renderer_declarations: declarations, + renderer_entrypoint: null, + }); + }, +); + it.each([null, false, 123, {}, []].map((main) => ({ main })))( "retains non-string main $main evidence separately from Node index fallback", async ({ main }) => { diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 21ae157f6e..648fa64565 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -379,6 +379,23 @@ const parsePackage = ( if (document.packageFields === null) return invalidPackage(file, "package.json root is not an object."); const value = document.packageFields; + const rendererDeclarations: NonNullable< + JavaScriptPackageObservation["renderer_declarations"] + > = {}; + let rendererEntrypoint: JavaScriptPackageObservation["renderer_entrypoint"] = + null; + for (const field of ["renderer", "browser", "module"] as const) { + if (!Object.hasOwn(value, field)) continue; + const declaration = value[field]; + rendererDeclarations[field] = + typeof declaration === "string" ? declaration : { status: "non-string" }; + if ( + rendererEntrypoint === null && + typeof declaration === "string" && + declaration !== "" + ) + rendererEntrypoint = { field, path: declaration }; + } return { path: file.path, sha256: file.sha256, @@ -393,10 +410,8 @@ const parsePackage = ( ? "declared" : "invalid", main_entrypoint: typeof value.main === "string" ? value.main : "", - renderer: - optionalString(value.renderer) ?? - optionalString(value.browser) ?? - optionalString(value.module), + renderer_declarations: rendererDeclarations, + renderer_entrypoint: rendererEntrypoint, limitation: null, }; }; @@ -421,7 +436,8 @@ const unavailablePackage = ( main: null, main_status: "unavailable", main_entrypoint: null, - renderer: null, + renderer_declarations: null, + renderer_entrypoint: null, limitation, }); diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index 144e4af58f..cd19abb8bf 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -20,6 +20,14 @@ interface JavaScriptStructuredObservation { readonly sha256: string; } +/** Present source declarations only; non-strings stay bounded and retain source ownership. */ +export type JavaScriptPackageRendererDeclarations = Partial< + Record< + "renderer" | "browser" | "module", + string | { readonly status: "non-string" } + > +>; + interface JavaScriptPackageValues { readonly name: string | null; readonly version: string | null; @@ -27,7 +35,12 @@ interface JavaScriptPackageValues { readonly main_status: "default" | "declared" | "invalid"; /** Node launch declaration; an empty string requests the package index fallback. */ readonly main_entrypoint: string; - readonly renderer: string | null; + readonly renderer_declarations: JavaScriptPackageRendererDeclarations; + /** Selected static renderer candidate, retaining the declaration that supplied it. */ + readonly renderer_entrypoint: { + readonly field: "renderer" | "browser" | "module"; + readonly path: string; + } | null; } /** Parsed package metadata used only for static entrypoint discovery. */ @@ -44,7 +57,8 @@ export type JavaScriptPackageObservation = JavaScriptStructuredObservation & readonly main: null; readonly main_status: "unavailable"; readonly main_entrypoint: null; - readonly renderer: null; + readonly renderer_declarations: null; + readonly renderer_entrypoint: null; readonly limitation: string; } ); diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 086f0531d2..c3ff99f226 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -53,6 +53,7 @@ interface PackageRoleInput { readonly packageFile: JavaScriptArtifactFile; readonly kind: "electron-main" | "electron-renderer"; readonly declaredPath: string | null; + readonly field: "main" | "renderer" | "browser" | "module"; } /** Create the exact root artifact node from the inventory manifest. */ @@ -552,7 +553,9 @@ export const addJavaScriptPackageNodes = ( version: packageValue.version, main: packageValue.main, main_status: packageValue.main_status, - renderer: packageValue.renderer, + // Legacy derived candidate; source fields live in renderer_declarations. + renderer: packageValue.renderer_entrypoint?.path ?? null, + renderer_declarations: packageValue.renderer_declarations, parse_status: packageValue.status, }, evidence: artifactObservationEvidence({ @@ -612,13 +615,16 @@ export const addJavaScriptPackageNodes = ( packageFile: file, kind: "electron-main", declaredPath: packageValue.main, + field: "main", }); - addPackageRole(context, { - packageNode: node, - packageFile: file, - kind: "electron-renderer", - declaredPath: packageValue.renderer, - }); + if (packageValue.renderer_entrypoint !== null) + addPackageRole(context, { + packageNode: node, + packageFile: file, + kind: "electron-renderer", + declaredPath: packageValue.renderer_entrypoint.path, + field: packageValue.renderer_entrypoint.field, + }); } return roots; }; @@ -755,16 +761,14 @@ const addPackageRole = ( kind: input.kind, anchor: input.packageFile, resolution, - mechanism: - input.kind === "electron-main" - ? "package.json:main" - : "package.json:renderer", + mechanism: `package.json:${input.field}`, }); context.accumulator.addEdge({ source_node_id: input.packageNode.node_id, target_node_id: role.node_id, relation: "loads", properties: { + source_field: input.field, declared_path: input.declaredPath, resolution_context: resolution.resolution_context, resolved_path: resolution.resolved_path, diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts index cd103df8a3..1a276c8598 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts @@ -81,7 +81,7 @@ it.each(["browser", "module", "renderer", "main"] as const)( kind === "electron-renderer" && observations.some( ({ properties }) => - properties.mechanism === "package.json:renderer" && + properties.mechanism === `package.json:${field}` && properties.resolved_path === "lib/window.cjs", ), ), diff --git a/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts new file mode 100644 index 0000000000..467de327ca --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts @@ -0,0 +1,105 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; + +const packageGraph = async (metadata: Record) => { + const root = await createTestTempDirectory( + "rea-package-renderer-provenance-", + ); + await Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify(metadata)), + writeFile(join(root, "entry.js"), "export const value = 42;"), + ]); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + return javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ).graph; +}; + +it.each(["renderer", "browser", "module"] as const)( + "attributes the package renderer candidate to its actual %s field", + async (field) => { + const graph = await packageGraph({ [field]: "entry.js" }); + const role = graph.nodes.find(({ kind }) => kind === "electron-renderer"); + expect(role?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + mechanism: `package.json:${field}`, + resolved_path: "entry.js", + }), + }), + ); + const metadata = graph.nodes.find(({ kind }) => kind === "package"); + expect(metadata?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + renderer: "entry.js", + renderer_declarations: { [field]: "entry.js" }, + }), + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: metadata?.node_id, + target_node_id: role?.node_id, + relation: "loads", + properties: expect.objectContaining({ + source_field: field, + declared_path: "entry.js", + }), + }), + ); + expect(metadata?.observations[0]?.properties.renderer_declarations).toEqual( + { [field]: "entry.js" }, + ); + }, +); + +it("keeps present browser mappings distinct from absent renderer metadata when module supplies the candidate", async () => { + const graph = await packageGraph({ + browser: { "./server.js": "./client.js" }, + module: "entry.js", + }); + const metadata = graph.nodes.find(({ kind }) => kind === "package"); + expect(metadata?.observations[0]?.properties).toMatchObject({ + renderer: "entry.js", + renderer_declarations: { + browser: { status: "non-string" }, + module: "entry.js", + }, + }); + expect( + graph.nodes.find(({ kind }) => kind === "electron-renderer") + ?.observations[0]?.properties.mechanism, + ).toBe("package.json:module"); +}); + +it("retains simultaneous string declarations while keeping renderer priority", async () => { + const graph = await packageGraph({ + renderer: "entry.js", + browser: "browser.js", + module: "module.js", + }); + const metadata = graph.nodes.find(({ kind }) => kind === "package"); + expect(metadata?.observations[0]?.properties).toMatchObject({ + renderer: "entry.js", + renderer_declarations: { + renderer: "entry.js", + browser: "browser.js", + module: "module.js", + }, + }); + expect( + graph.nodes.find(({ kind }) => kind === "electron-renderer") + ?.observations[0]?.properties.mechanism, + ).toBe("package.json:renderer"); +}); diff --git a/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts index 6cbf2c6ea9..58d16d2454 100644 --- a/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts @@ -67,7 +67,8 @@ it("uses the first structured observation when paths repeat", async () => { name: null, version: null, main: null, - renderer: null, + renderer_declarations: null, + renderer_entrypoint: null, limitation: "later duplicate", } satisfies JavaScriptPackageObservation, ], From 567ea4b1e8bb7dda67563d06f1b4977c2d92995a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:31:23 +0800 Subject: [PATCH 06/29] refactor(ownership)!: consolidate legacy aliases Migrate schema, installer, ASN.1, and tooling consumers to their owning definitions. Remove the redundant CI task entry and the deprecated Cutter command-local JSON flag. BREAKING CHANGE: Replace Cutter --json=true or --json=false with --json-output=true or --json-output=false. Bare --json still controls CLI result formatting. --- docs/cli.md | 8 ++--- package.json | 1 - src/application/LinuxHopper.test.ts | 4 +-- src/application/LinuxHopper.ts | 5 +-- .../environment/environmentBuild.ts | 3 -- src/cli/cutterCommands.ts | 8 +---- .../native/binaryDiagnosticsToolContracts.ts | 11 ++---- src/domain/analysisView/analysisView.ts | 14 ++++---- .../analysisView/javascriptRoutesView.ts | 2 -- .../nativeFunctionMatchingView.ts | 3 -- src/domain/erlang/beam.ts | 3 +- .../javascriptApplicationEvidenceSchemas.ts | 28 +++++++-------- src/domain/native/byteEntropy.ts | 3 +- src/domain/publicInput.test.ts | 16 --------- src/domain/publicInput.ts | 9 ----- src/native/pe/PeCertificateAsn1.ts | 35 ++++++++++--------- tests/boundary/cli/cutterCommands.test.ts | 29 ++++++++++----- 17 files changed, 73 insertions(+), 109 deletions(-) delete mode 100644 src/domain/publicInput.test.ts delete mode 100644 src/domain/publicInput.ts diff --git a/docs/cli.md b/docs/cli.md index ce1cc38fb9..7d7f2823bb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -985,10 +985,10 @@ Cutter when available. This is separate from `--format json`, which selects the CLI result representation; the global `--json` flag selects that same CLI representation. -Cutter still accepts deprecated `--json=true` and `--json=false` compatibility -values. When both forms are present, `--json-output` takes precedence in either -argument order. Bare `--json` retains its global meaning of selecting JSON CLI -output formatting. +Cutter no longer accepts command-local `--json=true` or `--json=false`. +Replace these with `--json-output=true` or `--json-output=false`, respectively. +Combine `--json-output` with bare `--json` when both Cutter command output and +the CLI result should use JSON; either argument order is accepted. | Status | Meaning | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/package.json b/package.json index d5e3f0e1e5..c5d35afb1e 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,6 @@ "check:ci:fast:tasks": "node scripts/run-turbo.mjs run typecheck lint:built format:check", "check:ci:deep": "node scripts/run-exclusive.mjs artifacts npm run check:ci:deep:tasks && npm run verify:architecture-guards && npm run verify:test-discovery && npm run test:python && npm run knip:production && npm run lint:typed", "check:ci:deep:tasks": "node scripts/run-turbo.mjs run knip metadata:check jscpd scan:todos", - "check:ci:tasks": "node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check jscpd scan:todos", "ci:static-context": "node scripts/ci/static-task-context.mjs", "build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:skills", "build:termux": "node scripts/run-exclusive.mjs artifacts npm run build:termux:run", diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index b9a1f80624..fec5529dbb 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -13,12 +13,12 @@ import { LinuxHopperTemporaryCleanupOwner, systemLinuxHopperInstallHost, type LinuxDistribution, - type LinuxHopperDownload, type LinuxHopperInstallHost, type LinuxHopperLauncherStatus, type LinuxPackageInstallFailure, type LinuxPackageInstallOutcome, } from "./LinuxHopper.js"; +import type { HopperDownload } from "./HopperDownload.js"; import { err, ok } from "../domain/result.js"; import { type LinuxPackageFamily, @@ -74,7 +74,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { Promise.resolve(this.distributionValue); release = (family: LinuxPackageFamily) => Promise.resolve(ok(releasesFor(new Uint8Array([1, 2, 3]))[family])); - download = (url: string): Promise => { + download = (url: string): Promise => { this.downloads.push(url); return Promise.resolve({ ok: this.archiveOk, bytes: this.archive }); }; diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index dc7e953b43..7df46cff03 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -119,9 +119,6 @@ export const supportsReaHost = ( distribution: LinuxDistribution | undefined, ): boolean => distribution?.packageFamily !== undefined; -/** Download response kept independent of Node's global fetch implementation. */ -export type LinuxHopperDownload = HopperDownload; - /** One failed package-manager command, with the output that explains it. */ export interface LinuxPackageInstallFailure { /** The executed executable and arguments, joined for display. */ @@ -159,7 +156,7 @@ export interface LinuxHopperInstallHost { download( url: string, options: { readonly signal?: AbortSignal }, - ): Promise; + ): Promise; createTemporaryDirectory(): Promise; writeArchive(path: string, bytes: Uint8Array): Promise; installPackage( diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts index 65a37b89cf..097e7663f2 100644 --- a/src/application/environment/environmentBuild.ts +++ b/src/application/environment/environmentBuild.ts @@ -107,9 +107,6 @@ export interface EnvironmentBuildInput { readonly exclusiveDestination?: boolean | undefined; } -/** The report environment-build prints and export_environment_bundle returns. */ -export type { EnvironmentBuildReport }; - const captureOperations = new Set([ "inspect_web_network_capture", "capture_browser_scenario", diff --git a/src/cli/cutterCommands.ts b/src/cli/cutterCommands.ts index 68b818122e..b80461b145 100644 --- a/src/cli/cutterCommands.ts +++ b/src/cli/cutterCommands.ts @@ -60,12 +60,6 @@ export const registerCutterCommands = ( .describe( "Return command-specific JSON output when available (independent of CLI output formatting).", ), - json: z - .boolean() - .optional() - .describe( - "Deprecated compatibility input: use --json-output; pass --json=true or --json=false (bare --json formats CLI output).", - ), }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -78,7 +72,7 @@ export const registerCutterCommands = ( session_id: args.sessionId, expected_generation: args.expectedGeneration, command: args.command, - json: options.jsonOutput ?? options.json ?? false, + json: options.jsonOutput ?? false, }, { signal }, ), diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 3dbb820bad..eba10d04fa 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -12,7 +12,6 @@ import { peLoadConfigSchema, } from "../../domain/native/peLoadConfig.js"; import { inspectByteEntropyInputSchema } from "../../domain/native/byteEntropy.js"; -import { publicInputStrictObject } from "../../domain/publicInput.js"; import { byteEntropyOutputSchema } from "../toolOutputSchemaGroups.js"; import { inspectRecordedCrashInputSchema, @@ -65,13 +64,9 @@ export const inspectBinaryLayoutRequestSchema = export const inspectNativeExportsRequestSchema = inspectNativeExportsInputSchema.extend({ detail: analysisDetailSchema }); -/** - * Public `inspect_byte_entropy` input. The shared constructor runs before - * `detail` is added so the advertised input stays closed. - */ -export const inspectByteEntropyRequestSchema = publicInputStrictObject( - inspectByteEntropyInputSchema.shape, -).extend({ detail: analysisDetailSchema }); +/** Public byte-entropy input retains the producer schema's closed-object policy. */ +export const inspectByteEntropyRequestSchema = + inspectByteEntropyInputSchema.extend({ detail: analysisDetailSchema }); /** Offline binary diagnostics are independent of macOS process/UI providers. */ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index e20f3d3b90..a8e8420231 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,8 @@ +import { + javaScriptRouteInventorySourceSchema, + javaScriptRouteInventorySchema, +} from "../javascript/javascriptRouteInventory.js"; +import { nativeFunctionMatchingContextSchema } from "../native/functionMatching.js"; import { nativeObservationViewRequestSchema, nativeContextViewRequestSchema, @@ -58,7 +63,6 @@ import { javaScriptRoutesViewRequestSchema, javaScriptRoutesViewItemsSchema, javaScriptRoutesViewSummarySchema, - javaScriptRoutesSourceReferenceSchema, projectJavaScriptRoutesView, } from "./javascriptRoutesView.js"; import { @@ -69,7 +73,6 @@ import { } from "./webBundleView.js"; import { webBundleAnalysisSchema } from "../webBundleAnalysis.js"; import { recordedBrowserOriginSchema } from "../browserObservation.js"; -import { javaScriptRouteInventorySchema } from "../javascript/javascriptRouteInventory.js"; import { sourceToBundleViewRequestSchema, sourceToBundleViewItemsSchema, @@ -122,7 +125,6 @@ import { projectNativeFunctionMatchingView, nativeFunctionMatchingViewRequestSchema, nativeFunctionMatchingViewItemsSchema, - nativeFunctionMatchingViewContextSchema, } from "./nativeFunctionMatchingView.js"; import { protobufViewRequestSchema, @@ -885,7 +887,7 @@ export const javaScriptRoutesViewResultSchema = z.strictObject({ inventory_capacity: javaScriptRouteInventorySchema.shape.capacity, capture_url: z.string().nullable(), items: javaScriptRoutesViewItemsSchema, - sources: z.array(javaScriptRoutesSourceReferenceSchema), + sources: z.array(javaScriptRouteInventorySourceSchema), summary: javaScriptRoutesViewSummarySchema.nullable(), byte_limited: z.boolean(), }); @@ -990,7 +992,7 @@ export const nativeFunctionMatchingSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, artifact: z.null(), parent_operation: z.literal("match_native_functions"), - summary: nativeFunctionMatchingViewContextSchema, + summary: nativeFunctionMatchingContextSchema, }); /** Rows observed for one facet, or null when the provider declared that facet unavailable. */ @@ -1141,7 +1143,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ sourceToBundlePageViewResultSchema, z.strictObject({ ...viewResultBase, - ...nativeFunctionMatchingViewContextSchema.shape, + ...nativeFunctionMatchingContextSchema.shape, kind: z.literal("function-matches"), artifact: z.null(), view: nativeFunctionMatchingViewRequestSchema, diff --git a/src/domain/analysisView/javascriptRoutesView.ts b/src/domain/analysisView/javascriptRoutesView.ts index c2bab2d08b..4fa079c3dd 100644 --- a/src/domain/analysisView/javascriptRoutesView.ts +++ b/src/domain/analysisView/javascriptRoutesView.ts @@ -191,8 +191,6 @@ export const javaScriptRoutesViewSummarySchema = z.strictObject({ unknown: z.int().nonnegative(), }), }); -export const javaScriptRoutesSourceReferenceSchema = - javaScriptRouteInventorySourceSchema; type Request = z.output; type Gap = z.infer; diff --git a/src/domain/analysisView/nativeFunctionMatchingView.ts b/src/domain/analysisView/nativeFunctionMatchingView.ts index f4090dc4dc..e6870322aa 100644 --- a/src/domain/analysisView/nativeFunctionMatchingView.ts +++ b/src/domain/analysisView/nativeFunctionMatchingView.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { nativeFunctionMatchingSchema, - nativeFunctionMatchingContextSchema, nativeFunctionMatchSchema, nativeFunctionMatchMemberSchema, nativeMatchStateSchema, @@ -80,8 +79,6 @@ export const nativeFunctionMatchingViewItemsSchema = z.union([ }), ), ]); -export const nativeFunctionMatchingViewContextSchema = - nativeFunctionMatchingContextSchema; interface FunctionRow { readonly member: NativeFunctionMatching["matches"][number]["left"][number]; readonly match_id: string; diff --git a/src/domain/erlang/beam.ts b/src/domain/erlang/beam.ts index f5344e53dd..87170b6eaf 100644 --- a/src/domain/erlang/beam.ts +++ b/src/domain/erlang/beam.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { absoluteLocalPathStringSchema } from "../localPath.js"; -import { publicInputStrictObject } from "../publicInput.js"; const uint = z.number().int().min(0).max(0xffffffff); const range = z.strictObject({ offset: uint, bytes: uint }); @@ -17,7 +16,7 @@ export const MAX_INSPECT_ERLANG_BEAM_ATOMS = 65536; export const MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS = 65536; /** Complete bounded inspection of one Erlang BEAM IFF container. */ -export const inspectErlangBeamInputSchema = publicInputStrictObject({ +export const inspectErlangBeamInputSchema = z.strictObject({ path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to a BEAM file", ), diff --git a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts index 203e1da82c..6ee5ac8bab 100644 --- a/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts +++ b/src/domain/javascript/javascriptApplicationEvidenceSchemas.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { javascriptSourceRangeSchema } from "./javascriptSourceCoordinates.js"; -import { digestSchema } from "../digests.js"; -import { prefixedDigestSchema } from "../digests.js"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; const boundedTextSchema = z.string().min(1); const boundedKeySchema = z.string().min(1); @@ -139,14 +138,17 @@ const applicationLocationSchema = z.discriminatedUnion("kind", [ }), ]); -/** Explicit source-location availability for one graph fact. */ -const applicationLocationReferenceSchema = z.discriminatedUnion("available", [ - z.strictObject({ - available: z.literal(true), - value: applicationLocationSchema, - }), - unavailableFactSchema, -]); +/** Source-location availability and coordinates retained on each graph fact. */ +export const applicationGraphEvidenceLocationSchema = z.discriminatedUnion( + "available", + [ + z.strictObject({ + available: z.literal(true), + value: applicationLocationSchema, + }), + unavailableFactSchema, + ], +); /** Versioned extractor coordinates attached to every graph fact. */ const applicationExtractorSchema = z.strictObject({ @@ -242,7 +244,7 @@ const applicationGraphEvidenceBaseSchema = z.strictObject({ state: applicationFactStateSchema, confidence: applicationConfidenceSchema, artifact: applicationArtifactReferenceSchema, - location: applicationLocationReferenceSchema, + location: applicationGraphEvidenceLocationSchema, extractor: applicationExtractorSchema, coverage: applicationCoverageSchema, limitations: z.array(boundedTextSchema), @@ -253,10 +255,6 @@ const applicationGraphEvidenceBaseSchema = z.strictObject({ export const applicationGraphEvidenceContextSchema = applicationGraphEvidenceBaseSchema.omit({ location: true }); -/** Exact coordinates retained on each graph fact. */ -export const applicationGraphEvidenceLocationSchema = - applicationLocationReferenceSchema; - type EvidenceValue = z.infer; const checkAuthorityState = ( diff --git a/src/domain/native/byteEntropy.ts b/src/domain/native/byteEntropy.ts index 052d3fdd6e..c4f04082ec 100644 --- a/src/domain/native/byteEntropy.ts +++ b/src/domain/native/byteEntropy.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; -import { publicInputStrictObject } from "../publicInput.js"; const offsetSchema = z .number() @@ -10,7 +9,7 @@ const offsetSchema = z const entropySchema = z.number().min(0).max(8); /** Select blocks relative to an explicit byte window; identity remains file-wide. */ -export const inspectByteEntropyInputSchema = publicInputStrictObject({ +export const inspectByteEntropyInputSchema = z.strictObject({ path: localPathStringSchema, block_size: z .number() diff --git a/src/domain/publicInput.test.ts b/src/domain/publicInput.test.ts deleted file mode 100644 index 04b46983bd..0000000000 --- a/src/domain/publicInput.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, it } from "vitest"; -import { z } from "zod"; - -import { publicInputStrictObject } from "./publicInput.js"; - -it("rejects unknown keys on a public tool input", () => { - const schema = publicInputStrictObject({ - path: z.string(), - block_size: z.number().default(4096), - }); - expect(schema.parse({ path: "/file" })).toEqual({ - path: "/file", - block_size: 4096, - }); - expect(schema.safeParse({ path: "/file", extra: true }).success).toBe(false); -}); diff --git a/src/domain/publicInput.ts b/src/domain/publicInput.ts deleted file mode 100644 index 09093bc6ae..0000000000 --- a/src/domain/publicInput.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from "zod"; - -/** - * Closed object schema for a public CLI or MCP tool input. - * - * Unknown keys fail here so public input policy has one owner. Result and - * internal objects stay on their own constructors. - */ -export const publicInputStrictObject = z.strictObject; diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index 4b3125e769..aaaca68450 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -25,8 +25,6 @@ const OID_NAMES: Readonly> = { "2.16.840.1.101.3.4.2.3": "sha512", }; -export type Asn1Node = Asn1BerNode; - export class PeCertificateAsn1Failure extends Error { constructor(message: string) { super(message); @@ -46,7 +44,7 @@ const required = (value: T | undefined, message: string): T => export const ASN1_NODE_BUDGET = 16_384; /** Decode one bounded ASN.1 TLV, including indefinite constructed BER values. */ -export const readAsn1 = (bytes: Buffer): Asn1Node => { +export const readAsn1 = (bytes: Buffer): Asn1BerNode => { try { return new Asn1BerReader(bytes, ASN1_NODE_BUDGET).read(); } catch (cause: unknown) { @@ -55,7 +53,7 @@ export const readAsn1 = (bytes: Buffer): Asn1Node => { } }; -const requireDefiniteEncoding = (node: Asn1Node, label: string): void => { +const requireDefiniteEncoding = (node: Asn1BerNode, label: string): void => { if (node.lengthForm === "indefinite") fail(`${label} must use definite DER length encoding.`); for (const child of node.children) requireDefiniteEncoding(child, label); @@ -165,7 +163,7 @@ const decodeUtf8 = (bytes: Buffer, budget: DecodedNameBudget): string => { }; const decodeDirectoryString = ( - node: Asn1Node, + node: Asn1BerNode, budget: DecodedNameBudget, ): string => { switch (node.tag) { @@ -211,18 +209,21 @@ const requireBitStringContent = (content: Buffer, label: string): void => { return fail(`${label} BIT STRING is truncated or has invalid unused bits.`); }; -const requireBitString = (node: Asn1Node | undefined, label: string): void => { +const requireBitString = ( + node: Asn1BerNode | undefined, + label: string, +): void => { if (node === undefined || node.tag !== 0x03) return fail(`${label} is not a BIT STRING.`); requireBitStringContent(node.content, label); }; -const sequence = (node: Asn1Node, label: string): readonly Asn1Node[] => { +const sequence = (node: Asn1BerNode, label: string): readonly Asn1BerNode[] => { if (node.tag !== 0x30) return fail(`Expected SEQUENCE for ${label}.`); return node.children; }; -const algorithmOid = (node: Asn1Node): string => { +const algorithmOid = (node: Asn1BerNode): string => { const children = sequence(node, "AlgorithmIdentifier"); const oid = children[0]; if (oid === undefined || oid.tag !== 0x06) @@ -237,7 +238,7 @@ interface Attribute { } const decodeName = ( - node: Asn1Node, + node: Asn1BerNode, budget: DecodedNameBudget, ): { readonly text: string; @@ -273,7 +274,7 @@ const decodeName = ( return { text, attributes }; }; -const validityTime = (node: Asn1Node, budget: DecodedNameBudget): string => { +const validityTime = (node: Asn1BerNode, budget: DecodedNameBudget): string => { if (node.tag !== 0x17 && node.tag !== 0x18) return fail("Validity instant is not UTCTime or GeneralizedTime."); // A zero byte becomes "\u0000" (six characters) in the hashed evidence JSON. @@ -292,7 +293,7 @@ export interface ParsedCertificate { readonly signature_algorithm_oid: string; } -const requireSubjectPublicKeyInfo = (node: Asn1Node): void => { +const requireSubjectPublicKeyInfo = (node: Asn1BerNode): void => { const children = sequence(node, "SubjectPublicKeyInfo"); const algorithm = children[0]; const key = children[1]; @@ -307,7 +308,7 @@ const requireSubjectPublicKeyInfo = (node: Asn1Node): void => { // issuerUniqueID [1], subjectUniqueID [2], and extensions [3] are optional // and ordered. Anything else is not a TBSCertificate field. const finishTbsCertificate = ( - children: readonly Asn1Node[], + children: readonly Asn1BerNode[], start: number, ): void => { let cursor = start; @@ -344,7 +345,7 @@ const finishTbsCertificate = ( /** Decode one X.509 Certificate SEQUENCE at a known file-relative DER node. */ export const parseCertificate = ( - node: Asn1Node, + node: Asn1BerNode, names: DecodedNameBudget = { remaining: MAX_DECODED_NAME_BYTES, teletexStringCompatibilityUsed: false, @@ -432,7 +433,7 @@ export interface ParsedSigner { } const signerIdentifier = ( - sid: Asn1Node, + sid: Asn1BerNode, names: DecodedNameBudget, ): { readonly issuer: ReturnType | null; @@ -471,7 +472,7 @@ const signerIdentifier = ( }; const parseSigner = ( - node: Asn1Node, + node: Asn1BerNode, names: DecodedNameBudget, ): ParsedSigner => { const children = sequence(node, "SignerInfo"); @@ -523,7 +524,7 @@ export interface ParsedSignedData { } const parseSignedData = ( - node: Asn1Node, + node: Asn1BerNode, names: DecodedNameBudget, ): ParsedSignedData => { const children = sequence(node, "SignedData"); @@ -569,7 +570,7 @@ const parseSignedData = ( }; /** Decode ContentInfo wrapping SignedData; other content types remain unsupported. */ -export const parsePkcs7SignedData = (root: Asn1Node): ParsedSignedData => { +export const parsePkcs7SignedData = (root: Asn1BerNode): ParsedSignedData => { const children = sequence(root, "ContentInfo"); const type = children[0]; const content = children[1]; diff --git a/tests/boundary/cli/cutterCommands.test.ts b/tests/boundary/cli/cutterCommands.test.ts index 1dcd2d5f49..c1db5b48ac 100644 --- a/tests/boundary/cli/cutterCommands.test.ts +++ b/tests/boundary/cli/cutterCommands.test.ts @@ -315,21 +315,34 @@ describe("Cutter CLI command output flags", () => { expect(() => JSON.parse(formatted.stdout)).not.toThrow(); for (const [flags, expected] of [ - [["--json=true"], true], - [["--json=false"], false], - [["--json=true", "--json-output=false"], false], - [["--json=false", "--json-output=true"], true], - [["--json-output=false", "--json=true"], false], - [["--json-output=true", "--json=false"], true], + [["--json-output=true"], true], + [["--json-output=false"], false], + [["--json", "--json-output=false"], false], + [["--json", "--json-output=true"], true], + [["--json-output=false", "--json"], false], + [["--json-output=true", "--json"], true], ] as const) { - const compatible = await serveCli( + const selected = await serveCli( ["cutter-command", SESSION_ID, "0", "pdi", ...flags], { REA_CUTTER_BRIDGE_DIR: directory }, ); - expect(compatible.exitCode).toBe(0); + expect(selected.exitCode).toBe(0); expect(commandJsonModes.at(-1)).toBe(expected); + if (flags.some((flag) => flag === "--json")) + expect(() => JSON.parse(selected.stdout)).not.toThrow(); } + const commandsBeforeRejectedFlags = commandJsonModes.length; + for (const flag of ["--json=true", "--json=false"]) { + const rejected = await serveCli( + ["cutter-command", SESSION_ID, "0", "pdi", flag], + { REA_CUTTER_BRIDGE_DIR: directory }, + ); + expect(rejected.exitCode).toBe(1); + expect(rejected.stdout).toContain("Unknown flag: --json"); + } + expect(commandJsonModes).toHaveLength(commandsBeforeRejectedFlags); + await assertRealVerifierJsonInspection(directory, commandJsonModes); }); }); From 50717c39b6b7c78aee8378aa5704b376395f35c2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:36:24 +0800 Subject: [PATCH 07/29] refactor(contracts): consolidate provider and search ownership Resolve IDA compatibility facts in one place and migrate capability, session, and verifier consumers. Move native search fields to the domain and reuse residual unknown fields for filters. --- scripts/verify-real-ida.mjs | 4 +- src/contracts/officialToolContracts.ts | 6 +- src/contracts/sessionToolSchemas.ts | 9 +- src/domain/analysisSearch.ts | 4 +- .../native/nativeSearch.ts} | 9 +- src/ghidra/GhidraInventoryValues.test.ts | 41 +++++++++ src/ghidra/GhidraInventoryValues.ts | 5 +- src/hopper/HopperRegexSearch.ts | 4 +- src/ida/IdaConfiguration.ts | 37 +++++++- src/ida/IdaContainment.test.ts | 14 ++- src/ida/IdaNativeProtocol.test.ts | 16 ++-- src/ida/IdaProvider.ts | 32 ++++--- src/ida/IdaProviderCapabilities.ts | 87 ++++++++++--------- src/ida/IdaSessionClient.ts | 31 +++---- .../ida/idaCapabilityInventory.test.ts | 6 +- .../conformance/ida/providerContracts.test.ts | 8 +- 16 files changed, 205 insertions(+), 108 deletions(-) rename src/{contracts/analysisSearchContract.ts => domain/native/nativeSearch.ts} (63%) diff --git a/scripts/verify-real-ida.mjs b/scripts/verify-real-ida.mjs index 964f4abecf..1aff82bad6 100644 --- a/scripts/verify-real-ida.mjs +++ b/scripts/verify-real-ida.mjs @@ -54,7 +54,7 @@ const [ { toolContract }, { evidenceSchema }, { functionDossierSchema }, - { readIdaConfiguration, idaProtocol }, + { readIdaConfiguration, idaCompatibilityProfile }, { createIdaMcpConnection }, { idaAddressSchema }, ] = await Promise.all([ @@ -91,7 +91,7 @@ const environment = Object.fromEntries( const report = { passed: false, mode: config.mode, - protocol: idaProtocol(config), + protocol: idaCompatibilityProfile(config).protocol, checks: [], observations: [], unverified: [], diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 1faa17bfd4..27b87841a9 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -18,7 +18,7 @@ import { nativeDataTypeInputSchema } from "../domain/native/nativeDataType.js"; import { nativeInstructionInputSchema } from "../domain/native/nativeInstruction.js"; import { functionInstructionInputSchema } from "./functionInstructionContract.js"; import { HOPPER_MEMORY_TOOL_DEFINITIONS } from "./hopperMemoryContracts.js"; -import { analysisSearchInput } from "./analysisSearchContract.js"; +import { nativeSearchInput } from "../domain/native/nativeSearch.js"; const hopperRegexDeadline = "Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker. The matching deadline is REA_REGEX_MATCHING_DEADLINE_MS (a positive decimal integer of milliseconds from 1 to 2147483647, default 5000). Unset keeps the default. A deadline failure names the effective milliseconds, source (default or setting), the default, and the maximum, and leaves the API usable. The worker old-generation request is REA_REGEX_WORKER_HEAP_REQUEST_MIB (a decimal integer of mebibytes from 7 to 16384, default 128). Unset keeps the default. A positive Node --max-old-space-size, including NODE_OPTIONS, overrides that request and is not limited by the setting's floor or maximum. A heap failure names the effective request, its source (default or setting), the default, the floor, and the maximum."; @@ -206,12 +206,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [ official( "search_procedures", `Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, - z.object({ ...analysisSearchInput, detail: analysisDetailSchema }), + z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), ), official( "search_strings", `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, - z.object({ ...analysisSearchInput, detail: analysisDetailSchema }), + z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), ), official( "set_address_name", diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index b92cb9086b..0a916eda0e 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -7,6 +7,7 @@ import { processTraceSpecificationSchema } from "../domain/process/processTraceS import { evidenceSchema } from "../domain/evidence.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { evidenceRecordFiltersSchema } from "../domain/evidenceRecordSummary.js"; +import { residualUnknownSchema } from "../domain/residualUnknown.js"; /** Complete retained Evidence, or selected discovery metadata without payloads. */ export const getEvidenceBundleInputSchema = z.strictObject({ @@ -53,11 +54,9 @@ export const processComparisonInputSchema = z.strictObject({ /** Residual-unknown list filters. */ export const listUnknownsInputSchema = z.strictObject({ - status: z - .enum(["open", "investigating", "blocked", "contradicted", "resolved"]) - .optional(), - severity: z.enum(["low", "medium", "high", "critical"]).optional(), - domain: z.string().trim().min(1).optional(), + status: residualUnknownSchema.shape.status.optional(), + severity: residualUnknownSchema.shape.severity.optional(), + domain: residualUnknownSchema.shape.domain.optional(), offset: z .int() .min(0) diff --git a/src/domain/analysisSearch.ts b/src/domain/analysisSearch.ts index 3a51350858..3937feaf3d 100644 --- a/src/domain/analysisSearch.ts +++ b/src/domain/analysisSearch.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { analysisSearchInput } from "../contracts/analysisSearchContract.js"; +import { nativeSearchInput } from "./native/nativeSearch.js"; import { AnalysisInputError } from "./analysisErrorCore.js"; /** Parsed `search_procedures`/`search_strings` query shared by providers that match in REA. */ -export const analysisSearchQuerySchema = z.object(analysisSearchInput); +export const analysisSearchQuerySchema = z.object(nativeSearchInput); export type AnalysisSearchQuery = z.infer; diff --git a/src/contracts/analysisSearchContract.ts b/src/domain/native/nativeSearch.ts similarity index 63% rename from src/contracts/analysisSearchContract.ts rename to src/domain/native/nativeSearch.ts index 4114d80390..6784723c90 100644 --- a/src/contracts/analysisSearchContract.ts +++ b/src/domain/native/nativeSearch.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -/** Shared provider search input fields. */ -export const analysisSearchInput = { +/** Provider-neutral native text search semantics, independent of document selection. */ +export const nativeSearchFields = { pattern: z .string() .min(1) @@ -13,5 +13,10 @@ export const analysisSearchInput = { .describe( "Whether to match case; case-insensitive matching uses Unicode simple case folding, one character at a time", ), +}; + +/** Public native search inputs retain an omitted document selection. */ +export const nativeSearchInput = { + ...nativeSearchFields, document: z.string().optional().describe("The document name"), }; diff --git a/src/ghidra/GhidraInventoryValues.test.ts b/src/ghidra/GhidraInventoryValues.test.ts index 0831b29dd4..b565b1cd03 100644 --- a/src/ghidra/GhidraInventoryValues.test.ts +++ b/src/ghidra/GhidraInventoryValues.test.ts @@ -6,6 +6,47 @@ import { parseGhidraInventoryResult, } from "./GhidraInventoryValues.js"; +it.each(["search_procedures", "search_strings"] as const)( + "%s retains bridge document selection and native search defaults", + (operation) => { + for (const selection of [{}, { document: null }, { document: "fixture" }]) { + expect( + parseGhidraInventoryInput(operation, { + pattern: "entry", + ...selection, + }), + ).toEqual({ + ok: true, + value: { + pattern: "entry", + mode: "literal", + case_sensitive: false, + document: selection.document ?? null, + }, + }); + } + expect( + parseGhidraInventoryInput(operation, { pattern: "entry", document: "" }) + .ok, + ).toBe(false); + expect( + parseGhidraInventoryInput(operation, { + pattern: "Entry.*", + mode: "regex", + case_sensitive: true, + }), + ).toEqual({ + ok: true, + value: { + pattern: "Entry.*", + mode: "regex", + case_sensitive: true, + document: null, + }, + }); + }, +); + it.each(["other", 42, null, {}])( "rejects an invalid own destination with value %j before admitting any batch naming subset", (value) => { diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 1b73e7b964..7002f501e8 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -1,3 +1,4 @@ +import { nativeSearchFields } from "../domain/native/nativeSearch.js"; import { nativeSymbolMetadataSchema } from "../domain/native/nativeSymbol.js"; import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js"; import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; @@ -82,9 +83,7 @@ export const ghidraInputAddressSchema = z const explicitAddress = ghidraInputAddressSchema; const filteredAddress = explicitAddress.nullable().default(null); const searchInput = { - pattern: z.string().min(1), - mode: z.enum(["literal", "regex"]).default("literal"), - case_sensitive: z.boolean().default(false), + ...nativeSearchFields, document, }; diff --git a/src/hopper/HopperRegexSearch.ts b/src/hopper/HopperRegexSearch.ts index 0238d59bfe..a0d34410fb 100644 --- a/src/hopper/HopperRegexSearch.ts +++ b/src/hopper/HopperRegexSearch.ts @@ -10,7 +10,7 @@ import { analysisStringSchema } from "../domain/hopperValues.js"; import { HopperRemoteError } from "../domain/hopperErrors.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; -import { analysisSearchInput } from "../contracts/analysisSearchContract.js"; +import { nativeSearchInput } from "../domain/native/nativeSearch.js"; import type { HopperClient } from "./HopperClient.js"; import { DEFAULT_REGEX_MATCHING_DEADLINE, @@ -24,7 +24,7 @@ import { matchRegexValues } from "../process/RegexMatcher.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; const inputSchema = z.strictObject({ - ...analysisSearchInput, + ...nativeSearchInput, mode: z.literal("regex"), }); /** Match collected native facts off Hopper's API thread, with cancellable lifetime. */ diff --git a/src/ida/IdaConfiguration.ts b/src/ida/IdaConfiguration.ts index b8127fb610..8c28fb1aa7 100644 --- a/src/ida/IdaConfiguration.ts +++ b/src/ida/IdaConfiguration.ts @@ -77,9 +77,40 @@ const registrationSchema = z /** An upstream MCP registration plus REA's database lifecycle selection. */ export type IdaConfiguration = z.infer; -/** Select a protocol without changing existing attached/headless registrations. */ -export const idaProtocol = (config: IdaConfiguration) => - config.protocol ?? (config.mode === "attached" ? "legacy" : "modern"); +/** Resolved compatibility facts; persisted identities and diagnostics intentionally differ. */ +export const IDA_COMPATIBILITY_PROFILES = { + legacy: { + protocol: "legacy", + mode: "attached", + identity: "legacy-1.4", + diagnosticIdentity: "legacy-1.4", + diagnosticLabel: "attached", + }, + modern: { + protocol: "modern", + mode: "headless", + identity: "database-supervisor", + diagnosticIdentity: "database-supervisor", + diagnosticLabel: "headless", + }, + native: { + protocol: "native", + mode: "attached", + identity: "native-ida-mcp", + diagnosticIdentity: "native", + diagnosticLabel: "native attached", + }, +} as const; +export type IdaCompatibilityProfile = + (typeof IDA_COMPATIBILITY_PROFILES)[keyof typeof IDA_COMPATIBILITY_PROFILES]; + +/** Preserve existing attached/headless registrations when protocol is omitted. */ +export const idaCompatibilityProfile = ( + config: IdaConfiguration, +): IdaCompatibilityProfile => + IDA_COMPATIBILITY_PROFILES[ + config.protocol ?? (config.mode === "attached" ? "legacy" : "modern") + ]; /** Read one explicit registration without starting or installing a provider. */ export const readIdaConfiguration = ( diff --git a/src/ida/IdaContainment.test.ts b/src/ida/IdaContainment.test.ts index 53b0887cd9..045df2e778 100644 --- a/src/ida/IdaContainment.test.ts +++ b/src/ida/IdaContainment.test.ts @@ -1,3 +1,4 @@ +import { IDA_COMPATIBILITY_PROFILES } from "./IdaConfiguration.js"; import { rm } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -150,7 +151,12 @@ const found = (query: string, fn: typeof function_) => ({ describe("IDA containment capabilities", () => { it("declares document identity in both profiles and address resolution only in headless", () => { const declared = (mode: "attached" | "headless", native = false) => - idaCapabilities(mode, false, native) + idaCapabilities( + IDA_COMPATIBILITY_PROFILES[ + native ? "native" : mode === "attached" ? "legacy" : "modern" + ], + false, + ) .map(({ operation }) => operation) .filter( (operation) => @@ -159,7 +165,7 @@ describe("IDA containment capabilities", () => { ); expect( - idaCapabilities("headless", false) + idaCapabilities(IDA_COMPATIBILITY_PROFILES.modern, false) .filter( ({ operation }) => operation === "resolve_containing_procedure" || @@ -175,7 +181,9 @@ describe("IDA containment capabilities", () => { expect(declared("attached")).toEqual(["list_documents"]); expect(declared("attached", true)).toEqual(["list_documents"]); expect( - idaCapabilities("attached", false).map(({ operation }) => operation), + idaCapabilities(IDA_COMPATIBILITY_PROFILES.legacy, false).map( + ({ operation }) => operation, + ), "attached profiles declare the bound document but not address resolution", ).toContain("list_documents"); }); diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 83fa022e69..ec3e70f103 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -1,3 +1,7 @@ +import { + IDA_COMPATIBILITY_PROFILES, + readIdaConfiguration, +} from "./IdaConfiguration.js"; import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -10,7 +14,6 @@ import { AnalysisInputError, AnalysisProtocolError, } from "../domain/analysisErrorCore.js"; -import { readIdaConfiguration } from "./IdaConfiguration.js"; import { decodeIdaToolResult, type IdaMcpConnection, @@ -479,18 +482,17 @@ describe("native IDA provider observations", () => { expect(producer.calls.some(({ name }) => name.startsWith("idb_"))).toBe( false, ); - const capabilities = idaCapabilities("attached", false, true); + const declared = idaCapabilities(IDA_COMPATIBILITY_PROFILES.native, false); expect( - capabilities.find(({ operation }) => operation === "procedure_callers") + declared.find(({ operation }) => operation === "procedure_callers") ?.available, ).toBe(false); expect( - capabilities.find( - ({ operation }) => operation === "procedure_pseudo_code", - )?.effects, + declared.find(({ operation }) => operation === "procedure_pseudo_code") + ?.effects, ).toMatchObject({ mayShowUi: true, mayAccessNetwork: true }); expect( - idaCapabilities("attached", false).find( + idaCapabilities(IDA_COMPATIBILITY_PROFILES.legacy, false).find( ({ operation }) => operation === "procedure_callers", )?.available, ).toBe(true); diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index d4cec0b8ce..a7a6c4c9e2 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -22,6 +22,7 @@ import type { AnalysisProviderCandidate, ProviderAvailability, ProviderTargetSupport, + RetainedResourceScope, } from "../application/AnalysisProvider.js"; import type { AppConfig } from "../config/types.js"; import type { ConfigurationError } from "../domain/configurationErrors.js"; @@ -30,6 +31,8 @@ import { isDeepStrictEqual } from "node:util"; import type { Result } from "../domain/result.js"; import { readIdaConfiguration, + idaCompatibilityProfile, + IDA_COMPATIBILITY_PROFILES, type IdaConfiguration, idaConfigurationError, } from "./IdaConfiguration.js"; @@ -50,7 +53,6 @@ import { import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js"; /** Bring-your-own IDA MCP candidate; discovery performs no provider startup. */ @@ -86,9 +88,21 @@ export class IdaProvider implements AnalysisProviderCandidate { } capabilities(): readonly CapabilityDescriptor[] { const profiles = [ - ...idaCapabilities("attached", false, false, this.#regexLimitation()), - ...idaCapabilities("attached", true, true, this.#regexLimitation()), - ...idaCapabilities("headless", true, false, this.#regexLimitation()), + ...idaCapabilities( + IDA_COMPATIBILITY_PROFILES.legacy, + false, + this.#regexLimitation(), + ), + ...idaCapabilities( + IDA_COMPATIBILITY_PROFILES.native, + true, + this.#regexLimitation(), + ), + ...idaCapabilities( + IDA_COMPATIBILITY_PROFILES.modern, + true, + this.#regexLimitation(), + ), ]; const byOperation = new Map(); for (const capability of profiles) { @@ -171,9 +185,8 @@ export class IdaProvider implements AnalysisProviderCandidate { const config = await this.#configuration(options); const capabilities = config.ok ? idaCapabilities( - config.value.mode, + idaCompatibilityProfile(config.value), "command" in config.value, - config.value.protocol === "native", this.#regexLimitation(), ) : this.capabilities(); @@ -350,12 +363,7 @@ export class IdaProvider implements AnalysisProviderCandidate { engine_version: null, upstream_distribution_version: null, mode: config.value.mode, - compatibility_profile: - config.value.protocol === "native" - ? "native-ida-mcp" - : config.value.mode === "attached" - ? "legacy-1.4" - : "database-supervisor", + compatibility_profile: idaCompatibilityProfile(config.value).identity, database_revision: null, cache_policy: "live", registration_digest: idaRegistrationDigest(config.value), diff --git a/src/ida/IdaProviderCapabilities.ts b/src/ida/IdaProviderCapabilities.ts index d3a41dc89b..89ee9bffe5 100644 --- a/src/ida/IdaProviderCapabilities.ts +++ b/src/ida/IdaProviderCapabilities.ts @@ -1,3 +1,4 @@ +import type { IdaCompatibilityProfile } from "./IdaConfiguration.js"; import type { AnalysisOperation, CapabilityDescriptor, @@ -38,8 +39,9 @@ const HEADLESS_ONLY_OPERATIONS: ReadonlySet = new Set([ /** Whether the configured profile admits an operation. */ export const idaProfileAdmits = ( operation: IdaOperation, - mode: "attached" | "headless", -): boolean => mode === "headless" || !HEADLESS_ONLY_OPERATIONS.has(operation); + profile: IdaCompatibilityProfile, +): boolean => + profile.mode === "headless" || !HEADLESS_ONLY_OPERATIONS.has(operation); /** Shared limitations of observations from an externally mutable analysis database. */ export const IDA_LIMITATIONS = Object.freeze([ @@ -55,7 +57,7 @@ export const IDA_NATIVE_STRING_LIMITATION = "Native string observations use upstream C-string rendering; other string representations remain unknown."; /** - * Attached mode requires the legacy 1.4 profile, whose `disassemble_function` + * The legacy attached profile uses `disassemble_function`, which * jumps to the function and whose `decompile_function` opens or reuses a * pseudocode view whenever an IDA window is active. */ @@ -68,47 +70,48 @@ const ATTACHED_GUI_NAVIGATION_OPERATIONS: ReadonlySet = new Set([ /** Advertise live observations and configured lifecycle effects truthfully. */ export const idaCapabilities = ( - mode: "attached" | "headless", + profile: IdaCompatibilityProfile, stdio: boolean, - native = false, regexLimitation = REGEX_MATCHING_LIMITATION, ): readonly CapabilityDescriptor[] => - IDA_OPERATIONS.filter((operation) => idaProfileAdmits(operation, mode)).map( - (operation) => ({ - provider: IDA_PROVIDER_IDENTITY, - operation, - ...((mode === "headless" || native) && operation === "procedure_callers" - ? ({ - available: false, - reason: native + IDA_OPERATIONS.filter((operation) => + idaProfileAdmits(operation, profile), + ).map((operation) => ({ + provider: IDA_PROVIDER_IDENTITY, + operation, + ...((profile.mode === "headless" || profile.protocol === "native") && + operation === "procedure_callers" + ? ({ + available: false, + reason: + profile.protocol === "native" ? "The native upstream profile reports code references without proving direct call edges." : "The modern upstream profile reports code references without proving direct call edges.", - } as const) - : ({ available: true, reason: null } as const)), - cachePolicy: "live", - effects: { - mutatesArtifact: false, - launchesProcess: stdio || mode === "headless", - mayShowUi: - mode === "attached" && - (native - ? operation === "procedure_pseudo_code" || - operation === "analyze_function" - : ATTACHED_GUI_NAVIGATION_OPERATIONS.has(operation)), - mayAccessNetwork: true, - mayWriteFilesystem: stdio || mode === "headless", - changesPermissions: false, - requiresRoot: false, - }, - limitations: [ - ...IDA_LIMITATIONS, - ...(native && - (operation === "list_strings" || operation === "search_strings") - ? [IDA_NATIVE_STRING_LIMITATION] - : []), - ...(operation === "search_procedures" || operation === "search_strings" - ? [regexLimitation] - : []), - ], - }), - ); + } as const) + : ({ available: true, reason: null } as const)), + cachePolicy: "live", + effects: { + mutatesArtifact: false, + launchesProcess: stdio || profile.mode === "headless", + mayShowUi: + profile.mode === "attached" && + (profile.protocol === "native" + ? operation === "procedure_pseudo_code" || + operation === "analyze_function" + : ATTACHED_GUI_NAVIGATION_OPERATIONS.has(operation)), + mayAccessNetwork: true, + mayWriteFilesystem: stdio || profile.mode === "headless", + changesPermissions: false, + requiresRoot: false, + }, + limitations: [ + ...IDA_LIMITATIONS, + ...(profile.protocol === "native" && + (operation === "list_strings" || operation === "search_strings") + ? [IDA_NATIVE_STRING_LIMITATION] + : []), + ...(operation === "search_procedures" || operation === "search_strings" + ? [regexLimitation] + : []), + ], + })); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index e9745bd8f6..59b769ec43 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -24,7 +24,7 @@ import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { IdaConfiguration } from "./IdaConfiguration.js"; -import { idaProtocol } from "./IdaConfiguration.js"; +import { idaCompatibilityProfile } from "./IdaConfiguration.js"; import type { IdaMcpConnection } from "./IdaMcpConnection.js"; import { nativeTools } from "./IdaNativeProtocol.js"; import { readSettledHealth } from "./IdaBusyHealth.js"; @@ -305,7 +305,7 @@ export class IdaSessionClient implements AnalysisClient { ); if ( operation !== "health" && - !idaProfileAdmits(operation, this.config.mode) + !idaProfileAdmits(operation, idaCompatibilityProfile(this.config)) ) return err( new AnalysisCapabilityUnavailableError( @@ -316,7 +316,7 @@ export class IdaSessionClient implements AnalysisClient { ); if ( operation === "procedure_callers" && - idaProtocol(this.config) !== "legacy" + idaCompatibilityProfile(this.config).protocol !== "legacy" ) return err( new AnalysisCapabilityUnavailableError( @@ -418,7 +418,7 @@ export class IdaSessionClient implements AnalysisClient { #runner(options: ExecutionOptions | undefined): IdaOperationRunner { return new IdaOperationRunner( this.connection, - idaProtocol(this.config), + idaCompatibilityProfile(this.config).protocol, this.#database, { regexMatchingDeadline: this.regexSettings?.regexMatchingDeadline, @@ -687,15 +687,15 @@ export class IdaSessionClient implements AnalysisClient { const tools = new Set(await this.connection.connect()); this.#callGraphAdvertised = tools.has("callgraph"); this.#surveyAdvertised = tools.has("survey_binary"); - const expected = - this.config.protocol === "native" - ? nativeTools - : this.config.mode === "attached" - ? legacyTools - : modernTools; + const profile = idaCompatibilityProfile(this.config); + const expected = { + native: nativeTools, + legacy: legacyTools, + modern: modernTools, + }[profile.protocol]; const missing = expected.filter((name) => !tools.has(name)); if (missing.length > 0) { - const summary = `IDA MCP ${this.config.protocol === "native" ? "native attached" : this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`; + const summary = `IDA MCP ${profile.diagnosticLabel} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`; // The caller cannot act on a profile mismatch it never sees, so the // missing names travel as adapter diagnostics rather than a bare message. throw new ProviderAdapterError("ida", "initialize", { @@ -703,12 +703,7 @@ export class IdaSessionClient implements AnalysisClient { diagnostics: { reason: summary, mode: this.config.mode, - expected_profile: - this.config.protocol === "native" - ? "native" - : this.config.mode === "attached" - ? "legacy-1.4" - : "database-supervisor", + expected_profile: profile.diagnosticIdentity, missing_tools: [...missing], recovery: "Install an IDA MCP build that serves the selected profile; see docs/ida-provider.md.", @@ -796,7 +791,7 @@ export class IdaSessionClient implements AnalysisClient { let identity: string; if (this.config.mode === "attached") { const raw = - this.config.protocol === "native" + idaCompatibilityProfile(this.config).protocol === "native" ? await this.#nativeMetadata(options?.signal) : await this.connection.call( "get_metadata", diff --git a/tests/boundary/providers/ida/idaCapabilityInventory.test.ts b/tests/boundary/providers/ida/idaCapabilityInventory.test.ts index 0f5e38ef36..a74cd3c2a6 100644 --- a/tests/boundary/providers/ida/idaCapabilityInventory.test.ts +++ b/tests/boundary/providers/ida/idaCapabilityInventory.test.ts @@ -1,3 +1,4 @@ +import { IDA_COMPATIBILITY_PROFILES } from "../../../../src/ida/IdaConfiguration.js"; import { expect, it } from "vitest"; import { buildCapabilityInventory } from "../../../../src/application/CapabilityInventory.js"; import { providerCapabilitySets } from "../../../../src/application/binary/BinarySessionStatus.js"; @@ -18,7 +19,10 @@ it.each([false, true])( open: true, kind: "executable", capabilities: providerCapabilitySets( - idaCapabilities(showUi ? "attached" : "headless", false), + idaCapabilities( + IDA_COMPATIBILITY_PROFILES[showUi ? "legacy" : "modern"], + false, + ), ), }; const entries = buildCapabilityInventory(input, enabledPolicy); diff --git a/tests/conformance/ida/providerContracts.test.ts b/tests/conformance/ida/providerContracts.test.ts index 7a13f61998..10b1475eb2 100644 --- a/tests/conformance/ida/providerContracts.test.ts +++ b/tests/conformance/ida/providerContracts.test.ts @@ -1,3 +1,4 @@ +import { IDA_COMPATIBILITY_PROFILES } from "../../../src/ida/IdaConfiguration.js"; import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; import { rm } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; @@ -70,9 +71,10 @@ describe.each(["attached", "headless"] as const)( } finally { await client.close(); } - const descriptor = idaCapabilities(mode, true).find( - (capability) => capability.operation === operation, - ); + const descriptor = idaCapabilities( + IDA_COMPATIBILITY_PROFILES[mode === "attached" ? "legacy" : "modern"], + true, + ).find((capability) => capability.operation === operation); expect(descriptor?.effects.mayShowUi).toBe( producer.calls.some(({ name }) => GUI_NAVIGATING_TOOLS.has(name)), ); From ed6668b5bcb6ac2137700c53224046ccfdae2934 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:36:31 +0800 Subject: [PATCH 08/29] fix(evidence): validate process captures before bundle export Reuse the read-side capture validation before publication so exported bundles can be imported. Preserve historical and foreign record identity and existing destination files on failure. --- src/application/EvidenceBundleFiles.ts | 12 +- .../filesystem/evidenceBundleFiles.test.ts | 105 ++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index 5b290cb0a9..d20cfb9771 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -114,7 +114,13 @@ export const authenticateEvidenceBundleValue = ( }), ); } - const bundle = parsed.value; + return validateBundlePayloads(parsed.value); +}; + +/** Validate typed payloads without restricting historical workflow or provider identities. */ +const validateBundlePayloads = ( + bundle: EvidenceBundle, +): Result => { for (const record of bundle.records) { if (record.predicate_type !== "rea.process-capture") continue; const parsedCapture = safeParseProcessCapture(record.normalized_result); @@ -158,8 +164,10 @@ export const writeEvidenceBundle = async ( }), ); } + const validated = validateBundlePayloads(checked.value); + if (!validated.ok) return validated; return writeTextParts( - bufferedJsonParts(canonicalJsonParts(checked.value)), + bufferedJsonParts(canonicalJsonParts(validated.value)), path, overwrite, resources, diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 9ee0b57fca..c5de3f9cd0 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -49,6 +49,8 @@ import { } from "../../../src/domain/evidenceBundle.js"; import { analysisViewLayoutEvidence } from "../../fixtures/analysisView.js"; import { publishCliAnalysisEvidence } from "../../../src/cli/analysisEvidenceDelivery.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; +import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; const bundle = (result = true) => createEvidenceBundle([ @@ -754,6 +756,109 @@ it.skipIf(process.platform === "win32")( }, ); +describe("evidence bundle process-capture payloads", () => { + it.each([false, true])( + "rejects semantically invalid capture export before publication (overwrite: %s)", + async (overwrite) => { + const directory = await createTestTempDirectory( + "rea-invalid-capture-bundle-", + ); + const path = join(directory, "bundle.json"); + if (overwrite) await writeFile(path, "original"); + // Authenticate the enclosing Evidence correctly while breaking its + // capture commitment, so generic bundle authentication still succeeds. + const capture = EMPTY_PROCESS_CAPTURE_EXAMPLE; + const record = createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture", + operation: "capture_process_scenario", + parameters: {}, + result: { + ...capture, + manifest: { + ...capture.manifest, + full_scenario_sha256: "0".repeat(64), + }, + }, + }); + const value = createEvidenceBundle([record]); + const rejected = await writeBundle(value, path, overwrite); + if (rejected.ok) throw new Error("Expected invalid capture rejection"); + expect(rejected.error).toMatchObject({ + _tag: "EvidenceIntegrityError", + userMessage: expect.stringContaining( + `Process capture record ${record.evidence_id} has an invalid normalized_result`, + ), + }); + expect(await readdir(directory)).toEqual( + overwrite ? ["bundle.json"] : [], + ); + if (overwrite) expect(await readFile(path, "utf8")).toBe("original"); + // Existing files and inline authentication use the same rejection facts. + await writeFile(path, serializeEvidenceBundle(value)); + for (const read of [readEvidenceBundle, readImmutableEvidenceBundle]) { + const loaded = await read(path); + if (loaded.ok) throw new Error("Expected invalid capture rejection"); + expect(projectAnalysisError(loaded.error)).toEqual( + projectAnalysisError(rejected.error), + ); + } + }, + ); + + it.each([ + { + label: "current provider", + provider: PROCESS_PROVIDER, + operation: "capture_process_scenario", + predicateType: "rea.process-capture", + result: EMPTY_PROCESS_CAPTURE_EXAMPLE, + }, + { + label: "historical provider name", + provider: { + ...PROCESS_PROVIDER, + name: "REA deterministic process harness", + }, + operation: "capture_process_scenario", + predicateType: "rea.process-capture", + result: EMPTY_PROCESS_CAPTURE_EXAMPLE, + }, + { + label: "foreign provider and operation", + provider: { id: "foreign", name: "Foreign capture", version: "1" }, + operation: "historical_capture", + predicateType: "rea.process-capture", + result: EMPTY_PROCESS_CAPTURE_EXAMPLE, + }, + { + label: "unrelated predicate", + provider: PROCESS_PROVIDER, + operation: "capture_process_scenario", + predicateType: "foreign.payload", + result: {}, + }, + ])( + "round trips capture-related records without narrowing their identities ($label)", + async ({ provider, operation, predicateType, result }) => { + const directory = await createTestTempDirectory( + "rea-capture-bundle-identity-", + ); + const path = join(directory, "bundle.json"); + const value = createEvidenceBundle([ + createEvidence(undefined, provider, { + operation, + predicateType, + result, + parameters: {}, + }), + ]); + expect(await writeBundle(value, path, false)).toMatchObject({ ok: true }); + for (const read of [readEvidenceBundle, readImmutableEvidenceBundle]) + expect(await read(path)).toEqual({ ok: true, value }); + }, + ); +}); + describe("evidence bundle filesystem adapter", () => { it.skipIf(process.platform === "win32").each(["/", "/."])( "preserves the directory requirement of a missing output ending in %s", From d60b2cab65c3c9119b25ede4d2b99928ff815fbf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:38:51 +0800 Subject: [PATCH 09/29] docs(ida): document migration from legacy attached registrations Describe the native plugin setup, identity and capability differences, verification, and rollback. Link the migration from the packaged skill source. --- .../skills/reverse-engineer-anything/SKILL.md | 5 ++ docs/ida-provider.md | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index d173763599..2032717a40 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -344,6 +344,11 @@ leaves its database open. The modern headless profile creates and closes an owned private database without saving. Installation and registration reuse [mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp); see the [IDA provider guide](https://github.com/morluto/rea/blob/main/docs/ida-provider.md). +For migration to the separate native IDA MCP plugin, follow the guide's +[legacy attached migration](https://github.com/morluto/rea/blob/main/docs/ida-provider.md#migrate-a-legacy-attached-registration). +Replace the upstream registration explicitly; modern mrexodia tools are not the +native profile. Native attachment preserves the database but does not provide +direct callers; check session availability and the guide's verification limits. Proceed directly to `analyze_function`, pseudocode, or function/string searches after opening; IDA does not supply `binary_overview`. Consult session availability when composing broader workflows. Modern direct callers and unsupported dossier diff --git a/docs/ida-provider.md b/docs/ida-provider.md index bdc036930b..491a0d67e5 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -80,6 +80,71 @@ Native MCP 1.1.2 can navigate in the previously focused pseudocode tab when swit Native tool observations retain actual tool names and complete JSON text. Upstream `structuredContent` can be a shortened preview even while text contains the full result. REA reads the full native representation and rejects missing/malformed text instead of treating previews as complete observations. +### Migrate a legacy attached registration + +Both attached profiles remain supported. Migrating replaces the upstream server; +adding `"protocol": "native"` to a legacy Python proxy registration is insufficient. +The native plugin is separate from mrexodia/ida-pro-mcp, and upgrading mrexodia to +its modern tool catalog does not select this profile. + +1. Back up the working registration file. Install and start the native plugin + using its [upstream instructions](https://github.com/0xshlomil/ida-free-mcp). + IDA and the plugin are bring-your-own; REA does not install or build them. + Use a build with the navigation correction described above, and restrict its + listener to loopback or apply the documented host firewall restriction. + Stop the legacy endpoint first if both servers would use the same port. +2. Open the same original input binary in IDA and wait for analysis. Keep that + file readable by REA. The native metadata resource must report its recorded + input SHA-256; the resource's `path` identifies the database, not an input + file to substitute. Universal Mach-O inputs still require a thin extraction. +3. Replace the registration, using the native plugin's actual local endpoint. + For example, this legacy registration: + + ```json + { + "mode": "attached", + "command": "python", + "args": [ + "/absolute/path/to/ida_pro_mcp/server.py", + "--ida-rpc", + "http://127.0.0.1:13337" + ] + } + ``` + + becomes: + + ```json + { + "mode": "attached", + "protocol": "native", + "url": "http://127.0.0.1:13337/mcp" + } + ``` + + Keep `REA_IDA_MCP_CONFIG` pointing at the replacement file and close/reopen + the REA binary session to connect with it. If changing the environment's + configuration path, restart the MCP server as described above. Do not reuse + the legacy RPC URL as an MCP HTTP endpoint. + +4. Check `binary_session` availability before resuming workflows. Direct + `procedure_callers` becomes unavailable and dossier callers remain unknown; + incoming address xrefs do not establish direct call edges. Native strings + use C-string rendering. Assembly and instruction reads no longer navigate + the GUI; native pseudocode still changes focus and closes existing + pseudocode tabs. IDA Free decompilation may require cloud access and consent. +5. Run the [real-provider verification](#verification) with two distinct + functions and the required second-function marker. Supply independently + known string and xref oracles for those checks. Existing Windows evidence + covers IDA Pro 9.3 with the documented source build and listener change; + it does not establish stock-plugin, Windows Free, or native macOS behavior. + A valid registration or successful doctor check alone does not verify the + connection, target identity, or function switching. + +REA continues to verify identity and image base around requests and closes only +its connection, preserving the attached database. Restore the backed-up legacy +registration and its upstream endpoint to return to the legacy profile. + The legacy producer moves the active GUI as a side effect: assembly and raw function instructions jump to the function, and successful pseudocode opens or reuses a pseudocode view. A function dossier does both. Native GUI decompilation also changes the active view. `binary_session` reports these operations with `effects.may_show_ui: true`; headless operations and the other attached operations report `false`. ## Give REA a target for headless analysis From 90475ec9e59bc1f10fc5c927cf44a3ad8a86eac0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:42:50 +0800 Subject: [PATCH 10/29] fix(artifacts): retain selected keyed archive paths --- src/artifacts/ArtifactProvider.ts | 73 ++++---- src/artifacts/apple/KeyedArchiveReader.ts | 164 +++++++++++++++++- .../cli/keyedArchiveSelectedPathCli.test.ts | 44 +++++ .../filesystem/keyedArchiveSelection.test.ts | 5 +- .../keyedArchiveStandaloneSelection.test.ts | 135 ++++++++++++++ 5 files changed, 376 insertions(+), 45 deletions(-) create mode 100644 tests/boundary/cli/keyedArchiveSelectedPathCli.test.ts create mode 100644 tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 42712a0b56..79ac6138af 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -14,9 +14,12 @@ import { type ProviderIdentity, type ExecutionOptions, } from "../application/AnalysisProvider.js"; -import { inspectBundleKeyedArchive } from "./apple/KeyedArchiveReader.js"; +import { + inspectBundleKeyedArchive, + inspectStandaloneKeyedArchive, +} from "./apple/KeyedArchiveReader.js"; import { traceDylibResolution } from "./apple/DylibResolutionReader.js"; -import { basename, dirname } from "node:path"; +import { dirname } from "node:path"; import { inventoryArtifact } from "./inventory/ArtifactInventory.js"; import { extractArtifact } from "./extraction/ArtifactExtraction.js"; import { analyzeInterfaceBuilderBundle } from "./apple/InterfaceBuilderAnalysis.js"; @@ -33,7 +36,6 @@ import { } from "../domain/binaryTargetTypes.js"; import { AnalysisCapabilityUnavailableError, - AnalysisInputError, AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -260,43 +262,42 @@ class ArtifactClient implements AnalysisClient { if (operation === "inspect_keyed_archive") { const standalone = this.target.kind === "artifact" && this.target.format === "plist"; - if ( - standalone && - parameters.path !== undefined && - parameters.path !== "." && - parameters.path !== basename(this.target.path) - ) - throw new AnalysisInputError(operation, undefined, [ - { - path: ["path"], - reason: "invalid_value", - message: - "For an active plist, path must select that archive (omit path or use its basename).", - expected: basename(this.target.path), - }, - ]); - const bundlePath = standalone - ? dirname(this.target.path) - : (this.target.sourcePath ?? this.target.path); - const result = await inspectBundleKeyedArchive({ - bundlePath, - targetSha256: this.target.sha256, - parameters: standalone - ? { ...parameters, path: basename(this.target.path) } - : parameters, - platform: this.platform, - resources: this.#resourceScope, - ...(options?.signal === undefined ? {} : { signal: options.signal }), - }); - if (standalone && result.archive_sha256 !== this.target.sha256) - throw new ArtifactReaderFailure( - "integrity", - `Active archive digest changed: expected ${this.target.sha256}, observed ${result.archive_sha256}`, - ); + const selectedPath = artifactTargetSelectedPath(this.target); + const result = standalone + ? await inspectStandaloneKeyedArchive({ + path: this.target.path, + selectedPath, + targetSha256: this.target.sha256, + parameters, + platform: this.platform, + resources: this.#resourceScope, + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + }) + : await inspectBundleKeyedArchive({ + bundlePath: this.target.sourcePath ?? this.target.path, + targetSha256: this.target.sha256, + parameters, + platform: this.platform, + resources: this.#resourceScope, + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + }); return ok( createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, { limitations: result.limitations, locations: [{ kind: "artifact-path", path: result.archive_path }], + ...(standalone + ? { + subject: { + path: selectedPath, + sha256: result.archive_sha256, + format: "plist" as const, + }, + } + : {}), }), ); } diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 5101c1f4ba..5a2e589751 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -1,10 +1,15 @@ import { createHash } from "node:crypto"; -import { join } from "node:path"; +import { basename, join } from "node:path"; +import { realpath } from "node:fs/promises"; +import { readStableArtifact } from "../readStableArtifact.js"; +import { projectStableArtifactFailure } from "../StableArtifactFailure.js"; +import { ARTIFACT_GRAPH_PROVIDER } from "../../domain/investigationProviders.js"; import { z } from "zod"; import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js"; import { AnalysisAccessDeniedError, + AnalysisArtifactChangedError, AnalysisInputError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; @@ -33,7 +38,12 @@ import { } from "../DirectoryArtifactReader.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; -import { bindArtifactFileCleanup } from "../ArtifactFileOperation.js"; +import { + artifactSelectedPathErrno, + artifactFilePrimaryFacts, + assertArtifactReadActive, + bindArtifactFileCleanup, +} from "../ArtifactFileOperation.js"; import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; @@ -216,6 +226,122 @@ export const inspectBundleKeyedArchive = (input: BundleKeyedArchiveInput) => { }); }; +/** Inspect the admitted physical file while retaining its caller-selected identity. */ +export const inspectStandaloneKeyedArchive = (input: { + readonly path: string; + readonly selectedPath: string; + readonly targetSha256: string; + readonly parameters: unknown; + readonly platform: NodeJS.Platform; + readonly resources: ArtifactResourceScope; + readonly signal?: AbortSignal; +}) => + input.resources.run(async () => { + const selected = keyedArchiveInputSchema.parse(input.parameters); + const selectedBasename = basename(input.selectedPath); + const physicalBasename = basename(input.path); + if ( + selected.path !== "." && + selected.path !== selectedBasename && + selected.path !== physicalBasename + ) + throw new AnalysisInputError("inspect_keyed_archive", undefined, [ + { + path: ["path"], + reason: "invalid_value", + message: + "For an active plist, path must select that archive (omit path or use its basename).", + expected: [selectedBasename, physicalBasename], + }, + ]); + await assertStandaloneArchiveSelection( + input.path, + input.selectedPath, + input.signal, + ); + const snapshot = await readStableArtifact( + input.path, + MAX_BYTES, + input.signal, + undefined, + input.resources, + ).catch((cause: unknown) => { + const failure = artifactFilePrimaryFacts(cause).cause; + if ( + failure instanceof Error && + "code" in failure && + (failure.code === "EACCES" || failure.code === "EPERM") + ) + throw projectStableArtifactFailure( + cause, + new AnalysisAccessDeniedError( + "inspect_keyed_archive", + input.selectedPath, + failure.code, + { cause: failure }, + ), + input.selectedPath, + ARTIFACT_GRAPH_PROVIDER.id, + "inspect_keyed_archive", + ); + throw cause; + }); + await assertStandaloneArchiveSelection( + input.path, + input.selectedPath, + input.signal, + ); + if (snapshot.sha256 !== input.targetSha256) + throw new ArtifactReaderFailure( + "integrity", + `Active archive digest changed: expected ${input.targetSha256}, observed ${snapshot.sha256}`, + ); + return decodeSelectedArchive(snapshot.bytes, { + targetSha256: input.targetSha256, + archiveSha256: snapshot.sha256, + parameters: selected, + platform: input.platform, + archivePath: selected.path === "." ? selectedBasename : selected.path, + artifactPath: input.selectedPath, + }); + }); + +/** Selected aliases must continue naming the admitted physical archive. */ +const assertStandaloneArchiveSelection = async ( + path: string, + selectedPath: string, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const changed = (cause?: unknown) => + new AnalysisArtifactChangedError( + "inspect_keyed_archive", + selectedPath, + "Selected archive no longer resolves to the admitted file.", + { cause }, + ); + let current: string; + try { + current = await realpath(selectedPath); + } catch (cause: unknown) { + if (artifactSelectedPathErrno(cause) !== undefined) throw changed(cause); + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + throw new AnalysisAccessDeniedError( + "inspect_keyed_archive", + selectedPath, + cause.code, + { cause }, + ); + throw cause; + } + assertArtifactReadActive(signal); + if (current !== path) throw changed(); +}; + /** Read exactly one regular, contained bundle entry without following symlinks. */ const readSelectedKeyedArchive = async ( input: BundleKeyedArchiveInput, @@ -285,22 +411,46 @@ const readSelectedKeyedArchive = async ( chunks.push(bytes); } const bytes = Buffer.concat(chunks, size); + return decodeSelectedArchive(bytes, { + targetSha256: input.targetSha256, + archiveSha256: createHash("sha256").update(bytes).digest("hex"), + resourceRoot: input.resourceRoot, + parameters: selected, + platform: input.platform, + archivePath: entry.path, + artifactPath: join(input.bundlePath, entry.path), + }); +}; + +/** Decode and bind one archive at its owning read boundary. */ +const decodeSelectedArchive = ( + bytes: Buffer, + input: { + readonly targetSha256: BundleKeyedArchiveInput["targetSha256"]; + readonly archiveSha256: string; + readonly resourceRoot?: BundleKeyedArchiveInput["resourceRoot"]; + readonly parameters: ReturnType; + readonly platform?: NodeJS.Platform | undefined; + readonly archivePath: string; + readonly artifactPath: string; + }, +) => { try { return keyedArchiveResultSchema.parse({ target_sha256: input.targetSha256, ...(input.resourceRoot === undefined ? {} : { resource_root: input.resourceRoot }), - archive_path: entry.path, - archive_sha256: createHash("sha256").update(bytes).digest("hex"), - ...decodeKeyedArchiveBytes(bytes, selected), + archive_path: input.archivePath, + archive_sha256: input.archiveSha256, + ...decodeKeyedArchiveBytes(bytes, input.parameters), }); } catch (cause) { if (cause instanceof AnalysisInputError) throw cause; if (cause instanceof UnsupportedBinaryPlistUidError) throw new AnalysisUnsupportedTargetError( "inspect_keyed_archive", - join(input.bundlePath, entry.path), + input.artifactPath, cause.message, { cause }, ); @@ -308,7 +458,7 @@ const readSelectedKeyedArchive = async ( if (cause instanceof KeyedArchiveKindError) throw new AnalysisUnsupportedTargetError( "inspect_keyed_archive", - join(input.bundlePath, entry.path), + input.artifactPath, cause.message, { cause, diff --git a/tests/boundary/cli/keyedArchiveSelectedPathCli.test.ts b/tests/boundary/cli/keyedArchiveSelectedPathCli.test.ts new file mode 100644 index 0000000000..637677070f --- /dev/null +++ b/tests/boundary/cli/keyedArchiveSelectedPathCli.test.ts @@ -0,0 +1,44 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build } from "plist"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform === "win32")( + "compiled CLI retains a standalone keyed archive alias and accepts either admitted basename", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-selected-cli-"); + const stored = join(root, "stored.plist"); + const selected = join(root, "chosen.plist"); + await writeFile( + stored, + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { CF$UID: 1 } }, + }), + ); + await symlink(stored, selected); + for (const selection of [undefined, "chosen.plist", "stored.plist"]) { + const output = await cli.run({ + arguments: [ + "inspect-keyed-archive", + selected, + ...(selection === undefined ? [] : [selection]), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + const evidence = parseEvidence(output.json); + expect(evidence.subject?.local_path).toBe(selected); + expect(evidence.normalized_result).toMatchObject({ + archive_path: selection ?? "chosen.plist", + }); + } + }, +); diff --git a/tests/boundary/filesystem/keyedArchiveSelection.test.ts b/tests/boundary/filesystem/keyedArchiveSelection.test.ts index a5214b541a..90518433c9 100644 --- a/tests/boundary/filesystem/keyedArchiveSelection.test.ts +++ b/tests/boundary/filesystem/keyedArchiveSelection.test.ts @@ -244,11 +244,12 @@ it.each([ async (platform, action) => { const root = await createTestTempDirectory("rea-keyed-kind-"); const path = join(root, "Defaults.plist"); - await writeFile(path, Buffer.from(build({ plain: true }))); + const bytes = Buffer.from(build({ plain: true })); + await writeFile(path, bytes); const result = await new ArtifactProvider(process.env, platform) .createClient({ path, - sha256: "0".repeat(64), + sha256: createHash("sha256").update(bytes).digest("hex"), kind: "artifact", format: "plist", }) diff --git a/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts b/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts new file mode 100644 index 0000000000..5c29f6019e --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; +import { chmod, symlink, truncate, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build } from "plist"; +import { expect, it } from "vitest"; + +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { createAnalysisExecutionEvidence } from "../../../src/domain/analysisObservation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "retains standalone archive aliases across session selection and refuses retargeted selections", + async () => { + const root = await createTestTempDirectory("rea-keyed-alias-"); + const stored = join(root, "stored.plist"); + const other = join(root, "other.plist"); + const aliases = [join(root, "chosen.plist"), join(root, "second.plist")]; + const bytes = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { CF$UID: 1 } }, + }); + await writeFile(stored, bytes); + await writeFile(other, bytes); + for (const alias of aliases) await symlink(stored, alias); + const session = composeBinarySession( + new AnalysisProviderRegistry([], "auto", () => true), + [new ArtifactProvider(process.env)], + ); + try { + for (const alias of aliases) { + const opened = await session.open(alias); + if (!opened.ok) throw opened.error; + for (const parameters of [ + {}, + { path: alias.split("/").at(-1)! }, + { path: "." }, + ]) { + const inspected = await session.execute( + "inspect_keyed_archive", + parameters, + ); + if (!inspected.ok) throw inspected.error; + const evidence = createAnalysisExecutionEvidence( + "inspect_keyed_archive", + parameters, + inspected.value, + "observed", + ); + expect(evidence.subject).toMatchObject({ + local_path: alias, + digest: { + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }); + expect(evidence.normalized_result).toMatchObject({ + archive_path: alias.split("/").at(-1), + objects: expect.arrayContaining([ + expect.objectContaining({ value: "leaf" }), + ]), + }); + } + const physicalSelection = await session.execute( + "inspect_keyed_archive", + { path: "stored.plist" }, + ); + if (!physicalSelection.ok) throw physicalSelection.error; + expect(physicalSelection.value.result).toMatchObject({ + archive_path: "stored.plist", + }); + expect(physicalSelection.value.subject).toMatchObject({ path: alias }); + expect( + await session.execute("inspect_keyed_archive", { + path: "unrelated.plist", + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + } + await unlink(aliases[1]!); + await symlink(other, aliases[1]!); + expect(await session.execute("inspect_keyed_archive", {})).toMatchObject({ + ok: false, + error: { _tag: "AnalysisArtifactChangedError" }, + }); + await unlink(aliases[1]!); + expect(await session.execute("inspect_keyed_archive", {})).toMatchObject({ + ok: false, + error: { _tag: "AnalysisArtifactChangedError" }, + }); + await symlink(stored, aliases[1]!); + if (process.getuid?.() !== 0) { + await chmod(stored, 0); + try { + expect( + await session.execute("inspect_keyed_archive", {}), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisAccessDeniedError" }, + }); + } finally { + await chmod(stored, 0o600); + } + } + const cancelled = new AbortController(); + cancelled.abort("caller-stopped"); + expect( + await session.execute( + "inspect_keyed_archive", + {}, + { signal: cancelled.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + await writeFile(stored, "not a keyed archive"); + expect(await session.execute("inspect_keyed_archive", {})).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining("Active archive digest changed"), + }, + }); + await truncate(stored, 64 * 1024 * 1024 + 1); + expect(await session.execute("inspect_keyed_archive", {})).toMatchObject({ + ok: false, + error: { + reason: "limit", + detail: expect.stringContaining("67108864 bytes"), + }, + }); + } finally { + expect(await session.close()).toEqual({ ok: true, value: null }); + } + }, +); From d0e695b42a16b29acf65c14ea1822a56cd32c3c1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:42:55 +0800 Subject: [PATCH 11/29] fix(hopper): bind relative launcher paths once --- src/hopper/HopperProvider.ts | 12 +- .../hopper/hopperRelativeLauncher.test.ts | 120 ++++++++++++++++++ 2 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 tests/boundary/providers/hopper/hopperRelativeLauncher.test.ts diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index eb8352ee24..7dc9e464ea 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -1,4 +1,5 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { fileURLToPath } from "node:url"; import { accessSync, constants, statSync } from "node:fs"; @@ -57,6 +58,7 @@ import { /** Concrete analysis provider backed by REA's private Hopper bridge. */ export class HopperProvider implements AnalysisProviderCandidate { private readonly environment: Readonly; + readonly #launcherPath: string; readonly #ownedResources = new ArtifactResourceScope(); readonly #inspectionOwner: RetainedResourceScope = { retain: (owner, resource) => @@ -81,6 +83,8 @@ export class HopperProvider implements AnalysisProviderCandidate { private readonly platform: NodeJS.Platform = process.platform, ) { this.environment = snapshotEnvironment(environment, platform); + // Bind lookup once; retain config's selected spelling for diagnostics. + this.#launcherPath = absoluteCallerPath(config.hopperLauncherPath); } identity(): ProviderIdentity { @@ -107,9 +111,9 @@ export class HopperProvider implements AnalysisProviderCandidate { diagnostics, }; try { - accessSync(this.config.hopperLauncherPath, constants.X_OK); + accessSync(this.#launcherPath, constants.X_OK); // A searchable directory, such as an .app bundle, also passes X_OK. - if (!statSync(this.config.hopperLauncherPath).isFile()) + if (!statSync(this.#launcherPath).isFile()) throw new TypeError("Hopper launcher is not a regular file"); return { status: "available", @@ -202,7 +206,7 @@ export class HopperProvider implements AnalysisProviderCandidate { ) { const resourceScope = options?.resourceScope ?? this.#inspectionOwner; return resolveHopperAnalysisProfile(target, { - launcherPath: this.config.hopperLauncherPath, + launcherPath: this.#launcherPath, loaderArgsOverride: this.config.hopperLoaderArgs, provider: HOPPER_PROVIDER_IDENTITY, resourceScope, @@ -289,7 +293,7 @@ export class HopperProvider implements AnalysisProviderCandidate { const client = new HopperClient({ launcher: new HopperApplicationLauncher({ environment: this.environment, - launcherPath: this.config.hopperLauncherPath, + launcherPath: this.#launcherPath, targetPath: target.path, targetKind: target.kind, ...(target.kind === "executable" diff --git a/tests/boundary/providers/hopper/hopperRelativeLauncher.test.ts b/tests/boundary/providers/hopper/hopperRelativeLauncher.test.ts new file mode 100644 index 0000000000..bd27108932 --- /dev/null +++ b/tests/boundary/providers/hopper/hopperRelativeLauncher.test.ts @@ -0,0 +1,120 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { + copyFile, + mkdir, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "binds relative Hopper launchers once for availability, profile and real bridge lifecycle", + async () => { + const root = await createTestTempDirectory("rea-hopper-relative-launcher-"); + const away = join(root, "away"); + await mkdir(away); + await mkdir(join(root, "physical/child"), { recursive: true }); + await symlink(join(root, "physical/child"), join(root, "link")); + const target = join(root, "target"); + await copyFile("/usr/bin/true", target); + const fakeBridge = new URL( + "../../../fixtures/fakeHopper.mjs", + import.meta.url, + ).href; + const helper = join(root, "bridge-helper.mjs"); + await writeFile( + helper, + ` + import { readFileSync, writeFileSync } from 'node:fs'; + const bootstrap = readFileSync(process.argv[process.argv.indexOf('-Y') + 1], 'utf8'); + const setting = name => JSON.parse(bootstrap.split('\\n').find(line => line.startsWith(name + ' = ')).slice(name.length + 3)); + writeFileSync(process.env.REA_LAUNCHER_MARKER, process.env.REA_SELECTED_LAUNCHER); + process.argv = [process.execPath, ${JSON.stringify(fakeBridge)}, setting('REA_SOCKET'), setting('REA_TOKEN'), setting('REA_RUN_ID')]; + await import(${JSON.stringify(fakeBridge)}); + `, + ); + const script = `#!/bin/sh\nREA_SELECTED_LAUNCHER="$0" exec '${process.execPath}' '${helper}' "$@"\n`; + for (const path of [ + join(root, "my-hopper"), + join(root, "physical/my-hopper"), + ]) + await writeFile(path, script, { mode: 0o700 }); + await writeFile(join(away, "my-hopper"), "#!/bin/sh\nexit 87\n", { + mode: 0o700, + }); + const module = (path: string) => + new URL(`../../../../src/${path}.ts`, import.meta.url).href; + const source = ` + import { HopperProvider } from ${JSON.stringify(module("hopper/HopperProvider"))}; + import { parseConfig } from ${JSON.stringify(module("config/parseConfig"))}; + import { parseBinaryTarget } from ${JSON.stringify(module("application/BinaryTargetResolver"))}; + import { silentLogger } from ${JSON.stringify(module("logger"))}; + const root = ${JSON.stringify(root)}; + const target = await parseBinaryTarget(${JSON.stringify(target)}, { cwd: root, hostArchitecture: process.arch }); + if (!target.ok) throw target.error; + const results = []; + for (const selected of ['my-hopper', './my-hopper', 'link/../my-hopper']) { + process.chdir(root); + const config = parseConfig({ HOPPER_LAUNCHER_PATH: selected, HOPPER_LOADER_ARGS_JSON: '["--fixture"]', REA_HOPPER_STARTUP_TIMEOUT_MS: '3000' }); + if (!config.ok) throw config.error; + const marker = root + '/marker-' + results.length; + const provider = new HopperProvider(config.value, silentLogger, { REA_LAUNCHER_MARKER: marker }, 'darwin'); + process.chdir(${JSON.stringify(away)}); + const availability = provider.inspectAvailability(); + const profile = await provider.resolveAnalysisProfile(target.value); + if (!profile.ok) { results.push({ selected, availability, profile: false }); await provider.closeOwnedResources(); continue; } + if (profile.value.profile.provider.version !== ${JSON.stringify("launcher-sha256:")} + ${JSON.stringify(createHash("sha256").update(script).digest("hex"))}) { await provider.closeOwnedResources(); throw new Error('Profile selected a different launcher after cwd changed'); } + const client = provider.createClient(target.value, profile.value.profile); + try { + const result = await client.execute('list_documents', {}); + results.push({ selected, availability, profile: true, executed: result.ok, marker }); + } finally { + const closed = await client.close(); + if (!closed.ok) throw closed.error; + const released = await provider.closeOwnedResources(); + if (!released.ok) throw released.error; + } + } + console.log(JSON.stringify(results)); + `; + const { stdout } = await promisify(execFile)( + process.execPath, + [ + "--import", + import.meta.resolve("tsx"), + "--input-type=module", + "--eval", + source, + ], + { cwd: root, timeout: 20_000 }, + ); + expect(JSON.parse(stdout)).toEqual( + ["my-hopper", "./my-hopper", "link/../my-hopper"].map( + (selected, index) => ({ + selected, + availability: expect.objectContaining({ + status: "available", + diagnostics: { launcher_path: selected, platform: "darwin" }, + }), + profile: true, + executed: true, + marker: join(root, `marker-${String(index)}`), + }), + ), + ); + expect(await readFile(join(root, "marker-0"), "utf8")).toBe( + join(root, "my-hopper"), + ); + expect(await readFile(join(root, "marker-1"), "utf8")).toBe( + join(root, "my-hopper"), + ); + expect(await readFile(join(root, "marker-2"), "utf8")).toBe( + `${root}/link/../my-hopper`, + ); + }, +); From a6488a96a967d4e2ef75a896c65172ee72240c83 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:42:58 +0800 Subject: [PATCH 12/29] fix(javascript): preserve renderer path anchors --- .../JavaScriptArtifactGraphFindings.ts | 22 +++- .../javascriptStaticAnalysisCalls.ts | 79 +++++++----- .../javascriptStaticAnalysisHelpers.ts | 117 +++++++++++++----- .../javascriptStaticAnalysisTypes.ts | 2 + .../javascriptStaticPathResolution.test.ts | 44 +++++++ .../JavaScriptAnalysisWorkerSchemas.ts | 1 + .../electronRendererStaticPaths.test.ts | 59 +++++++++ 7 files changed, 259 insertions(+), 65 deletions(-) create mode 100644 tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 3dd863e594..1250fb3319 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -492,7 +492,7 @@ const addDiscoveredRole = ( changedRoots: ReadonlySet | null, ): void => { const root = applicationRootPath(context, input.file); - const resolution = resolveArtifactPathByContext({ + const resolved = resolveArtifactPathByContext({ declaredPath: input.value.path, sourcePath: input.file.path, context: input.value.resolution_context, @@ -500,7 +500,8 @@ const addDiscoveredRole = ( jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, - ...(input.value.role === "preload" + ...(input.value.role === "preload" || + input.value.path_base === "source-directory" ? { runtimePathKind: "exact-file" as const } : input.value.role === "renderer" && input.value.resolution_context === "filesystem-expression" @@ -520,6 +521,16 @@ const addDiscoveredRole = ( ? { runtimePathKind: "url-resource" as const } : {}), }); + const resolution = + input.value.path_base === "source-directory" + ? { + ...resolved, + limitations: [ + ...resolved.limitations, + "The source-directory anchor is inferred from authored code; bundled or transformed runtime path behavior was not verified.", + ], + } + : resolved; const role = createElectronRoleNode(context, { kind: input.value.role === "preload" ? "electron-preload" : "electron-renderer", @@ -543,7 +554,12 @@ const addDiscoveredRole = ( range: input.value.location, coverage: input.coverage, relation: "loads", - properties: { mechanism: input.value.mechanism }, + properties: { + mechanism: input.value.mechanism, + ...(input.value.path_base === undefined + ? {} + : { path_base: input.value.path_base }), + }, }); linkElectronRoleToAsset(context, { role, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index ea46a0f474..bf934a9d30 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -19,6 +19,7 @@ import { rangeForOffsets, sourceSlice, stringValue, + staticFileExpressionPath, } from "./javascriptStaticAnalysisHelpers.js"; import { semanticStaticPropertyKey, @@ -149,40 +150,54 @@ export const inspectCall = ( inspectStorageCall(node, name, first, context, bindings); if ( (name === "loadFile" || name.endsWith(".loadFile")) && - context.rendererLoad?.(node.callee, node) === true && - first !== undefined - ) - addLocatedFinding(context, { - collection: accumulator.roles, - key: `role\0renderer\0${first}`, - node, - value: { - role: "renderer", - path: first, - resolution_context: "filesystem-expression", - mechanism: `call:${name}`, - module_key: null, - location: range(node), - }, - }); + context.rendererLoad?.(node.callee, node) === true + ) { + const argument = argumentNode(node.arguments[0]); + const filePath = + first === undefined && argument !== undefined + ? staticFileExpressionPath(argument, context.pathIntrinsics) + : first === undefined + ? undefined + : { path: first, base: "literal-path" as const }; + if (filePath === undefined) accumulator.unknownFindings += 1; + else + addLocatedFinding(context, { + collection: accumulator.roles, + key: `role\0renderer\0${filePath.path}\0${filePath.base}`, + node, + value: { + role: "renderer", + path: filePath.path, + ...(filePath.base === "source-directory" + ? { path_base: "source-directory" as const } + : {}), + resolution_context: "filesystem-expression", + mechanism: `call:${name}`, + module_key: null, + location: range(node), + }, + }); + } if ( (name === "loadURL" || name.endsWith(".loadURL")) && - context.rendererLoad?.(node.callee, node) === true && - first !== undefined - ) - addLocatedFinding(context, { - collection: accumulator.roles, - key: `role\0renderer\0${first}`, - node, - value: { - role: "renderer", - path: first, - resolution_context: "module-specifier", - mechanism: `call:${name}`, - module_key: null, - location: range(node), - }, - }); + context.rendererLoad?.(node.callee, node) === true + ) { + if (first === undefined) accumulator.unknownFindings += 1; + else + addLocatedFinding(context, { + collection: accumulator.roles, + key: `role\0renderer\0${first}`, + node, + value: { + role: "renderer", + path: first, + resolution_context: "module-specifier", + mechanism: `call:${name}`, + module_key: null, + location: range(node), + }, + }); + } }; const moduleUrlSpecifier = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 5cc4187e74..b3569e29b1 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -187,14 +187,25 @@ export const staticPathResolutionContext = ( ? "filesystem-expression" : "module-specifier"; +interface StaticPathFact { + readonly path: string; + readonly base: "source-directory" | "literal-path" | "unknown"; +} + /** Resolve a path string composed only from inert literal syntax. */ export const staticPath = ( node: t.Node, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { - node = unwrapJavaScriptExpression(node).node; +): string | undefined => staticPathFact(node, pathIntrinsics)?.path; + +/** Parse spelling and its source-directory provenance together at the owning boundary. */ +const staticPathFact = ( + raw: t.Node, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, +): StaticPathFact | undefined => { + const node = unwrapJavaScriptExpression(raw).node; const value = stringValue(node); - if (value !== undefined) return value; + if (value !== undefined) return { path: value, base: "literal-path" }; if (t.isBinaryExpression(node, { operator: "+" })) return staticConcatenation(node, pathIntrinsics); if (t.isCallExpression(node) || t.isNewExpression(node)) @@ -202,6 +213,22 @@ export const staticPath = ( return undefined; }; +/** Admit file expressions only when the normalized spelling retains a proven base. */ +export const staticFileExpressionPath = ( + node: t.Node, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, +): + | { + readonly path: string; + readonly base: "source-directory" | "literal-path"; + } + | undefined => { + const fact = staticPathFact(node, pathIntrinsics); + return fact === undefined || fact.base === "unknown" + ? undefined + : { path: fact.path, base: fact.base }; +}; + /** * Concatenate inert text. A leading source directory contributes no text: * `__dirname + "/preload.js"` names a file inside it, while text without a @@ -210,7 +237,7 @@ export const staticPath = ( const staticConcatenation = ( node: t.BinaryExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): StaticPathFact | undefined => { const operands: t.Node[] = []; let current: t.Node = node; while (t.isBinaryExpression(current, { operator: "+" })) { @@ -220,43 +247,62 @@ const staticConcatenation = ( const anchored = isDirectoryPath(current, pathIntrinsics); if (!anchored) operands.push(current); let text = ""; + let literal = true; for (const operand of operands.reverse()) { - const value = staticPath(operand, pathIntrinsics); + const value = staticPathFact(operand, pathIntrinsics); if (value === undefined) return undefined; - text += value; + text += value.path; + literal &&= value.base === "literal-path"; } - if (!anchored) return text; - return text.startsWith("/") ? posix.join(".", text) : undefined; + if (!anchored) + return { path: text, base: literal ? "literal-path" : "unknown" }; + return text.startsWith("/") + ? { + path: posix.join(".", text), + base: literal ? "source-directory" : "unknown", + } + : undefined; }; const staticCallPath = ( node: t.CallExpression | t.NewExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): StaticPathFact | undefined => { if (t.isCallExpression(node) && pathIntrinsics?.(node.callee)) return staticResolvedPath(node, pathIntrinsics); if ( t.isCallExpression(node) && pathIntrinsics?.(node.callee, "file-url-to-path") ) - return staticFileUrlPath(node, pathIntrinsics); + return staticFileUrlPathFact(node, pathIntrinsics); if (!t.isCallExpression(node) || !pathIntrinsics?.(node.callee, "join")) return undefined; - const parts: string[] = []; + const parts: StaticPathFact[] = []; for (const argument of node.arguments) { if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) { - // join never resets at an absolute segment, so `/preload.js` after the - // source directory stays inside it. - parts.push("."); + // join never resets at an absolute segment after the source directory. + parts.push({ path: ".", base: "source-directory" }); continue; } const value = t.isNode(argument) - ? staticPath(argument, pathIntrinsics) + ? staticPathFact(argument, pathIntrinsics) : undefined; if (value === undefined) return undefined; parts.push(value); } - return parts.length === 0 ? undefined : posix.join(...parts); + if (parts.length === 0) return undefined; + const first = parts.findIndex(({ path }) => path !== ""); + const sourceAnchors = parts.flatMap(({ base }, index) => + base === "source-directory" ? [index] : [], + ); + const base = parts.some(({ base }) => base === "unknown") + ? "unknown" + : sourceAnchors.length === 0 + ? "literal-path" + : sourceAnchors.length === 1 && sourceAnchors[0] === first + ? "source-directory" + : "unknown"; + return { path: posix.join(...parts.map(({ path }) => path)), base }; }; /** Normalized path facts from a source-relative file URL expression. */ @@ -269,13 +315,22 @@ export interface JavaScriptStaticSourceUrlPath { export const staticFileUrlPath = ( node: t.CallExpression | t.NewExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): string | undefined => staticFileUrlPathFact(node, pathIntrinsics)?.path; + +const staticFileUrlPathFact = ( + node: t.CallExpression | t.NewExpression, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, +): StaticPathFact | undefined => { if (node.arguments.length !== 1) return undefined; const rawArgument = argumentNode(node.arguments[0]); if (rawArgument === undefined) return undefined; const argument = unwrapJavaScriptExpression(rawArgument).node; - if (t.isNewExpression(argument) && pathIntrinsics?.(argument, "source-url")) - return staticSourceRelativeUrlPath(argument)?.path; + if (t.isNewExpression(argument) && pathIntrinsics?.(argument, "source-url")) { + const resolved = staticSourceRelativeUrlPath(argument); + return resolved === undefined + ? undefined + : { path: resolved.path, base: "source-directory" }; + } const value = staticPath(argument, pathIntrinsics); const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; if ( @@ -293,7 +348,9 @@ export const staticFileUrlPath = ( // Canonical analysis paths use POSIX spelling so results do not depend on // the analyzer host. Drive and UNC paths need an explicit target platform. const path = fileURLToPath(url, { windows: false }); - return /^\/[a-z]:/iu.test(path) ? undefined : path; + return /^\/[a-z]:/iu.test(path) + ? undefined + : { path, base: "literal-path" }; } catch (cause: unknown) { void cause; return undefined; @@ -345,30 +402,30 @@ export const staticSourceRelativeUrlPath = ( const staticResolvedPath = ( node: t.CallExpression | t.NewExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): StaticPathFact | undefined => { const parts: string[] = []; - let anchored = false; + let base: StaticPathFact["base"] | undefined; // Node walks right-to-left and never inspects arguments before its anchor. for (let index = node.arguments.length - 1; index >= 0; index -= 1) { const argument = node.arguments[index]; if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) { - anchored = true; + base = "source-directory"; break; } const value = t.isNode(argument) - ? staticPath(argument, pathIntrinsics) + ? staticPathFact(argument, pathIntrinsics) : undefined; - if (value === undefined) return undefined; - parts.push(value); - if (posix.isAbsolute(value)) { - anchored = true; + if (value === undefined || value.base === "unknown") return undefined; + parts.push(value.path); + if (value.base === "source-directory" || posix.isAbsolute(value.path)) { + base = value.base; break; } } // Relative-only calls depend on the target process cwd, which is unknown. - if (!anchored) return undefined; + if (base === undefined) return undefined; const path = posix.join(...parts.reverse()); - return path === "/" ? path : path.replace(/\/$/u, ""); + return { path: path === "/" ? path : path.replace(/\/$/u, ""), base }; }; const isFilesystemPathExpression = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 4a49d611fe..994ab20a55 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -116,6 +116,8 @@ export interface JavaScriptRolePath { readonly role: "preload" | "renderer"; readonly path: string; readonly resolution_context: JavaScriptStaticPathContext; + /** Inferred from intact source-directory intrinsics; not a runtime/bundler guarantee. */ + readonly path_base?: "source-directory"; readonly mechanism: string; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 948fd1f0f0..2f13040109 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -5,6 +5,50 @@ import { parseExpression } from "@babel/parser"; import { describe, expect, it } from "vitest"; import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; + +it.each([ + ['path.join(__dirname, "page.html")', "page.html", "source-directory"], + ['path.resolve(__dirname, "page.html")', "page.html", "source-directory"], + ['__dirname + "/page.html"', "page.html", "source-directory"], + ['path.join("pages", "page.html")', "pages/page.html", undefined], + ['path.join("prefix", __dirname, "page.html")', null, undefined], + ['path.join(__dirname, __dirname, "page.html")', null, undefined], + ["path.join(__dirname, runtimePage)", null, undefined], +] as const)( + "retains the owned renderer path base for %s", + (expression, path, base) => { + const analysis = analyzeCommonJsStaticFixture( + `const path=require('node:path');const{BrowserWindow}=require('electron');new BrowserWindow().loadFile(${expression});`, + ); + if (path === null) { + expect(analysis.role_paths).toEqual([]); + expect( + analysis.analysis_coverage?.semantics.unknown_findings, + ).toBeGreaterThan(0); + } else { + expect(analysis.role_paths).toMatchObject([ + { role: "renderer", path, resolution_context: "filesystem-expression" }, + ]); + expect(analysis.role_paths[0]?.path_base).toBe(base); + } + }, +); + +it.each(["loadFile", "loadURL"])( + "reports an unresolved proven BrowserWindow %s expression", + (method) => { + const analysis = analyzeCommonJsStaticFixture( + `const{BrowserWindow}=require('electron');new BrowserWindow().${method}(runtimePage);`, + ); + expect(analysis.role_paths).toEqual([]); + expect( + analysis.analysis_coverage?.semantics.unknown_findings, + ).toBeGreaterThan(0); + expect(analysis.limitations).toContain( + "One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.", + ); + }, +); import { parserPluginsForPath } from "./javascriptSourceParser.js"; import { calleeName, diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index e06022c2f0..31a7f5f311 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -401,6 +401,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z ...findingContext, role: z.enum(["preload", "renderer"]), path: z.string(), + path_base: z.literal("source-directory").exactOptional(), resolution_context: pathContext, mechanism: z.string(), }), diff --git a/tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts b/tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts new file mode 100644 index 0000000000..253dec5200 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; + +it("distinguishes module-anchored and application-relative BrowserWindow loadFile calls", async () => { + const root = await createTestTempDirectory("rea-renderer-static-path-"); + await mkdir(join(root, "src")); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ main: "src/main.cjs" }), + ), + writeFile( + join(root, "src", "main.cjs"), + `const path = require('node:path'); +const { BrowserWindow } = require('electron'); +new BrowserWindow().loadFile(path.join(__dirname, 'anchored.html')); +new BrowserWindow().loadFile('literal.html'); +new BrowserWindow().loadFile(runtimePage);`, + ), + writeFile( + join(root, "src", "anchored.html"), + "actual source-anchored page", + ), + writeFile(join(root, "anchored.html"), "wrong app-root decoy"), + writeFile(join(root, "literal.html"), "actual app-root page"), + writeFile(join(root, "src", "literal.html"), "wrong source-relative decoy"), + ]); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const graph = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ).graph; + const rendererPaths = graph.nodes + .filter(({ kind }) => kind === "electron-renderer") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.resolved_path), + ); + expect(rendererPaths.sort()).toEqual(["literal.html", "src/anchored.html"]); + expect(graph.coverage.status).toBe("partial"); + const anchored = graph.nodes.find( + ({ kind, observations }) => + kind === "electron-renderer" && + observations.some( + ({ properties }) => properties.resolved_path === "src/anchored.html", + ), + ); + expect(anchored?.observations[0]?.evidence.limitations).toContain( + "The source-directory anchor is inferred from authored code; bundled or transformed runtime path behavior was not verified.", + ); +}); From 82fd2236385138dd71cf0a49be3b417acfc58292 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:43:17 +0800 Subject: [PATCH 13/29] refactor(native): consolidate provider-neutral analysis contracts Move shared function, dossier, inventory, reference and output semantics from the legacy Hopper module to native domain owners. Migrate provider, adapter, test and verifier consumers directly. --- scripts/fixtures/managed/function-dossier.mjs | 2 +- scripts/lib/real-hopper-search.d.mts | 2 +- scripts/verify-real-ghidra-com.mjs | 2 +- scripts/verify-real-ghidra-dol.mjs | 2 +- scripts/verify-real-ghidra-dos.mjs | 6 +- .../verify-real-ghidra-mips-relocatable.mjs | 2 +- scripts/verify-real-ghidra-mips.mjs | 2 +- scripts/verify-real-ghidra-ppc.mjs | 2 +- scripts/verify-real-ghidra-riscv.mjs | 2 +- scripts/verify-real-ida.mjs | 2 +- src/application/AnalysisContextQueries.ts | 6 +- src/application/AnalysisDocument.ts | 2 +- src/application/CallPathTracing.ts | 2 +- src/application/EnhancedLiteralTracing.ts | 2 +- src/application/EnhancedToolTypes.ts | 2 +- src/application/EnhancedTools.ts | 10 +- src/application/FunctionAnalysisEvidence.ts | 2 +- src/application/WorkflowEvidence.test.ts | 2 +- .../analysisView/AnalysisViewService.test.ts | 2 +- .../native/NativeApiInspection.test.ts | 2 +- src/application/native/NativeApiInspection.ts | 2 +- src/application/native/NativeCallRoutes.ts | 6 +- .../NativeFunctionMatchingService.test.ts | 2 +- .../native/NativeValueTrace.test.ts | 2 +- src/application/native/NativeValueTrace.ts | 2 +- src/contracts/toolOutputSchemaGroups.ts | 10 +- src/contracts/toolOutputSchemaPrimitives.ts | 2 +- src/contracts/tupleSchemaParity.test.ts | 2 +- src/domain/analysisView/analysisView.test.ts | 2 +- src/domain/analysisView/analysisView.ts | 2 +- src/domain/analysisView/nativeFunctionView.ts | 2 +- .../analysisView/nativeInventoryView.ts | 2 +- .../analysisView/nativeObservationSchemas.ts | 6 +- src/domain/functionCallCoverage.ts | 2 +- src/domain/functionComparison.test.ts | 2 +- src/domain/functionDossierEvidence.ts | 5 +- src/domain/hopperValues.test.ts | 264 -------- src/domain/hopperValues.ts | 616 ------------------ .../managedNativeVerificationSource.ts | 2 +- src/domain/native/addressCoordinate.ts | 11 + src/domain/native/functionBody.test.ts | 134 ++++ src/domain/native/functionBody.ts | 110 ++++ src/domain/native/functionByteCoverage.ts | 2 +- src/domain/native/functionDossier.ts | 128 ++++ src/domain/native/functionInventory.ts | 4 +- .../native/functionStructuralCandidate.ts | 2 +- src/domain/native/literalFeatureMatch.ts | 2 +- src/domain/native/nativeDocument.ts | 21 + src/domain/native/nativeFactAvailability.ts | 6 + .../native/nativeFunctionAnnotations.ts | 2 +- src/domain/native/nativeInventory.ts | 36 +- src/domain/native/nativeObservation.ts | 62 +- .../native/nativeProviderOutput.test.ts | 129 ++++ src/domain/native/nativeProviderOutput.ts | 34 + src/domain/native/nativeReference.ts | 46 ++ src/domain/native/nativeSegments.ts | 87 +++ src/domain/native/nativeString.ts | 32 + src/domain/native/procedureInfo.ts | 48 +- src/domain/native/swiftStringLiteral.ts | 70 ++ ...uctionVerification.functionSources.test.ts | 2 +- src/domain/reconstructionVerification.ts | 2 +- src/domain/staticRuntimeCorrelation.test.ts | 2 +- src/domain/symbolAnalysis.ts | 2 +- src/ghidra/GhidraFunctionValues.test.ts | 2 +- src/ghidra/GhidraFunctionValues.ts | 8 +- src/ghidra/GhidraInventoryValues.ts | 6 +- src/ghidra/GhidraSnapshotCache.test.ts | 2 +- src/hopper/HopperRegexSearch.ts | 2 +- src/ida/IdaOperationRunner.ts | 2 +- src/ida/IdaSessionClient.test.ts | 2 +- src/kuna/KunaReferenceQueries.ts | 2 +- src/kuna/KunaSessionClient.ts | 2 +- src/kuna/KunaStringInventory.ts | 2 +- .../cli/analyzeFunctionDetail.test.ts | 2 +- tests/boundary/mcp/analysisViewMcp.test.ts | 2 +- .../mcp/analyzeFunctionDetail.test.ts | 2 +- .../mcp/callPathSearchExtentMcp.test.ts | 2 +- ...ts-incomplete-function-dossiers-at.test.ts | 2 +- .../mcp/functionCallDirectionsMcp.test.ts | 2 +- .../mcp/functionComparisonCoverageMcp.test.ts | 2 +- .../mcp/functionComparisonMcp.test.ts | 2 +- ...unctionComparisonReferenceFactsMcp.test.ts | 2 +- ...unctionComparisonStringDecodingMcp.test.ts | 2 +- tests/boundary/mcp/ghidraEvidenceMcp.test.ts | 2 +- tests/boundary/mcp/ghidraNativeApiMcp.test.ts | 2 +- .../managedNativeVerificationAdapters.test.ts | 2 +- .../mcp/nativeAddressContextMcp.test.ts | 2 +- tests/boundary/mcp/officialArguments.test.ts | 2 +- .../mcp/workflowUnknownIdentityMcp.test.ts | 2 +- .../hopper/hopperBridgeFacade.test.ts | 2 +- .../providers/ida/functionIdentity.test.ts | 2 +- .../managed/managedNativeVerification.test.ts | 2 +- tests/fixtures/analysisView.ts | 2 +- tests/fixtures/nativeFunctionMatching.ts | 2 +- 94 files changed, 1050 insertions(+), 985 deletions(-) delete mode 100644 src/domain/hopperValues.test.ts delete mode 100644 src/domain/hopperValues.ts create mode 100644 src/domain/native/functionBody.test.ts create mode 100644 src/domain/native/functionBody.ts create mode 100644 src/domain/native/functionDossier.ts create mode 100644 src/domain/native/nativeDocument.ts create mode 100644 src/domain/native/nativeFactAvailability.ts create mode 100644 src/domain/native/nativeProviderOutput.test.ts create mode 100644 src/domain/native/nativeProviderOutput.ts create mode 100644 src/domain/native/nativeReference.ts create mode 100644 src/domain/native/nativeSegments.ts create mode 100644 src/domain/native/nativeString.ts create mode 100644 src/domain/native/swiftStringLiteral.ts diff --git a/scripts/fixtures/managed/function-dossier.mjs b/scripts/fixtures/managed/function-dossier.mjs index 125d41e621..d47cae198a 100644 --- a/scripts/fixtures/managed/function-dossier.mjs +++ b/scripts/fixtures/managed/function-dossier.mjs @@ -1,4 +1,4 @@ -import { functionDossierSchema } from "../../../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../dist/domain/native/functionDossier.js"; /** Validated synthetic native observations shared by managed verification lanes. */ export const functionDossier = (name) => diff --git a/scripts/lib/real-hopper-search.d.mts b/scripts/lib/real-hopper-search.d.mts index d3bb4c0d3a..7f9bf8d2ba 100644 --- a/scripts/lib/real-hopper-search.d.mts +++ b/scripts/lib/real-hopper-search.d.mts @@ -1,6 +1,6 @@ import type { z } from "zod"; -import type { analysisStringSchema } from "../../src/domain/hopperValues.js"; +import type { analysisStringSchema } from "../../src/domain/native/nativeString.js"; type VerifierCall = ( name: string, diff --git a/scripts/verify-real-ghidra-com.mjs b/scripts/verify-real-ghidra-com.mjs index 6139f633c2..80b997f990 100644 --- a/scripts/verify-real-ghidra-com.mjs +++ b/scripts/verify-real-ghidra-com.mjs @@ -14,7 +14,7 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseEvidence } from "../dist/domain/evidence.js"; -import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildDosComFixture } from "../tests/conformance/ghidra/dos-com-fixture.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; diff --git a/scripts/verify-real-ghidra-dol.mjs b/scripts/verify-real-ghidra-dol.mjs index c025903498..b26bc7d28e 100644 --- a/scripts/verify-real-ghidra-dol.mjs +++ b/scripts/verify-real-ghidra-dol.mjs @@ -14,7 +14,7 @@ import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.j import { readGhidraLanguageCatalog } from "../dist/ghidra/GhidraLanguageCatalog.js"; import { parseEvidence } from "../dist/domain/evidence.js"; import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; -import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildGameCubeDolFixture } from "../tests/conformance/ghidra/dol-fixture.mjs"; import { diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index 1c685f8d97..75663b572f 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -21,10 +21,8 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseEvidence } from "../dist/domain/evidence.js"; -import { - functionBodySchema, - functionDossierSchema, -} from "../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; +import { functionBodySchema } from "../dist/domain/native/functionBody.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildDosMzFixture } from "../tests/conformance/ghidra/dos-mz-fixture.mjs"; import { diff --git a/scripts/verify-real-ghidra-mips-relocatable.mjs b/scripts/verify-real-ghidra-mips-relocatable.mjs index 49de4c1bfe..95c3a8b8e0 100755 --- a/scripts/verify-real-ghidra-mips-relocatable.mjs +++ b/scripts/verify-real-ghidra-mips-relocatable.mjs @@ -12,7 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { parseEvidence } from "../dist/domain/evidence.js"; import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; -import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildMipsRelocatableFixture } from "../tests/conformance/ghidra/mips-fixture.mjs"; import { diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index 7d7c2c47de..bbfa4b382d 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -21,7 +21,7 @@ import { } from "../tests/conformance/ghidra/mips-checks.mjs"; import { parseEvidence } from "../dist/domain/evidence.js"; import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; -import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs"; import { diff --git a/scripts/verify-real-ghidra-ppc.mjs b/scripts/verify-real-ghidra-ppc.mjs index 32d56f83e5..a8a99d9408 100644 --- a/scripts/verify-real-ghidra-ppc.mjs +++ b/scripts/verify-real-ghidra-ppc.mjs @@ -18,7 +18,7 @@ import { } from "../tests/conformance/ghidra/ppc-checks.mjs"; import { parseEvidence } from "../dist/domain/evidence.js"; import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; -import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildPowerPcFixture } from "../tests/conformance/ghidra/ppc-fixture.mjs"; import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; diff --git a/scripts/verify-real-ghidra-riscv.mjs b/scripts/verify-real-ghidra-riscv.mjs index 29e67d88fa..0a79119de3 100644 --- a/scripts/verify-real-ghidra-riscv.mjs +++ b/scripts/verify-real-ghidra-riscv.mjs @@ -21,7 +21,7 @@ import addFormats from "ajv-formats"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; import { parseEvidence } from "../dist/domain/evidence.js"; -import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { buildRiscvFixture } from "../tests/conformance/ghidra/riscv-fixture.mjs"; import { diff --git a/scripts/verify-real-ida.mjs b/scripts/verify-real-ida.mjs index 1aff82bad6..438b2ed40e 100644 --- a/scripts/verify-real-ida.mjs +++ b/scripts/verify-real-ida.mjs @@ -60,7 +60,7 @@ const [ ] = await Promise.all([ importBuilt("contracts/toolContracts.js"), importBuilt("domain/evidence.js"), - importBuilt("domain/hopperValues.js"), + importBuilt("domain/native/functionDossier.js"), importBuilt("ida/IdaConfiguration.js"), importBuilt("ida/IdaMcpConnection.js"), importBuilt("ida/IdaProtocolValues.js"), diff --git a/src/application/AnalysisContextQueries.ts b/src/application/AnalysisContextQueries.ts index 6735bb308a..436cea3ad3 100644 --- a/src/application/AnalysisContextQueries.ts +++ b/src/application/AnalysisContextQueries.ts @@ -6,10 +6,8 @@ import type { import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import { - analysisBookmarkSchema, - parseContainingProcedureResolution, -} from "../domain/hopperValues.js"; +import { analysisBookmarkSchema } from "../domain/native/nativeInventory.js"; +import { parseContainingProcedureResolution } from "../domain/native/nativeObservation.js"; import { resolveAnalysisDocument } from "./AnalysisDocument.js"; import { err, ok, type Result } from "../domain/result.js"; diff --git a/src/application/AnalysisDocument.ts b/src/application/AnalysisDocument.ts index 6f9200eb29..73e7418df2 100644 --- a/src/application/AnalysisDocument.ts +++ b/src/application/AnalysisDocument.ts @@ -9,7 +9,7 @@ import { AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { parseDocuments } from "../domain/hopperValues.js"; +import { parseDocuments } from "../domain/native/nativeDocument.js"; import { err, ok, type Result } from "../domain/result.js"; const executionOptions = (signal: AbortSignal | undefined) => diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts index cfe81081c2..a114b5efb8 100644 --- a/src/application/CallPathTracing.ts +++ b/src/application/CallPathTracing.ts @@ -8,7 +8,7 @@ import type { import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { parseRelatedAddresses } from "../domain/hopperValues.js"; +import { parseRelatedAddresses } from "../domain/native/nativeObservation.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { EnhancedResult } from "./EnhancedToolTypes.js"; diff --git a/src/application/EnhancedLiteralTracing.ts b/src/application/EnhancedLiteralTracing.ts index 89dd10d1e5..d76a627a37 100644 --- a/src/application/EnhancedLiteralTracing.ts +++ b/src/application/EnhancedLiteralTracing.ts @@ -10,7 +10,7 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { parseContainingProcedureResolution, type ContainingProcedureResolution, -} from "../domain/hopperValues.js"; +} from "../domain/native/nativeObservation.js"; import { err, ok, type Result } from "../domain/result.js"; import type { EnhancedResult, TraceReference } from "./EnhancedToolTypes.js"; diff --git a/src/application/EnhancedToolTypes.ts b/src/application/EnhancedToolTypes.ts index 3d4170ff6e..1352364d51 100644 --- a/src/application/EnhancedToolTypes.ts +++ b/src/application/EnhancedToolTypes.ts @@ -3,7 +3,7 @@ import type { z } from "zod"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import type { ContainingProcedureResolution } from "../domain/hopperValues.js"; +import type { ContainingProcedureResolution } from "../domain/native/nativeObservation.js"; import { err, type Result } from "../domain/result.js"; import { enhancedInputSchemas, diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 1c56bb7ff1..89179d6c08 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -21,13 +21,16 @@ import { } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { parseFunctionDossier } from "../domain/native/functionDossier.js"; import { - parseFunctionDossier, parseListCount, - parseRelatedAddresses, + addressedValueSchema, +} from "../domain/native/nativeInventory.js"; +import { parseRelatedAddresses } from "../domain/native/nativeObservation.js"; +import { parseSegments, projectBinaryOverview, -} from "../domain/hopperValues.js"; +} from "../domain/native/nativeSegments.js"; import { err, ok, type Result } from "../domain/result.js"; import { categorizeSwiftTypes, @@ -40,7 +43,6 @@ import { traceNativeValues } from "./native/NativeValueTrace.js"; import { traceNativeUiAction } from "./native/NativeUiActionTrace.js"; import { decompileAll } from "./decompileDirectory/decompileAll.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; -import { addressedValueSchema } from "../domain/native/nativeInventory.js"; import { nativeDispatchMetadataResultSchema, MAX_DISPATCH_METADATA_RECORDS, diff --git a/src/application/FunctionAnalysisEvidence.ts b/src/application/FunctionAnalysisEvidence.ts index d8f6463ea2..835f81ed80 100644 --- a/src/application/FunctionAnalysisEvidence.ts +++ b/src/application/FunctionAnalysisEvidence.ts @@ -6,7 +6,7 @@ import type { import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; -import { parseFunctionDossier } from "../domain/hopperValues.js"; +import { parseFunctionDossier } from "../domain/native/functionDossier.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { completeProviderEvidence } from "./ProviderOperationExecution.js"; import type { Evidence } from "../domain/evidence.js"; diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index 88596f3653..0cb64d0cbf 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -16,7 +16,7 @@ import { import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js"; import { projectNativeApiInspection } from "./native/NativeApiInspection.js"; -import { functionDossierSchema } from "../domain/hopperValues.js"; +import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js"; import { ANALYSIS_SNAPSHOT_PROFILE } from "../domain/analysisSnapshot.fixture.js"; import { createEvidence } from "../domain/evidence.js"; diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index 0b8a3311c5..ad762d5881 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -20,7 +20,7 @@ import { rebindEvidenceToTargetPath, } from "../../domain/evidence.js"; import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js"; -import { functionDossierSchema } from "../../domain/hopperValues.js"; +import { functionDossierSchema } from "../../domain/native/functionDossier.js"; import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js"; import { inspectAnalysisView, diff --git a/src/application/native/NativeApiInspection.test.ts b/src/application/native/NativeApiInspection.test.ts index 9990106383..593b3dd299 100644 --- a/src/application/native/NativeApiInspection.test.ts +++ b/src/application/native/NativeApiInspection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { functionDossierSchema } from "../../domain/hopperValues.js"; +import { functionDossierSchema } from "../../domain/native/functionDossier.js"; import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js"; import { nativeApiBoundarySchema } from "../../domain/native/nativeApiBoundary.js"; import { projectNativeApiInspection } from "./NativeApiInspection.js"; diff --git a/src/application/native/NativeApiInspection.ts b/src/application/native/NativeApiInspection.ts index 9e1aedbc33..bf9bef0567 100644 --- a/src/application/native/NativeApiInspection.ts +++ b/src/application/native/NativeApiInspection.ts @@ -1,4 +1,4 @@ -import type { FunctionDossier } from "../../domain/hopperValues.js"; +import type { FunctionDossier } from "../../domain/native/functionDossier.js"; import { nativeApiInspectionResultSchema, type NativeApiBoundary, diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index 9c082c1966..16d1568adb 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -4,10 +4,8 @@ import type { ExecutionOptions, ProviderIdentity, } from "../AnalysisProvider.js"; -import { - parseRelatedAddresses, - referenceKindSchema, -} from "../../domain/hopperValues.js"; +import { parseRelatedAddresses } from "../../domain/native/nativeObservation.js"; +import { referenceKindSchema } from "../../domain/native/nativeReference.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../../domain/result.js"; diff --git a/src/application/native/NativeFunctionMatchingService.test.ts b/src/application/native/NativeFunctionMatchingService.test.ts index e51922d56a..fb0652c184 100644 --- a/src/application/native/NativeFunctionMatchingService.test.ts +++ b/src/application/native/NativeFunctionMatchingService.test.ts @@ -5,7 +5,7 @@ import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatchi import { createEvidence, immutableEvidence } from "../../domain/evidence.js"; import { createAnalysisProfile } from "../../domain/analysisProfile.js"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; -import { functionDossierSchema } from "../../domain/hopperValues.js"; +import { functionDossierSchema } from "../../domain/native/functionDossier.js"; import { procedureInfoSchema } from "../../domain/native/procedureInfo.js"; import { nativeMatchingNamedInventory as nativeMatchingInventory, diff --git a/src/application/native/NativeValueTrace.test.ts b/src/application/native/NativeValueTrace.test.ts index b73a2a5de4..29dc6b3f67 100644 --- a/src/application/native/NativeValueTrace.test.ts +++ b/src/application/native/NativeValueTrace.test.ts @@ -5,7 +5,7 @@ import { type AnalysisOperationPort, } from "../AnalysisProvider.js"; import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js"; -import { functionDossierSchema } from "../../domain/hopperValues.js"; +import { functionDossierSchema } from "../../domain/native/functionDossier.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js"; import { diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts index 2eb932f5c1..2f330b55d5 100644 --- a/src/application/native/NativeValueTrace.ts +++ b/src/application/native/NativeValueTrace.ts @@ -1,4 +1,4 @@ -import { functionDossierSchema } from "../../domain/hopperValues.js"; +import { functionDossierSchema } from "../../domain/native/functionDossier.js"; import { nativeCallTargetsSchema } from "../../domain/native/nativeInstruction.js"; import { nativeValueTraceInputSchema, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index dd4ee73d91..4dd8f6b25e 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,6 +1,7 @@ import { nativeAddressListSchema, nativeObservationSourceSchemas, + containingProcedureResolutionSchema, } from "../domain/native/nativeObservation.js"; import { nativeObservationSummaryViewResultSchemas, @@ -22,13 +23,14 @@ import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; import { analysisDetailOutputSchemaOf } from "./analysisDetail.js"; import { functionDossierSchema, - analysisBookmarkSchema, functionInstructionWindowSchema, +} from "../domain/native/functionDossier.js"; +import { analysisBookmarkSchema } from "../domain/native/nativeInventory.js"; +import { referenceEdgeSchema, unresolvedCallSchema, - procedureIdentitySchema, - containingProcedureResolutionSchema, -} from "../domain/hopperValues.js"; +} from "../domain/native/nativeReference.js"; +import { procedureIdentitySchema } from "../domain/native/procedureInfo.js"; import { nativeInventoryRowsSchemas } from "../domain/native/nativeInventory.js"; import { z } from "zod"; import { jsonRecordSchema } from "../domain/jsonRecord.js"; diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 3d2c7ca651..f66d02beab 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -2,7 +2,7 @@ import { procedureInfoSchema } from "../domain/native/procedureInfo.js"; import { z } from "zod"; import { utcDateTimeSchema } from "../domain/offsetDateTime.js"; -import { functionDossierSchema } from "../domain/hopperValues.js"; +import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { analysisProfileSchema } from "../domain/analysisProfile.js"; import { evidenceSchema, diff --git a/src/contracts/tupleSchemaParity.test.ts b/src/contracts/tupleSchemaParity.test.ts index e5a64adac1..170254d60a 100644 --- a/src/contracts/tupleSchemaParity.test.ts +++ b/src/contracts/tupleSchemaParity.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { packetFieldsSchema } from "../domain/captures/packetCapture.js"; -import { swiftStringLiteralSchema } from "../domain/hopperValues.js"; +import { swiftStringLiteralSchema } from "../domain/native/swiftStringLiteral.js"; import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; import { kernelModuleSchema } from "../domain/native/kernelModule.js"; import { pinvokeVerificationSchema } from "../domain/managed/managedNativeVerificationSchemas.js"; diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 334b1dc242..595fc9ff3f 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -5,7 +5,7 @@ import { } from "./analysisViewCoverage.js"; import { expect, it } from "vitest"; -import { functionDossierSchema } from "../hopperValues.js"; +import { functionDossierSchema } from "../native/functionDossier.js"; import { ghidraFunctionDossier } from "../ghidraValues.fixture.js"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js"; diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index a8e8420231..cfd24de9c0 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -195,7 +195,7 @@ import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js"; import { functionDossierSchema, functionFacetAvailabilitySchema, -} from "../hopperValues.js"; +} from "../native/functionDossier.js"; import { artifactGraphManifestSchema, artifactInventoryCoverageSchema, diff --git a/src/domain/analysisView/nativeFunctionView.ts b/src/domain/analysisView/nativeFunctionView.ts index 0ff8a98222..dea093806e 100644 --- a/src/domain/analysisView/nativeFunctionView.ts +++ b/src/domain/analysisView/nativeFunctionView.ts @@ -2,7 +2,7 @@ import { analysisViewInputError } from "./analysisViewErrors.js"; import { analysisTextPage } from "./analysisViewText.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { uniqueSorted } from "../canonicalOrdering.js"; -import type { FunctionDossier } from "../hopperValues.js"; +import type { FunctionDossier } from "../native/functionDossier.js"; import { jsonValueSchema } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import type { diff --git a/src/domain/analysisView/nativeInventoryView.ts b/src/domain/analysisView/nativeInventoryView.ts index f63a5958d0..2ef5dedccc 100644 --- a/src/domain/analysisView/nativeInventoryView.ts +++ b/src/domain/analysisView/nativeInventoryView.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { analysisStringSchema } from "../hopperValues.js"; +import { analysisStringSchema } from "../native/nativeString.js"; import { jsonValueSchema } from "../jsonValue.js"; import { addressedValueSchema, diff --git a/src/domain/analysisView/nativeObservationSchemas.ts b/src/domain/analysisView/nativeObservationSchemas.ts index 91401875db..50f42c6c99 100644 --- a/src/domain/analysisView/nativeObservationSchemas.ts +++ b/src/domain/analysisView/nativeObservationSchemas.ts @@ -1,8 +1,10 @@ -import { NATIVE_OBSERVATION_OPERATIONS } from "../native/nativeObservation.js"; +import { + NATIVE_OBSERVATION_OPERATIONS, + containingProcedureResolutionSchema, +} from "../native/nativeObservation.js"; import { z } from "zod"; import { digestSchema } from "../digests.js"; import { evidenceSchema } from "../evidence.js"; -import { containingProcedureResolutionSchema } from "../hopperValues.js"; import { completeRelationshipCoverageSchema, partialRelationshipCoverageSchema, diff --git a/src/domain/functionCallCoverage.ts b/src/domain/functionCallCoverage.ts index b172263cc3..4c377d13bd 100644 --- a/src/domain/functionCallCoverage.ts +++ b/src/domain/functionCallCoverage.ts @@ -2,7 +2,7 @@ import { relationshipCoverageLimitations, type RelationshipCoverage, } from "./relationshipCoverage.js"; -import type { FunctionDossier } from "./hopperValues.js"; +import type { FunctionDossier } from "./native/functionDossier.js"; import { uniqueSorted } from "./canonicalOrdering.js"; const callerCoveragePrefix = "Callers: "; diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 6f2f767cdb..027a448080 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -7,7 +7,7 @@ import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; import { createFunctionCoordinateContext } from "./native/functionCoordinateContext.js"; import { createAnalysisProfile } from "./analysisProfile.js"; import { createEvidence, type Evidence } from "./evidence.js"; -import { functionDossierSchema } from "./hopperValues.js"; +import { functionDossierSchema } from "./native/functionDossier.js"; import { canonicalJson } from "./comparisonSemantics.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; diff --git a/src/domain/functionDossierEvidence.ts b/src/domain/functionDossierEvidence.ts index 855836ada0..7757f3bf75 100644 --- a/src/domain/functionDossierEvidence.ts +++ b/src/domain/functionDossierEvidence.ts @@ -5,7 +5,10 @@ import { readEvidence, readEvidencePayload } from "./evidenceValidation.js"; import type { JsonValue } from "./jsonValue.js"; import { err, ok, type Result } from "./result.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js"; +import { + functionDossierSchema, + type FunctionDossier, +} from "./native/functionDossier.js"; import { assessFunctionCallCoverage } from "./functionCallCoverage.js"; /** Complete function observation parsed from one analyze_function Evidence record. */ diff --git a/src/domain/hopperValues.test.ts b/src/domain/hopperValues.test.ts deleted file mode 100644 index 9eaff335e4..0000000000 --- a/src/domain/hopperValues.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - functionBodySchema, - addressDistance, - procedureIdentitySchema, - parseDocuments, - parseListCount, - parseRelatedAddresses, - parseSegments, -} from "./hopperValues.js"; - -describe("Hopper boundary values", () => { - it("distinguishes measured zero, malformed, incomparable, reversed, and oversized extents", () => { - expect(addressDistance("not-an-address", "0x2000")).toBeNull(); - expect(addressDistance("ram:0x1000", "rom:0x2000")).toBeNull(); - expect(addressDistance("0x2000", "0x1000")).toBeNull(); - expect(addressDistance("0x1000", "0x1000")).toBe(0); - expect(addressDistance("0x1000", "0x2000")).toBe(4096); - expect(addressDistance("0x0", "0x20000000000000")).toBeNull(); - }); - - it("counts Hopper's address-keyed string maps", () => { - expect( - parseListCount({ "0x1000": "hello", "0x2000": "world" }, "strings"), - ).toEqual({ ok: true, value: 2 }); - }); - - it("parses direct and wrapped protocol shapes", () => { - expect(parseRelatedAddresses({ callers: ["0x3"] }, "callers")).toEqual({ - ok: true, - value: ["0x3"], - }); - expect( - parseSegments([ - { - name: "__TEXT", - start: "0x1", - end: "0x2", - readable: true, - writable: false, - executable: null, - }, - ]), - ).toEqual({ - ok: true, - value: [ - { - name: "__TEXT", - start: "0x1", - end: "0x2", - readable: true, - writable: false, - executable: null, - }, - ], - }); - expect(parseDocuments(["fixture"])).toEqual({ - ok: true, - value: ["fixture"], - }); - }); - - it.each([ - ["relations", () => parseRelatedAddresses([1], "callees")], - ["segments", () => parseSegments([{ name: 1 }])], - ["documents", () => parseDocuments([1])], - ])( - "rejects malformed %s instead of manufacturing empty output", - (_label, parse) => { - const result = parse(); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); - }, - ); - - it("uses provider-neutral wording for malformed shared values", () => { - const result = parseSegments([{ name: 1 }]); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error._tag).toBe("AnalysisOutputError"); - expect(result.error.operation).toBe("list_segments"); - expect(result.error.reason).toBe( - "Provider returned an invalid segment list", - ); - } - }); -}); - -describe("provider-neutral inclusive function body evidence", () => { - const observed = () => ({ - available: true, - provenance: "test-address-set", - ranges: [ - { start: "0x1000", end: "0x1002" }, - { start: "0x1010", end: "0x1011" }, - ], - total_bytes: 5, - span_bytes: 18, - non_contiguous: true, - contains_entry: true, - }); - it("rejects missing provider body metadata", () => { - expect( - procedureIdentitySchema.safeParse({ address: "0x1000", name: "main" }) - .success, - ).toBe(false); - }); - it("rejects segments when required coordinates or unknown permissions are omitted", () => { - expect( - parseSegments([ - { - name: "__TEXT", - start: "0x1", - end: "0x2", - readable: null, - writable: null, - }, - ]).ok, - ).toBe(false); - expect( - parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok, - ).toBe(false); - }); - it("counts inclusive one-byte ranges without assuming an enclosing continuous body", () => { - expect( - functionBodySchema.safeParse({ - ...observed(), - ranges: [{ start: "0x1000", end: "0x1000" }], - total_bytes: 1, - span_bytes: 1, - non_contiguous: false, - }).success, - ).toBe(true); - expect(functionBodySchema.parse(observed())).toMatchObject({ - total_bytes: 5, - span_bytes: 18, - }); - }); - it("preserves address-space identity and leaves a multi-space span unknown", () => { - expect( - functionBodySchema.safeParse({ - ...observed(), - ranges: [ - { start: "a:0x1000", end: "a:0x1000" }, - { start: "b:0x1000", end: "b:0x1001" }, - ], - total_bytes: 3, - span_bytes: null, - }).success, - ).toBe(true); - expect( - functionBodySchema.safeParse({ - ...observed(), - ranges: [{ start: "a:0x1000", end: "b:0x1000" }], - }).success, - ).toBe(false); - }); - it("allows an observed empty set without manufacturing entry membership", () => { - expect( - procedureIdentitySchema.safeParse({ - address: "0x1000", - name: "external", - classification: null, - body: { - ...observed(), - ranges: [], - total_bytes: 0, - span_bytes: 0, - non_contiguous: false, - contains_entry: false, - }, - }).success, - ).toBe(true); - }); - it("checks entry membership using address space and inclusive endpoints", () => { - expect( - procedureIdentitySchema.safeParse({ - address: "0x1002", - name: "last", - classification: null, - body: observed(), - }).success, - ).toBe(true); - expect( - procedureIdentitySchema.safeParse({ - address: "0x1003", - name: "gap", - classification: null, - body: observed(), - }).success, - ).toBe(false); - expect( - procedureIdentitySchema.safeParse({ - address: "other:0x1000", - name: "other", - classification: null, - body: observed(), - }).success, - ).toBe(false); - }); - it.each([ - { total_bytes: 18 }, - { span_bytes: 5 }, - { non_contiguous: false }, - { - ranges: [ - { start: "0x1000", end: "0x1002" }, - { start: "0x1003", end: "0x1004" }, - ], - }, - { - ranges: [ - { start: "0x1000", end: "0x1002" }, - { start: "0x1002", end: "0x1004" }, - ], - }, - { - ranges: [ - { start: "0x1010", end: "0x1011" }, - { start: "0x1000", end: "0x1002" }, - ], - }, - { - ranges: [{ start: "0x0", end: "0x20000000000000" }], - total_bytes: Number.MAX_SAFE_INTEGER, - span_bytes: Number.MAX_SAFE_INTEGER, - non_contiguous: false, - }, - ])("rejects contradictory or unsafe range facts %j", (change) => { - expect( - functionBodySchema.safeParse({ ...observed(), ...change }).success, - ).toBe(false); - }); -}); - -it.each(["procedures", "strings"])( - "counts every own entry of direct and wrapped %s maps", - (property) => { - for (const text of [ - '{"__proto__":"observed"}', - '{"__proto__":"observed","0x1000":"one"}', - ]) { - const source = JSON.parse(text); - const count = Object.keys(source).length; - expect(parseListCount(source, property)).toEqual({ - ok: true, - value: count, - }); - expect(parseListCount({ [property]: source }, property)).toEqual({ - ok: true, - value: count, - }); - expect(JSON.stringify(source)).toBe(text); - } - expect(parseListCount({ [property]: [] }, property)).toEqual({ - ok: true, - value: 0, - }); - expect(parseListCount({}, property)).toEqual({ ok: true, value: 0 }); - for (const value of [null, "not a list", 3, false]) - expect(parseListCount(value, property).ok).toBe(false); - }, -); diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts deleted file mode 100644 index 02e05e0110..0000000000 --- a/src/domain/hopperValues.ts +++ /dev/null @@ -1,616 +0,0 @@ -import { nativeSymbolMetadataSchema } from "./native/nativeSymbol.js"; -import { z } from "zod"; -import { fixedTupleSchema } from "./fixedTupleSchema.js"; -import { addressCoordinate } from "./native/addressCoordinate.js"; - -import { jsonObjectSchema, type JsonValue } from "./jsonValue.js"; - -import { err, ok, type Result } from "./result.js"; -import { nativeApiBoundarySchema } from "./native/nativeApiBoundary.js"; -import { nativeValueFlowSchema } from "./native/nativeValueFlow.js"; -import { nativeCallClassificationSchema } from "./native/nativeCallReference.js"; -import { AnalysisOutputError } from "./analysisErrorCore.js"; - -export interface AddressedName { - readonly address: string; - readonly name: string; - readonly objc_class_label?: "hopper_legacy_class_object"; -} - -export interface SegmentSummary { - readonly name: string; - readonly start: string; - readonly end: string; - readonly readable: boolean | null; - readonly writable: boolean | null; - readonly executable: boolean | null; - readonly image_base?: string; - readonly address_space?: string; -} - -/** Return a bounded byte distance between canonical addresses in one space. */ -export const addressDistance = (start: string, end: string): number | null => { - const left = addressCoordinate(start); - const right = addressCoordinate(end); - if (left === null || right === null || left.space !== right.space) - return null; - const distance = right.offset - left.offset; - if (distance < 0n || distance > BigInt(Number.MAX_SAFE_INTEGER)) return null; - return Number(distance); -}; - -const segmentSchema = z.object({ - name: z.string(), - start: z.string(), - end: z.string(), - readable: z.boolean().nullable(), - writable: z.boolean().nullable(), - executable: z.boolean().nullable(), - image_base: z.string().min(1).exactOptional(), - address_space: z.string().min(1).exactOptional(), -}); -const unavailableAnalysisFactSchema = z - .object({ available: z.literal(false), reason: z.string() }) - .strict(); -/** Typed string evidence shared by inventories and referenced function literals. */ -export const analysisStringSchema = z.object({ - address: z.string(), - value: z.string(), - provider_value: z - .string() - .exactOptional() - .describe( - "Original provider display text, which may shorten the typed object.", - ), - decoding: unavailableAnalysisFactSchema - .exactOptional() - .describe( - "When present, value retains native display text because typed bytes could not be decoded.", - ), - string: z - .object({ - encoding: z.string().min(1), - encoding_status: z - .enum(["observed", "inferred"]) - .exactOptional() - .describe( - "Whether the provider declares the encoding or REA infers it from the typed bytes and native display. This does not establish the original source's intended encoding.", - ), - termination: z.enum(["missing", "present_or_not_required"]), - byte_length: z.int().min(0), - }) - .exactOptional(), -}); - -/** An analyzed bookmark retains a missing provider label as null. */ -export const analysisBookmarkSchema = z.strictObject({ - address: z.string(), - name: z.string().nullable(), -}); -/** Complete observed function-body ranges; every range endpoint is inclusive. */ -export const availableFunctionBodySchema = z - .strictObject({ - available: z.literal(true), - provenance: z.string().min(1), - ranges: z.array( - z.strictObject({ - start: z.string(), - end: z - .string() - .describe("Inclusive last address of this observed body range."), - }), - ), - total_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER), - span_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(), - non_contiguous: z.boolean(), - contains_entry: z.boolean(), - }) - .superRefine((body, context) => { - let total = 0n; - const bounds = new Map(); - const closedSpaces = new Set(); - let previousSpace: string | null | undefined; - for (const range of body.ranges) { - const start = addressCoordinate(range.start); - const end = addressCoordinate(range.end); - if ( - start === null || - end === null || - start.space !== end.space || - end.offset < start.offset - ) { - context.addIssue({ - code: "custom", - message: "Function body range has invalid inclusive endpoints", - }); - return; - } - if (previousSpace !== undefined && previousSpace !== start.space) - closedSpaces.add(previousSpace); - const previous = bounds.get(start.space); - if ( - closedSpaces.has(start.space) || - (previous !== undefined && start.offset <= previous.end + 1n) - ) { - context.addIssue({ - code: "custom", - message: - "Function body ranges must be ordered, disjoint and maximal within each address space", - }); - return; - } - bounds.set(start.space, { - start: previous?.start ?? start.offset, - end: end.offset, - }); - previousSpace = start.space; - total += end.offset - start.offset + 1n; - } - const onlyBounds = bounds.size === 1 ? [...bounds.values()][0] : undefined; - const span = - bounds.size === 0 - ? 0n - : onlyBounds === undefined - ? null - : onlyBounds.end - onlyBounds.start + 1n; - const reportedSpan = - body.span_bytes === null ? null : BigInt(body.span_bytes); - if ( - total !== BigInt(body.total_bytes) || - span !== reportedSpan || - body.non_contiguous !== body.ranges.length > 1 - ) - context.addIssue({ - code: "custom", - message: - "Function body counts, enclosing span or contiguity disagree with inclusive ranges", - }); - }); - -export const functionBodySchema = z.discriminatedUnion("available", [ - unavailableAnalysisFactSchema, - availableFunctionBodySchema, -]); - -/** Check the declared entry membership against complete, inclusive body ranges. */ -export const functionBodyEntryAgrees = ( - body: z.infer, - address: string, -): boolean => { - if (!body.available) return true; - const entry = addressCoordinate(address); - if (entry === null) return false; - const contains = body.ranges.some((range) => { - const start = addressCoordinate(range.start); - const end = addressCoordinate(range.end); - return ( - start !== null && - end !== null && - start.space === entry.space && - entry.offset >= start.offset && - entry.offset <= end.offset - ); - }); - return contains === body.contains_entry; -}; - -export const procedureClassificationSchema = z - .object({ - external: z.boolean(), - thunk: z.boolean(), - thunk_target: z.string().nullable(), - provenance: z.string().min(1), - }) - .strict(); -export const procedureIdentitySchema = z - .object({ - address: z.string(), - name: z.string(), - symbol: nativeSymbolMetadataSchema.nullable().exactOptional(), - classification: procedureClassificationSchema.nullable(), - body: functionBodySchema, - }) - .strict() - .superRefine((identity, context) => { - if (!functionBodyEntryAgrees(identity.body, identity.address)) - context.addIssue({ - code: "custom", - message: - "Function body entry membership disagrees with procedure identity", - }); - }); -/** Exact containing-procedure outcome; valid absence is distinct from unreadable output. */ -export const containingProcedureResolutionSchema = z.discriminatedUnion( - "found", - [ - z.object({ - query_address: z.string(), - found: z.literal(true), - procedure: procedureIdentitySchema, - }), - z.object({ - query_address: z.string(), - found: z.literal(false), - procedure: z.null(), - reason: z.enum(["outside_segments", "not_in_procedure"]), - }), - ], -); -export type ContainingProcedureResolution = z.infer< - typeof containingProcedureResolutionSchema ->; - -/** Validate provider procedure facts before a workflow interprets absence. */ -export const parseContainingProcedureResolution = ( - value: JsonValue, -): Result => { - const parsed = containingProcedureResolutionSchema.safeParse(value); - return parsed.success - ? ok(parsed.data) - : err( - new AnalysisOutputError( - "resolve_containing_procedure", - "expected a containing-procedure resolution", - { cause: parsed.error }, - ), - ); -}; - -export const localVariableSchema = z - .object({ - description: z.string(), - provenance: z.string().min(1), - name: z.string().nullable().exactOptional(), - stack_displacement: z - .string() - .regex(/^-?\d+$/u) - .nullable() - .exactOptional() - .describe( - "Observed signed stack byte displacement, encoded as decimal to preserve precision.", - ), - }) - .strict(); -/** Provider-neutral complete raw-instruction list for one analyzed function. */ -export const functionInstructionWindowSchema = z - .object({ - procedure: procedureIdentitySchema, - instructions: z.array(z.string()), - limitations: z.array(z.string()), - }) - .strict(); - -export const availableReferenceKindSchema = z - .object({ - available: z.literal(true), - provenance: z.string().min(1), - type: z.string(), - flow: z.boolean(), - call: z.boolean(), - jump: z.boolean(), - data: z.boolean(), - read: z.boolean(), - write: z.boolean(), - indirect: z.boolean(), - computed: z.boolean(), - conditional: z.boolean(), - terminal: z.boolean(), - primary: z.boolean(), - operand_index: z.int(), - external: z.boolean(), - }) - .strict(); -export const referenceKindSchema = z.discriminatedUnion("available", [ - unavailableAnalysisFactSchema, - availableReferenceKindSchema, -]); -/** Reported call sites without resolved targets; unreported dispatch stays unknown. */ -export const unresolvedCallSchema = z.strictObject({ - address: z.string(), - reason: z.string(), -}); - -const swiftStringLiteralFields = { - source_address: z - .string() - .describe( - "Instruction that consumes both words: a call, tail call, return, or paired store.", - ), - value: z - .string() - .describe("Decoded UTF-8 text, including the canonical empty string."), - registers: fixedTupleSchema([z.string(), z.string()]), - words: fixedTupleSchema([z.string(), z.string()]).describe( - "Observed constant register values, in register order.", - ), -}; - -/** A Swift String literal decoded from the two words of its ABI value, not a data reference. */ -export const swiftSmallStringLiteralSchema = z.strictObject({ - ...swiftStringLiteralFields, - representation: z - .literal("small") - .describe("The UTF-8 bytes are inside the two words."), - address: z.null().describe("A small literal has no storage of its own."), -}); -export const swiftLargeStringLiteralSchema = z.strictObject({ - ...swiftStringLiteralFields, - representation: z - .literal("large") - .describe( - "An immortal literal whose words hold its count and a pointer to its bytes.", - ), - address: z.string().describe("Address of a large literal's bytes."), -}); -export const swiftStringLiteralSchema = z.discriminatedUnion("representation", [ - swiftSmallStringLiteralSchema, - swiftLargeStringLiteralSchema, -]); - -const swiftStringLiteralOmissionFields = { - source_address: swiftStringLiteralFields.source_address, - byte_length: z.int().min(0).max(Number.MAX_SAFE_INTEGER), - reason: z - .enum(["capacity", "unresolved_jump"]) - .describe( - "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 candidate omitted because decoding capacity or control flow left it unknown. */ -export const swiftSmallStringLiteralOmissionSchema = z.strictObject({ - ...swiftStringLiteralOmissionFields, - representation: z.literal("small"), - address: z.null(), -}); -export const swiftLargeStringLiteralOmissionSchema = z.strictObject({ - ...swiftStringLiteralOmissionFields, - representation: z.literal("large"), - address: z.string().describe("Address of the literal's bytes."), -}); -export const swiftStringLiteralOmissionSchema = z.discriminatedUnion( - "representation", - [ - swiftSmallStringLiteralOmissionSchema, - swiftLargeStringLiteralOmissionSchema, - ], -); - -/** Observed endpoints with complete or partial reference classification evidence. */ -export const referenceEdgeSchema = z - .object({ - source_address: z.string(), - target_address: z.string(), - source_procedure: procedureIdentitySchema.nullable(), - target_procedure: procedureIdentitySchema.nullable(), - kind: referenceKindSchema, - call: nativeCallClassificationSchema.exactOptional(), - }) - .strict(); -const facetAvailabilitySchema = z.discriminatedUnion("available", [ - z.object({ available: z.literal(true) }).strict(), - z.object({ available: z.literal(false), reason: z.string().min(1) }).strict(), -]); - -/** Provider-declared availability per dossier facet; an omitted facet declares nothing. */ -export const functionFacetAvailabilitySchema = z - .object({ - pseudocode: facetAvailabilitySchema.exactOptional(), - assembly: facetAvailabilitySchema.exactOptional(), - comments: facetAvailabilitySchema.exactOptional(), - callers: facetAvailabilitySchema.exactOptional(), - callees: facetAvailabilitySchema.exactOptional(), - references: facetAvailabilitySchema.exactOptional(), - strings_names: facetAvailabilitySchema.exactOptional(), - cfg: facetAvailabilitySchema.exactOptional(), - }) - .strict(); - -export const functionDossierSchema = z - .object({ - procedure: procedureIdentitySchema.safeExtend({ - signature: z.string().nullable(), - locals: z.array(localVariableSchema), - }), - pseudocode: z.string(), - assembly: z.array(z.string()), - comments: z.array( - z - .object({ - address: z.string(), - kind: z.enum(["comment", "inline"]), - text: z.string(), - }) - .strict(), - ), - callers: z.array(procedureIdentitySchema), - callees: z.array(procedureIdentitySchema), - incoming_references: z.array(referenceEdgeSchema), - outgoing_references: z.array(referenceEdgeSchema), - unresolved_calls: z.array(unresolvedCallSchema).exactOptional(), - referenced_strings: z.array( - analysisStringSchema.extend({ source_address: z.string() }), - ), - referenced_names: z.array( - z - .object({ - address: z.string(), - value: z.string(), - source_address: z.string(), - }) - .strict(), - ), - swift_string_literals: z - .array(swiftStringLiteralSchema) - .exactOptional() - .describe( - "Swift String literals decoded from constant register values. Absent when the provider does not decode them for this target.", - ), - swift_string_literal_omissions: z - .array(swiftStringLiteralOmissionSchema) - .exactOptional() - .describe( - "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 - .object({ - start: z.string(), - end: z.string(), - successors: z.array(z.string()), - }) - .strict(), - ), - native_api: nativeApiBoundarySchema.nullable(), - native_value_flow: nativeValueFlowSchema.nullable(), - facet_availability: functionFacetAvailabilitySchema.exactOptional(), - limitations: z.array(z.string()), - }) - .strict(); - -/** Strict analyzed-function dossier shared by provider and comparison boundaries. */ -export type FunctionDossier = z.infer; - -/** Strictly parse a complete provider-neutral function dossier. */ -export const parseFunctionDossier = ( - value: JsonValue, -): Result => { - const parsed = functionDossierSchema.safeParse(value); - return parsed.success - ? ok(parsed.data) - : err( - new AnalysisOutputError( - "analyze_function", - "provider output did not match the dossier contract", - { cause: parsed.error }, - ), - ); -}; - -/** Parse callee/caller strings from direct or wrapped provider results. */ -export const parseRelatedAddresses = ( - value: JsonValue, - relation: "callees" | "callers", - operation = relation === "callees" - ? "procedure_callees" - : "procedure_callers", -): Result => { - const parsed = z.array(z.string()).safeParse(unwrapProperty(value, relation)); - return parsed.success - ? ok(parsed.data) - : invalid(operation, `${relation} list`, parsed.error); -}; - -/** Preserve reported address context in direct or wrapped provider segments. */ -export const parseSegments = ( - value: JsonValue, - operation = "list_segments", -): Result => { - const parsed = z - .array(segmentSchema) - .safeParse(unwrapProperty(value, "segments")); - return parsed.success - ? ok(parsed.data) - : invalid(operation, "segment list", parsed.error); -}; - -/** Return a reported overview base only when every parsed segment agrees. */ -export const parseSegmentImageBase = ( - segments: readonly SegmentSummary[], -): string | undefined => { - const base = segments[0]?.image_base; - return base !== undefined && - segments.every((segment) => segment.image_base === base) - ? base - : undefined; -}; - -/** Project the same overview from complete lists or provider-owned aggregates. */ -export const projectBinaryOverview = (input: { - readonly document: string; - readonly procedure_count: number; - readonly string_count: number; - readonly segments?: readonly SegmentSummary[]; -}): JsonValue => { - const { document, procedure_count, string_count, segments } = input; - const imageBase = - segments === undefined ? undefined : parseSegmentImageBase(segments); - return { - document, - ...(imageBase === undefined ? {} : { image_base: imageBase }), - ...(segments === undefined - ? {} - : { - segments: segments.map( - ({ name, start, end, image_base, address_space }) => ({ - name, - start, - end, - length: addressDistance(start, end), - ...(image_base === undefined ? {} : { image_base }), - ...(address_space === undefined ? {} : { address_space }), - }), - ), - segment_count: segments.length, - }), - procedure_count, - string_count, - }; -}; - -/** Parse direct or wrapped provider document names. */ -export const parseDocuments = ( - value: JsonValue, - operation = "list_documents", -): Result => { - const parsed = z - .array(z.string()) - .safeParse(unwrapProperty(value, "documents")); - return parsed.success - ? ok(parsed.data) - : invalid(operation, "document list", parsed.error); -}; - -/** Parse a direct or wrapped list when only its cardinality is required. */ -export const parseListCount = ( - value: JsonValue, - property: string, - operation = property === "procedures" ? "list_procedures" : "list_strings", -): Result => { - const unwrapped = unwrapProperty(value, property); - const list = z.array(z.unknown()).safeParse(unwrapped); - if (list.success) return ok(list.data.length); - const map = jsonObjectSchema.safeParse(unwrapped); - return map.success - ? ok(Object.keys(map.data).length) - : invalid(operation, `${property} list`, map.error); -}; - -const unwrapProperty = (value: JsonValue, property: string): JsonValue => { - if ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - property in value - ) { - return value[property] ?? null; - } - return value; -}; - -const invalid = ( - operation: string, - expected: string, - cause: z.ZodError, -): Result => - err( - new AnalysisOutputError( - operation, - `Provider returned an invalid ${expected}`, - { - cause, - }, - ), - ); diff --git a/src/domain/managed/managedNativeVerificationSource.ts b/src/domain/managed/managedNativeVerificationSource.ts index b47d55f232..c49071e5bf 100644 --- a/src/domain/managed/managedNativeVerificationSource.ts +++ b/src/domain/managed/managedNativeVerificationSource.ts @@ -7,7 +7,7 @@ import { EvidenceIntegrityError } from "../evidenceErrors.js"; import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import { readEvidence, readEvidencePayload } from "../evidenceValidation.js"; import { err, ok, type Result } from "../result.js"; -import { functionDossierSchema } from "../hopperValues.js"; +import { functionDossierSchema } from "../native/functionDossier.js"; import { inspectMachoSchema } from "../native/nativeInspection.js"; import { managedNativeBoundaryInspectionSchema, diff --git a/src/domain/native/addressCoordinate.ts b/src/domain/native/addressCoordinate.ts index d43b1b4f14..1511ebc167 100644 --- a/src/domain/native/addressCoordinate.ts +++ b/src/domain/native/addressCoordinate.ts @@ -9,3 +9,14 @@ export const addressCoordinate = ( if (!/^0x[0-9a-f]+$/u.test(offset)) return null; return { space, offset: BigInt(offset) }; }; + +/** Return a bounded byte distance between canonical addresses in one space. */ +export const addressDistance = (start: string, end: string): number | null => { + const left = addressCoordinate(start); + const right = addressCoordinate(end); + if (left === null || right === null || left.space !== right.space) + return null; + const distance = right.offset - left.offset; + if (distance < 0n || distance > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(distance); +}; diff --git a/src/domain/native/functionBody.test.ts b/src/domain/native/functionBody.test.ts new file mode 100644 index 0000000000..e3a4a2019d --- /dev/null +++ b/src/domain/native/functionBody.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { functionBodySchema } from "./functionBody.js"; +import { procedureIdentitySchema } from "./procedureInfo.js"; + +describe("provider-neutral inclusive function body evidence", () => { + const observed = () => ({ + available: true, + provenance: "test-address-set", + ranges: [ + { start: "0x1000", end: "0x1002" }, + { start: "0x1010", end: "0x1011" }, + ], + total_bytes: 5, + span_bytes: 18, + non_contiguous: true, + contains_entry: true, + }); + it("rejects missing provider body metadata", () => { + expect( + procedureIdentitySchema.safeParse({ address: "0x1000", name: "main" }) + .success, + ).toBe(false); + }); + it("counts inclusive one-byte ranges without assuming an enclosing continuous body", () => { + expect( + functionBodySchema.safeParse({ + ...observed(), + ranges: [{ start: "0x1000", end: "0x1000" }], + total_bytes: 1, + span_bytes: 1, + non_contiguous: false, + }).success, + ).toBe(true); + expect(functionBodySchema.parse(observed())).toMatchObject({ + total_bytes: 5, + span_bytes: 18, + }); + }); + it("preserves address-space identity and leaves a multi-space span unknown", () => { + expect( + functionBodySchema.safeParse({ + ...observed(), + ranges: [ + { start: "a:0x1000", end: "a:0x1000" }, + { start: "b:0x1000", end: "b:0x1001" }, + ], + total_bytes: 3, + span_bytes: null, + }).success, + ).toBe(true); + expect( + functionBodySchema.safeParse({ + ...observed(), + ranges: [{ start: "a:0x1000", end: "b:0x1000" }], + }).success, + ).toBe(false); + }); + it("allows an observed empty set without manufacturing entry membership", () => { + expect( + procedureIdentitySchema.safeParse({ + address: "0x1000", + name: "external", + classification: null, + body: { + ...observed(), + ranges: [], + total_bytes: 0, + span_bytes: 0, + non_contiguous: false, + contains_entry: false, + }, + }).success, + ).toBe(true); + }); + it("checks entry membership using address space and inclusive endpoints", () => { + expect( + procedureIdentitySchema.safeParse({ + address: "0x1002", + name: "last", + classification: null, + body: observed(), + }).success, + ).toBe(true); + expect( + procedureIdentitySchema.safeParse({ + address: "0x1003", + name: "gap", + classification: null, + body: observed(), + }).success, + ).toBe(false); + expect( + procedureIdentitySchema.safeParse({ + address: "other:0x1000", + name: "other", + classification: null, + body: observed(), + }).success, + ).toBe(false); + }); + it.each([ + { total_bytes: 18 }, + { span_bytes: 5 }, + { non_contiguous: false }, + { + ranges: [ + { start: "0x1000", end: "0x1002" }, + { start: "0x1003", end: "0x1004" }, + ], + }, + { + ranges: [ + { start: "0x1000", end: "0x1002" }, + { start: "0x1002", end: "0x1004" }, + ], + }, + { + ranges: [ + { start: "0x1010", end: "0x1011" }, + { start: "0x1000", end: "0x1002" }, + ], + }, + { + ranges: [{ start: "0x0", end: "0x20000000000000" }], + total_bytes: Number.MAX_SAFE_INTEGER, + span_bytes: Number.MAX_SAFE_INTEGER, + non_contiguous: false, + }, + ])("rejects contradictory or unsafe range facts %j", (change) => { + expect( + functionBodySchema.safeParse({ ...observed(), ...change }).success, + ).toBe(false); + }); +}); diff --git a/src/domain/native/functionBody.ts b/src/domain/native/functionBody.ts new file mode 100644 index 0000000000..b2df20790b --- /dev/null +++ b/src/domain/native/functionBody.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; +import { addressCoordinate } from "./addressCoordinate.js"; +import { unavailableNativeFactSchema } from "./nativeFactAvailability.js"; + +/** Complete observed function-body ranges; every range endpoint is inclusive. */ +export const availableFunctionBodySchema = z + .strictObject({ + available: z.literal(true), + provenance: z.string().min(1), + ranges: z.array( + z.strictObject({ + start: z.string(), + end: z + .string() + .describe("Inclusive last address of this observed body range."), + }), + ), + total_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER), + span_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(), + non_contiguous: z.boolean(), + contains_entry: z.boolean(), + }) + .superRefine((body, context) => { + let total = 0n; + const bounds = new Map(); + const closedSpaces = new Set(); + let previousSpace: string | null | undefined; + for (const range of body.ranges) { + const start = addressCoordinate(range.start); + const end = addressCoordinate(range.end); + if ( + start === null || + end === null || + start.space !== end.space || + end.offset < start.offset + ) { + context.addIssue({ + code: "custom", + message: "Function body range has invalid inclusive endpoints", + }); + return; + } + if (previousSpace !== undefined && previousSpace !== start.space) + closedSpaces.add(previousSpace); + const previous = bounds.get(start.space); + if ( + closedSpaces.has(start.space) || + (previous !== undefined && start.offset <= previous.end + 1n) + ) { + context.addIssue({ + code: "custom", + message: + "Function body ranges must be ordered, disjoint and maximal within each address space", + }); + return; + } + bounds.set(start.space, { + start: previous?.start ?? start.offset, + end: end.offset, + }); + previousSpace = start.space; + total += end.offset - start.offset + 1n; + } + const onlyBounds = bounds.size === 1 ? [...bounds.values()][0] : undefined; + const span = + bounds.size === 0 + ? 0n + : onlyBounds === undefined + ? null + : onlyBounds.end - onlyBounds.start + 1n; + const reportedSpan = + body.span_bytes === null ? null : BigInt(body.span_bytes); + if ( + total !== BigInt(body.total_bytes) || + span !== reportedSpan || + body.non_contiguous !== body.ranges.length > 1 + ) + context.addIssue({ + code: "custom", + message: + "Function body counts, enclosing span or contiguity disagree with inclusive ranges", + }); + }); + +export const functionBodySchema = z.discriminatedUnion("available", [ + unavailableNativeFactSchema, + availableFunctionBodySchema, +]); + +/** Check the declared entry membership against complete, inclusive body ranges. */ +export const functionBodyEntryAgrees = ( + body: z.infer, + address: string, +): boolean => { + if (!body.available) return true; + const entry = addressCoordinate(address); + if (entry === null) return false; + const contains = body.ranges.some((range) => { + const start = addressCoordinate(range.start); + const end = addressCoordinate(range.end); + return ( + start !== null && + end !== null && + start.space === entry.space && + entry.offset >= start.offset && + entry.offset <= end.offset + ); + }); + return contains === body.contains_entry; +}; diff --git a/src/domain/native/functionByteCoverage.ts b/src/domain/native/functionByteCoverage.ts index c5f1a75ebf..8dcce92d63 100644 --- a/src/domain/native/functionByteCoverage.ts +++ b/src/domain/native/functionByteCoverage.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import type { z } from "zod"; -import type { functionBodySchema } from "../hopperValues.js"; +import type { functionBodySchema } from "../native/functionBody.js"; import type { nativeByteReadSchema } from "./nativeByteRead.js"; import { addressCoordinate } from "./addressCoordinate.js"; diff --git a/src/domain/native/functionDossier.ts b/src/domain/native/functionDossier.ts new file mode 100644 index 0000000000..7155530883 --- /dev/null +++ b/src/domain/native/functionDossier.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; +import { err, ok, type Result } from "../result.js"; +import { AnalysisOutputError } from "../analysisErrorCore.js"; +import type { JsonValue } from "../jsonValue.js"; +import { + procedureIdentitySchema, + localVariableSchema, +} from "./procedureInfo.js"; +import { analysisStringSchema } from "./nativeString.js"; +import { + referenceEdgeSchema, + unresolvedCallSchema, +} from "./nativeReference.js"; +import { + swiftStringLiteralSchema, + swiftStringLiteralOmissionSchema, +} from "./swiftStringLiteral.js"; +import { nativeApiBoundarySchema } from "./nativeApiBoundary.js"; +import { nativeValueFlowSchema } from "./nativeValueFlow.js"; + +/** Provider-neutral complete raw-instruction list for one analyzed function. */ +export const functionInstructionWindowSchema = z + .object({ + procedure: procedureIdentitySchema, + instructions: z.array(z.string()), + limitations: z.array(z.string()), + }) + .strict(); + +const facetAvailabilitySchema = z.discriminatedUnion("available", [ + z.object({ available: z.literal(true) }).strict(), + z.object({ available: z.literal(false), reason: z.string().min(1) }).strict(), +]); + +/** Provider-declared availability per dossier facet; an omitted facet declares nothing. */ +export const functionFacetAvailabilitySchema = z + .object({ + pseudocode: facetAvailabilitySchema.exactOptional(), + assembly: facetAvailabilitySchema.exactOptional(), + comments: facetAvailabilitySchema.exactOptional(), + callers: facetAvailabilitySchema.exactOptional(), + callees: facetAvailabilitySchema.exactOptional(), + references: facetAvailabilitySchema.exactOptional(), + strings_names: facetAvailabilitySchema.exactOptional(), + cfg: facetAvailabilitySchema.exactOptional(), + }) + .strict(); + +export const functionDossierSchema = z + .object({ + procedure: procedureIdentitySchema.safeExtend({ + signature: z.string().nullable(), + locals: z.array(localVariableSchema), + }), + pseudocode: z.string(), + assembly: z.array(z.string()), + comments: z.array( + z + .object({ + address: z.string(), + kind: z.enum(["comment", "inline"]), + text: z.string(), + }) + .strict(), + ), + callers: z.array(procedureIdentitySchema), + callees: z.array(procedureIdentitySchema), + incoming_references: z.array(referenceEdgeSchema), + outgoing_references: z.array(referenceEdgeSchema), + unresolved_calls: z.array(unresolvedCallSchema).exactOptional(), + referenced_strings: z.array( + analysisStringSchema.extend({ source_address: z.string() }), + ), + referenced_names: z.array( + z + .object({ + address: z.string(), + value: z.string(), + source_address: z.string(), + }) + .strict(), + ), + swift_string_literals: z + .array(swiftStringLiteralSchema) + .exactOptional() + .describe( + "Swift String literals decoded from constant register values. Absent when the provider does not decode them for this target.", + ), + swift_string_literal_omissions: z + .array(swiftStringLiteralOmissionSchema) + .exactOptional() + .describe( + "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 + .object({ + start: z.string(), + end: z.string(), + successors: z.array(z.string()), + }) + .strict(), + ), + native_api: nativeApiBoundarySchema.nullable(), + native_value_flow: nativeValueFlowSchema.nullable(), + facet_availability: functionFacetAvailabilitySchema.exactOptional(), + limitations: z.array(z.string()), + }) + .strict(); + +/** Strict analyzed-function dossier shared by provider and comparison boundaries. */ +export type FunctionDossier = z.infer; + +/** Strictly parse a complete provider-neutral function dossier. */ +export const parseFunctionDossier = ( + value: JsonValue, +): Result => { + const parsed = functionDossierSchema.safeParse(value); + return parsed.success + ? ok(parsed.data) + : err( + new AnalysisOutputError( + "analyze_function", + "provider output did not match the dossier contract", + { cause: parsed.error }, + ), + ); +}; diff --git a/src/domain/native/functionInventory.ts b/src/domain/native/functionInventory.ts index 668eb8a5b0..b43e1a1179 100644 --- a/src/domain/native/functionInventory.ts +++ b/src/domain/native/functionInventory.ts @@ -4,11 +4,11 @@ import type { Evidence } from "../evidence.js"; import { EvidenceIntegrityError } from "../evidenceErrors.js"; import { readEvidencePayload } from "../evidenceValidation.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; +import { functionDossierSchema } from "../native/functionDossier.js"; import { functionBodySchema, functionBodyEntryAgrees, - functionDossierSchema, -} from "../hopperValues.js"; +} from "../native/functionBody.js"; import { nativeSymbolOrigin } from "./nativeSymbol.js"; import { addressedValueSchema, diff --git a/src/domain/native/functionStructuralCandidate.ts b/src/domain/native/functionStructuralCandidate.ts index e7d2529316..5fcb4d7e85 100644 --- a/src/domain/native/functionStructuralCandidate.ts +++ b/src/domain/native/functionStructuralCandidate.ts @@ -1,5 +1,5 @@ import type { Evidence } from "../evidence.js"; -import type { FunctionDossier } from "../hopperValues.js"; +import type { FunctionDossier } from "../native/functionDossier.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { normalizeCfg } from "../functionComparisonNormalization.js"; import { hasStableNativeSymbol } from "./nativeSymbol.js"; diff --git a/src/domain/native/literalFeatureMatch.ts b/src/domain/native/literalFeatureMatch.ts index 0e5fc195af..26e7c5f4a3 100644 --- a/src/domain/native/literalFeatureMatch.ts +++ b/src/domain/native/literalFeatureMatch.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { analysisStringSchema } from "../hopperValues.js"; +import { analysisStringSchema } from "../native/nativeString.js"; import { addressedValueSchema } from "./nativeInventory.js"; /** Tracing reuses the search row's qualifications without reinterpreting them. */ diff --git a/src/domain/native/nativeDocument.ts b/src/domain/native/nativeDocument.ts new file mode 100644 index 0000000000..ddfe059052 --- /dev/null +++ b/src/domain/native/nativeDocument.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { ok, type Result } from "../result.js"; +import { AnalysisOutputError } from "../analysisErrorCore.js"; +import type { JsonValue } from "../jsonValue.js"; +import { + unwrapNativeProperty, + invalidNativeOutput, +} from "./nativeProviderOutput.js"; + +/** Parse direct or wrapped provider document names. */ +export const parseDocuments = ( + value: JsonValue, + operation = "list_documents", +): Result => { + const parsed = z + .array(z.string()) + .safeParse(unwrapNativeProperty(value, "documents")); + return parsed.success + ? ok(parsed.data) + : invalidNativeOutput(operation, "document list", parsed.error); +}; diff --git a/src/domain/native/nativeFactAvailability.ts b/src/domain/native/nativeFactAvailability.ts new file mode 100644 index 0000000000..19f33e09ed --- /dev/null +++ b/src/domain/native/nativeFactAvailability.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +/** An explicitly unavailable provider fact preserves its reported reason. */ +export const unavailableNativeFactSchema = z + .object({ available: z.literal(false), reason: z.string() }) + .strict(); diff --git a/src/domain/native/nativeFunctionAnnotations.ts b/src/domain/native/nativeFunctionAnnotations.ts index 046200e920..0e5d5f12b8 100644 --- a/src/domain/native/nativeFunctionAnnotations.ts +++ b/src/domain/native/nativeFunctionAnnotations.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { functionDossierSchema } from "../hopperValues.js"; +import { functionDossierSchema } from "../native/functionDossier.js"; /** One explicit function's analyst-authored metadata changes. */ export const nativeFunctionAnnotationsInputSchema = z diff --git a/src/domain/native/nativeInventory.ts b/src/domain/native/nativeInventory.ts index 8e811c784d..ef7d932730 100644 --- a/src/domain/native/nativeInventory.ts +++ b/src/domain/native/nativeInventory.ts @@ -1,6 +1,14 @@ import { z } from "zod"; import { nativeSymbolMetadataSchema } from "./nativeSymbol.js"; -import { analysisStringSchema } from "../hopperValues.js"; +import { analysisStringSchema } from "./nativeString.js"; +import { ok, type Result } from "../result.js"; +import { AnalysisOutputError } from "../analysisErrorCore.js"; +import type { JsonValue } from "../jsonValue.js"; +import { jsonObjectSchema } from "../jsonValue.js"; +import { + unwrapNativeProperty, + invalidNativeOutput, +} from "./nativeProviderOutput.js"; /** Canonical named native observations; same-address alternatives stay separate. */ export const addressedValueSchema = z.object({ @@ -49,3 +57,29 @@ export const isNativeInventoryOperation = ( operation: string, ): operation is NativeInventoryOperation => NATIVE_INVENTORY_OPERATIONS.some((supported) => supported === operation); + +export interface AddressedName { + readonly address: string; + readonly name: string; + readonly objc_class_label?: "hopper_legacy_class_object"; +} + +/** An analyzed bookmark retains a missing provider label as null. */ +export const analysisBookmarkSchema = z.strictObject({ + address: z.string(), + name: z.string().nullable(), +}); +/** Parse a direct or wrapped list when only its cardinality is required. */ +export const parseListCount = ( + value: JsonValue, + property: string, + operation = property === "procedures" ? "list_procedures" : "list_strings", +): Result => { + const unwrapped = unwrapNativeProperty(value, property); + const list = z.array(z.unknown()).safeParse(unwrapped); + if (list.success) return ok(list.data.length); + const map = jsonObjectSchema.safeParse(unwrapped); + return map.success + ? ok(Object.keys(map.data).length) + : invalidNativeOutput(operation, `${property} list`, map.error); +}; diff --git a/src/domain/native/nativeObservation.ts b/src/domain/native/nativeObservation.ts index 4ad3723217..f8b170c342 100644 --- a/src/domain/native/nativeObservation.ts +++ b/src/domain/native/nativeObservation.ts @@ -1,7 +1,51 @@ +import { + unwrapNativeProperty, + invalidNativeOutput, +} from "./nativeProviderOutput.js"; import { z } from "zod"; -import { containingProcedureResolutionSchema } from "../hopperValues.js"; +import { procedureIdentitySchema } from "./procedureInfo.js"; +import { AnalysisOutputError } from "../analysisErrorCore.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; import { nativeCallGraphSchema } from "./nativeCallGraph.js"; +/** Exact containing-procedure outcome; valid absence is distinct from unreadable output. */ +export const containingProcedureResolutionSchema = z.discriminatedUnion( + "found", + [ + z.object({ + query_address: z.string(), + found: z.literal(true), + procedure: procedureIdentitySchema, + }), + z.object({ + query_address: z.string(), + found: z.literal(false), + procedure: z.null(), + reason: z.enum(["outside_segments", "not_in_procedure"]), + }), + ], +); +export type ContainingProcedureResolution = z.infer< + typeof containingProcedureResolutionSchema +>; + +/** Validate provider procedure facts before a workflow interprets absence. */ +export const parseContainingProcedureResolution = ( + value: JsonValue, +): Result => { + const parsed = containingProcedureResolutionSchema.safeParse(value); + return parsed.success + ? ok(parsed.data) + : err( + new AnalysisOutputError( + "resolve_containing_procedure", + "expected a containing-procedure resolution", + { cause: parsed.error }, + ), + ); +}; + export const NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS = [ "procedure_address", "procedure_callees", @@ -34,3 +78,19 @@ export const isNativeObservationOperation = ( operation: string, ): operation is NativeObservationOperation => NATIVE_OBSERVATION_OPERATIONS.some((supported) => supported === operation); + +/** Parse callee/caller strings from direct or wrapped provider results. */ +export const parseRelatedAddresses = ( + value: JsonValue, + relation: "callees" | "callers", + operation = relation === "callees" + ? "procedure_callees" + : "procedure_callers", +): Result => { + const parsed = z + .array(z.string()) + .safeParse(unwrapNativeProperty(value, relation)); + return parsed.success + ? ok(parsed.data) + : invalidNativeOutput(operation, `${relation} list`, parsed.error); +}; diff --git a/src/domain/native/nativeProviderOutput.test.ts b/src/domain/native/nativeProviderOutput.test.ts new file mode 100644 index 0000000000..f315587800 --- /dev/null +++ b/src/domain/native/nativeProviderOutput.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { addressDistance } from "./addressCoordinate.js"; +import { parseDocuments } from "./nativeDocument.js"; +import { parseListCount } from "./nativeInventory.js"; +import { parseRelatedAddresses } from "./nativeObservation.js"; +import { parseSegments } from "./nativeSegments.js"; + +describe("native provider boundary values", () => { + it("distinguishes measured zero, malformed, incomparable, reversed, and oversized extents", () => { + expect(addressDistance("not-an-address", "0x2000")).toBeNull(); + expect(addressDistance("ram:0x1000", "rom:0x2000")).toBeNull(); + expect(addressDistance("0x2000", "0x1000")).toBeNull(); + expect(addressDistance("0x1000", "0x1000")).toBe(0); + expect(addressDistance("0x1000", "0x2000")).toBe(4096); + expect(addressDistance("0x0", "0x20000000000000")).toBeNull(); + }); + + it("rejects segments when required coordinates or unknown permissions are omitted", () => { + expect( + parseSegments([ + { + name: "__TEXT", + start: "0x1", + end: "0x2", + readable: null, + writable: null, + }, + ]).ok, + ).toBe(false); + expect( + parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok, + ).toBe(false); + }); + it("counts Hopper's address-keyed string maps", () => { + expect( + parseListCount({ "0x1000": "hello", "0x2000": "world" }, "strings"), + ).toEqual({ ok: true, value: 2 }); + }); + + it("parses direct and wrapped protocol shapes", () => { + expect(parseRelatedAddresses({ callers: ["0x3"] }, "callers")).toEqual({ + ok: true, + value: ["0x3"], + }); + expect( + parseSegments([ + { + name: "__TEXT", + start: "0x1", + end: "0x2", + readable: true, + writable: false, + executable: null, + }, + ]), + ).toEqual({ + ok: true, + value: [ + { + name: "__TEXT", + start: "0x1", + end: "0x2", + readable: true, + writable: false, + executable: null, + }, + ], + }); + expect(parseDocuments(["fixture"])).toEqual({ + ok: true, + value: ["fixture"], + }); + }); + + it.each([ + ["relations", () => parseRelatedAddresses([1], "callees")], + ["segments", () => parseSegments([{ name: 1 }])], + ["documents", () => parseDocuments([1])], + ])( + "rejects malformed %s instead of manufacturing empty output", + (_label, parse) => { + const result = parse(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); + }, + ); + + it("uses provider-neutral wording for malformed shared values", () => { + const result = parseSegments([{ name: 1 }]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisOutputError"); + expect(result.error.operation).toBe("list_segments"); + expect(result.error.reason).toBe( + "Provider returned an invalid segment list", + ); + } + }); +}); + +it.each(["procedures", "strings"])( + "counts every own entry of direct and wrapped %s maps", + (property) => { + for (const text of [ + '{"__proto__":"observed"}', + '{"__proto__":"observed","0x1000":"one"}', + ]) { + const source = JSON.parse(text); + const count = Object.keys(source).length; + expect(parseListCount(source, property)).toEqual({ + ok: true, + value: count, + }); + expect(parseListCount({ [property]: source }, property)).toEqual({ + ok: true, + value: count, + }); + expect(JSON.stringify(source)).toBe(text); + } + expect(parseListCount({ [property]: [] }, property)).toEqual({ + ok: true, + value: 0, + }); + expect(parseListCount({}, property)).toEqual({ ok: true, value: 0 }); + for (const value of [null, "not a list", 3, false]) + expect(parseListCount(value, property).ok).toBe(false); + }, +); diff --git a/src/domain/native/nativeProviderOutput.ts b/src/domain/native/nativeProviderOutput.ts new file mode 100644 index 0000000000..5dee5058dd --- /dev/null +++ b/src/domain/native/nativeProviderOutput.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { err, type Result } from "../result.js"; +import { AnalysisOutputError } from "../analysisErrorCore.js"; +import type { JsonValue } from "../jsonValue.js"; + +export const unwrapNativeProperty = ( + value: JsonValue, + property: string, +): JsonValue => { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + property in value + ) { + return value[property] ?? null; + } + return value; +}; + +export const invalidNativeOutput = ( + operation: string, + expected: string, + cause: z.ZodError, +): Result => + err( + new AnalysisOutputError( + operation, + `Provider returned an invalid ${expected}`, + { + cause, + }, + ), + ); diff --git a/src/domain/native/nativeReference.ts b/src/domain/native/nativeReference.ts new file mode 100644 index 0000000000..4043fe463d --- /dev/null +++ b/src/domain/native/nativeReference.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { unavailableNativeFactSchema } from "./nativeFactAvailability.js"; +import { procedureIdentitySchema } from "./procedureInfo.js"; +import { nativeCallClassificationSchema } from "./nativeCallReference.js"; + +export const availableReferenceKindSchema = z + .object({ + available: z.literal(true), + provenance: z.string().min(1), + type: z.string(), + flow: z.boolean(), + call: z.boolean(), + jump: z.boolean(), + data: z.boolean(), + read: z.boolean(), + write: z.boolean(), + indirect: z.boolean(), + computed: z.boolean(), + conditional: z.boolean(), + terminal: z.boolean(), + primary: z.boolean(), + operand_index: z.int(), + external: z.boolean(), + }) + .strict(); +export const referenceKindSchema = z.discriminatedUnion("available", [ + unavailableNativeFactSchema, + availableReferenceKindSchema, +]); +/** Reported call sites without resolved targets; unreported dispatch stays unknown. */ +export const unresolvedCallSchema = z.strictObject({ + address: z.string(), + reason: z.string(), +}); + +/** Observed endpoints with complete or partial reference classification evidence. */ +export const referenceEdgeSchema = z + .object({ + source_address: z.string(), + target_address: z.string(), + source_procedure: procedureIdentitySchema.nullable(), + target_procedure: procedureIdentitySchema.nullable(), + kind: referenceKindSchema, + call: nativeCallClassificationSchema.exactOptional(), + }) + .strict(); diff --git a/src/domain/native/nativeSegments.ts b/src/domain/native/nativeSegments.ts new file mode 100644 index 0000000000..04c9a31102 --- /dev/null +++ b/src/domain/native/nativeSegments.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { ok, type Result } from "../result.js"; +import { AnalysisOutputError } from "../analysisErrorCore.js"; +import type { JsonValue } from "../jsonValue.js"; +import { + unwrapNativeProperty, + invalidNativeOutput, +} from "./nativeProviderOutput.js"; +import { addressDistance } from "./addressCoordinate.js"; + +export interface SegmentSummary { + readonly name: string; + readonly start: string; + readonly end: string; + readonly readable: boolean | null; + readonly writable: boolean | null; + readonly executable: boolean | null; + readonly image_base?: string; + readonly address_space?: string; +} + +const segmentSchema = z.object({ + name: z.string(), + start: z.string(), + end: z.string(), + readable: z.boolean().nullable(), + writable: z.boolean().nullable(), + executable: z.boolean().nullable(), + image_base: z.string().min(1).exactOptional(), + address_space: z.string().min(1).exactOptional(), +}); +/** Preserve reported address context in direct or wrapped provider segments. */ +export const parseSegments = ( + value: JsonValue, + operation = "list_segments", +): Result => { + const parsed = z + .array(segmentSchema) + .safeParse(unwrapNativeProperty(value, "segments")); + return parsed.success + ? ok(parsed.data) + : invalidNativeOutput(operation, "segment list", parsed.error); +}; + +/** Return a reported overview base only when every parsed segment agrees. */ +export const parseSegmentImageBase = ( + segments: readonly SegmentSummary[], +): string | undefined => { + const base = segments[0]?.image_base; + return base !== undefined && + segments.every((segment) => segment.image_base === base) + ? base + : undefined; +}; + +/** Project the same overview from complete lists or provider-owned aggregates. */ +export const projectBinaryOverview = (input: { + readonly document: string; + readonly procedure_count: number; + readonly string_count: number; + readonly segments?: readonly SegmentSummary[]; +}): JsonValue => { + const { document, procedure_count, string_count, segments } = input; + const imageBase = + segments === undefined ? undefined : parseSegmentImageBase(segments); + return { + document, + ...(imageBase === undefined ? {} : { image_base: imageBase }), + ...(segments === undefined + ? {} + : { + segments: segments.map( + ({ name, start, end, image_base, address_space }) => ({ + name, + start, + end, + length: addressDistance(start, end), + ...(image_base === undefined ? {} : { image_base }), + ...(address_space === undefined ? {} : { address_space }), + }), + ), + segment_count: segments.length, + }), + procedure_count, + string_count, + }; +}; diff --git a/src/domain/native/nativeString.ts b/src/domain/native/nativeString.ts new file mode 100644 index 0000000000..d8ab3ca3bf --- /dev/null +++ b/src/domain/native/nativeString.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { unavailableNativeFactSchema } from "./nativeFactAvailability.js"; + +/** Typed string evidence shared by inventories and referenced function literals. */ +export const analysisStringSchema = z.object({ + address: z.string(), + value: z.string(), + provider_value: z + .string() + .exactOptional() + .describe( + "Original provider display text, which may shorten the typed object.", + ), + decoding: unavailableNativeFactSchema + .exactOptional() + .describe( + "When present, value retains native display text because typed bytes could not be decoded.", + ), + string: z + .object({ + encoding: z.string().min(1), + encoding_status: z + .enum(["observed", "inferred"]) + .exactOptional() + .describe( + "Whether the provider declares the encoding or REA infers it from the typed bytes and native display. This does not establish the original source's intended encoding.", + ), + termination: z.enum(["missing", "present_or_not_required"]), + byte_length: z.int().min(0), + }) + .exactOptional(), +}); diff --git a/src/domain/native/procedureInfo.ts b/src/domain/native/procedureInfo.ts index 8baf87143f..102c5fe23d 100644 --- a/src/domain/native/procedureInfo.ts +++ b/src/domain/native/procedureInfo.ts @@ -1,11 +1,47 @@ -import { nativeSymbolMetadataSchema } from "./nativeSymbol.js"; import { z } from "zod"; -import { - functionBodySchema, - localVariableSchema, - procedureClassificationSchema, -} from "../hopperValues.js"; +import { nativeSymbolMetadataSchema } from "./nativeSymbol.js"; +import { functionBodySchema, functionBodyEntryAgrees } from "./functionBody.js"; +export const procedureClassificationSchema = z + .object({ + external: z.boolean(), + thunk: z.boolean(), + thunk_target: z.string().nullable(), + provenance: z.string().min(1), + }) + .strict(); +export const procedureIdentitySchema = z + .object({ + address: z.string(), + name: z.string(), + symbol: nativeSymbolMetadataSchema.nullable().exactOptional(), + classification: procedureClassificationSchema.nullable(), + body: functionBodySchema, + }) + .strict() + .superRefine((identity, context) => { + if (!functionBodyEntryAgrees(identity.body, identity.address)) + context.addIssue({ + code: "custom", + message: + "Function body entry membership disagrees with procedure identity", + }); + }); +export const localVariableSchema = z + .object({ + description: z.string(), + provenance: z.string().min(1), + name: z.string().nullable().exactOptional(), + stack_displacement: z + .string() + .regex(/^-?\d+$/u) + .nullable() + .exactOptional() + .describe( + "Observed signed stack byte displacement, encoded as decimal to preserve precision.", + ), + }) + .strict(); /** Provider-neutral procedure metadata, including exact body ranges. */ export const procedureInfoSchema = z.object({ name: z.string(), diff --git a/src/domain/native/swiftStringLiteral.ts b/src/domain/native/swiftStringLiteral.ts new file mode 100644 index 0000000000..c8a8760a89 --- /dev/null +++ b/src/domain/native/swiftStringLiteral.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { fixedTupleSchema } from "../fixedTupleSchema.js"; + +const swiftStringLiteralFields = { + source_address: z + .string() + .describe( + "Instruction that consumes both words: a call, tail call, return, or paired store.", + ), + value: z + .string() + .describe("Decoded UTF-8 text, including the canonical empty string."), + registers: fixedTupleSchema([z.string(), z.string()]), + words: fixedTupleSchema([z.string(), z.string()]).describe( + "Observed constant register values, in register order.", + ), +}; + +/** A Swift String literal decoded from the two words of its ABI value, not a data reference. */ +export const swiftSmallStringLiteralSchema = z.strictObject({ + ...swiftStringLiteralFields, + representation: z + .literal("small") + .describe("The UTF-8 bytes are inside the two words."), + address: z.null().describe("A small literal has no storage of its own."), +}); +export const swiftLargeStringLiteralSchema = z.strictObject({ + ...swiftStringLiteralFields, + representation: z + .literal("large") + .describe( + "An immortal literal whose words hold its count and a pointer to its bytes.", + ), + address: z.string().describe("Address of a large literal's bytes."), +}); +export const swiftStringLiteralSchema = z.discriminatedUnion("representation", [ + swiftSmallStringLiteralSchema, + swiftLargeStringLiteralSchema, +]); + +const swiftStringLiteralOmissionFields = { + source_address: swiftStringLiteralFields.source_address, + byte_length: z.int().min(0).max(Number.MAX_SAFE_INTEGER), + reason: z + .enum(["capacity", "unresolved_jump"]) + .describe( + "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 candidate omitted because decoding capacity or control flow left it unknown. */ +export const swiftSmallStringLiteralOmissionSchema = z.strictObject({ + ...swiftStringLiteralOmissionFields, + representation: z.literal("small"), + address: z.null(), +}); +export const swiftLargeStringLiteralOmissionSchema = z.strictObject({ + ...swiftStringLiteralOmissionFields, + representation: z.literal("large"), + address: z.string().describe("Address of the literal's bytes."), +}); +export const swiftStringLiteralOmissionSchema = z.discriminatedUnion( + "representation", + [ + swiftSmallStringLiteralOmissionSchema, + swiftLargeStringLiteralOmissionSchema, + ], +); diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index df111467a8..a083719e2e 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -11,7 +11,7 @@ import { type EvidenceSubjectTarget, } from "./evidence.js"; import { createEvidenceBundle, parseEvidenceBundle } from "./evidenceBundle.js"; -import { functionDossierSchema } from "./hopperValues.js"; +import { functionDossierSchema } from "./native/functionDossier.js"; import { jsonObjectSchema, jsonValueSchema, diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index f23e83d868..f151b0a233 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -13,7 +13,7 @@ import { AnalysisInputError } from "./analysisErrorCore.js"; import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { analysisInputErrorFromIssues } from "./inputIssueProjection.js"; import { readEvidencePayload } from "./evidenceValidation.js"; -import { functionDossierSchema } from "./hopperValues.js"; +import { functionDossierSchema } from "./native/functionDossier.js"; import { assessFunctionCallCoverage } from "./functionCallCoverage.js"; import { processCaptureSchema } from "./process/processCapture.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; diff --git a/src/domain/staticRuntimeCorrelation.test.ts b/src/domain/staticRuntimeCorrelation.test.ts index 1fe58af428..e22bd341d2 100644 --- a/src/domain/staticRuntimeCorrelation.test.ts +++ b/src/domain/staticRuntimeCorrelation.test.ts @@ -11,7 +11,7 @@ import { createEvidence, type Evidence } from "./evidence.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "../contracts/functionComparisonExample.js"; -import { functionDossierSchema } from "./hopperValues.js"; +import { functionDossierSchema } from "./native/functionDossier.js"; import { compareFunctions } from "./functionComparison.js"; import { functionSnapshotFixture } from "./functionDossierEvidence.fixture.js"; import { diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index e3d197e0fb..e178437d1f 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -1,6 +1,6 @@ import { objcClassSymbol } from "./objcClassSymbol.js"; import type { JsonValue } from "./jsonValue.js"; -import type { AddressedName } from "./hopperValues.js"; +import type { AddressedName } from "./native/nativeInventory.js"; import type { MachoImportBinding, ObjcClassImportBindingCoverage, diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index bde8f319e8..9c33de190a 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../domain/jsonValue.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; -import { functionDossierSchema } from "../domain/hopperValues.js"; +import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { compactReference, compactInstruction, diff --git a/src/ghidra/GhidraFunctionValues.ts b/src/ghidra/GhidraFunctionValues.ts index 8f5ec281ce..dc24c22871 100644 --- a/src/ghidra/GhidraFunctionValues.ts +++ b/src/ghidra/GhidraFunctionValues.ts @@ -20,16 +20,20 @@ import { AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { - functionBodyEntryAgrees, functionInstructionWindowSchema, functionDossierSchema, +} from "../domain/native/functionDossier.js"; +import { availableReferenceKindSchema, unresolvedCallSchema, +} from "../domain/native/nativeReference.js"; +import { swiftSmallStringLiteralSchema, swiftLargeStringLiteralSchema, swiftSmallStringLiteralOmissionSchema, swiftLargeStringLiteralOmissionSchema, -} from "../domain/hopperValues.js"; +} from "../domain/native/swiftStringLiteral.js"; +import { functionBodyEntryAgrees } from "../domain/native/functionBody.js"; import { jsonObjectSchema, jsonValueSchema, diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 7002f501e8..206a451065 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -19,12 +19,12 @@ import { type AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; +import { projectBinaryOverview } from "../domain/native/nativeSegments.js"; import { availableFunctionBodySchema, - procedureIdentitySchema, functionBodyEntryAgrees, - projectBinaryOverview, -} from "../domain/hopperValues.js"; +} from "../domain/native/functionBody.js"; +import { procedureIdentitySchema } from "../domain/native/procedureInfo.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index e7b105dbc3..dde944b5c2 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -6,7 +6,7 @@ import { createTestBinarySession } from "../../tests/fixtures/binarySession.js"; import { parseConfig } from "../config/parseConfig.js"; import type { AnalysisOperation } from "../application/AnalysisProvider.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import { functionDossierSchema } from "../domain/hopperValues.js"; +import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js"; import { silentLogger } from "../logger.js"; diff --git a/src/hopper/HopperRegexSearch.ts b/src/hopper/HopperRegexSearch.ts index a0d34410fb..b49fac82dc 100644 --- a/src/hopper/HopperRegexSearch.ts +++ b/src/hopper/HopperRegexSearch.ts @@ -6,7 +6,7 @@ import { AnalysisCancelledError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; -import { analysisStringSchema } from "../domain/hopperValues.js"; +import { analysisStringSchema } from "../domain/native/nativeString.js"; import { HopperRemoteError } from "../domain/hopperErrors.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 144487efc9..9c2121df78 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -25,7 +25,7 @@ import { import { functionDossierSchema, functionInstructionWindowSchema, -} from "../domain/hopperValues.js"; +} from "../domain/native/functionDossier.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js"; diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 59908c79ff..38a8dac783 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -12,7 +12,7 @@ import { DEFAULT_REGEX_MATCHING_DEADLINE, REGEX_MATCHING_DEADLINE_SETTING, } from "../config/regexMatchingDeadline.js"; -import { functionDossierSchema } from "../domain/hopperValues.js"; +import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { AnalysisProtocolError, diff --git a/src/kuna/KunaReferenceQueries.ts b/src/kuna/KunaReferenceQueries.ts index 462293b52d..9576da3777 100644 --- a/src/kuna/KunaReferenceQueries.ts +++ b/src/kuna/KunaReferenceQueries.ts @@ -4,7 +4,7 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import type { FunctionDossier } from "../domain/hopperValues.js"; +import type { FunctionDossier } from "../domain/native/functionDossier.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index ca9ee6f86f..c2b1331ce8 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -34,7 +34,7 @@ import { import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js"; -import { functionDossierSchema } from "../domain/hopperValues.js"; +import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; diff --git a/src/kuna/KunaStringInventory.ts b/src/kuna/KunaStringInventory.ts index 83fd5ea3de..378f53d144 100644 --- a/src/kuna/KunaStringInventory.ts +++ b/src/kuna/KunaStringInventory.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import type { analysisStringSchema } from "../domain/hopperValues.js"; +import type { analysisStringSchema } from "../domain/native/nativeString.js"; import { err, ok, type Result } from "../domain/result.js"; import { compareKunaAddresses } from "./KunaAddress.js"; import { KunaJsonDocumentReader, parseKunaRow } from "./KunaJsonStream.js"; diff --git a/tests/boundary/cli/analyzeFunctionDetail.test.ts b/tests/boundary/cli/analyzeFunctionDetail.test.ts index 16d3e9891a..38b5c67a93 100644 --- a/tests/boundary/cli/analyzeFunctionDetail.test.ts +++ b/tests/boundary/cli/analyzeFunctionDetail.test.ts @@ -9,7 +9,7 @@ import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands. import type { CliInstance } from "../../../src/cli/types.js"; import { CLI_COMMANDS } from "../../../src/cliCommandNames.js"; import { createEvidence } from "../../../src/domain/evidence.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 25ecd16e2a..ba0ea9d6ed 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -26,7 +26,7 @@ import { jsonObjectSchema, type JsonValue, } from "../../../src/domain/jsonValue.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { ok } from "../../../src/domain/result.js"; diff --git a/tests/boundary/mcp/analyzeFunctionDetail.test.ts b/tests/boundary/mcp/analyzeFunctionDetail.test.ts index 1a92deffcc..7a88e84892 100644 --- a/tests/boundary/mcp/analyzeFunctionDetail.test.ts +++ b/tests/boundary/mcp/analyzeFunctionDetail.test.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { ok } from "../../../src/domain/result.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { parseGhidraFunctionInput } from "../../../src/ghidra/GhidraFunctionValues.js"; import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js"; diff --git a/tests/boundary/mcp/callPathSearchExtentMcp.test.ts b/tests/boundary/mcp/callPathSearchExtentMcp.test.ts index 9c72af6164..e859a7d647 100644 --- a/tests/boundary/mcp/callPathSearchExtentMcp.test.ts +++ b/tests/boundary/mcp/callPathSearchExtentMcp.test.ts @@ -4,7 +4,7 @@ import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; import { callPathResultSchema } from "../../../src/domain/callPathSchemas.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; diff --git a/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts b/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts index 449432e26d..e839b544c1 100644 --- a/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts +++ b/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts @@ -1,7 +1,7 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { afterEach, describe, expect, it } from "vitest"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js"; diff --git a/tests/boundary/mcp/functionCallDirectionsMcp.test.ts b/tests/boundary/mcp/functionCallDirectionsMcp.test.ts index 3084c070e1..e72f9f575e 100644 --- a/tests/boundary/mcp/functionCallDirectionsMcp.test.ts +++ b/tests/boundary/mcp/functionCallDirectionsMcp.test.ts @@ -4,7 +4,7 @@ import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; import { createEvidence } from "../../../src/domain/evidence.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; diff --git a/tests/boundary/mcp/functionComparisonCoverageMcp.test.ts b/tests/boundary/mcp/functionComparisonCoverageMcp.test.ts index 3d3295fc45..aada5039c0 100644 --- a/tests/boundary/mcp/functionComparisonCoverageMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonCoverageMcp.test.ts @@ -3,7 +3,7 @@ import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; diff --git a/tests/boundary/mcp/functionComparisonMcp.test.ts b/tests/boundary/mcp/functionComparisonMcp.test.ts index c9380f8b97..ae08b1d7c9 100644 --- a/tests/boundary/mcp/functionComparisonMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonMcp.test.ts @@ -16,7 +16,7 @@ import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { reconstructionVerificationResultSchema } from "../../../src/domain/reconstructionVerificationSchemas.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; it.each([ { leftSpace: "ram", rightSpace: "ram" }, diff --git a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts index 4e38faf6f5..86788ce923 100644 --- a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts @@ -10,7 +10,7 @@ import { } from "../../../src/domain/analysisProfile.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { reconstructionVerificationResultSchema } from "../../../src/domain/reconstructionVerificationSchemas.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; diff --git a/tests/boundary/mcp/functionComparisonStringDecodingMcp.test.ts b/tests/boundary/mcp/functionComparisonStringDecodingMcp.test.ts index 9f2aba5bef..25abaad765 100644 --- a/tests/boundary/mcp/functionComparisonStringDecodingMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonStringDecodingMcp.test.ts @@ -3,7 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { functionComparisonResultSchema, type FunctionComparisonResult as Comparison, diff --git a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts index 87050304bc..c9021f1d08 100644 --- a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts +++ b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts @@ -2,7 +2,7 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { describe, expect, it } from "vitest"; import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js"; import { ghidraFunctionDossier, diff --git a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts index a4ef3ce5b4..50bc88d184 100644 --- a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts +++ b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { nativeLoadImageSchema } from "../../../src/domain/native/nativeLoadImage.js"; import { nativeApiInspectionResultSchema } from "../../../src/domain/native/nativeApiBoundary.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { ok } from "../../../src/domain/result.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; diff --git a/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts b/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts index 8a76ea7ceb..995dd576f7 100644 --- a/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts +++ b/tests/boundary/mcp/managedNativeVerificationAdapters.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from "node:path"; import { afterAll, beforeAll, expect } from "vitest"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { MANAGED_APPLICATION_GRAPH_EXAMPLE, MANAGED_NATIVE_VERIFICATION_EXAMPLE, diff --git a/tests/boundary/mcp/nativeAddressContextMcp.test.ts b/tests/boundary/mcp/nativeAddressContextMcp.test.ts index 76a7666815..2fe4390840 100644 --- a/tests/boundary/mcp/nativeAddressContextMcp.test.ts +++ b/tests/boundary/mcp/nativeAddressContextMcp.test.ts @@ -9,7 +9,7 @@ import { import { observed as ok } from "../../fixtures/analysisExecution.js"; import { err } from "../../../src/domain/result.js"; import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; -import type { SegmentSummary } from "../../../src/domain/hopperValues.js"; +import type { SegmentSummary } from "../../../src/domain/native/nativeSegments.js"; afterEach(closeEnhancedToolResources); diff --git a/tests/boundary/mcp/officialArguments.test.ts b/tests/boundary/mcp/officialArguments.test.ts index 5af106f949..8621f13ce6 100644 --- a/tests/boundary/mcp/officialArguments.test.ts +++ b/tests/boundary/mcp/officialArguments.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { jsonValueSchema, diff --git a/tests/boundary/mcp/workflowUnknownIdentityMcp.test.ts b/tests/boundary/mcp/workflowUnknownIdentityMcp.test.ts index f1653c4ff3..01b994d605 100644 --- a/tests/boundary/mcp/workflowUnknownIdentityMcp.test.ts +++ b/tests/boundary/mcp/workflowUnknownIdentityMcp.test.ts @@ -3,7 +3,7 @@ import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; -import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; diff --git a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts index 4165b78366..fd5eb1ef8e 100644 --- a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts +++ b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { OFFICIAL_TOOL_CONTRACTS } from "../../../../src/contracts/officialToolContracts.js"; import { nativeSymbolMetadataSchema } from "../../../../src/domain/native/nativeSymbol.js"; -import { functionDossierSchema } from "../../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../../src/domain/native/functionDossier.js"; const execute = promisify(execFile); const bridgePath = new URL( diff --git a/tests/boundary/providers/ida/functionIdentity.test.ts b/tests/boundary/providers/ida/functionIdentity.test.ts index b3e317359d..237f883619 100644 --- a/tests/boundary/providers/ida/functionIdentity.test.ts +++ b/tests/boundary/providers/ida/functionIdentity.test.ts @@ -1,7 +1,7 @@ import { rm } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { functionDossierSchema } from "../../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../../src/domain/native/functionDossier.js"; import { jsonValueSchema, type JsonValue, diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index 1fc4dea7d0..3616ffed1b 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -19,7 +19,7 @@ import { } from "../../../../src/domain/managed/managedNativeVerificationSchemas.js"; import { managedNativeBoundaryInspectionSchema } from "../../../../src/domain/managed/managedArtifact.js"; import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js"; -import { functionDossierSchema } from "../../../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../../../src/domain/native/functionDossier.js"; import { verifyManagedNativeVerificationSource } from "../../../../src/domain/managed/managedNativeVerification.js"; import { readManagedNativeVerificationSource } from "../../../../src/domain/managed/managedNativeVerificationSource.js"; diff --git a/tests/fixtures/analysisView.ts b/tests/fixtures/analysisView.ts index af2e243551..64b005f532 100644 --- a/tests/fixtures/analysisView.ts +++ b/tests/fixtures/analysisView.ts @@ -21,7 +21,7 @@ import { createJavaScriptSemanticGraph } from "../../src/domain/javascript/javas import type { BinaryLayout } from "../../src/domain/native/binaryLayout.js"; import { binaryLayoutSchema } from "../../src/domain/native/binaryLayout.js"; import { ghidraFunctionDossier } from "../../src/domain/ghidraValues.fixture.js"; -import { functionDossierSchema } from "../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../src/domain/native/functionDossier.js"; import type { RelationshipCoverage } from "../../src/domain/relationshipCoverage.js"; /** Canonical portable Evidence; no native provider is started by this fixture. */ diff --git a/tests/fixtures/nativeFunctionMatching.ts b/tests/fixtures/nativeFunctionMatching.ts index bc0bf80b61..bb5ec9d303 100644 --- a/tests/fixtures/nativeFunctionMatching.ts +++ b/tests/fixtures/nativeFunctionMatching.ts @@ -2,7 +2,7 @@ import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../src/contracts/functionComparisonExample.js"; import type { NativeSymbolMetadata } from "../../src/domain/native/nativeSymbol.js"; -import { functionDossierSchema } from "../../src/domain/hopperValues.js"; +import { functionDossierSchema } from "../../src/domain/native/functionDossier.js"; /** Explicit synthetic producer facts; they do not qualify any real provider. */ export const nativeMatchingProvider = { From 030fa886f94e894e34ed2539e9d8dedff19c3327 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:43:26 +0800 Subject: [PATCH 14/29] fix(setup): verify published configuration content during uninstall Require publication readback in the owning check so in-place edits are detected and preserved with the original backup. --- .../ClientConfigurationPublication.ts | 4 +- src/application/Uninstall.ts | 8 ++-- .../clientConfigurationTransactions.test.ts | 2 +- .../setupConfigurationRollback.test.ts | 38 ++++++++++++++++++- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/application/ClientConfigurationPublication.ts b/src/application/ClientConfigurationPublication.ts index 1fb69059d9..960945bc19 100644 --- a/src/application/ClientConfigurationPublication.ts +++ b/src/application/ClientConfigurationPublication.ts @@ -169,12 +169,12 @@ export class ClientConfigurationPublication { return outcome.value; } - async assertCurrent(readback?: string): Promise { + async assertCurrent(readback: string): Promise { const owned = await this.owner.handle.stat(); const named = await lstat(this.path); if ( owned.ino === 0 || - (readback !== undefined && readback !== this.contents) || + readback !== this.contents || named.isSymbolicLink() || !sameRegularFileState(owned, named) ) diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 5e10a9e2e0..60e2bc451b 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -963,11 +963,9 @@ const removeClient = async ( try { return await publication.verify(async () => { try { - const readback = parseClientConfiguration( - await fileSystem.readText(publication.path), - client.format, - ); - await publication.assertCurrent(); + const text = await fileSystem.readText(publication.path); + await publication.assertCurrent(text); + const readback = parseClientConfiguration(text, client.format); if ( (removeNative && Object.hasOwn(readback.servers, name)) || (readback.dialect === "dsh" && diff --git a/tests/boundary/filesystem/clientConfigurationTransactions.test.ts b/tests/boundary/filesystem/clientConfigurationTransactions.test.ts index 6802427ada..4e9679596b 100644 --- a/tests/boundary/filesystem/clientConfigurationTransactions.test.ts +++ b/tests/boundary/filesystem/clientConfigurationTransactions.test.ts @@ -55,7 +55,7 @@ it { requestedPath, state }, ); await publication.verify(async () => { - await publication.assertCurrent(); + await publication.assertCurrent(await readFile(publication.path, "utf8")); expect(await readFile(selected, "utf8")).toBe("updated"); expect(await readFile(join(second, "config.json"), "utf8")).toBe( "original", diff --git a/tests/process-global/setupConfigurationRollback.test.ts b/tests/process-global/setupConfigurationRollback.test.ts index 36fc48f259..2b0ee13a47 100644 --- a/tests/process-global/setupConfigurationRollback.test.ts +++ b/tests/process-global/setupConfigurationRollback.test.ts @@ -629,6 +629,40 @@ it.each(["symlink", "regular", "valid-readback"] as const)( }, ); +it("uninstall retains concurrent edits to the published inode and reports failure", async () => { + const root = await createTestTempDirectory("rea-uninstall-inode-edit-"); + const path = join(root, "client.json"); + const fs = + await vi.importActual( + "node:fs/promises", + ); + const original = + '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":"original"}\n'; + const edited = '{"keep":"concurrent edit"}\n'; + await fs.writeFile(path, original); + state.failReadback = false; + state.afterPublication = async (publishedPath) => { + state.afterPublication = undefined; + const before = await fs.stat(publishedPath); + await fs.writeFile(publishedPath, edited); + expect((await fs.stat(publishedPath)).ino).toBe(before.ino); + }; + const host = systemUninstallHost(root); + try { + const result = await host.removeClient({ + name: "cursor", + configPath: path, + format: "json", + }); + expect(result.status).toBe("failed"); + expect(result.detail).not.toContain("The original was restored"); + expect(await fs.readFile(path, "utf8")).toBe(edited); + expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original); + } finally { + await host.close?.(); + } +}); + it("uninstall retains both readback and publication owners until cleanup retry", async () => { const root = await createTestTempDirectory("rea-uninstall-two-close-owners-"); const path = join(root, "client.json"); @@ -1394,7 +1428,9 @@ it("publication keeps its selected canonical parent when a directory symlink is "{}", original, ); - await publication.verify(() => publication.assertCurrent()); + await publication.verify(async () => + publication.assertCurrent(await fs.readFile(publication.path, "utf8")), + ); expect(await fs.readFile(join(selected, "client.json"), "utf8")).toBe("{}"); expect(await fs.readFile(join(foreign, "client.json"), "utf8")).toBe( unrelated, From 248133d784522488b01296391a0eea3eabbd7567 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:48:48 +0800 Subject: [PATCH 15/29] fix(analysis): interpret provider and ELF inputs once Pass parsed MIPS program declarations to shared-load analysis and validate IDA string address selectors before enumeration, including empty inventories. --- src/application/MipsElfMetadata.ts | 10 +++++-- src/application/MipsElfSharedLoad.ts | 13 ++------- src/domain/elfProgramHeader.ts | 5 ++++ src/ida/IdaOperationRunner.test.ts | 43 ++++++++++++++++++++++++++++ src/ida/IdaOperationRunner.ts | 11 +++---- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/application/MipsElfMetadata.ts b/src/application/MipsElfMetadata.ts index 6fde331d35..0cf6ab9ade 100644 --- a/src/application/MipsElfMetadata.ts +++ b/src/application/MipsElfMetadata.ts @@ -1,5 +1,8 @@ import { readElfSectionHeader } from "../domain/elfSectionHeader.js"; -import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; +import { + readElfProgramHeader, + type ElfProgramHeader, +} from "../domain/elfProgramHeader.js"; import { readElfRawTableDeclarations } from "../domain/elfRawTableDeclarations.js"; import type { FileHandle } from "node:fs/promises"; import type { @@ -123,7 +126,7 @@ export const readMipsElfMetadata = async ( return err("extended or reserved MIPS ELF table numbering is unsupported"); let record: Buffer | undefined; let sharedLoad: MipsElfMetadata["sharedLoad"]; - let programHeaders: Buffer | undefined; + let programHeaders: ElfProgramHeader[] | undefined; const relocationSections: MipsElfRelocationSection[] = []; const limitations: string[] = []; let gnuAttributes: MipsGnuAttributes | null = null; @@ -154,7 +157,7 @@ export const readMipsElfMetadata = async ( table.label, ); if (!contents.ok) return contents; - if (!table.optional && metadata.type === 3) programHeaders = contents.value; + if (!table.optional && metadata.type === 3) programHeaders = []; let found = false; for (let i = 0; i < count; i += 1) { checkCancelled(); @@ -172,6 +175,7 @@ export const readMipsElfMetadata = async ( ); if (!parsedRecord.ok) return err(parsedRecord.error.message); const declaration = parsedRecord.value; + if ("fileSize" in declaration) programHeaders?.push(declaration); const { type } = declaration; const declaredOffset = Number(declaration.offset); const declaredSize = Number( diff --git a/src/application/MipsElfSharedLoad.ts b/src/application/MipsElfSharedLoad.ts index b4c8756b2b..9d03e1b472 100644 --- a/src/application/MipsElfSharedLoad.ts +++ b/src/application/MipsElfSharedLoad.ts @@ -1,6 +1,6 @@ import { readElfRelocationEntry } from "../domain/elfRelocation.js"; import { readElfDynamicEntry } from "../domain/elfDynamicEntry.js"; -import { readElfProgramHeader } from "../domain/elfProgramHeader.js"; +import type { ElfProgramHeader } from "../domain/elfProgramHeader.js"; import type { MipsElfMetadata } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -23,7 +23,7 @@ type SharedLoadFacts = Extract< /** Source ELF facts only; the provider owns interpretation and admission. */ export const readMipsElfSharedLoad = async ( - programHeaders: Buffer, + programHeaders: readonly ElfProgramHeader[], little: boolean, fileSize: number, read: ReadRange, @@ -37,15 +37,8 @@ export const readMipsElfSharedLoad = async ( memorySize: number; }[] = []; let dynamic: { offset: number; address: number; size: number } | undefined; - for (let position = 0; position < programHeaders.length; position += 32) { + for (const program of programHeaders) { checkCancelled(); - const parsedProgram = readElfProgramHeader( - programHeaders.subarray(position, position + 32), - 32, - little, - ); - if (!parsedProgram.ok) return err(parsedProgram.error.message); - const program = parsedProgram.value; const { type } = program; const offset = Number(program.offset); const size = Number(program.fileSize); diff --git a/src/domain/elfProgramHeader.ts b/src/domain/elfProgramHeader.ts index 52475cb86d..b05cf9542b 100644 --- a/src/domain/elfProgramHeader.ts +++ b/src/domain/elfProgramHeader.ts @@ -1,5 +1,10 @@ import { err, ok } from "./result.js"; +export type ElfProgramHeader = Extract< + ReturnType, + { readonly ok: true } +>["value"]; + /** Fixed ELF32/ELF64 program declarations; consumers own type, precision and mapping admission. */ export const readElfProgramHeader = ( bytes: Uint8Array, diff --git a/src/ida/IdaOperationRunner.test.ts b/src/ida/IdaOperationRunner.test.ts index 11959cb15d..aacd1a9553 100644 --- a/src/ida/IdaOperationRunner.test.ts +++ b/src/ida/IdaOperationRunner.test.ts @@ -7,6 +7,49 @@ import { import { IdaOperationRunner } from "./IdaOperationRunner.js"; import type { JsonValue } from "../domain/jsonValue.js"; +it.each(["legacy", "modern", "native"] as const)( + "%s validates string address selectors independently of inventory contents", + async (protocol) => { + const { root, target } = await createIdaTarget(); + const producer = new RecordingIdaMcp(target); + const runner = new IdaOperationRunner(producer, protocol); + try { + for (const populated of [false, true]) { + const strings = populated + ? [ + { address: "0x10", string: "selected", length: 8 }, + { address: "0x20", string: "other", length: 5 }, + ] + : []; + producer.overrideCall = (tool) => { + if (tool === "list_strings") + return { data: strings, next_offset: null }; + if (tool === "find_regex") + return { + matches: strings.map(({ address, string }) => ({ + addr: address, + string, + })), + cursor: { done: true }, + }; + return undefined; + }; + const callsBeforeInvalidSelector = producer.calls.length; + await expect( + runner.run("list_strings", { address: "invalid" }), + ).rejects.toThrow(); + expect(producer.calls).toHaveLength(callsBeforeInvalidSelector); + expect(await runner.run("list_strings", { address: "0X0010" })).toEqual( + populated ? [{ address: "0x10", value: "selected" }] : [], + ); + } + } finally { + await producer.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); + it.each(["legacy", "modern", "native"] as const)( "%s procedure resolution rejects file coordinates before any symbol lookup", async (protocol) => { diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 9c2121df78..a447057362 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -661,6 +661,10 @@ export class IdaOperationRunner { return this.searchItems(operation, items, query); } if (operation === "list_strings" || operation === "search_strings") { + const selectedAddress = + query === undefined && parameters.address !== undefined + ? idaAddressSchema.parse(parameters.address) + : undefined; const nativePattern = this.protocol === "native" && query?.mode === "literal" ? nativeLiteralSearchPattern(query.pattern, query.case_sensitive) @@ -668,12 +672,9 @@ export class IdaOperationRunner { const items = await this.strings(nativePattern); return query !== undefined ? this.searchItems(operation, items, query) - : parameters.address === undefined + : selectedAddress === undefined ? items - : items.filter( - ({ address }) => - address === idaAddressSchema.parse(parameters.address), - ); + : items.filter(({ address }) => address === selectedAddress); } if (operation === "list_documents") { if (this.options.admittedDocument === undefined) From b529bd84d727fcd5d1bd91fa4428a29a96ac780e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:48:55 +0800 Subject: [PATCH 16/29] refactor(contracts): preserve named output schema types Keep exact readonly schema maps and constrain contract factories to their owned keys. Verify named catalog lookups retain each schema type. --- .../binary/AnalysisSnapshotCache.test.ts | 2 +- src/contracts/artifactToolContracts.ts | 4 +-- src/contracts/enhancedToolContracts.ts | 7 ++-- .../functionWorkflowToolContracts.ts | 2 +- src/contracts/managed/managedToolContracts.ts | 2 +- .../managed/managedWorkflowToolContracts.ts | 2 +- src/contracts/native/nativeToolContracts.ts | 7 ++-- src/contracts/officialToolContracts.ts | 7 ++-- src/contracts/sessionToolContracts.ts | 2 +- src/contracts/toolFamilies.test.ts | 32 +++++++++++++++-- src/contracts/toolOutputSchemaGroups.ts | 34 +++++++++---------- 11 files changed, 69 insertions(+), 32 deletions(-) diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 39441cf936..86640eb2aa 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -924,7 +924,7 @@ it("does not replay pre-envelope call graphs under the revised workflow profile" }), ).toBeUndefined(); expect( - enhancedOutputSchemas.get_call_graph?.safeParse({ + enhancedOutputSchemas.get_call_graph.safeParse({ normalized_result: oldResult, }).success, ).toBe(false); diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index f82da9bd89..6b299aad20 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -1,3 +1,4 @@ +import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; @@ -5,7 +6,6 @@ import { artifactOutputSchemas } from "./toolOutputSchemaGroups.js"; import { artifactIntegrityPolicySchema } from "../domain/artifactIntegrityPolicy.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { toolContractMetadata } from "./toolEffects.js"; -import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { appleAssetCatalogInputSchema } from "../domain/apple/appleAssetCatalog.js"; import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js"; import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js"; @@ -52,7 +52,7 @@ const examples: Readonly>>> = { }; const artifact = < - Name extends string, + Name extends keyof typeof artifactOutputSchemas, Schema extends z.ZodType>>, >( name: Name, diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 1bc48dab46..144828d54a 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -1,15 +1,18 @@ +import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { analysisDetailSchema } from "./analysisDetail.js"; import { z } from "zod"; import { enhancedInputSchemas } from "./enhancedInputs.js"; import { enhancedOutputSchemas } from "./toolOutputSchemaGroups.js"; -import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { examplesFor } from "./toolContractHelpers.js"; import type { ToolContract } from "./toolContractTypes.js"; import { toolContractMetadata } from "./toolEffects.js"; import { FUNCTION_WORKFLOW_TOOL_CONTRACTS } from "./functionWorkflowToolContracts.js"; -const enhanced = ( +const enhanced = < + Name extends keyof typeof enhancedOutputSchemas, + Schema extends z.ZodObject, +>( name: Name, description: string, inputSchema: Schema, diff --git a/src/contracts/functionWorkflowToolContracts.ts b/src/contracts/functionWorkflowToolContracts.ts index dd3b1c2ae7..cc360d916b 100644 --- a/src/contracts/functionWorkflowToolContracts.ts +++ b/src/contracts/functionWorkflowToolContracts.ts @@ -1,3 +1,4 @@ +import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; import { analysisDetailSummaryDefaultSchema } from "./analysisDetail.js"; import { enhancedInputSchemas } from "./enhancedInputs.js"; @@ -5,7 +6,6 @@ import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js"; import type { ToolContract } from "./toolContractTypes.js"; import { toolContractMetadata } from "./toolEffects.js"; import { enhancedOutputSchemas } from "./toolOutputSchemaGroups.js"; -import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; type FunctionWorkflowName = "analyze_function" | "inspect_native_api"; diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index 627f2db50e..2ae07e1042 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -1,9 +1,9 @@ +import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; import { z } from "zod"; import type { ToolContract } from "../toolContractTypes.js"; import { managedOutputSchemas } from "../toolOutputSchemaGroups.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; import { absoluteLocalPathStringSchema } from "../../domain/localPath.js"; import { analysisDetailSchema } from "../analysisDetail.js"; diff --git a/src/contracts/managed/managedWorkflowToolContracts.ts b/src/contracts/managed/managedWorkflowToolContracts.ts index 4db8335ae5..dff0b0c55b 100644 --- a/src/contracts/managed/managedWorkflowToolContracts.ts +++ b/src/contracts/managed/managedWorkflowToolContracts.ts @@ -1,3 +1,4 @@ +import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; import { z } from "zod"; import { evidenceSchema } from "../../domain/evidence.js"; import { compareManagedMembersInputSchema } from "../../domain/managed/managedMemberComparison.js"; @@ -13,7 +14,6 @@ import { MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE, } from "./managedWorkflowExamples.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; const managedApplicationGraphReferenceShape = z.strictObject({ managed_artifact: evidenceSchema.optional(), diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index dc64d451dd..0fde5e4420 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -1,3 +1,4 @@ +import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; import { z } from "zod"; import { nativeCallObservationInputSchema } from "../../domain/native/nativeCallObservation.js"; import { @@ -10,7 +11,6 @@ import { nativeOutputSchemas } from "../toolOutputSchemaGroups.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { localPathStringSchema } from "../../domain/localPath.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js"; import { analysisDetailSchema } from "../analysisDetail.js"; import { nativeCommandTimeoutField } from "../../domain/native/nativeDeadlines.js"; @@ -41,7 +41,10 @@ const examples: Readonly>>> = { }, }; -const native = ( +const native = < + Name extends keyof typeof nativeOutputSchemas, + Schema extends z.ZodObject, +>( name: Name, description: string, inputSchema: Schema, diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 27b87841a9..a81bc86bb5 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -1,10 +1,10 @@ +import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { analysisDetailSchema } from "./analysisDetail.js"; import { z } from "zod"; import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { nativeFunctionAnnotationsInputSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { officialOutputSchemas } from "./toolOutputSchemaGroups.js"; -import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { address, document, @@ -23,7 +23,10 @@ import { nativeSearchInput } from "../domain/native/nativeSearch.js"; const hopperRegexDeadline = "Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker. The matching deadline is REA_REGEX_MATCHING_DEADLINE_MS (a positive decimal integer of milliseconds from 1 to 2147483647, default 5000). Unset keeps the default. A deadline failure names the effective milliseconds, source (default or setting), the default, and the maximum, and leaves the API usable. The worker old-generation request is REA_REGEX_WORKER_HEAP_REQUEST_MIB (a decimal integer of mebibytes from 7 to 16384, default 128). Unset keeps the default. A positive Node --max-old-space-size, including NODE_OPTIONS, overrides that request and is not limited by the setting's floor or maximum. A heap failure names the effective request, its source (default or setting), the default, the floor, and the maximum."; -const official = ( +const official = < + Name extends keyof typeof officialOutputSchemas, + Schema extends z.ZodObject, +>( name: Name, description: string, inputSchema: Schema, diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index e5884f8643..df1b443942 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -1,3 +1,4 @@ +import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { matchNativeFunctionsInputSchema } from "./native/functionMatchingContracts.js"; import { z } from "zod"; @@ -14,7 +15,6 @@ import { import { reconstructionVerificationInputSchema } from "../domain/reconstructionVerificationSchemas.js"; import { staticRuntimeCorrelationInputSchema } from "../domain/staticRuntimeCorrelation.js"; import { sessionOutputSchemas } from "./toolOutputSchemaGroups.js"; -import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { closeBinaryInputSchema, openBundleResourcesInputSchema, diff --git a/src/contracts/toolFamilies.test.ts b/src/contracts/toolFamilies.test.ts index a10f40f105..9d639445a9 100644 --- a/src/contracts/toolFamilies.test.ts +++ b/src/contracts/toolFamilies.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { TOOL_FAMILY_DEFINITIONS, @@ -6,7 +6,35 @@ import { toolFamilyId, } from "./toolFamilies.js"; import { TOOL_FAMILY_IDS } from "./toolFamilyIds.js"; -import { TOOL_CONTRACTS } from "./toolContracts.js"; +import { TOOL_CONTRACTS, toolContract } from "./toolContracts.js"; +import type { + officialOutputSchemas, + enhancedOutputSchemas, + nativeOutputSchemas, + artifactOutputSchemas, + managedOutputSchemas, + managedWorkflowOutputSchemas, +} from "./toolOutputSchemaGroups.js"; + +// Catalog resolution must retain the exact schema owned by each named family. +expectTypeOf( + toolContract("inspect_native_instruction").outputSchema, +).toEqualTypeOf(); +expectTypeOf(toolContract("analyze_function").outputSchema).toEqualTypeOf< + typeof enhancedOutputSchemas.analyze_function +>(); +expectTypeOf(toolContract("inspect_macho").outputSchema).toEqualTypeOf< + typeof nativeOutputSchemas.inspect_macho +>(); +expectTypeOf(toolContract("inspect_artifact").outputSchema).toEqualTypeOf< + typeof artifactOutputSchemas.inspect_artifact +>(); +expectTypeOf( + toolContract("inspect_managed_artifact").outputSchema, +).toEqualTypeOf(); +expectTypeOf( + toolContract("compare_managed_members").outputSchema, +).toEqualTypeOf(); describe("tool families", () => { it("declares exactly one definition per published id", () => { diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 4dd8f6b25e..9816a89ab5 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -25,13 +25,15 @@ import { functionDossierSchema, functionInstructionWindowSchema, } from "../domain/native/functionDossier.js"; -import { analysisBookmarkSchema } from "../domain/native/nativeInventory.js"; +import { + analysisBookmarkSchema, + nativeInventoryRowsSchemas, +} from "../domain/native/nativeInventory.js"; import { referenceEdgeSchema, unresolvedCallSchema, } from "../domain/native/nativeReference.js"; import { procedureIdentitySchema } from "../domain/native/procedureInfo.js"; -import { nativeInventoryRowsSchemas } from "../domain/native/nativeInventory.js"; import { z } from "zod"; import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; @@ -134,7 +136,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [ ]); /** Exact structured-content schemas shared by direct analysis providers. */ -export const officialOutputSchemas: Readonly> = { +export const officialOutputSchemas = { search_bytes: evidenceResultOf(byteSearchResultSchema), annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema), inspect_native_load_image: analysisDetailOutputSchemaOf( @@ -252,7 +254,7 @@ export const officialOutputSchemas: Readonly> = { set_inline_comment: evidenceResultOf(z.boolean()), unset_bookmark: evidenceResultOf(z.boolean()), xrefs: evidenceResultOf(nativeAddressListSchema), -}; +} as const satisfies Readonly>; const literalTraceOutput = evidenceResultOf( z.object({ @@ -325,7 +327,7 @@ const batchProcedureIdentity = z.discriminatedUnion("status", [ ]); /** Exact structured-content schemas for composed analysis workflows. */ -export const enhancedOutputSchemas: Readonly> = { +export const enhancedOutputSchemas = { inspect_native_dispatch_metadata: evidenceResultOf( nativeDispatchMetadataResultSchema, ), @@ -432,7 +434,7 @@ export const enhancedOutputSchemas: Readonly> = { inspect_native_api: evidenceResultOf(nativeApiInspectionResultSchema), trace_feature: literalTraceOutput, trace_call_path: callPathTraceOutput, -}; +} as const satisfies Readonly>; /** Complete entropy Evidence or its retained summary, shared by the named contract. */ export const byteEntropyOutputSchema = analysisDetailOutputSchemaOf( @@ -441,7 +443,7 @@ export const byteEntropyOutputSchema = analysisDetailOutputSchemaOf( ); /** Exact Evidence schemas for provider-neutral native inspection. */ -export const nativeOutputSchemas: Readonly> = { +export const nativeOutputSchemas = { inspect_byte_entropy: byteEntropyOutputSchema, observe_native_ui: evidenceResultOf(nativeUiResultSchema), capture_native_ui_scenario: evidenceResultOf(nativeUiResultSchema), @@ -453,10 +455,10 @@ export const nativeOutputSchemas: Readonly> = { inspect_plist: evidenceResultOf(inspectPlistSchema), list_architectures: evidenceResultOf(listArchitecturesSchema), demangle_swift: evidenceResultOf(demangleSwiftSchema), -}; +} as const satisfies Readonly>; /** Exact Evidence schemas for provider-neutral artifact graph operations. */ -export const artifactOutputSchemas: Readonly> = { +export const artifactOutputSchemas = { inspect_artifact: evidenceResultOf( z.union([ artifactInspectionResultSchema, @@ -473,10 +475,10 @@ export const artifactOutputSchemas: Readonly> = { inspect_keyed_archive: evidenceResultOf(keyedArchiveResultSchema), inspect_asset_catalog: evidenceResultOf(appleAssetCatalogResultSchema), trace_dylib_resolution: evidenceResultOf(dylibResolutionResultSchema), -}; +} as const satisfies Readonly>; /** Exact Evidence schema for execution-free managed static analysis. */ -export const managedOutputSchemas: Readonly> = { +export const managedOutputSchemas = { inspect_managed_artifact: evidenceResultOf( z.union([ managedArtifactInspectionSchema, @@ -492,12 +494,10 @@ export const managedOutputSchemas: Readonly> = { inspect_managed_native_boundaries: evidenceResultOf( managedNativeBoundaryInspectionSchema, ), -}; +} as const satisfies Readonly>; /** Exact Evidence schema for provider-neutral managed workflows. */ -export const managedWorkflowOutputSchemas: Readonly< - Record -> = { +export const managedWorkflowOutputSchemas = { compare_managed_members: evidenceResultOf( managedMemberComparisonResultSchema, ), @@ -510,7 +510,7 @@ export const managedWorkflowOutputSchemas: Readonly< project_managed_application_graph: evidenceResultOf( managedApplicationGraphResultSchema, ), -}; +} as const satisfies Readonly>; const lifecycleTargetFields = { path: z.string(), @@ -677,7 +677,7 @@ export const sessionOutputSchemas = { unknown: residualUnknownSchema, }), ), -} satisfies Readonly>; +} as const satisfies Readonly>; /** Exact Evidence schema for read-only Electron fuse inspection. */ export const electronFuseOutputSchemas = { From fc7f042c8cae607a4d18016788c05dc4711413dd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:49:07 +0800 Subject: [PATCH 17/29] refactor(lifecycle): require cleanup for offline providers Require resource-owning provider ports to expose cleanup and migrate test adapters to delegate or explicitly close their own resources. --- src/application/electron/ElectronFusePort.ts | 4 ++-- .../electron/ElectronFuseService.test.ts | 2 ++ src/application/electron/ElectronFuseService.ts | 2 +- src/application/erlang/BeamPort.ts | 2 +- src/application/erlang/BeamService.test.ts | 3 +++ src/application/erlang/BeamService.ts | 2 +- src/application/go/GoBinaryPort.ts | 2 +- src/application/go/GoBinaryService.test.ts | 9 +++++++++ src/application/go/GoBinaryService.ts | 2 +- src/application/il2cpp/Il2cppMetadataPort.ts | 2 +- .../il2cpp/Il2cppMetadataService.test.ts | 14 ++++++++++++-- src/application/il2cpp/Il2cppMetadataService.ts | 2 +- src/application/python/PythonPycPort.ts | 2 +- src/application/python/PythonPycService.test.ts | 5 +++++ src/application/python/PythonPycService.ts | 2 +- src/application/rust/RustBinaryPort.ts | 2 +- src/application/rust/RustBinaryService.test.ts | 10 ++++++++++ src/application/rust/RustBinaryService.ts | 2 +- src/electron/ElectronFuseCompletion.test.ts | 1 + tests/boundary/mcp/goBinaryMcp.test.ts | 1 + .../mcp/lateAbortApplicationEvidenceMcp.test.ts | 7 +++++-- 21 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/application/electron/ElectronFusePort.ts b/src/application/electron/ElectronFusePort.ts index 16b0eb9c10..202a8b28ef 100644 --- a/src/application/electron/ElectronFusePort.ts +++ b/src/application/electron/ElectronFusePort.ts @@ -12,8 +12,8 @@ import type { Result } from "../../domain/result.js"; /** Replaceable read-only Electron fuse reader with no target-execution authority. */ export interface ElectronFusePort { readonly identity: ProviderIdentity; - /** Release owned read resources; legacy read-only ports may own none. */ - close?(): Promise; + /** Release owned read resources, retrying retained cleanup failures. */ + close(): Promise; inspect( input: InspectElectronFusesInput, options?: ExecutionOptions, diff --git a/src/application/electron/ElectronFuseService.test.ts b/src/application/electron/ElectronFuseService.test.ts index 59eeec14bc..1a501fee62 100644 --- a/src/application/electron/ElectronFuseService.test.ts +++ b/src/application/electron/ElectronFuseService.test.ts @@ -32,6 +32,7 @@ it("retains completed fuse Evidence when the provider completes after abort", as const controller = new AbortController(); const result = await new ElectronFuseService({ identity, + close: async () => {}, inspect: async () => { controller.abort(); return ok(report); @@ -58,6 +59,7 @@ it("preserves malformed fuse output ahead of late cancellation", async () => { const controller = new AbortController(); const invalid = await new ElectronFuseService({ identity, + close: async () => {}, inspect: async () => { controller.abort(); return ok(malformed); diff --git a/src/application/electron/ElectronFuseService.ts b/src/application/electron/ElectronFuseService.ts index dab580db09..7a23c862fb 100644 --- a/src/application/electron/ElectronFuseService.ts +++ b/src/application/electron/ElectronFuseService.ts @@ -20,7 +20,7 @@ export class ElectronFuseService { /** Await and retry the reader's exact retained descriptor owners. */ async close(): Promise { - await this.provider.close?.(); + await this.provider.close(); } /** Read fuse wires for one explicitly selected path without launching it. */ diff --git a/src/application/erlang/BeamPort.ts b/src/application/erlang/BeamPort.ts index bec5e2667b..aaaafe684e 100644 --- a/src/application/erlang/BeamPort.ts +++ b/src/application/erlang/BeamPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Stable read and BEAM IFF decoding, independent of a disassembler session. */ export interface BeamPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: InspectErlangBeamInput, options?: ExecutionOptions, diff --git a/src/application/erlang/BeamService.test.ts b/src/application/erlang/BeamService.test.ts index 258897f3d8..031f771436 100644 --- a/src/application/erlang/BeamService.test.ts +++ b/src/application/erlang/BeamService.test.ts @@ -31,6 +31,7 @@ const report: ErlangBeam = { it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; const service = new BeamService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -62,6 +63,7 @@ it("rejects a relative path and extra arguments before reading", async () => { it("records the selected BEAM form", async () => { const result = await new BeamService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }).inspect({ path: report.artifact.path }); @@ -78,6 +80,7 @@ it("discards an observation if the caller cancels while the provider completes", const controller = new AbortController(); const reason = { source: "caller-request", operation: "cancel" }; const service = new BeamService({ + close: async () => {}, identity, inspect: () => { controller.abort(reason); diff --git a/src/application/erlang/BeamService.ts b/src/application/erlang/BeamService.ts index bcf5e904b0..610aec6619 100644 --- a/src/application/erlang/BeamService.ts +++ b/src/application/erlang/BeamService.ts @@ -19,7 +19,7 @@ export class BeamService { constructor(readonly provider: BeamPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } async inspect( diff --git a/src/application/go/GoBinaryPort.ts b/src/application/go/GoBinaryPort.ts index 59a66777c6..a6fd60eedc 100644 --- a/src/application/go/GoBinaryPort.ts +++ b/src/application/go/GoBinaryPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline build-information reader with no target-execution authority. */ export interface GoBinaryPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: Omit, options?: ExecutionOptions, diff --git a/src/application/go/GoBinaryService.test.ts b/src/application/go/GoBinaryService.test.ts index ba7eb3deb7..efa65a4087 100644 --- a/src/application/go/GoBinaryService.test.ts +++ b/src/application/go/GoBinaryService.test.ts @@ -28,6 +28,7 @@ const report: GoBinary = { it("rejects invalid paths and extra caller arguments before invoking the reader", async () => { let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -49,6 +50,7 @@ it("rejects invalid paths and extra caller arguments before invoking the reader" it("requires a complete Evidence retention path before starting summary inspection", async () => { let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: async () => { calls++; @@ -67,6 +69,7 @@ it("requires a complete Evidence retention path before starting summary inspecti it("preserves unsupported subject architectures and does not duplicate metadata", async () => { const service = new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }); @@ -113,6 +116,7 @@ it("preserves byte-valued build metadata and validates compiler source lengths", limitations: ["Some embedded strings contain non-UTF-8 bytes."], }; const result = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(partial)), }).inspect({ path: report.artifact.path }); @@ -128,6 +132,7 @@ it("preserves byte-valued build metadata and validates compiler source lengths", }, }; const invalid = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(mismatched)), }).inspect({ path: report.artifact.path }); @@ -163,6 +168,7 @@ it("refuses reader identity changes and invalid original source ranges", async ( outside, ]) { const result = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(value)), }).inspect({ path: report.artifact.path }); @@ -176,6 +182,7 @@ it("honors cancellation before reading and after a completed observation", async const reason = { request: "late Go cancellation" }; let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -212,6 +219,7 @@ it("keeps malformed provider output ahead of a late cancellation", async () => { artifact: { ...report.artifact, path: "/other" }, }; const result = await new GoBinaryService({ + close: async () => {}, identity, inspect: () => { controller.abort(); @@ -240,6 +248,7 @@ it.each([ controller.abort(reason); let calls = 0; const service = new GoBinaryService({ + close: async () => {}, identity, inspect: async () => { calls += 1; diff --git a/src/application/go/GoBinaryService.ts b/src/application/go/GoBinaryService.ts index 536c7c59a5..6f12cdc6f8 100644 --- a/src/application/go/GoBinaryService.ts +++ b/src/application/go/GoBinaryService.ts @@ -29,7 +29,7 @@ export class GoBinaryService { constructor(readonly provider: GoBinaryPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } /** Inspect one explicitly selected file without acquiring a binary session. */ diff --git a/src/application/il2cpp/Il2cppMetadataPort.ts b/src/application/il2cpp/Il2cppMetadataPort.ts index 4de8180594..bee54c43cb 100644 --- a/src/application/il2cpp/Il2cppMetadataPort.ts +++ b/src/application/il2cpp/Il2cppMetadataPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline metadata reader with no target-execution or decryption authority. */ export interface Il2cppMetadataPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: Il2cppMetadataProviderInput, options?: ExecutionOptions, diff --git a/src/application/il2cpp/Il2cppMetadataService.test.ts b/src/application/il2cpp/Il2cppMetadataService.test.ts index 696fcc158f..3102609ffb 100644 --- a/src/application/il2cpp/Il2cppMetadataService.test.ts +++ b/src/application/il2cpp/Il2cppMetadataService.test.ts @@ -23,6 +23,7 @@ const report = createEmptyIl2cppMetadataFixture(); const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) => new Il2cppMetadataService({ + close: async () => {}, identity, inspect: () => { calls.count += 1; @@ -104,6 +105,7 @@ it("reports cancellation before and after reading", async () => { const during = new AbortController(); const service = new Il2cppMetadataService({ + close: async () => {}, identity, inspect: () => { during.abort(); @@ -158,7 +160,11 @@ it("preserves source Evidence and requested output choices when publication fail path: "/output/seeds.tsv", }); const service = new Il2cppMetadataService( - { identity, inspect: async () => ok(linkedReport()) }, + { + close: async () => {}, + identity, + inspect: async () => ok(linkedReport()), + }, seedWriter(async () => err(failure)), ); const result = await service.inspectWithGhidraSeeds(selectedPair, { @@ -192,7 +198,11 @@ it("retains a published seed receipt if cancellation arrives after the atomic wr generic_instances: null, }; const service = new Il2cppMetadataService( - { identity, inspect: async () => ok(linkedReport()) }, + { + close: async () => {}, + identity, + inspect: async () => ok(linkedReport()), + }, seedWriter(async () => { controller.abort(reason); return ok(receipt); diff --git a/src/application/il2cpp/Il2cppMetadataService.ts b/src/application/il2cpp/Il2cppMetadataService.ts index be18ef7a2f..0c4c78136b 100644 --- a/src/application/il2cpp/Il2cppMetadataService.ts +++ b/src/application/il2cpp/Il2cppMetadataService.ts @@ -44,7 +44,7 @@ export class Il2cppMetadataService { async close(): Promise { let failure: AnalysisError | undefined; for (const close of [ - () => this.provider.close?.(), + () => this.provider.close(), () => this.seeds?.close(), ]) { try { diff --git a/src/application/python/PythonPycPort.ts b/src/application/python/PythonPycPort.ts index 09a0964559..59b9231eee 100644 --- a/src/application/python/PythonPycPort.ts +++ b/src/application/python/PythonPycPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Stable read and CPython pyc decoding, independent of a disassembler session. */ export interface PythonPycPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: InspectPythonPycInput, options?: ExecutionOptions, diff --git a/src/application/python/PythonPycService.test.ts b/src/application/python/PythonPycService.test.ts index e8bba03e2e..950c4e3deb 100644 --- a/src/application/python/PythonPycService.test.ts +++ b/src/application/python/PythonPycService.test.ts @@ -69,6 +69,7 @@ const report: PythonPyc = { it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; const service = new PythonPycService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -89,6 +90,7 @@ it("rejects a relative path and extra arguments before reading", async () => { it("records the selected artifact and the header plus marshal ranges", async () => { const result = await new PythonPycService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }).inspect({ path: report.artifact.path }); @@ -110,6 +112,7 @@ it("retains completed pyc Evidence and the opaque caller reason on late cancella const controller = new AbortController(); const reason = new AnalysisInputError("opaque caller metadata"); const service = new PythonPycService({ + close: async () => {}, identity, inspect: () => { controller.abort(reason); @@ -135,6 +138,7 @@ it("retains a pre-admission cancellation reason without calling the pyc provider const reason = new Error("caller stopped before pyc admission"); let calls = 0; const service = new PythonPycService({ + close: async () => {}, identity, inspect: async () => { calls++; @@ -155,6 +159,7 @@ it("retains a pre-admission cancellation reason without calling the pyc provider it("preserves malformed provider output instead of relabeling it as a late abort", async () => { const controller = new AbortController(); const service = new PythonPycService({ + close: async () => {}, identity, inspect: async () => { controller.abort(new Error("later caller stop")); diff --git a/src/application/python/PythonPycService.ts b/src/application/python/PythonPycService.ts index 3d77369b06..c891efd5f4 100644 --- a/src/application/python/PythonPycService.ts +++ b/src/application/python/PythonPycService.ts @@ -23,7 +23,7 @@ export class PythonPycService { constructor(readonly provider: PythonPycPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } async inspect( diff --git a/src/application/rust/RustBinaryPort.ts b/src/application/rust/RustBinaryPort.ts index 9007f8cc28..18b870756f 100644 --- a/src/application/rust/RustBinaryPort.ts +++ b/src/application/rust/RustBinaryPort.ts @@ -12,7 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline dependency reader with no target-execution authority. */ export interface RustBinaryPort { readonly identity: ProviderIdentity; - close?(): Promise; + close(): Promise; inspect( input: InspectRustBinaryInput, options?: ExecutionOptions, diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts index 28704c6503..f145d9502c 100644 --- a/src/application/rust/RustBinaryService.test.ts +++ b/src/application/rust/RustBinaryService.test.ts @@ -51,6 +51,7 @@ const report: RustBinary = { it("rejects a relative path and extra arguments before reading", async () => { let calls = 0; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: () => { calls += 1; @@ -71,6 +72,7 @@ it("rejects a relative path and extra arguments before reading", async () => { it("records the selected artifact and the dependency section range", async () => { const result = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(report)), }).inspect({ path: report.artifact.path }); @@ -90,6 +92,7 @@ it("records the selected artifact and the dependency section range", async () => artifact: { ...report.artifact, path: "/artifacts/other" }, }; const invalid = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(mismatched)), }).inspect({ path: report.artifact.path }); @@ -107,6 +110,7 @@ it("rejects a cycle claim the package dependencies do not contain", async () => }, }; const result = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(invalidReport)), }).inspect({ path: report.artifact.path }); @@ -118,6 +122,7 @@ it("retains completed Rust Evidence on late cancellation and keeps output failur const controller = new AbortController(); const reason = { request: "late Rust cancellation" }; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: () => { controller.abort(reason); @@ -142,6 +147,7 @@ it("retains completed Rust Evidence on late cancellation and keeps output failur artifact: { ...report.artifact, path: "/artifacts/other" }, }; const invalid = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => { invalidController.abort(); @@ -179,6 +185,7 @@ it.each([ }, }; const result = await new RustBinaryService({ + close: async () => {}, identity, inspect: () => Promise.resolve(ok(invalidReport)), }).inspect({ path: report.artifact.path }); @@ -204,6 +211,7 @@ it.each([ controller.abort(reason); let calls = 0; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: async () => { calls += 1; @@ -226,6 +234,7 @@ it.each([ it("requires retention and rejects contradictory detail before invoking the reader", async () => { let calls = 0; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: async () => { calls++; @@ -250,6 +259,7 @@ it("requires retention and rejects contradictory detail before invoking the read it("retains the unchanged parent and keeps delivery selectors out of provider inputs", async () => { const inputs: unknown[] = []; const service = new RustBinaryService({ + close: async () => {}, identity, inspect: async (input) => { inputs.push(input); diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts index 3784f4ada7..015355a313 100644 --- a/src/application/rust/RustBinaryService.ts +++ b/src/application/rust/RustBinaryService.ts @@ -26,7 +26,7 @@ export class RustBinaryService { constructor(readonly provider: RustBinaryPort) {} close(): Promise { - return this.provider.close?.() ?? Promise.resolve(); + return this.provider.close(); } /** Inspect one explicitly selected file without acquiring a binary session. */ diff --git a/src/electron/ElectronFuseCompletion.test.ts b/src/electron/ElectronFuseCompletion.test.ts index 676c67bf72..932d8fb4d8 100644 --- a/src/electron/ElectronFuseCompletion.test.ts +++ b/src/electron/ElectronFuseCompletion.test.ts @@ -124,6 +124,7 @@ it.each([null, { caller: "already aborted" }, undefined])( const inspect = vi.fn(); const service = new ElectronFuseService({ identity: { id: "not-invoked", name: "not invoked", version: "1" }, + close: async () => {}, inspect, }); const result = await service.inspect( diff --git a/tests/boundary/mcp/goBinaryMcp.test.ts b/tests/boundary/mcp/goBinaryMcp.test.ts index 76b814ecc0..3c682480f9 100644 --- a/tests/boundary/mcp/goBinaryMcp.test.ts +++ b/tests/boundary/mcp/goBinaryMcp.test.ts @@ -65,6 +65,7 @@ mcpTest( const provider = new GoBinaryProvider(); const session = createTestBinarySession(createCacheProvider([])); const service = new GoBinaryService({ + close: () => provider.close(), identity: provider.identity, inspect: async (input, options) => { const result = await provider.inspect(input, options); diff --git a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts index 1932446510..cd43628d70 100644 --- a/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts +++ b/tests/boundary/mcp/lateAbortApplicationEvidenceMcp.test.ts @@ -1,11 +1,13 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, onTestFinished, vi } from "vitest"; -import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { + createTestBinarySession, + createDeferred, +} from "../../fixtures/binarySession.js"; import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js"; import type { GoBinary } from "../../../src/domain/go/goBinary.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; -import { createDeferred } from "../../fixtures/binarySession.js"; import { ok } from "../../../src/domain/result.js"; import type { JavaScriptRuntimeObservationPort } from "../../../src/application/javascript/JavaScriptRuntimeObservationPort.js"; import type { BrowserObservationPort } from "../../../src/application/BrowserObservationPort.js"; @@ -29,6 +31,7 @@ it("retains late-cancelled Go artifact Evidence through MCP and keeps the server throw new Error("unexpected binary admission"); }); const service = new GoBinaryService({ + close: async () => {}, identity: { id: "fixture", name: "fixture", version: "1" }, inspect: async (_input, options) => { started.resolve(); From ace4dbf36e8c8f36a0e192aff9a11484ed428358 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:53:47 +0800 Subject: [PATCH 18/29] fix(contracts): align live target classification with admitted facts Share exact live target variants across open and session responses, preserving historical Evidence and snapshot admission. Consolidate equivalent ELF metadata aliases at their owner. --- src/application/binary/BinaryTargetSummary.ts | 23 +- src/contracts/toolOutputSchemaGroups.ts | 57 +--- src/contracts/toolOutputSchemaPrimitives.ts | 29 -- src/domain/binaryTarget.ts | 6 +- src/domain/binaryTargetTypes.ts | 15 +- src/domain/liveBinaryTargetSummary.ts | 85 ++++++ src/ghidra/GhidraPowerPcProfile.test.ts | 6 +- src/ghidra/GhidraRiscvProfile.test.ts | 6 +- .../mcp/liveBinaryTargetSummary.test.ts | 262 ++++++++++++++++++ tests/conformance/ghidra/ppc-checks.d.mts | 4 +- 10 files changed, 377 insertions(+), 116 deletions(-) create mode 100644 src/domain/liveBinaryTargetSummary.ts create mode 100644 tests/boundary/mcp/liveBinaryTargetSummary.test.ts diff --git a/src/application/binary/BinaryTargetSummary.ts b/src/application/binary/BinaryTargetSummary.ts index a0a4f9e7bd..018c0af679 100644 --- a/src/application/binary/BinaryTargetSummary.ts +++ b/src/application/binary/BinaryTargetSummary.ts @@ -1,13 +1,16 @@ import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import { liveBinaryTargetSummarySchema } from "../../domain/liveBinaryTargetSummary.js"; + /** Preserve the admitted target interpretation in public lifecycle results. */ -export const binaryTargetSummary = (target: BinaryTarget) => ({ - path: target.path, - format: target.format, - kind: target.kind, - sha256: target.sha256, - architecture: target.architecture ?? null, - ...(target.format === "raw-binary" || target.format === "gamecube-dol" - ? { entry_point: target.entryPoint } - : {}), -}); +export const binaryTargetSummary = (target: BinaryTarget) => + liveBinaryTargetSummarySchema.parse({ + path: target.path, + format: target.format, + kind: target.kind, + sha256: target.sha256, + architecture: target.architecture ?? null, + ...(target.format === "raw-binary" || target.format === "gamecube-dol" + ? { entry_point: target.entryPoint } + : {}), + }); diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 9816a89ab5..63907f51a5 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -38,14 +38,7 @@ import { z } from "zod"; import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js"; -import { - BINARY_ARCHITECTURES, - RAW_ONLY_ARCHITECTURES, -} from "../domain/binaryTargetTypes.js"; -import { - binaryArchitectureSchema, - rawBinaryEntryPointSchema, -} from "../domain/rawBinary.js"; +import { liveBinaryTargetSummaryVariants } from "../domain/liveBinaryTargetSummary.js"; import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js"; import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js"; @@ -109,8 +102,6 @@ import { sessionProviderFacts, sessionStatusDetailShapes, symbolDiscoveryOutput, - targetFormatSchema, - targetKindSchema, } from "./toolOutputSchemaPrimitives.js"; import { processCaptureSchema } from "../domain/process/processCapture.js"; @@ -512,52 +503,10 @@ export const managedWorkflowOutputSchemas = { ), } as const satisfies Readonly>; -const lifecycleTargetFields = { - path: z.string(), - kind: targetKindSchema, - sha256: z.string().regex(/^[a-f0-9]{64}$/u), -}; -const detectedLifecycleTargetSchema = z.object({ - ...lifecycleTargetFields, - format: targetFormatSchema.exclude(["raw-binary", "gamecube-dol"]), - architecture: z - .enum(BINARY_ARCHITECTURES) - .exclude([...RAW_ONLY_ARCHITECTURES]) - .nullable(), -}); -const rawLifecycleTargetSchema = z.object({ - ...lifecycleTargetFields, - format: z.literal("raw-binary"), - kind: z.literal("executable"), - architecture: binaryArchitectureSchema, - entry_point: rawBinaryEntryPointSchema.describe( - "Canonical caller-selected address where raw file offset 0 is mapped and execution is seeded.", - ), -}); -const dolLifecycleTargetSchema = z.object({ - ...lifecycleTargetFields, - format: z.literal("gamecube-dol"), - kind: z.literal("executable"), - architecture: z.literal("powerpc"), - entry_point: rawBinaryEntryPointSchema.describe( - "Canonical entry address taken from the caller-selected DOL header.", - ), -}); - -/** - * Every open-target variant. open_binary and binary_session share this list so - * a target that open_binary returns is always valid in binary_session status. - */ -const openLifecycleTargetVariants = [ - detectedLifecycleTargetSchema, - rawLifecycleTargetSchema, - dolLifecycleTargetSchema, -] as const; - /** Exact structured-content schemas for target lifecycle operations. */ export const sessionOutputSchemas = { open_binary: lifecycleResultOf( - z.discriminatedUnion("format", [...openLifecycleTargetVariants]), + z.discriminatedUnion("format", [...liveBinaryTargetSummaryVariants]), ), open_bundle_resources: lifecycleResultOf(bundleResourceRootSchema), close_binary: lifecycleResultOf( @@ -595,7 +544,7 @@ export const sessionOutputSchemas = { resource_root: bundleResourceRootSchema, ...detail, }), - ...openLifecycleTargetVariants.map((variant) => + ...liveBinaryTargetSummaryVariants.map((variant) => sessionProviderFacts.extend({ open: z.literal(true), ...variant.shape, diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index f66d02beab..1b1e42c73b 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -19,7 +19,6 @@ import { PROVIDER_RETRY_ACTIONS, } from "../domain/providerOperationHealth.js"; import { analysisErrorProjectionSchema } from "./errorSchemas.js"; -import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js"; import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js"; import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "./sessionStatusContract.js"; @@ -47,34 +46,6 @@ export const requireOutputSchema = < return schema; }; -export const targetFormatSchema = z.enum([ - "hopper", - "analysis-database", - "mach-o", - "mach-o-universal", - "elf", - "pe", - "dos-mz", - "dos-com", - "raw-binary", - "gamecube-dol", - ...ZIP_PACKAGE_FORMATS, - "asar", - "godot-pck", - "dmg", - "pkg", - "plist", - "javascript", - "source-map", -]); - -export const targetKindSchema = z.enum([ - "executable", - "database", - "archive", - "artifact", -]); - const providerCapabilityFacts = { operation: z.string(), effects: z.object({ diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 35bd9faf72..acf7e8ca1e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -4,8 +4,6 @@ import type { HeaderArchitecture, MipsElfMetadata, PeCodeViewRecord, - RiscvElfMetadata, - PowerPcElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; @@ -48,13 +46,13 @@ export type ExecutableMetadata = readonly format: "elf"; readonly architecture: "riscv"; readonly availableArchitectures: readonly "riscv"[]; - readonly riscv: RiscvElfMetadata; + readonly riscv: ElfFamilyMetadata; } | { readonly format: "elf"; readonly architecture: "powerpc"; readonly availableArchitectures: readonly "powerpc"[]; - readonly powerpc: PowerPcElfMetadata; + readonly powerpc: ElfFamilyMetadata; } | { readonly format: "elf"; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index d7fb7fb940..7763b23618 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -110,15 +110,6 @@ export interface MipsGnuAttributes { readonly msa: number | null; } -/** - * ELF class and flags retain the width and ABI declarations used by this lane. - * ISA extensions in `.riscv.attributes` are not inspected by this header parser. - */ -export type RiscvElfMetadata = ElfFamilyMetadata; - -/** Raw PowerPC declarations; family identity does not select a processor variant. */ -export type PowerPcElfMetadata = ElfFamilyMetadata; - /** Byte range of the selected slice inside its universal Mach-O container. */ export interface UniversalMachOSlice { readonly offset: number; @@ -167,7 +158,8 @@ export type BinaryTarget = readonly format: "elf"; readonly architecture: "riscv"; readonly availableArchitectures: readonly "riscv"[]; - readonly riscv: RiscvElfMetadata; + /** ISA extensions in `.riscv.attributes` are not inspected by the header parser. */ + readonly riscv: ElfFamilyMetadata; readonly executableRole?: never; readonly managed?: never; }) @@ -176,7 +168,8 @@ export type BinaryTarget = readonly format: "elf"; readonly architecture: "powerpc"; readonly availableArchitectures: readonly "powerpc"[]; - readonly powerpc: PowerPcElfMetadata; + /** Family identity does not select a processor variant. */ + readonly powerpc: ElfFamilyMetadata; readonly executableRole?: never; readonly managed?: never; }) diff --git a/src/domain/liveBinaryTargetSummary.ts b/src/domain/liveBinaryTargetSummary.ts new file mode 100644 index 0000000000..633154baf9 --- /dev/null +++ b/src/domain/liveBinaryTargetSummary.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { + BINARY_ARCHITECTURES, + ELF_METADATA_ARCHITECTURES, + RAW_ONLY_ARCHITECTURES, +} from "./binaryTargetTypes.js"; +import { digestSchema } from "./digests.js"; +import { + binaryArchitectureSchema, + rawBinaryEntryPointSchema, +} from "./rawBinary.js"; +import { ZIP_PACKAGE_FORMATS } from "./zipPackageFormat.js"; + +const architectureSchema = z.enum(BINARY_ARCHITECTURES); +const headerArchitectureSchema = architectureSchema.exclude([ + ...ELF_METADATA_ARCHITECTURES, + ...RAW_ONLY_ARCHITECTURES, + "hexagon", + "arm64_32", +]); +const identity = { path: z.string(), sha256: digestSchema }; +const executable = { ...identity, kind: z.literal("executable") }; + +/** Live target classifications; historical Evidence and snapshots retain their own admission. */ +export const liveBinaryTargetSummaryVariants = [ + z.object({ + ...executable, + format: z.literal("elf"), + architecture: architectureSchema.exclude([ + ...RAW_ONLY_ARCHITECTURES, + "arm64_32", + ]), + }), + z.object({ + ...executable, + format: z.literal("mach-o"), + architecture: z.enum([...headerArchitectureSchema.options, "arm64_32"]), + }), + z.object({ + ...executable, + format: z.enum(["mach-o-universal", "pe", "dos-mz", "dos-com"]), + architecture: headerArchitectureSchema, + }), + z.object({ + ...executable, + format: z.literal("raw-binary"), + architecture: binaryArchitectureSchema, + entry_point: rawBinaryEntryPointSchema.describe( + "Canonical caller-selected address where raw file offset 0 is mapped and execution is seeded.", + ), + }), + z.object({ + ...executable, + format: z.literal("gamecube-dol"), + architecture: z.literal("powerpc"), + entry_point: rawBinaryEntryPointSchema.describe( + "Canonical entry address taken from the caller-selected DOL header.", + ), + }), + z.object({ + ...identity, + kind: z.literal("database"), + format: z.literal("analysis-database"), + architecture: z.null(), + }), + z.object({ + ...identity, + kind: z.literal("archive"), + format: z.enum([...ZIP_PACKAGE_FORMATS, "asar", "godot-pck", "dmg", "pkg"]), + architecture: z.null(), + }), + z.object({ + ...identity, + kind: z.literal("artifact"), + format: z.enum(["plist", "javascript", "source-map"]), + architecture: z.null(), + }), +] as const; + +/** Exact wire projection of the currently admitted canonical target variants. */ +export const liveBinaryTargetSummarySchema = z.discriminatedUnion( + "format", + liveBinaryTargetSummaryVariants, +); diff --git a/src/ghidra/GhidraPowerPcProfile.test.ts b/src/ghidra/GhidraPowerPcProfile.test.ts index 72fb0ac545..01f9e00779 100644 --- a/src/ghidra/GhidraPowerPcProfile.test.ts +++ b/src/ghidra/GhidraPowerPcProfile.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget, - PowerPcElfMetadata, + ElfFamilyMetadata, } from "../domain/binaryTargetTypes.js"; import { silentLogger } from "../logger.js"; import { HopperProvider } from "../hopper/HopperProvider.js"; @@ -38,7 +38,7 @@ const configFor = (environment: NodeJS.ProcessEnv) => { return parsed.value; }; const config = configFor({}); -const target = (change: Partial = {}): BinaryTarget => ({ +const target = (change: Partial = {}): BinaryTarget => ({ path: "/fixture.elf", sha256: "a".repeat(64), kind: "executable", @@ -143,7 +143,7 @@ describe("bounded Ghidra PowerPC admission", () => { [{ type: 1 }, "ET_EXEC"], [{ type: 3 }, "ET_EXEC"], [{ flags: 0x80000000 }, "EF_PPC_EMB"], - ] satisfies [Partial, string][])( + ] satisfies [Partial, string][])( "refuses an unverified PowerPC interpretation %j", async (change, reason) => { const ghidra = new GhidraProvider(config, silentLogger, {}, host); diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts index 4ae4c7e59d..31f7ed04ed 100644 --- a/src/ghidra/GhidraRiscvProfile.test.ts +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget, - RiscvElfMetadata, + ElfFamilyMetadata, } from "../domain/binaryTargetTypes.js"; import { silentLogger } from "../logger.js"; import { HopperProvider } from "../hopper/HopperProvider.js"; @@ -60,7 +60,7 @@ const configOf = ( if (!parsed.ok) throw parsed.error; return parsed.value; }; -const target = (change: Partial = {}): BinaryTarget => ({ +const target = (change: Partial = {}): BinaryTarget => ({ path: "/fixture.elf", sha256: "a".repeat(64), kind: "executable", @@ -170,7 +170,7 @@ const registerRiscvAdmissionAndCapabilityCases = () => { [{ flags: 0x7 }, "quad-float"], [{ flags: 0x8000_0005 }, "(0x80000000)"], [{ flags: 0x24 }, "bits outside the verified lane"], - ] satisfies [Partial, string][])( + ] satisfies [Partial, string][])( "refuses an unverified RISC-V interpretation %j", async (change, reason) => { const provider = ghidra(); diff --git a/tests/boundary/mcp/liveBinaryTargetSummary.test.ts b/tests/boundary/mcp/liveBinaryTargetSummary.test.ts new file mode 100644 index 0000000000..dc593ac3ec --- /dev/null +++ b/tests/boundary/mcp/liveBinaryTargetSummary.test.ts @@ -0,0 +1,262 @@ +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +import { expect, it } from "vitest"; + +import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; +import { ZIP_PACKAGE_FORMATS } from "../../../src/domain/zipPackageFormat.js"; +import { binaryTargetSummary } from "../../../src/application/binary/BinaryTargetSummary.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js"; +import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js"; + +const identity = { path: "/fixture/target", sha256: "a".repeat(64) }; +const executable = { ...identity, kind: "executable" as const }; +const elf = { + elfClass: 32 as const, + byteOrder: "little" as const, + type: 2, + flags: 0, +}; +const targets: readonly BinaryTarget[] = [ + ...(["mach-o", "elf", "dos-mz", "dos-com"] as const).map((format) => ({ + ...executable, + format, + architecture: "x86" as const, + availableArchitectures: ["x86" as const], + })), + { + ...executable, + format: "elf", + architecture: "mips", + availableArchitectures: ["mips"], + mips: elf, + }, + { + ...executable, + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: elf, + }, + { + ...executable, + format: "elf", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + powerpc: elf, + }, + { + ...executable, + format: "elf", + architecture: "hexagon", + availableArchitectures: ["hexagon"], + }, + { + ...executable, + format: "mach-o", + architecture: "arm64_32", + availableArchitectures: ["arm64_32"], + }, + { + ...executable, + format: "mach-o-universal", + architecture: "arm64", + availableArchitectures: ["arm64"], + universalSlice: { offset: 32, size: 32 }, + }, + { + ...executable, + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + managed: false, + executableRole: "application", + }, + ...(["x86", "x86_64", "arm", "arm64", "6502", "m68k"] as const).map( + (architecture) => ({ + ...executable, + format: "raw-binary" as const, + architecture, + availableArchitectures: [architecture], + entryPoint: "0x1000", + entryPointSource: "0x00001000", + }), + ), + { + ...executable, + format: "gamecube-dol", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + byteOrder: "big", + bits: 32, + entryPoint: "0x80003100", + entryPointSource: "dol-header", + dol: { + sections: [], + bss: { address: 0, size: 0 }, + bssGaps: [], + bssOverlaps: [], + entryPoint: 0x80003100, + }, + }, + { ...identity, kind: "database", format: "analysis-database" }, + ...([...ZIP_PACKAGE_FORMATS, "asar", "godot-pck", "dmg", "pkg"] as const).map( + (format) => ({ ...identity, kind: "archive" as const, format }), + ), + ...(["plist", "javascript", "source-map"] as const).map((format) => ({ + ...identity, + kind: "artifact" as const, + format, + })), +]; +const impossible = [ + { kind: "database", format: "elf", architecture: "x86" }, + { kind: "executable", format: "zip", architecture: "arm64" }, + { kind: "archive", format: "zip", architecture: "arm64" }, + { kind: "executable", format: "pe", architecture: null }, + { kind: "executable", format: "mach-o", architecture: "hexagon" }, + { kind: "executable", format: "elf", architecture: "arm64_32" }, + { kind: "executable", format: "pe", architecture: "mips" }, + { kind: "database", format: "hopper", architecture: null }, + { + kind: "executable", + format: "raw-binary", + architecture: "riscv", + entry_point: "0x1000", + }, + { kind: "executable", format: "gamecube-dol", architecture: "powerpc" }, + { + kind: "executable", + format: "gamecube-dol", + architecture: "arm", + entry_point: "0x1000", + }, +]; + +it.each(["draft-2020-12", "draft-07"] as const)( + "preserves canonical live summaries and rejects impossible classifications through %s clients", + (target) => { + const advertised = toolOutputSchemaWithMetadata( + toolContract("open_binary"), + )["~standard"].jsonSchema.output({ target }); + const validate = new AjvJsonSchemaValidator().getValidator(advertised); + for (const target of targets) { + const summary = binaryTargetSummary(target); + expect(summary).toEqual({ + ...identity, + kind: target.kind, + format: target.format, + architecture: target.architecture ?? null, + ...(target.format === "raw-binary" || target.format === "gamecube-dol" + ? { entry_point: target.entryPoint } + : {}), + }); + expect( + sessionOutputSchemas.open_binary.safeParse({ result: summary }).success, + ).toBe(true); + expect(validate({ result: summary }).valid).toBe(true); + } + for (const classification of impossible) { + const result = { ...identity, ...classification }; + expect( + sessionOutputSchemas.open_binary.safeParse({ result }).success, + ).toBe(false); + expect(validate({ result }).valid).toBe(false); + } + }, +); + +const sessionFacts = { + open: true, + cleanup_pending: false, + capabilities: [], + analysis_run: null, + analysis_activity: { status: "not_observed", providers: [] }, + provider_operation_health: null, + analysis_provider_binding: null, + analysis_provider_candidates: [], + client_features: { + elicitation_form: false, + elicitation_url: false, + roots: false, + sampling: false, + }, + server_identity: { + package: { + name: "rea", + version: "fixture", + root_path: "/fixture", + build_commit: null, + }, + server: { + name: "rea", + version: "fixture", + started_at: "2026-01-01T00:00:00Z", + command_path: null, + }, + sdk: { server: "fixture", client_test: "fixture", core: "fixture" }, + negotiated_protocol_version: null, + client: null, + skill: { name: "rea", expected_version: "fixture" }, + catalog: { + counts: { cli_commands: 0, mcp_tools: 0, mcp_prompts: 0 }, + digests: { + tools_sha256: identity.sha256, + prompts_sha256: identity.sha256, + combined_sha256: identity.sha256, + }, + }, + protocol_features: { + progress: false, + cancellation: false, + elicitation: false, + }, + alignment: { state: "unknown", reasons: [], remediation: null }, + }, + detail: "complete", + tool_availability: { + client_requirements: { + required: [], + optional: [], + missing_required: [], + missing_optional: [], + }, + groups: [], + }, +}; + +it.each(["draft-2020-12", "draft-07"] as const)( + "shares live classification admission with binary_session in %s", + (target) => { + const advertised = toolOutputSchemaWithMetadata( + toolContract("binary_session"), + )["~standard"].jsonSchema.output({ target }); + const validate = new AjvJsonSchemaValidator().getValidator(advertised); + // One representative per distinct projection shape; the complete target matrix is above. + for (const format of [ + "elf", + "raw-binary", + "gamecube-dol", + "zip", + ] as const) { + const selected = targets.find((candidate) => candidate.format === format); + if (selected === undefined) + throw new Error(`Missing canonical fixture: ${format}`); + const value = { + result: { ...sessionFacts, ...binaryTargetSummary(selected) }, + }; + expect(sessionOutputSchemas.binary_session.safeParse(value).success).toBe( + true, + ); + expect(validate(value).valid).toBe(true); + } + for (const classification of impossible.slice(0, 4)) { + const value = { + result: { ...sessionFacts, ...identity, ...classification }, + }; + expect(sessionOutputSchemas.binary_session.safeParse(value).success).toBe( + false, + ); + expect(validate(value).valid).toBe(false); + } + }, +); diff --git a/tests/conformance/ghidra/ppc-checks.d.mts b/tests/conformance/ghidra/ppc-checks.d.mts index 38609de454..ef27c3ddd0 100644 --- a/tests/conformance/ghidra/ppc-checks.d.mts +++ b/tests/conformance/ghidra/ppc-checks.d.mts @@ -1,7 +1,7 @@ -import type { PowerPcElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; +import type { ElfFamilyMetadata } from "../../../src/domain/binaryTargetTypes.js"; export interface PowerPcReadelfFacts { - powerpc: PowerPcElfMetadata; + powerpc: ElfFamilyMetadata; symbols: Record< | "rea_ppc_entry" | "rea_ppc_leaf" From 2dd2ffcd4534e19a03db0f2e591663fe92ce5f42 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:03:02 +0800 Subject: [PATCH 19/29] fix(artifacts): bind operations to admitted sources --- .../binary/BinarySessionExecution.ts | 1 + src/artifacts/ArtifactProvider.ts | 8 + src/artifacts/ArtifactSelectedSource.ts | 33 +++ src/artifacts/AsarArtifactReader.ts | 8 +- .../extraction/ArtifactExtraction.ts | 36 ++- src/artifacts/inventory/classify.ts | 78 ++++- src/artifacts/inventory/types.ts | 2 + .../artifactAdmittedIdentityMcp.test.ts | 66 +++++ .../artifactAdmittedIdentity.test.ts | 272 ++++++++++++++++++ .../artifactProvider.archiveInventory.test.ts | 23 +- .../native/artifactProvider.capacity.test.ts | 15 +- .../artifactProvider.cleanupRetry.test.ts | 30 +- .../artifactProvider.directoryErrors.test.ts | 82 +++--- ...rtifactProvider.directoryInventory.test.ts | 29 +- .../artifactProvider.extraction.test.ts | 41 +-- ...tifactProvider.extractionAdmission.test.ts | 30 +- ...actProvider.extractionCompleteness.test.ts | 39 ++- .../artifactProvider.extractionFormat.test.ts | 104 ++++--- ...rtifactProvider.extractionIdentity.test.ts | 15 +- ...tifactProvider.universalExtraction.test.ts | 30 +- 20 files changed, 704 insertions(+), 238 deletions(-) create mode 100644 src/artifacts/ArtifactSelectedSource.ts create mode 100644 tests/acceptance/artifactAdmittedIdentityMcp.test.ts create mode 100644 tests/boundary/filesystem/artifactAdmittedIdentity.test.ts diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index d0c7f3f0c3..37c1f2551b 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -158,6 +158,7 @@ const isArtifactBundleSubject = ( subject: EvidenceSubjectTarget, target: BinaryTarget, ): boolean => + target.bundleInfoPlist !== undefined && (operation === "inventory_artifact" || operation === "inspect_artifact" || operation === "extract_artifact") && diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 79ac6138af..ca5ac153fd 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -311,6 +311,10 @@ class ArtifactClient implements AnalysisClient { const result = await extractArtifact( { inputPath: this.target.sourcePath ?? this.target.path, + expectedRootSha256: + this.target.bundleInfoPlist === undefined + ? this.target.sha256 + : undefined, selectedPath: this.target.artifactSelectedPath, inputFormat: this.target.format, outputRoot: parsed.output_root, @@ -506,6 +510,10 @@ class ArtifactClient implements AnalysisClient { options?: ExecutionOptions, ) { return inventoryArtifact(this.target.sourcePath ?? this.target.path, { + expectedRootSha256: + this.target.bundleInfoPlist === undefined + ? this.target.sha256 + : undefined, selectedPath: this.target.artifactSelectedPath, resourceScope: this.#resourceScope, environment: this.environment, diff --git a/src/artifacts/ArtifactSelectedSource.ts b/src/artifacts/ArtifactSelectedSource.ts new file mode 100644 index 0000000000..fa79e6a5c6 --- /dev/null +++ b/src/artifacts/ArtifactSelectedSource.ts @@ -0,0 +1,33 @@ +import { realpath } from "node:fs/promises"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; + +/** Verify selection spelling independently of canonical descriptor ownership. */ +export const assertArtifactSelectedSource = async ( + canonicalPath: string, + selectedPath: string | undefined, +): Promise => { + if (selectedPath === undefined) return; + const changed = (cause?: unknown) => + new ArtifactReaderFailure( + "integrity", + `Selected artifact no longer resolves to its admitted source: ${selectedPath}`, + { + userMessage: + "The selected artifact path no longer names the opened file. Reopen the selected artifact, then retry.", + ...(cause === undefined ? {} : { cause }), + }, + ); + let current: string; + try { + current = await realpath(selectedPath); + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code)) + ) + throw changed(cause); + throw cause; + } + if (current !== canonicalPath) throw changed(); +}; diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index bda90dd089..a0e5a6a8d0 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -1,3 +1,4 @@ +import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js"; import { appendCallerPath } from "../filesystem/callerPath.js"; import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js"; @@ -437,12 +438,7 @@ export class AsarArtifactReader implements ArtifactReader { // The packed pathname is fixed for this reader; member checks only need // to re-observe the caller's independently selected alias. this.#selectedCanonicalSource ??= await realpath(this.path); - const current = await realpath(selected); - if (this.#selectedCanonicalSource !== current) - throw new ArtifactReaderFailure( - "integrity", - `Selected ASAR archive no longer resolves to its admitted source: ${selected}`, - ); + await assertArtifactSelectedSource(this.#selectedCanonicalSource, selected); } async #openUnpackedStream( diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 2191297b1b..047dd9956b 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -1,3 +1,6 @@ +import { assertAdmittedRootDigest } from "../inventory/classify.js"; +import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; +import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js"; import { absoluteCallerPath } from "../../filesystem/callerPath.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { @@ -56,7 +59,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity export interface ArtifactExtractionInput { readonly asarHeaderAdmission?: AsarHeaderAdmission; readonly inputPath: string; - /** Selected ASAR spelling; packed I/O remains on inputPath. */ + readonly expectedRootSha256?: string | undefined; + /** Selected artifact spelling; packed I/O remains on inputPath. */ readonly selectedPath?: string | undefined; readonly inputFormat: BinaryTarget["format"]; readonly outputRoot: string; @@ -79,6 +83,8 @@ const extractArtifactInScope = async ( // Cancellation wins over every later refusal, including unsupported formats. abortIfNeeded(signal); const sourcePath = await realpath(input.inputPath); + if (input.expectedRootSha256 !== undefined) + await assertArtifactSelectedSource(sourcePath, input.selectedPath); const admission = await requireExtractableFormat(sourcePath, input, signal); const { snapshot, zipSource } = await scanArtifactInventoryForExtractionInScope( @@ -86,6 +92,7 @@ const extractArtifactInScope = async ( { resourceScope: input.resourceScope, selectedPath: input.selectedPath, + expectedRootSha256: input.expectedRootSha256, signal, environment: input.environment, asarHeaderAdmission: @@ -176,7 +183,7 @@ export const materializeArtifactInventoryInScope = async ( ) throw new AnalysisUnsupportedTargetError( "extract_artifact", - input.inputPath, + input.selectedPath ?? input.inputPath, `Godot PCK directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`, ); const selectedOccurrences = snapshot.occurrences.filter( @@ -218,7 +225,7 @@ export const materializeArtifactInventoryInScope = async ( if (occurrence.encrypted) throw new AnalysisUnsupportedTargetError( "extract_artifact", - input.inputPath, + input.selectedPath ?? input.inputPath, `Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`, ); // Inventory retains unavailable source occurrences, but extraction cannot @@ -377,6 +384,8 @@ const materializeSelection = async ({ traversal.snapshot(), reader.provenance(), ); + if (input.expectedRootSha256 !== undefined) + await assertArtifactSelectedSource(sourcePath, input.selectedPath); await output.commit(signal); return completed; } catch (cause: unknown) { @@ -550,7 +559,11 @@ const requireExtractableFormat = async ( path: string, input: Pick< ArtifactExtractionInput, - "inputPath" | "inputFormat" | "resourceScope" + | "inputPath" + | "selectedPath" + | "inputFormat" + | "resourceScope" + | "expectedRootSha256" >, signal?: AbortSignal, previous?: ArtifactEmbeddedFormatProbeFacts, @@ -591,13 +604,26 @@ const requireExtractableFormat = async ( ((format === "elf" || format === "pe" || format === "mach-o") && ((await probes.nodeSea()) !== undefined || (await probes.pyInstaller()))); + if (pack && input.expectedRootSha256 !== undefined) { + const digest = await hashStableRootArtifactHandle( + path, + source.handle, + source.initial, + signal, + ); + assertAdmittedRootDigest( + path, + input.expectedRootSha256, + digest.sha256, + ); + } return { pack, probeFacts: probes.completedFacts() }; }, ); if (!detected.pack) throw new AnalysisUnsupportedTargetError( "extract_artifact", - input.inputPath, + input.selectedPath ?? input.inputPath, `Artifact format has no extraction reader: ${format}`, ); return { directory, probeFacts: detected.probeFacts }; diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 8a31fea340..55ab3a3171 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; @@ -65,7 +66,11 @@ export const classifyAndHashRootForInventory = async ( expectedMetadata: Stats, options: Pick< ArtifactInventoryOptions, - "resourceScope" | "signal" | "asarHeaderAdmission" | "selectedPath" + | "resourceScope" + | "signal" + | "asarHeaderAdmission" + | "selectedPath" + | "expectedRootSha256" >, probeFacts?: ArtifactEmbeddedFormatProbeFacts, ): Promise => { @@ -76,6 +81,15 @@ export const classifyAndHashRootForInventory = async ( `Root artifact changed before inventory: ${path}`, ); } + if (directory && options.expectedRootSha256 !== undefined) + throw new ArtifactReaderFailure( + "integrity", + `Artifact root changed after target admission: expected an admitted file at ${path}, observed a directory`, + { + userMessage: + "The opened file was replaced by a directory. Select the intended artifact and reopen it before retrying.", + }, + ); if (directory) return { format: "directory", @@ -83,6 +97,8 @@ export const classifyAndHashRootForInventory = async ( rootSource: undefined, readerAdmission: undefined, }; + if (options.expectedRootSha256 !== undefined) + await assertArtifactSelectedSource(path, options.selectedPath); const source = await openRootArtifact( path, options.resourceScope, @@ -101,27 +117,46 @@ export const classifyAndHashRootForInventory = async ( "integrity", `Root artifact changed before inventory: ${path}`, ); + // Prior probe state can prove replacement without interpreting any bytes. + const probes = new ArtifactEmbeddedFormatProbes( + path, + source, + options.signal, + probeFacts, + ); + const admittedDigest = + options.expectedRootSha256 === undefined + ? undefined + : await hashStableRootArtifactHandle( + path, + handle, + initial, + options.signal, + ); + if (admittedDigest !== undefined) + assertAdmittedRootDigest( + path, + options.expectedRootSha256, + admittedDigest.sha256, + ); const classified = await classifyRootFormat( path, source, - new ArtifactEmbeddedFormatProbes( - path, - source, - options.signal, - probeFacts, - ), + probes, options.signal, options.asarHeaderAdmission, options.selectedPath, ); const format = typeof classified === "string" ? classified : classified.kind; - const digest = await hashStableRootArtifactHandle( - path, - handle, - initial, - options.signal, - ); + const digest = + admittedDigest ?? + (await hashStableRootArtifactHandle( + path, + handle, + initial, + options.signal, + )); const retainSource = isZipPackageFormat(format) || format === "mach-o-universal" || @@ -357,3 +392,20 @@ const readRootMzWindowsHeaderSignature = async ( if (available === 0) return undefined; return { offset, bytes: window.subarray(0, available) }; }; + +/** Compare physically completed bytes before interpreting an admitted file root. */ +export const assertAdmittedRootDigest = ( + path: string, + expected: string | undefined, + observed: string, +): void => { + if (expected !== undefined && observed !== expected) + throw new ArtifactReaderFailure( + "integrity", + `Artifact root changed after target admission at ${path}: expected ${expected}, observed ${observed}`, + { + userMessage: + "The artifact changed after it was opened. Reopen the selected artifact, then retry.", + }, + ); +}; diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts index 21fb73dc14..659cf43d74 100644 --- a/src/artifacts/inventory/types.ts +++ b/src/artifacts/inventory/types.ts @@ -13,6 +13,8 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = { /** Options shared by artifact inventory scans. */ export interface ArtifactInventoryOptions { readonly resourceScope: ArtifactResourceScope; + /** Identity of a previously admitted file root; directory roots have no file digest. */ + readonly expectedRootSha256?: string | undefined; /** Independently selected root spelling used only for ASAR companion lookup. */ readonly selectedPath?: string | undefined; readonly signal?: AbortSignal | undefined; diff --git a/tests/acceptance/artifactAdmittedIdentityMcp.test.ts b/tests/acceptance/artifactAdmittedIdentityMcp.test.ts new file mode 100644 index 0000000000..392f8203fd --- /dev/null +++ b/tests/acceptance/artifactAdmittedIdentityMcp.test.ts @@ -0,0 +1,66 @@ +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; +import { artifactExtractionResultSchema } from "../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +it("refuses changed admitted archives over stdio MCP before publishing extraction and accepts an explicit reopen", async () => { + const root = await createTestTempDirectory("rea-admitted-archive-mcp-"); + const temporary = join(root, "temporary"); + await mkdir(temporary); + const path = join(root, "selected.zip"); + const writeArchive = async (value: string) => { + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader(value)); + await writeFile(path, await zip.close()); + }; + await writeArchive("admitted version one"); + const { call } = await connectLocalToolsMcp({ + TMPDIR: temporary, + TEMP: temporary, + TMP: temporary, + REA_MCP_TOOL_FAMILIES: "session,artifact", + }); + expect((await call("open_binary", { path })).isError).not.toBe(true); + const openedTemporaryBaseline = (await readdir(temporary)).sort(); + await writeArchive("changed version two"); + for (const operation of ["inspect_artifact", "extract_artifact"]) { + const refused = parseMcpToolError(await call(operation, {})); + expect(refused.error).toMatchObject({ + code: "artifact_operation_failed", + details: { reason: "integrity" }, + message: expect.stringContaining("Reopen the selected artifact"), + }); + expect((await readdir(temporary)).sort()).toEqual(openedTemporaryBaseline); + } + expect((await call("open_binary", { path })).isError).not.toBe(true); + const extracted = await call("extract_artifact", {}); + expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true); + const result = artifactExtractionResultSchema.parse( + parseEvidence(extracted.structuredContent).normalized_result, + ); + try { + expect(dirname(result.output_root)).toBe(temporary); + expect( + await readFile(join(result.output_root, "payload.txt"), "utf8"), + ).toBe("changed version two"); + } finally { + await rm(result.output_root, { recursive: true, force: true }); + } + const reopenedTemporaryBaseline = (await readdir(temporary)).sort(); + await writeFile(path, "not an archive"); + for (const operation of ["inspect_artifact", "extract_artifact"]) { + const refused = parseMcpToolError(await call(operation, {})); + expect(refused.error).toMatchObject({ + code: "artifact_operation_failed", + details: { reason: "integrity" }, + }); + expect((await readdir(temporary)).sort()).toEqual( + reopenedTemporaryBaseline, + ); + } +}); diff --git a/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts b/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts new file mode 100644 index 0000000000..121efd7448 --- /dev/null +++ b/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts @@ -0,0 +1,272 @@ +import { build } from "plist"; +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { + machoImage, + CPU, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; +import { buildGodotPck, pckU64 } from "../../fixtures/godotPck.js"; +import { + access, + mkdir, + readFile, + writeFile, + unlink, + symlink, +} from "node:fs/promises"; +import { join } from "node:path"; +import { createPackage } from "@electron/asar"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../../src/domain/artifactGraph.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + { format: "zip", replacement: "valid" }, + { format: "apk", replacement: "valid" }, + { format: "asar", replacement: "valid" }, + { format: "zip", replacement: "malformed" }, + { format: "asar", replacement: "malformed" }, +])( + "binds $format $replacement archive operations to the admitted source before output creation", + async ({ format, replacement }) => { + const root = await createTestTempDirectory( + "rea-archive-admitted-identity-", + ); + const path = join(root, `selected.${format}`); + const source = join(root, "source"); + await mkdir(source); + const writeArchive = async (value: string) => { + if (format === "asar") { + await writeFile(join(source, "payload.txt"), value); + await createPackage(source, path); + } else { + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader(value)); + await writeFile(path, await zip.close()); + } + }; + await writeArchive("admitted version one"); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + const initial = await session.execute("inventory_artifact", {}); + if (!initial.ok) throw initial.error; + if (replacement === "malformed") await writeFile(path, "not an archive"); + else await writeArchive("changed version two"); + for (const operation of [ + "inventory_artifact", + "inspect_artifact", + "extract_artifact", + ] as const) { + const output = join(root, `refused-${operation}`); + const result = await session.execute( + operation, + operation === "extract_artifact" + ? { output_root: output, integrity_policy: "fail" } + : {}, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining("changed after target admission"), + }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } + if (replacement === "valid") { + const reopened = await session.open(path); + if (!reopened.ok) throw reopened.error; + const inventory = await session.execute("inventory_artifact", {}); + if (!inventory.ok) throw inventory.error; + expect( + artifactInventoryResultSchema.parse(inventory.value.result).manifest + .root_sha256, + ).not.toBe( + artifactInventoryResultSchema.parse(initial.value.result).manifest + .root_sha256, + ); + const extracted = await session.execute("extract_artifact", { + output_root: join(root, "reopened-output"), + integrity_policy: "fail", + }); + if (!extracted.ok) throw extracted.error; + const result = artifactExtractionResultSchema.parse( + extracted.value.result, + ); + expect( + await readFile(join(result.output_root, "payload.txt"), "utf8"), + ).toBe("changed version two"); + } + } finally { + expect(await session.close()).toEqual({ ok: true, value: null }); + } + }, +); + +it.skipIf(process.platform === "win32")( + "rejects retargeted ZIP selections without attributing or publishing the old packed bytes", + async () => { + const root = await createTestTempDirectory("rea-admitted-zip-alias-"); + const stored = join(root, "stored.zip"); + const other = join(root, "other.zip"); + const selected = join(root, "selected.zip"); + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader("same bytes")); + const bytes = await zip.close(); + await writeFile(stored, bytes); + await writeFile(other, bytes); + await symlink(stored, selected); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(selected); + if (!opened.ok) throw opened.error; + await unlink(selected); + await symlink(other, selected); + for (const operation of [ + "inventory_artifact", + "extract_artifact", + ] as const) { + const output = join(root, operation); + const result = await session.execute( + operation, + operation === "extract_artifact" ? { output_root: output } : {}, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining( + "no longer resolves to its admitted source", + ), + }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } + } finally { + await session.close(); + } + }, +); + +it("rejects an admitted archive replaced by a directory", async () => { + const root = await createTestTempDirectory("rea-admitted-file-directory-"); + const path = join(root, "selected.zip"); + const zip = new ZipWriter(new Uint8ArrayWriter()); + await zip.add("payload.txt", new TextReader("original")); + await writeFile(path, await zip.close()); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + await unlink(path); + await mkdir(path); + await writeFile(join(path, "payload.txt"), "replacement directory"); + const output = join(root, "output"); + expect( + await session.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ ok: false, error: { reason: "integrity" } }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await session.close(); + } +}); + +it("keeps app bundle aggregate inventories live when resources change", async () => { + const root = await createTestTempDirectory("rea-admitted-bundle-resources-"); + const app = join(root, "Fixture.app"); + await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); + await writeFile( + join(app, "Contents", "MacOS", "fixture"), + machoImage({ cpu: CPU.arm64e }), + ); + await writeFile( + join(app, "Contents", "Info.plist"), + build({ CFBundlePackageType: "APPL", CFBundleExecutable: "fixture" }), + ); + const payload = join(app, "Contents", "payload.txt"); + await writeFile(payload, "first resource"); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(app); + if (!opened.ok) throw opened.error; + const initial = await session.execute("inventory_artifact", {}); + if (!initial.ok) throw initial.error; + await writeFile(payload, "second resource"); + const current = await session.execute("inventory_artifact", {}); + if (!current.ok) throw current.error; + expect( + artifactInventoryResultSchema.parse(current.value.result).manifest + .root_sha256, + ).not.toBe( + artifactInventoryResultSchema.parse(initial.value.result).manifest + .root_sha256, + ); + const extracted = await session.execute("extract_artifact", { + output_root: join(root, "output"), + }); + if (!extracted.ok) throw extracted.error; + const result = artifactExtractionResultSchema.parse(extracted.value.result); + expect( + await readFile( + join(result.output_root, "Contents", "payload.txt"), + "utf8", + ), + ).toBe("second resource"); + } finally { + await session.close(); + } +}); + +it("checks embedded native packs before acceptance while preserving unsupported fast refusal", async () => { + const root = await createTestTempDirectory("rea-admitted-native-preflight-"); + const path = join(root, "game"); + const writePack = async (text: string) => { + const pack = buildGodotPck(2, [ + { path: "res://payload.txt", data: Buffer.from(text) }, + ]); + await writeFile( + path, + Buffer.concat([ + elf(2, 1, 62), + pack, + pckU64(pack.length), + Buffer.from("GDPC"), + ]), + ); + }; + await writePack("first"); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + await writePack("second"); + const output = join(root, "output"); + expect( + await session.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining("changed after target admission"), + }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + await writeFile(path, "not a native pack"); + expect( + await session.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError" }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await session.close(); + } +}); diff --git a/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts index 79765e711a..506212f201 100644 --- a/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts +++ b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts @@ -98,7 +98,7 @@ describe("artifact archive inventory", () => { await writeFile(join(source, "main.js"), "console.log('ok');\n"); const asarPath = join(root, "app.asar"); await createPackage(source, asarPath); - const asarResult = await inventory(target(asarPath, "asar")); + const asarResult = await inventory(await target(asarPath)); expect(asarResult.occurrences).toEqual( expect.arrayContaining([ expect.objectContaining({ logical_path: "main.js" }), @@ -107,7 +107,7 @@ describe("artifact archive inventory", () => { const unpackedPath = join(root, "unpacked.asar"); await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" }); - const unpackedResult = await inventory(target(unpackedPath, "asar")); + const unpackedResult = await inventory(await target(unpackedPath)); expect(unpackedResult.occurrences).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -121,7 +121,7 @@ describe("artifact archive inventory", () => { "changed();\n", ); const corrupted = await new ArtifactProvider(process.env) - .createClient(target(unpackedPath, "asar")) + .createClient(await target(unpackedPath)) .execute("inventory_artifact", {}); expect(corrupted).toMatchObject({ ok: false, @@ -135,7 +135,7 @@ describe("artifact archive inventory", () => { }, }); const failedInspection = await new ArtifactProvider(process.env) - .createClient(target(unpackedPath, "asar")) + .createClient(await target(unpackedPath)) .execute("inspect_artifact", {}); expect(failedInspection).toMatchObject({ ok: false, @@ -151,13 +151,8 @@ const inventory = async (targetValue: BinaryTarget) => { return artifactInventoryResultSchema.parse(result.value.result); }; -const target = ( - path: string, - format: Extract["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: format === "directory" ? "asar" : format, -}); +const target = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.capacity.test.ts b/tests/boundary/providers/native/artifactProvider.capacity.test.ts index c9caa394da..357a1d631a 100644 --- a/tests/boundary/providers/native/artifactProvider.capacity.test.ts +++ b/tests/boundary/providers/native/artifactProvider.capacity.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { cArchiveExecutable, pyzArchive, @@ -28,17 +29,9 @@ it.each([1, 129])( })), }); await writeFile(path, bytes); - const client = new ArtifactProvider({}).createClient({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "executable", - format: "pe", - executableRole: "application", - managed: false, - architecture: "x86_64", - availableArchitectures: ["x86_64"], - }); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + const client = new ArtifactProvider({}).createClient(target.value); try { const result = await client.execute("inventory_artifact", {}); if (count === 1) { diff --git a/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts index d216d18cbc..629159bd0e 100644 --- a/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts +++ b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts @@ -5,6 +5,7 @@ import { createPackage } from "@electron/asar"; import { expect, it, onTestFinished, vi } from "vitest"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; import { AsarArtifactReader } from "../../../../src/artifacts/AsarArtifactReader.js"; import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js"; @@ -29,19 +30,16 @@ const createArchive = async () => { return { root, archive }; }; -const createClient = (path: string) => - new ArtifactProvider(process.env).createClient({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: "asar", - }); +const createClient = async (path: string) => { + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + return new ArtifactProvider(process.env).createClient(target.value); +}; it("retains a failed reader close, blocks new work, and retries the same reader", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalClose = AsarArtifactReader.prototype.close; const closeOwners: AsarArtifactReader[] = []; let readerCloseBlocked = true; @@ -109,7 +107,7 @@ it("retains a failed reader close, blocks new work, and retries the same reader" it("retains a failed rollback owner and preserves the materialization failure", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalWrite = SafeOutputTree.prototype.write; const originalRollback = SafeOutputTree.prototype.rollback; const rollbackOwners: SafeOutputTree[] = []; @@ -173,7 +171,7 @@ it("retains a tree whose setup failed after root identity was captured", async ( const { root, archive } = await createArchive(); const output = join(root, "output"); const blocker = join(output, "unowned.txt"); - const client = createClient(archive); + const client = await createClient(archive); const prototypeHandle = await open(join(root, "prototype-handle"), "w"); const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick< typeof prototypeHandle, @@ -213,7 +211,7 @@ it("retains a tree whose setup failed after root identity was captured", async ( it("waits for admitted extraction before close and rejects later work", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalWrite = SafeOutputTree.prototype.write; const started = deferred(); const finish = deferred(); @@ -247,7 +245,13 @@ it("waits for admitted extraction before close and rejects later work", async () const extraction = client.execute("extract_artifact", { output_root: output, }); - await started.promise; + await Promise.race([ + started.promise, + extraction.then((result) => { + if (!result.ok) throw result.error; + throw new Error("Extraction completed before reaching its write stage"); + }), + ]); const closing = client.close().then((result) => { closeFinished = true; return result; diff --git a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts index 97eecf0ac5..462a540acb 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts @@ -15,53 +15,55 @@ describe("artifact directory diagnostics", () => { "omits inaccessible directory descendants during inventory but refuses incomplete extraction", async () => { const root = await createTestTempDirectory("rea-directory-policy-"); - const source = join(root, "source"); + const source = join(root, "Fixture.app"); const blocked = join(source, "blocked"); const output = join(root, "output"); await mkdir(blocked, { recursive: true }); + await mkdir(join(source, "Contents", "MacOS"), { recursive: true }); + await writeFile( + join(source, "Contents", "Info.plist"), + "CFBundleExecutableFixture", + ); + await writeFile( + join(source, "Contents", "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); await writeFile(join(source, "readable.txt"), "retained bytes\n"); await writeFile(join(blocked, "hidden.txt"), "unexamined bytes\n"); await chmod(blocked, 0); try { - await withArtifactClient( - { - path: source, - sourcePath: source, - sha256: "0".repeat(64), - kind: "archive", - format: "asar", - }, - async (client) => { - const inventoried = await client.execute("inventory_artifact", {}); - if (!inventoried.ok) throw inventoried.error; - const inventory = artifactInventoryResultSchema.parse( - inventoried.value.result, - ); - expect(inventory.coverage.status).toBe("unknown"); - expect(inventory.occurrences).toContainEqual( - expect.objectContaining({ - logical_path: "readable.txt", - hash_status: "verified", - }), - ); - expect(inventory.limitations).toContainEqual( - expect.stringContaining("EACCES"), - ); - const extracted = await client.execute("extract_artifact", { - output_root: output, - }); - expect(extracted).toMatchObject({ - ok: false, - error: { - _tag: "ArtifactOperationError", - detail: expect.stringContaining("EACCES"), - }, - }); - await expect(access(output)).rejects.toMatchObject({ - code: "ENOENT", - }); - }, - ); + const target = await parseBinaryTarget(source); + if (!target.ok) throw target.error; + await withArtifactClient(target.value, async (client) => { + const inventoried = await client.execute("inventory_artifact", {}); + if (!inventoried.ok) throw inventoried.error; + const inventory = artifactInventoryResultSchema.parse( + inventoried.value.result, + ); + expect(inventory.coverage.status).toBe("unknown"); + expect(inventory.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: "readable.txt", + hash_status: "verified", + }), + ); + expect(inventory.limitations).toContainEqual( + expect.stringContaining("EACCES"), + ); + const extracted = await client.execute("extract_artifact", { + output_root: output, + }); + expect(extracted).toMatchObject({ + ok: false, + error: { + _tag: "ArtifactOperationError", + detail: expect.stringContaining("EACCES"), + }, + }); + await expect(access(output)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); } finally { await chmod(blocked, 0o700); } diff --git a/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts index 31f5915f7e..ae47f4e741 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts @@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { + CPU, + machoImage, +} from "../../../../src/artifacts/apple/MachoImage.fixture.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; @@ -18,7 +23,11 @@ describe("artifact directory inventory", () => { await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), { recursive: true, }); - const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]); + const machO = machoImage({ cpu: CPU.arm64 }); + await writeFile( + join(app, "Contents/Info.plist"), + 'CFBundleExecutableFixture', + ); await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, { mode: 0o755, }); @@ -30,7 +39,7 @@ describe("artifact directory inventory", () => { await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n"); await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside")); const client = new ArtifactProvider(process.env).createClient( - target(app, "directory"), + await target(app), ); const input = {}; const first = await client.execute("inventory_artifact", input); @@ -72,13 +81,9 @@ describe("artifact directory inventory", () => { }); }); -const target = ( - path: string, - format: Extract["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: format === "directory" ? "asar" : format, -}); +const target = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.bundleInfoPlist).toBe(join(path, "Contents/Info.plist")); + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 81d1dd9462..de78ae6e22 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { + CPU, + machoImage, +} from "../../../../src/artifacts/apple/MachoImage.fixture.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; import { @@ -19,13 +24,13 @@ describe("artifact extraction", () => { "refuses an invalid parent (%s) before acquiring output and permits a corrected request", async (parent) => { const root = await createTestTempDirectory("rea-extract-invalid-parent-"); - const source = join(root, "source"); + const source = join(root, "source.app"); await mkdir(source); await writeFile(join(source, "payload.txt"), "selected payload\n"); await writeFile(join(root, "file"), "parent is a file"); const output = join(root, "out"); - await withArtifactClient(target(source, "directory"), async (client) => { + await withArtifactClient(await target(source), async (client) => { const refused = await client.execute("extract_artifact", { output_root: `${root}/${parent}/out`, }); @@ -52,12 +57,12 @@ describe("artifact extraction", () => { it("keeps committed files after the owning client closes", async () => { const root = await createTestTempDirectory("rea-extract-client-close-"); - const source = join(root, "source"); + const source = join(root, "source.app"); await mkdir(source); await writeFile(join(source, "payload.txt"), "committed\n"); const output = join(root, "output"); const client = new ArtifactProvider(process.env).createClient( - target(source, "directory"), + await target(source), ); const result = await client.execute("extract_artifact", { @@ -72,11 +77,11 @@ describe("artifact extraction", () => { it("extracts all regular occurrences through an exclusively owned output tree", async () => { const root = await createTestTempDirectory("rea-extract-"); - const source = join(root, "source"); + const source = join(root, "source.app"); await mkdir(join(source, "assets"), { recursive: true }); await writeFile(join(source, "assets", "selected.js"), "selected();\n"); await writeFile(join(source, "assets", "ignored.js"), "ignored();\n"); - const targetValue = target(source, "directory"); + const targetValue = await target(source); const graph = await inventory(targetValue); expect( graph.occurrences.some( @@ -160,7 +165,7 @@ describe("artifact extraction", () => { expect((await readdir(root)).sort()).toEqual([ "output", "relocated-output", - "source", + "source.app", ]); }); }); @@ -172,13 +177,15 @@ const inventory = async (targetValue: BinaryTarget) => { return artifactInventoryResultSchema.parse(result.value.result); }; -const target = ( - path: string, - format: Extract["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: format === "directory" ? "asar" : format, -}); +/** A real bundle target has an executable identity distinct from its resource tree. */ +const target = async (path: string): Promise => { + await writeFile( + join(path, "Info.plist"), + 'CFBundleExecutableMain', + ); + await writeFile(join(path, "Main"), machoImage({ cpu: CPU.arm64 })); + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.bundleInfoPlist).toBe(join(path, "Info.plist")); + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts index 949d0046ae..45b3636190 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { access, appendFile, mkdir, + readFile, rename, rm, writeFile, @@ -33,7 +35,9 @@ it("refuses an unsupported source without hashing its bytes", async () => { { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "archive", format: "dmg", }, @@ -73,7 +77,9 @@ it("preserves cancellation arriving during the detection descriptor's final clos { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "archive", format: "dmg", }, @@ -118,7 +124,9 @@ it("keeps detection I/O failure distinct from unsupported format and closes its { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "archive", format: "dmg", }, @@ -150,7 +158,7 @@ it.each([ "changed same inode", "directory replacement", ] as const)( - "refuses to reuse completed detection facts after %s before hashing", + "refuses to reuse completed detection facts after %s before inventory output", async (change) => { const root = await createTestTempDirectory( "rea-extraction-admission-identity-", @@ -161,11 +169,13 @@ it.each([ buildGodotPck(3, [{ path: "res://main.gd", data: "extends Node\n" }]), ); await writeFile(source, bytes); + let hashesAtMutation: number | undefined; const close = OwnedFileHandle.prototype.close; const closeSpy = vi .spyOn(OwnedFileHandle.prototype, "close") .mockImplementationOnce(async function (this: OwnedFileHandle) { await close.call(this); + hashesAtMutation = hash.mock.calls.length; if (change === "changed same inode") await appendFile(source, "changed"); else if (change === "directory replacement") { @@ -183,7 +193,9 @@ it.each([ { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "executable", format: "elf", architecture: "x86_64", @@ -197,7 +209,9 @@ it.each([ ok: false, error: { _tag: "ArtifactOperationError", reason: "integrity" }, }); - expect(hash).not.toHaveBeenCalled(); + expect(hashesAtMutation).toBeDefined(); + // Initial admission may hash; facts known stale after close must not. + expect(hash).toHaveBeenCalledTimes(hashesAtMutation!); await expect(access(output)).rejects.toMatchObject({ code: "ENOENT", }); @@ -275,7 +289,9 @@ it.each([ { path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), kind: "executable", format: "pe", executableRole: "application", diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index aa0371d389..12340e4d86 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -5,12 +5,12 @@ import { join } from "node:path"; import { createPackage, createPackageWithOptions } from "@electron/asar"; import { expect, it, vi } from "vitest"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { materializeArtifactInventory, scanArtifactInventory, } from "../../../fixtures/artifactInventory.js"; -import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { DirectoryArtifactReader } from "../../../../src/artifacts/DirectoryArtifactReader.js"; @@ -29,9 +29,7 @@ it.for([false, true])( "unrepresented", ); const snapshot = await scanArtifactInventory(source); - const result = await extract(source, join(root, "output")); - if (!result.ok) throw result.error; - const extracted = artifactExtractionResultSchema.parse(result.value.result); + const extracted = await extractDirectory(source, join(root, "output")); expect(extracted.coverage).toEqual(snapshot.coverage); expect(extracted.coverage.status).toBe( invalidName ? "unknown" : "complete", @@ -317,9 +315,7 @@ it("preserves every equal-content occurrence and copies a nested ASAR as its con await writeFile(join(source, "a.txt"), "same bytes\n"); await writeFile(join(source, "b.txt"), "same bytes\n"); const output = join(root, "output"); - const result = await extract(source, output); - if (!result.ok) throw result.error; - const extracted = artifactExtractionResultSchema.parse(result.value.result); + const extracted = await extractDirectory(source, output); expect(extracted.artifacts.map(({ relative_path }) => relative_path)).toEqual( ["a.txt", "app.asar", "b.txt"], ); @@ -350,11 +346,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr await rm(join(`${archive}.unpacked`, "native", "addon.node")); const nestedOutput = join(root, "nested-output"); - const nestedResult = await extract(source, nestedOutput); - if (!nestedResult.ok) throw nestedResult.error; - const nestedExtraction = artifactExtractionResultSchema.parse( - nestedResult.value.result, - ); + const nestedExtraction = await extractDirectory(source, nestedOutput); expect( nestedExtraction.extraction_manifest.selected_occurrence_ids, ).toHaveLength(1); @@ -369,7 +361,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr ).toBe(0); const activeOutput = join(root, "active-output"); - const activeResult = await extract(archive, activeOutput); + const activeResult = await extractArchive(archive, activeOutput); if (activeResult.ok) throw new Error("Unavailable active ASAR member must fail extraction"); expect(projectAnalysisError(activeResult.error)).toMatchObject({ @@ -396,13 +388,16 @@ const inventoriedExtraction = async (source: string, output: string) => { ); }; -const extract = (source: string, output: string) => - new ArtifactProvider(process.env) - .createClient({ - path: source, - sourcePath: source, - sha256: "0".repeat(64), - kind: "archive", - format: "asar", - }) +/** Raw directory completeness belongs to inventory/materialization, not file target admission. */ +const extractDirectory = async (source: string, output: string) => { + const materialize = await inventoriedExtraction(source, output); + return materialize(); +}; + +const extractArchive = async (source: string, output: string) => { + const target = await parseBinaryTarget(source); + if (!target.ok) throw target.error; + return new ArtifactProvider(process.env) + .createClient(target.value) .execute("extract_artifact", { output_root: output }); +}; diff --git a/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts b/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts index 0415b89625..e52ac5ed59 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts @@ -3,11 +3,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { elfExportImage } from "../../../fixtures/binaryDiagnostics/nativeExports.js"; import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { buildGodotPck, embedGodotPck } from "../../../fixtures/godotPck.js"; +import { buildGodotPck, pckU64 } from "../../../fixtures/godotPck.js"; import { withArtifactClient } from "../../../support/artifactClientScope.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { CPU, @@ -22,13 +24,17 @@ describe("artifact extraction format support", () => { it("refuses unsupported formats before inventory or output", async () => { const root = await createTestTempDirectory("rea-extract-format-"); const image = join(root, "Image.dmg"); - await writeFile(image, "not mounted"); + const udif = Buffer.alloc(512); + udif.write("koly"); + await writeFile(image, udif); const alias = join(root, "Alias.dmg"); await symlink(image, alias); const output = join(root, "output"); + const selected = await admittedTarget(alias); + expect(selected.format).toBe("dmg"); const extract = (signal?: AbortSignal) => new ArtifactProvider(process.env) - .createClient(archiveTarget(alias, "dmg")) + .createClient(selected) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output }), @@ -59,7 +65,7 @@ describe("artifact extraction format support", () => { const output = join(root, "output"); expect( await new ArtifactProvider(process.env) - .createClient(thinMachOTarget(binary)) + .createClient(await admittedTarget(binary)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output }), @@ -99,7 +105,7 @@ describe("artifact extraction format support", () => { ]), ); const result = await new ArtifactProvider(process.env) - .createClient(thinMachOTarget(binary)) + .createClient(await admittedTarget(binary)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -119,39 +125,40 @@ describe("artifact extraction reader selection", () => { const root = await createTestTempDirectory("rea-extract-embedded-pck-"); const binary = join(root, "game"); const output = join(root, "output"); + const pack = buildGodotPck(3, [ + { path: "res://main.gd", data: "extends Node\n" }, + ]); await writeFile( binary, - embedGodotPck( - buildGodotPck(3, [{ path: "res://main.gd", data: "extends Node\n" }]), - ), - ); - await withArtifactClient( - { - path: binary, - sourcePath: binary, - sha256: "0".repeat(64), - kind: "executable", - format: "elf", - architecture: "x86_64", - availableArchitectures: ["x86_64"], - }, - async (client) => { - const result = await client.execute("extract_artifact", { - output_root: output, - }); - if (!result.ok) throw result.error; - const extracted = artifactExtractionResultSchema.parse( - result.value.result, - ); - expect(extracted.manifest.root_format).toBe("godot-pck"); - expect( - extracted.artifacts.map(({ relative_path }) => relative_path), - ).toEqual(["main.gd"]); - expect(await readFile(join(output, "main.gd"), "utf8")).toBe( - "extends Node\n", - ); - }, + Buffer.concat([ + elfExportImage({ symbols: [] }), + pack, + pckU64(pack.length), + Buffer.from("GDPC"), + ]), ); + const selected = await admittedTarget(binary); + expect(selected).toMatchObject({ + kind: "executable", + format: "elf", + architecture: "x86_64", + }); + await withArtifactClient(await admittedTarget(binary), async (client) => { + const result = await client.execute("extract_artifact", { + output_root: output, + }); + if (!result.ok) throw result.error; + const extracted = artifactExtractionResultSchema.parse( + result.value.result, + ); + expect(extracted.manifest.root_format).toBe("godot-pck"); + expect( + extracted.artifacts.map(({ relative_path }) => relative_path), + ).toEqual(["main.gd"]); + expect(await readFile(join(output, "main.gd"), "utf8")).toBe( + "extends Node\n", + ); + }); }); it.each(["zip", "hap", "hsp", "app-pack"] as const)( @@ -164,7 +171,7 @@ describe("artifact extraction reader selection", () => { ); await writeOrderedZip(archive, ["payload.txt"]); const result = await new ArtifactProvider(process.env) - .createClient(archiveTarget(archive, format)) + .createClient(await admittedTarget(archive)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -182,23 +189,8 @@ describe("artifact extraction reader selection", () => { ); }); -const archiveTarget = ( - path: string, - format: Extract["format"], -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format, -}); - -const thinMachOTarget = (path: string): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "executable", - format: "mach-o", - architecture: "arm64", - availableArchitectures: ["arm64"], -}); +const admittedTarget = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts b/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts index 7f47b030cc..99e45631f1 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js"; @@ -16,7 +17,7 @@ describe("artifact extraction identity", () => { const source = join(root, "source.zip"); await writeOrderedZip(source, ["ä.js", "a.js", "Z.js"]); const result = await new ArtifactProvider(process.env) - .createClient(archiveTarget(source)) + .createClient(await archiveTarget(source)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -34,10 +35,8 @@ describe("artifact extraction identity", () => { }); }); -const archiveTarget = (path: string): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: "zip", -}); +const archiveTarget = async (path: string): Promise => { + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts index c89cc9dfce..c61e0a6bb3 100644 --- a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts @@ -4,11 +4,13 @@ import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { CPU_ARM64, universalMacho } from "../../../fixtures/universalMacho.js"; +import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js"; +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; -import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; const sliceReader = vi.hoisted(() => ({ reached: vi.fn(), @@ -41,10 +43,21 @@ it("routes universal Mach-O extraction through slice inventory", async () => { ); const root = await createTestTempDirectory("rea-extract-universal-"); const image = join(root, "Universal"); - await writeFile(image, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0])); + await writeFile( + image, + universalMacho([ + { + bytes: thinMach(0xfeedfacf, CPU_ARM64), + cpuType: CPU_ARM64, + cpuSubtype: 0, + }, + ]).bytes, + ); + const target = await parseBinaryTarget(image, { hostArchitecture: "arm64" }); + if (!target.ok) throw target.error; const output = join(root, "output"); const result = await new ArtifactProvider(process.env) - .createClient(universalTarget(image)) + .createClient(target.value) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output }), @@ -61,14 +74,3 @@ it("routes universal Mach-O extraction through slice inventory", async () => { expect(sliceReader.reached).toHaveBeenCalledOnce(); await expect(access(output)).rejects.toThrow(); }); - -const universalTarget = (path: string): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "executable", - format: "mach-o-universal", - architecture: "arm64", - availableArchitectures: ["arm64"], - universalSlice: { offset: 0, size: 32 }, -}); From 077de92594cbb86f6e5e7b82fbe52d63dcfc7498 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:03:07 +0800 Subject: [PATCH 20/29] fix(setup): preserve selected Hopper loader arguments --- .../SetupRegistrationEnvironment.ts | 1 + .../registrationRuntimeEnvironmentCli.test.ts | 3 + .../setup/setupLoaderEnvironment.test.ts | 74 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 tests/boundary/setup/setupLoaderEnvironment.test.ts diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index f531c6ffb0..fff31b3223 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -7,6 +7,7 @@ const SELECTED_REGISTRATION_SETTINGS = [ "GHIDRA_MAXMEM", "JAVA_HOME", "HOPPER_LAUNCHER_PATH", + "HOPPER_LOADER_ARGS_JSON", "REA_ILSPY_CMD_PATH", "REA_ADB_PATH", "REA_APKTOOL_COMMAND", diff --git a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts index 6d9bdfd20c..edbeb257a3 100644 --- a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts +++ b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts @@ -26,6 +26,7 @@ cliTest( const config = join(home, ".qwen/settings.json"); const selected = { HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + HOPPER_LOADER_ARGS_JSON: '["", "--loader", "Mach-O"]', GHIDRA_INSTALL_DIR: join(home, "offline-ghidra"), GHIDRA_HEADLESS_MAXMEM: "512M", GHIDRA_MAXMEM: "3G", @@ -111,6 +112,8 @@ cliTest( environment.REA_MCP_TOOL_FAMILIES = selected.REA_MCP_TOOL_FAMILIES; selected.REA_OBJDUMP_COMMAND = join(home, "replacement-objdump"); environment.REA_OBJDUMP_COMMAND = selected.REA_OBJDUMP_COMMAND; + selected.HOPPER_LOADER_ARGS_JSON = "[]"; + environment.HOPPER_LOADER_ARGS_JSON = selected.HOPPER_LOADER_ARGS_JSON; // Empty headless heap explicitly selects the lower-priority caller value. selected.GHIDRA_HEADLESS_MAXMEM = ""; selected.GHIDRA_MAXMEM = "768M"; diff --git a/tests/boundary/setup/setupLoaderEnvironment.test.ts b/tests/boundary/setup/setupLoaderEnvironment.test.ts new file mode 100644 index 0000000000..4eb80caad9 --- /dev/null +++ b/tests/boundary/setup/setupLoaderEnvironment.test.ts @@ -0,0 +1,74 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { createCli } from "../../../src/cli.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("setup carries explicit Hopper loader selections into Cursor without persisting target or unrelated settings", async () => { + const home = await createTestTempDirectory("rea-setup-loader-environment-"); + const path = join(home, ".cursor/mcp.json"); + await mkdir(join(home, ".cursor")); + await writeFile(path, '{"caller":"retain"}\n'); + const environment = { + HOME: home, + USERPROFILE: home, + HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"), + JAVA_HOME: join(home, "missing-java"), + HOPPER_LOADER_ARGS_JSON: '["", "--loader", "Mach-O"]', + HOPPER_TARGET_PATH: join(home, "per-target"), + HOPPER_TARGET_KIND: "executable", + CALLER_UNRELATED_SETTING: "do not persist", + }; + const setup = async () => { + let stdout = ""; + await createCli(environment).serve( + ["setup", "--client", "cursor", "--skill=false", "--yes", "--json"], + { + env: environment, + stdout: (chunk) => { + stdout += chunk; + }, + exit: () => undefined, + }, + ); + return JSON.parse(stdout) as unknown; + }; + const loaderFromSavedRegistration = async () => { + const document = JSON.parse(await readFile(path, "utf8")); + expect(document.caller).toBe("retain"); + const saved = document.mcpServers.rea.env; + expect(saved).not.toHaveProperty("CALLER_UNRELATED_SETTING"); + expect(saved).not.toHaveProperty("HOPPER_TARGET_PATH"); + expect(saved).not.toHaveProperty("HOPPER_TARGET_KIND"); + const config = parseConfig(saved); + if (!config.ok) throw config.error; + return config.value.hopperLoaderArgs; + }; + + expect(await setup()).toMatchObject({ status: "ready" }); + expect(await loaderFromSavedRegistration()).toEqual([ + "", + "--loader", + "Mach-O", + ]); + const created = await readFile(path, "utf8"); + expect(JSON.parse(created).mcpServers.rea.env.HOPPER_LOADER_ARGS_JSON).toBe( + environment.HOPPER_LOADER_ARGS_JSON, + ); + environment.HOPPER_LOADER_ARGS_JSON = "[]"; + expect(await setup()).toMatchObject({ status: "ready" }); + expect(await loaderFromSavedRegistration()).toEqual([]); + const updated = await readFile(path, "utf8"); + expect(JSON.parse(updated).mcpServers.rea.env.HOPPER_LOADER_ARGS_JSON).toBe( + "[]", + ); + environment.HOPPER_LOADER_ARGS_JSON = "{"; + expect(await setup()).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + }); + expect(await readFile(path, "utf8")).toBe(updated); +}); From db0908a7321bbd695c6ea62158861ec60b944197 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:09:05 +0800 Subject: [PATCH 21/29] refactor(artifacts): consolidate projection and PCK interpretation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep historical inventory summary variants with their projection owner and share embedded PCK trailer observations while preserving each reader’s admission policy. --- .../artifacts/ArtifactComparisonEvidence.ts | 61 +++---------------- src/artifacts/DesktopFileInspection.ts | 32 +++++----- src/artifacts/GodotPckLocation.ts | 61 ++++++++++++++++--- .../artifactInventorySummaryProjection.ts | 55 +++++++++++++++++ 4 files changed, 129 insertions(+), 80 deletions(-) create mode 100644 src/domain/analysisView/artifactInventorySummaryProjection.ts diff --git a/src/application/artifacts/ArtifactComparisonEvidence.ts b/src/application/artifacts/ArtifactComparisonEvidence.ts index 403f11310e..a434067afb 100644 --- a/src/application/artifacts/ArtifactComparisonEvidence.ts +++ b/src/application/artifacts/ArtifactComparisonEvidence.ts @@ -5,22 +5,17 @@ import { import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js"; -import { - artifactInventorySummaryViewResultSchema, - sealAnalysisView, -} from "../../domain/analysisView/analysisView.js"; +import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; import { EvidenceIntegrityError, EvidenceReferenceError, } from "../../domain/evidenceErrors.js"; import { - evidenceSemanticContent, rebindEvidenceToTargetPath, type Evidence, } from "../../domain/evidence.js"; import { readEvidence } from "../../domain/evidenceValidation.js"; -import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; -import { ARTIFACT_INVENTORY_VIEW_CAVEATS } from "../../domain/analysisView/artifactInventoryViewCaveats.js"; +import { artifactInventorySummaryProjectionIds } from "../../domain/analysisView/artifactInventorySummaryProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; /** @@ -94,7 +89,11 @@ export const resolveArtifactComparisonEvidence = ( (evidenceId) => (evidenceId === parentId ? presentation : undefined), ); if (!reprojected.ok) return reprojected; - if (!summaryProjectionIds(reprojected.value).includes(evidence.evidence_id)) + if ( + !artifactInventorySummaryProjectionIds(reprojected.value).includes( + evidence.evidence_id, + ) + ) return mismatch(); return ok(owner); }; @@ -105,49 +104,3 @@ const mismatch = (): Result => "Artifact summary does not match its retained parent inventory.", ), ); - -/** Exact shipped projections, rather than arbitrary omission or prose tolerance. */ -const summaryProjectionIds = (current: Evidence): readonly string[] => { - const result = artifactInventorySummaryViewResultSchema.parse( - current.normalized_result, - ); - const format = result.summary.manifest.root_format; - if ( - format === "godot-pck" || - format === "pyinstaller" || - format === "node-sea" - ) - return [current.evidence_id]; - const { view_digest: _digest, ...projection } = result; - // b395190e4 began disclosing unavailable hashes for ordinary root formats. - const beforeHashDisclosure = { - ...projection, - unknowns: projection.unknowns.filter( - (value) => value !== ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownContentHashes, - ), - }; - // ce1f4b1bb began carrying traversal coverage and its caveat for these roots. - const { source_coverage: _coverage, ...summary } = projection.summary; - const beforeTraversalDisclosure = { - ...beforeHashDisclosure, - summary, - unknowns: beforeHashDisclosure.unknowns.filter( - (value) => value !== ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownTraversal, - ), - }; - return [ - current.evidence_id, - ...[beforeHashDisclosure, beforeTraversalDisclosure].map( - (variant) => - `ev_${digestCanonicalValue( - evidenceSemanticContent({ - ...current, - normalized_result: artifactInventorySummaryViewResultSchema.parse( - sealAnalysisView(variant), - ), - }), - "Evidence", - )}`, - ), - ]; -}; diff --git a/src/artifacts/DesktopFileInspection.ts b/src/artifacts/DesktopFileInspection.ts index 067f14da06..f76e91f240 100644 --- a/src/artifacts/DesktopFileInspection.ts +++ b/src/artifacts/DesktopFileInspection.ts @@ -3,8 +3,8 @@ import { readPeCoffHeader } from "../domain/peCoffHeader.js"; import { hasGodotPckHeader, readGodotPckHeaderFacts, - readGodotPckTrailerFacts, } from "../domain/godotPckFormat.js"; +import { observeEmbeddedGodotPck } from "./GodotPckLocation.js"; import type { FileHandle } from "node:fs/promises"; import { createHash } from "node:crypto"; import { setImmediate } from "node:timers/promises"; @@ -351,22 +351,20 @@ const inspectPckTrailer = async ( read: DesktopByteRead, fact: RecordDesktopFact, ): Promise => { - const trailer = await read(size - 12, 12); - const declaration = readGodotPckTrailerFacts(trailer, size); - if (declaration.state === "declared") { - const { start: offset, packBytes } = declaration; - if (offset === null || packBytes < 20n) - throw new ArtifactReaderFailure( - "format", - "Embedded PCK trailer declares an invalid pack extent", - ); - const bytes = await read(offset, 20); - if (!hasGodotPckHeader(bytes)) - throw new ArtifactReaderFailure( - "format", - "Embedded PCK trailer does not locate a pack header", - ); - const header = readGodotPckHeaderFacts(bytes); + const observation = await observeEmbeddedGodotPck(size, 20, read); + if (observation.state === "invalid-extent") + throw new ArtifactReaderFailure( + "format", + "Embedded PCK trailer declares an invalid pack extent", + ); + if (observation.state === "header-mismatch") + throw new ArtifactReaderFailure( + "format", + "Embedded PCK trailer does not locate a pack header", + ); + if (observation.state === "located") { + const offset = observation.start; + const header = readGodotPckHeaderFacts(observation.header); fact({ kind: "godot-pck-header", offset, diff --git a/src/artifacts/GodotPckLocation.ts b/src/artifacts/GodotPckLocation.ts index 468aa44e69..d1e3e4e7a2 100644 --- a/src/artifacts/GodotPckLocation.ts +++ b/src/artifacts/GodotPckLocation.ts @@ -2,6 +2,7 @@ import type { FileHandle } from "node:fs/promises"; import { hasGodotPckHeader, readGodotPckTrailerFacts, + type GodotPckTrailerFacts, } from "../domain/godotPckFormat.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; @@ -12,6 +13,46 @@ export interface GodotPckLocation { readonly embedded: boolean; } +type DeclaredGodotPckTrailer = Extract< + GodotPckTrailerFacts, + { readonly state: "declared" } +>; + +/** A malformed declaration remains distinct from an examined missing marker. */ +export type EmbeddedGodotPckObservation = + | { readonly state: "absent" } + | { + readonly state: "invalid-extent" | "header-mismatch"; + readonly declaration: DeclaredGodotPckTrailer; + } + | { + readonly state: "located"; + readonly declaration: DeclaredGodotPckTrailer; + readonly start: number; + readonly header: Buffer; + }; + +/** + * Observe an admitted source of at least 12 bytes. Caller-owned exact reads retain + * admission, cancellation, budgets and range evidence; read failures propagate. + */ +export const observeEmbeddedGodotPck = async ( + size: number, + headerBytes: 4 | 20, + read: (offset: number, bytes: number, end: number) => Promise, +): Promise => { + const trailer = await read(size - 12, 12, size); + const declaration = readGodotPckTrailerFacts(trailer, size); + if (declaration.state === "absent") return declaration; + const { start, end, packBytes } = declaration; + if (start === null || packBytes < BigInt(headerBytes)) + return { state: "invalid-extent", declaration }; + const header = await read(start, headerBytes, end); + if (!hasGodotPckHeader(header)) + return { state: "header-mismatch", declaration }; + return { state: "located", declaration, start, header }; +}; + /** Positional exact reads preserve short-read, cancellation and I/O semantics. */ export const readPckBytes = async ( handle: Pick, @@ -67,13 +108,15 @@ export const locateGodotPck = async ( : await readPckBytes(handle, 0, 4, size, signal); if (hasGodotPckHeader(magic)) return { start: 0, end: size, embedded: false }; if (size < 12) return undefined; - const trailer = await readPckBytes(handle, size - 12, 12, size, signal); - const declaration = readGodotPckTrailerFacts(trailer, size); - // The terminal marker is only a candidate until its range resolves to a PCK header. - if (declaration.state === "absent") return undefined; - const { start, end, packBytes } = declaration; - if (start === null || packBytes < 4n) return undefined; - if (!hasGodotPckHeader(await readPckBytes(handle, start, 4, end, signal))) - return undefined; - return { start, end, embedded: true }; + const observation = await observeEmbeddedGodotPck( + size, + 4, + (offset, bytes, end) => readPckBytes(handle, offset, bytes, end, signal), + ); + if (observation.state !== "located") return undefined; + return { + start: observation.start, + end: observation.declaration.end, + embedded: true, + }; }; diff --git a/src/domain/analysisView/artifactInventorySummaryProjection.ts b/src/domain/analysisView/artifactInventorySummaryProjection.ts new file mode 100644 index 0000000000..2abae947ab --- /dev/null +++ b/src/domain/analysisView/artifactInventorySummaryProjection.ts @@ -0,0 +1,55 @@ +import { + artifactInventorySummaryViewResultSchema, + sealAnalysisView, +} from "./analysisView.js"; +import { ARTIFACT_INVENTORY_VIEW_CAVEATS } from "./artifactInventoryViewCaveats.js"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { evidenceSemanticContent, type Evidence } from "../evidence.js"; + +/** Exact shipped projections, rather than arbitrary omission or prose tolerance. */ +export const artifactInventorySummaryProjectionIds = ( + current: Evidence, +): readonly string[] => { + const result = artifactInventorySummaryViewResultSchema.parse( + current.normalized_result, + ); + const format = result.summary.manifest.root_format; + if ( + format === "godot-pck" || + format === "pyinstaller" || + format === "node-sea" + ) + return [current.evidence_id]; + const { view_digest: _digest, ...projection } = result; + // b395190e4 began disclosing unavailable hashes for ordinary root formats. + const beforeHashDisclosure = { + ...projection, + unknowns: projection.unknowns.filter( + (value) => value !== ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownContentHashes, + ), + }; + // ce1f4b1bb began carrying traversal coverage and its caveat for these roots. + const { source_coverage: _coverage, ...summary } = projection.summary; + const beforeTraversalDisclosure = { + ...beforeHashDisclosure, + summary, + unknowns: beforeHashDisclosure.unknowns.filter( + (value) => value !== ARTIFACT_INVENTORY_VIEW_CAVEATS.unknownTraversal, + ), + }; + return [ + current.evidence_id, + ...[beforeHashDisclosure, beforeTraversalDisclosure].map( + (variant) => + `ev_${digestCanonicalValue( + evidenceSemanticContent({ + ...current, + normalized_result: artifactInventorySummaryViewResultSchema.parse( + sealAnalysisView(variant), + ), + }), + "Evidence", + )}`, + ), + ]; +}; From 3052a067a3e5d65470ee142234985a197ff60acb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:09:25 +0800 Subject: [PATCH 22/29] fix(apple): derive NIB hierarchy from observed relationships Replace overlapping traversals with one bounded relationship owner. Preserve superview direction, treat conflicting or unsupported registry placement as unknown, and retain source facts and coverage. --- docs/native-investigation.md | 14 +- .../apple/InterfaceBuilderAnalysis.ts | 89 +---- src/artifacts/apple/NibViewHierarchy.test.ts | 96 ++--- src/artifacts/apple/NibViewHierarchy.ts | 361 +++++++++--------- .../interfaceBuilderNibRelationships.test.ts | 314 +++++++++++++++ .../filesystem/interfaceBuilderBundle.test.ts | 5 +- 6 files changed, 545 insertions(+), 334 deletions(-) create mode 100644 tests/acceptance/applications/interfaceBuilderNibRelationships.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 3bbcd635c3..73d5bc43e9 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -700,10 +700,16 @@ complete observation. release their representation reservation for later documents. Repeated NIBArchive fields retain all serialized values in order. Array outlet connections resolve each serialized member and retain the collection - object ID and member position on the outlet observation. AppKit object-parent - registries use paired keys and parents; objects paired with `NSRoot` are - top-level, while nested objects keep their recorded parent. Conflicting or - unreadable registry pairs remain incomplete hierarchy evidence. + object ID and member position on the outlet observation. NIB hierarchy uses + recognized child fields, reverse `NSSuperview` links and window content links. + AppKit registry pairs with a resolvable `NSRoot` owner establish top-level + membership; other registry associations alone do not establish visual + parentage and leave hierarchy coverage partial. Known non-view cell ownership + and control-view backreferences remain raw facts. Conflicting explicit parents + remain unknown rather than choosing an archive-order winner. Retained objects + keep their decoded fields; uncertain registry diagnostics retain the source + registry, child and value IDs. Missing, malformed or repeated registry + declarations also qualify hierarchy coverage. `NSString` objects with the [recognized `NS.bytes` representation](https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/NSString.swift#L201-L215) are decoded as UTF-8 without substituting malformed bytes. A failed conversion retains the source string object ID and `nibValues.NS.bytes` base64 data; dependent diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index ef2c593229..852dd850c3 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -49,11 +49,7 @@ import { interfaceBuilderLimitsSchema, type InterfaceBuilderDocumentInput, } from "../../domain/apple/interfaceBuilderGraph.js"; -import { - mergeNibHierarchies, - projectNibViewHierarchy, - type NibHierarchyNode, -} from "./NibViewHierarchy.js"; +import { projectNibViewHierarchy } from "./NibViewHierarchy.js"; import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; @@ -806,7 +802,6 @@ const projectNibArchive = ( .map(projectObject), ); const connections: Record = {}; - const hierarchy: NibHierarchyNode[] = []; // A recognized connector whose owner reference cannot be resolved produces no // connection entry, so the omission has to be counted here: the projected // dictionary carries no trace of it for a downstream consumer to find. @@ -904,80 +899,26 @@ const projectNibArchive = ( facets: ["objects", "connections", "classes"], description: `NSString NS.bytes is not valid UTF-8 for ${unavailableStringIds.size} source objects; their bytes and object IDs are retained, while dependent class names, titles and selectors remain unknown.`, }); - let hierarchyOmitted = 0; - let hierarchyNodeCount = 0; - const hierarchyFor = ( - objectId: number, - seen: Set, - depth: number, - ): NibHierarchyNode | null => { - if (depth > 32 || seen.has(objectId)) { - hierarchyOmitted += 1; - return null; - } - if (hierarchyNodeCount >= maxHierarchyNodes) { - hierarchyOmitted += 1; - return null; - } - if (!budget.tryReserve(128)) { - hierarchyOmitted += 1; - return null; - } - const object = byId.get(objectId); - if (object === undefined) { - hierarchyOmitted += 1; - return null; - } - hierarchyNodeCount += 1; - const nextSeen = new Set(seen).add(objectId); - const children: NibHierarchyNode[] = []; - for (const [key, value] of Object.entries(object.values)) { - if (!/(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key)) - continue; - const direct = reference(value); - if (direct === null) continue; - const target = byId.get(direct); - if (target === undefined) { - hierarchyOmitted += 1; - continue; - } - if (/array|set/iu.test(target.class_name)) { - for (const candidate of nibCollectionMembers(target) ?? []) { - const child = reference(candidate); - const nested = - child === null ? null : hierarchyFor(child, nextSeen, depth + 1); - if (nested !== null) children.push(nested); - } - } else { - const nested = hierarchyFor(direct, nextSeen, depth + 1); - if (nested !== null) children.push(nested); - } - } - return { objectID: String(objectId), children }; - }; - const ibData = archive.objects.find( - ({ class_name }) => normalizeClassName(class_name) === "NSIBObjectData", - ); - const rootId = reference(ibData?.values.NSRoot); - const hierarchyRoots = rootId === null ? [] : [rootId]; - for (const hierarchyRoot of hierarchyRoots) { - const root = hierarchyFor(hierarchyRoot, new Set(), 0); - if (root !== null) hierarchy.push(root); - } const viewHierarchy = projectNibViewHierarchy( archive.objects, new Set(Object.keys(objects).map(Number)), () => budget.tryReserve(128), maxHierarchyNodes, - ); - const merged = mergeNibHierarchies( - hierarchy, - viewHierarchy.hierarchy ?? [], - () => budget.tryReserve(96), - maxHierarchyNodes, + (description) => { + budget.reserve( + 256, + "NIB registry hierarchy diagnostics exceed the aggregate Interface Builder decode budget", + ); + budget.reserveJsonStringOccurrence(description); + unexaminedSources.push({ + reason: "nib_registry_parentage_unexamined", + facets: ["hierarchy"], + description, + }); + }, ); return { - omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, + omitted: viewHierarchy.omitted, unexaminedSources, unresolvedConnections, nonFiniteRealCount, @@ -985,7 +926,7 @@ const projectNibArchive = ( raw: jsonValueSchema.parse({ "com.apple.ibtool.document.objects": objects, "com.apple.ibtool.document.connections": connections, - "com.apple.ibtool.document.hierarchy": merged.hierarchy, + "com.apple.ibtool.document.hierarchy": viewHierarchy.hierarchy ?? [], "com.apple.ibtool.document.classes": Object.fromEntries( archive.classes.map((name) => { const className = normalizeClassName(name); diff --git a/src/artifacts/apple/NibViewHierarchy.test.ts b/src/artifacts/apple/NibViewHierarchy.test.ts index bcda88ee0c..4aedb7f11f 100644 --- a/src/artifacts/apple/NibViewHierarchy.test.ts +++ b/src/artifacts/apple/NibViewHierarchy.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - mergeNibHierarchies, - projectNibViewHierarchy, -} from "./NibViewHierarchy.js"; +import { projectNibViewHierarchy } from "./NibViewHierarchy.js"; const object = (id: number, parent?: number) => ({ id, @@ -13,7 +10,7 @@ const object = (id: number, parent?: number) => ({ }); describe("serialized NIB view hierarchy bounds", () => { - it("leaves archives without superview links to the legacy projection", () => { + it("does not invent hierarchy for objects without serialized relationships", () => { expect(projectNibViewHierarchy([object(0)], new Set([0]))).toEqual({ hierarchy: null, omitted: 0, @@ -113,77 +110,32 @@ describe("serialized NIB view hierarchy bounds", () => { }); }); -describe("legacy NIB hierarchy preservation", () => { - it("keeps encoded window parents while merging shared views only once", () => { - const legacy = [ +it("does not treat a dangling registry owner as top-level authority", () => { + const result = projectNibViewHierarchy( + [ { - objectID: "window", - children: [ - { objectID: "view", children: [{ objectID: "first", children: [] }] }, - ], + id: 0, + class_name: "NSIBObjectData", + values: { + NSRoot: { $nib_object_ref: 99 }, + NSObjectsKeys: { $nib_object_ref: 2 }, + NSObjectsValues: { $nib_object_ref: 3 }, + }, }, - ]; - const recovered = [ + object(1), { - objectID: "view", - children: [ - { objectID: "first", children: [] }, - { objectID: "second", children: [] }, - ], + id: 2, + class_name: "NSArray", + values: { "NS.objects": { $nib_object_ref: 1 } }, }, - ]; - expect(mergeNibHierarchies(legacy, recovered).hierarchy).toEqual([ { - objectID: "window", - children: [ - { - objectID: "view", - children: [ - { objectID: "first", children: [] }, - { objectID: "second", children: [] }, - ], - }, - ], + id: 3, + class_name: "NSArray", + values: { "NS.objects": { $nib_object_ref: 99 } }, }, - ]); - }); - it("moves an existing root under its encoded parent without duplicate visits", () => { - expect( - mergeNibHierarchies( - [{ objectID: "child", children: [] }], - [ - { - objectID: "parent", - children: [{ objectID: "child", children: [] }], - }, - ], - ), - ).toEqual({ - hierarchy: [ - { objectID: "parent", children: [{ objectID: "child", children: [] }] }, - ], - omitted: 0, - }); - }); - it("reports conflicting encoded parents rather than dropping a link silently", () => { - const legacy = [ - { objectID: "first", children: [{ objectID: "child", children: [] }] }, - ]; - const recovered = [ - { objectID: "second", children: [{ objectID: "child", children: [] }] }, - ]; - expect(mergeNibHierarchies(legacy, recovered).omitted).toBe(1); - }); - it("does not introduce a cycle when moving an existing root", () => { - const legacy = [ - { objectID: "child", children: [{ objectID: "parent", children: [] }] }, - ]; - const recovered = [ - { objectID: "parent", children: [{ objectID: "child", children: [] }] }, - ]; - expect(mergeNibHierarchies(legacy, recovered)).toEqual({ - hierarchy: legacy, - omitted: 1, - }); - }); + ], + new Set([1]), + ); + expect(result.hierarchy).toEqual([]); + expect(result.omitted).toBeGreaterThan(0); }); diff --git a/src/artifacts/apple/NibViewHierarchy.ts b/src/artifacts/apple/NibViewHierarchy.ts index 6b6741809f..53e7b0bf25 100644 --- a/src/artifacts/apple/NibViewHierarchy.ts +++ b/src/artifacts/apple/NibViewHierarchy.ts @@ -6,25 +6,195 @@ export type NibHierarchyNode = { children: NibHierarchyNode[]; }; -/** Project encoded view parents; archive owners are not inferred view parents. */ +const childCollections = new Set([ + "subviews", + "NSSubviews", + "UISubviews", + "childViewControllers", + "NSChildViewControllers", + "UIChildViewControllers", +]); +const childReferences = new Set([ + "view", + "NSView", + "UIView", + "contentView", + "NSContentView", + "UIContentView", +]); + +// AppKit declares NSButtonCell : NSActionCell : NSCell : NSObject, not NSView. +// NSActionCell encodes its control-view backreference as NSControlView. +const nonViewCellClasses = new Set(["NSCell", "NSActionCell", "NSButtonCell"]); +const isNonViewCell = ( + object: NibArchiveDocument["objects"][number] | undefined, +) => + object !== undefined && + nonViewCellClasses.has(object.class_name.replace(/\0+$/u, "")); + +/** Interpret serialized relationships once, before bounded forest materialization. */ export const projectNibViewHierarchy = ( objects: NibArchiveDocument["objects"], retainedIds: ReadonlySet, reserveNode: () => boolean = () => true, maxNodes = 20_000, + reportUnknownRegistry: (description: string) => void = () => {}, ) => { - const { parents, involved, invalid } = collectNibParents( - objects, - retainedIds, - ); - if (involved.size === 0) return { hierarchy: null, omitted: invalid }; + const byId = new Map(objects.map((object) => [object.id, object])); + const parents = new Map(); + const involved = new Set(); + const registryUnknown = new Set(); + let invalid = 0; + let ownerWithoutRegistry = false; + const addParent = (child: number, parent: number) => { + if (retainedIds.has(parent)) involved.add(parent); + if (!retainedIds.has(child)) { + invalid += 1; + return; + } + involved.add(child); + const previous = parents.get(child); + // A disagreement invalidates placement; archive order is not authority. + parents.set( + child, + previous === undefined || previous === parent ? parent : null, + ); + }; + const fieldValues = ( + object: NibArchiveDocument["objects"][number], + key: string, + ) => + object.repeated_values?.[key] ?? + (Object.hasOwn(object.values, key) ? [object.values[key]!] : []); + const addReference = (value: JsonValue, parent: number) => { + if (value === null) return; + const child = reference(value); + if (child === null) invalid += 1; + else addParent(child, parent); + }; + for (const object of objects) { + if (!retainedIds.has(object.id)) continue; + for (const value of fieldValues(object, "NSSuperview")) { + if (value === null) continue; + const parent = reference(value); + if (parent === null) invalid += 1; + else addParent(object.id, parent); + } + const keys = new Set([ + ...Object.keys(object.values), + ...Object.keys(object.repeated_values ?? {}), + ]); + for (const key of keys) { + if (childCollections.has(key)) { + for (const value of fieldValues(object, key)) { + if (value === null) continue; + const collection = reference(value); + const members = nibCollectionMembers( + collection === null ? undefined : byId.get(collection), + ); + if (members === undefined) invalid += 1; + else { + involved.add(object.id); + for (const member of members) addReference(member, object.id); + } + } + } else if ( + childReferences.has(key) || + (key === "NSWindowView" && + object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate") + ) { + for (const value of fieldValues(object, key)) + addReference(value, object.id); + } else if ( + !["NSSuperview", "NSNextKeyView", "NSPreviousKeyView"].includes(key) && + !(key === "NSControlView" && isNonViewCell(object)) && + /(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key) + ) { + // Preserve formerly admitted fields as raw facts without inventing semantics. + invalid += 1; + } + } + } + for (const object of objects) { + if (object.class_name.replace(/\0+$/u, "") !== "NSIBObjectData") continue; + const registryFields = ["NSRoot", "NSObjectsKeys", "NSObjectsValues"]; + const ambiguous = registryFields.filter( + (key) => fieldValues(object, key).length > 1, + ); + if (ambiguous.length > 0) { + invalid += 1; + reportUnknownRegistry( + `NSIBObjectData object ${object.id} has repeated ${ambiguous.join(", ")} declarations; registry hierarchy interpretation remains unknown.`, + ); + continue; + } + const rootValue = fieldValues(object, "NSRoot")[0]; + const keysValue = fieldValues(object, "NSObjectsKeys")[0]; + const valuesValue = fieldValues(object, "NSObjectsValues")[0]; + const ownerReference = reference(rootValue); + const owner = + ownerReference !== null && byId.has(ownerReference) + ? ownerReference + : null; + if (rootValue !== undefined && rootValue !== null && owner === null) + invalid += 1; + const keysRef = reference(keysValue); + const valuesRef = reference(valuesValue); + if (keysValue === undefined && valuesValue === undefined) { + if (rootValue !== undefined) ownerWithoutRegistry = true; + continue; + } + const keys = nibCollectionMembers( + keysRef === null ? undefined : byId.get(keysRef), + ); + const values = nibCollectionMembers( + valuesRef === null ? undefined : byId.get(valuesRef), + ); + if ( + keys === undefined || + values === undefined || + keys.length !== values.length + ) { + invalid += 1; + continue; + } + for (let index = 0; index < keys.length; index += 1) { + const child = reference(keys[index]); + const parent = reference(values[index]); + if (child === null || parent === null) { + invalid += 1; + continue; + } + if (!retainedIds.has(child) || isNonViewCell(byId.get(child))) continue; + involved.add(child); + // The registry identifies top-level membership through the owner. + // Other registry associations alone do not establish view parentage. + if (owner !== null && parent === owner) { + if (parents.has(child)) parents.set(child, null); + } else { + registryUnknown.add(child); + if (!parents.has(child)) + reportUnknownRegistry( + `NSIBObjectData object ${object.id}: NSObjectsKeys/NSObjectsValues pair child ${child} with registry value ${parent}; NSRoot is ${ownerReference === null ? "unresolved" : owner === null ? `${ownerReference} (unresolved)` : owner}. This decoded registry association does not establish visual parentage.`, + ); + } + } + } + if (ownerWithoutRegistry) invalid += 1; + for (const child of registryUnknown) + if (!parents.has(child)) parents.set(child, null); const children = new Map(); for (const [child, parent] of parents) { - if (!retainedIds.has(parent) || !retainedIds.has(child)) continue; + if (parent === null || !retainedIds.has(parent)) continue; const siblings = children.get(parent) ?? []; siblings.push(child); children.set(parent, siblings); } + if (involved.size === 0) + return { + hierarchy: null, + omitted: invalid, + }; const roots: NibHierarchyNode[] = []; const pending: { id: number; depth: number; output: NibHierarchyNode[] }[] = []; @@ -47,101 +217,7 @@ export const projectNibViewHierarchy = ( for (const child of [...(children.get(item.id) ?? [])].reverse()) pending.push({ id: child, depth: item.depth + 1, output: node.children }); } - return { - hierarchy: roots, - omitted: involved.size - visited.size + invalid, - }; -}; - -/** Add encoded parent links once; conflicting legacy parents remain unknown. */ -export const mergeNibHierarchies = ( - legacy: NibHierarchyNode[], - recovered: NibHierarchyNode[], - reserveNode: () => boolean = () => true, - maxNodes = 20_000, -) => { - const existing = new Map(); - const owners = new Map(); - const parents = new Map(); - const pending = legacy.map((node) => ({ - node, - output: legacy, - parent: null as string | null, - })); - while (pending.length > 0) { - const entry = pending.pop(); - if (entry === undefined || existing.has(entry.node.objectID)) continue; - existing.set(entry.node.objectID, entry.node); - owners.set(entry.node.objectID, entry.output); - if (entry.parent !== null) parents.set(entry.node.objectID, entry.parent); - pending.push( - ...entry.node.children.map((node) => ({ - node, - output: entry.node.children, - parent: entry.node.objectID, - })), - ); - } - let omitted = 0; - const additions = recovered.map((node) => ({ - node, - output: legacy, - parent: null as string | null, - })); - while (additions.length > 0) { - const entry = additions.pop(); - if (entry === undefined) break; - let target = existing.get(entry.node.objectID); - if (target === undefined) { - if (existing.size >= maxNodes || !reserveNode()) { - omitted += 1; - continue; - } - target = { objectID: entry.node.objectID, children: [] }; - existing.set(target.objectID, target); - entry.output.push(target); - owners.set(target.objectID, entry.output); - if (entry.parent !== null) parents.set(target.objectID, entry.parent); - } else if ( - entry.parent !== null && - owners.get(target.objectID) !== entry.output - ) { - const owner = owners.get(target.objectID); - if ( - owner !== legacy || - wouldCycle(target.objectID, entry.parent, parents) - ) { - omitted += 1; - } else { - owner.splice(owner.indexOf(target), 1); - entry.output.push(target); - owners.set(target.objectID, entry.output); - parents.set(target.objectID, entry.parent); - } - } - for (const node of [...entry.node.children].reverse()) - additions.push({ - node, - output: target.children, - parent: target.objectID, - }); - } - return { hierarchy: legacy, omitted }; -}; - -const wouldCycle = ( - child: string, - parent: string, - parents: ReadonlyMap, -): boolean => { - const seen = new Set(); - let current: string | undefined = parent; - while (current !== undefined && !seen.has(current)) { - if (current === child) return true; - seen.add(current); - current = parents.get(current); - } - return false; + return { hierarchy: roots, omitted: involved.size - visited.size + invalid }; }; const reference = (value: JsonValue | undefined): number | null => { @@ -151,84 +227,3 @@ const reference = (value: JsonValue | undefined): number | null => { ? value.$nib_object_ref : null; }; - -const collectNibParents = ( - objects: NibArchiveDocument["objects"], - retainedIds: ReadonlySet, -) => { - const parents = new Map(); - const involved = new Set(); - let invalid = 0; - for (const object of objects) { - if (!retainedIds.has(object.id)) continue; - const child = reference(object.values.NSWindowView); - if ( - object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" && - invalidReference(object.values.NSWindowView) - ) - invalid += 1; - if ( - object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" && - child !== null - ) { - parents.set(child, object.id); - involved.add(object.id); - involved.add(child); - } - const parent = reference(object.values.NSSuperview); - if (invalidReference(object.values.NSSuperview)) invalid += 1; - if (parent === null) continue; - parents.set(object.id, parent); - involved.add(object.id); - if (retainedIds.has(parent)) involved.add(parent); - } - const byId = new Map(objects.map((object) => [object.id, object])); - for (const object of objects) { - if (object.class_name.replace(/\0+$/u, "") !== "NSIBObjectData") continue; - const keysRef = reference(object.values.NSObjectsKeys); - const valuesRef = reference(object.values.NSObjectsValues); - if (keysRef === null && valuesRef === null) continue; - const keys = nibCollectionMembers( - keysRef === null ? undefined : byId.get(keysRef), - ); - const values = nibCollectionMembers( - valuesRef === null ? undefined : byId.get(valuesRef), - ); - if ( - keys === undefined || - values === undefined || - keys.length !== values.length - ) { - invalid += 1; - continue; - } - const rootOwner = reference(object.values.NSRoot); - for (let index = 0; index < keys.length; index += 1) { - const child = reference(keys[index]); - const parent = reference(values[index]); - if (child === null || parent === null) { - invalid += 1; - continue; - } - // Registry keys include non-UI records. Only retained objects contribute - // hierarchy, and a paired NSRoot owner identifies a top-level object. - if (!retainedIds.has(child)) continue; - involved.add(child); - const prior = parents.get(child); - if (parent === rootOwner) { - if (prior !== undefined) invalid += 1; - continue; - } - if (prior !== undefined && prior !== parent) { - invalid += 1; - continue; - } - parents.set(child, parent); - if (retainedIds.has(parent)) involved.add(parent); - } - } - return { parents, involved, invalid }; -}; - -const invalidReference = (value: JsonValue | undefined): boolean => - value !== undefined && value !== null && reference(value) === null; diff --git a/tests/acceptance/applications/interfaceBuilderNibRelationships.test.ts b/tests/acceptance/applications/interfaceBuilderNibRelationships.test.ts new file mode 100644 index 0000000000..043fb57753 --- /dev/null +++ b/tests/acceptance/applications/interfaceBuilderNibRelationships.test.ts @@ -0,0 +1,314 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, onTestFinished } from "vitest"; +import { build } from "plist"; + +import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js"; +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { createTestMcp } from "../../support/mcp/mcpFixture.js"; +import { openBundleResourcesTarget } from "../../support/mcp/artifactSession.js"; + +type Objects = Parameters[0]["objects"]; +const cases: readonly { + name: string; + objects: Objects; + links: readonly (readonly [string, string])[]; + complete: boolean; + registryDiagnostic?: string; + decodeRejected?: boolean; +}[] = [ + { + name: "archive owner alone does not establish a visual hierarchy", + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { classIndex: 3, values: {} }, + ], + links: [], + complete: false, + }, + { + name: "repeated registry declarations", + objects: [ + { + classIndex: 0, + values: [ + ["NSRoot", { ref: 1 }], + ["NSRoot", { ref: 2 }], + ], + }, + { classIndex: 3, values: {} }, + { classIndex: 3, values: {} }, + ], + links: [], + complete: false, + }, + { + name: "malformed registry owner with valid empty collections", + objects: [ + { + classIndex: 0, + values: { + NSRoot: "unknown", + NSObjectsKeys: { ref: 1 }, + NSObjectsValues: { ref: 2 }, + }, + }, + { classIndex: 2, values: {} }, + { classIndex: 2, values: {} }, + ], + links: [], + complete: false, + }, + { + name: "dangling registry owner is rejected at the archive boundary", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 99 }, + NSObjectsKeys: { ref: 2 }, + NSObjectsValues: { ref: 3 }, + }, + }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: { "NS.objects": { ref: 1 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 99 } } }, + ], + links: [], + complete: false, + decodeRejected: true, + }, + { + name: "superview direction when the archive owner is a view", + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 2 } } }, + { classIndex: 1, values: {} }, + ], + links: [["2", "1"]], + complete: false, + }, + { + name: "conflicting child collections and superview", + objects: [ + { classIndex: 1, values: { NSSubviews: { ref: 3 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 2 } } }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: { "NS.objects": { ref: 1 } } }, + ], + links: [], + complete: false, + }, + { + name: "registry ownership without an encoded view parent", + registryDiagnostic: + "NSObjectsKeys/NSObjectsValues pair child 3 with registry value 2", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + NSObjectsKeys: { ref: 4 }, + NSObjectsValues: { ref: 5 }, + }, + }, + { classIndex: 3, values: {} }, + { classIndex: 3, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 2, values: { "NS.objects": { ref: 3 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 2 } } }, + ], + links: [], + complete: false, + }, + { + name: "repeated identical child links", + objects: [ + { classIndex: 1, values: { subviews: { ref: 2 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 0 } } }, + { + classIndex: 2, + values: [ + ["NS.objects", { ref: 1 }], + ["NS.objects", { ref: 1 }], + ], + }, + ], + links: [["0", "1"]], + complete: true, + }, + { + name: "key-view navigation is retained without visual containment", + objects: [ + { + classIndex: 1, + values: { NSNextKeyView: { ref: 1 }, NSPreviousKeyView: { ref: 1 } }, + }, + { classIndex: 1, values: {} }, + { classIndex: 1, values: { NSSuperview: { ref: 0 } } }, + ], + links: [["0", "2"]], + complete: true, + }, + { + name: "known non-view cells retain control backreferences without hierarchy claims", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + NSObjectsKeys: { ref: 4 }, + NSObjectsValues: { ref: 5 }, + }, + }, + { classIndex: 3, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 4, values: { NSControlView: { ref: 2 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 3 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 2 } } }, + { classIndex: 1, values: { NSSuperview: { ref: 2 } } }, + ], + links: [["2", "6"]], + complete: true, + }, + { + name: "unknown cell classes retain unknown registry parentage", + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + NSObjectsKeys: { ref: 4 }, + NSObjectsValues: { ref: 5 }, + }, + }, + { classIndex: 3, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 5, values: { NSControlView: { ref: 2 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 3 } } }, + { classIndex: 2, values: { "NS.objects": { ref: 2 } } }, + ], + links: [], + complete: false, + registryDiagnostic: + "NSObjectsKeys/NSObjectsValues pair child 3 with registry value 2", + }, + { + name: "unrecognized relationship fields", + objects: [ + { classIndex: 1, values: { preview: { ref: 1 } } }, + { classIndex: 1, values: {} }, + ], + links: [], + complete: false, + }, +]; + +cliTest.for(cases)( + "preserves NIB relationship evidence through CLI and MCP: $name", + async ( + { objects, links, complete, registryDiagnostic, decodeRejected }, + { cli }, + ) => { + const root = await createTestTempDirectory("rea-nib-relationships-"); + const app = join(root, "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"), + build({ CFBundleExecutable: "Fixture" }), + ); + await writeFile( + join(resources, "Main.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSIBObjectData", + "NSView", + "NSArray", + "NSCustomObject", + "NSButtonCell", + "CustomButtonCell", + ], + objects, + }), + ); + const output = await cli.run({ + arguments: ["decode-interface-builder", app, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stdout + output.stderr).toBe(0); + if (output.json === null) + throw new Error( + `CLI returned no JSON: ${output.stdout} ${output.stderr}`, + ); + const report = interfaceBuilderAnalysisSchema.parse( + parseEvidence(output.json).normalized_result, + ); + if (decodeRejected) { + expect(report.documents).toEqual([]); + expect(report.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "archive_decode", status: "partial" }), + ); + } else expect(report.documents[0]?.hierarchy_complete).toBe(complete); + const objectId = (id: string | null) => + id === null + ? undefined + : report.graph.nodes.find((node) => node.id === id)?.attributes + .interface_builder_object_id; + const objectLinks = report.graph.edges.flatMap((edge) => { + const from = objectId(edge.from); + const to = objectId(edge.to); + return edge.relation === "contains" && + typeof from === "string" && + typeof to === "string" + ? [[from, to]] + : []; + }); + expect(objectLinks).toEqual(links); + for (const [index, object] of objects.entries()) { + if ( + decodeRejected || + ![1, 4, 5].includes(object.classIndex) || + Array.isArray(object.values) + ) + continue; + const node = report.graph.nodes.find( + ({ attributes }) => + attributes.interface_builder_object_id === String(index), + ); + for (const [key, value] of Object.entries(object.values)) { + if (typeof value !== "object" || value === null || !("ref" in value)) + continue; + expect(node?.attributes.nibValues).toMatchObject({ + [key]: { objectID: String(value.ref) }, + }); + } + } + if (registryDiagnostic !== undefined) + expect(report.limitations.join("\n")).toContain(registryDiagnostic); + const mcp = createTestMcp(); + onTestFinished(async () => mcp.close()); + const client = await openBundleResourcesTarget(mcp, onTestFinished, app); + const result = await client.callTool({ + name: "decode_interface_builder", + arguments: {}, + }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + interfaceBuilderAnalysisSchema.parse( + parseEvidence(result.structuredContent).normalized_result, + ), + ).toEqual(report); + }, +); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index e67b515fe6..844ce0411b 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -745,8 +745,10 @@ describe("bounded Interface Builder archive decoding", () => { } }, ); +}); - it("marks repeated NIB hierarchy references partial at the caller object limit", async () => { +describe("bounded Interface Builder hierarchy and value expansion", () => { + it("deduplicates repeated NIB links while reporting the unavailable owner registry", async () => { const root = await createTestTempDirectory("rea-ib-repeated-hierarchy-"); const bundle = join(root, "Example.app"); await mkdir(bundle, { recursive: true }); @@ -794,6 +796,7 @@ describe("bounded Interface Builder archive decoding", () => { facet: "hierarchy:RepeatedHierarchy.nib", status: "partial", reason: "serialized_view_hierarchy_incomplete", + omitted: 1, }), ); }); From dbe2c97af34361b11b9504e37611dd47715b52cb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:13:32 +0800 Subject: [PATCH 23/29] refactor(ownership): consolidate snapshot and comparison facts Retain authenticated immutable snapshot payloads while keeping mutable exports independent. Derive artifact metadata comparisons once and reuse the owning native trace limits. --- src/contracts/enhancedInputs.ts | 6 +- src/domain/analysisSnapshot.test.ts | 91 ++++++++++++++++++ src/domain/analysisSnapshot.ts | 18 +++- src/domain/artifactComparison.ts | 118 ++++++++++++------------ src/domain/artifactInventoryEvidence.ts | 20 ++-- 5 files changed, 174 insertions(+), 79 deletions(-) diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index 22cc9b0ce1..40e5001934 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -3,6 +3,7 @@ import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMet import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js"; import { decompileDirectoryInputSchema } from "./decompileDirectory.js"; import { nativeValueTraceInputSchema } from "../domain/native/nativeValueTrace.js"; +import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js"; const traceLiteralInputSchema = z.strictObject({ query: z.string().min(1), case_sensitive: z.boolean().default(false), @@ -64,16 +65,13 @@ export const enhancedInputSchemas = { .optional(), direction: callRelationshipDirectionSchema, }), - trace_native_ui_action: z.strictObject({ + trace_native_ui_action: nativeInvestigationTraceLimitsSchema.extend({ action: z .string() .min(1) .describe( "A unique compiled UI action selector, interface object ID, native symbol or exact function address", ), - max_depth: z.int().min(0).max(32).default(8), - max_nodes: z.int().min(1).max(2_000).default(250), - max_edges: z.int().min(1).max(5_000).default(500), }), } as const; diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index acaa36f435..76cf22f7dc 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -5,6 +5,7 @@ import { analysisSnapshotSchema, analysisQueryId, parseAnalysisSnapshot, + immutableAnalysisSnapshot, snapshotBinding, snapshotEvidenceForQuery, snapshotTarget, @@ -639,3 +640,93 @@ describe("analysis snapshot Evidence binding", () => { } }); }); + +describe("analysis snapshot payload ownership", () => { + it.each(["provider", "workflow"] as const)( + "shares authenticated %s payloads only in immutable snapshots", + (kind) => { + const { snapshot } = snapshotWithHistoricalEvidence(); + const entry = snapshot.entries[0]; + if (entry === undefined) throw new Error("Missing fixture query"); + entry.execution.raw_result = { source: "raw" }; + const evidence = createEvidence( + ANALYSIS_SNAPSHOT_TARGET, + ANALYSIS_SNAPSHOT_PROVIDER, + { + operation: entry.operation, + parameters: entry.parameters, + result: entry.execution.result, + rawResult: entry.execution.raw_result, + analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, + confidence: kind === "provider" ? "observed" : "derived", + }, + ); + const input = { + ...snapshot, + entries: kind === "provider" ? [entry] : [], + workflow_entries: + kind === "workflow" + ? [ + { + ...entry, + execution: { + ...entry.execution, + analysis_profile: ANALYSIS_SNAPSHOT_PROFILE, + }, + }, + ] + : [], + evidence_bundle: createEvidenceBundle([evidence]), + }; + const original = structuredClone(input); + const immutable = immutableAnalysisSnapshot(input); + const immutableEntry = ( + kind === "provider" ? immutable.entries : immutable.workflow_entries + )[0]; + const immutableEvidence = immutable.evidence_bundle.records[0]; + if (immutableEntry === undefined || immutableEvidence === undefined) + throw new Error("Missing immutable payload"); + expect(immutableEntry.execution.result).toBe( + immutableEvidence.normalized_result, + ); + expect(immutableEntry.execution.raw_result).toBe( + immutableEvidence.raw_result, + ); + expect(Object.isFrozen(immutableEntry.execution.result)).toBe(true); + expect(Object.isFrozen(immutableEntry.execution.raw_result)).toBe(true); + expect(Object.isFrozen(immutable)).toBe(true); + expect(immutableAnalysisSnapshot(immutable)).toBe(immutable); + expect(parseAnalysisSnapshot(immutable)).toBe(immutable); + expect(input).toEqual(original); + expect(Object.isFrozen(entry.execution.result)).toBe(false); + expect(Object.isFrozen(entry.execution.raw_result)).toBe(false); + const mutable = parseAnalysisSnapshot(input); + const mutableEntry = ( + kind === "provider" ? mutable.entries : mutable.workflow_entries + )[0]; + const mutableEvidence = mutable.evidence_bundle.records[0]; + if (mutableEntry === undefined || mutableEvidence === undefined) + throw new Error("Missing mutable payload"); + expect(mutableEntry.execution.result).not.toBe( + mutableEvidence.normalized_result, + ); + expect(mutableEntry.execution.raw_result).not.toBe( + mutableEvidence.raw_result, + ); + mutableEntry.execution.result = { changed: true }; + mutableEntry.execution.raw_result = { changed: true }; + expect(mutableEvidence).toEqual(evidence); + expect(input).toEqual(original); + const tampered = structuredClone(input); + const tamperedEntry = ( + kind === "provider" ? tampered.entries : tampered.workflow_entries + )[0]; + if (tamperedEntry === undefined) throw new Error("Missing tamper query"); + tamperedEntry.execution.result = { changed: true }; + expect(() => immutableAnalysisSnapshot(tampered)).toThrow( + /differs from its Evidence/u, + ); + expect(Object.isFrozen(tamperedEntry.execution.result)).toBe(false); + }, + ); +}); diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 2b998ccb9a..a4722fa3c2 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -607,13 +607,21 @@ export const createAnalysisSnapshotEntry = (input: { const validatedSnapshots = new WeakMap(); /** Parse a provider/profile-exact snapshot and verify its entries and evidence. */ -export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => { +export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => + authenticateSnapshot(input).parsed; + +const authenticateSnapshot = ( + input: unknown, +): { + readonly parsed: AnalysisSnapshot; + readonly restored: AnalysisSnapshot; +} => { if (typeof input === "object" && input !== null) { const owned = validatedSnapshots.get(input); - if (owned !== undefined) return owned; + if (owned !== undefined) return { parsed: owned, restored: owned }; } const parsed = analysisSnapshotSchema.parse(input); - restoreSnapshot( + const restored = restoreSnapshot( parsed, parseEvidenceBundle(parsed.evidence_bundle), parsed.entries.map((entry) => ({ @@ -635,12 +643,12 @@ export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => { ), })), ); - return parsed; + return { parsed, restored }; }; /** Own an authenticated immutable cache while preserving mutable caller exports. */ export const immutableAnalysisSnapshot = (input: unknown): AnalysisSnapshot => - sealSnapshot(parseAnalysisSnapshot(input)); + sealSnapshot(authenticateSnapshot(input).restored); const sealSnapshot = (snapshot: AnalysisSnapshot): AnalysisSnapshot => { for (const entry of [...snapshot.entries, ...snapshot.workflow_entries]) { diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 54f976cada..78fdedf3e8 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -289,19 +289,13 @@ const compareOccurrences = ( } const leftOccurrence = leftMatches[0]; const rightOccurrence = rightMatches[0]; - if ( - leftOccurrence !== undefined && - rightOccurrence !== undefined && - asarPackingUnknown({ leftOccurrence, rightOccurrence }) - ) - hasUnknownAsarPacking = true; - if ( - leftOccurrence !== undefined && - rightOccurrence !== undefined && - nodeSeaMetadataUnknown({ leftOccurrence, rightOccurrence }) - ) - hasUnknownNodeSeaMetadata = true; + const metadata = occurrenceMetadata({ leftOccurrence, rightOccurrence }); + if (leftOccurrence !== undefined && rightOccurrence !== undefined) { + if (metadata.asarPacking === "unknown") hasUnknownAsarPacking = true; + if (metadata.nodeSea === "unknown") hasUnknownNodeSeaMetadata = true; + } const change = classifyPath({ + metadata, path, leftOccurrence, rightOccurrence, @@ -337,6 +331,7 @@ const occurrencesByPath = ( }; interface ClassifyPathInput { + readonly metadata: OccurrenceMetadata; readonly path: string; readonly leftOccurrence: ArtifactOccurrence | undefined; readonly rightOccurrence: ArtifactOccurrence | undefined; @@ -419,7 +414,8 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => { if (declaration.handled) return declaration.change; const availability = classifyAvailability(input, base); if (availability !== null) - return asarPackingChanged(input) || nodeSeaMetadataChanged(input) + return input.metadata.asarPacking === "changed" || + input.metadata.nodeSea === "changed" ? { ...base, classification: "changed", @@ -427,11 +423,7 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => { } : availability; const dimensions = changedDimensions(input); - if ( - pyInstallerMetadataUnknown(input) || - asarPackingUnknown(input) || - nodeSeaMetadataUnknown(input) - ) { + if (Object.values(input.metadata).includes("unknown")) { if (dimensions.length === 0) return { ...base, @@ -450,35 +442,52 @@ type OccurrencePair = Pick< "leftOccurrence" | "rightOccurrence" >; -const asarPackingUnknown = (input: OccurrencePair): boolean => - (input.leftOccurrence?.unpacked === undefined) !== - (input.rightOccurrence?.unpacked === undefined); +type MetadataComparison = "unchanged" | "changed" | "unknown"; -const asarPackingChanged = (input: OccurrencePair): boolean => - input.leftOccurrence?.unpacked !== undefined && - input.rightOccurrence?.unpacked !== undefined && - input.leftOccurrence.unpacked !== input.rightOccurrence.unpacked; +interface OccurrenceMetadata { + readonly asarPacking: MetadataComparison; + readonly nodeSea: MetadataComparison; + readonly pyInstaller: MetadataComparison; +} -const nodeSeaMetadataUnknown = (input: OccurrencePair): boolean => - (input.leftOccurrence?.format_metadata?.kind === "node-sea-entry") !== - (input.rightOccurrence?.format_metadata?.kind === "node-sea-entry"); - -const nodeSeaMetadataChanged = (input: OccurrencePair): boolean => { - const left = input.leftOccurrence?.format_metadata; - const right = input.rightOccurrence?.format_metadata; - return ( - left?.kind === "node-sea-entry" && - right?.kind === "node-sea-entry" && - canonicalJson(nodeSeaEntryDeclaration(left)) !== - canonicalJson(nodeSeaEntryDeclaration(right)) - ); +const compareOptionalMetadata = ( + left: T | undefined, + right: T | undefined, + declaration: (value: T) => unknown, +): MetadataComparison => { + if (left === undefined || right === undefined) + return left === right ? "unchanged" : "unknown"; + return canonicalJson(declaration(left)) === canonicalJson(declaration(right)) + ? "unchanged" + : "changed"; }; -const pyInstallerMetadataUnknown = (input: ClassifyPathInput): boolean => - (isPyInstallerEntry(input.leftOccurrence) && - !isPyInstallerEntry(input.rightOccurrence)) || - (isPyInstallerEntry(input.rightOccurrence) && - !isPyInstallerEntry(input.leftOccurrence)); +/** Interpret each pair's format declarations once, preserving historical omission. */ +const occurrenceMetadata = (input: OccurrencePair): OccurrenceMetadata => { + const left = input.leftOccurrence; + const right = input.rightOccurrence; + return { + asarPacking: compareOptionalMetadata( + left?.unpacked, + right?.unpacked, + (value) => value, + ), + nodeSea: compareOptionalMetadata( + left?.format_metadata?.kind === "node-sea-entry" + ? left.format_metadata + : undefined, + right?.format_metadata?.kind === "node-sea-entry" + ? right.format_metadata + : undefined, + nodeSeaEntryDeclaration, + ), + pyInstaller: compareOptionalMetadata( + isPyInstallerEntry(left) ? left.format_metadata : undefined, + isPyInstallerEntry(right) ? right.format_metadata : undefined, + pyInstallerEntryDeclaration, + ), + }; +}; const isPyInstallerEntry = ( occurrence: ArtifactOccurrence | undefined, @@ -518,13 +527,7 @@ const classifyPyInstallerDeclaration = ( dimensions: ["availability"], }, }; - const changed = - canonicalJson( - pyInstallerEntryDeclaration(input.leftOccurrence.format_metadata), - ) !== - canonicalJson( - pyInstallerEntryDeclaration(input.rightOccurrence.format_metadata), - ); + const changed = input.metadata.pyInstaller === "changed"; return { handled: true, change: changed @@ -534,6 +537,7 @@ const classifyPyInstallerDeclaration = ( }; const changedDimensions = (input: { + readonly metadata: OccurrenceMetadata; readonly leftNode: ArtifactNode | undefined; readonly rightNode: ArtifactNode | undefined; readonly leftOccurrence: ArtifactOccurrence | undefined; @@ -579,14 +583,15 @@ const changedDimensions = (input: { const metadataChanged = ( input: { + readonly metadata: OccurrenceMetadata; readonly leftOccurrence: ArtifactOccurrence | undefined; readonly rightOccurrence: ArtifactOccurrence | undefined; }, left: ArtifactNode, right: ArtifactNode, ): boolean => - asarPackingChanged(input) || - nodeSeaMetadataChanged(input) || + input.metadata.asarPacking === "changed" || + input.metadata.nodeSea === "changed" || left.media_type !== right.media_type || left.architecture !== right.architecture || left.content_state !== right.content_state || @@ -595,14 +600,7 @@ const metadataChanged = ( JSON.stringify(left.limitations) !== JSON.stringify(right.limitations) || JSON.stringify(input.leftOccurrence?.limitations) !== JSON.stringify(input.rightOccurrence?.limitations) || - (isPyInstallerEntry(input.leftOccurrence) && - isPyInstallerEntry(input.rightOccurrence) && - canonicalJson( - pyInstallerEntryDeclaration(input.leftOccurrence.format_metadata), - ) !== - canonicalJson( - pyInstallerEntryDeclaration(input.rightOccurrence.format_metadata), - )); + input.metadata.pyInstaller === "changed"; const nodeFor = ( occurrence: ArtifactOccurrence | undefined, diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index c19de27874..c91f0dff16 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -246,11 +246,7 @@ const validateInspectionSubject = ( throw new ArtifactInventoryValidationError( "Artifact inspection subject does not match its inventory manifest", ); - if ( - subject === null || - subject.digest.sha256 !== manifest.root_sha256 || - subject.format !== manifest.root_format - ) + if (!subjectMatchesInventoryRoot(subject, manifest)) throw new ArtifactInventoryValidationError( "Artifact inspection Evidence subject does not match its root manifest", ); @@ -295,16 +291,20 @@ const parseInventoryEvidencePage = ( } }; +const subjectMatchesInventoryRoot = ( + subject: Evidence["subject"], + manifest: ArtifactInventoryResult["manifest"], +): boolean => + subject !== null && + subject.digest.sha256 === manifest.root_sha256 && + subject.format === manifest.root_format; + /** Both inventory carriers require the same authenticated root identity. */ export const validateArtifactInventorySubject = ( subject: Evidence["subject"], manifest: ArtifactInventoryResult["manifest"], ): void => { - if ( - subject === null || - subject.digest.sha256 !== manifest.root_sha256 || - subject.format !== manifest.root_format - ) + if (!subjectMatchesInventoryRoot(subject, manifest)) throw new ArtifactInventoryValidationError( "Artifact inventory Evidence subject does not match its root digest", ); From 08f99dbaf238264c58df79b97d0c34ac0419f43a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:12:08 +0800 Subject: [PATCH 24/29] fix(setup): preserve and validate selected JEB endpoints --- src/application/ClientConfigurationFiles.ts | 7 +- src/application/Doctor.ts | 11 ---- src/application/SetupClientConfiguration.ts | 21 +++++- src/application/SetupHost.ts | 1 - .../SetupRegistrationEnvironment.ts | 1 + src/config/environment.ts | 15 +++++ .../jebMcpEndpoint.ts} | 0 src/config/parseConfig.ts | 9 ++- src/doctorRuntime.ts | 9 --- src/jeb/JebMcpEndpoint.test.ts | 2 +- src/jeb/JebProvider.ts | 2 +- src/setupEnvironment.test.ts | 14 ---- .../clientConfigurationEnvironment.test.ts | 62 ++++++++++++++++++ .../setup/setupJebEndpointSelection.test.ts | 65 +++++++++++++++++++ 14 files changed, 173 insertions(+), 46 deletions(-) rename src/{jeb/JebMcpEndpoint.ts => config/jebMcpEndpoint.ts} (100%) create mode 100644 tests/boundary/setup/setupJebEndpointSelection.test.ts diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index c4048e9488..a40d069c89 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -85,11 +85,12 @@ export const readConfigurationFile = async ( } }; -/** Read every applicable user file; malformed lower-priority files also stop setup. */ +/** Read each layer; setup may validate its writable layer with proposed overrides. */ export const readClientConfigurationFiles = async ( client: SetupClient, resources?: ArtifactResourceScope, platform: NodeJS.Platform = process.platform, + writeOverrides?: Readonly>, ): Promise< Result > => { @@ -104,7 +105,7 @@ export const readClientConfigurationFiles = async ( if (document.value !== undefined) { const environment = clientRegistrationEnvironment( document.value, - {}, + path === client.configPath ? writeOverrides : undefined, "fragment", platform, ); @@ -115,7 +116,7 @@ export const readClientConfigurationFiles = async ( } const environment = clientRegistrationEnvironment( effectiveClientConfiguration(documents, undefined, platform), - {}, + writeOverrides, "effective", platform, ); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 9501438f99..355d24ba58 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -138,8 +138,6 @@ export interface DoctorHost { providerInspections?(): Promise; /** Resolve a parsed upstream request budget from the effective client environment. */ resolveProviderRequestBudget?: ProviderRequestBudgetResolver; - /** Validated provider settings preserved by setup, independent of readiness. */ - providerRegistrationEnvironment?(): Readonly>; installationPaths?(): Promise; /** Undefined checks installed skill roots; explicit selections stay scoped. */ installedSkillIdentity?( @@ -523,9 +521,6 @@ export interface SystemDoctorHostOptions { resourceScope: RetainedResourceScope, signal: AbortSignal, ) => Promise; - readonly providerRegistrationEnvironment?: () => Readonly< - Record - >; readonly resolveProviderRequestBudget?: ( provider: Parameters[0], environment: Parameters[1], @@ -726,12 +721,6 @@ export const systemDoctorHost = ( options.providerInspections!(resourceScope, inspectionSignal), ), }), - ...(options.providerRegistrationEnvironment === undefined - ? {} - : { - providerRegistrationEnvironment: - options.providerRegistrationEnvironment, - }), ...(resolveProviderRequestBudget === undefined ? {} : { resolveProviderRequestBudget }), diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index ca0e1b3d05..d58d49158e 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -126,7 +126,12 @@ const configureClientDocument = async ( const format = client.format; if (format === undefined || format === "unsupported") return { status: "failed", reason: "readback" }; - const files = await readClientConfigurationFiles(client); + const files = await readClientConfigurationFiles( + client, + undefined, + undefined, + environment, + ); if (!files.ok) { throwCleanupFailure(files.error); return { status: "failed", reason: "readback" }; @@ -420,7 +425,12 @@ export const clientConfigurationAligned = async ( ): Promise => { if (client.configPathError !== undefined) return false; try { - const files = await readClientConfigurationFiles(client); + const files = await readClientConfigurationFiles( + client, + undefined, + undefined, + providerEnvironment, + ); if (!files.ok) { throwCleanupFailure(files.error); return false; @@ -492,7 +502,12 @@ const inspectClientDocument = async ( if (client.configPathError !== undefined) return { status: "invalid", remediation: client.configPathError }; if (client.format === "unsupported") return { status: "already_current" }; - const files = await readClientConfigurationFiles(client); + const files = await readClientConfigurationFiles( + client, + undefined, + undefined, + providerEnvironment, + ); if (!files.ok) { throwCleanupFailure(files.error); return { diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index f76396e916..569a0b3d16 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -231,7 +231,6 @@ export const systemSetupHost = ( const diagnosis = await runDoctor(undefined, doctorHost, scope); const providerEnvironment: SetupProviderEnvironment = { ...providerRegistrationEnvironment(diagnosis.providerInspections ?? []), - ...doctorHost.providerRegistrationEnvironment?.(), ...(diagnosis.hopperPath === undefined ? {} : { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }), diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index fff31b3223..4eb04d06d1 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -66,6 +66,7 @@ const SELECTED_REGISTRATION_SETTINGS = [ "REA_GHIDRA_PROGRAM_NAME", "REA_GHIDRA_NATIVEAOT_JAR", "REA_HOPPER_STARTUP_TIMEOUT_MS", + "REA_JEB_MCP_URL", "REA_JEB_MCP_TIMEOUT_MS", "REA_MCP_INPUT_SCHEMA_PROFILE", "REA_MCP_TOOL_FAMILIES", diff --git a/src/config/environment.ts b/src/config/environment.ts index 63bea529b7..ba19d64982 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -39,6 +39,7 @@ import { import { mcpToolFamiliesSchema } from "./mcpToolFamilies.js"; import { parseLoaderArgs, parseStringArray } from "./parsers.js"; import { processStartConfigurationSchema } from "./processStart.js"; +import { parseJebMcpEndpoint } from "./jebMcpEndpoint.js"; const environmentSchema = z.object({ REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"), @@ -62,6 +63,20 @@ const environmentSchema = z.object({ .refine(isAbsolute, "REA_IDA_MCP_CONFIG must be absolute") .optional(), REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema.optional(), + REA_JEB_MCP_URL: z + .string() + .superRefine((value, context) => { + const parsed = parseJebMcpEndpoint(value); + if (!parsed.ok) + context.addIssue({ + code: "custom", + message: + typeof parsed.error.cause === "string" + ? parsed.error.cause + : parsed.error.message, + }); + }) + .optional(), GHIDRA_INSTALL_DIR: z .string() .min(1) diff --git a/src/jeb/JebMcpEndpoint.ts b/src/config/jebMcpEndpoint.ts similarity index 100% rename from src/jeb/JebMcpEndpoint.ts rename to src/config/jebMcpEndpoint.ts diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 6a88086d50..7905af5a33 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -40,15 +40,18 @@ export const defaultHopperLauncherPath = ( }; /** - * Environment native analysis admits. JEB validates its own timeout during + * Environment native analysis admits. JEB validates its own settings during * optional provider composition; that setting must not block unrelated * CLI/MCP startup. Registration validation still checks it directly. */ const nativeAnalysisEnvironment = ( environment: Readonly>, ): Readonly> => { - const { REA_JEB_MCP_TIMEOUT_MS: _jebTimeout, ...appEnvironment } = - environment; + const { + REA_JEB_MCP_TIMEOUT_MS: _jebTimeout, + REA_JEB_MCP_URL: _jebUrl, + ...appEnvironment + } = environment; return appEnvironment; }; diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 94dd414460..242ea5fb96 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -9,7 +9,6 @@ import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js"; import { inspectIdaRegistration } from "./ida/IdaDoctor.js"; import { inspectKunaProvider } from "./kuna/KunaDoctor.js"; import { readIdaConfiguration } from "./ida/IdaConfiguration.js"; -import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js"; import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js"; import { ok } from "./domain/result.js"; import { AnalysisCancelledError } from "./domain/analysisErrorCore.js"; @@ -50,17 +49,9 @@ export const createSystemDoctorHost = ( } return registration; }; - const selectedJebEndpoint = environment.REA_JEB_MCP_URL; - const selectedJebRegistrationEnvironment: Readonly> = - selectedJebEndpoint !== undefined && - selectedJebEndpoint.length > 0 && - parseJebMcpEndpoint(selectedJebEndpoint).ok - ? { REA_JEB_MCP_URL: selectedJebEndpoint } - : {}; return systemDoctorHost({ environment, ...(signal === undefined ? {} : { signal }), - providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment, resolveProviderRequestBudget: async ( analysisProvider, registrationEnvironment, diff --git a/src/jeb/JebMcpEndpoint.test.ts b/src/jeb/JebMcpEndpoint.test.ts index d07ad0d586..3415f7d650 100644 --- a/src/jeb/JebMcpEndpoint.test.ts +++ b/src/jeb/JebMcpEndpoint.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseJebMcpEndpoint } from "./JebMcpEndpoint.js"; +import { parseJebMcpEndpoint } from "../config/jebMcpEndpoint.js"; describe("JEB MCP endpoint", () => { it("rejects an explicit empty query or fragment instead of dropping it", () => { diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 6ce87b99f0..8f2380528e 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -31,7 +31,7 @@ import { err, ok, type Result } from "../domain/result.js"; import { parseJebMcpEndpoint, DEFAULT_JEB_MCP_ENDPOINT, -} from "./JebMcpEndpoint.js"; +} from "../config/jebMcpEndpoint.js"; import { createStreamableHttpJebMcpConnection, JEB_ENGINE_TOOLS, diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 0f22cafabe..00a2347709 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -180,20 +180,6 @@ it("preserves a validated selected JEB endpoint in setup registrations without c expect(JSON.stringify(planned.plannedActions)).not.toContain( ambientEndpoint, ); - - expect( - createSystemDoctorHost({}).providerRegistrationEnvironment?.(), - ).toEqual({}); - expect( - createSystemDoctorHost({ - REA_JEB_MCP_URL: "http://127.0.0.1:8425/mcp", - }).providerRegistrationEnvironment?.(), - ).toEqual({ REA_JEB_MCP_URL: "http://127.0.0.1:8425/mcp" }); - expect( - createSystemDoctorHost({ - REA_JEB_MCP_URL: "https://example.com/mcp", - }).providerRegistrationEnvironment?.(), - ).toEqual({}); } finally { vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 54d6b46ac0..8c0a103205 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -461,6 +461,38 @@ it.each(["linux", "win32"] as const)( }, ); +it("retains an unresolved JEB endpoint expression until a selected literal replaces it", async () => { + const parsed = parseClientConfiguration( + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + env: + REA_JEB_MCP_URL: !!js caller.endpoint +`, + "dsh", + ); + const unresolved = await clientRegistrationEnvironmentDetailsWithBudget( + parsed, + {}, + "effective", + undefined, + ); + expect(unresolved.ok).toBe(true); + if (!unresolved.ok) throw unresolved.error; + expect(unresolved.value.unresolvedSettings).toEqual( + new Set(["REA_JEB_MCP_URL"]), + ); + const endpoint = "http://127.0.0.1:8426/mcp/"; + const selected = clientRegistrationEnvironmentDetails(parsed, { + REA_JEB_MCP_URL: endpoint, + }); + expect(selected.ok).toBe(true); + if (!selected.ok) throw selected.error; + expect(selected.value.values.REA_JEB_MCP_URL).toBe(endpoint); + expect(selected.value.unresolvedSettings.size).toBe(0); +}); + it.each( [ "REA_ANALYSIS_PROVIDER", @@ -641,6 +673,36 @@ it("rejects a non-string caller environment key before editing configuration", a }); }); +it("does not hide an invalid JEB endpoint in an untouched configuration layer", async () => { + const root = await createTestTempDirectory("rea-jeb-layer-validation-"); + const lower = join(root, "lower.json"); + const configPath = join(root, "opencode.json"); + const bad = JSON.stringify({ + mcp: { + rea: clientRegistrationEntry("opencode", ["rea", "mcp"], { + REA_JEB_MCP_URL: "https://example.com/mcp", + }), + }, + }); + await writeFile(lower, bad); + await writeFile(configPath, "{}\n"); + const client = { + name: "opencode", + format: "opencode", + configPath, + configPaths: [lower, configPath], + } as const; + const selected = { REA_JEB_MCP_URL: "http://127.0.0.1:8426/mcp" }; + expect( + await inspectClientConfiguration(client, selected, ["rea", "mcp"]), + ).toMatchObject({ + status: "invalid", + remediation: expect.stringContaining(lower), + }); + expect(await readFile(lower, "utf8")).toBe(bad); + expect(await readFile(configPath, "utf8")).toBe("{}\n"); +}); + it("retains legacy OpenCode environment when migrating into a native server table", async () => { const root = await createTestTempDirectory("rea-opencode-environment-"); const configPath = join(root, "opencode.json"); diff --git a/tests/boundary/setup/setupJebEndpointSelection.test.ts b/tests/boundary/setup/setupJebEndpointSelection.test.ts new file mode 100644 index 0000000000..1353d35817 --- /dev/null +++ b/tests/boundary/setup/setupJebEndpointSelection.test.ts @@ -0,0 +1,65 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { createCli } from "../../../src/cli.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("validates selected and existing JEB endpoints without losing caller selection", async () => { + const home = await createTestTempDirectory("rea-setup-jeb-selection-"); + const path = join(home, ".cursor/mcp.json"); + await mkdir(join(home, ".cursor")); + const oldEndpoint = "http://127.0.0.1:8425/mcp"; + const invalidEndpoint = "https://example.com/mcp"; + const selectedEndpoint = "http://127.0.0.1:8426/mcp/"; + const registration = (endpoint: string) => + JSON.stringify({ + mcpServers: { + rea: { + command: "rea-agents", + args: ["mcp"], + env: { REA_JEB_MCP_URL: endpoint }, + }, + }, + }); + const original = registration(oldEndpoint); + await writeFile(path, original); + const env: Record = { + HOME: home, + USERPROFILE: home, + REA_JEB_MCP_URL: invalidEndpoint, + HOPPER_LAUNCHER_PATH: join(home, "missing"), + }; + const setup = async () => { + let stdout = ""; + await createCli(env).serve( + ["setup", "--client", "cursor", "--skill=false", "--yes", "--json"], + { + env, + stdout: (chunk) => { + stdout += chunk; + }, + exit: () => undefined, + }, + ); + return JSON.parse(stdout) as { status: string; remediation?: string }; + }; + + const refused = await setup(); + expect(refused.status).toBe("needs_human"); + expect(refused.remediation).toContain("REA_JEB_MCP_URL"); + expect(await readFile(path, "utf8")).toBe(original); + expect(parseConfig(env).ok).toBe(true); + + delete env.REA_JEB_MCP_URL; + const invalidExisting = registration(invalidEndpoint); + await writeFile(path, invalidExisting); + expect((await setup()).status).toBe("needs_human"); + expect(await readFile(path, "utf8")).toBe(invalidExisting); + + env.REA_JEB_MCP_URL = selectedEndpoint; + const repaired = await setup(); + expect(repaired.status, JSON.stringify(repaired)).toBe("ready"); + const saved = JSON.parse(await readFile(path, "utf8")); + expect(saved.mcpServers.rea.env.REA_JEB_MCP_URL).toBe(selectedEndpoint); +}); From 087acf26e8eca07dfc77bb40f9540c207b1af033 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:16:55 +0800 Subject: [PATCH 25/29] fix(evidence): own cached snapshot data --- .../binary/AnalysisSnapshotCache.test.ts | 60 +++++++++++++++++++ src/domain/analysisSnapshot.ts | 35 +++++------ .../analysisSnapshotProvenance.test.ts | 53 ++++++++++++++++ 3 files changed, 131 insertions(+), 17 deletions(-) diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 86640eb2aa..dae7084061 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -33,6 +33,66 @@ import { import { EvidenceLedger } from "../investigation/EvidenceLedger.js"; import { enhancedOutputSchemas } from "../../contracts/toolOutputSchemaGroups.js"; +it.each(["provider", "workflow"] as const)( + "owns nested %s execution data before returning control to callers", + (kind) => { + const cache = new AnalysisSnapshotCache(); + const target = structuredClone(ANALYSIS_SNAPSHOT_TARGET); + const profile = structuredClone(ANALYSIS_SNAPSHOT_PROFILE); + const analysisProfile = + kind === "provider" ? profile : workflowAnalysisProfile(profile); + const execution = createAnalysisExecution( + { nested: { value: "original" } }, + structuredClone( + kind === "provider" + ? ANALYSIS_SNAPSHOT_PROVIDER + : REA_WORKFLOW_PROVIDER, + ), + { + rawResult: { nested: { value: "raw" } }, + subject: target, + analysisProfile, + locations: [{ kind: "address", address: "0x1000" }], + relationshipCoverage: { + status: "partial", + limitations: ["original coverage"], + }, + }, + ); + const input = { + target, + profile, + operation: "procedure_info" as const, + parameters: { address: "0x1000", document: "fixture" }, + execution: { ...execution, analysisProfile }, + }; + if (kind === "provider") cache.record(input); + else cache.recordWorkflow(input); + const entries = () => + kind === "provider" ? cache.entries() : cache.workflowEntries(); + const expected = entries(); + for (const payload of [execution.result, execution.rawResult]) { + if (payload === null || typeof payload !== "object") + throw new Error("Expected structured observation payload"); + Reflect.set(payload, "nested", { value: "caller edit" }); + } + const location = execution.locations[0]; + if (location !== undefined) Reflect.set(location, "address", "0x2000"); + if (execution.relationshipCoverage?.status === "partial") + execution.relationshipCoverage.limitations.push("caller edit"); + Reflect.set(execution.limitations, 0, "caller limitation"); + Reflect.set(execution.provider, "name", "caller provider"); + Reflect.set(execution.subject, "path", "/tmp/caller-edited-subject"); + if (kind === "workflow") { + const upstream = analysisProfile.parameters.upstream_analysis_profile; + if (upstream === null || typeof upstream !== "object") + throw new Error("Expected nested workflow profile"); + Reflect.set(upstream, "parameters", { loader: "caller profile" }); + } + expect(entries()).toEqual(expected); + }, +); + it("exports independent nested query, workflow and Evidence payloads", () => { const cache = new AnalysisSnapshotCache(); const target = ANALYSIS_SNAPSHOT_TARGET; diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index a4722fa3c2..2cb1084a78 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -358,25 +358,26 @@ type SnapshotExecutionProjection = Pick< | "subject" >; -/** Project one authenticated execution into the shared persisted snapshot shape. */ +/** Own one execution's snapshot data independently of its caller-visible result. */ const snapshotExecutionProjection = ( execution: SnapshotExecution, provider: AnalysisSnapshotEntry["execution"]["provider"], -): SnapshotExecutionProjection => ({ - result: execution.result, - raw_result: execution.rawResult, - provider, - limitations: [...execution.limitations], - relationship_coverage: execution.relationshipCoverage ?? null, - locations: [...execution.locations], - subject: - execution.subject === null - ? null - : subjectSchema.parse({ - ...execution.subject, - architecture: execution.subject.architecture ?? null, - }), -}); +): SnapshotExecutionProjection => + entrySchema.shape.execution.parse({ + result: execution.result, + raw_result: execution.rawResult, + provider, + limitations: execution.limitations, + relationship_coverage: execution.relationshipCoverage ?? null, + locations: execution.locations, + subject: + execution.subject === null + ? null + : { + ...execution.subject, + architecture: execution.subject.architecture ?? null, + }, + }); const canonicalJson = (value: unknown): string => { const json = jsonValueSchema.parse(value); @@ -549,7 +550,7 @@ export const createAnalysisSnapshotWorkflowEntry = (input: { execution, execution.analysisProfile.provider, ), - analysis_profile: execution.analysisProfile, + analysis_profile: analysisProfileSchema.parse(execution.analysisProfile), }, }; }; diff --git a/tests/boundary/filesystem/analysisSnapshotProvenance.test.ts b/tests/boundary/filesystem/analysisSnapshotProvenance.test.ts index 3b42be0b73..e7b3dba979 100644 --- a/tests/boundary/filesystem/analysisSnapshotProvenance.test.ts +++ b/tests/boundary/filesystem/analysisSnapshotProvenance.test.ts @@ -14,6 +14,59 @@ import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js" import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +it("keeps real provider replay and persisted Evidence independent of caller edits", async () => { + const directory = await createTestTempDirectory( + "rea-snapshot-result-ownership-", + ); + const path = join(directory, "application.exe"); + await writeFile(path, buildManagedPeFixture()); + const provider = new ManagedStaticProvider(); + const profile = createAnalysisProfile(MANAGED_STATIC_PROVIDER, { + fixture: "snapshot-result-ownership", + }); + const session = createTestBinarySession(provider, { + resolveAnalysisProfile: () => Promise.resolve(ok({ profile })), + }); + onTestFinished(async () => { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + }); + const opened = await session.open(path); + if (!opened.ok) throw opened.error; + const observed = await session.execute("inspect_managed_artifact", {}); + if (!observed.ok) throw observed.error; + const expected = structuredClone(observed.value.result); + if (observed.value.evidenceId === undefined) + throw new Error("Real managed observation has no Evidence identity"); + const historical = session.evidenceById(observed.value.evidenceId); + if (historical === undefined) throw new Error("Observation was not retained"); + if ( + observed.value.result === null || + typeof observed.value.result !== "object" + ) + throw new Error("Expected a structured managed artifact result"); + Reflect.set(observed.value.result, "caller_edit", "must not enter replay"); + + const replayed = await session.execute("inspect_managed_artifact", {}); + if (!replayed.ok) throw replayed.error; + expect(replayed.value.evidenceId).toBe(observed.value.evidenceId); + expect(replayed.value.result).toEqual(expected); + expect(session.evidenceById(observed.value.evidenceId)).toEqual(historical); + const snapshot = session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + const snapshotPath = join(directory, "analysis.json"); + const written = await session.writeAnalysisSnapshot( + snapshot.value, + snapshotPath, + false, + ); + if (!written.ok) throw written.error; + const restored = await readAnalysisSnapshot(snapshotPath); + if (!restored.ok) throw restored.error; + expect(restored.value.entries[0]?.execution.result).toEqual(expected); + expect(restored.value.evidence_bundle.records).toContainEqual(historical); +}); + it("reruns ambiguous imported observations without changing their historical Evidence", async () => { const directory = await createTestTempDirectory("rea-snapshot-provenance-"); const path = join(directory, "application.exe"); From 0dea08fb072e7a8e9bfa5a72065f688766ad2e0e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:17:01 +0800 Subject: [PATCH 26/29] fix(web): interpret encoded Next route paths --- .../javascriptRouteInventory.test.ts | 83 +++++++++++++++++++ src/domain/javascript/javascriptRoutePaths.ts | 35 ++++++-- src/domain/webBundleAnalyzer.test.ts | 39 +++++++++ 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/domain/javascript/javascriptRouteInventory.test.ts b/src/domain/javascript/javascriptRouteInventory.test.ts index a1423210fb..b4fd09c20a 100644 --- a/src/domain/javascript/javascriptRouteInventory.test.ts +++ b/src/domain/javascript/javascriptRouteInventory.test.ts @@ -421,3 +421,86 @@ it.each([ ); }, ); + +it("decodes captured Next script URL segments once while retaining source URL identity", () => { + const url = + "https://example.test/base/_next/static/chunks/app/%28shop%29/blog/%5Bid%5D/page-abc.js"; + const result = build([ + source("captured-page", "", url), + source("app/blog/%5Bid%5D/page.js", ""), + ]); + expect(result.routes.map(({ path }) => path)).toEqual([ + "/base/blog/:id", + "/blog/%5Bid%5D", + ]); + expect(result.sources.find((entry) => entry.url === url)?.url).toBe(url); + expect(result.occurrences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + origin: "source-url", + unprefixed_path: "/blog/:id", + inferred_base_path: "/base", + }), + ]), + ); +}); + +it.each(["team%2Fadmin", "team%5Cadmin", "%E0%A4%A"])( + "keeps malformed Next URL segment %s unknown alongside usable script facts", + (segment) => { + const url = `https://example.test/_next/static/chunks/app/${segment}/page-abc.js`; + const result = build([source("page", "router.push('/usable');", url)]); + expect(result.routes.map(({ path }) => path)).toEqual(["/usable"]); + expect(result.sources[0]?.url).toBe(url); + expect(result.coverage.candidate_enumeration).toBe("partial"); + expect(result.unknowns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "interpretation", + reason: expect.stringContaining("Next script URL"), + }), + ]), + ); + }, +); + +it("preserves literal percent encoding after one script URL decode", () => { + const result = build([ + source( + "page", + "", + "https://example.test/_next/static/chunks/app/%255Bid%255D/page-abc.js", + ), + ]); + expect(result.routes.map(({ path }) => path)).toEqual(["/%5Bid%5D"]); +}); + +it.each([ + "page%3Fnotes.js", + "page%23notes.js", + "page.js%3Fnotes", + "page.js%23notes", +])("keeps encoded Next script filename delimiters literal: %s", (leaf) => { + const url = `https://example.test/_next/static/chunks/app/dashboard/${leaf}`; + const result = build([source("captured-page", "", url)]); + expect(result.routes).toEqual([]); + expect(result.sources[0]?.url).toBe(url); +}); + +it.each(["?cache=1", "#section", "?cache=1#section"])( + "parses actual Next script URL suffix %s before classifying the filename", + (suffix) => { + const url = `https://example.test/_next/static/chunks/app/dashboard/page-abc.js${suffix}`; + const result = build([source("captured-page", "", url)]); + expect(result.routes.map(({ path }) => path)).toEqual(["/dashboard"]); + expect(result.sources[0]?.url).toBe(url); + }, +); + +it.each(["page?notes.js", "page#notes.js", "page.js?notes", "page.js#notes"])( + "keeps filesystem Next page filename delimiters literal: %s", + (leaf) => { + const result = build([source(`app/dashboard/${leaf}`, "")]); + expect(result.routes).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptRoutePaths.ts b/src/domain/javascript/javascriptRoutePaths.ts index f65cd31f22..ecfe8fd571 100644 --- a/src/domain/javascript/javascriptRoutePaths.ts +++ b/src/domain/javascript/javascriptRoutePaths.ts @@ -62,10 +62,10 @@ export const classifyNextAppRoutePath = ( ): NextAppRoutePath | undefined => { if (raw.includes("\\") || context.kind === "source-reference") return; let value = raw; - if ( + const urlPath = context.kind === "script-url" || - (context.kind === "source-literal" && raw.includes("://")) - ) { + (context.kind === "source-literal" && raw.includes("://")); + if (urlPath) { try { value = new URL(raw).pathname; } catch { @@ -118,11 +118,36 @@ export const classifyNextAppRoutePath = ( }; return; } + if (urlPath) { + // Captured script URLs encode filesystem segments. Decode once here, after + // selecting the Next asset root; source paths retain their literal spelling. + if (/%(?:2f|5c)/iu.test(relative)) + return { + kind: "unknown", + reason: "A Next script URL contains an encoded path separator.", + }; + try { + relative = decodeURIComponent(relative); + } catch (cause: unknown) { + if (!(cause instanceof URIError)) throw cause; + return { + kind: "unknown", + reason: "A Next script URL contains malformed percent encoding.", + }; + } + } const match = - /^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)(?:[?#].*)?$/u.exec( + /^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec( relative, ); - if (match === null || (match[2] !== undefined && !chunk)) return; + if ( + match === null || + (match[2] !== undefined && !chunk) || + // URL parsing already removed URL suffixes. Any decoded delimiter is a + // filename byte, as it is in an inventoried filesystem source path. + (match[3] !== undefined && (urlPath || context.kind === "source-path")) + ) + return; const sourceSegments = (match[1] ?? "").split("/"); // A private folder excludes the whole page, including intercepted ancestors. // Keep filesystem segment spelling: only Next's explicit %5F escape is diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index bd7f9cb6ce..e8bbb3cadb 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -2045,3 +2045,42 @@ it("keeps known capture omissions in compact route summaries and queryable gap f for (const row of gaps.value.items) expect(row).toMatchObject({ source_id: null, provenance: null }); }); + +it("retains captured Next script identity while deriving decoded route patterns", () => { + const url = `${origin}/base/_next/static/chunks/app/%28shop%29/blog/%5Bid%5D/page-abc.js`; + const captured = createWebPageInspection([ + { + source: "router.push('/help');", + scriptKey: `scr_${"2".repeat(64)}`, + url, + origin, + cdpHash: "hash", + }, + ]); + const result = analyzeWebBundleFixture(captured); + expect(result.route_inventory?.routes.map(({ path }) => path)).toEqual([ + "/base/blog/:id", + "/base/help", + ]); + expect(result.route_inventory?.sources).toEqual([ + expect.objectContaining({ + url, + sha256: captured.scripts.items[0]?.source.included + ? captured.scripts.items[0].source.artifact.sha256 + : null, + }), + ]); + expect(result.route_inventory?.occurrences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + origin: "source-url", + unprefixed_path: "/blog/:id", + inferred_base_path: "/base", + }), + expect.objectContaining({ + origin: "source-syntax", + unprefixed_path: "/help", + }), + ]), + ); +}); From 57d0ed45f7559fc3329a6080a92f68420ae52d63 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:17:06 +0800 Subject: [PATCH 27/29] fix(process): retain inherited pipes on output overflow --- src/process/ExecFileOutput.test.ts | 205 +++++++++++++++++++++++++++-- src/process/ExecFileOutput.ts | 197 +++++++++++++++++++-------- 2 files changed, 332 insertions(+), 70 deletions(-) diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 4aacc960c4..e31ffe62ab 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -1,8 +1,8 @@ import { execFile } from "node:child_process"; -import { access, mkdir, readFile, rm } from "node:fs/promises"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; @@ -12,6 +12,8 @@ import { execFileOutputFailure, } from "./ExecFileOutput.js"; +const realSetTimeout = globalThis.setTimeout; + const waitForFile = async (path: string): Promise => { for (let attempt = 0; attempt < 500; attempt += 1) { if ( @@ -21,7 +23,7 @@ const waitForFile = async (path: string): Promise => { ) ) return true; - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => realSetTimeout(resolve, 10)); } return access(path).then( () => true, @@ -29,6 +31,28 @@ const waitForFile = async (path: string): Promise => { ); }; +it.each([undefined, "SIGTERM"] as const)( + "retains native spawn error facts with stop signal %s", + async (stopSignal) => { + const directory = await createTestTempDirectory("rea-exec-missing-"); + const command = join(directory, "missing-executable"); + await expect( + execFileBytes( + command, + ["operand"], + stopSignal === undefined ? {} : { stopSignal }, + ), + ).rejects.toMatchObject({ + code: "ENOENT", + path: command, + spawnargs: ["operand"], + cmd: `${command} operand`, + stdout: "", + stderr: "", + }); + }, +); + describe("execFileOutput", () => { it("ignores non-Error and incomplete failure metadata", () => { expect(execFileOutputFailure("failure")).toBeUndefined(); @@ -45,17 +69,24 @@ describe("execFileOutput", () => { expect(execFileOutputFailure(failure)?.outputTruncated).toBe(true); }); - it("preserves captured bytes for callers that own the output format", async () => { - const result = await execFileBytes(process.execPath, [ - "-e", - "process.stdout.write(Buffer.from([0x00, 0xff, 0x41])); process.stderr.write(Buffer.from([0xfe]));", - ]); + it.each([undefined, "SIGTERM"] as const)( + "preserves captured bytes with stop signal %s", + async (stopSignal) => { + const result = await execFileBytes( + process.execPath, + [ + "-e", + "process.stdout.write(Buffer.from([0x00, 0xff, 0x41])); process.stderr.write(Buffer.from([0xfe]));", + ], + stopSignal === undefined ? {} : { stopSignal }, + ); - expect(Buffer.compare(result.stdout, Buffer.from([0x00, 0xff, 0x41]))).toBe( - 0, - ); - expect(Buffer.compare(result.stderr, Buffer.from([0xfe]))).toBe(0); - }); + expect( + Buffer.compare(result.stdout, Buffer.from([0x00, 0xff, 0x41])), + ).toBe(0); + expect(Buffer.compare(result.stderr, Buffer.from([0xfe]))).toBe(0); + }, + ); it("reports raw byte counts when UTF-8 decoding changes the captured text", async () => { const result = await execFileOutput(process.execPath, [ @@ -92,6 +123,83 @@ describe("execFileOutput", () => { ).rejects.toMatchObject({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }); }); + it.skipIf(process.platform === "win32").each(["stdout", "stderr"] as const)( + "escalates %s overflow and preserves capacity failure when the timeout follows", + async (stream) => { + const directory = await createTestTempDirectory("rea-exec-overflow-"); + const identity = join(directory, "pid"); + const overflowSignal = join(directory, "overflow-signal"); + const script = [ + 'const fs = require("node:fs");', + `process.on("SIGTERM", () => fs.writeFileSync(${JSON.stringify(overflowSignal)}, ""));`, + `fs.writeFileSync(${JSON.stringify(identity)}, String(process.pid));`, + "setInterval(() => {}, 1000);", + `process.${stream}.write("x".repeat(4096));`, + ].join("\n"); + // Hold the parent's deadline until the real child confirms overflow's + // stop signal. Startup speed cannot reorder the tested interruptions. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const execution = execFileOutput(process.execPath, ["-e", script], { + maxBuffer: 64, + stopSignal: "SIGTERM", + timeout: 1_000, + }).catch((cause: unknown) => cause); + try { + expect(await waitForFile(overflowSignal)).toBe(true); + await vi.advanceTimersByTimeAsync(1_000); + await vi.runAllTimersAsync(); + const failure: unknown = await execution; + expect(execFileOutputFailure(failure)).toMatchObject({ + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + outputTruncated: true, + [`${stream}Bytes`]: 64, + }); + const pid = Number(await readFile(identity, "utf8")); + expect(() => process.kill(pid, 0)).toThrow(); + } finally { + await vi.runAllTimersAsync(); + vi.useRealTimers(); + await execution; + } + }, + 10_000, + ); + + it.skipIf(process.platform === "win32")( + "preserves earlier cancellation and reports output truncated during stop", + async () => { + const directory = await createTestTempDirectory( + "rea-exec-abort-overflow-", + ); + const ready = join(directory, "ready"); + const script = [ + 'const fs = require("node:fs");', + 'process.on("SIGTERM", () => { process.stdout.write("x".repeat(4096)); process.exit(0); });', + `fs.writeFileSync(${JSON.stringify(ready)}, "");`, + "setInterval(() => {}, 1000);", + ].join("\n"); + const controller = new AbortController(); + const reason = new Error("caller stopped before overflow"); + const execution = execFileOutput(process.execPath, ["-e", script], { + maxBuffer: 64, + stopSignal: "SIGTERM", + signal: controller.signal, + }).catch((cause: unknown) => cause); + const readyObserved = await waitForFile(ready); + controller.abort(reason); + const failure: unknown = await execution; + expect(readyObserved).toBe(true); + expect(failure).toMatchObject({ + _tag: "ExecFileOutputCancelledError", + cause: reason, + }); + expect(execFileOutputFailure(failure)).toMatchObject({ + outputTruncated: true, + stdoutBytes: 64, + }); + }, + ); + it("retains both captured streams on a failed child process", async () => { await expect( execFileOutput(process.execPath, [ @@ -331,6 +439,77 @@ const stopOwnedDescendant = async ( describe.skipIf(process.platform === "win32")( "execFileOutput inherited pipes", () => { + it("keeps overflowing command scratch files until its inherited-pipe descendant finishes", async () => { + const directory = await createTestTempDirectory( + "rea-exec-overflow-pipes-", + ); + const scratch = join(directory, "scratch"); + const ready = join(directory, "descendant.pid"); + const stopped = join(directory, "launcher-stopped"); + const release = join(directory, "release"); + const finished = join(directory, "finished.json"); + await mkdir(scratch); + const childSource = [ + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(ready)}, String(process.pid));`, + "setInterval(() => {", + ` if (!fs.existsSync(${JSON.stringify(release)})) return;`, + ` fs.writeFileSync(${JSON.stringify(finished)}, JSON.stringify({ scratchExists: fs.existsSync(${JSON.stringify(scratch)}) }));`, + ' process.stdout.write("late output");', + " process.exit(0);", + "}, 10);", + ].join("\n"); + const script = [ + 'const { spawn } = require("node:child_process");', + 'const fs = require("node:fs");', + `process.on("SIGTERM", () => { fs.writeFileSync(${JSON.stringify(stopped)}, ""); process.exit(0); });`, + `spawn(process.execPath, ["-e", ${JSON.stringify(childSource)}], { stdio: "inherit" });`, + "const timer = setInterval(() => {", + ` if (!fs.existsSync(${JSON.stringify(ready)})) return;`, + " clearInterval(timer);", + ' process.stdout.write("x".repeat(4096));', + "}, 10);", + ].join("\n"); + const controller = new AbortController(); + let completed = false; + const execution = execFileOutput(process.execPath, ["-e", script], { + maxBuffer: 64, + stopSignal: "SIGTERM", + signal: controller.signal, + }) + .finally(async () => { + completed = true; + await rm(scratch, { recursive: true, force: true }); + }) + .catch((cause: unknown) => cause); + let killFailure: unknown; + try { + expect(await waitForFile(stopped)).toBe(true); + const pid = Number(await readFile(ready, "utf8")); + expect((await descendantCommand(pid)).includes(finished)).toBe(true); + expect(completed).toBe(false); + await expect(access(scratch)).resolves.toBeUndefined(); + await writeFile(release, ""); + const failure: unknown = await execution; + expect(execFileOutputFailure(failure)).toMatchObject({ + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", + outputTruncated: true, + stdoutBytes: 64, + }); + expect(JSON.parse(await readFile(finished, "utf8"))).toEqual({ + scratchExists: true, + }); + await expect(access(scratch)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await writeFile(release, ""); + controller.abort(); + await execution; + const pid = Number(await readFile(ready, "utf8").catch(() => "")); + killFailure = await stopOwnedDescendant(pid, finished); + } + if (killFailure !== undefined) throw killFailure; + }, 15_000); + it("keeps scratch files until an inherited-pipe descendant closes them", async () => { const directory = await createTestTempDirectory("rea-exec-inherited-"); const scratch = join(directory, "scratch"); diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 2c9dc591fd..0174e6f7ff 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -1,6 +1,7 @@ import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import { execFile, + spawn, type ExecFileOptionsWithBufferEncoding, } from "node:child_process"; @@ -158,67 +159,149 @@ export const execFileBytes = ( reject(new ExecFileOutputCancelledError(signal.reason)); return; } - let stopped: "abort" | "timeout" | undefined; - let deadline: NodeJS.Timeout | undefined; - let grace: NodeJS.Timeout | undefined; - const child = execFile( + const complete = ( + error: Error | null, + stdout: Buffer, + stderr: Buffer, + ): void => { + if (error !== null) { + Reflect.set(error, "stdout", stdout.toString("utf8")); + Reflect.set(error, "stderr", stderr.toString("utf8")); + Reflect.set(error, "stdoutBytes", stdout.byteLength); + Reflect.set(error, "stderrBytes", stderr.byteLength); + reject(error); + return; + } + resolve({ stdout, stderr }); + }; + if (stopSignal === undefined) { + execFile( + command, + [...arguments_], + { + ...execOptions, + ...(signal === undefined ? {} : { signal }), + ...(timeout === undefined ? {} : { timeout }), + encoding: "buffer", + maxBuffer: options.maxBuffer ?? Number.POSITIVE_INFINITY, + }, + (error, stdout, stderr) => + complete( + error?.name === "AbortError" && error.code === "ABORT_ERR" + ? new ExecFileOutputCancelledError(error.cause) + : error, + stdout, + stderr, + ), + ); + return; + } + startStoppableCommand( command, - [...arguments_], - { - ...execOptions, - // A stoppable command's abort and timeout are handled below, because - // execFile neither waits for the exit on abort nor kills a process - // that ignores its timeout signal. - ...(stopSignal === undefined - ? { - ...(signal === undefined ? {} : { signal }), - ...(timeout === undefined ? {} : { timeout }), - } - : { killSignal: stopSignal }), - encoding: "buffer", - maxBuffer: options.maxBuffer ?? Number.POSITIVE_INFINITY, - }, - (error, stdout, stderr) => { - signal?.removeEventListener("abort", onAbort); - clearTimeout(deadline); - clearTimeout(grace); - const failure = - stopped === "abort" && signal !== undefined - ? new ExecFileOutputCancelledError(signal.reason) - : stopped === "timeout" && timeout !== undefined - ? timeoutError(command, timeout, error) - : error?.name === "AbortError" && error.code === "ABORT_ERR" - ? new ExecFileOutputCancelledError(error.cause) - : error; - if (failure !== null) { - Reflect.set(failure, "stdout", stdout.toString("utf8")); - Reflect.set(failure, "stderr", stderr.toString("utf8")); - Reflect.set(failure, "stdoutBytes", stdout.byteLength); - Reflect.set(failure, "stderrBytes", stderr.byteLength); - reject(failure); - return; - } - resolve({ stdout, stderr }); - }, + arguments_, + { ...options, stopSignal }, + complete, ); - // The first stop request names the failure; a later one is not resent. - const stop = (reason: "abort" | "timeout"): void => { - if (stopped !== undefined) return; - stopped = reason; - child.kill(stopSignal); - grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); - }; - const onAbort = (): void => { - stop("abort"); - }; - if (stopSignal === undefined) return; - signal?.addEventListener("abort", onAbort, { once: true }); - if (timeout !== undefined && timeout > 0) - deadline = setTimeout(() => { - stop("timeout"); - }, timeout); }); +/** Own stop requests and bounded capture until the root and inherited pipes close. */ +const startStoppableCommand = ( + command: string, + arguments_: readonly string[], + options: ExecFileOutputOptions & { readonly stopSignal: NodeJS.Signals }, + complete: (error: Error | null, stdout: Buffer, stderr: Buffer) => void, +): void => { + const { stopSignal, signal, timeout, ...execOptions } = options; + const maxBuffer = options.maxBuffer ?? Number.POSITIVE_INFINITY; + if (typeof maxBuffer !== "number" || !(maxBuffer >= 0)) { + const error = new RangeError("maxBuffer must be a nonnegative number"); + Reflect.set(error, "code", "ERR_OUT_OF_RANGE"); + throw error; + } + let stopped: "abort" | "timeout" | "output-limit" | undefined; + let processError: Error | null = null; + let outputError: Error | undefined; + let deadline: NodeJS.Timeout | undefined; + let grace: NodeJS.Timeout | undefined; + // Node's execFile destroys both pipes when maxBuffer is exceeded. Own + // bounded capture here so overflow stops the root without discarding the + // pipe lifetime of descendants still using caller-owned temporary files. + const child = spawn(command, [...arguments_], { + ...execOptions, + stdio: "pipe", + killSignal: stopSignal, + }); + // The first stop request names the failure; a later one is not resent. + const stop = (reason: "abort" | "timeout" | "output-limit"): void => { + if (stopped !== undefined) return; + stopped = reason; + child.kill(stopSignal); + grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); + }; + const onAbort = (): void => { + stop("abort"); + }; + const capture = (stream: typeof child.stdout, name: "stdout" | "stderr") => { + const chunks: Buffer[] = []; + let retained = 0; + stream.on("data", (chunk: Buffer) => { + const remaining = Math.max(0, Math.floor(maxBuffer - retained)); + if (chunk.byteLength <= remaining) { + chunks.push(chunk); + retained += chunk.byteLength; + return; + } + if (remaining > 0) { + chunks.push(Buffer.from(chunk.subarray(0, remaining))); + retained += remaining; + } + if (outputError === undefined) { + outputError = new RangeError(`${name} maxBuffer length exceeded`); + Reflect.set(outputError, "code", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); + stop("output-limit"); + } + }); + return () => Buffer.concat(chunks, retained); + }; + const stdout = capture(child.stdout, "stdout"); + const stderr = capture(child.stderr, "stderr"); + child.on("error", (error: Error) => { + processError ??= error; + }); + child.on("close", (code, exitSignal) => { + signal?.removeEventListener("abort", onAbort); + clearTimeout(deadline); + clearTimeout(grace); + const out = stdout(); + const diagnostic = stderr(); + const invocation = [command, ...arguments_].join(" "); + if (processError === null && (code !== 0 || exitSignal !== null)) { + processError = new Error( + `Command failed: ${invocation}\n${diagnostic.toString("utf8")}`, + ); + Reflect.set(processError, "code", code); + Reflect.set(processError, "signal", exitSignal); + Reflect.set(processError, "killed", child.killed); + } + if (processError !== null) Reflect.set(processError, "cmd", invocation); + if (outputError !== undefined) Reflect.set(outputError, "cmd", invocation); + const failure = + stopped === "abort" && signal !== undefined + ? new ExecFileOutputCancelledError(signal.reason) + : stopped === "timeout" && timeout !== undefined + ? timeoutError(command, timeout, processError) + : (outputError ?? processError); + if (failure !== null && outputError !== undefined) + Reflect.set(failure, "outputTruncated", true); + complete(failure, out, diagnostic); + }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (timeout !== undefined && timeout > 0) + deadline = setTimeout(() => { + stop("timeout"); + }, timeout); +}; + /** Run a shell-free command while capturing its complete UTF-8 output. */ export const execFileOutput = ( command: string, From 8706068b57de06141b7d41d1b07ac63d4c7a41cc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:19:46 +0800 Subject: [PATCH 28/29] fix(contracts): validate provider boundary facts consistently Count only canonical inventory rows in binary overview, reuse the owned Objective-C coverage schemas, and share firmware input issue projection. --- src/application/BinaryOverview.test.ts | 91 +++++++++++++++++++ src/application/EnhancedTools.ts | 12 ++- .../firmware/FirmwareAnalysisService.test.ts | 41 ++++++++- .../firmware/FirmwareAnalysisService.ts | 17 +--- src/contracts/toolOutputSchemaPrimitives.ts | 17 +--- src/domain/native/nativeInventory.ts | 28 +++--- .../native/nativeProviderOutput.test.ts | 36 -------- src/domain/native/objcSwiftMetadata.ts | 27 ++++-- src/domain/symbolAnalysis.ts | 10 +- ...4-uses-complete-inventories-inline.test.ts | 52 ++++++++++- 10 files changed, 230 insertions(+), 101 deletions(-) diff --git a/src/application/BinaryOverview.test.ts b/src/application/BinaryOverview.test.ts index 6c38350b36..310f9e8373 100644 --- a/src/application/BinaryOverview.test.ts +++ b/src/application/BinaryOverview.test.ts @@ -6,6 +6,7 @@ import { AnalysisCapabilityUnavailableError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; it("uses the provider-owned overview without requesting complete inventories", async () => { @@ -59,3 +60,93 @@ it.each([ expect(calls).toEqual(["read_binary_inventory_summary"]); }, ); + +const fallbackOverview = (procedures: JsonValue, strings: JsonValue) => + new EnhancedTools({ + execute: async (operation) => { + if ( + operation === "read_binary_inventory_summary" || + operation === "list_segments" + ) + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "Not supported", + ), + ); + const result = + operation === "current_document" + ? "selected" + : operation === "list_procedures" + ? procedures + : strings; + return ok( + createAnalysisExecution(result, { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); + }, + }); + +it.each(["list_procedures", "list_strings"] as const)( + "rejects malformed successful %s inventories in the public overview workflow", + async (operation) => { + for (const malformed of [ + { error: "inventory unavailable" }, + { "0x1000": "obsolete map" }, + { [operation === "list_procedures" ? "procedures" : "strings"]: [] }, + JSON.parse('{"__proto__":"observed"}'), + [null], + [{ address: "0x1000" }], + [{ address: "0x1000", value: 1 }], + ]) { + const tools = fallbackOverview( + operation === "list_procedures" ? malformed : [], + operation === "list_strings" ? malformed : [], + ); + const result = await tools.execute("binary_overview", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisOutputError); + if (result.error instanceof AnalysisOutputError) + expect(result.error.operation).toBe(operation); + } + } + }, +); + +it("counts canonical fallback inventories including typed and display-only strings", async () => { + const tools = fallbackOverview( + [{ address: "0x1000", value: "entry" }], + [ + { address: "0x2000", value: "display-only" }, + { + address: "0x2000", + value: "typed", + provider_value: "source display", + string: { + encoding: "utf-8", + termination: "present_or_not_required", + byte_length: 5, + }, + }, + ], + ); + expect(await tools.execute("binary_overview", {})).toEqual( + ok({ + document: "selected", + procedure_count: 1, + string_count: 2, + }), + ); + expect(await fallbackOverview([], []).execute("binary_overview", {})).toEqual( + ok({ + document: "selected", + procedure_count: 0, + string_count: 0, + }), + ); +}); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 89179d6c08..a23d66aa20 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -23,7 +23,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { parseFunctionDossier } from "../domain/native/functionDossier.js"; import { - parseListCount, + parseNativeInventoryCount, addressedValueSchema, } from "../domain/native/nativeInventory.js"; import { parseRelatedAddresses } from "../domain/native/nativeObservation.js"; @@ -763,9 +763,15 @@ export class EnhancedTools { ? parseSegments(segmentsResult.value) : undefined; if (segments !== undefined && !segments.ok) return segments; - const procedureCount = parseListCount(proceduresResult.value, "procedures"); + const procedureCount = parseNativeInventoryCount( + proceduresResult.value, + "list_procedures", + ); if (!procedureCount.ok) return procedureCount; - const stringCount = parseListCount(stringsResult.value, "strings"); + const stringCount = parseNativeInventoryCount( + stringsResult.value, + "list_strings", + ); if (!stringCount.ok) return stringCount; return ok( diff --git a/src/application/firmware/FirmwareAnalysisService.test.ts b/src/application/firmware/FirmwareAnalysisService.test.ts index 90be958f77..b39c69d152 100644 --- a/src/application/firmware/FirmwareAnalysisService.test.ts +++ b/src/application/firmware/FirmwareAnalysisService.test.ts @@ -1,4 +1,5 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; +import { ZodError } from "zod"; import { FirmwareAnalysisService } from "./FirmwareAnalysisService.js"; import { createAnalysisExecution } from "../AnalysisProvider.js"; @@ -65,6 +66,44 @@ const fixture = ( return { input, execution }; }; +it.each([ + { + input: {}, + issue: { path: ["path"], reason: "missing_argument" }, + }, + { + input: { path: 17 }, + issue: { path: ["path"], reason: "invalid_type" }, + }, + { + input: { path: "/firmware.bin", range: { offset: -1, length: 1 } }, + issue: { path: ["range", "offset"], reason: "out_of_range" }, + }, + { + input: { path: "/firmware.bin", unexpected: "caller value" }, + issue: { path: ["unexpected"], reason: "unknown_argument" }, + }, +])( + "rejects malformed firmware inputs with caller-relative issues: $issue", + async ({ input, issue }) => { + const execute = vi.fn(); + const result = await new FirmwareAnalysisService({ execute }).execute( + "extract_firmware", + { output_directory: "/output", ...input }, + ); + expect(execute).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + operation: "extract_firmware", + cause: expect.any(ZodError), + issues: expect.arrayContaining([expect.objectContaining(issue)]), + }, + }); + }, +); + it.each(["inspect_firmware_regions", "extract_firmware"] as const)( "retains %s Evidence after late cancellation and preserves provider failure precedence", async (operation) => { diff --git a/src/application/firmware/FirmwareAnalysisService.ts b/src/application/firmware/FirmwareAnalysisService.ts index cc05d63997..af74216d8d 100644 --- a/src/application/firmware/FirmwareAnalysisService.ts +++ b/src/application/firmware/FirmwareAnalysisService.ts @@ -9,12 +9,11 @@ import { } from "../../domain/firmware/firmwareAnalysis.js"; import { AnalysisCancelledError, - AnalysisInputError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Evidence } from "../../domain/evidence.js"; -import { projectInputIssues } from "../../domain/inputIssueProjection.js"; +import { analysisOperationInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; @@ -39,17 +38,11 @@ export class FirmwareAnalysisService { const parsed = firmwareRequestSchema.safeParse({ operation, input }); if (!parsed.success) return err( - new AnalysisInputError( + analysisOperationInputErrorFromIssues( operation, - { cause: parsed.error }, - projectInputIssues(parsed.error.issues, { operation, input }).map( - (issue) => ({ - ...issue, - // `input` is the internal discriminated request envelope. - path: - issue.path[0] === "input" ? issue.path.slice(1) : issue.path, - }), - ), + parsed.error.issues, + input, + parsed.error, ), ); const executed = await this.provider.execute(parsed.data, options); diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 1b1e42c73b..3aaa48332a 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -19,7 +19,10 @@ import { PROVIDER_RETRY_ACTIONS, } from "../domain/providerOperationHealth.js"; import { analysisErrorProjectionSchema } from "./errorSchemas.js"; -import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js"; +import { + objcClassDiscoveryCoverageSchema, + objcClassImportIdentitySchema, +} from "../domain/native/objcSwiftMetadata.js"; import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "./sessionStatusContract.js"; /** Specialize the single normalized result inside a complete Evidence record. */ @@ -683,17 +686,7 @@ export const objcClassDiscoveryOutput = evidenceResultOf( .optional(), }), ), - identity_coverage: z.strictObject({ - status: z.enum([ - "complete", - "partial", - "unsupported", - "not_requested", - "unknown", - "not_applicable", - ]), - reason: z.string().nullable(), - }), + identity_coverage: objcClassDiscoveryCoverageSchema, }), ); diff --git a/src/domain/native/nativeInventory.ts b/src/domain/native/nativeInventory.ts index ef7d932730..b8ca5d7eb1 100644 --- a/src/domain/native/nativeInventory.ts +++ b/src/domain/native/nativeInventory.ts @@ -4,11 +4,7 @@ import { analysisStringSchema } from "./nativeString.js"; import { ok, type Result } from "../result.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; import type { JsonValue } from "../jsonValue.js"; -import { jsonObjectSchema } from "../jsonValue.js"; -import { - unwrapNativeProperty, - invalidNativeOutput, -} from "./nativeProviderOutput.js"; +import { invalidNativeOutput } from "./nativeProviderOutput.js"; /** Canonical named native observations; same-address alternatives stay separate. */ export const addressedValueSchema = z.object({ @@ -69,17 +65,17 @@ export const analysisBookmarkSchema = z.strictObject({ address: z.string(), name: z.string().nullable(), }); -/** Parse a direct or wrapped list when only its cardinality is required. */ -export const parseListCount = ( +/** Count only complete canonical inventories, using the advertised row contracts. */ +export const parseNativeInventoryCount = ( value: JsonValue, - property: string, - operation = property === "procedures" ? "list_procedures" : "list_strings", + operation: "list_procedures" | "list_strings", ): Result => { - const unwrapped = unwrapNativeProperty(value, property); - const list = z.array(z.unknown()).safeParse(unwrapped); - if (list.success) return ok(list.data.length); - const map = jsonObjectSchema.safeParse(unwrapped); - return map.success - ? ok(Object.keys(map.data).length) - : invalidNativeOutput(operation, `${property} list`, map.error); + const schema = + operation === "list_procedures" + ? nativeInventoryRowsSchemas.names + : nativeInventoryRowsSchemas.strings; + const parsed = schema.safeParse(value); + return parsed.success + ? ok(parsed.data.length) + : invalidNativeOutput(operation, "inventory rows", parsed.error); }; diff --git a/src/domain/native/nativeProviderOutput.test.ts b/src/domain/native/nativeProviderOutput.test.ts index f315587800..69ac6b4a6c 100644 --- a/src/domain/native/nativeProviderOutput.test.ts +++ b/src/domain/native/nativeProviderOutput.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { addressDistance } from "./addressCoordinate.js"; import { parseDocuments } from "./nativeDocument.js"; -import { parseListCount } from "./nativeInventory.js"; import { parseRelatedAddresses } from "./nativeObservation.js"; import { parseSegments } from "./nativeSegments.js"; @@ -32,12 +31,6 @@ describe("native provider boundary values", () => { parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok, ).toBe(false); }); - it("counts Hopper's address-keyed string maps", () => { - expect( - parseListCount({ "0x1000": "hello", "0x2000": "world" }, "strings"), - ).toEqual({ ok: true, value: 2 }); - }); - it("parses direct and wrapped protocol shapes", () => { expect(parseRelatedAddresses({ callers: ["0x3"] }, "callers")).toEqual({ ok: true, @@ -98,32 +91,3 @@ describe("native provider boundary values", () => { } }); }); - -it.each(["procedures", "strings"])( - "counts every own entry of direct and wrapped %s maps", - (property) => { - for (const text of [ - '{"__proto__":"observed"}', - '{"__proto__":"observed","0x1000":"one"}', - ]) { - const source = JSON.parse(text); - const count = Object.keys(source).length; - expect(parseListCount(source, property)).toEqual({ - ok: true, - value: count, - }); - expect(parseListCount({ [property]: source }, property)).toEqual({ - ok: true, - value: count, - }); - expect(JSON.stringify(source)).toBe(text); - } - expect(parseListCount({ [property]: [] }, property)).toEqual({ - ok: true, - value: 0, - }); - expect(parseListCount({}, property)).toEqual({ ok: true, value: 0 }); - for (const value of [null, "not a list", 3, false]) - expect(parseListCount(value, property).ok).toBe(false); - }, -); diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index baa3368ec1..c5cfa26041 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -316,17 +316,30 @@ export const nativeMetadataCoverageSchema = z.strictObject({ export const objcClassImportIdentitySchema = machoImportBindingSchema.omit({ requested_address: true, }); -export const objcClassImportBindingCoverageSchema = z.strictObject({ - facet: z.literal("objc_class_import_bindings"), - status: z.enum(["complete", "partial", "unsupported", "not_requested"]), - reason: z.string().nullable(), - examined: z.number().int().nonnegative(), - decoded: z.number().int().nonnegative(), -}); +export const objcClassImportBindingCoverageSchema = + nativeMetadataCoverageSchema.extend({ + facet: z.literal("objc_class_import_bindings"), + examined: z.number().int().nonnegative(), + decoded: z.number().int().nonnegative(), + }); export type ObjcClassImportBindingCoverage = z.infer< typeof objcClassImportBindingCoverageSchema >; +/** Discovery retains unknown or inapplicable import identity separately from scan coverage. */ +export const objcClassDiscoveryCoverageSchema = nativeMetadataCoverageSchema + .pick({ status: true, reason: true }) + .extend({ + status: z.enum([ + ...nativeMetadataCoverageSchema.shape.status.options, + "unknown", + "not_applicable", + ]), + }); +export type ObjcClassDiscoveryCoverage = z.infer< + typeof objcClassDiscoveryCoverageSchema +>; + /** Nominal Swift type kinds recorded by `__swift5_types` descriptors. */ export const swiftDeclKindSchema = z.enum(["class", "struct", "enum", "actor"]); diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index e178437d1f..66b3264501 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -3,7 +3,7 @@ import type { JsonValue } from "./jsonValue.js"; import type { AddressedName } from "./native/nativeInventory.js"; import type { MachoImportBinding, - ObjcClassImportBindingCoverage, + ObjcClassDiscoveryCoverage, } from "./native/objcSwiftMetadata.js"; import { classifySwiftSymbol, @@ -20,14 +20,6 @@ const LEGACY_OBJC_CLASS_PREFIX = "l_OBJC_CLASS_" as const; */ const OBJC_PROTOCOL_MARKER = "OBJC_PROTOCOL_$_" as const; -export interface ObjcClassDiscoveryCoverage { - readonly status: - | ObjcClassImportBindingCoverage["status"] - | "unknown" - | "not_applicable"; - readonly reason: string | null; -} - export interface ObjcClassImportBindingScan { readonly bindings: readonly MachoImportBinding[]; readonly coverage: ObjcClassDiscoveryCoverage; diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index 0a45132738..ee0e53b924 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -6,12 +6,12 @@ import { connect as connectTool, jsonResult, } from "./enhancedToolsHarness.js"; -import type { - AnalysisExecution, - AnalysisOperationPort, +import { + createAnalysisExecution, + type AnalysisExecution, + type AnalysisOperation, + type AnalysisOperationPort, } from "../../../src/application/AnalysisProvider.js"; -import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; -import type { AnalysisOperation } from "../../../src/application/AnalysisProvider.js"; import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError, @@ -22,6 +22,7 @@ import { ok as resultOk, type Result, } from "../../../src/domain/result.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; @@ -411,6 +412,47 @@ describe("enhanced MCP binary overview", () => { }); describe("enhanced MCP inventory validation", () => { + // Injected provider successes verify public MCP composition, not real-provider behavior. + it.each(["list_procedures", "list_strings"] as const)( + "rejects malformed successful %s inventory without manufacturing overview counts", + async (operation) => { + let inventory: JsonValue = []; + const client = await connect({ + execute: (name, parameters, options) => + name === operation + ? Promise.resolve(ok(inventory)) + : completeOverviewPort.execute(name, parameters, options), + }); + for (const malformed of [ + { error: "inventory unavailable" }, + { "0x1000": "obsolete map" }, + [{ address: "0x1000", value: 7 }], + ]) { + inventory = malformed; + const response = await client.callTool({ + name: "binary_overview", + arguments: {}, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "unreadable_output", + details: { operation }, + }); + } + inventory = [{ address: "0x1000", value: "canonical control" }]; + const response = await client.callTool({ + name: "binary_overview", + arguments: {}, + }); + expect(response.isError).not.toBe(true); + expect(jsonResult(response)).toMatchObject({ + document: "fixture", + procedure_count: operation === "list_procedures" ? 1 : 2, + string_count: operation === "list_strings" ? 1 : 700, + }); + }, + ); + it("rejects paginated provider output", async () => { const client = await connect({ execute: () => From 4b233f7670a1912921eac15ad1793e135b62a85e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 26 Oct 2026 16:31:53 -0700 Subject: [PATCH 29/29] fix(environment): keep unloaded create answers a sent value did not echo, and learn no sort from query-sorted reads - A served create keeps a value its recorded answer had not loaded unless the call sent that member and the plan stores it as sent, so a create sending `active: false` is answered `false` as recorded while the state holds the read's `true`. - A rename no longer teaches a sort from a whole-collection read its query sorts, whose ties need not follow the collection's own order. Co-Authored-By: Claude Opus 5.5 --- .../write-form-calls/spec.md | 5 ++- docs/web-network-captures.md | 5 ++- .../environmentStateFormAnswer.test.ts | 13 ++++++ .../environmentStateWriteAnswer.ts | 14 +++++- .../EnvironmentStateWriteSimulation.ts | 34 ++++++++------ .../environmentStateFormWrites.test.ts | 5 +++ .../environmentStateWriteApply.ts | 2 +- tests/fixtures/environmentStateFormWrites.ts | 44 +++++++++++++++++++ 8 files changed, 102 insertions(+), 20 deletions(-) diff --git a/docs/specs/agent-environments/write-form-calls/spec.md b/docs/specs/agent-environments/write-form-calls/spec.md index ba78d9d6dc..f3fee2d1da 100644 --- a/docs/specs/agent-environments/write-form-calls/spec.md +++ b/docs/specs/agent-environments/write-form-calls/spec.md @@ -30,7 +30,8 @@ natural-language tasks ask for, or withheld them: matches, which keeps the recorded value as a partial page's total does. - **Sorted collections.** An update that writes the field its type's creates are sorted by moves its entity to where the new value sorts, in replay and serving (a bulk update moving each entity in turn), and its plan carries that - `insert`. When a read listing the whole collection fails after an update and the type's creates are not sorted by a + `insert`. When a read listing the whole collection in collection order (not sorted by its query) fails after an + update and the type's creates are not sorted by a field the update writes, replay tries the collection sorted by such a field when the entities no exercised write changed already follow it, as serving moves only written entities, and unless a create was placed by a sort that excludes it or a whole-collection read in collection order (not sorted by its query) that placed a create did @@ -38,7 +39,7 @@ natural-language tasks ask for, or withheld them: - **Unloaded answers.** A create's later item read is taken as its observation when it differs from the answer only in values the answer had not loaded: empty, zero, `false` or Go's zero time, or objects whose members are each the same or loaded so; the plan points to those values (`observation.read.unloaded`), which a served create's answer keeps as - the recorded answer showed them while the state holds the read's. An unsent update field that changes only between + the recorded answer showed them, unless the call sent one the plan stores as sent, while the state holds the read's. An unsent update field that changes only between `null`, `[]` and `{}` is unchanged. Write answers, bulk answers and replay's checks of them included, spell empty lists and maps the call did not send as their recorded answers did, and a sent one as sent. - **Unruled fields.** A later create that stored a field no rule writes otherwise than the plan keeps it is diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index e1cb714089..967cf82ec1 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -760,7 +760,8 @@ precision, including microseconds. A list read only adds fields the answer lacks and still checks it. Serving answers a create in its answer's shape and clock precision, with empty lists and maps the call did not send spelled (`null`, `[]` or `{}`) as its answer spelled them and the -values it had not loaded as it answered them, and stores the fields of both, +values it had not loaded as it answered them (unless the call sent one the plan +stores as sent), and stores the fields of both, as the item read-back shows them, with its clock precision. Updates answer empty lists and maps they did not send as their answers spelled them too; a member a call sent is answered as sent. A `reference` is an object @@ -856,7 +857,7 @@ all request completions are known; otherwise it is null. An update that writes the field its type's creates are sorted by moves its entity to where the new value sorts (its own `insert`); a bulk update moves each entity it writes in turn, as replay applies them. When a read listing the whole -collection fails after an update and the type's creates are not sorted by a field +collection in its own order, not one its query sorts, fails after an update and the type's creates are not sorted by a field that update writes, the view tries the collection sorted by such a field, when the entities no write it exercised changed already follow it, as serving moves only the entities a write changes, and unless diff --git a/src/application/environment/environmentStateFormAnswer.test.ts b/src/application/environment/environmentStateFormAnswer.test.ts index 1f9024f3a7..9bdced859d 100644 --- a/src/application/environment/environmentStateFormAnswer.test.ts +++ b/src/application/environment/environmentStateFormAnswer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + ACTIVE_ON_READ_ROWS, BULK_RENAMED_LABEL_ROWS, ECHOED_EMPTY_ROWS, formLabelRows, @@ -146,6 +147,18 @@ describe("served creates and updates modelled on form recordings", () => { }); }); + it("answers a sent value its create does not store as the recorded answer did, and stores the read's", () => { + const users = servedFrom(ACTIVE_ON_READ_ROWS); + expect( + users.answer("post_users", { body: { name: "Cy", active: false } }).body, + ).toEqual({ id: 2, name: "Cy", active: false }); + expect(users.answer("get_users_by_id", { id: "2" }).body).toEqual({ + id: 2, + name: "Cy", + active: true, + }); + }); + it("lists a created tag's id in the list envelope's ids, in list order", () => { const tags = servedFrom(LISTED_ID_ROWS); tags.answer("post_tags", { body: { name: "Lamp" } }); diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 9354e43e47..f4c1533ce4 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -43,6 +43,7 @@ import { collectionMatch, decodedSegment, isObject, + pointerTokens, scalarText, type JsonObject, type StateEntity, @@ -375,7 +376,18 @@ export const writeAnswer = ( rule.rule === "clock" ? [rule.field] : [], ) : [], - unloaded: plan.observation?.read?.unloaded ?? [], + // A member the call sent and the plan stores as sent is answered as sent; any other keeps the recorded answer. + unloaded: (plan.observation?.read?.unloaded ?? []).filter( + (path) => { + const [field] = pointerTokens(path); + return !( + rendered.sent?.includes(field!) === true && + plan.rules.some( + (rule) => rule.field === field && rule.rule === "echo", + ) + ); + }, + ), }, optionalFields(plan), ); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index fb62818bfb..40280ce770 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -104,6 +104,23 @@ const keptInOrder = ( ); }); +/** Whether a list read's query sets a parameter the view's query model reads as a sort or its direction. */ +const sortedByQuery = ( + view: Extract, + record: StateRecordFacts, +): boolean => { + const sent = new Set( + [...new URLSearchParams(record.query ?? "").keys()].map((key) => + key.toLowerCase(), + ), + ); + return Object.entries(view.query?.parameters ?? {}).some( + ([parameter, { role }]) => + (role === "sort" || role === "direction") && + sent.has(parameter.toLowerCase()), + ); +}; + const APPLY = { create: applyCreate, update: applyUpdate, @@ -882,19 +899,7 @@ export class EnvironmentStateWriteSimulation { record: StateRecordFacts, name: string, ): void { - const sent = new Set( - [...new URLSearchParams(record.query ?? "").keys()].map((key) => - key.toLowerCase(), - ), - ); - if ( - Object.entries(view.query?.parameters ?? {}).some( - ([parameter, { role }]) => - (role === "sort" || role === "direction") && - sent.has(parameter.toLowerCase()), - ) - ) - return; + if (sortedByQuery(view, record)) return; const draft = this.#drafts.get(name)!; const kept = this.#wholeListing(view, record, (items) => draft.candidates.filter( @@ -919,7 +924,8 @@ export class EnvironmentStateWriteSimulation { exercised: ReadonlySet, ): CheckOutcome | undefined { const line = this.#line(view, record); - if (line === undefined) return undefined; + // A read its query sorts shows the query's order, which may break ties otherwise than the collection. + if (line === undefined || sortedByQuery(view, record)) return undefined; const written = new Set( [...exercised].flatMap((name) => { const { plan } = this.#drafts.get(name)!; diff --git a/src/domain/analysisView/environmentStateFormWrites.test.ts b/src/domain/analysisView/environmentStateFormWrites.test.ts index 63fa375b9e..1c4f3f0d20 100644 --- a/src/domain/analysisView/environmentStateFormWrites.test.ts +++ b/src/domain/analysisView/environmentStateFormWrites.test.ts @@ -15,6 +15,7 @@ import { NOTE_DEFAULT_ROWS, RENAMED_LABEL_ROWS, SHELF_SORTED_ROWS, + SHELF_TIE_ROWS, TITLE_ORDER_ROWS, UNLOADED_ANSWER_ROWS, UNSORTED_SEED_ROWS, @@ -169,6 +170,10 @@ describe("collections kept sorted", () => { expect(plan.insert).toBeNull(); }); + it("learns no sort from a read its query sorts, whose ties need not follow the collection's order", () => { + expect(planOf(SHELF_TIE_ROWS, "put_books_by_id").insert).toBeNull(); + }); + it("serves a bulk update whose recordings echo the empty values each call sent", () => { expect(planOf(BULK_EMPTY_TAG_ROWS, "patch_labels")).toMatchObject({ served: true, diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 5e15eac04e..a775c4cbe0 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -590,7 +590,7 @@ export const writtenResponse = ( (rendered, path) => { const tokens = pointerTokens(path); const kept = memberAt(shown, tokens); - return kept === undefined || written.sent?.includes(tokens[0]!) === true + return kept === undefined ? rendered : withMemberAt(rendered, tokens, kept); }, diff --git a/tests/fixtures/environmentStateFormWrites.ts b/tests/fixtures/environmentStateFormWrites.ts index c5575c782c..ce78d7e220 100644 --- a/tests/fixtures/environmentStateFormWrites.ts +++ b/tests/fixtures/environmentStateFormWrites.ts @@ -439,3 +439,47 @@ export const UNSORTED_SEED_ROWS: readonly TimedCaptureRow[] = (() => { ["GET", "/api/folders", [alpha, bravo, renamed]], ]; })(); + +/** Books kept in id order, which a `?sort=shelf` read breaks ties of by name, then a rename that read shows reordered. */ +export const SHELF_TIE_ROWS: readonly TimedCaptureRow[] = (() => { + const shelved = (id: number, name: string): Entity => ({ + id, + name, + shelf: "a", + }); + const [alpha, bravo] = [shelved(1, "Alpha"), shelved(2, "Bravo")]; + const zulu = shelved(1, "Zulu"); + return [ + ["GET", "/api/books", [alpha, bravo]], + ["GET", "/api/books?sort=-id", [bravo, alpha]], + ["GET", "/api/books/1", alpha], + ["GET", "/api/books/2", bravo], + ["PUT", "/api/books/1", zulu, 200, { name: "Zulu", shelf: "a" }], + ["GET", "/api/books/1", zulu], + ["GET", "/api/books?sort=shelf", [bravo, zulu]], + ["GET", "/api/books", [zulu, bravo]], + ]; +})(); + +/** A user create that sends `active: false` and is answered so, whose read shows the account active. */ +export const ACTIVE_ON_READ_ROWS: readonly TimedCaptureRow[] = (() => { + const user = (id: number, name: string, active: boolean): Entity => ({ + id, + name, + active, + }); + const root = user(1, "Root", true); + return [ + ["GET", "/api/users", [root]], + ["GET", "/api/users/1", root], + [ + "POST", + "/api/users", + user(2, "Ada", false), + 201, + { name: "Ada", active: false }, + ], + ["GET", "/api/users/2", user(2, "Ada", true)], + ["GET", "/api/users", [root, user(2, "Ada", true)]], + ]; +})();