import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // Mock node:os so tilde-expansion is deterministic across platforms. // Every other binding passes through, so `tmpdir()` reaches the real module // for the filesystem-backed `entryExists` fixtures below. vi.mock("node:os", async () => { const actual = await vi.importActual("node:os"); const homedir = vi.fn(() => "/mock/home"); return { ...actual, homedir, default: { ...actual, homedir }, }; }); // Mock node:fs so realpathSync (used by canonicalizePath) is controllable. // Default implementation is identity — lexical assertions are unaffected. // Every other fs binding passes through to the real module, so filesystem- // backed helpers (lstatSync, mkdtempSync, symlinkSync, …) stay usable here. const realpathSync = vi.hoisted(() => vi.fn<(path: string) => string>((p) => p), ); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { ...actual, realpathSync, default: { ...actual, realpathSync }, }; }); import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { createTmpFixture } from "#test/helpers/tmp-fixture"; describe("PathNormalizer", () => { beforeEach(() => { realpathSync.mockReset(); realpathSync.mockImplementation((p: string) => p); }); describe("posix flavor", () => { const normalizer = new PathNormalizer(posixPathFlavor, "/projects/my-app"); test("forPath builds an AccessPath resolved against the baked cwd", () => { const ap = normalizer.forPath("src/foo.ts"); expect(ap.value()).toBe("/projects/my-app/src/foo.ts"); expect(ap.matchValues()).toEqual([ "/projects/my-app/src/foo.ts", "src/foo.ts", ]); }); test("forPath honors an explicit resolveBase", () => { const ap = normalizer.forPath("foo.ts", { resolveBase: "/projects/my-app/sub", }); expect(ap.value()).toBe("/projects/my-app/sub/foo.ts"); }); test("forLiteral builds a literal-only AccessPath", () => { const ap = normalizer.forLiteral("foo.ts"); expect(ap.matchValues()).toEqual(["foo.ts"]); expect(ap.boundaryValue()).toBe(""); }); test("isAbsolute uses posix rules", () => { expect(normalizer.isAbsolute("/etc/hosts")).toBe(true); expect(normalizer.isAbsolute("rel/path")).toBe(false); }); test("resolveBase resolves an offset against the baked cwd", () => { expect(normalizer.resolveBase("sub")).toBe("/projects/my-app/sub"); expect(normalizer.resolveBase("/abs")).toBe("/abs"); }); test("joinBase joins an offset with a relative target", () => { expect(normalizer.joinBase("sub", "nested")).toBe("sub/nested"); }); test("isWithinDirectory decides containment", () => { expect(normalizer.isWithinDirectory("/a/b/c", "/a/b")).toBe(true); expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false); }); test("isOutsideWorkingDirectory tests against the baked cwd", () => { expect(normalizer.isOutsideWorkingDirectory("/projects/my-app/src")).toBe( false, ); expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true); }); test("isOutsideWorkingDirectory expands a home-relative token", () => { expect(normalizer.isOutsideWorkingDirectory("~/secrets")).toBe(true); }); test("isOutsideWorkingDirectory resolves a relative token inside cwd", () => { expect(normalizer.isOutsideWorkingDirectory("src/index.ts")).toBe(false); }); test("isOutsideWorkingDirectory follows an in-cwd symlink to an external target", () => { // ./link -> /etc: realpathSync resolves the full token in one call. realpathSync.mockImplementation((p: string) => { if (p === "/projects/my-app/link/hosts") return "/etc/hosts"; return p; }); expect(normalizer.isOutsideWorkingDirectory("./link/hosts")).toBe(true); }); test("isOutsideWorkingDirectory keeps a path inside a symlinked cwd", () => { // /tmp -> /private/tmp on macOS; cwd reported as the resolved /private/tmp. realpathSync.mockImplementation((p: string) => { if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`; if (p === "/tmp") return "/private/tmp"; return p; }); const symlinkNormalizer = new PathNormalizer( posixPathFlavor, "/private/tmp", ); expect( symlinkNormalizer.isOutsideWorkingDirectory("/tmp/workspace/file.ts"), ).toBe(false); }); test("comparableValue returns the lexical absolute form (no FS)", () => { expect(normalizer.comparableValue("src/foo.ts")).toBe( "/projects/my-app/src/foo.ts", ); expect(normalizer.comparableValue("/etc/hosts")).toBe("/etc/hosts"); }); test("forBashToken delegates to forPath on posix", () => { const ap = normalizer.forBashToken("src/foo.ts"); expect(ap.value()).toBe("/projects/my-app/src/foo.ts"); }); test("isBoundaryOutsideWorkingDirectory tests a canonical path against cwd", () => { expect( normalizer.isBoundaryOutsideWorkingDirectory("/projects/my-app/src"), ).toBe(false); expect(normalizer.isBoundaryOutsideWorkingDirectory("/etc/hosts")).toBe( true, ); }); test("isBoundaryOutsideWorkingDirectory excludes a safe device path", () => { expect(normalizer.isBoundaryOutsideWorkingDirectory("/dev/null")).toBe( false, ); }); test("interpretBashCdTarget maps an absolute target to absolute (posix)", () => { expect(normalizer.interpretBashCdTarget("/etc")).toEqual({ kind: "absolute", value: "/etc", }); }); test("interpretBashCdTarget maps a relative target to relative (posix)", () => { expect(normalizer.interpretBashCdTarget("sub")).toEqual({ kind: "relative", }); }); }); describe("win32 flavor", () => { const normalizer = new PathNormalizer(win32PathFlavor, "C:\\Projects\\App"); test("forPath builds a case-folded AccessPath with win32 rules", () => { const ap = normalizer.forPath("src\\foo.ts"); expect(ap.value()).toBe("c:\\projects\\app\\src\\foo.ts"); expect(ap.matchValues()).toEqual([ "c:\\projects\\app\\src\\foo.ts", "src\\foo.ts", ]); }); test("isAbsolute uses win32 rules", () => { expect(normalizer.isAbsolute("C:\\Users\\foo")).toBe(true); expect(normalizer.isAbsolute("rel\\path")).toBe(false); }); test("resolveBase resolves an offset against the baked cwd", () => { expect(normalizer.resolveBase("sub")).toBe("C:\\Projects\\App\\sub"); }); test("joinBase joins an offset with a relative target", () => { expect(normalizer.joinBase("sub", "nested")).toBe("sub\\nested"); }); test("isWithinDirectory folds case", () => { expect( normalizer.isWithinDirectory( "c:\\users\\foo\\dir\\sub", "C:\\Users\\Foo\\dir", ), ).toBe(true); }); test("isOutsideWorkingDirectory case-folds against the baked cwd", () => { expect( normalizer.isOutsideWorkingDirectory("c:\\projects\\app\\src"), ).toBe(false); expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true); }); test("comparableValue case-folds the lexical absolute form", () => { expect(normalizer.comparableValue("src\\foo.ts")).toBe( "c:\\projects\\app\\src\\foo.ts", ); }); test("forBashToken preserves a POSIX device path as a safe device", () => { const ap = normalizer.forBashToken("/dev/null"); expect(ap.value()).toBe("/dev/null"); expect(ap.boundaryValue()).toBe("/dev/null"); expect( normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()), ).toBe(false); }); test("forBashToken preserves all four safe device paths", () => { for (const device of [ "/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr", ]) { expect(normalizer.forBashToken(device).boundaryValue()).toBe(device); } }); test("forBashToken translates an MSYS drive mount to a Windows path", () => { const ap = normalizer.forBashToken("/c/Other/x"); expect(ap.value()).toBe("c:\\other\\x"); expect( normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()), ).toBe(true); }); test("forBashToken translates an in-cwd drive mount (not external)", () => { const ap = normalizer.forBashToken("/c/projects/app/inside.txt"); expect(ap.value()).toBe("c:\\projects\\app\\inside.txt"); expect( normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()), ).toBe(false); }); test("forBashToken keeps a non-mount POSIX absolute as a literal", () => { const ap = normalizer.forBashToken("/tmp/foo"); // Matched and displayed as typed: the matcher folds separators on both // the rule and the value, so a /tmp/* rule resolves without an alias. expect(ap.value()).toBe("/tmp/foo"); expect(ap.boundaryValue()).toBe(""); expect(ap.matchValues()).toEqual(["/tmp/foo"]); }); test("interpretBashCdTarget translates a drive-mount target to absolute", () => { expect(normalizer.interpretBashCdTarget("/c/Other")).toEqual({ kind: "absolute", value: "C:\\Other", }); }); test("interpretBashCdTarget maps a non-mount POSIX absolute to unknown", () => { expect(normalizer.interpretBashCdTarget("/tmp")).toEqual({ kind: "unknown", }); }); test("interpretBashCdTarget maps a native drive path to absolute", () => { expect(normalizer.interpretBashCdTarget("C:\\Other")).toEqual({ kind: "absolute", value: "C:\\Other", }); }); test("interpretBashCdTarget maps a relative target to relative (win32)", () => { expect(normalizer.interpretBashCdTarget("sub")).toEqual({ kind: "relative", }); }); test("isBoundaryOutsideWorkingDirectory case-folds against the baked cwd", () => { expect( normalizer.isBoundaryOutsideWorkingDirectory("c:\\projects\\app\\src"), ).toBe(false); expect( normalizer.isBoundaryOutsideWorkingDirectory("c:\\other\\dir"), ).toBe(true); }); }); describe("isInfrastructureRead", () => { const normalizer = new PathNormalizer(posixPathFlavor, "/projects/my-app"); test("allows a read-only tool targeting a configured infra dir", () => { const ap = normalizer.forPath("/infra/git/pkg/SKILL.md"); expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe( true, ); }); test("does not allow a write tool targeting an infra dir", () => { const ap = normalizer.forPath("/infra/git/pkg/file.ts"); expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe( false, ); }); test("does not allow a read-only tool outside any infra dir", () => { const ap = normalizer.forPath("/elsewhere/file.ts"); expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe( false, ); }); test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => { const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js"); expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true); }); }); describe("entryExists", () => { // Real filesystem: the probe's whole purpose is to consult fs state, so // these use actual temp files rather than the mocked realpathSync above. const tmp = createTmpFixture(); let root: string; let normalizer: PathNormalizer; beforeEach(() => { root = tmp.dir("pi-perm-exists-"); normalizer = new PathNormalizer(posixPathFlavor, root); }); afterEach(() => { tmp.cleanup(); }); test("existing regular file → true", () => { const file = tmp.file(root, "secret.txt", "contents"); expect(normalizer.entryExists(file)).toBe(true); }); test("existing directory → true", () => { const dir = tmp.subdir(root, "nested"); expect(normalizer.entryExists(dir)).toBe(true); }); test("symlink to an existing target → true", () => { const target = tmp.file(root, "target.txt"); const link = tmp.symlink(root, "link", target); expect(normalizer.entryExists(link)).toBe(true); }); test("dangling symlink → true (lstat: the link itself is an entry)", () => { const link = tmp.symlink(root, "dangling", join(root, "gone.txt")); expect(normalizer.entryExists(link)).toBe(true); }); test("nonexistent path → false", () => { expect(normalizer.entryExists(join(root, "missing.txt"))).toBe(false); }); test("path under a nonexistent parent → false (ENOTDIR/ENOENT)", () => { expect(normalizer.entryExists(join(root, "no-dir", "file.txt"))).toBe( false, ); }); test("empty path → false", () => { expect(normalizer.entryExists("")).toBe(false); }); test("answers from the filesystem, not the flavor — win32 normalizer agrees", () => { const file = tmp.file(root, "flavor-independent.txt"); const win32Normalizer = new PathNormalizer(win32PathFlavor, root); expect(win32Normalizer.entryExists(file)).toBe(true); expect(win32Normalizer.entryExists(join(root, "nope.txt"))).toBe(false); }); }); });