mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
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
This commit is contained in:
1 parent
47925f3bef
commit
44fedc8fdd
13 files changed
+622
-51
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
+82
-6
@@ -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<typeof runOwnedCommand>[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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<Record<string, LaunchRefusal["kind"]>> = {
|
||||
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 <path>` 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 <path>` 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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <path>`. 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<ReturnType<typeof prlimitStandIn>>["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");
|
||||
});
|
||||
Reference in new issue
Block a user