Merge remote-tracking branch 'origin/main' into feat/dylib-resolution

# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
This commit is contained in:
Kaoru0822 committed 2026-10-07 12:54:53 +00:00
commit 9049620dfe
29 files changed
+1619 -264

No files matched your search

+5 -3
View File
@@ -96,9 +96,11 @@ The components also list:
Symlinks are reported by path in `symlinks`. Their targets are not inventoried,
so a versioned framework is described through its real `Versions/<version>/`
directories rather than `Versions/Current`. When a framework has zero or several
`Versions/*/Resources/Info.plist` files, its `info_plist_path` is `null` and a
limitation explains why.
directories rather than `Versions/Current`. A framework's `info_plist_path` is
`null`, with a limitation explaining why, when it has more than one real
`Versions/<version>/` directory (even if only one of them holds a plist,
because `Versions/Current` decides which one applies) or when its single
version directory has no `Resources/Info.plist`.
AppleDouble sidecar files (`._*` and `__MACOSX/`) are inventory facts, but they
describe neighbouring files. They are excluded from roots, bundle roles, and the
+18 -5
View File
@@ -46,9 +46,20 @@ explicit limitation; architecture inventory locations remain available.
vtable descriptors. Records include exact virtual addresses/file offsets and
artifact evidence. Vtable indexes are metadata word offsets; witness indexes
start after the conformance header. Names unavailable in metadata remain null.
Chained fixups, external bindings, authenticated pointers, categories,
properties, generic/resilient/async/coroutine tables and inherited overrides
have explicit unsupported or partial coverage. Other providers retain the
Pointers are decoded through the image's fixups:
- `LC_DYLD_CHAINED_FIXUPS` rebases and binds, including the authenticated
arm64e formats;
- legacy `LC_DYLD_INFO` bind opcodes.
So an external superclass resolves from its `_OBJC_CLASS_$_` bind (for
example `NSObject`). The `pointer_fixups` coverage facet names the mechanism.
Class properties, with parsed attributes, and `__objc_catlist` categories are
also decoded. Categories record the extended class, local or external, plus
their methods, protocols and properties; category methods appear as
implementations with a `category`. Swift field-offset globals that are only
initialized at runtime stay unresolved. Generic, resilient, async and
coroutine tables, and inherited overrides, have explicit unsupported or
partial coverage. Other providers retain the
existing symbol-based inventory with its narrower coverage.
## Native instruction, call and type primitives
@@ -172,8 +183,10 @@ GUI authority, and REA never falls back automatically to Hopper.
instruction/call facts, value dependencies and process/project cleanup.
- `npm run verify:ghidra:aarch64-jump-table`: optimized ELF and byte/halfword
relative tables, plus ARM64 Mach-O on an ARM64 macOS host.
- `npm run verify:apple-dispatch`: source-built Objective-C protocols/classes and
Swift conformances/vtables, repeated after stripping local symbols.
- `npm run verify:apple-dispatch`: source-built Objective-C protocols, classes,
properties and categories, and Swift conformances and vtables. Each is linked
with legacy `LC_DYLD_INFO` and with chained fixups (and as arm64e on Apple
silicon), then repeated after stripping local symbols.
- `npm run verify:native-ui`: one source-owned fixture window, successful
selected-window capture and actions, changed-target rejection, and cleanup.
Missing OS permissions fail this lane.
+2 -2
View File
@@ -672,9 +672,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "d9a9724ba1b2232aaa81324405a3d436168c9525b1d1cbe8d85898b357ba760b",
"tools_sha256": "23afd2ad272de67c0f3c350e36eb68f3eafa229cd135797a6b4bbdb1acea18d7",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "60e8649520b637638644745ad55b370ad113b20bb73e8f62814731d5f656d57d",
"combined_sha256": "9d48bf6a2a523c2b472f7273797aeab30bf103bbd1ba74e21c23c3306b3d2e60",
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
}
}
+18 -11
View File
@@ -472,17 +472,24 @@ that explicit lane, not the routine iteration requirement.
## Apple native metadata and UI
`npm run verify:apple-dispatch` compiles Objective-C class/protocol and Swift
conformance/vtable fixtures, inspects their bytes and repeats after stripping
local symbols. It requires macOS and the host Xcode toolchain; targets are not
executed. `npm run verify:native-ui` launches exactly one source-owned fixture
window and requires successful selected-window capture and selected actions.
An OS permission denial fails the positive lane. `npm run verify:native-ui:permissions`
allows a host-permission-boundary-only result and explicitly reports
`positive_e2e: false`; it must not be reported as capture/action proof.
Both commands reject a changed executable digest and clean up the fixture
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
for the exact ABI, authority, graph and observation boundaries.
`npm run verify:apple-dispatch` compiles the Objective-C fixture (classes,
protocols, a property and an `NSString` category) and the Swift
conformance/vtable fixture.
- Each fixture is linked with legacy `LC_DYLD_INFO` binds and with chained
fixups; on Apple silicon the ObjC fixture is also built as arm64e, which uses
authenticated pointers.
- The lane inspects the bytes and repeats after stripping local symbols.
- It requires the bound `NSObject` superclass, the external category, and the
matching `pointer_fixups` coverage. It requires macOS and the host Xcode toolchain; targets are not
executed. `npm run verify:native-ui` launches exactly one source-owned fixture
window and requires successful selected-window capture and selected actions.
An OS permission denial fails the positive lane. `npm run verify:native-ui:permissions`
allows a host-permission-boundary-only result and explicitly reports
`positive_e2e: false`; it must not be reported as capture/action proof.
Both commands reject a changed executable digest and clean up the fixture
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
for the exact ABI, authority, graph and observation boundaries.
### Firmware adapters
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_5b01353b9bf50f8c58d4572f81da5367d760d0fb1d93f3f156828d3a6e75ecf3",
"manifest_id": "ecm_0f113449440f920714b896b90f99dfd94ac35b0ba56f56f759c4a77ee9ddd2ba",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "1c86d8257be704370605c96fa6774c870b59ff2da557ad8557426d884757e8cf"
"sha256": "2ed9cb3039053459635b32739cf4fbf0ef7670c85ed246aa3b7433d1d7193457"
}
],
"claims": [
+181 -109
View File
@@ -12,118 +12,190 @@ if (process.platform !== "darwin")
"Apple dispatch verification requires macOS with a host Objective-C compiler and Foundation SDK",
);
const exec = promisify(execFile);
const source = (name) =>
fileURLToPath(
new URL(`../tests/conformance/native/${name}`, import.meta.url),
);
/** Both link modes: legacy LC_DYLD_INFO binds and the default chained fixups. */
const LINK_MODES = [
{
mode: "dyld-info",
flags: ["-Wl,-no_fixup_chains"],
fixups: "LC_DYLD_INFO bind opcodes",
swift: true,
},
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
// arm64e uses authenticated chained pointers (DYLD_CHAINED_PTR_ARM64E_*).
...(process.arch === "arm64"
? [
{
mode: "chained-arm64e",
flags: ["-arch", "arm64e"],
fixups: "chained fixups: DYLD_CHAINED_PTR_ARM64E",
swift: false,
},
]
: []),
];
const fixupCoverage = (metadata) =>
metadata.coverage.find(({ facet }) => facet === "pointer_fixups");
const inspect = async (path) => {
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
return {
target: target.value,
metadata: (await inspectAppleDispatchMetadata(target.value, 5000)).result,
};
};
const checkObjc = (metadata, mode) => {
const fixture = metadata.objc_classes.find(
({ name, is_meta_class: meta }) => name === "ReaDispatchFixture" && !meta,
);
const category = metadata.objc_categories.find(
({ name }) => name === "ReaDispatchAdditions",
);
const objcCoverage = metadata.coverage.find(
({ facet }) => facet === "objc_class_method_ivar_metadata",
);
if (
!metadata.objc_dispatch_implementations.some(
(item) =>
item.class_name === "ReaDispatchFixture" &&
item.selector === "performAction:" &&
item.implementation_address !== null,
) ||
!metadata.objc_dispatch_implementations.some(
(item) =>
item.selector === "fixtureVersion" && item.method_type === "class",
) ||
!metadata.objc_ivars.some(
(item) => item.name === "state" && item.offset !== null,
) ||
!metadata.objc_protocol_records.some(
(protocol) =>
protocol.name === "ReaDispatchProtocol" &&
protocol.methods.some(
(method) => method.selector === "performAction:",
) &&
protocol.optional_methods.some(
(method) => method.selector === "optionalFixtureValue",
),
) ||
// The superclass lives in libobjc, so it comes from a bind fixup.
fixture?.super_class !== "NSObject" ||
fixture.decode.status !== "decoded" ||
!fixture.properties.some(
(property) =>
property.name === "fixtureName" &&
property.type_encoding === '@"NSString"' &&
property.attributes.some(({ name }) => name === "C"),
) ||
category?.class_name !== "NSString" ||
category.class_source !== "external" ||
!category.instance_methods.includes("reaFixtureLength") ||
!metadata.objc_dispatch_implementations.some(
(item) =>
item.class_name === "NSString" &&
item.category === "ReaDispatchAdditions" &&
item.selector === "reaFixtureLength",
) ||
objcCoverage?.status !== "complete" ||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
)
throw new Error(
`Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
);
};
const checkSwift = (metadata, mode) => {
if (
!metadata.swift_dispatch_slots.some(
(slot) =>
slot.table_kind === "class_vtable" &&
slot.owner === "ReaVtableFixture" &&
slot.decode.status === "decoded",
)
)
throw new Error(
`Swift class vtable fixture unavailable (${mode.mode}): ${JSON.stringify(metadata.coverage)}`,
);
if (
!metadata.swift_conformances.some(
(item) =>
item.type_name === "ReaWitnessFixture" &&
item.protocol_name === "ReaWitnessFixtureProtocol",
) ||
!metadata.swift_dispatch_slots.some(
(item) =>
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
item.implementation_address !== null,
) ||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
)
throw new Error(
`Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
);
};
const root = await mkdtemp(join(tmpdir(), "rea-dispatch-"));
try {
const targetPath = join(root, "fixture");
await exec("/usr/bin/xcrun", [
"clang",
"-O2",
"-framework",
"Foundation",
"-Wl,-no_fixup_chains",
fileURLToPath(
new URL("../tests/conformance/native/dispatch.m", import.meta.url),
),
"-o",
targetPath,
]);
const results = [];
for (const variant of ["symbols", "stripped"]) {
if (variant === "stripped")
await exec("/usr/bin/strip", ["-x", targetPath]);
const target = await parseBinaryTarget(targetPath);
if (!target.ok) throw target.error;
const observation = await inspectAppleDispatchMetadata(target.value, 5000);
const metadata = observation.result;
if (
!metadata.objc_dispatch_implementations.some(
(item) =>
item.class_name === "ReaDispatchFixture" &&
item.selector === "performAction:" &&
item.implementation_address !== null,
) ||
!metadata.objc_dispatch_implementations.some(
(item) =>
item.selector === "fixtureVersion" && item.method_type === "class",
) ||
!metadata.objc_ivars.some(
(item) => item.name === "state" && item.offset !== null,
) ||
!metadata.objc_protocol_records.some(
(protocol) =>
protocol.name === "ReaDispatchProtocol" &&
protocol.methods.some(
(method) => method.selector === "performAction:",
) &&
protocol.optional_methods.some(
(method) => method.selector === "optionalFixtureValue",
),
)
)
throw new Error(
`Apple metadata fixture drifted: ${JSON.stringify(observation)}`,
);
results.push({
variant,
target_sha256: target.value.sha256,
implementations: metadata.objc_dispatch_implementations.length,
ivars: metadata.objc_ivars.length,
coverage: metadata.coverage,
});
}
const swiftPath = join(root, "libReaWitnessFixture.dylib");
await exec("/usr/bin/xcrun", [
"swiftc",
"-emit-library",
"-O",
"-Xlinker",
"-no_fixup_chains",
fileURLToPath(
new URL("../tests/conformance/native/dispatch.swift", import.meta.url),
),
"-o",
swiftPath,
]);
for (const variant of ["swift-symbols", "swift-stripped"]) {
if (variant === "swift-stripped")
await exec("/usr/bin/strip", ["-x", swiftPath]);
const target = await parseBinaryTarget(swiftPath);
if (!target.ok) throw target.error;
const observation = await inspectAppleDispatchMetadata(target.value, 5000);
const metadata = observation.result;
if (
!metadata.swift_dispatch_slots.some(
(slot) =>
slot.table_kind === "class_vtable" &&
slot.owner === "ReaVtableFixture" &&
slot.decode.status === "decoded",
)
)
throw new Error(
`Swift class vtable fixture unavailable: ${JSON.stringify(metadata.coverage)}`,
);
if (
!metadata.swift_conformances.some(
(item) =>
item.type_name === "ReaWitnessFixture" &&
item.protocol_name === "ReaWitnessFixtureProtocol",
) ||
!metadata.swift_dispatch_slots.some(
(item) =>
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
item.implementation_address !== null,
)
)
throw new Error(
`Swift metadata fixture drifted: ${JSON.stringify(observation)}`,
);
results.push({
variant,
target_sha256: target.value.sha256,
conformances: metadata.swift_conformances.length,
slots: metadata.swift_dispatch_slots.length,
coverage: metadata.coverage,
});
for (const mode of LINK_MODES) {
const objcPath = join(root, `fixture-${mode.mode}`);
await exec("/usr/bin/xcrun", [
"clang",
"-O2",
"-framework",
"Foundation",
...mode.flags,
source("dispatch.m"),
"-o",
objcPath,
]);
for (const variant of ["symbols", "stripped"]) {
if (variant === "stripped")
await exec("/usr/bin/strip", ["-x", objcPath]);
const { target, metadata } = await inspect(objcPath);
checkObjc(metadata, mode);
results.push({
variant: `objc-${mode.mode}-${variant}`,
target_sha256: target.sha256,
classes: metadata.objc_classes.length,
categories: metadata.objc_categories.length,
implementations: metadata.objc_dispatch_implementations.length,
fixups: fixupCoverage(metadata)?.reason,
});
}
if (!mode.swift) continue;
const swiftPath = join(root, `libReaWitnessFixture-${mode.mode}.dylib`);
await exec("/usr/bin/xcrun", [
"swiftc",
"-emit-library",
"-O",
...mode.flags
.map((flag) => flag.replace(/^-Wl,/u, "-Xlinker\0"))
.flatMap((flag) => flag.split("\0")),
source("dispatch.swift"),
"-o",
swiftPath,
]);
for (const variant of ["symbols", "stripped"]) {
if (variant === "stripped")
await exec("/usr/bin/strip", ["-x", swiftPath]);
const { target, metadata } = await inspect(swiftPath);
checkSwift(metadata, mode);
results.push({
variant: `swift-${mode.mode}-${variant}`,
target_sha256: target.sha256,
conformances: metadata.swift_conformances.length,
slots: metadata.swift_dispatch_slots.length,
fixups: fixupCoverage(metadata)?.reason,
});
}
}
process.stdout.write(
`${JSON.stringify({ ok: true, target_executed: false, fixtures: results })}\n`,
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "28"
tool_count: 132
catalog_digest: "60e8649520b637638644745ad55b370ad113b20bb73e8f62814731d5f656d57d"
catalog_digest: "9d48bf6a2a523c2b472f7273797aeab30bf103bbd1ba74e21c23c3306b3d2e60"
---
# REA
+57
View File
@@ -309,6 +309,29 @@ describe("Apple bundle layouts and containers", () => {
expect(
result.bundles.find(({ path }) => path === framework)?.info_plist_path,
).toBeNull();
// Files beside the only real version are not versions.
const withFiles = project(
inventoryEvidence("zip", "Fixture.zip", [
macho("Fixture.app/Contents/MacOS/Fixture"),
{ path: `${framework}/Versions/A/Resources/Info.plist` },
{ path: `${framework}/Versions/.DS_Store` },
{ path: `${framework}/Versions/Current`, kind: "symlink" },
]),
);
expect(
withFiles.bundles.find(({ path }) => path === framework)?.info_plist_path,
).toBe(`${framework}/Versions/A/Resources/Info.plist`);
// Only A has a plist, but Versions/Current may select B.
const onlyA = project(
inventoryEvidence("zip", "Fixture.zip", [
macho("Fixture.app/Contents/MacOS/Fixture"),
{ path: `${framework}/Versions/A/Resources/Info.plist` },
macho(`${framework}/Versions/B/Core`),
]),
);
expect(
onlyA.bundles.find(({ path }) => path === framework)?.info_plist_path,
).toBeNull();
expect(result.limitations).toContainEqual(
expect.stringContaining("Versions/Current selects is unknown"),
);
@@ -438,3 +461,37 @@ describe("macOS application scope", () => {
});
});
});
describe("Apple application bridge candidates", () => {
it("pairs scripts and native code only within one application root", () => {
const result = project(
inventoryEvidence("zip", "Suite.zip", [
macho("First.app/Contents/MacOS/First"),
{
path: "First.app/Contents/Resources/first.js",
format: "javascript-bundle",
},
macho("Second.app/Contents/MacOS/Second"),
{
path: "Second.app/Contents/Resources/second.js",
format: "javascript-bundle",
},
]),
);
expect(result.application_roots).toEqual(["First.app", "Second.app"]);
expect(
result.bridge_candidates.map(
({ source_path: source, native_path: target }) => [source, target],
),
).toEqual([
[
"First.app/Contents/Resources/first.js",
"First.app/Contents/MacOS/First",
],
[
"Second.app/Contents/Resources/second.js",
"Second.app/Contents/MacOS/Second",
],
]);
});
});
+20 -10
View File
@@ -149,6 +149,7 @@ export const projectAppleApplication = (
...classified.executables,
...classified.native_libraries,
]),
roots,
);
const uninventoriedImage =
rootFormat === "dmg" && inventory.occurrences.length === 1;
@@ -305,18 +306,27 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => {
return [...families].sort(compare);
};
/** Pair scripts and native code only within the same application root. */
const identifyBridgeCandidates = (
scripts: readonly Component[],
native: readonly Component[],
) => ({
candidates: scripts.flatMap((script) =>
native.map((item) => ({
source_path: script.path,
native_path: item.path,
basis: bridgeBasis(item.path),
})),
),
});
roots: readonly string[],
) => {
const owner = (path: string): string | null =>
roots.find((root) => isWithin(path, root)) ?? null;
return {
candidates: scripts.flatMap((script) => {
const root = owner(script.path);
return native
.filter(({ path }) => owner(path) === root)
.map((item) => ({
source_path: script.path,
native_path: item.path,
basis: bridgeBasis(item.path),
}));
}),
};
};
const bridgeBasis = (
path: string,
@@ -360,7 +370,7 @@ const projectionLimitations = (facts: {
layout === "versioned-framework" && plist === null,
)
? [
"A versioned framework has zero or several Versions/*/Resources/Info.plist files; the one Versions/Current selects is unknown because symlink targets are not inventoried.",
"A versioned framework has several version directories or no Versions/*/Resources/Info.plist; the Info.plist that Versions/Current selects is unknown because symlink targets are not inventoried.",
]
: []),
...(facts.symlinks
+14 -2
View File
@@ -141,14 +141,18 @@ export const detectBundles = (
interface Tree {
readonly children: ReadonlyMap<string, ReadonlySet<string>>;
readonly files: ReadonlyMap<string, Component>;
/** Paths observed as directories, or implied by a deeper path. */
readonly directories: ReadonlySet<string>;
}
const indexTree = (entries: readonly Entry[]): Tree => {
const children = new Map<string, Set<string>>();
const files = new Map<string, Component>();
const directories = new Set<string>();
for (const { path, kind, component } of entries) {
if (isSidecar(path)) continue;
if (kind === "file" && component !== undefined) files.set(path, component);
if (kind === "directory") directories.add(path);
const segments = path.split("/");
let parent = "";
for (const segment of segments) {
@@ -156,9 +160,10 @@ const indexTree = (entries: readonly Entry[]): Tree => {
names.add(segment);
children.set(parent, names);
parent = joinPath(parent, segment);
if (parent !== path) directories.add(parent);
}
}
return { children, files };
return { children, files, directories };
};
const joinPath = (directory: string, name: string): string =>
@@ -223,9 +228,12 @@ const contentDirectories = (
if (layout === "macos-deep") return [joinPath(directory, "Contents")];
if (layout === "shallow") return [directory];
const versions = joinPath(directory, "Versions");
// Only real version directories count: Current is a symlink, and files
// such as .DS_Store are not versions.
return [...(tree.children.get(versions) ?? [])]
.filter((version) => version !== "Current")
.map((version) => joinPath(versions, version))
.filter((version) => tree.directories.has(version))
.sort(compare);
};
@@ -234,7 +242,11 @@ const infoPlistPath = (
layout: Bundle["layout"],
tree: Tree,
): string | null => {
const candidates = contentDirectories(bundle, layout, tree)
const directories = contentDirectories(bundle, layout, tree);
// With several real versions, Versions/Current (a symlink outside the
// inventory) decides which plist applies, so the plist stays unknown.
if (directories.length !== 1) return null;
const candidates = directories
.map((directory) =>
joinPath(
directory,
@@ -149,6 +149,31 @@ describe("JavaScript semantic analysis: dataflow 1", () => {
});
});
describe("JavaScript semantic call result flows", () => {
it("links only directly assigned scalar call results", () => {
const ir = analyzeJavaScriptSemantics(`
function run() {
const direct = produce();
const { field } = unpack();
const nested = outer(inner());
return direct;
}
`);
const bindingNames = new Map(
ir.bindings.map(({ bindingId, name }) => [bindingId, name]),
);
expect(
ir.callResultFlows.map(({ bindingId }) => bindingNames.get(bindingId)),
).toEqual(["direct", "nested"]);
expect(ir.callSites).toHaveLength(4);
expect(ir.callResultFlows.map(({ callSiteId }) => callSiteId)).toEqual([
ir.callSites[0]?.callSiteId,
ir.callSites[2]?.callSiteId,
]);
});
});
describe("JavaScript semantic analysis: read-modify-write", () => {
it.each(["+= 1", "++", "--", "||= 1", "&&= 1", "??= 1"])(
"retains the property read in a read-modify-write operation: %s",
@@ -183,9 +183,7 @@ const collectCallResultFlow = (
context.ancestors,
context.state,
).filter(({ projectionPath }) => projectionPath.length === 0)) {
const binding = [...context.state.bindingsById.values()].find(
({ bindingId }) => bindingId === assigned.bindingId,
);
const binding = context.state.bindingsById.get(assigned.bindingId);
if (binding === undefined) continue;
const identifierRange = range(assigned.identifier);
const definition = binding.definitions.find(
@@ -51,3 +51,16 @@ it("preserves source-tree enter/exit order and detached ancestor snapshots", ()
],
]);
});
it("skips sparse child slots without disturbing sibling visit order", () => {
const parsed = parseJavaScriptSource("const values = [first, , second];");
if (parsed === null)
throw new TypeError("Expected a parsed JavaScript fixture");
const identifiers: string[] = [];
traverseJavaScriptAst(parsed.program, {
enter: (node) => {
if (t.isIdentifier(node)) identifiers.push(node.name);
},
});
expect(identifiers).toEqual(["values", "first", "second"]);
});
@@ -51,11 +51,19 @@ interface TraversalFrame {
}
const childNodes = (node: t.Node): t.Node[] => {
const keys: readonly string[] = t.VISITOR_KEYS[node.type] ?? [];
return keys.flatMap((key) => {
const keys = t.VISITOR_KEYS[node.type];
if (keys === undefined) return [];
const children: t.Node[] = [];
for (const key of keys) {
const value: unknown = Reflect.get(node, key);
if (t.isNode(value)) return [value];
if (!Array.isArray(value)) return [];
return value.filter((item): item is t.Node => t.isNode(item));
});
if (t.isNode(value)) {
children.push(value);
continue;
}
if (!Array.isArray(value)) continue;
for (const item of value) {
if (t.isNode(item)) children.push(item);
}
}
return children;
};
@@ -166,6 +166,7 @@ describe("Interface Builder dispatch joins", () => {
const joined = joinInterfaceBuilderDispatch(ui.graph, {
objc_classes: [],
objc_protocols: [],
objc_categories: [],
swift_decls: [],
objc_ivars: [],
objc_protocol_records: [],
@@ -227,6 +228,7 @@ describe("Interface Builder dispatch joins", () => {
const metadata = {
objc_classes: [],
objc_protocols: [],
objc_categories: [],
swift_decls: [],
objc_ivars: [],
objc_protocol_records: [],
@@ -304,6 +306,7 @@ describe("placeholder dispatch joins", () => {
const joined = joinInterfaceBuilderDispatch(ui.graph, {
objc_classes: [],
objc_protocols: [],
objc_categories: [],
swift_decls: [],
objc_ivars: [],
objc_protocol_records: [],
+18
View File
@@ -156,6 +156,8 @@ export const objcDispatchImplementationSchema = z.strictObject({
selector: z.string().min(1),
method_type: z.enum(["instance", "class"]),
implementation_address: z.string().nullable(),
/** Category that adds this method, when it is not defined by the class itself. */
category: z.string().nullable().optional(),
location: nativeMetadataLocationSchema,
decode: nativeMetadataDecodeSchema,
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
@@ -164,6 +166,21 @@ export type ObjcDispatchImplementation = z.infer<
typeof objcDispatchImplementationSchema
>;
/** Objective-C category: methods, protocols and properties added to a class. */
export const objcCategorySchema = z.strictObject({
name: z.string().min(1),
/** Extended class; external classes come from the category's bind symbol. */
class_name: z.string().min(1).nullable(),
class_source: z.enum(["local", "external", "unresolved"]),
instance_methods: z.array(z.string()),
class_methods: z.array(z.string()),
protocols: z.array(z.string()),
properties: z.array(objcPropertySchema),
location: nativeMetadataLocationSchema,
decode: nativeMetadataDecodeSchema,
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
});
/** Swift protocol conformance and its associated witness-table location. */
export const swiftConformanceSchema = z.strictObject({
type_name: z.string().min(1),
@@ -279,6 +296,7 @@ export type DbSaveResult = z.infer<typeof dbSaveResultSchema>;
export const objcSwiftMetadataSchema = z.strictObject({
objc_classes: z.array(objcClassSchema).default([]),
objc_protocols: z.array(objcProtocolSchema).default([]),
objc_categories: z.array(objcCategorySchema).default([]),
swift_decls: z.array(swiftDeclSchema).default([]),
objc_ivars: z.array(objcIvarSchema).default([]),
objc_protocol_records: z.array(objcProtocolRecordSchema).default([]),
+1 -1
View File
@@ -30,7 +30,7 @@ interface GeneratedPayload {
}
const GENERATED_PAYLOAD_JSON =
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
if (!isGeneratedPayload(generatedPayload))
throw new TypeError("Generated MCP catalog payload is invalid");
@@ -151,6 +151,9 @@ describe("source-map layout admission", () => {
expect(() => trace({ ...leaf, mappings })).toThrow();
},
);
it("validates regular-map source indexes after reusing its decoded rows", () => {
expect(() => trace({ ...leaf, mappings: "ACAA" })).toThrow(/index/u);
});
it("rejects huge indexed offsets before upstream row allocation", () => {
expect(() =>
trace({
+3 -1
View File
@@ -81,10 +81,12 @@ const decodeValidatedMap = (
inspected: ReturnType<typeof inspectSourceMap>,
url: string,
) => {
let regularRows: ReturnType<typeof decodedMappings> | undefined;
for (const leaf of inspected.leaves) {
const decoded = decodedMappings(
new TraceMap(JSON.stringify(leaf.map), url),
);
if (inspected.format === "regular") regularRows = decoded;
for (const [line, row] of decoded.entries())
for (const segment of row) {
if (
@@ -112,7 +114,7 @@ const decodeValidatedMap = (
}
}
const map = new AnyMap(inspected.jsonText, url);
const rows = decodedMappings(map);
const rows = regularRows ?? decodedMappings(map);
for (const row of rows)
for (const segment of row) {
if (
+82
View File
@@ -0,0 +1,82 @@
import type { ObjcSwiftMetadata } from "../domain/objcSwiftMetadata.js";
import type { PointerFixups } from "./AppleMachoFixups.js";
const describeFixups = (fixups: PointerFixups): string => {
if (fixups.kind === "chained")
return `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}`;
return fixups.kind === "dyld-info"
? "LC_DYLD_INFO bind opcodes"
: "no fixup load commands";
};
/** Append the per-facet coverage of one Apple dispatch metadata decode. */
export const pushDispatchCoverage = (input: {
readonly result: ObjcSwiftMetadata;
readonly failures: readonly string[];
readonly examined: number;
readonly categoriesExamined: number;
readonly truncated: boolean;
readonly fixups: PointerFixups;
}): void => {
const { result, failures, examined, categoriesExamined, truncated, fixups } =
input;
result.coverage.push({
facet: "objc_class_method_ivar_metadata",
status: failures.length > 0 || truncated ? "partial" : "complete",
reason:
[...failures, ...(truncated ? ["max_records_reached"] : [])].join("; ") ||
null,
examined,
decoded: result.objc_classes.length,
});
result.coverage.push({
facet: "binary_relative_pointers",
status:
truncated ||
result.relative_pointers.some((item) => item.decode.status !== "decoded")
? "partial"
: "complete",
reason: truncated
? "max_records_reached"
: result.relative_pointers.some(
(item) => item.decode.status !== "decoded",
)
? "relative_pointer_targets_unresolved"
: null,
examined: result.relative_pointers.length,
decoded: result.relative_pointers.filter(
(item) => item.decode.status === "decoded",
).length,
});
const categoryFailures = failures.filter((failure) =>
/^(?:Properties of|Category at)/u.test(failure),
);
result.coverage.push({
facet: "objc_properties_categories",
status:
categoryFailures.length > 0 ||
result.objc_categories.some(({ decode }) => decode.status !== "decoded")
? "partial"
: "complete",
reason: categoryFailures.join("; ") || null,
examined: categoriesExamined,
decoded: result.objc_categories.filter(
({ decode }) => decode.status === "decoded",
).length,
});
result.coverage.push({
facet: "pointer_fixups",
status: fixups.failures.length > 0 ? "partial" : "complete",
reason: [describeFixups(fixups), ...fixups.failures].join("; "),
examined: 0,
decoded: 0,
});
result.coverage.push({
facet: "swift_generic_resilient_witnesses_overrides_async_coroutines",
status: "unsupported",
reason:
"This reader admits simple Swift conformance/static witness records and non-generic, non-resilient class vtables; generic/resilient tables and other Swift metadata families are not decoded",
examined: 0,
decoded: 0,
});
};
+30 -43
View File
@@ -9,6 +9,8 @@ import {
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import { pushDispatchCoverage } from "./AppleDispatchCoverage.js";
import { parsePointerFixups } from "./AppleMachoFixups.js";
import { parseMachoLayout } from "./AppleMachoSelection.js";
import { decodeObjcDispatchFacets } from "./AppleObjcDispatchFacets.js";
import {
@@ -25,10 +27,26 @@ export const decodeAppleDispatchMetadata = (
provenance: { path: string; sha256: string },
architecture = "arm64",
): ObjcSwiftMetadata => {
const { segments, sections, offset } = parseMachoLayout(bytes, architecture);
const layout = parseMachoLayout(bytes, architecture);
const { segments, sections, offset } = layout;
const fixups = parsePointerFixups(
bytes,
layout.fixups,
segments,
layout.baseAddress,
);
const u32 = (address: bigint) => bytes.readUInt32LE(offset(address, 4));
const pointer = (address: bigint) =>
bytes.readBigUInt64LE(offset(address, 8));
const decodePointer = (address: bigint) =>
fixups.decode(address, bytes.readBigUInt64LE(offset(address, 8)));
/** Rebased target of a pointer; a bind to another image reads as 0. */
const pointer = (address: bigint) => {
const decodedPointer = decodePointer(address);
return decodedPointer.kind === "rebase" ? decodedPointer.target : 0n;
};
const bound = (address: bigint) => {
const decodedPointer = decodePointer(address);
return decodedPointer.kind === "bind" ? decodedPointer : undefined;
};
const location = (address: bigint) => ({
address: `0x${address.toString(16)}`,
file_offset: offset(address),
@@ -73,13 +91,15 @@ export const decodeAppleDispatchMetadata = (
const readers = {
u32,
i32: (address: bigint) => bytes.readInt32LE(offset(address, 4)),
u64: (address: bigint) => bytes.readBigUInt64LE(offset(address, 8)),
pointer,
bound,
string,
location,
evidence,
offset,
};
const { failures, examined } = decodeObjcDispatchFacets({
const { failures, examined, categoriesExamined } = decodeObjcDispatchFacets({
bytes,
sections,
segments,
@@ -130,47 +150,14 @@ export const decodeAppleDispatchMetadata = (
),
},
});
result.coverage.push({
facet: "objc_class_method_ivar_metadata",
status: failures.length > 0 || budget.truncated ? "partial" : "complete",
reason:
[...failures, ...(budget.truncated ? ["max_records_reached"] : [])].join(
"; ",
) || null,
pushDispatchCoverage({
result,
failures,
examined,
decoded: result.objc_classes.length,
categoriesExamined,
truncated: budget.truncated,
fixups,
});
result.coverage.push({
facet: "binary_relative_pointers",
status:
budget.truncated ||
result.relative_pointers.some((item) => item.decode.status !== "decoded")
? "partial"
: "complete",
reason: budget.truncated
? "max_records_reached"
: result.relative_pointers.some(
(item) => item.decode.status !== "decoded",
)
? "relative_pointer_targets_unresolved"
: null,
examined: result.relative_pointers.length,
decoded: result.relative_pointers.filter(
(item) => item.decode.status === "decoded",
).length,
});
for (const facet of [
"objc_properties_categories",
"swift_generic_resilient_witnesses_overrides_async_coroutines",
])
result.coverage.push({
facet,
status: "unsupported",
reason:
"This reader admits validated 64-bit Objective-C class/ivar/method lists and simple Swift conformance/static witness records; chained fixups, generic/resilient tables and other metadata families are not decoded",
examined: 0,
decoded: 0,
});
return objcSwiftMetadataSchema.parse(result);
};
+275
View File
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import {
decodeChainedPointer,
parsePointerFixups,
} from "./AppleMachoFixups.js";
import { parsePropertyAttributes } from "./AppleObjcProperties.js";
import type { Segment } from "./AppleMachoSelection.js";
const BASE = 0x100000000n;
const IMPORTS = [
{
symbol: "_OBJC_CLASS_$_NSObject",
library: "/usr/lib/libobjc.A.dylib",
weak: false,
addend: 0n,
},
{
symbol: "_free",
library: "/usr/lib/libSystem.B.dylib",
weak: true,
addend: 4n,
},
];
const field = (value: bigint, from: number): bigint => value << BigInt(from);
describe("chained pointer decoding", () => {
it("decodes DYLD_CHAINED_PTR_64 and _64_OFFSET rebases and binds", () => {
const rebase = 0x4000n | field(0x80n, 36) | field(3n, 51);
expect(decodeChainedPointer(BASE + 0x4000n, 2, BASE, IMPORTS)).toEqual({
kind: "rebase",
target: BASE + 0x4000n,
});
expect(decodeChainedPointer(rebase, 6, BASE, IMPORTS)).toEqual({
kind: "rebase",
target: (0x80n << 56n) + BASE + 0x4000n,
});
const bind = 1n | field(2n, 24) | field(1n, 63);
expect(decodeChainedPointer(bind, 6, BASE, IMPORTS)).toEqual({
kind: "bind",
symbol: "_free",
library: "/usr/lib/libSystem.B.dylib",
weak: true,
addend: 6n,
});
});
it("decodes arm64e plain, authenticated and 24-bit binds", () => {
expect(decodeChainedPointer(0x8000n, 9, BASE, IMPORTS)).toEqual({
kind: "rebase",
target: BASE + 0x8000n,
});
expect(decodeChainedPointer(BASE + 0x8000n, 1, BASE, IMPORTS)).toEqual({
kind: "rebase",
target: BASE + 0x8000n,
});
const authRebase = 0x8000n | field(0x1234n, 32) | field(1n, 63);
expect(decodeChainedPointer(authRebase, 1, BASE, IMPORTS)).toEqual({
kind: "rebase",
target: BASE + 0x8000n,
});
const authBind = 0n | field(1n, 62) | field(1n, 63);
expect(decodeChainedPointer(authBind, 9, BASE, IMPORTS)).toMatchObject({
kind: "bind",
symbol: "_OBJC_CLASS_$_NSObject",
addend: 0n,
});
const bind24 = 1n | field(0x7ffffn, 32) | field(1n, 62);
expect(decodeChainedPointer(bind24, 12, BASE, IMPORTS)).toMatchObject({
kind: "bind",
symbol: "_free",
addend: 3n,
});
expect(() =>
decodeChainedPointer(field(1n, 62) | 9n, 9, BASE, IMPORTS),
).toThrow("Chained bind ordinal 9 has no import");
expect(() => decodeChainedPointer(1n, 3, BASE, IMPORTS)).toThrow(
"Unsupported chained pointer format 3",
);
});
});
/** Build `LC_DYLD_CHAINED_FIXUPS` data with one fixup segment and the given imports. */
const chainedData = (format: 1 | 2 | 3): Buffer => {
const symbols = Buffer.from("\0_OBJC_CLASS_$_NSObject\0_free\0");
const importSize = format === 3 ? 16 : format === 2 ? 8 : 4;
const imports = Buffer.alloc(importSize * 2);
const entry = (
index: number,
{
ordinal,
weak,
nameOffset,
addend,
}: {
ordinal: number;
weak: boolean;
nameOffset: number;
addend: number;
},
) => {
const at = index * importSize;
if (format === 3) {
imports.writeBigUInt64LE(
BigInt(ordinal & 0xffff) |
(BigInt(weak ? 1 : 0) << 16n) |
(BigInt(nameOffset) << 32n),
at,
);
imports.writeBigInt64LE(BigInt(addend), at + 8);
} else {
imports.writeUInt32LE(
((nameOffset << 9) | ((weak ? 1 : 0) << 8) | (ordinal & 0xff)) >>> 0,
at,
);
if (format === 2) imports.writeInt32LE(addend, at + 4);
}
};
entry(0, { ordinal: 1, weak: false, nameOffset: 1, addend: 0 });
entry(1, { ordinal: 2, weak: true, nameOffset: 24, addend: 7 });
// starts_in_image: two segments, only the second has fixups.
const starts = Buffer.alloc(12 + 24);
starts.writeUInt32LE(2, 0);
starts.writeUInt32LE(0, 4);
starts.writeUInt32LE(12, 8);
starts.writeUInt32LE(24, 12);
starts.writeUInt16LE(0x4000, 16);
starts.writeUInt16LE(6, 18);
const header = Buffer.alloc(28);
header.writeUInt32LE(0, 0);
header.writeUInt32LE(28, 4);
header.writeUInt32LE(28 + starts.length, 8);
header.writeUInt32LE(28 + starts.length + imports.length, 12);
header.writeUInt32LE(2, 16);
header.writeUInt32LE(format, 20);
header.writeUInt32LE(0, 24);
return Buffer.concat([header, starts, imports, symbols]);
};
const SEGMENTS: Segment[] = [
{ address: BASE, size: 0x4000n, offset: 0, executable: true },
{ address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false },
];
describe("pointer fixup tables", () => {
for (const format of [1, 2, 3] as const)
it(`reads chained import format ${format}`, () => {
const data = chainedData(format);
const fixups = parsePointerFixups(
data,
{
chained: { offset: 0, size: data.length },
binds: [],
dylibs: ["/usr/lib/libobjc.A.dylib", "/usr/lib/libSystem.B.dylib"],
},
SEGMENTS,
BASE,
);
expect(fixups).toMatchObject({
kind: "chained",
formats: ["DYLD_CHAINED_PTR_64_OFFSET"],
});
expect(fixups.decode(BASE + 0x4008n, 1n | field(1n, 63))).toMatchObject({
kind: "bind",
symbol: "_free",
library: "/usr/lib/libSystem.B.dylib",
weak: true,
addend: format === 1 ? 0n : 7n,
});
// Pointers outside fixup segments are stored plainly.
expect(fixups.decode(BASE + 0x10n, 0x1234n)).toEqual({
kind: "rebase",
target: 0x1234n,
});
});
it("interprets legacy bind, weak-bind and lazy-bind opcodes", () => {
const opcodes = Buffer.from([
0x11, // SET_DYLIB_ORDINAL_IMM 1
0x40,
...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
0x51, // SET_TYPE_IMM pointer
0x71,
0x08, // SET_SEGMENT_AND_OFFSET segment 1 + 8
0x90, // DO_BIND
0xc0,
0x02,
0x08, // DO_BIND_ULEB_TIMES_SKIPPING_ULEB 2 times, skip 8
0x00,
]);
const lazy = Buffer.from([
0x71,
0x30,
0x12,
0x40,
...Buffer.from("_free\0"),
0x90,
0x00,
0x71,
0x38,
0x12,
0x40,
...Buffer.from("_malloc\0"),
0x90,
0x00,
]);
const data = Buffer.concat([opcodes, lazy]);
const fixups = parsePointerFixups(
data,
{
chained: null,
binds: [
{ offset: 0, size: opcodes.length, stream: "bind" },
{ offset: opcodes.length, size: lazy.length, stream: "lazy" },
],
dylibs: ["/usr/lib/libobjc.A.dylib", "/usr/lib/libSystem.B.dylib"],
},
SEGMENTS,
BASE,
);
expect(fixups.kind).toBe("dyld-info");
expect(fixups.failures).toEqual([]);
for (const offset of [0x08n, 0x10n, 0x20n])
expect(fixups.decode(BASE + 0x4000n + offset, 0n)).toMatchObject({
kind: "bind",
symbol: "_OBJC_CLASS_$_NSObject",
library: "/usr/lib/libobjc.A.dylib",
});
expect(fixups.decode(BASE + 0x4038n, 0n)).toMatchObject({
symbol: "_malloc",
library: "/usr/lib/libSystem.B.dylib",
});
expect(fixups.decode(BASE + 0x4018n, 0x99n)).toEqual({
kind: "rebase",
target: 0x99n,
});
});
});
describe("Objective-C property attributes", () => {
it("parses type, ownership, accessors and quoted class names", () => {
expect(
parsePropertyAttributes(
"title",
'T@"NSString<NSCopying, NSSecureCoding>",C,N,GcustomTitle,V_title',
),
).toEqual({
name: "title",
type_encoding: '@"NSString<NSCopying, NSSecureCoding>"',
attributes: [
expect.objectContaining({
name: "T",
value: '@"NSString<NSCopying, NSSecureCoding>"',
}),
expect.objectContaining({ name: "C", is_copy: true }),
expect.objectContaining({ name: "N" }),
expect.objectContaining({ name: "G", value: "customTitle" }),
expect.objectContaining({ name: "V", value: "_title" }),
],
is_readonly: false,
getter: "customTitle",
setter: null,
});
expect(parsePropertyAttributes("count", "Tq,R,N")).toMatchObject({
type_encoding: "q",
is_readonly: true,
});
expect(
parsePropertyAttributes("delegate", "T@,W,N,V_delegate").attributes[1],
).toMatchObject({
is_weak: true,
});
});
});
+414
View File
@@ -0,0 +1,414 @@
import type { Segment } from "./AppleMachoSelection.js";
/** Where a Mach-O stores pointer fixups, from its load commands. */
export interface FixupCommands {
/** Absolute file range of `LC_DYLD_CHAINED_FIXUPS` data. */
readonly chained: { readonly offset: number; readonly size: number } | null;
/** Absolute file ranges of `LC_DYLD_INFO(_ONLY)` bind streams. */
readonly binds: readonly {
readonly offset: number;
readonly size: number;
readonly stream: "bind" | "weak" | "lazy";
}[];
/** Install names of dependent dylibs, in library-ordinal order. */
readonly dylibs: readonly string[];
}
/** One decoded pointer: a rebase to an in-image address or a bind to a symbol. */
export type DecodedPointer =
| { readonly kind: "rebase"; readonly target: bigint }
| {
readonly kind: "bind";
readonly symbol: string;
readonly library: string | null;
readonly weak: boolean;
readonly addend: bigint;
};
/** Pointer decoding for one Mach-O slice. */
export interface PointerFixups {
readonly kind: "chained" | "dyld-info" | "none";
/** Chained pointer formats in use, such as `DYLD_CHAINED_PTR_64_OFFSET`. */
readonly formats: readonly string[];
readonly failures: readonly string[];
/** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */
decode(address: bigint, raw: bigint): DecodedPointer;
}
const POINTER_FORMATS: Readonly<Record<number, string>> = {
1: "DYLD_CHAINED_PTR_ARM64E",
2: "DYLD_CHAINED_PTR_64",
6: "DYLD_CHAINED_PTR_64_OFFSET",
9: "DYLD_CHAINED_PTR_ARM64E_USERLAND",
12: "DYLD_CHAINED_PTR_ARM64E_USERLAND24",
};
const bits = (value: bigint, from: number, width: number): bigint =>
(value >> BigInt(from)) & ((1n << BigInt(width)) - 1n);
const signed = (value: bigint, width: number): bigint =>
value >= 1n << BigInt(width - 1) ? value - (1n << BigInt(width)) : value;
interface Import {
readonly symbol: string;
readonly library: string | null;
readonly weak: boolean;
readonly addend: bigint;
}
/** Library ordinal to install name; 0 and negative ordinals are special lookups. */
const libraryName = (
ordinal: number,
dylibs: readonly string[],
): string | null => {
if (ordinal > 0) return dylibs[ordinal - 1] ?? null;
return null;
};
const cString = (bytes: Buffer, offset: number, end: number): string => {
const stop = bytes.indexOf(0, offset);
if (offset < 0 || offset >= end || stop < 0 || stop >= end)
throw new RangeError("Fixup symbol name is outside the symbol pool");
return bytes.toString("utf8", offset, stop);
};
const readImports = (
data: {
readonly bytes: Buffer;
readonly base: number;
readonly end: number;
readonly dylibs: readonly string[];
},
header: {
readonly offset: number;
readonly count: number;
readonly format: number;
readonly symbols: number;
},
): Import[] => {
const { bytes, base, end, dylibs } = data;
const size = header.format === 3 ? 16 : header.format === 2 ? 8 : 4;
if (header.format < 1 || header.format > 3)
throw new RangeError(`Unsupported chained import format ${header.format}`);
if (base + header.offset + header.count * size > end)
throw new RangeError("Chained import table exceeds the fixup data");
return Array.from({ length: header.count }, (_, index) => {
const at = base + header.offset + index * size;
if (header.format === 3) {
const word = bytes.readBigUInt64LE(at);
const ordinal = Number(signed(bits(word, 0, 16), 16));
return {
symbol: cString(
bytes,
base + header.symbols + Number(bits(word, 32, 32)),
end,
),
library: libraryName(ordinal, dylibs),
weak: bits(word, 16, 1) === 1n,
addend: bytes.readBigInt64LE(at + 8),
};
}
const word = bytes.readUInt32LE(at);
const ordinal = Number(signed(BigInt(word & 0xff), 8));
return {
symbol: cString(bytes, base + header.symbols + (word >>> 9), end),
library: libraryName(ordinal, dylibs),
weak: ((word >>> 8) & 1) === 1,
addend: header.format === 2 ? BigInt(bytes.readInt32LE(at + 4)) : 0n,
};
});
};
/** Decode one raw chained pointer for its segment's pointer format. */
export const decodeChainedPointer = (
raw: bigint,
format: number,
baseAddress: bigint,
imports: readonly Import[],
): DecodedPointer => {
const bind = (ordinal: bigint, addend: bigint): DecodedPointer => {
const target = imports[Number(ordinal)];
if (target === undefined)
throw new RangeError(`Chained bind ordinal ${ordinal} has no import`);
return { kind: "bind", ...target, addend: target.addend + addend };
};
if (format === 2 || format === 6) {
if (bits(raw, 63, 1) === 1n)
return bind(bits(raw, 0, 24), bits(raw, 24, 8));
const target = bits(raw, 0, 36) | (bits(raw, 36, 8) << 56n);
return {
kind: "rebase",
target: format === 6 ? target + baseAddress : target,
};
}
if (format === 1 || format === 9 || format === 12) {
const auth = bits(raw, 63, 1) === 1n;
if (bits(raw, 62, 1) === 1n)
return bind(
bits(raw, 0, format === 12 ? 24 : 16),
auth ? 0n : signed(bits(raw, 32, 19), 19),
);
if (auth) return { kind: "rebase", target: bits(raw, 0, 32) + baseAddress };
const target = bits(raw, 0, 43) | (bits(raw, 43, 8) << 56n);
return {
kind: "rebase",
target: format === 1 ? target : target + baseAddress,
};
}
throw new RangeError(`Unsupported chained pointer format ${format}`);
};
/** The slice bytes and load-command facts that fixup decoding reads. */
interface FixupImage {
readonly bytes: Buffer;
readonly segments: readonly Segment[];
readonly dylibs: readonly string[];
readonly baseAddress: bigint;
}
const chainedFixups = (
image: FixupImage,
range: { readonly offset: number; readonly size: number },
): PointerFixups => {
const { bytes, segments, dylibs, baseAddress } = image;
const base = range.offset;
const end = range.offset + range.size;
if (range.size < 28 || end > bytes.length)
throw new RangeError("Chained fixup header exceeds the target");
const startsOffset = bytes.readUInt32LE(base + 4);
const imports = readImports(
{ bytes, base, end, dylibs },
{
offset: bytes.readUInt32LE(base + 8),
symbols: bytes.readUInt32LE(base + 12),
count: bytes.readUInt32LE(base + 16),
format: bytes.readUInt32LE(base + 20),
},
);
if (bytes.readUInt32LE(base + 24) !== 0)
throw new RangeError(
"Compressed chained-fixup symbol names are not supported",
);
const starts = base + startsOffset;
const segmentCount = bytes.readUInt32LE(starts);
const ranges: {
readonly from: bigint;
readonly to: bigint;
readonly format: number;
}[] = [];
for (let index = 0; index < segmentCount; index++) {
const infoOffset = bytes.readUInt32LE(starts + 4 + index * 4);
if (infoOffset === 0) continue;
const segment = segments[index];
if (segment === undefined || starts + infoOffset + 22 > end)
throw new RangeError("Chained fixup segment table is out of range");
ranges.push({
from: segment.address,
to: segment.address + segment.size,
format: bytes.readUInt16LE(starts + infoOffset + 6),
});
}
const formats = [
...new Set(
ranges.map(({ format }) => POINTER_FORMATS[format] ?? `format-${format}`),
),
];
return {
kind: "chained",
formats,
failures: [],
decode(address, raw) {
const segment = ranges.find(
({ from, to }) => address >= from && address < to,
);
// Pointers outside fixup segments, such as in __TEXT, are stored plainly.
if (segment === undefined) return { kind: "rebase", target: raw };
if (raw === 0n) return { kind: "rebase", target: 0n };
return decodeChainedPointer(raw, segment.format, baseAddress, imports);
},
};
};
const uleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
let value = 0n;
let shift = 0n;
for (;;) {
if (cursor.at >= end) throw new RangeError("Truncated bind opcode operand");
const byte = bytes[cursor.at++] ?? 0;
value |= BigInt(byte & 0x7f) << shift;
shift += 7n;
if ((byte & 0x80) === 0) return value;
if (shift > 63n) throw new RangeError("Oversized bind opcode operand");
}
};
const sleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => {
let value = 0n;
let shift = 0n;
let byte = 0;
do {
if (cursor.at >= end) throw new RangeError("Truncated bind opcode operand");
byte = bytes[cursor.at++] ?? 0;
value |= BigInt(byte & 0x7f) << shift;
shift += 7n;
} while ((byte & 0x80) !== 0 && shift <= 63n);
return (byte & 0x40) !== 0 ? value - (1n << shift) : value;
};
/** Registers of the `LC_DYLD_INFO` bind state machine. */
interface BindState {
ordinal: number;
symbol: string;
weak: boolean;
addend: bigint;
address: bigint;
}
/** Apply a bind opcode that sets a register; false when the opcode is not one. */
const setBindRegister = (
image: FixupImage,
stream: { readonly cursor: { at: number }; readonly end: number },
state: BindState,
byte: number,
): boolean => {
const { bytes } = image;
const { cursor, end } = stream;
const immediate = byte & 0x0f;
switch (byte & 0xf0) {
case 0x10:
state.ordinal = immediate;
return true;
case 0x20:
state.ordinal = Number(uleb(bytes, cursor, end));
return true;
case 0x30:
state.ordinal = immediate === 0 ? 0 : immediate - 16;
return true;
case 0x40: {
const stop = bytes.indexOf(0, cursor.at);
if (stop < 0 || stop >= end)
throw new RangeError("Unterminated bind symbol");
state.symbol = bytes.toString("utf8", cursor.at, stop);
state.weak = (immediate & 0x1) !== 0;
cursor.at = stop + 1;
return true;
}
case 0x50:
return true;
case 0x60:
state.addend = sleb(bytes, cursor, end);
return true;
case 0x70: {
const segment = image.segments[immediate];
if (segment === undefined)
throw new RangeError("Bind segment index is out of range");
state.address = segment.address + uleb(bytes, cursor, end);
return true;
}
case 0x80:
// ULEB deltas wrap at 64 bits to encode negative steps.
state.address =
(state.address + uleb(bytes, cursor, end)) & 0xffffffffffffffffn;
return true;
default:
return false;
}
};
/** Interpret one `LC_DYLD_INFO` bind opcode stream into address -> import. */
const readBindStream = (
image: FixupImage,
range: FixupCommands["binds"][number],
binds: Map<bigint, Import>,
): void => {
const { bytes } = image;
const end = range.offset + range.size;
if (end > bytes.length)
throw new RangeError("Bind opcodes exceed the target");
const cursor = { at: range.offset };
const state: BindState = {
ordinal: 0,
symbol: "",
weak: false,
addend: 0n,
address: 0n,
};
const bind = (skip: bigint): void => {
binds.set(state.address, {
symbol: state.symbol,
library:
range.stream === "weak"
? null
: libraryName(state.ordinal, image.dylibs),
weak: state.weak,
addend: state.addend,
});
state.address += 8n + skip;
};
while (cursor.at < end) {
const byte = bytes[cursor.at++] ?? 0;
if (setBindRegister(image, { cursor, end }, state, byte)) continue;
switch (byte & 0xf0) {
case 0x00:
// DONE separates lazy-bind entries; other streams end here.
if (range.stream !== "lazy") return;
break;
case 0x90:
bind(0n);
break;
case 0xa0:
bind(uleb(bytes, cursor, end));
break;
case 0xb0:
bind(BigInt(byte & 0x0f) * 8n);
break;
case 0xc0: {
const count = uleb(bytes, cursor, end);
const skip = uleb(bytes, cursor, end);
for (let index = 0n; index < count; index++) bind(skip);
break;
}
default:
throw new RangeError(`Unsupported bind opcode 0x${byte.toString(16)}`);
}
}
};
/** Build pointer decoding for one slice from its fixup load commands. */
export const parsePointerFixups = (
bytes: Buffer,
commands: FixupCommands,
segments: readonly Segment[],
baseAddress: bigint,
): PointerFixups => {
const image = { bytes, segments, dylibs: commands.dylibs, baseAddress };
if (commands.chained !== null) return chainedFixups(image, commands.chained);
if (commands.binds.length === 0)
return {
kind: "none",
formats: [],
failures: [],
decode: (_address, raw) => ({ kind: "rebase", target: raw }),
};
const binds = new Map<bigint, Import>();
const failures: string[] = [];
for (const stream of commands.binds)
try {
readBindStream(image, stream, binds);
} catch (cause: unknown) {
failures.push(
`${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
return {
kind: "dyld-info",
formats: [],
failures,
decode(address, raw) {
const bound = binds.get(address);
return bound === undefined
? { kind: "rebase", target: raw }
: { kind: "bind", ...bound };
},
};
};
+131 -38
View File
@@ -1,3 +1,5 @@
import type { FixupCommands } from "./AppleMachoFixups.js";
/** One mapped Mach-O segment with file-backed VA range. */
export interface Segment {
address: bigint;
@@ -23,6 +25,10 @@ export interface MachoSlice {
export interface MachoLayout {
segments: Segment[];
sections: Section[];
/** Fixup load commands and dependent dylibs of the selected slice. */
fixups: FixupCommands;
/** VM address of the segment that maps file offset 0 (the preferred load address). */
baseAddress: bigint;
/** Map a virtual address range onto a file offset within the selected slice. */
offset(address: bigint, size?: number): number;
}
@@ -112,48 +118,31 @@ export const parseMachoLayout = (
const commandEnd = slice + 32 + bytes.readUInt32LE(slice + 20);
if (commands > 4096 || commandEnd > sliceEnd)
throw new RangeError("Malformed Mach-O command bounds");
const segments: Segment[] = [];
const sections: Section[] = [];
const state: LayoutState = {
slice,
sliceEnd,
segments: [],
sections: [],
dylibs: [],
binds: [],
chained: null,
baseAddress: undefined,
};
let cursor = slice + 32;
for (let index = 0; index < commands; index++) {
if (cursor + 8 > commandEnd) throw new RangeError("Truncated load command");
const kind = bytes.readUInt32LE(cursor),
size = bytes.readUInt32LE(cursor + 4);
if (size < 8 || cursor + size > commandEnd)
const command = {
cursor,
kind: bytes.readUInt32LE(cursor),
size: bytes.readUInt32LE(cursor + 4),
};
if (command.size < 8 || cursor + command.size > commandEnd)
throw new RangeError("Invalid load command size");
if (kind === 0x19) {
if (size < 72) throw new RangeError("Truncated segment command");
const address = bytes.readBigUInt64LE(cursor + 24),
fileSize = bytes.readBigUInt64LE(cursor + 48),
fileOffset = bytes.readBigUInt64LE(cursor + 40);
if (fileOffset + fileSize > BigInt(sliceEnd - slice))
throw new RangeError("Segment file range exceeds target bytes");
segments.push({
address,
size: fileSize,
offset: slice + Number(fileOffset),
executable: (bytes.readUInt32LE(cursor + 60) & 4) !== 0,
});
const count = bytes.readUInt32LE(cursor + 64);
if (72 + count * 80 > size)
throw new RangeError("Truncated section table");
for (let section = 0; section < count; section++) {
const position = cursor + 72 + section * 80;
const sectionSize = bytes.readBigUInt64LE(position + 40);
if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER))
throw new RangeError("Section size exceeds exact numeric range");
sections.push({
name: bytes
.subarray(position, position + 16)
.toString("ascii")
.replace(/\0.*$/u, ""),
address: bytes.readBigUInt64LE(position + 32),
size: Number(sectionSize),
});
}
}
cursor += size;
if (command.kind === 0x19) collectSegment(bytes, command, state);
else collectFixupCommand(bytes, command, state);
cursor += command.size;
}
const { segments, sections, chained, binds, dylibs } = state;
const offset = (address: bigint, size = 1): number => {
const matches = segments.filter(
(segment) =>
@@ -168,5 +157,109 @@ export const parseMachoLayout = (
if (segment === undefined) throw new RangeError("Missing metadata segment");
return segment.offset + Number(address - segment.address);
};
return { segments, sections, offset };
return {
segments,
sections,
offset,
fixups: { chained, binds, dylibs },
baseAddress: state.baseAddress ?? 0n,
};
};
/** Load commands and fixup ranges collected while walking one slice. */
interface LayoutState {
readonly slice: number;
readonly sliceEnd: number;
readonly segments: Segment[];
readonly sections: Section[];
readonly dylibs: string[];
readonly binds: FixupCommands["binds"][number][];
chained: FixupCommands["chained"];
baseAddress: bigint | undefined;
}
/** One load command header inside the selected slice. */
interface LoadCommand {
readonly cursor: number;
readonly kind: number;
readonly size: number;
}
/** Record an `LC_SEGMENT_64` and its section table. */
const collectSegment = (
bytes: Buffer,
{ cursor, size }: LoadCommand,
state: LayoutState,
): void => {
if (size < 72) throw new RangeError("Truncated segment command");
const address = bytes.readBigUInt64LE(cursor + 24),
fileSize = bytes.readBigUInt64LE(cursor + 48),
fileOffset = bytes.readBigUInt64LE(cursor + 40);
if (fileOffset + fileSize > BigInt(state.sliceEnd - state.slice))
throw new RangeError("Segment file range exceeds target bytes");
state.segments.push({
address,
size: fileSize,
offset: state.slice + Number(fileOffset),
executable: (bytes.readUInt32LE(cursor + 60) & 4) !== 0,
});
if (fileOffset === 0n && fileSize > 0n) state.baseAddress ??= address;
const count = bytes.readUInt32LE(cursor + 64);
if (72 + count * 80 > size) throw new RangeError("Truncated section table");
for (let section = 0; section < count; section++) {
const position = cursor + 72 + section * 80;
const sectionSize = bytes.readBigUInt64LE(position + 40);
if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER))
throw new RangeError("Section size exceeds exact numeric range");
state.sections.push({
name: bytes
.subarray(position, position + 16)
.toString("ascii")
.replace(/\0.*$/u, ""),
address: bytes.readBigUInt64LE(position + 32),
size: Number(sectionSize),
});
}
};
const LC_DYLD_INFO = 0x22;
const LC_DYLD_INFO_ONLY = 0x80000022;
const LC_DYLD_CHAINED_FIXUPS = 0x80000034;
const DYLIB_COMMANDS: readonly number[] = [
0xc, 0x80000018, 0x8000001f, 0x20, 0x80000023,
];
/** Record fixup data ranges and dylib install names from one load command. */
const collectFixupCommand = (
bytes: Buffer,
{ cursor, kind, size }: LoadCommand,
state: LayoutState,
): void => {
const range = (at: number): { offset: number; size: number } => {
const offset = state.slice + bytes.readUInt32LE(at);
const length = bytes.readUInt32LE(at + 4);
if (offset + length > state.sliceEnd)
throw new RangeError("Fixup data exceeds the Mach-O slice");
return { offset, size: length };
};
if (kind === LC_DYLD_CHAINED_FIXUPS && size >= 16)
state.chained = range(cursor + 8);
else if ((kind === LC_DYLD_INFO || kind === LC_DYLD_INFO_ONLY) && size >= 48)
for (const [at, stream] of [
[cursor + 16, "bind"],
[cursor + 24, "weak"],
[cursor + 32, "lazy"],
] as const) {
const bound = range(at);
if (bound.size > 0) state.binds.push({ ...bound, stream });
}
else if (DYLIB_COMMANDS.includes(kind) && size >= 24) {
const nameOffset = bytes.readUInt32LE(cursor + 8);
const end = bytes.indexOf(0, cursor + nameOffset);
state.dylibs.push(
nameOffset >= size || end < 0 || end > cursor + size
? ""
: bytes.toString("utf8", cursor + nameOffset, end),
);
}
};
+99
View File
@@ -0,0 +1,99 @@
import type { ObjcSwiftMetadata } from "../domain/objcSwiftMetadata.js";
import type { Section } from "./AppleMachoSelection.js";
import type { ObjcDispatchReaders } from "./AppleObjcDispatchFacets.js";
import { readObjcPropertiesOf } from "./AppleObjcProperties.js";
const CLASS_SYMBOL = /^_OBJC_(?:META)?CLASS_\$_(.+)$/u;
/** External class name from a bound `_OBJC_CLASS_$_Name` symbol. */
export const boundClassName = (
read: Pick<ObjcDispatchReaders, "bound">,
field: bigint,
): string | undefined => {
const bound = read.bound(field);
return bound === undefined ? undefined : CLASS_SYMBOL.exec(bound.symbol)?.[1];
};
/**
* Decode `__objc_catlist` `category_t` records:
* name, cls, instanceMethods, classMethods, protocols, instanceProperties.
*/
export const decodeObjcCategories = (input: {
readonly sections: readonly Section[];
readonly read: ObjcDispatchReaders;
readonly result: ObjcSwiftMetadata;
readonly failures: string[];
readonly admit: () => boolean;
/** Decode a method list into implementations attributed to the category. */
readonly methods: (
list: bigint,
className: string,
methodType: "instance" | "class",
category: string,
) => string[];
readonly protocols: (list: bigint) => string[];
/** Name of a class defined in this image, from its class_ro_t. */
readonly localClassName: (address: bigint) => string;
}): number => {
const { read, result, failures } = input;
let examined = 0;
for (const section of input.sections.filter(
({ name }) => name === "__objc_catlist",
)) {
if (section.size % 8 !== 0)
throw new RangeError("Misaligned Objective-C category list");
for (let index = 0; index < section.size / 8; index++) {
if (!input.admit()) break;
examined++;
const field = section.address + BigInt(index * 8);
try {
const category = read.pointer(field);
const name = read.string(read.pointer(category));
const external = boundClassName(read, category + 8n);
const local = read.pointer(category + 8n);
const className =
external ?? (local === 0n ? null : input.localClassName(local));
const owner = className ?? `(${name})`;
result.objc_categories.push({
name,
class_name: className,
class_source:
external !== undefined
? "external"
: className === null
? "unresolved"
: "local",
instance_methods: input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
),
class_methods: input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
),
protocols: input.protocols(read.pointer(category + 32n)),
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
owner: `category ${name}`,
admit: input.admit,
failures,
}),
location: read.location(category),
decode:
className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
evidence: read.evidence(category, "Objective-C category_t record"),
});
} catch (cause: unknown) {
failures.push(
`Category at ${`0x${field.toString(16)}`}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}
}
return examined;
};
+70 -25
View File
@@ -1,5 +1,7 @@
import type { ObjcSwiftMetadata } from "../domain/objcSwiftMetadata.js";
import type { Segment, Section } from "./AppleMachoSelection.js";
import { boundClassName, decodeObjcCategories } from "./AppleObjcCategories.js";
import { readObjcPropertiesOf } from "./AppleObjcProperties.js";
import { createObjcProtocolReader } from "./AppleObjcProtocols.js";
import type {
BinaryMetadataEvidence,
@@ -10,8 +12,14 @@ import type {
/** Byte readers shared by Objective-C class/method/ivar decode facets. */
export interface ObjcDispatchReaders {
u32(address: bigint): number;
/** Plain 64-bit data, never a fixup. */
u64(address: bigint): bigint;
i32(address: bigint): number;
pointer(address: bigint): bigint;
/** The symbol a pointer at `address` is bound to, when it is a bind fixup. */
bound(
address: bigint,
): { readonly symbol: string; readonly library: string | null } | undefined;
string(address: bigint): string;
location(address: bigint): ResolvedMetadataLocation;
evidence(address: bigint, description: string): BinaryMetadataEvidence;
@@ -21,6 +29,36 @@ export interface ObjcDispatchReaders {
const hex = (value: bigint) => `0x${value.toString(16)}`;
const decoded = { status: "decoded" as const, reason: null };
/** Name of a class defined in this image, from its class_ro_t. */
const localClassName = (read: ObjcDispatchReaders, address: bigint): string =>
read.string(read.pointer((read.pointer(address + 32n) & ~7n) + 24n));
/**
* Resolve a class's superclass: a bind to `_OBJC_CLASS_$_Name` names a class
* in another image; otherwise the pointer is a class_t in this image.
*/
const resolveSuperclass = (
read: ObjcDispatchReaders,
address: bigint,
className: string,
failures: string[],
): { name: string | null; address: string | null } => {
const external = boundClassName(read, address + 8n);
if (external !== undefined) return { name: external, address: null };
const superclass = read.pointer(address + 8n);
if (superclass === 0n) return { name: null, address: null };
try {
return { name: localClassName(read, superclass), address: hex(superclass) };
} catch (cause: unknown) {
// Superclass resolution failure is recorded; the cause adds no identity.
void cause;
failures.push(
`Superclass pointer ${hex(superclass)} of ${className} requires unsupported binding/fixup resolution`,
);
return { name: null, address: hex(superclass) };
}
};
/** Decode Objective-C protocol lists, classlists, methods, and ivars into `result`. */
export const decodeObjcDispatchFacets = (input: {
bytes: Buffer;
@@ -29,7 +67,7 @@ export const decodeObjcDispatchFacets = (input: {
readers: ObjcDispatchReaders;
budget: DispatchRecordBudget;
result: ObjcSwiftMetadata;
}): { failures: string[]; examined: number } => {
}): { failures: string[]; examined: number; categoriesExamined: number } => {
const { bytes, sections, segments, readers: read, budget, result } = input;
const failures: string[] = [];
let examined = 0;
@@ -37,8 +75,10 @@ export const decodeObjcDispatchFacets = (input: {
list: bigint,
className: string,
methodType: "instance" | "class",
) => {
if (list === 0n) return;
category?: string,
): string[] => {
const selectors: string[] = [];
if (list === 0n) return selectors;
const flags = read.u32(list),
count = read.u32(list + 4n),
relative = (flags & 0x80000000) !== 0;
@@ -103,10 +143,13 @@ export const decodeObjcDispatchFacets = (input: {
// best-effort cleanup: external/chained pointers remain unresolved.
void cause;
}
const selector = read.string(selectorAddress);
selectors.push(selector);
result.objc_dispatch_implementations.push({
class_name: className,
selector: read.string(selectorAddress),
selector,
method_type: methodType,
...(category === undefined ? {} : { category }),
implementation_address: implementationLocation?.address ?? null,
location: read.location(entry),
decode:
@@ -124,6 +167,7 @@ export const decodeObjcDispatchFacets = (input: {
),
});
}
return selectors;
};
const protocolReader = createObjcProtocolReader({
readers: {
@@ -134,6 +178,7 @@ export const decodeObjcDispatchFacets = (input: {
evidence: read.evidence,
admit: () => budget.admit(),
i32: read.i32,
u64: read.u64,
},
result,
failures,
@@ -157,23 +202,9 @@ export const decodeObjcDispatchFacets = (input: {
visited.add(hex(address));
const ro = read.pointer(address + 32n) & ~7n;
const name = read.string(read.pointer(ro + 24n));
const superclass = read.pointer(address + 8n);
const root = (read.u32(ro) & 2) !== 0;
let superclassName: string | null = null;
if (superclass !== 0n) {
try {
superclassName = read.string(
read.pointer((read.pointer(superclass + 32n) & ~7n) + 24n),
);
} catch (cause: unknown) {
// Superclass resolution failure is recorded; the cause adds no identity.
void cause;
failures.push(
`Superclass pointer ${hex(superclass)} of ${name} requires unsupported binding/fixup resolution`,
);
}
}
if (superclass === 0n && !root)
const superclass = resolveSuperclass(read, address, name, failures);
if (superclass.name === null && superclass.address === null && !root)
failures.push(
`Superclass of ${name} requires external binding resolution`,
);
@@ -220,7 +251,7 @@ export const decodeObjcDispatchFacets = (input: {
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
result.objc_classes.push({
name,
super_class: superclassName,
super_class: superclass.name,
is_meta_class: meta,
is_root_class: root,
methods: result.objc_dispatch_implementations
@@ -241,15 +272,19 @@ export const decodeObjcDispatchFacets = (input: {
is_required: false,
is_optional: false,
})),
properties: [],
properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), {
owner: name,
admit: () => budget.admit(),
failures,
}),
protocols: protocolReader.list(read.pointer(ro + 40n)),
ivar_count: ivarCount,
instance_size: read.u32(ro + 8n),
location: read.location(address),
superclass_address: superclass === 0n ? null : hex(superclass),
superclass_address: superclass.address,
metaclass_address: meta ? null : hex(read.pointer(address)),
decode:
superclassName === null && !root
superclass.name === null && !root
? { status: "partial", reason: "superclass_binding_unresolved" }
: decoded,
evidence: read.evidence(
@@ -288,5 +323,15 @@ export const decodeObjcDispatchFacets = (input: {
}
}
}
return { failures, examined };
const categoriesExamined = decodeObjcCategories({
sections,
read,
result,
failures,
admit: () => budget.admit(),
methods,
protocols: (list) => protocolReader.list(list),
localClassName: (address) => localClassName(read, address),
});
return { failures, examined, categoriesExamined };
};
+103
View File
@@ -0,0 +1,103 @@
import type { z } from "zod";
import type { objcPropertySchema } from "../domain/objcSwiftMetadata.js";
type ObjcProperty = z.infer<typeof objcPropertySchema>;
/** Byte readers needed to decode an Objective-C `property_list_t`. */
export interface ObjcPropertyReaders {
u32(address: bigint): number;
pointer(address: bigint): bigint;
string(address: bigint): string;
}
/**
* Parse an `@property` attribute string such as `T@"NSString",C,N,V_name`.
* Each comma-separated token is one attribute; `T` is the type encoding.
*/
export const parsePropertyAttributes = (
name: string,
attributes: string,
): ObjcProperty => {
const tokens = attributes.length === 0 ? [] : splitAttributes(attributes);
const value = (code: string): string | null =>
tokens.find((token) => token.startsWith(code))?.slice(1) ?? null;
return {
name,
type_encoding: value("T"),
attributes: tokens.map((token) => ({
name: token.slice(0, 1),
value: token.slice(1),
is_weak: token === "W",
is_atomic: false,
is_copy: token === "C",
is_strong: token === "&",
})),
is_readonly: tokens.includes("R"),
getter: value("G"),
setter: value("S"),
};
};
/** Split on commas that are outside a quoted class name in the type encoding. */
const splitAttributes = (attributes: string): string[] => {
const tokens: string[] = [];
let current = "";
let quoted = false;
for (const character of attributes) {
if (character === '"') quoted = !quoted;
if (character === "," && !quoted) {
tokens.push(current);
current = "";
} else current += character;
}
tokens.push(current);
return tokens.filter((token) => token.length > 0);
};
/** Decode `property_list_t { entsize_and_flags, count, property_t[] }`. */
export const readObjcPropertyList = (
read: ObjcPropertyReaders,
list: bigint,
admit: () => boolean,
): ObjcProperty[] => {
if (list === 0n) return [];
const entrySize = read.u32(list) & ~3;
const count = read.u32(list + 4n);
if (entrySize !== 16)
throw new TypeError(
`Unsupported Objective-C property entry size ${entrySize}`,
);
const properties: ObjcProperty[] = [];
for (let index = 0; index < count; index++) {
if (!admit()) break;
const entry = list + 8n + BigInt(index * entrySize);
properties.push(
parsePropertyAttributes(
read.string(read.pointer(entry)),
read.string(read.pointer(entry + 8n)),
),
);
}
return properties;
};
/** Decode a property list, recording a failure for `owner` instead of throwing. */
export const readObjcPropertiesOf = (
read: ObjcPropertyReaders,
list: bigint,
context: {
readonly owner: string;
readonly admit: () => boolean;
readonly failures: string[];
},
): ObjcProperty[] => {
try {
return readObjcPropertyList(read, list, context.admit);
} catch (cause: unknown) {
context.failures.push(
`Properties of ${context.owner}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
return [];
}
};
+8 -2
View File
@@ -6,7 +6,12 @@ export const createObjcProtocolReader = (input: {
readers: Pick<
SwiftMetadataReaders,
"u32" | "string" | "location" | "evidence" | "admit"
> & { pointer(address: bigint): bigint; i32(address: bigint): number };
> & {
pointer(address: bigint): bigint;
/** Plain 64-bit data, never a fixup. */
u64(address: bigint): bigint;
i32(address: bigint): number;
};
result: ObjcSwiftMetadata;
failures: string[];
}) => {
@@ -15,7 +20,8 @@ export const createObjcProtocolReader = (input: {
const name = (address: bigint) => read.string(read.pointer(address + 8n));
const list = (address: bigint): string[] => {
if (address === 0n) return [];
const count = read.pointer(address);
// `count` is plain uintptr_t data, not a pointer fixup.
const count = read.u64(address);
if (count > 20000n)
throw new RangeError("Protocol list exceeds 20000 entries");
const names: string[] = [];
+8
View File
@@ -9,6 +9,7 @@
@interface ReaDispatchFixture : NSObject <ReaDispatchProtocol> {
int state;
}
@property(nonatomic, copy) NSString *fixtureName;
- (void)performAction:(id)sender;
+ (int)fixtureVersion;
@end
@@ -16,4 +17,11 @@
- (void)performAction:(id)sender { state += sender == nil ? 1 : 2; }
+ (int)fixtureVersion { return 1; }
@end
@interface NSString (ReaDispatchAdditions)
- (NSUInteger)reaFixtureLength;
@end
@implementation NSString (ReaDispatchAdditions)
- (NSUInteger)reaFixtureLength { return self.length; }
@end
int main(void) { return 0; }