diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index dfc4d58175..a460891ccc 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -205,6 +205,19 @@ "src/wasm/WabtArtifactProvider.ts": 2, "src/wasm/WabtFailures.ts": 2 }, + "parser-format-failure-class": { + "src/browser/assets/WebArtifactReads.ts": 1, + "src/browser/history/CaptureFormatError.ts": 1, + "src/domain/xmlDocument.ts": 1, + "src/il2cpp/Il2cppBinaryFormat.ts": 1, + "src/il2cpp/Il2cppMetadataFormat.ts": 1, + "src/javascript/sourceMaps/SourceMapFormat.ts": 1, + "src/model/ModelFormat.ts": 1, + "src/native/AppleMachoSelection.ts": 1, + "src/native/MachoCommandNames.ts": 1, + "src/native/apple/AppleIps.errors.ts": 1, + "src/python/PythonPyc.errors.ts": 1 + }, "parser-json-parse": { "src/android/JadxProtocol.ts": 2, "src/android/JadxWorkspaceRoot.ts": 1, diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 5ff65735e0..f0989baa1b 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -283,6 +283,17 @@ checkCounted( [], ); +// A per-format failure class restates the ParseFailure algebra under its own +// spelling and needs its own catch-site translation; parsers throw a +// ParseFailureError (or a subclass of it) instead. +checkCounted( + "per-format failure class extending Error (throw a ParseFailureError from src/domain/parseFailure)", + "parser-format-failure-class", + files, + /class \w*(?:Format|Parse)(?:Failure|Error) extends Error\b/gu, + ["src/domain/parseFailure.ts"], +); + // JSON.parse keeps the last of repeated member names without saying so; a // parser of artifact content must state its duplicate-member policy // (src/domain/jsonDuplicateMember.ts finds a repeated name). diff --git a/src/rust/RustAuditableReader.test.ts b/src/rust/RustAuditableReader.test.ts index 40d42691c2..323f444f5a 100644 --- a/src/rust/RustAuditableReader.test.ts +++ b/src/rust/RustAuditableReader.test.ts @@ -1,10 +1,10 @@ import { deflateSync } from "node:zlib"; import { expect, it } from "vitest"; import { rustAuditableMetadataSchema } from "../domain/rust/rustBinary.js"; +import { ParseFailureError } from "../domain/parseFailure.js"; import { readAuditableSection, - RustAuditableFormatFailure, RustAuditableResourceFailure, } from "./RustAuditableReader.js"; @@ -112,7 +112,7 @@ it("preserves historical cargo-auditable feature lists and rejects bad values", }, ], }), - ).toThrow(RustAuditableFormatFailure); + ).toThrow(ParseFailureError); }); it("reports dependency cycles and keeps an omitted format version unknown", () => { @@ -163,7 +163,7 @@ it("rejects out-of-range indices, multiple roots, and schema drift", () => { { name: "a", version: "1", source: "local", dependencies: [2] }, ], }), - ).toThrow(RustAuditableFormatFailure); + ).toThrow(ParseFailureError); expect(() => section({ packages: [ @@ -196,7 +196,7 @@ it("rejects invalid JSON, trailing zlib bytes, and an empty section", () => { ); expect(() => readAuditableSection(".dep-v0", 0, Buffer.from([1, 2, 3])), - ).toThrow(RustAuditableFormatFailure); + ).toThrow(ParseFailureError); }); it("rejects duplicate decoded JSON fields before object conversion", () => { @@ -353,8 +353,11 @@ it.each([-1, 1.5, 0x1_0000_0000])( (format) => { expect(() => section({ format, packages: [] })).toThrow( expect.objectContaining({ - name: "RustAuditableFormatFailure", - kind: "malformed", + name: "ParseFailureError", + failure: expect.objectContaining({ + kind: "malformed", + format: "rust-auditable", + }), }), ); }, diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts index 0d77238df4..236cbc097d 100644 --- a/src/rust/RustAuditableReader.ts +++ b/src/rust/RustAuditableReader.ts @@ -9,6 +9,7 @@ import { type RustPackage, } from "../domain/rust/rustBinary.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; +import { malformedInput, ParseFailureError } from "../domain/parseFailure.js"; /** Compressed zlib stream limit. */ const RUST_SECTION_BYTES = 1024 * 1024; @@ -36,20 +37,15 @@ const documentSchema = documentVersionSchema }) .strict(); -/** Malformed or unsupported dependency-section bytes. */ -export class RustAuditableFormatFailure extends Error { - /** The image holds the metadata, in a form this reader cannot decode. */ - readonly undecodedContent: boolean; - constructor( - readonly kind: "malformed" | "unsupported", - message: string, - options?: ErrorOptions & { readonly undecodedContent?: boolean }, - ) { - super(message, options); - this.undecodedContent = options?.undecodedContent ?? false; - this.name = "RustAuditableFormatFailure"; - } -} +/** The ParseFailure format of `cargo auditable` dependency sections. */ +export const RUST_AUDITABLE_FORMAT = "rust-auditable"; + +/** Malformed section bytes, keeping the decoder or schema failure that showed it. */ +const malformedSection = (detail: string, cause: unknown) => + new ParseFailureError( + { kind: "malformed", format: RUST_AUDITABLE_FORMAT, detail }, + { cause }, + ); /** A resource admission limit with explicit semantic units. */ export type RustAuditableConstraint = @@ -85,10 +81,7 @@ export const readAuditableSection = ( } = {}, ): RustAuditableMetadata => { if (bytes.length === 0) - throw new RustAuditableFormatFailure( - "malformed", - "Dependency section is empty", - ); + throw malformedInput(RUST_AUDITABLE_FORMAT, "Dependency section is empty"); if ( bytes.length > RUST_SECTION_BYTES && options.peFileAlignment === undefined @@ -172,13 +165,13 @@ const inflateSection = ( const alignedRawSize = Math.ceil(compressedBytes / peFileAlignment) * peFileAlignment; if (bytes.length !== alignedRawSize || padding.some((byte) => byte !== 0)) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, "Dependency section has bytes after the zlib stream", ); } else if (compressedBytes !== bytes.length) { - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, "Dependency section has bytes after the zlib stream", ); } @@ -219,8 +212,8 @@ const inflateFailure = ( code.startsWith("Z_") || code === "ERR_INVALID_ARG_TYPE" ) - return new RustAuditableFormatFailure( - "malformed", + return malformedInput( + RUST_AUDITABLE_FORMAT, "Dependency section is not a complete zlib stream", ); return cause instanceof Error ? cause : new Error(String(cause)); @@ -234,11 +227,7 @@ const parseDocument = ( try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch (cause) { - throw new RustAuditableFormatFailure( - "malformed", - "Dependency JSON is not valid UTF-8", - { cause }, - ); + throw malformedSection("Dependency JSON is not valid UTF-8", cause); } assertDepth(text); let invalidJson = false; @@ -269,42 +258,36 @@ const parseDocument = ( }, ); if (invalidJson) { - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, "Dependency section JSON is invalid", ); } if (duplicate !== undefined) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, `Dependency JSON contains duplicate field ${JSON.stringify(duplicate)}`, ); let value: unknown; try { value = JSON.parse(text); } catch (cause) { - throw new RustAuditableFormatFailure( - "malformed", - "Dependency section JSON is invalid", - { cause }, - ); + throw malformedSection("Dependency section JSON is invalid", cause); } const version = documentVersionSchema.safeParse(value); if (!version.success) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedSection( "Dependency JSON does not match the format schema", - { cause: version.error }, + version.error, ); admitGraphExpansion(value, maxOutputBytes); // One malformed package must fail before Zod retains an issue for every field. rejectMalformedPackages(value); const parsed = documentSchema.safeParse(value); if (!parsed.success) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedSection( "Dependency JSON does not match the package schema", - { cause: parsed.error }, + parsed.error, ); return parsed.data; }; @@ -404,8 +387,8 @@ const rejectMalformedPackages = (value: unknown): void => { return; for (const [index, pkg] of value.packages.entries()) { if (!packageRecord(pkg)) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, `Dependency package ${String(index)} does not match the package schema`, ); } @@ -475,8 +458,8 @@ const assertDepth = (text: string): void => { if (char === "{" || char === "[") { depth += 1; if (depth > JSON_DEPTH) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, "Dependency JSON nesting exceeds 32 levels", ); } else if ((char === "}" || char === "]") && depth > 0) depth -= 1; @@ -499,8 +482,8 @@ const normalizePackages = ( const rootIndex = (packages: readonly RustPackage[]): number | null => { const roots = packages.flatMap((pkg, index) => (pkg.root ? [index] : [])); if (roots.length > 1) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, "Dependency JSON declares more than one root package", ); for (const [index, pkg] of packages.entries()) { @@ -508,8 +491,8 @@ const rootIndex = (packages: readonly RustPackage[]): number | null => { (target) => target >= packages.length, ); if (outside !== undefined) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, `Dependency index ${String(outside)} from package ${String(index)} is outside the package list`, ); } diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts index 13e2c07637..65f9f7ce7b 100644 --- a/src/rust/RustBinaryImage.ts +++ b/src/rust/RustBinaryImage.ts @@ -1,9 +1,9 @@ import { - BinaryFormatFailure, BinaryResourceFailure, type BinaryContainer, type ImageSection, } from "../native/metadata/BinaryContainer.js"; +import { malformedInput, unsupportedInput } from "../domain/parseFailure.js"; import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; import type { RustAuditableMetadata, @@ -13,7 +13,7 @@ import { DEFAULT_RUSTC_HASH_LIMIT } from "../domain/rust/rustBinary.js"; import { RUST_PRODUCER_DEFAULTS } from "../domain/rust/rustResourceLimits.js"; import { readAuditableSection, - RustAuditableFormatFailure, + RUST_AUDITABLE_FORMAT, RustAuditableResourceFailure, } from "./RustAuditableReader.js"; @@ -59,12 +59,8 @@ const readContainer = (bytes: Buffer) => { try { return readBinaryContainer(bytes); } catch (cause) { - if (cause instanceof BinaryFormatFailure) - throw new RustAuditableFormatFailure( - cause.kind === "unsupported" ? "unsupported" : "malformed", - cause.message, - { cause }, - ); + // A container format failure is already a ParseFailureError; it keeps + // its own kind and facts instead of being restated as a Rust failure. if (cause instanceof BinaryResourceFailure) throw new RustAuditableResourceFailure( { @@ -87,21 +83,21 @@ const dependencySection = ( const expected = format === "macho" ? "__DATA,.dep-v0" : ".dep-v0"; const matches = sections.filter((section) => section.name === expected); if (matches.length > 1) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, "Image contains more than one dependency section", ); const section = matches[0]; if (section === undefined) return null; if (section.compressed) - throw new RustAuditableFormatFailure( - "unsupported", + throw unsupportedInput( + RUST_AUDITABLE_FORMAT, "Compressed dependency sections are unsupported", { undecodedContent: true }, ); if (!section.file_backed) - throw new RustAuditableFormatFailure( - "malformed", + throw malformedInput( + RUST_AUDITABLE_FORMAT, "Dependency section is not file-backed", ); return section; diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index aa7dbcdbee..c75b5c7932 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -23,10 +23,8 @@ import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisArtifactChangedError, AnalysisCancelledError, - AnalysisInputError, AnalysisOutputError, AnalysisResourceConstraintError, - AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { RUST_AUDITABLE_FORMAT_VERSIONS, @@ -42,10 +40,8 @@ import { type RustBinaryImage, } from "./RustBinaryImage.js"; import { requireSelectedNativeImage } from "../native/metadata/MachoSliceSelection.js"; -import { - RustAuditableFormatFailure, - RustAuditableResourceFailure, -} from "./RustAuditableReader.js"; +import { RustAuditableResourceFailure } from "./RustAuditableReader.js"; +import { parseFailureAnalysisError } from "../domain/parseFailureAnalysisError.js"; import { resolveRustProducerLimits, @@ -347,20 +343,10 @@ const failureForPrimary = ( )), }, ); - if (cause instanceof RustAuditableFormatFailure) - return cause.kind === "unsupported" - ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { - cause, - undecodedContent: cause.undecodedContent, - }) - : new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: `Selected artifact ${path}: ${cause.message}`, - }, - ]); - return artifactFailure(facts, path, limits); + return ( + parseFailureAnalysisError(OPERATION, path, cause) ?? + artifactFailure(facts, path, limits) + ); }; const failureFor = ( diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts index 330fc2a279..6134eaf6c8 100644 --- a/tests/boundary/rust/rustBinaryImage.test.ts +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -12,7 +12,7 @@ import { import { readRustBinaryImage } from "../../../src/rust/RustBinaryImage.js"; import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js"; import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js"; -import { RustAuditableFormatFailure } from "../../../src/rust/RustAuditableReader.js"; +import { ParseFailureError } from "../../../src/domain/parseFailure.js"; const packages = [ { @@ -306,11 +306,39 @@ it("finds rustc paths in read-only ELF load data without section headers", () => it("refuses a universal Mach-O container", () => { expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow( - RustAuditableFormatFailure, + ParseFailureError, ); expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow(/thin slice/); }); +it("keeps the container's truncated failure and its facts unchanged", () => { + const bytes = createRustImage({ format: "elf" }); + // The fixture's first program header is at byte 128; p_filesz and + // p_memsz are at +32 and +40. + bytes.writeBigUInt64LE(BigInt(bytes.length) * 2n, 128 + 32); + bytes.writeBigUInt64LE(BigInt(bytes.length) * 2n, 128 + 40); + const thrown = (read: () => unknown): unknown => { + try { + read(); + } catch (cause: unknown) { + return cause; + } + throw new Error("expected a container failure"); + }; + const container = thrown(() => readBinaryContainer(bytes)); + const rust = thrown(() => readRustBinaryImage(bytes)); + expect(rust).toBeInstanceOf(ParseFailureError); + expect((rust as ParseFailureError).failure).toEqual( + (container as ParseFailureError).failure, + ); + expect((rust as ParseFailureError).failure).toMatchObject({ + kind: "truncated", + format: "elf", + offset: expect.any(Number), + field: expect.any(String), + }); +}); + it("does not read SHT_NOBITS dependency sections as stored bytes", () => { const bytes = createRustImage({ format: "elf" }); // The fixture writes the .dep-v0 section header at byte 384. @@ -319,7 +347,7 @@ it("does not read SHT_NOBITS dependency sections as stored bytes", () => { try { metadata = readRustBinaryImage(bytes).auditable_metadata; } catch (cause: unknown) { - expect(cause).toBeInstanceOf(RustAuditableFormatFailure); + expect(cause).toBeInstanceOf(ParseFailureError); return; } expect(metadata).toBeNull();