diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 027a448080..216c55fb0e 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -1121,6 +1121,21 @@ const stringsDimension = ( ).dimensions.find(({ dimension }) => dimension === "strings_names"); describe("function comparison Swift string literals", () => { + it("retains literal comparisons when legacy dossiers omit facet declarations", () => { + const legacy = (base: "0x1000" | "0x2000", value: string) => { + const result = withLiterals(base, [value]); + delete result.facet_availability; + return result; + }; + const left = legacy("0x1000", "SleepDisabled"); + expect( + stringsDimension("1", left, "2", legacy("0x2000", "SleepDisabled")), + ).toMatchObject({ status: "unchanged" }); + expect( + stringsDimension("1", left, "2", legacy("0x2000", "Holding awake")), + ).toMatchObject({ status: "changed" }); + }); + it("treats differing decoded literals as a strings and names change", () => { expect( stringsDimension( diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index e3b19d95a0..09ce541c95 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -14,6 +14,7 @@ import { normalizeCfg, referencedStringProjection, sorted, + swiftLiteralCoverage, swiftLiteralSitesConflict, } from "./functionComparisonNormalization.js"; import type { @@ -101,7 +102,9 @@ const facetLimitations = ( strings_names: [ [ "strings_names", - dossier.referenced_strings.length + dossier.referenced_names.length, + dossier.referenced_strings.length + + dossier.referenced_names.length + + (dossier.swift_string_literals?.length ?? 0), ], ], cfg: [["cfg", dossier.basic_blocks.length]], @@ -515,15 +518,6 @@ const groupStringsByOffset = ( return groups; }; -const swiftLiteralCoverage = ( - snapshot: FunctionSnapshot, -): "absent" | "complete" | "partial" => - (snapshot.dossier.swift_string_literal_omissions ?? []).length > 0 - ? "partial" - : snapshot.dossier.swift_string_literals === undefined - ? "absent" - : "complete"; - const compareReferences = ( compared: FunctionRelationshipComparison, context: ComparisonContext, diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts index 55480f2daf..d80b6895d9 100644 --- a/src/domain/functionComparisonNormalization.ts +++ b/src/domain/functionComparisonNormalization.ts @@ -191,9 +191,18 @@ export const swiftLiteralSitesConflict = ( return false; }; +/** Omitted literal inventory differs from an observed empty inventory. */ +export const swiftLiteralCoverage = ( + snapshot: FunctionSnapshot, +): "absent" | "complete" | "partial" => + (snapshot.dossier.swift_string_literal_omissions ?? []).length > 0 + ? "partial" + : snapshot.dossier.swift_string_literals === undefined + ? "absent" + : "complete"; + const swiftLiteralSites = (snapshot: FunctionSnapshot) => { const base = snapshot.dossier.procedure.address; - const omissions = snapshot.dossier.swift_string_literal_omissions ?? []; const site = (sourceAddress: string, registers: readonly string[]) => { const offset = relativeAddress(sourceAddress, base); return offset === null ? null : canonicalJson([offset, registers]); @@ -211,9 +220,7 @@ const swiftLiteralSites = (snapshot: FunctionSnapshot) => { ].sort(compareUnicodeCodePoints), ); } - const complete = - snapshot.dossier.swift_string_literals !== undefined && - omissions.length === 0; + const complete = swiftLiteralCoverage(snapshot) === "complete"; return { values, complete }; }; diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index fe7468142f..5400d886e6 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -133,6 +133,106 @@ it.each([ }, ); +/** File ranges remain inside the padded source while VM ranges may alias. */ +const mappingSegment = ( + address: bigint, + fileOffset: number, + fileSize: number, + memorySize = fileSize, +): Buffer => { + const command = Buffer.alloc(72); + command.writeUInt32LE(0x19); + command.writeUInt32LE(command.length, 4); + command.writeBigUInt64LE(address, 24); + command.writeBigUInt64LE(BigInt(memorySize), 32); + command.writeBigUInt64LE(BigInt(fileOffset), 40); + command.writeBigUInt64LE(BigInt(fileSize), 48); + command.writeUInt32LE(1, 60); + return command; +}; + +const mappingImage = (commands: readonly Buffer[]): Buffer => { + const bytes = Buffer.alloc(512); + Buffer.from(machoImage({ commands })).copy(bytes); + return bytes; +}; + +describe("Mach-O metadata span mapping", () => { + it("rejects a conflicting alias covering only the end of a pointer", () => { + const layout = parseMachoLayout( + mappingImage([ + mappingSegment(0x1000n, 0, 512), + mappingSegment(0x1104n, 400, 4), + ]), + "arm64", + ); + expect(layout.offset(0x1100n, 4)).toBe(256); + expect(() => layout.offset(0x1100n, 8)).toThrow(RangeError); + }); + + it("maps adjacent ranges and duplicate aliases with the same file translation", () => { + const layout = parseMachoLayout( + mappingImage([ + mappingSegment(0x1000n, 0, 128), + mappingSegment(0x1080n, 128, 128), + mappingSegment(0x107cn, 124, 8), + ]), + "arm64", + ); + expect(layout.offset(0x107cn, 8)).toBe(124); + }); + + it("rejects file aliases into another segment's zero-filled memory", () => { + const layout = parseMachoLayout( + mappingImage([ + mappingSegment(0x1000n, 0, 128, 256), + mappingSegment(0x1080n, 256, 8), + ]), + "arm64", + ); + expect(() => layout.offset(0x1080n, 8)).toThrow(RangeError); + }); + + it.each([-1, 0.5, Number.POSITIVE_INFINITY])( + "rejects an invalid requested span size %s", + (size) => { + const layout = parseMachoLayout( + mappingImage([mappingSegment(0x1000n, 0, 512)]), + "arm64", + ); + expect(() => layout.offset(0x1000n, size)).toThrow(RangeError); + }, + ); + + it("keeps dispatch coverage partial when a class-list pointer has a partial alias", () => { + const main = Buffer.concat([ + mappingSegment(0x1000n, 0, 512), + Buffer.alloc(80), + ]); + main.writeUInt32LE(main.length, 4); + main.writeUInt32LE(1, 64); + main.write("__objc_classlist", 72, "ascii"); + main.writeBigUInt64LE(0x1100n, 72 + 32); + main.writeBigUInt64LE(8n, 72 + 40); + const result = decodeAppleDispatchMetadata( + mappingImage([main, mappingSegment(0x1104n, 400, 4)]), + 10, + { path: "/aliased", sha256: "fixture" }, + ); + expect(result.objc_classes).toEqual([]); + expect(result.coverage).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "partial", + reason: expect.stringContaining( + "Unmapped or ambiguous metadata pointer", + ), + }), + ]), + ); + }); +}); + describe("native Mach-O slice selection", () => { it("rejects misaligned load commands", () => { expect(() => diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index b6d8d67969..7c05b84622 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -9,6 +9,11 @@ import { readMachoLoadCommandDeclarations, } from "../domain/apple/machoLoadCommands.js"; import { createHash } from "node:crypto"; +import { + BinaryFormatFailure, + createFileMappingIndex, + type ZeroFillMapping, +} from "./metadata/BinaryContainer.js"; import type { FixupCommands } from "./AppleMachoFixups.js"; import { decodeMachoFixedName, decodeMachoName } from "./AppleMachoNames.js"; @@ -197,6 +202,7 @@ export const parseMachoLayout = ( slice, sliceEnd, segments: [], + zeroFills: [], sections: [], dylibs: [], binds: [], @@ -220,19 +226,27 @@ export const parseMachoLayout = ( const complete = framing.finish(); if (!complete.ok) throw new RangeError(complete.error); const { segments, sections, chained, binds, rebases, dylibs } = state; + const mappings = createFileMappingIndex( + segments.map((segment) => ({ + address: segment.address, + offset: segment.offset, + size: Number(segment.size), + })), + state.zeroFills, + ); 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) + 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. + if (!(cause instanceof BinaryFormatFailure)) throw cause; throw new RangeError( `Unmapped or ambiguous metadata pointer ${hex(address)}`, + { cause }, ); - const segment = matches[0]; - if (segment === undefined) throw new RangeError("Missing metadata segment"); - return segment.offset + Number(address - segment.address); + } }; return { segments, @@ -261,6 +275,7 @@ interface LayoutState { readonly slice: number; readonly sliceEnd: number; readonly segments: Segment[]; + readonly zeroFills: ZeroFillMapping[]; readonly sections: Section[]; readonly dylibs: FixupCommands["dylibs"][number][]; readonly binds: FixupCommands["binds"][number][]; @@ -290,6 +305,8 @@ const collectSegment = ( if (!parsed.ok) throw new RangeError("Truncated segment command"); const { address, + memorySize, + nameBytes, fileSize, fileOffset, protection, @@ -304,6 +321,11 @@ const collectSegment = ( offset: state.slice + Number(fileOffset), executable: (protection & 4) !== 0, }); + if (memorySize > fileSize && decodeMachoFixedName(nameBytes) !== "__PAGEZERO") + state.zeroFills.push({ + address: address + fileSize, + size: memorySize - fileSize, + }); if (fileOffset === 0n && fileSize > 0n) state.baseAddress ??= address; if (headerSize + count * 80 > size) throw new RangeError("Truncated section table");