GitNexus/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts

198 lines
8.6 KiB
TypeScript

/**
* #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<string>());
vi.mock('path', async () => {
const real = await vi.importActual<typeof import('path')>('path');
return { ...real.win32, default: real.win32 };
});
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
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<typeof import('../../src/lib/utils.js')>();
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<RegistryEntry, 'storagePath'> = {
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();
});
});