mirror of
https://github.com/morluto/rea.git
synced 2026-10-22 13:52:32 -05:00
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:
1 parent
61e688070f
commit
f9ccacca2c
9 files changed
+1043
-233
No files matched your search
@@ -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. |
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<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 =>
|
||||
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<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. */
|
||||
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<Result<GhidraSeedCommitment | undefined, AnalysisError>> =>
|
||||
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<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",
|
||||
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. */
|
||||
export const snapshotGhidraAnalysisSeeds = async (
|
||||
commitment: GhidraSeedCommitment,
|
||||
runtimeRoot: string,
|
||||
): Promise<string> => {
|
||||
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<string> =>
|
||||
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 +
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<Result<AnalysisProfileResolution, AnalysisError>>
|
||||
>();
|
||||
#closing: Promise<Result<null, AnalysisError>> | 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<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 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;
|
||||
|
||||
@@ -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") },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Buffer> => {
|
||||
const file = await open(
|
||||
/** JAR framing belongs to the extension adapter; file lifecycle is shared. */
|
||||
const readJar = (
|
||||
path: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> =>
|
||||
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<Result<readonly GhidraExtension[], AnalysisError>> => {
|
||||
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<Result<readonly GhidraExtension[], AnalysisError>> =>
|
||||
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<readonly GhidraExtension[]> => {
|
||||
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<readonly GhidraExtension[]> =>
|
||||
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 = (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user