Files
stack/packages/plugins/src/install.mjs
T

108 lines
6.9 KiB
JavaScript

// 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};
});
}