mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
commit
9049620dfe
29 files changed
+1619
-264
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user