fs-safe/test/extracted-helpers.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

194 lines
7.1 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
isWindowsDrivePath,
normalizeArchiveEntryPath,
resolveArchiveOutputPath,
stripArchivePath,
validateArchiveEntryPath,
} from "../src/archive.js";
import {
assertCanonicalPathWithinBase,
resolveSafeInstallDir,
safeDirName,
safePathSegmentHashed,
} from "../src/install-path.js";
import { basenameFromMediaSource, safeFileURLToPath } from "../src/local-file-access.js";
import { formatPosixMode } from "../src/mode.js";
import { isPathInside, isWithinDir, resolveSafeBaseDir } from "../src/path.js";
import { tryReadJson, tryReadJsonSync, writeJson, writeJsonSync } from "../src/json.js";
import {
DEFAULT_SECRET_FILE_MAX_BYTES,
PRIVATE_SECRET_DIR_MODE,
PRIVATE_SECRET_FILE_MODE,
readSecretFileSync,
tryReadSecretFileSync,
writeSecretFileAtomic,
} from "../src/secret-file.js";
const tempDirs: string[] = [];
async function tempRoot(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
});
describe("local file access helpers", () => {
it("accepts local file URLs and rejects remote hosts or encoded separators", () => {
const [validUrl, expectedPath] = process.platform === "win32"
? ["file:///C:/tmp/demo.txt", "C:\\tmp\\demo.txt"]
: ["file:///tmp/demo.txt", "/tmp/demo.txt"];
expect(safeFileURLToPath(validUrl)).toBe(expectedPath);
expect(() => safeFileURLToPath("file://example.com/tmp/demo.txt")).toThrow(/remote hosts/);
expect(() => safeFileURLToPath("file:///tmp/a%2Fb.txt")).toThrow(/encode path separators/);
});
it("extracts basenames from file paths and URLs", () => {
expect(basenameFromMediaSource("https://example.com/files/report.txt?x=1")).toBe("report.txt");
expect(basenameFromMediaSource("/tmp/report.txt")).toBe("report.txt");
});
});
describe("path helpers", () => {
it("checks containment and formats modes", () => {
// Use path.resolve so on Windows the root carries a drive letter, which
// is what resolveSafeBaseDir / isPathInside both produce internally.
const root = path.resolve(path.sep, "tmp", "root");
const otherRoot = path.resolve(path.sep, "tmp", "root-other");
expect(resolveSafeBaseDir(root)).toBe(`${root}${path.sep}`);
expect(isWithinDir(root, path.join(root, "file.txt"))).toBe(true);
expect(isPathInside(root, otherRoot)).toBe(false);
expect(formatPosixMode(0o100755)).toBe("755");
});
});
describe("install path helpers", () => {
it("normalizes path segments and resolves install dirs under the base", () => {
expect(safeDirName("../demo/plugin")).toBe("..__demo__plugin");
expect(safePathSegmentHashed("../../demo/skill")).toMatch(/-[a-f0-9]{10}$/);
expect(
resolveSafeInstallDir({
baseDir: "/tmp/plugins",
id: "@openclaw/matrix",
invalidNameMessage: "invalid plugin name",
}),
).toEqual({
ok: true,
path: path.join("/tmp/plugins", "@openclaw__matrix"),
});
});
it("validates canonical paths under a base directory", async () => {
const baseDir = await tempRoot("fs-safe-install-");
const candidate = path.join(baseDir, "tools", "plugin");
await fs.mkdir(path.dirname(candidate), { recursive: true });
await expect(
assertCanonicalPathWithinBase({
baseDir,
candidatePath: candidate,
boundaryLabel: "install directory",
}),
).resolves.toBeUndefined();
});
});
describe("json helpers", () => {
it("writes and reads sync and async JSON files", async () => {
const root = await tempRoot("fs-safe-json-");
const syncPath = path.join(root, "sync", "state.json");
const asyncPath = path.join(root, "async", "state.json");
writeJsonSync(syncPath, { ok: true });
expect(tryReadJsonSync(syncPath)).toEqual({ ok: true });
await writeJson(asyncPath, { ok: true }, { trailingNewline: true });
await expect(tryReadJson(asyncPath)).resolves.toEqual({ ok: true });
await expect(fs.readFile(asyncPath, "utf8")).resolves.toBe("{\n \"ok\": true\n}\n");
});
});
describe("archive entry helpers", () => {
it("validates and strips archive paths", () => {
expect(isWindowsDrivePath("C:\\temp\\file.txt")).toBe(true);
expect(normalizeArchiveEntryPath("dir\\file.txt")).toBe("dir/file.txt");
expect(stripArchivePath("a//b/file.txt", 1)).toBe("b/file.txt");
expect(stripArchivePath("./", 0)).toBeNull();
expect(() => validateArchiveEntryPath("../escape.txt", { escapeLabel: "targetDir" })).toThrow(
"archive entry escapes targetDir: ../escape.txt",
);
expect(() => validateArchiveEntryPath("C:\\temp\\file.txt")).toThrow(
"archive entry uses a drive path",
);
});
it("resolves archive output paths under the destination root", () => {
const rootDir = path.join(path.sep, "tmp", "archive-root");
expect(
resolveArchiveOutputPath({
rootDir,
relPath: "sub/file.txt",
originalPath: "sub/file.txt",
}),
).toBe(path.resolve(rootDir, "sub/file.txt"));
expect(() =>
resolveArchiveOutputPath({
rootDir,
relPath: "../escape.txt",
originalPath: "../escape.txt",
escapeLabel: "targetDir",
}),
).toThrow("archive entry escapes targetDir: ../escape.txt");
});
});
describe("secret file helpers", () => {
it("reads and validates secret files", async () => {
const root = await tempRoot("fs-safe-secret-");
const filePath = path.join(root, "secret.txt");
await fs.writeFile(filePath, " top-secret \n", "utf8");
expect(readSecretFileSync(filePath, "Gateway password")).toBe("top-secret");
expect(tryReadSecretFileSync(filePath, "Gateway password")).toBe("top-secret");
await fs.writeFile(filePath, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1), "utf8");
expect(() => readSecretFileSync(filePath, "Gateway password")).toThrow(
`Gateway password file at ${filePath} exceeds ${DEFAULT_SECRET_FILE_MAX_BYTES} bytes.`,
);
});
it("writes private secret files without following symlink parents", async () => {
const root = await tempRoot("fs-safe-secret-write-");
const filePath = path.join(root, "nested", "auth.json");
await writeSecretFileAtomic({
rootDir: root,
filePath,
content: '{"ok":true}\n',
});
expect(readSecretFileSync(filePath, "Gateway password")).toBe('{"ok":true}');
if (process.platform !== "win32") {
const dirStat = await fs.stat(path.dirname(filePath));
const fileStat = await fs.stat(filePath);
expect(dirStat.mode & 0o777).toBe(PRIVATE_SECRET_DIR_MODE);
expect(fileStat.mode & 0o777).toBe(PRIVATE_SECRET_FILE_MODE);
}
const outside = await tempRoot("fs-safe-secret-outside-");
await fs.symlink(outside, path.join(root, "linked"));
await expect(
writeSecretFileAtomic({
rootDir: root,
filePath: path.join(root, "linked", "auth.json"),
content: '{"ok":true}\n',
}),
).rejects.toThrow("must not be a symlink");
});
});