import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; const bundles=new Map(); const LIMIT=256*1024*1024; const hash=data=>crypto.createHash('sha256').update(data).digest('hex'); export function resourcePath(value){ if(typeof value!=='string'||value.length>1024||value.includes('\\')||value.includes('\0')||value.startsWith('/')||/^[A-Za-z]:/.test(value)||value.split('/').some(p=>!p||p==='.'||p==='..'))throw new Error('RESOURCE_PATH_INVALID'); return value; } export function createResourceBundle(data){ if(data.length<8||data.subarray(0,4).toString()!=='BAR1')throw new Error('RESOURCE_ENVELOPE_INVALID'); const length=data.readUInt32LE(4); if(length>2*1024*1024||data.length>LIMIT+2*1024*1024||8+length>data.length)throw new Error('RESOURCE_BUDGET'); const metadata=JSON.parse(data.subarray(8,8+length).toString()); if(!Array.isArray(metadata.files)||metadata.files.length>4096||bundles.size>=8)throw new Error('RESOURCE_CAPACITY'); const files=[],names=new Set(),aliases=new Set();let offset=8+length; for(const item of metadata.files){ const name=resourcePath(item.path); if(names.has(name)||!Number.isSafeInteger(item.bytes)||item.bytes<0||offset+item.bytes>data.length||!/^[a-f0-9]{64}$/.test(item.sha256))throw new Error('RESOURCE_MANIFEST_INVALID'); if(item.sourcePath!==undefined&&(typeof item.sourcePath!=='string'||item.sourcePath.length>2048||item.sourcePath.includes('\0')||aliases.has(item.sourcePath)))throw new Error('RESOURCE_ALIAS_INVALID'); names.add(name);if(item.sourcePath)aliases.add(item.sourcePath); const content=data.subarray(offset,offset+item.bytes);offset+=item.bytes; if(hash(content)!==item.sha256)throw new Error('RESOURCE_HASH_MISMATCH'); files.push({path:name,bytes:item.bytes,sha256:item.sha256,...(item.sourcePath?{sourcePath:item.sourcePath}:{}),content}); } if(offset!==data.length)throw new Error('RESOURCE_TRAILING_DATA'); const root=fs.mkdtempSync(path.join(os.tmpdir(),'blender-resources-')); try{for(const file of files){const dest=path.join(root,file.path);fs.mkdirSync(path.dirname(dest),{recursive:true});fs.writeFileSync(dest,file.content,{mode:0o600});delete file.content;}} catch(error){fs.rmSync(root,{recursive:true,force:true});throw error;} const token=crypto.randomBytes(32).toString('hex'),manifest={files},sha256=hash(Buffer.from(JSON.stringify(manifest))); bundles.set(token,{root,manifest,sha256,touched:Date.now()}); return {token,sha256,files}; } export function materializeResources(binding,workspace){ if(!binding)return; const bundle=bundles.get(binding.token); if(!bundle||bundle.sha256!==binding.sha256)throw new Error('RESOURCE_BUNDLE_EXPIRED_OR_CHANGED'); bundle.touched=Date.now(); fs.cpSync(bundle.root,path.join(workspace,'resources'),{recursive:true,errorOnExist:true}); fs.writeFileSync(path.join(workspace,'resources.json'),JSON.stringify(bundle.manifest)); } export function releaseResourceBundle(binding){ const bundle=bundles.get(binding?.token);if(!bundle||bundle.sha256!==binding.sha256)return; bundles.delete(binding.token);fs.rmSync(bundle.root,{recursive:true,force:true}); } export function describeResourceBundle(binding){ const bundle=bundles.get(binding?.token); if(!bundle||bundle.sha256!==binding.sha256)throw new Error('RESOURCE_BUNDLE_EXPIRED_OR_CHANGED'); bundle.touched=Date.now();return {sha256:bundle.sha256,files:bundle.manifest.files}; } export function exportResourceBundle(binding){ describeResourceBundle(binding); const bundle=bundles.get(binding.token); const buffers=bundle.manifest.files.map(file=>{ const data=fs.readFileSync(path.join(bundle.root,file.path)); if(data.length!==file.bytes||hash(data)!==file.sha256)throw new Error('RESOURCE_HASH_MISMATCH'); return data; }); bundle.touched=Date.now(); const metadata=Buffer.from(JSON.stringify(bundle.manifest)),header=Buffer.alloc(8); header.write('BAR1');header.writeUInt32LE(metadata.length,4); return Buffer.concat([header,metadata,...buffers]); } export function captureResourceBundle(workspace,metadataOnly=false){ const directory=path.join(workspace,'resources');if(!fs.existsSync(directory))return undefined; const directoryInfo=fs.lstatSync(directory);if(!directoryInfo.isDirectory()||directoryInfo.isSymbolicLink())throw new Error('RESOURCE_OUTPUT_DIRECTORY_INVALID'); const manifestPath=path.join(workspace,'resources.json');if(fs.existsSync(manifestPath)){const info=fs.lstatSync(manifestPath);if(!info.isFile()||info.isSymbolicLink()||info.size>2*1024*1024)throw new Error('RESOURCE_OUTPUT_MANIFEST_INVALID');} const old=fs.existsSync(path.join(workspace,'resources.json'))?JSON.parse(fs.readFileSync(path.join(workspace,'resources.json'),'utf8')).files:[]; const aliases=new Map(old.map(file=>[file.path,file.sourcePath])); const files=[],buffers=[];let total=0; const visit=folder=>{for(const entry of fs.readdirSync(folder,{withFileTypes:true})){ const absolute=path.join(folder,entry.name);const stat=fs.lstatSync(absolute); if(stat.isSymbolicLink())throw new Error('RESOURCE_OUTPUT_SYMLINK'); if(stat.isDirectory())visit(absolute); else if(stat.isFile()){ total+=stat.size;if(total>LIMIT||files.length>=4096)throw new Error('RESOURCE_OUTPUT_BUDGET'); const relative=path.relative(directory,absolute).split(path.sep).join('/');resourcePath(relative); const data=fs.readFileSync(absolute);if(!metadataOnly)buffers.push(data);files.push({path:relative,bytes:data.length,sha256:hash(data),...(aliases.get(relative)?{sourcePath:aliases.get(relative)}:{})}); } }};visit(directory); const metadata=Buffer.from(JSON.stringify({files}));if(metadata.length>2*1024*1024)throw new Error('RESOURCE_OUTPUT_MANIFEST_BUDGET');if(metadataOnly)return {files}; const header=Buffer.alloc(8);header.write('BAR1');header.writeUInt32LE(metadata.length,4); return createResourceBundle(Buffer.concat([header,metadata,...buffers])); } const expiry=setInterval(()=>{for(const [token,bundle] of bundles)if(Date.now()-bundle.touched>60*60*1000)releaseResourceBundle({token,sha256:bundle.sha256});},60000);expiry.unref();