diff --git a/src/native/AppleDispatchMetadata.classification.test.ts b/src/native/AppleDispatchMetadata.classification.test.ts new file mode 100644 index 0000000000..38e8ed213e --- /dev/null +++ b/src/native/AppleDispatchMetadata.classification.test.ts @@ -0,0 +1,104 @@ +import { expect, it } from "vitest"; + +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + appleDispatchMetadataFailure, + decodeAppleDispatchMetadata, +} from "./AppleDispatchMetadata.js"; +import { parseMachoLayout } from "./AppleMachoSelection.js"; + +const provenance = { path: "/selected", sha256: "fixture" }; + +const metadataSection = (name: string, size: number): Buffer => { + const bytes = Buffer.alloc(4096); + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(0x0100000c, 4); + bytes.writeUInt32LE(2, 12); + bytes.writeUInt32LE(1, 16); + bytes.writeUInt32LE(152, 20); + bytes.writeUInt32LE(0x19, 32); + bytes.writeUInt32LE(152, 36); + bytes.write("__DATA", 40); + bytes.writeBigUInt64LE(0x100000000n, 56); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + bytes.writeUInt32LE(1, 96); + bytes.write(name, 104); + bytes.write("__DATA", 120); + bytes.writeBigUInt64LE(0x100000200n, 136); + bytes.writeBigUInt64LE(BigInt(size), 144); + bytes.writeUInt32LE(512, 152); + return bytes; +}; + +it.each([ + ["__objc_classlist", 12, "Misaligned Objective-C class-list size"], + ["__objc_protolist", 12, "Misaligned Objective-C protocol list"], + ["__objc_catlist", 12, "Misaligned Objective-C category list"], + ["__swift5_types", 6, "Misaligned Swift type section"], + ["__swift5_proto", 6, "Misaligned Swift conformance section"], +] as const)( + "classifies malformed %s as a target refusal", + (name, size, reason) => { + let thrown: unknown; + try { + decodeAppleDispatchMetadata(metadataSection(name, size), 10, provenance); + } catch (cause) { + thrown = cause; + } + expect(thrown).toBeInstanceOf(Error); + expect( + projectAnalysisError( + appleDispatchMetadataFailure(thrown, provenance.path), + ), + ).toMatchObject({ + code: "unsupported_target", + message: expect.stringContaining(reason), + }); + }, +); + +it("admits a complete 4097-command table and rejects truncated framing", () => { + const count = 4097; + const bytes = Buffer.alloc(32 + count * 16); + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(0x0100000c, 4); + bytes.writeUInt32LE(2, 12); + bytes.writeUInt32LE(count, 16); + bytes.writeUInt32LE(count * 16, 20); + for (let index = 0; index < count; index++) { + bytes.writeUInt32LE(0x2a, 32 + index * 16); + bytes.writeUInt32LE(16, 36 + index * 16); + } + expect(parseMachoLayout(bytes, "arm64").sections).toEqual([]); + let thrown: unknown; + try { + parseMachoLayout(bytes.subarray(0, bytes.length - 1), "arm64"); + } catch (cause) { + thrown = cause; + } + expect( + projectAnalysisError(appleDispatchMetadataFailure(thrown, provenance.path)), + ).toMatchObject({ + code: "unsupported_target", + details: { reason: "Malformed Mach-O command bounds" }, + }); +}); + +it("classifies an unsupported big-endian CPU as a target refusal", () => { + const bytes = Buffer.alloc(32); + bytes.writeUInt32BE(0xfeedfacf, 0); + bytes.writeUInt32BE(0x0100000c, 4); + let thrown: unknown; + try { + decodeAppleDispatchMetadata(bytes, 10, provenance); + } catch (cause) { + thrown = cause; + } + expect( + projectAnalysisError(appleDispatchMetadataFailure(thrown, provenance.path)), + ).toMatchObject({ + code: "unsupported_target", + message: expect.stringContaining("Unsupported big-endian Mach-O CPU type"), + }); +}); diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 0c871d586b..ac07b6890e 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -18,6 +18,7 @@ import { AnalysisAccessDeniedError, AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { ArtifactFileCleanupFailure, @@ -42,6 +43,7 @@ import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { pushDispatchCoverage } from "./AppleDispatchCoverage.js"; import { parsePointerFixups } from "./AppleMachoFixups.js"; import { + MalformedMachoLayoutError, parseMachoLayout, UnsupportedMachoMetadataFormatError, } from "./AppleMachoSelection.js"; @@ -58,6 +60,7 @@ import { createSwiftContextReader, } from "./AppleSwiftNominalTypes.js"; import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; /** Decode validated little-endian 64-bit Apple Objective-C metadata without loading target code. */ export const decodeAppleDispatchMetadata = ( @@ -230,7 +233,10 @@ export const decodeAppleDispatchMetadata = ( ({ name }) => name === "__swift5_types", )) { if (section.size % 4 !== 0) - throw new RangeError("Misaligned Swift type section"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Swift type section", + ); for (let index = 0; index < section.size / 4 && index < maxRecords; index++) typeEntries.push(section.address + BigInt(index * 4)); if (section.size / 4 > maxRecords) budget.truncated = true; @@ -445,6 +451,18 @@ const appleDispatchMetadataPrimaryFailure = ( ): AnalysisError => { if (origin === "owned-or-classified") { if (cause instanceof AnalysisError) return cause; + if ( + cause instanceof BinaryFormatFailure || + cause instanceof MalformedMachoLayoutError + ) + return new AnalysisUnsupportedTargetError( + OPERATION, + path, + cause.message, + { + cause, + }, + ); if (cause instanceof UnsupportedMachoMetadataFormatError) return new AnalysisCapabilityUnavailableError( IDENTITY.id, diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 2cba27ad30..18eaa331fb 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -141,6 +141,9 @@ export const selectMachoSlice = ( return selected; }; +/** A rejected layout declaration in the selected Mach-O image. */ +export class MalformedMachoLayoutError extends RangeError {} + /** A recognized Mach-O class outside this metadata reader's supported formats. */ export class UnsupportedMachoMetadataFormatError extends Error { constructor(message: string) { @@ -154,19 +157,22 @@ export const parseMachoLayout = ( bytes: Buffer, architecture: string, ): MachoLayout => { - if (bytes.length < 4) throw new RangeError("Truncated Mach-O header"); + if (bytes.length < 4) + throw new MalformedMachoLayoutError("Truncated Mach-O header"); const selection = selectMachoSlice(bytes, architecture); const { slice, sliceEnd } = selection; - if (slice + 4 > sliceEnd) throw new RangeError("Truncated Mach-O header"); + if (slice + 4 > sliceEnd) + throw new MalformedMachoLayoutError("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 new MalformedMachoLayoutError("Truncated Mach-O header"); if (declarations.value === null) { if (selectedBytes.length < 32) - throw new RangeError("Truncated Mach-O header"); + throw new MalformedMachoLayoutError("Truncated Mach-O header"); throw new TypeError( "Only little-endian 64-bit Mach-O metadata is supported", ); @@ -183,7 +189,10 @@ export const parseMachoLayout = ( } if (table.wide && !table.littleEndian) { if (cpuType !== 0x01000012) - throw new TypeError("Unsupported big-endian Mach-O CPU type"); + throw new BinaryFormatFailure( + "unsupported", + "Unsupported big-endian Mach-O CPU type", + ); throw new UnsupportedMachoMetadataFormatError( "Unsupported big-endian 64-bit PowerPC Mach-O metadata", ); @@ -200,7 +209,7 @@ export const parseMachoLayout = ( ); const commandEnd = slice + table.headerSize + table.commandBytes; if (commandEnd > sliceEnd) - throw new RangeError("Malformed Mach-O command bounds"); + throw new MalformedMachoLayoutError("Malformed Mach-O command bounds"); const state: LayoutState = { slice, sliceEnd, @@ -218,7 +227,7 @@ 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 new MalformedMachoLayoutError(frame.error); const command = { cursor, kind: frame.value.command, @@ -228,7 +237,7 @@ export const parseMachoLayout = ( else collectFixupCommand(bytes, command, state); } const complete = framing.finish(); - if (!complete.ok) throw new RangeError(complete.error); + if (!complete.ok) throw new MalformedMachoLayoutError(complete.error); const { segments, sections, chained, binds, rebases, dylibs } = state; const mappings = createFileMappingIndex(state.mappings, state.zeroFills); const offset = (address: bigint, size = 1): number => { @@ -300,7 +309,8 @@ const collectSegment = ( true, true, ); - if (!parsed.ok) throw new RangeError("Truncated segment command"); + if (!parsed.ok) + throw new MalformedMachoLayoutError("Truncated segment command"); const { address, memorySize, @@ -312,9 +322,11 @@ const collectSegment = ( headerSize, } = parsed.value; const admitted = admitMachoSegmentMapping(parsed.value, 64); - if (!admitted.ok) throw new RangeError(admitted.error.message); + if (!admitted.ok) throw new MalformedMachoLayoutError(admitted.error.message); if (fileOffset + fileSize > BigInt(state.sliceEnd - state.slice)) - throw new RangeError("Segment file range exceeds target bytes"); + throw new MalformedMachoLayoutError( + "Segment file range exceeds target bytes", + ); const name = decodeMachoFixedName(nameBytes); state.segments.push({ address, @@ -341,7 +353,7 @@ const collectSegment = ( ) state.baseAddress ??= address; if (headerSize + count * 80 > size) - throw new RangeError("Truncated section table"); + throw new MalformedMachoLayoutError("Truncated section table"); for (let section = 0; section < count; section++) { const position = cursor + headerSize + section * 80; const parsedSection = readMachoSectionHeader( @@ -349,10 +361,13 @@ const collectSegment = ( true, true, ); - if (!parsedSection.ok) throw new RangeError("Truncated section table"); + if (!parsedSection.ok) + throw new MalformedMachoLayoutError("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 new MalformedMachoLayoutError( + "Section size exceeds exact numeric range", + ); state.sections.push({ name: decodeMachoFixedName(nameBytes), address, @@ -375,14 +390,18 @@ 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 new MalformedMachoLayoutError( + "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 new MalformedMachoLayoutError("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 new MalformedMachoLayoutError("Truncated dyld-info command"); const rebase = range(cursor + 8); if (rebase.size > 0) state.rebases.push(rebase); for (const [at, stream] of [ @@ -400,10 +419,12 @@ const collectFixupCommand = ( ); if (!parsed.ok) { if (parsed.error._tag === "InvalidMachoDylibCommand") - throw new RangeError("Truncated dylib command"); + throw new MalformedMachoLayoutError("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 new MalformedMachoLayoutError("Unterminated dylib install name"); + throw new MalformedMachoLayoutError( + "Dylib install name is outside its load command", + ); } const { nameBytes } = parsed.value; state.dylibs.push( diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index 994f34b5d4..aee8c33e5a 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -1,5 +1,6 @@ import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { Section } from "./AppleMachoSelection.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; import type { ObjcDispatchReaders } from "./AppleObjcDispatchFacets.js"; import { readObjcPropertiesOf } from "./AppleObjcProperties.js"; import { issue, type DecodeIssue } from "./AppleDispatchDecodeFacts.js"; @@ -43,7 +44,10 @@ export const decodeObjcCategories = (input: { ({ name }) => name === "__objc_catlist", )) { if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C category list"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Objective-C category list", + ); for (let index = 0; index < section.size / 8; index++) { if (!input.admit()) break; examined++; diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 047642bbd9..5d32122cee 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -4,6 +4,7 @@ import { type ObjcSwiftMetadata, } from "../domain/native/objcSwiftMetadata.js"; import type { Segment, Section } from "./AppleMachoSelection.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; import { boundClassName, decodeObjcCategories } from "./AppleObjcCategories.js"; import { readObjcPropertiesOf } from "./AppleObjcProperties.js"; import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; @@ -250,7 +251,10 @@ export const decodeObjcDispatchFacets = (input: { ({ name }) => name === "__objc_protolist", )) { if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C protocol list"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Objective-C protocol list", + ); for (let index = 0; index < section.size / 8; index++) { if (budget.isFull()) { budget.markTruncated(); @@ -406,7 +410,10 @@ export const decodeObjcDispatchFacets = (input: { ({ name }) => name === "__objc_classlist", )) { if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C class-list size"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Objective-C class-list size", + ); for (let index = 0; index < section.size / 8; index++) { if (budget.isFull()) { budget.markTruncated(); diff --git a/src/native/AppleSwiftDispatchFacets.ts b/src/native/AppleSwiftDispatchFacets.ts index 8d223a5b97..c014d49ba1 100644 --- a/src/native/AppleSwiftDispatchFacets.ts +++ b/src/native/AppleSwiftDispatchFacets.ts @@ -5,6 +5,7 @@ import { import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { PointerResolution } from "./AppleMachoFixups.js"; import type { Segment, Section } from "./AppleMachoSelection.js"; +import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; import { issue, type FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js"; import { decodeSwiftConformanceTypeReference, @@ -325,7 +326,10 @@ export const decodeSwiftDispatchFacets = (input: { ({ name }) => name === "__swift5_proto", )) { if (section.size % 4 !== 0) - throw new RangeError("Misaligned Swift conformance section"); + throw new BinaryFormatFailure( + "malformed", + "Misaligned Swift conformance section", + ); for (let index = 0; index < section.size / 4; index++) { if (!budget.admit()) break; swiftExamined++; diff --git a/tests/boundary/cli/nativeDispatchFormatAdmission.test.ts b/tests/boundary/cli/nativeDispatchFormatAdmission.test.ts new file mode 100644 index 0000000000..11130bf0ed --- /dev/null +++ b/tests/boundary/cli/nativeDispatchFormatAdmission.test.ts @@ -0,0 +1,65 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +describe.skipIf(process.platform !== "darwin")( + "native dispatch format admission", + () => { + cliTest( + "accepts a walkable 4097-command table and classifies malformed section sizes", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-dispatch-format-"); + const commands = Buffer.alloc(32 + 4097 * 16); + commands.writeUInt32LE(0xfeedfacf, 0); + commands.writeUInt32LE(0x0100000c, 4); + commands.writeUInt32LE(2, 12); + commands.writeUInt32LE(4097, 16); + commands.writeUInt32LE(4097 * 16, 20); + for (let index = 0; index < 4097; index++) { + commands.writeUInt32LE(0x2a, 32 + index * 16); + commands.writeUInt32LE(16, 36 + index * 16); + } + const path = join(root, "commands.macho"); + await writeFile(path, commands); + const valid = await cli.run({ + arguments: ["inspect-native-dispatch-metadata", path, "--json"], + }); + expect(valid.exitCode, valid.stdout).toBe(0); + expect(valid.json).toMatchObject({ + normalized_result: { result: { objc_classes: [] } }, + }); + + const malformed = Buffer.alloc(4096); + malformed.writeUInt32LE(0xfeedfacf, 0); + malformed.writeUInt32LE(0x0100000c, 4); + malformed.writeUInt32LE(2, 12); + malformed.writeUInt32LE(1, 16); + malformed.writeUInt32LE(152, 20); + malformed.writeUInt32LE(0x19, 32); + malformed.writeUInt32LE(152, 36); + malformed.write("__DATA", 40); + malformed.writeBigUInt64LE(0x100000000n, 56); + malformed.writeBigUInt64LE(4096n, 64); + malformed.writeBigUInt64LE(4096n, 80); + malformed.writeUInt32LE(1, 96); + malformed.write("__objc_classlist", 104); + malformed.write("__DATA", 120); + malformed.writeBigUInt64LE(0x100000200n, 136); + malformed.writeBigUInt64LE(12n, 144); + malformed.writeUInt32LE(512, 152); + await writeFile(path, malformed); + const refused = await cli.run({ + arguments: ["inspect-native-dispatch-metadata", path, "--json"], + }); + expect(refused.exitCode, refused.stdout).toBe(1); + expect(refused.json).toMatchObject({ + code: "unsupported_target", + details: { reason: "Misaligned Objective-C class-list size" }, + }); + }, + ); + }, +);