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