fix(cso): record a nested repository as an explicit snapshot exclusion

An untracked nested repository makes gstack-cso start fail with
UNSAFE_PATH. Git lists it as one entry with a trailing slash, so
relativePath() rejected the empty segment and aborted the run. A
registered submodule gitlink already failed one step later for the same
reason: it is a directory, and the capture loop only reads regular
files.

Accept the boundary under its own name and record it like a host
dependency tree: one manifest entry, originalHash not-read, an
excluded: transformation that discloses the omission in the report's
snapshot-inputs coverage, no payload copied, and an identity check
during capture so a swapped or symlinked boundary fails closed. Nested
repository contents are never audited with the outer repository because
Git never descends into them.
This commit is contained in:
Chao Chen authored and garrytan committed 2026-10-19 18:30:09 +00:00
1 parent 9665621d18
commit c03461a306
2 files changed
+51 -4

No files matched your search

+48 -3
View File
@@ -68,6 +68,9 @@ export function sourceCapMessage(bytes: number, files: number): string {
}
/** Transformation recorded for a source path outside the start --paths prefixes; its payload is never copied or assessed. */
export const OUTSIDE_AUDIT_PATHS = 'withheld: outside the --paths audit scope';
/** Transformation recorded for a repository boundary Git listed as one entry: an untracked nested repository or a registered submodule gitlink. */
export const NESTED_REPOSITORY =
'excluded: nested Git repository; Git listed it as a single boundary entry and never descends into it, so its own source and history are not audited with this repository';
export function inAuditPaths(path: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
@@ -517,7 +520,12 @@ async function paths(
for (const value of data.split('\0')) {
admission.time();
if (!value) continue;
seen.add(relativePath(value));
// `ls-files --others` reports a repository boundary as one entry with a
// trailing slash: an untracked nested repository that Git refuses to
// descend into. The path itself is the boundary, exactly like a
// registered submodule's gitlink, so record it under its own name
// instead of rejecting the whole run.
seen.add(relativePath(value.replace(/\/$/, '')));
admission.count(seen.size);
}
const result = [...seen].sort();
@@ -643,6 +651,7 @@ export async function capture(
gitHashes = new Map<string, string>(),
gitModes = new Map<string, string>(),
sensitiveEvidence: any[] = [],
boundaries = new Map<string, fs.Stats>(),
absentPaths = new Set<string>();
let total = 0;
guard();
@@ -690,15 +699,35 @@ export async function capture(
};
for (const path of list) {
guard();
let size: number;
let source: fs.Stats;
try {
size = fs.lstatSync(join(repo, path)).size;
source = fs.lstatSync(join(repo, path));
} catch (error: any) {
if (error?.code === 'ENOENT') {
absentPaths.add(path);
continue;
}
throw error;
}
const size = source.size;
// A directory in Git's inventory is a repository boundary, not source:
// an untracked nested repository (listed with a trailing slash) or a
// registered submodule's gitlink. Git reports it as one entry and never
// descends, so its contents are not this repository's source. Record an
// explicit exclusion bound to the boundary's identity, exactly like a
// host dependency tree, instead of reading it or failing the run.
if (source.isDirectory()) {
boundaries.set(path, source);
entries.push({
path,
pathId: snapshotPathId(repo, path),
originalHash: 'not-read',
bytes: source.size,
mode: source.mode & 0o777,
transformation: NESTED_REPOSITORY,
});
guard();
continue;
} // tracked deletions are represented by absence and the diff manifest
// Host dependency trees and paths outside --paths aren't copied, assessed,
// or counted toward the source cap. Their omission is still explicit.
@@ -807,6 +836,22 @@ export async function capture(
for (const e of entries) {
guard();
if (e.originalHash === 'not-read') {
const boundary = boundaries.get(e.path);
if (boundary) {
// A nested repository is recorded by identity, never read: a
// swap, symlink or replacement directory fails the capture.
const current = fs.lstatSync(containedFile(repo, e.path));
if (
current.isSymbolicLink() ||
!current.isDirectory() ||
current.dev !== boundary.dev ||
current.ino !== boundary.ino ||
current.mode !== boundary.mode
)
throw new CsoError('SNAPSHOT_RACE', `${message}: ${e.path}`);
guard();
continue;
}
const current = fs.lstatSync(containedFile(repo, e.path)),
compared = gitHashes.get(e.path);
if (
+3 -1
View File
@@ -1,6 +1,6 @@
import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test';
import * as fs from 'node:fs';import * as os from 'node:os';import * as path from 'node:path';import { spawn, spawnSync } from 'node:child_process';
import { assertSnapshot, capture } from '../lib/cso/snapshot';import { CsoError, snapshotPathHandle } from '../lib/cso/contracts';import { runProcess, sanitizeForJson, sanitizeHelperForJson } from '../lib/cso/process';import { discardAtomicNoReplaceTemp, finalizeReplayTemporary,reclaimDeadAttemptScratch, loadReport, newRun, privateRoot, readJson, recoverAtomicNoReplaceJson, retention, saveReport, secureDirectory, stateRoot, withLock, writeHelperJson, writeJson } from '../lib/cso/state';
import { assertSnapshot, capture, NESTED_REPOSITORY } from '../lib/cso/snapshot';import { CsoError, snapshotPathHandle } from '../lib/cso/contracts';import { runProcess, sanitizeForJson, sanitizeHelperForJson } from '../lib/cso/process';import { discardAtomicNoReplaceTemp, finalizeReplayTemporary,reclaimDeadAttemptScratch, loadReport, newRun, privateRoot, readJson, recoverAtomicNoReplaceJson, retention, saveReport, secureDirectory, stateRoot, withLock, writeHelperJson, writeJson } from '../lib/cso/state';
const roots:string[]=[];const tmp=()=>{const p=fs.mkdtempSync(path.join(os.tmpdir(),'cso-snapshot-'));roots.push(p);return p;};afterEach(()=>{for(const p of roots.splice(0))fs.rmSync(p,{recursive:true,force:true});});
const originalState=process.env.GSTACK_HOME,state=fs.mkdtempSync(path.join(os.tmpdir(),'cso-state-'));process.env.GSTACK_HOME=state;afterAll(()=>{if(originalState===undefined)delete process.env.GSTACK_HOME;else process.env.GSTACK_HOME=originalState;fs.rmSync(state,{recursive:true,force:true});});
function git(repo:string,...args:string[]){const r=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{PATH:'/usr/bin:/bin',HOME:repo},timeout:30_000});if(r.status)throw new Error(r.stderr);return r.stdout;}
@@ -19,6 +19,8 @@ const unlink=fs.unlinkSync.bind(fs);spyOn(fsModule,'unlinkSync').mockImplementat
const {withLock}=await import(${JSON.stringify(path.resolve(import.meta.dir,'../lib/cso/state.ts'))});let outcome='success';try{withLock(dir,()=>{const active=path.join(barrier,'work-active');try{write(active,id,{flag:'wx'});}catch{fs.appendFileSync(path.join(barrier,'overlap'),'overlap\\n');}mark('entered-'+id);if(process.env.RACE_HOLD==='1')waitFor('release-'+id);try{if(fs.readFileSync(active,'utf8')===id)fs.unlinkSync(active);}catch{}});}catch(error){outcome=String(error?.code??error);}write(path.join(barrier,'result-'+id),outcome);`;}
describe('CSO dirty snapshot boundary',()=>{
test('captures tracked modifications and nonignored untracked source without changing Git state',async()=>{const p=repo();fs.writeFileSync(path.join(p,'tracked.txt'),'dirty\n');fs.writeFileSync(path.join(p,'new.txt'),'untracked\n');fs.writeFileSync(path.join(p,'.gitignore'),'ignored.txt\n');fs.writeFileSync(path.join(p,'ignored.txt'),'ignored\n');const before=git(p,'status','--porcelain=v1','-z'),run=newRun(p),m=await capture(p,run.dir,'HEAD');expect(fs.readFileSync(path.join(run.dir,'snapshot','tracked.txt'),'utf8')).toBe('dirty\n');expect(fs.readFileSync(path.join(run.dir,'snapshot','new.txt'),'utf8')).toBe('untracked\n');expect(fs.existsSync(path.join(run.dir,'snapshot','ignored.txt'))).toBe(false);expect(git(p,'status','--porcelain=v1','-z')).toBe(before);expect(m.changedPaths).toContain('new.txt');});
test('records an untracked nested repository as one boundary entry instead of failing the run',async()=>{const p=repo(),inner=path.join(p,'inner');fs.mkdirSync(inner);git(inner,'init','-q');git(inner,'config','user.email','fixture@example.test');git(inner,'config','user.name','Fixture');fs.writeFileSync(path.join(inner,'f.txt'),'x\n');git(inner,'add','.');git(inner,'commit','-qm','inner');const run=newRun(p),manifest=await capture(p,run.dir,'HEAD');assertSnapshot(run.dir,manifest);const entry=manifest.entries.find(e=>e.path==='inner');expect(entry?.transformation).toBe(NESTED_REPOSITORY);expect(entry?.originalHash).toBe('not-read');expect(manifest.entries.map(e=>e.path)).toEqual(['inner','tracked.txt']);expect(manifest.changedPaths).toContain('inner');expect(fs.existsSync(path.join(run.dir,'snapshot','inner'))).toBe(false);expect(fs.existsSync(path.join(run.dir,'readable','inner'))).toBe(false);});
test('records a registered submodule gitlink the same way as an untracked nested repository',async()=>{const p=repo(),src=tmp();git(src,'init','-q');git(src,'config','user.email','fixture@example.test');git(src,'config','user.name','Fixture');fs.writeFileSync(path.join(src,'g.txt'),'g\n');git(src,'add','.');git(src,'commit','-qm','sub');git(p,'-c','protocol.file.allow=always','submodule','add','-q',src,'sub');const run=newRun(p),manifest=await capture(p,run.dir,'HEAD');assertSnapshot(run.dir,manifest);expect(manifest.entries.find(e=>e.path==='sub')?.transformation).toBe(NESTED_REPOSITORY);expect(fs.existsSync(path.join(run.dir,'snapshot','sub','g.txt'))).toBe(false);});
test('admits source membership and time before an unbounded startup walk',async()=>{const p=repo();fs.writeFileSync(path.join(p,'second.txt'),'second\n');const tooMany=newRun(p);await expect(capture(p,tooMany.dir,undefined,undefined,{maxEntries:1})).rejects.toMatchObject({code:'MISSING_INPUT'});expect(fs.existsSync(path.join(tooMany.dir,'snapshot'))).toBe(false);const expired=newRun(p);await expect(capture(p,expired.dir,undefined,undefined,{deadlineMs:Date.now()-1})).rejects.toMatchObject({code:'DEADLINE'});expect(fs.existsSync(path.join(expired.dir,'snapshot'))).toBe(false);});
test('includes chmod-only changes in diff scope',async()=>{const p=repo();fs.chmodSync(path.join(p,'tracked.txt'),0o755);const manifest=await capture(p,newRun(p).dir,'HEAD');expect(manifest.changedPaths).toContain('tracked.txt');});
test('does not invoke configured clean filters, hooks, fsmonitor, or PATH shims',async()=>{const p=repo(),marker=path.join(p,'marker');fs.writeFileSync(path.join(p,'.gitattributes'),'*.txt filter=hostile\n');git(p,'config','filter.hostile.clean',`/bin/sh -c 'touch ${marker}; cat'`);git(p,'config','core.fsmonitor',`/bin/sh -c 'touch ${marker}'`);const fake=path.join(p,'bin');fs.mkdirSync(fake);fs.writeFileSync(path.join(fake,'git'),`#!/bin/sh\ntouch '${marker}'\nexit 99\n`,{mode:0o755});const old=process.env.PATH;process.env.PATH=`${fake}:${old}`;try{await capture(p,newRun(p).dir,'HEAD');}finally{process.env.PATH=old;}expect(fs.existsSync(marker)).toBe(false);});