From 676959ecb55b606a567af3d2eb3d3edc96f0aa2f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:22:31 +0800 Subject: [PATCH] refactor(native): split Apple dispatch metadata decode facets (#855) Extract Mach-O/FAT selection, Objective-C class/method/ivar decode, and Swift conformance/witness facets so AppleDispatchMetadata stays a thin orchestrator without changing decode semantics. Co-authored-by: Cursor Agent --- src/native/AppleDispatchMetadata.ts | 623 +++---------------------- src/native/AppleMachoSelection.ts | 172 +++++++ src/native/AppleObjcDispatchFacets.ts | 292 ++++++++++++ src/native/AppleSwiftDispatchFacets.ts | 221 +++++++++ 4 files changed, 745 insertions(+), 563 deletions(-) create mode 100644 src/native/AppleMachoSelection.ts create mode 100644 src/native/AppleObjcDispatchFacets.ts create mode 100644 src/native/AppleSwiftDispatchFacets.ts diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index c485bfc43..edd3c6d75 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -9,22 +9,14 @@ import { import type { BinaryTarget } from "../domain/binaryTarget.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; +import { parseMachoLayout } from "./AppleMachoSelection.js"; +import { decodeObjcDispatchFacets } from "./AppleObjcDispatchFacets.js"; +import { + createSwiftRelativeReader, + decodeSwiftDispatchFacets, + type DispatchRecordBudget, +} from "./AppleSwiftDispatchFacets.js"; import { decodeSwiftClassVtables } from "./AppleSwiftVtables.js"; -import { createObjcProtocolReader } from "./AppleObjcProtocols.js"; - -interface Segment { - address: bigint; - size: bigint; - offset: number; - executable: boolean; -} -interface Section { - name: string; - address: bigint; - size: number; -} -const hex = (value: bigint) => `0x${value.toString(16)}`; -const decoded = { status: "decoded" as const, reason: null }; /** Decode validated little-endian 64-bit Apple Objective-C metadata without loading target code. */ export const decodeAppleDispatchMetadata = ( @@ -33,79 +25,12 @@ export const decodeAppleDispatchMetadata = ( provenance: { path: string; sha256: string }, architecture = "arm64", ): ObjcSwiftMetadata => { - if (bytes.length < 4) throw new RangeError("Truncated Mach-O header"); - const { slice, sliceEnd } = selectMachoSlice(bytes, architecture); - if (slice === 0 && sliceEnd === bytes.length && bytes.length < 32) - throw new RangeError("Truncated Mach-O header"); - if (slice + 32 > sliceEnd || bytes.readUInt32LE(slice) !== 0xfeedfacf) - throw new TypeError( - "Only little-endian 64-bit Mach-O metadata is supported", - ); - const commands = bytes.readUInt32LE(slice + 16); - const commandEnd = slice + 32 + bytes.readUInt32LE(slice + 20); - if (commands > 4096 || commandEnd > sliceEnd) - throw new RangeError("Malformed Mach-O command bounds"); - const segments: Segment[] = []; - const sections: Section[] = []; - let cursor = slice + 32; - for (let index = 0; index < commands; index++) { - if (cursor + 8 > commandEnd) throw new RangeError("Truncated load command"); - const kind = bytes.readUInt32LE(cursor), - size = bytes.readUInt32LE(cursor + 4); - if (size < 8 || cursor + size > commandEnd) - throw new RangeError("Invalid load command size"); - if (kind === 0x19) { - if (size < 72) throw new RangeError("Truncated segment command"); - const address = bytes.readBigUInt64LE(cursor + 24), - fileSize = bytes.readBigUInt64LE(cursor + 48), - fileOffset = bytes.readBigUInt64LE(cursor + 40); - if (fileOffset + fileSize > BigInt(sliceEnd - slice)) - throw new RangeError("Segment file range exceeds target bytes"); - segments.push({ - address, - size: fileSize, - offset: slice + Number(fileOffset), - executable: (bytes.readUInt32LE(cursor + 60) & 4) !== 0, - }); - const count = bytes.readUInt32LE(cursor + 64); - if (72 + count * 80 > size) - throw new RangeError("Truncated section table"); - for (let section = 0; section < count; section++) { - const position = cursor + 72 + section * 80; - const sectionSize = bytes.readBigUInt64LE(position + 40); - if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER)) - throw new RangeError("Section size exceeds exact numeric range"); - sections.push({ - name: bytes - .subarray(position, position + 16) - .toString("ascii") - .replace(/\0.*$/u, ""), - address: bytes.readBigUInt64LE(position + 32), - size: Number(sectionSize), - }); - } - } - cursor += size; - } - const offset = (address: bigint, size = 1): number => { - const matches = segments.filter( - (segment) => - address >= segment.address && - address + BigInt(size) <= segment.address + segment.size, - ); - if (matches.length !== 1) - throw new RangeError( - `Unmapped or ambiguous metadata pointer ${hex(address)}`, - ); - const segment = matches[0]; - if (segment === undefined) throw new RangeError("Missing metadata segment"); - return segment.offset + Number(address - segment.address); - }; + const { segments, sections, offset } = parseMachoLayout(bytes, architecture); const u32 = (address: bigint) => bytes.readUInt32LE(offset(address, 4)); const pointer = (address: bigint) => bytes.readBigUInt64LE(offset(address, 8)); const location = (address: bigint) => ({ - address: hex(address), + address: `0x${address.toString(16)}`, file_offset: offset(address), }); const evidence = (address: bigint, description: string) => [ @@ -129,420 +54,52 @@ export const decodeAppleDispatchMetadata = ( ); }; const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); - const failures: string[] = []; - let records = 0, - examined = 0, - truncated = false; - const admit = () => { - if (records >= maxRecords) { - truncated = true; - return false; - } - records++; - return true; - }; - const methods = ( - list: bigint, - className: string, - methodType: "instance" | "class", - ) => { - if (list === 0n) return; - const flags = u32(list), - count = u32(list + 4n), - relative = (flags & 0x80000000) !== 0; - const stride = flags & 0xfffc; - if (stride !== (relative ? 12 : 24)) - throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`); - for (let index = 0; index < count; index++) { - if (!admit()) break; - const entry = list + 8n + BigInt(index * stride); - const rel = (field: bigint) => { - const displacement = bytes.readInt32LE(offset(field, 4)); - const target = field + BigInt(displacement); - let fileOffset: number | null = null; - try { - fileOffset = offset(target); - } catch (cause: unknown) { - // best-effort cleanup: serialized target remains unresolved. - void cause; - } - if (admit()) - result.relative_pointers.push({ - field: location(field), - target: { address: hex(target), file_offset: fileOffset }, - displacement, - indirectable: false, - indirect: false, - decode: - fileOffset === null - ? { status: "partial", reason: "relative_target_unmapped" } - : decoded, - evidence: evidence( - field, - "Signed-relative Objective-C method-list pointer", - ), - }); - return target; - }; - let selectorAddress = relative ? rel(entry) : pointer(entry); - if (relative && (flags & 0x40000000) === 0) - selectorAddress = pointer(selectorAddress); - const implementation = relative ? rel(entry + 8n) : pointer(entry + 16n); - let implementationLocation: { - address: string; - file_offset: number; - } | null = null; - try { - if ( - !segments.some( - (segment) => - segment.executable && - implementation >= segment.address && - implementation < segment.address + segment.size, - ) - ) - throw new RangeError( - "Method implementation is not in an executable segment", - ); - implementationLocation = location(implementation); - } catch (cause: unknown) { - // best-effort cleanup: external/chained pointers remain unresolved. - void cause; - } - result.objc_dispatch_implementations.push({ - class_name: className, - selector: string(selectorAddress), - method_type: methodType, - implementation_address: implementationLocation?.address ?? null, - location: location(entry), - decode: - implementationLocation === null - ? { - status: "partial", - reason: "implementation_pointer_unmapped_or_encoded", - } - : decoded, - evidence: evidence( - entry, - relative - ? "Encoded relative Objective-C method entry" - : "Encoded absolute Objective-C method entry", - ), - }); - } - }; - const protocolReader = createObjcProtocolReader({ - readers: { - u32, - pointer, - string, - location, - evidence, - admit, - i32: (address) => bytes.readInt32LE(offset(address, 4)), + let records = 0; + const budget: DispatchRecordBudget = { + truncated: false, + isFull: () => records >= maxRecords, + markTruncated: () => { + budget.truncated = true; }, + admit: () => { + if (records >= maxRecords) { + budget.truncated = true; + return false; + } + records++; + return true; + }, + }; + const readers = { + u32, + i32: (address: bigint) => bytes.readInt32LE(offset(address, 4)), + pointer, + string, + location, + evidence, + offset, + }; + const { failures, examined } = decodeObjcDispatchFacets({ + bytes, + sections, + segments, + readers, + budget, result, - failures, }); - for (const section of sections.filter( - ({ name }) => name === "__objc_protolist", - )) { - if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C protocol list"); - for (let index = 0; index < section.size / 8; index++) { - if (records >= maxRecords) { - truncated = true; - break; - } - protocolReader.record(pointer(section.address + BigInt(index * 8))); - } - } - const visited = new Set(); - const readClass = (address: bigint, meta = false) => { - if (address === 0n || visited.has(hex(address)) || !admit()) return; - visited.add(hex(address)); - const ro = pointer(address + 32n) & ~7n; - const name = string(pointer(ro + 24n)); - const superclass = pointer(address + 8n); - const root = (u32(ro) & 2) !== 0; - let superclassName: string | null = null; - if (superclass !== 0n) { - try { - superclassName = string( - pointer((pointer(superclass + 32n) & ~7n) + 24n), - ); - } catch (cause: unknown) { - // Superclass resolution failure is recorded; the cause adds no identity. - void cause; - failures.push( - `Superclass pointer ${hex(superclass)} of ${name} requires unsupported binding/fixup resolution`, - ); - } - } - if (superclass === 0n && !root) - failures.push( - `Superclass of ${name} requires external binding resolution`, - ); - const ivarList = pointer(ro + 48n); - let ivarCount = 0; - if (ivarList !== 0n && !meta) { - const stride = u32(ivarList), - count = u32(ivarList + 4n); - if (stride !== 32) - throw new TypeError("Unsupported Objective-C ivar entry size"); - ivarCount = count; - for (let index = 0; index < count; index++) { - if (!admit()) break; - const entry = ivarList + 8n + BigInt(index * stride); - const encodedOffset = pointer(entry); - let value: number | null = null; - try { - value = u32(encodedOffset); - } catch (cause: unknown) { - // Unresolved offsets are recorded; the cause adds no identity. - void cause; - failures.push(`Unresolved ivar offset pointer ${hex(encodedOffset)}`); - } - result.objc_ivars.push({ - class_name: name, - name: string(pointer(entry + 8n)), - type_encoding: string(pointer(entry + 16n)), - offset: value, - size: u32(entry + 28n), - alignment: u32(entry + 24n) < 31 ? 2 ** u32(entry + 24n) : null, - location: location(entry), - decode: - value === null - ? { status: "partial", reason: "ivar_offset_pointer_unresolved" } - : decoded, - evidence: evidence( - entry, - "Objective-C ivar entry and encoded offset storage", - ), - }); - } - } - methods(pointer(ro + 32n), name, meta ? "class" : "instance"); - result.objc_classes.push({ - name, - super_class: superclassName, - is_meta_class: meta, - is_root_class: root, - methods: result.objc_dispatch_implementations - .filter( - (item) => - item.class_name === name && - item.method_type === (meta ? "class" : "instance"), - ) - .map((item) => ({ - selector: item.selector, - method_type: item.method_type, - address: - item.implementation_address !== null && - BigInt(item.implementation_address) <= - BigInt(Number.MAX_SAFE_INTEGER) - ? Number(BigInt(item.implementation_address)) - : null, - is_required: false, - is_optional: false, - })), - properties: [], - protocols: protocolReader.list(pointer(ro + 40n)), - ivar_count: ivarCount, - instance_size: u32(ro + 8n), - location: location(address), - superclass_address: superclass === 0n ? null : hex(superclass), - metaclass_address: meta ? null : hex(pointer(address)), - decode: - superclassName === null && !root - ? { status: "partial", reason: "superclass_binding_unresolved" } - : decoded, - evidence: evidence(address, "Objective-C class and class_ro_t records"), - }); - if (!meta) { - try { - readClass(pointer(address), true); - } catch (cause) { - failures.push( - `Metaclass of ${name}: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - } - }; - for (const section of sections.filter( - ({ name }) => name === "__objc_classlist", - )) { - if (section.size % 8 !== 0) - throw new RangeError("Misaligned Objective-C class-list size"); - for (let index = 0; index < section.size / 8; index++) { - if (records >= maxRecords) { - truncated = true; - break; - } - examined++; - const field = section.address + BigInt(index * 8); - try { - readClass(pointer(field)); - } catch (cause) { - failures.push( - `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - } - } - const swiftRelative = (field: bigint, indirectable = false): bigint => { - const displacement = bytes.readInt32LE(offset(field, 4)); - const indirect = indirectable && (displacement & 1) !== 0; - let target = - displacement === 0 - ? 0n - : field + BigInt(indirectable ? displacement & ~1 : displacement); - let fileOffset: number | null = null; - let reason: string | null = null; - try { - if (indirect && target !== 0n) target = pointer(target); - if (target !== 0n) fileOffset = offset(target); - } catch (cause) { - reason = cause instanceof Error ? cause.message : String(cause); - } - if (admit()) - result.relative_pointers.push({ - field: location(field), - target: { - address: target === 0n ? null : hex(target), - file_offset: fileOffset, - }, - displacement, - indirectable, - indirect, - decode: reason === null ? decoded : { status: "partial", reason }, - evidence: evidence(field, "Swift ABI signed-relative metadata pointer"), - }); - if (reason !== null) throw new RangeError(reason); - return target; - }; - const swiftFailures: string[] = []; - let swiftExamined = 0; - for (const section of sections.filter( - ({ name }) => name === "__swift5_proto", - )) { - if (section.size % 4 !== 0) - throw new RangeError("Misaligned Swift conformance section"); - for (let index = 0; index < section.size / 4; index++) { - if (!admit()) break; - swiftExamined++; - const field = section.address + BigInt(index * 4); - try { - const descriptor = swiftRelative(field); - const flags = u32(descriptor + 12n); - const protocol = swiftRelative(descriptor, true); - if ((u32(protocol) & 31) !== 3) - throw new TypeError("Unsupported Swift protocol descriptor kind"); - const protocolName = string(swiftRelative(protocol + 8n)); - const typeKind = (flags >>> 3) & 7; - if (typeKind !== 0 && typeKind !== 1) - throw new TypeError( - "Unsupported Swift conformance type-reference kind", - ); - let type = swiftRelative(descriptor + 4n); - if (typeKind === 1) type = pointer(type); - if (![16, 17, 18].includes(u32(type) & 31)) - throw new TypeError("Unsupported Swift type descriptor kind"); - const typeName = string(swiftRelative(type + 8n)); - const witness = swiftRelative(descriptor + 8n); - const staticWitness = - witness !== 0n && (flags & ~0x38) === 0 && (u32(type) & 0x80) === 0; - let witnessReason: string | null = staticWitness - ? null - : "Conditional, generic, resilient or missing static witness table is not decoded"; - if (staticWitness && pointer(witness) !== descriptor) - witnessReason = - "Witness-table conformance header is unresolved or encoded"; - result.swift_conformances.push({ - type_name: typeName, - protocol_name: protocolName, - module: null, - witness_table: - witness === 0n - ? { address: null, file_offset: null } - : location(witness), - location: location(descriptor), - decode: - witnessReason === null - ? decoded - : { - status: "partial", - reason: witnessReason, - }, - evidence: evidence( - descriptor, - "Swift protocol conformance descriptor and directly encoded type/protocol names", - ), - }); - if (witnessReason !== null) { - swiftFailures.push(`${typeName}: ${witnessReason}`); - continue; - } - const count = u32(protocol + 16n); - const signatureCount = u32(protocol + 12n); - if (count > 20_000 || signatureCount > 20_000) - throw new RangeError("Swift witness requirement count exceeds 20000"); - for (let slot = 0; slot < count; slot++) { - if (!admit()) break; - const entry = witness + BigInt((slot + 1) * 8); - const implementation = pointer(entry); - const requirementFlags = u32( - protocol + 24n + BigInt(signatureCount * 12 + slot * 8), - ); - const kind = requirementFlags & 15; - const synchronousFunction = - kind >= 1 && kind <= 4 && (requirementFlags & 0x20) === 0; - const executable = - synchronousFunction && - segments.some( - (segment) => - segment.executable && - implementation >= segment.address && - implementation < segment.address + segment.size, - ); - result.swift_dispatch_slots.push({ - owner: `${typeName}: ${protocolName}`, - table_kind: "witness_table", - slot_index: slot + 1, - requirement: null, - implementation: null, - implementation_address: executable ? hex(implementation) : null, - thunk_address: null, - location: location(entry), - decode: executable - ? decoded - : { - status: "partial", - reason: - "Witness slot is non-function, async, nil, external, authenticated or encoded; implementation target is unresolved", - }, - evidence: evidence( - entry, - "Static Swift witness slot, indexed after the conformance-descriptor header", - ), - }); - } - } catch (cause) { - swiftFailures.push( - `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - } - } - result.coverage.push({ - facet: "swift_conformances_static_witness_slots", - status: swiftFailures.length > 0 || truncated ? "partial" : "complete", - reason: - [...swiftFailures, ...(truncated ? ["max_records_reached"] : [])].join( - "; ", - ) || null, - examined: swiftExamined, - decoded: result.swift_conformances.length, + const swiftRelative = createSwiftRelativeReader({ + bytes, + readers, + budget, + result, + }); + decodeSwiftDispatchFacets({ + sections, + segments, + readers, + relative: swiftRelative, + budget, + result, }); const typeEntries: bigint[] = []; for (const section of sections.filter( @@ -552,7 +109,7 @@ export const decodeAppleDispatchMetadata = ( throw new RangeError("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) truncated = true; + if (section.size / 4 > maxRecords) budget.truncated = true; } decodeSwiftClassVtables({ entries: typeEntries, @@ -563,7 +120,7 @@ export const decodeAppleDispatchMetadata = ( string, location, evidence, - admit, + admit: () => budget.admit(), executable: (address) => segments.some( (segment) => @@ -575,21 +132,22 @@ export const decodeAppleDispatchMetadata = ( }); result.coverage.push({ facet: "objc_class_method_ivar_metadata", - status: failures.length > 0 || truncated ? "partial" : "complete", + status: failures.length > 0 || budget.truncated ? "partial" : "complete", reason: - [...failures, ...(truncated ? ["max_records_reached"] : [])].join("; ") || - null, + [...failures, ...(budget.truncated ? ["max_records_reached"] : [])].join( + "; ", + ) || null, examined, decoded: result.objc_classes.length, }); result.coverage.push({ facet: "binary_relative_pointers", status: - truncated || + budget.truncated || result.relative_pointers.some((item) => item.decode.status !== "decoded") ? "partial" : "complete", - reason: truncated + reason: budget.truncated ? "max_records_reached" : result.relative_pointers.some( (item) => item.decode.status !== "decoded", @@ -661,64 +219,3 @@ export const inspectAppleDispatchMetadata = async ( await handle.close(); } }; - -const selectMachoSlice = (bytes: Buffer, architecture: string) => { - const magic = bytes.readUInt32BE(0); - const header = fatHeaderFormat(magic); - if (header === null) return { slice: 0, sliceEnd: bytes.length }; - const { littleEndian, fat64 } = header; - const stride = fat64 ? 32 : 20; - const readUInt32 = littleEndian - ? (offset: number) => bytes.readUInt32LE(offset) - : (offset: number) => bytes.readUInt32BE(offset); - const readUInt64 = littleEndian - ? (offset: number) => bytes.readBigUInt64LE(offset) - : (offset: number) => bytes.readBigUInt64BE(offset); - const byteOrder = littleEndian ? "little-endian" : "big-endian"; - const malformed = (reason: string) => - new RangeError(`${reason} (FAT header byte order: ${byteOrder})`); - const invalid = (reason: string) => - new TypeError(`${reason} (FAT header byte order: ${byteOrder})`); - if (bytes.length < 8) throw malformed("Malformed FAT architecture table"); - const count = readUInt32(4); - const headerEnd = 8 + count * stride; - if (count > 128 || headerEnd > bytes.length) - throw malformed("Malformed FAT architecture table"); - const cpu = architecture === "arm64" ? 0x0100000c : 0x01000007; - let selected: { slice: number; sliceEnd: number } | undefined; - for (let index = 0; index < count; index++) { - const offset = 8 + index * stride; - if (readUInt32(offset) !== cpu) continue; - if (selected !== undefined) - throw invalid("Ambiguous FAT architecture slice"); - const start = fat64 - ? readUInt64(offset + 8) - : BigInt(readUInt32(offset + 8)); - const size = fat64 - ? readUInt64(offset + 16) - : BigInt(readUInt32(offset + 12)); - if (start < BigInt(headerEnd) || start + size > BigInt(bytes.length)) - throw malformed("FAT slice exceeds file"); - selected = { slice: Number(start), sliceEnd: Number(start + size) }; - } - if (selected === undefined) - throw invalid("Requested FAT architecture is absent"); - return selected; -}; - -const fatHeaderFormat = ( - magic: number, -): { readonly littleEndian: boolean; readonly fat64: boolean } | null => { - switch (magic) { - case 0xcafebabe: - return { littleEndian: false, fat64: false }; - case 0xcafebabf: - return { littleEndian: false, fat64: true }; - case 0xbebafeca: - return { littleEndian: true, fat64: false }; - case 0xbfbafeca: - return { littleEndian: true, fat64: true }; - default: - return null; - } -}; diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts new file mode 100644 index 000000000..b6de0fe34 --- /dev/null +++ b/src/native/AppleMachoSelection.ts @@ -0,0 +1,172 @@ +/** One mapped Mach-O segment with file-backed VA range. */ +export interface Segment { + address: bigint; + size: bigint; + offset: number; + executable: boolean; +} + +/** Named Mach-O section within a loaded segment. */ +export interface Section { + name: string; + address: bigint; + size: number; +} + +/** Selected thin slice bounds inside a Mach-O or FAT container. */ +export interface MachoSlice { + slice: number; + sliceEnd: number; +} + +/** Parsed little-endian 64-bit Mach-O layout for the selected architecture slice. */ +export interface MachoLayout { + segments: Segment[]; + sections: Section[]; + /** Map a virtual address range onto a file offset within the selected slice. */ + offset(address: bigint, size?: number): number; +} + +const hex = (value: bigint) => `0x${value.toString(16)}`; + +/** Classify a FAT magic word; thin Mach-O returns null. */ +export const fatHeaderFormat = ( + magic: number, +): { readonly littleEndian: boolean; readonly fat64: boolean } | null => { + switch (magic) { + case 0xcafebabe: + return { littleEndian: false, fat64: false }; + case 0xcafebabf: + return { littleEndian: false, fat64: true }; + case 0xbebafeca: + return { littleEndian: true, fat64: false }; + case 0xbfbafeca: + return { littleEndian: true, fat64: true }; + default: + return null; + } +}; + +/** Select the thin Mach-O slice for `architecture` inside a FAT or thin container. */ +export const selectMachoSlice = ( + bytes: Buffer, + architecture: string, +): MachoSlice => { + const magic = bytes.readUInt32BE(0); + const header = fatHeaderFormat(magic); + if (header === null) return { slice: 0, sliceEnd: bytes.length }; + const { littleEndian, fat64 } = header; + const stride = fat64 ? 32 : 20; + const readUInt32 = littleEndian + ? (offset: number) => bytes.readUInt32LE(offset) + : (offset: number) => bytes.readUInt32BE(offset); + const readUInt64 = littleEndian + ? (offset: number) => bytes.readBigUInt64LE(offset) + : (offset: number) => bytes.readBigUInt64BE(offset); + const byteOrder = littleEndian ? "little-endian" : "big-endian"; + const malformed = (reason: string) => + new RangeError(`${reason} (FAT header byte order: ${byteOrder})`); + const invalid = (reason: string) => + new TypeError(`${reason} (FAT header byte order: ${byteOrder})`); + if (bytes.length < 8) throw malformed("Malformed FAT architecture table"); + const count = readUInt32(4); + const headerEnd = 8 + count * stride; + if (count > 128 || headerEnd > bytes.length) + throw malformed("Malformed FAT architecture table"); + const cpu = architecture === "arm64" ? 0x0100000c : 0x01000007; + let selected: MachoSlice | undefined; + for (let index = 0; index < count; index++) { + const offset = 8 + index * stride; + if (readUInt32(offset) !== cpu) continue; + if (selected !== undefined) + throw invalid("Ambiguous FAT architecture slice"); + const start = fat64 + ? readUInt64(offset + 8) + : BigInt(readUInt32(offset + 8)); + const size = fat64 + ? readUInt64(offset + 16) + : BigInt(readUInt32(offset + 12)); + if (start < BigInt(headerEnd) || start + size > BigInt(bytes.length)) + throw malformed("FAT slice exceeds file"); + selected = { slice: Number(start), sliceEnd: Number(start + size) }; + } + if (selected === undefined) + throw invalid("Requested FAT architecture is absent"); + return selected; +}; + +/** Parse segments/sections for a little-endian 64-bit Mach-O architecture slice. */ +export const parseMachoLayout = ( + bytes: Buffer, + architecture: string, +): MachoLayout => { + if (bytes.length < 4) throw new RangeError("Truncated Mach-O header"); + const { slice, sliceEnd } = selectMachoSlice(bytes, architecture); + if (slice === 0 && sliceEnd === bytes.length && bytes.length < 32) + throw new RangeError("Truncated Mach-O header"); + if (slice + 32 > sliceEnd || bytes.readUInt32LE(slice) !== 0xfeedfacf) + throw new TypeError( + "Only little-endian 64-bit Mach-O metadata is supported", + ); + const commands = bytes.readUInt32LE(slice + 16); + const commandEnd = slice + 32 + bytes.readUInt32LE(slice + 20); + if (commands > 4096 || commandEnd > sliceEnd) + throw new RangeError("Malformed Mach-O command bounds"); + const segments: Segment[] = []; + const sections: Section[] = []; + let cursor = slice + 32; + for (let index = 0; index < commands; index++) { + if (cursor + 8 > commandEnd) throw new RangeError("Truncated load command"); + const kind = bytes.readUInt32LE(cursor), + size = bytes.readUInt32LE(cursor + 4); + if (size < 8 || cursor + size > commandEnd) + throw new RangeError("Invalid load command size"); + if (kind === 0x19) { + if (size < 72) throw new RangeError("Truncated segment command"); + const address = bytes.readBigUInt64LE(cursor + 24), + fileSize = bytes.readBigUInt64LE(cursor + 48), + fileOffset = bytes.readBigUInt64LE(cursor + 40); + if (fileOffset + fileSize > BigInt(sliceEnd - slice)) + throw new RangeError("Segment file range exceeds target bytes"); + segments.push({ + address, + size: fileSize, + offset: slice + Number(fileOffset), + executable: (bytes.readUInt32LE(cursor + 60) & 4) !== 0, + }); + const count = bytes.readUInt32LE(cursor + 64); + if (72 + count * 80 > size) + throw new RangeError("Truncated section table"); + for (let section = 0; section < count; section++) { + const position = cursor + 72 + section * 80; + const sectionSize = bytes.readBigUInt64LE(position + 40); + if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER)) + throw new RangeError("Section size exceeds exact numeric range"); + sections.push({ + name: bytes + .subarray(position, position + 16) + .toString("ascii") + .replace(/\0.*$/u, ""), + address: bytes.readBigUInt64LE(position + 32), + size: Number(sectionSize), + }); + } + } + cursor += size; + } + const offset = (address: bigint, size = 1): number => { + const matches = segments.filter( + (segment) => + address >= segment.address && + address + BigInt(size) <= segment.address + segment.size, + ); + if (matches.length !== 1) + throw new RangeError( + `Unmapped or ambiguous metadata pointer ${hex(address)}`, + ); + const segment = matches[0]; + if (segment === undefined) throw new RangeError("Missing metadata segment"); + return segment.offset + Number(address - segment.address); + }; + return { segments, sections, offset }; +}; diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts new file mode 100644 index 000000000..a20123793 --- /dev/null +++ b/src/native/AppleObjcDispatchFacets.ts @@ -0,0 +1,292 @@ +import type { ObjcSwiftMetadata } from "../domain/objcSwiftMetadata.js"; +import type { Segment, Section } from "./AppleMachoSelection.js"; +import { createObjcProtocolReader } from "./AppleObjcProtocols.js"; +import type { + BinaryMetadataEvidence, + DispatchRecordBudget, + ResolvedMetadataLocation, +} from "./AppleSwiftDispatchFacets.js"; + +/** Byte readers shared by Objective-C class/method/ivar decode facets. */ +export interface ObjcDispatchReaders { + u32(address: bigint): number; + i32(address: bigint): number; + pointer(address: bigint): bigint; + string(address: bigint): string; + location(address: bigint): ResolvedMetadataLocation; + evidence(address: bigint, description: string): BinaryMetadataEvidence; + offset(address: bigint, size?: number): number; +} + +const hex = (value: bigint) => `0x${value.toString(16)}`; +const decoded = { status: "decoded" as const, reason: null }; + +/** Decode Objective-C protocol lists, classlists, methods, and ivars into `result`. */ +export const decodeObjcDispatchFacets = (input: { + bytes: Buffer; + sections: readonly Section[]; + segments: readonly Segment[]; + readers: ObjcDispatchReaders; + budget: DispatchRecordBudget; + result: ObjcSwiftMetadata; +}): { failures: string[]; examined: number } => { + const { bytes, sections, segments, readers: read, budget, result } = input; + const failures: string[] = []; + let examined = 0; + const methods = ( + list: bigint, + className: string, + methodType: "instance" | "class", + ) => { + if (list === 0n) return; + const flags = read.u32(list), + count = read.u32(list + 4n), + relative = (flags & 0x80000000) !== 0; + const stride = flags & 0xfffc; + if (stride !== (relative ? 12 : 24)) + throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`); + for (let index = 0; index < count; index++) { + if (!budget.admit()) break; + const entry = list + 8n + BigInt(index * stride); + const rel = (field: bigint) => { + const displacement = bytes.readInt32LE(read.offset(field, 4)); + const target = field + BigInt(displacement); + let fileOffset: number | null = null; + try { + fileOffset = read.offset(target); + } catch (cause: unknown) { + // best-effort cleanup: serialized target remains unresolved. + void cause; + } + if (budget.admit()) + result.relative_pointers.push({ + field: read.location(field), + target: { address: hex(target), file_offset: fileOffset }, + displacement, + indirectable: false, + indirect: false, + decode: + fileOffset === null + ? { status: "partial", reason: "relative_target_unmapped" } + : decoded, + evidence: read.evidence( + field, + "Signed-relative Objective-C method-list pointer", + ), + }); + return target; + }; + let selectorAddress = relative ? rel(entry) : read.pointer(entry); + if (relative && (flags & 0x40000000) === 0) + selectorAddress = read.pointer(selectorAddress); + const implementation = relative + ? rel(entry + 8n) + : read.pointer(entry + 16n); + let implementationLocation: { + address: string; + file_offset: number; + } | null = null; + try { + if ( + !segments.some( + (segment) => + segment.executable && + implementation >= segment.address && + implementation < segment.address + segment.size, + ) + ) + throw new RangeError( + "Method implementation is not in an executable segment", + ); + implementationLocation = read.location(implementation); + } catch (cause: unknown) { + // best-effort cleanup: external/chained pointers remain unresolved. + void cause; + } + result.objc_dispatch_implementations.push({ + class_name: className, + selector: read.string(selectorAddress), + method_type: methodType, + implementation_address: implementationLocation?.address ?? null, + location: read.location(entry), + decode: + implementationLocation === null + ? { + status: "partial", + reason: "implementation_pointer_unmapped_or_encoded", + } + : decoded, + evidence: read.evidence( + entry, + relative + ? "Encoded relative Objective-C method entry" + : "Encoded absolute Objective-C method entry", + ), + }); + } + }; + const protocolReader = createObjcProtocolReader({ + readers: { + u32: read.u32, + pointer: read.pointer, + string: read.string, + location: read.location, + evidence: read.evidence, + admit: () => budget.admit(), + i32: read.i32, + }, + result, + failures, + }); + for (const section of sections.filter( + ({ name }) => name === "__objc_protolist", + )) { + if (section.size % 8 !== 0) + throw new RangeError("Misaligned Objective-C protocol list"); + for (let index = 0; index < section.size / 8; index++) { + if (budget.isFull()) { + budget.markTruncated(); + break; + } + protocolReader.record(read.pointer(section.address + BigInt(index * 8))); + } + } + const visited = new Set(); + const readClass = (address: bigint, meta = false) => { + if (address === 0n || visited.has(hex(address)) || !budget.admit()) return; + visited.add(hex(address)); + const ro = read.pointer(address + 32n) & ~7n; + const name = read.string(read.pointer(ro + 24n)); + const superclass = read.pointer(address + 8n); + const root = (read.u32(ro) & 2) !== 0; + let superclassName: string | null = null; + if (superclass !== 0n) { + try { + superclassName = read.string( + read.pointer((read.pointer(superclass + 32n) & ~7n) + 24n), + ); + } catch (cause: unknown) { + // Superclass resolution failure is recorded; the cause adds no identity. + void cause; + failures.push( + `Superclass pointer ${hex(superclass)} of ${name} requires unsupported binding/fixup resolution`, + ); + } + } + if (superclass === 0n && !root) + failures.push( + `Superclass of ${name} requires external binding resolution`, + ); + const ivarList = read.pointer(ro + 48n); + let ivarCount = 0; + if (ivarList !== 0n && !meta) { + const stride = read.u32(ivarList), + count = read.u32(ivarList + 4n); + if (stride !== 32) + throw new TypeError("Unsupported Objective-C ivar entry size"); + ivarCount = count; + for (let index = 0; index < count; index++) { + if (!budget.admit()) break; + const entry = ivarList + 8n + BigInt(index * stride); + const encodedOffset = read.pointer(entry); + let value: number | null = null; + try { + value = read.u32(encodedOffset); + } catch (cause: unknown) { + // Unresolved offsets are recorded; the cause adds no identity. + void cause; + failures.push(`Unresolved ivar offset pointer ${hex(encodedOffset)}`); + } + result.objc_ivars.push({ + class_name: name, + name: read.string(read.pointer(entry + 8n)), + type_encoding: read.string(read.pointer(entry + 16n)), + offset: value, + size: read.u32(entry + 28n), + alignment: + read.u32(entry + 24n) < 31 ? 2 ** read.u32(entry + 24n) : null, + location: read.location(entry), + decode: + value === null + ? { status: "partial", reason: "ivar_offset_pointer_unresolved" } + : decoded, + evidence: read.evidence( + entry, + "Objective-C ivar entry and encoded offset storage", + ), + }); + } + } + methods(read.pointer(ro + 32n), name, meta ? "class" : "instance"); + result.objc_classes.push({ + name, + super_class: superclassName, + is_meta_class: meta, + is_root_class: root, + methods: result.objc_dispatch_implementations + .filter( + (item) => + item.class_name === name && + item.method_type === (meta ? "class" : "instance"), + ) + .map((item) => ({ + selector: item.selector, + method_type: item.method_type, + address: + item.implementation_address !== null && + BigInt(item.implementation_address) <= + BigInt(Number.MAX_SAFE_INTEGER) + ? Number(BigInt(item.implementation_address)) + : null, + is_required: false, + is_optional: false, + })), + properties: [], + protocols: protocolReader.list(read.pointer(ro + 40n)), + ivar_count: ivarCount, + instance_size: read.u32(ro + 8n), + location: read.location(address), + superclass_address: superclass === 0n ? null : hex(superclass), + metaclass_address: meta ? null : hex(read.pointer(address)), + decode: + superclassName === null && !root + ? { status: "partial", reason: "superclass_binding_unresolved" } + : decoded, + evidence: read.evidence( + address, + "Objective-C class and class_ro_t records", + ), + }); + if (!meta) { + try { + readClass(read.pointer(address), true); + } catch (cause) { + failures.push( + `Metaclass of ${name}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } + }; + for (const section of sections.filter( + ({ name }) => name === "__objc_classlist", + )) { + if (section.size % 8 !== 0) + throw new RangeError("Misaligned Objective-C class-list size"); + for (let index = 0; index < section.size / 8; index++) { + if (budget.isFull()) { + budget.markTruncated(); + break; + } + examined++; + const field = section.address + BigInt(index * 8); + try { + readClass(read.pointer(field)); + } catch (cause) { + failures.push( + `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } + } + return { failures, examined }; +}; diff --git a/src/native/AppleSwiftDispatchFacets.ts b/src/native/AppleSwiftDispatchFacets.ts new file mode 100644 index 000000000..95f8c81fa --- /dev/null +++ b/src/native/AppleSwiftDispatchFacets.ts @@ -0,0 +1,221 @@ +import type { ObjcSwiftMetadata } from "../domain/objcSwiftMetadata.js"; +import type { Segment, Section } from "./AppleMachoSelection.js"; + +/** Resolved file-backed metadata location from the Mach-O layout. */ +export interface ResolvedMetadataLocation { + address: string; + file_offset: number; +} + +/** Binary-metadata evidence produced by the Apple dispatch readers. */ +export type BinaryMetadataEvidence = { + kind: "binary_metadata"; + description: string; + location: ResolvedMetadataLocation; + artifact_path: string; + artifact_sha256: string; +}[]; + +/** Shared admission budget for max_records truncation across decode facets. */ +export interface DispatchRecordBudget { + truncated: boolean; + isFull(): boolean; + markTruncated(): void; + admit(): boolean; +} + +/** Byte readers for Swift relative-pointer and conformance decode facets. */ +export interface SwiftDispatchReaders { + u32(address: bigint): number; + pointer(address: bigint): bigint; + string(address: bigint): string; + location(address: bigint): ResolvedMetadataLocation; + evidence(address: bigint, description: string): BinaryMetadataEvidence; + offset(address: bigint, size?: number): number; +} + +const hex = (value: bigint) => `0x${value.toString(16)}`; +const decoded = { status: "decoded" as const, reason: null }; + +/** Create a Swift ABI signed-relative pointer decoder that records relative_pointers. */ +export const createSwiftRelativeReader = (input: { + bytes: Buffer; + readers: SwiftDispatchReaders; + budget: DispatchRecordBudget; + result: ObjcSwiftMetadata; +}) => { + const { bytes, readers: read, budget, result } = input; + return (field: bigint, indirectable = false): bigint => { + const displacement = bytes.readInt32LE(read.offset(field, 4)); + const indirect = indirectable && (displacement & 1) !== 0; + let target = + displacement === 0 + ? 0n + : field + BigInt(indirectable ? displacement & ~1 : displacement); + let fileOffset: number | null = null; + let reason: string | null = null; + try { + if (indirect && target !== 0n) target = read.pointer(target); + if (target !== 0n) fileOffset = read.offset(target); + } catch (cause) { + reason = cause instanceof Error ? cause.message : String(cause); + } + if (budget.admit()) + result.relative_pointers.push({ + field: read.location(field), + target: { + address: target === 0n ? null : hex(target), + file_offset: fileOffset, + }, + displacement, + indirectable, + indirect, + decode: reason === null ? decoded : { status: "partial", reason }, + evidence: read.evidence( + field, + "Swift ABI signed-relative metadata pointer", + ), + }); + if (reason !== null) throw new RangeError(reason); + return target; + }; +}; + +/** Decode `__swift5_proto` conformances and static witness slots into `result`. */ +export const decodeSwiftDispatchFacets = (input: { + sections: readonly Section[]; + segments: readonly Segment[]; + readers: SwiftDispatchReaders; + relative: (field: bigint, indirectable?: boolean) => bigint; + budget: DispatchRecordBudget; + result: ObjcSwiftMetadata; +}): void => { + const { sections, segments, readers: read, relative, budget, result } = input; + const swiftFailures: string[] = []; + let swiftExamined = 0; + for (const section of sections.filter( + ({ name }) => name === "__swift5_proto", + )) { + if (section.size % 4 !== 0) + throw new RangeError("Misaligned Swift conformance section"); + for (let index = 0; index < section.size / 4; index++) { + if (!budget.admit()) break; + swiftExamined++; + const field = section.address + BigInt(index * 4); + try { + const descriptor = relative(field); + const flags = read.u32(descriptor + 12n); + const protocol = relative(descriptor, true); + if ((read.u32(protocol) & 31) !== 3) + throw new TypeError("Unsupported Swift protocol descriptor kind"); + const protocolName = read.string(relative(protocol + 8n)); + const typeKind = (flags >>> 3) & 7; + if (typeKind !== 0 && typeKind !== 1) + throw new TypeError( + "Unsupported Swift conformance type-reference kind", + ); + let type = relative(descriptor + 4n); + if (typeKind === 1) type = read.pointer(type); + if (![16, 17, 18].includes(read.u32(type) & 31)) + throw new TypeError("Unsupported Swift type descriptor kind"); + const typeName = read.string(relative(type + 8n)); + const witness = relative(descriptor + 8n); + const staticWitness = + witness !== 0n && + (flags & ~0x38) === 0 && + (read.u32(type) & 0x80) === 0; + let witnessReason: string | null = staticWitness + ? null + : "Conditional, generic, resilient or missing static witness table is not decoded"; + if (staticWitness && read.pointer(witness) !== descriptor) + witnessReason = + "Witness-table conformance header is unresolved or encoded"; + result.swift_conformances.push({ + type_name: typeName, + protocol_name: protocolName, + module: null, + witness_table: + witness === 0n + ? { address: null, file_offset: null } + : read.location(witness), + location: read.location(descriptor), + decode: + witnessReason === null + ? decoded + : { + status: "partial", + reason: witnessReason, + }, + evidence: read.evidence( + descriptor, + "Swift protocol conformance descriptor and directly encoded type/protocol names", + ), + }); + if (witnessReason !== null) { + swiftFailures.push(`${typeName}: ${witnessReason}`); + continue; + } + 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"); + for (let slot = 0; slot < count; slot++) { + if (!budget.admit()) break; + const entry = witness + BigInt((slot + 1) * 8); + const implementation = read.pointer(entry); + const requirementFlags = read.u32( + protocol + 24n + BigInt(signatureCount * 12 + slot * 8), + ); + const kind = requirementFlags & 15; + const synchronousFunction = + kind >= 1 && kind <= 4 && (requirementFlags & 0x20) === 0; + const executable = + synchronousFunction && + segments.some( + (segment) => + segment.executable && + implementation >= segment.address && + implementation < segment.address + segment.size, + ); + result.swift_dispatch_slots.push({ + owner: `${typeName}: ${protocolName}`, + table_kind: "witness_table", + slot_index: slot + 1, + requirement: null, + implementation: null, + implementation_address: executable ? hex(implementation) : null, + thunk_address: null, + location: read.location(entry), + decode: executable + ? decoded + : { + status: "partial", + reason: + "Witness slot is non-function, async, nil, external, authenticated or encoded; implementation target is unresolved", + }, + evidence: read.evidence( + entry, + "Static Swift witness slot, indexed after the conformance-descriptor header", + ), + }); + } + } catch (cause) { + swiftFailures.push( + `${hex(field)}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } + } + result.coverage.push({ + facet: "swift_conformances_static_witness_slots", + status: + swiftFailures.length > 0 || budget.truncated ? "partial" : "complete", + reason: + [ + ...swiftFailures, + ...(budget.truncated ? ["max_records_reached"] : []), + ].join("; ") || null, + examined: swiftExamined, + decoded: result.swift_conformances.length, + }); +};