diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d83b452 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + name: Node 22 check + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Enable pnpm + run: | + corepack enable + corepack prepare pnpm@10.33.2 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check + run: pnpm check diff --git a/test/openclaw-read-bypass-parity.test.ts b/test/openclaw-read-bypass-parity.test.ts new file mode 100644 index 0000000..ffe649e --- /dev/null +++ b/test/openclaw-read-bypass-parity.test.ts @@ -0,0 +1,316 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + FsSafeError, + openPinnedFileSync, + openRootFile, + openRootFileSync, + pathScope, + resolveAbsolutePathForRead, + root as openRoot, +} from "../src/index.js"; + +type TempLayout = { + outside: string; + outsideFile: string; + root: string; +}; + +const tempDirs: string[] = []; + +const TRAVERSAL_PAYLOADS = [ + "../secret.txt", + "../../secret.txt", + "nested/../../secret.txt", + "nested/../../../secret.txt", + "./../secret.txt", + "nested/..//../secret.txt", + "nested/%2e%2e/secret.txt", + "%2e%2e/secret.txt", + "%2e%2e%2fsecret.txt", + "..%2fsecret.txt", + "%252e%252e%252fsecret.txt", + "..%00/secret.txt", + "..\\secret.txt", + "nested\\..\\..\\secret.txt", + "C:\\Windows\\win.ini", + "\\\\server\\share\\secret.txt", +] as const; + +const LIST_TRAVERSAL_PAYLOADS = [ + "..", + "../", + "../../", + "nested/../..", + "nested/../../outside", + "%2e%2e", + "%2e%2e%2f", + "..\\", + "C:\\Windows", + "\\\\server\\share", +] as const; + +async function makeTempLayout(prefix: string): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-root-`)); + const outside = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-outside-`)); + tempDirs.push(root, outside); + const outsideFile = path.join(outside, "secret.txt"); + await fsp.writeFile(outsideFile, "outside secret"); + return { outside, outsideFile, root }; +} + +async function closeIfOpen(value: unknown): Promise { + if (typeof value === "object" && value !== null && "handle" in value) { + const handle = (value as { handle?: { close(): Promise } }).handle; + if (handle) { + await handle.close(); + } + } +} + +function expectFsSafeCode(error: unknown, codes: readonly string[]): void { + expect(error).toBeInstanceOf(FsSafeError); + expect(codes).toContain((error as FsSafeError).code); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { force: true, recursive: true }))); +}); + +describe("OpenClaw read bypass parity", () => { + it("rejects a payload corpus of traversal, encoded, NUL, Windows, and UNC read attempts", async () => { + const layout = await makeTempLayout("fs-safe-read-payloads"); + await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true }); + await fsp.writeFile(path.join(layout.root, "nested", "safe.txt"), "safe"); + const safeRoot = await openRoot(layout.root); + + for (const payload of TRAVERSAL_PAYLOADS) { + await expect(safeRoot.read(payload), `read(${payload})`).rejects.toBeTruthy(); + await expect(safeRoot.open(payload), `open(${payload})`).rejects.toBeTruthy(); + await expect(safeRoot.stat(payload), `stat(${payload})`).rejects.toBeTruthy(); + } + }); + + it("rejects a payload corpus of traversal, encoded, Windows, and UNC directory listing attempts", async () => { + const layout = await makeTempLayout("fs-safe-list-payloads"); + await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true }); + await fsp.writeFile(path.join(layout.root, "nested", "safe.txt"), "safe"); + const safeRoot = await openRoot(layout.root); + + for (const payload of LIST_TRAVERSAL_PAYLOADS) { + await expect(safeRoot.list(payload), `list(${payload})`).rejects.toBeTruthy(); + } + }); + + it("rejects traversal across root read, open, stat, list, and path scope APIs", async () => { + const layout = await makeTempLayout("fs-safe-read-traversal"); + const safeRoot = await openRoot(layout.root); + const scope = pathScope(layout.root, { label: "test root" }); + + await expect(safeRoot.read("../secret.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]); + return true; + }); + await expect(safeRoot.open("../secret.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]); + return true; + }); + await expect(safeRoot.stat("../secret.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]); + return true; + }); + await expect(safeRoot.list(".." as string)).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "invalid-path", "path-alias"]); + return true; + }); + await expect(scope.files(["../secret.txt"])).resolves.toMatchObject({ ok: false }); + }); + + it("rejects symlink parents across root read/open/stat/list APIs", async () => { + const layout = await makeTempLayout("fs-safe-read-symlink-parent"); + await fsp.symlink(layout.outside, path.join(layout.root, "link"), "dir"); + const safeRoot = await openRoot(layout.root); + + await expect(safeRoot.read("link/secret.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]); + return true; + }); + await expect(safeRoot.open("link/secret.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]); + return true; + }); + await expect(safeRoot.stat("link/secret.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]); + return true; + }); + await expect(safeRoot.list("link")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]); + return true; + }); + }); + + it("rejects final symlink leaves for root read/open/stat and direct root-file APIs", async () => { + const layout = await makeTempLayout("fs-safe-read-symlink-leaf"); + const linkPath = path.join(layout.root, "secret-link.txt"); + await fsp.symlink(layout.outsideFile, linkPath, "file"); + const safeRoot = await openRoot(layout.root); + + await expect(safeRoot.read("secret-link.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]); + return true; + }); + await expect(safeRoot.open("secret-link.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]); + return true; + }); + await expect(safeRoot.stat("secret-link.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "symlink"]); + return true; + }); + + const rootRealPath = await fsp.realpath(layout.root); + const syncOpened = openRootFileSync({ + absolutePath: linkPath, + boundaryLabel: "root", + rootPath: layout.root, + rootRealPath, + }); + expect(syncOpened.ok).toBe(false); + if (syncOpened.ok) { + fs.closeSync(syncOpened.fd); + } + + const asyncOpened = await openRootFile({ + absolutePath: linkPath, + boundaryLabel: "root", + rootPath: layout.root, + rootRealPath, + }); + expect(asyncOpened.ok).toBe(false); + if (asyncOpened.ok) { + await asyncOpened.handle.close(); + } + + const pinnedOpened = openPinnedFileSync({ filePath: linkPath, rejectPathSymlink: true }); + expect(pinnedOpened.ok).toBe(false); + if (pinnedOpened.ok) { + fs.closeSync(pinnedOpened.fd); + } + }); + + it("rejects absolute outside files across root read, open, stat, and direct root-file APIs", async () => { + const layout = await makeTempLayout("fs-safe-absolute-outside"); + const safeRoot = await openRoot(layout.root); + const rootRealPath = await fsp.realpath(layout.root); + + await expect(safeRoot.read(layout.outsideFile)).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]); + return true; + }); + await expect(safeRoot.open(layout.outsideFile)).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]); + return true; + }); + await expect(safeRoot.stat(layout.outsideFile)).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]); + return true; + }); + + const syncOpened = openRootFileSync({ + absolutePath: layout.outsideFile, + boundaryLabel: "root", + rootPath: layout.root, + rootRealPath, + }); + expect(syncOpened.ok).toBe(false); + if (syncOpened.ok) { + fs.closeSync(syncOpened.fd); + } + + const asyncOpened = await openRootFile({ + absolutePath: layout.outsideFile, + boundaryLabel: "root", + rootPath: layout.root, + rootRealPath, + }); + expect(asyncOpened.ok).toBe(false); + if (asyncOpened.ok) { + await asyncOpened.handle.close(); + } + }); + + it("rejects hardlinked read targets when hardlink rejection is enabled", async () => { + const layout = await makeTempLayout("fs-safe-read-hardlink"); + const source = path.join(layout.root, "source.txt"); + const hardlink = path.join(layout.root, "hardlink.txt"); + await fsp.writeFile(source, "shared"); + await fsp.link(source, hardlink); + const safeRoot = await openRoot(layout.root, { hardlinks: "reject" }); + + await expect(safeRoot.read("hardlink.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["hardlink", "invalid-path"]); + return true; + }); + await expect(safeRoot.open("hardlink.txt")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["hardlink", "invalid-path"]); + return true; + }); + }); + + it("rejects absolute read paths that traverse symlinks by default", async () => { + const layout = await makeTempLayout("fs-safe-absolute-read"); + const linkPath = path.join(layout.root, "absolute-link.txt"); + await fsp.symlink(layout.outsideFile, linkPath, "file"); + + await expect(resolveAbsolutePathForRead(linkPath)).rejects.toMatchObject({ code: "symlink" }); + const outsideFileReal = await fsp.realpath(layout.outsideFile); + await expect(resolveAbsolutePathForRead(linkPath, { symlinks: "follow" })).resolves.toMatchObject({ + canonicalPath: outsideFileReal, + }); + }); + + it("keeps encoded traversal payloads literal instead of URL-decoding into an escape", async () => { + const layout = await makeTempLayout("fs-safe-encoded-literal"); + await fsp.writeFile(path.join(layout.root, "%2e%2e%2fsecret.txt"), "literal"); + const safeRoot = await openRoot(layout.root); + + await expect(safeRoot.readText("%2e%2e%2fsecret.txt")).resolves.toBe("literal"); + await expect(safeRoot.read("%2e%2e/secret.txt")).rejects.toBeTruthy(); + }); + + it("rejects pathScope payload batches when any member escapes", async () => { + const layout = await makeTempLayout("fs-safe-pathscope-payloads"); + const scope = pathScope(layout.root, { label: "test root" }); + + for (const payload of TRAVERSAL_PAYLOADS) { + await expect(scope.files(["safe.txt", payload]), `pathScope.files(${payload})`).resolves.toMatchObject({ + ok: false, + }); + } + }); + + it("does not return outside bytes when root read APIs reject unsafe paths", async () => { + const layout = await makeTempLayout("fs-safe-read-no-leak"); + await fsp.symlink(layout.outside, path.join(layout.root, "link"), "dir"); + const safeRoot = await openRoot(layout.root); + + for (const attempt of [ + () => safeRoot.read("../outside/secret.txt"), + () => safeRoot.read("link/secret.txt"), + () => safeRoot.open("link/secret.txt"), + ]) { + let opened: unknown; + try { + opened = await attempt(); + await closeIfOpen(opened); + throw new Error("unsafe read unexpectedly succeeded"); + } catch (error) { + await closeIfOpen(opened); + expect(error).not.toMatchObject({ message: "unsafe read unexpectedly succeeded" }); + } + } + }); +}); diff --git a/test/openclaw-write-bypass-parity.test.ts b/test/openclaw-write-bypass-parity.test.ts new file mode 100644 index 0000000..0c625b8 --- /dev/null +++ b/test/openclaw-write-bypass-parity.test.ts @@ -0,0 +1,261 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { FsSafeError, root as openRoot } from "../src/index.js"; + +type TempLayout = { + outside: string; + outsideFile: string; + root: string; +}; + +const tempDirs: string[] = []; + +const ESCAPING_WRITE_PAYLOADS = [ + "../pwned.txt", + "../../pwned.txt", + "nested/../../pwned.txt", + "nested/../../../pwned.txt", + "./../pwned.txt", + "nested/..//../pwned.txt", +] as const; + +const LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [ + "nested/%2e%2e/pwned.txt", + "%2e%2e/pwned.txt", + "%2e%2e%2fpwned.txt", + "%252e%252e%252fpwned.txt", +] as const; + +const POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [ + "nested\\..\\..\\pwned.txt", + "C:\\Windows\\win.ini", + "\\\\server\\share\\pwned.txt", +] as const; + +const SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS = [ + "..%2fpwned.txt", + "..%00/pwned.txt", + "..\\pwned.txt", +] as const; + +const ESCAPING_DIRECTORY_PAYLOADS = [ + "..", + "../", + "../../", + "nested/../..", + "nested/../../outside", +] as const; + +const LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS = ["%2e%2e", "%2e%2e%2f"] as const; + +const SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = ["..\\"] as const; + +const WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = [ + "C:\\Windows", + "\\\\server\\share", +] as const; + +async function makeTempLayout(prefix: string): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-root-`)); + const outside = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-outside-`)); + tempDirs.push(root, outside); + const outsideFile = path.join(outside, "secret.txt"); + await fsp.writeFile(outsideFile, "outside secret"); + return { outside, outsideFile, root }; +} + +function expectFsSafeCode(error: unknown, codes: readonly string[]): void { + expect(error).toBeInstanceOf(FsSafeError); + expect(codes).toContain((error as FsSafeError).code); +} + +async function expectNoOutsideWrite(layout: TempLayout, expected = "outside secret"): Promise { + await expect(fsp.readFile(layout.outsideFile, "utf8")).resolves.toBe(expected); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { force: true, recursive: true }))); +}); + +describe("OpenClaw write/move/delete bypass parity", () => { + it("rejects payload corpus write/create/append/openWritable attempts without touching outside files", async () => { + const layout = await makeTempLayout("fs-safe-write-payloads"); + await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true }); + const safeRoot = await openRoot(layout.root); + + for (const payload of ESCAPING_WRITE_PAYLOADS) { + await expect(safeRoot.write(payload, "pwned"), `write(${payload})`).rejects.toBeTruthy(); + await expect(safeRoot.create(payload, "pwned"), `create(${payload})`).rejects.toBeTruthy(); + await expect(safeRoot.append(payload, "pwned"), `append(${payload})`).rejects.toBeTruthy(); + await expect(safeRoot.openWritable(payload), `openWritable(${payload})`).rejects.toBeTruthy(); + } + await expectNoOutsideWrite(layout); + }); + + it("rejects payload corpus mkdir and remove attempts", async () => { + const layout = await makeTempLayout("fs-safe-dir-payloads"); + await fsp.mkdir(path.join(layout.root, "nested"), { recursive: true }); + const safeRoot = await openRoot(layout.root); + + for (const payload of ESCAPING_DIRECTORY_PAYLOADS) { + await expect(safeRoot.mkdir(payload), `mkdir(${payload})`).rejects.toBeTruthy(); + await expect(safeRoot.remove(payload), `remove(${payload})`).rejects.toBeTruthy(); + } + await expectNoOutsideWrite(layout); + }); + + it("rejects absolute outside write/create/append/openWritable/copy destinations", async () => { + const layout = await makeTempLayout("fs-safe-write-absolute"); + const safeRoot = await openRoot(layout.root); + const source = path.join(layout.root, "source.txt"); + await fsp.writeFile(source, "source"); + + await expect(safeRoot.write(layout.outsideFile, "pwned")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["outside-workspace", "path-alias", "invalid-path"]); + return true; + }); + await expect(safeRoot.create(layout.outsideFile, "pwned")).rejects.toBeTruthy(); + await expect(safeRoot.append(layout.outsideFile, "pwned")).rejects.toBeTruthy(); + await expect(safeRoot.openWritable(layout.outsideFile)).rejects.toBeTruthy(); + await expect(safeRoot.copyIn(layout.outsideFile, source)).rejects.toBeTruthy(); + await expectNoOutsideWrite(layout); + }); + + it("rejects symlink parent write/create/append/openWritable/copy/mkdir/remove destinations", async () => { + const layout = await makeTempLayout("fs-safe-write-symlink-parent"); + await fsp.symlink(layout.outside, path.join(layout.root, "link"), "dir"); + const safeRoot = await openRoot(layout.root); + const source = path.join(layout.root, "source.txt"); + await fsp.writeFile(source, "source"); + + for (const action of [ + () => safeRoot.write("link/secret.txt", "pwned"), + () => safeRoot.create("link/secret.txt", "pwned"), + () => safeRoot.append("link/secret.txt", "pwned"), + () => safeRoot.openWritable("link/secret.txt"), + () => safeRoot.copyIn("link/secret.txt", source), + () => safeRoot.mkdir("link/nested"), + () => safeRoot.remove("link/secret.txt"), + ]) { + await expect(action()).rejects.toBeTruthy(); + } + await expectNoOutsideWrite(layout); + }); + + it("rejects final symlink leaf write/append/openWritable/copy targets without clobbering their target", async () => { + const layout = await makeTempLayout("fs-safe-write-symlink-leaf"); + await fsp.symlink(layout.outsideFile, path.join(layout.root, "link.txt"), "file"); + const source = path.join(layout.root, "source.txt"); + await fsp.writeFile(source, "source"); + const safeRoot = await openRoot(layout.root); + + for (const action of [ + () => safeRoot.write("link.txt", "pwned"), + () => safeRoot.append("link.txt", "pwned"), + () => safeRoot.openWritable("link.txt"), + () => safeRoot.copyIn("link.txt", source), + ]) { + await expect(action()).rejects.toBeTruthy(); + } + await expectNoOutsideWrite(layout); + }); + + it("rejects hardlinked write and append targets", async () => { + const layout = await makeTempLayout("fs-safe-write-hardlink"); + const source = path.join(layout.root, "source.txt"); + const hardlink = path.join(layout.root, "hardlink.txt"); + await fsp.writeFile(source, "shared"); + await fsp.link(source, hardlink); + const safeRoot = await openRoot(layout.root); + + await expect(safeRoot.write("hardlink.txt", "pwned")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["hardlink", "invalid-path", "path-alias"]); + return true; + }); + await expect(safeRoot.append("hardlink.txt", "pwned")).rejects.toSatisfy((error: unknown) => { + expectFsSafeCode(error, ["hardlink", "invalid-path", "path-alias"]); + return true; + }); + }); + + it("rejects move payloads for escaping sources and destinations", async () => { + const layout = await makeTempLayout("fs-safe-move-payloads"); + await fsp.writeFile(path.join(layout.root, "from.txt"), "from"); + const safeRoot = await openRoot(layout.root); + + for (const payload of ESCAPING_WRITE_PAYLOADS) { + await expect(safeRoot.move(payload, "to.txt"), `move-from(${payload})`).rejects.toBeTruthy(); + await expect(safeRoot.move("from.txt", payload), `move-to(${payload})`).rejects.toBeTruthy(); + } + await expectNoOutsideWrite(layout); + await expect(fsp.readFile(path.join(layout.root, "from.txt"), "utf8")).resolves.toBe("from"); + }); + + it("rejects symlink move source and destination endpoints without touching outside targets", async () => { + const layout = await makeTempLayout("fs-safe-move-symlink"); + await fsp.writeFile(path.join(layout.root, "from.txt"), "from"); + await fsp.symlink(layout.outsideFile, path.join(layout.root, "source-link.txt"), "file"); + await fsp.symlink(layout.outsideFile, path.join(layout.root, "dest-link.txt"), "file"); + const safeRoot = await openRoot(layout.root); + + await expect(safeRoot.move("source-link.txt", "moved.txt")).rejects.toBeTruthy(); + await safeRoot.move("from.txt", "dest-link.txt", { overwrite: true }); + await expectNoOutsideWrite(layout); + await expect(fsp.readFile(path.join(layout.root, "dest-link.txt"), "utf8")).resolves.toBe("from"); + }); + + it("rejects remove through symlink parents but removes final symlink entries without following", async () => { + const layout = await makeTempLayout("fs-safe-remove-symlink"); + await fsp.symlink(layout.outside, path.join(layout.root, "parent-link"), "dir"); + await fsp.symlink(layout.outsideFile, path.join(layout.root, "leaf-link.txt"), "file"); + const safeRoot = await openRoot(layout.root); + + await expect(safeRoot.remove("parent-link/secret.txt")).rejects.toBeTruthy(); + await safeRoot.remove("leaf-link.txt"); + await expect(fsp.lstat(path.join(layout.root, "leaf-link.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + await expectNoOutsideWrite(layout); + }); + + it("keeps encoded, backslash, Windows, and UNC-looking write payloads literal and inside root", async () => { + const layout = await makeTempLayout("fs-safe-write-encoded-literal"); + const safeRoot = await openRoot(layout.root); + + const literalWritePayloads = process.platform === "win32" + ? LITERAL_SUSPICIOUS_WRITE_PAYLOADS + : [...LITERAL_SUSPICIOUS_WRITE_PAYLOADS, ...POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS]; + for (const payload of literalWritePayloads) { + await safeRoot.write(payload, "literal"); + await expect(safeRoot.readText(payload), `read literal ${payload}`).resolves.toBe("literal"); + } + if (process.platform === "win32") { + for (const payload of POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS) { + await expect(safeRoot.write(payload, "rejected"), `write safely rejects ${payload}`).rejects.toBeTruthy(); + } + } + for (const payload of SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS) { + await expect(safeRoot.write(payload, "rejected"), `write safely rejects ${payload}`).rejects.toBeTruthy(); + } + for (const payload of SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS) { + await expect(safeRoot.mkdir(payload), `mkdir safely rejects ${payload}`).rejects.toBeTruthy(); + } + if (process.platform === "win32") { + for (const payload of WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS) { + await expect(safeRoot.mkdir(payload), `mkdir safely rejects ${payload}`).rejects.toBeTruthy(); + } + } else { + for (const payload of WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS) { + await safeRoot.mkdir(payload); + await expect(fsp.stat(path.join(layout.root, payload)), `created literal ${payload}`).resolves.toSatisfy((stat) => + stat.isDirectory() + ); + } + } + for (const payload of LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS) { + await safeRoot.mkdir(payload); + await expect(safeRoot.list(payload), `list literal ${payload}`).resolves.toBeInstanceOf(Array); + } + await expectNoOutsideWrite(layout); + }); +});