fix(artifacts): follow dyld slice grading and report unparsed images
Address review findings in trace_dylib_resolution: - Choose child slices by dyld's graded architectures, so an x86_64h process loads a generic x86_64 slice instead of reporting architecture-missing. - Read FAT containers with the byte-swapped FAT_CIGAM and FAT_CIGAM_64 magics, which target opening already accepts. - Report string-bearing load commands too short to hold their string offset as malformed instead of failing with a RangeError. - Keep recognized but unsupported images as an `unsupported` candidate outcome. It is undeterminable like an outside-root path, not malformed. - List Mach-O files that do not parse during root enumeration as unparsed images, so coverage is partial rather than silently complete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
63800ff5a2
commit
a0ec3e2016
|
|
@ -149,8 +149,10 @@ Resolution follows dyld:
|
|||
An escape through `..` or an absolute link is `escapes-target`.
|
||||
|
||||
Each edge lists every candidate path tried, with its outcome: `resolved`,
|
||||
`absent`, `not-mach-o`, `malformed`, `architecture-missing`, `outside-target`,
|
||||
`escapes-target`, or `undetermined`. The edge's `resolution` is one of:
|
||||
`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
|
||||
parser coverage, such as big-endian images), `architecture-missing`,
|
||||
`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
|
||||
graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
|
||||
|
||||
| Status | Meaning |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
|
|||
|
|
@ -672,9 +672,9 @@
|
|||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "80a81207beed8b4b85a533346bb6fb7048416369c2bb0f57b38ff64b8ead99e1",
|
||||
"tools_sha256": "7ad6e57f4a82e441c28969242a6e2f098871b48388049cb6b1ebac161ba3d699",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "2165dbe16dd3870d4855803623fb813d6b5fc8c111bbb132e86846f63b9ba900",
|
||||
"combined_sha256": "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200",
|
||||
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"manifest_id": "ecm_3eb6f1d8a4178eb2bffc5835693f29e7b11b40f2b466ff1c389ef0222954a8f5",
|
||||
"manifest_id": "ecm_93f08d561a3f3decdeeb2350630274ea7404a6b072908b213c853b97c8adca1d",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "a584eaa4cf66fcaa03c8add46361b3346669b231a34b61edadc1a72dbc73d753"
|
||||
"sha256": "d65a570cd63b6fb73dc44fc5a08122fdfa7d9ce43f3dfd4b4d2b18950ea2387b"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
|
|||
metadata:
|
||||
version: "28"
|
||||
tool_count: 132
|
||||
catalog_digest: "2165dbe16dd3870d4855803623fb813d6b5fc8c111bbb132e86846f63b9ba900"
|
||||
catalog_digest: "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
|
|
|||
|
|
@ -81,14 +81,16 @@ export const traceDylibResolution = async (options: {
|
|||
.join("/");
|
||||
const view = new FilesystemTreeView(root, options.signal);
|
||||
try {
|
||||
const roots =
|
||||
parsed.data.roots ??
|
||||
(options.enumerateRoots
|
||||
? await executableRoots(root, view, options.signal)
|
||||
: [target]);
|
||||
const { roots, unclassified } =
|
||||
parsed.data.roots !== undefined
|
||||
? { roots: parsed.data.roots, unclassified: [] }
|
||||
: options.enumerateRoots
|
||||
? await executableRoots(root, view, options.signal)
|
||||
: { roots: [target], unclassified: [] };
|
||||
await requireMachoRoots(view, roots);
|
||||
const trace = await traceDylibLoading(view, {
|
||||
roots,
|
||||
unclassified,
|
||||
...(parsed.data.architecture === undefined
|
||||
? {}
|
||||
: { architecture: parsed.data.architecture }),
|
||||
|
|
@ -125,13 +127,18 @@ export const traceDylibResolution = async (options: {
|
|||
}
|
||||
};
|
||||
|
||||
/** Every Mach-O in the bundle with an executable slice is its own process root. */
|
||||
/**
|
||||
* Every Mach-O in the bundle with an executable slice is its own process root.
|
||||
* Mach-O files that do not parse are returned separately: whether they are
|
||||
* executables is unknown, so they make coverage partial instead of vanishing.
|
||||
*/
|
||||
const executableRoots = async (
|
||||
root: string,
|
||||
view: FilesystemTreeView,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> => {
|
||||
): Promise<{ readonly roots: string[]; readonly unclassified: string[] }> => {
|
||||
const roots: string[] = [];
|
||||
const unclassified: string[] = [];
|
||||
for await (const entry of new DirectoryArtifactReader(root).entries(signal)) {
|
||||
if (
|
||||
entry.kind !== "file" ||
|
||||
|
|
@ -139,17 +146,23 @@ const executableRoots = async (
|
|||
)
|
||||
continue;
|
||||
const facts = await view.image(entry.path);
|
||||
if (
|
||||
if (facts.status === "malformed" || facts.status === "unsupported")
|
||||
unclassified.push(entry.path);
|
||||
else if (
|
||||
facts.status === "parsed" &&
|
||||
facts.slices.some(({ file_type: type }) => type === "execute")
|
||||
)
|
||||
roots.push(entry.path);
|
||||
}
|
||||
return roots.sort((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
return {
|
||||
roots: roots.sort(compare),
|
||||
unclassified: unclassified.sort(compare),
|
||||
};
|
||||
};
|
||||
|
||||
const compare = (left: string, right: string): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
|
||||
const requireMachoRoots = async (
|
||||
view: FilesystemTreeView,
|
||||
roots: readonly string[],
|
||||
|
|
|
|||
|
|
@ -103,13 +103,17 @@ export const machoImage = (options: {
|
|||
return bytes;
|
||||
};
|
||||
|
||||
/** A big-endian FAT container whose slices start on 4 KiB boundaries. */
|
||||
/**
|
||||
* A FAT container whose slices start on 4 KiB boundaries. Headers are
|
||||
* big-endian unless `littleEndian` selects the swapped FAT_CIGAM magics.
|
||||
*/
|
||||
export const fatImage = (
|
||||
slices: readonly {
|
||||
readonly cpu: { readonly type: number; readonly subtype: number };
|
||||
readonly bytes: Uint8Array;
|
||||
}[],
|
||||
wide = false,
|
||||
littleEndian = false,
|
||||
): Uint8Array => {
|
||||
const entrySize = wide ? 32 : 20;
|
||||
const offsets: number[] = [];
|
||||
|
|
@ -120,21 +124,21 @@ export const fatImage = (
|
|||
}
|
||||
const bytes = new Uint8Array(end);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, false);
|
||||
view.setUint32(4, slices.length, false);
|
||||
view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, littleEndian);
|
||||
view.setUint32(4, slices.length, littleEndian);
|
||||
slices.forEach((slice, index) => {
|
||||
const base = 8 + index * entrySize;
|
||||
const offset = offsets[index] ?? 0;
|
||||
view.setUint32(base, slice.cpu.type, false);
|
||||
view.setUint32(base + 4, slice.cpu.subtype, false);
|
||||
view.setUint32(base, slice.cpu.type, littleEndian);
|
||||
view.setUint32(base + 4, slice.cpu.subtype, littleEndian);
|
||||
if (wide) {
|
||||
view.setBigUint64(base + 8, BigInt(offset), false);
|
||||
view.setBigUint64(base + 16, BigInt(slice.bytes.length), false);
|
||||
view.setUint32(base + 24, 12, false);
|
||||
view.setBigUint64(base + 8, BigInt(offset), littleEndian);
|
||||
view.setBigUint64(base + 16, BigInt(slice.bytes.length), littleEndian);
|
||||
view.setUint32(base + 24, 12, littleEndian);
|
||||
} else {
|
||||
view.setUint32(base + 8, offset, false);
|
||||
view.setUint32(base + 12, slice.bytes.length, false);
|
||||
view.setUint32(base + 16, 12, false);
|
||||
view.setUint32(base + 8, offset, littleEndian);
|
||||
view.setUint32(base + 12, slice.bytes.length, littleEndian);
|
||||
view.setUint32(base + 16, 12, littleEndian);
|
||||
}
|
||||
bytes.set(slice.bytes, offset);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -125,8 +125,13 @@ describe("Mach-O load command reader", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("reads every slice of FAT and FAT_64 containers", async () => {
|
||||
for (const wide of [false, true]) {
|
||||
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
|
||||
for (const [wide, littleEndian] of [
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
] as const) {
|
||||
const facts = await read(
|
||||
fatImage(
|
||||
[
|
||||
|
|
@ -146,6 +151,7 @@ describe("Mach-O load command reader", () => {
|
|||
},
|
||||
],
|
||||
wide,
|
||||
littleEndian,
|
||||
),
|
||||
);
|
||||
if (facts.status !== "parsed") throw new Error(facts.status);
|
||||
|
|
@ -204,6 +210,17 @@ describe("Mach-O load command reader failures", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("rejects string commands too short to hold their string offset", async () => {
|
||||
const short = new Uint8Array(8);
|
||||
const view = new DataView(short.buffer);
|
||||
view.setUint32(0, LC.RPATH, true);
|
||||
view.setUint32(4, 8, true);
|
||||
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "load command 0 is too short to hold a string offset",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
|
||||
const unterminated = rpathCommand("abcd");
|
||||
unterminated.fill(0x41, 12);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ const MH_CIGAM = 0xcefaedfe;
|
|||
const MH_CIGAM_64 = 0xcffaedfe;
|
||||
const FAT_MAGIC = 0xcafebabe;
|
||||
const FAT_MAGIC_64 = 0xcafebabf;
|
||||
const FAT_CIGAM = 0xbebafeca;
|
||||
const FAT_CIGAM_64 = 0xbfbafeca;
|
||||
|
||||
const LC_ID_DYLIB = 0xd;
|
||||
const LC_LOAD_DYLIB = 0xc;
|
||||
|
|
@ -70,6 +72,8 @@ export const hasMachoMagic = (head: Uint8Array): boolean => {
|
|||
MH_CIGAM_64,
|
||||
FAT_MAGIC,
|
||||
FAT_MAGIC_64,
|
||||
FAT_CIGAM,
|
||||
FAT_CIGAM_64,
|
||||
].includes(magic);
|
||||
};
|
||||
|
||||
|
|
@ -83,18 +87,19 @@ export const readMachoImage = async (
|
|||
if (head.byteLength < 8) return { status: "not-mach-o" };
|
||||
const view = viewOf(head);
|
||||
const magic = view.getUint32(0, false);
|
||||
if (magic === FAT_MAGIC || magic === FAT_MAGIC_64) {
|
||||
const count = view.getUint32(4, false);
|
||||
if ([FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) {
|
||||
// FAT headers are big-endian; the swapped magics store them little-endian.
|
||||
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
|
||||
const count = view.getUint32(4, littleEndian);
|
||||
if (count === 0 || count >= MAX_FAT_ARCHITECTURES)
|
||||
return { status: "not-mach-o" };
|
||||
return {
|
||||
status: "parsed",
|
||||
slices: await readFatSlices(
|
||||
readAt,
|
||||
size,
|
||||
magic === FAT_MAGIC_64,
|
||||
slices: await readFatSlices(readAt, size, {
|
||||
wide: magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64,
|
||||
littleEndian,
|
||||
count,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (magic === MH_CIGAM || magic === MH_CIGAM_64)
|
||||
|
|
@ -115,21 +120,26 @@ export const readMachoImage = async (
|
|||
const readFatSlices = async (
|
||||
readAt: ReadAt,
|
||||
size: number,
|
||||
wide: boolean,
|
||||
count: number,
|
||||
table: {
|
||||
readonly wide: boolean;
|
||||
readonly littleEndian: boolean;
|
||||
readonly count: number;
|
||||
},
|
||||
): Promise<MachoSlice[]> => {
|
||||
const { wide, littleEndian, count } = table;
|
||||
const entrySize = wide ? 32 : 20;
|
||||
const table = await readExact(readAt, 8, count * entrySize, "FAT table");
|
||||
const view = viewOf(table);
|
||||
const view = viewOf(
|
||||
await readExact(readAt, 8, count * entrySize, "FAT table"),
|
||||
);
|
||||
const slices: MachoSlice[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const base = index * entrySize;
|
||||
const offset = wide
|
||||
? safeNumber(view.getBigUint64(base + 8, false))
|
||||
: view.getUint32(base + 8, false);
|
||||
? safeNumber(view.getBigUint64(base + 8, littleEndian))
|
||||
: view.getUint32(base + 8, littleEndian);
|
||||
const length = wide
|
||||
? safeNumber(view.getBigUint64(base + 16, false))
|
||||
: view.getUint32(base + 12, false);
|
||||
? safeNumber(view.getBigUint64(base + 16, littleEndian))
|
||||
: view.getUint32(base + 12, littleEndian);
|
||||
if (offset + length > size)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
|
|
@ -291,6 +301,11 @@ const commandString = (
|
|||
minimumOffset: number,
|
||||
index: number,
|
||||
): string => {
|
||||
if (body.byteLength < 12)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short to hold a string offset`,
|
||||
);
|
||||
const offset = viewOf(body).getUint32(8, true);
|
||||
if (offset < minimumOffset || offset >= body.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { DylibTreeEntry, DylibTreeView } from "./dylibResolution.js";
|
||||
import type {
|
||||
DylibTreeEntry,
|
||||
DylibTreeView,
|
||||
MachoSlice,
|
||||
} from "./dylibResolution.js";
|
||||
|
||||
const MAX_SYMLINK_HOPS = 32;
|
||||
|
||||
|
|
@ -85,3 +89,22 @@ export const expandPrefix = (
|
|||
if (raw.startsWith("/")) return { scope: "outside", path: raw };
|
||||
return { scope: "undetermined", path: raw };
|
||||
};
|
||||
|
||||
/**
|
||||
* Slices a process may load, best first, following dyld's graded
|
||||
* architectures: an x86_64h process also accepts generic x86_64.
|
||||
*/
|
||||
const COMPATIBLE_ARCHITECTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
x86_64h: ["x86_64h", "x86_64"],
|
||||
};
|
||||
|
||||
export const compatibleSlice = (
|
||||
slices: readonly MachoSlice[],
|
||||
process: string,
|
||||
): MachoSlice | undefined => {
|
||||
for (const architecture of COMPATIBLE_ARCHITECTURES[process] ?? [process]) {
|
||||
const slice = slices.find((item) => item.architecture === architecture);
|
||||
if (slice !== undefined) return slice;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -438,3 +438,83 @@ describe("dyld load order", () => {
|
|||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dyld slice compatibility and coverage", () => {
|
||||
it("lets an x86_64h process load generic x86_64 but not arm64e load arm64", async () => {
|
||||
const files = {
|
||||
[MAIN]: parsed(
|
||||
slice({
|
||||
architecture: "x86_64h",
|
||||
file_type: "execute",
|
||||
dependencies: [dependency("@executable_path/libgeneric.dylib")],
|
||||
}),
|
||||
slice({
|
||||
architecture: "arm64e",
|
||||
file_type: "execute",
|
||||
dependencies: [dependency("@executable_path/libgeneric.dylib")],
|
||||
}),
|
||||
),
|
||||
"Contents/MacOS/libgeneric.dylib": parsed(
|
||||
slice({ architecture: "x86_64" }),
|
||||
slice({ architecture: "arm64" }),
|
||||
),
|
||||
};
|
||||
const trace = await traceDylibLoading(memoryView(files), { roots: [MAIN] });
|
||||
expect(
|
||||
trace.edges.map(({ architecture, candidates, resolution }) => [
|
||||
architecture,
|
||||
candidates[0]?.outcome,
|
||||
resolution.status,
|
||||
]),
|
||||
).toEqual([
|
||||
["x86_64h", "resolved", "resolved"],
|
||||
["arm64e", "architecture-missing", "unresolved"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unsupported candidates undeterminable rather than malformed", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
dependencies: [dependency("@executable_path/libbig.dylib")],
|
||||
}),
|
||||
"Contents/MacOS/libbig.dylib": {
|
||||
status: "unsupported",
|
||||
reason: "big-endian Mach-O images are not supported",
|
||||
},
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(trace.edges[0]).toMatchObject({
|
||||
candidates: [{ outcome: "unsupported" }],
|
||||
resolution: { status: "undetermined", image: null },
|
||||
});
|
||||
expect(
|
||||
trace.images.find(({ path }) => path.endsWith("libbig.dylib")),
|
||||
).toMatchObject({
|
||||
parse_status: "unsupported",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unclassified Mach-O files as partial coverage", async () => {
|
||||
const broken = "Contents/Helpers/broken";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable(),
|
||||
[broken]: {
|
||||
status: "malformed",
|
||||
reason: "load command 0 has invalid cmdsize 0",
|
||||
},
|
||||
}),
|
||||
{ roots: [MAIN], unclassified: [broken] },
|
||||
);
|
||||
expect(trace.coverage).toEqual({
|
||||
status: "partial",
|
||||
unparsed_images: [broken],
|
||||
roots_without_architecture: [],
|
||||
});
|
||||
expect(trace.images.find(({ path }) => path === broken)?.reason).toBe(
|
||||
"load command 0 has invalid cmdsize 0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
compatibleSlice,
|
||||
directoryOf,
|
||||
expandPrefix,
|
||||
joinPath,
|
||||
|
|
@ -90,6 +91,7 @@ const candidateSchema = z.strictObject({
|
|||
"absent",
|
||||
"not-mach-o",
|
||||
"malformed",
|
||||
"unsupported",
|
||||
"architecture-missing",
|
||||
"outside-target",
|
||||
"escapes-target",
|
||||
|
|
@ -255,15 +257,11 @@ const evaluateCandidate = async (
|
|||
return { ...base, outcome: "absent", resolved_path: null };
|
||||
const facts = await imageFacts(context, lookup.path);
|
||||
const outcome: Candidate["outcome"] =
|
||||
facts.status === "not-mach-o"
|
||||
? "not-mach-o"
|
||||
: facts.status !== "parsed"
|
||||
? "malformed"
|
||||
: facts.slices.some(
|
||||
({ architecture }) => architecture === context.architecture,
|
||||
)
|
||||
? "resolved"
|
||||
: "architecture-missing";
|
||||
facts.status !== "parsed"
|
||||
? facts.status
|
||||
: compatibleSlice(facts.slices, context.architecture) === undefined
|
||||
? "architecture-missing"
|
||||
: "resolved";
|
||||
return { ...base, outcome, resolved_path: lookup.path };
|
||||
};
|
||||
|
||||
|
|
@ -286,9 +284,11 @@ const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
|
|||
status: external ? "conditional" : "resolved",
|
||||
image: candidate.resolved_path,
|
||||
};
|
||||
// dyld might load these; REA cannot decide them from the analyzed root.
|
||||
if (
|
||||
candidate.outcome === "outside-target" ||
|
||||
candidate.outcome === "escapes-target" ||
|
||||
candidate.outcome === "unsupported" ||
|
||||
candidate.outcome === "undetermined"
|
||||
)
|
||||
external = true;
|
||||
|
|
@ -325,9 +325,7 @@ const resolveDependency = async (
|
|||
resolved.image === null ? undefined : context.images.get(resolved.image);
|
||||
const slice =
|
||||
image?.status === "parsed"
|
||||
? image.slices.find(
|
||||
({ architecture }) => architecture === context.architecture,
|
||||
)
|
||||
? compatibleSlice(image.slices, context.architecture)
|
||||
: undefined;
|
||||
return {
|
||||
root: context.root,
|
||||
|
|
@ -372,9 +370,7 @@ const traceProcess = async (
|
|||
const facts = context.images.get(image);
|
||||
const slice =
|
||||
facts?.status === "parsed"
|
||||
? facts.slices.find(
|
||||
({ architecture }) => architecture === context.architecture,
|
||||
)
|
||||
? compatibleSlice(facts.slices, context.architecture)
|
||||
: undefined;
|
||||
if (slice === undefined) continue;
|
||||
context.loaded.set(image, { slice, chain: [...current.chain, image] });
|
||||
|
|
@ -395,11 +391,15 @@ export const traceDylibLoading = async (
|
|||
view: DylibTreeView,
|
||||
request: {
|
||||
readonly roots: readonly string[];
|
||||
/** Mach-O files whose root role could not be classified because they did not parse. */
|
||||
readonly unclassified?: readonly string[];
|
||||
readonly architecture?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
},
|
||||
): Promise<DylibTrace> => {
|
||||
const images = new Map<string, MachoImageFacts>();
|
||||
for (const path of request.unclassified ?? [])
|
||||
images.set(path, await view.image(path));
|
||||
const roots: DylibTrace["roots"][number][] = [];
|
||||
const edges: Edge[] = [];
|
||||
const withoutArchitecture: string[] = [];
|
||||
|
|
@ -531,6 +531,7 @@ const deriveFindings = (
|
|||
};
|
||||
|
||||
const DYLIB_RESOLUTION_LIMITATIONS = [
|
||||
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
|
||||
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
|
||||
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
|
||||
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -52,6 +52,13 @@ const fixtureApp = async (): Promise<string> => {
|
|||
return app;
|
||||
};
|
||||
|
||||
const withBrokenHelper = async (app: string): Promise<void> => {
|
||||
const broken = machoImage({ commands: [rpathCommand("x")] });
|
||||
// cmdsize 0 makes the command table unreadable.
|
||||
new DataView(broken.buffer).setUint32(32 + 4, 0, true);
|
||||
await writeFiles(app, { "Contents/Helpers/broken": broken });
|
||||
};
|
||||
|
||||
describe("trace-dylib-resolution CLI", () => {
|
||||
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
|
||||
const app = await fixtureApp();
|
||||
|
|
@ -164,4 +171,24 @@ describe("trace-dylib-resolution CLI", () => {
|
|||
]);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports unparsed Mach-O files found while enumerating roots",
|
||||
async ({ cli }) => {
|
||||
const app = await fixtureApp();
|
||||
await withBrokenHelper(app);
|
||||
const result = await cli.run({
|
||||
arguments: ["trace-dylib-resolution", app, "--json"],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
(result.json as { normalized_result: unknown }).normalized_result,
|
||||
);
|
||||
expect(trace.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
unparsed_images: ["Contents/Helpers/broken"],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue