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
|
Environment overrides are recorded in the scenario commitment; inherited
|
||||||
values are not copied into Evidence.
|
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`.
|
Terminal dimensions must satisfy `columns × (rows + scrollback) ≤ 1,000,000`.
|
||||||
Every scheduled resize uses the same selected scrollback and must fit this
|
Every scheduled resize uses the same selected scrollback and must fit this
|
||||||
working-buffer budget. REA rejects oversized scenarios before launching the
|
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. */
|
/** Per-user cache directory that REA owns for Kuna on this host. */
|
||||||
export const defaultKunaCacheDirectory = (
|
export const defaultKunaCacheDirectory = (
|
||||||
@@ -7,11 +8,11 @@ export const defaultKunaCacheDirectory = (
|
|||||||
platform: NodeJS.Platform,
|
platform: NodeJS.Platform,
|
||||||
): string => {
|
): string => {
|
||||||
if (platform === "darwin")
|
if (platform === "darwin")
|
||||||
return join(home, "Library", "Caches", "rea", "kuna");
|
return appendCallerPath(home, "Library/Caches/rea/kuna", platform);
|
||||||
const xdg = environment.XDG_CACHE_HOME;
|
const xdg = environment.XDG_CACHE_HOME;
|
||||||
return join(
|
const root =
|
||||||
xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".cache"),
|
xdg !== undefined && isAbsolute(xdg)
|
||||||
"rea",
|
? xdg
|
||||||
"kuna",
|
: appendCallerPath(home, ".cache", platform);
|
||||||
);
|
return appendCallerPath(root, "rea/kuna", platform);
|
||||||
};
|
};
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { access, realpath, stat } from "node:fs/promises";
|
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 { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
@@ -263,10 +263,10 @@ export const createKunaResourceScope = (
|
|||||||
export const kunaChildEnvironment = (
|
export const kunaChildEnvironment = (
|
||||||
configuration: KunaConfiguration,
|
configuration: KunaConfiguration,
|
||||||
): NodeJS.ProcessEnv => {
|
): NodeJS.ProcessEnv => {
|
||||||
const home = join(configuration.cacheRoot, "home");
|
const home = appendCallerPath(configuration.cacheRoot, "home");
|
||||||
return {
|
return {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
XDG_CACHE_HOME: join(home, ".cache"),
|
XDG_CACHE_HOME: appendCallerPath(home, ".cache"),
|
||||||
NO_COLOR: "1",
|
NO_COLOR: "1",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
mkdir,
|
mkdir,
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
readdir,
|
readdir,
|
||||||
|
realpath,
|
||||||
readFile,
|
readFile,
|
||||||
rename,
|
rename,
|
||||||
rm,
|
rm,
|
||||||
@@ -264,7 +265,8 @@ const manifestBytes = (manifest: KunaProjectManifest): number =>
|
|||||||
* used entries above the size cap, never an entry pinned by this process.
|
* used entries above the size cap, never an entry pinned by this process.
|
||||||
*/
|
*/
|
||||||
export class KunaProjectCache {
|
export class KunaProjectCache {
|
||||||
readonly #root: string;
|
readonly #selectedRoot: string;
|
||||||
|
#canonicalRoot: string | undefined;
|
||||||
readonly #maxBytes: number;
|
readonly #maxBytes: number;
|
||||||
readonly #launcher: KunaProcessLauncher | undefined;
|
readonly #launcher: KunaProcessLauncher | undefined;
|
||||||
/** Builds new requests may join, at most one per key. */
|
/** Builds new requests may join, at most one per key. */
|
||||||
@@ -306,11 +308,16 @@ export class KunaProjectCache {
|
|||||||
readonly launcher?: KunaProcessLauncher;
|
readonly launcher?: KunaProcessLauncher;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
this.#root = root;
|
this.#selectedRoot = root;
|
||||||
this.#maxBytes = options.maxBytes;
|
this.#maxBytes = options.maxBytes;
|
||||||
this.#launcher = options.launcher;
|
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. */
|
/** Report cleanup owners retained after failed process or file cleanup. */
|
||||||
get hasPendingCleanup(): boolean {
|
get hasPendingCleanup(): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -346,6 +353,13 @@ export class KunaProjectCache {
|
|||||||
await this.#retryHashFiles(key);
|
await this.#retryHashFiles(key);
|
||||||
const cleanup = this.#cleanupResult(this.#hashCleanupFailures(key));
|
const cleanup = this.#cleanupResult(this.#hashCleanupFailures(key));
|
||||||
if (!cleanup.ok) throw cleanup.error;
|
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);
|
const verified = this.#verified.get(key);
|
||||||
if (verified !== undefined) {
|
if (verified !== undefined) {
|
||||||
// Another process may have evicted it since this one verified it.
|
// Another process may have evicted it since this one verified it.
|
||||||
@@ -353,7 +367,7 @@ export class KunaProjectCache {
|
|||||||
this.#verified.delete(key);
|
this.#verified.delete(key);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const entry = join(this.#root, "projects", key);
|
const entry = join(root, "projects", key);
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = await this.#readBounded(join(entry, "manifest.json"));
|
text = await this.#readBounded(join(entry, "manifest.json"));
|
||||||
@@ -691,7 +705,11 @@ export class KunaProjectCache {
|
|||||||
let cleanupPath = staging;
|
let cleanupPath = staging;
|
||||||
const stagingOwner = this.#stagingCleanupOwners.get(staging);
|
const stagingOwner = this.#stagingCleanupOwners.get(staging);
|
||||||
try {
|
try {
|
||||||
const retired = join(this.#root, "trash", `build-${randomUUID()}`);
|
const retired = join(
|
||||||
|
await this.#resolveRoot(),
|
||||||
|
"trash",
|
||||||
|
`build-${randomUUID()}`,
|
||||||
|
);
|
||||||
await rename(staging, retired);
|
await rename(staging, retired);
|
||||||
cleanupPath = retired;
|
cleanupPath = retired;
|
||||||
await this.#removeRetired(retired, key);
|
await this.#removeRetired(retired, key);
|
||||||
@@ -762,13 +780,18 @@ export class KunaProjectCache {
|
|||||||
try {
|
try {
|
||||||
const existing = await this.lookup(key, signal);
|
const existing = await this.lookup(key, signal);
|
||||||
if (existing !== undefined) return ok(existing);
|
if (existing !== undefined) return ok(existing);
|
||||||
|
await mkdir(this.#canonicalRoot ?? this.#selectedRoot, {
|
||||||
|
recursive: true,
|
||||||
|
mode: 0o700,
|
||||||
|
});
|
||||||
|
const root = await this.#resolveRoot();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
["projects", "staging", "trash", "home"].map((name) =>
|
["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);
|
await this.#sweep(signal);
|
||||||
staging = await mkdtemp(join(this.#root, "staging", "build-"));
|
staging = await mkdtemp(join(root, "staging", "build-"));
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
return err(kunaExportFailure(cause, signal));
|
return err(kunaExportFailure(cause, signal));
|
||||||
}
|
}
|
||||||
@@ -912,7 +935,7 @@ export class KunaProjectCache {
|
|||||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
{ mode: 0o600 },
|
{ mode: 0o600 },
|
||||||
);
|
);
|
||||||
const destination = join(this.#root, "projects", key);
|
const destination = join(await this.#resolveRoot(), "projects", key);
|
||||||
try {
|
try {
|
||||||
await rename(join(staging, "entry"), destination);
|
await rename(join(staging, "entry"), destination);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
@@ -937,9 +960,10 @@ export class KunaProjectCache {
|
|||||||
* that a process stopped, so unresolved staging is never swept.
|
* that a process stopped, so unresolved staging is never swept.
|
||||||
*/
|
*/
|
||||||
async #sweep(signal: AbortSignal): Promise<void> {
|
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();
|
signal.throwIfAborted();
|
||||||
await this.#removeRetired(join(this.#root, "trash", name)).catch(
|
await this.#removeRetired(join(root, "trash", name)).catch(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -971,13 +995,14 @@ export class KunaProjectCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #evictUnpinned(keep: string | null): Promise<void> {
|
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.
|
// A successful build must reach its callers before they can pin its entry.
|
||||||
// Protect every unfinished build while reading this eviction snapshot.
|
// Protect every unfinished build while reading this eviction snapshot.
|
||||||
const building = new Set([...this.#unfinished].map(({ key }) => key));
|
const building = new Set([...this.#unfinished].map(({ key }) => key));
|
||||||
const entries: { key: string; bytes: number; mtimeMs: number }[] = [];
|
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;
|
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);
|
const status = await stat(path).catch(() => undefined);
|
||||||
if (status === undefined) continue;
|
if (status === undefined) continue;
|
||||||
const manifest = await this.#readBounded(join(path, "manifest.json"))
|
const manifest = await this.#readBounded(join(path, "manifest.json"))
|
||||||
@@ -1017,10 +1042,11 @@ export class KunaProjectCache {
|
|||||||
/** Atomically retire one owned entry, then remove it. */
|
/** Atomically retire one owned entry, then remove it. */
|
||||||
async #discard(key: string): Promise<void> {
|
async #discard(key: string): Promise<void> {
|
||||||
this.#verified.delete(key);
|
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 {
|
try {
|
||||||
await mkdir(join(this.#root, "trash"), { recursive: true, mode: 0o700 });
|
await mkdir(join(root, "trash"), { recursive: true, mode: 0o700 });
|
||||||
await rename(join(this.#root, "projects", key), trash);
|
await rename(join(root, "projects", key), trash);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (isMissing(cause)) return;
|
if (isMissing(cause)) return;
|
||||||
throw cause;
|
throw cause;
|
||||||
@@ -1045,7 +1071,7 @@ export class KunaProjectCache {
|
|||||||
async #touch(key: string): Promise<boolean> {
|
async #touch(key: string): Promise<boolean> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
await utimes(join(this.#root, "projects", key), now, now);
|
await utimes(join(await this.#resolveRoot(), "projects", key), now, now);
|
||||||
return true;
|
return true;
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// Recency is advisory; only a missing entry changes the answer.
|
// Recency is advisory; only a missing entry changes the answer.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { join, relative } from "node:path";
|
||||||
|
|
||||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
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 { 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 { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||||
@@ -146,7 +148,7 @@ export const locateWiresharkTool = (
|
|||||||
return selection.command;
|
return selection.command;
|
||||||
for (const directory of (environment.PATH ?? "").split(delimiter)) {
|
for (const directory of (environment.PATH ?? "").split(delimiter)) {
|
||||||
if (!isAbsolute(directory)) continue;
|
if (!isAbsolute(directory)) continue;
|
||||||
const candidate = join(directory, selection.command);
|
const candidate = appendCallerPath(directory, selection.command);
|
||||||
try {
|
try {
|
||||||
accessSync(candidate, constants.X_OK);
|
accessSync(candidate, constants.X_OK);
|
||||||
if (statSync(candidate).isFile()) return candidate;
|
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 { lstat, open, opendir, readlink, realpath } from "node:fs/promises";
|
||||||
import { basename, dirname, join, relative } from "node:path";
|
import { basename, dirname, join, relative } from "node:path";
|
||||||
import type { FileHandle } from "node:fs/promises";
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
import { appendCallerPath } from "../../filesystem/callerPath.js";
|
||||||
import type { ProcessScenario } from "../../domain/process/processScenario.js";
|
import type { ProcessScenario } from "../../domain/process/processScenario.js";
|
||||||
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
|
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
|
||||||
import type { Stats } from "node:fs";
|
import type { Stats } from "node:fs";
|
||||||
@@ -606,7 +607,13 @@ export const snapshotRoots = async (
|
|||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const child = await ownedDirectory.read();
|
const child = await ownedDirectory.read();
|
||||||
if (child === null) break;
|
if (child === null) break;
|
||||||
if (await omitObserverPath(root, rootAlias, join(path, child.name)))
|
if (
|
||||||
|
await omitObserverPath(
|
||||||
|
root,
|
||||||
|
rootAlias,
|
||||||
|
appendCallerPath(path, child.name),
|
||||||
|
)
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
if (
|
if (
|
||||||
children.length >= remainingEntries ||
|
children.length >= remainingEntries ||
|
||||||
@@ -657,7 +664,14 @@ export const snapshotRoots = async (
|
|||||||
const child = children.pop();
|
const child = children.pop();
|
||||||
if (child === undefined) break;
|
if (child === undefined) break;
|
||||||
pendingNames -= 1;
|
pendingNames -= 1;
|
||||||
if (!(await visit(root, rootAlias, join(path, child), depth + 1)))
|
if (
|
||||||
|
!(await visit(
|
||||||
|
root,
|
||||||
|
rootAlias,
|
||||||
|
appendCallerPath(path, child),
|
||||||
|
depth + 1,
|
||||||
|
))
|
||||||
|
)
|
||||||
complete = false;
|
complete = false;
|
||||||
}
|
}
|
||||||
return complete;
|
return complete;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
chmod,
|
chmod,
|
||||||
mkdir,
|
mkdir,
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
rm,
|
rm,
|
||||||
|
realpath,
|
||||||
symlink,
|
symlink,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
@@ -335,3 +337,75 @@ const runtimePathFailure = async (
|
|||||||
throw new Error("Selected runtime path should be invalid input");
|
throw new Error("Selected runtime path should be invalid input");
|
||||||
return failure;
|
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 { constants as fsConstants } from "node:fs";
|
||||||
import { access, realpath, stat } from "node:fs/promises";
|
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 type { ProcessScenario } from "../../domain/process/processScenario.js";
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +21,9 @@ export const resolveProcessScenarioRuntimePaths = async (
|
|||||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||||
platform: NodeJS.Platform = process.platform,
|
platform: NodeJS.Platform = process.platform,
|
||||||
): Promise<ProcessScenario> => {
|
): Promise<ProcessScenario> => {
|
||||||
const selectedWorkingDirectory = resolve(scenario.working_directory);
|
const selectedWorkingDirectory = absoluteCallerPath(
|
||||||
|
scenario.working_directory,
|
||||||
|
);
|
||||||
let workingDirectory: string;
|
let workingDirectory: string;
|
||||||
try {
|
try {
|
||||||
workingDirectory = await realpath(selectedWorkingDirectory);
|
workingDirectory = await realpath(selectedWorkingDirectory);
|
||||||
@@ -52,11 +59,10 @@ export const resolveProcessScenarioRuntimePaths = async (
|
|||||||
scenario.executable,
|
scenario.executable,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
snapshotEnvironment(scenario.environment, platform).PATH ??
|
snapshotEnvironment(scenario.environment, platform).PATH ??
|
||||||
snapshotEnvironment(environment, platform).PATH ??
|
snapshotEnvironment(environment, platform).PATH,
|
||||||
"",
|
|
||||||
);
|
);
|
||||||
const observationPaths = scenario.filesystem_observation_paths.map((path) =>
|
const observationPaths = scenario.filesystem_observation_paths.map((path) =>
|
||||||
resolve(workingDirectory, path),
|
absoluteCallerPath(path, { cwd: workingDirectory }),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...scenario,
|
...scenario,
|
||||||
@@ -75,22 +81,24 @@ export const resolveProcessScenarioRuntimePaths = async (
|
|||||||
export const resolveExecutable = async (
|
export const resolveExecutable = async (
|
||||||
command: string,
|
command: string,
|
||||||
workingDirectory: string,
|
workingDirectory: string,
|
||||||
searchPath: string,
|
searchPath: string | undefined,
|
||||||
): Promise<string> => {
|
): 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)
|
.split(delimiter)
|
||||||
.map((directory) =>
|
.map((directory) =>
|
||||||
directory === ""
|
directory === ""
|
||||||
? workingDirectory
|
? workingDirectory
|
||||||
: resolve(workingDirectory, directory),
|
: absoluteCallerPath(directory, { cwd: workingDirectory }),
|
||||||
);
|
);
|
||||||
const explicit =
|
const explicit =
|
||||||
isAbsolute(command) ||
|
isAbsolute(command) ||
|
||||||
command.includes("/") ||
|
command.includes("/") ||
|
||||||
(sep === "\\" && command.includes("\\"));
|
(sep === "\\" && command.includes("\\"));
|
||||||
const candidates = explicit
|
const candidates = explicit
|
||||||
? [resolve(workingDirectory, command)]
|
? [absoluteCallerPath(command, { cwd: workingDirectory })]
|
||||||
: directories.map((directory) => join(directory, command));
|
: directories.map((directory) => appendCallerPath(directory, command));
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
let metadata;
|
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 { join } from "node:path";
|
||||||
import { expect } from "vitest";
|
import { expect } from "vitest";
|
||||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
@@ -85,3 +86,60 @@ itWithCaptureCapability(
|
|||||||
},
|
},
|
||||||
20_000,
|
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);
|
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,
|
chmod,
|
||||||
mkdir,
|
mkdir,
|
||||||
readdir,
|
readdir,
|
||||||
|
realpath,
|
||||||
|
symlink,
|
||||||
readFile,
|
readFile,
|
||||||
rename,
|
rename,
|
||||||
rm,
|
rm,
|
||||||
@@ -1085,3 +1087,61 @@ it("removes its staging when publication itself fails", async () => {
|
|||||||
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
expect(await entries(cacheRoot, "staging")).toEqual([]);
|
||||||
expect(await entries(cacheRoot, "projects")).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