mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(paths): preserve native traversal and unset PATH semantics
This commit is contained in:
1 parent
1fe1f1f8d6
commit
83a6cdb859
12 files changed
+335
-41
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<Record<string, string | undefined>> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<ProcessScenario> => {
|
||||
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<string> => {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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" },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user