From 1d62e54e1f976e64130e4e9b651c3180aa4fa727 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:33:39 +0800 Subject: [PATCH] fix(snapshot): replay validated workflow evidence (#984) * fix(snapshot): bind replay to validated cache entry * fix(snapshot): persist composed workflow results * fix(snapshot): enforce workflow replay identity * fix(snapshot): honor cancellation after route resolution * test(snapshot): make cancellation race deterministic * fix(snapshot): replay all composed workflow evidence * fix(snapshot): index workflow evidence once per snapshot Validate workflow entries against a query and content index instead of scanning the evidence bundle for every entry. * fix(snapshot): reuse previewed routes and bound workflow cache --------- Co-authored-by: Cursor Agent --- src/application/DirectAnalysis.ts | 165 ++++- .../binary/AnalysisSnapshotCache.test.ts | 122 ++++ .../binary/AnalysisSnapshotCache.ts | 82 ++- src/application/binary/BinarySession.ts | 71 +- src/application/binary/BinarySessionOpen.ts | 39 +- .../binary/BinarySessionRecords.ts | 37 + src/domain/analysisSnapshot.test.ts | 77 ++- src/domain/analysisSnapshot.ts | 252 ++++++- .../directAnalysisPermissions.test.ts | 4 +- .../analysis-sessions/binarySession.test.ts | 60 ++ .../directAnalysisSnapshotReplay.test.ts | 633 ++++++++++++++++++ 11 files changed, 1462 insertions(+), 80 deletions(-) create mode 100644 tests/composition/directAnalysisSnapshotReplay.test.ts diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index a6c27834b..ea7ed8f6c 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -5,6 +5,8 @@ import { EnhancedTools } from "./EnhancedTools.js"; import { executeFunctionAnalysisEvidence } from "./FunctionAnalysisEvidence.js"; import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js"; import type { BinarySession } from "./binary/BinarySession.js"; +import type { SessionProviderRoute } from "./binary/SessionProviderRouter.js"; +import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js"; import { silentLogger, type Logger } from "../logger.js"; import { createEvidence } from "../domain/evidence.js"; import type { Evidence } from "../domain/evidence.js"; @@ -28,10 +30,17 @@ import { snapshotEvidenceForQuery, snapshotMatchesTarget, } from "../domain/analysisSnapshot.js"; -import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; +import { + analysisProfileSchema, + committedProviderSchema, + type AnalysisProfileCommitment, +} from "../domain/analysisProfile.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; -import type { AnalysisExecution } from "./AnalysisProvider.js"; +import type { + AnalysisExecution, + ProviderIdentity, +} from "./AnalysisProvider.js"; import { REA_WORKFLOW_PROVIDER, workflowAnalysisProfile, @@ -194,7 +203,42 @@ const runAnalysis = async ( }); if (!prepared.ok) return cliError(prepared.error); const { snapshot } = prepared.value; - const opened = await session.open(path, { + let resolvedTarget: ResolvedSessionOpen | undefined; + if (snapshot !== undefined && prepared.value.target !== undefined) { + const preview = await session.previewTarget(prepared.value.target, { + signal, + snapshot, + ...(options.formatHint === undefined + ? {} + : { formatHint: options.formatHint }), + ...(options.providerId === undefined + ? {} + : { providerId: options.providerId }), + }); + if (!preview.ok) return cliError(preview.error); + resolvedTarget = preview.value; + const route = preview.value.route; + const bindingProfile = route.profile ?? undefined; + const evidenceProfile = analysisProfileForRoute(route, tool); + if ( + bindingProfile !== undefined && + evidenceProfile !== undefined && + allowsSnapshotReplay(route, tool) + ) { + const cached = snapshotEvidenceForQuery(snapshot, { + target: preview.value.target, + bindingProfile, + operation: tool, + parameters: arguments_, + provider: isWorkflowEvidenceTool(tool) + ? REA_WORKFLOW_PROVIDER + : providerIdentityForRoute(route, tool), + evidenceProfile, + }); + if (cached !== undefined) return cached; + } + } + const openOptions = { signal, ...(options.formatHint === undefined ? {} @@ -203,26 +247,13 @@ const runAnalysis = async ( ...(options.providerId === undefined ? {} : { providerId: options.providerId }), - }); + }; + const opened = + resolvedTarget === undefined + ? await session.open(path, openOptions) + : await session.openResolvedTarget(resolvedTarget, openOptions); if (!opened.ok) return cliError(opened.error); const evidenceProfile = analysisProfileForEvidence(session, tool); - const bindingProfile = session.analysisProfile(); - if ( - canReplayDirectTool(session, tool) && - snapshot !== undefined && - evidenceProfile !== undefined && - bindingProfile !== undefined - ) { - const cached = snapshotEvidenceForQuery(snapshot, { - target: opened.value, - bindingProfile, - operation: tool, - parameters: arguments_, - provider: replayProviderFor(session, tool), - evidenceProfile, - }); - if (cached !== undefined) return cached; - } const { output, evidence } = await executeAnalysisTool({ session, openedTarget: opened.value, @@ -232,6 +263,41 @@ const runAnalysis = async ( evidenceProfile, }); if (evidence !== undefined) session.recordEvidence(evidence); + if ( + isWorkflowEvidenceTool(tool) && + tool !== "trace_native_ui_action" && + snapshotPath !== undefined && + evidence !== undefined && + "analysis_profile" in evidence && + session.allowsSnapshotReplay(tool) + ) { + const recorded = session.recordWorkflowSnapshot({ + operation: tool, + parameters: arguments_, + execution: { + result: evidence.normalized_result, + rawResult: evidence.raw_result, + provider: committedProviderSchema.parse(evidence.provider), + analysisProfile: analysisProfileSchema.parse( + evidence.analysis_profile, + ), + limitations: evidence.limitations, + locations: evidence.locations, + subject: + evidence.subject === null + ? null + : { + path: evidence.subject.local_path, + sha256: evidence.subject.digest.sha256, + format: evidence.subject.format, + ...(evidence.subject.architecture === null + ? {} + : { architecture: evidence.subject.architecture }), + }, + }, + }); + if (!recorded.ok) return cliError(recorded.error); + } if ( tool !== "trace_native_ui_action" && snapshotPath !== undefined && @@ -252,12 +318,6 @@ const runAnalysis = async ( } }; -const canReplayDirectTool = ( - session: BinarySession, - tool: Parameters[2], -): boolean => - tool !== "trace_native_ui_action" && session.allowsSnapshotReplay(tool); - const executeAnalysisTool = async (input: { readonly session: BinarySession; readonly openedTarget: BinaryTarget; @@ -353,7 +413,10 @@ const prepareSnapshot = async (options: { readonly formatHint?: ExecutableFormatHint; readonly snapshotPath: string | undefined; }): Promise< - Result<{ readonly snapshot?: AnalysisSnapshot }, AnalysisError> + Result< + { readonly snapshot?: AnalysisSnapshot; readonly target?: BinaryTarget }, + AnalysisError + > > => { const { path, snapshotPath } = options; if (snapshotPath === undefined || !(await fileExists(snapshotPath))) @@ -374,20 +437,52 @@ const prepareSnapshot = async (options: { "Analysis snapshot target does not match the requested binary", ), ); - return ok({ snapshot: loaded.value }); + return ok({ snapshot: loaded.value, target: target.value }); }; -const replayProviderFor = ( - session: BinarySession, +const allowsSnapshotReplay = ( + route: SessionProviderRoute, tool: | NativeToolName | ArtifactAnalysisOperation | ManagedToolName | DirectAnalysisTool, -) => - isWorkflowEvidenceTool(tool) - ? REA_WORKFLOW_PROVIDER - : session.providerIdentity(tool); +): boolean => { + if (tool === "trace_native_ui_action") return false; + const descriptor = route.capabilities?.get(tool); + return descriptor === undefined + ? ![...(route.capabilities?.values() ?? [])].some( + ({ cachePolicy }) => cachePolicy === "live", + ) + : descriptor.cachePolicy !== "live"; +}; + +const analysisProfileForRoute = ( + route: SessionProviderRoute, + tool: + | NativeToolName + | ArtifactAnalysisOperation + | ManagedToolName + | DirectAnalysisTool, +): AnalysisProfileCommitment | undefined => { + const profile = route.profile; + if (profile === null || profile === undefined) return undefined; + if (isWorkflowEvidenceTool(tool)) return workflowAnalysisProfile(profile); + const provider = providerIdentityForRoute(route, tool); + return provider.id === profile.provider.id ? profile : undefined; +}; + +/** Mirror BinarySession.providerIdentity for a route not opened yet. */ +const providerIdentityForRoute = ( + route: SessionProviderRoute, + operation: string, +): ProviderIdentity => { + const selected = + route.capabilities?.get(operation)?.provider ?? route.identity; + return route.profile?.provider.id === selected.id + ? route.profile.provider + : selected; +}; const analysisProfileForEvidence = ( session: BinarySession, diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 79ccfaa73..5a7472ba4 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -10,15 +10,137 @@ import { createEvidence } from "../../domain/evidence.js"; import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; import { ok } from "../../domain/result.js"; import { createAnalysisExecution } from "../AnalysisProvider.js"; +import { + createAnalysisSnapshotWorkflowEntry, + parseAnalysisSnapshot, + snapshotBinding, + snapshotTarget, +} from "../../domain/analysisSnapshot.js"; +import { + REA_WORKFLOW_PROVIDER, + workflowAnalysisProfile, +} from "../InvestigationProviders.js"; import type { AnalysisOperation, CapabilityDescriptor, } from "../AnalysisProvider.js"; import { AnalysisSnapshotCache, + SNAPSHOT_CACHE_ENTRY_CEILING, isSnapshotCacheable, } from "./AnalysisSnapshotCache.js"; +describe("analysis snapshot cache capacity", () => { + it("shares capacity across query kinds while allowing replacements and fresh partitions", () => { + const cache = new AnalysisSnapshotCache(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const execution = { + ...createAnalysisExecution("workflow", REA_WORKFLOW_PROVIDER, { + subject: target, + }), + analysisProfile: workflowAnalysisProfile(profile), + }; + const workflowInput = { + target, + profile, + operation: "trace_feature", + parameters: { query: "existing" }, + execution, + }; + for (let index = 0; index < SNAPSHOT_CACHE_ENTRY_CEILING - 1; index += 1) + cache.recordWorkflow({ + ...workflowInput, + parameters: { query: String(index) }, + }); + // Staging and recording both spend the same budget; an existing query can + // still be refreshed when no space remains for a new binding. + const directInput = { + target, + profile, + operation: "address_name" as const, + parameters: { address: "0x1000", document: "fixture" }, + execution: createAnalysisExecution( + "initial", + ANALYSIS_SNAPSHOT_PROVIDER, + { subject: target, analysisProfile: profile }, + ), + }; + cache.record(directInput); + cache.record({ + ...directInput, + parameters: { address: "0x2000", document: "fixture" }, + }); + cache.recordWorkflow(workflowInput); + expect(cache.entries()).toHaveLength(1); + expect(cache.workflowEntries()).toHaveLength( + SNAPSHOT_CACHE_ENTRY_CEILING - 1, + ); + + cache.record({ + ...directInput, + execution: { ...directInput.execution, result: "updated" }, + }); + cache.recordWorkflow({ + ...workflowInput, + parameters: { query: "0" }, + execution: { ...execution, result: "updated" }, + }); + expect( + cache.lookup(target, profile, "address_name", directInput.parameters) + ?.result, + ).toBe("updated"); + expect( + cache.workflowEntries().find(({ parameters }) => parameters.query === "0") + ?.execution.result, + ).toBe("updated"); + + const incoming = createAnalysisSnapshotWorkflowEntry({ + ...workflowInput, + target: snapshotTarget(target), + binding: snapshotBinding(profile), + }); + const evidence = createEvidence(target, REA_WORKFLOW_PROVIDER, { + operation: workflowInput.operation, + parameters: workflowInput.parameters, + result: execution.result, + rawResult: execution.rawResult, + analysisProfile: execution.analysisProfile, + limitations: execution.limitations, + locations: execution.locations, + }); + cache.stage( + parseAnalysisSnapshot({ + target: snapshotTarget(target), + binding: snapshotBinding(profile), + entries: [], + workflow_entries: [incoming], + evidence_bundle: createEvidenceBundle([evidence]), + }), + ); + expect(cache.workflowEntries()).toHaveLength( + SNAPSHOT_CACHE_ENTRY_CEILING - 1, + ); + + const otherTarget = { ...target, sha256: "b".repeat(64) }; + cache.recordWorkflow({ + ...workflowInput, + target: otherTarget, + execution: { ...execution, subject: otherTarget }, + }); + expect(cache.entries()).toEqual([]); + expect(cache.workflowEntries()).toHaveLength(1); + cache.clear(); + cache.record({ + ...directInput, + target: otherTarget, + execution: { ...directInput.execution, subject: otherTarget }, + }); + expect(cache.entries()).toHaveLength(1); + expect(cache.workflowEntries()).toEqual([]); + }); +}); + describe("analysis snapshot cache partitioning", () => { it.each<{ operation: Exclude; diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 473434542..edb39da10 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -9,6 +9,7 @@ import { err, ok, type Result } from "../../domain/result.js"; import { analysisQueryId, createAnalysisSnapshotEntry, + createAnalysisSnapshotWorkflowEntry, snapshotBinding, snapshotMatchesProfile, snapshotMatchesTarget, @@ -16,6 +17,7 @@ import { parseAnalysisSnapshot, type AnalysisSnapshot, type AnalysisSnapshotEntry, + type AnalysisSnapshotWorkflowEntry, type AnalysisSnapshotBinding, type AnalysisSnapshotTarget, } from "../../domain/analysisSnapshot.js"; @@ -54,6 +56,9 @@ const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet = new Set( ), ); +/** Maximum duplicated result bindings retained across direct and workflow queries. */ +export const SNAPSHOT_CACHE_ENTRY_CEILING = 10_000; + /** Whether an operation is immutable and independent of provider UI state. */ export const isSnapshotCacheable = ( operation: AnalysisOperation, @@ -73,6 +78,7 @@ export const isSnapshotCacheable = ( /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); + readonly #workflowEntries = new Map(); #target: AnalysisSnapshotTarget | undefined; #binding: AnalysisSnapshotBinding | undefined; @@ -93,7 +99,7 @@ export class AnalysisSnapshotCache { /** Replace cache state when the target or selected profile changes. */ select(target: BinaryTarget, profile: AnalysisProfileCommitment): void { - if (!this.matches(target, profile)) this.#entries.clear(); + if (!this.matches(target, profile)) this.#clearEntries(); this.#target = snapshotTarget(target); this.#binding = snapshotBinding(profile); } @@ -109,14 +115,20 @@ export class AnalysisSnapshotCache { snapshot.binding.analysis_profile, )) ) - this.#entries.clear(); + this.#clearEntries(); this.#target = structuredClone(snapshot.target); this.#binding = structuredClone(snapshot.binding); let imported = 0; for (const entry of snapshot.entries) { + if (!this.#entries.has(entry.query_id) && !this.#hasCapacity()) continue; if (!this.#entries.has(entry.query_id)) imported += 1; this.#entries.set(entry.query_id, structuredClone(entry)); } + for (const entry of snapshot.workflow_entries ?? []) { + if (!this.#workflowEntries.has(entry.query_id) && !this.#hasCapacity()) + continue; + this.#workflowEntries.set(entry.query_id, structuredClone(entry)); + } return imported; } @@ -137,6 +149,9 @@ export class AnalysisSnapshotCache { target: snapshotTargetIdentity, binding, entries: this.entries(), + ...(this.workflowEntries().length === 0 + ? {} + : { workflow_entries: this.workflowEntries() }), evidence_bundle: retainedEvidence, }), ); @@ -193,6 +208,47 @@ export class AnalysisSnapshotCache { .map((entry) => structuredClone(entry)); } + /** Return canonical composed-workflow entries for persistence. */ + workflowEntries(): AnalysisSnapshotWorkflowEntry[] { + return [...this.#workflowEntries.values()] + .sort((left, right) => left.query_id.localeCompare(right.query_id)) + .map((entry) => structuredClone(entry)); + } + + /** Record one exact derived workflow result unless the shared cache is full. */ + recordWorkflow(input: { + readonly target: BinaryTarget; + readonly profile: AnalysisProfileCommitment; + readonly operation: string; + readonly parameters: Readonly>; + readonly execution: { + readonly result: JsonValue; + readonly rawResult: JsonValue | null; + readonly provider: AnalysisExecution["provider"]; + readonly analysisProfile: AnalysisProfileCommitment; + readonly limitations: readonly string[]; + readonly locations: AnalysisExecution["locations"]; + readonly subject: AnalysisExecution["subject"]; + }; + }): void { + this.select(input.target, input.profile); + const queryId = analysisQueryId( + snapshotTarget(input.target), + snapshotBinding(input.profile), + input.operation, + input.parameters, + ); + if (!this.#workflowEntries.has(queryId) && !this.#hasCapacity()) return; + const entry = createAnalysisSnapshotWorkflowEntry({ + target: snapshotTarget(input.target), + binding: snapshotBinding(input.profile), + operation: input.operation, + parameters: input.parameters, + execution: input.execution, + }); + this.#workflowEntries.set(entry.query_id, entry); + } + /** Replay an exact provider-specific query, marking its cached provenance. */ lookup( target: BinaryTarget, @@ -246,8 +302,14 @@ export class AnalysisSnapshotCache { readonly execution: AnalysisExecution; }): void { const { target, profile, operation, parameters, execution } = input; - if (this.#entries.size >= 10_000) return; this.select(target, profile); + const queryId = analysisQueryId( + snapshotTarget(target), + snapshotBinding(profile), + operation, + parameters, + ); + if (!this.#entries.has(queryId) && !this.#hasCapacity()) return; const entry = createAnalysisSnapshotEntry({ target: snapshotTarget(target), binding: snapshotBinding(profile), @@ -260,8 +322,20 @@ export class AnalysisSnapshotCache { /** Forget all target-bound entries. */ clear(): void { - this.#entries.clear(); + this.#clearEntries(); this.#target = undefined; this.#binding = undefined; } + + #clearEntries(): void { + this.#entries.clear(); + this.#workflowEntries.clear(); + } + + #hasCapacity(): boolean { + return ( + this.#entries.size + this.#workflowEntries.size < + SNAPSHOT_CACHE_ENTRY_CEILING + ); + } } diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 7acc84357..2e256bb1a 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -30,7 +30,10 @@ import { BinarySessionRecords } from "./BinarySessionRecords.js"; import { binarySessionStatus } from "./BinarySessionStatus.js"; import { resolveSessionOpen, + resolveSessionTarget, + validateResolvedSessionTarget, type BinarySessionOpenOptions, + type ResolvedSessionOpen, } from "./BinarySessionOpen.js"; import { bindExecutionTarget, @@ -150,6 +153,27 @@ export class BinarySession return () => this.#availabilityListeners.delete(listener); } + /** Resolve a target and provider profile without creating a provider client. */ + previewTarget( + target: BinaryTarget, + options: BinarySessionOpenOptions = {}, + ): Promise> { + if (isAborted(options.signal)) + return Promise.resolve(err(new AnalysisCancelledError("open_binary"))); + return resolveSessionTarget({ + router: this.#providerRouter, + current: this.#active, + target, + options, + stagedSnapshotMatches: (target, profile) => + this.matchesSnapshot(target, profile), + }).then((resolved) => + isAborted(options.signal) + ? err(new AnalysisCancelledError("open_binary")) + : resolved, + ); + } + /** * Open or switch targets after draining calls against the current target. * Returns the switch failure even if best-effort restoration also fails. @@ -157,18 +181,49 @@ export class BinarySession open( path: string, options: BinarySessionOpenOptions = {}, + ): Promise> { + return this.#open( + () => + resolveSessionOpen({ + router: this.#providerRouter, + current: this.#active, + path, + options, + stagedSnapshotMatches: (target, profile) => + this.matchesSnapshot(target, profile), + }), + options, + ); + } + + /** Open a previewed target without repeating parsing or provider profile discovery. */ + openResolvedTarget( + resolved: Pick, + options: Pick = {}, + ): Promise> { + return this.#open( + () => + Promise.resolve( + validateResolvedSessionTarget({ + ...resolved, + current: this.#active, + options, + stagedSnapshotMatches: (target, profile) => + this.matchesSnapshot(target, profile), + }), + ), + options, + ); + } + + #open( + resolve: () => Promise>, + options: Pick, ): Promise> { return this.#serialize(async () => { if (isAborted(options.signal)) return err(new AnalysisCancelledError("open_binary")); - const resolved = await resolveSessionOpen({ - router: this.#providerRouter, - current: this.#active, - path, - options, - stagedSnapshotMatches: (target, profile) => - this.matchesSnapshot(target, profile), - }); + const resolved = await resolve(); if (!resolved.ok) return resolved; const { target, route, sameTarget } = resolved.value; const { profile, compatibility } = route; diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index e0d425ee9..859d8f413 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -46,11 +46,22 @@ interface ResolveSessionOpenInput { ) => boolean; } +interface ResolveSessionTargetInput { + readonly router: SessionProviderRouter; + readonly current: CurrentOpenBinding | undefined; + readonly target: BinaryTarget; + readonly options: BinarySessionOpenOptions; + readonly stagedSnapshotMatches: ( + target: BinaryTarget, + profile: AnalysisProfileCommitment | null, + ) => boolean; +} + /** Parse a target, resolve its provider route, and validate snapshot binding. */ export const resolveSessionOpen = async ( input: ResolveSessionOpenInput, ): Promise> => { - const { router, current, path, options, stagedSnapshotMatches } = input; + const { path, options } = input; const parsed = await parseBinaryTarget( path, process.cwd(), @@ -59,7 +70,14 @@ export const resolveSessionOpen = async ( options.formatHint, ); if (!parsed.ok) return parsed; - const target = parsed.value; + return resolveSessionTarget({ ...input, target: parsed.value }); +}; + +/** Resolve a known target's provider route and validate snapshot binding. */ +export const resolveSessionTarget = async ( + input: ResolveSessionTargetInput, +): Promise> => { + const { router, current, target, options } = input; const sameTarget = current?.target.path === target.path && snapshotMatchesTarget(snapshotTarget(current.target), target); @@ -68,7 +86,22 @@ export const resolveSessionOpen = async ( ? ({ ok: true, value: current.route } as const) : await router.resolve(target, options.providerId, options.signal); if (!resolvedRoute.ok) return resolvedRoute; - const route = resolvedRoute.value; + return validateResolvedSessionTarget({ + ...input, + route: resolvedRoute.value, + }); +}; + +/** Revalidate a previewed route against the current session and snapshot binding. */ +export const validateResolvedSessionTarget = ( + input: Omit & { + readonly route: SessionProviderRoute; + }, +): Result => { + const { current, target, route, options, stagedSnapshotMatches } = input; + const sameTarget = + current?.target.path === target.path && + snapshotMatchesTarget(snapshotTarget(current.target), target); const snapshotError = validateSnapshot({ snapshot: options.snapshot, target, diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 0613b884f..ee97d8849 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -160,6 +160,43 @@ export abstract class BinarySessionRecords { this.#emitSnapshotChanged(); } + /** Retain one derived workflow result alongside its provider cache entries. */ + recordWorkflowSnapshot(input: { + readonly operation: string; + readonly parameters: Readonly< + Record + >; + readonly execution: Parameters< + AnalysisSnapshotCache["recordWorkflow"] + >[0]["execution"]; + }): Result { + const active = this.activeAnalysisBinding(); + if (active === undefined || active.profile === null) + return err( + new EvidenceIntegrityError( + "Workflow snapshot entries require an active concrete provider profile", + ), + ); + try { + this.#snapshot.recordWorkflow({ + target: active.target, + profile: active.profile, + ...input, + }); + this.#emitSnapshotChanged(); + return ok(null); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError( + cause instanceof Error + ? cause.message + : "Workflow snapshot entry validation failed", + { cause }, + ), + ); + } + } + protected invalidateSnapshot(): void { this.#snapshot.clear(); this.#snapshotInvalidated = true; diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index da6fd3a61..1ee8cc620 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -17,6 +17,55 @@ import { import { createEvidence } from "./evidence.js"; import { createEvidenceBundle } from "./evidenceBundle.js"; +const snapshotWithHistoricalEvidence = () => { + const target = snapshotTarget(ANALYSIS_SNAPSHOT_TARGET); + const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); + const parameters = { procedure: "main" }; + const makeEvidence = (result: string) => + createEvidence(ANALYSIS_SNAPSHOT_TARGET, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "analyze_function", + parameters, + result: { summary: result }, + analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, + }); + const current = makeEvidence("cached"); + const entry = { + query_id: analysisQueryId(target, binding, "analyze_function", parameters), + operation: "analyze_function", + parameters, + execution: { + result: { summary: "cached" }, + raw_result: null, + provider: binding.provider, + limitations: [], + locations: [], + subject: { + path: ANALYSIS_SNAPSHOT_TARGET.path, + sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, + format: ANALYSIS_SNAPSHOT_TARGET.format, + architecture: ANALYSIS_SNAPSHOT_TARGET.architecture ?? null, + }, + }, + }; + return { + current, + snapshot: { + target, + binding, + entries: [entry], + evidence_bundle: createEvidenceBundle([ + makeEvidence("older"), + current, + createEvidence(ANALYSIS_SNAPSHOT_TARGET, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "legacy_query", + parameters: {}, + result: { summary: "legacy" }, + }), + ]), + } satisfies AnalysisSnapshot, + }; +}; + describe("analysis snapshot contract", () => { it("preserves DOS MZ target and subject formats with the x86 family", () => { const target = { @@ -91,31 +140,7 @@ describe("analysis snapshot contract", () => { describe("analysis snapshot Evidence binding", () => { it("finds only Evidence committed to the exact binding and profile", () => { - const evidence = createEvidence( - ANALYSIS_SNAPSHOT_TARGET, - ANALYSIS_SNAPSHOT_PROVIDER, - { - operation: "analyze_function", - parameters: { procedure: "main" }, - result: { summary: "cached" }, - analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, - }, - ); - const legacy = createEvidence( - ANALYSIS_SNAPSHOT_TARGET, - ANALYSIS_SNAPSHOT_PROVIDER, - { - operation: "legacy_query", - parameters: {}, - result: { summary: "legacy" }, - }, - ); - const snapshot: AnalysisSnapshot = { - target: snapshotTarget(ANALYSIS_SNAPSHOT_TARGET), - binding: snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE), - entries: [], - evidence_bundle: createEvidenceBundle([evidence, legacy]), - }; + const { current, snapshot } = snapshotWithHistoricalEvidence(); expect( snapshotEvidenceForQuery(snapshot, { target: ANALYSIS_SNAPSHOT_TARGET, @@ -125,7 +150,7 @@ describe("analysis snapshot Evidence binding", () => { provider: ANALYSIS_SNAPSHOT_PROVIDER, evidenceProfile: ANALYSIS_SNAPSHOT_PROFILE, }), - ).toEqual(evidence); + ).toEqual(current); expect( snapshotEvidenceForQuery(snapshot, { target: ANALYSIS_SNAPSHOT_TARGET, diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 459aa3720..c91144802 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -101,17 +101,33 @@ const entrySchema = z.object({ subject: subjectSchema.nullable(), }), }); +const workflowEntrySchema = z.object({ + query_id: prefixedDigestSchema("query"), + operation: z.string().min(1), + parameters: jsonObjectSchema, + execution: z.object({ + result: jsonValueSchema, + raw_result: jsonValueSchema.nullable(), + provider: committedProviderSchema, + analysis_profile: analysisProfileSchema, + limitations: z.array(z.string()), + locations: z.array(evidenceLocationSchema), + subject: subjectSchema.nullable(), + }), +}); /** Provider- and profile-exact cache of successful immutable analysis calls. */ export const analysisSnapshotSchema = z.object({ target: targetSchema, binding: bindingSchema, entries: z.array(entrySchema), + workflow_entries: z.array(workflowEntrySchema).optional(), evidence_bundle: evidenceBundleSchema, }); export type AnalysisSnapshot = z.infer; export type AnalysisSnapshotEntry = z.infer; +export type AnalysisSnapshotWorkflowEntry = z.infer; export type AnalysisSnapshotTarget = z.infer; export type AnalysisSnapshotBinding = z.infer; @@ -208,6 +224,19 @@ export const snapshotEvidenceForQuery = ( if (!snapshotMatchesBinding(snapshot, target, bindingProfile)) return undefined; const encodedParameters = canonicalJson(parameters); + const queryId = analysisQueryId( + snapshot.target, + snapshot.binding, + operation, + parameters, + ); + const entry = snapshot.entries.find( + (candidate) => candidate.query_id === queryId, + ); + const workflowEntry = snapshot.workflow_entries?.find( + (candidate) => candidate.query_id === queryId, + ); + if (entry === undefined && workflowEntry === undefined) return undefined; return snapshot.evidence_bundle.records.find( (record) => record.subject?.digest.sha256 === target.sha256 && @@ -217,10 +246,77 @@ export const snapshotEvidenceForQuery = ( record.provider.version === provider.version && "analysis_profile" in record && analysisProfilesEqual(record.analysis_profile, evidenceProfile) && - canonicalJson(record.parameters) === encodedParameters, + canonicalJson(record.parameters) === encodedParameters && + ((entry !== undefined && evidenceMatchesEntry(record, entry, snapshot)) || + (workflowEntry !== undefined && + evidenceMatchesWorkflowEntry(record, workflowEntry, snapshot))), ); }; +/** Create one exact replay record for a derived application workflow result. */ +export const createAnalysisSnapshotWorkflowEntry = (input: { + readonly target: AnalysisSnapshotTarget; + readonly binding: AnalysisSnapshotBinding; + readonly operation: string; + readonly parameters: Readonly>; + readonly execution: SnapshotExecution & { + readonly analysisProfile: AnalysisProfileCommitment; + }; +}): AnalysisSnapshotWorkflowEntry => { + const { target, binding, operation, parameters, execution } = input; + if ( + execution.provider.id !== execution.analysisProfile.provider.id || + execution.provider.name !== execution.analysisProfile.provider.name || + execution.provider.version !== execution.analysisProfile.provider.version + ) + throw new TypeError( + "Workflow snapshot execution provider does not match its profile", + ); + return { + query_id: analysisQueryId(target, binding, operation, parameters), + operation, + parameters: jsonObjectSchema.parse(parameters), + execution: { + result: execution.result, + raw_result: execution.rawResult, + provider: execution.analysisProfile.provider, + analysis_profile: execution.analysisProfile, + limitations: [...execution.limitations], + locations: [...execution.locations], + subject: + execution.subject === null + ? null + : { + ...execution.subject, + architecture: execution.subject.architecture ?? null, + }, + }, + }; +}; + +const evidenceMatchesEntry = ( + evidence: Evidence, + entry: AnalysisSnapshotEntry, + snapshot: Pick, +): boolean => + isCorrespondingEvidence(evidence, snapshot) && + canonicalJson({ + query: evidenceQueryKey(evidence), + normalized_result: evidence.normalized_result, + raw_result: evidence.raw_result, + limitations: evidence.limitations, + locations: evidence.locations, + subject: + evidence.subject === null + ? null + : { + local_path: evidence.subject.local_path, + sha256: evidence.subject.digest.sha256, + format: evidence.subject.format, + architecture: evidence.subject.architecture, + }, + }) === entryEvidenceKey(entry, snapshot.binding); + /** Compute the stable lookup key for one provider/profile-specific query. */ export const analysisQueryId = ( target: AnalysisSnapshotTarget, @@ -286,7 +382,9 @@ export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => { ); const ids = new Set(); const boundEntries: AnalysisSnapshotEntry[] = []; + const boundWorkflowEntries: AnalysisSnapshotWorkflowEntry[] = []; const index = createEvidenceIndex(parsed); + const workflowIndex = createWorkflowEvidenceIndex(parsed); for (const entry of parsed.entries) { if ( entry.execution.provider.id !== parsed.binding.provider.id || @@ -316,12 +414,60 @@ export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => { } boundEntries.push(entry); } + for (const entry of parsed.workflow_entries ?? []) { + if ( + entry.execution.provider.id !== + entry.execution.analysis_profile.provider.id || + entry.execution.provider.name !== + entry.execution.analysis_profile.provider.name || + entry.execution.provider.version !== + entry.execution.analysis_profile.provider.version + ) + throw new TypeError( + "Analysis snapshot workflow entry provider does not match its profile", + ); + const expected = analysisQueryId( + parsed.target, + parsed.binding, + entry.operation, + entry.parameters, + ); + if (entry.query_id !== expected) + throw new TypeError( + "Analysis snapshot workflow query identifier does not match", + ); + if (ids.has(entry.query_id)) + throw new TypeError("Analysis snapshot contains duplicate queries"); + ids.add(entry.query_id); + if (!workflowEntryHasMatchingEvidence(entry, workflowIndex)) { + if (workflowEntryHasCorrespondingEvidence(entry, workflowIndex)) + throw new TypeError( + `Analysis snapshot workflow entry ${entry.operation} differs from its Evidence record`, + ); + continue; + } + boundWorkflowEntries.push(entry); + } const sorted = [...parsed.entries].sort((left, right) => left.query_id.localeCompare(right.query_id), ); if (JSON.stringify(parsed.entries) !== JSON.stringify(sorted)) throw new TypeError("Analysis snapshot entries are not canonical"); - return { ...parsed, entries: boundEntries }; + const sortedWorkflows = [...(parsed.workflow_entries ?? [])].sort( + (left, right) => left.query_id.localeCompare(right.query_id), + ); + if ( + parsed.workflow_entries !== undefined && + JSON.stringify(parsed.workflow_entries) !== JSON.stringify(sortedWorkflows) + ) + throw new TypeError("Analysis snapshot workflow entries are not canonical"); + return { + ...parsed, + entries: boundEntries, + ...(parsed.workflow_entries === undefined + ? {} + : { workflow_entries: boundWorkflowEntries }), + }; }; /** Check that a cached provider execution is represented by bundled Evidence. */ @@ -429,6 +575,108 @@ const entryEvidenceKey = ( }, }); +const workflowEntryQueryKey = (entry: AnalysisSnapshotWorkflowEntry): string => + canonicalJson({ + operation: entry.operation, + parameters: entry.parameters, + provider: entry.execution.provider, + analysis_profile: entry.execution.analysis_profile, + }); + +const workflowEntryEvidenceKey = ( + entry: AnalysisSnapshotWorkflowEntry, +): string => + canonicalJson({ + query: workflowEntryQueryKey(entry), + normalized_result: entry.execution.result, + raw_result: entry.execution.raw_result, + limitations: entry.execution.limitations, + locations: entry.execution.locations, + subject: + entry.execution.subject === null + ? null + : { + local_path: entry.execution.subject.path, + sha256: entry.execution.subject.sha256, + format: entry.execution.subject.format, + architecture: entry.execution.subject.architecture, + }, + }); + +const isWorkflowEvidence = ( + evidence: Evidence, + snapshot: Pick, + entry: AnalysisSnapshotWorkflowEntry, +): boolean => + evidence.predicate_type === "rea.analysis" && + evidence.confidence === "derived" && + evidence.authority === "shipped-artifact" && + evidence.subject?.digest.sha256 === snapshot.target.sha256 && + "analysis_profile" in evidence && + analysisProfilesEqual( + evidence.analysis_profile, + entry.execution.analysis_profile, + ) && + evidence.provider.id === entry.execution.provider.id && + evidence.provider.name === entry.execution.provider.name && + evidence.provider.version === entry.execution.provider.version; + +const createWorkflowEvidenceIndex = ( + snapshot: Pick, +): EvidenceIndex => { + const matching = new Set(); + const corresponding = new Set(); + for (const evidence of snapshot.evidence_bundle.records) { + if ( + evidence.predicate_type !== "rea.analysis" || + evidence.confidence !== "derived" || + evidence.authority !== "shipped-artifact" || + evidence.subject?.digest.sha256 !== snapshot.target.sha256 || + !("analysis_profile" in evidence) + ) + continue; + corresponding.add(evidenceQueryKey(evidence)); + matching.add(canonicalJson(evidenceContentProjection(evidence))); + } + return { matching, corresponding }; +}; + +const workflowEntryHasMatchingEvidence = ( + entry: AnalysisSnapshotWorkflowEntry, + index: EvidenceIndex, +): boolean => index.matching.has(workflowEntryEvidenceKey(entry)); + +const workflowEntryHasCorrespondingEvidence = ( + entry: AnalysisSnapshotWorkflowEntry, + index: EvidenceIndex, +): boolean => index.corresponding.has(workflowEntryQueryKey(entry)); + +const evidenceMatchesWorkflowEntry = ( + evidence: Evidence, + entry: AnalysisSnapshotWorkflowEntry, + snapshot: Pick, +): boolean => + isWorkflowEvidence(evidence, snapshot, entry) && + canonicalJson(evidenceContentProjection(evidence)) === + workflowEntryEvidenceKey(entry); + +const evidenceContentProjection = (evidence: Evidence) => ({ + query: evidenceQueryKey(evidence), + normalized_result: evidence.normalized_result, + raw_result: evidence.raw_result, + limitations: evidence.limitations, + locations: evidence.locations, + subject: + evidence.subject === null + ? null + : { + local_path: evidence.subject.local_path, + sha256: evidence.subject.digest.sha256, + format: evidence.subject.format, + architecture: evidence.subject.architecture, + }, +}); + /** Serialize a validated snapshot with byte-stable canonical entry ordering. */ export const serializeAnalysisSnapshot = (snapshot: AnalysisSnapshot): string => `${canonicalJson(parseAnalysisSnapshot(snapshot))}\n`; diff --git a/tests/boundary/filesystem/directAnalysisPermissions.test.ts b/tests/boundary/filesystem/directAnalysisPermissions.test.ts index fcdef7e5d..cc2281938 100644 --- a/tests/boundary/filesystem/directAnalysisPermissions.test.ts +++ b/tests/boundary/filesystem/directAnalysisPermissions.test.ts @@ -54,7 +54,7 @@ describe("direct analysis snapshot files", () => { }); }); - it("replays a cache hit from the supplied path without root configuration", async () => { + it("does not replay unbound historical Evidence from a supplied snapshot", async () => { const directory = await createTestTempDirectory("rea-direct-snapshot-"); const snapshotPath = join(directory, "analysis.json"); const launcherPath = join(directory, "hopper-launcher"); @@ -97,6 +97,6 @@ describe("direct analysis snapshot files", () => { snapshotPath, }, ), - ).resolves.toEqual(evidence); + ).resolves.not.toEqual(evidence); }); }); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 6cbdda008..205692e77 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -3,6 +3,7 @@ import type { AnalysisProvider, } from "../../../src/application/AnalysisProvider.js"; import type { BinarySession } from "../../../src/application/binary/BinarySession.js"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import type { RecordUnknownInput } from "../../../src/domain/residualUnknown.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; @@ -159,6 +160,65 @@ describe("detached provider and target metadata", () => { }); }); +describe("opening previewed targets", () => { + it("rechecks the active target after another serialized transition", async () => { + const [first, second] = await createBinarySessionTargets(); + const calls: string[] = []; + const session = createTestBinarySession(createCacheProvider(calls)); + try { + expect((await session.open(first)).ok).toBe(true); + const target = session.activeTarget(); + if (target === undefined) throw new Error("Expected an active target"); + const preview = await session.previewTarget(target); + if (!preview.ok) throw preview.error; + expect(preview.value.sameTarget).toBe(true); + expect((await session.open(second)).ok).toBe(true); + expect((await session.openResolvedTarget(preview.value)).ok).toBe(true); + expect(session.activeTarget()?.path).toBe(first); + expect(calls).toEqual(["health", "health", "health"]); + } finally { + await session.close(); + } + }); + + it("rejects cancellation and a staged snapshot changed after preview without startup", async () => { + const [first, second] = await createBinarySessionTargets(); + const calls: string[] = []; + const session = createTestBinarySession(createCacheProvider(calls)); + const other = createTestBinarySession(createCacheProvider([])); + try { + const parsed = await parseBinaryTarget(first); + if (!parsed.ok) throw parsed.error; + const preview = await session.previewTarget(parsed.value); + if (!preview.ok) throw preview.error; + const controller = new AbortController(); + controller.abort(); + const cancelled = await session.openResolvedTarget(preview.value, { + signal: controller.signal, + }); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + + expect((await other.open(second)).ok).toBe(true); + const snapshot = other.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true); + const rejected = await session.openResolvedTarget(preview.value); + expect(rejected).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + expect(calls).toEqual([]); + expect(session.activeTarget()).toBeUndefined(); + } finally { + await session.close(); + await other.close(); + } + }); +}); + describe("non-hopper provider dispatch", () => { it("runs through a non-Hopper analysis provider", async () => { const [first] = await createBinarySessionTargets(); diff --git a/tests/composition/directAnalysisSnapshotReplay.test.ts b/tests/composition/directAnalysisSnapshotReplay.test.ts new file mode 100644 index 000000000..9f5d09ce4 --- /dev/null +++ b/tests/composition/directAnalysisSnapshotReplay.test.ts @@ -0,0 +1,633 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { runDirectAnalysis } from "../../src/application/DirectAnalysis.js"; +import type { + AnalysisProvider, + CapabilityDescriptor, + ProviderIdentity, +} from "../../src/application/AnalysisProvider.js"; +import type { DirectAnalysisDependencies } from "../../src/application/DirectAnalysisDependencies.js"; +import { createAnalysisProfile } from "../../src/domain/analysisProfile.js"; +import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; +import { ok } from "../../src/domain/result.js"; +import { + readAnalysisSnapshot, + writeAnalysisSnapshot, +} from "../../src/application/binary/AnalysisSnapshotFiles.js"; +import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js"; +import { + REA_WORKFLOW_PROVIDER, + workflowAnalysisProfile, +} from "../../src/application/InvestigationProviders.js"; +import { + createAnalysisSnapshotWorkflowEntry, + type AnalysisSnapshot, +} from "../../src/domain/analysisSnapshot.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { BinarySession } from "../../src/application/binary/BinarySession.js"; +import { SessionProviderRouter } from "../../src/application/binary/SessionProviderRouter.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +const IDENTITY = { + id: "snapshot-fixture", + name: "Snapshot Fixture Provider", + version: "1", +} as const; + +const operations = [ + "list_segments", + "list_documents", + "list_procedures", + "list_strings", +] as const; + +const workflowProviderOperations = [ + ...operations, + "analyze_function", + "list_names", + "search_strings", + "search_procedures", + "xrefs", + "resolve_containing_procedure", +] as const; + +const functionDossier = { + procedure: { + address: "0x1000", + name: "fixture", + signature: null, + locals: [], + }, + pseudocode: "return 0;", + assembly: ["ret"], + comments: [], + callers: [], + callees: [], + incoming_references: [], + outgoing_references: [], + referenced_strings: [], + referenced_names: [], + basic_blocks: [], + native_api: null, + native_value_flow: null, + limitations: [], +}; + +const makeProvider = ( + starts: string[], + calls: string[], + profile = createAnalysisProfile(IDENTITY, { fixture: true }), + identity: ProviderIdentity = profile.provider, + supportedOperations: readonly CapabilityDescriptor["operation"][] = operations, + resolutions: string[] = [], +): AnalysisProvider => { + const capabilities: CapabilityDescriptor[] = supportedOperations.map( + (operation) => ({ + operation, + provider: identity, + available: true, + reason: null, + cachePolicy: "snapshot", + effects: { + mutatesArtifact: false, + launchesProcess: true, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [], + }), + ); + return { + identity: () => identity, + capabilities: () => capabilities, + resolveAnalysisProfile: async () => { + resolutions.push(profile.digest); + return ok({ profile, compatibility: {} }); + }, + createClient: () => { + starts.push("start"); + return { + execute: async (operation) => { + calls.push(operation); + const results: Readonly> = { + list_segments: [{ name: "__TEXT", start: "0x1000", end: "0x2000" }], + list_documents: ["fixture"], + list_procedures: ["0x1000"], + list_strings: { "0x1000": "fixture" }, + analyze_function: functionDossier, + list_names: [{ address: "0x1000", name: "fixture" }], + search_strings: [{ address: "0x1000", value: "fixture" }], + search_procedures: [{ address: "0x1000", value: "fixture" }], + xrefs: [], + resolve_containing_procedure: null, + procedure_pseudo_code: "return 0;", + }; + const result = results[operation] ?? null; + return ok( + createAnalysisExecution(result, profile.provider, { + analysisProfile: profile, + rawResult: result, + }), + ); + }, + close: async () => undefined, + }; + }, + }; +}; + +const withAlternateWorkflowProfile = ( + snapshot: AnalysisSnapshot, + path: string, + current: Evidence, +): AnalysisSnapshot => { + const alternateProfile = createAnalysisProfile(REA_WORKFLOW_PROVIDER, { + workflow: "binary_overview", + fixture: "alternate-profile", + }); + const subject = { + path, + sha256: snapshot.target.sha256, + format: snapshot.target.format, + ...(snapshot.target.architecture === null + ? {} + : { architecture: snapshot.target.architecture }), + }; + const alternateEvidence = createEvidence(subject, REA_WORKFLOW_PROVIDER, { + operation: current.operation, + parameters: current.parameters, + result: current.normalized_result, + rawResult: current.raw_result, + analysisProfile: alternateProfile, + confidence: "derived", + limitations: current.limitations, + locations: current.locations, + }); + const alternateEntry = createAnalysisSnapshotWorkflowEntry({ + target: snapshot.target, + binding: snapshot.binding, + operation: current.operation, + parameters: current.parameters, + execution: { + result: current.normalized_result, + rawResult: current.raw_result, + provider: REA_WORKFLOW_PROVIDER, + analysisProfile: alternateProfile, + limitations: current.limitations, + locations: current.locations, + subject, + }, + }); + return { + ...snapshot, + workflow_entries: [alternateEntry], + evidence_bundle: createEvidenceBundle([ + ...snapshot.evidence_bundle.records, + alternateEvidence, + ]), + }; +}; + +const withEarlierHistoricalEvidence = ( + snapshot: AnalysisSnapshot, + path: string, + current: Evidence, +): AnalysisSnapshot => { + const older = Array.from({ length: 64 }, (_, index) => + createEvidence( + { + path, + sha256: snapshot.target.sha256, + format: "analysis-database", + }, + REA_WORKFLOW_PROVIDER, + { + operation: "binary_overview", + parameters: {}, + result: `historical-${index}`, + analysisProfile: workflowAnalysisProfile( + snapshot.binding.analysis_profile, + ), + confidence: "derived", + limitations: ["Derived by an REA composed workflow."], + }, + ), + ).find((record) => record.evidence_id < current.evidence_id); + if (older === undefined) + throw new Error("fixture could not construct earlier historical Evidence"); + return { + ...snapshot, + evidence_bundle: createEvidenceBundle([ + ...snapshot.evidence_bundle.records, + older, + ]), + }; +}; + +describe("direct analysis composed snapshot replay", () => { + it.each([ + { + tool: "search_strings", + arguments: { pattern: "uncached", document: "fixture" }, + }, + { tool: "inspect_native_api", arguments: { procedure: "0x2000" } }, + ] as const)( + "reuses the previewed profile on a $tool snapshot miss", + async (scenario) => { + const directory = await createTestTempDirectory( + "rea-snapshot-miss-route-", + ); + const path = join(directory, "fixture.hop"); + const snapshotPath = join(directory, "snapshot.json"); + await writeFile(path, "fixture"); + const starts: string[] = []; + const calls: string[] = []; + const resolutions: string[] = []; + const profile = createAnalysisProfile(IDENTITY, { fixture: true }); + const provider = makeProvider( + starts, + calls, + profile, + IDENTITY, + workflowProviderOperations, + resolutions, + ); + const dependencies: DirectAnalysisDependencies = { + createBinarySession: () => createTestBinarySession(provider), + createManagedBinarySession: () => createTestBinarySession(provider), + }; + await runDirectAnalysis( + dependencies, + path, + "binary_overview", + {}, + { snapshotPath }, + ); + expect(resolutions).toHaveLength(1); + + const first = await runDirectAnalysis( + dependencies, + path, + scenario.tool, + scenario.arguments, + { snapshotPath }, + ); + expect(first).toMatchObject({ operation: scenario.tool }); + expect(resolutions).toHaveLength(2); + expect(starts).toHaveLength(2); + const callsAfterMiss = [...calls]; + + const replay = await runDirectAnalysis( + dependencies, + path, + scenario.tool, + scenario.arguments, + { snapshotPath }, + ); + expect(replay).toEqual(first); + expect(resolutions).toHaveLength(3); + expect(starts).toHaveLength(2); + expect(calls).toEqual(callsAfterMiss); + }, + ); + + for (const scenario of [ + { + tool: "inspect_native_api", + arguments: { procedure: "0x1000" }, + }, + { + tool: "inspect_native_dispatch_metadata", + arguments: {}, + }, + { + tool: "trace_feature", + arguments: { query: "fixture" }, + }, + { + tool: "trace_native_values", + arguments: { procedure: "0x1000" }, + }, + ] as const) { + it(`persists and replays ${scenario.tool} without provider startup`, async () => { + const directory = await createTestTempDirectory("rea-workflow-command-"); + const path = join(directory, "fixture.hop"); + const snapshotPath = join(directory, "snapshot.json"); + await writeFile(path, "fixture"); + const starts: string[] = []; + const calls: string[] = []; + const provider = makeProvider( + starts, + calls, + createAnalysisProfile(IDENTITY, { fixture: true }), + IDENTITY, + workflowProviderOperations, + ); + const dependencies: DirectAnalysisDependencies = { + createBinarySession: () => createTestBinarySession(provider), + createManagedBinarySession: () => createTestBinarySession(provider), + }; + const first = await runDirectAnalysis( + dependencies, + path, + scenario.tool, + scenario.arguments, + { snapshotPath }, + ); + const callsAfterFirst = [...calls]; + const startsAfterFirst = [...starts]; + const loaded = await readAnalysisSnapshot(snapshotPath); + if (!loaded.ok) throw loaded.error; + expect( + loaded.value.workflow_entries?.map(({ operation }) => operation), + ).toContain(scenario.tool); + + const second = await runDirectAnalysis( + dependencies, + path, + scenario.tool, + scenario.arguments, + { snapshotPath }, + ); + + expect(second).toEqual(first); + expect(calls).toEqual(callsAfterFirst); + expect(starts).toEqual(startsAfterFirst); + }); + } +}); + +describe("binary overview snapshot replay", () => { + it("replays the identical binary overview without provider startup or calls", async () => { + const directory = await createTestTempDirectory("rea-workflow-snapshot-"); + const path = join(directory, "fixture.hop"); + const snapshotPath = join(directory, "snapshot.json"); + await writeFile(path, "fixture"); + const starts: string[] = []; + const calls: string[] = []; + const provider = makeProvider(starts, calls); + const dependencies: DirectAnalysisDependencies = { + createBinarySession: () => createTestBinarySession(provider), + createManagedBinarySession: () => createTestBinarySession(provider), + }; + + const first = await runDirectAnalysis( + dependencies, + path, + "binary_overview", + {}, + { snapshotPath }, + ); + expect(calls).toEqual(["health", ...operations]); + expect(starts).toHaveLength(1); + + const loaded = await readAnalysisSnapshot(snapshotPath); + if (!loaded.ok) throw loaded.error; + expect(loaded.value.workflow_entries).toHaveLength(1); + const current = loaded.value.evidence_bundle.records.find( + (record) => record.operation === "binary_overview", + ); + if (current === undefined) + throw new Error("composed Evidence was not saved"); + expect( + ( + await writeAnalysisSnapshot( + withEarlierHistoricalEvidence(loaded.value, path, current), + snapshotPath, + true, + ) + ).ok, + ).toBe(true); + + const second = await runDirectAnalysis( + dependencies, + path, + "binary_overview", + {}, + { snapshotPath }, + ); + expect(second).toEqual(first); + expect(calls).toEqual(["health", ...operations]); + expect(starts).toHaveLength(1); + }); +}); + +describe("snapshot replay provider identity", () => { + it("uses the resolved provider version when capability metadata omits it", async () => { + const directory = await createTestTempDirectory("rea-profile-version-"); + const path = join(directory, "fixture.hop"); + const snapshotPath = join(directory, "snapshot.json"); + await writeFile(path, "fixture"); + const starts: string[] = []; + const calls: string[] = []; + const unresolvedIdentity = { ...IDENTITY, version: null }; + const resolvedProfile = createAnalysisProfile( + { ...IDENTITY, version: "launcher-sha256:fixture" }, + { fixture: true }, + ); + const provider = makeProvider( + starts, + calls, + resolvedProfile, + unresolvedIdentity, + ["search_strings"], + ); + const dependencies: DirectAnalysisDependencies = { + createBinarySession: () => createTestBinarySession(provider), + createManagedBinarySession: () => createTestBinarySession(provider), + }; + const first = await runDirectAnalysis( + dependencies, + path, + "search_strings", + { pattern: "fixture", document: "fixture" }, + { snapshotPath }, + ); + const callsAfterFirst = [...calls]; + const startsAfterFirst = [...starts]; + const firstSnapshot = await readAnalysisSnapshot(snapshotPath); + if (!firstSnapshot.ok) throw firstSnapshot.error; + expect(firstSnapshot.value.entries[0]?.execution.provider.version).toBe( + "launcher-sha256:fixture", + ); + + const second = await runDirectAnalysis( + dependencies, + path, + "search_strings", + { pattern: "fixture", document: "fixture" }, + { snapshotPath }, + ); + expect(calls).toEqual(callsAfterFirst); + expect(starts).toEqual(startsAfterFirst); + expect(second).toEqual(first); + + const changedProfileProvider = makeProvider( + starts, + calls, + createAnalysisProfile( + { ...IDENTITY, version: "launcher-sha256:changed" }, + { fixture: true }, + ), + unresolvedIdentity, + ["search_strings"], + ); + const changedProfileResult = await runDirectAnalysis( + { + createBinarySession: () => + createTestBinarySession(changedProfileProvider), + createManagedBinarySession: () => + createTestBinarySession(changedProfileProvider), + }, + path, + "search_strings", + { pattern: "fixture", document: "fixture" }, + { snapshotPath }, + ); + expect(changedProfileResult).not.toEqual(first); + expect(changedProfileResult).toMatchObject({ + code: "evidence_integrity_mismatch", + }); + expect(starts).toEqual(startsAfterFirst); + expect(calls).toEqual(callsAfterFirst); + + const changedProviderIdentity = { + id: "other-provider", + name: "Other Provider", + version: null, + }; + const changedProvider = makeProvider( + starts, + calls, + createAnalysisProfile( + { ...changedProviderIdentity, version: "other-version" }, + { fixture: true }, + ), + changedProviderIdentity, + ["search_strings"], + ); + const changedProviderResult = await runDirectAnalysis( + { + createBinarySession: () => createTestBinarySession(changedProvider), + createManagedBinarySession: () => + createTestBinarySession(changedProvider), + }, + path, + "search_strings", + { pattern: "fixture", document: "fixture" }, + { snapshotPath }, + ); + expect(changedProviderResult).not.toEqual(first); + expect(changedProviderResult).toMatchObject({ + code: "evidence_integrity_mismatch", + }); + expect(starts).toEqual(startsAfterFirst); + expect(calls).toEqual(callsAfterFirst); + }); +}); + +describe("workflow snapshot profile and cancellation binding", () => { + it("does not replay a valid entry from another workflow profile", async () => { + const directory = await createTestTempDirectory("rea-workflow-profile-"); + const path = join(directory, "fixture.hop"); + const snapshotPath = join(directory, "snapshot.json"); + await writeFile(path, "fixture"); + const starts: string[] = []; + const calls: string[] = []; + const provider = makeProvider(starts, calls); + const dependencies: DirectAnalysisDependencies = { + createBinarySession: () => createTestBinarySession(provider), + createManagedBinarySession: () => createTestBinarySession(provider), + }; + await runDirectAnalysis( + dependencies, + path, + "binary_overview", + {}, + { snapshotPath }, + ); + const loaded = await readAnalysisSnapshot(snapshotPath); + if (!loaded.ok) throw loaded.error; + const current = loaded.value.evidence_bundle.records.find( + (record) => record.operation === "binary_overview", + ); + if (current === undefined) + throw new Error("composed Evidence was not saved"); + expect( + ( + await writeAnalysisSnapshot( + withAlternateWorkflowProfile(loaded.value, path, current), + snapshotPath, + true, + ) + ).ok, + ).toBe(true); + + await runDirectAnalysis( + dependencies, + path, + "binary_overview", + {}, + { snapshotPath }, + ); + expect(calls).toEqual(["health", ...operations, "health", ...operations]); + expect(starts).toHaveLength(2); + }); + + it("does not return a cached result when cancelled during route resolution", async () => { + const directory = await createTestTempDirectory("rea-workflow-cancel-"); + const path = join(directory, "fixture.hop"); + const snapshotPath = join(directory, "snapshot.json"); + await writeFile(path, "fixture"); + const starts: string[] = []; + const calls: string[] = []; + const profile = createAnalysisProfile(IDENTITY, { fixture: true }); + const provider = makeProvider(starts, calls, profile); + const initialDependencies: DirectAnalysisDependencies = { + createBinarySession: () => createTestBinarySession(provider), + createManagedBinarySession: () => createTestBinarySession(provider), + }; + await runDirectAnalysis( + initialDependencies, + path, + "binary_overview", + {}, + { snapshotPath }, + ); + + const controller = new AbortController(); + const router = SessionProviderRouter.single(provider); + const resolve = router.resolve.bind(router); + router.resolve = async (...arguments_) => { + const resolved = await resolve(...arguments_); + controller.abort(); + return resolved; + }; + const session = new BinarySession(router); + const dependencies: DirectAnalysisDependencies = { + createBinarySession: () => session, + createManagedBinarySession: () => session, + }; + const result = await runDirectAnalysis( + dependencies, + path, + "binary_overview", + {}, + { snapshotPath, signal: controller.signal }, + ); + + expect(result).toMatchObject({ + error: "Analysis failed", + code: "cancelled", + }); + expect(calls).toEqual(["health", ...operations]); + expect(starts).toHaveLength(1); + }); +});