mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge branch 'main' into fix/2693-callable-flow-languages
This commit is contained in:
commit
1d3088173f
9 changed files with 532 additions and 13 deletions
|
|
@ -49,6 +49,14 @@ const PLATFORM_LOGIC = [
|
|||
// returns the absolute target across drives, so the guard needs isAbsolute.
|
||||
// Fixture-free and pathApi-injectable, so it is portable to every runner.
|
||||
'test/unit/analyzer-identity-is-inside.test.ts',
|
||||
// `\\?\` extended-length prefix normalization (#2667): fixture-free and
|
||||
// platform-injectable (every assertion passes an explicit 'win32'), so like the
|
||||
// is-inside guard above it is portable to every runner and its assertions run
|
||||
// identically here and on Ubuntu. Registered alongside its two siblings so the
|
||||
// Windows path-handling guards stay discoverable as one group. Same
|
||||
// mixed-prefix relativize hazard as is-inside, reached through a
|
||||
// caller-supplied path.
|
||||
'test/unit/windows-long-path-prefix.test.ts',
|
||||
// getconf page-size probe: explicit process.platform gate (win32 short-circuit)
|
||||
// plus a live-probe test whose only real non-4K coverage is macos-arm64's
|
||||
// 16 KiB pages — the exact hardware class #1231 targets (#2424 review).
|
||||
|
|
|
|||
|
|
@ -548,10 +548,29 @@ function resolveExistingPath(candidate: string): string {
|
|||
* unchanged. `platform` is explicit so the transform is unit-testable off
|
||||
* Windows.
|
||||
*
|
||||
* The optional `\\?\` extended-length prefix (which `realpathSync.native` can
|
||||
* emit for paths over MAX_PATH) is preserved and the drive letter after it is
|
||||
* still normalized; UNC paths (`\\server\share`, `\\?\UNC\...`) have no drive
|
||||
* letter and are left untouched.
|
||||
* The optional `\\?\` extended-length prefix is preserved and the drive letter
|
||||
* after it is still normalized; UNC paths (`\\server\share`, `\\?\UNC\...`)
|
||||
* have no drive letter and are left untouched.
|
||||
*
|
||||
* That optional group is defensive, not a case `realpathSync.native` produces:
|
||||
* libuv's `fs__realpath_handle` strips `\\?\` (and rewrites `\\?\UNC\` back to
|
||||
* `\\`) before returning, so the prefix can only reach here from caller-supplied
|
||||
* input, which `path.resolve` preserves (#2667).
|
||||
*
|
||||
* Preserving it is load-bearing. The roots this normalizes are not just compared —
|
||||
* they are READ FROM: `resolveBuildRoot` joins `package.json` onto `packageRoot`,
|
||||
* `collectBuildEntries` walks `buildRoot`, and the lockfile lookup walks
|
||||
* `packageRoot`'s ancestors. Node does not re-add `\\?\` for over-MAX_PATH paths,
|
||||
* so stripping here would break analyzer-identity resolution on a deep checkout
|
||||
* exactly as it would at any other filesystem boundary. (These fields are also
|
||||
* compared between an `analyze` and a later `status` run, so a shape change would
|
||||
* additionally risk the #2668 false-stale class — but the filesystem reads are the
|
||||
* reason that matters.)
|
||||
*
|
||||
* Registry-style path COMPARISON is a different domain, never opens what it
|
||||
* canonicalizes, and does normalize the prefix away: see
|
||||
* `stripWindowsLongPathPrefix` in `src/lib/utils.ts` and its use in
|
||||
* `canonicalizePath`.
|
||||
*/
|
||||
export function normalizeAnalyzerRootPath(p: string, platform: NodeJS.Platform): string {
|
||||
if (platform !== 'win32') return p;
|
||||
|
|
|
|||
|
|
@ -440,18 +440,36 @@ export class IncludeExtractor implements ContractExtractor {
|
|||
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh|cuh)$'
|
||||
RETURN f.filePath AS filePath, f.id AS fileId`,
|
||||
);
|
||||
// gitnexus analyze stores absolute paths in the File.filePath column.
|
||||
// Provider contract IDs MUST be repo-relative — otherwise the consumer
|
||||
// emits `include::map/base/view.h` and the provider emits
|
||||
// `include::/abs/path/to/repo/map/base/view.h`, which never match
|
||||
// through runExactMatch and the cross-link silently disappears.
|
||||
// (PR #1156 follow-up review: graph provider absolute-path bug.)
|
||||
//
|
||||
// Current `gitnexus analyze` does NOT store absolute paths here, contrary
|
||||
// to what this comment used to claim: File.filePath is built from the
|
||||
// walker's repo-relative, forward-slash paths (filesystem-walker.ts →
|
||||
// processStructure), and a full self-index at 89bbdcf5 had 0 of 239,070
|
||||
// nodes with an absolute or backslash-bearing filePath (#2667). The
|
||||
// relativisation below therefore stays as a guard against rows this
|
||||
// process did not write — an index built by an older version, or one
|
||||
// carried over from another machine — not as a description of what
|
||||
// analyze currently emits.
|
||||
const normalizedRepoPath = path.resolve(repoPath);
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const r of rows) {
|
||||
if (typeof r.filePath !== 'string' || !r.filePath) continue;
|
||||
const absolute = r.filePath as string;
|
||||
const rel = path.relative(normalizedRepoPath, absolute);
|
||||
// Only relativise a row that is actually absolute. Current analyze writes
|
||||
// repo-relative paths (above), and `path.relative(repoRoot, 'src/a.h')`
|
||||
// resolves the second argument against the PROCESS CWD — so from any cwd
|
||||
// other than the repo root every relative row came back `..`-prefixed and
|
||||
// was dropped by the guard below, silently emptying this strategy (#2667
|
||||
// review). Absolute rows still go through `path.relative` so the
|
||||
// containment check keeps rejecting foreign and escaping paths.
|
||||
const rel = path.isAbsolute(absolute)
|
||||
? path.relative(normalizedRepoPath, absolute)
|
||||
: absolute;
|
||||
// Skip rows that resolve outside the repo (e.g., system headers
|
||||
// somehow indexed, or stale absolute paths from a different machine).
|
||||
// path.relative returns a `..`-prefixed path or an absolute path
|
||||
|
|
|
|||
|
|
@ -1,3 +1,52 @@
|
|||
export const generateId = (label: string, name: string): string => {
|
||||
return `${label}:${name}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop a Windows extended-length (`\\?\`) prefix from a path (#2667).
|
||||
*
|
||||
* **Comparison domain only.** Never apply this to a string that is about to be
|
||||
* handed to `fs`: libuv's `fs__capture_path` only converts WTF-8 to UTF-16 and
|
||||
* does *not* re-add the prefix for over-MAX_PATH paths, so stripping an
|
||||
* filesystem-facing path would break long-path access on hosts that have not
|
||||
* opted into `LongPathsEnabled`. Registry keys, repo-resolution lookups and
|
||||
* other pure string comparisons are safe — and are exactly where an
|
||||
* un-normalized prefix silently fails to match.
|
||||
*
|
||||
* The prefix can only ever arrive from caller-supplied input (`path.resolve`
|
||||
* preserves it); `realpathSync.native` cannot emit it, because libuv's
|
||||
* `fs__realpath_handle` strips it unconditionally.
|
||||
*
|
||||
* `\\?\Volume{GUID}\…` is deliberately left alone: the remainder of a volume-GUID
|
||||
* path is not a usable path, so stripping it would invent a wrong one. The `\\.\`
|
||||
* device namespace is left alone for the same reason and one more — most of what
|
||||
* it addresses (`\\.\PhysicalDrive0`, `\\.\COM1`, `\\.\pipe\…`) is not a
|
||||
* filesystem path at all. Both forms simply fail to match a registry entry, which
|
||||
* is the safe direction. `platform` is explicit so the transform is unit-testable
|
||||
* off Windows — same shape as `normalizeAnalyzerRootPath` in
|
||||
* `src/core/analyzer-identity.ts`.
|
||||
*
|
||||
* Call this on a `path.resolve`d path. Only the backslash spelling is matched,
|
||||
* which is sufficient there because `path.win32.resolve` already folds the
|
||||
* forward-slash spelling into it (`//?/D:/a` → `\\?\D:\a`). A raw, unresolved
|
||||
* `//?/…` string is returned unchanged rather than half-normalized.
|
||||
*/
|
||||
export const stripWindowsLongPathPrefix = (
|
||||
p: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string => {
|
||||
if (platform !== 'win32') return p;
|
||||
// Both spellings are case-insensitive: the Windows object namespace that
|
||||
// `\\?\` addresses is, so `\\?\unc\…` is as valid as `\\?\UNC\…`.
|
||||
//
|
||||
// Each pattern requires the component that makes the remainder a usable path —
|
||||
// a share name after `UNC\`, a separator after the drive colon. Without those
|
||||
// the slice would emit something worse than the input it was handed: `\\?\UNC`
|
||||
// would become the bare root `\\`, and the drive-relative `\\?\D:foo` would
|
||||
// become `D:foo`, which is not absolute and would resolve against the process
|
||||
// cwd if a future caller ever passed it to `fs`. A malformed extended path is
|
||||
// left untouched instead, so it simply fails to match a registry entry.
|
||||
if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`;
|
||||
if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4);
|
||||
return p;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import path from 'path';
|
|||
import os from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
|
||||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { retryRename } from './fs-atomic.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import {
|
||||
|
|
@ -49,6 +50,20 @@ export type { BranchSummary };
|
|||
* form (`RUNNERA~1\...`), but `process.cwd()` often returns the
|
||||
* long form (`runneradmin\...`). `realpathSync.native` normalises
|
||||
* both sides to the long-name canonical path.
|
||||
* - **Windows, extended-length paths** (#2667): a caller can supply a
|
||||
* `\\?\`-prefixed path — the usual MAX_PATH workaround — and
|
||||
* `path.resolve` preserves the prefix, so the string compare below
|
||||
* never matches the un-prefixed entry the registry stores. The
|
||||
* realpath branch already dropped it (libuv strips the prefix inside
|
||||
* `fs__realpath`), but the fallback branch did not, which is exactly
|
||||
* the branch a missing path takes. `stripWindowsLongPathPrefix` is
|
||||
* applied to both so the two branches agree.
|
||||
*
|
||||
* This normalisation is safe here precisely because the result is only ever
|
||||
* compared, never opened: Node does NOT re-add `\\?\` for over-MAX_PATH
|
||||
* paths, so an fs-facing path must keep whatever form the caller gave it.
|
||||
* See the `registerRepo` comment on applying canonicalisation at COMPARE
|
||||
* points only.
|
||||
*
|
||||
* Fallback behaviour: if the path does not exist on disk (e.g. a user
|
||||
* passed `gitnexus remove some-alias` and the alias misses every
|
||||
|
|
@ -60,16 +75,16 @@ export type { BranchSummary };
|
|||
* Backwards compatibility: this function is applied to BOTH the
|
||||
* caller-supplied input AND each stored `entry.path` at compare time
|
||||
* inside `resolveRegistryEntry`, so registries written by older
|
||||
* versions (where `registerRepo` only ran `path.resolve`) still match
|
||||
* correctly. Newly-written entries are canonicalised at write time too
|
||||
* so the registry stabilises over analyze/re-analyze cycles.
|
||||
* versions still match correctly. Entries are NOT canonicalised at
|
||||
* write time — `registerRepo` stores `path.resolve(repoPath)` — which
|
||||
* is what makes the compare-only rule above hold.
|
||||
*/
|
||||
export const canonicalizePath = (p: string): string => {
|
||||
const resolved = path.resolve(p);
|
||||
try {
|
||||
return realpathSync.native(resolved);
|
||||
return stripWindowsLongPathPrefix(realpathSync.native(resolved));
|
||||
} catch {
|
||||
return resolved;
|
||||
return stripWindowsLongPathPrefix(resolved);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
187
gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts
Normal file
187
gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* #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();
|
||||
});
|
||||
});
|
||||
|
|
@ -477,8 +477,11 @@ int main(){return 0;}`,
|
|||
writeFile('map/base/view.h', '#pragma once\nclass View {};');
|
||||
writeFile('utils/types.hpp', '#pragma once');
|
||||
|
||||
// Stub the Cypher executor to return absolute paths the way
|
||||
// gitnexus analyze actually persists them.
|
||||
// Stub the Cypher executor to return absolute paths. Current `gitnexus
|
||||
// analyze` does NOT persist them this way — File.filePath is repo-relative
|
||||
// with forward slashes (see the comment on extractProvidersGraph, #2667) —
|
||||
// so this exercises the defensive relativisation against rows written by an
|
||||
// older version or carried over from another machine.
|
||||
const absolute1 = path.join(tmpDir, 'map/base/view.h');
|
||||
const absolute2 = path.join(tmpDir, 'utils/types.hpp');
|
||||
const stubDb = async () => [
|
||||
|
|
@ -494,6 +497,33 @@ int main(){return 0;}`,
|
|||
expect(providers.every((p) => p.meta?.source === 'graph')).toBe(true);
|
||||
});
|
||||
|
||||
// #2667 review: the rows analyze ACTUALLY writes are repo-relative, and
|
||||
// `path.relative(repoRoot, 'src/a.h')` resolves its second argument against
|
||||
// the process cwd. Vitest runs from `gitnexus/`, never from `tmpDir`, so
|
||||
// before the isAbsolute guard every row here came back `..`-prefixed and was
|
||||
// dropped — this strategy silently returned [] and fell through to the
|
||||
// filesystem fallback.
|
||||
it('keeps repo-relative graph rows when cwd is not the repo root', async () => {
|
||||
writeFile('map/base/view.h', '#pragma once\nclass View {};');
|
||||
writeFile('utils/types.hpp', '#pragma once');
|
||||
|
||||
expect(process.cwd()).not.toBe(tmpDir);
|
||||
|
||||
const stubDb = async () => [
|
||||
{ filePath: 'map/base/view.h', fileId: 'File:rel:1' },
|
||||
{ filePath: 'utils/types.hpp', fileId: 'File:rel:2' },
|
||||
];
|
||||
|
||||
const contracts = await extractor.extract(stubDb, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
|
||||
expect(providers.map((p) => p.contractId).sort()).toEqual([
|
||||
'include::map/base/view.h',
|
||||
'include::utils/types.hpp',
|
||||
]);
|
||||
expect(providers.every((p) => p.meta?.source === 'graph')).toBe(true);
|
||||
});
|
||||
|
||||
it('drops graph rows whose path resolves outside the repo root', async () => {
|
||||
writeFile('local/header.h', '#pragma once');
|
||||
const absoluteLocal = path.join(tmpDir, 'local/header.h');
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
listRegisteredRepos,
|
||||
resolveRegistryEntry,
|
||||
canonicalizePath,
|
||||
registryPathEquals,
|
||||
cloneDirBelongsToEntry,
|
||||
assertSafeStoragePath,
|
||||
RegistryNameCollisionError,
|
||||
|
|
@ -710,6 +711,48 @@ describe('case-insensitive path comparison', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── Windows \\?\ extended-length prefix (#2667) ──────────────────────
|
||||
//
|
||||
// `canonicalizePath` is the single comparison key for the 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: `path.resolve` preserves the prefix, and the `catch` fallback returns
|
||||
// that resolved path untouched.
|
||||
//
|
||||
// These run only on windows-latest (the file is registered in
|
||||
// scripts/cross-platform-tests.ts): `\\?\` is a Win32 concept, and on POSIX the
|
||||
// same string is just an oddly-named relative file.
|
||||
describe('canonicalizePath vs the \\\\?\\ long-path prefix (#2667)', () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Realpath branch. libuv's fs__realpath_handle strips the prefix itself, so
|
||||
// this documents the branch that was already safe.
|
||||
it.skipIf(!isWindows)('drops the prefix for a path that exists on disk', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-longpath-'));
|
||||
try {
|
||||
const prefixed = canonicalizePath(`\\\\?\\${dir}`);
|
||||
|
||||
expect(prefixed.startsWith('\\\\?\\')).toBe(false);
|
||||
expect(registryPathEquals(prefixed, canonicalizePath(dir))).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Catch fallback — the branch that actually leaked. `realpathSync.native`
|
||||
// throws for a path that is not on disk (a registry entry rm'd externally, a
|
||||
// `remove`/`clean` alias, an MCP `repo` argument for an unindexed path), so
|
||||
// the resolved string is returned as-is and carried the prefix straight into
|
||||
// the string compare.
|
||||
it.skipIf(!isWindows)('drops the prefix for a path that does not exist', () => {
|
||||
const missing = path.join(os.tmpdir(), 'gn-longpath-absent-2667', 'repo');
|
||||
const prefixed = canonicalizePath(`\\\\?\\${missing}`);
|
||||
|
||||
expect(prefixed.startsWith('\\\\?\\')).toBe(false);
|
||||
expect(registryPathEquals(prefixed, canonicalizePath(missing))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── API key file permissions (hardening #29) ────────────────────────
|
||||
|
||||
describe('API key file permissions', () => {
|
||||
|
|
|
|||
150
gitnexus/test/unit/windows-long-path-prefix.test.ts
Normal file
150
gitnexus/test/unit/windows-long-path-prefix.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* #2667 — the Windows extended-length (`\\?\`) prefix must never survive into a
|
||||
* path GitNexus compares or keys on.
|
||||
*
|
||||
* `stripWindowsLongPathPrefix` is a POSIX no-op, so these assertions only bite on
|
||||
* windows-latest; the file is registered in `scripts/cross-platform-tests.ts` for
|
||||
* exactly that reason. Like `analyzer-identity-path-normalization.test.ts`, it holds
|
||||
* ONLY pure-function assertions with an explicit `platform` argument — no fixture,
|
||||
* no filesystem — so it stays green on every runner.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import { stripWindowsLongPathPrefix } from '../../src/lib/utils.js';
|
||||
|
||||
describe('stripWindowsLongPathPrefix (#2667)', () => {
|
||||
it('strips the prefix from a drive path', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\D:\\Projects\\repo', 'win32')).toBe(
|
||||
'D:\\Projects\\repo',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites the UNC form back to its `\\\\server\\share` shape', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\UNC\\server\\share\\repo', 'win32')).toBe(
|
||||
'\\\\server\\share\\repo',
|
||||
);
|
||||
});
|
||||
|
||||
// The namespace `\\?\` addresses is case-insensitive, so a caller can spell
|
||||
// the token in any case. Matching only `UNC` left `\\?\unc\…` prefixed, which
|
||||
// is the #2667 registry mismatch all over again on a network share.
|
||||
it('rewrites the UNC form whatever case the token is spelled in', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\unc\\server\\share\\repo', 'win32')).toBe(
|
||||
'\\\\server\\share\\repo',
|
||||
);
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\Unc\\server\\share\\repo', 'win32')).toBe(
|
||||
'\\\\server\\share\\repo',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a volume-GUID path untouched — its remainder is not a usable path', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\Volume{1a2b3c4d}\\repo', 'win32')).toBe(
|
||||
'\\\\?\\Volume{1a2b3c4d}\\repo',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the `\\\\.\\` device namespace untouched', () => {
|
||||
// Most of what it addresses is not a filesystem path at all.
|
||||
expect(stripWindowsLongPathPrefix('\\\\.\\D:\\repo', 'win32')).toBe('\\\\.\\D:\\repo');
|
||||
expect(stripWindowsLongPathPrefix('\\\\.\\PhysicalDrive0', 'win32')).toBe(
|
||||
'\\\\.\\PhysicalDrive0',
|
||||
);
|
||||
});
|
||||
|
||||
// Degenerate extended paths: stripping these would emit something worse than
|
||||
// the input. `\\?\UNC` has no share to keep, so a blind slice yields the bare
|
||||
// root `\\`; `\\?\D:foo` is drive-RELATIVE, so a blind slice yields `D:foo`,
|
||||
// which is not absolute and would resolve against the process cwd. Both are
|
||||
// left untouched so they simply fail to match a registry entry.
|
||||
it('leaves a UNC prefix with no share component untouched', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\UNC\\', 'win32')).toBe('\\\\?\\UNC\\');
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\UNC', 'win32')).toBe('\\\\?\\UNC');
|
||||
});
|
||||
|
||||
it('leaves a drive-relative extended path untouched, so output stays absolute', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\D:foo', 'win32')).toBe('\\\\?\\D:foo');
|
||||
expect(path.win32.isAbsolute(stripWindowsLongPathPrefix('\\\\?\\D:\\foo', 'win32'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op on already-canonical drive and UNC paths', () => {
|
||||
expect(stripWindowsLongPathPrefix('D:\\Projects\\repo', 'win32')).toBe('D:\\Projects\\repo');
|
||||
expect(stripWindowsLongPathPrefix('\\\\server\\share\\repo', 'win32')).toBe(
|
||||
'\\\\server\\share\\repo',
|
||||
);
|
||||
});
|
||||
|
||||
it('is idempotent — it runs wherever a comparison key is built', () => {
|
||||
const once = stripWindowsLongPathPrefix('\\\\?\\D:\\Projects\\repo', 'win32');
|
||||
expect(stripWindowsLongPathPrefix(once, 'win32')).toBe(once);
|
||||
|
||||
const uncOnce = stripWindowsLongPathPrefix('\\\\?\\UNC\\server\\share\\repo', 'win32');
|
||||
expect(stripWindowsLongPathPrefix(uncOnce, 'win32')).toBe(uncOnce);
|
||||
});
|
||||
|
||||
// Near-miss spellings must fail closed rather than be half-normalized: none of
|
||||
// these is the extended-length prefix, so none may be sliced.
|
||||
it('leaves near-miss namespace spellings untouched', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\??\\D:\\repo', 'win32')).toBe('\\\\??\\D:\\repo');
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\\\D:\\repo', 'win32')).toBe('\\\\?\\\\D:\\repo');
|
||||
expect(stripWindowsLongPathPrefix('\\?\\D:\\repo', 'win32')).toBe('\\?\\D:\\repo');
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\GLOBALROOT\\Device\\X', 'win32')).toBe(
|
||||
'\\\\?\\GLOBALROOT\\Device\\X',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles degenerate and empty input without throwing', () => {
|
||||
expect(stripWindowsLongPathPrefix('', 'win32')).toBe('');
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\', 'win32')).toBe('\\\\?\\');
|
||||
expect(stripWindowsLongPathPrefix('\\\\', 'win32')).toBe('\\\\');
|
||||
expect(stripWindowsLongPathPrefix('D:', 'win32')).toBe('D:');
|
||||
});
|
||||
|
||||
// The helper matches the backslash spelling only, by design — `path.resolve`
|
||||
// folds `//?/` into it first. A half-converted path is left alone rather than
|
||||
// sliced on one separator convention and rejoined on the other.
|
||||
it('leaves a forward-slash or mixed-separator prefix untouched', () => {
|
||||
expect(stripWindowsLongPathPrefix('//?/D:/repo', 'win32')).toBe('//?/D:/repo');
|
||||
expect(stripWindowsLongPathPrefix('//?/UNC/server/share', 'win32')).toBe(
|
||||
'//?/UNC/server/share',
|
||||
);
|
||||
// Backslash prefix with a forward-slash body IS sliced — the prefix matched.
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\D:\\a/b/c', 'win32')).toBe('D:\\a/b/c');
|
||||
});
|
||||
|
||||
it('is a no-op off Windows, where `\\\\?\\…` is an ordinary filename', () => {
|
||||
expect(stripWindowsLongPathPrefix('\\\\?\\D:\\repo', 'linux')).toBe('\\\\?\\D:\\repo');
|
||||
expect(stripWindowsLongPathPrefix('/home/node/repo', 'linux')).toBe('/home/node/repo');
|
||||
});
|
||||
|
||||
// The leak this normalization exists to prevent. `path.win32.relative` cannot
|
||||
// express a relative path between a prefixed and an un-prefixed form of the SAME
|
||||
// directory — they share no root — so it returns the absolute target instead.
|
||||
// That absolute string is exactly what #2667 reported inside node IDs
|
||||
// (`Function:\\?\D:\…\market.move:…`), and it is the same defect class as the
|
||||
// cross-drive `isInside` bug fixed in #2688.
|
||||
it('makes a mixed-prefix relativization relative again', () => {
|
||||
const prefixed = '\\\\?\\D:\\repo';
|
||||
const child = 'D:\\repo\\a\\b.move';
|
||||
|
||||
expect(path.win32.relative(prefixed, child)).toBe(child);
|
||||
expect(path.win32.relative(stripWindowsLongPathPrefix(prefixed, 'win32'), child)).toBe(
|
||||
'a\\b.move',
|
||||
);
|
||||
});
|
||||
|
||||
// Why `canonicalizePath`'s `catch` branch leaked and its realpath branch did
|
||||
// not. The repo-manager regression tests for that branch can only run on
|
||||
// windows-latest, so pin the underlying platform fact here, where it runs
|
||||
// everywhere: `path.resolve` carries the prefix through untouched, which is
|
||||
// all the fallback branch used to do. Also pins the forward-slash spelling
|
||||
// that the helper deliberately does not match, because `resolve` folds it
|
||||
// into the backslash form first.
|
||||
it('pins that path.resolve preserves the prefix (the fallback branch #2667 leaked through)', () => {
|
||||
expect(path.win32.resolve('\\\\?\\D:\\repo\\sub')).toBe('\\\\?\\D:\\repo\\sub');
|
||||
expect(path.win32.resolve('//?/D:/repo/sub')).toBe('\\\\?\\D:\\repo\\sub');
|
||||
|
||||
expect(stripWindowsLongPathPrefix(path.win32.resolve('\\\\?\\D:\\repo\\sub'), 'win32')).toBe(
|
||||
'D:\\repo\\sub',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue