Files
rea/src/artifacts/DylibResolutionReader.ts
T
Kaoru0822andClaude Opus 5.5 a0ec3e2016 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 <[email protected]>
2026-10-07 11:07:31 +00:00

249 lines
7.9 KiB
TypeScript

import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, readlink, realpath } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import {
dylibResolutionInputSchema,
dylibResolutionResultSchema,
traceDylibLoading,
type DylibResolutionResult,
type DylibTreeEntry,
type DylibTreeView,
type MachoImageFacts,
} from "../domain/dylibResolution.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
const HASH_CHUNK_BYTES = 1024 * 1024;
/** Lazily probed, symlink-preserving view of one analyzed directory. */
class FilesystemTreeView implements DylibTreeView {
readonly #entries = new Map<string, Promise<DylibTreeEntry | undefined>>();
readonly #images = new Map<string, Promise<MachoImageFacts>>();
constructor(
private readonly root: string,
private readonly signal?: AbortSignal,
) {}
entry(path: string): Promise<DylibTreeEntry | undefined> {
const cached = this.#entries.get(path);
if (cached !== undefined) return cached;
const pending = this.#readEntry(path);
this.#entries.set(path, pending);
return pending;
}
image(path: string): Promise<MachoImageFacts> {
const cached = this.#images.get(path);
if (cached !== undefined) return cached;
const pending = readImage(join(this.root, path), this.signal);
this.#images.set(path, pending);
return pending;
}
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
cancelled(this.signal);
const absolute = join(this.root, path);
try {
const metadata = await lstat(absolute);
if (metadata.isSymbolicLink())
return { kind: "symlink", target: await readlink(absolute) };
if (metadata.isDirectory()) return { kind: "directory" };
return metadata.isFile() ? { kind: "file" } : undefined;
} catch (cause: unknown) {
if (missing(cause)) return undefined;
throw cause;
}
}
}
/** Resolve dyld load paths for the active Mach-O or every executable in its bundle. */
export const traceDylibResolution = async (options: {
readonly rootPath: string;
readonly targetPath: string;
readonly targetSha256: string;
readonly enumerateRoots: boolean;
readonly parameters: unknown;
readonly signal?: AbortSignal;
}): Promise<DylibResolutionResult> => {
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
if (!parsed.success)
throw new ArtifactReaderFailure(
"path",
"roots must be normalized paths relative to the analyzed root",
);
const root = await realpath(options.rootPath);
const target = relative(root, await realpath(options.targetPath))
.split(sep)
.join("/");
const view = new FilesystemTreeView(root, options.signal);
try {
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 }),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
const digests = new Map<string, string>();
for (const { path } of trace.images)
digests.set(path, await fileSha256(join(root, path), options.signal));
const targetDigest =
digests.get(target) ??
(await fileSha256(join(root, target), options.signal));
if (targetDigest !== options.targetSha256)
throw new ArtifactReaderFailure(
"integrity",
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
);
return dylibResolutionResultSchema.parse({
...trace,
root_path: options.rootPath,
target_sha256: options.targetSha256,
images: trace.images.map((image) => ({
...image,
sha256: digests.get(image.path),
})),
});
} catch (cause: unknown) {
if (options.signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Dylib resolution was cancelled",
{ cause },
);
throw cause;
}
};
/**
* 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<{ 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" ||
!(await startsWithMachoMagic(entry.adapterKey))
)
continue;
const facts = await view.image(entry.path);
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: 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[],
): Promise<void> => {
for (const path of roots) {
const entry = await view.entry(path);
if (entry?.kind !== "file")
throw new ArtifactReaderFailure(
"path",
`Root ${path} is not a regular file in the analyzed root`,
);
if ((await view.image(path)).status === "not-mach-o")
throw new ArtifactReaderFailure(
"format",
`Root ${path} is not a Mach-O image`,
);
}
};
const startsWithMachoMagic = async (path: string): Promise<boolean> => {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const buffer = new Uint8Array(4);
const { bytesRead } = await handle.read(buffer, 0, 4, 0);
return hasMachoMagic(buffer.subarray(0, bytesRead));
} finally {
await handle.close();
}
};
const readImage = async (
path: string,
signal?: AbortSignal,
): Promise<MachoImageFacts> => {
cancelled(signal);
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const { size } = await handle.stat();
return await readMachoImage(async (offset, length) => {
cancelled(signal);
const buffer = new Uint8Array(
Math.max(0, Math.min(length, size - offset)),
);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
return buffer.subarray(0, bytesRead);
}, size);
} finally {
await handle.close();
}
};
const fileSha256 = async (
path: string,
signal?: AbortSignal,
): Promise<string> => {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const hash = createHash("sha256");
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
for (;;) {
cancelled(signal);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
return hash.digest("hex");
} finally {
await handle.close();
}
};
const cancelled = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Dylib resolution was cancelled",
);
};
const missing = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR");