diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index e5610bb0d..d8546dc30 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -90,7 +90,13 @@ function systemctl(){ } process.exit(0); } - if(verb==='is-active'){const q=rest[0]==='--quiet',u=q?rest[1]:rest[0],s=(load()[u]||{}).state||'inactive';if(!q)console.log(s);process.exit(s==='active'?0:3)} + if(verb==='is-active'){ + const q=rest[0]==='--quiet',u=q?rest[1]:rest[0];let s=(load()[u]||{}).state||'inactive'; + // bus-stops-after N (T29): the next N bus checks answer the real state, every later one answers + // inactive, and units.json stays as it is, as if the bus stopped between two checks. + if(isBus(u)&&fault('bus-stops-after')){const n=Number(fs.readFileSync(faultFile('bus-stops-after'),'utf8'));if(n>0)fs.writeFileSync(faultFile('bus-stops-after'),String(n-1));else s='inactive'} + if(!q)console.log(s);process.exit(s==='active'?0:3); + } if(verb==='is-enabled'){console.log('disabled');process.exit(1)} if(verb==='daemon-reload')process.exit(0); if(verb==='list-units'){ @@ -150,9 +156,12 @@ function journalctl(){ } function systemdAnalyze(){ - const path=process.getBuiltinModule('node:path'); + const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'); if(process.argv[2]!=='--user'||process.argv[3]!=='unit-paths')process.exit(1); console.log(path.join(process.env.XDG_CONFIG_HOME,'systemd','user')); + // The unit-path fault adds its text as one more path, as T28's relative path needs. + const extra=path.join(process.env.SD_STATE,'faults','unit-path'); + if(fs.existsSync(extra))console.log(fs.readFileSync(extra,'utf8')); } // The bus host: holds the store open in WAL mode until SIGTERM, as the broker does. @@ -303,7 +312,7 @@ async function mk(t){ async function run(e,...args){ const before=existsSync(e.data)?kept(e):[]; - const ch=spawn('bash',[SR,...args,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']}); + const ch=spawn('bash',[e.sr??SR,...args,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']}); let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d}); const timer=setTimeout(()=>ch.kill('SIGKILL'),120000); const [code]=await once(ch,'close');clearTimeout(timer); @@ -730,3 +739,91 @@ test('T22 the stopped store read: a -wal or -shm refuses store-unclean and is le assert.deepEqual(Object.keys(stop.files),['bus.sqlite']);assert.deepEqual(start.files,stop.files); }); }); + +// --- Row 166 (#1663): docs/plans/2026-10-18_stack-release-hardening.md items 1 to 4 --- + +test('T28 a unit path, drop-in directory or drop-in file that cannot be read, or a relative unit path, refuses drop-in-override before any stop',async(t)=>{ + const cases=[ + ['unit path',(e)=>[e.units,`cannot inspect unit path ${e.units}`]], + ['drop-in directory',(e)=>{const d=join(e.units,`${BUS}.d`);mkdirSync(d);writeFileSync(join(d,'note.conf'),'[Service]\n');return [d,`cannot inspect drop-ins ${d}`]}], + ['drop-in file',(e)=>{const d=join(e.units,`${BUS}.d`),f=join(d,'claude.conf');mkdirSync(d);writeFileSync(f,'[Service]\n');return [f,`cannot read drop-in ${f}`]}], + ]; + for(const [name,make] of cases)await t.test(name,async(t)=>{ + const e=await mk(t);await seed(e); + const [p,msg]=make(e),before=fp(e),n=calls(e).length,logs=logLines(e).length,mode=statSync(p).mode&0o7777; + chmodSync(p,0); + let r;try{r=await run(e,'activate',C1,'acme')}finally{chmodSync(p,mode)} + assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,`stack-release: refusing drop-in-override: ${msg}\n`); + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]); + assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['active','active']); + assert.equal(logLines(e).length,logs+1);assert.equal(logLines(e).at(-1).code,'drop-in-override'); + }); + // install_guard refuses a unit path that isn't absolute, so the precheck does too. + await t.test('relative unit path',async(t)=>{ + const e=await mk(t);await seed(e);fault(e,'unit-path','systemd/user'); + const before=fp(e),n=calls(e).length,logs=logLines(e).length; + const r=await run(e,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err); + assert.equal(r.err,'stack-release: refusing drop-in-override: invalid systemd unit path systemd/user\n'); + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]); + assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['active','active']); + assert.equal(logLines(e).length,logs+1);assert.equal(logLines(e).at(-1).code,'drop-in-override'); + }); +}); + +test('T29 a bus that stops during the held-store read: --plan says so, rollback --restore refuses restore-mismatch',async(t)=>{ + await t.test('--plan',async(t)=>{ + const e=await mk(t);await seed(e);fault(e,'bus-stops-after','1'); + const before=fp(e),logs=logLines(e).length; + const r=await run(e,'activate',C,'acme','--plan');assert.equal(r.code,0,r.err);assert.equal(r.err,''); + assert.equal(r.out,['plan: activate for acme',`from: ${B}`,`to: ${C}`, + 'store schema: unknown (the bus stopped during the read, so the store state changed; run --plan again)','target schema: 3h', + 'commits: the run adds 2 (newest first, at most 200):',...commitLines(`${B}..${C}`),...planTail(e,C),''].join('\n')); + assert.deepEqual(fp(e),before);assert.equal(logLines(e).length,logs); + }); + await t.test('rollback --restore',async(t)=>{ + const e=await mk(t);await seed(e); + assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation; + const stamp=readFileSync(join(e.data,'backups','stack',act,'STAMP'),'utf8').replace(/\n+$/,''); + fault(e,'bus-stops-after','1');const before=fp(e),n=calls(e).length; + const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,3,r.out+r.err); + assert.equal(r.err,`stack-release: refusing restore-mismatch: the bus stopped while the events after ${stamp} were counted, so the store state changed; nothing was stopped\n`); + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(posts(e).length,0); + assert.equal(logLines(e).at(-1).code,'restore-mismatch'); + }); +}); + +test('T30 a copy check that agrees on another copy fails the gate with copy-check and rolls back',async(t)=>{ + const e=await mk(t);await seed(e); + // A repo of its own, so the stand-in check replaces stack-copy.sh for this test only. Sourced, the + // stand-in is the real script; run with `check`, it reports all three on the pointer's copy. + const own=join(e.root,'repo');cpSync(repo,own,{recursive:true}); + const sc=join(own,'scripts','stack-copy.sh'),at=join(COPIES,B);cpSync(sc,join(own,'scripts','stack-copy.real.sh')); + writeFileSync(sc,`#!/usr/bin/env bash\nreal="$(dirname "\${BASH_SOURCE[0]}")/stack-copy.real.sh"\nif [ "\${BASH_SOURCE[0]}" != "$0" ]; then source "$real"; return 0; fi\nif [ "\${1:-}" = check ]; then printf 'bus: %s\\nConsole: %s\\nCLI: %s\\n' '${at}' '${at}' '${at}'; exit 0; fi\nexec bash "$real" "$@"\n`); + e.sr=join(own,'scripts','stack-release.sh'); + const r=await run(e,'activate',C1,'acme','--timeout','3');assert.equal(r.code,1,r.out+r.err); + assert.equal(r.err,rolledBack('copy-check')); + assert.ok(r.out.includes(`stack-release: gate failed on ${C1}: copy-check (the bus, Console and CLI agree on ${at}, not ${join(COPIES,C1)})\n`),r.out); + const l=logLines(e).at(-1);assert.deepEqual([l.op,l.result,l.code,l.rollback],['activate','gate-failed','copy-check','ok']); + assert.equal(pointer(e).copy,B);assert.deepEqual([units(e)[BUS].state,units(e)[BUS].wd],['active',at]); +}); + +test('T31 a quoted or escaped Environment= or ExecStart= is unreadable: refusals by name, nothing read from /a',async(t)=>{ + const forms=[ + ['Environment double quotes',(e)=>edit(join(e.units,'mosaic-bus@.service'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment="MOSAIC_CLAUDE_BIN=/a b/claude"\n')], + ['Environment single quotes',(e)=>edit(join(e.units,'mosaic-bus@.service'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment=\'MOSAIC_CLAUDE_BIN=/a b/claude\'\n')], + ['Environment backslash',(e)=>edit(join(e.units,'mosaic-bus@.service'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment=MOSAIC_CLAUDE_BIN=/a\\ b/claude\n')], + ['ExecStart quotes',(e)=>edit(join(e.units,'mosaic-bus@.service'),' bus start %i\n',' bus start %i "--pm"\n')], + ['Console ExecStart quotes',(e)=>edit(join(e.units,CON),' --business acme\n',' --business "acme"\n')], + ]; + for(const [name,change] of forms)await t.test(name,async(t)=>{ + const e=await mk(t);await seed(e);change(e);const before=fp(e),n=calls(e).length; + const r=await run(e,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err); + assert.equal(r.err,`stack-release: refusing unit-flags-drift: cannot read ${BUS} or ${CON}\n`); + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]); + const s=await run(e,'status','acme');assert.equal(s.code,0,s.err);assert.ok(s.out.includes('unit flags: cannot read the units\n'),s.out); + const a=await mk(t);await seed(a,{pointer:'none'});change(a); + const d=await run(a,'adopt','acme');assert.equal(d.code,3,d.out+d.err); + assert.equal(d.err,`stack-release: refusing check-failed: cannot read ${BUS} or ${CON}\n`);assert.equal(pointer(a),null); + for(const x of [r,d])assert.ok(!x.err.includes('/a'),x.err); + }); +}); diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 4b16e848a..ed5b41d46 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -143,20 +143,26 @@ process.exit(1);' -- "$LOG" "$1" "$P_COPY" show() { systemctl --user show "$1" -p LoadState -p WorkingDirectory -p ExecStart -p Environment -p MainPID -p DropInPaths 2>/dev/null; } argv_of() { sed -n 's/^ExecStart=.*argv\[\]=\([^;]*\) ;.*/\1/p' <<< "$1"; } +# systemctl show quotes a value that holds a space and escapes with a backslash, and the read -a +# split below can't undo either, so such a line is unreadable rather than read wrong (row 166 item 4). +unquoted() { [[ "$1" != *[\"\'\\]* ]]; } # Sets U_PM U_CLAUDE U_CONSOLE (array) from the effective units; returns 1 if a unit can't be read. read_units() { - local bus console tok + local bus console tok bus_argv console_argv env bus="$(show "$BUS_UNIT")" && console="$(show "$CONSOLE_UNIT")" || return 1 [ "$(sed -n 's/^LoadState=//p' <<< "$bus")" = loaded ] && [ "$(sed -n 's/^LoadState=//p' <<< "$console")" = loaded ] || return 1 + bus_argv="$(argv_of "$bus")" console_argv="$(argv_of "$console")" env="$(sed -n 's/^Environment=//p' <<< "$bus")" + unquoted "$bus_argv" && unquoted "$console_argv" || return 1 + unquoted "$env" || return 1 local -a argv - read -r -a argv <<< "$(argv_of "$bus")" + read -r -a argv <<< "$bus_argv" U_PM=0 for tok in "${argv[@]:1}"; do [ "$tok" != --pm ] || U_PM=1; done U_CLAUDE="" - read -r -a argv <<< "$(sed -n 's/^Environment=//p' <<< "$bus")" + read -r -a argv <<< "$env" for tok in "${argv[@]}"; do case "$tok" in MOSAIC_CLAUDE_BIN=*) U_CLAUDE="${tok#MOSAIC_CLAUDE_BIN=}" ;; esac; done - read -r -a argv <<< "$(argv_of "$console")" + read -r -a argv <<< "$console_argv" U_CONSOLE=() local i serve=0 for ((i = 0; i < ${#argv[@]}; i++)); do @@ -371,7 +377,10 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$main" "$START_MS" 2>&1)" || node -e ' fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$(console_port)" || return 1 GATE=copy-check - scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" > /dev/null 2>&1 || return 1 + out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1 + # check passes when the three agree on one copy; this run needs it to be the target (row 166 item 3). + out="$(sed -n 's/^bus: //p' <<< "$out")" + [ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; } if [ "$pm" = 1 ]; then GATE=pm-launch-unconfirmed pm_launched "$copy" || return 1 @@ -499,17 +508,23 @@ instance_busy() { done <<< "$out" } -# install_guard's drop-in scan (bus-service.sh), done before anything stops. +# install_guard's drop-in scan (bus-service.sh), done before anything stops. A path it can't read +# refuses here, as install_guard would refuse it after the stop (row 166 item 1). drop_in_override() { - local paths directory dropdir file + local paths directory dropdir file content paths="$(systemd-analyze --user unit-paths 2>/dev/null)" && [ -n "$paths" ] || refuse drop-in-override "systemd unit paths are unknown" while IFS= read -r directory; do - [ -n "$directory" ] && [ -d "$directory" ] || continue + [ -n "$directory" ] || continue + case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac + [ -e "$directory" ] || [ -L "$directory" ] || continue + [ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || refuse drop-in-override "cannot inspect unit path $directory" for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do - [ -d "$dropdir" ] || continue + [ -e "$dropdir" ] || [ -L "$dropdir" ] || continue + [ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir" for file in "$dropdir"/*.conf; do - [ -e "$file" ] || continue - if grep -Eq '^[[:space:]]*(ExecStart|WorkingDirectory)[[:space:]]*=' "$file" 2>/dev/null; then + [ -e "$file" ] || [ -L "$file" ] || continue + content="$(cat "$file" 2>/dev/null)" || refuse drop-in-override "cannot read drop-in $file" + if grep -Eq '^[[:space:]]*(ExecStart|WorkingDirectory)[[:space:]]*=' <<< "$content"; then refuse drop-in-override "$file overrides ExecStart or WorkingDirectory; the operator removes it as in TOOLS.md step 4" fi done @@ -572,17 +587,21 @@ do_adopt() { # pointer's schema, and says so, when there is no store file or it can't be read. plan_store() { local src="" why="" - S_SCHEMA="" S_FROM="" + S_SCHEMA="" S_FROM="" S_CHANGED="" if [ ! -e "$STORE" ]; then why="there is no store file" elif systemctl --user is-active --quiet "$BUS_UNIT"; then src="the store the running bus holds" S_SCHEMA="$(node -e "$STORE_JS" -- heldversion "$STORE" 2>/dev/null)" || S_SCHEMA="" + # A bus that stops during the readOnly open can leave -wal or -shm beside the stopped + # store, so the version read can't be planned on (row 166 item 2). + systemctl --user is-active --quiet "$BUS_UNIT" || S_CHANGED=1 elif store_unclean; then why="the store was not read: $UNCLEAN is present" else src="the stopped store" S_SCHEMA="$(store_read version 2>/dev/null)" || S_SCHEMA="" fi - if [ -n "$S_SCHEMA" ]; then S_FROM="read from $src" + if [ -n "$S_CHANGED" ]; then S_SCHEMA=unknown S_FROM="the bus stopped during the read, so the store state changed; run --plan again" + elif [ -n "$S_SCHEMA" ]; then S_FROM="read from $src" else S_SCHEMA="$P_SCHEMA" S_FROM="from the pointer; ${why:-$src could not be read}" fi } @@ -631,8 +650,10 @@ plan() { echo "to: $TO" echo "store schema: $S_SCHEMA ($S_FROM)" echo "target schema: $T_SCHEMA" - [ "$S_SCHEMA" = "$P_SCHEMA" ] || echo "note: the pointer says $P_SCHEMA; the run reads the stopped store and goes by that" - if [ "$S_SCHEMA" = "$T_SCHEMA" ]; then + [ "$S_SCHEMA" = "$P_SCHEMA" ] || [ -n "$S_CHANGED" ] || echo "note: the pointer says $P_SCHEMA; the run reads the stopped store and goes by that" + if [ -n "$S_CHANGED" ]; then + : # no migration or restore line from a read the bus stopped under + elif [ "$S_SCHEMA" = "$T_SCHEMA" ]; then echo "migration: none; the store is at the target's version" elif [ "$OP" = activate ] && in_chain "$S_SCHEMA"; then echo "migration: $S_SCHEMA to $T_SCHEMA, after a backup of the stopped store" @@ -715,6 +736,8 @@ do_rollback() { # no file (the probe); a stopped one falls under the store_unclean rule. if systemctl --user is-active --quiet "$BUS_UNIT"; then COUNT="$(node -e "$STORE_JS" -- heldcount "$STORE" "$(cat "$o/STAMP")")" || rc=1 + # plan_store's recheck (row 166 item 2), before anything stops. + systemctl --user is-active --quiet "$BUS_UNIT" || refuse restore-mismatch "the bus stopped while the events after $(cat "$o/STAMP") were counted, so the store state changed; nothing was stopped" else ! store_unclean || refuse store-unclean "$UNCLEAN is present; the bus did not close the store cleanly" COUNT="$(store_read count "$(cat "$o/STAMP")")" || rc=1 @@ -778,7 +801,7 @@ LOG="$STATE/stack-activation-log.jsonl" STORE="$DATA/bus/bus.sqlite" BUS_UNIT="mosaic-bus@$BUSINESS.service" FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL="" -STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" +STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED="" P_COPY="" P_PM=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() case "$OP" in adopt) do_adopt ;;