diff --git a/scripts/verify-real-ghidra-function-bundle.mjs b/scripts/verify-real-ghidra-function-bundle.mjs new file mode 100644 index 0000000000..ebc3d7bb48 --- /dev/null +++ b/scripts/verify-real-ghidra-function-bundle.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +// Exercise the public one-shot CLI on a caller-owned saved project. The report +// is intentionally synthetic; it is not a claim about Ghidra's analysis history. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createVerifierCommands } from "./lib/verify-commands.mjs"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; +const installation = process.env.GHIDRA_INSTALL_DIR; +if (!installation || !process.env.JAVA_HOME) + throw new Error( + "Set GHIDRA_INSTALL_DIR and JAVA_HOME to caller-installed Ghidra and JDK 21.", + ); +const commands = await createVerifierCommands(); +let root; +let commandsClosed = false; +const targetHash = await withVerifierCleanup(async () => { + root = await mkdtemp(join(tmpdir(), "rea-function-bundle-")); + const target = join(root, "fixture"); + const projectDir = join(root, "project"); + const scriptDir = join(root, "scripts"); + const source = join(root, "fixture.c"); + const project = join(projectDir, "SourceControl.gpr"); + const repo = resolve(process.cwd()); + const hash = (data) => createHash("sha256").update(data).digest("hex"); + const tree = async (directory, prefix = "") => { + const result = []; + for (const entry of ( + await readdir(directory, { withFileTypes: true }) + ).sort((a, b) => a.name.localeCompare(b.name))) { + const path = join(directory, entry.name); + const relative = join(prefix, entry.name); + if (entry.isDirectory()) result.push(...(await tree(path, relative))); + else if (entry.isFile()) + result.push([relative, hash(await readFile(path))]); + else throw new Error(`Unexpected project entry: ${relative}`); + } + return result; + }; + const run = async (command, args, options = {}) => + commands.exec(command, args, { + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + ...options, + }); + await mkdir(projectDir); + await mkdir(scriptDir); + await writeFile( + source, + "__attribute__((noinline)) int leaf(int x) { return x + 1; }\nint main(void) { return leaf(2); }\n", + ); + await writeFile( + join(scriptDir, "SetHistory.java"), + `import ghidra.app.script.GhidraScript; +import ghidra.program.model.listing.Program; +public class SetHistory extends GhidraScript { + public void run() throws Exception { + currentProgram.getOptions(Program.PROGRAM_INFO).setString("REA disabled analyzers", "{\\"format\\":\\"rea-ghidra-disabled-analyzers-v1\\",\\"analyzers\\":[{\\"name\\":\\"Synthetic historical control\\",\\"status\\":\\"disabled\\"}]}"); + } +} +`, + ); + await run(process.env.REA_CC ?? "cc", ["-O0", "-g", source, "-o", target]); + await run(join(installation, "support", "analyzeHeadless"), [ + projectDir, + "SourceControl", + "-import", + target, + "-scriptPath", + scriptDir, + "-postScript", + "SetHistory.java", + ]); + const before = await tree(projectDir); + const targetHash = hash(await readFile(target)); + const env = { + ...process.env, + REA_GHIDRA_PROJECT_PATH: project, + REA_GHIDRA_PROGRAM_NAME: "fixture", + }; + const cli = join(repo, "scripts", "rea.mjs"); + for (const detail of ["complete", "summary"]) { + const output = join(root, `${detail}.json`); + const { stdout } = await run( + process.execPath, + [ + cli, + "function", + target, + "main", + "--provider", + "ghidra", + "--detail", + detail, + "--evidence_output", + output, + "--format", + "json", + "--full-output", + ], + { env }, + ); + assert.equal(JSON.parse(stdout).ok, true); + const bundle = JSON.parse(await readFile(output, "utf8")); + const parent = bundle.records.find( + (record) => record.operation === "analyze_function", + ); + assert.ok(parent, "function observation missing from bundle"); + assert.ok( + parent.evidence_links.length, + "fixture did not produce a linked historical source", + ); + const records = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + for (const record of bundle.records) + for (const link of record.evidence_links) + assert.ok(records.has(link), `bundle omits linked Evidence ${link}`); + assert.ok( + parent.evidence_links.some( + (id) => records.get(id)?.operation === "health", + ), + "historical health source missing", + ); + } + const existing = join(root, "existing.json"); + await writeFile(existing, "caller-owned\n"); + let failure; + try { + await run( + process.execPath, + [ + cli, + "function", + target, + "main", + "--provider", + "ghidra", + "--detail", + "summary", + "--evidence_output", + existing, + "--format", + "json", + "--full-output", + ], + { env }, + ); + assert.fail("existing output unexpectedly overwritten"); + } catch (error) { + if (error.code !== 1) throw error; + failure = JSON.parse(error.snapshot.stdout.text); + } + assert.equal(await readFile(existing, "utf8"), "caller-owned\n"); + assert.equal(failure.ok, false); + assert.equal(failure.error.details.source_closure.complete, true); + assert.ok( + failure.error.details.source_closure.records.some( + (record) => record.operation === "health", + ), + ); + assert.equal(hash(await readFile(target)), targetHash); + assert.deepEqual(await tree(projectDir), before); + return targetHash; +}, [ + async () => { + await commands.close(); + commandsClosed = true; + }, + async () => { + if (commandsClosed && root !== undefined) + await rm(root, { recursive: true, force: true }); + }, +]); +process.stdout.write( + `PASS Ghidra function bundles: complete, summary, no-overwrite; target and project unchanged (${targetHash})\n`, +); diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index f8d5bb7e7e..a613526a75 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -16,7 +16,10 @@ import { import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js"; import { silentLogger } from "../logger.js"; import type { Logger } from "pino"; -import { withEvidenceCallerInputPath } from "../domain/evidence.js"; +import { + tryParseEvidence, + withEvidenceCallerInputPath, +} from "../domain/evidence.js"; import { createImmutableEvidenceBundle, type EvidenceBundle, @@ -26,8 +29,8 @@ import { type AnalysisResultWithSources, } from "./investigation/recordEvidenceSources.js"; import { - resolveInvestigationEvidenceClosure, createInvestigationEvidenceClosureChecker, + collectInvestigationEvidenceClosure, } from "./investigation/InvestigationEvidence.js"; import type { Evidence } from "../domain/evidence.js"; import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; @@ -388,12 +391,12 @@ const runAnalysis = async ( readonly evidenceParameters?: Readonly>; readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void; readonly createSession?: () => BinarySession | Promise; + readonly includeEvidenceSources?: boolean; readonly providerId?: AnalysisProviderSelector; readonly formatHint?: ExecutableFormatHint; readonly architecture?: BinaryArchitecture; readonly entryPoint?: string; readonly espFlash?: EspFlashSelection; - readonly includeEvidenceSources?: boolean; }, ): Promise> => { const { logger, signal, snapshotPath } = options; @@ -416,23 +419,19 @@ const runAnalysis = async ( evidence: Evidence, supplied: readonly Evidence[] = [], ): Result => { - if (options.includeEvidenceSources !== true) return ok(null); - const known = new Map( + if (!options.includeEvidenceSources) return ok(null); + const records = new Map( supplied.map((record) => [record.evidence_id, record]), ); - const closure = resolveInvestigationEvidenceClosure( + const collected = collectInvestigationEvidenceClosure( { - evidenceForAnalysis: (id) => { - const record = known.get(id) ?? session.evidenceForAnalysis(id); - if (record !== undefined) known.set(id, record); - return record; - }, + evidenceForAnalysis: (id) => + records.get(id) ?? session.evidenceForAnalysis(id), }, evidence.evidence_links, ); - if (!closure.ok) return closure; - sourceEvidence = closure.value.map((id) => known.get(id) as Evidence); - return ok(null); + sourceEvidence = collected.records; + return collected.result; }; const execute = async (): Promise> => { let isDirectory = false; @@ -518,12 +517,12 @@ const runAnalysis = async ( }); if (checkClosure(cached.evidence_links)) { const evidence = withEvidenceCallerInputPath(cached, path); - const sources = captureSources( + const captured = captureSources( evidence, snapshot.evidence_bundle.records, ); - if (!sources.ok) - return err(sources.error.retainPartialObservation(evidence)); + if (!captured.ok) + return err(captured.error.retainPartialObservation(evidence)); options.onEvidenceBundle?.(snapshot.evidence_bundle); return ok(evidence); } @@ -554,20 +553,30 @@ const runAnalysis = async ( evidenceProfile, saveSnapshot: snapshotPath !== undefined, }); - if (!executed.ok) return executed; + if (!executed.ok) { + if (options.includeEvidenceSources) { + const partial = tryParseEvidence(executed.error.partialObservation); + if (partial !== undefined) { + const captured = captureSources(partial); + if (!captured.ok) + executed.error.retainSecondaryFailure(captured.error); + } + } + return executed; + } const { cachePolicy } = executed.value; const evidence = withEvidenceCallerInputPath(executed.value.evidence, path); // Provider execution may have already recorded the same semantic Evidence // without this invocation's request path. Import replaces that metadata // while retaining the authenticated result and snapshot binding. + const captured = captureSources(evidence); + if (!captured.ok) + return err(captured.error.retainPartialObservation(evidence)); const recorded = session.importEvidenceBundle( createImmutableEvidenceBundle([evidence]), ); if (!recorded.ok) return err(recorded.error.retainPartialObservation(evidence)); - const sources = captureSources(evidence); - if (!sources.ok) - return err(sources.error.retainPartialObservation(evidence)); if (isWorkflowEvidenceTool(tool)) { const unknowns = recordWorkflowUnknowns({ name: tool, @@ -649,16 +658,15 @@ const runAnalysis = async ( outcome.cleanupError.retainDiagnostics({ analysis_snapshot: { ...publishedSnapshot }, }); - return err( - directAnalysisCleanupFailure(result, outcome.cleanupError, path), - ); + return { + ...err(directAnalysisCleanupFailure(result, outcome.cleanupError, path)), + ...(sourceEvidence === undefined ? {} : { sourceEvidence }), + }; } - return result.ok - ? { - ...ok(result.value), - ...(sourceEvidence === undefined ? {} : { sourceEvidence }), - } - : err(cliError(result.error, path)); + return { + ...(result.ok ? ok(result.value) : err(cliError(result.error, path))), + ...(sourceEvidence === undefined ? {} : { sourceEvidence }), + }; }; const executeAnalysisTool = async (input: { diff --git a/src/application/investigation/InvestigationEvidence.test.ts b/src/application/investigation/InvestigationEvidence.test.ts index f62926e093..36f6753f88 100644 --- a/src/application/investigation/InvestigationEvidence.test.ts +++ b/src/application/investigation/InvestigationEvidence.test.ts @@ -7,6 +7,7 @@ import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; import { InvestigationRecords } from "./InvestigationRecords.js"; import { createInvestigationEvidenceClosureChecker, + collectInvestigationEvidenceClosure, resolveInvestigationEvidenceClosure, } from "./InvestigationEvidence.js"; @@ -158,3 +159,25 @@ it("reuses a complete shared source graph without weakening missing-source check expect(lookups).toBe(3); expect(check([source.evidence_id, missing])).toBe(false); }); + +it("collects other reachable sources after a missing ancestor", () => { + const missing = `ev_${"0".repeat(64)}`; + const first = observation("/reachable"); + const incomplete = observation("/incomplete", [missing]); + const records = new InvestigationRecords(); + for (const record of [first, incomplete]) { + const added = records.recordEvidence(record); + if (!added.ok) throw added.error; + } + const result = collectInvestigationEvidenceClosure(records, [ + first.evidence_id, + incomplete.evidence_id, + ]); + expect(result.result).toMatchObject({ + ok: false, + error: { reason: "missing" }, + }); + expect(result.records.map((record) => record.evidence_id).sort()).toEqual( + [first.evidence_id, incomplete.evidence_id].sort(), + ); +}); diff --git a/src/application/investigation/InvestigationEvidence.ts b/src/application/investigation/InvestigationEvidence.ts index 819231b530..fc559db82d 100644 --- a/src/application/investigation/InvestigationEvidence.ts +++ b/src/application/investigation/InvestigationEvidence.ts @@ -3,7 +3,7 @@ import type { EvidenceReader } from "./InvestigationRecordPort.js"; import { uniqueSorted } from "../../domain/canonicalOrdering.js"; import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import type { Evidence } from "../../domain/evidence.js"; -import { ok, type Result } from "../../domain/result.js"; +import { err, ok, type Result } from "../../domain/result.js"; type Lookup = (id: string) => Evidence | undefined; @@ -12,8 +12,10 @@ const visitClosure = ( lookup: Lookup, seedIds: readonly string[], knownComplete?: ReadonlySet, + collect?: (record: Evidence) => void, ): Result, EvidenceIntegrityError> => { const visited = new Set(); + let failure: EvidenceIntegrityError | undefined; const pending = [...seedIds]; while (pending.length > 0) { const evidenceId = pending.pop(); @@ -28,12 +30,36 @@ const visitClosure = ( lookup, "retained-closure", ); - if (!evidence.ok) return evidence; + if (!evidence.ok) { + if (collect === undefined) return evidence; + failure ??= evidence.error; + visited.add(evidenceId); + continue; + } visited.add(evidenceId); + collect?.(evidence.value); for (const linkedId of evidence.value.evidence_links) pending.push(linkedId); } - return ok(visited); + return failure === undefined ? ok(visited) : err(failure); +}; + +/** Gather every reachable authenticated record while retaining the first failure. */ +export const collectInvestigationEvidenceClosure = ( + reader: Pick, + seedIds: readonly string[], +): { + readonly records: Evidence[]; + readonly result: Result; +} => { + const records: Evidence[] = []; + const visited = visitClosure( + (id) => reader.evidenceForAnalysis(id), + seedIds, + undefined, + (record) => records.push(record), + ); + return { records, result: visited.ok ? ok(null) : visited }; }; /** Follow exact session references without exporting unrelated records or Unknowns. */ diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 2daac5255b..a4f36e1c36 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -452,7 +452,11 @@ const registerFunctionCommand = ( "analyze_function", ), }, - { ...directAnalysisOptionsFromCli(logger, options), signal }, + { + ...directAnalysisOptionsFromCli(logger, options), + signal, + includeEvidenceSources: options.evidence_output !== undefined, + }, ); return publishCliAnalysisEvidence(complete, delivery); }), diff --git a/tests/boundary/cli/analyzeFunctionDetail.test.ts b/tests/boundary/cli/analyzeFunctionDetail.test.ts index ecc1390e0c..721006b23f 100644 --- a/tests/boundary/cli/analyzeFunctionDetail.test.ts +++ b/tests/boundary/cli/analyzeFunctionDetail.test.ts @@ -89,6 +89,9 @@ it("summary delivery returns the compact view and writes a bundle inspect-analys evidence_bundle: { path: string; bytes: number }; }; expect(h.runDirectAnalysis).toHaveBeenCalledOnce(); + expect(h.runDirectAnalysis.mock.calls[0]?.[3]).toMatchObject({ + includeEvidenceSources: true, + }); expect(out.summary.normalized_result.kind).toBe("summary"); expect(typeof out.summary.normalized_result.parent_evidence_id).toBe( "string", diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 704cbc44fb..5535c59056 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -1,8 +1,10 @@ +import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; +import { publishCliAnalysisEvidence } from "../../../src/cli/analysisEvidenceDelivery.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { readFile, symlink, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; @@ -159,6 +161,7 @@ interface SnapshotProviderOptions { readonly supportedOperations?: readonly CapabilityDescriptor["operation"][]; readonly limitations?: readonly string[]; readonly sourceEvidence?: boolean; + readonly sourceMissingLink?: boolean; } const makeProvider = ( @@ -263,6 +266,9 @@ const makeProvider = ( operation: "health", parameters: {}, result: "fixture source", + ...(options.sourceMissingLink + ? { evidenceLinks: [`ev_${"0".repeat(64)}`] } + : {}), }), ], } @@ -2036,7 +2042,10 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i }); const dependencies = fixture.dependencies(provider); const parameters = { procedure: "0x1000", document: "fixture" }; - const options = { snapshotPath: fixture.snapshotPath }; + const options = { + snapshotPath: fixture.snapshotPath, + includeEvidenceSources: true, + }; const run = () => runDirectAnalysis( dependencies, @@ -2044,8 +2053,12 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i "procedure_pseudo_code", parameters, options, - ).then(projectCliAnalysisResult); - const first = parseEvidence(await run()); + ); + const firstResult = await run(); + const first = parseEvidence(projectCliAnalysisResult(firstResult)); + expect( + firstResult.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual(first.evidence_links); expect(first.evidence_links).toHaveLength(1); const saved = await readAnalysisSnapshot(fixture.snapshotPath); if (!saved.ok) throw saved.error; @@ -2057,9 +2070,30 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i ), ).toBe(true); const initialStarts = fixture.starts.length; - expect(parseEvidence(await run()).evidence_links).toEqual( + const replayed = await run(); + expect( + parseEvidence(projectCliAnalysisResult(replayed)).evidence_links, + ).toEqual(first.evidence_links); + expect(replayed.sourceEvidence?.map((record) => record.evidence_id)).toEqual( first.evidence_links, ); + const outputPath = join( + dirname(fixture.snapshotPath), + "replayed-evidence.json", + ); + await publishCliAnalysisEvidence(replayed, { + providerId: IDENTITY.id, + operation: "procedure_pseudo_code", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: outputPath, + }); + const published = JSON.parse(await readFile(outputPath, "utf8")) as { + records: Evidence[]; + }; + expect(published.records.map((record) => record.evidence_id).sort()).toEqual( + [first.evidence_id, ...first.evidence_links].sort(), + ); expect(fixture.starts).toHaveLength(initialStarts); const withoutSource = { @@ -2074,7 +2108,11 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i writeAnalysisSnapshot(withoutSource, fixture.snapshotPath, true, resources), ); if (!written.ok) throw written.error; - const refreshed = parseEvidence(await run()); + const refreshedResult = await run(); + const refreshed = parseEvidence(projectCliAnalysisResult(refreshedResult)); + expect( + refreshedResult.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual(first.evidence_links); expect(fixture.starts).toHaveLength(initialStarts + 1); expect(refreshed.evidence_links).toEqual(first.evidence_links); const repaired = await readAnalysisSnapshot(fixture.snapshotPath); @@ -2086,6 +2124,146 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i ).toBe(true); }); +it("preserves captured sources when a transitive source is missing", async () => { + const fixture = await createSnapshotReplayFixture( + "rea-direct-partial-source-", + ); + const provider = fixture.createProvider({ + supportedOperations: ["procedure_pseudo_code"], + sourceEvidence: true, + sourceMissingLink: true, + }); + const result = await runDirectAnalysis( + fixture.dependencies(provider), + fixture.path, + "procedure_pseudo_code", + { procedure: "0x1000", document: "fixture" }, + { includeEvidenceSources: true }, + ); + expect(result.ok).toBe(false); + expect(result.sourceEvidence).toHaveLength(1); + const outputPath = join(dirname(fixture.snapshotPath), "missing-source.json"); + const output = await publishCliAnalysisEvidence(result, { + providerId: IDENTITY.id, + operation: "procedure_pseudo_code", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: outputPath, + }); + const failure = output as { + details: { + source_closure: { + records: Evidence[]; + omitted: { evidence_id: string }[]; + complete: boolean; + }; + }; + }; + expect(failure.details.source_closure.complete).toBe(false); + expect( + failure.details.source_closure.records.map((record) => record.evidence_id), + ).toEqual(result.sourceEvidence?.map((record) => record.evidence_id)); + expect(failure.details.source_closure.omitted).toEqual([ + { evidence_id: `ev_${"0".repeat(64)}`, reason: "not_captured" }, + ]); + await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("retains completed function sources when cancellation arrives as execution returns", async () => { + const fixture = await createSnapshotReplayFixture( + "rea-direct-cancel-source-", + ); + const provider = fixture.createProvider({ + supportedOperations: ["analyze_function"], + sourceEvidence: true, + }); + const session = createTestBinarySession(provider); + const execute = session.execute.bind(session); + const controller = new AbortController(); + session.execute = async (...args) => { + const result = await execute(...args); + if (args[0] === "analyze_function") controller.abort(); + return result; + }; + const dependencies = { + ...fixture.dependencies(provider), + createBinarySession: () => session, + }; + const result = await runDirectAnalysis( + dependencies, + fixture.path, + "analyze_function", + { procedure: "0x1000" }, + { signal: controller.signal, includeEvidenceSources: true }, + ); + expect(result.ok).toBe(false); + const outputPath = join(dirname(fixture.snapshotPath), "cancelled.json"); + const output = await publishCliAnalysisEvidence(result, { + providerId: IDENTITY.id, + operation: "analyze_function", + signal: controller.signal, + detail: "complete", + evidenceOutput: outputPath, + }); + const failure = output as { + code: string; + details: { + partial_observation: Evidence; + source_closure: { records: Evidence[]; complete: boolean }; + }; + }; + expect(failure.code).toBe("cancelled"); + expect(failure.details.source_closure.complete).toBe(true); + expect( + failure.details.source_closure.records.map((record) => record.evidence_id), + ).toEqual(failure.details.partial_observation.evidence_links); + await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("retains function sources when completed parent admission fails", async () => { + const fixture = await createSnapshotReplayFixture( + "rea-direct-parent-admission-", + ); + const provider = fixture.createProvider({ + supportedOperations: ["analyze_function"], + sourceEvidence: true, + }); + const session = createTestBinarySession(provider); + session.importEvidenceBundle = () => + err(new EvidenceIntegrityError("controlled parent admission failure")); + const dependencies = { + ...fixture.dependencies(provider), + createBinarySession: () => session, + }; + const result = await runDirectAnalysis( + dependencies, + fixture.path, + "analyze_function", + { procedure: "0x1000" }, + { includeEvidenceSources: true }, + ); + expect(result.ok).toBe(false); + const outputPath = join(dirname(fixture.snapshotPath), "refused.json"); + const output = await publishCliAnalysisEvidence(result, { + providerId: IDENTITY.id, + operation: "analyze_function", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: outputPath, + }); + const failure = output as { + details: { + partial_observation: Evidence; + source_closure: { records: Evidence[]; complete: boolean }; + }; + }; + expect(failure.details.source_closure.complete).toBe(true); + expect( + failure.details.source_closure.records.map((record) => record.evidence_id), + ).toEqual(failure.details.partial_observation.evidence_links); + await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" }); +}); + it("runs decompile export against the provider after loading a pseudocode snapshot", async () => { const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-"); const original = fixture.createProvider({ diff --git a/tests/boundary/filesystem/undeliveredSourceClosure.test.ts b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts index 48dd7928a0..ac947c63aa 100644 --- a/tests/boundary/filesystem/undeliveredSourceClosure.test.ts +++ b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts @@ -592,6 +592,63 @@ it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)( }, ); +it.each(["interrupt", "cleanup-failure"] as const)( + "retains captured source closure when direct finalization fails: %s", + async (scenario) => { + const workflow = await composedWorkflowFixture(); + const controller = new AbortController(); + const close = workflow.session.close.bind(workflow.session); + workflow.session.close = async () => { + workflow.session.close = close; + if (scenario === "interrupt") controller.abort(); + const closed = await close(); + if (scenario === "interrupt" || !closed.ok) return closed; + return err( + new ProviderCleanupError( + "fixture", + ["owned-root"], + { reason: "controlled session close failure" }, + { operation: "close_binary" }, + ), + ); + }; + const root = await createTestTempDirectory("rea-closure-direct-captured-"); + const complete = await runDirectAnalysis( + workflow.dependencies, + workflow.path, + "binary_overview", + {}, + { signal: controller.signal, includeEvidenceSources: true }, + ); + const output = await publishCliAnalysisEvidence(complete, { + ...delivery(root), + signal: controller.signal, + }); + const failure = output as { + code: string; + details: { partial_observation: unknown }; + }; + expect(failure.code).toBe( + scenario === "interrupt" ? "cancelled" : "cleanup_incomplete", + ); + const links = parseEvidence( + failure.details.partial_observation, + ).evidence_links; + expect(links.length).toBeGreaterThan(0); + expect(closureOf(output)).toMatchObject({ + publication: "not_published", + complete: true, + omitted_count: 0, + }); + expect( + closureOf(output) + .records.map((record) => record.evidence_id) + .sort(), + ).toEqual([...links].sort()); + expect(await readdir(root)).toEqual([]); + }, +); + it("reports through the shared one-shot delivery when cancellation arrives during close", async () => { const root = await createTestTempDirectory("rea-closure-deliver-"); const held = source("held"); diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index 580dc41820..774f37aab2 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -3,6 +3,12 @@ These source-owned fixtures are compiled at verification time; generated binaries and Ghidra projects are never committed. +For the one-shot function bundle handoff, run `npm run build:cached` and then +`node scripts/verify-real-ghidra-function-bundle.mjs` with `GHIDRA_INSTALL_DIR` +and `JAVA_HOME` set. The verifier compiles its own target, creates a project +with a synthetic history report, and exercises complete, summary, and +no-overwrite CLI delivery. It checks the target and project files for changes. + - `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images with double-float ABI declarations and an independently known instruction. `../c/riscv-float-abi.c`, `riscv-probe.S` and `riscv-isa-probe.S` produce