/** * Direct unit tests for the Linux cmdline-first DB-owner scan (#2180). * * These exercise linuxProcScanFindGitNexusServer / hasGitNexusDbLockedByGitNexusServer * against a FAKE /proc tree (GITNEXUS_HOOK_PROC_ROOT) so the three-phase logic * (comm -> cmdline -> fd dev+ino) is asserted deterministically, without * scanning the test host's real /proc. One live e2e at the bottom uses the REAL * /proc to protect the "lbug handle is fd-visible" property the scan relies on. * * The probe is a CJS module; we require it through createRequire and toggle env * per-test. resetModules-style isolation is unnecessary because the only * module-level cache (unixGuardTimeoutCache) is on the macOS/Unix path, which * these Linux tests never reach. */ import { describe, it, expect, afterEach, vi } from 'vitest'; import { createRequire } from 'node:module'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { spawn, spawnSync } from 'child_process'; import { createFakeProcRoot, type FakeProcEntry } from '../utils/hook-test-helpers.js'; const PROBE_PATH = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs'); type Probe = { hasGitNexusDbLockedByGitNexusServer: (dbPath: string, myPid: number) => boolean; linuxProcScanFindGitNexusServer?: (dbPathAbs: string, myPid: number) => string; getCmdlineMaxBytes?: () => number; resolveLinuxProcBudgetMs?: () => number; resolveHookBinary?: (tool: 'lsof' | 'ps') => string; hasMissingHookBinaryOverride?: (tool: 'lsof' | 'ps') => boolean; }; const probe = createRequire(import.meta.url)(PROBE_PATH) as Probe; // The probe now exports linuxProcScanFindGitNexusServer unconditionally (F1 // white-box verdict assertions). Narrow it once here to a non-optional typed // fn so the per-test call sites stay assertion-free; a dedicated test below // pins that the export really is a function. type ScanVerdictFn = (dbPathAbs: string, myPid: number) => string; const scanVerdictFn = probe.linuxProcScanFindGitNexusServer as ScanVerdictFn; const isLinux = process.platform === 'linux'; // ── env scoping helpers ──────────────────────────────────────────── const ENV_KEYS = [ 'GITNEXUS_HOOK_PROC_ROOT', 'GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS', 'GITNEXUS_HOOK_PROC_CMDLINE_MAX', 'GITNEXUS_HOOK_LSOF_PATH', 'GITNEXUS_HOOK_PS_PATH', ] as const; const savedEnv: Record = {}; function setEnv(overrides: Record) { for (const k of ENV_KEYS) { if (!(k in savedEnv)) savedEnv[k] = process.env[k]; } for (const [k, v] of Object.entries(overrides)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } } const cleanups: Array<() => void> = []; afterEach(() => { for (const k of Object.keys(savedEnv)) { const v = savedEnv[k]; if (v === undefined) delete process.env[k]; else process.env[k] = v; delete savedEnv[k]; } while (cleanups.length) { try { cleanups.pop()!(); } catch { /* best-effort */ } } }); /** * Build a temp lbug + a fake /proc root, run the dispatcher with the fake root, * and return the boolean owner verdict. The lbug is the dev+ino the fake fd * symlinks point at, so a holder whose fdTargets include `lbug` is a true owner. */ function runScan( entries: (lbugPath: string) => FakeProcEntry[], env: Record = {}, ): { owned: boolean; lbugPath: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-')); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); const procRoot = createFakeProcRoot(entries(lbugPath)); cleanups.push(() => fs.rmSync(procRoot, { recursive: true, force: true })); setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot, ...env }); const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1); return { owned, lbugPath }; } const GITNEXUS_MCP_ARGV = (script: string) => ['node', script, 'mcp']; // ── Hook binary override: check and lookup agree on trimming (#2543 review) ── // // unixLsofPsFindGitNexusServer calls hasMissingHookBinaryOverride (trims) and // then resolveHookBinary. If the lookup did NOT trim, a padded-but-valid // override such as " /tmp/lsof " passed the missing-check yet fell through to // the built-in/PATH binary. Both helpers must see the same trimmed path. describe('hook binary override trimming (white-box, #2543 review)', () => { const resolveBin = probe.resolveHookBinary as (tool: 'lsof' | 'ps') => string; const missing = probe.hasMissingHookBinaryOverride as (tool: 'lsof' | 'ps') => boolean; function makeFakeBinary(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hookbin-')); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); const bin = path.join(dir, 'fake-tool'); fs.writeFileSync(bin, ''); return bin; } it.each(['lsof', 'ps'] as const)( '%s: whitespace-padded override to an existing file resolves to the trimmed path', (tool) => { const bin = makeFakeBinary(); const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; setEnv({ [envKey]: ` ${bin}\t ` }); expect(missing(tool)).toBe(false); expect(resolveBin(tool)).toBe(bin); }, ); it.each(['', ' '])('empty/whitespace override %j is ignored by both helpers', (value) => { setEnv({ GITNEXUS_HOOK_LSOF_PATH: value }); expect(missing('lsof')).toBe(false); expect(resolveBin('lsof').trim()).not.toBe(''); }); it('missing-path override is reported missing and never returned by the lookup', () => { const absent = path.join(os.tmpdir(), 'gitnexus-hookbin-does-not-exist', 'lsof'); setEnv({ GITNEXUS_HOOK_LSOF_PATH: ` ${absent} ` }); expect(missing('lsof')).toBe(true); expect(resolveBin('lsof')).not.toBe(absent); }); }); // ── Numeric env parsing (white-box, #2183 review) ────────────────────── // // getCmdlineMaxBytes / resolveLinuxProcBudgetMs switched from parseInt(.,10) to // Number() so scientific notation ("16e3") parses as 16000 instead of 16. These // are platform-independent (pure string->number), so they run on every OS, not // just Linux. The load-bearing case is the EMPTY-STRING budget regression guard: // a naive parseInt->Number swap would make a set-but-empty // GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS="" resolve to Number("")===0 => budget 0 => // immediate fail-CLOSED timeout (augment permanently skipped). The added // `&& String(raw).trim()` guard keeps ''/whitespace on the 1200 default. // GITNEXUS_HOOK_TIMEOUT_PATH relative override (#2543 review): the adapters // spawn the returned wrapper with the tool request's `cwd`, so the probe must // hand back an ABSOLUTE path resolved against the directory its existsSync // check and self-test ran in. The resolution is memoized per module instance, // so each case runs in a fresh `node` child whose cwd holds a self-testing // guard script (`-k cmd…` -> exec cmd…). describe.skipIf(process.platform === 'win32')( 'GITNEXUS_HOOK_TIMEOUT_PATH relative override resolves to absolute (#2543 review)', () => { it.each(['./fake-guard', 'fake-guard'])('override %s -> path.resolve(value)', (value) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-relguard-')); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); fs.writeFileSync(path.join(dir, 'fake-guard'), '#!/bin/sh\nshift 3\nexec "$@"\n', { mode: 0o755, }); const script = `const p=require(${JSON.stringify(PROBE_PATH)});` + `process.stdout.write(JSON.stringify({cwd:process.cwd(),guard:p.resolveUnixGuardTimeout()}));`; const child = spawnSync(process.execPath, ['-e', script], { cwd: dir, encoding: 'utf-8', env: { ...process.env, GITNEXUS_HOOK_TIMEOUT_PATH: value }, timeout: 15000, }); expect(child.status).toBe(0); const out = JSON.parse(child.stdout) as { cwd: string; guard: string | null }; expect(out.guard).toBe(path.resolve(out.cwd, value)); expect(path.isAbsolute(out.guard ?? '')).toBe(true); }); }, ); describe('numeric env parsing (white-box, #2183 review)', () => { const budget = probe.resolveLinuxProcBudgetMs as () => number; const cmdlineMax = probe.getCmdlineMaxBytes as () => number; it('exports the two parse helpers as functions', () => { expect(typeof probe.resolveLinuxProcBudgetMs).toBe('function'); expect(typeof probe.getCmdlineMaxBytes).toBe('function'); }); it('budget: "16e3" parses as 16000 (scientific notation), not 16', () => { // parseInt('16e3',10) === 16 (stops at 'e'); Number('16e3') === 16000. setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '16e3' }); expect(budget()).toBe(16000); }); it('budget: set-but-empty "" and whitespace fall back to 1200, NOT 0 (regression guard)', () => { // The deepening catch: without the `&& String(raw).trim()` guard these would // be Number('')===0 => an immediate fail-closed timeout on every hook call. for (const empty of ['', ' ', '\t']) { setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: empty }); expect(budget()).toBe(1200); } }); it('budget: "0" still parses to 0 (the deliberate #2180 immediate-timeout vector)', () => { setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '0' }); expect(budget()).toBe(0); }); it('budget: trailing garbage "123abc" and unset fall back to 1200', () => { setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '123abc' }); expect(budget()).toBe(1200); setEnv({ GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: undefined }); expect(budget()).toBe(1200); }); it('cmdline max: "8e3" parses as 8000 (>= floor); "2e3" (=2000, below floor) and ""/unset -> 16384', () => { setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '8e3' }); expect(cmdlineMax()).toBe(8000); setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '2e3' }); expect(cmdlineMax()).toBe(16384); setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: '' }); expect(cmdlineMax()).toBe(16384); setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: undefined }); expect(cmdlineMax()).toBe(16384); }); // #2543 review: an oversized-but-finite override used to reach // Buffer.allocUnsafe unchanged; the allocation threw, readLinuxCmdline's catch // returned '' (non-candidate) and a real owner was silently missed. Oversized // integers now clamp to the 256 KiB ceiling (the escalation's HARD_CEIL). it.each([ ['262145', 262144], [String(2 ** 40), 262144], [String(Number.MAX_SAFE_INTEGER), 262144], ['1e300', 262144], ])('cmdline max: oversized %s clamps to the 256 KiB ceiling', (raw, expected) => { setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: raw }); expect(cmdlineMax()).toBe(expected); }); it.each(['4096', '8000', '16384', '262144'])( 'cmdline max: in-range integer %s is returned unchanged', (raw) => { setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: raw }); expect(cmdlineMax()).toBe(Number(raw)); }, ); it.each(['Infinity', '-Infinity', 'NaN', '8192.5', '8abc', '4095', '-1'])( 'cmdline max: non-integer / garbage / below-floor %s falls back to the 16384 default', (raw) => { setEnv({ GITNEXUS_HOOK_PROC_CMDLINE_MAX: raw }); expect(cmdlineMax()).toBe(16384); }, ); }); describe.skipIf(!isLinux)('Linux cmdline-first DB-owner scan (#2180)', () => { // ── D1: three-phase correctness ────────────────────────────────── it('owned: a gitnexus mcp process holding the lbug fd is detected', () => { const { owned } = runScan((lbug) => [ { pid: 4242, comm: 'MainThread', // real gitnexus servers report this on modern Node cmdline: GITNEXUS_MCP_ARGV('/opt/app/node_modules/gitnexus/dist/cli/index.js'), fdTargets: ['/dev/null', lbug], }, ]); expect(owned).toBe(true); }); it('owned: an oversized GITNEXUS_HOOK_PROC_CMDLINE_MAX (2**40) does not blind the scan (#2543 review)', () => { // Pre-fix, the 2**40 cap reached Buffer.allocUnsafe, threw, and the catch // mapped it to '' (non-candidate) => a real owner silently reported false. const { owned } = runScan( (lbug) => [ { pid: 4243, comm: 'MainThread', cmdline: GITNEXUS_MCP_ARGV('/opt/app/node_modules/gitnexus/dist/cli/index.js'), fdTargets: [lbug], }, ], { GITNEXUS_HOOK_PROC_CMDLINE_MAX: String(2 ** 40) }, ); expect(owned).toBe(true); }); it('not-owned: a node process that is not a gitnexus server (even holding the lbug) is ignored', () => { const { owned } = runScan((lbug) => [ { pid: 5555, comm: 'node', cmdline: ['node', '/some/app/server.js'], fdTargets: [lbug], // holds the fd, but cmdline is not a gitnexus server }, ]); expect(owned).toBe(false); }); it('not-owned: a gitnexus mcp process that does NOT hold the lbug fd is not an owner', () => { const { owned } = runScan((lbug) => [ { pid: 6001, comm: 'MainThread', cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'), fdTargets: ['/dev/null'], // server, but holds some OTHER fd, not this lbug }, // a decoy that holds the lbug but is not a server { pid: 6002, comm: 'vim', cmdline: ['vim', '/etc/hosts'], fdTargets: [lbug], }, ]); expect(owned).toBe(false); }); it('Phase 0 trap: cmdline LOOKS like gitnexus but comm is non-candidate → filtered out before fd check', () => { // The fd symlink points at the lbug, so if Phase 0 did NOT filter on comm // the cmdline prefilter would match and the fd check would say "owned". // Because comm is a non-candidate ('postgres'), Phase 0 drops it first. const { owned } = runScan((lbug) => [ { pid: 7007, comm: 'postgres', // not in COMM_CANDIDATES, not a prefix of any cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'), fdTargets: [lbug], }, ]); expect(owned).toBe(false); }); it('Phase 0 truncation-safe: a 15-char-truncated comm prefix of a candidate still matches', () => { // Kernel comm cap is 15 visible chars; a candidate name truncated to a // prefix must NOT be dropped. We use a comm that is a strict prefix of a // whitelist entry ('MainThr' ⊂ 'MainThread'). const { owned } = runScan((lbug) => [ { pid: 8008, comm: 'MainThr', cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'), fdTargets: [lbug], }, ]); expect(owned).toBe(true); }); // ── D2: budget / timeout → fail-closed ─────────────────────────── it('budget <= 0 → immediate timeout → dispatcher fails CLOSED (owner=true)', () => { // Even though NO process is a gitnexus server, budget 0 yields 'timeout' // which the dispatcher maps to true (self-throttle). This also pins the // #2180 budget-parse fix: "0" must NOT fall back to 1200. const { owned } = runScan( () => [{ pid: 9001, comm: 'bash', cmdline: ['bash'], fdTargets: [] }], { GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '0' }, ); expect(owned).toBe(true); }); it('budget "0" is not silently treated as 1200 (regression for the parse bug)', () => { // With a healthy non-owner fake proc and budget '0', the OLD code (which // coerced "0" to 1200) would have completed the scan and returned // not-owned (false). The fixed code returns timeout → true. const { owned } = runScan( () => [{ pid: 9100, comm: 'node', cmdline: ['node', '/app/x.js'], fdTargets: [] }], { GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '0' }, ); expect(owned).toBe(true); }); it('a generous budget over a non-owner tree completes and returns not-owned', () => { const { owned } = runScan( () => [ { pid: 9200, comm: 'node', cmdline: ['node', '/app/x.js'], fdTargets: [] }, { pid: 9201, comm: 'bash', cmdline: ['bash', '-l'], fdTargets: [] }, ], { GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '5000' }, ); expect(owned).toBe(false); }); // ── D3: 4 KB+ cmdline cap — escalation must really iterate (F2) ──── // // The cmdline shape here is deliberate (Codex): the `gitnexus` token sits in // the SECOND argv (a SHORT node_modules/gitnexus path, well inside the first // 4 KB chunk); a ~9 KB // pad argv then pushes the trailing `mcp` mode token PAST 4096, so the first // 4 KB chunk has gitnexus-but-no-mode and the loop MUST escalate to a second // read to find `mcp`. Setting GITNEXUS_HOOK_PROC_CMDLINE_MAX=4096 makes the // chunk size 4 KB so escalation actually happens (the 16 KB default would read // the whole line in one shot and the loop would never iterate — the old test's // latent no-op). // gitnexus token early (well under 4 KB), mode token forced past 4 KB by pad. const GITNEXUS_SHORT = '/nm/node_modules/gitnexus/dist/cli/index.js'; const PAD_PAST_4K = 'x'.repeat(9000); // pushes the trailing `mcp` well past 4096 it('owned even when the mode token sits far past 4 KB → escalation iterates and finds it', () => { const readSyncSpy = vi.spyOn(fs, 'readSync'); cleanups.push(() => readSyncSpy.mockRestore()); const { owned } = runScan( (lbug) => [ { pid: 10001, comm: 'MainThread', // node | SHORT gitnexus path (<4KB) | 9KB pad | mcp → mcp lands >4096 cmdline: ['node', GITNEXUS_SHORT, PAD_PAST_4K, 'mcp'], fdTargets: [lbug], }, ], { GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' }, ); expect(owned).toBe(true); // White-box proof the escalation actually re-read: with a 4 KB chunk over a // >4 KB cmdline, readSync must have been called more than once for this pid. // (A "just bump the cap" pseudo-fix that read everything in one go would // leave this at 1 and fail.) expect(readSyncSpy.mock.calls.length).toBeGreaterThan(1); }); it('discrimination: same gitnexus cmdline but mode token past HARD_CEIL → not-owned', () => { // Negative control proving the escalation has a real upper bound (HARD_CEIL // = 256 KiB) and the positive test above is not just "always escalates". The // gitnexus token is early so escalation runs, but a >256 KiB pad keeps the // `mcp` token beyond the ceiling, so the bounded read stops before reaching // it → isGitNexusServerCommand sees no mode token → not a candidate → // not-owned. (A broken "escalate forever" impl would wrongly read to `mcp` // and report owned, failing this assertion.) const padPastCeil = 'x'.repeat(300000); // > HARD_CEIL (262144) const { owned } = runScan( (lbug) => [ { pid: 10002, comm: 'MainThread', cmdline: ['node', GITNEXUS_SHORT, padPastCeil, 'mcp'], fdTargets: [lbug], }, ], { GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' }, ); expect(owned).toBe(false); }); // ── D3c: a partial read stops early only when DECIDED (#2543 review) ── // // The escalation loop used to stop as soon as the chunk held a mode word, or // as soon as it lacked the gitnexus token. Neither decides ownership: a Node // preload flag can put `mcp` in the first chunk while the gitnexus CLI path // lies past it, and a long interpreter prefix can push BOTH tokens past it. // Either way the old loop returned an incomplete cmdline, Phase 1 rejected it, // and Phase 2 never compared the holder's fd (a fail-OPEN owner miss, #1492). // Now only "both tokens present", EOF, the ceiling, or the budget stop it. it('owned: a mode word in the first chunk (`--require mcp`) with the gitnexus path past it', () => { const { owned } = runScan( (lbug) => [ { pid: 10050, comm: 'MainThread', // node | --require mcp | 9KB pad | gitnexus path (>4096) | serve cmdline: ['node', '--require', 'mcp', PAD_PAST_4K, GITNEXUS_SHORT, 'serve'], fdTargets: [lbug], }, ], { GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' }, ); expect(owned).toBe(true); }); it('owned: neither token in the first chunk, both past it (long interpreter prefix)', () => { const { owned } = runScan( (lbug) => [ { pid: 10051, comm: 'MainThread', cmdline: ['node', PAD_PAST_4K, GITNEXUS_SHORT, 'mcp'], fdTargets: [lbug], }, ], { GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' }, ); expect(owned).toBe(true); }); it('not-owned: a mode word early but no gitnexus token anywhere is read to EOF and rejected', () => { const { owned } = runScan( (lbug) => [ { pid: 10052, comm: 'node', cmdline: ['node', '--require', 'mcp', PAD_PAST_4K, '/app/other-server.js', 'serve'], fdTargets: [lbug], }, ], { GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096' }, ); expect(owned).toBe(false); }); it('does not over-read: a giant non-gitnexus cmdline is bounded and yields not-owned', () => { const giant = 'x'.repeat(500000); // 500 KB single arg, no gitnexus token const { owned } = runScan((lbug) => [ { pid: 10100, comm: 'node', cmdline: ['node', `/app/${giant}.js`], fdTargets: [lbug], }, ]); expect(owned).toBe(false); }); // ── D3b: cmdline escalation respects the scan budget (F3) ───────── // // A single pathological candidate whose `mcp` token sits far past the chunk // size used to be able to read up to HARD_CEIL (256 KiB) inside one // readLinuxCmdline call before the scan-level budget was re-checked. F3 wires // outOfBudget into the escalation loop: when the deadline trips mid-read it // returns the CMDLINE_TIMEOUT *Symbol* (NOT '' — '' would flow through // isGitNexusServerCommand as a non-candidate and silently drop a possible // owner, a fail-OPEN), and the Phase 1 caller maps that Symbol to the 'timeout' // verdict (fail-CLOSED). We drive the deadline deterministically by advancing // a Date.now spy after the first escalation read. it('escalation that exceeds the budget mid-read → verdict timeout (sentinel, not silent drop)', () => { const scan = scanVerdictFn; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-f3-')); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); // gitnexus token early (escalation will start), mode token pushed past 4 KB // so a SECOND read is required — between those reads we trip the clock. const procRoot = createFakeProcRoot([ { pid: 10200, comm: 'MainThread', cmdline: ['node', GITNEXUS_SHORT, 'x'.repeat(9000), 'mcp'], fdTargets: [lbugPath], }, ]); cleanups.push(() => fs.rmSync(procRoot, { recursive: true, force: true })); setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot, GITNEXUS_HOOK_PROC_CMDLINE_MAX: '4096', GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1000', // positive, so the scan starts }); // Deterministic clock: the scan captures `start` (call #1) and runs its // Phase-0/1 entry budget checks in-budget; once the escalation loop is under // way we jump Date.now() past the 1000 ms budget so the loop's in-read // outOfBudget() returns the CMDLINE_TIMEOUT sentinel. The threshold (>4) is // chosen so the early checks (start capture, per-entry + Phase-1 pre-read // checks) stay at base and only the escalation's mid-loop check trips. const base = Date.now(); let nowCalls = 0; const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { nowCalls += 1; return nowCalls > 4 ? base + 5000 : base; }); cleanups.push(() => nowSpy.mockRestore()); const verdict = scan(lbugPath, 1); // The load-bearing assertion: a mid-escalation budget trip yields the // 'timeout' verdict (via the Symbol sentinel) — NOT a silent non-candidate // drop (which would be 'not-owned' here and a fail-OPEN if this were a real // cross-budget owner). expect(verdict).toBe('timeout'); nowSpy.mockRestore(); }); // ── D5: GITNEXUS_HOOK_PROC_ROOT is honored ONLY under a test runner (F4) ── // // Production hooks run as `node .cjs` with neither VITEST nor // NODE_ENV=test set; vitest injects both into every worker (verified). F4 // gates getProcRoot() on that signal so a production env that leaked // GITNEXUS_HOOK_PROC_ROOT (pointing at an empty/bad tree) cannot turn Linux // owner detection OFF (no pids -> not-owned -> fail-OPEN, the #1492 class). // These tests run inside vitest, so the gate is OPEN and injection works (the // entire fake-procfs suite above already depends on that). Here we prove the // gate is load-bearing: with the test signals stripped, the override is // ignored and the scan falls back to the real /proc (so our fake lbug is NOT // found there -> not-owned), and with them present the override is honored. it('honors GITNEXUS_HOOK_PROC_ROOT under the vitest test signal (gate open)', () => { // Sanity: in this vitest worker VITEST/NODE_ENV are set, so the fake root is // honored and a fake owner is detected — same mechanism the whole suite uses. const { owned } = runScan((lbug) => [ { pid: 10300, comm: 'MainThread', cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'), fdTargets: [lbug], }, ]); expect(owned).toBe(true); }); it('ignores GITNEXUS_HOOK_PROC_ROOT when the test signal is absent (gate closed → real /proc)', () => { // Strip BOTH test signals so getProcRoot() falls back to /proc even though // GITNEXUS_HOOK_PROC_ROOT points at our fake tree. The fake lbug is not an // fd under the real /proc, so the scan returns not-owned: proof the override // is inert in a non-test (production-shaped) context. const savedVitest = process.env.VITEST; const savedNodeEnv = process.env.NODE_ENV; cleanups.push(() => { if (savedVitest === undefined) delete process.env.VITEST; else process.env.VITEST = savedVitest; if (savedNodeEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = savedNodeEnv; }); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-f4-')); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); const procRoot = createFakeProcRoot([ { pid: 10400, comm: 'MainThread', cmdline: GITNEXUS_MCP_ARGV('/x/node_modules/gitnexus/dist/cli/index.js'), fdTargets: [lbugPath], }, ]); cleanups.push(() => fs.rmSync(procRoot, { recursive: true, force: true })); setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot }); // Now drop the test signals — must happen AFTER setEnv so the gate sees them gone. delete process.env.VITEST; delete process.env.NODE_ENV; const verdict = scanVerdictFn(lbugPath, 1); // Gate closed -> getProcRoot() returns '/proc'; our fake lbug fd is not in // the real /proc, so no owner is found. expect(verdict).toBe('not-owned'); expect(probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1)).toBe(false); }); // ── D4: unreadable candidate fd dir → honest tri-state verdict (F1) ── // // /proc//fd is owner-only (mode 0500). A cross-user/root `gitnexus mcp` // serving a DIFFERENT repo clears Phase 0+1 (cmdline matches) and then EACCES // here — but its dev+ino was never compared against THIS lbug. The OLD code // returned 'owned' for every non-ENOENT readdir error, falsely claiming // ownership and permanently suppressing augment for a repo that process does // not lock. F1 splits the failure shapes: // - EACCES / EPERM -> 'timeout' (unverifiable; fail-closed HONESTLY) // - EIO / ESTALE -> 'timeout' (transient I/O; fail-closed) // - ENOTDIR -> continue (not a real fd dir; treat as non-owner) // - any other errno -> 'timeout' (EMFILE/ENFILE/ENOMEM/…: inconclusive) // The dispatcher collapses owned+timeout to boolean true, so these assert the // exported tri-state verdict directly — a boolean check could not tell the F1 // fix from the old bug. it('exports linuxProcScanFindGitNexusServer for white-box verdict assertions', () => { expect(typeof probe.linuxProcScanFindGitNexusServer).toBe('function'); }); it('candidate fd dir EACCES → verdict timeout (honest fail-closed, NOT owned)', () => { if (process.getuid && process.getuid() === 0) { // root bypasses chmod 000, so a real EACCES is not reproducible on this // host. This disk-based test no-ops under root; the uid-agnostic spy // tests below cover every F1 errno branch (EACCES/EPERM/EIO/ESTALE/ // ENOTDIR) regardless of who runs the suite. return; } const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-eacces-')); cleanups.push(() => { try { fs.chmodSync(path.join(dir, 'proc', '11001', 'fd'), 0o755); } catch { /* ignore */ } fs.rmSync(dir, { recursive: true, force: true }); }); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); const procRoot = path.join(dir, 'proc'); const fdDir = path.join(procRoot, '11001', 'fd'); fs.mkdirSync(fdDir, { recursive: true }); fs.writeFileSync(path.join(procRoot, '11001', 'comm'), 'MainThread\n'); fs.writeFileSync( path.join(procRoot, '11001', 'cmdline'), ['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0', ); fs.chmodSync(fdDir, 0o000); // EACCES on readdir setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot }); // White-box: assert the verdict is 'timeout' (NOT 'owned' — the F1 point). const verdict = scanVerdictFn(lbugPath, 1); expect(verdict).toBe('timeout'); // And the dispatcher still fails closed (boolean true) on that timeout. const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1); expect(owned).toBe(true); }); // uid-agnostic coverage of every F1 fd-readdir errno branch. chmod 000 yields // no EACCES for root, so the disk-based tests above no-op there — these spy // fs.readdirSync to throw a chosen errno only for the candidate's fd dir (the // procRoot enumeration calls through), pinning the F1 split in CI regardless // of the runner's uid. for (const { code, expected } of [ { code: 'EACCES', expected: 'timeout' }, { code: 'EPERM', expected: 'timeout' }, { code: 'EIO', expected: 'timeout' }, { code: 'ESTALE', expected: 'timeout' }, { code: 'ENOTDIR', expected: 'not-owned' }, // Resource/interruption failures say nothing about ownership of an // already-identified server candidate: fail closed, never not-owned. { code: 'EMFILE', expected: 'timeout' }, { code: 'ENFILE', expected: 'timeout' }, { code: 'ENOMEM', expected: 'timeout' }, { code: 'EINTR', expected: 'timeout' }, ] as const) { it(`candidate fd readdir ${code} → verdict ${expected} (uid-agnostic spy)`, () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-fderr-')); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); const procRoot = path.join(dir, 'proc'); const fdDir = path.join(procRoot, '11001', 'fd'); fs.mkdirSync(fdDir, { recursive: true }); fs.writeFileSync(path.join(procRoot, '11001', 'comm'), 'MainThread\n'); fs.writeFileSync( path.join(procRoot, '11001', 'cmdline'), ['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0', ); setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot }); const realReaddir = fs.readdirSync.bind(fs); const spy = vi.spyOn(fs, 'readdirSync').mockImplementation((p, ...rest) => { if (typeof p === 'string' && p.endsWith(`${path.sep}fd`)) { const err = new Error(`mock ${code}`) as NodeJS.ErrnoException; err.code = code; throw err; } return (realReaddir as (...a: unknown[]) => unknown)(p, ...rest); }); cleanups.push(() => spy.mockRestore()); // White-box: assert the exported tri-state verdict directly (the // dispatcher would collapse timeout+owned to the same boolean). expect(scanVerdictFn(lbugPath, 1)).toBe(expected); spy.mockRestore(); }); } it('candidate fd path is a FILE (ENOTDIR) → treated as non-owner → not-owned', () => { // ENOTDIR means the fd entry is not a real /proc//fd directory at all, // so it is not a plausible live owner. The candidate is skipped (continue); // with no other candidate the scan ends not-owned (the OLD code wrongly // returned 'owned' here). Runs on every OS incl. root. const { verdict, owned } = runScanFdEnotdir(); expect(verdict).toBe('not-owned'); expect(owned).toBe(false); }); it('EACCES candidate then a REAL owner later → still detects the real owner', () => { // Regression guard for the F1 continue/return choice: an EACCES candidate // must NOT short-circuit the scan in a way that hides a genuine owner. Here // the EACCES dir yields timeout BEFORE reaching the true owner — timeout is // the protective (fail-closed) verdict, so dispatcher returns true either // way. (Ordering in /proc readdir is numeric-string; 11001 < 11050.) if (process.getuid && process.getuid() === 0) return; // EACCES needs non-root const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-mixed-')); cleanups.push(() => { try { fs.chmodSync(path.join(dir, 'proc', '11001', 'fd'), 0o755); } catch { /* ignore */ } fs.rmSync(dir, { recursive: true, force: true }); }); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); const procRoot = path.join(dir, 'proc'); // Candidate A: EACCES fd dir. const fdDirA = path.join(procRoot, '11001', 'fd'); fs.mkdirSync(fdDirA, { recursive: true }); fs.writeFileSync(path.join(procRoot, '11001', 'comm'), 'MainThread\n'); fs.writeFileSync( path.join(procRoot, '11001', 'cmdline'), ['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0', ); fs.chmodSync(fdDirA, 0o000); setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot }); const verdict = scanVerdictFn(lbugPath, 1); // EACCES is hit first and fails closed (timeout) — the protective outcome. expect(verdict).toBe('timeout'); expect(probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1)).toBe(true); }); }); // Helper for the ENOTDIR branch: fd is a FILE not a dir, so readdir throws // ENOTDIR. F1: this candidate is treated as a non-owner (continue) → not-owned. function runScanFdEnotdir(): { verdict: string; owned: boolean } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-probe-enotdir-')); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); const procRoot = path.join(dir, 'proc'); const pidDir = path.join(procRoot, '11002'); fs.mkdirSync(pidDir, { recursive: true }); fs.writeFileSync(path.join(pidDir, 'comm'), 'MainThread\n'); fs.writeFileSync( path.join(pidDir, 'cmdline'), ['node', '/x/node_modules/gitnexus/dist/cli/index.js', 'mcp'].join('\0') + '\0', ); fs.writeFileSync(path.join(pidDir, 'fd'), 'not a dir'); // readdir -> ENOTDIR setEnv({ GITNEXUS_HOOK_PROC_ROOT: procRoot }); const verdict = scanVerdictFn(lbugPath, 1); const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, 1); return { verdict, owned }; } // ── D6: live e2e against the REAL /proc ───────────────────────────── // // Protects the load-bearing assumption that a real lbug handle is fd-visible in // /proc//fd (a @ladybugdb/core property; a future move to mmap-only would // silently regress #1492 with no other test going red). We spawn a child that // opens an fd on a real temp lbug AND wears a gitnexus-mcp cmdline, then assert // the scan reports owned. Crucially we assert against OUR holder's identity, not // "any owner" — this host runs background gitnexus servers, so a bare // truthiness check could be a false positive. describe.skipIf(!isLinux)('Linux DB-owner scan — live /proc e2e (#2180)', () => { it('detects a real fd-visible gitnexus-mcp-shaped lbug holder', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-e2e-')); const lbugPath = path.join(dir, 'lbug'); fs.writeFileSync(lbugPath, ''); // Give the holder a gitnexus-server cmdline by running it from a // node_modules/gitnexus/dist/cli/index.js path with an `mcp` arg. const scriptDir = path.join(dir, 'node_modules', 'gitnexus', 'dist', 'cli'); fs.mkdirSync(scriptDir, { recursive: true }); const script = path.join(scriptDir, 'index.js'); const pidFile = path.join(dir, 'holder.pid'); fs.writeFileSync( script, `const fs=require('fs');` + `const fd=fs.openSync(${JSON.stringify(lbugPath)},'r');` + `fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));` + `process.on('SIGTERM',()=>{try{fs.closeSync(fd);}catch{}process.exit(0);});` + `setInterval(()=>{},1<<30);`, ); const holder = spawn(process.execPath, [script, 'mcp'], { stdio: 'ignore' }); try { // Wait for the holder to report ready (pid file written). Widened to ~10s // (was 5s): a loaded CI runner can be slow to spawn the child, and this is // the one genuine false-FAIL path in the e2e (the budget timeout below // merely hollows the assertion rather than failing it). let holderPid = 0; for (let i = 0; i < 400; i++) { try { const raw = fs.readFileSync(pidFile, 'utf8').trim(); if (raw) { holderPid = Number.parseInt(raw, 10); break; } } catch { /* not ready yet */ } await new Promise((r) => setTimeout(r, 25)); } expect(holderPid).toBeGreaterThan(0); // Confirm the holder really is fd-visible (the property under test). const fdDir = `/proc/${holderPid}/fd`; const targetStat = fs.statSync(lbugPath); const fdVisible = fs.readdirSync(fdDir).some((fd) => { try { const st = fs.statSync(path.join(fdDir, fd)); return st.dev === targetStat.dev && st.ino === targetStat.ino; } catch { return false; } }); expect(fdVisible).toBe(true); // Real /proc, generous explicit budget. Clear PROC_ROOT (-> real /proc) // and raise the scan budget via setEnv so the module afterEach restores // BOTH (no raw process.env mutation leaking to sibling tests). The // generous budget is load-bearing: this dispatcher maps a budget 'timeout' // to owned=TRUE, so on a busy host the default 1200ms could be exhausted // before reaching the holder and the assertion would still pass for the // WRONG reason (a hollow timeout, not real fd-visible detection). 10s // keeps the assertion honest. Use a PID we are NOT so the holder is not // excluded, and assert owned for OUR lbug specifically. setEnv({ GITNEXUS_HOOK_PROC_ROOT: undefined, GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '10000', }); const t0 = Date.now(); const owned = probe.hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid); const ms = Date.now() - t0; expect(owned).toBe(true); // Coarse regression guard against the old O(procs×fds)+lsof path (~1.2s+). // The bound sits ABOVE the 10s budget so a legitimately-slow-but-correct // scan can't trip it — a regression guard, not a tight perf SLA. expect(ms).toBeLessThan(15000); } finally { try { holder.kill('SIGKILL'); } catch { /* ignore */ } fs.rmSync(dir, { recursive: true, force: true }); } }, 40000); });