mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* feat(storage): add configurable index storage and content retention tiers Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH, GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in main's FTS skip, embed-session, and help-text updates. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep legacy registry rows on the local storage fallback, resolve symlinks before the destructive-path guard, and align hook lookup with CLI branch slugs, branch-slot metadata, and longest-path match. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Only list swept upload directories after a successful removal so callers cannot treat a permission or transient rm failure as gone. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Document that getStoragePath may consult registered storage while this module still does not mutate the global registry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): close review findings for external indexes and retention Re-inspect ownership under the analyze lock, fail-closed when the registry file is missing, and keep skip-git hook discovery plus retention fields on HTTP/MCP list surfaces. /api/file stays 410 unless contentRetention is full. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3060) Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix macOS hook test expecting realpath'd registry paths. resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that. * Address gitnexus-check warnings on hook install docs and slot tests. The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory. * Align the HTTP catalog source-scan with skippable resolveRepo validation. resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true. * Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear. Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time. * Settle bridge stamps before writing so CI size/mtime matches stay stable. LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches. * Type the settled bridge stat as fs.Stats so tsc does not see bigint. Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI. * Keep the bridge mtime stamp exact so same-size swaps still fail the pair check. Co-authored-by: Cursor <cursoragent@cursor.com> * Wrap the bridge stamp predicate so prettier --check stays green. Co-authored-by: Cursor <cursoragent@cursor.com> * Require a quiet interval before stamping a settled bridge file. Co-authored-by: Cursor <cursoragent@cursor.com> * Reuse shared storage and settle helpers instead of local copies. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
188 lines
8.4 KiB
TypeScript
188 lines
8.4 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 itOnWindows = process.platform === 'win32' ? it : it.skip;
|
|
const base: Omit<RegistryEntry, 'storagePath'> = {
|
|
name: 'repo',
|
|
path: '\\\\?\\D:\\Projects\\repo',
|
|
indexedAt: '2026-07-26T00:00:00.000Z',
|
|
lastCommit: 'deadbee',
|
|
};
|
|
|
|
itOnWindows('accepts an entry whose path and storagePath share the prefix', async () => {
|
|
await expect(
|
|
assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
itOnWindows('rejects a mixed-form entry instead of deleting through it', async () => {
|
|
await expect(
|
|
assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }),
|
|
).rejects.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();
|
|
});
|
|
});
|