From 83a6cdb8596fdb2334862d512ca1b1be991fa4a6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:17:27 +0800 Subject: [PATCH] fix(paths): preserve native traversal and unset PATH semantics --- docs/process-capture.md | 6 ++ src/config/kunaCacheDirectory.ts | 15 ++-- src/kuna/KunaInstallation.ts | 6 +- src/kuna/KunaProjectCache.ts | 56 ++++++++++---- src/pcap/PcapConfiguration.test.ts | 21 +++++- src/pcap/PcapConfiguration.ts | 6 +- src/process/capture/FilesystemSnapshot.ts | 18 ++++- .../ProcessScenarioRuntimeValidation.test.ts | 74 +++++++++++++++++++ .../ProcessScenarioRuntimeValidation.ts | 28 ++++--- .../process/processCaptureTerminal.test.ts | 60 ++++++++++++++- .../providers/kuna/kunaInstallation.test.ts | 26 +++++++ .../providers/kuna/kunaProjectCache.test.ts | 60 +++++++++++++++ 12 files changed, 335 insertions(+), 41 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 8fd1d882fd..88156c3ab3 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -228,6 +228,12 @@ from launch, must be ordered, and must fall within `timeout_ms`. Inputs marked Environment overrides are recorded in the scenario commitment; inherited values are not copied into Evidence. +A bare executable name uses the scenario's `PATH` override, then the inherited +`PATH`. When neither supplies `PATH`, lookup uses `/usr/bin:/bin`, matching +[Node's Unix default](https://nodejs.org/api/child_process.html#child-process). +An explicitly empty `PATH` searches only the working directory. Executable, +working-directory, and observation paths preserve native `symlink/..` traversal. + Terminal dimensions must satisfy `columns × (rows + scrollback) ≤ 1,000,000`. Every scheduled resize uses the same selected scrollback and must fit this working-buffer budget. REA rejects oversized scenarios before launching the diff --git a/src/config/kunaCacheDirectory.ts b/src/config/kunaCacheDirectory.ts index c6f0cd623a..25a0f7f789 100644 --- a/src/config/kunaCacheDirectory.ts +++ b/src/config/kunaCacheDirectory.ts @@ -1,4 +1,5 @@ -import { isAbsolute, join } from "node:path"; +import { isAbsolute } from "node:path"; +import { appendCallerPath } from "../filesystem/callerPath.js"; /** Per-user cache directory that REA owns for Kuna on this host. */ export const defaultKunaCacheDirectory = ( @@ -7,11 +8,11 @@ export const defaultKunaCacheDirectory = ( platform: NodeJS.Platform, ): string => { if (platform === "darwin") - return join(home, "Library", "Caches", "rea", "kuna"); + return appendCallerPath(home, "Library/Caches/rea/kuna", platform); const xdg = environment.XDG_CACHE_HOME; - return join( - xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".cache"), - "rea", - "kuna", - ); + const root = + xdg !== undefined && isAbsolute(xdg) + ? xdg + : appendCallerPath(home, ".cache", platform); + return appendCallerPath(root, "rea/kuna", platform); }; diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index 2197ec101b..b238862d76 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -5,7 +5,7 @@ import { import { constants } from "node:fs"; import { randomUUID } from "node:crypto"; import { access, realpath, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { appendCallerPath } from "../filesystem/callerPath.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -263,10 +263,10 @@ export const createKunaResourceScope = ( export const kunaChildEnvironment = ( configuration: KunaConfiguration, ): NodeJS.ProcessEnv => { - const home = join(configuration.cacheRoot, "home"); + const home = appendCallerPath(configuration.cacheRoot, "home"); return { HOME: home, - XDG_CACHE_HOME: join(home, ".cache"), + XDG_CACHE_HOME: appendCallerPath(home, ".cache"), NO_COLOR: "1", }; }; diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index d25002865a..04a240012e 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, + realpath, readFile, rename, rm, @@ -264,7 +265,8 @@ const manifestBytes = (manifest: KunaProjectManifest): number => * used entries above the size cap, never an entry pinned by this process. */ export class KunaProjectCache { - readonly #root: string; + readonly #selectedRoot: string; + #canonicalRoot: string | undefined; readonly #maxBytes: number; readonly #launcher: KunaProcessLauncher | undefined; /** Builds new requests may join, at most one per key. */ @@ -306,11 +308,16 @@ export class KunaProjectCache { readonly launcher?: KunaProcessLauncher; }, ) { - this.#root = root; + this.#selectedRoot = root; this.#maxBytes = options.maxBytes; this.#launcher = options.launcher; } + /** Resolve native traversal once; all cache consumers use this filesystem identity. */ + async #resolveRoot(): Promise { + return (this.#canonicalRoot ??= await realpath(this.#selectedRoot)); + } + /** Report cleanup owners retained after failed process or file cleanup. */ get hasPendingCleanup(): boolean { return ( @@ -346,6 +353,13 @@ export class KunaProjectCache { await this.#retryHashFiles(key); const cleanup = this.#cleanupResult(this.#hashCleanupFailures(key)); if (!cleanup.ok) throw cleanup.error; + let root: string; + try { + root = await this.#resolveRoot(); + } catch (cause: unknown) { + if (isMissing(cause)) return undefined; + throw cause; + } const verified = this.#verified.get(key); if (verified !== undefined) { // Another process may have evicted it since this one verified it. @@ -353,7 +367,7 @@ export class KunaProjectCache { this.#verified.delete(key); return undefined; } - const entry = join(this.#root, "projects", key); + const entry = join(root, "projects", key); let text: string; try { text = await this.#readBounded(join(entry, "manifest.json")); @@ -691,7 +705,11 @@ export class KunaProjectCache { let cleanupPath = staging; const stagingOwner = this.#stagingCleanupOwners.get(staging); try { - const retired = join(this.#root, "trash", `build-${randomUUID()}`); + const retired = join( + await this.#resolveRoot(), + "trash", + `build-${randomUUID()}`, + ); await rename(staging, retired); cleanupPath = retired; await this.#removeRetired(retired, key); @@ -762,13 +780,18 @@ export class KunaProjectCache { try { const existing = await this.lookup(key, signal); if (existing !== undefined) return ok(existing); + await mkdir(this.#canonicalRoot ?? this.#selectedRoot, { + recursive: true, + mode: 0o700, + }); + const root = await this.#resolveRoot(); await Promise.all( ["projects", "staging", "trash", "home"].map((name) => - mkdir(join(this.#root, name), { recursive: true, mode: 0o700 }), + mkdir(join(root, name), { recursive: true, mode: 0o700 }), ), ); await this.#sweep(signal); - staging = await mkdtemp(join(this.#root, "staging", "build-")); + staging = await mkdtemp(join(root, "staging", "build-")); } catch (cause: unknown) { return err(kunaExportFailure(cause, signal)); } @@ -912,7 +935,7 @@ export class KunaProjectCache { `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }, ); - const destination = join(this.#root, "projects", key); + const destination = join(await this.#resolveRoot(), "projects", key); try { await rename(join(staging, "entry"), destination); } catch (cause: unknown) { @@ -937,9 +960,10 @@ export class KunaProjectCache { * that a process stopped, so unresolved staging is never swept. */ async #sweep(signal: AbortSignal): Promise { - for (const name of await readdir(join(this.#root, "trash"))) { + const root = await this.#resolveRoot(); + for (const name of await readdir(join(root, "trash"))) { signal.throwIfAborted(); - await this.#removeRetired(join(this.#root, "trash", name)).catch( + await this.#removeRetired(join(root, "trash", name)).catch( () => undefined, ); } @@ -971,13 +995,14 @@ export class KunaProjectCache { } async #evictUnpinned(keep: string | null): Promise { + const root = await this.#resolveRoot(); // A successful build must reach its callers before they can pin its entry. // Protect every unfinished build while reading this eviction snapshot. const building = new Set([...this.#unfinished].map(({ key }) => key)); const entries: { key: string; bytes: number; mtimeMs: number }[] = []; - for (const name of await readdir(join(this.#root, "projects"))) { + for (const name of await readdir(join(root, "projects"))) { if (!KEY_PATTERN.test(name)) continue; - const path = join(this.#root, "projects", name); + const path = join(root, "projects", name); const status = await stat(path).catch(() => undefined); if (status === undefined) continue; const manifest = await this.#readBounded(join(path, "manifest.json")) @@ -1017,10 +1042,11 @@ export class KunaProjectCache { /** Atomically retire one owned entry, then remove it. */ async #discard(key: string): Promise { this.#verified.delete(key); - const trash = join(this.#root, "trash", `${key}-${randomUUID()}`); + const root = await this.#resolveRoot(); + const trash = join(root, "trash", `${key}-${randomUUID()}`); try { - await mkdir(join(this.#root, "trash"), { recursive: true, mode: 0o700 }); - await rename(join(this.#root, "projects", key), trash); + await mkdir(join(root, "trash"), { recursive: true, mode: 0o700 }); + await rename(join(root, "projects", key), trash); } catch (cause: unknown) { if (isMissing(cause)) return; throw cause; @@ -1045,7 +1071,7 @@ export class KunaProjectCache { async #touch(key: string): Promise { const now = new Date(); try { - await utimes(join(this.#root, "projects", key), now, now); + await utimes(join(await this.#resolveRoot(), "projects", key), now, now); return true; } catch (cause: unknown) { // Recency is advisory; only a missing entry changes the answer. diff --git a/src/pcap/PcapConfiguration.test.ts b/src/pcap/PcapConfiguration.test.ts index afd5f667d8..813f5d25e7 100644 --- a/src/pcap/PcapConfiguration.test.ts +++ b/src/pcap/PcapConfiguration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; @@ -392,3 +392,22 @@ describe("parseTsharkJsonOutput", () => { }); }); }); + +it.skipIf(process.platform === "win32")( + "resolves Wireshark tools through native PATH traversal instead of a decoy directory", + async () => { + const root = await createTestTempDirectory("rea-wireshark-native-path-"); + const intended = join(root, "intended"); + await mkdir(join(intended, "nested"), { recursive: true }); + await symlink(join(intended, "nested"), join(root, "alias")); + for (const directory of [root, intended]) + for (const tool of ["tshark", "capinfos"]) + await writeFile(join(directory, tool), "", { mode: 0o755 }); + const selected = resolvePcapTools({ PATH: `${root}/alias/..` }); + for (const tool of ["tshark", "capinfos"] as const) + expect(await realpath(selected[tool].command)).toBe(join(intended, tool)); + expect(() => resolvePcapTools({ PATH: `${root}/missing/..` })).toThrow( + /tshark was not found/u, + ); + }, +); diff --git a/src/pcap/PcapConfiguration.ts b/src/pcap/PcapConfiguration.ts index 9b17af045a..463cca8e51 100644 --- a/src/pcap/PcapConfiguration.ts +++ b/src/pcap/PcapConfiguration.ts @@ -1,5 +1,7 @@ import { accessSync, constants, statSync } from "node:fs"; -import { delimiter, isAbsolute, join } from "node:path"; +import { delimiter, isAbsolute } from "node:path"; + +import { appendCallerPath } from "../filesystem/callerPath.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; @@ -146,7 +148,7 @@ export const locateWiresharkTool = ( return selection.command; for (const directory of (environment.PATH ?? "").split(delimiter)) { if (!isAbsolute(directory)) continue; - const candidate = join(directory, selection.command); + const candidate = appendCallerPath(directory, selection.command); try { accessSync(candidate, constants.X_OK); if (statSync(candidate).isFile()) return candidate; diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index 9ef4447939..9d72f584c9 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import { lstat, open, opendir, readlink, realpath } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; import type { FileHandle } from "node:fs/promises"; +import { appendCallerPath } from "../../filesystem/callerPath.js"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js"; import type { Stats } from "node:fs"; @@ -606,7 +607,13 @@ export const snapshotRoots = async ( signal?.throwIfAborted(); const child = await ownedDirectory.read(); if (child === null) break; - if (await omitObserverPath(root, rootAlias, join(path, child.name))) + if ( + await omitObserverPath( + root, + rootAlias, + appendCallerPath(path, child.name), + ) + ) continue; if ( children.length >= remainingEntries || @@ -657,7 +664,14 @@ export const snapshotRoots = async ( const child = children.pop(); if (child === undefined) break; pendingNames -= 1; - if (!(await visit(root, rootAlias, join(path, child), depth + 1))) + if ( + !(await visit( + root, + rootAlias, + appendCallerPath(path, child), + depth + 1, + )) + ) complete = false; } return complete; diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts index 51b95e297a..47b8db2097 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts @@ -1,8 +1,10 @@ +import { execFileSync } from "node:child_process"; import { chmod, mkdir, mkdtemp, rm, + realpath, symlink, writeFile, } from "node:fs/promises"; @@ -335,3 +337,75 @@ const runtimePathFailure = async ( throw new Error("Selected runtime path should be invalid input"); return failure; }; + +it.skipIf(process.platform === "win32").each(["absolute", "relative", "PATH"])( + "preserves native symlink/.. traversal for a %s executable", + async (selection) => { + const root = await createTestTempDirectory("rea-process-native-path-"); + const intended = join(root, "intended"); + await mkdir(join(intended, "nested"), { recursive: true }); + await symlink(join(intended, "nested"), join(root, "alias")); + for (const [directory, text] of [ + [root, "decoy"], + [intended, "intended"], + ] as const) + await writeFile( + join(directory, "probe"), + `#!/bin/sh\nprintf '${text}'\n`, + { mode: 0o755 }, + ); + const command = + selection === "absolute" + ? `${root}/alias/../probe` + : selection === "relative" + ? "alias/../probe" + : "probe"; + const executable = await resolveExecutable(command, root, "alias/.."); + expect(await realpath(executable)).toBe(join(intended, "probe")); + expect(execFileSync(executable, { encoding: "utf8", timeout: 2000 })).toBe( + "intended", + ); + }, +); + +it.skipIf(process.platform === "win32")( + "distinguishes an unset PATH from empty and relative PATH selections", + async () => { + const root = await createTestTempDirectory("rea-process-absent-path-"); + const localShell = join(root, "sh"); + await writeFile(localShell, "#!/bin/sh\nprintf local-shell\n", { + mode: 0o755, + }); + const scenario = processScenarioSchema.parse({ + executable: "sh", + working_directory: root, + }); + const unset = await resolveProcessScenarioRuntimePaths(scenario, {}); + expect(unset.executable).not.toBe(localShell); + expect( + execFileSync(unset.executable, ["-c", "printf system-shell"], { + env: {}, + encoding: "utf8", + timeout: 2000, + }), + ).toBe("system-shell"); + for (const environment of [{ PATH: "" }, { PATH: "." }]) { + const selected = await resolveProcessScenarioRuntimePaths( + { ...scenario, environment }, + { PATH: "/usr/bin:/bin" }, + ); + expect(selected.executable).toBe(localShell); + } + const inheritedEmpty = await resolveProcessScenarioRuntimePaths(scenario, { + PATH: "", + }); + expect(inheritedEmpty.executable).toBe(localShell); + await rm(localShell); + await expect( + resolveProcessScenarioRuntimePaths( + { ...scenario, environment: { PATH: "" } }, + {}, + ), + ).rejects.toBeInstanceOf(AnalysisInputError); + }, +); diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.ts b/src/process/capture/ProcessScenarioRuntimeValidation.ts index 0aa6f6abcd..c8f527c613 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.ts @@ -1,6 +1,11 @@ import { constants as fsConstants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; -import { delimiter, isAbsolute, join, resolve, sep } from "node:path"; +import { delimiter, isAbsolute, sep } from "node:path"; + +import { + absoluteCallerPath, + appendCallerPath, +} from "../../filesystem/callerPath.js"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; import { @@ -16,7 +21,9 @@ export const resolveProcessScenarioRuntimePaths = async ( environment: Readonly> = process.env, platform: NodeJS.Platform = process.platform, ): Promise => { - const selectedWorkingDirectory = resolve(scenario.working_directory); + const selectedWorkingDirectory = absoluteCallerPath( + scenario.working_directory, + ); let workingDirectory: string; try { workingDirectory = await realpath(selectedWorkingDirectory); @@ -52,11 +59,10 @@ export const resolveProcessScenarioRuntimePaths = async ( scenario.executable, workingDirectory, snapshotEnvironment(scenario.environment, platform).PATH ?? - snapshotEnvironment(environment, platform).PATH ?? - "", + snapshotEnvironment(environment, platform).PATH, ); const observationPaths = scenario.filesystem_observation_paths.map((path) => - resolve(workingDirectory, path), + absoluteCallerPath(path, { cwd: workingDirectory }), ); return { ...scenario, @@ -75,22 +81,24 @@ export const resolveProcessScenarioRuntimePaths = async ( export const resolveExecutable = async ( command: string, workingDirectory: string, - searchPath: string, + searchPath: string | undefined, ): Promise => { - const directories = searchPath + // Match Node's Unix default search path when PATH is absent. An empty + // PATH is different: its empty component selects the working directory. + const directories = (searchPath ?? "/usr/bin:/bin") .split(delimiter) .map((directory) => directory === "" ? workingDirectory - : resolve(workingDirectory, directory), + : absoluteCallerPath(directory, { cwd: workingDirectory }), ); const explicit = isAbsolute(command) || command.includes("/") || (sep === "\\" && command.includes("\\")); const candidates = explicit - ? [resolve(workingDirectory, command)] - : directories.map((directory) => join(directory, command)); + ? [absoluteCallerPath(command, { cwd: workingDirectory })] + : directories.map((directory) => appendCallerPath(directory, command)); const failures: string[] = []; for (const candidate of candidates) { let metadata; diff --git a/tests/boundary/process/processCaptureTerminal.test.ts b/tests/boundary/process/processCaptureTerminal.test.ts index 79d17023b0..a6855b97ff 100644 --- a/tests/boundary/process/processCaptureTerminal.test.ts +++ b/tests/boundary/process/processCaptureTerminal.test.ts @@ -1,4 +1,5 @@ -import { rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -85,3 +86,60 @@ itWithCaptureCapability( }, 20_000, ); + +itWithCaptureCapability( + "executes and observes the selected symlink/.. directory without touching its lexical decoy", + async () => { + const root = await createTestTempDirectory("rea-process-native-traversal-"); + const intended = join(root, "intended"); + await mkdir(join(intended, "nested"), { recursive: true }); + await symlink(join(intended, "nested"), join(root, "alias")); + for (const [directory, label] of [ + [root, "decoy"], + [intended, "intended"], + ] as const) { + await mkdir(join(directory, "observed")); + await writeFile(join(directory, "observed", "state.txt"), "before"); + await writeFile( + join(directory, "probe"), + `#!/bin/sh\nprintf '${label}'\nprintf after > observed/state.txt\n`, + { mode: 0o755 }, + ); + } + const selected = `${root}/alias/..`; + const result = await captureProcessScenario( + processScenarioSchema.parse({ + executable: `${selected}/probe`, + working_directory: selected, + filesystem_observation_paths: [`${selected}/observed`], + }), + ); + if (!result.ok) throw result.error; + expect(result.value.frames.map(({ data }) => data).join("")).toBe( + "intended", + ); + expect(await readFile(join(root, "observed", "state.txt"), "utf8")).toBe( + "before", + ); + expect( + await readFile(join(intended, "observed", "state.txt"), "utf8"), + ).toBe("after"); + const before = result.value.files_before.find( + ({ path }) => path === "root_0:state.txt", + ); + const after = result.value.files_after.find( + ({ path }) => path === "root_0:state.txt", + ); + expect(before?.sha256).toBe( + createHash("sha256").update("before").digest("hex"), + ); + expect(after?.sha256).toBe( + createHash("sha256").update("after").digest("hex"), + ); + expect(result.value.cleanup).toMatchObject({ + owned_process_group: "verified", + temporary_root: "removed", + }); + }, + 20_000, +); diff --git a/tests/boundary/providers/kuna/kunaInstallation.test.ts b/tests/boundary/providers/kuna/kunaInstallation.test.ts index a223ca1898..c1882ff737 100644 --- a/tests/boundary/providers/kuna/kunaInstallation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallation.test.ts @@ -400,3 +400,29 @@ it("preserves an already-cancelled caller before admitting a lookup", async () = ); expect(kunaInstallationCancellation(cause)?.reason).toBe(reason); }); + +it.each(["darwin", "linux"] as const)( + "preserves native traversal in default cache roots on %s", + (platform) => { + const home = "/workspace/alias/../home"; + const environment = { REA_KUNA_COMMAND: "/tools/kuna", HOME: home }; + const configured = readKunaConfiguration(environment, platform); + expect(configured).toMatchObject({ + value: { + cacheRoot: + platform === "darwin" + ? `${home}/Library/Caches/rea/kuna` + : `${home}/.cache/rea/kuna`, + }, + }); + if (platform === "linux") + expect( + readKunaConfiguration( + { ...environment, XDG_CACHE_HOME: "/workspace/alias/../cache" }, + platform, + ), + ).toMatchObject({ + value: { cacheRoot: "/workspace/alias/../cache/rea/kuna" }, + }); + }, +); diff --git a/tests/boundary/providers/kuna/kunaProjectCache.test.ts b/tests/boundary/providers/kuna/kunaProjectCache.test.ts index 4d71d0de46..94ee0727f8 100644 --- a/tests/boundary/providers/kuna/kunaProjectCache.test.ts +++ b/tests/boundary/providers/kuna/kunaProjectCache.test.ts @@ -4,6 +4,8 @@ import { chmod, mkdir, readdir, + realpath, + symlink, readFile, rename, rm, @@ -1085,3 +1087,61 @@ it("removes its staging when publication itself fails", async () => { expect(await entries(cacheRoot, "staging")).toEqual([]); expect(await entries(cacheRoot, "projects")).toEqual([]); }); + +it.each(["missing", "corrupt"])( + "keeps a %s cache beneath the natively resolved selected root through publication, reads and cleanup", + async (state) => { + const root = await createTestTempDirectory("rea-kuna-native-cache-"); + const intended = join(root, "intended"); + await mkdir(join(intended, "nested"), { recursive: true }); + await symlink(join(intended, "nested"), join(root, "alias")); + const selected = `${root}/alias/../cache`; + const { kuna, installation, cache, target } = await fixture( + "rea-kuna-cache-input-", + {}, + { + environment: { REA_KUNA_CACHE_DIR: selected }, + }, + ); + const input = await target("program"); + const key = keyOf(installation, input); + const intendedEntry = join(intended, "cache", "projects", key); + const decoyEntry = join(root, "cache", "projects", key); + for (const entry of state === "corrupt" + ? [intendedEntry, decoyEntry] + : [decoyEntry]) { + await mkdir(entry, { recursive: true }); + await writeFile(join(entry, "manifest.json"), "{}"); + } + const reopened = newCache(installation); + try { + expect(await cache.lookup(key)).toBeUndefined(); + await expect( + readFile(join(intendedEntry, "manifest.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(decoyEntry, "manifest.json"), "utf8")).toBe( + "{}", + ); + const built = await cache.build({ installation, ...input }); + if (!built.ok) throw built.error; + expect(built.value.directory).toBe(join(intendedEntry, "project")); + expect(await reopened.lookup(key)).toEqual(built.value); + expect( + await readFile( + join(built.value.directory, built.value.manifest.files.c.path), + "utf8", + ), + ).toContain("main"); + for (const call of await fakeKunaCalls(kuna)) { + expect(await realpath(call.home)).toBe(join(intended, "cache", "home")); + } + expect(await readdir(join(intended, "cache", "staging"))).toEqual([]); + expect(await readFile(join(decoyEntry, "manifest.json"), "utf8")).toBe( + "{}", + ); + } finally { + expect(await cache.close()).toEqual({ ok: true, value: null }); + expect(await reopened.close()).toEqual({ ok: true, value: null }); + } + }, +);