fix(ghidra): own PSP inspection finalization
Retain exact file and directory cleanup owners through profile admission and launch reinspection. Preserve typed primary failures and exact cancellation; keep fingerprint framing and bounds stable. Validation: 137 tests passed, 3 platform skips across six filesystem/provider/MCP files; check:fast passed. Controlled real-handle fixtures do not establish actual Allegrex execution or native Windows faults. Refs: #4521, #4517. Shared profile evaluation completion remains tracked in #4569.
This commit is contained in:
1 parent
ae07d15170
commit
b0ddb58e27
6 files changed
+647
-99
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type {
|
||||
AnalysisProfileResolution,
|
||||
ProviderIdentity,
|
||||
@@ -63,12 +64,15 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
target: BinaryTarget,
|
||||
identity: ProviderIdentity,
|
||||
installation: GhidraInstallationInspection,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
languageOverride?: GhidraLanguageOverride,
|
||||
disabledAnalyzers?: readonly string[],
|
||||
): Promise<Result<AnalysisProfileResolution, AnalysisError>> => {
|
||||
if (signal?.aborted === true)
|
||||
return Promise.resolve(err(new AnalysisCancelledError("open_binary")));
|
||||
return Promise.resolve(
|
||||
err(new AnalysisCancelledError("open_binary", { cause: signal.reason })),
|
||||
);
|
||||
if (target.kind !== "executable")
|
||||
return Promise.resolve(ok({ profile: null }));
|
||||
if (installation.status === "unavailable")
|
||||
@@ -96,7 +100,7 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
),
|
||||
);
|
||||
const pspExtension = isGhidraPspTarget(target)
|
||||
? await resolveGhidraPspExtension(installation, signal)
|
||||
? await resolveGhidraPspExtension(installation, resources, signal)
|
||||
: null;
|
||||
if (pspExtension !== null && !pspExtension.ok) return pspExtension;
|
||||
const provider = { ...identity, version: installation.providerVersion };
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import type { Socket } from "node:net";
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
@@ -760,7 +761,7 @@ export class GhidraClient {
|
||||
providerVersion: this.#options.providerVersion,
|
||||
profileDigest: this.#options.profileDigest,
|
||||
},
|
||||
{ signal: deadline.signal },
|
||||
{ signal: deadline.signal, resourceScope: this.#fileResources },
|
||||
)
|
||||
.catch((cause: unknown) =>
|
||||
err(this.#failure("start", "Ghidra launcher failed", cause)),
|
||||
@@ -778,7 +779,15 @@ export class GhidraClient {
|
||||
});
|
||||
this.#startupOutputCheckpoint = this.#process.checkpointOutput();
|
||||
}
|
||||
const failure = deadline.signal.aborted
|
||||
const producerFailure =
|
||||
launched.error instanceof GhidraLaunchError
|
||||
? launched.error.primaryFailure
|
||||
: undefined;
|
||||
const interrupted =
|
||||
launched.error instanceof AnalysisCancelledError ||
|
||||
producerFailure instanceof AnalysisCancelledError ||
|
||||
(producerFailure === undefined && deadline.signal.aborted);
|
||||
const failure = interrupted
|
||||
? this.#interruptionFailure(deadline, launched.error)
|
||||
: launched.error instanceof GhidraSessionError
|
||||
? launched.error
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
|
||||
import {
|
||||
snapshotGhidraAnalysisSeeds,
|
||||
@@ -88,7 +89,10 @@ export type GhidraLaunchSupportDiagnostic = {
|
||||
export interface GhidraLauncher {
|
||||
launch(
|
||||
session: GhidraLaunchSession,
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly resourceScope?: ArtifactResourceScope;
|
||||
},
|
||||
): Promise<Result<GhidraLaunch, GhidraLaunchError | AnalysisCancelledError>>;
|
||||
}
|
||||
|
||||
@@ -104,6 +108,8 @@ export class GhidraLaunchError extends Error {
|
||||
readonly launchSupport: readonly GhidraLaunchSupportDiagnostic[];
|
||||
readonly rollbackFailure: GhidraLaunchRollbackFailure | undefined;
|
||||
readonly cleanupFailure: AnalysisError | undefined;
|
||||
/** Known producer outcome, independent of a signal that changes during finalization. */
|
||||
readonly primaryFailure: AnalysisError | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -127,6 +133,12 @@ export class GhidraLaunchError extends Error {
|
||||
this.name = "GhidraLaunchError";
|
||||
this.#partialLaunch = partialLaunch;
|
||||
this.rollbackFailure = rollbackFailure;
|
||||
this.primaryFailure =
|
||||
errorOptions.cause instanceof AnalysisError
|
||||
? errorOptions.cause
|
||||
: errorOptions.cause instanceof GhidraLaunchError
|
||||
? errorOptions.cause.primaryFailure
|
||||
: undefined;
|
||||
const inherited =
|
||||
errorOptions.cause instanceof DarwinProcessOwnershipInspectionError ||
|
||||
errorOptions.cause instanceof GhidraLaunchError
|
||||
@@ -241,10 +253,17 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
|
||||
async launch(
|
||||
session: GhidraLaunchSession,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly resourceScope?: ArtifactResourceScope;
|
||||
} = {},
|
||||
): Promise<Result<GhidraLaunch, GhidraLaunchError | AnalysisCancelledError>> {
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", {
|
||||
cause: options.signal?.reason,
|
||||
}),
|
||||
);
|
||||
const paths = ghidraRuntimePaths(session.runtimeRoot);
|
||||
const platform = this.options.platform ?? process.platform;
|
||||
let started: SpawnedOwnedProviderProcess | undefined;
|
||||
@@ -270,9 +289,14 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
throw new GhidraLaunchError(
|
||||
"Allegrex profile does not belong to the selected Ghidra installation.",
|
||||
);
|
||||
if (options.resourceScope === undefined)
|
||||
throw new GhidraLaunchError(
|
||||
"PSP inspection requires a caller-owned resource scope.",
|
||||
);
|
||||
const inspected = await inspectGhidraPspExtension(
|
||||
psp.root,
|
||||
psp.ghidra_version,
|
||||
options.resourceScope,
|
||||
options.signal,
|
||||
);
|
||||
if (inspected.sha256 !== psp.sha256)
|
||||
@@ -450,6 +474,8 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
}
|
||||
return ok(partialLaunch);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError && started === undefined)
|
||||
return err(cause);
|
||||
const capturedOutput = ghidraLaunchCommandOutput(cause);
|
||||
const launchFailure = new GhidraLaunchError(
|
||||
"Ghidra headless launch failed",
|
||||
@@ -459,23 +485,25 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
},
|
||||
);
|
||||
const primary = isAborted(options.signal)
|
||||
? new AnalysisCancelledError("open_binary", {
|
||||
cause: launchFailure,
|
||||
...(launchFailure.cleanupFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: launchFailure.cleanupFailure.message,
|
||||
resources: launchFailure.cleanupFailure.cleanupResources,
|
||||
},
|
||||
}),
|
||||
}).bindCleanupDependencies(
|
||||
launchFailure.cleanupFailure === undefined
|
||||
? []
|
||||
: [launchFailure.cleanupFailure],
|
||||
)
|
||||
: launchFailure;
|
||||
const primary =
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
(!(cause instanceof AnalysisError) && isAborted(options.signal))
|
||||
? new AnalysisCancelledError("open_binary", {
|
||||
cause: launchFailure,
|
||||
...(launchFailure.cleanupFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: launchFailure.cleanupFailure.message,
|
||||
resources: launchFailure.cleanupFailure.cleanupResources,
|
||||
},
|
||||
}),
|
||||
}).bindCleanupDependencies(
|
||||
launchFailure.cleanupFailure === undefined
|
||||
? []
|
||||
: [launchFailure.cleanupFailure],
|
||||
)
|
||||
: launchFailure;
|
||||
if (started === undefined) return err(primary);
|
||||
|
||||
const cleanupOwner = started;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
@@ -78,6 +81,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
|
||||
|
||||
/** Ghidra candidate backed by an isolated ephemeral headless import. */
|
||||
export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
readonly #inspectionResources = new ArtifactResourceScope();
|
||||
#installation: GhidraInstallationInspection | undefined;
|
||||
private readonly environment: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -223,6 +227,28 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
};
|
||||
}
|
||||
|
||||
hasPendingOwnedResources(): boolean {
|
||||
return this.#inspectionResources.hasPending;
|
||||
}
|
||||
|
||||
async closeOwnedResources() {
|
||||
try {
|
||||
await this.#inspectionResources.retryCleanup();
|
||||
return ok(null);
|
||||
} catch (cause: unknown) {
|
||||
const error = new ProviderCleanupError(
|
||||
"ghidra",
|
||||
cause instanceof ArtifactReaderFailure
|
||||
? (cause.cleanup?.resources ?? [])
|
||||
: [],
|
||||
{ reason: providerFailureText(cause) },
|
||||
{ cause },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return err(error);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
@@ -232,6 +258,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
target,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
installation,
|
||||
this.#inspectionResources,
|
||||
options?.signal,
|
||||
this.config.ghidraLanguageOverride,
|
||||
this.config.ghidraDisabledAnalyzers,
|
||||
|
||||
@@ -2,19 +2,28 @@ import { createHash } from "node:crypto";
|
||||
import { lstat, opendir, realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { sameRegularFileState } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
|
||||
import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js";
|
||||
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js";
|
||||
import {
|
||||
assertArtifactReadActive,
|
||||
artifactFileCancellationCause,
|
||||
artifactFilePrimaryFailure,
|
||||
bindArtifactFileCleanup,
|
||||
withArtifactRegularFile,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
|
||||
/** Installed processor code and data, distinct from REA's analysis-extension ABI. */
|
||||
export const ghidraPspExtensionSchema = z.strictObject({
|
||||
id: z.literal("ghidra-allegrex"),
|
||||
@@ -41,9 +50,25 @@ const REQUIRED = [
|
||||
export const inspectGhidraPspExtension = async (
|
||||
root: string,
|
||||
ghidraVersion: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GhidraPspExtension> => {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
return await resources.run(() =>
|
||||
fingerprint(root, ghidraVersion, resources, signal),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw ghidraPspInspectionError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
const fingerprint = async (
|
||||
root: string,
|
||||
ghidraVersion: string,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GhidraPspExtension> => {
|
||||
assertArtifactReadActive(signal);
|
||||
if (!isAbsolute(root) || (await realpath(root)) !== root)
|
||||
throw new Error(
|
||||
"The installed Allegrex directory must be a canonical non-symlink path.",
|
||||
@@ -51,7 +76,7 @@ export const inspectGhidraPspExtension = async (
|
||||
const files: string[] = [];
|
||||
let entries = 1; // The root itself consumes one inventory entry.
|
||||
const visit = async (relative: string, depth: number): Promise<void> => {
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
if (depth > MAX_DEPTH)
|
||||
throw new Error(
|
||||
"Allegrex extension directory nesting exceeds the inspection limit.",
|
||||
@@ -59,15 +84,13 @@ export const inspectGhidraPspExtension = async (
|
||||
const path = join(root, relative);
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isDirectory()) {
|
||||
const children: string[] = [];
|
||||
for await (const child of await opendir(path)) {
|
||||
signal?.throwIfAborted();
|
||||
if (++entries > MAX_ENTRIES)
|
||||
throw new Error(
|
||||
"Allegrex extension inventory exceeds the inspection limit.",
|
||||
);
|
||||
children.push(child.name);
|
||||
}
|
||||
const children = await readChildren(
|
||||
path,
|
||||
MAX_ENTRIES - entries,
|
||||
resources,
|
||||
signal,
|
||||
);
|
||||
entries += children.length;
|
||||
children.sort();
|
||||
for (const child of children)
|
||||
await visit(
|
||||
@@ -91,10 +114,9 @@ export const inspectGhidraPspExtension = async (
|
||||
let bytesRead = 0;
|
||||
let properties = Buffer.alloc(0);
|
||||
for (const relative of files) {
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
const path = join(root, relative);
|
||||
const handle = await openRegularFile(path, { symlinks: "reject", signal });
|
||||
try {
|
||||
await withArtifactRegularFile(path, resources, signal, async (handle) => {
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!Number.isSafeInteger(before.size) ||
|
||||
@@ -111,22 +133,30 @@ export const inspectGhidraPspExtension = async (
|
||||
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
|
||||
let offset = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
while (offset < before.size) {
|
||||
signal?.throwIfAborted();
|
||||
const read = await handle.read(
|
||||
// Serial reads keep final close behind every native read. One extra byte
|
||||
// detects growth without a stream's read-ahead or unbounded allocation.
|
||||
while (offset <= before.size) {
|
||||
assertArtifactReadActive(signal);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, before.size - offset),
|
||||
Math.min(buffer.length, before.size - offset + 1),
|
||||
offset,
|
||||
);
|
||||
if (read.bytesRead === 0)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
const chunk = buffer.subarray(0, read.bytesRead);
|
||||
assertArtifactReadActive(signal);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
if (offset > before.size)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
const chunk = buffer.subarray(0, bytesRead);
|
||||
hash.update(chunk);
|
||||
if (relative === "extension.properties")
|
||||
chunks.push(Buffer.from(chunk));
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
if (offset !== before.size)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
if (
|
||||
!sameRegularFileState(before, await handle.stat()) ||
|
||||
!sameRegularFileState(before, await lstat(path))
|
||||
@@ -137,9 +167,7 @@ export const inspectGhidraPspExtension = async (
|
||||
bytesRead += before.size;
|
||||
if (relative === "extension.properties")
|
||||
properties = Buffer.concat(chunks);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Ghidra's extension.properties version is the compatible Ghidra version,
|
||||
// not the extension release (e.g. v21.4); content identity supplies the latter.
|
||||
@@ -159,7 +187,7 @@ export const inspectGhidraPspExtension = async (
|
||||
throw new Error(
|
||||
`Allegrex extension metadata does not match the selected Ghidra ${ghidraVersion}; install or rebuild a compatible extension.`,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
return {
|
||||
id: "ghidra-allegrex",
|
||||
root,
|
||||
@@ -171,11 +199,11 @@ export const inspectGhidraPspExtension = async (
|
||||
/** Resolve prerequisites before opening or admitting cached PSP analysis. */
|
||||
export const resolveGhidraPspExtension = async (
|
||||
installation: GhidraInstallationInspection,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<GhidraPspExtension, AnalysisError>> => {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("resolve_analysis_profile"));
|
||||
try {
|
||||
assertArtifactReadActive(signal);
|
||||
if (
|
||||
installation.status !== "available" ||
|
||||
installation.platform !== "linux" ||
|
||||
@@ -194,20 +222,98 @@ export const resolveGhidraPspExtension = async (
|
||||
await inspectGhidraPspExtension(
|
||||
root,
|
||||
installation.providerVersion,
|
||||
resources,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("resolve_analysis_profile"));
|
||||
const reason = `PSP_EXTENSION_UNAVAILABLE: ${cause instanceof Error ? cause.message : String(cause)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
"resolve_analysis_profile",
|
||||
reason,
|
||||
{ cause, userMessage: reason },
|
||||
),
|
||||
);
|
||||
return err(ghidraPspInspectionError(cause));
|
||||
}
|
||||
};
|
||||
|
||||
/** Preserve the producer's primary failure and its live cleanup dependencies. */
|
||||
const ghidraPspInspectionError = (cause: unknown): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
const cleanup =
|
||||
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
||||
const primary = artifactFilePrimaryFailure(cause);
|
||||
const reason = `PSP_EXTENSION_UNAVAILABLE: ${primary instanceof Error ? primary.message : String(primary)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
|
||||
const error =
|
||||
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
|
||||
? new AnalysisCancelledError("resolve_analysis_profile", {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
cleanup,
|
||||
})
|
||||
: new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
"resolve_analysis_profile",
|
||||
reason,
|
||||
{
|
||||
cause,
|
||||
userMessage: reason,
|
||||
cleanup,
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
/** Close before descending, retaining the exact directory owner on uncertainty. */
|
||||
const readChildren = async (
|
||||
path: string,
|
||||
remainingEntries: number,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> => {
|
||||
assertArtifactReadActive(signal);
|
||||
const owner = new OwnedDirectoryHandle(await opendir(path));
|
||||
let outcome:
|
||||
| { readonly kind: "completed"; readonly names: string[] }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
const names: string[] = [];
|
||||
for (;;) {
|
||||
assertArtifactReadActive(signal);
|
||||
const child = await owner.handle.read();
|
||||
assertArtifactReadActive(signal);
|
||||
if (child === null) break;
|
||||
if (names.length >= remainingEntries)
|
||||
throw new Error(
|
||||
"Allegrex extension inventory exceeds the inspection limit.",
|
||||
);
|
||||
names.push(child.name);
|
||||
}
|
||||
outcome = { kind: "completed", names };
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
const lease = new AnalysisCleanupLease(() => owner.close());
|
||||
const attempt = await resources.release({
|
||||
kind: "lease",
|
||||
resource: path,
|
||||
lease,
|
||||
});
|
||||
if (attempt.kind === "failed") {
|
||||
const primary =
|
||||
outcome.kind === "failed"
|
||||
? outcome.cause
|
||||
: signal?.aborted
|
||||
? new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"PSP inspection cancelled during directory close",
|
||||
{ cause: signal.reason },
|
||||
)
|
||||
: attempt.cause;
|
||||
const error = ghidraPspInspectionError(
|
||||
ArtifactReaderFailure.withCleanup(
|
||||
primary,
|
||||
ArtifactReaderFailure.cleanupObservation(attempt.cause, path),
|
||||
),
|
||||
);
|
||||
lease.bindCleanupError(error);
|
||||
throw error;
|
||||
}
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
assertArtifactReadActive(signal);
|
||||
return outcome.names;
|
||||
};
|
||||
@@ -1,8 +1,20 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Dir } from "node:fs";
|
||||
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { ok as javaProbeOk } from "../../../src/domain/result.js";
|
||||
import type { GhidraJavaObservation } from "../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import { mkdir, rename, symlink, truncate, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
symlink,
|
||||
truncate,
|
||||
writeFile,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
@@ -20,6 +32,32 @@ import {
|
||||
} from "../../../src/ghidra/GhidraLauncher.js";
|
||||
import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js";
|
||||
|
||||
const controls = vi.hoisted(() => ({
|
||||
file: undefined as ((handle: FileHandle, path: string) => void) | undefined,
|
||||
directory: undefined as ((handle: Dir) => void) | undefined,
|
||||
}));
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
const handle = await actual.open(...args);
|
||||
controls.file?.(handle, String(args[0]));
|
||||
return handle;
|
||||
},
|
||||
opendir: async (...args: Parameters<typeof actual.opendir>) => {
|
||||
const handle = await actual.opendir(...args);
|
||||
controls.directory?.(handle);
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
controls.file = undefined;
|
||||
controls.directory = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Synthetic installation files exercise the filesystem/profile boundary, not
|
||||
// Allegrex execution. Only the optional real CLI/MCP lane proves tool behavior.
|
||||
// Independently inspected with PSPDEV v20261001 / GNU readelf 2.44.
|
||||
@@ -151,7 +189,11 @@ describe.skipIf(process.platform === "win32")(
|
||||
|
||||
it("fails before starting a provider after resolved extension bytes change", async () => {
|
||||
const { directory, root } = await installation();
|
||||
const extension = await inspectGhidraPspExtension(root, "12.1.3");
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
await writeFile(join(root, "lib/extension.jar"), "changed loader");
|
||||
const spawn = vi.fn();
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
@@ -163,17 +205,20 @@ describe.skipIf(process.platform === "win32")(
|
||||
pspExtension: extension,
|
||||
spawnProcess: spawn,
|
||||
});
|
||||
const launched = await launcher.launch({
|
||||
runtimeRoot: join(directory, "runtime"),
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(directory, "socket"),
|
||||
token: "test-only-token",
|
||||
runId: "00000000-0000-4000-8000-000000000001",
|
||||
targetPath: target.path,
|
||||
targetSha256: target.sha256,
|
||||
providerVersion: "12.1.3",
|
||||
profileDigest: "b".repeat(64),
|
||||
});
|
||||
const launched = await launcher.launch(
|
||||
{
|
||||
runtimeRoot: join(directory, "runtime"),
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(directory, "socket"),
|
||||
token: "test-only-token",
|
||||
runId: "00000000-0000-4000-8000-000000000001",
|
||||
targetPath: target.path,
|
||||
targetSha256: target.sha256,
|
||||
providerVersion: "12.1.3",
|
||||
profileDigest: "b".repeat(64),
|
||||
},
|
||||
{ resourceScope: new ArtifactResourceScope() },
|
||||
);
|
||||
expect(launched.ok).toBe(false);
|
||||
if (launched.ok)
|
||||
throw new Error("Changed extension reached provider startup");
|
||||
@@ -204,9 +249,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
async (properties, reason) => {
|
||||
const { root } = await installation();
|
||||
await writeFile(join(root, "extension.properties"), properties);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
reason,
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
),
|
||||
).rejects.toThrow(reason);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -216,9 +265,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
join(root, "Module.manifest"),
|
||||
join(root, "Module.manifest.uninstalled"),
|
||||
);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"Module.manifest",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("Module.manifest");
|
||||
});
|
||||
|
||||
it("rejects symlinked code instead of hashing an untracked external producer", async () => {
|
||||
@@ -226,17 +275,17 @@ describe.skipIf(process.platform === "win32")(
|
||||
const path = join(root, "lib/extension.jar");
|
||||
await rename(path, join(directory, "external.jar"));
|
||||
await symlink(join(directory, "external.jar"), path);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"regular files",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("regular files");
|
||||
});
|
||||
|
||||
it("bounds sparse extension input before reading or allocating its declared extent", async () => {
|
||||
const { root } = await installation();
|
||||
await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"inspection limit",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("inspection limit");
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -257,9 +306,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
),
|
||||
);
|
||||
}
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"inventory exceeds the inspection limit",
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
),
|
||||
).rejects.toThrow("inventory exceeds the inspection limit");
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -583,16 +636,337 @@ describe.skipIf(process.platform === "win32")(
|
||||
"accepts Java Properties $form and hashes raw bytes",
|
||||
async ({ properties }) => {
|
||||
const { root } = await installation();
|
||||
const canonical = await inspectGhidraPspExtension(root, "12.1.3");
|
||||
const canonical = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
const latin1Properties = Buffer.from(
|
||||
`${properties}author=caf\u00e9\n`,
|
||||
"latin1",
|
||||
);
|
||||
await writeFile(join(root, "extension.properties"), latin1Properties);
|
||||
const extension = await inspectGhidraPspExtension(root, "12.1.3");
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
expect(extension.id).toBe("ghidra-allegrex");
|
||||
expect(extension.sha256).not.toBe(canonical.sha256);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP inspection completion ownership",
|
||||
() => {
|
||||
it.each([true, false])(
|
||||
"preserves physical read failure when close aborts the caller (close rejection: %s)",
|
||||
async (rejectClose) => {
|
||||
const { provider, root } = await installation();
|
||||
const controller = new AbortController();
|
||||
const readFailure = new Error("controlled PSP read failure");
|
||||
let closes = 0;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
if (closes === 1) {
|
||||
controller.abort({ caller: "late close" });
|
||||
if (rejectClose) throw new Error("controlled PSP close failure");
|
||||
}
|
||||
await close();
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await provider.resolveAnalysisProfile(target, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed read admitted");
|
||||
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||||
expect(result.error.message).toContain(readFailure.message);
|
||||
expect(result.error.cleanupIncomplete).toBe(rejectClose);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(rejectClose);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(true);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(false);
|
||||
expect(closes).toBe(rejectClose ? 2 : 1);
|
||||
} finally {
|
||||
await provider.closeOwnedResources();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
"preserves exact cancellation during successful read finalization (close rejection: %s)",
|
||||
async (rejectClose) => {
|
||||
const { provider, root } = await installation();
|
||||
const controller = new AbortController();
|
||||
const reason = { caller: "finalization" };
|
||||
let closes = 0;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
if (closes === 1) {
|
||||
controller.abort(reason);
|
||||
if (rejectClose) throw new Error("controlled PSP close failure");
|
||||
}
|
||||
await close();
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await provider.resolveAnalysisProfile(target, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Cancelled inspection admitted");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(rejectClose);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(true);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(closes).toBe(rejectClose ? 2 : 1);
|
||||
} finally {
|
||||
await provider.closeOwnedResources();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains directory close uncertainty without retrying an invalidated native handle", async () => {
|
||||
const { provider } = await installation();
|
||||
const controller = new AbortController();
|
||||
const readFailure = new Error("controlled directory read failure");
|
||||
let closes = 0;
|
||||
controls.directory = (handle) => {
|
||||
controls.directory = undefined;
|
||||
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
await close();
|
||||
controller.abort({ caller: "directory finalization" });
|
||||
throw new Error("controlled native directory close rejection");
|
||||
});
|
||||
};
|
||||
const result = await provider.resolveAnalysisProfile(target, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed directory admitted");
|
||||
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||||
expect(result.error.message).toContain(readFailure.message);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(true);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(closes).toBe(1);
|
||||
const retried = await provider.resolveAnalysisProfile(target);
|
||||
expect(retried.ok).toBe(false);
|
||||
expect(closes).toBe(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP launch inspection ownership",
|
||||
() => {
|
||||
it("keeps the launch inspection failure through client finalization and retries its exact descriptor", async () => {
|
||||
const { directory, root } = await installation();
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const readFailure = new Error(
|
||||
"controlled launch inspection read failure",
|
||||
);
|
||||
let closes = 0;
|
||||
let allowClose = false;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
controller.abort({ caller: "launch finalization" });
|
||||
if (!allowClose)
|
||||
throw new Error("controlled launch inspection close failure");
|
||||
await close();
|
||||
});
|
||||
};
|
||||
const spawn = vi.fn();
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
environment: {},
|
||||
analyzeHeadlessPath: join(directory, "support/analyzeHeadless"),
|
||||
configurationDirectoryName: "ghidra_12.1.3_PUBLIC",
|
||||
bridgeScriptPath: "/packaged/ReaGhidraBridge.java",
|
||||
pspExtension: extension,
|
||||
spawnProcess: spawn,
|
||||
});
|
||||
const targetPath = join(directory, "target.bin");
|
||||
await writeFile(targetPath, "synthetic target");
|
||||
const client = new GhidraClient({
|
||||
launcher,
|
||||
targetPath,
|
||||
targetSha256: createHash("sha256")
|
||||
.update("synthetic target")
|
||||
.digest("hex"),
|
||||
providerVersion: "12.1.3",
|
||||
profileDigest: "b".repeat(64),
|
||||
});
|
||||
try {
|
||||
const result = await client.start(controller.signal);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Failed PSP inspection started");
|
||||
expect(result.error.kind).toBe("start");
|
||||
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
|
||||
expect(String(result.error.cause)).toContain(
|
||||
"Ghidra headless launch failed",
|
||||
);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(closes).toBe(2); // Inspection close, then client rollback.
|
||||
allowClose = true;
|
||||
await client.close();
|
||||
expect(closes).toBe(3);
|
||||
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
allowClose = true;
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP inspection admission and identity",
|
||||
() => {
|
||||
it("retains the existing sorted recursive hash framing", async () => {
|
||||
const { root } = await installation();
|
||||
await mkdir(join(root, "a"));
|
||||
await writeFile(join(root, "a/z"), "nested before sibling");
|
||||
await writeFile(join(root, "a-foo"), "sibling after directory");
|
||||
const paths = [
|
||||
"Module.manifest",
|
||||
"a/z",
|
||||
"a-foo",
|
||||
"data/languages/allegrex.cspec",
|
||||
"data/languages/allegrex.ldefs",
|
||||
"data/languages/allegrex.pspec",
|
||||
"data/languages/allegrex.sla",
|
||||
"extension.properties",
|
||||
"lib/extension.jar",
|
||||
];
|
||||
const hash = createHash("sha256");
|
||||
for (const path of paths) {
|
||||
const bytes = await readFile(join(root, path));
|
||||
hash.update(JSON.stringify([path, bytes.length]));
|
||||
hash.update(bytes);
|
||||
}
|
||||
const extension = await inspectGhidraPspExtension(
|
||||
root,
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
);
|
||||
expect(extension.sha256).toBe(hash.digest("hex"));
|
||||
});
|
||||
|
||||
it.each(["growth", "truncation"] as const)(
|
||||
"rejects file %s during the bounded read and confirms close",
|
||||
async (change) => {
|
||||
const { root, provider } = await installation();
|
||||
const path = join(
|
||||
root,
|
||||
change === "growth" ? "Module.manifest" : "extension.properties",
|
||||
);
|
||||
let closes = 0;
|
||||
controls.file = (handle, openedPath) => {
|
||||
if (openedPath !== path) return;
|
||||
controls.file = undefined;
|
||||
const read = handle.read.bind(handle);
|
||||
vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => {
|
||||
await writeFile(path, change === "growth" ? "extra byte" : "");
|
||||
return read(...args);
|
||||
});
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
await close();
|
||||
});
|
||||
};
|
||||
const result = await provider.resolveAnalysisProfile(target);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Changed file admitted");
|
||||
expect(result.error.message).toContain(
|
||||
change === "growth"
|
||||
? "changed during inspection"
|
||||
: "Truncated Allegrex component",
|
||||
);
|
||||
expect(closes).toBe(1);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(false);
|
||||
expect((await provider.closeOwnedResources()).ok).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("joins active final close before provider cleanup retries the same descriptor", async () => {
|
||||
const { root, provider } = await installation();
|
||||
let markClosing: (() => void) | undefined;
|
||||
let releaseClose: (() => void) | undefined;
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
markClosing = resolve;
|
||||
});
|
||||
const finishClose = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
let closes = 0;
|
||||
controls.file = (handle, path) => {
|
||||
if (path !== join(root, "Module.manifest")) return;
|
||||
controls.file = undefined;
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
if (closes === 1) {
|
||||
markClosing?.();
|
||||
await finishClose;
|
||||
throw new Error("controlled active close rejection");
|
||||
}
|
||||
await close();
|
||||
});
|
||||
};
|
||||
const inspecting = provider.resolveAnalysisProfile(target);
|
||||
await closing;
|
||||
let cleanupSettled = false;
|
||||
const cleaning = provider.closeOwnedResources().then((result) => {
|
||||
cleanupSettled = true;
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(cleanupSettled).toBe(false);
|
||||
expect(closes).toBe(1);
|
||||
releaseClose?.();
|
||||
const result = await inspecting;
|
||||
expect(result.ok).toBe(false);
|
||||
expect((await cleaning).ok).toBe(true);
|
||||
if (result.ok) throw new Error("Failed close admitted");
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(closes).toBe(2);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(false);
|
||||
} finally {
|
||||
releaseClose?.();
|
||||
await cleaning;
|
||||
await provider.closeOwnedResources();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user