fix(ghidra): own configured profile artifact lifetimes

Keep bounded stable reads, format interpretation, snapshot writes and final descriptor cleanup under exact owners. Join complete provider profile work during shutdown and retain primary failures through late cancellation and cleanup retry.

Related: #4584, #4586
This commit is contained in:
morluto committed 2026-10-17 04:50:13 +08:00
1 parent 61e688070f
commit f9ccacca2c
9 files changed
+1043 -233

No files matched your search

+7
View File
@@ -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 and owned directories until a successful close. Closing the provider reports a
provider-state failure to live callers rather than cancelling their signals. 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 | | 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. | | 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. |
+23 -7
View File
@@ -1,3 +1,4 @@
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { import {
mkdtemp, mkdtemp,
@@ -27,7 +28,10 @@ import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixt
import { GhidraProvider } from "./GhidraProvider.js"; import { GhidraProvider } from "./GhidraProvider.js";
const directories: string[] = []; const directories: string[] = [];
let resources = new ArtifactResourceScope();
afterEach(async () => { afterEach(async () => {
await resources.close();
resources = new ArtifactResourceScope();
for (const directory of directories.splice(0)) for (const directory of directories.splice(0))
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
}); });
@@ -92,7 +96,7 @@ describe("Ghidra seed commitment", () => {
const directory = await workspace(); const directory = await workspace();
const path = join(directory, "seeds.tsv"); const path = join(directory, "seeds.tsv");
await writeFile(path, SEEDS); await writeFile(path, SEEDS);
const resolved = await resolveGhidraAnalysisSeeds(path); const resolved = await resolveGhidraAnalysisSeeds(path, resources);
if (!resolved.ok) throw resolved.error; if (!resolved.ok) throw resolved.error;
const commitment = resolved.value; const commitment = resolved.value;
if (commitment === undefined) throw new Error("Expected a commitment"); if (commitment === undefined) throw new Error("Expected a commitment");
@@ -103,12 +107,16 @@ describe("Ghidra seed commitment", () => {
entries: { function: 2, code: 1, label: 1 }, entries: { function: 2, code: 1, label: 1 },
}); });
const runtime = await workspace(); const runtime = await workspace();
const snapshot = await snapshotGhidraAnalysisSeeds(commitment, runtime); const snapshot = await snapshotGhidraAnalysisSeeds(
commitment,
runtime,
resources,
);
expect(await readFile(snapshot, "utf8")).toBe(CANONICAL); expect(await readFile(snapshot, "utf8")).toBe(CANONICAL);
}); });
it("is absent when no seed file is configured", async () => { 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(); expect(resolved.ok && resolved.value).toBeUndefined();
}); });
@@ -116,13 +124,20 @@ describe("Ghidra seed commitment", () => {
const directory = await workspace(); const directory = await workspace();
const path = join(directory, "seeds.tsv"); const path = join(directory, "seeds.tsv");
await writeFile(path, SEEDS); await writeFile(path, SEEDS);
const resolved = await resolveGhidraAnalysisSeeds(path); const resolved = await resolveGhidraAnalysisSeeds(path, resources);
if (!resolved.ok || resolved.value === undefined) if (!resolved.ok || resolved.value === undefined)
throw new Error("Expected a commitment"); throw new Error("Expected a commitment");
await writeFile(path, `${SEEDS}0x401100\tcode\n`); await writeFile(path, `${SEEDS}0x401100\tcode\n`);
await expect( await expect(
snapshotGhidraAnalysisSeeds(resolved.value, await workspace()), snapshotGhidraAnalysisSeeds(resolved.value, await workspace(), resources),
).rejects.toThrow(/changed after the analysis profile was committed/u); ).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 () => { it("commits the resolved path behind a configured symbolic link", async () => {
@@ -131,7 +146,7 @@ describe("Ghidra seed commitment", () => {
await writeFile(path, SEEDS); await writeFile(path, SEEDS);
const link = join(directory, "link.tsv"); const link = join(directory, "link.tsv");
await symlink(path, link); await symlink(path, link);
const resolved = await resolveGhidraAnalysisSeeds(link); const resolved = await resolveGhidraAnalysisSeeds(link, resources);
if (!resolved.ok || resolved.value === undefined) if (!resolved.ok || resolved.value === undefined)
throw new Error("Expected a commitment"); throw new Error("Expected a commitment");
expect(resolved.value.path).toBe(await realpath(path)); 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 () => { it("reports an unreadable seed file as a provider error", async () => {
const resolved = await resolveGhidraAnalysisSeeds( const resolved = await resolveGhidraAnalysisSeeds(
join(await workspace(), "absent.tsv"), join(await workspace(), "absent.tsv"),
resources,
); );
if (resolved.ok) throw new Error("Expected an error"); if (resolved.ok) throw new Error("Expected an error");
expect(projectAnalysisError(resolved.error)).toMatchObject({ expect(projectAnalysisError(resolved.error)).toMatchObject({
+94 -96
View File
@@ -1,12 +1,18 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { constants } from "node:fs"; import { mkdir, realpath } from "node:fs/promises";
import { mkdir, open, realpath, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path"; import { isAbsolute, join } from "node:path";
import { z } from "zod"; import { z } from "zod";
import type { AnalysisOperation } from "../application/AnalysisProvider.js"; import type { AnalysisOperation } from "../application/AnalysisProvider.js";
import { ConfigurationError } from "../domain/configurationErrors.js"; import { ConfigurationError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.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"; import { err, ok, type Result } from "../domain/result.js";
/** Seed file format admitted by this adapter. */ /** Seed file format admitted by this adapter. */
@@ -131,114 +137,106 @@ const normalizedSeeds = (entries: readonly SeedEntry[]): string =>
) )
.join(""); .join("");
/** Read a bounded regular file through one handle without following links. */
const readSeedFile = async (path: string): Promise<Buffer> => {
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 => const sha256 = (bytes: Buffer | string): string =>
createHash("sha256").update(bytes).digest("hex"); createHash("sha256").update(bytes).digest("hex");
const decodeSeeds = (bytes: Buffer) => { /** Parse seed facts before final close so later cleanup cannot replace invalid input. */
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); const readSeeds = (
return parseGhidraSeeds(text); path: string,
}; resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<readonly SeedEntry[]> =>
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. */ /** Resolve the configured seed file into an immutable profile commitment. */
export const resolveGhidraAnalysisSeeds = async ( export const resolveGhidraAnalysisSeeds = async (
configured: string | undefined, configured: string | undefined,
): Promise< resources: ArtifactResourceScope,
Result< signal?: AbortSignal,
GhidraSeedCommitment | undefined, ): Promise<Result<GhidraSeedCommitment | undefined, AnalysisError>> =>
ConfigurationError | ProviderAdapterError resources
> .run(async () => {
> => { if (configured === undefined) return ok(undefined);
if (configured === undefined) return ok(undefined); let entries: readonly SeedEntry[];
let bytes: Buffer; let path: string;
let path: string; try {
try { path = await realpath(configured);
path = await realpath(configured); entries = await readSeeds(path, resources, signal);
bytes = await readSeedFile(path); } catch (cause: unknown) {
} catch (cause: unknown) { return err(
return err( ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
new ProviderAdapterError("ghidra", "resolve_analysis_profile", { setting: "REA_GHIDRA_SEED_FILE",
cause, path: configured,
diagnostics: { }),
);
}
const normalized = normalizedSeeds(entries);
const tally = { function: 0, code: 0, label: 0 };
for (const entry of entries) tally[entry.kind] += 1;
return ok<GhidraSeedCommitment>({
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", setting: "REA_GHIDRA_SEED_FILE",
path: configured, }),
reason: cause instanceof Error ? cause.message : String(cause), ),
},
}),
); );
}
let parsed: ReturnType<typeof decodeSeeds>;
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. */ /** Re-read the committed seeds and write the canonical copy the script applies. */
export const snapshotGhidraAnalysisSeeds = async ( export const snapshotGhidraAnalysisSeeds = async (
commitment: GhidraSeedCommitment, commitment: GhidraSeedCommitment,
runtimeRoot: string, runtimeRoot: string,
): Promise<string> => { resources: ArtifactResourceScope,
ghidraSeedCommitmentSchema.parse(commitment); signal?: AbortSignal,
if ((await realpath(commitment.configured_path)) !== commitment.path) ): Promise<string> =>
throw new Error( resources
"Ghidra seed file resolved path differs from explicit configuration", .run(async () => {
); ghidraSeedCommitmentSchema.parse(commitment);
const parsed = decodeSeeds(await readSeedFile(commitment.path)); if ((await realpath(commitment.configured_path)) !== commitment.path)
if (!parsed.ok) throw parsed.error; throw new Error(
const normalized = normalizedSeeds(parsed.value); "Ghidra seed file resolved path differs from explicit configuration",
if (sha256(normalized) !== commitment.sha256) );
throw new Error( const entries = await readSeeds(commitment.path, resources, signal);
`Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`, const normalized = normalizedSeeds(entries);
); if (sha256(normalized) !== commitment.sha256)
const directory = join(runtimeRoot, "seeds"); throw new Error(
await mkdir(directory, { mode: 0o700 }); `Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`,
const path = join(directory, "seeds.tsv"); );
await writeFile(path, normalized, { flag: "wx", mode: 0o600 }); const directory = join(runtimeRoot, "seeds");
return path; 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 => const totalEntries = (commitment: GhidraSeedCommitment): number =>
commitment.entries.function + commitment.entries.function +
+24 -11
View File
@@ -309,17 +309,30 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
platform, platform,
this.options.configurationDirectoryName, this.options.configurationDirectoryName,
); );
const seedPath = let seedPath: string | undefined;
this.options.analysisSeeds === undefined let extensions: readonly GhidraExtension[] = [];
? undefined if (
: await snapshotGhidraAnalysisSeeds( this.options.analysisSeeds !== undefined ||
this.options.analysisSeeds, (this.options.analysisExtensions?.length ?? 0) > 0
session.runtimeRoot, ) {
); if (options.resourceScope === undefined)
const extensions = await snapshotGhidraExtensions( throw new GhidraLaunchError(
this.options.analysisExtensions ?? [], "Configured Ghidra artifacts require a caller-owned resource scope.",
session.runtimeRoot, );
); 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( await writeGhidraRuntimeFile(
paths.descriptorPath, paths.descriptorPath,
`${JSON.stringify({ `${JSON.stringify({
+120
View File
@@ -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 = <Value>(
path: string,
maximumBytes: number,
resources: ArtifactResourceScope,
signal: AbortSignal | undefined,
interpret: (bytes: Buffer) => Value,
): Promise<Value> =>
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<void> => {
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<Record<string, JsonValue>> = {},
): 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;
};
+51 -5
View File
@@ -6,6 +6,7 @@ import type {
AnalysisClient, AnalysisClient,
AnalysisClientContext, AnalysisClientContext,
AnalysisProfileResolutionOptions, AnalysisProfileResolutionOptions,
AnalysisProfileResolution,
AnalysisProviderCandidate, AnalysisProviderCandidate,
AnalysisProviderReadinessSnapshot, AnalysisProviderReadinessSnapshot,
CapabilityDescriptor, CapabilityDescriptor,
@@ -27,7 +28,8 @@ import {
jsonValueSchema, jsonValueSchema,
type JsonValue, type JsonValue,
} from "../domain/jsonValue.js"; } 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 { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -82,6 +84,10 @@ const SUPPORTED_ARCHITECTURES = new Set([
/** Ghidra candidate backed by an isolated ephemeral headless import. */ /** Ghidra candidate backed by an isolated ephemeral headless import. */
export class GhidraProvider implements AnalysisProviderCandidate { export class GhidraProvider implements AnalysisProviderCandidate {
readonly #inspectionResources = new ArtifactResourceScope(); readonly #inspectionResources = new ArtifactResourceScope();
readonly #profileWork = new Set<
Promise<Result<AnalysisProfileResolution, AnalysisError>>
>();
#closing: Promise<Result<null, AnalysisError>> | undefined;
#installation: GhidraInstallationInspection | undefined; #installation: GhidraInstallationInspection | undefined;
private readonly environment: NodeJS.ProcessEnv; private readonly environment: NodeJS.ProcessEnv;
@@ -228,10 +234,22 @@ export class GhidraProvider implements AnalysisProviderCandidate {
} }
hasPendingOwnedResources(): boolean { 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 { try {
await this.#inspectionResources.retryCleanup(); await this.#inspectionResources.retryCleanup();
return ok(null); return ok(null);
@@ -252,6 +270,25 @@ export class GhidraProvider implements AnalysisProviderCandidate {
async resolveAnalysisProfile( async resolveAnalysisProfile(
target: BinaryTarget, target: BinaryTarget,
options?: AnalysisProfileResolutionOptions, options?: AnalysisProfileResolutionOptions,
): Promise<Result<AnalysisProfileResolution, AnalysisError>> {
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 installation = this.#inspectInstallation();
const resolved = await resolveGhidraAnalysisProfile( const resolved = await resolveGhidraAnalysisProfile(
@@ -307,12 +344,21 @@ export class GhidraProvider implements AnalysisProviderCandidate {
this.config, this.config,
target, target,
installation.platform, installation.platform,
this.#inspectionResources,
options?.signal, options?.signal,
); );
if (!extensions.ok) return extensions; if (!extensions.ok) return extensions;
if (options?.signal?.aborted === true) if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError("open_binary")); return err(
const seeds = await resolveGhidraAnalysisSeeds(this.config.ghidraSeedFile); 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 (!seeds.ok) return seeds;
if (extensions.value.length === 0 && seeds.value === undefined) if (extensions.value.length === 0 && seeds.value === undefined)
return resolved; return resolved;
+50 -11
View File
@@ -1,3 +1,4 @@
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { constants } from "node:fs"; import { constants } from "node:fs";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -19,7 +20,10 @@ import {
} from "./GhidraExtensions.js"; } from "./GhidraExtensions.js";
const roots: string[] = []; const roots: string[] = [];
let resources = new ArtifactResourceScope();
afterEach(async () => { afterEach(async () => {
await resources.close();
resources = new ArtifactResourceScope();
await Promise.all( await Promise.all(
roots.splice(0).map((path) => rm(path, { recursive: true, force: true })), 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 path = join(directory, "fixture.jar");
const bytes = Buffer.from([0x50, 0x4b, 3, 4, 0, 1, 2, 3]); const bytes = Buffer.from([0x50, 0x4b, 3, 4, 0, 1, 2, 3]);
await writeFile(path, bytes); 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) if (!resolved.ok || resolved.value[0] === undefined)
throw new Error("expected fixture artifact"); throw new Error("expected fixture artifact");
return { extension: resolved.value[0], bytes, path }; return { extension: resolved.value[0], bytes, path };
@@ -77,6 +86,7 @@ describe("optional Ghidra extension refusals", () => {
config("/absent.jar"), config("/absent.jar"),
target, target,
"darwin", "darwin",
resources,
); );
expect(host.ok).toBe(false); expect(host.ok).toBe(false);
if (!host.ok) if (!host.ok)
@@ -101,6 +111,7 @@ describe("optional Ghidra extension refusals", () => {
availableArchitectures: ["arm64"], availableArchitectures: ["arm64"],
}, },
"linux", "linux",
resources,
); );
expect(architecture.ok).toBe(false); expect(architecture.ok).toBe(false);
if (!architecture.ok) if (!architecture.ok)
@@ -120,7 +131,9 @@ describe("optional Ghidra extension refusals", () => {
describe("optional Ghidra extension boundary", () => { describe("optional Ghidra extension boundary", () => {
it("keeps ordinary profiles free of unconfigured extensions", async () => { 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, ok: true,
value: [], value: [],
}); });
@@ -142,21 +155,31 @@ describe("optional Ghidra extension boundary", () => {
it("snapshots identical bytes exclusively into the private runtime", async () => { it("snapshots identical bytes exclusively into the private runtime", async () => {
const { extension, bytes } = await artifact(); const { extension, bytes } = await artifact();
const runtime = await root(); 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(snapshots[0]?.path).toBe(join(runtime, "extensions/nativeaot.jar"));
expect(await readFile(join(runtime, "extensions/nativeaot.jar"))).toEqual( expect(await readFile(join(runtime, "extensions/nativeaot.jar"))).toEqual(
bytes, bytes,
); );
await expect( await expect(
snapshotGhidraExtensions([extension], runtime), snapshotGhidraExtensions([extension], runtime, resources),
).rejects.toMatchObject({ code: "EEXIST" }); ).rejects.toMatchObject({
_tag: "ProviderAdapterError",
cause: { code: "EEXIST" },
});
}); });
it("rejects artifact changes after the profile commitment", async () => { it("rejects artifact changes after the profile commitment", async () => {
const { extension, path } = await artifact(); const { extension, path } = await artifact();
await writeFile(path, Buffer.from([0x50, 0x4b, 3, 4, 9, 9, 9, 9])); await writeFile(path, Buffer.from([0x50, 0x4b, 3, 4, 9, 9, 9, 9]));
await expect( await expect(
snapshotGhidraExtensions([extension], await root()), snapshotGhidraExtensions([extension], await root(), resources),
).rejects.toThrow("digest differs"); ).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("digest differs") },
});
}); });
it("rejects nonexistent, empty and non-JAR artifacts with useful reasons", async () => { it("rejects nonexistent, empty and non-JAR artifacts with useful reasons", async () => {
const directory = await root(); const directory = await root();
@@ -167,6 +190,7 @@ describe("optional Ghidra extension boundary", () => {
config(path), config(path),
target, target,
"linux", "linux",
resources,
); );
expect(resolved.ok).toBe(false); expect(resolved.ok).toBe(false);
if (!resolved.ok) if (!resolved.ok)
@@ -179,6 +203,7 @@ describe("optional Ghidra extension boundary", () => {
config(absentPath), config(absentPath),
target, target,
"linux", "linux",
resources,
); );
expect(absent.ok).toBe(false); expect(absent.ok).toBe(false);
if (!absent.ok) if (!absent.ok)
@@ -212,11 +237,12 @@ describe("optional Ghidra extension boundary", () => {
config(path), config(path),
target, target,
"linux", "linux",
resources,
); );
expect(resolved.ok).toBe(false); expect(resolved.ok).toBe(false);
if (!resolved.ok) if (!resolved.ok)
expect(resolved.error).toMatchObject({ expect(resolved.error).toMatchObject({
diagnostics: { reason: expect.stringContaining("regular JAR") }, diagnostics: { reason: expect.stringContaining("regular file") },
}); });
expect(writerRequired).toBe(false); expect(writerRequired).toBe(false);
} finally { } finally {
@@ -232,6 +258,7 @@ describe("optional Ghidra extension boundary", () => {
config("/absent.jar"), config("/absent.jar"),
target, target,
"linux", "linux",
resources,
controller.signal, controller.signal,
); );
expect(resolved.ok).toBe(false); expect(resolved.ok).toBe(false);
@@ -332,6 +359,7 @@ describe("Ghidra extension profile and producer validation", () => {
config("/absent.jar"), config("/absent.jar"),
{ ...target, format: "pe", managed: true, executableRole: "application" }, { ...target, format: "pe", managed: true, executableRole: "application" },
"linux", "linux",
resources,
); );
expect(resolved.ok).toBe(false); expect(resolved.ok).toBe(false);
if (!resolved.ok) if (!resolved.ok)
@@ -349,15 +377,26 @@ describe("Ghidra extension profile and producer validation", () => {
snapshotGhidraExtensions( snapshotGhidraExtensions(
[{ ...second.extension, configured_path: first.path }], [{ ...second.extension, configured_path: first.path }],
await root(), 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 () => { it("rejects unregistered descriptors before snapshot filesystem writes", async () => {
const { extension } = await artifact(); const { extension } = await artifact();
const runtime = await root(); const runtime = await root();
await expect( await expect(
snapshotGhidraExtensions([{ ...extension, id: "unknown" }], runtime), snapshotGhidraExtensions(
).rejects.toThrow("Unregistered"); [{ ...extension, id: "unknown" }],
runtime,
resources,
),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("Unregistered") },
});
}); });
}); });
+109 -103
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { constants } from "node:fs"; import { realpath, mkdir } from "node:fs/promises";
import { open, realpath, writeFile, mkdir } from "node:fs/promises";
import { isAbsolute, join } from "node:path"; import { isAbsolute, join } from "node:path";
import { z } from "zod"; import { z } from "zod";
import type { AppConfig } from "../../config/types.js"; import type { AppConfig } from "../../config/types.js";
@@ -10,7 +9,13 @@ import {
AnalysisCapabilityUnavailableError, AnalysisCapabilityUnavailableError,
AnalysisUnsupportedTargetError, AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js"; } 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 type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js"; import { jsonObjectSchema } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js"; import { err, ok, type Result } from "../../domain/result.js";
@@ -68,86 +73,74 @@ export interface GhidraExtensionAdapter {
const adapters: readonly GhidraExtensionAdapter[] = [nativeAotAdapter]; const adapters: readonly GhidraExtensionAdapter[] = [nativeAotAdapter];
const MAX_EXTENSION_BYTES = 8 * 1024 * 1024; const MAX_EXTENSION_BYTES = 8 * 1024 * 1024;
/** Read a bounded regular artifact through its open handle, without ambient state changes. */ /** JAR framing belongs to the extension adapter; file lifecycle is shared. */
const readJar = async (path: string): Promise<Buffer> => { const readJar = (
const file = await open( path: string,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<Buffer> =>
readGhidraProfileArtifact(
path, 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. */ /** Resolve explicitly configured optional extensions into the immutable analysis profile. */
export const resolveGhidraExtensions = async ( export const resolveGhidraExtensions = async (
config: AppConfig, config: AppConfig,
target: BinaryTarget, target: BinaryTarget,
platform: NodeJS.Platform, platform: NodeJS.Platform,
resources: ArtifactResourceScope,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<Result<readonly GhidraExtension[], AnalysisError>> => { ): Promise<Result<readonly GhidraExtension[], AnalysisError>> =>
const extensions: GhidraExtension[] = []; resources
for (const adapter of adapters) { .run(async () => {
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); const extensions: GhidraExtension[] = [];
const configured = adapter.configuredPath(config); for (const adapter of adapters) {
if (configured === undefined) continue; if (signal?.aborted)
const refusal = adapter.unsupportedReason(target, platform); return err(
if (refusal !== null) return err(extensionRefusalError(refusal, target)); new AnalysisCancelledError("open_binary", { cause: signal.reason }),
try { );
const path = await realpath(configured); const configured = adapter.configuredPath(config);
const bytes = await readJar(path); if (configured === undefined) continue;
extensions.push({ const refusal = adapter.unsupportedReason(target, platform);
id: adapter.id, if (refusal !== null)
path, return err(extensionRefusalError(refusal, target));
configured_path: configured, try {
sha256: createHash("sha256").update(bytes).digest("hex"), const path = await realpath(configured);
entry_class: adapter.entryClass, const bytes = await readJar(path, resources, signal);
integration_api: 1, extensions.push({
}); id: adapter.id,
} catch (cause: unknown) { path,
return err( configured_path: configured,
new ProviderAdapterError("ghidra", "resolve_analysis_profile", { sha256: createHash("sha256").update(bytes).digest("hex"),
cause, entry_class: adapter.entryClass,
diagnostics: { integration_api: 1,
extension: adapter.id, });
path: configured, } catch (cause: unknown) {
reason: cause instanceof Error ? cause.message : String(cause), return err(
}, ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
}), extension: adapter.id,
); path: configured,
} }),
} );
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); }
return ok(extensions); }
}; 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. */ /** Require explicit local configuration before admitting executable code from a profile. */
export const validateGhidraExtensionProfile = ( export const validateGhidraExtensionProfile = (
@@ -206,35 +199,48 @@ const extensionRefusalError = (
export const snapshotGhidraExtensions = async ( export const snapshotGhidraExtensions = async (
extensions: readonly GhidraExtension[], extensions: readonly GhidraExtension[],
runtimeRoot: string, runtimeRoot: string,
): Promise<readonly GhidraExtension[]> => { resources: ArtifactResourceScope,
if (extensions.length === 0) return []; signal?: AbortSignal,
for (const extension of extensions) { ): Promise<readonly GhidraExtension[]> =>
ghidraExtensionSchema.parse(extension); resources
const adapter = adapters.find((value) => value.id === extension.id); .run(async () => {
if (adapter === undefined || adapter.entryClass !== extension.entry_class) assertArtifactReadActive(signal);
throw new Error( if (extensions.length === 0) return [];
`Unregistered Ghidra extension identity: ${extension.id}`, for (const extension of extensions) {
); ghidraExtensionSchema.parse(extension);
if ((await realpath(extension.configured_path)) !== extension.path) const adapter = adapters.find((value) => value.id === extension.id);
throw new Error( if (
`Ghidra extension resolved path differs from explicit configuration: ${extension.id}`, adapter === undefined ||
); adapter.entryClass !== extension.entry_class
} )
const directory = join(runtimeRoot, "extensions"); throw new Error(
await mkdir(directory, { mode: 0o700 }); `Unregistered Ghidra extension identity: ${extension.id}`,
const result: GhidraExtension[] = []; );
for (const extension of extensions) { if ((await realpath(extension.configured_path)) !== extension.path)
const bytes = await readJar(extension.path); throw new Error(
if (createHash("sha256").update(bytes).digest("hex") !== extension.sha256) `Ghidra extension resolved path differs from explicit configuration: ${extension.id}`,
throw new Error( );
`Ghidra extension digest differs from committed profile: ${extension.id} (${extension.path})`, }
); const directory = join(runtimeRoot, "extensions");
const path = join(directory, `${extension.id}.jar`); await mkdir(directory, { mode: 0o700 });
await writeFile(path, bytes, { flag: "wx", mode: 0o600 }); const result: GhidraExtension[] = [];
result.push({ ...extension, path }); for (const extension of extensions) {
} const bytes = await readJar(extension.path, resources, signal);
return result; 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. */ /** Require every configured producer result to match its artifact and registered ABI. */
export const validateGhidraExtensionResults = ( export const validateGhidraExtensionResults = (
@@ -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<void>) | undefined,
file: undefined as ((handle: FileHandle, path: string) => void) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
realpath: async (...args: Parameters<typeof actual.realpath>) => {
await controls.realpath?.(String(args[0]));
return actual.realpath(...args);
},
open: async (...args: Parameters<typeof actual.open>) => {
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<Record<string, string>> = {},
) => {
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<void>) | 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<typeof handle.read>) => {
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<void>();
const release = createDeferred<void>();
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<void>((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();
}
},
);