fs-safe/test/fs-safe.test.ts
Sarah Fortune 3be7ba6ee3 ci+test: run check on windows and guard windows-only test behavior
Run the check job on windows-latest in addition to ubuntu so the
windows code paths (no O_NOFOLLOW, node fallbacks for fd-relative
ops, ACL inspection) are exercised on every PR rather than only
documented.

Make the test suite pass on the new windows runner by addressing
the platform-specific failures:

- Long happy-path tests that mix supported (mkdir, write, read) and
  unsupported (stat, list, move, exists) operations are guarded
  with skipIf(process.platform === "win32") since the pinned
  filesystem helper throws "unsupported-platform" on win32 by
  design (src/pinned-python.ts).
- Short focused tests where the unsupported operation is the whole
  point (pinned-python, pinned-write-fallback-coverage,
  write-boundary-bypass symlink-move) split into runIf(non-win32)
  and runIf(win32) tests, with the windows variant asserting
  unsupported-platform.
- The expectFsSafeCode helper accepts unsupported-platform on
  windows; new expectedFsSafeCode helper substitutes for
  per-rejects.toMatchObject sites where the windows code differs
  from posix (e.g. path-alias / not-found returning
  unsupported-platform via the helper layer).
- secure-file-reads test split into a posix happy-path runIf and a
  windows runIf that asserts permission-unverified, since ACL
  inspection has no portable equivalent on windows
  (src/secure-file.ts:177).
- safeFileURLToPath test uses hardcoded platform-specific input/
  output instead of building the URL via pathToFileURL+fileURLToPath
  so the assertion verifies the function directly.
- Fix expandHomePrefix to normalize path separators by splitting via
  path.normalize + path.sep and rejoining via path.join. Apply the
  same segment-based check to resolveHomeRelativePath and
  resolveOsHomeRelativePath. Drop input.trim() — whitespace is a
  valid filename character on both platforms and env-var inputs are
  already trimmed upstream via normalizeOptionalString.
- coverage-more's "normalizes empty temp names" decomposes the
  result with path.dirname/path.basename instead of regex-matching
  a path-separator literal.
- extracted-helpers' path-helpers test builds its root with
  path.resolve so the drive letter is present on windows.
- additional-boundary-bypass guards its "..\evil.txt" sanitizer
  assertion behind a non-win32 check (windows reserves "\" as a
  path separator and cannot have it in a filename).
- coverage-more's sibling temp test guards just the posix file-mode
  assertion (stat.mode & 0o777 === 0o600), which has no analog on
  windows. The syncing behaviour the test actually targets still
  runs on both platforms.
- Raise test/new-primitives.test.ts size budget to 1500 to
  accommodate the secure-file-reads test split.

After: 253 passed, 1 failed, 66 skipped on windows-11-arm64. The
single remaining failure is a separate library-side gap (a
SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS payload resolves on windows
instead of rejecting) and will be tracked in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:59:24 -07:00

433 lines
17 KiB
TypeScript

import { appendFileSync } from "node:fs";
import { mkdtemp, readdir, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { configureFsSafePython, FsSafeError, root as openRoot } from "../src/index.js";
import { openLocalFileSafely, readLocalFileSafely } from "../src/root.js";
import { __setFsSafeTestHooksForTest } from "../src/test-hooks.js";
import { expectedFsSafeCode } from "./helpers/security.js";
const skipOnWindows = process.platform === "win32";
const tempDirs: string[] = [];
async function tempRoot(prefix: string): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
configureFsSafePython({ mode: "auto", pythonPath: undefined });
__setFsSafeTestHooksForTest(undefined);
const { rm } = await import("node:fs/promises");
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
describe("@openclaw/fs-safe", () => {
it.skipIf(skipOnWindows)("reuses a root capability across filesystem operations", async () => {
const rootPath = await tempRoot("fs-root-object-");
const root = await openRoot(rootPath);
await root.mkdir("nested");
await root.write("nested/file.txt", "hello");
await root.append("nested/file.txt", " world");
await expect(root.readText("nested/file.txt")).resolves.toBe("hello world");
await expect(root.readBytes("nested/file.txt")).resolves.toEqual(
Buffer.from("hello world"),
);
const stat = await root.stat("nested/file.txt");
expect(stat.isFile).toBe(true);
await expect(root.list("nested")).resolves.toEqual(["file.txt"]);
await root.move("nested/file.txt", "nested/renamed.txt");
await expect(root.read("nested/renamed.txt")).resolves.toMatchObject({
realPath: expect.stringContaining("renamed.txt"),
});
await root.remove("nested/renamed.txt");
await expect(root.stat("nested/renamed.txt")).rejects.toMatchObject({
code: "not-found",
});
});
it("rejects non-directory roots before creating a capability", async () => {
const rootPath = await tempRoot("fs-safe-root-file-");
const filePath = path.join(rootPath, "file.txt");
await writeFile(filePath, "not a directory");
await expect(openRoot(filePath)).rejects.toMatchObject({
code: "invalid-path",
message: "root dir is not a directory",
});
});
it.skipIf(skipOnWindows)("can disable the Python helper and keep root operations available", async () => {
configureFsSafePython({ mode: "off" });
const rootPath = await tempRoot("fs-safe-python-off-");
const sourceRoot = await tempRoot("fs-safe-python-off-source-");
const sourcePath = path.join(sourceRoot, "source.txt");
const root = await openRoot(rootPath);
await writeFile(sourcePath, "copied");
await root.mkdir("nested");
await root.write("nested/file.txt", "hello");
await root.copyIn("nested/copied.txt", sourcePath, { maxBytes: 16 });
await expect(root.stat("nested/file.txt")).resolves.toMatchObject({ isFile: true });
await expect(root.list("nested")).resolves.toEqual(["copied.txt", "file.txt"]);
await root.move("nested/file.txt", "nested/moved.txt");
await expect(root.readText("nested/moved.txt")).resolves.toBe("hello");
await root.remove("nested/copied.txt");
await expect(root.exists("nested/copied.txt")).resolves.toBe(false);
});
it("applies per-root defaults", async () => {
const rootPath = await tempRoot("fs-safe-defaults-");
const root = await openRoot(rootPath, {
hardlinks: "reject",
mkdir: true,
});
await root.writeJson("nested/config.json", { ok: true }, { space: 2 });
await expect(root.readJson("nested/config.json")).resolves.toEqual({ ok: true });
await expect(readFile(path.join(rootPath, "nested/config.json"), "utf8")).resolves.toBe(
'{\n "ok": true\n}\n',
);
});
it("limits root reads by default and allows explicit larger reads", async () => {
const rootPath = await tempRoot("fs-safe-default-max-");
const root = await openRoot(rootPath);
await writeFile(path.join(rootPath, "large.bin"), Buffer.alloc(16 * 1024 * 1024 + 1));
await expect(root.read("large.bin")).rejects.toMatchObject({ code: "too-large" });
await expect(root.readBytes("large.bin", { maxBytes: Number.POSITIVE_INFINITY })).resolves
.toHaveLength(16 * 1024 * 1024 + 1);
});
it("creates files only when missing", async () => {
const rootPath = await tempRoot("fs-safe-if-missing-");
const root = await openRoot(rootPath);
await expect(root.create("nested/file.txt", "first")).resolves.toBeUndefined();
await expect(root.create("nested/file.txt", "second")).rejects.toMatchObject({
code: "already-exists",
});
await expect(readFile(path.join(rootPath, "nested/file.txt"), "utf8")).resolves.toBe("first");
await expect(root.createJson("state.json", { ok: true })).resolves.toBeUndefined();
await expect(root.createJson("state.json", { ok: false })).rejects.toMatchObject({
code: "already-exists",
});
await expect(readFile(path.join(rootPath, "state.json"), "utf8")).resolves.toBe(
'{"ok":true}\n',
);
});
it.skipIf(skipOnWindows)("writes, reads, stats, and lists files within a root", async () => {
const root = await openRoot(await tempRoot("fs-safe-basic-"));
await root.mkdir("nested");
await root.write("nested/file.txt", "hello");
const read = await root.read("nested/file.txt");
expect(read.buffer.toString("utf8")).toBe("hello");
const stat = await root.stat("nested/file.txt");
expect(stat.isFile).toBe(true);
expect(stat.size).toBe(5);
await expect(root.exists("nested/file.txt")).resolves.toBe(true);
await expect(root.exists("nested/missing.txt")).resolves.toBe(false);
await expect(root.list("nested")).resolves.toEqual(["file.txt"]);
const entries = await root.list("nested", { withFileTypes: true });
expect(entries).toMatchObject([{ isFile: true, name: "file.txt" }]);
});
it("rejects traversal and absolute paths before touching the filesystem", async () => {
const root = await openRoot(await tempRoot("fs-safe-traversal-"));
await expect(root.stat("../outside")).rejects.toMatchObject({ code: "invalid-path" });
await expect(root.read("/etc/passwd")).rejects.toMatchObject({
category: "policy",
code: "outside-workspace",
} satisfies Partial<FsSafeError>);
await expect(root.write("../write", "")).rejects.toMatchObject({
code: "outside-workspace",
});
});
it("rejects NUL bytes with FsSafeError before reaching Node fs", async () => {
const root = await openRoot(await tempRoot("fs-safe-nul-"));
for (const operation of [
() => root.resolve("x\0y"),
() => root.open("x\0y"),
() => root.openWritable("x\0y"),
() => root.read("x\0y"),
() => root.readBytes("x\0y"),
() => root.readText("x\0y"),
() => root.readJson("x\0y"),
() => root.write("x\0y", "data"),
() => root.append("x\0y", "data"),
() => root.copyIn("x\0y", path.join(root.rootDir, "source.txt")),
() => root.exists("x\0y"),
() => root.stat("x\0y"),
() => root.list("x\0y"),
() => root.move("x\0y", "dest.txt"),
() => root.move("source.txt", "x\0y"),
() => root.remove("x\0y"),
() => root.mkdir("x\0y"),
]) {
await expect(operation()).rejects.toMatchObject({
code: "invalid-path",
message: "relative path contains a NUL byte",
});
}
await expect(root.copyIn("dest.txt", `${root.rootDir}/source\0.txt`)).rejects.toMatchObject({
code: "invalid-path",
message: "source path contains a NUL byte",
});
});
it("rejects NUL bytes on public root and local-file entry points", async () => {
const rootPath = await tempRoot("fs-safe-public-nul-");
const filePath = path.join(rootPath, "file.txt");
await writeFile(filePath, "ok");
for (const operation of [
() => openRoot(`${rootPath}\0bad`),
() => openLocalFileSafely({ filePath: `${filePath}\0bad` }),
() => readLocalFileSafely({ filePath: `${filePath}\0bad` }),
]) {
let thrown: unknown;
try {
await operation();
} catch (error) {
thrown = error;
}
expect(thrown).toMatchObject({ code: "invalid-path" });
expect(String(thrown)).not.toContain(rootPath);
}
});
it("rejects reader callbacks for absolute paths outside the root", async () => {
const root = await openRoot(await tempRoot("fs-safe-reader-root-"));
const outside = await tempRoot("fs-safe-reader-outside-");
const outsidePath = path.join(outside, "secret.txt");
await writeFile(outsidePath, "secret");
await expect(root.reader()(outsidePath)).rejects.toMatchObject({
code: "outside-workspace",
});
});
it("rejects symlink parents", async () => {
const rootPath = await tempRoot("fs-safe-symlink-parent-");
const root = await openRoot(rootPath);
const outside = await tempRoot("fs-safe-outside-");
await writeFile(path.join(outside, "secret.txt"), "secret");
await symlink(outside, path.join(rootPath, "link"), "dir");
await expect(root.read("link/secret.txt")).rejects.toMatchObject({
code: "outside-workspace",
});
await expect(root.list("link")).rejects.toMatchObject({ code: expectedFsSafeCode("path-alias") });
});
it("rejects symlink leaves for stat and read", async () => {
const rootPath = await tempRoot("fs-safe-symlink-leaf-");
const root = await openRoot(rootPath);
const outside = await tempRoot("fs-safe-outside-");
await writeFile(path.join(outside, "secret.txt"), "secret");
await symlink(path.join(outside, "secret.txt"), path.join(rootPath, "secret-link"), "file");
await expect(root.stat("secret-link")).rejects.toMatchObject({ code: expectedFsSafeCode("path-alias") });
await expect(root.read("secret-link")).rejects.toMatchObject({ code: "symlink" });
});
it.skipIf(skipOnWindows)("renames paths within the same root and rejects symlink sources", async () => {
const rootPath = await tempRoot("fs-safe-rename-");
const root = await openRoot(rootPath);
const outside = await tempRoot("fs-safe-outside-");
await root.write("from.txt", "move me");
await root.move("from.txt", "to.txt");
await expect(readFile(path.join(rootPath, "to.txt"), "utf8")).resolves.toBe("move me");
await writeFile(path.join(outside, "secret.txt"), "secret");
await symlink(path.join(outside, "secret.txt"), path.join(rootPath, "link"), "file");
await expect(root.move("link", "moved-link")).rejects.toMatchObject({
code: "path-alias",
});
});
it.skipIf(skipOnWindows)("requires explicit overwrite for moves that replace a target", async () => {
const rootPath = await tempRoot("fs-safe-rename-overwrite-");
const root = await openRoot(rootPath);
await root.write("from.txt", "source");
await root.write("to.txt", "target");
await expect(root.move("from.txt", "to.txt")).rejects.toMatchObject({
code: "already-exists",
});
await expect(readFile(path.join(rootPath, "to.txt"), "utf8")).resolves.toBe("target");
await root.move("from.txt", "to.txt", { overwrite: true });
await expect(readFile(path.join(rootPath, "to.txt"), "utf8")).resolves.toBe("source");
});
it.skipIf(skipOnWindows)("enforces copyIn maxBytes while streaming", async () => {
const rootPath = await tempRoot("fs-safe-copy-limit-");
const sourceRoot = await tempRoot("fs-safe-copy-source-");
const sourcePath = path.join(sourceRoot, "source.txt");
await writeFile(sourcePath, "1234");
const root = await openRoot(rootPath);
__setFsSafeTestHooksForTest({
afterOpen(filePath, handle) {
if (filePath !== sourcePath) {
return;
}
appendFileSync(sourcePath, "567890");
},
});
await expect(root.copyIn("copied.txt", sourcePath, { maxBytes: 4 })).rejects.toMatchObject({
code: "too-large",
});
await expect(root.exists("copied.txt")).resolves.toBe(false);
await expect(readdir(rootPath)).resolves.toEqual([]);
});
it("rejects pinned copy when the source path is swapped after identity capture", async () => {
if (process.platform === "win32") {
return;
}
const { runPinnedCopyHelper } = await import("../src/pinned-write.js");
const rootPath = await tempRoot("fs-safe-copy-source-swap-root-");
const sourceRoot = await tempRoot("fs-safe-copy-source-swap-source-");
const sourcePath = path.join(sourceRoot, "source.txt");
const replacementPath = path.join(sourceRoot, "replacement.txt");
await writeFile(sourcePath, "original");
await writeFile(replacementPath, "replacement");
const sourceIdentity = await stat(sourcePath);
await rm(sourcePath);
await rename(replacementPath, sourcePath);
configureFsSafePython({ mode: "require" });
try {
await runPinnedCopyHelper({
rootPath,
relativeParentPath: "",
basename: "copied.txt",
mkdir: true,
mode: 0o600,
overwrite: true,
maxBytes: 1024,
sourcePath,
sourceIdentity: { dev: sourceIdentity.dev, ino: sourceIdentity.ino },
});
throw new Error("expected pinned copy source swap to fail");
} catch (error) {
if (error instanceof FsSafeError && error.code === "helper-unavailable") {
return;
}
expect(error).toMatchObject({ code: "path-mismatch" });
}
await expect(stat(path.join(rootPath, "copied.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("removes symlink leaves without following them", async () => {
const rootPath = await tempRoot("fs-safe-remove-");
const root = await openRoot(rootPath);
const outside = await tempRoot("fs-safe-outside-");
const outsideFile = path.join(outside, "kept.txt");
await writeFile(outsideFile, "kept");
await symlink(outsideFile, path.join(rootPath, "link"), "file");
await root.remove("link");
await expect(readFile(outsideFile, "utf8")).resolves.toBe("kept");
await expect(root.stat("link")).rejects.toMatchObject({
code: expectedFsSafeCode("not-found"),
});
});
it("opens a file handle for fast reads when kernel fd path validation is available", async () => {
const root = await openRoot(await tempRoot("fs-safe-open-"));
await root.write("file.txt", "fast");
const opened = await root.open("file.txt");
try {
await expect(opened.handle.readFile("utf8")).resolves.toBe("fast");
} finally {
await opened.handle.close();
}
});
it("supports await using for escaped read and write handles", async () => {
const rootPath = await tempRoot("fs-safe-dispose-");
const root = await openRoot(rootPath);
await root.write("file.txt", "fast");
{
await using opened = await root.open("file.txt");
await expect(opened.handle.readFile("utf8")).resolves.toBe("fast");
}
{
await using writable = await root.openWritable("write.txt");
await writable.handle.writeFile("written");
}
await expect(readFile(path.join(rootPath, "write.txt"), "utf8")).resolves.toBe("written");
{
await using writable = await root.openWritable("write.txt", { writeMode: "append" });
await writable.handle.appendFile(" again");
}
await expect(readFile(path.join(rootPath, "write.txt"), "utf8")).resolves.toBe(
"written again",
);
});
it("honors mode on root text and JSON writes", async () => {
const rootPath = await tempRoot("fs-safe-write-mode-");
const root = await openRoot(rootPath);
await root.write("secret.txt", "secret", { mode: 0o640 });
await root.writeJson("secret.json", { ok: true }, { mode: 0o640 });
if (process.platform !== "win32") {
await expect(stat(path.join(rootPath, "secret.txt")).then((s) => s.mode & 0o777)).resolves
.toBe(0o640);
await expect(stat(path.join(rootPath, "secret.json")).then((s) => s.mode & 0o777)).resolves
.toBe(0o640);
}
});
it("honors default mode on root writes", async () => {
const rootPath = await tempRoot("fs-safe-default-write-mode-");
const root = await openRoot(rootPath, { mode: 0o640 });
await root.write("secret.txt", "secret");
await root.writeJson("secret.json", { ok: true });
if (process.platform !== "win32") {
await expect(stat(path.join(rootPath, "secret.txt")).then((s) => s.mode & 0o777)).resolves
.toBe(0o640);
await expect(stat(path.join(rootPath, "secret.json")).then((s) => s.mode & 0o777)).resolves
.toBe(0o640);
}
});
});