import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import { EvidenceIntegrityError, AnalysisSnapshotMismatchError, } from "../../domain/evidenceErrors.js"; import { NoBinaryOpenError } from "../../domain/configurationErrors.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { rebindEvidenceSubjectTargetPath, type Evidence, } from "../../domain/evidence.js"; import { err, ok, type Result } from "../../domain/result.js"; import { analysisQueryId, evidenceMatchesEntry, evidenceMatchesWorkflowEntry, createAnalysisSnapshotEntry, createAnalysisSnapshotWorkflowEntry, snapshotBinding, snapshotBindingMismatch, snapshotMatchesProfile, snapshotTarget, parseAnalysisSnapshot, immutableAnalysisSnapshot, type AnalysisSnapshot, type AnalysisSnapshotEntry, type AnalysisSnapshotWorkflowEntry, type AnalysisSnapshotBinding, type AnalysisSnapshotTarget, type SnapshotBindingMismatch, } from "../../domain/analysisSnapshot.js"; import type { AnalysisExecution, AnalysisOperation, CapabilityDescriptor, ExecutionOptions, } from "../AnalysisProvider.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { EvidenceMetadataReplacement } from "../investigation/EvidenceLedger.js"; import { snapshotReplayBinding, type SnapshotReplayBinding, } from "./AnalysisSnapshotReplayBinding.js"; import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js"; const RELATIONSHIP_OPERATIONS: ReadonlySet = new Set([ "procedure_callers", "procedure_callees", ]); // These reports depend on selected bundle resources or loader context beyond // the immutable executable identity carried by a binary analysis snapshot. const RESOURCE_SCOPED_OPERATIONS: ReadonlySet = new Set([ "decode_interface_builder", "inspect_asset_catalog", "inspect_keyed_archive", "trace_dylib_resolution", ]); const STATEFUL_OPERATIONS: ReadonlySet = new Set([ "health", "current_address", "current_procedure", "current_document", "goto_address", "list_documents", ]); const CURSOR_DEFAULT_OPERATIONS: ReadonlySet = new Set([ "address_name", "comment", "inline_comment", "next_address", "prev_address", "xrefs", ]); const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet = new Set( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter( (name) => name !== "current_document" && name !== "list_documents", ), ); /** Maximum duplicated result bindings retained across direct and workflow queries. */ export const SNAPSHOT_CACHE_ENTRY_CEILING = 10_000; const replayDocumentSelected = ( operation: AnalysisOperation, parameters: Readonly>, ): boolean => !DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string"; /** Ghidra's private Program belongs to this one-shot target; GUI defaults do not. */ const isOneShotPseudocode = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, context: ExecutionOptions["snapshotContext"], ): boolean => context === "one-shot-target" && operation === "procedure_pseudo_code" && descriptor?.provider.id === "ghidra"; /** * Caller and callee walks omit `document`. Their observations are still * Evidence. Other document-scoped reads, including `procedure_pseudo_code` * from `decompile_all`, already keep each body in the file-backed export. * Copying those bodies into the in-memory ledger would retain the export. */ const DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS: ReadonlySet = new Set(["procedure_callers", "procedure_callees"]); /** * Whether a source observation is safe to retain as Evidence. * A missing document selector blocks snapshot replay. A producer can require * its observation as Evidence independently of whether it can be replayed. */ export const isSourceEvidenceRetainable = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, parameters: Readonly>, options: { readonly snapshotContext?: ExecutionOptions["snapshotContext"]; readonly requiresSourceEvidence?: boolean; } = {}, ): descriptor is CapabilityDescriptor => !STATEFUL_OPERATIONS.has(operation) && !RESOURCE_SCOPED_OPERATIONS.has(operation) && (!DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string" || DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation) || options.requiresSourceEvidence === true || isOneShotPseudocode(operation, descriptor, options.snapshotContext)) && (!CURSOR_DEFAULT_OPERATIONS.has(operation) || typeof parameters.address === "string") && descriptor?.effects.mutatesArtifact === false && (options.requiresSourceEvidence === true || descriptor.cachePolicy === "snapshot" || descriptor.effects.mayWriteFilesystem === false) && descriptor.effects.changesPermissions === false; /** Explicit independent decompiles must run even if a snapshot has a matching entry. */ export const requestsFreshIndependentDecompile = ( operation: string, parameters: Readonly>, ): boolean => operation === "procedure_pseudo_code" && parameters.analysis === "independent_fresh"; /** Whether an observation can additionally be replayed as a snapshot. */ export const isSnapshotCacheable = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, parameters: Readonly>, context?: ExecutionOptions["snapshotContext"], ): descriptor is CapabilityDescriptor => !requestsFreshIndependentDecompile(operation, parameters) && isSourceEvidenceRetainable(operation, descriptor, parameters, { snapshotContext: context, }) && (replayDocumentSelected(operation, parameters) || isOneShotPseudocode(operation, descriptor, context)) && descriptor.cachePolicy !== "live"; /** Windows Ghidra must admit the selected source through its native snapshot owner. */ export const requiresNativeSourceAdmissionBeforeReplay = ( providerId: string | undefined, ): boolean => process.platform === "win32" && providerId === "ghidra"; /** Limitation that marks every observation replayed from a snapshot. */ export const SNAPSHOT_REPLAY_LIMITATION = "Loaded from a local REA analysis snapshot; this call did not re-run the provider."; /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); // Null is ambiguous provenance; a pending ID is an exact parent whose source // closure was missing and can be checked again against the owning ledger. readonly #replayBindings = new Map< string, SnapshotReplayBinding | { readonly pendingEvidenceId: string } | null >(); readonly #workflowEntries = new Map(); #target: AnalysisSnapshotTarget | undefined; #binding: AnalysisSnapshotBinding | undefined; /** Whether staged entries belong to the supplied target and profile. */ matches( target: BinaryTarget, profile: AnalysisProfileCommitment | undefined, ): boolean { return this.mismatch(target, profile) === undefined; } /** Explain staged mismatch without treating missing binding facts as absent. */ mismatch( target: BinaryTarget, profile: AnalysisProfileCommitment | undefined, ): SnapshotBindingMismatch | undefined { if (this.#target === undefined && this.#binding === undefined) return undefined; if (this.#target === undefined || this.#binding === undefined) return { staged_binding: "incomplete" }; return snapshotBindingMismatch( { target: this.#target, binding: this.#binding }, target, profile, ); } /** Replace cache state when the target or selected profile changes. */ select(target: BinaryTarget, profile: AnalysisProfileCommitment): void { if (!this.matches(target, profile)) this.#clearEntries(); this.#target = snapshotTarget(target); this.#binding = snapshotBinding(profile); } /** Merge already-validated entries and return the new-entry count. */ stage(snapshot: AnalysisSnapshot): number { snapshot = immutableAnalysisSnapshot(snapshot); if ( (this.#target !== undefined && JSON.stringify(this.#target) !== JSON.stringify(snapshot.target)) || (this.#binding !== undefined && !snapshotMatchesProfile( this.#binding, snapshot.binding.analysis_profile, )) ) this.#clearEntries(); this.#target = structuredClone(snapshot.target); this.#binding = structuredClone(snapshot.binding); let imported = 0; const stagedQueryIds = new Set(); 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, entry); stagedQueryIds.add(entry.query_id); this.#replayBindings.delete(entry.query_id); } // Snapshot validation authenticated these records. Recover the original // identity before lookup adds replay caveats; do not mint a new record. const evidenceByQuery = new Map(); const records = new Map( snapshot.evidence_bundle.records.map((record) => [ record.evidence_id, record, ]), ); const lookup = (id: string) => records.get(id); const checkClosure = createInvestigationEvidenceClosureChecker({ evidenceForAnalysis: lookup, }); const ambiguous = new Set(); for (const evidence of snapshot.evidence_bundle.records) { const queryId = analysisQueryId( snapshot.target, snapshot.binding, evidence.operation, evidence.parameters, ); if (!stagedQueryIds.has(queryId)) continue; const entry = this.#entries.get(queryId); if ( entry === undefined || !evidenceMatchesEntry(evidence, entry, snapshot) ) continue; const previous = evidenceByQuery.get(queryId); if ( previous !== undefined && previous.evidence_id !== evidence.evidence_id ) ambiguous.add(queryId); else evidenceByQuery.set(queryId, evidence); } for (const [queryId, evidence] of evidenceByQuery) { if (ambiguous.has(queryId)) { this.#replayBindings.set(queryId, null); continue; } this.#replayBindings.set( queryId, snapshotReplayBinding(evidence, lookup, checkClosure) ?? { pendingEvidenceId: evidence.evidence_id, }, ); } for (const entry of snapshot.workflow_entries) { if (!this.#workflowEntries.has(entry.query_id) && !this.#hasCapacity()) continue; if (!this.#workflowEntries.has(entry.query_id)) imported += 1; this.#workflowEntries.set(entry.query_id, entry); } return imported; } /** Refresh subject bindings backed by replaced authenticated metadata. */ refreshEvidenceMetadata( replacements: readonly EvidenceMetadataReplacement[], ): void { if ( replacements.length === 0 || this.#target === undefined || this.#binding === undefined ) return; const currentRecords = new Map( replacements.map(({ current }) => [current.evidence_id, current]), ); for (const [queryId, binding] of this.#replayBindings) { if ( binding === null || "pendingEvidenceId" in binding || binding.sourceEvidence === undefined ) continue; const sources = binding.sourceEvidence; if (!sources.some((source) => currentRecords.has(source.evidence_id))) continue; this.#replayBindings.set(queryId, { evidenceId: binding.evidenceId, sourceEvidence: Object.freeze( sources.map( (source) => currentRecords.get(source.evidence_id) ?? source, ), ), }); } const header = { target: this.#target, binding: this.#binding }; for (const { previous, current } of replacements) { if ( current.subject === null || previous.subject?.local_path === current.subject.local_path ) continue; const queryId = analysisQueryId( header.target, header.binding, previous.operation, previous.parameters, ); const entry = this.#entries.get(queryId); if ( entry !== undefined && entry.execution.subject !== null && evidenceMatchesEntry(previous, entry, header) ) this.#entries.set(queryId, { ...entry, execution: { ...entry.execution, subject: { ...entry.execution.subject, path: current.subject.local_path, }, }, }); const workflow = this.#workflowEntries.get(queryId); if ( workflow !== undefined && workflow.execution.subject !== null && evidenceMatchesWorkflowEntry(previous, workflow, header) ) this.#workflowEntries.set(queryId, { ...workflow, execution: { ...workflow.execution, subject: { ...workflow.execution.subject, path: current.subject.local_path, }, }, }); } } /** Build a complete snapshot for an active target. */ export( target: BinaryTarget | undefined, profile: AnalysisProfileCommitment | undefined, evidenceBundle: EvidenceBundle, ): Result { if (target === undefined || profile === undefined) return err(new NoBinaryOpenError()); try { const snapshotTargetIdentity = snapshotTarget(target); const binding = snapshotBinding(profile); // In-memory replay can preserve unbound and child-artifact observations. // A persisted target snapshot can authenticate only rows whose Evidence // has that target subject; the record owner applies the same scope to its bundle. const entries = [...this.#entries.values()] .filter((entry) => entry.execution.subject?.sha256 === target.sha256) .sort((left, right) => compareUnicodeCodePoints(left.query_id, right.query_id), ); const workflows = [...this.#workflowEntries.values()] .filter((entry) => entry.execution.subject?.sha256 === target.sha256) .sort((left, right) => compareUnicodeCodePoints(left.query_id, right.query_id), ); // Parsing owns the returned JSON and metadata; pre-cloning the same // payloads here only adds another full materialization. return ok( parseAnalysisSnapshot({ target: snapshotTargetIdentity, binding, entries, workflow_entries: workflows, evidence_bundle: evidenceBundle, }), ); } catch (cause: unknown) { return err( new EvidenceIntegrityError("Analysis snapshot validation failed", { cause, }), ); } } /** Validate target identity, merge evidence atomically, then stage entries. */ import( snapshot: AnalysisSnapshot, active: | { readonly target: BinaryTarget; readonly profile: AnalysisProfileCommitment; } | undefined, mergeEvidence: (bundle: EvidenceBundle) => Result, ): Result { let validated: AnalysisSnapshot; try { validated = immutableAnalysisSnapshot(snapshot); } catch (cause: unknown) { return err( new EvidenceIntegrityError("Analysis snapshot validation failed", { cause, }), ); } if (active !== undefined) { const mismatch = snapshotBindingMismatch( validated, active.target, active.profile, ); if (mismatch !== undefined) return err( new AnalysisSnapshotMismatchError( "Analysis snapshot target, provider, or analysis profile does not match the active binary", mismatch, ), ); } const importedEvidence = mergeEvidence(validated.evidence_bundle); return importedEvidence.ok ? ok(this.stage(validated)) : importedEvidence; } /** Return canonical entries for persistence. */ entries(): AnalysisSnapshotEntry[] { return [...this.#entries.values()] .sort((left, right) => compareUnicodeCodePoints(left.query_id, right.query_id), ) .map((entry) => structuredClone(entry)); } /** Return canonical composed-workflow entries for persistence. */ workflowEntries(): AnalysisSnapshotWorkflowEntry[] { return [...this.#workflowEntries.values()] .sort((left, right) => compareUnicodeCodePoints(left.query_id, 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 relationshipCoverage?: AnalysisExecution["relationshipCoverage"]; readonly locations: AnalysisExecution["locations"]; readonly subject: NonNullable | null; }; }): 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, profile: AnalysisProfileCommitment, operation: AnalysisOperation, parameters: Readonly>, lookup?: (id: string) => Evidence | undefined, ): AnalysisExecution | undefined { const queryId = analysisQueryId( snapshotTarget(target), snapshotBinding(profile), operation, parameters, ); const cached = this.#entries.get(queryId); if (cached === undefined) return undefined; let binding = this.#replayBindings.get(queryId); // Do not turn a refused provenance selection into a fresh, unlinked record. // The provider must produce a new observation before this query can replay. if (binding === null) return undefined; if (binding !== undefined && "pendingEvidenceId" in binding) { const parent = lookup?.(binding.pendingEvidenceId); if (parent === undefined || lookup === undefined) return undefined; const restored = snapshotReplayBinding(parent, lookup); if (restored === null) return undefined; this.#replayBindings.set(queryId, restored); binding = restored; } const cachedSubject = cached.execution.subject; const subject = cachedSubject == null ? null : rebindEvidenceSubjectTargetPath( { path: cachedSubject.path, sha256: cachedSubject.sha256, format: cachedSubject.format, ...(cachedSubject.architecture === null ? {} : { architecture: cachedSubject.architecture }), }, target, ); const execution: AnalysisExecution = structuredClone({ ...(binding === undefined ? {} : { evidenceId: binding.evidenceId }), result: cached.execution.result, rawResult: cached.execution.raw_result, provider: cached.execution.provider, analysisProfile: structuredClone(profile), limitations: [ ...cached.execution.limitations, SNAPSHOT_REPLAY_LIMITATION, ], ...(cached.execution.relationship_coverage === null && RELATIONSHIP_OPERATIONS.has(operation) ? { relationshipCoverage: { status: "unknown", reason: "Snapshot entry does not report relationship coverage.", }, } : cached.execution.relationship_coverage === null ? {} : { relationshipCoverage: cached.execution.relationship_coverage, }), locations: cached.execution.locations, subject: subject ?? null, }); return binding?.sourceEvidence === undefined ? execution : { ...execution, sourceEvidence: binding.sourceEvidence }; } /** Record one successful immutable call unless the cache is full. */ record( input: { readonly target: BinaryTarget; readonly profile: AnalysisProfileCommitment; readonly operation: AnalysisOperation; readonly parameters: Readonly>; readonly execution: AnalysisExecution; readonly evidence?: Evidence; }, lookup?: (id: string) => Evidence | undefined, ): void { const { target, profile, operation, parameters, execution } = input; this.select(target, profile); // The snapshot format does not carry reference truncation or stored-pointer // classifications. Replaying either would present a capped or untyped read // as the live observation. if ( execution.referencesTruncated === true || execution.referenceClassifications !== undefined || execution.referenceCoverageGaps !== undefined ) return; let sourceBinding: SnapshotReplayBinding | undefined; if ((execution.sourceEvidence?.length ?? 0) > 0) { if (execution.evidenceId === undefined || lookup === undefined) return; const parent = lookup(execution.evidenceId); if (parent === undefined) return; const closed = snapshotReplayBinding(parent, lookup); if (closed === null) return; sourceBinding = closed; } 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), operation, parameters, execution: { ...execution, subject: execution.subject === undefined ? { path: target.path, sha256: target.sha256, format: target.format, ...(target.architecture === undefined ? {} : { architecture: target.architecture }), } : execution.subject, }, }); this.#entries.set(entry.query_id, entry); if (execution.evidenceId === undefined) this.#replayBindings.delete(entry.query_id); else this.#replayBindings.set( entry.query_id, sourceBinding ?? { evidenceId: execution.evidenceId, ...(input.evidence === undefined ? {} : { sourceEvidence: Object.freeze([input.evidence]) }), }, ); } /** Forget all target-bound entries. */ clear(): void { this.#clearEntries(); this.#target = undefined; this.#binding = undefined; } #clearEntries(): void { this.#entries.clear(); this.#replayBindings.clear(); this.#workflowEntries.clear(); } #hasCapacity(): boolean { return ( this.#entries.size + this.#workflowEntries.size < SNAPSHOT_CACHE_ENTRY_CEILING ); } }