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>
194 lines
7.1 KiB
TypeScript
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");
|
|
});
|
|
});
|