From 176941700118cc33c86e07373cd6950f30548386 Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:05:01 +0700 Subject: [PATCH] fix(firmware): name missing extractor dependencies in partial extraction (#1879) Unblob reports a missing external extractor as an ExtractorDependencyNotFoundReport nested inside a chunk or multi-file report, and normalizeUnblobReport forwarded that value verbatim into diagnostics as { input_path, report }. A caller that reached partial coverage therefore had to decode the raw producer report to learn which extractor was absent and at which stage, even though the report already names the dependencies. Extraction diagnostics now normalize those entries: the named dependencies are collected from the report and any nested value, the producing handler is recorded as the stage, and the original report stays retained for provenance. Entries without a dependency report keep their previous shape. Validation: FirmwareReports unit cases cover the chunk-nested and multi-file-nested dependency reports and the retained raw report; firmware suite passes; typecheck, oxlint and oxfmt on changed files. Real Unblob extraction is unverified here because it needs the external extractor stack. --- src/firmware/FirmwareReports.test.ts | 48 +++++++++++++- src/firmware/FirmwareReports.ts | 63 ++++++++++++++++++- .../firmware/firmwareIntegration.test.ts | 2 + 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/firmware/FirmwareReports.test.ts b/src/firmware/FirmwareReports.test.ts index 2546c80058..5ffd5a7225 100644 --- a/src/firmware/FirmwareReports.test.ts +++ b/src/firmware/FirmwareReports.test.ts @@ -77,6 +77,47 @@ describe("firmware producer boundaries", () => { }); describe("Unblob multi-file extraction diagnostics", () => { + it("names a missing extractor dependency and stage while retaining the raw report", () => { + const dependencyFailure = { + __typename__: "ExtractorDependencyNotFoundReport", + severity: "ERROR", + dependencies: ["sasquatch"], + }; + const record = root(); + const normalized = normalizeUnblobReport( + [ + { + ...record, + reports: [ + ...record.reports.filter( + (item) => item.__typename__ !== "UnknownChunkReport", + ), + { + __typename__: "ChunkReport", + id: "gzip-id", + start_offset: 0, + end_offset: 20, + size: 20, + handler_name: "gzip", + is_encrypted: false, + extraction_reports: [dependencyFailure], + }, + ], + }, + ], + context, + ); + expect(normalized.coverage).toBe("partial"); + expect(normalized.diagnostics).toEqual([ + { + input_path: "$input", + stage: "gzip", + missing_dependencies: ["sasquatch"], + report: dependencyFailure, + }, + ]); + }); + it("retains nested MultiFile extraction failures in partial coverage", () => { const outputPath = `${context.outputRoot}/multi`; const childTask = { @@ -130,7 +171,12 @@ describe("Unblob multi-file extraction diagnostics", () => { expect(normalized.coverage).toBe("partial"); expect(normalized.diagnostics).toEqual([ - { input_path: "$output/multi", report: multiFileReport }, + { + input_path: "$output/multi", + stage: "multipart-firmware", + missing_dependencies: ["sasquatch"], + report: multiFileReport, + }, ]); }); }); diff --git a/src/firmware/FirmwareReports.ts b/src/firmware/FirmwareReports.ts index 3a7e93d48b..782c52a89f 100644 --- a/src/firmware/FirmwareReports.ts +++ b/src/firmware/FirmwareReports.ts @@ -68,6 +68,53 @@ const known = new Set([ "MultiFileReport", ]); +const dependencyFailure = z.object({ + __typename__: z.literal("ExtractorDependencyNotFoundReport"), + dependencies: z.array(z.string().min(1)).min(1), +}); + +/** Collect the named extractors from any dependency report nested in one value. */ +const missingDependencies = (value: unknown): string[] => { + const found: string[] = []; + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + if (typeof node !== "object" || node === null) return; + const failure = dependencyFailure.safeParse(node); + if (failure.success) { + for (const dependency of failure.data.dependencies) + if (!found.includes(dependency)) found.push(dependency); + return; + } + for (const child of Object.values(node)) visit(child); + }; + visit(value); + return found; +}; + +/** + * Name the missing extractors and the producing stage for one extraction + * problem so a caller can act without decoding the raw report. The original + * report stays retained alongside the normalized fields. + */ +const extractionDiagnostic = ( + inputPath: string, + stage: string | null, + report: JsonValue, +): JsonValue => { + const dependencies = missingDependencies(report); + return { + input_path: inputPath, + ...(stage === null ? {} : { stage }), + ...(dependencies.length === 0 + ? {} + : { missing_dependencies: dependencies }), + report, + }; +}; + /** Interpret the pinned Binwalk report without inventing validated region lengths. */ export const normalizeBinwalkReport = ( raw: unknown, @@ -234,7 +281,13 @@ export const normalizeUnblobReport = ( : null; if (detail !== null) for (const problem of detail.extraction_reports) - diagnostics.push({ input_path: path, report: problem }); + diagnostics.push( + extractionDiagnostic( + path, + detail.handler_name, + jsonValueSchema.parse(problem), + ), + ); chunks.push({ input_path: path, provider_id: parsed.id, @@ -253,7 +306,13 @@ export const normalizeUnblobReport = ( if (item.__typename__ === "MultiFileReport") { const parsed = multiFile.parse(item); if (parsed.extraction_reports.length > 0) - diagnostics.push({ input_path: path, report: item }); + diagnostics.push( + extractionDiagnostic( + path, + parsed.handler_name, + jsonValueSchema.parse(item), + ), + ); } } } diff --git a/tests/boundary/firmware/firmwareIntegration.test.ts b/tests/boundary/firmware/firmwareIntegration.test.ts index 8fee7e83c9..4244212d3c 100644 --- a/tests/boundary/firmware/firmwareIntegration.test.ts +++ b/tests/boundary/firmware/firmwareIntegration.test.ts @@ -308,6 +308,8 @@ it.each(["depth", "dependency", "link"])( expect(normalized.diagnostics).toEqual([ { input_path: "$input", + stage: "gzip", + missing_dependencies: ["sasquatch"], report: { __typename__: "ExtractorDependencyNotFoundReport", severity: "ERROR",