Files
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

267 lines
17 KiB
Diff

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,'[email protected]'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment="MOSAIC_CLAUDE_BIN=/a b/claude"\n')],
+ ['Environment single quotes',(e)=>edit(join(e.units,'[email protected]'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment=\'MOSAIC_CLAUDE_BIN=/a b/claude\'\n')],
+ ['Environment backslash',(e)=>edit(join(e.units,'[email protected]'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment=MOSAIC_CLAUDE_BIN=/a\\ b/claude\n')],
+ ['ExecStart quotes',(e)=>edit(join(e.units,'[email protected]'),' 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 ;;