diff --git a/docs/process-capture.md b/docs/process-capture.md index e4b10435be..885606daa8 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -652,6 +652,33 @@ that directory: it outlives each run, so the first compilation into a new directory pays a cold-cache build and later runs reuse it. An absent or empty value keeps the shared default. +With the default `xcrun` compiler and module cache, REA also keeps one verified +copy of the compiled helper in `~/Library/Caches/rea/process-helper/`, so later +REA processes skip both the compile and macOS's first-launch assessment of a +new executable. Each entry is keyed by the helper source, the `swiftc` and SDK +that `xcrun --find swiftc` and `xcrun --show-sdk-path` resolve (their real +paths, sizes and modification times, so `DEVELOPER_DIR`, `TOOLCHAINS`, +`xcode-select` and in-place toolchain updates all select a new entry), the CPU +architecture and the Darwin release. Before running an entry, REA requires the +cache directory, the entry and its files to be owned by the current user and +not writable by group or others, every real parent directory to be owned by +the current user or root and not writable by others (sticky root-owned +directories excepted), no ACL on any of them to allow another principal a +write-class right (the default `deny delete` entries are fine), the helper and +manifest to be regular files rather than +links or FIFOs, and the helper's SHA-256 to match the manifest. Anything else is +never executed and never removed, because another REA process may still be +verifying or running that path; REA then compiles a per-process helper as +before. A published entry is never replaced. If the cache is unavailable, for +example under a read-only home directory, the ownership operation proceeds +with the per-process compile. These checks reject corruption, loosened +permissions and other users, but they do not defend against another process +running as the same user, which could already modify REA itself. Setting +`REA_DARWIN_SWIFTC_PATH` or `REA_DARWIN_MODULE_CACHE_PATH` keeps the per-process +compile and writes nothing to this cache. REA does not prune old entries; each +is about 240 KiB. Delete the directory while no REA process is running, or run +`rea uninstall --purge-data`, to remove them. + `xcrun` also maintains a separate tool-lookup cache. Its `--no-cache` option refreshes entries and does not guarantee a write-free lookup. To avoid that lookup while compiling REA's helper, set `REA_DARWIN_SWIFTC_PATH` to the absolute diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 8074067e57..43c2196fd4 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -25,6 +25,7 @@ import { defaultKunaCacheDirectory, sameLexicalDirectory, } from "../config/kunaCacheDirectory.js"; +import { defaultDarwinHelperCacheDirectory } from "../process/DarwinHelperCache.js"; import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js"; import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js"; @@ -1163,6 +1164,9 @@ const purgeSelectedData = async ( ), ); } + // The ownership helper cache has no caller-selected location to retain. + if (platform === "darwin") + directories.push(defaultDarwinHelperCacheDirectory(home)); if (platform === "linux") { const hopper = linuxHopperReleaseCacheDirectory(environment, home); if (hopper.ok) directories.push(hopper.value); diff --git a/src/process/DarwinHelperCache.ts b/src/process/DarwinHelperCache.ts new file mode 100644 index 0000000000..61ac6e0150 --- /dev/null +++ b/src/process/DarwinHelperCache.ts @@ -0,0 +1,465 @@ +import { createHash } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { release } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { z } from "zod"; + +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { safeParseJson } from "../domain/safeJson.js"; +import { appendCallerPath } from "../filesystem/callerPath.js"; +import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { withOwnedRegularFile } from "../filesystem/OwnedRegularFile.js"; +import { readStableFileBytes } from "../filesystem/readStableFileBytes.js"; +import { execFileOutput } from "./ExecFileOutput.js"; + +/** Bumped when the entry layout or the key material changes meaning. */ +const CACHE_FORMAT = 2; +const HELPER_NAME = "reader"; +const MANIFEST_NAME = "manifest.json"; +const MAX_MANIFEST_BYTES = 4096; +// The compiled helper is about 240 KiB; refuse to hash anything far larger. +const MAX_HELPER_BYTES = 16 * 1024 * 1024; +// xcrun answers a lookup in milliseconds; a stalled one only forgoes reuse. +const TOOLCHAIN_LOOKUP_TIMEOUT_MS = 15_000; + +const manifestSchema = z.strictObject({ + format: z.literal(CACHE_FORMAT), + key: z.string().regex(/^[a-f0-9]{64}$/u), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + size: z.int().positive(), +}); + +// Sticky directories let only an entry's owner rename or remove it. +const STICKY = 0o1000; + +const sha256 = (value: string | Uint8Array): string => + createHash("sha256").update(value).digest("hex"); + +/** Per-user namespace REA owns for the compiled Darwin ownership helper. */ +export const defaultDarwinHelperCacheDirectory = (home: string): string => + appendCallerPath(home, "Library/Caches/rea/process-helper", "darwin"); + +/** + * Only the default toolchain shares a helper: a caller-selected compiler or + * module cache keeps every helper write inside the boundary the caller chose. + */ +export const darwinHelperCacheDirectory = (selection: { + readonly home: string | undefined; + readonly compilerSelected: boolean; + readonly moduleCachePath: string | undefined; +}): string | undefined => + selection.compilerSelected || + (selection.moduleCachePath ?? "") !== "" || + selection.home === undefined || + !isAbsolute(selection.home) + ? undefined + : defaultDarwinHelperCacheDirectory(selection.home); + +/** Only the current user may have written an entry REA will execute. */ +const privatelyOwned = (stats: Stats, uid: number): boolean => + stats.uid === uid && (stats.mode & 0o022) === 0; + +/** + * No other user can rename an entry on the path: each real ancestor belongs + * to this user or root and is not writable by others, unless it is a sticky + * root-owned directory, where only an entry's owner may rename it. + */ +const ancestorsTrusted = async ( + path: string, + uid: number, +): Promise => { + for (let current = path; ; current = dirname(current)) { + const stats = await lstat(current); + if (!stats.isDirectory() || (stats.uid !== uid && stats.uid !== 0)) + return false; + if ( + (stats.mode & 0o022) !== 0 && + !(stats.uid === 0 && (stats.mode & STICKY) !== 0) + ) + return false; + if (dirname(current) === current) return true; + } +}; + +/** Every real directory from the cache root up to the filesystem root. */ +const realAncestors = (path: string): string[] => { + const paths = [path]; + for (let current = path; dirname(current) !== current;) { + current = dirname(current); + paths.push(current); + } + return paths; +}; + +// Rights an ACL can grant beyond the POSIX mode bits that permit replacing an +// entry or its contents. +const ACL_WRITE_RIGHTS: ReadonlySet = new Set([ + "write", + "append", + "writeattr", + "writeextattr", + "writesecurity", + "chown", + "delete", + "add_file", + "add_subdirectory", + "delete_child", +]); + +/** + * `ls -le` lists each ACL entry as `N: principal [inherited] allow|deny + * rights`. macOS homes carry `deny delete` entries by default; only an + * `allow` of a write-class right lets someone the mode bits exclude write. + */ +export const aclListingGrantsWrite = (listing: string): boolean => + listing.split("\n").some((line) => { + const entry = + /^\s*\d+:\s+\S+\s+(?:inherited\s+)?(allow|deny)\s+(\S+)/u.exec(line); + return ( + entry?.[1] === "allow" && + (entry[2] ?? "").split(",").some((right) => ACL_WRITE_RIGHTS.has(right)) + ); + }); + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + +/** Identity of a file whose bytes shape the compiled helper. */ +const fileIdentity = async (path: string) => { + const real = await realpath(path); + const facts = await stat(real); + return { path: real, size: facts.size, mtime_ms: facts.mtimeMs }; +}; + +/** Result of looking up the helper for this exact source and toolchain. */ +export type DarwinHelperLookup = + | { readonly kind: "hit"; readonly executable: string } + | { readonly kind: "miss"; readonly key: string } + | { readonly kind: "unavailable" }; + +/** + * Content-addressed reuse of the compiled ownership helper across REA + * processes. Every failure falls back to the per-process compile: the cache + * never fails an ownership operation and never executes an entry that is not + * a private, current-user, digest-verified copy for this key. A published + * entry is never replaced, so a running reader's helper path stays valid. + * Same-user tampering is outside this boundary, as it is for the per-process + * helper. + */ +export const createDarwinHelperCache = (options: { + readonly directory: string; + readonly sources: readonly string[]; + /** The xcrun that resolves the toolchain the compile will use. */ + readonly xcrun: string; + readonly environment: Readonly; +}) => { + const uid = process.getuid?.(); + // Failed closes and removals stay owned until close() retries them. + const retained = new Map Promise>(); + // The first unresolved close failure; later ones depend on it. + let cleanupAnchor: ProviderCleanupError | undefined; + let key: Promise | undefined; + + const xcrun = async ( + arguments_: readonly string[], + signal: AbortSignal | undefined, + ): Promise => { + const { stdout } = await execFileOutput(options.xcrun, arguments_, { + env: options.environment, + timeout: TOOLCHAIN_LOOKUP_TIMEOUT_MS, + maxBuffer: 64 * 1024, + stopSignal: "SIGTERM", + ...(signal === undefined ? {} : { signal }), + }); + const path = stdout.trim(); + if (!isAbsolute(path)) + throw new Error(`xcrun ${arguments_.join(" ")} returned no path`); + return path; + }; + + /** Wait for every started read before reporting the first failure. */ + const settleAll = async (pending: { + readonly [Index in keyof Values]: Promise; + }): Promise => { + await Promise.allSettled(pending); + return Promise.all(pending) as Promise; + }; + + /** xcrun owns toolchain selection; key on what it resolves. */ + const computeKey = async (signal: AbortSignal | undefined) => { + const [sources, compilerPath, sdkPath] = await settleAll< + [string[], string, string] + >([ + Promise.all( + options.sources.map(async (source) => sha256(await readFile(source))), + ), + xcrun(["--find", "swiftc"], signal), + xcrun(["--show-sdk-path"], signal), + ]); + const [compiler, sdk] = await settleAll< + [ + Awaited>, + Awaited>, + ] + >([ + fileIdentity(compilerPath), + fileIdentity(join(sdkPath, "SDKSettings.json")), + ]); + return sha256( + JSON.stringify({ + format: CACHE_FORMAT, + sources, + compiler, + sdk, + architecture: process.arch, + os_release: release(), + }), + ); + }; + + /** A cancelled or failed key computation is retried by the next lookup. */ + const cacheKey = (signal: AbortSignal | undefined): Promise => { + if (key !== undefined) return key; + const pending = computeKey(signal); + key = pending; + pending.catch(() => { + if (key === pending) key = undefined; + }); + return pending; + }; + + /** On macOS, no ACL may grant a write that the mode bits withhold. */ + const withoutAclWriteGrants = async ( + paths: readonly string[], + signal: AbortSignal | undefined, + ): Promise => { + if (process.platform !== "darwin") return true; + const { stdout } = await execFileOutput( + "/bin/ls", + ["-lde", "--", ...paths], + { + env: options.environment, + timeout: TOOLCHAIN_LOOKUP_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + stopSignal: "SIGTERM", + ...(signal === undefined ? {} : { signal }), + }, + ); + return !aclListingGrantsWrite(stdout); + }; + + /** The real cache root, created privately, with only trusted ancestors. */ + const trustedRoot = async (owner: number): Promise => { + await mkdir(options.directory, { recursive: true, mode: 0o700 }); + const named = await lstat(options.directory); + if (!named.isDirectory() || !privatelyOwned(named, owner)) return undefined; + const root = await realpath(options.directory); + return (await ancestorsTrusted(root, owner)) ? root : undefined; + }; + + const retainClose = (owner: OwnedFileHandle): void => { + retained.set(`file handle ${String(owner.handle.fd)}`, () => owner.close()); + }; + + /** Hash the opened descriptor itself; FIFOs and links never block or pass. */ + const readEntryFile = ( + path: string, + owner: number, + maximumBytes: number, + executable: boolean, + ): Promise => + withOwnedRegularFile( + path, + { symlinks: "reject", retainFailedClose: retainClose }, + async ({ handle, initial }) => + !privatelyOwned(initial, owner) || + (executable && (initial.mode & 0o100) === 0) || + initial.size === 0 || + initial.size > maximumBytes + ? undefined + : readStableFileBytes(handle, path, maximumBytes, { + symlinks: "reject", + }), + ).catch(() => undefined); + + const verifiedHelper = async ( + entry: string, + entryKey: string, + owner: number, + ): Promise => { + const manifestBytes = await readEntryFile( + join(entry, MANIFEST_NAME), + owner, + MAX_MANIFEST_BYTES, + false, + ); + const decoded = + manifestBytes === undefined + ? undefined + : safeParseJson(manifestBytes.toString("utf8")); + const manifest = + decoded?.ok === true + ? manifestSchema.safeParse(decoded.value) + : undefined; + if (manifest?.success !== true || manifest.data.key !== entryKey) + return undefined; + const executable = join(entry, HELPER_NAME); + const helper = await readEntryFile( + executable, + owner, + MAX_HELPER_BYTES, + true, + ); + return helper !== undefined && + helper.byteLength === manifest.data.size && + sha256(helper) === manifest.data.sha256 + ? executable + : undefined; + }; + + const lookup = async (signal?: AbortSignal): Promise => { + if (uid === undefined) return { kind: "unavailable" }; + try { + const root = await trustedRoot(uid); + if (root === undefined) return { kind: "unavailable" }; + const entryKey = await cacheKey(signal); + const entry = join(root, entryKey); + let entryStats: Stats; + try { + entryStats = await lstat(entry); + } catch (cause: unknown) { + if (isMissing(cause)) return { kind: "miss", key: entryKey }; + throw cause; + } + // An invalid entry is left in place: removing it could race another + // process that has just verified or published that path. + if (!entryStats.isDirectory() || !privatelyOwned(entryStats, uid)) + return { kind: "unavailable" }; + const executable = await verifiedHelper(entry, entryKey, uid); + if ( + executable === undefined || + !(await withoutAclWriteGrants( + [ + ...realAncestors(root), + entry, + executable, + join(entry, MANIFEST_NAME), + ], + signal, + )) + ) + return { kind: "unavailable" }; + return { kind: "hit", executable }; + } catch { + return { kind: "unavailable" }; + } + }; + + const removeStaging = async (path: string): Promise => { + const remove = () => rm(path, { recursive: true, force: true }); + await remove().catch(() => { + retained.set(path, remove); + }); + }; + + /** + * Atomically publish a verified copy and return the shared helper to run. + * macOS assesses each new executable file on its first launch (about 0.3 s), + * so running the shared copy pays that once for every later process. + */ + const publish = async ( + entryKey: string, + compiled: string, + signal?: AbortSignal, + ): Promise => { + if (uid === undefined) return undefined; + let staging: string | undefined; + try { + const root = await trustedRoot(uid); + if ( + root === undefined || + !(await withoutAclWriteGrants(realAncestors(root), signal)) + ) + return undefined; + staging = await mkdtemp(join(root, ".publish-")); + const copied = join(staging, HELPER_NAME); + await copyFile(compiled, copied, constants.COPYFILE_EXCL); + await chmod(copied, 0o700); + const helper = await readEntryFile(copied, uid, MAX_HELPER_BYTES, true); + if (helper === undefined) return undefined; + await writeFile( + join(staging, MANIFEST_NAME), + JSON.stringify({ + format: CACHE_FORMAT, + key: entryKey, + sha256: sha256(helper), + size: helper.byteLength, + }), + { mode: 0o600, flag: "wx" }, + ); + const entry = join(root, entryKey); + try { + await rename(staging, entry); + staging = undefined; + return join(entry, HELPER_NAME); + } catch { + // A concurrent publisher won; run its entry only once it verifies. + const winner = await lookup(signal); + return winner.kind === "hit" ? winner.executable : undefined; + } + } catch { + // Publication is an optimization; the caller keeps its own helper. + return undefined; + } finally { + if (staging !== undefined) await removeStaging(staging); + } + }; + + /** Retry every retained close or removal; unresolved ones stay owned. */ + const close = async (): Promise => { + const failed: string[] = []; + // Deleting the visited entry while iterating a Map is well defined. + for (const [resource, cleanup] of retained) { + try { + await cleanup(); + retained.delete(resource); + } catch { + failed.push(resource); + } + } + if (failed.length > 0) { + const failure = new ProviderCleanupError( + "process-ownership", + failed, + { + reason: + "Darwin ownership-helper cache resources remain owned until cleanup succeeds.", + }, + { operation: "prepare_owned_process" }, + ); + // Every returned failure clears once a later retry releases them all. + if (cleanupAnchor === undefined) cleanupAnchor = failure; + else failure.bindCleanupDependencies([cleanupAnchor]); + throw failure; + } + cleanupAnchor?.resolveCleanup(); + cleanupAnchor = undefined; + }; + + return { lookup, publish, close }; +}; + +export type DarwinHelperCache = ReturnType; diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 333c70a68c..7de6ebe20b 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -20,6 +20,7 @@ import type { import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { safeParseJson } from "../domain/safeJson.js"; import { boundedUtf8Text } from "./boundedUtf8Text.js"; +import { createDarwinHelperCache } from "./DarwinHelperCache.js"; import { snapshotEnvironment } from "./snapshotEnvironment.js"; import { execFileOutput, @@ -394,6 +395,19 @@ const waitForCompilation = async ( }); }; +/** The kernel or policy refused to launch the file, before it could answer. */ +const helperLaunchFailed = (cause: unknown): boolean => { + const failure = execFileOutputFailure(cause); + // spawn rejects with the bare errno before any output exists. + const code = + failure?.code ?? + (cause instanceof Error && "code" in cause ? cause.code : undefined); + return ( + ["EACCES", "EPERM", "ENOEXEC", "ENOENT"].includes(String(code)) || + (failure?.signal === "SIGKILL" && !failure.killed) + ); +}; + /** Explicit tool selection; direct Swift compilation also needs its matching SDK. */ export type DarwinProcessReaderCompiler = | { readonly kind: "xcrun"; readonly executable: string } @@ -417,6 +431,13 @@ export const createDarwinProcessRunTokenReader = ( * reader because cleanup only removes the per-run helper root. */ readonly moduleCachePath?: string; + /** + * REA-owned per-user directory that reuses a verified compiled helper + * across processes with the xcrun compiler. Unset compiles once per + * reader, as callers selecting their own compiler, module cache, or write + * boundary expect. + */ + readonly helperCacheDirectory?: string; } = {}, ) => { const compiler = options.compiler ?? { @@ -429,8 +450,21 @@ export const createDarwinProcessRunTokenReader = ( environment, }); const moduleCachePath = options.moduleCachePath ?? ""; + const helperCache = + options.helperCacheDirectory === undefined || compiler.kind !== "xcrun" + ? undefined + : createDarwinHelperCache({ + directory: options.helperCacheDirectory, + sources: sourceFiles, + xcrun: compiler.executable, + environment, + }); let root: ProcessReaderRoot | undefined; let executable: string | undefined; + // The shared copy runs while it launches; this reader's own helper is kept. + let sharedExecutable: string | undefined; + let perRunExecutable: string | undefined; + let bypassCache = false; let compilation: ProcessReaderCompilation | undefined; let closePromise: Promise | undefined; let closed = false; @@ -489,6 +523,16 @@ export const createDarwinProcessRunTokenReader = ( "Direct Darwin helper compilation requires absolute REA_DARWIN_SWIFTC_PATH and SDKROOT paths selecting a compatible installed Swift compiler and macOS SDK.", ); } + const cached = bypassCache + ? undefined + : await helperCache?.lookup(signal); + if (signal.aborted || closed) throw abortReason(signal); + // A verified shared entry needs no per-run root and is never removed. + if (cached?.kind === "hit") { + sharedExecutable = cached.executable; + executable = cached.executable; + return cached.executable; + } operationRoot = { path: await mkdtemp(join(tmpdir(), "rea-process-token-")), }; @@ -532,8 +576,16 @@ export const createDarwinProcessRunTokenReader = ( throw compilerInspectionError(cause, compilerTimeoutMs, compiler); } if (signal.aborted || closed) throw abortReason(signal); - executable = output; - return output; + // The per-run root stays owned until close even when the shared copy runs. + const shared = + cached?.kind === "miss" + ? await helperCache?.publish(cached.key, output, signal) + : undefined; + if (signal.aborted || closed) throw abortReason(signal); + perRunExecutable = output; + sharedExecutable = shared; + executable = shared ?? output; + return executable; } catch (cause: unknown) { operation.primaryFailure = { cause }; if (operationRoot !== undefined) { @@ -644,8 +696,7 @@ export const createDarwinProcessRunTokenReader = ( ): Promise => { if (closed) return Promise.reject(new ProcessAdmissionError("reader_closed")); - const pending = (async () => { - const binary = await compile(signal); + const execute = async (binary: string) => { const { stdout } = await execFileOutput(binary, arguments_, { env: environment, timeout: 15_000, @@ -654,6 +705,22 @@ export const createDarwinProcessRunTokenReader = ( }); signal?.throwIfAborted(); return stdout; + }; + const pending = (async () => { + const binary = await compile(signal); + // Decide before awaiting: a concurrent fallback may already switch. + const usedShared = binary === sharedExecutable; + try { + return await execute(binary); + } catch (cause: unknown) { + if (!usedShared || !helperLaunchFailed(cause)) throw cause; + // The shared copy cannot run here, for example from a noexec home + // volume; this reader keeps its own helper from now on. + bypassCache = true; + sharedExecutable = undefined; + if (executable === binary) executable = perRunExecutable; + return execute(await compile(signal)); + } })(); activeCommands.add(pending); return pending.finally(() => { @@ -774,7 +841,24 @@ export const createDarwinProcessRunTokenReader = ( await operation.promise; } await Promise.allSettled(activeCommands); - await cleanupRoot(); + // Each owner keeps its own failed cleanup for the next close attempt; + // a failure of both stays incomplete until both owners recover. + const [rootCleanup, cacheCleanup] = await Promise.allSettled([ + cleanupRoot(), + helperCache?.close(), + ]); + if ( + rootCleanup.status === "rejected" && + cacheCleanup.status === "rejected" && + rootCleanup.reason instanceof ProviderCleanupError && + cacheCleanup.reason instanceof ProviderCleanupError + ) + throw analysisErrorWithCleanupFailure( + rootCleanup.reason, + cacheCleanup.reason, + ).bindCleanupDependencies([rootCleanup.reason, cacheCleanup.reason]); + if (rootCleanup.status === "rejected") throw rootCleanup.reason; + if (cacheCleanup.status === "rejected") throw cacheCleanup.reason; })().catch((cause: unknown) => { if (closePromise === pendingClose) closePromise = undefined; throw cause; diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 15ca968f41..10f67cd8fc 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -24,6 +24,7 @@ import { createDarwinProcessRunTokenReader, type DarwinProcessReaderCompiler, } from "./DarwinProcessRunTokenReader.js"; +import { darwinHelperCacheDirectory } from "./DarwinHelperCache.js"; import { ProcessRunTokenReadError, readProcessRunTokens, @@ -1096,6 +1097,11 @@ export const createSystemProcessOwnershipHost = ( executable: swiftcPath, sdkRoot: hostEnvironment.SDKROOT ?? "", }); + const helperCacheDirectory = darwinHelperCacheDirectory({ + home: hostEnvironment.HOME, + compilerSelected: darwinCompiler !== undefined, + moduleCachePath: darwinModuleCachePath, + }); const darwinTokens = platform === "darwin" ? createDarwinProcessRunTokenReader({ @@ -1104,6 +1110,9 @@ export const createSystemProcessOwnershipHost = ( ...(darwinModuleCachePath === undefined ? {} : { moduleCachePath: darwinModuleCachePath }), + ...(helperCacheDirectory === undefined + ? {} + : { helperCacheDirectory }), ...(options.compileTimeoutMs === undefined ? {} : { compileTimeoutMs: options.compileTimeoutMs }), diff --git a/tests/boundary/filesystem/managedDataPurge.test.ts b/tests/boundary/filesystem/managedDataPurge.test.ts index 5465247aeb..e5dd717930 100644 --- a/tests/boundary/filesystem/managedDataPurge.test.ts +++ b/tests/boundary/filesystem/managedDataPurge.test.ts @@ -204,6 +204,22 @@ it.each([ }, ); +it("purges the Darwin ownership-helper cache with the default Kuna namespace", async () => { + const home = await createTestTempDirectory("rea-managed-helper-cache-"); + const helper = join(home, "Library/Caches/rea/process-helper"); + await mkdir(join(helper, "a".repeat(64)), { recursive: true }); + await writeFile(join(helper, "a".repeat(64), "reader"), "REA helper"); + const host = systemUninstallHost(home, undefined, {}, "darwin"); + try { + expect(await host.purgeData()).toContainEqual( + expect.objectContaining({ name: "process-helper", status: "removed" }), + ); + await expect(lstat(helper)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await host.close?.(); + } +}); + it("reports the Hopper producer's invalid cache selection rather than silently falling back", async () => { const home = await createTestTempDirectory("rea-managed-cache-invalid-"); const fallback = join(home, ".cache", "rea", "hopper-releases"); diff --git a/tests/boundary/process/darwinHelperCache.test.ts b/tests/boundary/process/darwinHelperCache.test.ts new file mode 100644 index 0000000000..192622ca55 --- /dev/null +++ b/tests/boundary/process/darwinHelperCache.test.ts @@ -0,0 +1,527 @@ +import { execFile } from "node:child_process"; +import { + chmod, + lstat, + mkdir, + readFile, + readdir, + realpath, + rm, + stat, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it, vi } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFakeXcrun } from "../../fixtures/darwinHelperToolchain.js"; +import { + aclListingGrantsWrite, + createDarwinHelperCache, + darwinHelperCacheDirectory, + defaultDarwinHelperCacheDirectory, +} from "../../../src/process/DarwinHelperCache.js"; +import { createDarwinProcessRunTokenReader } from "../../../src/process/DarwinProcessRunTokenReader.js"; +import { createSystemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; + +const execFileAsync = promisify(execFile); +const posix = process.platform !== "win32"; +const onDarwin = process.platform === "darwin"; + +const exists = (path: string): Promise => + lstat(path).then( + () => true, + () => false, + ); + +/** One cache over fixture sources and toolchain, plus a helper to publish. */ +const fixtureCache = async (prefix: string, rootParent = "cache") => { + const directory = await createTestTempDirectory(prefix); + const source = join(directory, "Reader.swift"); + await writeFile(source, "// helper source v1\n"); + const compiled = join(directory, "compiled-reader"); + await writeFile(compiled, "#!/bin/sh\necho compiled\n"); + await chmod(compiled, 0o755); + const xcrun = await writeFakeXcrun(directory); + const root = join(directory, rootParent, "process-helper"); + const create = () => + createDarwinHelperCache({ + directory: root, + sources: [source], + xcrun: xcrun.executable, + environment: process.env, + }); + return { directory, source, compiled, root, xcrun, create }; +}; + +const missKey = async ( + cache: ReturnType, +): Promise => { + const lookup = await cache.lookup(); + if (lookup.kind !== "miss") + throw new Error(`Expected a miss: ${lookup.kind}`); + return lookup.key; +}; + +it.skipIf(!posix)( + "publishes a private verified entry that a later process reuses", + async () => { + const fixture = await fixtureCache("rea-helper-cache-publish-"); + const first = fixture.create(); + const key = await missKey(first); + const published = await first.publish(key, fixture.compiled); + expect(published).toBe(join(await realpath(fixture.root), key, "reader")); + expect((await stat(fixture.root)).mode & 0o777).toBe(0o700); + expect((await stat(join(fixture.root, key))).mode & 0o777).toBe(0o700); + expect((await stat(published ?? "")).mode & 0o777).toBe(0o700); + expect( + (await stat(join(fixture.root, key, "manifest.json"))).mode & 0o777, + ).toBe(0o600); + expect(await readdir(fixture.root)).toEqual([key]); + // A fresh cache object stands in for the next REA process. + expect(await fixture.create().lookup()).toEqual({ + kind: "hit", + executable: published, + }); + await first.close(); + }, +); + +it.skipIf(!posix)( + "keys entries on helper sources and the toolchain xcrun resolves", + async () => { + const fixture = await fixtureCache("rea-helper-cache-key-"); + const keys = [await missKey(fixture.create())]; + await writeFile(fixture.source, "// helper source v2\n"); + keys.push(await missKey(fixture.create())); + // An in-place compiler or SDK update keeps paths but changes identity. + await writeFile(fixture.xcrun.swiftc, "compiler v2 (longer)"); + keys.push(await missKey(fixture.create())); + const later = new Date(Date.now() + 60_000); + await utimes(join(fixture.xcrun.sdk, "SDKSettings.json"), later, later); + keys.push(await missKey(fixture.create())); + expect(new Set(keys).size).toBe(keys.length); + }, +); + +it.skipIf(!posix)( + "never executes or removes an invalid, linked, or loosened entry", + async () => { + const fixture = await fixtureCache("rea-helper-cache-invalid-"); + const corruptions: Record Promise> = { + "changed helper bytes": (entry) => + writeFile(join(entry, "reader"), "#!/bin/sh\necho replaced\n"), + "manifest for another key": async (entry) => { + const manifest = JSON.parse( + await readFile(join(entry, "manifest.json"), "utf8"), + ) as Record; + await writeFile( + join(entry, "manifest.json"), + JSON.stringify({ ...manifest, key: "0".repeat(64) }), + ); + }, + "group-writable entry": (entry) => chmod(entry, 0o770), + "group-writable helper": (entry) => chmod(join(entry, "reader"), 0o770), + "malformed manifest": (entry) => + writeFile(join(entry, "manifest.json"), "{"), + "linked helper": async (entry) => { + const target = join(fixture.directory, "elsewhere"); + await writeFile(target, "#!/bin/sh\necho elsewhere\n"); + await chmod(target, 0o700); + await rm(join(entry, "reader")); + await symlink(target, join(entry, "reader")); + }, + "manifest FIFO": async (entry) => { + await rm(join(entry, "manifest.json")); + await execFileAsync("/usr/bin/mkfifo", [join(entry, "manifest.json")]); + }, + }; + for (const [name, corrupt] of Object.entries(corruptions)) { + const cache = fixture.create(); + const key = await missKey(cache); + expect(await cache.publish(key, fixture.compiled), name).toBeDefined(); + await corrupt(join(fixture.root, key)); + // Opening the FIFO must not wait for a writer. + expect(await fixture.create().lookup(), name).toEqual({ + kind: "unavailable", + }); + // Removal could race another process that verified this path. + expect(await exists(join(fixture.root, key)), name).toBe(true); + await chmod(join(fixture.root, key), 0o700); + await rm(join(fixture.root, key), { recursive: true, force: true }); + } + }, +); + +it.skipIf(!posix)( + "never trusts a linked entry, an untrusted root, or an ancestor others can write", + async () => { + const fixture = await fixtureCache("rea-helper-cache-untrusted-"); + const key = await missKey(fixture.create()); + const valid = join(fixture.directory, "valid-entry"); + await mkdir(valid, { mode: 0o700 }); + await symlink(valid, join(fixture.root, key)); + expect(await fixture.create().lookup()).toEqual({ kind: "unavailable" }); + expect((await lstat(join(fixture.root, key))).isSymbolicLink()).toBe(true); + + const loose = await fixtureCache("rea-helper-cache-loose-root-"); + await mkdir(loose.root, { recursive: true }); + await chmod(loose.root, 0o777); + const looseCache = loose.create(); + expect(await looseCache.lookup()).toEqual({ kind: "unavailable" }); + expect(await looseCache.publish("a".repeat(64), loose.compiled)).toBe( + undefined, + ); + expect(await readdir(loose.root)).toEqual([]); + + // Another user could rename the verified entry out of a shared parent. + const shared = await fixtureCache( + "rea-helper-cache-shared-parent-", + "shared/cache", + ); + await mkdir(join(shared.directory, "shared"), { recursive: true }); + await chmod(join(shared.directory, "shared"), 0o777); + expect(await shared.create().lookup()).toEqual({ kind: "unavailable" }); + + const blocked = await fixtureCache("rea-helper-cache-blocked-root-"); + await writeFile(join(blocked.directory, "cache"), "not a directory"); + expect(await blocked.create().lookup()).toEqual({ kind: "unavailable" }); + }, +); + +it.skipIf(!posix)( + "keeps the first concurrent publication and leaves no staging directory", + async () => { + const fixture = await fixtureCache("rea-helper-cache-race-"); + const key = await missKey(fixture.create()); + const caches = Array.from({ length: 4 }, () => fixture.create()); + const results = await Promise.all( + caches.map((cache) => cache.publish(key, fixture.compiled)), + ); + expect(new Set(results).size).toBe(1); + expect(results[0]).toMatch(/\/reader$/u); + expect(await readdir(fixture.root)).toEqual([key]); + await Promise.all(caches.map((cache) => cache.close())); + }, +); + +it.skipIf(!posix)( + "returns no shared helper when the compiled output or toolchain is unusable", + async () => { + const fixture = await fixtureCache("rea-helper-cache-unusable-"); + const key = await missKey(fixture.create()); + expect( + await fixture.create().publish(key, join(fixture.directory, "absent")), + ).toBeUndefined(); + const empty = join(fixture.directory, "empty"); + await writeFile(empty, ""); + expect(await fixture.create().publish(key, empty)).toBeUndefined(); + expect(await readdir(fixture.root)).toEqual([]); + await rm(fixture.xcrun.swiftc); + expect(await fixture.create().lookup()).toEqual({ kind: "unavailable" }); + }, +); + +it("shares a helper only for the default toolchain and an absolute home", () => { + const select = ( + overrides: Partial[0]>, + ) => + darwinHelperCacheDirectory({ + home: "/Users/analyst", + compilerSelected: false, + moduleCachePath: undefined, + ...overrides, + }); + expect(select({})).toBe("/Users/analyst/Library/Caches/rea/process-helper"); + // An empty module cache setting keeps the default, as documented. + expect(select({ moduleCachePath: "" })).toBe( + "/Users/analyst/Library/Caches/rea/process-helper", + ); + expect(select({ moduleCachePath: "/boundary/modules" })).toBeUndefined(); + expect(select({ compilerSelected: true })).toBeUndefined(); + expect(select({ home: "relative/home" })).toBeUndefined(); + expect(select({ home: undefined })).toBeUndefined(); +}); + +it("keeps the caller's home path segments instead of normalizing them", () => { + // A symlink followed by `..` must resolve on the filesystem, not lexically. + expect(defaultDarwinHelperCacheDirectory("/Users/analyst/link/../home")).toBe( + "/Users/analyst/link/../home/Library/Caches/rea/process-helper", + ); +}); + +it.skipIf(!posix)( + "stops stalled toolchain lookups when preparation is cancelled", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-stall-"); + const source = join(directory, "Reader.swift"); + await writeFile(source, "// helper source\n"); + const xcrun = await writeFakeXcrun(directory, false, true); + const cache = createDarwinHelperCache({ + directory: join(directory, "process-helper"), + sources: [source], + xcrun: xcrun.executable, + environment: process.env, + }); + const started = Date.now(); + expect(await cache.lookup(AbortSignal.timeout(200))).toEqual({ + kind: "unavailable", + }); + // Far below the 15 s lookup deadline: both lookups ended with the signal. + expect(Date.now() - started).toBeLessThan(5_000); + + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory: join(directory, "reader-cache"), + }); + const controller = new AbortController(); + const prepared = reader.prepare(controller.signal); + setTimeout(() => controller.abort(new Error("caller cancelled")), 200); + const cancelled = Date.now(); + await expect(prepared).rejects.toThrow("caller cancelled"); + await reader.close(); + expect(Date.now() - cancelled).toBeLessThan(5_000); + expect(await xcrun.compilations()).toBe(0); + }, +); + +it("rejects only ACL entries that allow another principal to write", () => { + const entry = (line: string) => + `drwx------+ 3 analyst staff 96 31 Oct 09:00 /Users/analyst/x\n${line}\n`; + // macOS homes ship with this entry; it must not disable the cache. + expect(aclListingGrantsWrite(entry(" 0: group:everyone deny delete"))).toBe( + false, + ); + expect( + aclListingGrantsWrite(entry(" 0: group:everyone inherited deny delete")), + ).toBe(false); + expect( + aclListingGrantsWrite(entry(" 0: user:nobody allow read,search")), + ).toBe(false); + expect( + aclListingGrantsWrite( + entry(" 0: FFFFEEEE-DDDD-CCCC-BBBB-AAAAFFFFFFFE allow write,append"), + ), + ).toBe(true); + expect( + aclListingGrantsWrite( + entry(" 1: user:nobody inherited allow add_file,file_inherit"), + ), + ).toBe(true); + expect(aclListingGrantsWrite("-rw------- 1 analyst staff 0 x\n")).toBe(false); +}); + +it.skipIf(!onDarwin)( + "never trusts an entry or ancestor whose ACL lets another user write", + async () => { + const grants: Record< + string, + (fixture: { + root: string; + key: string; + directory: string; + }) => Promise + > = { + "helper write": ({ root, key }) => + execFileAsync("/bin/chmod", [ + "+a", + "user:nobody allow write", + join(root, key, "reader"), + ]).then(() => undefined), + "entry add_file": ({ root, key }) => + execFileAsync("/bin/chmod", [ + "+a", + "user:nobody allow add_file,delete_child", + join(root, key), + ]).then(() => undefined), + "ancestor delete_child": ({ directory }) => + execFileAsync("/bin/chmod", [ + "+a", + "user:nobody allow delete_child", + join(directory, "cache"), + ]).then(() => undefined), + }; + for (const [name, grant] of Object.entries(grants)) { + const fixture = await fixtureCache(`rea-helper-cache-acl-`); + const cache = fixture.create(); + const key = await missKey(cache); + expect(await cache.publish(key, fixture.compiled), name).toBeDefined(); + // The default home ACL keeps sharing. + await execFileAsync("/bin/chmod", [ + "+a", + "group:everyone deny delete", + fixture.root, + ]); + expect((await fixture.create().lookup()).kind, name).toBe("hit"); + await grant({ root: fixture.root, key, directory: fixture.directory }); + expect(await fixture.create().lookup(), name).toEqual({ + kind: "unavailable", + }); + await execFileAsync("/bin/chmod", ["-RN", fixture.directory]); + } + }, +); + +const fixtureProcess = { + pid: 123, + parentPid: 1, + processGroupId: 123, + state: "S", + command: "fixture", +}; + +it.skipIf(!posix)( + "runs the shared helper in later readers without recompiling and keeps it after close", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-reader-"); + const helperCacheDirectory = join(directory, "process-helper"); + const xcrun = await writeFakeXcrun(directory); + const create = () => + createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + const first = create(); + let shared: string; + try { + shared = await first.prepare(); + expect(shared.endsWith("/reader")).toBe(true); + expect(shared.includes("/process-helper/")).toBe(true); + expect((await first.read([fixtureProcess])).get(123)).toEqual({ + state: "readable", + runId: "fixture", + }); + } finally { + await first.close(); + } + const [compileRoot] = await xcrun.roots(); + expect(compileRoot).toBeDefined(); + expect(await exists(compileRoot ?? "")).toBe(false); + expect(await exists(shared)).toBe(true); + + const second = create(); + try { + expect(await second.prepare()).toBe(shared); + expect((await second.read([fixtureProcess])).get(123)).toEqual({ + state: "readable", + runId: "fixture", + }); + } finally { + await second.close(); + } + expect(await xcrun.compilations()).toBe(1); + expect(await exists(shared)).toBe(true); + }, +); + +it.skipIf(!posix)( + "falls back to its own helper when a verified shared copy cannot launch", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-noexec-"); + const helperCacheDirectory = join(directory, "process-helper"); + const xcrun = await writeFakeXcrun(directory); + const create = () => + createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + const first = create(); + let shared: string; + try { + shared = await first.prepare(); + } finally { + await first.close(); + } + const second = create(); + try { + expect(await second.prepare()).toBe(shared); + // The verified copy is refused at launch, as on a noexec volume. + await chmod(shared, 0o600); + // Concurrent reads that both launched the shared copy both fall back. + const reads = await Promise.all([ + second.read([fixtureProcess]), + second.read([fixtureProcess]), + ]); + for (const read of reads) + expect(read.get(123)).toEqual({ state: "readable", runId: "fixture" }); + } finally { + await second.close(); + } + expect(await xcrun.compilations()).toBe(2); + expect((await stat(shared)).mode & 0o777).toBe(0o600); + }, +); + +it.skipIf(!posix)( + "publishes nothing when helper compilation is cancelled", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-cancel-"); + const helperCacheDirectory = join(directory, "process-helper"); + const xcrun = await writeFakeXcrun(directory, true); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + const controller = new AbortController(); + try { + const prepared = reader.prepare(controller.signal); + const deadline = Date.now() + 10_000; + while ((await xcrun.compilations()) === 0 && Date.now() < deadline) + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(new Error("caller cancelled")); + await expect(prepared).rejects.toThrow("caller cancelled"); + } finally { + await reader.close(); + } + expect(await readdir(helperCacheDirectory)).toEqual([]); + const [compileRoot] = await xcrun.roots(); + expect(await exists(compileRoot ?? "")).toBe(false); + }, +); + +it.skipIf(!onDarwin)( + "reuses the real default-toolchain helper in a later host without compiling", + async () => { + const home = await createTestTempDirectory("rea-helper-cache-home-"); + const cacheRoot = join(home, "Library/Caches/rea/process-helper"); + const environment: NodeJS.ProcessEnv = { ...process.env, HOME: home }; + delete environment.REA_DARWIN_SWIFTC_PATH; + delete environment.REA_DARWIN_MODULE_CACHE_PATH; + const prepare = async (selected: NodeJS.ProcessEnv) => { + const host = createSystemProcessOwnershipHost("darwin", selected); + try { + await host.prepare?.(AbortSignal.timeout(180_000)); + const current = (await host.listProcesses()).find( + ({ pid }) => pid === process.pid, + ); + if (current === undefined || host.processIdentities === undefined) + throw new Error("Darwin host omitted the current process identity"); + expect( + (await host.processIdentities([current])).get(process.pid), + ).toMatchObject({ state: "readable" }); + } finally { + await host.close?.(); + } + }; + await prepare(environment); + const [key, ...others] = await readdir(cacheRoot); + expect(others).toEqual([]); + const helper = join(cacheRoot, key ?? "", "reader"); + const published = await stat(helper); + // A compile would need a per-run root under TMPDIR; deny it. + const noTemporary = join(home, "no-temporary"); + await mkdir(noTemporary, { mode: 0o500 }); + vi.stubEnv("TMPDIR", noTemporary); + try { + await prepare(environment); + } finally { + vi.unstubAllEnvs(); + } + expect((await stat(helper)).ino).toBe(published.ino); + expect(await readdir(cacheRoot)).toEqual([key]); + expect(await readdir(noTemporary)).toEqual([]); + }, +); diff --git a/tests/fixtures/darwinHelperToolchain.ts b/tests/fixtures/darwinHelperToolchain.ts new file mode 100644 index 0000000000..11d54b2285 --- /dev/null +++ b/tests/fixtures/darwinHelperToolchain.ts @@ -0,0 +1,61 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Fake xcrun: answers toolchain lookups from fixture files and, given + * `swiftc … -o OUT`, writes a helper that reads every requested PID. + */ +export const writeFakeXcrun = async ( + directory: string, + blocking = false, + stalledLookups = false, +) => { + const executable = join(directory, "fake-xcrun"); + const swiftc = join(directory, "toolchain", "swiftc"); + const sdk = join(directory, "sdk"); + const countPath = join(directory, "compile-count"); + const rootsPath = join(directory, "compile-roots"); + await mkdir(join(directory, "toolchain"), { recursive: true }); + await mkdir(sdk, { recursive: true }); + await writeFile(swiftc, "compiler v1"); + await writeFile(join(sdk, "SDKSettings.json"), '{"Version":"1"}'); + const helper = `#!/usr/bin/env node +const pids = process.argv.slice(2).filter((value) => value !== "--identities"); +process.stdout.write(JSON.stringify({ results: pids.map((pid) => ({ pid: Number(pid), state: "readable", run_id: "fixture" })) })); +`; + await writeFile( + executable, + `#!/usr/bin/env node +const fs = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); +const args = process.argv.slice(2); +const compile = () => { + const outputIndex = args.indexOf("-o"); + if (outputIndex < 0) process.exit(64); + const output = args[outputIndex + 1]; + fs.appendFileSync(${JSON.stringify(countPath)}, "x"); + fs.appendFileSync(${JSON.stringify(rootsPath)}, path.dirname(output) + "\\n"); + if (${String(blocking)}) return setInterval(() => {}, 1000); + fs.writeFileSync(output, ${JSON.stringify(helper)}); + fs.chmodSync(output, 0o755); +}; +if (args[0] === "swiftc") compile(); +else if (${String(stalledLookups)}) setInterval(() => {}, 1000); +else if (args[0] === "--find") process.stdout.write(${JSON.stringify(swiftc)} + "\\n"); +else if (args[0] === "--show-sdk-path") process.stdout.write(${JSON.stringify(sdk)} + "\\n"); +else process.exit(64); +`, + ); + await chmod(executable, 0o755); + return { + executable, + swiftc, + sdk, + compilations: async () => + (await readFile(countPath, "utf8").catch(() => "")).length, + roots: async () => + (await readFile(rootsPath, "utf8").catch(() => "")) + .split("\n") + .filter((line) => line !== ""), + }; +}; diff --git a/tests/process-global/darwinHelperCacheCleanup.test.ts b/tests/process-global/darwinHelperCacheCleanup.test.ts new file mode 100644 index 0000000000..571801cbc0 --- /dev/null +++ b/tests/process-global/darwinHelperCacheCleanup.test.ts @@ -0,0 +1,130 @@ +import { chmod, mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ removals: 0, roots: 0, renames: 0 })); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: async (...args: Parameters) => { + if (String(args[0]).includes("/.publish-") && fault.renames > 0) { + fault.renames -= 1; + throw Object.assign(new Error("controlled lost publication race"), { + code: "ENOTEMPTY", + }); + } + return actual.rename(...args); + }, + rm: async (...args: Parameters) => { + if (String(args[0]).includes("/.publish-") && fault.removals > 0) { + fault.removals -= 1; + throw new Error("controlled staging removal failure"); + } + if (String(args[0]).includes("rea-process-token-") && fault.roots > 0) { + fault.roots -= 1; + throw new Error("controlled helper-root removal failure"); + } + return actual.rm(...args); + }, + }; +}); + +import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; +import { createDarwinHelperCache } from "../../src/process/DarwinHelperCache.js"; +import { createDarwinProcessRunTokenReader } from "../../src/process/DarwinProcessRunTokenReader.js"; +import { writeFakeXcrun } from "../fixtures/darwinHelperToolchain.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "retains a staging directory whose removal failed until a close retry removes it", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-retry-"); + const source = join(directory, "Reader.swift"); + await writeFile(source, "// helper source\n"); + const compiled = join(directory, "compiled-reader"); + await writeFile(compiled, "#!/bin/sh\necho compiled\n"); + await chmod(compiled, 0o755); + const xcrun = await writeFakeXcrun(directory); + const root = join(directory, "process-helper"); + const cache = createDarwinHelperCache({ + directory: root, + sources: [source], + xcrun: xcrun.executable, + environment: process.env, + }); + const lookup = await cache.lookup(); + if (lookup.kind !== "miss") throw new Error("Expected a cache miss"); + // An occupied, invalid entry makes this publication lose its rename. + await mkdir(join(root, lookup.key), { mode: 0o700 }); + await writeFile(join(root, lookup.key, "unrelated"), "occupied"); + // Publication, then two close attempts, fail to remove the staging copy. + fault.removals = 3; + expect(await cache.publish(lookup.key, compiled)).toBeUndefined(); + const staging = (await readdir(root)).filter((name) => + name.startsWith(".publish-"), + ); + expect(staging).toHaveLength(1); + const failed: unknown = await cache + .close() + .catch((cause: unknown) => cause); + expect(failed).toBeInstanceOf(ProviderCleanupError); + expect(failed).toMatchObject({ + cleanupResources: [ + join( + await import("node:fs/promises").then(({ realpath }) => + realpath(root), + ), + staging[0] ?? "", + ), + ], + }); + expect(await readdir(root)).toContain(staging[0]); + expect(failed).toMatchObject({ cleanupIncomplete: true }); + // A second failed attempt depends on the first returned error. + const again: unknown = await cache.close().catch((cause: unknown) => cause); + expect(again).toMatchObject({ cleanupIncomplete: true }); + await cache.close(); + expect(await readdir(root)).toEqual([lookup.key]); + // Every error returned while cleanup was pending now reports release. + expect(failed).toMatchObject({ cleanupIncomplete: false }); + expect(again).toMatchObject({ cleanupIncomplete: false }); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps a close incomplete until both the helper root and the cache recover", + async () => { + const directory = await createTestTempDirectory("rea-helper-cache-both-"); + const xcrun = await writeFakeXcrun(directory); + const helperCacheDirectory = join(directory, "process-helper"); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: xcrun.executable }, + helperCacheDirectory, + }); + // Publication loses its rename; publication and two closes cannot remove + // its staging copy, while the helper root fails only the first close. + fault.renames = 1; + fault.removals = 3; + expect(await reader.prepare()).toContain("rea-process-token-"); + fault.roots = 1; + const failed: unknown = await reader + .close() + .catch((cause: unknown) => cause); + expect(failed).toMatchObject({ cleanupIncomplete: true }); + const resources = (failed as { cleanupResources: readonly string[] }) + .cleanupResources; + expect(resources.some((path) => path.includes("rea-process-token-"))).toBe( + true, + ); + expect(resources.some((path) => path.includes("/.publish-"))).toBe(true); + // The root recovers first; the error must still wait for the cache. + const again: unknown = await reader + .close() + .catch((cause: unknown) => cause); + expect(again).toMatchObject({ cleanupIncomplete: true }); + expect(failed).toMatchObject({ cleanupIncomplete: true }); + await reader.close(); + expect(failed).toMatchObject({ cleanupIncomplete: false }); + }, +);