import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { refuse } from './lane-state.mjs'; // This is the declared build input, not an observation of a running binary. function pinnedBase(checkout){ const lines=readFileSync(join(checkout,'Containerfile'),'utf8').split('\n').filter(line=>/^\s*FROM(?:\s|$)/i.test(line)); if(lines.length!==1)refuse('image requires exactly one pinned FROM'); const fields=lines[0].trim().split(/\s+/); if(fields.length!==2||!/^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[a-f0-9]{64}$/.test(fields[1]))refuse('image requires one literal digest-pinned FROM'); return fields[1]; } export function imageFingerprint(checkout){ const dockerfile=readFileSync(join(checkout,'Containerfile'),'utf8'); const copies=dockerfile.split('\n').filter(l=>/^\s*(COPY|ADD)\s/i.test(l)).map(l=>l.trim()); const expected=['COPY package.json package-lock.json ./','COPY contracts /opt/mosaic/contracts','COPY src /opt/mosaic/src','COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/','COPY adapters /opt/mosaic/adapters']; if(JSON.stringify(copies)!==JSON.stringify(expected))refuse('image COPY inputs changed; manifest review required'); const hash=createHash('sha256'); const visit=rel=>{const path=join(checkout,rel),st=lstatSync(path);if(st.isSymbolicLink())refuse('image input is a symlink');hash.update(JSON.stringify([rel,st.mode&0o777,st.isDirectory()?'d':'f'])+'\n');if(st.isDirectory())for(const n of readdirSync(path).sort())visit(`${rel}/${n}`);else if(st.isFile())hash.update(readFileSync(path));else refuse('unsupported image input');}; for(const rel of ['Containerfile','package.json','package-lock.json','contracts','src','adapters','packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'])visit(rel); return hash.digest('hex'); } export function inspectImage(tag,docker=process.env.MOSAIC_GATE_REAL_DOCKER??'docker'){ return JSON.parse(execFileSync(docker,['image','inspect',tag,'--format','{{json .}}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']})); } export function verifyImage(receipt,checkout,inspect=inspectImage){ if(!receipt||!/^sha256:[a-f0-9]{64}$/.test(receipt.imageId??'')||!/^.+@sha256:[a-f0-9]{64}$/.test(receipt.baseDigest??'')||!/^linux\/[a-z0-9_]+$/.test(receipt.platform??'')||!/^mosaic-poc-agent:[A-Za-z0-9_.-]+$/.test(receipt.tag??''))refuse('image provenance receipt invalid'); if(receipt.baseDigest!==pinnedBase(checkout))refuse('image base differs from pinned Containerfile'); if(receipt.inputDigest!==imageFingerprint(checkout))refuse('image inputs differ'); const actual=inspect(receipt.tag); if(actual.Id!==receipt.imageId)refuse('pinned image ID differs'); if(`${actual.Os}/${actual.Architecture}`!==receipt.platform)refuse('image platform differs'); const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8')); if(pkg.dependencies?.['@earendil-works/pi-coding-agent']&&receipt.tag!==`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`)refuse('image tag differs from release identity'); return receipt; } // Called by the CLI only while both lanes and the image lock are exclusive. export function prepareImage(checkout,{grant,tempRoot,out},invoke){ if(!grant)refuse('missing image preparation grant'); const baseDigest=pinnedBase(checkout); const inputDigest=imageFingerprint(checkout); const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8')); const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim(); if(!/^\d+\.\d+\.\d+$/.test(release))refuse('invalid image release'); const tag=`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`; const run=invoke??((args)=>execFileSync('docker',args,{cwd:checkout,encoding:'utf8',stdio:['ignore','pipe','pipe'],env:{PATH:process.env.PATH,HOME:process.env.HOME,...(process.env.DOCKER_HOST?{DOCKER_HOST:process.env.DOCKER_HOST}:{}),COMPOSE_PROJECT_NAME:'mg197-image-prepare',MOSAIC_IMAGE_TAG:tag,MOSAIC_DATA_ROOT:join(tempRoot,'data'),MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',PI_AUTH_FILE:'/dev/null'}})); const start=performance.now();let buildOutput;try{buildOutput=run(['compose','build']);}catch(e){if(out)writeFileSync(join(out,'image-build.log'),String(e.stdout??'')+String(e.stderr??''),{mode:0o600,flag:'wx'});throw e;}if(out)writeFileSync(join(out,'image-build.log'),buildOutput,{mode:0o600,flag:'wx'});const buildWallMs=performance.now()-start; if(inputDigest!==imageFingerprint(checkout))refuse('image inputs changed while building'); const actual=JSON.parse(run(['image','inspect',tag,'--format','{{json .}}'])); const receipt={inputDigest,imageId:actual.Id,tag,baseDigest,platform:`${actual.Os}/${actual.Architecture}`,grant,builds:1,buildWallMs,at:new Date().toISOString()}; verifyImage(receipt,checkout,()=>actual);return receipt; }