Merge branch 'main' into fix/2693-callable-flow-languages

This commit is contained in:
Gergő Magyar 2026-07-26 09:08:20 +01:00 committed by GitHub
commit 1d3088173f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 532 additions and 13 deletions

View file

@ -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).

View file

@ -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;

View file

@ -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

View file

@ -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;
};

View file

@ -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);
}
};

View 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();
});
});

View file

@ -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');

View file

@ -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', () => {

View 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',
);
});
});