Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a122699a0 | ||
|
|
f382699d47 |
No files matched your search
@@ -55,8 +55,8 @@ for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills desc
|
||||
assert.equal(result.stdout,'');
|
||||
assert.equal(result.stderr,'');
|
||||
const pairs=result.pairs;
|
||||
assert.equal(pairs.length,2,'the session leader and its live descendant both started');
|
||||
assert.equal(pairs[0][0],pairs[0][1],'the command leads its kernel session');
|
||||
assert.equal(pairs.length,2,'the command and its live descendant both started');
|
||||
assert.notEqual(pairs[0][0],pairs[0][1],'the trusted parent leads the command session');
|
||||
assert.notEqual(pairs[1][0],pairs[0][0]);
|
||||
assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session');
|
||||
assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// No real process is signalled. These hostile kernel-boundary controls expose
|
||||
// a new session reusing the old SID after the original leader disappears.
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
for(const name of ['case1-accept.sh','case2-unit.sh'])for(const mode of ['already-dead','dies-before-signal'])test(`${name}: ${mode} original session never signals a recycled SID`,()=>{
|
||||
const source=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
|
||||
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
|
||||
assert.ok(program,'the shipped cleanup program exists');
|
||||
const anchor='\ntry:\n';assert.equal(program.split(anchor).length,2);
|
||||
const control=`
|
||||
import atexit, json
|
||||
from types import SimpleNamespace
|
||||
attempts = []
|
||||
checks = 0
|
||||
atexit.register(lambda: print(json.dumps(attempts)))
|
||||
def observed(pid):
|
||||
global checks
|
||||
if pid == "self/fd/42":
|
||||
checks += 1
|
||||
return (b"T", session) if ${mode==='already-dead'?'False':'checks < 3'} else None
|
||||
return (b"R", session)
|
||||
def send(fd, sig):
|
||||
attempts.append([fd, sig])
|
||||
raise PermissionError("never signal a real task")
|
||||
fields = observed
|
||||
os = SimpleNamespace(listdir=lambda path: ["999"], pidfd_open=lambda pid: 17 if pid == session else 99, close=lambda fd: None)
|
||||
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
|
||||
`;
|
||||
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,'\n'+control+anchor),'424242','42'],{encoding:'utf8'});
|
||||
assert.equal(r.status,1,r.stderr);assert.equal(r.stderr,'');
|
||||
assert.deepEqual(JSON.parse(r.stdout),[],'a stale SID must never authorize a signal');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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`);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Namespace-local evidence and cleanup. The JSON observation precedes teardown.
|
||||
import {readFileSync,writeFileSync,readdirSync,openSync,closeSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs';
|
||||
const source=readFileSync(script,'utf8');
|
||||
let body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
|
||||
// Adversarial kernel boundary controls keep the real cleanup loop and budget.
|
||||
if(mode==='unreadable')body=body.replace('def fields(pid):','def fields(pid):\n raise PermissionError("injected unreadable stat")');
|
||||
if(mode==='unkillable')body=body.replace('signal.pidfd_send_signal(fd, signal.SIGKILL)','None # injected successful kill with a surviving member');
|
||||
if(mode==='fork')writeFileSync(worker,`
|
||||
import {spawn} from 'node:child_process';
|
||||
import {appendFileSync,readFileSync} from 'node:fs';
|
||||
const record=${JSON.stringify(records)};
|
||||
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
|
||||
appendFileSync(record,JSON.stringify([process.pid,sid(process.pid)])+'\\n');
|
||||
setInterval(()=>{
|
||||
const child=spawn('/usr/bin/sleep',['30'],{stdio:'ignore'});
|
||||
appendFileSync(record,JSON.stringify([child.pid,sid(child.pid)])+'\\n');
|
||||
},4);
|
||||
`);
|
||||
else writeFileSync(worker,`
|
||||
import {spawn} from 'node:child_process';
|
||||
import {readFileSync,writeFileSync} from 'node:fs';
|
||||
const record=${JSON.stringify(records)};
|
||||
const child=spawn('/bin/bash',['-c',${JSON.stringify(mode==='escape'?'exec /usr/bin/setsid /usr/bin/sleep 30':'/usr/bin/sleep 30 & echo $! > "$1"')},'--',record+'.child'],{stdio:'ignore'});
|
||||
setTimeout(()=>{
|
||||
const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"};
|
||||
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
|
||||
writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]]));
|
||||
writeFileSync(record+'.anchor',String(process.ppid));
|
||||
writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]);
|
||||
if(${JSON.stringify(mode)} === "anchor-death")process.kill(process.ppid,"SIGKILL");
|
||||
},100);
|
||||
setInterval(()=>{},1000);
|
||||
`);
|
||||
const out=openSync(dir+'/out','w'),err=openSync(dir+'/err','w');
|
||||
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',stdio:['ignore',out,err],timeout:8000,killSignal:'SIGKILL'});
|
||||
closeSync(out);closeSync(err);
|
||||
result.stdout=readFileSync(dir+'/out','utf8');result.stderr=readFileSync(dir+'/err','utf8');
|
||||
let pairs=[];
|
||||
try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{}
|
||||
const live=[];
|
||||
for(const [pid] of pairs){try{const state=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[0];if(!['Z','X'].includes(state))live.push(pid);}catch{}}
|
||||
// Inventory the entire private namespace too: a fork between spawn and its
|
||||
// record write must not escape the assertion just because it was unrecorded.
|
||||
const sessionLive=[];
|
||||
for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){
|
||||
try{
|
||||
const words=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ');
|
||||
if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid));
|
||||
}catch{}
|
||||
}
|
||||
let parent,anchor;
|
||||
try{anchor=Number(readFileSync(records+".anchor","utf8"));}catch{}
|
||||
try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{}
|
||||
process.stdout.write(JSON.stringify({sessionLive,parent,anchor,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live}));
|
||||
// These PIDs exist only in the private namespace. Never signal them on the host.
|
||||
for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{}
|
||||
@@ -938,7 +938,7 @@ test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env
|
||||
// mos-1326: assert every argv element, not flattened/substr command detection.
|
||||
const source=read(SCRIPT);
|
||||
const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n';
|
||||
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'34063098d7edb9067c11456165dc7535bc0e779d053c43599884d6b614528639');
|
||||
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'52509773fa2bb0701402b8d2454816153e18323cfaa75cb72418b46c4dbb2d34');
|
||||
const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1];
|
||||
const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1];
|
||||
const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line));
|
||||
|
||||
@@ -215,7 +215,7 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
|
||||
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
|
||||
const seconds=readProbeSeconds(read(clock));
|
||||
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
|
||||
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
|
||||
assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
|
||||
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
|
||||
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
|
||||
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
|
||||
@@ -256,24 +256,39 @@ test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
|
||||
});
|
||||
|
||||
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
|
||||
// A deterministic clock models a slow inventory without host-load timing.
|
||||
// All process reads and signals are stand-ins; no real PID is signalled.
|
||||
// Run the shipped cleanup with a deterministic slow inventory/clock. Every
|
||||
// kernel operation is replaced before execution; no real PID is signalled.
|
||||
const source=read(UNIT);
|
||||
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
|
||||
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
|
||||
assert.ok(program,'fixed cleanup program exists');
|
||||
const anchor='\ntry:\n';
|
||||
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
|
||||
const standins=`
|
||||
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
|
||||
stat_fields() {
|
||||
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
|
||||
[ "$seen" != 1 ] || ST_SESSION="$child"
|
||||
return 0
|
||||
}
|
||||
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
|
||||
kill() { kills=$((kills+1)); return 0; }
|
||||
clean_session; rc=$?
|
||||
printf '%s' "$kills"
|
||||
exit "$rc"
|
||||
from types import SimpleNamespace
|
||||
alive = True
|
||||
kills = 0
|
||||
clock = time.monotonic()
|
||||
|
||||
def inventory(path):
|
||||
global clock
|
||||
clock += 1000
|
||||
return ["999"]
|
||||
|
||||
def fake_fields(pid):
|
||||
return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
|
||||
|
||||
def send(fd, sig):
|
||||
global alive, kills
|
||||
if sig == 9:
|
||||
alive = False
|
||||
kills += 1
|
||||
|
||||
fields = fake_fields
|
||||
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
|
||||
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
|
||||
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
|
||||
`;
|
||||
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
|
||||
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
|
||||
});
|
||||
|
||||
|
||||
+134
-42
@@ -136,47 +136,110 @@ trap 'rm -rf -- "$WORK"' EXIT
|
||||
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
|
||||
set +m
|
||||
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
|
||||
child="" timer="" verified=0
|
||||
child="" timer="" session_ref="" verified=0
|
||||
stat_fields() {
|
||||
local pid="$1" raw="" rest
|
||||
local pid="$1" raw="" rest file="/proc/$1/stat"
|
||||
if [ "$pid" = "${child:-}" ] && [ -n "${session_ref:-}" ]; then
|
||||
file="/proc/self/fd/$session_ref/stat"
|
||||
fi
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw 2>/dev/null < "/proc/$pid/stat" || :
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
IFS= read -r -d '' raw 2>/dev/null < "$file" || :
|
||||
[ -n "$raw" ] || { [ -e "$file" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
|
||||
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
|
||||
}
|
||||
clock_cs() {
|
||||
local boot spare
|
||||
read -r boot spare < /proc/uptime || return 1
|
||||
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
|
||||
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
|
||||
}
|
||||
clean_session() {
|
||||
local entry pid rc live until=""
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
pid="${entry%/stat}"; pid="${pid##*/}"
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" != 3 ] || continue
|
||||
[ "$rc" = 0 ] || return 1
|
||||
[ "$ST_SESSION" = "$child" ] || continue
|
||||
case "$ST_STATE" in Z|X) continue ;; esac
|
||||
live=1
|
||||
if ! kill -KILL "$pid" 2>/dev/null; then
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
|
||||
fi
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
# Fixed, isolated stdlib code, never imported from the copy or its cwd.
|
||||
# Buffered reads avoid bash read -d's one-byte reads over the host inventory.
|
||||
/usr/bin/python3 -I -S -c '
|
||||
import os, signal, sys, time
|
||||
session = int(sys.argv[1])
|
||||
until = None
|
||||
anchor_fd = None
|
||||
|
||||
def fields(pid):
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
# A disappearing task can return EOF while its proc entry is still visible.
|
||||
# Retry once; a persistent unreadable/malformed entry refuses cleanup.
|
||||
if not raw:
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
rest = raw.rsplit(b") ", 1)
|
||||
if len(rest) != 2:
|
||||
raise ValueError("stat")
|
||||
words = rest[1].split()
|
||||
if len(words) < 4 or len(words[0]) != 1 or not words[0].isalpha():
|
||||
raise ValueError("stat")
|
||||
return words[0], int(words[3])
|
||||
|
||||
def original_leader():
|
||||
# The inherited proc directory refers to the original task, even if its
|
||||
# numeric PID is reused. A live trusted leader keeps this SID allocated.
|
||||
info = fields("self/fd/" + sys.argv[2])
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
raise ValueError("lost session anchor")
|
||||
return info
|
||||
|
||||
try:
|
||||
anchor_fd = os.pidfd_open(session)
|
||||
original_leader()
|
||||
while True:
|
||||
original_leader()
|
||||
live = False
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdecimal() or int(pid) == session:
|
||||
continue
|
||||
info = fields(pid)
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
continue
|
||||
live = True
|
||||
try:
|
||||
# Pin the task before rechecking membership: a recycled numeric
|
||||
# pid must never redirect our signal to an unrelated process.
|
||||
fd = os.pidfd_open(int(pid))
|
||||
try:
|
||||
current = fields(pid)
|
||||
if current is not None and current[1] == session and current[0] not in (b"Z", b"X"):
|
||||
# Check AFTER membership: while the original anchor is
|
||||
# alive, this SID cannot name a later session. The pidfd
|
||||
# then prevents task reuse between this check and kill.
|
||||
original_leader()
|
||||
signal.pidfd_send_signal(fd, signal.SIGKILL)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError:
|
||||
info = fields(pid)
|
||||
if info is not None and info[0] not in (b"Z", b"X"):
|
||||
raise
|
||||
if not live and original_leader()[0] == b"T":
|
||||
# The trusted leader is stopped after waiting for the command.
|
||||
# With all other members dead it may return the saved command code.
|
||||
signal.pidfd_send_signal(anchor_fd, signal.SIGCONT)
|
||||
break
|
||||
now = time.monotonic()
|
||||
# Inventory pass 1 remains outside the 250 ms confirmation budget.
|
||||
if until is None:
|
||||
until = now + 0.25
|
||||
if now >= until:
|
||||
raise TimeoutError("cleanup")
|
||||
time.sleep(0.01)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if anchor_fd is not None:
|
||||
os.close(anchor_fd)
|
||||
' "$child" "$session_ref"
|
||||
}
|
||||
finish() {
|
||||
local rc="$?"
|
||||
@@ -186,30 +249,59 @@ finish() {
|
||||
elif [ -n "$child" ]; then
|
||||
kill -KILL "$child" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$child" ] || wait "$child" 2>/dev/null || :
|
||||
# A refused cleanup may leave an unkillable member. Do not hide rc 86
|
||||
# behind an unbounded wait; the caller retains its existing outer guard.
|
||||
if [ "$rc" != 86 ] && [ -n "$child" ]; then
|
||||
local command_rc=0
|
||||
wait "$child" 2>/dev/null || command_rc=$?
|
||||
[ "$rc" != 0 ] || [ "$command_rc" = 0 ] || rc=87
|
||||
fi
|
||||
[ -z "$session_ref" ] || exec {session_ref}<&-
|
||||
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
# The trusted session leader stays alive through cleanup. Copy code runs as
|
||||
# its child; only after cleanup continues the stopped leader does it return
|
||||
# the saved command status. An open proc directory pins its task identity.
|
||||
"$setsid_bin" /bin/bash -c '
|
||||
set +m
|
||||
kill -STOP "$BASHPID"
|
||||
"$@" & command=$!
|
||||
rc=0; wait "$command" 2>/dev/null || rc=$?
|
||||
kill -STOP "$BASHPID"
|
||||
exit "${rc}"
|
||||
' -- "$@" & child=$!
|
||||
{ exec {session_ref}<"/proc/$child"; } 2>/dev/null || exit 85
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
[ "$rc" != 3 ] || exit 85
|
||||
if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then
|
||||
verified=1
|
||||
kill -CONT "$child" 2>/dev/null || exit 85
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$timer" 2>/dev/null; then
|
||||
# Persistent unreadable metadata remains a session refusal, not a
|
||||
# command timeout. A transient read gets only this same inner budget.
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
exit 81
|
||||
fi
|
||||
"$sleep_bin" 0.002
|
||||
done
|
||||
[ "$verified" = 1 ] || exit 85
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 86
|
||||
case "$ST_STATE" in Z|X) exit 86 ;; esac
|
||||
[ "$ST_STATE" != T ] || break
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
rc=0; wait "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 87
|
||||
exit 0
|
||||
MOSAIC_TESTER_SUPERVISOR
|
||||
# row253-supervisor-end
|
||||
@@ -217,7 +309,7 @@ MOSAIC_TESTER_SUPERVISOR
|
||||
tester_tools() {
|
||||
local input target p st magic
|
||||
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD="" TS_TAIL=""
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head "$@"; do
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head /usr/bin/python3 "$@"; do
|
||||
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
|
||||
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
|
||||
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
|
||||
|
||||
+134
-42
@@ -228,47 +228,110 @@ fi
|
||||
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
|
||||
set +m
|
||||
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
|
||||
child="" timer="" verified=0
|
||||
child="" timer="" session_ref="" verified=0
|
||||
stat_fields() {
|
||||
local pid="$1" raw="" rest
|
||||
local pid="$1" raw="" rest file="/proc/$1/stat"
|
||||
if [ "$pid" = "${child:-}" ] && [ -n "${session_ref:-}" ]; then
|
||||
file="/proc/self/fd/$session_ref/stat"
|
||||
fi
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw 2>/dev/null < "/proc/$pid/stat" || :
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
IFS= read -r -d '' raw 2>/dev/null < "$file" || :
|
||||
[ -n "$raw" ] || { [ -e "$file" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
|
||||
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
|
||||
}
|
||||
clock_cs() {
|
||||
local boot spare
|
||||
read -r boot spare < /proc/uptime || return 1
|
||||
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
|
||||
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
|
||||
}
|
||||
clean_session() {
|
||||
local entry pid rc live until=""
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
pid="${entry%/stat}"; pid="${pid##*/}"
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" != 3 ] || continue
|
||||
[ "$rc" = 0 ] || return 1
|
||||
[ "$ST_SESSION" = "$child" ] || continue
|
||||
case "$ST_STATE" in Z|X) continue ;; esac
|
||||
live=1
|
||||
if ! kill -KILL "$pid" 2>/dev/null; then
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
|
||||
fi
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
# Fixed, isolated stdlib code, never imported from the copy or its cwd.
|
||||
# Buffered reads avoid bash read -d's one-byte reads over the host inventory.
|
||||
/usr/bin/python3 -I -S -c '
|
||||
import os, signal, sys, time
|
||||
session = int(sys.argv[1])
|
||||
until = None
|
||||
anchor_fd = None
|
||||
|
||||
def fields(pid):
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
# A disappearing task can return EOF while its proc entry is still visible.
|
||||
# Retry once; a persistent unreadable/malformed entry refuses cleanup.
|
||||
if not raw:
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
rest = raw.rsplit(b") ", 1)
|
||||
if len(rest) != 2:
|
||||
raise ValueError("stat")
|
||||
words = rest[1].split()
|
||||
if len(words) < 4 or len(words[0]) != 1 or not words[0].isalpha():
|
||||
raise ValueError("stat")
|
||||
return words[0], int(words[3])
|
||||
|
||||
def original_leader():
|
||||
# The inherited proc directory refers to the original task, even if its
|
||||
# numeric PID is reused. A live trusted leader keeps this SID allocated.
|
||||
info = fields("self/fd/" + sys.argv[2])
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
raise ValueError("lost session anchor")
|
||||
return info
|
||||
|
||||
try:
|
||||
anchor_fd = os.pidfd_open(session)
|
||||
original_leader()
|
||||
while True:
|
||||
original_leader()
|
||||
live = False
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdecimal() or int(pid) == session:
|
||||
continue
|
||||
info = fields(pid)
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
continue
|
||||
live = True
|
||||
try:
|
||||
# Pin the task before rechecking membership: a recycled numeric
|
||||
# pid must never redirect our signal to an unrelated process.
|
||||
fd = os.pidfd_open(int(pid))
|
||||
try:
|
||||
current = fields(pid)
|
||||
if current is not None and current[1] == session and current[0] not in (b"Z", b"X"):
|
||||
# Check AFTER membership: while the original anchor is
|
||||
# alive, this SID cannot name a later session. The pidfd
|
||||
# then prevents task reuse between this check and kill.
|
||||
original_leader()
|
||||
signal.pidfd_send_signal(fd, signal.SIGKILL)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError:
|
||||
info = fields(pid)
|
||||
if info is not None and info[0] not in (b"Z", b"X"):
|
||||
raise
|
||||
if not live and original_leader()[0] == b"T":
|
||||
# The trusted leader is stopped after waiting for the command.
|
||||
# With all other members dead it may return the saved command code.
|
||||
signal.pidfd_send_signal(anchor_fd, signal.SIGCONT)
|
||||
break
|
||||
now = time.monotonic()
|
||||
# Inventory pass 1 remains outside the 250 ms confirmation budget.
|
||||
if until is None:
|
||||
until = now + 0.25
|
||||
if now >= until:
|
||||
raise TimeoutError("cleanup")
|
||||
time.sleep(0.01)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if anchor_fd is not None:
|
||||
os.close(anchor_fd)
|
||||
' "$child" "$session_ref"
|
||||
}
|
||||
finish() {
|
||||
local rc="$?"
|
||||
@@ -278,30 +341,59 @@ finish() {
|
||||
elif [ -n "$child" ]; then
|
||||
kill -KILL "$child" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$child" ] || wait "$child" 2>/dev/null || :
|
||||
# A refused cleanup may leave an unkillable member. Do not hide rc 86
|
||||
# behind an unbounded wait; the caller retains its existing outer guard.
|
||||
if [ "$rc" != 86 ] && [ -n "$child" ]; then
|
||||
local command_rc=0
|
||||
wait "$child" 2>/dev/null || command_rc=$?
|
||||
[ "$rc" != 0 ] || [ "$command_rc" = 0 ] || rc=87
|
||||
fi
|
||||
[ -z "$session_ref" ] || exec {session_ref}<&-
|
||||
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
# The trusted session leader stays alive through cleanup. Copy code runs as
|
||||
# its child; only after cleanup continues the stopped leader does it return
|
||||
# the saved command status. An open proc directory pins its task identity.
|
||||
"$setsid_bin" /bin/bash -c '
|
||||
set +m
|
||||
kill -STOP "$BASHPID"
|
||||
"$@" & command=$!
|
||||
rc=0; wait "$command" 2>/dev/null || rc=$?
|
||||
kill -STOP "$BASHPID"
|
||||
exit "${rc}"
|
||||
' -- "$@" & child=$!
|
||||
{ exec {session_ref}<"/proc/$child"; } 2>/dev/null || exit 85
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
[ "$rc" != 3 ] || exit 85
|
||||
if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then
|
||||
verified=1
|
||||
kill -CONT "$child" 2>/dev/null || exit 85
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$timer" 2>/dev/null; then
|
||||
# Persistent unreadable metadata remains a session refusal, not a
|
||||
# command timeout. A transient read gets only this same inner budget.
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
exit 81
|
||||
fi
|
||||
"$sleep_bin" 0.002
|
||||
done
|
||||
[ "$verified" = 1 ] || exit 85
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 86
|
||||
case "$ST_STATE" in Z|X) exit 86 ;; esac
|
||||
[ "$ST_STATE" != T ] || break
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
rc=0; wait "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 87
|
||||
exit 0
|
||||
MOSAIC_TESTER_SUPERVISOR
|
||||
# row253-supervisor-end
|
||||
@@ -309,7 +401,7 @@ MOSAIC_TESTER_SUPERVISOR
|
||||
tester_tools() {
|
||||
local input target p st magic
|
||||
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD=""
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head; do
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head /usr/bin/python3; do
|
||||
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
|
||||
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
|
||||
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
|
||||
|
||||
Reference in new issue
Block a user