mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
198 lines
8.6 KiB
TypeScript
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();
|
|
});
|
|
});
|