mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<number, string | null>();
|
||||
const unavailableStringIds = new Set<number>();
|
||||
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<string, JsonValue[]> = {};
|
||||
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 = (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<DecodedPointer, { readonly kind: "rebase" }> =>
|
||||
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)}`,
|
||||
),
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
};
|
||||
@@ -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é",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string><key>CFBundleIdentifier</key><string>dev.rea.nib-fields</string></dict></plist>",
|
||||
);
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user