From 3d472bde23b70f9aa7bcf0d28a0cd10e469545e9 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 22 Oct 2026 08:00:50 -0700 Subject: [PATCH] fix(ghidra): reject JDK_JAVA_OPTIONS argument-file platform overrides Fixes #6212. Parse effective argfile syntax once, admit bounded regular files with retained cleanup/cancellation ownership, freeze validated bytes for subsequent helpers and analysis launch, and share existing private-runtime writing. Resolves the continuation, FIFO and mutable-source review findings. Verified 97 related tests (3 explicit skips), full static/docs checks, two independent local Codex reviews and 12 real installed-JDK17 grammar/property controls. Real Ghidra, JDK25 and native Windows qualification remain unavailable. Used the explicitly authorized maintainer bypass: current GitHub CI remained queued and Greptile rerun was still in progress; author-readiness metadata was not fabricated. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- docs/installation.md | 12 +- src/ghidra/GhidraJavaLaunch.ts | 82 ++--- src/ghidra/GhidraJavaOptions.ts | 278 ++++++++++++++ src/ghidra/GhidraLauncher.ts | 31 +- src/ghidra/GhidraRuntimeFile.ts | 16 + .../providers/ghidra/ghidraJavaLaunch.test.ts | 347 +++++++++++++++++- 6 files changed, 706 insertions(+), 60 deletions(-) create mode 100644 src/ghidra/GhidraJavaOptions.ts create mode 100644 src/ghidra/GhidraRuntimeFile.ts diff --git a/docs/installation.md b/docs/installation.md index d0d4e51d26..dc331ac712 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -923,7 +923,17 @@ It also checks that the reported JVM `os.arch` and `os.name` map to the same nat directory validated for the host; unrecognized reported architectures or a non-host operating system are refused. LaunchSupport overrides of either property, including through helper-environment JVM option variables, are -rejected at launch. +rejected at launch. `JDK_JAVA_OPTIONS` argument files (`@file`) are expanded +and inspected with that same check using the Java launcher's argfile quoting, +comments and continuation rules. The reader admits regular UTF-8 files, retains +failed descriptor cleanup, and bounds all admitted file contents and the prepared +environment to 64 KiB per launch. Unreadable, oversized or nested `@` references +are refused before the analysis JVM starts. Validated bytes are snapshotted under +the owned runtime root, so later helpers and the analysis JVM do not reopen mutable +caller files. Repeated references share that launch's snapshot; no live state is +cached across launches. `JAVA_TOOL_OPTIONS` +and `_JAVA_OPTIONS` do not expand `@file` tokens, so they remain +literal-token checks. When Java is found through `PATH`, setup records its observed JDK home so GUI MCP clients do not depend on an incidental shell path. Setup shows every exact environment entry in its plan, writes only after approval, and never downloads, diff --git a/src/ghidra/GhidraJavaLaunch.ts b/src/ghidra/GhidraJavaLaunch.ts index 5bd95303f5..31923bcbfb 100644 --- a/src/ghidra/GhidraJavaLaunch.ts +++ b/src/ghidra/GhidraJavaLaunch.ts @@ -1,5 +1,12 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { dirname, join } from "node:path"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + jvmEnvironmentArguments, + prepareJdkJavaOptions, + rejectJvmPlatformOverrides, +} from "./GhidraJavaOptions.js"; import { execFileOutput, ExecFileOutputCancelledError, @@ -8,7 +15,7 @@ import { } from "../process/ExecFileOutput.js"; export const GHIDRA_LAUNCH_SUPPORT_CAPTURE_BYTES = 64 * 1024; -type LaunchSupportPhase = "-envvars" | "-vmargs"; +type LaunchSupportPhase = "-envvars" | "-vmargs" | "JDK_JAVA_OPTIONS"; type LaunchSupportOutcome = Omit< ExecFileOutputFailure, "killed" | "outputTruncated" @@ -27,7 +34,9 @@ export class GhidraLaunchSupportError extends Error { this.cancellation = cause instanceof ExecFileOutputCancelledError ? { reason: cause.cause } - : undefined; + : cause instanceof ArtifactReaderFailure + ? cause.cancellation + : undefined; const outcome = execFileOutputFailure(cause); this.outcome = outcome === undefined @@ -41,38 +50,6 @@ export class GhidraLaunchSupportError extends Error { } } -/** JVM option environments remove quotes, including quotes inside an option. */ -const jvmEnvironmentArguments = (value: string): readonly string[] => { - const arguments_: string[] = []; - let argument = ""; - let quote: "'" | '"' | undefined; - for (const character of value) { - if (quote !== undefined) { - if (character === quote) quote = undefined; - else argument += character; - } else if (character === "'" || character === '"') quote = character; - else if (/\s/u.test(character)) { - if (argument !== "") arguments_.push(argument); - argument = ""; - } else argument += character; - } - if (quote !== undefined) - throw new Error( - "Invalid quoted Ghidra LaunchSupport JVM environment option", - ); - if (argument !== "") arguments_.push(argument); - return arguments_; -}; - -const rejectJvmPlatformOverrides = (arguments_: readonly string[]): void => { - if ( - arguments_.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument)) - ) - throw new Error( - "Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.", - ); -}; - /** Launch coordinates obtained from Ghidra's own release configuration helper. */ export const ghidraJavaLaunch = async (options: { readonly platform: NodeJS.Platform; @@ -84,6 +61,8 @@ export const ghidraJavaLaunch = async (options: { readonly arguments: readonly string[]; readonly environment: NodeJS.ProcessEnv; readonly signal?: AbortSignal; + readonly resourceScope?: ArtifactResourceScope; + readonly argumentFileRoot?: string; readonly onStderr: (mode: "-envvars" | "-vmargs", stderr: string) => void; }): Promise<{ readonly command: string; @@ -91,6 +70,8 @@ export const ghidraJavaLaunch = async (options: { readonly environment: NodeJS.ProcessEnv; /** REA-configured -Xmx value, not effective or observed JVM memory. */ readonly configuredMaxHeap?: string; + /** Frozen argfile references for wrappers which launch their own JVM. */ + readonly preparedJdkJavaOptions?: string; }> => { const supportRoot = dirname(options.analyzeHeadlessPath); const installRoot = dirname(supportRoot); @@ -126,7 +107,7 @@ export const ghidraJavaLaunch = async (options: { const environment = { ...snapshotEnvironment(options.environment, options.platform), }; - const supportOutput = async (phase: LaunchSupportPhase) => { + const supportOutput = async (phase: "-envvars" | "-vmargs") => { try { return await execFileOutput(command, [...supportArguments, phase], { env: environment, @@ -160,13 +141,31 @@ export const ghidraJavaLaunch = async (options: { // JVM applies these options variables automatically. An injected // -Dos.arch/-Dos.name there selects an unvalidated native directory without // appearing in vmArguments, so reject the effective values as well. - for (const key of [ - "JAVA_TOOL_OPTIONS", - "_JAVA_OPTIONS", - "JDK_JAVA_OPTIONS", - ] as const) { + // JDK_JAVA_OPTIONS is the only helper variable the launched JVM expands as + // argument files. Inspect those effective tokens with the same + // platform-override policy. JAVA_TOOL_OPTIONS and _JAVA_OPTIONS treat @file + // as an unrecognized option rather than expanding it. + for (const key of ["JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS"] as const) rejectJvmPlatformOverrides(jvmEnvironmentArguments(environment[key] ?? "")); - } + const preparedJdkJavaOptions = await prepareJdkJavaOptions( + environment.JDK_JAVA_OPTIONS ?? "", + { + ...(options.resourceScope === undefined + ? {} + : { resources: options.resourceScope }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.argumentFileRoot === undefined + ? {} + : { directory: options.argumentFileRoot }), + platform: options.platform, + }, + ).catch((cause: unknown) => { + if (cause instanceof ArtifactReaderFailure) + throw new GhidraLaunchSupportError("JDK_JAVA_OPTIONS", cause); + throw cause; + }); + if (preparedJdkJavaOptions !== undefined) + environment.JDK_JAVA_OPTIONS = preparedJdkJavaOptions; const configuredArguments = await supportOutput("-vmargs"); options.onStderr("-vmargs", configuredArguments.stderr); const vmArguments = configuredArguments.stdout @@ -183,6 +182,7 @@ export const ghidraJavaLaunch = async (options: { command, environment, configuredMaxHeap, + ...(preparedJdkJavaOptions === undefined ? {} : { preparedJdkJavaOptions }), arguments: [ ...vmArguments, ...(options.platform === "darwin" ? ["-Xdock:name=Ghidra-Headless"] : []), diff --git a/src/ghidra/GhidraJavaOptions.ts b/src/ghidra/GhidraJavaOptions.ts new file mode 100644 index 0000000000..251d2a4e1d --- /dev/null +++ b/src/ghidra/GhidraJavaOptions.ts @@ -0,0 +1,278 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { withArtifactRegularFile } from "../artifacts/ArtifactFileOperation.js"; +import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; +import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js"; + +/** One launch's admitted argument-file contents and expanded environment stay within the helper capture budget. */ +const OPTIONS_BYTES = 64 * 1024; + +/** Environment quoting does not have the escape/comment rules of an argument file. */ +export const jvmEnvironmentArguments = (value: string): readonly string[] => { + const args: string[] = []; + let token = ""; + let active = false; + let quote: "'" | '"' | undefined; + for (const character of value) { + if (quote !== undefined) { + if (character === quote) quote = undefined; + else token += character; + } else if (character === "'" || character === '"') { + quote = character; + active = true; + } else if (/[ \t\n\r\f\v]/u.test(character)) { + if (active) args.push(token); + token = ""; + active = false; + } else { + token += character; + active = true; + } + } + if (quote !== undefined) + throw new Error( + "Invalid quoted Ghidra LaunchSupport JVM environment option", + ); + if (active) args.push(token); + return args; +}; + +export const rejectJvmPlatformOverrides = (args: readonly string[]): void => { + if (args.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument))) + throw new Error( + "Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.", + ); +}; + +/** Java launcher argfile grammar: quotes, escapes inside quotes, comments and line continuation are one parse. */ +const argumentFileTokens = (value: string): readonly string[] => { + const args: string[] = []; + let prefix = "", + segment = ""; + let quote: "'" | '"' | undefined; + let comment = false, + active = false; + const finish = (): void => { + if (active) args.push(prefix + segment); + prefix = ""; + segment = ""; + active = false; + quote = undefined; + }; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]!; + if (comment) { + if (character === "\r" || character === "\n") comment = false; + continue; + } + if (quote !== undefined && character === "\\") { + const next = value[++index]; + if (next === undefined) { + active = false; + break; + } + prefix += segment; + segment = ""; + if (next === "\n" || next === "\r") { + while (/[ \t\n\r\f]/u.test(value[index + 1] ?? "")) index += 1; + if (index + 1 === value.length) { + active = false; + break; + } + } else + prefix += + ( + { n: "\n", r: "\r", t: "\t", f: "\f" } as Readonly< + Record + > + )[next] ?? next; + } else if ( + character === "\r" || + character === "\n" || + (quote === undefined && /[ \t\f]/u.test(character)) + ) { + if (active) finish(); + } else if (quote === undefined && character === "#") { + // Unquoted text adjoining a comment is discarded by the launcher; completed quoted fragments remain pending. + segment = ""; + active = false; + comment = true; + } else if ( + (character === "'" || character === '"') && + (quote === undefined || quote === character) + ) { + prefix += segment; + segment = ""; + active = true; + quote = quote === undefined ? character : undefined; + } else { + segment += character; + active = true; + } + } + finish(); + return args; +}; + +const fileReference = (value: string): boolean => + value.startsWith("@") && !value.startsWith("@@"); +/** JDK environment quotes concatenate; protect literal quote characters without shell interpretation. */ +const environmentToken = (value: string): string => + value + .split("'") + .map((part) => `'${part}'`) + .join('"\'"'); + +/** The file owner retains read/parse failure independently of final-close failure. */ +const readArgumentFile = ( + path: string, + resources: ArtifactResourceScope, + signal: AbortSignal | undefined, + remainingBytes: number, +): Promise<{ + readonly text: string; + readonly bytes: number; + readonly disablesExpansion: boolean; +}> => + withArtifactRegularFile( + path, + resources, + signal, + async (file) => { + const data = await readBoundedFileBytes(file, remainingBytes, { + signal, + cancellationFailure: (reason) => + new RegularFileOperationCancelled(reason), + }); + if (data === undefined) + throw new Error( + `JDK_JAVA_OPTIONS argument files exceed the ${String(OPTIONS_BYTES)}-byte cumulative admission bound.`, + ); + const text = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(data); + const tokens = argumentFileTokens(text); + if (tokens.some(fileReference)) + throw new Error( + "Ghidra LaunchSupport JDK_JAVA_OPTIONS argument files may not nest @ references.", + ); + rejectJvmPlatformOverrides(tokens); + return { + text, + bytes: data.length, + disablesExpansion: tokens.includes("--disable-@files"), + }; + }, + "follow", + ).catch((cause: unknown) => { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + throw new Error( + `Ghidra LaunchSupport JDK_JAVA_OPTIONS argument file is unreadable: ${path}`, + { cause }, + ); + throw cause; + }); + +/** Freeze validated bytes under the already owned runtime root; the JVM must not reopen a mutable caller file. */ +export const prepareJdkJavaOptions = async ( + value: string, + options: { + readonly resources?: ArtifactResourceScope; + readonly signal?: AbortSignal; + readonly directory?: string; + readonly platform: NodeJS.Platform; + }, +): Promise => { + const assertActive = (): void => { + if (options.signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "JDK_JAVA_OPTIONS preparation cancelled", + { cause: options.signal.reason }, + ); + }; + assertActive(); + const args = jvmEnvironmentArguments(value); + rejectJvmPlatformOverrides(args); + const disabledAt = args.indexOf("--disable-@files"); + if ( + !args.some( + (argument, index) => + fileReference(argument) && (disabledAt < 0 || index < disabledAt), + ) + ) + return undefined; + const resources = options.resources; + const directory = options.directory; + if (resources === undefined || directory === undefined) + throw new Error( + "JDK_JAVA_OPTIONS argument files require a caller-owned resource scope and runtime root.", + ); + const snapshots = new Map< + string, + { + readonly path: string; + readonly bytes: number; + readonly disablesExpansion: boolean; + } + >(); + const prepared: string[] = []; + let expansionEnabled = true; + let bytes = 0, + encodedBytes = 0; + for (const argument of args) { + assertActive(); + if (argument === "--disable-@files") expansionEnabled = false; + let replacement = argument; + if (expansionEnabled && fileReference(argument)) { + const path = argument.slice(1); + let snapshot = snapshots.get(path); + if (snapshot === undefined) { + if (path === "" || path.includes("\0")) + throw new Error( + "Ghidra LaunchSupport JDK_JAVA_OPTIONS argument file is unreadable.", + ); + const content = await readArgumentFile( + path, + resources, + options.signal, + OPTIONS_BYTES - bytes, + ); + bytes += content.bytes; + snapshot = { + path: join(directory, `rea-jdk-options-${randomUUID()}.args`), + bytes: content.bytes, + disablesExpansion: content.disablesExpansion, + }; + await writeGhidraRuntimeFile( + snapshot.path, + content.text, + options.platform, + ); + snapshots.set(path, snapshot); + } else { + bytes += snapshot.bytes; + if (bytes > OPTIONS_BYTES) + throw new Error( + `JDK_JAVA_OPTIONS argument files exceed the ${String(OPTIONS_BYTES)}-byte cumulative admission bound.`, + ); + } + replacement = `@${snapshot.path}`; + if (snapshot.disablesExpansion) expansionEnabled = false; + } + const encoded = environmentToken(replacement); + encodedBytes += + Buffer.byteLength(encoded) + (prepared.length === 0 ? 0 : 1); + if (encodedBytes > OPTIONS_BYTES) + throw new Error( + `Prepared JDK_JAVA_OPTIONS exceeds the ${String(OPTIONS_BYTES)}-byte environment admission bound.`, + ); + prepared.push(encoded); + } + assertActive(); + return prepared.join(" "); +}; diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index ca10a16e8f..ebbfd89464 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -9,7 +9,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { mkdir, realpath } from "node:fs/promises"; import { basename, dirname, join, posix, win32 } from "node:path"; -import writeFileAtomic from "write-file-atomic"; +import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js"; import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js"; import { @@ -423,6 +423,10 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { : { javaVmName: this.options.javaVmName }), homeRoot: paths.homeRoot, tempRoot: paths.tempRoot, + argumentFileRoot: session.runtimeRoot, + ...(options.resourceScope === undefined + ? {} + : { resourceScope: options.resourceScope }), arguments: headlessArguments, environment, onStderr: (mode, stderr) => { @@ -453,7 +457,18 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { : inspectedJavaLaunch.configuredMaxHeap; const command = platform === "win32" - ? { ...scriptCommand, environment } + ? { + ...scriptCommand, + environment: { + ...environment, + ...(inspectedJavaLaunch.preparedJdkJavaOptions === undefined + ? {} + : { + JDK_JAVA_OPTIONS: + inspectedJavaLaunch.preparedJdkJavaOptions, + }), + }, + } : inspectedJavaLaunch; started = await (this.options.spawnProcess ?? spawnOwnedProviderProcess)({ command: command.command, @@ -922,18 +937,6 @@ const createGhidraRuntimeDirectories = async ( ); }; -const writeGhidraRuntimeFile = async ( - path: string, - content: string, - platform: NodeJS.Platform, -): Promise => { - if (platform === "win32") { - windowsPrivateRuntime(dirname(path)).writeFile(basename(path), content); - return; - } - await writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 }); -}; - const ghidraLaunchEnvironment = ( paths: ReturnType, javaHome: string | undefined, diff --git a/src/ghidra/GhidraRuntimeFile.ts b/src/ghidra/GhidraRuntimeFile.ts new file mode 100644 index 0000000000..0abc519d4e --- /dev/null +++ b/src/ghidra/GhidraRuntimeFile.ts @@ -0,0 +1,16 @@ +import { basename, dirname } from "node:path"; +import writeFileAtomic from "write-file-atomic"; +import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js"; + +/** Runtime-owned configuration text: private native handle on Windows, private atomic write on POSIX. */ +export const writeGhidraRuntimeFile = async ( + path: string, + content: string, + platform: NodeJS.Platform, +): Promise => { + if (platform === "win32") { + windowsPrivateRuntime(dirname(path)).writeFile(basename(path), content); + return; + } + await writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 }); +}; diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 9a48b29123..5611f27f8c 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -1,14 +1,20 @@ import { ok as javaProbeOk } from "../../../../src/domain/result.js"; import type { GhidraJavaObservation } from "../../../../src/ghidra/GhidraJavaProbe.js"; -import { mkdir, stat, writeFile } from "node:fs/promises"; +import { mkdir, stat, writeFile, readFile, readdir } from "node:fs/promises"; import { createHash, randomUUID } from "node:crypto"; import { join } from "node:path"; -import { describe, expect, it, onTestFinished } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFileOperation.js"; +import { GhidraLaunchSupportError } from "../../../../src/ghidra/GhidraJavaLaunch.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; +import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { createGhidraDiagnostics } from "../../../../src/ghidra/GhidraDiagnostics.js"; import { GhidraHeadlessLauncher, + GhidraLaunchError, GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES, type GhidraLaunchSession, } from "../../../../src/ghidra/GhidraLauncher.js"; @@ -16,6 +22,7 @@ import { bindGhidraSessionFailure } from "../../../../src/ghidra/GhidraSessionEr import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; import { waitForAbortableDelay } from "../../../../src/process/ProviderDeadline.js"; +import { execFileOutput } from "../../../../src/process/ExecFileOutput.js"; import { GhidraProvider } from "../../../../src/ghidra/GhidraProvider.js"; import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; import { parseConfig } from "../../../../src/config/parseConfig.js"; @@ -52,8 +59,9 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { process.stderr.write("failed " + probe + "\\n"); process.exitCode = 3; } else if (probe === "-envvars") { - process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n"); + process.stdout.write(mode === "env-malformed" ? "invalid environment\\n" : mode.startsWith("env-injection:") ? mode.slice("env-injection:".length) + "\\n" : mode.startsWith("env-race:") ? "JDK_JAVA_OPTIONS=@" + mode.slice("env-race:".length) + "\\n" : "GHIDRA_MAXMEM=1G\\n"); } else { + if (mode.startsWith("env-race:")) require("node:fs").writeFileSync(mode.slice("env-race:".length), "-Dos.arch=changed_after_admission\\n"); const vmArguments = mode === "vm-malformed" ? "invalid JVM argument\\n" : mode === "os-arch-override" @@ -80,6 +88,9 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { profileDigest: "test", }; const analysisArguments: (readonly string[])[] = []; + const analysisEnvironments: NodeJS.ProcessEnv[] = []; + const resources = new ArtifactResourceScope(); + onTestFinished(() => resources.close()); const controller = new AbortController(); const launcher = new GhidraHeadlessLauncher({ environment: { PATH: process.env.PATH ?? "" }, @@ -91,6 +102,7 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { bridgeScriptPath: join(root, "ReaGhidraBridge.java"), spawnProcess: async (options) => { analysisArguments.push(options.arguments); + analysisEnvironments.push(options.env ?? {}); const spawned = await spawnOwnedProviderProcess({ ...options, command: process.execPath, @@ -110,7 +122,14 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) { return spawned; }, }); - return { session, launcher, analysisArguments, controller }; + return { + session, + launcher, + analysisArguments, + analysisEnvironments, + controller, + resources, + }; }; describe.skipIf(process.platform === "win32")( @@ -411,6 +430,326 @@ describe.skipIf(process.platform === "win32")( }, ); +describe.skipIf(process.platform === "win32")( + "Ghidra LaunchSupport JDK_JAVA_OPTIONS argument files", + () => { + it.each([ + '-Dnote="prefix\\"#suffix" -Dos.arch=rea_argfile_probe\n', + '"-Dos.ar\\\n ch=rea_argfile_probe"\n', + '"-Dos.ar"discard#comment\n ch=rea_argfile_probe\n', + ])( + "rejects effective platform overrides after argfile quoting and continuation: %s", + async (text) => { + const root = await createTestTempDirectory("rea-ghidra-jdk-grammar-"); + const optionsFile = join(root, "platform.options"); + await writeFile(optionsFile, text); + const { session, launcher, analysisArguments, resources } = + await fixture(`env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`); + const launched = await launcher.launch(session, { + resourceScope: resources, + }); + expect(launched.ok).toBe(false); + expect(analysisArguments).toEqual([]); + if (launched.ok) + throw new Error("Expected an effective platform override refusal"); + expect(launched.error.cause).toMatchObject({ + message: expect.stringContaining( + "may not override os.arch or os.name", + ), + }); + }, + ); + + it("rejects argument-file platform overrides before starting analysis", async () => { + const root = await createTestTempDirectory("rea-ghidra-jdk-argfile-"); + const optionsFile = join(root, "platform.options"); + await writeFile(optionsFile, "-Dos.arch=rea_argfile_probe\n"); + const { session, launcher, analysisArguments, resources } = await fixture( + `env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`, + ); + const launched = await launcher.launch(session, { + resourceScope: resources, + }); + expect(launched.ok).toBe(false); + expect(analysisArguments).toEqual([]); + if (launched.ok) + throw new Error("Expected a JVM platform override failure"); + expect(launched.error.cause).toMatchObject({ + message: expect.stringContaining("may not override os.arch or os.name"), + }); + }); + + it("admits an argument file that does not override the platform", async () => { + const root = await createTestTempDirectory("rea-ghidra-jdk-argfile-ok-"); + const optionsFile = join(root, "platform.options"); + await writeFile(optionsFile, "-Dfile.encoding=UTF8\n"); + const { session, launcher, analysisArguments, resources } = await fixture( + `env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`, + ); + const launched = await launcher.launch(session, { + resourceScope: resources, + }); + if (!launched.ok) throw launched.error; + expect(analysisArguments).toHaveLength(1); + }); + + it("rejects an unreadable argument file before starting analysis", async () => { + const root = await createTestTempDirectory( + "rea-ghidra-jdk-argfile-missing-", + ); + const optionsFile = join(root, "missing.options"); + const { session, launcher, analysisArguments, resources } = await fixture( + `env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`, + ); + const launched = await launcher.launch(session, { + resourceScope: resources, + }); + expect(launched.ok).toBe(false); + expect(analysisArguments).toEqual([]); + if (launched.ok) + throw new Error("Expected an unreadable argument-file failure"); + expect(launched.error.cause).toMatchObject({ + message: expect.stringContaining("argument file is unreadable"), + }); + }); + + it("rejects nested argument-file references before starting analysis", async () => { + const root = await createTestTempDirectory( + "rea-ghidra-jdk-argfile-nested-", + ); + const nested = join(root, "nested.options"); + const optionsFile = join(root, "platform.options"); + await writeFile(nested, "-Dfile.encoding=UTF8\n"); + await writeFile(optionsFile, `@${nested}\n`); + const { session, launcher, analysisArguments, resources } = await fixture( + `env-injection:JDK_JAVA_OPTIONS=@${optionsFile}`, + ); + const launched = await launcher.launch(session, { + resourceScope: resources, + }); + expect(launched.ok).toBe(false); + expect(analysisArguments).toEqual([]); + if (launched.ok) + throw new Error("Expected a nested argument-file failure"); + expect(launched.error.cause).toMatchObject({ + message: expect.stringContaining("may not nest @ references"), + }); + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "Ghidra argument-file admission", + () => { + it.each([false, true])( + "keeps @ values literal after expansion was disabled by a file: %s", + async (fromFile) => { + const root = await createTestTempDirectory( + "rea-ghidra-options-disabled-", + ); + const path = join(root, "options.args"), + missing = join(root, "literal-classpath"); + await writeFile(path, "--disable-@files\n"); + const value = `${fromFile ? `@${path}` : "--disable-@files"} --class-path @${missing}`; + const { session, launcher, resources, analysisEnvironments } = + await fixture(`env-injection:JDK_JAVA_OPTIONS=${value}`); + const result = await launcher.launch(session, { + resourceScope: resources, + }); + if (!result.ok) throw result.error; + expect(analysisEnvironments[0]?.JDK_JAVA_OPTIONS).toContain(missing); + const snapshots = (await readdir(session.runtimeRoot)).filter((name) => + name.startsWith("rea-jdk-options-"), + ); + expect(snapshots).toHaveLength(fromFile ? 1 : 0); + }, + ); + + it.each([100, 40000])( + "counts repeated references within the cumulative byte budget: %s", + async (size) => { + const root = await createTestTempDirectory( + "rea-ghidra-options-repeat-", + ); + const path = join(root, "options.args"); + await writeFile(path, " ".repeat(size)); + const { session, launcher, resources, analysisArguments } = + await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path} @${path}`); + const result = await launcher.launch(session, { + resourceScope: resources, + }); + expect(result.ok).toBe(size * 2 <= 65536); + expect(analysisArguments).toHaveLength(result.ok ? 1 : 0); + const snapshots = (await readdir(session.runtimeRoot)).filter((name) => + name.startsWith("rea-jdk-options-"), + ); + expect(snapshots).toHaveLength(1); + }, + ); + + it.each([ + { label: "oversized", content: Buffer.alloc(65537, 32) }, + { label: "invalid UTF-8", content: Buffer.from([255]) }, + ])( + "refuses $label input without starting analysis", + async ({ content }) => { + const root = await createTestTempDirectory( + "rea-ghidra-options-admission-", + ); + const path = join(root, "options.args"); + await writeFile(path, content); + const { session, launcher, resources, analysisArguments } = + await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path}`); + const result = await launcher.launch(session, { + resourceScope: resources, + }); + expect(result.ok).toBe(false); + expect(analysisArguments).toEqual([]); + expect(resources.hasPending).toBe(false); + }, + ); + + it("refuses a named pipe without blocking an owned file read", async () => { + const root = await createTestTempDirectory("rea-ghidra-options-pipe-"); + const path = join(root, "options.args"); + await execFileOutput("mkfifo", [path], { maxBuffer: 1024 }); + const { session, launcher, resources, analysisArguments } = await fixture( + `env-injection:JDK_JAVA_OPTIONS=@${path}`, + ); + const result = await launcher.launch(session, { + resourceScope: resources, + }); + expect(result.ok).toBe(false); + expect(analysisArguments).toEqual([]); + expect(resources.hasPending).toBe(false); + }, 5000); + }, +); + +describe.skipIf(process.platform === "win32")( + "Ghidra argument-file ownership", + () => { + it("launches with admitted bytes rather than reopening a file changed by the next helper", async () => { + const root = await createTestTempDirectory( + "rea-ghidra-options-snapshot-", + ); + const path = join(root, "options.args"); + const content = '-Dnote="retained\\"#literal"\n'; + await writeFile(path, content); + const { session, launcher, resources, analysisEnvironments } = + await fixture(`env-race:${path}`); + const result = await launcher.launch(session, { + resourceScope: resources, + }); + if (!result.ok) throw result.error; + expect(await readFile(path, "utf8")).toContain("changed_after_admission"); + expect(analysisEnvironments[0]?.JDK_JAVA_OPTIONS).not.toContain(path); + const snapshots = (await readdir(session.runtimeRoot)).filter((name) => + name.startsWith("rea-jdk-options-"), + ); + expect(snapshots).toHaveLength(1); + expect( + await readFile(join(session.runtimeRoot, snapshots[0]!), "utf8"), + ).toBe(content); + }); + + it.each([false, true])( + "retains the exact failed close owner alongside an invalid-read fact: %s", + async (invalid) => { + const root = await createTestTempDirectory("rea-ghidra-options-close-"); + const path = join(root, "options.args"); + await writeFile( + path, + invalid ? "-Dos.arch=invalid\n" : "-Dnote=valid\n", + ); + const { session, launcher, resources, analysisArguments } = + await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path}`); + const close = OwnedFileHandle.prototype.close; + let denied: OwnedFileHandle | undefined; + const calls: OwnedFileHandle[] = []; + const spy = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + calls.push(this); + if (denied === undefined) { + denied = this; + throw new Error("controlled file close denial"); + } + await close.call(this); + }); + try { + const result = await launcher.launch(session, { + resourceScope: resources, + }); + expect(result.ok).toBe(false); + expect(analysisArguments).toEqual([]); + expect(resources.hasPending).toBe(true); + if ( + result.ok || + !(result.error.cause instanceof GhidraLaunchSupportError) + ) + throw new Error("Expected a retained argument-file failure"); + const cleanup = result.error.cause.cause; + if (!(cleanup instanceof ArtifactFileCleanupFailure)) + throw new Error("Expected the exact artifact file owner"); + expect(cleanup.fileCleanupFailure.owner).toBe(denied); + expect(cleanup.fileCleanupFailure.outcome.kind).toBe( + invalid ? "failed" : "completed", + ); + if (cleanup.fileCleanupFailure.outcome.kind === "failed") + expect(cleanup.fileCleanupFailure.outcome.cause).toMatchObject({ + message: expect.stringContaining( + "may not override os.arch or os.name", + ), + }); + await resources.retryCleanup(); + expect(resources.hasPending).toBe(false); + expect(calls).toEqual([denied, denied]); + } finally { + spy.mockRestore(); + await resources.close(); + } + }, + ); + + it("preserves an abort observed during final descriptor close", async () => { + const root = await createTestTempDirectory( + "rea-ghidra-options-late-abort-", + ); + const path = join(root, "options.args"); + await writeFile(path, "-Dnote=valid\n"); + const { session, launcher, resources, analysisArguments, controller } = + await fixture(`env-injection:JDK_JAVA_OPTIONS=@${path}`); + const reason = { phase: "argument-file close" }; + const close = OwnedFileHandle.prototype.close; + const spy = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + await close.call(this); + controller.abort(reason); + }); + try { + const result = await launcher.launch(session, { + resourceScope: resources, + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok || !(result.error instanceof GhidraLaunchError)) + throw new Error("Expected a typed launch cancellation"); + expect(result.error.primaryFailure).toBeInstanceOf( + AnalysisCancelledError, + ); + expect(result.error.primaryFailure?.cause).toBe(reason); + expect(analysisArguments).toEqual([]); + expect(resources.hasPending).toBe(false); + } finally { + spy.mockRestore(); + await resources.close(); + } + }); + }, +); + describe.skipIf(process.platform === "win32")( "Ghidra startup interruption diagnostics", () => {