// Instance-uid, host-only management. No broker verb or organization writer calls this module. import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { bad, HASH } from './manifest.mjs'; import { KINDS } from './kinds.mjs'; import { hashFiles, pluginFiles, pluginPaths, readPlugin, readPluginLock, safeDirectory } from './pinned.mjs'; const run = (cmd,args,options={}) => { const r=spawnSync(cmd,args,{encoding:'utf8',maxBuffer:40*1024*1024,timeout:120000,...options}); if(r.status!==0 || r.error) bad('source-refused'); return r.stdout; }; // Validate the complete archive before creating a file. Never extract links, devices, // duplicate names, absolute paths, dot components, or an archive's permission bits. const EXTRACT = `import sys,zipfile,tarfile,os,stat,io kind,src,dst=sys.argv[1:] archive=zipfile.ZipFile(src) if kind=='zip' else tarfile.open(src) rows=[]; seen=set(); total=0 for e in archive.infolist() if kind=='zip' else archive.getmembers(): n=e.filename if kind=='zip' else e.name directory=e.is_dir() if kind=='zip' else e.isdir() n=n.rstrip('/') if directory else n if not n or n.startswith('/') or any(p in ('','..','.') for p in n.split('/')) or '\\\\' in n: raise ValueError('path') if n in seen: raise ValueError('duplicate') seen.add(n) mode=e.external_attr>>16 if kind=='zip' else e.mode if kind=='zip': if stat.S_IFMT(mode) not in (0,stat.S_IFREG,stat.S_IFDIR): raise ValueError('type') elif not (e.isfile() or e.isdir()): raise ValueError('type') size=e.file_size if kind=='zip' else e.size total+=size if total>33554432 or len(seen)>2048: raise ValueError('size') rows.append((e,n,directory)) for e,n,directory in rows: p=os.path.join(dst,n) if directory: os.makedirs(p,exist_ok=True) else: os.makedirs(os.path.dirname(p),exist_ok=True) with open(p,'xb') as out: out.write(archive.read(e) if kind=='zip' else archive.extractfile(e).read()) `; function sourceFiles(source,commit,scratch) { if(commit!==undefined) { if(!/^[a-f0-9]{40}$/.test(commit) || typeof source!=='string' || source.startsWith('-') || /[\x00-\x1f]/.test(source)) bad('commit-required'); const repo=join(scratch,'repo'),archive=join(scratch,'source.tar'),out=join(scratch,'out'); mkdirSync(out); run('git',['-c','core.hooksPath=/dev/null','clone','--no-checkout','--no-local','--',source,repo]); const actual=run('git',['-C',repo,'rev-parse','--verify',`${commit}^{commit}`]).trim(); if(actual!==commit) bad('source-refused'); run('git',['-C',repo,'archive','--format=tar',`--output=${archive}`,commit]); run('python3',['-c',EXTRACT,'tar',archive,out]); return pluginFiles(out); } if(typeof source!=='string' || /^(https?:|ssh:|git:|git@)/.test(source)) bad('commit-required'); const st=lstatSync(source); if(st.isSymbolicLink()) bad('unsafe-path'); if(st.isDirectory()) return pluginFiles(source); if(!st.isFile() || !source.endsWith('.zip')) bad('source-refused'); const out=join(scratch,'out');mkdirSync(out);run('python3',['-c',EXTRACT,'zip',source,out]); return pluginFiles(out); } function writableRemove(dir) { if(!existsSync(dir))return; for(const [p] of pluginFiles(dir)) chmodSync(join(dir,p),0o600); const dirs=new Set(['']);for(const [p] of pluginFiles(dir)) {let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}} for(const p of dirs)chmodSync(join(dir,p),0o700); rmSync(dir,{recursive:true}); } function withInstallLock(dataRoot,fn) { safeDirectory(dataRoot);const st=lstatSync(dataRoot);if(st.uid!==process.getuid() || st.mode&0o022)bad("plugin-root");const paths=pluginPaths(dataRoot);let fd; try {fd=openSync(paths.mutex,'wx',0o600);}catch{bad('plugin-locked');} try{return fn(paths);}finally{closeSync(fd);unlinkSync(paths.mutex);} } function writeLock(lock,doc) { const temp=`${lock}.new`; try {writeFileSync(temp,JSON.stringify(doc,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(temp,lock);}finally{if(existsSync(temp))unlinkSync(temp);} } export function installPlugin({dataRoot,source,commit,expectedHash,print=()=>{}}) { if(!HASH.test(expectedHash))bad('expected-hash-required'); const scratch=mkdtempSync(join(tmpdir(),'pl1-')); try { const files=sourceFiles(source,commit,scratch),candidate=join(scratch,'candidate');mkdirSync(candidate); for(const [p,bytes]of files){mkdirSync(join(candidate,p,'..'),{recursive:true});writeFileSync(join(candidate,p),bytes,{flag:'wx',mode:0o600});} const {manifest}=readPlugin(candidate); if(hashFiles(files)!==expectedHash || manifest.sha256!==expectedHash)bad('hash-mismatch',manifest.name); for(const kind of Object.keys(KINDS)) if (existsSync(new URL(`../${kind}/${manifest.name}/index.mjs`,import.meta.url))) bad('core-name'); // This is before even the instance lock is created. Only validated names are printed. for(const tool of manifest.tools.filter(t=>t.runs==='host'))print(`host tool: ${tool.name}`); return withInstallLock(dataRoot,({root,lock})=>{ const doc=readPluginLock(dataRoot);if(Object.hasOwn(doc.plugins,manifest.name))bad('already-installed',manifest.name); if(!existsSync(root))mkdirSync(root,{mode:0o700});safeDirectory(root); const dest=join(root,manifest.name);if(existsSync(dest))bad('already-installed',manifest.name); const stage=mkdtempSync(join(root,'.install-')); try { for(const[p,bytes]of files){mkdirSync(join(stage,p,'..'),{recursive:true,mode:0o755});writeFileSync(join(stage,p),bytes,{flag:'wx',mode:0o444});} const dirs=new Set(['']);for(const[p]of files){let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}} for(const p of dirs)chmodSync(join(stage,p),0o555); readPlugin(stage);renameSync(stage,dest); doc.plugins[manifest.name]={version:manifest.version,source:commit === undefined ? manifest.source : {url:source,commit},sha256:expectedHash}; try{writeLock(lock,doc);}catch(e){writableRemove(dest);throw e;} return {name:manifest.name,...doc.plugins[manifest.name]}; }finally{if(existsSync(stage))writableRemove(stage);} }); }catch(e){if(e?.name==='PluginError')throw e;bad('install-refused');}finally{rmSync(scratch,{recursive:true,force:true});} } export function removePlugin({dataRoot,name,enabled}) { if(typeof enabled!=='function')bad('organization-state-required'); return withInstallLock(dataRoot,({root,lock})=>{ const doc=readPluginLock(dataRoot);if(!Object.hasOwn(doc.plugins,name))bad('missing-lock'); if(enabled(name))bad('plugin-enabled',name); // Remove the lock first: an interrupted removal can never leave unpinned code loadable. delete doc.plugins[name];writeLock(lock,doc);writableRemove(join(root,name));return {removed:name}; }); }