mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
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:
1 parent
9665621d18
commit
c03461a306
2 files changed
+51
-4
No files matched your search
+48
-3
@@ -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 (
|
||||
|
||||
@@ -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);});
|
||||
|
||||
Reference in new issue
Block a user