diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 275faf872b..43d9386c96 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -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> => { 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 }; diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 6e65230fd2..4609185997 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -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 diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index ac093c7161..7167f3bd80 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -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>; } @@ -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> { 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; diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index d07cd9df61..f8dd4f9001 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -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, diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts index 9c017f367f..bdd759fda8 100644 --- a/src/ghidra/GhidraPspExtension.ts +++ b/src/ghidra/GhidraPspExtension.ts @@ -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 => { - 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 => { + 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 => { - 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> => { - 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 => { + 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; +}; diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index 0b5017da40..38c7b1c780 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -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(); + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + controls.file?.(handle, String(args[0])); + return handle; + }, + opendir: async (...args: Parameters) => { + 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((resolve) => { + markClosing = resolve; + }); + const finishClose = new Promise((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(); + } + }); + }, +);