fix(paths): preserve native traversal and unset PATH semantics

This commit is contained in:
morluto committed 2026-10-26 14:17:27 +08:00
1 parent 1fe1f1f8d6
commit 83a6cdb859
12 files changed
+335 -41

No files matched your search

+6
View File
@@ -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
+8 -7
View File
@@ -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);
};
+3 -3
View File
@@ -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",
};
};
+41 -15
View File
@@ -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.
+20 -1
View File
@@ -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,
);
},
);
+4 -2
View File
@@ -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;
+16 -2
View File
@@ -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 });
}
},
);