mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 23:06:18 +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>
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
inventoryFirmwareOutput,
|
|
FirmwareFileAccess,
|
|
} from "../../../src/firmware/FirmwareFiles.js";
|
|
import { readFirmwareCommandReport } from "../../../src/firmware/FirmwareCommand.js";
|
|
import { FIRMWARE_LIMITS } from "../../../src/firmware/FirmwareRelease.js";
|
|
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
|
import {
|
|
normalizeBinwalkReport,
|
|
normalizeUnblobReport,
|
|
} from "../../../src/firmware/FirmwareReports.js";
|
|
import {
|
|
firmwareResultSchemas,
|
|
firmwareRequestSchema,
|
|
} from "../../../src/domain/firmware/firmwareAnalysis.js";
|
|
import { createHash } from "node:crypto";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
it.each([
|
|
["invalid leading byte", [0xff]],
|
|
["incomplete sequence", [0xe2, 0x82]],
|
|
["overlong sequence", [0xc0, 0xaf]],
|
|
["encoded surrogate", [0xed, 0xa0, 0x80]],
|
|
])(
|
|
"rejects complete JSON containing %s without repairing producer bytes",
|
|
async (_name, bytes) => {
|
|
const root = await createTestTempDirectory("rea-firmware-report-");
|
|
const path = join(root, "report.json");
|
|
await writeFile(
|
|
path,
|
|
Buffer.concat([
|
|
Buffer.from('{"description":"'),
|
|
Buffer.from(bytes),
|
|
Buffer.from('"}'),
|
|
]),
|
|
);
|
|
await expect(
|
|
new FirmwareFileAccess().report(path, "inspect_firmware_regions"),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisOutputError",
|
|
reason: "Tool report is not valid complete JSON",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["missing", "Report was not created", async () => {}],
|
|
["empty", "Report is empty", (path: string) => writeFile(path, "")],
|
|
[
|
|
"invalid_json",
|
|
"Tool report is not valid complete JSON",
|
|
(path: string) => writeFile(path, '[{"Analysis":'),
|
|
],
|
|
[
|
|
"oversized",
|
|
`Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`,
|
|
(path: string) =>
|
|
writeFile(path, Buffer.alloc(FIRMWARE_LIMITS.reportBytes + 1, 0x20)),
|
|
],
|
|
[
|
|
"not_regular_file",
|
|
"Report is not a regular file",
|
|
(path: string) => mkdir(path),
|
|
],
|
|
] as const)(
|
|
"keeps report state %s after a successful exit distinct from an empty scan",
|
|
async (state, reason, prepare) => {
|
|
const root = await createTestTempDirectory("rea-firmware-report-");
|
|
const path = join(root, "report.json");
|
|
await prepare(path);
|
|
const failure: unknown = await readFirmwareCommandReport({
|
|
path,
|
|
engine: "binwalk",
|
|
operation: "inspect_firmware_regions",
|
|
execution: {
|
|
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
|
stderr: { text: "", bytes: 0, observedBytes: 0 },
|
|
exit_code: 0,
|
|
signal: null,
|
|
command: "/opt/binwalk",
|
|
args: ["--quiet", "--log", path, "/work/firmware.bin"],
|
|
resource_limiter: "/usr/bin/prlimit",
|
|
},
|
|
files: new FirmwareFileAccess(),
|
|
}).catch((cause: unknown) => cause);
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected a typed report failure");
|
|
const projected = projectAnalysisError(failure);
|
|
expect(projected).toMatchObject({
|
|
code: "unreadable_output",
|
|
retryable: false,
|
|
message: expect.stringContaining("not an empty or complete result"),
|
|
details: {
|
|
reason: `binwalk exited 0 without a usable report: ${reason}`,
|
|
diagnostics: {
|
|
report_state: state,
|
|
report_path: path,
|
|
command: "/opt/binwalk",
|
|
args: ["--quiet", "--log", path, "/work/firmware.bin"],
|
|
resource_limiter: "/usr/bin/prlimit",
|
|
exit_code: 0,
|
|
signal: null,
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
},
|
|
},
|
|
});
|
|
// Generic doctor advice is not presented as a repair for a missing report.
|
|
expect(projected.message).not.toContain("rea doctor");
|
|
// Recorded arguments name a removed private workspace; recovery must not
|
|
// present them as a replayable command.
|
|
expect(projected.remediation.action).toContain("diagnostics.report_state");
|
|
expect(projected.remediation.action).toContain("cannot be replayed as-is");
|
|
if (state === "oversized") {
|
|
expect(projected.remediation.action).toContain("no runtime override");
|
|
expect(projected.remediation.action).toContain(
|
|
"Repeating the unchanged request fails the same way",
|
|
);
|
|
expect(projected.remediation.action).not.toContain(
|
|
"Rerun the same REA request",
|
|
);
|
|
expect(projected.message).toContain("no runtime override");
|
|
expect(projected.message).not.toContain("Rerun the same REA request");
|
|
} else {
|
|
expect(projected.remediation.action).toContain(
|
|
"replacing every private path",
|
|
);
|
|
expect(projected.remediation.action).toContain(
|
|
"Rerun the same REA request",
|
|
);
|
|
expect(projected.remediation.action).not.toContain(
|
|
"Repeating the unchanged request fails the same way",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("preserves valid Unicode and a literal replacement character in producer JSON", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-report-");
|
|
const path = join(root, "report.json");
|
|
const report = { description: "固件 🧩 \ufffd", path: "/输入/firmware.bin" };
|
|
await writeFile(path, JSON.stringify(report));
|
|
expect(
|
|
await new FirmwareFileAccess().report(path, "inspect_firmware_regions"),
|
|
).toEqual(report);
|
|
});
|
|
|
|
it("orders extracted firmware paths by code point independently of host locale", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-inventory-");
|
|
await mkdir(join(root, "Lib"));
|
|
for (const path of ["a.bin", "z.bin", "\u00e4.bin", "B.bin", "Lib/x.bin"])
|
|
await writeFile(join(root, path), "");
|
|
const entries = await inventoryFirmwareOutput(
|
|
root,
|
|
1024,
|
|
16,
|
|
Number.MAX_SAFE_INTEGER,
|
|
"inspect_firmware_regions",
|
|
);
|
|
// Locale collation would interleave case and place "ä" by language rules.
|
|
expect(entries.map(({ relativePath }) => relativePath)).toEqual([
|
|
"B.bin",
|
|
"Lib/x.bin",
|
|
"a.bin",
|
|
"z.bin",
|
|
"\u00e4.bin",
|
|
]);
|
|
});
|
|
|
|
it.skipIf(process.platform !== "linux")(
|
|
"refuses raw filenames that cannot round-trip through UTF-8 instead of omitting their bytes",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-raw-name-");
|
|
await writeFile(
|
|
Buffer.concat([
|
|
Buffer.from(`${root}/bad`),
|
|
Buffer.from([0xff]),
|
|
Buffer.from(".bin"),
|
|
]),
|
|
"123",
|
|
);
|
|
await writeFile(join(root, "good.bin"), "1234");
|
|
await expect(
|
|
inventoryFirmwareOutput(
|
|
root,
|
|
4,
|
|
10,
|
|
Number.MAX_SAFE_INTEGER,
|
|
"extract_firmware",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisOutputError",
|
|
diagnostics: { raw_name_hex: "626164ff2e62696e" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ bytes: 1, files: 10, expected: "max_output_bytes" },
|
|
{ bytes: 10, files: 1, expected: "max_output_files" },
|
|
])(
|
|
"reports the selected $expected staging budget as a deterministic resource refusal",
|
|
async ({ bytes, files, expected }) => {
|
|
const root = await createTestTempDirectory("rea-firmware-staging-");
|
|
await writeFile(join(root, "a"), "1");
|
|
await writeFile(join(root, "b"), "2");
|
|
await expect(
|
|
inventoryFirmwareOutput(
|
|
root,
|
|
bytes,
|
|
files,
|
|
Number.MAX_SAFE_INTEGER,
|
|
"extract_firmware",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "capacity",
|
|
reportedLimits: { [expected]: 1 },
|
|
retryable: false,
|
|
remediationAction: expect.stringContaining(expected),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("refuses a staged file that reaches the producer's per-file limit", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-file-limit-");
|
|
await writeFile(join(root, "below"), "123");
|
|
await expect(
|
|
inventoryFirmwareOutput(root, 1024, 10, 4, "extract_firmware"),
|
|
).resolves.toEqual([expect.objectContaining({ size: 3 })]);
|
|
await writeFile(join(root, "reached"), "1234");
|
|
await expect(
|
|
inventoryFirmwareOutput(root, 1024, 10, 4, "extract_firmware"),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisResourceConstraintError",
|
|
resource: "file-size",
|
|
reportedLimits: { file_size_bytes: 4 },
|
|
retryable: false,
|
|
});
|
|
});
|
|
|
|
it("attributes missing sources to the selected path without relabeling snapshot destination failure", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-source-");
|
|
const files = new FirmwareFileAccess();
|
|
const request = (path: string) =>
|
|
firmwareRequestSchema.parse({
|
|
operation: "inspect_firmware_regions",
|
|
input: { path },
|
|
});
|
|
await expect(
|
|
files.snapshot(
|
|
request(join(root, "absent")),
|
|
root,
|
|
new AbortController().signal,
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisInputError",
|
|
issues: [{ path: ["path"], reason: "invalid_value" }],
|
|
});
|
|
const source = join(root, "source");
|
|
await writeFile(source, "firmware");
|
|
await expect(
|
|
files.snapshot(
|
|
request(source),
|
|
join(root, "missing-workspace"),
|
|
new AbortController().signal,
|
|
),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
await files.close();
|
|
});
|
|
|
|
it("does not activate an abort-bound source during awaited destination setup", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-publish-abort-");
|
|
const source = join(root, "source");
|
|
const bytes = Buffer.from("firmware");
|
|
await writeFile(source, bytes);
|
|
const files = new FirmwareFileAccess();
|
|
const tree = await SafeOutputTree.create(join(root, "published"));
|
|
const controller = new AbortController();
|
|
const reason = { stop: "during destination setup" };
|
|
const write = tree.write.bind(tree);
|
|
const spy = vi.spyOn(tree, "write").mockImplementation(async (...args) => {
|
|
controller.abort(reason);
|
|
// Yield a turn while the source has no consumer, reproducing the handoff.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
return write(...args);
|
|
});
|
|
try {
|
|
await expect(
|
|
files.publish(
|
|
{
|
|
path: source,
|
|
relativePath: "nested/file",
|
|
kind: "file",
|
|
size: bytes.length,
|
|
linkTarget: null,
|
|
},
|
|
createHash("sha256").update(bytes).digest("hex"),
|
|
tree,
|
|
controller.signal,
|
|
),
|
|
).rejects.toMatchObject({ reason: "cancelled", cause: reason });
|
|
} finally {
|
|
spy.mockRestore();
|
|
await tree.rollback();
|
|
await files.close();
|
|
}
|
|
await expect(access(join(root, "published"))).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
expect(Buffer.compare(await readFile(source), bytes)).toBe(0);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
"keeps denied source access distinct from invalid paths and producer failures",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-source-denial-");
|
|
const source = join(root, "input");
|
|
await writeFile(source, "firmware", { mode: 0o000 });
|
|
const files = new FirmwareFileAccess();
|
|
try {
|
|
await expect(
|
|
files.snapshot(
|
|
firmwareRequestSchema.parse({
|
|
operation: "inspect_firmware_regions",
|
|
input: { path: source },
|
|
}),
|
|
root,
|
|
new AbortController().signal,
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisAccessDeniedError",
|
|
path: source,
|
|
systemCode: "EACCES",
|
|
});
|
|
} finally {
|
|
await chmod(source, 0o600);
|
|
await files.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retains unfamiliar Unblob report members from JSON files through public diagnostics", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-report-metadata-");
|
|
const inputPath = join(root, "input.bin");
|
|
const reportPath = join(root, "report.json");
|
|
const sha256 = "a".repeat(64);
|
|
const unfamiliar: unknown = JSON.parse(
|
|
'{"__typename__":"FutureReport","__proto__":{"source":"local","empty":null},"constructor":false,"prototype":0,"nested":{"__proto__":"retained"}}',
|
|
);
|
|
const record = {
|
|
task: { path: inputPath, depth: 0, blob_id: "", is_multi_file: false },
|
|
reports: [
|
|
{ __typename__: "HashReport", sha256 },
|
|
{
|
|
__typename__: "StatReport",
|
|
path: inputPath,
|
|
size: 4,
|
|
is_dir: false,
|
|
is_file: true,
|
|
is_link: false,
|
|
link_target: null,
|
|
},
|
|
unfamiliar,
|
|
],
|
|
subtasks: [],
|
|
};
|
|
await writeFile(reportPath, JSON.stringify([record]));
|
|
const files = new FirmwareFileAccess();
|
|
try {
|
|
const raw = await files.report(reportPath, "extract_firmware");
|
|
const normalized = normalizeUnblobReport(raw, {
|
|
inputPath,
|
|
outputRoot: join(root, "output"),
|
|
sha256,
|
|
length: 4,
|
|
offset: 0,
|
|
maxDepth: 3,
|
|
});
|
|
const diagnostics =
|
|
firmwareResultSchemas.extract_firmware.shape.diagnostics.parse(
|
|
normalized.diagnostics,
|
|
);
|
|
expect(JSON.stringify(diagnostics)).toBe(
|
|
JSON.stringify([{ input_path: "$input", report: unfamiliar }]),
|
|
);
|
|
expect(normalized.coverage).toBe("partial");
|
|
for (const malformed of [{}, { __typename__: null }, { __typename__: 1 }]) {
|
|
expect(() =>
|
|
normalizeUnblobReport(
|
|
[{ ...record, reports: [...record.reports.slice(0, 2), malformed] }],
|
|
{
|
|
inputPath,
|
|
outputRoot: join(root, "output"),
|
|
sha256,
|
|
length: 4,
|
|
offset: 0,
|
|
maxDepth: 3,
|
|
},
|
|
),
|
|
).toThrow();
|
|
}
|
|
} finally {
|
|
await files.close();
|
|
}
|
|
});
|
|
|
|
it("rejects Binwalk extraction activity under every own JSON member", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-firmware-report-extractions-",
|
|
);
|
|
const reportPath = join(root, "report.json");
|
|
const inputPath = join(root, "input.bin");
|
|
const files = new FirmwareFileAccess();
|
|
try {
|
|
for (const extractions of [
|
|
"{}",
|
|
'{"__proto__":null}',
|
|
'{"ordinary":false}',
|
|
]) {
|
|
await writeFile(
|
|
reportPath,
|
|
`[{"Analysis":{"file_path":${JSON.stringify(inputPath)},"file_map":[],"extractions":${extractions}}}]`,
|
|
);
|
|
const raw = await files.report(reportPath, "inspect_firmware_regions");
|
|
const normalize = () => normalizeBinwalkReport(raw, inputPath, 4);
|
|
if (extractions === "{}") expect(normalize()).toEqual([]);
|
|
else expect(normalize).toThrow("Expected one inspection-only report");
|
|
}
|
|
} finally {
|
|
await files.close();
|
|
}
|
|
});
|