From bc0fb45f16db97d3f79860d850c9f2fe0f6b7b22 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Fri, 17 Jul 2026 00:54:54 +0800 Subject: [PATCH] feat(ghidra): launch bounded Windows headless sessions Invoke analyzeHeadless.bat through an absolute cmd.exe with fixed interpreter switches, quoted arguments, delayed expansion disabled, and fail-closed metacharacter and length checks. Isolate Windows home and temporary coordinates, hide provider windows, and terminate the P0 launcher tree through bounded taskkill cleanup. Advertise explicit limitations that this preview does not provide Job Object ownership, DACL enforcement, reparse-point authority, or hostile-target safety. --- scripts/verify-real-ghidra.mjs | 1 + src/ghidra/GhidraLauncher.ts | 151 ++++++++++++++++++++----- src/ghidra/GhidraProvider.ts | 27 ++++- src/process/ProcessOwnership.ts | 50 ++++++++ src/process/ProviderProcess.ts | 1 + tests/fixtures/captureGhidraLaunch.cmd | 2 + tests/fixtures/captureGhidraLaunch.mjs | 3 + tests/ghidraLauncher.test.ts | 82 ++++++++++++-- tests/ghidraProvider.test.ts | 11 ++ tests/processOwnership.test.ts | 38 +++++++ 10 files changed, 325 insertions(+), 41 deletions(-) create mode 100644 tests/fixtures/captureGhidraLaunch.cmd diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index f76fbb0ed..5b03d2edd 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -187,6 +187,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) { bridgeScriptPath: fileURLToPath( new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url), ), + platform: installation.platform, }), targetPath: parsedTarget.value.path, targetSha256: parsedTarget.value.sha256, diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index ee7929d08..18e5c86e3 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -1,11 +1,15 @@ import { mkdir } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, win32 } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { AnalysisCancelledError } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; -import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; +import { + cleanupOwnedProcessGroup, + cleanupWindowsProcessTree, + type ProcessCleanupResult, +} from "../process/ProcessOwnership.js"; import { type ProviderProcessLaunch, type SpawnedOwnedProviderProcess, @@ -60,6 +64,8 @@ export interface GhidraHeadlessLauncherOptions { readonly analyzeHeadlessPath: string; readonly javaHome?: string; readonly bridgeScriptPath: string; + readonly platform?: NodeJS.Platform; + readonly comSpec?: string; } /** Launch Ghidra without copying scripts into or modifying its installation. */ @@ -73,6 +79,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { if (isAborted(options.signal)) return err(new AnalysisCancelledError("open_binary")); const paths = ghidraRuntimePaths(session.runtimeRoot); + const platform = this.options.platform ?? process.platform; let started: SpawnedOwnedProviderProcess | undefined; try { await Promise.all( @@ -101,21 +108,30 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ); if (isAborted(options.signal)) return err(new AnalysisCancelledError("open_binary")); + const headlessArguments = ghidraHeadlessArguments({ + projectRoot: paths.projectRoot, + targetPath: session.targetPath, + bridgeScriptPath: this.options.bridgeScriptPath, + descriptorPath: paths.descriptorPath, + ghidraLogPath: paths.ghidraLogPath, + scriptLogPath: paths.scriptLogPath, + }); + const command = ghidraHeadlessCommand({ + platform, + analyzeHeadlessPath: this.options.analyzeHeadlessPath, + arguments: headlessArguments, + ...(this.options.comSpec === undefined + ? {} + : { comSpec: this.options.comSpec }), + }); started = await spawnOwnedProviderProcess({ - command: this.options.analyzeHeadlessPath, - arguments: ghidraHeadlessArguments({ - projectRoot: paths.projectRoot, - targetPath: session.targetPath, - bridgeScriptPath: this.options.bridgeScriptPath, - descriptorPath: paths.descriptorPath, - ghidraLogPath: paths.ghidraLogPath, - scriptLogPath: paths.scriptLogPath, - }), + command: command.command, + arguments: command.arguments, runId: session.runId, // analyzeHeadless is an interpreter-driven script. Parent identity and // the per-process run token remain the cleanup authority. expectedCommand: null, - env: ghidraLaunchEnvironment(paths, this.options.javaHome), + env: ghidraLaunchEnvironment(paths, this.options.javaHome, platform), }); await writeFileAtomic( paths.ownershipPath, @@ -125,29 +141,31 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { pid: started.ownership.leaderPid, process_group_id: started.ownership.processGroupId, parent_pid: process.pid, + ownership_kind: + platform === "win32" + ? "windows-process-tree-p0" + : "posix-process-group", launcher: this.options.analyzeHeadlessPath, created_at: new Date().toISOString(), })}\n`, { encoding: "utf8", mode: 0o600 }, ); if (isAborted(options.signal)) { - await cleanupOwnedProcessGroup(started.ownership); + await cleanupStartedProcess(started, platform); return err(new AnalysisCancelledError("open_binary")); } - const ownership = started.ownership; + const spawned = started; return ok({ - process: started.process, + process: spawned.process, ownsProcessLifetime: true, - cleanup: () => cleanupOwnedProcessGroup(ownership), + cleanup: () => cleanupStartedProcess(spawned, platform), projectRoot: paths.projectRoot, ghidraLogPath: paths.ghidraLogPath, scriptLogPath: paths.scriptLogPath, }); } catch (cause: unknown) { if (started !== undefined) - await cleanupOwnedProcessGroup(started.ownership).catch( - () => undefined, - ); + await cleanupStartedProcess(started, platform).catch(() => undefined); return isAborted(options.signal) ? err(new AnalysisCancelledError("open_binary")) : err( @@ -157,6 +175,67 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { } } +/** Exact executable and argv passed to shell-free process creation. */ +export interface GhidraHeadlessCommand { + readonly command: string; + readonly arguments: readonly string[]; +} + +/** Build a direct POSIX launch or a conservatively quoted Windows batch call. */ +export const ghidraHeadlessCommand = (options: { + readonly platform: NodeJS.Platform; + readonly analyzeHeadlessPath: string; + readonly arguments: readonly string[]; + readonly comSpec?: string; +}): GhidraHeadlessCommand => { + if (options.platform !== "win32") + return { + command: options.analyzeHeadlessPath, + arguments: [...options.arguments], + }; + const comSpec = + options.comSpec ?? + process.env.ComSpec ?? + (process.env.SystemRoot === undefined + ? "C:\\Windows\\System32\\cmd.exe" + : win32.join(process.env.SystemRoot, "System32", "cmd.exe")); + if ( + !win32.isAbsolute(comSpec) || + win32.basename(comSpec).toLowerCase() !== "cmd.exe" + ) + throw new GhidraLaunchError( + "Windows ComSpec must be an absolute cmd.exe path", + ); + const tokens = [options.analyzeHeadlessPath, ...options.arguments].map( + quoteWindowsBatchToken, + ); + const invocation = `"${tokens.join(" ")}"`; + if (invocation.length > 30_000) + throw new GhidraLaunchError( + "Windows Ghidra command exceeds the P0 length limit", + ); + return { + command: comSpec, + arguments: ["/d", "/e:on", "/v:off", "/s", "/c", invocation], + }; +}; + +const quoteWindowsBatchToken = (value: string): string => { + if (value.length === 0 || /[\u0000\r\n"%^!&|<>]/u.test(value)) + throw new GhidraLaunchError( + "Windows Ghidra P0 paths cannot contain command-interpreter metacharacters", + ); + return `"${value}"`; +}; + +const cleanupStartedProcess = ( + started: SpawnedOwnedProviderProcess, + platform: NodeJS.Platform, +): Promise => + platform === "win32" + ? cleanupWindowsProcessTree(started.ownership.leaderPid) + : cleanupOwnedProcessGroup(started.ownership); + /** Paths encoded into one bounded analyzeHeadless invocation. */ export interface GhidraHeadlessArgumentOptions { readonly projectRoot: string; @@ -208,15 +287,29 @@ const ghidraRuntimePaths = (runtimeRoot: string) => ({ const ghidraLaunchEnvironment = ( paths: ReturnType, javaHome: string | undefined, -): NodeJS.ProcessEnv => ({ - ...ghidraJavaEnvironment(javaHome), - HOME: paths.homeRoot, - TMPDIR: paths.tempRoot, - XDG_CACHE_HOME: paths.cacheRoot, - XDG_CONFIG_HOME: paths.configRoot, - XDG_DATA_HOME: paths.dataRoot, - GHIDRA_HEADLESS_MAXMEM: GHIDRA_MAX_HEAP, - GHIDRA_HEADLESS_JAVA_OPTIONS: `-Duser.home=${paths.homeRoot} -Djava.io.tmpdir=${paths.tempRoot}`, -}); + platform: NodeJS.Platform, +): NodeJS.ProcessEnv => { + const javaOptions = + platform === "win32" + ? `"-Duser.home=${paths.homeRoot}" "-Djava.io.tmpdir=${paths.tempRoot}"` + : `-Duser.home=${paths.homeRoot} -Djava.io.tmpdir=${paths.tempRoot}`; + return { + ...ghidraJavaEnvironment(javaHome, process.env, platform), + HOME: paths.homeRoot, + TMPDIR: paths.tempRoot, + ...(platform === "win32" + ? { + USERPROFILE: paths.homeRoot, + TEMP: paths.tempRoot, + TMP: paths.tempRoot, + } + : {}), + XDG_CACHE_HOME: paths.cacheRoot, + XDG_CONFIG_HOME: paths.configRoot, + XDG_DATA_HOME: paths.dataRoot, + GHIDRA_HEADLESS_MAXMEM: GHIDRA_MAX_HEAP, + GHIDRA_HEADLESS_JAVA_OPTIONS: javaOptions, + }; +}; const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 527f633aa..6cce99a1f 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -109,6 +109,11 @@ const healthLimitations = Object.freeze([ "The session serves operations only after default Ghidra auto-analysis completes; an analysis timeout fails the open instead of exposing partial results.", "The imported Program and temporary project are ephemeral, read-only to REA, and deleted on close.", ]); +const windowsP0Limitations = Object.freeze([ + "Windows Ghidra P0 accepts approved native x86-64 PE applications only; DLL, managed, hostile, sensitive, and mutable-path targets are unsupported.", + "The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.", + "Windows P0 process-tree cleanup uses bounded taskkill termination and does not claim Job Object ownership, private DACL enforcement, or reparse-point-safe path authority.", +]); const limitationsFor = (operation: string): readonly string[] => { const common = [ @@ -228,6 +233,17 @@ const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( }), ), ); +const WINDOWS_P0_CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( + CAPABILITIES.map((capability) => + Object.freeze({ + ...capability, + limitations: Object.freeze([ + ...capability.limitations, + ...windowsP0Limitations, + ]), + }), + ), +); const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]); const SUPPORTED_FORMATS = new Set(["elf", "pe", "mach-o"]); @@ -254,7 +270,9 @@ export class GhidraProvider implements AnalysisProviderCandidate { } capabilities(): readonly CapabilityDescriptor[] { - return CAPABILITIES; + return (this.installationHost?.platform ?? process.platform) === "win32" + ? WINDOWS_P0_CAPABILITIES + : CAPABILITIES; } inspectAvailability(): ProviderAvailability { @@ -345,6 +363,8 @@ export class GhidraProvider implements AnalysisProviderCandidate { ); if (!prerequisites.ok) return unavailableClient(prerequisites.error); const committedProfile = prerequisites.value.profile; + const providerLimitations = + installation.platform === "win32" ? windowsP0Limitations : []; const client = this.clientFactory({ launcher: new GhidraHeadlessLauncher({ analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath, @@ -354,6 +374,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { bridgeScriptPath: fileURLToPath( new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url), ), + platform: installation.platform, }), targetPath: target.path, targetSha256: target.sha256, @@ -386,7 +407,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { return ok( createAnalysisExecution(started.value, committedProfile.provider, { analysisProfile: committedProfile, - limitations: healthLimitations, + limitations: [...healthLimitations, ...providerLimitations], }), ); } @@ -410,7 +431,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { createAnalysisExecution(result.value, committedProfile.provider, { rawResult: called.value, analysisProfile: committedProfile, - limitations: limitationsFor(operation), + limitations: [...limitationsFor(operation), ...providerLimitations], }), ); }, diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 490b3e337..0852aa9f0 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -39,6 +39,11 @@ export interface ProcessOwnershipHost { signalGroup(processGroupId: number, signal: NodeJS.Signals): void; } +/** Narrow Windows P0 seam for bounded process-tree termination. */ +export interface WindowsProcessTreeHost { + terminateTree(rootPid: number): Promise<"terminated" | "missing">; +} + /** Per-member reason that token-verified cleanup failed closed. */ interface ProcessOwnershipValidationFailure { readonly pid: number; @@ -63,6 +68,30 @@ export type ProcessGroupObservation = | { readonly state: "alive" } | { readonly state: "unverifiable"; readonly reason: string }; +/** + * Terminate one Windows process tree through the platform utility. + * + * This is a bounded P0 cleanup mechanism, not Job Object ownership proof. The + * caller-visible Windows limitations must preserve that distinction. + */ +export const cleanupWindowsProcessTree = async ( + rootPid: number, + host: WindowsProcessTreeHost = systemWindowsProcessTreeHost, +): Promise => { + if (!Number.isSafeInteger(rootPid) || rootPid <= 0) + return { cleaned: false, reason: "Windows process-tree PID is invalid" }; + try { + const result = await host.terminateTree(rootPid); + return { cleaned: true, signaled: result === "terminated" }; + } catch { + return { + cleaned: false, + reason: + "Windows P0 process-tree termination failed; Job Object ownership is unavailable", + }; + } +}; + /** Select the PTY root group and groups led by an observed captured process. */ export const selectCapturedProcessGroupIds = ( rootPid: number, @@ -135,6 +164,27 @@ const systemHost: ProcessOwnershipHost = { }, }; +const systemWindowsProcessTreeHost: WindowsProcessTreeHost = { + async terminateTree(rootPid) { + try { + await execFileAsync( + "taskkill.exe", + ["/pid", String(rootPid), "/t", "/f"], + { windowsHide: true, timeout: 5_000 }, + ); + return "terminated"; + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === 128 || cause.code === "ESRCH") + ) + return "missing"; + throw cause; + } + }, +}; + /** Read the capture run token currently exposed by one live process. */ export const readProcessRunId = async ( pid: number, diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index be3b4d01f..6891efff0 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -105,6 +105,7 @@ export const spawnOwnedProviderProcess = async ( ): Promise => { const child = spawn(options.command, [...options.arguments], { shell: false, + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", env: { diff --git a/tests/fixtures/captureGhidraLaunch.cmd b/tests/fixtures/captureGhidraLaunch.cmd new file mode 100644 index 000000000..e73e9b9f0 --- /dev/null +++ b/tests/fixtures/captureGhidraLaunch.cmd @@ -0,0 +1,2 @@ +@echo off +node "%~dp0captureGhidraLaunch.mjs" %* diff --git a/tests/fixtures/captureGhidraLaunch.mjs b/tests/fixtures/captureGhidraLaunch.mjs index 0a5d88598..556c47316 100755 --- a/tests/fixtures/captureGhidraLaunch.mjs +++ b/tests/fixtures/captureGhidraLaunch.mjs @@ -14,7 +14,10 @@ await writeFile( arguments: arguments_, environment: { HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, TMPDIR: process.env.TMPDIR, + TEMP: process.env.TEMP, + TMP: process.env.TMP, XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME, diff --git a/tests/ghidraLauncher.test.ts b/tests/ghidraLauncher.test.ts index 18285fb73..8e30f461c 100644 --- a/tests/ghidraLauncher.test.ts +++ b/tests/ghidraLauncher.test.ts @@ -7,15 +7,23 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { ghidraHeadlessArguments, + ghidraHeadlessCommand, GhidraHeadlessLauncher, } from "../src/ghidra/GhidraLauncher.js"; const fixturePath = fileURLToPath( - new URL("./fixtures/captureGhidraLaunch.mjs", import.meta.url), + new URL( + process.platform === "win32" + ? "./fixtures/captureGhidraLaunch.cmd" + : "./fixtures/captureGhidraLaunch.mjs", + import.meta.url, + ), ); const roots: string[] = []; -beforeAll(() => chmod(fixturePath, 0o755)); +beforeAll(async () => { + if (process.platform !== "win32") await chmod(fixturePath, 0o755); +}); afterEach(async () => { vi.unstubAllEnvs(); await Promise.all( @@ -57,6 +65,48 @@ describe("Ghidra headless launcher", () => { ]); }); + it("wraps the Windows batch launcher without enabling a Node shell", () => { + const command = ghidraHeadlessCommand({ + platform: "win32", + analyzeHeadlessPath: + "C:\\Program Files\\Ghidra 12.1.2\\support\\analyzeHeadless.bat", + arguments: [ + "C:\\REA Runtime\\project", + "rea-project", + "-import", + "C:\\REA Runtime\\target.exe", + ], + comSpec: "C:\\Windows\\System32\\cmd.exe", + }); + + expect(command).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + arguments: [ + "/d", + "/e:on", + "/v:off", + "/s", + "/c", + '""C:\\Program Files\\Ghidra 12.1.2\\support\\analyzeHeadless.bat" "C:\\REA Runtime\\project" "rea-project" "-import" "C:\\REA Runtime\\target.exe""', + ], + }); + }); + + it.each([ + "C:\\targets\\%TEMP%\\fixture.exe", + "C:\\targets\\fixture & calc.exe", + 'C:\\targets\\fixture".exe', + ])("rejects a Windows command-interpreter path: %s", (targetPath) => { + expect(() => + ghidraHeadlessCommand({ + platform: "win32", + analyzeHeadlessPath: "C:\\Ghidra\\support\\analyzeHeadless.bat", + arguments: ["-import", targetPath], + comSpec: "C:\\Windows\\System32\\cmd.exe", + }), + ).toThrow(/metacharacters/u); + }); + it("keeps authority in a private descriptor and isolates Ghidra state", async () => { vi.stubEnv("GHIDRA_JAVA_OPTIONS", "-javaagent:/unapproved/agent.jar"); vi.stubEnv("JAVA_TOOL_OPTIONS", "-Duser.home=/unapproved/home"); @@ -65,9 +115,11 @@ describe("Ghidra headless launcher", () => { const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-launcher-test-")); roots.push(runtimeRoot); const token = "secret-token-that-must-not-leak"; + const javaHome = + process.platform === "win32" ? "C:\\Java\\jdk-21" : "/opt/jdk-21"; const launcher = new GhidraHeadlessLauncher({ analyzeHeadlessPath: fixturePath, - javaHome: "/opt/jdk-21", + javaHome, bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java", }); const launched = await launcher.launch({ @@ -91,10 +143,17 @@ describe("Ghidra headless launcher", () => { expect(encodedArguments).not.toContain(token); expect(encodedEnvironment).not.toContain(token); expect(capture).toMatchObject({ - descriptor_mode: 0o600, + ...(process.platform === "win32" ? {} : { descriptor_mode: 0o600 }), descriptor_has_token: true, environment: { HOME: join(runtimeRoot, "home"), + ...(process.platform === "win32" + ? { + USERPROFILE: join(runtimeRoot, "home"), + TEMP: join(runtimeRoot, "tmp"), + TMP: join(runtimeRoot, "tmp"), + } + : {}), TMPDIR: join(runtimeRoot, "tmp"), XDG_CACHE_HOME: join(runtimeRoot, "cache"), XDG_CONFIG_HOME: join(runtimeRoot, "config"), @@ -104,17 +163,22 @@ describe("Ghidra headless launcher", () => { JAVA_TOOL_OPTIONS: "", JDK_JAVA_OPTIONS: "", _JAVA_OPTIONS: "", - JAVA_HOME: "/opt/jdk-21", + JAVA_HOME: javaHome, REA_PROCESS_RUN_ID: "d6fcbb66-e829-4ff6-a535-0035aec63139", }, }); - expect(capture.environment.PATH).toMatch(/^\/opt\/jdk-21\/bin:/u); + expect(capture.environment.PATH).toMatch( + process.platform === "win32" + ? /^C:\\Java\\jdk-21\\bin;/u + : /^\/opt\/jdk-21\/bin:/u, + ); expect(capture.environment.GHIDRA_HEADLESS_JAVA_OPTIONS).toContain( `-Duser.home=${join(runtimeRoot, "home")}`, ); - expect((await stat(join(runtimeRoot, "ownership.json"))).mode & 0o777).toBe( - 0o600, - ); + if (process.platform !== "win32") + expect( + (await stat(join(runtimeRoot, "ownership.json"))).mode & 0o777, + ).toBe(0o600); const cleaned = await launched.value.cleanup?.(); expect(cleaned).toMatchObject({ cleaned: true }); diff --git a/tests/ghidraProvider.test.ts b/tests/ghidraProvider.test.ts index 41a68999f..414b8747b 100644 --- a/tests/ghidraProvider.test.ts +++ b/tests/ghidraProvider.test.ts @@ -144,6 +144,17 @@ describe("Ghidra provider", () => { managed: false, }, }); + expect(ghidra.capabilities()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "list_procedures", + limitations: expect.arrayContaining([ + expect.stringContaining("bounded taskkill termination"), + expect.stringContaining("private DACL enforcement"), + ]), + }), + ]), + ); expect( ghidra.inspectTargetSupport({ ...nativeApplication, diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts index 1fec52758..19dbb8f0f 100644 --- a/tests/processOwnership.test.ts +++ b/tests/processOwnership.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, + cleanupWindowsProcessTree, observeOwnedProcessGroup, parseProcessEnvironment, selectCapturedProcessGroupIds, type ProcessOwnershipHost, + type WindowsProcessTreeHost, } from "../src/process/ProcessOwnership.js"; const ownership = { @@ -224,3 +226,39 @@ describe("owned process-group cleanup", () => { expect(signalGroup).not.toHaveBeenCalled(); }); }); + +describe("Windows P0 process-tree cleanup", () => { + it("reports whether taskkill signaled or found an exited tree", async () => { + const terminated: WindowsProcessTreeHost = { + terminateTree: () => Promise.resolve("terminated"), + }; + const missing: WindowsProcessTreeHost = { + terminateTree: () => Promise.resolve("missing"), + }; + + await expect(cleanupWindowsProcessTree(42, terminated)).resolves.toEqual({ + cleaned: true, + signaled: true, + }); + await expect(cleanupWindowsProcessTree(42, missing)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + }); + + it("keeps invalid identity and termination failures explicit", async () => { + const failing: WindowsProcessTreeHost = { + terminateTree: () => Promise.reject(new Error("taskkill failed")), + }; + + await expect(cleanupWindowsProcessTree(0, failing)).resolves.toEqual({ + cleaned: false, + reason: "Windows process-tree PID is invalid", + }); + await expect(cleanupWindowsProcessTree(42, failing)).resolves.toEqual({ + cleaned: false, + reason: + "Windows P0 process-tree termination failed; Job Object ownership is unavailable", + }); + }); +});