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,