fix(storage): never follow a symlinked checkout .gitnexus (#3374)

A checkout whose `.gitnexus` is a symlink (e.g. `.gitnexus -> ..`) made
the shared-store pointer helpers operate on whatever it pointed at:
findLegacyLocalIndex listed the target as a "legacy index", so
`clean --local-index --force` recursively deleted the checkout's
siblings; writeSharedStorePointer wrote store.json/.gitignore there; and
removeSharedStorePointer deleted store.json there and could rm -r the
target directory.

All four now go through probePointerDir, which lstats `.gitnexus` and
accepts it only when it is a real directory whose realpath is
realpath(checkout)/.gitnexus (mirroring stale-branch-slots.ts). Anything
else is left untouched: no legacy index is reported or removed, and no
pointer is written or removed. removeLegacyLocalIndex re-probes after
sizing, just before its delete loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 06:38:26 +00:00
parent 6ad8d7d389
commit 416af2a8f6
2 changed files with 94 additions and 5 deletions

View file

@ -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 `<checkout>/.gitnexus`; everything else there is legacy. */
const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore', 'run.cjs']);
/**
* Whether `<checkout>/.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<ReturnType<typeof fs.lstat>>;
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 `<checkout>/.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<SharedStoreLayout, 'key' | 'checkoutSlot'>,
): Promise<void> => {
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<void> => {
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<LegacyLocalIndex | null> => {
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<LegacyLocalIndex | null> => {
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 });
}

View file

@ -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);
});
/** `<parent>/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');