diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 434d822309..4d1090ea00 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -145,6 +145,13 @@ New attempts wait for that cleanup; failed cleanup retains its original error and owned directories until a successful close. Closing the provider reports a provider-state failure to live callers rather than cancelling their signals. +Configured analysis extensions and seeds use owned file reads during profile +resolution and launch snapshots. A changed read extent or descriptor state +refuses the commitment. Failed source or snapshot descriptor closes remain +owned and block new admission until cleanup succeeds; the physical failure +and exact cancellation reason remain available. Provider shutdown joins all +profile stages before retrying their retained descriptors. + | Concern | Ghidra contract | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Decompiler lifetime | One persistent `DecompInterface` is opened for the imported Program and disposed during bridge shutdown. Native decompilation has no fixed per-function deadline. External functions or functions without bodies return `null`; cancellation and native failure remain distinct. | diff --git a/src/ghidra/GhidraAnalysisSeeds.test.ts b/src/ghidra/GhidraAnalysisSeeds.test.ts index 7693573732..226f69ca2c 100644 --- a/src/ghidra/GhidraAnalysisSeeds.test.ts +++ b/src/ghidra/GhidraAnalysisSeeds.test.ts @@ -1,3 +1,4 @@ +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { createHash } from "node:crypto"; import { mkdtemp, @@ -27,7 +28,10 @@ import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixt import { GhidraProvider } from "./GhidraProvider.js"; const directories: string[] = []; +let resources = new ArtifactResourceScope(); afterEach(async () => { + await resources.close(); + resources = new ArtifactResourceScope(); for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); }); @@ -92,7 +96,7 @@ describe("Ghidra seed commitment", () => { const directory = await workspace(); const path = join(directory, "seeds.tsv"); await writeFile(path, SEEDS); - const resolved = await resolveGhidraAnalysisSeeds(path); + const resolved = await resolveGhidraAnalysisSeeds(path, resources); if (!resolved.ok) throw resolved.error; const commitment = resolved.value; if (commitment === undefined) throw new Error("Expected a commitment"); @@ -103,12 +107,16 @@ describe("Ghidra seed commitment", () => { entries: { function: 2, code: 1, label: 1 }, }); const runtime = await workspace(); - const snapshot = await snapshotGhidraAnalysisSeeds(commitment, runtime); + const snapshot = await snapshotGhidraAnalysisSeeds( + commitment, + runtime, + resources, + ); expect(await readFile(snapshot, "utf8")).toBe(CANONICAL); }); it("is absent when no seed file is configured", async () => { - const resolved = await resolveGhidraAnalysisSeeds(undefined); + const resolved = await resolveGhidraAnalysisSeeds(undefined, resources); expect(resolved.ok && resolved.value).toBeUndefined(); }); @@ -116,13 +124,20 @@ describe("Ghidra seed commitment", () => { const directory = await workspace(); const path = join(directory, "seeds.tsv"); await writeFile(path, SEEDS); - const resolved = await resolveGhidraAnalysisSeeds(path); + const resolved = await resolveGhidraAnalysisSeeds(path, resources); if (!resolved.ok || resolved.value === undefined) throw new Error("Expected a commitment"); await writeFile(path, `${SEEDS}0x401100\tcode\n`); await expect( - snapshotGhidraAnalysisSeeds(resolved.value, await workspace()), - ).rejects.toThrow(/changed after the analysis profile was committed/u); + snapshotGhidraAnalysisSeeds(resolved.value, await workspace(), resources), + ).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { + reason: expect.stringMatching( + /changed after the analysis profile was committed/u, + ), + }, + }); }); it("commits the resolved path behind a configured symbolic link", async () => { @@ -131,7 +146,7 @@ describe("Ghidra seed commitment", () => { await writeFile(path, SEEDS); const link = join(directory, "link.tsv"); await symlink(path, link); - const resolved = await resolveGhidraAnalysisSeeds(link); + const resolved = await resolveGhidraAnalysisSeeds(link, resources); if (!resolved.ok || resolved.value === undefined) throw new Error("Expected a commitment"); expect(resolved.value.path).toBe(await realpath(path)); @@ -140,6 +155,7 @@ describe("Ghidra seed commitment", () => { it("reports an unreadable seed file as a provider error", async () => { const resolved = await resolveGhidraAnalysisSeeds( join(await workspace(), "absent.tsv"), + resources, ); if (resolved.ok) throw new Error("Expected an error"); expect(projectAnalysisError(resolved.error)).toMatchObject({ diff --git a/src/ghidra/GhidraAnalysisSeeds.ts b/src/ghidra/GhidraAnalysisSeeds.ts index e6621c459e..5dc6ba2d66 100644 --- a/src/ghidra/GhidraAnalysisSeeds.ts +++ b/src/ghidra/GhidraAnalysisSeeds.ts @@ -1,12 +1,18 @@ import { createHash } from "node:crypto"; -import { constants } from "node:fs"; -import { mkdir, open, realpath, writeFile } from "node:fs/promises"; +import { mkdir, realpath } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { z } from "zod"; import type { AnalysisOperation } from "../application/AnalysisProvider.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + readGhidraProfileArtifact, + writeGhidraProfileArtifact, + ghidraProfileArtifactError, +} from "./GhidraProfileArtifacts.js"; import { err, ok, type Result } from "../domain/result.js"; /** Seed file format admitted by this adapter. */ @@ -131,114 +137,106 @@ const normalizedSeeds = (entries: readonly SeedEntry[]): string => ) .join(""); -/** Read a bounded regular file through one handle without following links. */ -const readSeedFile = async (path: string): Promise => { - const file = await open( - path, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, - ); - try { - const stat = await file.stat(); - if (!stat.isFile() || stat.size > MAX_GHIDRA_SEED_BYTES) - throw new Error( - `Ghidra seed file must be a regular file of at most ${MAX_GHIDRA_SEED_BYTES} bytes: ${path}`, - ); - const bytes = Buffer.alloc(stat.size); - let offset = 0; - while (offset < bytes.length) { - const read = await file.read( - bytes, - offset, - bytes.length - offset, - offset, - ); - if (read.bytesRead === 0) - throw new Error(`Incomplete Ghidra seed file read: ${path}`); - offset += read.bytesRead; - } - return bytes; - } finally { - await file.close(); - } -}; - const sha256 = (bytes: Buffer | string): string => createHash("sha256").update(bytes).digest("hex"); -const decodeSeeds = (bytes: Buffer) => { - const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - return parseGhidraSeeds(text); -}; +/** Parse seed facts before final close so later cleanup cannot replace invalid input. */ +const readSeeds = ( + path: string, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => + readGhidraProfileArtifact( + path, + MAX_GHIDRA_SEED_BYTES, + resources, + signal, + (bytes) => { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw seedLineError(0, "file is not valid UTF-8"); + } + const parsed = parseGhidraSeeds(text); + if (!parsed.ok) throw parsed.error; + return parsed.value; + }, + ); /** Resolve the configured seed file into an immutable profile commitment. */ export const resolveGhidraAnalysisSeeds = async ( configured: string | undefined, -): Promise< - Result< - GhidraSeedCommitment | undefined, - ConfigurationError | ProviderAdapterError - > -> => { - if (configured === undefined) return ok(undefined); - let bytes: Buffer; - let path: string; - try { - path = await realpath(configured); - bytes = await readSeedFile(path); - } catch (cause: unknown) { - return err( - new ProviderAdapterError("ghidra", "resolve_analysis_profile", { - cause, - diagnostics: { + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise> => + resources + .run(async () => { + if (configured === undefined) return ok(undefined); + let entries: readonly SeedEntry[]; + let path: string; + try { + path = await realpath(configured); + entries = await readSeeds(path, resources, signal); + } catch (cause: unknown) { + return err( + ghidraProfileArtifactError(cause, "resolve_analysis_profile", { + setting: "REA_GHIDRA_SEED_FILE", + path: configured, + }), + ); + } + const normalized = normalizedSeeds(entries); + const tally = { function: 0, code: 0, label: 0 }; + for (const entry of entries) tally[entry.kind] += 1; + return ok({ + format: GHIDRA_SEED_FORMAT, + path, + configured_path: configured, + sha256: sha256(normalized), + entries: tally, + }); + }) + .catch((cause: unknown) => + err( + ghidraProfileArtifactError(cause, "resolve_analysis_profile", { setting: "REA_GHIDRA_SEED_FILE", - path: configured, - reason: cause instanceof Error ? cause.message : String(cause), - }, - }), + }), + ), ); - } - let parsed: ReturnType; - try { - parsed = decodeSeeds(bytes); - } catch { - return err(seedLineError(0, "file is not valid UTF-8")); - } - if (!parsed.ok) return parsed; - const normalized = normalizedSeeds(parsed.value); - const tally = { function: 0, code: 0, label: 0 }; - for (const entry of parsed.value) tally[entry.kind] += 1; - return ok({ - format: GHIDRA_SEED_FORMAT, - path, - configured_path: configured, - sha256: sha256(normalized), - entries: tally, - }); -}; /** Re-read the committed seeds and write the canonical copy the script applies. */ export const snapshotGhidraAnalysisSeeds = async ( commitment: GhidraSeedCommitment, runtimeRoot: string, -): Promise => { - ghidraSeedCommitmentSchema.parse(commitment); - if ((await realpath(commitment.configured_path)) !== commitment.path) - throw new Error( - "Ghidra seed file resolved path differs from explicit configuration", - ); - const parsed = decodeSeeds(await readSeedFile(commitment.path)); - if (!parsed.ok) throw parsed.error; - const normalized = normalizedSeeds(parsed.value); - if (sha256(normalized) !== commitment.sha256) - throw new Error( - `Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`, - ); - const directory = join(runtimeRoot, "seeds"); - await mkdir(directory, { mode: 0o700 }); - const path = join(directory, "seeds.tsv"); - await writeFile(path, normalized, { flag: "wx", mode: 0o600 }); - return path; -}; + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => + resources + .run(async () => { + ghidraSeedCommitmentSchema.parse(commitment); + if ((await realpath(commitment.configured_path)) !== commitment.path) + throw new Error( + "Ghidra seed file resolved path differs from explicit configuration", + ); + const entries = await readSeeds(commitment.path, resources, signal); + const normalized = normalizedSeeds(entries); + if (sha256(normalized) !== commitment.sha256) + throw new Error( + `Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`, + ); + const directory = join(runtimeRoot, "seeds"); + await mkdir(directory, { mode: 0o700 }); + const path = join(directory, "seeds.tsv"); + await writeGhidraProfileArtifact(path, normalized, resources, signal); + return path; + }) + .catch((cause: unknown) => { + throw ghidraProfileArtifactError(cause, "open_binary", { + setting: "REA_GHIDRA_SEED_FILE", + path: commitment.path, + }); + }); const totalEntries = (commitment: GhidraSeedCommitment): number => commitment.entries.function + diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 7167f3bd80..186a7e4755 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -309,17 +309,30 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, this.options.configurationDirectoryName, ); - const seedPath = - this.options.analysisSeeds === undefined - ? undefined - : await snapshotGhidraAnalysisSeeds( - this.options.analysisSeeds, - session.runtimeRoot, - ); - const extensions = await snapshotGhidraExtensions( - this.options.analysisExtensions ?? [], - session.runtimeRoot, - ); + let seedPath: string | undefined; + let extensions: readonly GhidraExtension[] = []; + if ( + this.options.analysisSeeds !== undefined || + (this.options.analysisExtensions?.length ?? 0) > 0 + ) { + if (options.resourceScope === undefined) + throw new GhidraLaunchError( + "Configured Ghidra artifacts require a caller-owned resource scope.", + ); + if (this.options.analysisSeeds !== undefined) + seedPath = await snapshotGhidraAnalysisSeeds( + this.options.analysisSeeds, + session.runtimeRoot, + options.resourceScope, + options.signal, + ); + extensions = await snapshotGhidraExtensions( + this.options.analysisExtensions ?? [], + session.runtimeRoot, + options.resourceScope, + options.signal, + ); + } await writeGhidraRuntimeFile( paths.descriptorPath, `${JSON.stringify({ diff --git a/src/ghidra/GhidraProfileArtifacts.ts b/src/ghidra/GhidraProfileArtifacts.ts new file mode 100644 index 0000000000..3527992eda --- /dev/null +++ b/src/ghidra/GhidraProfileArtifacts.ts @@ -0,0 +1,120 @@ +import { lstat, open } from "node:fs/promises"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + assertArtifactReadActive, + artifactFileCancellationCause, + artifactFilePrimaryFailure, + bindArtifactFileCleanup, + withArtifactFileHandle, + withArtifactRegularFile, +} from "../artifacts/ArtifactFileOperation.js"; +import { sameRegularFileState } from "../filesystem/RegularFile.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +/** Bounded serial read; the caller owns admission and final descriptor cleanup. */ +export const readGhidraProfileArtifact = ( + path: string, + maximumBytes: number, + resources: ArtifactResourceScope, + signal: AbortSignal | undefined, + interpret: (bytes: Buffer) => Value, +): Promise => + withArtifactRegularFile(path, resources, signal, async (file) => { + const before = await file.stat({ bigint: true }); + if (before.size > BigInt(maximumBytes)) + throw new Error( + `Ghidra profile artifact must be at most ${String(maximumBytes)} bytes: ${path}`, + ); + const bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.length) { + assertArtifactReadActive(signal); + const read = await file.read( + bytes, + offset, + bytes.length - offset, + offset, + ); + if (read.bytesRead === 0) + throw new Error(`Incomplete Ghidra profile artifact read: ${path}`); + offset += read.bytesRead; + } + assertArtifactReadActive(signal); + const extra = Buffer.alloc(1); + if ( + (await file.read(extra, 0, 1, offset)).bytesRead !== 0 || + !sameRegularFileState(before, await file.stat({ bigint: true })) || + !sameRegularFileState(before, await lstat(path, { bigint: true })) + ) + throw new Error(`Ghidra profile artifact changed during read: ${path}`); + assertArtifactReadActive(signal); + return interpret(bytes); + }); + +/** Own the new private snapshot descriptor through write and final close. */ +export const writeGhidraProfileArtifact = async ( + path: string, + bytes: Buffer | string, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const file = await open(path, "wx", 0o600); + await withArtifactFileHandle(path, file, resources, signal, (handle) => + handle.writeFile(bytes), + ); +}; + +/** Preserve physical failure, exact cancellation, and all retained file leases. */ +export const ghidraProfileArtifactError = ( + cause: unknown, + operation: string, + diagnostics: Readonly> = {}, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + const primary = artifactFilePrimaryFailure(cause); + const reason = primary instanceof Error ? primary.message : String(primary); + const cleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; + if (primary instanceof AnalysisError) { + if (cleanup === undefined) return primary; + const failure = new ProviderCleanupError( + "ghidra", + cleanup.resources, + { + reason: cleanup.reason, + }, + { cause }, + ); + bindArtifactFileCleanup(cause, failure); + return analysisErrorWithCleanupFailure( + primary, + failure, + operation, + ).bindCleanupDependencies([ + ...(primary.cleanupIncomplete ? [primary] : []), + failure, + ]); + } + const error = + cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" + ? new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + cleanup, + diagnostics, + }) + : new ProviderAdapterError("ghidra", operation, { + cause, + cleanup, + diagnostics: { ...diagnostics, reason }, + userMessage: reason, + }); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index f8dd4f9001..4c447b818d 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -6,6 +6,7 @@ import type { AnalysisClient, AnalysisClientContext, AnalysisProfileResolutionOptions, + AnalysisProfileResolution, AnalysisProviderCandidate, AnalysisProviderReadinessSnapshot, CapabilityDescriptor, @@ -27,7 +28,8 @@ import { jsonValueSchema, type JsonValue, } from "../domain/jsonValue.js"; -import { err, ok } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { Logger } from "pino"; @@ -82,6 +84,10 @@ const SUPPORTED_ARCHITECTURES = new Set([ /** Ghidra candidate backed by an isolated ephemeral headless import. */ export class GhidraProvider implements AnalysisProviderCandidate { readonly #inspectionResources = new ArtifactResourceScope(); + readonly #profileWork = new Set< + Promise> + >(); + #closing: Promise> | undefined; #installation: GhidraInstallationInspection | undefined; private readonly environment: NodeJS.ProcessEnv; @@ -228,10 +234,22 @@ export class GhidraProvider implements AnalysisProviderCandidate { } hasPendingOwnedResources(): boolean { - return this.#inspectionResources.hasPending; + return this.#profileWork.size > 0 || this.#inspectionResources.hasPending; } - async closeOwnedResources() { + closeOwnedResources() { + this.#closing ??= Promise.resolve() + .then(async () => { + await Promise.allSettled(this.#profileWork); + return this.#closeInspectionResources(); + }) + .finally(() => { + this.#closing = undefined; + }); + return this.#closing; + } + + async #closeInspectionResources() { try { await this.#inspectionResources.retryCleanup(); return ok(null); @@ -252,6 +270,25 @@ export class GhidraProvider implements AnalysisProviderCandidate { async resolveAnalysisProfile( target: BinaryTarget, options?: AnalysisProfileResolutionOptions, + ): Promise> { + if (this.#closing !== undefined) { + const closed = await this.#closing; + if (!closed.ok) return closed; + } + const work = Promise.resolve().then(() => + this.#resolveProfile(target, options), + ); + this.#profileWork.add(work); + try { + return await work; + } finally { + this.#profileWork.delete(work); + } + } + + async #resolveProfile( + target: BinaryTarget, + options?: AnalysisProfileResolutionOptions, ) { const installation = this.#inspectInstallation(); const resolved = await resolveGhidraAnalysisProfile( @@ -307,12 +344,21 @@ export class GhidraProvider implements AnalysisProviderCandidate { this.config, target, installation.platform, + this.#inspectionResources, options?.signal, ); if (!extensions.ok) return extensions; if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError("open_binary")); - const seeds = await resolveGhidraAnalysisSeeds(this.config.ghidraSeedFile); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal.reason, + }), + ); + const seeds = await resolveGhidraAnalysisSeeds( + this.config.ghidraSeedFile, + this.#inspectionResources, + options?.signal, + ); if (!seeds.ok) return seeds; if (extensions.value.length === 0 && seeds.value === undefined) return resolved; diff --git a/src/ghidra/extensions/GhidraExtensions.test.ts b/src/ghidra/extensions/GhidraExtensions.test.ts index 1fbe884cfb..61edea4beb 100644 --- a/src/ghidra/extensions/GhidraExtensions.test.ts +++ b/src/ghidra/extensions/GhidraExtensions.test.ts @@ -1,3 +1,4 @@ +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { execFile } from "node:child_process"; import { constants } from "node:fs"; import { promisify } from "node:util"; @@ -19,7 +20,10 @@ import { } from "./GhidraExtensions.js"; const roots: string[] = []; +let resources = new ArtifactResourceScope(); afterEach(async () => { + await resources.close(); + resources = new ArtifactResourceScope(); await Promise.all( roots.splice(0).map((path) => rm(path, { recursive: true, force: true })), ); @@ -49,7 +53,12 @@ const artifact = async () => { const path = join(directory, "fixture.jar"); const bytes = Buffer.from([0x50, 0x4b, 3, 4, 0, 1, 2, 3]); await writeFile(path, bytes); - const resolved = await resolveGhidraExtensions(config(path), target, "linux"); + const resolved = await resolveGhidraExtensions( + config(path), + target, + "linux", + resources, + ); if (!resolved.ok || resolved.value[0] === undefined) throw new Error("expected fixture artifact"); return { extension: resolved.value[0], bytes, path }; @@ -77,6 +86,7 @@ describe("optional Ghidra extension refusals", () => { config("/absent.jar"), target, "darwin", + resources, ); expect(host.ok).toBe(false); if (!host.ok) @@ -101,6 +111,7 @@ describe("optional Ghidra extension refusals", () => { availableArchitectures: ["arm64"], }, "linux", + resources, ); expect(architecture.ok).toBe(false); if (!architecture.ok) @@ -120,7 +131,9 @@ describe("optional Ghidra extension refusals", () => { describe("optional Ghidra extension boundary", () => { it("keeps ordinary profiles free of unconfigured extensions", async () => { - expect(await resolveGhidraExtensions(config(), target, "linux")).toEqual({ + expect( + await resolveGhidraExtensions(config(), target, "linux", resources), + ).toEqual({ ok: true, value: [], }); @@ -142,21 +155,31 @@ describe("optional Ghidra extension boundary", () => { it("snapshots identical bytes exclusively into the private runtime", async () => { const { extension, bytes } = await artifact(); const runtime = await root(); - const snapshots = await snapshotGhidraExtensions([extension], runtime); + const snapshots = await snapshotGhidraExtensions( + [extension], + runtime, + resources, + ); expect(snapshots[0]?.path).toBe(join(runtime, "extensions/nativeaot.jar")); expect(await readFile(join(runtime, "extensions/nativeaot.jar"))).toEqual( bytes, ); await expect( - snapshotGhidraExtensions([extension], runtime), - ).rejects.toMatchObject({ code: "EEXIST" }); + snapshotGhidraExtensions([extension], runtime, resources), + ).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + cause: { code: "EEXIST" }, + }); }); it("rejects artifact changes after the profile commitment", async () => { const { extension, path } = await artifact(); await writeFile(path, Buffer.from([0x50, 0x4b, 3, 4, 9, 9, 9, 9])); await expect( - snapshotGhidraExtensions([extension], await root()), - ).rejects.toThrow("digest differs"); + snapshotGhidraExtensions([extension], await root(), resources), + ).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { reason: expect.stringContaining("digest differs") }, + }); }); it("rejects nonexistent, empty and non-JAR artifacts with useful reasons", async () => { const directory = await root(); @@ -167,6 +190,7 @@ describe("optional Ghidra extension boundary", () => { config(path), target, "linux", + resources, ); expect(resolved.ok).toBe(false); if (!resolved.ok) @@ -179,6 +203,7 @@ describe("optional Ghidra extension boundary", () => { config(absentPath), target, "linux", + resources, ); expect(absent.ok).toBe(false); if (!absent.ok) @@ -212,11 +237,12 @@ describe("optional Ghidra extension boundary", () => { config(path), target, "linux", + resources, ); expect(resolved.ok).toBe(false); if (!resolved.ok) expect(resolved.error).toMatchObject({ - diagnostics: { reason: expect.stringContaining("regular JAR") }, + diagnostics: { reason: expect.stringContaining("regular file") }, }); expect(writerRequired).toBe(false); } finally { @@ -232,6 +258,7 @@ describe("optional Ghidra extension boundary", () => { config("/absent.jar"), target, "linux", + resources, controller.signal, ); expect(resolved.ok).toBe(false); @@ -332,6 +359,7 @@ describe("Ghidra extension profile and producer validation", () => { config("/absent.jar"), { ...target, format: "pe", managed: true, executableRole: "application" }, "linux", + resources, ); expect(resolved.ok).toBe(false); if (!resolved.ok) @@ -349,15 +377,26 @@ describe("Ghidra extension profile and producer validation", () => { snapshotGhidraExtensions( [{ ...second.extension, configured_path: first.path }], await root(), + resources, ), - ).rejects.toThrow("resolved path differs"); + ).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { reason: expect.stringContaining("resolved path differs") }, + }); }); it("rejects unregistered descriptors before snapshot filesystem writes", async () => { const { extension } = await artifact(); const runtime = await root(); await expect( - snapshotGhidraExtensions([{ ...extension, id: "unknown" }], runtime), - ).rejects.toThrow("Unregistered"); + snapshotGhidraExtensions( + [{ ...extension, id: "unknown" }], + runtime, + resources, + ), + ).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { reason: expect.stringContaining("Unregistered") }, + }); }); }); diff --git a/src/ghidra/extensions/GhidraExtensions.ts b/src/ghidra/extensions/GhidraExtensions.ts index 11740a75fd..8d188ec723 100644 --- a/src/ghidra/extensions/GhidraExtensions.ts +++ b/src/ghidra/extensions/GhidraExtensions.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; -import { constants } from "node:fs"; -import { open, realpath, writeFile, mkdir } from "node:fs/promises"; +import { realpath, mkdir } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { z } from "zod"; import type { AppConfig } from "../../config/types.js"; @@ -10,7 +9,13 @@ import { AnalysisCapabilityUnavailableError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; -import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { + readGhidraProfileArtifact, + writeGhidraProfileArtifact, + ghidraProfileArtifactError, +} from "../GhidraProfileArtifacts.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { jsonObjectSchema } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; @@ -68,86 +73,74 @@ export interface GhidraExtensionAdapter { const adapters: readonly GhidraExtensionAdapter[] = [nativeAotAdapter]; const MAX_EXTENSION_BYTES = 8 * 1024 * 1024; -/** Read a bounded regular artifact through its open handle, without ambient state changes. */ -const readJar = async (path: string): Promise => { - const file = await open( +/** JAR framing belongs to the extension adapter; file lifecycle is shared. */ +const readJar = ( + path: string, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => + readGhidraProfileArtifact( path, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + MAX_EXTENSION_BYTES, + resources, + signal, + (bytes) => { + if (bytes.length < 4 || bytes.readUInt32LE(0) !== 0x04034b50) + throw new Error(`Ghidra extension is not a JAR: ${path}`); + return bytes; + }, ); - try { - const stat = await file.stat(); - if (!stat.isFile() || stat.size < 4 || stat.size > MAX_EXTENSION_BYTES) - throw new Error( - `Ghidra extension must be a regular JAR of 4..${MAX_EXTENSION_BYTES} bytes: ${path}`, - ); - const bytes = Buffer.alloc(stat.size); - let offset = 0; - while (offset < bytes.length) { - const read = await file.read( - bytes, - offset, - bytes.length - offset, - offset, - ); - if (read.bytesRead === 0) - throw new Error(`Incomplete Ghidra extension read: ${path}`); - offset += read.bytesRead; - } - const extra = Buffer.alloc(1); - if ( - (await file.read(extra, 0, 1, offset)).bytesRead !== 0 || - bytes.readUInt32LE(0) !== 0x04034b50 - ) - throw new Error( - `Ghidra extension changed during read or is not a JAR: ${path}`, - ); - return bytes; - } finally { - await file.close(); - } -}; /** Resolve explicitly configured optional extensions into the immutable analysis profile. */ export const resolveGhidraExtensions = async ( config: AppConfig, target: BinaryTarget, platform: NodeJS.Platform, + resources: ArtifactResourceScope, signal?: AbortSignal, -): Promise> => { - const extensions: GhidraExtension[] = []; - for (const adapter of adapters) { - if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - const configured = adapter.configuredPath(config); - if (configured === undefined) continue; - const refusal = adapter.unsupportedReason(target, platform); - if (refusal !== null) return err(extensionRefusalError(refusal, target)); - try { - const path = await realpath(configured); - const bytes = await readJar(path); - extensions.push({ - id: adapter.id, - path, - configured_path: configured, - sha256: createHash("sha256").update(bytes).digest("hex"), - entry_class: adapter.entryClass, - integration_api: 1, - }); - } catch (cause: unknown) { - return err( - new ProviderAdapterError("ghidra", "resolve_analysis_profile", { - cause, - diagnostics: { - extension: adapter.id, - path: configured, - reason: cause instanceof Error ? cause.message : String(cause), - }, - }), - ); - } - } - if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - return ok(extensions); -}; +): Promise> => + resources + .run(async () => { + const extensions: GhidraExtension[] = []; + for (const adapter of adapters) { + if (signal?.aborted) + return err( + new AnalysisCancelledError("open_binary", { cause: signal.reason }), + ); + const configured = adapter.configuredPath(config); + if (configured === undefined) continue; + const refusal = adapter.unsupportedReason(target, platform); + if (refusal !== null) + return err(extensionRefusalError(refusal, target)); + try { + const path = await realpath(configured); + const bytes = await readJar(path, resources, signal); + extensions.push({ + id: adapter.id, + path, + configured_path: configured, + sha256: createHash("sha256").update(bytes).digest("hex"), + entry_class: adapter.entryClass, + integration_api: 1, + }); + } catch (cause: unknown) { + return err( + ghidraProfileArtifactError(cause, "resolve_analysis_profile", { + extension: adapter.id, + path: configured, + }), + ); + } + } + if (signal?.aborted) + return err( + new AnalysisCancelledError("open_binary", { cause: signal.reason }), + ); + return ok(extensions); + }) + .catch((cause: unknown) => + err(ghidraProfileArtifactError(cause, "resolve_analysis_profile")), + ); /** Require explicit local configuration before admitting executable code from a profile. */ export const validateGhidraExtensionProfile = ( @@ -206,35 +199,48 @@ const extensionRefusalError = ( export const snapshotGhidraExtensions = async ( extensions: readonly GhidraExtension[], runtimeRoot: string, -): Promise => { - if (extensions.length === 0) return []; - for (const extension of extensions) { - ghidraExtensionSchema.parse(extension); - const adapter = adapters.find((value) => value.id === extension.id); - if (adapter === undefined || adapter.entryClass !== extension.entry_class) - throw new Error( - `Unregistered Ghidra extension identity: ${extension.id}`, - ); - if ((await realpath(extension.configured_path)) !== extension.path) - throw new Error( - `Ghidra extension resolved path differs from explicit configuration: ${extension.id}`, - ); - } - const directory = join(runtimeRoot, "extensions"); - await mkdir(directory, { mode: 0o700 }); - const result: GhidraExtension[] = []; - for (const extension of extensions) { - const bytes = await readJar(extension.path); - if (createHash("sha256").update(bytes).digest("hex") !== extension.sha256) - throw new Error( - `Ghidra extension digest differs from committed profile: ${extension.id} (${extension.path})`, - ); - const path = join(directory, `${extension.id}.jar`); - await writeFile(path, bytes, { flag: "wx", mode: 0o600 }); - result.push({ ...extension, path }); - } - return result; -}; + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => + resources + .run(async () => { + assertArtifactReadActive(signal); + if (extensions.length === 0) return []; + for (const extension of extensions) { + ghidraExtensionSchema.parse(extension); + const adapter = adapters.find((value) => value.id === extension.id); + if ( + adapter === undefined || + adapter.entryClass !== extension.entry_class + ) + throw new Error( + `Unregistered Ghidra extension identity: ${extension.id}`, + ); + if ((await realpath(extension.configured_path)) !== extension.path) + throw new Error( + `Ghidra extension resolved path differs from explicit configuration: ${extension.id}`, + ); + } + const directory = join(runtimeRoot, "extensions"); + await mkdir(directory, { mode: 0o700 }); + const result: GhidraExtension[] = []; + for (const extension of extensions) { + const bytes = await readJar(extension.path, resources, signal); + if ( + createHash("sha256").update(bytes).digest("hex") !== extension.sha256 + ) + throw new Error( + `Ghidra extension digest differs from committed profile: ${extension.id} (${extension.path})`, + ); + const path = join(directory, `${extension.id}.jar`); + await writeGhidraProfileArtifact(path, bytes, resources, signal); + result.push({ ...extension, path }); + } + return result; + }) + .catch((cause: unknown) => { + throw ghidraProfileArtifactError(cause, "open_binary"); + }); /** Require every configured producer result to match its artifact and registered ABI. */ export const validateGhidraExtensionResults = ( diff --git a/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts new file mode 100644 index 0000000000..4da6e57731 --- /dev/null +++ b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts @@ -0,0 +1,565 @@ +import * as extensionModule from "../../../src/ghidra/extensions/GhidraExtensions.js"; +import { createHash } from "node:crypto"; +import { basename } from "node:path"; +import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; +import { GhidraHeadlessLauncher } from "../../../src/ghidra/GhidraLauncher.js"; +import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; +import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createDeferred } from "../../fixtures/binarySession.js"; +import { readFile, rename, writeFile, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { + resolveGhidraAnalysisSeeds, + snapshotGhidraAnalysisSeeds, +} from "../../../src/ghidra/GhidraAnalysisSeeds.js"; +import { + resolveGhidraExtensions, + snapshotGhidraExtensions, +} from "../../../src/ghidra/extensions/GhidraExtensions.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const controls = vi.hoisted(() => ({ + realpath: undefined as ((path: string) => Promise) | undefined, + file: undefined as ((handle: FileHandle, path: string) => void) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpath: async (...args: Parameters) => { + await controls.realpath?.(String(args[0])); + return actual.realpath(...args); + }, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + controls.file?.(handle, String(args[0])); + return handle; + }, + }; +}); +afterEach(() => { + controls.file = undefined; + controls.realpath = undefined; + vi.restoreAllMocks(); +}); + +const target = { + path: "/tmp/native", + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + sha256: "a".repeat(64), +} as const; +const configured = ( + path: string, + extra: Readonly> = {}, +) => { + const result = parseConfig({ REA_GHIDRA_NATIVEAOT_JAR: path, ...extra }); + if (!result.ok) throw result.error; + return result.value; +}; + +it.each( + (["seed", "extension"] as const).flatMap((kind) => + [false, true].map((lateAbort) => ({ kind, lateAbort })), + ), +)( + "retains $kind read failure and exact close owner through retry (late abort: $lateAbort)", + async ({ kind, lateAbort }) => { + const root = await createTestTempDirectory("rea-profile-file-"); + const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar"); + await writeFile( + path, + kind === "seed" ? "0x1000\tcode\n" : Buffer.from([0x50, 0x4b, 3, 4, 0]), + ); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const physical = new Error("controlled profile read failure"); + let allowClose = false; + let closeActual: (() => Promise) | undefined; + let closes = 0; + controls.file = (handle, opened) => { + if (opened !== path) return; + controls.file = undefined; + closeActual = handle.close.bind(handle); + vi.spyOn(handle, "read").mockRejectedValue(physical); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + if (lateAbort) + controller.abort({ caller: "after physical profile failure" }); + if (!allowClose) throw new Error("controlled profile close failure"); + await closeActual?.(); + }); + }; + try { + const result = + kind === "seed" + ? await resolveGhidraAnalysisSeeds(path, resources, controller.signal) + : await resolveGhidraExtensions( + configured(path), + target, + "linux", + resources, + controller.signal, + ); + if (result.ok) throw new Error("Expected profile read failure"); + expect( + result.error.message + JSON.stringify(result.error.diagnostics), + ).toContain(physical.message); + expect(result.error._tag).toBe("ProviderAdapterError"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([path]); + expect(resources.hasPending).toBe(true); + await expect(resources.retryCleanup()).rejects.toThrow(); + expect(closes).toBe(2); + let extraOpens = 0; + controls.file = () => { + extraOpens++; + }; + const refused = + kind === "seed" + ? await resolveGhidraAnalysisSeeds(path, resources) + : await resolveGhidraExtensions( + configured(path), + target, + "linux", + resources, + ); + expect(refused).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(extraOpens).toBe(0); + expect(closes).toBe(3); + allowClose = true; + await resources.retryCleanup(); + expect(closes).toBe(4); + expect(result.error.cleanupIncomplete).toBe(false); + if (refused.ok) throw new Error("Unexpected admission"); + expect(refused.error.cleanupIncomplete).toBe(false); + expect(resources.hasPending).toBe(false); + } finally { + allowClose = true; + await resources.close(); + await closeActual?.(); + } + }, +); + +it.each(["grow", "replace"])( + "refuses seeds that %s during the admitted read", + async (mode) => { + const root = await createTestTempDirectory("rea-seed-growth-"); + const path = join(root, "seeds.tsv"); + await writeFile(path, "0x1000\tcode\n"); + const resources = new ArtifactResourceScope(); + controls.file = (handle, opened) => { + if (opened !== path) return; + controls.file = undefined; + const read = handle.read.bind(handle); + vi.spyOn(handle, "read").mockImplementationOnce( + async (...args: Parameters) => { + const result = await read(...args); + if (mode === "grow") + await writeFile(path, "0x1000\tcode\n0x2000\tcode\n"); + else { + const next = join(root, "replacement.tsv"); + await writeFile(next, "0x1000\tcode\n"); + await rename(next, path); + } + return result; + }, + ); + }; + try { + expect((await resolveGhidraAnalysisSeeds(path, resources)).ok).toBe( + false, + ); + } finally { + await resources.close(); + } + }, +); + +it.each(["seed", "extension"] as const)( + "retains %s cleanup and exact cancellation during final close", + async (kind) => { + const root = await createTestTempDirectory("rea-profile-cancel-close-"); + const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar"); + await writeFile( + path, + kind === "seed" ? "0x1000\tcode\n" : Buffer.from([0x50, 0x4b, 3, 4, 0]), + ); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = { caller: "final profile close" }; + let allowClose = false; + controls.file = (handle, opened) => { + if (opened !== path) return; + controls.file = undefined; + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + controller.abort(reason); + if (!allowClose) + throw new Error("controlled final profile close failure"); + await close(); + }); + }; + try { + const result = + kind === "seed" + ? await resolveGhidraAnalysisSeeds(path, resources, controller.signal) + : await resolveGhidraExtensions( + configured(path), + target, + "linux", + resources, + controller.signal, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: true, + cleanupResources: [path], + }, + }); + if (result.ok) throw new Error("Expected cancelled finalization"); + allowClose = true; + await resources.retryCleanup(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + allowClose = true; + await resources.close(); + } + }, +); + +it.each(["seed", "extension"] as const)( + "retains %s snapshot output owner after cancellation during close", + async (kind) => { + const root = await createTestTempDirectory("rea-profile-output-close-"); + const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar"); + const bytes = + kind === "seed" + ? Buffer.from("0x1000\tcode\n") + : Buffer.from([0x50, 0x4b, 3, 4, 0]); + await writeFile(path, bytes); + const resources = new ArtifactResourceScope(); + const seeds = await resolveGhidraAnalysisSeeds( + kind === "seed" ? path : undefined, + resources, + ); + const extensions = + kind === "extension" + ? await resolveGhidraExtensions( + configured(path), + target, + "linux", + resources, + ) + : ({ ok: true, value: [] } as const); + if (!seeds.ok || !extensions.ok) + throw new Error("Failed fixture commitment"); + const output = join( + root, + kind === "seed" ? "seeds/seeds.tsv" : "extensions/nativeaot.jar", + ); + const controller = new AbortController(); + const reason = { caller: "snapshot output close" }; + let allowClose = false; + controls.file = (handle, opened) => { + if (opened !== output) return; + controls.file = undefined; + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + controller.abort(reason); + if (!allowClose) + throw new Error("controlled snapshot output close failure"); + await close(); + }); + }; + try { + const error = await ( + kind === "seed" && seeds.value !== undefined + ? snapshotGhidraAnalysisSeeds( + seeds.value, + root, + resources, + controller.signal, + ) + : snapshotGhidraExtensions( + extensions.value, + root, + resources, + controller.signal, + ) + ).then( + () => { + throw new Error("Expected snapshot failure"); + }, + (cause: unknown) => cause, + ); + expect(error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: true, + cleanupResources: [output], + }); + expect(await readFile(output)).toEqual(bytes); + allowClose = true; + await resources.retryCleanup(); + expect(error).toMatchObject({ cleanupIncomplete: false }); + } finally { + allowClose = true; + await resources.close(); + } + }, +); + +it("joins whole provider resolution before shutdown and gates the next admission", async () => { + const root = await createTestTempDirectory("rea-profile-completion-"); + const jar = join(root, "extension.jar"); + const seed = join(root, "seeds.tsv"); + await writeFile(jar, Buffer.from([0x50, 0x4b, 3, 4, 0])); + await writeFile(seed, "0x1000\tcode\n"); + const provider = new GhidraProvider( + configured(jar, { + GHIDRA_INSTALL_DIR: "/ghidra", + REA_GHIDRA_SEED_FILE: seed, + }), + silentLogger, + {}, + ghidraProfileInstallationHost, + ); + const entered = createDeferred(); + const release = createDeferred(); + controls.realpath = async (path) => { + if (path !== seed) return; + controls.realpath = undefined; + entered.resolve(); + await release.promise; + }; + let opens = 0; + controls.file = () => { + opens++; + }; + const resolving = provider.resolveAnalysisProfile(target); + await entered.promise; + const count = opens; + let closed = false; + const closing = provider.closeOwnedResources().then((result) => { + closed = true; + return result; + }); + const next = provider.resolveAnalysisProfile(target); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(closed).toBe(false); + expect(opens).toBe(count); + expect(provider.hasPendingOwnedResources()).toBe(true); + release.resolve(); + expect((await resolving).ok).toBe(true); + expect(await closing).toEqual({ ok: true, value: null }); + expect((await next).ok).toBe(true); + expect(provider.hasPendingOwnedResources()).toBe(false); + } finally { + release.resolve(); + await Promise.all([resolving, closing, next]); + await provider.closeOwnedResources(); + } +}); + +it.each(["seed", "extension"] as const)( + "preserves %s snapshot write failure through client cancellation and cleanup retry", + async (kind) => { + const root = await createTestTempDirectory("rea-profile-client-write-"); + const source = join(root, kind === "seed" ? "input.tsv" : "input.jar"); + await writeFile( + source, + kind === "seed" ? "0x1000\tcode\n" : Buffer.from([0x50, 0x4b, 3, 4, 0]), + ); + const scope = new ArtifactResourceScope(); + const seeds = await resolveGhidraAnalysisSeeds( + kind === "seed" ? source : undefined, + scope, + ); + const extensions = + kind === "extension" + ? await resolveGhidraExtensions( + configured(source), + target, + "linux", + scope, + ) + : ({ ok: true, value: [] } as const); + if (!seeds.ok || !extensions.ok) + throw new Error("Failed fixture commitment"); + const targetPath = join(root, "target.bin"); + await writeFile(targetPath, "synthetic target"); + const spawn = vi.fn(); + const launcher = new GhidraHeadlessLauncher({ + environment: {}, + analyzeHeadlessPath: join(root, "support/analyzeHeadless"), + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", + bridgeScriptPath: "/packaged/ReaGhidraBridge.java", + ...(seeds.value === undefined ? {} : { analysisSeeds: seeds.value }), + analysisExtensions: extensions.value, + spawnProcess: spawn, + }); + const client = new GhidraClient({ + launcher, + targetPath, + targetSha256: createHash("sha256") + .update("synthetic target") + .digest("hex"), + providerVersion: "12.1.4", + profileDigest: "b".repeat(64), + }); + const controller = new AbortController(); + let allowClose = false; + let closes = 0; + let outputPath = ""; + const physical = new Error("controlled snapshot physical write failure"); + controls.file = (handle, opened) => { + if ( + basename(opened) !== (kind === "seed" ? "seeds.tsv" : "nativeaot.jar") + ) + return; + controls.file = undefined; + outputPath = opened; + const close = handle.close.bind(handle); + vi.spyOn(handle, "writeFile").mockRejectedValue(physical); + vi.spyOn(handle, "close").mockImplementation(async () => { + closes++; + controller.abort({ caller: "after snapshot write failure" }); + if (!allowClose) throw new Error("controlled snapshot close failure"); + await close(); + }); + }; + try { + const result = await client.start(controller.signal); + if (result.ok) throw new Error("Failed snapshot admitted"); + expect(result.error.kind).toBe("start"); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); + expect(result.error.cleanupFailure?.cleanupResources).toContain( + outputPath, + ); + expect(result.error.cause).toMatchObject({ + primaryFailure: { diagnostics: { reason: physical.message } }, + }); + expect(spawn).not.toHaveBeenCalled(); + expect(closes).toBe(2); + allowClose = true; + await client.close(); + expect(closes).toBe(3); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); + } finally { + allowClose = true; + await client.close(); + await scope.close(); + } + }, +); + +it("preserves the exact cancellation reason between extension completion and provider continuation", async () => { + const root = await createTestTempDirectory("rea-profile-handoff-"); + const jar = join(root, "extension.jar"); + await writeFile(jar, Buffer.from([0x50, 0x4b, 3, 4, 0])); + const controller = new AbortController(); + const reason = { caller: "profile continuation" }; + const resolve = extensionModule.resolveGhidraExtensions; + vi.spyOn(extensionModule, "resolveGhidraExtensions").mockImplementationOnce( + async (...args) => { + const result = await resolve(...args); + queueMicrotask(() => controller.abort(reason)); + return result; + }, + ); + const provider = new GhidraProvider( + configured(jar, { GHIDRA_INSTALL_DIR: "/ghidra" }), + silentLogger, + {}, + ghidraProfileInstallationHost, + ); + try { + const result = await provider.resolveAnalysisProfile(target, { + signal: controller.signal, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + if (result.ok) throw new Error("Cancelled handoff admitted"); + expect(result.error.cause).toBe(reason); + } finally { + await provider.closeOwnedResources(); + } +}); + +it.each(["seed", "extension"] as const)( + "preserves malformed %s facts before final close failure and late cancellation", + async (kind) => { + const root = await createTestTempDirectory("rea-profile-format-close-"); + const path = join(root, kind === "seed" ? "seeds.tsv" : "extension.jar"); + await writeFile( + path, + kind === "seed" ? "0x1000\tinvalid\n" : "invalid jar", + ); + const scope = new ArtifactResourceScope(); + const controller = new AbortController(); + let allowClose = false; + controls.file = (handle, opened) => { + if (opened !== path) return; + controls.file = undefined; + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + controller.abort({ caller: "after format interpretation" }); + if (!allowClose) + throw new Error("controlled malformed artifact close failure"); + await close(); + }); + }; + try { + const result = + kind === "seed" + ? await resolveGhidraAnalysisSeeds(path, scope, controller.signal) + : await resolveGhidraExtensions( + configured(path), + target, + "linux", + scope, + controller.signal, + ); + if (result.ok) throw new Error("Malformed artifact admitted"); + expect(result.error._tag).toBe("ProviderAdapterError"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([path]); + if (kind === "seed") + expect(result.error.diagnostics?.primary_error).toMatchObject({ + code: "configuration_invalid", + details: { + settings: [ + { + setting: "REA_GHIDRA_SEED_FILE", + constraint: expect.stringContaining("kind must be"), + }, + ], + }, + }); + else expect(result.error.diagnostics?.reason).toContain("not a JAR"); + allowClose = true; + await scope.retryCleanup(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + allowClose = true; + await scope.close(); + } + }, +);