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"); +});