fix(apple): share dylib read ownership and cancellation facts

Refs #4495.

Normalize dylib file access once at the admitted descriptor boundary. Preserve full file identity, physical read outcomes, cancellation provenance and cleanup retry through artifact analysis.
This commit is contained in:
morluto committed 2026-10-17 00:44:02 +08:00
1 parent 2d163c12b1
commit d292c445ee
6 files changed
+670 -239

No files matched your search

+27 -13
View File
@@ -27,6 +27,7 @@ import {
} from "../domain/binaryTargetTypes.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
@@ -37,6 +38,10 @@ import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGr
import { err, ok, type Result } from "../domain/result.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
artifactFileCancellationCause,
bindArtifactFileCleanup,
} from "./ArtifactFileOperation.js";
import { artifactCapabilities } from "./ArtifactProviderMetadata.js";
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
import { createEvidence } from "../domain/evidence.js";
@@ -234,18 +239,18 @@ class ArtifactClient implements AnalysisClient {
(cause: unknown) => {
const failure =
cause instanceof ArtifactReaderFailure ? cause : undefined;
return err(
new ProviderCleanupError(
ARTIFACT_GRAPH_PROVIDER.id,
failure?.cleanup?.resources ?? ["artifact resources"],
{
reason:
failure?.cleanup?.reason ??
(cause instanceof Error ? cause.message : String(cause)),
},
{ cause, operation: "close_binary" },
),
const error = new ProviderCleanupError(
ARTIFACT_GRAPH_PROVIDER.id,
failure?.cleanup?.resources ?? ["artifact resources"],
{
reason:
failure?.cleanup?.reason ??
(cause instanceof Error ? cause.message : String(cause)),
},
{ cause, operation: "close_binary" },
);
bindArtifactFileCleanup(cause, error);
return err(error);
},
);
}
@@ -315,6 +320,7 @@ class ArtifactClient implements AnalysisClient {
targetSha256: this.target.sha256,
enumerateRoots: bundle !== undefined,
parameters,
resourceScope: this.#resourceScope,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
return ok(
@@ -407,23 +413,31 @@ export const translateArtifactFailure = (
operation: ArtifactAnalysisOperation,
cause: unknown,
): AnalysisError => {
if (cause instanceof ArtifactReaderFailure)
return new ArtifactOperationError(
if (cause instanceof ArtifactReaderFailure) {
const error = new ArtifactOperationError(
operation,
cause.reason,
cause.details,
cause.message,
{
cause:
cause.reason === "cancelled"
? artifactFileCancellationCause(cause)
: cause,
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
...(cause.partialObservation === undefined
? {}
: { partialObservation: cause.partialObservation }),
},
);
bindArtifactFileCleanup(cause, error);
return error;
}
// Caller-selection and unsupported-target failures are already typed; keep
// their correction details instead of reducing them to an I/O failure.
if (
cause instanceof AnalysisInputError ||
cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisUnsupportedTargetError
)
return cause;
+159
View File
@@ -0,0 +1,159 @@
import { createHash } from "node:crypto";
import type { BigIntStats } from "node:fs";
import { lstat, type FileHandle } from "node:fs/promises";
import { isAbsolute, relative, sep } from "node:path";
import {
NonRegularFileReadError,
sameRegularFileState,
} from "../../filesystem/RegularFile.js";
import {
ArtifactReaderFailure,
sameArtifactSourceIdentity,
type ArtifactEntry,
} from "../ArtifactReader.js";
import {
assertArtifactReadActive,
mapArtifactFileFailure,
withArtifactRegularFile,
} from "../ArtifactFileOperation.js";
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { readArtifactFileChunks } from "../readArtifactFileChunks.js";
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
/** One owner for dyld descriptor admission, physical identity and finalization. */
export class DylibFileAccess {
constructor(
private readonly resources: ArtifactResourceScope,
private readonly root: string,
private readonly signal?: AbortSignal,
) {}
startsWithMachoMagic(entry: ArtifactEntry): Promise<boolean> {
return this.#read(entry.adapterKey, async (file, initial) => {
if (!sameArtifactSourceIdentity(entry.sourceIdentity, initial))
throw changed(entry.adapterKey);
const bytes = new Uint8Array(4);
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
return hasMachoMagic(bytes.subarray(0, bytesRead));
});
}
image(path: string) {
return this.#read(path, async (file, identity) => {
const size = Number(identity.size);
if (!Number.isSafeInteger(size))
throw new ArtifactReaderFailure(
"limit",
`Mach-O file extent is not safely addressable: ${path}`,
);
const facts = await readMachoImage(async (offset, length) => {
assertArtifactReadActive(this.signal);
const bytes = new Uint8Array(
Math.max(0, Math.min(length, size - offset)),
);
const { bytesRead } = await file.read(bytes, 0, bytes.length, offset);
assertArtifactReadActive(this.signal);
return bytes.subarray(0, bytesRead);
}, size);
return { facts, identity };
});
}
sha256(path: string, expected: BigIntStats | undefined): Promise<string> {
return this.#read(path, async (file, initial) => {
if (expected !== undefined && !sameRegularFileState(expected, initial))
throw changed(path);
const hash = createHash("sha256");
// Admit the extent plus one byte to detect growth without following an
// actively appended file indefinitely. The descriptor remains owned.
const source = readArtifactFileChunks(file, {
end: initial.size,
signal: this.signal,
});
for await (const chunk of source) hash.update(chunk);
const coverage = source.readCoverage();
if (coverage.kind !== "complete" || coverage.bytes !== initial.size)
throw changed(path);
return hash.digest("hex");
});
}
async #read<Value>(
path: string,
operation: (file: FileHandle, initial: BigIntStats) => Promise<Value>,
): Promise<Value> {
try {
return await withArtifactRegularFile(
path,
this.resources,
this.signal,
async (file) => {
const initial = await file.stat({ bigint: true });
assertArtifactReadActive(this.signal);
const result = await operation(file, initial);
// Parsed headers, magic and digest must refer to the same physical
// version still selected by the pathname when the read completes.
const opened = await file.stat({ bigint: true });
const named = await lstat(path, { bigint: true });
if (
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, named)
)
throw changed(path);
assertArtifactReadActive(this.signal);
return result;
},
);
} catch (cause: unknown) {
throw classifyDylibReadFailure(this.root, cause);
}
}
}
/** Normalize dyld admission and permission facts inside their physical outcome. */
export const classifyDylibReadFailure = (
root: string,
cause: unknown,
): unknown =>
mapArtifactFileFailure(cause, (primary) => {
if (primary instanceof NonRegularFileReadError)
return new ArtifactReaderFailure(
"format",
`Dyld input is not a regular file: ${primary.path}`,
{ cause: primary },
);
if (
primary instanceof Error &&
"code" in primary &&
(primary.code === "EACCES" || primary.code === "EPERM")
) {
const path =
"path" in primary && typeof primary.path === "string"
? primary.path
: undefined;
const display =
path === undefined
? "a file in the analyzed root"
: deniedDisplayPath(root, path);
return new ArtifactReaderFailure(
"unavailable",
`Permission denied (${primary.code}) reading ${display}`,
{ cause: primary },
);
}
return primary;
});
const deniedDisplayPath = (root: string, path: string): string => {
const inside = relative(root, path);
const outside =
isAbsolute(inside) || inside === ".." || inside.startsWith(`..${sep}`);
return inside === "" || outside ? path : inside;
};
const changed = (path: string): ArtifactReaderFailure =>
new ArtifactReaderFailure(
"integrity",
`${path} changed while its dyld load commands were traced`,
);
+80 -215
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, readlink, realpath } from "node:fs/promises";
import type { BigIntStats } from "node:fs";
import { lstat, readlink, realpath } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
@@ -18,24 +17,26 @@ import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { consumeWithOrderedReads } from "../OrderedReads.js";
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
import {
DylibFileAccess,
classifyDylibReadFailure,
} from "./DylibFileAccess.js";
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { assertArtifactReadActive } from "../ArtifactFileOperation.js";
const OPERATION = "trace_dylib_resolution";
const HASH_CHUNK_BYTES = 1024 * 1024;
/** Concurrent four-byte magic reads while enumerating bundle roots. */
const MAGIC_READ_CONCURRENCY = 8;
/** Device, inode, size and change times of the file whose bytes were parsed. */
type FileIdentity = string;
/** 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>>();
readonly #identities = new Map<string, FileIdentity>();
readonly #identities = new Map<string, BigIntStats>();
constructor(
private readonly root: string,
private readonly files: DylibFileAccess,
private readonly signal?: AbortSignal,
) {}
@@ -50,23 +51,23 @@ class FilesystemTreeView implements DylibTreeView {
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).then(
({ facts, identity }) => {
const pending = this.files
.image(join(this.root, path))
.then(({ facts, identity }) => {
this.#identities.set(path, identity);
return facts;
},
);
});
this.#images.set(path, pending);
return pending;
}
/** Identity of the file whose header bytes `image()` parsed. */
identity(path: string): FileIdentity | undefined {
identity(path: string): BigIntStats | undefined {
return this.#identities.get(path);
}
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
cancelled(this.signal);
assertArtifactReadActive(this.signal);
const absolute = join(this.root, path);
try {
const metadata = await lstat(absolute);
@@ -88,6 +89,7 @@ export const traceDylibResolution = async (options: {
readonly targetSha256: string;
readonly enumerateRoots: boolean;
readonly parameters: unknown;
readonly resourceScope?: ArtifactResourceScope;
readonly signal?: AbortSignal;
}): Promise<DylibResolutionResult> => {
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
@@ -97,82 +99,69 @@ export const traceDylibResolution = async (options: {
{ cause: parsed.error },
projectInputIssues(parsed.error.issues, options.parameters),
);
// Canonicalization is inside the translated region: a revoked permission
// keeps its reason and path.
let root = options.rootPath;
try {
root = await realpath(options.rootPath);
const target = relative(root, await realpath(options.targetPath))
.split(sep)
.join("/");
const view = new FilesystemTreeView(root, options.signal);
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
: options.enumerateRoots
? await executableRoots(root, view, options.signal)
: { roots: [target], unclassified: [] };
const roots = await requireMachoRoots(
view,
requested,
parsed.data.roots !== undefined,
);
const trace = await traceDylibLoading(view, {
roots,
unclassified,
...(parsed.data.architecture === undefined
? {}
: { architecture: parsed.data.architecture }),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
// Each digest must describe the same file whose headers were parsed.
const digests = new Map<string, string>();
for (const { path } of trace.images)
digests.set(
path,
await fileSha256(join(root, path), {
path,
expected: view.identity(path),
...(options.signal === undefined ? {} : { signal: options.signal }),
}),
const resources = options.resourceScope ?? new ArtifactResourceScope();
return await resources.run(async () => {
// Canonicalization is inside the translated region: a revoked permission
// keeps its reason and path.
let root = options.rootPath;
try {
assertArtifactReadActive(options.signal);
root = await realpath(options.rootPath);
assertArtifactReadActive(options.signal);
const files = new DylibFileAccess(resources, root, options.signal);
const target = relative(root, await realpath(options.targetPath))
.split(sep)
.join("/");
assertArtifactReadActive(options.signal);
const view = new FilesystemTreeView(root, files, options.signal);
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
: options.enumerateRoots
? await executableRoots(root, view, files, options.signal)
: { roots: [target], unclassified: [] };
const roots = await requireMachoRoots(
view,
requested,
parsed.data.roots !== undefined,
);
const targetDigest =
digests.get(target) ??
(await fileSha256(join(root, target), {
path: target,
expected: undefined,
const trace = await traceDylibLoading(view, {
roots,
unclassified,
...(parsed.data.architecture === undefined
? {}
: { architecture: parsed.data.architecture }),
...(options.signal === undefined ? {} : { signal: 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 },
);
const denied = permissionDenied(cause);
if (denied !== undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Permission denied (${denied.code}) reading ${deniedPath(root, denied.path)}`,
{ cause },
);
throw cause;
}
});
// Each digest must describe the same file whose headers were parsed.
const digests = new Map<string, string>();
for (const { path } of trace.images)
digests.set(
path,
await files.sha256(join(root, path), view.identity(path)),
);
const targetDigest =
digests.get(target) ??
(await files.sha256(join(root, target), undefined));
if (targetDigest !== options.targetSha256)
throw new ArtifactReaderFailure(
"integrity",
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
);
assertArtifactReadActive(options.signal);
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) {
throw classifyDylibReadFailure(root, cause);
}
});
};
/**
@@ -183,6 +172,7 @@ export const traceDylibResolution = async (options: {
const executableRoots = async (
root: string,
view: FilesystemTreeView,
files: DylibFileAccess,
signal?: AbortSignal,
): Promise<{ readonly roots: string[]; readonly unclassified: string[] }> => {
const roots: string[] = [];
@@ -193,9 +183,7 @@ const executableRoots = async (
new DirectoryArtifactReader(root).entries(signal),
MAGIC_READ_CONCURRENCY,
(entry) =>
entry.kind === "file"
? startsWithMachoMagic(entry.adapterKey)
: undefined,
entry.kind === "file" ? files.startsWithMachoMagic(entry) : undefined,
async (entry, macho) => {
if (!macho) return;
const facts = await view.image(entry.path);
@@ -254,129 +242,6 @@ const requireMachoRoots = async (
return resolved;
};
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 identityOf = (metadata: {
readonly dev: bigint;
readonly ino: bigint;
readonly size: bigint;
readonly mtimeNs: bigint;
readonly ctimeNs: bigint;
}): FileIdentity =>
[
metadata.dev,
metadata.ino,
metadata.size,
metadata.mtimeNs,
metadata.ctimeNs,
].join(":");
const readImage = async (
path: string,
signal?: AbortSignal,
): Promise<{
readonly facts: MachoImageFacts;
readonly identity: FileIdentity;
}> => {
cancelled(signal);
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const metadata = await handle.stat({ bigint: true });
const size = Number(metadata.size);
const facts = 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);
return { facts, identity: identityOf(metadata) };
} finally {
await handle.close();
}
};
/** Hash a file, failing if it is not the file (or the version) that was parsed. */
const fileSha256 = async (
absolute: string,
options: {
readonly path: string;
readonly expected: FileIdentity | undefined;
readonly signal?: AbortSignal;
},
): Promise<string> => {
const { signal } = options;
const handle = await open(
absolute,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
const unchanged = async (): Promise<void> => {
if (
options.expected !== undefined &&
identityOf(await handle.stat({ bigint: true })) !== options.expected
)
throw new ArtifactReaderFailure(
"integrity",
`${options.path} changed while its dyld load commands were traced`,
);
};
try {
await unchanged();
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));
}
await unchanged();
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",
);
};
/** A denied path relative to the analyzed root, or absolute when outside it. */
const deniedPath = (root: string, path: string | undefined): string => {
if (path === undefined) return "a file in the analyzed root";
const inside = relative(root, path);
return inside === "" || inside.startsWith("..") ? path : inside;
};
/** Host permission denials, kept distinct from malformed or missing files. */
const permissionDenied = (
cause: unknown,
): { readonly code: string; readonly path: string | undefined } | undefined => {
if (!(cause instanceof Error) || !("code" in cause)) return undefined;
if (cause.code !== "EACCES" && cause.code !== "EPERM") return undefined;
return {
code: cause.code,
path:
"path" in cause && typeof cause.path === "string"
? cause.path
: undefined,
};
};
const missing = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
+16 -10
View File
@@ -585,16 +585,22 @@ describe("dyld load order", () => {
]);
});
it("stops when cancelled", async () => {
const controller = new AbortController();
controller.abort();
await expect(
traceDylibLoading(memoryView({ [MAIN]: executable() }), {
roots: [MAIN],
signal: controller.signal,
}),
).rejects.toThrow();
});
it.each([undefined, null, "caller reason"])(
"stops with the exact caller cancellation reason: %s",
async (reason) => {
const controller = new AbortController();
controller.abort(reason);
await expect(
traceDylibLoading(memoryView({ [MAIN]: executable() }), {
roots: [MAIN],
signal: controller.signal,
}),
).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
cause: controller.signal.reason,
});
},
);
});
describe("loader-relative dylib resolution", () => {
+5 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { AnalysisCancelledError } from "../analysisErrorCore.js";
import {
compatibleSlice,
@@ -516,7 +517,10 @@ const traceProcess = async (
context.byInstallName.set(rootSlice.install_name, context.root);
const pending = [context.root];
while (pending.length > 0) {
signal?.throwIfAborted();
if (signal?.aborted)
throw new AnalysisCancelledError("trace_dylib_resolution", {
cause: signal.reason,
});
const loader = pending.pop() ?? "";
const current = context.loaded.get(loader);
if (current === undefined) continue;
@@ -0,0 +1,383 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdir, rename, writeFile, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const fault = vi.hoisted(() => ({
path: "",
handles: [] as FileHandle[],
blocked: new Set<FileHandle>(),
blockClose: false,
readFailure: undefined as Error | undefined,
controller: undefined as AbortController | undefined,
abortOnRead: false,
abortClose: 0,
closeCount: 0,
onClose: undefined as (() => Promise<void>) | undefined,
beforeOpen: undefined as (() => Promise<void>) | undefined,
onRead: undefined as (() => Promise<void>) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
open: async (...args: Parameters<typeof original.open>) => {
if (args[0] === fault.path) await fault.beforeOpen?.();
const handle = await original.open(...args);
if (args[0] !== fault.path) return handle;
fault.handles.push(handle);
if (fault.blockClose) fault.blocked.add(handle);
const read = handle.read.bind(handle);
handle.read = (async (...readArgs: unknown[]) => {
if (fault.abortOnRead) fault.controller?.abort(null);
if (fault.readFailure !== undefined) throw fault.readFailure;
const result = await Reflect.apply(read, handle, readArgs);
await fault.onRead?.();
return result;
}) as FileHandle["read"];
const close = handle.close.bind(handle);
handle.close = async () => {
fault.closeCount += 1;
await fault.onClose?.();
if (fault.closeCount === fault.abortClose)
fault.controller?.abort(null);
if (fault.blocked.has(handle))
throw new Error("controlled dyld close failure");
await close();
};
return handle;
},
};
});
import { traceDylibResolution } from "../../src/artifacts/apple/DylibResolutionReader.js";
import { machoImage } from "../../src/artifacts/apple/MachoImage.fixture.js";
import { ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
import { ArtifactProvider } from "../../src/artifacts/ArtifactProvider.js";
import type { AnalysisClient } from "../../src/application/AnalysisProvider.js";
import { parseBinaryTarget } from "../../src/application/BinaryTargetResolver.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const clients: AnalysisClient[] = [];
beforeEach(() => {
fault.path = "";
fault.handles = [];
fault.blocked.clear();
fault.blockClose = false;
fault.readFailure = undefined;
fault.controller = undefined;
fault.abortOnRead = false;
fault.abortClose = 0;
fault.closeCount = 0;
fault.onClose = undefined;
fault.beforeOpen = undefined;
fault.onRead = undefined;
});
afterEach(async () => {
fault.blocked.clear();
fault.onClose = undefined;
for (const client of clients.splice(0)) await client.close();
for (const handle of fault.handles)
if (handle.fd !== -1) await handle.close();
});
const fixture = async () => {
const root = await createTestTempDirectory("rea-dyld-lifetime-");
const path = join(root, "Main");
const bytes = machoImage({});
await writeFile(path, bytes);
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const controller = new AbortController();
fault.path = path;
fault.controller = controller;
const sha256 = createHash("sha256").update(bytes).digest("hex");
const client = new ArtifactProvider({}).createClient(target.value);
clients.push(client);
return {
controller,
client,
trace: () =>
traceDylibResolution({
rootPath: root,
targetPath: path,
targetSha256: sha256,
enumerateRoots: false,
parameters: { roots: ["Main"] },
signal: controller.signal,
}),
};
};
it("preserves a dyld read failure when the same read observes cancellation", async () => {
const { trace } = await fixture();
const cause = Object.assign(new Error("controlled dyld EIO"), {
code: "EIO",
});
fault.readFailure = cause;
fault.abortOnRead = true;
await expect(trace()).rejects.toBe(cause);
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it("observes cancellation during the final dyld digest close", async () => {
const { trace } = await fixture();
fault.abortClose = 2;
await expect(trace()).rejects.toMatchObject({
reason: "cancelled",
cancellation: { reason: null },
});
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it.each(["completed", "failed", "cancelled"] as const)(
"retains the exact dyld descriptor and %s outcome when close fails",
async (kind) => {
const { trace } = await fixture();
fault.blockClose = true;
const cause = new ArtifactReaderFailure("io", "controlled primary");
if (kind === "failed") fault.readFailure = cause;
if (kind === "cancelled") fault.abortClose = 1;
const failure = await trace().catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure);
if (!(failure instanceof ArtifactFileCleanupFailure))
throw new Error("Expected exact descriptor cleanup capability");
expect(failure.fileCleanupFailure.owner.handle).toBe(fault.handles[0]);
if (kind === "cancelled")
expect(failure).toMatchObject({
reason: "cancelled",
cancellation: { reason: null },
});
expect(failure.fileCleanupFailure.outcome.kind).toBe(
kind === "failed" ? "failed" : "completed",
);
if (kind === "failed")
expect(failure.fileCleanupFailure.outcome).toEqual({
kind: "failed",
cause,
});
fault.blocked.clear();
await failure.cleanupLease.release();
expect(fault.handles[0]?.fd).toBe(-1);
},
);
it("projects cancellation with cleanup uncertainty when dyld final close fails", async () => {
const { client, controller } = await fixture();
fault.abortClose = 1;
fault.blockClose = true;
const result = await client.execute(
"trace_dylib_resolution",
{ roots: ["Main"] },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected interruption during finalization");
expect(result.error).toMatchObject({
reason: "cancelled",
cause: null,
cleanupIncomplete: true,
});
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
});
it.skipIf(process.platform === "win32")(
"retains nonregular admission classification and its exact failed close",
async () => {
const { client } = await fixture();
fault.beforeOpen = async () => {
fault.beforeOpen = undefined;
await rename(fault.path, `${fault.path}.old`);
await mkdir(fault.path);
};
fault.blockClose = true;
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
if (result.ok) throw new Error("Expected nonregular admission failure");
expect(result.error).toMatchObject({
reason: "format",
cleanupIncomplete: true,
});
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
expect(fault.handles).toHaveLength(1);
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
expect(fault.handles[0]?.fd).toBe(-1);
},
);
it.each(["EACCES", "EPERM"])(
"retains %s semantics alongside failed cleanup",
async (code) => {
const { client } = await fixture();
fault.readFailure = Object.assign(
new Error("controlled permission failure"),
{ code },
);
fault.blockClose = true;
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
if (result.ok) throw new Error("Expected permission failure");
expect(result.error).toMatchObject({
reason: "unavailable",
cleanupIncomplete: true,
});
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
},
);
it("rejects a substituted pathname after parsing the original descriptor", async () => {
const { trace } = await fixture();
fault.onRead = async () => {
fault.onRead = undefined;
await rename(fault.path, `${fault.path}.old`);
await writeFile(fault.path, machoImage({}));
};
await expect(trace()).rejects.toMatchObject({ reason: "integrity" });
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
});
it.skipIf(process.platform === "win32").each(["magic", "image", "digest"])(
"rejects a FIFO substituted at the compiled dyld %s open without a writer",
async (stage) => {
await fixture();
const path = fault.path;
fault.path = "";
const module = pathToFileURL(
join(process.cwd(), "dist/artifacts/apple/DylibResolutionReader.js"),
).href;
const script = `
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { dirname } from 'node:path';
const [stage, path] = process.argv.slice(1);
const sha256 = createHash('sha256').update(await fs.promises.readFile(path)).digest('hex');
const nativeOpen = fs.promises.open;
let opens = 0;
let substituted = false;
fs.promises.open = async (selected, ...args) => {
if (selected === path && ++opens === (stage === 'digest' ? 2 : 1)) {
await fs.promises.rename(path, path + '.old');
execFileSync('mkfifo', [path]);
substituted = true;
}
return await nativeOpen(selected, ...args);
};
syncBuiltinESMExports();
const { traceDylibResolution } = await import(${JSON.stringify(module)});
try {
await traceDylibResolution({ rootPath: dirname(path), targetPath: path,
targetSha256: sha256, enumerateRoots: stage === 'magic',
parameters: stage === 'magic' ? {} : { roots: ['Main'] } });
throw new Error('Unexpected FIFO success');
} catch (error) {
if (error.reason !== 'format') throw error;
process.stdout.write(JSON.stringify({ stage, opens, substituted, reason: error.reason }));
}
`;
const result = await promisify(execFile)(
process.execPath,
["--input-type=module", "--eval", script, stage, path],
{ timeout: 5000 },
);
expect(JSON.parse(result.stdout)).toEqual({
stage,
opens: stage === "digest" ? 2 : 1,
substituted: true,
reason: "format",
});
},
);
it("binds both dyld failure and failed provider-close projections to the same descriptor", async () => {
const { client } = await fixture();
fault.blockClose = true;
fault.readFailure = new ArtifactReaderFailure("io", "controlled primary");
const result = await client.execute("trace_dylib_resolution", {
roots: ["Main"],
});
if (result.ok) throw new Error("Expected failed dyld read");
expect(result.error).toMatchObject({ reason: "io", cleanupIncomplete: true });
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
const close = await client.close();
if (close.ok) throw new Error("Expected retained descriptor");
expect(close.error.cleanupIncomplete).toBe(true);
expect(fault.handles).toHaveLength(1);
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
expect(close.error.cleanupIncomplete).toBe(false);
expect(fault.handles[0]?.fd).toBe(-1);
});
it("preserves the caller's explicit null cancellation reason through dyld provider projection", async () => {
const { client, controller } = await fixture();
fault.abortOnRead = true;
fault.blockClose = true;
const result = await client.execute(
"trace_dylib_resolution",
{ roots: ["Main"] },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected cancelled dyld read");
expect(result.error).toMatchObject({
reason: "cancelled",
cleanupIncomplete: true,
});
expect(result.error.cause).toBeNull();
fault.blocked.clear();
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(result.error.cleanupIncomplete).toBe(false);
expect(fault.handles[0]?.fd).toBe(-1);
});
it("waits for an in-flight final dyld close before completing provider close", async () => {
const { client, controller } = await fixture();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
fault.onClose = async () => {
if (fault.closeCount === 2) await gate;
};
const running = client.execute(
"trace_dylib_resolution",
{ roots: ["Main"] },
{ signal: controller.signal },
);
try {
await expect.poll(() => fault.closeCount).toBe(2);
let closed = false;
const closing = client.close().then((result) => {
closed = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closed).toBe(false);
controller.abort(null);
release();
await expect(running).resolves.toMatchObject({
ok: false,
error: { reason: "cancelled", cause: null },
});
await expect(closing).resolves.toMatchObject({ ok: true });
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
} finally {
release();
await running;
}
});