Files
reverse-engineering-anything/src/firmware/FirmwareCommand.test.ts
T

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();
},
);