'use strict'; const {createHash}=require('node:crypto'); // Owns Windows read-only leases until Chromium reports a terminal request event. // A denied callback is not a terminal event: releasing there would reopen a race. function createFileUploadGate({createLease,inspectPart,isCurrent,abort,maxBytes}){ const pending=new Map();let disposed=false; const deny=ruleId=>({decision:'block',ruleId}); function alive(state){return !disposed&&!state.cancelled&&isCurrent(state.context)&&state.leases.every(lease=>lease.isAlive());} function cancel(state){state.cancelled=true;try{abort(state.id);}catch{} } async function release(state){for(const lease of state.leases)await lease.release();state.leases.length=0;} function snapshot(details,context){ const parts=details.uploadData,limit=maxBytes(context),copies=[]; try{ if(!Array.isArray(parts)||!parts.length||parts.length>256||!Number.isSafeInteger(limit)||limit<0)throw Error(); const hash=createHash('sha256');hash.update(JSON.stringify([details.url,details.method]));let rawBytes=0; const input=parts.map(part=>{ if(part?.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)){ rawBytes+=part.bytes.length;if(rawBytes>limit)throw Error(); const bytes=Buffer.from(part.bytes);copies.push(bytes);hash.update(JSON.stringify(['raw',bytes.length]));hash.update(bytes);return {type:'rawData',bytes}; } if(part?.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID){ hash.update(JSON.stringify(['file',part.file,0,-1]));return {type:'file',file:part.file,offset:0,length:-1}; }throw Error(); }); return {uploadData:input,copies,fingerprint:hash.digest('hex')}; }catch(error){for(const copy of copies)copy.fill(0);throw error;} } async function run(state,details){ const buffers=[...details.copies]; try{ if(!alive(state))return deny('policy-changed'); const parts=details.uploadData; if(!Array.isArray(parts)||!parts.length||parts.length>256)return deny('uninspectable-upload'); if(parts.some(part=>!part||!(part.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)||part.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID)))return deny('uninspectable-upload'); const limit=maxBytes(state.context); if(!Number.isSafeInteger(limit)||limit<0||parts.reduce((sum,part)=>sum+(part.type==='rawData'?part.bytes.length:0),0)>limit)return deny('size-limit'); const paths=parts.filter(part=>part?.type==='file').map(part=>part.file); let lease; if(paths.length){lease=await createLease({paths,maxBytes:limit,onLost:()=>cancel(state)});state.leases.push(lease);if(!alive(state))return deny('upload-lease-lost');} let fileIndex=0,total=0; for(const part of parts){ if(!part||typeof part!=='object')return deny('uninspectable-upload'); let bytes,kind; if(part.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)){ bytes=part.bytes;kind='raw'; }else if(part.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID){ const file=lease.files[fileIndex++]; if(file?.path!==part.file)return deny('uninspectable-upload'); bytes=file.bytes;kind='file'; if(!Buffer.isBuffer(bytes))return deny('uninspectable-upload'); }else return deny('uninspectable-upload'); total+=bytes.length; if(!Number.isSafeInteger(total)||total>maxBytes(state.context))return deny('size-limit'); state.parts.push({bytes,kind,filename:kind==='file'?part.file:undefined}); } if(!alive(state))return deny('policy-changed'); // Inspect actual file bytes independently; multipart wrappers must not mask magic. for(const part of state.parts){ const verdict=await inspectPart(part.bytes,{kind:part.kind,context:state.context,filename:part.filename}); if(!alive(state))return deny('policy-changed'); if(verdict?.decision!=='allow'||verdict.coverage?.complete!==true)return deny(verdict?.ruleId||'inspection-incomplete'); } // Ordered raw segments are also joined: catches literals split at native boundaries. const raw=Buffer.concat(state.parts.filter(part=>part.kind==='raw').map(part=>part.bytes));buffers.push(raw); const verdict=await inspectPart(raw,{kind:'raw',context:state.context}); if(!alive(state))return deny('policy-changed'); if(verdict?.decision!=='allow'||verdict.coverage?.complete!==true)return deny(verdict?.ruleId||'inspection-incomplete'); return {decision:'allow',ruleId:null,request:{bytes:total,detectedType:'unknown'}}; }catch{return deny('inspection-incomplete');} finally{for(const buffer of buffers)buffer.fill(0);for(const lease of state.leases)for(const file of lease.files||[])file.bytes?.fill(0);state.parts.length=0;} } return { inspect(details,context){ if(disposed)return Promise.resolve(deny('session-disposed')); let input;try{input=snapshot(details,context);}catch{return Promise.resolve(deny('uninspectable-upload'));} let state=pending.get(details.id); if(state){for(const copy of input.copies)copy.fill(0);if(state.fingerprint!==input.fingerprint||state.context!==context||!alive(state)){cancel(state);return Promise.resolve(deny('request-changed'));}return state.promise;} if(pending.size>=8){for(const copy of input.copies)copy.fill(0);return Promise.resolve(deny('upload-capacity'));} state={id:details.id,fingerprint:input.fingerprint,context,leases:[],parts:[],cancelled:false,terminal:false};pending.set(details.id,state); state.promise=run(state,input).then(verdict=>{if(verdict.decision!=='allow')cancel(state);return verdict;});return state.promise; }, async terminal(id){const state=pending.get(id);if(!state)return;pending.delete(id);state.terminal=true;state.cancelled=true;await state.promise;await release(state);}, cancelAll(){for(const state of pending.values())cancel(state);}, // Disposal rejects new work but is not proof that native readers have stopped. // Only a real terminal callback releases handles; process exit closes leftovers. dispose(){disposed=true;for(const state of pending.values())cancel(state);}, size(){return pending.size;}, has(id){return pending.has(id);} }; } module.exports={createFileUploadGate};