import {createHash} from 'node:crypto' import {readFile,writeFile} from 'node:fs/promises' import {fileURLToPath} from 'node:url' const startKeys=new Set(['schema','files']) const fileKeys=new Set(['path','content','sha256']) const packetKeys=new Set(['schema','task','allowedPaths','operations','recordedChecks','unresolved']) const taskKeys=new Set(['id','intent','excluded','baseManifestHash']) const operationKeys=new Set(['id','type','path','expectedBeforeHash','content','generated']) const checkKeys=new Set(['id','label','exitCode','evidenceHash']) const idPattern=/^[a-z][a-z0-9-]{0,63}$/ const hashPattern=/^[a-f0-9]{64}$/ const segmentPattern=/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ const windowsReserved=/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i function exact(value,keys,label){ if(!value||typeof value!=='object'||Array.isArray(value)||Object.getPrototypeOf(value)!==Object.prototype)throw Error(`${label} must be a plain object`) for(const key of Reflect.ownKeys(value)){const descriptor=Object.getOwnPropertyDescriptor(value,key);if(typeof key!=='string'||!keys.has(key)||!descriptor?.enumerable||!Object.hasOwn(descriptor,'value'))throw Error(`${label}.${String(key)} is not an enumerable data field`)} for(const key of keys)if(!Object.hasOwn(value,key))throw Error(`${label}.${key} is required`) } const sha=value=>createHash('sha256').update(value,'utf8').digest('hex') const byPath=([a],[b])=>ab?1:0 const manifestHash=files=>sha(JSON.stringify([...files.entries()].sort(byPath).map(([path,value])=>({path,sha256:value.sha256})))) function validId(value,label){if(typeof value!=='string'||!idPattern.test(value))throw Error(`${label} is invalid`)} function validPath(value,label){ if(typeof value!=='string'||value.length<1||value.length>160||value.includes('\\'))throw Error(`${label} is invalid`) const segments=value.split('/');if(segments.some(segment=>!segmentPattern.test(segment)||segment==='.'||segment==='..'||segment.endsWith('.')||segment.endsWith(' ')||windowsReserved.test(segment)))throw Error(`${label} is invalid`) } function validText(value,label,max=10000){if(typeof value!=='string'||!value.trim()||value.length>max)throw Error(`${label} is invalid`)} function unique(values,label,{caseInsensitive=false}={}){const normalized=caseInsensitive?values.map(value=>typeof value==='string'?value.toLowerCase():value):values;if(new Set(normalized).size!==normalized.length)throw Error(`${label} contains duplicates`)} function compatiblePath(path,existing,label){ const candidate=path.toLowerCase() for(const currentPath of existing){ if(currentPath===path)continue const current=currentPath.toLowerCase() if(candidate===current||candidate.startsWith(`${current}/`)||current.startsWith(`${candidate}/`))throw Error(`${label} collides with ${currentPath}`) } } export function replay(start,packet){ exact(start,startKeys,'start');if(start.schema!=='martybytes-task-start/v1'||!Array.isArray(start.files)||start.files.length<1||start.files.length>100)throw Error('invalid start') const tree=new Map(),startPaths=[];for(const[index,file]of start.files.entries()){exact(file,fileKeys,`start.files[${index}]`);validPath(file.path,`start.files[${index}].path`);compatiblePath(file.path,startPaths,`start.files[${index}].path`);if(typeof file.content!=='string'||file.content.length>100000||typeof file.sha256!=='string'||!hashPattern.test(file.sha256)||sha(file.content)!==file.sha256)throw Error(`invalid start file ${file.path}`);startPaths.push(file.path);tree.set(file.path,{content:file.content,sha256:file.sha256})}unique(startPaths,'start paths',{caseInsensitive:true}) exact(packet,packetKeys,'packet');if(packet.schema!=='martybytes-task-packet/v1')throw Error('unsupported packet schema') exact(packet.task,taskKeys,'packet.task');validId(packet.task.id,'packet.task.id');validText(packet.task.intent,'packet.task.intent',500) if(!Array.isArray(packet.task.excluded)||packet.task.excluded.length<1||packet.task.excluded.length>20||packet.task.excluded.some(value=>typeof value!=='string'||!value.trim()||value.length>200))throw Error('invalid exclusions') if(typeof packet.task.baseManifestHash!=='string'||!hashPattern.test(packet.task.baseManifestHash)||packet.task.baseManifestHash!==manifestHash(tree))throw Error('base manifest mismatch') if(!Array.isArray(packet.allowedPaths)||packet.allowedPaths.length<1||packet.allowedPaths.length>100)throw Error('invalid allowed paths');packet.allowedPaths.forEach((value,index)=>validPath(value,`packet.allowedPaths[${index}]`));unique(packet.allowedPaths,'allowed paths',{caseInsensitive:true}) if(!Array.isArray(packet.operations)||packet.operations.length<1||packet.operations.length>100)throw Error('invalid operations');packet.operations.forEach((value,index)=>exact(value,operationKeys,`packet.operations[${index}]`));unique(packet.operations.map(value=>value.id),'operation ids');unique(packet.operations.map(value=>value.path),'operation paths',{caseInsensitive:true}) const effects=[] for(const[index,operation]of packet.operations.entries()){ const label=`packet.operations[${index}]`;validId(operation.id,`${label}.id`);validPath(operation.path,`${label}.path`);compatiblePath(operation.path,tree.keys(),`${label}.path`) if(operation.type!=='write'||!packet.allowedPaths.includes(operation.path)||typeof operation.content!=='string'||operation.content.length>100000||typeof operation.generated!=='boolean')throw Error(`${label} is invalid`) const before=tree.get(operation.path),beforeHash=before?.sha256??null if(operation.expectedBeforeHash!==null&&(typeof operation.expectedBeforeHash!=='string'||!hashPattern.test(operation.expectedBeforeHash)))throw Error(`${label}.expectedBeforeHash is invalid`) if(operation.expectedBeforeHash!==beforeHash)throw Error(`${label} has stale before hash`) const afterHash=sha(operation.content);if(afterHash===beforeHash)throw Error(`${label} is a no-op`);tree.set(operation.path,{content:operation.content,sha256:afterHash});effects.push({id:operation.id,path:operation.path,beforeHash,afterHash,generated:operation.generated}) } if(!Array.isArray(packet.recordedChecks)||packet.recordedChecks.length<1||packet.recordedChecks.length>50)throw Error('invalid recorded checks');packet.recordedChecks.forEach((value,index)=>exact(value,checkKeys,`packet.recordedChecks[${index}]`));unique(packet.recordedChecks.map(value=>value.id),'check ids') const recordedChecks=packet.recordedChecks.map((check,index)=>{const label=`packet.recordedChecks[${index}]`;validId(check.id,`${label}.id`);validText(check.label,`${label}.label`,200);if(!Number.isSafeInteger(check.exitCode)||check.exitCode<0||check.exitCode>255||typeof check.evidenceHash!=='string'||!hashPattern.test(check.evidenceHash))throw Error(`${label} is invalid`);return{id:check.id,label:check.label,exitCode:check.exitCode,evidenceHash:check.evidenceHash}}) if(!Array.isArray(packet.unresolved)||packet.unresolved.length<1||packet.unresolved.length>20||packet.unresolved.some(value=>typeof value!=='string'||!value.trim()||value.length>300))throw Error('invalid unresolved items') const files=[...tree.entries()].sort(byPath).map(([path,value])=>({path,sha256:value.sha256})) return{schema:'martybytes-task-replay-result/v1',taskId:packet.task.id,baseManifestHash:packet.task.baseManifestHash,finalManifestHash:manifestHash(tree),effects,files,recordedChecks,unresolved:[...packet.unresolved],boundary:'Data-only replay. Recorded checks were not executed or verified; correctness, authorization, isolation, and merge readiness require separate review.'} } async function main(args){ if(args.length!==3)throw Error('Usage: node task-packet-replay.mjs start.json packet.json result.json') const[startPath,packetPath,outputPath]=args const result=replay(JSON.parse(await readFile(startPath,'utf8')),JSON.parse(await readFile(packetPath,'utf8'))) await writeFile(outputPath,JSON.stringify(result,null,2)+'\n',{flag:'wx'}) } if(process.argv[1]===fileURLToPath(import.meta.url))main(process.argv.slice(2)).catch(error=>{console.error(error.message);process.exitCode=1})