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; } };