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",