From d292c445ee4e9271906e444979f384c8f97008c9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:33:12 +0800 Subject: [PATCH] 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. --- src/artifacts/ArtifactProvider.ts | 40 +- src/artifacts/apple/DylibFileAccess.ts | 159 ++++++++ src/artifacts/apple/DylibResolutionReader.ts | 295 ++++---------- src/domain/apple/dylibResolution.test.ts | 26 +- src/domain/apple/dylibResolution.ts | 6 +- .../process-global/dylibFileLifetime.test.ts | 383 ++++++++++++++++++ 6 files changed, 670 insertions(+), 239 deletions(-) create mode 100644 src/artifacts/apple/DylibFileAccess.ts create mode 100644 tests/process-global/dylibFileLifetime.test.ts diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 47d5906ea5..46692f54f7 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -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; diff --git a/src/artifacts/apple/DylibFileAccess.ts b/src/artifacts/apple/DylibFileAccess.ts new file mode 100644 index 0000000000..7bdc58f91a --- /dev/null +++ b/src/artifacts/apple/DylibFileAccess.ts @@ -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 { + 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 { + 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( + path: string, + operation: (file: FileHandle, initial: BigIntStats) => Promise, + ): Promise { + 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`, + ); diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts index 92d03c7e3a..42219ddb20 100644 --- a/src/artifacts/apple/DylibResolutionReader.ts +++ b/src/artifacts/apple/DylibResolutionReader.ts @@ -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>(); readonly #images = new Map>(); - readonly #identities = new Map(); + readonly #identities = new Map(); 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 { 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 { - 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 => { 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(); - 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(); + 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 => { - 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 => { - const { signal } = options; - const handle = await open( - absolute, - constants.O_RDONLY | constants.O_NOFOLLOW, - ); - const unchanged = async (): Promise => { - 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 && diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index 13bb7c4f63..88a9e379fa 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -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", () => { diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index cf10c4b781..630fdd6942 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -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; diff --git a/tests/process-global/dylibFileLifetime.test.ts b/tests/process-global/dylibFileLifetime.test.ts new file mode 100644 index 0000000000..2f54d65b5f --- /dev/null +++ b/tests/process-global/dylibFileLifetime.test.ts @@ -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(), + blockClose: false, + readFailure: undefined as Error | undefined, + controller: undefined as AbortController | undefined, + abortOnRead: false, + abortClose: 0, + closeCount: 0, + onClose: undefined as (() => Promise) | undefined, + beforeOpen: undefined as (() => Promise) | undefined, + onRead: undefined as (() => Promise) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + open: async (...args: Parameters) => { + 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((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((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; + } +});