fix(model): report malformed GGUF and Safetensors headers as invalid_format through ParseFailure (#10667)

* refactor(model): report GGUF and Safetensors format failures through ParseFailure

* fix(model): report malformed model headers as invalid_format through the shared mapping

Malformed and truncated GGUF and Safetensors headers now take the shared ParseFailure mapping (invalid_request, invalid_format on path) instead of the model provider's own BinaryTargetError file_format_rejected. Unsupported forms stay unsupported_target and model allowances keep their resource-constraint recovery.
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 18:58:59 -05:00
1 parent 2705aad365
commit a22fb6e60b
9 files changed
+160 -136

No files matched your search

+5 -2
View File
@@ -165,8 +165,11 @@ unknown fields on tensor entries are ignored within the reader's 32-level JSON
nesting limit, while the required dtype, shape and data offsets are still
validated. JSON duplicate keys, invalid UTF-8, out-of-file
ranges, overlaps, gaps, trailing unindexed data and known dtype/shape byte-length
contradictions are rejected with a typed `file_format_rejected` constraint (public code `target_unavailable`, category `unsupported_target`).
A missing, non-regular or otherwise unusable selected path is also `target_unavailable`;
contradictions are rejected as an invalid `path` (public code `invalid_request`,
category `invalid_input`, issue reason `invalid_format`), like any other
malformed or truncated model header. A form outside the reader's coverage, such
as a PyTorch pickle, is `unsupported_target`.
A missing, non-regular or otherwise unusable selected path is `target_unavailable`;
host I/O or resource failures on a valid selection, such as `EMFILE` or `EIO`, are
`execution_failure` with the path and reason in `details.diagnostics`. Empty
tensors and scalar shapes are supported. The reader retains shape dimensions as
@@ -191,7 +191,6 @@
"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,
@@ -240,7 +239,6 @@
"src/dotnet/ManagedStaticProvider.ts": 1,
"src/firmware/FirmwareCommand.ts": 2,
"src/il2cpp/Il2cppMetadataFile.ts": 2,
"src/model/ModelFormat.ts": 2,
"src/model/ModelHeaderReader.ts": 1,
"src/native/AppleDispatchMetadata.ts": 3,
"src/native/AppleMachoFixups.ts": 48,
+7 -6
View File
@@ -8,10 +8,12 @@ import type {
ModelArtifactPartialTensor,
} from "../domain/model/modelArtifact.js";
import { ModelHeaderReader } from "./ModelHeaderReader.js";
import { unsupportedInput } from "../domain/parseFailure.js";
import {
MODEL_ARRAY_PREVIEW,
MODEL_METADATA_NESTING,
ModelFormatFailure,
MODEL_FORMAT,
modelCapacityFailure,
ModelReportBudget,
rejectModel,
} from "./ModelFormat.js";
@@ -125,10 +127,9 @@ export class GgufHeader {
// occupies one, so charge it on entry instead of on a child visit.
const frames = enclosingFrames + 1;
if (frames > MODEL_METADATA_NESTING)
throw new ModelFormatFailure(
"capacity",
`GGUF metadata nesting exceeds ${String(MODEL_METADATA_NESTING)} array frames`,
throw modelCapacityFailure(
"metadata-nesting",
`GGUF metadata nesting exceeds ${String(MODEL_METADATA_NESTING)} array frames`,
);
const element = Number(await this.#unsigned(4));
const rawCount = await this.#unsigned(8);
@@ -262,8 +263,8 @@ export class GgufHeader {
this.#little = false;
version = bigVersion;
} else {
throw new ModelFormatFailure(
"unsupported",
throw unsupportedInput(
MODEL_FORMAT,
`GGUF version bytes ${versionBytes.toString("hex")} are unsupported (little-endian ${String(littleVersion)}, big-endian ${String(bigVersion)}; byte order unknown); supported versions are 2 and 3`,
);
}
+31 -38
View File
@@ -21,9 +21,7 @@ import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
@@ -40,12 +38,15 @@ import { ModelHeaderReader } from "./ModelHeaderReader.js";
import { GgufHeader } from "./GgufHeader.js";
import { readSafetensorsHeader } from "./SafetensorsHeader.js";
import {
ModelFormatFailure,
modelCapacityFailure,
modelCapacityLimitOf,
MODEL_REPORT_BYTES,
modelCapacityRemediation,
modelCapacityReportedLimits,
} from "./ModelFormat.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import { ParseFailureError } from "../domain/parseFailure.js";
import { parseFailureAnalysisError } from "../domain/parseFailureAnalysisError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { selectedFileTargetError } from "../artifacts/RegularFileErrorProjection.js";
import {
@@ -107,36 +108,33 @@ export class ModelArtifactProvider implements ModelArtifactPort {
);
} catch (cause) {
const { cause: primary, origin } = artifactFilePrimaryFacts(cause);
const capacity =
primary instanceof ParseFailureError
? modelCapacityLimitOf(primary)
: undefined;
const formatError = parseFailureAnalysisError(
OPERATION,
input.path,
primary,
);
let error: AnalysisError;
if (origin === "owned-or-classified" && primary instanceof AnalysisError)
error = primary;
else if (primary instanceof ModelFormatFailure) {
const limit = primary.kind === "capacity" ? primary.limit : undefined;
error =
limit !== undefined
? new AnalysisResourceConstraintError(
OPERATION,
"memory",
primary.message,
modelCapacityReportedLimits(limit),
{
cause,
retryable: false,
remediationAction: modelCapacityRemediation(limit),
},
)
: primary.kind === "unsupported"
? new AnalysisUnsupportedTargetError(
OPERATION,
input.path,
primary.message,
{ cause },
)
: new BinaryTargetError(input.path, primary.message, {
cause,
constraint: "file_format_rejected",
});
} else if (
else if (primary instanceof ParseFailureError && capacity !== undefined)
// A model allowance names its own recovery.
error = new AnalysisResourceConstraintError(
OPERATION,
"memory",
primary.message,
modelCapacityReportedLimits(capacity),
{
cause,
retryable: false,
remediationAction: modelCapacityRemediation(capacity),
},
);
else if (formatError !== undefined) error = formatError;
else if (
origin === "owned-or-classified" &&
primary instanceof ArtifactReaderFailure &&
primary.reason === "cancelled"
@@ -245,10 +243,9 @@ export class ModelArtifactProvider implements ModelArtifactPort {
): Promise<ModelArtifact> {
const initial = await file.stat({ bigint: true });
if (initial.size > BigInt(Number.MAX_SAFE_INTEGER))
throw new ModelFormatFailure(
"capacity",
"Model file exceeds exact host offset precision",
throw modelCapacityFailure(
"exact-offset",
"Model file exceeds exact host offset precision",
);
const reader = new ModelHeaderReader(file, Number(initial.size), signal);
const first = await reader.take(4);
@@ -430,11 +427,7 @@ export class ModelArtifactProvider implements ModelArtifactPort {
],
};
if (jsonValueByteLength(report, MODEL_REPORT_BYTES) > MODEL_REPORT_BYTES)
throw new ModelFormatFailure(
"capacity",
"Model report exceeds 32 MiB",
"report-bytes",
);
throw modelCapacityFailure("report-bytes", "Model report exceeds 32 MiB");
return modelArtifactSchema.parse(report);
}
}
+37 -26
View File
@@ -1,3 +1,9 @@
import {
malformedInput,
ParseFailureError,
unsupportedInput,
} from "../domain/parseFailure.js";
/** Producing constraint for a model capacity refusal. Recovery uses this, not message text. */
export const MODEL_CAPACITY_LIMITS = [
"header-bytes",
@@ -11,34 +17,40 @@ export const MODEL_CAPACITY_LIMITS = [
/** Which fixed model allowance refused the request. */
export type ModelCapacityLimit = (typeof MODEL_CAPACITY_LIMITS)[number];
/** Format failures stay separate from filesystem, capacity and cancellation failures. */
export class ModelFormatFailure extends Error {
/** Set only for `kind: "capacity"`. */
readonly limit: ModelCapacityLimit | undefined;
constructor(kind: "format" | "unsupported", message: string);
constructor(kind: "capacity", message: string, limit: ModelCapacityLimit);
constructor(
readonly kind: "format" | "unsupported" | "capacity",
message: string,
limit?: ModelCapacityLimit,
) {
super(message);
this.name = "ModelFormatFailure";
if (kind === "capacity" && limit === undefined)
throw new TypeError("Model capacity failures require a limit kind");
if (kind !== "capacity" && limit !== undefined)
throw new TypeError("Only model capacity failures carry a limit kind");
this.limit = limit;
}
}
/** The ParseFailure format of GGUF and Safetensors model headers. */
export const MODEL_FORMAT = "model";
/** A fixed model allowance refused the input; the limit name is its {@link ModelCapacityLimit}. */
export const modelCapacityFailure = (
limit: ModelCapacityLimit,
detail: string,
): ParseFailureError<"limit"> =>
new ParseFailureError({
kind: "limit",
format: MODEL_FORMAT,
detail,
limit: {
name: limit,
maximum: Object.values(modelCapacityReportedLimits(limit))[0] ?? 0,
},
});
/** The model allowance a failure names, or undefined for any other failure. */
export const modelCapacityLimitOf = (
cause: ParseFailureError,
): ModelCapacityLimit | undefined => {
const { failure } = cause;
if (failure.kind !== "limit") return undefined;
return MODEL_CAPACITY_LIMITS.find((limit) => limit === failure.limit.name);
};
export function rejectModel(message: string): never {
throw new ModelFormatFailure("format", message);
throw malformedInput(MODEL_FORMAT, message);
}
export function rejectPickle(): never {
throw new ModelFormatFailure(
"unsupported",
throw unsupportedInput(
MODEL_FORMAT,
"PyTorch pickle/ZIP model deserialization is unsupported; supply GGUF or safetensors instead",
);
}
@@ -108,10 +120,9 @@ export class ModelReportBudget {
#remaining = MODEL_REPORT_BYTES;
charge(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw new ModelFormatFailure(
"capacity",
"Model facts exceed the 32 MiB retained representation budget. This release has no runtime override.",
throw modelCapacityFailure(
"representation-bytes",
"Model facts exceed the 32 MiB retained representation budget. This release has no runtime override.",
);
this.#remaining -= bytes;
}
+3 -4
View File
@@ -3,7 +3,7 @@ import type { FileHandle } from "node:fs/promises";
import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
ModelFormatFailure,
modelCapacityFailure,
MODEL_HEADER_BYTES,
rejectModel,
} from "./ModelFormat.js";
@@ -58,10 +58,9 @@ export class ModelHeaderReader {
if (!Number.isSafeInteger(end) || end < 0 || end > this.size)
rejectModel("Declared header extent is outside the file");
if (end - this.#headerPrefixBytes > MODEL_HEADER_BYTES)
throw new ModelFormatFailure(
"capacity",
"Model header exceeds 100,000,000 bytes. This release has no runtime override.",
throw modelCapacityFailure(
"header-bytes",
"Model header exceeds 100,000,000 bytes. This release has no runtime override.",
);
}
+5 -7
View File
@@ -10,7 +10,7 @@ import { ModelHeaderReader } from "./ModelHeaderReader.js";
import {
MODEL_HEADER_BYTES,
MODEL_JSON_NESTING,
ModelFormatFailure,
modelCapacityFailure,
ModelReportBudget,
rejectModel,
rejectPickle,
@@ -73,10 +73,9 @@ class HeaderJson {
this.#space();
this.budget.charge(64);
if (depth > MODEL_JSON_NESTING)
throw new ModelFormatFailure(
"capacity",
`Safetensors JSON nesting exceeds ${String(MODEL_JSON_NESTING)} levels`,
throw modelCapacityFailure(
"json-nesting",
`Safetensors JSON nesting exceeds ${String(MODEL_JSON_NESTING)} levels`,
);
const c = this.text[this.#position];
if (c === '"') return this.#string();
@@ -190,10 +189,9 @@ const integersOf = (value: HeaderValue | undefined): bigint[] => {
};
const exactOffset = (value: bigint): number => {
if (value > BigInt(Number.MAX_SAFE_INTEGER))
throw new ModelFormatFailure(
"capacity",
"Safetensors data offset exceeds exact host offset precision",
throw modelCapacityFailure(
"exact-offset",
"Safetensors data offset exceeds exact host offset precision",
);
return Number(value);
};
+27 -20
View File
@@ -24,6 +24,18 @@ import {
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest, createSharedMcpSession } from "../support/cli/cliFixture.js";
/** A malformed model header is an invalid `path` on both adapters. */
const MALFORMED_MODEL = {
code: "invalid_request",
category: "invalid_input",
details: {
operation: "inspect_model_artifact",
issues: [
expect.objectContaining({ path: ["path"], reason: "invalid_format" }),
],
},
} as const;
// Model queries are target-free; share immutable registration and schema setup.
// Portable export keeps its separate post-CLI connection below.
const modelMcp = createSharedMcpSession(async (registerCleanup) => {
@@ -519,12 +531,17 @@ cliTest(
// The raw extent is outside this empty file, and that check runs before
// the offset is converted to a host number.
expect(offsetFailure.error).toMatchObject({
code: "target_unavailable",
category: "unsupported_target",
code: "invalid_request",
category: "invalid_input",
details: {
constraint: "file_format_rejected",
path,
reason: "Declared extent is outside the file",
operation: "inspect_model_artifact",
issues: [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${path}: Declared extent is outside the file`,
},
],
},
});
},
@@ -1085,17 +1102,13 @@ cliTest(
});
expect(malformedCli.exitCode).toBe(1);
expect(malformedCli.json).toMatchObject({
code: "target_unavailable",
category: "unsupported_target",
details: { constraint: "file_format_rejected" },
...MALFORMED_MODEL,
});
const malformedMcp = parseMcpToolError(
await call("inspect_model_artifact", { path: malformedPath }),
);
expect(malformedMcp.error).toMatchObject({
code: "target_unavailable",
category: "unsupported_target",
details: { constraint: "file_format_rejected" },
...MALFORMED_MODEL,
});
}
},
@@ -1194,17 +1207,13 @@ cliTest(
});
expect(cliResult.exitCode).toBe(1);
expect(cliResult.json).toMatchObject({
code: "target_unavailable",
category: "unsupported_target",
details: { constraint: "file_format_rejected" },
...MALFORMED_MODEL,
});
const mcpResult = parseMcpToolError(
await call("inspect_model_artifact", { path }),
);
expect(mcpResult.error).toMatchObject({
code: "target_unavailable",
category: "unsupported_target",
details: { constraint: "file_format_rejected" },
...MALFORMED_MODEL,
});
}
},
@@ -1283,9 +1292,7 @@ cliTest(
await call("inspect_model_artifact", { path }),
);
expect(malformed.error).toMatchObject({
code: "target_unavailable",
category: "unsupported_target",
details: { constraint: "file_format_rejected" },
...MALFORMED_MODEL,
});
},
);
+45 -31
View File
@@ -8,11 +8,11 @@ import {
MODEL_JSON_NESTING,
MODEL_METADATA_NESTING,
MODEL_REPORT_BYTES,
ModelFormatFailure,
modelCapacityRemediation,
modelCapacityReportedLimits,
} from "../../../src/model/ModelFormat.js";
import { readSafetensorsHeader } from "../../../src/model/SafetensorsHeader.js";
import { ParseFailureError } from "../../../src/domain/parseFailure.js";
import { GgufHeader } from "../../../src/model/GgufHeader.js";
import { createModelArtifactService } from "../../../src/composition/model.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
@@ -25,6 +25,14 @@ import {
safetensorsFixture,
} from "../../fixtures/model/model.js";
/** A malformed model header is an invalid `path`, like every format failure. */
const INVALID_FORMAT = {
_tag: "AnalysisInputError",
issues: [
expect.objectContaining({ path: ["path"], reason: "invalid_format" }),
],
} as const;
const hash = (bytes: Buffer) =>
createHash("sha256").update(bytes).digest("hex");
const providerAt = async (bytes: Buffer) => {
@@ -82,8 +90,7 @@ it("continues refusing the unsupported big-endian GGUF v2 layout", async () => {
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Big-endian GGUF v2 admitted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
});
@@ -212,7 +219,7 @@ it.each([
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Expected format refusal");
expect(result.error).toMatchObject({
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
},
);
@@ -263,8 +270,7 @@ it.each([0, 1, 2, 4, 12])(
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Invalid alignment accepted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
},
);
@@ -289,8 +295,7 @@ it.each([
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Invalid metadata key accepted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
},
);
@@ -340,14 +345,18 @@ it("retains the absolute header budget for GGUF without allocating that extent",
await file.truncate(100_000_009);
const reader = new ModelHeaderReader(file, 100_000_009);
expect(() => reader.guarantee(100_000_000)).not.toThrow();
expect(() => reader.guarantee(100_000_001)).toThrow(ModelFormatFailure);
expect(() => reader.guarantee(100_000_001)).toThrow(ParseFailureError);
try {
reader.guarantee(100_000_001);
} catch (cause) {
expect(cause).toBeInstanceOf(ModelFormatFailure);
if (!(cause instanceof ModelFormatFailure))
expect(cause).toBeInstanceOf(ParseFailureError);
if (!(cause instanceof ParseFailureError))
throw new Error("Expected a model header capacity failure");
expect(cause.limit).toBe("header-bytes");
expect(cause.failure).toMatchObject({
kind: "limit",
format: "model",
limit: { name: "header-bytes", maximum: 100_000_000 },
});
expect(cause.message).toContain("100,000,000 bytes");
expect(cause.message).not.toContain("Safetensors");
}
@@ -379,7 +388,7 @@ it("separates impossible string lengths from nesting capacity", async () => {
const invalid = await providerAt(oversized.bytes);
expect(
await invalid.provider.inspect({ path: invalid.path, hash: "header" }),
).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } });
).toMatchObject({ ok: false, error: INVALID_FORMAT });
for (const [label, bytes, allowance] of [
["nesting", deep.bytes, "32 array frames"],
] as const) {
@@ -656,8 +665,13 @@ it.each(ggufKLayouts)(
const rejected = await provider.inspect({ path, hash: "header" });
if (rejected.ok) throw new Error("Incomplete common K row admitted");
expect(rejected.error).toMatchObject({
constraint: "file_format_rejected",
reason: expect.stringContaining("multiple of 64 elements"),
...INVALID_FORMAT,
issues: [
expect.objectContaining({
reason: "invalid_format",
message: expect.stringContaining("multiple of 64 elements"),
}),
],
});
},
);
@@ -778,8 +792,7 @@ it("validates booleans beyond an array preview without allocating the full value
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Invalid omitted boolean was accepted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
});
@@ -861,9 +874,13 @@ it.each([
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Invalid omitted UTF-8 string was accepted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
message: expect.stringContaining("GGUF string is not valid UTF-8"),
...INVALID_FORMAT,
issues: [
expect.objectContaining({
reason: "invalid_format",
message: expect.stringContaining("GGUF string is not valid UTF-8"),
}),
],
});
},
);
@@ -1116,14 +1133,13 @@ const malformed = [
],
] as const;
it.each(malformed)(
"rejects %s with typed file_format_rejected",
"rejects %s as an invalid_format path",
async (_name, make) => {
const { provider, path } = await providerAt(make());
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Malformed model was accepted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
},
);
@@ -1172,8 +1188,7 @@ it("retains GGUF declarations and metadata when storage overlap is refused", asy
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Overlapping tensors admitted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
const evidence = parseEvidence(result.error.partialObservation);
expect(evidence.subject).toBeNull();
@@ -1322,8 +1337,7 @@ it("reports a truncated safetensors header whose length resembles a pickle marke
const result = await provider.inspect({ path, hash: "header" });
if (result.ok) throw new Error("Truncated model was accepted");
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
...INVALID_FORMAT,
});
});
@@ -1333,7 +1347,7 @@ it("bounds expansion before allocation and distinguishes unsupported pickle and
const { provider, path, root } = await providerAt(fixture.bytes);
const huge = await provider.inspect({ path, hash: "header" });
if (huge.ok) throw new Error("Huge count admitted");
expect(huge.error._tag).toBe("BinaryTargetError");
expect(huge.error).toMatchObject(INVALID_FORMAT);
await writeFile(path, Buffer.from([0x80, 4, 0, 0]));
const pickle = await provider.inspect({ path, hash: "header" });
if (pickle.ok) throw new Error("Pickle admitted");
@@ -1585,7 +1599,7 @@ it.each([false, true])(
const { provider, path } = await providerAt(bytes);
expect(await provider.inspect({ path, hash: "header" })).toMatchObject({
ok: false,
error: { _tag: "BinaryTargetError" },
error: INVALID_FORMAT,
});
}
},
@@ -1606,7 +1620,7 @@ it("rejects impossible safetensors header and data extents before numeric conver
const { provider, path } = await providerAt(bytes);
expect(await provider.inspect({ path, hash: "header" })).toMatchObject({
ok: false,
error: { _tag: "BinaryTargetError" },
error: INVALID_FORMAT,
});
}
});
@@ -1620,7 +1634,7 @@ it("rejects a tensor directory whose old 20-byte estimate fits but fixed fields
await truncate(path, 24 + 65537 * 20);
expect(await provider.inspect({ path, hash: "header" })).toMatchObject({
ok: false,
error: { _tag: "BinaryTargetError" },
error: INVALID_FORMAT,
});
});