mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
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<typeof import("node:os")>("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<typeof import("node:fs")>("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);
|
|
});
|
|
});
|
|
});
|