fix(native): preserve selected paths and binding identities

Reuse filesystem-aware path selection for resource-only plist sessions so directory symlinks and trailing separators retain their meaning. Parse dyld bind names once as lossless text or exact bytes, preserving partial binding facts and independent valid metadata without invented class identities. Keep coverage issues separate from known opcode applicability and exercise both producer and generated Mach-O consumers.
This commit is contained in:
morluto committed 2026-10-24 08:01:25 +08:00
1 parent 64fa1db130
commit 76500f12b5
7 files changed
+469 -55

No files matched your search

+16
View File
@@ -467,6 +467,12 @@ unreadable default plist of another target reports `capability_unavailable`.
Permission failures remain `access_denied`; output and cleanup failures retain
their own error categories.
Selected plist paths preserve filesystem resolution, including `..` after a
directory symlink and trailing directory separators. Resource-only bundle
sessions resolve relative selections from the bundle root with the same path
semantics as executable sessions; the reported source path retains those
selected segments.
A conversion refusal includes plutil's diagnostic from either output stream.
`details.captured_output` retains bounded stdout/stderr excerpts, observed byte
totals and exit status. `truncated: true` means those excerpts are incomplete.
@@ -700,6 +706,16 @@ complete observation.
arm64e formats;
- legacy `LC_DYLD_INFO` rebase and bind opcodes.
[dyld symbol names are byte strings](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L203-L208),
compared with [`strcmp`](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L42-L56).
When a name has no lossless UTF-8 text
projection, the affected binding remains unresolved and pointer-fixup
coverage is partial; later readable bindings and other metadata survive.
The internal fixup reader retains the exact name bytes and parsed binding
fields, while public Objective-C and Swift results retain the unresolved
reason instead of publishing a guessed symbol or class name. A literal
U+FFFD in valid UTF-8 remains a known symbol character.
A Swift conformance whose type or protocol reference is an external bind
keeps its name as `null` and carries the raw dyld identity in
`type_binding` or `protocol_binding`; that record and its facet coverage are
+49 -1
View File
@@ -314,6 +314,54 @@ const swiftConformanceTypeReferenceImage = (
return bytes;
};
describe("Imported class symbol byte identities", () => {
it.each(["invalid bytes", "literal replacement character"] as const)(
"keeps imported class identity distinct for %s",
(nameKind) => {
const bytes = swiftExternalSwiftBindingImage("objc-class");
const nameAt = 0xf02;
const literalName = "_OBJC_CLASS_$_�bject";
if (nameKind === "invalid bytes")
bytes[nameAt + Buffer.byteLength("_OBJC_CLASS_$_")] = 0xff;
else Buffer.from(literalName).copy(bytes, nameAt);
const result = decodeAppleDispatchMetadata(
bytes,
100,
provenance,
"arm64",
["0x100000b00"],
);
expect(result.swift_conformances).toMatchObject([
{
type_name: null,
type_reference_kind: 3,
protocol_name: "Equatable",
type_binding:
nameKind === "invalid bytes" ? null : { symbol: literalName },
decode: { status: "partial" },
},
]);
expect(result.objc_class_import_bindings).toEqual(
nameKind === "invalid bytes"
? []
: [expect.objectContaining({ symbol: literalName })],
);
expect(result.objc_class_import_binding_coverage).toMatchObject({
status: nameKind === "invalid bytes" ? "partial" : "complete",
});
expect(
result.coverage.find(({ facet }) => facet === "pointer_fixups"),
).toMatchObject({
status: nameKind === "invalid bytes" ? "partial" : "complete",
});
if (nameKind === "invalid bytes")
expect(result.objc_class_import_binding_coverage?.reason).toContain(
"UTF-8",
);
},
);
});
describe("Swift imported protocol references", () => {
it("preserves bind identity without reading VM address zero and marks coverage partial", () => {
const result = decodeAppleDispatchMetadata(
@@ -1638,7 +1686,7 @@ describe("malformed Apple dispatch metadata", () => {
const bytes = fixture();
bytes.writeUInt32LE(7, 36);
expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow(
"command size",
new RangeError("load command 0 at offset 0 has invalid cmdsize 7"),
);
expect(() =>
decodeAppleDispatchMetadata(Buffer.alloc(10), 100, provenance),
+165
View File
@@ -155,6 +155,171 @@ const SEGMENTS: Segment[] = [
{ address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false },
];
it.each([1, 2, 3] as const)(
"preserves non-UTF-8 chained symbol bytes without inventing class identity for import format %i",
(format) => {
const data = chainedData(format);
const nameAt = data.readUInt32LE(12) + 1;
data[nameAt + Buffer.byteLength("_OBJC_CLASS_$_")] = 0xff;
data.writeBigUInt64LE(
field(1n, 63) | field(2n, 51) | field(3n, 24),
0x4008,
);
data.writeBigUInt64LE(1n | field(1n, 63), 0x4010);
const fixups = parsePointerFixups(
data,
{
chained: { offset: 0, size: 180 },
binds: [],
rebases: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
);
expect(
fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
).toMatchObject({
kind: "unsupported",
reason: expect.stringContaining("UTF-8"),
binding: {
symbol: null,
symbol_bytes_base64: data
.subarray(nameAt, data.indexOf(0, nameAt))
.toString("base64"),
binding_source: "chained",
library_ordinal: 1,
library: "/library-1",
weak: false,
addend: 3n,
},
});
expect(
fixups.decode(BASE + 0x4010n, data.readBigUInt64LE(0x4010)),
).toMatchObject({
kind: "bind",
symbol: "_free",
library_ordinal: 2,
});
expect(fixups.issues).toContainEqual(
expect.objectContaining({
code: "chained_pointer_decode_failed",
location: `0x${(BASE + 0x4008n).toString(16)}`,
}),
);
expect(fixups.decoded).toBe(0);
const literal = "_OBJC_CLASS_$_�bject";
Buffer.from(literal).copy(data, nameAt);
const literalFixups = parsePointerFixups(
data,
{
chained: { offset: 0, size: 180 },
binds: [],
rebases: [],
dylibs: ["/library-1", "/library-2"],
},
SEGMENTS,
BASE,
);
expect(
literalFixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
).toMatchObject({
kind: "bind",
symbol: literal,
});
},
);
it.each(["bind", "weak", "lazy"] as const)(
"retains partial non-UTF-8 %s bindings and recovers subsequent lossless names",
(stream) => {
const invalidName = Buffer.concat([
Buffer.from("_OBJC_CLASS_$_"),
Buffer.from([0xff]),
]);
const otherInvalidName = Buffer.concat([
Buffer.from("_OBJC_CLASS_$_"),
Buffer.from([0xfe]),
]);
const literalName = "_OBJC_CLASS_$_�";
const opcodes = Buffer.from([
0x11,
0x71,
0x08,
0x60,
0x04,
0x40,
...invalidName,
0,
0x90,
0x40,
...otherInvalidName,
0,
0x90,
0x40,
...Buffer.from(literalName),
0,
0x90,
0x40,
...Buffer.from("_later"),
0,
0x90,
0,
]);
const data = Buffer.alloc(0x8000);
opcodes.copy(data, 0x7000);
const fixups = parsePointerFixups(
data,
{
chained: null,
binds: [{ offset: 0x7000, size: opcodes.length, stream }],
rebases: [],
dylibs: ["/library-1"],
},
SEGMENTS,
BASE,
);
expect(fixups.decode(BASE + 0x4008n, 0n)).toMatchObject({
kind: "unsupported",
reason: expect.stringContaining("UTF-8"),
binding: {
symbol: null,
symbol_bytes_base64: invalidName.toString("base64"),
binding_source: stream,
library_ordinal: 1,
library: stream === "weak" ? null : "/library-1",
weak: false,
addend: 4n,
},
});
expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({
kind: "unsupported",
binding: {
symbol: null,
symbol_bytes_base64: otherInvalidName.toString("base64"),
},
});
expect(fixups.decode(BASE + 0x4018n, 0n)).toMatchObject({
kind: "bind",
symbol: literalName,
});
expect(fixups.decode(BASE + 0x4020n, 0n)).toMatchObject({
kind: "bind",
symbol: "_later",
});
expect(fixups.issues).toContainEqual(
expect.objectContaining({
code: "bind_symbol_text_unavailable",
location: `0x${(BASE + 0x4008n).toString(16)}`,
}),
);
expect(fixups.decode(BASE + 0x4028n, 0n)).toEqual({
kind: "raw",
target: 0n,
});
},
);
const legacyRebaseInput = (stream: Buffer) => {
const data = Buffer.alloc(0x8000);
stream.copy(data, 0x7000);
+126 -50
View File
@@ -35,7 +35,12 @@ export type DecodedPointer =
readonly weak: boolean;
readonly addend: bigint;
}
| { readonly kind: "unsupported"; readonly reason: string };
| {
readonly kind: "unsupported";
readonly reason: string;
/** Source bind facts survive when its byte name has no lossless text projection. */
readonly binding?: Import & { readonly symbol: null };
};
/** Pointer-slot outcome, including whether a rebase was actually applied. */
export type PointerResolution =
@@ -58,7 +63,10 @@ export interface PointerFixups {
readonly issues: readonly DecodeIssue[];
/** Chained segments, legacy rebase streams, or bind streams this reader walked. */
readonly examined: number;
/** Fully decoded fixup segments or bind streams, in the same units as `examined`. */
/** Chained segments without observed decode failures, plus structurally parsed
* legacy opcode streams, in the same units as `examined`. Legacy location
* coverage survives unavailable symbol text, which is qualified by `issues`.
*/
readonly decoded: number;
/** Declared legacy opcode streams examined by this owner. */
readonly legacyRebaseStreams: number;
@@ -87,22 +95,28 @@ const bits = (value: bigint, from: number, width: number): bigint =>
const signed = (value: bigint, width: number): bigint =>
value >= 1n << BigInt(width - 1) ? value - (1n << BigInt(width)) : value;
interface Import {
readonly symbol: string;
type SymbolName =
| { readonly symbol: string }
| { readonly symbol: null; readonly symbol_bytes_base64: string };
type Import = SymbolName & {
readonly binding_source: "chained" | "bind" | "weak" | "lazy";
readonly library_ordinal: number;
readonly library: string | null;
readonly weak: boolean;
readonly addend: bigint;
}
};
interface BindRun {
readonly start: bigint;
count: bigint;
readonly stride: bigint;
readonly binding:
| { readonly kind: "bind"; readonly value: Import }
| { readonly kind: "unsupported"; readonly reason: string };
| {
readonly kind: "bind";
readonly value: Import & { readonly symbol: string };
}
| Extract<DecodedPointer, { readonly kind: "unsupported" }>;
}
interface BindFacts {
@@ -111,6 +125,7 @@ interface BindFacts {
readonly issues: readonly DecodeIssue[];
readonly examined: number;
readonly decoded: number;
/** All opcode locations are known, independently of symbol text availability. */
readonly complete: boolean;
}
@@ -132,13 +147,50 @@ const libraryName = (
return null;
};
const cString = (bytes: Buffer, offset: number, end: number): string => {
/** dyld names are NUL-terminated bytes; text projection must never replace their identity. */
const symbolName = (bytes: Buffer): SymbolName => {
try {
return {
symbol: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes,
),
};
} catch {
return { symbol: null, symbol_bytes_base64: bytes.toString("base64") };
}
};
const cString = (bytes: Buffer, offset: number, end: number): SymbolName => {
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);
return symbolName(bytes.subarray(offset, stop));
};
const resolvedBind = (
imported: Import,
addend: bigint,
): Exclude<DecodedPointer, { readonly kind: "rebase" }> =>
imported.symbol === null
? {
kind: "unsupported",
reason:
"Fixup symbol bytes cannot be represented losslessly as UTF-8 text",
binding: { ...imported, addend: imported.addend + addend },
}
: { kind: "bind", ...imported, addend: imported.addend + addend };
const sameImport = (left: Import, right: Import): boolean =>
left.symbol === right.symbol &&
(left.symbol !== null ||
right.symbol !== null ||
left.symbol_bytes_base64 === right.symbol_bytes_base64) &&
left.binding_source === right.binding_source &&
left.library_ordinal === right.library_ordinal &&
left.library === right.library &&
left.weak === right.weak &&
left.addend === right.addend;
const readImport = (
data: {
readonly bytes: Buffer;
@@ -172,7 +224,7 @@ const readImport = (
const ordinal =
encodedOrdinal > 0xfff0 ? encodedOrdinal - 0x10000 : encodedOrdinal;
return {
symbol: cString(
...cString(
bytes,
base + header.symbols + Number(bits(word, 32, 32)),
end,
@@ -189,7 +241,7 @@ const readImport = (
const ordinal =
encodedOrdinal > 0xf0 ? encodedOrdinal - 0x100 : encodedOrdinal;
return {
symbol: cString(bytes, base + header.symbols + (word >>> 9), end),
...cString(bytes, base + header.symbols + (word >>> 9), end),
binding_source: "chained",
library_ordinal: ordinal,
library: libraryName(ordinal, dylibs),
@@ -212,7 +264,7 @@ export const decodeChainedPointer = (
: imports[Number(ordinal)];
if (target === undefined)
throw new RangeError(`Chained bind ordinal ${ordinal} has no import`);
return { kind: "bind", ...target, addend: target.addend + addend };
return resolvedBind(target, addend);
};
if (format === 2 || format === 6) {
if (bits(raw, 63, 1) === 1n)
@@ -496,6 +548,31 @@ const chainedFixups = (
const failures = issues.map(({ message }) => message);
const applicabilityFailures = [...rebases.failures, ...binds.failures];
const applicabilityComplete = rebases.complete && binds.complete;
const recordPointerFailure = (
reason: string,
address: bigint,
chainIndex: number,
): void => {
const location = `0x${address.toString(16)}`;
if (
!issues.some(
(entry) =>
entry.code === "chained_pointer_decode_failed" &&
entry.location === location,
)
) {
issues.push(
issue(
"pointer_fixups",
"chained_pointer_decode_failed",
reason,
location,
),
);
failures.push(reason);
}
decodedRanges.delete(chainIndex);
};
return {
kind: "chained",
formats,
@@ -595,6 +672,8 @@ const chainedFixups = (
baseAddress,
imports,
);
if (decoded.kind === "unsupported")
recordPointerFailure(decoded.reason, address, chainIndex);
return decoded;
}
if (
@@ -605,25 +684,7 @@ const chainedFixups = (
return legacyBind ?? legacyRebase ?? rawPointer();
} catch (cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
const location = `0x${address.toString(16)}`;
if (
!issues.some(
(entry) =>
entry.code === "chained_pointer_decode_failed" &&
entry.location === location,
)
) {
issues.push(
issue(
"pointer_fixups",
"chained_pointer_decode_failed",
reason,
location,
),
);
failures.push(reason);
}
decodedRanges.delete(chainIndex);
recordPointerFailure(reason, address, chainIndex);
return { kind: "unsupported", reason };
}
},
@@ -926,7 +987,7 @@ interface BindState {
ordinal: number;
ordinalFailure: string | null;
segmentIndex: number;
symbol: string;
symbol: SymbolName;
weak: boolean;
addend: bigint;
address: bigint;
@@ -967,7 +1028,7 @@ const setBindRegister = (
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.symbol = symbolName(bytes.subarray(cursor.at, stop));
state.weak = (immediate & 0x1) !== 0;
cursor.at = stop + 1;
return true;
@@ -1010,7 +1071,7 @@ const readBindStream = (
ordinal: 0,
ordinalFailure: null,
segmentIndex: -1,
symbol: "",
symbol: { symbol: "" },
weak: false,
addend: 0n,
address: 0n,
@@ -1034,17 +1095,19 @@ const readBindStream = (
if (state.ordinalFailure !== null)
throw new RangeError(state.ordinalFailure);
const resolvedLibrary = libraryName(state.ordinal, image.dylibs);
binding = {
kind: "bind",
value: {
symbol: state.symbol,
const decoded = resolvedBind(
{
...state.symbol,
binding_source: range.stream,
library_ordinal: state.ordinal,
library: range.stream === "weak" ? null : resolvedLibrary,
weak: state.weak,
addend: state.addend,
},
};
0n,
);
binding =
decoded.kind === "bind" ? { kind: "bind", value: decoded } : decoded;
} catch (cause: unknown) {
binding = {
kind: "unsupported",
@@ -1057,14 +1120,12 @@ const readBindStream = (
right: BindRun["binding"],
) =>
left.kind === "unsupported" && right.kind === "unsupported"
? left.reason === right.reason
? left.reason === right.reason &&
(left.binding === undefined || right.binding === undefined
? left.binding === right.binding
: sameImport(left.binding, right.binding))
: left.kind === "bind" && right.kind === "bind"
? left.value.symbol === right.value.symbol &&
left.value.binding_source === right.value.binding_source &&
left.value.library_ordinal === right.value.library_ordinal &&
left.value.library === right.value.library &&
left.value.weak === right.value.weak &&
left.value.addend === right.value.addend
? sameImport(left.value, right.value)
: false;
if (
previous !== undefined &&
@@ -1157,15 +1218,30 @@ const parseLegacyBinds = (
);
}
}
const complete = failures.length === 0;
const issues = failures.map((message) =>
issue("pointer_fixups", "bind_stream_decode_failed", message),
);
for (const run of runs) {
if (run.binding.kind !== "unsupported" || run.binding.binding === undefined)
continue;
issues.push(
issue(
"pointer_fixups",
"bind_symbol_text_unavailable",
run.binding.reason,
`0x${run.start.toString(16)}`,
),
);
failures.push(run.binding.reason);
}
return {
runs,
failures,
issues: failures.map((message) =>
issue("pointer_fixups", "bind_stream_decode_failed", message),
),
issues,
examined: streams.length,
decoded,
complete: failures.length === 0,
complete,
};
};
+37 -1
View File
@@ -4,7 +4,7 @@ import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
import { resolveBundleResourcePlistSelection } from "./NativePlistSource.js";
import { NativeFixtureRunner } from "../../tests/fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { writeFile } from "node:fs/promises";
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
it("inspects a resolved app plist without executable identity", async () => {
const bundle = await createTestTempDirectory("rea-plist-resource-root-");
@@ -44,3 +44,39 @@ it("inspects a resolved app plist without executable identity", async () => {
},
});
});
it.skipIf(process.platform === "win32")(
"preserves physical selected plist paths in resource-only roots",
async () => {
const bundle = await createTestTempDirectory("rea-plist-resource-path-");
const physical = join(bundle, "physical");
await mkdir(join(physical, "child"), { recursive: true });
await writeFile(join(bundle, "Info.plist"), "lexical decoy");
await writeFile(join(physical, "Info.plist"), "selected source");
await symlink(join(physical, "child"), join(bundle, "alias"));
const root = {
callerPath: bundle,
canonicalPath: bundle,
infoPlistPath: join(bundle, "Info.plist"),
layout: "root" as const,
executableSha256: null,
};
for (const requested of [
"alias/../Info.plist",
`${bundle}/alias/../Info.plist`,
"physical/Info.plist",
]) {
const selected = resolveBundleResourcePlistSelection(root, requested);
if (!selected.ok) throw selected.error;
expect(selected.value.origin).toBe("selected");
expect(await readFile(selected.value.path, "utf8")).toBe(
"selected source",
);
}
const directory = resolveBundleResourcePlistSelection(root, "Info.plist/");
if (!directory.ok) throw directory.error;
await expect(readFile(directory.value.path)).rejects.toMatchObject({
code: "ENOTDIR",
});
},
);
+1 -3
View File
@@ -91,9 +91,7 @@ export const resolveBundleResourcePlistSelection = (
if (requested === undefined)
return ok({ path: root.infoPlistPath, origin: "default" });
return ok({
path: isAbsolute(requested)
? resolve(requested)
: resolve(root.canonicalPath, requested),
path: absoluteCallerPath(requested, { cwd: root.canonicalPath }),
origin: "selected",
});
};
@@ -0,0 +1,75 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { buildBinary } from "plist";
import { expect, it } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform !== "darwin")(
"resource-only MCP plist selections retain symlink-parent and directory semantics",
async () => {
const root = await createTestTempDirectory("rea-resource-plist-selection-");
const app = join(root, "Fixture.app");
const contents = join(app, "Contents");
const physical = join(contents, "physical");
await mkdir(join(physical, "child"), { recursive: true });
const defaultPath = join(contents, "Info.plist");
const sourcePath = join(physical, "Selected.plist");
const decoyPath = join(contents, "Selected.plist");
const source = buildBinary({ Marker: "physical source" });
const decoy = buildBinary({ Marker: "lexical decoy" });
await writeFile(
defaultPath,
buildBinary({ CFBundleIdentifier: "fixture" }),
);
await writeFile(sourcePath, source);
await writeFile(decoyPath, decoy);
await symlink("physical/child", join(contents, "alias"));
const { call } = await connectLocalToolsMcp({
HOME: root,
USERPROFILE: root,
});
expect(
(await call("open_bundle_resources", { path: app })).isError,
).not.toBe(true);
const defaultResult = await call("inspect_plist", {});
expect(defaultResult.isError).not.toBe(true);
expect(
parseEvidence(defaultResult.structuredContent).normalized_result,
).toMatchObject({
source_path: defaultPath,
value: { CFBundleIdentifier: "fixture" },
});
for (const requested of [
"Contents/alias/../Selected.plist",
`${contents}/alias/../Selected.plist`,
sourcePath,
]) {
const response = await call("inspect_plist", { path: requested });
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
expect(
parseEvidence(response.structuredContent).normalized_result,
).toMatchObject({
source_path: requested.startsWith("/")
? requested
: `${app}/${requested}`,
value: { Marker: "physical source" },
resource_root: { canonical_path: app, executable_sha256: null },
});
}
expect(
parseMcpToolError(
await call("inspect_plist", { path: "Contents/Selected.plist/" }),
).error,
).toMatchObject({
code: "invalid_request",
details: { issues: [{ path: ["path"] }] },
});
expect(await readFile(sourcePath)).toEqual(Buffer.from(source));
expect(await readFile(decoyPath)).toEqual(Buffer.from(decoy));
},
);