diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index eadfa854b..6fb553840 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -52,6 +52,7 @@ import { readSync, closeSync, rmSync, + realpathSync, } from "fs"; import { join, basename, dirname } from "path"; import { execFileSync, spawnSync, spawn, type ChildProcess } from "child_process"; @@ -1424,13 +1425,32 @@ function runGbrainImport( // still reporting `written: N` from the staged count. Silent data loss // on every run. A working run logs `import.collect_files done ... files=N` // with N > 0 and takes minutes, not seconds. - const child = spawnGbrainAsync([ - "import", - stagingDir, - "--no-embed", - "--include-gitignored", - "--json", - ]); + // + // GIT_CEILING_DIRECTORIES is the second layer of the same #2144 defense: + // it stops git's upward repo discovery at the staging dir's parent, so a + // git-enumerating collector fails cleanly out of the git fast path and + // falls back to its plain FS walk even on gbrain builds whose flag + // semantics drift. The ceiling must be the REAL path — git compares + // canonicalized directories during discovery, and a staging dir reached + // through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME) + // otherwise never matches the ceiling entry. Scoped to this one child; + // no on-disk state, staging-guard/resume contracts untouched. + let ceiling: string; + try { + ceiling = realpathSync(dirname(stagingDir)); + } catch { + ceiling = dirname(stagingDir); // staging parent vanished mid-run; spawn will fail loudly anyway + } + const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_CEILING_DIRECTORIES: process.env.GIT_CEILING_DIRECTORIES + ? `${ceiling}:${process.env.GIT_CEILING_DIRECTORIES}` + : ceiling, + }; + const child = spawnGbrainAsync( + ["import", stagingDir, "--no-embed", "--include-gitignored", "--json"], + { baseEnv }, + ); _activeImportChild = child; let stdout = ""; let stderr = ""; diff --git a/test/memory-ingest-include-gitignored.test.ts b/test/memory-ingest-include-gitignored.test.ts index 89245530d..6868912c9 100644 --- a/test/memory-ingest-include-gitignored.test.ts +++ b/test/memory-ingest-include-gitignored.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect } from "bun:test"; import { execFileSync } from "child_process"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { readFileSync } from "fs"; @@ -50,6 +50,63 @@ describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore expect(call![0]).toContain("--include-gitignored"); }); + it("sets a realpath'd GIT_CEILING_DIRECTORIES on the import child (defense-in-depth)", () => { + const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8")); + // Second #2144 layer: the ceiling env must be built from the staging + // dir's REAL parent path and merged into the spawn's baseEnv, so a + // git-enumerating collector fails out of the git fast path even when + // the flag's semantics drift, and symlinked staging paths still match. + expect(stripped).toContain("GIT_CEILING_DIRECTORIES"); + expect(stripped).toMatch(/realpathSync\(dirname\(stagingDir\)\)/); + const call = stripped.match(/spawnGbrainAsync\(\s*\[[^\]]*"import"[^\]]*\]\s*,\s*\{\s*baseEnv\s*\}/s); + expect(call).not.toBeNull(); + }); + + it("proves the ceiling stops git discovery from the staging dir — including through a symlink", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-ceiling-")); + try { + const git = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => + execFileSync("git", args, { + cwd, + encoding: "utf-8", + env: { ...process.env, ...env }, + }); + // ~/.gstack shape: a git repo whose root ignores everything, with the + // staging dir as a direct child. + const home = join(dir, "gstack-home"); + mkdirSync(home, { recursive: true }); + git(["init", "-q", "."], home); + writeFileSync(join(home, ".gitignore"), "*\n", "utf-8"); + const staging = join(home, ".staging-ingest-12345-1700000000000"); + mkdirSync(staging, { recursive: true }); + + // Without a ceiling: discovery from the staging dir finds the repo — + // this is the git fast path that collects zero files. + const found = git(["rev-parse", "--show-toplevel"], staging).trim(); + expect(realpathSync(found)).toBe(realpathSync(home)); + + // With the ceiling at the staging dir's REAL parent: discovery fails, + // which is exactly what pushes a collector onto its plain FS walk. + const ceiling = realpathSync(home); + expect(() => + git(["rev-parse", "--show-toplevel"], staging, { GIT_CEILING_DIRECTORIES: ceiling }), + ).toThrow(); + + // Symlink variant (the OV4 trap): reach the same staging dir through a + // symlinked path. A realpath'd ceiling still stops discovery. + const linked = join(dir, "linked-home"); + symlinkSync(home, linked); + const stagingViaLink = join(linked, ".staging-ingest-12345-1700000000000"); + expect(() => + git(["rev-parse", "--show-toplevel"], stagingViaLink, { + GIT_CEILING_DIRECTORIES: ceiling, + }), + ).toThrow(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("demonstrates the collision: an ignore-everything root hides staged pages", () => { const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-gitignore-")); try {