From 44fedc8fdd6193ef2cfbeb127b9a5cdf65b443df Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:11:59 +0800 Subject: [PATCH] fix(pcap): report a refused tool launch as an unavailable tool (#10553) * fix(pcap): report a refused tool launch as an unavailable tool When a capture read's tool launch was refused with EPERM, pcap reported it as unreadable tool output (AnalysisProtocolError) instead of an unavailable tool. Pcap also never told "not executable" apart from "missing". Add classifyLaunchFailure in src/process as the one owner of which spawn errnos mean missing (ENOENT, ENOTDIR) and which mean present but not executable (EACCES, EPERM, ENOEXEC, EISDIR). Pcap, apktool and gdb used three different errno lists; all three now use it. * refactor(process): classify Java and adb launch refusals with the shared owner (#10598) The Ghidra and JADX Java probes and adb's version probe now take missing-versus-not-executable from classifyLaunchFailure instead of their own errno checks. ELOOP and ENAMETOOLONG join the owner's missing set, so adb keeps its existing classification and both Java probes now treat a non-directory path component as a missing runtime. * fix(pcap): preserve command launch attribution * test(pcap): expect an unknown failing stage when a limited capture tool exits unsuccessfully * fix(pcap): attribute a limited capture's exit through the prlimit wrapper * fix(process): identify the refused executable for every launcher shape Name the executable on errnos Node throws synchronously from spawn (ENOEXEC, ENOTDIR, ...) and read the Windows native launcher's executable-launch constraint, so pcap attributes those refusals to the selected tool or limiter instead of an unknown stage. * test(pcap): prove synchronous launch naming with ENOTDIR on every POSIX host --- src/adb/AdbConfiguration.ts | 7 +- src/android/JadxJavaProbe.ts | 17 +- src/apktool/ApktoolConfiguration.ts | 17 +- src/gdb/GdbSessionManager.ts | 16 +- src/ghidra/GhidraJavaProbe.test.ts | 2 + src/ghidra/GhidraJavaProbe.ts | 6 +- src/pcap/PcapCommand.ts | 88 +++++++- src/pcap/PcapNativeBound.ts | 43 +++- src/process/ProviderProcess.ts | 24 ++- src/process/launchFailure.test.ts | 79 ++++++++ src/process/launchFailure.ts | 99 +++++++++ src/windows/WindowsOwnedProcess.ts | 19 ++ tests/boundary/pcap/pcapCommand.test.ts | 256 +++++++++++++++++++++++- 13 files changed, 622 insertions(+), 51 deletions(-) create mode 100644 src/process/launchFailure.test.ts create mode 100644 src/process/launchFailure.ts diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts index f0ffcb190c..77281230a9 100644 --- a/src/adb/AdbConfiguration.ts +++ b/src/adb/AdbConfiguration.ts @@ -20,7 +20,7 @@ import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import { isUnresolvedPathErrorCode } from "../filesystem/RegularFile.js"; +import { classifyLaunchFailureCode } from "../process/launchFailure.js"; /** Environment key selecting the caller's adb binary. */ export const ADB_BINARY_ENV = "REA_ADB_PATH"; @@ -267,8 +267,9 @@ export const inspectAdbClient = async ( path_source: selection.pathSource, filesystem_error: code, }; + const refusal = classifyLaunchFailureCode(code); // Nothing executable resolves at the selected path. - if (isUnresolvedPathErrorCode(code)) + if (refusal?.kind === "missing") throw new AdbConfigurationFailure( "binary_missing", `The selected adb binary was not found: ${selection.binary}`, @@ -288,7 +289,7 @@ export const inspectAdbClient = async ( ); throw new AdbConfigurationFailure( "binary_not_executable", - code === "EACCES" || code === "EPERM" + refusal?.code === "EACCES" || refusal?.code === "EPERM" ? `Execution of the selected adb binary was denied (${code}): ${selection.binary}` : `The selected adb binary could not be started (${code}): ${selection.binary}`, code === "EACCES" diff --git a/src/android/JadxJavaProbe.ts b/src/android/JadxJavaProbe.ts index dc10438dfa..33be77ef85 100644 --- a/src/android/JadxJavaProbe.ts +++ b/src/android/JadxJavaProbe.ts @@ -1,4 +1,5 @@ import type { ProcessInspectionFailure } from "../process/ProcessInspectionCleanup.js"; +import { classifyLaunchFailureCode } from "../process/launchFailure.js"; import { randomUUID } from "node:crypto"; import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { @@ -284,21 +285,21 @@ const commandProbeRefusal = ( command?.processError ?? (cause instanceof Error ? cause : undefined); const systemCode = processError === undefined ? undefined : Reflect.get(processError, "code"); + const missing = classifyLaunchFailureCode(systemCode)?.kind === "missing"; const limited = command?.reason === "output-limit"; const timedOut = command?.reason === "timeout"; return { - code: - systemCode === "ENOENT" - ? "runtime_missing" - : limited || timedOut - ? "version_unresolved" - : "runtime_unavailable", - kind: systemCode === "ENOENT" ? "java_runtime" : "java_probe", + code: missing + ? "runtime_missing" + : limited || timedOut + ? "version_unresolved" + : "runtime_unavailable", + kind: missing ? "java_runtime" : "java_probe", message: limited ? `The selected Java launcher ${java} exceeded the ${String(JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES)}-byte module-listing output budget. This release has no runtime override. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; java --list-modules should return a concise module listing. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.` : timedOut ? `The selected Java launcher ${java} did not finish the module-listing probe within ${String(timeoutMs)} ms. This release has no runtime override. Inspect its startup responsiveness and verify it with java --list-modules. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.` - : `Cannot inspect selected Java runtime ${java}: ${systemCode === "ENOENT" ? "spawn java ENOENT" : cause instanceof Error ? cause.message : cause === undefined ? `exit ${String(output?.exit_code ?? "unknown")}${output?.signal == null ? "" : ` (${output.signal})`}` : String(cause)}. Review the selected launcher and retained diagnostics.`, + : `Cannot inspect selected Java runtime ${java}: ${missing ? `spawn java ${String(systemCode)}` : cause instanceof Error ? cause.message : cause === undefined ? `exit ${String(output?.exit_code ?? "unknown")}${output?.signal == null ? "" : ` (${output.signal})`}` : String(cause)}. Review the selected launcher and retained diagnostics.`, diagnostics: { java_executable: java, phase: "module-inspection", diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 150be55f14..f49cabcd24 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -10,6 +10,7 @@ import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; +import { classifyLaunchFailure } from "../process/launchFailure.js"; import { AnalysisResourceConstraintError, AnalysisTimeoutError, @@ -522,17 +523,9 @@ const versionProbeFailure = ( ...apktoolVersionProbeTimeoutGuidance(), }, ); - // EPERM is the other errno a refused exec can surface (for example a launcher - // blocked by an operating-system security policy), so it joins EACCES as a - // permission failure, not a missing launcher. - if ( - cause instanceof Error && - "code" in cause && - (cause.code === "ENOENT" || - cause.code === "EACCES" || - cause.code === "EPERM") - ) { - const execDenied = cause.code === "EACCES" || cause.code === "EPERM"; + const refusal = classifyLaunchFailure(cause); + if (refusal !== undefined) { + const execDenied = refusal.kind === "not_executable"; return new ApktoolConfigurationFailure( execDenied ? "command_not_executable" : "command_missing", execDenied @@ -542,7 +535,7 @@ const versionProbeFailure = ( ? APKTOOL_NOT_EXECUTABLE_REMEDIATION : APKTOOL_MISSING_REMEDIATION, { - filesystem_error: cause.code, + filesystem_error: refusal.code, selected_command: selection.command, command_source: selection.commandSource, }, diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 664fd26269..eda0e430f7 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -1,6 +1,7 @@ import { GDB_PROVIDER_IDENTITY } from "./GdbProviderIdentity.js"; import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js"; import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; +import { classifyLaunchFailure } from "../process/launchFailure.js"; import { snapshotGdbCommandRecords } from "./GdbCommandRecords.js"; import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; @@ -846,16 +847,11 @@ export class GdbSessionManager { cause instanceof AnalysisCancelledError ) return err(new AnalysisCancelledError("start_gdb_session", { cause })); - const code: unknown = - cause instanceof Error && "code" in cause ? cause.code : undefined; - if ( - cause instanceof Error && - typeof code === "string" && - ["ENOENT", "EACCES", "EPERM", "ENOEXEC", "ENOTDIR", "EISDIR"].includes( - code, - ) - ) - return err(new GdbExecutableRefusal(this.#command, code, cause)); + const refusal = classifyLaunchFailure(cause); + if (refusal !== undefined && cause instanceof Error) + return err( + new GdbExecutableRefusal(this.#command, refusal.code, cause), + ); return err( new AnalysisCapabilityUnavailableError( "gdb", diff --git a/src/ghidra/GhidraJavaProbe.test.ts b/src/ghidra/GhidraJavaProbe.test.ts index 8851facbca..b3c101d6aa 100644 --- a/src/ghidra/GhidraJavaProbe.test.ts +++ b/src/ghidra/GhidraJavaProbe.test.ts @@ -44,6 +44,8 @@ const probe = (values: { describe("Ghidra Java prerequisite process facts", () => { it.each([ ["ENOENT", "runtime_missing"], + // A non-directory path component also leaves nothing runnable to start. + ["ENOTDIR", "runtime_missing"], ["EACCES", "runtime_unavailable"], ["ETIMEDOUT", "runtime_unavailable"], ["ENOBUFS", "runtime_unavailable"], diff --git a/src/ghidra/GhidraJavaProbe.ts b/src/ghidra/GhidraJavaProbe.ts index d34d378717..72da6bf837 100644 --- a/src/ghidra/GhidraJavaProbe.ts +++ b/src/ghidra/GhidraJavaProbe.ts @@ -1,4 +1,5 @@ import type { ProcessInspectionFailure } from "../process/ProcessInspectionCleanup.js"; +import { classifyLaunchFailureCode } from "../process/launchFailure.js"; import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; import { retainOwnedCommandCleanup, @@ -297,7 +298,10 @@ const observeGhidraJava = async ( : systemCode(commandFailure?.processError ?? cause); const failed: GhidraJavaProbeFailure = { _tag: "GhidraJavaProbeFailure", - code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable", + code: + classifyLaunchFailureCode(code)?.kind === "missing" + ? "runtime_missing" + : "runtime_unavailable", detail: `${command}: ${cause instanceof Error ? cause.message : String(cause)}`, ...(code === undefined ? {} : { processErrorCode: code }), ...(output === undefined ? {} : { output }), diff --git a/src/pcap/PcapCommand.ts b/src/pcap/PcapCommand.ts index cf8e1327d6..a91bbd0254 100644 --- a/src/pcap/PcapCommand.ts +++ b/src/pcap/PcapCommand.ts @@ -15,6 +15,7 @@ import { import { createPcapDiagnosticProjection } from "./PcapDiagnosticOutput.js"; import { AnalysisCancelledError, + AnalysisCapabilityUnavailableError, AnalysisProtocolError, AnalysisResourceConstraintError, AnalysisTimeoutError, @@ -35,7 +36,15 @@ import { windowsBatchCommand, WindowsBatchCommandError, } from "../process/WindowsBatchCommand.js"; +import { + classifyLaunchFailure, + executableLaunchRefusalOf, +} from "../process/launchFailure.js"; import { pcapToolUnavailable } from "./PcapConfiguration.js"; +import { + attributePcapLimitedExit, + PCAP_LIMITER_REMEDIATION, +} from "./PcapNativeBound.js"; export type PcapCommandLauncher = NonNullable< Parameters[2] @@ -112,6 +121,7 @@ export const runPcapCommand = async (options: { LANG: "C", }; let cleanup: PcapCommandCleanup | undefined; + let commandAttempted = false; try { const batch = process.platform === "win32" && isWindowsBatchFile(command); const invocation = batch @@ -138,6 +148,7 @@ export const runPcapCommand = async (options: { const selectedLimiter = options.nativeBound?.limiter?.command; if (selectedLimiter !== undefined) await assertArtifactCommandPath(selectedLimiter, operation, { signal }); + commandAttempted = true; const output = await runOwnedCommand( { ...boundedInvocation, @@ -285,23 +296,88 @@ export const runPcapCommand = async (options: { ), }, ); - if ( - cause instanceof WindowsBatchCommandError || - (cause instanceof Error && - "code" in cause && - (cause.code === "ENOENT" || cause.code === "EACCES")) - ) + if (cause instanceof WindowsBatchCommandError) throw pcapToolUnavailable( operation, `Cannot run selected tool ${command}: ${cause.message}`, details, ); + const selectedLimiter = + options.nativeBound?.limiter?.command.invocationPath; + const launchedPath = selectedLimiter ?? command; + const launchRefusal = executableLaunchRefusalOf(cause); + const spawnRefusal = + launchRefusal?.executable === launchedPath ? launchRefusal : undefined; + if (spawnRefusal !== undefined && selectedLimiter !== undefined) { + const reason = `Selected capture limiter ${selectedLimiter} could not be launched (${spawnRefusal.code}).`; + throw new AnalysisCapabilityUnavailableError("pcap", operation, reason, { + ...details, + userMessage: `${reason} ${PCAP_LIMITER_REMEDIATION}`, + remediationAction: PCAP_LIMITER_REMEDIATION, + }); + } + if (spawnRefusal !== undefined) + throw pcapToolUnavailable( + operation, + spawnRefusal.kind === "missing" + ? `Selected tool ${command} was not found (${spawnRefusal.code})` + : `Selected tool ${command} exists but could not be executed (${spawnRefusal.code})`, + details, + ); const diagnostic = cause instanceof OwnedCommandFailure ? cause.snapshot?.stderr.text.trim() || cause.message : cause instanceof Error ? cause.message : String(cause); + if (!commandAttempted) + throw new AnalysisProtocolError( + `Preparing the owned packet command failed: ${diagnostic}`, + details, + ); + if (classifyLaunchFailure(cause) !== undefined) + throw new AnalysisProtocolError( + `The packet command could not start; the failing stage is unknown: ${diagnostic}`, + details, + ); + if ( + selectedLimiter !== undefined && + cause instanceof OwnedCommandFailure && + cause.reason === "process" + ) { + if (cause.collectionFailure || cause.snapshot === null) + throw new AnalysisProtocolError( + `The bounded packet command failed before its exit was observed; whether the limiter or selected tool failed is unknown: ${diagnostic}`, + details, + ); + const attributed = attributePcapLimitedExit(selectedLimiter, { + exitCode: cause.snapshot.exitCode, + signal: cause.snapshot.signal, + stderr: cause.snapshot.stderr.text, + }); + if (attributed.stage === "limiter") { + const reason = `Selected capture limiter ${selectedLimiter} failed before starting ${command}: ${diagnostic}`; + throw new AnalysisCapabilityUnavailableError( + "pcap", + operation, + reason, + { + ...details, + userMessage: `${reason} ${PCAP_LIMITER_REMEDIATION}`, + remediationAction: PCAP_LIMITER_REMEDIATION, + }, + ); + } + if (attributed.stage === "tool-launch") + throw pcapToolUnavailable( + operation, + attributed.refusal === "missing" + ? `Selected tool ${command} was not found: ${diagnostic}` + : `Selected tool ${command} exists but could not be executed: ${diagnostic}`, + details, + ); + // Otherwise prlimit started the tool; its exit and output are the tool's. + } if (options.inputPath === undefined) throw pcapToolUnavailable( operation, diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts index ac90039d56..520b2f3363 100644 --- a/src/pcap/PcapNativeBound.ts +++ b/src/pcap/PcapNativeBound.ts @@ -23,7 +23,48 @@ import type { ArtifactCommandPath } from "../process/ArtifactCommandPath.js"; const PRLIMIT_COMMAND_ENV = "REA_TSHARK_PRLIMIT_COMMAND"; /** Repair for an unusable selected capture limiter; readiness and admission share it. */ -const PCAP_LIMITER_REMEDIATION = `Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`; +export const PCAP_LIMITER_REMEDIATION = `Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`; +/** Which stage an unsuccessful limited capture command's exit belongs to. */ +export type PcapLimitedExitStage = + | { readonly stage: "tool" } + | { readonly stage: "tool-launch"; readonly refusal: "missing" | "denied" } + | { readonly stage: "limiter" }; + +/** + * Attribute an unsuccessful exit of a command run through util-linux prlimit. + * prlimit sets the limits and then replaces itself with the tool (execvp), so + * once that succeeds the exit status and output are the tool's own. Before + * that, prlimit reports its own failure through err(3) on stderr, prefixed + * with its invocation name: a failed exec exits 127 (ENOENT) or 126, any + * other prlimit failure exits 1. Commands run with LC_ALL=C, so the message + * is untranslated. + */ +export const attributePcapLimitedExit = ( + limiterInvocationPath: string, + exit: { + readonly exitCode: number | null | undefined; + readonly signal: string | null | undefined; + readonly stderr: string; + }, +): PcapLimitedExitStage => { + const prefix = `${basename(limiterInvocationPath)}: `; + // A signal or output without prlimit's own prefix belongs to the tool. + if ( + (exit.signal !== null && exit.signal !== undefined) || + !exit.stderr.startsWith(prefix) + ) + return { stage: "tool" }; + if ( + (exit.exitCode === 126 || exit.exitCode === 127) && + exit.stderr.startsWith(`${prefix}failed to execute `) + ) + return { + stage: "tool-launch", + refusal: exit.exitCode === 127 ? "missing" : "denied", + }; + return exit.exitCode === 1 ? { stage: "limiter" } : { stage: "tool" }; +}; + /** Capture CPU ceiling, in seconds. */ const PCAP_CPU_SECONDS = 300; diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index a50fb4bc9c..fb6e14b632 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -6,6 +6,7 @@ import { } from "./ProcessInspectionCleanup.js"; import { assertProcessOwnershipInspectionActive } from "./ProcessInspectionCancellation.js"; import { ProcessAdmissionError } from "./ProcessAdmissionError.js"; +import { nameSpawnedExecutable } from "./launchFailure.js"; import { spawn, type ChildProcess } from "node:child_process"; import type { EventEmitter } from "node:events"; import type { Readable, Writable } from "node:stream"; @@ -331,15 +332,20 @@ export const spawnOwnedProviderProcess = async ( } if (options.memoryLimitBytes !== undefined) throw new ProcessAdmissionError("memory_limit_unsupported"); - const child = spawn(options.command, [...options.arguments], { - shell: false, - windowsHide: true, - stdio: [options.stdin ?? "ignore", "pipe", "pipe"], - detached: platform !== "win32", - windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, - env: environment, - ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - }); + let child: ChildProcess; + try { + child = spawn(options.command, [...options.arguments], { + shell: false, + windowsHide: true, + stdio: [options.stdin ?? "ignore", "pipe", "pipe"], + detached: platform !== "win32", + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + env: environment, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + }); + } catch (cause: unknown) { + throw nameSpawnedExecutable(cause, options.command); + } await waitForSpawn(child); const pid = child.pid; if (pid === undefined) diff --git a/src/process/launchFailure.test.ts b/src/process/launchFailure.test.ts new file mode 100644 index 0000000000..b612bff964 --- /dev/null +++ b/src/process/launchFailure.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "vitest"; + +import { + classifyLaunchFailure, + classifyLaunchFailureCode, + executableLaunchRefusalOf, +} from "./launchFailure.js"; + +it.each([ + ["ENOENT", "missing"], + ["ENOTDIR", "missing"], + ["ELOOP", "missing"], + ["ENAMETOOLONG", "missing"], + ["EACCES", "not_executable"], + ["EPERM", "not_executable"], + ["ENOEXEC", "not_executable"], + ["EISDIR", "not_executable"], +])("classifies spawn %s as %s", (code, kind) => { + expect(classifyLaunchFailureCode(code)).toEqual({ kind, code }); + expect( + classifyLaunchFailure(Object.assign(new Error(code), { code })), + ).toEqual({ kind, code }); +}); + +it.each([["EAGAIN"], ["EMFILE"], ["ETIMEDOUT"], [1], [null], ["toString"]])( + "leaves %j unclassified", + (code) => { + expect(classifyLaunchFailureCode(code)).toBeUndefined(); + }, +); + +it.each([ + [ + "Node spawn", + { code: "ENOENT", syscall: "spawn /tool", path: "/tool" }, + { kind: "missing", code: "ENOENT", executable: "/tool" }, + ], + [ + "Windows native launcher", + { + code: "EACCES", + constraint: "Create process atomically inside owned Job Object failed", + requestedPath: "C:\\tools\\tool.exe", + }, + { + kind: "not_executable", + code: "EACCES", + executable: "C:\\tools\\tool.exe", + }, + ], +])("names the executable a %s refusal identifies", (_, fields, expected) => { + expect( + executableLaunchRefusalOf(Object.assign(new Error("refused"), fields)), + ).toEqual(expected); +}); + +it.each([ + ["an errno without a launcher", { code: "EPERM" }], + [ + "a spawn syscall naming another path", + { code: "ENOENT", syscall: "spawn /other", path: "/tool" }, + ], + [ + "a Windows host-control rejection", + { + code: "EACCES", + constraint: "Create owned Job Object failed", + requestedPath: "C:\\tools\\tool.exe", + }, + ], + [ + "a launcher error whose errno is not a refusal", + { code: "EAGAIN", syscall: "spawn /tool", path: "/tool" }, + ], +])("does not read %s as an executable refusal", (_, fields) => { + expect( + executableLaunchRefusalOf(Object.assign(new Error("refused"), fields)), + ).toBeUndefined(); +}); diff --git a/src/process/launchFailure.ts b/src/process/launchFailure.ts new file mode 100644 index 0000000000..9f3c344419 --- /dev/null +++ b/src/process/launchFailure.ts @@ -0,0 +1,99 @@ +import { windowsExecutableLaunchPath } from "../windows/WindowsOwnedProcess.js"; + +/** + * Why the operating system refused to start a selected executable. + * + * `missing`: nothing runnable resolves at the selected path. + * `not_executable`: something exists there but may not be run, including a + * refusal by an operating-system security policy (EPERM). + */ +export interface LaunchRefusal { + readonly kind: "missing" | "not_executable"; + readonly code: string; +} + +const LAUNCH_REFUSALS: Readonly> = { + ENOENT: "missing", + ENOTDIR: "missing", + ELOOP: "missing", + ENAMETOOLONG: "missing", + EACCES: "not_executable", + EPERM: "not_executable", + ENOEXEC: "not_executable", + EISDIR: "not_executable", +}; + +/** Classify a spawn errno; undefined when it is not a launch refusal. */ +export const classifyLaunchFailureCode = ( + code: unknown, +): LaunchRefusal | undefined => { + if (typeof code !== "string" || !Object.hasOwn(LAUNCH_REFUSALS, code)) + return undefined; + const kind = LAUNCH_REFUSALS[code]; + return kind === undefined ? undefined : { kind, code }; +}; + +/** Classify a spawn failure; undefined when it was not a launch refusal. */ +export const classifyLaunchFailure = ( + cause: unknown, +): LaunchRefusal | undefined => + cause instanceof Error && "code" in cause + ? classifyLaunchFailureCode(cause.code) + : undefined; + +/** + * Name the executable on an errno Node threw synchronously from `spawn`. + * Node emits ENOENT and EACCES as `spawn ` with `path`, but throws + * other refusals such as ENOEXEC and ENOTDIR with a bare `spawn` syscall; + * the launcher that passed `command` gives them the emitted shape. + */ +export const nameSpawnedExecutable = ( + cause: unknown, + command: string, +): unknown => { + if ( + cause instanceof Error && + Reflect.get(cause, "syscall") === "spawn" && + Reflect.get(cause, "path") === undefined + ) { + Reflect.set(cause, "syscall", `spawn ${command}`); + Reflect.set(cause, "path", command); + } + return cause; +}; + +/** A launch refusal together with the executable the launcher named. */ +export interface ExecutableLaunchRefusal extends LaunchRefusal { + readonly executable: string; +} + +/** + * The executable a launcher named when it failed to start it: Node's + * `spawn ` error or the Windows native launcher's executable-launch + * constraint. Undefined when the error does not identify a launch. + */ +const launchedExecutableOf = (cause: Error): string | undefined => { + const path = Reflect.get(cause, "path"); + if ( + typeof path === "string" && + Reflect.get(cause, "syscall") === `spawn ${path}` + ) + return path; + return windowsExecutableLaunchPath(cause); +}; + +/** + * Classify a failure to start a named executable. Undefined when the errno is + * not a launch refusal or the error does not identify the executable a + * launcher refused, such as an errno from reading a file or preparing + * ownership. + */ +export const executableLaunchRefusalOf = ( + cause: unknown, +): ExecutableLaunchRefusal | undefined => { + if (!(cause instanceof Error)) return undefined; + const executable = launchedExecutableOf(cause); + if (executable === undefined) return undefined; + const refusal = classifyLaunchFailure(cause); + return refusal === undefined ? undefined : { ...refusal, executable }; +}; diff --git a/src/windows/WindowsOwnedProcess.ts b/src/windows/WindowsOwnedProcess.ts index c7bbe1ebe0..316b6ced85 100644 --- a/src/windows/WindowsOwnedProcess.ts +++ b/src/windows/WindowsOwnedProcess.ts @@ -157,6 +157,25 @@ export const isWindowsExecutableLaunchFailure = (cause: unknown): boolean => { return typeof code === "string" && code !== "ERR_REA_WINDOWS_NATIVE"; }; +/** + * The executable path the native launcher refused to start, when its error + * names an executable-launch constraint; undefined for host-control + * rejections and errors from elsewhere. + */ +export const windowsExecutableLaunchPath = ( + cause: unknown, +): string | undefined => { + if (!(cause instanceof Error)) return undefined; + const constraint = Reflect.get(cause, "constraint"); + const requestedPath = Reflect.get(cause, "requestedPath"); + return typeof constraint === "string" && + WINDOWS_EXECUTABLE_SPAWN_CONSTRAINTS.has(constraint) && + typeof requestedPath === "string" && + requestedPath.length > 0 + ? requestedPath + : undefined; +}; + /** Wrap a raw native process_spawn host-control rejection as a RuntimeControlUnavailableError. */ export const windowsNativeSpawnControlError = ( cause: unknown, diff --git a/tests/boundary/pcap/pcapCommand.test.ts b/tests/boundary/pcap/pcapCommand.test.ts index f165b1caf6..b80290aee8 100644 --- a/tests/boundary/pcap/pcapCommand.test.ts +++ b/tests/boundary/pcap/pcapCommand.test.ts @@ -1,5 +1,10 @@ +import { join } from "node:path"; +import { writeFile } from "node:fs/promises"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { setTimeout as delay } from "node:timers/promises"; -import { expect, it, onTestFinished, vi } from "vitest"; +import { expect, it, onTestFinished, test, vi } from "vitest"; import { runPcapCommand } from "../../../src/pcap/PcapCommand.js"; import * as ownership from "../../../src/process/ProcessOwnershipObservation.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; @@ -8,9 +13,13 @@ import type { ProviderProcessSupervisor } from "../../../src/process/ProviderPro import { AnalysisCancelledError, AnalysisProtocolError, + AnalysisUnsupportedTargetError, } from "../../../src/domain/analysisErrorCore.js"; + +const posixIt = test.skipIf(process.platform === "win32"); import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; +import { selectArtifactCommandPath } from "../../../src/process/ArtifactCommandPath.js"; it.skipIf(process.platform !== "darwin")( "preserves opaque cancellation during native ownership preparation without acquiring caller resources", @@ -235,3 +244,248 @@ it("returns complete stdout while retaining unresolved process cleanup", async ( mayClean = true; expect((await retained.owner.stop()).status).not.toBe("incomplete"); }); + +it.each([ + ["EPERM", "exists but could not be executed"], + ["EACCES", "exists but could not be executed"], + ["ENOENT", "was not found"], +])( + "reports a %s launch refusal while reading a capture as an unavailable tool", + async (code, wording) => { + const failure = await runPcapCommand({ + operation: "inspect_pcap_capture", + command: "/selected/tshark", + arguments: ["-r", "/authored-capture.pcap"], + inputPath: "/authored-capture.pcap", + limits: { timeoutMs: 5000, maxOutputBytes: 1024 }, + environment: {}, + signal: undefined, + launcher: async () => { + throw Object.assign(new Error(`spawn /selected/tshark ${code}`), { + code, + syscall: "spawn /selected/tshark", + path: "/selected/tshark", + }); + }, + retain: () => { + throw new Error("No provider resources were acquired"); + }, + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining(`${wording} (${code})`), + }); + }, +); + +posixIt( + "names the selected tool on a launch refusal Node throws synchronously", + async () => { + // Node throws ENOTDIR synchronously with a bare `spawn` syscall rather + // than emitting `spawn `. A path through a regular file refuses + // the same way on every POSIX host; ENOEXEC does not, because Linux + // libc may retry a headerless file through /bin/sh. + const directory = await createTestTempDirectory("rea-pcap-enotdir-"); + const file = join(directory, "not-a-directory"); + await writeFile(file, ""); + const command = join(file, "tshark"); + const failure = await runPcapCommand({ + operation: "inspect_pcap_client", + command, + arguments: ["--version"], + limits: { timeoutMs: 5000, maxOutputBytes: 1024 }, + environment: {}, + signal: undefined, + launcher: spawnOwnedProviderProcess, + retain: () => { + throw new Error("No provider resources were acquired"); + }, + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining("was not found (ENOTDIR)"), + }); + }, +); + +it("reports the Windows native launcher's refusal of the selected tool as an unavailable tool", async () => { + const command = "C:\\Wireshark\\tshark.exe"; + const failure = await runPcapCommand({ + operation: "inspect_pcap_capture", + command, + arguments: ["-r", "/authored-capture.pcap"], + inputPath: "/authored-capture.pcap", + limits: { timeoutMs: 5000, maxOutputBytes: 1024 }, + environment: {}, + signal: undefined, + launcher: async () => { + // The native addon names the refused executable without Node's + // `syscall` and `path` fields. + throw Object.assign(new Error("refused"), { + code: "ENOENT", + constraint: "Create process atomically inside owned Job Object failed", + requestedPath: command, + win32Code: 2, + }); + }, + retain: () => { + throw new Error("No provider resources were acquired"); + }, + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining("was not found (ENOENT)"), + }); +}); + +it("attributes a refused limiter spawn to the limiter, not Wireshark", async () => { + const limiter = await selectArtifactCommandPath(process.execPath); + const failure = await runPcapCommand({ + operation: "inspect_pcap_capture", + command: "/selected/tshark", + arguments: [], + inputPath: "/authored-capture.pcap", + limits: { timeoutMs: 5000, maxOutputBytes: 1024 }, + environment: {}, + signal: undefined, + nativeBound: { limiter: { command: limiter, arguments: [] } }, + launcher: async () => { + throw Object.assign(new Error("refused"), { + code: "EPERM", + syscall: `spawn ${limiter.invocationPath}`, + path: limiter.invocationPath, + }); + }, + retain: () => { + throw new Error("No provider resources were acquired"); + }, + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining( + `capture limiter ${limiter.invocationPath}`, + ), + remediationAction: expect.stringContaining("REA_TSHARK_PRLIMIT_COMMAND"), + }); +}); + +/** + * A stand-in for util-linux prlimit: it either refuses with prlimit's own + * err(3) diagnostics or replaces itself with the selected tool. + */ +const prlimitStandIn = async () => { + const root = await createTestTempDirectory("rea-pcap-prlimit-"); + const prlimit = join(root, "prlimit"); + await writeFile( + prlimit, + `#!/bin/sh +while [ "\${1#--}" != "$1" ]; do + if [ "$1" = "--refuse" ]; then + printf 'prlimit: failed to set the AS resource limit: Operation not permitted\\n' >&2; exit 1 + fi + shift +done +if [ ! -e "$1" ]; then printf 'prlimit: failed to execute %s: No such file or directory\\n' "$1" >&2; exit 127; fi +if [ ! -x "$1" ]; then printf 'prlimit: failed to execute %s: Permission denied\\n' "$1" >&2; exit 126; fi +exec "$@" +`, + { mode: 0o755 }, + ); + const capinfos = join(root, "capinfos"); + await writeFile( + capinfos, + "#!/bin/sh\nprintf 'capinfos: The file is not a capture file\\n' >&2; exit 1\n", + { mode: 0o755 }, + ); + const notExecutable = join(root, "tshark-not-executable"); + await writeFile(notExecutable, "#!/bin/sh\nexit 0\n", { mode: 0o644 }); + return { + limiter: await selectArtifactCommandPath(prlimit), + capinfos, + notExecutable, + missing: join(root, "missing-tshark"), + }; +}; + +const runLimited = async ( + limiter: Awaited>["limiter"], + command: string, + limiterArguments: string[] = ["--as=1073741824"], +) => + runPcapCommand({ + operation: "inspect_pcap_capture", + command, + arguments: [], + inputPath: "/authored-capture.pcap", + limits: { timeoutMs: 5000, maxOutputBytes: 1024 }, + environment: {}, + signal: undefined, + nativeBound: { limiter: { command: limiter, arguments: limiterArguments } }, + retain: (owner) => + onTestFinished(async () => { + await owner.stop(); + }), + }).catch((cause: unknown) => cause); + +posixIt( + "attributes a limited tool's own exit to the tool through prlimit", + async () => { + const { limiter, capinfos } = await prlimitStandIn(); + const failure = await runLimited(limiter, capinfos); + expect(failure).toBeInstanceOf(AnalysisUnsupportedTargetError); + expect(projectAnalysisError(failure as AnalysisError)).toMatchObject({ + code: "unsupported_target", + }); + }, +); + +posixIt("attributes prlimit's failed exec to the selected tool", async () => { + const { limiter, missing, notExecutable } = await prlimitStandIn(); + for (const [command, wording] of [ + [missing, "was not found"], + [notExecutable, "exists but could not be executed"], + ] as const) { + const failure = await runLimited(limiter, command); + expect(failure).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining(`Selected tool ${command} ${wording}`), + }); + expect(failure).not.toMatchObject({ + remediationAction: expect.stringContaining("REA_TSHARK_PRLIMIT_COMMAND"), + }); + } +}); + +posixIt("attributes prlimit's own refusal to the limiter", async () => { + const { limiter, capinfos } = await prlimitStandIn(); + const failure = await runLimited(limiter, capinfos, ["--refuse"]); + expect(failure).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + message: expect.stringContaining( + `capture limiter ${limiter.invocationPath} failed before starting`, + ), + remediationAction: expect.stringContaining("REA_TSHARK_PRLIMIT_COMMAND"), + }); +}); + +it("does not attribute an errno without spawn identity to the selected tool", async () => { + const failure = await runPcapCommand({ + operation: "inspect_pcap_client", + command: "/selected/tshark", + arguments: ["--version"], + limits: { timeoutMs: 5000, maxOutputBytes: 1024 }, + environment: {}, + signal: undefined, + launcher: async () => { + throw Object.assign(new Error("ownership setup denied"), { + code: "EPERM", + }); + }, + retain: () => { + throw new Error("No provider resources were acquired"); + }, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(AnalysisProtocolError); + if (!(failure instanceof AnalysisProtocolError)) throw failure; + expect(failure.message).toContain("failing stage is unknown"); +});