diff --git a/src/domain/localPath.test.ts b/src/domain/localPath.test.ts index 1bf3290d91..0cc8721870 100644 --- a/src/domain/localPath.test.ts +++ b/src/domain/localPath.test.ts @@ -1,22 +1,27 @@ -import { isAbsolute } from "node:path"; +import { isAbsolute, win32 } from "node:path"; import { expect, it } from "vitest"; -import { absoluteLocalPathStringSchema } from "./localPath.js"; +import { + absoluteLocalPathStringSchema, + windowsAbsolutePathPattern, +} from "./localPath.js"; + +const PATHS = [ + "/absolute/path", + "/", + "relative/path", + "C:\\absolute\\path", + "c:/absolute/path", + "C:relative/path", + "\\\\server\\share\\path", + "\\rooted\\path", + "//server/share/path", + "file:///absolute/path", +]; it("matches the host path module for absolute local paths", () => { - for (const path of [ - "/absolute/path", - "/", - "relative/path", - "C:\\absolute\\path", - "c:/absolute/path", - "C:relative/path", - "\\\\server\\share\\path", - "\\rooted\\path", - "//server/share/path", - "file:///absolute/path", - ]) { + for (const path of PATHS) { expect(absoluteLocalPathStringSchema.safeParse(path).success).toBe( isAbsolute(path), ); @@ -26,3 +31,17 @@ it("matches the host path module for absolute local paths", () => { absoluteLocalPathStringSchema.safeParse("/absolute/with\0nul").success, ).toBe(false); }); + +it("keeps the Windows absolute-path pattern valid in every client mode", () => { + // Annex B and `u` accept an unescaped `/` inside a class; `v` does not, and a + // client that compiles advertised patterns rejects the complete tools request. + for (const flags of ["", "u", "v"]) + expect( + () => new RegExp(windowsAbsolutePathPattern.source, flags), + flags, + ).not.toThrow(); + for (const path of PATHS) + expect(windowsAbsolutePathPattern.test(path), path).toBe( + win32.isAbsolute(path), + ); +}); diff --git a/src/domain/localPath.ts b/src/domain/localPath.ts index f6035536e7..8b4a109fd0 100644 --- a/src/domain/localPath.ts +++ b/src/domain/localPath.ts @@ -19,9 +19,17 @@ export const nulFreeStringSchema = z /** A selected local filesystem path: nonempty and free of NUL bytes. */ export const localPathStringSchema = nulFreeStringSchema.min(1); +/** + * A Windows absolute path: rooted, UNC, or drive-qualified. The classes spell + * backslash and slash as `\x5C` and `\x2F` because `v` mode rejects an + * unescaped `/` inside a class, and Windows hosts advertise this pattern. + */ +export const windowsAbsolutePathPattern = + /^(?:[\x5C\x2F]|[A-Za-z]:[\x5C\x2F])/u; + /** A caller-selected path that is absolute on the host running REA. */ export const absoluteLocalPathStringSchema = localPathStringSchema.regex( - process.platform === "win32" ? /^(?:[\\/]|[A-Za-z]:[\\/])/u : /^\//u, + process.platform === "win32" ? windowsAbsolutePathPattern : /^\//u, "must be an absolute local filesystem path", );