diff --git a/gitnexus/src/storage/shared-store-lifecycle.ts b/gitnexus/src/storage/shared-store-lifecycle.ts index 3da0e49b4..36f60e488 100644 --- a/gitnexus/src/storage/shared-store-lifecycle.ts +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -19,7 +19,7 @@ import { readRegistryStrictIfPresent, registryPathEquals, } from './repo-manager.js'; -import { loadMeta } from './repo-meta.js'; +import { isMissingFilesystemError, loadMeta } from './repo-meta.js'; import { SHARED_STORE_POINTER, storeRootOfCheckoutSlot, @@ -249,6 +249,34 @@ export const describeSharedGraph = ( /** Files a shared checkout keeps in `/.gitnexus`; everything else there is legacy. */ const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore', 'run.cjs']); +/** + * Whether `/.gitnexus` is absent, a real directory inside the + * checkout, or anything else. A symlink (or junction) there could point + * anywhere — `.gitnexus -> ..` would expose the checkout's parent — so + * nothing is written, listed, or deleted through it. + */ +const probePointerDir = async ( + checkoutPath: string, +): Promise<{ status: 'missing' } | { status: 'contained'; dir: string } | { status: 'unsafe' }> => { + const dir = path.join(checkoutPath, GITNEXUS_DIR); + let stat: Awaited>; + try { + stat = await fs.lstat(dir); + } catch (err) { + return isMissingFilesystemError(err) ? { status: 'missing' } : { status: 'unsafe' }; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) return { status: 'unsafe' }; + try { + const real = await fs.realpath(dir); + const expected = path.join(await fs.realpath(checkoutPath), GITNEXUS_DIR); + return path.relative(real, expected) === '' + ? { status: 'contained', dir } + : { status: 'unsafe' }; + } catch { + return { status: 'unsafe' }; + } +}; + /** * Point `/.gitnexus` at the checkout's store slot (#3352 R16). The * directory's other contents — a pre-adoption index — are left untouched. @@ -257,8 +285,12 @@ export const writeSharedStorePointer = async ( checkoutPath: string, layout: Pick, ): Promise => { - const dir = path.join(checkoutPath, GITNEXUS_DIR); - await fs.mkdir(dir, { recursive: true }); + if ((await probePointerDir(checkoutPath)).status === 'missing') { + await fs.mkdir(path.join(checkoutPath, GITNEXUS_DIR), { recursive: true }); + } + const probe = await probePointerDir(checkoutPath); + if (probe.status !== 'contained') return; + const { dir } = probe; await fs.writeFile( path.join(dir, SHARED_STORE_POINTER), `${JSON.stringify({ version: 1, storeKey: layout.key, checkoutSlot: layout.checkoutSlot }, null, 2)}\n`, @@ -271,7 +303,9 @@ export const writeSharedStorePointer = async ( * if it cannot be listed (its contents are then unknown). */ export const removeSharedStorePointer = async (checkoutPath: string): Promise => { - const dir = path.join(checkoutPath, GITNEXUS_DIR); + const probe = await probePointerDir(checkoutPath); + if (probe.status !== 'contained') return; + const { dir } = probe; await fs.rm(path.join(dir, SHARED_STORE_POINTER), { force: true }); const rest = await fs .readdir(dir) @@ -306,7 +340,9 @@ export const findLegacyLocalIndex = async ( storagePath: string, ): Promise => { if (!storeRootOfCheckoutSlot(storagePath)) return null; - const dir = path.join(checkoutPath, GITNEXUS_DIR); + const probe = await probePointerDir(checkoutPath); + if (probe.status !== 'contained') return null; + const { dir } = probe; const entries = (await listDir(dir)).filter((name) => !POINTER_DIR_KEEP.has(name)); if (entries.length === 0) return null; let bytes = 0; @@ -321,6 +357,8 @@ export const removeLegacyLocalIndex = async ( ): Promise => { const legacy = await findLegacyLocalIndex(checkoutPath, storagePath); if (!legacy) return null; + // Sizing walked the whole index; re-check the directory was not swapped meanwhile. + if ((await probePointerDir(checkoutPath)).status !== 'contained') return null; for (const name of legacy.entries) { await fs.rm(path.join(legacy.dir, name), { recursive: true, force: true }); } diff --git a/gitnexus/test/integration/shared-store-clean.test.ts b/gitnexus/test/integration/shared-store-clean.test.ts index ab5e8c682..139021ad5 100644 --- a/gitnexus/test/integration/shared-store-clean.test.ts +++ b/gitnexus/test/integration/shared-store-clean.test.ts @@ -10,10 +10,13 @@ import { type SharedStoreLayout, } from '../../src/storage/shared-store.js'; import { + findLegacyLocalIndex, reclaimAfterSlotRemoval, readGraphCloneKind, reclaimSharedStore, + removeLegacyLocalIndex, removeSharedStorePointer, + writeSharedStorePointer, } from '../../src/storage/shared-store-lifecycle.js'; import { getGlobalDir } from '../../src/storage/global-dir.js'; import { createTempDir } from '../helpers/test-db.js'; @@ -408,6 +411,54 @@ describe('reclaimSharedStore', () => { expect(existsSync(path.join(dir, 'store.json'))).toBe(false); }); + /** `/repo/.gitnexus -> ..`, beside a file that lives outside the checkout. */ + const symlinkedPointerDir = async (): Promise<{ checkout: string; victim: string }> => { + const parent = path.join(tmpHome.dbPath, 'parent'); + const checkout = path.join(parent, 'repo'); + await fs.mkdir(checkout, { recursive: true }); + const victim = path.join(parent, 'a-victim.txt'); + await fs.writeFile(victim, 'keep'); + await fs.symlink('..', path.join(checkout, '.gitnexus'), 'dir'); + return { checkout, victim }; + }; + + it('writes the pointer into a real checkout .gitnexus and removes only the legacy index', async () => { + const checkout = path.join(tmpHome.dbPath, 'checkout'); + await fs.mkdir(checkout); + const slot = await member('wt-000000000000', { repoPath: checkout }); + await writeSharedStorePointer(checkout, layout()); + await fs.writeFile(path.join(checkout, '.gitnexus', 'lbug'), 'old graph'); + expect((await removeLegacyLocalIndex(checkout, slot))?.entries).toEqual(['lbug']); + expect(await fs.readdir(path.join(checkout, '.gitnexus'))).toEqual( + expect.arrayContaining(['store.json', '.gitignore']), + ); + expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(false); + }); + + it('ignores a symlinked checkout .gitnexus when finding or removing a legacy index', async () => { + const { checkout, victim } = await symlinkedPointerDir(); + const slot = await member('wt-000000000000', { repoPath: checkout }); + expect(await findLegacyLocalIndex(checkout, slot)).toBeNull(); + expect(await removeLegacyLocalIndex(checkout, slot)).toBeNull(); + expect(readFileSync(victim, 'utf-8')).toBe('keep'); + }); + + it('writes no pointer through a symlinked checkout .gitnexus', async () => { + const { checkout } = await symlinkedPointerDir(); + await writeSharedStorePointer(checkout, layout()); + expect(existsSync(path.join(path.dirname(checkout), 'store.json'))).toBe(false); + expect(existsSync(path.join(path.dirname(checkout), '.gitignore'))).toBe(false); + }); + + it('removes nothing through a symlinked checkout .gitnexus', async () => { + const { checkout, victim } = await symlinkedPointerDir(); + const outsidePointer = path.join(path.dirname(checkout), 'store.json'); + await fs.writeFile(outsidePointer, '{}'); + await removeSharedStorePointer(checkout); + expect(existsSync(outsidePointer)).toBe(true); + expect(readFileSync(victim, 'utf-8')).toBe('keep'); + }); + it('aborts instead of collecting members when the registry cannot be read', async () => { const slot = await member('wt-000000000000', { repoPath: tmpHome.dbPath }); await fs.writeFile(path.join(tmpHome.dbPath, 'registry.json'), '{not json');