86 lines
6.2 KiB
JavaScript
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`);
|
|
});
|