GitNexus/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts
Gergő Magyar 7a064a1f2a
fix(storage): stop the Windows \\?\ long-path prefix from breaking repo path matching (#2667) (#2700)
* fix(lib): add stripWindowsLongPathPrefix for path comparisons (#2667)

A caller can hand GitNexus a `\\?\`-prefixed path — the usual MAX_PATH
workaround on Windows — and `path.resolve` preserves the prefix, so it
reaches every string comparison GitNexus keys paths on. It also poisons
relativization: `path.win32.relative` cannot express a relative path
between a prefixed and an un-prefixed form of the same directory, so it
returns the absolute target instead. That absolute string is the shape
reported in #2667.

The helper is deliberately scoped to the comparison domain. libuv's
`fs__capture_path` does not re-add the prefix for over-MAX_PATH paths, so
stripping a filesystem-facing path would break long-path access on hosts
that have not opted into LongPathsEnabled. `\\?\Volume{GUID}\…` is left
alone because the remainder is not a usable path.

The test is fixture-free and takes an explicit `platform`, mirroring
`normalizeAnalyzerRootPath`, and is registered on the cross-platform
matrix since the whole transform is a POSIX no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* fix(storage): normalize the `\\?\` prefix in canonicalizePath (#2667)

`canonicalizePath` is the single comparison key for the repo registry,
MCP repo resolution and the server repo routes, and `registryPathEquals`
compares its output as a plain string. A caller-supplied `\\?\` prefix
therefore matched nothing: a repo registered as `D:\repo` was invisible
to a caller passing `\\?\D:\repo`, which surfaces as "repo not found" or
a duplicate registration from `analyze`, `remove`, `clean`, the MCP
`repo` parameter and the server routes.

Both branches are normalized. The realpath branch was already safe —
libuv's `fs__realpath` strips the prefix itself — but the `catch`
fallback returns `path.resolve(p)` untouched, and that is exactly the
branch a path which is not on disk takes.

Safe despite the CRITICAL blast radius (27 impacted, 12 direct
dependents) because the result is only ever compared, never opened: all
23 call sites feed `registryPathEquals` or a string comparison. Both
operands are canonicalized, so the equality relation is preserved and
behaviour is unchanged for every un-prefixed input.

The two regression assertions run only on windows-latest, where the file
already runs via the cross-platform matrix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* docs(core): correct two false comments about Windows paths (#2667)

Both comments assert the opposite of how the platform and the analyzer
actually behave, and both would send the next investigator of #2667 the
wrong way.

`analyzer-identity.ts` claimed the `\\?\` prefix is one that
`realpathSync.native` "can emit for paths over MAX_PATH". libuv's
`fs__realpath_handle` strips the prefix unconditionally and rewrites
`\\?\UNC\` back to `\\`, erroring if neither is present, so realpath
never returns one. The prefix can only arrive from caller-supplied
input. The optional group in the regex stays as a labelled defensive
no-op, and the function's behaviour is unchanged on purpose: these
identity fields are compared between an `analyze` and a later `status`
run, so this is not the place to reshape a path.

`include-extractor.ts` claimed "gitnexus analyze stores absolute paths in
the File.filePath column". A full self-index at 89bbdcf5 had 0 of 239,070
nodes with an absolute or backslash-bearing filePath: File nodes are
built from the walker's repo-relative forward-slash paths. The
relativization guard below it stays, now described as what it is — a
guard against rows this process did not write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* test(lib): pin that path.resolve preserves the `\\?\` prefix (#2667)

The `canonicalizePath` regression tests for the `catch` fallback can only
run on windows-latest, so the fact they rest on is invisible in the Ubuntu
suite. Pin it here, in the fixture-free file that runs everywhere:
`path.win32.resolve` carries the prefix through untouched, which is all
the fallback branch used to do before this fix.

Also pins the forward-slash spelling (`//?/D:/…`), which the helper
deliberately does not match because `resolve` folds it into the backslash
form first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* fix(lib): match the `\\?\UNC\` token case-insensitively (#2667)

The namespace `\\?\` addresses is the Windows object namespace, which is
case-insensitive, so `\\?\unc\server\share` is as valid as the uppercase
spelling. Matching only `UNC` left the lowercase form prefixed, which is
the same registry mismatch #2667 is about, reached through a network
share instead of a drive.

The drive branch was already case-insensitive (`[A-Za-z]`), so the two
branches disagreed with each other. Probed against the built artifact:
`\\?\unc\…`, `\\?\Unc\…` and `\\?\UNC\…` now all yield
`\\server\share\…`, and `\\?\Volume{…}` is still left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* test(storage): guard the canonicalizePath fix on Linux too (#2667)

The two `canonicalizePath` assertions in `repo-manager.test.ts` drive the
real `realpathSync.native`, so they are `it.skipIf(win32)` and only run on
the windows-latest matrix leg. The Ubuntu gate — the one every PR runs —
had no coverage of the behaviour at all.

This runs the same wiring anywhere by injecting only the platform
primitives: `path` becomes `path.win32` (Node's real Windows path
implementation, not a stand-in), `realpathSync.native` gets its two actual
behaviours (libuv strips `\\?\` for a path on disk, throws ENOENT for one
that is not), and the real `stripWindowsLongPathPrefix` is pinned to
win32 rather than defaulting to the host. `canonicalizePath` and
`registryPathEquals` run unmodified.

Pinning the helper is a module mock rather than an override of
`process.platform`, which is shared by every test file in a worker.

Verified to discriminate: against the pre-fix tree at 89bbdcf5 it fails 3
of 5, and reverting just the two strip calls on this branch reproduces the
same 3 failures with `expected '\\?\D:\Projects\moved-away' to be
'D:\Projects\moved-away'`. The two that pass either way are the realpath
branch and the un-prefixed no-op, neither of which ever leaked.

Not registered in scripts/cross-platform-tests.ts: it simulates Windows
rather than needing it, so its home is the Ubuntu suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* fix(lib): require the component that makes a stripped path usable (#2667)

Both regexes were under-anchored, so the slice could emit something worse
than the input it was handed. `\\?\UNC` has no share to keep and became
the bare root `\\`; `\\?\D:foo` is drive-relative and became `D:foo`,
which is not absolute and would resolve against the process cwd if a
future caller ever passed it to `fs`. `canonicalizePath` previously
always returned an absolute path and had stopped doing so.

Each pattern now requires the part that makes the remainder a real path —
a share name after `UNC\`, a separator after the drive colon. Malformed
extended paths are left untouched and simply fail to match a registry
entry, which is the safe direction.

Also from review: document `\\.\` as a deliberate non-goal alongside
`\\?\Volume{GUID}\` (most of what it addresses is not a filesystem path),
correct the canonicalizePath docblock, which still claimed entries are
canonicalised at write time — `registerRepo` stores `path.resolve` and
the paragraph added two lines above says compare-only — and reword the
cross-platform registration comment, which claimed the test is only
meaningful on windows-latest when every assertion passes an explicit
'win32' and runs identically on Ubuntu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* fix(group): keep repo-relative rows in the graph provider strategy (#2667)

`extractProvidersGraph` relativised every row with
`path.relative(normalizedRepoPath, absolute)`. The rows analyze actually
writes are repo-relative — which is exactly what the comment corrected
earlier in this branch establishes — and `path.relative` resolves a
relative second argument against the PROCESS CWD. So from any cwd other
than the repo root, every row came back `..`-prefixed, the containment
guard dropped it, and the strategy silently returned [] and fell through
to the filesystem fallback.

Only absolute rows go through `path.relative` now. The containment guard
is unchanged, so foreign and escaping rows are still rejected.

Found by three independent reviewers reading the comment this branch
corrected and following it to its consequence. The regression test fails
without the guard (`expected false to be true`) and passes with it;
vitest runs from `gitnexus/`, never the fixture dir, so it exercises the
cwd mismatch by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

* test: close the coverage gaps the review surfaced (#2667)

Adds the assertions the reviewers named as missing, and corrects one more
comment that gave the right conclusion for the wrong reason.

analyzer-identity: the comment said the `\\?\` prefix is preserved so the
identity fields keep a stable compare shape. That is true but secondary.
The load-bearing reason is that these roots are READ FROM — resolveBuildRoot
joins package.json onto packageRoot, collectBuildEntries walks buildRoot,
and the lockfile lookup walks packageRoot's ancestors — so stripping here
would break analyzer identity on a deep checkout for exactly the reason the
ingress strip was withdrawn.

Helper: near-miss spellings (`\\??\`, `\\?\\`, single-backslash, GLOBALROOT)
and forward-slash/mixed-separator forms are pinned as untouched, plus
degenerate and empty input.

canonicalizePath: volume-GUID and `\\.\` are asserted unmatched through
canonicalizePath itself, not just the helper, so the deliberate branch
asymmetry is pinned where it is consumed.

assertSafeStoragePath: prefixed path + prefixed storagePath passes, mixed
form throws. This guard fronts fs.rm(recursive) and deliberately does NOT
canonicalize; "complete the fix by stripping here too" is the tempting
follow-up and would widen what the recursive delete accepts.

resolveRegisteredRepoEntry: the consumer surface the fix exists for — an
MCP `repo` argument or `?repo=` value in the prefixed spelling now resolves
its un-prefixed entry, and a prefixed path naming no entry still fails
closed. Verified to discriminate: reverting the strip fails this test along
with the three catch-branch ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119fkrRFdQDQKh58LY9Tqh2

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:07:55 +01:00

187 lines
8.3 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 `it.skipIf(win32)`, 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.
*
* Deliberately NOT registered in `scripts/cross-platform-tests.ts`: it simulates
* Windows rather than needing it, so its home is the Ubuntu suite.
*/
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 guard in front of `fs.rm(recursive)` in remove.ts / clean.ts. It compares
// `path.resolve` forms on both sides and deliberately does NOT canonicalize, so a
// prefixed entry stays self-consistent while a mixed-form entry fails closed.
// Pinned here because "complete the fix by stripping here too" is the tempting
// follow-up refactor, and it would widen what the recursive delete accepts.
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', () => {
expect(() =>
assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }),
).not.toThrow();
});
it('rejects a mixed-form entry instead of deleting through it', () => {
expect(() =>
assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }),
).toThrow();
});
});
// 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();
});
});