diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index b0f92c14dc..7cbbc26923 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -183,6 +183,9 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture. 120 CPU seconds and zero core-dump allowance through `prlimit`. - JSON reports: at most **8 MiB** and valid UTF-8; malformed encoding and oversized reports fail without repairing or truncating producer data. + Exit status 0 is not an empty result: a missing, empty, malformed or + oversized report fails with `unreadable_output`, its `report_state`, the + command, exit status and captured output. REA does not retry automatically. Diagnostic output is bounded with exact captured byte counts. - Default staging budget: **256 MiB** and **10,000 entries**, configurable using `max_output_bytes` / `max_output_files` (CLI `--max-output-bytes` / diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index 1b4362c47d..b677f79535 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -291,7 +291,12 @@ export const runFirmwareCommand = async (context: { }; }; -/** Preserve tool failure diagnostics when an accepted exit 1 has no usable report. */ +/** + * Consume the report a completed command was asked to write. A successful exit + * status is not analysis Evidence by itself: an absent, empty or malformed report + * fails with the observed report state and command facts, without retrying or + * substituting an empty result. + */ export const readFirmwareCommandReport = async (context: { path: string; engine: string; @@ -308,33 +313,43 @@ export const readFirmwareCommandReport = async (context: { ); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) throw cause; - if (context.execution.exit_code === 1) - throw new ProviderAdapterError(context.engine, context.operation, { + const { engine, operation, execution } = context; + const reportFailure = + cause instanceof AnalysisOutputError + ? cause.reason + : `Report could not be read: ${cause instanceof Error ? cause.message : String(cause)}`; + const diagnostics = { + ...(cause instanceof AnalysisOutputError ? cause.diagnostics : {}), + report_path: context.path, + report_failure: reportFailure, + command: execution.command, + args: execution.args, + resource_limiter: execution.resource_limiter, + exit_code: execution.exit_code ?? null, + signal: execution.signal, + stdout: execution.stdout.text, + stderr: execution.stderr.text, + stdout_bytes: execution.stdout.bytes, + stderr_bytes: execution.stderr.bytes, + }; + if (execution.exit_code === 1) + throw new ProviderAdapterError(engine, operation, { cause, diagnostics: { reason: "Tool exited 1 without a usable analysis report; inspect stderr for startup, sandbox or dependency failures", - report_failure: - cause instanceof Error ? cause.message : String(cause), - exit_code: 1, - signal: context.execution.signal, - stdout: context.execution.stdout.text, - stderr: context.execution.stderr.text, - stdout_bytes: context.execution.stdout.bytes, - stderr_bytes: context.execution.stderr.bytes, + ...diagnostics, }, }); - const diagnostics = `stdout (${context.execution.stdout.bytes} bytes): ${context.execution.stdout.text}; stderr (${context.execution.stderr.bytes} bytes): ${context.execution.stderr.text}`; - if (cause instanceof AnalysisOutputError) - throw new AnalysisOutputError( - context.operation, - `${cause.reason}; ${diagnostics}`, - { cause }, - ); throw new AnalysisOutputError( - context.operation, - `Tool did not produce the expected complete report: ${context.path}; ${diagnostics}`, - { cause }, + operation, + `${engine} exited ${String(execution.exit_code)} without a usable report: ${reportFailure}`, + { + cause, + diagnostics, + userMessage: `${engine} exited successfully but left no usable report (${reportFailure}), so ${operation} produced no Evidence. A successful exit status is not an empty or complete result.`, + remediationAction: `Review diagnostics.report_state, the exit status and captured stdout/stderr. REA did not retry or substitute an empty result. diagnostics.command and diagnostics.args record what ran; their workspace paths are REA-private and removed after the attempt, so they cannot be replayed as-is. Rerun the same REA request to check whether this is intermittent; each attempt uses a fresh workspace. If it recurs, copy the selected input bytes into a new directory and run diagnostics.command with the same flags, replacing every private path with your copy and a new report path, under the documented firmware resource limits; confirm that it writes the requested report. \`rea doctor\` checks configuration only and cannot recover this report.`, + }, ); } }; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index 1451af6106..699f276e34 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -188,7 +188,11 @@ export class FirmwareFileAccess { ); } - /** Read a complete bounded report; oversized data is never reported as complete. */ + /** + * Read a complete bounded report; oversized data is never reported as complete. + * Each refusal records its observed report state, so an absent or empty report + * is never conflated with an empty scan or a malformed one. + */ async report( path: string, operation: string, @@ -201,11 +205,14 @@ export class FirmwareFileAccess { FIRMWARE_LIMITS.reportBytes, ); if (bytes === undefined) - throw new AnalysisOutputError( + throw firmwareReportFailure( operation, + "oversized", `Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`, ); assertArtifactReadActive(signal); + if (bytes.length === 0) + throw firmwareReportFailure(operation, "empty", "Report is empty"); try { const value: unknown = JSON.parse( new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( @@ -214,25 +221,57 @@ export class FirmwareFileAccess { ); return value; } catch (cause: unknown) { - throw new AnalysisOutputError( + throw firmwareReportFailure( operation, + "invalid_json", "Tool report is not valid complete JSON", - { cause }, + cause, ); } }).catch((cause: unknown) => { - throw firmwareAdmissionFailure( - cause, - (primary) => - new AnalysisOutputError(operation, "Report is not a regular file", { - cause: primary, - }), + throw mapArtifactFileFailure(cause, (primary) => + primary instanceof NonRegularFileReadError + ? firmwareReportFailure( + operation, + "not_regular_file", + "Report is not a regular file", + primary, + ) + : primary instanceof Error && + "code" in primary && + primary.code === "ENOENT" + ? firmwareReportFailure( + operation, + "missing", + "Report was not created", + primary, + ) + : primary, ); }), ); } } +/** Observed state of a producer report that cannot supply analysis Evidence. */ +type FirmwareReportState = + | "missing" + | "empty" + | "oversized" + | "invalid_json" + | "not_regular_file"; + +const firmwareReportFailure = ( + operation: string, + state: FirmwareReportState, + reason: string, + cause?: unknown, +) => + new AnalysisOutputError(operation, reason, { + cause, + diagnostics: { report_state: state }, + }); + /** Preserve admission semantics inside the same exact owner's physical outcome. */ const firmwareAdmissionFailure = ( cause: unknown, diff --git a/tests/boundary/firmware/firmwareIntegration.test.ts b/tests/boundary/firmware/firmwareIntegration.test.ts index d2f1a22872..13cfadb00d 100644 --- a/tests/boundary/firmware/firmwareIntegration.test.ts +++ b/tests/boundary/firmware/firmwareIntegration.test.ts @@ -730,7 +730,7 @@ it("retains startup stderr when Unblob exits 1 without a report", async () => { diagnostics: { exit_code: 1, stderr: expect.stringContaining("Landlock sandbox is not available"), - report_failure: expect.stringContaining("ENOENT"), + report_state: "missing", }, }, }); @@ -740,25 +740,51 @@ it("retains startup stderr when Unblob exits 1 without a report", async () => { await assertFirmwareCleanup(fixture.launches); }); -it("distinguishes a successful process with missing output from startup failure", async () => { - const fixture = await firmwareFixture("missing-report"); - const result = await fixture.service.execute("extract_firmware", { - path: fixture.path, - output_directory: fixture.output, - }); - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisOutputError", - reason: expect.stringContaining("expected complete report"), - }, - }); - if (result.ok) throw new Error("Expected missing-output failure"); - expect(projectAnalysisError(result.error)).toMatchObject({ - details: { reason: expect.stringContaining("Producer forgot its report") }, - }); - await assertFirmwareCleanup(fixture.launches); -}); +it.each([ + { operation: "inspect_firmware_regions", engine: "binwalk" }, + { operation: "extract_firmware", engine: "unblob" }, +] as const)( + "keeps $engine's missing report after exit 0 distinct from an empty result", + async ({ operation, engine }) => { + const fixture = await firmwareFixture("missing-report"); + const result = await fixture.service.execute(operation, { + path: fixture.path, + ...(operation === "extract_firmware" + ? { output_directory: fixture.output } + : {}), + }); + if (result.ok) throw new Error("Expected missing-report failure"); + const error = { + code: "unreadable_output", + retryable: false, + message: expect.stringContaining("not an empty or complete result"), + details: { + reason: `${engine} exited 0 without a usable report: Report was not created`, + diagnostics: { + report_state: "missing", + report_path: expect.stringMatching(/report\.json$/), + command: expect.any(String), + args: expect.any(Array), + exit_code: 0, + signal: null, + stderr: "Producer forgot its report\n", + }, + }, + remediation: { + action: expect.stringContaining("did not retry or substitute"), + }, + }; + expect(projectAnalysisError(result.error)).toMatchObject(error); + expect(projectAnalysisError(result.error).message).not.toContain( + "rea doctor", + ); + expect( + delivery.toCallToolResult(result, toolContract(operation)) + .structuredContent, + ).toMatchObject({ error }); + await assertFirmwareCleanup(fixture.launches); + }, +); it("cancels an owned active process and permits the next queued operation", async () => { const fixture = await firmwareFixture("stall"); diff --git a/tests/boundary/firmware/firmwareReportFiles.test.ts b/tests/boundary/firmware/firmwareReportFiles.test.ts index 69360ef25d..7415856728 100644 --- a/tests/boundary/firmware/firmwareReportFiles.test.ts +++ b/tests/boundary/firmware/firmwareReportFiles.test.ts @@ -6,6 +6,10 @@ 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 { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it.each([ @@ -35,6 +39,78 @@ it.each([ }, ); +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).toMatch( + /diagnostics\.report_state.*cannot be replayed as-is.*replacing every private path/, + ); + }, +); + 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");