From 89640a446a48fb047bea8aa430170c730177c184 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:03:26 +0800 Subject: [PATCH] fix(parsers): stop reading every RangeError as a format failure (#10602) * refactor(parsers): report format failures through one ParseFailure algebra Add ParseFailure (malformed, truncated, unsupported, limit, with format, offset and field) and its one carrier, ParseFailureError, in the domain. BinaryReader now names truncation and the field it read, and its native format and resource failures are ParseFailureError subclasses. Providers projected a format failure with their own instanceof ladder, and some dropped the selected path from the invalid_format message. parseFailureAnalysisError now owns that projection for the native, Go, devicetree, V8, Windows Shortcut and Electron fuse providers. The V8 code cache, Windows Shortcut and Electron fuse format classes are removed in favour of the algebra. The architecture guard counts raw Buffer and DataView reads, plain Error throws in parser folders and plain JSON.parse of parser input against baselines that can only shrink. * fix(parsers): stop reading every RangeError as a format failure Mach-O, Swift and Objective-C metadata parsers threw RangeError for a truncated or malformed record, so their catch sites treated any RangeError as a partial decode: a call stack overflow or a host string limit became an "unresolved" metadata reason, and the IL2CPP Mach-O path reported it as a malformed container. Those parsers and the CIL method body reader now throw ParseFailureError. hostRangeErrorKind is the one interpreter of a host RangeError (stack overflow, host capacity, raw out-of-bounds read), and parseFailureFromCause admits only a parse failure or a raw out-of-bounds read as evidence about the input. Fixup stream catch-alls rethrow host limits. The architecture guard counts the remaining direct instanceof RangeError checks. * fix(parsers): preserve Apple metadata failure ownership * test(architecture): shrink parser failure baseline * fix: preserve uncertainty for host range errors --- docs/development/tool-design.md | 11 +- .../verify/architecture-guard-baselines.json | 26 +-- scripts/verify/architecture-guards.mjs | 11 ++ src/apkSignature/apkV1Signers.ts | 3 +- src/domain/parseFailure.test.ts | 77 ++++++++ src/domain/parseFailure.ts | 117 ++++++++++-- src/dotnet/ManagedMethodBodyReader.ts | 33 +++- src/il2cpp/Il2cppBinaryImage.ts | 13 +- src/native/AppleDispatchMetadata.test.ts | 6 +- src/native/AppleDispatchMetadata.ts | 13 +- src/native/AppleMachoFixups.test.ts | 7 +- src/native/AppleMachoFixups.ts | 176 +++++++++++++----- src/native/AppleMachoSelection.test.ts | 22 ++- src/native/AppleMachoSelection.ts | 63 ++++--- src/native/AppleMetadataReadFailure.ts | 18 ++ src/native/AppleObjcCategories.ts | 9 +- src/native/AppleObjcClassCoverage.test.ts | 21 +++ .../AppleObjcClassImportBindings.test.ts | 3 +- src/native/AppleObjcDispatchFacets.ts | 33 +++- src/native/AppleObjcProperties.ts | 10 +- src/native/AppleObjcProtocols.ts | 27 ++- .../AppleSwiftConformanceTypeReference.ts | 13 +- src/native/AppleSwiftDispatchFacets.ts | 26 ++- src/native/AppleSwiftMetadataReadFailure.ts | 13 -- src/native/AppleSwiftNominalTypes.ts | 20 +- src/native/AppleSwiftRelativePointer.test.ts | 14 +- src/native/AppleSwiftVtables.test.ts | 32 ++++ src/native/AppleSwiftVtables.ts | 24 ++- src/native/NativeMachoInspection.ts | 4 +- 29 files changed, 659 insertions(+), 186 deletions(-) create mode 100644 src/domain/parseFailure.test.ts create mode 100644 src/native/AppleMetadataReadFailure.ts delete mode 100644 src/native/AppleSwiftMetadataReadFailure.ts diff --git a/docs/development/tool-design.md b/docs/development/tool-design.md index 89a234142f..30b029b0f9 100644 --- a/docs/development/tool-design.md +++ b/docs/development/tool-design.md @@ -294,9 +294,16 @@ fields through `BinaryReader` (`src/native/metadata/BinaryContainer.ts`), which raises that failure instead of a bare `RangeError`. Providers project the failure with `parseFailureAnalysisError` rather than their own `instanceof` ladder; a `limit` stays with its producer, which knows the budget to name. +Do not use `RangeError` as a failure channel: a stack overflow, a host string +or buffer limit, and an unchecked read past the end of a buffer are all +`RangeError`s. `hostRangeErrorKind` is the one interpreter of a host +`RangeError`, and `parseFailureFromCause` admits only a reported parse failure +as evidence about the input. Readers report bounds failures at the read site; +host error codes alone cannot distinguish a read from a write or allocation. `scripts/verify/architecture-guards.mjs` counts the remaining raw `Buffer` and -`DataView` reads, plain `Error` throws in parser folders, and plain `JSON.parse` -of parser input against `scripts/verify/architecture-guard-baselines.json`. A +`DataView` reads, plain `Error` throws in parser folders, plain `JSON.parse` +of parser input, and direct `instanceof RangeError` checks against +`scripts/verify/architecture-guard-baselines.json`. A file may not exceed its count; after removing copies, run `node scripts/verify/architecture-guards.mjs --shrink-baselines`. diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 0d53dc78c1..c6ad7dd075 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -232,23 +232,13 @@ "src/dotnet/ManagedArtifactInspector.ts": 1, "src/dotnet/ManagedDecodeBudget.ts": 1, "src/dotnet/ManagedMemberInspector.ts": 2, - "src/dotnet/ManagedMethodBodyReader.ts": 6, "src/dotnet/ManagedSnapshot.ts": 1, "src/dotnet/ManagedStaticProvider.ts": 1, "src/firmware/FirmwareCommand.ts": 2, "src/il2cpp/Il2cppMetadataFile.ts": 2, "src/model/ModelHeaderReader.ts": 1, - "src/native/AppleDispatchMetadata.ts": 3, - "src/native/AppleMachoFixups.ts": 48, - "src/native/AppleMachoSelection.ts": 25, - "src/native/AppleObjcCategories.ts": 1, - "src/native/AppleObjcDispatchFacets.ts": 6, - "src/native/AppleObjcProperties.ts": 2, - "src/native/AppleObjcProtocols.ts": 4, - "src/native/AppleSwiftConformanceTypeReference.ts": 1, - "src/native/AppleSwiftDispatchFacets.ts": 1, - "src/native/AppleSwiftNominalTypes.ts": 3, - "src/native/AppleSwiftVtables.ts": 3, + "src/native/AppleDispatchMetadata.ts": 1, + "src/native/AppleMachoSelection.ts": 3, "src/native/LldbCallTracer.ts": 1, "src/native/LldbRetainedObservations.ts": 1, "src/native/MachoCommandNames.ts": 1, @@ -399,5 +389,17 @@ "src/pcap/PcapDnsWire.ts": 5, "src/python/PythonPycParser.ts": 9, "src/v8/V8CodeCacheParser.ts": 1 + }, + "range-error-interpretation": { + "src/application/JsonInputFile.ts": 1, + "src/artifacts/apple/InterfaceBuilderAnalysis.ts": 1, + "src/artifacts/apple/KeyedArchiveReader.ts": 1, + "src/artifacts/nodeSea/NodeSeaBlob.ts": 1, + "src/browser/WebBundleAnalysisWorkerServer.ts": 1, + "src/cli/cliResultOutput.ts": 1, + "src/domain/apiContract/GraphqlDocuments.ts": 1, + "src/domain/apiContract/parseGraphqlContract.ts": 1, + "src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts": 2, + "src/wasm/WabtFailures.ts": 1 } } diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index b3a8c20673..f2d3c49019 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -313,6 +313,17 @@ checkCounted( [], ); +// A RangeError can mean a stack overflow, a host string or buffer limit, a +// raw read or write past a buffer, or whatever its thrower intended. Only +// known host limits are classified; parsers throw a ParseFailureError. +checkCounted( + "direct RangeError interpretation (use hostRangeErrorKind or parseFailureFromCause from src/domain/parseFailure)", + "range-error-interpretation", + files, + /instanceof RangeError/gu, + ["src/domain/parseFailure.ts"], +); + if (baselinesShrunk) { const sorted = Object.fromEntries( Object.entries(baselines).map(([key, counts]) => [ diff --git a/src/apkSignature/apkV1Signers.ts b/src/apkSignature/apkV1Signers.ts index 99ab968f84..80268d761e 100644 --- a/src/apkSignature/apkV1Signers.ts +++ b/src/apkSignature/apkV1Signers.ts @@ -5,6 +5,7 @@ * hands every certificate to the shared certificate owner. */ import { inflateRawSync } from "node:zlib"; +import { hostRangeErrorKind } from "../domain/parseFailure.js"; import type { ApkSignatureResult } from "../domain/apkSignature/apkSignatureReport.js"; import { @@ -105,7 +106,7 @@ export const decodeV1Signer = ( data = inflateRawSync(stored.value.data, { maxOutputLength: limit }); } catch (cause: unknown) { return fail( - cause instanceof RangeError + hostRangeErrorKind(cause) === "host-capacity" ? `signature block inflates past the ${String(limit)}-byte bound` : "signature block deflate stream is damaged", ); diff --git a/src/domain/parseFailure.test.ts b/src/domain/parseFailure.test.ts new file mode 100644 index 0000000000..8d00d62e49 --- /dev/null +++ b/src/domain/parseFailure.test.ts @@ -0,0 +1,77 @@ +import { inflateRawSync, deflateRawSync } from "node:zlib"; + +import { expect, it } from "vitest"; + +import { + hostRangeErrorKind, + isHostLimitFailure, + malformedInput, + parseFailureFromCause, +} from "./parseFailure.js"; + +const thrown = (run: () => unknown): unknown => { + try { + run(); + } catch (cause: unknown) { + return cause; + } + throw new Error("expected the host to throw"); +}; + +const recurse = (depth: number): number => recurse(depth + 1) + 1; + +const stackOverflow = thrown(() => recurse(0)); +const bufferPastEnd = thrown(() => Buffer.alloc(4).readUInt32LE(2)); +const dataViewPastEnd = thrown(() => + new DataView(new ArrayBuffer(2)).getUint32(0), +); +const inflatePastBound = thrown(() => + inflateRawSync(deflateRawSync(Buffer.alloc(4096)), { maxOutputLength: 16 }), +); + +it.each([ + ["a call stack overflow", stackOverflow, "stack-overflow"], + ["an output past its allocation bound", inflatePastBound, "host-capacity"], + [ + "an over-long string", + new RangeError("Invalid string length"), + "host-capacity", + ], + ["a Buffer read past its end", bufferPastEnd, undefined], + ["a DataView read past its end", dataViewPastEnd, undefined], + ["an invalid allocation", thrown(() => Buffer.alloc(-1)), undefined], + [ + "an invalid write value", + thrown(() => Buffer.alloc(1).writeUInt8(256)), + undefined, + ], + [ + "a write past its end", + thrown(() => Buffer.alloc(4).writeUInt32LE(1, 2)), + undefined, + ], + [ + "a RangeError REA threw itself", + new RangeError("Truncated header"), + undefined, + ], + ["an unrelated error", new TypeError("not a range"), undefined], +] as const)("classifies %s", (_label, cause, kind) => { + expect(hostRangeErrorKind(cause)).toBe(kind); + expect(parseFailureFromCause(cause)).toBeUndefined(); + expect(isHostLimitFailure(cause)).toBe( + kind === "stack-overflow" || kind === "host-capacity", + ); +}); + +it("preserves an explicitly reported format failure", () => { + const reported = malformedInput("macho", "load command leaves the header"); + expect(parseFailureFromCause(reported)).toBe(reported); +}); + +it.each([stackOverflow, inflatePastBound, new RangeError("Truncated header")])( + "leaves a host failure or an unclassified RangeError to the caller (%s)", + (cause) => { + expect(parseFailureFromCause(cause)).toBeUndefined(); + }, +); diff --git a/src/domain/parseFailure.ts b/src/domain/parseFailure.ts index caa10ef1db..bdea0cd537 100644 --- a/src/domain/parseFailure.ts +++ b/src/domain/parseFailure.ts @@ -60,26 +60,115 @@ export class ParseFailureError< } } +/** Where the failure was found, and the failure that revealed it. */ +type ParseFailureSite = Pick & { + readonly cause?: unknown; +}; + +const failureWith = ( + failure: ParseFailure, + cause: unknown, +): ParseFailureError => + new ParseFailureError(failure, cause === undefined ? undefined : { cause }); + /** The bytes violate the format's structure. */ export const malformedInput = ( format: string, detail: string, - facts?: Pick, -): ParseFailureError<"malformed"> => - new ParseFailureError({ kind: "malformed", format, detail, ...facts }); + { cause, ...at }: ParseFailureSite = {}, +): ParseFailureError => + failureWith({ kind: "malformed", format, detail, ...at }, cause); + +/** A declared structure starts in the bytes but ends past them. */ +export const truncatedInput = ( + format: string, + detail: string, + { cause, ...at }: ParseFailureSite = {}, +): ParseFailureError => + failureWith({ kind: "truncated", format, detail, ...at }, cause); /** The bytes use a form outside this decoder's coverage. */ export const unsupportedInput = ( format: string, detail: string, - facts?: Pick & { - readonly undecodedContent?: boolean; - }, -): ParseFailureError<"unsupported"> => - new ParseFailureError({ - kind: "unsupported", - format, - detail, - ...facts, - undecodedContent: facts?.undecodedContent ?? false, - }); + { + cause, + undecodedContent = false, + ...at + }: ParseFailureSite & { readonly undecodedContent?: boolean } = {}, +): ParseFailureError => + failureWith( + { kind: "unsupported", format, detail, ...at, undecodedContent }, + cause, + ); + +/** A named decode budget stopped the parse before the input was shown invalid. */ +export const decodeLimit = ( + format: string, + detail: string, + limit: { readonly name: string; readonly maximum: number }, + { cause, ...at }: ParseFailureSite = {}, +): ParseFailureError => + failureWith({ kind: "limit", format, detail, ...at, limit }, cause); + +/** + * What a JavaScript or Node `RangeError` says about the work that raised it. + * + * - `stack-overflow`: the call stack ran out. A REA defect or host limit, + * never evidence about the input bytes. + * - `host-capacity`: a string, array or buffer exceeded what the runtime can + * represent or allocate. + * + * Any other `RangeError` (including one REA code threw itself) returns + * `undefined`: its meaning belongs to whoever threw it. + */ +export type HostRangeErrorKind = "stack-overflow" | "host-capacity"; + +const HOST_CAPACITY_MESSAGES = new Set([ + "Invalid string length", + "Array buffer allocation failed", + "Invalid array length", +]); +const HOST_CAPACITY_CODES = new Set([ + "ERR_STRING_TOO_LONG", + "ERR_BUFFER_TOO_LARGE", +]); + +/** Classify a host `RangeError`; the one place that interprets one. */ +export const hostRangeErrorKind = ( + cause: unknown, +): HostRangeErrorKind | undefined => { + if (!(cause instanceof Error)) return undefined; + const code = "code" in cause ? String(cause.code) : undefined; + // Node raises an over-long string as a plain Error with this code. + if (code !== undefined && HOST_CAPACITY_CODES.has(code)) + return "host-capacity"; + if (!(cause instanceof RangeError)) return undefined; + if (cause.message === "Maximum call stack size exceeded") + return "stack-overflow"; + if ( + HOST_CAPACITY_MESSAGES.has(cause.message) || + cause.message.startsWith("Invalid typed array length") + ) + return "host-capacity"; + return undefined; +}; + +/** A stack overflow or host capacity failure: never evidence about the input. */ +export const isHostLimitFailure = (cause: unknown): boolean => { + const kind = hostRangeErrorKind(cause); + return kind === "stack-overflow" || kind === "host-capacity"; +}; + +/** + * The format failure a parse step established, or `undefined` when the cause + * says nothing about the input. Host errors do not identify the operation + * that failed: buffer reads, writes and allocations can share error codes. + * Readers must report their own bounds failures at the read boundary. + */ +export const parseFailureFromCause = ( + cause: unknown, +): ParseFailureError | undefined => { + if (cause instanceof ParseFailureError) return cause; + return undefined; +}; diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index b51122533a..76fd99e2b6 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -1,3 +1,9 @@ +import { + malformedInput, + parseFailureFromCause, + truncatedInput, + unsupportedInput, +} from "../domain/parseFailure.js"; import type { ManagedMethodImplementationFlags } from "./ManagedMethodDefinitionRows.js"; import { admitManagedProjection, @@ -70,16 +76,26 @@ const readMethodBodyHeader = ( ilSize: first >> 2, localSig: 0, }; - if ((first & 3) !== 3) throw new RangeError("Unsupported method body header"); + if ((first & 3) !== 3) + throw unsupportedInput("cil-method-body", "Unsupported method body header"); if (offset > bytes.length - 12) - throw new RangeError("Fat method body header leaves artifact"); + throw truncatedInput( + "cil-method-body", + "Fat method body header leaves artifact", + ); const flagsAndSize = bytes.readUInt16LE(offset); const headerDwords = flagsAndSize >>> 12; if (headerDwords < 3) - throw new RangeError("Fat method body header is smaller than 12 bytes"); + throw malformedInput( + "cil-method-body", + "Fat method body header is smaller than 12 bytes", + ); const size = headerDwords * 4; if (offset > bytes.length - size) - throw new RangeError("Method body header leaves artifact"); + throw truncatedInput( + "cil-method-body", + "Method body header leaves artifact", + ); return { format: "fat", size, @@ -151,10 +167,13 @@ export const methodBody = ( header.size > methodExtent || header.ilSize > methodExtent - header.size ) - throw new RangeError("Method body leaves file-backed PE section data"); + throw malformedInput( + "cil-method-body", + "Method body leaves file-backed PE section data", + ); const ilOffset = offset + header.size; if (ilOffset > bytes.length - header.ilSize) - throw new RangeError("Method IL bytes leave artifact"); + throw truncatedInput("cil-method-body", "Method IL bytes leave artifact"); const il = bytes.subarray(ilOffset, ilOffset + header.ilSize); // Bound the actual instruction records before allocating them. One raw // byte is only an upper bound on count; wide operands do not create a @@ -336,7 +355,7 @@ export const methodBody = ( throw cause; if ( !(cause instanceof ManagedReaderFailure) && - !(cause instanceof RangeError) + parseFailureFromCause(cause) === undefined ) throw cause; return { diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index 9c0607bdb1..bf7a46e61e 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -1,3 +1,4 @@ +import { parseFailureFromCause } from "../domain/parseFailure.js"; import { binaryMetadataArchitecture } from "../domain/binaryMetadataArchitecture.js"; import { IL2CPP_SLICE_SELECTORS, @@ -154,7 +155,17 @@ export function* openImageSteps(image: Buffer): Generator { "unsupported-container", cause.message, ); - if (cause instanceof RangeError || cause instanceof TypeError) + const failure = parseFailureFromCause(cause); + if (failure !== undefined) + throw new Il2cppBinaryFormatFailure( + failure.kind === "unsupported" + ? "unsupported-container" + : "malformed-container", + failure.message, + ); + // Mach-O slice selection reports an inconsistent FAT or CPU identity as + // a TypeError. + if (cause instanceof TypeError) throw new Il2cppBinaryFormatFailure( "malformed-container", cause.message, diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index b5fbbbc9c7..9a56acca6a 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -1,3 +1,4 @@ +import { malformedInput } from "../domain/parseFailure.js"; import { describe, expect, it } from "vitest"; import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js"; import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js"; @@ -1963,7 +1964,10 @@ describe("malformed Apple dispatch metadata", () => { const bytes = fixture(); bytes.writeUInt32LE(7, 36); expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow( - new RangeError("load command 0 at offset 0 has invalid cmdsize 7"), + malformedInput( + "macho", + "load command 0 at offset 0 has invalid cmdsize 7", + ), ); expect(() => decodeAppleDispatchMetadata(Buffer.alloc(10), 100, provenance), diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index b3ef2922e7..08d48a1965 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -1,3 +1,7 @@ +import { + malformedInput, + parseFailureFromCause, +} from "../domain/parseFailure.js"; import { setImmediate as immediate } from "node:timers/promises"; import { createHash } from "node:crypto"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; @@ -101,7 +105,7 @@ export function* decodeAppleDispatchMetadataSteps( // pointer-fixup coverage remains partial, and consumers that need to // distinguish null from an unresolved bind use resolvePointer directly. if (decodedPointer.kind === "unknown") return decodedPointer.raw; - throw new RangeError(decodedPointer.reason); + throw malformedInput("macho", decodedPointer.reason); }; const bound = (address: bigint) => { const decodedPointer = decodePointer(address); @@ -116,7 +120,7 @@ export function* decodeAppleDispatchMetadataSteps( try { fileOffset = offset(address); } catch (cause) { - if (!(cause instanceof RangeError)) throw cause; + if (parseFailureFromCause(cause) === undefined) throw cause; fileOffset = null; } return { address: `0x${address.toString(16)}`, file_offset: fileOffset }; @@ -135,7 +139,10 @@ export function* decodeAppleDispatchMetadataSteps( let end = start; while (end < bytes.length && end - start < 4096 && bytes[end] !== 0) end++; if (end === bytes.length || end - start >= 4096) - throw new RangeError("Unterminated or oversized metadata string"); + throw malformedInput( + "macho", + "Unterminated or oversized metadata string", + ); offset(address, end - start + 1); // A metadata C string owns every code point, including an initial U+FEFF. return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index e51cc224cf..8c8e94c019 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -1,3 +1,4 @@ +import { ParseFailureError } from "../domain/parseFailure.js"; import { describe, expect, it, vi } from "vitest"; import { @@ -165,8 +166,10 @@ describe("chained import names", () => { it("rejects a malformed import table", () => { const data = chainedData(1); data.writeUInt32LE(4, 20); - expect(() => chainedImportNames(data)).toThrow(RangeError); - expect(() => chainedImportNames(Buffer.alloc(0))).toThrow(RangeError); + expect(() => chainedImportNames(data)).toThrow(ParseFailureError); + expect(() => chainedImportNames(Buffer.alloc(0))).toThrow( + ParseFailureError, + ); }); }); diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index ee4c7517b1..3dccf639ed 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -1,3 +1,9 @@ +import { + isHostLimitFailure, + malformedInput, + truncatedInput, + unsupportedInput, +} from "../domain/parseFailure.js"; import type { Segment } from "./AppleMachoSelection.js"; import { issue } from "./AppleDispatchDecodeFacts.js"; import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; @@ -154,11 +160,15 @@ const libraryFacts = ( dylibs: FixupCommands["dylibs"], ): LibraryName => { if (!Number.isSafeInteger(ordinal) || ordinal < -3) - throw new RangeError(`Unsupported dylib ordinal ${String(ordinal)}`); + throw unsupportedInput( + "macho", + `Unsupported dylib ordinal ${String(ordinal)}`, + ); if (ordinal > 0) { const name = dylibs[ordinal - 1]; if (name === undefined) - throw new RangeError( + throw malformedInput( + "macho", `Dylib ordinal ${ordinal} exceeds the ${dylibs.length} declared dependencies`, ); return typeof name === "string" @@ -179,7 +189,10 @@ const symbolName = (bytes: Buffer): SymbolName => { const cString = (bytes: Buffer, offset: number, end: number): SymbolName => { const stop = bytes.indexOf(0, offset); if (offset < 0 || offset >= end || stop < 0 || stop >= end) - throw new RangeError("Fixup symbol name is outside the symbol pool"); + throw malformedInput( + "macho", + "Fixup symbol name is outside the symbol pool", + ); return symbolName(bytes.subarray(offset, stop)); }; @@ -235,14 +248,20 @@ const chainedImportEntry = ( ): ChainedImportEntry => { const size = header.format === 3 ? 16 : header.format === 2 ? 8 : 4; if (header.format < 1 || header.format > 3) - throw new RangeError(`Unsupported chained import format ${header.format}`); + throw unsupportedInput( + "macho", + `Unsupported chained import format ${header.format}`, + ); if ( !Number.isSafeInteger(header.count) || base + header.offset + header.count * size > end ) - throw new RangeError("Chained import table exceeds the fixup data"); + throw malformedInput( + "macho", + "Chained import table exceeds the fixup data", + ); if (index < 0 || index >= header.count) - throw new RangeError("Chained import index is outside the table"); + throw malformedInput("macho", "Chained import index is outside the table"); const at = base + header.offset + index * size; if (header.format === 3) { const word = bytes.readBigUInt64LE(at); @@ -300,7 +319,10 @@ export const decodeChainedPointer = ( ? imports(Number(ordinal)) : imports[Number(ordinal)]; if (target === undefined) - throw new RangeError(`Chained bind ordinal ${ordinal} has no import`); + throw malformedInput( + "macho", + `Chained bind ordinal ${ordinal} has no import`, + ); return resolvedBind(target, addend); }; if (format === 2 || format === 6) { @@ -326,7 +348,10 @@ export const decodeChainedPointer = ( target: format === 1 ? target : target + baseAddress, }; } - throw new RangeError(`Unsupported chained pointer format ${format}`); + throw unsupportedInput( + "macho", + `Unsupported chained pointer format ${format}`, + ); }; /** The slice bytes and load-command facts that fixup decoding reads. */ @@ -370,11 +395,15 @@ const readChainedPage = ( let failure: string | undefined; try { if ((source.start & 0x8000) !== 0) - throw new RangeError( + throw malformedInput( + "macho", "Multiple chained starts are not valid for 64-bit pointers", ); if (source.start + 8 > source.size) - throw new RangeError("Chained pointer leaves its file-backed page"); + throw malformedInput( + "macho", + "Chained pointer leaves its file-backed page", + ); // At most one eighth of this file-backed page, not one object per fixup. locations = new Uint8Array(Math.ceil(source.size / 8)); const generic = source.format === 2 || source.format === 6; @@ -386,12 +415,15 @@ const readChainedPage = ( const next = Number(bits(raw, 51, generic ? 12 : 11)) * (generic ? 4 : 8); if (next === 0) break; if (at + next + 8 > source.size) - throw new RangeError( + throw malformedInput( + "macho", "Chained pointer link leaves its file-backed page", ); at += next; } } catch (cause: unknown) { + // A decode failure stays with this record; a host limit is not one. + if (isHostLimitFailure(cause)) throw cause; failure = cause instanceof Error ? cause.message : String(cause); } return { locations, lastOffset, failure }; @@ -411,9 +443,9 @@ const chainedFixupsHeader = ( size < 28 || end > bytes.length ) - throw new RangeError("Chained fixup header exceeds the target"); + throw malformedInput("macho", "Chained fixup header exceeds the target"); if (bytes.readUInt32LE(base) !== 0) - throw new RangeError("Unsupported chained fixup header version"); + throw unsupportedInput("macho", "Unsupported chained fixup header version"); const imports = { offset: bytes.readUInt32LE(base + 8), symbols: bytes.readUInt32LE(base + 12), @@ -423,16 +455,23 @@ const chainedFixupsHeader = ( const importSize = imports.format === 3 ? 16 : imports.format === 2 ? 8 : 4; const importTableEnd = base + imports.offset + imports.count * importSize; if (imports.format < 1 || imports.format > 3) - throw new RangeError(`Unsupported chained import format ${imports.format}`); + throw unsupportedInput( + "macho", + `Unsupported chained import format ${imports.format}`, + ); if ( imports.offset < 28 || imports.symbols < imports.offset || importTableEnd > base + imports.symbols || base + imports.symbols > end ) - throw new RangeError("Chained import table exceeds the fixup data"); + throw malformedInput( + "macho", + "Chained import table exceeds the fixup data", + ); if (bytes.readUInt32LE(base + 24) !== 0) - throw new RangeError( + throw unsupportedInput( + "macho", "Compressed chained-fixup symbol names are not supported", ); return { startsOffset: bytes.readUInt32LE(base + 4), imports }; @@ -501,12 +540,16 @@ const chainedFixups = ( const startsEnd = base + importHeader.offset; const requireStartsRange = (at: number, size: number): void => { if (at < base + 28 || at > startsEnd - size) - throw new RangeError("Chained starts table exceeds its fixup data"); + throw malformedInput( + "macho", + "Chained starts table exceeds its fixup data", + ); }; requireStartsRange(starts, 4); const segmentCount = bytes.readUInt32LE(starts); if (segmentCount > segments.length) - throw new RangeError( + throw malformedInput( + "macho", "Chained starts segment count exceeds the Mach-O layout", ); requireStartsRange(starts, 4 + segmentCount * 4); @@ -555,19 +598,29 @@ const chainedFixups = ( if (infoOffset === 0) continue; const segment = segments[index]; if (segment === undefined) - throw new RangeError("Chained fixup segment table is out of range"); + throw malformedInput( + "macho", + "Chained fixup segment table is out of range", + ); const info = starts + infoOffset; requireStartsRange(info, 22); const infoSize = bytes.readUInt32LE(info); const pageSize = bytes.readUInt16LE(info + 4); const pageCount = bytes.readUInt16LE(info + 20); if (infoSize < 22 + pageCount * 2) - throw new RangeError("Chained page starts exceed the segment table size"); + throw malformedInput( + "macho", + "Chained page starts exceed the segment table size", + ); requireStartsRange(info, infoSize); if (pageSize !== 0x1000 && pageSize !== 0x4000) - throw new RangeError("Chained page size must be 4096 or 16384 bytes"); + throw malformedInput( + "macho", + "Chained page size must be 4096 or 16384 bytes", + ); if (bytes.readBigUInt64LE(info + 8) !== segment.address - baseAddress) - throw new RangeError( + throw malformedInput( + "macho", "Chained segment offset disagrees with its Mach-O layout", ); ranges.push({ @@ -821,9 +874,11 @@ const chainedFixups = ( facts.failure !== undefined && (facts.lastOffset === undefined || at > facts.lastOffset) ) - throw new RangeError(facts.failure); + throw malformedInput("macho", facts.failure); return legacyBind ?? legacyRebase ?? rawPointer(); } catch (cause: unknown) { + // A decode failure stays with this record; a host limit is not one. + if (isHostLimitFailure(cause)) throw cause; const reason = cause instanceof Error ? cause.message : String(cause); recordPointerFailure(reason, address, chainIndex); return { kind: "unsupported", reason }; @@ -843,10 +898,11 @@ const uleb = ( let shift = 0; for (;;) { if (cursor.at >= end) - throw new RangeError(`Truncated ${stream} opcode operand`); + throw truncatedInput("macho", `Truncated ${stream} opcode operand`); if (rebaseWork !== undefined) { if (rebaseWork.workRemaining === 0) - throw new RangeError( + throw malformedInput( + "macho", "Legacy rebase parsing exceeded the source-derived work budget", ); rebaseWork.workRemaining--; @@ -854,7 +910,7 @@ const uleb = ( const byte = bytes[cursor.at++] ?? 0; const slice = byte & 0x7f; if ((shift === 63 && slice > 1) || (shift === 64 && slice !== 0)) - throw new RangeError(`Oversized ${stream} opcode operand`); + throw malformedInput("macho", `Oversized ${stream} opcode operand`); if (shift < 64) value |= BigInt(slice) << BigInt(shift); // Padding may extend the encoding, but never the represented integer. shift = Math.min(64, shift + 7); @@ -909,7 +965,7 @@ function* readRebaseStreamSteps( !Number.isSafeInteger(end) || end > bytes.length ) - throw new RangeError("Rebase opcodes exceed the target"); + throw malformedInput("macho", "Rebase opcodes exceed the target"); const cursor = { at: range.offset }; let segmentIndex = -1; let segmentOffset = 0n; @@ -923,23 +979,26 @@ function* readRebaseStreamSteps( function* addRun(count: bigint, skip: bigint): Generator { if (count === 0n) return; if (type === 0 || type > 3) - throw new RangeError(`Unsupported legacy rebase type ${type}`); + throw unsupportedInput("macho", `Unsupported legacy rebase type ${type}`); const segment = segments[segmentIndex]; if (segment === undefined) - throw new RangeError("Rebase location has no selected segment"); + throw malformedInput("macho", "Rebase location has no selected segment"); const stride = REBASE_POINTER_SIZE + skip; const lastOffset = segmentOffset + (count - 1n) * stride; if (segmentOffset < 0n || lastOffset + REBASE_POINTER_SIZE > segment.size) - throw new RangeError("Rebase expansion leaves its file-backed segment"); + throw malformedInput( + "macho", + "Rebase expansion leaves its file-backed segment", + ); if (!Number.isSafeInteger(segment.offset) || segment.offset < 0) - throw new RangeError("Rebase segment file offset is invalid"); + throw malformedInput("macho", "Rebase segment file offset is invalid"); const firstFileOffset = BigInt(segment.offset) + segmentOffset; if ( firstFileOffset < 0n || lastOffset + BigInt(segment.offset) + REBASE_POINTER_SIZE > BigInt(bytes.length) ) - throw new RangeError("Rebase location exceeds the source file"); + throw malformedInput("macho", "Rebase location exceeds the source file"); const fact = type === 1 ? REBASE_POINTER : REBASE_NON_POINTER; const admitted = count < BigInt(index.workRemaining) ? count : BigInt(index.workRemaining); @@ -950,7 +1009,8 @@ function* readRebaseStreamSteps( } index.workRemaining -= Number(admitted); if (admitted < count) - throw new RangeError( + throw malformedInput( + "macho", "Legacy rebase opcode and expansion work exceeded the source-derived budget", ); // dyld advances after the final record too; that cursor may legally be @@ -961,7 +1021,8 @@ function* readRebaseStreamSteps( while (cursor.at < end) { if (++opcodes % 4096 === 0) yield; if (index.workRemaining === 0) - throw new RangeError( + throw malformedInput( + "macho", "Legacy rebase parsing exceeded the source-derived work budget", ); index.workRemaining--; @@ -970,7 +1031,8 @@ function* readRebaseStreamSteps( switch (byte & 0xf0) { case 0x00: // REBASE_OPCODE_DONE; up to 15 trailing bytes are alignment. if (end - cursor.at > 15) - throw new RangeError( + throw malformedInput( + "macho", "Rebase opcodes terminated before the stream end", ); cursor.at = end; @@ -981,7 +1043,7 @@ function* readRebaseStreamSteps( case 0x20: { // REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB if (segments[immediate] === undefined) - throw new RangeError("Rebase segment index is out of range"); + throw malformedInput("macho", "Rebase segment index is out of range"); segmentIndex = immediate; segmentOffset = uleb(bytes, cursor, end, "rebase", index); break; @@ -1015,7 +1077,8 @@ function* readRebaseStreamSteps( break; } default: - throw new RangeError( + throw unsupportedInput( + "macho", `Unsupported rebase opcode 0x${byte.toString(16)}`, ); } @@ -1091,6 +1154,8 @@ function* parseLegacyRebasesSteps( yield* readRebaseStreamSteps(image, stream, index); decoded++; } catch (cause: unknown) { + // A decode failure stays with this record; a host limit is not one. + if (isHostLimitFailure(cause)) throw cause; failures.push( `rebase opcodes: ${cause instanceof Error ? cause.message : String(cause)}`, ); @@ -1115,14 +1180,15 @@ const sleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => { let shift = 0; let byte = 0; do { - if (cursor.at >= end) throw new RangeError("Truncated bind opcode operand"); + if (cursor.at >= end) + throw truncatedInput("macho", "Truncated bind opcode operand"); byte = bytes[cursor.at++] ?? 0; const slice = byte & 0x7f; if ( (shift === 63 && slice !== 0 && slice !== 0x7f) || (shift === 64 && slice !== (value < 0n ? 0x7f : 0)) ) - throw new RangeError("Oversized bind opcode operand"); + throw malformedInput("macho", "Oversized bind opcode operand"); if (shift < 64) value = BigInt.asIntN(64, value | (BigInt(slice) << BigInt(shift))); shift = Math.min(64, shift + 7); @@ -1178,7 +1244,7 @@ const setBindRegister = ( case 0x40: { const stop = bytes.indexOf(0, cursor.at); if (stop < 0 || stop >= end) - throw new RangeError("Unterminated bind symbol"); + throw truncatedInput("macho", "Unterminated bind symbol"); state.symbol = symbolName(bytes.subarray(cursor.at, stop)); state.weak = (immediate & 0x1) !== 0; cursor.at = stop + 1; @@ -1193,7 +1259,7 @@ const setBindRegister = ( case 0x70: { const segment = image.segments[immediate]; if (segment === undefined) - throw new RangeError("Bind segment index is out of range"); + throw malformedInput("macho", "Bind segment index is out of range"); state.segmentIndex = immediate; state.address = segment.address + uleb(bytes, cursor, end); return true; @@ -1217,7 +1283,7 @@ function* readBindStreamSteps( const { bytes } = image; const end = range.offset + range.size; if (end > bytes.length) - throw new RangeError("Bind opcodes exceed the target"); + throw malformedInput("macho", "Bind opcodes exceed the target"); const cursor = { at: range.offset }; const state: BindState = { type: range.stream === "lazy" ? 1 : 0, @@ -1232,15 +1298,21 @@ function* readBindStreamSteps( const preflightRun = (count: bigint, skip: bigint): void => { if (count === 0n) return; if (state.type < 1 || state.type > 3) - throw new RangeError(`Unsupported legacy bind type ${state.type}`); + throw unsupportedInput( + "macho", + `Unsupported legacy bind type ${state.type}`, + ); const segment = image.segments[state.segmentIndex]; if (segment === undefined) - throw new RangeError("Bind location has no selected segment"); + throw malformedInput("macho", "Bind location has no selected segment"); const stride = 8n + skip; const width = state.type === 1 ? 8n : 4n; const end = state.address + (count - 1n) * stride + width; if (state.address < segment.address || end > segment.address + segment.size) - throw new RangeError("Bind expansion leaves its file-backed segment"); + throw malformedInput( + "macho", + "Bind expansion leaves its file-backed segment", + ); }; const bind = (count: bigint, skip: bigint): void => { if (count === 0n) return; @@ -1250,7 +1322,7 @@ function* readBindStreamSteps( let binding: BindRun["binding"]; try { if (state.ordinalFailure !== null) - throw new RangeError(state.ordinalFailure); + throw malformedInput("macho", state.ordinalFailure); const resolvedLibrary = libraryFacts(state.ordinal, image.dylibs); const imported: Import = { ...state.symbol, @@ -1272,6 +1344,8 @@ function* readBindStreamSteps( binding = decoded.kind === "bind" ? { kind: "bind", value: decoded } : decoded; } catch (cause: unknown) { + // A decode failure stays with this record; a host limit is not one. + if (isHostLimitFailure(cause)) throw cause; binding = { kind: "unsupported", reason: cause instanceof Error ? cause.message : String(cause), @@ -1333,7 +1407,8 @@ function* readBindStreamSteps( break; case 0xc0: { if (range.stream === "lazy") - throw new RangeError( + throw malformedInput( + "macho", "Lazy bind streams do not support repeated bind opcodes", ); const count = uleb(bytes, cursor, end); @@ -1342,7 +1417,10 @@ function* readBindStreamSteps( break; } default: - throw new RangeError(`Unsupported bind opcode 0x${byte.toString(16)}`); + throw unsupportedInput( + "macho", + `Unsupported bind opcode 0x${byte.toString(16)}`, + ); } } } @@ -1446,6 +1524,8 @@ function* parseLegacyBindsSteps( yield* readBindStreamSteps(image, stream, runs); decoded++; } catch (cause: unknown) { + // A decode failure stays with this record; a host limit is not one. + if (isHostLimitFailure(cause)) throw cause; failures.push( `${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`, ); diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index bd3df505b5..c4ce89c1bc 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -1,3 +1,8 @@ +import { + ParseFailureError, + malformedInput, + truncatedInput, +} from "../domain/parseFailure.js"; import { readFatContainerHeader } from "../domain/apple/machoContainer.js"; import { describe, expect, it } from "vitest"; @@ -52,7 +57,7 @@ it.each([ const bytes = Buffer.alloc(headerSize - 1); bytes.writeUInt32BE(magic); expect(() => parseMachoLayout(bytes, "unrecognized")).toThrow( - new RangeError("Truncated Mach-O header"), + truncatedInput("macho", "Truncated Mach-O header"), ); }, ); @@ -228,7 +233,7 @@ describe("Mach-O metadata span mapping", () => { // Dyld fixup segment ordinals retain the original command order. expect(layout.segments).toHaveLength(1); expect(layout.baseAddress).toBe(0n); - expect(() => layout.offset(0x1000n)).toThrow(RangeError); + expect(() => layout.offset(0x1000n)).toThrow(ParseFailureError); expect(readMachoMetadataImage(bytes).mappings).toEqual([]); }, ); @@ -257,8 +262,8 @@ describe("Mach-O metadata span mapping", () => { opcodes.copy(bytes, 400); const layout = parseMachoLayout(bytes, "arm64"); expect(layout.offset(0x2008n, 8)).toBe(264); - expect(() => layout.offset(8n, 8)).toThrow(RangeError); - expect(() => layout.offset(0x1000n, 8)).toThrow(RangeError); + expect(() => layout.offset(8n, 8)).toThrow(ParseFailureError); + expect(() => layout.offset(0x1000n, 8)).toThrow(ParseFailureError); const fixups = parsePointerFixups( bytes, layout.fixups, @@ -283,7 +288,7 @@ describe("Mach-O metadata span mapping", () => { "arm64", ); expect(layout.offset(0x1100n, 4)).toBe(256); - expect(() => layout.offset(0x1100n, 8)).toThrow(RangeError); + expect(() => layout.offset(0x1100n, 8)).toThrow(ParseFailureError); }); it("maps adjacent ranges and duplicate aliases with the same file translation", () => { @@ -306,7 +311,7 @@ describe("Mach-O metadata span mapping", () => { ]), "arm64", ); - expect(() => layout.offset(0x1080n, 8)).toThrow(RangeError); + expect(() => layout.offset(0x1080n, 8)).toThrow(ParseFailureError); }); it.each([-1, 0.5, Number.POSITIVE_INFINITY])( @@ -316,7 +321,7 @@ describe("Mach-O metadata span mapping", () => { mappingImage([mappingSegment(0x1000n, 0, 512)]), "arm64", ); - expect(() => layout.offset(0x1000n, size)).toThrow(RangeError); + expect(() => layout.offset(0x1000n, size)).toThrow(ParseFailureError); }, ); @@ -710,7 +715,8 @@ it.each([0, 129])( ? new TypeError( "Universal Mach-O metadata needs the admitted slice (FAT header byte order: big-endian)", ) - : new RangeError( + : malformedInput( + "macho", "Malformed FAT architecture table (FAT header byte order: big-endian)", ), ); diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 47aab7a5c3..ab04961550 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -1,3 +1,4 @@ +import { malformedInput, truncatedInput } from "../domain/parseFailure.js"; import { LC } from "../domain/apple/machoLoadCommandCodes.js"; import type { FileMapping, @@ -97,12 +98,12 @@ export const selectMachoSlice = ( ): MachoSlice => { const header = readFatHeaderPrefix(bytes); if (header.state === "unknown") - throw new RangeError("Truncated Mach-O header"); + throw truncatedInput("macho", "Truncated Mach-O header"); if (header.state === "not-fat") return { slice: 0, sliceEnd: bytes.length }; const { littleEndian, wide } = header; const byteOrder = littleEndian ? "little-endian" : "big-endian"; const malformed = (reason: string) => - new RangeError(`${reason} (FAT header byte order: ${byteOrder})`); + malformedInput("macho", `${reason} (FAT header byte order: ${byteOrder})`); const invalid = (reason: string) => new TypeError(`${reason} (FAT header byte order: ${byteOrder})`); if (header.state === "incomplete") @@ -159,19 +160,22 @@ export const parseMachoLayout = ( architecture: string, admitted?: AdmittedMachoSlice, ): MachoLayout => { - if (bytes.length < 4) throw new RangeError("Truncated Mach-O header"); + if (bytes.length < 4) + throw truncatedInput("macho", "Truncated Mach-O header"); const selection = selectMachoSlice(bytes, architecture, admitted); const { slice, sliceEnd } = selection; - if (slice + 4 > sliceEnd) throw new RangeError("Truncated Mach-O header"); + if (slice + 4 > sliceEnd) + throw truncatedInput("macho", "Truncated Mach-O header"); const selectedBytes = bytes.subarray(slice, sliceEnd); const declarations = readMachoLoadCommandDeclarations( selectedBytes, selectedBytes.length, ); - if (!declarations.ok) throw new RangeError("Truncated Mach-O header"); + if (!declarations.ok) + throw truncatedInput("macho", "Truncated Mach-O header"); if (declarations.value === null) { if (selectedBytes.length < 32) - throw new RangeError("Truncated Mach-O header"); + throw truncatedInput("macho", "Truncated Mach-O header"); throw new TypeError( "Only little-endian 64-bit Mach-O metadata is supported", ); @@ -208,7 +212,7 @@ export const parseMachoLayout = ( ); const commandEnd = slice + table.headerSize + table.commandBytes; if (commandEnd > sliceEnd) - throw new RangeError("Malformed Mach-O command bounds"); + throw malformedInput("macho", "Malformed Mach-O command bounds"); const state: LayoutState = { slice, sliceEnd, @@ -226,32 +230,34 @@ export const parseMachoLayout = ( while (framing.nextOffset !== null) { const cursor = slice + table.headerSize + framing.nextOffset; const frame = framing.read(bytes.subarray(cursor, cursor + 8)); - if (!frame.ok) throw new RangeError(frame.error); + if (!frame.ok) throw malformedInput("macho", frame.error); const command = { cursor, kind: frame.value.command, size: frame.value.size, }; if (command.kind === 1) - throw new RangeError( + throw malformedInput( + "macho", "Mach-O segment command does not match the header width", ); if (command.kind === 0x19) collectSegment(bytes, command, state); else collectFixupCommand(bytes, command, state); } const complete = framing.finish(); - if (!complete.ok) throw new RangeError(complete.error); + if (!complete.ok) throw malformedInput("macho", complete.error); const { segments, sections, chained, binds, rebases, dylibs } = state; const mappings = createFileMappingIndex(state.mappings, state.zeroFills); const offset = (address: bigint, size = 1): number => { try { return mappings.offset(address, size); } catch (cause) { - // Metadata consumers use RangeError to retain partial facts when a - // pointer is unavailable. Mapping interpretation belongs to the shared - // image reader, including aliases that only overlap part of the span. + // Metadata consumers retain partial facts when a pointer is unavailable. + // Mapping interpretation belongs to the shared image reader, including + // aliases that only overlap part of the span. if (!(cause instanceof BinaryFormatFailure)) throw cause; - throw new RangeError( + throw malformedInput( + "macho", `Unmapped or ambiguous metadata pointer ${hex(address)}`, { cause }, ); @@ -312,7 +318,7 @@ const collectSegment = ( true, true, ); - if (!parsed.ok) throw new RangeError("Truncated segment command"); + if (!parsed.ok) throw truncatedInput("macho", "Truncated segment command"); const { address, memorySize, @@ -324,9 +330,9 @@ const collectSegment = ( headerSize, } = parsed.value; const admitted = admitMachoSegmentMapping(parsed.value, 64); - if (!admitted.ok) throw new RangeError(admitted.error.message); + if (!admitted.ok) throw malformedInput("macho", admitted.error.message); if (fileOffset + fileSize > BigInt(state.sliceEnd - state.slice)) - throw new RangeError("Segment file range exceeds target bytes"); + throw malformedInput("macho", "Segment file range exceeds target bytes"); const name = decodeMachoFixedName(nameBytes); state.segments.push({ address, @@ -353,7 +359,7 @@ const collectSegment = ( ) state.baseAddress ??= address; if (headerSize + count * 80 > size) - throw new RangeError("Truncated section table"); + throw truncatedInput("macho", "Truncated section table"); for (let section = 0; section < count; section++) { const position = cursor + headerSize + section * 80; const parsedSection = readMachoSectionHeader( @@ -361,10 +367,11 @@ const collectSegment = ( true, true, ); - if (!parsedSection.ok) throw new RangeError("Truncated section table"); + if (!parsedSection.ok) + throw truncatedInput("macho", "Truncated section table"); const { nameBytes, address, size: sectionSize } = parsedSection.value; if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER)) - throw new RangeError("Section size exceeds exact numeric range"); + throw malformedInput("macho", "Section size exceeds exact numeric range"); state.sections.push({ name: decodeMachoFixedName(nameBytes), address, @@ -383,14 +390,15 @@ const collectFixupCommand = ( const offset = state.slice + bytes.readUInt32LE(at); const length = bytes.readUInt32LE(at + 4); if (offset + length > state.sliceEnd) - throw new RangeError("Fixup data exceeds the Mach-O slice"); + throw malformedInput("macho", "Fixup data exceeds the Mach-O slice"); return { offset, size: length }; }; if (kind === LC.DYLD_CHAINED_FIXUPS) { - if (size < 16) throw new RangeError("Truncated chained-fixups command"); + if (size < 16) + throw truncatedInput("macho", "Truncated chained-fixups command"); state.chained = range(cursor + 8); } else if (kind === LC.DYLD_INFO || kind === LC.DYLD_INFO_ONLY) { - if (size < 48) throw new RangeError("Truncated dyld-info command"); + if (size < 48) throw truncatedInput("macho", "Truncated dyld-info command"); const rebase = range(cursor + 8); if (rebase.size > 0) state.rebases.push(rebase); for (const [at, stream] of [ @@ -408,10 +416,13 @@ const collectFixupCommand = ( ); if (!parsed.ok) { if (parsed.error._tag === "InvalidMachoDylibCommand") - throw new RangeError("Truncated dylib command"); + throw truncatedInput("macho", "Truncated dylib command"); if (parsed.error.reason === "unterminated") - throw new RangeError("Unterminated dylib install name"); - throw new RangeError("Dylib install name is outside its load command"); + throw truncatedInput("macho", "Unterminated dylib install name"); + throw malformedInput( + "macho", + "Dylib install name is outside its load command", + ); } const { nameBytes } = parsed.value; state.dylibs.push( diff --git a/src/native/AppleMetadataReadFailure.ts b/src/native/AppleMetadataReadFailure.ts new file mode 100644 index 0000000000..9fe0295cfc --- /dev/null +++ b/src/native/AppleMetadataReadFailure.ts @@ -0,0 +1,18 @@ +import { parseFailureFromCause } from "../domain/parseFailure.js"; +/** + * Expected format-boundary failures from bounded Apple metadata reads. A host + * stack or capacity failure is not one: it says nothing about the metadata. + */ +export const expectedAppleMetadataReadFailure = ( + cause: unknown, +): string | null => { + const failure = parseFailureFromCause(cause); + if (failure !== undefined) return failure.message; + if ( + cause instanceof TypeError && + "code" in cause && + cause.code === "ERR_ENCODING_INVALID_ENCODED_DATA" + ) + return cause.message; + return null; +}; diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index dd2b144130..4b184edd97 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -1,3 +1,5 @@ +import { malformedInput } from "../domain/parseFailure.js"; +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { Section } from "./AppleMachoSelection.js"; import type { ObjcDispatchReaders } from "./AppleObjcDispatchFacets.js"; @@ -77,7 +79,10 @@ export const decodeObjcCategories = (input: { const category = read.pointer(field); const name = read.string(read.pointer(category)); if (name.length === 0) - throw new TypeError("Objective-C category has an empty name"); + throw malformedInput( + "macho", + "Objective-C category has an empty name", + ); const external = boundClassName(read, category + 8n); const local = read.pointer(category + 8n); let className = external ?? null; @@ -85,6 +90,7 @@ export const decodeObjcCategories = (input: { try { className = input.localClassName(local); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; // The category's own name and methods do not depend on its owner; // keep them and report the unreadable class reference. input.issues.push( @@ -166,6 +172,7 @@ export const decodeObjcCategories = (input: { evidence: read.evidence(category, "Objective-C category_t record"), }); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; const message = cause instanceof Error ? cause.message : String(cause); input.issues.push( issue( diff --git a/src/native/AppleObjcClassCoverage.test.ts b/src/native/AppleObjcClassCoverage.test.ts index d4752f2318..5ba533181a 100644 --- a/src/native/AppleObjcClassCoverage.test.ts +++ b/src/native/AppleObjcClassCoverage.test.ts @@ -28,6 +28,27 @@ const classWithMetaclass = (options: { duplicateRoot?: boolean } = {}) => { }; describe("Objective-C class-object coverage units", () => { + it("propagates a host stack limit while decoding a deep protocol chain", () => { + const count = 5_000; + const bytes = Buffer.alloc(0x1000 + count * 128); + objcRecordBudgetMacho("protocol").copy(bytes); + bytes.writeBigUInt64LE(BigInt(bytes.length), 64); + bytes.writeBigUInt64LE(BigInt(bytes.length), 80); + bytes.writeBigUInt64LE(base + 0x1000n, 0x6c0); + for (let index = 0; index < count; index++) { + const at = 0x1000 + index * 128; + bytes.writeBigUInt64LE(base + 0x980n, at + 8); + bytes.writeUInt32LE(72, at + 64); + if (index + 1 < count) { + bytes.writeBigUInt64LE(base + BigInt(at + 96), at + 16); + bytes.writeBigUInt64LE(1n, at + 96); + bytes.writeBigUInt64LE(base + BigInt(at + 128), at + 104); + } + } + expect(() => + decodeAppleDispatchMetadata(bytes, count * 3, provenance), + ).toThrow(RangeError); + }); it("counts both admitted class and metaclass records", () => { const result = decodeAppleDispatchMetadata( classWithMetaclass(), diff --git a/src/native/AppleObjcClassImportBindings.test.ts b/src/native/AppleObjcClassImportBindings.test.ts index 3a640af22c..b3d0ca64b9 100644 --- a/src/native/AppleObjcClassImportBindings.test.ts +++ b/src/native/AppleObjcClassImportBindings.test.ts @@ -1,3 +1,4 @@ +import { malformedInput } from "../domain/parseFailure.js"; import { describe, expect, it } from "vitest"; import type { PointerFixups } from "./AppleMachoFixups.js"; @@ -59,7 +60,7 @@ const createInput = ( offset: (address, size = 1) => { if (address === 0x1000n && size === 8) return 0; if (address === 0x1008n && size === 8) return 8; - throw new RangeError("address is outside fixture"); + throw malformedInput("macho", "address is outside fixture"); }, }; return { diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 89886573c9..1fa0c5234c 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -1,3 +1,5 @@ +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; +import { malformedInput, unsupportedInput } from "../domain/parseFailure.js"; import { err, ok, type Result } from "../domain/result.js"; import { projectObjcClassMethods, @@ -40,7 +42,8 @@ const readLocalClass = (read: ObjcDispatchReaders, address: bigint) => { const ro = read.pointer(address + 32n) & ~7n; const name = read.string(read.pointer(ro + 24n)); if (name.length === 0) - throw new TypeError( + throw malformedInput( + "macho", `Objective-C class at ${hex(address)} has an empty name`, ); return { ro, name }; @@ -66,6 +69,7 @@ const resolveSuperclass = ( address: hex(superclass), }; } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; failures.push( `Superclass pointer ${hex(superclass)} of ${className} could not be decoded: ${cause instanceof Error ? cause.message : String(cause)}`, ); @@ -116,7 +120,10 @@ export const decodeObjcDispatchFacets = (input: { relative = (flags & 0x80000000) !== 0; const stride = flags & 0xfffc; if (stride !== (relative ? 12 : 24)) - throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`); + throw unsupportedInput( + "macho", + `Unsupported method stride/flags at ${hex(list)}`, + ); for (let index = 0; index < count; index++) { if (!budget.admit()) { onTruncated?.(); @@ -130,6 +137,7 @@ export const decodeObjcDispatchFacets = (input: { try { fileOffset = read.offset(target); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; // best-effort cleanup: serialized target remains unresolved. void cause; } @@ -170,17 +178,22 @@ export const decodeObjcDispatchFacets = (input: { implementation < segment.address + segment.size, ) ) - throw new RangeError( + throw malformedInput( + "macho", "Method implementation is not in an executable segment", ); implementationLocation = read.location(implementation); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; // best-effort cleanup: external/chained pointers remain unresolved. void cause; } const selector = read.string(selectorAddress); if (selector.length === 0) - throw new TypeError("Objective-C method has an empty selector"); + throw malformedInput( + "macho", + "Objective-C method has an empty selector", + ); selectors.push(selector); result.objc_dispatch_implementations.push({ class_name: className, @@ -237,6 +250,7 @@ export const decodeObjcDispatchFacets = (input: { try { return ok(read.pointer(field)); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; const admitted = budget.admit(); failures.push( `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -269,6 +283,7 @@ export const decodeObjcDispatchFacets = (input: { try { protocolReader.record(pointer.value); } catch (cause) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; // record() admits the slot itself before decoding, so admitting // again here would count one protocol twice. failures.push( @@ -301,7 +316,10 @@ export const decodeObjcDispatchFacets = (input: { const stride = read.u32(ivarList), count = read.u32(ivarList + 4n); if (stride !== 32) - throw new TypeError("Unsupported Objective-C ivar entry size"); + throw unsupportedInput( + "macho", + "Unsupported Objective-C ivar entry size", + ); ivarCount = count; for (let index = 0; index < count; index++) { if (!budget.admit()) { @@ -314,13 +332,14 @@ export const decodeObjcDispatchFacets = (input: { try { value = read.u32(encodedOffset); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; // Unresolved offsets are recorded; the cause adds no identity. void cause; failures.push(`Unresolved ivar offset pointer ${hex(encodedOffset)}`); } const ivarName = read.string(read.pointer(entry + 8n)); if (ivarName.length === 0) - throw new TypeError("Objective-C ivar has an empty name"); + throw malformedInput("macho", "Objective-C ivar has an empty name"); const alignment = read.u32(entry + 24n); result.objc_ivars.push({ class_name: name, @@ -407,6 +426,7 @@ export const decodeObjcDispatchFacets = (input: { try { readClass(read.pointer(address), true); } catch (cause) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; failures.push( `Metaclass of ${name}: ${cause instanceof Error ? cause.message : String(cause)}`, ); @@ -438,6 +458,7 @@ export const decodeObjcDispatchFacets = (input: { try { readClass(pointer.value); } catch (cause) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; // The slot was already admitted inside readClass before anything // could throw (its admit runs before the first pointer read), so // admitting again here would count one class twice: at the budget diff --git a/src/native/AppleObjcProperties.ts b/src/native/AppleObjcProperties.ts index 5b572bae1d..c4ed16b5af 100644 --- a/src/native/AppleObjcProperties.ts +++ b/src/native/AppleObjcProperties.ts @@ -1,3 +1,5 @@ +import { malformedInput, unsupportedInput } from "../domain/parseFailure.js"; +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; import type { z } from "zod"; import type { objcPropertySchema } from "../domain/native/objcSwiftMetadata.js"; @@ -119,7 +121,8 @@ export const readObjcPropertyList = ( const entrySize = read.u32(list) & ~3; const count = read.u32(list + 4n); if (entrySize !== 16) - throw new TypeError( + throw unsupportedInput( + "macho", `Unsupported Objective-C property entry size ${entrySize}`, ); for (let index = 0; index < count; index++) { @@ -131,7 +134,8 @@ export const readObjcPropertyList = ( const location = `0x${entry.toString(16)}`; try { const name = read.string(read.pointer(entry)); - if (name.length === 0) throw new TypeError("the property name is empty"); + if (name.length === 0) + throw malformedInput("macho", "the property name is empty"); const decoded = decodePropertyAttributes( name, read.string(read.pointer(entry + 8n)), @@ -144,6 +148,7 @@ export const readObjcPropertyList = ( location, }); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; issues.push({ code: "property_decode_failed", message: `property ${String(index)} could not be decoded: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -186,6 +191,7 @@ export const readObjcPropertiesOf = ( ); return properties; } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; context.issues.push( issue( "objc_properties_categories", diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 60335bbd43..2b7b0ac52c 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -1,3 +1,10 @@ +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; +import { + decodeLimit, + malformedInput, + unsupportedInput, + truncatedInput, +} from "../domain/parseFailure.js"; import { readObjcPropertyList } from "./AppleObjcProperties.js"; import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { SwiftNominalReaders } from "./AppleSwiftNominalTypes.js"; @@ -42,7 +49,10 @@ export const createObjcProtocolReader = (input: { // `count` is plain uintptr_t data, not a pointer fixup. const count = read.u64(address); if (count > 20000n) - throw new RangeError("Protocol list exceeds 20000 entries"); + throw decodeLimit("macho", "Protocol list exceeds 20000 entries", { + name: "objc_protocol_list_entries", + maximum: 20_000, + }); for (let index = 0n; index < count; index++) { if (!read.admit()) { onTruncated?.(); @@ -54,6 +64,7 @@ export const createObjcProtocolReader = (input: { } return ok(names); } catch (cause: unknown) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; return err({ _tag: "ProtocolListUnreadable", names, @@ -76,7 +87,10 @@ export const createObjcProtocolReader = (input: { const relative = (flags & 0x80000000) !== 0; const stride = flags & 0xfffc; if (stride !== (relative ? 12 : 24)) - throw new TypeError("Unsupported Objective-C protocol method entry size"); + throw unsupportedInput( + "macho", + "Unsupported Objective-C protocol method entry size", + ); for (let index = 0; index < count; index++) { if (!read.admit()) { onTruncated?.(); @@ -113,7 +127,10 @@ export const createObjcProtocolReader = (input: { try { const protocolSize = read.u32(address + 64n); if (protocolSize < 72) - throw new RangeError("Truncated Objective-C protocol declaration"); + throw truncatedInput( + "macho", + "Truncated Objective-C protocol declaration", + ); const protocolName = name(address); // Adopted protocols are independent declarations; decode them before // rejecting a parent whose own name is empty. @@ -127,7 +144,7 @@ export const createObjcProtocolReader = (input: { `Protocol 0x${address.toString(16)} adopted protocol list: ${adopted.error.reason}`, ); if (protocolName.length === 0) - throw new TypeError("Objective-C protocol has an empty name"); + throw malformedInput("macho", "Objective-C protocol has an empty name"); const skipped: string[] = []; const required = [ ...methods( @@ -183,6 +200,7 @@ export const createObjcProtocolReader = (input: { if (protocolSize >= 96 && read.pointer(address + 88n) !== 0n) propertyReasons.push("protocol class properties are not decoded"); } catch (cause) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; propertyReasons.push( cause instanceof Error ? cause.message : String(cause), ); @@ -219,6 +237,7 @@ export const createObjcProtocolReader = (input: { ), }); } catch (cause) { + if (expectedAppleMetadataReadFailure(cause) === null) throw cause; failures.push( `Protocol 0x${address.toString(16)}: ${cause instanceof Error ? cause.message : String(cause)}`, ); diff --git a/src/native/AppleSwiftConformanceTypeReference.ts b/src/native/AppleSwiftConformanceTypeReference.ts index 756bde90c1..0acf1a9bac 100644 --- a/src/native/AppleSwiftConformanceTypeReference.ts +++ b/src/native/AppleSwiftConformanceTypeReference.ts @@ -1,3 +1,4 @@ +import { malformedInput } from "../domain/parseFailure.js"; import { createSwiftContextReader, type SwiftContextReader, @@ -8,7 +9,7 @@ import type { SwiftRelativeField, SwiftRelativeValue, } from "./AppleSwiftDispatchFacets.js"; -import { expectedSwiftMetadataReadFailure } from "./AppleSwiftMetadataReadFailure.js"; +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; const hex = (value: bigint) => `0x${value.toString(16)}`; @@ -80,7 +81,7 @@ export const requiredSwiftRelativeTarget = ( ): bigint => { const value = relative(field, kind); if (value.kind !== "target") - throw new RangeError(unresolvedReason(value, kind)); + throw malformedInput("macho", unresolvedReason(value, kind)); return value.target; }; @@ -143,7 +144,7 @@ const decodeNominalTypeReference = ( generic: (descriptorFlags & 0x80) !== 0, }); } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return typeReferenceResult({ kind, @@ -177,7 +178,7 @@ const decodeDirectObjcClassName = ( reason: name.length === 0 ? "Empty inline Objective-C class name" : null, }); } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return typeReferenceResult({ kind, @@ -205,7 +206,7 @@ const decodeIndirectObjcClass = ( try { pointer = read.resolvePointer(value.target); } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return typeReferenceResult({ kind, @@ -260,7 +261,7 @@ export const decodeSwiftConformanceTypeReference = ( }); } } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return typeReferenceResult({ kind, diff --git a/src/native/AppleSwiftDispatchFacets.ts b/src/native/AppleSwiftDispatchFacets.ts index 48d8e2730a..9118220169 100644 --- a/src/native/AppleSwiftDispatchFacets.ts +++ b/src/native/AppleSwiftDispatchFacets.ts @@ -1,3 +1,4 @@ +import { decodeLimit, parseFailureFromCause } from "../domain/parseFailure.js"; import { createSwiftContextReader, type SwiftContextReader, @@ -12,7 +13,7 @@ import { requiredSwiftRelativeTarget, swiftImportBinding as importBinding, } from "./AppleSwiftConformanceTypeReference.js"; -import { expectedSwiftMetadataReadFailure } from "./AppleSwiftMetadataReadFailure.js"; +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; /** Resolved file-backed metadata location from the Mach-O layout. */ export interface ResolvedMetadataLocation { @@ -127,7 +128,7 @@ const readSwiftProtocolFacts = (input: { try { descriptorKind = read.u32(target) & 31; } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return { name: null, readable: false, reason }; } @@ -146,7 +147,7 @@ const readSwiftProtocolFacts = (input: { reason: context.reason, }; } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return { name: null, readable: false, reason }; } @@ -167,7 +168,7 @@ const readWitnessConformanceHeader = ( ? null : "Witness-table conformance header is unresolved or encoded"; } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return reason; } @@ -263,7 +264,7 @@ export const createSwiftRelativeReader = (input: { try { pointer = read.resolvePointer(target); } catch (cause) { - const failure = expectedSwiftMetadataReadFailure(cause); + const failure = expectedAppleMetadataReadFailure(cause); if (failure === null) throw cause; return unknownIndirect(failure); } @@ -299,7 +300,7 @@ export const createSwiftRelativeReader = (input: { } if (target !== null) fileOffset = read.offset(target); } catch (cause) { - reason = expectedSwiftMetadataReadFailure(cause); + reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; } if (budget.admit()) @@ -486,7 +487,11 @@ export const decodeSwiftDispatchFacets = (input: { const count = read.u32(protocol! + 16n); const signatureCount = read.u32(protocol! + 12n); if (count > 20_000 || signatureCount > 20_000) - throw new RangeError("Swift witness requirement count exceeds 20000"); + throw decodeLimit( + "macho", + "Swift witness requirement count exceeds 20000", + { name: "swift_witness_requirements", maximum: 20_000 }, + ); for (let slot = 0; slot < count; slot++) { if (!budget.admit()) break; const entry = witness + BigInt((slot + 1) * 8); @@ -557,12 +562,13 @@ export const decodeSwiftDispatchFacets = (input: { ), ); } catch (cause) { - if (!(cause instanceof RangeError)) throw cause; + const failure = parseFailureFromCause(cause); + if (failure === undefined) throw cause; issues.push( issue( "swift_conformances_static_witness_slots", "witness_slot_unresolved", - cause.message, + failure.message, hex(entry), ), ); @@ -570,7 +576,7 @@ export const decodeSwiftDispatchFacets = (input: { } } } catch (cause) { - const message = expectedSwiftMetadataReadFailure(cause); + const message = expectedAppleMetadataReadFailure(cause); if (message === null) throw cause; issues.push( issue( diff --git a/src/native/AppleSwiftMetadataReadFailure.ts b/src/native/AppleSwiftMetadataReadFailure.ts deleted file mode 100644 index 8eceddf87f..0000000000 --- a/src/native/AppleSwiftMetadataReadFailure.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** Expected format-boundary failures from bounded Swift metadata reads. */ -export const expectedSwiftMetadataReadFailure = ( - cause: unknown, -): string | null => { - if (cause instanceof RangeError) return cause.message; - if ( - cause instanceof TypeError && - "code" in cause && - cause.code === "ERR_ENCODING_INVALID_ENCODED_DATA" - ) - return cause.message; - return null; -}; diff --git a/src/native/AppleSwiftNominalTypes.ts b/src/native/AppleSwiftNominalTypes.ts index b2156aa96b..50e80e881d 100644 --- a/src/native/AppleSwiftNominalTypes.ts +++ b/src/native/AppleSwiftNominalTypes.ts @@ -1,3 +1,4 @@ +import { malformedInput } from "../domain/parseFailure.js"; import type { NativeMetadataEvidence, NativeMetadataLocation, @@ -9,7 +10,7 @@ import { type SwiftRelativeField, type SwiftRelativeValue, } from "./AppleSwiftDispatchFacets.js"; -import { expectedSwiftMetadataReadFailure } from "./AppleSwiftMetadataReadFailure.js"; +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; /** Byte readers shared with the validated Mach-O metadata boundary. */ export interface SwiftNominalReaders { @@ -111,10 +112,14 @@ export const createSwiftContextReader = ( if (known !== undefined) return known; const value = read.relative(descriptor + 8n, field); if (value.kind !== "target") - throw new RangeError(unresolvedSwiftRelativeReason(value, field)); + throw malformedInput( + "macho", + unresolvedSwiftRelativeReason(value, field), + ); const name = read.string(value.target); if (name.length === 0) - throw new RangeError( + throw malformedInput( + "macho", `Swift ${field === "protocolName" ? "protocol" : "type"} descriptor name is empty`, ); names.set(descriptor, name); @@ -221,7 +226,7 @@ export const createSwiftContextReader = ( try { facts = readContext(context, walk); } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; facts = unresolved(reason); } @@ -274,7 +279,7 @@ export const decodeSwiftNominalTypes = (input: { reason: `Swift metadata accessor ${hex(value.target)} is not in executable mapped code`, }; } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); + const reason = expectedAppleMetadataReadFailure(cause); if (reason === null) throw cause; return { address: null, reason }; } @@ -301,7 +306,8 @@ export const decodeSwiftNominalTypes = (input: { try { const value = read.relative(record, "typeMetadataRecord"); if (value.kind !== "target") - throw new RangeError( + throw malformedInput( + "macho", unresolvedSwiftRelativeReason(value, "typeMetadataRecord"), ); descriptor = value.target; @@ -320,7 +326,7 @@ export const decodeSwiftNominalTypes = (input: { } name = typeName(descriptor); } catch (cause) { - const message = expectedSwiftMetadataReadFailure(cause); + const message = expectedAppleMetadataReadFailure(cause); if (message === null) throw cause; if (kind === null || kind === CLASS) visitor.failure({ record, message }); issues.push(issue(FACET, "type_decode_failed", message, hex(record))); diff --git a/src/native/AppleSwiftRelativePointer.test.ts b/src/native/AppleSwiftRelativePointer.test.ts index 5c5929baa1..7676dd4c6b 100644 --- a/src/native/AppleSwiftRelativePointer.test.ts +++ b/src/native/AppleSwiftRelativePointer.test.ts @@ -1,3 +1,4 @@ +import { ParseFailureError, malformedInput } from "../domain/parseFailure.js"; import { expect, it } from "vitest"; import { objcSwiftMetadataSchema } from "../domain/native/objcSwiftMetadata.js"; @@ -36,7 +37,7 @@ const readers = (bytes: Buffer) => ({ ], offset: (address: bigint, size = 1) => { if (address < 0n || address + BigInt(size) > BigInt(bytes.length)) - throw new RangeError("outside fixture bytes"); + throw malformedInput("macho", "outside fixture bytes"); return Number(address); }, }); @@ -205,7 +206,8 @@ it("keeps a fixup rebase to VM address zero as an address, not null", () => { ...readers(bytes), resolvePointer: () => ({ kind: "rebase", target: 0n }), offset: (address, size = 1) => { - if (address === 0n) throw new RangeError("unmapped target address"); + if (address === 0n) + throw malformedInput("macho", "unmapped target address"); return readers(bytes).offset(address, size); }, }, @@ -278,8 +280,12 @@ it("retains unsafe target addresses as partial decode facts", () => { }); it.each([ - new RangeError("indirect pointer cell is unreadable"), + malformedInput("macho", "indirect pointer cell is unreadable"), new Error("reader invariant failed"), + // Host failures say nothing about the metadata, so they are not partial + // decode reasons. + new RangeError("Maximum call stack size exceeded"), + new RangeError("Invalid string length"), ])( "distinguishes indirect pointer read failure from a known unmapped target: %s", (failure) => { @@ -297,7 +303,7 @@ it.each([ budget: budget(), result, }); - if (failure instanceof RangeError) { + if (failure instanceof ParseFailureError) { expect(relative(0x24n, "conformanceProtocol")).toEqual({ kind: "unknown", reason: failure.message, diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index a944242164..20c00f270d 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -69,6 +69,38 @@ it("propagates unexpected vtable reader failures", () => { ).toThrow(failure); }); +it("reports the vtable decoder's slot limit separately from malformed bounds", () => { + const vtables = createSwiftClassVtableDecoder({ + result: objcSwiftMetadataSchema.parse({ db_save_result: null }), + readers: { + u32: (address) => + address === 0x230n ? 20_001 : address === 0x21cn ? 30_000 : 0, + relative: () => ({ kind: "null" }), + location: () => ({ address: null, file_offset: null }), + evidence: () => [], + executable: () => false, + admit: () => true, + }, + }); + vtables.visitor.descriptor({ + record: 0x100n, + descriptor: 0x200n, + flags: 0x80000010, + name: "Widget", + context: { + module: "Test", + path: "Widget", + depth: 2, + anonymous: false, + reason: null, + walkLimited: false, + }, + }); + expect(vtables.facts().issues).toContainEqual( + expect.objectContaining({ message: "Swift vtable count exceeds 20000" }), + ); +}); + it("preserves unsupported type-metadata tag reasons instead of reporting null", () => { const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); const vtables = createSwiftClassVtableDecoder({ diff --git a/src/native/AppleSwiftVtables.ts b/src/native/AppleSwiftVtables.ts index 317f606916..ec94f46c50 100644 --- a/src/native/AppleSwiftVtables.ts +++ b/src/native/AppleSwiftVtables.ts @@ -1,3 +1,8 @@ +import { + decodeLimit, + malformedInput, + unsupportedInput, +} from "../domain/parseFailure.js"; import type { ObjcSwiftMetadata, NativeMetadataEvidence, @@ -14,7 +19,7 @@ import type { SwiftNominalRecordFailure, SwiftNominalVisitor, } from "./AppleSwiftNominalTypes.js"; -import { expectedSwiftMetadataReadFailure } from "./AppleSwiftMetadataReadFailure.js"; +import { expectedAppleMetadataReadFailure } from "./AppleMetadataReadFailure.js"; /** Byte readers shared with the validated Mach-O metadata boundary. */ export interface SwiftMetadataReaders { @@ -64,13 +69,14 @@ export const createSwiftClassVtableDecoder = (input: { try { const specific = flags >>> 16; if ((flags & 0x80) !== 0 || (specific & 0x2000) !== 0) - throw new RangeError( + throw unsupportedInput( + "macho", "Generic class or resilient superclass metadata is unsupported", ); if ((specific & 0x8000) === 0) return; const initialization = specific & 3; if (initialization === 3) - throw new RangeError("Unknown metadata initialization kind"); + throw malformedInput("macho", "Unknown metadata initialization kind"); const header = descriptor + 44n + @@ -78,8 +84,14 @@ export const createSwiftClassVtableDecoder = (input: { const tableOffset = read.u32(header); const count = read.u32(header + 4n); const positiveWords = read.u32(descriptor + 28n); - if (count > 20000 || tableOffset + count > positiveWords) - throw new RangeError( + if (count > 20_000) + throw decodeLimit("macho", "Swift vtable count exceeds 20000", { + name: "swift_vtable_entries", + maximum: 20_000, + }); + if (tableOffset + count > positiveWords) + throw malformedInput( + "macho", "Swift vtable count or offset exceeds declared metadata bounds", ); for (let index = 0; index < count; index++) { @@ -168,7 +180,7 @@ export const createSwiftClassVtableDecoder = (input: { ); } } catch (cause) { - const message = expectedSwiftMetadataReadFailure(cause); + const message = expectedAppleMetadataReadFailure(cause); if (message === null) throw cause; issues.push( issue( diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index ba5b69ad8b..cf00a3f500 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -1,3 +1,4 @@ +import { parseFailureFromCause } from "../domain/parseFailure.js"; import { LC } from "../domain/apple/machoLoadCommandCodes.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { EvidenceLocation } from "../domain/evidence.js"; @@ -132,7 +133,8 @@ const readChainedImportNames = async ( ), ); } catch (cause) { - if (cause instanceof RangeError) return err(cause.message); + const failure = parseFailureFromCause(cause); + if (failure !== undefined) return err(failure.message); throw cause; } };