pi-mono/packages/coding-agent/test/paths.test.ts
Petr Houška 1be312b46a
fix(coding-agent): compare paths case-insensitively on Windows (#11588)
Fixes #10488

## Problem

On Windows, `c:\Users\me` and `C:\Users\me` are the same directory, but
pi compared paths as exact strings (`===`, `startsWith`, map and set
keys). `realpathSync` keeps the input casing, so canonicalizing does not
help.

The reported case, with cwd `c:\Users\me\project` and home
`C:\Users\me`:

1. Ancestor discovery finds `c:\Users\me\.agents\skills`. The exclusion
of the user dir `C:\Users\me\.agents\skills` does not match, so global
skills load as project skills.
2. The same dir loads again as user skills. Dedup does not match either,
so every skill is reported as a name collision.
3. `hasTrustRequiringProjectResources` treats `~/.agents/skills` as a
project resource that requires trust.

The same pattern affected other places:

- Saved trust decisions in `trust.json` were not found when cwd casing
differed, so pi asked for trust again.
- With a custom session dir, `--continue`, resume by id, and the session
list hid sessions recorded with different cwd casing.
- Git worktree `AGENTS.md` shadowing failed when cwd casing differed
from git's paths.
- Local package matching (`pi remove c:\pkg` vs configured `C:\pkg`) and
package identity across global and project settings.
- Extension preload matching, skill scope classification, resource
source info, the config and session selector keys, and the session
import self-check.

Separately, home-to-`~` shortening used an unanchored `startsWith(home)`
in four places, so `/home/me2/x` was shown as `~2/x`.

## Fix

New helpers in `utils/paths.ts`, all based on one comparison key:

- `getPathComparisonKey(path)`: the resolved path, lowercased on
Windows. It does not touch the filesystem.
- `pathsEqual(a, b)`
- `isPathWithin(parent, child)`: true for equal paths or paths inside
`parent`, matching whole path segments.
- `shortenHomePath(path, home?)`: replaces a leading home dir with `~`.

The helpers pick `path.win32` or `path.posix` from `process.platform` at
call time, so tests can exercise real Windows paths (drive letters,
UNC).

`getResourcePathKey(path)` in `source-info.ts` is the dedup key for
resources. Synthetic identifiers (`builtin:<name>`, `<inline:...>`) stay
unchanged, because they are not filesystem paths and their lookup is
case-sensitive. File paths go through realpath plus the comparison key.

The helpers are used only for comparison; stored and displayed paths are
unchanged. Trust store lookups and `set` match keys case-insensitively
on Windows.

Unchanged on purpose:

- The package install-root guards (`resolveManagedPath`,
`pruneEmptyGitParents`, the `node_modules` dependency check) keep exact
string checks. Both sides come from the same root string, and exact is
the stricter option.
- Checks based on `path.relative()` (`getCwdRelativePath`, the changelog
containment check) were already correct: Node's `path.win32.relative`
ignores case.

NTFS can enable per-directory case sensitivity, but it is rarely used
outside WSL interop, so it is ignored (see the comment on
`getPathComparisonKey`).

## Tests

- `test/paths.test.ts`: Windows drive-letter, UNC and root cases, POSIX
case sensitivity, segment-boundary containment, and home shortening,
including root and UNC homes.
- `test/source-info.test.ts`: file paths fold case on Windows; builtin
and inline identifiers keep their case.
- `test/trust-manager.test.ts`: the `.agents/skills` exclusion with a
lowercase `HOME`, plus saved trust decisions matched and replaced across
casing (simulated win32).

An end-to-end test of the reported loader scenario is not feasible on
Linux: the loader resolves paths with POSIX `node:path`, and the
filesystem is case-sensitive.

`npm run check` passes. The coding-agent suite passes, except for 13
failures that also occur without this change: clipboard tool detection
in this environment, and one test that needs built `dist/` output.
2026-10-29 21:53:06 +01:00

246 lines
8.5 KiB
TypeScript

import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
canonicalizePath,
getCwdRelativePath,
getPathComparisonKey,
isLocalPath,
isPathWithin,
normalizePath,
normalizeWindowsShellPath,
pathsEqual,
resolvePath,
shortenHomePath,
} from "../src/utils/paths.ts";
let tempDir: string;
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = "";
}
});
function createTempDir(): string {
tempDir = mkdtempSync(join(tmpdir(), "pi-paths-"));
return tempDir;
}
describe("path comparison", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
afterEach(() => {
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
});
function simulatePlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", { value: platform });
}
// Regression test for https://github.com/earendil-works/pi/issues/10488
it("treats drive letters and path components case-insensitively on Windows", () => {
simulatePlatform("win32");
expect(getPathComparisonKey("c:\\Users\\me\\.agents\\skills")).toBe(
getPathComparisonKey("C:\\Users\\Me\\.agents\\skills"),
);
expect(pathsEqual("c:\\Users\\me", "C:/Users/Me/")).toBe(true);
expect(pathsEqual("C:\\Users\\me", "D:\\Users\\me")).toBe(false);
expect(isPathWithin("C:\\Users\\Me", "c:\\users\\me\\project")).toBe(true);
expect(isPathWithin("C:\\Users\\Me", "C:\\Users\\Me2")).toBe(false);
expect(isPathWithin("C:\\", "c:\\Users")).toBe(true);
expect(isPathWithin("\\\\server\\share", "\\\\SERVER\\Share\\folder")).toBe(true);
});
it("keeps casing on case-sensitive platforms", () => {
simulatePlatform("linux");
expect(pathsEqual("/home/me", "/home/Me")).toBe(false);
expect(isPathWithin("/home/me", "/home/Me/project")).toBe(false);
});
it("isPathWithin matches whole path segments", () => {
simulatePlatform("linux");
expect(isPathWithin("/home/me", "/home/me")).toBe(true);
expect(isPathWithin("/home/me", "/home/me/project")).toBe(true);
expect(isPathWithin("/home/me/", "/home/me/project")).toBe(true);
expect(isPathWithin("/home/me", "/home/me2")).toBe(false);
expect(isPathWithin("/", "/home")).toBe(true);
});
it("shortenHomePath replaces only a leading home directory", () => {
simulatePlatform("linux");
expect(shortenHomePath("/home/me", "/home/me")).toBe("~");
expect(shortenHomePath("/home/me/project", "/home/me")).toBe("~/project");
expect(shortenHomePath("/home/me2/project", "/home/me")).toBe("/home/me2/project");
expect(shortenHomePath("/home/Me/project", "/home/me")).toBe("/home/Me/project");
expect(shortenHomePath("/etc/passwd", "/")).toBe("~/etc/passwd");
expect(shortenHomePath("home/me/project", "/home/me")).toBe("home/me/project");
expect(shortenHomePath("/home/me/project", "")).toBe("/home/me/project");
});
it("shortenHomePath ignores casing and keeps separators on Windows", () => {
simulatePlatform("win32");
expect(shortenHomePath("c:\\users\\me\\project", "C:\\Users\\Me")).toBe("~\\project");
expect(shortenHomePath("\\\\server\\share\\folder", "\\\\server\\share")).toBe("~\\folder");
});
});
describe("canonicalizePath", () => {
it("returns the real path for a regular file", () => {
const dir = createTempDir();
const file = join(dir, "file.txt");
writeFileSync(file, "hello");
expect(canonicalizePath(file)).toBe(realpathSync(file));
});
it("resolves symlinks to their targets", () => {
const dir = createTempDir();
const target = join(dir, "target.txt");
const link = join(dir, "link.txt");
writeFileSync(target, "hello");
symlinkSync(target, link);
expect(canonicalizePath(link)).toBe(realpathSync(target));
});
it("resolves directory symlinks", () => {
const dir = createTempDir();
const targetDir = join(dir, "target-dir");
const linkDir = join(dir, "link-dir");
mkdirSync(targetDir);
symlinkSync(targetDir, linkDir, "dir");
expect(canonicalizePath(linkDir)).toBe(realpathSync(targetDir));
});
it("falls back to the raw path when the target does not exist", () => {
const dir = createTempDir();
const nonexistent = join(dir, "no-such-file");
expect(canonicalizePath(nonexistent)).toBe(nonexistent);
});
it("falls back to the raw path for a dangling symlink", () => {
const dir = createTempDir();
const target = join(dir, "target.txt");
const link = join(dir, "link.txt");
// Create a symlink whose target does not exist.
symlinkSync(target, link);
// realpathSync would throw, so canonicalizePath returns the link path.
expect(canonicalizePath(link)).toBe(link);
});
});
describe("getCwdRelativePath", () => {
it("keeps cwd-relative names that start with dots", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(getCwdRelativePath(join(cwd, "..config", "AGENTS.md"), cwd)).toBe(join("..config", "AGENTS.md"));
});
it("rejects parent-directory traversals", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(getCwdRelativePath(join(cwd, "..", "AGENTS.md"), cwd)).toBeUndefined();
});
});
describe("resolvePath", () => {
it("expands only home tilde shortcuts", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(normalizePath("~")).toBe(homedir());
expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt"));
expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
expect(normalizePath("~draft.md")).toBe("~draft.md");
});
it("resolves relative paths against the base directory", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt"));
expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt"));
});
it("accepts file URLs", () => {
const dir = createTempDir();
const filePath = join(dir, "file with spaces.txt");
expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath));
});
it("throws for invalid file URLs", () => {
expect(() => resolvePath("file:///%E0%A4%A")).toThrow();
});
it("preserves POSIX absolute paths with literal percent sequences", () => {
if (process.platform === "win32") {
return;
}
const dir = createTempDir();
for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) {
expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath));
}
});
it("does not treat Windows file URL pathname strings as native paths", () => {
if (process.platform !== "win32") {
return;
}
const dir = createTempDir();
const filePath = join(dir, "dir", "SKILL.md");
const pathname = pathToFileURL(filePath).pathname;
expect(pathname).toMatch(/^\/[A-Za-z]:/);
expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname));
});
});
describe("normalizeWindowsShellPath", () => {
it("converts Git Bash, MSYS, Cygwin, and WSL drive paths", () => {
expect(normalizeWindowsShellPath("/c/Users/example/project")).toBe("C:\\Users\\example\\project");
expect(normalizeWindowsShellPath("/cygdrive/d/work")).toBe("D:\\work");
expect(normalizeWindowsShellPath("/mnt/e/source")).toBe("E:\\source");
expect(normalizeWindowsShellPath("/c")).toBe("C:\\");
});
it("leaves other path forms unchanged", () => {
for (const path of [
"C:/Users/example",
"C:\\Users\\example",
"//server/share/file",
"/c/Users\\example",
"relative/file",
"/tmp/file",
]) {
expect(normalizeWindowsShellPath(path)).toBe(path);
}
});
it.runIf(process.platform === "win32")("is applied by normal path handling on Windows", () => {
expect(normalizePath("/c/Users/example")).toBe("C:\\Users\\example");
expect(resolvePath("/mnt/c/Users/example", "D:\\work")).toBe(resolve("C:/Users/example"));
});
});
describe("isLocalPath", () => {
it("returns true for bare names", () => {
expect(isLocalPath("my-package")).toBe(true);
});
it("returns true for relative paths", () => {
expect(isLocalPath("./foo")).toBe(true);
});
it("returns true for file URLs", () => {
expect(isLocalPath("file:///tmp/foo")).toBe(true);
});
it("returns false for npm: protocol", () => {
expect(isLocalPath("npm:package")).toBe(false);
});
it("returns false for git: protocol", () => {
expect(isLocalPath("git://repo")).toBe(false);
});
it("returns false for https: protocol", () => {
expect(isLocalPath("https://example.com")).toBe(false);
});
});