mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00:00
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
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 { FirmwareFileAccess } from "./FirmwareFiles.js";
|
|
import {
|
|
resolveFirmwareCommand,
|
|
runFirmwareCommand,
|
|
} from "./FirmwareCommand.js";
|
|
import {
|
|
spawnOwnedProviderProcess,
|
|
type ProviderProcessHandle,
|
|
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),
|
|
});
|
|
});
|
|
|
|
it("keeps captured tool output when firmware stops on SIGXCPU", async () => {
|
|
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: "SIGXCPU",
|
|
kill: () => false,
|
|
};
|
|
let ended = 0;
|
|
const close = () => {
|
|
ended += 1;
|
|
if (ended === 2) emitter.emit("close", null, "SIGXCPU");
|
|
};
|
|
stdout.once("end", close);
|
|
stderr.once("end", close);
|
|
setTimeout(() => {
|
|
stdout.end("partial scan\n");
|
|
stderr.end("cpu limit\n");
|
|
}, 15);
|
|
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: {
|
|
phase: "analysis",
|
|
signal: analysis.signal,
|
|
timeoutMs: 30_000,
|
|
expiresAtMs: performance.now() + 30_000,
|
|
},
|
|
launcher: () =>
|
|
Promise.resolve({
|
|
process: child,
|
|
ownership: {
|
|
runId: "rea-firmware-signal",
|
|
leaderPid: 4242,
|
|
processGroupId: 4242,
|
|
},
|
|
cleanup: () => Promise.resolve({ cleaned: true, signaled: false }),
|
|
}),
|
|
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 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,
|
|
},
|
|
{
|
|
selected: 16 * 1024 * 1024,
|
|
inherited: Number.MAX_SAFE_INTEGER,
|
|
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();
|
|
},
|
|
);
|