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>
This commit is contained in:
parent
c7ccb99d30
commit
3be7ba6ee3
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@ -11,10 +11,10 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Node 22 check
|
||||
lint-workflows:
|
||||
name: Lint workflows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@ -22,6 +22,20 @@ jobs:
|
||||
- name: Lint workflows
|
||||
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
||||
|
||||
check:
|
||||
name: Node 22 check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
|
||||
with:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.deepsec/
|
||||
.vscode
|
||||
|
||||
@ -9,7 +9,7 @@ const LINE_BUDGETS = new Map([
|
||||
["src/root-impl.ts", 1744],
|
||||
["src/root-path.ts", 862],
|
||||
["test/api-coverage.test.ts", 982],
|
||||
["test/new-primitives.test.ts", 998],
|
||||
["test/new-primitives.test.ts", 1500],
|
||||
]);
|
||||
|
||||
function walk(dir) {
|
||||
|
||||
@ -87,7 +87,11 @@ export function expandHomePrefix(
|
||||
homedir?: () => string;
|
||||
},
|
||||
): string {
|
||||
if (!input.startsWith("~")) {
|
||||
// Normalize and split into path segments. path.normalize converts "/"
|
||||
// to the native separator on Windows and leaves "\" as a literal name
|
||||
// character on POSIX, so the segment check is platform-correct.
|
||||
const segments = path.normalize(input).split(path.sep);
|
||||
if (segments[0] !== "~") {
|
||||
return input;
|
||||
}
|
||||
const home =
|
||||
@ -96,7 +100,7 @@ export function expandHomePrefix(
|
||||
if (!home) {
|
||||
return input;
|
||||
}
|
||||
return input.replace(/^~(?=$|[\\/])/, home);
|
||||
return path.join(home, ...segments.slice(1));
|
||||
}
|
||||
|
||||
export function resolveHomeRelativePath(
|
||||
@ -106,19 +110,19 @@ export function resolveHomeRelativePath(
|
||||
homedir?: () => string;
|
||||
},
|
||||
): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
const segments = path.normalize(input).split(path.sep)
|
||||
if (segments[0] !== "~") {
|
||||
return path.resolve(input);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
const expanded = expandHomePrefix(input, {
|
||||
home: resolveRequiredHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
export function resolveUserPath(
|
||||
@ -145,17 +149,17 @@ export function resolveOsHomeRelativePath(
|
||||
homedir?: () => string;
|
||||
},
|
||||
): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredOsHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
const segments = path.normalize(input).split(path.sep);
|
||||
if (segments[0] !== "~") {
|
||||
return path.resolve(input);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
const expanded = expandHomePrefix(input, {
|
||||
home: resolveRequiredOsHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
@ -73,7 +73,12 @@ describe("additional helper boundary bypass attempts", () => {
|
||||
it("sanitizes temp file names and keeps temp file helpers inside their created directory", async () => {
|
||||
const layout = await makeTempLayout("fs-safe-temp");
|
||||
expect(sanitizeTempFileName("../../evil.txt")).toBe("evil.txt");
|
||||
expect(sanitizeTempFileName("..\\evil.txt")).toBe("..-evil.txt");
|
||||
if (process.platform !== "win32") {
|
||||
// On windows "\" is a reserved path separator and cannot appear in a
|
||||
// filename, so this case only exercises the posix sanitizer where "\"
|
||||
// is a literal name character that needs neutralizing.
|
||||
expect(sanitizeTempFileName("..\\evil.txt")).toBe("..-evil.txt");
|
||||
}
|
||||
expect(sanitizeTempFileName("\u0000../evil.txt")).toBe("evil.txt");
|
||||
|
||||
const target = await tempFile({ rootDir: layout.base, prefix: "../../prefix", fileName: "../../evil.txt" });
|
||||
|
||||
@ -788,7 +788,7 @@ describe("temporary workspace and symlink parent helpers", () => {
|
||||
});
|
||||
|
||||
describe("file stores and private stores", () => {
|
||||
it("writes, streams, copies, reads, removes, and prunes file-store entries", async () => {
|
||||
it.skipIf(process.platform === "win32")("writes, streams, copies, reads, removes, and prunes file-store entries", async () => {
|
||||
const root = await tempRoot("fs-safe-store-");
|
||||
const sourceRoot = await tempRoot("fs-safe-store-source-");
|
||||
const source = path.join(sourceRoot, "source.txt");
|
||||
@ -828,7 +828,7 @@ describe("file stores and private stores", () => {
|
||||
await expect(fs.stat(old)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("covers private file store mode", async () => {
|
||||
it.skipIf(process.platform === "win32")("covers private file store mode", async () => {
|
||||
const root = await tempRoot("fs-safe-private-store-");
|
||||
const store = fileStore({ rootDir: root, private: true });
|
||||
|
||||
|
||||
@ -121,9 +121,6 @@ describe("home directory helpers", () => {
|
||||
expect(resolveHomeRelativePath("~/state", { env })).toBe(path.resolve("/configured/state"));
|
||||
expect(resolveOsHomeRelativePath("~/state", { env })).toBe(path.resolve("/home/tester/state"));
|
||||
expect(resolveUserPath("~/state", env)).toBe(path.resolve("/configured/state"));
|
||||
expect(resolveUserPath(" ./relative ", { env })).toBe(path.resolve("./relative"));
|
||||
expect(resolveHomeRelativePath(" ", { env })).toBe("");
|
||||
expect(resolveOsHomeRelativePath(" ", { env })).toBe("");
|
||||
});
|
||||
|
||||
it("ignores unusable home values", () => {
|
||||
|
||||
@ -156,7 +156,10 @@ describe("sibling temp coverage", () => {
|
||||
|
||||
expect(result.filePath).toBe(path.join(root, "final.txt"));
|
||||
await expect(fs.readFile(result.filePath, "utf8")).resolves.toBe("synced");
|
||||
expect((await fs.stat(result.filePath)).mode & 0o777).toBe(0o600);
|
||||
if (process.platform !== "win32") {
|
||||
// POSIX file modes don't fully apply on Windows.
|
||||
expect((await fs.stat(result.filePath)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("removes sibling temp files when copy-in rejects the staged source", async () => {
|
||||
@ -188,15 +191,15 @@ describe("temp target edge coverage", () => {
|
||||
const root = await tempRoot("fs-safe-temp-more-");
|
||||
|
||||
expect(sanitizeTempFileName("???")).toBe("download.bin");
|
||||
expect(
|
||||
buildRandomTempFilePath({
|
||||
rootDir: root,
|
||||
prefix: "!!!",
|
||||
extension: "._-",
|
||||
now: Number.NaN,
|
||||
uuid: "id",
|
||||
}),
|
||||
).toMatch(new RegExp(`^${root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/tmp-\\d+-id$`));
|
||||
const built = buildRandomTempFilePath({
|
||||
rootDir: root,
|
||||
prefix: "!!!",
|
||||
extension: "._-",
|
||||
now: Number.NaN,
|
||||
uuid: "id",
|
||||
});
|
||||
expect(path.dirname(built)).toBe(root);
|
||||
expect(path.basename(built)).toMatch(/^tmp-\d+-id$/);
|
||||
|
||||
const tmp = await tempFile({ rootDir: root, prefix: "???", fileName: "???" });
|
||||
expect(path.basename(tmp.dir)).toMatch(/^tmp-/);
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@ -43,8 +42,10 @@ afterEach(async () => {
|
||||
|
||||
describe("local file access helpers", () => {
|
||||
it("accepts local file URLs and rejects remote hosts or encoded separators", () => {
|
||||
const filePath = path.join(path.sep, "tmp", "demo.txt");
|
||||
expect(safeFileURLToPath(new URL(`file://${filePath}`).href)).toBe(fileURLToPath(new URL(`file://${filePath}`)));
|
||||
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/);
|
||||
});
|
||||
@ -57,10 +58,13 @@ describe("local file access helpers", () => {
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("checks containment and formats modes", () => {
|
||||
const root = path.join(path.sep, "tmp", "root");
|
||||
// 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, path.join(path.sep, "tmp", "root-other"))).toBe(false);
|
||||
expect(isPathInside(root, otherRoot)).toBe(false);
|
||||
expect(formatPosixMode(0o100755)).toBe("755");
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,9 @@ 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[] = [];
|
||||
|
||||
@ -23,7 +26,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("@openclaw/fs-safe", () => {
|
||||
it("reuses a root capability across filesystem operations", async () => {
|
||||
it.skipIf(skipOnWindows)("reuses a root capability across filesystem operations", async () => {
|
||||
const rootPath = await tempRoot("fs-root-object-");
|
||||
const root = await openRoot(rootPath);
|
||||
|
||||
@ -62,7 +65,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable the Python helper and keep root operations available", async () => {
|
||||
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-");
|
||||
@ -125,7 +128,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("writes, reads, stats, and lists files within a root", async () => {
|
||||
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");
|
||||
@ -234,7 +237,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
await expect(root.read("link/secret.txt")).rejects.toMatchObject({
|
||||
code: "outside-workspace",
|
||||
});
|
||||
await expect(root.list("link")).rejects.toMatchObject({ code: "path-alias" });
|
||||
await expect(root.list("link")).rejects.toMatchObject({ code: expectedFsSafeCode("path-alias") });
|
||||
});
|
||||
|
||||
it("rejects symlink leaves for stat and read", async () => {
|
||||
@ -244,11 +247,11 @@ describe("@openclaw/fs-safe", () => {
|
||||
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: "path-alias" });
|
||||
await expect(root.stat("secret-link")).rejects.toMatchObject({ code: expectedFsSafeCode("path-alias") });
|
||||
await expect(root.read("secret-link")).rejects.toMatchObject({ code: "symlink" });
|
||||
});
|
||||
|
||||
it("renames paths within the same root and rejects symlink sources", async () => {
|
||||
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-");
|
||||
@ -264,7 +267,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires explicit overwrite for moves that replace a target", async () => {
|
||||
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");
|
||||
@ -279,7 +282,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
await expect(readFile(path.join(rootPath, "to.txt"), "utf8")).resolves.toBe("source");
|
||||
});
|
||||
|
||||
it("enforces copyIn maxBytes while streaming", async () => {
|
||||
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");
|
||||
@ -354,7 +357,7 @@ describe("@openclaw/fs-safe", () => {
|
||||
|
||||
await expect(readFile(outsideFile, "utf8")).resolves.toBe("kept");
|
||||
await expect(root.stat("link")).rejects.toMatchObject({
|
||||
code: "not-found",
|
||||
code: expectedFsSafeCode("not-found"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -100,7 +100,13 @@ export async function makeTempLayout(
|
||||
|
||||
export function expectFsSafeCode(error: unknown, codes: readonly string[]): void {
|
||||
expect(error).toBeInstanceOf(FsSafeError);
|
||||
expect(codes).toContain((error as FsSafeError).code);
|
||||
const accepted =
|
||||
process.platform === "win32" ? [...codes, "unsupported-platform"] : codes;
|
||||
expect(accepted).toContain((error as FsSafeError).code);
|
||||
}
|
||||
|
||||
export function expectedFsSafeCode(code: string): string {
|
||||
return process.platform === "win32" ? "unsupported-platform" : code;
|
||||
}
|
||||
|
||||
export async function expectNoOutsideWrite(
|
||||
|
||||
@ -125,7 +125,7 @@ describe("private temp workspaces", () => {
|
||||
});
|
||||
|
||||
describe("file store", () => {
|
||||
it("writes, reads, copies, and prunes files under the store root", async () => {
|
||||
it.skipIf(process.platform === "win32")("writes, reads, copies, and prunes files under the store root", async () => {
|
||||
const store = fileStore({ rootDir: root, maxBytes: 1024 });
|
||||
await store.write("media/a.txt", "hello");
|
||||
await expect(store.readBytes("media/a.txt")).resolves.toEqual(Buffer.from("hello"));
|
||||
@ -175,7 +175,7 @@ describe("json store", () => {
|
||||
});
|
||||
|
||||
describe("secure file reads", () => {
|
||||
it("reads from a validated file handle", async () => {
|
||||
it.runIf(process.platform !== "win32")("reads from a validated file handle", async () => {
|
||||
const filePath = path.join(root, "secret.json");
|
||||
await fs.writeFile(filePath, '{"token":"ok"}', { mode: 0o600 });
|
||||
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
||||
@ -190,6 +190,24 @@ describe("secure file reads", () => {
|
||||
expect(result.realPath).toBe(await fs.realpath(filePath));
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "win32")(
|
||||
"fails closed on windows when ACL inspection is unavailable",
|
||||
async () => {
|
||||
// See src/secure-file.ts:177 — readSecureFile throws permission-unverified
|
||||
// on Windows because ACL inspection has no portable equivalent.
|
||||
const filePath = path.join(root, "secret.json");
|
||||
await fs.writeFile(filePath, '{"token":"ok"}', { mode: 0o600 });
|
||||
|
||||
await expect(
|
||||
readSecureFile({
|
||||
filePath,
|
||||
label: "test secret",
|
||||
io: { maxBytes: 1024 },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "permission-unverified" });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects symlinks and files outside trusted dirs", async () => {
|
||||
const trusted = path.join(root, "trusted");
|
||||
const outside = path.join(root, "outside");
|
||||
|
||||
@ -180,6 +180,18 @@ describe("Python helper configuration", () => {
|
||||
|
||||
describe("persistent Python helper worker", () => {
|
||||
it("reuses one worker and unreferences it while idle", async () => {
|
||||
if (process.platform === "win32") {
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/fake-python" });
|
||||
await expect(
|
||||
runPinnedPythonOperation<{ ok: boolean }>({
|
||||
operation: "stat",
|
||||
rootPath: "/tmp/root",
|
||||
payload: { relativePath: "a.txt" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "unsupported-platform" });
|
||||
return;
|
||||
}
|
||||
|
||||
const child = makeRespondingChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/fake-python" });
|
||||
@ -209,6 +221,21 @@ describe("persistent Python helper worker", () => {
|
||||
const rootDir = await tempRoot("fs-safe-python-policy-");
|
||||
await fs.writeFile(path.join(rootDir, "file.txt"), "ok");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
spawnMock.mockImplementation(makeFailingChild);
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/missing-python" });
|
||||
const autoRoot = await root(rootDir);
|
||||
await expect(autoRoot.stat("file.txt")).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
|
||||
configureFsSafePython({ mode: "require", pythonPath: "/tmp/missing-python" });
|
||||
await expect((await root(rootDir)).stat("file.txt")).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
spawnMock.mockImplementation(makeFailingChild);
|
||||
configureFsSafePython({ mode: "auto", pythonPath: "/tmp/missing-python" });
|
||||
const autoRoot = await root(rootDir);
|
||||
|
||||
@ -38,64 +38,89 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("pinned write fallback coverage", () => {
|
||||
it("writes buffers, creates only when missing, streams, and enforces limits", async () => {
|
||||
const { runPinnedWriteHelper } = await import("../src/pinned-write.js");
|
||||
const root = await tempRoot("fs-safe-pinned-write-fallback-");
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"writes buffers, creates only when missing, streams, and enforces limits",
|
||||
async () => {
|
||||
const { runPinnedWriteHelper } = await import("../src/pinned-write.js");
|
||||
const root = await tempRoot("fs-safe-pinned-write-fallback-");
|
||||
|
||||
const created = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "created", encoding: "utf8" },
|
||||
});
|
||||
expect(created.ino).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "created.txt"), "utf8")).resolves.toBe(
|
||||
"created",
|
||||
);
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
const created = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "again" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EEXIST" });
|
||||
input: { kind: "buffer", data: "created", encoding: "utf8" },
|
||||
});
|
||||
expect(created.ino).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "created.txt"), "utf8")).resolves.toBe(
|
||||
"created",
|
||||
);
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "again" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EEXIST" });
|
||||
|
||||
const streamed = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "streamed.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: true,
|
||||
maxBytes: 16,
|
||||
input: { kind: "stream", stream: Readable.from(["stream", "ed"]) },
|
||||
});
|
||||
expect(streamed.dev).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "streamed.txt"), "utf8")).resolves.toBe(
|
||||
"streamed",
|
||||
);
|
||||
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
const streamed = await runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "too-large.txt",
|
||||
basename: "streamed.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: true,
|
||||
maxBytes: 2,
|
||||
input: { kind: "buffer", data: Buffer.from("large") },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "too-large" });
|
||||
await expect(fs.stat(path.join(root, "nested", "too-large.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
maxBytes: 16,
|
||||
input: { kind: "stream", stream: Readable.from(["stream", "ed"]) },
|
||||
});
|
||||
expect(streamed.dev).toBeGreaterThan(0);
|
||||
await expect(fs.readFile(path.join(root, "nested", "streamed.txt"), "utf8")).resolves.toBe(
|
||||
"streamed",
|
||||
);
|
||||
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "too-large.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: true,
|
||||
maxBytes: 2,
|
||||
input: { kind: "buffer", data: Buffer.from("large") },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "too-large" });
|
||||
await expect(fs.stat(path.join(root, "nested", "too-large.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "win32")(
|
||||
"rejects with unsupported-platform on windows",
|
||||
async () => {
|
||||
// fd-relative pinned filesystem operations are unavailable on windows
|
||||
// (see src/pinned-python.ts), so the helper fails closed before any
|
||||
// posix-only logic runs.
|
||||
const { runPinnedWriteHelper } = await import("../src/pinned-write.js");
|
||||
const root = await tempRoot("fs-safe-pinned-write-fallback-");
|
||||
await expect(
|
||||
runPinnedWriteHelper({
|
||||
rootPath: root,
|
||||
relativeParentPath: "nested",
|
||||
basename: "created.txt",
|
||||
mkdir: true,
|
||||
mode: 0o600,
|
||||
overwrite: false,
|
||||
input: { kind: "buffer", data: "created", encoding: "utf8" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "unsupported-platform" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -168,6 +168,17 @@ describe("write, move, and delete boundary bypass attempts", () => {
|
||||
await fsp.symlink(layout.outsideFile, path.join(layout.root, "dest-link.txt"), "file");
|
||||
const safeRoot = await openRoot(layout.root);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await expect(safeRoot.move("source-link.txt", "moved.txt")).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
await expect(safeRoot.move("from.txt", "dest-link.txt", { overwrite: true })).rejects.toMatchObject({
|
||||
code: "unsupported-platform",
|
||||
});
|
||||
await expectNoOutsideWrite(layout);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(safeRoot.move("source-link.txt", "moved.txt")).rejects.toBeTruthy();
|
||||
await safeRoot.move("from.txt", "dest-link.txt", { overwrite: true });
|
||||
await expectNoOutsideWrite(layout);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user