From f382699d472620c26644de449a32c9dd59fb1f0c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 05:13:06 -0600 Subject: [PATCH 1/2] fix: bound tester supervisor cleanup and verify fast sessions (#1873) --- .../cli/tests/case-supervisor-cost.test.mjs | 82 ++++++++++++ .../tests/case-supervisor-session-fixture.mjs | 52 ++++++++ packages/cli/tests/case1-accept.test.mjs | 2 +- packages/cli/tests/case2-unit.test.mjs | 44 ++++--- scripts/case1-accept.sh | 122 +++++++++++++----- scripts/case2-unit.sh | 122 +++++++++++++----- 6 files changed, 338 insertions(+), 86 deletions(-) create mode 100644 packages/cli/tests/case-supervisor-cost.test.mjs create mode 100644 packages/cli/tests/case-supervisor-session-fixture.mjs diff --git a/packages/cli/tests/case-supervisor-cost.test.mjs b/packages/cli/tests/case-supervisor-cost.test.mjs new file mode 100644 index 000000000..1049550b7 --- /dev/null +++ b/packages/cli/tests/case-supervisor-cost.test.mjs @@ -0,0 +1,82 @@ +// 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'])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'].includes(mode)?86:81,JSON.stringify(result)); + assert.equal(result.stdout,'');assert.equal(result.stderr,''); + if(!['unreadable','unkillable'].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.equal(leader[0],leader[1]); + 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==='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`); +}); diff --git a/packages/cli/tests/case-supervisor-session-fixture.mjs b/packages/cli/tests/case-supervisor-session-fixture.mjs new file mode 100644 index 000000000..45d295bf6 --- /dev/null +++ b/packages/cli/tests/case-supervisor-session-fixture.mjs @@ -0,0 +1,52 @@ +// Namespace-local evidence and cleanup. The JSON observation precedes teardown. +import {readFileSync,writeFileSync,readdirSync} 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+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]); +},100); +setInterval(()=>{},1000); +`); +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',timeout:8000,killSignal:'SIGKILL'}); +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; +try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{} +process.stdout.write(JSON.stringify({sessionLive,parent,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{} diff --git a/packages/cli/tests/case1-accept.test.mjs b/packages/cli/tests/case1-accept.test.mjs index 746fd8eca..7b41b370b 100644 --- a/packages/cli/tests/case1-accept.test.mjs +++ b/packages/cli/tests/case1-accept.test.mjs @@ -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'),'9158cc66c027a901e6751f01de56b92311f08ab804d64f14ec571c9c332642e1'); 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)); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 7a6382ec3..82977702f 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -256,24 +256,38 @@ 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='try:\n while True:'; + 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 ["424242"] + +def fake_fields(pid): + return (b"R", session) if alive else None + +def send(fd, sig): + global alive, kills + alive = False + kills += 1 + +fields = fake_fields +os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None) +signal = SimpleNamespace(SIGKILL=9, 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'],{encoding:'utf8'}); assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1'); }); diff --git a/scripts/case1-accept.sh b/scripts/case1-accept.sh index 8444dc1d4..38e6cc952 100755 --- a/scripts/case1-accept.sh +++ b/scripts/case1-accept.sh @@ -147,36 +147,74 @@ stat_fields() { 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 + +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]) + +try: + while True: + live = False + for pid in os.listdir("/proc"): + if not pid.isdecimal(): + 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"): + 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: + 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) +' "$child" } finish() { local rc="$?" @@ -186,20 +224,34 @@ 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. + [ "$rc" = 86 ] || [ -z "$child" ] || wait "$child" 2>/dev/null || : 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=$! +# Hold the trusted launcher until its session is observed. Even /bin/true +# cannot exit (and be reaped by bash) before verification. No copy code runs +# in this handshake; set +m ensures setsid need not fork away from child. +"$setsid_bin" /bin/bash -c 'kill -STOP "$BASHPID"; exec "$@"' -- "$@" & child=$! # 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 ]; 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 @@ -217,7 +269,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 diff --git a/scripts/case2-unit.sh b/scripts/case2-unit.sh index 946d9bb38..468c8601c 100755 --- a/scripts/case2-unit.sh +++ b/scripts/case2-unit.sh @@ -239,36 +239,74 @@ stat_fields() { 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 + +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]) + +try: + while True: + live = False + for pid in os.listdir("/proc"): + if not pid.isdecimal(): + 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"): + 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: + 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) +' "$child" } finish() { local rc="$?" @@ -278,20 +316,34 @@ 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. + [ "$rc" = 86 ] || [ -z "$child" ] || wait "$child" 2>/dev/null || : 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=$! +# Hold the trusted launcher until its session is observed. Even /bin/true +# cannot exit (and be reaped by bash) before verification. No copy code runs +# in this handshake; set +m ensures setsid need not fork away from child. +"$setsid_bin" /bin/bash -c 'kill -STOP "$BASHPID"; exec "$@"' -- "$@" & child=$! # 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 ]; 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 @@ -309,7 +361,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 -- 2.54.0 From 4a122699a0ff7c09305294edfed2173c8fcd7e1a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 08:22:31 -0600 Subject: [PATCH 2/2] fix: preserve tester session identity through cleanup (#1873) --- packages/cli/tests/case-cleanup-time.test.mjs | 4 +- .../cli/tests/case-session-identity.test.mjs | 34 +++++++++ .../cli/tests/case-supervisor-cost.test.mjs | 15 ++-- .../tests/case-supervisor-session-fixture.mjs | 14 ++-- packages/cli/tests/case1-accept.test.mjs | 2 +- packages/cli/tests/case2-unit.test.mjs | 17 ++--- scripts/case1-accept.sh | 72 ++++++++++++++----- scripts/case2-unit.sh | 72 ++++++++++++++----- 8 files changed, 177 insertions(+), 53 deletions(-) create mode 100644 packages/cli/tests/case-session-identity.test.mjs diff --git a/packages/cli/tests/case-cleanup-time.test.mjs b/packages/cli/tests/case-cleanup-time.test.mjs index e6fa10af5..de74423f6 100644 --- a/packages/cli/tests/case-cleanup-time.test.mjs +++ b/packages/cli/tests/case-cleanup-time.test.mjs @@ -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'); diff --git a/packages/cli/tests/case-session-identity.test.mjs b/packages/cli/tests/case-session-identity.test.mjs new file mode 100644 index 000000000..28664ccec --- /dev/null +++ b/packages/cli/tests/case-session-identity.test.mjs @@ -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'); +}); diff --git a/packages/cli/tests/case-supervisor-cost.test.mjs b/packages/cli/tests/case-supervisor-cost.test.mjs index 1049550b7..61e9aa3ec 100644 --- a/packages/cli/tests/case-supervisor-cost.test.mjs +++ b/packages/cli/tests/case-supervisor-cost.test.mjs @@ -45,24 +45,27 @@ 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'])test(`${name}: namespace cleanup ${mode}`,t=>{ +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'].includes(mode)?86:81,JSON.stringify(result)); - assert.equal(result.stdout,'');assert.equal(result.stderr,''); - if(!['unreadable','unkillable'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive'); + 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.equal(leader[0],leader[1]); + 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==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');} + 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'); } }); diff --git a/packages/cli/tests/case-supervisor-session-fixture.mjs b/packages/cli/tests/case-supervisor-session-fixture.mjs index 45d295bf6..3bb9e319b 100644 --- a/packages/cli/tests/case-supervisor-session-fixture.mjs +++ b/packages/cli/tests/case-supervisor-session-fixture.mjs @@ -1,5 +1,5 @@ // Namespace-local evidence and cleanup. The JSON observation precedes teardown. -import {readFileSync,writeFileSync,readdirSync} from 'node:fs'; +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'); @@ -27,11 +27,16 @@ 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 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',timeout:8000,killSignal:'SIGKILL'}); +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=[]; @@ -45,8 +50,9 @@ for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){ if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid)); }catch{} } -let parent; +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,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live})); +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{} diff --git a/packages/cli/tests/case1-accept.test.mjs b/packages/cli/tests/case1-accept.test.mjs index 7b41b370b..f3f75b015 100644 --- a/packages/cli/tests/case1-accept.test.mjs +++ b/packages/cli/tests/case1-accept.test.mjs @@ -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'),'9158cc66c027a901e6751f01de56b92311f08ab804d64f14ec571c9c332642e1'); + 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)); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 82977702f..e6533c1fa 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -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`); @@ -261,7 +261,7 @@ test('row253 C4 cleanup budget starts after inventory pass one',()=>{ const source=read(UNIT); 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='try:\n while True:'; + const anchor='\ntry:\n'; assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique'); const standins=` from types import SimpleNamespace @@ -272,22 +272,23 @@ clock = time.monotonic() def inventory(path): global clock clock += 1000 - return ["424242"] + return ["999"] def fake_fields(pid): - return (b"R", session) if alive else None + return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None) def send(fd, sig): global alive, kills - alive = False - kills += 1 + 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, pidfd_send_signal=send) +signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send) time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None) `; - const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242'],{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'); }); diff --git a/scripts/case1-accept.sh b/scripts/case1-accept.sh index 38e6cc952..10bc835c3 100755 --- a/scripts/case1-accept.sh +++ b/scripts/case1-accept.sh @@ -136,12 +136,15 @@ 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" @@ -154,6 +157,7 @@ clean_session() { import os, signal, sys, time session = int(sys.argv[1]) until = None +anchor_fd = None def fields(pid): try: @@ -177,11 +181,22 @@ def fields(pid): 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(): + 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"): @@ -194,6 +209,10 @@ try: 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) @@ -203,7 +222,10 @@ try: info = fields(pid) if info is not None and info[0] not in (b"Z", b"X"): raise - if not live: + 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. @@ -214,7 +236,10 @@ try: time.sleep(0.01) except Exception: sys.exit(1) -' "$child" +finally: + if anchor_fd is not None: + os.close(anchor_fd) +' "$child" "$session_ref" } finish() { local rc="$?" @@ -226,22 +251,35 @@ finish() { fi # A refused cleanup may leave an unkillable member. Do not hide rc 86 # behind an unbounded wait; the caller retains its existing outer guard. - [ "$rc" = 86 ] || [ -z "$child" ] || wait "$child" 2>/dev/null || : + 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=$! -# Hold the trusted launcher until its session is observed. Even /bin/true -# cannot exit (and be reaped by bash) before verification. No copy code runs -# in this handshake; set +m ensures setsid need not fork away from child. -"$setsid_bin" /bin/bash -c 'kill -STOP "$BASHPID"; exec "$@"' -- "$@" & 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" != 3 ] || exit 85 - if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ]; then + if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then verified=1 kill -CONT "$child" 2>/dev/null || exit 85 break @@ -255,13 +293,15 @@ while :; do "$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 diff --git a/scripts/case2-unit.sh b/scripts/case2-unit.sh index 468c8601c..ad0e5b2b8 100755 --- a/scripts/case2-unit.sh +++ b/scripts/case2-unit.sh @@ -228,12 +228,15 @@ 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" @@ -246,6 +249,7 @@ clean_session() { import os, signal, sys, time session = int(sys.argv[1]) until = None +anchor_fd = None def fields(pid): try: @@ -269,11 +273,22 @@ def fields(pid): 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(): + 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"): @@ -286,6 +301,10 @@ try: 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) @@ -295,7 +314,10 @@ try: info = fields(pid) if info is not None and info[0] not in (b"Z", b"X"): raise - if not live: + 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. @@ -306,7 +328,10 @@ try: time.sleep(0.01) except Exception: sys.exit(1) -' "$child" +finally: + if anchor_fd is not None: + os.close(anchor_fd) +' "$child" "$session_ref" } finish() { local rc="$?" @@ -318,22 +343,35 @@ finish() { fi # A refused cleanup may leave an unkillable member. Do not hide rc 86 # behind an unbounded wait; the caller retains its existing outer guard. - [ "$rc" = 86 ] || [ -z "$child" ] || wait "$child" 2>/dev/null || : + 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=$! -# Hold the trusted launcher until its session is observed. Even /bin/true -# cannot exit (and be reaped by bash) before verification. No copy code runs -# in this handshake; set +m ensures setsid need not fork away from child. -"$setsid_bin" /bin/bash -c 'kill -STOP "$BASHPID"; exec "$@"' -- "$@" & 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" != 3 ] || exit 85 - if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ]; then + if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then verified=1 kill -CONT "$child" 2>/dev/null || exit 85 break @@ -347,13 +385,15 @@ while :; do "$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 -- 2.54.0