diff --git a/lib/cso/snapshot.ts b/lib/cso/snapshot.ts index 8a498e67b..2f46698c9 100644 --- a/lib/cso/snapshot.ts +++ b/lib/cso/snapshot.ts @@ -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(), gitModes = new Map(), sensitiveEvidence: any[] = [], + boundaries = new Map(), absentPaths = new Set(); 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 ( diff --git a/test/cso-snapshot-state.test.ts b/test/cso-snapshot-state.test.ts index deaa73018..4d47568c6 100644 --- a/test/cso-snapshot-state.test.ts +++ b/test/cso-snapshot-state.test.ts @@ -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);});