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 <cursoragent@cursor.com>
This commit is contained in:
parent
96a46ada0a
commit
1d62e54e1f
|
|
@ -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<typeof runAnalysis>[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,
|
||||
|
|
|
|||
|
|
@ -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<AnalysisOperation, "health">;
|
||||
|
|
|
|||
|
|
@ -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<string> = 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<string, AnalysisSnapshotEntry>();
|
||||
readonly #workflowEntries = new Map<string, AnalysisSnapshotWorkflowEntry>();
|
||||
#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<Record<string, JsonValue>>;
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Result<ResolvedSessionOpen, AnalysisError>> {
|
||||
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<Result<BinaryTarget, AnalysisError>> {
|
||||
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<ResolvedSessionOpen, "target" | "route">,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot"> = {},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#open(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
validateResolvedSessionTarget({
|
||||
...resolved,
|
||||
current: this.#active,
|
||||
options,
|
||||
stagedSnapshotMatches: (target, profile) =>
|
||||
this.matchesSnapshot(target, profile),
|
||||
}),
|
||||
),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
#open(
|
||||
resolve: () => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<Result<ResolvedSessionOpen, AnalysisError>> => {
|
||||
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<Result<ResolvedSessionOpen, AnalysisError>> => {
|
||||
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<ResolveSessionTargetInput, "router"> & {
|
||||
readonly route: SessionProviderRoute;
|
||||
},
|
||||
): Result<ResolvedSessionOpen, AnalysisError> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, import("../../domain/jsonValue.js").JsonValue>
|
||||
>;
|
||||
readonly execution: Parameters<
|
||||
AnalysisSnapshotCache["recordWorkflow"]
|
||||
>[0]["execution"];
|
||||
}): Result<null, EvidenceIntegrityError> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof analysisSnapshotSchema>;
|
||||
export type AnalysisSnapshotEntry = z.infer<typeof entrySchema>;
|
||||
export type AnalysisSnapshotWorkflowEntry = z.infer<typeof workflowEntrySchema>;
|
||||
export type AnalysisSnapshotTarget = z.infer<typeof targetSchema>;
|
||||
export type AnalysisSnapshotBinding = z.infer<typeof bindingSchema>;
|
||||
|
||||
|
|
@ -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<Record<string, JsonValue>>;
|
||||
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<AnalysisSnapshot, "target" | "binding">,
|
||||
): 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<string>();
|
||||
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<AnalysisSnapshot, "target">,
|
||||
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<AnalysisSnapshot, "target" | "evidence_bundle">,
|
||||
): EvidenceIndex => {
|
||||
const matching = new Set<string>();
|
||||
const corresponding = new Set<string>();
|
||||
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<AnalysisSnapshot, "target">,
|
||||
): 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`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue