/** * #2667 — a `\\?\`-prefixed path must still find its registry entry. * * This is the Linux-runnable guard for the `canonicalizePath` wiring. The * companion assertions in `repo-manager.test.ts` exercise the real * `realpathSync.native` and are therefore Windows-only, so they only run on * the windows-latest matrix leg — leaving the Ubuntu gate with no coverage of the * behaviour at all. This file closes that hole by injecting the two platform * primitives and nothing else: * * - `path` → `path.win32`, which is Node's real Windows path implementation, * not a stand-in for it; * - `realpathSync.native` → its two actual Windows behaviours: for a path that * is on disk libuv's `fs__realpath_handle` strips `\\?\` before returning, * and for a path that is not it throws ENOENT; * - `stripWindowsLongPathPrefix` → the real implementation, pinned to `'win32'` * instead of defaulting to `process.platform`. * * That last one is deliberately a module mock rather than an * `Object.defineProperty(process, 'platform', …)`: module mocks are scoped to this * file, whereas `process` is shared by every test file in the same worker, so * overriding it risks a sibling that branches on the host platform. * * `canonicalizePath` and `registryPathEquals` themselves run unmodified. Run * against the pre-fix tree (89bbdcf5) the two `catch`-fallback cases below fail * and the realpath case passes, which is exactly the asymmetry the fix targets: * the realpath branch never leaked, because libuv strips the prefix itself. * * Registered in `scripts/cross-platform-tests.ts` as well: the injected Windows * path rules must agree with the native Windows run. */ import { describe, it, expect, vi } from 'vitest'; // `vi.mock` factories are hoisted above imports, so the set of "paths that exist // on disk" has to be hoisted with them rather than captured from module scope. const onDisk = vi.hoisted(() => new Set()); vi.mock('path', async () => { const real = await vi.importActual('path'); return { ...real.win32, default: real.win32 }; }); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); const realpath = (target: string): string => { const bare = String(target).replace(/^\\\\\?\\(UNC\\)?/, (_m, unc) => (unc ? '\\\\' : '')); if (!onDisk.has(bare)) { const err: NodeJS.ErrnoException = new Error( `ENOENT: no such file or directory, realpath '${target}'`, ); err.code = 'ENOENT'; throw err; } return bare; }; const realpathSync = Object.assign(realpath, { native: realpath }); return { ...actual, realpathSync, default: { ...actual, realpathSync } }; }); // The real helper, pinned to win32 — `canonicalizePath` calls it without a // platform argument, so it would otherwise default to the host's. vi.mock('../../src/lib/utils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, stripWindowsLongPathPrefix: (p: string) => actual.stripWindowsLongPathPrefix(p, 'win32'), }; }); import { assertSafeStoragePath, canonicalizePath, registryPathEquals, type RegistryEntry, } from '../../src/storage/repo-manager.js'; import { resolveRegisteredRepoEntry } from '../../src/server/api.js'; /** * The lookup every registry consumer performs — `resolveRegistryEntry`, * `registerRepo`, `unregisterRepo`, `isRepoRegistered`, `cloneDirBelongsToEntry`, * the MCP handle match and the server repo routes all canonicalise both sides and * compare with `registryPathEquals`. */ const registryLookupMatches = (stored: string, supplied: string): boolean => registryPathEquals(canonicalizePath(stored), canonicalizePath(supplied)); describe('canonicalizePath vs the `\\\\?\\` long-path prefix (#2667)', () => { it('matches a prefixed drive path against its stored entry when the repo is gone from disk', () => { const stored = 'D:\\Projects\\moved-away'; // The `catch` fallback: realpath throws, so pre-fix this returned // `path.resolve(p)` with the prefix still attached and matched nothing. expect(canonicalizePath(`\\\\?\\${stored}`)).toBe(stored); expect(registryLookupMatches(stored, `\\\\?\\${stored}`)).toBe(true); }); it('matches a prefixed UNC path against its stored entry when the share is unreachable', () => { const stored = '\\\\server\\share\\moved-away'; expect(canonicalizePath('\\\\?\\UNC\\server\\share\\moved-away')).toBe(stored); expect(registryLookupMatches(stored, '\\\\?\\UNC\\server\\share\\moved-away')).toBe(true); }); it('matches whatever case the UNC token is spelled in', () => { const stored = '\\\\server\\share\\moved-away'; expect(registryLookupMatches(stored, '\\\\?\\unc\\server\\share\\moved-away')).toBe(true); expect(registryLookupMatches(stored, '\\\\?\\Unc\\server\\share\\moved-away')).toBe(true); }); it('still matches through the realpath branch when the repo is present on disk', () => { const stored = 'D:\\Projects\\present'; onDisk.add(stored); // This branch never leaked — libuv strips the prefix inside fs__realpath — so // it passes on the pre-fix tree too. It is here so a future change that moves // the normalisation cannot silently break the path that always worked. expect(canonicalizePath(`\\\\?\\${stored}`)).toBe(stored); expect(registryLookupMatches(stored, `\\\\?\\${stored}`)).toBe(true); }); it('leaves an un-prefixed path byte-identical, on both branches', () => { const present = 'D:\\Projects\\present'; onDisk.add(present); expect(canonicalizePath(present)).toBe(present); expect(canonicalizePath('D:\\Projects\\absent')).toBe('D:\\Projects\\absent'); }); // The spellings the helper deliberately does not strip must stay unmatched // rather than be half-normalized. Asserted through canonicalizePath, not just // the helper, so the deliberate branch asymmetry is pinned where it is used. it('leaves volume-GUID and device-namespace spellings unmatched', () => { expect(canonicalizePath('\\\\?\\Volume{1a2b}\\repo')).toBe('\\\\?\\Volume{1a2b}\\repo'); expect(canonicalizePath('\\\\.\\D:\\repo')).toBe('\\\\.\\D:\\repo'); expect(registryLookupMatches('D:\\repo', '\\\\?\\Volume{1a2b}\\repo')).toBe(false); expect(registryLookupMatches('D:\\repo', '\\\\.\\D:\\repo')).toBe(false); }); }); // The storage resolver now compares canonical paths for repository-local slots. // Equivalent prefix spellings name the same .gitnexus directory; normalization // must still reject the repository itself, its parents, and unowned external paths. describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { const base: Omit = { name: 'repo', path: '\\\\?\\D:\\Projects\\repo', indexedAt: '2026-07-26T00:00:00.000Z', lastCommit: 'deadbee', }; it('accepts an entry whose path and storagePath share the prefix', async () => { await expect( assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }), ).resolves.toBeUndefined(); }); it('accepts mixed prefix spellings of the same repository-local slot', async () => { await expect( assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }), ).resolves.toBeUndefined(); }); it.each([ 'D:\\Projects\\repo', '\\\\?\\D:\\Projects\\repo', 'D:\\Projects', 'D:\\', 'D:\\Projects\\other\\.gitnexus', '\\\\?\\D:\\Projects\\other\\.gitnexus', ])('rejects an unsafe deletion target despite prefix normalization: %s', async (storagePath) => { await expect(assertSafeStoragePath({ ...base, storagePath })).rejects.toThrow( /Refusing to remove storage path for safety/, ); }); }); // The consumer surface the fix exists for: an MCP `repo` argument or an // `?repo=` query value arriving in the prefixed spelling must resolve the // un-prefixed registry entry it names. describe('resolveRegisteredRepoEntry with a prefixed path claim (#2667)', () => { const registered: RegistryEntry = { name: 'repo', path: 'D:\\Projects\\repo', storagePath: 'D:\\Projects\\repo\\.gitnexus', indexedAt: '2026-07-26T00:00:00.000Z', lastCommit: 'deadbee', }; it('resolves the entry when the caller supplies the extended-length spelling', () => { expect(resolveRegisteredRepoEntry([registered], '\\\\?\\D:\\Projects\\repo')).toBe(registered); }); it('still fails closed for a prefixed path that names no entry', () => { expect(resolveRegisteredRepoEntry([registered], '\\\\?\\D:\\Projects\\other')).toBeNull(); }); });