mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
* fix(firmware): keep producer reports writable under small staging budgets extract_firmware passed max_output_bytes straight to prlimit --fsize. RLIMIT_FSIZE applies to every regular file the producer writes, including the mandatory report.json and unblob.log outside the staging tree, so a staging budget smaller than the report truncated it and failed the extraction even when every staged file fit. Derive the per-file ceiling as the larger of the staging budget and the 8 MiB report budget; polling and the final inventory still enforce the aggregate staging budget. Fixes #3888 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(firmware): keep files stopped at the size ceiling over budget The per-file RLIMIT_FSIZE ceiling equalled the largest size its readers accept, and both the staging inventory and the report reader accept a file of exactly their budget. A write the kernel stopped at the ceiling therefore left a truncated file that passed as complete. Raise the ceiling by one byte so such a file exceeds its budget and is rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(firmware): report the effective file-size ceiling on limit failures The exit path re-derived the staging budget for SIGXFSZ and process-limit recovery, so a small-budget run killed at the 8 MiB + 1 ceiling reported file_size_bytes 64 and said the cap was max_output_bytes. Pass the ceiling runFirmwareCommand installs and describe how it is derived. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(firmware): reject staged files cut off at the per-file limit RLIMIT_FSIZE stops a write at the limit without extending the file, and the staging inventory admits a file of exactly max_output_bytes. With a budget of 8 MiB or more the producer limit equalled the budget, so a staged file the kernel cut short published as a complete extraction; an inherited ceiling below the budget did the same. Derive the per-run limit once in firmwareFileSizeLimit as one byte above the largest budget, still capped by the inherited ceiling, so a file stopped at the limit exceeds its budget. When the inherited ceiling removes that byte, both staging inventories refuse a regular file that reaches the installed limit with a nonretryable file-size constraint naming the inherited limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(firmware): derive limit adjustability from the per-run limit The one byte of headroom makes the per-run file limit equal an inherited ceiling of exactly max_output_bytes + 1, but adjustability still compared that ceiling with the raw budget. A SIGXFSZ then advised raising max_output_bytes, which the same ceiling clamps back to the same limit. Decide adjustability in firmwareFileSizeAdjustable from firmwareFileSizeLimit itself: recommend a larger budget only when it raises that limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(firmware): compare file-size recovery with its owners The SIGXFSZ adjustability cases and the inherited-ceiling workflow matched fragments of remediation prose, so a harmless rewrite would fail them. Compare the emitted guidance with firmwareSignalLimitError for the expected branch, check that the two branches stay distinct, and compare the inventory refusal with firmwareFileSizeLimitReached. The selected limit, resource and retryability stay asserted as structured facts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
670 lines
22 KiB
TypeScript
670 lines
22 KiB
TypeScript
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
|
import { EventEmitter } from "node:events";
|
|
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { PassThrough } from "node:stream";
|
|
|
|
import { expect, it as test, vi } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import { FIRMWARE_LIMITS } from "./FirmwareRelease.js";
|
|
import { firmwareSignalLimitError } from "./FirmwareFailures.js";
|
|
import { FirmwareFileAccess } from "./FirmwareFiles.js";
|
|
import {
|
|
resolveFirmwareCommand,
|
|
runFirmwareCommand,
|
|
type FirmwareLauncher,
|
|
} from "./FirmwareCommand.js";
|
|
import {
|
|
spawnOwnedProviderProcess,
|
|
type ProviderProcessHandle,
|
|
type ProviderProcessSupervisor,
|
|
type OwnedProviderProcessSpawnOptions,
|
|
} from "../process/ProviderProcess.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js";
|
|
|
|
const it = test.skipIf(process.platform !== "linux");
|
|
|
|
it.each([
|
|
{ source: "analysis", wrapped: false },
|
|
{ source: "caller", wrapped: false },
|
|
{ source: "analysis", wrapped: true },
|
|
{ source: "caller", wrapped: true },
|
|
] as const)(
|
|
"classifies $source cancellation during launcher admission (wrapped: $wrapped)",
|
|
async ({ source, wrapped }) => {
|
|
const analysis = new AbortController();
|
|
const caller = new AbortController();
|
|
const reason =
|
|
source === "analysis"
|
|
? new DOMException("selected deadline", "TimeoutError")
|
|
: { caller: "opaque admission cancellation" };
|
|
const retainCleanup = vi.fn();
|
|
const failure: unknown = await runFirmwareCommand({
|
|
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
|
engine: "binwalk",
|
|
operation: "inspect_firmware_regions",
|
|
command: "/usr/bin/true",
|
|
limiter: "/usr/bin/true",
|
|
args: ["--version"],
|
|
cwd: await createTestTempDirectory("rea-firmware-admission-"),
|
|
environment: process.env,
|
|
signal: AbortSignal.any([analysis.signal, caller.signal]),
|
|
deadline: {
|
|
phase: "analysis",
|
|
signal: analysis.signal,
|
|
timeoutMs: 1234,
|
|
expiresAtMs: performance.now() + 1234,
|
|
},
|
|
launcher: async (options) => {
|
|
// Real launcher is paused at its first ownership-preparation await;
|
|
// it must reject before acquiring a child after this cancellation.
|
|
const admission = wrapped
|
|
? undefined
|
|
: spawnOwnedProviderProcess(options);
|
|
(source === "analysis" ? analysis : caller).abort(reason);
|
|
if (admission !== undefined) return admission;
|
|
throw new ExecFileOutputCancelledError(reason);
|
|
},
|
|
retainCleanup,
|
|
}).catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(AnalysisError);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected typed admission interruption");
|
|
expect(failure.cause).toBe(reason);
|
|
expect(projectAnalysisError(failure)).toMatchObject(
|
|
source === "analysis"
|
|
? {
|
|
code: "provider_timeout",
|
|
details: { timeout_ms: 1234, diagnostics: { phase: "analysis" } },
|
|
remediation: {
|
|
action: expect.stringContaining("analysis_timeout_ms"),
|
|
},
|
|
}
|
|
: { code: "cancelled" },
|
|
);
|
|
expect(retainCleanup).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("does not relabel an unrelated launch failure when a deadline also fires", async () => {
|
|
const analysis = new AbortController();
|
|
const failure = new Error("distinct launch refusal");
|
|
await expect(
|
|
runFirmwareCommand({
|
|
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
|
engine: "binwalk",
|
|
operation: "inspect_firmware_regions",
|
|
command: "/usr/bin/true",
|
|
limiter: "/usr/bin/true",
|
|
args: [],
|
|
cwd: await createTestTempDirectory("rea-firmware-admission-error-"),
|
|
environment: process.env,
|
|
signal: analysis.signal,
|
|
deadline: {
|
|
phase: "analysis",
|
|
signal: analysis.signal,
|
|
timeoutMs: 1234,
|
|
expiresAtMs: performance.now() + 1234,
|
|
},
|
|
launcher: async () => {
|
|
analysis.abort();
|
|
throw failure;
|
|
},
|
|
retainCleanup: vi.fn(),
|
|
}),
|
|
).rejects.toBe(failure);
|
|
});
|
|
|
|
test("names the host and tool requirement of an unconfigured engine", async () => {
|
|
const refusal: unknown = await resolveFirmwareCommand(
|
|
{},
|
|
"binwalk",
|
|
"inspect_firmware_regions",
|
|
new FirmwareFileAccess(),
|
|
).catch((cause: unknown) => cause);
|
|
|
|
expect(refusal).toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
});
|
|
if (!(refusal instanceof AnalysisCapabilityUnavailableError))
|
|
throw new Error("Expected a capability refusal");
|
|
expect(projectAnalysisError(refusal)).toMatchObject({
|
|
code: "capability_unavailable",
|
|
message: refusal.reason,
|
|
remediation: { action: refusal.reason },
|
|
});
|
|
expect(refusal.reason).toContain("REA_BINWALK_COMMAND");
|
|
});
|
|
|
|
it("rejects a directory configured as the firmware engine executable", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-command-");
|
|
const command = join(root, "binwalk");
|
|
const limiter = await executableFixture(root, "prlimit");
|
|
await mkdir(command);
|
|
|
|
await expect(
|
|
resolveFirmwareCommand(
|
|
{
|
|
REA_BINWALK_COMMAND: command,
|
|
REA_FIRMWARE_PRLIMIT_COMMAND: limiter,
|
|
},
|
|
"binwalk",
|
|
"inspect_firmware_regions",
|
|
new FirmwareFileAccess(),
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining(command),
|
|
});
|
|
});
|
|
|
|
it("classifies an unusable firmware engine as an unavailable capability", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-command-");
|
|
const command = join(root, "binwalk");
|
|
const limiter = await executableFixture(root, "prlimit");
|
|
await writeFile(command, "not executable", { mode: 0o600 });
|
|
|
|
await expect(
|
|
resolveFirmwareCommand(
|
|
{
|
|
REA_BINWALK_COMMAND: command,
|
|
REA_FIRMWARE_PRLIMIT_COMMAND: limiter,
|
|
},
|
|
"binwalk",
|
|
"inspect_firmware_regions",
|
|
new FirmwareFileAccess(),
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining(command),
|
|
});
|
|
});
|
|
|
|
it("rejects a directory configured as the resource limiter executable", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-limiter-");
|
|
const command = await executableFixture(root, "binwalk");
|
|
const limiter = join(root, "prlimit");
|
|
await mkdir(limiter);
|
|
|
|
await expect(
|
|
resolveFirmwareCommand(
|
|
{
|
|
REA_BINWALK_COMMAND: command,
|
|
REA_FIRMWARE_PRLIMIT_COMMAND: limiter,
|
|
},
|
|
"binwalk",
|
|
"inspect_firmware_regions",
|
|
new FirmwareFileAccess(),
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining(limiter),
|
|
});
|
|
});
|
|
|
|
// A launched producer that writes its output, then stops on a limit signal.
|
|
const signalledLauncher = (
|
|
signal: NodeJS.Signals,
|
|
output: { stdout: string; stderr: string },
|
|
): FirmwareLauncher => {
|
|
const stdout = new PassThrough();
|
|
const stderr = new PassThrough();
|
|
const emitter = new EventEmitter();
|
|
const child: ProviderProcessHandle = {
|
|
on: emitter.on.bind(emitter),
|
|
once: emitter.once.bind(emitter),
|
|
off: emitter.off.bind(emitter),
|
|
pid: 4242,
|
|
stdout,
|
|
stderr,
|
|
exitCode: null,
|
|
signalCode: signal,
|
|
kill: () => false,
|
|
};
|
|
let ended = 0;
|
|
const close = () => {
|
|
ended += 1;
|
|
if (ended === 2) emitter.emit("close", null, signal);
|
|
};
|
|
stdout.once("end", close);
|
|
stderr.once("end", close);
|
|
setTimeout(() => {
|
|
stdout.end(output.stdout);
|
|
stderr.end(output.stderr);
|
|
}, 15);
|
|
return () =>
|
|
Promise.resolve({
|
|
process: child,
|
|
ownership: {
|
|
runId: "rea-firmware-signal",
|
|
leaderPid: 4242,
|
|
processGroupId: 4242,
|
|
},
|
|
cleanup: () => Promise.resolve({ cleaned: true, signaled: false }),
|
|
});
|
|
};
|
|
|
|
const analysisDeadline = (signal: AbortSignal) => ({
|
|
phase: "analysis" as const,
|
|
signal,
|
|
timeoutMs: 30_000,
|
|
expiresAtMs: performance.now() + 30_000,
|
|
});
|
|
|
|
it("keeps captured tool output when firmware stops on SIGXCPU", async () => {
|
|
const analysis = new AbortController();
|
|
const failure: unknown = await runFirmwareCommand({
|
|
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
|
engine: "binwalk",
|
|
operation: "inspect_firmware_regions",
|
|
command: "/opt/binwalk",
|
|
limiter: "/usr/bin/prlimit",
|
|
args: ["--quiet"],
|
|
cwd: await createTestTempDirectory("rea-firmware-signal-"),
|
|
environment: process.env,
|
|
signal: analysis.signal,
|
|
deadline: analysisDeadline(analysis.signal),
|
|
launcher: signalledLauncher("SIGXCPU", {
|
|
stdout: "partial scan\n",
|
|
stderr: "cpu limit\n",
|
|
}),
|
|
retainCleanup: vi.fn(),
|
|
}).catch((cause: unknown) => cause);
|
|
expect(failure).toMatchObject({
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "cpu",
|
|
capturedOutput: {
|
|
stdout: "partial scan\n",
|
|
stderr: "cpu limit\n",
|
|
signal: "SIGXCPU",
|
|
exit_code: null,
|
|
truncated: false,
|
|
},
|
|
});
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected a firmware resource constraint");
|
|
expect(projectAnalysisError(failure).details?.captured_output).toMatchObject({
|
|
stdout: "partial scan\n",
|
|
stderr: "cpu limit\n",
|
|
signal: "SIGXCPU",
|
|
});
|
|
});
|
|
|
|
const sixteenMiB = 16 * 1024 * 1024;
|
|
it.each([
|
|
{
|
|
inherited: Number.MAX_SAFE_INTEGER,
|
|
limit: sixteenMiB + 1,
|
|
adjustable: true,
|
|
},
|
|
{ inherited: sixteenMiB + 1, limit: sixteenMiB + 1, adjustable: false },
|
|
])(
|
|
"recommends a larger max_output_bytes on SIGXFSZ only when it raises the limit (inherited $inherited)",
|
|
async ({ inherited, limit, adjustable }) => {
|
|
const root = await createTestTempDirectory("rea-firmware-fsize-signal-");
|
|
const analysis = new AbortController();
|
|
const failure: unknown = await runFirmwareCommand({
|
|
fileSizeCeiling: inherited,
|
|
engine: "unblob",
|
|
operation: "extract_firmware",
|
|
command: "/opt/unblob",
|
|
limiter: "/usr/bin/prlimit",
|
|
args: [],
|
|
cwd: root,
|
|
environment: process.env,
|
|
signal: analysis.signal,
|
|
deadline: analysisDeadline(analysis.signal),
|
|
launcher: signalledLauncher("SIGXFSZ", { stdout: "", stderr: "" }),
|
|
outputBudget: { root, bytes: sixteenMiB, entries: 10 },
|
|
retainCleanup: vi.fn(),
|
|
}).catch((cause: unknown) => cause);
|
|
expect(failure).toMatchObject({
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "file-size",
|
|
reportedLimits: { file_size_bytes: limit },
|
|
retryable: false,
|
|
});
|
|
// Compare with the recovery owner instead of its wording; the two branches
|
|
// must differ for the comparison to pin the selected one.
|
|
const recovery = (fileSizeAdjustable: boolean) =>
|
|
firmwareSignalLimitError(
|
|
"extract_firmware",
|
|
"SIGXFSZ",
|
|
limit,
|
|
fileSizeAdjustable,
|
|
)?.remediationAction;
|
|
expect(recovery(true)).not.toBe(recovery(false));
|
|
expect(failure).toHaveProperty("remediationAction", recovery(adjustable));
|
|
},
|
|
);
|
|
|
|
const executableFixture = async (root: string, name: string) => {
|
|
const path = join(root, name);
|
|
await writeFile(path, "#!/bin/sh\n");
|
|
await chmod(path, 0o700);
|
|
return path;
|
|
};
|
|
|
|
test.each([
|
|
{
|
|
selected: 1,
|
|
inherited: Number.MAX_SAFE_INTEGER,
|
|
expected: 8 * 1024 * 1024 + 1,
|
|
},
|
|
{
|
|
selected: 16 * 1024 * 1024,
|
|
inherited: Number.MAX_SAFE_INTEGER,
|
|
expected: 16 * 1024 * 1024 + 1,
|
|
},
|
|
{
|
|
selected: 16 * 1024 * 1024,
|
|
inherited: 16 * 1024 * 1024,
|
|
expected: 16 * 1024 * 1024,
|
|
},
|
|
{ selected: 16 * 1024 * 1024, inherited: 1024, expected: 1024 },
|
|
])(
|
|
"separates payload $selected from metadata file headroom and inherited ceiling $inherited",
|
|
async ({ selected, inherited, expected }) => {
|
|
const root = await createTestTempDirectory("rea-firmware-file-policy-");
|
|
const refused = new Error("stop after inspecting launch policy");
|
|
const launcher = vi.fn(
|
|
async (options: OwnedProviderProcessSpawnOptions) => {
|
|
expect(options.arguments).toContain(`--fsize=${expected}:${expected}`);
|
|
expect(options.arguments).toContain(
|
|
`--as=${FIRMWARE_LIMITS.addressSpaceBytes}:${FIRMWARE_LIMITS.addressSpaceBytes}`,
|
|
);
|
|
throw refused;
|
|
},
|
|
);
|
|
await expect(
|
|
runFirmwareCommand({
|
|
engine: "unblob",
|
|
operation: "extract_firmware",
|
|
command: "/synthetic/unblob",
|
|
limiter: "/synthetic/prlimit",
|
|
fileSizeCeiling: inherited,
|
|
args: [],
|
|
cwd: root,
|
|
environment: {},
|
|
signal: new AbortController().signal,
|
|
deadline: {
|
|
phase: "analysis",
|
|
signal: new AbortController().signal,
|
|
timeoutMs: 120_000,
|
|
expiresAtMs: performance.now() + 120_000,
|
|
},
|
|
launcher,
|
|
outputBudget: { root, bytes: selected, entries: 10 },
|
|
retainCleanup: vi.fn(),
|
|
}),
|
|
).rejects.toBe(refused);
|
|
expect(launcher).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
|
|
test.each([
|
|
{ exitCode: 0, cleanupFails: false },
|
|
{ exitCode: 7, cleanupFails: false },
|
|
{ exitCode: 7, cleanupFails: true },
|
|
])(
|
|
"preserves completed exit $exitCode through cancellation during real owned cleanup (fails: $cleanupFails)",
|
|
async ({ exitCode, cleanupFails }) => {
|
|
const root = await createTestTempDirectory("rea-firmware-finalization-");
|
|
const caller = new AbortController();
|
|
const reason = { caller: "cancel during final cleanup" };
|
|
let cleanupCalls = 0;
|
|
const retained: ProviderProcessSupervisor[] = [];
|
|
const failure: unknown = await runFirmwareCommand({
|
|
engine: "binwalk",
|
|
operation: "inspect_firmware_regions",
|
|
command: process.execPath,
|
|
limiter: process.execPath,
|
|
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
|
args: [],
|
|
cwd: root,
|
|
environment: process.env,
|
|
signal: caller.signal,
|
|
deadline: {
|
|
phase: "analysis",
|
|
signal: new AbortController().signal,
|
|
timeoutMs: 30_000,
|
|
expiresAtMs: performance.now() + 30_000,
|
|
},
|
|
launcher: async (options) => {
|
|
const owned = await spawnOwnedProviderProcess({
|
|
...options,
|
|
command: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
`process.stdout.write("scan evidence\\n"); process.stderr.write("producer diagnostics\\n"); process.exitCode = ${exitCode}`,
|
|
],
|
|
});
|
|
return {
|
|
...owned,
|
|
cleanup: async () => {
|
|
cleanupCalls += 1;
|
|
expect(owned.process.exitCode).toBe(exitCode);
|
|
caller.abort(reason);
|
|
const cleaned = await cleanupOwnedProcessGroup(owned.ownership);
|
|
if (cleanupFails && cleanupCalls === 1)
|
|
return {
|
|
cleaned: false as const,
|
|
reason: "controlled cleanup refusal after real release",
|
|
};
|
|
return cleaned;
|
|
},
|
|
};
|
|
},
|
|
retainCleanup: (owner) => {
|
|
retained.push(owner);
|
|
},
|
|
}).catch((cause: unknown) => cause);
|
|
expect(cleanupCalls).toBe(1);
|
|
expect(failure).toBeInstanceOf(AnalysisError);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected typed failure");
|
|
if (exitCode === 0) {
|
|
expect(failure).toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
cause: reason,
|
|
});
|
|
} else if (cleanupFails) {
|
|
expect(retained).toHaveLength(1);
|
|
expect(await retained[0]!.stop()).toMatchObject({
|
|
status: "verified-cleanup",
|
|
});
|
|
expect(cleanupCalls).toBe(2);
|
|
retained[0]!.dispose();
|
|
expect(failure).toMatchObject({
|
|
diagnostics: {
|
|
previous_error: {
|
|
code: "execution_failure",
|
|
details: { diagnostics: { exit_code: 7 } },
|
|
},
|
|
},
|
|
capturedOutput: {
|
|
stdout: "scan evidence\n",
|
|
stderr: "producer diagnostics\n",
|
|
exit_code: 7,
|
|
},
|
|
});
|
|
} else {
|
|
expect(failure).toMatchObject({
|
|
_tag: "ProviderAdapterError",
|
|
diagnostics: {
|
|
exit_code: 7,
|
|
stdout: "scan evidence\n",
|
|
stderr: "producer diagnostics\n",
|
|
},
|
|
});
|
|
expect(projectAnalysisError(failure).code).toBe("execution_failure");
|
|
}
|
|
},
|
|
);
|
|
|
|
// The injected launcher runs real owned Node processes on every POSIX host;
|
|
// Linux firmware binaries and prlimit are deliberately outside this lifecycle seam.
|
|
test.skipIf(process.platform === "win32").each([
|
|
{
|
|
overflow: false,
|
|
exitCode: 7,
|
|
acceptFailure: false,
|
|
cancelBeforeExit: false,
|
|
expected: "ProviderAdapterError",
|
|
},
|
|
{
|
|
overflow: false,
|
|
exitCode: 0,
|
|
acceptFailure: false,
|
|
cancelBeforeExit: false,
|
|
expected: "AnalysisCancelledError",
|
|
},
|
|
{
|
|
overflow: false,
|
|
exitCode: 1,
|
|
acceptFailure: true,
|
|
cancelBeforeExit: false,
|
|
expected: "AnalysisCancelledError",
|
|
},
|
|
{
|
|
overflow: false,
|
|
exitCode: 7,
|
|
acceptFailure: false,
|
|
cancelBeforeExit: true,
|
|
expected: "AnalysisCancelledError",
|
|
},
|
|
{
|
|
overflow: true,
|
|
exitCode: 7,
|
|
acceptFailure: false,
|
|
cancelBeforeExit: false,
|
|
expected: "ProviderAdapterError",
|
|
},
|
|
])(
|
|
"retains root exit $exitCode while a handshaken descendant holds output (accepted: $acceptFailure, earlier cancellation: $cancelBeforeExit, overflow: $overflow)",
|
|
async ({ exitCode, acceptFailure, cancelBeforeExit, expected, overflow }) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-firmware-inherited-output-",
|
|
);
|
|
const caller = new AbortController();
|
|
const reason = { caller: "cancel with inherited output still open" };
|
|
let cleanupCalls = 0;
|
|
let closed = false;
|
|
let exited = false;
|
|
const failure: unknown = await runFirmwareCommand({
|
|
engine: "binwalk",
|
|
operation: "inspect_firmware_regions",
|
|
command: process.execPath,
|
|
limiter: process.execPath,
|
|
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
|
args: [],
|
|
cwd: root,
|
|
environment: process.env,
|
|
signal: caller.signal,
|
|
deadline: {
|
|
phase: "analysis",
|
|
signal: new AbortController().signal,
|
|
timeoutMs: 30_000,
|
|
expiresAtMs: performance.now() + 30_000,
|
|
},
|
|
acceptReportedExtractionFailure: acceptFailure,
|
|
launcher: async (options) => {
|
|
const exitRelease = join(root, "exit-release");
|
|
const tailRelease = join(root, "tail-release");
|
|
const descendant = `
|
|
const { existsSync } = require("node:fs");
|
|
setInterval(() => {}, 1000);
|
|
const tail = setInterval(() => {
|
|
if (!existsSync(${JSON.stringify(tailRelease)})) return;
|
|
clearInterval(tail);
|
|
process.stderr.write(Buffer.alloc(${overflow ? FIRMWARE_LIMITS.diagnosticBytes : 0}, 120), () => {
|
|
process.stderr.write("descendant-tail\\n");
|
|
});
|
|
}, 10);
|
|
process.send("ready");
|
|
`;
|
|
const owned = await spawnOwnedProviderProcess({
|
|
...options,
|
|
command: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
`
|
|
const { spawn } = require("node:child_process");
|
|
const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], {
|
|
stdio: ["ignore", "inherit", "inherit", "ipc"],
|
|
});
|
|
child.once("message", () => {
|
|
process.stderr.write("descendant-ready\\n");
|
|
child.disconnect();
|
|
child.unref();
|
|
if (${cancelBeforeExit}) {
|
|
const release = setInterval(() => {
|
|
if (!require("node:fs").existsSync(${JSON.stringify(exitRelease)})) return;
|
|
clearInterval(release);
|
|
process.exitCode = ${exitCode};
|
|
}, 10);
|
|
} else process.exitCode = ${exitCode};
|
|
});
|
|
`,
|
|
],
|
|
});
|
|
owned.process.once("close", () => {
|
|
closed = true;
|
|
});
|
|
let diagnostics = "";
|
|
owned.process.stderr?.on("data", (chunk: Buffer) => {
|
|
diagnostics += chunk.toString();
|
|
if (cancelBeforeExit && !caller.signal.aborted) {
|
|
expect(exited).toBe(false);
|
|
caller.abort(reason);
|
|
void writeFile(exitRelease, "release");
|
|
} else if (
|
|
!cancelBeforeExit &&
|
|
diagnostics.includes("descendant-tail")
|
|
) {
|
|
expect(exited).toBe(true);
|
|
expect(closed).toBe(false);
|
|
caller.abort(reason);
|
|
}
|
|
});
|
|
owned.process.once("exit", () => {
|
|
exited = true;
|
|
// Release descendant diagnostics only after the supervisor has also
|
|
// observed the root's exit, leaving inherited pipes open until cleanup.
|
|
queueMicrotask(() => {
|
|
void writeFile(tailRelease, "release");
|
|
});
|
|
});
|
|
return {
|
|
...owned,
|
|
cleanup: async () => {
|
|
cleanupCalls += 1;
|
|
return cleanupOwnedProcessGroup(owned.ownership);
|
|
},
|
|
};
|
|
},
|
|
retainCleanup: () => {
|
|
throw new Error("Real descendant cleanup must complete");
|
|
},
|
|
}).catch((cause: unknown) => cause);
|
|
expect(exited).toBe(true);
|
|
expect(cleanupCalls).toBe(1);
|
|
expect(failure).toMatchObject({ _tag: expected });
|
|
if (expected === "ProviderAdapterError")
|
|
expect(failure).toMatchObject({
|
|
diagnostics: {
|
|
exit_code: 7,
|
|
stderr: overflow
|
|
? expect.stringMatching(/^descendant-ready\nxxx/)
|
|
: "descendant-ready\ndescendant-tail\n",
|
|
truncated: overflow,
|
|
},
|
|
});
|
|
else expect(failure).toMatchObject({ cause: reason });
|
|
},
|
|
);
|