From 9d4293be275f14a24aae5855ee66648704fdbbda Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:19:43 +0800 Subject: [PATCH] fix(apple): preserve byte identity in metadata names Use one lossless Mach-O name projection for symbols and dependency paths, retaining undecodable bytes and partial binding facts. Keep referenced NIB string failures unknown and preserve their source nodes through the shared lazy projection. Valid Unicode, readable sibling metadata and weak-bind scope remain usable. Exercise the producer and CLI/MCP consumers, and align date-overlap and object-capacity fixtures with their owning archive contracts without relaxing admission or omission assertions. --- docs/native-investigation.md | 19 ++- .../apple/InterfaceBuilderAnalysis.ts | 137 +++++++++++------- .../InterfaceBuilderDecodeBudget.test.ts | 18 ++- src/artifacts/apple/NibArchive.fixture.ts | 8 + src/native/AppleDispatchMetadata.test.ts | 51 +++++++ src/native/AppleMachoFixups.test.ts | 114 +++++++++++++++ src/native/AppleMachoFixups.ts | 79 +++++----- src/native/AppleMachoNames.ts | 13 ++ src/native/AppleMachoSelection.test.ts | 30 ++++ src/native/AppleMachoSelection.ts | 7 +- .../applications/nibFieldIdentity.test.ts | 124 +++++++++++++--- 11 files changed, 485 insertions(+), 115 deletions(-) create mode 100644 src/native/AppleMachoNames.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index fb70dc19d8..89e541c895 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -649,6 +649,14 @@ complete observation. registries use paired keys and parents; objects paired with `NSRoot` are top-level, while nested objects keep their recorded parent. Conflicting or unreadable registry pairs remain incomplete hierarchy evidence. + `NSString` objects with the [recognized `NS.bytes` representation](https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/NSString.swift#L201-L215) are decoded + as UTF-8 without substituting malformed bytes. A failed conversion retains + the source string object ID and `nibValues.NS.bytes` base64 data; dependent + authored classes, titles and selectors remain unknown, and object, class, + connection and archive-decode coverage is partial. Other decoded objects and + connections remain available. Genuine U+FFFD and leading U+FEFF characters in + valid UTF-8 retain their identity; opaque data fields are not interpreted as + strings. This does not establish support for other NSString representations. Embedded `UINib` archive data remains retained and undecoded; affected object, connection, class and hierarchy coverage is partial. Recognized `UIStoryboardShowSegueTemplate` source records similarly qualify connection @@ -708,13 +716,18 @@ complete observation. [dyld symbol names are byte strings](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L203-L208), compared with [`strcmp`](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L42-L56). - When a name has no lossless UTF-8 text + Dependent library install names likewise originate as + [load-command byte strings](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/Header.cpp#L1278-L1298) + and are [compared byte for byte](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/dyld/DyldAPIs.cpp#L607-L618). + When a symbol or applicable library name has no lossless UTF-8 text projection, the affected binding remains unresolved and pointer-fixup coverage is partial; later readable bindings and other metadata survive. The internal fixup reader retains the exact name bytes and parsed binding fields, while public Objective-C and Swift results retain the unresolved - reason instead of publishing a guessed symbol or class name. A literal - U+FFFD in valid UTF-8 remains a known symbol character. + reason instead of publishing a guessed symbol, library or class name. A literal + U+FFFD or leading BOM in valid UTF-8 remains part of the name. Legacy + weak-bind lookup has no library-name scope, so an opaque dependency name + does not prevent decoding an otherwise readable weak binding. A Swift conformance whose type or protocol reference is an external bind keeps its name as `null` and carries the raw dyld identity in diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 94d83ea11a..7456be7aae 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { TextDecoder } from "node:util"; import { parseBinary } from "plist"; import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js"; @@ -638,6 +639,7 @@ const projectNibArchive = ( return normalized; }; const decodedStrings = new Map(); + const unavailableStringIds = new Set(); const decodedString = ( objectId: number, object: NibArchiveDocument["objects"][number], @@ -658,7 +660,18 @@ const projectNibArchive = ( 64 + encoded.length * 2, "NIBArchive string projection exceeds the aggregate Interface Builder decode budget", ); - const value = Buffer.from(encoded, "base64").toString("utf8"); + let value: string; + try { + // NSString's NS.bytes representation is UTF-8. A failed conversion + // does not establish a replacement-character class, title or selector. + value = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + Buffer.from(encoded, "base64"), + ); + } catch { + unavailableStringIds.add(objectId); + decodedStrings.set(objectId, null); + return null; + } decodedStrings.set(objectId, value); return value; }; @@ -754,6 +767,58 @@ const projectNibArchive = ( runtimeClasses.set(objectId, resolvedClass); return resolvedClass; }; + const projectObject = ( + object: NibArchiveDocument["objects"][number], + ): readonly [string, JsonValue] => { + const className = + runtimeClass(object.id) ?? normalizeClassName(object.class_name); + budget.reserveJsonStringOccurrence(className); + let name = className; + for (const [key, value] of Object.entries(object.values)) { + if (!/(?:title|label|identifier|accessibility|name|contents)/iu.test(key)) + continue; + const resolved = dereference(value); + if (typeof resolved !== "string") continue; + name = resolved; + break; + } + budget.reserveJsonStringOccurrence(name); + const nibValues = Object.fromEntries( + Object.entries(projectedValues.get(object.id) ?? {}).map( + ([key, value]) => { + budget.reserveJsonPropertyNameOccurrence(key); + return [key, dereference(value) ?? null]; + }, + ), + ); + // Normalize only recognized scalar fields at the format owner. A keyed + // archive's authored nibValues dictionary must not gain this meaning. + const resourceFields = appleResourceKeyFields(nibValues); + for (const [field, value] of resourceFields) { + budget.reserveJsonPropertyNameOccurrence(field); + budget.reserveJsonStringOccurrence(value); + } + return [ + String(object.id), + { + customClass: className, + label: name, + objectID: String(object.id), + nibValues, + ...(projectedRepeatedValues.has(object.id) + ? { nibRepeatedValues: projectedRepeatedValues.get(object.id)! } + : {}), + ...(className === normalizeClassName(object.class_name) + ? {} + : { + interface_builder_archived_class_name: normalizeClassName( + object.class_name, + ), + }), + ...Object.fromEntries(resourceFields), + }, + ]; + }; const objects = Object.fromEntries( archive.objects .filter((object) => { @@ -764,60 +829,7 @@ const projectNibArchive = ( ) && !isUIKitRuntimeConnectionClass(name) ); }) - .map((object) => { - const className = - runtimeClass(object.id) ?? normalizeClassName(object.class_name); - budget.reserveJsonStringOccurrence(className); - let name = className; - for (const [key, value] of Object.entries(object.values)) { - if ( - !/(?:title|label|identifier|accessibility|name|contents)/iu.test( - key, - ) - ) - continue; - const resolved = dereference(value); - if (typeof resolved !== "string") continue; - name = resolved; - break; - } - budget.reserveJsonStringOccurrence(name); - const nibValues = Object.fromEntries( - Object.entries(projectedValues.get(object.id) ?? {}).map( - ([key, value]) => { - budget.reserveJsonPropertyNameOccurrence(key); - return [key, dereference(value) ?? null]; - }, - ), - ); - // Normalize only recognized scalar fields at the format owner. A keyed - // archive's authored nibValues dictionary must not gain this meaning. - const resourceFields = appleResourceKeyFields(nibValues); - for (const [field, value] of resourceFields) { - budget.reserveJsonPropertyNameOccurrence(field); - budget.reserveJsonStringOccurrence(value); - } - return [ - String(object.id), - { - customClass: className, - label: name, - objectID: String(object.id), - nibValues, - ...(projectedRepeatedValues.has(object.id) - ? { nibRepeatedValues: projectedRepeatedValues.get(object.id)! } - : {}), - ...(className === normalizeClassName(object.class_name) - ? {} - : { - interface_builder_archived_class_name: normalizeClassName( - object.class_name, - ), - }), - ...Object.fromEntries(resourceFields), - }, - ]; - }), + .map(projectObject), ); const connections: Record = {}; const hierarchy: NibHierarchyNode[] = []; @@ -903,6 +915,21 @@ const projectNibArchive = ( }); } } + // Retain only string sources whose shared lazy interpretation failed. + // Reuse the ordinary projection and graph admission for their raw bytes. + for (const id of unavailableStringIds) { + const object = byId.get(id); + if (object !== undefined) { + const [key, value] = projectObject(object); + objects[key] = value; + } + } + if (unavailableStringIds.size > 0) + unexaminedSources.push({ + reason: "nib_string_utf8_unavailable", + facets: ["objects", "connections", "classes"], + description: `NSString NS.bytes is not valid UTF-8 for ${unavailableStringIds.size} source objects; their bytes and object IDs are retained, while dependent class names, titles and selectors remain unknown.`, + }); let hierarchyOmitted = 0; let hierarchyNodeCount = 0; const hierarchyFor = ( diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts index a929afc319..0e1a36cfaf 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts @@ -195,6 +195,17 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a bytes.writeDoubleBE(0.0005, date.offset + 1); bytes[date.offset + 2] = 0x40; writeBinaryPlistObjectOffset(bytes, table, dataObjectId, date.offset + 2); + expect(readBinaryPlistObjectOffset(bytes, table, dataObjectId)).toBe( + date.offset + 2, + ); + expect( + preflightPropertyListDecode(bytes, 1024 * 1024, undefined, { + numberProjection: true, + }), + ).toMatchObject({ + datePrecisionOverlapCount: 1, + reachableDateOffsets: [date.offset], + }); await writeFile(join(bundle, "Panel.nib"), bytes); const result = await analyzeInterfaceBuilderBundle({ @@ -211,8 +222,11 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a expect( result.graph.nodes.find( ({ attributes }) => attributes.interface_builder_object_id === "button", - )?.attributes.date, - ).toEqual({ $plist_type: "date", iso8601: null }); + )?.attributes, + ).toMatchObject({ + date: { $plist_type: "date", iso8601: null }, + data: { $plist_type: "data", base64: "" }, + }); expect(result.limitations).toContainEqual( expect.stringContaining( "Panel.nib: 1 date value(s) are reported with unknown timestamps because their sub-millisecond source records overlap", diff --git a/src/artifacts/apple/NibArchive.fixture.ts b/src/artifacts/apple/NibArchive.fixture.ts index 5e412f6344..59d0ed4007 100644 --- a/src/artifacts/apple/NibArchive.fixture.ts +++ b/src/artifacts/apple/NibArchive.fixture.ts @@ -4,6 +4,7 @@ type NibFixtureValue = | boolean | null | { ref: number } + | { data: Uint8Array } | { float32: number } | { float64: number }; type NibFixtureFields = @@ -97,6 +98,7 @@ const encodeValue = ( | boolean | null | { ref: number } + | { data: Uint8Array } | { float32: number } | { float64: number }, ): Buffer => { @@ -110,6 +112,12 @@ const encodeValue = ( return bytes; } if (typeof value === "object") { + if ("data" in value) + return Buffer.concat([ + Buffer.from([8]), + varint(value.data.length), + value.data, + ]); if ("float32" in value) { const bytes = Buffer.alloc(5); bytes[0] = 6; diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 81f8559f3d..8fe3a34950 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -479,6 +479,57 @@ describe("Imported class symbol byte identities", () => { ); }); +describe("Imported library byte identities", () => { + it.each(["invalid bytes", "literal replacement character"] as const)( + "keeps Swift and Objective-C library identity distinct for %s", + (nameKind) => { + const bytes = swiftExternalSwiftBindingImage("objc-class"); + const nameAt = 32 + 232 + 48 + 24; + bytes.fill(0, nameAt, nameAt + 32); + const name = + nameKind === "invalid bytes" + ? Buffer.from([0x2f, 0x6c, 0x69, 0x62, 0x80]) + : Buffer.from("/lib�"); + name.copy(bytes, nameAt); + const result = decodeAppleDispatchMetadata( + bytes, + 100, + provenance, + "arm64", + ["0x100000b00"], + ); + expect(result.swift_conformances).toMatchObject([ + { + protocol_name: "Equatable", + type_binding: + nameKind === "invalid bytes" + ? null + : { symbol: "_OBJC_CLASS_$_NSObject", library: "/lib�" }, + decode: { status: "partial" }, + }, + ]); + expect(result.objc_class_import_bindings).toEqual( + nameKind === "invalid bytes" + ? [] + : [expect.objectContaining({ library: "/lib�" })], + ); + expect(result.objc_class_import_binding_coverage).toMatchObject({ + status: nameKind === "invalid bytes" ? "partial" : "complete", + }); + expect( + result.coverage.find(({ facet }) => facet === "pointer_fixups"), + ).toMatchObject({ + status: nameKind === "invalid bytes" ? "partial" : "complete", + }); + expect(result.objc_classes[0]?.name).toBe("Fixture"); + if (nameKind === "invalid bytes") + expect(result.objc_class_import_binding_coverage?.reason).toContain( + "install-name bytes", + ); + }, + ); +}); + describe("Swift imported protocol references", () => { it("preserves bind identity without reading VM address zero and marks coverage partial", () => { const result = decodeAppleDispatchMetadata( diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 1a9644e2a8..16accc9a10 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -154,6 +154,120 @@ const SEGMENTS: Segment[] = [ { address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false }, ]; +it.each([1, 2, 3] as const)( + "preserves opaque chained library identity and readable siblings for import format %i", + (format) => { + const data = chainedData(format); + const libraryBytes = Buffer.from([0x2f, 0x6c, 0x69, 0x62, 0x80]).toString( + "base64", + ); + data.writeBigUInt64LE( + field(1n, 63) | field(2n, 51) | field(3n, 24), + 0x4008, + ); + data.writeBigUInt64LE(1n | field(1n, 63), 0x4010); + const fixups = parsePointerFixups( + data, + { + chained: { offset: 0, size: 180 }, + binds: [], + rebases: [], + dylibs: [{ bytes_base64: libraryBytes }, "/library-2"], + }, + SEGMENTS, + BASE, + ); + expect( + fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)), + ).toMatchObject({ + kind: "unsupported", + reason: expect.stringContaining("install-name bytes"), + binding: { + symbol: "_OBJC_CLASS_$_NSObject", + binding_source: "chained", + library_ordinal: 1, + library: null, + library_bytes_base64: libraryBytes, + weak: false, + addend: 3n, + }, + }); + expect( + fixups.decode(BASE + 0x4010n, data.readBigUInt64LE(0x4010)), + ).toMatchObject({ kind: "bind", symbol: "_free", library: "/library-2" }); + expect(fixups.issues).toContainEqual( + expect.objectContaining({ + code: "chained_pointer_decode_failed", + location: "0x100004008", + }), + ); + }, +); + +it.each(["bind", "weak", "lazy"] as const)( + "retains opaque library facts for %s without changing weak lookup scope", + (stream) => { + const libraryBytes = Buffer.from([0x2f, 0x6c, 0x69, 0x62, 0x80]).toString( + "base64", + ); + const opcodes = Buffer.from([ + 0x11, + 0x40, + ...Buffer.from("_first\0"), + 0x71, + 0x08, + 0x90, + 0x12, + 0x40, + ...Buffer.from("_second\0"), + 0x90, + 0, + ]); + const data = Buffer.alloc(0x8000); + opcodes.copy(data); + const fixups = parsePointerFixups( + data, + { + chained: null, + rebases: [], + binds: [{ offset: 0, size: opcodes.length, stream }], + dylibs: [{ bytes_base64: libraryBytes }, "/library-2"], + }, + SEGMENTS, + BASE, + ); + expect(fixups.decode(BASE + 0x4008n, 0n)).toMatchObject( + stream === "weak" + ? { kind: "bind", symbol: "_first", library: null } + : { + kind: "unsupported", + binding: { + symbol: "_first", + library_ordinal: 1, + library: null, + library_bytes_base64: libraryBytes, + }, + }, + ); + expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({ + kind: "bind", + symbol: "_second", + library: stream === "weak" ? null : "/library-2", + }); + expect(fixups.decoded).toBe(1); + expect(fixups.issues).toEqual( + stream === "weak" + ? [] + : [ + expect.objectContaining({ + code: "bind_library_text_unavailable", + location: "0x100004008", + }), + ], + ); + }, +); + it.each([1, 2, 3] as const)( "preserves non-UTF-8 chained symbol bytes without inventing class identity for import format %i", (format) => { diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index 0e977418de..d0b09f1070 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -1,6 +1,7 @@ import type { Segment } from "./AppleMachoSelection.js"; import { issue } from "./AppleDispatchDecodeFacts.js"; import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; +import { decodeMachoName, type MachoName } from "./AppleMachoNames.js"; /** Where a Mach-O stores pointer fixups, from its load commands. */ export interface FixupCommands { @@ -17,8 +18,8 @@ export interface FixupCommands { readonly size: number; readonly stream: "bind" | "weak" | "lazy"; }[]; - /** Install names of dependent dylibs, in library-ordinal order. */ - readonly dylibs: readonly string[]; + /** Lossless install names of dependent dylibs, in library-ordinal order. */ + readonly dylibs: readonly MachoName[]; } /** One decoded pointer: a rebase to an in-image address or a bind to a symbol. */ @@ -38,8 +39,8 @@ export type DecodedPointer = | { readonly kind: "unsupported"; readonly reason: string; - /** Source bind facts survive when its byte name has no lossless text projection. */ - readonly binding?: Import & { readonly symbol: null }; + /** Source bind facts survive when a byte name has no lossless text projection. */ + readonly binding?: Import; }; /** Pointer-slot outcome, including whether a rebase was actually applied. */ @@ -65,7 +66,7 @@ export interface PointerFixups { readonly examined: number; /** Chained segments without observed decode failures, plus structurally parsed * legacy opcode streams, in the same units as `examined`. Legacy location - * coverage survives unavailable symbol text, which is qualified by `issues`. + * coverage survives unavailable name text, which is qualified by `issues`. */ readonly decoded: number; /** Declared legacy opcode streams examined by this owner. */ @@ -99,13 +100,17 @@ type SymbolName = | { readonly symbol: string } | { readonly symbol: null; readonly symbol_bytes_base64: string }; -type Import = SymbolName & { - readonly binding_source: "chained" | "bind" | "weak" | "lazy"; - readonly library_ordinal: number; - readonly library: string | null; - readonly weak: boolean; - readonly addend: bigint; -}; +type LibraryName = + | { readonly library: string | null; readonly library_bytes_base64?: never } + | { readonly library: null; readonly library_bytes_base64: string }; + +type Import = SymbolName & + LibraryName & { + readonly binding_source: "chained" | "bind" | "weak" | "lazy"; + readonly library_ordinal: number; + readonly weak: boolean; + readonly addend: bigint; + }; interface BindRun { readonly start: bigint; @@ -125,15 +130,15 @@ interface BindFacts { readonly issues: readonly DecodeIssue[]; readonly examined: number; readonly decoded: number; - /** All opcode locations are known, independently of symbol text availability. */ + /** All opcode locations are known, independently of name text availability. */ readonly complete: boolean; } /** Resolve only valid dylib ordinals; preserve special scopes as raw ordinals. */ -const libraryName = ( +const libraryFacts = ( ordinal: number, - dylibs: readonly string[], -): string | null => { + dylibs: FixupCommands["dylibs"], +): LibraryName => { if (!Number.isSafeInteger(ordinal) || ordinal < -3) throw new RangeError(`Unsupported dylib ordinal ${String(ordinal)}`); if (ordinal > 0) { @@ -142,22 +147,19 @@ const libraryName = ( throw new RangeError( `Dylib ordinal ${ordinal} exceeds the ${dylibs.length} declared dependencies`, ); - return name; + return typeof name === "string" + ? { library: name } + : { library: null, library_bytes_base64: name.bytes_base64 }; } - return null; + return { library: null }; }; /** dyld names are NUL-terminated bytes; text projection must never replace their identity. */ const symbolName = (bytes: Buffer): SymbolName => { - try { - return { - symbol: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( - bytes, - ), - }; - } catch { - return { symbol: null, symbol_bytes_base64: bytes.toString("base64") }; - } + const name = decodeMachoName(bytes); + return typeof name === "string" + ? { symbol: name } + : { symbol: null, symbol_bytes_base64: name.bytes_base64 }; }; const cString = (bytes: Buffer, offset: number, end: number): SymbolName => { @@ -171,11 +173,13 @@ const resolvedBind = ( imported: Import, addend: bigint, ): Exclude => - imported.symbol === null + imported.symbol === null || imported.library_bytes_base64 !== undefined ? { kind: "unsupported", reason: - "Fixup symbol bytes cannot be represented losslessly as UTF-8 text", + imported.symbol === null + ? "Fixup symbol bytes cannot be represented losslessly as UTF-8 text" + : "Dylib install-name bytes cannot be represented losslessly as UTF-8 text", binding: { ...imported, addend: imported.addend + addend }, } : { kind: "bind", ...imported, addend: imported.addend + addend }; @@ -188,6 +192,7 @@ const sameImport = (left: Import, right: Import): boolean => left.binding_source === right.binding_source && left.library_ordinal === right.library_ordinal && left.library === right.library && + left.library_bytes_base64 === right.library_bytes_base64 && left.weak === right.weak && left.addend === right.addend; @@ -196,7 +201,7 @@ const readImport = ( readonly bytes: Buffer; readonly base: number; readonly end: number; - readonly dylibs: readonly string[]; + readonly dylibs: FixupCommands["dylibs"]; }, header: { readonly offset: number; @@ -231,7 +236,7 @@ const readImport = ( ), binding_source: "chained", library_ordinal: ordinal, - library: libraryName(ordinal, dylibs), + ...libraryFacts(ordinal, dylibs), weak: bits(word, 16, 1) === 1n, addend: bytes.readBigInt64LE(at + 8), }; @@ -244,7 +249,7 @@ const readImport = ( ...cString(bytes, base + header.symbols + (word >>> 9), end), binding_source: "chained", library_ordinal: ordinal, - library: libraryName(ordinal, dylibs), + ...libraryFacts(ordinal, dylibs), weak: ((word >>> 8) & 1) === 1, addend: header.format === 2 ? BigInt(bytes.readInt32LE(at + 4)) : 0n, }; @@ -296,7 +301,7 @@ export const decodeChainedPointer = ( interface FixupImage { readonly bytes: Buffer; readonly segments: readonly Segment[]; - readonly dylibs: readonly string[]; + readonly dylibs: FixupCommands["dylibs"]; readonly baseAddress: bigint; } @@ -1094,13 +1099,13 @@ const readBindStream = ( try { if (state.ordinalFailure !== null) throw new RangeError(state.ordinalFailure); - const resolvedLibrary = libraryName(state.ordinal, image.dylibs); + const resolvedLibrary = libraryFacts(state.ordinal, image.dylibs); const decoded = resolvedBind( { ...state.symbol, binding_source: range.stream, library_ordinal: state.ordinal, - library: range.stream === "weak" ? null : resolvedLibrary, + ...(range.stream === "weak" ? { library: null } : resolvedLibrary), weak: state.weak, addend: state.addend, }, @@ -1228,7 +1233,9 @@ const parseLegacyBinds = ( issues.push( issue( "pointer_fixups", - "bind_symbol_text_unavailable", + run.binding.binding.symbol === null + ? "bind_symbol_text_unavailable" + : "bind_library_text_unavailable", run.binding.reason, `0x${run.start.toString(16)}`, ), diff --git a/src/native/AppleMachoNames.ts b/src/native/AppleMachoNames.ts new file mode 100644 index 0000000000..d0040af15e --- /dev/null +++ b/src/native/AppleMachoNames.ts @@ -0,0 +1,13 @@ +/** A dyld byte name projected losslessly as UTF-8, or retained exactly. */ +export type MachoName = string | { readonly bytes_base64: string }; + +/** NUL terminators and source bounds are validated by the owning format reader. */ +export const decodeMachoName = (bytes: Buffer): MachoName => { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch { + return { bytes_base64: bytes.toString("base64") }; + } +}; diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index c1343164b5..69c54725e9 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -274,4 +274,34 @@ describe("native Mach-O fixup load command boundaries", () => { // machoImage includes zero padding after sizeofcmds; it is outside this command. expect(() => decode(unterminated)).toThrow(/Unterminated dylib/u); }); + + it("preserves distinct opaque install-name bytes and literal Unicode names", () => { + const commands = [0x80, 0x81].map((byte) => { + const command = Buffer.from(dylibCommand(LC.LOAD_DYLIB, "/lib?")); + command[28] = byte; + return command; + }); + commands.push( + Buffer.from(dylibCommand(LC.LOAD_DYLIB, "/lib�")), + Buffer.from(dylibCommand(LC.LOAD_DYLIB, "\ufeff/libé")), + ); + const layout = parseMachoLayout( + Buffer.from(machoImage({ commands })), + "arm64", + ); + expect(layout.fixups.dylibs).toEqual([ + { + bytes_base64: Buffer.from([0x2f, 0x6c, 0x69, 0x62, 0x80]).toString( + "base64", + ), + }, + { + bytes_base64: Buffer.from([0x2f, 0x6c, 0x69, 0x62, 0x81]).toString( + "base64", + ), + }, + "/lib�", + "\ufeff/libé", + ]); + }); }); diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 13cac40d44..5ca972f526 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -2,6 +2,7 @@ import { MachoLoadCommandCursor } from "../domain/apple/machoLoadCommands.js"; import { createHash } from "node:crypto"; import type { FixupCommands } from "./AppleMachoFixups.js"; +import { decodeMachoName } from "./AppleMachoNames.js"; import { MACHO_ARCHITECTURE_POLICIES, machoSubtypeRank, @@ -293,7 +294,7 @@ interface LayoutState { readonly sliceEnd: number; readonly segments: Segment[]; readonly sections: Section[]; - readonly dylibs: string[]; + readonly dylibs: FixupCommands["dylibs"][number][]; readonly binds: FixupCommands["binds"][number][]; readonly rebases: FixupCommands["rebases"][number][]; chained: FixupCommands["chained"]; @@ -388,6 +389,8 @@ const collectFixupCommand = ( const end = bytes.indexOf(0, cursor + nameOffset); if (end < 0 || end >= cursor + size) throw new RangeError("Unterminated dylib install name"); - state.dylibs.push(bytes.toString("utf8", cursor + nameOffset, end)); + state.dylibs.push( + decodeMachoName(bytes.subarray(cursor + nameOffset, end)), + ); } }; diff --git a/tests/acceptance/applications/nibFieldIdentity.test.ts b/tests/acceptance/applications/nibFieldIdentity.test.ts index 82aa769d19..166583ffbb 100644 --- a/tests/acceptance/applications/nibFieldIdentity.test.ts +++ b/tests/acceptance/applications/nibFieldIdentity.test.ts @@ -12,8 +12,64 @@ import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +const createIdentityArchive = () => + encodeNibArchiveFixture({ + classes: [ + "UIView", + "\uFEFFCustomView", + "UIClassSwapper", + "UIButton", + "UIRuntimeEventConnection", + "NSString", + ], + objects: [ + { + classIndex: 0, + values: { + ["__proto__"]: { ref: 1 }, + constructor: 7, + enabled: true, + "\uFEFFenabled": false, + }, + }, + { classIndex: 1, values: { ["__proto__"]: 23 } }, + { classIndex: 2, values: { UIClassName: { ref: 5 } } }, + { classIndex: 3, values: { title: { ref: 6 } } }, + { + classIndex: 4, + values: { + UISource: { ref: 3 }, + UIDestination: { ref: 2 }, + UILabel: { ref: 7 }, + }, + }, + { + classIndex: 5, + values: { "NS.bytes": { data: Uint8Array.of(0x43, 0xff) } }, + }, + { + classIndex: 5, + values: { "NS.bytes": { data: Uint8Array.of(0x54, 0xfe) } }, + }, + { + classIndex: 5, + values: { "NS.bytes": { data: Uint8Array.of(0x64, 0xff, 0x3a) } }, + }, + { classIndex: 3, values: { title: { ref: 9 } } }, + { classIndex: 5, values: { "NS.bytes": "\uFEFFGenuine\uFFFD" } }, + { + classIndex: 3, + values: { opaqueData: { data: Uint8Array.of(0xff) } }, + }, + { + classIndex: 5, + values: { "NS.bytes": { data: Uint8Array.of(0xff) } }, + }, + ], + }); + cliTest( - "preserves literal NIB fields and UTF-8 names through CLI and stdio MCP", + "preserves literal NIB fields and unavailable string identities through CLI and stdio MCP", async ({ cli }) => { const root = await createTestTempDirectory("rea-nib-field-identity-"); const app = join(root, "Fixture.app"); @@ -28,21 +84,7 @@ cliTest( join(contents, "Info.plist"), "CFBundleExecutableAppCFBundleIdentifierdev.rea.nib-fields", ); - const archive = encodeNibArchiveFixture({ - classes: ["UIView", "\uFEFFCustomView"], - objects: [ - { - classIndex: 0, - values: { - ["__proto__"]: { ref: 1 }, - constructor: 7, - enabled: true, - "\uFEFFenabled": false, - }, - }, - { classIndex: 1, values: { ["__proto__"]: 23 } }, - ], - }); + const archive = createIdentityArchive(); const path = join(contents, "Resources/Panel.nib"); await writeFile(path, archive); const result = await cli.run({ @@ -93,7 +135,7 @@ cliTest( const objects = report.graph.nodes.filter( ({ attributes }) => attributes.interface_builder_object_id !== undefined, ); - expect(objects).toHaveLength(2); + expect(objects).toHaveLength(9); expect(objects[0]?.attributes.nibValues).toEqual({ ["__proto__"]: { objectID: "1" }, constructor: 7, @@ -104,6 +146,54 @@ cliTest( class_name: "\uFEFFCustomView", nibValues: { ["__proto__"]: 23 }, }); + const byId = new Map( + objects.map((node) => [ + node.attributes.interface_builder_object_id, + node, + ]), + ); + expect(byId.get("2")?.attributes).toMatchObject({ + class_name: "UIClassSwapper", + nibValues: { UIClassName: { objectID: "5" } }, + }); + expect(byId.get("3")).toMatchObject({ + name: "UIButton", + attributes: { nibValues: { title: { objectID: "6" } } }, + }); + expect( + report.graph.nodes.find(({ kind }) => kind === "action"), + ).toMatchObject({ + name: "unlabeled action", + attributes: { selector: null, label: null }, + }); + for (const [id, bytes] of [ + ["5", Uint8Array.of(0x43, 0xff)], + ["6", Uint8Array.of(0x54, 0xfe)], + ["7", Uint8Array.of(0x64, 0xff, 0x3a)], + ] as const) + expect(byId.get(id)?.attributes.nibValues).toEqual({ + "NS.bytes": { $nib_data_base64: Buffer.from(bytes).toString("base64") }, + }); + expect(byId.get("8")?.name).toBe("\uFEFFGenuine\uFFFD"); + expect(byId.has("9")).toBe(false); + expect(byId.get("10")?.attributes.nibValues).toEqual({ + opaqueData: { $nib_data_base64: "/w==" }, + }); + expect(byId.has("11")).toBe(false); + for (const facet of ["objects", "connections", "classes"]) + expect(report.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: `${facet}:Contents/Resources/Panel.nib`, + status: "partial", + reason: "nib_string_utf8_unavailable", + }), + ); + expect(report.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "archive_decode", status: "partial" }), + ); + expect(report.limitations).toContainEqual( + expect.stringContaining("NSString NS.bytes is not valid UTF-8"), + ); expect(await readFile(path)).toEqual(archive); }, 60_000,