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