From 243790adc9c6ce147ba10e330209bfc2422b7923 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:27:17 +0800 Subject: [PATCH] fix(firmware): derive extraction failure from producer severity --- docs/firmware-analysis.md | 5 + src/firmware/FirmwareReports.test.ts | 114 +++++++++++++++++- src/firmware/FirmwareReports.ts | 16 ++- .../firmware/firmwareIntegration.test.ts | 43 +++++++ tests/fixtures/firmware/producer.mjs | 27 ++++- 5 files changed, 198 insertions(+), 7 deletions(-) diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 8eb0ca16fd..736b66839c 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -139,6 +139,11 @@ build's source revision, so that field remains unknown. share recovery prose without losing any region identity. Instructions use already configured capabilities where one exists. Every extraction rerun requires a fresh absent `output_directory`, preserving the prior evidence. + Unblob's declared `ERROR` severity establishes an extraction failure, + including standalone `UnknownError` task exceptions. Warnings and resolved + extraction problems remain diagnostics with partial coverage; they do not + establish failed extractor stages. Standalone errors do not acquire a nearby + chunk's handler or byte range. Nested unknown chunks use `inspect_firmware_regions` on the actual published child rather than repeating the same extraction with unchanged handlers. Inspection requires separately configured Binwalk (`REA_BINWALK_COMMAND`); diff --git a/src/firmware/FirmwareReports.test.ts b/src/firmware/FirmwareReports.test.ts index 1a491d5073..937f87ccba 100644 --- a/src/firmware/FirmwareReports.test.ts +++ b/src/firmware/FirmwareReports.test.ts @@ -382,6 +382,116 @@ describe("firmware producer chunk failures", () => { }); }); +describe("firmware producer error severity", () => { + const outcomes = [ + { + name: "task exception", + report: { + __typename__: "UnknownError", + severity: "ERROR", + exception: "inert fixture", + }, + failed: true, + }, + { + name: "future declared error", + report: { __typename__: "FutureProducerError", severity: "ERROR" }, + failed: true, + }, + { + name: "command warning", + report: { + __typename__: "ExtractCommandFailedReport", + severity: "WARNING", + command: "fixture", + stdout: "", + stderr: "", + exit_code: 1, + }, + failed: false, + }, + { + name: "resolved extraction problem", + report: { + __typename__: "ExtractionProblem", + problem: "duplicate path", + resolution: "kept first entry", + path: "config", + }, + failed: false, + }, + ]; + + it.each(["task", "chunk", "multi-file"] as const)( + "preserves diagnostic outcomes at the %s owner", + (owner) => { + for (const { report, failed } of outcomes) { + const record = root(); + const observation = + owner === "task" + ? report + : owner === "chunk" + ? { + __typename__: "ChunkReport", + id: "stage-id", + handler_name: "gzip", + start_offset: 0, + end_offset: 20, + size: 20, + is_encrypted: false, + extraction_reports: [report], + } + : { + __typename__: "MultiFileReport", + id: "stage-id", + handler_name: "gzip", + name: "rootfs", + paths: [], + extraction_reports: [report], + }; + const normalized = normalizeUnblobReport( + [ + { + ...record, + reports: [ + ...record.reports.filter( + (item) => item.__typename__ !== "UnknownChunkReport", + ), + observation, + ], + }, + ], + context, + ); + expect(normalized.coverage).toBe("partial"); + expect(normalized.diagnostics).toHaveLength(1); + expect(normalized.diagnostics[0]).toMatchObject({ + input_path: "$input", + report: owner === "multi-file" ? observation : report, + }); + if (owner === "chunk") { + expect(normalized.chunks[0]?.extraction_failed).toBe(failed); + expect(normalized.failed_extraction_stages).toEqual([]); + } else { + expect(normalized.failed_extraction_stages).toEqual( + failed + ? [ + { + input_path: "$input", + provider_id: owner === "task" ? null : "stage-id", + handler: owner === "task" ? null : "gzip", + dependencies: [], + kind: "extractor-failure", + }, + ] + : [], + ); + } + } + }, + ); +}); + describe("firmware task dependency failures", () => { it.each([{ dependencies: [] }, { dependencies: ["", ""] }])( "retains unnamed dependency failures ($dependencies)", @@ -686,9 +796,9 @@ describe("failed multi-file extraction stages", () => { is_multi_file: true, }; const stageFailure = { - __typename__: "ExtractorExecutionErrorReport", + __typename__: "UnknownError", severity: "ERROR", - handler: "multipart-firmware", + exception: "inert multi-file extraction failure", }; const normalized = normalizeUnblobReport( [ diff --git a/src/firmware/FirmwareReports.ts b/src/firmware/FirmwareReports.ts index cbbb52e5b2..60e508ec4a 100644 --- a/src/firmware/FirmwareReports.ts +++ b/src/firmware/FirmwareReports.ts @@ -61,6 +61,12 @@ const missingDependency = z.object({ __typename__: z.literal("ExtractorDependencyNotFoundReport"), dependencies: z.array(z.string()), }); +// Unblob reports resolved extraction problems and warnings alongside errors. +// The declared severity owns failure; a diagnostic's presence or class name +// alone does not establish that extraction failed. +const extractionError = z.object({ severity: z.literal("ERROR") }); +const hasExtractionError = (reports: readonly unknown[]): boolean => + reports.some((item) => extractionError.safeParse(item).success); const region = z.object({ id: z.string(), start_offset: integer, @@ -359,7 +365,8 @@ export const normalizeUnblobReport = ( ); noteMissingDependencies(path, [item]); if ( - item.__typename__ === "ExtractorExecutionErrorReport" && + hasExtractionError([item]) && + !missingDependency.safeParse(item).success && !taskFailures.has(path) ) { taskFailures.add(path); @@ -396,7 +403,7 @@ export const normalizeUnblobReport = ( .parse(item) : null; const extractionFailed = - detail !== null && detail.extraction_reports.length > 0; + detail !== null && hasExtractionError(detail.extraction_reports); if (detail !== null) for (const problem of detail.extraction_reports) { diagnostics.push( @@ -446,6 +453,11 @@ export const normalizeUnblobReport = ( // The report owns its failure and dependencies. A produced subtask // is a derivation, not a new owner for the parent's report. const dependencies = dependencyNames(parsed.extraction_reports); + if ( + dependencies === null && + !hasExtractionError(parsed.extraction_reports) + ) + continue; const rows = dependencies === null ? [{ dependencies: [] as readonly string[] }] diff --git a/tests/boundary/firmware/firmwareIntegration.test.ts b/tests/boundary/firmware/firmwareIntegration.test.ts index eb3ebd8d48..9b347f2de3 100644 --- a/tests/boundary/firmware/firmwareIntegration.test.ts +++ b/tests/boundary/firmware/firmwareIntegration.test.ts @@ -524,6 +524,49 @@ it("reports a failed multi-file stage as an extractor-failure gap", async () => ); }); +it.each(["task-error", "task-warning"])( + "preserves standalone diagnostic recovery without inventing stage ownership: %s", + async (mode) => { + const fixture = await firmwareFixture(mode); + const result = await fixture.service.execute("extract_firmware", { + path: fixture.path, + output_directory: fixture.output, + }); + if (!result.ok) throw result.error; + const normalized = firmwareResultSchemas.extract_firmware.parse( + result.value.normalized_result, + ); + const failures = gapsWithContinuations(normalized).filter( + (gap) => gap.kind === "extractor-failure", + ); + expect(normalized.diagnostics).toContainEqual( + expect.objectContaining({ + input_path: "$input", + report: expect.objectContaining({ + __typename__: + mode === "task-error" + ? "UnknownError" + : "ExtractCommandFailedReport", + }), + }), + ); + if (mode === "task-error") { + expect(failures).toEqual([ + expect.objectContaining({ + input_path: "$input", + handler: null, + provider_id: null, + range: null, + root_file_range: null, + dependencies: [], + continuation: expect.stringContaining("diagnostics"), + }), + ]); + } else expect(failures).toEqual([]); + await assertFirmwareCleanup(fixture.launches); + }, +); + it("carries the owning multi-file stage on a task-level dependency gap", async () => { const fixture = await firmwareFixture("multi-file-dependency"); const result = await fixture.service.execute("extract_firmware", { diff --git a/tests/fixtures/firmware/producer.mjs b/tests/fixtures/firmware/producer.mjs index 02a8fc68c2..d3aca38e2c 100644 --- a/tests/fixtures/firmware/producer.mjs +++ b/tests/fixtures/firmware/producer.mjs @@ -325,9 +325,9 @@ if (args.includes("--version")) { dependencies: ["sasquatch"], } : { - __typename__: "ExtractorExecutionErrorReport", + __typename__: "UnknownError", severity: "ERROR", - handler: "tar", + exception: "inert multi-file extraction failure", }, ], }, @@ -381,6 +381,23 @@ if (args.includes("--version")) { }, ]; } + if (mode === "task-error" || mode === "task-warning") + records[1].reports.push( + mode === "task-error" + ? { + __typename__: "UnknownError", + severity: "ERROR", + exception: "inert task exception", + } + : { + __typename__: "ExtractCommandFailedReport", + severity: "WARNING", + command: "fixture", + stdout: "", + stderr: "", + exit_code: 1, + }, + ); if ( mode !== "depth-reentry" && mode !== "depth-orphan" && @@ -393,7 +410,11 @@ if (args.includes("--version")) { ? "[" : JSON.stringify(records), ); - if (mode === "dependency" || mode === "multi-file-dependency") + if ( + mode === "dependency" || + mode === "multi-file-dependency" || + mode === "task-error" + ) process.exitCode = 1; if ( mode === "workspace-cleanup-failure" ||