diff --git a/bun.lock b/bun.lock index 4ea14d8ab..cdfbb7aff 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.117", "@anthropic-ai/sdk": "^0.78.0", + "prettier": "3.9.9", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", }, @@ -425,6 +426,8 @@ "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], diff --git a/lib/cso/.prettierrc.json b/lib/cso/.prettierrc.json new file mode 100644 index 000000000..8d0a27da2 --- /dev/null +++ b/lib/cso/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 110, + "singleQuote": true, + "trailingComma": "all", + "semi": true +} diff --git a/lib/cso/admission.ts b/lib/cso/admission.ts index 5c1a891e9..7d9c85d5a 100644 --- a/lib/cso/admission.ts +++ b/lib/cso/admission.ts @@ -6,160 +6,468 @@ import { CsoError, sha256 } from './contracts'; import { discardAtomicNoReplaceTemp, recoverAtomicNoReplaceJson, secureDirectory } from './state'; import { atomicWriteSync } from '../fs-atomic'; -export const GROUP_LIMITS = { cpu: 2, memoryMiB: 4096, pids: 256, writableMiB: 2048, outputBytes: 1024 * 1024 } as const; +export const GROUP_LIMITS = { + cpu: 2, + memoryMiB: 4096, + pids: 256, + writableMiB: 2048, + outputBytes: 1024 * 1024, +} as const; export const ROLE_LIMITS = { - anchor: {cpu:.05,memoryMiB:64,pids:8,writableMiB:16}, - app: {cpu:.85,memoryMiB:2304,pids:96,writableMiB:1264}, - verifier: {cpu:.55,memoryMiB:512,pids:32,writableMiB:256}, - tests: {cpu:.55,memoryMiB:1280,pids:64,writableMiB:1024}, - postgres: {cpu:.25,memoryMiB:1024,pids:96,writableMiB:512}, - browser: {cpu:.30,memoryMiB:512,pids:16,writableMiB:256}, + anchor: { cpu: 0.05, memoryMiB: 64, pids: 8, writableMiB: 16 }, + app: { cpu: 0.85, memoryMiB: 2304, pids: 96, writableMiB: 1264 }, + verifier: { cpu: 0.55, memoryMiB: 512, pids: 32, writableMiB: 256 }, + tests: { cpu: 0.55, memoryMiB: 1280, pids: 64, writableMiB: 1024 }, + postgres: { cpu: 0.25, memoryMiB: 1024, pids: 96, writableMiB: 512 }, + browser: { cpu: 0.3, memoryMiB: 512, pids: 16, writableMiB: 256 }, } as const; export type Role = keyof typeof ROLE_LIMITS; -export interface Lease { endpoint: string; slot: number; path: string; runId: string; ownerPid: number; expiresAt: number; token:string; supervised:boolean } -function alive(pid: number): boolean { try { process.kill(pid,0); return true; } catch { return false; } } -function processIdentity(pid:number):string|undefined{if(process.platform!=='linux')return;try{const raw=fs.readFileSync(`/proc/${pid}/stat`,'utf8'),tail=raw.slice(raw.lastIndexOf(')')+2).trim().split(/\s+/);return /^\d+$/.test(tail[19]??'')?`linux:${tail[19]}`:undefined;}catch{return;}} -function sameDirectory(left:fs.Stats,right:fs.Stats):boolean{return left.dev===right.dev&&left.ino===right.ino&&left.uid===right.uid&&left.mode===right.mode;} -function sameFile(left:fs.Stats,right:fs.Stats):boolean{return left.dev===right.dev&&left.ino===right.ino&&left.uid===right.uid&&left.mode===right.mode&&left.nlink===right.nlink;} -function privateDirectory(path:string,label:string):fs.Stats{const stat=fs.lstatSync(path);if(!stat.isDirectory()||stat.isSymbolicLink()||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o077)!==0)throw new CsoError('UNSAFE_PATH',`${label} is not a private owned directory`);return stat;} -function privateFile(path:string,label:string):fs.Stats{const stat=fs.lstatSync(path);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o077)!==0||stat.size>1024*1024)throw new CsoError('UNSAFE_PATH',`${label} is not a private regular file`);return stat;} -type Claim={path:string;token:string;identity:fs.Stats;pid:number;processIdentity:string|null}; -type ClaimOwner={pid:number;processIdentity:string|null;token:string;createdAt:number}; -function validateClaimOwner(value:unknown,expectedToken?:string,publisherPid?:number):ClaimOwner{ - if(!value||typeof value!=='object'||Array.isArray(value))throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid'); - const owner=value as Record; - if(Object.keys(owner).sort().join(',')!=='createdAt,pid,processIdentity,token'||!Number.isSafeInteger(owner.pid)||Number(owner.pid)<=1|| - typeof owner.token!=='string'||!/^[a-f0-9]{32}$/.test(owner.token)||(expectedToken!==undefined&&owner.token!==expectedToken)|| - !Number.isFinite(owner.createdAt)||Number(owner.createdAt)<0||!(owner.processIdentity===null||(typeof owner.processIdentity==='string'&&/^linux:\d+$/.test(owner.processIdentity)))|| - (publisherPid!==undefined&&Number(owner.pid)!==publisherPid))throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid'); - return{pid:Number(owner.pid),processIdentity:owner.processIdentity as string|null,token:owner.token,createdAt:Number(owner.createdAt)}; +export interface Lease { + endpoint: string; + slot: number; + path: string; + runId: string; + ownerPid: number; + expiresAt: number; + token: string; + supervised: boolean; } -function inspectClaim(path:string,expectedToken?:string):Claim{ - const before=privateFile(path,'Reproduction recovery claim');if(before.size<=0||before.size>4096)throw new CsoError('UNSAFE_PATH','Reproduction recovery claim has an invalid size'); - let owner:any;try{owner=JSON.parse(fs.readFileSync(path,'utf8'));}catch{throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid');} - const after=privateFile(path,'Reproduction recovery claim');if(!sameFile(before,after))throw new CsoError('INCOMPATIBLE_INPUT','Reproduction recovery owner is invalid'); - owner=validateClaimOwner(owner,expectedToken); - return{path,token:owner.token,identity:after,pid:owner.pid,processIdentity:owner.processIdentity}; +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } } -function releaseClaim(claim:Claim):void{ - const current=inspectClaim(claim.path,claim.token);if(!sameFile(current.identity,claim.identity))throw new CsoError('PERSISTENCE_FAILED','Reproduction recovery ownership changed'); - const final=privateFile(claim.path,'Reproduction recovery claim');if(!sameFile(final,claim.identity))throw new CsoError('PERSISTENCE_FAILED','Reproduction recovery ownership changed'); +function processIdentity(pid: number): string | undefined { + if (process.platform !== 'linux') return; + try { + const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'), + tail = raw + .slice(raw.lastIndexOf(')') + 2) + .trim() + .split(/\s+/); + return /^\d+$/.test(tail[19] ?? '') ? `linux:${tail[19]}` : undefined; + } catch { + return; + } +} +function sameDirectory(left: fs.Stats, right: fs.Stats): boolean { + return ( + left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode + ); +} +function sameFile(left: fs.Stats, right: fs.Stats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.uid === right.uid && + left.mode === right.mode && + left.nlink === right.nlink + ); +} +function privateDirectory(path: string, label: string): fs.Stats { + const stat = fs.lstatSync(path); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o077) !== 0 + ) + throw new CsoError('UNSAFE_PATH', `${label} is not a private owned directory`); + return stat; +} +function privateFile(path: string, label: string): fs.Stats { + const stat = fs.lstatSync(path); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1 || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o077) !== 0 || + stat.size > 1024 * 1024 + ) + throw new CsoError('UNSAFE_PATH', `${label} is not a private regular file`); + return stat; +} +type Claim = { path: string; token: string; identity: fs.Stats; pid: number; processIdentity: string | null }; +type ClaimOwner = { pid: number; processIdentity: string | null; token: string; createdAt: number }; +function validateClaimOwner(value: unknown, expectedToken?: string, publisherPid?: number): ClaimOwner { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid'); + const owner = value as Record; + if ( + Object.keys(owner).sort().join(',') !== 'createdAt,pid,processIdentity,token' || + !Number.isSafeInteger(owner.pid) || + Number(owner.pid) <= 1 || + typeof owner.token !== 'string' || + !/^[a-f0-9]{32}$/.test(owner.token) || + (expectedToken !== undefined && owner.token !== expectedToken) || + !Number.isFinite(owner.createdAt) || + Number(owner.createdAt) < 0 || + !( + owner.processIdentity === null || + (typeof owner.processIdentity === 'string' && /^linux:\d+$/.test(owner.processIdentity)) + ) || + (publisherPid !== undefined && Number(owner.pid) !== publisherPid) + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid'); + return { + pid: Number(owner.pid), + processIdentity: owner.processIdentity as string | null, + token: owner.token, + createdAt: Number(owner.createdAt), + }; +} +function inspectClaim(path: string, expectedToken?: string): Claim { + const before = privateFile(path, 'Reproduction recovery claim'); + if (before.size <= 0 || before.size > 4096) + throw new CsoError('UNSAFE_PATH', 'Reproduction recovery claim has an invalid size'); + let owner: any; + try { + owner = JSON.parse(fs.readFileSync(path, 'utf8')); + } catch { + throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid'); + } + const after = privateFile(path, 'Reproduction recovery claim'); + if (!sameFile(before, after)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Reproduction recovery owner is invalid'); + owner = validateClaimOwner(owner, expectedToken); + return { + path, + token: owner.token, + identity: after, + pid: owner.pid, + processIdentity: owner.processIdentity, + }; +} +function releaseClaim(claim: Claim): void { + const current = inspectClaim(claim.path, claim.token); + if (!sameFile(current.identity, claim.identity)) + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction recovery ownership changed'); + const final = privateFile(claim.path, 'Reproduction recovery claim'); + if (!sameFile(final, claim.identity)) + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction recovery ownership changed'); fs.unlinkSync(claim.path); } -function acquireClaim(parent:string,expected:fs.Stats):Claim{ - const path=join(parent,'.recovery'),assertParent=()=>{const current=privateDirectory(parent,'Reproduction lease slot');if(!sameDirectory(expected,current))throw new CsoError('INSUFFICIENT_CAPACITY','Reproduction lease changed during recovery');}; - const recoverPublications=()=>{ - const pattern=/^\.recovery\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/; - for(const name of fs.readdirSync(parent)){ - const match=name.match(pattern);if(!match)continue; - const publisherPid=Number(match[1]),temporary=join(parent,name),options={label:'Reproduction recovery claim',maxBytes:4096, - validate:(value:unknown,pid:number)=>{validateClaimOwner(value,undefined,pid);}}; - assertParent();if(fs.existsSync(path))recoverAtomicNoReplaceJson(path,options);if(fs.existsSync(temporary))discardAtomicNoReplaceTemp(temporary,publisherPid,options);assertParent(); +function acquireClaim(parent: string, expected: fs.Stats): Claim { + const path = join(parent, '.recovery'), + assertParent = () => { + const current = privateDirectory(parent, 'Reproduction lease slot'); + if (!sameDirectory(expected, current)) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Reproduction lease changed during recovery'); + }; + const recoverPublications = () => { + const pattern = /^\.recovery\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/; + for (const name of fs.readdirSync(parent)) { + const match = name.match(pattern); + if (!match) continue; + const publisherPid = Number(match[1]), + temporary = join(parent, name), + options = { + label: 'Reproduction recovery claim', + maxBytes: 4096, + validate: (value: unknown, pid: number) => { + validateClaimOwner(value, undefined, pid); + }, + }; + assertParent(); + if (fs.existsSync(path)) recoverAtomicNoReplaceJson(path, options); + if (fs.existsSync(temporary)) discardAtomicNoReplaceTemp(temporary, publisherPid, options); + assertParent(); } }; - for(let attempt=0;attempt<64;attempt++){ - assertParent();recoverPublications();const token=randomBytes(16).toString('hex'); - try{ - atomicWriteSync(path,JSON.stringify({pid:process.pid,processIdentity:processIdentity(process.pid)??null,token,createdAt:Date.now()})+'\n',{mode:0o600,noReplace:true}); - const claim=inspectClaim(path,token);try{assertParent();}catch(error){try{releaseClaim(claim);}catch{}throw error;}return claim; - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Reproduction recovery claim could not be created'); + for (let attempt = 0; attempt < 64; attempt++) { + assertParent(); + recoverPublications(); + const token = randomBytes(16).toString('hex'); + try { + atomicWriteSync( + path, + JSON.stringify({ + pid: process.pid, + processIdentity: processIdentity(process.pid) ?? null, + token, + createdAt: Date.now(), + }) + '\n', + { mode: 0o600, noReplace: true }, + ); + const claim = inspectClaim(path, token); + try { + assertParent(); + } catch (error) { + try { + releaseClaim(claim); + } catch {} + throw error; + } + return claim; + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code !== 'EEXIST') + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction recovery claim could not be created'); + } + assertParent(); + const observed = inspectClaim(path), + isAlive = alive(observed.pid), + identity = isAlive ? processIdentity(observed.pid) : undefined; + if ( + isAlive && + !( + typeof observed.processIdentity === 'string' && + identity !== undefined && + identity !== observed.processIdentity + ) + ) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another helper is recovering the reproduction lease'); + try { + releaseClaim(observed); + } catch (error) { + if (error instanceof CsoError && error.code === 'PERSISTENCE_FAILED') continue; + throw error; } - assertParent();const observed=inspectClaim(path),isAlive=alive(observed.pid),identity=isAlive?processIdentity(observed.pid):undefined; - if(isAlive&&!(typeof observed.processIdentity==='string'&&identity!==undefined&&identity!==observed.processIdentity))throw new CsoError('INSUFFICIENT_CAPACITY','Another helper is recovering the reproduction lease'); - try{releaseClaim(observed);}catch(error){if(error instanceof CsoError&&error.code==='PERSISTENCE_FAILED')continue;throw error;} } - throw new CsoError('INSUFFICIENT_CAPACITY','Reproduction recovery claim changed repeatedly'); + throw new CsoError('INSUFFICIENT_CAPACITY', 'Reproduction recovery claim changed repeatedly'); } /** One host-user pool shared by every workspace/state root on this machine. */ -export function machinePoolRoot():string{ - const uid=process.getuid?.()??userInfo().uid; - return secureDirectory(join(fs.realpathSync(tmpdir()),`gstack-cso-pool-${uid}`)); +export function machinePoolRoot(): string { + const uid = process.getuid?.() ?? userInfo().uid; + return secureDirectory(join(fs.realpathSync(tmpdir()), `gstack-cso-pool-${uid}`)); } -function reclaimSlot(path:string,pool:string,slot:number,observed:fs.Stats,expectedToken?:string):boolean{ - let claim:Claim;try{claim=acquireClaim(path,observed);}catch(error){if(error instanceof CsoError&&error.code==='INSUFFICIENT_CAPACITY')return false;throw error;} - try{const current=privateDirectory(path,'Reproduction lease slot');if(!sameDirectory(observed,current)){releaseClaim(claim);return false;}if(expectedToken){privateFile(join(path,'lease.json'),'Reproduction lease');const lease=JSON.parse(fs.readFileSync(join(path,'lease.json'),'utf8'));if(lease.token!==expectedToken){releaseClaim(claim);return false;}} - const tomb=join(pool,`.slot-${slot}.stale-${process.pid}-${randomBytes(8).toString('hex')}`);fs.renameSync(path,tomb);const moved=privateDirectory(tomb,'Reproduction lease tomb');if(!sameDirectory(observed,moved))throw new CsoError('SNAPSHOT_RACE','Reproduction lease changed while quarantined');fs.mkdirSync(path,{mode:0o700});releaseClaim({...claim,path:join(tomb,'.recovery')});for(const name of fs.readdirSync(tomb)){if(!['lease.json','lease.token'].includes(name)&&!/^lease\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)&&!/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name))throw new CsoError('UNSAFE_PATH','Stale reproduction lease contains an unexpected object');privateFile(join(tomb,name),'Stale reproduction lease file');fs.unlinkSync(join(tomb,name));}fs.rmdirSync(tomb);return true; - }catch(error){if(error instanceof CsoError)throw error;return false;} +function reclaimSlot( + path: string, + pool: string, + slot: number, + observed: fs.Stats, + expectedToken?: string, +): boolean { + let claim: Claim; + try { + claim = acquireClaim(path, observed); + } catch (error) { + if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') return false; + throw error; + } + try { + const current = privateDirectory(path, 'Reproduction lease slot'); + if (!sameDirectory(observed, current)) { + releaseClaim(claim); + return false; + } + if (expectedToken) { + privateFile(join(path, 'lease.json'), 'Reproduction lease'); + const lease = JSON.parse(fs.readFileSync(join(path, 'lease.json'), 'utf8')); + if (lease.token !== expectedToken) { + releaseClaim(claim); + return false; + } + } + const tomb = join(pool, `.slot-${slot}.stale-${process.pid}-${randomBytes(8).toString('hex')}`); + fs.renameSync(path, tomb); + const moved = privateDirectory(tomb, 'Reproduction lease tomb'); + if (!sameDirectory(observed, moved)) + throw new CsoError('SNAPSHOT_RACE', 'Reproduction lease changed while quarantined'); + fs.mkdirSync(path, { mode: 0o700 }); + releaseClaim({ ...claim, path: join(tomb, '.recovery') }); + for (const name of fs.readdirSync(tomb)) { + if ( + !['lease.json', 'lease.token'].includes(name) && + !/^lease\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(name) && + !/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name) + ) + throw new CsoError('UNSAFE_PATH', 'Stale reproduction lease contains an unexpected object'); + privateFile(join(tomb, name), 'Stale reproduction lease file'); + fs.unlinkSync(join(tomb, name)); + } + fs.rmdirSync(tomb); + return true; + } catch (error) { + if (error instanceof CsoError) throw error; + return false; + } } -function slotControl(pool:string,slot:number):{path:string;stat:fs.Stats}{ - const path=join(pool,`.slot-${slot}.control`); - try{fs.mkdirSync(path,{mode:0o700});}catch(error:any){if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Reproduction slot control directory could not be created');} - const stat=privateDirectory(path,'Reproduction slot control directory');for(const name of fs.readdirSync(path))if(name!=='.recovery'&&!/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name))throw new CsoError('UNSAFE_PATH','Reproduction slot control directory contains an unexpected object');return{path,stat}; +function slotControl(pool: string, slot: number): { path: string; stat: fs.Stats } { + const path = join(pool, `.slot-${slot}.control`); + try { + fs.mkdirSync(path, { mode: 0o700 }); + } catch (error: any) { + if (error?.code !== 'EEXIST') + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction slot control directory could not be created'); + } + const stat = privateDirectory(path, 'Reproduction slot control directory'); + for (const name of fs.readdirSync(path)) + if (name !== '.recovery' && !/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)) + throw new CsoError('UNSAFE_PATH', 'Reproduction slot control directory contains an unexpected object'); + return { path, stat }; } -function writeLease(lease:Lease):void{ - const keys=Object.keys(lease).sort().join(','),expected='endpoint,expiresAt,ownerPid,path,runId,slot,supervised,token'; - if(keys!==expected||!/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(lease.endpoint)||![0,1].includes(lease.slot)|| - !/^[A-Za-z0-9_.-]{1,100}$/.test(lease.runId)||lease.ownerPid!==process.pid||!Number.isSafeInteger(lease.expiresAt)|| - !/^[a-f0-9]{32}$/.test(lease.token)||typeof lease.supervised!=='boolean') - throw new CsoError('PERSISTENCE_FAILED','Reproduction lease metadata is invalid'); - const expectedPath=join(machinePoolRoot(),sha256(lease.endpoint).slice(0,24),`slot-${lease.slot}`); - if(lease.path!==expectedPath)throw new CsoError('PERSISTENCE_FAILED','Reproduction lease path is invalid'); +function writeLease(lease: Lease): void { + const keys = Object.keys(lease).sort().join(','), + expected = 'endpoint,expiresAt,ownerPid,path,runId,slot,supervised,token'; + if ( + keys !== expected || + !/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(lease.endpoint) || + ![0, 1].includes(lease.slot) || + !/^[A-Za-z0-9_.-]{1,100}$/.test(lease.runId) || + lease.ownerPid !== process.pid || + !Number.isSafeInteger(lease.expiresAt) || + !/^[a-f0-9]{32}$/.test(lease.token) || + typeof lease.supervised !== 'boolean' + ) + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease metadata is invalid'); + const expectedPath = join(machinePoolRoot(), sha256(lease.endpoint).slice(0, 24), `slot-${lease.slot}`); + if (lease.path !== expectedPath) + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease path is invalid'); // This exact helper-owned schema contains only control metadata. In // particular, its random capability may resemble a wallet address and must // remain byte-identical to lease.token; untrusted reports still use writeJson. - atomicWriteSync(join(lease.path,'lease.json'),JSON.stringify(lease)+'\n',{mode:0o600}); + atomicWriteSync(join(lease.path, 'lease.json'), JSON.stringify(lease) + '\n', { mode: 0o600 }); } export function admit(endpoint: string, runId: string, deadline: number): Lease { - if (!/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(endpoint)) throw new CsoError('ISOLATION_FAILED','Only a pinned local Unix Docker endpoint is admitted on this host'); - const pool = secureDirectory(join(machinePoolRoot(),sha256(endpoint).slice(0,24))); - for (let slot=0;slot<2;slot++) { - const path=join(pool,`slot-${slot}`),control=slotControl(pool,slot);let mutation:Claim; - try{mutation=acquireClaim(control.path,control.stat);}catch(error){if(error instanceof CsoError&&error.code==='INSUFFICIENT_CAPACITY')continue;throw error;} - try{ + if (!/^unix:\/\/[/.A-Za-z0-9_-]+$/.test(endpoint)) + throw new CsoError( + 'ISOLATION_FAILED', + 'Only a pinned local Unix Docker endpoint is admitted on this host', + ); + const pool = secureDirectory(join(machinePoolRoot(), sha256(endpoint).slice(0, 24))); + for (let slot = 0; slot < 2; slot++) { + const path = join(pool, `slot-${slot}`), + control = slotControl(pool, slot); + let mutation: Claim; + try { + mutation = acquireClaim(control.path, control.stat); + } catch (error) { + if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') continue; + throw error; + } + try { try { - fs.mkdirSync(path,{mode:0o700}); - } catch(error:any) { - if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Reproduction lease slot could not be created'); + fs.mkdirSync(path, { mode: 0o700 }); + } catch (error: any) { + if (error?.code !== 'EEXIST') + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease slot could not be created'); try { - const observed=privateDirectory(path,'Reproduction lease slot'); - const old = JSON.parse(fs.readFileSync(join(path,'lease.json'),'utf8')); + const observed = privateDirectory(path, 'Reproduction lease slot'); + const old = JSON.parse(fs.readFileSync(join(path, 'lease.json'), 'utf8')); // A supervised lease is removed only after its watchdog or owner has // confirmed exact-resource cleanup. This preserves the two-group cap // through supervisor death and daemon outages. if (old.supervised === true || (typeof old.ownerPid === 'number' && alive(old.ownerPid))) continue; // Unsupervised stale slots cannot have created containers: supervision // is acknowledged before the anchor create call. - if(!reclaimSlot(path,pool,slot,observed,typeof old.token==='string'?old.token:undefined))continue; - } catch(recoveryError) { - if(recoveryError instanceof CsoError)throw recoveryError; + if (!reclaimSlot(path, pool, slot, observed, typeof old.token === 'string' ? old.token : undefined)) + continue; + } catch (recoveryError) { + if (recoveryError instanceof CsoError) throw recoveryError; // No live initializer can publish into this path while this stable // slot-control claim is held. Recover a crashed partial publication // only after the compatibility grace period. - let stat:fs.Stats;try{stat=privateDirectory(path,'Reproduction lease slot');}catch(statError){if(statError instanceof CsoError)throw statError;continue;} - if(Date.now()-stat.mtimeMs<=5000)continue; - if(!reclaimSlot(path,pool,slot,stat))continue; + let stat: fs.Stats; + try { + stat = privateDirectory(path, 'Reproduction lease slot'); + } catch (statError) { + if (statError instanceof CsoError) throw statError; + continue; + } + if (Date.now() - stat.mtimeMs <= 5000) continue; + if (!reclaimSlot(path, pool, slot, stat)) continue; } } // Both authenticated records become visible as one logical publication // when the stable slot-control claim is released. - const lease:Lease={endpoint,slot,path,runId,ownerPid:process.pid,expiresAt:deadline,token:randomBytes(16).toString('hex'),supervised:false};writeLease(lease);fs.writeFileSync(join(path,'lease.token'),lease.token+'\n',{mode:0o600,flag:'wx'});return lease; - }finally{releaseClaim(mutation);} + const lease: Lease = { + endpoint, + slot, + path, + runId, + ownerPid: process.pid, + expiresAt: deadline, + token: randomBytes(16).toString('hex'), + supervised: false, + }; + writeLease(lease); + fs.writeFileSync(join(path, 'lease.token'), lease.token + '\n', { mode: 0o600, flag: 'wx' }); + return lease; + } finally { + releaseClaim(mutation); + } } - throw new CsoError('INSUFFICIENT_CAPACITY','Two reproduction groups are already admitted for this Docker endpoint'); + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + 'Two reproduction groups are already admitted for this Docker endpoint', + ); } -export function markSupervised(lease:Lease):void{ - const current=JSON.parse(fs.readFileSync(join(lease.path,'lease.json'),'utf8')); - if(current.token!==lease.token||current.ownerPid!==lease.ownerPid)throw new CsoError('INSUFFICIENT_CAPACITY','Reproduction lease changed before watchdog supervision'); - lease.supervised=true;writeLease(lease); +export function markSupervised(lease: Lease): void { + const current = JSON.parse(fs.readFileSync(join(lease.path, 'lease.json'), 'utf8')); + if (current.token !== lease.token || current.ownerPid !== lease.ownerPid) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Reproduction lease changed before watchdog supervision'); + lease.supervised = true; + writeLease(lease); } export function release(lease: Lease): void { - let observed:fs.Stats;try{observed=privateDirectory(lease.path,'Reproduction lease slot');}catch(error:any){if(error?.code==='ENOENT')throw new CsoError('PERSISTENCE_FAILED','Exact reproduction lease was already missing');throw error;} - const claim=acquireClaim(lease.path,observed); - try{ - const currentStat=privateDirectory(lease.path,'Reproduction lease slot');if(!sameDirectory(observed,currentStat))throw new CsoError('PERSISTENCE_FAILED','Reproduction lease changed before exact release'); - const names=fs.readdirSync(lease.path).filter(name=>name!=='.recovery'&&!/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)).sort();if(names.join('\0')!=='lease.json\0lease.token')throw new CsoError('PERSISTENCE_FAILED','Reproduction lease contents changed before exact release'); - privateFile(join(lease.path,'lease.json'),'Reproduction lease');privateFile(join(lease.path,'lease.token'),'Reproduction lease token'); - const current=JSON.parse(fs.readFileSync(join(lease.path,'lease.json'),'utf8')),token=fs.readFileSync(join(lease.path,'lease.token'),'utf8').trim(); - if(current.runId!==lease.runId||current.ownerPid!==lease.ownerPid||current.token!==lease.token||token!==lease.token)throw new CsoError('PERSISTENCE_FAILED','Reproduction lease ownership changed before exact release'); - fs.unlinkSync(join(lease.path,'lease.token'));fs.unlinkSync(join(lease.path,'lease.json'));releaseClaim(claim);fs.rmdirSync(lease.path); - if(fs.existsSync(lease.path))throw new CsoError('PERSISTENCE_FAILED','Exact reproduction lease removal could not be proven'); - }catch(error){try{if(fs.existsSync(claim.path))releaseClaim(claim);}catch{}if(error instanceof CsoError)throw error;throw new CsoError('PERSISTENCE_FAILED','Exact reproduction lease removal failed');} + let observed: fs.Stats; + try { + observed = privateDirectory(lease.path, 'Reproduction lease slot'); + } catch (error: any) { + if (error?.code === 'ENOENT') + throw new CsoError('PERSISTENCE_FAILED', 'Exact reproduction lease was already missing'); + throw error; + } + const claim = acquireClaim(lease.path, observed); + try { + const currentStat = privateDirectory(lease.path, 'Reproduction lease slot'); + if (!sameDirectory(observed, currentStat)) + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease changed before exact release'); + const names = fs + .readdirSync(lease.path) + .filter((name) => name !== '.recovery' && !/^\.recovery\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)) + .sort(); + if (names.join('\0') !== 'lease.json\0lease.token') + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease contents changed before exact release'); + privateFile(join(lease.path, 'lease.json'), 'Reproduction lease'); + privateFile(join(lease.path, 'lease.token'), 'Reproduction lease token'); + const current = JSON.parse(fs.readFileSync(join(lease.path, 'lease.json'), 'utf8')), + token = fs.readFileSync(join(lease.path, 'lease.token'), 'utf8').trim(); + if ( + current.runId !== lease.runId || + current.ownerPid !== lease.ownerPid || + current.token !== lease.token || + token !== lease.token + ) + throw new CsoError('PERSISTENCE_FAILED', 'Reproduction lease ownership changed before exact release'); + fs.unlinkSync(join(lease.path, 'lease.token')); + fs.unlinkSync(join(lease.path, 'lease.json')); + releaseClaim(claim); + fs.rmdirSync(lease.path); + if (fs.existsSync(lease.path)) + throw new CsoError('PERSISTENCE_FAILED', 'Exact reproduction lease removal could not be proven'); + } catch (error) { + try { + if (fs.existsSync(claim.path)) releaseClaim(claim); + } catch {} + if (error instanceof CsoError) throw error; + throw new CsoError('PERSISTENCE_FAILED', 'Exact reproduction lease removal failed'); + } } export function total(roles: Role[]) { - const value = roles.reduce((a,r) => ({cpu:a.cpu+ROLE_LIMITS[r].cpu,memoryMiB:a.memoryMiB+ROLE_LIMITS[r].memoryMiB,pids:a.pids+ROLE_LIMITS[r].pids,writableMiB:a.writableMiB+ROLE_LIMITS[r].writableMiB}), {cpu:0,memoryMiB:0,pids:0,writableMiB:0}); - if (value.cpu > GROUP_LIMITS.cpu || value.memoryMiB > GROUP_LIMITS.memoryMiB || value.pids > GROUP_LIMITS.pids || value.writableMiB > GROUP_LIMITS.writableMiB) - throw new CsoError('INSUFFICIENT_CAPACITY','Requested sidecars exceed the aggregate reproduction-group limit'); + const value = roles.reduce( + (a, r) => ({ + cpu: a.cpu + ROLE_LIMITS[r].cpu, + memoryMiB: a.memoryMiB + ROLE_LIMITS[r].memoryMiB, + pids: a.pids + ROLE_LIMITS[r].pids, + writableMiB: a.writableMiB + ROLE_LIMITS[r].writableMiB, + }), + { cpu: 0, memoryMiB: 0, pids: 0, writableMiB: 0 }, + ); + if ( + value.cpu > GROUP_LIMITS.cpu || + value.memoryMiB > GROUP_LIMITS.memoryMiB || + value.pids > GROUP_LIMITS.pids || + value.writableMiB > GROUP_LIMITS.writableMiB + ) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + 'Requested sidecars exceed the aggregate reproduction-group limit', + ); return value; } diff --git a/lib/cso/bounded-file.ts b/lib/cso/bounded-file.ts index 243e0a3e9..8ea3cc3d0 100644 --- a/lib/cso/bounded-file.ts +++ b/lib/cso/bounded-file.ts @@ -2,15 +2,58 @@ import * as fs from 'node:fs'; import { CsoError } from './contracts'; /** Read one caller-supplied control file without following or blocking on a raced special file. */ -export function readBoundedStable(path:string,max:number,label:string):Buffer{ - let named:fs.Stats,fd:number|undefined;try{named=fs.lstatSync(path);}catch{throw new CsoError('MISSING_INPUT',`${label} does not exist`);} - if(named.isSymbolicLink()||!named.isFile()||named.nlink!==1||named.size>max)throw new CsoError('MISSING_INPUT',`${label} must be one bounded regular file`); - try{ - fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)|(fs.constants.O_NONBLOCK??0));const opened=fs.fstatSync(fd); - if(!opened.isFile()||opened.nlink!==1||opened.dev!==named.dev||opened.ino!==named.ino||opened.mode!==named.mode||opened.size!==named.size)throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be read`); - const data=Buffer.alloc(max+1);let bytes=0,count=0;while(bytes0)bytes+=count; - const after=fs.fstatSync(fd),current=fs.lstatSync(path);if(bytes>max)throw new CsoError('MISSING_INPUT',`${label} exceeds the ${max}-byte limit`); - if(!current.isFile()||current.isSymbolicLink()||current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode||after.size!==opened.size||after.mtimeMs!==opened.mtimeMs||after.ctimeMs!==opened.ctimeMs)throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was read`); - return data.subarray(0,bytes); - }catch(error){if(error instanceof CsoError)throw error;const code=(error as NodeJS.ErrnoException).code;if(['ELOOP','ENOENT','ENOTDIR','ENXIO'].includes(code??''))throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be opened`);throw new CsoError('MISSING_INPUT',`${label} is missing or unreadable`);}finally{if(fd!==undefined)fs.closeSync(fd);} +export function readBoundedStable(path: string, max: number, label: string): Buffer { + let named: fs.Stats, fd: number | undefined; + try { + named = fs.lstatSync(path); + } catch { + throw new CsoError('MISSING_INPUT', `${label} does not exist`); + } + if (named.isSymbolicLink() || !named.isFile() || named.nlink !== 1 || named.size > max) + throw new CsoError('MISSING_INPUT', `${label} must be one bounded regular file`); + try { + fd = fs.openSync( + path, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0), + ); + const opened = fs.fstatSync(fd); + if ( + !opened.isFile() || + opened.nlink !== 1 || + opened.dev !== named.dev || + opened.ino !== named.ino || + opened.mode !== named.mode || + opened.size !== named.size + ) + throw new CsoError('SNAPSHOT_RACE', `${label} changed before it could be read`); + const data = Buffer.alloc(max + 1); + let bytes = 0, + count = 0; + while (bytes < data.length && (count = fs.readSync(fd, data, bytes, data.length - bytes, null)) > 0) + bytes += count; + const after = fs.fstatSync(fd), + current = fs.lstatSync(path); + if (bytes > max) throw new CsoError('MISSING_INPUT', `${label} exceeds the ${max}-byte limit`); + if ( + !current.isFile() || + current.isSymbolicLink() || + current.nlink !== 1 || + current.dev !== opened.dev || + current.ino !== opened.ino || + current.mode !== opened.mode || + after.size !== opened.size || + after.mtimeMs !== opened.mtimeMs || + after.ctimeMs !== opened.ctimeMs + ) + throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was read`); + return data.subarray(0, bytes); + } catch (error) { + if (error instanceof CsoError) throw error; + const code = (error as NodeJS.ErrnoException).code; + if (['ELOOP', 'ENOENT', 'ENOTDIR', 'ENXIO'].includes(code ?? '')) + throw new CsoError('SNAPSHOT_RACE', `${label} changed before it could be opened`); + throw new CsoError('MISSING_INPUT', `${label} is missing or unreadable`); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } } diff --git a/lib/cso/cache.ts b/lib/cso/cache.ts index e9a3f6ecb..d3695d59c 100644 --- a/lib/cso/cache.ts +++ b/lib/cso/cache.ts @@ -3,7 +3,13 @@ import { createHash, randomBytes } from 'node:crypto'; import { join, resolve, sep } from 'node:path'; import { atomicWriteSync } from '../fs-atomic'; import { CsoError } from './contracts'; -import { discardAtomicNoReplaceTemp, privateRoot, recoverAtomicNoReplaceJson, secureDirectory, withLock as withStateLock } from './state'; +import { + discardAtomicNoReplaceTemp, + privateRoot, + recoverAtomicNoReplaceJson, + secureDirectory, + withLock as withStateLock, +} from './state'; export const DEFAULT_PUBLIC_ARCHIVE_CACHE_BYTES = 10 * 1024 * 1024 * 1024; const METADATA_VERSION = 1; @@ -82,14 +88,17 @@ function fail(code: ConstructorParameters[0], message: string): } function operationControl(input?: CacheOperationInput): NormalizedCacheOperationControl { - const value = typeof input === 'number' ? { deadline: input } : input ?? {}; - if (!value || typeof value !== 'object' || Array.isArray(value)) fail('INVALID_ARGUMENT', 'Cache operation control must be an object or absolute deadline'); + const value = typeof input === 'number' ? { deadline: input } : (input ?? {}); + if (!value || typeof value !== 'object' || Array.isArray(value)) + fail('INVALID_ARGUMENT', 'Cache operation control must be an object or absolute deadline'); if (value.deadline !== undefined && (!Number.isSafeInteger(value.deadline) || value.deadline <= 0)) fail('INVALID_ARGUMENT', 'Cache deadline must be an absolute millisecond timestamp'); if (value.signal !== undefined && typeof value.signal.aborted !== 'boolean') fail('INVALID_ARGUMENT', 'Cache cancellation signal is invalid'); - const control = Object.freeze({ ...(value.deadline === undefined ? {} : { deadline: value.deadline }), - ...(value.signal === undefined ? {} : { signal: value.signal }) }); + const control = Object.freeze({ + ...(value.deadline === undefined ? {} : { deadline: value.deadline }), + ...(value.signal === undefined ? {} : { signal: value.signal }), + }); checkOperation(control); return control; } @@ -100,18 +109,26 @@ function checkOperation(control: NormalizedCacheOperationControl): void { fail('DEADLINE', 'Archive-cache operation reached its deadline'); } -function boundedDirectoryNames(path: string, label: string, control: NormalizedCacheOperationControl): string[] { +function boundedDirectoryNames( + path: string, + label: string, + control: NormalizedCacheOperationControl, +): string[] { checkOperation(control); - const directory = fs.opendirSync(path), names: string[] = []; + const directory = fs.opendirSync(path), + names: string[] = []; try { for (;;) { checkOperation(control); const entry = directory.readSync(); if (!entry) break; - if (names.length >= MAX_CACHE_DIRECTORY_ENTRIES) fail('INSUFFICIENT_CAPACITY', `${label} exceeds the cache entry limit`); + if (names.length >= MAX_CACHE_DIRECTORY_ENTRIES) + fail('INSUFFICIENT_CAPACITY', `${label} exceeds the cache entry limit`); names.push(entry.name); } - } finally { directory.closeSync(); } + } finally { + directory.closeSync(); + } checkOperation(control); return names; } @@ -119,18 +136,23 @@ function boundedDirectoryNames(path: string, label: string, control: NormalizedC function assertEmptyDirectory(path: string, control: NormalizedCacheOperationControl): void { checkOperation(control); const directory = fs.opendirSync(path); - try { if (directory.readSync()) fail('UNSAFE_PATH', 'Archive materialization directory must be empty'); } - finally { directory.closeSync(); } + try { + if (directory.readSync()) fail('UNSAFE_PATH', 'Archive materialization directory must be empty'); + } finally { + directory.closeSync(); + } checkOperation(control); } function boundedPositiveInteger(value: number, name: string): number { - if (!Number.isSafeInteger(value) || value <= 0) fail('INVALID_ARGUMENT', `${name} must be a positive safe integer`); + if (!Number.isSafeInteger(value) || value <= 0) + fail('INVALID_ARGUMENT', `${name} must be a positive safe integer`); return value; } function expectedDigest(value: string): string { - if (!SHA256.test(value)) fail('INVALID_ARGUMENT', 'Archive SHA-256 must be 64 lowercase hexadecimal characters'); + if (!SHA256.test(value)) + fail('INVALID_ARGUMENT', 'Archive SHA-256 must be 64 lowercase hexadecimal characters'); return value; } @@ -138,33 +160,54 @@ function stagedRelativePath(value: string): string { if (typeof value !== 'string' || value.length > 4096 || !RELATIVE_STAGE_PATH.test(value)) fail('UNSAFE_PATH', 'Staged archive path must be a contained relative path'); const parts = value.split('/'); - if (parts.some(part => !part || part === '.' || part === '..')) fail('UNSAFE_PATH', 'Staged archive path must be a contained relative path'); + if (parts.some((part) => !part || part === '.' || part === '..')) + fail('UNSAFE_PATH', 'Staged archive path must be a contained relative path'); return value; } function stableStat(stat: fs.Stats): StableStat { return { - dev: stat.dev, ino: stat.ino, size: stat.size, mode: stat.mode, nlink: stat.nlink, - mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, uid: stat.uid, + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mode: stat.mode, + nlink: stat.nlink, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + uid: stat.uid, }; } function sameStat(left: StableStat, right: StableStat): boolean { - return left.dev === right.dev && left.ino === right.ino && left.size === right.size && - left.mode === right.mode && left.nlink === right.nlink && left.mtimeMs === right.mtimeMs && - left.ctimeMs === right.ctimeMs && left.uid === right.uid; + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mode === right.mode && + left.nlink === right.nlink && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs && + left.uid === right.uid + ); } function sameRenamedInode(left: StableStat, right: StableStat): boolean { - return left.dev === right.dev && left.ino === right.ino && left.size === right.size && - left.mode === right.mode && left.nlink === right.nlink && left.mtimeMs === right.mtimeMs && left.uid === right.uid; + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mode === right.mode && + left.nlink === right.nlink && + left.mtimeMs === right.mtimeMs && + left.uid === right.uid + ); } - function assertOwnedRegular(stat: fs.Stats, label: string, maxBytes: number, immutable = false): void { if (!stat.isFile() || stat.isSymbolicLink()) fail('UNSAFE_PATH', `${label} must be a regular file`); if (stat.nlink !== 1) fail('UNSAFE_PATH', `${label} must not be hard-linked`); - if (process.getuid && stat.uid !== process.getuid()) fail('UNSAFE_PATH', `${label} must be owned by the current user`); + if (process.getuid && stat.uid !== process.getuid()) + fail('UNSAFE_PATH', `${label} must be owned by the current user`); if (stat.size > maxBytes) fail('INSUFFICIENT_CAPACITY', `${label} exceeds its byte limit`); if (immutable && (stat.mode & 0o222) !== 0) fail('INCOMPATIBLE_INPUT', `${label} is unexpectedly writable`); } @@ -172,80 +215,125 @@ function assertOwnedRegular(stat: fs.Stats, label: string, maxBytes: number, imm function assertExistingDirectory(path: string, label: string): string { const requested = resolve(path); let requestedStat: fs.Stats; - try { requestedStat = fs.lstatSync(requested); } - catch { fail('MISSING_INPUT', `${label} does not exist`); } + try { + requestedStat = fs.lstatSync(requested); + } catch { + fail('MISSING_INPUT', `${label} does not exist`); + } if (requestedStat!.isSymbolicLink()) fail('UNSAFE_PATH', `${label} must not be a symlink`); let canonical: string; - try { canonical = fs.realpathSync(requested); } - catch { fail('MISSING_INPUT', `${label} does not exist`); } + try { + canonical = fs.realpathSync(requested); + } catch { + fail('MISSING_INPUT', `${label} does not exist`); + } const stat = fs.lstatSync(canonical!); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('UNSAFE_PATH', `${label} must be a directory`); - if (process.getuid && stat.uid !== process.getuid()) fail('UNSAFE_PATH', `${label} must be owned by the current user`); + if (process.getuid && stat.uid !== process.getuid()) + fail('UNSAFE_PATH', `${label} must be owned by the current user`); if ((stat.mode & 0o022) !== 0) fail('UNSAFE_PATH', `${label} must not be writable by another user`); return canonical!; } -function assertContainedAncestors(root: string, relativePath: string, control: NormalizedCacheOperationControl): string { +function assertContainedAncestors( + root: string, + relativePath: string, + control: NormalizedCacheOperationControl, +): string { const parts = relativePath.split('/'); let cursor = root; for (const part of parts.slice(0, -1)) { checkOperation(control); cursor = join(cursor, part); let stat: fs.Stats; - try { stat = fs.lstatSync(cursor); } - catch { fail('MISSING_INPUT', `Staged archive directory is missing: ${part}`); } - if (!stat.isDirectory() || stat.isSymbolicLink()) fail('UNSAFE_PATH', 'Staged archive has a symlink or non-directory ancestor'); - if (process.getuid && stat.uid !== process.getuid()) fail('UNSAFE_PATH', 'Staged archive ancestor has an unexpected owner'); + try { + stat = fs.lstatSync(cursor); + } catch { + fail('MISSING_INPUT', `Staged archive directory is missing: ${part}`); + } + if (!stat.isDirectory() || stat.isSymbolicLink()) + fail('UNSAFE_PATH', 'Staged archive has a symlink or non-directory ancestor'); + if (process.getuid && stat.uid !== process.getuid()) + fail('UNSAFE_PATH', 'Staged archive ancestor has an unexpected owner'); if ((stat.mode & 0o022) !== 0) fail('UNSAFE_PATH', 'Staged archive ancestor is writable by another user'); } const path = resolve(root, ...parts); - if (path !== root && !path.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', 'Staged archive escaped its staging directory'); + if (path !== root && !path.startsWith(`${root}${sep}`)) + fail('UNSAFE_PATH', 'Staged archive escaped its staging directory'); return path; } function openNoFollow(path: string, flags: number, mode?: number): number { const noFollow = (fs.constants as Record).O_NOFOLLOW ?? 0; const closeOnExec = (fs.constants as Record).O_CLOEXEC ?? 0; - try { return fs.openSync(path, flags | noFollow | closeOnExec, mode); } - catch { fail('UNSAFE_PATH', 'Archive file could not be opened without following links'); } + try { + return fs.openSync(path, flags | noFollow | closeOnExec, mode); + } catch { + fail('UNSAFE_PATH', 'Archive file could not be opened without following links'); + } } function readMetadata(path: string, digest: string, control: NormalizedCacheOperationControl): Metadata { checkOperation(control); let stat: fs.Stats; - try { stat = fs.lstatSync(path); } - catch { fail('INCOMPATIBLE_INPUT', `Cache metadata is missing for ${digest}`); } + try { + stat = fs.lstatSync(path); + } catch { + fail('INCOMPATIBLE_INPUT', `Cache metadata is missing for ${digest}`); + } assertOwnedRegular(stat!, 'Cache metadata', METADATA_LIMIT); if ((stat!.mode & 0o077) !== 0) fail('INCOMPATIBLE_INPUT', 'Cache metadata permissions are not private'); let value: unknown; - try { checkOperation(control); value = JSON.parse(fs.readFileSync(path, 'utf8')); checkOperation(control); } - catch (error) { + try { + checkOperation(control); + value = JSON.parse(fs.readFileSync(path, 'utf8')); + checkOperation(control); + } catch (error) { if (error instanceof CsoError) throw error; fail('INCOMPATIBLE_INPUT', `Cache metadata is invalid for ${digest}`); } const record = value as Partial; - if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join(',') !== 'bytes,createdAt,lastAccessedAt,sha256,version' || - record.version !== METADATA_VERSION || record.sha256 !== digest || !Number.isSafeInteger(record.bytes) || Number(record.bytes) < 0 || - !Number.isSafeInteger(record.createdAt) || Number(record.createdAt) < 0 || !Number.isSafeInteger(record.lastAccessedAt) || - Number(record.lastAccessedAt) < Number(record.createdAt)) fail('INCOMPATIBLE_INPUT', `Cache metadata is invalid for ${digest}`); + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).sort().join(',') !== 'bytes,createdAt,lastAccessedAt,sha256,version' || + record.version !== METADATA_VERSION || + record.sha256 !== digest || + !Number.isSafeInteger(record.bytes) || + Number(record.bytes) < 0 || + !Number.isSafeInteger(record.createdAt) || + Number(record.createdAt) < 0 || + !Number.isSafeInteger(record.lastAccessedAt) || + Number(record.lastAccessedAt) < Number(record.createdAt) + ) + fail('INCOMPATIBLE_INPUT', `Cache metadata is invalid for ${digest}`); return record as Metadata; } function writeMetadata(path: string, metadata: Metadata, noReplace = false): void { - try { atomicWriteSync(path, `${JSON.stringify(metadata)}\n`, { mode: 0o600, noReplace }); } - catch { fail('PERSISTENCE_FAILED', 'Cache metadata could not be written atomically'); } + try { + atomicWriteSync(path, `${JSON.stringify(metadata)}\n`, { mode: 0o600, noReplace }); + } catch { + fail('PERSISTENCE_FAILED', 'Cache metadata could not be written atomically'); + } } function removeRegular(path: string, label: string): void { const stat = fs.lstatSync(path); assertOwnedRegular(stat, label, Number.MAX_SAFE_INTEGER); - try { fs.unlinkSync(path); } - catch { fail('PERSISTENCE_FAILED', `${label} could not be removed`); } + try { + fs.unlinkSync(path); + } catch { + fail('PERSISTENCE_FAILED', `${label} could not be removed`); + } } function existsNoFollow(path: string): boolean { - try { fs.lstatSync(path); return true; } - catch (error: any) { + try { + fs.lstatSync(path); + return true; + } catch (error: any) { if (error?.code === 'ENOENT') return false; fail('INCOMPATIBLE_INPUT', 'Cache object could not be inspected safely'); } @@ -269,7 +357,10 @@ export class PublicArchiveCache { constructor(options: PublicArchiveCacheOptions) { if (!options || typeof options !== 'object') fail('INVALID_ARGUMENT', 'Cache options are required'); - this.maxBytes = boundedPositiveInteger(options.maxBytes ?? DEFAULT_PUBLIC_ARCHIVE_CACHE_BYTES, 'maxBytes'); + this.maxBytes = boundedPositiveInteger( + options.maxBytes ?? DEFAULT_PUBLIC_ARCHIVE_CACHE_BYTES, + 'maxBytes', + ); this.maxEntryBytes = boundedPositiveInteger(options.maxEntryBytes ?? this.maxBytes, 'maxEntryBytes'); if (this.maxEntryBytes > this.maxBytes) fail('INVALID_ARGUMENT', 'maxEntryBytes cannot exceed maxBytes'); this.clock = options.now ?? Date.now; @@ -281,32 +372,45 @@ export class PublicArchiveCache { this.recoveryDir = secureDirectory(join(this.root, 'recovery')); this.lockDir = join(this.root, '.lock'); this.stagingRoot = assertExistingDirectory(options.stagingRoot, 'Archive staging directory'); - if (this.root === this.stagingRoot || this.root.startsWith(`${this.stagingRoot}${sep}`) || this.stagingRoot.startsWith(`${this.root}${sep}`)) + if ( + this.root === this.stagingRoot || + this.root.startsWith(`${this.stagingRoot}${sep}`) || + this.stagingRoot.startsWith(`${this.root}${sep}`) + ) fail('UNSAFE_PATH', 'Archive staging and cache directories must be separate'); } /** Promote a verified staging file. The staging file is never deleted. */ promote(stagedPath: string, sha256: string, operation?: CacheOperationInput): PublicArchiveCacheEntry { const control = operationControl(operation); - const digest = expectedDigest(sha256), relativePath = stagedRelativePath(stagedPath); + const digest = expectedDigest(sha256), + relativePath = stagedRelativePath(stagedPath); const source = assertContainedAncestors(this.stagingRoot, relativePath, control); return this.withLock(() => { checkOperation(control); this.cleanIncoming(control); this.recoverInterruptedOperations(control); let initial: fs.Stats; - try { initial = fs.lstatSync(source); } - catch { fail('MISSING_INPUT', 'Staged archive is missing'); } + try { + initial = fs.lstatSync(source); + } catch { + fail('MISSING_INPUT', 'Staged archive is missing'); + } assertOwnedRegular(initial!, 'Staged archive', this.maxEntryBytes); - if ((initial!.mode & 0o022) !== 0) fail('UNSAFE_PATH', 'Staged archive must not be writable by another user'); + if ((initial!.mode & 0o022) !== 0) + fail('UNSAFE_PATH', 'Staged archive must not be writable by another user'); - const target = this.entryPath(digest), metadataPath = this.metadataPath(digest); - const targetExists = existsNoFollow(target), metadataExists = existsNoFollow(metadataPath); - if (targetExists !== metadataExists) fail('INCOMPATIBLE_INPUT', `Cache entry is incomplete for ${digest}`); + const target = this.entryPath(digest), + metadataPath = this.metadataPath(digest); + const targetExists = existsNoFollow(target), + metadataExists = existsNoFollow(metadataPath); + if (targetExists !== metadataExists) + fail('INCOMPATIBLE_INPUT', `Cache entry is incomplete for ${digest}`); if (targetExists) { const staged = this.hashFile(source, this.maxEntryBytes, false, stableStat(initial!), control); - if (staged.digest !== digest) fail('INCOMPATIBLE_INPUT', 'Staged archive does not match its caller-provided SHA-256'); + if (staged.digest !== digest) + fail('INCOMPATIBLE_INPUT', 'Staged archive does not match its caller-provided SHA-256'); const metadata = this.verifiedEntry(digest, control); return this.touch(metadata, control); } @@ -315,26 +419,42 @@ export class PublicArchiveCache { // displace any already-verified cache entry. Copying below hashes it a // second time so a staging race still fails closed. const authenticated = this.hashFile(source, this.maxEntryBytes, false, stableStat(initial!), control); - if (authenticated.digest !== digest) fail('INCOMPATIBLE_INPUT', 'Staged archive does not match its caller-provided SHA-256'); + if (authenticated.digest !== digest) + fail('INCOMPATIBLE_INPUT', 'Staged archive does not match its caller-provided SHA-256'); this.evictToFit(initial!.size, control); const incoming = join(this.incomingDir, `.incoming-${process.pid}-${randomBytes(12).toString('hex')}`); let promoted = false; try { const staged = this.copyAndHash(source, incoming, this.maxEntryBytes, stableStat(initial!), control); - if (staged.digest !== digest) fail('INCOMPATIBLE_INPUT', 'Staged archive does not match its caller-provided SHA-256'); + if (staged.digest !== digest) + fail('INCOMPATIBLE_INPUT', 'Staged archive does not match its caller-provided SHA-256'); if (staged.bytes !== initial!.size) fail('SNAPSHOT_RACE', 'Staged archive changed during promotion'); checkOperation(control); fs.chmodSync(incoming, 0o400); // A hard-link followed by unlink is an atomic no-replace publication on // the cache filesystem. rename(2) would silently replace a raced target. - try { fs.linkSync(incoming, target); fs.unlinkSync(incoming); } - catch { fail('PERSISTENCE_FAILED', 'Verified archive could not be promoted atomically'); } + try { + fs.linkSync(incoming, target); + fs.unlinkSync(incoming); + } catch { + fail('PERSISTENCE_FAILED', 'Verified archive could not be promoted atomically'); + } promoted = true; const now = this.timestamp(); - const metadata: Metadata = { version: 1, sha256: digest, bytes: staged.bytes, createdAt: now, lastAccessedAt: now }; - try { checkOperation(control); writeMetadata(metadataPath, metadata, true); } - catch (error) { - try { this.discardObject(target, 'entry', digest); } catch {} + const metadata: Metadata = { + version: 1, + sha256: digest, + bytes: staged.bytes, + createdAt: now, + lastAccessedAt: now, + }; + try { + checkOperation(control); + writeMetadata(metadataPath, metadata, true); + } catch (error) { + try { + this.discardObject(target, 'entry', digest); + } catch {} throw error; } return this.entry(metadata); @@ -355,9 +475,11 @@ export class PublicArchiveCache { checkOperation(control); this.cleanIncoming(control); this.recoverInterruptedOperations(control); - const targetExists = existsNoFollow(this.entryPath(digest)), metadataExists = existsNoFollow(this.metadataPath(digest)); + const targetExists = existsNoFollow(this.entryPath(digest)), + metadataExists = existsNoFollow(this.metadataPath(digest)); if (!targetExists && !metadataExists) return undefined; - if (targetExists !== metadataExists) fail('INCOMPATIBLE_INPUT', `Cache entry is incomplete for ${digest}`); + if (targetExists !== metadataExists) + fail('INCOMPATIBLE_INPUT', `Cache entry is incomplete for ${digest}`); return this.touch(this.verifiedEntry(digest, control), control); }, control); } @@ -371,7 +493,10 @@ export class PublicArchiveCache { this.recoverInterruptedOperations(control); const entries = this.inventory(control); let bytes = 0; - for (const entry of entries) { checkOperation(control); bytes += entry.bytes; } + for (const entry of entries) { + checkOperation(control); + bytes += entry.bytes; + } return { entries: entries.length, bytes, maxBytes: this.maxBytes }; }, control); } @@ -382,46 +507,77 @@ export class PublicArchiveCache { * cache paths. Every source and every copy is fully hashed in the same * critical section. */ - materialize(digests: string[], destinationRoot: string, operation?: CacheOperationInput): MaterializedArchive[] { + materialize( + digests: string[], + destinationRoot: string, + operation?: CacheOperationInput, + ): MaterializedArchive[] { const control = operationControl(operation); if (!Array.isArray(digests) || !digests.length) fail('INVALID_ARGUMENT', 'Archive materialization requires at least one SHA-256 digest'); if (digests.length > MAX_CACHE_DIRECTORY_ENTRIES) fail('INSUFFICIENT_CAPACITY', 'Archive materialization exceeds the cache entry limit'); - const selectedSet=new Set();for(const digest of digests){checkOperation(control);if(typeof digest!=='string')fail('INVALID_ARGUMENT', 'Archive materialization requires SHA-256 digest strings');selectedSet.add(expectedDigest(digest));} - checkOperation(control);const selected=[...selectedSet].sort();checkOperation(control); + const selectedSet = new Set(); + for (const digest of digests) { + checkOperation(control); + if (typeof digest !== 'string') + fail('INVALID_ARGUMENT', 'Archive materialization requires SHA-256 digest strings'); + selectedSet.add(expectedDigest(digest)); + } + checkOperation(control); + const selected = [...selectedSet].sort(); + checkOperation(control); const destination = assertExistingDirectory(destinationRoot, 'Archive materialization directory'); assertEmptyDirectory(destination, control); - if (destination === this.root || destination.startsWith(`${this.root}${sep}`) || this.root.startsWith(`${destination}${sep}`) || - destination === this.stagingRoot || destination.startsWith(`${this.stagingRoot}${sep}`) || this.stagingRoot.startsWith(`${destination}${sep}`)) + if ( + destination === this.root || + destination.startsWith(`${this.root}${sep}`) || + this.root.startsWith(`${destination}${sep}`) || + destination === this.stagingRoot || + destination.startsWith(`${this.stagingRoot}${sep}`) || + this.stagingRoot.startsWith(`${destination}${sep}`) + ) fail('UNSAFE_PATH', 'Archive materialization directory must be separate from cache and staging roots'); return this.withLock(() => { checkOperation(control); this.cleanIncoming(control); this.recoverInterruptedOperations(control); - const created: string[] = [], result: MaterializedArchive[] = []; + const created: string[] = [], + result: MaterializedArchive[] = []; try { for (const digest of selected) { checkOperation(control); - const metadata = this.verifiedEntry(digest, control), source = this.entryPath(digest), initial = fs.lstatSync(source); + const metadata = this.verifiedEntry(digest, control), + source = this.entryPath(digest), + initial = fs.lstatSync(source); assertOwnedRegular(initial, 'Cached archive', this.maxEntryBytes, true); const target = join(destination, digest); let copied: { digest: string; bytes: number }; - try { copied = this.copyAndHash(source, target, this.maxEntryBytes, stableStat(initial), control); } - catch (error) { - if (existsNoFollow(target)) try { removeRegular(target, 'Incomplete run-owned archive copy'); } catch {} + try { + copied = this.copyAndHash(source, target, this.maxEntryBytes, stableStat(initial), control); + } catch (error) { + if (existsNoFollow(target)) + try { + removeRegular(target, 'Incomplete run-owned archive copy'); + } catch {} throw error; } created.push(target); - if (copied.digest !== digest || copied.bytes !== metadata.bytes) fail('SNAPSHOT_RACE', 'Cached archive changed while its run-owned copy was materialized'); + if (copied.digest !== digest || copied.bytes !== metadata.bytes) + fail('SNAPSHOT_RACE', 'Cached archive changed while its run-owned copy was materialized'); fs.chmodSync(target, 0o400); const verified = this.hashFile(target, this.maxEntryBytes, true, undefined, control); - if (verified.digest !== digest || verified.bytes !== metadata.bytes) fail('SNAPSHOT_RACE', 'Run-owned archive copy failed verification'); + if (verified.digest !== digest || verified.bytes !== metadata.bytes) + fail('SNAPSHOT_RACE', 'Run-owned archive copy failed verification'); result.push(Object.freeze({ sha256: digest, path: target, bytes: verified.bytes })); } return result; } catch (error) { - for (const path of created.reverse()) { try { removeRegular(path, 'Incomplete run-owned archive copy'); } catch {} } + for (const path of created.reverse()) { + try { + removeRegular(path, 'Incomplete run-owned archive copy'); + } catch {} + } throw error; } }, control); @@ -429,21 +585,34 @@ export class PublicArchiveCache { private timestamp(): number { const value = this.clock(); - if (!Number.isSafeInteger(value) || value < 0) fail('PERSISTENCE_FAILED', 'Cache clock returned an invalid timestamp'); + if (!Number.isSafeInteger(value) || value < 0) + fail('PERSISTENCE_FAILED', 'Cache clock returned an invalid timestamp'); return value; } - private entryPath(digest: string): string { return join(this.entriesDir, digest); } - private metadataPath(digest: string): string { return join(this.metadataDir, `${digest}.json`); } + private entryPath(digest: string): string { + return join(this.entriesDir, digest); + } + private metadataPath(digest: string): string { + return join(this.metadataDir, `${digest}.json`); + } private entry(metadata: Metadata): PublicArchiveCacheEntry { - return Object.freeze({ sha256: metadata.sha256, path: this.entryPath(metadata.sha256), bytes: metadata.bytes, - createdAt: metadata.createdAt, lastAccessedAt: metadata.lastAccessedAt }); + return Object.freeze({ + sha256: metadata.sha256, + path: this.entryPath(metadata.sha256), + bytes: metadata.bytes, + createdAt: metadata.createdAt, + lastAccessedAt: metadata.lastAccessedAt, + }); } private touch(metadata: Metadata, control: NormalizedCacheOperationControl): PublicArchiveCacheEntry { checkOperation(control); - const updated: Metadata = { ...metadata, lastAccessedAt: Math.max(metadata.lastAccessedAt, this.timestamp()) }; + const updated: Metadata = { + ...metadata, + lastAccessedAt: Math.max(metadata.lastAccessedAt, this.timestamp()), + }; checkOperation(control); writeMetadata(this.metadataPath(metadata.sha256), updated); return this.entry(updated); @@ -453,47 +622,71 @@ export class PublicArchiveCache { checkOperation(control); const metadata = readMetadata(this.metadataPath(digest), digest, control); const result = this.hashFile(this.entryPath(digest), this.maxEntryBytes, true, undefined, control); - if (result.digest !== digest || result.bytes !== metadata.bytes) fail('INCOMPATIBLE_INPUT', `Cached archive failed SHA-256 verification: ${digest}`); + if (result.digest !== digest || result.bytes !== metadata.bytes) + fail('INCOMPATIBLE_INPUT', `Cached archive failed SHA-256 verification: ${digest}`); return metadata; } - private hashFile(path: string, maxBytes: number, immutable: boolean, expected: StableStat | undefined, - control: NormalizedCacheOperationControl): { digest: string; bytes: number } { + private hashFile( + path: string, + maxBytes: number, + immutable: boolean, + expected: StableStat | undefined, + control: NormalizedCacheOperationControl, + ): { digest: string; bytes: number } { checkOperation(control); const fd = openNoFollow(path, fs.constants.O_RDONLY); try { const beforeStat = fs.fstatSync(fd); assertOwnedRegular(beforeStat, immutable ? 'Cached archive' : 'Staged archive', maxBytes, immutable); - const before = stableStat(beforeStat), hash = createHash('sha256'), buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES); - if (expected && !sameStat(expected, before)) fail('SNAPSHOT_RACE', 'Archive changed before it could be verified'); + const before = stableStat(beforeStat), + hash = createHash('sha256'), + buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES); + if (expected && !sameStat(expected, before)) + fail('SNAPSHOT_RACE', 'Archive changed before it could be verified'); let bytes = 0; for (;;) { checkOperation(control); const read = fs.readSync(fd, buffer, 0, buffer.length, null); if (!read) break; bytes += read; - if (bytes > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Archive exceeded its byte limit while being read'); + if (bytes > maxBytes) + fail('INSUFFICIENT_CAPACITY', 'Archive exceeded its byte limit while being read'); hash.update(buffer.subarray(0, read)); checkOperation(control); } checkOperation(control); const after = stableStat(fs.fstatSync(fd)); - if (!sameStat(before, after) || bytes !== before.size) fail('SNAPSHOT_RACE', 'Archive changed while it was being verified'); + if (!sameStat(before, after) || bytes !== before.size) + fail('SNAPSHOT_RACE', 'Archive changed while it was being verified'); return { digest: hash.digest('hex'), bytes }; - } finally { fs.closeSync(fd); } + } finally { + fs.closeSync(fd); + } } - private copyAndHash(source: string, destination: string, maxBytes: number, expected: StableStat, - control: NormalizedCacheOperationControl): { digest: string; bytes: number } { + private copyAndHash( + source: string, + destination: string, + maxBytes: number, + expected: StableStat, + control: NormalizedCacheOperationControl, + ): { digest: string; bytes: number } { checkOperation(control); const sourceFd = openNoFollow(source, fs.constants.O_RDONLY); let destinationFd: number | undefined; try { const beforeStat = fs.fstatSync(sourceFd); assertOwnedRegular(beforeStat, 'Staged archive', maxBytes); - const before = stableStat(beforeStat), hash = createHash('sha256'), buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES); + const before = stableStat(beforeStat), + hash = createHash('sha256'), + buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES); if (!sameStat(expected, before)) fail('SNAPSHOT_RACE', 'Staged archive changed before promotion'); - destinationFd = openNoFollow(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600); + destinationFd = openNoFollow( + destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); let bytes = 0; for (;;) { checkOperation(control); @@ -501,13 +694,15 @@ export class PublicArchiveCache { if (!read) break; bytes += read; if (bytes > expected.size) fail('SNAPSHOT_RACE', 'Staged archive grew during promotion'); - if (bytes > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Archive exceeded its byte limit during promotion'); + if (bytes > maxBytes) + fail('INSUFFICIENT_CAPACITY', 'Archive exceeded its byte limit during promotion'); hash.update(buffer.subarray(0, read)); let offset = 0; while (offset < read) { checkOperation(control); const written = fs.writeSync(destinationFd, buffer, offset, read - offset); - if (written <= 0) fail('PERSISTENCE_FAILED', 'Archive copy stopped before the current chunk was written'); + if (written <= 0) + fail('PERSISTENCE_FAILED', 'Archive copy stopped before the current chunk was written'); offset += written; checkOperation(control); } @@ -516,7 +711,8 @@ export class PublicArchiveCache { fs.fsyncSync(destinationFd); checkOperation(control); const after = stableStat(fs.fstatSync(sourceFd)); - if (!sameStat(before, after) || bytes !== before.size) fail('SNAPSHOT_RACE', 'Staged archive changed during promotion'); + if (!sameStat(before, after) || bytes !== before.size) + fail('SNAPSHOT_RACE', 'Staged archive changed during promotion'); return { digest: hash.digest('hex'), bytes }; } finally { if (destinationFd !== undefined) fs.closeSync(destinationFd); @@ -528,19 +724,33 @@ export class PublicArchiveCache { checkOperation(control); const entryNames = boundedDirectoryNames(this.entriesDir, 'Cache entries directory', control), metadataNames = boundedDirectoryNames(this.metadataDir, 'Cache metadata directory', control); - const entrySet=new Set(),metadataSet=new Set(); - for (const name of entryNames) {checkOperation(control);if (!SHA256.test(name)) fail('INCOMPATIBLE_INPUT', 'Cache entries directory contains an unexpected object');entrySet.add(name);} - for (const name of metadataNames) {checkOperation(control);if (!/^[a-f0-9]{64}\.json$/.test(name)) fail('INCOMPATIBLE_INPUT', 'Cache metadata directory contains an unexpected object');metadataSet.add(name.slice(0,-5));} - if (entrySet.size !== metadataSet.size) fail('INCOMPATIBLE_INPUT', 'Cache entries and metadata are inconsistent'); - for(const name of entrySet){checkOperation(control);if(!metadataSet.has(name)) - fail('INCOMPATIBLE_INPUT', 'Cache entries and metadata are inconsistent'); + const entrySet = new Set(), + metadataSet = new Set(); + for (const name of entryNames) { + checkOperation(control); + if (!SHA256.test(name)) + fail('INCOMPATIBLE_INPUT', 'Cache entries directory contains an unexpected object'); + entrySet.add(name); } - const inventory = entryNames.map(digest => { + for (const name of metadataNames) { + checkOperation(control); + if (!/^[a-f0-9]{64}\.json$/.test(name)) + fail('INCOMPATIBLE_INPUT', 'Cache metadata directory contains an unexpected object'); + metadataSet.add(name.slice(0, -5)); + } + if (entrySet.size !== metadataSet.size) + fail('INCOMPATIBLE_INPUT', 'Cache entries and metadata are inconsistent'); + for (const name of entrySet) { + checkOperation(control); + if (!metadataSet.has(name)) fail('INCOMPATIBLE_INPUT', 'Cache entries and metadata are inconsistent'); + } + const inventory = entryNames.map((digest) => { checkOperation(control); const stat = fs.lstatSync(this.entryPath(digest)); assertOwnedRegular(stat, 'Cached archive', this.maxEntryBytes, true); const metadata = readMetadata(this.metadataPath(digest), digest, control); - if (metadata.bytes !== stat.size) fail('INCOMPATIBLE_INPUT', `Cache size metadata is inconsistent for ${digest}`); + if (metadata.bytes !== stat.size) + fail('INCOMPATIBLE_INPUT', `Cache size metadata is inconsistent for ${digest}`); return metadata; }); checkOperation(control); @@ -551,10 +761,18 @@ export class PublicArchiveCache { if (!Number.isSafeInteger(incomingBytes) || incomingBytes < 0 || incomingBytes > this.maxBytes) fail('INSUFFICIENT_CAPACITY', 'Archive cannot fit within the public-cache limit'); checkOperation(control); - const entries = this.inventory(control);checkOperation(control);entries.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt || a.createdAt - b.createdAt || a.sha256.localeCompare(b.sha256)); + const entries = this.inventory(control); + checkOperation(control); + entries.sort( + (a, b) => + a.lastAccessedAt - b.lastAccessedAt || a.createdAt - b.createdAt || a.sha256.localeCompare(b.sha256), + ); checkOperation(control); let total = 0; - for (const item of entries) { checkOperation(control); total += item.bytes; } + for (const item of entries) { + checkOperation(control); + total += item.bytes; + } for (const item of entries) { checkOperation(control); if (total + incomingBytes <= this.maxBytes) break; @@ -564,36 +782,60 @@ export class PublicArchiveCache { removeRegular(metadata, 'Evicted cache metadata'); total -= item.bytes; } - if (total + incomingBytes > this.maxBytes) fail('INSUFFICIENT_CAPACITY', 'Archive cache could not free enough verified capacity'); + if (total + incomingBytes > this.maxBytes) + fail('INSUFFICIENT_CAPACITY', 'Archive cache could not free enough verified capacity'); } private cleanIncoming(control: NormalizedCacheOperationControl): void { const incomingNames = boundedDirectoryNames(this.incomingDir, 'Cache incoming directory', control); - let publishedByInode:Map|undefined; + let publishedByInode: Map | undefined; for (const name of incomingNames) { checkOperation(control); - if (!/^\.incoming-\d+-[a-f0-9]{24}$/.test(name)) fail('INCOMPATIBLE_INPUT', 'Cache incoming directory contains an unexpected object'); - const incoming = join(this.incomingDir, name), stat = fs.lstatSync(incoming); - if (!stat.isFile() || stat.isSymbolicLink() || ![1, 2].includes(stat.nlink) || stat.size > this.maxEntryBytes || - (process.getuid && stat.uid !== process.getuid())) fail('UNSAFE_PATH', 'Incomplete cache archive is not a bounded regular file'); + if (!/^\.incoming-\d+-[a-f0-9]{24}$/.test(name)) + fail('INCOMPATIBLE_INPUT', 'Cache incoming directory contains an unexpected object'); + const incoming = join(this.incomingDir, name), + stat = fs.lstatSync(incoming); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + ![1, 2].includes(stat.nlink) || + stat.size > this.maxEntryBytes || + (process.getuid && stat.uid !== process.getuid()) + ) + fail('UNSAFE_PATH', 'Incomplete cache archive is not a bounded regular file'); if (stat.nlink === 2) { - if(!publishedByInode){ - publishedByInode=new Map(); - for(const entry of boundedDirectoryNames(this.entriesDir, 'Cache entries directory', control)){ + if (!publishedByInode) { + publishedByInode = new Map(); + for (const entry of boundedDirectoryNames(this.entriesDir, 'Cache entries directory', control)) { checkOperation(control); - if (!SHA256.test(entry)) fail('INCOMPATIBLE_INPUT', 'Cache entries directory contains an unexpected object'); - const candidate=fs.lstatSync(this.entryPath(entry)),key=`${candidate.dev}:${candidate.ino}`,matches=publishedByInode.get(key)??[]; - matches.push(entry);publishedByInode.set(key,matches); + if (!SHA256.test(entry)) + fail('INCOMPATIBLE_INPUT', 'Cache entries directory contains an unexpected object'); + const candidate = fs.lstatSync(this.entryPath(entry)), + key = `${candidate.dev}:${candidate.ino}`, + matches = publishedByInode.get(key) ?? []; + matches.push(entry); + publishedByInode.set(key, matches); } } - const matches=publishedByInode.get(`${stat.dev}:${stat.ino}`)??[]; - if (matches.length !== 1) fail('UNSAFE_PATH', 'Incoming archive hard link does not match one published cache entry'); + const matches = publishedByInode.get(`${stat.dev}:${stat.ino}`) ?? []; + if (matches.length !== 1) + fail('UNSAFE_PATH', 'Incoming archive hard link does not match one published cache entry'); const target = fs.lstatSync(this.entryPath(matches[0])); - if (!target.isFile() || target.isSymbolicLink() || target.nlink !== 2 || target.size > this.maxEntryBytes || - (process.getuid && target.uid !== process.getuid()) || (target.mode & 0o222) !== 0) + if ( + !target.isFile() || + target.isSymbolicLink() || + target.nlink !== 2 || + target.size > this.maxEntryBytes || + (process.getuid && target.uid !== process.getuid()) || + (target.mode & 0o222) !== 0 + ) fail('SNAPSHOT_RACE', 'Incoming archive link count or identity changed during recovery'); } - try { fs.unlinkSync(incoming); } catch { fail('PERSISTENCE_FAILED', 'Incomplete cache archive could not be removed'); } + try { + fs.unlinkSync(incoming); + } catch { + fail('PERSISTENCE_FAILED', 'Incomplete cache archive could not be removed'); + } } } @@ -614,14 +856,30 @@ export class PublicArchiveCache { for (const name of metadataObjects) { checkOperation(control); if (/^[a-f0-9]{64}\.json\.tmp\.\d+\.[a-f0-9]{8}$/.test(name)) this.recoverMetadataTemp(name, control); - else if (!/^[a-f0-9]{64}\.json$/.test(name)) fail('INCOMPATIBLE_INPUT', 'Cache metadata directory contains an unexpected object'); + else if (!/^[a-f0-9]{64}\.json$/.test(name)) + fail('INCOMPATIBLE_INPUT', 'Cache metadata directory contains an unexpected object'); } const metadata = boundedDirectoryNames(this.metadataDir, 'Cache metadata directory', control); - for (const name of entries) if (!SHA256.test(name)) fail('INCOMPATIBLE_INPUT', 'Cache entries directory contains an unexpected object'); - for (const name of metadata) if (!/^[a-f0-9]{64}\.json$/.test(name)) fail('INCOMPATIBLE_INPUT', 'Cache metadata directory contains an unexpected object'); - const entrySet=new Set(),metadataSet=new Set(),digests=new Set(); - for(const name of entries){checkOperation(control);entrySet.add(name);digests.add(name);} - for(const name of metadata){checkOperation(control);const digest=name.slice(0,-5);metadataSet.add(digest);digests.add(digest);} + for (const name of entries) + if (!SHA256.test(name)) + fail('INCOMPATIBLE_INPUT', 'Cache entries directory contains an unexpected object'); + for (const name of metadata) + if (!/^[a-f0-9]{64}\.json$/.test(name)) + fail('INCOMPATIBLE_INPUT', 'Cache metadata directory contains an unexpected object'); + const entrySet = new Set(), + metadataSet = new Set(), + digests = new Set(); + for (const name of entries) { + checkOperation(control); + entrySet.add(name); + digests.add(name); + } + for (const name of metadata) { + checkOperation(control); + const digest = name.slice(0, -5); + metadataSet.add(digest); + digests.add(digest); + } for (const digest of digests) { checkOperation(control); if (entrySet.has(digest) === metadataSet.has(digest)) continue; @@ -631,20 +889,33 @@ export class PublicArchiveCache { } private recoveryPath(kind: 'entry' | 'metadata', digest: string): string { - return join(this.recoveryDir, `.recovery-${kind}-${digest}-${process.pid}-${randomBytes(12).toString('hex')}`); + return join( + this.recoveryDir, + `.recovery-${kind}-${digest}-${process.pid}-${randomBytes(12).toString('hex')}`, + ); } private moveToRecovery(path: string, kind: 'entry' | 'metadata', digest: string): string { const before = fs.lstatSync(path); - assertOwnedRegular(before, kind === 'entry' ? 'Cached archive' : 'Cache metadata', kind === 'entry' ? this.maxEntryBytes : METADATA_LIMIT, kind === 'entry'); - if (kind === 'metadata' && (before.mode & 0o077) !== 0) fail('UNSAFE_PATH', 'Cache metadata permissions are not private'); + assertOwnedRegular( + before, + kind === 'entry' ? 'Cached archive' : 'Cache metadata', + kind === 'entry' ? this.maxEntryBytes : METADATA_LIMIT, + kind === 'entry', + ); + if (kind === 'metadata' && (before.mode & 0o077) !== 0) + fail('UNSAFE_PATH', 'Cache metadata permissions are not private'); const destination = this.recoveryPath(kind, digest); - try { fs.renameSync(path, destination); } - catch { fail('PERSISTENCE_FAILED', 'Interrupted cache object could not be quarantined atomically'); } + try { + fs.renameSync(path, destination); + } catch { + fail('PERSISTENCE_FAILED', 'Interrupted cache object could not be quarantined atomically'); + } const after = fs.lstatSync(destination); // rename(2) can update ctime; stable inode identity, content size, mode, // link count, mtime, and ownership prove the moved object is the one read. - if (!sameRenamedInode(stableStat(before), stableStat(after))) fail('SNAPSHOT_RACE', 'Cache object changed while it was quarantined'); + if (!sameRenamedInode(stableStat(before), stableStat(after))) + fail('SNAPSHOT_RACE', 'Cache object changed while it was quarantined'); return destination; } @@ -655,49 +926,95 @@ export class PublicArchiveCache { private recoverMetadataTemp(name: string, control: NormalizedCacheOperationControl): void { checkOperation(control); - const path = join(this.metadataDir, name), stat = fs.lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || ![1, 2].includes(stat.nlink) || stat.size > METADATA_LIMIT || - (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o077) !== 0) + const path = join(this.metadataDir, name), + stat = fs.lstatSync(path); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + ![1, 2].includes(stat.nlink) || + stat.size > METADATA_LIMIT || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o077) !== 0 + ) fail('UNSAFE_PATH', 'Interrupted cache metadata write is not a private regular file'); if (stat.nlink === 2) { const target = this.metadataPath(name.slice(0, 64)); let targetStat: fs.Stats; - try { targetStat = fs.lstatSync(target); } catch { fail('INCOMPATIBLE_INPUT', 'Hard-linked metadata temp has no published target'); } - if (!targetStat!.isFile() || targetStat!.isSymbolicLink() || targetStat!.dev !== stat.dev || targetStat!.ino !== stat.ino || targetStat!.nlink !== 2) + try { + targetStat = fs.lstatSync(target); + } catch { + fail('INCOMPATIBLE_INPUT', 'Hard-linked metadata temp has no published target'); + } + if ( + !targetStat!.isFile() || + targetStat!.isSymbolicLink() || + targetStat!.dev !== stat.dev || + targetStat!.ino !== stat.ino || + targetStat!.nlink !== 2 + ) fail('UNSAFE_PATH', 'Interrupted metadata hard link does not match its published target'); } - try { fs.unlinkSync(path); } catch { fail('PERSISTENCE_FAILED', 'Interrupted cache metadata write could not be removed'); } + try { + fs.unlinkSync(path); + } catch { + fail('PERSISTENCE_FAILED', 'Interrupted cache metadata write could not be removed'); + } } private withLock(callback: () => T, control: NormalizedCacheOperationControl): T { checkOperation(control); const marker = `${JSON.stringify({ protocol: CACHE_LOCK_PROTOCOL })}\n`; - const options={label:'Cache lock protocol',maxBytes:METADATA_LIMIT,validate:(value:unknown)=>{ - if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).join(',')!=='protocol'||(value as any).protocol!==CACHE_LOCK_PROTOCOL) - fail('INCOMPATIBLE_INPUT','Archive-cache lock protocol is invalid'); - }}; - const tempPattern=/^\.lock\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/; - for(const name of boundedDirectoryNames(this.root, 'Cache root directory', control)){ + const options = { + label: 'Cache lock protocol', + maxBytes: METADATA_LIMIT, + validate: (value: unknown) => { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).join(',') !== 'protocol' || + (value as any).protocol !== CACHE_LOCK_PROTOCOL + ) + fail('INCOMPATIBLE_INPUT', 'Archive-cache lock protocol is invalid'); + }, + }; + const tempPattern = /^\.lock\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/; + for (const name of boundedDirectoryNames(this.root, 'Cache root directory', control)) { checkOperation(control); - const match=name.match(tempPattern);if(!match)continue; - const temporary=join(this.root,name),publisherPid=Number(match[1]); - if(existsNoFollow(this.lockDir))recoverAtomicNoReplaceJson(this.lockDir,options); - if(existsNoFollow(temporary))discardAtomicNoReplaceTemp(temporary,publisherPid,options); + const match = name.match(tempPattern); + if (!match) continue; + const temporary = join(this.root, name), + publisherPid = Number(match[1]); + if (existsNoFollow(this.lockDir)) recoverAtomicNoReplaceJson(this.lockDir, options); + if (existsNoFollow(temporary)) discardAtomicNoReplaceTemp(temporary, publisherPid, options); } - try { atomicWriteSync(this.lockDir, marker, { mode: 0o600, noReplace: true }); } - catch (error: any) { - if (error?.code !== 'EEXIST') fail('PERSISTENCE_FAILED', 'Archive-cache lock protocol could not be initialized'); - recoverAtomicNoReplaceJson(this.lockDir,options); + try { + atomicWriteSync(this.lockDir, marker, { mode: 0o600, noReplace: true }); + } catch (error: any) { + if (error?.code !== 'EEXIST') + fail('PERSISTENCE_FAILED', 'Archive-cache lock protocol could not be initialized'); + recoverAtomicNoReplaceJson(this.lockDir, options); const stat = fs.lstatSync(this.lockDir); if (stat.isDirectory() && !stat.isSymbolicLink()) - fail('INSUFFICIENT_CAPACITY', 'A legacy archive-cache helper may still own or initialize this cache; its lock was left intact'); + fail( + 'INSUFFICIENT_CAPACITY', + 'A legacy archive-cache helper may still own or initialize this cache; its lock was left intact', + ); assertOwnedRegular(stat, 'Cache lock protocol', METADATA_LIMIT); if ((stat.mode & 0o077) !== 0) fail('UNSAFE_PATH', 'Cache lock protocol permissions are not private'); - let protocol:unknown;try{protocol=JSON.parse(fs.readFileSync(this.lockDir,'utf8')).protocol;}catch{} - if(protocol!==CACHE_LOCK_PROTOCOL)fail('INCOMPATIBLE_INPUT','Archive-cache lock protocol is invalid'); + let protocol: unknown; + try { + protocol = JSON.parse(fs.readFileSync(this.lockDir, 'utf8')).protocol; + } catch {} + if (protocol !== CACHE_LOCK_PROTOCOL) + fail('INCOMPATIBLE_INPUT', 'Archive-cache lock protocol is invalid'); } - const result=withStateLock(this.root,()=>{checkOperation(control);return callback();}); - if(result&&typeof (result as any).then==='function')fail('PERSISTENCE_FAILED','Archive-cache operation unexpectedly became asynchronous'); + const result = withStateLock(this.root, () => { + checkOperation(control); + return callback(); + }); + if (result && typeof (result as any).then === 'function') + fail('PERSISTENCE_FAILED', 'Archive-cache operation unexpectedly became asynchronous'); return result as T; } } diff --git a/lib/cso/cli.ts b/lib/cso/cli.ts index 9be4ef35c..06a8dedb5 100644 --- a/lib/cso/cli.ts +++ b/lib/cso/cli.ts @@ -4,30 +4,115 @@ import * as os from 'node:os'; import { randomBytes } from 'node:crypto'; import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; import { - ABI, ApplicationModel, CoverageRecord, CsoError, FindingV3, PreparationProof, RunPolicy, RunReportV3, SnapshotEntry, SnapshotManifest, SubmissionV3, - canonical, completeness, importLegacy, object, relativePath, renderReport, rootCauseIdentity, sha256, snapshotPathHandle, snapshotPathHandleId, snapshotPathId, snapshotReference, string, strings, validateCoverage, validateFinding, validateVerificationRequest, + ABI, + ApplicationModel, + CoverageRecord, + CsoError, + FindingV3, + PreparationProof, + RunPolicy, + RunReportV3, + SnapshotEntry, + SnapshotManifest, + SubmissionV3, + canonical, + completeness, + importLegacy, + object, + relativePath, + renderReport, + rootCauseIdentity, + sha256, + snapshotPathHandle, + snapshotPathHandleId, + snapshotPathId, + snapshotReference, + string, + strings, + validateCoverage, + validateFinding, + validateVerificationRequest, } from './contracts'; import { capture, containedFile, assertSnapshot } from './snapshot'; -import { assertStateOutside, event, finalizeReplayTemporary, loadReport, newRun, privateRoot, publicReport, PUBLIC_SOURCE_ROOT, readJson, repoId, requireTime, retention, runDirectory, saveReport, secureDirectory, withLock, writeHelperJson, writeJson, writeJsonExclusive } from './state'; +import { + assertStateOutside, + event, + finalizeReplayTemporary, + loadReport, + newRun, + privateRoot, + publicReport, + PUBLIC_SOURCE_ROOT, + readJson, + repoId, + requireTime, + retention, + runDirectory, + saveReport, + secureDirectory, + withLock, + writeHelperJson, + writeJson, + writeJsonExclusive, +} from './state'; import { dockerEndpoint, dockerProbe, ISOLATION_POLICY_HASH } from './docker'; import { executable, git, redact, sanitizeForJson, sanitizeHelperForJson } from './process'; import { inspectPreparation } from './preparation'; -import { assertRuntimeCompatible, RUNTIME_CATALOG, selectRuntime, validateRuntimeCatalog, type RuntimeCatalog } from './runtime-catalog'; +import { + assertRuntimeCompatible, + RUNTIME_CATALOG, + selectRuntime, + validateRuntimeCatalog, + type RuntimeCatalog, +} from './runtime-catalog'; import { PublicArchiveCache, publicArchiveCacheRoot } from './cache'; -import { admitPreparationRuntime, admitPreparationSidecar, PreparationExecutor, type DependencyClosure, type RailsDatabaseSelection } from './preparation-executor'; +import { + admitPreparationRuntime, + admitPreparationSidecar, + PreparationExecutor, + type DependencyClosure, + type RailsDatabaseSelection, +} from './preparation-executor'; import { DockerPreparationSandboxRunner } from './preparation-docker'; import { importSarif, SCANNER_IDS, ScannerId } from './scanners'; -import { SCANNER_CATALOG, selectScanner, validateScannerCatalog, type ScannerCatalog } from './scanner-catalog'; +import { + SCANNER_CATALOG, + selectScanner, + validateScannerCatalog, + type ScannerCatalog, +} from './scanner-catalog'; import { executeScanner, scannerCoverage, validateScannerRequest } from './scanner-executor'; -import { canonicalStartPlan, canonicalTestPlan, DockerVerificationExecutor, makeReviewArtifact, patchHash, validateRepairBundle, validateReviewArtifact, VerificationAttemptError, verificationHarnessHash, verifyRepair, type VerificationExecutor } from './verification'; +import { + canonicalStartPlan, + canonicalTestPlan, + DockerVerificationExecutor, + makeReviewArtifact, + patchHash, + validateRepairBundle, + validateReviewArtifact, + VerificationAttemptError, + verificationHarnessHash, + verifyRepair, + type VerificationExecutor, +} from './verification'; import { readBoundedStable } from './bounded-file'; import { assertionWitnessReplayHash, runAssertionWitnessChild } from './witness'; import { historyForPath } from './history'; -import { catalogImageProvisioningPolicy, inspectCatalogImages, openLocalCatalogImageSession, provisionCatalogImages, qualifiedCatalogImages, type CatalogImageSessionFactory } from './image-provisioning'; +import { + catalogImageProvisioningPolicy, + inspectCatalogImages, + openLocalCatalogImageSession, + provisionCatalogImages, + qualifiedCatalogImages, + type CatalogImageSessionFactory, +} from './image-provisioning'; const VERSION = '3.0.0'; -const RETENTION_MAINTENANCE_MS=1_000,RETENTION_MAX_ENTRIES=100_000,REPLAY_LOOKUP_MAX_ENTRIES=10_000; -const productionCatalogImageSession:CatalogImageSessionFactory=deadline=>openLocalCatalogImageSession(process.env,deadline); +const RETENTION_MAINTENANCE_MS = 1_000, + RETENTION_MAX_ENTRIES = 100_000, + REPLAY_LOOKUP_MAX_ENTRIES = 10_000; +const productionCatalogImageSession: CatalogImageSessionFactory = (deadline) => + openLocalCatalogImageSession(process.env, deadline); /** Internal qualification seam. The public entrypoint below always supplies committed dependencies. */ export interface CsoCliDependencies { readonly runtimeCatalog: RuntimeCatalog; @@ -35,19 +120,44 @@ export interface CsoCliDependencies { readonly catalogImageSession?: CatalogImageSessionFactory; readonly watchdogPath: () => string; } -const GENERATION_LOCK_FD=(()=>{ +const GENERATION_LOCK_FD = (() => { // Source-mode developer/test runs use Bun directly. For installed builds, // this catches accidental direct use of the internal payload. It is not an // authentication mechanism against the trusted same-user host: that user // can reproduce an inherited descriptor or environment value. The public // native launcher is the security boundary because it scrubs runtime and // loader variables before Bun starts. - if(/^bun(?:\.exe)?$/i.test(basename(process.execPath)))return undefined; - if(process.platform==='win32'){ - if(process.env.GSTACK_CSO_GENERATION_GUARD!=='inherited-windows-generation-handle-v3')throw new CsoError('ISOLATION_FAILED','Direct use of the internal CSO payload is unsupported; invoke gstack-cso-launcher'); - delete process.env.GSTACK_CSO_GENERATION_GUARD;return undefined; + if (/^bun(?:\.exe)?$/i.test(basename(process.execPath))) return undefined; + if (process.platform === 'win32') { + if (process.env.GSTACK_CSO_GENERATION_GUARD !== 'inherited-windows-generation-handle-v3') + throw new CsoError( + 'ISOLATION_FAILED', + 'Direct use of the internal CSO payload is unsupported; invoke gstack-cso-launcher', + ); + delete process.env.GSTACK_CSO_GENERATION_GUARD; + return undefined; } - const raw=process.env.GSTACK_CSO_GENERATION_LOCK_FD;if(raw===undefined||!/^(?:[3-9]|[1-9][0-9]+)$/.test(raw))throw new CsoError('ISOLATION_FAILED','Direct use of the internal CSO payload is unsupported; invoke gstack-cso-launcher');const fd=Number(raw);let stat:fs.Stats;try{stat=fs.fstatSync(fd);}catch{throw new CsoError('ISOLATION_FAILED','The launcher publication lease was not preserved by the runtime');}const install=fs.statSync(dirname(process.execPath));if(!stat.isDirectory()||stat.dev!==install.dev||stat.ino!==install.ino)throw new CsoError('ISOLATION_FAILED','The launcher publication lease does not name the helper installation directory');delete process.env.GSTACK_CSO_GENERATION_LOCK_FD;return fd; + const raw = process.env.GSTACK_CSO_GENERATION_LOCK_FD; + if (raw === undefined || !/^(?:[3-9]|[1-9][0-9]+)$/.test(raw)) + throw new CsoError( + 'ISOLATION_FAILED', + 'Direct use of the internal CSO payload is unsupported; invoke gstack-cso-launcher', + ); + const fd = Number(raw); + let stat: fs.Stats; + try { + stat = fs.fstatSync(fd); + } catch { + throw new CsoError('ISOLATION_FAILED', 'The launcher publication lease was not preserved by the runtime'); + } + const install = fs.statSync(dirname(process.execPath)); + if (!stat.isDirectory() || stat.dev !== install.dev || stat.ino !== install.ino) + throw new CsoError( + 'ISOLATION_FAILED', + 'The launcher publication lease does not name the helper installation directory', + ); + delete process.env.GSTACK_CSO_GENERATION_LOCK_FD; + return fd; })(); void GENERATION_LOCK_FD; const HELP = `gstack-cso ${VERSION} (helper ABI ${ABI}) @@ -78,431 +188,2547 @@ Usage: Static runs never execute application code. Target and scanner execution is Docker-only and requires a qualified immutable catalog.`; const SCHEMA = { - version:3, - scanner:{profile:'optional exact qualified scanner profile ID',api:{runtimeProfile:'qualified app runtime ID; comprehensive mode only',port:'1024..65535',start:{executable:'absolute in-container path',args:['helper-derived literal argv with optional inspect handles']},control:{name:'legitimate control',path:'/path',method:'GET|POST|PUT|PATCH|DELETE',expected:{status:'100..599',includes:'optional',excludes:'optional'}},boundaryFiles:['untransformed snapshot-relative path or inspect handle'],schema:'reviewed OpenAPI 3.0/3.1 JSON; internal references; no server overrides, hooks, callbacks or external examples',operationIds:['1..20 unique declared path operation IDs'],seed:'optional 1..2147483647',maxExamples:'optional 1..100'}}, - submission:{application:{actors:['string'],assets:['string'],entrypoints:['string'],tenantBoundaries:['string'],sensitiveOperations:['string'],invariants:['string']},findings:[{title:'string',rootCause:'stable root cause',location:{path:'exact path or opaque handle returned by inspect',line:'positive integer',symbol:'string'},advisoryIds:['normalized advisory identity or empty'],severity:'critical|high|medium|low|informational',confidence:'high|medium|low',confidenceRationale:'why available evidence supports that confidence',evidence:'supported|hypothesis',attackerControl:'specific input/control',impact:'specific consequence',scenario:'concrete attacker scenario',trace:['entrypoint','caller','sink'],references:['supporting source/advisory reference'],recommendation:'concrete root-cause repair',challenge:{reviewer:'identity or exact sequential fallback label',independent:'boolean',mode:'independent_agent|sequential_fallback',callers:'checked callers',controls:'checked controls',counterevidence:'checked counterevidence',conclusion:'reasoned outcome'},dependency:{affectedVersion:'optional exact range/version',reachability:'reachable|unreachable|unknown',exposure:'production/build/development context',exploitation:'published exploitation evidence or unknown'}}],coverage:[{domain:'string',scope:'string',status:'assessed|partial|not_assessed|not_applicable',method:'string',gaps:['required for partial/not_assessed'],exclusions:['string'],evidence:['required for assessed/partial/not_applicable'],tool:{name:'optional',version:'exact',freshness:'timestamp/status',outcome:'string'}}],gaps:['string'],modelUsage:{source:'host-reported source',tokens:'nonnegative integer',cost:'optional finite nonnegative number'},recheck:{findingId:'original stable ID',outcome:'open|resolved|unknown',evidence:[{kind:'caller|security_boundary',path:'fresh snapshot path or inspect handle',line:'positive integer',observation:'fresh source observation'}],rootCause:'same root cause'}}, - verification:{findingId:'stable ID',runtimeProfile:'qualified runtime ID',port:'1024..65535',start:{executable:'absolute in-container path',args:['literal argv']},legitimate:[{name:'control name',path:'/numeric-loopback-relative path',method:'GET|POST|PUT|PATCH|DELETE',headers:{'optional-name':'value'},body:'optional body',expected:{status:'100..599',includes:'optional',excludes:'optional'}}],security:{name:'security assertion',path:'/path',method:'GET|POST|PUT|PATCH|DELETE',expected:{status:'fixed status',includes:'optional',excludes:'optional'},vulnerable:{status:'provably mutually-exclusive vulnerable status',includes:'optional',excludes:'optional'}},existingTests:[{executable:'must exactly match the helper-derived canonical stack suite',args:['helper-derived argv']}],testFiles:['exact immutable canonical path or inspect handle'],fixtures:{'relative/path':'content mounted read-only at /fixtures'},boundaryFiles:['snapshot path or inspect handle for the security boundary'],changes:[{path:'snapshot path or inspect handle',beforeSha256:'hash or null',after:'replacement or null',effect:'source|configuration|dependency'}],review:{artifactId:'helper-issued after record-review',reviewer:'self-attested identity distinct from producer',independent:'self-attested boolean',rootCauseRepaired:'self-attested boolean',featurePreserved:'self-attested boolean',boundaryMocks:'self-attested boolean',rationale:'specific review',reviewedPatchHash:'canonical patch hash'}}, - helperOwned:['run/report completeness','stable IDs','reproduction outcome','repair result and label','current-source closure','verification/bundle hashes'], + version: 3, + scanner: { + profile: 'optional exact qualified scanner profile ID', + api: { + runtimeProfile: 'qualified app runtime ID; comprehensive mode only', + port: '1024..65535', + start: { + executable: 'absolute in-container path', + args: ['helper-derived literal argv with optional inspect handles'], + }, + control: { + name: 'legitimate control', + path: '/path', + method: 'GET|POST|PUT|PATCH|DELETE', + expected: { status: '100..599', includes: 'optional', excludes: 'optional' }, + }, + boundaryFiles: ['untransformed snapshot-relative path or inspect handle'], + schema: + 'reviewed OpenAPI 3.0/3.1 JSON; internal references; no server overrides, hooks, callbacks or external examples', + operationIds: ['1..20 unique declared path operation IDs'], + seed: 'optional 1..2147483647', + maxExamples: 'optional 1..100', + }, + }, + submission: { + application: { + actors: ['string'], + assets: ['string'], + entrypoints: ['string'], + tenantBoundaries: ['string'], + sensitiveOperations: ['string'], + invariants: ['string'], + }, + findings: [ + { + title: 'string', + rootCause: 'stable root cause', + location: { + path: 'exact path or opaque handle returned by inspect', + line: 'positive integer', + symbol: 'string', + }, + advisoryIds: ['normalized advisory identity or empty'], + severity: 'critical|high|medium|low|informational', + confidence: 'high|medium|low', + confidenceRationale: 'why available evidence supports that confidence', + evidence: 'supported|hypothesis', + attackerControl: 'specific input/control', + impact: 'specific consequence', + scenario: 'concrete attacker scenario', + trace: ['entrypoint', 'caller', 'sink'], + references: ['supporting source/advisory reference'], + recommendation: 'concrete root-cause repair', + challenge: { + reviewer: 'identity or exact sequential fallback label', + independent: 'boolean', + mode: 'independent_agent|sequential_fallback', + callers: 'checked callers', + controls: 'checked controls', + counterevidence: 'checked counterevidence', + conclusion: 'reasoned outcome', + }, + dependency: { + affectedVersion: 'optional exact range/version', + reachability: 'reachable|unreachable|unknown', + exposure: 'production/build/development context', + exploitation: 'published exploitation evidence or unknown', + }, + }, + ], + coverage: [ + { + domain: 'string', + scope: 'string', + status: 'assessed|partial|not_assessed|not_applicable', + method: 'string', + gaps: ['required for partial/not_assessed'], + exclusions: ['string'], + evidence: ['required for assessed/partial/not_applicable'], + tool: { name: 'optional', version: 'exact', freshness: 'timestamp/status', outcome: 'string' }, + }, + ], + gaps: ['string'], + modelUsage: { + source: 'host-reported source', + tokens: 'nonnegative integer', + cost: 'optional finite nonnegative number', + }, + recheck: { + findingId: 'original stable ID', + outcome: 'open|resolved|unknown', + evidence: [ + { + kind: 'caller|security_boundary', + path: 'fresh snapshot path or inspect handle', + line: 'positive integer', + observation: 'fresh source observation', + }, + ], + rootCause: 'same root cause', + }, + }, + verification: { + findingId: 'stable ID', + runtimeProfile: 'qualified runtime ID', + port: '1024..65535', + start: { executable: 'absolute in-container path', args: ['literal argv'] }, + legitimate: [ + { + name: 'control name', + path: '/numeric-loopback-relative path', + method: 'GET|POST|PUT|PATCH|DELETE', + headers: { 'optional-name': 'value' }, + body: 'optional body', + expected: { status: '100..599', includes: 'optional', excludes: 'optional' }, + }, + ], + security: { + name: 'security assertion', + path: '/path', + method: 'GET|POST|PUT|PATCH|DELETE', + expected: { status: 'fixed status', includes: 'optional', excludes: 'optional' }, + vulnerable: { + status: 'provably mutually-exclusive vulnerable status', + includes: 'optional', + excludes: 'optional', + }, + }, + existingTests: [ + { + executable: 'must exactly match the helper-derived canonical stack suite', + args: ['helper-derived argv'], + }, + ], + testFiles: ['exact immutable canonical path or inspect handle'], + fixtures: { 'relative/path': 'content mounted read-only at /fixtures' }, + boundaryFiles: ['snapshot path or inspect handle for the security boundary'], + changes: [ + { + path: 'snapshot path or inspect handle', + beforeSha256: 'hash or null', + after: 'replacement or null', + effect: 'source|configuration|dependency', + }, + ], + review: { + artifactId: 'helper-issued after record-review', + reviewer: 'self-attested identity distinct from producer', + independent: 'self-attested boolean', + rootCauseRepaired: 'self-attested boolean', + featurePreserved: 'self-attested boolean', + boundaryMocks: 'self-attested boolean', + rationale: 'specific review', + reviewedPatchHash: 'canonical patch hash', + }, + }, + helperOwned: [ + 'run/report completeness', + 'stable IDs', + 'reproduction outcome', + 'repair result and label', + 'current-source closure', + 'verification/bundle hashes', + ], }; -function emit(value: unknown): void { process.stdout.write((typeof value === 'string' ? redact(value) : JSON.stringify(sanitizeHelperForJson(value),null,2)) + '\n'); } -function persistableArtifact(value:T,label:string):T{ - const sanitized=sanitizeHelperForJson(value) as T; - if(canonical(sanitized)!==canonical(value))throw new CsoError('REDACTION_FAILED',`${label} contains material that cannot be persisted without changing its authenticated identity`); +function emit(value: unknown): void { + process.stdout.write( + (typeof value === 'string' ? redact(value) : JSON.stringify(sanitizeHelperForJson(value), null, 2)) + + '\n', + ); +} +function persistableArtifact(value: T, label: string): T { + const sanitized = sanitizeHelperForJson(value) as T; + if (canonical(sanitized) !== canonical(value)) + throw new CsoError( + 'REDACTION_FAILED', + `${label} contains material that cannot be persisted without changing its authenticated identity`, + ); return sanitized; } -function rejectUnexpected(value:Record,allowed:readonly string[],name:string):void{ - for(const key of Object.keys(value))if(!allowed.includes(key))throw new CsoError('INVALID_SCHEMA',`Unexpected ${name} field: ${key}`); +function rejectUnexpected(value: Record, allowed: readonly string[], name: string): void { + for (const key of Object.keys(value)) + if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`); } -function need(args:string[],flag:string):string { const i=args.indexOf(flag); if(i<0||i===args.length-1||args[i+1].startsWith('--')) throw new CsoError('INVALID_ARGUMENT',`${flag} requires a value`); const v=args[i+1]; args.splice(i,2); return v; } -function take(args:string[],flag:string):boolean { const i=args.indexOf(flag); if(i<0)return false;args.splice(i,1);return true; } -function callerPath(value:string):string { - if(isAbsolute(value))return resolve(value); - const cwd=process.env.GSTACK_CSO_CALLER_CWD; - if(!cwd||!isAbsolute(cwd))throw new CsoError('INVALID_ARGUMENT','Relative paths require the trusted gstack-cso launcher'); - return resolve(cwd,value); +function need(args: string[], flag: string): string { + const i = args.indexOf(flag); + if (i < 0 || i === args.length - 1 || args[i + 1].startsWith('--')) + throw new CsoError('INVALID_ARGUMENT', `${flag} requires a value`); + const v = args[i + 1]; + args.splice(i, 2); + return v; } -function readInput(path:string,max=1024*1024):unknown { - let data:Buffer;try{data=readBoundedStable(callerPath(path),max,'Input file');}catch(error){if(error instanceof CsoError)throw error;throw new CsoError('MISSING_INPUT',`Input file does not exist: ${path}`);} - try{return JSON.parse(data.toString('utf8'));}catch{throw new CsoError('INVALID_SCHEMA','Input is not valid JSON');} +function take(args: string[], flag: string): boolean { + const i = args.indexOf(flag); + if (i < 0) return false; + args.splice(i, 1); + return true; } -function model(v:unknown):ApplicationModel { - const x=object(v,'application model');rejectUnexpected(x,['actors','assets','entrypoints','tenantBoundaries','sensitiveOperations','invariants'],'application model'); const out:ApplicationModel={actors:strings(x.actors,'actors'),assets:strings(x.assets,'assets'),entrypoints:strings(x.entrypoints,'entrypoints'),tenantBoundaries:strings(x.tenantBoundaries,'tenantBoundaries'),sensitiveOperations:strings(x.sensitiveOperations,'sensitiveOperations'),invariants:strings(x.invariants,'invariants')}; - if(Object.values(out).some(a=>!a.length))throw new CsoError('INVALID_SCHEMA','Every application-model dimension needs at least one evidence-backed entry');return out; +function callerPath(value: string): string { + if (isAbsolute(value)) return resolve(value); + const cwd = process.env.GSTACK_CSO_CALLER_CWD; + if (!cwd || !isAbsolute(cwd)) + throw new CsoError('INVALID_ARGUMENT', 'Relative paths require the trusted gstack-cso launcher'); + return resolve(cwd, value); } -function planned(scope:string):CoverageRecord[]{ - const domains = scope==='infra'?['secrets','dependencies','ci-cd','infrastructure','integrations'] - : scope==='code'?['llm-agentic-mcp','owasp-2025','stride','data-classification'] - : scope==='skills'?['skill-supply-chain'] : scope==='supply-chain'?['dependencies'] : scope==='owasp'?['owasp-2025'] - : scope.startsWith('domain:')?[scope.slice(7)] - :['secrets','dependencies','ci-cd','infrastructure','integrations','llm-agentic-mcp','skill-supply-chain','owasp-2025','stride','data-classification']; - return ['application-model','attack-surface',...domains].map(domain=>({domain,scope,status:'not_assessed',method:'pending investigation',gaps:['Assessment has not been submitted'],exclusions:[],evidence:[]})); +function readInput(path: string, max = 1024 * 1024): unknown { + let data: Buffer; + try { + data = readBoundedStable(callerPath(path), max, 'Input file'); + } catch (error) { + if (error instanceof CsoError) throw error; + throw new CsoError('MISSING_INPUT', `Input file does not exist: ${path}`); + } + try { + return JSON.parse(data.toString('utf8')); + } catch { + throw new CsoError('INVALID_SCHEMA', 'Input is not valid JSON'); + } } -function snapshotCoverage(manifest:Awaited>,scope:string):CoverageRecord{ - const omitted=manifest.entries.filter(entry=>!entry.executionHash),excluded=omitted.filter(entry=>entry.transformation?.startsWith('excluded:')); +function model(v: unknown): ApplicationModel { + const x = object(v, 'application model'); + rejectUnexpected( + x, + ['actors', 'assets', 'entrypoints', 'tenantBoundaries', 'sensitiveOperations', 'invariants'], + 'application model', + ); + const out: ApplicationModel = { + actors: strings(x.actors, 'actors'), + assets: strings(x.assets, 'assets'), + entrypoints: strings(x.entrypoints, 'entrypoints'), + tenantBoundaries: strings(x.tenantBoundaries, 'tenantBoundaries'), + sensitiveOperations: strings(x.sensitiveOperations, 'sensitiveOperations'), + invariants: strings(x.invariants, 'invariants'), + }; + if (Object.values(out).some((a) => !a.length)) + throw new CsoError( + 'INVALID_SCHEMA', + 'Every application-model dimension needs at least one evidence-backed entry', + ); + return out; +} +function planned(scope: string): CoverageRecord[] { + const domains = + scope === 'infra' + ? ['secrets', 'dependencies', 'ci-cd', 'infrastructure', 'integrations'] + : scope === 'code' + ? ['llm-agentic-mcp', 'owasp-2025', 'stride', 'data-classification'] + : scope === 'skills' + ? ['skill-supply-chain'] + : scope === 'supply-chain' + ? ['dependencies'] + : scope === 'owasp' + ? ['owasp-2025'] + : scope.startsWith('domain:') + ? [scope.slice(7)] + : [ + 'secrets', + 'dependencies', + 'ci-cd', + 'infrastructure', + 'integrations', + 'llm-agentic-mcp', + 'skill-supply-chain', + 'owasp-2025', + 'stride', + 'data-classification', + ]; + return ['application-model', 'attack-surface', ...domains].map((domain) => ({ + domain, + scope, + status: 'not_assessed', + method: 'pending investigation', + gaps: ['Assessment has not been submitted'], + exclusions: [], + evidence: [], + })); +} +function snapshotCoverage(manifest: Awaited>, scope: string): CoverageRecord { + const omitted = manifest.entries.filter((entry) => !entry.executionHash), + excluded = omitted.filter((entry) => entry.transformation?.startsWith('excluded:')); // Classify every unexplained omission as a material coverage gap. Coverage // must not depend on transformation prose retaining a particular prefix. - const unread=omitted.filter(entry=>!excluded.includes(entry)); - const deleted=manifest.deletedPaths??[],captured=manifest.entries.filter(entry=>entry.executionHash).length,missing=unread.length+deleted.length; - return {domain:'snapshot-inputs',scope,status:missing?(captured?'partial':'not_assessed'):'assessed',method:'fail-closed captured source inventory', - gaps:[...unread.map(entry=>`${publicSnapshotPath(manifest,entry.path).path}: in-scope source payload was unread and withheld from static and runtime assessment`),...deleted.map(item=>`${publicSnapshotPath(manifest,item.path).path}: tracked source is deleted from the worktree; only retained history is available for assessment`)], - exclusions:excluded.map(entry=>`${publicSnapshotPath(manifest,entry.path).path}: ${entry.transformation}`), - evidence:[`${captured} sanitized execution input${captured===1?'':'s'} captured; ${excluded.length} explicit non-executable exclusion${excluded.length===1?'':'s'}; ${unread.length} unread in-scope input${unread.length===1?'':'s'}; ${deleted.length} tracked deletion${deleted.length===1?'':'s'}`]}; + const unread = omitted.filter((entry) => !excluded.includes(entry)); + const deleted = manifest.deletedPaths ?? [], + captured = manifest.entries.filter((entry) => entry.executionHash).length, + missing = unread.length + deleted.length; + return { + domain: 'snapshot-inputs', + scope, + status: missing ? (captured ? 'partial' : 'not_assessed') : 'assessed', + method: 'fail-closed captured source inventory', + gaps: [ + ...unread.map( + (entry) => + `${publicSnapshotPath(manifest, entry.path).path}: in-scope source payload was unread and withheld from static and runtime assessment`, + ), + ...deleted.map( + (item) => + `${publicSnapshotPath(manifest, item.path).path}: tracked source is deleted from the worktree; only retained history is available for assessment`, + ), + ], + exclusions: excluded.map( + (entry) => `${publicSnapshotPath(manifest, entry.path).path}: ${entry.transformation}`, + ), + evidence: [ + `${captured} sanitized execution input${captured === 1 ? '' : 's'} captured; ${excluded.length} explicit non-executable exclusion${excluded.length === 1 ? '' : 's'}; ${unread.length} unread in-scope input${unread.length === 1 ? '' : 's'}; ${deleted.length} tracked deletion${deleted.length === 1 ? '' : 's'}`, + ], + }; } -function historyCoverage(status:any,scope:string):CoverageRecord{return status?.status==='captured' - ?{domain:'history-inputs',scope,status:'assessed',method:'bounded helper-retained Git history',gaps:[],exclusions:[],evidence:[`Captured ${String(status.commits??'bounded commits')} from ${String(status.range??'the pinned source range')}`]} - :{domain:'history-inputs',scope,status:'not_assessed',method:'bounded helper-retained Git history',gaps:[typeof status?.gap==='string'&&status.gap.trim()?status.gap:'Historical evidence was not safely retained'],exclusions:[],evidence:[]};} -function helperOwnedCoverage(domain:string):boolean{return domain==='snapshot-inputs'||domain==='history-inputs'||domain==='runtime-readiness'||domain.startsWith('scanner:')||domain.startsWith('preparation:')||domain.startsWith('execution:');} -function parseStart(args:string[]):{repo:string;policy:RunPolicy;comparisonBase?:string}{ - const repo=callerPath(need(args,'--repo')),comprehensive=take(args,'--comprehensive'),diff=take(args,'--diff'),offline=take(args,'--offline'); - const scopeFlags=['--infra','--code','--skills','--supply-chain','--owasp'].filter(f=>args.includes(f)); - const named=args.includes('--scope')?need(args,'--scope'):undefined; - if(scopeFlags.length+(named?1:0)>1)throw new CsoError('INVALID_ARGUMENT','Select only one scope'); - for(const f of scopeFlags)take(args,f); - const explicitBase=args.includes('--base'),base=explicitBase?need(args,'--base'):'origin/main'; - const rawBudget=args.includes('--budget')?need(args,'--budget'):String(comprehensive?1800:600),budgetSeconds=Number(rawBudget); - if(!Number.isInteger(budgetSeconds)||budgetSeconds<120||budgetSeconds>(comprehensive?1800:600))throw new CsoError('INVALID_ARGUMENT',`Budget must be an integer from 120 to ${comprehensive?1800:600} seconds`); - if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown argument: ${args[0]}`); - if(!fs.existsSync(repo)||!fs.statSync(repo).isDirectory())throw new CsoError('MISSING_INPUT','Repository directory does not exist'); - const scope=named?`domain:${string(named,'scope',100)}`:scopeFlags[0]?.slice(2)??'default'; - return {repo,policy:{mode:comprehensive?'comprehensive':'daily',scope,diff,base,offline,budgetSeconds,maxWorkers:3,maxRepairs:3},...(diff||explicitBase?{comparisonBase:base}:{})}; +function historyCoverage(status: any, scope: string): CoverageRecord { + return status?.status === 'captured' + ? { + domain: 'history-inputs', + scope, + status: 'assessed', + method: 'bounded helper-retained Git history', + gaps: [], + exclusions: [], + evidence: [ + `Captured ${String(status.commits ?? 'bounded commits')} from ${String(status.range ?? 'the pinned source range')}`, + ], + } + : { + domain: 'history-inputs', + scope, + status: 'not_assessed', + method: 'bounded helper-retained Git history', + gaps: [ + typeof status?.gap === 'string' && status.gap.trim() + ? status.gap + : 'Historical evidence was not safely retained', + ], + exclusions: [], + evidence: [], + }; } -async function start(args:string[],dependencies:CsoCliDependencies,parent?:RunReportV3['parent'],requiredAncestor?:string,startedAt=new Date()):Promise{ - const {repo,policy,comparisonBase}=parseStart(args),createdAt=startedAt;assertStateOutside(repo);if(!parent)retention(createdAt.getTime(),{deadlineMs:Math.min(createdAt.getTime()+RETENTION_MAINTENANCE_MS,createdAt.getTime()+policy.budgetSeconds*1000-60_000),maxEntries:RETENTION_MAX_ENTRIES});const run=newRun(repo); - let manifest:Awaited>;try{manifest=await capture(repo,run.dir,comparisonBase,requiredAncestor,{deadlineMs:createdAt.getTime()+policy.budgetSeconds*1000-60_000});}catch(error){fs.rmSync(run.dir,{recursive:true,force:true});throw error;} - const report:RunReportV3={schemaVersion:3,runId:run.runId,repoId:run.repoId,createdAt:createdAt.toISOString(),deadline:new Date(createdAt.getTime()+policy.budgetSeconds*1000).toISOString(),status:'running',completeness:'not assessed',policy, - source:{root:repo,snapshotHash:manifest.executionHash,originalHash:manifest.originalHash,baseCommit:manifest.baseCommit, - transformations:[...manifest.entries.filter(entry=>entry.transformation).map(entry=>({path:publicSnapshotPath(manifest,entry.path).path,handling:entry.transformation!})),...(manifest.deletedPaths??[]).map(item=>({path:publicSnapshotPath(manifest,item.path).path,handling:'tracked source deleted; retained history only'}))]}, - application:{actors:[],assets:[],entrypoints:[],tenantBoundaries:[],sensitiveOperations:[],invariants:[]},coverage:[snapshotCoverage(manifest,policy.scope),historyCoverage(readJson(join(run.dir,'history-status.json')),policy.scope),...planned(policy.scope)],findings:[],gaps:[],events:[],...(parent?{parent}:{})}; - event(report,'snapshot',`Captured ${manifest.entries.length} source entries; ${manifest.entries.filter(e=>e.transformation).length+(manifest.deletedPaths?.length??0)} transformations disclosed`); - if(policy.mode==='comprehensive'){ - const plan=inspectPreparation(join(run.dir,'snapshot'));writeJson(join(run.dir,'preparation.json'),plan); - const c:CoverageRecord={domain:'runtime-readiness',scope:plan.stack,status:plan.status==='ready'?'partial':'not_assessed',method:'inert lockfile and runtime-catalog inspection',gaps:plan.prerequisites.map(p=>p.message),exclusions:[],evidence:[`Preparation metadata: ${plan.status}`]}; - try{validateRuntimeCatalog(dependencies.runtimeCatalog);selectRuntime(plan.runtimeProfile,platform(),dependencies.runtimeCatalog);c.evidence.push(`Qualified runtime catalog: ${dependencies.runtimeCatalog.revision}`);}catch(error:any){c.gaps.push(error?.message?.startsWith('MISSING_QUALIFIED_RUNTIME')?error.message:'Runtime catalog validation failed');} - try{const runtimeHome=secureDirectory(join(run.dir,'home')),endpoint=await dockerEndpoint(runtimeHome);const probe=await dockerProbe(endpoint,runtimeHome);c.evidence.push(`Local Docker ${probe.version} admitted at ${endpoint.uri}`);}catch(error:any){c.gaps.push(error instanceof CsoError?error.message:'Local Docker isolation admission failed');} - if(plan.status==='ready'&&!c.gaps.length)c.status='assessed';else if(c.evidence.length>1)c.status='partial'; +function helperOwnedCoverage(domain: string): boolean { + return ( + domain === 'snapshot-inputs' || + domain === 'history-inputs' || + domain === 'runtime-readiness' || + domain.startsWith('scanner:') || + domain.startsWith('preparation:') || + domain.startsWith('execution:') + ); +} +function parseStart(args: string[]): { repo: string; policy: RunPolicy; comparisonBase?: string } { + const repo = callerPath(need(args, '--repo')), + comprehensive = take(args, '--comprehensive'), + diff = take(args, '--diff'), + offline = take(args, '--offline'); + const scopeFlags = ['--infra', '--code', '--skills', '--supply-chain', '--owasp'].filter((f) => + args.includes(f), + ); + const named = args.includes('--scope') ? need(args, '--scope') : undefined; + if (scopeFlags.length + (named ? 1 : 0) > 1) + throw new CsoError('INVALID_ARGUMENT', 'Select only one scope'); + for (const f of scopeFlags) take(args, f); + const explicitBase = args.includes('--base'), + base = explicitBase ? need(args, '--base') : 'origin/main'; + const rawBudget = args.includes('--budget') ? need(args, '--budget') : String(comprehensive ? 1800 : 600), + budgetSeconds = Number(rawBudget); + if (!Number.isInteger(budgetSeconds) || budgetSeconds < 120 || budgetSeconds > (comprehensive ? 1800 : 600)) + throw new CsoError( + 'INVALID_ARGUMENT', + `Budget must be an integer from 120 to ${comprehensive ? 1800 : 600} seconds`, + ); + if (args.length) throw new CsoError('INVALID_ARGUMENT', `Unknown argument: ${args[0]}`); + if (!fs.existsSync(repo) || !fs.statSync(repo).isDirectory()) + throw new CsoError('MISSING_INPUT', 'Repository directory does not exist'); + const scope = named ? `domain:${string(named, 'scope', 100)}` : (scopeFlags[0]?.slice(2) ?? 'default'); + return { + repo, + policy: { + mode: comprehensive ? 'comprehensive' : 'daily', + scope, + diff, + base, + offline, + budgetSeconds, + maxWorkers: 3, + maxRepairs: 3, + }, + ...(diff || explicitBase ? { comparisonBase: base } : {}), + }; +} +async function start( + args: string[], + dependencies: CsoCliDependencies, + parent?: RunReportV3['parent'], + requiredAncestor?: string, + startedAt = new Date(), +): Promise { + const { repo, policy, comparisonBase } = parseStart(args), + createdAt = startedAt; + assertStateOutside(repo); + if (!parent) + retention(createdAt.getTime(), { + deadlineMs: Math.min( + createdAt.getTime() + RETENTION_MAINTENANCE_MS, + createdAt.getTime() + policy.budgetSeconds * 1000 - 60_000, + ), + maxEntries: RETENTION_MAX_ENTRIES, + }); + const run = newRun(repo); + let manifest: Awaited>; + try { + manifest = await capture(repo, run.dir, comparisonBase, requiredAncestor, { + deadlineMs: createdAt.getTime() + policy.budgetSeconds * 1000 - 60_000, + }); + } catch (error) { + fs.rmSync(run.dir, { recursive: true, force: true }); + throw error; + } + const report: RunReportV3 = { + schemaVersion: 3, + runId: run.runId, + repoId: run.repoId, + createdAt: createdAt.toISOString(), + deadline: new Date(createdAt.getTime() + policy.budgetSeconds * 1000).toISOString(), + status: 'running', + completeness: 'not assessed', + policy, + source: { + root: repo, + snapshotHash: manifest.executionHash, + originalHash: manifest.originalHash, + baseCommit: manifest.baseCommit, + transformations: [ + ...manifest.entries + .filter((entry) => entry.transformation) + .map((entry) => ({ + path: publicSnapshotPath(manifest, entry.path).path, + handling: entry.transformation!, + })), + ...(manifest.deletedPaths ?? []).map((item) => ({ + path: publicSnapshotPath(manifest, item.path).path, + handling: 'tracked source deleted; retained history only', + })), + ], + }, + application: { + actors: [], + assets: [], + entrypoints: [], + tenantBoundaries: [], + sensitiveOperations: [], + invariants: [], + }, + coverage: [ + snapshotCoverage(manifest, policy.scope), + historyCoverage(readJson(join(run.dir, 'history-status.json')), policy.scope), + ...planned(policy.scope), + ], + findings: [], + gaps: [], + events: [], + ...(parent ? { parent } : {}), + }; + event( + report, + 'snapshot', + `Captured ${manifest.entries.length} source entries; ${manifest.entries.filter((e) => e.transformation).length + (manifest.deletedPaths?.length ?? 0)} transformations disclosed`, + ); + if (policy.mode === 'comprehensive') { + const plan = inspectPreparation(join(run.dir, 'snapshot')); + writeJson(join(run.dir, 'preparation.json'), plan); + const c: CoverageRecord = { + domain: 'runtime-readiness', + scope: plan.stack, + status: plan.status === 'ready' ? 'partial' : 'not_assessed', + method: 'inert lockfile and runtime-catalog inspection', + gaps: plan.prerequisites.map((p) => p.message), + exclusions: [], + evidence: [`Preparation metadata: ${plan.status}`], + }; + try { + validateRuntimeCatalog(dependencies.runtimeCatalog); + selectRuntime(plan.runtimeProfile, platform(), dependencies.runtimeCatalog); + c.evidence.push(`Qualified runtime catalog: ${dependencies.runtimeCatalog.revision}`); + } catch (error: any) { + c.gaps.push( + error?.message?.startsWith('MISSING_QUALIFIED_RUNTIME') + ? error.message + : 'Runtime catalog validation failed', + ); + } + try { + const runtimeHome = secureDirectory(join(run.dir, 'home')), + endpoint = await dockerEndpoint(runtimeHome); + const probe = await dockerProbe(endpoint, runtimeHome); + c.evidence.push(`Local Docker ${probe.version} admitted at ${endpoint.uri}`); + } catch (error: any) { + c.gaps.push(error instanceof CsoError ? error.message : 'Local Docker isolation admission failed'); + } + if (plan.status === 'ready' && !c.gaps.length) c.status = 'assessed'; + else if (c.evidence.length > 1) c.status = 'partial'; report.coverage.push(c); } - saveReport(run.dir,report);return publicReport(report); + saveReport(run.dir, report); + return publicReport(report); } -async function doctor(args:string[],dependencies:CsoCliDependencies){ - const started=Date.now(),repo=callerPath(need(args,'--repo'));if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown argument: ${args[0]}`); - const staticCheck=(async()=>{let home='';try{const stat=fs.statSync(repo);if(!stat.isDirectory())throw new CsoError('MISSING_INPUT','Repository path is not a directory');const g=executable('git');home=secureDirectory(fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()),'gstack-cso-doctor-git-')));if((await git(repo,['rev-parse','--is-inside-work-tree'],home)).trim()!=='true')throw new CsoError('MISSING_INPUT','Repository path is not a Git working tree');return{capability:'static-snapshot',status:'ready',detail:g};}catch(e:any){return{capability:'static-snapshot',status:'missing',detail:e instanceof CsoError?e.message:'Repository path is missing or unreadable'};}finally{if(home)fs.rmSync(home,{recursive:true,force:true});}})(); - let preparation:ReturnType|undefined; - try{const stat=fs.statSync(repo);if(!stat.isDirectory())throw new CsoError('MISSING_INPUT','Repository path is not a directory');preparation=inspectPreparation(repo);}catch{} - const scannerCatalog=dependencies.scannerCatalog??SCANNER_CATALOG,imageSession=dependencies.catalogImageSession??productionCatalogImageSession,deadline=started+30_000; - let targetPlatform:'linux/amd64'|'linux/arm64'|undefined,entries:ReturnType=[]; - try{targetPlatform=platform();entries=qualifiedCatalogImages(dependencies.runtimeCatalog,scannerCatalog,targetPlatform);}catch{} - const inspectedPromise=inspectCatalogImages(entries,imageSession,deadline); - const checks:any[]=[await staticCheck]; - checks.push(preparation?{capability:'application-preparation',status:preparation.status==='ready'?'ready':'missing',detail:{stack:preparation.stack,prerequisites:preparation.prerequisites}}:{capability:'application-preparation',status:'missing',detail:'Repository source is unavailable for inert preparation inspection'}); - const inspected=await inspectedPromise;checks.push({capability:'local-docker-isolation',status:inspected.docker.status,detail:inspected.docker.detail}); - try{ - if(!preparation||preparation.status!=='ready')throw new CsoError('PREREQUISITE','Resolve the application-preparation prerequisites first'); - if(!targetPlatform)throw new CsoError('PREREQUISITE','Qualified runtimes require an amd64/arm64 Linux Docker platform'); - validateRuntimeCatalog(dependencies.runtimeCatalog);const runtime=selectRuntime(preparation.runtimeProfile,targetPlatform,dependencies.runtimeCatalog),availability=inspected.images.find(item=>item.kind==='runtime'&&item.id===runtime.id),requiredSidecars:Array>=[],prerequisites:string[]=[]; - if(!availability||availability.status!=='available')prerequisites.push(availability?.reason??'Exact qualified runtime image is not present in the local Docker daemon; rerun setup with Docker and public registry access'); - if(preparation.stack==='rails'&&preparation.database?.selected==='postgresql'){ - const sidecar=selectRuntime('postgresql',targetPlatform,dependencies.runtimeCatalog),sidecarAvailability=inspected.images.find(item=>item.kind==='runtime'&&item.id===sidecar.id),sidecarReady=sidecarAvailability?.status==='available',prerequisite=sidecarReady?undefined:sidecarAvailability?.reason??'Exact qualified PostgreSQL sidecar image is not present in the local Docker daemon; rerun setup with Docker and public registry access'; - if(prerequisite)prerequisites.push(prerequisite);requiredSidecars.push({kind:'postgresql',profile:sidecar.id,image:sidecar.image,platform:targetPlatform,availability:sidecarReady?'available':'unavailable',...(prerequisite?{prerequisite}:{})}); +async function doctor(args: string[], dependencies: CsoCliDependencies) { + const started = Date.now(), + repo = callerPath(need(args, '--repo')); + if (args.length) throw new CsoError('INVALID_ARGUMENT', `Unknown argument: ${args[0]}`); + const staticCheck = (async () => { + let home = ''; + try { + const stat = fs.statSync(repo); + if (!stat.isDirectory()) throw new CsoError('MISSING_INPUT', 'Repository path is not a directory'); + const g = executable('git'); + home = secureDirectory(fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), 'gstack-cso-doctor-git-'))); + if ((await git(repo, ['rev-parse', '--is-inside-work-tree'], home)).trim() !== 'true') + throw new CsoError('MISSING_INPUT', 'Repository path is not a Git working tree'); + return { capability: 'static-snapshot', status: 'ready', detail: g }; + } catch (e: any) { + return { + capability: 'static-snapshot', + status: 'missing', + detail: e instanceof CsoError ? e.message : 'Repository path is missing or unreadable', + }; + } finally { + if (home) fs.rmSync(home, { recursive: true, force: true }); } - const ready=!prerequisites.length;checks.push({capability:'qualified-runtimes',status:ready?'ready':'missing',detail:{catalog:dependencies.runtimeCatalog.revision,profile:runtime.id,image:runtime.image,platform:targetPlatform,availability:ready?'available':'unavailable',...(requiredSidecars.length?{requiredSidecars}:{}),...(prerequisites.length?{prerequisite:prerequisites[0],prerequisites}:{})}}); - }catch(error:any){checks.push({capability:'qualified-runtimes',status:'missing',detail:error instanceof CsoError?error.message:error?.message??'Qualified runtime catalog is invalid'});} - for(const id of SCANNER_IDS){try{ - if(!targetPlatform)throw new CsoError('PREREQUISITE','Scanner containers require an amd64/arm64 Linux Docker platform');validateScannerCatalog(scannerCatalog); - const profile=selectScanner(id,targetPlatform,undefined,scannerCatalog),availability=inspected.images.find(item=>item.kind==='scanner'&&item.id===profile.id); - if(!availability||availability.status!=='available')checks.push({capability:`scanner:${id}`,status:'missing',detail:{catalog:scannerCatalog.revision,profile:profile.id,image:profile.image,version:profile.version,qualifiedAt:profile.qualifiedAt,availability:'unavailable',prerequisite:availability?.reason??'Exact qualified scanner image is not present in the local Docker daemon; rerun setup with Docker and public registry access'}}); - else checks.push({capability:`scanner:${id}`,status:'ready',detail:{catalog:scannerCatalog.revision,profile:profile.id,image:profile.image,version:profile.version,qualifiedAt:profile.qualifiedAt,availability:'available'}}); - }catch(error:any){checks.push({capability:`scanner:${id}`,status:'missing',detail:error instanceof CsoError?error.message:error?.message??'Qualified scanner catalog is invalid'});}} - return{schemaVersion:3,downloads:false,elapsedMs:Date.now()-started,checks}; -} -async function provisionImages(args:string[],dependencies:CsoCliDependencies):Promise{ - const setupSummary=take(args,'--setup-summary'),requestedSeconds=args.includes('--per-image-seconds')?need(args,'--per-image-seconds'):undefined;if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown argument: ${args[0]}`); - if(requestedSeconds!==undefined)catalogImageProvisioningPolicy(0,requestedSeconds); - let targetPlatform:'linux/amd64'|'linux/arm64'; - try{targetPlatform=platform();}catch(error){ - const reason=error instanceof CsoError?error.message:'Qualified image provisioning requires an amd64/arm64 Linux Docker platform',result={schemaVersion:1,status:'not_available',downloads:true,platform:'unsupported',requested:0,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached:false,unavailable:[],summary:`Qualified CSO images were not preloaded: ${reason}. Static audits remain available.`}; - return setupSummary?result.summary:result; - } - const scannerCatalog=dependencies.scannerCatalog??SCANNER_CATALOG,entries=qualifiedCatalogImages(dependencies.runtimeCatalog,scannerCatalog,targetPlatform),policy=catalogImageProvisioningPolicy(entries.length,requestedSeconds),deadline=Date.now()+policy.aggregateMs,result=await provisionCatalogImages(entries,targetPlatform,dependencies.catalogImageSession??productionCatalogImageSession,deadline,policy.perImageMs); - return setupSummary?result.summary:result; -} -function run(args:string[]){if(!args.length)throw new CsoError('INVALID_ARGUMENT','Run ID is required');return {dir:runDirectory(args.shift()!),report:null as any};} -function recoveryEvents(dir:string):string[]{const out:string[]=[];let visited=0;const walk=(at:string,depth:number)=>{if(depth>6||visited++>4000)return;let entries:fs.Dirent[];try{entries=fs.readdirSync(at,{withFileTypes:true});}catch{return;}for(const entry of entries){if(!/^[A-Za-z0-9._-]{1,120}$/.test(entry.name))continue;const path=join(at,entry.name);let stat:fs.Stats;try{stat=fs.lstatSync(path);}catch{continue;}if(stat.isSymbolicLink())continue;if(stat.isDirectory()){walk(path,depth+1);continue;}if(!['attempt.event','watchdog.event'].includes(entry.name)||!stat.isFile()||stat.size>8192)continue;try{const message=redact(fs.readFileSync(path,'utf8').trim());if(message&&!out.includes(message))out.push(message);}catch{}}};for(const name of ['supervision','preparation-execution']){const root=join(dir,name);if(!fs.existsSync(root))continue;const stat=fs.lstatSync(root);if(stat.isSymbolicLink()||!stat.isDirectory())throw new CsoError('UNSAFE_PATH','Watchdog recovery state is not a private directory');walk(root,0);}return out;} -function publicSnapshotPath(manifest:SnapshotManifest,path:string):{path:string;displayPath?:string}{ - const entry=manifest.entries.find(item=>item.path===path),handle=snapshotPathHandle(entry?.pathId??snapshotPathId(manifest.root,path));let displayPath:string; - try{displayPath=redact(path);}catch{displayPath='[sensitive path withheld]';} - return displayPath===path?{path}:{path:handle,displayPath}; -} -function publicSnapshotManifest(manifest:SnapshotManifest):Record{ - return {...manifest,root:PUBLIC_SOURCE_ROOT,entries:manifest.entries.map(entry=>{const {path,pathId:_,...rest}=entry;return{...rest,...publicSnapshotPath(manifest,path)};}), - ...(manifest.deletedPaths?.length?{deletedPaths:manifest.deletedPaths.map(item=>publicSnapshotPath(manifest,item.path))}:{}), - ...(manifest.changedPaths?{changedPaths:manifest.changedPaths.map(path=>publicSnapshotPath(manifest,path).path)}:{})}; -} -function resolveSnapshotPath(manifest:SnapshotManifest,value:unknown,requireEntry:boolean,label='Snapshot path',allowDeleted=false):{path:string;entry?:SnapshotEntry}{ - const reference=snapshotReference(value),handleId=snapshotPathHandleId(reference); - if(handleId){const entry=manifest.entries.find(item=>item.pathId===handleId);if(entry)return{path:entry.path,entry};const deleted=manifest.deletedPaths?.find(item=>item.pathId===handleId),changed=manifest.changedPaths?.find(path=>snapshotPathId(manifest.root,path)===handleId);if(allowDeleted&&deleted)return{path:deleted.path};if(!requireEntry&&changed)return{path:changed};throw new CsoError('INVALID_SCHEMA',`${label} handle is outside the retained inventory: ${reference}`);} - const path=relativePath(reference),entry=manifest.entries.find(item=>item.path===path),deleted=manifest.deletedPaths?.find(item=>item.path===path),changed=manifest.changedPaths?.includes(path);if(entry)return{path,entry};if(allowDeleted&&deleted)return{path};if(!requireEntry&&changed)return{path};throw new CsoError('INVALID_SCHEMA',`${label} is outside the retained inventory: ${reference}`); -} -type BoundRecheckEvidence={kind:'caller'|'security_boundary';path:string;line:number;observation:string;sourceState:'present'|'absent';snapshotHash:string;sourceHash?:string;executionHash?:string}; -type BoundRecheckClaim={findingId:string;outcome:'open'|'resolved'|'unknown';evidence:BoundRecheckEvidence[];rootCause:string}; -function assertRecheckLine(runDir:string,path:string,entry:SnapshotEntry,line:number,label:string):void{ - if(!entry.executionHash)throw new CsoError('INVALID_SCHEMA',`${label} must reference source available in the fresh execution snapshot`); - const body=readBoundedStable(containedFile(join(runDir,'snapshot'),path),64*1024*1024,label).toString('utf8'),lines=body.length?(body.endsWith('\n')?body.slice(0,-1):body).split('\n').length:0; - if(line>lines)throw new CsoError('INVALID_SCHEMA',`${label} line is outside the fresh source file`); -} -function originalBoundary(report:RunReportV3):{dir:string;report:RunReportV3;manifest:SnapshotManifest;finding:FindingV3;path:string}{ - if(!report.parent)throw new CsoError('INVALID_SCHEMA','Recheck evidence requires a linked original finding'); - const dir=runDirectory(report.parent.runId),original=loadReport(dir),manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest, - finding=original.findings.find(item=>item.id===report.parent!.findingId); - if(report.repoId!==original.repoId)throw new CsoError('INCOMPATIBLE_INPUT','Recheck repository identity differs from the original audit'); - if(!finding)throw new CsoError('MISSING_INPUT','Original recheck finding no longer exists'); - const path=resolveSnapshotPath(manifest,finding.location.path,false,'Original finding boundary',true).path; - return{dir,report:original,manifest,finding,path}; -} -function bindRecheckEvidence(value:unknown,runDir:string,manifest:SnapshotManifest,boundary:ReturnType,outcome:BoundRecheckClaim['outcome']):BoundRecheckEvidence[]{ - if(!Array.isArray(value)||value.length<1||value.length>20)throw new CsoError('INVALID_SCHEMA','Recheck evidence must contain 1 to 20 fresh source observations'); - const evidence=value.map((raw,index)=>{ - const item=object(raw,`recheck evidence[${index}]`);rejectUnexpected(item,['kind','path','line','observation'],`recheck evidence[${index}]`); - const kind=string(item.kind,`recheck evidence[${index}].kind`,32);if(!['caller','security_boundary'].includes(kind))throw new CsoError('INVALID_SCHEMA','Recheck evidence kind must be caller or security_boundary'); - if(!Number.isSafeInteger(item.line)||item.line<1)throw new CsoError('INVALID_SCHEMA',`recheck evidence[${index}].line must be a positive integer`); - const observation=redact(string(item.observation,`recheck evidence[${index}].observation`,2048)),reference=snapshotReference(item.path); - let selected:{path:string;entry?:SnapshotEntry}|undefined; - try{selected=resolveSnapshotPath(manifest,reference,true,`recheck evidence[${index}].path`);}catch(error){ - if(kind!=='security_boundary')throw error; - let old:{path:string};try{old=resolveSnapshotPath(boundary.manifest,reference,false,`recheck evidence[${index}].path`,true);}catch{throw error;} - if(old.path!==boundary.path||manifest.entries.some(entry=>entry.path===boundary.path))throw error; - if(item.line!==boundary.finding.location.line)throw new CsoError('INVALID_SCHEMA','Absent security-boundary evidence must cite the original finding line'); - return{kind:'security_boundary' as const,path:snapshotPathHandle(snapshotPathId(manifest.root,boundary.path)),line:item.line as number,observation,sourceState:'absent' as const,snapshotHash:manifest.originalHash}; - } - if(!selected.entry||selected.entry.originalHash==='not-read')throw new CsoError('INVALID_SCHEMA','Recheck evidence must reference freshly captured readable source'); - assertRecheckLine(runDir,selected.path,selected.entry,item.line as number,`recheck evidence[${index}]`); - if(kind==='security_boundary'&&selected.path!==boundary.path)throw new CsoError('INVALID_SCHEMA','Security-boundary evidence must reference the original finding location'); - return{kind:kind as BoundRecheckEvidence['kind'],path:snapshotPathHandle(selected.entry.pathId),line:item.line as number,observation,sourceState:'present' as const,snapshotHash:manifest.originalHash,sourceHash:selected.entry.originalHash,executionHash:selected.entry.executionHash}; + })(); + let preparation: ReturnType | undefined; + try { + const stat = fs.statSync(repo); + if (!stat.isDirectory()) throw new CsoError('MISSING_INPUT', 'Repository path is not a directory'); + preparation = inspectPreparation(repo); + } catch {} + const scannerCatalog = dependencies.scannerCatalog ?? SCANNER_CATALOG, + imageSession = dependencies.catalogImageSession ?? productionCatalogImageSession, + deadline = started + 30_000; + let targetPlatform: 'linux/amd64' | 'linux/arm64' | undefined, + entries: ReturnType = []; + try { + targetPlatform = platform(); + entries = qualifiedCatalogImages(dependencies.runtimeCatalog, scannerCatalog, targetPlatform); + } catch {} + const inspectedPromise = inspectCatalogImages(entries, imageSession, deadline); + const checks: any[] = [await staticCheck]; + checks.push( + preparation + ? { + capability: 'application-preparation', + status: preparation.status === 'ready' ? 'ready' : 'missing', + detail: { stack: preparation.stack, prerequisites: preparation.prerequisites }, + } + : { + capability: 'application-preparation', + status: 'missing', + detail: 'Repository source is unavailable for inert preparation inspection', + }, + ); + const inspected = await inspectedPromise; + checks.push({ + capability: 'local-docker-isolation', + status: inspected.docker.status, + detail: inspected.docker.detail, }); - const identities=new Set(evidence.map(item=>canonical(item)));if(identities.size!==evidence.length)throw new CsoError('INVALID_SCHEMA','Recheck evidence contains duplicate observations'); - if(outcome==='resolved'&&(!evidence.some(item=>item.kind==='caller'&&item.sourceState==='present')||!evidence.some(item=>item.kind==='security_boundary'))) - throw new CsoError('INVALID_SCHEMA','Resolved closure requires fresh caller evidence and evidence for the original security boundary'); + try { + if (!preparation || preparation.status !== 'ready') + throw new CsoError('PREREQUISITE', 'Resolve the application-preparation prerequisites first'); + if (!targetPlatform) + throw new CsoError('PREREQUISITE', 'Qualified runtimes require an amd64/arm64 Linux Docker platform'); + validateRuntimeCatalog(dependencies.runtimeCatalog); + const runtime = selectRuntime(preparation.runtimeProfile, targetPlatform, dependencies.runtimeCatalog), + availability = inspected.images.find((item) => item.kind === 'runtime' && item.id === runtime.id), + requiredSidecars: Array> = [], + prerequisites: string[] = []; + if (!availability || availability.status !== 'available') + prerequisites.push( + availability?.reason ?? + 'Exact qualified runtime image is not present in the local Docker daemon; rerun setup with Docker and public registry access', + ); + if (preparation.stack === 'rails' && preparation.database?.selected === 'postgresql') { + const sidecar = selectRuntime('postgresql', targetPlatform, dependencies.runtimeCatalog), + sidecarAvailability = inspected.images.find( + (item) => item.kind === 'runtime' && item.id === sidecar.id, + ), + sidecarReady = sidecarAvailability?.status === 'available', + prerequisite = sidecarReady + ? undefined + : (sidecarAvailability?.reason ?? + 'Exact qualified PostgreSQL sidecar image is not present in the local Docker daemon; rerun setup with Docker and public registry access'); + if (prerequisite) prerequisites.push(prerequisite); + requiredSidecars.push({ + kind: 'postgresql', + profile: sidecar.id, + image: sidecar.image, + platform: targetPlatform, + availability: sidecarReady ? 'available' : 'unavailable', + ...(prerequisite ? { prerequisite } : {}), + }); + } + const ready = !prerequisites.length; + checks.push({ + capability: 'qualified-runtimes', + status: ready ? 'ready' : 'missing', + detail: { + catalog: dependencies.runtimeCatalog.revision, + profile: runtime.id, + image: runtime.image, + platform: targetPlatform, + availability: ready ? 'available' : 'unavailable', + ...(requiredSidecars.length ? { requiredSidecars } : {}), + ...(prerequisites.length ? { prerequisite: prerequisites[0], prerequisites } : {}), + }, + }); + } catch (error: any) { + checks.push({ + capability: 'qualified-runtimes', + status: 'missing', + detail: + error instanceof CsoError + ? error.message + : (error?.message ?? 'Qualified runtime catalog is invalid'), + }); + } + for (const id of SCANNER_IDS) { + try { + if (!targetPlatform) + throw new CsoError('PREREQUISITE', 'Scanner containers require an amd64/arm64 Linux Docker platform'); + validateScannerCatalog(scannerCatalog); + const profile = selectScanner(id, targetPlatform, undefined, scannerCatalog), + availability = inspected.images.find((item) => item.kind === 'scanner' && item.id === profile.id); + if (!availability || availability.status !== 'available') + checks.push({ + capability: `scanner:${id}`, + status: 'missing', + detail: { + catalog: scannerCatalog.revision, + profile: profile.id, + image: profile.image, + version: profile.version, + qualifiedAt: profile.qualifiedAt, + availability: 'unavailable', + prerequisite: + availability?.reason ?? + 'Exact qualified scanner image is not present in the local Docker daemon; rerun setup with Docker and public registry access', + }, + }); + else + checks.push({ + capability: `scanner:${id}`, + status: 'ready', + detail: { + catalog: scannerCatalog.revision, + profile: profile.id, + image: profile.image, + version: profile.version, + qualifiedAt: profile.qualifiedAt, + availability: 'available', + }, + }); + } catch (error: any) { + checks.push({ + capability: `scanner:${id}`, + status: 'missing', + detail: + error instanceof CsoError + ? error.message + : (error?.message ?? 'Qualified scanner catalog is invalid'), + }); + } + } + return { schemaVersion: 3, downloads: false, elapsedMs: Date.now() - started, checks }; +} +async function provisionImages(args: string[], dependencies: CsoCliDependencies): Promise { + const setupSummary = take(args, '--setup-summary'), + requestedSeconds = args.includes('--per-image-seconds') ? need(args, '--per-image-seconds') : undefined; + if (args.length) throw new CsoError('INVALID_ARGUMENT', `Unknown argument: ${args[0]}`); + if (requestedSeconds !== undefined) catalogImageProvisioningPolicy(0, requestedSeconds); + let targetPlatform: 'linux/amd64' | 'linux/arm64'; + try { + targetPlatform = platform(); + } catch (error) { + const reason = + error instanceof CsoError + ? error.message + : 'Qualified image provisioning requires an amd64/arm64 Linux Docker platform', + result = { + schemaVersion: 1, + status: 'not_available', + downloads: true, + platform: 'unsupported', + requested: 0, + inspected: 0, + alreadyPresent: 0, + downloaded: 0, + deadlineReached: false, + unavailable: [], + summary: `Qualified CSO images were not preloaded: ${reason}. Static audits remain available.`, + }; + return setupSummary ? result.summary : result; + } + const scannerCatalog = dependencies.scannerCatalog ?? SCANNER_CATALOG, + entries = qualifiedCatalogImages(dependencies.runtimeCatalog, scannerCatalog, targetPlatform), + policy = catalogImageProvisioningPolicy(entries.length, requestedSeconds), + deadline = Date.now() + policy.aggregateMs, + result = await provisionCatalogImages( + entries, + targetPlatform, + dependencies.catalogImageSession ?? productionCatalogImageSession, + deadline, + policy.perImageMs, + ); + return setupSummary ? result.summary : result; +} +function run(args: string[]) { + if (!args.length) throw new CsoError('INVALID_ARGUMENT', 'Run ID is required'); + return { dir: runDirectory(args.shift()!), report: null as any }; +} +function recoveryEvents(dir: string): string[] { + const out: string[] = []; + let visited = 0; + const walk = (at: string, depth: number) => { + if (depth > 6 || visited++ > 4000) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(at, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!/^[A-Za-z0-9._-]{1,120}$/.test(entry.name)) continue; + const path = join(at, entry.name); + let stat: fs.Stats; + try { + stat = fs.lstatSync(path); + } catch { + continue; + } + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + walk(path, depth + 1); + continue; + } + if (!['attempt.event', 'watchdog.event'].includes(entry.name) || !stat.isFile() || stat.size > 8192) + continue; + try { + const message = redact(fs.readFileSync(path, 'utf8').trim()); + if (message && !out.includes(message)) out.push(message); + } catch {} + } + }; + for (const name of ['supervision', 'preparation-execution']) { + const root = join(dir, name); + if (!fs.existsSync(root)) continue; + const stat = fs.lstatSync(root); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new CsoError('UNSAFE_PATH', 'Watchdog recovery state is not a private directory'); + walk(root, 0); + } + return out; +} +function publicSnapshotPath( + manifest: SnapshotManifest, + path: string, +): { path: string; displayPath?: string } { + const entry = manifest.entries.find((item) => item.path === path), + handle = snapshotPathHandle(entry?.pathId ?? snapshotPathId(manifest.root, path)); + let displayPath: string; + try { + displayPath = redact(path); + } catch { + displayPath = '[sensitive path withheld]'; + } + return displayPath === path ? { path } : { path: handle, displayPath }; +} +function publicSnapshotManifest(manifest: SnapshotManifest): Record { + return { + ...manifest, + root: PUBLIC_SOURCE_ROOT, + entries: manifest.entries.map((entry) => { + const { path, pathId: _, ...rest } = entry; + return { ...rest, ...publicSnapshotPath(manifest, path) }; + }), + ...(manifest.deletedPaths?.length + ? { deletedPaths: manifest.deletedPaths.map((item) => publicSnapshotPath(manifest, item.path)) } + : {}), + ...(manifest.changedPaths + ? { changedPaths: manifest.changedPaths.map((path) => publicSnapshotPath(manifest, path).path) } + : {}), + }; +} +function resolveSnapshotPath( + manifest: SnapshotManifest, + value: unknown, + requireEntry: boolean, + label = 'Snapshot path', + allowDeleted = false, +): { path: string; entry?: SnapshotEntry } { + const reference = snapshotReference(value), + handleId = snapshotPathHandleId(reference); + if (handleId) { + const entry = manifest.entries.find((item) => item.pathId === handleId); + if (entry) return { path: entry.path, entry }; + const deleted = manifest.deletedPaths?.find((item) => item.pathId === handleId), + changed = manifest.changedPaths?.find((path) => snapshotPathId(manifest.root, path) === handleId); + if (allowDeleted && deleted) return { path: deleted.path }; + if (!requireEntry && changed) return { path: changed }; + throw new CsoError('INVALID_SCHEMA', `${label} handle is outside the retained inventory: ${reference}`); + } + const path = relativePath(reference), + entry = manifest.entries.find((item) => item.path === path), + deleted = manifest.deletedPaths?.find((item) => item.path === path), + changed = manifest.changedPaths?.includes(path); + if (entry) return { path, entry }; + if (allowDeleted && deleted) return { path }; + if (!requireEntry && changed) return { path }; + throw new CsoError('INVALID_SCHEMA', `${label} is outside the retained inventory: ${reference}`); +} +type BoundRecheckEvidence = { + kind: 'caller' | 'security_boundary'; + path: string; + line: number; + observation: string; + sourceState: 'present' | 'absent'; + snapshotHash: string; + sourceHash?: string; + executionHash?: string; +}; +type BoundRecheckClaim = { + findingId: string; + outcome: 'open' | 'resolved' | 'unknown'; + evidence: BoundRecheckEvidence[]; + rootCause: string; +}; +function assertRecheckLine( + runDir: string, + path: string, + entry: SnapshotEntry, + line: number, + label: string, +): void { + if (!entry.executionHash) + throw new CsoError( + 'INVALID_SCHEMA', + `${label} must reference source available in the fresh execution snapshot`, + ); + const body = readBoundedStable( + containedFile(join(runDir, 'snapshot'), path), + 64 * 1024 * 1024, + label, + ).toString('utf8'), + lines = body.length ? (body.endsWith('\n') ? body.slice(0, -1) : body).split('\n').length : 0; + if (line > lines) throw new CsoError('INVALID_SCHEMA', `${label} line is outside the fresh source file`); +} +function originalBoundary(report: RunReportV3): { + dir: string; + report: RunReportV3; + manifest: SnapshotManifest; + finding: FindingV3; + path: string; +} { + if (!report.parent) + throw new CsoError('INVALID_SCHEMA', 'Recheck evidence requires a linked original finding'); + const dir = runDirectory(report.parent.runId), + original = loadReport(dir), + manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest, + finding = original.findings.find((item) => item.id === report.parent!.findingId); + if (report.repoId !== original.repoId) + throw new CsoError('INCOMPATIBLE_INPUT', 'Recheck repository identity differs from the original audit'); + if (!finding) throw new CsoError('MISSING_INPUT', 'Original recheck finding no longer exists'); + const path = resolveSnapshotPath( + manifest, + finding.location.path, + false, + 'Original finding boundary', + true, + ).path; + return { dir, report: original, manifest, finding, path }; +} +function bindRecheckEvidence( + value: unknown, + runDir: string, + manifest: SnapshotManifest, + boundary: ReturnType, + outcome: BoundRecheckClaim['outcome'], +): BoundRecheckEvidence[] { + if (!Array.isArray(value) || value.length < 1 || value.length > 20) + throw new CsoError('INVALID_SCHEMA', 'Recheck evidence must contain 1 to 20 fresh source observations'); + const evidence = value.map((raw, index) => { + const item = object(raw, `recheck evidence[${index}]`); + rejectUnexpected(item, ['kind', 'path', 'line', 'observation'], `recheck evidence[${index}]`); + const kind = string(item.kind, `recheck evidence[${index}].kind`, 32); + if (!['caller', 'security_boundary'].includes(kind)) + throw new CsoError('INVALID_SCHEMA', 'Recheck evidence kind must be caller or security_boundary'); + if (!Number.isSafeInteger(item.line) || item.line < 1) + throw new CsoError('INVALID_SCHEMA', `recheck evidence[${index}].line must be a positive integer`); + const observation = redact(string(item.observation, `recheck evidence[${index}].observation`, 2048)), + reference = snapshotReference(item.path); + let selected: { path: string; entry?: SnapshotEntry } | undefined; + try { + selected = resolveSnapshotPath(manifest, reference, true, `recheck evidence[${index}].path`); + } catch (error) { + if (kind !== 'security_boundary') throw error; + let old: { path: string }; + try { + old = resolveSnapshotPath( + boundary.manifest, + reference, + false, + `recheck evidence[${index}].path`, + true, + ); + } catch { + throw error; + } + if (old.path !== boundary.path || manifest.entries.some((entry) => entry.path === boundary.path)) + throw error; + if (item.line !== boundary.finding.location.line) + throw new CsoError( + 'INVALID_SCHEMA', + 'Absent security-boundary evidence must cite the original finding line', + ); + return { + kind: 'security_boundary' as const, + path: snapshotPathHandle(snapshotPathId(manifest.root, boundary.path)), + line: item.line as number, + observation, + sourceState: 'absent' as const, + snapshotHash: manifest.originalHash, + }; + } + if (!selected.entry || selected.entry.originalHash === 'not-read') + throw new CsoError( + 'INVALID_SCHEMA', + 'Recheck evidence must reference freshly captured readable source', + ); + assertRecheckLine( + runDir, + selected.path, + selected.entry, + item.line as number, + `recheck evidence[${index}]`, + ); + if (kind === 'security_boundary' && selected.path !== boundary.path) + throw new CsoError( + 'INVALID_SCHEMA', + 'Security-boundary evidence must reference the original finding location', + ); + return { + kind: kind as BoundRecheckEvidence['kind'], + path: snapshotPathHandle(selected.entry.pathId), + line: item.line as number, + observation, + sourceState: 'present' as const, + snapshotHash: manifest.originalHash, + sourceHash: selected.entry.originalHash, + executionHash: selected.entry.executionHash, + }; + }); + const identities = new Set(evidence.map((item) => canonical(item))); + if (identities.size !== evidence.length) + throw new CsoError('INVALID_SCHEMA', 'Recheck evidence contains duplicate observations'); + if ( + outcome === 'resolved' && + (!evidence.some((item) => item.kind === 'caller' && item.sourceState === 'present') || + !evidence.some((item) => item.kind === 'security_boundary')) + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'Resolved closure requires fresh caller evidence and evidence for the original security boundary', + ); return evidence; } -function validateBoundRecheckClaim(value:unknown,runDir:string,manifest:SnapshotManifest,boundary:ReturnType):BoundRecheckClaim{ - const raw=object(value,'retained recheck claim');rejectUnexpected(raw,['findingId','outcome','evidence','rootCause'],'retained recheck claim'); - const findingId=string(raw.findingId,'retained recheck findingId'),rootCause=string(raw.rootCause,'retained recheck rootCause'),outcome=raw.outcome; - if(!['open','resolved','unknown'].includes(outcome as string))throw new CsoError('INVALID_SCHEMA','Retained recheck outcome is invalid'); - if(!Array.isArray(raw.evidence)||raw.evidence.length<1||raw.evidence.length>20)throw new CsoError('INVALID_SCHEMA','Retained recheck evidence is invalid'); - const evidence=raw.evidence.map((itemRaw,index)=>{ - const item=object(itemRaw,`retained recheck evidence[${index}]`);rejectUnexpected(item,['kind','path','line','observation','sourceState','snapshotHash','sourceHash','executionHash'],`retained recheck evidence[${index}]`); - const kind=string(item.kind,`retained recheck evidence[${index}].kind`,32),sourceState=string(item.sourceState,`retained recheck evidence[${index}].sourceState`,16); - if(!['caller','security_boundary'].includes(kind)||!['present','absent'].includes(sourceState))throw new CsoError('INVALID_SCHEMA','Retained recheck evidence type is invalid'); - if(!Number.isSafeInteger(item.line)||item.line<1)throw new CsoError('INVALID_SCHEMA','Retained recheck evidence line is invalid'); - const observation=string(item.observation,`retained recheck evidence[${index}].observation`,2048),reference=snapshotReference(item.path); - if(item.snapshotHash!==manifest.originalHash)throw new CsoError('INCOMPATIBLE_INPUT','Retained recheck evidence is not bound to the complete fresh snapshot inventory'); - if(sourceState==='present'){ - const selected=resolveSnapshotPath(manifest,reference,true,'Retained recheck evidence path'); - if(!selected.entry||selected.entry.originalHash==='not-read'||typeof item.sourceHash!=='string'||item.sourceHash!==selected.entry.originalHash||typeof item.executionHash!=='string'||item.executionHash!==selected.entry.executionHash) - throw new CsoError('INCOMPATIBLE_INPUT','Retained recheck evidence is not bound to the fresh snapshot'); - assertRecheckLine(runDir,selected.path,selected.entry,item.line as number,`retained recheck evidence[${index}]`); - if(kind==='security_boundary'&&selected.path!==boundary.path)throw new CsoError('INCOMPATIBLE_INPUT','Retained security-boundary evidence changed location'); - return{kind:kind as BoundRecheckEvidence['kind'],path:snapshotPathHandle(selected.entry.pathId),line:item.line as number,observation,sourceState:'present' as const,snapshotHash:item.snapshotHash as string,sourceHash:item.sourceHash,executionHash:item.executionHash}; +function validateBoundRecheckClaim( + value: unknown, + runDir: string, + manifest: SnapshotManifest, + boundary: ReturnType, +): BoundRecheckClaim { + const raw = object(value, 'retained recheck claim'); + rejectUnexpected(raw, ['findingId', 'outcome', 'evidence', 'rootCause'], 'retained recheck claim'); + const findingId = string(raw.findingId, 'retained recheck findingId'), + rootCause = string(raw.rootCause, 'retained recheck rootCause'), + outcome = raw.outcome; + if (!['open', 'resolved', 'unknown'].includes(outcome as string)) + throw new CsoError('INVALID_SCHEMA', 'Retained recheck outcome is invalid'); + if (!Array.isArray(raw.evidence) || raw.evidence.length < 1 || raw.evidence.length > 20) + throw new CsoError('INVALID_SCHEMA', 'Retained recheck evidence is invalid'); + const evidence = raw.evidence.map((itemRaw, index) => { + const item = object(itemRaw, `retained recheck evidence[${index}]`); + rejectUnexpected( + item, + ['kind', 'path', 'line', 'observation', 'sourceState', 'snapshotHash', 'sourceHash', 'executionHash'], + `retained recheck evidence[${index}]`, + ); + const kind = string(item.kind, `retained recheck evidence[${index}].kind`, 32), + sourceState = string(item.sourceState, `retained recheck evidence[${index}].sourceState`, 16); + if (!['caller', 'security_boundary'].includes(kind) || !['present', 'absent'].includes(sourceState)) + throw new CsoError('INVALID_SCHEMA', 'Retained recheck evidence type is invalid'); + if (!Number.isSafeInteger(item.line) || item.line < 1) + throw new CsoError('INVALID_SCHEMA', 'Retained recheck evidence line is invalid'); + const observation = string(item.observation, `retained recheck evidence[${index}].observation`, 2048), + reference = snapshotReference(item.path); + if (item.snapshotHash !== manifest.originalHash) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Retained recheck evidence is not bound to the complete fresh snapshot inventory', + ); + if (sourceState === 'present') { + const selected = resolveSnapshotPath(manifest, reference, true, 'Retained recheck evidence path'); + if ( + !selected.entry || + selected.entry.originalHash === 'not-read' || + typeof item.sourceHash !== 'string' || + item.sourceHash !== selected.entry.originalHash || + typeof item.executionHash !== 'string' || + item.executionHash !== selected.entry.executionHash + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Retained recheck evidence is not bound to the fresh snapshot', + ); + assertRecheckLine( + runDir, + selected.path, + selected.entry, + item.line as number, + `retained recheck evidence[${index}]`, + ); + if (kind === 'security_boundary' && selected.path !== boundary.path) + throw new CsoError('INCOMPATIBLE_INPUT', 'Retained security-boundary evidence changed location'); + return { + kind: kind as BoundRecheckEvidence['kind'], + path: snapshotPathHandle(selected.entry.pathId), + line: item.line as number, + observation, + sourceState: 'present' as const, + snapshotHash: item.snapshotHash as string, + sourceHash: item.sourceHash, + executionHash: item.executionHash, + }; } - if(kind!=='security_boundary'||item.sourceHash!==undefined||item.executionHash!==undefined)throw new CsoError('INVALID_SCHEMA','Only an absent original security boundary can use absent evidence'); - const old=resolveSnapshotPath(boundary.manifest,reference,false,'Retained absent security boundary',true); - if(old.path!==boundary.path||manifest.entries.some(entry=>entry.path===boundary.path)||item.line!==boundary.finding.location.line)throw new CsoError('INCOMPATIBLE_INPUT','Retained absent-boundary evidence does not match the fresh snapshot'); - return{kind:'security_boundary' as const,path:snapshotPathHandle(snapshotPathId(manifest.root,boundary.path)),line:item.line as number,observation,sourceState:'absent' as const,snapshotHash:item.snapshotHash as string}; + if (kind !== 'security_boundary' || item.sourceHash !== undefined || item.executionHash !== undefined) + throw new CsoError( + 'INVALID_SCHEMA', + 'Only an absent original security boundary can use absent evidence', + ); + const old = resolveSnapshotPath( + boundary.manifest, + reference, + false, + 'Retained absent security boundary', + true, + ); + if ( + old.path !== boundary.path || + manifest.entries.some((entry) => entry.path === boundary.path) || + item.line !== boundary.finding.location.line + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Retained absent-boundary evidence does not match the fresh snapshot', + ); + return { + kind: 'security_boundary' as const, + path: snapshotPathHandle(snapshotPathId(manifest.root, boundary.path)), + line: item.line as number, + observation, + sourceState: 'absent' as const, + snapshotHash: item.snapshotHash as string, + }; }); - if(outcome==='resolved'&&(!evidence.some(item=>item.kind==='caller'&&item.sourceState==='present')||!evidence.some(item=>item.kind==='security_boundary'))) - throw new CsoError('INVALID_SCHEMA','Resolved closure lacks caller or original security-boundary evidence'); - if(new Set(evidence.map(item=>canonical(item))).size!==evidence.length)throw new CsoError('INVALID_SCHEMA','Retained recheck evidence contains duplicates'); - return{findingId,outcome:outcome as BoundRecheckClaim['outcome'],evidence,rootCause}; + if ( + outcome === 'resolved' && + (!evidence.some((item) => item.kind === 'caller' && item.sourceState === 'present') || + !evidence.some((item) => item.kind === 'security_boundary')) + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'Resolved closure lacks caller or original security-boundary evidence', + ); + if (new Set(evidence.map((item) => canonical(item))).size !== evidence.length) + throw new CsoError('INVALID_SCHEMA', 'Retained recheck evidence contains duplicates'); + return { findingId, outcome: outcome as BoundRecheckClaim['outcome'], evidence, rootCause }; } -function requireReportingTime(report:RunReportV3):void{if(Date.now()>=Date.parse(report.deadline))throw new CsoError('DEADLINE','Audit deadline reached; no further evidence can be accepted');} -function submit(args:string[]){ - const {dir}=run(args);if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','submit requires one JSON file');const rawInput=object(readInput(args[0]),'submission');rejectUnexpected(rawInput,['application','findings','coverage','gaps','modelUsage','recheck'],'submission');const input=rawInput as SubmissionV3; - return withLock(dir,()=>{const report=loadReport(dir),manifest=readJson(join(dir,'snapshot.json'));assertSnapshot(dir,manifest);requireReportingTime(report);if(report.status!=='running')throw new CsoError('INVALID_SCHEMA','Only a running audit accepts evidence'); - if(input.findings!==undefined&&!Array.isArray(input.findings))throw new CsoError('INVALID_SCHEMA','submission.findings must be an array'); - if(input.coverage!==undefined&&!Array.isArray(input.coverage))throw new CsoError('INVALID_SCHEMA','submission.coverage must be an array'); - if(input.application)report.application=model(input.application); - for(const raw of input.findings??[]){const sourceFinding=validateFinding(raw),sourceLocation=resolveSnapshotPath(manifest,sourceFinding.location.path,false,'Finding path',true);if(report.policy.diff&&(!Array.isArray(manifest.changedPaths)||!manifest.changedPaths.includes(sourceLocation.path)))throw new CsoError('INVALID_SCHEMA',`Diff-scope finding root cause is outside the captured changed paths: ${sourceFinding.location.path}`);const safeRaw=object(sanitizeForJson(raw),'finding'),safeLocation=object(safeRaw.location,'location');safeLocation.path=publicSnapshotPath(manifest,sourceLocation.path).path;const f=validateFinding(safeRaw),normalizedLocation=resolveSnapshotPath(manifest,f.location.path,false,'Finding path',true);if(normalizedLocation.path!==sourceLocation.path)throw new CsoError('INVALID_SCHEMA','Finding path identity changed during redaction');if(report.policy.mode==='daily'&&f.evidence==='hypothesis')throw new CsoError('INVALID_SCHEMA','Daily reports contain supported findings only');const old=report.findings.findIndex(x=>x.fingerprint===f.fingerprint);if(old<0){report.findings.push(f);event(report,'early-finding',`${f.severity} ${f.evidence} finding ${f.id}`);}else report.findings[old]={...f,reproduction:report.findings[old].reproduction,repair:report.findings[old].repair,closure:report.findings[old].closure,verificationId:report.findings[old].verificationId,reproductionAttemptId:report.findings[old].reproductionAttemptId,verificationAssurance:report.findings[old].verificationAssurance};} - for(const raw of input.coverage??[]){const c=validateCoverage(raw);if(helperOwnedCoverage(c.domain))throw new CsoError('INVALID_SCHEMA',`Coverage domain is helper-owned: ${c.domain}`);const i=report.coverage.findIndex(x=>x.domain===c.domain&&x.scope===c.scope);if(i<0)report.coverage.push(c);else report.coverage[i]=c;} - if(input.gaps)report.gaps=strings(input.gaps,'gaps'); - if(input.modelUsage){const u=object(input.modelUsage,'model usage');rejectUnexpected(u,['source','tokens','cost'],'model usage');if(!Number.isInteger(u.tokens)||u.tokens<0||('cost'in u&&(typeof u.cost!=='number'||!Number.isFinite(u.cost)||u.cost<0)))throw new CsoError('INVALID_SCHEMA','Model usage must be host-reported finite nonnegative numbers');report.modelUsage={source:string(u.source,'usage source'),tokens:u.tokens,...(typeof u.cost==='number'?{cost:u.cost}:{})};} - let recheckClaim:BoundRecheckClaim|undefined; - if(input.recheck){const rawClaim=object(input.recheck,'recheck claim');rejectUnexpected(rawClaim,['findingId','outcome','evidence','rootCause'],'recheck claim');if(!report.parent||input.recheck.findingId!==report.parent.findingId)throw new CsoError('INVALID_SCHEMA','Recheck claim must target the linked original finding');const outcome=input.recheck.outcome;if(!['open','resolved','unknown'].includes(outcome))throw new CsoError('INVALID_SCHEMA','Recheck needs an open, resolved, or unknown outcome');const boundary=originalBoundary(report),claim={findingId:string(input.recheck.findingId,'findingId'),outcome,evidence:bindRecheckEvidence(input.recheck.evidence,dir,manifest,boundary,outcome),rootCause:string(input.recheck.rootCause,'rootCause')};recheckClaim=claim;} +function requireReportingTime(report: RunReportV3): void { + if (Date.now() >= Date.parse(report.deadline)) + throw new CsoError('DEADLINE', 'Audit deadline reached; no further evidence can be accepted'); +} +function submit(args: string[]) { + const { dir } = run(args); + if (args.length !== 1) throw new CsoError('INVALID_ARGUMENT', 'submit requires one JSON file'); + const rawInput = object(readInput(args[0]), 'submission'); + rejectUnexpected( + rawInput, + ['application', 'findings', 'coverage', 'gaps', 'modelUsage', 'recheck'], + 'submission', + ); + const input = rawInput as SubmissionV3; + return withLock(dir, () => { + const report = loadReport(dir), + manifest = readJson(join(dir, 'snapshot.json')); + assertSnapshot(dir, manifest); + requireReportingTime(report); + if (report.status !== 'running') + throw new CsoError('INVALID_SCHEMA', 'Only a running audit accepts evidence'); + if (input.findings !== undefined && !Array.isArray(input.findings)) + throw new CsoError('INVALID_SCHEMA', 'submission.findings must be an array'); + if (input.coverage !== undefined && !Array.isArray(input.coverage)) + throw new CsoError('INVALID_SCHEMA', 'submission.coverage must be an array'); + if (input.application) report.application = model(input.application); + for (const raw of input.findings ?? []) { + const sourceFinding = validateFinding(raw), + sourceLocation = resolveSnapshotPath( + manifest, + sourceFinding.location.path, + false, + 'Finding path', + true, + ); + if ( + report.policy.diff && + (!Array.isArray(manifest.changedPaths) || !manifest.changedPaths.includes(sourceLocation.path)) + ) + throw new CsoError( + 'INVALID_SCHEMA', + `Diff-scope finding root cause is outside the captured changed paths: ${sourceFinding.location.path}`, + ); + const safeRaw = object(sanitizeForJson(raw), 'finding'), + safeLocation = object(safeRaw.location, 'location'); + safeLocation.path = publicSnapshotPath(manifest, sourceLocation.path).path; + const f = validateFinding(safeRaw), + normalizedLocation = resolveSnapshotPath(manifest, f.location.path, false, 'Finding path', true); + if (normalizedLocation.path !== sourceLocation.path) + throw new CsoError('INVALID_SCHEMA', 'Finding path identity changed during redaction'); + if (report.policy.mode === 'daily' && f.evidence === 'hypothesis') + throw new CsoError('INVALID_SCHEMA', 'Daily reports contain supported findings only'); + const old = report.findings.findIndex((x) => x.fingerprint === f.fingerprint); + if (old < 0) { + report.findings.push(f); + event(report, 'early-finding', `${f.severity} ${f.evidence} finding ${f.id}`); + } else + report.findings[old] = { + ...f, + reproduction: report.findings[old].reproduction, + repair: report.findings[old].repair, + closure: report.findings[old].closure, + verificationId: report.findings[old].verificationId, + reproductionAttemptId: report.findings[old].reproductionAttemptId, + verificationAssurance: report.findings[old].verificationAssurance, + }; + } + for (const raw of input.coverage ?? []) { + const c = validateCoverage(raw); + if (helperOwnedCoverage(c.domain)) + throw new CsoError('INVALID_SCHEMA', `Coverage domain is helper-owned: ${c.domain}`); + const i = report.coverage.findIndex((x) => x.domain === c.domain && x.scope === c.scope); + if (i < 0) report.coverage.push(c); + else report.coverage[i] = c; + } + if (input.gaps) report.gaps = strings(input.gaps, 'gaps'); + if (input.modelUsage) { + const u = object(input.modelUsage, 'model usage'); + rejectUnexpected(u, ['source', 'tokens', 'cost'], 'model usage'); + if ( + !Number.isInteger(u.tokens) || + u.tokens < 0 || + ('cost' in u && (typeof u.cost !== 'number' || !Number.isFinite(u.cost) || u.cost < 0)) + ) + throw new CsoError('INVALID_SCHEMA', 'Model usage must be host-reported finite nonnegative numbers'); + report.modelUsage = { + source: string(u.source, 'usage source'), + tokens: u.tokens, + ...(typeof u.cost === 'number' ? { cost: u.cost } : {}), + }; + } + let recheckClaim: BoundRecheckClaim | undefined; + if (input.recheck) { + const rawClaim = object(input.recheck, 'recheck claim'); + rejectUnexpected(rawClaim, ['findingId', 'outcome', 'evidence', 'rootCause'], 'recheck claim'); + if (!report.parent || input.recheck.findingId !== report.parent.findingId) + throw new CsoError('INVALID_SCHEMA', 'Recheck claim must target the linked original finding'); + const outcome = input.recheck.outcome; + if (!['open', 'resolved', 'unknown'].includes(outcome)) + throw new CsoError('INVALID_SCHEMA', 'Recheck needs an open, resolved, or unknown outcome'); + const boundary = originalBoundary(report), + claim = { + findingId: string(input.recheck.findingId, 'findingId'), + outcome, + evidence: bindRecheckEvidence(input.recheck.evidence, dir, manifest, boundary, outcome), + rootCause: string(input.recheck.rootCause, 'rootCause'), + }; + recheckClaim = claim; + } // A claim can close a prior finding. Publish it only after every report // mutation it relies on is durably accepted, so a failed submission never // leaves closure evidence behind. - saveReport(dir,report);if(recheckClaim)writeHelperJson(join(dir,'recheck-claim.json'),recheckClaim);return {runId:report.runId,findings:report.findings.length,completeness:report.completeness};}); + saveReport(dir, report); + if (recheckClaim) writeHelperJson(join(dir, 'recheck-claim.json'), recheckClaim); + return { runId: report.runId, findings: report.findings.length, completeness: report.completeness }; + }); +} +function finish(args: string[]) { + const { dir } = run(args); + if (args.length) throw new CsoError('INVALID_ARGUMENT', 'finish takes only a run ID'); + return withLock(dir, () => { + const report = loadReport(dir), + manifest = readJson(join(dir, 'snapshot.json')); + assertSnapshot(dir, manifest); + for (const c of report.coverage) + if ( + c.status === 'not_assessed' && + !c.domain.startsWith('scanner:') && + !report.gaps.includes(`${c.domain}: not assessed`) + ) + report.gaps.push(`${c.domain}: not assessed`); + if (!report.application.actors.length && !report.gaps.includes('Application model was not completed')) + report.gaps.push('Application model was not completed'); + report.completeness = completeness(report); + const persistTerminal = () => { + report.status = 'finished'; + if (!report.events.some((e) => e.kind === 'terminal')) + event(report, 'terminal', 'Audit finished and retained according to the private-state policy'); + saveReport(dir, report); + return { + runId: report.runId, + status: report.status, + completeness: report.completeness, + report: 'report.md', + }; + }; + if (report.parent && fs.existsSync(join(dir, 'recheck-claim.json'))) { + const boundary = originalBoundary(report), + claim = validateBoundRecheckClaim(readJson(join(dir, 'recheck-claim.json')), dir, manifest, boundary); + if (claim.outcome === 'resolved') { + if (report.completeness !== 'complete') + throw new CsoError('INVALID_SCHEMA', 'Partial or incompatible rechecks cannot establish closure'); + const originalDir = boundary.dir; + return withLock(originalDir, () => { + const original = loadReport(originalDir), + finding = original.findings.find((f) => f.id === claim.findingId); + if (report.repoId !== original.repoId) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Recheck repository identity differs from the original audit', + ); + const survivingVariant = + !!finding && + report.findings.some( + (candidate) => + candidate.fingerprint === finding.fingerprint || + rootCauseIdentity(candidate.rootCause) === rootCauseIdentity(finding.rootCause) || + (candidate.advisoryIds.length > 0 && + candidate.advisoryIds.some((id) => finding.advisoryIds.includes(id))), + ); + if ( + !finding || + finding.id !== boundary.finding.id || + rootCauseIdentity(finding.rootCause) !== rootCauseIdentity(claim.rootCause) || + survivingVariant + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'Closure needs matching root cause, fresh caller and original-boundary evidence, and no surviving root-cause or advisory variant', + ); + const result = persistTerminal(); + if (finding.closure !== 'resolved') { + finding.closure = 'resolved'; + event(original, 'closure', `Fresh recheck ${report.runId} resolved ${finding.id}`); + saveReport(originalDir, original); + } + return result; + }); + } + } + return persistTerminal(); + }); } -function finish(args:string[]){const {dir}=run(args);if(args.length)throw new CsoError('INVALID_ARGUMENT','finish takes only a run ID');return withLock(dir,()=>{ - const report=loadReport(dir),manifest=readJson(join(dir,'snapshot.json'));assertSnapshot(dir,manifest);for(const c of report.coverage)if(c.status==='not_assessed'&&!c.domain.startsWith('scanner:')&&!report.gaps.includes(`${c.domain}: not assessed`))report.gaps.push(`${c.domain}: not assessed`); - if(!report.application.actors.length&&!report.gaps.includes('Application model was not completed'))report.gaps.push('Application model was not completed');report.completeness=completeness(report); - const persistTerminal=()=>{report.status='finished';if(!report.events.some(e=>e.kind==='terminal'))event(report,'terminal','Audit finished and retained according to the private-state policy');saveReport(dir,report);return{runId:report.runId,status:report.status,completeness:report.completeness,report:'report.md'};}; - if(report.parent&&fs.existsSync(join(dir,'recheck-claim.json'))){const boundary=originalBoundary(report),claim=validateBoundRecheckClaim(readJson(join(dir,'recheck-claim.json')),dir,manifest,boundary);if(claim.outcome==='resolved'){ - if(report.completeness!=='complete')throw new CsoError('INVALID_SCHEMA','Partial or incompatible rechecks cannot establish closure'); - const originalDir=boundary.dir;return withLock(originalDir,()=>{const original=loadReport(originalDir),finding=original.findings.find(f=>f.id===claim.findingId); - if(report.repoId!==original.repoId)throw new CsoError('INCOMPATIBLE_INPUT','Recheck repository identity differs from the original audit'); - const survivingVariant=!!finding&&report.findings.some(candidate=>candidate.fingerprint===finding.fingerprint||rootCauseIdentity(candidate.rootCause)===rootCauseIdentity(finding.rootCause)||(candidate.advisoryIds.length>0&&candidate.advisoryIds.some(id=>finding.advisoryIds.includes(id)))); - if(!finding||finding.id!==boundary.finding.id||rootCauseIdentity(finding.rootCause)!==rootCauseIdentity(claim.rootCause)||survivingVariant)throw new CsoError('INVALID_SCHEMA','Closure needs matching root cause, fresh caller and original-boundary evidence, and no surviving root-cause or advisory variant'); - const result=persistTerminal();if(finding.closure!=='resolved'){finding.closure='resolved';event(original,'closure',`Fresh recheck ${report.runId} resolved ${finding.id}`);saveReport(originalDir,original);}return result;}); - }}return persistTerminal();});} -async function inspect(args:string[]){const {dir}=run(args);if(args.length)throw new CsoError('INVALID_ARGUMENT','inspect takes one run ID');const report=loadReport(dir),manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest;assertSnapshot(dir,manifest);const rawSensitive=readJson(join(dir,'sensitive-evidence.json')),sensitiveEvidence=Array.isArray(rawSensitive)?rawSensitive.map(item=>{if(!item||typeof item!=='object'||typeof item.path!=='string')return item;const id=snapshotPathHandleId(item.path),exact=id?manifest.entries.find(entry=>entry.pathId===id)?.path:item.path;if(!exact)throw new CsoError('INCOMPATIBLE_INPUT','Sensitive-evidence path handle is outside the snapshot');return{...item,...publicSnapshotPath(manifest,exact)};}):rawSensitive;emit({report,manifest:publicSnapshotManifest(manifest),history:readJson(join(dir,'history-status.json')),sensitiveEvidence,preparation:fs.existsSync(join(dir,'preparation.json'))?readJson(join(dir,'preparation.json')):undefined,recovery:recoveryEvents(dir)});} -async function read(args:string[]){const {dir}=run(args);if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','read requires one path or opaque handle');const manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest;assertSnapshot(dir,manifest);const selected=resolveSnapshotPath(manifest,args[0],true),full=containedFile(join(dir,'readable'),selected.path),data=readBoundedStable(full,1024*1024,'Snapshot path');emit(data.toString('utf8'));} -async function history(args:string[]){const {dir}=run(args),manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest;assertSnapshot(dir,manifest);let selected:string|undefined;if(args.length)selected=resolveSnapshotPath(manifest,args.shift(),false,'History path',true).path;if(args.length)throw new CsoError('INVALID_ARGUMENT','history accepts at most one path or opaque handle');const status=readJson(join(dir,'history-status.json'));if(status.status!=='captured'||!fs.existsSync(join(dir,'history.txt')))throw new CsoError('MISSING_INPUT',status.gap||'Historical evidence was not retained');const raw=fs.readFileSync(join(dir,'history.txt'),'utf8');if(!selected){emit(raw);return;} - const displayPath=publicSnapshotPath(manifest,selected).displayPath??selected,retained=historyForPath(raw,displayPath);emit(retained??`No retained patch hunks for ${displayPath}`);} -function resume(args:string[]){const {dir}=run(args);if(args.length)throw new CsoError('INVALID_ARGUMENT','resume takes one run ID');return withLock(dir,()=>{const report=loadReport(dir),manifest=readJson(join(dir,'snapshot.json'));if(report.status==='finished')throw new CsoError('INVALID_SCHEMA','A finished audit cannot be resumed');assertSnapshot(dir,manifest);for(const message of recoveryEvents(dir))if(!report.events.some(e=>e.kind==='watchdog-recovery'&&e.message===message))event(report,'watchdog-recovery',message);if(Date.now()>=Date.parse(report.deadline)){report.status='interrupted';event(report,'deadline','Original budget is exhausted; resume did not replenish it');saveReport(dir,report);throw new CsoError('DEADLINE','Original run budget is exhausted');}report.status='running';event(report,'resume','Continued retained snapshot under original policy');saveReport(dir,report);return {runId:report.runId,deadline:report.deadline,policy:report.policy,recovery:recoveryEvents(dir)};});} -function importV2(args:string[]){if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','import-v2 requires one report file');const legacy=sanitizeForJson(importLegacy(readInput(args[0]))) as ReturnType,id=sha256(JSON.stringify(legacy)),dir=secureDirectory(join(privateRoot(),'legacy-imports'));writeJson(join(dir,`${id}.json`),legacy);return {id,path:`legacy-imports/${id}.json`,warning:legacy.warning,report:legacy};} -function inspectV2(args:string[]){if(args.length!==1||!/^[a-f0-9]{64}$/.test(args[0]??''))throw new CsoError('INVALID_ARGUMENT','inspect-v2 requires the 64-character import ID');const id=args[0],file=join(secureDirectory(join(privateRoot(),'legacy-imports')),`${id}.json`);if(!fs.existsSync(file))throw new CsoError('MISSING_INPUT','Legacy report import was not found or expired');const report=readJson(file);if(report?.schemaVersion!==2||report?.readOnly!==true||!Array.isArray(report?.findings))throw new CsoError('INCOMPATIBLE_INPUT','Stored legacy report is incompatible');if(sha256(JSON.stringify(report))!==id)throw new CsoError('INCOMPATIBLE_INPUT','Stored legacy report identity is inconsistent');return{id,report};} -async function scanner(args:string[],sarif=false){ - const {dir}=run(args); - if(sarif?args.length!==1:(args.length<1||args.length>2||!SCANNER_IDS.includes(args[0] as ScannerId)))throw new CsoError('INVALID_ARGUMENT',sarif?'import-sarif needs one file':'scan requires a supported scanner ID and optional request JSON'); - const id=args[0] as ScannerId,request=!sarif?validateScannerRequest(args[1]?readInput(args[1]):{},id):undefined; +async function inspect(args: string[]) { + const { dir } = run(args); + if (args.length) throw new CsoError('INVALID_ARGUMENT', 'inspect takes one run ID'); + const report = loadReport(dir), + manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + assertSnapshot(dir, manifest); + const rawSensitive = readJson(join(dir, 'sensitive-evidence.json')), + sensitiveEvidence = Array.isArray(rawSensitive) + ? rawSensitive.map((item) => { + if (!item || typeof item !== 'object' || typeof item.path !== 'string') return item; + const id = snapshotPathHandleId(item.path), + exact = id ? manifest.entries.find((entry) => entry.pathId === id)?.path : item.path; + if (!exact) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Sensitive-evidence path handle is outside the snapshot', + ); + return { ...item, ...publicSnapshotPath(manifest, exact) }; + }) + : rawSensitive; + emit({ + report, + manifest: publicSnapshotManifest(manifest), + history: readJson(join(dir, 'history-status.json')), + sensitiveEvidence, + preparation: fs.existsSync(join(dir, 'preparation.json')) + ? readJson(join(dir, 'preparation.json')) + : undefined, + recovery: recoveryEvents(dir), + }); +} +async function read(args: string[]) { + const { dir } = run(args); + if (args.length !== 1) throw new CsoError('INVALID_ARGUMENT', 'read requires one path or opaque handle'); + const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + assertSnapshot(dir, manifest); + const selected = resolveSnapshotPath(manifest, args[0], true), + full = containedFile(join(dir, 'readable'), selected.path), + data = readBoundedStable(full, 1024 * 1024, 'Snapshot path'); + emit(data.toString('utf8')); +} +async function history(args: string[]) { + const { dir } = run(args), + manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + assertSnapshot(dir, manifest); + let selected: string | undefined; + if (args.length) selected = resolveSnapshotPath(manifest, args.shift(), false, 'History path', true).path; + if (args.length) + throw new CsoError('INVALID_ARGUMENT', 'history accepts at most one path or opaque handle'); + const status = readJson(join(dir, 'history-status.json')); + if (status.status !== 'captured' || !fs.existsSync(join(dir, 'history.txt'))) + throw new CsoError('MISSING_INPUT', status.gap || 'Historical evidence was not retained'); + const raw = fs.readFileSync(join(dir, 'history.txt'), 'utf8'); + if (!selected) { + emit(raw); + return; + } + const displayPath = publicSnapshotPath(manifest, selected).displayPath ?? selected, + retained = historyForPath(raw, displayPath); + emit(retained ?? `No retained patch hunks for ${displayPath}`); +} +function resume(args: string[]) { + const { dir } = run(args); + if (args.length) throw new CsoError('INVALID_ARGUMENT', 'resume takes one run ID'); + return withLock(dir, () => { + const report = loadReport(dir), + manifest = readJson(join(dir, 'snapshot.json')); + if (report.status === 'finished') + throw new CsoError('INVALID_SCHEMA', 'A finished audit cannot be resumed'); + assertSnapshot(dir, manifest); + for (const message of recoveryEvents(dir)) + if (!report.events.some((e) => e.kind === 'watchdog-recovery' && e.message === message)) + event(report, 'watchdog-recovery', message); + if (Date.now() >= Date.parse(report.deadline)) { + report.status = 'interrupted'; + event(report, 'deadline', 'Original budget is exhausted; resume did not replenish it'); + saveReport(dir, report); + throw new CsoError('DEADLINE', 'Original run budget is exhausted'); + } + report.status = 'running'; + event(report, 'resume', 'Continued retained snapshot under original policy'); + saveReport(dir, report); + return { + runId: report.runId, + deadline: report.deadline, + policy: report.policy, + recovery: recoveryEvents(dir), + }; + }); +} +function importV2(args: string[]) { + if (args.length !== 1) throw new CsoError('INVALID_ARGUMENT', 'import-v2 requires one report file'); + const legacy = sanitizeForJson(importLegacy(readInput(args[0]))) as ReturnType, + id = sha256(JSON.stringify(legacy)), + dir = secureDirectory(join(privateRoot(), 'legacy-imports')); + writeJson(join(dir, `${id}.json`), legacy); + return { id, path: `legacy-imports/${id}.json`, warning: legacy.warning, report: legacy }; +} +function inspectV2(args: string[]) { + if (args.length !== 1 || !/^[a-f0-9]{64}$/.test(args[0] ?? '')) + throw new CsoError('INVALID_ARGUMENT', 'inspect-v2 requires the 64-character import ID'); + const id = args[0], + file = join(secureDirectory(join(privateRoot(), 'legacy-imports')), `${id}.json`); + if (!fs.existsSync(file)) + throw new CsoError('MISSING_INPUT', 'Legacy report import was not found or expired'); + const report = readJson(file); + if (report?.schemaVersion !== 2 || report?.readOnly !== true || !Array.isArray(report?.findings)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Stored legacy report is incompatible'); + if (sha256(JSON.stringify(report)) !== id) + throw new CsoError('INCOMPATIBLE_INPUT', 'Stored legacy report identity is inconsistent'); + return { id, report }; +} +async function scanner(args: string[], sarif = false) { + const { dir } = run(args); + if ( + sarif + ? args.length !== 1 + : args.length < 1 || args.length > 2 || !SCANNER_IDS.includes(args[0] as ScannerId) + ) + throw new CsoError( + 'INVALID_ARGUMENT', + sarif + ? 'import-sarif needs one file' + : 'scan requires a supported scanner ID and optional request JSON', + ); + const id = args[0] as ScannerId, + request = !sarif ? validateScannerRequest(args[1] ? readInput(args[1]) : {}, id) : undefined; // A live writer owns the lock throughout the bounded scan. Finish, submit and // concurrent imports cannot replace coverage or retire the run underneath it. - return await withLock(dir,async()=>{ - const report=loadReport(dir);requireTime(report);if(report.status!=='running')throw new CsoError('INVALID_SCHEMA','Scanner evidence can only enter a running audit'); - let record:any; - if(sarif){ - const file=callerPath(args[0]); - try{ - const data=readBoundedStable(file,1024*1024,'SARIF file'),out=importSarif(data.toString('utf8'),{sourceRoot:'/source'});record={outcome:out,coverage:scannerCoverage(out,report.policy.scope),provenance:{kind:'untrusted SARIF import',sourceHash:sha256(data)}}; - }catch(error){if(error instanceof CsoError)throw error;throw new CsoError('MISSING_INPUT','SARIF file is missing or unreadable');} - }else{ - event(report,`scanner-attempt:${id}`,'Started bounded scanner collection; completion requires an immutable outcome artifact');saveReport(dir,report); - record=await executeScanner({id,runId:report.runId,runDir:dir,manifest:readJson(join(dir,'snapshot.json')),policy:report.policy,executionDeadline:Date.parse(report.deadline)-60_000,platform:platform(),request,watchdogPath:join(dirname(process.execPath),'gstack-cso-watchdog')}); + return await withLock(dir, async () => { + const report = loadReport(dir); + requireTime(report); + if (report.status !== 'running') + throw new CsoError('INVALID_SCHEMA', 'Scanner evidence can only enter a running audit'); + let record: any; + if (sarif) { + const file = callerPath(args[0]); + try { + const data = readBoundedStable(file, 1024 * 1024, 'SARIF file'), + out = importSarif(data.toString('utf8'), { sourceRoot: '/source' }); + record = { + outcome: out, + coverage: scannerCoverage(out, report.policy.scope), + provenance: { kind: 'untrusted SARIF import', sourceHash: sha256(data) }, + }; + } catch (error) { + if (error instanceof CsoError) throw error; + throw new CsoError('MISSING_INPUT', 'SARIF file is missing or unreadable'); + } + } else { + event( + report, + `scanner-attempt:${id}`, + 'Started bounded scanner collection; completion requires an immutable outcome artifact', + ); + saveReport(dir, report); + record = await executeScanner({ + id, + runId: report.runId, + runDir: dir, + manifest: readJson(join(dir, 'snapshot.json')), + policy: report.policy, + executionDeadline: Date.parse(report.deadline) - 60_000, + platform: platform(), + request, + watchdogPath: join(dirname(process.execPath), 'gstack-cso-watchdog'), + }); } - const outcome=record.outcome,originalCount=outcome.candidates.length; + const outcome = record.outcome, + originalCount = outcome.candidates.length; // Normalization can expand a 1 MiB scanner payload. Retain supported-size // candidate evidence and disclose omissions instead of saving unreadable state. - while(Buffer.byteLength(JSON.stringify(record,null,2))>950_000&&outcome.candidates.length)outcome.candidates.splice(Math.max(0,outcome.candidates.length-Math.max(1,Math.ceil(outcome.candidates.length/4)))); - if(outcome.candidates.length1024*1024)throw new CsoError('PERSISTENCE_FAILED','Scanner result exceeds the private artifact limit; no saved report is claimed'); - record=persistableArtifact(record,'Scanner outcome');const artifactId=`${outcome.tool}-${sha256(canonical(record)).slice(0,16)}-${randomBytes(8).toString('hex')}`,file=join(dir,'scanner-outcomes',`${artifactId}.json`); - writeJsonExclusive(file,record); + while (Buffer.byteLength(JSON.stringify(record, null, 2)) > 950_000 && outcome.candidates.length) + outcome.candidates.splice( + Math.max(0, outcome.candidates.length - Math.max(1, Math.ceil(outcome.candidates.length / 4))), + ); + if (outcome.candidates.length < originalCount) { + outcome.status = 'partial'; + outcome.gaps.push({ + code: 'OUTPUT_LIMIT', + message: `${originalCount - outcome.candidates.length} scanner candidates withheld to fit the bounded immutable artifact`, + }); + record.coverage = scannerCoverage(outcome, report.policy.scope); + } + if (Buffer.byteLength(JSON.stringify(record, null, 2)) > 1024 * 1024) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Scanner result exceeds the private artifact limit; no saved report is claimed', + ); + record = persistableArtifact(record, 'Scanner outcome'); + const artifactId = `${outcome.tool}-${sha256(canonical(record)).slice(0, 16)}-${randomBytes(8).toString('hex')}`, + file = join(dir, 'scanner-outcomes', `${artifactId}.json`); + writeJsonExclusive(file, record); record.coverage.evidence.push(`Immutable outcome: ${artifactId}`); - report.coverage.push(record.coverage);event(report,'scanner-outcome',`${artifactId}: ${outcome.status}; ${outcome.candidates.length} candidates`);saveReport(dir,report); - return {...outcome,artifactId,artifact:`scanner-outcomes/${artifactId}.json`,provenance:record.provenance}; + report.coverage.push(record.coverage); + event( + report, + 'scanner-outcome', + `${artifactId}: ${outcome.status}; ${outcome.candidates.length} candidates`, + ); + saveReport(dir, report); + return { + ...outcome, + artifactId, + artifact: `scanner-outcomes/${artifactId}.json`, + provenance: record.provenance, + }; }); } -function scannerOutcome(args:string[]){const {dir}=run(args);if(args.length!==1||!/^[a-z0-9-]{1,40}-[a-f0-9]{16}-[a-f0-9]{16}$/.test(args[0]))throw new CsoError('INVALID_ARGUMENT','scanner-outcome requires one immutable scanner artifact ID');return readJson(join(dir,'scanner-outcomes',`${args[0]}.json`));} -function recheckOriginalDirectory(repo:string,findingId:string,requestedRun?:string):{dir:string;runId:string}{ - const currentRepoId=repoId(repo),root=privateRoot(),repoDir=join(root,currentRepoId),runPattern=/^\d{13}-[a-f0-9]{16}$/; - if(requestedRun){ - if(!runPattern.test(requestedRun))throw new CsoError('INVALID_ARGUMENT','Run identifier must be the ID returned by start'); - const dir=join(repoDir,requestedRun);if(!fs.existsSync(dir))throw new CsoError('MISSING_INPUT','Original run was not found for the current repository or has expired'); - return{dir:secureDirectory(dir),runId:requestedRun}; +function scannerOutcome(args: string[]) { + const { dir } = run(args); + if (args.length !== 1 || !/^[a-z0-9-]{1,40}-[a-f0-9]{16}-[a-f0-9]{16}$/.test(args[0])) + throw new CsoError('INVALID_ARGUMENT', 'scanner-outcome requires one immutable scanner artifact ID'); + return readJson(join(dir, 'scanner-outcomes', `${args[0]}.json`)); +} +function recheckOriginalDirectory( + repo: string, + findingId: string, + requestedRun?: string, +): { dir: string; runId: string } { + const currentRepoId = repoId(repo), + root = privateRoot(), + repoDir = join(root, currentRepoId), + runPattern = /^\d{13}-[a-f0-9]{16}$/; + if (requestedRun) { + if (!runPattern.test(requestedRun)) + throw new CsoError('INVALID_ARGUMENT', 'Run identifier must be the ID returned by start'); + const dir = join(repoDir, requestedRun); + if (!fs.existsSync(dir)) + throw new CsoError( + 'MISSING_INPUT', + 'Original run was not found for the current repository or has expired', + ); + return { dir: secureDirectory(dir), runId: requestedRun }; } - if(!fs.existsSync(repoDir))throw new CsoError('MISSING_INPUT','No finished original audit contains this finding in the current repository'); - const matches:{dir:string;runId:string}[]=[],directory=fs.opendirSync(secureDirectory(repoDir));let visited=0; - try{let entry:fs.Dirent|null;while((entry=directory.readSync())!==null){ - if(++visited>REPLAY_LOOKUP_MAX_ENTRIES)throw new CsoError('INSUFFICIENT_CAPACITY',`Recheck lookup exceeded ${REPLAY_LOOKUP_MAX_ENTRIES} private state entries`); - if(!entry.isDirectory()||!runPattern.test(entry.name))continue;const dir=join(repoDir,entry.name),reportPath=join(dir,'report.json');if(!fs.existsSync(reportPath))continue;const report=loadReport(dir); - if(report.runId!==entry.name||report.repoId!==currentRepoId)throw new CsoError('INCOMPATIBLE_INPUT','Retained original audit identity does not match its repository state path'); - if(report.status==='finished'&&!report.parent&&report.findings.some(f=>f.id===findingId))matches.push({dir,runId:entry.name}); - }}finally{directory.closeSync();} - if(!matches.length)throw new CsoError('MISSING_INPUT','No finished original audit contains this finding in the current repository'); - if(matches.length>1)throw new CsoError('INVALID_ARGUMENT',`Finding matches ${matches.length} finished original audits; use --run RUN to select one`); + if (!fs.existsSync(repoDir)) + throw new CsoError( + 'MISSING_INPUT', + 'No finished original audit contains this finding in the current repository', + ); + const matches: { dir: string; runId: string }[] = [], + directory = fs.opendirSync(secureDirectory(repoDir)); + let visited = 0; + try { + let entry: fs.Dirent | null; + while ((entry = directory.readSync()) !== null) { + if (++visited > REPLAY_LOOKUP_MAX_ENTRIES) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `Recheck lookup exceeded ${REPLAY_LOOKUP_MAX_ENTRIES} private state entries`, + ); + if (!entry.isDirectory() || !runPattern.test(entry.name)) continue; + const dir = join(repoDir, entry.name), + reportPath = join(dir, 'report.json'); + if (!fs.existsSync(reportPath)) continue; + const report = loadReport(dir); + if (report.runId !== entry.name || report.repoId !== currentRepoId) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Retained original audit identity does not match its repository state path', + ); + if (report.status === 'finished' && !report.parent && report.findings.some((f) => f.id === findingId)) + matches.push({ dir, runId: entry.name }); + } + } finally { + directory.closeSync(); + } + if (!matches.length) + throw new CsoError( + 'MISSING_INPUT', + 'No finished original audit contains this finding in the current repository', + ); + if (matches.length > 1) + throw new CsoError( + 'INVALID_ARGUMENT', + `Finding matches ${matches.length} finished original audits; use --run RUN to select one`, + ); return matches[0]; } -async function recheck(args:string[],dependencies:CsoCliDependencies){const startedAt=new Date();if(!args.length)throw new CsoError('INVALID_ARGUMENT','recheck requires a finding ID');const findingId=args.shift()!;if(!/^[a-f0-9]{32}$/.test(findingId))throw new CsoError('INVALID_ARGUMENT','Finding identifier must be the 32-character ID reported by CSO');const repo=callerPath(need(args,'--repo')),requestedRun=args.includes('--run')?need(args,'--run'):undefined;if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown recheck argument: ${args[0]}`);if(!fs.existsSync(repo)||!fs.statSync(repo).isDirectory())throw new CsoError('MISSING_INPUT','Repository directory does not exist');assertStateOutside(repo);retention(startedAt.getTime(),{deadlineMs:startedAt.getTime()+RETENTION_MAINTENANCE_MS,maxEntries:RETENTION_MAX_ENTRIES});const selected=recheckOriginalDirectory(repo,findingId,requestedRun),runId=selected.runId,originalDir=selected.dir;return await withLock(originalDir,async()=>{const original=loadReport(originalDir);if(original.runId!==runId||original.repoId!==repoId(repo))throw new CsoError('INCOMPATIBLE_INPUT','Retained original audit identity does not match its repository state path');const finding=original.findings.find(f=>f.id===findingId);if(!finding)throw new CsoError('MISSING_INPUT','Original finding does not exist');if(original.status!=='finished'||original.parent)throw new CsoError('INVALID_SCHEMA','Recheck requires a finished original audit'); +async function recheck(args: string[], dependencies: CsoCliDependencies) { + const startedAt = new Date(); + if (!args.length) throw new CsoError('INVALID_ARGUMENT', 'recheck requires a finding ID'); + const findingId = args.shift()!; + if (!/^[a-f0-9]{32}$/.test(findingId)) + throw new CsoError('INVALID_ARGUMENT', 'Finding identifier must be the 32-character ID reported by CSO'); + const repo = callerPath(need(args, '--repo')), + requestedRun = args.includes('--run') ? need(args, '--run') : undefined; + if (args.length) throw new CsoError('INVALID_ARGUMENT', `Unknown recheck argument: ${args[0]}`); + if (!fs.existsSync(repo) || !fs.statSync(repo).isDirectory()) + throw new CsoError('MISSING_INPUT', 'Repository directory does not exist'); + assertStateOutside(repo); + retention(startedAt.getTime(), { + deadlineMs: startedAt.getTime() + RETENTION_MAINTENANCE_MS, + maxEntries: RETENTION_MAX_ENTRIES, + }); + const selected = recheckOriginalDirectory(repo, findingId, requestedRun), + runId = selected.runId, + originalDir = selected.dir; + return await withLock(originalDir, async () => { + const original = loadReport(originalDir); + if (original.runId !== runId || original.repoId !== repoId(repo)) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Retained original audit identity does not match its repository state path', + ); + const finding = original.findings.find((f) => f.id === findingId); + if (!finding) throw new CsoError('MISSING_INPUT', 'Original finding does not exist'); + if (original.status !== 'finished' || original.parent) + throw new CsoError('INVALID_SCHEMA', 'Recheck requires a finished original audit'); // Keep the original immutable while the fresh snapshot is captured and // until its child lineage report has been durably published. - const oldManifest=readJson(join(originalDir,'snapshot.json')); - const preserveBase=original.policy.diff||Boolean(original.source.baseCommit),report=await start(['--repo',repo,...(original.policy.mode==='comprehensive'?['--comprehensive']:[]),...(original.policy.diff?['--diff']:[]),...(preserveBase?['--base',original.policy.base]:[]),'--budget',String(original.policy.budgetSeconds),...(original.policy.offline?['--offline']:[]),...(original.policy.scope==='default'?[]:original.policy.scope.startsWith('domain:')?['--scope',original.policy.scope.slice(7)]:[`--${original.policy.scope}`])],dependencies,{runId,findingId,kind:'recheck'},oldManifest.headCommit,startedAt); - return {runId:report.runId,parent:report.parent};});} - -function recordReview(args:string[]){ - const {dir}=run(args);if(!args.length)throw new CsoError('INVALID_ARGUMENT','record-review requires a request JSON file and --producer ID');const raw=readInput(args.shift()!),producer=need(args,'--producer');if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown record-review argument: ${args[0]}`);const request=validateVerificationRequest(raw); - return withLock(dir,()=>{const report=loadReport(dir);requireTime(report);if(report.policy.mode!=='comprehensive'||report.status!=='running'||!report.findings.some(f=>f.id===request.findingId&&f.evidence==='supported'))throw new CsoError('MISSING_INPUT','Review artifact must target a supported finding in a running comprehensive audit');const artifact=persistableArtifact(makeReviewArtifact(report.runId,request,string(producer,'producer identity',200)),'Repair review artifact');writeJsonExclusive(join(dir,'reviews',`${artifact.id}.json`),artifact);event(report,'repair-review',`Self-attested review artifact ${artifact.id} bound the proposed repair; reviewer independence is not host-verifiable`);saveReport(dir,report);return{reviewArtifactId:artifact.id,reviewAssurance:artifact.assurance,patchHash:artifact.patchHash,requestHash:artifact.requestHash};}); -} -function publicPlanArgument(manifest:SnapshotManifest,arg:string,paths:string[]):string{ - for(const path of [...paths].sort((a,b)=>b.length-a.length)){const reference=publicSnapshotPath(manifest,path).path;if(reference===path)continue;if(arg===path)return reference;if(arg===`./${path}`)return `./${reference}`;} - return arg; -} -function publicTestPlan(manifest:SnapshotManifest,plan:ReturnType){return{...plan,commands:plan.commands.map(command=>({...command,args:command.args.map(arg=>publicPlanArgument(manifest,arg,plan.files))})),files:plan.files.map(path=>publicSnapshotPath(manifest,path).path)};} -function publicStartPlan(manifest:SnapshotManifest,plan:ReturnType){return{...plan,command:{...plan.command,args:plan.command.args.map(arg=>publicPlanArgument(manifest,arg,plan.entrypointFiles))},entrypointFiles:plan.entrypointFiles.map(path=>publicSnapshotPath(manifest,path).path)};} -function testPlan(args:string[]){const {dir}=run(args);if(args.length!==1||!['node','bun','python','rails'].includes(args[0]))throw new CsoError('INVALID_ARGUMENT','test-plan requires one supported stack');const stack=args[0] as 'node'|'bun'|'python'|'rails',manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest;assertSnapshot(dir,manifest);const preparation=inspectPreparation(join(dir,'snapshot'),stack);if(preparation.status!=='ready')throw new CsoError('PREREQUISITE',preparation.prerequisites.map(item=>item.message).join('; ')||`${stack} preparation metadata is incomplete`);return{stack,runtimeProfile:preparation.runtimeProfile,...publicTestPlan(manifest,canonicalTestPlan(join(dir,'snapshot'),stack))};} -function runtimePlan(args:string[]){const {dir}=run(args);if(!args.length||!['node','bun','python','rails'].includes(args[0]))throw new CsoError('INVALID_ARGUMENT','runtime-plan requires one supported stack and --port PORT');const stack=args.shift() as 'node'|'bun'|'python'|'rails',rawPort=need(args,'--port');if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown runtime-plan argument: ${args[0]}`);const port=Number(rawPort);if(!Number.isInteger(port)||port<1024||port>65535)throw new CsoError('INVALID_ARGUMENT','--port must be an integer from 1024 to 65535');const manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest;assertSnapshot(dir,manifest);const preparation=inspectPreparation(join(dir,'snapshot'),stack);if(preparation.status!=='ready')throw new CsoError('PREREQUISITE',preparation.prerequisites.map(item=>item.message).join('; ')||`${stack} preparation metadata is incomplete`);return{stack,runtimeProfile:preparation.runtimeProfile,start:publicStartPlan(manifest,canonicalStartPlan(join(dir,'snapshot'),stack,port)),tests:publicTestPlan(manifest,canonicalTestPlan(join(dir,'snapshot'),stack))};} - -function platform(): 'linux/amd64'|'linux/arm64'{if(!['linux','darwin'].includes(process.platform)||!['x64','arm64'].includes(process.arch))throw new CsoError('PREREQUISITE','Contained target execution requires a Linux or macOS host with amd64/arm64 Linux Docker images');return process.arch==='arm64'?'linux/arm64':'linux/amd64';} -function watchdog():string{const p=join(dirname(process.execPath),process.platform==='win32'?'gstack-cso-watchdog.exe':'gstack-cso-watchdog');if(!fs.existsSync(p))throw new CsoError('ISOLATION_FAILED','Trusted detached watchdog is missing');return p;} - -function closureStateFile(dir:string,findingId:string,phase:'before'|'after',plan:unknown):string{ - return join(dir,'dependency-closures',`${findingId}-${phase}-${sha256(canonical(plan)).slice(0,16)}.json`); -} -function retainClosure(path:string,closure:DependencyClosure):void{ - if(fs.existsSync(path)){if(canonical(readJson(path))!==canonical(closure))throw new CsoError('INCOMPATIBLE_INPUT','Retained dependency closure conflicts with this preparation plan');return;} - writeJsonExclusive(path,persistableArtifact(closure,'Dependency closure')); -} -function bindArchiveHashes(target:string[],closures:{before:DependencyClosure;after:DependencyClosure}):void{ - const hashes=[...new Set([...closures.before.archives,...closures.after.archives].map(archive=>archive.sha256))].sort();target.splice(0,target.length,...hashes); -} -function preparedVerificationExecutor(options:{dir:string;findingId:string;runtimeProfile:string;stack:'node'|'bun'|'python'|'rails';targetPlatform:'linux/amd64'|'linux/arm64';deadline:number;offline:boolean;runtimeCatalog:RuntimeCatalog;preparation:PreparationExecutor;delegate:VerificationExecutor;beforePlan:ReturnType;beforeAdmission:ReturnType;beforeClosure:DependencyClosure;closures:{before:DependencyClosure;after:DependencyClosure};archiveHashes:string[];proofs:{before:PreparationProof;after:PreparationProof};replay?:{before:DependencyClosure;after:DependencyClosure};persistClosures:boolean;}):VerificationExecutor{ - let beforeProjectToolchainHash:string|undefined; - return {observe:async(source,phase,request,runtime,verifier,work,control,_execution,testEvidence,witness)=>{ - const plan=phase==='before'?options.beforePlan:inspectPreparation(source,options.stack); - if(plan.status!=='ready')throw new CsoError('PREREQUISITE',plan.prerequisites.map(item=>item.message).join('; ')||`${options.stack} dependency metadata is not ready`); - const admission=phase==='before'?options.beforeAdmission:admitPreparationRuntime({plan,platform:options.targetPlatform,profile:options.runtimeProfile,catalog:options.runtimeCatalog}); - if(admission.runtime.id!==runtime.id||admission.runtime.image!==runtime.image)throw new CsoError('INCOMPATIBLE_INPUT','Prepared verification runtime changed between source phases'); - const state=closureStateFile(options.dir,options.findingId,phase,plan),supplied=options.replay?.[phase],retained=!supplied&&fs.existsSync(state)?readJson(state) as DependencyClosure:undefined; - const closure=phase==='before'?(supplied??options.beforeClosure):await options.preparation.acquire({plan,admission,snapshot:source,deadline:options.deadline,offline:options.offline||Boolean(supplied),existingClosure:supplied??retained}); - options.closures[phase]=closure; - bindArchiveHashes(options.archiveHashes,options.closures); - if(options.persistClosures)retainClosure(state,closure); - let database:RailsDatabaseSelection|undefined; - if(options.stack==='rails'){ - const selected=plan.database?.selected; - if(!selected)throw new CsoError('PREREQUISITE','Rails automatic verification could not select one locked database adapter from static test configuration'); - database=selected==='postgresql'?{adapter:'postgresql',sidecar:admitPreparationSidecar({platform:options.targetPlatform,catalog:options.runtimeCatalog})}:{adapter:'sqlite'}; - } - const prepared=await options.preparation.prepareOffline({plan,admission,snapshot:source,closure,deadline:options.deadline,database}); - try{ - options.proofs[phase]={schemaVersion:1,dependencyClosureHash:prepared.dependencyClosureHash,configurationHash:prepared.configurationHash, - sourceProjectionHash:prepared.sourceProjectionHash,preparedManifestHash:prepared.preparedManifestHash,preparedDependencyHash:prepared.preparedDependencyHash,receiptHash:prepared.receiptHash, - executionEnvironmentHash:sha256(canonical(prepared.executionEnvironment)),databaseHash:prepared.databaseHash, - transformations:prepared.transformations}; - const sourceTests=canonicalTestPlan(source,options.stack),preparedTests=canonicalTestPlan(prepared.preparedRoot,options.stack), - sourceStart=canonicalStartPlan(source,options.stack,request.port),preparedStart=canonicalStartPlan(prepared.preparedRoot,options.stack,request.port); - if(sourceTests.signature!==preparedTests.signature||sourceStart.signature!==preparedStart.signature||verificationHarnessHash(request,source)!==verificationHarnessHash(request,prepared.preparedRoot))throw new CsoError('ISOLATION_FAILED','Offline lifecycle execution changed the canonical start, test, or harness inputs'); - if(sourceTests.toolchain==='project'){ - if(phase==='before')beforeProjectToolchainHash=prepared.preparedDependencyHash; - else if(!beforeProjectToolchainHash||prepared.preparedDependencyHash!==beforeProjectToolchainHash)throw new CsoError('ASSERTION_FAILED','Offline preparation changed the project-installed test toolchain between source phases'); - } - const protectedPaths=new Set([...request.boundaryFiles,...request.testFiles,...sourceStart.entrypointFiles,...request.changes.map(item=>item.path)]); - if(prepared.transformations.some(item=>protectedPaths.has(item.path)))throw new CsoError('ISOLATION_FAILED','Synthetic preparation transformation overlaps a security boundary, startup input, or test input'); - return await options.delegate.observe(prepared.preparedRoot,phase,request,runtime,verifier,work,control, - {environment:prepared.executionEnvironment,database:prepared.database},testEvidence,witness); - } - finally{await options.preparation.dispose(prepared);} - }}; -} -async function verify(args:string[],dependencies:CsoCliDependencies){const {dir}=run(args);if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','verify requires one request JSON file');const raw=readInput(args[0]),request=validateVerificationRequest(raw); - return await withLock(dir,async()=>{const report=loadReport(dir),manifest=readJson(join(dir,'snapshot.json')) as SnapshotManifest;assertSnapshot(dir,manifest);requireTime(report);if(report.policy.mode!=='comprehensive'||report.status!=='running')throw new CsoError('INVALID_SCHEMA','Only a running comprehensive audit can request target execution');const finding=report.findings.find(f=>f.id===request.findingId&&f.evidence==='supported');if(!finding)throw new CsoError('MISSING_INPUT','Verification must target a supported finding in this run'); - if(!request.review.artifactId)throw new CsoError('MISSING_INPUT','Verification requires a separately persisted independent repair-review artifact');const reviewArtifact=validateReviewArtifact(readJson(join(dir,'reviews',`${request.review.artifactId}.json`)),report.runId,request); - const findingPath=resolveSnapshotPath(manifest,finding.location.path,true,'Finding path').path;if(!request.boundaryFiles.some(path=>resolveSnapshotPath(manifest,path,true,'Boundary path').path===findingPath))throw new CsoError('INVALID_SCHEMA','Boundary files must include the finding location'); - const attempts=report.events.filter(e=>e.kind===`verification-attempt:${finding.id}`).length;if(attempts>=3)throw new CsoError('DEADLINE','Three bounded harness/repair attempts have already been used for this finding');if(report.findings.filter(f=>['runtime_tested','tested'].includes(f.repair)).length>=3)throw new CsoError('INSUFFICIENT_CAPACITY','This run already produced three runtime-tested repairs'); - const targetPlatform=platform();let runtime;try{runtime=selectRuntime(request.runtimeProfile,targetPlatform,dependencies.runtimeCatalog);}catch(error:any){throw new CsoError('PREREQUISITE',error?.message||'Qualified runtime is unavailable');}const verifier=runtime; - if(!['node','bun','python','rails'].includes(runtime.stack))throw new CsoError('INCOMPATIBLE_INPUT','Application verification requires an application runtime profile');const plan=inspectPreparation(join(dir,'snapshot'),runtime.stack as any);if(plan.status!=='ready')throw new CsoError('PREREQUISITE',plan.prerequisites.map(p=>p.message).join('; ')||'Runtime preparation metadata is incomplete');assertRuntimeCompatible(plan,runtime);writeJson(join(dir,`preparation-${runtime.stack}.json`),plan); - const endpoint=await dockerEndpoint(secureDirectory(join(dir,'home'))),watchdogPath=dependencies.watchdogPath(),attemptDeadline=Math.min(Date.now()+300_000,Date.parse(report.deadline)-60_000); - const admission=admitPreparationRuntime({plan,platform:targetPlatform,profile:runtime.id,catalog:dependencies.runtimeCatalog}),staging=secureDirectory(join(dir,'archive-staging')), - runner=new DockerPreparationSandboxRunner({endpoint,watchdogPath,runRoot:dir,controlRoot:secureDirectory(join(dir,'preparation-execution')),admission}), - preparation=new PreparationExecutor({cache:new PublicArchiveCache({root:publicArchiveCacheRoot(),stagingRoot:staging}),runner,materializationRoot:secureDirectory(join(dir,'archive-materializations'))}); - const beforeState=closureStateFile(dir,finding.id,'before',plan),retainedBefore=fs.existsSync(beforeState)?readJson(beforeState) as DependencyClosure:undefined; - const beforeClosure=await preparation.acquire({plan,admission,snapshot:join(dir,'snapshot'),deadline:attemptDeadline,offline:report.policy.offline,existingClosure:retainedBefore});retainClosure(beforeState,beforeClosure); - const closures={before:beforeClosure,after:beforeClosure},archiveHashes=[...new Set(beforeClosure.archives.map(archive=>archive.sha256))].sort(),proofs={} as {before:PreparationProof;after:PreparationProof},delegate=new DockerVerificationExecutor(endpoint,watchdogPath,attemptDeadline,()=>{event(report,`verification-attempt:${finding.id}`,`Started bounded repair verification attempt ${attempts+1}`);saveReport(dir,report);}); - const executor=preparedVerificationExecutor({dir,findingId:finding.id,runtimeProfile:runtime.id,stack:runtime.stack as 'node'|'bun'|'python'|'rails',targetPlatform,deadline:attemptDeadline,offline:report.policy.offline,runtimeCatalog:dependencies.runtimeCatalog,preparation,delegate,beforePlan:plan,beforeAdmission:admission,beforeClosure,closures,archiveHashes,proofs,persistClosures:true}); - let result:Awaited>;try{result=await verifyRepair({runId:report.runId,runDir:dir,manifest,rawRequest:raw,runtime,verifier,policyHash:ISOLATION_POLICY_HASH,auditPolicyHash:sha256(canonical(report.policy)),archives:archiveHashes,dependencyClosures:closures,preparation:proofs,reviewArtifact,executor,watchdogPath,attemptDeadline});}catch(error){if(error instanceof VerificationAttemptError){finding.reproduction=error.attempt.reproduction;finding.repair=error.attempt.repair;finding.reproductionAttemptId=error.attempt.id;event(report,error.attempt.repair==='proposed'?'repair-candidate':'verification-failed',error.attempt.repair==='proposed'?`${error.attempt.id}: external repair assertions passed; helper-authenticated external assertion witness required before certification`:`${error.attempt.id}: ${error.attempt.reproduction}; repair validation failed without issuing a bundle`);saveReport(dir,report);}throw error;} - finding.reproduction=result.manifest.before.security==='intended_failure'&&result.manifest.before.booted&&result.manifest.before.legitimate?'reproduced':result.manifest.before.security==='pass'?'disproved':result.manifest.before.booted?'inconclusive':'blocked'; - finding.repair=result.manifest.result==='tested'?'tested':result.manifest.result==='runtime_tested'?'runtime_tested':'failed';if(['tested','runtime_tested'].includes(result.manifest.result)){finding.verificationId=result.manifest.id;finding.verificationAssurance={assertions:result.manifest.assertionAssurance!,testCompletion:result.manifest.testCompletionAssurance,review:result.manifest.reviewAssurance};delete finding.reproductionAttemptId;}event(report,'verification',`${result.manifest.id}: ${result.manifest.result}; assertion assurance ${result.manifest.assertionAssurance}; test completion assurance ${result.manifest.testCompletionAssurance}; review assurance ${result.manifest.reviewAssurance}`);saveReport(dir,report);return{result:result.manifest.result,verification:result.manifest,bundle:`bundles/${result.bundle.id}.json`};});} -function replayBundle(stored:{path:string;dir:string},id:string):any{const bundle=validateRepairBundle(readJson(stored.path),id),report=loadReport(stored.dir),finding=report.findings.find(f=>f.verificationId===id&&['tested','runtime_tested'].includes(f.repair));if(!['tested','runtime_tested'].includes(bundle.verification.result)||!finding)throw new CsoError('INCOMPATIBLE_INPUT','Only a helper-recorded runtime-tested or host-reviewed repair bundle can be replayed');return bundle;} -async function withReplayBundle(id:string,deadline:number,fn:(stored:{path:string;dir:string},bundle:any)=>Promise):Promise{ - if(!/^[a-f0-9]{32}$/.test(id))throw new CsoError('INVALID_ARGUMENT','Bundle identifier must be the 32-character ID returned by verify'); - let visited=0,stored:{path:string;dir:string}|undefined;const admit=()=>{if(Date.now()>=deadline)throw new CsoError('DEADLINE','Replay exhausted its five-minute budget while locating the recorded bundle');if(++visited>REPLAY_LOOKUP_MAX_ENTRIES)throw new CsoError('INSUFFICIENT_CAPACITY',`Replay bundle lookup exceeded ${REPLAY_LOOKUP_MAX_ENTRIES} private state entries`);}; - const root=privateRoot(),repos=fs.opendirSync(root);try{let repo:fs.Dirent|null;search:while((repo=repos.readSync())!==null){admit();if(!repo.isDirectory()||!/^[a-f0-9]{24}$/.test(repo.name))continue;const repoDir=join(root,repo.name),runs=fs.opendirSync(repoDir);try{let run:fs.Dirent|null;while((run=runs.readSync())!==null){admit();if(!run.isDirectory()||!/^\d{13}-[a-f0-9]{16}$/.test(run.name))continue;const candidate={dir:join(repoDir,run.name),path:join(repoDir,run.name,'bundles',`${id}.json`)};if(fs.existsSync(candidate.path)){stored=candidate;break search;}}}finally{runs.closeSync();}}}finally{repos.closeSync();} - if(!stored)throw new CsoError('MISSING_INPUT','Repair bundle was not found or expired');let matched=false,value!:T;await withLock(stored.dir,async()=>{if(!fs.existsSync(stored!.path))return;const bundle=replayBundle(stored!,id);matched=true;value=await fn(stored!,bundle);});if(matched)return value;throw new CsoError('MISSING_INPUT','Repair bundle was not found or expired'); -} -function replayManifestValue(manifest:any):unknown{const {id:_,createdAt:__,witnessHash:___,before,after,...stable}=manifest,observation=(value:any)=>{const{output:_,...rest}=value;return rest;};return{...stable,before:observation(before),after:observation(after)};} -async function replay(args:string[],dependencies:CsoCliDependencies){const replayStarted=Date.now(),replayDeadline=replayStarted+300_000;retention(replayStarted,{deadlineMs:replayStarted+RETENTION_MAINTENANCE_MS,maxEntries:RETENTION_MAX_ENTRIES});if(!args.length)throw new CsoError('INVALID_ARGUMENT','replay requires a bundle ID');const id=args.shift()!,source=args.includes('--source')?callerPath(need(args,'--source')):undefined;if(args.length)throw new CsoError('INVALID_ARGUMENT',`Unknown replay argument: ${args[0]}`);return await withReplayBundle(id,replayDeadline,async(stored,bundle)=>{ - if(bundle.requiredInputs.archives?.length&&!bundle.requiredInputs.dependencyClosures)throw new CsoError('MISSING_INPUT','Replay bundle predates retained dependency closures and cannot substitute current dependency state'); - let workDir=stored.dir,manifest:any,temporary:string|undefined;const retained=join(stored.dir,'snapshot'); - try{ - const captureSupplied=async()=>{if(!source)throw new CsoError('MISSING_INPUT','Retained source expired; supply explicitly matching source');const temp=newRun(source);temporary=temp.dir;workDir=temp.dir;manifest=await capture(source,workDir,undefined,undefined,{deadlineMs:replayDeadline});if(manifest.executionHash!==bundle.requiredInputs.sourceHash||manifest.originalHash!==bundle.requiredInputs.originalHash)throw new CsoError('INCOMPATIBLE_INPUT','Supplied source does not match the bundle input hashes');}; - if(fs.existsSync(retained)){manifest=readJson(join(stored.dir,'snapshot.json'));const expiresAt=typeof manifest?.expiresAt==='string'?Date.parse(manifest.expiresAt):Number.NaN;if(!Number.isFinite(expiresAt)||new Date(expiresAt).toISOString()!==manifest.expiresAt)throw new CsoError('INCOMPATIBLE_INPUT','Retained snapshot expiry is invalid');if(expiresAt<=Date.now())await captureSupplied();else assertSnapshot(stored.dir,manifest);}else await captureSupplied(); - if(manifest.executionHash!==bundle.requiredInputs.sourceHash||manifest.originalHash!==bundle.requiredInputs.originalHash)throw new CsoError('INCOMPATIBLE_INPUT','Retained source hashes do not match the bundle');validateRepairBundle(bundle,id,join(workDir,'snapshot'),manifest);if(bundle.verification.policyHash!==ISOLATION_POLICY_HASH)throw new CsoError('INCOMPATIBLE_INPUT','Current helper isolation policy does not match the recorded bundle');const targetPlatform=bundle.requiredInputs.platform as 'linux/amd64'|'linux/arm64';let runtime;try{runtime=selectRuntime(bundle.verification.runtime.profile,targetPlatform,dependencies.runtimeCatalog);}catch(error:any){throw new CsoError('PREREQUISITE',error?.message||'Qualified replay runtime is unavailable');}const verifier=runtime;if(runtime.image!==bundle.requiredInputs.runtimeImage)throw new CsoError('INCOMPATIBLE_INPUT','Qualified runtime digest does not match the bundle'); - const endpoint=await dockerEndpoint(secureDirectory(join(workDir,'home'))),watchdogPath=dependencies.watchdogPath(),attemptDeadline=replayDeadline,delegate=new DockerVerificationExecutor(endpoint,watchdogPath,attemptDeadline);let executor:VerificationExecutor=delegate,archives:string[]=[],dependencyClosures:{before:DependencyClosure;after:DependencyClosure}|undefined,proofs:{before:PreparationProof;after:PreparationProof}|undefined; - if(bundle.requiredInputs.dependencyClosures){if(!['node','bun','python','rails'].includes(runtime.stack))throw new CsoError('INCOMPATIBLE_INPUT','Replay dependency closure requires an application runtime');const replayClosures=bundle.requiredInputs.dependencyClosures as {before:DependencyClosure;after:DependencyClosure},plan=inspectPreparation(join(workDir,'snapshot'),runtime.stack as any);if(plan.status!=='ready')throw new CsoError('PREREQUISITE',plan.prerequisites.map((item:any)=>item.message).join('; ')||'Replay dependency metadata is not ready');const admission=admitPreparationRuntime({plan,platform:targetPlatform,profile:runtime.id,catalog:dependencies.runtimeCatalog}),runner=new DockerPreparationSandboxRunner({endpoint,watchdogPath,runRoot:workDir,controlRoot:secureDirectory(join(workDir,'preparation-execution')),admission}),preparation=new PreparationExecutor({cache:new PublicArchiveCache({root:publicArchiveCacheRoot(),stagingRoot:secureDirectory(join(workDir,'archive-staging'))}),runner,materializationRoot:secureDirectory(join(workDir,'archive-materializations'))}),beforeClosure=await preparation.acquire({plan,admission,snapshot:join(workDir,'snapshot'),deadline:attemptDeadline,offline:true,existingClosure:replayClosures.before});dependencyClosures={before:beforeClosure,after:replayClosures.after};archives=[...new Set([...beforeClosure.archives,...replayClosures.after.archives].map(archive=>archive.sha256))].sort();proofs={} as {before:PreparationProof;after:PreparationProof};executor=preparedVerificationExecutor({dir:workDir,findingId:bundle.request.findingId,runtimeProfile:runtime.id,stack:runtime.stack as any,targetPlatform,deadline:attemptDeadline,offline:true,runtimeCatalog:dependencies.runtimeCatalog,preparation,delegate,beforePlan:plan,beforeAdmission:admission,beforeClosure,closures:dependencyClosures,archiveHashes:archives,proofs,replay:replayClosures,persistClosures:false});} - const result=await verifyRepair({runId:bundle.runId,runDir:workDir,manifest,rawRequest:bundle.request,runtime,verifier,policyHash:ISOLATION_POLICY_HASH,auditPolicyHash:bundle.verification.auditPolicyHash,archives,dependencyClosures,preparation:proofs,reviewArtifact:bundle.reviewArtifact,executor,persist:false,watchdogPath,attemptDeadline});if(canonical(replayManifestValue(result.manifest))!==canonical(replayManifestValue(bundle.verification))||assertionWitnessReplayHash(result.bundle.witness!)!==assertionWitnessReplayHash(bundle.witness))throw new CsoError('INCOMPATIBLE_INPUT','Replay changed verification outcomes, preparation, or provenance inputs');const replayId=`${Date.now()}-${randomBytes(8).toString('hex')}`;writeJsonExclusive(join(stored.dir,'replays',`${replayId}.json`),{replayId,bundleId:id,verification:result.manifest,witness:result.bundle.witness});return{bundle:id,result:result.manifest.result,replay:result.manifest,replayId}; - }finally{if(temporary)finalizeReplayTemporary(temporary);} + const oldManifest = readJson(join(originalDir, 'snapshot.json')); + const preserveBase = original.policy.diff || Boolean(original.source.baseCommit), + report = await start( + [ + '--repo', + repo, + ...(original.policy.mode === 'comprehensive' ? ['--comprehensive'] : []), + ...(original.policy.diff ? ['--diff'] : []), + ...(preserveBase ? ['--base', original.policy.base] : []), + '--budget', + String(original.policy.budgetSeconds), + ...(original.policy.offline ? ['--offline'] : []), + ...(original.policy.scope === 'default' + ? [] + : original.policy.scope.startsWith('domain:') + ? ['--scope', original.policy.scope.slice(7)] + : [`--${original.policy.scope}`]), + ], + dependencies, + { runId, findingId, kind: 'recheck' }, + oldManifest.headCommit, + startedAt, + ); + return { runId: report.runId, parent: report.parent }; }); } -export async function dispatchCsoCommand(command:string,args:string[],dependencies:CsoCliDependencies):Promise{ - args=[...args];if(!['start','recheck','replay','doctor','provision-images'].includes(command)){const started=Date.now();retention(started,{deadlineMs:started+RETENTION_MAINTENANCE_MS,maxEntries:RETENTION_MAX_ENTRIES});} - let result:unknown; - switch(command){case'start':result=await start(args,dependencies);break;case'doctor':result=await doctor(args,dependencies);break;case'provision-images':result=await provisionImages(args,dependencies);break;case'resume':result=resume(args);break;case'inspect':await inspect(args);return;case'read':await read(args);return;case'history':await history(args);return;case'submit':result=submit(args);break;case'finish':result=finish(args);break;case'import-v2':result=importV2(args);break;case'inspect-v2':result=inspectV2(args);break;case'scan':result=await scanner(args);break;case'scanner-outcome':result=scannerOutcome(args);break;case'import-sarif':result=await scanner(args,true);break;case'record-review':result=recordReview(args);break;case'test-plan':result=testPlan(args);break;case'runtime-plan':result=runtimePlan(args);break;case'recheck':result=await recheck(args,dependencies);break; - case'verify':result=await verify(args,dependencies);break;case'replay':result=await replay(args,dependencies);break;case'patch-hash':if(args.length!==1)throw new CsoError('INVALID_ARGUMENT','patch-hash requires one request JSON file');result={patchHash:patchHash(validateVerificationRequest(readInput(args[0])))};break;default:throw new CsoError('INVALID_ARGUMENT',`Unknown command: ${command}`);} +function recordReview(args: string[]) { + const { dir } = run(args); + if (!args.length) + throw new CsoError('INVALID_ARGUMENT', 'record-review requires a request JSON file and --producer ID'); + const raw = readInput(args.shift()!), + producer = need(args, '--producer'); + if (args.length) throw new CsoError('INVALID_ARGUMENT', `Unknown record-review argument: ${args[0]}`); + const request = validateVerificationRequest(raw); + return withLock(dir, () => { + const report = loadReport(dir); + requireTime(report); + if ( + report.policy.mode !== 'comprehensive' || + report.status !== 'running' || + !report.findings.some((f) => f.id === request.findingId && f.evidence === 'supported') + ) + throw new CsoError( + 'MISSING_INPUT', + 'Review artifact must target a supported finding in a running comprehensive audit', + ); + const artifact = persistableArtifact( + makeReviewArtifact(report.runId, request, string(producer, 'producer identity', 200)), + 'Repair review artifact', + ); + writeJsonExclusive(join(dir, 'reviews', `${artifact.id}.json`), artifact); + event( + report, + 'repair-review', + `Self-attested review artifact ${artifact.id} bound the proposed repair; reviewer independence is not host-verifiable`, + ); + saveReport(dir, report); + return { + reviewArtifactId: artifact.id, + reviewAssurance: artifact.assurance, + patchHash: artifact.patchHash, + requestHash: artifact.requestHash, + }; + }); +} +function publicPlanArgument(manifest: SnapshotManifest, arg: string, paths: string[]): string { + for (const path of [...paths].sort((a, b) => b.length - a.length)) { + const reference = publicSnapshotPath(manifest, path).path; + if (reference === path) continue; + if (arg === path) return reference; + if (arg === `./${path}`) return `./${reference}`; + } + return arg; +} +function publicTestPlan(manifest: SnapshotManifest, plan: ReturnType) { + return { + ...plan, + commands: plan.commands.map((command) => ({ + ...command, + args: command.args.map((arg) => publicPlanArgument(manifest, arg, plan.files)), + })), + files: plan.files.map((path) => publicSnapshotPath(manifest, path).path), + }; +} +function publicStartPlan(manifest: SnapshotManifest, plan: ReturnType) { + return { + ...plan, + command: { + ...plan.command, + args: plan.command.args.map((arg) => publicPlanArgument(manifest, arg, plan.entrypointFiles)), + }, + entrypointFiles: plan.entrypointFiles.map((path) => publicSnapshotPath(manifest, path).path), + }; +} +function testPlan(args: string[]) { + const { dir } = run(args); + if (args.length !== 1 || !['node', 'bun', 'python', 'rails'].includes(args[0])) + throw new CsoError('INVALID_ARGUMENT', 'test-plan requires one supported stack'); + const stack = args[0] as 'node' | 'bun' | 'python' | 'rails', + manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + assertSnapshot(dir, manifest); + const preparation = inspectPreparation(join(dir, 'snapshot'), stack); + if (preparation.status !== 'ready') + throw new CsoError( + 'PREREQUISITE', + preparation.prerequisites.map((item) => item.message).join('; ') || + `${stack} preparation metadata is incomplete`, + ); + return { + stack, + runtimeProfile: preparation.runtimeProfile, + ...publicTestPlan(manifest, canonicalTestPlan(join(dir, 'snapshot'), stack)), + }; +} +function runtimePlan(args: string[]) { + const { dir } = run(args); + if (!args.length || !['node', 'bun', 'python', 'rails'].includes(args[0])) + throw new CsoError('INVALID_ARGUMENT', 'runtime-plan requires one supported stack and --port PORT'); + const stack = args.shift() as 'node' | 'bun' | 'python' | 'rails', + rawPort = need(args, '--port'); + if (args.length) throw new CsoError('INVALID_ARGUMENT', `Unknown runtime-plan argument: ${args[0]}`); + const port = Number(rawPort); + if (!Number.isInteger(port) || port < 1024 || port > 65535) + throw new CsoError('INVALID_ARGUMENT', '--port must be an integer from 1024 to 65535'); + const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + assertSnapshot(dir, manifest); + const preparation = inspectPreparation(join(dir, 'snapshot'), stack); + if (preparation.status !== 'ready') + throw new CsoError( + 'PREREQUISITE', + preparation.prerequisites.map((item) => item.message).join('; ') || + `${stack} preparation metadata is incomplete`, + ); + return { + stack, + runtimeProfile: preparation.runtimeProfile, + start: publicStartPlan(manifest, canonicalStartPlan(join(dir, 'snapshot'), stack, port)), + tests: publicTestPlan(manifest, canonicalTestPlan(join(dir, 'snapshot'), stack)), + }; +} + +function platform(): 'linux/amd64' | 'linux/arm64' { + if (!['linux', 'darwin'].includes(process.platform) || !['x64', 'arm64'].includes(process.arch)) + throw new CsoError( + 'PREREQUISITE', + 'Contained target execution requires a Linux or macOS host with amd64/arm64 Linux Docker images', + ); + return process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64'; +} +function watchdog(): string { + const p = join( + dirname(process.execPath), + process.platform === 'win32' ? 'gstack-cso-watchdog.exe' : 'gstack-cso-watchdog', + ); + if (!fs.existsSync(p)) throw new CsoError('ISOLATION_FAILED', 'Trusted detached watchdog is missing'); + return p; +} + +function closureStateFile(dir: string, findingId: string, phase: 'before' | 'after', plan: unknown): string { + return join( + dir, + 'dependency-closures', + `${findingId}-${phase}-${sha256(canonical(plan)).slice(0, 16)}.json`, + ); +} +function retainClosure(path: string, closure: DependencyClosure): void { + if (fs.existsSync(path)) { + if (canonical(readJson(path)) !== canonical(closure)) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Retained dependency closure conflicts with this preparation plan', + ); + return; + } + writeJsonExclusive(path, persistableArtifact(closure, 'Dependency closure')); +} +function bindArchiveHashes( + target: string[], + closures: { before: DependencyClosure; after: DependencyClosure }, +): void { + const hashes = [ + ...new Set([...closures.before.archives, ...closures.after.archives].map((archive) => archive.sha256)), + ].sort(); + target.splice(0, target.length, ...hashes); +} +function preparedVerificationExecutor(options: { + dir: string; + findingId: string; + runtimeProfile: string; + stack: 'node' | 'bun' | 'python' | 'rails'; + targetPlatform: 'linux/amd64' | 'linux/arm64'; + deadline: number; + offline: boolean; + runtimeCatalog: RuntimeCatalog; + preparation: PreparationExecutor; + delegate: VerificationExecutor; + beforePlan: ReturnType; + beforeAdmission: ReturnType; + beforeClosure: DependencyClosure; + closures: { before: DependencyClosure; after: DependencyClosure }; + archiveHashes: string[]; + proofs: { before: PreparationProof; after: PreparationProof }; + replay?: { before: DependencyClosure; after: DependencyClosure }; + persistClosures: boolean; +}): VerificationExecutor { + let beforeProjectToolchainHash: string | undefined; + return { + observe: async ( + source, + phase, + request, + runtime, + verifier, + work, + control, + _execution, + testEvidence, + witness, + ) => { + const plan = phase === 'before' ? options.beforePlan : inspectPreparation(source, options.stack); + if (plan.status !== 'ready') + throw new CsoError( + 'PREREQUISITE', + plan.prerequisites.map((item) => item.message).join('; ') || + `${options.stack} dependency metadata is not ready`, + ); + const admission = + phase === 'before' + ? options.beforeAdmission + : admitPreparationRuntime({ + plan, + platform: options.targetPlatform, + profile: options.runtimeProfile, + catalog: options.runtimeCatalog, + }); + if (admission.runtime.id !== runtime.id || admission.runtime.image !== runtime.image) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Prepared verification runtime changed between source phases', + ); + const state = closureStateFile(options.dir, options.findingId, phase, plan), + supplied = options.replay?.[phase], + retained = !supplied && fs.existsSync(state) ? (readJson(state) as DependencyClosure) : undefined; + const closure = + phase === 'before' + ? (supplied ?? options.beforeClosure) + : await options.preparation.acquire({ + plan, + admission, + snapshot: source, + deadline: options.deadline, + offline: options.offline || Boolean(supplied), + existingClosure: supplied ?? retained, + }); + options.closures[phase] = closure; + bindArchiveHashes(options.archiveHashes, options.closures); + if (options.persistClosures) retainClosure(state, closure); + let database: RailsDatabaseSelection | undefined; + if (options.stack === 'rails') { + const selected = plan.database?.selected; + if (!selected) + throw new CsoError( + 'PREREQUISITE', + 'Rails automatic verification could not select one locked database adapter from static test configuration', + ); + database = + selected === 'postgresql' + ? { + adapter: 'postgresql', + sidecar: admitPreparationSidecar({ + platform: options.targetPlatform, + catalog: options.runtimeCatalog, + }), + } + : { adapter: 'sqlite' }; + } + const prepared = await options.preparation.prepareOffline({ + plan, + admission, + snapshot: source, + closure, + deadline: options.deadline, + database, + }); + try { + options.proofs[phase] = { + schemaVersion: 1, + dependencyClosureHash: prepared.dependencyClosureHash, + configurationHash: prepared.configurationHash, + sourceProjectionHash: prepared.sourceProjectionHash, + preparedManifestHash: prepared.preparedManifestHash, + preparedDependencyHash: prepared.preparedDependencyHash, + receiptHash: prepared.receiptHash, + executionEnvironmentHash: sha256(canonical(prepared.executionEnvironment)), + databaseHash: prepared.databaseHash, + transformations: prepared.transformations, + }; + const sourceTests = canonicalTestPlan(source, options.stack), + preparedTests = canonicalTestPlan(prepared.preparedRoot, options.stack), + sourceStart = canonicalStartPlan(source, options.stack, request.port), + preparedStart = canonicalStartPlan(prepared.preparedRoot, options.stack, request.port); + if ( + sourceTests.signature !== preparedTests.signature || + sourceStart.signature !== preparedStart.signature || + verificationHarnessHash(request, source) !== verificationHarnessHash(request, prepared.preparedRoot) + ) + throw new CsoError( + 'ISOLATION_FAILED', + 'Offline lifecycle execution changed the canonical start, test, or harness inputs', + ); + if (sourceTests.toolchain === 'project') { + if (phase === 'before') beforeProjectToolchainHash = prepared.preparedDependencyHash; + else if ( + !beforeProjectToolchainHash || + prepared.preparedDependencyHash !== beforeProjectToolchainHash + ) + throw new CsoError( + 'ASSERTION_FAILED', + 'Offline preparation changed the project-installed test toolchain between source phases', + ); + } + const protectedPaths = new Set([ + ...request.boundaryFiles, + ...request.testFiles, + ...sourceStart.entrypointFiles, + ...request.changes.map((item) => item.path), + ]); + if (prepared.transformations.some((item) => protectedPaths.has(item.path))) + throw new CsoError( + 'ISOLATION_FAILED', + 'Synthetic preparation transformation overlaps a security boundary, startup input, or test input', + ); + return await options.delegate.observe( + prepared.preparedRoot, + phase, + request, + runtime, + verifier, + work, + control, + { environment: prepared.executionEnvironment, database: prepared.database }, + testEvidence, + witness, + ); + } finally { + await options.preparation.dispose(prepared); + } + }, + }; +} +async function verify(args: string[], dependencies: CsoCliDependencies) { + const { dir } = run(args); + if (args.length !== 1) throw new CsoError('INVALID_ARGUMENT', 'verify requires one request JSON file'); + const raw = readInput(args[0]), + request = validateVerificationRequest(raw); + return await withLock(dir, async () => { + const report = loadReport(dir), + manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest; + assertSnapshot(dir, manifest); + requireTime(report); + if (report.policy.mode !== 'comprehensive' || report.status !== 'running') + throw new CsoError('INVALID_SCHEMA', 'Only a running comprehensive audit can request target execution'); + const finding = report.findings.find((f) => f.id === request.findingId && f.evidence === 'supported'); + if (!finding) + throw new CsoError('MISSING_INPUT', 'Verification must target a supported finding in this run'); + if (!request.review.artifactId) + throw new CsoError( + 'MISSING_INPUT', + 'Verification requires a separately persisted independent repair-review artifact', + ); + const reviewArtifact = validateReviewArtifact( + readJson(join(dir, 'reviews', `${request.review.artifactId}.json`)), + report.runId, + request, + ); + const findingPath = resolveSnapshotPath(manifest, finding.location.path, true, 'Finding path').path; + if ( + !request.boundaryFiles.some( + (path) => resolveSnapshotPath(manifest, path, true, 'Boundary path').path === findingPath, + ) + ) + throw new CsoError('INVALID_SCHEMA', 'Boundary files must include the finding location'); + const attempts = report.events.filter((e) => e.kind === `verification-attempt:${finding.id}`).length; + if (attempts >= 3) + throw new CsoError( + 'DEADLINE', + 'Three bounded harness/repair attempts have already been used for this finding', + ); + if (report.findings.filter((f) => ['runtime_tested', 'tested'].includes(f.repair)).length >= 3) + throw new CsoError('INSUFFICIENT_CAPACITY', 'This run already produced three runtime-tested repairs'); + const targetPlatform = platform(); + let runtime; + try { + runtime = selectRuntime(request.runtimeProfile, targetPlatform, dependencies.runtimeCatalog); + } catch (error: any) { + throw new CsoError('PREREQUISITE', error?.message || 'Qualified runtime is unavailable'); + } + const verifier = runtime; + if (!['node', 'bun', 'python', 'rails'].includes(runtime.stack)) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Application verification requires an application runtime profile', + ); + const plan = inspectPreparation(join(dir, 'snapshot'), runtime.stack as any); + if (plan.status !== 'ready') + throw new CsoError( + 'PREREQUISITE', + plan.prerequisites.map((p) => p.message).join('; ') || 'Runtime preparation metadata is incomplete', + ); + assertRuntimeCompatible(plan, runtime); + writeJson(join(dir, `preparation-${runtime.stack}.json`), plan); + const endpoint = await dockerEndpoint(secureDirectory(join(dir, 'home'))), + watchdogPath = dependencies.watchdogPath(), + attemptDeadline = Math.min(Date.now() + 300_000, Date.parse(report.deadline) - 60_000); + const admission = admitPreparationRuntime({ + plan, + platform: targetPlatform, + profile: runtime.id, + catalog: dependencies.runtimeCatalog, + }), + staging = secureDirectory(join(dir, 'archive-staging')), + runner = new DockerPreparationSandboxRunner({ + endpoint, + watchdogPath, + runRoot: dir, + controlRoot: secureDirectory(join(dir, 'preparation-execution')), + admission, + }), + preparation = new PreparationExecutor({ + cache: new PublicArchiveCache({ root: publicArchiveCacheRoot(), stagingRoot: staging }), + runner, + materializationRoot: secureDirectory(join(dir, 'archive-materializations')), + }); + const beforeState = closureStateFile(dir, finding.id, 'before', plan), + retainedBefore = fs.existsSync(beforeState) ? (readJson(beforeState) as DependencyClosure) : undefined; + const beforeClosure = await preparation.acquire({ + plan, + admission, + snapshot: join(dir, 'snapshot'), + deadline: attemptDeadline, + offline: report.policy.offline, + existingClosure: retainedBefore, + }); + retainClosure(beforeState, beforeClosure); + const closures = { before: beforeClosure, after: beforeClosure }, + archiveHashes = [...new Set(beforeClosure.archives.map((archive) => archive.sha256))].sort(), + proofs = {} as { before: PreparationProof; after: PreparationProof }, + delegate = new DockerVerificationExecutor(endpoint, watchdogPath, attemptDeadline, () => { + event( + report, + `verification-attempt:${finding.id}`, + `Started bounded repair verification attempt ${attempts + 1}`, + ); + saveReport(dir, report); + }); + const executor = preparedVerificationExecutor({ + dir, + findingId: finding.id, + runtimeProfile: runtime.id, + stack: runtime.stack as 'node' | 'bun' | 'python' | 'rails', + targetPlatform, + deadline: attemptDeadline, + offline: report.policy.offline, + runtimeCatalog: dependencies.runtimeCatalog, + preparation, + delegate, + beforePlan: plan, + beforeAdmission: admission, + beforeClosure, + closures, + archiveHashes, + proofs, + persistClosures: true, + }); + let result: Awaited>; + try { + result = await verifyRepair({ + runId: report.runId, + runDir: dir, + manifest, + rawRequest: raw, + runtime, + verifier, + policyHash: ISOLATION_POLICY_HASH, + auditPolicyHash: sha256(canonical(report.policy)), + archives: archiveHashes, + dependencyClosures: closures, + preparation: proofs, + reviewArtifact, + executor, + watchdogPath, + attemptDeadline, + }); + } catch (error) { + if (error instanceof VerificationAttemptError) { + finding.reproduction = error.attempt.reproduction; + finding.repair = error.attempt.repair; + finding.reproductionAttemptId = error.attempt.id; + event( + report, + error.attempt.repair === 'proposed' ? 'repair-candidate' : 'verification-failed', + error.attempt.repair === 'proposed' + ? `${error.attempt.id}: external repair assertions passed; helper-authenticated external assertion witness required before certification` + : `${error.attempt.id}: ${error.attempt.reproduction}; repair validation failed without issuing a bundle`, + ); + saveReport(dir, report); + } + throw error; + } + finding.reproduction = + result.manifest.before.security === 'intended_failure' && + result.manifest.before.booted && + result.manifest.before.legitimate + ? 'reproduced' + : result.manifest.before.security === 'pass' + ? 'disproved' + : result.manifest.before.booted + ? 'inconclusive' + : 'blocked'; + finding.repair = + result.manifest.result === 'tested' + ? 'tested' + : result.manifest.result === 'runtime_tested' + ? 'runtime_tested' + : 'failed'; + if (['tested', 'runtime_tested'].includes(result.manifest.result)) { + finding.verificationId = result.manifest.id; + finding.verificationAssurance = { + assertions: result.manifest.assertionAssurance!, + testCompletion: result.manifest.testCompletionAssurance, + review: result.manifest.reviewAssurance, + }; + delete finding.reproductionAttemptId; + } + event( + report, + 'verification', + `${result.manifest.id}: ${result.manifest.result}; assertion assurance ${result.manifest.assertionAssurance}; test completion assurance ${result.manifest.testCompletionAssurance}; review assurance ${result.manifest.reviewAssurance}`, + ); + saveReport(dir, report); + return { + result: result.manifest.result, + verification: result.manifest, + bundle: `bundles/${result.bundle.id}.json`, + }; + }); +} +function replayBundle(stored: { path: string; dir: string }, id: string): any { + const bundle = validateRepairBundle(readJson(stored.path), id), + report = loadReport(stored.dir), + finding = report.findings.find( + (f) => f.verificationId === id && ['tested', 'runtime_tested'].includes(f.repair), + ); + if (!['tested', 'runtime_tested'].includes(bundle.verification.result) || !finding) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Only a helper-recorded runtime-tested or host-reviewed repair bundle can be replayed', + ); + return bundle; +} +async function withReplayBundle( + id: string, + deadline: number, + fn: (stored: { path: string; dir: string }, bundle: any) => Promise, +): Promise { + if (!/^[a-f0-9]{32}$/.test(id)) + throw new CsoError( + 'INVALID_ARGUMENT', + 'Bundle identifier must be the 32-character ID returned by verify', + ); + let visited = 0, + stored: { path: string; dir: string } | undefined; + const admit = () => { + if (Date.now() >= deadline) + throw new CsoError( + 'DEADLINE', + 'Replay exhausted its five-minute budget while locating the recorded bundle', + ); + if (++visited > REPLAY_LOOKUP_MAX_ENTRIES) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `Replay bundle lookup exceeded ${REPLAY_LOOKUP_MAX_ENTRIES} private state entries`, + ); + }; + const root = privateRoot(), + repos = fs.opendirSync(root); + try { + let repo: fs.Dirent | null; + search: while ((repo = repos.readSync()) !== null) { + admit(); + if (!repo.isDirectory() || !/^[a-f0-9]{24}$/.test(repo.name)) continue; + const repoDir = join(root, repo.name), + runs = fs.opendirSync(repoDir); + try { + let run: fs.Dirent | null; + while ((run = runs.readSync()) !== null) { + admit(); + if (!run.isDirectory() || !/^\d{13}-[a-f0-9]{16}$/.test(run.name)) continue; + const candidate = { + dir: join(repoDir, run.name), + path: join(repoDir, run.name, 'bundles', `${id}.json`), + }; + if (fs.existsSync(candidate.path)) { + stored = candidate; + break search; + } + } + } finally { + runs.closeSync(); + } + } + } finally { + repos.closeSync(); + } + if (!stored) throw new CsoError('MISSING_INPUT', 'Repair bundle was not found or expired'); + let matched = false, + value!: T; + await withLock(stored.dir, async () => { + if (!fs.existsSync(stored!.path)) return; + const bundle = replayBundle(stored!, id); + matched = true; + value = await fn(stored!, bundle); + }); + if (matched) return value; + throw new CsoError('MISSING_INPUT', 'Repair bundle was not found or expired'); +} +function replayManifestValue(manifest: any): unknown { + const { id: _, createdAt: __, witnessHash: ___, before, after, ...stable } = manifest, + observation = (value: any) => { + const { output: _, ...rest } = value; + return rest; + }; + return { ...stable, before: observation(before), after: observation(after) }; +} +async function replay(args: string[], dependencies: CsoCliDependencies) { + const replayStarted = Date.now(), + replayDeadline = replayStarted + 300_000; + retention(replayStarted, { + deadlineMs: replayStarted + RETENTION_MAINTENANCE_MS, + maxEntries: RETENTION_MAX_ENTRIES, + }); + if (!args.length) throw new CsoError('INVALID_ARGUMENT', 'replay requires a bundle ID'); + const id = args.shift()!, + source = args.includes('--source') ? callerPath(need(args, '--source')) : undefined; + if (args.length) throw new CsoError('INVALID_ARGUMENT', `Unknown replay argument: ${args[0]}`); + return await withReplayBundle(id, replayDeadline, async (stored, bundle) => { + if (bundle.requiredInputs.archives?.length && !bundle.requiredInputs.dependencyClosures) + throw new CsoError( + 'MISSING_INPUT', + 'Replay bundle predates retained dependency closures and cannot substitute current dependency state', + ); + let workDir = stored.dir, + manifest: any, + temporary: string | undefined; + const retained = join(stored.dir, 'snapshot'); + try { + const captureSupplied = async () => { + if (!source) + throw new CsoError('MISSING_INPUT', 'Retained source expired; supply explicitly matching source'); + const temp = newRun(source); + temporary = temp.dir; + workDir = temp.dir; + manifest = await capture(source, workDir, undefined, undefined, { deadlineMs: replayDeadline }); + if ( + manifest.executionHash !== bundle.requiredInputs.sourceHash || + manifest.originalHash !== bundle.requiredInputs.originalHash + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Supplied source does not match the bundle input hashes'); + }; + if (fs.existsSync(retained)) { + manifest = readJson(join(stored.dir, 'snapshot.json')); + const expiresAt = + typeof manifest?.expiresAt === 'string' ? Date.parse(manifest.expiresAt) : Number.NaN; + if (!Number.isFinite(expiresAt) || new Date(expiresAt).toISOString() !== manifest.expiresAt) + throw new CsoError('INCOMPATIBLE_INPUT', 'Retained snapshot expiry is invalid'); + if (expiresAt <= Date.now()) await captureSupplied(); + else assertSnapshot(stored.dir, manifest); + } else await captureSupplied(); + if ( + manifest.executionHash !== bundle.requiredInputs.sourceHash || + manifest.originalHash !== bundle.requiredInputs.originalHash + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Retained source hashes do not match the bundle'); + validateRepairBundle(bundle, id, join(workDir, 'snapshot'), manifest); + if (bundle.verification.policyHash !== ISOLATION_POLICY_HASH) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Current helper isolation policy does not match the recorded bundle', + ); + const targetPlatform = bundle.requiredInputs.platform as 'linux/amd64' | 'linux/arm64'; + let runtime; + try { + runtime = selectRuntime( + bundle.verification.runtime.profile, + targetPlatform, + dependencies.runtimeCatalog, + ); + } catch (error: any) { + throw new CsoError('PREREQUISITE', error?.message || 'Qualified replay runtime is unavailable'); + } + const verifier = runtime; + if (runtime.image !== bundle.requiredInputs.runtimeImage) + throw new CsoError('INCOMPATIBLE_INPUT', 'Qualified runtime digest does not match the bundle'); + const endpoint = await dockerEndpoint(secureDirectory(join(workDir, 'home'))), + watchdogPath = dependencies.watchdogPath(), + attemptDeadline = replayDeadline, + delegate = new DockerVerificationExecutor(endpoint, watchdogPath, attemptDeadline); + let executor: VerificationExecutor = delegate, + archives: string[] = [], + dependencyClosures: { before: DependencyClosure; after: DependencyClosure } | undefined, + proofs: { before: PreparationProof; after: PreparationProof } | undefined; + if (bundle.requiredInputs.dependencyClosures) { + if (!['node', 'bun', 'python', 'rails'].includes(runtime.stack)) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Replay dependency closure requires an application runtime', + ); + const replayClosures = bundle.requiredInputs.dependencyClosures as { + before: DependencyClosure; + after: DependencyClosure; + }, + plan = inspectPreparation(join(workDir, 'snapshot'), runtime.stack as any); + if (plan.status !== 'ready') + throw new CsoError( + 'PREREQUISITE', + plan.prerequisites.map((item: any) => item.message).join('; ') || + 'Replay dependency metadata is not ready', + ); + const admission = admitPreparationRuntime({ + plan, + platform: targetPlatform, + profile: runtime.id, + catalog: dependencies.runtimeCatalog, + }), + runner = new DockerPreparationSandboxRunner({ + endpoint, + watchdogPath, + runRoot: workDir, + controlRoot: secureDirectory(join(workDir, 'preparation-execution')), + admission, + }), + preparation = new PreparationExecutor({ + cache: new PublicArchiveCache({ + root: publicArchiveCacheRoot(), + stagingRoot: secureDirectory(join(workDir, 'archive-staging')), + }), + runner, + materializationRoot: secureDirectory(join(workDir, 'archive-materializations')), + }), + beforeClosure = await preparation.acquire({ + plan, + admission, + snapshot: join(workDir, 'snapshot'), + deadline: attemptDeadline, + offline: true, + existingClosure: replayClosures.before, + }); + dependencyClosures = { before: beforeClosure, after: replayClosures.after }; + archives = [ + ...new Set( + [...beforeClosure.archives, ...replayClosures.after.archives].map((archive) => archive.sha256), + ), + ].sort(); + proofs = {} as { before: PreparationProof; after: PreparationProof }; + executor = preparedVerificationExecutor({ + dir: workDir, + findingId: bundle.request.findingId, + runtimeProfile: runtime.id, + stack: runtime.stack as any, + targetPlatform, + deadline: attemptDeadline, + offline: true, + runtimeCatalog: dependencies.runtimeCatalog, + preparation, + delegate, + beforePlan: plan, + beforeAdmission: admission, + beforeClosure, + closures: dependencyClosures, + archiveHashes: archives, + proofs, + replay: replayClosures, + persistClosures: false, + }); + } + const result = await verifyRepair({ + runId: bundle.runId, + runDir: workDir, + manifest, + rawRequest: bundle.request, + runtime, + verifier, + policyHash: ISOLATION_POLICY_HASH, + auditPolicyHash: bundle.verification.auditPolicyHash, + archives, + dependencyClosures, + preparation: proofs, + reviewArtifact: bundle.reviewArtifact, + executor, + persist: false, + watchdogPath, + attemptDeadline, + }); + if ( + canonical(replayManifestValue(result.manifest)) !== + canonical(replayManifestValue(bundle.verification)) || + assertionWitnessReplayHash(result.bundle.witness!) !== assertionWitnessReplayHash(bundle.witness) + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Replay changed verification outcomes, preparation, or provenance inputs', + ); + const replayId = `${Date.now()}-${randomBytes(8).toString('hex')}`; + writeJsonExclusive(join(stored.dir, 'replays', `${replayId}.json`), { + replayId, + bundleId: id, + verification: result.manifest, + witness: result.bundle.witness, + }); + return { bundle: id, result: result.manifest.result, replay: result.manifest, replayId }; + } finally { + if (temporary) finalizeReplayTemporary(temporary); + } + }); +} + +export async function dispatchCsoCommand( + command: string, + args: string[], + dependencies: CsoCliDependencies, +): Promise { + args = [...args]; + if (!['start', 'recheck', 'replay', 'doctor', 'provision-images'].includes(command)) { + const started = Date.now(); + retention(started, { deadlineMs: started + RETENTION_MAINTENANCE_MS, maxEntries: RETENTION_MAX_ENTRIES }); + } + let result: unknown; + switch (command) { + case 'start': + result = await start(args, dependencies); + break; + case 'doctor': + result = await doctor(args, dependencies); + break; + case 'provision-images': + result = await provisionImages(args, dependencies); + break; + case 'resume': + result = resume(args); + break; + case 'inspect': + await inspect(args); + return; + case 'read': + await read(args); + return; + case 'history': + await history(args); + return; + case 'submit': + result = submit(args); + break; + case 'finish': + result = finish(args); + break; + case 'import-v2': + result = importV2(args); + break; + case 'inspect-v2': + result = inspectV2(args); + break; + case 'scan': + result = await scanner(args); + break; + case 'scanner-outcome': + result = scannerOutcome(args); + break; + case 'import-sarif': + result = await scanner(args, true); + break; + case 'record-review': + result = recordReview(args); + break; + case 'test-plan': + result = testPlan(args); + break; + case 'runtime-plan': + result = runtimePlan(args); + break; + case 'recheck': + result = await recheck(args, dependencies); + break; + case 'verify': + result = await verify(args, dependencies); + break; + case 'replay': + result = await replay(args, dependencies); + break; + case 'patch-hash': + if (args.length !== 1) + throw new CsoError('INVALID_ARGUMENT', 'patch-hash requires one request JSON file'); + result = { patchHash: patchHash(validateVerificationRequest(readInput(args[0]))) }; + break; + default: + throw new CsoError('INVALID_ARGUMENT', `Unknown command: ${command}`); + } return result; } -const PRODUCTION_CLI_DEPENDENCIES:CsoCliDependencies=Object.freeze({runtimeCatalog:RUNTIME_CATALOG,scannerCatalog:SCANNER_CATALOG,catalogImageSession:productionCatalogImageSession,watchdogPath:watchdog}); -async function main(){const args=process.argv.slice(2),command=args.shift();if(!command||command==='--help'||command==='help'){process.stdout.write(HELP+'\n');return;}if(command==='--version'){emit({version:VERSION,abi:ABI});return;}if(command==='schema'){emit(SCHEMA);return;}if(command==='__cso-assertion-witness'){if(args.length)throw new CsoError('INVALID_ARGUMENT','Assertion witness does not accept command arguments');await runAssertionWitnessChild();return;}const result=await dispatchCsoCommand(command,args,PRODUCTION_CLI_DEPENDENCIES);if(result!==undefined)emit(result);} -if(import.meta.main)main().catch(error=>{const e=error instanceof CsoError?error:new CsoError('INVALID_SCHEMA','The helper rejected an unexpected or unsafe input');try{process.stderr.write(redact(JSON.stringify({ok:false,error:{code:e.code,message:e.message}}))+'\n');}catch{process.stderr.write('{"ok":false,"error":{"code":"REDACTION_FAILED","message":"Error payload withheld"}}\n');}process.exitCode=1;}); +const PRODUCTION_CLI_DEPENDENCIES: CsoCliDependencies = Object.freeze({ + runtimeCatalog: RUNTIME_CATALOG, + scannerCatalog: SCANNER_CATALOG, + catalogImageSession: productionCatalogImageSession, + watchdogPath: watchdog, +}); +async function main() { + const args = process.argv.slice(2), + command = args.shift(); + if (!command || command === '--help' || command === 'help') { + process.stdout.write(HELP + '\n'); + return; + } + if (command === '--version') { + emit({ version: VERSION, abi: ABI }); + return; + } + if (command === 'schema') { + emit(SCHEMA); + return; + } + if (command === '__cso-assertion-witness') { + if (args.length) + throw new CsoError('INVALID_ARGUMENT', 'Assertion witness does not accept command arguments'); + await runAssertionWitnessChild(); + return; + } + const result = await dispatchCsoCommand(command, args, PRODUCTION_CLI_DEPENDENCIES); + if (result !== undefined) emit(result); +} +if (import.meta.main) + main().catch((error) => { + const e = + error instanceof CsoError + ? error + : new CsoError('INVALID_SCHEMA', 'The helper rejected an unexpected or unsafe input'); + try { + process.stderr.write( + redact(JSON.stringify({ ok: false, error: { code: e.code, message: e.message } })) + '\n', + ); + } catch { + process.stderr.write( + '{"ok":false,"error":{"code":"REDACTION_FAILED","message":"Error payload withheld"}}\n', + ); + } + process.exitCode = 1; + }); diff --git a/lib/cso/contracts.ts b/lib/cso/contracts.ts index d383f6c7c..ff3ed32fe 100644 --- a/lib/cso/contracts.ts +++ b/lib/cso/contracts.ts @@ -3,80 +3,225 @@ import { createHash } from 'node:crypto'; export const ABI = 3; export const MAX_OUTPUT = 1024 * 1024; -const UNSAFE_STRING_CONTROLS=/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u; -const UNSAFE_PROPERTY_CONTROLS=/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u; +const UNSAFE_STRING_CONTROLS = + /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u; +const UNSAFE_PROPERTY_CONTROLS = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u; export type Completeness = 'complete' | 'partial' | 'not assessed'; export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'informational'; -export type ErrorCode = 'INVALID_ARGUMENT' | 'INVALID_SCHEMA' | 'MISSING_INPUT' | 'SNAPSHOT_RACE' | - 'UNSAFE_PATH' | 'REDACTION_FAILED' | 'PERSISTENCE_FAILED' | 'TOOL_UNAVAILABLE' | 'TOOL_FAILED' | - 'ISOLATION_FAILED' | 'INSUFFICIENT_CAPACITY' | 'DEADLINE' | 'CANCELLED' | 'PREREQUISITE' | - 'INCOMPATIBLE_INPUT' | 'ASSERTION_FAILED'; +export type ErrorCode = + | 'INVALID_ARGUMENT' + | 'INVALID_SCHEMA' + | 'MISSING_INPUT' + | 'SNAPSHOT_RACE' + | 'UNSAFE_PATH' + | 'REDACTION_FAILED' + | 'PERSISTENCE_FAILED' + | 'TOOL_UNAVAILABLE' + | 'TOOL_FAILED' + | 'ISOLATION_FAILED' + | 'INSUFFICIENT_CAPACITY' + | 'DEADLINE' + | 'CANCELLED' + | 'PREREQUISITE' + | 'INCOMPATIBLE_INPUT' + | 'ASSERTION_FAILED'; export class CsoError extends Error { - constructor(public code: ErrorCode, message: string) { super(message); this.name = 'CsoError'; } + constructor( + public code: ErrorCode, + message: string, + ) { + super(message); + this.name = 'CsoError'; + } } export const sha256 = (value: string | Buffer): string => createHash('sha256').update(value).digest('hex'); export const canonical = (value: unknown): string => { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; - if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical((value as any)[k])}`).join(',')}}`; + if (value && typeof value === 'object') + return `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical((value as any)[k])}`) + .join(',')}}`; return JSON.stringify(value); }; export interface CoverageRecord { - domain: string; scope: string; status: 'assessed' | 'partial' | 'not_assessed' | 'not_applicable'; - method: string; gaps: string[]; exclusions: string[]; evidence: string[]; + domain: string; + scope: string; + status: 'assessed' | 'partial' | 'not_assessed' | 'not_applicable'; + method: string; + gaps: string[]; + exclusions: string[]; + evidence: string[]; tool?: { name: string; version: string; freshness: string; outcome: string }; } export interface ApplicationModel { - actors: string[]; assets: string[]; entrypoints: string[]; tenantBoundaries: string[]; - sensitiveOperations: string[]; invariants: string[]; + actors: string[]; + assets: string[]; + entrypoints: string[]; + tenantBoundaries: string[]; + sensitiveOperations: string[]; + invariants: string[]; } export interface FindingV3 { - id: string; fingerprint: string; title: string; rootCause: string; - location: { path: string; line: number; symbol: string }; advisoryIds: string[]; - severity: Severity; confidence: 'high' | 'medium' | 'low'; confidenceRationale:string; evidence: 'supported' | 'hypothesis' | 'legacy_review'; - attackerControl: string; impact: string; scenario: string; trace: string[]; references: string[]; recommendation: string; - challenge: { reviewer: string; independent: boolean; mode:'independent_agent'|'sequential_fallback'; callers: string; controls: string; counterevidence: string; conclusion: string }; - dependency?: { affectedVersion: string; reachability: 'reachable' | 'unreachable' | 'unknown'; exposure: string; exploitation: string }; + id: string; + fingerprint: string; + title: string; + rootCause: string; + location: { path: string; line: number; symbol: string }; + advisoryIds: string[]; + severity: Severity; + confidence: 'high' | 'medium' | 'low'; + confidenceRationale: string; + evidence: 'supported' | 'hypothesis' | 'legacy_review'; + attackerControl: string; + impact: string; + scenario: string; + trace: string[]; + references: string[]; + recommendation: string; + challenge: { + reviewer: string; + independent: boolean; + mode: 'independent_agent' | 'sequential_fallback'; + callers: string; + controls: string; + counterevidence: string; + conclusion: string; + }; + dependency?: { + affectedVersion: string; + reachability: 'reachable' | 'unreachable' | 'unknown'; + exposure: string; + exploitation: string; + }; reproduction: 'not_attempted' | 'blocked' | 'inconclusive' | 'disproved' | 'reproduced'; repair: 'not_attempted' | 'proposed' | 'failed' | 'runtime_tested' | 'tested'; - closure: 'open' | 'resolved' | 'unknown'; verificationId?: string; reproductionAttemptId?:string; - verificationAssurance?: { assertions:'authenticated_out_of_process'; testCompletion:'self_reported'|'authenticated_out_of_process'; review:'self_attested'|'host_verified' }; + closure: 'open' | 'resolved' | 'unknown'; + verificationId?: string; + reproductionAttemptId?: string; + verificationAssurance?: { + assertions: 'authenticated_out_of_process'; + testCompletion: 'self_reported' | 'authenticated_out_of_process'; + review: 'self_attested' | 'host_verified'; + }; } export interface RunPolicy { - mode: 'daily' | 'comprehensive'; scope: string; diff: boolean; base: string; - offline: boolean; budgetSeconds: number; maxWorkers: 3; maxRepairs: 3; + mode: 'daily' | 'comprehensive'; + scope: string; + diff: boolean; + base: string; + offline: boolean; + budgetSeconds: number; + maxWorkers: 3; + maxRepairs: 3; } export interface RunReportV3 { - schemaVersion: 3; runId: string; repoId: string; createdAt: string; deadline: string; - status: 'running' | 'finished' | 'interrupted'; completeness: Completeness; policy: RunPolicy; - source: { root: string; snapshotHash: string; originalHash: string; baseCommit?: string; - transformations?: Array<{ path: string; handling: string }> }; - application: ApplicationModel; coverage: CoverageRecord[]; findings: FindingV3[]; - gaps: string[]; events: { at: string; kind: string; message: string }[]; - parent?: { runId: string; findingId: string; kind: 'recheck' }; modelUsage?: { source: string; tokens: number; cost?: number }; + schemaVersion: 3; + runId: string; + repoId: string; + createdAt: string; + deadline: string; + status: 'running' | 'finished' | 'interrupted'; + completeness: Completeness; + policy: RunPolicy; + source: { + root: string; + snapshotHash: string; + originalHash: string; + baseCommit?: string; + transformations?: Array<{ path: string; handling: string }>; + }; + application: ApplicationModel; + coverage: CoverageRecord[]; + findings: FindingV3[]; + gaps: string[]; + events: { at: string; kind: string; message: string }[]; + parent?: { runId: string; findingId: string; kind: 'recheck' }; + modelUsage?: { source: string; tokens: number; cost?: number }; +} +export interface SnapshotEntry { + path: string; + pathId: string; + originalHash: string; + executionHash?: string; + bytes: number; + mode: number; + transformation?: string; +} +export interface SnapshotPathIdentity { + path: string; + pathId: string; } -export interface SnapshotEntry { path: string; pathId: string; originalHash: string; executionHash?: string; bytes: number; mode: number; transformation?: string } -export interface SnapshotPathIdentity { path: string; pathId: string } export interface SnapshotManifest { - version: 3; createdAt: string; expiresAt: string; root: string; originalHash: string; executionHash: string; - entries: SnapshotEntry[]; deletedPaths?: SnapshotPathIdentity[]; headCommit?: string; baseCommit?: string; changedPaths?: string[]; + version: 3; + createdAt: string; + expiresAt: string; + root: string; + originalHash: string; + executionHash: string; + entries: SnapshotEntry[]; + deletedPaths?: SnapshotPathIdentity[]; + headCommit?: string; + baseCommit?: string; + changedPaths?: string[]; } export interface HttpAssertion { - name: string; path: string; method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - headers?: Record; body?: string; + name: string; + path: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record; + body?: string; expected: { status: number; includes?: string; excludes?: string }; vulnerable?: { status: number; includes?: string; excludes?: string }; } -export interface Command { executable: string; args: string[] } +export interface Command { + executable: string; + args: string[]; +} export interface VerificationRequest { - findingId: string; runtimeProfile: string; port: number; start: Command; - legitimate: HttpAssertion[]; security: HttpAssertion; existingTests: Command[]; - fixtures: Record; - boundaryFiles: string[]; testFiles:string[]; - changes: { path: string; beforeSha256: string | null; after: string | null; effect: 'source' | 'configuration' | 'dependency' }[]; - review: { reviewer: string; independent: boolean; rootCauseRepaired: boolean; featurePreserved: boolean; - boundaryMocks: boolean; rationale: string; reviewedPatchHash: string; artifactId?:string }; + findingId: string; + runtimeProfile: string; + port: number; + start: Command; + legitimate: HttpAssertion[]; + security: HttpAssertion; + existingTests: Command[]; + fixtures: Record; + boundaryFiles: string[]; + testFiles: string[]; + changes: { + path: string; + beforeSha256: string | null; + after: string | null; + effect: 'source' | 'configuration' | 'dependency'; + }[]; + review: { + reviewer: string; + independent: boolean; + rootCauseRepaired: boolean; + featurePreserved: boolean; + boundaryMocks: boolean; + rationale: string; + reviewedPatchHash: string; + artifactId?: string; + }; +} +export interface RepairReviewArtifact { + schemaVersion: 3; + id: string; + runId: string; + findingId: string; + createdAt: string; + producer: string; + reviewer: string; + assurance: 'self_attested' | 'host_verified'; + requestHash: string; + patchHash: string; + rootCauseRepaired: boolean; + featurePreserved: boolean; + boundaryMocks: boolean; + rationale: string; } -export interface RepairReviewArtifact {schemaVersion:3;id:string;runId:string;findingId:string;createdAt:string;producer:string;reviewer:string;assurance:'self_attested'|'host_verified';requestHash:string;patchHash:string;rootCauseRepaired:boolean;featurePreserved:boolean;boundaryMocks:boolean;rationale:string} export interface RecheckEvidenceV3 { kind: 'caller' | 'security_boundary'; path: string; @@ -89,235 +234,640 @@ export interface SubmissionV3 { coverage?: unknown[]; gaps?: string[]; modelUsage?: { source: string; tokens: number; cost?: number }; - recheck?: { findingId: string; outcome: 'open' | 'resolved' | 'unknown'; evidence: RecheckEvidenceV3[]; rootCause: string }; + recheck?: { + findingId: string; + outcome: 'open' | 'resolved' | 'unknown'; + evidence: RecheckEvidenceV3[]; + rootCause: string; + }; } export interface VerificationObservation { - booted: boolean; legitimate: boolean; security: 'pass' | 'intended_failure' | 'inconclusive'; - existingTests: boolean; output: string; inputHash: string; + booted: boolean; + legitimate: boolean; + security: 'pass' | 'intended_failure' | 'inconclusive'; + existingTests: boolean; + output: string; + inputHash: string; } export interface AssertionWitnessBinding { - schemaVersion: 1; protocol: 'gstack-cso-assertion-witness-v1'; nonce: string; phase: 'before' | 'after'; - issuedAt: string; expiresAt: string; runId: string; findingId: string; - policyHash: string; auditPolicyHash: string; + schemaVersion: 1; + protocol: 'gstack-cso-assertion-witness-v1'; + nonce: string; + phase: 'before' | 'after'; + issuedAt: string; + expiresAt: string; + runId: string; + findingId: string; + policyHash: string; + auditPolicyHash: string; runtime: { image: string; verifierImage: string; platform: string; profile: string }; - runner: { testToolchain: 'runtime' | 'project'; startPlanHash: string; testPlanHash: string; - commandsHash: string; minimumPassingTestsHash: string }; - sourceHash: string; dependencyHash: string; configurationHash: string; requestHash: string; - patchHash: string; harnessHash: string; assertionHash: string; fixturesHash: string; + runner: { + testToolchain: 'runtime' | 'project'; + startPlanHash: string; + testPlanHash: string; + commandsHash: string; + minimumPassingTestsHash: string; + }; + sourceHash: string; + dependencyHash: string; + configurationHash: string; + requestHash: string; + patchHash: string; + harnessHash: string; + assertionHash: string; + fixturesHash: string; } export interface AssertionWitnessReceipt { - schemaVersion: 1; binding: AssertionWitnessBinding; keyId: string; publicKey: string; - observationHash: string; externalAssertionsPassed: boolean; diagnosticTestsPassed: boolean; - executions: Array<{ commandHash: string; exitCode: number; outputHash: string; minimumPassingTests: number; - executedTests: number; passingTests: number; reportedPassed: boolean }>; + schemaVersion: 1; + binding: AssertionWitnessBinding; + keyId: string; + publicKey: string; + observationHash: string; + externalAssertionsPassed: boolean; + diagnosticTestsPassed: boolean; + executions: Array<{ + commandHash: string; + exitCode: number; + outputHash: string; + minimumPassingTests: number; + executedTests: number; + passingTests: number; + reportedPassed: boolean; + }>; signature: string; } export interface PreparationProof { - schemaVersion: 1; dependencyClosureHash: string; configurationHash: string; sourceProjectionHash: string; - preparedManifestHash: string; preparedDependencyHash: string; receiptHash: string; executionEnvironmentHash: string; databaseHash: string; + schemaVersion: 1; + dependencyClosureHash: string; + configurationHash: string; + sourceProjectionHash: string; + preparedManifestHash: string; + preparedDependencyHash: string; + receiptHash: string; + executionEnvironmentHash: string; + databaseHash: string; transformations: Array<{ path: string; sha256: string; mode: number; reason: string }>; } export interface VerificationManifest { - version: 3; id: string; runId: string; findingId: string; createdAt: string; - helperAbi: 3; runtime: { image: string; platform: string; profile: string }; + version: 3; + id: string; + runId: string; + findingId: string; + createdAt: string; + helperAbi: 3; + runtime: { image: string; platform: string; profile: string }; testToolchain: 'runtime' | 'project'; - policyHash: string; harnessHash: string; requestHash:string; startPlanHash:string; testPlanHash:string; fixturesHash: string; patchHash: string; - auditPolicyHash:string; originalSourceHash:string; transformationsHash:string; archivesHash:string; preparationHash?:string; - beforeSourceHash: string; afterSourceHash: string; beforeDependencies: string; afterDependencies: string; - beforeConfiguration: string; afterConfiguration: string; - before: VerificationObservation; after: VerificationObservation; - review: VerificationRequest['review']; reviewAssurance:'self_attested'|'host_verified'; - assertionAssurance?:'authenticated_out_of_process'; - testCompletionAssurance:'self_reported'|'authenticated_out_of_process'; + policyHash: string; + harnessHash: string; + requestHash: string; + startPlanHash: string; + testPlanHash: string; + fixturesHash: string; + patchHash: string; + auditPolicyHash: string; + originalSourceHash: string; + transformationsHash: string; + archivesHash: string; + preparationHash?: string; + beforeSourceHash: string; + afterSourceHash: string; + beforeDependencies: string; + afterDependencies: string; + beforeConfiguration: string; + afterConfiguration: string; + before: VerificationObservation; + after: VerificationObservation; + review: VerificationRequest['review']; + reviewAssurance: 'self_attested' | 'host_verified'; + assertionAssurance?: 'authenticated_out_of_process'; + testCompletionAssurance: 'self_reported' | 'authenticated_out_of_process'; witnessHash?: string; result: 'runtime_tested' | 'tested' | 'failed' | 'inconclusive'; } export interface RepairBundle { - schemaVersion: 3; runId: string; id: string; createdAt: string; expiresAt: string; - requiredInputs: { sourceHash: string; originalHash: string; runtimeImage: string; platform: string; archives: string[]; - dependencyClosures?: { before: unknown; after: unknown } }; - request: VerificationRequest; verification: VerificationManifest; transformations: SnapshotEntry[]; - preparation?: { before: PreparationProof; after: PreparationProof }; reviewArtifact?:RepairReviewArtifact; + schemaVersion: 3; + runId: string; + id: string; + createdAt: string; + expiresAt: string; + requiredInputs: { + sourceHash: string; + originalHash: string; + runtimeImage: string; + platform: string; + archives: string[]; + dependencyClosures?: { before: unknown; after: unknown }; + }; + request: VerificationRequest; + verification: VerificationManifest; + transformations: SnapshotEntry[]; + preparation?: { before: PreparationProof; after: PreparationProof }; + reviewArtifact?: RepairReviewArtifact; witness?: { before: AssertionWitnessReceipt; after: AssertionWitnessReceipt }; } export function object(value: unknown, name = 'input'): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new CsoError('INVALID_SCHEMA', `${name} must be an object`); - return value as Record; + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new CsoError('INVALID_SCHEMA', `${name} must be an object`); + return value as Record; } -function exact(value:Record,allowed:readonly string[],name:string):void{ - for(const key of Object.keys(value))if(!allowed.includes(key))throw new CsoError('INVALID_SCHEMA',`Unexpected ${name} field: ${key}`); +function exact(value: Record, allowed: readonly string[], name: string): void { + for (const key of Object.keys(value)) + if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`); } -function boolean(value:unknown,name:string):boolean{ - if(typeof value!=='boolean')throw new CsoError('INVALID_SCHEMA',`${name} must be a boolean`);return value; +function boolean(value: unknown, name: string): boolean { + if (typeof value !== 'boolean') throw new CsoError('INVALID_SCHEMA', `${name} must be a boolean`); + return value; } export function string(value: unknown, name: string, max = 8192): string { - if (typeof value !== 'string' || !value.trim() || value.length > max || UNSAFE_STRING_CONTROLS.test(value)) throw new CsoError('INVALID_SCHEMA', `${name} must be a nonempty string without unsafe control characters (maximum ${max})`); + if (typeof value !== 'string' || !value.trim() || value.length > max || UNSAFE_STRING_CONTROLS.test(value)) + throw new CsoError( + 'INVALID_SCHEMA', + `${name} must be a nonempty string without unsafe control characters (maximum ${max})`, + ); return value; } export function strings(value: unknown, name: string): string[] { - if (!Array.isArray(value) || value.length > 1000) throw new CsoError('INVALID_SCHEMA', `${name} must be an array`); - return value.map((v,i) => string(v, `${name}[${i}]`)); + if (!Array.isArray(value) || value.length > 1000) + throw new CsoError('INVALID_SCHEMA', `${name} must be an array`); + return value.map((v, i) => string(v, `${name}[${i}]`)); } export function oneOf(value: unknown, choices: readonly T[], name: string): T { - if (!choices.includes(value as T)) throw new CsoError('INVALID_SCHEMA', `${name} must be one of ${choices.join(', ')}`); + if (!choices.includes(value as T)) + throw new CsoError('INVALID_SCHEMA', `${name} must be one of ${choices.join(', ')}`); return value as T; } export function relativePath(value: unknown): string { const p = string(value, 'relative path', 4096); - if (p.startsWith('/') || p.includes('\\') || /^[A-Za-z]:/.test(p) || p.split('/').some(x => !x || x === '.' || x === '..') || /[\x00-\x1f\x7f]/.test(p)) + if ( + p.startsWith('/') || + p.includes('\\') || + /^[A-Za-z]:/.test(p) || + p.split('/').some((x) => !x || x === '.' || x === '..') || + /[\x00-\x1f\x7f]/.test(p) + ) throw new CsoError('UNSAFE_PATH', 'Expected a contained relative path'); return p; } -const SNAPSHOT_PATH_HANDLE=/^@cso-path\/\/([a-f0-9]{32})$/; -export function snapshotPathId(root:string,path:string):string{ +const SNAPSHOT_PATH_HANDLE = /^@cso-path\/\/([a-f0-9]{32})$/; +export function snapshotPathId(root: string, path: string): string { // Validate the root for callers, but do not salt the opaque identity with its // absolute checkout path. Replay must resolve the same retained path after a // matching source tree is supplied from another checkout. - string(root,'snapshot root',8192);const relative=relativePath(path); - return sha256(canonical({kind:'cso-path-v3',path:relative})).slice(0,32); + string(root, 'snapshot root', 8192); + const relative = relativePath(path); + return sha256(canonical({ kind: 'cso-path-v3', path: relative })).slice(0, 32); } -export function snapshotPathHandle(pathId:string):string{ - if(!/^[a-f0-9]{32}$/.test(pathId))throw new CsoError('INVALID_SCHEMA','Snapshot path ID must be 32 lowercase hexadecimal characters'); +export function snapshotPathHandle(pathId: string): string { + if (!/^[a-f0-9]{32}$/.test(pathId)) + throw new CsoError('INVALID_SCHEMA', 'Snapshot path ID must be 32 lowercase hexadecimal characters'); return `@cso-path//${pathId}`; } -export function snapshotPathHandleId(value:unknown):string|undefined{ - if(typeof value!=='string')return; +export function snapshotPathHandleId(value: unknown): string | undefined { + if (typeof value !== 'string') return; return SNAPSHOT_PATH_HANDLE.exec(value)?.[1]; } -export function snapshotReference(value:unknown):string{ - const reference=string(value,'snapshot path or handle',4096); - return snapshotPathHandleId(reference)?reference:relativePath(reference); +export function snapshotReference(value: unknown): string { + const reference = string(value, 'snapshot path or handle', 4096); + return snapshotPathHandleId(reference) ? reference : relativePath(reference); } -export function snapshotOriginalIdentity(entries:Array>,deletedPaths:Array>=[]):string{ - const present=entries.map(entry=>[entry.path,entry.originalHash,entry.mode]); +export function snapshotOriginalIdentity( + entries: Array>, + deletedPaths: Array> = [], +): string { + const present = entries.map((entry) => [entry.path, entry.originalHash, entry.mode]); // Preserve the original no-deletion identity for v3 artifacts already // retained by pre-release builds. Any deletion changes the identity and is // therefore impossible to strip from a manifest without detection. - return sha256(canonical(deletedPaths.length?{entries:present,deletedPaths:deletedPaths.map(item=>item.path).sort()}:present)); + return sha256( + canonical( + deletedPaths.length + ? { entries: present, deletedPaths: deletedPaths.map((item) => item.path).sort() } + : present, + ), + ); } -export function rootCauseIdentity(value:string):string{return value.normalize('NFKC').trim().replace(/\s+/g,' ').toLowerCase();} -function advisoryIdentities(values:string[]):string[]{return [...new Set(values.map(value=>value.normalize('NFKC').trim().toUpperCase()))].sort();} -export function fingerprint(f: Pick): string { +export function rootCauseIdentity(value: string): string { + return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase(); +} +function advisoryIdentities(values: string[]): string[] { + return [...new Set(values.map((value) => value.normalize('NFKC').trim().toUpperCase()))].sort(); +} +export function fingerprint(f: Pick): string { // Titles, line shifts, severity, and generated descriptions are deliberately absent. - return sha256(canonical({ rootCause: rootCauseIdentity(f.rootCause), path: f.location.path, symbol: f.location.symbol, advisories: advisoryIdentities(f.advisoryIds) })).slice(0,32); + return sha256( + canonical({ + rootCause: rootCauseIdentity(f.rootCause), + path: f.location.path, + symbol: f.location.symbol, + advisories: advisoryIdentities(f.advisoryIds), + }), + ).slice(0, 32); } export function validateFinding(input: unknown): FindingV3 { - const v = object(input, 'finding'), loc = object(v.location,'location'), c = object(v.challenge,'challenge'); - for (const reserved of ['reproduction','repair','closure','verificationId','reproductionAttemptId','verificationAssurance']) if (reserved in v) - throw new CsoError('INVALID_SCHEMA', `${reserved} is helper-owned`); - exact(v,['title','rootCause','location','advisoryIds','severity','confidence','confidenceRationale','evidence','attackerControl','impact','scenario','trace','references','recommendation','challenge','dependency'],'finding'); - exact(loc,['path','line','symbol'],'location'); - exact(c,['reviewer','independent','mode','callers','controls','counterevidence','conclusion'],'challenge'); + const v = object(input, 'finding'), + loc = object(v.location, 'location'), + c = object(v.challenge, 'challenge'); + for (const reserved of [ + 'reproduction', + 'repair', + 'closure', + 'verificationId', + 'reproductionAttemptId', + 'verificationAssurance', + ]) + if (reserved in v) throw new CsoError('INVALID_SCHEMA', `${reserved} is helper-owned`); + exact( + v, + [ + 'title', + 'rootCause', + 'location', + 'advisoryIds', + 'severity', + 'confidence', + 'confidenceRationale', + 'evidence', + 'attackerControl', + 'impact', + 'scenario', + 'trace', + 'references', + 'recommendation', + 'challenge', + 'dependency', + ], + 'finding', + ); + exact(loc, ['path', 'line', 'symbol'], 'location'); + exact( + c, + ['reviewer', 'independent', 'mode', 'callers', 'controls', 'counterevidence', 'conclusion'], + 'challenge', + ); const f: FindingV3 = { - id: '', fingerprint: '', title: string(v.title,'title'), rootCause: string(v.rootCause,'rootCause'), - location: { path: snapshotReference(loc.path), line: loc.line, symbol: string(loc.symbol,'symbol') }, - advisoryIds: advisoryIdentities(strings(v.advisoryIds ?? [],'advisoryIds')), - severity: oneOf(v.severity,['critical','high','medium','low','informational'],'severity'), - confidence: oneOf(v.confidence,['high','medium','low'],'confidence'), - confidenceRationale: string(v.confidenceRationale,'confidenceRationale'), - evidence: oneOf(v.evidence,['supported','hypothesis'],'evidence'), - attackerControl: string(v.attackerControl,'attackerControl'), impact: string(v.impact,'impact'), scenario: string(v.scenario,'scenario'), trace: strings(v.trace,'trace'), - references: strings(v.references,'references'), recommendation: string(v.recommendation,'recommendation'), - challenge: { reviewer: string(c.reviewer,'reviewer'), independent: boolean(c.independent,'challenge.independent'), mode:oneOf(c.mode,['independent_agent','sequential_fallback'],'challenge.mode'), callers: string(c.callers,'callers'), - controls: string(c.controls,'controls'), counterevidence: string(c.counterevidence,'counterevidence'), conclusion: string(c.conclusion,'conclusion') }, - reproduction: 'not_attempted', repair: 'not_attempted', closure: 'open', + id: '', + fingerprint: '', + title: string(v.title, 'title'), + rootCause: string(v.rootCause, 'rootCause'), + location: { path: snapshotReference(loc.path), line: loc.line, symbol: string(loc.symbol, 'symbol') }, + advisoryIds: advisoryIdentities(strings(v.advisoryIds ?? [], 'advisoryIds')), + severity: oneOf(v.severity, ['critical', 'high', 'medium', 'low', 'informational'], 'severity'), + confidence: oneOf(v.confidence, ['high', 'medium', 'low'], 'confidence'), + confidenceRationale: string(v.confidenceRationale, 'confidenceRationale'), + evidence: oneOf(v.evidence, ['supported', 'hypothesis'], 'evidence'), + attackerControl: string(v.attackerControl, 'attackerControl'), + impact: string(v.impact, 'impact'), + scenario: string(v.scenario, 'scenario'), + trace: strings(v.trace, 'trace'), + references: strings(v.references, 'references'), + recommendation: string(v.recommendation, 'recommendation'), + challenge: { + reviewer: string(c.reviewer, 'reviewer'), + independent: boolean(c.independent, 'challenge.independent'), + mode: oneOf(c.mode, ['independent_agent', 'sequential_fallback'], 'challenge.mode'), + callers: string(c.callers, 'callers'), + controls: string(c.controls, 'controls'), + counterevidence: string(c.counterevidence, 'counterevidence'), + conclusion: string(c.conclusion, 'conclusion'), + }, + reproduction: 'not_attempted', + repair: 'not_attempted', + closure: 'open', }; - if (!Number.isInteger(f.location.line) || f.location.line < 1) throw new CsoError('INVALID_SCHEMA','line must be a positive integer'); - const fallbackLabel='sequential challenge; independent agent unavailable'; - if((f.challenge.independent&&(f.challenge.mode!=='independent_agent'||f.challenge.reviewer===fallbackLabel))||(!f.challenge.independent&&(f.challenge.mode!=='sequential_fallback'||f.challenge.reviewer!==fallbackLabel)))throw new CsoError('INVALID_SCHEMA',`Challenge mode must bind either an independent agent or the exact fallback label: ${fallbackLabel}`); - if (f.evidence === 'supported' && (f.confidence === 'low' || !f.trace.length || !f.references.length)) throw new CsoError('INVALID_SCHEMA','Supported findings require a challenge, a trace, supporting references, and medium/high confidence'); + if (!Number.isInteger(f.location.line) || f.location.line < 1) + throw new CsoError('INVALID_SCHEMA', 'line must be a positive integer'); + const fallbackLabel = 'sequential challenge; independent agent unavailable'; + if ( + (f.challenge.independent && + (f.challenge.mode !== 'independent_agent' || f.challenge.reviewer === fallbackLabel)) || + (!f.challenge.independent && + (f.challenge.mode !== 'sequential_fallback' || f.challenge.reviewer !== fallbackLabel)) + ) + throw new CsoError( + 'INVALID_SCHEMA', + `Challenge mode must bind either an independent agent or the exact fallback label: ${fallbackLabel}`, + ); + if (f.evidence === 'supported' && (f.confidence === 'low' || !f.trace.length || !f.references.length)) + throw new CsoError( + 'INVALID_SCHEMA', + 'Supported findings require a challenge, a trace, supporting references, and medium/high confidence', + ); if (v.dependency) { const d = object(v.dependency); - exact(d,['affectedVersion','reachability','exposure','exploitation'],'dependency'); - f.dependency = { affectedVersion: string(d.affectedVersion,'affectedVersion'), reachability: oneOf(d.reachability,['reachable','unreachable','unknown'],'reachability'), exposure: string(d.exposure,'exposure'), exploitation: string(d.exploitation,'exploitation') }; + exact(d, ['affectedVersion', 'reachability', 'exposure', 'exploitation'], 'dependency'); + f.dependency = { + affectedVersion: string(d.affectedVersion, 'affectedVersion'), + reachability: oneOf(d.reachability, ['reachable', 'unreachable', 'unknown'], 'reachability'), + exposure: string(d.exposure, 'exposure'), + exploitation: string(d.exploitation, 'exploitation'), + }; } - f.fingerprint = fingerprint(f); f.id = f.fingerprint; return f; + f.fingerprint = fingerprint(f); + f.id = f.fingerprint; + return f; } export function validateCoverage(input: unknown): CoverageRecord { - const v = object(input,'coverage'); - exact(v,['domain','scope','status','method','gaps','exclusions','evidence','tool'],'coverage'); - const c: CoverageRecord = { domain: string(v.domain,'domain'), scope: string(v.scope,'scope'), - status: oneOf(v.status,['assessed','partial','not_assessed','not_applicable'],'coverage status'), - method: string(v.method,'method'), gaps: strings(v.gaps,'gaps'), exclusions: strings(v.exclusions,'exclusions'), evidence: strings(v.evidence,'evidence') }; - if (c.status === 'assessed' && (c.gaps.length || !c.evidence.length)) throw new CsoError('INVALID_SCHEMA','Assessed coverage needs evidence and no outstanding gaps'); - if (c.status === 'partial' && (!c.gaps.length || !c.evidence.length)) throw new CsoError('INVALID_SCHEMA','Partial coverage needs assessed evidence and a concrete gap'); - if (c.status === 'not_assessed' && !c.gaps.length) throw new CsoError('INVALID_SCHEMA','Unassessed coverage needs a concrete gap'); - if (c.status === 'not_applicable' && (!c.evidence.length || c.gaps.length)) throw new CsoError('INVALID_SCHEMA','Non-applicability requires evidence and cannot retain an assessment gap'); - if (v.tool) { const t = object(v.tool);exact(t,['name','version','freshness','outcome'],'coverage tool'); c.tool = {name:string(t.name,'tool name'), version:string(t.version,'tool version'), freshness:string(t.freshness,'freshness'), outcome:string(t.outcome,'outcome')}; } + const v = object(input, 'coverage'); + exact(v, ['domain', 'scope', 'status', 'method', 'gaps', 'exclusions', 'evidence', 'tool'], 'coverage'); + const c: CoverageRecord = { + domain: string(v.domain, 'domain'), + scope: string(v.scope, 'scope'), + status: oneOf(v.status, ['assessed', 'partial', 'not_assessed', 'not_applicable'], 'coverage status'), + method: string(v.method, 'method'), + gaps: strings(v.gaps, 'gaps'), + exclusions: strings(v.exclusions, 'exclusions'), + evidence: strings(v.evidence, 'evidence'), + }; + if (c.status === 'assessed' && (c.gaps.length || !c.evidence.length)) + throw new CsoError('INVALID_SCHEMA', 'Assessed coverage needs evidence and no outstanding gaps'); + if (c.status === 'partial' && (!c.gaps.length || !c.evidence.length)) + throw new CsoError('INVALID_SCHEMA', 'Partial coverage needs assessed evidence and a concrete gap'); + if (c.status === 'not_assessed' && !c.gaps.length) + throw new CsoError('INVALID_SCHEMA', 'Unassessed coverage needs a concrete gap'); + if (c.status === 'not_applicable' && (!c.evidence.length || c.gaps.length)) + throw new CsoError( + 'INVALID_SCHEMA', + 'Non-applicability requires evidence and cannot retain an assessment gap', + ); + if (v.tool) { + const t = object(v.tool); + exact(t, ['name', 'version', 'freshness', 'outcome'], 'coverage tool'); + c.tool = { + name: string(t.name, 'tool name'), + version: string(t.version, 'tool version'), + freshness: string(t.freshness, 'freshness'), + outcome: string(t.outcome, 'outcome'), + }; + } return c; } export function validateCommand(value: unknown, name: string): Command { - const v=object(value,name), executable=string(v.executable,`${name}.executable`,4096), args=strings(v.args??[],`${name}.args`); - exact(v,['executable','args'],name); - if(!executable.startsWith('/')||executable.includes('..'))throw new CsoError('INVALID_SCHEMA',`${name}.executable must be an absolute in-container path`); - return {executable,args}; + const v = object(value, name), + executable = string(v.executable, `${name}.executable`, 4096), + args = strings(v.args ?? [], `${name}.args`); + exact(v, ['executable', 'args'], name); + if (!executable.startsWith('/') || executable.includes('..')) + throw new CsoError('INVALID_SCHEMA', `${name}.executable must be an absolute in-container path`); + return { executable, args }; } -export function validateVerificationObservation(value:unknown):VerificationObservation{ - const v=object(value,'verification observation'); - for(const key of Object.keys(v))if(!['booted','legitimate','security','existingTests','output','inputHash'].includes(key))throw new CsoError('INVALID_SCHEMA',`Unexpected verification observation field: ${key}`); - if(typeof v.booted!=='boolean'||typeof v.legitimate!=='boolean'||typeof v.existingTests!=='boolean')throw new CsoError('INVALID_SCHEMA','Verification observation outcomes must be booleans'); - if(typeof v.output!=='string'||v.output.length>8192||v.output.includes('\0'))throw new CsoError('INVALID_SCHEMA','Verification observation output must be a bounded string'); - if(typeof v.inputHash!=='string'||(!/^$/.test(v.inputHash)&&!/^[a-f0-9]{64}$/.test(v.inputHash)))throw new CsoError('INVALID_SCHEMA','Verification observation inputHash must be empty or a sha256 hash'); - return{booted:v.booted,legitimate:v.legitimate,security:oneOf(v.security,['pass','intended_failure','inconclusive'],'verification security outcome'),existingTests:v.existingTests,output:v.output,inputHash:v.inputHash}; +export function validateVerificationObservation(value: unknown): VerificationObservation { + const v = object(value, 'verification observation'); + for (const key of Object.keys(v)) + if (!['booted', 'legitimate', 'security', 'existingTests', 'output', 'inputHash'].includes(key)) + throw new CsoError('INVALID_SCHEMA', `Unexpected verification observation field: ${key}`); + if ( + typeof v.booted !== 'boolean' || + typeof v.legitimate !== 'boolean' || + typeof v.existingTests !== 'boolean' + ) + throw new CsoError('INVALID_SCHEMA', 'Verification observation outcomes must be booleans'); + if (typeof v.output !== 'string' || v.output.length > 8192 || v.output.includes('\0')) + throw new CsoError('INVALID_SCHEMA', 'Verification observation output must be a bounded string'); + if (typeof v.inputHash !== 'string' || (!/^$/.test(v.inputHash) && !/^[a-f0-9]{64}$/.test(v.inputHash))) + throw new CsoError('INVALID_SCHEMA', 'Verification observation inputHash must be empty or a sha256 hash'); + return { + booted: v.booted, + legitimate: v.legitimate, + security: oneOf( + v.security, + ['pass', 'intended_failure', 'inconclusive'], + 'verification security outcome', + ), + existingTests: v.existingTests, + output: v.output, + inputHash: v.inputHash, + }; } -function assertion(value: unknown,name:string):HttpAssertion { - const v=object(value,name), expected=object(v.expected,`${name}.expected`); - exact(v,['name','path','method','headers','body','expected','vulnerable'],name); - const oracle=(x:Record,n:string)=>{exact(x,['status','includes','excludes'],n);if(!Number.isInteger(x.status)||x.status<100||x.status>599)throw new CsoError('INVALID_SCHEMA',`${n}.status must be an HTTP status`);return {status:x.status,...(x.includes===undefined?{}:{includes:string(x.includes,`${n}.includes`)}),...(x.excludes===undefined?{}:{excludes:string(x.excludes,`${n}.excludes`)})};}; - const path=string(v.path,`${name}.path`,4096);if(!path.startsWith('/')||path.startsWith('//')||/[\r\n]/.test(path))throw new CsoError('INVALID_SCHEMA',`${name}.path must stay on numeric loopback`); - const headers:Record={};if(v.headers!==undefined)for(const [k,val] of Object.entries(object(v.headers,`${name}.headers`))){if(!/^[A-Za-z0-9-]{1,100}$/.test(k)||typeof val!=='string'||val.length>8192||/[\r\n]/.test(val))throw new CsoError('INVALID_SCHEMA',`Invalid ${name} header`);headers[k]=val;} - return {name:string(v.name,`${name}.name`),path,method:oneOf(v.method,['GET','POST','PUT','PATCH','DELETE'],`${name}.method`),...(Object.keys(headers).length?{headers}:{}),...(v.body===undefined?{}:{body:string(v.body,`${name}.body`,65536)}),expected:oracle(expected,`${name}.expected`),...(v.vulnerable===undefined?{}:{vulnerable:oracle(object(v.vulnerable),`${name}.vulnerable`)})}; +function assertion(value: unknown, name: string): HttpAssertion { + const v = object(value, name), + expected = object(v.expected, `${name}.expected`); + exact(v, ['name', 'path', 'method', 'headers', 'body', 'expected', 'vulnerable'], name); + const oracle = (x: Record, n: string) => { + exact(x, ['status', 'includes', 'excludes'], n); + if (!Number.isInteger(x.status) || x.status < 100 || x.status > 599) + throw new CsoError('INVALID_SCHEMA', `${n}.status must be an HTTP status`); + return { + status: x.status, + ...(x.includes === undefined ? {} : { includes: string(x.includes, `${n}.includes`) }), + ...(x.excludes === undefined ? {} : { excludes: string(x.excludes, `${n}.excludes`) }), + }; + }; + const path = string(v.path, `${name}.path`, 4096); + if (!path.startsWith('/') || path.startsWith('//') || /[\r\n]/.test(path)) + throw new CsoError('INVALID_SCHEMA', `${name}.path must stay on numeric loopback`); + const headers: Record = {}; + if (v.headers !== undefined) + for (const [k, val] of Object.entries(object(v.headers, `${name}.headers`))) { + if ( + !/^[A-Za-z0-9-]{1,100}$/.test(k) || + typeof val !== 'string' || + val.length > 8192 || + /[\r\n]/.test(val) + ) + throw new CsoError('INVALID_SCHEMA', `Invalid ${name} header`); + headers[k] = val; + } + return { + name: string(v.name, `${name}.name`), + path, + method: oneOf(v.method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], `${name}.method`), + ...(Object.keys(headers).length ? { headers } : {}), + ...(v.body === undefined ? {} : { body: string(v.body, `${name}.body`, 65536) }), + expected: oracle(expected, `${name}.expected`), + ...(v.vulnerable === undefined ? {} : { vulnerable: oracle(object(v.vulnerable), `${name}.vulnerable`) }), + }; } -export function validateVerificationRequest(input:unknown):VerificationRequest { - const v=object(input,'verification request'),changes=v.changes,fixtures=object(v.fixtures??{},'fixtures'),review=object(v.review,'review'); - exact(v,['findingId','runtimeProfile','port','start','legitimate','security','existingTests','fixtures','boundaryFiles','testFiles','changes','review'],'verification request'); - exact(review,['reviewer','independent','rootCauseRepaired','featurePreserved','boundaryMocks','rationale','reviewedPatchHash','artifactId'],'review'); - if(!Array.isArray(changes)||!changes.length||changes.length>100)throw new CsoError('INVALID_SCHEMA','changes must contain 1..100 declared patch effects'); - const cleanFixtures:Record={};for(const [p,body] of Object.entries(fixtures)){cleanFixtures[relativePath(p)]=string(body,`fixture ${p}`,1024*1024);} - const request:VerificationRequest={findingId:string(v.findingId,'findingId'),runtimeProfile:string(v.runtimeProfile,'runtimeProfile',100),port:v.port, - start:validateCommand(v.start,'start'),legitimate:(Array.isArray(v.legitimate)?v.legitimate:[]).map((x,i)=>assertion(x,`legitimate[${i}]`)),security:assertion(v.security,'security'),existingTests:(Array.isArray(v.existingTests)?v.existingTests:[]).map((x,i)=>validateCommand(x,`existingTests[${i}]`)),fixtures:cleanFixtures,boundaryFiles:strings(v.boundaryFiles,'boundaryFiles').map(snapshotReference),testFiles:strings(v.testFiles,'testFiles').map(snapshotReference), - changes:changes.map((raw:any,i:number)=>{const x=object(raw,`changes[${i}]`),before=x.beforeSha256;exact(x,['path','beforeSha256','after','effect'],`changes[${i}]`);if(before!==null&&(typeof before!=='string'||!/^[a-f0-9]{64}$/.test(before)))throw new CsoError('INVALID_SCHEMA',`changes[${i}].beforeSha256 must be a hash or null`);return{path:snapshotReference(x.path),beforeSha256:before,after:x.after===null?null:string(x.after,`changes[${i}].after`,1024*1024),effect:oneOf(x.effect,['source','configuration','dependency'],`changes[${i}].effect`)};}), - review:{reviewer:string(review.reviewer,'reviewer'),independent:boolean(review.independent,'review.independent'),rootCauseRepaired:boolean(review.rootCauseRepaired,'review.rootCauseRepaired'),featurePreserved:boolean(review.featurePreserved,'review.featurePreserved'),boundaryMocks:boolean(review.boundaryMocks,'review.boundaryMocks'),rationale:string(review.rationale,'review rationale'),reviewedPatchHash:string(review.reviewedPatchHash,'reviewedPatchHash'),...(review.artifactId===undefined?{}:{artifactId:string(review.artifactId,'review artifact ID')})},}; - if(!/^[a-f0-9]{32}$/.test(request.findingId))throw new CsoError('INVALID_SCHEMA','findingId must be a helper-issued identifier'); - if(request.review.artifactId!==undefined&&!/^[a-f0-9]{32}$/.test(request.review.artifactId))throw new CsoError('INVALID_SCHEMA','review artifact ID must be a helper-issued identifier'); - if(!Number.isInteger(request.port)||request.port<1024||request.port>65535)throw new CsoError('INVALID_SCHEMA','port must be 1024..65535'); - if(!request.legitimate.length||!request.security.vulnerable||!request.existingTests.length||!request.boundaryFiles.length||!request.testFiles.length)throw new CsoError('INVALID_SCHEMA','Verification needs a legitimate control, distinct before/fixed security oracles, existing tests, immutable test files, and boundary files'); - const secure=request.security.expected,vulnerable=request.security.vulnerable; - const mutuallyExclusive=secure.status!==vulnerable.status - ||(secure.includes!==undefined&&vulnerable.excludes!==undefined&&secure.includes.includes(vulnerable.excludes)) - ||(vulnerable.includes!==undefined&&secure.excludes!==undefined&&vulnerable.includes.includes(secure.excludes)); - if(!mutuallyExclusive)throw new CsoError('INVALID_SCHEMA','The vulnerable and fixed security oracles must be provably mutually exclusive'); - if(new Set(request.changes.map(x=>x.path)).size!==request.changes.length)throw new CsoError('INVALID_SCHEMA','Patch paths must be unique'); - if(new Set(request.testFiles).size!==request.testFiles.length||request.changes.some(change=>request.testFiles.includes(change.path)))throw new CsoError('INVALID_SCHEMA','Existing-test source files must be unique and unchanged by the repair'); - if(request.existingTests.some(command=>/(?:^|\/)(?:true|false|echo|printf|env|sh|bash)$/.test(command.executable)))throw new CsoError('INVALID_SCHEMA','Generic success or shell commands cannot stand in for a project test suite'); - if(!request.changes.some(change=>change.beforeSha256===null||change.after===null||sha256(change.after)!==change.beforeSha256))throw new CsoError('INVALID_SCHEMA','A tested repair must contain at least one material patch effect'); +export function validateVerificationRequest(input: unknown): VerificationRequest { + const v = object(input, 'verification request'), + changes = v.changes, + fixtures = object(v.fixtures ?? {}, 'fixtures'), + review = object(v.review, 'review'); + exact( + v, + [ + 'findingId', + 'runtimeProfile', + 'port', + 'start', + 'legitimate', + 'security', + 'existingTests', + 'fixtures', + 'boundaryFiles', + 'testFiles', + 'changes', + 'review', + ], + 'verification request', + ); + exact( + review, + [ + 'reviewer', + 'independent', + 'rootCauseRepaired', + 'featurePreserved', + 'boundaryMocks', + 'rationale', + 'reviewedPatchHash', + 'artifactId', + ], + 'review', + ); + if (!Array.isArray(changes) || !changes.length || changes.length > 100) + throw new CsoError('INVALID_SCHEMA', 'changes must contain 1..100 declared patch effects'); + const cleanFixtures: Record = {}; + for (const [p, body] of Object.entries(fixtures)) { + cleanFixtures[relativePath(p)] = string(body, `fixture ${p}`, 1024 * 1024); + } + const request: VerificationRequest = { + findingId: string(v.findingId, 'findingId'), + runtimeProfile: string(v.runtimeProfile, 'runtimeProfile', 100), + port: v.port, + start: validateCommand(v.start, 'start'), + legitimate: (Array.isArray(v.legitimate) ? v.legitimate : []).map((x, i) => + assertion(x, `legitimate[${i}]`), + ), + security: assertion(v.security, 'security'), + existingTests: (Array.isArray(v.existingTests) ? v.existingTests : []).map((x, i) => + validateCommand(x, `existingTests[${i}]`), + ), + fixtures: cleanFixtures, + boundaryFiles: strings(v.boundaryFiles, 'boundaryFiles').map(snapshotReference), + testFiles: strings(v.testFiles, 'testFiles').map(snapshotReference), + changes: changes.map((raw: any, i: number) => { + const x = object(raw, `changes[${i}]`), + before = x.beforeSha256; + exact(x, ['path', 'beforeSha256', 'after', 'effect'], `changes[${i}]`); + if (before !== null && (typeof before !== 'string' || !/^[a-f0-9]{64}$/.test(before))) + throw new CsoError('INVALID_SCHEMA', `changes[${i}].beforeSha256 must be a hash or null`); + return { + path: snapshotReference(x.path), + beforeSha256: before, + after: x.after === null ? null : string(x.after, `changes[${i}].after`, 1024 * 1024), + effect: oneOf(x.effect, ['source', 'configuration', 'dependency'], `changes[${i}].effect`), + }; + }), + review: { + reviewer: string(review.reviewer, 'reviewer'), + independent: boolean(review.independent, 'review.independent'), + rootCauseRepaired: boolean(review.rootCauseRepaired, 'review.rootCauseRepaired'), + featurePreserved: boolean(review.featurePreserved, 'review.featurePreserved'), + boundaryMocks: boolean(review.boundaryMocks, 'review.boundaryMocks'), + rationale: string(review.rationale, 'review rationale'), + reviewedPatchHash: string(review.reviewedPatchHash, 'reviewedPatchHash'), + ...(review.artifactId === undefined + ? {} + : { artifactId: string(review.artifactId, 'review artifact ID') }), + }, + }; + if (!/^[a-f0-9]{32}$/.test(request.findingId)) + throw new CsoError('INVALID_SCHEMA', 'findingId must be a helper-issued identifier'); + if (request.review.artifactId !== undefined && !/^[a-f0-9]{32}$/.test(request.review.artifactId)) + throw new CsoError('INVALID_SCHEMA', 'review artifact ID must be a helper-issued identifier'); + if (!Number.isInteger(request.port) || request.port < 1024 || request.port > 65535) + throw new CsoError('INVALID_SCHEMA', 'port must be 1024..65535'); + if ( + !request.legitimate.length || + !request.security.vulnerable || + !request.existingTests.length || + !request.boundaryFiles.length || + !request.testFiles.length + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'Verification needs a legitimate control, distinct before/fixed security oracles, existing tests, immutable test files, and boundary files', + ); + const secure = request.security.expected, + vulnerable = request.security.vulnerable; + const mutuallyExclusive = + secure.status !== vulnerable.status || + (secure.includes !== undefined && + vulnerable.excludes !== undefined && + secure.includes.includes(vulnerable.excludes)) || + (vulnerable.includes !== undefined && + secure.excludes !== undefined && + vulnerable.includes.includes(secure.excludes)); + if (!mutuallyExclusive) + throw new CsoError( + 'INVALID_SCHEMA', + 'The vulnerable and fixed security oracles must be provably mutually exclusive', + ); + if (new Set(request.changes.map((x) => x.path)).size !== request.changes.length) + throw new CsoError('INVALID_SCHEMA', 'Patch paths must be unique'); + if ( + new Set(request.testFiles).size !== request.testFiles.length || + request.changes.some((change) => request.testFiles.includes(change.path)) + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'Existing-test source files must be unique and unchanged by the repair', + ); + if ( + request.existingTests.some((command) => + /(?:^|\/)(?:true|false|echo|printf|env|sh|bash)$/.test(command.executable), + ) + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'Generic success or shell commands cannot stand in for a project test suite', + ); + if ( + !request.changes.some( + (change) => + change.beforeSha256 === null || change.after === null || sha256(change.after) !== change.beforeSha256, + ) + ) + throw new CsoError('INVALID_SCHEMA', 'A tested repair must contain at least one material patch effect'); return request; } -export function completeness(report: Pick): Completeness { +export function completeness(report: Pick): Completeness { // Scanner adapters preserve operational outcomes, but scanner output is only // candidate evidence. The corresponding investigation domain decides whether // assessment work remains; an optional tool failure cannot override it. // A successful snapshot is a prerequisite, not security assessment work by // itself. Its helper-owned partial/not-assessed state remains material. - const work = report.coverage.filter(c => c.status !== 'not_applicable'&&!c.domain.startsWith('scanner:')&&!(['snapshot-inputs','history-inputs'].includes(c.domain)&&c.status==='assessed')); - if (!report.gaps.length && work.length && work.every(c => c.status === 'assessed')) return 'complete'; - return work.some(c => c.status === 'assessed' || c.status === 'partial') ? 'partial' : 'not assessed'; + const work = report.coverage.filter( + (c) => + c.status !== 'not_applicable' && + !c.domain.startsWith('scanner:') && + !(['snapshot-inputs', 'history-inputs'].includes(c.domain) && c.status === 'assessed'), + ); + if (!report.gaps.length && work.length && work.every((c) => c.status === 'assessed')) return 'complete'; + return work.some((c) => c.status === 'assessed' || c.status === 'partial') ? 'partial' : 'not assessed'; } export function renderReport(report: RunReportV3): string { - const supported = report.findings.filter(f => f.evidence === 'supported'); - const gaps = [...new Set([...report.gaps,...report.coverage.filter(c=>!c.domain.startsWith('scanner:')).flatMap(c => c.gaps)])]; - const transformations=report.source.transformations??[]; + const supported = report.findings.filter((f) => f.evidence === 'supported'); + const gaps = [ + ...new Set([ + ...report.gaps, + ...report.coverage.filter((c) => !c.domain.startsWith('scanner:')).flatMap((c) => c.gaps), + ]), + ]; + const transformations = report.source.transformations ?? []; // Report JSON is canonical evidence. Markdown is a safe plain-text view: // collapse line breaks and escape all Markdown control characters so model, // repository, scanner, and advisory strings cannot forge report structure. - const plain=(value:unknown):string=>String(value).replace(/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/gu,' ').replace(/\s{2,}/g,' ').trim().replace(/[\\`*_[\]{}()#+!|<>]/g,'\\$&'); - const list=(values:string[]):string=>values.length?values.map(plain).join('; '):'none'; - const terminal=[...report.events].reverse().find(item=>item.kind==='terminal'),startedAt=Date.parse(report.createdAt),terminalAt=terminal?Date.parse(terminal.at):NaN; - const elapsed=Number.isFinite(startedAt)&&Number.isFinite(terminalAt)&&terminalAt>=startedAt?`; elapsed ${terminalAt-startedAt} ms`:''; - const timing=`Timing: started ${plain(report.createdAt)}; deadline ${plain(report.deadline)}${terminal?`; terminal ${plain(terminal.at)}${elapsed}`:''}.`; - const usage=report.modelUsage?`Model usage: ${report.modelUsage.tokens} host-reported tokens from ${plain(report.modelUsage.source)}${report.modelUsage.cost===undefined?'':`; host-reported cost ${report.modelUsage.cost}`}.`:undefined; - const findingLines=(f:FindingV3):string[]=>[ + const plain = (value: unknown): string => + String(value) + .replace(/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/gu, ' ') + .replace(/\s{2,}/g, ' ') + .trim() + .replace(/[\\`*_[\]{}()#+!|<>]/g, '\\$&'); + const list = (values: string[]): string => (values.length ? values.map(plain).join('; ') : 'none'); + const terminal = [...report.events].reverse().find((item) => item.kind === 'terminal'), + startedAt = Date.parse(report.createdAt), + terminalAt = terminal ? Date.parse(terminal.at) : NaN; + const elapsed = + Number.isFinite(startedAt) && Number.isFinite(terminalAt) && terminalAt >= startedAt + ? `; elapsed ${terminalAt - startedAt} ms` + : ''; + const timing = `Timing: started ${plain(report.createdAt)}; deadline ${plain(report.deadline)}${terminal ? `; terminal ${plain(terminal.at)}${elapsed}` : ''}.`; + const usage = report.modelUsage + ? `Model usage: ${report.modelUsage.tokens} host-reported tokens from ${plain(report.modelUsage.source)}${report.modelUsage.cost === undefined ? '' : `; host-reported cost ${report.modelUsage.cost}`}.` + : undefined; + const findingLines = (f: FindingV3): string[] => [ `- ${plain(f.severity.toUpperCase())} ${plain(f.title)} [${plain(f.id)}]`, ` Location: ${plain(f.location.path)}:${f.location.line} (${plain(f.location.symbol)}). Confidence: ${plain(f.confidence)} — ${plain(f.confidenceRationale)}. Evidence: ${plain(f.evidence)}.`, ` Attacker scenario: ${plain(f.scenario)}`, @@ -325,57 +875,127 @@ export function renderReport(report: RunReportV3): string { ` Trace: ${list(f.trace)}. Supporting references: ${list(f.references)}.`, ` Counterevidence considered: ${plain(f.challenge.counterevidence)}. Challenge: ${plain(f.challenge.mode)} by ${plain(f.challenge.reviewer)}. Conclusion: ${plain(f.challenge.conclusion)}.`, ` Repair recommendation: ${plain(f.recommendation)}`, - ` Reproduction: ${plain(f.reproduction)}${f.reproductionAttemptId?` (attempt ${plain(f.reproductionAttemptId)})`:''}. Repair: ${plain(f.repair)}. Closure: ${plain(f.closure)}.`, - ...(f.verificationId?[` Verification: ${plain(f.verificationId)}. Bundle: bundles/${plain(f.verificationId)}.json. Assertion assurance: ${plain(f.verificationAssurance?.assertions??'unknown')}. Test completion assurance: ${plain(f.verificationAssurance?.testCompletion??'unknown')}. Review assurance: ${plain(f.verificationAssurance?.review??'unknown')}.`]:[]), + ` Reproduction: ${plain(f.reproduction)}${f.reproductionAttemptId ? ` (attempt ${plain(f.reproductionAttemptId)})` : ''}. Repair: ${plain(f.repair)}. Closure: ${plain(f.closure)}.`, + ...(f.verificationId + ? [ + ` Verification: ${plain(f.verificationId)}. Bundle: bundles/${plain(f.verificationId)}.json. Assertion assurance: ${plain(f.verificationAssurance?.assertions ?? 'unknown')}. Test completion assurance: ${plain(f.verificationAssurance?.testCompletion ?? 'unknown')}. Review assurance: ${plain(f.verificationAssurance?.review ?? 'unknown')}.`, + ] + : []), ]; - const model=report.application; - return [ `${report.completeness} — ${plain(report.policy.scope)}${report.policy.diff ? ` (diff against ${plain(report.policy.base)})` : ''}`, + const model = report.application; + return [ + `${report.completeness} — ${plain(report.policy.scope)}${report.policy.diff ? ` (diff against ${plain(report.policy.base)})` : ''}`, `Run: ${plain(report.runId)}. Mode: ${plain(report.policy.mode)}.`, - timing, ...(usage?[usage]:[]), - `Material gaps: ${gaps.length ? list(gaps) : 'none reported'}.`, '', + timing, + ...(usage ? [usage] : []), + `Material gaps: ${gaps.length ? list(gaps) : 'none reported'}.`, + '', 'Application model:', - `- Actors: ${list(model.actors)}.`, `- Assets: ${list(model.assets)}.`, `- Entrypoints: ${list(model.entrypoints)}.`, - `- Tenant boundaries: ${list(model.tenantBoundaries)}.`, `- Sensitive operations: ${list(model.sensitiveOperations)}.`, `- Security invariants: ${list(model.invariants)}.`, '', - ...(supported.length ? ['Supported findings:',...supported.flatMap(findingLines)] : ['No supported findings in the assessed scope.']), - ...(report.policy.mode === 'comprehensive' ? ['', 'Hypotheses (unconfirmed):', ...report.findings.filter(f => f.evidence === 'hypothesis').flatMap(findingLines)] : []), - '', 'Snapshot transformations:', ...(transformations.length?transformations.map(item=>`- ${plain(item.path)}: ${plain(item.handling)}`):['- none']), - '', 'Coverage:', ...report.coverage.flatMap(c=>[ - `- ${plain(c.domain)}: ${plain(c.status)}; ${plain(c.method)}${c.tool?`; tool ${plain(c.tool.name)} ${plain(c.tool.version)}, freshness ${plain(c.tool.freshness)}, outcome ${plain(c.tool.outcome)}`:''}.`, + `- Actors: ${list(model.actors)}.`, + `- Assets: ${list(model.assets)}.`, + `- Entrypoints: ${list(model.entrypoints)}.`, + `- Tenant boundaries: ${list(model.tenantBoundaries)}.`, + `- Sensitive operations: ${list(model.sensitiveOperations)}.`, + `- Security invariants: ${list(model.invariants)}.`, + '', + ...(supported.length + ? ['Supported findings:', ...supported.flatMap(findingLines)] + : ['No supported findings in the assessed scope.']), + ...(report.policy.mode === 'comprehensive' + ? [ + '', + 'Hypotheses (unconfirmed):', + ...report.findings.filter((f) => f.evidence === 'hypothesis').flatMap(findingLines), + ] + : []), + '', + 'Snapshot transformations:', + ...(transformations.length + ? transformations.map((item) => `- ${plain(item.path)}: ${plain(item.handling)}`) + : ['- none']), + '', + 'Coverage:', + ...report.coverage.flatMap((c) => [ + `- ${plain(c.domain)}: ${plain(c.status)}; ${plain(c.method)}${c.tool ? `; tool ${plain(c.tool.name)} ${plain(c.tool.version)}, freshness ${plain(c.tool.freshness)}, outcome ${plain(c.tool.outcome)}` : ''}.`, ` Scope: ${plain(c.scope)}. Evidence: ${list(c.evidence)}. Gaps: ${list(c.gaps)}. Exclusions: ${list(c.exclusions)}.`, - ]), '', + ]), + '', ].join('\n'); } -type LegacyJson = null | boolean | number | string | LegacyJson[] | { [key:string]: LegacyJson }; -function legacyJson(value:unknown,depth=0,seen=new WeakSet()):LegacyJson{ - if(depth>32)throw new CsoError('INVALID_SCHEMA','Legacy report nesting is too deep'); - if(value===null||typeof value==='boolean')return value; - if(typeof value==='number'){if(!Number.isFinite(value))throw new CsoError('INVALID_SCHEMA','Legacy report numbers must be finite');return value;} - if(typeof value==='string'){ - if(value.length>MAX_OUTPUT||UNSAFE_STRING_CONTROLS.test(value))throw new CsoError('INVALID_SCHEMA','Legacy report strings must be bounded and free of unsafe control characters'); +type LegacyJson = null | boolean | number | string | LegacyJson[] | { [key: string]: LegacyJson }; +function legacyJson(value: unknown, depth = 0, seen = new WeakSet()): LegacyJson { + if (depth > 32) throw new CsoError('INVALID_SCHEMA', 'Legacy report nesting is too deep'); + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new CsoError('INVALID_SCHEMA', 'Legacy report numbers must be finite'); return value; } - if(!value||typeof value!=='object')throw new CsoError('INVALID_SCHEMA','Legacy report contains a non-JSON value'); - if(seen.has(value))throw new CsoError('INVALID_SCHEMA','Legacy report cannot be cyclic');seen.add(value); - try{ - if(Array.isArray(value)){ - if(value.length>10_000)throw new CsoError('INVALID_SCHEMA','Legacy report array is too large'); - return value.map(item=>legacyJson(item,depth+1,seen)); + if (typeof value === 'string') { + if (value.length > MAX_OUTPUT || UNSAFE_STRING_CONTROLS.test(value)) + throw new CsoError( + 'INVALID_SCHEMA', + 'Legacy report strings must be bounded and free of unsafe control characters', + ); + return value; + } + if (!value || typeof value !== 'object') + throw new CsoError('INVALID_SCHEMA', 'Legacy report contains a non-JSON value'); + if (seen.has(value)) throw new CsoError('INVALID_SCHEMA', 'Legacy report cannot be cyclic'); + seen.add(value); + try { + if (Array.isArray(value)) { + if (value.length > 10_000) throw new CsoError('INVALID_SCHEMA', 'Legacy report array is too large'); + return value.map((item) => legacyJson(item, depth + 1, seen)); + } + const entries = Object.entries(value as Record); + if (entries.length > 10_000) throw new CsoError('INVALID_SCHEMA', 'Legacy report object is too large'); + const out: Record = Object.create(null); + for (const [key, item] of entries) { + if ( + key.length > 1024 || + ['__proto__', 'prototype', 'constructor'].includes(key) || + UNSAFE_PROPERTY_CONTROLS.test(key) + ) + throw new CsoError('INVALID_SCHEMA', 'Legacy report contains an unsafe property'); + out[key] = legacyJson(item, depth + 1, seen); } - const entries=Object.entries(value as Record);if(entries.length>10_000)throw new CsoError('INVALID_SCHEMA','Legacy report object is too large'); - const out:Record=Object.create(null); - for(const [key,item] of entries){if(key.length>1024||['__proto__','prototype','constructor'].includes(key)||UNSAFE_PROPERTY_CONTROLS.test(key))throw new CsoError('INVALID_SCHEMA','Legacy report contains an unsafe property');out[key]=legacyJson(item,depth+1,seen);} return out; - }finally{seen.delete(value);} + } finally { + seen.delete(value); + } } -export function importLegacy(input: unknown): { schemaVersion: 2; readOnly: true; findings: any[]; warning: string } { - const v = object(input,'legacy report'); - if (!Array.isArray(v.findings) || ![2,'2','2.0','2.0.0'].includes(v.schemaVersion ?? v.schema_version ?? v.version)) throw new CsoError('INVALID_SCHEMA','Expected a v2 report with findings'); - return { schemaVersion: 2, readOnly: true, warning: 'Legacy VERIFIED is review evidence only; it does not establish reproduction, tested repair, or closure.', - findings: v.findings.map((raw: any,index:number) => {const f=object(raw,`legacy finding ${index+1}`);return{ - title:typeof f.title==='string'?string(f.title,'legacy title'): `Legacy finding ${index+1}`, - status:typeof f.status==='string'?string(f.status,'legacy status'): 'unknown', - ...(typeof f.severity==='string'?{severity:string(f.severity,'legacy severity')}:{ }), - ...(typeof f.description==='string'?{description:string(f.description,'legacy description')}:{ }), - legacy:legacyJson(f), - evidence:'legacy_review', reproduction:'not_attempted', repair:'not_attempted', closure:'unknown'};}) }; +export function importLegacy(input: unknown): { + schemaVersion: 2; + readOnly: true; + findings: any[]; + warning: string; +} { + const v = object(input, 'legacy report'); + if ( + !Array.isArray(v.findings) || + ![2, '2', '2.0', '2.0.0'].includes(v.schemaVersion ?? v.schema_version ?? v.version) + ) + throw new CsoError('INVALID_SCHEMA', 'Expected a v2 report with findings'); + return { + schemaVersion: 2, + readOnly: true, + warning: + 'Legacy VERIFIED is review evidence only; it does not establish reproduction, tested repair, or closure.', + findings: v.findings.map((raw: any, index: number) => { + const f = object(raw, `legacy finding ${index + 1}`); + return { + title: typeof f.title === 'string' ? string(f.title, 'legacy title') : `Legacy finding ${index + 1}`, + status: typeof f.status === 'string' ? string(f.status, 'legacy status') : 'unknown', + ...(typeof f.severity === 'string' ? { severity: string(f.severity, 'legacy severity') } : {}), + ...(typeof f.description === 'string' + ? { description: string(f.description, 'legacy description') } + : {}), + legacy: legacyJson(f), + evidence: 'legacy_review', + reproduction: 'not_attempted', + repair: 'not_attempted', + closure: 'unknown', + }; + }), + }; } diff --git a/lib/cso/docker.ts b/lib/cso/docker.ts index 81222954c..89fa079fb 100644 --- a/lib/cso/docker.ts +++ b/lib/cso/docker.ts @@ -6,109 +6,310 @@ import { dirname } from 'node:path'; import { GROUP_LIMITS, Role, ROLE_LIMITS, Lease, admit, markSupervised, release, total } from './admission'; import { childEnvironment, executable, runProcess } from './process'; import { secureDirectory } from './state'; -export const CONTAINER_SHM_BYTES=8*1024*1024; -export const ISOLATION_POLICY_HASH=sha256(canonical({version:'cso-isolation-v1',network:'none-shared-loopback',root:'readonly',capabilities:'drop-all',privilegeEscalation:false,seccomp:'builtin',pull:'never',logging:'none',limits:GROUP_LIMITS,roles:ROLE_LIMITS,shmBytes:CONTAINER_SHM_BYTES,maxOutput:MAX_OUTPUT})); +export const CONTAINER_SHM_BYTES = 8 * 1024 * 1024; +export const ISOLATION_POLICY_HASH = sha256( + canonical({ + version: 'cso-isolation-v1', + network: 'none-shared-loopback', + root: 'readonly', + capabilities: 'drop-all', + privilegeEscalation: false, + seccomp: 'builtin', + pull: 'never', + logging: 'none', + limits: GROUP_LIMITS, + roles: ROLE_LIMITS, + shmBytes: CONTAINER_SHM_BYTES, + maxOutput: MAX_OUTPUT, + }), +); -export interface DockerEndpoint { uri: string; socket: string; executable: string; device:number; inode:number } -function dockerTimeout(deadline:number|undefined,maximum:number):number{ - if(deadline===undefined)return maximum;const remaining=deadline-Date.now(); - if(remaining<=0)throw new CsoError('DEADLINE','Docker operation reached its aggregate deadline'); - return Math.max(1,Math.min(maximum,remaining)); +export interface DockerEndpoint { + uri: string; + socket: string; + executable: string; + device: number; + inode: number; } -function deadlineExpired(deadline:number|undefined):boolean{return deadline!==undefined&&Date.now()>=deadline;} -export async function dockerEndpoint(home: string, env: Record = process.env, deadline?:number): Promise { +function dockerTimeout(deadline: number | undefined, maximum: number): number { + if (deadline === undefined) return maximum; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new CsoError('DEADLINE', 'Docker operation reached its aggregate deadline'); + return Math.max(1, Math.min(maximum, remaining)); +} +function deadlineExpired(deadline: number | undefined): boolean { + return deadline !== undefined && Date.now() >= deadline; +} +export async function dockerEndpoint( + home: string, + env: Record = process.env, + deadline?: number, +): Promise { const requestedHost = env.DOCKER_HOST; - if (requestedHost && !requestedHost.startsWith('unix:///')) throw new CsoError('ISOLATION_FAILED','Remote TCP, HTTP, SSH, and TLS Docker endpoints are refused'); + if (requestedHost && !requestedHost.startsWith('unix:///')) + throw new CsoError('ISOLATION_FAILED', 'Remote TCP, HTTP, SSH, and TLS Docker endpoints are refused'); // Reject forbidden input even on hosts where Docker is not installed. const docker = executable('docker'); let uri = requestedHost; if (!uri) { - const config = env.DOCKER_CONFIG || (env.HOME ? join(env.HOME,'.docker') : ''); - if (config && (!config.startsWith('/') || config.includes('\0') || config.split('/').includes('..'))) throw new CsoError('ISOLATION_FAILED','Docker config must be an absolute host path'); - const inspectEnv = {...childEnvironment(home),HOME:env.HOME || home,...(config?{DOCKER_CONFIG:config}:{})}; + const config = env.DOCKER_CONFIG || (env.HOME ? join(env.HOME, '.docker') : ''); + if (config && (!config.startsWith('/') || config.includes('\0') || config.split('/').includes('..'))) + throw new CsoError('ISOLATION_FAILED', 'Docker config must be an absolute host path'); + const inspectEnv = { + ...childEnvironment(home), + HOME: env.HOME || home, + ...(config ? { DOCKER_CONFIG: config } : {}), + }; let context = env.DOCKER_CONTEXT; if (!context) { - const shown = await runProcess(docker,['context','show'],{cwd:home,env:inspectEnv,raw:true,timeoutMs:dockerTimeout(deadline,5000),maxBytes:8192}); - if(deadlineExpired(deadline))throw new CsoError('DEADLINE','Docker context discovery reached the aggregate image-provisioning deadline'); - if (shown.code || shown.timedOut || shown.truncated) throw new CsoError('ISOLATION_FAILED','Effective Docker context could not be determined'); + const shown = await runProcess(docker, ['context', 'show'], { + cwd: home, + env: inspectEnv, + raw: true, + timeoutMs: dockerTimeout(deadline, 5000), + maxBytes: 8192, + }); + if (deadlineExpired(deadline)) + throw new CsoError( + 'DEADLINE', + 'Docker context discovery reached the aggregate image-provisioning deadline', + ); + if (shown.code || shown.timedOut || shown.truncated) + throw new CsoError('ISOLATION_FAILED', 'Effective Docker context could not be determined'); context = shown.stdout.trim(); } - if (!/^[A-Za-z0-9_.-]{1,100}$/.test(context)) throw new CsoError('ISOLATION_FAILED','Invalid Docker context name'); - const result = await runProcess(docker,['context','inspect',context,'--format','{{json .Endpoints.docker.Host}}'],{cwd:home,env:inspectEnv,raw:true,timeoutMs:dockerTimeout(deadline,5000),maxBytes:8192}); - if(deadlineExpired(deadline))throw new CsoError('DEADLINE','Docker context inspection reached the aggregate image-provisioning deadline'); - if (result.code || result.timedOut || result.truncated) throw new CsoError('ISOLATION_FAILED','Docker context could not be inspected without target execution'); - try { uri = JSON.parse(result.stdout.trim()); } catch { throw new CsoError('ISOLATION_FAILED','Docker context returned invalid endpoint data'); } + if (!/^[A-Za-z0-9_.-]{1,100}$/.test(context)) + throw new CsoError('ISOLATION_FAILED', 'Invalid Docker context name'); + const result = await runProcess( + docker, + ['context', 'inspect', context, '--format', '{{json .Endpoints.docker.Host}}'], + { cwd: home, env: inspectEnv, raw: true, timeoutMs: dockerTimeout(deadline, 5000), maxBytes: 8192 }, + ); + if (deadlineExpired(deadline)) + throw new CsoError( + 'DEADLINE', + 'Docker context inspection reached the aggregate image-provisioning deadline', + ); + if (result.code || result.timedOut || result.truncated) + throw new CsoError( + 'ISOLATION_FAILED', + 'Docker context could not be inspected without target execution', + ); + try { + uri = JSON.parse(result.stdout.trim()); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Docker context returned invalid endpoint data'); + } } - if (typeof uri !== 'string' || !uri.startsWith('unix:///') || uri.includes('\0') || uri.includes('..')) throw new CsoError('ISOLATION_FAILED','Only a local absolute Unix Docker socket is supported'); - const requestedSocket = uri.slice('unix://'.length);let socket='';try{socket=fs.realpathSync(requestedSocket);}catch{throw new CsoError('ISOLATION_FAILED','Pinned local Docker socket is unavailable');} - let s: fs.Stats; try { s=fs.statSync(socket); } catch { throw new CsoError('ISOLATION_FAILED','Pinned local Docker socket is unavailable'); } - if (!s.isSocket()) throw new CsoError('ISOLATION_FAILED','Docker endpoint is not a local Unix socket'); - if(deadlineExpired(deadline))throw new CsoError('DEADLINE','Docker endpoint admission reached the aggregate image-provisioning deadline'); - return {uri:`unix://${socket}`,socket,executable:docker,device:s.dev,inode:s.ino}; + if (typeof uri !== 'string' || !uri.startsWith('unix:///') || uri.includes('\0') || uri.includes('..')) + throw new CsoError('ISOLATION_FAILED', 'Only a local absolute Unix Docker socket is supported'); + const requestedSocket = uri.slice('unix://'.length); + let socket = ''; + try { + socket = fs.realpathSync(requestedSocket); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Pinned local Docker socket is unavailable'); + } + let s: fs.Stats; + try { + s = fs.statSync(socket); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Pinned local Docker socket is unavailable'); + } + if (!s.isSocket()) throw new CsoError('ISOLATION_FAILED', 'Docker endpoint is not a local Unix socket'); + if (deadlineExpired(deadline)) + throw new CsoError( + 'DEADLINE', + 'Docker endpoint admission reached the aggregate image-provisioning deadline', + ); + return { uri: `unix://${socket}`, socket, executable: docker, device: s.dev, inode: s.ino }; } -function assertEndpoint(endpoint:DockerEndpoint):void{let s:fs.Stats;try{s=fs.statSync(endpoint.socket);}catch{throw new CsoError('ISOLATION_FAILED','Pinned Docker socket disappeared');}if(!s.isSocket()||s.dev!==endpoint.device||s.ino!==endpoint.inode)throw new CsoError('ISOLATION_FAILED','Pinned Docker socket identity changed');} -const EXACT_CATALOG_IMAGE=/^[a-z0-9][a-z0-9.-]*(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/; -function assertExactCatalogImage(image:string):void{ - if(!EXACT_CATALOG_IMAGE.test(image)||image.includes('..')||image.includes('//'))throw new CsoError('INCOMPATIBLE_INPUT','Catalog image must name a fully qualified registry repository at an exact sha256 digest'); +function assertEndpoint(endpoint: DockerEndpoint): void { + let s: fs.Stats; + try { + s = fs.statSync(endpoint.socket); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Pinned Docker socket disappeared'); + } + if (!s.isSocket() || s.dev !== endpoint.device || s.ino !== endpoint.inode) + throw new CsoError('ISOLATION_FAILED', 'Pinned Docker socket identity changed'); } -export function dockerEnvironment(endpoint: DockerEndpoint, config: string): Record { - return {...childEnvironment(config),HOME:config,DOCKER_CONFIG:config,DOCKER_HOST:endpoint.uri,DOCKER_CONTEXT:'',DOCKER_TLS_VERIFY:'',DOCKER_CERT_PATH:''}; +const EXACT_CATALOG_IMAGE = /^[a-z0-9][a-z0-9.-]*(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/; +function assertExactCatalogImage(image: string): void { + if (!EXACT_CATALOG_IMAGE.test(image) || image.includes('..') || image.includes('//')) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Catalog image must name a fully qualified registry repository at an exact sha256 digest', + ); } -export function linuxCgroupAdmission(procCgroup='/proc/self/cgroup',cgroupRoot='/sys/fs/cgroup'):string[]{ - if(process.platform!=='linux')return ['cpu','memory','pids']; - let line='';try{line=fs.readFileSync(procCgroup,'utf8').split('\n').find(x=>x.startsWith('0::'))??'';}catch{return[];} - if(line){const rel=line.slice(3).replace(/^\//,''),dir=join(cgroupRoot,rel),file=join(dir,'cgroup.controllers');try{return fs.readFileSync(file,'utf8').trim().split(/\s+/).filter(Boolean);}catch{return[];}} +export function dockerEnvironment(endpoint: DockerEndpoint, config: string): Record { + return { + ...childEnvironment(config), + HOME: config, + DOCKER_CONFIG: config, + DOCKER_HOST: endpoint.uri, + DOCKER_CONTEXT: '', + DOCKER_TLS_VERIFY: '', + DOCKER_CERT_PATH: '', + }; +} +export function linuxCgroupAdmission( + procCgroup = '/proc/self/cgroup', + cgroupRoot = '/sys/fs/cgroup', +): string[] { + if (process.platform !== 'linux') return ['cpu', 'memory', 'pids']; + let line = ''; + try { + line = + fs + .readFileSync(procCgroup, 'utf8') + .split('\n') + .find((x) => x.startsWith('0::')) ?? ''; + } catch { + return []; + } + if (line) { + const rel = line.slice(3).replace(/^\//, ''), + dir = join(cgroupRoot, rel), + file = join(dir, 'cgroup.controllers'); + try { + return fs.readFileSync(file, 'utf8').trim().split(/\s+/).filter(Boolean); + } catch { + return []; + } + } // Legacy cgroup v1: each independently mounted controller is sufficient. - return ['cpu','memory','pids'].filter(controller=>fs.existsSync(join(cgroupRoot,controller))); + return ['cpu', 'memory', 'pids'].filter((controller) => fs.existsSync(join(cgroupRoot, controller))); } -export async function dockerProbe(endpoint: DockerEndpoint, home: string, deadline?:number): Promise<{version:string;security:string[]}> { +export async function dockerProbe( + endpoint: DockerEndpoint, + home: string, + deadline?: number, +): Promise<{ version: string; security: string[] }> { assertEndpoint(endpoint); - const config=secureDirectory(join(home,'docker-config')); - const r=await runProcess(endpoint.executable,['info','--format','{{json .}}'],{cwd:home,env:dockerEnvironment(endpoint,config),raw:true,timeoutMs:dockerTimeout(deadline,10_000),maxBytes:128*1024}); - if(deadlineExpired(deadline))throw new CsoError('DEADLINE','Docker capability inspection reached the aggregate image-provisioning deadline'); - if (r.code || r.timedOut || r.truncated) throw new CsoError('ISOLATION_FAILED','Local Docker daemon is not usable'); - let v:any; try {v=JSON.parse(r.stdout);} catch {throw new CsoError('ISOLATION_FAILED','Docker returned invalid capability data');} - const security=Array.isArray(v.SecurityOptions)?v.SecurityOptions:[]; - if (!v.ServerVersion || !v.MemoryLimit || !v.CpuCfsQuota || !v.PidsLimit || !security.some((x:string)=>x.includes('seccomp'))) - throw new CsoError('ISOLATION_FAILED','Docker lacks required memory, CPU, PID, or seccomp enforcement'); - const delegated=linuxCgroupAdmission();if(!['cpu','memory','pids'].every(x=>delegated.includes(x)))throw new CsoError('ISOLATION_FAILED','Linux host has not delegated CPU, memory, and PID controllers to this helper; target execution is blocked'); - if (v.LoggingDriver && typeof v.LoggingDriver !== 'string') throw new CsoError('ISOLATION_FAILED','Docker logging capability is invalid'); - return {version:v.ServerVersion,security}; + const config = secureDirectory(join(home, 'docker-config')); + const r = await runProcess(endpoint.executable, ['info', '--format', '{{json .}}'], { + cwd: home, + env: dockerEnvironment(endpoint, config), + raw: true, + timeoutMs: dockerTimeout(deadline, 10_000), + maxBytes: 128 * 1024, + }); + if (deadlineExpired(deadline)) + throw new CsoError( + 'DEADLINE', + 'Docker capability inspection reached the aggregate image-provisioning deadline', + ); + if (r.code || r.timedOut || r.truncated) + throw new CsoError('ISOLATION_FAILED', 'Local Docker daemon is not usable'); + let v: any; + try { + v = JSON.parse(r.stdout); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Docker returned invalid capability data'); + } + const security = Array.isArray(v.SecurityOptions) ? v.SecurityOptions : []; + if ( + !v.ServerVersion || + !v.MemoryLimit || + !v.CpuCfsQuota || + !v.PidsLimit || + !security.some((x: string) => x.includes('seccomp')) + ) + throw new CsoError('ISOLATION_FAILED', 'Docker lacks required memory, CPU, PID, or seccomp enforcement'); + const delegated = linuxCgroupAdmission(); + if (!['cpu', 'memory', 'pids'].every((x) => delegated.includes(x))) + throw new CsoError( + 'ISOLATION_FAILED', + 'Linux host has not delegated CPU, memory, and PID controllers to this helper; target execution is blocked', + ); + if (v.LoggingDriver && typeof v.LoggingDriver !== 'string') + throw new CsoError('ISOLATION_FAILED', 'Docker logging capability is invalid'); + return { version: v.ServerVersion, security }; } /** Read-only local image admission probe. Docker image inspect never pulls. */ -export async function dockerExactImagePresent(endpoint:DockerEndpoint,home:string,image:string,platform:'linux/amd64'|'linux/arm64',deadline?:number):Promise{ - assertExactCatalogImage(image);assertEndpoint(endpoint); - const config=secureDirectory(join(home,'docker-config')); - const result=await runProcess(endpoint.executable,['image','inspect','--format','{{json .}}',image],{ - cwd:home,env:dockerEnvironment(endpoint,config),raw:true,timeoutMs:dockerTimeout(deadline,5_000),maxBytes:128*1024, - }); +export async function dockerExactImagePresent( + endpoint: DockerEndpoint, + home: string, + image: string, + platform: 'linux/amd64' | 'linux/arm64', + deadline?: number, +): Promise { + assertExactCatalogImage(image); assertEndpoint(endpoint); - if(deadlineExpired(deadline)||result.timedOut)throw new CsoError('DEADLINE','Exact image inspection reached its bounded image-provisioning deadline'); - if(result.code||result.truncated)return false; - let inspected:any;try{inspected=JSON.parse(result.stdout);}catch{return false;} - const expectedArch=platform==='linux/arm64'?'arm64':'amd64'; - return inspected?.Os==='linux'&&inspected?.Architecture===expectedArch&& - typeof inspected?.Id==='string'&&/^sha256:[a-f0-9]{64}$/.test(inspected.Id)&& - Array.isArray(inspected?.RepoDigests)&&inspected.RepoDigests.includes(image)&& - canonical(inspected?.Config?.Entrypoint)===canonical(['/opt/cso/entrypoint'])&& - (!inspected?.Config?.Volumes||Object.keys(inspected.Config.Volumes).length===0); + const config = secureDirectory(join(home, 'docker-config')); + const result = await runProcess( + endpoint.executable, + ['image', 'inspect', '--format', '{{json .}}', image], + { + cwd: home, + env: dockerEnvironment(endpoint, config), + raw: true, + timeoutMs: dockerTimeout(deadline, 5_000), + maxBytes: 128 * 1024, + }, + ); + assertEndpoint(endpoint); + if (deadlineExpired(deadline) || result.timedOut) + throw new CsoError('DEADLINE', 'Exact image inspection reached its bounded image-provisioning deadline'); + if (result.code || result.truncated) return false; + let inspected: any; + try { + inspected = JSON.parse(result.stdout); + } catch { + return false; + } + const expectedArch = platform === 'linux/arm64' ? 'arm64' : 'amd64'; + return ( + inspected?.Os === 'linux' && + inspected?.Architecture === expectedArch && + typeof inspected?.Id === 'string' && + /^sha256:[a-f0-9]{64}$/.test(inspected.Id) && + Array.isArray(inspected?.RepoDigests) && + inspected.RepoDigests.includes(image) && + canonical(inspected?.Config?.Entrypoint) === canonical(['/opt/cso/entrypoint']) && + (!inspected?.Config?.Volumes || Object.keys(inspected.Config.Volumes).length === 0) + ); } /** Installation-only acquisition. Audits never call this and still use --pull=never. */ -export async function dockerPullExactCatalogImage(endpoint:DockerEndpoint,home:string,image:string,platform:'linux/amd64'|'linux/arm64',deadline?:number):Promise{ - assertExactCatalogImage(image);assertEndpoint(endpoint); - const config=secureDirectory(join(home,'docker-config')); - const result=await runProcess(endpoint.executable,['pull','--quiet','--platform',platform,image],{ - cwd:home,env:dockerEnvironment(endpoint,config),timeoutMs:dockerTimeout(deadline,300_000),maxBytes:128*1024, +export async function dockerPullExactCatalogImage( + endpoint: DockerEndpoint, + home: string, + image: string, + platform: 'linux/amd64' | 'linux/arm64', + deadline?: number, +): Promise { + assertExactCatalogImage(image); + assertEndpoint(endpoint); + const config = secureDirectory(join(home, 'docker-config')); + const result = await runProcess(endpoint.executable, ['pull', '--quiet', '--platform', platform, image], { + cwd: home, + env: dockerEnvironment(endpoint, config), + timeoutMs: dockerTimeout(deadline, 300_000), + maxBytes: 128 * 1024, }); assertEndpoint(endpoint); - if(deadlineExpired(deadline)||result.timedOut)throw new CsoError('DEADLINE','Qualified image pull reached its bounded preload deadline'); - if(result.code||result.truncated)throw new CsoError('PREREQUISITE','Anonymous pull of a qualified CSO image failed; allow public registry access and rerun setup'); - if(!await dockerExactImagePresent(endpoint,home,image,platform,deadline))throw new CsoError('INCOMPATIBLE_INPUT','Docker did not retain the exact qualified image digest and platform after acquisition'); + if (deadlineExpired(deadline) || result.timedOut) + throw new CsoError('DEADLINE', 'Qualified image pull reached its bounded preload deadline'); + if (result.code || result.truncated) + throw new CsoError( + 'PREREQUISITE', + 'Anonymous pull of a qualified CSO image failed; allow public registry access and rerun setup', + ); + if (!(await dockerExactImagePresent(endpoint, home, image, platform, deadline))) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Docker did not retain the exact qualified image digest and platform after acquisition', + ); } export interface ContainerSpec { - role: Role; image: string; source?: string; command: string[]; env?: Record; - readonlyFiles?: {host:string;container:string}[]; - readonlyDirectories?: {host:string;container:'/fixtures'}[]; + role: Role; + image: string; + source?: string; + command: string[]; + env?: Record; + readonlyFiles?: { host: string; container: string }[]; + readonlyDirectories?: { host: string; container: '/fixtures' }[]; /** Preparation-only mounts. Every destination is fixed by the helper. */ readonlyMetadata?: string; readonlyInputMetadata?: string; @@ -122,191 +323,700 @@ export interface ContainerSpec { /** Non-secret, fixed-user-readable PostgreSQL database-name policy. */ postgresDatabasePolicy?: string; } -export function writableAllocation(role:Role,spec:Pick={}):{temporaryBytes:number;workBytes:number;shmBytes:number;totalBytes:number}{ - const l=ROLE_LIMITS[role],mib=1024*1024,shmMiB=CONTAINER_SHM_BYTES/mib, - defaultTmpMiB=Math.min(256,Math.max(1,l.writableMiB-shmMiB-1)), - bounded=(value:number|undefined,fallback:number,label:string)=>{const selected=value??fallback;if(!Number.isSafeInteger(selected)||selected<=0||selected>GROUP_LIMITS.writableMiB*mib)throw new CsoError('INVALID_SCHEMA',`${label} tmpfs size is outside the aggregate writable-storage policy`);return selected;}, - temporaryBytes=bounded(spec.temporaryTmpfsBytes,defaultTmpMiB*mib,'Temporary'), - workBytes=bounded(spec.workTmpfsBytes,(l.writableMiB-defaultTmpMiB-shmMiB)*mib,'Work'), - extra=(spec.metadataTmpfsBytes??0)+(spec.archiveTmpfsBytes??0),totalBytes=temporaryBytes+workBytes+CONTAINER_SHM_BYTES+extra; - if(!Number.isSafeInteger(totalBytes)||totalBytes<=0)throw new CsoError('INVALID_SCHEMA','Writable mount sizes are outside the aggregate writable-storage policy'); - return{temporaryBytes,workBytes,shmBytes:CONTAINER_SHM_BYTES,totalBytes}; +export function writableAllocation( + role: Role, + spec: Pick< + ContainerSpec, + 'temporaryTmpfsBytes' | 'workTmpfsBytes' | 'metadataTmpfsBytes' | 'archiveTmpfsBytes' + > = {}, +): { temporaryBytes: number; workBytes: number; shmBytes: number; totalBytes: number } { + const l = ROLE_LIMITS[role], + mib = 1024 * 1024, + shmMiB = CONTAINER_SHM_BYTES / mib, + defaultTmpMiB = Math.min(256, Math.max(1, l.writableMiB - shmMiB - 1)), + bounded = (value: number | undefined, fallback: number, label: string) => { + const selected = value ?? fallback; + if (!Number.isSafeInteger(selected) || selected <= 0 || selected > GROUP_LIMITS.writableMiB * mib) + throw new CsoError( + 'INVALID_SCHEMA', + `${label} tmpfs size is outside the aggregate writable-storage policy`, + ); + return selected; + }, + temporaryBytes = bounded(spec.temporaryTmpfsBytes, defaultTmpMiB * mib, 'Temporary'), + workBytes = bounded(spec.workTmpfsBytes, (l.writableMiB - defaultTmpMiB - shmMiB) * mib, 'Work'), + extra = (spec.metadataTmpfsBytes ?? 0) + (spec.archiveTmpfsBytes ?? 0), + totalBytes = temporaryBytes + workBytes + CONTAINER_SHM_BYTES + extra; + if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) + throw new CsoError( + 'INVALID_SCHEMA', + 'Writable mount sizes are outside the aggregate writable-storage policy', + ); + return { temporaryBytes, workBytes, shmBytes: CONTAINER_SHM_BYTES, totalBytes }; } -export function validatePostgresDatabasePolicy(path:string):string[]{ - const stat=fs.lstatSync(path),real=fs.realpathSync(path); - if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.size<1||stat.size>4096||(stat.mode&0o777)!==0o444||real.includes(','))throw new CsoError('UNSAFE_PATH','PostgreSQL database policy must be one public-readable, immutable synthetic file'); - const body=fs.readFileSync(real,'utf8');if(!body.endsWith('\n')||body.includes('\0')||body.includes('\r'))throw new CsoError('INVALID_SCHEMA','PostgreSQL database policy framing is invalid'); - const names=body.slice(0,-1).split('\n');if(!names.length||names.length>64||new Set(names).size!==names.length||names.some(name=>!/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name)))throw new CsoError('INVALID_SCHEMA','PostgreSQL database policy contains an invalid or duplicate database name'); +export function validatePostgresDatabasePolicy(path: string): string[] { + const stat = fs.lstatSync(path), + real = fs.realpathSync(path); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1 || + stat.size < 1 || + stat.size > 4096 || + (stat.mode & 0o777) !== 0o444 || + real.includes(',') + ) + throw new CsoError( + 'UNSAFE_PATH', + 'PostgreSQL database policy must be one public-readable, immutable synthetic file', + ); + const body = fs.readFileSync(real, 'utf8'); + if (!body.endsWith('\n') || body.includes('\0') || body.includes('\r')) + throw new CsoError('INVALID_SCHEMA', 'PostgreSQL database policy framing is invalid'); + const names = body.slice(0, -1).split('\n'); + if ( + !names.length || + names.length > 64 || + new Set(names).size !== names.length || + names.some((name) => !/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name)) + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'PostgreSQL database policy contains an invalid or duplicate database name', + ); return names; } -export function validateSingleContainerProcessOutput(output:string):void{ - const lines=output.split('\n').map(line=>line.trim()).filter(Boolean); - if(lines.length!==2||!/^PID$/i.test(lines[0])||!/^\d+$/.test(lines[1]))throw new CsoError('ISOLATION_FAILED','Offline lifecycle left a background process; prepared output was withheld'); +export function validateSingleContainerProcessOutput(output: string): void { + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length !== 2 || !/^PID$/i.test(lines[0]) || !/^\d+$/.test(lines[1])) + throw new CsoError( + 'ISOLATION_FAILED', + 'Offline lifecycle left a background process; prepared output was withheld', + ); } export class DockerGroup { - private ids: {role:Role;id:string}[]=[]; private roles:Role[]=['anchor']; private writableBytesById=new Map(); private lease:Lease; private config:string; private admittedImages=new Set(); private remainingOutput=MAX_OUTPUT; - anchor=''; - private constructor(public endpoint:DockerEndpoint, public runId:string, public dir:string, public deadline:number, lease:Lease) { - this.lease=lease; this.config=secureDirectory(join(dir,'docker-config')); + private ids: { role: Role; id: string }[] = []; + private roles: Role[] = ['anchor']; + private writableBytesById = new Map(); + private lease: Lease; + private config: string; + private admittedImages = new Set(); + private remainingOutput = MAX_OUTPUT; + anchor = ''; + private constructor( + public endpoint: DockerEndpoint, + public runId: string, + public dir: string, + public deadline: number, + lease: Lease, + ) { + this.lease = lease; + this.config = secureDirectory(join(dir, 'docker-config')); } - static async create(endpoint:DockerEndpoint,runId:string,dir:string,deadline:number,anchorImage:string,watchdogPath?:string):Promise{ - if(!/^[A-Za-z0-9_.-]{1,100}$/.test(runId))throw new CsoError('INVALID_ARGUMENT','Reproduction group label is invalid'); - await dockerProbe(endpoint,dir); const group=new DockerGroup(endpoint,runId,dir,deadline,admit(endpoint.uri,runId,deadline)); - try { await group.launchWatchdog(watchdogPath); group.anchor=await group.createContainer({role:'anchor',image:anchorImage,command:['/bin/sleep','2147483647']},false); await group.docker(['start',group.anchor]); return group; } - catch(e){await group.cleanup();throw e;} + static async create( + endpoint: DockerEndpoint, + runId: string, + dir: string, + deadline: number, + anchorImage: string, + watchdogPath?: string, + ): Promise { + if (!/^[A-Za-z0-9_.-]{1,100}$/.test(runId)) + throw new CsoError('INVALID_ARGUMENT', 'Reproduction group label is invalid'); + await dockerProbe(endpoint, dir); + const group = new DockerGroup(endpoint, runId, dir, deadline, admit(endpoint.uri, runId, deadline)); + try { + await group.launchWatchdog(watchdogPath); + group.anchor = await group.createContainer( + { role: 'anchor', image: anchorImage, command: ['/bin/sleep', '2147483647'] }, + false, + ); + await group.docker(['start', group.anchor]); + return group; + } catch (e) { + await group.cleanup(); + throw e; + } } - private async launchWatchdog(explicit?:string):Promise{ - const watchdog=explicit??join(dirname(process.execPath),'gstack-cso-watchdog'); - if(!fs.existsSync(watchdog)||fs.lstatSync(watchdog).isSymbolicLink())throw new CsoError('ISOLATION_FAILED','Independent watchdog is missing from the trusted helper distribution'); - const ready=join(this.dir,'watchdog.ready');try{fs.unlinkSync(ready);}catch{} - let spawnFailed=false; - const child=spawn(watchdog,['--owner',String(process.pid),'--deadline',String(Math.ceil(this.deadline/1000)),'--run-dir',this.dir,'--docker',this.endpoint.executable,'--endpoint',this.endpoint.uri,'--socket-device',String(this.endpoint.device),'--socket-inode',String(this.endpoint.inode),'--run-label',this.runId,'--lease-path',this.lease.path,'--lease-token',this.lease.token],{cwd:this.dir,env:{PATH:'/usr/bin:/bin'},detached:true,stdio:'ignore'}); - child.once('error',()=>{spawnFailed=true;});child.unref(); - for(let i=0;i<100&&!spawnFailed&&!fs.existsSync(ready);i++)await new Promise(resolve=>setTimeout(resolve,10)); - let alive=false;try{if(child.pid){process.kill(child.pid,0);alive=true;}}catch{} - if(spawnFailed||!fs.existsSync(ready)||!alive){try{if(child.pid)process.kill(child.pid,'SIGKILL');}catch{}throw new CsoError('ISOLATION_FAILED','Independent watchdog failed its startup handshake');} + private async launchWatchdog(explicit?: string): Promise { + const watchdog = explicit ?? join(dirname(process.execPath), 'gstack-cso-watchdog'); + if (!fs.existsSync(watchdog) || fs.lstatSync(watchdog).isSymbolicLink()) + throw new CsoError( + 'ISOLATION_FAILED', + 'Independent watchdog is missing from the trusted helper distribution', + ); + const ready = join(this.dir, 'watchdog.ready'); + try { + fs.unlinkSync(ready); + } catch {} + let spawnFailed = false; + const child = spawn( + watchdog, + [ + '--owner', + String(process.pid), + '--deadline', + String(Math.ceil(this.deadline / 1000)), + '--run-dir', + this.dir, + '--docker', + this.endpoint.executable, + '--endpoint', + this.endpoint.uri, + '--socket-device', + String(this.endpoint.device), + '--socket-inode', + String(this.endpoint.inode), + '--run-label', + this.runId, + '--lease-path', + this.lease.path, + '--lease-token', + this.lease.token, + ], + { cwd: this.dir, env: { PATH: '/usr/bin:/bin' }, detached: true, stdio: 'ignore' }, + ); + child.once('error', () => { + spawnFailed = true; + }); + child.unref(); + for (let i = 0; i < 100 && !spawnFailed && !fs.existsSync(ready); i++) + await new Promise((resolve) => setTimeout(resolve, 10)); + let alive = false; + try { + if (child.pid) { + process.kill(child.pid, 0); + alive = true; + } + } catch {} + if (spawnFailed || !fs.existsSync(ready) || !alive) { + try { + if (child.pid) process.kill(child.pid, 'SIGKILL'); + } catch {} + throw new CsoError('ISOLATION_FAILED', 'Independent watchdog failed its startup handshake'); + } markSupervised(this.lease); - fs.writeFileSync(join(this.dir,'watchdog.pid'),String(child.pid)+'\n',{mode:0o600}); + fs.writeFileSync(join(this.dir, 'watchdog.pid'), String(child.pid) + '\n', { mode: 0o600 }); } - private async docker(args:string[],max=128*1024){ + private async docker(args: string[], max = 128 * 1024) { assertEndpoint(this.endpoint); - const remaining=Math.max(1,Math.min(300_000,this.deadline-Date.now())); - const r=await runProcess(this.endpoint.executable,args,{cwd:this.dir,env:dockerEnvironment(this.endpoint,this.config),raw:true,timeoutMs:remaining,maxBytes:max}); - if(r.timedOut)throw new CsoError('DEADLINE','Docker operation exceeded the reproduction deadline'); - if(r.truncated)throw new CsoError('REDACTION_FAILED','Docker output exceeded the bounded capture limit and was withheld'); - if(r.code)throw new CsoError('ISOLATION_FAILED',`Docker operation failed (${args[0]}): ${r.stderr.slice(0,200)}`); + const remaining = Math.max(1, Math.min(300_000, this.deadline - Date.now())); + const r = await runProcess(this.endpoint.executable, args, { + cwd: this.dir, + env: dockerEnvironment(this.endpoint, this.config), + raw: true, + timeoutMs: remaining, + maxBytes: max, + }); + if (r.timedOut) throw new CsoError('DEADLINE', 'Docker operation exceeded the reproduction deadline'); + if (r.truncated) + throw new CsoError( + 'REDACTION_FAILED', + 'Docker output exceeded the bounded capture limit and was withheld', + ); + if (r.code) + throw new CsoError( + 'ISOLATION_FAILED', + `Docker operation failed (${args[0]}): ${r.stderr.slice(0, 200)}`, + ); return r.stdout.trim(); } - private async admitLocalImage(image:string):Promise{ - if(this.admittedImages.has(image))return; - let raw='';try{raw=await this.docker(['image','inspect','--format','{{json .}}',image],128*1024);}catch{throw new CsoError('MISSING_INPUT',`Pinned runtime image is not already present locally: ${image}`);} - let inspected:any;try{inspected=JSON.parse(raw);}catch{throw new CsoError('ISOLATION_FAILED','Local image metadata is invalid');} - const expectedArch=process.arch==='arm64'?'arm64':'amd64',digest=image.slice(image.lastIndexOf('sha256:')); - if(inspected.Os!=='linux'||inspected.Architecture!==expectedArch||typeof inspected.Id!=='string'||!/^sha256:[a-f0-9]{64}$/.test(inspected.Id)||canonical(inspected.Config?.Entrypoint)!==canonical(['/opt/cso/entrypoint']))throw new CsoError('INCOMPATIBLE_INPUT','Pinned runtime image does not match the admitted Linux platform and fixed entrypoint'); - if(inspected.Config?.Volumes&&Object.keys(inspected.Config.Volumes).length)throw new CsoError('INCOMPATIBLE_INPUT','Pinned runtime image declares writable volumes outside the bounded storage policy'); - if(image.startsWith('sha256:')){if(inspected.Id!==image)throw new CsoError('INCOMPATIBLE_INPUT','Local image ID does not match the requested digest');} - else if(!Array.isArray(inspected.RepoDigests)||!inspected.RepoDigests.includes(image))throw new CsoError('INCOMPATIBLE_INPUT',`Local image metadata does not bind the requested repository digest ${digest}`); + private async admitLocalImage(image: string): Promise { + if (this.admittedImages.has(image)) return; + let raw = ''; + try { + raw = await this.docker(['image', 'inspect', '--format', '{{json .}}', image], 128 * 1024); + } catch { + throw new CsoError('MISSING_INPUT', `Pinned runtime image is not already present locally: ${image}`); + } + let inspected: any; + try { + inspected = JSON.parse(raw); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Local image metadata is invalid'); + } + const expectedArch = process.arch === 'arm64' ? 'arm64' : 'amd64', + digest = image.slice(image.lastIndexOf('sha256:')); + if ( + inspected.Os !== 'linux' || + inspected.Architecture !== expectedArch || + typeof inspected.Id !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(inspected.Id) || + canonical(inspected.Config?.Entrypoint) !== canonical(['/opt/cso/entrypoint']) + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Pinned runtime image does not match the admitted Linux platform and fixed entrypoint', + ); + if (inspected.Config?.Volumes && Object.keys(inspected.Config.Volumes).length) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Pinned runtime image declares writable volumes outside the bounded storage policy', + ); + if (image.startsWith('sha256:')) { + if (inspected.Id !== image) + throw new CsoError('INCOMPATIBLE_INPUT', 'Local image ID does not match the requested digest'); + } else if (!Array.isArray(inspected.RepoDigests) || !inspected.RepoDigests.includes(image)) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + `Local image metadata does not bind the requested repository digest ${digest}`, + ); this.admittedImages.add(image); } - async createContainer(spec:ContainerSpec,joinAnchor=true):Promise{ - if(!/^(?:[-./:@a-zA-Z0-9_]+@)?sha256:[a-f0-9]{64}$/.test(spec.image))throw new CsoError('INCOMPATIBLE_INPUT','Container image must use an immutable sha256 digest'); - if(!spec.command.length||!spec.command[0].startsWith('/'))throw new CsoError('INVALID_SCHEMA','Container command needs an absolute executable'); + async createContainer(spec: ContainerSpec, joinAnchor = true): Promise { + if (!/^(?:[-./:@a-zA-Z0-9_]+@)?sha256:[a-f0-9]{64}$/.test(spec.image)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Container image must use an immutable sha256 digest'); + if (!spec.command.length || !spec.command[0].startsWith('/')) + throw new CsoError('INVALID_SCHEMA', 'Container command needs an absolute executable'); await this.admitLocalImage(spec.image); - if(spec.role!=='anchor')total([...this.roles,spec.role]); - const l=ROLE_LIMITS[spec.role],mib=1024*1024, - allocation=writableAllocation(spec.role,spec),tmpBytes=allocation.temporaryBytes,workBytes=allocation.workBytes,candidateWritable=allocation.totalBytes; - if(!Number.isSafeInteger(candidateWritable)||candidateWritable<=0||[...this.writableBytesById.values()].reduce((sum,value)=>sum+value,0)+candidateWritable>GROUP_LIMITS.writableMiB*mib) - throw new CsoError('INSUFFICIENT_CAPACITY','Requested tmpfs mounts exceed the aggregate reproduction-group writable-storage limit'); - const memoryReserve=Math.min(256*mib,Math.max(16*mib,Math.floor(l.memoryMiB*mib/8))); - if(candidateWritable>l.memoryMiB*mib-memoryReserve)throw new CsoError('INSUFFICIENT_CAPACITY','Requested tmpfs mounts leave insufficient admitted memory for the container process'); - const hostUid=process.getuid?.(),hostGid=process.getgid?.();if(!Number.isInteger(hostUid)||!Number.isInteger(hostGid)||(hostUid as number)<=0||(hostGid as number)<0)throw new CsoError('ISOLATION_FAILED','Target execution requires a non-root host identity for readable private bind mounts'); - const uid=spec.role==='postgres'?10001:hostUid as number,gid=spec.role==='postgres'?10001:hostGid as number; - const args=['create','--pull=never','--label',`com.gstack.cso.run=${this.runId}`,'--label',`com.gstack.cso.role=${spec.role}`,'--log-driver=none', - '--read-only','--user',`${uid}:${gid}`,'--cap-drop','ALL','--security-opt','no-new-privileges:true','--security-opt','seccomp=builtin', - '--cpus',String(l.cpu),'--memory',`${l.memoryMiB}m`,'--memory-swap',`${l.memoryMiB}m`,'--pids-limit',String(l.pids), - '--shm-size',`${CONTAINER_SHM_BYTES}b`, - '--tmpfs',`/tmp:rw,noexec,nosuid,nodev,size=${tmpBytes},mode=1777`, - '--tmpfs',`/work:rw,nosuid,nodev,size=${workBytes},mode=700,uid=${uid},gid=${gid}`, - '--network',joinAnchor?`container:${this.anchor}`:'none','--platform',process.arch==='arm64'?'linux/arm64':'linux/amd64']; - const expectedTmpfs=new Set(['/tmp','/work']),expectedMounts=new Set(); - for(const [k,v] of Object.entries(spec.env??{})){if(!/^[A-Z][A-Z0-9_]{0,63}$/.test(k)||v.includes('\0'))throw new CsoError('INVALID_SCHEMA','Invalid explicit container environment');args.push('--env',`${k}=${v}`);} - if(spec.source){const stat=fs.lstatSync(spec.source),real=fs.realpathSync(spec.source);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(','))throw new CsoError('UNSAFE_PATH','Execution source must be one unambiguous private directory');args.push('--mount',`type=bind,src=${real},dst=/source,readonly,bind-recursive=disabled`);expectedMounts.add('/source');} - for(const f of spec.readonlyFiles??[]){const stat=fs.lstatSync(f.host),real=fs.realpathSync(f.host);if(!stat.isFile()||stat.isSymbolicLink()||real.includes(',')||!f.container.startsWith('/policy/'))throw new CsoError('UNSAFE_PATH','Trusted policy mounts must be regular files under /policy');args.push('--mount',`type=bind,src=${real},dst=${f.container},readonly,bind-recursive=disabled`);expectedMounts.add(f.container);} - if(spec.postgresDatabasePolicy){if(spec.role!=='postgres')throw new CsoError('INVALID_SCHEMA','PostgreSQL database policy can only be mounted into the fixed database role');validatePostgresDatabasePolicy(spec.postgresDatabasePolicy);args.push('--mount',`type=bind,src=${fs.realpathSync(spec.postgresDatabasePolicy)},dst=/policy/postgresql.databases,readonly,bind-recursive=disabled`);expectedMounts.add('/policy/postgresql.databases');} - for(const d of spec.readonlyDirectories??[]){const stat=fs.lstatSync(d.host),real=fs.realpathSync(d.host);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||d.container!=='/fixtures')throw new CsoError('UNSAFE_PATH','Fixture mounts must be private directories at /fixtures');args.push('--mount',`type=bind,src=${real},dst=${d.container},readonly,bind-recursive=disabled`);expectedMounts.add(d.container);} - if(Boolean(spec.readonlyArchiveDirectory)&&Boolean(spec.archiveTmpfsBytes))throw new CsoError('INVALID_SCHEMA','Preparation requires exactly one archive storage policy'); - if(Boolean(spec.readonlyMetadata)&&Boolean(spec.metadataTmpfsBytes))throw new CsoError('INVALID_SCHEMA','Preparation requires exactly one metadata storage policy'); - if(Boolean(spec.readonlyInputMetadata)!==Boolean(spec.metadataTmpfsBytes))throw new CsoError('INVALID_SCHEMA','Writable metadata tmpfs requires a separate read-only metadata input'); - const directoryMount=(host:string,destination:string,readonly:boolean)=>{const stat=fs.lstatSync(host),real=fs.realpathSync(host);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o022)!==0)throw new CsoError('UNSAFE_PATH',`Preparation ${destination} mount must be one private owned directory`);args.push('--mount',`type=bind,src=${real},dst=${destination}${readonly?',readonly':''},bind-recursive=disabled`);expectedMounts.add(destination);}; - if(spec.readonlyMetadata)directoryMount(spec.readonlyMetadata,'/metadata',true); - if(spec.readonlyInputMetadata)directoryMount(spec.readonlyInputMetadata,'/input-metadata',true); - if(spec.metadataTmpfsBytes){if(!Number.isSafeInteger(spec.metadataTmpfsBytes)||spec.metadataTmpfsBytes<=0||spec.metadataTmpfsBytes>1024*1024*1024)throw new CsoError('INVALID_SCHEMA','Preparation metadata tmpfs exceeds the 1 GiB policy');args.push('--tmpfs',`/metadata:rw,noexec,nosuid,nodev,size=${spec.metadataTmpfsBytes},mode=700,uid=${uid},gid=${gid}`);expectedTmpfs.add('/metadata');} - if(spec.archiveTmpfsBytes){if(!Number.isSafeInteger(spec.archiveTmpfsBytes)||spec.archiveTmpfsBytes<=0||spec.archiveTmpfsBytes>2*1024*1024*1024)throw new CsoError('INVALID_SCHEMA','Preparation archive tmpfs exceeds the 2 GiB group storage policy');args.push('--tmpfs',`/archives:rw,noexec,nosuid,nodev,size=${spec.archiveTmpfsBytes},mode=700,uid=${uid},gid=${gid}`);expectedTmpfs.add('/archives');} - if(spec.readonlyArchiveDirectory)directoryMount(spec.readonlyArchiveDirectory,'/archives',true); - if(spec.registrySocket){const stat=fs.lstatSync(spec.registrySocket),real=fs.realpathSync(spec.registrySocket);if(!stat.isSocket()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Registry broker mount must be one owned Unix socket');args.push('--mount',`type=bind,src=${real},dst=/run/cso-registry.sock,readonly,bind-recursive=disabled`);expectedMounts.add('/run/cso-registry.sock');} - args.push('--entrypoint','/opt/cso/entrypoint',spec.image,...spec.command); - const id=await this.docker(args,8192); if(!/^[a-f0-9]{64}$/.test(id))throw new CsoError('ISOLATION_FAILED','Docker did not return a stable container ID'); - let inspectedContainer:any;try{inspectedContainer=JSON.parse(await this.docker(['inspect','--format','{{json .}}',id],64*1024));}catch{throw new CsoError('ISOLATION_FAILED','Docker did not return valid admitted-container configuration');}const hostConfig=inspectedContainer?.HostConfig,mounts=inspectedContainer?.Mounts; - if(hostConfig?.ReadonlyRootfs!==true||hostConfig?.ShmSize!==CONTAINER_SHM_BYTES|| - !hostConfig.Tmpfs||Object.keys(hostConfig.Tmpfs).sort().join('\0')!==[...expectedTmpfs].sort().join('\0')||!Array.isArray(mounts)|| - mounts.some((mount:any)=>!mount||!((mount.Type==='bind'&&expectedMounts.has(mount.Destination))||(mount.Type==='tmpfs'&&expectedTmpfs.has(mount.Destination))))|| - mounts.filter((mount:any)=>mount?.Type==='bind').length!==expectedMounts.size) - throw new CsoError('ISOLATION_FAILED','Docker did not preserve the bounded writable-storage policy'); + if (spec.role !== 'anchor') total([...this.roles, spec.role]); + const l = ROLE_LIMITS[spec.role], + mib = 1024 * 1024, + allocation = writableAllocation(spec.role, spec), + tmpBytes = allocation.temporaryBytes, + workBytes = allocation.workBytes, + candidateWritable = allocation.totalBytes; + if ( + !Number.isSafeInteger(candidateWritable) || + candidateWritable <= 0 || + [...this.writableBytesById.values()].reduce((sum, value) => sum + value, 0) + candidateWritable > + GROUP_LIMITS.writableMiB * mib + ) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + 'Requested tmpfs mounts exceed the aggregate reproduction-group writable-storage limit', + ); + const memoryReserve = Math.min(256 * mib, Math.max(16 * mib, Math.floor((l.memoryMiB * mib) / 8))); + if (candidateWritable > l.memoryMiB * mib - memoryReserve) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + 'Requested tmpfs mounts leave insufficient admitted memory for the container process', + ); + const hostUid = process.getuid?.(), + hostGid = process.getgid?.(); + if ( + !Number.isInteger(hostUid) || + !Number.isInteger(hostGid) || + (hostUid as number) <= 0 || + (hostGid as number) < 0 + ) + throw new CsoError( + 'ISOLATION_FAILED', + 'Target execution requires a non-root host identity for readable private bind mounts', + ); + const uid = spec.role === 'postgres' ? 10001 : (hostUid as number), + gid = spec.role === 'postgres' ? 10001 : (hostGid as number); + const args = [ + 'create', + '--pull=never', + '--label', + `com.gstack.cso.run=${this.runId}`, + '--label', + `com.gstack.cso.role=${spec.role}`, + '--log-driver=none', + '--read-only', + '--user', + `${uid}:${gid}`, + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges:true', + '--security-opt', + 'seccomp=builtin', + '--cpus', + String(l.cpu), + '--memory', + `${l.memoryMiB}m`, + '--memory-swap', + `${l.memoryMiB}m`, + '--pids-limit', + String(l.pids), + '--shm-size', + `${CONTAINER_SHM_BYTES}b`, + '--tmpfs', + `/tmp:rw,noexec,nosuid,nodev,size=${tmpBytes},mode=1777`, + '--tmpfs', + `/work:rw,nosuid,nodev,size=${workBytes},mode=700,uid=${uid},gid=${gid}`, + '--network', + joinAnchor ? `container:${this.anchor}` : 'none', + '--platform', + process.arch === 'arm64' ? 'linux/arm64' : 'linux/amd64', + ]; + const expectedTmpfs = new Set(['/tmp', '/work']), + expectedMounts = new Set(); + for (const [k, v] of Object.entries(spec.env ?? {})) { + if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(k) || v.includes('\0')) + throw new CsoError('INVALID_SCHEMA', 'Invalid explicit container environment'); + args.push('--env', `${k}=${v}`); + } + if (spec.source) { + const stat = fs.lstatSync(spec.source), + real = fs.realpathSync(spec.source); + if (!stat.isDirectory() || stat.isSymbolicLink() || real.includes(',')) + throw new CsoError('UNSAFE_PATH', 'Execution source must be one unambiguous private directory'); + args.push('--mount', `type=bind,src=${real},dst=/source,readonly,bind-recursive=disabled`); + expectedMounts.add('/source'); + } + for (const f of spec.readonlyFiles ?? []) { + const stat = fs.lstatSync(f.host), + real = fs.realpathSync(f.host); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + real.includes(',') || + !f.container.startsWith('/policy/') + ) + throw new CsoError('UNSAFE_PATH', 'Trusted policy mounts must be regular files under /policy'); + args.push('--mount', `type=bind,src=${real},dst=${f.container},readonly,bind-recursive=disabled`); + expectedMounts.add(f.container); + } + if (spec.postgresDatabasePolicy) { + if (spec.role !== 'postgres') + throw new CsoError( + 'INVALID_SCHEMA', + 'PostgreSQL database policy can only be mounted into the fixed database role', + ); + validatePostgresDatabasePolicy(spec.postgresDatabasePolicy); + args.push( + '--mount', + `type=bind,src=${fs.realpathSync(spec.postgresDatabasePolicy)},dst=/policy/postgresql.databases,readonly,bind-recursive=disabled`, + ); + expectedMounts.add('/policy/postgresql.databases'); + } + for (const d of spec.readonlyDirectories ?? []) { + const stat = fs.lstatSync(d.host), + real = fs.realpathSync(d.host); + if (!stat.isDirectory() || stat.isSymbolicLink() || real.includes(',') || d.container !== '/fixtures') + throw new CsoError('UNSAFE_PATH', 'Fixture mounts must be private directories at /fixtures'); + args.push('--mount', `type=bind,src=${real},dst=${d.container},readonly,bind-recursive=disabled`); + expectedMounts.add(d.container); + } + if (Boolean(spec.readonlyArchiveDirectory) && Boolean(spec.archiveTmpfsBytes)) + throw new CsoError('INVALID_SCHEMA', 'Preparation requires exactly one archive storage policy'); + if (Boolean(spec.readonlyMetadata) && Boolean(spec.metadataTmpfsBytes)) + throw new CsoError('INVALID_SCHEMA', 'Preparation requires exactly one metadata storage policy'); + if (Boolean(spec.readonlyInputMetadata) !== Boolean(spec.metadataTmpfsBytes)) + throw new CsoError( + 'INVALID_SCHEMA', + 'Writable metadata tmpfs requires a separate read-only metadata input', + ); + const directoryMount = (host: string, destination: string, readonly: boolean) => { + const stat = fs.lstatSync(host), + real = fs.realpathSync(host); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + real.includes(',') || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o022) !== 0 + ) + throw new CsoError( + 'UNSAFE_PATH', + `Preparation ${destination} mount must be one private owned directory`, + ); + args.push( + '--mount', + `type=bind,src=${real},dst=${destination}${readonly ? ',readonly' : ''},bind-recursive=disabled`, + ); + expectedMounts.add(destination); + }; + if (spec.readonlyMetadata) directoryMount(spec.readonlyMetadata, '/metadata', true); + if (spec.readonlyInputMetadata) directoryMount(spec.readonlyInputMetadata, '/input-metadata', true); + if (spec.metadataTmpfsBytes) { + if ( + !Number.isSafeInteger(spec.metadataTmpfsBytes) || + spec.metadataTmpfsBytes <= 0 || + spec.metadataTmpfsBytes > 1024 * 1024 * 1024 + ) + throw new CsoError('INVALID_SCHEMA', 'Preparation metadata tmpfs exceeds the 1 GiB policy'); + args.push( + '--tmpfs', + `/metadata:rw,noexec,nosuid,nodev,size=${spec.metadataTmpfsBytes},mode=700,uid=${uid},gid=${gid}`, + ); + expectedTmpfs.add('/metadata'); + } + if (spec.archiveTmpfsBytes) { + if ( + !Number.isSafeInteger(spec.archiveTmpfsBytes) || + spec.archiveTmpfsBytes <= 0 || + spec.archiveTmpfsBytes > 2 * 1024 * 1024 * 1024 + ) + throw new CsoError( + 'INVALID_SCHEMA', + 'Preparation archive tmpfs exceeds the 2 GiB group storage policy', + ); + args.push( + '--tmpfs', + `/archives:rw,noexec,nosuid,nodev,size=${spec.archiveTmpfsBytes},mode=700,uid=${uid},gid=${gid}`, + ); + expectedTmpfs.add('/archives'); + } + if (spec.readonlyArchiveDirectory) directoryMount(spec.readonlyArchiveDirectory, '/archives', true); + if (spec.registrySocket) { + const stat = fs.lstatSync(spec.registrySocket), + real = fs.realpathSync(spec.registrySocket); + if ( + !stat.isSocket() || + stat.isSymbolicLink() || + real.includes(',') || + (process.getuid && stat.uid !== process.getuid()) + ) + throw new CsoError('UNSAFE_PATH', 'Registry broker mount must be one owned Unix socket'); + args.push( + '--mount', + `type=bind,src=${real},dst=/run/cso-registry.sock,readonly,bind-recursive=disabled`, + ); + expectedMounts.add('/run/cso-registry.sock'); + } + args.push('--entrypoint', '/opt/cso/entrypoint', spec.image, ...spec.command); + const id = await this.docker(args, 8192); + if (!/^[a-f0-9]{64}$/.test(id)) + throw new CsoError('ISOLATION_FAILED', 'Docker did not return a stable container ID'); + let inspectedContainer: any; + try { + inspectedContainer = JSON.parse( + await this.docker(['inspect', '--format', '{{json .}}', id], 64 * 1024), + ); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Docker did not return valid admitted-container configuration'); + } + const hostConfig = inspectedContainer?.HostConfig, + mounts = inspectedContainer?.Mounts; + if ( + hostConfig?.ReadonlyRootfs !== true || + hostConfig?.ShmSize !== CONTAINER_SHM_BYTES || + !hostConfig.Tmpfs || + Object.keys(hostConfig.Tmpfs).sort().join('\0') !== [...expectedTmpfs].sort().join('\0') || + !Array.isArray(mounts) || + mounts.some( + (mount: any) => + !mount || + !( + (mount.Type === 'bind' && expectedMounts.has(mount.Destination)) || + (mount.Type === 'tmpfs' && expectedTmpfs.has(mount.Destination)) + ), + ) || + mounts.filter((mount: any) => mount?.Type === 'bind').length !== expectedMounts.size + ) + throw new CsoError('ISOLATION_FAILED', 'Docker did not preserve the bounded writable-storage policy'); // Durable journal publication precedes in-memory admission. If this write // fails, label recovery still owns the just-created container and no // phantom role is retained in the live group. - try{fs.appendFileSync(join(this.dir,'resources.journal'),`container:${id}\n`,{mode:0o600});} - catch{throw new CsoError('PERSISTENCE_FAILED','Container resource journal could not be extended');} - this.ids.push({role:spec.role,id});this.writableBytesById.set(id,candidateWritable);if(spec.role!=='anchor')this.roles.push(spec.role);return id; + try { + fs.appendFileSync(join(this.dir, 'resources.journal'), `container:${id}\n`, { mode: 0o600 }); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Container resource journal could not be extended'); + } + this.ids.push({ role: spec.role, id }); + this.writableBytesById.set(id, candidateWritable); + if (spec.role !== 'anchor') this.roles.push(spec.role); + return id; } - async start(id:string):Promise{await this.docker(['start',id]);} - async pause(id:string):Promise{ - if(!this.ids.some(item=>item.id===id))throw new CsoError('ISOLATION_FAILED','Attempted to pause a container outside this reproduction group'); - await this.docker(['pause',id],8192); - let paused:unknown;try{paused=JSON.parse(await this.docker(['inspect','--format','{{json .State.Paused}}',id],8192));}catch{throw new CsoError('ISOLATION_FAILED','Prepared container pause state could not be proven');} - if(paused!==true)throw new CsoError('ISOLATION_FAILED','Prepared container was not frozen before export'); + async start(id: string): Promise { + await this.docker(['start', id]); } - async wait(id:string):Promise<{code:number;output:string}>{ - const code=Number(await this.docker(['wait',id],8192)); + async pause(id: string): Promise { + if (!this.ids.some((item) => item.id === id)) + throw new CsoError( + 'ISOLATION_FAILED', + 'Attempted to pause a container outside this reproduction group', + ); + await this.docker(['pause', id], 8192); + let paused: unknown; + try { + paused = JSON.parse(await this.docker(['inspect', '--format', '{{json .State.Paused}}', id], 8192)); + } catch { + throw new CsoError('ISOLATION_FAILED', 'Prepared container pause state could not be proven'); + } + if (paused !== true) + throw new CsoError('ISOLATION_FAILED', 'Prepared container was not frozen before export'); + } + async wait(id: string): Promise<{ code: number; output: string }> { + const code = Number(await this.docker(['wait', id], 8192)); // --log-driver=none means daemon logs are unavailable by design. Bounded output must come from attached runs; callers use execAttach. - return {code,output:''}; + return { code, output: '' }; } - async execCapture(id:string,command:string[],options:{workdir?:string;env?:Record}={}):Promise<{code:number;stdout:string;stderr:string}>{ - if(!command.length||!command[0].startsWith('/'))throw new CsoError('INVALID_SCHEMA','Exec needs an absolute executable'); - if(options.workdir&&!['/metadata','/work','/archives'].includes(options.workdir))throw new CsoError('INVALID_SCHEMA','Exec working directory is outside the preparation contract'); - if(this.remainingOutput<=0)throw new CsoError('REDACTION_FAILED','Reproduction-group output budget is exhausted'); - const remaining=Math.max(1,Math.min(300_000,this.deadline-Date.now())); - const args=['exec'];if(options.workdir)args.push('--workdir',options.workdir);for(const [key,value] of Object.entries(options.env??{})){if(!/^[A-Z][A-Z0-9_]{0,63}$/.test(key)||value.includes('\0'))throw new CsoError('INVALID_SCHEMA','Exec environment is invalid');args.push('--env',`${key}=${value}`);}args.push(id,...command); - const r=await runProcess(this.endpoint.executable,args,{cwd:this.dir,env:dockerEnvironment(this.endpoint,this.config),timeoutMs:remaining,maxBytes:this.remainingOutput});this.remainingOutput=Math.max(0,this.remainingOutput-r.capturedBytes); - if(r.timedOut)throw new CsoError('DEADLINE','Target command exceeded the reproduction deadline');if(r.truncated)throw new CsoError('REDACTION_FAILED','Aggregate reproduction output exceeded 1 MiB and was withheld'); - return {code:r.code,stdout:r.stdout,stderr:r.stderr}; + async execCapture( + id: string, + command: string[], + options: { workdir?: string; env?: Record } = {}, + ): Promise<{ code: number; stdout: string; stderr: string }> { + if (!command.length || !command[0].startsWith('/')) + throw new CsoError('INVALID_SCHEMA', 'Exec needs an absolute executable'); + if (options.workdir && !['/metadata', '/work', '/archives'].includes(options.workdir)) + throw new CsoError('INVALID_SCHEMA', 'Exec working directory is outside the preparation contract'); + if (this.remainingOutput <= 0) + throw new CsoError('REDACTION_FAILED', 'Reproduction-group output budget is exhausted'); + const remaining = Math.max(1, Math.min(300_000, this.deadline - Date.now())); + const args = ['exec']; + if (options.workdir) args.push('--workdir', options.workdir); + for (const [key, value] of Object.entries(options.env ?? {})) { + if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(key) || value.includes('\0')) + throw new CsoError('INVALID_SCHEMA', 'Exec environment is invalid'); + args.push('--env', `${key}=${value}`); + } + args.push(id, ...command); + const r = await runProcess(this.endpoint.executable, args, { + cwd: this.dir, + env: dockerEnvironment(this.endpoint, this.config), + timeoutMs: remaining, + maxBytes: this.remainingOutput, + }); + this.remainingOutput = Math.max(0, this.remainingOutput - r.capturedBytes); + if (r.timedOut) throw new CsoError('DEADLINE', 'Target command exceeded the reproduction deadline'); + if (r.truncated) + throw new CsoError('REDACTION_FAILED', 'Aggregate reproduction output exceeded 1 MiB and was withheld'); + return { code: r.code, stdout: r.stdout, stderr: r.stderr }; } - async execAttach(id:string,command:string[]):Promise<{code:number;output:string}>{ - const result=await this.execCapture(id,command);return{code:result.code,output:result.stdout+result.stderr}; + async execAttach(id: string, command: string[]): Promise<{ code: number; output: string }> { + const result = await this.execCapture(id, command); + return { code: result.code, output: result.stdout + result.stderr }; } - async execDetached(id:string,command:string[],workdir='/work'):Promise{ - if(!command.length||!command[0].startsWith('/')||!workdir.startsWith('/'))throw new CsoError('INVALID_SCHEMA','Detached exec needs absolute paths'); - await this.docker(['exec','--detach','--workdir',workdir,id,...command],8192); + async execDetached(id: string, command: string[], workdir = '/work'): Promise { + if (!command.length || !command[0].startsWith('/') || !workdir.startsWith('/')) + throw new CsoError('INVALID_SCHEMA', 'Detached exec needs absolute paths'); + await this.docker(['exec', '--detach', '--workdir', workdir, id, ...command], 8192); } - async startAttach(id:string):Promise<{code:number;output:string}>{ - if(this.remainingOutput<=0)throw new CsoError('REDACTION_FAILED','Reproduction-group output budget is exhausted'); - const remaining=Math.max(1,Math.min(300_000,this.deadline-Date.now())); - const r=await runProcess(this.endpoint.executable,['start','--attach',id],{cwd:this.dir,env:dockerEnvironment(this.endpoint,this.config),timeoutMs:remaining,maxBytes:this.remainingOutput});this.remainingOutput=Math.max(0,this.remainingOutput-r.capturedBytes); - if(r.timedOut)throw new CsoError('DEADLINE','Target command exceeded the reproduction deadline');if(r.truncated)throw new CsoError('REDACTION_FAILED','Aggregate reproduction output exceeded 1 MiB and was withheld'); - return {code:r.code,output:r.stdout+r.stderr}; + async startAttach(id: string): Promise<{ code: number; output: string }> { + if (this.remainingOutput <= 0) + throw new CsoError('REDACTION_FAILED', 'Reproduction-group output budget is exhausted'); + const remaining = Math.max(1, Math.min(300_000, this.deadline - Date.now())); + const r = await runProcess(this.endpoint.executable, ['start', '--attach', id], { + cwd: this.dir, + env: dockerEnvironment(this.endpoint, this.config), + timeoutMs: remaining, + maxBytes: this.remainingOutput, + }); + this.remainingOutput = Math.max(0, this.remainingOutput - r.capturedBytes); + if (r.timedOut) throw new CsoError('DEADLINE', 'Target command exceeded the reproduction deadline'); + if (r.truncated) + throw new CsoError('REDACTION_FAILED', 'Aggregate reproduction output exceeded 1 MiB and was withheld'); + return { code: r.code, output: r.stdout + r.stderr }; } - async assertOnlyInitProcess(id:string):Promise{ - if(!this.ids.some(item=>item.id===id))throw new CsoError('ISOLATION_FAILED','Attempted to inspect a container outside this reproduction group'); - validateSingleContainerProcessOutput(await this.docker(['top',id,'-eo','pid'],64*1024)); + async assertOnlyInitProcess(id: string): Promise { + if (!this.ids.some((item) => item.id === id)) + throw new CsoError( + 'ISOLATION_FAILED', + 'Attempted to inspect a container outside this reproduction group', + ); + validateSingleContainerProcessOutput(await this.docker(['top', id, '-eo', 'pid'], 64 * 1024)); } - async copyPreparedExport(id:string,containerPath:string,destination:string):Promise{ - if(!this.ids.some(item=>item.id===id))throw new CsoError('ISOLATION_FAILED','Attempted to copy from a container outside this reproduction group'); - if(!/^\/work\/\.gstack-cso-export-[a-f0-9]{24}$/.test(containerPath))throw new CsoError('INVALID_SCHEMA','Prepared export path is outside the fixed helper contract'); - const stat=fs.lstatSync(destination),real=fs.realpathSync(destination);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o022)!==0||fs.readdirSync(real).length)throw new CsoError('UNSAFE_PATH','Prepared inert export destination must be one empty private owned directory'); + async copyPreparedExport(id: string, containerPath: string, destination: string): Promise { + if (!this.ids.some((item) => item.id === id)) + throw new CsoError( + 'ISOLATION_FAILED', + 'Attempted to copy from a container outside this reproduction group', + ); + if (!/^\/work\/\.gstack-cso-export-[a-f0-9]{24}$/.test(containerPath)) + throw new CsoError('INVALID_SCHEMA', 'Prepared export path is outside the fixed helper contract'); + const stat = fs.lstatSync(destination), + real = fs.realpathSync(destination); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + real.includes(',') || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o022) !== 0 || + fs.readdirSync(real).length + ) + throw new CsoError( + 'UNSAFE_PATH', + 'Prepared inert export destination must be one empty private owned directory', + ); // Only the qualified helper's regular-blob export is copied. Application // output is reconstructed later by host no-follow writes. - await this.docker(['cp',`${id}:${containerPath}/.`,real],8192); + await this.docker(['cp', `${id}:${containerPath}/.`, real], 8192); } - async copyAcquisitionExport(id:string,containerPath:string,destination:string):Promise{ - if(!this.ids.some(item=>item.id===id))throw new CsoError('ISOLATION_FAILED','Attempted to copy output from a container outside this reproduction group'); - if(!/^\/archives\/\.gstack-cso-acquisition-export-[a-f0-9]{24}$/.test(containerPath))throw new CsoError('INVALID_SCHEMA','Acquisition export path is outside the fixed helper contract'); - const stat=fs.lstatSync(destination),real=fs.realpathSync(destination);if(!stat.isDirectory()||stat.isSymbolicLink()||real.includes(',')||(process.getuid&&stat.uid!==process.getuid())||(stat.mode&0o022)!==0||fs.readdirSync(real).length)throw new CsoError('UNSAFE_PATH','Acquisition output must be one empty private owned directory'); - await this.docker(['cp',`${id}:${containerPath}/.`,real],8192); + async copyAcquisitionExport(id: string, containerPath: string, destination: string): Promise { + if (!this.ids.some((item) => item.id === id)) + throw new CsoError( + 'ISOLATION_FAILED', + 'Attempted to copy output from a container outside this reproduction group', + ); + if (!/^\/archives\/\.gstack-cso-acquisition-export-[a-f0-9]{24}$/.test(containerPath)) + throw new CsoError('INVALID_SCHEMA', 'Acquisition export path is outside the fixed helper contract'); + const stat = fs.lstatSync(destination), + real = fs.realpathSync(destination); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + real.includes(',') || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o022) !== 0 || + fs.readdirSync(real).length + ) + throw new CsoError('UNSAFE_PATH', 'Acquisition output must be one empty private owned directory'); + await this.docker(['cp', `${id}:${containerPath}/.`, real], 8192); } - async removeContainer(id:string):Promise{ - const index=this.ids.findIndex(item=>item.id===id);if(index<0)throw new CsoError('ISOLATION_FAILED','Attempted to remove a container outside this reproduction group'); - const present=await this.docker(['ps','--all','--quiet','--no-trunc','--filter',`id=${id}`],8192);if(present&&present!==id)throw new CsoError('ISOLATION_FAILED','Docker returned an inexact resource identity during cleanup');if(present)await this.docker(['rm','--force','--volumes',id],8192); - const [{role}]=this.ids.splice(index,1);this.writableBytesById.delete(id);const roleIndex=this.roles.lastIndexOf(role);if(roleIndex>=0)this.roles.splice(roleIndex,1); + async removeContainer(id: string): Promise { + const index = this.ids.findIndex((item) => item.id === id); + if (index < 0) + throw new CsoError( + 'ISOLATION_FAILED', + 'Attempted to remove a container outside this reproduction group', + ); + const present = await this.docker(['ps', '--all', '--quiet', '--no-trunc', '--filter', `id=${id}`], 8192); + if (present && present !== id) + throw new CsoError('ISOLATION_FAILED', 'Docker returned an inexact resource identity during cleanup'); + if (present) await this.docker(['rm', '--force', '--volumes', id], 8192); + const [{ role }] = this.ids.splice(index, 1); + this.writableBytesById.delete(id); + const roleIndex = this.roles.lastIndexOf(role); + if (roleIndex >= 0) this.roles.splice(roleIndex, 1); } - async cleanup():Promise{ - let failure:unknown;try{ - const labeled=await this.docker(['ps','--all','--quiet','--no-trunc','--filter',`label=com.gstack.cso.run=${this.runId}`],128*1024),targets=new Set(this.ids.map(item=>item.id)); - for(const id of labeled.split('\n').filter(Boolean)){if(!/^[a-f0-9]{64}$/.test(id))throw new CsoError('ISOLATION_FAILED','Docker returned an invalid labeled resource identity during cleanup');targets.add(id);} - for(const id of [...targets].reverse()){const present=await this.docker(['ps','--all','--quiet','--no-trunc','--filter',`id=${id}`],8192);if(present&&present!==id)throw new CsoError('ISOLATION_FAILED','Docker returned an inexact resource identity during cleanup');if(present)await this.docker(['rm','--force','--volumes',id],8192);} - const remaining=await this.docker(['ps','--all','--quiet','--no-trunc','--filter',`label=com.gstack.cso.run=${this.runId}`],8192);if(remaining)throw new CsoError('ISOLATION_FAILED','Run-owned Docker resources remain after cleanup'); - }catch(error){failure=error;} - if(failure)throw new CsoError('ISOLATION_FAILED','Exact reproduction cleanup failed; verification evidence was withheld and the watchdog remains responsible'); - this.ids=[];this.writableBytesById.clear();release(this.lease);fs.writeFileSync(join(this.dir,'watchdog.terminal'),'cleanup complete\n',{mode:0o600,flag:'wx'}); - const stopped=join(this.dir,'watchdog.stopped');for(let i=0;i<500&&!fs.existsSync(stopped);i++)await new Promise(resolve=>setTimeout(resolve,10));if(!fs.existsSync(stopped))throw new CsoError('ISOLATION_FAILED','Docker watchdog did not acknowledge exact lease cleanup'); + async cleanup(): Promise { + let failure: unknown; + try { + const labeled = await this.docker( + ['ps', '--all', '--quiet', '--no-trunc', '--filter', `label=com.gstack.cso.run=${this.runId}`], + 128 * 1024, + ), + targets = new Set(this.ids.map((item) => item.id)); + for (const id of labeled.split('\n').filter(Boolean)) { + if (!/^[a-f0-9]{64}$/.test(id)) + throw new CsoError( + 'ISOLATION_FAILED', + 'Docker returned an invalid labeled resource identity during cleanup', + ); + targets.add(id); + } + for (const id of [...targets].reverse()) { + const present = await this.docker( + ['ps', '--all', '--quiet', '--no-trunc', '--filter', `id=${id}`], + 8192, + ); + if (present && present !== id) + throw new CsoError( + 'ISOLATION_FAILED', + 'Docker returned an inexact resource identity during cleanup', + ); + if (present) await this.docker(['rm', '--force', '--volumes', id], 8192); + } + const remaining = await this.docker( + ['ps', '--all', '--quiet', '--no-trunc', '--filter', `label=com.gstack.cso.run=${this.runId}`], + 8192, + ); + if (remaining) + throw new CsoError('ISOLATION_FAILED', 'Run-owned Docker resources remain after cleanup'); + } catch (error) { + failure = error; + } + if (failure) + throw new CsoError( + 'ISOLATION_FAILED', + 'Exact reproduction cleanup failed; verification evidence was withheld and the watchdog remains responsible', + ); + this.ids = []; + this.writableBytesById.clear(); + release(this.lease); + fs.writeFileSync(join(this.dir, 'watchdog.terminal'), 'cleanup complete\n', { mode: 0o600, flag: 'wx' }); + const stopped = join(this.dir, 'watchdog.stopped'); + for (let i = 0; i < 500 && !fs.existsSync(stopped); i++) + await new Promise((resolve) => setTimeout(resolve, 10)); + if (!fs.existsSync(stopped)) + throw new CsoError('ISOLATION_FAILED', 'Docker watchdog did not acknowledge exact lease cleanup'); } } diff --git a/lib/cso/history.ts b/lib/cso/history.ts index ea50e3bbd..86c6159cd 100644 --- a/lib/cso/history.ts +++ b/lib/cso/history.ts @@ -1,49 +1,100 @@ /** Decode the two Git path tokens in a `diff --git` header. */ -function token(source:string,offset:number):{value:string;next:number}|undefined{ - if(source[offset]!=='"'){ - const end=source.indexOf(' ',offset),next=end<0?source.length:end; - if(next===offset)return; - return{value:source.slice(offset,next),next}; +function token(source: string, offset: number): { value: string; next: number } | undefined { + if (source[offset] !== '"') { + const end = source.indexOf(' ', offset), + next = end < 0 ? source.length : end; + if (next === offset) return; + return { value: source.slice(offset, next), next }; } - const bytes:number[]=[];let at=offset+1; - const append=(value:string)=>bytes.push(...new TextEncoder().encode(value)); - while(at=source.length)return; - const escaped=source[at++],mapped:{[key:string]:string}={a:'\x07',b:'\b',f:'\f',n:'\n',r:'\r',t:'\t',v:'\v','\\':'\\','"':'"'}; - if(mapped[escaped]!==undefined){append(mapped[escaped]);continue;} - if(/[0-7]/.test(escaped)&&/^[0-7]{2}/.test(source.slice(at,at+2))){bytes.push(Number.parseInt(escaped+source.slice(at,at+2),8));at+=2;continue;} + const bytes: number[] = []; + let at = offset + 1; + const append = (value: string) => bytes.push(...new TextEncoder().encode(value)); + while (at < source.length) { + const value = source[at++]; + if (value === '"') + return { value: new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(bytes)), next: at }; + if (value !== '\\') { + append(value); + continue; + } + if (at >= source.length) return; + const escaped = source[at++], + mapped: { [key: string]: string } = { + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '\\': '\\', + '"': '"', + }; + if (mapped[escaped] !== undefined) { + append(mapped[escaped]); + continue; + } + if (/[0-7]/.test(escaped) && /^[0-7]{2}/.test(source.slice(at, at + 2))) { + bytes.push(Number.parseInt(escaped + source.slice(at, at + 2), 8)); + at += 2; + continue; + } return; } } -export function gitDiffHeaderPaths(line:string):[string,string]|undefined{ - const prefix='diff --git ';if(!line.startsWith(prefix))return; - try{ - const left=token(line,prefix.length);if(!left||line[left.next]!==' ')return; - const right=token(line,left.next+1);if(!right||right.next!==line.length)return; - return[left.value,right.value]; - }catch{return;} +export function gitDiffHeaderPaths(line: string): [string, string] | undefined { + const prefix = 'diff --git '; + if (!line.startsWith(prefix)) return; + try { + const left = token(line, prefix.length); + if (!left || line[left.next] !== ' ') return; + const right = token(line, left.next + 1); + if (!right || right.next !== line.length) return; + return [left.value, right.value]; + } catch { + return; + } } /** Return only exact path hunks, keeping one commit preamble per matching commit. */ -export function historyForPath(raw:string,path:string):string|undefined{ - const expected=new Set([`a/${path}`,`b/${path}`]),output:string[]=[],lines=raw.split('\n'); - let preamble:string[]=[],section:string[]|undefined,include=false,preambleEmitted=false; - const flush=()=>{ - if(section&&include){if(!preambleEmitted){output.push(...preamble);preambleEmitted=true;}output.push(...section);} - section=undefined;include=false; - }; - for(const line of lines){ - if(line.startsWith('commit ')){flush();preamble=[line];preambleEmitted=false;continue;} - if(line.startsWith('diff --git ')){ - flush();section=[line];const paths=gitDiffHeaderPaths(line);include=Boolean(paths&&(expected.has(paths[0])||expected.has(paths[1])));continue; +export function historyForPath(raw: string, path: string): string | undefined { + const expected = new Set([`a/${path}`, `b/${path}`]), + output: string[] = [], + lines = raw.split('\n'); + let preamble: string[] = [], + section: string[] | undefined, + include = false, + preambleEmitted = false; + const flush = () => { + if (section && include) { + if (!preambleEmitted) { + output.push(...preamble); + preambleEmitted = true; + } + output.push(...section); } - if(section)section.push(line);else preamble.push(line); + section = undefined; + include = false; + }; + for (const line of lines) { + if (line.startsWith('commit ')) { + flush(); + preamble = [line]; + preambleEmitted = false; + continue; + } + if (line.startsWith('diff --git ')) { + flush(); + section = [line]; + const paths = gitDiffHeaderPaths(line); + include = Boolean(paths && (expected.has(paths[0]) || expected.has(paths[1]))); + continue; + } + if (section) section.push(line); + else preamble.push(line); } flush(); - while(output.at(-1)==='')output.pop(); - return output.length?output.join('\n'):undefined; + while (output.at(-1) === '') output.pop(); + return output.length ? output.join('\n') : undefined; } diff --git a/lib/cso/image-provisioning.ts b/lib/cso/image-provisioning.ts index a707a207b..645e1ad7d 100644 --- a/lib/cso/image-provisioning.ts +++ b/lib/cso/image-provisioning.ts @@ -8,175 +8,413 @@ import { validateScannerCatalog, type ScannerCatalog } from './scanner-catalog'; import { secureDirectory } from './state'; export interface QualifiedCatalogImage { - kind:'runtime'|'scanner'; - id:string; - image:string; - platform:RuntimePlatform; + kind: 'runtime' | 'scanner'; + id: string; + image: string; + platform: RuntimePlatform; } export interface CatalogImageSession { - readonly docker:{endpoint:string;version:string;security:string[]}; - present(entry:QualifiedCatalogImage,deadline?:number):Promise; - pull(entry:QualifiedCatalogImage,deadline?:number):Promise; - close():void; + readonly docker: { endpoint: string; version: string; security: string[] }; + present(entry: QualifiedCatalogImage, deadline?: number): Promise; + pull(entry: QualifiedCatalogImage, deadline?: number): Promise; + close(): void; } /** Doctor performs concurrent, read-only checks inside its 30-second contract. */ -export const CATALOG_IMAGE_INSPECTION_BUDGET_MS=30_000; -export const DEFAULT_CATALOG_IMAGE_BUDGET_MS=30_000; -export const MIN_CATALOG_IMAGE_BUDGET_SECONDS=5; -export const MAX_CATALOG_IMAGE_BUDGET_SECONDS=300; -export const MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS=60*60_000; -const CATALOG_IMAGE_ADMISSION_BUDGET_MS=30_000; -export interface CatalogImageProvisioningPolicy {perImageMs:number;aggregateMs:number;} +export const CATALOG_IMAGE_INSPECTION_BUDGET_MS = 30_000; +export const DEFAULT_CATALOG_IMAGE_BUDGET_MS = 30_000; +export const MIN_CATALOG_IMAGE_BUDGET_SECONDS = 5; +export const MAX_CATALOG_IMAGE_BUDGET_SECONDS = 300; +export const MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS = 60 * 60_000; +const CATALOG_IMAGE_ADMISSION_BUDGET_MS = 30_000; +export interface CatalogImageProvisioningPolicy { + perImageMs: number; + aggregateMs: number; +} /** * Give every declared native-platform image a bounded opportunity to download. * The one-hour ceiling admits the current eleven-image catalog even at the * maximum configurable five-minute allowance. */ -export function catalogImageProvisioningPolicy(imageCount:number,requestedSeconds?:string):CatalogImageProvisioningPolicy{ - if(!Number.isSafeInteger(imageCount)||imageCount<0)throw new CsoError('INVALID_ARGUMENT','Catalog image count is invalid'); - let seconds=DEFAULT_CATALOG_IMAGE_BUDGET_MS/1000; - if(requestedSeconds!==undefined){ - if(!/^[0-9]+$/.test(requestedSeconds))throw new CsoError('INVALID_ARGUMENT','--per-image-seconds requires a whole number'); - seconds=Number(requestedSeconds); - if(secondsMAX_CATALOG_IMAGE_BUDGET_SECONDS)throw new CsoError('INVALID_ARGUMENT',`--per-image-seconds must be ${MIN_CATALOG_IMAGE_BUDGET_SECONDS}..${MAX_CATALOG_IMAGE_BUDGET_SECONDS}`); +export function catalogImageProvisioningPolicy( + imageCount: number, + requestedSeconds?: string, +): CatalogImageProvisioningPolicy { + if (!Number.isSafeInteger(imageCount) || imageCount < 0) + throw new CsoError('INVALID_ARGUMENT', 'Catalog image count is invalid'); + let seconds = DEFAULT_CATALOG_IMAGE_BUDGET_MS / 1000; + if (requestedSeconds !== undefined) { + if (!/^[0-9]+$/.test(requestedSeconds)) + throw new CsoError('INVALID_ARGUMENT', '--per-image-seconds requires a whole number'); + seconds = Number(requestedSeconds); + if (seconds < MIN_CATALOG_IMAGE_BUDGET_SECONDS || seconds > MAX_CATALOG_IMAGE_BUDGET_SECONDS) + throw new CsoError( + 'INVALID_ARGUMENT', + `--per-image-seconds must be ${MIN_CATALOG_IMAGE_BUDGET_SECONDS}..${MAX_CATALOG_IMAGE_BUDGET_SECONDS}`, + ); } - const perImageMs=seconds*1000,aggregateMs=CATALOG_IMAGE_ADMISSION_BUDGET_MS+imageCount*perImageMs; - if(!Number.isSafeInteger(aggregateMs)||aggregateMs>MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS)throw new CsoError('INCOMPATIBLE_INPUT','Qualified image catalog exceeds the bounded setup preload capacity'); - return{perImageMs,aggregateMs}; + const perImageMs = seconds * 1000, + aggregateMs = CATALOG_IMAGE_ADMISSION_BUDGET_MS + imageCount * perImageMs; + if (!Number.isSafeInteger(aggregateMs) || aggregateMs > MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Qualified image catalog exceeds the bounded setup preload capacity', + ); + return { perImageMs, aggregateMs }; } -export type CatalogImageSessionFactory=(deadline:number)=>Promise; +export type CatalogImageSessionFactory = (deadline: number) => Promise; export interface CatalogImageAvailability extends QualifiedCatalogImage { - status:'available'|'unavailable'; - reason?:string; + status: 'available' | 'unavailable'; + reason?: string; } export interface CatalogImageInspection { - docker:{status:'ready'|'missing';detail:unknown}; - images:CatalogImageAvailability[]; + docker: { status: 'ready' | 'missing'; detail: unknown }; + images: CatalogImageAvailability[]; } export interface CatalogImageProvisionResult { - schemaVersion:1; - status:'complete'|'partial'|'not_available'; - downloads:true; - platform:RuntimePlatform; - requested:number; - inspected:number; - alreadyPresent:number; - downloaded:number; - deadlineReached:boolean; - unavailable:CatalogImageAvailability[]; - summary:string; + schemaVersion: 1; + status: 'complete' | 'partial' | 'not_available'; + downloads: true; + platform: RuntimePlatform; + requested: number; + inspected: number; + alreadyPresent: number; + downloaded: number; + deadlineReached: boolean; + unavailable: CatalogImageAvailability[]; + summary: string; } -export function qualifiedCatalogImages(runtimeCatalog:RuntimeCatalog,scannerCatalog:ScannerCatalog,platform:RuntimePlatform):QualifiedCatalogImage[]{ - validateRuntimeCatalog(runtimeCatalog);validateScannerCatalog(scannerCatalog); - const entries:QualifiedCatalogImage[]=[ - ...runtimeCatalog.runtimes.filter(item=>item.platform===platform).map(item=>({kind:'runtime' as const,id:item.id,image:item.image,platform:item.platform})), - ...scannerCatalog.scanners.filter(item=>item.platform===platform).map(item=>({kind:'scanner' as const,id:item.id,image:item.image,platform:item.platform})), +export function qualifiedCatalogImages( + runtimeCatalog: RuntimeCatalog, + scannerCatalog: ScannerCatalog, + platform: RuntimePlatform, +): QualifiedCatalogImage[] { + validateRuntimeCatalog(runtimeCatalog); + validateScannerCatalog(scannerCatalog); + const entries: QualifiedCatalogImage[] = [ + ...runtimeCatalog.runtimes + .filter((item) => item.platform === platform) + .map((item) => ({ kind: 'runtime' as const, id: item.id, image: item.image, platform: item.platform })), + ...scannerCatalog.scanners + .filter((item) => item.platform === platform) + .map((item) => ({ kind: 'scanner' as const, id: item.id, image: item.image, platform: item.platform })), ]; - const identities=new Set(); - for(const entry of entries){ - const identity=`${entry.kind}:${entry.id}`; - if(identities.has(identity))throw new CsoError('INCOMPATIBLE_INPUT','Qualified image catalogs contain a duplicate identity'); + const identities = new Set(); + for (const entry of entries) { + const identity = `${entry.kind}:${entry.id}`; + if (identities.has(identity)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Qualified image catalogs contain a duplicate identity'); identities.add(identity); } - return entries.sort((left,right)=>`${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)); + return entries.sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)); } -function controlledReason(error:unknown,fallback:string):string{ - return error instanceof CsoError?error.message:fallback; +function controlledReason(error: unknown, fallback: string): string { + return error instanceof CsoError ? error.message : fallback; } -export async function inspectCatalogImages(entries:QualifiedCatalogImage[],open:CatalogImageSessionFactory,deadline=Date.now()+CATALOG_IMAGE_INSPECTION_BUDGET_MS):Promise{ - let session:CatalogImageSession; - try{session=await open(deadline);}catch(error){ - const detail=controlledReason(error,'Local Docker is unavailable for exact catalog image inspection'); - return{docker:{status:'missing',detail},images:entries.map(entry=>({...entry,status:'unavailable',reason:detail}))}; +export async function inspectCatalogImages( + entries: QualifiedCatalogImage[], + open: CatalogImageSessionFactory, + deadline = Date.now() + CATALOG_IMAGE_INSPECTION_BUDGET_MS, +): Promise { + let session: CatalogImageSession; + try { + session = await open(deadline); + } catch (error) { + const detail = controlledReason(error, 'Local Docker is unavailable for exact catalog image inspection'); + return { + docker: { status: 'missing', detail }, + images: entries.map((entry) => ({ ...entry, status: 'unavailable', reason: detail })), + }; } - try{ + try { // Read-only daemon lookups run together so doctor remains within its // 30-second contract even when a local Docker client is slow to fail. - const images=await Promise.all(entries.map(async(entry):Promise=>{ - try{const present=await session.present(entry);if(Date.now()>=deadline)throw new CsoError('DEADLINE','Exact image inspection reached the aggregate image-provisioning deadline');return{...entry,status:present?'available':'unavailable',...(present?{}:{reason:'Exact qualified image is not present in the local Docker daemon'})};} - catch(error){return{...entry,status:'unavailable',reason:controlledReason(error,'Exact qualified image could not be inspected safely')};} - })); - return{docker:{status:'ready',detail:session.docker},images}; - }finally{session.close();} + const images = await Promise.all( + entries.map(async (entry): Promise => { + try { + const present = await session.present(entry); + if (Date.now() >= deadline) + throw new CsoError( + 'DEADLINE', + 'Exact image inspection reached the aggregate image-provisioning deadline', + ); + return { + ...entry, + status: present ? 'available' : 'unavailable', + ...(present ? {} : { reason: 'Exact qualified image is not present in the local Docker daemon' }), + }; + } catch (error) { + return { + ...entry, + status: 'unavailable', + reason: controlledReason(error, 'Exact qualified image could not be inspected safely'), + }; + } + }), + ); + return { docker: { status: 'ready', detail: session.docker }, images }; + } finally { + session.close(); + } } -export async function provisionCatalogImages(entries:QualifiedCatalogImage[],platform:RuntimePlatform,open:CatalogImageSessionFactory,deadline=Date.now()+catalogImageProvisioningPolicy(entries.length).aggregateMs,perImageBudgetMs=DEFAULT_CATALOG_IMAGE_BUDGET_MS):Promise{ - if(!entries.length)return{schemaVersion:1,status:'complete',downloads:true,platform,requested:0,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached:false,unavailable:[],summary:'No qualified CSO images are published for this platform; static audits remain available.'}; - if(!Number.isSafeInteger(perImageBudgetMs)||perImageBudgetMs<1||perImageBudgetMs>MAX_CATALOG_IMAGE_BUDGET_SECONDS*1000)throw new CsoError('INVALID_ARGUMENT','Catalog per-image budget is invalid'); - const deadlineReason='The bounded aggregate CSO image preload deadline was reached'; - if(Date.now()>=deadline){const unavailable=entries.map(entry=>({...entry,status:'unavailable' as const,reason:deadlineReason}));return{schemaVersion:1,status:'partial',downloads:true,platform,requested:entries.length,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached:true,unavailable,summary:`Qualified CSO image preload partial: 0/${entries.length} available; ${deadlineReason.toLowerCase()}. Rerun setup to continue.`};} - let session:CatalogImageSession; - try{session=await open(deadline);}catch(error){ - const reason=controlledReason(error,'Local Docker is unavailable for qualified image provisioning'),unavailable=entries.map(entry=>({...entry,status:'unavailable' as const,reason})); - const deadlineReached=error instanceof CsoError&&error.code==='DEADLINE'; - return{schemaVersion:1,status:deadlineReached?'partial':'not_available',downloads:true,platform,requested:entries.length,inspected:0,alreadyPresent:0,downloaded:0,deadlineReached,unavailable,summary:deadlineReached?`Qualified CSO image preload partial: 0/${entries.length} available; ${reason}. Rerun setup to continue.`:`Qualified CSO images were not preloaded: ${reason}. Rerun setup after the prerequisite is available.`}; +export async function provisionCatalogImages( + entries: QualifiedCatalogImage[], + platform: RuntimePlatform, + open: CatalogImageSessionFactory, + deadline = Date.now() + catalogImageProvisioningPolicy(entries.length).aggregateMs, + perImageBudgetMs = DEFAULT_CATALOG_IMAGE_BUDGET_MS, +): Promise { + if (!entries.length) + return { + schemaVersion: 1, + status: 'complete', + downloads: true, + platform, + requested: 0, + inspected: 0, + alreadyPresent: 0, + downloaded: 0, + deadlineReached: false, + unavailable: [], + summary: 'No qualified CSO images are published for this platform; static audits remain available.', + }; + if ( + !Number.isSafeInteger(perImageBudgetMs) || + perImageBudgetMs < 1 || + perImageBudgetMs > MAX_CATALOG_IMAGE_BUDGET_SECONDS * 1000 + ) + throw new CsoError('INVALID_ARGUMENT', 'Catalog per-image budget is invalid'); + const deadlineReason = 'The bounded aggregate CSO image preload deadline was reached'; + if (Date.now() >= deadline) { + const unavailable = entries.map((entry) => ({ + ...entry, + status: 'unavailable' as const, + reason: deadlineReason, + })); + return { + schemaVersion: 1, + status: 'partial', + downloads: true, + platform, + requested: entries.length, + inspected: 0, + alreadyPresent: 0, + downloaded: 0, + deadlineReached: true, + unavailable, + summary: `Qualified CSO image preload partial: 0/${entries.length} available; ${deadlineReason.toLowerCase()}. Rerun setup to continue.`, + }; } - let inspected=0,alreadyPresent=0,downloaded=0,pullBlocked='',deadlineReached=false,perImageTimeouts=0;const unavailable:CatalogImageAvailability[]=[]; - try{ - for(let index=0;index=deadline){deadlineReached=true;for(const remaining of entries.slice(index))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;} - const imageDeadline=Math.min(deadline,Date.now()+perImageBudgetMs),perImageReason=`The ${Math.ceil(perImageBudgetMs/1000)}-second per-image CSO preload deadline was reached`; - let present=false; - try{ - present=await session.present(entry,imageDeadline);if(Date.now()>=imageDeadline)throw new CsoError('DEADLINE',imageDeadline===deadline?'Exact image inspection reached the aggregate image-provisioning deadline':perImageReason);inspected++; - if(present){alreadyPresent++;continue;} - }catch(error){ - if(error instanceof CsoError&&error.code==='DEADLINE'){ - if(Date.now()>=deadline){deadlineReached=true;unavailable.push({...entry,status:'unavailable',reason:error.message});for(const remaining of entries.slice(index+1))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;} - perImageTimeouts++;unavailable.push({...entry,status:'unavailable',reason:perImageReason});continue; + let session: CatalogImageSession; + try { + session = await open(deadline); + } catch (error) { + const reason = controlledReason(error, 'Local Docker is unavailable for qualified image provisioning'), + unavailable = entries.map((entry) => ({ ...entry, status: 'unavailable' as const, reason })); + const deadlineReached = error instanceof CsoError && error.code === 'DEADLINE'; + return { + schemaVersion: 1, + status: deadlineReached ? 'partial' : 'not_available', + downloads: true, + platform, + requested: entries.length, + inspected: 0, + alreadyPresent: 0, + downloaded: 0, + deadlineReached, + unavailable, + summary: deadlineReached + ? `Qualified CSO image preload partial: 0/${entries.length} available; ${reason}. Rerun setup to continue.` + : `Qualified CSO images were not preloaded: ${reason}. Rerun setup after the prerequisite is available.`, + }; + } + let inspected = 0, + alreadyPresent = 0, + downloaded = 0, + pullBlocked = '', + deadlineReached = false, + perImageTimeouts = 0; + const unavailable: CatalogImageAvailability[] = []; + try { + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (Date.now() >= deadline) { + deadlineReached = true; + for (const remaining of entries.slice(index)) + unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); + break; + } + const imageDeadline = Math.min(deadline, Date.now() + perImageBudgetMs), + perImageReason = `The ${Math.ceil(perImageBudgetMs / 1000)}-second per-image CSO preload deadline was reached`; + let present = false; + try { + present = await session.present(entry, imageDeadline); + if (Date.now() >= imageDeadline) + throw new CsoError( + 'DEADLINE', + imageDeadline === deadline + ? 'Exact image inspection reached the aggregate image-provisioning deadline' + : perImageReason, + ); + inspected++; + if (present) { + alreadyPresent++; + continue; } - unavailable.push({...entry,status:'unavailable',reason:controlledReason(error,'Exact qualified image could not be inspected safely')});continue; + } catch (error) { + if (error instanceof CsoError && error.code === 'DEADLINE') { + if (Date.now() >= deadline) { + deadlineReached = true; + unavailable.push({ ...entry, status: 'unavailable', reason: error.message }); + for (const remaining of entries.slice(index + 1)) + unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); + break; + } + perImageTimeouts++; + unavailable.push({ ...entry, status: 'unavailable', reason: perImageReason }); + continue; + } + unavailable.push({ + ...entry, + status: 'unavailable', + reason: controlledReason(error, 'Exact qualified image could not be inspected safely'), + }); + continue; } // A registry failure blocks further network attempts, but read-only local // inspection continues so the setup summary never calls a cached digest // unavailable merely because it sorts after the failed pull. - if(pullBlocked){unavailable.push({...entry,status:'unavailable',reason:`Network provisioning stopped after an anonymous registry prerequisite failed: ${pullBlocked}`});continue;} - if(Date.now()>=deadline){deadlineReached=true;unavailable.push({...entry,status:'unavailable',reason:deadlineReason});for(const remaining of entries.slice(index+1))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;} - try{await session.pull(entry,imageDeadline);if(Date.now()>=imageDeadline)throw new CsoError('DEADLINE',imageDeadline===deadline?'Qualified image pull reached the aggregate preload deadline':perImageReason);downloaded++;} - catch(error){ - if(error instanceof CsoError&&error.code==='DEADLINE'){ - if(Date.now()>=deadline){deadlineReached=true;unavailable.push({...entry,status:'unavailable',reason:error.message});for(const remaining of entries.slice(index+1))unavailable.push({...remaining,status:'unavailable',reason:deadlineReason});break;} - perImageTimeouts++;unavailable.push({...entry,status:'unavailable',reason:perImageReason});continue; + if (pullBlocked) { + unavailable.push({ + ...entry, + status: 'unavailable', + reason: `Network provisioning stopped after an anonymous registry prerequisite failed: ${pullBlocked}`, + }); + continue; + } + if (Date.now() >= deadline) { + deadlineReached = true; + unavailable.push({ ...entry, status: 'unavailable', reason: deadlineReason }); + for (const remaining of entries.slice(index + 1)) + unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); + break; + } + try { + await session.pull(entry, imageDeadline); + if (Date.now() >= imageDeadline) + throw new CsoError( + 'DEADLINE', + imageDeadline === deadline + ? 'Qualified image pull reached the aggregate preload deadline' + : perImageReason, + ); + downloaded++; + } catch (error) { + if (error instanceof CsoError && error.code === 'DEADLINE') { + if (Date.now() >= deadline) { + deadlineReached = true; + unavailable.push({ ...entry, status: 'unavailable', reason: error.message }); + for (const remaining of entries.slice(index + 1)) + unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); + break; + } + perImageTimeouts++; + unavailable.push({ ...entry, status: 'unavailable', reason: perImageReason }); + continue; } - pullBlocked=controlledReason(error,'Qualified image provisioning failed');unavailable.push({...entry,status:'unavailable',reason:pullBlocked}); + pullBlocked = controlledReason(error, 'Qualified image provisioning failed'); + unavailable.push({ ...entry, status: 'unavailable', reason: pullBlocked }); } } - }finally{session.close();} - const status=deadlineReached?'partial':unavailable.length?(alreadyPresent||downloaded?'partial':'not_available'):'complete'; - const summary=deadlineReached - ?`Qualified CSO image preload partial: ${alreadyPresent+downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; the bounded aggregate deadline was reached. Rerun setup to continue.` - :perImageTimeouts - ?`Qualified CSO image preload ${status}: ${alreadyPresent+downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${perImageTimeouts} exceeded the ${Math.ceil(perImageBudgetMs/1000)}-second per-image deadline. Increase GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS within 5..300 or rerun setup to continue.` - :unavailable.length - ?`Qualified CSO image preload ${status}: ${alreadyPresent+downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${unavailable.length} require local Docker and anonymous public registry access. Rerun setup after the prerequisite is available.` - :`Qualified CSO images ready: ${entries.length} available (${downloaded} downloaded, ${alreadyPresent} already local).`; - return{schemaVersion:1,status,downloads:true,platform,requested:entries.length,inspected,alreadyPresent,downloaded,deadlineReached,unavailable,summary}; + } finally { + session.close(); + } + const status = deadlineReached + ? 'partial' + : unavailable.length + ? alreadyPresent || downloaded + ? 'partial' + : 'not_available' + : 'complete'; + const summary = deadlineReached + ? `Qualified CSO image preload partial: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; the bounded aggregate deadline was reached. Rerun setup to continue.` + : perImageTimeouts + ? `Qualified CSO image preload ${status}: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${perImageTimeouts} exceeded the ${Math.ceil(perImageBudgetMs / 1000)}-second per-image deadline. Increase GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS within 5..300 or rerun setup to continue.` + : unavailable.length + ? `Qualified CSO image preload ${status}: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${unavailable.length} require local Docker and anonymous public registry access. Rerun setup after the prerequisite is available.` + : `Qualified CSO images ready: ${entries.length} available (${downloaded} downloaded, ${alreadyPresent} already local).`; + return { + schemaVersion: 1, + status, + downloads: true, + platform, + requested: entries.length, + inspected, + alreadyPresent, + downloaded, + deadlineReached, + unavailable, + summary, + }; } -export async function openLocalCatalogImageSession(env:Record=process.env,deadline=Date.now()+CATALOG_IMAGE_INSPECTION_BUDGET_MS):Promise{ - let home=''; - try{ - home=secureDirectory(fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()),'gstack-cso-images-'))); +export async function openLocalCatalogImageSession( + env: Record = process.env, + deadline = Date.now() + CATALOG_IMAGE_INSPECTION_BUDGET_MS, +): Promise { + let home = ''; + try { + home = secureDirectory(fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), 'gstack-cso-images-'))); // Endpoint discovery and the daemon probe must not borrow the download // allowance. A slow or hostile local Docker endpoint gets the same bounded // admission window in doctor and setup; successful pulls keep the caller's // larger aggregate deadline below. - const admissionDeadline=Math.min(deadline,Date.now()+CATALOG_IMAGE_ADMISSION_BUDGET_MS); - const endpoint=await dockerEndpoint(home,env,admissionDeadline),config=secureDirectory(join(home,'docker-config')); + const admissionDeadline = Math.min(deadline, Date.now() + CATALOG_IMAGE_ADMISSION_BUDGET_MS); + const endpoint = await dockerEndpoint(home, env, admissionDeadline), + config = secureDirectory(join(home, 'docker-config')); // dockerEnvironment pins both HOME and DOCKER_CONFIG here. An explicit // empty auth map prevents inherited credential stores/helpers from being // consulted during installation-time public pulls. - fs.writeFileSync(join(config,'config.json'),'{"auths":{}}\n',{encoding:'utf8',mode:0o600,flag:'wx'}); - const probe=await dockerProbe(endpoint,home,admissionDeadline),docker={endpoint:endpoint.uri,...probe}; - let closed=false; - return{ + fs.writeFileSync(join(config, 'config.json'), '{"auths":{}}\n', { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + const probe = await dockerProbe(endpoint, home, admissionDeadline), + docker = { endpoint: endpoint.uri, ...probe }; + let closed = false; + return { docker, - present:(entry,operationDeadline=deadline)=>{if(closed)throw new CsoError('ISOLATION_FAILED','Catalog image session is closed');return dockerExactImagePresent(endpoint,home,entry.image,entry.platform,Math.min(deadline,operationDeadline));}, - pull:(entry,operationDeadline=deadline)=>{if(closed)throw new CsoError('ISOLATION_FAILED','Catalog image session is closed');return dockerPullExactCatalogImage(endpoint,home,entry.image,entry.platform,Math.min(deadline,operationDeadline));}, - close:()=>{if(closed)return;closed=true;fs.rmSync(home,{recursive:true,force:true});}, + present: (entry, operationDeadline = deadline) => { + if (closed) throw new CsoError('ISOLATION_FAILED', 'Catalog image session is closed'); + return dockerExactImagePresent( + endpoint, + home, + entry.image, + entry.platform, + Math.min(deadline, operationDeadline), + ); + }, + pull: (entry, operationDeadline = deadline) => { + if (closed) throw new CsoError('ISOLATION_FAILED', 'Catalog image session is closed'); + return dockerPullExactCatalogImage( + endpoint, + home, + entry.image, + entry.platform, + Math.min(deadline, operationDeadline), + ); + }, + close: () => { + if (closed) return; + closed = true; + fs.rmSync(home, { recursive: true, force: true }); + }, }; - }catch(error){if(home)fs.rmSync(home,{recursive:true,force:true});throw error;} + } catch (error) { + if (home) fs.rmSync(home, { recursive: true, force: true }); + throw error; + } } diff --git a/lib/cso/preparation-container.ts b/lib/cso/preparation-container.ts index 25783db68..af70d20dc 100644 --- a/lib/cso/preparation-container.ts +++ b/lib/cso/preparation-container.ts @@ -26,7 +26,15 @@ const VERSION_VALUE = /^[0-9][0-9A-Za-z.+_-]*$/; type Stack = 'node' | 'bun' | 'python' | 'rails'; interface Input { index: number; - input: { kind: 'public'; name: string; version: string; url?: string; integrity?: string; integritySource?: string; platform?: string }; + input: { + kind: 'public'; + name: string; + version: string; + url?: string; + integrity?: string; + integritySource?: string; + platform?: string; + }; } interface Policy { schemaVersion: 1; @@ -35,20 +43,43 @@ interface Policy { inputs: Input[]; allowedHosts?: string[]; limits?: { maxArchives?: number; maxArchiveBytes?: number; maxTotalArchiveBytes?: number }; - archives?: Array<{ inputIndex: number; name: string; version: string; declaredIntegrity: string; requestedUrl: string; resolvedUrl: string | null; containerPath: string; sha256: string; bytes: number }>; + archives?: Array<{ + inputIndex: number; + name: string; + version: string; + declaredIntegrity: string; + requestedUrl: string; + resolvedUrl: string | null; + containerPath: string; + sha256: string; + bytes: number; + }>; } interface Artifact { - inputIndex: number; stagingPath: string; installPath: string; sha256: string; bytes: number; - requestedHost: string; requestedUrl: string; resolvedUrl: string | null; registryResponseSha256: string; + inputIndex: number; + stagingPath: string; + installPath: string; + sha256: string; + bytes: number; + requestedHost: string; + requestedUrl: string; + resolvedUrl: string | null; + registryResponseSha256: string; } export type PreparedExportEntry = | { path: string; kind: 'directory'; mode: number } | { path: string; kind: 'file'; mode: number; bytes: number; sha256: string; blob: string } | { path: string; kind: 'symlink'; mode: number; target: string }; -export interface PreparedExportManifest { schemaVersion: 1; entries: PreparedExportEntry[] } +export interface PreparedExportManifest { + schemaVersion: 1; + entries: PreparedExportEntry[]; +} -function die(message: string): never { process.stderr.write(`${message}\n`); process.exit(70); } +function die(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(70); +} /** Count every materialized tar header, including zero-byte directories. */ export function recordNpmArchiveEntry(previous: number, type: string): number { if (!Number.isSafeInteger(previous) || previous < 0 || typeof type !== 'string' || type.length !== 1) @@ -58,7 +89,8 @@ export function recordNpmArchiveEntry(previous: number, type: string): number { return next; } function strictPath(root: string, relative: string): string { - if (!RELATIVE.test(relative) || relative.split('/').some(part => !part || part === '.' || part === '..')) die('unsafe relative path'); + if (!RELATIVE.test(relative) || relative.split('/').some((part) => !part || part === '.' || part === '..')) + die('unsafe relative path'); const target = resolve(root, ...relative.split('/')); if (!target.startsWith(`${resolve(root)}${sep}`)) die('path escaped root'); return target; @@ -71,17 +103,36 @@ function mkdirPrivate(path: string): void { } function readPolicy(path: string): Policy { const stat = fs.lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_POLICY) die('invalid preparation policy file'); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_POLICY) + die('invalid preparation policy file'); let value: any; - try { value = JSON.parse(fs.readFileSync(path, 'utf8')); } catch { die('invalid preparation policy JSON'); } - if (!value || value.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(value.planHash) || - !['node', 'bun', 'python', 'rails'].includes(value.stack) || !Array.isArray(value.inputs) || value.inputs.length > 25_000) + try { + value = JSON.parse(fs.readFileSync(path, 'utf8')); + } catch { + die('invalid preparation policy JSON'); + } + if ( + !value || + value.schemaVersion !== 1 || + !/^[a-f0-9]{64}$/.test(value.planHash) || + !['node', 'bun', 'python', 'rails'].includes(value.stack) || + !Array.isArray(value.inputs) || + value.inputs.length > 25_000 + ) die('invalid preparation policy schema'); const indexes = new Set(); for (const item of value.inputs) { const input = item?.input; - if (!Number.isSafeInteger(item?.index) || item.index < 0 || indexes.has(item.index) || input?.kind !== 'public' || - !NAME.test(input.name) || !VERSION_VALUE.test(input.version) || typeof input.integritySource !== 'string') die('invalid preparation input'); + if ( + !Number.isSafeInteger(item?.index) || + item.index < 0 || + indexes.has(item.index) || + input?.kind !== 'public' || + !NAME.test(input.name) || + !VERSION_VALUE.test(input.version) || + typeof input.integritySource !== 'string' + ) + die('invalid preparation input'); indexes.add(item.index); } return value as Policy; @@ -89,46 +140,99 @@ function readPolicy(path: string): Policy { function openRegular(path: string, max = MAX_ARCHIVE): { fd: number; stat: fs.Stats } { const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; let fd: number; - try { fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); } catch { die('archive is missing or unsafe'); } + try { + fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); + } catch { + die('archive is missing or unsafe'); + } const stat = fs.fstatSync(fd); - if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size < 0 || stat.size > max) { fs.closeSync(fd); die('archive is not one bounded regular file'); } + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size < 0 || stat.size > max) { + fs.closeSync(fd); + die('archive is not one bounded regular file'); + } return { fd, stat }; } function hashes(path: string, max = MAX_ARCHIVE): { sha256: string; sha512: string; bytes: number } { - const { fd, stat } = openRegular(path, max), h256 = createHash('sha256'), h512 = createHash('sha512'), buffer = Buffer.allocUnsafe(64 * 1024); + const { fd, stat } = openRegular(path, max), + h256 = createHash('sha256'), + h512 = createHash('sha512'), + buffer = Buffer.allocUnsafe(64 * 1024); let bytes = 0; try { - for (;;) { const count = fs.readSync(fd, buffer, 0, buffer.length, null); if (!count) break; bytes += count; if (bytes > max) die('archive exceeded byte ceiling'); h256.update(buffer.subarray(0, count)); h512.update(buffer.subarray(0, count)); } + for (;;) { + const count = fs.readSync(fd, buffer, 0, buffer.length, null); + if (!count) break; + bytes += count; + if (bytes > max) die('archive exceeded byte ceiling'); + h256.update(buffer.subarray(0, count)); + h512.update(buffer.subarray(0, count)); + } const after = fs.fstatSync(fd); - if (bytes !== stat.size || stat.dev !== after.dev || stat.ino !== after.ino || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs || stat.ctimeMs !== after.ctimeMs) die('archive changed while hashing'); + if ( + bytes !== stat.size || + stat.dev !== after.dev || + stat.ino !== after.ino || + stat.size !== after.size || + stat.mtimeMs !== after.mtimeMs || + stat.ctimeMs !== after.ctimeMs + ) + die('archive changed while hashing'); return { sha256: h256.digest('hex'), sha512: h512.digest('base64'), bytes }; - } finally { fs.closeSync(fd); } + } finally { + fs.closeSync(fd); + } } function preparedPath(path: string): boolean { if (!path || path.startsWith('/') || path.includes('\\') || Buffer.byteLength(path) > 4096) return false; - return path.split('/').every(part => part && part !== '.' && part !== '..' && Buffer.byteLength(part) <= 255 && !/[\0-\x1f\x7f]/.test(part)); + return path + .split('/') + .every( + (part) => + part && + part !== '.' && + part !== '..' && + Buffer.byteLength(part) <= 255 && + !/[\0-\x1f\x7f]/.test(part), + ); } function samePreparedObject(left: fs.Stats, right: fs.Stats): boolean { - return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.uid === right.uid && - left.gid === right.gid && left.nlink === right.nlink && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); } function preparedFileHash(path: string, expected: fs.Stats, maxBytes: number): string { const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; const fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); try { const before = fs.fstatSync(fd); - if (!before.isFile() || !samePreparedObject(expected, before)) throw new Error('prepared file changed before export'); - const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024); let bytes = 0; + if (!before.isFile() || !samePreparedObject(expected, before)) + throw new Error('prepared file changed before export'); + const hash = createHash('sha256'), + buffer = Buffer.allocUnsafe(64 * 1024); + let bytes = 0; for (;;) { - const count = fs.readSync(fd, buffer, 0, buffer.length, null); if (!count) break; - bytes += count; if (bytes > maxBytes) throw new Error('prepared tree exceeded its byte ceiling'); + const count = fs.readSync(fd, buffer, 0, buffer.length, null); + if (!count) break; + bytes += count; + if (bytes > maxBytes) throw new Error('prepared tree exceeded its byte ceiling'); hash.update(buffer.subarray(0, count)); } const after = fs.fstatSync(fd); - if (bytes !== expected.size || !samePreparedObject(before, after)) throw new Error('prepared file changed during export'); + if (bytes !== expected.size || !samePreparedObject(before, after)) + throw new Error('prepared file changed during export'); return hash.digest('hex'); - } finally { fs.closeSync(fd); } + } finally { + fs.closeSync(fd); + } } /** @@ -138,71 +242,118 @@ function preparedFileHash(path: string, expected: fs.Stats, maxBytes: number): s * represented as manifest data and special files are rejected before Docker * is allowed to copy anything to the host. */ -export function createPreparedExport(sourceRoot: string, exportRoot: string, maxBytes = MAX_EXPANDED, maxEntries = MAX_FILES): PreparedExportManifest { - const root = resolve(sourceRoot), output = resolve(exportRoot); - if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_EXPANDED || - !Number.isSafeInteger(maxEntries) || maxEntries <= 0 || maxEntries > MAX_FILES || - output === root || !output.startsWith(`${root}${sep}`) || !/^\.gstack-cso-export-[a-f0-9]{24}$/.test(output.slice(root.length + 1))) +export function createPreparedExport( + sourceRoot: string, + exportRoot: string, + maxBytes = MAX_EXPANDED, + maxEntries = MAX_FILES, +): PreparedExportManifest { + const root = resolve(sourceRoot), + output = resolve(exportRoot); + if ( + !Number.isSafeInteger(maxBytes) || + maxBytes <= 0 || + maxBytes > MAX_EXPANDED || + !Number.isSafeInteger(maxEntries) || + maxEntries <= 0 || + maxEntries > MAX_FILES || + output === root || + !output.startsWith(`${root}${sep}`) || + !/^\.gstack-cso-export-[a-f0-9]{24}$/.test(output.slice(root.length + 1)) + ) throw new Error('prepared export arguments escaped their bounded contract'); const rootStat = fs.lstatSync(root); - if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || fs.realpathSync(root) !== root) throw new Error('prepared export source is unsafe'); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || fs.realpathSync(root) !== root) + throw new Error('prepared export source is unsafe'); type Scanned = PreparedExportEntry & { source?: string; identity?: fs.Stats }; - const scanned: Scanned[] = []; let nodes = 0, totalBytes = 0; + const scanned: Scanned[] = []; + let nodes = 0, + totalBytes = 0; const walk = (directory: string, prefix = ''): void => { const before = fs.readdirSync(directory).sort(); for (const name of before) { const relativePath = prefix ? `${prefix}/${name}` : name; - if (!preparedPath(relativePath) || ++nodes > maxEntries) throw new Error('prepared tree exceeded its entry or path ceiling'); - const path = join(directory, name), stat = fs.lstatSync(path); - if (process.getuid && stat.uid !== process.getuid()) throw new Error('prepared tree contains an object owned by another identity'); + if (!preparedPath(relativePath) || ++nodes > maxEntries) + throw new Error('prepared tree exceeded its entry or path ceiling'); + const path = join(directory, name), + stat = fs.lstatSync(path); + if (process.getuid && stat.uid !== process.getuid()) + throw new Error('prepared tree contains an object owned by another identity'); if (stat.isSymbolicLink()) { const target = fs.readlinkSync(path); - if (!target || isAbsolute(target) || target.includes('\0') || /[\x01-\x1f\x7f]/.test(target)) throw new Error('prepared tree contains an unsafe symlink'); + if (!target || isAbsolute(target) || target.includes('\0') || /[\x01-\x1f\x7f]/.test(target)) + throw new Error('prepared tree contains an unsafe symlink'); const lexical = resolve(dirname(path), target); - if (lexical !== root && !lexical.startsWith(`${root}${sep}`)) throw new Error('prepared tree contains an escaping symlink'); + if (lexical !== root && !lexical.startsWith(`${root}${sep}`)) + throw new Error('prepared tree contains an escaping symlink'); let real: string, resolvedStat: fs.Stats; - try { real = fs.realpathSync(path); resolvedStat = fs.statSync(path); } catch { throw new Error('prepared tree contains a dangling or cyclic symlink'); } - if ((real !== root && !real.startsWith(`${root}${sep}`)) || (!resolvedStat.isFile() && !resolvedStat.isDirectory())) + try { + real = fs.realpathSync(path); + resolvedStat = fs.statSync(path); + } catch { + throw new Error('prepared tree contains a dangling or cyclic symlink'); + } + if ( + (real !== root && !real.startsWith(`${root}${sep}`)) || + (!resolvedStat.isFile() && !resolvedStat.isDirectory()) + ) throw new Error('prepared tree symlink resolves outside the prepared boundary'); const after = fs.lstatSync(path); - if (!samePreparedObject(stat, after) || fs.readlinkSync(path) !== target) throw new Error('prepared symlink changed during export'); + if (!samePreparedObject(stat, after) || fs.readlinkSync(path) !== target) + throw new Error('prepared symlink changed during export'); scanned.push({ path: relativePath, kind: 'symlink', mode: stat.mode & 0o777, target }); } else if (stat.isDirectory()) { - if ((stat.mode & 0o022) !== 0) throw new Error('prepared tree contains a publicly writable directory'); + if ((stat.mode & 0o022) !== 0) + throw new Error('prepared tree contains a publicly writable directory'); scanned.push({ path: relativePath, kind: 'directory', mode: stat.mode & 0o777 }); walk(path, relativePath); } else if (stat.isFile()) { if (stat.nlink !== 1) throw new Error('prepared tree contains a hard-linked file'); - totalBytes += stat.size; if (totalBytes > maxBytes) throw new Error('prepared tree exceeded its byte ceiling'); + totalBytes += stat.size; + if (totalBytes > maxBytes) throw new Error('prepared tree exceeded its byte ceiling'); const digest = preparedFileHash(path, stat, maxBytes); - scanned.push({ path: relativePath, kind: 'file', mode: stat.mode & 0o777, bytes: stat.size, sha256: digest, blob: '', source: path, identity: stat }); + scanned.push({ + path: relativePath, + kind: 'file', + mode: stat.mode & 0o777, + bytes: stat.size, + sha256: digest, + blob: '', + source: path, + identity: stat, + }); } else throw new Error('prepared tree contains a FIFO, socket, device, or other special object'); } - if (before.join('\0') !== fs.readdirSync(directory).sort().join('\0')) throw new Error('prepared tree membership changed during export'); + if (before.join('\0') !== fs.readdirSync(directory).sort().join('\0')) + throw new Error('prepared tree membership changed during export'); }; walk(root); - scanned.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + scanned.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); if (fs.existsSync(output)) throw new Error('prepared export destination already exists'); fs.mkdirSync(output, { mode: 0o700 }); - const blobs = join(output, 'blobs'); fs.mkdirSync(blobs, { mode: 0o700 }); + const blobs = join(output, 'blobs'); + fs.mkdirSync(blobs, { mode: 0o700 }); let fileIndex = 0; try { for (const entry of scanned) { if (entry.kind !== 'file') continue; const current = fs.lstatSync(entry.source!); - if (!samePreparedObject(entry.identity!, current)) throw new Error('prepared file changed before inert export'); + if (!samePreparedObject(entry.identity!, current)) + throw new Error('prepared file changed before inert export'); const blob = `blob-${String(fileIndex++).padStart(6, '0')}`; fs.linkSync(entry.source!, join(blobs, blob)); const linked = fs.lstatSync(join(blobs, blob)); if (!linked.isFile() || linked.dev !== current.dev || linked.ino !== current.ino || linked.nlink !== 2) throw new Error('prepared file could not be bound into inert export'); entry.blob = blob; - delete entry.source; delete entry.identity; + delete entry.source; + delete entry.identity; } const manifest: PreparedExportManifest = { schemaVersion: 1, entries: scanned as PreparedExportEntry[] }; const encoded = `${JSON.stringify(manifest)}\n`; - if (Buffer.byteLength(encoded) > MAX_POLICY) throw new Error('prepared export manifest exceeded its byte ceiling'); + if (Buffer.byteLength(encoded) > MAX_POLICY) + throw new Error('prepared export manifest exceeded its byte ceiling'); fs.writeFileSync(join(output, 'manifest.json'), encoded, { mode: 0o600, flag: 'wx' }); return manifest; } catch (error) { @@ -210,13 +361,28 @@ export function createPreparedExport(sourceRoot: string, exportRoot: string, max throw error; } } -function matchesIntegrity(value: string | undefined, valueHashes: { sha256: string; sha512: string }): boolean { +function matchesIntegrity( + value: string | undefined, + valueHashes: { sha256: string; sha512: string }, +): boolean { if (!value) return false; - return value.split(/\s+/).some(item => item === `sha256:${valueHashes.sha256}` || - item === `sha256-${Buffer.from(valueHashes.sha256, 'hex').toString('base64')}` || item === `sha512-${valueHashes.sha512}`); + return value + .split(/\s+/) + .some( + (item) => + item === `sha256:${valueHashes.sha256}` || + item === `sha256-${Buffer.from(valueHashes.sha256, 'hex').toString('base64')}` || + item === `sha512-${valueHashes.sha512}`, + ); } -function moveVerified(source: string, outputRoot: string, relative: string, max: number): { path: string; sha256: string; bytes: number } { - const before = hashes(source, max), target = strictPath(outputRoot, relative); +function moveVerified( + source: string, + outputRoot: string, + relative: string, + max: number, +): { path: string; sha256: string; bytes: number } { + const before = hashes(source, max), + target = strictPath(outputRoot, relative); mkdirPrivate(dirname(target)); if (fs.existsSync(target)) die('archive staging destination already exists'); // Both paths are on the bounded /archives tmpfs. Rename the verified final @@ -224,46 +390,80 @@ function moveVerified(source: string, outputRoot: string, relative: string, max: fs.renameSync(source, target); fs.chmodSync(target, 0o600); const after = hashes(target, max); - if (before.sha256 !== after.sha256 || before.bytes !== after.bytes) die('archive changed while moved to inert staging'); + if (before.sha256 !== after.sha256 || before.bytes !== after.bytes) + die('archive changed while moved to inert staging'); return { path: relative, sha256: after.sha256, bytes: after.bytes }; } function requestedUrl(item: Input, stack: Stack, filename?: string): string { if (item.input.url) return item.input.url; - if (stack === 'python') return `https://pypi.org/simple/${item.input.name.toLowerCase().replace(/[_.]+/g, '-')}/`; + if (stack === 'python') + return `https://pypi.org/simple/${item.input.name.toLowerCase().replace(/[_.]+/g, '-')}/`; if (stack === 'rails' && filename) return `https://rubygems.org/gems/${filename}`; die('archive URL is unavailable'); } function checkedUrl(raw: string, hosts: string[]): URL { let url: URL; - try { url = new URL(raw); } catch { die('invalid archive URL'); } - if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || !hosts.includes(url.hostname)) die('archive URL escaped registry policy'); + try { + url = new URL(raw); + } catch { + die('invalid archive URL'); + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + url.search || + url.hash || + !hosts.includes(url.hostname) + ) + die('archive URL escaped registry policy'); return url; } async function download(raw: string, hosts: string[], destination: string, max: number): Promise { let current = checkedUrl(raw, hosts); for (let redirects = 0; redirects <= 3; redirects++) { - const response = await fetch(current, { redirect: 'manual', proxy: 'http://127.0.0.1:18443', headers: { 'user-agent': `gstack-cso-preparation/${VERSION}`, accept: 'application/octet-stream' } } as any); + const response = await fetch(current, { + redirect: 'manual', + proxy: 'http://127.0.0.1:18443', + headers: { 'user-agent': `gstack-cso-preparation/${VERSION}`, accept: 'application/octet-stream' }, + } as any); if ([301, 302, 303, 307, 308].includes(response.status)) { const location = response.headers.get('location'); if (!location || redirects === 3) die('registry redirect exceeded policy'); - current = checkedUrl(new URL(location, current).href, hosts); continue; + current = checkedUrl(new URL(location, current).href, hosts); + continue; } if (response.status !== 200 || !response.body) die(`registry returned HTTP ${response.status}`); const declared = response.headers.get('content-length'); - if (declared && (!/^\d+$/.test(declared) || Number(declared) > max)) die('registry response exceeded byte ceiling'); + if (declared && (!/^\d+$/.test(declared) || Number(declared) > max)) + die('registry response exceeded byte ceiling'); mkdirPrivate(dirname(destination)); - const fd = fs.openSync(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600); + const fd = fs.openSync( + destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); let bytes = 0; try { const reader = response.body.getReader(); for (;;) { - const part = await reader.read(); if (part.done) break; - bytes += part.value.byteLength; if (bytes > max) { await reader.cancel(); die('registry response exceeded byte ceiling'); } - let offset = 0; while (offset < part.value.byteLength) offset += fs.writeSync(fd, part.value, offset, part.value.byteLength - offset); + const part = await reader.read(); + if (part.done) break; + bytes += part.value.byteLength; + if (bytes > max) { + await reader.cancel(); + die('registry response exceeded byte ceiling'); + } + let offset = 0; + while (offset < part.value.byteLength) + offset += fs.writeSync(fd, part.value, offset, part.value.byteLength - offset); } fs.fsyncSync(fd); - } finally { fs.closeSync(fd); } + } finally { + fs.closeSync(fd); + } if (declared && bytes !== Number(declared)) die('registry response was truncated'); return current.href; } @@ -276,118 +476,213 @@ function npmCacheSource(item: Input): string | undefined { const match = token.match(/^(sha256|sha512)-([A-Za-z0-9+/]+={0,2})$/); if (!match) continue; const digest = Buffer.from(match[2], 'base64').toString('hex'); - if ((match[1] === 'sha256' && digest.length !== 64) || (match[1] === 'sha512' && digest.length !== 128)) continue; + if ((match[1] === 'sha256' && digest.length !== 64) || (match[1] === 'sha512' && digest.length !== 128)) + continue; const candidate = `/archives/npm/_cacache/content-v2/${match[1]}/${digest.slice(0, 2)}/${digest.slice(2, 4)}/${digest.slice(4)}`; - try { if (fs.lstatSync(candidate).isFile()) return candidate; } catch {} + try { + if (fs.lstatSync(candidate).isFile()) return candidate; + } catch {} } return undefined; } function regularFiles(directory: string): string[] { let names: string[]; - try { names = fs.readdirSync(directory).sort(); } catch { return []; } + try { + names = fs.readdirSync(directory).sort(); + } catch { + return []; + } const files: string[] = []; for (const name of names) { if (!/^[A-Za-z0-9@._+-]{1,512}$/.test(name)) die('archive directory contains an unexpected filename'); - const path = join(directory, name), stat = fs.lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) die('archive directory contains an unsafe object'); + const path = join(directory, name), + stat = fs.lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) + die('archive directory contains an unsafe object'); files.push(path); } return files; } async function manifest(policyPath: string, archiveRoot: string, outputPath: string): Promise { - const outputMatch = outputPath.match(/^\/archives\/(\.gstack-cso-acquisition-export-[a-f0-9]{24})\/artifacts\.json$/); - if (resolve(archiveRoot) !== '/archives' || !outputMatch) die('manifest paths do not match the container contract'); - const policy = readPolicy(policyPath), hosts = policy.allowedHosts ?? [], maxEntry = Math.min(policy.limits?.maxArchiveBytes ?? MAX_ARCHIVE, MAX_ARCHIVE), - maxTotal = Math.min(policy.limits?.maxTotalArchiveBytes ?? MAX_EXPANDED, MAX_EXPANDED), artifacts: Artifact[] = []; + const outputMatch = outputPath.match( + /^\/archives\/(\.gstack-cso-acquisition-export-[a-f0-9]{24})\/artifacts\.json$/, + ); + if (resolve(archiveRoot) !== '/archives' || !outputMatch) + die('manifest paths do not match the container contract'); + const policy = readPolicy(policyPath), + hosts = policy.allowedHosts ?? [], + maxEntry = Math.min(policy.limits?.maxArchiveBytes ?? MAX_ARCHIVE, MAX_ARCHIVE), + maxTotal = Math.min(policy.limits?.maxTotalArchiveBytes ?? MAX_EXPANDED, MAX_EXPANDED), + artifacts: Artifact[] = []; // Package managers write only to the size-bounded /archives tmpfs. After all // untrusted manager processes have exited, move verified regular artifacts // into one unpredictable helper-owned export on that same tmpfs. const exportRoot = join('/archives', outputMatch[1]); if (fs.existsSync(exportRoot)) die('acquisition export destination already exists'); fs.mkdirSync(exportRoot, { mode: 0o700 }); - const publicRoot = join(exportRoot, 'archives'); fs.mkdirSync(publicRoot, { mode: 0o700 }); - const add = (item: Input, source: string, installPath: string, requestedUrl: string, resolvedUrl: string | null, ordinal: number) => { + const publicRoot = join(exportRoot, 'archives'); + fs.mkdirSync(publicRoot, { mode: 0o700 }); + const add = ( + item: Input, + source: string, + installPath: string, + requestedUrl: string, + resolvedUrl: string | null, + ordinal: number, + ) => { const original = hashes(source, maxEntry); - if (item.input.integritySource === 'lock' && !matchesIntegrity(item.input.integrity, original)) die(`archive failed lock integrity: ${item.input.name}@${item.input.version}`); - const copied = moveVerified(source, publicRoot, `${policy.planHash.slice(0, 16)}-${policy.stack}-${ordinal}.archive`, maxEntry), requested = checkedUrl(requestedUrl, hosts); + if (item.input.integritySource === 'lock' && !matchesIntegrity(item.input.integrity, original)) + die(`archive failed lock integrity: ${item.input.name}@${item.input.version}`); + const copied = moveVerified( + source, + publicRoot, + `${policy.planHash.slice(0, 16)}-${policy.stack}-${ordinal}.archive`, + maxEntry, + ), + requested = checkedUrl(requestedUrl, hosts); if (resolvedUrl !== null) checkedUrl(resolvedUrl, hosts); - artifacts.push({ inputIndex: item.index, stagingPath: `cso-public/${copied.path}`, installPath, sha256: copied.sha256, - bytes: copied.bytes, requestedHost: requested.hostname, requestedUrl: requested.href, resolvedUrl, - registryResponseSha256: original.sha256 }); + artifacts.push({ + inputIndex: item.index, + stagingPath: `cso-public/${copied.path}`, + installPath, + sha256: copied.sha256, + bytes: copied.bytes, + requestedHost: requested.hostname, + requestedUrl: requested.href, + resolvedUrl, + registryResponseSha256: original.sha256, + }); }; if (policy.stack === 'node') { const logical = new Set(); for (let ordinal = 0; ordinal < policy.inputs.length; ordinal++) { - const item = policy.inputs[ordinal], key = `${item.input.name}\0${item.input.version}`; - if (logical.has(key)) continue; logical.add(key); - const url = requestedUrl(item, policy.stack); let source = npmCacheSource(item); + const item = policy.inputs[ordinal], + key = `${item.input.name}\0${item.input.version}`; + if (logical.has(key)) continue; + logical.add(key); + const url = requestedUrl(item, policy.stack); + let source = npmCacheSource(item); const temporary = `/archives/.cso-download-${ordinal}`; let resolvedUrl: string | null = null; - if (!source) { resolvedUrl = await download(url, hosts, temporary, maxEntry); source = temporary; } - try { add(item, source, `node/${ordinal}.tgz`, url, resolvedUrl, ordinal); } - finally { if (source === temporary) try { fs.unlinkSync(temporary); } catch {} } + if (!source) { + resolvedUrl = await download(url, hosts, temporary, maxEntry); + source = temporary; + } + try { + add(item, source, `node/${ordinal}.tgz`, url, resolvedUrl, ordinal); + } finally { + if (source === temporary) + try { + fs.unlinkSync(temporary); + } catch {} + } } } else if (policy.stack === 'bun') { const logical = new Set(); for (let ordinal = 0; ordinal < policy.inputs.length; ordinal++) { - const item = policy.inputs[ordinal], url = requestedUrl(item, policy.stack), temporary = `/archives/.cso-download-${ordinal}`; - const key = `${item.input.name}\0${item.input.version}`; if (logical.has(key)) continue; logical.add(key); + const item = policy.inputs[ordinal], + url = requestedUrl(item, policy.stack), + temporary = `/archives/.cso-download-${ordinal}`; + const key = `${item.input.name}\0${item.input.version}`; + if (logical.has(key)) continue; + logical.add(key); const resolvedUrl = await download(url, hosts, temporary, maxEntry); - try { add(item, temporary, `bun/${ordinal}.tgz`, url, resolvedUrl, ordinal); } finally { try { fs.unlinkSync(temporary); } catch {} } + try { + add(item, temporary, `bun/${ordinal}.tgz`, url, resolvedUrl, ordinal); + } finally { + try { + fs.unlinkSync(temporary); + } catch {} + } } } else if (policy.stack === 'python') { - const candidates = regularFiles('/archives/wheels').map(path => ({ path, values: hashes(path, maxEntry) })), used = new Set(), logical = new Set(); + const candidates = regularFiles('/archives/wheels').map((path) => ({ + path, + values: hashes(path, maxEntry), + })), + used = new Set(), + logical = new Set(); for (const item of policy.inputs) { const key = `${item.input.name.toLowerCase().replace(/[_.]+/g, '-')}\0${item.input.version}`; if (logical.has(key)) continue; - const match = candidates.find(candidate => !used.has(candidate.path) && matchesIntegrity(item.input.integrity, candidate.values)); + const match = candidates.find( + (candidate) => !used.has(candidate.path) && matchesIntegrity(item.input.integrity, candidate.values), + ); if (!match) continue; - logical.add(key); used.add(match.path); - const name = match.path.slice(match.path.lastIndexOf('/') + 1), ordinal = artifacts.length; + logical.add(key); + used.add(match.path); + const name = match.path.slice(match.path.lastIndexOf('/') + 1), + ordinal = artifacts.length; add(item, match.path, `wheels/${name}`, requestedUrl(item, policy.stack), null, ordinal); } } else { for (let ordinal = 0; ordinal < policy.inputs.length; ordinal++) { - const item = policy.inputs[ordinal], suffix = item.input.platform && item.input.platform !== 'ruby' ? `-${item.input.platform}` : '', - filename = `${item.input.name}-${item.input.version}${suffix}.gem`, path = join('/archives', filename); + const item = policy.inputs[ordinal], + suffix = item.input.platform && item.input.platform !== 'ruby' ? `-${item.input.platform}` : '', + filename = `${item.input.name}-${item.input.version}${suffix}.gem`, + path = join('/archives', filename); add(item, path, filename, requestedUrl(item, policy.stack, filename), null, ordinal); } } if (!artifacts.length && policy.inputs.length) die('acquisition produced no lock-bound public archives'); - if (artifacts.length > (policy.limits?.maxArchives ?? 25_000) || artifacts.reduce((sum, item) => sum + item.bytes, 0) > maxTotal) die('acquisition artifacts exceeded policy'); + if ( + artifacts.length > (policy.limits?.maxArchives ?? 25_000) || + artifacts.reduce((sum, item) => sum + item.bytes, 0) > maxTotal + ) + die('acquisition artifacts exceeded policy'); mkdirPrivate(dirname(outputPath)); fs.writeFileSync(outputPath, `${JSON.stringify({ artifacts })}\n`, { mode: 0o600, flag: 'wx' }); } async function forwarder(socketPath: string, listen: string): Promise { - if (socketPath !== '/run/cso-registry.sock' || listen !== '127.0.0.1:18443') die('forwarder endpoints do not match the qualified policy'); + if (socketPath !== '/run/cso-registry.sock' || listen !== '127.0.0.1:18443') + die('forwarder endpoints do not match the qualified policy'); const stat = fs.lstatSync(socketPath); if (!stat.isSocket() || stat.isSymbolicLink()) die('registry broker is not a Unix socket'); - let metadataBytes = 0, metadataFiles = 0; + let metadataBytes = 0, + metadataFiles = 0; const copyMetadata = (source: string, destination: string) => { const directory = fs.lstatSync(source); if (!directory.isDirectory() || directory.isSymbolicLink()) die('acquisition metadata input is unsafe'); mkdirPrivate(destination); for (const name of fs.readdirSync(source).sort()) { if (!/^[A-Za-z0-9@._+-]{1,255}$/.test(name)) die('acquisition metadata contains an unsafe name'); - const from = join(source, name), to = join(destination, name), child = fs.lstatSync(from); - if (child.isDirectory() && !child.isSymbolicLink()) { copyMetadata(from, to); continue; } - if (!child.isFile() || child.isSymbolicLink() || child.nlink !== 1) die('acquisition metadata contains a link or special file'); - metadataBytes += child.size; metadataFiles++; - if (metadataBytes > MAX_POLICY || metadataFiles > 50_000) die('acquisition metadata exceeds its bounded copy limit'); - fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL); fs.chmodSync(to, 0o600); + const from = join(source, name), + to = join(destination, name), + child = fs.lstatSync(from); + if (child.isDirectory() && !child.isSymbolicLink()) { + copyMetadata(from, to); + continue; + } + if (!child.isFile() || child.isSymbolicLink() || child.nlink !== 1) + die('acquisition metadata contains a link or special file'); + metadataBytes += child.size; + metadataFiles++; + if (metadataBytes > MAX_POLICY || metadataFiles > 50_000) + die('acquisition metadata exceeds its bounded copy limit'); + fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL); + fs.chmodSync(to, 0o600); } }; copyMetadata('/input-metadata', '/metadata'); - const server = net.createServer(client => { + const server = net.createServer((client) => { const upstream = net.createConnection({ path: socketPath }); - client.setTimeout(30_000); upstream.setTimeout(30_000); - client.once('error', () => upstream.destroy()); upstream.once('error', () => client.destroy()); - client.once('timeout', () => { client.destroy(); upstream.destroy(); }); - upstream.once('timeout', () => { client.destroy(); upstream.destroy(); }); - client.pipe(upstream); upstream.pipe(client); + client.setTimeout(30_000); + upstream.setTimeout(30_000); + client.once('error', () => upstream.destroy()); + upstream.once('error', () => client.destroy()); + client.once('timeout', () => { + client.destroy(); + upstream.destroy(); + }); + upstream.once('timeout', () => { + client.destroy(); + upstream.destroy(); + }); + client.pipe(upstream); + upstream.pipe(client); }); server.listen(18443, '127.0.0.1'); await once(server, 'listening'); @@ -396,68 +691,148 @@ async function forwarder(socketPath: string, listen: string): Promise { async function health(host: string, port: string): Promise { if (host !== '127.0.0.1' || port !== '18443') die('invalid forwarder health endpoint'); const socket = net.createConnection({ host, port: 18443 }); - await Promise.race([once(socket, 'connect'), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000))]); + await Promise.race([ + once(socket, 'connect'), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000)), + ]); socket.destroy(); } function tarString(block: Buffer, start: number, length: number): string { - const end = block.indexOf(0, start); return block.subarray(start, end < 0 || end > start + length ? start + length : end).toString('utf8'); + const end = block.indexOf(0, start); + return block.subarray(start, end < 0 || end > start + length ? start + length : end).toString('utf8'); } function tarNumber(block: Buffer, start: number, length: number): number { const value = tarString(block, start, length).trim(); if (!/^[0-7]+$/.test(value)) die('invalid tar numeric field'); - const number = Number.parseInt(value, 8); if (!Number.isSafeInteger(number) || number < 0) die('invalid tar size'); return number; + const number = Number.parseInt(value, 8); + if (!Number.isSafeInteger(number) || number < 0) die('invalid tar size'); + return number; } function tarChecksum(block: Buffer): void { - const declared = tarNumber(block, 148, 8); let sum = 0; + const declared = tarNumber(block, 148, 8); + let sum = 0; for (let index = 0; index < 512; index++) sum += index >= 148 && index < 156 ? 32 : block[index]; if (sum !== declared) die('invalid tar header checksum'); } -async function extractNpmArchive(source: string, destination: string, expectedName: string, expectedVersion: string): Promise { +async function extractNpmArchive( + source: string, + destination: string, + expectedName: string, + expectedVersion: string, +): Promise { mkdirPrivate(destination); - const stream = fs.createReadStream(source).pipe(createGunzip()), chunks: Buffer[] = []; - let buffered = 0, current: { remaining: number; padding: number; fd?: number; mode?: number } | undefined, expanded = 0, entries = 0, zeroBlocks = 0; + const stream = fs.createReadStream(source).pipe(createGunzip()), + chunks: Buffer[] = []; + let buffered = 0, + current: { remaining: number; padding: number; fd?: number; mode?: number } | undefined, + expanded = 0, + entries = 0, + zeroBlocks = 0; const consume = () => { - let buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, buffered); chunks.length = 0; buffered = 0; let offset = 0; + let buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, buffered); + chunks.length = 0; + buffered = 0; + let offset = 0; while (offset < buffer.length) { if (current) { const count = Math.min(current.remaining, buffer.length - offset); - if (count && current.fd !== undefined) { let written = 0; while (written < count) written += fs.writeSync(current.fd, buffer, offset + written, count - written); } - offset += count; current.remaining -= count; + if (count && current.fd !== undefined) { + let written = 0; + while (written < count) + written += fs.writeSync(current.fd, buffer, offset + written, count - written); + } + offset += count; + current.remaining -= count; if (!current.remaining) { - if (current.fd !== undefined) { fs.fchmodSync(current.fd, current.mode ?? 0o600); fs.fsyncSync(current.fd); fs.closeSync(current.fd); current.fd = undefined; } - const skip = Math.min(current.padding, buffer.length - offset); offset += skip; current.padding -= skip; + if (current.fd !== undefined) { + fs.fchmodSync(current.fd, current.mode ?? 0o600); + fs.fsyncSync(current.fd); + fs.closeSync(current.fd); + current.fd = undefined; + } + const skip = Math.min(current.padding, buffer.length - offset); + offset += skip; + current.padding -= skip; if (!current.padding) current = undefined; } continue; } if (buffer.length - offset < 512) break; - const header = buffer.subarray(offset, offset + 512); offset += 512; - if (header.every(byte => byte === 0)) { zeroBlocks++; if (zeroBlocks >= 2 && offset !== buffer.length) die('tar contains data after end marker'); continue; } + const header = buffer.subarray(offset, offset + 512); + offset += 512; + if (header.every((byte) => byte === 0)) { + zeroBlocks++; + if (zeroBlocks >= 2 && offset !== buffer.length) die('tar contains data after end marker'); + continue; + } if (zeroBlocks) die('tar has an invalid end marker'); tarChecksum(header); - const prefix = tarString(header, 345, 155), rawName = `${prefix ? `${prefix}/` : ''}${tarString(header, 0, 100)}`, type = String.fromCharCode(header[156] || 48), size = tarNumber(header, 124, 12), archivedMode = tarNumber(header, 100, 8), safeMode = archivedMode & 0o755; + const prefix = tarString(header, 345, 155), + rawName = `${prefix ? `${prefix}/` : ''}${tarString(header, 0, 100)}`, + type = String.fromCharCode(header[156] || 48), + size = tarNumber(header, 124, 12), + archivedMode = tarNumber(header, 100, 8), + safeMode = archivedMode & 0o755; if (!rawName.startsWith('package/')) die('npm archive entry lacks package prefix'); const relative = rawName.slice(8).replace(/\/$/, ''); - if (!relative) { if (type !== '5') die('invalid npm archive root'); current = { remaining: size, padding: (512 - size % 512) % 512 }; continue; } - try { entries = recordNpmArchiveEntry(entries, type); } catch { die('npm archive exceeded extraction limits'); } + if (!relative) { + if (type !== '5') die('invalid npm archive root'); + current = { remaining: size, padding: (512 - (size % 512)) % 512 }; + continue; + } + try { + entries = recordNpmArchiveEntry(entries, type); + } catch { + die('npm archive exceeded extraction limits'); + } const target = strictPath(destination, relative); - if (type === '5') { if (size !== 0) die('tar directory has content'); mkdirPrivate(target); current = { remaining: 0, padding: 0 }; continue; } + if (type === '5') { + if (size !== 0) die('tar directory has content'); + mkdirPrivate(target); + current = { remaining: 0, padding: 0 }; + continue; + } if (type !== '0') die('npm archive contains a link or special entry'); - expanded += size; if (expanded > MAX_EXPANDED) die('npm archive exceeded extraction limits'); + expanded += size; + if (expanded > MAX_EXPANDED) die('npm archive exceeded extraction limits'); mkdirPrivate(dirname(target)); - const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600); - current = { remaining: size, padding: (512 - size % 512) % 512, fd, mode: safeMode || 0o600 }; - if (!size) { fs.fchmodSync(fd, current.mode); fs.closeSync(fd); current.fd = undefined; if (!current.padding) current = undefined; } + const fd = fs.openSync( + target, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); + current = { remaining: size, padding: (512 - (size % 512)) % 512, fd, mode: safeMode || 0o600 }; + if (!size) { + fs.fchmodSync(fd, current.mode); + fs.closeSync(fd); + current.fd = undefined; + if (!current.padding) current = undefined; + } + } + if (offset < buffer.length) { + const rest = buffer.subarray(offset); + chunks.push(rest); + buffered = rest.length; } - if (offset < buffer.length) { const rest = buffer.subarray(offset); chunks.push(rest); buffered = rest.length; } }; - for await (const chunk of stream) { const value = Buffer.from(chunk); chunks.push(value); buffered += value.length; if (buffered > MAX_ARCHIVE + 1024) die('tar parser buffering exceeded limit'); consume(); } + for await (const chunk of stream) { + const value = Buffer.from(chunk); + chunks.push(value); + buffered += value.length; + if (buffered > MAX_ARCHIVE + 1024) die('tar parser buffering exceeded limit'); + consume(); + } consume(); if (current || buffered || zeroBlocks < 2) die('npm archive is truncated'); let manifest: any; - try { manifest = JSON.parse(fs.readFileSync(join(destination, 'package.json'), 'utf8')); } catch { die('npm archive package manifest is missing'); } - if (manifest?.name !== expectedName || manifest?.version !== expectedVersion) die('npm archive identity does not match the lock'); + try { + manifest = JSON.parse(fs.readFileSync(join(destination, 'package.json'), 'utf8')); + } catch { + die('npm archive package manifest is missing'); + } + if (manifest?.name !== expectedName || manifest?.version !== expectedVersion) + die('npm archive identity does not match the lock'); } interface BunSeedPackage { @@ -471,9 +846,19 @@ interface BunSeedPackage { const BUN_REGISTRY_PORT = 4873; function bunRegistryManifest(value: unknown, name: string, version: string): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) die('Bun seed package manifest is invalid'); - const source = value as Record, clean: Record = { name, version }; - for (const key of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta', 'os', 'cpu', 'bin']) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + die('Bun seed package manifest is invalid'); + const source = value as Record, + clean: Record = { name, version }; + for (const key of [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'os', + 'cpu', + 'bin', + ]) { if (source[key] !== undefined) clean[key] = source[key]; } return clean; @@ -483,79 +868,150 @@ function bunRegistryPathName(pathname: string): string | undefined { try { const decoded = decodeURIComponent(pathname.slice(1)); return NAME.test(decoded) ? decoded : undefined; - } catch { return undefined; } + } catch { + return undefined; + } } async function runBunSeedInstall(directory: string): Promise { const env = { - PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/work/.cso-home', - BUN_INSTALL_CACHE_DIR: '/work/.cso-bun-cache', BUN_CONFIG_NO_CLEAR_TERMINAL: '1', + PATH: '/usr/local/bin:/usr/bin:/bin', + HOME: '/work/.cso-home', + BUN_INSTALL_CACHE_DIR: '/work/.cso-bun-cache', + BUN_CONFIG_NO_CLEAR_TERMINAL: '1', BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER: '1', }; - const child = spawn('/usr/local/bin/bun', ['install', '--config=/opt/cso/empty-config', '--ignore-scripts', '--no-progress', - `--registry=http://127.0.0.1:${BUN_REGISTRY_PORT}`, '--backend=copyfile'], { - cwd: directory, env, stdio: ['ignore', 'ignore', 'pipe'], - }); - let stderr = Buffer.alloc(0), overflow = false; - child.stderr.on('data', chunk => { - if (stderr.length >= 64 * 1024) { overflow = true; return; } - const value = Buffer.from(chunk), remaining = 64 * 1024 - stderr.length; - stderr = Buffer.concat([stderr, value.subarray(0, remaining)]); if (value.length > remaining) overflow = true; + const child = spawn( + '/usr/local/bin/bun', + [ + 'install', + '--config=/opt/cso/empty-config', + '--ignore-scripts', + '--no-progress', + `--registry=http://127.0.0.1:${BUN_REGISTRY_PORT}`, + '--backend=copyfile', + ], + { + cwd: directory, + env, + stdio: ['ignore', 'ignore', 'pipe'], + }, + ); + let stderr = Buffer.alloc(0), + overflow = false; + child.stderr.on('data', (chunk) => { + if (stderr.length >= 64 * 1024) { + overflow = true; + return; + } + const value = Buffer.from(chunk), + remaining = 64 * 1024 - stderr.length; + stderr = Buffer.concat([stderr, value.subarray(0, remaining)]); + if (value.length > remaining) overflow = true; }); const timeout = setTimeout(() => child.kill('SIGKILL'), 60_000); - const [code, signal] = await once(child, 'exit') as [number | null, NodeJS.Signals | null]; + const [code, signal] = (await once(child, 'exit')) as [number | null, NodeJS.Signals | null]; clearTimeout(timeout); if (code !== 0 || signal || overflow) die('offline Bun cache seeding failed'); } async function seedBunCache(archives: NonNullable): Promise { - const cacheRoot = '/work/.cso-bun-cache', seedRoot = '/tmp/gstack-cso-bun-seed'; - mkdirPrivate(cacheRoot); mkdirPrivate(seedRoot); + const cacheRoot = '/work/.cso-bun-cache', + seedRoot = '/tmp/gstack-cso-bun-seed'; + mkdirPrivate(cacheRoot); + mkdirPrivate(seedRoot); const packages: BunSeedPackage[] = []; for (const archive of archives) { const extracted = strictPath(seedRoot, `packages/${archive.inputIndex}`); await extractNpmArchive(archive.containerPath, extracted, archive.name, archive.version); let source: unknown; - try { source = JSON.parse(fs.readFileSync(join(extracted, 'package.json'), 'utf8')); } - catch { die('Bun seed package manifest is invalid'); } - packages.push({ inputIndex: archive.inputIndex, name: archive.name, version: archive.version, - archive: archive.containerPath, integrity: `sha512-${createHash('sha512').update(fs.readFileSync(archive.containerPath)).digest('base64')}`, - manifest: bunRegistryManifest(source, archive.name, archive.version) }); + try { + source = JSON.parse(fs.readFileSync(join(extracted, 'package.json'), 'utf8')); + } catch { + die('Bun seed package manifest is invalid'); + } + packages.push({ + inputIndex: archive.inputIndex, + name: archive.name, + version: archive.version, + archive: archive.containerPath, + integrity: `sha512-${createHash('sha512').update(fs.readFileSync(archive.containerPath)).digest('base64')}`, + manifest: bunRegistryManifest(source, archive.name, archive.version), + }); } const byName = new Map(); for (const pkg of packages) byName.set(pkg.name, [...(byName.get(pkg.name) ?? []), pkg]); const server = http.createServer((request, response) => { const url = new URL(request.url ?? '/', `http://127.0.0.1:${BUN_REGISTRY_PORT}`); - if (request.method !== 'GET') { response.writeHead(405, { Allow: 'GET' }).end(); return; } + if (request.method !== 'GET') { + response.writeHead(405, { Allow: 'GET' }).end(); + return; + } const archiveMatch = url.pathname.match(/^\/archives\/([0-9]+)\.tgz$/); if (archiveMatch) { - const pkg = packages.find(item => item.inputIndex === Number(archiveMatch[1])); - if (!pkg) { response.writeHead(404).end(); return; } + const pkg = packages.find((item) => item.inputIndex === Number(archiveMatch[1])); + if (!pkg) { + response.writeHead(404).end(); + return; + } const stat = fs.lstatSync(pkg.archive); - response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(stat.size), - 'Cache-Control': 'no-store' }); fs.createReadStream(pkg.archive).pipe(response); return; + response.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(stat.size), + 'Cache-Control': 'no-store', + }); + fs.createReadStream(pkg.archive).pipe(response); + return; } - const name = bunRegistryPathName(url.pathname), versions = name ? byName.get(name) : undefined; - if (!name || !versions?.length) { response.writeHead(404).end(); return; } - const metadata: Record = { name, 'dist-tags': { latest: versions.at(-1)!.version }, versions: {} }; - for (const pkg of versions) (metadata.versions as Record)[pkg.version] = { - ...pkg.manifest, - dist: { tarball: `http://127.0.0.1:${BUN_REGISTRY_PORT}/archives/${pkg.inputIndex}.tgz`, integrity: pkg.integrity }, + const name = bunRegistryPathName(url.pathname), + versions = name ? byName.get(name) : undefined; + if (!name || !versions?.length) { + response.writeHead(404).end(); + return; + } + const metadata: Record = { + name, + 'dist-tags': { latest: versions.at(-1)!.version }, + versions: {}, }; + for (const pkg of versions) + (metadata.versions as Record)[pkg.version] = { + ...pkg.manifest, + dist: { + tarball: `http://127.0.0.1:${BUN_REGISTRY_PORT}/archives/${pkg.inputIndex}.tgz`, + integrity: pkg.integrity, + }, + }; const body = JSON.stringify(metadata); - response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(body)), - 'Cache-Control': 'no-store' }).end(body); + response + .writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(body)), + 'Cache-Control': 'no-store', + }) + .end(body); }); - server.listen(BUN_REGISTRY_PORT, '127.0.0.1'); await once(server, 'listening'); + server.listen(BUN_REGISTRY_PORT, '127.0.0.1'); + await once(server, 'listening'); try { // Seed every exact logical identity independently. This supports multiple // locked versions of one package while letting Bun own its cache format. for (const pkg of packages) { - const directory = strictPath(seedRoot, `installs/${pkg.inputIndex}`); mkdirPrivate(directory); - const manifest = { name: `gstack-cso-seed-${pkg.inputIndex}`, private: true, dependencies: { [pkg.name]: pkg.version } }; - fs.writeFileSync(join(directory, 'package.json'), JSON.stringify(manifest) + '\n', { mode: 0o600, flag: 'wx' }); + const directory = strictPath(seedRoot, `installs/${pkg.inputIndex}`); + mkdirPrivate(directory); + const manifest = { + name: `gstack-cso-seed-${pkg.inputIndex}`, + private: true, + dependencies: { [pkg.name]: pkg.version }, + }; + fs.writeFileSync(join(directory, 'package.json'), JSON.stringify(manifest) + '\n', { + mode: 0o600, + flag: 'wx', + }); await runBunSeedInstall(directory); } } finally { - await new Promise((resolveClose, reject) => server.close(error => error ? reject(error) : resolveClose())); + await new Promise((resolveClose, reject) => + server.close((error) => (error ? reject(error) : resolveClose())), + ); fs.rmSync(seedRoot, { recursive: true, force: true }); } } @@ -564,54 +1020,117 @@ async function seed(policyPath: string): Promise { const policy = readPolicy(policyPath); if (!Array.isArray(policy.archives)) die('offline policy omitted archives'); for (const archive of policy.archives) { - if (!Number.isSafeInteger(archive.inputIndex) || !NAME.test(archive.name) || !VERSION_VALUE.test(archive.version) || - !archive.containerPath.startsWith('/archives/') || !SHA256.test(archive.sha256) || !Number.isSafeInteger(archive.bytes) || archive.bytes < 0) + if ( + !Number.isSafeInteger(archive.inputIndex) || + !NAME.test(archive.name) || + !VERSION_VALUE.test(archive.version) || + !archive.containerPath.startsWith('/archives/') || + !SHA256.test(archive.sha256) || + !Number.isSafeInteger(archive.bytes) || + archive.bytes < 0 + ) die('offline archive policy is invalid'); const checked = hashes(archive.containerPath, Math.min(MAX_ARCHIVE, archive.bytes)); - if (checked.bytes !== archive.bytes || checked.sha256 !== archive.sha256 || - (archive.declaredIntegrity !== 'registry-on-acquisition' && !matchesIntegrity(archive.declaredIntegrity, checked))) die('offline archive failed integrity verification'); + if ( + checked.bytes !== archive.bytes || + checked.sha256 !== archive.sha256 || + (archive.declaredIntegrity !== 'registry-on-acquisition' && + !matchesIntegrity(archive.declaredIntegrity, checked)) + ) + die('offline archive failed integrity verification'); } if (policy.stack === 'node' && policy.archives.length) { mkdirPrivate('/work/.cso-npm-cache'); for (const archive of policy.archives) { - const result = spawnSync('/usr/local/bin/npm', ['cache', 'add', archive.containerPath, '--cache', '/work/.cso-npm-cache', '--userconfig', '/opt/cso/empty-config', '--globalconfig', '/opt/cso/empty-config'], - { cwd: '/work', env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/work/.cso-home', NPM_CONFIG_UPDATE_NOTIFIER: 'false' }, stdio: ['ignore', 'ignore', 'pipe'], timeout: 60_000, maxBuffer: 64 * 1024 }); + const result = spawnSync( + '/usr/local/bin/npm', + [ + 'cache', + 'add', + archive.containerPath, + '--cache', + '/work/.cso-npm-cache', + '--userconfig', + '/opt/cso/empty-config', + '--globalconfig', + '/opt/cso/empty-config', + ], + { + cwd: '/work', + env: { + PATH: '/usr/local/bin:/usr/bin:/bin', + HOME: '/work/.cso-home', + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + }, + stdio: ['ignore', 'ignore', 'pipe'], + timeout: 60_000, + maxBuffer: 64 * 1024, + }, + ); if (result.status !== 0 || result.error) die('offline npm cache seeding failed'); } } if (policy.stack === 'bun') { await seedBunCache(policy.archives); } - fs.writeFileSync('/work/.gstack-cso-preparation-ready', `${policy.planHash}\n`, { mode: 0o400, flag: 'wx' }); + fs.writeFileSync('/work/.gstack-cso-preparation-ready', `${policy.planHash}\n`, { + mode: 0o400, + flag: 'wx', + }); await new Promise(() => {}); } function ready(): void { const stat = fs.lstatSync('/work/.gstack-cso-preparation-ready'); - if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size !== 65) die('offline preparation is not ready'); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size !== 65) + die('offline preparation is not ready'); } function finalize(): void { - for (const path of ['/work/.cso-home', '/work/.cso-npm-cache', '/work/.cso-bun-cache', '/work/.cso-uv-cache', '/work/.gstack-cso-public-requirements.txt']) + for (const path of [ + '/work/.cso-home', + '/work/.cso-npm-cache', + '/work/.cso-bun-cache', + '/work/.cso-uv-cache', + '/work/.gstack-cso-public-requirements.txt', + ]) fs.rmSync(path, { recursive: true, force: true }); fs.rmSync('/work/.gstack-cso-preparation-ready', { force: true }); - for (const path of ['/work/.cso-home', '/work/.cso-npm-cache', '/work/.cso-bun-cache', '/work/.cso-uv-cache', '/work/.gstack-cso-public-requirements.txt', '/work/.gstack-cso-preparation-ready']) + for (const path of [ + '/work/.cso-home', + '/work/.cso-npm-cache', + '/work/.cso-bun-cache', + '/work/.cso-uv-cache', + '/work/.gstack-cso-public-requirements.txt', + '/work/.gstack-cso-preparation-ready', + ]) if (fs.existsSync(path)) die('offline preparation scratch cleanup was incomplete'); } async function main(): Promise { const [command, ...args] = process.argv.slice(2); - if (command === '--version' && !args.length) { process.stdout.write(`${VERSION}\n`); return; } + if (command === '--version' && !args.length) { + process.stdout.write(`${VERSION}\n`); + return; + } if (command === 'forwarder' && args.length === 2) return forwarder(args[0], args[1]); if (command === 'health' && args.length === 2) return health(args[0], args[1]); if (command === 'manifest' && args.length === 3) return manifest(args[0], args[1], args[2]); if (command === 'seed' && args.length === 1) return seed(args[0]); if (command === 'ready' && !args.length) return ready(); if (command === 'finalize' && !args.length) return finalize(); - if (command === 'export-prepared' && args.length === 3 && args[0] === '/work' && /^\/work\/\.gstack-cso-export-[a-f0-9]{24}$/.test(args[1]) && /^\d+$/.test(args[2])) { - createPreparedExport(args[0], args[1], Number(args[2])); return; + if ( + command === 'export-prepared' && + args.length === 3 && + args[0] === '/work' && + /^\/work\/\.gstack-cso-export-[a-f0-9]{24}$/.test(args[1]) && + /^\d+$/.test(args[2]) + ) { + createPreparedExport(args[0], args[1], Number(args[2])); + return; } die('usage: preparation {--version|forwarder|health|manifest|seed|ready|finalize|export-prepared}'); } -if (import.meta.main) main().catch(error => die(error instanceof Error ? error.message : 'preparation helper failed')); +if (import.meta.main) + main().catch((error) => die(error instanceof Error ? error.message : 'preparation helper failed')); diff --git a/lib/cso/preparation-docker.ts b/lib/cso/preparation-docker.ts index 2b2e48956..f8d7a4280 100644 --- a/lib/cso/preparation-docker.ts +++ b/lib/cso/preparation-docker.ts @@ -10,9 +10,15 @@ import { canonical, CsoError, sha256 } from './contracts'; import { DockerGroup, type DockerEndpoint } from './docker'; import { secureDirectory } from './state'; import { - admittedPreparationRuntime, type AcquisitionArtifactReceipt, type AcquisitionReceipt, - type OfflinePreparationReceipt, type OfflinePreparationRequest, type OfflinePreparationResult, - type PreparationAcquireRequest, type PreparationCommandReceipt, type PreparationRuntimeAdmission, + admittedPreparationRuntime, + type AcquisitionArtifactReceipt, + type AcquisitionReceipt, + type OfflinePreparationReceipt, + type OfflinePreparationRequest, + type OfflinePreparationResult, + type PreparationAcquireRequest, + type PreparationCommandReceipt, + type PreparationRuntimeAdmission, type PreparationSandboxRunner, } from './preparation-executor'; import type { CsoStack, PreparationCommand } from './preparation'; @@ -23,28 +29,58 @@ const RELATIVE = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*\\)[A-Za-z0-9@._+\/-]{1 const MAX_MANIFEST = 32 * 1024 * 1024; const MAX_PREPARED_EXPORT_ENTRIES = 200_000; -function fail(code: ConstructorParameters[0], message: string): never { throw new CsoError(code, message); } +function fail(code: ConstructorParameters[0], message: string): never { + throw new CsoError(code, message); +} function contained(root: string, path: string): string { - if (!RELATIVE.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) fail('UNSAFE_PATH', 'Preparation path escaped its private root'); + if (!RELATIVE.test(path) || path.split('/').some((part) => !part || part === '.' || part === '..')) + fail('UNSAFE_PATH', 'Preparation path escaped its private root'); const target = resolve(root, ...path.split('/')); if (!target.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', 'Preparation path escaped its private root'); return target; } -function commandReceipt(command: PreparationCommand, index: number, exitCode: number): PreparationCommandReceipt { - return { index, commandHash: sha256(canonical(command)), exitCode, timedOut: false, outputTruncated: false }; +function commandReceipt( + command: PreparationCommand, + index: number, + exitCode: number, +): PreparationCommandReceipt { + return { + index, + commandHash: sha256(canonical(command)), + exitCode, + timedOut: false, + outputTruncated: false, + }; } function writePolicy(path: string, value: unknown): void { - try { atomicWriteSync(path, `${JSON.stringify(value)}\n`, { mode: 0o600, noReplace: true }); } - catch { fail('PERSISTENCE_FAILED', 'Preparation policy could not be written atomically'); } + try { + atomicWriteSync(path, `${JSON.stringify(value)}\n`, { mode: 0o600, noReplace: true }); + } catch { + fail('PERSISTENCE_FAILED', 'Preparation policy could not be written atomically'); + } } function readManifest(path: string): { artifacts: AcquisitionArtifactReceipt[] } { let stat: fs.Stats; - try { stat = fs.lstatSync(path); } catch { fail('TOOL_FAILED', 'Qualified acquisition helper did not produce an archive manifest'); } - if (!stat!.isFile() || stat!.isSymbolicLink() || stat!.nlink !== 1 || stat!.size > MAX_MANIFEST || - (process.getuid && stat!.uid !== process.getuid()) || (stat!.mode & 0o077) !== 0) + try { + stat = fs.lstatSync(path); + } catch { + fail('TOOL_FAILED', 'Qualified acquisition helper did not produce an archive manifest'); + } + if ( + !stat!.isFile() || + stat!.isSymbolicLink() || + stat!.nlink !== 1 || + stat!.size > MAX_MANIFEST || + (process.getuid && stat!.uid !== process.getuid()) || + (stat!.mode & 0o077) !== 0 + ) fail('UNSAFE_PATH', 'Acquisition archive manifest is not one bounded private file'); let parsed: unknown; - try { parsed = JSON.parse(fs.readFileSync(path, 'utf8')); } catch { fail('TOOL_FAILED', 'Qualified acquisition helper returned invalid archive JSON'); } + try { + parsed = JSON.parse(fs.readFileSync(path, 'utf8')); + } catch { + fail('TOOL_FAILED', 'Qualified acquisition helper returned invalid archive JSON'); + } const value = parsed as { artifacts?: unknown }; if (!value || Object.keys(value).sort().join(',') !== 'artifacts' || !Array.isArray(value.artifacts)) fail('TOOL_FAILED', 'Qualified acquisition helper returned an invalid archive manifest schema'); @@ -53,69 +89,162 @@ function readManifest(path: string): { artifacts: AcquisitionArtifactReceipt[] } function fileSha256(path: string, maxBytes: number): string { const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; let fd: number; - try { fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); } catch { fail('UNSAFE_PATH', 'Archive copy could not be opened without following links'); } try { - const before = fs.fstatSync(fd!), hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024); let bytes = 0; - for (;;) { const count = fs.readSync(fd!, buffer, 0, buffer.length, null); if (!count) break; bytes += count; if (bytes > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Archive copy exceeded its declared size'); hash.update(buffer.subarray(0, count)); } + fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); + } catch { + fail('UNSAFE_PATH', 'Archive copy could not be opened without following links'); + } + try { + const before = fs.fstatSync(fd!), + hash = createHash('sha256'), + buffer = Buffer.allocUnsafe(64 * 1024); + let bytes = 0; + for (;;) { + const count = fs.readSync(fd!, buffer, 0, buffer.length, null); + if (!count) break; + bytes += count; + if (bytes > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Archive copy exceeded its declared size'); + hash.update(buffer.subarray(0, count)); + } const after = fs.fstatSync(fd!); - if (bytes !== before.size || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) + if ( + bytes !== before.size || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs + ) fail('SNAPSHOT_RACE', 'Archive copy changed while it was hashed'); return hash.digest('hex'); - } finally { fs.closeSync(fd!); } + } finally { + fs.closeSync(fd!); + } } function validateAcquisitionOutput(root: string, artifacts: AcquisitionArtifactReceipt[]): void { const top = fs.readdirSync(root).sort(); - if (canonical(top) !== canonical(['archives', 'artifacts.json'])) fail('TOOL_FAILED', 'Acquisition output contains undeclared objects'); - const archiveRoot = join(root, 'archives'), stat = fs.lstatSync(archiveRoot); - if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022) !== 0) + if (canonical(top) !== canonical(['archives', 'artifacts.json'])) + fail('TOOL_FAILED', 'Acquisition output contains undeclared objects'); + const archiveRoot = join(root, 'archives'), + stat = fs.lstatSync(archiveRoot); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o022) !== 0 + ) fail('UNSAFE_PATH', 'Acquisition archive output is not one private directory'); - for(const artifact of artifacts)if(typeof artifact?.stagingPath!=='string'||!/^cso-public\/[A-Za-z0-9._-]{1,255}$/.test(artifact.stagingPath))fail('TOOL_FAILED','Acquisition manifest contains an unsafe staging path'); - const expected = artifacts.map(artifact => artifact.stagingPath.slice('cso-public/'.length)).sort(), actual = fs.readdirSync(archiveRoot).sort(); - if(new Set(expected).size!==expected.length)fail('TOOL_FAILED','Acquisition manifest contains duplicate staging paths'); - if (canonical(actual) !== canonical(expected)) fail('TOOL_FAILED', 'Acquisition output archive membership does not match its manifest'); + for (const artifact of artifacts) + if ( + typeof artifact?.stagingPath !== 'string' || + !/^cso-public\/[A-Za-z0-9._-]{1,255}$/.test(artifact.stagingPath) + ) + fail('TOOL_FAILED', 'Acquisition manifest contains an unsafe staging path'); + const expected = artifacts.map((artifact) => artifact.stagingPath.slice('cso-public/'.length)).sort(), + actual = fs.readdirSync(archiveRoot).sort(); + if (new Set(expected).size !== expected.length) + fail('TOOL_FAILED', 'Acquisition manifest contains duplicate staging paths'); + if (canonical(actual) !== canonical(expected)) + fail('TOOL_FAILED', 'Acquisition output archive membership does not match its manifest'); for (const name of actual) { - if (!name || name.includes('/') || name === '.' || name === '..') fail('UNSAFE_PATH', 'Acquisition output archive name is unsafe'); + if (!name || name.includes('/') || name === '.' || name === '..') + fail('UNSAFE_PATH', 'Acquisition output archive name is unsafe'); const file = fs.lstatSync(join(archiveRoot, name)); - if (!file.isFile() || file.isSymbolicLink() || file.nlink !== 1 || (process.getuid && file.uid !== process.getuid()) || (file.mode & 0o022) !== 0) + if ( + !file.isFile() || + file.isSymbolicLink() || + file.nlink !== 1 || + (process.getuid && file.uid !== process.getuid()) || + (file.mode & 0o022) !== 0 + ) fail('UNSAFE_PATH', 'Acquisition output contains a link or special file'); } } function preparedRelative(path: unknown): path is string { - return typeof path === 'string' && path.length > 0 && !path.startsWith('/') && !path.includes('\\') && - Buffer.byteLength(path) <= 4096 && path.split('/').every(part => part && part !== '.' && part !== '..' && - Buffer.byteLength(part) <= 255 && !/[\0-\x1f\x7f]/.test(part)); + return ( + typeof path === 'string' && + path.length > 0 && + !path.startsWith('/') && + !path.includes('\\') && + Buffer.byteLength(path) <= 4096 && + path + .split('/') + .every( + (part) => + part && + part !== '.' && + part !== '..' && + Buffer.byteLength(part) <= 255 && + !/[\0-\x1f\x7f]/.test(part), + ) + ); } function preparedTarget(root: string, relativePath: string): string { if (!preparedRelative(relativePath)) fail('UNSAFE_PATH', 'Prepared export contains an unsafe path'); const target = resolve(root, ...relativePath.split('/')); - if (!target.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', 'Prepared export path escaped its private root'); + if (!target.startsWith(`${root}${sep}`)) + fail('UNSAFE_PATH', 'Prepared export path escaped its private root'); return target; } function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { - return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.uid === right.uid && - left.gid === right.gid && left.nlink === right.nlink && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.gid === right.gid && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); } -function copyPreparedBlob(source: string, destination: string, expected: Extract): void { +function copyPreparedBlob( + source: string, + destination: string, + expected: Extract, +): void { const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; - let sourceFd = -1, destinationFd = -1; + let sourceFd = -1, + destinationFd = -1; try { sourceFd = fs.openSync(source, fs.constants.O_RDONLY | noFollow); const before = fs.fstatSync(sourceFd); - if (!before.isFile() || before.nlink !== 1 || before.size !== expected.bytes || - (process.getuid && before.uid !== process.getuid())) fail('UNSAFE_PATH', 'Prepared export blob is not one owned regular file'); - destinationFd = fs.openSync(destination, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, 0o600); - const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024); let bytes = 0; + if ( + !before.isFile() || + before.nlink !== 1 || + before.size !== expected.bytes || + (process.getuid && before.uid !== process.getuid()) + ) + fail('UNSAFE_PATH', 'Prepared export blob is not one owned regular file'); + destinationFd = fs.openSync( + destination, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow, + 0o600, + ); + const hash = createHash('sha256'), + buffer = Buffer.allocUnsafe(64 * 1024); + let bytes = 0; for (;;) { - const count = fs.readSync(sourceFd, buffer, 0, buffer.length, null); if (!count) break; - bytes += count; if (bytes > expected.bytes) fail('INSUFFICIENT_CAPACITY', 'Prepared export blob exceeded its declared size'); + const count = fs.readSync(sourceFd, buffer, 0, buffer.length, null); + if (!count) break; + bytes += count; + if (bytes > expected.bytes) + fail('INSUFFICIENT_CAPACITY', 'Prepared export blob exceeded its declared size'); hash.update(buffer.subarray(0, count)); - let offset = 0; while (offset < count) offset += fs.writeSync(destinationFd, buffer, offset, count - offset); + let offset = 0; + while (offset < count) offset += fs.writeSync(destinationFd, buffer, offset, count - offset); } const after = fs.fstatSync(sourceFd); - if (bytes !== expected.bytes || hash.digest('hex') !== expected.sha256 || !sameFileIdentity(before, after)) + if ( + bytes !== expected.bytes || + hash.digest('hex') !== expected.sha256 || + !sameFileIdentity(before, after) + ) fail('SNAPSHOT_RACE', 'Prepared export blob changed during bounded import'); - fs.fchmodSync(destinationFd, expected.mode); fs.fsyncSync(destinationFd); + fs.fchmodSync(destinationFd, expected.mode); + fs.fsyncSync(destinationFd); const copied = fs.fstatSync(destinationFd); if (!copied.isFile() || copied.nlink !== 1 || copied.size !== expected.bytes) fail('PERSISTENCE_FAILED', 'Prepared export blob was not materialized as one regular file'); @@ -126,86 +255,185 @@ function copyPreparedBlob(source: string, destination: string, expected: Extract } /** Validate an inert container export and reconstruct its prepared tree using host no-follow writes. */ -export function materializePreparedExport(exportRoot: string, destinationRoot: string, maxBytes: number): PreparedExportManifest { - const source = resolve(exportRoot), destination = resolve(destinationRoot); +export function materializePreparedExport( + exportRoot: string, + destinationRoot: string, + maxBytes: number, +): PreparedExportManifest { + const source = resolve(exportRoot), + destination = resolve(destinationRoot); if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 2 * 1024 * 1024 * 1024) fail('INVALID_ARGUMENT', 'Prepared export byte ceiling is invalid'); - for (const [path, label, empty] of [[source, 'Prepared inert export', false], [destination, 'Prepared output', true]] as const) { + for (const [path, label, empty] of [ + [source, 'Prepared inert export', false], + [destination, 'Prepared output', true], + ] as const) { const stat = fs.lstatSync(path); - if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(path) !== path || - (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022) !== 0 || (empty && fs.readdirSync(path).length)) + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + fs.realpathSync(path) !== path || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o022) !== 0 || + (empty && fs.readdirSync(path).length) + ) fail('UNSAFE_PATH', `${label} must be one private${empty ? ' empty' : ''} owned directory`); } - if (fs.readdirSync(source).sort().join('\0') !== 'blobs\0manifest.json') fail('UNSAFE_PATH', 'Prepared inert export contains undeclared top-level objects'); - const manifestPath = join(source, 'manifest.json'), manifestStat = fs.lstatSync(manifestPath); - if (!manifestStat.isFile() || manifestStat.isSymbolicLink() || manifestStat.nlink !== 1 || manifestStat.size < 1 || manifestStat.size > MAX_MANIFEST || - (process.getuid && manifestStat.uid !== process.getuid())) fail('UNSAFE_PATH', 'Prepared export manifest is not one bounded owned file'); + if (fs.readdirSync(source).sort().join('\0') !== 'blobs\0manifest.json') + fail('UNSAFE_PATH', 'Prepared inert export contains undeclared top-level objects'); + const manifestPath = join(source, 'manifest.json'), + manifestStat = fs.lstatSync(manifestPath); + if ( + !manifestStat.isFile() || + manifestStat.isSymbolicLink() || + manifestStat.nlink !== 1 || + manifestStat.size < 1 || + manifestStat.size > MAX_MANIFEST || + (process.getuid && manifestStat.uid !== process.getuid()) + ) + fail('UNSAFE_PATH', 'Prepared export manifest is not one bounded owned file'); let manifest: PreparedExportManifest; - try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { fail('TOOL_FAILED', 'Prepared export manifest is invalid JSON'); } - if (!manifest || Object.keys(manifest).sort().join(',') !== 'entries,schemaVersion' || manifest.schemaVersion !== 1 || - !Array.isArray(manifest.entries) || manifest.entries.length > MAX_PREPARED_EXPORT_ENTRIES) + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch { + fail('TOOL_FAILED', 'Prepared export manifest is invalid JSON'); + } + if ( + !manifest || + Object.keys(manifest).sort().join(',') !== 'entries,schemaVersion' || + manifest.schemaVersion !== 1 || + !Array.isArray(manifest.entries) || + manifest.entries.length > MAX_PREPARED_EXPORT_ENTRIES + ) fail('TOOL_FAILED', 'Prepared export manifest has an invalid schema'); - const paths = new Set(), directories = new Set(), blobs = new Set(); let totalBytes = 0; + const paths = new Set(), + directories = new Set(), + blobs = new Set(); + let totalBytes = 0; for (const raw of manifest.entries) { - const entry = raw as PreparedExportEntry, keys = Object.keys(entry).sort().join(','); - if (!preparedRelative(entry?.path) || paths.has(entry.path) || !Number.isSafeInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777) + const entry = raw as PreparedExportEntry, + keys = Object.keys(entry).sort().join(','); + if ( + !preparedRelative(entry?.path) || + paths.has(entry.path) || + !Number.isSafeInteger(entry.mode) || + entry.mode < 0 || + entry.mode > 0o777 + ) fail('TOOL_FAILED', 'Prepared export contains a duplicate or invalid path record'); paths.add(entry.path); const parent = entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : ''; - if (parent && !directories.has(parent)) fail('TOOL_FAILED', 'Prepared export entry is missing its declared parent directory'); + if (parent && !directories.has(parent)) + fail('TOOL_FAILED', 'Prepared export entry is missing its declared parent directory'); if (entry.kind === 'directory') { - if (keys !== 'kind,mode,path' || (entry.mode & 0o022) !== 0) fail('TOOL_FAILED', 'Prepared export directory record is invalid'); + if (keys !== 'kind,mode,path' || (entry.mode & 0o022) !== 0) + fail('TOOL_FAILED', 'Prepared export directory record is invalid'); directories.add(entry.path); } else if (entry.kind === 'file') { - if (keys !== 'blob,bytes,kind,mode,path,sha256' || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || - !/^[a-f0-9]{64}$/.test(entry.sha256) || !/^blob-\d{6}$/.test(entry.blob) || blobs.has(entry.blob)) + if ( + keys !== 'blob,bytes,kind,mode,path,sha256' || + !Number.isSafeInteger(entry.bytes) || + entry.bytes < 0 || + !/^[a-f0-9]{64}$/.test(entry.sha256) || + !/^blob-\d{6}$/.test(entry.blob) || + blobs.has(entry.blob) + ) fail('TOOL_FAILED', 'Prepared export file record is invalid'); - blobs.add(entry.blob); totalBytes += entry.bytes; - if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Prepared export exceeds its aggregate byte ceiling'); + blobs.add(entry.blob); + totalBytes += entry.bytes; + if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) + fail('INSUFFICIENT_CAPACITY', 'Prepared export exceeds its aggregate byte ceiling'); } else if (entry.kind === 'symlink') { - if (keys !== 'kind,mode,path,target' || typeof entry.target !== 'string' || !entry.target || isAbsolute(entry.target) || - entry.target.includes('\0') || /[\x01-\x1f\x7f]/.test(entry.target)) fail('TOOL_FAILED', 'Prepared export symlink record is invalid'); + if ( + keys !== 'kind,mode,path,target' || + typeof entry.target !== 'string' || + !entry.target || + isAbsolute(entry.target) || + entry.target.includes('\0') || + /[\x01-\x1f\x7f]/.test(entry.target) + ) + fail('TOOL_FAILED', 'Prepared export symlink record is invalid'); const lexical = resolve(dirname(preparedTarget(destination, entry.path)), entry.target); - if (lexical !== destination && !lexical.startsWith(`${destination}${sep}`)) fail('UNSAFE_PATH', 'Prepared export symlink escapes its destination'); + if (lexical !== destination && !lexical.startsWith(`${destination}${sep}`)) + fail('UNSAFE_PATH', 'Prepared export symlink escapes its destination'); } else fail('TOOL_FAILED', 'Prepared export entry kind is invalid'); } - const ordered = manifest.entries.map(entry => entry.path); - if (ordered.join('\0') !== [...ordered].sort().join('\0')) fail('TOOL_FAILED', 'Prepared export manifest is not in deterministic path order'); - const blobRoot = join(source, 'blobs'), blobRootStat = fs.lstatSync(blobRoot); - if (!blobRootStat.isDirectory() || blobRootStat.isSymbolicLink() || (process.getuid && blobRootStat.uid !== process.getuid()) || - fs.readdirSync(blobRoot).sort().join('\0') !== [...blobs].sort().join('\0')) fail('UNSAFE_PATH', 'Prepared export blob membership does not match its manifest'); + const ordered = manifest.entries.map((entry) => entry.path); + if (ordered.join('\0') !== [...ordered].sort().join('\0')) + fail('TOOL_FAILED', 'Prepared export manifest is not in deterministic path order'); + const blobRoot = join(source, 'blobs'), + blobRootStat = fs.lstatSync(blobRoot); + if ( + !blobRootStat.isDirectory() || + blobRootStat.isSymbolicLink() || + (process.getuid && blobRootStat.uid !== process.getuid()) || + fs.readdirSync(blobRoot).sort().join('\0') !== [...blobs].sort().join('\0') + ) + fail('UNSAFE_PATH', 'Prepared export blob membership does not match its manifest'); - for (const entry of manifest.entries) if (entry.kind === 'directory') fs.mkdirSync(preparedTarget(destination, entry.path), { mode: 0o700 }); - for (const entry of manifest.entries) if (entry.kind === 'file') copyPreparedBlob(join(blobRoot, entry.blob), preparedTarget(destination, entry.path), entry); - for (const entry of manifest.entries) if (entry.kind === 'symlink') fs.symlinkSync(entry.target, preparedTarget(destination, entry.path)); - for (const entry of manifest.entries) if (entry.kind === 'symlink') { - const path = preparedTarget(destination, entry.path); let real: string, stat: fs.Stats; - try { real = fs.realpathSync(path); stat = fs.statSync(path); } catch { fail('UNSAFE_PATH', 'Prepared export symlink is dangling or cyclic'); } - if ((real !== destination && !real.startsWith(`${destination}${sep}`)) || (!stat.isFile() && !stat.isDirectory())) - fail('UNSAFE_PATH', 'Prepared export symlink resolves outside its prepared tree'); - } - for (const entry of [...manifest.entries].reverse()) if (entry.kind === 'directory') fs.chmodSync(preparedTarget(destination, entry.path), entry.mode); + for (const entry of manifest.entries) + if (entry.kind === 'directory') fs.mkdirSync(preparedTarget(destination, entry.path), { mode: 0o700 }); + for (const entry of manifest.entries) + if (entry.kind === 'file') + copyPreparedBlob(join(blobRoot, entry.blob), preparedTarget(destination, entry.path), entry); + for (const entry of manifest.entries) + if (entry.kind === 'symlink') fs.symlinkSync(entry.target, preparedTarget(destination, entry.path)); + for (const entry of manifest.entries) + if (entry.kind === 'symlink') { + const path = preparedTarget(destination, entry.path); + let real: string, stat: fs.Stats; + try { + real = fs.realpathSync(path); + stat = fs.statSync(path); + } catch { + fail('UNSAFE_PATH', 'Prepared export symlink is dangling or cyclic'); + } + if ( + (real !== destination && !real.startsWith(`${destination}${sep}`)) || + (!stat.isFile() && !stat.isDirectory()) + ) + fail('UNSAFE_PATH', 'Prepared export symlink resolves outside its prepared tree'); + } + for (const entry of [...manifest.entries].reverse()) + if (entry.kind === 'directory') fs.chmodSync(preparedTarget(destination, entry.path), entry.mode); return manifest; } function addBlockedIpv4Ranges(blocked: net.BlockList): void { - for (const [address, prefix] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], - ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], - ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]] as Array<[string, number]>) + for (const [address, prefix] of [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], + ] as Array<[string, number]>) blocked.addSubnet(address, prefix, 'ipv4'); } function addBlockedIpv6Ranges(blocked: net.BlockList): void { for (const [address, prefix] of [ ['::', 96], // unspecified, IPv4-compatible, and other deprecated v4 embeddings ['::ffff:0.0.0.0', 96], // IPv4-mapped addresses must not bypass the IPv4 ranges - ['64:ff9b::', 96], ['64:ff9b:1::', 48], // public and local-use NAT64 translators + ['64:ff9b::', 96], + ['64:ff9b:1::', 48], // public and local-use NAT64 translators ['100::', 64], // discard-only ['2001::', 23], // IETF special-purpose assignments (Teredo, ORCHID, benchmarking) ['2001:db8::', 32], // documentation prefix ['2002::', 16], // 6to4 can embed otherwise blocked IPv4 destinations ['3fff::', 20], // documentation prefix - ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8], + ['fc00::', 7], + ['fe80::', 10], + ['fec0::', 10], + ['ff00::', 8], ] as Array<[string, number]>) blocked.addSubnet(address, prefix, 'ipv6'); } @@ -218,21 +446,34 @@ addBlockedIpv6Ranges(registryBlockedIpv6Addresses); /** Pure classification used before any registry connection is attempted. */ export function isBlockedRegistryAddress(address: string, family: 4 | 6): boolean { if (net.isIP(address) !== family) return true; - return (family === 6 ? registryBlockedIpv6Addresses : registryBlockedAddresses).check(address, family === 6 ? 'ipv6' : 'ipv4'); + return (family === 6 ? registryBlockedIpv6Addresses : registryBlockedAddresses).check( + address, + family === 6 ? 'ipv6' : 'ipv4', + ); } -export interface RegistryDnsAddress { address: string; family: 4 | 6 } -export interface RegistryDnsResolution { promise: Promise; cancel(): void } +export interface RegistryDnsAddress { + address: string; + family: 4 | 6; +} +export interface RegistryDnsResolution { + promise: Promise; + cancel(): void; +} export type RegistryDnsResolver = (host: string) => RegistryDnsResolution; function systemRegistryDnsResolver(host: string): RegistryDnsResolution { const resolver = new dns.Resolver(); - const promise = Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]).then(results => { + const promise = Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]).then((results) => { const answers: RegistryDnsAddress[] = []; - if (results[0].status === 'fulfilled') for (const address of results[0].value) answers.push({ address, family: 4 }); - if (results[1].status === 'fulfilled') for (const address of results[1].value) answers.push({ address, family: 6 }); + if (results[0].status === 'fulfilled') + for (const address of results[0].value) answers.push({ address, family: 4 }); + if (results[1].status === 'fulfilled') + for (const address of results[1].value) answers.push({ address, family: 6 }); if (!answers.length) { - const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); if (rejected) throw rejected.reason; } return answers; @@ -248,7 +489,7 @@ function systemRegistryDnsResolver(host: string): RegistryDnsResolution { export class RegistryEgressBroker { readonly contactedHosts = new Set(); readonly deniedHosts = new Set(); - private readonly server = net.createServer(socket => this.accept(socket)); + private readonly server = net.createServer((socket) => this.accept(socket)); private readonly sockets = new Set(); private readonly tasks = new Set>(); private readonly resolutions = new Set<{ cancel(error: CsoError): void }>(); @@ -258,24 +499,43 @@ export class RegistryEgressBroker { private started = false; private closing = false; - constructor(readonly socketPath: string, readonly allowedHosts: string[], private readonly deadline: number, private readonly maxBytes: number, - private readonly resolveDns: RegistryDnsResolver = systemRegistryDnsResolver) { - if (!socketPath.startsWith('/') || !allowedHosts.length || new Set(allowedHosts).size !== allowedHosts.length || - allowedHosts.some(host => host !== host.toLowerCase() || !/^[a-z0-9.-]{1,253}$/.test(host)) || - !Number.isSafeInteger(deadline) || deadline <= Date.now() || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) + constructor( + readonly socketPath: string, + readonly allowedHosts: string[], + private readonly deadline: number, + private readonly maxBytes: number, + private readonly resolveDns: RegistryDnsResolver = systemRegistryDnsResolver, + ) { + if ( + !socketPath.startsWith('/') || + !allowedHosts.length || + new Set(allowedHosts).size !== allowedHosts.length || + allowedHosts.some((host) => host !== host.toLowerCase() || !/^[a-z0-9.-]{1,253}$/.test(host)) || + !Number.isSafeInteger(deadline) || + deadline <= Date.now() || + !Number.isSafeInteger(maxBytes) || + maxBytes <= 0 + ) fail('INVALID_ARGUMENT', 'Registry broker requires a bounded allowlist, deadline, and byte ceiling'); } async start(): Promise { - if (this.started || this.closing) fail('INVALID_ARGUMENT', 'Registry broker cannot be started more than once'); + if (this.started || this.closing) + fail('INVALID_ARGUMENT', 'Registry broker cannot be started more than once'); if (fs.existsSync(this.socketPath)) fail('UNSAFE_PATH', 'Registry broker socket path already exists'); await new Promise((resolveStart, reject) => { - const onError = () => reject(new CsoError('ISOLATION_FAILED', 'Registry broker could not bind its private Unix socket')); + const onError = () => + reject(new CsoError('ISOLATION_FAILED', 'Registry broker could not bind its private Unix socket')); this.server.once('error', onError); - this.server.listen(this.socketPath, () => { this.server.off('error', onError); resolveStart(); }); + this.server.listen(this.socketPath, () => { + this.server.off('error', onError); + resolveStart(); + }); }); this.started = true; - this.server.on('error', () => { this.violation ??= 'Registry broker listener failed'; }); + this.server.on('error', () => { + this.violation ??= 'Registry broker listener failed'; + }); fs.chmodSync(this.socketPath, 0o600); const stat = fs.lstatSync(this.socketPath); if (!stat.isSocket() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) @@ -285,29 +545,55 @@ export class RegistryEgressBroker { private deny(socket: net.Socket, message: string, host?: string): void { this.violation ??= message; if (host) this.deniedHosts.add(host); - try { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); } catch { socket.destroy(); } + try { + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + } catch { + socket.destroy(); + } } private async lookup(host: string, lookupDeadline: number): Promise { const resolution = this.resolveDns(host); - if (!resolution || typeof resolution.cancel !== 'function' || !resolution.promise || typeof resolution.promise.then !== 'function') + if ( + !resolution || + typeof resolution.cancel !== 'function' || + !resolution.promise || + typeof resolution.promise.then !== 'function' + ) fail('ISOLATION_FAILED', 'Registry DNS resolver returned an invalid operation'); - let rejectCancellation!: (error: CsoError) => void, settled = false; - const cancelled = new Promise((_resolve, reject) => { rejectCancellation = reject; }); + let rejectCancellation!: (error: CsoError) => void, + settled = false; + const cancelled = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); const active = { cancel: (error: CsoError) => { if (settled) return; - try { resolution.cancel(); } catch {} + try { + resolution.cancel(); + } catch {} rejectCancellation(error); }, }; this.resolutions.add(active); const remaining = lookupDeadline - Date.now(); if (remaining <= 0) active.cancel(new CsoError('DEADLINE', 'Registry DNS lookup deadline elapsed')); - const timer = remaining > 0 ? setTimeout(() => active.cancel(new CsoError('DEADLINE', 'Registry DNS lookup deadline elapsed')), remaining) : undefined; + const timer = + remaining > 0 + ? setTimeout( + () => active.cancel(new CsoError('DEADLINE', 'Registry DNS lookup deadline elapsed')), + remaining, + ) + : undefined; try { const answers = await Promise.race([resolution.promise, cancelled]); - if (!Array.isArray(answers) || answers.some(answer => !answer || typeof answer.address !== 'string' || (answer.family !== 4 && answer.family !== 6))) + if ( + !Array.isArray(answers) || + answers.some( + (answer) => + !answer || typeof answer.address !== 'string' || (answer.family !== 4 && answer.family !== 6), + ) + ) fail('ISOLATION_FAILED', 'Registry DNS resolver returned invalid addresses'); return answers; } finally { @@ -317,38 +603,70 @@ export class RegistryEgressBroker { } } - private async openTunnel(socket: net.Socket, host: string, remainder: Buffer, connectionDeadline: number): Promise { + private async openTunnel( + socket: net.Socket, + host: string, + remainder: Buffer, + connectionDeadline: number, + ): Promise { try { - const answers = (await this.lookup(host, connectionDeadline)).filter(answer => !isBlockedRegistryAddress(answer.address, answer.family)); + const answers = (await this.lookup(host, connectionDeadline)).filter( + (answer) => !isBlockedRegistryAddress(answer.address, answer.family), + ); if (this.closing || socket.destroyed) return; if (Date.now() >= this.deadline || Date.now() >= connectionDeadline) { - this.deny(socket, 'Registry acquisition deadline elapsed', host); return; + this.deny(socket, 'Registry acquisition deadline elapsed', host); + return; } - if (!answers.length) { this.deny(socket, 'Registry DNS resolved only to blocked or invalid addresses', host); return; } - const identity = answers.map(answer => `${answer.family}:${answer.address}`).sort().join(','); + if (!answers.length) { + this.deny(socket, 'Registry DNS resolved only to blocked or invalid addresses', host); + return; + } + const identity = answers + .map((answer) => `${answer.family}:${answer.address}`) + .sort() + .join(','); const prior = this.pinned.get(host); - if (prior && prior !== identity) { this.deny(socket, 'Registry DNS answers changed during one acquisition', host); return; } + if (prior && prior !== identity) { + this.deny(socket, 'Registry DNS answers changed during one acquisition', host); + return; + } this.pinned.set(host, identity); const selected = answers.sort((a, b) => a.address.localeCompare(b.address))[0]; // No await is permitted between the closing/deadline check and socket // registration: close() must either prevent this dial or destroy it. if (this.closing || socket.destroyed || Date.now() >= this.deadline) return; const upstream = net.connect({ host: selected.address, port: 443, family: selected.family }); - this.sockets.add(upstream); upstream.setTimeout(Math.max(1, Math.min(30_000, this.deadline - Date.now()))); + this.sockets.add(upstream); + upstream.setTimeout(Math.max(1, Math.min(30_000, this.deadline - Date.now()))); upstream.once('close', () => this.sockets.delete(upstream)); - upstream.once('error', () => { if (!this.closing) this.violation ??= 'Registry connection failed after DNS pinning'; socket.destroy(); }); + upstream.once('error', () => { + if (!this.closing) this.violation ??= 'Registry connection failed after DNS pinning'; + socket.destroy(); + }); upstream.once('connect', () => { if (this.closing || socket.destroyed || Date.now() >= this.deadline) { - upstream.destroy(); socket.destroy(); return; + upstream.destroy(); + socket.destroy(); + return; } const addBytes = (bytes: number) => { this.transferred += bytes; if (this.transferred <= this.maxBytes) return true; - this.violation = 'Registry transfer exceeded its byte ceiling'; socket.destroy(); upstream.destroy(); return false; + this.violation = 'Registry transfer exceeded its byte ceiling'; + socket.destroy(); + upstream.destroy(); + return false; }; - const count = (chunk: Buffer) => { addBytes(chunk.length); }; - socket.on('data', count); upstream.on('data', count); socket.pipe(upstream); upstream.pipe(socket); - this.contactedHosts.add(host); socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + const count = (chunk: Buffer) => { + addBytes(chunk.length); + }; + socket.on('data', count); + upstream.on('data', count); + socket.pipe(upstream); + upstream.pipe(socket); + this.contactedHosts.add(host); + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); if (remainder.length && addBytes(remainder.length)) upstream.write(remainder); }); } catch { @@ -358,46 +676,78 @@ export class RegistryEgressBroker { } private accept(socket: net.Socket): void { - if (this.closing || Date.now() >= this.deadline) { socket.destroy(); return; } - if (this.sockets.size >= 64) { this.deny(socket, 'Registry broker connection limit exceeded'); return; } + if (this.closing || Date.now() >= this.deadline) { + socket.destroy(); + return; + } + if (this.sockets.size >= 64) { + this.deny(socket, 'Registry broker connection limit exceeded'); + return; + } const connectionDeadline = Math.min(this.deadline, Date.now() + 30_000); - this.sockets.add(socket); socket.setTimeout(Math.max(1, connectionDeadline - Date.now())); + this.sockets.add(socket); + socket.setTimeout(Math.max(1, connectionDeadline - Date.now())); socket.once('close', () => this.sockets.delete(socket)); - let pending = Buffer.alloc(0), handled = false; + let pending = Buffer.alloc(0), + handled = false; const first = (chunk: Buffer) => { if (handled) return; pending = Buffer.concat([pending, chunk]); - if (pending.length > 16 * 1024) { handled = true; this.deny(socket, 'Registry proxy request exceeded its header limit'); return; } - const end = pending.indexOf('\r\n\r\n'); if (end < 0) return; - handled = true; socket.off('data', first); - const header = pending.subarray(0, end + 4).toString('ascii'), remainder = pending.subarray(end + 4); + if (pending.length > 16 * 1024) { + handled = true; + this.deny(socket, 'Registry proxy request exceeded its header limit'); + return; + } + const end = pending.indexOf('\r\n\r\n'); + if (end < 0) return; + handled = true; + socket.off('data', first); + const header = pending.subarray(0, end + 4).toString('ascii'), + remainder = pending.subarray(end + 4); const line = header.slice(0, header.indexOf('\r\n')); const match = line.match(/^CONNECT ([a-zA-Z0-9.-]+):443 HTTP\/1\.[01]$/); const host = match?.[1].toLowerCase(); - if (!host || header.toLowerCase().includes('\r\nproxy-authorization:') || !this.allowedHosts.includes(host)) { - this.deny(socket, 'Registry proxy rejected a non-allowlisted CONNECT request', host); return; + if ( + !host || + header.toLowerCase().includes('\r\nproxy-authorization:') || + !this.allowedHosts.includes(host) + ) { + this.deny(socket, 'Registry proxy rejected a non-allowlisted CONNECT request', host); + return; } const task = this.openTunnel(socket, host, remainder, connectionDeadline); - this.tasks.add(task); void task.then(() => this.tasks.delete(task), () => this.tasks.delete(task)); + this.tasks.add(task); + void task.then( + () => this.tasks.delete(task), + () => this.tasks.delete(task), + ); }; - socket.on('data', first); socket.once('timeout', () => this.deny(socket, 'Registry proxy connection timed out')); + socket.on('data', first); + socket.once('timeout', () => this.deny(socket, 'Registry proxy connection timed out')); socket.once('error', () => {}); } - assertClean(): void { if (this.violation) fail('ISOLATION_FAILED', this.violation); } + assertClean(): void { + if (this.violation) fail('ISOLATION_FAILED', this.violation); + } async close(): Promise { this.closing = true; - for (const resolution of this.resolutions) resolution.cancel(new CsoError('ISOLATION_FAILED', 'Registry broker closed during DNS resolution')); + for (const resolution of this.resolutions) + resolution.cancel(new CsoError('ISOLATION_FAILED', 'Registry broker closed during DNS resolution')); for (const socket of this.sockets) socket.destroy(); - if (this.started && this.server.listening) await new Promise(resolveClose => this.server.close(() => resolveClose())); + if (this.started && this.server.listening) + await new Promise((resolveClose) => this.server.close(() => resolveClose())); await Promise.allSettled([...this.tasks]); this.started = false; try { const stat = fs.lstatSync(this.socketPath); - if (!stat.isSocket() || stat.isSymbolicLink()) fail('UNSAFE_PATH', 'Registry broker socket changed before cleanup'); + if (!stat.isSocket() || stat.isSymbolicLink()) + fail('UNSAFE_PATH', 'Registry broker socket changed before cleanup'); fs.unlinkSync(this.socketPath); - } catch (error: any) { if (error?.code !== 'ENOENT') throw error; } + } catch (error: any) { + if (error?.code !== 'ENOENT') throw error; + } } } @@ -409,68 +759,170 @@ export interface DockerPreparationRunnerOptions { admission: PreparationRuntimeAdmission; } -export interface PreparedCallGuard { callRoot: string; controlRoot: string; dispose(): Promise } -export interface SupervisedRegistrySocket { root: string; socketPath: string; dispose(): Promise } +export interface PreparedCallGuard { + callRoot: string; + controlRoot: string; + dispose(): Promise; +} +export interface SupervisedRegistrySocket { + root: string; + socketPath: string; + dispose(): Promise; +} /** Darwin's sockaddr_un.sun_path is 104 bytes including its terminator. */ export const REGISTRY_SOCKET_PATH_MAX_BYTES = 90; -function sameDirectory(left:fs.Stats,right:fs.Stats):boolean{return left.dev===right.dev&&left.ino===right.ino&&left.uid===right.uid;} -function ownedDirectory(path:string,label:string):fs.Stats{ - let stat:fs.Stats;try{stat=fs.lstatSync(path);}catch{fail('PERSISTENCE_FAILED',`${label} disappeared`);} - if(!stat!.isDirectory()||stat!.isSymbolicLink()||(process.getuid&&stat!.uid!==process.getuid()))fail('UNSAFE_PATH',`${label} is not one owned directory`); +function sameDirectory(left: fs.Stats, right: fs.Stats): boolean { + return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid; +} +function ownedDirectory(path: string, label: string): fs.Stats { + let stat: fs.Stats; + try { + stat = fs.lstatSync(path); + } catch { + fail('PERSISTENCE_FAILED', `${label} disappeared`); + } + if (!stat!.isDirectory() || stat!.isSymbolicLink() || (process.getuid && stat!.uid !== process.getuid())) + fail('UNSAFE_PATH', `${label} is not one owned directory`); return stat!; } /** Detached guard for a successful retained preparation copy. Exported for fault-injection qualification. */ -export async function supervisePreparedCall(options:{watchdogPath:string;ownerPid:number;deadline:number;runRoot:string;callRoot:string;controlRoot:string}):Promise{ - const run=fs.realpathSync(options.runRoot),call=fs.realpathSync(options.callRoot),control=fs.realpathSync(options.controlRoot); - if(!Number.isSafeInteger(options.ownerPid)||options.ownerPid<=1||!Number.isSafeInteger(options.deadline)||options.deadline<=Date.now()|| - !call.startsWith(`${run}${sep}`)||!control.startsWith(`${run}${sep}`)||call===control)fail('ISOLATION_FAILED','Prepared-copy supervision paths or deadline are invalid'); - const callIdentity=ownedDirectory(call,'Prepared call root'),controlIdentity=ownedDirectory(control,'Prepared supervision control'); - if(!fs.existsSync(options.watchdogPath)||fs.lstatSync(options.watchdogPath).isSymbolicLink())fail('ISOLATION_FAILED','Prepared-copy watchdog is missing from the trusted helper distribution'); - const ready=join(control,'attempt.ready'),terminal=join(control,'attempt.terminal'),stopped=join(control,'attempt.stopped'),event=join(control,'attempt.event'); - const child=spawn(options.watchdogPath,['--attempt-owner',String(options.ownerPid),'--deadline',String(Math.ceil(options.deadline/1000)), - '--control-dir',control,'--work-root',call,'--run-root',run],{cwd:control,env:{PATH:'/usr/bin:/bin'},detached:true,stdio:'ignore'}); - let failed=false;child.once('error',()=>{failed=true;});child.unref(); - for(let attempt=0;attempt<100&&!failed&&!fs.existsSync(ready);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10)); - let alive=false;try{if(child.pid){process.kill(child.pid,0);alive=true;}}catch{} - if(failed||!alive||!fs.existsSync(ready)){ - try{if(child.pid)process.kill(child.pid,'SIGKILL');}catch{} - fail('ISOLATION_FAILED','Prepared-copy watchdog failed its startup handshake'); - } - let disposed=false; - return {callRoot:call,controlRoot:control,dispose:async()=>{ - if(disposed)fail('PERSISTENCE_FAILED','Prepared-copy guard was already disposed'); - disposed=true;let supervised=false; - try{ - const current=fs.lstatSync(call); - if(!sameDirectory(callIdentity,current)||!current.isDirectory()||current.isSymbolicLink())fail('SNAPSHOT_RACE','Prepared call root changed before cleanup'); - fs.rmSync(call,{recursive:true,force:false});supervised=true; - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(error?.code!=='ENOENT'||!fs.existsSync(event))fail('PERSISTENCE_FAILED','Prepared execution copy could not be removed exactly'); +export async function supervisePreparedCall(options: { + watchdogPath: string; + ownerPid: number; + deadline: number; + runRoot: string; + callRoot: string; + controlRoot: string; +}): Promise { + const run = fs.realpathSync(options.runRoot), + call = fs.realpathSync(options.callRoot), + control = fs.realpathSync(options.controlRoot); + if ( + !Number.isSafeInteger(options.ownerPid) || + options.ownerPid <= 1 || + !Number.isSafeInteger(options.deadline) || + options.deadline <= Date.now() || + !call.startsWith(`${run}${sep}`) || + !control.startsWith(`${run}${sep}`) || + call === control + ) + fail('ISOLATION_FAILED', 'Prepared-copy supervision paths or deadline are invalid'); + const callIdentity = ownedDirectory(call, 'Prepared call root'), + controlIdentity = ownedDirectory(control, 'Prepared supervision control'); + if (!fs.existsSync(options.watchdogPath) || fs.lstatSync(options.watchdogPath).isSymbolicLink()) + fail('ISOLATION_FAILED', 'Prepared-copy watchdog is missing from the trusted helper distribution'); + const ready = join(control, 'attempt.ready'), + terminal = join(control, 'attempt.terminal'), + stopped = join(control, 'attempt.stopped'), + event = join(control, 'attempt.event'); + const child = spawn( + options.watchdogPath, + [ + '--attempt-owner', + String(options.ownerPid), + '--deadline', + String(Math.ceil(options.deadline / 1000)), + '--control-dir', + control, + '--work-root', + call, + '--run-root', + run, + ], + { cwd: control, env: { PATH: '/usr/bin:/bin' }, detached: true, stdio: 'ignore' }, + ); + let failed = false; + child.once('error', () => { + failed = true; + }); + child.unref(); + for (let attempt = 0; attempt < 100 && !failed && !fs.existsSync(ready); attempt++) + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + let alive = false; + try { + if (child.pid) { + process.kill(child.pid, 0); + alive = true; } - if(supervised){fs.writeFileSync(terminal,'normal cleanup complete\n',{mode:0o600,flag:'wx'});for(let attempt=0;attempt<100&&!fs.existsSync(stopped);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10));if(!fs.existsSync(stopped))fail('ISOLATION_FAILED','Prepared-copy watchdog did not acknowledge exact cleanup');} - const currentControl=ownedDirectory(control,'Prepared supervision control');if(!sameDirectory(controlIdentity,currentControl))fail('SNAPSHOT_RACE','Prepared supervision control changed before cleanup'); - fs.rmSync(control,{recursive:true,force:false}); - }}; + } catch {} + if (failed || !alive || !fs.existsSync(ready)) { + try { + if (child.pid) process.kill(child.pid, 'SIGKILL'); + } catch {} + fail('ISOLATION_FAILED', 'Prepared-copy watchdog failed its startup handshake'); + } + let disposed = false; + return { + callRoot: call, + controlRoot: control, + dispose: async () => { + if (disposed) fail('PERSISTENCE_FAILED', 'Prepared-copy guard was already disposed'); + disposed = true; + let supervised = false; + try { + const current = fs.lstatSync(call); + if (!sameDirectory(callIdentity, current) || !current.isDirectory() || current.isSymbolicLink()) + fail('SNAPSHOT_RACE', 'Prepared call root changed before cleanup'); + fs.rmSync(call, { recursive: true, force: false }); + supervised = true; + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code !== 'ENOENT' || !fs.existsSync(event)) + fail('PERSISTENCE_FAILED', 'Prepared execution copy could not be removed exactly'); + } + if (supervised) { + fs.writeFileSync(terminal, 'normal cleanup complete\n', { mode: 0o600, flag: 'wx' }); + for (let attempt = 0; attempt < 100 && !fs.existsSync(stopped); attempt++) + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + if (!fs.existsSync(stopped)) + fail('ISOLATION_FAILED', 'Prepared-copy watchdog did not acknowledge exact cleanup'); + } + const currentControl = ownedDirectory(control, 'Prepared supervision control'); + if (!sameDirectory(controlIdentity, currentControl)) + fail('SNAPSHOT_RACE', 'Prepared supervision control changed before cleanup'); + fs.rmSync(control, { recursive: true, force: false }); + }, + }; } -function removeExactDirectory(path:string,identity:fs.Stats,label:string):void{ - let current:fs.Stats; - try{current=fs.lstatSync(path);}catch(error:any){if(error?.code==='ENOENT')return;fail('PERSISTENCE_FAILED',`${label} disappeared before exact cleanup`);} - if(!current!.isDirectory()||current!.isSymbolicLink()||!sameDirectory(identity,current!))fail('SNAPSHOT_RACE',`${label} changed before exact cleanup`); - try{fs.rmSync(path,{recursive:true,force:false});}catch{fail('PERSISTENCE_FAILED',`${label} could not be removed exactly`);} - if(fs.existsSync(path))fail('PERSISTENCE_FAILED',`${label} cleanup could not be proven`); +function removeExactDirectory(path: string, identity: fs.Stats, label: string): void { + let current: fs.Stats; + try { + current = fs.lstatSync(path); + } catch (error: any) { + if (error?.code === 'ENOENT') return; + fail('PERSISTENCE_FAILED', `${label} disappeared before exact cleanup`); + } + if (!current!.isDirectory() || current!.isSymbolicLink() || !sameDirectory(identity, current!)) + fail('SNAPSHOT_RACE', `${label} changed before exact cleanup`); + try { + fs.rmSync(path, { recursive: true, force: false }); + } catch { + fail('PERSISTENCE_FAILED', `${label} could not be removed exactly`); + } + if (fs.existsSync(path)) fail('PERSISTENCE_FAILED', `${label} cleanup could not be proven`); } -function registrySocketBase():{path:string;uid:number}{ - const getuid=process.getuid; - if(process.platform==='win32'||!getuid)fail('PREREQUISITE','Registry acquisition requires local Unix sockets'); - const uid=getuid(); - let temporary:string; - try{temporary=fs.realpathSync('/tmp');}catch{fail('PREREQUISITE','A canonical local temporary directory is required for registry acquisition');} - const stat=fs.lstatSync(temporary!); - if(temporary==='/'||!stat.isDirectory()||stat.isSymbolicLink()||(stat.uid!==0&&stat.uid!==uid)|| - ((stat.mode&0o022)!==0&&(stat.mode&0o1000)===0))fail('UNSAFE_PATH','The local temporary directory is not a trusted sticky directory'); - return {path:temporary!,uid}; +function registrySocketBase(): { path: string; uid: number } { + const getuid = process.getuid; + if (process.platform === 'win32' || !getuid) + fail('PREREQUISITE', 'Registry acquisition requires local Unix sockets'); + const uid = getuid(); + let temporary: string; + try { + temporary = fs.realpathSync('/tmp'); + } catch { + fail('PREREQUISITE', 'A canonical local temporary directory is required for registry acquisition'); + } + const stat = fs.lstatSync(temporary!); + if ( + temporary === '/' || + !stat.isDirectory() || + stat.isSymbolicLink() || + (stat.uid !== 0 && stat.uid !== uid) || + ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) + ) + fail('UNSAFE_PATH', 'The local temporary directory is not a trusted sticky directory'); + return { path: temporary!, uid }; } /** @@ -480,65 +932,165 @@ function registrySocketBase():{path:string;uid:number}{ * owner death, and deadline expiry all remove the same exact root without a * caller-only cleanup interval. */ -export async function superviseRegistrySocket(options:{watchdogPath:string;ownerPid:number;deadline:number}):Promise{ - if(!Number.isSafeInteger(options.ownerPid)||options.ownerPid<=1||!Number.isSafeInteger(options.deadline)||options.deadline<=Date.now()) - fail('ISOLATION_FAILED','Registry socket supervision owner or deadline is invalid'); - let watchdog='',watchdogStat:fs.Stats; - try{watchdog=fs.realpathSync(options.watchdogPath);watchdogStat=fs.lstatSync(options.watchdogPath);}catch{fail('ISOLATION_FAILED','Registry socket watchdog is missing from the trusted helper distribution');} - if(!isAbsolute(options.watchdogPath)||watchdog!==options.watchdogPath||!watchdogStat!.isFile()||watchdogStat!.isSymbolicLink()|| - (watchdogStat!.mode&0o111)===0||(process.getuid&&watchdogStat!.uid!==0&&watchdogStat!.uid!==process.getuid())) - fail('ISOLATION_FAILED','Registry socket watchdog must be one canonical owned executable'); - const {path:base,uid}=registrySocketBase();let root=''; - for(let attempt=0;attempt<4&&!root;attempt++){ - const candidate=join(base,`gscso-${uid}-${randomBytes(16).toString('hex')}`); - try{fs.mkdirSync(candidate,{mode:0o700});root=candidate;}catch(error:any){if(error?.code!=='EEXIST')throw error;} +export async function superviseRegistrySocket(options: { + watchdogPath: string; + ownerPid: number; + deadline: number; +}): Promise { + if ( + !Number.isSafeInteger(options.ownerPid) || + options.ownerPid <= 1 || + !Number.isSafeInteger(options.deadline) || + options.deadline <= Date.now() + ) + fail('ISOLATION_FAILED', 'Registry socket supervision owner or deadline is invalid'); + let watchdog = '', + watchdogStat: fs.Stats; + try { + watchdog = fs.realpathSync(options.watchdogPath); + watchdogStat = fs.lstatSync(options.watchdogPath); + } catch { + fail('ISOLATION_FAILED', 'Registry socket watchdog is missing from the trusted helper distribution'); } - if(!root)fail('INSUFFICIENT_CAPACITY','A unique private registry socket directory could not be allocated'); - fs.chmodSync(root,0o700); - const rootIdentity=ownedDirectory(root,'Registry socket root'),socketPath=join(root,'r.sock'); - if(fs.realpathSync(root)!==root||Buffer.byteLength(socketPath)>REGISTRY_SOCKET_PATH_MAX_BYTES){ - removeExactDirectory(root,rootIdentity,'Registry socket root'); - fail('PREREQUISITE',`The canonical registry socket path exceeds ${REGISTRY_SOCKET_PATH_MAX_BYTES} bytes`); - } - let control='';let child:ReturnType|undefined;let failed=false;let exitCode:number|null|undefined; - try{ - control=secureDirectory(join(root,'control')); - const ready=join(control,'attempt.ready'),terminal=join(control,'attempt.terminal'); - child=spawn(watchdog,['--ephemeral-owner',String(options.ownerPid),'--deadline',String(Math.ceil(options.deadline/1000)), - '--control-dir',control,'--work-root',root,'--run-root',base],{cwd:control,env:{PATH:'/usr/bin:/bin'},detached:true,stdio:'ignore'}); - const exited=new Promise(resolveExit=>child!.once('close',code=>{exitCode=code;resolveExit(code);})); - child.once('error',()=>{failed=true;});child.unref(); - for(let attempt=0;attempt<100&&!failed&&!fs.existsSync(ready);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10)); - let alive=false;try{if(child.pid){process.kill(child.pid,0);alive=true;}}catch{} - if(failed||!alive||!fs.existsSync(ready)){ - try{if(child.pid)process.kill(child.pid,'SIGKILL');}catch{} - await Promise.race([exited,new Promise(resolveWait=>setTimeout(resolveWait,1000))]); - fail('ISOLATION_FAILED','Registry socket watchdog failed its startup handshake'); + if ( + !isAbsolute(options.watchdogPath) || + watchdog !== options.watchdogPath || + !watchdogStat!.isFile() || + watchdogStat!.isSymbolicLink() || + (watchdogStat!.mode & 0o111) === 0 || + (process.getuid && watchdogStat!.uid !== 0 && watchdogStat!.uid !== process.getuid()) + ) + fail('ISOLATION_FAILED', 'Registry socket watchdog must be one canonical owned executable'); + const { path: base, uid } = registrySocketBase(); + let root = ''; + for (let attempt = 0; attempt < 4 && !root; attempt++) { + const candidate = join(base, `gscso-${uid}-${randomBytes(16).toString('hex')}`); + try { + fs.mkdirSync(candidate, { mode: 0o700 }); + root = candidate; + } catch (error: any) { + if (error?.code !== 'EEXIST') throw error; } - let disposed=false; - return {root,socketPath,dispose:async()=>{ - if(disposed)fail('PERSISTENCE_FAILED','Registry socket guard was already disposed'); - disposed=true; - let current:fs.Stats; - try{current=fs.lstatSync(root);}catch(error:any){ - if(error?.code!=='ENOENT')fail('PERSISTENCE_FAILED','Registry socket root disappeared during cleanup'); - const code=exitCode===undefined?await Promise.race([exited,new Promise(resolveWait=>setTimeout(()=>resolveWait(undefined),5000))]):exitCode; - if(code!==0)fail('ISOLATION_FAILED','Registry socket watchdog did not complete abnormal exact cleanup'); - return; + } + if (!root) + fail('INSUFFICIENT_CAPACITY', 'A unique private registry socket directory could not be allocated'); + fs.chmodSync(root, 0o700); + const rootIdentity = ownedDirectory(root, 'Registry socket root'), + socketPath = join(root, 'r.sock'); + if (fs.realpathSync(root) !== root || Buffer.byteLength(socketPath) > REGISTRY_SOCKET_PATH_MAX_BYTES) { + removeExactDirectory(root, rootIdentity, 'Registry socket root'); + fail( + 'PREREQUISITE', + `The canonical registry socket path exceeds ${REGISTRY_SOCKET_PATH_MAX_BYTES} bytes`, + ); + } + let control = ''; + let child: ReturnType | undefined; + let failed = false; + let exitCode: number | null | undefined; + try { + control = secureDirectory(join(root, 'control')); + const ready = join(control, 'attempt.ready'), + terminal = join(control, 'attempt.terminal'); + child = spawn( + watchdog, + [ + '--ephemeral-owner', + String(options.ownerPid), + '--deadline', + String(Math.ceil(options.deadline / 1000)), + '--control-dir', + control, + '--work-root', + root, + '--run-root', + base, + ], + { cwd: control, env: { PATH: '/usr/bin:/bin' }, detached: true, stdio: 'ignore' }, + ); + const exited = new Promise((resolveExit) => + child!.once('close', (code) => { + exitCode = code; + resolveExit(code); + }), + ); + child.once('error', () => { + failed = true; + }); + child.unref(); + for (let attempt = 0; attempt < 100 && !failed && !fs.existsSync(ready); attempt++) + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + let alive = false; + try { + if (child.pid) { + process.kill(child.pid, 0); + alive = true; } - if(!current!.isDirectory()||current!.isSymbolicLink()||!sameDirectory(rootIdentity,current!))fail('SNAPSHOT_RACE','Registry socket root changed before cleanup'); - try{fs.writeFileSync(terminal,'normal cleanup complete\n',{mode:0o600,flag:'wx'});}catch(error:any){ - if(error?.code!=='ENOENT')throw error; - } - for(let attempt=0;attempt<500&&fs.existsSync(root);attempt++)await new Promise(resolveWait=>setTimeout(resolveWait,10)); - if(fs.existsSync(root))fail('ISOLATION_FAILED','Registry socket watchdog did not remove the exact private root'); - const code=exitCode===undefined?await Promise.race([exited,new Promise(resolveWait=>setTimeout(()=>resolveWait(undefined),5000))]):exitCode; - if(code!==0)fail('ISOLATION_FAILED','Registry socket watchdog exited without completing exact cleanup'); - }}; - }catch(error){ - try{if(child?.pid)process.kill(child.pid,'SIGKILL');}catch{} - let cleanupError:unknown;try{removeExactDirectory(root,rootIdentity,'Registry socket root');}catch(failure){cleanupError=failure;} - if(cleanupError)throw cleanupError; + } catch {} + if (failed || !alive || !fs.existsSync(ready)) { + try { + if (child.pid) process.kill(child.pid, 'SIGKILL'); + } catch {} + await Promise.race([exited, new Promise((resolveWait) => setTimeout(resolveWait, 1000))]); + fail('ISOLATION_FAILED', 'Registry socket watchdog failed its startup handshake'); + } + let disposed = false; + return { + root, + socketPath, + dispose: async () => { + if (disposed) fail('PERSISTENCE_FAILED', 'Registry socket guard was already disposed'); + disposed = true; + let current: fs.Stats; + try { + current = fs.lstatSync(root); + } catch (error: any) { + if (error?.code !== 'ENOENT') + fail('PERSISTENCE_FAILED', 'Registry socket root disappeared during cleanup'); + const code = + exitCode === undefined + ? await Promise.race([ + exited, + new Promise((resolveWait) => setTimeout(() => resolveWait(undefined), 5000)), + ]) + : exitCode; + if (code !== 0) + fail('ISOLATION_FAILED', 'Registry socket watchdog did not complete abnormal exact cleanup'); + return; + } + if (!current!.isDirectory() || current!.isSymbolicLink() || !sameDirectory(rootIdentity, current!)) + fail('SNAPSHOT_RACE', 'Registry socket root changed before cleanup'); + try { + fs.writeFileSync(terminal, 'normal cleanup complete\n', { mode: 0o600, flag: 'wx' }); + } catch (error: any) { + if (error?.code !== 'ENOENT') throw error; + } + for (let attempt = 0; attempt < 500 && fs.existsSync(root); attempt++) + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + if (fs.existsSync(root)) + fail('ISOLATION_FAILED', 'Registry socket watchdog did not remove the exact private root'); + const code = + exitCode === undefined + ? await Promise.race([ + exited, + new Promise((resolveWait) => setTimeout(() => resolveWait(undefined), 5000)), + ]) + : exitCode; + if (code !== 0) + fail('ISOLATION_FAILED', 'Registry socket watchdog exited without completing exact cleanup'); + }, + }; + } catch (error) { + try { + if (child?.pid) process.kill(child.pid, 'SIGKILL'); + } catch {} + let cleanupError: unknown; + try { + removeExactDirectory(root, rootIdentity, 'Registry socket root'); + } catch (failure) { + cleanupError = failure; + } + if (cleanupError) throw cleanupError; throw error; } } @@ -551,119 +1103,279 @@ export class DockerPreparationSandboxRunner implements PreparationSandboxRunner readonly qualification; private readonly runtime; private readonly controlRoot: string; - private readonly preparedRoots = new Map(); + private readonly preparedRoots = new Map< + string, + { guard: PreparedCallGuard; callDir: string; callIdentity: fs.Stats } + >(); constructor(private readonly options: DockerPreparationRunnerOptions) { this.runtime = admittedPreparationRuntime(options.admission); - if (!['node', 'bun', 'python', 'rails'].includes(this.runtime.stack) || this.runtime.versions['cso-preparation'] !== '1.0.0') + if ( + !['node', 'bun', 'python', 'rails'].includes(this.runtime.stack) || + this.runtime.versions['cso-preparation'] !== '1.0.0' + ) fail('PREREQUISITE', 'Qualified runtime lacks the cso-preparation=1.0.0 container helper contract'); this.controlRoot = secureDirectory(resolve(options.controlRoot)); - this.qualification = Object.freeze({ schemaVersion: 1 as const, helperAbi: CSO_HELPER_ABI, - runnerId: 'docker-registry-broker-v1', policyVersion: 'cso-preparation-v1' as const, - supportedStacks: [this.runtime.stack as CsoStack], registryRestrictionQualified: true as const, - dnsRebindingTestsPassed: true as const, acquisitionExcludesSource: true as const, - offlineContainmentQualified: true as const, immutableArchiveMounts: true as const, resourceLimitsEnforced: true as const }); + this.qualification = Object.freeze({ + schemaVersion: 1 as const, + helperAbi: CSO_HELPER_ABI, + runnerId: 'docker-registry-broker-v1', + policyVersion: 'cso-preparation-v1' as const, + supportedStacks: [this.runtime.stack as CsoStack], + registryRestrictionQualified: true as const, + dnsRebindingTestsPassed: true as const, + acquisitionExcludesSource: true as const, + offlineContainmentQualified: true as const, + immutableArchiveMounts: true as const, + resourceLimitsEnforced: true as const, + }); } private assertRuntime(request: PreparationAcquireRequest | OfflinePreparationRequest): void { - if (request.runtime.id !== this.runtime.id || request.runtime.image !== this.runtime.image || request.runtime.platform !== this.runtime.platform || request.stack !== this.runtime.stack) + if ( + request.runtime.id !== this.runtime.id || + request.runtime.image !== this.runtime.image || + request.runtime.platform !== this.runtime.platform || + request.stack !== this.runtime.stack + ) fail('INCOMPATIBLE_INPUT', 'Docker preparation request does not match its admitted runtime'); } private callDirectory(prefix: string): string { - return secureDirectory(join(this.controlRoot, `${prefix}-${Date.now()}-${randomBytes(8).toString('hex')}`)); + return secureDirectory( + join(this.controlRoot, `${prefix}-${Date.now()}-${randomBytes(8).toString('hex')}`), + ); } - private removeCallDirectory(path:string,identity:fs.Stats):void{ - let current:fs.Stats;try{current=fs.lstatSync(path);}catch{fail('PERSISTENCE_FAILED','Preparation call directory disappeared before exact cleanup');} - if(!current!.isDirectory()||current!.isSymbolicLink()||current!.dev!==identity.dev||current!.ino!==identity.ino)fail('SNAPSHOT_RACE','Preparation call directory changed before cleanup'); - try{fs.rmSync(path,{recursive:true,force:false});}catch{fail('PERSISTENCE_FAILED','Preparation call directory could not be removed');} - if(fs.existsSync(path))fail('PERSISTENCE_FAILED','Preparation call directory cleanup could not be proven'); + private removeCallDirectory(path: string, identity: fs.Stats): void { + let current: fs.Stats; + try { + current = fs.lstatSync(path); + } catch { + fail('PERSISTENCE_FAILED', 'Preparation call directory disappeared before exact cleanup'); + } + if ( + !current!.isDirectory() || + current!.isSymbolicLink() || + current!.dev !== identity.dev || + current!.ino !== identity.ino + ) + fail('SNAPSHOT_RACE', 'Preparation call directory changed before cleanup'); + try { + fs.rmSync(path, { recursive: true, force: false }); + } catch { + fail('PERSISTENCE_FAILED', 'Preparation call directory could not be removed'); + } + if (fs.existsSync(path)) + fail('PERSISTENCE_FAILED', 'Preparation call directory cleanup could not be proven'); } - private publishArtifacts(artifacts: AcquisitionArtifactReceipt[], output: string, stagingRoot: string, maxBytes: number): void { + private publishArtifacts( + artifacts: AcquisitionArtifactReceipt[], + output: string, + stagingRoot: string, + maxBytes: number, + ): void { const created: string[] = []; try { for (const artifact of artifacts) { - if (typeof artifact.stagingPath !== 'string' || !artifact.stagingPath.startsWith('cso-public/') || - artifact.stagingPath.slice('cso-public/'.length).includes('/')) fail('TOOL_FAILED', 'Qualified helper returned an invalid staging artifact path'); - const name = artifact.stagingPath.slice('cso-public/'.length), source = contained(join(output, 'archives'), name), + if ( + typeof artifact.stagingPath !== 'string' || + !artifact.stagingPath.startsWith('cso-public/') || + artifact.stagingPath.slice('cso-public/'.length).includes('/') + ) + fail('TOOL_FAILED', 'Qualified helper returned an invalid staging artifact path'); + const name = artifact.stagingPath.slice('cso-public/'.length), + source = contained(join(output, 'archives'), name), target = contained(stagingRoot, artifact.stagingPath); const stat = fs.lstatSync(source); - if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size !== artifact.bytes || stat.size > maxBytes || - (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022) !== 0 || fileSha256(source, maxBytes) !== artifact.sha256) + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1 || + stat.size !== artifact.bytes || + stat.size > maxBytes || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o022) !== 0 || + fileSha256(source, maxBytes) !== artifact.sha256 + ) fail('TOOL_FAILED', 'Qualified helper output did not match its archive receipt'); secureDirectory(resolve(target, '..')); - fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); fs.chmodSync(target, 0o600); created.push(target); + fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); + fs.chmodSync(target, 0o600); + created.push(target); const copied = fs.lstatSync(target); - if (!copied.isFile() || copied.isSymbolicLink() || copied.nlink !== 1 || copied.size !== artifact.bytes || - fileSha256(target, maxBytes) !== artifact.sha256) fail('SNAPSHOT_RACE', 'Staged acquisition artifact changed during publication'); + if ( + !copied.isFile() || + copied.isSymbolicLink() || + copied.nlink !== 1 || + copied.size !== artifact.bytes || + fileSha256(target, maxBytes) !== artifact.sha256 + ) + fail('SNAPSHOT_RACE', 'Staged acquisition artifact changed during publication'); } } catch (error) { - for (const path of created.reverse()) { try { const stat = fs.lstatSync(path); if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) fs.unlinkSync(path); } catch {} } + for (const path of created.reverse()) { + try { + const stat = fs.lstatSync(path); + if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) fs.unlinkSync(path); + } catch {} + } throw error; } } - private execClean(group: DockerGroup, id: string, command: string[], options: { workdir?: string; env?: Record } = {}) { - const forbidden = new Set(['BUN_OPTIONS', 'BUN_BE_BUN', 'NODE_OPTIONS', 'RUBYOPT', 'RUBYLIB', 'PYTHONPATH', 'PYTHONHOME', - 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'ENV', 'BASH_ENV', 'CDPATH']); - if (Object.keys(options.env ?? {}).some(key => forbidden.has(key))) fail('ISOLATION_FAILED', 'Preparation command attempted to restore a runtime injection variable'); + private execClean( + group: DockerGroup, + id: string, + command: string[], + options: { workdir?: string; env?: Record } = {}, + ) { + const forbidden = new Set([ + 'BUN_OPTIONS', + 'BUN_BE_BUN', + 'NODE_OPTIONS', + 'RUBYOPT', + 'RUBYLIB', + 'PYTHONPATH', + 'PYTHONHOME', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'ENV', + 'BASH_ENV', + 'CDPATH', + ]); + if (Object.keys(options.env ?? {}).some((key) => forbidden.has(key))) + fail('ISOLATION_FAILED', 'Preparation command attempted to restore a runtime injection variable'); const clean = { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/work/.cso-home', ...(options.env ?? {}) }; - const argv = ['/usr/bin/env', '-i', ...Object.entries(clean).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`), ...command]; + const argv = [ + '/usr/bin/env', + '-i', + ...Object.entries(clean) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`), + ...command, + ]; return group.execCapture(id, argv, { workdir: options.workdir }); } async acquire(request: PreparationAcquireRequest): Promise { this.assertRuntime(request); - const dir = this.callDirectory('acquire'),callIdentity = fs.lstatSync(dir); - let group: DockerGroup | undefined, broker: RegistryEgressBroker | undefined,guard:PreparedCallGuard|undefined, - registrySocket:SupervisedRegistrySocket|undefined; + const dir = this.callDirectory('acquire'), + callIdentity = fs.lstatSync(dir); + let group: DockerGroup | undefined, + broker: RegistryEgressBroker | undefined, + guard: PreparedCallGuard | undefined, + registrySocket: SupervisedRegistrySocket | undefined; const commandResults: PreparationCommandReceipt[] = []; try { - const executionCopies=secureDirectory(join(dir,'execution-copies')),metadata = secureDirectory(join(executionCopies, 'metadata')), - policyDir = secureDirectory(join(executionCopies, 'policy')),output = secureDirectory(join(executionCopies, 'output')), - runRoot=secureDirectory(resolve(this.options.runRoot??this.controlRoot)),supervision=secureDirectory(join(runRoot,'supervision')), - guardControl=secureDirectory(join(supervision,`acquisition-${randomBytes(12).toString('hex')}`)); - guard=await supervisePreparedCall({watchdogPath:this.options.watchdogPath,ownerPid:process.pid,deadline:request.deadline, - runRoot,callRoot:executionCopies,controlRoot:guardControl}); + const executionCopies = secureDirectory(join(dir, 'execution-copies')), + metadata = secureDirectory(join(executionCopies, 'metadata')), + policyDir = secureDirectory(join(executionCopies, 'policy')), + output = secureDirectory(join(executionCopies, 'output')), + runRoot = secureDirectory(resolve(this.options.runRoot ?? this.controlRoot)), + supervision = secureDirectory(join(runRoot, 'supervision')), + guardControl = secureDirectory(join(supervision, `acquisition-${randomBytes(12).toString('hex')}`)); + guard = await supervisePreparedCall({ + watchdogPath: this.options.watchdogPath, + ownerPid: process.pid, + deadline: request.deadline, + runRoot, + callRoot: executionCopies, + controlRoot: guardControl, + }); for (const item of request.metadata) { - if (sha256(item.content) !== item.sha256) fail('INCOMPATIBLE_INPUT', `Acquisition metadata hash changed: ${item.path}`); - const file = contained(metadata, item.path); secureDirectory(resolve(file, '..')); fs.writeFileSync(file, item.content, { mode: 0o600, flag: 'wx' }); + if (sha256(item.content) !== item.sha256) + fail('INCOMPATIBLE_INPUT', `Acquisition metadata hash changed: ${item.path}`); + const file = contained(metadata, item.path); + secureDirectory(resolve(file, '..')); + fs.writeFileSync(file, item.content, { mode: 0o600, flag: 'wx' }); } const policyFile = join(policyDir, 'acquisition.json'); - writePolicy(policyFile, { schemaVersion: 1, planHash: request.planHash, stack: request.stack, inputs: request.inputs, - commands: request.commands, allowedHosts: request.network.allowedHosts, archiveRoot: '/archives', limits: request.limits }); - group = await DockerGroup.create(this.options.endpoint, `prep-a-${randomBytes(10).toString('hex')}`, dir, - request.deadline, this.runtime.image, this.options.watchdogPath); - registrySocket=await superviseRegistrySocket({watchdogPath:this.options.watchdogPath,ownerPid:process.pid,deadline:request.deadline}); - broker = new RegistryEgressBroker(registrySocket.socketPath, request.network.allowedHosts, request.deadline, - request.limits.maxTotalArchiveBytes + Math.min(64 * 1024 * 1024, request.limits.maxTotalArchiveBytes)); + writePolicy(policyFile, { + schemaVersion: 1, + planHash: request.planHash, + stack: request.stack, + inputs: request.inputs, + commands: request.commands, + allowedHosts: request.network.allowedHosts, + archiveRoot: '/archives', + limits: request.limits, + }); + group = await DockerGroup.create( + this.options.endpoint, + `prep-a-${randomBytes(10).toString('hex')}`, + dir, + request.deadline, + this.runtime.image, + this.options.watchdogPath, + ); + registrySocket = await superviseRegistrySocket({ + watchdogPath: this.options.watchdogPath, + ownerPid: process.pid, + deadline: request.deadline, + }); + broker = new RegistryEgressBroker( + registrySocket.socketPath, + request.network.allowedHosts, + request.deadline, + request.limits.maxTotalArchiveBytes + Math.min(64 * 1024 * 1024, request.limits.maxTotalArchiveBytes), + ); await broker.start(); - const container = await group.createContainer({ role: 'app', image: this.runtime.image, + const container = await group.createContainer({ + role: 'app', + image: this.runtime.image, command: ['/opt/cso/preparation', 'forwarder', '/run/cso-registry.sock', '127.0.0.1:18443'], - readonlyFiles: [{ host: policyFile, container: '/policy/acquisition.json' }], readonlyInputMetadata: metadata, - workTmpfsBytes: 64 * 1024 * 1024, temporaryTmpfsBytes: 64 * 1024 * 1024, + readonlyFiles: [{ host: policyFile, container: '/policy/acquisition.json' }], + readonlyInputMetadata: metadata, + workTmpfsBytes: 64 * 1024 * 1024, + temporaryTmpfsBytes: 64 * 1024 * 1024, metadataTmpfsBytes: (request.stack === 'node' || request.stack === 'bun' ? 1024 : 64) * 1024 * 1024, - archiveTmpfsBytes: request.limits.maxTotalArchiveBytes, registrySocket: broker.socketPath }); + archiveTmpfsBytes: request.limits.maxTotalArchiveBytes, + registrySocket: broker.socketPath, + }); await group.start(container); let ready = false; for (let attempt = 0; attempt < 20 && !ready; attempt++) { - const health = await this.execClean(group, container, ['/opt/cso/preparation', 'health', '127.0.0.1', '18443']); - ready = health.code === 0; if (!ready) await new Promise(resolveWait => setTimeout(resolveWait, 25)); + const health = await this.execClean(group, container, [ + '/opt/cso/preparation', + 'health', + '127.0.0.1', + '18443', + ]); + ready = health.code === 0; + if (!ready) await new Promise((resolveWait) => setTimeout(resolveWait, 25)); } if (!ready) fail('ISOLATION_FAILED', 'Qualified registry forwarder did not become ready'); - const proxy = { HTTP_PROXY: 'http://127.0.0.1:18443', HTTPS_PROXY: 'http://127.0.0.1:18443', - ALL_PROXY: 'http://127.0.0.1:18443', NO_PROXY: '' }; + const proxy = { + HTTP_PROXY: 'http://127.0.0.1:18443', + HTTPS_PROXY: 'http://127.0.0.1:18443', + ALL_PROXY: 'http://127.0.0.1:18443', + NO_PROXY: '', + }; for (let index = 0; index < request.commands.length; index++) { - const command = request.commands[index], result = await this.execClean(group, container, - [command.executable, ...command.args], { workdir: command.cwd, env: { ...command.env, ...proxy } }); - broker.assertClean(); commandResults.push(commandReceipt(command, index, result.code)); + const command = request.commands[index], + result = await this.execClean(group, container, [command.executable, ...command.args], { + workdir: command.cwd, + env: { ...command.env, ...proxy }, + }); + broker.assertClean(); + commandResults.push(commandReceipt(command, index, result.code)); if (result.code !== 0) fail('TOOL_FAILED', `Dependency acquisition command ${index + 1} failed`); } await group.assertOnlyInitProcess(container); const containerExport = `/archives/.gstack-cso-acquisition-export-${randomBytes(12).toString('hex')}`; - const manifest = await this.execClean(group, container, - ['/opt/cso/preparation', 'manifest', '/policy/acquisition.json', '/archives', `${containerExport}/artifacts.json`], { workdir: '/archives', env: proxy }); + const manifest = await this.execClean( + group, + container, + [ + '/opt/cso/preparation', + 'manifest', + '/policy/acquisition.json', + '/archives', + `${containerExport}/artifacts.json`, + ], + { workdir: '/archives', env: proxy }, + ); if (manifest.code !== 0) fail('TOOL_FAILED', 'Qualified archive manifest helper failed'); broker.assertClean(); await group.assertOnlyInitProcess(container); @@ -672,144 +1384,299 @@ export class DockerPreparationSandboxRunner implements PreparationSandboxRunner const artifacts = readManifest(join(output, 'artifacts.json')).artifacts; validateAcquisitionOutput(output, artifacts); this.publishArtifacts(artifacts, output, request.stagingRoot, request.limits.maxArchiveBytes); - return { schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id, runtimeImage: request.runtime.image, - platform: request.runtime.platform, deadlineEnforced: true, network: { mode: 'registry-restricted', - allowedHosts: [...request.network.allowedHosts], contactedHosts: [...broker.contactedHosts].sort(), + return { + schemaVersion: 1, + planHash: request.planHash, + runtimeId: request.runtime.id, + runtimeImage: request.runtime.image, + platform: request.runtime.platform, + deadlineEnforced: true, + network: { + mode: 'registry-restricted', + allowedHosts: [...request.network.allowedHosts], + contactedHosts: [...broker.contactedHosts].sort(), redirectVisibility: 'opaque-tls', - dnsRebindingBlocked: true, credentialsMounted: false, sourceMounted: false, dockerSocketMounted: false }, - lifecycleScriptsExecuted: false, targetCodeExecuted: false, commands: commandResults, artifacts }; + dnsRebindingBlocked: true, + credentialsMounted: false, + sourceMounted: false, + dockerSocketMounted: false, + }, + lifecycleScriptsExecuted: false, + targetCodeExecuted: false, + commands: commandResults, + artifacts, + }; } finally { let cleanupError: unknown; - try { if (broker) await broker.close(); } catch (error) { cleanupError = error; } - try { if (group) await group.cleanup(); } catch (error) { cleanupError ??= error; } - try { if (registrySocket) await registrySocket.dispose(); } catch (error) { cleanupError ??= error; } - try { if (guard) await guard.dispose(); } catch (error) { cleanupError ??= error; } + try { + if (broker) await broker.close(); + } catch (error) { + cleanupError = error; + } + try { + if (group) await group.cleanup(); + } catch (error) { + cleanupError ??= error; + } + try { + if (registrySocket) await registrySocket.dispose(); + } catch (error) { + cleanupError ??= error; + } + try { + if (guard) await guard.dispose(); + } catch (error) { + cleanupError ??= error; + } if (cleanupError) throw cleanupError; - this.removeCallDirectory(dir,callIdentity); + this.removeCallDirectory(dir, callIdentity); } } private materializeArchives(request: OfflinePreparationRequest, root: string): void { for (const archive of request.archives) { - if (!archive.containerPath.startsWith('/archives/')) fail('UNSAFE_PATH', 'Offline archive mount escaped /archives'); - const relativePath = archive.containerPath.slice('/archives/'.length), target = contained(root, relativePath), source = resolve(archive.hostPath); + if (!archive.containerPath.startsWith('/archives/')) + fail('UNSAFE_PATH', 'Offline archive mount escaped /archives'); + const relativePath = archive.containerPath.slice('/archives/'.length), + target = contained(root, relativePath), + source = resolve(archive.hostPath); const stat = fs.lstatSync(source); - if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size !== archive.bytes || (stat.mode & 0o222) !== 0 || - (process.getuid && stat.uid !== process.getuid())) fail('UNSAFE_PATH', 'Offline archive is not an immutable cache file'); - secureDirectory(resolve(target, '..')); fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); fs.chmodSync(target, 0o400); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1 || + stat.size !== archive.bytes || + (stat.mode & 0o222) !== 0 || + (process.getuid && stat.uid !== process.getuid()) + ) + fail('UNSAFE_PATH', 'Offline archive is not an immutable cache file'); + secureDirectory(resolve(target, '..')); + fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); + fs.chmodSync(target, 0o400); const hash = fileSha256(target, archive.bytes); - if (hash !== archive.sha256) fail('INCOMPATIBLE_INPUT', 'Offline archive changed while its execution view was materialized'); + if (hash !== archive.sha256) + fail('INCOMPATIBLE_INPUT', 'Offline archive changed while its execution view was materialized'); } } async prepareOffline(request: OfflinePreparationRequest): Promise { this.assertRuntime(request); - const dir = this.callDirectory('offline'),callIdentity=fs.lstatSync(dir);let preparedRoot='',group: DockerGroup | undefined,success=false,guard:PreparedCallGuard|undefined; + const dir = this.callDirectory('offline'), + callIdentity = fs.lstatSync(dir); + let preparedRoot = '', + group: DockerGroup | undefined, + success = false, + guard: PreparedCallGuard | undefined; try { - // Docker supervision owns `dir`; the retained-copy watchdog owns this - // child. An owner death can therefore remove every execution input without - // deleting the Docker watchdog's journal or control files. - const executionCopies=secureDirectory(join(dir,'execution-copies')), - source = secureDirectory(join(executionCopies, 'source')), metadata = secureDirectory(join(executionCopies, 'metadata')), - archives = secureDirectory(join(executionCopies, 'archives')), policyDir = secureDirectory(join(executionCopies, 'policy')); - preparedRoot = secureDirectory(join(executionCopies, 'prepared')); - const runRoot=secureDirectory(resolve(this.options.runRoot??this.controlRoot)),supervision=secureDirectory(join(runRoot,'supervision')), - guardControl=secureDirectory(join(supervision,`prepared-${randomBytes(12).toString('hex')}`)); - // Supervision is acknowledged before the first potentially large source - // or dependency copy. It remains the retained-copy guard after Docker - // teardown, closing the owner-death handoff window. - guard=await supervisePreparedCall({watchdogPath:this.options.watchdogPath,ownerPid:process.pid,deadline:request.deadline, - runRoot,callRoot:executionCopies,controlRoot:guardControl}); - fs.cpSync(request.sourceRoot, source, { recursive: true, force: false, errorOnExist: false, preserveTimestamps: false }); - for (const transformation of request.transformations) { - if (sha256(transformation.content) !== transformation.sha256) fail('INCOMPATIBLE_INPUT', 'Offline transformation content hash changed'); - const file = contained(source, transformation.path); secureDirectory(resolve(file, '..')); fs.writeFileSync(file, transformation.content, { mode: 0o600 }); - } - for (const item of request.metadata) { - if (sha256(item.content) !== item.sha256) fail('INCOMPATIBLE_INPUT', `Offline metadata hash changed: ${item.path}`); - const file = contained(metadata, item.path); secureDirectory(resolve(file, '..')); fs.writeFileSync(file, item.content, { mode: 0o600, flag: 'wx' }); - } - this.materializeArchives(request, archives); - const offlinePolicy = join(policyDir, 'offline.json'); - writePolicy(offlinePolicy, { schemaVersion: 1, planHash: request.planHash, stack: request.stack, - inputs: request.archives.map(archive => ({ index: archive.inputIndex, input: { kind: 'public', name: archive.name, - version: archive.version, url: archive.requestedUrl, integrity: archive.declaredIntegrity, - integritySource: archive.declaredIntegrity === 'registry-on-acquisition' ? 'registry-on-acquisition' : 'lock' } })), - archives: request.archives.map(archive => ({ inputIndex: archive.inputIndex, name: archive.name, version: archive.version, - declaredIntegrity: archive.declaredIntegrity, requestedUrl: archive.requestedUrl, resolvedUrl: archive.resolvedUrl, - containerPath: archive.containerPath, - sha256: archive.sha256, bytes: archive.bytes })) }); - const commands: PreparationCommandReceipt[] = []; - group = await DockerGroup.create(this.options.endpoint, `prep-o-${randomBytes(10).toString('hex')}`, dir, - request.deadline, this.runtime.image, this.options.watchdogPath); - const app = await group.createContainer({ role: 'app', image: this.runtime.image, source, - readonlyFiles: [{ host: offlinePolicy, container: '/policy/offline.json' }], readonlyMetadata: metadata, readonlyArchiveDirectory: archives, - command: ['/opt/cso/run-app', '/opt/cso/preparation', 'seed', '/policy/offline.json'] }); + // Docker supervision owns `dir`; the retained-copy watchdog owns this + // child. An owner death can therefore remove every execution input without + // deleting the Docker watchdog's journal or control files. + const executionCopies = secureDirectory(join(dir, 'execution-copies')), + source = secureDirectory(join(executionCopies, 'source')), + metadata = secureDirectory(join(executionCopies, 'metadata')), + archives = secureDirectory(join(executionCopies, 'archives')), + policyDir = secureDirectory(join(executionCopies, 'policy')); + preparedRoot = secureDirectory(join(executionCopies, 'prepared')); + const runRoot = secureDirectory(resolve(this.options.runRoot ?? this.controlRoot)), + supervision = secureDirectory(join(runRoot, 'supervision')), + guardControl = secureDirectory(join(supervision, `prepared-${randomBytes(12).toString('hex')}`)); + // Supervision is acknowledged before the first potentially large source + // or dependency copy. It remains the retained-copy guard after Docker + // teardown, closing the owner-death handoff window. + guard = await supervisePreparedCall({ + watchdogPath: this.options.watchdogPath, + ownerPid: process.pid, + deadline: request.deadline, + runRoot, + callRoot: executionCopies, + controlRoot: guardControl, + }); + fs.cpSync(request.sourceRoot, source, { + recursive: true, + force: false, + errorOnExist: false, + preserveTimestamps: false, + }); + for (const transformation of request.transformations) { + if (sha256(transformation.content) !== transformation.sha256) + fail('INCOMPATIBLE_INPUT', 'Offline transformation content hash changed'); + const file = contained(source, transformation.path); + secureDirectory(resolve(file, '..')); + fs.writeFileSync(file, transformation.content, { mode: 0o600 }); + } + for (const item of request.metadata) { + if (sha256(item.content) !== item.sha256) + fail('INCOMPATIBLE_INPUT', `Offline metadata hash changed: ${item.path}`); + const file = contained(metadata, item.path); + secureDirectory(resolve(file, '..')); + fs.writeFileSync(file, item.content, { mode: 0o600, flag: 'wx' }); + } + this.materializeArchives(request, archives); + const offlinePolicy = join(policyDir, 'offline.json'); + writePolicy(offlinePolicy, { + schemaVersion: 1, + planHash: request.planHash, + stack: request.stack, + inputs: request.archives.map((archive) => ({ + index: archive.inputIndex, + input: { + kind: 'public', + name: archive.name, + version: archive.version, + url: archive.requestedUrl, + integrity: archive.declaredIntegrity, + integritySource: + archive.declaredIntegrity === 'registry-on-acquisition' ? 'registry-on-acquisition' : 'lock', + }, + })), + archives: request.archives.map((archive) => ({ + inputIndex: archive.inputIndex, + name: archive.name, + version: archive.version, + declaredIntegrity: archive.declaredIntegrity, + requestedUrl: archive.requestedUrl, + resolvedUrl: archive.resolvedUrl, + containerPath: archive.containerPath, + sha256: archive.sha256, + bytes: archive.bytes, + })), + }); + const commands: PreparationCommandReceipt[] = []; + group = await DockerGroup.create( + this.options.endpoint, + `prep-o-${randomBytes(10).toString('hex')}`, + dir, + request.deadline, + this.runtime.image, + this.options.watchdogPath, + ); + const app = await group.createContainer({ + role: 'app', + image: this.runtime.image, + source, + readonlyFiles: [{ host: offlinePolicy, container: '/policy/offline.json' }], + readonlyMetadata: metadata, + readonlyArchiveDirectory: archives, + command: ['/opt/cso/run-app', '/opt/cso/preparation', 'seed', '/policy/offline.json'], + }); await group.start(app); let ready = false; for (let attempt = 0; attempt < 100 && !ready; attempt++) { const result = await this.execClean(group, app, ['/opt/cso/preparation', 'ready']); - ready = result.code === 0; if (!ready) await new Promise(resolveWait => setTimeout(resolveWait, 25)); + ready = result.code === 0; + if (!ready) await new Promise((resolveWait) => setTimeout(resolveWait, 25)); } if (!ready) fail('TOOL_FAILED', 'Offline cache seeding did not become ready'); for (let index = 0; index < request.commands.length; index++) { - const command = request.commands[index], result = await this.execClean(group, app, - [command.executable, ...command.args], { workdir: command.cwd, env: command.env }); + const command = request.commands[index], + result = await this.execClean(group, app, [command.executable, ...command.args], { + workdir: command.cwd, + env: command.env, + }); commands.push(commandReceipt(command, index, result.code)); - if (result.code !== 0) fail('TOOL_FAILED', `Offline dependency preparation command ${index + 1} failed`); + if (result.code !== 0) + fail('TOOL_FAILED', `Offline dependency preparation command ${index + 1} failed`); } const finalized = await this.execClean(group, app, ['/opt/cso/preparation', 'finalize']); if (finalized.code !== 0) fail('TOOL_FAILED', 'Offline preparation scratch cleanup failed'); await group.assertOnlyInitProcess(app); const containerExport = `/work/.gstack-cso-export-${randomBytes(12).toString('hex')}`; - const exported = await this.execClean(group, app, - ['/opt/cso/preparation', 'export-prepared', '/work', containerExport, String(request.limits.writableBytes)]); - if (exported.code !== 0) fail('TOOL_FAILED', 'Qualified prepared-tree export rejected offline application output'); + const exported = await this.execClean(group, app, [ + '/opt/cso/preparation', + 'export-prepared', + '/work', + containerExport, + String(request.limits.writableBytes), + ]); + if (exported.code !== 0) + fail('TOOL_FAILED', 'Qualified prepared-tree export rejected offline application output'); await group.assertOnlyInitProcess(app); await group.pause(app); - const inertExport = secureDirectory(join(executionCopies, 'prepared-export')), inertIdentity = fs.lstatSync(inertExport); + const inertExport = secureDirectory(join(executionCopies, 'prepared-export')), + inertIdentity = fs.lstatSync(inertExport); await group.copyPreparedExport(app, containerExport, inertExport); materializePreparedExport(inertExport, preparedRoot, request.limits.writableBytes); - this.removeCallDirectory(inertExport,inertIdentity); + this.removeCallDirectory(inertExport, inertIdentity); const services: Array<'application' | 'postgresql'> = ['application']; - const receipt: OfflinePreparationReceipt = { schemaVersion: 1, planHash: request.planHash, runtimeId: request.runtime.id, - runtimeImage: request.runtime.image, platform: request.runtime.platform, sourceHash: request.sourceHash, - dependencyClosureHash: request.dependencyClosureHash, configurationHash: request.configurationHash, - databaseHash: request.databaseHash, deadlineEnforced: true, - network: { mode: 'none', namespaceAnchor: group.anchor, externalEgress: false, dnsAvailable: false, publishedPorts: false, services }, - commands, inputSourceReadOnly: true, preparedCopySeparate: true, archivesReadOnly: true, applicationCodeExecutedOnlyOffline: true }; + const receipt: OfflinePreparationReceipt = { + schemaVersion: 1, + planHash: request.planHash, + runtimeId: request.runtime.id, + runtimeImage: request.runtime.image, + platform: request.runtime.platform, + sourceHash: request.sourceHash, + dependencyClosureHash: request.dependencyClosureHash, + configurationHash: request.configurationHash, + databaseHash: request.databaseHash, + deadlineEnforced: true, + network: { + mode: 'none', + namespaceAnchor: group.anchor, + externalEgress: false, + dnsAvailable: false, + publishedPorts: false, + services, + }, + commands, + inputSourceReadOnly: true, + preparedCopySeparate: true, + archivesReadOnly: true, + applicationCodeExecutedOnlyOffline: true, + }; // The guard is live before Docker teardown starts. Remove redundant // source, metadata, archives, and policy now; only the prepared tree is // retained. Docker's independent watchdog keeps its parent control root. - for(const current of [source,metadata,archives,policyDir])this.removeCallDirectory(current,fs.lstatSync(current)); - const completedGroup=group;await completedGroup.cleanup();group=undefined; - this.preparedRoots.set(resolve(preparedRoot),{guard,callDir:dir,callIdentity}); - success=true; + for (const current of [source, metadata, archives, policyDir]) + this.removeCallDirectory(current, fs.lstatSync(current)); + const completedGroup = group; + await completedGroup.cleanup(); + group = undefined; + this.preparedRoots.set(resolve(preparedRoot), { guard, callDir: dir, callIdentity }); + success = true; return { preparedRoot, receipt }; } finally { - let cleanupError:unknown; + let cleanupError: unknown; if (group) { - try { await group.cleanup(); } - catch (error) { cleanupError=error; } + try { + await group.cleanup(); + } catch (error) { + cleanupError = error; + } } - if(cleanupError){if(preparedRoot)this.preparedRoots.delete(resolve(preparedRoot));if(guard)try{await guard.dispose();}catch{}throw cleanupError;} - if(!success){ - if(preparedRoot)this.preparedRoots.delete(resolve(preparedRoot)); + if (cleanupError) { + if (preparedRoot) this.preparedRoots.delete(resolve(preparedRoot)); + if (guard) + try { + await guard.dispose(); + } catch {} + throw cleanupError; + } + if (!success) { + if (preparedRoot) this.preparedRoots.delete(resolve(preparedRoot)); // Once the retained-copy watchdog has acknowledged supervision it is // the only actor allowed to consume its call root. Stop and // acknowledge it before removing the Docker call directory. - if(guard)await guard.dispose(); - this.removeCallDirectory(dir,callIdentity); + if (guard) await guard.dispose(); + this.removeCallDirectory(dir, callIdentity); } } } async disposePrepared(preparedRoot: string): Promise { - const root = resolve(preparedRoot), owned = this.preparedRoots.get(root),guard=owned?.guard,call=guard?.callRoot; - if (!guard || !call || !owned || root !== join(call, 'prepared') || !owned.callDir.startsWith(`${this.controlRoot}${sep}`)) + const root = resolve(preparedRoot), + owned = this.preparedRoots.get(root), + guard = owned?.guard, + call = guard?.callRoot; + if ( + !guard || + !call || + !owned || + root !== join(call, 'prepared') || + !owned.callDir.startsWith(`${this.controlRoot}${sep}`) + ) fail('UNSAFE_PATH', 'Prepared execution copy is not owned by this runner'); this.preparedRoots.delete(root); await guard.dispose(); - this.removeCallDirectory(owned.callDir,owned.callIdentity); + this.removeCallDirectory(owned.callDir, owned.callIdentity); } } diff --git a/lib/cso/preparation-executor.ts b/lib/cso/preparation-executor.ts index ffa20067d..e2ff177af 100644 --- a/lib/cso/preparation-executor.ts +++ b/lib/cso/preparation-executor.ts @@ -11,12 +11,22 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { PublicArchiveCache } from './cache'; import { canonical, CsoError, MAX_OUTPUT, sha256 } from './contracts'; import { - inspectPreparation, railsTestConfiguration, type CsoStack, type PreparationCommand, - type PreparationInput, type PreparationPlan, + inspectPreparation, + railsTestConfiguration, + type CsoStack, + type PreparationCommand, + type PreparationInput, + type PreparationPlan, } from './preparation'; import { - assertRuntimeCompatible, CSO_HELPER_ABI, RUNTIME_CATALOG, selectRuntime, validateRuntimeCatalog, - type QualifiedRuntime, type RuntimeCatalog, type RuntimePlatform, + assertRuntimeCompatible, + CSO_HELPER_ABI, + RUNTIME_CATALOG, + selectRuntime, + validateRuntimeCatalog, + type QualifiedRuntime, + type RuntimeCatalog, + type RuntimePlatform, } from './runtime-catalog'; const SHA256 = /^[a-f0-9]{64}$/; @@ -36,7 +46,9 @@ const MAX_PREPARED_BYTES = 736 * 1024 * 1024; const MAX_ACQUISITION_ARCHIVE_BYTES = 384 * 1024 * 1024; const admittedRuntimes = new WeakSet(); -function fail(code: ConstructorParameters[0], message: string): never { throw new CsoError(code, message); } +function fail(code: ConstructorParameters[0], message: string): never { + throw new CsoError(code, message); +} function sameStrings(left: string[], right: string[]): boolean { return canonical([...left].sort()) === canonical([...right].sort()); } @@ -49,10 +61,14 @@ function deepFreeze(value: T): T { } function checkDeadline(deadline: number, signal?: AbortSignal): void { if (signal?.aborted) fail('CANCELLED', 'Preparation was cancelled before the operation completed'); - if (!Number.isSafeInteger(deadline) || deadline <= 0) fail('INVALID_ARGUMENT', 'Preparation deadline must be an absolute millisecond timestamp'); - if (Date.now() >= deadline) fail('DEADLINE', 'Preparation deadline was reached before the operation completed'); + if (!Number.isSafeInteger(deadline) || deadline <= 0) + fail('INVALID_ARGUMENT', 'Preparation deadline must be an absolute millisecond timestamp'); + if (Date.now() >= deadline) + fail('DEADLINE', 'Preparation deadline was reached before the operation completed'); +} +function safeMessage(error: unknown): string { + return error instanceof Error ? error.message.slice(0, 500) : 'Runtime catalog admission failed'; } -function safeMessage(error: unknown): string { return error instanceof Error ? error.message.slice(0, 500) : 'Runtime catalog admission failed'; } export interface PreparationRuntimeAdmission { schemaVersion: 1; @@ -68,13 +84,23 @@ export function admitPreparationRuntime(options: { catalog?: RuntimeCatalog; }): PreparationRuntimeAdmission { const catalog = options.catalog ?? RUNTIME_CATALOG; - try { validateRuntimeCatalog(catalog); } - catch (error) { fail('PREREQUISITE', `Runtime catalog is not qualified: ${safeMessage(error)}`); } + try { + validateRuntimeCatalog(catalog); + } catch (error) { + fail('PREREQUISITE', `Runtime catalog is not qualified: ${safeMessage(error)}`); + } let runtime: QualifiedRuntime; - try { runtime = selectRuntime(options.profile ?? options.plan.runtimeProfile, options.platform, catalog); } - catch (error) { fail('PREREQUISITE', safeMessage(error)); } + try { + runtime = selectRuntime(options.profile ?? options.plan.runtimeProfile, options.platform, catalog); + } catch (error) { + fail('PREREQUISITE', safeMessage(error)); + } assertRuntimeCompatible(options.plan, runtime!); - const admission = Object.freeze({ schemaVersion: 1 as const, catalogRevision: catalog.revision, runtime: runtime! }); + const admission = Object.freeze({ + schemaVersion: 1 as const, + catalogRevision: catalog.revision, + runtime: runtime!, + }); admittedRuntimes.add(admission); return admission; } @@ -86,13 +112,24 @@ export function admitPreparationSidecar(options: { catalog?: RuntimeCatalog; }): PreparationRuntimeAdmission { const catalog = options.catalog ?? RUNTIME_CATALOG; - try { validateRuntimeCatalog(catalog); } - catch (error) { fail('PREREQUISITE', `Runtime catalog is not qualified: ${safeMessage(error)}`); } + try { + validateRuntimeCatalog(catalog); + } catch (error) { + fail('PREREQUISITE', `Runtime catalog is not qualified: ${safeMessage(error)}`); + } let runtime: QualifiedRuntime; - try { runtime = selectRuntime(options.profile ?? 'postgresql', options.platform, catalog); } - catch (error) { fail('PREREQUISITE', safeMessage(error)); } - if (runtime!.stack !== 'postgresql') fail('INCOMPATIBLE_INPUT', 'Rails PostgreSQL preparation requires a qualified PostgreSQL runtime'); - const admission = Object.freeze({ schemaVersion: 1 as const, catalogRevision: catalog.revision, runtime: runtime! }); + try { + runtime = selectRuntime(options.profile ?? 'postgresql', options.platform, catalog); + } catch (error) { + fail('PREREQUISITE', safeMessage(error)); + } + if (runtime!.stack !== 'postgresql') + fail('INCOMPATIBLE_INPUT', 'Rails PostgreSQL preparation requires a qualified PostgreSQL runtime'); + const admission = Object.freeze({ + schemaVersion: 1 as const, + catalogRevision: catalog.revision, + runtime: runtime!, + }); admittedRuntimes.add(admission); return admission; } @@ -167,7 +204,12 @@ export interface PreparationAcquireRequest { commands: PreparationCommand[]; stagingRoot: string; deadline: number; - limits: { maxArchives: number; maxArchiveBytes: number; maxTotalArchiveBytes: number; maxOutputBytes: number }; + limits: { + maxArchives: number; + maxArchiveBytes: number; + maxTotalArchiveBytes: number; + maxOutputBytes: number; + }; network: { mode: 'registry-restricted'; allowedHosts: string[] }; sourceMounted: false; } @@ -186,8 +228,7 @@ export interface CachedArchiveMount { } export type RailsDatabaseSelection = - | { adapter: 'sqlite' } - | { adapter: 'postgresql'; sidecar: PreparationRuntimeAdmission }; + { adapter: 'sqlite' } | { adapter: 'postgresql'; sidecar: PreparationRuntimeAdmission }; export type PreparedDatabaseContract = | { adapter: 'sqlite'; connections: string[] } @@ -298,68 +339,130 @@ export interface PreparedApplication { receipt: OfflinePreparationReceipt; } -function runtimeFromAdmission(plan: PreparationPlan, admission: PreparationRuntimeAdmission): QualifiedRuntime { - if (!admission || !admittedRuntimes.has(admission)) fail('PREREQUISITE', 'Runtime must be selected through current-process catalog admission'); +function runtimeFromAdmission( + plan: PreparationPlan, + admission: PreparationRuntimeAdmission, +): QualifiedRuntime { + if (!admission || !admittedRuntimes.has(admission)) + fail('PREREQUISITE', 'Runtime must be selected through current-process catalog admission'); assertRuntimeCompatible(plan, admission.runtime); return admission.runtime; } /** Trusted adapters use this to bind themselves to a current-process catalog admission. */ export function admittedPreparationRuntime(admission: PreparationRuntimeAdmission): QualifiedRuntime { - if (!admission || !admittedRuntimes.has(admission)) fail('PREREQUISITE', 'Runtime must be selected through current-process catalog admission'); + if (!admission || !admittedRuntimes.has(admission)) + fail('PREREQUISITE', 'Runtime must be selected through current-process catalog admission'); return admission.runtime; } -function planHash(plan: PreparationPlan): string { return sha256(canonical(plan)); } -function commandHash(command: PreparationCommand): string { return sha256(canonical(command)); } +function planHash(plan: PreparationPlan): string { + return sha256(canonical(plan)); +} +function commandHash(command: PreparationCommand): string { + return sha256(canonical(command)); +} function validateRunner(runner: PreparationSandboxRunner, stack: CsoStack): void { const q = runner?.qualification; - if (!q || q.schemaVersion !== 1 || q.helperAbi !== CSO_HELPER_ABI || !/^[a-z0-9][a-z0-9._-]{0,100}$/.test(q.runnerId) || - q.policyVersion !== 'cso-preparation-v1' || !Array.isArray(q.supportedStacks) || !q.supportedStacks.includes(stack) || - new Set(q.supportedStacks).size !== q.supportedStacks.length || q.registryRestrictionQualified !== true || - q.dnsRebindingTestsPassed !== true || q.acquisitionExcludesSource !== true || q.offlineContainmentQualified !== true || - q.immutableArchiveMounts !== true || q.resourceLimitsEnforced !== true || typeof runner.disposePrepared !== 'function') + if ( + !q || + q.schemaVersion !== 1 || + q.helperAbi !== CSO_HELPER_ABI || + !/^[a-z0-9][a-z0-9._-]{0,100}$/.test(q.runnerId) || + q.policyVersion !== 'cso-preparation-v1' || + !Array.isArray(q.supportedStacks) || + !q.supportedStacks.includes(stack) || + new Set(q.supportedStacks).size !== q.supportedStacks.length || + q.registryRestrictionQualified !== true || + q.dnsRebindingTestsPassed !== true || + q.acquisitionExcludesSource !== true || + q.offlineContainmentQualified !== true || + q.immutableArchiveMounts !== true || + q.resourceLimitsEnforced !== true || + typeof runner.disposePrepared !== 'function' + ) fail('ISOLATION_FAILED', `Preparation runner is not qualified for ${stack}`); } function validatePlan(plan: PreparationPlan, snapshot: string): string { - if (!plan || plan.schemaVersion !== 1 || plan.status !== 'ready') fail('PREREQUISITE', 'Dependency metadata is not ready for automatic preparation'); + if (!plan || plan.schemaVersion !== 1 || plan.status !== 'ready') + fail('PREREQUISITE', 'Dependency metadata is not ready for automatic preparation'); const current = inspectPreparation(snapshot, plan.stack); if (current.status !== 'ready' || canonical(current) !== canonical(plan)) fail('INCOMPATIBLE_INPUT', 'Preparation plan does not match the current retained snapshot'); const hosts = plan.registryHosts; - if (!hosts.length || hosts.some(host => host !== host.toLowerCase() || !HOST.test(host)) || new Set(hosts).size !== hosts.length) + if ( + !hosts.length || + hosts.some((host) => host !== host.toLowerCase() || !HOST.test(host)) || + new Set(hosts).size !== hosts.length + ) fail('INVALID_SCHEMA', 'Preparation plan contains invalid or duplicate registry hosts'); - if (plan.inputs.filter(input => input.kind === 'public').length && !plan.acquisition.length) + if (plan.inputs.filter((input) => input.kind === 'public').length && !plan.acquisition.length) fail('INVALID_SCHEMA', 'Public dependencies require a constrained acquisition command'); for (const command of [...plan.acquisition, ...plan.offline]) { - if (!command.executable.startsWith('/') || !['/metadata', '/work', '/archives'].includes(command.cwd) || !Array.isArray(command.args) || - command.args.some(arg => typeof arg !== 'string' || arg.length > 4096 || /[\0\r\n]/.test(arg)) || - Object.entries(command.env).some(([key, value]) => !/^[A-Z][A-Z0-9_]{0,63}$/.test(key) || /[\0\r\n]/.test(value))) + if ( + !command.executable.startsWith('/') || + !['/metadata', '/work', '/archives'].includes(command.cwd) || + !Array.isArray(command.args) || + command.args.some((arg) => typeof arg !== 'string' || arg.length > 4096 || /[\0\r\n]/.test(arg)) || + Object.entries(command.env).some( + ([key, value]) => !/^[A-Z][A-Z0-9_]{0,63}$/.test(key) || /[\0\r\n]/.test(value), + ) + ) fail('INVALID_SCHEMA', 'Preparation command is not a bounded absolute argv/env description'); } for (const command of plan.acquisition) { - if (command.cwd === '/work' || command.args.some(arg => arg === '/work' || arg.startsWith('/work/'))) + if (command.cwd === '/work' || command.args.some((arg) => arg === '/work' || arg.startsWith('/work/'))) fail('ISOLATION_FAILED', 'Acquisition commands must not receive application source'); } - if (plan.stack === 'node' && plan.acquisition.some(command => !command.args.includes('--ignore-scripts'))) + if (plan.stack === 'node' && plan.acquisition.some((command) => !command.args.includes('--ignore-scripts'))) fail('ISOLATION_FAILED', 'Node acquisition must disable lifecycle scripts'); - if (plan.stack === 'bun' && plan.acquisition.some(command => !command.args.includes('--ignore-scripts'))) + if (plan.stack === 'bun' && plan.acquisition.some((command) => !command.args.includes('--ignore-scripts'))) fail('ISOLATION_FAILED', 'Bun acquisition must disable lifecycle scripts'); - if (plan.stack === 'python' && plan.acquisition.some(command => !['/usr/local/bin/uv', '/usr/local/bin/python'].includes(command.executable) || command.args.includes('install'))) + if ( + plan.stack === 'python' && + plan.acquisition.some( + (command) => + !['/usr/local/bin/uv', '/usr/local/bin/python'].includes(command.executable) || + command.args.includes('install'), + ) + ) fail('ISOLATION_FAILED', 'Python acquisition may only export metadata or download public wheels'); - if (plan.stack === 'rails' && plan.acquisition.some(command => command.executable !== '/usr/local/bin/gem' || command.args[0] !== 'fetch' || !command.args.includes('--norc'))) - fail('ISOLATION_FAILED', 'Rails acquisition may only fetch exact gems without evaluating application code'); + if ( + plan.stack === 'rails' && + plan.acquisition.some( + (command) => + command.executable !== '/usr/local/bin/gem' || + command.args[0] !== 'fetch' || + !command.args.includes('--norc'), + ) + ) + fail( + 'ISOLATION_FAILED', + 'Rails acquisition may only fetch exact gems without evaluating application code', + ); return planHash(plan); } -function validateCommandReceipts(receipts: PreparationCommandReceipt[], commands: PreparationCommand[]): void { - if (!Array.isArray(receipts) || receipts.length !== commands.length) fail('TOOL_FAILED', 'Preparation receipt omitted a command result'); +function validateCommandReceipts( + receipts: PreparationCommandReceipt[], + commands: PreparationCommand[], +): void { + if (!Array.isArray(receipts) || receipts.length !== commands.length) + fail('TOOL_FAILED', 'Preparation receipt omitted a command result'); const seen = new Set(); for (const receipt of receipts) { - if (!Number.isInteger(receipt.index) || receipt.index < 0 || receipt.index >= commands.length || seen.has(receipt.index) || - receipt.commandHash !== commandHash(commands[receipt.index]) || receipt.exitCode !== 0 || receipt.timedOut !== false || receipt.outputTruncated !== false) + if ( + !Number.isInteger(receipt.index) || + receipt.index < 0 || + receipt.index >= commands.length || + seen.has(receipt.index) || + receipt.commandHash !== commandHash(commands[receipt.index]) || + receipt.exitCode !== 0 || + receipt.timedOut !== false || + receipt.outputTruncated !== false + ) fail('TOOL_FAILED', 'Preparation command failed or its exact argv receipt is invalid'); seen.add(receipt.index); } @@ -367,19 +470,41 @@ function validateCommandReceipts(receipts: PreparationCommandReceipt[], commands function validateUrl(value: string, host: string, allowed: string[]): void { let url: URL; - try { url = new URL(value); } catch { fail('TOOL_FAILED', 'Acquisition receipt contains an invalid source URL'); } - if (url!.protocol !== 'https:' || url!.username || url!.password || url!.port || url!.search || url!.hash || - url!.hostname !== host || !allowed.includes(host)) fail('ISOLATION_FAILED', 'Acquisition receipt escaped its public registry allowlist'); + try { + url = new URL(value); + } catch { + fail('TOOL_FAILED', 'Acquisition receipt contains an invalid source URL'); + } + if ( + url!.protocol !== 'https:' || + url!.username || + url!.password || + url!.port || + url!.search || + url!.hash || + url!.hostname !== host || + !allowed.includes(host) + ) + fail('ISOLATION_FAILED', 'Acquisition receipt escaped its public registry allowlist'); } function relativeArchivePath(value: string, label: string): string { - if (typeof value !== 'string' || !RELATIVE_ARCHIVE.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) + if ( + typeof value !== 'string' || + !RELATIVE_ARCHIVE.test(value) || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) fail('UNSAFE_PATH', `${label} must be a contained archive path`); return value; } -function stagedFileHashes(root: string, relativePath: string, expectedBytes: number, deadline: number, - signal?: AbortSignal): { sha256: string; sha512: string } { +function stagedFileHashes( + root: string, + relativePath: string, + expectedBytes: number, + deadline: number, + signal?: AbortSignal, +): { sha256: string; sha512: string } { checkDeadline(deadline, signal); const parts = relativeArchivePath(relativePath, 'Staged archive').split('/'); let cursor = root; @@ -387,21 +512,41 @@ function stagedFileHashes(root: string, relativePath: string, expectedBytes: num checkDeadline(deadline, signal); cursor = join(cursor, part); let stat: fs.Stats; - try { stat = fs.lstatSync(cursor); } catch { fail('MISSING_INPUT', 'Acquisition staging directory is missing'); } - if (!stat!.isDirectory() || stat!.isSymbolicLink() || (process.getuid && stat!.uid !== process.getuid()) || (stat!.mode & 0o022) !== 0) + try { + stat = fs.lstatSync(cursor); + } catch { + fail('MISSING_INPUT', 'Acquisition staging directory is missing'); + } + if ( + !stat!.isDirectory() || + stat!.isSymbolicLink() || + (process.getuid && stat!.uid !== process.getuid()) || + (stat!.mode & 0o022) !== 0 + ) fail('UNSAFE_PATH', 'Acquisition staging path has an unsafe ancestor'); } const path = resolve(root, ...parts); if (!path.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', 'Staged archive escaped acquisition storage'); const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; let fd: number; - try { fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); } - catch { fail('UNSAFE_PATH', 'Staged archive could not be opened without following links'); } + try { + fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); + } catch { + fail('UNSAFE_PATH', 'Staged archive could not be opened without following links'); + } try { const before = fs.fstatSync(fd!); - if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || (process.getuid && before.uid !== process.getuid()) || - before.size !== expectedBytes) fail('UNSAFE_PATH', 'Staged archive is not the bounded regular file in its receipt'); - const h256 = createHash('sha256'), h512 = createHash('sha512'), buffer = Buffer.allocUnsafe(64 * 1024); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== 1 || + (process.getuid && before.uid !== process.getuid()) || + before.size !== expectedBytes + ) + fail('UNSAFE_PATH', 'Staged archive is not the bounded regular file in its receipt'); + const h256 = createHash('sha256'), + h512 = createHash('sha512'), + buffer = Buffer.allocUnsafe(64 * 1024); let bytes = 0; for (;;) { checkDeadline(deadline, signal); @@ -409,86 +554,174 @@ function stagedFileHashes(root: string, relativePath: string, expectedBytes: num if (!count) break; bytes += count; if (bytes > expectedBytes) fail('SNAPSHOT_RACE', 'Staged archive grew while it was verified'); - h256.update(buffer.subarray(0, count)); h512.update(buffer.subarray(0, count)); + h256.update(buffer.subarray(0, count)); + h512.update(buffer.subarray(0, count)); checkDeadline(deadline, signal); } checkDeadline(deadline, signal); const after = fs.fstatSync(fd!); - if (bytes !== expectedBytes || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || - before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || before.mode !== after.mode || before.nlink !== after.nlink) + if ( + bytes !== expectedBytes || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs || + before.mode !== after.mode || + before.nlink !== after.nlink + ) fail('SNAPSHOT_RACE', 'Staged archive changed while it was verified'); return { sha256: h256.digest('hex'), sha512: h512.digest('base64') }; - } finally { fs.closeSync(fd!); } + } finally { + fs.closeSync(fd!); + } } -function integrityMatches(integrity: string | undefined, hashes: { sha256: string; sha512: string }): boolean { +function integrityMatches( + integrity: string | undefined, + hashes: { sha256: string; sha512: string }, +): boolean { if (!integrity) return false; - return integrity.split(/\s+/).some(value => value === `sha256:${hashes.sha256}` || - value === `sha256-${Buffer.from(hashes.sha256, 'hex').toString('base64')}` || value === `sha512-${hashes.sha512}`); + return integrity + .split(/\s+/) + .some( + (value) => + value === `sha256:${hashes.sha256}` || + value === `sha256-${Buffer.from(hashes.sha256, 'hex').toString('base64')}` || + value === `sha512-${hashes.sha512}`, + ); } -function closureIdentity(closure: Omit): string { return sha256(canonical(closure)); } -function logicalInput(input: PreparationInput): string { return `${input.name}\0${input.version}`; } +function closureIdentity(closure: Omit): string { + return sha256(canonical(closure)); +} +function logicalInput(input: PreparationInput): string { + return `${input.name}\0${input.version}`; +} -function validateClosure(plan: PreparationPlan, admission: PreparationRuntimeAdmission, closure: DependencyClosure, - cache: PublicArchiveCache, deadline: number, signal?: AbortSignal, - materializationBase?: string): { mounts: CachedArchiveMount[]; materializedRoot?: string } { +function validateClosure( + plan: PreparationPlan, + admission: PreparationRuntimeAdmission, + closure: DependencyClosure, + cache: PublicArchiveCache, + deadline: number, + signal?: AbortSignal, + materializationBase?: string, +): { mounts: CachedArchiveMount[]; materializedRoot?: string } { checkDeadline(deadline, signal); - const runtime = admission.runtime, expectedPlanHash = planHash(plan); + const runtime = admission.runtime, + expectedPlanHash = planHash(plan); const { closureHash, ...closureBody } = closure ?? ({} as DependencyClosure); - if (!closure || closure.schemaVersion !== 1 || closure.stack !== plan.stack || closure.planHash !== expectedPlanHash || - closure.catalogRevision !== admission.catalogRevision || closure.runtimeId !== runtime.id || closure.runtimeImage !== runtime.image || - closure.platform !== runtime.platform || !Array.isArray(closure.archives) || closure.archives.length > MAX_ARCHIVES || !SHA256.test(closureHash) || - closureHash !== closureIdentity(closureBody)) + if ( + !closure || + closure.schemaVersion !== 1 || + closure.stack !== plan.stack || + closure.planHash !== expectedPlanHash || + closure.catalogRevision !== admission.catalogRevision || + closure.runtimeId !== runtime.id || + closure.runtimeImage !== runtime.image || + closure.platform !== runtime.platform || + !Array.isArray(closure.archives) || + closure.archives.length > MAX_ARCHIVES || + !SHA256.test(closureHash) || + closureHash !== closureIdentity(closureBody) + ) fail('INCOMPATIBLE_INPUT', 'Dependency closure does not match the admitted plan and runtime'); - const publicInputs = plan.inputs.map((input, index) => ({ input, index })).filter(item => item.input.kind === 'public'); - if (publicInputs.length > MAX_ARCHIVES) fail('INSUFFICIENT_CAPACITY', 'Dependency closure exceeds the archive-count limit'); - const publicInputsByIndex = new Map(publicInputs.map(item => [item.index, item])); - const covered = new Set(), paths = new Set(), pending: Array<{ archive: DependencyArchive; input: PreparationInput }> = []; + const publicInputs = plan.inputs + .map((input, index) => ({ input, index })) + .filter((item) => item.input.kind === 'public'); + if (publicInputs.length > MAX_ARCHIVES) + fail('INSUFFICIENT_CAPACITY', 'Dependency closure exceeds the archive-count limit'); + const publicInputsByIndex = new Map(publicInputs.map((item) => [item.index, item])); + const covered = new Set(), + paths = new Set(), + pending: Array<{ archive: DependencyArchive; input: PreparationInput }> = []; for (const archive of closure.archives) { checkDeadline(deadline, signal); const selected = publicInputsByIndex.get(archive.inputIndex); - if (!selected || archive.name !== selected.input.name || archive.version !== selected.input.version || - archive.declaredIntegrity !== (selected.input.integrity ?? 'registry-on-acquisition') || !SHA256.test(archive.sha256) || - !Number.isSafeInteger(archive.bytes) || archive.bytes < 0 || !plan.registryHosts.includes(archive.requestedHost)) + if ( + !selected || + archive.name !== selected.input.name || + archive.version !== selected.input.version || + archive.declaredIntegrity !== (selected.input.integrity ?? 'registry-on-acquisition') || + !SHA256.test(archive.sha256) || + !Number.isSafeInteger(archive.bytes) || + archive.bytes < 0 || + !plan.registryHosts.includes(archive.requestedHost) + ) fail('INCOMPATIBLE_INPUT', 'Dependency closure contains an invalid public archive'); relativeArchivePath(archive.installPath, 'Dependency install path'); validateUrl(archive.requestedUrl, archive.requestedHost, plan.registryHosts); - if (archive.resolvedUrl !== null) validateUrl(archive.resolvedUrl, new URL(archive.resolvedUrl).hostname, plan.registryHosts); - if (paths.has(archive.installPath)) fail('INCOMPATIBLE_INPUT', 'Dependency closure contains colliding archive install paths'); - paths.add(archive.installPath); covered.add(logicalInput(selected.input)); + if (archive.resolvedUrl !== null) + validateUrl(archive.resolvedUrl, new URL(archive.resolvedUrl).hostname, plan.registryHosts); + if (paths.has(archive.installPath)) + fail('INCOMPATIBLE_INPUT', 'Dependency closure contains colliding archive install paths'); + paths.add(archive.installPath); + covered.add(logicalInput(selected.input)); pending.push({ archive, input: selected.input }); } for (const { input } of publicInputs) { checkDeadline(deadline, signal); if (!covered.has(logicalInput(input))) - fail('MISSING_INPUT', `Dependency closure does not contain a compatible public archive for ${input.name}@${input.version}`); + fail( + 'MISSING_INPUT', + `Dependency closure does not contain a compatible public archive for ${input.name}@${input.version}`, + ); } if (!pending.length) return { mounts: [] }; let base: string; if (materializationBase) { base = resolve(materializationBase); - const stat = fs.lstatSync(base), real = fs.realpathSync(base); - if (!stat.isDirectory() || stat.isSymbolicLink() || real !== base || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022) !== 0) + const stat = fs.lstatSync(base), + real = fs.realpathSync(base); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + real !== base || + (process.getuid && stat.uid !== process.getuid()) || + (stat.mode & 0o022) !== 0 + ) fail('UNSAFE_PATH', 'Archive materialization root must be one private owned directory'); } else base = tmpdir(); const materializedRoot = fs.mkdtempSync(join(base, 'gstack-cso-archives-')); fs.chmodSync(materializedRoot, 0o700); try { checkDeadline(deadline, signal); - const copies = cache.materialize(pending.map(item => item.archive.sha256), materializedRoot, { deadline, signal }), - byDigest = new Map(copies.map(copy => [copy.sha256, copy])); + const copies = cache.materialize( + pending.map((item) => item.archive.sha256), + materializedRoot, + { deadline, signal }, + ), + byDigest = new Map(copies.map((copy) => [copy.sha256, copy])); const mounts = pending.map(({ archive }) => { checkDeadline(deadline, signal); const copy = byDigest.get(archive.sha256); - if (!copy || copy.bytes !== archive.bytes) fail('MISSING_INPUT', `Verified public archive is missing from the offline cache: ${archive.name}@${archive.version}`); - return { inputIndex: archive.inputIndex, name: archive.name, version: archive.version, - declaredIntegrity: archive.declaredIntegrity, requestedUrl: archive.requestedUrl, resolvedUrl: archive.resolvedUrl, hostPath: copy.path, - containerPath: `/archives/${archive.installPath}`, sha256: archive.sha256, bytes: archive.bytes }; + if (!copy || copy.bytes !== archive.bytes) + fail( + 'MISSING_INPUT', + `Verified public archive is missing from the offline cache: ${archive.name}@${archive.version}`, + ); + return { + inputIndex: archive.inputIndex, + name: archive.name, + version: archive.version, + declaredIntegrity: archive.declaredIntegrity, + requestedUrl: archive.requestedUrl, + resolvedUrl: archive.resolvedUrl, + hostPath: copy.path, + containerPath: `/archives/${archive.installPath}`, + sha256: archive.sha256, + bytes: archive.bytes, + }; }); - return { mounts: mounts.sort((a, b) => a.containerPath.localeCompare(b.containerPath)), materializedRoot }; + return { + mounts: mounts.sort((a, b) => a.containerPath.localeCompare(b.containerPath)), + materializedRoot, + }; } catch (error) { - try { fs.rmSync(materializedRoot, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(materializedRoot, { recursive: true, force: true }); + } catch {} throw error; } } @@ -496,39 +729,59 @@ function validateClosure(plan: PreparationPlan, admission: PreparationRuntimeAdm type TreeEntry = { path: string; kind: 'file' | 'symlink'; mode: number; bytes: number; sha256: string }; function boundedTreeNames(directory: string, deadline: number, signal?: AbortSignal): string[] { checkDeadline(deadline, signal); - const handle = fs.opendirSync(directory), names: string[] = []; + const handle = fs.opendirSync(directory), + names: string[] = []; try { for (;;) { checkDeadline(deadline, signal); const entry = handle.readSync(); if (!entry) break; - if (names.length >= MAX_TREE_FILES) fail('INSUFFICIENT_CAPACITY', 'Preparation directory exceeds its bounded manifest limit'); + if (names.length >= MAX_TREE_FILES) + fail('INSUFFICIENT_CAPACITY', 'Preparation directory exceeds its bounded manifest limit'); names.push(entry.name); } - } finally { handle.closeSync(); } + } finally { + handle.closeSync(); + } checkDeadline(deadline, signal); names.sort(); checkDeadline(deadline, signal); return names; } -function treeManifest(rootPath: string, maxBytes: number, deadline: number, allowContainedSymlinks = false, - signal?: AbortSignal): TreeEntry[] { +function treeManifest( + rootPath: string, + maxBytes: number, + deadline: number, + allowContainedSymlinks = false, + signal?: AbortSignal, +): TreeEntry[] { checkDeadline(deadline, signal); const root = resolve(rootPath); let rootStat: fs.Stats, canonicalRoot: string; - try { rootStat = fs.lstatSync(root); canonicalRoot = fs.realpathSync(root); } - catch { fail('MISSING_INPUT', 'Preparation source or output directory is missing'); } - if (!rootStat!.isDirectory() || rootStat!.isSymbolicLink() || canonicalRoot! !== root || - (process.getuid && rootStat!.uid !== process.getuid()) || (rootStat!.mode & 0o022) !== 0) + try { + rootStat = fs.lstatSync(root); + canonicalRoot = fs.realpathSync(root); + } catch { + fail('MISSING_INPUT', 'Preparation source or output directory is missing'); + } + if ( + !rootStat!.isDirectory() || + rootStat!.isSymbolicLink() || + canonicalRoot! !== root || + (process.getuid && rootStat!.uid !== process.getuid()) || + (rootStat!.mode & 0o022) !== 0 + ) fail('UNSAFE_PATH', 'Preparation source or output must be one private real directory'); const entries: TreeEntry[] = []; - let total = 0, nodes = 0; + let total = 0, + nodes = 0; const walk = (directory: string, prefix = ''): void => { checkDeadline(deadline, signal); const before = boundedTreeNames(directory, deadline, signal); for (const name of before) { checkDeadline(deadline, signal); - const path = join(directory, name), relativePath = prefix ? `${prefix}/${name}` : name; + const path = join(directory, name), + relativePath = prefix ? `${prefix}/${name}` : name; nodes++; if (nodes > MAX_TREE_FILES || Buffer.byteLength(name) > 255 || Buffer.byteLength(relativePath) > 4096) fail('INSUFFICIENT_CAPACITY', 'Preparation tree exceeds its bounded manifest limit'); @@ -536,147 +789,337 @@ function treeManifest(rootPath: string, maxBytes: number, deadline: number, allo if (process.getuid && stat.uid !== process.getuid()) fail('UNSAFE_PATH', `Preparation tree contains an unsafe object: ${relativePath}`); if (stat.isSymbolicLink()) { - if (!allowContainedSymlinks) fail('UNSAFE_PATH', `Preparation tree contains an unsafe object: ${relativePath}`); + if (!allowContainedSymlinks) + fail('UNSAFE_PATH', `Preparation tree contains an unsafe object: ${relativePath}`); const target = fs.readlinkSync(path); - if (!target || isAbsolute(target) || target.includes('\0')) fail('UNSAFE_PATH', `Prepared dependency symlink is not relative: ${relativePath}`); + if (!target || isAbsolute(target) || target.includes('\0')) + fail('UNSAFE_PATH', `Prepared dependency symlink is not relative: ${relativePath}`); const lexicalTarget = resolve(dirname(path), target); - if (lexicalTarget !== root && !lexicalTarget.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', `Prepared dependency symlink escapes its execution copy: ${relativePath}`); + if (lexicalTarget !== root && !lexicalTarget.startsWith(`${root}${sep}`)) + fail('UNSAFE_PATH', `Prepared dependency symlink escapes its execution copy: ${relativePath}`); let resolvedTarget: string, targetStat: fs.Stats; - try { resolvedTarget = fs.realpathSync(path); targetStat = fs.statSync(path); } - catch { fail('UNSAFE_PATH', `Prepared dependency symlink is dangling or cyclic: ${relativePath}`); } - if (resolvedTarget! !== root && !resolvedTarget!.startsWith(`${root}${sep}`)) fail('UNSAFE_PATH', `Prepared dependency symlink escapes its execution copy: ${relativePath}`); - if (!targetStat!.isFile() && !targetStat!.isDirectory()) fail('UNSAFE_PATH', `Prepared dependency symlink resolves to a special object: ${relativePath}`); + try { + resolvedTarget = fs.realpathSync(path); + targetStat = fs.statSync(path); + } catch { + fail('UNSAFE_PATH', `Prepared dependency symlink is dangling or cyclic: ${relativePath}`); + } + if (resolvedTarget! !== root && !resolvedTarget!.startsWith(`${root}${sep}`)) + fail('UNSAFE_PATH', `Prepared dependency symlink escapes its execution copy: ${relativePath}`); + if (!targetStat!.isFile() && !targetStat!.isDirectory()) + fail('UNSAFE_PATH', `Prepared dependency symlink resolves to a special object: ${relativePath}`); const after = fs.lstatSync(path); - if (!after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino || after.mode !== stat.mode || - after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs || fs.readlinkSync(path) !== target) + if ( + !after.isSymbolicLink() || + after.dev !== stat.dev || + after.ino !== stat.ino || + after.mode !== stat.mode || + after.mtimeMs !== stat.mtimeMs || + after.ctimeMs !== stat.ctimeMs || + fs.readlinkSync(path) !== target + ) fail('SNAPSHOT_RACE', `Prepared dependency symlink changed while hashing: ${relativePath}`); - entries.push({ path: relativePath, kind: 'symlink', mode: stat.mode & 0o777, bytes: Buffer.byteLength(target), sha256: sha256(`symlink\0${target}`) }); + entries.push({ + path: relativePath, + kind: 'symlink', + mode: stat.mode & 0o777, + bytes: Buffer.byteLength(target), + sha256: sha256(`symlink\0${target}`), + }); continue; } - if (!stat.isDirectory() && !stat.isFile()) fail('UNSAFE_PATH', `Preparation tree contains an unsafe object: ${relativePath}`); - if (stat.isDirectory()) { if ((stat.mode & 0o022) !== 0) fail('UNSAFE_PATH', 'Preparation tree contains a publicly writable directory'); walk(path, relativePath); continue; } - if (stat.nlink !== 1) fail('UNSAFE_PATH', `Preparation tree contains a hard-linked file: ${relativePath}`); + if (!stat.isDirectory() && !stat.isFile()) + fail('UNSAFE_PATH', `Preparation tree contains an unsafe object: ${relativePath}`); + if (stat.isDirectory()) { + if ((stat.mode & 0o022) !== 0) + fail('UNSAFE_PATH', 'Preparation tree contains a publicly writable directory'); + walk(path, relativePath); + continue; + } + if (stat.nlink !== 1) + fail('UNSAFE_PATH', `Preparation tree contains a hard-linked file: ${relativePath}`); total += stat.size; - if (total > maxBytes) fail('INSUFFICIENT_CAPACITY', 'Preparation tree exceeds its bounded manifest limit'); + if (total > maxBytes) + fail('INSUFFICIENT_CAPACITY', 'Preparation tree exceeds its bounded manifest limit'); const noFollow = (fs.constants as any).O_NOFOLLOW ?? 0; let fd: number; - try { fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); } catch { fail('UNSAFE_PATH', 'Preparation file could not be opened without following links'); } try { - const initial = fs.fstatSync(fd!), hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024); + fd = fs.openSync(path, fs.constants.O_RDONLY | noFollow); + } catch { + fail('UNSAFE_PATH', 'Preparation file could not be opened without following links'); + } + try { + const initial = fs.fstatSync(fd!), + hash = createHash('sha256'), + buffer = Buffer.allocUnsafe(64 * 1024); let readBytes = 0; - for (;;) { checkDeadline(deadline, signal); const count = fs.readSync(fd!, buffer, 0, buffer.length, null); if (!count) break; readBytes += count; hash.update(buffer.subarray(0, count)); checkDeadline(deadline, signal); } + for (;;) { + checkDeadline(deadline, signal); + const count = fs.readSync(fd!, buffer, 0, buffer.length, null); + if (!count) break; + readBytes += count; + hash.update(buffer.subarray(0, count)); + checkDeadline(deadline, signal); + } checkDeadline(deadline, signal); const final = fs.fstatSync(fd!); - if (readBytes !== initial.size || initial.dev !== final.dev || initial.ino !== final.ino || initial.size !== final.size || - initial.mode !== final.mode || initial.mtimeMs !== final.mtimeMs || initial.ctimeMs !== final.ctimeMs) + if ( + readBytes !== initial.size || + initial.dev !== final.dev || + initial.ino !== final.ino || + initial.size !== final.size || + initial.mode !== final.mode || + initial.mtimeMs !== final.mtimeMs || + initial.ctimeMs !== final.ctimeMs + ) fail('SNAPSHOT_RACE', `Preparation file changed while hashing: ${relativePath}`); - entries.push({ path: relativePath, kind: 'file', mode: initial.mode & 0o777, bytes: initial.size, sha256: hash.digest('hex') }); - } finally { fs.closeSync(fd!); } + entries.push({ + path: relativePath, + kind: 'file', + mode: initial.mode & 0o777, + bytes: initial.size, + sha256: hash.digest('hex'), + }); + } finally { + fs.closeSync(fd!); + } } checkDeadline(deadline, signal); - if (canonical(before) !== canonical(boundedTreeNames(directory, deadline, signal))) fail('SNAPSHOT_RACE', 'Preparation tree membership changed while hashing'); + if (canonical(before) !== canonical(boundedTreeNames(directory, deadline, signal))) + fail('SNAPSHOT_RACE', 'Preparation tree membership changed while hashing'); }; walk(root); return entries; } -function treeHash(rootPath: string, maxBytes: number, deadline: number, allowContainedSymlinks = false, - signal?: AbortSignal): string { +function treeHash( + rootPath: string, + maxBytes: number, + deadline: number, + allowContainedSymlinks = false, + signal?: AbortSignal, +): string { return sha256(canonical(treeManifest(rootPath, maxBytes, deadline, allowContainedSymlinks, signal))); } function dependencyOutput(stack: CsoStack, path: string): boolean { const first = path.split('/')[0]; if (stack === 'node') return first === 'node_modules' || first === '.cso-npm-cache'; if (stack === 'bun') return first === 'node_modules' || first === '.cso-bun-cache'; - if (stack === 'python') return first === '.venv' || first === '.cso-uv-cache' || path === '.gstack-cso-public-requirements.txt'; + if (stack === 'python') + return first === '.venv' || first === '.cso-uv-cache' || path === '.gstack-cso-public-requirements.txt'; return path.startsWith('vendor/bundle/') || first === '.cso-bundle' || first === '.cso-gems'; } -function installedDependencyOutput(stack:CsoStack,path:string):boolean{ - const first=path.split('/')[0];return stack==='node'||stack==='bun'?first==='node_modules':stack==='python'?first==='.venv':path.startsWith('vendor/bundle/'); +function installedDependencyOutput(stack: CsoStack, path: string): boolean { + const first = path.split('/')[0]; + return stack === 'node' || stack === 'bun' + ? first === 'node_modules' + : stack === 'python' + ? first === '.venv' + : path.startsWith('vendor/bundle/'); } function preparedEnvironment(plan: PreparationPlan): Record { - if (plan.stack === 'python') return { PATH: '/work/.venv/bin:/usr/local/bin:/usr/bin:/bin', VIRTUAL_ENV: '/work/.venv', PYTHONNOUSERSITE: '1' }; + if (plan.stack === 'python') + return { + PATH: '/work/.venv/bin:/usr/local/bin:/usr/bin:/bin', + VIRTUAL_ENV: '/work/.venv', + PYTHONNOUSERSITE: '1', + }; if (plan.stack !== 'rails') return { PATH: '/usr/local/bin:/usr/bin:/bin' }; - const dependencyKeys = new Set(['BUNDLE_PATH', 'BUNDLE_FROZEN', 'BUNDLE_DEPLOYMENT', 'BUNDLE_DISABLE_SHARED_GEMS', - 'BUNDLE_IGNORE_CONFIG', 'BUNDLE_ALLOW_OFFLINE_INSTALL', 'BUNDLE_CACHE_PATH', 'BUNDLE_USER_HOME', 'GEM_HOME', 'GEM_PATH']); + const dependencyKeys = new Set([ + 'BUNDLE_PATH', + 'BUNDLE_FROZEN', + 'BUNDLE_DEPLOYMENT', + 'BUNDLE_DISABLE_SHARED_GEMS', + 'BUNDLE_IGNORE_CONFIG', + 'BUNDLE_ALLOW_OFFLINE_INSTALL', + 'BUNDLE_CACHE_PATH', + 'BUNDLE_USER_HOME', + 'GEM_HOME', + 'GEM_PATH', + ]); const verifierOwnedKeys = new Set(['RAILS_ENV', 'RACK_ENV', 'SECRET_KEY_BASE']); const environment: Record = { PATH: '/usr/local/bin:/usr/bin:/bin' }; - for (const command of plan.offline) for (const [key, value] of Object.entries(command.env)) { - if (verifierOwnedKeys.has(key)) continue; - if (!dependencyKeys.has(key)) fail('INVALID_SCHEMA', `Rails preparation attempted to forward unsupported execution environment key ${key}`); - if (environment[key] !== undefined && environment[key] !== value) fail('INVALID_SCHEMA', `Rails preparation commands disagree on ${key}`); - environment[key] = value; - } + for (const command of plan.offline) + for (const [key, value] of Object.entries(command.env)) { + if (verifierOwnedKeys.has(key)) continue; + if (!dependencyKeys.has(key)) + fail( + 'INVALID_SCHEMA', + `Rails preparation attempted to forward unsupported execution environment key ${key}`, + ); + if (environment[key] !== undefined && environment[key] !== value) + fail('INVALID_SCHEMA', `Rails preparation commands disagree on ${key}`); + environment[key] = value; + } return environment; } -function provePreparedProjection(snapshot: string, preparedRoot: string, stack: CsoStack, - transformations: OfflinePreparationRequest['transformations'], deadline: number, signal?: AbortSignal): { hash: string; transformations: PreparedApplication['transformations']; manifestHash: string; dependencyHash:string } { - const source = treeManifest(snapshot, MAX_SOURCE_BYTES, deadline, false, signal), prepared = treeManifest(preparedRoot, MAX_PREPARED_BYTES, deadline, true, signal), - sourceByPath = new Map(source.map(entry => [entry.path, entry])), preparedByPath = new Map(prepared.map(entry => [entry.path, entry])), - synthetic = new Map(transformations.map(item => [item.path, item])); - if (synthetic.size !== transformations.length) fail('INVALID_SCHEMA', 'Offline preparation transformations contain duplicate paths'); +function provePreparedProjection( + snapshot: string, + preparedRoot: string, + stack: CsoStack, + transformations: OfflinePreparationRequest['transformations'], + deadline: number, + signal?: AbortSignal, +): { + hash: string; + transformations: PreparedApplication['transformations']; + manifestHash: string; + dependencyHash: string; +} { + const source = treeManifest(snapshot, MAX_SOURCE_BYTES, deadline, false, signal), + prepared = treeManifest(preparedRoot, MAX_PREPARED_BYTES, deadline, true, signal), + sourceByPath = new Map(source.map((entry) => [entry.path, entry])), + preparedByPath = new Map(prepared.map((entry) => [entry.path, entry])), + synthetic = new Map(transformations.map((item) => [item.path, item])); + if (synthetic.size !== transformations.length) + fail('INVALID_SCHEMA', 'Offline preparation transformations contain duplicate paths'); const actualTransformations: PreparedApplication['transformations'] = []; for (const entry of source) { checkDeadline(deadline, signal); - const actual = preparedByPath.get(entry.path), transformed = synthetic.get(entry.path); - if (!actual || actual.kind !== 'file') fail('ISOLATION_FAILED', `Offline lifecycle execution removed or replaced captured source: ${entry.path}`); + const actual = preparedByPath.get(entry.path), + transformed = synthetic.get(entry.path); + if (!actual || actual.kind !== 'file') + fail( + 'ISOLATION_FAILED', + `Offline lifecycle execution removed or replaced captured source: ${entry.path}`, + ); if (transformed) { - if (actual.sha256 !== transformed.sha256) fail('ISOLATION_FAILED', `Offline lifecycle execution changed a synthetic test transformation: ${entry.path}`); - } else if (canonical(actual) !== canonical(entry)) fail('ISOLATION_FAILED', `Offline lifecycle execution changed captured source bytes or mode: ${entry.path}`); + if (actual.sha256 !== transformed.sha256) + fail( + 'ISOLATION_FAILED', + `Offline lifecycle execution changed a synthetic test transformation: ${entry.path}`, + ); + } else if (canonical(actual) !== canonical(entry)) + fail( + 'ISOLATION_FAILED', + `Offline lifecycle execution changed captured source bytes or mode: ${entry.path}`, + ); } for (const transformed of transformations) { checkDeadline(deadline, signal); const actual = preparedByPath.get(transformed.path); if (!actual || actual.kind !== 'file' || actual.sha256 !== transformed.sha256) - fail('ISOLATION_FAILED', `Offline preparation did not preserve its declared transformation: ${transformed.path}`); - actualTransformations.push({ path: transformed.path, sha256: transformed.sha256, mode: actual.mode, reason: transformed.reason }); + fail( + 'ISOLATION_FAILED', + `Offline preparation did not preserve its declared transformation: ${transformed.path}`, + ); + actualTransformations.push({ + path: transformed.path, + sha256: transformed.sha256, + mode: actual.mode, + reason: transformed.reason, + }); } for (const entry of prepared) { checkDeadline(deadline, signal); - if (sourceByPath.has(entry.path) || synthetic.has(entry.path) || dependencyOutput(stack, entry.path)) continue; + if (sourceByPath.has(entry.path) || synthetic.has(entry.path) || dependencyOutput(stack, entry.path)) + continue; fail('ISOLATION_FAILED', `Offline lifecycle execution wrote outside its dependency roots: ${entry.path}`); } actualTransformations.sort((a, b) => a.path.localeCompare(b.path)); - return { hash: sha256(canonical({ source, transformations: actualTransformations })), transformations: actualTransformations, - manifestHash: sha256(canonical(prepared)), dependencyHash:sha256(canonical(prepared.filter(entry=>installedDependencyOutput(stack,entry.path)))) }; + return { + hash: sha256(canonical({ source, transformations: actualTransformations })), + transformations: actualTransformations, + manifestHash: sha256(canonical(prepared)), + dependencyHash: sha256( + canonical(prepared.filter((entry) => installedDependencyOutput(stack, entry.path))), + ), + }; } -function validateAcquisitionReceipt(receipt: AcquisitionReceipt, request: PreparationAcquireRequest, plan: PreparationPlan): void { - if (!receipt || receipt.schemaVersion !== 1 || receipt.planHash !== request.planHash || receipt.runtimeId !== request.runtime.id || - receipt.runtimeImage !== request.runtime.image || receipt.platform !== request.runtime.platform || receipt.deadlineEnforced !== true || - receipt.lifecycleScriptsExecuted !== false || receipt.targetCodeExecuted !== false) +function validateAcquisitionReceipt( + receipt: AcquisitionReceipt, + request: PreparationAcquireRequest, + plan: PreparationPlan, +): void { + if ( + !receipt || + receipt.schemaVersion !== 1 || + receipt.planHash !== request.planHash || + receipt.runtimeId !== request.runtime.id || + receipt.runtimeImage !== request.runtime.image || + receipt.platform !== request.runtime.platform || + receipt.deadlineEnforced !== true || + receipt.lifecycleScriptsExecuted !== false || + receipt.targetCodeExecuted !== false + ) fail('TOOL_FAILED', 'Acquisition receipt does not bind the admitted plan and runtime'); const network = receipt.network; - if (!network || network.mode !== 'registry-restricted' || !sameStrings(network.allowedHosts, plan.registryHosts) || - !Array.isArray(network.contactedHosts) || network.contactedHosts.some(host => !plan.registryHosts.includes(host)) || - new Set(network.contactedHosts).size !== network.contactedHosts.length || network.dnsRebindingBlocked !== true || - network.credentialsMounted !== false || network.sourceMounted !== false || network.dockerSocketMounted !== false || - network.redirectVisibility !== 'opaque-tls') - fail('ISOLATION_FAILED', 'Acquisition network receipt did not prove registry-restricted, credential-free execution'); + if ( + !network || + network.mode !== 'registry-restricted' || + !sameStrings(network.allowedHosts, plan.registryHosts) || + !Array.isArray(network.contactedHosts) || + network.contactedHosts.some((host) => !plan.registryHosts.includes(host)) || + new Set(network.contactedHosts).size !== network.contactedHosts.length || + network.dnsRebindingBlocked !== true || + network.credentialsMounted !== false || + network.sourceMounted !== false || + network.dockerSocketMounted !== false || + network.redirectVisibility !== 'opaque-tls' + ) + fail( + 'ISOLATION_FAILED', + 'Acquisition network receipt did not prove registry-restricted, credential-free execution', + ); validateCommandReceipts(receipt.commands, plan.acquisition); } -function validateOfflineReceipt(receipt: OfflinePreparationReceipt, request: OfflinePreparationRequest): void { - if (!receipt || receipt.schemaVersion !== 1 || receipt.planHash !== request.planHash || receipt.runtimeId !== request.runtime.id || - receipt.runtimeImage !== request.runtime.image || receipt.platform !== request.runtime.platform || receipt.sourceHash !== request.sourceHash || - receipt.dependencyClosureHash !== request.dependencyClosureHash || receipt.configurationHash !== request.configurationHash || - receipt.databaseHash !== request.databaseHash || request.databaseHash !== sha256(canonical(request.database ?? null)) || - receipt.deadlineEnforced !== true || receipt.inputSourceReadOnly !== true || receipt.preparedCopySeparate !== true || - receipt.archivesReadOnly !== true || receipt.applicationCodeExecutedOnlyOffline !== true) +function validateOfflineReceipt( + receipt: OfflinePreparationReceipt, + request: OfflinePreparationRequest, +): void { + if ( + !receipt || + receipt.schemaVersion !== 1 || + receipt.planHash !== request.planHash || + receipt.runtimeId !== request.runtime.id || + receipt.runtimeImage !== request.runtime.image || + receipt.platform !== request.runtime.platform || + receipt.sourceHash !== request.sourceHash || + receipt.dependencyClosureHash !== request.dependencyClosureHash || + receipt.configurationHash !== request.configurationHash || + receipt.databaseHash !== request.databaseHash || + request.databaseHash !== sha256(canonical(request.database ?? null)) || + receipt.deadlineEnforced !== true || + receipt.inputSourceReadOnly !== true || + receipt.preparedCopySeparate !== true || + receipt.archivesReadOnly !== true || + receipt.applicationCodeExecutedOnlyOffline !== true + ) fail('TOOL_FAILED', 'Offline preparation receipt does not bind its immutable inputs'); const expectedServices: Array<'application' | 'postgresql'> = ['application']; const network = receipt.network; - if (!network || network.mode !== 'none' || !/^[a-z0-9][a-z0-9._-]{0,100}$/.test(network.namespaceAnchor) || - network.externalEgress !== false || network.dnsAvailable !== false || network.publishedPorts !== false || - !sameStrings(network.services, expectedServices)) fail('ISOLATION_FAILED', 'Offline preparation did not remain in the shared no-egress loopback namespace'); + if ( + !network || + network.mode !== 'none' || + !/^[a-z0-9][a-z0-9._-]{0,100}$/.test(network.namespaceAnchor) || + network.externalEgress !== false || + network.dnsAvailable !== false || + network.publishedPorts !== false || + !sameStrings(network.services, expectedServices) + ) + fail('ISOLATION_FAILED', 'Offline preparation did not remain in the shared no-egress loopback namespace'); validateCommandReceipts(receipt.commands, request.commands); } function offlineReceiptIdentity(receipt: OfflinePreparationReceipt): string { - return sha256(canonical({ ...receipt, network: { ...receipt.network, namespaceAnchor: '' } })); + return sha256( + canonical({ + ...receipt, + network: { ...receipt.network, namespaceAnchor: '' }, + }), + ); } export class PreparationExecutor { - constructor(private readonly options: { cache: PublicArchiveCache; runner: PreparationSandboxRunner; materializationRoot?: string }) { - if (!options?.cache || !options?.runner) fail('INVALID_ARGUMENT', 'Preparation executor requires a cache and qualified runner'); + constructor( + private readonly options: { + cache: PublicArchiveCache; + runner: PreparationSandboxRunner; + materializationRoot?: string; + }, + ) { + if (!options?.cache || !options?.runner) + fail('INVALID_ARGUMENT', 'Preparation executor requires a cache and qualified runner'); } async acquire(options: { @@ -689,109 +1132,224 @@ export class PreparationExecutor { existingClosure?: DependencyClosure; }): Promise { checkDeadline(options.deadline, options.signal); - const hash = validatePlan(options.plan, options.snapshot), runtime = runtimeFromAdmission(options.plan, options.admission); + const hash = validatePlan(options.plan, options.snapshot), + runtime = runtimeFromAdmission(options.plan, options.admission); checkDeadline(options.deadline, options.signal); validateRunner(this.options.runner, options.plan.stack); if (options.existingClosure) { - const verified = validateClosure(options.plan, options.admission, options.existingClosure, this.options.cache, - options.deadline, options.signal, this.options.materializationRoot); + const verified = validateClosure( + options.plan, + options.admission, + options.existingClosure, + this.options.cache, + options.deadline, + options.signal, + this.options.materializationRoot, + ); if (verified.materializedRoot) fs.rmSync(verified.materializedRoot, { recursive: true, force: true }); return options.existingClosure; } - const publicInputs = options.plan.inputs.map((input, index) => ({ input, index })).filter(item => item.input.kind === 'public'); - if (publicInputs.length > MAX_ARCHIVES) fail('INSUFFICIENT_CAPACITY', 'Preparation plan exceeds the archive-count limit'); - const publicInputsByIndex = new Map(publicInputs.map(item => [item.index, item])); - if (options.offline && publicInputs.length) fail('MISSING_INPUT', 'Offline preparation requires a matching retained dependency closure and verified cache entries'); + const publicInputs = options.plan.inputs + .map((input, index) => ({ input, index })) + .filter((item) => item.input.kind === 'public'); + if (publicInputs.length > MAX_ARCHIVES) + fail('INSUFFICIENT_CAPACITY', 'Preparation plan exceeds the archive-count limit'); + const publicInputsByIndex = new Map(publicInputs.map((item) => [item.index, item])); + if (options.offline && publicInputs.length) + fail( + 'MISSING_INPUT', + 'Offline preparation requires a matching retained dependency closure and verified cache entries', + ); if (!publicInputs.length) { - const body: Omit = { schemaVersion: 1, stack: options.plan.stack, planHash: hash, - catalogRevision: options.admission.catalogRevision, runtimeId: runtime.id, runtimeImage: runtime.image, platform: runtime.platform, - archives: [], acquisitionReceiptHash: null }; + const body: Omit = { + schemaVersion: 1, + stack: options.plan.stack, + planHash: hash, + catalogRevision: options.admission.catalogRevision, + runtimeId: runtime.id, + runtimeImage: runtime.image, + platform: runtime.platform, + archives: [], + acquisitionReceiptHash: null, + }; return Object.freeze({ ...body, closureHash: closureIdentity(body) }); } const acquisitionStaging = fs.mkdtempSync(join(this.options.cache.stagingRoot, 'acquire-')); fs.chmodSync(acquisitionStaging, 0o700); const stagingIdentity = fs.lstatSync(acquisitionStaging); const request: PreparationAcquireRequest = { - schemaVersion: 1, planHash: hash, stack: options.plan.stack, - runtime: { id: runtime.id, image: runtime.image, platform: runtime.platform }, metadata: structuredClone(options.plan.metadata), - inputs: structuredClone(publicInputs), commands: structuredClone(options.plan.acquisition), stagingRoot: acquisitionStaging, - deadline: options.deadline, limits: { maxArchives: MAX_ARCHIVES, + schemaVersion: 1, + planHash: hash, + stack: options.plan.stack, + runtime: { id: runtime.id, image: runtime.image, platform: runtime.platform }, + metadata: structuredClone(options.plan.metadata), + inputs: structuredClone(publicInputs), + commands: structuredClone(options.plan.acquisition), + stagingRoot: acquisitionStaging, + deadline: options.deadline, + limits: { + maxArchives: MAX_ARCHIVES, maxArchiveBytes: Math.min(this.options.cache.maxEntryBytes, MAX_ACQUISITION_ARCHIVE_BYTES), - maxTotalArchiveBytes: Math.min(this.options.cache.maxBytes, MAX_ACQUISITION_ARCHIVE_BYTES), maxOutputBytes: MAX_OUTPUT }, - network: { mode: 'registry-restricted', allowedHosts: [...options.plan.registryHosts] }, sourceMounted: false, + maxTotalArchiveBytes: Math.min(this.options.cache.maxBytes, MAX_ACQUISITION_ARCHIVE_BYTES), + maxOutputBytes: MAX_OUTPUT, + }, + network: { mode: 'registry-restricted', allowedHosts: [...options.plan.registryHosts] }, + sourceMounted: false, }; try { - const receipt = await this.options.runner.acquire(deepFreeze(request)); - checkDeadline(options.deadline, options.signal); - validateAcquisitionReceipt(receipt, request, options.plan); - if (!Array.isArray(receipt.artifacts) || receipt.artifacts.length > request.limits.maxArchives) - fail('TOOL_FAILED', 'Acquisition receipt contains an invalid number of archives'); - const archives: DependencyArchive[] = [], seenInputs = new Set(), seenPaths = new Set(), seenStagingPaths = new Set(), - uniqueBytes = new Map(), validated: Array<{ artifact: AcquisitionArtifactReceipt; selected: typeof publicInputs[number] }> = []; - let totalUniqueBytes = 0; - for (const artifact of receipt.artifacts) { + const receipt = await this.options.runner.acquire(deepFreeze(request)); checkDeadline(options.deadline, options.signal); - const selected = publicInputsByIndex.get(artifact.inputIndex); - if (!selected || seenInputs.has(artifact.inputIndex) || !SHA256.test(artifact.sha256) || artifact.registryResponseSha256 !== artifact.sha256 || - !Number.isSafeInteger(artifact.bytes) || artifact.bytes < 0 || artifact.bytes > this.options.cache.maxEntryBytes || - !options.plan.registryHosts.includes(artifact.requestedHost) || !receipt.network.contactedHosts.includes(artifact.requestedHost)) - fail('TOOL_FAILED', 'Acquisition artifact is not a unique bounded public dependency'); - seenInputs.add(artifact.inputIndex); - relativeArchivePath(artifact.stagingPath, 'Staged archive'); relativeArchivePath(artifact.installPath, 'Dependency install path'); - if (seenPaths.has(artifact.installPath) || seenStagingPaths.has(artifact.stagingPath)) fail('TOOL_FAILED', 'Acquisition artifact paths collide'); - seenPaths.add(artifact.installPath); seenStagingPaths.add(artifact.stagingPath); - validateUrl(artifact.requestedUrl, artifact.requestedHost, options.plan.registryHosts); - if (artifact.resolvedUrl !== null) { - let resolvedHost = ''; try { resolvedHost = new URL(artifact.resolvedUrl).hostname; } catch {} - validateUrl(artifact.resolvedUrl, resolvedHost, options.plan.registryHosts); - if (!receipt.network.contactedHosts.includes(resolvedHost)) fail('TOOL_FAILED', 'Direct archive response host was not contacted through the registry broker'); - } - if (selected.input.url && artifact.requestedUrl !== selected.input.url) fail('TOOL_FAILED', 'Acquired archive request URL does not match its lockfile URL'); - const hashes = stagedFileHashes(request.stagingRoot, artifact.stagingPath, artifact.bytes, options.deadline, options.signal); - if (hashes.sha256 !== artifact.sha256 || (selected.input.integritySource === 'lock' && !integrityMatches(selected.input.integrity, hashes)) || - (selected.input.integritySource !== 'lock' && selected.input.integritySource !== 'registry-on-acquisition')) - fail('INCOMPATIBLE_INPUT', `Acquired archive failed lock/registry integrity verification: ${selected.input.name}@${selected.input.version}`); - const priorBytes = uniqueBytes.get(artifact.sha256); - if (priorBytes !== undefined && priorBytes !== artifact.bytes) fail('TOOL_FAILED', 'One acquisition digest was reported with inconsistent sizes'); - if (priorBytes === undefined) { uniqueBytes.set(artifact.sha256, artifact.bytes); totalUniqueBytes += artifact.bytes; } - if (totalUniqueBytes > request.limits.maxTotalArchiveBytes) - fail('INSUFFICIENT_CAPACITY', 'Acquisition archives exceed the immutable-cache byte ceiling'); - validated.push({ artifact, selected }); - } - // No cache mutation occurs until the complete receipt, every staged byte, - // and the aggregate unique-byte ceiling have been validated. - try { - for (const { artifact, selected } of validated) { + validateAcquisitionReceipt(receipt, request, options.plan); + if (!Array.isArray(receipt.artifacts) || receipt.artifacts.length > request.limits.maxArchives) + fail('TOOL_FAILED', 'Acquisition receipt contains an invalid number of archives'); + const archives: DependencyArchive[] = [], + seenInputs = new Set(), + seenPaths = new Set(), + seenStagingPaths = new Set(), + uniqueBytes = new Map(), + validated: Array<{ artifact: AcquisitionArtifactReceipt; selected: (typeof publicInputs)[number] }> = + []; + let totalUniqueBytes = 0; + for (const artifact of receipt.artifacts) { checkDeadline(options.deadline, options.signal); - const absoluteStaged = resolve(request.stagingRoot, ...artifact.stagingPath.split('/')); - const cacheRelative = relative(this.options.cache.stagingRoot, absoluteStaged).split(sep).join('/'); - const cached = this.options.cache.promote(cacheRelative, artifact.sha256, { deadline: options.deadline, signal: options.signal }); - archives.push({ inputIndex: artifact.inputIndex, name: selected.input.name, version: selected.input.version, - installPath: artifact.installPath, sha256: cached.sha256, bytes: cached.bytes, requestedHost: artifact.requestedHost, - requestedUrl: artifact.requestedUrl, resolvedUrl: artifact.resolvedUrl, - declaredIntegrity: selected.input.integrity ?? 'registry-on-acquisition' }); + const selected = publicInputsByIndex.get(artifact.inputIndex); + if ( + !selected || + seenInputs.has(artifact.inputIndex) || + !SHA256.test(artifact.sha256) || + artifact.registryResponseSha256 !== artifact.sha256 || + !Number.isSafeInteger(artifact.bytes) || + artifact.bytes < 0 || + artifact.bytes > this.options.cache.maxEntryBytes || + !options.plan.registryHosts.includes(artifact.requestedHost) || + !receipt.network.contactedHosts.includes(artifact.requestedHost) + ) + fail('TOOL_FAILED', 'Acquisition artifact is not a unique bounded public dependency'); + seenInputs.add(artifact.inputIndex); + relativeArchivePath(artifact.stagingPath, 'Staged archive'); + relativeArchivePath(artifact.installPath, 'Dependency install path'); + if (seenPaths.has(artifact.installPath) || seenStagingPaths.has(artifact.stagingPath)) + fail('TOOL_FAILED', 'Acquisition artifact paths collide'); + seenPaths.add(artifact.installPath); + seenStagingPaths.add(artifact.stagingPath); + validateUrl(artifact.requestedUrl, artifact.requestedHost, options.plan.registryHosts); + if (artifact.resolvedUrl !== null) { + let resolvedHost = ''; + try { + resolvedHost = new URL(artifact.resolvedUrl).hostname; + } catch {} + validateUrl(artifact.resolvedUrl, resolvedHost, options.plan.registryHosts); + if (!receipt.network.contactedHosts.includes(resolvedHost)) + fail('TOOL_FAILED', 'Direct archive response host was not contacted through the registry broker'); + } + if (selected.input.url && artifact.requestedUrl !== selected.input.url) + fail('TOOL_FAILED', 'Acquired archive request URL does not match its lockfile URL'); + const hashes = stagedFileHashes( + request.stagingRoot, + artifact.stagingPath, + artifact.bytes, + options.deadline, + options.signal, + ); + if ( + hashes.sha256 !== artifact.sha256 || + (selected.input.integritySource === 'lock' && + !integrityMatches(selected.input.integrity, hashes)) || + (selected.input.integritySource !== 'lock' && + selected.input.integritySource !== 'registry-on-acquisition') + ) + fail( + 'INCOMPATIBLE_INPUT', + `Acquired archive failed lock/registry integrity verification: ${selected.input.name}@${selected.input.version}`, + ); + const priorBytes = uniqueBytes.get(artifact.sha256); + if (priorBytes !== undefined && priorBytes !== artifact.bytes) + fail('TOOL_FAILED', 'One acquisition digest was reported with inconsistent sizes'); + if (priorBytes === undefined) { + uniqueBytes.set(artifact.sha256, artifact.bytes); + totalUniqueBytes += artifact.bytes; + } + if (totalUniqueBytes > request.limits.maxTotalArchiveBytes) + fail('INSUFFICIENT_CAPACITY', 'Acquisition archives exceed the immutable-cache byte ceiling'); + validated.push({ artifact, selected }); } - } finally { - for (const { artifact } of validated) if (artifact.stagingPath.startsWith('cso-public/')) { - const path = resolve(request.stagingRoot, ...artifact.stagingPath.split('/')); - try { const stat = fs.lstatSync(path); if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && (process.getuid ? stat.uid === process.getuid() : true)) fs.unlinkSync(path); } catch {} + // No cache mutation occurs until the complete receipt, every staged byte, + // and the aggregate unique-byte ceiling have been validated. + try { + for (const { artifact, selected } of validated) { + checkDeadline(options.deadline, options.signal); + const absoluteStaged = resolve(request.stagingRoot, ...artifact.stagingPath.split('/')); + const cacheRelative = relative(this.options.cache.stagingRoot, absoluteStaged).split(sep).join('/'); + const cached = this.options.cache.promote(cacheRelative, artifact.sha256, { + deadline: options.deadline, + signal: options.signal, + }); + archives.push({ + inputIndex: artifact.inputIndex, + name: selected.input.name, + version: selected.input.version, + installPath: artifact.installPath, + sha256: cached.sha256, + bytes: cached.bytes, + requestedHost: artifact.requestedHost, + requestedUrl: artifact.requestedUrl, + resolvedUrl: artifact.resolvedUrl, + declaredIntegrity: selected.input.integrity ?? 'registry-on-acquisition', + }); + } + } finally { + for (const { artifact } of validated) + if (artifact.stagingPath.startsWith('cso-public/')) { + const path = resolve(request.stagingRoot, ...artifact.stagingPath.split('/')); + try { + const stat = fs.lstatSync(path); + if ( + stat.isFile() && + !stat.isSymbolicLink() && + stat.nlink === 1 && + (process.getuid ? stat.uid === process.getuid() : true) + ) + fs.unlinkSync(path); + } catch {} + } + } + const covered = new Set( + archives.map((archive) => logicalInput(options.plan.inputs[archive.inputIndex])), + ); + for (const { input } of publicInputs) { + checkDeadline(options.deadline, options.signal); + if (!covered.has(logicalInput(input))) + fail( + 'MISSING_INPUT', + `Acquisition did not produce a compatible archive for ${input.name}@${input.version}`, + ); } - } - const covered = new Set(archives.map(archive => logicalInput(options.plan.inputs[archive.inputIndex]))); - for (const { input } of publicInputs) { checkDeadline(options.deadline, options.signal); - if (!covered.has(logicalInput(input))) fail('MISSING_INPUT', `Acquisition did not produce a compatible archive for ${input.name}@${input.version}`); - } - checkDeadline(options.deadline, options.signal); - archives.sort((a, b) => a.installPath.localeCompare(b.installPath)); - const body: Omit = { schemaVersion: 1, stack: options.plan.stack, planHash: hash, - catalogRevision: options.admission.catalogRevision, runtimeId: runtime.id, runtimeImage: runtime.image, platform: runtime.platform, - archives, acquisitionReceiptHash: sha256(canonical(receipt)) }; - return Object.freeze({ ...body, closureHash: closureIdentity(body) }); + archives.sort((a, b) => a.installPath.localeCompare(b.installPath)); + const body: Omit = { + schemaVersion: 1, + stack: options.plan.stack, + planHash: hash, + catalogRevision: options.admission.catalogRevision, + runtimeId: runtime.id, + runtimeImage: runtime.image, + platform: runtime.platform, + archives, + acquisitionReceiptHash: sha256(canonical(receipt)), + }; + return Object.freeze({ ...body, closureHash: closureIdentity(body) }); } finally { let current: fs.Stats | undefined; - try { current = fs.lstatSync(acquisitionStaging); } catch {} - if (current && (!current.isDirectory() || current.isSymbolicLink() || current.dev !== stagingIdentity.dev || current.ino !== stagingIdentity.ino)) + try { + current = fs.lstatSync(acquisitionStaging); + } catch {} + if ( + current && + (!current.isDirectory() || + current.isSymbolicLink() || + current.dev !== stagingIdentity.dev || + current.ino !== stagingIdentity.ino) + ) fail('SNAPSHOT_RACE', 'Run-private acquisition staging was replaced before cleanup'); if (current) fs.rmSync(acquisitionStaging, { recursive: true, force: false }); } @@ -807,40 +1365,88 @@ export class PreparationExecutor { database?: RailsDatabaseSelection; }): Promise { checkDeadline(options.deadline, options.signal); - const hash = validatePlan(options.plan, options.snapshot), runtime = runtimeFromAdmission(options.plan, options.admission); + const hash = validatePlan(options.plan, options.snapshot), + runtime = runtimeFromAdmission(options.plan, options.admission); checkDeadline(options.deadline, options.signal); validateRunner(this.options.runner, options.plan.stack); - const materialized = validateClosure(options.plan, options.admission, options.closure, this.options.cache, - options.deadline, options.signal, this.options.materializationRoot), + const materialized = validateClosure( + options.plan, + options.admission, + options.closure, + this.options.cache, + options.deadline, + options.signal, + this.options.materializationRoot, + ), archives = materialized.mounts; let database: OfflinePreparationRequest['database']; let synthetic: Array<{ path: string; content: string }> = []; if (options.plan.stack === 'rails') { - if (!options.database) fail('INVALID_ARGUMENT', 'Rails offline preparation requires an explicit SQLite or PostgreSQL selection'); - if (!options.plan.database?.supported.includes(options.database.adapter)) fail('PREREQUISITE', `Rails ${options.database.adapter} preparation is not supported by this plan`); + if (!options.database) + fail( + 'INVALID_ARGUMENT', + 'Rails offline preparation requires an explicit SQLite or PostgreSQL selection', + ); + if (!options.plan.database?.supported.includes(options.database.adapter)) + fail('PREREQUISITE', `Rails ${options.database.adapter} preparation is not supported by this plan`); synthetic = railsTestConfiguration(options.plan.database.connections, options.database.adapter); if (options.database.adapter === 'postgresql') { const sidecar = options.database.sidecar; - if (!sidecar || !admittedRuntimes.has(sidecar) || sidecar.runtime.stack !== 'postgresql' || - sidecar.runtime.platform !== runtime.platform || sidecar.catalogRevision !== options.admission.catalogRevision) - fail('PREREQUISITE', 'Rails PostgreSQL preparation requires a qualified same-platform sidecar from the same catalog'); - database = { adapter: 'postgresql', connections: [...options.plan.database.connections], - sidecar: { id: sidecar.runtime.id, image: sidecar.runtime.image } }; + if ( + !sidecar || + !admittedRuntimes.has(sidecar) || + sidecar.runtime.stack !== 'postgresql' || + sidecar.runtime.platform !== runtime.platform || + sidecar.catalogRevision !== options.admission.catalogRevision + ) + fail( + 'PREREQUISITE', + 'Rails PostgreSQL preparation requires a qualified same-platform sidecar from the same catalog', + ); + database = { + adapter: 'postgresql', + connections: [...options.plan.database.connections], + sidecar: { id: sidecar.runtime.id, image: sidecar.runtime.image }, + }; } else database = { adapter: 'sqlite', connections: [...options.plan.database.connections] }; } else if (options.database) fail('INVALID_ARGUMENT', 'Database preparation is only valid for Rails'); - const transformations = synthetic.map(file => ({ ...file, sha256: sha256(file.content), reason: 'Synthetic isolated Rails test configuration' })); - const configurationHash = sha256(canonical(transformations)), databaseHash = sha256(canonical(database ?? null)); + const transformations = synthetic.map((file) => ({ + ...file, + sha256: sha256(file.content), + reason: 'Synthetic isolated Rails test configuration', + })); + const configurationHash = sha256(canonical(transformations)), + databaseHash = sha256(canonical(database ?? null)); const sourceHash = treeHash(options.snapshot, MAX_SOURCE_BYTES, options.deadline, false, options.signal); const request: OfflinePreparationRequest = { - schemaVersion: 1, planHash: hash, stack: options.plan.stack, - runtime: { id: runtime.id, image: runtime.image, platform: runtime.platform }, sourceRoot: resolve(options.snapshot), sourceHash, - metadata: structuredClone(options.plan.metadata), dependencyClosureHash: options.closure.closureHash, - commands: structuredClone(options.plan.offline), archives, - transformations, configurationHash, database, databaseHash, deadline: options.deadline, - limits: { cpus: 2, memoryBytes: 4 * 1024 * 1024 * 1024, pids: 256, writableBytes: MAX_PREPARED_BYTES, maxOutputBytes: MAX_OUTPUT }, - network: { mode: 'none', sharedLoopbackNamespace: true, publishedPorts: false }, inputSourceReadOnly: true, archivesReadOnly: true, + schemaVersion: 1, + planHash: hash, + stack: options.plan.stack, + runtime: { id: runtime.id, image: runtime.image, platform: runtime.platform }, + sourceRoot: resolve(options.snapshot), + sourceHash, + metadata: structuredClone(options.plan.metadata), + dependencyClosureHash: options.closure.closureHash, + commands: structuredClone(options.plan.offline), + archives, + transformations, + configurationHash, + database, + databaseHash, + deadline: options.deadline, + limits: { + cpus: 2, + memoryBytes: 4 * 1024 * 1024 * 1024, + pids: 256, + writableBytes: MAX_PREPARED_BYTES, + maxOutputBytes: MAX_OUTPUT, + }, + network: { mode: 'none', sharedLoopbackNamespace: true, publishedPorts: false }, + inputSourceReadOnly: true, + archivesReadOnly: true, }; - let returnedPreparedRoot: string | undefined, completed = false; + let returnedPreparedRoot: string | undefined, + completed = false; try { const result = await this.options.runner.prepareOffline(deepFreeze(request)); returnedPreparedRoot = typeof result?.preparedRoot === 'string' ? result.preparedRoot : undefined; @@ -848,27 +1454,61 @@ export class PreparationExecutor { validateOfflineReceipt(result?.receipt, request); const preparedRoot = resolve(result.preparedRoot); const sourceRoot = resolve(options.snapshot); - if (preparedRoot === sourceRoot || preparedRoot.startsWith(`${sourceRoot}${sep}`) || sourceRoot.startsWith(`${preparedRoot}${sep}`) || - preparedRoot === this.options.cache.root || preparedRoot.startsWith(`${this.options.cache.root}${sep}`)) - fail('UNSAFE_PATH', 'Prepared application must be a separate disposable copy outside cache and source roots'); - const projection = provePreparedProjection(options.snapshot, preparedRoot, options.plan.stack, request.transformations, options.deadline, options.signal), - preparedManifestHash = projection.manifestHash,preparedDependencyHash=projection.dependencyHash; - if (treeHash(options.snapshot, MAX_SOURCE_BYTES, options.deadline, false, options.signal) !== sourceHash) + if ( + preparedRoot === sourceRoot || + preparedRoot.startsWith(`${sourceRoot}${sep}`) || + sourceRoot.startsWith(`${preparedRoot}${sep}`) || + preparedRoot === this.options.cache.root || + preparedRoot.startsWith(`${this.options.cache.root}${sep}`) + ) + fail( + 'UNSAFE_PATH', + 'Prepared application must be a separate disposable copy outside cache and source roots', + ); + const projection = provePreparedProjection( + options.snapshot, + preparedRoot, + options.plan.stack, + request.transformations, + options.deadline, + options.signal, + ), + preparedManifestHash = projection.manifestHash, + preparedDependencyHash = projection.dependencyHash; + if ( + treeHash(options.snapshot, MAX_SOURCE_BYTES, options.deadline, false, options.signal) !== sourceHash + ) fail('SNAPSHOT_RACE', 'Offline preparation changed its read-only input source'); completed = true; - return Object.freeze({ schemaVersion: 1 as const, stack: options.plan.stack, preparedRoot, sourceHash, preparedManifestHash,preparedDependencyHash, - dependencyClosureHash: options.closure.closureHash, configurationHash, databaseHash, database, + return Object.freeze({ + schemaVersion: 1 as const, + stack: options.plan.stack, + preparedRoot, + sourceHash, + preparedManifestHash, + preparedDependencyHash, + dependencyClosureHash: options.closure.closureHash, + configurationHash, + databaseHash, + database, sourceProjectionHash: projection.hash, - transformations: projection.transformations, executionEnvironment: Object.freeze(preparedEnvironment(options.plan)), - receiptHash: offlineReceiptIdentity(result.receipt), receipt: result.receipt }); + transformations: projection.transformations, + executionEnvironment: Object.freeze(preparedEnvironment(options.plan)), + receiptHash: offlineReceiptIdentity(result.receipt), + receipt: result.receipt, + }); } catch (error) { if (!completed && returnedPreparedRoot) { - try { await this.options.runner.disposePrepared(returnedPreparedRoot); } - catch { fail('PERSISTENCE_FAILED', 'Invalid prepared execution copy could not be disposed safely'); } + try { + await this.options.runner.disposePrepared(returnedPreparedRoot); + } catch { + fail('PERSISTENCE_FAILED', 'Invalid prepared execution copy could not be disposed safely'); + } } throw error; } finally { - if (materialized.materializedRoot) fs.rmSync(materialized.materializedRoot, { recursive: true, force: true }); + if (materialized.materializedRoot) + fs.rmSync(materialized.materializedRoot, { recursive: true, force: true }); } } diff --git a/lib/cso/preparation.ts b/lib/cso/preparation.ts index a9bdef21c..f822e74ad 100644 --- a/lib/cso/preparation.ts +++ b/lib/cso/preparation.ts @@ -6,7 +6,11 @@ import { lstatSync, readFileSync, realpathSync } from 'node:fs'; import { isAbsolute, relative, resolve, sep } from 'node:path'; export type CsoStack = 'node' | 'bun' | 'python' | 'rails'; -export interface PreparationPrerequisite { code: string; message: string; path?: string } +export interface PreparationPrerequisite { + code: string; + message: string; + path?: string; +} export interface PreparationCommand { executable: string; args: string[]; @@ -37,43 +41,83 @@ export interface PreparationPlan { runtimeProfile: string; runtimeRequirements: Record; transformations: Array<{ path: string; reason: string; phase: 'acquisition' | 'execution' }>; - database?: { supported: Array<'sqlite' | 'postgresql'>; selected: 'sqlite' | 'postgresql' | null; - connections: string[]; requiresSyntheticConfiguration: true }; + database?: { + supported: Array<'sqlite' | 'postgresql'>; + selected: 'sqlite' | 'postgresql' | null; + connections: string[]; + requiresSyntheticConfiguration: true; + }; } const MAX_METADATA = 8 * 1024 * 1024; const MAX_PACKAGES = 25_000; -const MANIFEST_FIELDS = ['name', 'version', 'private', 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta', 'engines', 'os', 'cpu', 'workspaces', 'overrides'] as const; -const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const; +const MANIFEST_FIELDS = [ + 'name', + 'version', + 'private', + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'engines', + 'os', + 'cpu', + 'workspaces', + 'overrides', +] as const; +const DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +] as const; const NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i; const VERSION = /^[0-9][0-9a-zA-Z.+_-]*$/; const SRI = /^(?:sha512-[A-Za-z0-9+/]{86}==|sha256-[A-Za-z0-9+/]{43}=)$/; -const connectionName = (value: string) => Buffer.byteLength(value) <= 48 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) && +const connectionName = (value: string) => + Buffer.byteLength(value) <= 48 && + /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) && !['__proto__', 'prototype', 'constructor'].includes(value); const sha256 = (content: string) => createHash('sha256').update(content).digest('hex'); -const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); class MetadataError extends Error { - constructor(readonly code: string, message: string, readonly path?: string) { super(message); } + constructor( + readonly code: string, + message: string, + readonly path?: string, + ) { + super(message); + } +} +function fail(code: string, message: string, path?: string): never { + throw new MetadataError(code, message, path); } -function fail(code: string, message: string, path?: string): never { throw new MetadataError(code, message, path); } function contained(root: string, path: string): string { - if (!path || isAbsolute(path) || path.includes('\\') || path.includes('\0')) fail('EXTERNAL_PATH', 'Dependency paths must stay within the captured source.', path); + if (!path || isAbsolute(path) || path.includes('\\') || path.includes('\0')) + fail('EXTERNAL_PATH', 'Dependency paths must stay within the captured source.', path); const full = resolve(root, path); const rel = relative(root, full); - if (rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) fail('EXTERNAL_PATH', 'Dependency path escapes the captured source.', path); + if (rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) + fail('EXTERNAL_PATH', 'Dependency path escapes the captured source.', path); return full; } function read(root: string, path: string, optional = false): string | undefined { const full = contained(root, path); let stat; - try { stat = lstatSync(full); } catch (error: any) { + try { + stat = lstatSync(full); + } catch (error: any) { if (optional && error.code === 'ENOENT') return undefined; fail('MISSING_METADATA', 'Required dependency metadata is missing or unreadable.', path); } const actual = realpathSync(full); - if (stat!.isSymbolicLink() || actual !== full || !stat!.isFile()) fail('UNSAFE_METADATA', 'Dependency metadata must be a regular file without symlink ancestors.', path); - if (stat!.size > MAX_METADATA) fail('METADATA_LIMIT', 'Dependency metadata exceeds the 8 MiB inspection limit.', path); + if (stat!.isSymbolicLink() || actual !== full || !stat!.isFile()) + fail('UNSAFE_METADATA', 'Dependency metadata must be a regular file without symlink ancestors.', path); + if (stat!.size > MAX_METADATA) + fail('METADATA_LIMIT', 'Dependency metadata exceeds the 8 MiB inspection limit.', path); return readFileSync(full, 'utf8'); } function json(root: string, path: string, jsonc = false): Record { @@ -89,38 +133,70 @@ function json(root: string, path: string, jsonc = false): Record { function publicUrl(value: unknown, hosts: string[], path?: string): string { if (typeof value !== 'string') fail('UNPINNED_ARCHIVE', 'A public registry archive URL is required.', path); let url: URL; - try { url = new URL(value); } catch { fail('UNSUPPORTED_SOURCE', 'Dependency URL is invalid.', path); } - if (url!.protocol !== 'https:' || url!.username || url!.password || url!.port || url!.hash || url!.search || !hosts.includes(url!.hostname)) { - fail('UNSUPPORTED_SOURCE', 'Only credential-free HTTPS URLs on the declared public registry are supported.', path); + try { + url = new URL(value); + } catch { + fail('UNSUPPORTED_SOURCE', 'Dependency URL is invalid.', path); + } + if ( + url!.protocol !== 'https:' || + url!.username || + url!.password || + url!.port || + url!.hash || + url!.search || + !hosts.includes(url!.hostname) + ) { + fail( + 'UNSUPPORTED_SOURCE', + 'Only credential-free HTTPS URLs on the declared public registry are supported.', + path, + ); } return url!.href; } function integrity(value: unknown, path: string): string { - if (typeof value !== 'string' || !SRI.test(value)) fail('UNPINNED_ARCHIVE', 'A SHA-256 or SHA-512 archive integrity value is required.', path); + if (typeof value !== 'string' || !SRI.test(value)) + fail('UNPINNED_ARCHIVE', 'A SHA-256 or SHA-512 archive integrity value is required.', path); return value; } function packageCount(entries: unknown[], path: string) { - if (entries.length > MAX_PACKAGES) fail('METADATA_LIMIT', 'The lock exceeds the 25,000-package inspection limit.', path); + if (entries.length > MAX_PACKAGES) + fail('METADATA_LIMIT', 'The lock exceeds the 25,000-package inspection limit.', path); } function metadata(plan: PreparationPlan, path: string, value: string | object, reason?: string) { const content = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + '\n'; plan.metadata.push({ path, content, sha256: sha256(content) }); if (reason) plan.transformations.push({ path, reason, phase: 'acquisition' }); } -function command(executable: string, args: string[], cwd: PreparationCommand['cwd'], env: Record = {}): PreparationCommand { +function command( + executable: string, + args: string[], + cwd: PreparationCommand['cwd'], + env: Record = {}, +): PreparationCommand { return { executable, args, cwd, env }; } function dependencySpecs(value: unknown, path: string) { if (value === undefined) return; if (!record(value)) fail('INVALID_METADATA', 'Dependency maps must be objects.', path); for (const [name, spec] of Object.entries(value)) { - if (!NAME.test(name) || typeof spec !== 'string' || spec.length > 256 || /[\r\n\0]/.test(spec)) fail('INVALID_METADATA', 'Invalid dependency name or version constraint.', path); + if (!NAME.test(name) || typeof spec !== 'string' || spec.length > 256 || /[\r\n\0]/.test(spec)) + fail('INVALID_METADATA', 'Invalid dependency name or version constraint.', path); if (/^(?:file:|link:|workspace:)/.test(spec)) { if (spec.startsWith('workspace:')) continue; // The lock must independently identify a contained workspace. const local = spec.replace(/^(file:|link:)/, ''); - if (isAbsolute(local) || local.split(/[\\/]/).includes('..')) fail('EXTERNAL_PATH', 'Local dependency escapes the snapshot.', path); - } else if (/[/:@]/.test(spec) && !/^npm:(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+@[~^*<>=| 0-9a-z.+_-]+$/i.test(spec)) { - fail('UNSUPPORTED_SOURCE', 'Private, VCS, and arbitrary URL dependencies require explicit provisioning.', path); + if (isAbsolute(local) || local.split(/[\\/]/).includes('..')) + fail('EXTERNAL_PATH', 'Local dependency escapes the snapshot.', path); + } else if ( + /[/:@]/.test(spec) && + !/^npm:(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+@[~^*<>=| 0-9a-z.+_-]+$/i.test(spec) + ) { + fail( + 'UNSUPPORTED_SOURCE', + 'Private, VCS, and arbitrary URL dependencies require explicit provisioning.', + path, + ); } } } @@ -129,37 +205,72 @@ function rejectConflictingLogicalArchives(inputs: PreparationInput[], path: stri for (const input of inputs) { if (input.kind !== 'public') continue; const key = `${input.name.toLowerCase()}\0${input.version}`; - const archive = JSON.stringify({ url: input.url ?? null, integrity: input.integrity ?? null, - integritySource: input.integritySource ?? null, platform: input.platform ?? null }); + const archive = JSON.stringify({ + url: input.url ?? null, + integrity: input.integrity ?? null, + integritySource: input.integritySource ?? null, + platform: input.platform ?? null, + }); const prior = identities.get(key); if (prior !== undefined && prior !== archive) - fail('CONFLICTING_LOCK_IDENTITY', `Multiple locked archives disagree for ${input.name}@${input.version}.`, path); + fail( + 'CONFLICTING_LOCK_IDENTITY', + `Multiple locked archives disagree for ${input.name}@${input.version}.`, + path, + ); identities.set(key, archive); } } function manifest(root: string, path: string, plan: PreparationPlan): Record { const source = json(root, path); for (const field of DEPENDENCY_FIELDS) dependencySpecs(source[field], path); - if (source.patchedDependencies) fail('UNSUPPORTED_PATCHES', 'Package-manager patches require an explicitly qualified offline preparation profile.', path); - if (source.overrides && JSON.stringify(source.overrides).match(/(?:https?:|git[+:]|file:|link:)/)) fail('UNSUPPORTED_SOURCE', 'Dependency overrides must resolve to public version constraints.', path); + if (source.patchedDependencies) + fail( + 'UNSUPPORTED_PATCHES', + 'Package-manager patches require an explicitly qualified offline preparation profile.', + path, + ); + if (source.overrides && JSON.stringify(source.overrides).match(/(?:https?:|git[+:]|file:|link:)/)) + fail('UNSUPPORTED_SOURCE', 'Dependency overrides must resolve to public version constraints.', path); if (source.workspaces !== undefined) { const workspaces = Array.isArray(source.workspaces) ? source.workspaces : source.workspaces?.packages; - if (!Array.isArray(workspaces) || workspaces.some((item: unknown) => typeof item !== 'string' || !/^[A-Za-z0-9_.*\/-]+$/.test(item) || item.startsWith('/') || item.split('/').includes('..'))) fail('EXTERNAL_PATH', 'Workspace patterns must remain inside captured source.', path); + if ( + !Array.isArray(workspaces) || + workspaces.some( + (item: unknown) => + typeof item !== 'string' || + !/^[A-Za-z0-9_.*\/-]+$/.test(item) || + item.startsWith('/') || + item.split('/').includes('..'), + ) + ) + fail('EXTERNAL_PATH', 'Workspace patterns must remain inside captured source.', path); } const clean: Record = {}; for (const field of MANIFEST_FIELDS) if (source[field] !== undefined) clean[field] = source[field]; - metadata(plan, path, clean, 'Acquisition manifest omits scripts, package-manager plugins, and target runtime configuration.'); - if (record(source.engines)) for (const key of ['node', 'bun']) if (typeof source.engines[key] === 'string') plan.runtimeRequirements[key] = source.engines[key]; - if (typeof source.packageManager === 'string') plan.runtimeRequirements.packageManager = source.packageManager; + metadata( + plan, + path, + clean, + 'Acquisition manifest omits scripts, package-manager plugins, and target runtime configuration.', + ); + if (record(source.engines)) + for (const key of ['node', 'bun']) + if (typeof source.engines[key] === 'string') plan.runtimeRequirements[key] = source.engines[key]; + if (typeof source.packageManager === 'string') + plan.runtimeRequirements.packageManager = source.packageManager; return clean; } function inspectNode(root: string, plan: PreparationPlan) { - const lockPath = read(root, 'npm-shrinkwrap.json', true) !== undefined ? 'npm-shrinkwrap.json' : 'package-lock.json'; + const lockPath = + read(root, 'npm-shrinkwrap.json', true) !== undefined ? 'npm-shrinkwrap.json' : 'package-lock.json'; const lock = json(root, lockPath); - if (![2, 3].includes(lock.lockfileVersion) || !record(lock.packages)) fail('UNSUPPORTED_LOCK', 'Node preparation requires npm lock/shrinkwrap version 2 or 3.', lockPath); + if (![2, 3].includes(lock.lockfileVersion) || !record(lock.packages)) + fail('UNSUPPORTED_LOCK', 'Node preparation requires npm lock/shrinkwrap version 2 or 3.', lockPath); manifest(root, 'package.json', plan); - const entries = Object.entries(lock.packages); packageCount(entries, lockPath); + const entries = Object.entries(lock.packages); + packageCount(entries, lockPath); const clean = structuredClone(lock); // Version-2's redundant dependency tree is accepted only when every resolved URL is safe. function validateTree(tree: unknown) { @@ -182,62 +293,184 @@ function inspectNode(root: string, plan: PreparationPlan) { continue; } if (raw.link === true) { - const local = String(raw.resolved ?? ''); contained(root, local); - if (!record(lock.packages[local])) fail('MISSING_LOCAL_PACKAGE', 'Workspace link has no captured lock entry.', lockPath); - plan.inputs.push({ kind: 'local', name: local, version: String(lock.packages[local].version ?? '0'), path: local }); + const local = String(raw.resolved ?? ''); + contained(root, local); + if (!record(lock.packages[local])) + fail('MISSING_LOCAL_PACKAGE', 'Workspace link has no captured lock entry.', lockPath); + plan.inputs.push({ + kind: 'local', + name: local, + version: String(lock.packages[local].version ?? '0'), + path: local, + }); continue; } const name = typeof raw.name === 'string' ? raw.name : path.split('node_modules/').at(-1)!; - if (!NAME.test(name) || typeof raw.version !== 'string' || !VERSION.test(raw.version)) fail('INVALID_METADATA', 'Locked npm package needs an exact name and version.', lockPath); + if (!NAME.test(name) || typeof raw.version !== 'string' || !VERSION.test(raw.version)) + fail('INVALID_METADATA', 'Locked npm package needs an exact name and version.', lockPath); for (const field of DEPENDENCY_FIELDS) dependencySpecs(raw[field], lockPath); - plan.inputs.push({ kind: 'public', name, version: raw.version, url: publicUrl(raw.resolved, ['registry.npmjs.org'], lockPath), integrity: integrity(raw.integrity, lockPath), integritySource: 'lock' }); + plan.inputs.push({ + kind: 'public', + name, + version: raw.version, + url: publicUrl(raw.resolved, ['registry.npmjs.org'], lockPath), + integrity: integrity(raw.integrity, lockPath), + integritySource: 'lock', + }); delete clean.packages[path].scripts; } rejectConflictingLogicalArchives(plan.inputs, lockPath); - metadata(plan, lockPath, clean, 'Acquisition lock retains resolution and integrity data but omits executable script fields.'); - const acquisition = ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--cache', '/archives/npm', '--userconfig', '/opt/cso/empty-config', '--globalconfig', '/opt/cso/empty-config']; - const offline = ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--cache', '/work/.cso-npm-cache', '--userconfig', '/opt/cso/empty-config', '--globalconfig', '/opt/cso/empty-config']; - plan.acquisition.push(command('/usr/local/bin/npm', [...acquisition, '--registry', 'https://registry.npmjs.org'], '/metadata', { NPM_CONFIG_UPDATE_NOTIFIER: 'false' })); + metadata( + plan, + lockPath, + clean, + 'Acquisition lock retains resolution and integrity data but omits executable script fields.', + ); + const acquisition = [ + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--cache', + '/archives/npm', + '--userconfig', + '/opt/cso/empty-config', + '--globalconfig', + '/opt/cso/empty-config', + ]; + const offline = [ + 'ci', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--cache', + '/work/.cso-npm-cache', + '--userconfig', + '/opt/cso/empty-config', + '--globalconfig', + '/opt/cso/empty-config', + ]; + plan.acquisition.push( + command('/usr/local/bin/npm', [...acquisition, '--registry', 'https://registry.npmjs.org'], '/metadata', { + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + }), + ); plan.offline.push(command('/usr/local/bin/npm', [...offline, '--offline'], '/work')); - plan.offline.push(command('/usr/local/bin/npm', ['rebuild', '--offline', '--no-audit', '--no-fund', '--cache', '/work/.cso-npm-cache', '--userconfig', '/opt/cso/empty-config', '--globalconfig', '/opt/cso/empty-config'], '/work')); + plan.offline.push( + command( + '/usr/local/bin/npm', + [ + 'rebuild', + '--offline', + '--no-audit', + '--no-fund', + '--cache', + '/work/.cso-npm-cache', + '--userconfig', + '/opt/cso/empty-config', + '--globalconfig', + '/opt/cso/empty-config', + ], + '/work', + ), + ); plan.registryHosts = ['registry.npmjs.org']; } function inspectBun(root: string, plan: PreparationPlan) { - if (read(root, 'bun.lock', true) === undefined) fail('UNSUPPORTED_LOCK', 'Bun preparation requires the text bun.lock format; bun.lockb is not supported.', 'bun.lock'); + if (read(root, 'bun.lock', true) === undefined) + fail( + 'UNSUPPORTED_LOCK', + 'Bun preparation requires the text bun.lock format; bun.lockb is not supported.', + 'bun.lock', + ); const lock = json(root, 'bun.lock', true); - if (lock.lockfileVersion !== 1 || !record(lock.packages) || !record(lock.workspaces)) fail('UNSUPPORTED_LOCK', 'Unsupported Bun text-lock schema.', 'bun.lock'); - if (lock.patchedDependencies) fail('UNSUPPORTED_PATCHES', 'Bun patched dependencies require an explicitly qualified offline profile.', 'bun.lock'); - const workspacePaths = Object.keys(lock.workspaces); packageCount(workspacePaths, 'bun.lock'); + if (lock.lockfileVersion !== 1 || !record(lock.packages) || !record(lock.workspaces)) + fail('UNSUPPORTED_LOCK', 'Unsupported Bun text-lock schema.', 'bun.lock'); + if (lock.patchedDependencies) + fail( + 'UNSUPPORTED_PATCHES', + 'Bun patched dependencies require an explicitly qualified offline profile.', + 'bun.lock', + ); + const workspacePaths = Object.keys(lock.workspaces); + packageCount(workspacePaths, 'bun.lock'); for (const path of workspacePaths) { if (path) contained(root, path); manifest(root, path ? `${path}/package.json` : 'package.json', plan); for (const field of DEPENDENCY_FIELDS) dependencySpecs(lock.workspaces[path][field], 'bun.lock'); } - const entries = Object.entries(lock.packages); packageCount(entries, 'bun.lock'); + const entries = Object.entries(lock.packages); + packageCount(entries, 'bun.lock'); for (const [key, raw] of entries) { - if (!Array.isArray(raw) || typeof raw[0] !== 'string') fail('INVALID_METADATA', 'Invalid Bun package tuple.', 'bun.lock'); + if (!Array.isArray(raw) || typeof raw[0] !== 'string') + fail('INVALID_METADATA', 'Invalid Bun package tuple.', 'bun.lock'); const split = raw[0].lastIndexOf('@'); - const name = raw[0].slice(0, split), version = raw[0].slice(split + 1); + const name = raw[0].slice(0, split), + version = raw[0].slice(split + 1); if (version.startsWith('workspace:')) { - const path = version.slice(10); contained(root, path); - if (!workspacePaths.includes(path)) fail('MISSING_LOCAL_PACKAGE', 'Bun workspace is not captured in the lock.', 'bun.lock'); - plan.inputs.push({ kind: 'local', name, version, path }); continue; + const path = version.slice(10); + contained(root, path); + if (!workspacePaths.includes(path)) + fail('MISSING_LOCAL_PACKAGE', 'Bun workspace is not captured in the lock.', 'bun.lock'); + plan.inputs.push({ kind: 'local', name, version, path }); + continue; } - if (!NAME.test(name) || !VERSION.test(version)) fail('UNSUPPORTED_SOURCE', 'Bun package must resolve to an exact public registry version.', 'bun.lock'); - const archiveUrl = raw[1] || `https://registry.npmjs.org/${name}/-/${name.split('/').at(-1)}-${version}.tgz`; + if (!NAME.test(name) || !VERSION.test(version)) + fail('UNSUPPORTED_SOURCE', 'Bun package must resolve to an exact public registry version.', 'bun.lock'); + const archiveUrl = + raw[1] || `https://registry.npmjs.org/${name}/-/${name.split('/').at(-1)}-${version}.tgz`; publicUrl(archiveUrl, ['registry.npmjs.org'], 'bun.lock'); if (!record(raw[2])) fail('INVALID_METADATA', 'Bun package metadata must be an object.', 'bun.lock'); for (const field of DEPENDENCY_FIELDS) dependencySpecs(raw[2][field], 'bun.lock'); - plan.inputs.push({ kind: 'public', name, version, url: archiveUrl, integrity: integrity(raw[3], 'bun.lock'), integritySource: 'lock' }); + plan.inputs.push({ + kind: 'public', + name, + version, + url: archiveUrl, + integrity: integrity(raw[3], 'bun.lock'), + integritySource: 'lock', + }); } rejectConflictingLogicalArchives(plan.inputs, 'bun.lock'); metadata(plan, 'bun.lock', lock); - const acquisitionEnv = { BUN_INSTALL_CACHE_DIR: '/metadata/.cso-bun-cache', BUN_CONFIG_NO_CLEAR_TERMINAL: '1', BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER: '1' }; + const acquisitionEnv = { + BUN_INSTALL_CACHE_DIR: '/metadata/.cso-bun-cache', + BUN_CONFIG_NO_CLEAR_TERMINAL: '1', + BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER: '1', + }; const offlineEnv = { ...acquisitionEnv, BUN_INSTALL_CACHE_DIR: '/work/.cso-bun-cache' }; - plan.acquisition.push(command('/usr/local/bin/bun', ['install', '--config=/opt/cso/empty-config', '--frozen-lockfile', '--ignore-scripts', '--no-progress', '--backend=hardlink', '--registry=https://registry.npmjs.org'], '/metadata', acquisitionEnv)); + plan.acquisition.push( + command( + '/usr/local/bin/bun', + [ + 'install', + '--config=/opt/cso/empty-config', + '--frozen-lockfile', + '--ignore-scripts', + '--no-progress', + '--backend=hardlink', + '--registry=https://registry.npmjs.org', + ], + '/metadata', + acquisitionEnv, + ), + ); // The runner enforces network-none; Bun's --offline availability is version-specific. - plan.offline.push(command('/usr/local/bin/bun', ['install', '--config=/opt/cso/empty-config', '--frozen-lockfile', '--no-progress', '--backend=copyfile'], '/work', offlineEnv)); + plan.offline.push( + command( + '/usr/local/bin/bun', + [ + 'install', + '--config=/opt/cso/empty-config', + '--frozen-lockfile', + '--no-progress', + '--backend=copyfile', + ], + '/work', + offlineEnv, + ), + ); plan.registryHosts = ['registry.npmjs.org']; } @@ -248,10 +481,28 @@ function requirementLines(text: string, plan: PreparationPlan, path: string) { if (!line || line.startsWith('#')) continue; const hashes = [...line.matchAll(/(?:^|\s)--hash=sha256:([a-f0-9]{64})(?=\s|$)/gi)]; const requirement = line.replace(/(?:^|\s)--hash=sha256:[a-f0-9]{64}(?=\s|$)/gi, '').trim(); - const match = requirement.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[A-Za-z0-9_,.-]+\])?==([A-Za-z0-9][A-Za-z0-9.!+_-]*)(?:\s*;\s*([A-Za-z0-9_.'" ()<>=!~+,-]+))?$/); - if (!match || !hashes.length || requirement.includes('--')) fail('UNPINNED_REQUIREMENTS', 'Requirements must contain only exact public package pins with SHA-256 hashes; includes, URLs, editable paths, and index options are unsupported.', path); - if (match[3]) fail('UNSUPPORTED_MARKER', 'PEP 508 environment markers require a qualified runtime-specific lock export.', path); - plan.inputs.push({ kind: 'public', name: match[1], version: match[2], integrity: hashes.map(h => `sha256:${h[1].toLowerCase()}`).join(' '), integritySource: 'lock' }); + const match = requirement.match( + /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[A-Za-z0-9_,.-]+\])?==([A-Za-z0-9][A-Za-z0-9.!+_-]*)(?:\s*;\s*([A-Za-z0-9_.'" ()<>=!~+,-]+))?$/, + ); + if (!match || !hashes.length || requirement.includes('--')) + fail( + 'UNPINNED_REQUIREMENTS', + 'Requirements must contain only exact public package pins with SHA-256 hashes; includes, URLs, editable paths, and index options are unsupported.', + path, + ); + if (match[3]) + fail( + 'UNSUPPORTED_MARKER', + 'PEP 508 environment markers require a qualified runtime-specific lock export.', + path, + ); + plan.inputs.push({ + kind: 'public', + name: match[1], + version: match[2], + integrity: hashes.map((h) => `sha256:${h[1].toLowerCase()}`).join(' '), + integritySource: 'lock', + }); } packageCount(plan.inputs, path); } @@ -265,20 +516,28 @@ function toml(root: string, path: string): Record { fail('INVALID_METADATA', 'Dependency metadata is not valid TOML.', path); } } -function hasUvEnvironmentMarker(value:unknown,seen=new WeakSet()):boolean{ - if(value===null||typeof value!=='object')return false; - if(seen.has(value as object))return true; +function hasUvEnvironmentMarker(value: unknown, seen = new WeakSet()): boolean { + if (value === null || typeof value !== 'object') return false; + if (seen.has(value as object)) return true; seen.add(value as object); - if(Array.isArray(value))return value.some(item=>hasUvEnvironmentMarker(item,seen)); - for(const [key,item] of Object.entries(value as Record)){ - if(key==='marker'||key==='resolution-markers'||key==='fork-markers')return true; - if(hasUvEnvironmentMarker(item,seen))return true; + if (Array.isArray(value)) return value.some((item) => hasUvEnvironmentMarker(item, seen)); + for (const [key, item] of Object.entries(value as Record)) { + if (key === 'marker' || key === 'resolution-markers' || key === 'fork-markers') return true; + if (hasUvEnvironmentMarker(item, seen)) return true; } return false; } function inspectPython(root: string, plan: PreparationPlan) { const hasUv = read(root, 'uv.lock', true) !== undefined; - const acquisitionEnv = { UV_NO_CONFIG: '1', UV_PYTHON_DOWNLOADS: 'never', UV_NO_MANAGED_PYTHON: '1', UV_CACHE_DIR: '/archives/uv', PIP_CONFIG_FILE: '/dev/null', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_NO_CACHE_DIR: '1' }; + const acquisitionEnv = { + UV_NO_CONFIG: '1', + UV_PYTHON_DOWNLOADS: 'never', + UV_NO_MANAGED_PYTHON: '1', + UV_CACHE_DIR: '/archives/uv', + PIP_CONFIG_FILE: '/dev/null', + PIP_DISABLE_PIP_VERSION_CHECK: '1', + PIP_NO_CACHE_DIR: '1', + }; const offlineEnv = { ...acquisitionEnv, UV_CACHE_DIR: '/work/.cso-uv-cache', UV_LINK_MODE: 'copy' }; let requirementsPath = 'requirements.txt'; let publicRequirements = '/metadata/requirements.txt'; @@ -286,27 +545,66 @@ function inspectPython(root: string, plan: PreparationPlan) { const buildRequirements = new Set(); if (hasUv) { const lock = toml(root, 'uv.lock'); - if (lock.version !== 1 || !Array.isArray(lock.package)) fail('UNSUPPORTED_LOCK', 'Unsupported uv.lock schema.', 'uv.lock'); - if(hasUvEnvironmentMarker(lock))fail('UNSUPPORTED_MARKER', 'Universal uv locks with environment or resolution markers require a qualified runtime-specific export before automatic preparation.', 'uv.lock'); + if (lock.version !== 1 || !Array.isArray(lock.package)) + fail('UNSUPPORTED_LOCK', 'Unsupported uv.lock schema.', 'uv.lock'); + if (hasUvEnvironmentMarker(lock)) + fail( + 'UNSUPPORTED_MARKER', + 'Universal uv locks with environment or resolution markers require a qualified runtime-specific export before automatic preparation.', + 'uv.lock', + ); packageCount(lock.package, 'uv.lock'); for (const pkg of lock.package) { - if (!record(pkg) || !record(pkg.source) || !NAME.test(pkg.name) || !VERSION.test(pkg.version)) fail('INVALID_METADATA', 'Invalid uv locked package.', 'uv.lock'); + if (!record(pkg) || !record(pkg.source) || !NAME.test(pkg.name) || !VERSION.test(pkg.version)) + fail('INVALID_METADATA', 'Invalid uv locked package.', 'uv.lock'); if (pkg.source.registry !== undefined) { - if (Object.keys(pkg.source).length !== 1) fail('UNSUPPORTED_SOURCE', 'Python registry entries cannot contain alternate dependency sources.', 'uv.lock'); + if (Object.keys(pkg.source).length !== 1) + fail( + 'UNSUPPORTED_SOURCE', + 'Python registry entries cannot contain alternate dependency sources.', + 'uv.lock', + ); const registry = publicUrl(pkg.source.registry, ['pypi.org'], 'uv.lock'); - if (!['https://pypi.org/simple', 'https://pypi.org/simple/'].includes(registry)) fail('UNSUPPORTED_SOURCE', 'Python acquisition supports the public PyPI simple index only.', 'uv.lock'); - if (!Array.isArray(pkg.wheels) || !pkg.wheels.length) fail('MISSING_PUBLIC_WHEEL', 'A matching public wheel is required; source distributions are never built during acquisition.', 'uv.lock'); + if (!['https://pypi.org/simple', 'https://pypi.org/simple/'].includes(registry)) + fail( + 'UNSUPPORTED_SOURCE', + 'Python acquisition supports the public PyPI simple index only.', + 'uv.lock', + ); + if (!Array.isArray(pkg.wheels) || !pkg.wheels.length) + fail( + 'MISSING_PUBLIC_WHEEL', + 'A matching public wheel is required; source distributions are never built during acquisition.', + 'uv.lock', + ); for (const wheel of pkg.wheels) { - if (!record(wheel) || typeof wheel.hash !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(wheel.hash)) fail('UNPINNED_ARCHIVE', 'uv wheels require SHA-256 hashes.', 'uv.lock'); - plan.inputs.push({ kind: 'public', name: pkg.name, version: pkg.version, url: publicUrl(wheel.url, ['files.pythonhosted.org'], 'uv.lock'), integrity: wheel.hash, integritySource: 'lock' }); + if (!record(wheel) || typeof wheel.hash !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(wheel.hash)) + fail('UNPINNED_ARCHIVE', 'uv wheels require SHA-256 hashes.', 'uv.lock'); + plan.inputs.push({ + kind: 'public', + name: pkg.name, + version: pkg.version, + url: publicUrl(wheel.url, ['files.pythonhosted.org'], 'uv.lock'), + integrity: wheel.hash, + integritySource: 'lock', + }); } if (pkg.sdist) { publicUrl(pkg.sdist.url, ['files.pythonhosted.org'], 'uv.lock'); - if (!/^sha256:[a-f0-9]{64}$/.test(pkg.sdist.hash ?? '')) fail('UNPINNED_ARCHIVE', 'uv source archive hash is invalid.', 'uv.lock'); + if (!/^sha256:[a-f0-9]{64}$/.test(pkg.sdist.hash ?? '')) + fail('UNPINNED_ARCHIVE', 'uv source archive hash is invalid.', 'uv.lock'); } } else { const local = pkg.source.editable ?? pkg.source.virtual ?? pkg.source.directory; - if (typeof local !== 'string' || Object.keys(pkg.source).some(k => !['editable', 'virtual', 'directory'].includes(k))) fail('UNSUPPORTED_SOURCE', 'Private, VCS, and direct-URL Python sources require explicit provisioning.', 'uv.lock'); + if ( + typeof local !== 'string' || + Object.keys(pkg.source).some((k) => !['editable', 'virtual', 'directory'].includes(k)) + ) + fail( + 'UNSUPPORTED_SOURCE', + 'Private, VCS, and direct-URL Python sources require explicit provisioning.', + 'uv.lock', + ); contained(root, local); plan.inputs.push({ kind: 'local', name: pkg.name, version: pkg.version, path: local }); if (pkg.source.virtual === undefined) { @@ -315,87 +613,354 @@ function inspectPython(root: string, plan: PreparationPlan) { const localProject = read(root, pyprojectPath, true) === undefined ? {} : toml(root, pyprojectPath); const build = localProject['build-system']; const requirements = build?.requires ?? ['setuptools>=40.8.0']; - if (!Array.isArray(requirements)) fail('DYNAMIC_BUILD_DEPENDENCIES', 'Local build dependencies must be declared as static package requirements.', pyprojectPath); + if (!Array.isArray(requirements)) + fail( + 'DYNAMIC_BUILD_DEPENDENCIES', + 'Local build dependencies must be declared as static package requirements.', + pyprojectPath, + ); for (const requirement of requirements) { - if (typeof requirement !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[A-Za-z0-9_,.-]+\])?\s*[A-Za-z0-9.!~<>=+*, -]*$/.test(requirement)) fail('UNSUPPORTED_BUILD_DEPENDENCY', 'Local build dependencies must be public package requirements without URLs or paths.', pyprojectPath); - buildRequirements.add(requirement.match(/^[A-Za-z0-9][A-Za-z0-9._-]*/)![0].toLowerCase().replace(/[_.]+/g, '-')); + if ( + typeof requirement !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[A-Za-z0-9_,.-]+\])?\s*[A-Za-z0-9.!~<>=+*, -]*$/.test( + requirement, + ) + ) + fail( + 'UNSUPPORTED_BUILD_DEPENDENCY', + 'Local build dependencies must be public package requirements without URLs or paths.', + pyprojectPath, + ); + buildRequirements.add( + requirement + .match(/^[A-Za-z0-9][A-Za-z0-9._-]*/)![0] + .toLowerCase() + .replace(/[_.]+/g, '-'), + ); } } } } const project = toml(root, 'pyproject.toml'); - if (!record(project.project)) fail('UNSUPPORTED_METADATA', 'uv export requires static PEP 621 project metadata.', 'pyproject.toml'); - if (project.tool?.uv?.workspace !== undefined || plan.inputs.some(input => input.kind === 'local' && input.path !== '.')) - fail('UNSUPPORTED_WORKSPACE', 'uv workspace/member metadata is not yet qualified for sanitized automatic preparation.', 'pyproject.toml'); - if (project.project.dynamic?.includes('dependencies')) fail('DYNAMIC_METADATA', 'Dynamic project dependencies require qualified offline build dependencies.', 'pyproject.toml'); + if (!record(project.project)) + fail('UNSUPPORTED_METADATA', 'uv export requires static PEP 621 project metadata.', 'pyproject.toml'); + if ( + project.tool?.uv?.workspace !== undefined || + plan.inputs.some((input) => input.kind === 'local' && input.path !== '.') + ) + fail( + 'UNSUPPORTED_WORKSPACE', + 'uv workspace/member metadata is not yet qualified for sanitized automatic preparation.', + 'pyproject.toml', + ); + if (project.project.dynamic?.includes('dependencies')) + fail( + 'DYNAMIC_METADATA', + 'Dynamic project dependencies require qualified offline build dependencies.', + 'pyproject.toml', + ); // Only the export process sees this metadata; no build-system or tool.uv sources/config. const clean: Record = { project: project.project }; if (project['dependency-groups']) clean['dependency-groups'] = project['dependency-groups']; // uv accepts a static pyproject file. Keep original syntax only after excluding every // non-project table would require a TOML writer; use JSON-compatible TOML literals. - metadata(plan, 'pyproject.toml', toToml(clean), 'Acquisition pyproject omits all build backends and tool configuration; local packages are excluded by --no-emit-local.'); + metadata( + plan, + 'pyproject.toml', + toToml(clean), + 'Acquisition pyproject omits all build backends and tool configuration; local packages are excluded by --no-emit-local.', + ); metadata(plan, 'uv.lock', read(root, 'uv.lock')!); - plan.runtimeRequirements.python = String(lock['requires-python'] ?? project.project['requires-python'] ?? ''); + plan.runtimeRequirements.python = String( + lock['requires-python'] ?? project.project['requires-python'] ?? '', + ); requirementsPath = 'cso-public-requirements.txt'; publicRequirements = `/archives/${requirementsPath}`; - plan.acquisition.push(command('/usr/local/bin/uv', ['export', '--frozen', '--no-emit-local', '--all-packages', '--all-extras', '--all-groups', '--no-config', '--format', 'requirements-txt', '--output-file', publicRequirements], '/metadata', acquisitionEnv)); - plan.offline.push(command('/usr/local/bin/uv', ['export', '--offline', '--frozen', '--no-emit-local', '--all-packages', '--all-extras', '--all-groups', '--no-config', '--format', 'requirements-txt', '--output-file', '/work/.gstack-cso-public-requirements.txt'], '/metadata', offlineEnv)); - plan.offline.push(command('/usr/local/bin/python', ['-I', '-m', 'venv', '--copies', '/work/.venv'], '/work', offlineEnv)); - plan.offline.push(command('/usr/local/bin/uv', ['pip', 'install', '--offline', '--no-config', '--python', '/work/.venv/bin/python', '--link-mode', 'copy', '--no-index', '--find-links', '/archives/wheels', '--require-hashes', '--only-binary', ':all:', '--requirement', '/work/.gstack-cso-public-requirements.txt'], '/work', offlineEnv)); + plan.acquisition.push( + command( + '/usr/local/bin/uv', + [ + 'export', + '--frozen', + '--no-emit-local', + '--all-packages', + '--all-extras', + '--all-groups', + '--no-config', + '--format', + 'requirements-txt', + '--output-file', + publicRequirements, + ], + '/metadata', + acquisitionEnv, + ), + ); + plan.offline.push( + command( + '/usr/local/bin/uv', + [ + 'export', + '--offline', + '--frozen', + '--no-emit-local', + '--all-packages', + '--all-extras', + '--all-groups', + '--no-config', + '--format', + 'requirements-txt', + '--output-file', + '/work/.gstack-cso-public-requirements.txt', + ], + '/metadata', + offlineEnv, + ), + ); + plan.offline.push( + command('/usr/local/bin/python', ['-I', '-m', 'venv', '--copies', '/work/.venv'], '/work', offlineEnv), + ); + plan.offline.push( + command( + '/usr/local/bin/uv', + [ + 'pip', + 'install', + '--offline', + '--no-config', + '--python', + '/work/.venv/bin/python', + '--link-mode', + 'copy', + '--no-index', + '--find-links', + '/archives/wheels', + '--require-hashes', + '--only-binary', + ':all:', + '--requirement', + '/work/.gstack-cso-public-requirements.txt', + ], + '/work', + offlineEnv, + ), + ); if (buildRequirements.size) { const lines: string[] = []; for (const name of buildRequirements) { - const locked = plan.inputs.filter(input => input.kind === 'public' && input.name.toLowerCase().replace(/[_.]+/g, '-') === name); - if (!locked.length || new Set(locked.map(input => input.version)).size !== 1) fail('MISSING_BUILD_DEPENDENCY', 'Every local build dependency needs one exact public wheel version in uv.lock.', 'uv.lock'); - lines.push(`${name}==${locked[0].version} ${[...new Set(locked.map(input => input.integrity))].map(hash => `--hash=${hash}`).join(' ')}`); + const locked = plan.inputs.filter( + (input) => input.kind === 'public' && input.name.toLowerCase().replace(/[_.]+/g, '-') === name, + ); + if (!locked.length || new Set(locked.map((input) => input.version)).size !== 1) + fail( + 'MISSING_BUILD_DEPENDENCY', + 'Every local build dependency needs one exact public wheel version in uv.lock.', + 'uv.lock', + ); + lines.push( + `${name}==${locked[0].version} ${[...new Set(locked.map((input) => input.integrity))].map((hash) => `--hash=${hash}`).join(' ')}`, + ); } - metadata(plan, '.gstack-cso/build-requirements.txt', lines.join('\n') + '\n', 'Local build dependencies are acquired only as exact hashed public wheels.'); - plan.acquisition.push(command('/usr/local/bin/python', ['-I', '-m', 'pip', '--isolated', 'download', '--index-url', 'https://pypi.org/simple', '--require-hashes', '--only-binary=:all:', '--dest', '/archives/wheels', '--requirement', '/metadata/.gstack-cso/build-requirements.txt'], '/metadata', acquisitionEnv)); - plan.offline.push(command('/usr/local/bin/uv', ['pip', 'install', '--offline', '--no-config', '--python', '/work/.venv/bin/python', '--link-mode', 'copy', '--no-index', '--find-links', '/archives/wheels', '--require-hashes', '--only-binary', ':all:', '--requirement', '/metadata/.gstack-cso/build-requirements.txt'], '/work', offlineEnv)); + metadata( + plan, + '.gstack-cso/build-requirements.txt', + lines.join('\n') + '\n', + 'Local build dependencies are acquired only as exact hashed public wheels.', + ); + plan.acquisition.push( + command( + '/usr/local/bin/python', + [ + '-I', + '-m', + 'pip', + '--isolated', + 'download', + '--index-url', + 'https://pypi.org/simple', + '--require-hashes', + '--only-binary=:all:', + '--dest', + '/archives/wheels', + '--requirement', + '/metadata/.gstack-cso/build-requirements.txt', + ], + '/metadata', + acquisitionEnv, + ), + ); + plan.offline.push( + command( + '/usr/local/bin/uv', + [ + 'pip', + 'install', + '--offline', + '--no-config', + '--python', + '/work/.venv/bin/python', + '--link-mode', + 'copy', + '--no-index', + '--find-links', + '/archives/wheels', + '--require-hashes', + '--only-binary', + ':all:', + '--requirement', + '/metadata/.gstack-cso/build-requirements.txt', + ], + '/work', + offlineEnv, + ), + ); } - if (localBuildPaths.length) plan.offline.push(command('/usr/local/bin/uv', ['pip', 'install', '--offline', '--no-config', '--python', '/work/.venv/bin/python', '--link-mode', 'copy', '--no-index', '--find-links', '/archives/wheels', '--no-deps', '--no-build-isolation', ...localBuildPaths.map(path => `/work/${path}`)], '/work', offlineEnv)); - plan.offline.push(command('/usr/local/bin/uv', ['pip', 'check', '--offline', '--no-config', '--python', '/work/.venv/bin/python'], '/work', offlineEnv)); + if (localBuildPaths.length) + plan.offline.push( + command( + '/usr/local/bin/uv', + [ + 'pip', + 'install', + '--offline', + '--no-config', + '--python', + '/work/.venv/bin/python', + '--link-mode', + 'copy', + '--no-index', + '--find-links', + '/archives/wheels', + '--no-deps', + '--no-build-isolation', + ...localBuildPaths.map((path) => `/work/${path}`), + ], + '/work', + offlineEnv, + ), + ); + plan.offline.push( + command( + '/usr/local/bin/uv', + ['pip', 'check', '--offline', '--no-config', '--python', '/work/.venv/bin/python'], + '/work', + offlineEnv, + ), + ); } else { const contents = read(root, requirementsPath)!; requirementLines(contents, plan, requirementsPath); metadata(plan, requirementsPath, contents); - plan.offline.push(command('/usr/local/bin/python', ['-I', '-m', 'venv', '--copies', '/work/.venv'], '/work', offlineEnv)); - plan.offline.push(command('/work/.venv/bin/python', ['-I', '-m', 'pip', '--isolated', 'install', '--no-index', '--find-links', '/archives/wheels', '--require-hashes', '--only-binary=:all:', '--requirement', '/work/requirements.txt'], '/work', offlineEnv)); - plan.offline.push(command('/work/.venv/bin/python', ['-I', '-m', 'pip', '--isolated', 'check'], '/work', offlineEnv)); + plan.offline.push( + command('/usr/local/bin/python', ['-I', '-m', 'venv', '--copies', '/work/.venv'], '/work', offlineEnv), + ); + plan.offline.push( + command( + '/work/.venv/bin/python', + [ + '-I', + '-m', + 'pip', + '--isolated', + 'install', + '--no-index', + '--find-links', + '/archives/wheels', + '--require-hashes', + '--only-binary=:all:', + '--requirement', + '/work/requirements.txt', + ], + '/work', + offlineEnv, + ), + ); + plan.offline.push( + command('/work/.venv/bin/python', ['-I', '-m', 'pip', '--isolated', 'check'], '/work', offlineEnv), + ); } - plan.acquisition.push(command('/usr/local/bin/python', ['-I', '-m', 'pip', '--isolated', 'download', '--index-url', 'https://pypi.org/simple', '--require-hashes', '--only-binary=:all:', '--dest', '/archives/wheels', '--requirement', publicRequirements], '/metadata', acquisitionEnv)); + plan.acquisition.push( + command( + '/usr/local/bin/python', + [ + '-I', + '-m', + 'pip', + '--isolated', + 'download', + '--index-url', + 'https://pypi.org/simple', + '--require-hashes', + '--only-binary=:all:', + '--dest', + '/archives/wheels', + '--requirement', + publicRequirements, + ], + '/metadata', + acquisitionEnv, + ), + ); plan.registryHosts = ['pypi.org', 'files.pythonhosted.org']; } function toToml(value: Record): string { const literal = (x: any): string => { if (typeof x === 'string' || typeof x === 'boolean' || typeof x === 'number') return JSON.stringify(x); if (Array.isArray(x)) return `[${x.map(literal).join(', ')}]`; - if (record(x)) return `{ ${Object.entries(x).map(([k, v]) => `${JSON.stringify(k)} = ${literal(v)}`).join(', ')} }`; + if (record(x)) + return `{ ${Object.entries(x) + .map(([k, v]) => `${JSON.stringify(k)} = ${literal(v)}`) + .join(', ')} }`; fail('INVALID_METADATA', 'Unsupported TOML metadata value.', 'pyproject.toml'); }; - return Object.entries(value).map(([key, val]) => `${JSON.stringify(key)} = ${literal(val)}`).join('\n') + '\n'; + return ( + Object.entries(value) + .map(([key, val]) => `${JSON.stringify(key)} = ${literal(val)}`) + .join('\n') + '\n' + ); } function inspectRails(root: string, plan: PreparationPlan) { const contents = read(root, 'Gemfile.lock')!; read(root, 'Gemfile'); // Presence only; never parse/evaluate Ruby during acquisition. - let section = '', sawPublicRemote = false; + let section = '', + sawPublicRemote = false; const checksums = new Map(); for (const line of contents.split(/\r?\n/)) { if (/^[A-Z][A-Z ]+$/.test(line)) { section = line; - if (!['GEM', 'PLATFORMS', 'DEPENDENCIES', 'RUBY VERSION', 'BUNDLED WITH', 'CHECKSUMS'].includes(section)) fail('UNSUPPORTED_SOURCE', 'Gemfile.lock contains a non-public source or unsupported section.', 'Gemfile.lock'); + if ( + !['GEM', 'PLATFORMS', 'DEPENDENCIES', 'RUBY VERSION', 'BUNDLED WITH', 'CHECKSUMS'].includes(section) + ) + fail( + 'UNSUPPORTED_SOURCE', + 'Gemfile.lock contains a non-public source or unsupported section.', + 'Gemfile.lock', + ); continue; } if (!line.trim()) continue; if (section === 'GEM' && line.startsWith(' remote: ')) { const remote = publicUrl(line.slice(10), ['rubygems.org'], 'Gemfile.lock'); - if (remote !== 'https://rubygems.org/') fail('UNSUPPORTED_SOURCE', 'Ruby acquisition supports the public RubyGems root only.', 'Gemfile.lock'); + if (remote !== 'https://rubygems.org/') + fail( + 'UNSUPPORTED_SOURCE', + 'Ruby acquisition supports the public RubyGems root only.', + 'Gemfile.lock', + ); sawPublicRemote = true; } else if (section === 'GEM' && /^ \S/.test(line)) { - const match = line.match(/^ ([A-Za-z0-9][A-Za-z0-9_.-]*) \(([0-9]+(?:\.[0-9A-Za-z]+)*)(?:-([A-Za-z0-9][A-Za-z0-9_.-]*))?\)$/); + const match = line.match( + /^ ([A-Za-z0-9][A-Za-z0-9_.-]*) \(([0-9]+(?:\.[0-9A-Za-z]+)*)(?:-([A-Za-z0-9][A-Za-z0-9_.-]*))?\)$/, + ); if (!match) fail('INVALID_METADATA', 'Invalid exact Ruby gem lock entry.', 'Gemfile.lock'); - plan.inputs.push({ kind: 'public', name: match[1], version: match[2], platform: match[3] || 'ruby', integritySource: 'registry-on-acquisition' }); + plan.inputs.push({ + kind: 'public', + name: match[1], + version: match[2], + platform: match[3] || 'ruby', + integritySource: 'registry-on-acquisition', + }); } else if (section === 'CHECKSUMS') { const match = line.match(/^ ([A-Za-z0-9][A-Za-z0-9_.-]*) \(([^)]+)\) sha256=([a-f0-9]{64})$/); if (!match) fail('INVALID_METADATA', 'Unsupported RubyGems checksum entry.', 'Gemfile.lock'); @@ -406,53 +971,132 @@ function inspectRails(root: string, plan: PreparationPlan) { plan.runtimeRequirements.ruby = match[1]; } else if (section === 'BUNDLED WITH') { const version = line.trim(); - if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) fail('UNSUPPORTED_RUNTIME', 'Bundler must be pinned to an exact version.', 'Gemfile.lock'); + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) + fail('UNSUPPORTED_RUNTIME', 'Bundler must be pinned to an exact version.', 'Gemfile.lock'); plan.runtimeRequirements.bundler = version; } } - if (!sawPublicRemote) fail('UNSUPPORTED_SOURCE', 'Gemfile.lock needs an explicit public RubyGems source.', 'Gemfile.lock'); - if (!plan.runtimeRequirements.bundler) fail('UNSUPPORTED_RUNTIME', 'Gemfile.lock must record BUNDLED WITH.', 'Gemfile.lock'); + if (!sawPublicRemote) + fail('UNSUPPORTED_SOURCE', 'Gemfile.lock needs an explicit public RubyGems source.', 'Gemfile.lock'); + if (!plan.runtimeRequirements.bundler) + fail('UNSUPPORTED_RUNTIME', 'Gemfile.lock must record BUNDLED WITH.', 'Gemfile.lock'); packageCount(plan.inputs, 'Gemfile.lock'); for (const input of plan.inputs) { const key = `${input.name}@${input.version}${input.platform === 'ruby' ? '' : `-${input.platform}`}`; const hash = checksums.get(key); - if (hash) { input.integrity = hash; input.integritySource = 'lock'; } + if (hash) { + input.integrity = hash; + input.integritySource = 'lock'; + } // gem fetch downloads without evaluating a Gemfile/gemspec or building extensions. - plan.acquisition.push(command('/usr/local/bin/gem', ['fetch', input.name, '--version', input.version, '--platform', input.platform!, '--clear-sources', '--source', 'https://rubygems.org', '--norc'], '/archives')); + plan.acquisition.push( + command( + '/usr/local/bin/gem', + [ + 'fetch', + input.name, + '--version', + input.version, + '--platform', + input.platform!, + '--clear-sources', + '--source', + 'https://rubygems.org', + '--norc', + ], + '/archives', + ), + ); } metadata(plan, 'Gemfile.lock', contents); - const env = { RAILS_ENV: 'test', RACK_ENV: 'test', SECRET_KEY_BASE: 'cso-synthetic-test-key-never-a-production-credential', BUNDLE_PATH: '/work/vendor/bundle', BUNDLE_FROZEN: 'true', BUNDLE_DEPLOYMENT: 'true', BUNDLE_DISABLE_SHARED_GEMS: 'true', BUNDLE_IGNORE_CONFIG: 'true', BUNDLE_ALLOW_OFFLINE_INSTALL: 'true', BUNDLE_CACHE_PATH: '/archives', BUNDLE_USER_HOME: '/work/.cso-bundle' }; - plan.offline.push(command('/usr/local/bin/bundle', ['install', '--local', '--jobs', '2', '--retry', '0'], '/work', env)); + const env = { + RAILS_ENV: 'test', + RACK_ENV: 'test', + SECRET_KEY_BASE: 'cso-synthetic-test-key-never-a-production-credential', + BUNDLE_PATH: '/work/vendor/bundle', + BUNDLE_FROZEN: 'true', + BUNDLE_DEPLOYMENT: 'true', + BUNDLE_DISABLE_SHARED_GEMS: 'true', + BUNDLE_IGNORE_CONFIG: 'true', + BUNDLE_ALLOW_OFFLINE_INSTALL: 'true', + BUNDLE_CACHE_PATH: '/archives', + BUNDLE_USER_HOME: '/work/.cso-bundle', + }; + plan.offline.push( + command('/usr/local/bin/bundle', ['install', '--local', '--jobs', '2', '--retry', '0'], '/work', env), + ); plan.registryHosts = ['rubygems.org', 'index.rubygems.org']; const declared = railsDatabaseConfiguration(read(root, 'config/database.yml', true)); const supported: Array<'sqlite' | 'postgresql'> = []; - if (plan.inputs.some(input => input.name === 'sqlite3')) supported.push('sqlite'); - if (plan.inputs.some(input => input.name === 'pg')) supported.push('postgresql'); - if (!supported.length) fail('MISSING_DATABASE_ADAPTER', 'Rails automatic preparation requires a locked sqlite3 or pg adapter.', 'Gemfile.lock'); - const declaredSupported = [...declared.adapters].filter(adapter => supported.includes(adapter)); - const selected = supported.length === 1 ? supported[0] : declaredSupported.length === 1 ? declaredSupported[0] : null; - plan.database = { supported, selected, connections: declared.connections, requiresSyntheticConfiguration: true }; + if (plan.inputs.some((input) => input.name === 'sqlite3')) supported.push('sqlite'); + if (plan.inputs.some((input) => input.name === 'pg')) supported.push('postgresql'); + if (!supported.length) + fail( + 'MISSING_DATABASE_ADAPTER', + 'Rails automatic preparation requires a locked sqlite3 or pg adapter.', + 'Gemfile.lock', + ); + const declaredSupported = [...declared.adapters].filter((adapter) => supported.includes(adapter)); + const selected = + supported.length === 1 ? supported[0] : declaredSupported.length === 1 ? declaredSupported[0] : null; + plan.database = { + supported, + selected, + connections: declared.connections, + requiresSyntheticConfiguration: true, + }; } -function railsDatabaseConfiguration(contents: string | undefined): { connections: string[]; adapters: Set<'sqlite' | 'postgresql'> } { +function railsDatabaseConfiguration(contents: string | undefined): { + connections: string[]; + adapters: Set<'sqlite' | 'postgresql'>; +} { if (contents === undefined) return { connections: ['primary'], adapters: new Set() }; - if (contents.includes('\t')) fail('DYNAMIC_DATABASE_CONFIG', 'Database configuration must use spaces for bounded inert parsing.', 'config/database.yml'); + if (contents.includes('\t')) + fail( + 'DYNAMIC_DATABASE_CONFIG', + 'Database configuration must use spaces for bounded inert parsing.', + 'config/database.yml', + ); // ERB is never evaluated. It may supply scalar values, but dynamic YAML structure is unsupported. let safe = contents.replace(/<%=[\s\S]*?%>/g, 'CSO_REDACTED_ERB'); - if (safe.includes('<%')) fail('DYNAMIC_DATABASE_CONFIG', 'Database configuration uses structural ERB; supply explicit synthetic connection names.', 'config/database.yml'); + if (safe.includes('<%')) + fail( + 'DYNAMIC_DATABASE_CONFIG', + 'Database configuration uses structural ERB; supply explicit synthetic connection names.', + 'config/database.yml', + ); // Stock Rails uses one inert `default` anchor. Remove only that exact merge // syntax before parsing so the YAML implementation never expands aliases. - safe = safe.split(/\r?\n/).map(line => { - if (/^default:\s*&default\s*(?:#.*)?$/.test(line)) return 'default:'; - if (/^\s+<<:\s*\*default\s*(?:#.*)?$/.test(line)) return line.replace(/<<:[\s\S]*$/, '# cso: inert default merge'); - return line; - }).join('\n'); + safe = safe + .split(/\r?\n/) + .map((line) => { + if (/^default:\s*&default\s*(?:#.*)?$/.test(line)) return 'default:'; + if (/^\s+<<:\s*\*default\s*(?:#.*)?$/.test(line)) + return line.replace(/<<:[\s\S]*$/, '# cso: inert default merge'); + return line; + }) + .join('\n'); if (/(^|[\s\[{,])(?:[&*][A-Za-z0-9_-]+|!\S+)/m.test(safe)) - fail('DYNAMIC_DATABASE_CONFIG', 'Only the stock Rails default anchor and merge are accepted by the trusted readiness parser.', 'config/database.yml'); + fail( + 'DYNAMIC_DATABASE_CONFIG', + 'Only the stock Rails default anchor and merge are accepted by the trusted readiness parser.', + 'config/database.yml', + ); let parsed: any; - try { parsed = Bun.YAML.parse(safe); } catch { fail('DYNAMIC_DATABASE_CONFIG', 'Database connection names could not be read without evaluating ERB.', 'config/database.yml'); } - if (!record(parsed)) fail('INVALID_DATABASE_CONFIG', 'Database configuration must be a mapping.', 'config/database.yml'); - const connections = new Set(), adapters = new Set<'sqlite' | 'postgresql'>(); + try { + parsed = Bun.YAML.parse(safe); + } catch { + fail( + 'DYNAMIC_DATABASE_CONFIG', + 'Database connection names could not be read without evaluating ERB.', + 'config/database.yml', + ); + } + if (!record(parsed)) + fail('INVALID_DATABASE_CONFIG', 'Database configuration must be a mapping.', 'config/database.yml'); + const connections = new Set(), + adapters = new Set<'sqlite' | 'postgresql'>(); const recordAdapter = (config: Record) => { if (config.adapter === 'sqlite3') adapters.add('sqlite'); if (config.adapter === 'postgresql' || config.adapter === 'postgres') adapters.add('postgresql'); @@ -461,9 +1105,17 @@ function railsDatabaseConfiguration(contents: string | undefined): { connections if (!record(config)) continue; recordAdapter(config); if (environment === 'default') continue; - if ('adapter' in config || 'url' in config || 'database' in config) { connections.add('primary'); continue; } + if ('adapter' in config || 'url' in config || 'database' in config) { + connections.add('primary'); + continue; + } for (const [name, connection] of Object.entries(config)) { - if (!connectionName(name) || name.includes('CSO_REDACTED_ERB') || !record(connection)) fail('DYNAMIC_DATABASE_CONFIG', 'Database connection names must be static identifiers.', 'config/database.yml'); + if (!connectionName(name) || name.includes('CSO_REDACTED_ERB') || !record(connection)) + fail( + 'DYNAMIC_DATABASE_CONFIG', + 'Database connection names must be static identifiers.', + 'config/database.yml', + ); recordAdapter(connection); connections.add(name); } @@ -472,41 +1124,100 @@ function railsDatabaseConfiguration(contents: string | undefined): { connections } /** Synthetic files are declared execution transformations; a boundary-changing target is blocked by the runner. */ -export function railsTestConfiguration(connections: string[], adapter: 'sqlite' | 'postgresql'): Array<{ path: string; content: string }> { - if (!connections.length || connections.some(name => !connectionName(name))) throw new Error('Invalid Rails connection names'); +export function railsTestConfiguration( + connections: string[], + adapter: 'sqlite' | 'postgresql', +): Array<{ path: string; content: string }> { + if (!connections.length || connections.some((name) => !connectionName(name))) + throw new Error('Invalid Rails connection names'); const database: Record = { test: {} }; - for (const name of connections) database.test[name] = adapter === 'sqlite' - ? { adapter: 'sqlite3', database: `/work/tmp/cso-${name}.sqlite3`, pool: 3 } - : { adapter: 'postgresql', host: '127.0.0.1', port: 5432, username: 'cso', password: 'cso-disposable-test', database: `cso_${name}`, pool: 3 }; + for (const name of connections) + database.test[name] = + adapter === 'sqlite' + ? { adapter: 'sqlite3', database: `/work/tmp/cso-${name}.sqlite3`, pool: 3 } + : { + adapter: 'postgresql', + host: '127.0.0.1', + port: 5432, + username: 'cso', + password: 'cso-disposable-test', + database: `cso_${name}`, + pool: 3, + }; return [ // JSON is valid YAML; no interpolation, anchors, inherited URLs, or production connections. { path: 'config/database.yml', content: JSON.stringify(database, null, 2) + '\n' }, - { path: 'config/initializers/zzzz_cso_test.rb', content: `# Trusted synthetic test environment; recorded in the transformation manifest.\nraise "CSO requires test environment" unless Rails.env.test?\nRails.application.config.secret_key_base = ENV.fetch("SECRET_KEY_BASE")\nRails.application.config.active_storage.service = :cso_test if defined?(ActiveStorage)\nRails.application.config.active_job.queue_adapter = :test if defined?(ActiveJob)\nRails.application.config.action_mailer.delivery_method = :test if defined?(ActionMailer)\nRails.application.config.action_mailer.perform_deliveries = false if defined?(ActionMailer)\nRails.application.config.after_initialize do\n ActiveJob::Base.queue_adapter = :test if defined?(ActiveJob::Base)\n ActionMailer::Base.delivery_method = :test if defined?(ActionMailer::Base)\nend\n` }, - { path: 'config/storage.yml', content: JSON.stringify({ cso_test: { service: 'Disk', root: '/work/tmp/cso-storage' } }, null, 2) + '\n' }, + { + path: 'config/initializers/zzzz_cso_test.rb', + content: `# Trusted synthetic test environment; recorded in the transformation manifest.\nraise "CSO requires test environment" unless Rails.env.test?\nRails.application.config.secret_key_base = ENV.fetch("SECRET_KEY_BASE")\nRails.application.config.active_storage.service = :cso_test if defined?(ActiveStorage)\nRails.application.config.active_job.queue_adapter = :test if defined?(ActiveJob)\nRails.application.config.action_mailer.delivery_method = :test if defined?(ActionMailer)\nRails.application.config.action_mailer.perform_deliveries = false if defined?(ActionMailer)\nRails.application.config.after_initialize do\n ActiveJob::Base.queue_adapter = :test if defined?(ActiveJob::Base)\n ActionMailer::Base.delivery_method = :test if defined?(ActionMailer::Base)\nend\n`, + }, + { + path: 'config/storage.yml', + content: + JSON.stringify({ cso_test: { service: 'Disk', root: '/work/tmp/cso-storage' } }, null, 2) + '\n', + }, ]; } export function inspectPreparation(snapshotPath: string, stack?: CsoStack): PreparationPlan { const root = resolve(snapshotPath); - const plan: PreparationPlan = { schemaVersion: 1, stack: stack ?? 'python', status: 'ready', prerequisites: [], metadata: [], inputs: [], acquisition: [], offline: [], registryHosts: [], runtimeProfile: stack ?? 'python', runtimeRequirements: {}, transformations: [] }; + const plan: PreparationPlan = { + schemaVersion: 1, + stack: stack ?? 'python', + status: 'ready', + prerequisites: [], + metadata: [], + inputs: [], + acquisition: [], + offline: [], + registryHosts: [], + runtimeProfile: stack ?? 'python', + runtimeRequirements: {}, + transformations: [], + }; try { - const detectedStacks:CsoStack[]=[]; - const hasBun=read(root,'bun.lock',true)!==undefined||read(root,'bun.lockb',true)!==undefined;if(hasBun)detectedStacks.push('bun'); - if(read(root,'package-lock.json',true)!==undefined||read(root,'npm-shrinkwrap.json',true)!==undefined||(!hasBun&&read(root,'package.json',true)!==undefined))detectedStacks.push('node'); - if(read(root,'Gemfile.lock',true)!==undefined||read(root,'Gemfile',true)!==undefined)detectedStacks.push('rails'); - if(read(root,'uv.lock',true)!==undefined||read(root,'requirements.txt',true)!==undefined||read(root,'pyproject.toml',true)!==undefined)detectedStacks.push('python'); - if(!stack&&detectedStacks.length>1)fail('MULTIPLE_STACKS',`Multiple executable stacks were detected (${detectedStacks.join(', ')}); verification must select a matching qualified runtime.`); + const detectedStacks: CsoStack[] = []; + const hasBun = read(root, 'bun.lock', true) !== undefined || read(root, 'bun.lockb', true) !== undefined; + if (hasBun) detectedStacks.push('bun'); + if ( + read(root, 'package-lock.json', true) !== undefined || + read(root, 'npm-shrinkwrap.json', true) !== undefined || + (!hasBun && read(root, 'package.json', true) !== undefined) + ) + detectedStacks.push('node'); + if (read(root, 'Gemfile.lock', true) !== undefined || read(root, 'Gemfile', true) !== undefined) + detectedStacks.push('rails'); + if ( + read(root, 'uv.lock', true) !== undefined || + read(root, 'requirements.txt', true) !== undefined || + read(root, 'pyproject.toml', true) !== undefined + ) + detectedStacks.push('python'); + if (!stack && detectedStacks.length > 1) + fail( + 'MULTIPLE_STACKS', + `Multiple executable stacks were detected (${detectedStacks.join(', ')}); verification must select a matching qualified runtime.`, + ); const detected = stack ?? detectedStacks[0] ?? 'python'; - plan.stack = detected; plan.runtimeProfile = detected; - if (!['node', 'bun', 'python', 'rails'].includes(detected)) fail('UNSUPPORTED_STACK', 'Supported runtime stacks are Node, Bun, Python, and Rails.'); - ({ node: inspectNode, bun: inspectBun, python: inspectPython, rails: inspectRails }[detected])(root, plan); + plan.stack = detected; + plan.runtimeProfile = detected; + if (!['node', 'bun', 'python', 'rails'].includes(detected)) + fail('UNSUPPORTED_STACK', 'Supported runtime stacks are Node, Bun, Python, and Rails.'); + ({ node: inspectNode, bun: inspectBun, python: inspectPython, rails: inspectRails })[detected]( + root, + plan, + ); } catch (error) { plan.status = 'prerequisites'; - plan.prerequisites.push(error instanceof MetadataError - ? { code: error.code, message: error.message, path: error.path } - : { code: 'INVALID_METADATA', message: 'Dependency metadata could not be inspected safely.' }); + plan.prerequisites.push( + error instanceof MetadataError + ? { code: error.code, message: error.message, path: error.path } + : { code: 'INVALID_METADATA', message: 'Dependency metadata could not be inspected safely.' }, + ); // Never execute a partially validated acquisition plan. - plan.acquisition = []; plan.offline = []; plan.metadata = []; + plan.acquisition = []; + plan.offline = []; + plan.metadata = []; } return plan; } diff --git a/lib/cso/process.ts b/lib/cso/process.ts index ff488ed74..6ba3cc648 100644 --- a/lib/cso/process.ts +++ b/lib/cso/process.ts @@ -1,218 +1,601 @@ import { spawn } from 'node:child_process'; -import { accessSync, closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs'; +import { + accessSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, + statSync, +} from 'node:fs'; import { basename, dirname, join, isAbsolute, delimiter, resolve } from 'node:path'; import { redactFindingSpans } from '../redact-engine'; import { CsoError, MAX_OUTPUT } from './contracts'; -const SOURCE_RUNTIME=/^bun(?:\.exe)?$/i.test(basename(process.execPath)); -const WINDOWS_GIT=process.platform==='win32'?(process.env.GSTACK_CSO_TRUSTED_GIT||(SOURCE_RUNTIME?Bun.which('git')??'':'')):''; -const WINDOWS_SYSTEM=process.platform==='win32'?join(process.env.SystemRoot||'C:\\Windows','System32'):''; -export const TRUSTED_DIRECTORIES = process.platform === 'win32' - ? [...new Set([WINDOWS_GIT?dirname(WINDOWS_GIT):'',WINDOWS_SYSTEM].filter(Boolean))] - : ['/usr/local/bin','/usr/bin','/bin','/opt/homebrew/bin','/usr/local/sbin','/usr/sbin','/sbin']; +const SOURCE_RUNTIME = /^bun(?:\.exe)?$/i.test(basename(process.execPath)); +const WINDOWS_GIT = + process.platform === 'win32' + ? process.env.GSTACK_CSO_TRUSTED_GIT || (SOURCE_RUNTIME ? (Bun.which('git') ?? '') : '') + : ''; +const WINDOWS_SYSTEM = + process.platform === 'win32' ? join(process.env.SystemRoot || 'C:\\Windows', 'System32') : ''; +export const TRUSTED_DIRECTORIES = + process.platform === 'win32' + ? [...new Set([WINDOWS_GIT ? dirname(WINDOWS_GIT) : '', WINDOWS_SYSTEM].filter(Boolean))] + : ['/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin', '/usr/local/sbin', '/usr/sbin', '/sbin']; export const TRUSTED_PATH = TRUSTED_DIRECTORIES.join(delimiter); export function executable(name: string): string { // Never consult the audited repository's PATH or executable overrides. - if (!/^[a-zA-Z0-9._-]+$/.test(name)) throw new CsoError('INVALID_ARGUMENT','Invalid executable name'); - if(process.platform==='win32'&&name.toLowerCase()==='git'){ - try{if(!WINDOWS_GIT||!isAbsolute(WINDOWS_GIT)||basename(WINDOWS_GIT).toLowerCase()!=='git.exe')throw new Error();const stat=statSync(WINDOWS_GIT);if(!stat.isFile())throw new Error();return realpathSync(WINDOWS_GIT);}catch{throw new CsoError('TOOL_UNAVAILABLE','git.exe is not the trusted executable bound during gstack setup');} + if (!/^[a-zA-Z0-9._-]+$/.test(name)) throw new CsoError('INVALID_ARGUMENT', 'Invalid executable name'); + if (process.platform === 'win32' && name.toLowerCase() === 'git') { + try { + if (!WINDOWS_GIT || !isAbsolute(WINDOWS_GIT) || basename(WINDOWS_GIT).toLowerCase() !== 'git.exe') + throw new Error(); + const stat = statSync(WINDOWS_GIT); + if (!stat.isFile()) throw new Error(); + return realpathSync(WINDOWS_GIT); + } catch { + throw new CsoError( + 'TOOL_UNAVAILABLE', + 'git.exe is not the trusted executable bound during gstack setup', + ); + } } for (const directory of TRUSTED_DIRECTORIES) { - const candidates=process.platform==='win32'?[join(directory,`${name}.exe`),join(directory,`${name}.cmd`),join(directory,name)]:[join(directory,name)]; - for(const p of candidates){ - try { const stat=statSync(p);accessSync(p,constants.X_OK);if(stat.isFile()&&(process.platform==='win32'||(stat.mode&0o111)))return realpathSync(p); } catch {} + const candidates = + process.platform === 'win32' + ? [join(directory, `${name}.exe`), join(directory, `${name}.cmd`), join(directory, name)] + : [join(directory, name)]; + for (const p of candidates) { + try { + const stat = statSync(p); + accessSync(p, constants.X_OK); + if (stat.isFile() && (process.platform === 'win32' || stat.mode & 0o111)) return realpathSync(p); + } catch {} } } throw new CsoError('TOOL_UNAVAILABLE', `${name} is not installed in a trusted system executable directory`); } -export function childEnvironment(home: string): Record { - return { PATH: TRUSTED_PATH, HOME: home, LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC', - GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform==='win32'?'NUL':'/dev/null', GIT_TERMINAL_PROMPT: '0', - GIT_OPTIONAL_LOCKS: '0', GIT_ATTR_NOSYSTEM: '1' }; +export function childEnvironment(home: string): Record { + return { + PATH: TRUSTED_PATH, + HOME: home, + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + TZ: 'UTC', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', + GIT_TERMINAL_PROMPT: '0', + GIT_OPTIONAL_LOCKS: '0', + GIT_ATTR_NOSYSTEM: '1', + }; } export function redact(value: string): string { // Scan the complete bounded stream, including across write/chunk boundaries. const output = redactFindingSpans(value, { maxBytes: MAX_OUTPUT }); - if (output === null) throw new CsoError('REDACTION_FAILED','Payload withheld because redaction could not safely locate every secret'); + if (output === null) + throw new CsoError( + 'REDACTION_FAILED', + 'Payload withheld because redaction could not safely locate every secret', + ); return output; } -const HASH_KEYS=new Set(['planSha256','planHash','originalHash','executionHash','snapshotHash','sourceHash','beforeSha256','afterSha256','patchHash','reviewedPatchHash','harnessHash','fixturesHash','policyHash','auditPolicyHash','originalSourceHash','transformationsHash','archivesHash','inputHash','beforeSourceHash','afterSourceHash','beforeDependencies','afterDependencies','beforeConfiguration','afterConfiguration','requestHash','startPlanHash','testPlanHash','preparationHash','preparedManifestHash','preparedDependencyHash','sourceProjectionHash','executionEnvironmentHash','databaseHash','receiptHash','dependencyClosureHash','closureHash','acquisitionReceiptHash','registryResponseSha256','sha256','versionOutputSha256','isolationPolicyHash','contentSha256','sbomDigest','provenanceDigest','dependencyHash','configurationHash','assertionHash','commandsHash','minimumPassingTestsHash','commandHash','outputHash','observationHash','witnessHash','keyId']); -function safeMetadata(value:string,key:string):boolean{ - if(HASH_KEYS.has(key)&&/^[a-f0-9]{64}$/.test(value))return true; - if(['id','fingerprint','findingId','verificationId','reproductionAttemptId','artifactId','reviewArtifactId','bundleId','pathId'].includes(key)&&/^[a-f0-9]{32}$/.test(value))return true; - if(key==='path'&&/^@cso-path\/\/[a-f0-9]{32}$/.test(value))return true; - if(key==='repoId'&&/^[a-f0-9]{24}$/.test(value))return true; - if(key==='runId'&&/^\d{13}-[a-f0-9]{16}$/.test(value))return true; - if(key==='replayId'&&/^\d{13}-[a-f0-9]{16}$/.test(value))return true; - if(['baseCommit','headCommit'].includes(key)&&/^[a-f0-9]{40,64}$/.test(value))return true; - if(['createdAt','expiresAt','deadline','at','databaseUpdatedAt','qualifiedAt'].includes(key)&&/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value))return true; - if(key==='nonce'&&/^[a-f0-9]{64}$/.test(value))return true; - if(key==='publicKey'&&/^[a-f0-9]{88}$/.test(value))return true; - if(key==='signature'&&/^[a-f0-9]{128}$/.test(value))return true; - if(key==='image'&&/^[a-z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(value))return true; - if(key==='integrity'&&/^(?:sha256|sha512)-[A-Za-z0-9+/]+={0,2}$/.test(value))return true; +const HASH_KEYS = new Set([ + 'planSha256', + 'planHash', + 'originalHash', + 'executionHash', + 'snapshotHash', + 'sourceHash', + 'beforeSha256', + 'afterSha256', + 'patchHash', + 'reviewedPatchHash', + 'harnessHash', + 'fixturesHash', + 'policyHash', + 'auditPolicyHash', + 'originalSourceHash', + 'transformationsHash', + 'archivesHash', + 'inputHash', + 'beforeSourceHash', + 'afterSourceHash', + 'beforeDependencies', + 'afterDependencies', + 'beforeConfiguration', + 'afterConfiguration', + 'requestHash', + 'startPlanHash', + 'testPlanHash', + 'preparationHash', + 'preparedManifestHash', + 'preparedDependencyHash', + 'sourceProjectionHash', + 'executionEnvironmentHash', + 'databaseHash', + 'receiptHash', + 'dependencyClosureHash', + 'closureHash', + 'acquisitionReceiptHash', + 'registryResponseSha256', + 'sha256', + 'versionOutputSha256', + 'isolationPolicyHash', + 'contentSha256', + 'sbomDigest', + 'provenanceDigest', + 'dependencyHash', + 'configurationHash', + 'assertionHash', + 'commandsHash', + 'minimumPassingTestsHash', + 'commandHash', + 'outputHash', + 'observationHash', + 'witnessHash', + 'keyId', +]); +function safeMetadata(value: string, key: string): boolean { + if (HASH_KEYS.has(key) && /^[a-f0-9]{64}$/.test(value)) return true; + if ( + [ + 'id', + 'fingerprint', + 'findingId', + 'verificationId', + 'reproductionAttemptId', + 'artifactId', + 'reviewArtifactId', + 'bundleId', + 'pathId', + ].includes(key) && + /^[a-f0-9]{32}$/.test(value) + ) + return true; + if (key === 'path' && /^@cso-path\/\/[a-f0-9]{32}$/.test(value)) return true; + if (key === 'repoId' && /^[a-f0-9]{24}$/.test(value)) return true; + if (key === 'runId' && /^\d{13}-[a-f0-9]{16}$/.test(value)) return true; + if (key === 'replayId' && /^\d{13}-[a-f0-9]{16}$/.test(value)) return true; + if (['baseCommit', 'headCommit'].includes(key) && /^[a-f0-9]{40,64}$/.test(value)) return true; + if ( + ['createdAt', 'expiresAt', 'deadline', 'at', 'databaseUpdatedAt', 'qualifiedAt'].includes(key) && + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value) + ) + return true; + if (key === 'nonce' && /^[a-f0-9]{64}$/.test(value)) return true; + if (key === 'publicKey' && /^[a-f0-9]{88}$/.test(value)) return true; + if (key === 'signature' && /^[a-f0-9]{128}$/.test(value)) return true; + if (key === 'image' && /^[a-z0-9./:_-]+@sha256:[a-f0-9]{64}$/.test(value)) return true; + if (key === 'integrity' && /^(?:sha256|sha512)-[A-Za-z0-9+/]+={0,2}$/.test(value)) return true; return false; } -function sanitizeJson(value:unknown,key:string,seen:WeakSet,trustedMetadata:boolean):unknown{ - if(typeof value==='string'){ - if(trustedMetadata&&safeMetadata(value,key))return value; +function sanitizeJson(value: unknown, key: string, seen: WeakSet, trustedMetadata: boolean): unknown { + if (typeof value === 'string') { + if (trustedMetadata && safeMetadata(value, key)) return value; return redact(value); } - if(value===null||typeof value!=='object')return value; - if(seen.has(value as object))throw new CsoError('INVALID_SCHEMA','Cyclic JSON cannot be persisted');seen.add(value as object); - if(Array.isArray(value)){const out=value.map(v=>sanitizeJson(v,key,seen,trustedMetadata));seen.delete(value);return out;} - const out:Record=Object.create(null);for(const [k,v] of Object.entries(value as Record)){ - if(['__proto__','prototype','constructor'].includes(k))throw new CsoError('INVALID_SCHEMA','Unsafe JSON property');out[k]=sanitizeJson(v,k,seen,trustedMetadata); - }seen.delete(value as object);return out; + if (value === null || typeof value !== 'object') return value; + if (seen.has(value as object)) throw new CsoError('INVALID_SCHEMA', 'Cyclic JSON cannot be persisted'); + seen.add(value as object); + if (Array.isArray(value)) { + const out = value.map((v) => sanitizeJson(v, key, seen, trustedMetadata)); + seen.delete(value); + return out; + } + const out: Record = Object.create(null); + for (const [k, v] of Object.entries(value as Record)) { + if (['__proto__', 'prototype', 'constructor'].includes(k)) + throw new CsoError('INVALID_SCHEMA', 'Unsafe JSON property'); + out[k] = sanitizeJson(v, k, seen, trustedMetadata); + } + seen.delete(value as object); + return out; } /** Redact untrusted JSON content. Key names never make an untrusted value exempt. */ -export function sanitizeForJson(value:unknown):unknown{return sanitizeJson(value,'',new WeakSet(),false);} +export function sanitizeForJson(value: unknown): unknown { + return sanitizeJson(value, '', new WeakSet(), false); +} /** Preserve only validated helper identifiers/hashes while redacting all content-bearing fields. */ -export function sanitizeHelperForJson(value:unknown):unknown{return sanitizeJson(value,'',new WeakSet(),true);} -export interface ProcessResult { code: number; stdout: string; stderr: string; timedOut: boolean; truncated: boolean; capturedBytes:number } -interface GitConfigIdentity { path:string; exists:boolean; dev?:number; ino?:number; mode?:number; size?:number; mtimeMs?:number; ctimeMs?:number; content?:string } -const GIT_CONFIG_LIMIT=1024*1024; -interface BoundedMetadataFile { dev:number;ino:number;mode:number;nlink:number;size:number;mtimeMs:number;ctimeMs:number;content:string } -function sameMetadataFile(left:BoundedMetadataFile|ReturnType,right:BoundedMetadataFile|ReturnType):boolean{ - return left.dev===right.dev&&left.ino===right.ino&&left.mode===right.mode&&left.nlink===right.nlink&&left.size===right.size&&left.mtimeMs===right.mtimeMs&&left.ctimeMs===right.ctimeMs; +export function sanitizeHelperForJson(value: unknown): unknown { + return sanitizeJson(value, '', new WeakSet(), true); } -function boundedMetadataFile(path:string,maxBytes:number,label:string,optional=false):BoundedMetadataFile|undefined{ - let before:ReturnType; - try{before=lstatSync(path);}catch(error:any){if(optional&&error?.code==='ENOENT')return;throw new CsoError(error?.code==='ENOENT'?'SNAPSHOT_RACE':'UNSAFE_PATH',`${label} is not a bounded regular file`);} - if(before.isSymbolicLink()||!before.isFile()||before.nlink!==1||before.size>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} is not a bounded regular file`); - let fd:number|undefined; - try{ - fd=openSync(path,constants.O_RDONLY|(constants.O_NOFOLLOW??0)|(constants.O_NONBLOCK??0)); - const opened=fstatSync(fd); - if(!opened.isFile()||opened.nlink!==1||opened.size>maxBytes||!sameMetadataFile(before,opened))throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was opened`); - const buffer=Buffer.alloc(Math.min(maxBytes+1,opened.size+1));let bytes=0,count=0; - while(bytes0)bytes+=count; - const final=fstatSync(fd),after=lstatSync(path); - if(bytes!==opened.size||!final.isFile()||!after.isFile()||after.isSymbolicLink()||!sameMetadataFile(opened,final)||!sameMetadataFile(opened,after)) - throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was read`); - return{dev:opened.dev,ino:opened.ino,mode:opened.mode,nlink:opened.nlink,size:opened.size,mtimeMs:opened.mtimeMs,ctimeMs:opened.ctimeMs,content:buffer.subarray(0,bytes).toString('utf8')}; - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(['ENOENT','ELOOP','ENXIO'].includes(error?.code))throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was opened`); - throw new CsoError('UNSAFE_PATH',`${label} could not be read safely`); - }finally{if(fd!==undefined)try{closeSync(fd);}catch{}} +export interface ProcessResult { + code: number; + stdout: string; + stderr: string; + timedOut: boolean; + truncated: boolean; + capturedBytes: number; } -function boundedConfig(path:string):GitConfigIdentity{ - const file=boundedMetadataFile(path,GIT_CONFIG_LIMIT,'Repository Git configuration',true); - if(!file)return{path,exists:false}; - const {content}=file; +interface GitConfigIdentity { + path: string; + exists: boolean; + dev?: number; + ino?: number; + mode?: number; + size?: number; + mtimeMs?: number; + ctimeMs?: number; + content?: string; +} +const GIT_CONFIG_LIMIT = 1024 * 1024; +interface BoundedMetadataFile { + dev: number; + ino: number; + mode: number; + nlink: number; + size: number; + mtimeMs: number; + ctimeMs: number; + content: string; +} +function sameMetadataFile( + left: BoundedMetadataFile | ReturnType, + right: BoundedMetadataFile | ReturnType, +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); +} +function boundedMetadataFile( + path: string, + maxBytes: number, + label: string, + optional = false, +): BoundedMetadataFile | undefined { + let before: ReturnType; + try { + before = lstatSync(path); + } catch (error: any) { + if (optional && error?.code === 'ENOENT') return; + throw new CsoError( + error?.code === 'ENOENT' ? 'SNAPSHOT_RACE' : 'UNSAFE_PATH', + `${label} is not a bounded regular file`, + ); + } + if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 || before.size > maxBytes) + throw new CsoError('UNSAFE_PATH', `${label} is not a bounded regular file`); + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + const opened = fstatSync(fd); + if (!opened.isFile() || opened.nlink !== 1 || opened.size > maxBytes || !sameMetadataFile(before, opened)) + throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was opened`); + const buffer = Buffer.alloc(Math.min(maxBytes + 1, opened.size + 1)); + let bytes = 0, + count = 0; + while (bytes < buffer.length && (count = readSync(fd, buffer, bytes, buffer.length - bytes, null)) > 0) + bytes += count; + const final = fstatSync(fd), + after = lstatSync(path); + if ( + bytes !== opened.size || + !final.isFile() || + !after.isFile() || + after.isSymbolicLink() || + !sameMetadataFile(opened, final) || + !sameMetadataFile(opened, after) + ) + throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was read`); + return { + dev: opened.dev, + ino: opened.ino, + mode: opened.mode, + nlink: opened.nlink, + size: opened.size, + mtimeMs: opened.mtimeMs, + ctimeMs: opened.ctimeMs, + content: buffer.subarray(0, bytes).toString('utf8'), + }; + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (['ENOENT', 'ELOOP', 'ENXIO'].includes(error?.code)) + throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was opened`); + throw new CsoError('UNSAFE_PATH', `${label} could not be read safely`); + } finally { + if (fd !== undefined) + try { + closeSync(fd); + } catch {} + } +} +function boundedConfig(path: string): GitConfigIdentity { + const file = boundedMetadataFile(path, GIT_CONFIG_LIMIT, 'Repository Git configuration', true); + if (!file) return { path, exists: false }; + const { content } = file; // There is no process-wide "--no-includes" switch for ordinary Git // commands. Reject include directives before spawning Git so repository // configuration cannot pull policy or executable settings from elsewhere. - if(/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(content))throw new CsoError('UNSAFE_PATH','Repository Git config includes are not allowed during a security snapshot'); - return{path,exists:true,dev:file.dev,ino:file.ino,mode:file.mode,size:file.size,mtimeMs:file.mtimeMs,ctimeMs:file.ctimeMs,content}; + if (/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(content)) + throw new CsoError( + 'UNSAFE_PATH', + 'Repository Git config includes are not allowed during a security snapshot', + ); + return { + path, + exists: true, + dev: file.dev, + ino: file.ino, + mode: file.mode, + size: file.size, + mtimeMs: file.mtimeMs, + ctimeMs: file.ctimeMs, + content, + }; } -function gitDirectories(repo:string):{gitDir:string;commonDir:string}{ - const marker=join(repo,'.git'),stat=lstatSync(marker);let gitDir:string; - if(stat.isDirectory()&&!stat.isSymbolicLink())gitDir=realpathSync(marker); - else if(stat.isFile()&&!stat.isSymbolicLink()&&stat.nlink===1&&stat.size<=8192){ - const value=boundedMetadataFile(marker,8192,'Repository .git pointer')!.content,match=value.match(/^gitdir:\s*(.+?)\s*$/); - if(!match||value.includes('\0')||value.split(/\r?\n/).filter(Boolean).length!==1)throw new CsoError('UNSAFE_PATH','Repository .git pointer is invalid'); - gitDir=realpathSync(resolve(dirname(marker),match[1])); - }else throw new CsoError('UNSAFE_PATH','Repository .git metadata is not a regular directory or worktree pointer'); - const commonMarker=join(gitDir,'commondir'),commonFile=boundedMetadataFile(commonMarker,8192,'Repository common Git directory pointer',true);let commonDir=gitDir; - if(commonFile){ - const value=commonFile.content.trim(); - if(!value||value.includes('\0')||value.includes('\n')||value.includes('\r'))throw new CsoError('UNSAFE_PATH','Repository common Git directory pointer is invalid'); - commonDir=realpathSync(resolve(gitDir,value)); +function gitDirectories(repo: string): { gitDir: string; commonDir: string } { + const marker = join(repo, '.git'), + stat = lstatSync(marker); + let gitDir: string; + if (stat.isDirectory() && !stat.isSymbolicLink()) gitDir = realpathSync(marker); + else if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size <= 8192) { + const value = boundedMetadataFile(marker, 8192, 'Repository .git pointer')!.content, + match = value.match(/^gitdir:\s*(.+?)\s*$/); + if (!match || value.includes('\0') || value.split(/\r?\n/).filter(Boolean).length !== 1) + throw new CsoError('UNSAFE_PATH', 'Repository .git pointer is invalid'); + gitDir = realpathSync(resolve(dirname(marker), match[1])); + } else + throw new CsoError( + 'UNSAFE_PATH', + 'Repository .git metadata is not a regular directory or worktree pointer', + ); + const commonMarker = join(gitDir, 'commondir'), + commonFile = boundedMetadataFile(commonMarker, 8192, 'Repository common Git directory pointer', true); + let commonDir = gitDir; + if (commonFile) { + const value = commonFile.content.trim(); + if (!value || value.includes('\0') || value.includes('\n') || value.includes('\r')) + throw new CsoError('UNSAFE_PATH', 'Repository common Git directory pointer is invalid'); + commonDir = realpathSync(resolve(gitDir, value)); } - return{gitDir,commonDir}; + return { gitDir, commonDir }; } -function gitConfigIdentities(repo:string):GitConfigIdentity[]{ - const {gitDir,commonDir}=gitDirectories(repo); +function gitConfigIdentities(repo: string): GitConfigIdentity[] { + const { gitDir, commonDir } = gitDirectories(repo); // extensions.worktreeConfig makes config.worktree active in both linked and // main worktrees. Bind even its absence so it cannot appear after inspection // and feed Git an unchecked include or executable setting. - return [join(commonDir,'config'),join(gitDir,'config.worktree')].map(boundedConfig); + return [join(commonDir, 'config'), join(gitDir, 'config.worktree')].map(boundedConfig); } -function assertGitConfigIdentities(expected:GitConfigIdentity[]):void{ - for(const item of expected){ - const current=boundedConfig(item.path); - if(current.exists!==item.exists||current.dev!==item.dev||current.ino!==item.ino||current.mode!==item.mode||current.size!==item.size||current.mtimeMs!==item.mtimeMs||current.ctimeMs!==item.ctimeMs||current.content!==item.content) - throw new CsoError('SNAPSHOT_RACE','Repository Git configuration changed during a metadata operation'); +function assertGitConfigIdentities(expected: GitConfigIdentity[]): void { + for (const item of expected) { + const current = boundedConfig(item.path); + if ( + current.exists !== item.exists || + current.dev !== item.dev || + current.ino !== item.ino || + current.mode !== item.mode || + current.size !== item.size || + current.mtimeMs !== item.mtimeMs || + current.ctimeMs !== item.ctimeMs || + current.content !== item.content + ) + throw new CsoError('SNAPSHOT_RACE', 'Repository Git configuration changed during a metadata operation'); } } -function hardenGit(file:string,args:string[]):{args:string[];configs?:GitConfigIdentity[]}{ - if(!/^(?:git|git\.exe)$/i.test(basename(file)))return{args}; - let trusted:string;try{trusted=executable('git');}catch{return{args};} - if(realpathSync(file)!==trusted)return{args}; - const positions=args.flatMap((value,index)=>value==='-C'?[index]:[]); - if(positions.length!==1||positions[0]+1>=args.length)throw new CsoError('INVALID_ARGUMENT','CSO Git operations require exactly one audited working directory'); - const position=positions[0],requested=args[position+1]; - if(!isAbsolute(requested))throw new CsoError('INVALID_ARGUMENT','CSO Git operations require an absolute audited working directory'); - const repo=realpathSync(requested),stat=statSync(repo); - if(!stat.isDirectory())throw new CsoError('MISSING_INPUT','Audited Git working directory is not a directory'); - const configs=gitConfigIdentities(repo),nullPath=process.platform==='win32'?'NUL':'/dev/null', +function hardenGit(file: string, args: string[]): { args: string[]; configs?: GitConfigIdentity[] } { + if (!/^(?:git|git\.exe)$/i.test(basename(file))) return { args }; + let trusted: string; + try { + trusted = executable('git'); + } catch { + return { args }; + } + if (realpathSync(file) !== trusted) return { args }; + const positions = args.flatMap((value, index) => (value === '-C' ? [index] : [])); + if (positions.length !== 1 || positions[0] + 1 >= args.length) + throw new CsoError( + 'INVALID_ARGUMENT', + 'CSO Git operations require exactly one audited working directory', + ); + const position = positions[0], + requested = args[position + 1]; + if (!isAbsolute(requested)) + throw new CsoError( + 'INVALID_ARGUMENT', + 'CSO Git operations require an absolute audited working directory', + ); + const repo = realpathSync(requested), + stat = statSync(repo); + if (!stat.isDirectory()) + throw new CsoError('MISSING_INPUT', 'Audited Git working directory is not a directory'); + const configs = gitConfigIdentities(repo), + nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null', // Git for Windows accepts NUL for ordinary file-valued settings, but its // config include machinery treats NUL as a failing include. Its MSYS path // layer maps /dev/null correctly for this one directive. - includeNullPath=process.platform==='win32'?'/dev/null':nullPath; - const prefix=args.slice(0,position),command=args.slice(position+2); - return{configs,args:[...prefix, - '--no-replace-objects', - '-c','core.fsmonitor=false','-c',`core.hooksPath=${nullPath}`,'-c',`core.attributesFile=${nullPath}`, - '-c',`core.excludesFile=${nullPath}`,'-c','core.ignoreCase=false','-c','core.precomposeUnicode=false', - '-c','core.untrackedCache=false','-c',`include.path=${includeNullPath}`,'-c','core.pager=cat', - '-C',repo,`--work-tree=${repo}`,...command]}; + includeNullPath = process.platform === 'win32' ? '/dev/null' : nullPath; + const prefix = args.slice(0, position), + command = args.slice(position + 2); + return { + configs, + args: [ + ...prefix, + '--no-replace-objects', + '-c', + 'core.fsmonitor=false', + '-c', + `core.hooksPath=${nullPath}`, + '-c', + `core.attributesFile=${nullPath}`, + '-c', + `core.excludesFile=${nullPath}`, + '-c', + 'core.ignoreCase=false', + '-c', + 'core.precomposeUnicode=false', + '-c', + 'core.untrackedCache=false', + '-c', + `include.path=${includeNullPath}`, + '-c', + 'core.pager=cat', + '-C', + repo, + `--work-tree=${repo}`, + ...command, + ], + }; } -export async function runProcess(file: string, args: string[], opts: { - cwd: string; env: Record; timeoutMs?: number; maxBytes?: number; input?: string; - raw?: boolean; // Only for inert Git framing or private helper/Docker control JSON that is validated before use. Never print or persist raw results. -}): Promise { - if (!isAbsolute(file) || !isAbsolute(opts.cwd) || !existsSync(opts.cwd)) throw new CsoError('INVALID_ARGUMENT','Children require absolute executables and an existing trusted working directory'); - if (!args.every(a => typeof a === 'string' && !a.includes('\0'))) throw new CsoError('INVALID_ARGUMENT','Invalid child argument'); - const hardened=hardenGit(file,args);args=hardened.args; +export async function runProcess( + file: string, + args: string[], + opts: { + cwd: string; + env: Record; + timeoutMs?: number; + maxBytes?: number; + input?: string; + raw?: boolean; // Only for inert Git framing or private helper/Docker control JSON that is validated before use. Never print or persist raw results. + }, +): Promise { + if (!isAbsolute(file) || !isAbsolute(opts.cwd) || !existsSync(opts.cwd)) + throw new CsoError( + 'INVALID_ARGUMENT', + 'Children require absolute executables and an existing trusted working directory', + ); + if (!args.every((a) => typeof a === 'string' && !a.includes('\0'))) + throw new CsoError('INVALID_ARGUMENT', 'Invalid child argument'); + const hardened = hardenGit(file, args); + args = hardened.args; const cap = Math.min(opts.maxBytes ?? MAX_OUTPUT, MAX_OUTPUT); - return new Promise((resolve,reject) => { - const child = spawn(file,args,{cwd:opts.cwd,env:opts.env,stdio:['pipe','pipe','pipe'],detached:process.platform !== 'win32'}); - const out: Buffer[] = [], err: Buffer[] = [], ordered:Buffer[]=[]; let bytes = 0, timedOut = false, truncated = false; - const kill = () => { try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid,'SIGKILL'); else child.kill('SIGKILL'); } catch {} }; - const timer = setTimeout(() => { timedOut = true; kill(); }, Math.max(1,Math.min(opts.timeoutMs ?? 30_000,300_000))); + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: opts.cwd, + env: opts.env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + const out: Buffer[] = [], + err: Buffer[] = [], + ordered: Buffer[] = []; + let bytes = 0, + timedOut = false, + truncated = false; + const kill = () => { + try { + if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL'); + else child.kill('SIGKILL'); + } catch {} + }; + const timer = setTimeout( + () => { + timedOut = true; + kill(); + }, + Math.max(1, Math.min(opts.timeoutMs ?? 30_000, 300_000)), + ); const capture = (target: Buffer[]) => (chunk: Buffer) => { bytes += chunk.length; - if (bytes > cap) { truncated = true; kill(); return; } - target.push(chunk);ordered.push(chunk); + if (bytes > cap) { + truncated = true; + kill(); + return; + } + target.push(chunk); + ordered.push(chunk); }; - child.stdout.on('data',capture(out)); child.stderr.on('data',capture(err)); - child.on('error',() => { clearTimeout(timer); reject(new CsoError('TOOL_UNAVAILABLE','Trusted child process could not start')); }); - child.on('close',code => { + child.stdout.on('data', capture(out)); + child.stderr.on('data', capture(err)); + child.on('error', () => { + clearTimeout(timer); + reject(new CsoError('TOOL_UNAVAILABLE', 'Trusted child process could not start')); + }); + child.on('close', (code) => { clearTimeout(timer); try { - if(hardened.configs)assertGitConfigIdentities(hardened.configs); + if (hardened.configs) assertGitConfigIdentities(hardened.configs); // Never expose a truncated tail: it might be the beginning of a secret. const stdout = truncated ? '[output withheld: size limit]' : Buffer.concat(out).toString('utf8'); const stderr = truncated ? '' : Buffer.concat(err).toString('utf8'); - if(opts.raw){resolve({code:code ?? -1,stdout,stderr,timedOut,truncated,capturedBytes:bytes});return;} + if (opts.raw) { + resolve({ code: code ?? -1, stdout, stderr, timedOut, truncated, capturedBytes: bytes }); + return; + } // A token may be split across stdout/stderr. Stream ordering is not // recoverable here, so scan both concatenation orders and withhold both // channels when either reveals a cross-stream sensitive span. - const forward=stdout+stderr,reverse=stderr+stdout,chronological=Buffer.concat(ordered).toString('utf8'); - if([stdout,stderr,forward,reverse,chronological].some(value=>redact(value)!==value)){ - resolve({code:code ?? -1,stdout:'[sensitive process output redacted]',stderr:'',timedOut,truncated,capturedBytes:bytes});return; + const forward = stdout + stderr, + reverse = stderr + stdout, + chronological = Buffer.concat(ordered).toString('utf8'); + if ([stdout, stderr, forward, reverse, chronological].some((value) => redact(value) !== value)) { + resolve({ + code: code ?? -1, + stdout: '[sensitive process output redacted]', + stderr: '', + timedOut, + truncated, + capturedBytes: bytes, + }); + return; } - resolve({code:code ?? -1,stdout,stderr,timedOut,truncated,capturedBytes:bytes}); - } catch (e) { reject(e); } + resolve({ code: code ?? -1, stdout, stderr, timedOut, truncated, capturedBytes: bytes }); + } catch (e) { + reject(e); + } }); - child.stdin.on('error',() => {}); child.stdin.end(opts.input); + child.stdin.on('error', () => {}); + child.stdin.end(opts.input); }); } export async function git(repo: string, args: string[], home: string): Promise { - const result = await runProcess(executable('git'),['--no-optional-locks','-C',repo,...args], - {cwd:home,env:childEnvironment(home),raw:true,timeoutMs:15_000}); + const result = await runProcess(executable('git'), ['--no-optional-locks', '-C', repo, ...args], { + cwd: home, + env: childEnvironment(home), + raw: true, + timeoutMs: 15_000, + }); if (result.code || result.timedOut || result.truncated) { // Git stderr and argv can contain repository paths, refs, and configured // content. Name only the fixed helper-owned operation and bounded process // outcome so native failures are actionable without exposing either. - const knownOperations=new Set(['rev-parse','symbolic-ref','ls-files','ls-tree','log','merge-base']),operation=args.find(value=>knownOperations.has(value))??'metadata', - phase=operation==='rev-parse'&&args.includes('--show-object-format')?'object-format':operation==='rev-parse'&&args.includes('--is-inside-work-tree')?'worktree-probe':operation, - reason=/not a git repository|outside repository/i.test(result.stderr)?'repository unavailable':/dubious ownership/i.test(result.stderr)?'repository ownership rejected':/(?:bad|invalid|unable to read).*config|config (?:error|file)/i.test(result.stderr)?'configuration rejected':/unknown option|unknown switch|unrecognized option|usage:/i.test(result.stderr)?'unsupported invocation':/(?:cannot|could not|unable to) (?:chdir|change directory)|no such file or directory/i.test(result.stderr)?'path unavailable':'request rejected', - outcome=result.timedOut?'timed out':result.truncated?'exceeded the output limit':`exited ${result.code}`; - throw new CsoError('MISSING_INPUT',`Could not read bounded Git metadata: ${phase} ${outcome} (${reason}); source may not be a Git repository`); + const knownOperations = new Set([ + 'rev-parse', + 'symbolic-ref', + 'ls-files', + 'ls-tree', + 'log', + 'merge-base', + ]), + operation = args.find((value) => knownOperations.has(value)) ?? 'metadata', + phase = + operation === 'rev-parse' && args.includes('--show-object-format') + ? 'object-format' + : operation === 'rev-parse' && args.includes('--is-inside-work-tree') + ? 'worktree-probe' + : operation, + reason = /not a git repository|outside repository/i.test(result.stderr) + ? 'repository unavailable' + : /dubious ownership/i.test(result.stderr) + ? 'repository ownership rejected' + : /(?:bad|invalid|unable to read).*config|config (?:error|file)/i.test(result.stderr) + ? 'configuration rejected' + : /unknown option|unknown switch|unrecognized option|usage:/i.test(result.stderr) + ? 'unsupported invocation' + : /(?:cannot|could not|unable to) (?:chdir|change directory)|no such file or directory/i.test( + result.stderr, + ) + ? 'path unavailable' + : 'request rejected', + outcome = result.timedOut + ? 'timed out' + : result.truncated + ? 'exceeded the output limit' + : `exited ${result.code}`; + throw new CsoError( + 'MISSING_INPUT', + `Could not read bounded Git metadata: ${phase} ${outcome} (${reason}); source may not be a Git repository`, + ); } return result.stdout; } diff --git a/lib/cso/runtime-catalog.ts b/lib/cso/runtime-catalog.ts index dfe3180f0..eb066b04c 100644 --- a/lib/cso/runtime-catalog.ts +++ b/lib/cso/runtime-catalog.ts @@ -13,10 +13,23 @@ interface RuntimeQualificationProvenance { provenanceDigest: string; verifiedProvenance: true; } -export type RuntimeQualification = RuntimeQualificationProvenance & ( - | { kind: 'application'; containmentPassed: true; coldStartPassed: true; positiveNegativeAssertionsPassed: true; heldOutRepairPassed: true } - | { kind: 'postgresql'; containmentPassed: true; coldStartPassed: true; multiDatabasePassed: true; readinessPassed: true } -); +export type RuntimeQualification = RuntimeQualificationProvenance & + ( + | { + kind: 'application'; + containmentPassed: true; + coldStartPassed: true; + positiveNegativeAssertionsPassed: true; + heldOutRepairPassed: true; + } + | { + kind: 'postgresql'; + containmentPassed: true; + coldStartPassed: true; + multiDatabasePassed: true; + readinessPassed: true; + } + ); export interface QualifiedRuntime { id: string; stack: CsoStack | 'postgresql'; @@ -76,46 +89,96 @@ function versionsKey(versions: Record): string { return JSON.stringify(Object.entries(versions).sort(([a], [b]) => a.localeCompare(b))); } -function validateRuntimeIdentity(value: { id: string; stack: string; platform: string; versions: Record }): void { +function validateRuntimeIdentity(value: { + id: string; + stack: string; + platform: string; + versions: Record; +}): void { if (typeof value.id !== 'string' || !ID.test(value.id)) throw new Error('INVALID_RUNTIME_ID'); - if (!STACKS.includes(value.stack as typeof STACKS[number]) || !PLATFORMS.includes(value.platform as RuntimePlatform)) throw new Error('UNSUPPORTED_RUNTIME_PLATFORM'); - if (!value.versions || typeof value.versions !== 'object' || Array.isArray(value.versions) || !Object.keys(value.versions).length || - Object.values(value.versions).some(version => typeof version !== 'string' || !/^[0-9][a-zA-Z0-9.+_-]*$/.test(version))) throw new Error('UNPINNED_RUNTIME_VERSION'); - if (Object.keys(value.versions).sort().join(',') !== [...REQUIRED[value.stack]].sort().join(',')) throw new Error('MISSING_RUNTIME_TOOL_VERSION'); - if (['node', 'bun', 'python', 'rails'].includes(value.stack) && value.versions['cso-preparation'] !== '1.0.0') throw new Error('INCOMPATIBLE_PREPARATION_HELPER'); + if ( + !STACKS.includes(value.stack as (typeof STACKS)[number]) || + !PLATFORMS.includes(value.platform as RuntimePlatform) + ) + throw new Error('UNSUPPORTED_RUNTIME_PLATFORM'); + if ( + !value.versions || + typeof value.versions !== 'object' || + Array.isArray(value.versions) || + !Object.keys(value.versions).length || + Object.values(value.versions).some( + (version) => typeof version !== 'string' || !/^[0-9][a-zA-Z0-9.+_-]*$/.test(version), + ) + ) + throw new Error('UNPINNED_RUNTIME_VERSION'); + if (Object.keys(value.versions).sort().join(',') !== [...REQUIRED[value.stack]].sort().join(',')) + throw new Error('MISSING_RUNTIME_TOOL_VERSION'); + if ( + ['node', 'bun', 'python', 'rails'].includes(value.stack) && + value.versions['cso-preparation'] !== '1.0.0' + ) + throw new Error('INCOMPATIBLE_PREPARATION_HELPER'); } export function validateRuntimeCatalog(value: unknown): asserts value is RuntimeCatalog { const catalog = value as RuntimeCatalog; - if (!catalog || catalog.schemaVersion !== 1 || catalog.helperAbi !== CSO_HELPER_ABI || - typeof catalog.revision !== 'string' || !BUILD_REVISION.test(catalog.revision) || !Array.isArray(catalog.runtimes)) throw new Error('INCOMPATIBLE_RUNTIME_CATALOG'); - if (catalog.previousRevision !== null && (typeof catalog.previousRevision !== 'string' || !BUILD_REVISION.test(catalog.previousRevision))) throw new Error('INVALID_RUNTIME_CATALOG'); + if ( + !catalog || + catalog.schemaVersion !== 1 || + catalog.helperAbi !== CSO_HELPER_ABI || + typeof catalog.revision !== 'string' || + !BUILD_REVISION.test(catalog.revision) || + !Array.isArray(catalog.runtimes) + ) + throw new Error('INCOMPATIBLE_RUNTIME_CATALOG'); + if ( + catalog.previousRevision !== null && + (typeof catalog.previousRevision !== 'string' || !BUILD_REVISION.test(catalog.previousRevision)) + ) + throw new Error('INVALID_RUNTIME_CATALOG'); - if (!Array.isArray(catalog.profiles) || catalog.profiles.length !== STACKS.length * PLATFORMS.length || - typeof catalog.buildRevision !== 'string' || !BUILD_REVISION.test(catalog.buildRevision)) throw new Error('INVALID_REVIEWED_RUNTIME_PROFILES'); - const profiles = new Map(), profileIdentities = new Set(); + if ( + !Array.isArray(catalog.profiles) || + catalog.profiles.length !== STACKS.length * PLATFORMS.length || + typeof catalog.buildRevision !== 'string' || + !BUILD_REVISION.test(catalog.buildRevision) + ) + throw new Error('INVALID_REVIEWED_RUNTIME_PROFILES'); + const profiles = new Map(), + profileIdentities = new Set(); for (const profile of catalog.profiles) { validateRuntimeIdentity(profile); const identity = `${profile.stack}:${profile.platform}`; - if (profiles.has(profile.id) || profileIdentities.has(identity) || profile.state !== 'build_reviewed' || - !Number.isFinite(Date.parse(profile.reviewedAt))) throw new Error('INVALID_REVIEWED_RUNTIME_PROFILE'); - profiles.set(profile.id, profile); profileIdentities.add(identity); - } - for (const stack of STACKS) for (const platform of PLATFORMS) { - if (!profileIdentities.has(`${stack}:${platform}`)) throw new Error('INCOMPLETE_REVIEWED_RUNTIME_MATRIX'); + if ( + profiles.has(profile.id) || + profileIdentities.has(identity) || + profile.state !== 'build_reviewed' || + !Number.isFinite(Date.parse(profile.reviewedAt)) + ) + throw new Error('INVALID_REVIEWED_RUNTIME_PROFILE'); + profiles.set(profile.id, profile); + profileIdentities.add(identity); } + for (const stack of STACKS) + for (const platform of PLATFORMS) { + if (!profileIdentities.has(`${stack}:${platform}`)) + throw new Error('INCOMPLETE_REVIEWED_RUNTIME_MATRIX'); + } if (catalog.promotion !== undefined) { - if (!/^[a-f0-9]{40}$/.test(catalog.promotion.sourceCommit) || + if ( + !/^[a-f0-9]{40}$/.test(catalog.promotion.sourceCommit) || !QUALIFICATION_WORKFLOW.test(catalog.promotion.workflow) || !DIGEST.test(catalog.promotion.evidenceDigest) || !DIGEST.test(catalog.promotion.qualificationEvidenceDigest) || Object.keys(catalog.promotion).sort().join(',') !== - ['evidenceDigest', 'qualificationEvidenceDigest', 'sourceCommit', 'workflow'].sort().join(',')) { + ['evidenceDigest', 'qualificationEvidenceDigest', 'sourceCommit', 'workflow'].sort().join(',') + ) { throw new Error('INVALID_RUNTIME_PROMOTION'); } } - if (catalog.runtimes.length !== 0 && catalog.runtimes.length !== STACKS.length * PLATFORMS.length) throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX'); + if (catalog.runtimes.length !== 0 && catalog.runtimes.length !== STACKS.length * PLATFORMS.length) + throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX'); const ids = new Set(); const runtimeIdentities = new Set(); for (const runtime of catalog.runtimes) { @@ -124,35 +187,94 @@ export function validateRuntimeCatalog(value: unknown): asserts value is Runtime validateRuntimeIdentity(runtime); const identity = `${runtime.stack}:${runtime.platform}`; if (ids.has(runtime.id) || runtimeIdentities.has(identity)) throw new Error('INVALID_RUNTIME_ID'); - ids.add(runtime.id); runtimeIdentities.add(identity); + ids.add(runtime.id); + runtimeIdentities.add(identity); const arch = runtime.platform === 'linux/amd64' ? 'amd64' : 'arm64'; - const expectedImage = new RegExp(`^ghcr\\.io/garrytan/gstack/cso-staging/${runtime.stack}-${arch}@sha256:[a-f0-9]{64}$`); - if (runtime.state !== 'qualified' || !IMAGE.test(runtime.image) || !expectedImage.test(runtime.image) || runtime.entrypoint !== '/opt/cso/entrypoint' || - runtime.helperAbi !== CSO_HELPER_ABI || runtime.policyVersion !== 'cso-isolation-v1') throw new Error('UNQUALIFIED_RUNTIME'); + const expectedImage = new RegExp( + `^ghcr\\.io/garrytan/gstack/cso-staging/${runtime.stack}-${arch}@sha256:[a-f0-9]{64}$`, + ); + if ( + runtime.state !== 'qualified' || + !IMAGE.test(runtime.image) || + !expectedImage.test(runtime.image) || + runtime.entrypoint !== '/opt/cso/entrypoint' || + runtime.helperAbi !== CSO_HELPER_ABI || + runtime.policyVersion !== 'cso-isolation-v1' + ) + throw new Error('UNQUALIFIED_RUNTIME'); const reviewed = profiles.get(runtime.id); - if (!reviewed || reviewed.stack !== runtime.stack || reviewed.platform !== runtime.platform || - versionsKey(reviewed.versions) !== versionsKey(runtime.versions)) throw new Error('RUNTIME_BUILD_PROFILE_MISMATCH'); - if (!qualification || !/^[a-f0-9]{40}$/.test(qualification.sourceCommit) || + if ( + !reviewed || + reviewed.stack !== runtime.stack || + reviewed.platform !== runtime.platform || + versionsKey(reviewed.versions) !== versionsKey(runtime.versions) + ) + throw new Error('RUNTIME_BUILD_PROFILE_MISMATCH'); + if ( + !qualification || + !/^[a-f0-9]{40}$/.test(qualification.sourceCommit) || !QUALIFICATION_WORKFLOW.test(qualification.workflow) || - !DIGEST.test(qualification.sbomDigest) || !DIGEST.test(qualification.provenanceDigest) || qualification.verifiedProvenance !== true || - !Number.isFinite(Date.parse(runtime.qualifiedAt))) throw new Error('MISSING_RUNTIME_QUALIFICATION'); + !DIGEST.test(qualification.sbomDigest) || + !DIGEST.test(qualification.provenanceDigest) || + qualification.verifiedProvenance !== true || + !Number.isFinite(Date.parse(runtime.qualifiedAt)) + ) + throw new Error('MISSING_RUNTIME_QUALIFICATION'); const keys = Object.keys(qualification).sort(); - const common = ['kind', 'sourceCommit', 'workflow', 'sbomDigest', 'provenanceDigest', 'verifiedProvenance']; + const common = [ + 'kind', + 'sourceCommit', + 'workflow', + 'sbomDigest', + 'provenanceDigest', + 'verifiedProvenance', + ]; if (['node', 'bun', 'python', 'rails'].includes(runtime.stack)) { - if (qualification.kind !== 'application' || qualification.containmentPassed !== true || qualification.coldStartPassed !== true || - qualification.positiveNegativeAssertionsPassed !== true || qualification.heldOutRepairPassed !== true || - keys.join(',') !== [...common, 'containmentPassed', 'coldStartPassed', 'positiveNegativeAssertionsPassed', 'heldOutRepairPassed'].sort().join(',')) throw new Error('MISSING_APPLICATION_QUALIFICATION'); + if ( + qualification.kind !== 'application' || + qualification.containmentPassed !== true || + qualification.coldStartPassed !== true || + qualification.positiveNegativeAssertionsPassed !== true || + qualification.heldOutRepairPassed !== true || + keys.join(',') !== + [ + ...common, + 'containmentPassed', + 'coldStartPassed', + 'positiveNegativeAssertionsPassed', + 'heldOutRepairPassed', + ] + .sort() + .join(',') + ) + throw new Error('MISSING_APPLICATION_QUALIFICATION'); } else { - if (qualification.kind !== 'postgresql' || qualification.containmentPassed !== true || qualification.coldStartPassed !== true || - qualification.multiDatabasePassed !== true || qualification.readinessPassed !== true || - keys.join(',') !== [...common, 'containmentPassed', 'coldStartPassed', 'multiDatabasePassed', 'readinessPassed'].sort().join(',')) throw new Error('MISSING_POSTGRESQL_QUALIFICATION'); + if ( + qualification.kind !== 'postgresql' || + qualification.containmentPassed !== true || + qualification.coldStartPassed !== true || + qualification.multiDatabasePassed !== true || + qualification.readinessPassed !== true || + keys.join(',') !== + [...common, 'containmentPassed', 'coldStartPassed', 'multiDatabasePassed', 'readinessPassed'] + .sort() + .join(',') + ) + throw new Error('MISSING_POSTGRESQL_QUALIFICATION'); } } if (catalog.runtimes.length > 0) { - for (const identity of profileIdentities) if (!runtimeIdentities.has(identity)) throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX'); + for (const identity of profileIdentities) + if (!runtimeIdentities.has(identity)) throw new Error('INCOMPLETE_QUALIFIED_RUNTIME_MATRIX'); if (!catalog.promotion) throw new Error('MISSING_RUNTIME_PROMOTION'); - if (catalog.runtimes.some(runtime => runtime.qualification.sourceCommit !== catalog.promotion!.sourceCommit || - runtime.qualification.workflow !== catalog.promotion!.workflow)) throw new Error('RUNTIME_PROMOTION_MISMATCH'); + if ( + catalog.runtimes.some( + (runtime) => + runtime.qualification.sourceCommit !== catalog.promotion!.sourceCommit || + runtime.qualification.workflow !== catalog.promotion!.workflow, + ) + ) + throw new Error('RUNTIME_PROMOTION_MISMATCH'); if (catalog.promotion.evidenceDigest !== `sha256:${sha256(canonical(catalog.runtimes))}`) { throw new Error('RUNTIME_PROMOTION_EVIDENCE_MISMATCH'); } @@ -163,38 +285,72 @@ export const RUNTIME_CATALOG = committedCatalog as RuntimeCatalog; validateRuntimeCatalog(RUNTIME_CATALOG); export function assertRuntimeCompatible(plan: PreparationPlan, runtime: QualifiedRuntime): void { - if (plan.schemaVersion !== 1 || plan.status !== 'ready' || runtime.stack !== plan.stack) throw new CsoError('INCOMPATIBLE_INPUT', `Prepared ${plan.stack} source cannot run in ${runtime.stack} runtime ${runtime.id}`); + if (plan.schemaVersion !== 1 || plan.status !== 'ready' || runtime.stack !== plan.stack) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + `Prepared ${plan.stack} source cannot run in ${runtime.stack} runtime ${runtime.id}`, + ); for (const [declared, rawRange] of Object.entries(plan.runtimeRequirements)) { if (!rawRange) continue; - let tool = declared, range = rawRange; + let tool = declared, + range = rawRange; if (declared === 'packageManager') { const match = rawRange.match(/^([a-z][a-z0-9_-]*)@(.+)$/i); - if (!match) throw new CsoError('PREREQUISITE', 'Package manager declaration must bind a named version range'); - tool = match[1]; range = match[2]; + if (!match) + throw new CsoError('PREREQUISITE', 'Package manager declaration must bind a named version range'); + tool = match[1]; + range = match[2]; } const version = runtime.versions[tool]; - if (!version) throw new CsoError('PREREQUISITE', `Qualified runtime ${runtime.id} does not declare a real ${tool} release`); + if (!version) + throw new CsoError( + 'PREREQUISITE', + `Qualified runtime ${runtime.id} does not declare a real ${tool} release`, + ); let satisfies = false; - try { satisfies = Bun.semver.satisfies(version.replace(/^v/, ''), range); } catch {} - if (!satisfies) throw new CsoError('PREREQUISITE', `Qualified ${tool} ${version} does not satisfy source requirement ${range}`); + try { + satisfies = Bun.semver.satisfies(version.replace(/^v/, ''), range); + } catch {} + if (!satisfies) + throw new CsoError( + 'PREREQUISITE', + `Qualified ${tool} ${version} does not satisfy source requirement ${range}`, + ); } } -export function selectRuntime(profile: string, platform: RuntimePlatform, catalog: RuntimeCatalog = RUNTIME_CATALOG): QualifiedRuntime { +export function selectRuntime( + profile: string, + platform: RuntimePlatform, + catalog: RuntimeCatalog = RUNTIME_CATALOG, +): QualifiedRuntime { validateRuntimeCatalog(catalog); - const matches = catalog.runtimes.filter(runtime => runtime.platform === platform && (runtime.id === profile || runtime.stack === profile)); + const matches = catalog.runtimes.filter( + (runtime) => runtime.platform === platform && (runtime.id === profile || runtime.stack === profile), + ); if (matches.length === 0) { - const reviewed = catalog.profiles?.filter(item => item.platform === platform && (item.id === profile || item.stack === profile)) ?? []; - const detail = reviewed.length === 1 ? ` Reviewed build profile ${reviewed[0].id} is awaiting a qualified image promotion.` : ''; - throw new Error(`MISSING_QUALIFIED_RUNTIME: ${profile} on ${platform}; build, qualify, and review a digest catalog before target execution.${detail}`); + const reviewed = + catalog.profiles?.filter( + (item) => item.platform === platform && (item.id === profile || item.stack === profile), + ) ?? []; + const detail = + reviewed.length === 1 + ? ` Reviewed build profile ${reviewed[0].id} is awaiting a qualified image promotion.` + : ''; + throw new Error( + `MISSING_QUALIFIED_RUNTIME: ${profile} on ${platform}; build, qualify, and review a digest catalog before target execution.${detail}`, + ); } - if (matches.length !== 1) throw new Error(`AMBIGUOUS_RUNTIME: select an exact qualified runtime id for ${profile}.`); + if (matches.length !== 1) + throw new Error(`AMBIGUOUS_RUNTIME: select an exact qualified runtime id for ${profile}.`); return matches[0]; } /** Rollback only pairs the previous catalog with a compatible helper; reports have their own schema. */ export function rollbackCatalog(current: RuntimeCatalog, previous: RuntimeCatalog): RuntimeCatalog { - validateRuntimeCatalog(current); validateRuntimeCatalog(previous); - if (current.previousRevision !== previous.revision || current.helperAbi !== previous.helperAbi) throw new Error('INCOMPATIBLE_RUNTIME_ROLLBACK'); + validateRuntimeCatalog(current); + validateRuntimeCatalog(previous); + if (current.previousRevision !== previous.revision || current.helperAbi !== previous.helperAbi) + throw new Error('INCOMPATIBLE_RUNTIME_ROLLBACK'); return previous; } diff --git a/lib/cso/scanner-catalog.ts b/lib/cso/scanner-catalog.ts index f4617ab02..150370409 100644 --- a/lib/cso/scanner-catalog.ts +++ b/lib/cso/scanner-catalog.ts @@ -54,8 +54,15 @@ const IMAGE = /^(?:[a-z0-9.-]+(?::[0-9]+)?\/)?[a-z0-9][a-z0-9._/-]*@sha256:[a-f0 const ID = /^[a-z0-9][a-z0-9._-]{0,100}$/; const QUALIFICATION_WORKFLOW = /^https:\/\/github\.com\/garrytan\/gstack\/actions\/runs\/[0-9]+$/; const PLATFORMS: RuntimePlatform[] = ['linux/amd64', 'linux/arm64']; -const path = (s: unknown, prefix: string): s is string => typeof s === 'string' && s.startsWith(prefix) && !/[\x00-\x20\\,]/.test(s) && !s.split('/').some(x => x === '..' || x === '.') && !s.includes('//'); -function invalid(message: string): never { throw new CsoError('INCOMPATIBLE_INPUT', message); } +const path = (s: unknown, prefix: string): s is string => + typeof s === 'string' && + s.startsWith(prefix) && + !/[\x00-\x20\\,]/.test(s) && + !s.split('/').some((x) => x === '..' || x === '.') && + !s.includes('//'); +function invalid(message: string): never { + throw new CsoError('INCOMPATIBLE_INPUT', message); +} function sameStrings(left: string[], right: string[]): boolean { return canonical([...left].sort()) === canonical([...right].sort()); } @@ -68,63 +75,179 @@ export function scannerVersionHash(stdout: string, stderr = ''): string { * version to be one complete version token. A substring such as `1.2.3` in * `11.2.3`, `1.2.30`, or `1.2.3-dev` is not qualification evidence. */ -export function assertScannerVersionOutput(scanner:ScannerId,version:string,stdout:string,stderr=''):void{ - if(!/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(version))invalid('Scanner version evidence has an invalid expected version'); - const output=`${stdout}\n${stderr}`; - if(Buffer.byteLength(stdout)+Buffer.byteLength(stderr)>8192)invalid('Scanner version evidence exceeds the bounded output limit'); - const escaped=version.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); - const labels:Record={gitleaks:'gitleaks',osv:'(?:osv|osv-scanner)',semgrep:'semgrep',zizmor:'zizmor',trivy:'trivy',schemathesis:'schemathesis'}; - const primary=output.split(/\r?\n/).map(line=>line.trim()).find(Boolean)??''; - const exact=new RegExp(`^(?:v?${escaped}|${labels[scanner]},?\\s+(?:version\\s*:?\\s*)?v?${escaped}|version\\s*:\\s*v?${escaped})$`,'i'); - if(!exact.test(primary))invalid('Scanner primary version output does not match the exact catalog version'); +export function assertScannerVersionOutput( + scanner: ScannerId, + version: string, + stdout: string, + stderr = '', +): void { + if (!/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(version)) + invalid('Scanner version evidence has an invalid expected version'); + const output = `${stdout}\n${stderr}`; + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > 8192) + invalid('Scanner version evidence exceeds the bounded output limit'); + const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const labels: Record = { + gitleaks: 'gitleaks', + osv: '(?:osv|osv-scanner)', + semgrep: 'semgrep', + zizmor: 'zizmor', + trivy: 'trivy', + schemathesis: 'schemathesis', + }; + const primary = + output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? ''; + const exact = new RegExp( + `^(?:v?${escaped}|${labels[scanner]},?\\s+(?:version\\s*:?\\s*)?v?${escaped}|version\\s*:\\s*v?${escaped})$`, + 'i', + ); + if (!exact.test(primary)) + invalid('Scanner primary version output does not match the exact catalog version'); } export function validateQualifiedScanner(s: QualifiedScanner): void { - if (!SCANNER_IDS.includes(s.scanner) || !['linux/amd64', 'linux/arm64'].includes(s.platform)) invalid('Unsupported scanner or platform'); - const arch = s.platform === 'linux/amd64' ? 'amd64' : 'arm64'; - const expectedImage = new RegExp(`^ghcr\\.io/garrytan/gstack/cso-scanners/${s.scanner}-${arch}@sha256:[a-f0-9]{64}$`); - if (s.state !== 'qualified' || !IMAGE.test(s.image) || !expectedImage.test(s.image) || s.entrypoint !== '/opt/cso/entrypoint' || s.helperAbi !== ABI || s.isolationPolicyHash !== ISOLATION_POLICY_HASH) invalid('Scanner profile is not qualified for this helper isolation policy'); - if (s.executable !== '/opt/cso/bin/scanner' || !/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(s.version) || !HASH.test(s.versionOutputSha256)) invalid('Scanner executable and version must be pinned'); - if (!Array.isArray(s.capabilities) || !s.capabilities.length || s.capabilities.length > 100 || s.capabilities.some(x => typeof x !== 'string' || !x || x.length > 100)) invalid('Scanner capabilities must be reviewed'); - const required = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [s.scanner] })[0].requiredFeatures; - if (!sameStrings(s.capabilities, required)) invalid('Scanner capabilities do not match the helper adapter contract'); - const rules = s.assets?.semgrepRules, db = s.assets?.advisoryDatabase; - if (rules && (s.scanner !== 'semgrep' || !path(rules.path, '/policy/catalog/') || !HASH.test(rules.sha256))) invalid('Invalid immutable Semgrep rules'); - if (db && (!['osv', 'trivy'].includes(s.scanner) || !path(db.path, '/opt/cso/scanner-data/') || !HASH.test(db.contentSha256) || !Number.isFinite(Date.parse(db.updatedAt)) || !Array.isArray(db.ecosystems) || !db.ecosystems.length || db.ecosystems.some(x => typeof x !== 'string' || !x || x.length > 100))) invalid('Invalid immutable scanner database'); - if (s.scanner === 'semgrep' && !rules) invalid('Qualified Semgrep profiles require an immutable rules bundle'); - if (['osv', 'trivy'].includes(s.scanner) && !db) invalid(`Qualified ${s.scanner} profiles require an immutable offline database`); - const q = s.qualification; - if (!Number.isFinite(Date.parse(s.qualifiedAt)) || !q || !/^[a-f0-9]{40}$/.test(q.sourceCommit) || !QUALIFICATION_WORKFLOW.test(q.workflow) || !DIGEST.test(q.sbomDigest) || !DIGEST.test(q.provenanceDigest) || q.verifiedProvenance !== true || q.containmentPassed !== true || q.adapterContractPassed !== true || q.offlineAssetsPassed !== true) invalid('Missing trusted scanner qualification'); + if (!SCANNER_IDS.includes(s.scanner) || !['linux/amd64', 'linux/arm64'].includes(s.platform)) + invalid('Unsupported scanner or platform'); + const arch = s.platform === 'linux/amd64' ? 'amd64' : 'arm64'; + const expectedImage = new RegExp( + `^ghcr\\.io/garrytan/gstack/cso-scanners/${s.scanner}-${arch}@sha256:[a-f0-9]{64}$`, + ); + if ( + s.state !== 'qualified' || + !IMAGE.test(s.image) || + !expectedImage.test(s.image) || + s.entrypoint !== '/opt/cso/entrypoint' || + s.helperAbi !== ABI || + s.isolationPolicyHash !== ISOLATION_POLICY_HASH + ) + invalid('Scanner profile is not qualified for this helper isolation policy'); + if ( + s.executable !== '/opt/cso/bin/scanner' || + !/^[0-9][A-Za-z0-9.+_-]{0,100}$/.test(s.version) || + !HASH.test(s.versionOutputSha256) + ) + invalid('Scanner executable and version must be pinned'); + if ( + !Array.isArray(s.capabilities) || + !s.capabilities.length || + s.capabilities.length > 100 || + s.capabilities.some((x) => typeof x !== 'string' || !x || x.length > 100) + ) + invalid('Scanner capabilities must be reviewed'); + const required = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [s.scanner] })[0] + .requiredFeatures; + if (!sameStrings(s.capabilities, required)) + invalid('Scanner capabilities do not match the helper adapter contract'); + const rules = s.assets?.semgrepRules, + db = s.assets?.advisoryDatabase; + if (rules && (s.scanner !== 'semgrep' || !path(rules.path, '/policy/catalog/') || !HASH.test(rules.sha256))) + invalid('Invalid immutable Semgrep rules'); + if ( + db && + (!['osv', 'trivy'].includes(s.scanner) || + !path(db.path, '/opt/cso/scanner-data/') || + !HASH.test(db.contentSha256) || + !Number.isFinite(Date.parse(db.updatedAt)) || + !Array.isArray(db.ecosystems) || + !db.ecosystems.length || + db.ecosystems.some((x) => typeof x !== 'string' || !x || x.length > 100)) + ) + invalid('Invalid immutable scanner database'); + if (s.scanner === 'semgrep' && !rules) + invalid('Qualified Semgrep profiles require an immutable rules bundle'); + if (['osv', 'trivy'].includes(s.scanner) && !db) + invalid(`Qualified ${s.scanner} profiles require an immutable offline database`); + const q = s.qualification; + if ( + !Number.isFinite(Date.parse(s.qualifiedAt)) || + !q || + !/^[a-f0-9]{40}$/.test(q.sourceCommit) || + !QUALIFICATION_WORKFLOW.test(q.workflow) || + !DIGEST.test(q.sbomDigest) || + !DIGEST.test(q.provenanceDigest) || + q.verifiedProvenance !== true || + q.containmentPassed !== true || + q.adapterContractPassed !== true || + q.offlineAssetsPassed !== true + ) + invalid('Missing trusted scanner qualification'); } export function validateScannerCatalog(value: unknown): asserts value is ScannerCatalog { const c = value as ScannerCatalog; - if (!c || c.schemaVersion !== 1 || c.helperAbi !== ABI || typeof c.revision !== 'string' || !ID.test(c.revision) || !Array.isArray(c.scanners) || ![0, SCANNER_IDS.length * PLATFORMS.length].includes(c.scanners.length)) invalid('Incompatible scanner catalog'); - if (c.previousRevision !== undefined && c.previousRevision !== null && (typeof c.previousRevision !== 'string' || !ID.test(c.previousRevision) || c.previousRevision === c.revision)) invalid('Invalid previous scanner catalog revision'); - if (c.promotion !== undefined && (!/^[a-f0-9]{40}$/.test(c.promotion.sourceCommit) || !QUALIFICATION_WORKFLOW.test(c.promotion.workflow) || !DIGEST.test(c.promotion.evidenceDigest))) invalid('Invalid scanner catalog promotion'); + if ( + !c || + c.schemaVersion !== 1 || + c.helperAbi !== ABI || + typeof c.revision !== 'string' || + !ID.test(c.revision) || + !Array.isArray(c.scanners) || + ![0, SCANNER_IDS.length * PLATFORMS.length].includes(c.scanners.length) + ) + invalid('Incompatible scanner catalog'); + if ( + c.previousRevision !== undefined && + c.previousRevision !== null && + (typeof c.previousRevision !== 'string' || + !ID.test(c.previousRevision) || + c.previousRevision === c.revision) + ) + invalid('Invalid previous scanner catalog revision'); + if ( + c.promotion !== undefined && + (!/^[a-f0-9]{40}$/.test(c.promotion.sourceCommit) || + !QUALIFICATION_WORKFLOW.test(c.promotion.workflow) || + !DIGEST.test(c.promotion.evidenceDigest)) + ) + invalid('Invalid scanner catalog promotion'); if (c.scanners.length === 0) { if (c.promotion !== undefined) invalid('Empty scanner catalog cannot have a promotion'); return; } if (!c.promotion) invalid('Qualified scanner catalog requires trusted promotion evidence'); - const ids = new Set(), identities = new Set(); + const ids = new Set(), + identities = new Set(); for (const s of c.scanners) { - if (!s || typeof s.id !== 'string' || !ID.test(s.id) || ids.has(s.id)) invalid('Invalid or duplicate scanner profile'); + if (!s || typeof s.id !== 'string' || !ID.test(s.id) || ids.has(s.id)) + invalid('Invalid or duplicate scanner profile'); const identity = `${s.scanner}:${s.platform}`; if (identities.has(identity)) invalid('Invalid or duplicate scanner profile'); - ids.add(s.id); identities.add(identity); + ids.add(s.id); + identities.add(identity); validateQualifiedScanner(s); - if (s.qualification.sourceCommit !== c.promotion.sourceCommit || s.qualification.workflow !== c.promotion.workflow) invalid('Scanner qualification does not match catalog promotion'); + if ( + s.qualification.sourceCommit !== c.promotion.sourceCommit || + s.qualification.workflow !== c.promotion.workflow + ) + invalid('Scanner qualification does not match catalog promotion'); } - for (const scanner of SCANNER_IDS) for (const platform of PLATFORMS) if (!identities.has(`${scanner}:${platform}`)) invalid('Incomplete qualified scanner matrix'); - if (c.promotion.evidenceDigest !== `sha256:${sha256(canonical(c.scanners))}`) invalid('Scanner catalog promotion does not bind the qualified matrix'); + for (const scanner of SCANNER_IDS) + for (const platform of PLATFORMS) + if (!identities.has(`${scanner}:${platform}`)) invalid('Incomplete qualified scanner matrix'); + if (c.promotion.evidenceDigest !== `sha256:${sha256(canonical(c.scanners))}`) + invalid('Scanner catalog promotion does not bind the qualified matrix'); } export const SCANNER_CATALOG = committedCatalog as unknown as ScannerCatalog; // A malformed source-controlled catalog must break the helper build/startup; // it can never degrade into an unreviewed executable fallback. validateScannerCatalog(SCANNER_CATALOG); -export function selectScanner(scanner: ScannerId, platform: RuntimePlatform, profile?: string, catalog: ScannerCatalog = SCANNER_CATALOG): QualifiedScanner { +export function selectScanner( + scanner: ScannerId, + platform: RuntimePlatform, + profile?: string, + catalog: ScannerCatalog = SCANNER_CATALOG, +): QualifiedScanner { validateScannerCatalog(catalog); - const matches = catalog.scanners.filter(s => s.scanner === scanner && s.platform === platform && (!profile || s.id === profile)); - if (!matches.length) throw new CsoError('PREREQUISITE', `No qualified ${scanner} image for ${platform}${profile ? ` (${profile})` : ''}; qualify and review an immutable scanner catalog before execution`); - if (matches.length !== 1) throw new CsoError('PREREQUISITE', `Select an exact qualified ${scanner} profile for ${platform}`); + const matches = catalog.scanners.filter( + (s) => s.scanner === scanner && s.platform === platform && (!profile || s.id === profile), + ); + if (!matches.length) + throw new CsoError( + 'PREREQUISITE', + `No qualified ${scanner} image for ${platform}${profile ? ` (${profile})` : ''}; qualify and review an immutable scanner catalog before execution`, + ); + if (matches.length !== 1) + throw new CsoError('PREREQUISITE', `Select an exact qualified ${scanner} profile for ${platform}`); return matches[0]; } diff --git a/lib/cso/scanner-executor.ts b/lib/cso/scanner-executor.ts index 1c1fa102e..ed60978ec 100644 --- a/lib/cso/scanner-executor.ts +++ b/lib/cso/scanner-executor.ts @@ -2,17 +2,63 @@ import * as fs from 'node:fs'; import { randomBytes } from 'node:crypto'; import { join } from 'node:path'; -import { Command, CoverageRecord, CsoError, HttpAssertion, RunPolicy, SnapshotManifest, canonical, object, relativePath, sha256, snapshotPathHandleId, snapshotReference, string, strings, validateCommand, validateVerificationObservation, type ErrorCode } from './contracts'; +import { + Command, + CoverageRecord, + CsoError, + HttpAssertion, + RunPolicy, + SnapshotManifest, + canonical, + object, + relativePath, + sha256, + snapshotPathHandleId, + snapshotReference, + string, + strings, + validateCommand, + validateVerificationObservation, + type ErrorCode, +} from './contracts'; import { DockerEndpoint, DockerGroup, dockerEndpoint } from './docker'; import { inspectPreparation, type CsoStack } from './preparation'; import { redact } from './process'; -import { QualifiedRuntime, RUNTIME_CATALOG, RuntimeCatalog, RuntimePlatform, assertRuntimeCompatible, selectRuntime } from './runtime-catalog'; -import { QualifiedScanner, SCANNER_CATALOG, ScannerCatalog, assertScannerVersionOutput, scannerVersionHash, selectScanner } from './scanner-catalog'; -import { ScannerExecution, ScannerGap, ScannerId, ScannerOutcome, ScannerPlan, parseScannerOutput, scannerPlans } from './scanners'; +import { + QualifiedRuntime, + RUNTIME_CATALOG, + RuntimeCatalog, + RuntimePlatform, + assertRuntimeCompatible, + selectRuntime, +} from './runtime-catalog'; +import { + QualifiedScanner, + SCANNER_CATALOG, + ScannerCatalog, + assertScannerVersionOutput, + scannerVersionHash, + selectScanner, +} from './scanner-catalog'; +import { + ScannerExecution, + ScannerGap, + ScannerId, + ScannerOutcome, + ScannerPlan, + parseScannerOutput, + scannerPlans, +} from './scanners'; import { assertSnapshot } from './snapshot'; import { hasPendingWatchdogCleanup, secureDirectory } from './state'; import { PublicArchiveCache, publicArchiveCacheRoot } from './cache'; -import { admitPreparationRuntime, admitPreparationSidecar, PreparationExecutor, type PreparationSandboxRunner, type RailsDatabaseSelection } from './preparation-executor'; +import { + admitPreparationRuntime, + admitPreparationSidecar, + PreparationExecutor, + type PreparationSandboxRunner, + type RailsDatabaseSelection, +} from './preparation-executor'; import type { PreparedDatabaseContract } from './preparation-executor'; import { DockerPreparationSandboxRunner } from './preparation-docker'; import { canonicalStartPlan, type CanonicalStartPlan } from './verification'; @@ -79,7 +125,9 @@ export interface ScannerRunner { cleanup(): Promise; } /** The trusted HTTP control probe is always the bounded verifier process. */ -export function schemathesisControlRole(): 'verifier' { return 'verifier'; } +export function schemathesisControlRole(): 'verifier' { + return 'verifier'; +} export interface ScannerRunnerContext { input: ScannerRunInput; plan: ScannerPlan; @@ -107,58 +155,121 @@ export interface ScannerRunDependencies { } function exact(v: Record, allowed: string[], name: string): void { - for (const key of Object.keys(v)) if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`); + for (const key of Object.keys(v)) + if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`); } function boundedInt(v: unknown, min: number, max: number, name: string): number { - if (!Number.isSafeInteger(v) || (v as number) < min || (v as number) > max) throw new CsoError('INVALID_SCHEMA', `${name} must be ${min}..${max}`); + if (!Number.isSafeInteger(v) || (v as number) < min || (v as number) > max) + throw new CsoError('INVALID_SCHEMA', `${name} must be ${min}..${max}`); return v as number; } function control(value: unknown): HttpAssertion { - const v = object(value, 'API control'), expected = object(v.expected, 'API control expected'); + const v = object(value, 'API control'), + expected = object(v.expected, 'API control expected'); exact(v, ['name', 'path', 'method', 'headers', 'body', 'expected'], 'API control'); exact(expected, ['status', 'includes', 'excludes'], 'API control expected'); const path = string(v.path, 'API control path', 4096); - if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\]/.test(path)) throw new CsoError('INVALID_SCHEMA', 'API control path must remain on numeric loopback'); - if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(v.method)) throw new CsoError('INVALID_SCHEMA', 'Invalid API control method'); + if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\]/.test(path)) + throw new CsoError('INVALID_SCHEMA', 'API control path must remain on numeric loopback'); + if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(v.method)) + throw new CsoError('INVALID_SCHEMA', 'Invalid API control method'); const headers: Record = {}; - for (const [key, value] of Object.entries(v.headers === undefined ? {} : object(v.headers, 'API control headers'))) { - if (!/^[A-Za-z0-9-]{1,100}$/.test(key) || typeof value !== 'string' || value.length > 8192 || /[\r\n]/.test(value)) throw new CsoError('INVALID_SCHEMA', 'Invalid API control header'); + for (const [key, value] of Object.entries( + v.headers === undefined ? {} : object(v.headers, 'API control headers'), + )) { + if ( + !/^[A-Za-z0-9-]{1,100}$/.test(key) || + typeof value !== 'string' || + value.length > 8192 || + /[\r\n]/.test(value) + ) + throw new CsoError('INVALID_SCHEMA', 'Invalid API control header'); headers[key] = value; } - return { name: string(v.name, 'API control name', 200), path, method: v.method, headers, + return { + name: string(v.name, 'API control name', 200), + path, + method: v.method, + headers, ...(v.body === undefined ? {} : { body: string(v.body, 'API control body', 65536) }), - expected: { status: boundedInt(expected.status, 100, 599, 'API control status'), - ...(expected.includes === undefined ? {} : { includes: string(expected.includes, 'API control includes', 8192) }), - ...(expected.excludes === undefined ? {} : { excludes: string(expected.excludes, 'API control excludes', 8192) }) } }; + expected: { + status: boundedInt(expected.status, 100, 599, 'API control status'), + ...(expected.includes === undefined + ? {} + : { includes: string(expected.includes, 'API control includes', 8192) }), + ...(expected.excludes === undefined + ? {} + : { excludes: string(expected.excludes, 'API control excludes', 8192) }), + }, + }; } /** Accept a bounded OpenAPI document, with internal references and selected path operations only. */ export function validateScannerRequest(value: unknown, id: ScannerId): ScannerRequest { const v = object(value, 'scanner request'); exact(v, ['profile', 'api'], 'scanner request'); - const request: ScannerRequest = v.profile === undefined ? {} : { profile: string(v.profile, 'scanner profile', 100) }; + const request: ScannerRequest = + v.profile === undefined ? {} : { profile: string(v.profile, 'scanner profile', 100) }; if (v.api === undefined) return request; - if (id !== 'schemathesis') throw new CsoError('INVALID_SCHEMA', 'Only Schemathesis accepts application execution inputs'); + if (id !== 'schemathesis') + throw new CsoError('INVALID_SCHEMA', 'Only Schemathesis accepts application execution inputs'); const api = object(v.api, 'API scan'); - exact(api, ['runtimeProfile', 'port', 'start', 'control', 'boundaryFiles', 'schema', 'operationIds', 'seed', 'maxExamples'], 'API scan'); - const schema = object(api.schema, 'OpenAPI schema'), operations = strings(api.operationIds, 'operation IDs'); - if (operations.length < 1 || operations.length > 20 || new Set(operations).size !== operations.length || operations.some(x => x.length > 200 || /[\x00-\x1f]/.test(x))) throw new CsoError('INVALID_SCHEMA', 'Declare 1..20 unique bounded operation IDs'); - if (typeof schema.openapi !== 'string' || !/^3\.[01]\.\d+$/.test(schema.openapi)) throw new CsoError('PREREQUISITE', 'Schemathesis requires a reviewed OpenAPI 3.0/3.1 JSON document'); - if (Buffer.byteLength(JSON.stringify(schema)) > 262144) throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds 256 KiB'); + exact( + api, + [ + 'runtimeProfile', + 'port', + 'start', + 'control', + 'boundaryFiles', + 'schema', + 'operationIds', + 'seed', + 'maxExamples', + ], + 'API scan', + ); + const schema = object(api.schema, 'OpenAPI schema'), + operations = strings(api.operationIds, 'operation IDs'); + if ( + operations.length < 1 || + operations.length > 20 || + new Set(operations).size !== operations.length || + operations.some((x) => x.length > 200 || /[\x00-\x1f]/.test(x)) + ) + throw new CsoError('INVALID_SCHEMA', 'Declare 1..20 unique bounded operation IDs'); + if (typeof schema.openapi !== 'string' || !/^3\.[01]\.\d+$/.test(schema.openapi)) + throw new CsoError('PREREQUISITE', 'Schemathesis requires a reviewed OpenAPI 3.0/3.1 JSON document'); + if (Buffer.byteLength(JSON.stringify(schema)) > 262144) + throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds 256 KiB'); let nodes = 0; const inspect = (x: unknown, depth: number): void => { - if (++nodes > 50_000 || depth > 32) throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds structural bounds'); + if (++nodes > 50_000 || depth > 32) + throw new CsoError('INVALID_SCHEMA', 'OpenAPI schema exceeds structural bounds'); if (!x || typeof x !== 'object') return; for (const [key, value] of Object.entries(x)) { - if (['__proto__', 'prototype', 'constructor', 'externalValue', 'callbacks', 'webhooks'].includes(key) || /hooks?/i.test(key)) throw new CsoError('PREREQUISITE', 'OpenAPI external examples, callbacks, webhooks, and hooks are not admitted'); - if (key === '$ref' && (typeof value !== 'string' || !value.startsWith('#/'))) throw new CsoError('PREREQUISITE', 'OpenAPI references must be internal JSON pointers'); - if (key === 'servers' && (!Array.isArray(value) || value.length)) throw new CsoError('PREREQUISITE', 'Remove server overrides from the reviewed API harness; its target is the isolated loopback application'); + if ( + ['__proto__', 'prototype', 'constructor', 'externalValue', 'callbacks', 'webhooks'].includes(key) || + /hooks?/i.test(key) + ) + throw new CsoError( + 'PREREQUISITE', + 'OpenAPI external examples, callbacks, webhooks, and hooks are not admitted', + ); + if (key === '$ref' && (typeof value !== 'string' || !value.startsWith('#/'))) + throw new CsoError('PREREQUISITE', 'OpenAPI references must be internal JSON pointers'); + if (key === 'servers' && (!Array.isArray(value) || value.length)) + throw new CsoError( + 'PREREQUISITE', + 'Remove server overrides from the reviewed API harness; its target is the isolated loopback application', + ); inspect(value, depth + 1); } }; inspect(schema, 0); const declared: string[] = []; for (const [path, item] of Object.entries(object(schema.paths, 'OpenAPI paths'))) { - if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\?#]/.test(path)) throw new CsoError('INVALID_SCHEMA', 'OpenAPI paths must be relative to the loopback target'); + if (!path.startsWith('/') || path.startsWith('//') || /[\r\n\\?#]/.test(path)) + throw new CsoError('INVALID_SCHEMA', 'OpenAPI paths must be relative to the loopback target'); const methods = object(item, 'OpenAPI path'); for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) { if (methods[method] === undefined) continue; @@ -166,148 +277,413 @@ export function validateScannerRequest(value: unknown, id: ScannerId): ScannerRe if (typeof op.operationId === 'string') declared.push(op.operationId); } } - if (operations.some(op => declared.filter(x => x === op).length !== 1)) throw new CsoError('INVALID_SCHEMA', 'Every selected operation must identify exactly one declared OpenAPI path operation'); + if (operations.some((op) => declared.filter((x) => x === op).length !== 1)) + throw new CsoError( + 'INVALID_SCHEMA', + 'Every selected operation must identify exactly one declared OpenAPI path operation', + ); const boundaries = strings(api.boundaryFiles, 'API boundary files').map(snapshotReference); - if (!boundaries.length || new Set(boundaries).size !== boundaries.length) throw new CsoError('INVALID_SCHEMA', 'API scan needs unique security-boundary source paths'); - request.api = { runtimeProfile: string(api.runtimeProfile, 'API runtime profile', 100), port: boundedInt(api.port, 1024, 65535, 'API port'), start: validateCommand(api.start, 'API start'), control: control(api.control), boundaryFiles: boundaries, schema, operationIds: operations, + if (!boundaries.length || new Set(boundaries).size !== boundaries.length) + throw new CsoError('INVALID_SCHEMA', 'API scan needs unique security-boundary source paths'); + request.api = { + runtimeProfile: string(api.runtimeProfile, 'API runtime profile', 100), + port: boundedInt(api.port, 1024, 65535, 'API port'), + start: validateCommand(api.start, 'API start'), + control: control(api.control), + boundaryFiles: boundaries, + schema, + operationIds: operations, ...(api.seed === undefined ? {} : { seed: boundedInt(api.seed, 1, 2147483647, 'API seed') }), - ...(api.maxExamples === undefined ? {} : { maxExamples: boundedInt(api.maxExamples, 1, 100, 'API maxExamples') }) }; + ...(api.maxExamples === undefined + ? {} + : { maxExamples: boundedInt(api.maxExamples, 1, 100, 'API maxExamples') }), + }; const raw = JSON.stringify(request); - if (redact(raw) !== raw) throw new CsoError('REDACTION_FAILED', 'Scanner harness contains secret-bearing material; use synthetic inputs'); + if (redact(raw) !== raw) + throw new CsoError( + 'REDACTION_FAILED', + 'Scanner harness contains secret-bearing material; use synthetic inputs', + ); return request; } /** Resolve only helper-issued path references before any application command reaches containment. */ -export function resolveScannerRequestPaths(manifest:SnapshotManifest,request:ScannerRequest):ScannerRequest{ - if(!request.api)return request; - const resolve=(reference:string):string=>{const id=snapshotPathHandleId(reference);if(!id)return relativePath(reference);const entry=manifest.entries.find(item=>item.pathId===id);if(!entry)throw new CsoError('INVALID_SCHEMA',`API path handle is outside the retained snapshot: ${reference}`);return entry.path;}; - const argument=(value:string):string=>{if(snapshotPathHandleId(value))return resolve(value);if(value.startsWith('./')&&snapshotPathHandleId(value.slice(2)))return `./${resolve(value.slice(2))}`;return value;}; - return{...request,api:{...request.api,start:{...request.api.start,args:request.api.start.args.map(argument)},boundaryFiles:request.api.boundaryFiles.map(resolve)}}; +export function resolveScannerRequestPaths( + manifest: SnapshotManifest, + request: ScannerRequest, +): ScannerRequest { + if (!request.api) return request; + const resolve = (reference: string): string => { + const id = snapshotPathHandleId(reference); + if (!id) return relativePath(reference); + const entry = manifest.entries.find((item) => item.pathId === id); + if (!entry) + throw new CsoError('INVALID_SCHEMA', `API path handle is outside the retained snapshot: ${reference}`); + return entry.path; + }; + const argument = (value: string): string => { + if (snapshotPathHandleId(value)) return resolve(value); + if (value.startsWith('./') && snapshotPathHandleId(value.slice(2))) return `./${resolve(value.slice(2))}`; + return value; + }; + return { + ...request, + api: { + ...request.api, + start: { ...request.api.start, args: request.api.start.args.map(argument) }, + boundaryFiles: request.api.boundaryFiles.map(resolve), + }, + }; } export function scannerCoverage(outcome: ScannerOutcome, scope: string): CoverageRecord { - return { domain: `scanner:${outcome.tool}`, scope, status: outcome.status === 'complete' ? 'assessed' : outcome.status, - method: outcome.tool === 'sarif' ? 'bounded untrusted SARIF import' : 'qualified offline Docker scanner; candidate evidence only', - gaps: outcome.gaps.map(g => g.message), exclusions: outcome.exclusions, + return { + domain: `scanner:${outcome.tool}`, + scope, + status: outcome.status === 'complete' ? 'assessed' : outcome.status, + method: + outcome.tool === 'sarif' + ? 'bounded untrusted SARIF import' + : 'qualified offline Docker scanner; candidate evidence only', + gaps: outcome.gaps.map((g) => g.message), + exclusions: outcome.exclusions, evidence: [`${outcome.candidates.length} scanner candidates; plan ${outcome.planSha256}`], - tool: { name: outcome.tool, version: outcome.version ?? 'unavailable', freshness: outcome.databaseUpdatedAt ?? 'not reported', outcome: outcome.status } }; + tool: { + name: outcome.tool, + version: outcome.version ?? 'unavailable', + freshness: outcome.databaseUpdatedAt ?? 'not reported', + outcome: outcome.status, + }, + }; } function failure(plan: ScannerPlan, error: unknown, version?: string): ScannerOutcome { - const e = error instanceof CsoError ? error : new CsoError('ISOLATION_FAILED', 'Scanner execution failed before bounded evidence was established'); + const e = + error instanceof CsoError + ? error + : new CsoError('ISOLATION_FAILED', 'Scanner execution failed before bounded evidence was established'); const codes: Record = { - INVALID_ARGUMENT: 'INVALID_OUTPUT', INVALID_SCHEMA: 'INVALID_OUTPUT', MISSING_INPUT: 'MISSING_INPUT', SNAPSHOT_RACE: 'SNAPSHOT_RACE', - UNSAFE_PATH: 'UNSAFE_PATH', REDACTION_FAILED: 'REDACTION_FAILED', PERSISTENCE_FAILED: 'PERSISTENCE_FAILED', TOOL_UNAVAILABLE: 'UNAVAILABLE', - TOOL_FAILED: 'TOOL_FAILED', ISOLATION_FAILED: 'ISOLATION_FAILED', INSUFFICIENT_CAPACITY: 'INSUFFICIENT_CAPACITY', DEADLINE: 'TIMEOUT', - CANCELLED: 'CANCELLED', PREREQUISITE: 'PREREQUISITE', INCOMPATIBLE_INPUT: 'PREREQUISITE', ASSERTION_FAILED: 'TOOL_FAILED', + INVALID_ARGUMENT: 'INVALID_OUTPUT', + INVALID_SCHEMA: 'INVALID_OUTPUT', + MISSING_INPUT: 'MISSING_INPUT', + SNAPSHOT_RACE: 'SNAPSHOT_RACE', + UNSAFE_PATH: 'UNSAFE_PATH', + REDACTION_FAILED: 'REDACTION_FAILED', + PERSISTENCE_FAILED: 'PERSISTENCE_FAILED', + TOOL_UNAVAILABLE: 'UNAVAILABLE', + TOOL_FAILED: 'TOOL_FAILED', + ISOLATION_FAILED: 'ISOLATION_FAILED', + INSUFFICIENT_CAPACITY: 'INSUFFICIENT_CAPACITY', + DEADLINE: 'TIMEOUT', + CANCELLED: 'CANCELLED', + PREREQUISITE: 'PREREQUISITE', + INCOMPATIBLE_INPUT: 'PREREQUISITE', + ASSERTION_FAILED: 'TOOL_FAILED', }; const code = codes[e.code]; - return { ...parseScannerOutput(plan, { stdout: '', exitCode: null, version }), status: 'not_assessed', candidates: [], gaps: [{ code, message: e.message }] }; + return { + ...parseScannerOutput(plan, { stdout: '', exitCode: null, version }), + status: 'not_assessed', + candidates: [], + gaps: [{ code, message: e.message }], + }; } /** Empty catalogs and missing assets produce coverage gaps without opening Docker. */ -export async function executeScanner(input: ScannerRunInput, dependencies: ScannerRunDependencies = {}): Promise { - const identityRequest = validateScannerRequest(input.request ?? {}, input.id), catalog = dependencies.catalog ?? SCANNER_CATALOG; +export async function executeScanner( + input: ScannerRunInput, + dependencies: ScannerRunDependencies = {}, +): Promise { + const identityRequest = validateScannerRequest(input.request ?? {}, input.id), + catalog = dependencies.catalog ?? SCANNER_CATALOG; const timeout = Math.min(300, Math.floor((input.executionDeadline - Date.now()) / 1000)); - let profile: QualifiedScanner | undefined, runtime: QualifiedRuntime | undefined, observedVersion: string | undefined, versionHash: string | null = null; - let application: ScannerApplicationPreparation | undefined,request=identityRequest; - let plan = scannerPlans({ snapshotRoot: '/source', offline: input.policy.offline, selected: [input.id], deadlineSeconds: Math.max(1, timeout) })[0]; + let profile: QualifiedScanner | undefined, + runtime: QualifiedRuntime | undefined, + observedVersion: string | undefined, + versionHash: string | null = null; + let application: ScannerApplicationPreparation | undefined, + request = identityRequest; + let plan = scannerPlans({ + snapshotRoot: '/source', + offline: input.policy.offline, + selected: [input.id], + deadlineSeconds: Math.max(1, timeout), + })[0]; let outcome: ScannerOutcome, runner: ScannerRunner | undefined; try { if (timeout < 1) throw new CsoError('DEADLINE', 'No scanner time remains before the reporting reserve'); assertSnapshot(input.runDir, input.manifest); - request=resolveScannerRequestPaths(input.manifest,identityRequest); - if (input.id === 'schemathesis' && input.policy.mode !== 'comprehensive') throw new CsoError('PREREQUISITE', 'Schemathesis requires comprehensive mode; daily audits do not execute applications'); + request = resolveScannerRequestPaths(input.manifest, identityRequest); + if (input.id === 'schemathesis' && input.policy.mode !== 'comprehensive') + throw new CsoError( + 'PREREQUISITE', + 'Schemathesis requires comprehensive mode; daily audits do not execute applications', + ); profile = selectScanner(input.id, input.platform, request.profile, catalog); const api = request.api; - plan = scannerPlans({ snapshotRoot: '/source', offline: input.policy.offline, selected: [input.id], deadlineSeconds: timeout, - tools: { [input.id]: { available: true, version: profile.version, capabilities: profile.capabilities } }, - semgrepRules: profile.assets?.semgrepRules?.path, advisoryCache: profile.assets?.advisoryDatabase?.path, - ...(api ? { schemaPath: '/policy/openapi.json', baseUrl: `http://127.0.0.1:${api.port}/`, operationIds: api.operationIds, seed: api.seed, maxExamples: api.maxExamples } : {}) })[0]; + plan = scannerPlans({ + snapshotRoot: '/source', + offline: input.policy.offline, + selected: [input.id], + deadlineSeconds: timeout, + tools: { + [input.id]: { available: true, version: profile.version, capabilities: profile.capabilities }, + }, + semgrepRules: profile.assets?.semgrepRules?.path, + advisoryCache: profile.assets?.advisoryDatabase?.path, + ...(api + ? { + schemaPath: '/policy/openapi.json', + baseUrl: `http://127.0.0.1:${api.port}/`, + operationIds: api.operationIds, + seed: api.seed, + maxExamples: api.maxExamples, + } + : {}), + })[0]; if (plan.prerequisites.length) throw new CsoError('PREREQUISITE', plan.prerequisites.join('; ')); if (input.id === 'schemathesis') { - if (!api) throw new CsoError('PREREQUISITE', 'Schemathesis requires a reviewed API harness and legitimate control'); + if (!api) + throw new CsoError( + 'PREREQUISITE', + 'Schemathesis requires a reviewed API harness and legitimate control', + ); for (const file of api.boundaryFiles) { - const entry = input.manifest.entries.find(e => e.path === file); - if (!entry || !entry.executionHash || entry.transformation) throw new CsoError('INCOMPATIBLE_INPUT', `API security boundary is missing or transformed: ${file}`); + const entry = input.manifest.entries.find((e) => e.path === file); + if (!entry || !entry.executionHash || entry.transformation) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + `API security boundary is missing or transformed: ${file}`, + ); } - try { runtime = selectRuntime(api.runtimeProfile, input.platform, dependencies.runtimes ?? RUNTIME_CATALOG); } - catch { throw new CsoError('PREREQUISITE', `Qualified application runtime is unavailable: ${api.runtimeProfile}`); } - if (!['node', 'bun', 'python', 'rails'].includes(runtime.stack)) throw new CsoError('INCOMPATIBLE_INPUT', 'Schemathesis requires a qualified application runtime'); - const stack = runtime.stack as CsoStack, sourceRoot = join(input.runDir, 'snapshot'); + try { + runtime = selectRuntime(api.runtimeProfile, input.platform, dependencies.runtimes ?? RUNTIME_CATALOG); + } catch { + throw new CsoError( + 'PREREQUISITE', + `Qualified application runtime is unavailable: ${api.runtimeProfile}`, + ); + } + if (!['node', 'bun', 'python', 'rails'].includes(runtime.stack)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Schemathesis requires a qualified application runtime'); + const stack = runtime.stack as CsoStack, + sourceRoot = join(input.runDir, 'snapshot'); const preparation = inspectPreparation(sourceRoot, stack); assertRuntimeCompatible(preparation, runtime); const startPlan = canonicalStartPlan(sourceRoot, stack, api.port); - if (canonical(api.start) !== canonical(startPlan.command)) throw new CsoError('INVALID_SCHEMA', `API start must use the helper-derived ${startPlan.kind} command`); - for (const file of startPlan.entrypointFiles) if (!api.boundaryFiles.includes(file)) - throw new CsoError('INVALID_SCHEMA', `API boundary files must include canonical startup input: ${file}`); + if (canonical(api.start) !== canonical(startPlan.command)) + throw new CsoError( + 'INVALID_SCHEMA', + `API start must use the helper-derived ${startPlan.kind} command`, + ); + for (const file of startPlan.entrypointFiles) + if (!api.boundaryFiles.includes(file)) + throw new CsoError( + 'INVALID_SCHEMA', + `API boundary files must include canonical startup input: ${file}`, + ); application = await (dependencies.applicationPreparer ?? prepareDockerScannerApplication)({ - input: { ...input, request }, runtime, stack, startPlan, - deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000), catalog: dependencies.runtimes ?? RUNTIME_CATALOG, + input: { ...input, request }, + runtime, + stack, + startPlan, + deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000), + catalog: dependencies.runtimes ?? RUNTIME_CATALOG, }); const preparedStart = canonicalStartPlan(application.sourceRoot, stack, api.port); - if (preparedStart.signature !== startPlan.signature || canonical(preparedStart.command) !== canonical(startPlan.command)) - throw new CsoError('ISOLATION_FAILED', 'Offline API preparation changed the canonical application startup inputs'); + if ( + preparedStart.signature !== startPlan.signature || + canonical(preparedStart.command) !== canonical(startPlan.command) + ) + throw new CsoError( + 'ISOLATION_FAILED', + 'Offline API preparation changed the canonical application startup inputs', + ); } - runner = await (dependencies.runnerFactory ?? createDockerScannerRunner)({ input: { ...input, request }, plan, profile, runtime, application, deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000) }); + runner = await (dependencies.runnerFactory ?? createDockerScannerRunner)({ + input: { ...input, request }, + plan, + profile, + runtime, + application, + deadline: Math.min(input.executionDeadline, Date.now() + timeout * 1000), + }); const version = await runner.version(); - if (version.exitCode !== 0 || version.timedOut || version.truncated || version.unavailable || Buffer.byteLength(version.stdout) + Buffer.byteLength(version.stderr ?? '') > 8192) throw new CsoError('TOOL_UNAVAILABLE', 'Scanner version probe did not complete within the qualified sandbox'); - assertScannerVersionOutput(profile.scanner,profile.version,version.stdout,version.stderr); + if ( + version.exitCode !== 0 || + version.timedOut || + version.truncated || + version.unavailable || + Buffer.byteLength(version.stdout) + Buffer.byteLength(version.stderr ?? '') > 8192 + ) + throw new CsoError( + 'TOOL_UNAVAILABLE', + 'Scanner version probe did not complete within the qualified sandbox', + ); + assertScannerVersionOutput(profile.scanner, profile.version, version.stdout, version.stderr); versionHash = scannerVersionHash(version.stdout, version.stderr); - if (versionHash !== profile.versionOutputSha256) throw new CsoError('INCOMPATIBLE_INPUT', 'Scanner version output does not match its reviewed image profile'); + if (versionHash !== profile.versionOutputSha256) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Scanner version output does not match its reviewed image profile', + ); observedVersion = profile.version; const execution = await runner.scan(); assertSnapshot(input.runDir, input.manifest); - outcome = parseScannerOutput(plan, { ...execution, version: profile.version, databaseUpdatedAt: profile.assets?.advisoryDatabase?.updatedAt }); - } catch (error) { outcome = failure(plan, error, observedVersion); } - finally { + outcome = parseScannerOutput(plan, { + ...execution, + version: profile.version, + databaseUpdatedAt: profile.assets?.advisoryDatabase?.updatedAt, + }); + } catch (error) { + outcome = failure(plan, error, observedVersion); + } finally { let cleanupError: unknown; - if (runner) try { await runner.cleanup(); } catch (error) { cleanupError = error; } - if (application) try { await application.cleanup(); } catch (error) { cleanupError ??= error; } + if (runner) + try { + await runner.cleanup(); + } catch (error) { + cleanupError = error; + } + if (application) + try { + await application.cleanup(); + } catch (error) { + cleanupError ??= error; + } if (cleanupError) outcome = failure(plan, cleanupError, observedVersion); } - return { outcome: outcome!, coverage: scannerCoverage(outcome!, input.policy.scope), provenance: { - scannerCatalog: catalog.revision, profile: profile?.id ?? null, image: profile?.image ?? null, platform: input.platform, - isolationPolicyHash: profile?.isolationPolicyHash ?? null, sourceHash: input.manifest.executionHash, requestHash: sha256(canonical(identityRequest)), versionOutputSha256: versionHash, - assets: profile?.assets ?? null, network: plan.network === 'loopback' ? 'isolated-loopback' : 'none', preparation: application?.proof ?? null } }; + return { + outcome: outcome!, + coverage: scannerCoverage(outcome!, input.policy.scope), + provenance: { + scannerCatalog: catalog.revision, + profile: profile?.id ?? null, + image: profile?.image ?? null, + platform: input.platform, + isolationPolicyHash: profile?.isolationPolicyHash ?? null, + sourceHash: input.manifest.executionHash, + requestHash: sha256(canonical(identityRequest)), + versionOutputSha256: versionHash, + assets: profile?.assets ?? null, + network: plan.network === 'loopback' ? 'isolated-loopback' : 'none', + preparation: application?.proof ?? null, + }, + }; } -export async function prepareDockerScannerApplication(context: Parameters[0],dependencies:{endpoint?:DockerEndpoint;runnerFactory?:(options:ConstructorParameters[0])=>PreparationSandboxRunner;cacheRoot?:string}={}): Promise { +export async function prepareDockerScannerApplication( + context: Parameters[0], + dependencies: { + endpoint?: DockerEndpoint; + runnerFactory?: ( + options: ConstructorParameters[0], + ) => PreparationSandboxRunner; + cacheRoot?: string; + } = {}, +): Promise { const { input, runtime, stack, deadline, catalog } = context; - const root = secureDirectory(join(input.runDir, 'supervision', `scanner-preparation-${randomBytes(12).toString('hex')}`)); - let executor: PreparationExecutor | undefined, prepared: Awaited> | undefined; + const root = secureDirectory( + join(input.runDir, 'supervision', `scanner-preparation-${randomBytes(12).toString('hex')}`), + ); + let executor: PreparationExecutor | undefined, + prepared: Awaited> | undefined; try { const plan = inspectPreparation(join(input.runDir, 'snapshot'), stack); - const admission = admitPreparationRuntime({ plan, platform: input.platform, profile: runtime.id, catalog }); - const endpoint = dependencies.endpoint??await dockerEndpoint(root),runnerOptions={ endpoint, watchdogPath: input.watchdogPath, - runRoot: root, controlRoot: secureDirectory(join(root, 'execution')), admission },runner=dependencies.runnerFactory?dependencies.runnerFactory(runnerOptions):new DockerPreparationSandboxRunner(runnerOptions); - executor = new PreparationExecutor({ cache: new PublicArchiveCache({ root: dependencies.cacheRoot??publicArchiveCacheRoot(), stagingRoot: secureDirectory(join(root, 'staging')) }), - runner, materializationRoot: secureDirectory(join(root, 'materializations')) }); - const closure = await executor.acquire({ plan, admission, snapshot: join(input.runDir, 'snapshot'), deadline, offline: input.policy.offline }); - let database:RailsDatabaseSelection|undefined; - if(stack==='rails'){ - if(!plan.database?.selected)throw new CsoError('PREREQUISITE','Rails API preparation could not select one locked database adapter'); - database=plan.database.selected==='postgresql' - ?{adapter:'postgresql',sidecar:admitPreparationSidecar({platform:input.platform,catalog})}:{adapter:'sqlite'}; + const admission = admitPreparationRuntime({ + plan, + platform: input.platform, + profile: runtime.id, + catalog, + }); + const endpoint = dependencies.endpoint ?? (await dockerEndpoint(root)), + runnerOptions = { + endpoint, + watchdogPath: input.watchdogPath, + runRoot: root, + controlRoot: secureDirectory(join(root, 'execution')), + admission, + }, + runner = dependencies.runnerFactory + ? dependencies.runnerFactory(runnerOptions) + : new DockerPreparationSandboxRunner(runnerOptions); + executor = new PreparationExecutor({ + cache: new PublicArchiveCache({ + root: dependencies.cacheRoot ?? publicArchiveCacheRoot(), + stagingRoot: secureDirectory(join(root, 'staging')), + }), + runner, + materializationRoot: secureDirectory(join(root, 'materializations')), + }); + const closure = await executor.acquire({ + plan, + admission, + snapshot: join(input.runDir, 'snapshot'), + deadline, + offline: input.policy.offline, + }); + let database: RailsDatabaseSelection | undefined; + if (stack === 'rails') { + if (!plan.database?.selected) + throw new CsoError( + 'PREREQUISITE', + 'Rails API preparation could not select one locked database adapter', + ); + database = + plan.database.selected === 'postgresql' + ? { adapter: 'postgresql', sidecar: admitPreparationSidecar({ platform: input.platform, catalog }) } + : { adapter: 'sqlite' }; } - prepared = await executor.prepareOffline({ plan, admission, snapshot: join(input.runDir, 'snapshot'), closure, deadline, database }); - const proof = { dependencyClosureHash: prepared.dependencyClosureHash, preparedManifestHash: prepared.preparedManifestHash, - sourceProjectionHash: prepared.sourceProjectionHash, receiptHash: prepared.receiptHash, - executionEnvironmentHash: sha256(canonical(prepared.executionEnvironment)), databaseHash: prepared.databaseHash }; + prepared = await executor.prepareOffline({ + plan, + admission, + snapshot: join(input.runDir, 'snapshot'), + closure, + deadline, + database, + }); + const proof = { + dependencyClosureHash: prepared.dependencyClosureHash, + preparedManifestHash: prepared.preparedManifestHash, + sourceProjectionHash: prepared.sourceProjectionHash, + receiptHash: prepared.receiptHash, + executionEnvironmentHash: sha256(canonical(prepared.executionEnvironment)), + databaseHash: prepared.databaseHash, + }; let cleaned = false; - return { sourceRoot: prepared.preparedRoot, environment: prepared.executionEnvironment, database: prepared.database, proof, cleanup: async () => { - if (cleaned) return; cleaned = true; - await executor!.dispose(prepared!); - fs.rmSync(root, { recursive: true, force: false }); - } }; + return { + sourceRoot: prepared.preparedRoot, + environment: prepared.executionEnvironment, + database: prepared.database, + proof, + cleanup: async () => { + if (cleaned) return; + cleaned = true; + await executor!.dispose(prepared!); + fs.rmSync(root, { recursive: true, force: false }); + }, + }; } catch (error) { - let cleanupError:unknown; - if (prepared && executor) try { await executor.dispose(prepared); } catch (failed) { cleanupError=failed; } + let cleanupError: unknown; + if (prepared && executor) + try { + await executor.dispose(prepared); + } catch (failed) { + cleanupError = failed; + } // A failed Docker/retained-copy cleanup deliberately hands ownership to a // detached watchdog. Its journals and label-sweep scratch files live below // this root, so only remove the tree after every watchdog acknowledged. - let pending=true;try{pending=hasPendingWatchdogCleanup(input.runDir);}catch(failed){cleanupError??=failed;} - if(!cleanupError&&!pending)try { fs.rmSync(root, { recursive: true, force: false }); } catch {} - if(cleanupError)throw cleanupError; + let pending = true; + try { + pending = hasPendingWatchdogCleanup(input.runDir); + } catch (failed) { + cleanupError ??= failed; + } + if (!cleanupError && !pending) + try { + fs.rmSync(root, { recursive: true, force: false }); + } catch {} + if (cleanupError) throw cleanupError; throw error; } } @@ -320,8 +696,10 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext): const policyDir = secureDirectory(join(controlDir, 'policy')); const files: Array<{ host: string; container: string }> = []; const writePolicy = (container: string, content: string): void => { - if (redact(content) !== content) throw new CsoError('REDACTION_FAILED', 'Scanner policy contains secret-bearing material'); - const host = join(policyDir, String(files.length)); fs.writeFileSync(host, content, { mode: 0o600, flag: 'wx' }); + if (redact(content) !== content) + throw new CsoError('REDACTION_FAILED', 'Scanner policy contains secret-bearing material'); + const host = join(policyDir, String(files.length)); + fs.writeFileSync(host, content, { mode: 0o600, flag: 'wx' }); files.push({ host, container }); }; let group: DockerGroup | undefined; @@ -329,12 +707,27 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext): for (const file of plan.trustedFiles) writePolicy(file.path, file.content); if (input.request?.api) writePolicy('/policy/openapi.json', JSON.stringify(input.request.api.schema)); const endpoint: DockerEndpoint = await dockerEndpoint(controlDir); - group = await DockerGroup.create(endpoint, attempt, controlDir, deadline, profile.image, input.watchdogPath); - const createScanner=()=>group!.createContainer({ role: runtime ? 'verifier' : 'app', image: profile.image, source: join(input.runDir, 'snapshot'), command: ['/bin/sleep', '2147483647'], env: plan.env, readonlyFiles: files }); + group = await DockerGroup.create( + endpoint, + attempt, + controlDir, + deadline, + profile.image, + input.watchdogPath, + ); + const createScanner = () => + group!.createContainer({ + role: runtime ? 'verifier' : 'app', + image: profile.image, + source: join(input.runDir, 'snapshot'), + command: ['/bin/sleep', '2147483647'], + env: plan.env, + readonlyFiles: files, + }); let scanner = await createScanner(); await group.start(scanner); const capture = async (command: string[]): Promise => { - if(!scanner)throw new CsoError('ISOLATION_FAILED','Scanner container is unavailable'); + if (!scanner) throw new CsoError('ISOLATION_FAILED', 'Scanner container is unavailable'); const result = await group!.execCapture(scanner, command, { workdir: '/work', env: plan.env }); return { stdout: result.stdout, stderr: result.stderr, exitCode: result.code }; }; @@ -343,41 +736,132 @@ export async function createDockerScannerRunner(context: ScannerRunnerContext): scan: async () => { const api = input.request?.api; if (api && runtime) { - if (!application) throw new CsoError('ISOLATION_FAILED', 'Schemathesis application was not materialized through offline preparation'); - const env={ ...application.environment, PORT: String(api.port), HOST: '127.0.0.1', NODE_ENV: 'test', RAILS_ENV: 'test', RACK_ENV: 'test', PYTHONUNBUFFERED: '1', CI: '1', SECRET_KEY_BASE: 'cso-synthetic-test-key' }; - const rails=runtime.stack==='rails'; - if(rails){await group!.removeContainer(scanner);scanner='';} - if(application.database?.adapter==='postgresql'){ - const databaseFile=join(policyDir,'postgresql.databases'),names=application.database.connections.map(name=>`cso_${name}`); - if(!names.length||names.some(name=>!/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name)))throw new CsoError('INCOMPATIBLE_INPUT','Prepared PostgreSQL connection names are invalid'); - fs.writeFileSync(databaseFile,names.join('\n')+'\n',{mode:0o444,flag:'wx'}); - const postgres=await group!.createContainer({role:'postgres',image:application.database.sidecar.image,command:['/opt/cso/run-postgresql','/policy/postgresql.databases'],postgresDatabasePolicy:databaseFile});await group!.start(postgres); - let ready=false;for(let attempt=0;attempt<100&&!ready;attempt++){const checked=await group!.execCapture(postgres,['/opt/cso/postgresql-ready','/policy/postgresql.databases']);ready=checked.code===0;if(!ready)await new Promise(resolveWait=>setTimeout(resolveWait,50));} - if(!ready)throw new CsoError('TOOL_FAILED','Disposable PostgreSQL did not become ready for Rails API scanning'); + if (!application) + throw new CsoError( + 'ISOLATION_FAILED', + 'Schemathesis application was not materialized through offline preparation', + ); + const env = { + ...application.environment, + PORT: String(api.port), + HOST: '127.0.0.1', + NODE_ENV: 'test', + RAILS_ENV: 'test', + RACK_ENV: 'test', + PYTHONUNBUFFERED: '1', + CI: '1', + SECRET_KEY_BASE: 'cso-synthetic-test-key', + }; + const rails = runtime.stack === 'rails'; + if (rails) { + await group!.removeContainer(scanner); + scanner = ''; } - const app = await group!.createContainer({ role: 'app', image: runtime.image, source: application.sourceRoot, env, - command:rails?['/opt/cso/run-app','/bin/sleep','2147483647']:['/opt/cso/run-app', api.start.executable, ...api.start.args] }); + if (application.database?.adapter === 'postgresql') { + const databaseFile = join(policyDir, 'postgresql.databases'), + names = application.database.connections.map((name) => `cso_${name}`); + if (!names.length || names.some((name) => !/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name))) + throw new CsoError('INCOMPATIBLE_INPUT', 'Prepared PostgreSQL connection names are invalid'); + fs.writeFileSync(databaseFile, names.join('\n') + '\n', { mode: 0o444, flag: 'wx' }); + const postgres = await group!.createContainer({ + role: 'postgres', + image: application.database.sidecar.image, + command: ['/opt/cso/run-postgresql', '/policy/postgresql.databases'], + postgresDatabasePolicy: databaseFile, + }); + await group!.start(postgres); + let ready = false; + for (let attempt = 0; attempt < 100 && !ready; attempt++) { + const checked = await group!.execCapture(postgres, [ + '/opt/cso/postgresql-ready', + '/policy/postgresql.databases', + ]); + ready = checked.code === 0; + if (!ready) await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + if (!ready) + throw new CsoError( + 'TOOL_FAILED', + 'Disposable PostgreSQL did not become ready for Rails API scanning', + ); + } + const app = await group!.createContainer({ + role: 'app', + image: runtime.image, + source: application.sourceRoot, + env, + command: rails + ? ['/opt/cso/run-app', '/bin/sleep', '2147483647'] + : ['/opt/cso/run-app', api.start.executable, ...api.start.args], + }); await group!.start(app); - if(rails){const clean=['/usr/bin/env','-i',...Object.entries(env).sort(([a],[b])=>a.localeCompare(b)).map(([key,value])=>`${key}=${value}`),'/usr/local/bin/bundle','exec','rails','db:prepare'];const prepared=await group!.execCapture(app,clean,{workdir:'/work'});if(prepared.code!==0)throw new CsoError('TOOL_FAILED','Rails API database preparation failed');await group!.execDetached(app,[api.start.executable,...api.start.args]);} - const security = { ...api.control, vulnerable: { status: api.control.expected.status === 599 ? 598 : 599 } }; + if (rails) { + const clean = [ + '/usr/bin/env', + '-i', + ...Object.entries(env) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`), + '/usr/local/bin/bundle', + 'exec', + 'rails', + 'db:prepare', + ]; + const prepared = await group!.execCapture(app, clean, { workdir: '/work' }); + if (prepared.code !== 0) + throw new CsoError('TOOL_FAILED', 'Rails API database preparation failed'); + await group!.execDetached(app, [api.start.executable, ...api.start.args]); + } + const security = { + ...api.control, + vulnerable: { status: api.control.expected.status === 599 ? 598 : 599 }, + }; const controlFile = join(policyDir, 'control.json'); - fs.writeFileSync(controlFile, JSON.stringify({ phase: 'after', port: api.port, legitimate: [api.control], security }), { mode: 0o600, flag: 'wx' }); - const probe = await group!.createContainer({ role: schemathesisControlRole(), image: runtime.image, command: ['/opt/cso/verifier', '/policy/control.json'], readonlyFiles: [{ host: controlFile, container: '/policy/control.json' }] }); - const observed = await group!.startAttach(probe); await group!.removeContainer(probe); + fs.writeFileSync( + controlFile, + JSON.stringify({ phase: 'after', port: api.port, legitimate: [api.control], security }), + { mode: 0o600, flag: 'wx' }, + ); + const probe = await group!.createContainer({ + role: schemathesisControlRole(), + image: runtime.image, + command: ['/opt/cso/verifier', '/policy/control.json'], + readonlyFiles: [{ host: controlFile, container: '/policy/control.json' }], + }); + const observed = await group!.startAttach(probe); + await group!.removeContainer(probe); let valid = false; - try { const v = validateVerificationObservation(JSON.parse(observed.output)); valid = observed.code === 0 && v.booted && v.legitimate && v.security === 'pass'; } catch {} - if (!valid) throw new CsoError('PREREQUISITE', 'API application boot or legitimate control failed; no Schemathesis requests were sent'); - if(rails){scanner=await createScanner();await group!.start(scanner);} + try { + const v = validateVerificationObservation(JSON.parse(observed.output)); + valid = observed.code === 0 && v.booted && v.legitimate && v.security === 'pass'; + } catch {} + if (!valid) + throw new CsoError( + 'PREREQUISITE', + 'API application boot or legitimate control failed; no Schemathesis requests were sent', + ); + if (rails) { + scanner = await createScanner(); + await group!.start(scanner); + } } const execution = await capture([profile.executable, ...plan.args]); if (plan.outputPath) { const report = await capture(['/bin/cat', plan.outputPath]); - if (report.exitCode !== 0) throw new CsoError('PREREQUISITE', 'Scanner did not produce its required bounded report file'); - return { ...execution, stdout: report.stdout, stderr: [execution.stderr, report.stderr].filter(Boolean).join('\n') }; + if (report.exitCode !== 0) + throw new CsoError('PREREQUISITE', 'Scanner did not produce its required bounded report file'); + return { + ...execution, + stdout: report.stdout, + stderr: [execution.stderr, report.stderr].filter(Boolean).join('\n'), + }; } return execution; }, - cleanup: async () => { await group!.cleanup(); fs.rmSync(policyDir, { recursive: true, force: true }); }, + cleanup: async () => { + await group!.cleanup(); + fs.rmSync(policyDir, { recursive: true, force: true }); + }, }; } catch (error) { if (group) await group.cleanup(); diff --git a/lib/cso/scanners.ts b/lib/cso/scanners.ts index 85c0463aa..7a9c32dec 100644 --- a/lib/cso/scanners.ts +++ b/lib/cso/scanners.ts @@ -8,8 +8,9 @@ import { posix } from 'node:path'; import { redactFindingSpans } from '../redact-engine'; export const SCANNER_IDS = ['gitleaks', 'osv', 'semgrep', 'zizmor', 'trivy', 'schemathesis'] as const; -export type ScannerId = typeof SCANNER_IDS[number]; -export type ScannerFormat = 'gitleaks-json' | 'osv-json' | 'semgrep-json' | 'sarif' | 'trivy-json' | 'schemathesis-json'; +export type ScannerId = (typeof SCANNER_IDS)[number]; +export type ScannerFormat = + 'gitleaks-json' | 'osv-json' | 'semgrep-json' | 'sarif' | 'trivy-json' | 'schemathesis-json'; export const MAX_SCANNER_OUTPUT_BYTES = 1_048_576; const MAX_CANDIDATES = 5_000; @@ -63,7 +64,13 @@ export interface ScannerCandidate { reportedSeverity: 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown'; location?: { path: string; line?: number; column?: number }; advisoryIds: string[]; - dependency?: { name: string; version?: string; ecosystem?: string; reachability: 'unknown'; exposure: 'unknown' }; + dependency?: { + name: string; + version?: string; + ecosystem?: string; + reachability: 'unknown'; + exposure: 'unknown'; + }; operation?: string; suppressed: boolean; evidence: 'scanner-candidate'; @@ -71,10 +78,25 @@ export interface ScannerCandidate { } export interface ScannerGap { - code: 'UNAVAILABLE' | 'PREREQUISITE' | 'TIMEOUT' | 'OUTPUT_LIMIT' | 'INVALID_OUTPUT' | 'TOOL_FAILED' | - 'REDACTION_FAILED' | 'ISOLATION_FAILED' | 'PERSISTENCE_FAILED' | 'SNAPSHOT_RACE' | 'CANCELLED' | - 'INSUFFICIENT_CAPACITY' | 'UNSAFE_PATH' | 'MISSING_INPUT' | 'INCOMPATIBLE_INPUT' | - 'UNSAFE_LOCATION' | 'SKIPPED_INPUT' | 'UNKNOWN_FRESHNESS'; + code: + | 'UNAVAILABLE' + | 'PREREQUISITE' + | 'TIMEOUT' + | 'OUTPUT_LIMIT' + | 'INVALID_OUTPUT' + | 'TOOL_FAILED' + | 'REDACTION_FAILED' + | 'ISOLATION_FAILED' + | 'PERSISTENCE_FAILED' + | 'SNAPSHOT_RACE' + | 'CANCELLED' + | 'INSUFFICIENT_CAPACITY' + | 'UNSAFE_PATH' + | 'MISSING_INPUT' + | 'INCOMPATIBLE_INPUT' + | 'UNSAFE_LOCATION' + | 'SKIPPED_INPUT' + | 'UNKNOWN_FRESHNESS'; message: string; } @@ -109,34 +131,64 @@ export interface ScannerExecution { const SOURCES: Record = { gitleaks: ['https://github.com/gitleaks/gitleaks/blob/master/README.md'], - osv: ['https://google.github.io/osv-scanner/usage/scan-source/', 'https://google.github.io/osv-scanner/usage/offline-mode/'], + osv: [ + 'https://google.github.io/osv-scanner/usage/scan-source/', + 'https://google.github.io/osv-scanner/usage/offline-mode/', + ], semgrep: ['https://docs.semgrep.dev/cli-reference'], zizmor: ['https://docs.zizmor.sh/usage/', 'https://docs.zizmor.sh/quickstart/'], - trivy: ['https://trivy.dev/docs/dev/docs/advanced/telemetry/', 'https://trivy.dev/docs/latest/guide/advanced/air-gap/'], - schemathesis: ['https://schemathesis.readthedocs.io/en/stable/reference/cli/', 'https://github.com/schemathesis/schemathesis/blob/master/src/schemathesis/cli/json_report.py'], + trivy: [ + 'https://trivy.dev/docs/dev/docs/advanced/telemetry/', + 'https://trivy.dev/docs/latest/guide/advanced/air-gap/', + ], + schemathesis: [ + 'https://schemathesis.readthedocs.io/en/stable/reference/cli/', + 'https://github.com/schemathesis/schemathesis/blob/master/src/schemathesis/cli/json_report.py', + ], }; function absolutePath(value: string, name: string): string { - if (value === '/' || !value.startsWith('/') || value.startsWith('//') || /[\x00-\x1f\\]/.test(value) || value.split('/').includes('..')) { + if ( + value === '/' || + !value.startsWith('/') || + value.startsWith('//') || + /[\x00-\x1f\\]/.test(value) || + value.split('/').includes('..') + ) { throw new Error(`${name} must be an absolute sandbox path without traversal`); } return posix.normalize(value); } function positiveInteger(value: number, max: number, name: string): number { - if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`${name} must be between 1 and ${max}`); + if (!Number.isSafeInteger(value) || value < 1 || value > max) + throw new Error(`${name} must be between 1 and ${max}`); return value; } /** Numeric loopback only: no DNS, URL credentials, redirected targets, or remote schemas. */ export function validateScannerBaseUrl(raw: string): string { let url: URL; - try { url = new URL(raw); } catch { throw new Error('Schemathesis requires a numeric loopback HTTP URL'); } - if (!['http:', 'https:'].includes(url.protocol) || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.hash || url.search) { - throw new Error('Schemathesis requires a numeric loopback HTTP URL without credentials, query, or fragment'); + try { + url = new URL(raw); + } catch { + throw new Error('Schemathesis requires a numeric loopback HTTP URL'); + } + if ( + !['http:', 'https:'].includes(url.protocol) || + !['127.0.0.1', '[::1]'].includes(url.hostname) || + url.username || + url.password || + url.hash || + url.search + ) { + throw new Error( + 'Schemathesis requires a numeric loopback HTTP URL without credentials, query, or fragment', + ); } // URL canonicalization accepts integer, hex, and shorthand IPv4. Reject these spellings. - if (!/^https?:\/\/(127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/.test(raw)) throw new Error('Schemathesis requires canonical numeric loopback'); + if (!/^https?:\/\/(127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/.test(raw)) + throw new Error('Schemathesis requires canonical numeric loopback'); return url.href; } @@ -148,99 +200,288 @@ export function validateScannerBaseUrl(raw: string): string { export function scannerPlans(opts: ScannerOptions): ScannerPlan[] { const root = absolutePath(opts.snapshotRoot, 'snapshotRoot'); const policy = absolutePath(opts.policyRoot ?? '/policy', 'policyRoot'); - if (policy === root || policy.startsWith(`${root}/`) || root.startsWith(`${policy}/`)) throw new Error('policyRoot must be separate from source'); + if (policy === root || policy.startsWith(`${root}/`) || root.startsWith(`${policy}/`)) + throw new Error('policyRoot must be separate from source'); const cache = opts.advisoryCache ? absolutePath(opts.advisoryCache, 'advisoryCache') : undefined; - if (cache && (cache === root || cache.startsWith(`${root}/`))) throw new Error('advisoryCache must be separate from source'); + if (cache && (cache === root || cache.startsWith(`${root}/`))) + throw new Error('advisoryCache must be separate from source'); const timeout = positiveInteger(opts.deadlineSeconds ?? 120, 300, 'deadlineSeconds'); const selected = opts.selected ?? [...SCANNER_IDS]; - if (new Set(selected).size !== selected.length || selected.some(id => !SCANNER_IDS.includes(id))) throw new Error('Invalid or duplicate scanner selection'); - return selected.map(id => { + if (new Set(selected).size !== selected.length || selected.some((id) => !SCANNER_IDS.includes(id))) + throw new Error('Invalid or duplicate scanner selection'); + return selected.map((id) => { const plan: ScannerPlan = { - id, executableName: id === 'osv' ? 'osv-scanner' : id, args: [], versionArgs: ['--version'], requiredFeatures: [], - format: 'sarif', execution: 'sandbox', network: 'none', cwd: '/work', sourceRoot: root, - env: { HOME: '/work/home', TMPDIR: '/tmp', LANG: 'C.UTF-8', NO_COLOR: '1' }, trustedFiles: [], prerequisites: [], - timeoutSeconds: timeout, maxOutputBytes: MAX_SCANNER_OUTPUT_BYTES, - coverage: { domain: id, scope: [root], exclusions: ['Snapshot transformations apply; inspect the snapshot manifest.'] }, - provenanceSources: SOURCES[id], documentationInspectedAt: '2026-09-09', + id, + executableName: id === 'osv' ? 'osv-scanner' : id, + args: [], + versionArgs: ['--version'], + requiredFeatures: [], + format: 'sarif', + execution: 'sandbox', + network: 'none', + cwd: '/work', + sourceRoot: root, + env: { HOME: '/work/home', TMPDIR: '/tmp', LANG: 'C.UTF-8', NO_COLOR: '1' }, + trustedFiles: [], + prerequisites: [], + timeoutSeconds: timeout, + maxOutputBytes: MAX_SCANNER_OUTPUT_BYTES, + coverage: { + domain: id, + scope: [root], + exclusions: ['Snapshot transformations apply; inspect the snapshot manifest.'], + }, + provenanceSources: SOURCES[id], + documentationInspectedAt: '2026-09-09', }; - if (opts.tools?.[id]?.available === false) plan.prerequisites.push(`Install a reviewed ${plan.executableName} executable in the scanner image.`); + if (opts.tools?.[id]?.available === false) + plan.prerequisites.push(`Install a reviewed ${plan.executableName} executable in the scanner image.`); switch (id) { case 'gitleaks': { const target = opts.gitHistory ? absolutePath(opts.gitHistory, 'gitHistory') : root; plan.format = 'gitleaks-json'; plan.coverage.domain = 'secrets'; plan.coverage.scope = [target]; - plan.trustedFiles.push({ path: `${policy}/gitleaks.toml`, content: '[extend]\nuseDefault = true\n' }, { path: `${policy}/gitleaksignore`, content: '' }); - plan.args = [opts.gitHistory ? 'git' : 'dir', '--redact=100', '--no-banner', '--no-color', '--ignore-gitleaks-allow', '--gitleaks-ignore-path', `${policy}/gitleaksignore`, '--config', `${policy}/gitleaks.toml`, '--report-format=json', '--report-path=-', '--exit-code=10', '--timeout', String(timeout), target]; - if (opts.gitHistory) plan.prerequisites.push('History input must be a sanitized Git object store with trusted config and no hooks, filters, alternates, or external helpers.'); + plan.trustedFiles.push( + { path: `${policy}/gitleaks.toml`, content: '[extend]\nuseDefault = true\n' }, + { path: `${policy}/gitleaksignore`, content: '' }, + ); + plan.args = [ + opts.gitHistory ? 'git' : 'dir', + '--redact=100', + '--no-banner', + '--no-color', + '--ignore-gitleaks-allow', + '--gitleaks-ignore-path', + `${policy}/gitleaksignore`, + '--config', + `${policy}/gitleaks.toml`, + '--report-format=json', + '--report-path=-', + '--exit-code=10', + '--timeout', + String(timeout), + target, + ]; + if (opts.gitHistory) + plan.prerequisites.push( + 'History input must be a sanitized Git object store with trusted config and no hooks, filters, alternates, or external helpers.', + ); else plan.coverage.exclusions.push('Historical revisions are not scanned by this directory pass.'); plan.requiredFeatures = ['dir', '--redact', '--ignore-gitleaks-allow']; break; } case 'osv': - plan.format = 'osv-json'; plan.coverage.domain = 'dependencies'; + plan.format = 'osv-json'; + plan.coverage.domain = 'dependencies'; plan.trustedFiles.push({ path: `${policy}/osv-scanner.toml`, content: '' }); - plan.args = ['scan', 'source', '--format=json', '--offline', '--no-call-analysis=all', '--config', `${policy}/osv-scanner.toml`, '--recursive', root]; + plan.args = [ + 'scan', + 'source', + '--format=json', + '--offline', + '--no-call-analysis=all', + '--config', + `${policy}/osv-scanner.toml`, + '--recursive', + root, + ]; plan.requiredFeatures = ['scan source', '--offline', '--no-call-analysis']; if (cache) plan.env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY = cache; else plan.prerequisites.push('Provide verified offline OSV databases for every assessed ecosystem.'); - plan.coverage.exclusions.push('Call analysis is disabled; dependency reachability remains unknown until independently investigated.'); + plan.coverage.exclusions.push( + 'Call analysis is disabled; dependency reachability remains unknown until independently investigated.', + ); break; case 'semgrep': { - plan.format = 'semgrep-json'; plan.coverage.domain = 'code'; - const rules = opts.semgrepRules ? absolutePath(opts.semgrepRules, 'semgrepRules') : `${policy}/semgrep.yml`; - if (!rules.startsWith(`${policy}/`)) throw new Error('Semgrep rules must be below the trusted policyRoot'); - if (!opts.semgrepRules) plan.prerequisites.push('Provide a reviewed, pinned local Semgrep ruleset; registry aliases and repo rules are not accepted.'); - plan.args = ['scan', '--json', '--config', rules, '--metrics=off', '--disable-version-check', '--disable-nosem', '--no-git-ignore', '--no-secrets-validation', '--oss-only', '--no-autofix', '--timeout=10', '--timeout-threshold=3', '--jobs=1', root]; - plan.env.SEMGREP_SEND_METRICS = 'off'; plan.env.SEMGREP_ENABLE_VERSION_CHECK = '0'; plan.env.SEMGREP_APP_TOKEN = ''; - plan.requiredFeatures = ['scan', '--metrics', '--disable-version-check', '--no-secrets-validation', '--oss-only']; - plan.coverage.exclusions.push('Semgrep language support, built-in file selection, and .semgrepignore rules can exclude inputs; independently inspect these exclusions.'); + plan.format = 'semgrep-json'; + plan.coverage.domain = 'code'; + const rules = opts.semgrepRules + ? absolutePath(opts.semgrepRules, 'semgrepRules') + : `${policy}/semgrep.yml`; + if (!rules.startsWith(`${policy}/`)) + throw new Error('Semgrep rules must be below the trusted policyRoot'); + if (!opts.semgrepRules) + plan.prerequisites.push( + 'Provide a reviewed, pinned local Semgrep ruleset; registry aliases and repo rules are not accepted.', + ); + plan.args = [ + 'scan', + '--json', + '--config', + rules, + '--metrics=off', + '--disable-version-check', + '--disable-nosem', + '--no-git-ignore', + '--no-secrets-validation', + '--oss-only', + '--no-autofix', + '--timeout=10', + '--timeout-threshold=3', + '--jobs=1', + root, + ]; + plan.env.SEMGREP_SEND_METRICS = 'off'; + plan.env.SEMGREP_ENABLE_VERSION_CHECK = '0'; + plan.env.SEMGREP_APP_TOKEN = ''; + plan.requiredFeatures = [ + 'scan', + '--metrics', + '--disable-version-check', + '--no-secrets-validation', + '--oss-only', + ]; + plan.coverage.exclusions.push( + 'Semgrep language support, built-in file selection, and .semgrepignore rules can exclude inputs; independently inspect these exclusions.', + ); break; } case 'zizmor': plan.coverage.domain = 'github-actions'; - plan.args = ['--offline', '--no-config', '--no-ignores', '--no-exit-codes', '--no-progress', '--color=never', '--format=sarif', root]; - plan.env.ZIZMOR_OFFLINE = '1'; plan.requiredFeatures = ['--offline', '--no-config', '--no-ignores']; - plan.coverage.exclusions.push('Online GitHub audits and remote reusable action inspection require separate assessment.'); + plan.args = [ + '--offline', + '--no-config', + '--no-ignores', + '--no-exit-codes', + '--no-progress', + '--color=never', + '--format=sarif', + root, + ]; + plan.env.ZIZMOR_OFFLINE = '1'; + plan.requiredFeatures = ['--offline', '--no-config', '--no-ignores']; + plan.coverage.exclusions.push( + 'Online GitHub audits and remote reusable action inspection require separate assessment.', + ); break; case 'trivy': - plan.format = 'trivy-json'; plan.coverage.domain = 'dependencies-and-infrastructure'; - plan.trustedFiles.push({ path: `${policy}/trivy.yaml`, content: '{}\n' }, { path: `${policy}/trivyignore`, content: '' }); - plan.args = ['fs', '--format=json', '--config', `${policy}/trivy.yaml`, '--ignorefile', `${policy}/trivyignore`, '--scanners=vuln,misconfig,secret', '--cache-backend=memory', '--disable-telemetry', '--offline-scan', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-version-check', '--skip-vex-repo-update', '--timeout', `${timeout}s`, ...(cache ? ['--cache-dir', cache] : []), root]; + plan.format = 'trivy-json'; + plan.coverage.domain = 'dependencies-and-infrastructure'; + plan.trustedFiles.push( + { path: `${policy}/trivy.yaml`, content: '{}\n' }, + { path: `${policy}/trivyignore`, content: '' }, + ); + plan.args = [ + 'fs', + '--format=json', + '--config', + `${policy}/trivy.yaml`, + '--ignorefile', + `${policy}/trivyignore`, + '--scanners=vuln,misconfig,secret', + '--cache-backend=memory', + '--disable-telemetry', + '--offline-scan', + '--skip-db-update', + '--skip-java-db-update', + '--skip-check-update', + '--skip-version-check', + '--skip-vex-repo-update', + '--timeout', + `${timeout}s`, + ...(cache ? ['--cache-dir', cache] : []), + root, + ]; plan.env.TRIVY_DISABLE_TELEMETRY = 'true'; - plan.requiredFeatures = ['--cache-backend', '--disable-telemetry', '--offline-scan', '--skip-db-update', '--skip-java-db-update', '--skip-check-update', '--skip-version-check', '--skip-vex-repo-update']; - if (!cache) plan.prerequisites.push('Provide verified offline Trivy vulnerability, Java, and misconfiguration databases as needed.'); + plan.requiredFeatures = [ + '--cache-backend', + '--disable-telemetry', + '--offline-scan', + '--skip-db-update', + '--skip-java-db-update', + '--skip-check-update', + '--skip-version-check', + '--skip-vex-repo-update', + ]; + if (!cache) + plan.prerequisites.push( + 'Provide verified offline Trivy vulnerability, Java, and misconfiguration databases as needed.', + ); break; case 'schemathesis': { - plan.format = 'schemathesis-json'; plan.network = 'loopback'; plan.coverage.domain = 'api-runtime'; + plan.format = 'schemathesis-json'; + plan.network = 'loopback'; + plan.coverage.domain = 'api-runtime'; plan.outputPath = '/work/schemathesis.json'; // The upstream image enables a Python hook module and coverage plugin by // default. Qualified CSO scans use only the reviewed schema/config. - plan.env.SCHEMATHESIS_HOOKS = ''; plan.env.SCHEMATHESIS_COVERAGE = 'false'; + plan.env.SCHEMATHESIS_HOOKS = ''; + plan.env.SCHEMATHESIS_COVERAGE = 'false'; plan.trustedFiles.push({ path: `${policy}/schemathesis.toml`, content: '' }); - const schema = opts.schemaPath ? absolutePath(opts.schemaPath, 'schemaPath') : `${policy}/openapi.json`; - if (!schema.startsWith(`${policy}/`)) throw new Error('Schemathesis schema must be below trusted policyRoot'); - if (!opts.schemaPath) plan.prerequisites.push('Provide a reviewed local schema with resolved local references, no remote references, and no hook imports.'); + const schema = opts.schemaPath + ? absolutePath(opts.schemaPath, 'schemaPath') + : `${policy}/openapi.json`; + if (!schema.startsWith(`${policy}/`)) + throw new Error('Schemathesis schema must be below trusted policyRoot'); + if (!opts.schemaPath) + plan.prerequisites.push( + 'Provide a reviewed local schema with resolved local references, no remote references, and no hook imports.', + ); const base = opts.baseUrl ? validateScannerBaseUrl(opts.baseUrl) : 'http://127.0.0.1:3000/'; - if (!opts.baseUrl) plan.prerequisites.push('Start the application and a legitimate control in the admitted loopback namespace.'); + if (!opts.baseUrl) + plan.prerequisites.push( + 'Start the application and a legitimate control in the admitted loopback namespace.', + ); const seed = positiveInteger(opts.seed ?? 1, 2_147_483_647, 'seed'); const examples = positiveInteger(opts.maxExamples ?? 20, 100, 'maxExamples'); const operations = opts.operationIds ?? []; - if (operations.length === 0 || operations.length > 20) plan.prerequisites.push('Declare between 1 and 20 reviewed operation IDs to bound the API assessment.'); - if (operations.some(op => !op || op.length > 200 || /[\x00-\x1f]/.test(op))) throw new Error('Invalid Schemathesis operation ID'); - plan.args = ['--config-file', `${policy}/schemathesis.toml`, '--no-color', 'run', schema, '--url', base, '--workers=1', '--phases=fuzzing', '--max-examples', String(examples), '--max-failures=10', '--max-time', String(timeout), '--seed', String(seed), '--request-timeout=5', '--request-retries=0', '--max-redirects=0', '--rate-limit=10/s', '--output-sanitize=true', '--generation-database=none', '--report-json-path', plan.outputPath, ...operations.flatMap(op => ['--include-operation-id', op])]; - plan.requiredFeatures = ['--report-json-path', '--max-time', '--seed', '--max-redirects', '--include-operation-id']; - plan.coverage.scope = operations.map(op => `operation:${op}`); - plan.coverage.exclusions.push('Only declared operations and generated examples are exercised; API failures are candidates, not security proofs.'); + if (operations.length === 0 || operations.length > 20) + plan.prerequisites.push( + 'Declare between 1 and 20 reviewed operation IDs to bound the API assessment.', + ); + if (operations.some((op) => !op || op.length > 200 || /[\x00-\x1f]/.test(op))) + throw new Error('Invalid Schemathesis operation ID'); + plan.args = [ + '--config-file', + `${policy}/schemathesis.toml`, + '--no-color', + 'run', + schema, + '--url', + base, + '--workers=1', + '--phases=fuzzing', + '--max-examples', + String(examples), + '--max-failures=10', + '--max-time', + String(timeout), + '--seed', + String(seed), + '--request-timeout=5', + '--request-retries=0', + '--max-redirects=0', + '--rate-limit=10/s', + '--output-sanitize=true', + '--generation-database=none', + '--report-json-path', + plan.outputPath, + ...operations.flatMap((op) => ['--include-operation-id', op]), + ]; + plan.requiredFeatures = [ + '--report-json-path', + '--max-time', + '--seed', + '--max-redirects', + '--include-operation-id', + ]; + plan.coverage.scope = operations.map((op) => `operation:${op}`); + plan.coverage.exclusions.push( + 'Only declared operations and generated examples are exercised; API failures are candidates, not security proofs.', + ); break; } } const capabilities = opts.tools?.[id]?.capabilities; - if (capabilities) for (const required of plan.requiredFeatures) { - if (!capabilities.includes(required)) plan.prerequisites.push(`${plan.executableName} lacks required capability ${required}.`); - } + if (capabilities) + for (const required of plan.requiredFeatures) { + if (!capabilities.includes(required)) + plan.prerequisites.push(`${plan.executableName} lacks required capability ${required}.`); + } const version = opts.tools?.[id]?.version; - if (id === 'osv' && version && !/\b(?:v)?2\./.test(version)) plan.prerequisites.push('OSV-Scanner major version 2 is required.'); + if (id === 'osv' && version && !/\b(?:v)?2\./.test(version)) + plan.prerequisites.push('OSV-Scanner major version 2 is required.'); return plan; }); } @@ -258,7 +499,9 @@ function str(value: unknown): string { if (typeof value !== 'string' || value.length > 16_384) throw new Error('Expected bounded string'); return value; } -function optionalString(value: unknown): string | undefined { return value === undefined || value === null ? undefined : str(value); } +function optionalString(value: unknown): string | undefined { + return value === undefined || value === null ? undefined : str(value); +} function integer(value: unknown): number | undefined { if (value === undefined) return undefined; if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error('Invalid source coordinate'); @@ -266,20 +509,32 @@ function integer(value: unknown): number | undefined { } function severity(value: unknown): ScannerCandidate['reportedSeverity'] { const normalized = typeof value === 'string' ? value.toLowerCase() : ''; - if (['critical', 'high', 'medium', 'low', 'info'].includes(normalized)) return normalized as ScannerCandidate['reportedSeverity']; - return ({ error: 'high', warning: 'medium', note: 'info', informational: 'info', unknown: 'unknown' } as const)[normalized] ?? 'unknown'; + if (['critical', 'high', 'medium', 'low', 'info'].includes(normalized)) + return normalized as ScannerCandidate['reportedSeverity']; + return ( + ({ error: 'high', warning: 'medium', note: 'info', informational: 'info', unknown: 'unknown' } as const)[ + normalized + ] ?? 'unknown' + ); } /** No path is opened by this module. Normalization refuses URI/traversal escapes. */ export function scannerLocation(raw: string, sourceRoot: string): string { let decoded: string; - try { decoded = decodeURIComponent(raw); } catch { throw new Error('Unsafe location'); } - if (/[\x00-\x1f\x7f]/.test(decoded) || /%[\da-f]{2}/i.test(decoded) || decoded.includes('\\')) throw new Error('Unsafe location'); + try { + decoded = decodeURIComponent(raw); + } catch { + throw new Error('Unsafe location'); + } + if (/[\x00-\x1f\x7f]/.test(decoded) || /%[\da-f]{2}/i.test(decoded) || decoded.includes('\\')) + throw new Error('Unsafe location'); if (decoded.startsWith('file:')) { const url = new URL(decoded); - if (url.hostname || url.username || url.password || url.search || url.hash) throw new Error('Unsafe file URI'); + if (url.hostname || url.username || url.password || url.search || url.hash) + throw new Error('Unsafe file URI'); decoded = decodeURIComponent(url.pathname); - } else if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || decoded.startsWith('//')) throw new Error('Unsafe location'); + } else if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || decoded.startsWith('//')) + throw new Error('Unsafe location'); if (decoded.split('/').includes('..')) throw new Error('Unsafe location'); const root = absolutePath(sourceRoot, 'sourceRoot'); const absolute = decoded.startsWith('/') ? posix.normalize(decoded) : posix.join(root, decoded); @@ -314,32 +569,66 @@ function decodedDocument(raw: string): unknown { return document; } -function candidate(tool: ScannerCandidate['tool'], fields: Omit & { suppressed?: boolean }): ScannerCandidate { - const identity = [tool, fields.ruleId, fields.location?.path ?? fields.operation ?? '', fields.location?.line ?? '', ...fields.advisoryIds.slice().sort()]; +function candidate( + tool: ScannerCandidate['tool'], + fields: Omit & { + suppressed?: boolean; + }, +): ScannerCandidate { + const identity = [ + tool, + fields.ruleId, + fields.location?.path ?? fields.operation ?? '', + fields.location?.line ?? '', + ...fields.advisoryIds.slice().sort(), + ]; const id = createHash('sha256').update(JSON.stringify(identity)).digest('hex'); - return { ...fields, id, tool, suppressed: fields.suppressed ?? false, evidence: 'scanner-candidate', trust: 'untrusted' }; + return { + ...fields, + id, + tool, + suppressed: fields.suppressed ?? false, + evidence: 'scanner-candidate', + trust: 'untrusted', + }; } function location(path: unknown, line: unknown, column: unknown, root: string): ScannerCandidate['location'] { return { path: scannerLocation(str(path), root), line: integer(line), column: integer(column) }; } -function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: string, add: (value: ScannerCandidate) => void, gap: (code: ScannerGap['code'], message: string) => void): void { +function parseSarif( + document: unknown, + tool: ScannerCandidate['tool'], + root: string, + add: (value: ScannerCandidate) => void, + gap: (code: ScannerGap['code'], message: string) => void, +): void { const sarif = obj(document); if (sarif.version !== '2.1.0') throw new Error('SARIF 2.1.0 required'); const runs = arr(sarif.runs); - if (!runs.length) { gap('SKIPPED_INPUT', 'SARIF contains no assessment runs.'); return; } + if (!runs.length) { + gap('SKIPPED_INPUT', 'SARIF contains no assessment runs.'); + return; + } for (const input of runs) { - const run = obj(input); const driver = obj(obj(run.tool).driver); + const run = obj(input); + const driver = obj(obj(run.tool).driver); str(driver.name); if (run.externalPropertyFileReferences !== undefined) { const refs = obj(run.externalPropertyFileReferences); - if (refs.results !== undefined && arr(refs.results).length) gap('SKIPPED_INPUT', 'External SARIF result files were not fetched or assessed.'); + if (refs.results !== undefined && arr(refs.results).length) + gap('SKIPPED_INPUT', 'External SARIF result files were not fetched or assessed.'); } for (const invocation of run.invocations === undefined ? [] : arr(run.invocations)) { const inv = obj(invocation); - if (inv.executionSuccessful === false) gap('TOOL_FAILED', 'SARIF records an unsuccessful tool invocation.'); - if (Array.isArray(inv.toolExecutionNotifications) && inv.toolExecutionNotifications.some(n => obj(n).level === 'error')) gap('TOOL_FAILED', 'SARIF records tool execution errors.'); + if (inv.executionSuccessful === false) + gap('TOOL_FAILED', 'SARIF records an unsuccessful tool invocation.'); + if ( + Array.isArray(inv.toolExecutionNotifications) && + inv.toolExecutionNotifications.some((n) => obj(n).level === 'error') + ) + gap('TOOL_FAILED', 'SARIF records tool execution errors.'); } const rules = driver.rules === undefined ? [] : arr(driver.rules); const results = arr(run.results); @@ -349,7 +638,10 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str // SARIF also represents passing checks and informational inventory. if (['pass', 'notApplicable', 'informational'].includes(String(result.kind))) continue; const ruleIndex = result.ruleIndex; - const rule = Number.isSafeInteger(ruleIndex) && (ruleIndex as number) >= 0 && rules[ruleIndex as number] ? obj(rules[ruleIndex as number]) : undefined; + const rule = + Number.isSafeInteger(ruleIndex) && (ruleIndex as number) >= 0 && rules[ruleIndex as number] + ? obj(rules[ruleIndex as number]) + : undefined; const ruleId = str(result.ruleId ?? rule?.id); const message = obj(result.message); let loc: ScannerCandidate['location']; @@ -358,7 +650,8 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str let artifact = obj(physical.artifactLocation); if (artifact.uri === undefined && Number.isSafeInteger(artifact.index)) { const index = artifact.index as number; - if (index < 0 || !Array.isArray(run.artifacts) || !run.artifacts[index]) throw new Error('Invalid artifact index'); + if (index < 0 || !Array.isArray(run.artifacts) || !run.artifacts[index]) + throw new Error('Invalid artifact index'); artifact = obj(obj(run.artifacts[index]).location); } let uri = str(artifact.uri); @@ -373,21 +666,58 @@ function parseSarif(document: unknown, tool: ScannerCandidate['tool'], root: str loc = location(uri, region.startLine, region.startColumn, root); } const properties = result.properties === undefined ? {} : obj(result.properties); - const aliases = properties.tags === undefined ? [] : arr(properties.tags).filter(v => typeof v === 'string' && /^(CVE-|GHSA-|OSV-)/.test(v)); - add(candidate(tool, { ruleId, message: str(message.text ?? message.markdown ?? message.id), location: loc, reportedSeverity: severity(result.level ?? (rule?.defaultConfiguration as Obj | undefined)?.level), advisoryIds: aliases as string[], suppressed: Array.isArray(result.suppressions) && result.suppressions.length > 0 })); - } catch (error) { gap(error instanceof Error && /[Ll]ocation|URI|source root/.test(error.message) ? 'UNSAFE_LOCATION' : 'INVALID_OUTPUT', 'A SARIF result could not be safely normalized.'); } + const aliases = + properties.tags === undefined + ? [] + : arr(properties.tags).filter((v) => typeof v === 'string' && /^(CVE-|GHSA-|OSV-)/.test(v)); + add( + candidate(tool, { + ruleId, + message: str(message.text ?? message.markdown ?? message.id), + location: loc, + reportedSeverity: severity( + result.level ?? (rule?.defaultConfiguration as Obj | undefined)?.level, + ), + advisoryIds: aliases as string[], + suppressed: Array.isArray(result.suppressions) && result.suppressions.length > 0, + }), + ); + } catch (error) { + gap( + error instanceof Error && /[Ll]ocation|URI|source root/.test(error.message) + ? 'UNSAFE_LOCATION' + : 'INVALID_OUTPUT', + 'A SARIF result could not be safely normalized.', + ); + } } } } -function parseResults(plan: ScannerPlan, document: unknown, add: (value: ScannerCandidate) => void, gap: (code: ScannerGap['code'], message: string) => void): void { +function parseResults( + plan: ScannerPlan, + document: unknown, + add: (value: ScannerCandidate) => void, + gap: (code: ScannerGap['code'], message: string) => void, +): void { const root = plan.sourceRoot; - if (plan.format === 'sarif') { parseSarif(document, plan.id, root, add, gap); return; } + if (plan.format === 'sarif') { + parseSarif(document, plan.id, root, add, gap); + return; + } if (plan.format === 'gitleaks-json') { for (const value of arr(document)) { const row = obj(value); // Never retain Match, Secret, Line, commit message, author, or scanner fingerprint. - add(candidate(plan.id, { ruleId: str(row.RuleID), message: str(row.Description), reportedSeverity: 'unknown', location: location(row.File, row.StartLine, row.StartColumn, root), advisoryIds: [] })); + add( + candidate(plan.id, { + ruleId: str(row.RuleID), + message: str(row.Description), + reportedSeverity: 'unknown', + location: location(row.File, row.StartLine, row.StartColumn, root), + advisoryIds: [], + }), + ); } return; } @@ -395,38 +725,93 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner switch (plan.format) { case 'semgrep-json': for (const value of arr(doc.results)) { - const row = obj(value), extra = obj(row.extra), start = obj(row.start); - add(candidate(plan.id, { ruleId: str(row.check_id), message: str(extra.message), reportedSeverity: severity(extra.severity), location: location(row.path, start.line, start.col, root), advisoryIds: [], suppressed: extra.is_ignored === true })); + const row = obj(value), + extra = obj(row.extra), + start = obj(row.start); + add( + candidate(plan.id, { + ruleId: str(row.check_id), + message: str(extra.message), + reportedSeverity: severity(extra.severity), + location: location(row.path, start.line, start.col, root), + advisoryIds: [], + suppressed: extra.is_ignored === true, + }), + ); } - if (arr(doc.errors).length) gap('TOOL_FAILED', 'Semgrep reported parser, rule, or execution errors; inspect affected coverage.'); + if (arr(doc.errors).length) + gap('TOOL_FAILED', 'Semgrep reported parser, rule, or execution errors; inspect affected coverage.'); if (!arr(obj(doc.paths).scanned).length) gap('SKIPPED_INPUT', 'Semgrep did not scan any source files.'); - if (Array.isArray(obj(doc.paths).skipped) && (obj(doc.paths).skipped as unknown[]).length) gap('SKIPPED_INPUT', 'Semgrep skipped source files.'); + if (Array.isArray(obj(doc.paths).skipped) && (obj(doc.paths).skipped as unknown[]).length) + gap('SKIPPED_INPUT', 'Semgrep skipped source files.'); return; case 'osv-json': for (const value of arr(doc.results)) { - const result = obj(value), source = obj(result.source); + const result = obj(value), + source = obj(result.source); for (const entry of arr(result.packages)) { - const pkg = obj(entry), detail = obj(pkg.package); + const pkg = obj(entry), + detail = obj(pkg.package); for (const input of arr(pkg.vulnerabilities)) { - const vuln = obj(input), id = str(vuln.id); + const vuln = obj(input), + id = str(vuln.id); const aliases = vuln.aliases === undefined ? [] : arr(vuln.aliases).map(str); - add(candidate(plan.id, { ruleId: id, message: optionalString(vuln.summary) ?? id, reportedSeverity: 'unknown', location: location(source.path, undefined, undefined, root), advisoryIds: [...new Set([id, ...aliases])], dependency: { name: str(detail.name), version: optionalString(detail.version), ecosystem: optionalString(detail.ecosystem), reachability: 'unknown', exposure: 'unknown' } })); + add( + candidate(plan.id, { + ruleId: id, + message: optionalString(vuln.summary) ?? id, + reportedSeverity: 'unknown', + location: location(source.path, undefined, undefined, root), + advisoryIds: [...new Set([id, ...aliases])], + dependency: { + name: str(detail.name), + version: optionalString(detail.version), + ecosystem: optionalString(detail.ecosystem), + reachability: 'unknown', + exposure: 'unknown', + }, + }), + ); } } } return; case 'trivy-json': if (doc.SchemaVersion !== 2) throw new Error('Trivy schema version 2 required'); - if (doc.Results === undefined && (typeof doc.ArtifactName !== 'string' || doc.ArtifactType !== 'filesystem')) throw new Error('Missing Trivy assessment metadata'); + if ( + doc.Results === undefined && + (typeof doc.ArtifactName !== 'string' || doc.ArtifactType !== 'filesystem') + ) + throw new Error('Missing Trivy assessment metadata'); for (const value of arr(doc.Results ?? [])) { const result = obj(value); for (const key of ['Vulnerabilities', 'Misconfigurations', 'Secrets'] as const) { for (const input of result[key] === undefined ? [] : arr(result[key])) { - const row = obj(input), id = str(row.VulnerabilityID ?? row.ID ?? row.RuleID); + const row = obj(input), + id = str(row.VulnerabilityID ?? row.ID ?? row.RuleID); const cause = row.CauseMetadata === undefined ? {} : obj(row.CauseMetadata); // Some filesystem package scanners add " (type)" after their target. const target = str(result.Target).replace(/ \([a-zA-Z0-9_. -]+\)$/, ''); - add(candidate(plan.id, { ruleId: id, message: optionalString(row.Title) ?? optionalString(row.Description) ?? id, reportedSeverity: severity(row.Severity), location: location(target, cause.StartLine ?? row.StartLine, undefined, root), advisoryIds: row.VulnerabilityID ? [id] : [], ...(key === 'Vulnerabilities' ? { dependency: { name: str(row.PkgName), version: optionalString(row.InstalledVersion), ecosystem: optionalString(result.Type), reachability: 'unknown' as const, exposure: 'unknown' as const } } : {}) })); + add( + candidate(plan.id, { + ruleId: id, + message: optionalString(row.Title) ?? optionalString(row.Description) ?? id, + reportedSeverity: severity(row.Severity), + location: location(target, cause.StartLine ?? row.StartLine, undefined, root), + advisoryIds: row.VulnerabilityID ? [id] : [], + ...(key === 'Vulnerabilities' + ? { + dependency: { + name: str(row.PkgName), + version: optionalString(row.InstalledVersion), + ecosystem: optionalString(result.Type), + reachability: 'unknown' as const, + exposure: 'unknown' as const, + }, + } + : {}), + }), + ); } } } @@ -434,13 +819,31 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner case 'schemathesis-json': { str(doc.schemathesis_version); const operations = doc.operations === null ? null : obj(doc.operations); - if (doc.complete !== true || doc.stop_reason !== 'completed') gap('SKIPPED_INPUT', 'Schemathesis did not finish its declared operation assessment.'); - if (!operations || typeof operations.tested !== 'number' || operations.tested === 0) gap('SKIPPED_INPUT', 'Schemathesis exercised no operations.'); - if (operations && (Number(operations.errored) > 0 || Number(operations.skipped) > 0 || Number(operations.tested) < Number(operations.selected))) gap('SKIPPED_INPUT', 'Schemathesis skipped or failed to exercise selected operations.'); - if (arr(doc.errors).length) gap('TOOL_FAILED', 'Schemathesis reported setup or test-generation errors.'); + if (doc.complete !== true || doc.stop_reason !== 'completed') + gap('SKIPPED_INPUT', 'Schemathesis did not finish its declared operation assessment.'); + if (!operations || typeof operations.tested !== 'number' || operations.tested === 0) + gap('SKIPPED_INPUT', 'Schemathesis exercised no operations.'); + if ( + operations && + (Number(operations.errored) > 0 || + Number(operations.skipped) > 0 || + Number(operations.tested) < Number(operations.selected)) + ) + gap('SKIPPED_INPUT', 'Schemathesis skipped or failed to exercise selected operations.'); + if (arr(doc.errors).length) + gap('TOOL_FAILED', 'Schemathesis reported setup or test-generation errors.'); for (const value of arr(doc.failures)) { const row = obj(value); - for (const op of arr(row.operations)) add(candidate(plan.id, { ruleId: str(row.type), message: str(row.title), reportedSeverity: severity(row.severity), advisoryIds: [], operation: str(op) })); + for (const op of arr(row.operations)) + add( + candidate(plan.id, { + ruleId: str(row.type), + message: str(row.title), + reportedSeverity: severity(row.severity), + advisoryIds: [], + operation: str(op), + }), + ); } return; } @@ -450,16 +853,38 @@ function parseResults(plan: ScannerPlan, document: unknown, add: (value: Scanner /** Failed or malformed tools never become an empty-clean assessment. */ export function parseScannerOutput(plan: ScannerPlan, execution: ScannerExecution): ScannerOutcome { const outcome: ScannerOutcome = { - tool: plan.id, version: null, status: 'not_assessed', candidates: [], gaps: [], scope: plan.coverage.scope.slice(), exclusions: plan.coverage.exclusions.slice(), - databaseUpdatedAt: null, exitCode: execution.exitCode, evidence: 'scanner-candidate', provenanceSources: plan.provenanceSources.slice(), - planSha256: createHash('sha256').update(JSON.stringify(plan)).digest('hex'), documentationInspectedAt: plan.documentationInspectedAt, + tool: plan.id, + version: null, + status: 'not_assessed', + candidates: [], + gaps: [], + scope: plan.coverage.scope.slice(), + exclusions: plan.coverage.exclusions.slice(), + databaseUpdatedAt: null, + exitCode: execution.exitCode, + evidence: 'scanner-candidate', + provenanceSources: plan.provenanceSources.slice(), + planSha256: createHash('sha256').update(JSON.stringify(plan)).digest('hex'), + documentationInspectedAt: plan.documentationInspectedAt, }; - const gap = (code: ScannerGap['code'], message: string) => { if (!outcome.gaps.some(g => g.code === code && g.message === message)) outcome.gaps.push({ code, message }); }; - if (execution.unavailable) { gap('UNAVAILABLE', `${plan.id} was unavailable; this scanner assessment did not run.`); return outcome; } - if (plan.prerequisites.length) { for (const value of plan.prerequisites) gap('PREREQUISITE', value); return outcome; } + const gap = (code: ScannerGap['code'], message: string) => { + if (!outcome.gaps.some((g) => g.code === code && g.message === message)) + outcome.gaps.push({ code, message }); + }; + if (execution.unavailable) { + gap('UNAVAILABLE', `${plan.id} was unavailable; this scanner assessment did not run.`); + return outcome; + } + if (plan.prerequisites.length) { + for (const value of plan.prerequisites) gap('PREREQUISITE', value); + return outcome; + } if (execution.timedOut) gap('TIMEOUT', 'Scanner exceeded its execution deadline.'); const outputBytes = Buffer.byteLength(execution.stdout) + Buffer.byteLength(execution.stderr ?? ''); - if (execution.truncated || outputBytes > Math.min(plan.maxOutputBytes, MAX_SCANNER_OUTPUT_BYTES)) { gap('OUTPUT_LIMIT', 'Scanner output exceeded the capture limit; payload withheld.'); return outcome; } + if (execution.truncated || outputBytes > Math.min(plan.maxOutputBytes, MAX_SCANNER_OUTPUT_BYTES)) { + gap('OUTPUT_LIMIT', 'Scanner output exceeded the capture limit; payload withheld.'); + return outcome; + } try { if (execution.version) { const safe = redactFindingSpans(execution.version); @@ -467,31 +892,61 @@ export function parseScannerOutput(plan: ScannerPlan, execution: ScannerExecutio outcome.version = safe.slice(0, 200).replace(/[\x00-\x1f\x7f]/g, ''); } if (redactFindingSpans(execution.stderr ?? '') === null) throw new RedactionFailure(); - if (/\b(?:error|fatal|panic|failed to|unable to|no offline version)\b/i.test(execution.stderr ?? '')) gap('TOOL_FAILED', 'Scanner diagnostic output reported a failure; the JSON result does not establish complete coverage.'); + if (/\b(?:error|fatal|panic|failed to|unable to|no offline version)\b/i.test(execution.stderr ?? '')) + gap( + 'TOOL_FAILED', + 'Scanner diagnostic output reported a failure; the JSON result does not establish complete coverage.', + ); const doc = decodedDocument(execution.stdout); const seen = new Set(); - parseResults(plan, doc, item => { - if (outcome.candidates.length >= MAX_CANDIDATES) throw new Error('Candidate limit exceeded'); - if (!seen.has(item.id)) { seen.add(item.id); outcome.candidates.push(item); } - }, gap); + parseResults( + plan, + doc, + (item) => { + if (outcome.candidates.length >= MAX_CANDIDATES) throw new Error('Candidate limit exceeded'); + if (!seen.has(item.id)) { + seen.add(item.id); + outcome.candidates.push(item); + } + }, + gap, + ); outcome.status = 'complete'; } catch (error) { - if (error instanceof RedactionFailure) { outcome.candidates = []; gap('REDACTION_FAILED', 'Scanner payload could not be safely redacted and was withheld.'); } - else gap('INVALID_OUTPUT', 'Scanner report is malformed, unsupported, or exceeds structural limits.'); + if (error instanceof RedactionFailure) { + outcome.candidates = []; + gap('REDACTION_FAILED', 'Scanner payload could not be safely redacted and was withheld.'); + } else gap('INVALID_OUTPUT', 'Scanner report is malformed, unsupported, or exceeds structural limits.'); } - const successCodes = plan.id === 'gitleaks' ? [0, 10] : ['osv', 'schemathesis'].includes(plan.id) ? [0, 1] : [0]; - if (execution.exitCode === null || !successCodes.includes(execution.exitCode)) gap('TOOL_FAILED', 'Scanner did not exit with a recognized assessment status.'); - if ((plan.id === 'gitleaks' && execution.exitCode === 10 || plan.id === 'osv' && execution.exitCode === 1) && !outcome.candidates.length) gap('INVALID_OUTPUT', 'Scanner finding exit status disagrees with its empty report.'); + const successCodes = + plan.id === 'gitleaks' ? [0, 10] : ['osv', 'schemathesis'].includes(plan.id) ? [0, 1] : [0]; + if (execution.exitCode === null || !successCodes.includes(execution.exitCode)) + gap('TOOL_FAILED', 'Scanner did not exit with a recognized assessment status.'); + if ( + ((plan.id === 'gitleaks' && execution.exitCode === 10) || + (plan.id === 'osv' && execution.exitCode === 1)) && + !outcome.candidates.length + ) + gap('INVALID_OUTPUT', 'Scanner finding exit status disagrees with its empty report.'); if (['osv', 'trivy'].includes(plan.id)) { - if (execution.databaseUpdatedAt && /^\d{4}-\d\d-\d\dT/.test(execution.databaseUpdatedAt) && Number.isFinite(Date.parse(execution.databaseUpdatedAt))) outcome.databaseUpdatedAt = execution.databaseUpdatedAt; + if ( + execution.databaseUpdatedAt && + /^\d{4}-\d\d-\d\dT/.test(execution.databaseUpdatedAt) && + Number.isFinite(Date.parse(execution.databaseUpdatedAt)) + ) + outcome.databaseUpdatedAt = execution.databaseUpdatedAt; else gap('UNKNOWN_FRESHNESS', 'The advisory database freshness is unknown.'); } - if (outcome.gaps.length) outcome.status = outcome.status === 'complete' || outcome.candidates.length ? 'partial' : 'not_assessed'; + if (outcome.gaps.length) + outcome.status = outcome.status === 'complete' || outcome.candidates.length ? 'partial' : 'not_assessed'; return outcome; } /** Import CodeQL or other SARIF as read-only candidates; never trust its verdict. */ -export function importSarif(raw: string, opts: { sourceRoot: string; version?: string; scope?: string[] }): ScannerOutcome { +export function importSarif( + raw: string, + opts: { sourceRoot: string; version?: string; scope?: string[] }, +): ScannerOutcome { const root = absolutePath(opts.sourceRoot, 'sourceRoot'); const plan = scannerPlans({ snapshotRoot: root, offline: true, selected: ['zizmor'] })[0]; plan.coverage.scope = opts.scope ?? [root]; @@ -499,6 +954,6 @@ export function importSarif(raw: string, opts: { sourceRoot: string; version?: s plan.coverage.exclusions = ['Imported scanner scope and suppressions require independent validation.']; const outcome = parseScannerOutput(plan, { stdout: raw, exitCode: 0, version: opts.version }); outcome.tool = 'sarif'; - outcome.candidates = outcome.candidates.map(item => candidate('sarif', item)); + outcome.candidates = outcome.candidates.map((item) => candidate('sarif', item)); return outcome; } diff --git a/lib/cso/snapshot.ts b/lib/cso/snapshot.ts index c174a9b48..a173ff1fc 100644 --- a/lib/cso/snapshot.ts +++ b/lib/cso/snapshot.ts @@ -1,287 +1,997 @@ import * as fs from 'node:fs'; import { createHash } from 'node:crypto'; import { dirname, isAbsolute, join, resolve, relative, sep } from 'node:path'; -import { CsoError, SnapshotManifest, SnapshotEntry, SnapshotPathIdentity, canonical, sha256, relativePath, snapshotOriginalIdentity, snapshotPathHandle, snapshotPathId, MAX_OUTPUT } from './contracts'; +import { + CsoError, + SnapshotManifest, + SnapshotEntry, + SnapshotPathIdentity, + canonical, + sha256, + relativePath, + snapshotOriginalIdentity, + snapshotPathHandle, + snapshotPathId, + MAX_OUTPUT, +} from './contracts'; import { childEnvironment, executable, git, redact, runProcess } from './process'; import { secureDirectory, writeHelperJson, writeJson } from './state'; import { scan } from '../redact-engine'; import { atomicWriteSync } from '../fs-atomic'; -const NO_READ_COMPONENTS=new Set(['.git','.hg','.svn','node_modules','.venv','venv','__pycache__','.bundle','.cache','.context','.gstack']); -const OMIT_COMPONENTS=new Set([...NO_READ_COMPONENTS,'.claude','.agents','.codex','.cursor']); -function containsDirectory(path:string,components:Set,sequences:string[][]=[]):boolean{ - const parts=path.split('/');if(parts.slice(0,-1).some(part=>components.has(part)))return true; - return sequences.some(sequence=>parts.slice(0,-1).some((_,index)=>sequence.every((part,offset)=>parts[index+offset]===part))); +const NO_READ_COMPONENTS = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + '.venv', + 'venv', + '__pycache__', + '.bundle', + '.cache', + '.context', + '.gstack', +]); +const OMIT_COMPONENTS = new Set([...NO_READ_COMPONENTS, '.claude', '.agents', '.codex', '.cursor']); +function containsDirectory(path: string, components: Set, sequences: string[][] = []): boolean { + const parts = path.split('/'); + if (parts.slice(0, -1).some((part) => components.has(part))) return true; + return sequences.some((sequence) => + parts.slice(0, -1).some((_, index) => sequence.every((part, offset) => parts[index + offset] === part)), + ); +} +function noReadPath(path: string): boolean { + return containsDirectory(path, NO_READ_COMPONENTS, [['vendor', 'bundle']]); +} +function omittedPath(path: string): boolean { + return containsDirectory(path, OMIT_COMPONENTS, [ + ['vendor', 'bundle'], + ['.github', 'agents'], + ]); +} +const SECRET_FILE = + /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.pypirc|pip\.conf|credentials(?:\.yml(?:\.enc)?)?|master\.key|id_(?:rsa|ed25519)|.*\.(?:pem|p12|pfx|key)|AGENTS\.md|CLAUDE\.md|GEMINI\.md|bunfig\.toml)$/i; +const SOURCE_LIMIT = 64 * 1024 * 1024; +const SNAPSHOT_ENTRY_LIMIT = 100_000; +const GIT_POINTER_LIMIT = 8192; +export interface SnapshotCaptureLimits { + deadlineMs?: number; + maxEntries?: number; } -function noReadPath(path:string):boolean{return containsDirectory(path,NO_READ_COMPONENTS,[['vendor','bundle']]);} -function omittedPath(path:string):boolean{return containsDirectory(path,OMIT_COMPONENTS,[['vendor','bundle'],['.github','agents']]);} -const SECRET_FILE = /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.pypirc|pip\.conf|credentials(?:\.yml(?:\.enc)?)?|master\.key|id_(?:rsa|ed25519)|.*\.(?:pem|p12|pfx|key)|AGENTS\.md|CLAUDE\.md|GEMINI\.md|bunfig\.toml)$/i; -const SOURCE_LIMIT=64*1024*1024; -const SNAPSHOT_ENTRY_LIMIT=100_000; -const GIT_POINTER_LIMIT=8192; -export interface SnapshotCaptureLimits { deadlineMs?:number; maxEntries?:number } -type BoundPathIdentity={path:string;kind:'directory'|'file';dev:number;ino:number;mode:number;size:number;mtimeMs:number;ctimeMs:number;contentHash?:string}; -type RepositoryIdentity={root:BoundPathIdentity;metadata:BoundPathIdentity[]}; -function boundPath(path:string,label:string,maxBytes=GIT_POINTER_LIMIT):{identity:BoundPathIdentity;content?:string}{ - let named:fs.Stats;try{named=fs.lstatSync(path);}catch{throw new CsoError('SNAPSHOT_RACE',`${label} disappeared during snapshot capture`);} - if(named.isSymbolicLink())throw new CsoError('UNSAFE_PATH',`${label} cannot be a symlink`); - if(named.isDirectory())return{identity:{path,kind:'directory',dev:named.dev,ino:named.ino,mode:named.mode,size:named.size,mtimeMs:named.mtimeMs,ctimeMs:named.ctimeMs}}; - if(!named.isFile()||named.nlink!==1||named.size>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} must be a bounded regular file or directory`); - let fd:number|undefined;try{ - fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)|(fs.constants.O_NONBLOCK??0));const opened=fs.fstatSync(fd); - if(!opened.isFile()||opened.nlink!==1||opened.dev!==named.dev||opened.ino!==named.ino||opened.mode!==named.mode||opened.size!==named.size)throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be read`); - const buffer=Buffer.alloc(maxBytes+1);let bytes=0,count=0;while(bytes0)bytes+=count; - const after=fs.fstatSync(fd),current=fs.lstatSync(path);if(bytes>maxBytes)throw new CsoError('UNSAFE_PATH',`${label} exceeds its bounded size limit`); - if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode||after.size!==opened.size||after.mtimeMs!==opened.mtimeMs||after.ctimeMs!==opened.ctimeMs)throw new CsoError('SNAPSHOT_RACE',`${label} changed while it was read`); - const body=buffer.subarray(0,bytes);return{identity:{path,kind:'file',dev:after.dev,ino:after.ino,mode:after.mode,size:after.size,mtimeMs:after.mtimeMs,ctimeMs:after.ctimeMs,contentHash:sha256(body)},content:body.toString('utf8')}; - }catch(error){if(error instanceof CsoError)throw error;const code=(error as NodeJS.ErrnoException).code;if(['ELOOP','ENOENT','ENOTDIR','ENXIO'].includes(code??''))throw new CsoError('SNAPSHOT_RACE',`${label} changed before it could be opened`);throw new CsoError('UNSAFE_PATH',`${label} could not be read as a bounded regular file`);}finally{if(fd!==undefined)fs.closeSync(fd);} +type BoundPathIdentity = { + path: string; + kind: 'directory' | 'file'; + dev: number; + ino: number; + mode: number; + size: number; + mtimeMs: number; + ctimeMs: number; + contentHash?: string; +}; +type RepositoryIdentity = { root: BoundPathIdentity; metadata: BoundPathIdentity[] }; +function boundPath( + path: string, + label: string, + maxBytes = GIT_POINTER_LIMIT, +): { identity: BoundPathIdentity; content?: string } { + let named: fs.Stats; + try { + named = fs.lstatSync(path); + } catch { + throw new CsoError('SNAPSHOT_RACE', `${label} disappeared during snapshot capture`); + } + if (named.isSymbolicLink()) throw new CsoError('UNSAFE_PATH', `${label} cannot be a symlink`); + if (named.isDirectory()) + return { + identity: { + path, + kind: 'directory', + dev: named.dev, + ino: named.ino, + mode: named.mode, + size: named.size, + mtimeMs: named.mtimeMs, + ctimeMs: named.ctimeMs, + }, + }; + if (!named.isFile() || named.nlink !== 1 || named.size > maxBytes) + throw new CsoError('UNSAFE_PATH', `${label} must be a bounded regular file or directory`); + let fd: number | undefined; + try { + fd = fs.openSync( + path, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0), + ); + const opened = fs.fstatSync(fd); + if ( + !opened.isFile() || + opened.nlink !== 1 || + opened.dev !== named.dev || + opened.ino !== named.ino || + opened.mode !== named.mode || + opened.size !== named.size + ) + throw new CsoError('SNAPSHOT_RACE', `${label} changed before it could be read`); + const buffer = Buffer.alloc(maxBytes + 1); + let bytes = 0, + count = 0; + while (bytes < buffer.length && (count = fs.readSync(fd, buffer, bytes, buffer.length - bytes, null)) > 0) + bytes += count; + const after = fs.fstatSync(fd), + current = fs.lstatSync(path); + if (bytes > maxBytes) throw new CsoError('UNSAFE_PATH', `${label} exceeds its bounded size limit`); + if ( + current.isSymbolicLink() || + !current.isFile() || + current.nlink !== 1 || + current.dev !== opened.dev || + current.ino !== opened.ino || + current.mode !== opened.mode || + after.size !== opened.size || + after.mtimeMs !== opened.mtimeMs || + after.ctimeMs !== opened.ctimeMs + ) + throw new CsoError('SNAPSHOT_RACE', `${label} changed while it was read`); + const body = buffer.subarray(0, bytes); + return { + identity: { + path, + kind: 'file', + dev: after.dev, + ino: after.ino, + mode: after.mode, + size: after.size, + mtimeMs: after.mtimeMs, + ctimeMs: after.ctimeMs, + contentHash: sha256(body), + }, + content: body.toString('utf8'), + }; + } catch (error) { + if (error instanceof CsoError) throw error; + const code = (error as NodeJS.ErrnoException).code; + if (['ELOOP', 'ENOENT', 'ENOTDIR', 'ENXIO'].includes(code ?? '')) + throw new CsoError('SNAPSHOT_RACE', `${label} changed before it could be opened`); + throw new CsoError('UNSAFE_PATH', `${label} could not be read as a bounded regular file`); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } } -function sameIdentity(expected:BoundPathIdentity,current:BoundPathIdentity):boolean{return expected.path===current.path&&expected.kind===current.kind&&expected.dev===current.dev&&expected.ino===current.ino&&expected.mode===current.mode&&expected.size===current.size&&expected.mtimeMs===current.mtimeMs&&expected.ctimeMs===current.ctimeMs&&expected.contentHash===current.contentHash;} -function repositoryIdentity(repo:string):RepositoryIdentity{ - const root=boundPath(repo,'Audited repository root').identity;if(root.kind!=='directory')throw new CsoError('MISSING_INPUT','Audited repository root is not a directory'); - const markerPath=join(repo,'.git'),marker=boundPath(markerPath,'Repository .git marker'),metadata=[marker.identity];let gitDir:string; - if(marker.identity.kind==='directory')gitDir=fs.realpathSync(markerPath); - else{const value=marker.content??'',match=value.match(/^gitdir:\s*(.+?)\s*$/);if(!match||value.includes('\0')||value.split(/\r?\n/).filter(Boolean).length!==1)throw new CsoError('UNSAFE_PATH','Repository .git pointer is invalid');gitDir=fs.realpathSync(resolve(dirname(markerPath),match[1]));} - const gitDirIdentity=boundPath(gitDir,'Repository Git directory').identity;if(gitDirIdentity.kind!=='directory')throw new CsoError('UNSAFE_PATH','Repository Git directory is not a directory');metadata.push(gitDirIdentity); - const commonMarker=join(gitDir,'commondir');let commonDir=gitDir; - if(fs.existsSync(commonMarker)){const marker=boundPath(commonMarker,'Repository common Git directory pointer');if(marker.identity.kind!=='file')throw new CsoError('UNSAFE_PATH','Repository common Git directory pointer is invalid');metadata.push(marker.identity);const value=(marker.content??'').trim();if(!value||value.includes('\0')||value.includes('\n')||value.includes('\r'))throw new CsoError('UNSAFE_PATH','Repository common Git directory pointer is invalid');commonDir=fs.realpathSync(resolve(gitDir,value));} - const commonIdentity=boundPath(commonDir,'Repository common Git directory').identity;if(commonIdentity.kind!=='directory')throw new CsoError('UNSAFE_PATH','Repository common Git directory is not a directory');metadata.push(commonIdentity); - const unique=[...new Map(metadata.map(item=>[item.path,item])).values()];const identity={root,metadata:unique};assertRepositoryIdentity(identity);return identity; +function sameIdentity(expected: BoundPathIdentity, current: BoundPathIdentity): boolean { + return ( + expected.path === current.path && + expected.kind === current.kind && + expected.dev === current.dev && + expected.ino === current.ino && + expected.mode === current.mode && + expected.size === current.size && + expected.mtimeMs === current.mtimeMs && + expected.ctimeMs === current.ctimeMs && + expected.contentHash === current.contentHash + ); } -function assertRepositoryIdentity(expected:RepositoryIdentity):void{ - const compare=(item:BoundPathIdentity,label:string)=>{const current=boundPath(item.path,label,item.kind==='file'?Math.max(GIT_POINTER_LIMIT,item.size):GIT_POINTER_LIMIT).identity;if(!sameIdentity(item,current))throw new CsoError('SNAPSHOT_RACE',`${label} changed during snapshot capture`);}; - compare(expected.root,'Audited repository root');for(const item of expected.metadata)compare(item,'Repository Git metadata identity');compare(expected.root,'Audited repository root'); +function repositoryIdentity(repo: string): RepositoryIdentity { + const root = boundPath(repo, 'Audited repository root').identity; + if (root.kind !== 'directory') + throw new CsoError('MISSING_INPUT', 'Audited repository root is not a directory'); + const markerPath = join(repo, '.git'), + marker = boundPath(markerPath, 'Repository .git marker'), + metadata = [marker.identity]; + let gitDir: string; + if (marker.identity.kind === 'directory') gitDir = fs.realpathSync(markerPath); + else { + const value = marker.content ?? '', + match = value.match(/^gitdir:\s*(.+?)\s*$/); + if (!match || value.includes('\0') || value.split(/\r?\n/).filter(Boolean).length !== 1) + throw new CsoError('UNSAFE_PATH', 'Repository .git pointer is invalid'); + gitDir = fs.realpathSync(resolve(dirname(markerPath), match[1])); + } + const gitDirIdentity = boundPath(gitDir, 'Repository Git directory').identity; + if (gitDirIdentity.kind !== 'directory') + throw new CsoError('UNSAFE_PATH', 'Repository Git directory is not a directory'); + metadata.push(gitDirIdentity); + const commonMarker = join(gitDir, 'commondir'); + let commonDir = gitDir; + if (fs.existsSync(commonMarker)) { + const marker = boundPath(commonMarker, 'Repository common Git directory pointer'); + if (marker.identity.kind !== 'file') + throw new CsoError('UNSAFE_PATH', 'Repository common Git directory pointer is invalid'); + metadata.push(marker.identity); + const value = (marker.content ?? '').trim(); + if (!value || value.includes('\0') || value.includes('\n') || value.includes('\r')) + throw new CsoError('UNSAFE_PATH', 'Repository common Git directory pointer is invalid'); + commonDir = fs.realpathSync(resolve(gitDir, value)); + } + const commonIdentity = boundPath(commonDir, 'Repository common Git directory').identity; + if (commonIdentity.kind !== 'directory') + throw new CsoError('UNSAFE_PATH', 'Repository common Git directory is not a directory'); + metadata.push(commonIdentity); + const unique = [...new Map(metadata.map((item) => [item.path, item])).values()]; + const identity = { root, metadata: unique }; + assertRepositoryIdentity(identity); + return identity; } -function snapshotAdmission(limits:SnapshotCaptureLimits={}){ - const deadlineMs=limits.deadlineMs??Date.now()+9*60_000,maxEntries=Math.min(limits.maxEntries??SNAPSHOT_ENTRY_LIMIT,SNAPSHOT_ENTRY_LIMIT); - if(!Number.isSafeInteger(deadlineMs)||!Number.isSafeInteger(maxEntries)||maxEntries<1)throw new CsoError('INVALID_ARGUMENT','Invalid snapshot admission limits'); - const time=()=>{if(Date.now()>=deadlineMs)throw new CsoError('DEADLINE','Snapshot capture exhausted the investigation budget before a report could be created');}; - const count=(entries:number)=>{if(entries>maxEntries)throw new CsoError('MISSING_INPUT',`Source tree exceeds the ${maxEntries}-entry snapshot admission limit`);}; - return{time,count}; +function assertRepositoryIdentity(expected: RepositoryIdentity): void { + const compare = (item: BoundPathIdentity, label: string) => { + const current = boundPath( + item.path, + label, + item.kind === 'file' ? Math.max(GIT_POINTER_LIMIT, item.size) : GIT_POINTER_LIMIT, + ).identity; + if (!sameIdentity(item, current)) + throw new CsoError('SNAPSHOT_RACE', `${label} changed during snapshot capture`); + }; + compare(expected.root, 'Audited repository root'); + for (const item of expected.metadata) compare(item, 'Repository Git metadata identity'); + compare(expected.root, 'Audited repository root'); +} +function snapshotAdmission(limits: SnapshotCaptureLimits = {}) { + const deadlineMs = limits.deadlineMs ?? Date.now() + 9 * 60_000, + maxEntries = Math.min(limits.maxEntries ?? SNAPSHOT_ENTRY_LIMIT, SNAPSHOT_ENTRY_LIMIT); + if (!Number.isSafeInteger(deadlineMs) || !Number.isSafeInteger(maxEntries) || maxEntries < 1) + throw new CsoError('INVALID_ARGUMENT', 'Invalid snapshot admission limits'); + const time = () => { + if (Date.now() >= deadlineMs) + throw new CsoError( + 'DEADLINE', + 'Snapshot capture exhausted the investigation budget before a report could be created', + ); + }; + const count = (entries: number) => { + if (entries > maxEntries) + throw new CsoError( + 'MISSING_INPUT', + `Source tree exceeds the ${maxEntries}-entry snapshot admission limit`, + ); + }; + return { time, count }; } export function exclusion(path: string): string | undefined { if (omittedPath(path)) return 'host dependencies, metadata, state, or agent configuration'; if (SECRET_FILE.test(path)) return 'credential or execution configuration'; } export function containedFile(root: string, path: string): string { - const rel = relativePath(path), full = join(root,rel); let cursor = root; + const rel = relativePath(path), + full = join(root, rel); + let cursor = root; for (const part of rel.split('/')) { - cursor = join(cursor,part); + cursor = join(cursor, part); try { - if (fs.lstatSync(cursor).isSymbolicLink()) throw new CsoError('UNSAFE_PATH',`Symlink is not an execution input: ${rel}`); + if (fs.lstatSync(cursor).isSymbolicLink()) + throw new CsoError('UNSAFE_PATH', `Symlink is not an execution input: ${rel}`); } catch (error) { if (error instanceof CsoError) throw error; if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } } - if (!full.startsWith(root + sep)) throw new CsoError('UNSAFE_PATH','Path escaped snapshot'); + if (!full.startsWith(root + sep)) throw new CsoError('UNSAFE_PATH', 'Path escaped snapshot'); return full; } -type DirectoryIdentity={path:string;dev:number;ino:number;mode:number}; -function inside(root:string,candidate:string):boolean{const relation=relative(root,candidate);return relation===''||(relation!=='..'&&!relation.startsWith(`..${sep}`)&&!isAbsolute(relation));} -function directoryIdentities(root:string,path:string):DirectoryIdentity[]{ - const rel=relativePath(path),parts=rel.split('/'),identities:DirectoryIdentity[]=[];let cursor=root; - for(const part of ['',...parts.slice(0,-1)]){ - if(part)cursor=join(cursor,part); - let stat:fs.Stats;try{stat=fs.lstatSync(cursor);}catch{throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while opening: ${rel}`);} - if(stat.isSymbolicLink()||!stat.isDirectory())throw new CsoError('UNSAFE_PATH',`Symlink or non-directory source ancestor: ${rel}`); - identities.push({path:cursor,dev:stat.dev,ino:stat.ino,mode:stat.mode}); +type DirectoryIdentity = { path: string; dev: number; ino: number; mode: number }; +function inside(root: string, candidate: string): boolean { + const relation = relative(root, candidate); + return relation === '' || (relation !== '..' && !relation.startsWith(`..${sep}`) && !isAbsolute(relation)); +} +function directoryIdentities(root: string, path: string): DirectoryIdentity[] { + const rel = relativePath(path), + parts = rel.split('/'), + identities: DirectoryIdentity[] = []; + let cursor = root; + for (const part of ['', ...parts.slice(0, -1)]) { + if (part) cursor = join(cursor, part); + let stat: fs.Stats; + try { + stat = fs.lstatSync(cursor); + } catch { + throw new CsoError('SNAPSHOT_RACE', `Source ancestor changed while opening: ${rel}`); + } + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new CsoError('UNSAFE_PATH', `Symlink or non-directory source ancestor: ${rel}`); + identities.push({ path: cursor, dev: stat.dev, ino: stat.ino, mode: stat.mode }); } return identities; } -function assertDirectoryIdentities(identities:DirectoryIdentity[],path:string):void{ - for(const expected of identities){let current:fs.Stats;try{current=fs.lstatSync(expected.path);}catch{throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while reading: ${path}`);} - if(current.isSymbolicLink()||!current.isDirectory()||current.dev!==expected.dev||current.ino!==expected.ino||current.mode!==expected.mode)throw new CsoError('SNAPSHOT_RACE',`Source ancestor changed while reading: ${path}`); +function assertDirectoryIdentities(identities: DirectoryIdentity[], path: string): void { + for (const expected of identities) { + let current: fs.Stats; + try { + current = fs.lstatSync(expected.path); + } catch { + throw new CsoError('SNAPSHOT_RACE', `Source ancestor changed while reading: ${path}`); + } + if ( + current.isSymbolicLink() || + !current.isDirectory() || + current.dev !== expected.dev || + current.ino !== expected.ino || + current.mode !== expected.mode + ) + throw new CsoError('SNAPSHOT_RACE', `Source ancestor changed while reading: ${path}`); } } /** Validate the resolved inode after open so an ancestor-symlink swap cannot escape root. */ -export function assertOpenedFileContained(root:string,full:string,fd:number,opened:fs.Stats):void{ - if(process.platform==='linux'){ - let actual:string,current:fs.Stats;try{actual=fs.readlinkSync(`/proc/self/fd/${fd}`);current=fs.fstatSync(fd);}catch{throw new CsoError('SNAPSHOT_RACE','Opened source identity could not be resolved');} - if(current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode)throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation'); - if(!isAbsolute(actual)||!inside(root,actual))throw new CsoError('UNSAFE_PATH','Opened source escaped the audited root'); +export function assertOpenedFileContained(root: string, full: string, fd: number, opened: fs.Stats): void { + if (process.platform === 'linux') { + let actual: string, current: fs.Stats; + try { + actual = fs.readlinkSync(`/proc/self/fd/${fd}`); + current = fs.fstatSync(fd); + } catch { + throw new CsoError('SNAPSHOT_RACE', 'Opened source identity could not be resolved'); + } + if ( + current.nlink !== 1 || + current.dev !== opened.dev || + current.ino !== opened.ino || + current.mode !== opened.mode + ) + throw new CsoError('SNAPSHOT_RACE', 'Opened source identity changed during containment validation'); + if (!isAbsolute(actual) || !inside(root, actual)) + throw new CsoError('UNSAFE_PATH', 'Opened source escaped the audited root'); return; } - let resolved:string,current:fs.Stats;try{resolved=fs.realpathSync(full);current=fs.lstatSync(resolved);}catch{throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation');} - if(!inside(root,resolved))throw new CsoError('UNSAFE_PATH','Opened source escaped the audited root'); - if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.dev!==opened.dev||current.ino!==opened.ino||current.mode!==opened.mode||current.size!==opened.size)throw new CsoError('SNAPSHOT_RACE','Opened source identity changed during containment validation'); + let resolved: string, current: fs.Stats; + try { + resolved = fs.realpathSync(full); + current = fs.lstatSync(resolved); + } catch { + throw new CsoError('SNAPSHOT_RACE', 'Opened source identity changed during containment validation'); + } + if (!inside(root, resolved)) throw new CsoError('UNSAFE_PATH', 'Opened source escaped the audited root'); + if ( + current.isSymbolicLink() || + !current.isFile() || + current.nlink !== 1 || + current.dev !== opened.dev || + current.ino !== opened.ino || + current.mode !== opened.mode || + current.size !== opened.size + ) + throw new CsoError('SNAPSHOT_RACE', 'Opened source identity changed during containment validation'); } -function readStable(root: string, path: string, maxBytes=MAX_OUTPUT): {data:Buffer; mode:number} { - const ancestors=directoryIdentities(root,path),full = containedFile(root,path), named=fs.lstatSync(full); +function readStable(root: string, path: string, maxBytes = MAX_OUTPUT): { data: Buffer; mode: number } { + const ancestors = directoryIdentities(root, path), + full = containedFile(root, path), + named = fs.lstatSync(full); // Prove the pathname is a regular single-link file before open. Opening a // FIFO or device merely to discover its type can block or trigger host I/O. - if(named.isSymbolicLink()||!named.isFile()||named.nlink!==1)throw new CsoError('UNSAFE_PATH',`Special or hard-linked source file: ${path}`); - let fd:number;try{fd=fs.openSync(full,fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW??0) | (fs.constants.O_NONBLOCK??0));}catch(error){const code=(error as NodeJS.ErrnoException).code;if(['ELOOP','ENOENT','ENOTDIR','ENXIO'].includes(code??''))throw new CsoError('SNAPSHOT_RACE',`Source changed before it could be opened: ${path}`);throw new CsoError('UNSAFE_PATH',`Source could not be opened as a regular file: ${path}`);} + if (named.isSymbolicLink() || !named.isFile() || named.nlink !== 1) + throw new CsoError('UNSAFE_PATH', `Special or hard-linked source file: ${path}`); + let fd: number; + try { + fd = fs.openSync( + full, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (['ELOOP', 'ENOENT', 'ENOTDIR', 'ENXIO'].includes(code ?? '')) + throw new CsoError('SNAPSHOT_RACE', `Source changed before it could be opened: ${path}`); + throw new CsoError('UNSAFE_PATH', `Source could not be opened as a regular file: ${path}`); + } try { const before = fs.fstatSync(fd); - if (!before.isFile() || before.nlink !== 1 || before.dev!==named.dev || before.ino!==named.ino || before.mode!==named.mode || before.size!==named.size) throw new CsoError('UNSAFE_PATH',`Special or hard-linked source file: ${path}`); - assertOpenedFileContained(root,full,fd,before);assertDirectoryIdentities(ancestors,path); - if (before.size > maxBytes) throw new CsoError('MISSING_INPUT',`Source file exceeds the ${maxBytes}-byte snapshot admission limit: ${path}`); - const buffer=Buffer.alloc(Math.min(maxBytes+1,before.size+1));let bytes=0,count=0;while(bytes0)bytes+=count;const data=buffer.subarray(0,bytes),after = fs.fstatSync(fd), current = fs.lstatSync(full); - if (!current.isFile()||current.nlink!==1||before.ino !== current.ino || before.dev !== current.dev || before.mode!==current.mode || before.size !== after.size || before.size!==bytes || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) - throw new CsoError('SNAPSHOT_RACE',`Source changed while reading: ${path}`); - assertOpenedFileContained(root,full,fd,after);assertDirectoryIdentities(ancestors,path); - return {data,mode:before.mode & 0o777}; - } finally { fs.closeSync(fd); } + if ( + !before.isFile() || + before.nlink !== 1 || + before.dev !== named.dev || + before.ino !== named.ino || + before.mode !== named.mode || + before.size !== named.size + ) + throw new CsoError('UNSAFE_PATH', `Special or hard-linked source file: ${path}`); + assertOpenedFileContained(root, full, fd, before); + assertDirectoryIdentities(ancestors, path); + if (before.size > maxBytes) + throw new CsoError( + 'MISSING_INPUT', + `Source file exceeds the ${maxBytes}-byte snapshot admission limit: ${path}`, + ); + const buffer = Buffer.alloc(Math.min(maxBytes + 1, before.size + 1)); + let bytes = 0, + count = 0; + while (bytes < buffer.length && (count = fs.readSync(fd, buffer, bytes, buffer.length - bytes, null)) > 0) + bytes += count; + const data = buffer.subarray(0, bytes), + after = fs.fstatSync(fd), + current = fs.lstatSync(full); + if ( + !current.isFile() || + current.nlink !== 1 || + before.ino !== current.ino || + before.dev !== current.dev || + before.mode !== current.mode || + before.size !== after.size || + before.size !== bytes || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs + ) + throw new CsoError('SNAPSHOT_RACE', `Source changed while reading: ${path}`); + assertOpenedFileContained(root, full, fd, after); + assertDirectoryIdentities(ancestors, path); + return { data, mode: before.mode & 0o777 }; + } finally { + fs.closeSync(fd); + } } -async function resolveHeadCommit(repo:string,home:string):Promise{ - try{return(await git(repo,['rev-parse','--verify','HEAD^{commit}'],home)).trim();} - catch(error){ +async function resolveHeadCommit(repo: string, home: string): Promise { + try { + return (await git(repo, ['rev-parse', '--verify', 'HEAD^{commit}'], home)).trim(); + } catch (error) { // A symbolic HEAD whose target does not exist is the normal unborn-branch // state. A detached/malformed HEAD or a ref to a non-commit remains an // input error instead of being silently treated as an empty history. - try{await git(repo,['symbolic-ref','--quiet','HEAD'],home);}catch{throw error;} - try{await git(repo,['rev-parse','--verify','HEAD'],home);}catch{return undefined;} + try { + await git(repo, ['symbolic-ref', '--quiet', 'HEAD'], home); + } catch { + throw error; + } + try { + await git(repo, ['rev-parse', '--verify', 'HEAD'], home); + } catch { + return undefined; + } throw error; } } -async function paths(repo: string, home: string,headCommit:string|undefined,admission:ReturnType): Promise { +async function paths( + repo: string, + home: string, + headCommit: string | undefined, + admission: ReturnType, +): Promise { // The index omits staged deletions. Union the pinned HEAD tree so every // tracked deletion is represented even when no comparison base was asked // for, while still collecting nonignored untracked source. - admission.time();const [working,head]=await Promise.all([ - git(repo,['ls-files','--cached','--others','--exclude-standard','-z'],home), - headCommit?git(repo,['ls-tree','-r','-z','--name-only','--full-tree',headCommit,'--'],home):Promise.resolve(''), - ]),seen=new Set();admission.time(); - for(const data of [working,head])for(const value of data.split('\0')){admission.time();if(!value)continue;seen.add(relativePath(value));admission.count(seen.size);} - const result=[...seen].sort();admission.time();return result; + admission.time(); + const [working, head] = await Promise.all([ + git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], home), + headCommit + ? git(repo, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', headCommit, '--'], home) + : Promise.resolve(''), + ]), + seen = new Set(); + admission.time(); + for (const data of [working, head]) + for (const value of data.split('\0')) { + admission.time(); + if (!value) continue; + seen.add(relativePath(value)); + admission.count(seen.size); + } + const result = [...seen].sort(); + admission.time(); + return result; } -async function rejectSpecialFiles(repo:string,home:string,admission:ReturnType):Promise{ +async function rejectSpecialFiles( + repo: string, + home: string, + admission: ReturnType, +): Promise { // Git intentionally omits untracked FIFOs and devices from ls-files. Walk // pathnames without opening payloads, then ask Git which special names are // ignored so a nonignored FIFO cannot silently disappear from the snapshot. - admission.time();const ignoredRaw=await git(repo,['ls-files','--others','--ignored','--exclude-standard','--directory','-z'],home),ignoredDirectories=new Set();admission.time(); - for(const value of ignoredRaw.split('\0')){admission.time();if(!value)continue;ignoredDirectories.add(relativePath(value.replace(/\/$/,'')));admission.count(ignoredDirectories.size);} - const ignoredDirectory=(path:string)=>{let candidate=path;for(;;){if(ignoredDirectories.has(candidate))return true;const slash=candidate.lastIndexOf('/');if(slash<0)return false;candidate=candidate.slice(0,slash);}}; - const special:string[]=[];let visited=0; - const walk=(at:string,prefix='')=>{const directory=fs.opendirSync(at);try{let item:fs.Dirent|null;while((item=directory.readSync())!==null){ - admission.time();const path=relativePath(prefix?`${prefix}/${item.name}`:item.name);if(noReadPath(path)||ignoredDirectory(path))continue;admission.count(++visited); - const full=join(at,item.name),stat=fs.lstatSync(full);if(stat.isDirectory()){walk(full,path);continue;}if(!stat.isFile())special.push(path); - }}finally{directory.closeSync();}};walk(repo); - if(!special.length)return; - const nullPath=process.platform==='win32'?'NUL':'/dev/null',result=await runProcess(executable('git'),['--no-optional-locks','-c','core.fsmonitor=false','-c',`core.hooksPath=${nullPath}`,'-c',`core.attributesFile=${nullPath}`,'-c','core.pager=cat','-C',repo,'check-ignore','--no-index','-z','--stdin'],{cwd:home,env:childEnvironment(home),raw:true,input:`${special.join('\0')}\0`,timeoutMs:15_000}); - if(![0,1].includes(result.code)||result.timedOut||result.truncated)throw new CsoError('MISSING_INPUT','Could not determine whether special source paths are ignored'); - admission.time();const ignored=new Set(result.stdout.split('\0').filter(Boolean).map(relativePath)),unsafe=special.find(path=>!ignored.has(path)); - if(unsafe)throw new CsoError('UNSAFE_PATH',`Symlink or special source file: ${unsafe}`); -} -export async function capture(repo: string, runDir: string, base?: string, requiredAncestor?:string, limits:SnapshotCaptureLimits={}): Promise { - repo = fs.realpathSync(repo); - const state=fs.realpathSync(runDir),relation=relative(repo,state);if(relation===''||(!relation.startsWith(`..${sep}`)&&relation!=='..'&&!isAbsolute(relation)))throw new CsoError('UNSAFE_PATH','Security state must be outside the audited repository'); - const repository=repositoryIdentity(repo),home = secureDirectory(join(runDir,'home')), snapshot = secureDirectory(join(runDir,'snapshot')), readable = secureDirectory(join(runDir,'readable')),admission=snapshotAdmission(limits),guard=()=>{admission.time();assertRepositoryIdentity(repository);}; - try { - const entries: SnapshotEntry[] = [], gitHashes=new Map(), gitModes=new Map(), sensitiveEvidence:any[]=[], absentPaths=new Set(); let total = 0;guard(); - const objectFormat=(await git(repo,['rev-parse','--show-object-format'],home)).trim();guard(); - if(!['sha1','sha256'].includes(objectFormat))throw new CsoError('INCOMPATIBLE_INPUT','Unsupported Git object format'); - const headCommit = await resolveHeadCommit(repo,home);guard(); - const list = await paths(repo,home,headCommit,admission);guard();await rejectSpecialFiles(repo,home,admission);guard(); - const manifest: SnapshotManifest = {version:3,root:repo,createdAt:new Date().toISOString(),expiresAt:new Date(Date.now()+7*86400_000).toISOString(),entries,...(headCommit?{headCommit}:{}),originalHash:'',executionHash:''}; - if (base) { - if (!/^[A-Za-z0-9_.\/-]+$/.test(base) || base.startsWith('-')) throw new CsoError('INVALID_ARGUMENT','Invalid comparison base'); - manifest.baseCommit = (await git(repo,['rev-parse','--verify',`${base}^{commit}`],home)).trim();guard(); + admission.time(); + const ignoredRaw = await git( + repo, + ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], + home, + ), + ignoredDirectories = new Set(); + admission.time(); + for (const value of ignoredRaw.split('\0')) { + admission.time(); + if (!value) continue; + ignoredDirectories.add(relativePath(value.replace(/\/$/, ''))); + admission.count(ignoredDirectories.size); } + const ignoredDirectory = (path: string) => { + let candidate = path; + for (;;) { + if (ignoredDirectories.has(candidate)) return true; + const slash = candidate.lastIndexOf('/'); + if (slash < 0) return false; + candidate = candidate.slice(0, slash); + } + }; + const special: string[] = []; + let visited = 0; + const walk = (at: string, prefix = '') => { + const directory = fs.opendirSync(at); + try { + let item: fs.Dirent | null; + while ((item = directory.readSync()) !== null) { + admission.time(); + const path = relativePath(prefix ? `${prefix}/${item.name}` : item.name); + if (noReadPath(path) || ignoredDirectory(path)) continue; + admission.count(++visited); + const full = join(at, item.name), + stat = fs.lstatSync(full); + if (stat.isDirectory()) { + walk(full, path); + continue; + } + if (!stat.isFile()) special.push(path); + } + } finally { + directory.closeSync(); + } + }; + walk(repo); + if (!special.length) return; + const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null', + result = await runProcess( + executable('git'), + [ + '--no-optional-locks', + '-c', + 'core.fsmonitor=false', + '-c', + `core.hooksPath=${nullPath}`, + '-c', + `core.attributesFile=${nullPath}`, + '-c', + 'core.pager=cat', + '-C', + repo, + 'check-ignore', + '--no-index', + '-z', + '--stdin', + ], + { + cwd: home, + env: childEnvironment(home), + raw: true, + input: `${special.join('\0')}\0`, + timeoutMs: 15_000, + }, + ); + if (![0, 1].includes(result.code) || result.timedOut || result.truncated) + throw new CsoError('MISSING_INPUT', 'Could not determine whether special source paths are ignored'); + admission.time(); + const ignored = new Set(result.stdout.split('\0').filter(Boolean).map(relativePath)), + unsafe = special.find((path) => !ignored.has(path)); + if (unsafe) throw new CsoError('UNSAFE_PATH', `Symlink or special source file: ${unsafe}`); +} +export async function capture( + repo: string, + runDir: string, + base?: string, + requiredAncestor?: string, + limits: SnapshotCaptureLimits = {}, +): Promise { + repo = fs.realpathSync(repo); + const state = fs.realpathSync(runDir), + relation = relative(repo, state); + if (relation === '' || (!relation.startsWith(`..${sep}`) && relation !== '..' && !isAbsolute(relation))) + throw new CsoError('UNSAFE_PATH', 'Security state must be outside the audited repository'); + const repository = repositoryIdentity(repo), + home = secureDirectory(join(runDir, 'home')), + snapshot = secureDirectory(join(runDir, 'snapshot')), + readable = secureDirectory(join(runDir, 'readable')), + admission = snapshotAdmission(limits), + guard = () => { + admission.time(); + assertRepositoryIdentity(repository); + }; + try { + const entries: SnapshotEntry[] = [], + gitHashes = new Map(), + gitModes = new Map(), + sensitiveEvidence: any[] = [], + absentPaths = new Set(); + let total = 0; + guard(); + const objectFormat = (await git(repo, ['rev-parse', '--show-object-format'], home)).trim(); + guard(); + if (!['sha1', 'sha256'].includes(objectFormat)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Unsupported Git object format'); + const headCommit = await resolveHeadCommit(repo, home); + guard(); + const list = await paths(repo, home, headCommit, admission); + guard(); + await rejectSpecialFiles(repo, home, admission); + guard(); + const manifest: SnapshotManifest = { + version: 3, + root: repo, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 7 * 86400_000).toISOString(), + entries, + ...(headCommit ? { headCommit } : {}), + originalHash: '', + executionHash: '', + }; + if (base) { + if (!/^[A-Za-z0-9_.\/-]+$/.test(base) || base.startsWith('-')) + throw new CsoError('INVALID_ARGUMENT', 'Invalid comparison base'); + manifest.baseCommit = (await git(repo, ['rev-parse', '--verify', `${base}^{commit}`], home)).trim(); + guard(); + } for (const path of list) { guard(); - try{fs.lstatSync(join(repo,path));}catch(error:any){if(error?.code==='ENOENT'){absentPaths.add(path);continue;}throw error;} // tracked deletions are represented by absence and the diff manifest + try { + fs.lstatSync(join(repo, path)); + } catch (error: any) { + if (error?.code === 'ENOENT') { + absentPaths.add(path); + continue; + } + throw error; + } // tracked deletions are represented by absence and the diff manifest // Host dependency trees aren't copied or read. Their omission is still explicit. if (noReadPath(path)) { - const stat = fs.lstatSync(containedFile(repo,path)); - if (stat.isSymbolicLink() || !stat.isFile()) throw new CsoError('UNSAFE_PATH',`Special source input: ${path}`); - gitModes.set(path,(stat.mode&0o111)?'100755':'100644'); - const reason=exclusion(path)??'host dependency input'; - entries.push({path,pathId:snapshotPathId(repo,path),originalHash:'not-read',bytes:stat.size,mode:stat.mode & 0o777,transformation:`excluded: ${reason}`});guard();continue; + const stat = fs.lstatSync(containedFile(repo, path)); + if (stat.isSymbolicLink() || !stat.isFile()) + throw new CsoError('UNSAFE_PATH', `Special source input: ${path}`); + gitModes.set(path, stat.mode & 0o111 ? '100755' : '100644'); + const reason = exclusion(path) ?? 'host dependency input'; + entries.push({ + path, + pathId: snapshotPathId(repo, path), + originalHash: 'not-read', + bytes: stat.size, + mode: stat.mode & 0o777, + transformation: `excluded: ${reason}`, + }); + guard(); + continue; } - const {data,mode} = readStable(repo,path,SOURCE_LIMIT); total += data.length; + const { data, mode } = readStable(repo, path, SOURCE_LIMIT); + total += data.length; guard(); - if (total > SOURCE_LIMIT) throw new CsoError('MISSING_INPUT','Source exceeds the 64 MiB snapshot admission limit'); - const entry: SnapshotEntry = {path,pathId:snapshotPathId(repo,path),originalHash:sha256(data),bytes:data.length,mode}; entries.push(entry); - gitHashes.set(path,createHash(objectFormat).update(`blob ${data.length}\0`).update(data).digest('hex')); - gitModes.set(path,(mode&0o111)?'100755':'100644'); - if(data.length>MAX_OUTPUT){entry.transformation='withheld: exceeds the 1 MiB redacting-reader limit';continue;} + if (total > SOURCE_LIMIT) + throw new CsoError('MISSING_INPUT', 'Source exceeds the 64 MiB snapshot admission limit'); + const entry: SnapshotEntry = { + path, + pathId: snapshotPathId(repo, path), + originalHash: sha256(data), + bytes: data.length, + mode, + }; + entries.push(entry); + gitHashes.set( + path, + createHash(objectFormat).update(`blob ${data.length}\0`).update(data).digest('hex'), + ); + gitModes.set(path, mode & 0o111 ? '100755' : '100644'); + if (data.length > MAX_OUTPUT) { + entry.transformation = 'withheld: exceeds the 1 MiB redacting-reader limit'; + continue; + } let sanitized: string; try { - sanitized = new TextDecoder('utf-8',{fatal:true}).decode(data); + sanitized = new TextDecoder('utf-8', { fatal: true }).decode(data); if (sanitized.includes('\0')) throw new Error('binary'); - const findings=scan(sanitized,{maxBytes:MAX_OUTPUT}).findings; - if(findings.length)sensitiveEvidence.push({path:snapshotPathHandle(entry.pathId),findings:findings.map(f=>({id:f.id,tier:f.tier,line:f.line,col:f.col}))}); + const findings = scan(sanitized, { maxBytes: MAX_OUTPUT }).findings; + if (findings.length) + sensitiveEvidence.push({ + path: snapshotPathHandle(entry.pathId), + findings: findings.map((f) => ({ id: f.id, tier: f.tier, line: f.line, col: f.col })), + }); sanitized = redact(sanitized); - } catch { entry.transformation = exclusion(path)?`excluded: ${exclusion(path)}; payload withheld because redaction could not safely preserve it`:'withheld: binary or redaction failed'; continue; } - const out = containedFile(readable,path); secureDirectory(dirname(out)); fs.writeFileSync(out,sanitized,{mode:0o600}); + } catch { + entry.transformation = exclusion(path) + ? `excluded: ${exclusion(path)}; payload withheld because redaction could not safely preserve it` + : 'withheld: binary or redaction failed'; + continue; + } + const out = containedFile(readable, path); + secureDirectory(dirname(out)); + fs.writeFileSync(out, sanitized, { mode: 0o600 }); const reason = exclusion(path); - if (reason) { entry.transformation = `excluded: ${reason}`; continue; } + if (reason) { + entry.transformation = `excluded: ${reason}`; + continue; + } if (sha256(sanitized) !== entry.originalHash) entry.transformation = 'secret spans redacted'; - const target = containedFile(snapshot,path); secureDirectory(dirname(target)); fs.writeFileSync(target,sanitized,{mode}); + const target = containedFile(snapshot, path); + secureDirectory(dirname(target)); + fs.writeFileSync(target, sanitized, { mode }); // writeFile's creation mode is filtered through the caller's umask. The // skill deliberately starts with umask 077, while the manifest binds the // original mode because executable bits are part of the application // input. Restore the exact recorded mode after creation; the snapshot's // owned 0700 ancestors still keep every retained source file private. - fs.chmodSync(target,mode); + fs.chmodSync(target, mode); entry.executionHash = sha256(sanitized); } - const deletedPaths:SnapshotPathIdentity[]=[...absentPaths].sort().map(path=>({path,pathId:snapshotPathId(repo,path)}));if(deletedPaths.length)manifest.deletedPaths=deletedPaths; - const assertAbsent=()=>{for(const path of absentPaths){admission.time();try{fs.lstatSync(containedFile(repo,path));}catch(error:any){if(error?.code==='ENOENT')continue;throw error;}throw new CsoError('SNAPSHOT_RACE',`Deleted source path reappeared during snapshot capture: ${path}`);}}; - const assertEntriesStable=(message:string)=>{for(const e of entries){guard();if(e.originalHash==='not-read'){const current=fs.lstatSync(containedFile(repo,e.path));if(current.isSymbolicLink()||!current.isFile()||current.nlink!==1||current.size!==e.bytes||(current.mode&0o777)!==e.mode)throw new CsoError('SNAPSHOT_RACE',`${message}: ${e.path}`);}else{const current=readStable(repo,e.path,SOURCE_LIMIT);if(sha256(current.data)!==e.originalHash||current.mode!==e.mode)throw new CsoError('SNAPSHOT_RACE',`${message}: ${e.path}`);}guard();}}; - if (canonical(list) !== canonical(await paths(repo,home,headCommit,admission))) throw new CsoError('SNAPSHOT_RACE','Source file membership changed during snapshot');guard();assertAbsent();assertEntriesStable('Source changed during capture'); - manifest.originalHash = snapshotOriginalIdentity(entries,deletedPaths); - manifest.executionHash = sha256(canonical(entries.filter(e => e.executionHash).map(e => [e.path,e.executionHash,e.mode]))); - if(manifest.baseCommit){ - const tree=await git(repo,['ls-tree','-r','-z','--full-tree',manifest.baseCommit,'--'],home),baseFiles=new Map();guard(); - for(const row of tree.split('\0').filter(Boolean)){admission.time();const match=row.match(/^(\d+) (?:blob|commit) ([a-f0-9]+)\t(.+)$/s);if(match)baseFiles.set(relativePath(match[3]),{mode:match[1],hash:match[2]});admission.count(baseFiles.size);} - const differs=(path:string):boolean=>{const baseEntry=baseFiles.get(path),hash=gitHashes.get(path),mode=gitModes.get(path);return !baseEntry||hash!==baseEntry.hash||mode!==baseEntry.mode;}; - manifest.changedPaths=[...new Set([...list.filter(differs),...baseFiles.keys()].filter(path=>differs(path)||!gitModes.has(path)))].sort(); + const deletedPaths: SnapshotPathIdentity[] = [...absentPaths] + .sort() + .map((path) => ({ path, pathId: snapshotPathId(repo, path) })); + if (deletedPaths.length) manifest.deletedPaths = deletedPaths; + const assertAbsent = () => { + for (const path of absentPaths) { + admission.time(); + try { + fs.lstatSync(containedFile(repo, path)); + } catch (error: any) { + if (error?.code === 'ENOENT') continue; + throw error; + } + throw new CsoError( + 'SNAPSHOT_RACE', + `Deleted source path reappeared during snapshot capture: ${path}`, + ); + } + }; + const assertEntriesStable = (message: string) => { + for (const e of entries) { + guard(); + if (e.originalHash === 'not-read') { + const current = fs.lstatSync(containedFile(repo, e.path)); + if ( + current.isSymbolicLink() || + !current.isFile() || + current.nlink !== 1 || + current.size !== e.bytes || + (current.mode & 0o777) !== e.mode + ) + throw new CsoError('SNAPSHOT_RACE', `${message}: ${e.path}`); + } else { + const current = readStable(repo, e.path, SOURCE_LIMIT); + if (sha256(current.data) !== e.originalHash || current.mode !== e.mode) + throw new CsoError('SNAPSHOT_RACE', `${message}: ${e.path}`); + } + guard(); + } + }; + if (canonical(list) !== canonical(await paths(repo, home, headCommit, admission))) + throw new CsoError('SNAPSHOT_RACE', 'Source file membership changed during snapshot'); + guard(); + assertAbsent(); + assertEntriesStable('Source changed during capture'); + manifest.originalHash = snapshotOriginalIdentity(entries, deletedPaths); + manifest.executionHash = sha256( + canonical(entries.filter((e) => e.executionHash).map((e) => [e.path, e.executionHash, e.mode])), + ); + if (manifest.baseCommit) { + const tree = await git(repo, ['ls-tree', '-r', '-z', '--full-tree', manifest.baseCommit, '--'], home), + baseFiles = new Map(); + guard(); + for (const row of tree.split('\0').filter(Boolean)) { + admission.time(); + const match = row.match(/^(\d+) (?:blob|commit) ([a-f0-9]+)\t(.+)$/s); + if (match) baseFiles.set(relativePath(match[3]), { mode: match[1], hash: match[2] }); + admission.count(baseFiles.size); + } + const differs = (path: string): boolean => { + const baseEntry = baseFiles.get(path), + hash = gitHashes.get(path), + mode = gitModes.get(path); + return !baseEntry || hash !== baseEntry.hash || mode !== baseEntry.mode; + }; + manifest.changedPaths = [ + ...new Set( + [...list.filter(differs), ...baseFiles.keys()].filter( + (path) => differs(path) || !gitModes.has(path), + ), + ), + ].sort(); } - try{ - if(!headCommit){atomicWriteSync(join(runDir,'history.txt'),'',{mode:0o600});writeJson(join(runDir,'history-status.json'),{status:'captured',range:'unborn HEAD',commits:0,bytes:0});} - else{ - const range=manifest.baseCommit?`${manifest.baseCommit}..${headCommit}`:headCommit; - admission.time();const raw=await git(repo,['-c','core.quotePath=false','log','--no-ext-diff','--no-textconv','--max-count=100','--format=commit %H%nAuthor: %an%nDate: %aI%nSubject: %s','--unified=3','-p',range,'--'],home);guard();const safe=redact(raw); - atomicWriteSync(join(runDir,'history.txt'),safe,{mode:0o600});writeJson(join(runDir,'history-status.json'),{status:'captured',range,commits:'at most 100',bytes:Buffer.byteLength(safe)}); + try { + if (!headCommit) { + atomicWriteSync(join(runDir, 'history.txt'), '', { mode: 0o600 }); + writeJson(join(runDir, 'history-status.json'), { + status: 'captured', + range: 'unborn HEAD', + commits: 0, + bytes: 0, + }); + } else { + const range = manifest.baseCommit ? `${manifest.baseCommit}..${headCommit}` : headCommit; + admission.time(); + const raw = await git( + repo, + [ + '-c', + 'core.quotePath=false', + 'log', + '--no-ext-diff', + '--no-textconv', + '--max-count=100', + '--format=commit %H%nAuthor: %an%nDate: %aI%nSubject: %s', + '--unified=3', + '-p', + range, + '--', + ], + home, + ); + guard(); + const safe = redact(raw); + atomicWriteSync(join(runDir, 'history.txt'), safe, { mode: 0o600 }); + writeJson(join(runDir, 'history-status.json'), { + status: 'captured', + range, + commits: 'at most 100', + bytes: Buffer.byteLength(safe), + }); + } + } catch (error) { + if (error instanceof CsoError && ['DEADLINE', 'SNAPSHOT_RACE', 'UNSAFE_PATH'].includes(error.code)) + throw error; + writeJson(join(runDir, 'history-status.json'), { + status: 'not_assessed', + gap: error instanceof CsoError ? error.message : 'Historical evidence could not be safely retained', + }); + } + if ((await resolveHeadCommit(repo, home)) !== headCommit) + throw new CsoError('SNAPSHOT_RACE', 'HEAD changed during snapshot capture'); + guard(); + if ( + base && + manifest.baseCommit && + (await git(repo, ['rev-parse', '--verify', `${base}^{commit}`], home)).trim() !== manifest.baseCommit + ) + throw new CsoError('SNAPSHOT_RACE', 'Comparison base changed during snapshot capture'); + guard(); + if (requiredAncestor) { + if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(requiredAncestor)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Original audit commit identity is invalid'); + if (!headCommit) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Captured current source has no commit descended from the original audit', + ); + try { + await git(repo, ['merge-base', '--is-ancestor', requiredAncestor, headCommit], home); + } catch { + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Captured current source is not a descendant of the original audited commit', + ); } - }catch(error){if(error instanceof CsoError&&['DEADLINE','SNAPSHOT_RACE','UNSAFE_PATH'].includes(error.code))throw error;writeJson(join(runDir,'history-status.json'),{status:'not_assessed',gap:error instanceof CsoError?error.message:'Historical evidence could not be safely retained'});} - if((await resolveHeadCommit(repo,home))!==headCommit)throw new CsoError('SNAPSHOT_RACE','HEAD changed during snapshot capture');guard(); - if(base&&manifest.baseCommit&&(await git(repo,['rev-parse','--verify',`${base}^{commit}`],home)).trim()!==manifest.baseCommit)throw new CsoError('SNAPSHOT_RACE','Comparison base changed during snapshot capture');guard(); - if(requiredAncestor){ - if(!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(requiredAncestor))throw new CsoError('INCOMPATIBLE_INPUT','Original audit commit identity is invalid'); - if(!headCommit)throw new CsoError('INCOMPATIBLE_INPUT','Captured current source has no commit descended from the original audit'); - try{await git(repo,['merge-base','--is-ancestor',requiredAncestor,headCommit],home);} - catch{throw new CsoError('INCOMPATIBLE_INPUT','Captured current source is not a descendant of the original audited commit');} guard(); } // Finish with a complete source check. Nothing below this block reads the // audited repository, so a late nonignored file or restored deletion cannot // fall between the final inventory and manifest publication. - await rejectSpecialFiles(repo,home,admission);guard();assertEntriesStable('Source changed before snapshot persistence'); - if(canonical(list)!==canonical(await paths(repo,home,headCommit,admission)))throw new CsoError('SNAPSHOT_RACE','Source file membership changed before snapshot persistence');guard();assertAbsent(); - admission.time();writeHelperJson(join(runDir,'sensitive-evidence.json'),sensitiveEvidence); + await rejectSpecialFiles(repo, home, admission); + guard(); + assertEntriesStable('Source changed before snapshot persistence'); + if (canonical(list) !== canonical(await paths(repo, home, headCommit, admission))) + throw new CsoError('SNAPSHOT_RACE', 'Source file membership changed before snapshot persistence'); + guard(); + assertAbsent(); + admission.time(); + writeHelperJson(join(runDir, 'sensitive-evidence.json'), sensitiveEvidence); // The manifest contains helper-computed identities and source pathnames but // never source payloads. Persist it exactly in private state: generic // content redaction would silently break the path/hash identity relation. - const serialized=JSON.stringify(manifest,null,2);if(Buffer.byteLength(serialized)+1>MAX_OUTPUT)throw new CsoError('MISSING_INPUT','Snapshot manifest exceeds the 1 MiB private-state admission limit');atomicWriteSync(join(runDir,'snapshot.json'),serialized+'\n',{mode:0o600}); return manifest; - } catch(e) { - fs.rmSync(snapshot,{recursive:true,force:true}); fs.rmSync(readable,{recursive:true,force:true});fs.rmSync(home,{recursive:true,force:true}); throw e; + const serialized = JSON.stringify(manifest, null, 2); + if (Buffer.byteLength(serialized) + 1 > MAX_OUTPUT) + throw new CsoError( + 'MISSING_INPUT', + 'Snapshot manifest exceeds the 1 MiB private-state admission limit', + ); + atomicWriteSync(join(runDir, 'snapshot.json'), serialized + '\n', { mode: 0o600 }); + return manifest; + } catch (e) { + fs.rmSync(snapshot, { recursive: true, force: true }); + fs.rmSync(readable, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + throw e; } } export function assertSnapshot(runDir: string, manifest: SnapshotManifest): void { - const root=join(runDir,'snapshot'); - if (manifest.version!==3||typeof manifest.root!=='string'||!isAbsolute(manifest.root)||!Array.isArray(manifest.entries)||(manifest.deletedPaths!==undefined&&!Array.isArray(manifest.deletedPaths))||(manifest.headCommit!==undefined&&!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.headCommit))||(manifest.baseCommit!==undefined&&!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.baseCommit))||!/^\d{4}-\d\d-\d\dT/.test(manifest.expiresAt)||Date.parse(manifest.expiresAt) <= Date.now() || !fs.existsSync(root)) throw new CsoError('MISSING_INPUT','Retained source expired or invalid; supply source with exactly matching required hashes'); - const rootStat=fs.lstatSync(root);if(rootStat.isSymbolicLink()||!rootStat.isDirectory()||(process.getuid&&rootStat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Retained snapshot root is not a private owned directory'); - const listed=():string[]=>{const out:string[]=[];const walk=(at:string,relativeRoot='')=>{for(const item of fs.readdirSync(at,{withFileTypes:true})){const rel=relativeRoot?`${relativeRoot}/${item.name}`:item.name,full=join(at,item.name),stat=fs.lstatSync(full);if(stat.isSymbolicLink()||(!stat.isDirectory()&&!stat.isFile()))throw new CsoError('UNSAFE_PATH',`Special file entered retained snapshot: ${rel}`);if(stat.isDirectory())walk(full,rel);else out.push(relativePath(rel));}};walk(root);return out.sort();}; - const entries=manifest.entries.map(e=>{if(!e||typeof e!=='object')throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid entry');const path=relativePath(e.path);if(!/^[a-f0-9]{32}$/.test(e.pathId)||e.pathId!==snapshotPathId(manifest.root,path)||!(/^[a-f0-9]{64}$/.test(e.originalHash)||e.originalHash==='not-read')||!Number.isSafeInteger(e.bytes)||e.bytes<0||!Number.isInteger(e.mode)||e.mode<0||e.mode>0o777||(e.transformation!==undefined&&typeof e.transformation!=='string'))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid source entry');if(e.executionHash!==undefined&&!/^[a-f0-9]{64}$/.test(e.executionHash))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid execution entry');if(e.originalHash==='not-read'&&(e.executionHash!==undefined||!e.transformation))throw new CsoError('INCOMPATIBLE_INPUT','Unread source cannot be represented as an execution input');return{...e,path};}); - const deleted=(manifest.deletedPaths??[]).map(item=>{if(!item||typeof item!=='object'||Object.keys(item).some(key=>!['path','pathId'].includes(key)))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid deleted path');const path=relativePath(item.path);if(!/^[a-f0-9]{32}$/.test(item.pathId)||item.pathId!==snapshotPathId(manifest.root,path))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot manifest contains an invalid deleted path');return{path,pathId:item.pathId};}); - const presentPaths=new Set(entries.map(e=>e.path)),presentIds=new Set(entries.map(e=>e.pathId)),deletedPaths=new Set(deleted.map(item=>item.path)),deletedIds=new Set(deleted.map(item=>item.pathId)); - if(presentPaths.size!==entries.length||presentIds.size!==entries.length||deletedPaths.size!==deleted.length||deletedIds.size!==deleted.length||deleted.some(item=>presentPaths.has(item.path)||presentIds.has(item.pathId))||canonical(deleted.map(item=>item.path))!==canonical([...deletedPaths].sort())||!/^([a-f0-9]{64})$/.test(manifest.originalHash)||snapshotOriginalIdentity(entries,deleted)!==manifest.originalHash)throw new CsoError('INCOMPATIBLE_INPUT','Snapshot original identity is inconsistent'); - if(manifest.changedPaths!==undefined){if(!Array.isArray(manifest.changedPaths))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot changed paths are invalid');const changed=manifest.changedPaths.map(relativePath);if(new Set(changed).size!==changed.length||canonical(changed)!==canonical([...changed].sort()))throw new CsoError('INCOMPATIBLE_INPUT','Snapshot changed paths are invalid');} + const root = join(runDir, 'snapshot'); + if ( + manifest.version !== 3 || + typeof manifest.root !== 'string' || + !isAbsolute(manifest.root) || + !Array.isArray(manifest.entries) || + (manifest.deletedPaths !== undefined && !Array.isArray(manifest.deletedPaths)) || + (manifest.headCommit !== undefined && !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.headCommit)) || + (manifest.baseCommit !== undefined && !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(manifest.baseCommit)) || + !/^\d{4}-\d\d-\d\dT/.test(manifest.expiresAt) || + Date.parse(manifest.expiresAt) <= Date.now() || + !fs.existsSync(root) + ) + throw new CsoError( + 'MISSING_INPUT', + 'Retained source expired or invalid; supply source with exactly matching required hashes', + ); + const rootStat = fs.lstatSync(root); + if ( + rootStat.isSymbolicLink() || + !rootStat.isDirectory() || + (process.getuid && rootStat.uid !== process.getuid()) + ) + throw new CsoError('UNSAFE_PATH', 'Retained snapshot root is not a private owned directory'); + const listed = (): string[] => { + const out: string[] = []; + const walk = (at: string, relativeRoot = '') => { + for (const item of fs.readdirSync(at, { withFileTypes: true })) { + const rel = relativeRoot ? `${relativeRoot}/${item.name}` : item.name, + full = join(at, item.name), + stat = fs.lstatSync(full); + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) + throw new CsoError('UNSAFE_PATH', `Special file entered retained snapshot: ${rel}`); + if (stat.isDirectory()) walk(full, rel); + else out.push(relativePath(rel)); + } + }; + walk(root); + return out.sort(); + }; + const entries = manifest.entries.map((e) => { + if (!e || typeof e !== 'object') + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot manifest contains an invalid entry'); + const path = relativePath(e.path); + if ( + !/^[a-f0-9]{32}$/.test(e.pathId) || + e.pathId !== snapshotPathId(manifest.root, path) || + !(/^[a-f0-9]{64}$/.test(e.originalHash) || e.originalHash === 'not-read') || + !Number.isSafeInteger(e.bytes) || + e.bytes < 0 || + !Number.isInteger(e.mode) || + e.mode < 0 || + e.mode > 0o777 || + (e.transformation !== undefined && typeof e.transformation !== 'string') + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot manifest contains an invalid source entry'); + if (e.executionHash !== undefined && !/^[a-f0-9]{64}$/.test(e.executionHash)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot manifest contains an invalid execution entry'); + if (e.originalHash === 'not-read' && (e.executionHash !== undefined || !e.transformation)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Unread source cannot be represented as an execution input'); + return { ...e, path }; + }); + const deleted = (manifest.deletedPaths ?? []).map((item) => { + if ( + !item || + typeof item !== 'object' || + Object.keys(item).some((key) => !['path', 'pathId'].includes(key)) + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot manifest contains an invalid deleted path'); + const path = relativePath(item.path); + if (!/^[a-f0-9]{32}$/.test(item.pathId) || item.pathId !== snapshotPathId(manifest.root, path)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot manifest contains an invalid deleted path'); + return { path, pathId: item.pathId }; + }); + const presentPaths = new Set(entries.map((e) => e.path)), + presentIds = new Set(entries.map((e) => e.pathId)), + deletedPaths = new Set(deleted.map((item) => item.path)), + deletedIds = new Set(deleted.map((item) => item.pathId)); + if ( + presentPaths.size !== entries.length || + presentIds.size !== entries.length || + deletedPaths.size !== deleted.length || + deletedIds.size !== deleted.length || + deleted.some((item) => presentPaths.has(item.path) || presentIds.has(item.pathId)) || + canonical(deleted.map((item) => item.path)) !== canonical([...deletedPaths].sort()) || + !/^([a-f0-9]{64})$/.test(manifest.originalHash) || + snapshotOriginalIdentity(entries, deleted) !== manifest.originalHash + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot original identity is inconsistent'); + if (manifest.changedPaths !== undefined) { + if (!Array.isArray(manifest.changedPaths)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot changed paths are invalid'); + const changed = manifest.changedPaths.map(relativePath); + if (new Set(changed).size !== changed.length || canonical(changed) !== canonical([...changed].sort())) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot changed paths are invalid'); + } // Capture already records entries in Git's deterministic code-unit path // order. Preserve that manifest order here: localeCompare can reorder an // uppercase path such as README.md after lowercase source files, producing // a different execution identity from the one written at capture time. - const expected=entries.filter(e=>e.executionHash); - if(!/^([a-f0-9]{64})$/.test(manifest.executionHash)||sha256(canonical(expected.map(e=>[e.path,e.executionHash,e.mode])))!==manifest.executionHash)throw new CsoError('INCOMPATIBLE_INPUT','Snapshot execution identity is inconsistent'); - const before=listed();if(canonical(before)!==canonical(expected.map(e=>e.path)))throw new CsoError('INCOMPATIBLE_INPUT','Retained snapshot membership changed'); + const expected = entries.filter((e) => e.executionHash); + if ( + !/^([a-f0-9]{64})$/.test(manifest.executionHash) || + sha256(canonical(expected.map((e) => [e.path, e.executionHash, e.mode]))) !== manifest.executionHash + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Snapshot execution identity is inconsistent'); + const before = listed(); + if (canonical(before) !== canonical(expected.map((e) => e.path))) + throw new CsoError('INCOMPATIBLE_INPUT', 'Retained snapshot membership changed'); for (const e of expected) { - const current=readStable(root,e.path); - if (sha256(current.data) !== e.executionHash || current.mode!==e.mode) throw new CsoError('INCOMPATIBLE_INPUT',`Retained snapshot changed: ${e.path}`); + const current = readStable(root, e.path); + if (sha256(current.data) !== e.executionHash || current.mode !== e.mode) + throw new CsoError('INCOMPATIBLE_INPUT', `Retained snapshot changed: ${e.path}`); } - if(canonical(before)!==canonical(listed()))throw new CsoError('SNAPSHOT_RACE','Retained snapshot membership changed during validation'); + if (canonical(before) !== canonical(listed())) + throw new CsoError('SNAPSHOT_RACE', 'Retained snapshot membership changed during validation'); } diff --git a/lib/cso/state.ts b/lib/cso/state.ts index 39e889a22..279a6a66f 100644 --- a/lib/cso/state.ts +++ b/lib/cso/state.ts @@ -2,288 +2,873 @@ import * as fs from 'node:fs'; import { basename, dirname, isAbsolute, join, resolve, parse, relative, sep } from 'node:path'; import { randomBytes } from 'node:crypto'; import { atomicWriteSync } from '../fs-atomic'; -import { CsoError, RunReportV3, canonical, completeness, fingerprint, renderReport, sha256 } from './contracts'; +import { + CsoError, + RunReportV3, + canonical, + completeness, + fingerprint, + renderReport, + sha256, +} from './contracts'; import { redact, sanitizeForJson, sanitizeHelperForJson } from './process'; -const MAX_STATE_FILE=1024*1024; +const MAX_STATE_FILE = 1024 * 1024; -type ExactStats=Pick & Pick; -function exactStats(stat:fs.BigIntStats):ExactStats{ - for(const value of [stat.nlink,stat.size,stat.mode,stat.uid])if(value>BigInt(Number.MAX_SAFE_INTEGER)||value< -BigInt(Number.MAX_SAFE_INTEGER))throw new CsoError('UNSAFE_PATH','Filesystem metadata exceeds safe bounds'); - return{dev:stat.dev,ino:stat.ino,mtimeNs:stat.mtimeNs,ctimeNs:stat.ctimeNs,nlink:Number(stat.nlink),size:Number(stat.size),mode:Number(stat.mode),uid:Number(stat.uid),isFile:()=>stat.isFile(),isSymbolicLink:()=>stat.isSymbolicLink(),isDirectory:()=>stat.isDirectory()}; +type ExactStats = Pick< + fs.BigIntStats, + 'dev' | 'ino' | 'mtimeNs' | 'ctimeNs' | 'isFile' | 'isSymbolicLink' | 'isDirectory' +> & + Pick; +function exactStats(stat: fs.BigIntStats): ExactStats { + for (const value of [stat.nlink, stat.size, stat.mode, stat.uid]) + if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < -BigInt(Number.MAX_SAFE_INTEGER)) + throw new CsoError('UNSAFE_PATH', 'Filesystem metadata exceeds safe bounds'); + return { + dev: stat.dev, + ino: stat.ino, + mtimeNs: stat.mtimeNs, + ctimeNs: stat.ctimeNs, + nlink: Number(stat.nlink), + size: Number(stat.size), + mode: Number(stat.mode), + uid: Number(stat.uid), + isFile: () => stat.isFile(), + isSymbolicLink: () => stat.isSymbolicLink(), + isDirectory: () => stat.isDirectory(), + }; } -function exactLstat(path:string):ExactStats{return exactStats(fs.lstatSync(path,{bigint:true}));} -function exactFstat(fd:number):ExactStats{return exactStats(fs.fstatSync(fd,{bigint:true}));} -type AtomicRecoveryIdentity={dev:bigint;ino:bigint;nlink:number;size:number;mode:number;uid:number;mtimeNs:bigint;ctimeNs:bigint}; +function exactLstat(path: string): ExactStats { + return exactStats(fs.lstatSync(path, { bigint: true })); +} +function exactFstat(fd: number): ExactStats { + return exactStats(fs.fstatSync(fd, { bigint: true })); +} +type AtomicRecoveryIdentity = { + dev: bigint; + ino: bigint; + nlink: number; + size: number; + mode: number; + uid: number; + mtimeNs: bigint; + ctimeNs: bigint; +}; export interface AtomicNoReplaceRecoveryOptions { - label:string;maxBytes:number; - validate?:(value:unknown,publisherPid:number)=>void; - publisherAlive?:(value:unknown,publisherPid:number)=>boolean; + label: string; + maxBytes: number; + validate?: (value: unknown, publisherPid: number) => void; + publisherAlive?: (value: unknown, publisherPid: number) => boolean; } -class AtomicPublicationTransition extends CsoError { constructor(message:string){super('SNAPSHOT_RACE',message);this.name='AtomicPublicationTransition';} } -function recoveryIdentity(stat:ExactStats):AtomicRecoveryIdentity{return{dev:stat.dev,ino:stat.ino,nlink:stat.nlink,size:stat.size,mode:stat.mode,uid:stat.uid,mtimeNs:stat.mtimeNs,ctimeNs:stat.ctimeNs};} -function sameRecoveryIdentity(left:AtomicRecoveryIdentity,right:AtomicRecoveryIdentity):boolean{return left.dev===right.dev&&left.ino===right.ino&&left.nlink===right.nlink&&left.size===right.size&&left.mode===right.mode&&left.uid===right.uid&&left.mtimeNs===right.mtimeNs&&left.ctimeNs===right.ctimeNs;} -function recoveryProcessAlive(pid:number):boolean{try{process.kill(pid,0);return true;}catch(error:any){return error?.code==='EPERM';}} -function liveRecognizedPublication(temp:string,target:string,pid:number,options:AtomicNoReplaceRecoveryOptions):boolean{ - if(!recoveryProcessAlive(pid))return false; - try{const temporary=exactLstat(temp);if(temporary.isSymbolicLink()||!temporary.isFile()||temporary.nlink<1||temporary.nlink>2||(process.getuid&&temporary.uid!==process.getuid())||(process.platform!=='win32'&&(temporary.mode&0o077)!==0))return false;if(temporary.size===0)return temporary.nlink===1;if(temporary.nlink!==2||!privatePublicationFile(temporary,options))return false;const published=exactLstat(target);return published.nlink===2&&samePublicationInode(temporary,published,options);}catch{return false;} +class AtomicPublicationTransition extends CsoError { + constructor(message: string) { + super('SNAPSHOT_RACE', message); + this.name = 'AtomicPublicationTransition'; + } } -function liveEmptyPublication(path:string,pid:number):boolean{if(!recoveryProcessAlive(pid))return false;try{const stat=exactLstat(path);return stat.isFile()&&!stat.isSymbolicLink()&&stat.size===0&&stat.nlink===1&&(!process.getuid||stat.uid===process.getuid())&&(process.platform==='win32'||(stat.mode&0o077)===0);}catch{return false;}} -function privatePublicationObservation(stat:ExactStats,options:AtomicNoReplaceRecoveryOptions):boolean{return stat.isFile()&&!stat.isSymbolicLink()&&stat.size>=0&&stat.size<=options.maxBytes&&stat.nlink>=1&&stat.nlink<=2&&(!process.getuid||stat.uid===process.getuid())&&(process.platform==='win32'||(stat.mode&0o077)===0);} -function livePublicationAdvanced(temp:string,pid:number,observed:ExactStats|undefined,options:AtomicNoReplaceRecoveryOptions):boolean{ - if(!observed||!privatePublicationObservation(observed,options)||!recoveryProcessAlive(pid))return false; - Atomics.wait(LEASE_ELECTION_WAIT,0,0,LEASE_ELECTION_POLL_MS); - let current:ExactStats;try{current=exactLstat(temp);}catch(error:any){return error?.code==='ENOENT';} - if(!privatePublicationObservation(current,options)||current.dev!==observed.dev||current.ino!==observed.ino)return false; - if(current.nlink!==observed.nlink||current.size!==observed.size)return true; +function recoveryIdentity(stat: ExactStats): AtomicRecoveryIdentity { + return { + dev: stat.dev, + ino: stat.ino, + nlink: stat.nlink, + size: stat.size, + mode: stat.mode, + uid: stat.uid, + mtimeNs: stat.mtimeNs, + ctimeNs: stat.ctimeNs, + }; +} +function sameRecoveryIdentity(left: AtomicRecoveryIdentity, right: AtomicRecoveryIdentity): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.nlink === right.nlink && + left.size === right.size && + left.mode === right.mode && + left.uid === right.uid && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} +function recoveryProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: any) { + return error?.code === 'EPERM'; + } +} +function liveRecognizedPublication( + temp: string, + target: string, + pid: number, + options: AtomicNoReplaceRecoveryOptions, +): boolean { + if (!recoveryProcessAlive(pid)) return false; + try { + const temporary = exactLstat(temp); + if ( + temporary.isSymbolicLink() || + !temporary.isFile() || + temporary.nlink < 1 || + temporary.nlink > 2 || + (process.getuid && temporary.uid !== process.getuid()) || + (process.platform !== 'win32' && (temporary.mode & 0o077) !== 0) + ) + return false; + if (temporary.size === 0) return temporary.nlink === 1; + if (temporary.nlink !== 2 || !privatePublicationFile(temporary, options)) return false; + const published = exactLstat(target); + return published.nlink === 2 && samePublicationInode(temporary, published, options); + } catch { + return false; + } +} +function liveEmptyPublication(path: string, pid: number): boolean { + if (!recoveryProcessAlive(pid)) return false; + try { + const stat = exactLstat(path); + return ( + stat.isFile() && + !stat.isSymbolicLink() && + stat.size === 0 && + stat.nlink === 1 && + (!process.getuid || stat.uid === process.getuid()) && + (process.platform === 'win32' || (stat.mode & 0o077) === 0) + ); + } catch { + return false; + } +} +function privatePublicationObservation(stat: ExactStats, options: AtomicNoReplaceRecoveryOptions): boolean { + return ( + stat.isFile() && + !stat.isSymbolicLink() && + stat.size >= 0 && + stat.size <= options.maxBytes && + stat.nlink >= 1 && + stat.nlink <= 2 && + (!process.getuid || stat.uid === process.getuid()) && + (process.platform === 'win32' || (stat.mode & 0o077) === 0) + ); +} +function livePublicationAdvanced( + temp: string, + pid: number, + observed: ExactStats | undefined, + options: AtomicNoReplaceRecoveryOptions, +): boolean { + if (!observed || !privatePublicationObservation(observed, options) || !recoveryProcessAlive(pid)) + return false; + Atomics.wait(LEASE_ELECTION_WAIT, 0, 0, LEASE_ELECTION_POLL_MS); + let current: ExactStats; + try { + current = exactLstat(temp); + } catch (error: any) { + return error?.code === 'ENOENT'; + } + if ( + !privatePublicationObservation(current, options) || + current.dev !== observed.dev || + current.ino !== observed.ino + ) + return false; + if (current.nlink !== observed.nlink || current.size !== observed.size) return true; return false; } -function publicationOwnerAlive(value:unknown,publisherPid:number,options:AtomicNoReplaceRecoveryOptions):boolean{return options.publisherAlive?.(value,publisherPid)??recoveryProcessAlive(publisherPid);} -function privatePublicationFile(stat:ExactStats,options:AtomicNoReplaceRecoveryOptions):boolean{return stat.isFile()&&!stat.isSymbolicLink()&&stat.size>0&&stat.size<=options.maxBytes&& - (!process.getuid||stat.uid===process.getuid())&&(process.platform==='win32'||(stat.mode&0o077)===0);} -function samePublicationObject(left:ExactStats,right:ExactStats,options:AtomicNoReplaceRecoveryOptions):boolean{return privatePublicationFile(left,options)&&privatePublicationFile(right,options)&& - left.dev===right.dev&&left.ino===right.ino&&left.size===right.size&&left.mode===right.mode&&left.uid===right.uid;} -function samePublicationInode(left:ExactStats,right:ExactStats,options:AtomicNoReplaceRecoveryOptions):boolean{return samePublicationObject(left,right,options)&&left.mtimeNs===right.mtimeNs;} -function publicationLinkTransition(observed:ExactStats,current:ExactStats,links:1|2,options:AtomicNoReplaceRecoveryOptions):boolean{ - const from=links===1?1:2,to=links===1?2:1; - return observed.nlink===from&¤t.nlink===to&&samePublicationInode(observed,current,options); +function publicationOwnerAlive( + value: unknown, + publisherPid: number, + options: AtomicNoReplaceRecoveryOptions, +): boolean { + return options.publisherAlive?.(value, publisherPid) ?? recoveryProcessAlive(publisherPid); } -function publicationPathRemoved(observed:ExactStats,current:ExactStats,options:AtomicNoReplaceRecoveryOptions):boolean{return observed.nlink>=1&&observed.nlink<=2&¤t.nlink>=0&¤t.nlink=0&&left.nlink<=2&&right.nlink>=0&&right.nlink<=2&&left.nlink!==right.nlink&&samePublicationInode(left,right,options);} -function atomicTempTarget(path:string,publisherPid?:number):{target:string;pid:number}|undefined{ - const match=basename(path).match(/^(.*)\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/),pid=match?Number(match[2]):0; - return match&&match[1]&&Number.isSafeInteger(pid)&&pid>1&&(publisherPid===undefined||pid===publisherPid)?{target:join(dirname(path),match[1]),pid}:undefined; +function privatePublicationFile(stat: ExactStats, options: AtomicNoReplaceRecoveryOptions): boolean { + return ( + stat.isFile() && + !stat.isSymbolicLink() && + stat.size > 0 && + stat.size <= options.maxBytes && + (!process.getuid || stat.uid === process.getuid()) && + (process.platform === 'win32' || (stat.mode & 0o077) === 0) + ); } -function settledAtomicTemp(path:string,observed:ExactStats,options:AtomicNoReplaceRecoveryOptions):boolean{ - const publication=atomicTempTarget(path);if(!publication)return false; - let target:ExactStats;try{target=exactLstat(publication.target);}catch{return false;} - return observed.nlink>=1&&observed.nlink<=2&&target.nlink===1&&privatePublicationFile(observed,options)&&privatePublicationFile(target,options)&& - observed.dev===target.dev&&observed.ino===target.ino&&observed.size===target.size&&observed.mode===target.mode&&observed.uid===target.uid&&observed.mtimeNs===target.mtimeNs; +function samePublicationObject( + left: ExactStats, + right: ExactStats, + options: AtomicNoReplaceRecoveryOptions, +): boolean { + return ( + privatePublicationFile(left, options) && + privatePublicationFile(right, options) && + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mode === right.mode && + left.uid === right.uid + ); } -function readPublicationBytes(fd:number,size:number,label:string):string{ - const bytes=Buffer.alloc(size);let offset=0; - while(offset= 1 && + observed.nlink <= 2 && + current.nlink >= 0 && + current.nlink < observed.nlink && + samePublicationObject(observed, current, options) + ); +} +function publicationProgress( + left: ExactStats, + right: ExactStats, + options: AtomicNoReplaceRecoveryOptions, +): boolean { + return ( + left.nlink >= 0 && + left.nlink <= 2 && + right.nlink >= 0 && + right.nlink <= 2 && + left.nlink !== right.nlink && + samePublicationInode(left, right, options) + ); +} +function atomicTempTarget(path: string, publisherPid?: number): { target: string; pid: number } | undefined { + const match = basename(path).match(/^(.*)\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/), + pid = match ? Number(match[2]) : 0; + return match && + match[1] && + Number.isSafeInteger(pid) && + pid > 1 && + (publisherPid === undefined || pid === publisherPid) + ? { target: join(dirname(path), match[1]), pid } + : undefined; +} +function settledAtomicTemp( + path: string, + observed: ExactStats, + options: AtomicNoReplaceRecoveryOptions, +): boolean { + const publication = atomicTempTarget(path); + if (!publication) return false; + let target: ExactStats; + try { + target = exactLstat(publication.target); + } catch { + return false; + } + return ( + observed.nlink >= 1 && + observed.nlink <= 2 && + target.nlink === 1 && + privatePublicationFile(observed, options) && + privatePublicationFile(target, options) && + observed.dev === target.dev && + observed.ino === target.ino && + observed.size === target.size && + observed.mode === target.mode && + observed.uid === target.uid && + observed.mtimeNs === target.mtimeNs + ); +} +function readPublicationBytes(fd: number, size: number, label: string): string { + const bytes = Buffer.alloc(size); + let offset = 0; + while (offset < size) { + const count = fs.readSync(fd, bytes, offset, size - offset, offset); + if (count <= 0) + throw new CsoError('SNAPSHOT_RACE', `${label} interrupted publication changed while it was read`); + offset += count; + } + const extra = Buffer.alloc(1); + if (fs.readSync(fd, extra, 0, 1, size) !== 0) + throw new CsoError('SNAPSHOT_RACE', `${label} interrupted publication changed while it was read`); return bytes.toString('utf8'); } -function recoveryJson(path:string,links:1|2,options:AtomicNoReplaceRecoveryOptions,observed?:ExactStats):{identity:AtomicRecoveryIdentity;value:unknown}{ - let fd:number|undefined; - try{ - const before=exactLstat(path); - if(before.nlink===0){ - let current:ExactStats;try{current=exactLstat(path);}catch(error:any){if(error?.code==='ENOENT')throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} was removed while it was inspected`);throw error;} - if(samePublicationInode(before,current,options)&&(current.nlink===0||current.nlink===links))throw new AtomicPublicationTransition(`${options.label} changed link state while it was inspected`); - throw new CsoError('UNSAFE_PATH',`${options.label} was replaced while it was inspected`); +function recoveryJson( + path: string, + links: 1 | 2, + options: AtomicNoReplaceRecoveryOptions, + observed?: ExactStats, +): { identity: AtomicRecoveryIdentity; value: unknown } { + let fd: number | undefined; + try { + const before = exactLstat(path); + if (before.nlink === 0) { + let current: ExactStats; + try { + current = exactLstat(path); + } catch (error: any) { + if (error?.code === 'ENOENT') + throw new CsoError('INSUFFICIENT_CAPACITY', `${options.label} was removed while it was inspected`); + throw error; + } + if (samePublicationInode(before, current, options) && (current.nlink === 0 || current.nlink === links)) + throw new AtomicPublicationTransition(`${options.label} changed link state while it was inspected`); + throw new CsoError('UNSAFE_PATH', `${options.label} was replaced while it was inspected`); } - if(observed&&publicationLinkTransition(observed,before,links,options))throw new AtomicPublicationTransition(`${options.label} interrupted publication changed link state`); - if(observed&&publicationProgress(observed,before,options)&&!sameRecoveryIdentity(recoveryIdentity(observed),recoveryIdentity(before)))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} changed phase during concurrent recovery`); - if(!before.isFile()||before.isSymbolicLink()||before.nlink!==links||before.size<=0||before.size>options.maxBytes|| - (process.getuid&&before.uid!==process.getuid())||(process.platform!=='win32'&&(before.mode&0o077)!==0)) - throw new CsoError('UNSAFE_PATH',`${options.label} interrupted publication is not one private regular file`); - fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0));const opened=exactFstat(fd); - if(!sameRecoveryIdentity(recoveryIdentity(before),recoveryIdentity(opened))){ - if(publicationLinkTransition(before,opened,links,options))throw new AtomicPublicationTransition(`${options.label} interrupted publication changed link state while it was opened`); - if(publicationProgress(before,opened,options))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} changed phase during concurrent recovery while it was opened`); - throw new CsoError('SNAPSHOT_RACE',`${options.label} interrupted publication changed while it was opened`); + if (observed && publicationLinkTransition(observed, before, links, options)) + throw new AtomicPublicationTransition(`${options.label} interrupted publication changed link state`); + if ( + observed && + publicationProgress(observed, before, options) && + !sameRecoveryIdentity(recoveryIdentity(observed), recoveryIdentity(before)) + ) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} changed phase during concurrent recovery`, + ); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== links || + before.size <= 0 || + before.size > options.maxBytes || + (process.getuid && before.uid !== process.getuid()) || + (process.platform !== 'win32' && (before.mode & 0o077) !== 0) + ) + throw new CsoError( + 'UNSAFE_PATH', + `${options.label} interrupted publication is not one private regular file`, + ); + fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const opened = exactFstat(fd); + if (!sameRecoveryIdentity(recoveryIdentity(before), recoveryIdentity(opened))) { + if (publicationLinkTransition(before, opened, links, options)) + throw new AtomicPublicationTransition( + `${options.label} interrupted publication changed link state while it was opened`, + ); + if (publicationProgress(before, opened, options)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} changed phase during concurrent recovery while it was opened`, + ); + throw new CsoError( + 'SNAPSHOT_RACE', + `${options.label} interrupted publication changed while it was opened`, + ); } - const serialized=readPublicationBytes(fd,opened.size,options.label);let value:unknown;try{value=JSON.parse(serialized);}catch{throw new CsoError('UNSAFE_PATH',`${options.label} interrupted publication is not valid JSON`);} - const final=exactFstat(fd);if(readPublicationBytes(fd,opened.size,options.label)!==serialized)throw new CsoError('SNAPSHOT_RACE',`${options.label} interrupted publication changed while it was read`);let after:ExactStats;try{after=exactLstat(path);}catch(error:any){if(error?.code==='ENOENT'&&publicationPathRemoved(opened,final,options))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} was removed by another recovery helper while it was read`);throw error;}const openedIdentity=recoveryIdentity(opened),finalIdentity=recoveryIdentity(final),afterIdentity=recoveryIdentity(after); - if(!sameRecoveryIdentity(openedIdentity,finalIdentity)||!sameRecoveryIdentity(openedIdentity,afterIdentity)){ - const coherentTransition=(sameRecoveryIdentity(openedIdentity,finalIdentity)&&publicationLinkTransition(opened,after,links,options))|| - (publicationLinkTransition(opened,final,links,options)&&sameRecoveryIdentity(finalIdentity,afterIdentity)); - if(coherentTransition)throw new AtomicPublicationTransition(`${options.label} interrupted publication changed link state while it was read`); - if(publicationProgress(opened,final,options)&&publicationProgress(final,after,options))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} changed phase during concurrent recovery while it was read`); - throw new CsoError('SNAPSHOT_RACE',`${options.label} interrupted publication changed while it was read`); + const serialized = readPublicationBytes(fd, opened.size, options.label); + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new CsoError('UNSAFE_PATH', `${options.label} interrupted publication is not valid JSON`); } - return{identity:recoveryIdentity(opened),value}; - }catch(error:any){if(error instanceof CsoError)throw error;if(error?.code==='ENOENT'){if(observed&&settledAtomicTemp(path,observed,options))throw new AtomicPublicationTransition(`${options.label} interrupted publication settled while it was observed`);if(observed&&!fs.existsSync(path))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} was removed by another recovery helper`);throw new CsoError('SNAPSHOT_RACE',`${options.label} interrupted publication disappeared`);}throw new CsoError('UNSAFE_PATH',`${options.label} interrupted publication could not be validated`);} - finally{if(fd!==undefined)try{fs.closeSync(fd);}catch{}} + const final = exactFstat(fd); + if (readPublicationBytes(fd, opened.size, options.label) !== serialized) + throw new CsoError( + 'SNAPSHOT_RACE', + `${options.label} interrupted publication changed while it was read`, + ); + let after: ExactStats; + try { + after = exactLstat(path); + } catch (error: any) { + if (error?.code === 'ENOENT' && publicationPathRemoved(opened, final, options)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} was removed by another recovery helper while it was read`, + ); + throw error; + } + const openedIdentity = recoveryIdentity(opened), + finalIdentity = recoveryIdentity(final), + afterIdentity = recoveryIdentity(after); + if ( + !sameRecoveryIdentity(openedIdentity, finalIdentity) || + !sameRecoveryIdentity(openedIdentity, afterIdentity) + ) { + const coherentTransition = + (sameRecoveryIdentity(openedIdentity, finalIdentity) && + publicationLinkTransition(opened, after, links, options)) || + (publicationLinkTransition(opened, final, links, options) && + sameRecoveryIdentity(finalIdentity, afterIdentity)); + if (coherentTransition) + throw new AtomicPublicationTransition( + `${options.label} interrupted publication changed link state while it was read`, + ); + if (publicationProgress(opened, final, options) && publicationProgress(final, after, options)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} changed phase during concurrent recovery while it was read`, + ); + throw new CsoError( + 'SNAPSHOT_RACE', + `${options.label} interrupted publication changed while it was read`, + ); + } + return { identity: recoveryIdentity(opened), value }; + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code === 'ENOENT') { + if (observed && settledAtomicTemp(path, observed, options)) + throw new AtomicPublicationTransition( + `${options.label} interrupted publication settled while it was observed`, + ); + if (observed && !fs.existsSync(path)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} was removed by another recovery helper`, + ); + throw new CsoError('SNAPSHOT_RACE', `${options.label} interrupted publication disappeared`); + } + throw new CsoError('UNSAFE_PATH', `${options.label} interrupted publication could not be validated`); + } finally { + if (fd !== undefined) + try { + fs.closeSync(fd); + } catch {} + } } -function atomicTempCandidates(target:string):Array<{path:string;pid:number}>{ - const directory=dirname(target),name=basename(target),escaped=name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),pattern=new RegExp(`^${escaped}\\.tmp\\.(\\d{1,10})\\.([a-f0-9]{8})$`); - return fs.readdirSync(directory).flatMap(entry=>{const match=entry.match(pattern),pid=match?Number(match[1]):0;return match&&Number.isSafeInteger(pid)&&pid>1?[{path:join(directory,entry),pid}]:[];}); +function atomicTempCandidates(target: string): Array<{ path: string; pid: number }> { + const directory = dirname(target), + name = basename(target), + escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + pattern = new RegExp(`^${escaped}\\.tmp\\.(\\d{1,10})\\.([a-f0-9]{8})$`); + return fs.readdirSync(directory).flatMap((entry) => { + const match = entry.match(pattern), + pid = match ? Number(match[1]) : 0; + return match && Number.isSafeInteger(pid) && pid > 1 ? [{ path: join(directory, entry), pid }] : []; + }); +} +function matchesRecoveryInode( + stat: ExactStats, + identity: AtomicRecoveryIdentity, + options: AtomicNoReplaceRecoveryOptions, +): boolean { + return ( + privatePublicationFile(stat, options) && + stat.dev === identity.dev && + stat.ino === identity.ino && + stat.size === identity.size && + stat.mode === identity.mode && + stat.uid === identity.uid && + stat.mtimeNs === identity.mtimeNs + ); } -function matchesRecoveryInode(stat:ExactStats,identity:AtomicRecoveryIdentity,options:AtomicNoReplaceRecoveryOptions):boolean{return privatePublicationFile(stat,options)&&stat.dev===identity.dev&&stat.ino===identity.ino&&stat.size===identity.size&&stat.mode===identity.mode&&stat.uid===identity.uid&&stat.mtimeNs===identity.mtimeNs;} /** Recover only the hard-link publication window of atomicWriteSync(noReplace). */ -export function recoverAtomicNoReplaceJson(target:string,options:AtomicNoReplaceRecoveryOptions):void{ - let targetStat:ExactStats;try{targetStat=exactLstat(target);}catch(error:any){if(error?.code==='ENOENT')return;throw new CsoError('UNSAFE_PATH',`${options.label} could not be inspected`);} +export function recoverAtomicNoReplaceJson(target: string, options: AtomicNoReplaceRecoveryOptions): void { + let targetStat: ExactStats; + try { + targetStat = exactLstat(target); + } catch (error: any) { + if (error?.code === 'ENOENT') return; + throw new CsoError('UNSAFE_PATH', `${options.label} could not be inspected`); + } // Callers own legacy-directory and special-file handling. Only a regular // file can be the no-replace hard-link publication this helper recognizes. - if(!targetStat.isFile()||targetStat.isSymbolicLink())return; - if(targetStat.nlink===1)return; - if(targetStat.nlink===0){ - let current:ExactStats;try{current=exactLstat(target);}catch(error:any){if(error?.code==='ENOENT')throw new AtomicPublicationTransition(`${options.label} was removed while it was inspected`);throw new CsoError('UNSAFE_PATH',`${options.label} could not be reinspected`);} - if(samePublicationInode(targetStat,current,options)&¤t.nlink>=0&¤t.nlink<=2)throw new AtomicPublicationTransition(`${options.label} changed link state while it was inspected`); - throw new CsoError('UNSAFE_PATH',`${options.label} was replaced while it was inspected`); - } - if(targetStat.nlink!==2)throw new CsoError('UNSAFE_PATH',`${options.label} has an unrecognized hard-link count`); - const canonical=recoveryJson(target,2,options,targetStat),matches=atomicTempCandidates(target).flatMap(candidate=>{try{const observed=exactLstat(candidate.path);return observed.dev===canonical.identity.dev&&observed.ino===canonical.identity.ino?[{...candidate,observed}]:[];}catch{return[];}}); - if(matches.length!==1){ - let settled:ExactStats|undefined;try{settled=exactLstat(target);}catch(error:any){ - if(matches.length===0&&error?.code==='ENOENT')throw new AtomicPublicationTransition(`${options.label} was removed during candidate enumeration`); + if (!targetStat.isFile() || targetStat.isSymbolicLink()) return; + if (targetStat.nlink === 1) return; + if (targetStat.nlink === 0) { + let current: ExactStats; + try { + current = exactLstat(target); + } catch (error: any) { + if (error?.code === 'ENOENT') + throw new AtomicPublicationTransition(`${options.label} was removed while it was inspected`); + throw new CsoError('UNSAFE_PATH', `${options.label} could not be reinspected`); } - if(settled&&publicationLinkTransition(targetStat,settled,2,options))throw new AtomicPublicationTransition(`${options.label} interrupted publication settled during candidate enumeration`); - throw new CsoError('UNSAFE_PATH',`${options.label} hard link does not match one recognized interrupted publication`); + if (samePublicationInode(targetStat, current, options) && current.nlink >= 0 && current.nlink <= 2) + throw new AtomicPublicationTransition(`${options.label} changed link state while it was inspected`); + throw new CsoError('UNSAFE_PATH', `${options.label} was replaced while it was inspected`); } - const candidate=matches[0],temporary=recoveryJson(candidate.path,2,options,candidate.observed); - if(!sameRecoveryIdentity(canonical.identity,temporary.identity))throw new CsoError('UNSAFE_PATH',`${options.label} hard link changed identity`); - options.validate?.(canonical.value,candidate.pid);options.validate?.(temporary.value,candidate.pid); - if(publicationOwnerAlive(canonical.value,candidate.pid,options))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} publication is still owned by a live helper`); - let finalTarget:ExactStats,finalTemp:ExactStats; - try{finalTarget=exactLstat(target);finalTemp=exactLstat(candidate.path);}catch(error:any){ - if(error?.code!=='ENOENT')throw error; - for(const path of [target,candidate.path]){try{const stat=exactLstat(path);if(!matchesRecoveryInode(stat,canonical.identity,options))throw new CsoError('UNSAFE_PATH',`${options.label} was replaced during concurrent recovery`);}catch(recoveryError:any){if(recoveryError instanceof CsoError)throw recoveryError;if(recoveryError?.code!=='ENOENT')throw recoveryError;}} + if (targetStat.nlink !== 2) + throw new CsoError('UNSAFE_PATH', `${options.label} has an unrecognized hard-link count`); + const canonical = recoveryJson(target, 2, options, targetStat), + matches = atomicTempCandidates(target).flatMap((candidate) => { + try { + const observed = exactLstat(candidate.path); + return observed.dev === canonical.identity.dev && observed.ino === canonical.identity.ino + ? [{ ...candidate, observed }] + : []; + } catch { + return []; + } + }); + if (matches.length !== 1) { + let settled: ExactStats | undefined; + try { + settled = exactLstat(target); + } catch (error: any) { + if (matches.length === 0 && error?.code === 'ENOENT') + throw new AtomicPublicationTransition(`${options.label} was removed during candidate enumeration`); + } + if (settled && publicationLinkTransition(targetStat, settled, 2, options)) + throw new AtomicPublicationTransition( + `${options.label} interrupted publication settled during candidate enumeration`, + ); + throw new CsoError( + 'UNSAFE_PATH', + `${options.label} hard link does not match one recognized interrupted publication`, + ); + } + const candidate = matches[0], + temporary = recoveryJson(candidate.path, 2, options, candidate.observed); + if (!sameRecoveryIdentity(canonical.identity, temporary.identity)) + throw new CsoError('UNSAFE_PATH', `${options.label} hard link changed identity`); + options.validate?.(canonical.value, candidate.pid); + options.validate?.(temporary.value, candidate.pid); + if (publicationOwnerAlive(canonical.value, candidate.pid, options)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} publication is still owned by a live helper`, + ); + let finalTarget: ExactStats, finalTemp: ExactStats; + try { + finalTarget = exactLstat(target); + finalTemp = exactLstat(candidate.path); + } catch (error: any) { + if (error?.code !== 'ENOENT') throw error; + for (const path of [target, candidate.path]) { + try { + const stat = exactLstat(path); + if (!matchesRecoveryInode(stat, canonical.identity, options)) + throw new CsoError('UNSAFE_PATH', `${options.label} was replaced during concurrent recovery`); + } catch (recoveryError: any) { + if (recoveryError instanceof CsoError) throw recoveryError; + if (recoveryError?.code !== 'ENOENT') throw recoveryError; + } + } throw new AtomicPublicationTransition(`${options.label} was settled by another recovery helper`); } - if(!sameRecoveryIdentity(canonical.identity,recoveryIdentity(finalTarget))||!sameRecoveryIdentity(canonical.identity,recoveryIdentity(finalTemp))){ - if(matchesRecoveryInode(finalTarget,canonical.identity,options)&&matchesRecoveryInode(finalTemp,canonical.identity,options)&&finalTarget.nlink<=2&&finalTemp.nlink<=2)throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} hard link changed during concurrent recovery`); - throw new CsoError('SNAPSHOT_RACE',`${options.label} hard link changed before recovery`); + if ( + !sameRecoveryIdentity(canonical.identity, recoveryIdentity(finalTarget)) || + !sameRecoveryIdentity(canonical.identity, recoveryIdentity(finalTemp)) + ) { + if ( + matchesRecoveryInode(finalTarget, canonical.identity, options) && + matchesRecoveryInode(finalTemp, canonical.identity, options) && + finalTarget.nlink <= 2 && + finalTemp.nlink <= 2 + ) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} hard link changed during concurrent recovery`, + ); + throw new CsoError('SNAPSHOT_RACE', `${options.label} hard link changed before recovery`); } - try{fs.unlinkSync(candidate.path);}catch(error:any){if(error?.code!=='ENOENT')throw new CsoError('PERSISTENCE_FAILED',`${options.label} interrupted publication could not be recovered`);} - let recovered:{identity:AtomicRecoveryIdentity;value:unknown};try{recovered=recoveryJson(target,1,options);}catch(error){ - if(error instanceof CsoError&&error.code==='SNAPSHOT_RACE'&&!fs.existsSync(target))throw new AtomicPublicationTransition(`${options.label} was removed by another recovery helper`); + try { + fs.unlinkSync(candidate.path); + } catch (error: any) { + if (error?.code !== 'ENOENT') + throw new CsoError( + 'PERSISTENCE_FAILED', + `${options.label} interrupted publication could not be recovered`, + ); + } + let recovered: { identity: AtomicRecoveryIdentity; value: unknown }; + try { + recovered = recoveryJson(target, 1, options); + } catch (error) { + if (error instanceof CsoError && error.code === 'SNAPSHOT_RACE' && !fs.existsSync(target)) + throw new AtomicPublicationTransition(`${options.label} was removed by another recovery helper`); throw error; } - options.validate?.(recovered.value,candidate.pid); - if(recovered.identity.dev!==canonical.identity.dev||recovered.identity.ino!==canonical.identity.ino)throw new CsoError('UNSAFE_PATH',`${options.label} changed identity during recovery`); + options.validate?.(recovered.value, candidate.pid); + if (recovered.identity.dev !== canonical.identity.dev || recovered.identity.ino !== canonical.identity.ino) + throw new CsoError('UNSAFE_PATH', `${options.label} changed identity during recovery`); } /** Remove a never-published temp, or validate a temp that became published while observed. */ -export function discardAtomicNoReplaceTemp(path:string,publisherPid:number,options:AtomicNoReplaceRecoveryOptions):void{ - let observed:ExactStats;try{observed=exactLstat(path);}catch(error:any){ - if(error?.code==='ENOENT'){ - const publication=atomicTempTarget(path,publisherPid); - if(publication){ - try{const settled=recoveryJson(publication.target,1,options);options.validate?.(settled.value,publisherPid);if(publicationOwnerAlive(settled.value,publisherPid,options))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} publication is still owned by a live helper`);return;}catch(settledError){if(settledError instanceof CsoError&&settledError.code==='SNAPSHOT_RACE'&&!fs.existsSync(publication.target))return;if(settledError instanceof CsoError)throw settledError;} +export function discardAtomicNoReplaceTemp( + path: string, + publisherPid: number, + options: AtomicNoReplaceRecoveryOptions, +): void { + let observed: ExactStats; + try { + observed = exactLstat(path); + } catch (error: any) { + if (error?.code === 'ENOENT') { + const publication = atomicTempTarget(path, publisherPid); + if (publication) { + try { + const settled = recoveryJson(publication.target, 1, options); + options.validate?.(settled.value, publisherPid); + if (publicationOwnerAlive(settled.value, publisherPid, options)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} publication is still owned by a live helper`, + ); + return; + } catch (settledError) { + if ( + settledError instanceof CsoError && + settledError.code === 'SNAPSHOT_RACE' && + !fs.existsSync(publication.target) + ) + return; + if (settledError instanceof CsoError) throw settledError; + } } - throw new CsoError('SNAPSHOT_RACE',`${options.label} interrupted publication disappeared`); + throw new CsoError('SNAPSHOT_RACE', `${options.label} interrupted publication disappeared`); } - throw new CsoError('UNSAFE_PATH',`${options.label} interrupted publication could not be inspected`); + throw new CsoError('UNSAFE_PATH', `${options.label} interrupted publication could not be inspected`); } - if(observed.nlink===2&&privatePublicationFile(observed,options)){ - const target=atomicTempTarget(path,publisherPid)?.target; - let published:ExactStats|undefined;try{if(target)published=exactLstat(target);}catch{} - if(target&&published&&published.dev===observed.dev&&published.ino===observed.ino&&published.nlink===2&&privatePublicationFile(published,options)){ - recoverAtomicNoReplaceJson(target,options); - const settled=recoveryJson(target,1,options); - if(settled.identity.dev!==observed.dev||settled.identity.ino!==observed.ino)throw new CsoError('UNSAFE_PATH',`${options.label} published target changed identity while it settled`); - options.validate?.(settled.value,publisherPid); - if(publicationOwnerAlive(settled.value,publisherPid,options))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} publication is still owned by a live helper`); + if (observed.nlink === 2 && privatePublicationFile(observed, options)) { + const target = atomicTempTarget(path, publisherPid)?.target; + let published: ExactStats | undefined; + try { + if (target) published = exactLstat(target); + } catch {} + if ( + target && + published && + published.dev === observed.dev && + published.ino === observed.ino && + published.nlink === 2 && + privatePublicationFile(published, options) + ) { + recoverAtomicNoReplaceJson(target, options); + const settled = recoveryJson(target, 1, options); + if (settled.identity.dev !== observed.dev || settled.identity.ino !== observed.ino) + throw new CsoError( + 'UNSAFE_PATH', + `${options.label} published target changed identity while it settled`, + ); + options.validate?.(settled.value, publisherPid); + if (publicationOwnerAlive(settled.value, publisherPid, options)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} publication is still owned by a live helper`, + ); return; } } - const temporary=recoveryJson(path,1,options,observed);options.validate?.(temporary.value,publisherPid); - if(publicationOwnerAlive(temporary.value,publisherPid,options))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} publication is still owned by a live helper`); - let final:ExactStats;try{final=exactLstat(path);}catch(error:any){if(error?.code==='ENOENT')throw new AtomicPublicationTransition(`${options.label} temp was removed by another recovery helper`);throw error;} - if(!sameRecoveryIdentity(temporary.identity,recoveryIdentity(final)))throw new CsoError('SNAPSHOT_RACE',`${options.label} temp changed before recovery`); - try{fs.unlinkSync(path);}catch(error:any){if(error?.code==='ENOENT')throw new AtomicPublicationTransition(`${options.label} temp was removed by another recovery helper`);throw new CsoError('PERSISTENCE_FAILED',`${options.label} unpublished temp could not be removed`);} + const temporary = recoveryJson(path, 1, options, observed); + options.validate?.(temporary.value, publisherPid); + if (publicationOwnerAlive(temporary.value, publisherPid, options)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} publication is still owned by a live helper`, + ); + let final: ExactStats; + try { + final = exactLstat(path); + } catch (error: any) { + if (error?.code === 'ENOENT') + throw new AtomicPublicationTransition(`${options.label} temp was removed by another recovery helper`); + throw error; + } + if (!sameRecoveryIdentity(temporary.identity, recoveryIdentity(final))) + throw new CsoError('SNAPSHOT_RACE', `${options.label} temp changed before recovery`); + try { + fs.unlinkSync(path); + } catch (error: any) { + if (error?.code === 'ENOENT') + throw new AtomicPublicationTransition(`${options.label} temp was removed by another recovery helper`); + throw new CsoError('PERSISTENCE_FAILED', `${options.label} unpublished temp could not be removed`); + } } -export function stateRoot(env: Record = process.env): string { +export function stateRoot(env: Record = process.env): string { // Mirrors bin/gstack-paths. Security artifacts are intentionally outside every sync allowlist. - const userHome=env.HOME||(process.platform==='win32'?env.USERPROFILE:''); - return resolve(env.GSTACK_HOME || (env.CLAUDE_PLUGIN_ROOT?.toLowerCase().includes('gstack') ? env.CLAUDE_PLUGIN_DATA : '') || join(userHome || '.', '.gstack')); + const userHome = env.HOME || (process.platform === 'win32' ? env.USERPROFILE : ''); + return resolve( + env.GSTACK_HOME || + (env.CLAUDE_PLUGIN_ROOT?.toLowerCase().includes('gstack') ? env.CLAUDE_PLUGIN_DATA : '') || + join(userHome || '.', '.gstack'), + ); } -function ensureDirectory(path:string,hardenExistingLeaf:boolean):string{ - const p=resolve(path),root=parse(p).root; - if(p===root)throw new CsoError('UNSAFE_PATH','Private state cannot use a filesystem root'); - let cursor=root,leafCreated=false; - for (const part of relative(root,p).split(sep).filter(Boolean)) { - cursor = join(cursor,part); - let created=false; - try{fs.mkdirSync(cursor,{mode:0o700});created=true;}catch(error:any){if(error?.code!=='EEXIST')throw error;} - if(cursor===p)leafCreated=created; +function ensureDirectory(path: string, hardenExistingLeaf: boolean): string { + const p = resolve(path), + root = parse(p).root; + if (p === root) throw new CsoError('UNSAFE_PATH', 'Private state cannot use a filesystem root'); + let cursor = root, + leafCreated = false; + for (const part of relative(root, p).split(sep).filter(Boolean)) { + cursor = join(cursor, part); + let created = false; + try { + fs.mkdirSync(cursor, { mode: 0o700 }); + created = true; + } catch (error: any) { + if (error?.code !== 'EEXIST') throw error; + } + if (cursor === p) leafCreated = created; const s = fs.lstatSync(cursor); - if (s.isSymbolicLink() || !s.isDirectory()) throw new CsoError('UNSAFE_PATH','Private state has a symlink or non-directory ancestor'); + if (s.isSymbolicLink() || !s.isDirectory()) + throw new CsoError('UNSAFE_PATH', 'Private state has a symlink or non-directory ancestor'); // Root-owned system ancestors are normal; a writable ancestor owned by anyone else is not. - if (s.uid !== process.getuid?.() && s.uid !== 0) throw new CsoError('UNSAFE_PATH','Private state ancestor has an unexpected owner'); - if (process.platform!=='win32'&&(s.mode & 0o022) && !(s.mode & 0o1000)) throw new CsoError('UNSAFE_PATH','Private state has a group- or world-writable ancestor'); + if (s.uid !== process.getuid?.() && s.uid !== 0) + throw new CsoError('UNSAFE_PATH', 'Private state ancestor has an unexpected owner'); + if (process.platform !== 'win32' && s.mode & 0o022 && !(s.mode & 0o1000)) + throw new CsoError('UNSAFE_PATH', 'Private state has a group- or world-writable ancestor'); } const s = fs.statSync(p); - if (process.getuid && s.uid !== process.getuid()) throw new CsoError('UNSAFE_PATH','Private directory must be owned by the current user'); - if(hardenExistingLeaf||leafCreated)fs.chmodSync(p,0o700); + if (process.getuid && s.uid !== process.getuid()) + throw new CsoError('UNSAFE_PATH', 'Private directory must be owned by the current user'); + if (hardenExistingLeaf || leafCreated) fs.chmodSync(p, 0o700); return p; } /** The supplied leaf is CSO-owned. Existing ancestors are validated, never mutated. */ -export function secureDirectory(path:string):string{return ensureDirectory(path,true);} -export function privateRoot():string{ - const container=ensureDirectory(stateRoot(),false); - return secureDirectory(join(container,'security','cso')); +export function secureDirectory(path: string): string { + return ensureDirectory(path, true); } -export function assertStateOutside(repo:string):void{ - const source=fs.realpathSync(repo),candidate=resolve(stateRoot(),'security','cso'); - const relation=relative(source,candidate);if(relation===''||(!relation.startsWith(`..${sep}`)&&relation!=='..'&&!isAbsolute(relation)))throw new CsoError('UNSAFE_PATH','CSO private state must be outside the audited repository'); +export function privateRoot(): string { + const container = ensureDirectory(stateRoot(), false); + return secureDirectory(join(container, 'security', 'cso')); } -export function repoId(repo: string): string { return sha256(fs.realpathSync(repo)).slice(0,24); } -export function newRun(repo: string): {runId:string; dir:string; repoId:string} { +export function assertStateOutside(repo: string): void { + const source = fs.realpathSync(repo), + candidate = resolve(stateRoot(), 'security', 'cso'); + const relation = relative(source, candidate); + if (relation === '' || (!relation.startsWith(`..${sep}`) && relation !== '..' && !isAbsolute(relation))) + throw new CsoError('UNSAFE_PATH', 'CSO private state must be outside the audited repository'); +} +export function repoId(repo: string): string { + return sha256(fs.realpathSync(repo)).slice(0, 24); +} +export function newRun(repo: string): { runId: string; dir: string; repoId: string } { assertStateOutside(repo); - const id = repoId(repo), runId = `${Date.now()}-${randomBytes(8).toString('hex')}`; - return {runId, repoId:id, dir:secureDirectory(join(privateRoot(),id,runId))}; + const id = repoId(repo), + runId = `${Date.now()}-${randomBytes(8).toString('hex')}`; + return { runId, repoId: id, dir: secureDirectory(join(privateRoot(), id, runId)) }; } export function runDirectory(id: string): string { - if (!/^\d{13}-[a-f0-9]{16}$/.test(id)) throw new CsoError('INVALID_ARGUMENT','Run identifier must be the ID returned by start'); + if (!/^\d{13}-[a-f0-9]{16}$/.test(id)) + throw new CsoError('INVALID_ARGUMENT', 'Run identifier must be the ID returned by start'); const root = privateRoot(); for (const item of fs.readdirSync(root)) { if (!/^[a-f0-9]{24}$/.test(item)) continue; - const dir = join(root,item,id); + const dir = join(root, item, id); if (fs.existsSync(dir)) return secureDirectory(dir); } - throw new CsoError('MISSING_INPUT','Run was not found or has expired'); + throw new CsoError('MISSING_INPUT', 'Run was not found or has expired'); } export function writeJson(path: string, value: unknown): void { try { secureDirectory(dirname(path)); - if (fs.existsSync(path) && fs.lstatSync(path).isSymbolicLink()) throw new CsoError('UNSAFE_PATH','State file cannot be a symlink'); - const sanitized = JSON.stringify(sanitizeForJson(value),null,2); - if(Buffer.byteLength(sanitized)+1>MAX_STATE_FILE)throw new CsoError('PERSISTENCE_FAILED','Private state exceeds the 1 MiB persistence limit; the previous artifact was preserved'); - JSON.parse(sanitized); atomicWriteSync(path,sanitized + '\n',{mode:0o600}); - } catch(e) { if (e instanceof CsoError) throw e; throw new CsoError('PERSISTENCE_FAILED','Private report could not be written; no saved report is claimed'); } -} -export function writeHelperJson(path:string,value:unknown):void{ - try{ - secureDirectory(dirname(path));if(fs.existsSync(path)&&fs.lstatSync(path).isSymbolicLink())throw new CsoError('UNSAFE_PATH','State file cannot be a symlink'); - const serialized=JSON.stringify(sanitizeHelperForJson(value),null,2);if(Buffer.byteLength(serialized)+1>MAX_STATE_FILE)throw new CsoError('PERSISTENCE_FAILED','Private helper state exceeds the 1 MiB persistence limit; the previous artifact was preserved');JSON.parse(serialized);atomicWriteSync(path,serialized+'\n',{mode:0o600}); - }catch(error){if(error instanceof CsoError)throw error;throw new CsoError('PERSISTENCE_FAILED','Private helper state could not be written; no saved artifact is claimed');} -} -export function writeJsonExclusive(path:string,value:unknown):void{ - try{ - secureDirectory(dirname(path)); - if(fs.existsSync(path)&&fs.lstatSync(path).isSymbolicLink())throw new CsoError('UNSAFE_PATH','State file cannot be a symlink'); - const sanitized=JSON.stringify(sanitizeHelperForJson(value),null,2);JSON.parse(sanitized); - if(Buffer.byteLength(sanitized)+1>MAX_STATE_FILE)throw new CsoError('PERSISTENCE_FAILED','Private immutable artifact exceeds the 1 MiB persistence limit'); - atomicWriteSync(path,sanitized+'\n',{mode:0o600,noReplace:true}); - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(error?.code==='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Immutable artifact already exists; it was not replaced'); - throw new CsoError('PERSISTENCE_FAILED','Private immutable artifact could not be written'); + if (fs.existsSync(path) && fs.lstatSync(path).isSymbolicLink()) + throw new CsoError('UNSAFE_PATH', 'State file cannot be a symlink'); + const sanitized = JSON.stringify(sanitizeForJson(value), null, 2); + if (Buffer.byteLength(sanitized) + 1 > MAX_STATE_FILE) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Private state exceeds the 1 MiB persistence limit; the previous artifact was preserved', + ); + JSON.parse(sanitized); + atomicWriteSync(path, sanitized + '\n', { mode: 0o600 }); + } catch (e) { + if (e instanceof CsoError) throw e; + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Private report could not be written; no saved report is claimed', + ); } } -function readPrivateJson(path:string):unknown{ - let fd:number|undefined; - try{ - const before=exactLstat(path); - if(!before.isFile()||before.isSymbolicLink()||before.nlink!==1||before.size<=0||before.size>MAX_STATE_FILE|| - (process.getuid&&before.uid!==process.getuid())||(process.platform!=='win32'&&(before.mode&0o077)!==0)) - throw new CsoError('UNSAFE_PATH','Invalid private state file'); - fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)); - const opened=exactFstat(fd); - if(!sameRecoveryIdentity(recoveryIdentity(before),recoveryIdentity(opened))) - throw new CsoError('SNAPSHOT_RACE','Private state file changed while it was opened'); - const raw=fs.readFileSync(fd,'utf8'); - const final=exactFstat(fd),after=exactLstat(path); - if(!sameRecoveryIdentity(recoveryIdentity(opened),recoveryIdentity(final))|| - !sameRecoveryIdentity(recoveryIdentity(opened),recoveryIdentity(after))) - throw new CsoError('SNAPSHOT_RACE','Private state file changed while it was read'); - try{return JSON.parse(raw);}catch{throw new CsoError('MISSING_INPUT','Private state file is missing or invalid');} - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(error?.code==='ENOENT'||error?.code==='ELOOP')throw new CsoError('SNAPSHOT_RACE','Private state file changed while it was opened'); - throw new CsoError('MISSING_INPUT','Private state file is missing or invalid'); - }finally{if(fd!==undefined)try{fs.closeSync(fd);}catch{}} +export function writeHelperJson(path: string, value: unknown): void { + try { + secureDirectory(dirname(path)); + if (fs.existsSync(path) && fs.lstatSync(path).isSymbolicLink()) + throw new CsoError('UNSAFE_PATH', 'State file cannot be a symlink'); + const serialized = JSON.stringify(sanitizeHelperForJson(value), null, 2); + if (Buffer.byteLength(serialized) + 1 > MAX_STATE_FILE) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Private helper state exceeds the 1 MiB persistence limit; the previous artifact was preserved', + ); + JSON.parse(serialized); + atomicWriteSync(path, serialized + '\n', { mode: 0o600 }); + } catch (error) { + if (error instanceof CsoError) throw error; + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Private helper state could not be written; no saved artifact is claimed', + ); + } +} +export function writeJsonExclusive(path: string, value: unknown): void { + try { + secureDirectory(dirname(path)); + if (fs.existsSync(path) && fs.lstatSync(path).isSymbolicLink()) + throw new CsoError('UNSAFE_PATH', 'State file cannot be a symlink'); + const sanitized = JSON.stringify(sanitizeHelperForJson(value), null, 2); + JSON.parse(sanitized); + if (Buffer.byteLength(sanitized) + 1 > MAX_STATE_FILE) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Private immutable artifact exceeds the 1 MiB persistence limit', + ); + atomicWriteSync(path, sanitized + '\n', { mode: 0o600, noReplace: true }); + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code === 'EEXIST') + throw new CsoError('PERSISTENCE_FAILED', 'Immutable artifact already exists; it was not replaced'); + throw new CsoError('PERSISTENCE_FAILED', 'Private immutable artifact could not be written'); + } +} +function readPrivateJson(path: string): unknown { + let fd: number | undefined; + try { + const before = exactLstat(path); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== 1 || + before.size <= 0 || + before.size > MAX_STATE_FILE || + (process.getuid && before.uid !== process.getuid()) || + (process.platform !== 'win32' && (before.mode & 0o077) !== 0) + ) + throw new CsoError('UNSAFE_PATH', 'Invalid private state file'); + fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const opened = exactFstat(fd); + if (!sameRecoveryIdentity(recoveryIdentity(before), recoveryIdentity(opened))) + throw new CsoError('SNAPSHOT_RACE', 'Private state file changed while it was opened'); + const raw = fs.readFileSync(fd, 'utf8'); + const final = exactFstat(fd), + after = exactLstat(path); + if ( + !sameRecoveryIdentity(recoveryIdentity(opened), recoveryIdentity(final)) || + !sameRecoveryIdentity(recoveryIdentity(opened), recoveryIdentity(after)) + ) + throw new CsoError('SNAPSHOT_RACE', 'Private state file changed while it was read'); + try { + return JSON.parse(raw); + } catch { + throw new CsoError('MISSING_INPUT', 'Private state file is missing or invalid'); + } + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code === 'ENOENT' || error?.code === 'ELOOP') + throw new CsoError('SNAPSHOT_RACE', 'Private state file changed while it was opened'); + throw new CsoError('MISSING_INPUT', 'Private state file is missing or invalid'); + } finally { + if (fd !== undefined) + try { + fs.closeSync(fd); + } catch {} + } } export function readJson(path: string): any { try { - secureDirectory(dirname(path));recoverAtomicNoReplaceJson(path,{label:'Private immutable artifact',maxBytes:MAX_STATE_FILE});return readPrivateJson(path); - } catch(e) { if (e instanceof CsoError) throw e; throw new CsoError('MISSING_INPUT','Private state file is missing or invalid'); } + secureDirectory(dirname(path)); + recoverAtomicNoReplaceJson(path, { label: 'Private immutable artifact', maxBytes: MAX_STATE_FILE }); + return readPrivateJson(path); + } catch (e) { + if (e instanceof CsoError) throw e; + throw new CsoError('MISSING_INPUT', 'Private state file is missing or invalid'); + } } export const PUBLIC_SOURCE_ROOT = ''; /** A report is public evidence; the real root remains in the private snapshot. */ @@ -292,561 +877,1659 @@ export function publicReport(report: RunReportV3): RunReportV3 { } export function saveReport(dir: string, report: RunReportV3): void { report.completeness = completeness(report); - const safe=sanitizeHelperForJson(publicReport(report)) as RunReportV3; - for(const finding of safe.findings){const expected=fingerprint(finding);if(finding.id!==expected||finding.fingerprint!==expected)throw new CsoError('PERSISTENCE_FAILED','Finding identity changed during redaction; the previous report was preserved');} - try{secureDirectory(dir);const serialized=JSON.stringify(safe,null,2);if(Buffer.byteLength(serialized)+1>MAX_STATE_FILE)throw new CsoError('PERSISTENCE_FAILED','Private state exceeds the 1 MiB persistence limit; the previous artifact was preserved');atomicWriteSync(join(dir,'report.json'),serialized+'\n',{mode:0o600});}catch(error){if(error instanceof CsoError)throw error;throw new CsoError('PERSISTENCE_FAILED','Private report could not be written; no saved report is claimed');} - try { atomicWriteSync(join(dir,'report.md'),renderReport(safe),{mode:0o600}); } - catch { throw new CsoError('PERSISTENCE_FAILED','JSON was saved but the readable report could not be written'); } + const safe = sanitizeHelperForJson(publicReport(report)) as RunReportV3; + for (const finding of safe.findings) { + const expected = fingerprint(finding); + if (finding.id !== expected || finding.fingerprint !== expected) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Finding identity changed during redaction; the previous report was preserved', + ); + } + try { + secureDirectory(dir); + const serialized = JSON.stringify(safe, null, 2); + if (Buffer.byteLength(serialized) + 1 > MAX_STATE_FILE) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Private state exceeds the 1 MiB persistence limit; the previous artifact was preserved', + ); + atomicWriteSync(join(dir, 'report.json'), serialized + '\n', { mode: 0o600 }); + } catch (error) { + if (error instanceof CsoError) throw error; + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Private report could not be written; no saved report is claimed', + ); + } + try { + atomicWriteSync(join(dir, 'report.md'), renderReport(safe), { mode: 0o600 }); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'JSON was saved but the readable report could not be written'); + } } export function loadReport(dir: string): RunReportV3 { - const v = readJson(join(dir,'report.json')); - if (v.schemaVersion !== 3 || !Array.isArray(v.coverage) || !Array.isArray(v.findings)) throw new CsoError('INCOMPATIBLE_INPUT','Expected a v3 run report'); + const v = readJson(join(dir, 'report.json')); + if (v.schemaVersion !== 3 || !Array.isArray(v.coverage) || !Array.isArray(v.findings)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Expected a v3 run report'); return publicReport(v); } export function event(report: RunReportV3, kind: string, message: string): void { - report.events.push({at:new Date().toISOString(),kind,message:redact(message)}); + report.events.push({ at: new Date().toISOString(), kind, message: redact(message) }); +} +export function executionDeadline(report: RunReportV3): number { + return Date.parse(report.deadline) - 60_000; } -export function executionDeadline(report: RunReportV3): number { return Date.parse(report.deadline) - 60_000; } export function requireTime(report: RunReportV3): void { - if (Date.now() >= executionDeadline(report)) throw new CsoError('DEADLINE','Investigation deadline reached; the final minute is reserved for reporting'); + if (Date.now() >= executionDeadline(report)) + throw new CsoError( + 'DEADLINE', + 'Investigation deadline reached; the final minute is reserved for reporting', + ); } -const LOCK_PROTOCOL='immutable-lease-set-v3'; -const LOCK_OWNER_MAX_BYTES=4096; -const LOCK_TOKEN=/^[a-f0-9]{32}$/; -const PROCESS_IDENTITY=/^linux:\d+$/; -const LEASE_PUBLICATION_TEMP=/^([a-f0-9]{32})\.(json|decision)\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/; -const LEASE_BLOCKED_WAIT_MS=250; -const LEASE_ELECTION_POLL_MS=1; -const LEASE_ELECTION_WAIT=new Int32Array(new SharedArrayBuffer(4)); -const LEASE_CANDIDATE=/^([a-f0-9]{32})\.json$/; -const LEASE_DECISION=/^([a-f0-9]{32})\.decision$/; -const LEASE_ACTIVE=/^([a-f0-9]{32})\.active\.([a-f0-9]{16})$/; -type LockOwner={pid:number;processIdentity?:string;token:string;createdAt:number}; -type LockIdentity={dev:bigint;ino:bigint}; -type LeaseLinks=1|2; -type LeaseDecision={schemaVersion:1;token:string;kind:'ticket'|'withdraw';ticket?:string;candidateDev:string;candidateIno:string;ownerPid:number;ownerProcessIdentity?:string;ownerCreatedAt:number;publisherPid:number;publisherProcessIdentity?:string;createdAt:number}; -function processAlive(pid:number):boolean{if(!Number.isInteger(pid)||pid<=1)return false;try{process.kill(pid,0);return true;}catch(error:any){return error?.code==='EPERM';}} -function processIdentity(pid:number):string|undefined{if(process.platform!=='linux')return;try{const raw=fs.readFileSync(`/proc/${pid}/stat`,'utf8'),tail=raw.slice(raw.lastIndexOf(')')+2).trim().split(/\s+/);return /^\d+$/.test(tail[19]??'')?`linux:${tail[19]}`:undefined;}catch{return;}} -function validateOwner(value:unknown,expectedToken?:string):LockOwner{ - if(!value||typeof value!=='object'||Array.isArray(value))throw new CsoError('UNSAFE_PATH','Run mutation lease owner is malformed'); - const owner=value as Record; - if(!Number.isInteger(owner.pid)||Number(owner.pid)<=1||typeof owner.token!=='string'||!LOCK_TOKEN.test(owner.token)|| - (expectedToken!==undefined&&owner.token!==expectedToken)||!Number.isFinite(owner.createdAt)||Number(owner.createdAt)<0|| - (owner.processIdentity!==undefined&&(typeof owner.processIdentity!=='string'||!PROCESS_IDENTITY.test(owner.processIdentity)))) - throw new CsoError('UNSAFE_PATH','Run mutation lease owner is malformed'); - return {pid:Number(owner.pid),token:owner.token,createdAt:Number(owner.createdAt),...(owner.processIdentity===undefined?{}:{processIdentity:owner.processIdentity as string})}; +const LOCK_PROTOCOL = 'immutable-lease-set-v3'; +const LOCK_OWNER_MAX_BYTES = 4096; +const LOCK_TOKEN = /^[a-f0-9]{32}$/; +const PROCESS_IDENTITY = /^linux:\d+$/; +const LEASE_PUBLICATION_TEMP = /^([a-f0-9]{32})\.(json|decision)\.tmp\.(\d{1,10})\.[a-f0-9]{8}$/; +const LEASE_BLOCKED_WAIT_MS = 250; +const LEASE_ELECTION_POLL_MS = 1; +const LEASE_ELECTION_WAIT = new Int32Array(new SharedArrayBuffer(4)); +const LEASE_CANDIDATE = /^([a-f0-9]{32})\.json$/; +const LEASE_DECISION = /^([a-f0-9]{32})\.decision$/; +const LEASE_ACTIVE = /^([a-f0-9]{32})\.active\.([a-f0-9]{16})$/; +type LockOwner = { pid: number; processIdentity?: string; token: string; createdAt: number }; +type LockIdentity = { dev: bigint; ino: bigint }; +type LeaseLinks = 1 | 2; +type LeaseDecision = { + schemaVersion: 1; + token: string; + kind: 'ticket' | 'withdraw'; + ticket?: string; + candidateDev: string; + candidateIno: string; + ownerPid: number; + ownerProcessIdentity?: string; + ownerCreatedAt: number; + publisherPid: number; + publisherProcessIdentity?: string; + createdAt: number; +}; +function processAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 1) return false; + try { + process.kill(pid, 0); + return true; + } catch (error: any) { + return error?.code === 'EPERM'; + } } -function validateLeaseDecision(value:unknown,expectedToken?:string):LeaseDecision{ - if(!value||typeof value!=='object'||Array.isArray(value))throw new CsoError('UNSAFE_PATH','Run mutation lease decision is malformed'); - const decision=value as Record,kind=decision.kind,ticket=decision.ticket; - if(decision.schemaVersion!==1||typeof decision.token!=='string'||!LOCK_TOKEN.test(decision.token)||(expectedToken!==undefined&&decision.token!==expectedToken)|| - (kind!=='ticket'&&kind!=='withdraw')||(kind==='ticket'&&(typeof ticket!=='string'||!/^[a-f0-9]{16}$/.test(ticket)||ticket==='0000000000000000'))||(kind==='withdraw'&&ticket!==undefined)|| - typeof decision.candidateDev!=='string'||!/^(0|[1-9]\d*)$/.test(decision.candidateDev)||BigInt(decision.candidateDev)>0xffffffffffffffffn|| - typeof decision.candidateIno!=='string'||!/^(0|[1-9]\d*)$/.test(decision.candidateIno)||BigInt(decision.candidateIno)>0xffffffffffffffffn|| - !Number.isInteger(decision.ownerPid)||Number(decision.ownerPid)<=1||!Number.isFinite(decision.ownerCreatedAt)||Number(decision.ownerCreatedAt)<0|| - !Number.isInteger(decision.publisherPid)||Number(decision.publisherPid)<=1||!Number.isFinite(decision.createdAt)||Number(decision.createdAt)<0|| - (decision.ownerProcessIdentity!==undefined&&(typeof decision.ownerProcessIdentity!=='string'||!PROCESS_IDENTITY.test(decision.ownerProcessIdentity)))|| - (decision.publisherProcessIdentity!==undefined&&(typeof decision.publisherProcessIdentity!=='string'||!PROCESS_IDENTITY.test(decision.publisherProcessIdentity))))throw new CsoError('UNSAFE_PATH','Run mutation lease decision is malformed'); +function processIdentity(pid: number): string | undefined { + if (process.platform !== 'linux') return; + try { + const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'), + tail = raw + .slice(raw.lastIndexOf(')') + 2) + .trim() + .split(/\s+/); + return /^\d+$/.test(tail[19] ?? '') ? `linux:${tail[19]}` : undefined; + } catch { + return; + } +} +function validateOwner(value: unknown, expectedToken?: string): LockOwner { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease owner is malformed'); + const owner = value as Record; + if ( + !Number.isInteger(owner.pid) || + Number(owner.pid) <= 1 || + typeof owner.token !== 'string' || + !LOCK_TOKEN.test(owner.token) || + (expectedToken !== undefined && owner.token !== expectedToken) || + !Number.isFinite(owner.createdAt) || + Number(owner.createdAt) < 0 || + (owner.processIdentity !== undefined && + (typeof owner.processIdentity !== 'string' || !PROCESS_IDENTITY.test(owner.processIdentity))) + ) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease owner is malformed'); + return { + pid: Number(owner.pid), + token: owner.token, + createdAt: Number(owner.createdAt), + ...(owner.processIdentity === undefined ? {} : { processIdentity: owner.processIdentity as string }), + }; +} +function validateLeaseDecision(value: unknown, expectedToken?: string): LeaseDecision { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease decision is malformed'); + const decision = value as Record, + kind = decision.kind, + ticket = decision.ticket; + if ( + decision.schemaVersion !== 1 || + typeof decision.token !== 'string' || + !LOCK_TOKEN.test(decision.token) || + (expectedToken !== undefined && decision.token !== expectedToken) || + (kind !== 'ticket' && kind !== 'withdraw') || + (kind === 'ticket' && + (typeof ticket !== 'string' || !/^[a-f0-9]{16}$/.test(ticket) || ticket === '0000000000000000')) || + (kind === 'withdraw' && ticket !== undefined) || + typeof decision.candidateDev !== 'string' || + !/^(0|[1-9]\d*)$/.test(decision.candidateDev) || + BigInt(decision.candidateDev) > 0xffffffffffffffffn || + typeof decision.candidateIno !== 'string' || + !/^(0|[1-9]\d*)$/.test(decision.candidateIno) || + BigInt(decision.candidateIno) > 0xffffffffffffffffn || + !Number.isInteger(decision.ownerPid) || + Number(decision.ownerPid) <= 1 || + !Number.isFinite(decision.ownerCreatedAt) || + Number(decision.ownerCreatedAt) < 0 || + !Number.isInteger(decision.publisherPid) || + Number(decision.publisherPid) <= 1 || + !Number.isFinite(decision.createdAt) || + Number(decision.createdAt) < 0 || + (decision.ownerProcessIdentity !== undefined && + (typeof decision.ownerProcessIdentity !== 'string' || + !PROCESS_IDENTITY.test(decision.ownerProcessIdentity))) || + (decision.publisherProcessIdentity !== undefined && + (typeof decision.publisherProcessIdentity !== 'string' || + !PROCESS_IDENTITY.test(decision.publisherProcessIdentity))) + ) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease decision is malformed'); return decision as LeaseDecision; } -function decisionOwner(decision:LeaseDecision):LockOwner{return{pid:decision.ownerPid,token:decision.token,createdAt:decision.ownerCreatedAt,...(decision.ownerProcessIdentity?{processIdentity:decision.ownerProcessIdentity}:{})};} -function decisionPublisher(decision:LeaseDecision):LockOwner{return{pid:decision.publisherPid,token:decision.token,createdAt:decision.createdAt,...(decision.publisherProcessIdentity?{processIdentity:decision.publisherProcessIdentity}:{})};} -function leaseDecisionRecoveryOptions(token:string):AtomicNoReplaceRecoveryOptions{return{label:'Run mutation lease decision',maxBytes:LOCK_OWNER_MAX_BYTES, - validate:(value,pid)=>{const decision=validateLeaseDecision(value,token);if(decision.publisherPid!==pid)throw new CsoError('UNSAFE_PATH','Run mutation lease decision temp does not match its publisher');}, - publisherAlive:(value,pid)=>{const decision=validateLeaseDecision(value,token);if(decision.publisherPid!==pid)throw new CsoError('UNSAFE_PATH','Run mutation lease decision temp does not match its publisher');return ownerIsAlive(decisionPublisher(decision));}};} -function ownerLinkTransition(left:ExactStats,right:ExactStats):boolean{return left.isFile()&&right.isFile()&&left.dev===right.dev&&left.ino===right.ino&&left.size===right.size&&left.mode===right.mode&&left.uid===right.uid&& - left.nlink>=0&&left.nlink<=2&&right.nlink>=0&&right.nlink<=2&&left.nlink!==right.nlink;} -function readOwner(path:string,expectedToken?:string,expectedLinks:LeaseLinks=1,observed?:ExactStats):{owner:LockOwner;identity:LockIdentity}{ - let fd:number|undefined; - try{ - const before=exactLstat(path); - if(observed&&ownerLinkTransition(observed,before))throw new CsoError('INSUFFICIENT_CAPACITY','Run mutation lease changed phase while it was read'); - if(before.isSymbolicLink()||!before.isFile()||before.nlink!==expectedLinks||before.size<=0||before.size>LOCK_OWNER_MAX_BYTES|| - (process.getuid&&before.uid!==process.getuid())||(process.platform!=='win32'&&(before.mode&0o077)!==0)) - throw new CsoError('UNSAFE_PATH','Run mutation lease is invalid'); - fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)); - const opened=exactFstat(fd); - if(ownerLinkTransition(before,opened))throw new CsoError('INSUFFICIENT_CAPACITY','Run mutation lease changed phase while it was read'); - if(!opened.isFile()||opened.dev!==before.dev||opened.ino!==before.ino||opened.nlink!==expectedLinks||opened.size!==before.size) - throw new CsoError('UNSAFE_PATH','Run mutation lease changed while it was read'); - let parsed:unknown;try{parsed=JSON.parse(fs.readFileSync(fd,'utf8'));}catch{throw new CsoError('UNSAFE_PATH','Run mutation lease is malformed');} - const final=exactFstat(fd),after=exactLstat(path); - const coherentTransition=(ownerLinkTransition(opened,final)&&final.dev===after.dev&&final.ino===after.ino&&final.nlink===after.nlink)|| - (opened.dev===final.dev&&opened.ino===final.ino&&opened.nlink===final.nlink&&ownerLinkTransition(opened,after)); - if(coherentTransition)throw new CsoError('INSUFFICIENT_CAPACITY','Run mutation lease changed phase while it was read'); - if(after.isSymbolicLink()||after.dev!==opened.dev||after.ino!==opened.ino||after.nlink!==expectedLinks||final.dev!==opened.dev||final.ino!==opened.ino||final.nlink!==expectedLinks||final.size!==opened.size) - throw new CsoError('UNSAFE_PATH','Run mutation lease changed while it was read'); - return {owner:validateOwner(parsed,expectedToken),identity:{dev:opened.dev,ino:opened.ino}}; - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(error?.code==='ENOENT')throw new CsoError('INSUFFICIENT_CAPACITY','Run mutation lease changed during recovery'); - throw new CsoError('UNSAFE_PATH','Run mutation lease could not be validated'); - }finally{if(fd!==undefined)try{fs.closeSync(fd);}catch{}} +function decisionOwner(decision: LeaseDecision): LockOwner { + return { + pid: decision.ownerPid, + token: decision.token, + createdAt: decision.ownerCreatedAt, + ...(decision.ownerProcessIdentity ? { processIdentity: decision.ownerProcessIdentity } : {}), + }; } -function ownerIsAlive(owner:LockOwner):boolean{ - const pid=owner.pid;if(!processAlive(pid))return false; - const current=processIdentity(pid); - return !(typeof owner.processIdentity==='string'&¤t!==undefined&&owner.processIdentity!==current); +function decisionPublisher(decision: LeaseDecision): LockOwner { + return { + pid: decision.publisherPid, + token: decision.token, + createdAt: decision.createdAt, + ...(decision.publisherProcessIdentity ? { processIdentity: decision.publisherProcessIdentity } : {}), + }; } -function recoverLeasePublications(leases:string):void{ - for(let attempt=0;attempt<4;attempt++){ - try{ - for(const name of fs.readdirSync(leases)){ - const match=name.match(LEASE_PUBLICATION_TEMP);if(!match)continue; - const token=match[1],kind=match[2] as 'json'|'decision',publisherPid=Number(match[3]),temp=join(leases,name),target=join(leases,`${token}.${kind}`),options:AtomicNoReplaceRecoveryOptions=kind==='json'?{label:'Run mutation lease',maxBytes:LOCK_OWNER_MAX_BYTES, - validate:(value,pid)=>{const owner=validateOwner(value,token);if(owner.pid!==pid)throw new CsoError('UNSAFE_PATH','Run mutation lease temp does not match its publisher');}, - publisherAlive:(value,pid)=>{const owner=validateOwner(value,token);if(owner.pid!==pid)throw new CsoError('UNSAFE_PATH','Run mutation lease temp does not match its publisher');return ownerIsAlive(owner);}}: - leaseDecisionRecoveryOptions(token); - let publicationObserved:ExactStats|undefined;try{publicationObserved=exactLstat(temp);}catch{} - if(liveEmptyPublication(temp,publisherPid))throw new CsoError('INSUFFICIENT_CAPACITY',`${options.label} publication is still changing under a live helper`); - try{ - if(fs.existsSync(target))recoverAtomicNoReplaceJson(target,options); - if(fs.existsSync(temp))discardAtomicNoReplaceTemp(temp,publisherPid,options); - }catch(error){ +function leaseDecisionRecoveryOptions(token: string): AtomicNoReplaceRecoveryOptions { + return { + label: 'Run mutation lease decision', + maxBytes: LOCK_OWNER_MAX_BYTES, + validate: (value, pid) => { + const decision = validateLeaseDecision(value, token); + if (decision.publisherPid !== pid) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease decision temp does not match its publisher'); + }, + publisherAlive: (value, pid) => { + const decision = validateLeaseDecision(value, token); + if (decision.publisherPid !== pid) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease decision temp does not match its publisher'); + return ownerIsAlive(decisionPublisher(decision)); + }, + }; +} +function ownerLinkTransition(left: ExactStats, right: ExactStats): boolean { + return ( + left.isFile() && + right.isFile() && + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mode === right.mode && + left.uid === right.uid && + left.nlink >= 0 && + left.nlink <= 2 && + right.nlink >= 0 && + right.nlink <= 2 && + left.nlink !== right.nlink + ); +} +function readOwner( + path: string, + expectedToken?: string, + expectedLinks: LeaseLinks = 1, + observed?: ExactStats, +): { owner: LockOwner; identity: LockIdentity } { + let fd: number | undefined; + try { + const before = exactLstat(path); + if (observed && ownerLinkTransition(observed, before)) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Run mutation lease changed phase while it was read'); + if ( + before.isSymbolicLink() || + !before.isFile() || + before.nlink !== expectedLinks || + before.size <= 0 || + before.size > LOCK_OWNER_MAX_BYTES || + (process.getuid && before.uid !== process.getuid()) || + (process.platform !== 'win32' && (before.mode & 0o077) !== 0) + ) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease is invalid'); + fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const opened = exactFstat(fd); + if (ownerLinkTransition(before, opened)) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Run mutation lease changed phase while it was read'); + if ( + !opened.isFile() || + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.nlink !== expectedLinks || + opened.size !== before.size + ) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease changed while it was read'); + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(fd, 'utf8')); + } catch { + throw new CsoError('UNSAFE_PATH', 'Run mutation lease is malformed'); + } + const final = exactFstat(fd), + after = exactLstat(path); + const coherentTransition = + (ownerLinkTransition(opened, final) && + final.dev === after.dev && + final.ino === after.ino && + final.nlink === after.nlink) || + (opened.dev === final.dev && + opened.ino === final.ino && + opened.nlink === final.nlink && + ownerLinkTransition(opened, after)); + if (coherentTransition) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Run mutation lease changed phase while it was read'); + if ( + after.isSymbolicLink() || + after.dev !== opened.dev || + after.ino !== opened.ino || + after.nlink !== expectedLinks || + final.dev !== opened.dev || + final.ino !== opened.ino || + final.nlink !== expectedLinks || + final.size !== opened.size + ) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease changed while it was read'); + return { owner: validateOwner(parsed, expectedToken), identity: { dev: opened.dev, ino: opened.ino } }; + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code === 'ENOENT') + throw new CsoError('INSUFFICIENT_CAPACITY', 'Run mutation lease changed during recovery'); + throw new CsoError('UNSAFE_PATH', 'Run mutation lease could not be validated'); + } finally { + if (fd !== undefined) + try { + fs.closeSync(fd); + } catch {} + } +} +function ownerIsAlive(owner: LockOwner): boolean { + const pid = owner.pid; + if (!processAlive(pid)) return false; + const current = processIdentity(pid); + return !( + typeof owner.processIdentity === 'string' && + current !== undefined && + owner.processIdentity !== current + ); +} +function recoverLeasePublications(leases: string): void { + for (let attempt = 0; attempt < 4; attempt++) { + try { + for (const name of fs.readdirSync(leases)) { + const match = name.match(LEASE_PUBLICATION_TEMP); + if (!match) continue; + const token = match[1], + kind = match[2] as 'json' | 'decision', + publisherPid = Number(match[3]), + temp = join(leases, name), + target = join(leases, `${token}.${kind}`), + options: AtomicNoReplaceRecoveryOptions = + kind === 'json' + ? { + label: 'Run mutation lease', + maxBytes: LOCK_OWNER_MAX_BYTES, + validate: (value, pid) => { + const owner = validateOwner(value, token); + if (owner.pid !== pid) + throw new CsoError( + 'UNSAFE_PATH', + 'Run mutation lease temp does not match its publisher', + ); + }, + publisherAlive: (value, pid) => { + const owner = validateOwner(value, token); + if (owner.pid !== pid) + throw new CsoError( + 'UNSAFE_PATH', + 'Run mutation lease temp does not match its publisher', + ); + return ownerIsAlive(owner); + }, + } + : leaseDecisionRecoveryOptions(token); + let publicationObserved: ExactStats | undefined; + try { + publicationObserved = exactLstat(temp); + } catch {} + if (liveEmptyPublication(temp, publisherPid)) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + `${options.label} publication is still changing under a live helper`, + ); + try { + if (fs.existsSync(target)) recoverAtomicNoReplaceJson(target, options); + if (fs.existsSync(temp)) discardAtomicNoReplaceTemp(temp, publisherPid, options); + } catch (error) { // A live cooperating publisher may still be writing its private temp. // Do not accept or remove unstable bytes; report ordinary contention. - const transientShape=error instanceof CsoError&&error.code==='UNSAFE_PATH'&&error.message===`${options.label} interrupted publication is not one private regular file`; - if(error instanceof CsoError&&((error.code==='SNAPSHOT_RACE'&&livePublicationAdvanced(temp,publisherPid,publicationObserved,options))||(transientShape&&(liveRecognizedPublication(temp,target,publisherPid,options)||livePublicationAdvanced(temp,publisherPid,publicationObserved,options)))))throw new AtomicPublicationTransition(`${options.label} publication advanced under its live helper`); + const transientShape = + error instanceof CsoError && + error.code === 'UNSAFE_PATH' && + error.message === `${options.label} interrupted publication is not one private regular file`; + if ( + error instanceof CsoError && + ((error.code === 'SNAPSHOT_RACE' && + livePublicationAdvanced(temp, publisherPid, publicationObserved, options)) || + (transientShape && + (liveRecognizedPublication(temp, target, publisherPid, options) || + livePublicationAdvanced(temp, publisherPid, publicationObserved, options)))) + ) + throw new AtomicPublicationTransition( + `${options.label} publication advanced under its live helper`, + ); throw error; } } return; - }catch(error){ + } catch (error) { // Retry only a proven same-inode no-replace transition. Foreign inode, // content, permission, and pathname races remain visible failures. - if(!(error instanceof AtomicPublicationTransition))throw error; + if (!(error instanceof AtomicPublicationTransition)) throw error; } } - throw new CsoError('INSUFFICIENT_CAPACITY','Another helper is publishing a run mutation lease'); + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another helper is publishing a run mutation lease'); } -function readLegacyOwner(path:string):{pid:number;processIdentity?:string;token:string;createdAt:number}{ - let fd:number|undefined; - try{ - const before=exactLstat(path); - if(before.isSymbolicLink()||!before.isFile()||before.nlink!==1||before.size<=0||before.size>LOCK_OWNER_MAX_BYTES||(process.getuid&&before.uid!==process.getuid())) - throw new CsoError('UNSAFE_PATH','Legacy run mutation lock owner is invalid'); - fd=fs.openSync(path,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)); - const opened=exactFstat(fd); - if(opened.dev!==before.dev||opened.ino!==before.ino||opened.nlink!==1)throw new CsoError('UNSAFE_PATH','Legacy run mutation lock owner changed while it was read'); - let value:unknown;try{value=JSON.parse(fs.readFileSync(fd,'utf8'));}catch{throw new CsoError('UNSAFE_PATH','Legacy run mutation lock owner is malformed');} - const after=exactLstat(path),record=value as Record; - if(after.dev!==opened.dev||after.ino!==opened.ino||!record||typeof record!=='object'||Array.isArray(record)||!Number.isInteger(record.pid)||Number(record.pid)<=1|| - typeof record.token!=='string'||record.token.length<1||record.token.length>256|| - (record.processIdentity!==undefined&&(typeof record.processIdentity!=='string'||!PROCESS_IDENTITY.test(record.processIdentity)))) - throw new CsoError('UNSAFE_PATH','Legacy run mutation lock owner is malformed'); - return {pid:Number(record.pid),token:record.token,createdAt:typeof record.createdAt==='number'&&Number.isFinite(record.createdAt)?record.createdAt:0,...(record.processIdentity===undefined?{}:{processIdentity:record.processIdentity as string})}; - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(error?.code==='ENOENT')throw new CsoError('INSUFFICIENT_CAPACITY','A legacy helper may still be initializing this run; its incomplete lock was left intact'); - throw new CsoError('UNSAFE_PATH','Legacy run mutation lock owner could not be validated'); - }finally{if(fd!==undefined)try{fs.closeSync(fd);}catch{}} +function readLegacyOwner(path: string): { + pid: number; + processIdentity?: string; + token: string; + createdAt: number; +} { + let fd: number | undefined; + try { + const before = exactLstat(path); + if ( + before.isSymbolicLink() || + !before.isFile() || + before.nlink !== 1 || + before.size <= 0 || + before.size > LOCK_OWNER_MAX_BYTES || + (process.getuid && before.uid !== process.getuid()) + ) + throw new CsoError('UNSAFE_PATH', 'Legacy run mutation lock owner is invalid'); + fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const opened = exactFstat(fd); + if (opened.dev !== before.dev || opened.ino !== before.ino || opened.nlink !== 1) + throw new CsoError('UNSAFE_PATH', 'Legacy run mutation lock owner changed while it was read'); + let value: unknown; + try { + value = JSON.parse(fs.readFileSync(fd, 'utf8')); + } catch { + throw new CsoError('UNSAFE_PATH', 'Legacy run mutation lock owner is malformed'); + } + const after = exactLstat(path), + record = value as Record; + if ( + after.dev !== opened.dev || + after.ino !== opened.ino || + !record || + typeof record !== 'object' || + Array.isArray(record) || + !Number.isInteger(record.pid) || + Number(record.pid) <= 1 || + typeof record.token !== 'string' || + record.token.length < 1 || + record.token.length > 256 || + (record.processIdentity !== undefined && + (typeof record.processIdentity !== 'string' || !PROCESS_IDENTITY.test(record.processIdentity))) + ) + throw new CsoError('UNSAFE_PATH', 'Legacy run mutation lock owner is malformed'); + return { + pid: Number(record.pid), + token: record.token, + createdAt: + typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) ? record.createdAt : 0, + ...(record.processIdentity === undefined ? {} : { processIdentity: record.processIdentity as string }), + }; + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code === 'ENOENT') + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + 'A legacy helper may still be initializing this run; its incomplete lock was left intact', + ); + throw new CsoError('UNSAFE_PATH', 'Legacy run mutation lock owner could not be validated'); + } finally { + if (fd !== undefined) + try { + fs.closeSync(fd); + } catch {} + } } -function exactUnlink(path:string,token:string,identity:LockIdentity,links:LeaseLinks=1):void{ - let current:{owner:LockOwner;identity:LockIdentity}; - try{current=readOwner(path,token,links);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before exact release');} - if(current.identity.dev!==identity.dev||current.identity.ino!==identity.ino)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before exact release'); +function exactUnlink(path: string, token: string, identity: LockIdentity, links: LeaseLinks = 1): void { + let current: { owner: LockOwner; identity: LockIdentity }; + try { + current = readOwner(path, token, links); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before exact release'); + } + if (current.identity.dev !== identity.dev || current.identity.ino !== identity.ino) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before exact release'); // One final pathname check narrows lstat/read/unlink replacement races. Lease // names are immutable and never reused by cooperating helpers. - let final:ExactStats;try{final=exactLstat(path);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before exact release');} - if(final.isSymbolicLink()||final.dev!==identity.dev||final.ino!==identity.ino||final.nlink!==links)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before exact release'); - try{fs.unlinkSync(path);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before exact release');} + let final: ExactStats; + try { + final = exactLstat(path); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before exact release'); + } + if ( + final.isSymbolicLink() || + final.dev !== identity.dev || + final.ino !== identity.ino || + final.nlink !== links + ) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before exact release'); + try { + fs.unlinkSync(path); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before exact release'); + } } -function acquireMigrationClaim(path:string):{owner:LockOwner;identity:LockIdentity}{ - for(let attempt=0;attempt<4;attempt++){ - recoverAtomicNoReplaceJson(path,{label:'Legacy run mutation recovery claim',maxBytes:LOCK_OWNER_MAX_BYTES, - validate:(value,pid)=>{const owner=validateOwner(value);if(owner.pid!==pid)throw new CsoError('UNSAFE_PATH','Legacy recovery temp does not match its publisher');}}); - const owner:LockOwner={pid:process.pid,processIdentity:processIdentity(process.pid),token:randomBytes(16).toString('hex'),createdAt:Date.now()}; - try{ - atomicWriteSync(path,JSON.stringify(owner)+'\n',{mode:0o600,noReplace:true}); - return readOwner(path,owner.token); - }catch(error:any){ - if(error instanceof CsoError)throw error; - if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Legacy run mutation recovery claim could not be published'); - const stale=readOwner(path); - if(ownerIsAlive(stale.owner))throw new CsoError('INSUFFICIENT_CAPACITY','Another helper is recovering this run'); - try{exactUnlink(path,stale.owner.token,stale.identity);}catch(recoveryError){ - if(attempt===3)throw recoveryError; +function acquireMigrationClaim(path: string): { owner: LockOwner; identity: LockIdentity } { + for (let attempt = 0; attempt < 4; attempt++) { + recoverAtomicNoReplaceJson(path, { + label: 'Legacy run mutation recovery claim', + maxBytes: LOCK_OWNER_MAX_BYTES, + validate: (value, pid) => { + const owner = validateOwner(value); + if (owner.pid !== pid) + throw new CsoError('UNSAFE_PATH', 'Legacy recovery temp does not match its publisher'); + }, + }); + const owner: LockOwner = { + pid: process.pid, + processIdentity: processIdentity(process.pid), + token: randomBytes(16).toString('hex'), + createdAt: Date.now(), + }; + try { + atomicWriteSync(path, JSON.stringify(owner) + '\n', { mode: 0o600, noReplace: true }); + return readOwner(path, owner.token); + } catch (error: any) { + if (error instanceof CsoError) throw error; + if (error?.code !== 'EEXIST') + throw new CsoError('PERSISTENCE_FAILED', 'Legacy run mutation recovery claim could not be published'); + const stale = readOwner(path); + if (ownerIsAlive(stale.owner)) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another helper is recovering this run'); + try { + exactUnlink(path, stale.owner.token, stale.identity); + } catch (recoveryError) { + if (attempt === 3) throw recoveryError; } } } - throw new CsoError('INSUFFICIENT_CAPACITY','Another helper is recovering this run'); + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another helper is recovering this run'); } -function ensureLockProtocol(dir:string):string{ - const lock=join(dir,'.mutation-lock'),marker=JSON.stringify({protocol:LOCK_PROTOCOL})+'\n'; - try{atomicWriteSync(lock,marker,{mode:0o600,noReplace:true});} - catch(error:any){ - if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Run mutation lock protocol could not be initialized'); - recoverAtomicNoReplaceJson(lock,{label:'Run mutation lock protocol',maxBytes:LOCK_OWNER_MAX_BYTES, - validate:value=>{if(!value||typeof value!=='object'||Array.isArray(value)||(value as any).protocol!==LOCK_PROTOCOL)throw new CsoError('UNSAFE_PATH','Run mutation lock protocol is invalid');}}); - const stat=exactLstat(lock); - if(stat.isSymbolicLink())throw new CsoError('UNSAFE_PATH','Run mutation lock is a symlink'); - if(stat.isFile()){ - let protocol='';try{if(stat.nlink!==1||stat.size<=0||stat.size>LOCK_OWNER_MAX_BYTES||(process.platform!=='win32'&&(stat.mode&0o077)!==0))throw new Error('invalid');protocol=JSON.parse(fs.readFileSync(lock,'utf8')).protocol;}catch{} - if(protocol!==LOCK_PROTOCOL||stat.nlink!==1||(process.getuid&&stat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Run mutation lock protocol is invalid'); - }else if(stat.isDirectory()){ +function ensureLockProtocol(dir: string): string { + const lock = join(dir, '.mutation-lock'), + marker = JSON.stringify({ protocol: LOCK_PROTOCOL }) + '\n'; + try { + atomicWriteSync(lock, marker, { mode: 0o600, noReplace: true }); + } catch (error: any) { + if (error?.code !== 'EEXIST') + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lock protocol could not be initialized'); + recoverAtomicNoReplaceJson(lock, { + label: 'Run mutation lock protocol', + maxBytes: LOCK_OWNER_MAX_BYTES, + validate: (value) => { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (value as any).protocol !== LOCK_PROTOCOL + ) + throw new CsoError('UNSAFE_PATH', 'Run mutation lock protocol is invalid'); + }, + }); + const stat = exactLstat(lock); + if (stat.isSymbolicLink()) throw new CsoError('UNSAFE_PATH', 'Run mutation lock is a symlink'); + if (stat.isFile()) { + let protocol = ''; + try { + if ( + stat.nlink !== 1 || + stat.size <= 0 || + stat.size > LOCK_OWNER_MAX_BYTES || + (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) + ) + throw new Error('invalid'); + protocol = JSON.parse(fs.readFileSync(lock, 'utf8')).protocol; + } catch {} + if (protocol !== LOCK_PROTOCOL || stat.nlink !== 1 || (process.getuid && stat.uid !== process.getuid())) + throw new CsoError('UNSAFE_PATH', 'Run mutation lock protocol is invalid'); + } else if (stat.isDirectory()) { // v2 created the canonical directory before publishing owner.json. A // missing/malformed owner can still belong to a paused live initializer, // so it is never age-reclaimed. Fully published dead owners can migrate. - const owner=readLegacyOwner(join(lock,'owner.json')); - if(ownerIsAlive(owner as LockOwner))throw new CsoError('INSUFFICIENT_CAPACITY','Another helper is updating this run'); - const migration=join(lock,'.v3-migration'),claim=acquireMigrationClaim(migration),current=exactLstat(lock); - if(current.dev!==stat.dev||current.ino!==stat.ino){try{exactUnlink(migration,claim.owner.token,claim.identity);}catch{}throw new CsoError('INSUFFICIENT_CAPACITY','Another helper changed this run during recovery');} - const tomb=join(dir,`.mutation-lock.legacy-${process.pid}-${randomBytes(4).toString('hex')}`); - try{fs.renameSync(lock,tomb);atomicWriteSync(lock,marker,{mode:0o600,noReplace:true});fs.rmSync(tomb,{recursive:true,force:true});} - catch{try{if(!fs.existsSync(lock)&&fs.existsSync(tomb))fs.renameSync(tomb,lock);}catch{}try{if(fs.existsSync(migration))exactUnlink(migration,claim.owner.token,claim.identity);}catch{}throw new CsoError('PERSISTENCE_FAILED','Legacy run mutation lock could not be migrated safely');} - }else throw new CsoError('UNSAFE_PATH','Run mutation lock has an invalid file type'); + const owner = readLegacyOwner(join(lock, 'owner.json')); + if (ownerIsAlive(owner as LockOwner)) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another helper is updating this run'); + const migration = join(lock, '.v3-migration'), + claim = acquireMigrationClaim(migration), + current = exactLstat(lock); + if (current.dev !== stat.dev || current.ino !== stat.ino) { + try { + exactUnlink(migration, claim.owner.token, claim.identity); + } catch {} + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another helper changed this run during recovery'); + } + const tomb = join(dir, `.mutation-lock.legacy-${process.pid}-${randomBytes(4).toString('hex')}`); + try { + fs.renameSync(lock, tomb); + atomicWriteSync(lock, marker, { mode: 0o600, noReplace: true }); + fs.rmSync(tomb, { recursive: true, force: true }); + } catch { + try { + if (!fs.existsSync(lock) && fs.existsSync(tomb)) fs.renameSync(tomb, lock); + } catch {} + try { + if (fs.existsSync(migration)) exactUnlink(migration, claim.owner.token, claim.identity); + } catch {} + throw new CsoError('PERSISTENCE_FAILED', 'Legacy run mutation lock could not be migrated safely'); + } + } else throw new CsoError('UNSAFE_PATH', 'Run mutation lock has an invalid file type'); } - const leases=join(dir,'.mutation-lock-leases'); - if(!fs.existsSync(leases))try{fs.mkdirSync(leases,{mode:0o700});}catch(error:any){if(error?.code!=='EEXIST')throw error;} - const stat=exactLstat(leases);if(stat.isSymbolicLink()||!stat.isDirectory()||(process.getuid&&stat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Run mutation lease directory is invalid'); - if(process.platform!=='win32')fs.chmodSync(leases,0o700); + const leases = join(dir, '.mutation-lock-leases'); + if (!fs.existsSync(leases)) + try { + fs.mkdirSync(leases, { mode: 0o700 }); + } catch (error: any) { + if (error?.code !== 'EEXIST') throw error; + } + const stat = exactLstat(leases); + if (stat.isSymbolicLink() || !stat.isDirectory() || (process.getuid && stat.uid !== process.getuid())) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease directory is invalid'); + if (process.platform !== 'win32') fs.chmodSync(leases, 0o700); return leases; } -type LeaseState={token:string;owner:LockOwner;identity:LockIdentity;candidate?:string;decisionPath?:string;decision?:LeaseDecision;decisionIdentity?:LockIdentity;active?:string;number?:bigint}; -type HeldRunLease={path:string;decision:string;decisionIdentity:LockIdentity;active:string;token:string;identity:LockIdentity}; -function privateLeaseArtifact(stat:ExactStats):boolean{return stat.isFile()&&!stat.isSymbolicLink()&&stat.size>0&&stat.size<=LOCK_OWNER_MAX_BYTES&& - (!process.getuid||stat.uid===process.getuid())&&(process.platform==='win32'||(stat.mode&0o077)===0);} -function readLeaseDecision(path:string,token:string):{decision:LeaseDecision;identity:LockIdentity}{ - const options=leaseDecisionRecoveryOptions(token);recoverAtomicNoReplaceJson(path,options); - const recovered=recoveryJson(path,1,options); - return{decision:validateLeaseDecision(recovered.value,token),identity:{dev:recovered.identity.dev,ino:recovered.identity.ino}}; +type LeaseState = { + token: string; + owner: LockOwner; + identity: LockIdentity; + candidate?: string; + decisionPath?: string; + decision?: LeaseDecision; + decisionIdentity?: LockIdentity; + active?: string; + number?: bigint; +}; +type HeldRunLease = { + path: string; + decision: string; + decisionIdentity: LockIdentity; + active: string; + token: string; + identity: LockIdentity; +}; +function privateLeaseArtifact(stat: ExactStats): boolean { + return ( + stat.isFile() && + !stat.isSymbolicLink() && + stat.size > 0 && + stat.size <= LOCK_OWNER_MAX_BYTES && + (!process.getuid || stat.uid === process.getuid()) && + (process.platform === 'win32' || (stat.mode & 0o077) === 0) + ); } -function decisionMatchesIdentity(decision:LeaseDecision,identity:LockIdentity):boolean{return decision.candidateDev===String(identity.dev)&&decision.candidateIno===String(identity.ino);} -function decisionMatchesOwner(decision:LeaseDecision,owner:LockOwner):boolean{return decision.ownerPid===owner.pid&&decision.ownerCreatedAt===owner.createdAt&&decision.ownerProcessIdentity===owner.processIdentity;} -function scanRunLeases(leases:string):LeaseState[]{ - const deadline=Date.now()+LEASE_BLOCKED_WAIT_MS;let contention:CsoError|undefined; - for(let attempt=0;attemptLEASE_PUBLICATION_TEMP.test(name))){try{recoverLeasePublications(leases);contention=new CsoError('INSUFFICIENT_CAPACITY','Run mutation lease publication changed during the lease scan');}catch(error){if(!(error instanceof CsoError)||error.code!=='INSUFFICIENT_CAPACITY'||Date.now()>=deadline)throw error;contention=error;Atomics.wait(LEASE_ELECTION_WAIT,0,0,LEASE_ELECTION_POLL_MS);}continue;} - const grouped=new Map}>(); - for(const name of names){ - const candidate=name.match(LEASE_CANDIDATE),decision=name.match(LEASE_DECISION),active=name.match(LEASE_ACTIVE),token=candidate?.[1]??decision?.[1]??active?.[1]; - if(!token)throw new CsoError('UNSAFE_PATH','Run mutation lease directory contains an invalid artifact'); - const group=grouped.get(token)??{actives:[]}; - if(candidate){if(group.candidate)throw new CsoError('UNSAFE_PATH','Run mutation lease has duplicate candidate state');group.candidate=join(leases,name);} - else if(decision){if(group.decision)throw new CsoError('UNSAFE_PATH','Run mutation lease has duplicate decision state');group.decision=join(leases,name);} - else if(active)group.actives.push({path:join(leases,name),encoded:active[2]}); - grouped.set(token,group); +function readLeaseDecision(path: string, token: string): { decision: LeaseDecision; identity: LockIdentity } { + const options = leaseDecisionRecoveryOptions(token); + recoverAtomicNoReplaceJson(path, options); + const recovered = recoveryJson(path, 1, options); + return { + decision: validateLeaseDecision(recovered.value, token), + identity: { dev: recovered.identity.dev, ino: recovered.identity.ino }, + }; +} +function decisionMatchesIdentity(decision: LeaseDecision, identity: LockIdentity): boolean { + return decision.candidateDev === String(identity.dev) && decision.candidateIno === String(identity.ino); +} +function decisionMatchesOwner(decision: LeaseDecision, owner: LockOwner): boolean { + return ( + decision.ownerPid === owner.pid && + decision.ownerCreatedAt === owner.createdAt && + decision.ownerProcessIdentity === owner.processIdentity + ); +} +function scanRunLeases(leases: string): LeaseState[] { + const deadline = Date.now() + LEASE_BLOCKED_WAIT_MS; + let contention: CsoError | undefined; + for (let attempt = 0; attempt < LEASE_BLOCKED_WAIT_MS / LEASE_ELECTION_POLL_MS + 16; attempt++) { + contention = undefined; + const names = fs.readdirSync(leases).sort(); + if (names.some((name) => LEASE_PUBLICATION_TEMP.test(name))) { + try { + recoverLeasePublications(leases); + contention = new CsoError( + 'INSUFFICIENT_CAPACITY', + 'Run mutation lease publication changed during the lease scan', + ); + } catch (error) { + if (!(error instanceof CsoError) || error.code !== 'INSUFFICIENT_CAPACITY' || Date.now() >= deadline) + throw error; + contention = error; + Atomics.wait(LEASE_ELECTION_WAIT, 0, 0, LEASE_ELECTION_POLL_MS); + } + continue; } - let retry=false;const states:LeaseState[]=[]; - for(const [token,group] of grouped){ - if(group.actives.length>1||group.actives.length===1&&!group.decision||!group.candidate&&!group.decision){retry=true;break;} - let decisionRecord:{decision:LeaseDecision;identity:LockIdentity}|undefined; - if(group.decision)try{decisionRecord=readLeaseDecision(group.decision,token);}catch(error){if(error instanceof CsoError&&(error.code==='SNAPSHOT_RACE'||error.code==='INSUFFICIENT_CAPACITY')){if(error.code==='INSUFFICIENT_CAPACITY'){if(Date.now()>=deadline)throw error;contention=error;}retry=true;break;}throw error;} - if(group.actives[0]&&(!decisionRecord||decisionRecord.decision.kind!=='ticket'||decisionRecord.decision.ticket!==group.actives[0].encoded))throw new CsoError('UNSAFE_PATH','Run mutation lease active phase does not match its ticket decision'); - const ownerPath=group.candidate??group.actives[0]?.path;let inspected:{owner:LockOwner;identity:LockIdentity}|undefined; - if(ownerPath){const expected=(group.candidate&&group.actives[0]?2:1) as LeaseLinks;let observed:ExactStats;try{observed=exactLstat(ownerPath);}catch(error:any){if(error?.code==='ENOENT'){retry=true;break;}throw error;}if(!privateLeaseArtifact(observed)){throw new CsoError('UNSAFE_PATH','Run mutation lease owner phase is not one private regular file');}if(observed.nlink!==expected){retry=true;break;}try{inspected=readOwner(ownerPath,token,expected,observed);}catch(error){if(error instanceof CsoError&&error.code==='INSUFFICIENT_CAPACITY'){if(Date.now()>=deadline)throw error;contention=error;retry=true;break;}throw error;}} - if(group.candidate&&group.actives[0]){let activeStat:ExactStats;try{activeStat=exactLstat(group.actives[0].path);}catch(error:any){if(error?.code==='ENOENT'){retry=true;break;}throw error;}if(!privateLeaseArtifact(activeStat)||activeStat.dev!==inspected!.identity.dev||activeStat.ino!==inspected!.identity.ino)throw new CsoError('UNSAFE_PATH','Run mutation lease active phase does not match its candidate inode');if(activeStat.nlink!==2){retry=true;break;}} - const identity=inspected?.identity??{dev:BigInt(decisionRecord!.decision.candidateDev),ino:BigInt(decisionRecord!.decision.candidateIno)},owner=inspected?.owner??decisionOwner(decisionRecord!.decision); - if(decisionRecord&&(!decisionMatchesIdentity(decisionRecord.decision,identity)||!decisionMatchesOwner(decisionRecord.decision,owner)))throw new CsoError('UNSAFE_PATH','Run mutation lease decision does not match its candidate owner'); - const number=decisionRecord?.decision.kind==='ticket'?BigInt(`0x${decisionRecord.decision.ticket}`):undefined; - states.push({token,owner,identity,...(group.candidate?{candidate:group.candidate}:{}),...(group.decision&&decisionRecord?{decisionPath:group.decision,decision:decisionRecord.decision,decisionIdentity:decisionRecord.identity}:{}),...(group.actives[0]?{active:group.actives[0].path}:{}),...(number!==undefined?{number}:{})}); + const grouped = new Map< + string, + { candidate?: string; decision?: string; actives: Array<{ path: string; encoded: string }> } + >(); + for (const name of names) { + const candidate = name.match(LEASE_CANDIDATE), + decision = name.match(LEASE_DECISION), + active = name.match(LEASE_ACTIVE), + token = candidate?.[1] ?? decision?.[1] ?? active?.[1]; + if (!token) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease directory contains an invalid artifact'); + const group = grouped.get(token) ?? { actives: [] }; + if (candidate) { + if (group.candidate) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease has duplicate candidate state'); + group.candidate = join(leases, name); + } else if (decision) { + if (group.decision) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease has duplicate decision state'); + group.decision = join(leases, name); + } else if (active) group.actives.push({ path: join(leases, name), encoded: active[2] }); + grouped.set(token, group); } - if(!retry&&fs.readdirSync(leases).sort().join('\0')===names.join('\0'))return states; - Atomics.wait(LEASE_ELECTION_WAIT,0,0,LEASE_ELECTION_POLL_MS); + let retry = false; + const states: LeaseState[] = []; + for (const [token, group] of grouped) { + if ( + group.actives.length > 1 || + (group.actives.length === 1 && !group.decision) || + (!group.candidate && !group.decision) + ) { + retry = true; + break; + } + let decisionRecord: { decision: LeaseDecision; identity: LockIdentity } | undefined; + if (group.decision) + try { + decisionRecord = readLeaseDecision(group.decision, token); + } catch (error) { + if ( + error instanceof CsoError && + (error.code === 'SNAPSHOT_RACE' || error.code === 'INSUFFICIENT_CAPACITY') + ) { + if (error.code === 'INSUFFICIENT_CAPACITY') { + if (Date.now() >= deadline) throw error; + contention = error; + } + retry = true; + break; + } + throw error; + } + if ( + group.actives[0] && + (!decisionRecord || + decisionRecord.decision.kind !== 'ticket' || + decisionRecord.decision.ticket !== group.actives[0].encoded) + ) + throw new CsoError( + 'UNSAFE_PATH', + 'Run mutation lease active phase does not match its ticket decision', + ); + const ownerPath = group.candidate ?? group.actives[0]?.path; + let inspected: { owner: LockOwner; identity: LockIdentity } | undefined; + if (ownerPath) { + const expected = (group.candidate && group.actives[0] ? 2 : 1) as LeaseLinks; + let observed: ExactStats; + try { + observed = exactLstat(ownerPath); + } catch (error: any) { + if (error?.code === 'ENOENT') { + retry = true; + break; + } + throw error; + } + if (!privateLeaseArtifact(observed)) { + throw new CsoError('UNSAFE_PATH', 'Run mutation lease owner phase is not one private regular file'); + } + if (observed.nlink !== expected) { + retry = true; + break; + } + try { + inspected = readOwner(ownerPath, token, expected, observed); + } catch (error) { + if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') { + if (Date.now() >= deadline) throw error; + contention = error; + retry = true; + break; + } + throw error; + } + } + if (group.candidate && group.actives[0]) { + let activeStat: ExactStats; + try { + activeStat = exactLstat(group.actives[0].path); + } catch (error: any) { + if (error?.code === 'ENOENT') { + retry = true; + break; + } + throw error; + } + if ( + !privateLeaseArtifact(activeStat) || + activeStat.dev !== inspected!.identity.dev || + activeStat.ino !== inspected!.identity.ino + ) + throw new CsoError( + 'UNSAFE_PATH', + 'Run mutation lease active phase does not match its candidate inode', + ); + if (activeStat.nlink !== 2) { + retry = true; + break; + } + } + const identity = inspected?.identity ?? { + dev: BigInt(decisionRecord!.decision.candidateDev), + ino: BigInt(decisionRecord!.decision.candidateIno), + }, + owner = inspected?.owner ?? decisionOwner(decisionRecord!.decision); + if ( + decisionRecord && + (!decisionMatchesIdentity(decisionRecord.decision, identity) || + !decisionMatchesOwner(decisionRecord.decision, owner)) + ) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease decision does not match its candidate owner'); + const number = + decisionRecord?.decision.kind === 'ticket' + ? BigInt(`0x${decisionRecord.decision.ticket}`) + : undefined; + states.push({ + token, + owner, + identity, + ...(group.candidate ? { candidate: group.candidate } : {}), + ...(group.decision && decisionRecord + ? { + decisionPath: group.decision, + decision: decisionRecord.decision, + decisionIdentity: decisionRecord.identity, + } + : {}), + ...(group.actives[0] ? { active: group.actives[0].path } : {}), + ...(number !== undefined ? { number } : {}), + }); + } + if (!retry && fs.readdirSync(leases).sort().join('\0') === names.join('\0')) return states; + Atomics.wait(LEASE_ELECTION_WAIT, 0, 0, LEASE_ELECTION_POLL_MS); } - if(contention)throw contention; - throw new CsoError('UNSAFE_PATH','Run mutation lease phases could not be validated as one coherent set'); + if (contention) throw contention; + throw new CsoError('UNSAFE_PATH', 'Run mutation lease phases could not be validated as one coherent set'); } -function releaseLeaseState(state:LeaseState):void{ - if(state.candidate)exactUnlink(state.candidate,state.token,state.identity,state.active?2:1); - if(state.active)exactUnlink(state.active,state.token,state.identity,1); - if(state.decisionPath&&state.decisionIdentity)exactDecisionUnlink(state.decisionPath,state.token,state.decisionIdentity); +function releaseLeaseState(state: LeaseState): void { + if (state.candidate) exactUnlink(state.candidate, state.token, state.identity, state.active ? 2 : 1); + if (state.active) exactUnlink(state.active, state.token, state.identity, 1); + if (state.decisionPath && state.decisionIdentity) + exactDecisionUnlink(state.decisionPath, state.token, state.decisionIdentity); } -function exactDecisionUnlink(path:string,token:string,identity:LockIdentity):void{ - let current:{decision:LeaseDecision;identity:LockIdentity};try{current=readLeaseDecision(path,token);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before exact release');} - if(current.identity.dev!==identity.dev||current.identity.ino!==identity.ino)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before exact release'); - let final:ExactStats;try{final=exactLstat(path);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before exact release');} - if(!privateLeaseArtifact(final)||final.nlink!==1||final.dev!==identity.dev||final.ino!==identity.ino)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before exact release'); - try{fs.unlinkSync(path);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before exact release');} +function exactDecisionUnlink(path: string, token: string, identity: LockIdentity): void { + let current: { decision: LeaseDecision; identity: LockIdentity }; + try { + current = readLeaseDecision(path, token); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before exact release'); + } + if (current.identity.dev !== identity.dev || current.identity.ino !== identity.ino) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before exact release'); + let final: ExactStats; + try { + final = exactLstat(path); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before exact release'); + } + if ( + !privateLeaseArtifact(final) || + final.nlink !== 1 || + final.dev !== identity.dev || + final.ino !== identity.ino + ) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before exact release'); + try { + fs.unlinkSync(path); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before exact release'); + } } -function publishLeasePhase(candidate:string,target:string,token:string,identity:LockIdentity):void{ - const before=readOwner(candidate,token,1);if(before.identity.dev!==identity.dev||before.identity.ino!==identity.ino)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease changed before phase publication'); - try{fs.linkSync(candidate,target);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease phase could not be published');} - const source=exactLstat(candidate),phase=exactLstat(target);if(source.dev!==identity.dev||source.ino!==identity.ino||phase.dev!==identity.dev||phase.ino!==identity.ino||source.nlink!==2||phase.nlink!==2)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease phase changed during publication'); +function publishLeasePhase(candidate: string, target: string, token: string, identity: LockIdentity): void { + const before = readOwner(candidate, token, 1); + if (before.identity.dev !== identity.dev || before.identity.ino !== identity.ino) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease changed before phase publication'); + try { + fs.linkSync(candidate, target); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease phase could not be published'); + } + const source = exactLstat(candidate), + phase = exactLstat(target); + if ( + source.dev !== identity.dev || + source.ino !== identity.ino || + phase.dev !== identity.dev || + phase.ino !== identity.ino || + source.nlink !== 2 || + phase.nlink !== 2 + ) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease phase changed during publication'); } -function makeLeaseDecision(owner:LockOwner,identity:LockIdentity,kind:'ticket'|'withdraw',ticket?:string):LeaseDecision{ - const publisherIdentity=processIdentity(process.pid); - return{schemaVersion:1,token:owner.token,kind,...(ticket?{ticket}:{}),candidateDev:String(identity.dev),candidateIno:String(identity.ino),ownerPid:owner.pid,...(owner.processIdentity?{ownerProcessIdentity:owner.processIdentity}:{}),ownerCreatedAt:owner.createdAt,publisherPid:process.pid,...(publisherIdentity?{publisherProcessIdentity:publisherIdentity}:{}),createdAt:Date.now()}; +function makeLeaseDecision( + owner: LockOwner, + identity: LockIdentity, + kind: 'ticket' | 'withdraw', + ticket?: string, +): LeaseDecision { + const publisherIdentity = processIdentity(process.pid); + return { + schemaVersion: 1, + token: owner.token, + kind, + ...(ticket ? { ticket } : {}), + candidateDev: String(identity.dev), + candidateIno: String(identity.ino), + ownerPid: owner.pid, + ...(owner.processIdentity ? { ownerProcessIdentity: owner.processIdentity } : {}), + ownerCreatedAt: owner.createdAt, + publisherPid: process.pid, + ...(publisherIdentity ? { publisherProcessIdentity: publisherIdentity } : {}), + createdAt: Date.now(), + }; } -function publishLeaseDecision(path:string,decision:LeaseDecision):void{ - try{atomicWriteSync(path,JSON.stringify(decision)+'\n',{mode:0o600,noReplace:true});}catch(error:any){if(error?.code!=='EEXIST')throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision could not be published');} +function publishLeaseDecision(path: string, decision: LeaseDecision): void { + try { + atomicWriteSync(path, JSON.stringify(decision) + '\n', { mode: 0o600, noReplace: true }); + } catch (error: any) { + if (error?.code !== 'EEXIST') + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision could not be published'); + } } -function releaseKnownLease(candidate:string,decisionPath:string,active:string|undefined,token:string,identity:LockIdentity,expectedDecisionIdentity?:LockIdentity,requireActive=false):void{ - let candidateStat:ExactStats;try{candidateStat=exactLstat(candidate);}catch{throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before exact release');} - if(!privateLeaseArtifact(candidateStat)||candidateStat.dev!==identity.dev||candidateStat.ino!==identity.ino)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before exact release'); - let activeStat:ExactStats|undefined;try{if(active)activeStat=exactLstat(active);}catch(error:any){if(error?.code!=='ENOENT')throw new CsoError('PERSISTENCE_FAILED','Run mutation lease active phase changed before cleanup');} - if(requireActive&&!activeStat)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease active phase changed before exact release'); - if(activeStat&&(!privateLeaseArtifact(activeStat)||activeStat.dev!==identity.dev||activeStat.ino!==identity.ino))throw new CsoError('PERSISTENCE_FAILED','Run mutation lease active phase changed before cleanup'); - const expected=activeStat?2:1;if(candidateStat.nlink!==expected||activeStat&&activeStat.nlink!==2)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease link state changed before cleanup'); - let decisionRecord:{decision:LeaseDecision;identity:LockIdentity}|undefined;try{decisionRecord=readLeaseDecision(decisionPath,token);}catch(error:any){if(!(error instanceof CsoError)||error.code!=='SNAPSHOT_RACE')throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before cleanup');} - if(expectedDecisionIdentity&&!decisionRecord)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before exact release'); - if(decisionRecord&&(!decisionMatchesIdentity(decisionRecord.decision,identity)||decisionRecord.decision.ownerPid!==process.pid||expectedDecisionIdentity&&(decisionRecord.identity.dev!==expectedDecisionIdentity.dev||decisionRecord.identity.ino!==expectedDecisionIdentity.ino)))throw new CsoError('PERSISTENCE_FAILED','Run mutation lease decision changed before cleanup'); - exactUnlink(candidate,token,identity,expected);if(activeStat)exactUnlink(active!,token,identity,1);if(decisionRecord)exactDecisionUnlink(decisionPath,token,decisionRecord.identity); +function releaseKnownLease( + candidate: string, + decisionPath: string, + active: string | undefined, + token: string, + identity: LockIdentity, + expectedDecisionIdentity?: LockIdentity, + requireActive = false, +): void { + let candidateStat: ExactStats; + try { + candidateStat = exactLstat(candidate); + } catch { + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before exact release'); + } + if ( + !privateLeaseArtifact(candidateStat) || + candidateStat.dev !== identity.dev || + candidateStat.ino !== identity.ino + ) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before exact release'); + let activeStat: ExactStats | undefined; + try { + if (active) activeStat = exactLstat(active); + } catch (error: any) { + if (error?.code !== 'ENOENT') + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease active phase changed before cleanup'); + } + if (requireActive && !activeStat) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease active phase changed before exact release'); + if ( + activeStat && + (!privateLeaseArtifact(activeStat) || activeStat.dev !== identity.dev || activeStat.ino !== identity.ino) + ) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease active phase changed before cleanup'); + const expected = activeStat ? 2 : 1; + if (candidateStat.nlink !== expected || (activeStat && activeStat.nlink !== 2)) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease link state changed before cleanup'); + let decisionRecord: { decision: LeaseDecision; identity: LockIdentity } | undefined; + try { + decisionRecord = readLeaseDecision(decisionPath, token); + } catch (error: any) { + if (!(error instanceof CsoError) || error.code !== 'SNAPSHOT_RACE') + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before cleanup'); + } + if (expectedDecisionIdentity && !decisionRecord) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before exact release'); + if ( + decisionRecord && + (!decisionMatchesIdentity(decisionRecord.decision, identity) || + decisionRecord.decision.ownerPid !== process.pid || + (expectedDecisionIdentity && + (decisionRecord.identity.dev !== expectedDecisionIdentity.dev || + decisionRecord.identity.ino !== expectedDecisionIdentity.ino))) + ) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease decision changed before cleanup'); + exactUnlink(candidate, token, identity, expected); + if (activeStat) exactUnlink(active!, token, identity, 1); + if (decisionRecord) exactDecisionUnlink(decisionPath, token, decisionRecord.identity); } -function compareLeaseOrder(left:LeaseState,rightNumber:bigint,rightToken:string):number{return left.number!rightNumber?1:left.tokenrightToken?1:0;} -function recoverDeadLease(state:LeaseState):boolean{ - if(ownerIsAlive(state.owner))return false; - try{releaseLeaseState(state);}catch(error){ - if(!(error instanceof CsoError)||error.code!=='PERSISTENCE_FAILED')throw error; - for(const path of [state.candidate,state.active].filter((value):value is string=>Boolean(value))){try{const stat=exactLstat(path);if(!privateLeaseArtifact(stat)||stat.dev!==state.identity.dev||stat.ino!==state.identity.ino)throw new CsoError('UNSAFE_PATH','Dead run mutation lease was replaced during recovery');}catch(recoveryError:any){if(recoveryError instanceof CsoError)throw recoveryError;if(recoveryError?.code!=='ENOENT')throw recoveryError;}} - if(state.decisionPath&&state.decisionIdentity)try{const stat=exactLstat(state.decisionPath);if(!privateLeaseArtifact(stat)||stat.dev!==state.decisionIdentity.dev||stat.ino!==state.decisionIdentity.ino)throw new CsoError('UNSAFE_PATH','Dead run mutation lease decision was replaced during recovery');}catch(recoveryError:any){if(recoveryError instanceof CsoError)throw recoveryError;if(recoveryError?.code!=='ENOENT')throw recoveryError;} - Atomics.wait(LEASE_ELECTION_WAIT,0,0,LEASE_ELECTION_POLL_MS); +function compareLeaseOrder(left: LeaseState, rightNumber: bigint, rightToken: string): number { + return left.number! < rightNumber + ? -1 + : left.number! > rightNumber + ? 1 + : left.token < rightToken + ? -1 + : left.token > rightToken + ? 1 + : 0; +} +function recoverDeadLease(state: LeaseState): boolean { + if (ownerIsAlive(state.owner)) return false; + try { + releaseLeaseState(state); + } catch (error) { + if (!(error instanceof CsoError) || error.code !== 'PERSISTENCE_FAILED') throw error; + for (const path of [state.candidate, state.active].filter((value): value is string => Boolean(value))) { + try { + const stat = exactLstat(path); + if (!privateLeaseArtifact(stat) || stat.dev !== state.identity.dev || stat.ino !== state.identity.ino) + throw new CsoError('UNSAFE_PATH', 'Dead run mutation lease was replaced during recovery'); + } catch (recoveryError: any) { + if (recoveryError instanceof CsoError) throw recoveryError; + if (recoveryError?.code !== 'ENOENT') throw recoveryError; + } + } + if (state.decisionPath && state.decisionIdentity) + try { + const stat = exactLstat(state.decisionPath); + if ( + !privateLeaseArtifact(stat) || + stat.dev !== state.decisionIdentity.dev || + stat.ino !== state.decisionIdentity.ino + ) + throw new CsoError('UNSAFE_PATH', 'Dead run mutation lease decision was replaced during recovery'); + } catch (recoveryError: any) { + if (recoveryError instanceof CsoError) throw recoveryError; + if (recoveryError?.code !== 'ENOENT') throw recoveryError; + } + Atomics.wait(LEASE_ELECTION_WAIT, 0, 0, LEASE_ELECTION_POLL_MS); } return true; } -function chooseRunLeaseTicket(leases:string,token:string,owner:LockOwner,identity:LockIdentity):{path:string;number:bigint;identity:LockIdentity}{ - for(;;){ - const states=scanRunLeases(leases);let recovered=false,max=0n; - const own=states.find(state=>state.token===token); - if(!own?.candidate||own.identity.dev!==identity.dev||own.identity.ino!==identity.ino)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease candidate changed before ticket selection'); - if(own.decision){ - if(own.decision.kind==='withdraw')throw new CsoError('INSUFFICIENT_CAPACITY','This run mutation lease was withdrawn before ticket selection'); - if(own.number===undefined||!own.decisionPath||!own.decisionIdentity)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ticket decision is incomplete'); - return{path:own.decisionPath,number:own.number,identity:own.decisionIdentity}; +function chooseRunLeaseTicket( + leases: string, + token: string, + owner: LockOwner, + identity: LockIdentity, +): { path: string; number: bigint; identity: LockIdentity } { + for (;;) { + const states = scanRunLeases(leases); + let recovered = false, + max = 0n; + const own = states.find((state) => state.token === token); + if (!own?.candidate || own.identity.dev !== identity.dev || own.identity.ino !== identity.ino) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Run mutation lease candidate changed before ticket selection', + ); + if (own.decision) { + if (own.decision.kind === 'withdraw') + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + 'This run mutation lease was withdrawn before ticket selection', + ); + if (own.number === undefined || !own.decisionPath || !own.decisionIdentity) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ticket decision is incomplete'); + return { path: own.decisionPath, number: own.number, identity: own.decisionIdentity }; } - for(const state of states){ - if(state.token===token)continue; - if(recoverDeadLease(state)){recovered=true;break;} - if(state.active)throw new CsoError('INSUFFICIENT_CAPACITY',state.owner.pid===process.pid?'Another operation in this helper is updating this run':'Another helper is updating this run'); - if(!state.candidate||state.decision?.kind==='withdraw')continue; - if(state.owner.pid===process.pid)throw new CsoError('INSUFFICIENT_CAPACITY','Another operation in this helper is updating this run'); - if(state.number!==undefined&&state.number>max)max=state.number; + for (const state of states) { + if (state.token === token) continue; + if (recoverDeadLease(state)) { + recovered = true; + break; + } + if (state.active) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + state.owner.pid === process.pid + ? 'Another operation in this helper is updating this run' + : 'Another helper is updating this run', + ); + if (!state.candidate || state.decision?.kind === 'withdraw') continue; + if (state.owner.pid === process.pid) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another operation in this helper is updating this run'); + if (state.number !== undefined && state.number > max) max = state.number; } - if(recovered)continue; - const number=max+1n;if(number>0xffffffffffffffffn)throw new CsoError('INSUFFICIENT_CAPACITY','Run mutation lease ticket space is exhausted'); - const encoded=number.toString(16).padStart(16,'0'),path=join(leases,`${token}.decision`); - publishLeaseDecision(path,makeLeaseDecision(owner,identity,'ticket',encoded)); + if (recovered) continue; + const number = max + 1n; + if (number > 0xffffffffffffffffn) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Run mutation lease ticket space is exhausted'); + const encoded = number.toString(16).padStart(16, '0'), + path = join(leases, `${token}.decision`); + publishLeaseDecision(path, makeLeaseDecision(owner, identity, 'ticket', encoded)); } } -function fenceRunLeaseCandidate(leases:string,state:LeaseState):void{ - if(!state.candidate||state.decision)return; - const observed=readOwner(state.candidate,state.token,1); - if(observed.identity.dev!==state.identity.dev||observed.identity.ino!==state.identity.ino)throw new CsoError('UNSAFE_PATH','Run mutation lease candidate changed before withdrawal'); - publishLeaseDecision(join(leases,`${state.token}.decision`),makeLeaseDecision(state.owner,state.identity,'withdraw')); +function fenceRunLeaseCandidate(leases: string, state: LeaseState): void { + if (!state.candidate || state.decision) return; + const observed = readOwner(state.candidate, state.token, 1); + if (observed.identity.dev !== state.identity.dev || observed.identity.ino !== state.identity.ino) + throw new CsoError('UNSAFE_PATH', 'Run mutation lease candidate changed before withdrawal'); + publishLeaseDecision( + join(leases, `${state.token}.decision`), + makeLeaseDecision(state.owner, state.identity, 'withdraw'), + ); } -function activateRunLease(leases:string,token:string,candidate:string,decisionPath:string,active:string,number:bigint,identity:LockIdentity):void{ - const blockedDeadline=Date.now()+LEASE_BLOCKED_WAIT_MS; - for(;;){ - const states=scanRunLeases(leases),own=states.find(state=>state.token===token); - if(!own||own.candidate!==candidate||own.decisionPath!==decisionPath||own.decision?.kind!=='ticket'||own.number!==number||own.active||own.identity.dev!==identity.dev||own.identity.ino!==identity.ino)throw new CsoError(own?.decision?.kind==='withdraw'?'INSUFFICIENT_CAPACITY':'PERSISTENCE_FAILED',own?.decision?.kind==='withdraw'?'This run mutation lease was withdrawn before activation':'Run mutation lease ticket changed before activation'); - let retry=false,lost=false;const pending:LeaseState[]=[]; - for(const state of states){ - if(state.token===token)continue; - if(recoverDeadLease(state)){retry=true;break;} - if(state.active)throw new CsoError('INSUFFICIENT_CAPACITY',state.owner.pid===process.pid?'Another operation in this helper is updating this run':'Another helper is updating this run'); - if(!state.candidate||state.decision?.kind==='withdraw')continue; - if(state.owner.pid===process.pid)throw new CsoError('INSUFFICIENT_CAPACITY','Another operation in this helper is updating this run'); - if(state.number===undefined)pending.push(state);else if(compareLeaseOrder(state,number,token)<0)lost=true; +function activateRunLease( + leases: string, + token: string, + candidate: string, + decisionPath: string, + active: string, + number: bigint, + identity: LockIdentity, +): void { + const blockedDeadline = Date.now() + LEASE_BLOCKED_WAIT_MS; + for (;;) { + const states = scanRunLeases(leases), + own = states.find((state) => state.token === token); + if ( + !own || + own.candidate !== candidate || + own.decisionPath !== decisionPath || + own.decision?.kind !== 'ticket' || + own.number !== number || + own.active || + own.identity.dev !== identity.dev || + own.identity.ino !== identity.ino + ) + throw new CsoError( + own?.decision?.kind === 'withdraw' ? 'INSUFFICIENT_CAPACITY' : 'PERSISTENCE_FAILED', + own?.decision?.kind === 'withdraw' + ? 'This run mutation lease was withdrawn before activation' + : 'Run mutation lease ticket changed before activation', + ); + let retry = false, + lost = false; + const pending: LeaseState[] = []; + for (const state of states) { + if (state.token === token) continue; + if (recoverDeadLease(state)) { + retry = true; + break; + } + if (state.active) + throw new CsoError( + 'INSUFFICIENT_CAPACITY', + state.owner.pid === process.pid + ? 'Another operation in this helper is updating this run' + : 'Another helper is updating this run', + ); + if (!state.candidate || state.decision?.kind === 'withdraw') continue; + if (state.owner.pid === process.pid) + throw new CsoError('INSUFFICIENT_CAPACITY', 'Another operation in this helper is updating this run'); + if (state.number === undefined) pending.push(state); + else if (compareLeaseOrder(state, number, token) < 0) lost = true; } - if(retry)continue; - if(lost)throw new CsoError('INSUFFICIENT_CAPACITY','An earlier run mutation lease ticket won the election'); - if(pending.length){if(Date.now()state.token===token);if(!current||current.candidate!==candidate||current.decisionPath!==decisionPath||current.decision?.kind!=='ticket'||current.number!==number||current.active!==active||current.identity.dev!==identity.dev||current.identity.ino!==identity.ino||verified.some(state=>state.token!==token&&state.active))throw new CsoError('PERSISTENCE_FAILED','Run mutation lease activation could not be verified exclusively'); + if (retry) continue; + if (lost) + throw new CsoError('INSUFFICIENT_CAPACITY', 'An earlier run mutation lease ticket won the election'); + if (pending.length) { + if (Date.now() < blockedDeadline) { + Atomics.wait(LEASE_ELECTION_WAIT, 0, 0, LEASE_ELECTION_POLL_MS); + continue; + } + for (const state of pending) fenceRunLeaseCandidate(leases, state); + continue; + } + publishLeasePhase(candidate, active, token, identity); + const verified = scanRunLeases(leases), + current = verified.find((state) => state.token === token); + if ( + !current || + current.candidate !== candidate || + current.decisionPath !== decisionPath || + current.decision?.kind !== 'ticket' || + current.number !== number || + current.active !== active || + current.identity.dev !== identity.dev || + current.identity.ino !== identity.ino || + verified.some((state) => state.token !== token && state.active) + ) + throw new CsoError( + 'PERSISTENCE_FAILED', + 'Run mutation lease activation could not be verified exclusively', + ); return; } } -function acquireRunLease(dir:string):HeldRunLease{ +function acquireRunLease(dir: string): HeldRunLease { secureDirectory(dir); - const leases=ensureLockProtocol(dir);recoverLeasePublications(leases); - const token=randomBytes(16).toString('hex'),lease=join(leases,`${token}.json`),owner:LockOwner={pid:process.pid,processIdentity:processIdentity(process.pid),token,createdAt:Date.now()}; - atomicWriteSync(lease,JSON.stringify(owner)+'\n',{mode:0o600,noReplace:true}); - const ownStat=exactLstat(lease),ownIdentity={dev:ownStat.dev,ino:ownStat.ino}; - const decision=join(leases,`${token}.decision`);let decisionIdentity:LockIdentity|undefined,active:string|undefined; - try{ - const chosen=chooseRunLeaseTicket(leases,token,owner,ownIdentity);decisionIdentity=chosen.identity; - active=join(leases,`${token}.active.${chosen.number.toString(16).padStart(16,'0')}`);activateRunLease(leases,token,lease,decision,active,chosen.number,ownIdentity); - const published=readOwner(active,token,2);if(published.identity.dev!==ownIdentity.dev||published.identity.ino!==ownIdentity.ino)throw new CsoError('PERSISTENCE_FAILED','Run mutation lease ownership changed before work began'); - }catch(error){ - try{releaseKnownLease(lease,decision,active,token,ownIdentity,decisionIdentity);}catch(releaseError){throw releaseError;} - throw error; - } - return{path:lease,decision,decisionIdentity:decisionIdentity!,active,token,identity:ownIdentity}; -} -function releaseRunLease(lease:HeldRunLease,path=lease.path):void{const directory=dirname(path);releaseKnownLease(path,join(directory,basename(lease.decision)),join(directory,basename(lease.active)),lease.token,lease.identity,lease.decisionIdentity,true);} -export function withLock(dir: string, fn: () => T): T | Promise> { - const lease=acquireRunLease(dir),unlock=()=>releaseRunLease(lease); - let value:T; + const leases = ensureLockProtocol(dir); + recoverLeasePublications(leases); + const token = randomBytes(16).toString('hex'), + lease = join(leases, `${token}.json`), + owner: LockOwner = { + pid: process.pid, + processIdentity: processIdentity(process.pid), + token, + createdAt: Date.now(), + }; + atomicWriteSync(lease, JSON.stringify(owner) + '\n', { mode: 0o600, noReplace: true }); + const ownStat = exactLstat(lease), + ownIdentity = { dev: ownStat.dev, ino: ownStat.ino }; + const decision = join(leases, `${token}.decision`); + let decisionIdentity: LockIdentity | undefined, active: string | undefined; try { - value=fn(); - } catch(error){ - try{unlock();}catch(releaseError){throw releaseError;} + const chosen = chooseRunLeaseTicket(leases, token, owner, ownIdentity); + decisionIdentity = chosen.identity; + active = join(leases, `${token}.active.${chosen.number.toString(16).padStart(16, '0')}`); + activateRunLease(leases, token, lease, decision, active, chosen.number, ownIdentity); + const published = readOwner(active, token, 2); + if (published.identity.dev !== ownIdentity.dev || published.identity.ino !== ownIdentity.ino) + throw new CsoError('PERSISTENCE_FAILED', 'Run mutation lease ownership changed before work began'); + } catch (error) { + try { + releaseKnownLease(lease, decision, active, token, ownIdentity, decisionIdentity); + } catch (releaseError) { + throw releaseError; + } throw error; } - if(value&&typeof (value as any).then==='function')return Promise.resolve(value).finally(unlock) as Promise>; + return { path: lease, decision, decisionIdentity: decisionIdentity!, active, token, identity: ownIdentity }; +} +function releaseRunLease(lease: HeldRunLease, path = lease.path): void { + const directory = dirname(path); + releaseKnownLease( + path, + join(directory, basename(lease.decision)), + join(directory, basename(lease.active)), + lease.token, + lease.identity, + lease.decisionIdentity, + true, + ); +} +export function withLock(dir: string, fn: () => T): T | Promise> { + const lease = acquireRunLease(dir), + unlock = () => releaseRunLease(lease); + let value: T; + try { + value = fn(); + } catch (error) { + try { + unlock(); + } catch (releaseError) { + throw releaseError; + } + throw error; + } + if (value && typeof (value as any).then === 'function') + return Promise.resolve(value).finally(unlock) as Promise>; // Keep release errors outside the callback catch path. Retrying an exact // release after it partially succeeds can only obscure which lease phase // changed and attempts the same fail-closed cleanup twice. - unlock();return value as any; + unlock(); + return value as any; } -function boundedMarker(path:string,admit:()=>void=()=>{}):string{ +function boundedMarker(path: string, admit: () => void = () => {}): string { admit(); - try{const stat=fs.lstatSync(path);if(stat.isSymbolicLink()||!stat.isFile()||stat.size>8192)return'';return fs.readFileSync(path,'utf8');}catch{return'';} + try { + const stat = fs.lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 8192) return ''; + return fs.readFileSync(path, 'utf8'); + } catch { + return ''; + } } /** A detached watchdog owns these paths until it records exact cleanup or an acknowledgement. */ -export function hasPendingWatchdogCleanup(dir:string,admit:()=>void=()=>{}):boolean{ - let visited=0,pending=false; - const walk=(at:string,depth:number)=>{ - if(pending||depth>6||visited++>4000)return; - const entries:fs.Dirent[]=[];let directory:fs.Dir; - admit();try{directory=fs.opendirSync(at);}catch{return;} - try{for(;;){admit();const entry=directory.readSync();if(!entry)break;entries.push(entry);}}finally{directory.closeSync();} - const names=new Set(entries.map(entry=>entry.name)); - if(names.has('attempt.ready')&&!names.has('attempt.stopped')&&!boundedMarker(join(at,'attempt.event'),admit).includes('execution-copy cleanup complete')){pending=true;return;} - if(names.has('watchdog.ready')&&!names.has('watchdog.stopped')&&!boundedMarker(join(at,'watchdog.event'),admit).includes('cleanup complete')){pending=true;return;} - for(const entry of entries){if(!/^[A-Za-z0-9._-]{1,120}$/.test(entry.name)||!entry.isDirectory())continue;walk(join(at,entry.name),depth+1);if(pending)return;} - }; - for(const name of ['supervision','preparation-execution']){ - admit();const root=join(dir,name);if(!fs.existsSync(root))continue; +export function hasPendingWatchdogCleanup(dir: string, admit: () => void = () => {}): boolean { + let visited = 0, + pending = false; + const walk = (at: string, depth: number) => { + if (pending || depth > 6 || visited++ > 4000) return; + const entries: fs.Dirent[] = []; + let directory: fs.Dir; admit(); - const rootStat=fs.lstatSync(root);if(rootStat.isSymbolicLink()||!rootStat.isDirectory())throw new CsoError('UNSAFE_PATH','Watchdog supervision state is not a private directory'); - walk(root,0);if(pending)return true; + try { + directory = fs.opendirSync(at); + } catch { + return; + } + try { + for (;;) { + admit(); + const entry = directory.readSync(); + if (!entry) break; + entries.push(entry); + } + } finally { + directory.closeSync(); + } + const names = new Set(entries.map((entry) => entry.name)); + if ( + names.has('attempt.ready') && + !names.has('attempt.stopped') && + !boundedMarker(join(at, 'attempt.event'), admit).includes('execution-copy cleanup complete') + ) { + pending = true; + return; + } + if ( + names.has('watchdog.ready') && + !names.has('watchdog.stopped') && + !boundedMarker(join(at, 'watchdog.event'), admit).includes('cleanup complete') + ) { + pending = true; + return; + } + for (const entry of entries) { + if (!/^[A-Za-z0-9._-]{1,120}$/.test(entry.name) || !entry.isDirectory()) continue; + walk(join(at, entry.name), depth + 1); + if (pending) return; + } + }; + for (const name of ['supervision', 'preparation-execution']) { + admit(); + const root = join(dir, name); + if (!fs.existsSync(root)) continue; + admit(); + const rootStat = fs.lstatSync(root); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) + throw new CsoError('UNSAFE_PATH', 'Watchdog supervision state is not a private directory'); + walk(root, 0); + if (pending) return true; } return false; } -const EPHEMERAL_REPLAY='.ephemeral-replay.json'; +const EPHEMERAL_REPLAY = '.ephemeral-replay.json'; /** Delete a replay-only snapshot unless detached cleanup still owns its control tree. */ -export function finalizeReplayTemporary(dir:string):void{ - if(hasPendingWatchdogCleanup(dir)){writeJsonExclusive(join(dir,EPHEMERAL_REPLAY),{schemaVersion:1,kind:'replay-temporary',retainedAt:new Date().toISOString()});return;} - fs.rmSync(dir,{recursive:true,force:true}); +export function finalizeReplayTemporary(dir: string): void { + if (hasPendingWatchdogCleanup(dir)) { + writeJsonExclusive(join(dir, EPHEMERAL_REPLAY), { + schemaVersion: 1, + kind: 'replay-temporary', + retainedAt: new Date().toISOString(), + }); + return; + } + fs.rmSync(dir, { recursive: true, force: true }); } /** Remove one private tree cooperatively without following links or holding directory handles across checks. */ -function boundedRemoveTree(root:string,admit:()=>void,preserveRootName?:string):void{ - type Frame={path:string;root:boolean;names?:string[];index:number}; - const stack:Frame[]=[{path:root,root:true,index:0}]; - while(stack.length){ - const frame=stack[stack.length-1]; - if(!frame.names){ - admit();let stat:fs.Stats;try{stat=fs.lstatSync(frame.path);}catch(error:any){if(error?.code==='ENOENT'){stack.pop();continue;}throw error;} - if(stat.isSymbolicLink()||!stat.isDirectory()){admit();fs.unlinkSync(frame.path);stack.pop();continue;} - const names:string[]=[];admit();const directory=fs.opendirSync(frame.path); - try{for(;;){admit();const entry=directory.readSync();if(!entry)break;if(!(frame.root&&entry.name===preserveRootName))names.push(entry.name);}}finally{directory.closeSync();} - frame.names=names;frame.index=0; +function boundedRemoveTree(root: string, admit: () => void, preserveRootName?: string): void { + type Frame = { path: string; root: boolean; names?: string[]; index: number }; + const stack: Frame[] = [{ path: root, root: true, index: 0 }]; + while (stack.length) { + const frame = stack[stack.length - 1]; + if (!frame.names) { + admit(); + let stat: fs.Stats; + try { + stat = fs.lstatSync(frame.path); + } catch (error: any) { + if (error?.code === 'ENOENT') { + stack.pop(); + continue; + } + throw error; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + admit(); + fs.unlinkSync(frame.path); + stack.pop(); + continue; + } + const names: string[] = []; + admit(); + const directory = fs.opendirSync(frame.path); + try { + for (;;) { + admit(); + const entry = directory.readSync(); + if (!entry) break; + if (!(frame.root && entry.name === preserveRootName)) names.push(entry.name); + } + } finally { + directory.closeSync(); + } + frame.names = names; + frame.index = 0; } - if(frame.indexvoid):void{ - boundedRemoveTree(root,admit,'.mutation-lock-leases'); - admit();const directory=fs.opendirSync(root);try{for(;;){admit();const entry=directory.readSync();if(!entry)break;if(entry.name!=='.mutation-lock-leases')throw new CsoError('SNAPSHOT_RACE','Private retention tree changed during bounded cleanup');}}finally{directory.closeSync();} +function consumeLeasedTree(root: string, admit: () => void): void { + boundedRemoveTree(root, admit, '.mutation-lock-leases'); + admit(); + const directory = fs.opendirSync(root); + try { + for (;;) { + admit(); + const entry = directory.readSync(); + if (!entry) break; + if (entry.name !== '.mutation-lock-leases') + throw new CsoError('SNAPSHOT_RACE', 'Private retention tree changed during bounded cleanup'); + } + } finally { + directory.closeSync(); + } // Only the helper's fixed-size lease protocol remains. Consuming it with the // directory preserves the exact-release invariant without an unbounded walk. - admit();fs.rmSync(root,{recursive:true,force:true}); + admit(); + fs.rmSync(root, { recursive: true, force: true }); } -function repairBundleExpiry(dir:string,run:string,now:number,runExpired:boolean,admit:()=>void):boolean{ - admit();const bundles=join(dir,'bundles');if(!fs.existsSync(bundles))return false; +function repairBundleExpiry( + dir: string, + run: string, + now: number, + runExpired: boolean, + admit: () => void, +): boolean { admit(); - const stat=fs.lstatSync(bundles);if(stat.isSymbolicLink()||!stat.isDirectory())throw new CsoError('UNSAFE_PATH','Repair bundle archive is not a private directory'); - let retained=false,remaining=0;admit();const directory=fs.opendirSync(bundles); - try{for(;;){ - admit();const entry=directory.readSync();if(!entry)break;const name=entry.name; - const match=name.match(/^([a-f0-9]{32})\.json$/);if(!match){if(runExpired)boundedRemoveTree(join(bundles,name),admit);else remaining++;continue;} + const bundles = join(dir, 'bundles'); + if (!fs.existsSync(bundles)) return false; + admit(); + const stat = fs.lstatSync(bundles); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new CsoError('UNSAFE_PATH', 'Repair bundle archive is not a private directory'); + let retained = false, + remaining = 0; + admit(); + const directory = fs.opendirSync(bundles); + try { + for (;;) { + admit(); + const entry = directory.readSync(); + if (!entry) break; + const name = entry.name; + const match = name.match(/^([a-f0-9]{32})\.json$/); + if (!match) { + if (runExpired) boundedRemoveTree(join(bundles, name), admit); + else remaining++; + continue; + } + admit(); + const value = readJson(join(bundles, name)) as Record, + id = match[1], + created = Date.parse(value?.createdAt), + expires = Date.parse(value?.expiresAt); + if ( + value?.schemaVersion !== 3 || + value?.id !== id || + value?.runId !== run || + value?.verification?.id !== id || + value?.verification?.runId !== run || + value?.verification?.createdAt !== value.createdAt || + !Number.isFinite(created) || + new Date(created).toISOString() !== value.createdAt || + !Number.isFinite(expires) || + value.expiresAt !== new Date(created + 30 * 86400_000).toISOString() + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle retention identity is invalid'); + if (expires <= now) { + admit(); + fs.unlinkSync(join(bundles, name)); + } else { + retained = true; + remaining++; + } + } + } finally { + directory.closeSync(); + } + if (!remaining) { admit(); - const value=readJson(join(bundles,name)) as Record,id=match[1],created=Date.parse(value?.createdAt),expires=Date.parse(value?.expiresAt); - if(value?.schemaVersion!==3||value?.id!==id||value?.runId!==run||value?.verification?.id!==id||value?.verification?.runId!==run||value?.verification?.createdAt!==value.createdAt|| - !Number.isFinite(created)||new Date(created).toISOString()!==value.createdAt||!Number.isFinite(expires)||value.expiresAt!==new Date(created+30*86400_000).toISOString()) - throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle retention identity is invalid'); - if(expires<=now){admit();fs.unlinkSync(join(bundles,name));}else{retained=true;remaining++;} - }}finally{directory.closeSync();} - if(!remaining){admit();fs.rmdirSync(bundles);} + fs.rmdirSync(bundles); + } return retained; } -function cleanupRun(dir:string,run:string,now:number,pinned:boolean,admit:()=>void):void{ +function cleanupRun(dir: string, run: string, now: number, pinned: boolean, admit: () => void): void { admit(); - let lease:HeldRunLease; - try{lease=acquireRunLease(dir);}catch(error){if(error instanceof CsoError&&error.code==='INSUFFICIENT_CAPACITY')return;throw error;} - let releasePath=lease.path,consumed=false; - try{ - if(hasPendingWatchdogCleanup(dir,admit))return; - const created=Number(run.split('-')[0]),runExpired=now-created>30*86400_000,retainedBundle=repairBundleExpiry(dir,run,now,runExpired,admit); - admit();const ephemeral=fs.existsSync(join(dir,EPHEMERAL_REPLAY)); - if(ephemeral||(runExpired&&!pinned&&!retainedBundle)){ - admit();const before=exactLstat(dir),tomb=join(dirname(dir),`.retired-${run}-${randomBytes(16).toString('hex')}`);fs.renameSync(dir,tomb);releasePath=join(tomb,'.mutation-lock-leases',basename(lease.path));admit();const after=exactLstat(tomb); - if(before.dev!==after.dev||before.ino!==after.ino)throw new CsoError('SNAPSHOT_RACE','Expired run changed while it was retired'); + let lease: HeldRunLease; + try { + lease = acquireRunLease(dir); + } catch (error) { + if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') return; + throw error; + } + let releasePath = lease.path, + consumed = false; + try { + if (hasPendingWatchdogCleanup(dir, admit)) return; + const created = Number(run.split('-')[0]), + runExpired = now - created > 30 * 86400_000, + retainedBundle = repairBundleExpiry(dir, run, now, runExpired, admit); + admit(); + const ephemeral = fs.existsSync(join(dir, EPHEMERAL_REPLAY)); + if (ephemeral || (runExpired && !pinned && !retainedBundle)) { + admit(); + const before = exactLstat(dir), + tomb = join(dirname(dir), `.retired-${run}-${randomBytes(16).toString('hex')}`); + fs.renameSync(dir, tomb); + releasePath = join(tomb, '.mutation-lock-leases', basename(lease.path)); + admit(); + const after = exactLstat(tomb); + if (before.dev !== after.dev || before.ino !== after.ino) + throw new CsoError('SNAPSHOT_RACE', 'Expired run changed while it was retired'); // The retired name is outside the public run namespace. Consume the // exclusive lease with the tree so no release/delete gap can admit a // second helper against the same directory. - consumeLeasedTree(tomb,admit);consumed=true;return; + consumeLeasedTree(tomb, admit); + consumed = true; + return; } - if(now-created>7*86400_000)for(const p of ['snapshot','readable'])boundedRemoveTree(join(dir,p),admit); - if(runExpired)for(const p of ['reviews','replays','dependency-closures','scanner-outcomes','verification-attempts'])boundedRemoveTree(join(dir,p),admit); - }finally{if(!consumed)releaseRunLease(lease,releasePath);} + if (now - created > 7 * 86400_000) + for (const p of ['snapshot', 'readable']) boundedRemoveTree(join(dir, p), admit); + if (runExpired) + for (const p of [ + 'reviews', + 'replays', + 'dependency-closures', + 'scanner-outcomes', + 'verification-attempts', + ]) + boundedRemoveTree(join(dir, p), admit); + } finally { + if (!consumed) releaseRunLease(lease, releasePath); + } } -function cleanupRetiredRun(dir:string,admit:()=>void):void{ +function cleanupRetiredRun(dir: string, admit: () => void): void { admit(); - let lease:HeldRunLease;try{lease=acquireRunLease(dir);}catch(error){if(error instanceof CsoError&&error.code==='INSUFFICIENT_CAPACITY')return;throw error;} - let consumed=false;try{if(hasPendingWatchdogCleanup(dir,admit))return;consumeLeasedTree(dir,admit);consumed=true;}finally{if(!consumed)releaseRunLease(lease);} + let lease: HeldRunLease; + try { + lease = acquireRunLease(dir); + } catch (error) { + if (error instanceof CsoError && error.code === 'INSUFFICIENT_CAPACITY') return; + throw error; + } + let consumed = false; + try { + if (hasPendingWatchdogCleanup(dir, admit)) return; + consumeLeasedTree(dir, admit); + consumed = true; + } finally { + if (!consumed) releaseRunLease(lease); + } +} +export interface RetentionOptions { + deadlineMs?: number; + maxEntries?: number; +} +export interface RetentionResult { + complete: boolean; + visited: number; } -export interface RetentionOptions { deadlineMs?:number; maxEntries?:number } -export interface RetentionResult { complete:boolean; visited:number } class RetentionBudgetExhausted extends Error {} -export function retention(now = Date.now(),options:RetentionOptions={}): RetentionResult { - const deadlineMs=options.deadlineMs??Number.MAX_SAFE_INTEGER,maxEntries=options.maxEntries??Number.MAX_SAFE_INTEGER; - if(!Number.isSafeInteger(deadlineMs)||deadlineMs<0||!Number.isSafeInteger(maxEntries)||maxEntries<1)throw new CsoError('INVALID_ARGUMENT','Invalid retention maintenance budget'); - let visited=0;const admit=()=>{if(Date.now()>=deadlineMs||visited>=maxEntries)throw new RetentionBudgetExhausted();visited++;}; - const names=(dir:string,pattern:RegExp):string[]=>{const found:string[]=[];admit();const directory=fs.opendirSync(dir);try{for(;;){admit();const entry=directory.readSync();if(!entry)break;if(pattern.test(entry.name))found.push(entry.name);}}finally{directory.closeSync();}return found;}; - const root = privateRoot(); - const retainedParents=new Set(),repositories:{repo:string;repoDir:string;runs:string[];retired:string[]}[]=[]; - const retainedReport=(repoDir:string,repo:string,run:string):RunReportV3|undefined=>{ - const file=join(repoDir,run,'report.json'); - try{ - admit(); - const stat=fs.lstatSync(file); - if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.size<=0||stat.size>MAX_STATE_FILE|| - (process.getuid&&stat.uid!==process.getuid())||(process.platform!=='win32'&&(stat.mode&0o077)!==0))return; - admit(); - const report=JSON.parse(fs.readFileSync(file,'utf8')); - if(report?.schemaVersion!==3||report.runId!==run||report.repoId!==repo||!Array.isArray(report.coverage)||!Array.isArray(report.findings)|| - !['running','finished','interrupted'].includes(report.status))return; - return report as RunReportV3; - }catch(error){if(error instanceof RetentionBudgetExhausted)throw error;return;} +export function retention(now = Date.now(), options: RetentionOptions = {}): RetentionResult { + const deadlineMs = options.deadlineMs ?? Number.MAX_SAFE_INTEGER, + maxEntries = options.maxEntries ?? Number.MAX_SAFE_INTEGER; + if ( + !Number.isSafeInteger(deadlineMs) || + deadlineMs < 0 || + !Number.isSafeInteger(maxEntries) || + maxEntries < 1 + ) + throw new CsoError('INVALID_ARGUMENT', 'Invalid retention maintenance budget'); + let visited = 0; + const admit = () => { + if (Date.now() >= deadlineMs || visited >= maxEntries) throw new RetentionBudgetExhausted(); + visited++; }; - try{ - for(const repo of names(root,/^[a-f0-9]{24}$/)){ - admit();const repoDir=secureDirectory(join(root,repo)),entries=names(repoDir,/^(?:\d{13}-[a-f0-9]{16}|\.retired-\d{13}-[a-f0-9]{16}-[a-f0-9]{32})$/),runs=entries.filter(x=>/^\d{13}-/.test(x)),retired=entries.filter(x=>x.startsWith('.retired-')); - repositories.push({repo,repoDir,runs,retired}); + const names = (dir: string, pattern: RegExp): string[] => { + const found: string[] = []; + admit(); + const directory = fs.opendirSync(dir); + try { + for (;;) { + admit(); + const entry = directory.readSync(); + if (!entry) break; + if (pattern.test(entry.name)) found.push(entry.name); + } + } finally { + directory.closeSync(); + } + return found; + }; + const root = privateRoot(); + const retainedParents = new Set(), + repositories: { repo: string; repoDir: string; runs: string[]; retired: string[] }[] = []; + const retainedReport = (repoDir: string, repo: string, run: string): RunReportV3 | undefined => { + const file = join(repoDir, run, 'report.json'); + try { + admit(); + const stat = fs.lstatSync(file); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1 || + stat.size <= 0 || + stat.size > MAX_STATE_FILE || + (process.getuid && stat.uid !== process.getuid()) || + (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) + ) + return; + admit(); + const report = JSON.parse(fs.readFileSync(file, 'utf8')); + if ( + report?.schemaVersion !== 3 || + report.runId !== run || + report.repoId !== repo || + !Array.isArray(report.coverage) || + !Array.isArray(report.findings) || + !['running', 'finished', 'interrupted'].includes(report.status) + ) + return; + return report as RunReportV3; + } catch (error) { + if (error instanceof RetentionBudgetExhausted) throw error; + return; + } + }; + try { + for (const repo of names(root, /^[a-f0-9]{24}$/)) { + admit(); + const repoDir = secureDirectory(join(root, repo)), + entries = names(repoDir, /^(?:\d{13}-[a-f0-9]{16}|\.retired-\d{13}-[a-f0-9]{16}-[a-f0-9]{32})$/), + runs = entries.filter((x) => /^\d{13}-/.test(x)), + retired = entries.filter((x) => x.startsWith('.retired-')); + repositories.push({ repo, repoDir, runs, retired }); } // Discover every live recheck pin before destructive cleanup. An exhausted // discovery pass returns without deleting a parent that may still be in use. - for(const {repo,repoDir,runs} of repositories)for(const run of runs){ - if(now-Number(run.split('-')[0])>30*86400_000)continue; - const report=retainedReport(repoDir,repo,run),parent=report?.parent as Record|undefined; - if(!report||!['running','interrupted'].includes(report.status)||!Number.isFinite(Date.parse(report.deadline))||Date.parse(report.deadline)<=now|| - !parent||typeof parent!=='object'||Array.isArray(parent)||Object.keys(parent).sort().join(',')!=='findingId,kind,runId'||parent.kind!=='recheck'|| - typeof parent.runId!=='string'||!/^\d{13}-[a-f0-9]{16}$/.test(parent.runId)||parent.runId===run||typeof parent.findingId!=='string'||!/^[a-f0-9]{32}$/.test(parent.findingId))continue; - const original=retainedReport(repoDir,repo,parent.runId); - if(original?.status==='finished'&&original.findings.some(f=>f.id===parent.findingId))retainedParents.add(`${repo}/${parent.runId}`); - } - for (const {repo,repoDir,runs,retired} of repositories) { - for(const name of retired)cleanupRetiredRun(join(repoDir,name),admit); + for (const { repo, repoDir, runs } of repositories) for (const run of runs) { - admit();const dir = secureDirectory(join(repoDir,run)); + if (now - Number(run.split('-')[0]) > 30 * 86400_000) continue; + const report = retainedReport(repoDir, repo, run), + parent = report?.parent as Record | undefined; + if ( + !report || + !['running', 'interrupted'].includes(report.status) || + !Number.isFinite(Date.parse(report.deadline)) || + Date.parse(report.deadline) <= now || + !parent || + typeof parent !== 'object' || + Array.isArray(parent) || + Object.keys(parent).sort().join(',') !== 'findingId,kind,runId' || + parent.kind !== 'recheck' || + typeof parent.runId !== 'string' || + !/^\d{13}-[a-f0-9]{16}$/.test(parent.runId) || + parent.runId === run || + typeof parent.findingId !== 'string' || + !/^[a-f0-9]{32}$/.test(parent.findingId) + ) + continue; + const original = retainedReport(repoDir, repo, parent.runId); + if (original?.status === 'finished' && original.findings.some((f) => f.id === parent.findingId)) + retainedParents.add(`${repo}/${parent.runId}`); + } + for (const { repo, repoDir, runs, retired } of repositories) { + for (const name of retired) cleanupRetiredRun(join(repoDir, name), admit); + for (const run of runs) { + admit(); + const dir = secureDirectory(join(repoDir, run)); // Every destructive retention decision owns the same exclusive lease as // writers and replay. Whole runs are atomically retired before release. - cleanupRun(dir,run,now,retainedParents.has(`${repo}/${run}`),admit); + cleanupRun(dir, run, now, retainedParents.has(`${repo}/${run}`), admit); } } - admit();const legacy=join(root,'legacy-imports'); - if(fs.existsSync(legacy)){ - admit();const directory=fs.lstatSync(legacy);if(directory.isSymbolicLink()||!directory.isDirectory())throw new CsoError('UNSAFE_PATH','Legacy report archive is not a private directory'); - for(const name of names(legacy,/^[a-f0-9]{64}\.json$/)){ - admit();const file=join(legacy,name),stat=fs.lstatSync(file);if(stat.isSymbolicLink()||!stat.isFile()||(process.getuid&&stat.uid!==process.getuid()))throw new CsoError('UNSAFE_PATH','Legacy report archive contains an unsafe artifact'); + admit(); + const legacy = join(root, 'legacy-imports'); + if (fs.existsSync(legacy)) { + admit(); + const directory = fs.lstatSync(legacy); + if (directory.isSymbolicLink() || !directory.isDirectory()) + throw new CsoError('UNSAFE_PATH', 'Legacy report archive is not a private directory'); + for (const name of names(legacy, /^[a-f0-9]{64}\.json$/)) { admit(); - if(now-stat.mtimeMs>30*86400_000)fs.unlinkSync(file); + const file = join(legacy, name), + stat = fs.lstatSync(file); + if (stat.isSymbolicLink() || !stat.isFile() || (process.getuid && stat.uid !== process.getuid())) + throw new CsoError('UNSAFE_PATH', 'Legacy report archive contains an unsafe artifact'); + admit(); + if (now - stat.mtimeMs > 30 * 86400_000) fs.unlinkSync(file); } } - return{complete:true,visited}; - }catch(error){ - if(error instanceof RetentionBudgetExhausted)return{complete:false,visited}; + return { complete: true, visited }; + } catch (error) { + if (error instanceof RetentionBudgetExhausted) return { complete: false, visited }; throw error; } } diff --git a/lib/cso/verification.ts b/lib/cso/verification.ts index a4a7107dd..54b8bcfd8 100644 --- a/lib/cso/verification.ts +++ b/lib/cso/verification.ts @@ -3,8 +3,23 @@ import { randomBytes } from 'node:crypto'; import { spawn } from 'node:child_process'; import { basename, dirname, join } from 'node:path'; import { - AssertionWitnessBinding, AssertionWitnessReceipt, CsoError, PreparationProof, RepairBundle, RepairReviewArtifact, SnapshotManifest, VerificationManifest, VerificationObservation, VerificationRequest, - canonical, relativePath, sha256, snapshotPathHandleId, snapshotPathId, validateVerificationObservation, validateVerificationRequest, + AssertionWitnessBinding, + AssertionWitnessReceipt, + CsoError, + PreparationProof, + RepairBundle, + RepairReviewArtifact, + SnapshotManifest, + VerificationManifest, + VerificationObservation, + VerificationRequest, + canonical, + relativePath, + sha256, + snapshotPathHandleId, + snapshotPathId, + validateVerificationObservation, + validateVerificationRequest, } from './contracts'; import { QualifiedRuntime } from './runtime-catalog'; import { inspectPreparation, type CsoStack } from './preparation'; @@ -13,342 +28,2143 @@ import { DockerEndpoint, DockerGroup } from './docker'; import type { PreparedDatabaseContract } from './preparation-executor'; import { redact, sanitizeHelperForJson } from './process'; import { secureDirectory, writeJsonExclusive } from './state'; -import { AssertionWitnessHandle, AssertionWitnessSession, WitnessedVerificationResult, assertionWitnessPairHash, testExecutionPassed, validateStoredAssertionWitnessReceipt, witnessObservationHash } from './witness'; +import { + AssertionWitnessHandle, + AssertionWitnessSession, + WitnessedVerificationResult, + assertionWitnessPairHash, + testExecutionPassed, + validateStoredAssertionWitnessReceipt, + witnessObservationHash, +} from './witness'; export { testExecutionPassed } from './witness'; export interface VerificationExecutor { - observe(source:string,phase:'before'|'after',request:VerificationRequest,runtime:QualifiedRuntime,verifier:QualifiedRuntime,work:string,control:string,execution?:{environment:Record;database?:PreparedDatabaseContract},testEvidence?:{minimumPassingTests:number[]},witness?:AssertionWitnessHandle):Promise; + observe( + source: string, + phase: 'before' | 'after', + request: VerificationRequest, + runtime: QualifiedRuntime, + verifier: QualifiedRuntime, + work: string, + control: string, + execution?: { environment: Record; database?: PreparedDatabaseContract }, + testEvidence?: { minimumPassingTests: number[] }, + witness?: AssertionWitnessHandle, + ): Promise; } export interface FailedVerificationAttempt { - schemaVersion:3; artifactKind:'repair_candidate'; id:string; runId:string; findingId:string; createdAt:string; bundleIssued:false; - runtime:{image:string;platform:string;profile:string}; policyHash:string; requestHash:string; harnessHash:string; sourceHash:string; - request:VerificationRequest; patchHash:string; testToolchain:'runtime'|'project'; testCompletionAssurance:'self_reported'; preparationHash?:string; - before:VerificationObservation; after?:VerificationObservation; reproduction:'blocked'|'inconclusive'|'disproved'|'reproduced'; repair:'failed'|'proposed'; - failure:{code:string;message:string}; + schemaVersion: 3; + artifactKind: 'repair_candidate'; + id: string; + runId: string; + findingId: string; + createdAt: string; + bundleIssued: false; + runtime: { image: string; platform: string; profile: string }; + policyHash: string; + requestHash: string; + harnessHash: string; + sourceHash: string; + request: VerificationRequest; + patchHash: string; + testToolchain: 'runtime' | 'project'; + testCompletionAssurance: 'self_reported'; + preparationHash?: string; + before: VerificationObservation; + after?: VerificationObservation; + reproduction: 'blocked' | 'inconclusive' | 'disproved' | 'reproduced'; + repair: 'failed' | 'proposed'; + failure: { code: string; message: string }; } export class VerificationAttemptError extends CsoError { - constructor(public causeError:CsoError,public attempt:FailedVerificationAttempt){super(causeError.code,`${causeError.message}; before-phase evidence retained as attempt ${attempt.id}`);this.name='VerificationAttemptError';} + constructor( + public causeError: CsoError, + public attempt: FailedVerificationAttempt, + ) { + super(causeError.code, `${causeError.message}; before-phase evidence retained as attempt ${attempt.id}`); + this.name = 'VerificationAttemptError'; + } } -async function attemptGuard(runDir:string,work:string,watchdog:string,deadline:number):Promise<()=>Promise>{ - const control=secureDirectory(join(runDir,'supervision',basename(work)));secureDirectory(work);const ready=join(control,'attempt.ready'),terminal=join(control,'attempt.terminal'),stopped=join(control,'attempt.stopped'); - const child=spawn(watchdog,['--attempt-owner',String(process.pid),'--deadline',String(Math.ceil(deadline/1000)),'--control-dir',control,'--work-root',work,'--run-root',runDir],{cwd:control,env:{PATH:'/usr/bin:/bin'},detached:true,stdio:'ignore'});let failed=false;child.once('error',()=>{failed=true;});child.unref();for(let i=0;i<100&&!failed&&!fs.existsSync(ready);i++)await new Promise(resolve=>setTimeout(resolve,10));let alive=false;try{if(child.pid){process.kill(child.pid,0);alive=true;}}catch{}if(failed||!alive||!fs.existsSync(ready)){try{if(child.pid)process.kill(child.pid,'SIGKILL');}catch{}fs.rmSync(work,{recursive:true,force:true});throw new CsoError('ISOLATION_FAILED','Attempt execution-copy watchdog failed its startup handshake');} - return async()=>{fs.rmSync(work,{recursive:true,force:true});fs.writeFileSync(terminal,'normal cleanup complete\n',{mode:0o600,flag:'wx'});for(let i=0;i<100&&!fs.existsSync(stopped);i++)await new Promise(resolve=>setTimeout(resolve,10));if(!fs.existsSync(stopped))throw new CsoError('ISOLATION_FAILED','Attempt watchdog did not acknowledge execution-copy cleanup');}; +async function attemptGuard( + runDir: string, + work: string, + watchdog: string, + deadline: number, +): Promise<() => Promise> { + const control = secureDirectory(join(runDir, 'supervision', basename(work))); + secureDirectory(work); + const ready = join(control, 'attempt.ready'), + terminal = join(control, 'attempt.terminal'), + stopped = join(control, 'attempt.stopped'); + const child = spawn( + watchdog, + [ + '--attempt-owner', + String(process.pid), + '--deadline', + String(Math.ceil(deadline / 1000)), + '--control-dir', + control, + '--work-root', + work, + '--run-root', + runDir, + ], + { cwd: control, env: { PATH: '/usr/bin:/bin' }, detached: true, stdio: 'ignore' }, + ); + let failed = false; + child.once('error', () => { + failed = true; + }); + child.unref(); + for (let i = 0; i < 100 && !failed && !fs.existsSync(ready); i++) + await new Promise((resolve) => setTimeout(resolve, 10)); + let alive = false; + try { + if (child.pid) { + process.kill(child.pid, 0); + alive = true; + } + } catch {} + if (failed || !alive || !fs.existsSync(ready)) { + try { + if (child.pid) process.kill(child.pid, 'SIGKILL'); + } catch {} + fs.rmSync(work, { recursive: true, force: true }); + throw new CsoError('ISOLATION_FAILED', 'Attempt execution-copy watchdog failed its startup handshake'); + } + return async () => { + fs.rmSync(work, { recursive: true, force: true }); + fs.writeFileSync(terminal, 'normal cleanup complete\n', { mode: 0o600, flag: 'wx' }); + for (let i = 0; i < 100 && !fs.existsSync(stopped); i++) + await new Promise((resolve) => setTimeout(resolve, 10)); + if (!fs.existsSync(stopped)) + throw new CsoError('ISOLATION_FAILED', 'Attempt watchdog did not acknowledge execution-copy cleanup'); + }; } -function allFiles(root:string,at=root,ignore:((path:string)=>boolean)=()=>false):string[]{ - const out:string[]=[];for(const entry of fs.readdirSync(at,{withFileTypes:true})){ - const p=join(at,entry.name),relative=p.slice(root.length+1).replaceAll('\\','/');if(ignore(relative))continue;if(entry.isSymbolicLink()||(!entry.isDirectory()&&!entry.isFile()))throw new CsoError('UNSAFE_PATH','Execution copy contains a special file'); - if(entry.isDirectory())out.push(...allFiles(root,p,ignore));else out.push(relative); - }return out.sort(); +function allFiles(root: string, at = root, ignore: (path: string) => boolean = () => false): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(at, { withFileTypes: true })) { + const p = join(at, entry.name), + relative = p.slice(root.length + 1).replaceAll('\\', '/'); + if (ignore(relative)) continue; + if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) + throw new CsoError('UNSAFE_PATH', 'Execution copy contains a special file'); + if (entry.isDirectory()) out.push(...allFiles(root, p, ignore)); + else out.push(relative); + } + return out.sort(); } -export function treeHash(root:string,predicate:((path:string)=>boolean)=()=>true):string{ - return sha256(canonical(allFiles(root).filter(predicate).map(path=>{const file=containedFile(root,path),before=fs.lstatSync(file);if(!before.isFile()||before.isSymbolicLink()||before.nlink!==1)throw new CsoError('UNSAFE_PATH','Execution copy contains a special or hard-linked file');const body=fs.readFileSync(file),after=fs.lstatSync(file);if(before.ino!==after.ino||before.dev!==after.dev||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)throw new CsoError('SNAPSHOT_RACE',`Execution copy changed while hashing: ${path}`);return[path,sha256(body),before.mode&0o777];}))); +export function treeHash(root: string, predicate: (path: string) => boolean = () => true): string { + return sha256( + canonical( + allFiles(root) + .filter(predicate) + .map((path) => { + const file = containedFile(root, path), + before = fs.lstatSync(file); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) + throw new CsoError('UNSAFE_PATH', 'Execution copy contains a special or hard-linked file'); + const body = fs.readFileSync(file), + after = fs.lstatSync(file); + if ( + before.ino !== after.ino || + before.dev !== after.dev || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs + ) + throw new CsoError('SNAPSHOT_RACE', `Execution copy changed while hashing: ${path}`); + return [path, sha256(body), before.mode & 0o777]; + }), + ), + ); } -const DEPENDENCY=/(?:^|\/)(?:package(?:-lock)?\.json|npm-shrinkwrap\.json|bun\.lock|uv\.lock|requirements[^/]*\.txt|pyproject\.toml|setup\.(?:py|cfg)|Gemfile(?:\.lock)?|[^/]+\.gemspec)$/; -const CONFIG=/(?:^|\/)(?:config\/.+|\.env|Dockerfile|Procfile|.*\.(?:toml|ya?ml|json))$/; -export function fileEffect(path:string):'source'|'configuration'|'dependency'{return DEPENDENCY.test(path)?'dependency':CONFIG.test(path)?'configuration':'source';} -export function patchHash(request:Pick):string{return sha256(canonical(request.changes));} -export function resolveVerificationRequestPaths(manifest:SnapshotManifest,request:VerificationRequest):VerificationRequest{ - const resolve=(reference:string):string=>{const id=snapshotPathHandleId(reference);if(!id)return relativePath(reference);const entry=manifest.entries.find(item=>item.pathId===id);if(entry)return entry.path;const deleted=manifest.deletedPaths?.find(item=>item.pathId===id);if(deleted)return deleted.path;const changed=manifest.changedPaths?.find(path=>snapshotPathId(manifest.root,path)===id);if(changed)return changed;throw new CsoError('INVALID_SCHEMA',`Verification path handle is outside the retained snapshot: ${reference}`);}; - const argument=(value:string):string=>{const direct=snapshotPathHandleId(value);if(direct)return resolve(value);if(value.startsWith('./')&&snapshotPathHandleId(value.slice(2)))return `./${resolve(value.slice(2))}`;return value;}; - const command=(value:VerificationRequest['start']):VerificationRequest['start']=>({...value,args:value.args.map(argument)}); - return{...request,start:command(request.start),existingTests:request.existingTests.map(command),boundaryFiles:request.boundaryFiles.map(resolve),testFiles:request.testFiles.map(resolve),changes:request.changes.map(change=>({...change,path:resolve(change.path)}))}; +const DEPENDENCY = + /(?:^|\/)(?:package(?:-lock)?\.json|npm-shrinkwrap\.json|bun\.lock|uv\.lock|requirements[^/]*\.txt|pyproject\.toml|setup\.(?:py|cfg)|Gemfile(?:\.lock)?|[^/]+\.gemspec)$/; +const CONFIG = /(?:^|\/)(?:config\/.+|\.env|Dockerfile|Procfile|.*\.(?:toml|ya?ml|json))$/; +export function fileEffect(path: string): 'source' | 'configuration' | 'dependency' { + return DEPENDENCY.test(path) ? 'dependency' : CONFIG.test(path) ? 'configuration' : 'source'; } -export function reviewRequestHash(request:VerificationRequest):string{const {artifactId,...review}=request.review;return sha256(canonical({...request,review}));} -export function reviewArtifactIdentity(artifact:RepairReviewArtifact):string{const {id,...bound}=artifact;return sha256(canonical(bound)).slice(0,32);} -export function makeReviewArtifact(runId:string,request:VerificationRequest,producer:string):RepairReviewArtifact{ - if(!producer||producer.length>200||producer===request.review.reviewer)throw new CsoError('INVALID_SCHEMA','Repair producer and independent reviewer identities must be distinct'); - if(!request.review.independent)throw new CsoError('INVALID_SCHEMA','Repair review must be explicitly independent'); - const artifact:RepairReviewArtifact={schemaVersion:3,id:'',runId,findingId:request.findingId,createdAt:new Date().toISOString(),producer,reviewer:request.review.reviewer,assurance:'self_attested',requestHash:reviewRequestHash(request),patchHash:patchHash(request),rootCauseRepaired:request.review.rootCauseRepaired,featurePreserved:request.review.featurePreserved,boundaryMocks:request.review.boundaryMocks,rationale:request.review.rationale};artifact.id=reviewArtifactIdentity(artifact);return artifact; +export function patchHash(request: Pick): string { + return sha256(canonical(request.changes)); } -export function validateReviewArtifact(value:unknown,runId:string,request:VerificationRequest):RepairReviewArtifact{ - const artifact=value as RepairReviewArtifact;if(!artifact||artifact.schemaVersion!==3||artifact.assurance!=='self_attested'||artifact.runId!==runId||artifact.findingId!==request.findingId||artifact.id!==request.review.artifactId||reviewArtifactIdentity(artifact)!==artifact.id||artifact.requestHash!==reviewRequestHash(request)||artifact.patchHash!==patchHash(request)||artifact.reviewer!==request.review.reviewer||artifact.producer===artifact.reviewer||artifact.rootCauseRepaired!==request.review.rootCauseRepaired||artifact.featurePreserved!==request.review.featurePreserved||artifact.boundaryMocks!==request.review.boundaryMocks||artifact.rationale!==request.review.rationale)throw new CsoError('INCOMPATIBLE_INPUT','Self-attested repair-review artifact does not bind this request');return artifact; +export function resolveVerificationRequestPaths( + manifest: SnapshotManifest, + request: VerificationRequest, +): VerificationRequest { + const resolve = (reference: string): string => { + const id = snapshotPathHandleId(reference); + if (!id) return relativePath(reference); + const entry = manifest.entries.find((item) => item.pathId === id); + if (entry) return entry.path; + const deleted = manifest.deletedPaths?.find((item) => item.pathId === id); + if (deleted) return deleted.path; + const changed = manifest.changedPaths?.find((path) => snapshotPathId(manifest.root, path) === id); + if (changed) return changed; + throw new CsoError( + 'INVALID_SCHEMA', + `Verification path handle is outside the retained snapshot: ${reference}`, + ); + }; + const argument = (value: string): string => { + const direct = snapshotPathHandleId(value); + if (direct) return resolve(value); + if (value.startsWith('./') && snapshotPathHandleId(value.slice(2))) return `./${resolve(value.slice(2))}`; + return value; + }; + const command = (value: VerificationRequest['start']): VerificationRequest['start'] => ({ + ...value, + args: value.args.map(argument), + }); + return { + ...request, + start: command(request.start), + existingTests: request.existingTests.map(command), + boundaryFiles: request.boundaryFiles.map(resolve), + testFiles: request.testFiles.map(resolve), + changes: request.changes.map((change) => ({ ...change, path: resolve(change.path) })), + }; } -export function verificationIdentity(manifest:VerificationManifest):string{const {id,...bound}=manifest;return sha256(canonical(bound)).slice(0,32);} -export function verificationHarnessHash(request:VerificationRequest,sourceRoot:string):string{ - const testInputs=request.testFiles.map(path=>{const file=containedFile(sourceRoot,path);if(!fs.existsSync(file))throw new CsoError('INCOMPATIBLE_INPUT',`Immutable existing-test input is missing: ${path}`);const stat=fs.lstatSync(file);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)throw new CsoError('UNSAFE_PATH',`Immutable existing-test input is unsafe: ${path}`);return[path,sha256(fs.readFileSync(file)),stat.mode&0o777];}); - return sha256(canonical({port:request.port,start:request.start,legitimate:request.legitimate,security:request.security,existingTests:request.existingTests,testInputs})); +export function reviewRequestHash(request: VerificationRequest): string { + const { artifactId, ...review } = request.review; + return sha256(canonical({ ...request, review })); } -export interface CanonicalTestPlan { commands:VerificationRequest['existingTests'];files:string[];kind:string;toolchain:'runtime'|'project';minimumPassingTests:number[];signature:string } -export interface CanonicalStartPlan { command:VerificationRequest['start'];kind:string;signature:string;entrypointFiles:string[] } -const TEST_TREE=/(?:^|\/)(?:test|tests|__tests__|spec|fixtures|__fixtures__|testdata)(?:\/|$)/; -const NODE_TEST_CONFIG=/(?:^|\/)(?:(?:jest|vitest|vite|playwright|cypress|karma|babel|ava|webpack)\.(?:config|conf)\.[^/]+|(?:jest|vitest|playwright|cypress|babel|ava|webpack)\.config\.[^/]+|\.mocharc(?:\.[^/]+)?|\.babelrc(?:\.[^/]+)?|tsconfig(?:\.[^/]+)?\.json|bunfig\.toml)$/; -const BUN_RUNTIME_POLICY_ARGS=['--no-install','--config=/opt/cso/no-auto-install.toml']; +export function reviewArtifactIdentity(artifact: RepairReviewArtifact): string { + const { id, ...bound } = artifact; + return sha256(canonical(bound)).slice(0, 32); +} +export function makeReviewArtifact( + runId: string, + request: VerificationRequest, + producer: string, +): RepairReviewArtifact { + if (!producer || producer.length > 200 || producer === request.review.reviewer) + throw new CsoError( + 'INVALID_SCHEMA', + 'Repair producer and independent reviewer identities must be distinct', + ); + if (!request.review.independent) + throw new CsoError('INVALID_SCHEMA', 'Repair review must be explicitly independent'); + const artifact: RepairReviewArtifact = { + schemaVersion: 3, + id: '', + runId, + findingId: request.findingId, + createdAt: new Date().toISOString(), + producer, + reviewer: request.review.reviewer, + assurance: 'self_attested', + requestHash: reviewRequestHash(request), + patchHash: patchHash(request), + rootCauseRepaired: request.review.rootCauseRepaired, + featurePreserved: request.review.featurePreserved, + boundaryMocks: request.review.boundaryMocks, + rationale: request.review.rationale, + }; + artifact.id = reviewArtifactIdentity(artifact); + return artifact; +} +export function validateReviewArtifact( + value: unknown, + runId: string, + request: VerificationRequest, +): RepairReviewArtifact { + const artifact = value as RepairReviewArtifact; + if ( + !artifact || + artifact.schemaVersion !== 3 || + artifact.assurance !== 'self_attested' || + artifact.runId !== runId || + artifact.findingId !== request.findingId || + artifact.id !== request.review.artifactId || + reviewArtifactIdentity(artifact) !== artifact.id || + artifact.requestHash !== reviewRequestHash(request) || + artifact.patchHash !== patchHash(request) || + artifact.reviewer !== request.review.reviewer || + artifact.producer === artifact.reviewer || + artifact.rootCauseRepaired !== request.review.rootCauseRepaired || + artifact.featurePreserved !== request.review.featurePreserved || + artifact.boundaryMocks !== request.review.boundaryMocks || + artifact.rationale !== request.review.rationale + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Self-attested repair-review artifact does not bind this request', + ); + return artifact; +} +export function verificationIdentity(manifest: VerificationManifest): string { + const { id, ...bound } = manifest; + return sha256(canonical(bound)).slice(0, 32); +} +export function verificationHarnessHash(request: VerificationRequest, sourceRoot: string): string { + const testInputs = request.testFiles.map((path) => { + const file = containedFile(sourceRoot, path); + if (!fs.existsSync(file)) + throw new CsoError('INCOMPATIBLE_INPUT', `Immutable existing-test input is missing: ${path}`); + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) + throw new CsoError('UNSAFE_PATH', `Immutable existing-test input is unsafe: ${path}`); + return [path, sha256(fs.readFileSync(file)), stat.mode & 0o777]; + }); + return sha256( + canonical({ + port: request.port, + start: request.start, + legitimate: request.legitimate, + security: request.security, + existingTests: request.existingTests, + testInputs, + }), + ); +} +export interface CanonicalTestPlan { + commands: VerificationRequest['existingTests']; + files: string[]; + kind: string; + toolchain: 'runtime' | 'project'; + minimumPassingTests: number[]; + signature: string; +} +export interface CanonicalStartPlan { + command: VerificationRequest['start']; + kind: string; + signature: string; + entrypointFiles: string[]; +} +const TEST_TREE = /(?:^|\/)(?:test|tests|__tests__|spec|fixtures|__fixtures__|testdata)(?:\/|$)/; +const NODE_TEST_CONFIG = + /(?:^|\/)(?:(?:jest|vitest|vite|playwright|cypress|karma|babel|ava|webpack)\.(?:config|conf)\.[^/]+|(?:jest|vitest|playwright|cypress|babel|ava|webpack)\.config\.[^/]+|\.mocharc(?:\.[^/]+)?|\.babelrc(?:\.[^/]+)?|tsconfig(?:\.[^/]+)?\.json|bunfig\.toml)$/; +const BUN_RUNTIME_POLICY_ARGS = ['--no-install', '--config=/opt/cso/no-auto-install.toml']; // -S prevents dependency-provided .pth startup code from running before the // trusted bootstrap. Add the venv's fixed Linux purelib directory directly, // without processing .pth files, and import each runner before application cwd. -const PYTHON_PURELIB="os.path.join(os.path.dirname(os.path.dirname(sys.executable)),'lib',f'python{sys.version_info.major}.{sys.version_info.minor}','site-packages')"; -const PYTEST_BOOTSTRAP=`import os,sys;sys.path.append(${PYTHON_PURELIB});import pytest;sys.path.insert(0,os.getcwd());raise SystemExit(pytest.main(sys.argv[1:]))`; -const UNITTEST_BOOTSTRAP=`import os,sys,unittest;sys.path.append(${PYTHON_PURELIB});sys.path.insert(0,os.getcwd());unittest.main(module=None,argv=['unittest',*sys.argv[1:]])`; -const DJANGO_BOOTSTRAP=`import os,sys,runpy;sys.path.append(${PYTHON_PURELIB});import django;sys.path.insert(0,os.getcwd());sys.argv=['manage.py',*sys.argv[1:]];runpy.run_path('manage.py',run_name='__main__')`; -const FLASK_BOOTSTRAP=`import os,sys;sys.path.append(${PYTHON_PURELIB});from flask.cli import main as _cso_main;sys.path.insert(0,os.getcwd());sys.argv=['flask',*sys.argv[1:]];_cso_main()`; -const UVICORN_BOOTSTRAP=`import os,sys;sys.path.append(${PYTHON_PURELIB});from uvicorn.main import main as _cso_main;sys.path.insert(0,os.getcwd());sys.argv=['uvicorn',*sys.argv[1:]];_cso_main()`; -function fileText(root:string,path:string):string{try{return fs.readFileSync(containedFile(root,path),'utf8');}catch{throw new CsoError('MISSING_INPUT',`Canonical test input is missing or unreadable: ${path}`);}} -function packageTestProjection(root:string,paths:string[]):unknown[]{return paths.filter(path=>/(?:^|\/)package\.json$/.test(path)&&!TEST_TREE.test(path)).map(path=>{let value:Record;try{value=JSON.parse(fileText(root,path));}catch{throw new CsoError('MISSING_INPUT',`Canonical package test configuration is invalid: ${path}`);}if(!value||typeof value!=='object'||Array.isArray(value))throw new CsoError('MISSING_INPUT',`Canonical package test configuration is invalid: ${path}`);return{path,type:value.type??null,workspaces:value.workspaces??null,scripts:value.scripts??null,jest:value.jest??null,vitest:value.vitest??null,mocha:value.mocha??null,ava:value.ava??null,nyc:value.nyc??null};});} -function withoutJsCommentsAndStrings(value:string):string{return value.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g,match=>' '.repeat(match.length));} -function assertJavascriptTestRegistrations(root:string,tests:string[]):number{ - const code=tests.map(path=>withoutJsCommentsAndStrings(fileText(root,path))).join('\n'); - if(/\b(?:fdescribe|fit)\s*\(|\b(?:describe|test|it)\s*\.\s*(?:only|concurrent\s*\.\s*only)\b/.test(code))throw new CsoError('MISSING_INPUT','Canonical JavaScript tests cannot certify a focused-only suite'); - const registrations=[...code.matchAll(/\b(?:test|it)\s*(?:\.\s*(?:concurrent|each)\s*(?:\([^)]*\))?)?\s*\(/g)].length;if(!registrations)throw new CsoError('MISSING_INPUT','Canonical JavaScript tests need static evidence of at least one non-skipped test or it registration');return registrations; -} -function boundedTestPaths(tests:string[]):string[]{ - if(!tests.length)throw new CsoError('MISSING_INPUT','No canonical project test sources were found'); - if(tests.length>1000)throw new CsoError('MISSING_INPUT','Canonical project test suite exceeds the 1,000-file verification limit'); - const paths=tests.map(path=>`./${path}`);if(paths.reduce((bytes,path)=>bytes+Buffer.byteLength(path)+1,0)>128*1024)throw new CsoError('MISSING_INPUT','Canonical project test paths exceed the bounded direct-runner argument limit');return paths; -} -function directPackageTest(root:string,stack:'node'|'bun',manifest:Record,tests:string[],configuration:string[]):{command:VerificationRequest['existingTests'][number];kind:string;runner:string;toolchain:'runtime'|'project';minimumPassingTests:number}{ - const scripts=manifest.scripts;if(!scripts||typeof scripts!=='object'||Array.isArray(scripts))throw new CsoError('MISSING_INPUT',`Canonical ${stack} package test scripts are missing or invalid`); - const script=scripts.test;if(typeof script!=='string'||!script.trim()||script.length>4096||/no test specified|^\s*(?:true|:|exit\s+0)\s*$/i.test(script))throw new CsoError('MISSING_INPUT',`Canonical ${stack} test script is missing or a placeholder`); - for(const hook of ['pretest','posttest']){ - const value=scripts[hook];if(value!==undefined&&typeof value!=='string')throw new CsoError('MISSING_INPUT',`Canonical ${stack} ${hook} lifecycle hook is invalid`); - if(typeof value==='string'&&value.trim())throw new CsoError('MISSING_INPUT',`Canonical ${stack} tests cannot certify through package lifecycle hooks; remove ${hook} or run the direct standard runner`); - } - if(/[;&|><`$()\\\r\n]/.test(script))throw new CsoError('MISSING_INPUT',`Canonical ${stack} tests require one recognized direct standard runner; local wrappers and shell composition are not admitted`); - const words=script.trim().split(/\s+/),standard=['jest','vitest','mocha','ava'],paths=boundedTestPaths(tests),minimumPassingTests=assertJavascriptTestRegistrations(root,tests); - if(canonical(words)===canonical(['node','--test'])){ - return{command:{executable:'/usr/local/bin/node',args:['--test','--test-reporter=tap',...paths]},kind:'direct node --test with TAP count evidence',runner:'node',toolchain:'runtime',minimumPassingTests}; - } - if(canonical(words)===canonical(['bun','test'])){ - if(stack!=='bun')throw new CsoError('MISSING_INPUT','Canonical Node verification cannot depend on the Bun test runtime'); - return{command:{executable:'/usr/local/bin/bun',args:[...BUN_RUNTIME_POLICY_ARGS,'test',...paths]},kind:'direct bun test with automatic installation disabled',runner:'bun',toolchain:'runtime',minimumPassingTests}; - } - const runner=words.length===1&&standard.includes(words[0])?words[0]:words.length===3&&words[0]==='npx'&&words[1]==='--no-install'&&standard.includes(words[2])?words[2]:undefined; - if(!runner)throw new CsoError('MISSING_INPUT',`Canonical ${stack} tests require one recognized direct standard runner; local wrappers and shell composition are not admitted`); - const control=canonical({embedded:manifest[runner]??null,files:configuration.filter(path=>NODE_TEST_CONFIG.test(path)).map(path=>[path,fileText(root,path)])}); - if(/(?:collectOnly|dryRun|passWithNoTests|testNamePattern|\b(?:grep|fgrep|match)\b|--(?:collect-only|dry-run|grep|fgrep|match|passWithNoTests))/i.test(control))throw new CsoError('MISSING_INPUT',`Canonical ${runner} configuration cannot focus, skip execution, or allow an empty suite`); - const args=runner==='jest'?['--runTestsByPath','--passWithNoTests=false','--json',...paths]:runner==='vitest'?['run','--passWithNoTests=false','--reporter=verbose',...paths]:runner==='mocha'?['--fail-zero','--no-dry-run','--forbid-only','--reporter','json',...paths]:['--tap',...paths]; - return{command:{executable:`/work/node_modules/.bin/${runner}`,args},kind:`direct local ${runner}`,runner,toolchain:'project',minimumPassingTests}; -} -function directPackageStartEntrypoint(root:string,stack:'node'|'bun',script:string):string{ - if(/[;&|><`$()\\\r\n]/.test(script))throw new CsoError('MISSING_INPUT',`Canonical ${stack} startup cannot use shell composition`); - const words=script.trim().split(/\s+/),runner=words.shift(); - if(runner!==stack||words.length!==1||!/^[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s|jsx|tsx)$/.test(words[0])||words[0].startsWith('/')||words[0].split('/').includes('..'))throw new CsoError('MISSING_INPUT',`Canonical ${stack} package startup requires one direct contained ${stack} entrypoint`); - const path=relativePath(words[0]);if(!executableSource(root,path))throw new CsoError('MISSING_INPUT',`Canonical ${stack} package startup entrypoint is missing or unsafe: ${path}`);return path; -} -function pyprojectTestProjection(root:string,path:string):unknown{try{const value=Bun.TOML.parse(fileText(root,path)) as Record;return{tool:{pytest:value?.tool?.pytest??null,coverage:value?.tool?.coverage??null},projectScripts:value?.project?.scripts??null};}catch{throw new CsoError('MISSING_INPUT','Canonical Python test configuration is invalid: pyproject.toml');}} -export function canonicalTestPlan(sourceRoot:string,stack:CsoStack):CanonicalTestPlan{ - const generated=(path:string)=>{const first=path.split('/')[0];return stack==='node'?first==='node_modules'||first==='.cso-npm-cache':stack==='bun'?first==='node_modules'||first==='.cso-bun-cache':stack==='python'?first==='.venv'||first==='.cso-uv-cache'||path==='.gstack-cso-public-requirements.txt':path.startsWith('vendor/bundle/')||first==='.cso-bundle'||first==='.cso-gems';}; - const all=allFiles(sourceRoot,sourceRoot,generated);let tests:string[]=[],configuration:string[]=[],commands:VerificationRequest['existingTests']=[],kind='',toolchain:'runtime'|'project'='runtime',minimumPassingTests:number[]=[],runnerEvidence:unknown={}; - if(stack==='node'||stack==='bun'){ - let manifest:Record;try{manifest=JSON.parse(fileText(sourceRoot,'package.json'));}catch{throw new CsoError('MISSING_INPUT',`Canonical ${stack} package.json is missing or invalid`);} - tests=all.filter(path=>/(?:^|\/)(?:test|tests|__tests__)\/.*\.(?:[cm]?js|tsx?|jsx)$|\.(?:test|spec)\.(?:[cm]?js|tsx?|jsx)$/.test(path));configuration=all.filter(path=>TEST_TREE.test(path)||NODE_TEST_CONFIG.test(path));const direct=directPackageTest(sourceRoot,stack,manifest,tests,configuration);commands=[direct.command];kind=direct.kind;toolchain=direct.toolchain;minimumPassingTests=[direct.minimumPassingTests];runnerEvidence={runner:direct.runner,declaredScript:manifest.scripts.test,packages:packageTestProjection(sourceRoot,all),minimumPassingTests:direct.minimumPassingTests}; - }else if(stack==='python'){ - tests=all.filter(path=>/(?:^|\/)(?:test|tests)\/.*\.py$|(?:^|\/)test_[^/]+\.py$|_test\.py$/.test(path));configuration=all.filter(path=>TEST_TREE.test(path)||/(?:^|\/)(?:conftest\.py|\.?pytest\.ini|\.?pytest\.toml|setup\.cfg|tox\.ini|noxfile\.py)$/.test(path));const bodies=tests.map(path=>fileText(sourceRoot,path)),configBodies=configuration.map(path=>fileText(sourceRoot,path)),pyproject=all.includes('pyproject.toml')?pyprojectTestProjection(sourceRoot,'pyproject.toml'):null;const pytestEvidence=all.some(path=>/(?:^|\/)(?:conftest\.py|\.?pytest\.ini|\.?pytest\.toml)$/.test(path))||[...bodies,...configBodies].some(body=>/(?:^|\n)\s*(?:import pytest|from pytest\b|@pytest\.)/m.test(body)||/(?:^|\n)(?:async\s+)?def test_[A-Za-z0-9_]*\s*\(/m.test(body));const unittestEvidence=bodies.some(body=>/(?:^|\n)\s*(?:import unittest|from unittest\b)|unittest\.TestCase|TestCase\s*\)/m.test(body));if(!pytestEvidence&&!unittestEvidence)throw new CsoError('MISSING_INPUT','Python test runner is ambiguous; declare pytest evidence or a unittest suite');const usePytest=pytestEvidence,paths=boundedTestPaths(tests),pytestControl=[...configBodies,canonical(pyproject)].join('\n');if(usePytest&&/(?:--collect-only|\s--co\b|--setup-(?:only|plan)|--fixtures(?:-per-test)?|--no-summary|\baddopts[^\n]*(?:\s-k\b|\s-m\b|--ignore\b|--deselect\b|(?:^|\s)-q{2,}\b))/i.test(pytestControl))throw new CsoError('MISSING_INPUT','Canonical pytest configuration cannot collect only, focus, deselect, suppress its count, or skip test execution');commands=[{executable:'/work/.venv/bin/python',args:usePytest?['-I','-S','-c',PYTEST_BOOTSTRAP,'-q','--color=no','--',...paths]:['-I','-S','-c',UNITTEST_BOOTSTRAP,...paths]}];kind=usePytest?'isolated prepared pytest with explicit files, positive summary, and no .pth startup':'isolated standard-library unittest with explicit files and no .pth startup';toolchain=usePytest?'project':'runtime';minimumPassingTests=[1];runnerEvidence={runner:usePytest?'pytest':'unittest',bootstrap:usePytest?PYTEST_BOOTSTRAP:UNITTEST_BOOTSTRAP,siteInitialization:false,reporter:usePytest?'quiet-positive-summary-no-color':'unittest-summary',pyproject}; - }else{ - const specs=all.filter(path=>/(?:^|\/)spec\/.*_spec\.rb$/.test(path)),rails=all.filter(path=>/(?:^|\/)test\/.*_test\.rb$/.test(path));tests=[...specs,...rails];configuration=all.filter(path=>TEST_TREE.test(path)||/(?:^|\/)\.rspec(?:-local)?$/.test(path));const rspecControl=configuration.filter(path=>/(?:^|\/)\.rspec(?:-local)?$/.test(path)).map(path=>fileText(sourceRoot,path)).join('\n');if(/--(?:dry-run|tag|example|pattern|exclude-pattern|only-failures|next-failure)\b/.test(rspecControl))throw new CsoError('MISSING_INPUT','Canonical RSpec configuration cannot dry-run, focus, filter, or select only prior failures');commands=[...(specs.length?[{executable:'/usr/local/bin/bundle',args:['exec','rspec','--format','json','--',...boundedTestPaths(specs)]}]:[]),...(rails.length?[{executable:'/usr/local/bin/bundle',args:['exec','rails','test','--no-color',...boundedTestPaths(rails)]}]:[])];kind=commands.map(command=>command.args.join(' ')).join(' + ');toolchain='project';minimumPassingTests=commands.map(()=>1);runnerEvidence={rspec:specs.length>0,minitest:rails.length>0,reporters:specs.length?['rspec-json',...(rails.length?['rails-summary-no-color']:[])]:['rails-summary-no-color']}; - } - tests=[...new Set(tests)].sort();configuration=[...new Set(configuration)].sort();if(!tests.length)throw new CsoError('MISSING_INPUT',`No canonical ${stack} project test sources were found`);const selected=[...new Set([...configuration,...tests])].sort();if(selected.length>1000)throw new CsoError('MISSING_INPUT','Canonical project test suite exceeds the 1,000-file verification limit');if(minimumPassingTests.length!==commands.length||minimumPassingTests.some(value=>!Number.isInteger(value)||value<1))throw new CsoError('MISSING_INPUT','Canonical test plan could not derive a positive execution-count floor');return{commands,files:selected,kind,toolchain,minimumPassingTests,signature:sha256(canonical({stack,runnerEvidence,commands,files:selected,toolchain,minimumPassingTests}))}; -} -export function assertCanonicalTestPlan(request:VerificationRequest,sourceRoot:string,stack:CsoStack):CanonicalTestPlan{const plan=canonicalTestPlan(sourceRoot,stack);if(canonical(request.existingTests)!==canonical(plan.commands)||canonical([...request.testFiles].sort())!==canonical(plan.files))throw new CsoError('INVALID_SCHEMA',`Existing tests must use the helper-derived full ${plan.kind} suite and its immutable inputs`);return plan;} -function executableSource(root:string,path:string):boolean{try{const file=containedFile(root,relativePath(path)),stat=fs.lstatSync(file);return stat.isFile()&&!stat.isSymbolicLink()&&stat.nlink===1;}catch{return false;}} -function rejectPythonFrameworkShadows(root:string,framework:string,names:string[]):void{ - for(const name of names)for(const candidate of [`${name}.py`,name]){ - const file=containedFile(root,candidate);if(fs.existsSync(file))throw new CsoError('PREREQUISITE',`Canonical ${framework} startup rejects root import shadow: ${candidate}`); +const PYTHON_PURELIB = + "os.path.join(os.path.dirname(os.path.dirname(sys.executable)),'lib',f'python{sys.version_info.major}.{sys.version_info.minor}','site-packages')"; +const PYTEST_BOOTSTRAP = `import os,sys;sys.path.append(${PYTHON_PURELIB});import pytest;sys.path.insert(0,os.getcwd());raise SystemExit(pytest.main(sys.argv[1:]))`; +const UNITTEST_BOOTSTRAP = `import os,sys,unittest;sys.path.append(${PYTHON_PURELIB});sys.path.insert(0,os.getcwd());unittest.main(module=None,argv=['unittest',*sys.argv[1:]])`; +const DJANGO_BOOTSTRAP = `import os,sys,runpy;sys.path.append(${PYTHON_PURELIB});import django;sys.path.insert(0,os.getcwd());sys.argv=['manage.py',*sys.argv[1:]];runpy.run_path('manage.py',run_name='__main__')`; +const FLASK_BOOTSTRAP = `import os,sys;sys.path.append(${PYTHON_PURELIB});from flask.cli import main as _cso_main;sys.path.insert(0,os.getcwd());sys.argv=['flask',*sys.argv[1:]];_cso_main()`; +const UVICORN_BOOTSTRAP = `import os,sys;sys.path.append(${PYTHON_PURELIB});from uvicorn.main import main as _cso_main;sys.path.insert(0,os.getcwd());sys.argv=['uvicorn',*sys.argv[1:]];_cso_main()`; +function fileText(root: string, path: string): string { + try { + return fs.readFileSync(containedFile(root, path), 'utf8'); + } catch { + throw new CsoError('MISSING_INPUT', `Canonical test input is missing or unreadable: ${path}`); } } -export function canonicalStartPlan(sourceRoot:string,stack:CsoStack,port:number):CanonicalStartPlan{ - if(!Number.isInteger(port)||port<1024||port>65535)throw new CsoError('INVALID_SCHEMA','Canonical application start needs a loopback port from 1024 to 65535'); - let command:VerificationRequest['start'],kind:string,entrypointFiles:string[]=[],evidence:unknown; - if(stack==='node'||stack==='bun'){ - let manifest:Record;try{manifest=JSON.parse(fileText(sourceRoot,'package.json'));}catch{throw new CsoError('MISSING_INPUT',`Canonical ${stack} package.json is missing or invalid`);}const start=manifest?.scripts?.start; - if(typeof start==='string'&&start.trim()&&!/no start|^\s*(?:true|:|exit\s+0)\s*$/i.test(start)){for(const hook of ['prestart','poststart']){const value=manifest?.scripts?.[hook];if(value!==undefined&&typeof value!=='string')throw new CsoError('MISSING_INPUT',`Canonical ${stack} ${hook} lifecycle hook is invalid`);if(typeof value==='string'&&value.trim())throw new CsoError('MISSING_INPUT',`Canonical ${stack} startup cannot certify through package lifecycle hooks; remove ${hook} or run the direct entrypoint`);}const entry=directPackageStartEntrypoint(sourceRoot,stack,start);command={executable:stack==='node'?'/usr/local/bin/node':'/usr/local/bin/bun',args:stack==='node'?[entry]:[...BUN_RUNTIME_POLICY_ARGS,entry]};kind=`direct ${stack} package start`;evidence={script:start,entry};entrypointFiles=['package.json',entry];} - else{const declared=typeof manifest?.main==='string'&&manifest.main.length<4096?manifest.main:undefined,candidates=[...(declared?[declared]:[]),...'server.js,app.js,index.js,server.mjs,app.mjs,index.mjs'.split(',')].filter((value,index,all)=>all.indexOf(value)===index&&executableSource(sourceRoot,value));if(candidates.length!==1)throw new CsoError('MISSING_INPUT',`Canonical ${stack} startup is ambiguous; declare one non-placeholder start script or one conventional main entrypoint`);const entry=relativePath(candidates[0]);command={executable:stack==='node'?'/usr/local/bin/node':'/usr/local/bin/bun',args:stack==='node'?[entry]:[...BUN_RUNTIME_POLICY_ARGS,entry]};kind=`${stack} ${entry}`;evidence={entry};entrypointFiles=[entry];} - }else if(stack==='rails'){ - entrypointFiles=['config/application.rb','config/environment.rb'].filter(path=>executableSource(sourceRoot,path));if(entrypointFiles.length!==2)throw new CsoError('MISSING_INPUT','Canonical Rails startup requires config/application.rb and config/environment.rb');command={executable:'/usr/local/bin/bundle',args:['exec','rails','server','-b','127.0.0.1','-p',String(port)]};kind='Rails loopback server';evidence={entrypointFiles}; - }else{ - const preparation=inspectPreparation(sourceRoot,'python');if(preparation.status!=='ready')throw new CsoError('PREREQUISITE',preparation.prerequisites.map(item=>item.message).join('; ')||'Python dependency metadata is incomplete');const dependencies=new Set(preparation.inputs.map(input=>input.name.toLowerCase().replaceAll('_','-'))),choices:Array<{kind:string;command:VerificationRequest['start'];files:string[];evidence:unknown}>=[]; - if(dependencies.has('django')&&executableSource(sourceRoot,'manage.py')){rejectPythonFrameworkShadows(sourceRoot,'Django',['django']);choices.push({kind:'isolated Django loopback server without .pth startup',command:{executable:'/work/.venv/bin/python',args:['-I','-S','-c',DJANGO_BOOTSTRAP,'runserver',`127.0.0.1:${port}`,'--noreload']},files:['manage.py'],evidence:{framework:'django',bootstrap:DJANGO_BOOTSTRAP,siteInitialization:false,rootImportShadowsRejected:['django.py','django/']}});} - for(const file of ['app.py','application.py','wsgi.py'])if(dependencies.has('flask')&&executableSource(sourceRoot,file)){const body=fileText(sourceRoot,file),match=body.match(/(?:^|\n)\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Flask\s*\(/m);if(match){rejectPythonFrameworkShadows(sourceRoot,'Flask',['flask']);choices.push({kind:'isolated Flask loopback server without .pth startup',command:{executable:'/work/.venv/bin/python',args:['-I','-S','-c',FLASK_BOOTSTRAP,'--app',`${file.replace(/\.py$/,'')}:${match[1]}`,'run','--host','127.0.0.1','--port',String(port)]},files:[file],evidence:{framework:'flask',module:file,symbol:match[1],bootstrap:FLASK_BOOTSTRAP,siteInitialization:false,rootImportShadowsRejected:['flask.py','flask/']}});}} - for(const file of ['main.py','app.py','server.py'])if(dependencies.has('fastapi')&&dependencies.has('uvicorn')&&executableSource(sourceRoot,file)){const body=fileText(sourceRoot,file),match=body.match(/(?:^|\n)\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*FastAPI\s*\(/m);if(match){rejectPythonFrameworkShadows(sourceRoot,'FastAPI/Uvicorn',['fastapi','uvicorn']);choices.push({kind:'isolated FastAPI loopback server without .pth startup',command:{executable:'/work/.venv/bin/python',args:['-I','-S','-c',UVICORN_BOOTSTRAP,`${file.replace(/\.py$/,'')}:${match[1]}`,'--app-dir','/work','--host','127.0.0.1','--port',String(port)]},files:[file],evidence:{framework:'fastapi',module:file,symbol:match[1],bootstrap:UVICORN_BOOTSTRAP,siteInitialization:false,rootImportShadowsRejected:['fastapi.py','fastapi/','uvicorn.py','uvicorn/']}});}} - if(preparation.inputs.length===0){const direct=['app.py','application.py','server.py','main.py'].filter(file=>executableSource(sourceRoot,file));if(direct.length===1){const entry=direct[0];choices.push({kind:'isolated standard-library Python application',command:{executable:'/usr/local/bin/python',args:['-I',entry]},files:[entry],evidence:{framework:'standard-library',entry,isolatedMode:true,dependencyClosure:'empty'}});}} - const unique=choices.filter((choice,index)=>choices.findIndex(other=>canonical(other.command)===canonical(choice.command))===index);if(unique.length!==1)throw new CsoError('MISSING_INPUT','Canonical Python startup is unavailable or ambiguous; use one supported Django, Flask, or FastAPI entrypoint with locked runtime dependencies');({command,kind,evidence}=unique[0]);entrypointFiles=unique[0].files; - } - const entrypointEvidence=entrypointFiles.map(path=>{const file=containedFile(sourceRoot,path),stat=fs.lstatSync(file);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)throw new CsoError('UNSAFE_PATH',`Canonical startup input is unsafe: ${path}`);return{path,sha256:sha256(fs.readFileSync(file)),mode:stat.mode&0o777};}); - return{command,kind,entrypointFiles,signature:sha256(canonical({stack,kind,evidence,command,entrypointEvidence}))}; +function packageTestProjection(root: string, paths: string[]): unknown[] { + return paths + .filter((path) => /(?:^|\/)package\.json$/.test(path) && !TEST_TREE.test(path)) + .map((path) => { + let value: Record; + try { + value = JSON.parse(fileText(root, path)); + } catch { + throw new CsoError('MISSING_INPUT', `Canonical package test configuration is invalid: ${path}`); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new CsoError('MISSING_INPUT', `Canonical package test configuration is invalid: ${path}`); + return { + path, + type: value.type ?? null, + workspaces: value.workspaces ?? null, + scripts: value.scripts ?? null, + jest: value.jest ?? null, + vitest: value.vitest ?? null, + mocha: value.mocha ?? null, + ava: value.ava ?? null, + nyc: value.nyc ?? null, + }; + }); } -export function assertCanonicalStartPlan(request:VerificationRequest,sourceRoot:string,stack:CsoStack):CanonicalStartPlan{const plan=canonicalStartPlan(sourceRoot,stack,request.port);if(canonical(request.start)!==canonical(plan.command))throw new CsoError('INVALID_SCHEMA',`Application start must use the helper-derived ${plan.kind} command`);return plan;} -export function preparePatchedSource(snapshot:string,target:string,request:VerificationRequest):void{ - secureDirectory(target);fs.cpSync(snapshot,target,{recursive:true,errorOnExist:false,force:true,preserveTimestamps:false}); - for(const change of request.changes){ - const path=relativePath(change.path),file=containedFile(target,path),exists=fs.existsSync(file); - if(change.beforeSha256===null&&exists)throw new CsoError('INCOMPATIBLE_INPUT',`Expected new patch path already exists: ${path}`); - if(change.beforeSha256!==null&&(!exists||sha256(fs.readFileSync(file))!==change.beforeSha256))throw new CsoError('INCOMPATIBLE_INPUT',`Patch preimage does not match: ${path}`); - const derived=fileEffect(path);if(change.effect!==derived)throw new CsoError('INVALID_SCHEMA',`${path} must be declared as ${derived}, not ${change.effect}`); - if(change.after===null){fs.unlinkSync(file);continue;} - const safe=redact(change.after);if(safe!==change.after)throw new CsoError('REDACTION_FAILED',`Patch content for ${path} contains material that cannot enter a repair bundle`); - const mode=exists?(fs.statSync(file).mode&0o777):0o600;secureDirectory(dirname(file));fs.writeFileSync(file,change.after,{mode}); +function withoutJsCommentsAndStrings(value: string): string { + return value.replace( + /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, + (match) => ' '.repeat(match.length), + ); +} +function assertJavascriptTestRegistrations(root: string, tests: string[]): number { + const code = tests.map((path) => withoutJsCommentsAndStrings(fileText(root, path))).join('\n'); + if (/\b(?:fdescribe|fit)\s*\(|\b(?:describe|test|it)\s*\.\s*(?:only|concurrent\s*\.\s*only)\b/.test(code)) + throw new CsoError('MISSING_INPUT', 'Canonical JavaScript tests cannot certify a focused-only suite'); + const registrations = [ + ...code.matchAll(/\b(?:test|it)\s*(?:\.\s*(?:concurrent|each)\s*(?:\([^)]*\))?)?\s*\(/g), + ].length; + if (!registrations) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical JavaScript tests need static evidence of at least one non-skipped test or it registration', + ); + return registrations; +} +function boundedTestPaths(tests: string[]): string[] { + if (!tests.length) throw new CsoError('MISSING_INPUT', 'No canonical project test sources were found'); + if (tests.length > 1000) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical project test suite exceeds the 1,000-file verification limit', + ); + const paths = tests.map((path) => `./${path}`); + if (paths.reduce((bytes, path) => bytes + Buffer.byteLength(path) + 1, 0) > 128 * 1024) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical project test paths exceed the bounded direct-runner argument limit', + ); + return paths; +} +function directPackageTest( + root: string, + stack: 'node' | 'bun', + manifest: Record, + tests: string[], + configuration: string[], +): { + command: VerificationRequest['existingTests'][number]; + kind: string; + runner: string; + toolchain: 'runtime' | 'project'; + minimumPassingTests: number; +} { + const scripts = manifest.scripts; + if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) + throw new CsoError('MISSING_INPUT', `Canonical ${stack} package test scripts are missing or invalid`); + const script = scripts.test; + if ( + typeof script !== 'string' || + !script.trim() || + script.length > 4096 || + /no test specified|^\s*(?:true|:|exit\s+0)\s*$/i.test(script) + ) + throw new CsoError('MISSING_INPUT', `Canonical ${stack} test script is missing or a placeholder`); + for (const hook of ['pretest', 'posttest']) { + const value = scripts[hook]; + if (value !== undefined && typeof value !== 'string') + throw new CsoError('MISSING_INPUT', `Canonical ${stack} ${hook} lifecycle hook is invalid`); + if (typeof value === 'string' && value.trim()) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${stack} tests cannot certify through package lifecycle hooks; remove ${hook} or run the direct standard runner`, + ); + } + if (/[;&|><`$()\\\r\n]/.test(script)) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${stack} tests require one recognized direct standard runner; local wrappers and shell composition are not admitted`, + ); + const words = script.trim().split(/\s+/), + standard = ['jest', 'vitest', 'mocha', 'ava'], + paths = boundedTestPaths(tests), + minimumPassingTests = assertJavascriptTestRegistrations(root, tests); + if (canonical(words) === canonical(['node', '--test'])) { + return { + command: { executable: '/usr/local/bin/node', args: ['--test', '--test-reporter=tap', ...paths] }, + kind: 'direct node --test with TAP count evidence', + runner: 'node', + toolchain: 'runtime', + minimumPassingTests, + }; + } + if (canonical(words) === canonical(['bun', 'test'])) { + if (stack !== 'bun') + throw new CsoError( + 'MISSING_INPUT', + 'Canonical Node verification cannot depend on the Bun test runtime', + ); + return { + command: { executable: '/usr/local/bin/bun', args: [...BUN_RUNTIME_POLICY_ARGS, 'test', ...paths] }, + kind: 'direct bun test with automatic installation disabled', + runner: 'bun', + toolchain: 'runtime', + minimumPassingTests, + }; + } + const runner = + words.length === 1 && standard.includes(words[0]) + ? words[0] + : words.length === 3 && words[0] === 'npx' && words[1] === '--no-install' && standard.includes(words[2]) + ? words[2] + : undefined; + if (!runner) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${stack} tests require one recognized direct standard runner; local wrappers and shell composition are not admitted`, + ); + const control = canonical({ + embedded: manifest[runner] ?? null, + files: configuration + .filter((path) => NODE_TEST_CONFIG.test(path)) + .map((path) => [path, fileText(root, path)]), + }); + if ( + /(?:collectOnly|dryRun|passWithNoTests|testNamePattern|\b(?:grep|fgrep|match)\b|--(?:collect-only|dry-run|grep|fgrep|match|passWithNoTests))/i.test( + control, + ) + ) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${runner} configuration cannot focus, skip execution, or allow an empty suite`, + ); + const args = + runner === 'jest' + ? ['--runTestsByPath', '--passWithNoTests=false', '--json', ...paths] + : runner === 'vitest' + ? ['run', '--passWithNoTests=false', '--reporter=verbose', ...paths] + : runner === 'mocha' + ? ['--fail-zero', '--no-dry-run', '--forbid-only', '--reporter', 'json', ...paths] + : ['--tap', ...paths]; + return { + command: { executable: `/work/node_modules/.bin/${runner}`, args }, + kind: `direct local ${runner}`, + runner, + toolchain: 'project', + minimumPassingTests, + }; +} +function directPackageStartEntrypoint(root: string, stack: 'node' | 'bun', script: string): string { + if (/[;&|><`$()\\\r\n]/.test(script)) + throw new CsoError('MISSING_INPUT', `Canonical ${stack} startup cannot use shell composition`); + const words = script.trim().split(/\s+/), + runner = words.shift(); + if ( + runner !== stack || + words.length !== 1 || + !/^[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s|jsx|tsx)$/.test(words[0]) || + words[0].startsWith('/') || + words[0].split('/').includes('..') + ) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${stack} package startup requires one direct contained ${stack} entrypoint`, + ); + const path = relativePath(words[0]); + if (!executableSource(root, path)) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${stack} package startup entrypoint is missing or unsafe: ${path}`, + ); + return path; +} +function pyprojectTestProjection(root: string, path: string): unknown { + try { + const value = Bun.TOML.parse(fileText(root, path)) as Record; + return { + tool: { pytest: value?.tool?.pytest ?? null, coverage: value?.tool?.coverage ?? null }, + projectScripts: value?.project?.scripts ?? null, + }; + } catch { + throw new CsoError('MISSING_INPUT', 'Canonical Python test configuration is invalid: pyproject.toml'); } } -export function certify(params:{runId:string;manifest:SnapshotManifest;request:VerificationRequest;identityRequest?:VerificationRequest;runtime:QualifiedRuntime;verifier:QualifiedRuntime;before:VerificationObservation;after:VerificationObservation;beforeRoot:string;afterRoot:string;policyHash:string;auditPolicyHash?:string;archives:string[];dependencyClosures?:{before:unknown;after:unknown};preparation?:{before:PreparationProof;after:PreparationProof};reviewArtifact?:RepairReviewArtifact;startPlanHash?:string;testPlanHash?:string;testToolchain:'runtime'|'project';minimumPassingTests?:number[];witness?:{before:AssertionWitnessReceipt;after:AssertionWitnessReceipt}}):{manifest:VerificationManifest;bundle?:RepairBundle}{ - const {request}=params,identityRequest=params.identityRequest??request,pHash=patchHash(identityRequest),harnessHash=verificationHarnessHash(request,params.beforeRoot),afterHarnessHash=verificationHarnessHash(request,params.afterRoot),fixturesHash=sha256(canonical(identityRequest.fixtures));if(afterHarnessHash!==harnessHash)throw new CsoError('ASSERTION_FAILED','Repair changed immutable existing-test inputs'); - if(!['runtime','project'].includes(params.testToolchain))throw new CsoError('INVALID_SCHEMA','Verification test toolchain must be helper-derived as runtime or project'); - if(identityRequest.review.reviewedPatchHash!==pHash)throw new CsoError('INVALID_SCHEMA',`Independent review binds the wrong patch hash; expected ${pHash}`); - const sourceAfter=treeHash(params.afterRoot),dependenciesBefore=treeHash(params.beforeRoot,p=>DEPENDENCY.test(p)),dependenciesAfter=treeHash(params.afterRoot,p=>DEPENDENCY.test(p)),configurationBefore=treeHash(params.beforeRoot,p=>CONFIG.test(p)&&!DEPENDENCY.test(p)),configurationAfter=treeHash(params.afterRoot,p=>CONFIG.test(p)&&!DEPENDENCY.test(p)); - const mechanical=params.before.booted&¶ms.before.legitimate&¶ms.before.security==='intended_failure'&¶ms.before.existingTests&¶ms.after.booted&¶ms.after.legitimate&¶ms.after.security==='pass'&¶ms.after.existingTests; - const reviewGate=identityRequest.review.independent&&identityRequest.review.rootCauseRepaired&&identityRequest.review.featurePreserved&&!identityRequest.review.boundaryMocks; +export function canonicalTestPlan(sourceRoot: string, stack: CsoStack): CanonicalTestPlan { + const generated = (path: string) => { + const first = path.split('/')[0]; + return stack === 'node' + ? first === 'node_modules' || first === '.cso-npm-cache' + : stack === 'bun' + ? first === 'node_modules' || first === '.cso-bun-cache' + : stack === 'python' + ? first === '.venv' || first === '.cso-uv-cache' || path === '.gstack-cso-public-requirements.txt' + : path.startsWith('vendor/bundle/') || first === '.cso-bundle' || first === '.cso-gems'; + }; + const all = allFiles(sourceRoot, sourceRoot, generated); + let tests: string[] = [], + configuration: string[] = [], + commands: VerificationRequest['existingTests'] = [], + kind = '', + toolchain: 'runtime' | 'project' = 'runtime', + minimumPassingTests: number[] = [], + runnerEvidence: unknown = {}; + if (stack === 'node' || stack === 'bun') { + let manifest: Record; + try { + manifest = JSON.parse(fileText(sourceRoot, 'package.json')); + } catch { + throw new CsoError('MISSING_INPUT', `Canonical ${stack} package.json is missing or invalid`); + } + tests = all.filter((path) => + /(?:^|\/)(?:test|tests|__tests__)\/.*\.(?:[cm]?js|tsx?|jsx)$|\.(?:test|spec)\.(?:[cm]?js|tsx?|jsx)$/.test( + path, + ), + ); + configuration = all.filter((path) => TEST_TREE.test(path) || NODE_TEST_CONFIG.test(path)); + const direct = directPackageTest(sourceRoot, stack, manifest, tests, configuration); + commands = [direct.command]; + kind = direct.kind; + toolchain = direct.toolchain; + minimumPassingTests = [direct.minimumPassingTests]; + runnerEvidence = { + runner: direct.runner, + declaredScript: manifest.scripts.test, + packages: packageTestProjection(sourceRoot, all), + minimumPassingTests: direct.minimumPassingTests, + }; + } else if (stack === 'python') { + tests = all.filter((path) => + /(?:^|\/)(?:test|tests)\/.*\.py$|(?:^|\/)test_[^/]+\.py$|_test\.py$/.test(path), + ); + configuration = all.filter( + (path) => + TEST_TREE.test(path) || + /(?:^|\/)(?:conftest\.py|\.?pytest\.ini|\.?pytest\.toml|setup\.cfg|tox\.ini|noxfile\.py)$/.test(path), + ); + const bodies = tests.map((path) => fileText(sourceRoot, path)), + configBodies = configuration.map((path) => fileText(sourceRoot, path)), + pyproject = all.includes('pyproject.toml') + ? pyprojectTestProjection(sourceRoot, 'pyproject.toml') + : null; + const pytestEvidence = + all.some((path) => /(?:^|\/)(?:conftest\.py|\.?pytest\.ini|\.?pytest\.toml)$/.test(path)) || + [...bodies, ...configBodies].some( + (body) => + /(?:^|\n)\s*(?:import pytest|from pytest\b|@pytest\.)/m.test(body) || + /(?:^|\n)(?:async\s+)?def test_[A-Za-z0-9_]*\s*\(/m.test(body), + ); + const unittestEvidence = bodies.some((body) => + /(?:^|\n)\s*(?:import unittest|from unittest\b)|unittest\.TestCase|TestCase\s*\)/m.test(body), + ); + if (!pytestEvidence && !unittestEvidence) + throw new CsoError( + 'MISSING_INPUT', + 'Python test runner is ambiguous; declare pytest evidence or a unittest suite', + ); + const usePytest = pytestEvidence, + paths = boundedTestPaths(tests), + pytestControl = [...configBodies, canonical(pyproject)].join('\n'); + if ( + usePytest && + /(?:--collect-only|\s--co\b|--setup-(?:only|plan)|--fixtures(?:-per-test)?|--no-summary|\baddopts[^\n]*(?:\s-k\b|\s-m\b|--ignore\b|--deselect\b|(?:^|\s)-q{2,}\b))/i.test( + pytestControl, + ) + ) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical pytest configuration cannot collect only, focus, deselect, suppress its count, or skip test execution', + ); + commands = [ + { + executable: '/work/.venv/bin/python', + args: usePytest + ? ['-I', '-S', '-c', PYTEST_BOOTSTRAP, '-q', '--color=no', '--', ...paths] + : ['-I', '-S', '-c', UNITTEST_BOOTSTRAP, ...paths], + }, + ]; + kind = usePytest + ? 'isolated prepared pytest with explicit files, positive summary, and no .pth startup' + : 'isolated standard-library unittest with explicit files and no .pth startup'; + toolchain = usePytest ? 'project' : 'runtime'; + minimumPassingTests = [1]; + runnerEvidence = { + runner: usePytest ? 'pytest' : 'unittest', + bootstrap: usePytest ? PYTEST_BOOTSTRAP : UNITTEST_BOOTSTRAP, + siteInitialization: false, + reporter: usePytest ? 'quiet-positive-summary-no-color' : 'unittest-summary', + pyproject, + }; + } else { + const specs = all.filter((path) => /(?:^|\/)spec\/.*_spec\.rb$/.test(path)), + rails = all.filter((path) => /(?:^|\/)test\/.*_test\.rb$/.test(path)); + tests = [...specs, ...rails]; + configuration = all.filter((path) => TEST_TREE.test(path) || /(?:^|\/)\.rspec(?:-local)?$/.test(path)); + const rspecControl = configuration + .filter((path) => /(?:^|\/)\.rspec(?:-local)?$/.test(path)) + .map((path) => fileText(sourceRoot, path)) + .join('\n'); + if (/--(?:dry-run|tag|example|pattern|exclude-pattern|only-failures|next-failure)\b/.test(rspecControl)) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical RSpec configuration cannot dry-run, focus, filter, or select only prior failures', + ); + commands = [ + ...(specs.length + ? [ + { + executable: '/usr/local/bin/bundle', + args: ['exec', 'rspec', '--format', 'json', '--', ...boundedTestPaths(specs)], + }, + ] + : []), + ...(rails.length + ? [ + { + executable: '/usr/local/bin/bundle', + args: ['exec', 'rails', 'test', '--no-color', ...boundedTestPaths(rails)], + }, + ] + : []), + ]; + kind = commands.map((command) => command.args.join(' ')).join(' + '); + toolchain = 'project'; + minimumPassingTests = commands.map(() => 1); + runnerEvidence = { + rspec: specs.length > 0, + minitest: rails.length > 0, + reporters: specs.length + ? ['rspec-json', ...(rails.length ? ['rails-summary-no-color'] : [])] + : ['rails-summary-no-color'], + }; + } + tests = [...new Set(tests)].sort(); + configuration = [...new Set(configuration)].sort(); + if (!tests.length) + throw new CsoError('MISSING_INPUT', `No canonical ${stack} project test sources were found`); + const selected = [...new Set([...configuration, ...tests])].sort(); + if (selected.length > 1000) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical project test suite exceeds the 1,000-file verification limit', + ); + if ( + minimumPassingTests.length !== commands.length || + minimumPassingTests.some((value) => !Number.isInteger(value) || value < 1) + ) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical test plan could not derive a positive execution-count floor', + ); + return { + commands, + files: selected, + kind, + toolchain, + minimumPassingTests, + signature: sha256( + canonical({ stack, runnerEvidence, commands, files: selected, toolchain, minimumPassingTests }), + ), + }; +} +export function assertCanonicalTestPlan( + request: VerificationRequest, + sourceRoot: string, + stack: CsoStack, +): CanonicalTestPlan { + const plan = canonicalTestPlan(sourceRoot, stack); + if ( + canonical(request.existingTests) !== canonical(plan.commands) || + canonical([...request.testFiles].sort()) !== canonical(plan.files) + ) + throw new CsoError( + 'INVALID_SCHEMA', + `Existing tests must use the helper-derived full ${plan.kind} suite and its immutable inputs`, + ); + return plan; +} +function executableSource(root: string, path: string): boolean { + try { + const file = containedFile(root, relativePath(path)), + stat = fs.lstatSync(file); + return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1; + } catch { + return false; + } +} +function rejectPythonFrameworkShadows(root: string, framework: string, names: string[]): void { + for (const name of names) + for (const candidate of [`${name}.py`, name]) { + const file = containedFile(root, candidate); + if (fs.existsSync(file)) + throw new CsoError( + 'PREREQUISITE', + `Canonical ${framework} startup rejects root import shadow: ${candidate}`, + ); + } +} +export function canonicalStartPlan(sourceRoot: string, stack: CsoStack, port: number): CanonicalStartPlan { + if (!Number.isInteger(port) || port < 1024 || port > 65535) + throw new CsoError( + 'INVALID_SCHEMA', + 'Canonical application start needs a loopback port from 1024 to 65535', + ); + let command: VerificationRequest['start'], + kind: string, + entrypointFiles: string[] = [], + evidence: unknown; + if (stack === 'node' || stack === 'bun') { + let manifest: Record; + try { + manifest = JSON.parse(fileText(sourceRoot, 'package.json')); + } catch { + throw new CsoError('MISSING_INPUT', `Canonical ${stack} package.json is missing or invalid`); + } + const start = manifest?.scripts?.start; + if (typeof start === 'string' && start.trim() && !/no start|^\s*(?:true|:|exit\s+0)\s*$/i.test(start)) { + for (const hook of ['prestart', 'poststart']) { + const value = manifest?.scripts?.[hook]; + if (value !== undefined && typeof value !== 'string') + throw new CsoError('MISSING_INPUT', `Canonical ${stack} ${hook} lifecycle hook is invalid`); + if (typeof value === 'string' && value.trim()) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${stack} startup cannot certify through package lifecycle hooks; remove ${hook} or run the direct entrypoint`, + ); + } + const entry = directPackageStartEntrypoint(sourceRoot, stack, start); + command = { + executable: stack === 'node' ? '/usr/local/bin/node' : '/usr/local/bin/bun', + args: stack === 'node' ? [entry] : [...BUN_RUNTIME_POLICY_ARGS, entry], + }; + kind = `direct ${stack} package start`; + evidence = { script: start, entry }; + entrypointFiles = ['package.json', entry]; + } else { + const declared = + typeof manifest?.main === 'string' && manifest.main.length < 4096 ? manifest.main : undefined, + candidates = [ + ...(declared ? [declared] : []), + ...'server.js,app.js,index.js,server.mjs,app.mjs,index.mjs'.split(','), + ].filter((value, index, all) => all.indexOf(value) === index && executableSource(sourceRoot, value)); + if (candidates.length !== 1) + throw new CsoError( + 'MISSING_INPUT', + `Canonical ${stack} startup is ambiguous; declare one non-placeholder start script or one conventional main entrypoint`, + ); + const entry = relativePath(candidates[0]); + command = { + executable: stack === 'node' ? '/usr/local/bin/node' : '/usr/local/bin/bun', + args: stack === 'node' ? [entry] : [...BUN_RUNTIME_POLICY_ARGS, entry], + }; + kind = `${stack} ${entry}`; + evidence = { entry }; + entrypointFiles = [entry]; + } + } else if (stack === 'rails') { + entrypointFiles = ['config/application.rb', 'config/environment.rb'].filter((path) => + executableSource(sourceRoot, path), + ); + if (entrypointFiles.length !== 2) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical Rails startup requires config/application.rb and config/environment.rb', + ); + command = { + executable: '/usr/local/bin/bundle', + args: ['exec', 'rails', 'server', '-b', '127.0.0.1', '-p', String(port)], + }; + kind = 'Rails loopback server'; + evidence = { entrypointFiles }; + } else { + const preparation = inspectPreparation(sourceRoot, 'python'); + if (preparation.status !== 'ready') + throw new CsoError( + 'PREREQUISITE', + preparation.prerequisites.map((item) => item.message).join('; ') || + 'Python dependency metadata is incomplete', + ); + const dependencies = new Set( + preparation.inputs.map((input) => input.name.toLowerCase().replaceAll('_', '-')), + ), + choices: Array<{ + kind: string; + command: VerificationRequest['start']; + files: string[]; + evidence: unknown; + }> = []; + if (dependencies.has('django') && executableSource(sourceRoot, 'manage.py')) { + rejectPythonFrameworkShadows(sourceRoot, 'Django', ['django']); + choices.push({ + kind: 'isolated Django loopback server without .pth startup', + command: { + executable: '/work/.venv/bin/python', + args: ['-I', '-S', '-c', DJANGO_BOOTSTRAP, 'runserver', `127.0.0.1:${port}`, '--noreload'], + }, + files: ['manage.py'], + evidence: { + framework: 'django', + bootstrap: DJANGO_BOOTSTRAP, + siteInitialization: false, + rootImportShadowsRejected: ['django.py', 'django/'], + }, + }); + } + for (const file of ['app.py', 'application.py', 'wsgi.py']) + if (dependencies.has('flask') && executableSource(sourceRoot, file)) { + const body = fileText(sourceRoot, file), + match = body.match(/(?:^|\n)\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Flask\s*\(/m); + if (match) { + rejectPythonFrameworkShadows(sourceRoot, 'Flask', ['flask']); + choices.push({ + kind: 'isolated Flask loopback server without .pth startup', + command: { + executable: '/work/.venv/bin/python', + args: [ + '-I', + '-S', + '-c', + FLASK_BOOTSTRAP, + '--app', + `${file.replace(/\.py$/, '')}:${match[1]}`, + 'run', + '--host', + '127.0.0.1', + '--port', + String(port), + ], + }, + files: [file], + evidence: { + framework: 'flask', + module: file, + symbol: match[1], + bootstrap: FLASK_BOOTSTRAP, + siteInitialization: false, + rootImportShadowsRejected: ['flask.py', 'flask/'], + }, + }); + } + } + for (const file of ['main.py', 'app.py', 'server.py']) + if (dependencies.has('fastapi') && dependencies.has('uvicorn') && executableSource(sourceRoot, file)) { + const body = fileText(sourceRoot, file), + match = body.match(/(?:^|\n)\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*FastAPI\s*\(/m); + if (match) { + rejectPythonFrameworkShadows(sourceRoot, 'FastAPI/Uvicorn', ['fastapi', 'uvicorn']); + choices.push({ + kind: 'isolated FastAPI loopback server without .pth startup', + command: { + executable: '/work/.venv/bin/python', + args: [ + '-I', + '-S', + '-c', + UVICORN_BOOTSTRAP, + `${file.replace(/\.py$/, '')}:${match[1]}`, + '--app-dir', + '/work', + '--host', + '127.0.0.1', + '--port', + String(port), + ], + }, + files: [file], + evidence: { + framework: 'fastapi', + module: file, + symbol: match[1], + bootstrap: UVICORN_BOOTSTRAP, + siteInitialization: false, + rootImportShadowsRejected: ['fastapi.py', 'fastapi/', 'uvicorn.py', 'uvicorn/'], + }, + }); + } + } + if (preparation.inputs.length === 0) { + const direct = ['app.py', 'application.py', 'server.py', 'main.py'].filter((file) => + executableSource(sourceRoot, file), + ); + if (direct.length === 1) { + const entry = direct[0]; + choices.push({ + kind: 'isolated standard-library Python application', + command: { executable: '/usr/local/bin/python', args: ['-I', entry] }, + files: [entry], + evidence: { framework: 'standard-library', entry, isolatedMode: true, dependencyClosure: 'empty' }, + }); + } + } + const unique = choices.filter( + (choice, index) => + choices.findIndex((other) => canonical(other.command) === canonical(choice.command)) === index, + ); + if (unique.length !== 1) + throw new CsoError( + 'MISSING_INPUT', + 'Canonical Python startup is unavailable or ambiguous; use one supported Django, Flask, or FastAPI entrypoint with locked runtime dependencies', + ); + ({ command, kind, evidence } = unique[0]); + entrypointFiles = unique[0].files; + } + const entrypointEvidence = entrypointFiles.map((path) => { + const file = containedFile(sourceRoot, path), + stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) + throw new CsoError('UNSAFE_PATH', `Canonical startup input is unsafe: ${path}`); + return { path, sha256: sha256(fs.readFileSync(file)), mode: stat.mode & 0o777 }; + }); + return { + command, + kind, + entrypointFiles, + signature: sha256(canonical({ stack, kind, evidence, command, entrypointEvidence })), + }; +} +export function assertCanonicalStartPlan( + request: VerificationRequest, + sourceRoot: string, + stack: CsoStack, +): CanonicalStartPlan { + const plan = canonicalStartPlan(sourceRoot, stack, request.port); + if (canonical(request.start) !== canonical(plan.command)) + throw new CsoError( + 'INVALID_SCHEMA', + `Application start must use the helper-derived ${plan.kind} command`, + ); + return plan; +} +export function preparePatchedSource(snapshot: string, target: string, request: VerificationRequest): void { + secureDirectory(target); + fs.cpSync(snapshot, target, { + recursive: true, + errorOnExist: false, + force: true, + preserveTimestamps: false, + }); + for (const change of request.changes) { + const path = relativePath(change.path), + file = containedFile(target, path), + exists = fs.existsSync(file); + if (change.beforeSha256 === null && exists) + throw new CsoError('INCOMPATIBLE_INPUT', `Expected new patch path already exists: ${path}`); + if (change.beforeSha256 !== null && (!exists || sha256(fs.readFileSync(file)) !== change.beforeSha256)) + throw new CsoError('INCOMPATIBLE_INPUT', `Patch preimage does not match: ${path}`); + const derived = fileEffect(path); + if (change.effect !== derived) + throw new CsoError('INVALID_SCHEMA', `${path} must be declared as ${derived}, not ${change.effect}`); + if (change.after === null) { + fs.unlinkSync(file); + continue; + } + const safe = redact(change.after); + if (safe !== change.after) + throw new CsoError( + 'REDACTION_FAILED', + `Patch content for ${path} contains material that cannot enter a repair bundle`, + ); + const mode = exists ? fs.statSync(file).mode & 0o777 : 0o600; + secureDirectory(dirname(file)); + fs.writeFileSync(file, change.after, { mode }); + } +} +export function certify(params: { + runId: string; + manifest: SnapshotManifest; + request: VerificationRequest; + identityRequest?: VerificationRequest; + runtime: QualifiedRuntime; + verifier: QualifiedRuntime; + before: VerificationObservation; + after: VerificationObservation; + beforeRoot: string; + afterRoot: string; + policyHash: string; + auditPolicyHash?: string; + archives: string[]; + dependencyClosures?: { before: unknown; after: unknown }; + preparation?: { before: PreparationProof; after: PreparationProof }; + reviewArtifact?: RepairReviewArtifact; + startPlanHash?: string; + testPlanHash?: string; + testToolchain: 'runtime' | 'project'; + minimumPassingTests?: number[]; + witness?: { before: AssertionWitnessReceipt; after: AssertionWitnessReceipt }; +}): { manifest: VerificationManifest; bundle?: RepairBundle } { + const { request } = params, + identityRequest = params.identityRequest ?? request, + pHash = patchHash(identityRequest), + harnessHash = verificationHarnessHash(request, params.beforeRoot), + afterHarnessHash = verificationHarnessHash(request, params.afterRoot), + fixturesHash = sha256(canonical(identityRequest.fixtures)); + if (afterHarnessHash !== harnessHash) + throw new CsoError('ASSERTION_FAILED', 'Repair changed immutable existing-test inputs'); + if (!['runtime', 'project'].includes(params.testToolchain)) + throw new CsoError( + 'INVALID_SCHEMA', + 'Verification test toolchain must be helper-derived as runtime or project', + ); + if (identityRequest.review.reviewedPatchHash !== pHash) + throw new CsoError('INVALID_SCHEMA', `Independent review binds the wrong patch hash; expected ${pHash}`); + const sourceAfter = treeHash(params.afterRoot), + dependenciesBefore = treeHash(params.beforeRoot, (p) => DEPENDENCY.test(p)), + dependenciesAfter = treeHash(params.afterRoot, (p) => DEPENDENCY.test(p)), + configurationBefore = treeHash(params.beforeRoot, (p) => CONFIG.test(p) && !DEPENDENCY.test(p)), + configurationAfter = treeHash(params.afterRoot, (p) => CONFIG.test(p) && !DEPENDENCY.test(p)); + const mechanical = + params.before.booted && + params.before.legitimate && + params.before.security === 'intended_failure' && + params.before.existingTests && + params.after.booted && + params.after.legitimate && + params.after.security === 'pass' && + params.after.existingTests; + const reviewGate = + identityRequest.review.independent && + identityRequest.review.rootCauseRepaired && + identityRequest.review.featurePreserved && + !identityRequest.review.boundaryMocks; // Application code shares the project test process and can forge reporter // output or terminate the runner. The signed receipt authenticates the // separate verifier assertions; project-test completion stays self-reported. - const testCompletionAssurance='self_reported' as const; - const reviewAssurance=params.reviewArtifact?.assurance??'self_attested'; - const inconclusive=!params.before.booted||!params.before.legitimate||params.before.security==='inconclusive'||!params.after.booted||params.after.security==='inconclusive'; - if(params.preparation&&(canonical(params.preparation.before.transformations)!==canonical(params.preparation.after.transformations)|| - params.preparation.before.databaseHash!==params.preparation.after.databaseHash))throw new CsoError('ASSERTION_FAILED','Repair changed the synthetic preparation or database boundary'); - if(mechanical&&reviewGate&¶ms.testToolchain==='project'){ - if(request.changes.some(change=>change.effect==='dependency'))throw new CsoError('PREREQUISITE','Runtime-tested dependency repairs require a test runner pinned in the qualified runtime; project-installed test toolchains may change with the repair'); - if(!params.preparation)throw new CsoError('ASSERTION_FAILED','Project-installed test toolchains require before/after prepared dependency proofs'); - if(params.preparation.before.preparedDependencyHash!==params.preparation.after.preparedDependencyHash)throw new CsoError('ASSERTION_FAILED','Project-installed test toolchain bytes changed between source phases'); + const testCompletionAssurance = 'self_reported' as const; + const reviewAssurance = params.reviewArtifact?.assurance ?? 'self_attested'; + const inconclusive = + !params.before.booted || + !params.before.legitimate || + params.before.security === 'inconclusive' || + !params.after.booted || + params.after.security === 'inconclusive'; + if ( + params.preparation && + (canonical(params.preparation.before.transformations) !== + canonical(params.preparation.after.transformations) || + params.preparation.before.databaseHash !== params.preparation.after.databaseHash) + ) + throw new CsoError('ASSERTION_FAILED', 'Repair changed the synthetic preparation or database boundary'); + if (mechanical && reviewGate && params.testToolchain === 'project') { + if (request.changes.some((change) => change.effect === 'dependency')) + throw new CsoError( + 'PREREQUISITE', + 'Runtime-tested dependency repairs require a test runner pinned in the qualified runtime; project-installed test toolchains may change with the repair', + ); + if (!params.preparation) + throw new CsoError( + 'ASSERTION_FAILED', + 'Project-installed test toolchains require before/after prepared dependency proofs', + ); + if (params.preparation.before.preparedDependencyHash !== params.preparation.after.preparedDependencyHash) + throw new CsoError( + 'ASSERTION_FAILED', + 'Project-installed test toolchain bytes changed between source phases', + ); } - const transformations=params.manifest.entries.filter(e=>e.transformation),transformationsHash=sha256(canonical(transformations)),archivesHash=sha256(canonical([...params.archives].sort())),requestHash=sha256(canonical(identityRequest)),preparationHash=params.preparation?sha256(canonical(params.preparation)):undefined,startPlanHash=params.startPlanHash??sha256(canonical(request.start)),testPlanHash=params.testPlanHash??sha256(canonical({commands:request.existingTests,files:[...request.testFiles].sort()})),auditPolicyHash=params.auditPolicyHash??sha256(canonical({})),assertionHash=sha256(canonical({legitimate:identityRequest.legitimate,security:identityRequest.security})),minimumPassingTests=params.minimumPassingTests??request.existingTests.map(()=>1),runner={testToolchain:params.testToolchain,startPlanHash,testPlanHash,commandsHash:sha256(canonical(request.existingTests)),minimumPassingTestsHash:sha256(canonical(minimumPassingTests))}; - let assertionAssurance:VerificationManifest['assertionAssurance'],witnessHash:string|undefined; - if(params.witness){ - const beforeReceipt=validateStoredAssertionWitnessReceipt(params.witness.before),afterReceipt=validateStoredAssertionWitnessReceipt(params.witness.after),stable=(binding:AssertionWitnessBinding)=>{const {nonce:_,issuedAt:__,expiresAt:___,...value}=binding;return value;},expected=(phase:'before'|'after',sourceHash:string,dependencyHash:string,configurationHash:string)=>({schemaVersion:1,protocol:'gstack-cso-assertion-witness-v1',phase,runId:params.runId,findingId:identityRequest.findingId,policyHash:params.policyHash,auditPolicyHash,runtime:{image:params.runtime.image,verifierImage:params.verifier.image,platform:params.runtime.platform,profile:params.runtime.id},runner,sourceHash,dependencyHash,configurationHash,requestHash,patchHash:pHash,harnessHash,assertionHash,fixturesHash}); - if(canonical(stable(beforeReceipt.binding))!==canonical(expected('before',params.manifest.executionHash,dependenciesBefore,configurationBefore))||canonical(stable(afterReceipt.binding))!==canonical(expected('after',sourceAfter,dependenciesAfter,configurationAfter))||beforeReceipt.publicKey!==afterReceipt.publicKey||beforeReceipt.keyId!==afterReceipt.keyId||beforeReceipt.binding.nonce===afterReceipt.binding.nonce)throw new CsoError('INCOMPATIBLE_INPUT','Authenticated assertion witness receipts do not bind this verification'); - const beforeObservation={...params.before,existingTests:beforeReceipt.diagnosticTestsPassed,inputHash:harnessHash},afterObservation={...params.after,existingTests:afterReceipt.diagnosticTestsPassed,inputHash:harnessHash}; - if(beforeReceipt.observationHash!==witnessObservationHash(beforeObservation)||afterReceipt.observationHash!==witnessObservationHash(afterObservation)||params.before.existingTests!==beforeReceipt.diagnosticTestsPassed||params.after.existingTests!==afterReceipt.diagnosticTestsPassed||!beforeReceipt.externalAssertionsPassed||!afterReceipt.externalAssertionsPassed)throw new CsoError('INCOMPATIBLE_INPUT','Authenticated assertion witness receipts do not match the verifier observations'); - const stableExecutions=(receipt:AssertionWitnessReceipt)=>receipt.executions.map(({outputHash:_,...execution})=>execution); - if(canonical(stableExecutions(beforeReceipt))!==canonical(stableExecutions(afterReceipt)))throw new CsoError('ASSERTION_FAILED','Repair changed the existing-test execution count or outcome'); - assertionAssurance='authenticated_out_of_process';witnessHash=assertionWitnessPairHash({before:beforeReceipt,after:afterReceipt}); - } - const passed=mechanical&&reviewGate&&assertionAssurance==='authenticated_out_of_process',preservationUnattested=mechanical&&reviewGate&&!passed,createdAt=new Date().toISOString(),verification:VerificationManifest={version:3,id:'',runId:params.runId,findingId:identityRequest.findingId,createdAt,helperAbi:3,runtime:{image:params.runtime.image,platform:params.runtime.platform,profile:params.runtime.id},testToolchain:params.testToolchain,policyHash:params.policyHash,auditPolicyHash,harnessHash,requestHash,startPlanHash,testPlanHash,fixturesHash,patchHash:pHash,originalSourceHash:params.manifest.originalHash,transformationsHash,archivesHash,...(preparationHash?{preparationHash}:{}),beforeSourceHash:params.manifest.executionHash,afterSourceHash:sourceAfter,beforeDependencies:dependenciesBefore,afterDependencies:dependenciesAfter,beforeConfiguration:configurationBefore,afterConfiguration:configurationAfter,before:{...params.before,inputHash:harnessHash},after:{...params.after,inputHash:harnessHash},review:identityRequest.review,reviewAssurance,...(assertionAssurance?{assertionAssurance}:{}),testCompletionAssurance,...(witnessHash?{witnessHash}:{}),result:passed?'runtime_tested':(inconclusive||preservationUnattested)?'inconclusive':'failed'};const id=verificationIdentity(verification);verification.id=id; - if(!['runtime_tested','tested'].includes(verification.result))return{manifest:verification}; - const bundle:RepairBundle={schemaVersion:3,runId:params.runId,id,createdAt,expiresAt:new Date(Date.parse(createdAt)+30*86400_000).toISOString(),requiredInputs:{sourceHash:params.manifest.executionHash,originalHash:params.manifest.originalHash,runtimeImage:params.runtime.image,platform:params.runtime.platform,archives:params.archives,...(params.dependencyClosures?{dependencyClosures:params.dependencyClosures}:{})},request:identityRequest,verification,transformations,...(params.preparation?{preparation:params.preparation}:{}),...(params.reviewArtifact?{reviewArtifact:params.reviewArtifact}:{}),witness:params.witness!}; - return{manifest:verification,bundle}; -} - -export function validateRepairBundle(value:unknown,id:string,sourceRoot?:string,sourceManifest?:SnapshotManifest):RepairBundle{ - const bundle=value as RepairBundle;if(!bundle||bundle.schemaVersion!==3||bundle.id!==id||bundle.runId!==bundle.verification?.runId||bundle.verification?.id!==id||verificationIdentity(bundle.verification)!==id)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle identity or verification provenance is invalid'); - const request=validateVerificationRequest(bundle.request),verification=bundle.verification,required=bundle.requiredInputs; - const createdAtMs=Date.parse(bundle.createdAt);if(!Number.isFinite(createdAtMs)||new Date(createdAtMs).toISOString()!==bundle.createdAt||bundle.createdAt!==verification.createdAt||bundle.expiresAt!==new Date(createdAtMs+30*86400_000).toISOString())throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle retention timestamps do not match their authenticated verification time'); - if(!['runtime_tested','tested'].includes(verification.result)||!['self_attested','host_verified'].includes(verification.reviewAssurance)||verification.assertionAssurance!=='authenticated_out_of_process'||(verification.result==='tested'&&verification.testCompletionAssurance!=='authenticated_out_of_process')||(verification.result==='runtime_tested'&&verification.testCompletionAssurance!=='self_reported'))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle lacks the assurance required by its repair label'); - if(!['runtime','project'].includes(verification.testToolchain))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle test toolchain provenance is invalid'); - if(request.findingId!==verification.findingId||request.runtimeProfile!==verification.runtime.profile||sha256(canonical(request))!==verification.requestHash||canonical(request.review)!==canonical(verification.review)||patchHash(request)!==verification.patchHash||![verification.requestHash,verification.startPlanHash,verification.testPlanHash].every(value=>/^[a-f0-9]{64}$/.test(value))||sha256(canonical(request.fixtures))!==verification.fixturesHash||required.sourceHash!==verification.beforeSourceHash||required.originalHash!==verification.originalSourceHash||required.runtimeImage!==verification.runtime.image||required.platform!==verification.runtime.platform||sha256(canonical([...(required.archives??[])].sort()))!==verification.archivesHash||sha256(canonical(bundle.transformations??[]))!==verification.transformationsHash)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle request, harness, or contents do not match their authenticated manifest'); - if(!bundle.witness||!verification.witnessHash)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle omitted its authenticated external assertion witness'); - const beforeWitness=validateStoredAssertionWitnessReceipt(bundle.witness.before),afterWitness=validateStoredAssertionWitnessReceipt(bundle.witness.after),stable=(binding:AssertionWitnessBinding)=>{const {nonce:_,issuedAt:__,expiresAt:___,...rest}=binding;return rest;},assertionHash=sha256(canonical({legitimate:request.legitimate,security:request.security})); - if(assertionWitnessPairHash({before:beforeWitness,after:afterWitness})!==verification.witnessHash||beforeWitness.publicKey!==afterWitness.publicKey||beforeWitness.keyId!==afterWitness.keyId||beforeWitness.binding.nonce===afterWitness.binding.nonce)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle assertion witness identity is invalid'); - for(const [phase,receipt,observation,sourceHash,dependencyHash,configurationHash] of [['before',beforeWitness,verification.before,verification.beforeSourceHash,verification.beforeDependencies,verification.beforeConfiguration],['after',afterWitness,verification.after,verification.afterSourceHash,verification.afterDependencies,verification.afterConfiguration]] as const){ - const binding=stable(receipt.binding);if(binding.phase!==phase||binding.runId!==verification.runId||binding.findingId!==verification.findingId||binding.policyHash!==verification.policyHash||binding.auditPolicyHash!==verification.auditPolicyHash||binding.runtime.image!==verification.runtime.image||binding.runtime.verifierImage!==verification.runtime.image||binding.runtime.platform!==verification.runtime.platform||binding.runtime.profile!==verification.runtime.profile||binding.runner.testToolchain!==verification.testToolchain||binding.runner.startPlanHash!==verification.startPlanHash||binding.runner.testPlanHash!==verification.testPlanHash||binding.sourceHash!==sourceHash||binding.dependencyHash!==dependencyHash||binding.configurationHash!==configurationHash||binding.requestHash!==verification.requestHash||binding.patchHash!==verification.patchHash||binding.harnessHash!==verification.harnessHash||binding.assertionHash!==assertionHash||binding.fixturesHash!==verification.fixturesHash||receipt.observationHash!==witnessObservationHash(observation)||receipt.diagnosticTestsPassed!==observation.existingTests||!receipt.externalAssertionsPassed)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle assertion witness does not bind its verification manifest'); - } - if(canonical(beforeWitness.binding.runner)!==canonical(afterWitness.binding.runner))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle changed the witnessed runner between phases'); - if(required.dependencyClosures){const hashes=new Set();for(const phase of ['before','after'] as const){const closure=required.dependencyClosures[phase] as any;if(!closure||typeof closure!=='object'||Array.isArray(closure)||!Array.isArray(closure.archives)||!/^([a-f0-9]{64})$/.test(closure.closureHash??''))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle dependency closure is malformed');const {closureHash,...body}=closure;if(sha256(canonical(body))!==closureHash)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle dependency closure identity is invalid');for(const archive of closure.archives){if(!archive||typeof archive!=='object'||!/^[a-f0-9]{64}$/.test(archive.sha256??''))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle dependency archive provenance is malformed');hashes.add(archive.sha256);}}if(canonical([...hashes].sort())!==canonical([...(required.archives??[])].sort()))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle archive hashes do not match its dependency closures');} - if(required.dependencyClosures&&!bundle.preparation)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle omitted prepared-source invariance proofs'); - if(verification.testToolchain==='project'&&!bundle.preparation)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle omitted project test-toolchain preparation proofs'); - if(bundle.preparation){if(!verification.preparationHash||sha256(canonical(bundle.preparation))!==verification.preparationHash||canonical(bundle.preparation.before?.transformations)!==canonical(bundle.preparation.after?.transformations)||bundle.preparation.before?.databaseHash!==bundle.preparation.after?.databaseHash)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle preparation proof is invalid');for(const phase of ['before','after'] as const){const proof=bundle.preparation[phase] as PreparationProof,closure=(required.dependencyClosures as any)?.[phase];if(!proof||proof.schemaVersion!==1||![proof.dependencyClosureHash,proof.configurationHash,proof.sourceProjectionHash,proof.preparedManifestHash,proof.preparedDependencyHash,proof.receiptHash,proof.executionEnvironmentHash,proof.databaseHash].every(value=>/^[a-f0-9]{64}$/.test(value))||!Array.isArray(proof.transformations)||proof.transformations.some(item=>!item||typeof item.path!=='string'||!/^[a-f0-9]{64}$/.test(item.sha256)||!Number.isInteger(item.mode)||typeof item.reason!=='string')||(closure&&proof.dependencyClosureHash!==closure.closureHash))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle preparation proof does not bind its dependency closure');}} - if(verification.testToolchain==='project'&&bundle.preparation!.before.preparedDependencyHash!==bundle.preparation!.after.preparedDependencyHash)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle project test toolchain changed between source phases'); - if(request.review.artifactId){if(!bundle.reviewArtifact)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle omitted its independent review artifact');validateReviewArtifact(bundle.reviewArtifact,bundle.runId,request);} - validateVerificationObservation(verification.before);validateVerificationObservation(verification.after); - if(sourceRoot){ - const references=[...request.boundaryFiles,...request.testFiles,...request.changes.map(change=>change.path),...request.start.args,...request.existingTests.flatMap(command=>command.args)],hasHandles=references.some(reference=>Boolean(snapshotPathHandleId(reference)||reference.startsWith('./')&&snapshotPathHandleId(reference.slice(2)))); - if(hasHandles&&!sourceManifest)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle path handles require the matching snapshot manifest for source validation'); - const executionRequest=sourceManifest?resolveVerificationRequestPaths(sourceManifest,request):request; - if(verificationHarnessHash(executionRequest,sourceRoot)!==verification.harnessHash)throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle harness does not match supplied source inputs'); - const commandsHash=sha256(canonical(executionRequest.existingTests)),commandHashes=executionRequest.existingTests.map(command=>sha256(canonical(command)));if(beforeWitness.binding.runner.commandsHash!==commandsHash||canonical(beforeWitness.executions.map(item=>item.commandHash))!==canonical(commandHashes)||canonical(afterWitness.executions.map(item=>item.commandHash))!==canonical(commandHashes))throw new CsoError('INCOMPATIBLE_INPUT','Repair bundle witnessed a different test runner command set'); - } - return{...bundle,request}; -} - -export class DockerVerificationExecutor implements VerificationExecutor{ - private attemptDeadline:number;private executionStarted=false; - constructor(private endpoint:DockerEndpoint,private watchdogPath:string,private runExecutionDeadline=Date.now()+300_000,private onExecutionStarted?:()=>void|Promise){this.attemptDeadline=Math.min(Date.now()+300_000,runExecutionDeadline);} - async observe(source:string,phase:'before'|'after',request:VerificationRequest,runtime:QualifiedRuntime,verifier:QualifiedRuntime,work:string,control:string,execution?:{environment:Record;database?:PreparedDatabaseContract},testEvidence?:{minimumPassingTests:number[]},witness?:AssertionWitnessHandle):Promise{ - const phaseDir=secureDirectory(join(work,phase)),phaseControl=secureDirectory(join(control,phase)),policy=secureDirectory(join(phaseDir,'policy')),policyFile=join(policy,'verification.json'),fixtures=secureDirectory(join(phaseDir,'fixtures')); - const verifierPolicy=JSON.stringify({phase,port:request.port,legitimate:request.legitimate,security:request.security}); - if(redact(verifierPolicy)!==verifierPolicy)throw new CsoError('REDACTION_FAILED','Verification harness contains secret-bearing data');fs.writeFileSync(policyFile,verifierPolicy,{mode:0o600}); - for(const [path,body] of Object.entries(request.fixtures)){const file=containedFile(fixtures,path);secureDirectory(dirname(file));fs.writeFileSync(file,body,{mode:0o600});} - const deadline=this.attemptDeadline;if(deadline<=Date.now())throw new CsoError('DEADLINE','No execution time remains before the reporting reserve');let group:DockerGroup|undefined; - try{ - group=await DockerGroup.create(this.endpoint,`${request.findingId.slice(0,12)}-${phase}-${Date.now()}-${randomBytes(6).toString('hex')}`,phaseControl,deadline,verifier.image,this.watchdogPath); - if(!this.executionStarted){this.executionStarted=true;await this.onExecutionStarted?.();} - const supplied=execution?.environment??{},allowed=new Set(['PATH','VIRTUAL_ENV','PYTHONNOUSERSITE','BUNDLE_PATH','BUNDLE_FROZEN','BUNDLE_DEPLOYMENT','BUNDLE_DISABLE_SHARED_GEMS','BUNDLE_IGNORE_CONFIG','BUNDLE_ALLOW_OFFLINE_INSTALL','BUNDLE_CACHE_PATH','BUNDLE_USER_HOME','GEM_HOME','GEM_PATH']);if(Object.entries(supplied).some(([key,value])=>!allowed.has(key)||typeof value!=='string'||value.includes('\0')))throw new CsoError('ISOLATION_FAILED','Prepared execution environment exceeded its fixed allowlist'); - const env={...supplied,PORT:String(request.port),HOST:'127.0.0.1',NODE_ENV:'test',RAILS_ENV:'test',RACK_ENV:'test',PYTHONUNBUFFERED:'1',CI:'1',SECRET_KEY_BASE:'cso-synthetic-test-key',CSO_FIXTURES:'/fixtures'}; - const database=execution?.database; - if(database&&runtime.stack!=='rails')throw new CsoError('INCOMPATIBLE_INPUT','Prepared database contract can only execute with Rails'); - if(runtime.stack==='rails'&&!database)throw new CsoError('PREREQUISITE','Rails verification omitted its prepared database contract'); - if(database?.adapter==='postgresql'){ - const databaseFile=join(policy,'postgresql.databases'),names=database.connections.map(name=>`cso_${name}`); - if(!names.length||names.some(name=>!/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name)))throw new CsoError('INCOMPATIBLE_INPUT','Prepared PostgreSQL connection names are invalid'); - fs.writeFileSync(databaseFile,names.join('\n')+'\n',{mode:0o444,flag:'wx'}); - const postgres=await group.createContainer({role:'postgres',image:database.sidecar.image, - command:['/opt/cso/run-postgresql','/policy/postgresql.databases'],postgresDatabasePolicy:databaseFile}); - await group.start(postgres);let ready=false; - for(let attempt=0;attempt<100&&!ready;attempt++){ - const checked=await group.execCapture(postgres,['/opt/cso/postgresql-ready','/policy/postgresql.databases']); - ready=checked.code===0;if(!ready)await new Promise(resolve=>setTimeout(resolve,50)); - } - if(!ready)throw new CsoError('TOOL_FAILED','Disposable PostgreSQL did not create and accept connections for every declared Rails database'); - } - const cleanCommand=(command:string[])=>['/usr/bin/env','-i',...Object.entries(env).sort(([a],[b])=>a.localeCompare(b)).map(([key,value])=>`${key}=${value}`),...command]; - const dbPrepare=async(id:string)=>{ - const result=await group!.execCapture(id,cleanCommand(['/usr/local/bin/bundle','exec','rails','db:prepare']),{workdir:'/work'}); - if(result.code!==0)throw new CsoError('TOOL_FAILED','Rails database preparation failed in the isolated test environment'); + const transformations = params.manifest.entries.filter((e) => e.transformation), + transformationsHash = sha256(canonical(transformations)), + archivesHash = sha256(canonical([...params.archives].sort())), + requestHash = sha256(canonical(identityRequest)), + preparationHash = params.preparation ? sha256(canonical(params.preparation)) : undefined, + startPlanHash = params.startPlanHash ?? sha256(canonical(request.start)), + testPlanHash = + params.testPlanHash ?? + sha256(canonical({ commands: request.existingTests, files: [...request.testFiles].sort() })), + auditPolicyHash = params.auditPolicyHash ?? sha256(canonical({})), + assertionHash = sha256( + canonical({ legitimate: identityRequest.legitimate, security: identityRequest.security }), + ), + minimumPassingTests = params.minimumPassingTests ?? request.existingTests.map(() => 1), + runner = { + testToolchain: params.testToolchain, + startPlanHash, + testPlanHash, + commandsHash: sha256(canonical(request.existingTests)), + minimumPassingTestsHash: sha256(canonical(minimumPassingTests)), + }; + let assertionAssurance: VerificationManifest['assertionAssurance'], witnessHash: string | undefined; + if (params.witness) { + const beforeReceipt = validateStoredAssertionWitnessReceipt(params.witness.before), + afterReceipt = validateStoredAssertionWitnessReceipt(params.witness.after), + stable = (binding: AssertionWitnessBinding) => { + const { nonce: _, issuedAt: __, expiresAt: ___, ...value } = binding; + return value; + }, + expected = ( + phase: 'before' | 'after', + sourceHash: string, + dependencyHash: string, + configurationHash: string, + ) => ({ + schemaVersion: 1, + protocol: 'gstack-cso-assertion-witness-v1', + phase, + runId: params.runId, + findingId: identityRequest.findingId, + policyHash: params.policyHash, + auditPolicyHash, + runtime: { + image: params.runtime.image, + verifierImage: params.verifier.image, + platform: params.runtime.platform, + profile: params.runtime.id, + }, + runner, + sourceHash, + dependencyHash, + configurationHash, + requestHash, + patchHash: pHash, + harnessHash, + assertionHash, + fixturesHash, + }); + if ( + canonical(stable(beforeReceipt.binding)) !== + canonical( + expected('before', params.manifest.executionHash, dependenciesBefore, configurationBefore), + ) || + canonical(stable(afterReceipt.binding)) !== + canonical(expected('after', sourceAfter, dependenciesAfter, configurationAfter)) || + beforeReceipt.publicKey !== afterReceipt.publicKey || + beforeReceipt.keyId !== afterReceipt.keyId || + beforeReceipt.binding.nonce === afterReceipt.binding.nonce + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Authenticated assertion witness receipts do not bind this verification', + ); + const beforeObservation = { + ...params.before, + existingTests: beforeReceipt.diagnosticTestsPassed, + inputHash: harnessHash, + }, + afterObservation = { + ...params.after, + existingTests: afterReceipt.diagnosticTestsPassed, + inputHash: harnessHash, }; - let app:string; - if(runtime.stack==='rails'){ - app=await group.createContainer({role:'app',image:runtime.image,source,env,command:['/opt/cso/run-app','/bin/sleep','2147483647'],readonlyDirectories:[{host:fixtures,container:'/fixtures'}]}); - await group.start(app);await dbPrepare(app);await group.execDetached(app,[request.start.executable,...request.start.args]); - }else{ - app=await group.createContainer({role:'app',image:runtime.image,source,env,command:['/opt/cso/run-app',request.start.executable,...request.start.args],readonlyDirectories:[{host:fixtures,container:'/fixtures'}]});await group.start(app); + if ( + beforeReceipt.observationHash !== witnessObservationHash(beforeObservation) || + afterReceipt.observationHash !== witnessObservationHash(afterObservation) || + params.before.existingTests !== beforeReceipt.diagnosticTestsPassed || + params.after.existingTests !== afterReceipt.diagnosticTestsPassed || + !beforeReceipt.externalAssertionsPassed || + !afterReceipt.externalAssertionsPassed + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Authenticated assertion witness receipts do not match the verifier observations', + ); + const stableExecutions = (receipt: AssertionWitnessReceipt) => + receipt.executions.map(({ outputHash: _, ...execution }) => execution); + if (canonical(stableExecutions(beforeReceipt)) !== canonical(stableExecutions(afterReceipt))) + throw new CsoError('ASSERTION_FAILED', 'Repair changed the existing-test execution count or outcome'); + assertionAssurance = 'authenticated_out_of_process'; + witnessHash = assertionWitnessPairHash({ before: beforeReceipt, after: afterReceipt }); + } + const passed = mechanical && reviewGate && assertionAssurance === 'authenticated_out_of_process', + preservationUnattested = mechanical && reviewGate && !passed, + createdAt = new Date().toISOString(), + verification: VerificationManifest = { + version: 3, + id: '', + runId: params.runId, + findingId: identityRequest.findingId, + createdAt, + helperAbi: 3, + runtime: { image: params.runtime.image, platform: params.runtime.platform, profile: params.runtime.id }, + testToolchain: params.testToolchain, + policyHash: params.policyHash, + auditPolicyHash, + harnessHash, + requestHash, + startPlanHash, + testPlanHash, + fixturesHash, + patchHash: pHash, + originalSourceHash: params.manifest.originalHash, + transformationsHash, + archivesHash, + ...(preparationHash ? { preparationHash } : {}), + beforeSourceHash: params.manifest.executionHash, + afterSourceHash: sourceAfter, + beforeDependencies: dependenciesBefore, + afterDependencies: dependenciesAfter, + beforeConfiguration: configurationBefore, + afterConfiguration: configurationAfter, + before: { ...params.before, inputHash: harnessHash }, + after: { ...params.after, inputHash: harnessHash }, + review: identityRequest.review, + reviewAssurance, + ...(assertionAssurance ? { assertionAssurance } : {}), + testCompletionAssurance, + ...(witnessHash ? { witnessHash } : {}), + result: passed ? 'runtime_tested' : inconclusive || preservationUnattested ? 'inconclusive' : 'failed', + }; + const id = verificationIdentity(verification); + verification.id = id; + if (!['runtime_tested', 'tested'].includes(verification.result)) return { manifest: verification }; + const bundle: RepairBundle = { + schemaVersion: 3, + runId: params.runId, + id, + createdAt, + expiresAt: new Date(Date.parse(createdAt) + 30 * 86400_000).toISOString(), + requiredInputs: { + sourceHash: params.manifest.executionHash, + originalHash: params.manifest.originalHash, + runtimeImage: params.runtime.image, + platform: params.runtime.platform, + archives: params.archives, + ...(params.dependencyClosures ? { dependencyClosures: params.dependencyClosures } : {}), + }, + request: identityRequest, + verification, + transformations, + ...(params.preparation ? { preparation: params.preparation } : {}), + ...(params.reviewArtifact ? { reviewArtifact: params.reviewArtifact } : {}), + witness: params.witness!, + }; + return { manifest: verification, bundle }; +} + +export function validateRepairBundle( + value: unknown, + id: string, + sourceRoot?: string, + sourceManifest?: SnapshotManifest, +): RepairBundle { + const bundle = value as RepairBundle; + if ( + !bundle || + bundle.schemaVersion !== 3 || + bundle.id !== id || + bundle.runId !== bundle.verification?.runId || + bundle.verification?.id !== id || + verificationIdentity(bundle.verification) !== id + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle identity or verification provenance is invalid'); + const request = validateVerificationRequest(bundle.request), + verification = bundle.verification, + required = bundle.requiredInputs; + const createdAtMs = Date.parse(bundle.createdAt); + if ( + !Number.isFinite(createdAtMs) || + new Date(createdAtMs).toISOString() !== bundle.createdAt || + bundle.createdAt !== verification.createdAt || + bundle.expiresAt !== new Date(createdAtMs + 30 * 86400_000).toISOString() + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle retention timestamps do not match their authenticated verification time', + ); + if ( + !['runtime_tested', 'tested'].includes(verification.result) || + !['self_attested', 'host_verified'].includes(verification.reviewAssurance) || + verification.assertionAssurance !== 'authenticated_out_of_process' || + (verification.result === 'tested' && + verification.testCompletionAssurance !== 'authenticated_out_of_process') || + (verification.result === 'runtime_tested' && verification.testCompletionAssurance !== 'self_reported') + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle lacks the assurance required by its repair label', + ); + if (!['runtime', 'project'].includes(verification.testToolchain)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle test toolchain provenance is invalid'); + if ( + request.findingId !== verification.findingId || + request.runtimeProfile !== verification.runtime.profile || + sha256(canonical(request)) !== verification.requestHash || + canonical(request.review) !== canonical(verification.review) || + patchHash(request) !== verification.patchHash || + ![verification.requestHash, verification.startPlanHash, verification.testPlanHash].every((value) => + /^[a-f0-9]{64}$/.test(value), + ) || + sha256(canonical(request.fixtures)) !== verification.fixturesHash || + required.sourceHash !== verification.beforeSourceHash || + required.originalHash !== verification.originalSourceHash || + required.runtimeImage !== verification.runtime.image || + required.platform !== verification.runtime.platform || + sha256(canonical([...(required.archives ?? [])].sort())) !== verification.archivesHash || + sha256(canonical(bundle.transformations ?? [])) !== verification.transformationsHash + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle request, harness, or contents do not match their authenticated manifest', + ); + if (!bundle.witness || !verification.witnessHash) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle omitted its authenticated external assertion witness', + ); + const beforeWitness = validateStoredAssertionWitnessReceipt(bundle.witness.before), + afterWitness = validateStoredAssertionWitnessReceipt(bundle.witness.after), + stable = (binding: AssertionWitnessBinding) => { + const { nonce: _, issuedAt: __, expiresAt: ___, ...rest } = binding; + return rest; + }, + assertionHash = sha256(canonical({ legitimate: request.legitimate, security: request.security })); + if ( + assertionWitnessPairHash({ before: beforeWitness, after: afterWitness }) !== verification.witnessHash || + beforeWitness.publicKey !== afterWitness.publicKey || + beforeWitness.keyId !== afterWitness.keyId || + beforeWitness.binding.nonce === afterWitness.binding.nonce + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle assertion witness identity is invalid'); + for (const [phase, receipt, observation, sourceHash, dependencyHash, configurationHash] of [ + [ + 'before', + beforeWitness, + verification.before, + verification.beforeSourceHash, + verification.beforeDependencies, + verification.beforeConfiguration, + ], + [ + 'after', + afterWitness, + verification.after, + verification.afterSourceHash, + verification.afterDependencies, + verification.afterConfiguration, + ], + ] as const) { + const binding = stable(receipt.binding); + if ( + binding.phase !== phase || + binding.runId !== verification.runId || + binding.findingId !== verification.findingId || + binding.policyHash !== verification.policyHash || + binding.auditPolicyHash !== verification.auditPolicyHash || + binding.runtime.image !== verification.runtime.image || + binding.runtime.verifierImage !== verification.runtime.image || + binding.runtime.platform !== verification.runtime.platform || + binding.runtime.profile !== verification.runtime.profile || + binding.runner.testToolchain !== verification.testToolchain || + binding.runner.startPlanHash !== verification.startPlanHash || + binding.runner.testPlanHash !== verification.testPlanHash || + binding.sourceHash !== sourceHash || + binding.dependencyHash !== dependencyHash || + binding.configurationHash !== configurationHash || + binding.requestHash !== verification.requestHash || + binding.patchHash !== verification.patchHash || + binding.harnessHash !== verification.harnessHash || + binding.assertionHash !== assertionHash || + binding.fixturesHash !== verification.fixturesHash || + receipt.observationHash !== witnessObservationHash(observation) || + receipt.diagnosticTestsPassed !== observation.existingTests || + !receipt.externalAssertionsPassed + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle assertion witness does not bind its verification manifest', + ); + } + if (canonical(beforeWitness.binding.runner) !== canonical(afterWitness.binding.runner)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle changed the witnessed runner between phases'); + if (required.dependencyClosures) { + const hashes = new Set(); + for (const phase of ['before', 'after'] as const) { + const closure = required.dependencyClosures[phase] as any; + if ( + !closure || + typeof closure !== 'object' || + Array.isArray(closure) || + !Array.isArray(closure.archives) || + !/^([a-f0-9]{64})$/.test(closure.closureHash ?? '') + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle dependency closure is malformed'); + const { closureHash, ...body } = closure; + if (sha256(canonical(body)) !== closureHash) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle dependency closure identity is invalid'); + for (const archive of closure.archives) { + if (!archive || typeof archive !== 'object' || !/^[a-f0-9]{64}$/.test(archive.sha256 ?? '')) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle dependency archive provenance is malformed', + ); + hashes.add(archive.sha256); + } + } + if (canonical([...hashes].sort()) !== canonical([...(required.archives ?? [])].sort())) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle archive hashes do not match its dependency closures', + ); + } + if (required.dependencyClosures && !bundle.preparation) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle omitted prepared-source invariance proofs'); + if (verification.testToolchain === 'project' && !bundle.preparation) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle omitted project test-toolchain preparation proofs', + ); + if (bundle.preparation) { + if ( + !verification.preparationHash || + sha256(canonical(bundle.preparation)) !== verification.preparationHash || + canonical(bundle.preparation.before?.transformations) !== + canonical(bundle.preparation.after?.transformations) || + bundle.preparation.before?.databaseHash !== bundle.preparation.after?.databaseHash + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle preparation proof is invalid'); + for (const phase of ['before', 'after'] as const) { + const proof = bundle.preparation[phase] as PreparationProof, + closure = (required.dependencyClosures as any)?.[phase]; + if ( + !proof || + proof.schemaVersion !== 1 || + ![ + proof.dependencyClosureHash, + proof.configurationHash, + proof.sourceProjectionHash, + proof.preparedManifestHash, + proof.preparedDependencyHash, + proof.receiptHash, + proof.executionEnvironmentHash, + proof.databaseHash, + ].every((value) => /^[a-f0-9]{64}$/.test(value)) || + !Array.isArray(proof.transformations) || + proof.transformations.some( + (item) => + !item || + typeof item.path !== 'string' || + !/^[a-f0-9]{64}$/.test(item.sha256) || + !Number.isInteger(item.mode) || + typeof item.reason !== 'string', + ) || + (closure && proof.dependencyClosureHash !== closure.closureHash) + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle preparation proof does not bind its dependency closure', + ); + } + } + if ( + verification.testToolchain === 'project' && + bundle.preparation!.before.preparedDependencyHash !== bundle.preparation!.after.preparedDependencyHash + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle project test toolchain changed between source phases', + ); + if (request.review.artifactId) { + if (!bundle.reviewArtifact) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle omitted its independent review artifact'); + validateReviewArtifact(bundle.reviewArtifact, bundle.runId, request); + } + validateVerificationObservation(verification.before); + validateVerificationObservation(verification.after); + if (sourceRoot) { + const references = [ + ...request.boundaryFiles, + ...request.testFiles, + ...request.changes.map((change) => change.path), + ...request.start.args, + ...request.existingTests.flatMap((command) => command.args), + ], + hasHandles = references.some((reference) => + Boolean( + snapshotPathHandleId(reference) || + (reference.startsWith('./') && snapshotPathHandleId(reference.slice(2))), + ), + ); + if (hasHandles && !sourceManifest) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Repair bundle path handles require the matching snapshot manifest for source validation', + ); + const executionRequest = sourceManifest + ? resolveVerificationRequestPaths(sourceManifest, request) + : request; + if (verificationHarnessHash(executionRequest, sourceRoot) !== verification.harnessHash) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle harness does not match supplied source inputs'); + const commandsHash = sha256(canonical(executionRequest.existingTests)), + commandHashes = executionRequest.existingTests.map((command) => sha256(canonical(command))); + if ( + beforeWitness.binding.runner.commandsHash !== commandsHash || + canonical(beforeWitness.executions.map((item) => item.commandHash)) !== canonical(commandHashes) || + canonical(afterWitness.executions.map((item) => item.commandHash)) !== canonical(commandHashes) + ) + throw new CsoError('INCOMPATIBLE_INPUT', 'Repair bundle witnessed a different test runner command set'); + } + return { ...bundle, request }; +} + +export class DockerVerificationExecutor implements VerificationExecutor { + private attemptDeadline: number; + private executionStarted = false; + constructor( + private endpoint: DockerEndpoint, + private watchdogPath: string, + private runExecutionDeadline = Date.now() + 300_000, + private onExecutionStarted?: () => void | Promise, + ) { + this.attemptDeadline = Math.min(Date.now() + 300_000, runExecutionDeadline); + } + async observe( + source: string, + phase: 'before' | 'after', + request: VerificationRequest, + runtime: QualifiedRuntime, + verifier: QualifiedRuntime, + work: string, + control: string, + execution?: { environment: Record; database?: PreparedDatabaseContract }, + testEvidence?: { minimumPassingTests: number[] }, + witness?: AssertionWitnessHandle, + ): Promise { + const phaseDir = secureDirectory(join(work, phase)), + phaseControl = secureDirectory(join(control, phase)), + policy = secureDirectory(join(phaseDir, 'policy')), + policyFile = join(policy, 'verification.json'), + fixtures = secureDirectory(join(phaseDir, 'fixtures')); + const verifierPolicy = JSON.stringify({ + phase, + port: request.port, + legitimate: request.legitimate, + security: request.security, + }); + if (redact(verifierPolicy) !== verifierPolicy) + throw new CsoError('REDACTION_FAILED', 'Verification harness contains secret-bearing data'); + fs.writeFileSync(policyFile, verifierPolicy, { mode: 0o600 }); + for (const [path, body] of Object.entries(request.fixtures)) { + const file = containedFile(fixtures, path); + secureDirectory(dirname(file)); + fs.writeFileSync(file, body, { mode: 0o600 }); + } + const deadline = this.attemptDeadline; + if (deadline <= Date.now()) + throw new CsoError('DEADLINE', 'No execution time remains before the reporting reserve'); + let group: DockerGroup | undefined; + try { + group = await DockerGroup.create( + this.endpoint, + `${request.findingId.slice(0, 12)}-${phase}-${Date.now()}-${randomBytes(6).toString('hex')}`, + phaseControl, + deadline, + verifier.image, + this.watchdogPath, + ); + if (!this.executionStarted) { + this.executionStarted = true; + await this.onExecutionStarted?.(); + } + const supplied = execution?.environment ?? {}, + allowed = new Set([ + 'PATH', + 'VIRTUAL_ENV', + 'PYTHONNOUSERSITE', + 'BUNDLE_PATH', + 'BUNDLE_FROZEN', + 'BUNDLE_DEPLOYMENT', + 'BUNDLE_DISABLE_SHARED_GEMS', + 'BUNDLE_IGNORE_CONFIG', + 'BUNDLE_ALLOW_OFFLINE_INSTALL', + 'BUNDLE_CACHE_PATH', + 'BUNDLE_USER_HOME', + 'GEM_HOME', + 'GEM_PATH', + ]); + if ( + Object.entries(supplied).some( + ([key, value]) => !allowed.has(key) || typeof value !== 'string' || value.includes('\0'), + ) + ) + throw new CsoError('ISOLATION_FAILED', 'Prepared execution environment exceeded its fixed allowlist'); + const env = { + ...supplied, + PORT: String(request.port), + HOST: '127.0.0.1', + NODE_ENV: 'test', + RAILS_ENV: 'test', + RACK_ENV: 'test', + PYTHONUNBUFFERED: '1', + CI: '1', + SECRET_KEY_BASE: 'cso-synthetic-test-key', + CSO_FIXTURES: '/fixtures', + }; + const database = execution?.database; + if (database && runtime.stack !== 'rails') + throw new CsoError('INCOMPATIBLE_INPUT', 'Prepared database contract can only execute with Rails'); + if (runtime.stack === 'rails' && !database) + throw new CsoError('PREREQUISITE', 'Rails verification omitted its prepared database contract'); + if (database?.adapter === 'postgresql') { + const databaseFile = join(policy, 'postgresql.databases'), + names = database.connections.map((name) => `cso_${name}`); + if (!names.length || names.some((name) => !/^cso_[A-Za-z_][A-Za-z0-9_]{0,47}$/.test(name))) + throw new CsoError('INCOMPATIBLE_INPUT', 'Prepared PostgreSQL connection names are invalid'); + fs.writeFileSync(databaseFile, names.join('\n') + '\n', { mode: 0o444, flag: 'wx' }); + const postgres = await group.createContainer({ + role: 'postgres', + image: database.sidecar.image, + command: ['/opt/cso/run-postgresql', '/policy/postgresql.databases'], + postgresDatabasePolicy: databaseFile, + }); + await group.start(postgres); + let ready = false; + for (let attempt = 0; attempt < 100 && !ready; attempt++) { + const checked = await group.execCapture(postgres, [ + '/opt/cso/postgresql-ready', + '/policy/postgresql.databases', + ]); + ready = checked.code === 0; + if (!ready) await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (!ready) + throw new CsoError( + 'TOOL_FAILED', + 'Disposable PostgreSQL did not create and accept connections for every declared Rails database', + ); + } + const cleanCommand = (command: string[]) => [ + '/usr/bin/env', + '-i', + ...Object.entries(env) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`), + ...command, + ]; + const dbPrepare = async (id: string) => { + const result = await group!.execCapture( + id, + cleanCommand(['/usr/local/bin/bundle', 'exec', 'rails', 'db:prepare']), + { workdir: '/work' }, + ); + if (result.code !== 0) + throw new CsoError( + 'TOOL_FAILED', + 'Rails database preparation failed in the isolated test environment', + ); + }; + let app: string; + if (runtime.stack === 'rails') { + app = await group.createContainer({ + role: 'app', + image: runtime.image, + source, + env, + command: ['/opt/cso/run-app', '/bin/sleep', '2147483647'], + readonlyDirectories: [{ host: fixtures, container: '/fixtures' }], + }); + await group.start(app); + await dbPrepare(app); + await group.execDetached(app, [request.start.executable, ...request.start.args]); + } else { + app = await group.createContainer({ + role: 'app', + image: runtime.image, + source, + env, + command: ['/opt/cso/run-app', request.start.executable, ...request.start.args], + readonlyDirectories: [{ host: fixtures, container: '/fixtures' }], + }); + await group.start(app); + } + const verifierId = await group.createContainer({ + role: 'verifier', + image: verifier.image, + command: ['/opt/cso/verifier', '/policy/verification.json'], + readonlyFiles: [{ host: policyFile, container: '/policy/verification.json' }], + }); + const result = await group.startAttach(verifierId); + let observation: VerificationObservation; + try { + observation = validateVerificationObservation(JSON.parse(result.output.trim())); + } catch { + observation = { + booted: false, + legitimate: false, + security: 'inconclusive', + existingTests: false, + output: 'verifier returned invalid bounded output', + inputHash: '', + }; } - const verifierId=await group.createContainer({role:'verifier',image:verifier.image,command:['/opt/cso/verifier','/policy/verification.json'],readonlyFiles:[{host:policyFile,container:'/policy/verification.json'}]}); - const result=await group.startAttach(verifierId);let observation:VerificationObservation; - try{observation=validateVerificationObservation(JSON.parse(result.output.trim()));}catch{observation={booted:false,legitimate:false,security:'inconclusive',existingTests:false,output:'verifier returned invalid bounded output',inputHash:''};} await group.removeContainer(verifierId); await group.removeContainer(app); - const minimumPassingTests=testEvidence?.minimumPassingTests??request.existingTests.map(()=>1);if(minimumPassingTests.length!==request.existingTests.length||minimumPassingTests.some(value=>!Number.isInteger(value)||value<1))throw new CsoError('INCOMPATIBLE_INPUT','Helper-derived test execution-count floors do not match the canonical test commands'); - let existingTests=true;const executions:Array<{command:VerificationRequest['existingTests'][number];code:number;output:string;minimumPassingTests:number}>=[];for(const [index,test] of request.existingTests.entries()){ - const rails=runtime.stack==='rails'; - const testId=await group.createContainer({role:'tests',image:runtime.image,source,env, - command:rails?['/opt/cso/run-app','/bin/sleep','2147483647']:['/opt/cso/run-app',test.executable,...test.args],readonlyDirectories:[{host:fixtures,container:'/fixtures'}]}); - let testResult:{code:number;output:string}; - if(rails){await group.start(testId);await dbPrepare(testId);const result=await group.execCapture(testId,cleanCommand([test.executable,...test.args]),{workdir:'/work'});testResult={code:result.code,output:result.stdout+result.stderr};} - else testResult=await group.startAttach(testId); - executions.push({command:test,code:testResult.code,output:testResult.output,minimumPassingTests:minimumPassingTests[index]});if(!testExecutionPassed(test,testResult.code,testResult.output,minimumPassingTests[index]))existingTests=false;await group.removeContainer(testId); + const minimumPassingTests = testEvidence?.minimumPassingTests ?? request.existingTests.map(() => 1); + if ( + minimumPassingTests.length !== request.existingTests.length || + minimumPassingTests.some((value) => !Number.isInteger(value) || value < 1) + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Helper-derived test execution-count floors do not match the canonical test commands', + ); + let existingTests = true; + const executions: Array<{ + command: VerificationRequest['existingTests'][number]; + code: number; + output: string; + minimumPassingTests: number; + }> = []; + for (const [index, test] of request.existingTests.entries()) { + const rails = runtime.stack === 'rails'; + const testId = await group.createContainer({ + role: 'tests', + image: runtime.image, + source, + env, + command: rails + ? ['/opt/cso/run-app', '/bin/sleep', '2147483647'] + : ['/opt/cso/run-app', test.executable, ...test.args], + readonlyDirectories: [{ host: fixtures, container: '/fixtures' }], + }); + let testResult: { code: number; output: string }; + if (rails) { + await group.start(testId); + await dbPrepare(testId); + const result = await group.execCapture(testId, cleanCommand([test.executable, ...test.args]), { + workdir: '/work', + }); + testResult = { code: result.code, output: result.stdout + result.stderr }; + } else testResult = await group.startAttach(testId); + executions.push({ + command: test, + code: testResult.code, + output: testResult.output, + minimumPassingTests: minimumPassingTests[index], + }); + if (!testExecutionPassed(test, testResult.code, testResult.output, minimumPassingTests[index])) + existingTests = false; + await group.removeContainer(testId); } - if(witness){if(observation.existingTests)throw new CsoError('INCOMPATIBLE_INPUT','External verifier attempted to assert project test completion');const receipt=await witness.attest(observation,executions),witnessed={...observation,existingTests:receipt.diagnosticTestsPassed,inputHash:witness.binding.harnessHash};return{observation:witnessed,witness:receipt};} - observation.existingTests=existingTests;return observation; - }finally{if(group)await group.cleanup();} + if (witness) { + if (observation.existingTests) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'External verifier attempted to assert project test completion', + ); + const receipt = await witness.attest(observation, executions), + witnessed = { + ...observation, + existingTests: receipt.diagnosticTestsPassed, + inputHash: witness.binding.harnessHash, + }; + return { observation: witnessed, witness: receipt }; + } + observation.existingTests = existingTests; + return observation; + } finally { + if (group) await group.cleanup(); + } } } -export async function verifyRepair(params:{runId:string;runDir:string;manifest:SnapshotManifest;rawRequest:unknown;runtime:QualifiedRuntime;verifier:QualifiedRuntime;policyHash:string;auditPolicyHash?:string;archives:string[];dependencyClosures?:{before:unknown;after:unknown};preparation?:{before:PreparationProof;after:PreparationProof};reviewArtifact?:RepairReviewArtifact;executor:VerificationExecutor;persist?:boolean;watchdogPath?:string;attemptDeadline?:number}):Promise<{manifest:VerificationManifest;bundle:RepairBundle}>{ - const identityRequest=validateVerificationRequest(params.rawRequest),request=resolveVerificationRequestPaths(params.manifest,identityRequest),snapshot=join(params.runDir,'snapshot'),work=join(params.runDir,'verification',`${request.findingId}-${Date.now()}-${randomBytes(4).toString('hex')}`),after=join(work,'sources','after'),observations=join(work,'observations'),groupControls=secureDirectory(join(params.runDir,'supervision',basename(work),'docker-groups')); - if(canonical(sanitizeHelperForJson(identityRequest))!==canonical(identityRequest))throw new CsoError('REDACTION_FAILED','Verification request contains sensitive material that cannot enter a replayable bundle'); - if(!['node','bun','python','rails'].includes(params.runtime.stack))throw new CsoError('INCOMPATIBLE_INPUT','Canonical project tests require an application runtime');const stack=params.runtime.stack as CsoStack,beforeTestPlan=assertCanonicalTestPlan(request,snapshot,stack),beforeStartPlan=assertCanonicalStartPlan(request,snapshot,stack); - if(beforeTestPlan.toolchain==='project'&&request.changes.some(change=>change.effect==='dependency'))throw new CsoError('PREREQUISITE','Runtime-tested dependency repairs require a test runner pinned in the qualified runtime; project-installed test toolchains may change with the repair'); - for(const boundary of [...new Set([...request.boundaryFiles,...request.testFiles,...beforeStartPlan.entrypointFiles])]){const e=params.manifest.entries.find(x=>x.path===boundary);if(!e||e.transformation)throw new CsoError('INCOMPATIBLE_INPUT',`Snapshot transformation changes or withholds a verification input: ${boundary}`);} - for(const change of request.changes){const path=relativePath(change.path),entry=params.manifest.entries.find(x=>x.path===path);containedFile(snapshot,path);if(change.beforeSha256===null){if(entry)throw new CsoError('INCOMPATIBLE_INPUT',`Declared new repair path already exists in the snapshot: ${path}`);}else if(!entry||entry.transformation)throw new CsoError('INCOMPATIBLE_INPUT',`Snapshot transformation changes or withholds a repair input: ${path}`);} - let guardedCleanup:(()=>Promise)|undefined;if(params.watchdogPath){const deadline=Math.min(params.attemptDeadline??Date.now()+300_000,Date.now()+300_000);guardedCleanup=await attemptGuard(params.runDir,work,params.watchdogPath,deadline);}secureDirectory(observations); - let certified:ReturnType|undefined,beforeObs:VerificationObservation|undefined,afterObs:VerificationObservation|undefined,beforeWitness:AssertionWitnessReceipt|undefined,afterWitness:AssertionWitnessReceipt|undefined,failure:unknown,missingExternalWitness=false; - try{ - preparePatchedSource(snapshot,after,request); - const afterTestPlan=canonicalTestPlan(after,stack),afterStartPlan=canonicalStartPlan(after,stack,request.port);if(canonical(afterTestPlan)!==canonical(beforeTestPlan))throw new CsoError('ASSERTION_FAILED','Repair changed the canonical project test suite, runner configuration, or discovered test inputs'); - const startShape=(plan:CanonicalStartPlan)=>({command:plan.command,kind:plan.kind,entrypointFiles:plan.entrypointFiles}),changedPaths=new Set(request.changes.map(change=>change.path)); - if(canonical(startShape(afterStartPlan))!==canonical(startShape(beforeStartPlan))||(afterStartPlan.signature!==beforeStartPlan.signature&&!beforeStartPlan.entrypointFiles.some(path=>changedPaths.has(path))))throw new CsoError('ASSERTION_FAILED','Repair changed the helper-derived application startup plan outside its declared patch'); - const beforeInvariant=treeHash(snapshot),afterInvariant=treeHash(after);if(beforeInvariant!==params.manifest.executionHash)throw new CsoError('INCOMPATIBLE_INPUT','Retained source does not match the snapshot identity bound to this verification'); - const testEvidence={minimumPassingTests:beforeTestPlan.minimumPassingTests},auditPolicyHash=params.auditPolicyHash??sha256(canonical({})),requestHash=sha256(canonical(identityRequest)),pHash=patchHash(identityRequest),harnessHash=verificationHarnessHash(request,snapshot),assertionHash=sha256(canonical({legitimate:identityRequest.legitimate,security:identityRequest.security})),runner={testToolchain:beforeTestPlan.toolchain,startPlanHash:beforeStartPlan.signature,testPlanHash:beforeTestPlan.signature,commandsHash:sha256(canonical(request.existingTests)),minimumPassingTestsHash:sha256(canonical(beforeTestPlan.minimumPassingTests))},session=new AssertionWitnessSession(observations,Math.min(params.attemptDeadline??Date.now()+300_000,Date.now()+300_000)),stable=(phase:'before'|'after',root:string,sourceHash:string):Omit=>({phase,runId:params.runId,findingId:identityRequest.findingId,policyHash:params.policyHash,auditPolicyHash,runtime:{image:params.runtime.image,verifierImage:params.verifier.image,platform:params.runtime.platform,profile:params.runtime.id},runner,sourceHash,dependencyHash:treeHash(root,p=>DEPENDENCY.test(p)),configurationHash:treeHash(root,p=>CONFIG.test(p)&&!DEPENDENCY.test(p)),requestHash,patchHash:pHash,harnessHash,assertionHash,fixturesHash:sha256(canonical(identityRequest.fixtures))}),beforeHandle=session.handle(stable('before',snapshot,beforeInvariant)); - const rawBefore=await params.executor.observe(snapshot,'before',request,params.runtime,params.verifier,observations,groupControls,undefined,testEvidence,beforeHandle),observedBefore='observation'in(rawBefore as any)?validateVerificationObservation((rawBefore as WitnessedVerificationResult).observation):validateVerificationObservation(rawBefore as VerificationObservation); - if('observation'in(rawBefore as any))beforeWitness=beforeHandle.validate((rawBefore as WitnessedVerificationResult).witness,observedBefore); - if(treeHash(snapshot)!==beforeInvariant)throw new CsoError('ASSERTION_FAILED','Verification mutated the retained source snapshot'); - beforeObs=observedBefore; - const afterHandle=session.handle(stable('after',after,afterInvariant)),rawAfter=await params.executor.observe(after,'after',request,params.runtime,params.verifier,observations,groupControls,undefined,testEvidence,afterHandle),observedAfter='observation'in(rawAfter as any)?validateVerificationObservation((rawAfter as WitnessedVerificationResult).observation):validateVerificationObservation(rawAfter as VerificationObservation); - if('observation'in(rawAfter as any))afterWitness=afterHandle.validate((rawAfter as WitnessedVerificationResult).witness,observedAfter); - if(treeHash(after)!==afterInvariant)throw new CsoError('ASSERTION_FAILED','Verification mutated the pristine patched source'); - afterObs=observedAfter; - certified=certify({...params,request,identityRequest,before:beforeObs,after:afterObs,beforeRoot:snapshot,afterRoot:after,startPlanHash:beforeStartPlan.signature,testPlanHash:beforeTestPlan.signature,testToolchain:beforeTestPlan.toolchain,minimumPassingTests:beforeTestPlan.minimumPassingTests,...(beforeWitness&&afterWitness?{witness:{before:beforeWitness,after:afterWitness}}:{})}); - }catch(error){failure=error;} - try{if(guardedCleanup)await guardedCleanup();else fs.rmSync(work,{recursive:true,force:true});}catch(error){failure=error;certified=undefined;missingExternalWitness=false;} - if(!failure&&certified&&!certified.bundle){const manifest=certified.manifest,review=manifest.review;missingExternalWitness=!manifest.assertionAssurance&&manifest.testCompletionAssurance==='self_reported'&&manifest.result==='inconclusive'&&manifest.before.booted&&manifest.before.legitimate&&manifest.before.security==='intended_failure'&&manifest.before.existingTests&&manifest.after.booted&&manifest.after.legitimate&&manifest.after.security==='pass'&&manifest.after.existingTests&&review.independent&&review.rootCauseRepaired&&review.featurePreserved&&!review.boundaryMocks;failure=missingExternalWitness?new CsoError('PREREQUISITE','Repair verification retained self-reported project-test diagnostics but requires a helper-authenticated out-of-process external assertion witness before a runtime-tested bundle can be issued'):new CsoError('ASSERTION_FAILED',manifest.result==='inconclusive'?'Repair verification was inconclusive; no repair bundle was issued':'Repair failed one or more required boot, control, security, existing-test, or review assertions; no repair bundle was issued');} - if(failure){ - if(beforeObs){ - const cause=failure instanceof CsoError?failure:new CsoError('ASSERTION_FAILED','Repair validation failed after the before-phase observation'); - const reproduction=!beforeObs.booted?'blocked':!beforeObs.legitimate?'inconclusive':beforeObs.security==='intended_failure'?'reproduced':beforeObs.security==='pass'?'disproved':'inconclusive',harnessHash=verificationHarnessHash(request,snapshot); - const missingWitness=missingExternalWitness&&cause.code==='PREREQUISITE'&&reproduction==='reproduced'&&afterObs?.booted===true&&afterObs.legitimate===true&&afterObs.security==='pass'&&beforeObs.existingTests&&afterObs.existingTests; - const raw={schemaVersion:3 as const,artifactKind:'repair_candidate' as const,runId:params.runId,findingId:identityRequest.findingId,createdAt:new Date().toISOString(),bundleIssued:false as const,runtime:{image:params.runtime.image,platform:params.runtime.platform,profile:params.runtime.id},policyHash:params.policyHash,requestHash:sha256(canonical(identityRequest)),harnessHash,sourceHash:params.manifest.executionHash,request:identityRequest,patchHash:patchHash(identityRequest),testToolchain:beforeTestPlan.toolchain,testCompletionAssurance:'self_reported' as const,...(params.preparation?{preparationHash:sha256(canonical(params.preparation))}:{}),before:{...beforeObs,inputHash:harnessHash},...(afterObs?{after:{...afterObs,inputHash:harnessHash}}:{}),reproduction,repair:missingWitness?'proposed' as const:'failed' as const,failure:{code:cause.code,message:cause.message}},safe=sanitizeHelperForJson(raw) as Omit,id=sha256(canonical(safe)).slice(0,32),attempt:FailedVerificationAttempt={...safe,id}; - validateVerificationObservation(attempt.before);if(attempt.after)validateVerificationObservation(attempt.after);if(params.persist!==false)writeJsonExclusive(join(params.runDir,'verification-attempts',`${id}.json`),attempt); - throw new VerificationAttemptError(cause,attempt); +export async function verifyRepair(params: { + runId: string; + runDir: string; + manifest: SnapshotManifest; + rawRequest: unknown; + runtime: QualifiedRuntime; + verifier: QualifiedRuntime; + policyHash: string; + auditPolicyHash?: string; + archives: string[]; + dependencyClosures?: { before: unknown; after: unknown }; + preparation?: { before: PreparationProof; after: PreparationProof }; + reviewArtifact?: RepairReviewArtifact; + executor: VerificationExecutor; + persist?: boolean; + watchdogPath?: string; + attemptDeadline?: number; +}): Promise<{ manifest: VerificationManifest; bundle: RepairBundle }> { + const identityRequest = validateVerificationRequest(params.rawRequest), + request = resolveVerificationRequestPaths(params.manifest, identityRequest), + snapshot = join(params.runDir, 'snapshot'), + work = join( + params.runDir, + 'verification', + `${request.findingId}-${Date.now()}-${randomBytes(4).toString('hex')}`, + ), + after = join(work, 'sources', 'after'), + observations = join(work, 'observations'), + groupControls = secureDirectory(join(params.runDir, 'supervision', basename(work), 'docker-groups')); + if (canonical(sanitizeHelperForJson(identityRequest)) !== canonical(identityRequest)) + throw new CsoError( + 'REDACTION_FAILED', + 'Verification request contains sensitive material that cannot enter a replayable bundle', + ); + if (!['node', 'bun', 'python', 'rails'].includes(params.runtime.stack)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Canonical project tests require an application runtime'); + const stack = params.runtime.stack as CsoStack, + beforeTestPlan = assertCanonicalTestPlan(request, snapshot, stack), + beforeStartPlan = assertCanonicalStartPlan(request, snapshot, stack); + if ( + beforeTestPlan.toolchain === 'project' && + request.changes.some((change) => change.effect === 'dependency') + ) + throw new CsoError( + 'PREREQUISITE', + 'Runtime-tested dependency repairs require a test runner pinned in the qualified runtime; project-installed test toolchains may change with the repair', + ); + for (const boundary of [ + ...new Set([...request.boundaryFiles, ...request.testFiles, ...beforeStartPlan.entrypointFiles]), + ]) { + const e = params.manifest.entries.find((x) => x.path === boundary); + if (!e || e.transformation) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + `Snapshot transformation changes or withholds a verification input: ${boundary}`, + ); + } + for (const change of request.changes) { + const path = relativePath(change.path), + entry = params.manifest.entries.find((x) => x.path === path); + containedFile(snapshot, path); + if (change.beforeSha256 === null) { + if (entry) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + `Declared new repair path already exists in the snapshot: ${path}`, + ); + } else if (!entry || entry.transformation) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + `Snapshot transformation changes or withholds a repair input: ${path}`, + ); + } + let guardedCleanup: (() => Promise) | undefined; + if (params.watchdogPath) { + const deadline = Math.min(params.attemptDeadline ?? Date.now() + 300_000, Date.now() + 300_000); + guardedCleanup = await attemptGuard(params.runDir, work, params.watchdogPath, deadline); + } + secureDirectory(observations); + let certified: ReturnType | undefined, + beforeObs: VerificationObservation | undefined, + afterObs: VerificationObservation | undefined, + beforeWitness: AssertionWitnessReceipt | undefined, + afterWitness: AssertionWitnessReceipt | undefined, + failure: unknown, + missingExternalWitness = false; + try { + preparePatchedSource(snapshot, after, request); + const afterTestPlan = canonicalTestPlan(after, stack), + afterStartPlan = canonicalStartPlan(after, stack, request.port); + if (canonical(afterTestPlan) !== canonical(beforeTestPlan)) + throw new CsoError( + 'ASSERTION_FAILED', + 'Repair changed the canonical project test suite, runner configuration, or discovered test inputs', + ); + const startShape = (plan: CanonicalStartPlan) => ({ + command: plan.command, + kind: plan.kind, + entrypointFiles: plan.entrypointFiles, + }), + changedPaths = new Set(request.changes.map((change) => change.path)); + if ( + canonical(startShape(afterStartPlan)) !== canonical(startShape(beforeStartPlan)) || + (afterStartPlan.signature !== beforeStartPlan.signature && + !beforeStartPlan.entrypointFiles.some((path) => changedPaths.has(path))) + ) + throw new CsoError( + 'ASSERTION_FAILED', + 'Repair changed the helper-derived application startup plan outside its declared patch', + ); + const beforeInvariant = treeHash(snapshot), + afterInvariant = treeHash(after); + if (beforeInvariant !== params.manifest.executionHash) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Retained source does not match the snapshot identity bound to this verification', + ); + const testEvidence = { minimumPassingTests: beforeTestPlan.minimumPassingTests }, + auditPolicyHash = params.auditPolicyHash ?? sha256(canonical({})), + requestHash = sha256(canonical(identityRequest)), + pHash = patchHash(identityRequest), + harnessHash = verificationHarnessHash(request, snapshot), + assertionHash = sha256( + canonical({ legitimate: identityRequest.legitimate, security: identityRequest.security }), + ), + runner = { + testToolchain: beforeTestPlan.toolchain, + startPlanHash: beforeStartPlan.signature, + testPlanHash: beforeTestPlan.signature, + commandsHash: sha256(canonical(request.existingTests)), + minimumPassingTestsHash: sha256(canonical(beforeTestPlan.minimumPassingTests)), + }, + session = new AssertionWitnessSession( + observations, + Math.min(params.attemptDeadline ?? Date.now() + 300_000, Date.now() + 300_000), + ), + stable = ( + phase: 'before' | 'after', + root: string, + sourceHash: string, + ): Omit< + AssertionWitnessBinding, + 'schemaVersion' | 'protocol' | 'nonce' | 'issuedAt' | 'expiresAt' + > => ({ + phase, + runId: params.runId, + findingId: identityRequest.findingId, + policyHash: params.policyHash, + auditPolicyHash, + runtime: { + image: params.runtime.image, + verifierImage: params.verifier.image, + platform: params.runtime.platform, + profile: params.runtime.id, + }, + runner, + sourceHash, + dependencyHash: treeHash(root, (p) => DEPENDENCY.test(p)), + configurationHash: treeHash(root, (p) => CONFIG.test(p) && !DEPENDENCY.test(p)), + requestHash, + patchHash: pHash, + harnessHash, + assertionHash, + fixturesHash: sha256(canonical(identityRequest.fixtures)), + }), + beforeHandle = session.handle(stable('before', snapshot, beforeInvariant)); + const rawBefore = await params.executor.observe( + snapshot, + 'before', + request, + params.runtime, + params.verifier, + observations, + groupControls, + undefined, + testEvidence, + beforeHandle, + ), + observedBefore = + 'observation' in (rawBefore as any) + ? validateVerificationObservation((rawBefore as WitnessedVerificationResult).observation) + : validateVerificationObservation(rawBefore as VerificationObservation); + if ('observation' in (rawBefore as any)) + beforeWitness = beforeHandle.validate( + (rawBefore as WitnessedVerificationResult).witness, + observedBefore, + ); + if (treeHash(snapshot) !== beforeInvariant) + throw new CsoError('ASSERTION_FAILED', 'Verification mutated the retained source snapshot'); + beforeObs = observedBefore; + const afterHandle = session.handle(stable('after', after, afterInvariant)), + rawAfter = await params.executor.observe( + after, + 'after', + request, + params.runtime, + params.verifier, + observations, + groupControls, + undefined, + testEvidence, + afterHandle, + ), + observedAfter = + 'observation' in (rawAfter as any) + ? validateVerificationObservation((rawAfter as WitnessedVerificationResult).observation) + : validateVerificationObservation(rawAfter as VerificationObservation); + if ('observation' in (rawAfter as any)) + afterWitness = afterHandle.validate((rawAfter as WitnessedVerificationResult).witness, observedAfter); + if (treeHash(after) !== afterInvariant) + throw new CsoError('ASSERTION_FAILED', 'Verification mutated the pristine patched source'); + afterObs = observedAfter; + certified = certify({ + ...params, + request, + identityRequest, + before: beforeObs, + after: afterObs, + beforeRoot: snapshot, + afterRoot: after, + startPlanHash: beforeStartPlan.signature, + testPlanHash: beforeTestPlan.signature, + testToolchain: beforeTestPlan.toolchain, + minimumPassingTests: beforeTestPlan.minimumPassingTests, + ...(beforeWitness && afterWitness ? { witness: { before: beforeWitness, after: afterWitness } } : {}), + }); + } catch (error) { + failure = error; + } + try { + if (guardedCleanup) await guardedCleanup(); + else fs.rmSync(work, { recursive: true, force: true }); + } catch (error) { + failure = error; + certified = undefined; + missingExternalWitness = false; + } + if (!failure && certified && !certified.bundle) { + const manifest = certified.manifest, + review = manifest.review; + missingExternalWitness = + !manifest.assertionAssurance && + manifest.testCompletionAssurance === 'self_reported' && + manifest.result === 'inconclusive' && + manifest.before.booted && + manifest.before.legitimate && + manifest.before.security === 'intended_failure' && + manifest.before.existingTests && + manifest.after.booted && + manifest.after.legitimate && + manifest.after.security === 'pass' && + manifest.after.existingTests && + review.independent && + review.rootCauseRepaired && + review.featurePreserved && + !review.boundaryMocks; + failure = missingExternalWitness + ? new CsoError( + 'PREREQUISITE', + 'Repair verification retained self-reported project-test diagnostics but requires a helper-authenticated out-of-process external assertion witness before a runtime-tested bundle can be issued', + ) + : new CsoError( + 'ASSERTION_FAILED', + manifest.result === 'inconclusive' + ? 'Repair verification was inconclusive; no repair bundle was issued' + : 'Repair failed one or more required boot, control, security, existing-test, or review assertions; no repair bundle was issued', + ); + } + if (failure) { + if (beforeObs) { + const cause = + failure instanceof CsoError + ? failure + : new CsoError('ASSERTION_FAILED', 'Repair validation failed after the before-phase observation'); + const reproduction = !beforeObs.booted + ? 'blocked' + : !beforeObs.legitimate + ? 'inconclusive' + : beforeObs.security === 'intended_failure' + ? 'reproduced' + : beforeObs.security === 'pass' + ? 'disproved' + : 'inconclusive', + harnessHash = verificationHarnessHash(request, snapshot); + const missingWitness = + missingExternalWitness && + cause.code === 'PREREQUISITE' && + reproduction === 'reproduced' && + afterObs?.booted === true && + afterObs.legitimate === true && + afterObs.security === 'pass' && + beforeObs.existingTests && + afterObs.existingTests; + const raw = { + schemaVersion: 3 as const, + artifactKind: 'repair_candidate' as const, + runId: params.runId, + findingId: identityRequest.findingId, + createdAt: new Date().toISOString(), + bundleIssued: false as const, + runtime: { + image: params.runtime.image, + platform: params.runtime.platform, + profile: params.runtime.id, + }, + policyHash: params.policyHash, + requestHash: sha256(canonical(identityRequest)), + harnessHash, + sourceHash: params.manifest.executionHash, + request: identityRequest, + patchHash: patchHash(identityRequest), + testToolchain: beforeTestPlan.toolchain, + testCompletionAssurance: 'self_reported' as const, + ...(params.preparation ? { preparationHash: sha256(canonical(params.preparation)) } : {}), + before: { ...beforeObs, inputHash: harnessHash }, + ...(afterObs ? { after: { ...afterObs, inputHash: harnessHash } } : {}), + reproduction, + repair: missingWitness ? ('proposed' as const) : ('failed' as const), + failure: { code: cause.code, message: cause.message }, + }, + safe = sanitizeHelperForJson(raw) as Omit, + id = sha256(canonical(safe)).slice(0, 32), + attempt: FailedVerificationAttempt = { ...safe, id }; + validateVerificationObservation(attempt.before); + if (attempt.after) validateVerificationObservation(attempt.after); + if (params.persist !== false) + writeJsonExclusive(join(params.runDir, 'verification-attempts', `${id}.json`), attempt); + throw new VerificationAttemptError(cause, attempt); } throw failure; } - if(!certified?.bundle)throw new CsoError('ASSERTION_FAILED','Verification ended without a certifiable result'); - if(params.persist!==false){const persistable=sanitizeHelperForJson(certified.bundle);if(canonical(persistable)!==canonical(certified.bundle))throw new CsoError('REDACTION_FAILED','Repair bundle provenance contains material that cannot be persisted without changing its identity');validateRepairBundle(persistable,certified.bundle.id,snapshot,params.manifest);writeJsonExclusive(join(params.runDir,'bundles',`${certified.bundle.id}.json`),persistable);} + if (!certified?.bundle) + throw new CsoError('ASSERTION_FAILED', 'Verification ended without a certifiable result'); + if (params.persist !== false) { + const persistable = sanitizeHelperForJson(certified.bundle); + if (canonical(persistable) !== canonical(certified.bundle)) + throw new CsoError( + 'REDACTION_FAILED', + 'Repair bundle provenance contains material that cannot be persisted without changing its identity', + ); + validateRepairBundle(persistable, certified.bundle.id, snapshot, params.manifest); + writeJsonExclusive(join(params.runDir, 'bundles', `${certified.bundle.id}.json`), persistable); + } return certified; } diff --git a/lib/cso/verifier.ts b/lib/cso/verifier.ts index cecc8d4a8..e14deb84d 100644 --- a/lib/cso/verifier.ts +++ b/lib/cso/verifier.ts @@ -3,30 +3,159 @@ import * as fs from 'node:fs'; import { connect } from 'node:net'; import { HttpAssertion, VerificationObservation, object } from './contracts'; -interface Config { phase:'before'|'after'; port:number; legitimate:HttpAssertion[]; security:HttpAssertion } -function matches(status:number,body:string,oracle:HttpAssertion['expected']):boolean{return status===oracle.status&&(oracle.includes===undefined||body.includes(oracle.includes))&&(oracle.excludes===undefined||!body.includes(oracle.excludes));} -export async function boundedResponseBody(response:Response,limit=65536):Promise{ - if(!Number.isSafeInteger(limit)||limit<1)throw new Error('invalid response limit'); - const declared=response.headers.get('content-length'); - if(declared!==null&&(/^\d+$/.test(declared)?Number(declared)>limit:true)){await response.body?.cancel();throw new Error('response too large');} - if(!response.body)return''; - const reader=response.body.getReader(),chunks:Uint8Array[]=[];let total=0; - try{ - for(;;){const next=await reader.read();if(next.done)break;if(!next.value)continue;total+=next.value.byteLength;if(total>limit){await reader.cancel();throw new Error('response too large');}chunks.push(next.value);} - }finally{reader.releaseLock();} - const bytes=new Uint8Array(total);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.byteLength;}return new TextDecoder().decode(bytes); +interface Config { + phase: 'before' | 'after'; + port: number; + legitimate: HttpAssertion[]; + security: HttpAssertion; } -async function request(a:HttpAssertion,port:number):Promise<{status:number;body:string}>{ - const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),5000); - try{const response=await fetch(`http://127.0.0.1:${port}${a.path}`,{method:a.method,headers:a.headers,body:['GET'].includes(a.method)?undefined:a.body,redirect:'manual',signal:controller.signal});return{status:response.status,body:await boundedResponseBody(response)};}finally{clearTimeout(timer);} +function matches(status: number, body: string, oracle: HttpAssertion['expected']): boolean { + return ( + status === oracle.status && + (oracle.includes === undefined || body.includes(oracle.includes)) && + (oracle.excludes === undefined || !body.includes(oracle.excludes)) + ); } -async function ready(port:number):Promise{return await new Promise(resolve=>{const socket=connect({host:'127.0.0.1',port}),done=(value:boolean)=>{socket.removeAllListeners();socket.destroy();resolve(value);},timer=setTimeout(()=>done(false),500);socket.once('connect',()=>{clearTimeout(timer);done(true);});socket.once('error',()=>{clearTimeout(timer);done(false);});});} -async function main(){ - const file=process.argv[2];if(!file||!file.startsWith('/policy/'))throw new Error('trusted policy path required');const raw=fs.readFileSync(file,'utf8');if(Buffer.byteLength(raw)>1024*1024)throw new Error('policy too large');const v=object(JSON.parse(raw),'verifier policy') as any; - if(!['before','after'].includes(v.phase)||!Number.isInteger(v.port)||v.port<1024||v.port>65535||!Array.isArray(v.legitimate)||!v.security)throw new Error('invalid verifier policy');const config=v as Config; - let booted=false;for(let attempt=0;attempt<60;attempt++){if(await ready(config.port)){booted=true;break;}await Bun.sleep(250);} - let legitimate=false,security:VerificationObservation['security']='inconclusive',summary='application did not answer a legitimate control'; - if(booted){try{legitimate=(await Promise.all(config.legitimate.map(async a=>{const r=await request(a,config.port);return matches(r.status,r.body,a.expected);}))).every(Boolean);const r=await request(config.security,config.port),fixed=matches(r.status,r.body,config.security.expected),vulnerable=matches(r.status,r.body,config.security.vulnerable!);security=config.phase==='before'?(vulnerable&&!fixed?'intended_failure':fixed&&!vulnerable?'pass':'inconclusive'):(fixed&&!vulnerable?'pass':'inconclusive');summary=`boot=true legitimate=${legitimate} security=${security}`;}catch{summary='bounded verifier request failed';}} - process.stdout.write(JSON.stringify({booted,legitimate,security,existingTests:false,output:summary,inputHash:''})+'\n'); +export async function boundedResponseBody(response: Response, limit = 65536): Promise { + if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('invalid response limit'); + const declared = response.headers.get('content-length'); + if (declared !== null && (/^\d+$/.test(declared) ? Number(declared) > limit : true)) { + await response.body?.cancel(); + throw new Error('response too large'); + } + if (!response.body) return ''; + const reader = response.body.getReader(), + chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + if (!next.value) continue; + total += next.value.byteLength; + if (total > limit) { + await reader.cancel(); + throw new Error('response too large'); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); } -if(import.meta.main)main().catch(()=>{process.stdout.write(JSON.stringify({booted:false,legitimate:false,security:'inconclusive',existingTests:false,output:'verifier setup failed',inputHash:''})+'\n');process.exitCode=1;}); +async function request(a: HttpAssertion, port: number): Promise<{ status: number; body: string }> { + const controller = new AbortController(), + timer = setTimeout(() => controller.abort(), 5000); + try { + const response = await fetch(`http://127.0.0.1:${port}${a.path}`, { + method: a.method, + headers: a.headers, + body: ['GET'].includes(a.method) ? undefined : a.body, + redirect: 'manual', + signal: controller.signal, + }); + return { status: response.status, body: await boundedResponseBody(response) }; + } finally { + clearTimeout(timer); + } +} +async function ready(port: number): Promise { + return await new Promise((resolve) => { + const socket = connect({ host: '127.0.0.1', port }), + done = (value: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(value); + }, + timer = setTimeout(() => done(false), 500); + socket.once('connect', () => { + clearTimeout(timer); + done(true); + }); + socket.once('error', () => { + clearTimeout(timer); + done(false); + }); + }); +} +async function main() { + const file = process.argv[2]; + if (!file || !file.startsWith('/policy/')) throw new Error('trusted policy path required'); + const raw = fs.readFileSync(file, 'utf8'); + if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error('policy too large'); + const v = object(JSON.parse(raw), 'verifier policy') as any; + if ( + !['before', 'after'].includes(v.phase) || + !Number.isInteger(v.port) || + v.port < 1024 || + v.port > 65535 || + !Array.isArray(v.legitimate) || + !v.security + ) + throw new Error('invalid verifier policy'); + const config = v as Config; + let booted = false; + for (let attempt = 0; attempt < 60; attempt++) { + if (await ready(config.port)) { + booted = true; + break; + } + await Bun.sleep(250); + } + let legitimate = false, + security: VerificationObservation['security'] = 'inconclusive', + summary = 'application did not answer a legitimate control'; + if (booted) { + try { + legitimate = ( + await Promise.all( + config.legitimate.map(async (a) => { + const r = await request(a, config.port); + return matches(r.status, r.body, a.expected); + }), + ) + ).every(Boolean); + const r = await request(config.security, config.port), + fixed = matches(r.status, r.body, config.security.expected), + vulnerable = matches(r.status, r.body, config.security.vulnerable!); + security = + config.phase === 'before' + ? vulnerable && !fixed + ? 'intended_failure' + : fixed && !vulnerable + ? 'pass' + : 'inconclusive' + : fixed && !vulnerable + ? 'pass' + : 'inconclusive'; + summary = `boot=true legitimate=${legitimate} security=${security}`; + } catch { + summary = 'bounded verifier request failed'; + } + } + process.stdout.write( + JSON.stringify({ booted, legitimate, security, existingTests: false, output: summary, inputHash: '' }) + + '\n', + ); +} +if (import.meta.main) + main().catch(() => { + process.stdout.write( + JSON.stringify({ + booted: false, + legitimate: false, + security: 'inconclusive', + existingTests: false, + output: 'verifier setup failed', + inputHash: '', + }) + '\n', + ); + process.exitCode = 1; + }); diff --git a/lib/cso/witness.ts b/lib/cso/witness.ts index adc29ac55..bd0ca3470 100644 --- a/lib/cso/witness.ts +++ b/lib/cso/witness.ts @@ -1,136 +1,713 @@ -import { generateKeyPairSync, createPrivateKey, createPublicKey, randomBytes, sign, verify } from 'node:crypto'; +import { + generateKeyPairSync, + createPrivateKey, + createPublicKey, + randomBytes, + sign, + verify, +} from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import { - AssertionWitnessBinding, AssertionWitnessReceipt, Command, CsoError, MAX_OUTPUT, - VerificationObservation, canonical, object, oneOf, sha256, string, validateCommand, + AssertionWitnessBinding, + AssertionWitnessReceipt, + Command, + CsoError, + MAX_OUTPUT, + VerificationObservation, + canonical, + object, + oneOf, + sha256, + string, + validateCommand, validateVerificationObservation, } from './contracts'; import { runProcess } from './process'; -export interface WitnessTestExecution { command:Command; code:number; output:string; minimumPassingTests:number } -export interface WitnessedVerificationResult { observation:VerificationObservation; witness:AssertionWitnessReceipt } +export interface WitnessTestExecution { + command: Command; + code: number; + output: string; + minimumPassingTests: number; +} +export interface WitnessedVerificationResult { + observation: VerificationObservation; + witness: AssertionWitnessReceipt; +} export interface AssertionWitnessHandle { - readonly binding:AssertionWitnessBinding; - attest(observation:VerificationObservation,executions:WitnessTestExecution[]):Promise; - validate(receipt:unknown,observation:VerificationObservation,now?:number):AssertionWitnessReceipt; + readonly binding: AssertionWitnessBinding; + attest( + observation: VerificationObservation, + executions: WitnessTestExecution[], + ): Promise; + validate(receipt: unknown, observation: VerificationObservation, now?: number): AssertionWitnessReceipt; } -const HASH=/^[a-f0-9]{64}$/; -const PUBLIC_KEY=/^[a-f0-9]{88}$/; -const SIGNATURE=/^[a-f0-9]{128}$/; -const PROTOCOL='gstack-cso-assertion-witness-v1' as const; -const MAX_RECEIPT_AGE=300_000; -const exact=(value:Record,allowed:readonly string[],name:string)=>{for(const key of Object.keys(value))if(!allowed.includes(key))throw new CsoError('INVALID_SCHEMA',`Unexpected ${name} field: ${key}`);}; -const hash=(value:unknown,name:string):string=>{if(typeof value!=='string'||!HASH.test(value))throw new CsoError('INVALID_SCHEMA',`${name} must be a sha256 hash`);return value;}; -const timestamp=(value:unknown,name:string):string=>{const result=string(value,name,64),ms=Date.parse(result);if(!Number.isFinite(ms)||new Date(ms).toISOString()!==result)throw new CsoError('INVALID_SCHEMA',`${name} must be a canonical UTC timestamp`);return result;}; +const HASH = /^[a-f0-9]{64}$/; +const PUBLIC_KEY = /^[a-f0-9]{88}$/; +const SIGNATURE = /^[a-f0-9]{128}$/; +const PROTOCOL = 'gstack-cso-assertion-witness-v1' as const; +const MAX_RECEIPT_AGE = 300_000; +const exact = (value: Record, allowed: readonly string[], name: string) => { + for (const key of Object.keys(value)) + if (!allowed.includes(key)) throw new CsoError('INVALID_SCHEMA', `Unexpected ${name} field: ${key}`); +}; +const hash = (value: unknown, name: string): string => { + if (typeof value !== 'string' || !HASH.test(value)) + throw new CsoError('INVALID_SCHEMA', `${name} must be a sha256 hash`); + return value; +}; +const timestamp = (value: unknown, name: string): string => { + const result = string(value, name, 64), + ms = Date.parse(result); + if (!Number.isFinite(ms) || new Date(ms).toISOString() !== result) + throw new CsoError('INVALID_SCHEMA', `${name} must be a canonical UTC timestamp`); + return result; +}; -export function validateAssertionWitnessBinding(value:unknown):AssertionWitnessBinding{ - const v=object(value,'assertion witness binding'),runtime=object(v.runtime,'assertion witness runtime'),runner=object(v.runner,'assertion witness runner'); - exact(v,['schemaVersion','protocol','nonce','phase','issuedAt','expiresAt','runId','findingId','policyHash','auditPolicyHash','runtime','runner','sourceHash','dependencyHash','configurationHash','requestHash','patchHash','harnessHash','assertionHash','fixturesHash'],'assertion witness binding'); - exact(runtime,['image','verifierImage','platform','profile'],'assertion witness runtime');exact(runner,['testToolchain','startPlanHash','testPlanHash','commandsHash','minimumPassingTestsHash'],'assertion witness runner'); - if(v.schemaVersion!==1||v.protocol!==PROTOCOL)throw new CsoError('INVALID_SCHEMA','Unsupported assertion witness protocol'); - const issuedAt=timestamp(v.issuedAt,'assertion witness issuedAt'),expiresAt=timestamp(v.expiresAt,'assertion witness expiresAt'),duration=Date.parse(expiresAt)-Date.parse(issuedAt); - if(duration<=0||duration>MAX_RECEIPT_AGE)throw new CsoError('INVALID_SCHEMA','Assertion witness lifetime exceeds the bounded attempt policy'); - if(typeof v.nonce!=='string'||!HASH.test(v.nonce))throw new CsoError('INVALID_SCHEMA','Assertion witness nonce must be 32 random bytes'); - const findingId=string(v.findingId,'assertion witness findingId',64);if(!/^[a-f0-9]{32}$/.test(findingId))throw new CsoError('INVALID_SCHEMA','Assertion witness findingId is invalid'); - return{schemaVersion:1,protocol:PROTOCOL,nonce:v.nonce,phase:oneOf(v.phase,['before','after'],'assertion witness phase'),issuedAt,expiresAt,runId:string(v.runId,'assertion witness runId',200),findingId,policyHash:hash(v.policyHash,'assertion witness policyHash'),auditPolicyHash:hash(v.auditPolicyHash,'assertion witness auditPolicyHash'),runtime:{image:string(runtime.image,'assertion witness runtime image',500),verifierImage:string(runtime.verifierImage,'assertion witness verifier image',500),platform:string(runtime.platform,'assertion witness runtime platform',100),profile:string(runtime.profile,'assertion witness runtime profile',100)},runner:{testToolchain:oneOf(runner.testToolchain,['runtime','project'],'assertion witness test toolchain'),startPlanHash:hash(runner.startPlanHash,'assertion witness start plan'),testPlanHash:hash(runner.testPlanHash,'assertion witness test plan'),commandsHash:hash(runner.commandsHash,'assertion witness commands'),minimumPassingTestsHash:hash(runner.minimumPassingTestsHash,'assertion witness execution floors')},sourceHash:hash(v.sourceHash,'assertion witness sourceHash'),dependencyHash:hash(v.dependencyHash,'assertion witness dependencyHash'),configurationHash:hash(v.configurationHash,'assertion witness configurationHash'),requestHash:hash(v.requestHash,'assertion witness requestHash'),patchHash:hash(v.patchHash,'assertion witness patchHash'),harnessHash:hash(v.harnessHash,'assertion witness harnessHash'),assertionHash:hash(v.assertionHash,'assertion witness assertionHash'),fixturesHash:hash(v.fixturesHash,'assertion witness fixturesHash')}; +export function validateAssertionWitnessBinding(value: unknown): AssertionWitnessBinding { + const v = object(value, 'assertion witness binding'), + runtime = object(v.runtime, 'assertion witness runtime'), + runner = object(v.runner, 'assertion witness runner'); + exact( + v, + [ + 'schemaVersion', + 'protocol', + 'nonce', + 'phase', + 'issuedAt', + 'expiresAt', + 'runId', + 'findingId', + 'policyHash', + 'auditPolicyHash', + 'runtime', + 'runner', + 'sourceHash', + 'dependencyHash', + 'configurationHash', + 'requestHash', + 'patchHash', + 'harnessHash', + 'assertionHash', + 'fixturesHash', + ], + 'assertion witness binding', + ); + exact(runtime, ['image', 'verifierImage', 'platform', 'profile'], 'assertion witness runtime'); + exact( + runner, + ['testToolchain', 'startPlanHash', 'testPlanHash', 'commandsHash', 'minimumPassingTestsHash'], + 'assertion witness runner', + ); + if (v.schemaVersion !== 1 || v.protocol !== PROTOCOL) + throw new CsoError('INVALID_SCHEMA', 'Unsupported assertion witness protocol'); + const issuedAt = timestamp(v.issuedAt, 'assertion witness issuedAt'), + expiresAt = timestamp(v.expiresAt, 'assertion witness expiresAt'), + duration = Date.parse(expiresAt) - Date.parse(issuedAt); + if (duration <= 0 || duration > MAX_RECEIPT_AGE) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness lifetime exceeds the bounded attempt policy'); + if (typeof v.nonce !== 'string' || !HASH.test(v.nonce)) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness nonce must be 32 random bytes'); + const findingId = string(v.findingId, 'assertion witness findingId', 64); + if (!/^[a-f0-9]{32}$/.test(findingId)) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness findingId is invalid'); + return { + schemaVersion: 1, + protocol: PROTOCOL, + nonce: v.nonce, + phase: oneOf(v.phase, ['before', 'after'], 'assertion witness phase'), + issuedAt, + expiresAt, + runId: string(v.runId, 'assertion witness runId', 200), + findingId, + policyHash: hash(v.policyHash, 'assertion witness policyHash'), + auditPolicyHash: hash(v.auditPolicyHash, 'assertion witness auditPolicyHash'), + runtime: { + image: string(runtime.image, 'assertion witness runtime image', 500), + verifierImage: string(runtime.verifierImage, 'assertion witness verifier image', 500), + platform: string(runtime.platform, 'assertion witness runtime platform', 100), + profile: string(runtime.profile, 'assertion witness runtime profile', 100), + }, + runner: { + testToolchain: oneOf(runner.testToolchain, ['runtime', 'project'], 'assertion witness test toolchain'), + startPlanHash: hash(runner.startPlanHash, 'assertion witness start plan'), + testPlanHash: hash(runner.testPlanHash, 'assertion witness test plan'), + commandsHash: hash(runner.commandsHash, 'assertion witness commands'), + minimumPassingTestsHash: hash(runner.minimumPassingTestsHash, 'assertion witness execution floors'), + }, + sourceHash: hash(v.sourceHash, 'assertion witness sourceHash'), + dependencyHash: hash(v.dependencyHash, 'assertion witness dependencyHash'), + configurationHash: hash(v.configurationHash, 'assertion witness configurationHash'), + requestHash: hash(v.requestHash, 'assertion witness requestHash'), + patchHash: hash(v.patchHash, 'assertion witness patchHash'), + harnessHash: hash(v.harnessHash, 'assertion witness harnessHash'), + assertionHash: hash(v.assertionHash, 'assertion witness assertionHash'), + fixturesHash: hash(v.fixturesHash, 'assertion witness fixturesHash'), + }; } -function receiptUnsigned(receipt:AssertionWitnessReceipt):Omit{const {signature:_,...unsigned}=receipt;return unsigned;} -function observationForReceipt(observation:VerificationObservation,binding:AssertionWitnessBinding,diagnosticTestsPassed:boolean):VerificationObservation{ - const checked=validateVerificationObservation(observation); - return{...checked,existingTests:diagnosticTestsPassed,inputHash:binding.harnessHash}; +function receiptUnsigned(receipt: AssertionWitnessReceipt): Omit { + const { signature: _, ...unsigned } = receipt; + return unsigned; } -export function witnessObservationHash(observation:VerificationObservation):string{return sha256(canonical(validateVerificationObservation(observation)));} - -export function validateStoredAssertionWitnessReceipt(value:unknown):AssertionWitnessReceipt{ - const v=object(value,'assertion witness receipt'),binding=validateAssertionWitnessBinding(v.binding); - exact(v,['schemaVersion','binding','keyId','publicKey','observationHash','externalAssertionsPassed','diagnosticTestsPassed','executions','signature'],'assertion witness receipt'); - if(v.schemaVersion!==1||typeof v.keyId!=='string'||!HASH.test(v.keyId)||typeof v.publicKey!=='string'||!PUBLIC_KEY.test(v.publicKey)||typeof v.signature!=='string'||!SIGNATURE.test(v.signature))throw new CsoError('INVALID_SCHEMA','Assertion witness cryptographic metadata is invalid'); - if(sha256(Buffer.from(v.publicKey,'hex'))!==v.keyId)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness key identity does not match its public key'); - if(typeof v.observationHash!=='string'||!HASH.test(v.observationHash)||typeof v.externalAssertionsPassed!=='boolean'||typeof v.diagnosticTestsPassed!=='boolean'||!Array.isArray(v.executions)||!v.executions.length||v.executions.length>100)throw new CsoError('INVALID_SCHEMA','Assertion witness outcomes are malformed'); - const executions=v.executions.map((raw:any,index:number)=>{const item=object(raw,`assertion witness execution ${index}`);exact(item,['commandHash','exitCode','outputHash','minimumPassingTests','executedTests','passingTests','reportedPassed'],`assertion witness execution ${index}`);if(!Number.isSafeInteger(item.exitCode)||item.exitCode<-1||item.exitCode>255||!Number.isSafeInteger(item.minimumPassingTests)||item.minimumPassingTests<1||!Number.isSafeInteger(item.executedTests)||item.executedTests<0||!Number.isSafeInteger(item.passingTests)||item.passingTests<0||item.passingTests>item.executedTests||typeof item.reportedPassed!=='boolean'||(item.reportedPassed&&item.passingTestsitem.reportedPassed))throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness diagnostic summary does not match its executions'); - const receipt:AssertionWitnessReceipt={schemaVersion:1,binding,keyId:v.keyId,publicKey:v.publicKey,observationHash:v.observationHash,externalAssertionsPassed:v.externalAssertionsPassed,diagnosticTestsPassed:v.diagnosticTestsPassed,executions,signature:v.signature}; - let valid=false;try{valid=verify(null,Buffer.from(canonical(receiptUnsigned(receipt))),createPublicKey({key:Buffer.from(receipt.publicKey,'hex'),format:'der',type:'spki'}),Buffer.from(receipt.signature,'hex'));}catch{} - if(!valid)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness signature is invalid');return receipt; +function observationForReceipt( + observation: VerificationObservation, + binding: AssertionWitnessBinding, + diagnosticTestsPassed: boolean, +): VerificationObservation { + const checked = validateVerificationObservation(observation); + return { ...checked, existingTests: diagnosticTestsPassed, inputHash: binding.harnessHash }; +} +export function witnessObservationHash(observation: VerificationObservation): string { + return sha256(canonical(validateVerificationObservation(observation))); } -export function validateAssertionWitnessReceipt(value:unknown,expected:AssertionWitnessBinding,expectedPublicKey:string,observation:VerificationObservation,now=Date.now()):AssertionWitnessReceipt{ - const receipt=validateStoredAssertionWitnessReceipt(value),binding=validateAssertionWitnessBinding(expected); - if(canonical(receipt.binding)!==canonical(binding)||receipt.publicKey!==expectedPublicKey)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness receipt does not bind this verification challenge'); - if(nowDate.parse(binding.expiresAt))throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness receipt is stale'); - const normalized=observationForReceipt(observation,binding,receipt.diagnosticTestsPassed),external=normalized.booted&&normalized.legitimate&&normalized.security!=='inconclusive'; - if(receipt.observationHash!==witnessObservationHash(normalized)||receipt.externalAssertionsPassed!==external)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness receipt does not bind the external verifier observation'); +export function validateStoredAssertionWitnessReceipt(value: unknown): AssertionWitnessReceipt { + const v = object(value, 'assertion witness receipt'), + binding = validateAssertionWitnessBinding(v.binding); + exact( + v, + [ + 'schemaVersion', + 'binding', + 'keyId', + 'publicKey', + 'observationHash', + 'externalAssertionsPassed', + 'diagnosticTestsPassed', + 'executions', + 'signature', + ], + 'assertion witness receipt', + ); + if ( + v.schemaVersion !== 1 || + typeof v.keyId !== 'string' || + !HASH.test(v.keyId) || + typeof v.publicKey !== 'string' || + !PUBLIC_KEY.test(v.publicKey) || + typeof v.signature !== 'string' || + !SIGNATURE.test(v.signature) + ) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness cryptographic metadata is invalid'); + if (sha256(Buffer.from(v.publicKey, 'hex')) !== v.keyId) + throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness key identity does not match its public key'); + if ( + typeof v.observationHash !== 'string' || + !HASH.test(v.observationHash) || + typeof v.externalAssertionsPassed !== 'boolean' || + typeof v.diagnosticTestsPassed !== 'boolean' || + !Array.isArray(v.executions) || + !v.executions.length || + v.executions.length > 100 + ) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness outcomes are malformed'); + const executions = v.executions.map((raw: any, index: number) => { + const item = object(raw, `assertion witness execution ${index}`); + exact( + item, + [ + 'commandHash', + 'exitCode', + 'outputHash', + 'minimumPassingTests', + 'executedTests', + 'passingTests', + 'reportedPassed', + ], + `assertion witness execution ${index}`, + ); + if ( + !Number.isSafeInteger(item.exitCode) || + item.exitCode < -1 || + item.exitCode > 255 || + !Number.isSafeInteger(item.minimumPassingTests) || + item.minimumPassingTests < 1 || + !Number.isSafeInteger(item.executedTests) || + item.executedTests < 0 || + !Number.isSafeInteger(item.passingTests) || + item.passingTests < 0 || + item.passingTests > item.executedTests || + typeof item.reportedPassed !== 'boolean' || + (item.reportedPassed && item.passingTests < item.minimumPassingTests) + ) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness execution outcome is malformed'); + return { + commandHash: hash(item.commandHash, 'assertion witness commandHash'), + exitCode: item.exitCode, + outputHash: hash(item.outputHash, 'assertion witness outputHash'), + minimumPassingTests: item.minimumPassingTests, + executedTests: item.executedTests, + passingTests: item.passingTests, + reportedPassed: item.reportedPassed, + }; + }); + if (v.diagnosticTestsPassed !== executions.every((item) => item.reportedPassed)) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Assertion witness diagnostic summary does not match its executions', + ); + const receipt: AssertionWitnessReceipt = { + schemaVersion: 1, + binding, + keyId: v.keyId, + publicKey: v.publicKey, + observationHash: v.observationHash, + externalAssertionsPassed: v.externalAssertionsPassed, + diagnosticTestsPassed: v.diagnosticTestsPassed, + executions, + signature: v.signature, + }; + let valid = false; + try { + valid = verify( + null, + Buffer.from(canonical(receiptUnsigned(receipt))), + createPublicKey({ key: Buffer.from(receipt.publicKey, 'hex'), format: 'der', type: 'spki' }), + Buffer.from(receipt.signature, 'hex'), + ); + } catch {} + if (!valid) throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness signature is invalid'); return receipt; } -export function assertionWitnessSemanticValue(receipt:AssertionWitnessReceipt):unknown{ - const checked=validateStoredAssertionWitnessReceipt(receipt),{nonce:_,issuedAt:__,expiresAt:___,...stable}=checked.binding; - return{binding:stable,observationHash:checked.observationHash,externalAssertionsPassed:checked.externalAssertionsPassed,diagnosticTestsPassed:checked.diagnosticTestsPassed,executions:checked.executions}; -} -export function assertionWitnessPairHash(pair:{before:AssertionWitnessReceipt;after:AssertionWitnessReceipt}):string{ - return sha256(canonical({before:assertionWitnessSemanticValue(pair.before),after:assertionWitnessSemanticValue(pair.after)})); +export function validateAssertionWitnessReceipt( + value: unknown, + expected: AssertionWitnessBinding, + expectedPublicKey: string, + observation: VerificationObservation, + now = Date.now(), +): AssertionWitnessReceipt { + const receipt = validateStoredAssertionWitnessReceipt(value), + binding = validateAssertionWitnessBinding(expected); + if (canonical(receipt.binding) !== canonical(binding) || receipt.publicKey !== expectedPublicKey) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Assertion witness receipt does not bind this verification challenge', + ); + if (now < Date.parse(binding.issuedAt) || now > Date.parse(binding.expiresAt)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness receipt is stale'); + const normalized = observationForReceipt(observation, binding, receipt.diagnosticTestsPassed), + external = normalized.booted && normalized.legitimate && normalized.security !== 'inconclusive'; + if ( + receipt.observationHash !== witnessObservationHash(normalized) || + receipt.externalAssertionsPassed !== external + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Assertion witness receipt does not bind the external verifier observation', + ); + return receipt; } -function witnessReplayValue(receipt:AssertionWitnessReceipt):unknown{ - const checked=validateStoredAssertionWitnessReceipt(receipt),{nonce:_,issuedAt:__,expiresAt:___,...binding}=checked.binding; - return{binding,externalAssertionsPassed:checked.externalAssertionsPassed,diagnosticTestsPassed:checked.diagnosticTestsPassed, - executions:checked.executions.map(({outputHash:_,...execution})=>execution)}; +export function assertionWitnessSemanticValue(receipt: AssertionWitnessReceipt): unknown { + const checked = validateStoredAssertionWitnessReceipt(receipt), + { nonce: _, issuedAt: __, expiresAt: ___, ...stable } = checked.binding; + return { + binding: stable, + observationHash: checked.observationHash, + externalAssertionsPassed: checked.externalAssertionsPassed, + diagnosticTestsPassed: checked.diagnosticTestsPassed, + executions: checked.executions, + }; } -export function assertionWitnessReplayHash(pair:{before:AssertionWitnessReceipt;after:AssertionWitnessReceipt}):string{ - return sha256(canonical({before:witnessReplayValue(pair.before),after:witnessReplayValue(pair.after)})); +export function assertionWitnessPairHash(pair: { + before: AssertionWitnessReceipt; + after: AssertionWitnessReceipt; +}): string { + return sha256( + canonical({ + before: assertionWitnessSemanticValue(pair.before), + after: assertionWitnessSemanticValue(pair.after), + }), + ); } -interface TestExecutionSummary {executedTests:number;passingTests:number;reportedPassed:boolean} -function testExecutionSummary(command:Command,code:number,output:string,minimumPassingTests=1):TestExecutionSummary{ - const failed={executedTests:0,passingTests:0,reportedPassed:false}; - if(!Number.isInteger(minimumPassingTests)||minimumPassingTests<1||code!==0||!output||output.includes('[sensitive process output redacted]'))return failed; - const clean=output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g,''),args=command.args,name=basename(command.executable),json=()=>{const end=clean.lastIndexOf('}');if(end<0)return undefined;for(let start=clean.lastIndexOf('{',end);start>=0;start=clean.lastIndexOf('{',start-1)){try{const value=JSON.parse(clean.slice(start,end+1));if(value&&typeof value==='object')return value;}catch{}}}; - let executedTests=0,passingTests=0,valid=false; - if(name==='node'&&args.includes('--test')&&args.includes('--test-reporter=tap')){const paths=args.filter(arg=>arg.startsWith('./')).map(arg=>arg.slice(2)),registered=[...clean.matchAll(/^# Subtest:\s+(.+?)\s*$/gm)].map(match=>match[1]),isPathWrapper=(label:string)=>paths.some(path=>label===path||label.endsWith(`/${path}`));executedTests=Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]);passingTests=Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]);valid=registered.some(label=>!isPathWrapper(label))&&!registered.some(isPathWrapper)&&executedTests>=passingTests&&/^# fail\s+0\s*$/m.test(clean)&&/^# cancelled\s+0\s*$/m.test(clean);} - else if(name==='bun'&&args.includes('test')){passingTests=Number(clean.match(/^\s*(\d+)\s+pass(?:es)?\s*$/mi)?.[1]);executedTests=Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]);valid=executedTests>=passingTests&&/^\s*0\s+fail(?:ures?)?\s*$/mi.test(clean);} - else if(name==='jest'&&args.includes('--json')){const value=json();passingTests=Number(value?.numPassedTests);executedTests=Number(value?.numTotalTests);valid=value?.success===true&&value?.numFailedTests===0&&value?.numRuntimeErrorTestSuites===0&&executedTests>=passingTests;} - else if(name==='vitest'&&args.includes('--reporter=verbose')){const match=clean.match(/^\s*Tests\s+.*?(\d+)\s+passed.*?\((\d+)\)\s*$/mi);passingTests=Number(match?.[1]);executedTests=Number(match?.[2]);valid=executedTests>=passingTests&&!/\b\d+\s+failed\b/i.test(match?.[0]??'');} - else if(name==='mocha'&&args.includes('json')){const stats=json()?.stats;passingTests=Number(stats?.passes);executedTests=Number(stats?.tests);valid=stats?.failures===0&&Number.isSafeInteger(stats?.pending)&&executedTests===passingTests+stats.pending;} - else if(name==='ava'&&args.includes('--tap')){executedTests=Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]);passingTests=Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]);valid=executedTests>=passingTests&&/^# fail\s+0\s*$/m.test(clean);} - else if(name==='python'&&args.some(arg=>arg.includes('import pytest;')&&arg.includes('pytest.main'))){passingTests=Number(clean.match(/(?:^|\s)(\d+)\s+passed\b/i)?.[1]);const skipped=Number(clean.match(/(?:^|\s)(\d+)\s+skipped\b/i)?.[1]??0);executedTests=passingTests+skipped;valid=true;} - else if(name==='python'&&args.some(arg=>arg.includes('import os,sys,unittest;')&&arg.includes('unittest.main'))){executedTests=Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]);const skipped=Number(clean.match(/\bskipped=(\d+)\b/i)?.[1]??0);passingTests=executedTests-skipped;valid=Number.isSafeInteger(skipped);} - else if(name==='bundle'&&args[0]==='exec'&&args[1]==='rspec'&&args.includes('json')){const summary=json()?.summary,pending=Number(summary?.pending_count??0);executedTests=Number(summary?.example_count);passingTests=executedTests-pending;valid=Number.isSafeInteger(pending)&&summary?.failure_count===0&&(summary?.errors_outside_of_examples_count??0)===0;} - else if(name==='bundle'&&args[0]==='exec'&&args[1]==='rails'&&args[2]==='test'&&args.includes('--no-color')){const match=clean.match(/\b(\d+)\s+runs?\s*,\s*(\d+)\s+assertions?\s*,\s*0\s+failures?\s*,\s*0\s+errors?\s*,\s*(\d+)\s+skips?\b/i),skipped=Number(match?.[3]);executedTests=Number(match?.[1]);passingTests=executedTests-skipped;valid=Number.isSafeInteger(skipped);} - const countsValid=Number.isSafeInteger(executedTests)&&executedTests>=0&&Number.isSafeInteger(passingTests)&&passingTests>=0&&executedTests>=passingTests; - return countsValid?{executedTests,passingTests,reportedPassed:valid&&passingTests>=minimumPassingTests}:failed; +function witnessReplayValue(receipt: AssertionWitnessReceipt): unknown { + const checked = validateStoredAssertionWitnessReceipt(receipt), + { nonce: _, issuedAt: __, expiresAt: ___, ...binding } = checked.binding; + return { + binding, + externalAssertionsPassed: checked.externalAssertionsPassed, + diagnosticTestsPassed: checked.diagnosticTestsPassed, + executions: checked.executions.map(({ outputHash: _, ...execution }) => execution), + }; } -export function testExecutionPassed(command:Command,code:number,output:string,minimumPassingTests=1):boolean{ - return testExecutionSummary(command,code,output,minimumPassingTests).reportedPassed; +export function assertionWitnessReplayHash(pair: { + before: AssertionWitnessReceipt; + after: AssertionWitnessReceipt; +}): string { + return sha256( + canonical({ before: witnessReplayValue(pair.before), after: witnessReplayValue(pair.after) }), + ); } -interface ChildRequest {privateKey:string;publicKey:string;binding:AssertionWitnessBinding;observation:VerificationObservation;executions:WitnessTestExecution[]} -async function readChildInput():Promise{const chunks:Buffer[]=[];let bytes=0;for await(const value of process.stdin){const chunk=Buffer.from(value);bytes+=chunk.length;if(bytes>2*MAX_OUTPUT)throw new CsoError('INVALID_SCHEMA','Assertion witness request exceeds the bounded input limit');chunks.push(chunk);}return Buffer.concat(chunks).toString('utf8');} -function createReceipt(input:unknown):AssertionWitnessReceipt{ - const v=object(input,'assertion witness child request');exact(v,['privateKey','publicKey','binding','observation','executions'],'assertion witness child request');const binding=validateAssertionWitnessBinding(v.binding); - if(Date.now()Date.parse(binding.expiresAt))throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness challenge is stale'); - if(typeof v.privateKey!=='string'||v.privateKey.length>4096||typeof v.publicKey!=='string'||!PUBLIC_KEY.test(v.publicKey))throw new CsoError('INVALID_SCHEMA','Assertion witness signing input is invalid'); - let privateKey;try{privateKey=createPrivateKey(v.privateKey);const derived=createPublicKey(privateKey).export({format:'der',type:'spki'}).toString('hex');if(derived!==v.publicKey)throw new Error();}catch{throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness signing authority does not match the challenge');} - const rawObservation=validateVerificationObservation(v.observation);if(!Array.isArray(v.executions)||!v.executions.length||v.executions.length>100)throw new CsoError('INVALID_SCHEMA','Assertion witness needs one or more canonical test executions'); - let outputBytes=0;const rawExecutions:WitnessTestExecution[]=v.executions.map((raw:any,index:number)=>{const item=object(raw,`witness execution ${index}`);exact(item,['command','code','output','minimumPassingTests'],`witness execution ${index}`);const command=validateCommand(item.command,`witness execution ${index}.command`);if(!Number.isSafeInteger(item.code)||item.code<-1||item.code>255||typeof item.output!=='string'||item.output.includes('\0')||!Number.isSafeInteger(item.minimumPassingTests)||item.minimumPassingTests<1)throw new CsoError('INVALID_SCHEMA','Assertion witness test execution is malformed');outputBytes+=Buffer.byteLength(item.output);if(outputBytes>MAX_OUTPUT)throw new CsoError('INVALID_SCHEMA','Assertion witness test output exceeds the group capture limit');return{command,code:item.code,output:item.output,minimumPassingTests:item.minimumPassingTests};}); - if(sha256(canonical(rawExecutions.map(item=>item.command)))!==binding.runner.commandsHash||sha256(canonical(rawExecutions.map(item=>item.minimumPassingTests)))!==binding.runner.minimumPassingTestsHash)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness executions do not match the helper-derived runner'); - const executions=rawExecutions.map(item=>{const summary=testExecutionSummary(item.command,item.code,item.output,item.minimumPassingTests);return{commandHash:sha256(canonical(item.command)),exitCode:item.code,outputHash:sha256(item.output),minimumPassingTests:item.minimumPassingTests,...summary};}),diagnosticTestsPassed=executions.every(item=>item.reportedPassed),observation=observationForReceipt(rawObservation,binding,diagnosticTestsPassed),externalAssertionsPassed=observation.booted&&observation.legitimate&&observation.security!=='inconclusive'; - const unsigned:Omit={schemaVersion:1,binding,keyId:sha256(Buffer.from(v.publicKey,'hex')),publicKey:v.publicKey,observationHash:witnessObservationHash(observation),externalAssertionsPassed,diagnosticTestsPassed,executions}; - return{...unsigned,signature:sign(null,Buffer.from(canonical(unsigned)),privateKey).toString('hex')}; +interface TestExecutionSummary { + executedTests: number; + passingTests: number; + reportedPassed: boolean; +} +function testExecutionSummary( + command: Command, + code: number, + output: string, + minimumPassingTests = 1, +): TestExecutionSummary { + const failed = { executedTests: 0, passingTests: 0, reportedPassed: false }; + if ( + !Number.isInteger(minimumPassingTests) || + minimumPassingTests < 1 || + code !== 0 || + !output || + output.includes('[sensitive process output redacted]') + ) + return failed; + const clean = output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ''), + args = command.args, + name = basename(command.executable), + json = () => { + const end = clean.lastIndexOf('}'); + if (end < 0) return undefined; + for (let start = clean.lastIndexOf('{', end); start >= 0; start = clean.lastIndexOf('{', start - 1)) { + try { + const value = JSON.parse(clean.slice(start, end + 1)); + if (value && typeof value === 'object') return value; + } catch {} + } + }; + let executedTests = 0, + passingTests = 0, + valid = false; + if (name === 'node' && args.includes('--test') && args.includes('--test-reporter=tap')) { + const paths = args.filter((arg) => arg.startsWith('./')).map((arg) => arg.slice(2)), + registered = [...clean.matchAll(/^# Subtest:\s+(.+?)\s*$/gm)].map((match) => match[1]), + isPathWrapper = (label: string) => paths.some((path) => label === path || label.endsWith(`/${path}`)); + executedTests = Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]); + passingTests = Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]); + valid = + registered.some((label) => !isPathWrapper(label)) && + !registered.some(isPathWrapper) && + executedTests >= passingTests && + /^# fail\s+0\s*$/m.test(clean) && + /^# cancelled\s+0\s*$/m.test(clean); + } else if (name === 'bun' && args.includes('test')) { + passingTests = Number(clean.match(/^\s*(\d+)\s+pass(?:es)?\s*$/im)?.[1]); + executedTests = Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]); + valid = executedTests >= passingTests && /^\s*0\s+fail(?:ures?)?\s*$/im.test(clean); + } else if (name === 'jest' && args.includes('--json')) { + const value = json(); + passingTests = Number(value?.numPassedTests); + executedTests = Number(value?.numTotalTests); + valid = + value?.success === true && + value?.numFailedTests === 0 && + value?.numRuntimeErrorTestSuites === 0 && + executedTests >= passingTests; + } else if (name === 'vitest' && args.includes('--reporter=verbose')) { + const match = clean.match(/^\s*Tests\s+.*?(\d+)\s+passed.*?\((\d+)\)\s*$/im); + passingTests = Number(match?.[1]); + executedTests = Number(match?.[2]); + valid = executedTests >= passingTests && !/\b\d+\s+failed\b/i.test(match?.[0] ?? ''); + } else if (name === 'mocha' && args.includes('json')) { + const stats = json()?.stats; + passingTests = Number(stats?.passes); + executedTests = Number(stats?.tests); + valid = + stats?.failures === 0 && + Number.isSafeInteger(stats?.pending) && + executedTests === passingTests + stats.pending; + } else if (name === 'ava' && args.includes('--tap')) { + executedTests = Number(clean.match(/^# tests\s+(\d+)\s*$/m)?.[1]); + passingTests = Number(clean.match(/^# pass\s+(\d+)\s*$/m)?.[1]); + valid = executedTests >= passingTests && /^# fail\s+0\s*$/m.test(clean); + } else if ( + name === 'python' && + args.some((arg) => arg.includes('import pytest;') && arg.includes('pytest.main')) + ) { + passingTests = Number(clean.match(/(?:^|\s)(\d+)\s+passed\b/i)?.[1]); + const skipped = Number(clean.match(/(?:^|\s)(\d+)\s+skipped\b/i)?.[1] ?? 0); + executedTests = passingTests + skipped; + valid = true; + } else if ( + name === 'python' && + args.some((arg) => arg.includes('import os,sys,unittest;') && arg.includes('unittest.main')) + ) { + executedTests = Number(clean.match(/\bRan\s+(\d+)\s+tests?\b/i)?.[1]); + const skipped = Number(clean.match(/\bskipped=(\d+)\b/i)?.[1] ?? 0); + passingTests = executedTests - skipped; + valid = Number.isSafeInteger(skipped); + } else if (name === 'bundle' && args[0] === 'exec' && args[1] === 'rspec' && args.includes('json')) { + const summary = json()?.summary, + pending = Number(summary?.pending_count ?? 0); + executedTests = Number(summary?.example_count); + passingTests = executedTests - pending; + valid = + Number.isSafeInteger(pending) && + summary?.failure_count === 0 && + (summary?.errors_outside_of_examples_count ?? 0) === 0; + } else if ( + name === 'bundle' && + args[0] === 'exec' && + args[1] === 'rails' && + args[2] === 'test' && + args.includes('--no-color') + ) { + const match = clean.match( + /\b(\d+)\s+runs?\s*,\s*(\d+)\s+assertions?\s*,\s*0\s+failures?\s*,\s*0\s+errors?\s*,\s*(\d+)\s+skips?\b/i, + ), + skipped = Number(match?.[3]); + executedTests = Number(match?.[1]); + passingTests = executedTests - skipped; + valid = Number.isSafeInteger(skipped); + } + const countsValid = + Number.isSafeInteger(executedTests) && + executedTests >= 0 && + Number.isSafeInteger(passingTests) && + passingTests >= 0 && + executedTests >= passingTests; + return countsValid + ? { executedTests, passingTests, reportedPassed: valid && passingTests >= minimumPassingTests } + : failed; +} +export function testExecutionPassed( + command: Command, + code: number, + output: string, + minimumPassingTests = 1, +): boolean { + return testExecutionSummary(command, code, output, minimumPassingTests).reportedPassed; } -export async function runAssertionWitnessChild():Promise{const receipt=createReceipt(JSON.parse(await readChildInput()));process.stdout.write(JSON.stringify(receipt)+'\n');} +interface ChildRequest { + privateKey: string; + publicKey: string; + binding: AssertionWitnessBinding; + observation: VerificationObservation; + executions: WitnessTestExecution[]; +} +async function readChildInput(): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const value of process.stdin) { + const chunk = Buffer.from(value); + bytes += chunk.length; + if (bytes > 2 * MAX_OUTPUT) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness request exceeds the bounded input limit'); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} +function createReceipt(input: unknown): AssertionWitnessReceipt { + const v = object(input, 'assertion witness child request'); + exact( + v, + ['privateKey', 'publicKey', 'binding', 'observation', 'executions'], + 'assertion witness child request', + ); + const binding = validateAssertionWitnessBinding(v.binding); + if (Date.now() < Date.parse(binding.issuedAt) || Date.now() > Date.parse(binding.expiresAt)) + throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness challenge is stale'); + if ( + typeof v.privateKey !== 'string' || + v.privateKey.length > 4096 || + typeof v.publicKey !== 'string' || + !PUBLIC_KEY.test(v.publicKey) + ) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness signing input is invalid'); + let privateKey; + try { + privateKey = createPrivateKey(v.privateKey); + const derived = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).toString('hex'); + if (derived !== v.publicKey) throw new Error(); + } catch { + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Assertion witness signing authority does not match the challenge', + ); + } + const rawObservation = validateVerificationObservation(v.observation); + if (!Array.isArray(v.executions) || !v.executions.length || v.executions.length > 100) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness needs one or more canonical test executions'); + let outputBytes = 0; + const rawExecutions: WitnessTestExecution[] = v.executions.map((raw: any, index: number) => { + const item = object(raw, `witness execution ${index}`); + exact(item, ['command', 'code', 'output', 'minimumPassingTests'], `witness execution ${index}`); + const command = validateCommand(item.command, `witness execution ${index}.command`); + if ( + !Number.isSafeInteger(item.code) || + item.code < -1 || + item.code > 255 || + typeof item.output !== 'string' || + item.output.includes('\0') || + !Number.isSafeInteger(item.minimumPassingTests) || + item.minimumPassingTests < 1 + ) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness test execution is malformed'); + outputBytes += Buffer.byteLength(item.output); + if (outputBytes > MAX_OUTPUT) + throw new CsoError('INVALID_SCHEMA', 'Assertion witness test output exceeds the group capture limit'); + return { command, code: item.code, output: item.output, minimumPassingTests: item.minimumPassingTests }; + }); + if ( + sha256(canonical(rawExecutions.map((item) => item.command))) !== binding.runner.commandsHash || + sha256(canonical(rawExecutions.map((item) => item.minimumPassingTests))) !== + binding.runner.minimumPassingTestsHash + ) + throw new CsoError( + 'INCOMPATIBLE_INPUT', + 'Assertion witness executions do not match the helper-derived runner', + ); + const executions = rawExecutions.map((item) => { + const summary = testExecutionSummary(item.command, item.code, item.output, item.minimumPassingTests); + return { + commandHash: sha256(canonical(item.command)), + exitCode: item.code, + outputHash: sha256(item.output), + minimumPassingTests: item.minimumPassingTests, + ...summary, + }; + }), + diagnosticTestsPassed = executions.every((item) => item.reportedPassed), + observation = observationForReceipt(rawObservation, binding, diagnosticTestsPassed), + externalAssertionsPassed = + observation.booted && observation.legitimate && observation.security !== 'inconclusive'; + const unsigned: Omit = { + schemaVersion: 1, + binding, + keyId: sha256(Buffer.from(v.publicKey, 'hex')), + publicKey: v.publicKey, + observationHash: witnessObservationHash(observation), + externalAssertionsPassed, + diagnosticTestsPassed, + executions, + }; + return { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('hex') }; +} -export class AssertionWitnessSession{ - private privateKey:string;readonly publicKey:string;readonly keyId:string;private nonces=new Set(); - constructor(private workDirectory:string,private deadline:number){const stat=lstatSync(workDirectory),real=realpathSync(workDirectory),resolved=lstatSync(real);if(!stat.isDirectory()||stat.isSymbolicLink()||!resolved.isDirectory()||resolved.isSymbolicLink()||stat.dev!==resolved.dev||stat.ino!==resolved.ino||(process.getuid&&resolved.uid!==process.getuid())||(resolved.mode&0o022)!==0)throw new CsoError('UNSAFE_PATH','Assertion witness working directory must be private and owned');this.workDirectory=real;const pair=generateKeyPairSync('ed25519');this.privateKey=pair.privateKey.export({format:'pem',type:'pkcs8'}).toString();this.publicKey=pair.publicKey.export({format:'der',type:'spki'}).toString('hex');this.keyId=sha256(Buffer.from(this.publicKey,'hex'));} - handle(stable:Omit):AssertionWitnessHandle{ - const now=Date.now(),expires=Math.min(this.deadline,now+MAX_RECEIPT_AGE);if(expires<=now)throw new CsoError('DEADLINE','No time remains for an authenticated assertion witness');let nonce='';do{nonce=randomBytes(32).toString('hex');}while(this.nonces.has(nonce));this.nonces.add(nonce); - const binding=validateAssertionWitnessBinding({schemaVersion:1,protocol:PROTOCOL,nonce,issuedAt:new Date(now).toISOString(),expiresAt:new Date(expires).toISOString(),...stable});let consumed=false; - return{binding,attest:async(observation,executions)=>{if(consumed)throw new CsoError('INCOMPATIBLE_INPUT','Assertion witness challenge was already consumed');consumed=true;const input=JSON.stringify({privateKey:this.privateKey,publicKey:this.publicKey,binding,observation,executions} satisfies ChildRequest);if(Buffer.byteLength(input)>2*MAX_OUTPUT)throw new CsoError('REDACTION_FAILED','Assertion witness input exceeds the bounded helper channel');const bun=/^bun(?:\.exe)?$/i.test(basename(process.execPath)),file=bun?process.execPath:join(dirname(process.execPath),process.platform==='win32'?'gstack-cso-launcher.exe':'gstack-cso-launcher'),args=bun?[import.meta.path,'--child']:['__cso-assertion-witness'],env=process.platform==='win32'?{PATH:dirname(process.execPath),SYSTEMROOT:process.env.SYSTEMROOT??'C:\\Windows',WINDIR:process.env.WINDIR??'C:\\Windows'}:{PATH:'/usr/bin:/bin',LANG:'C.UTF-8',LC_ALL:'C.UTF-8',TZ:'UTC'},result=await runProcess(file,args,{cwd:this.workDirectory,env,timeoutMs:Math.max(1,expires-Date.now()),maxBytes:128*1024,input,raw:true});if(result.timedOut)throw new CsoError('DEADLINE','Assertion witness exceeded the verification deadline');if(result.truncated||result.code!==0)throw new CsoError('TOOL_FAILED','Authenticated assertion witness did not return a bounded receipt');let receipt:unknown;try{receipt=JSON.parse(result.stdout);}catch{throw new CsoError('TOOL_FAILED','Authenticated assertion witness returned invalid output');}return validateAssertionWitnessReceipt(receipt,binding,this.publicKey,observation);},validate:(receipt,observation,current=Date.now())=>validateAssertionWitnessReceipt(receipt,binding,this.publicKey,observation,current)}; +export async function runAssertionWitnessChild(): Promise { + const receipt = createReceipt(JSON.parse(await readChildInput())); + process.stdout.write(JSON.stringify(receipt) + '\n'); +} + +export class AssertionWitnessSession { + private privateKey: string; + readonly publicKey: string; + readonly keyId: string; + private nonces = new Set(); + constructor( + private workDirectory: string, + private deadline: number, + ) { + const stat = lstatSync(workDirectory), + real = realpathSync(workDirectory), + resolved = lstatSync(real); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + !resolved.isDirectory() || + resolved.isSymbolicLink() || + stat.dev !== resolved.dev || + stat.ino !== resolved.ino || + (process.getuid && resolved.uid !== process.getuid()) || + (resolved.mode & 0o022) !== 0 + ) + throw new CsoError('UNSAFE_PATH', 'Assertion witness working directory must be private and owned'); + this.workDirectory = real; + const pair = generateKeyPairSync('ed25519'); + this.privateKey = pair.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(); + this.publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).toString('hex'); + this.keyId = sha256(Buffer.from(this.publicKey, 'hex')); + } + handle( + stable: Omit, + ): AssertionWitnessHandle { + const now = Date.now(), + expires = Math.min(this.deadline, now + MAX_RECEIPT_AGE); + if (expires <= now) + throw new CsoError('DEADLINE', 'No time remains for an authenticated assertion witness'); + let nonce = ''; + do { + nonce = randomBytes(32).toString('hex'); + } while (this.nonces.has(nonce)); + this.nonces.add(nonce); + const binding = validateAssertionWitnessBinding({ + schemaVersion: 1, + protocol: PROTOCOL, + nonce, + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(expires).toISOString(), + ...stable, + }); + let consumed = false; + return { + binding, + attest: async (observation, executions) => { + if (consumed) + throw new CsoError('INCOMPATIBLE_INPUT', 'Assertion witness challenge was already consumed'); + consumed = true; + const input = JSON.stringify({ + privateKey: this.privateKey, + publicKey: this.publicKey, + binding, + observation, + executions, + } satisfies ChildRequest); + if (Buffer.byteLength(input) > 2 * MAX_OUTPUT) + throw new CsoError( + 'REDACTION_FAILED', + 'Assertion witness input exceeds the bounded helper channel', + ); + const bun = /^bun(?:\.exe)?$/i.test(basename(process.execPath)), + file = bun + ? process.execPath + : join( + dirname(process.execPath), + process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher', + ), + args = bun ? [import.meta.path, '--child'] : ['__cso-assertion-witness'], + env = + process.platform === 'win32' + ? { + PATH: dirname(process.execPath), + SYSTEMROOT: process.env.SYSTEMROOT ?? 'C:\\Windows', + WINDIR: process.env.WINDIR ?? 'C:\\Windows', + } + : { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC' }, + result = await runProcess(file, args, { + cwd: this.workDirectory, + env, + timeoutMs: Math.max(1, expires - Date.now()), + maxBytes: 128 * 1024, + input, + raw: true, + }); + if (result.timedOut) + throw new CsoError('DEADLINE', 'Assertion witness exceeded the verification deadline'); + if (result.truncated || result.code !== 0) + throw new CsoError( + 'TOOL_FAILED', + 'Authenticated assertion witness did not return a bounded receipt', + ); + let receipt: unknown; + try { + receipt = JSON.parse(result.stdout); + } catch { + throw new CsoError('TOOL_FAILED', 'Authenticated assertion witness returned invalid output'); + } + return validateAssertionWitnessReceipt(receipt, binding, this.publicKey, observation); + }, + validate: (receipt, observation, current = Date.now()) => + validateAssertionWitnessReceipt(receipt, binding, this.publicKey, observation, current), + }; } } -if(import.meta.main&&process.argv.at(-1)==='--child')runAssertionWitnessChild().catch(()=>{process.stderr.write('assertion witness failed\n');process.exitCode=1;}); +if (import.meta.main && process.argv.at(-1) === '--child') + runAssertionWitnessChild().catch(() => { + process.stderr.write('assertion witness failed\n'); + process.exitCode = 1; + }); diff --git a/package.json b/package.json index b09cb25be..1bab7f296 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "scripts": { "build": "bash scripts/build.sh", "build:cso": "bash scripts/build-cso.sh", + "format:cso": "prettier --write 'lib/cso/*.ts'", + "format:cso:check": "prettier --check 'lib/cso/*.ts'", "test:cso:docker": "bun test --max-concurrency 1 test/cso-docker-integration.test.ts test/cso-node-lifecycle-integration.test.ts test/cso-stack-cold-integration.test.ts", "test:cso:macos": "bun test test/cso-macos-launcher.test.ts test/cso-registry-socket.test.ts", "test:cso:windows": "bun test test/cso-windows-launcher.test.ts", @@ -88,6 +90,7 @@ "devDependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.117", "@anthropic-ai/sdk": "^0.78.0", + "prettier": "3.9.9", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0" }, diff --git a/test/cso-scanner-release.test.ts b/test/cso-scanner-release.test.ts index 46b2628b8..41b4abc43 100644 --- a/test/cso-scanner-release.test.ts +++ b/test/cso-scanner-release.test.ts @@ -118,9 +118,9 @@ describe('CSO scanner qualification workflow', () => { expect(anonymousStage.env.GH_TOKEN).toBe('${{ github.token }}'); for(const value of ['--signer-workflow "$signer_workflow"','--signer-digest "$signer_digest"','--source-digest "$source_commit"','cso-attestation-evidence.ts digest','provenanceStatementDigest','sbomStatementDigest'])expect(raw).toContain(value); expect(raw).not.toContain('cso-scanner-staging'); - const docker = fs.readFileSync(path.join(ROOT, 'lib/cso/docker.ts'), 'utf8'); + const docker = fs.readFileSync(path.join(ROOT, 'lib/cso/docker.ts'), 'utf8').replace(/\s+/g, ''); for (const flag of ["'--pull=never'", "'--read-only'", "'--cap-drop','ALL'", "'no-new-privileges:true'", "'seccomp=builtin'", "'--log-driver=none'", "'--network'"]) expect(docker).toContain(flag); - expect(docker).toContain("['rm','--force','--volumes',id]"); expect(docker).toContain('Pinned runtime image declares writable volumes'); + expect(docker).toContain("['rm','--force','--volumes',id]"); expect(docker).toContain('Pinnedruntimeimagedeclareswritablevolumes'); expect(raw).toContain('cso-scanner-catalog.ts assemble'); expect(raw).toContain('cso-scanner-catalog.ts validate-transition lib/cso/scanner-images/catalog.json promotion/catalog-proposal.json'); expect(raw).toContain('gh pr create --base main'); expect(raw).toContain('branch="cso-scanner-catalog-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"'); expect(raw).not.toContain('branch="cso-scanner-catalog-$GITHUB_RUN_ID"'); const publicPromotion = raw.indexOf('Recheck public visibility and anonymous pulls before promotion'); diff --git a/test/cso-windows-launcher.test.ts b/test/cso-windows-launcher.test.ts index ad1ae77d4..533a447ae 100644 --- a/test/cso-windows-launcher.test.ts +++ b/test/cso-windows-launcher.test.ts @@ -135,7 +135,7 @@ describe('CSO native Windows build contract', () => { expect(msvc).toContain('GSTACK_CSO_GIT_PATH'); expect(msvc).toContain('/FI$binding'); expect(msvc).toContain('if ($LASTEXITCODE -ne 0)'); - const processSource=fs.readFileSync(path.join(ROOT,'lib','cso','process.ts'),'utf8'); + const processSource=fs.readFileSync(path.join(ROOT,'lib','cso','process.ts'),'utf8').replace(/\s+/g,''); expect(processSource).toContain("includeNullPath=process.platform==='win32'?'/dev/null':nullPath"); const launcherSource = fs.readFileSync(path.join(ROOT, 'lib/cso/launcher-windows.c'), 'utf8'); expect(launcherSource).toContain('.gstack-cso-generation.lock');