Files
stack/packages/cli/tests/case-supervisor-cost.test.mjs

86 lines
6.2 KiB
JavaScript

// Regression controls run the real supervisor. Only the kernel observation is
// fault-injected: a transient empty stat and a delayed first observation of an
// already completed fast command. Neither may authorize an unverified session.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
const names=['case1-accept.sh','case2-unit.sh'];
function body(name){const s=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');return s.slice(s.indexOf('# row253-supervisor-begin'),s.indexOf('# row253-supervisor-end'));}
function replaceOnce(source,anchor,replacement){assert.equal(source.split(anchor).length,2,'fault anchor must be unique');return source.replace(anchor,replacement);}
function run(source,setsid='/usr/bin/setsid',command=['/bin/true']){
return spawnSync('/bin/bash',['-c',source+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 "$@"','--',setsid,'/usr/bin/sleep',...command],{encoding:'utf8',timeout:8000});
}
test('shipped supervisors remain byte-identical',()=>assert.equal(body(names[0]),body(names[1])));
for(const name of names){
test(`${name}: a transient empty stat read retries within the inner budget`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${empty_once:-0}" = 0 ]; then empty_once=1; return 1; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'');
});
test(`${name}: fast success cannot disappear before session verification`,()=>{
// Delay only the parent's first observation. BASE lets /bin/true exit and
// loses its proc entry; a stopped trusted launcher remains verifiable.
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${delayed_once:-0}" = 0 ]; then delayed_once=1; "$sleep_bin" 0.08; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);
});
test(`${name}: a launcher that never creates a session is refused`,()=>{
const r=run(body(name),'/bin/false');assert.equal(r.status,85,r.stderr);
});
test(`${name}: a stopped launcher without a new session is never accepted`,()=>{
const r=run(body(name),'/usr/bin/env');assert.equal(r.status,81,r.stderr);
});
test(`${name}: persistently unreadable initial stat retains session refusal 85`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n return 1');
const r=run(source);assert.equal(r.status,85,r.stderr);
});
test(`${name}: command status and streams survive the launcher`,()=>{
const r=run(body(name),'/usr/bin/setsid',['/bin/bash','-c','printf out; printf err >&2; exit 7']);
assert.equal(r.status,87,r.stderr);assert.equal(r.stdout,'out');assert.equal(r.stderr,'err');
});
}
// A snapshot of the parent tree would miss a child reparented to namespace init.
// A new session is a separately documented escape, not a session member.
import {mkdtempSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable','anchor-death'])test(`${name}: namespace cleanup ${mode}`,t=>{
const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,'');
const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null);
assert.equal(result.status,['unreadable','unkillable','anchor-death'].includes(mode)?86:81,JSON.stringify(result));
assert.equal(result.stdout,'');
if(mode==='anchor-death')assert.match(result.stderr,/Killed/);else assert.equal(result.stderr,'');
if(!['unreadable','unkillable','anchor-death'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive');
if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;}
assert.equal(result.pairs.length,2);
const [leader,descendant]=result.pairs;
assert.notEqual(leader[0],leader[1]);
assert.equal(result.anchor,leader[1],'the trusted command parent reserves the SID');
if(mode==='escape'){
assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]);
assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it');
}else{
assert.equal(descendant[1],leader[1]);
if(mode==='anchor-death'){assert.ok(result.live.includes(leader[0]),'lost anchor refuses before signalling a bare SID');}
else if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');}
else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup');
}
});
// Unlike the private-inventory timing test, this sees the real host /proc.
// Restoring the old per-entry bash read/parse scan exceeds this bound on a
// representative host. It uses cleanup-time's stamps, excluding the inner wait.
import {writeFileSync} from 'node:fs';
import {instrumentCleanupTimer,readProbeSeconds} from './case-inner-timing-fixture.mjs';
for(const name of names)test(`${name}: host inventory cleanup stays within 0.5 seconds`,t=>{
const dir=mkdtempSync(join(tmpdir(),'hc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const clock=join(dir,'clock');writeFileSync(clock,'');
const r=run(instrumentCleanupTimer(body(name)+'# row253-supervisor-end\n',clock),'/usr/bin/setsid',['/bin/bash','-c','/usr/bin/sleep 30 & wait']);
assert.equal(r.status,81,r.stderr);assert.equal(r.stderr,'');assert.equal(r.stdout,'');
const seconds=readProbeSeconds(readFileSync(clock,'utf8'));t.diagnostic(`${name}: host cleanup ${seconds}s`);
assert.ok(seconds<0.5,`host cleanup ${seconds}s must be < 0.5s`);
});