refactor(rust): report cargo-auditable section failures through ParseFailure (#10652)

* refactor(rust): report cargo-auditable section failures through ParseFailure

RustAuditableFormatFailure restated the malformed/unsupported algebra and
the provider translated it by hand; container BinaryFormatFailures were
re-wrapped and lost their kind. The reader now throws ParseFailureError
(format rust-auditable), container failures propagate unchanged, and the
provider maps both through parseFailureAnalysisError. A counted guard
keeps new per-format failure classes from returning.

* test(rust): pin the container's truncated failure facts through the Rust reader
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 15:28:33 -05:00
1 parent 3e0dc29041
commit 29fb608559
7 files changed
+115 -95

No files matched your search

@@ -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,
+11
View File
@@ -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).
+9 -6
View File
@@ -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",
}),
}),
);
},
+35 -52
View File
@@ -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`,
);
}
+10 -14
View File
@@ -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;
+6 -20
View File
@@ -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 = (
+31 -3
View File
@@ -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();