From 3b17c75508a0fecee539b60ac4d16cc665ae0147 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 17:16:31 -0500 Subject: [PATCH] fix(release): validate non-console config keys with target loader --- docs/TOOLS.md | 11 +++-- packages/cli/tests/stack-release.test.mjs | 57 +++++++++++++++++++++++ scripts/stack-release.sh | 32 ++++++++----- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 180f65d24..cc19e9b47 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -632,9 +632,14 @@ onto the pointer's copy from the target, past any same-copy re-pin, checked against the set's `SHA256SUMS`. `--send` and `--tester-port` follow the pointer. When that set is missing, fails its sums, holds no readable argv or already ran `--sso`, the run refuses `sso-rollback-flags`. Any activate or rollback also -refuses `config-console-key` before anything stops when the config holds a -`console` key the target's `scripts/mosaic-config.mjs validate` refuses (a copy -before row 192 refuses the key). The message says to remove the key first. So a +refuses `config-console-key` before anything stops when the target's +`scripts/mosaic-config.mjs validate` refuses the config, including a non-console +key. Every existing parseable config is checked; a missing file passes. A target +without a loader accepts only the oldest top-level keys: `configVersion`, +`environment`, `dataRoot`, and `execution`; any later key refuses. The refusal +names the rejected key and says to restore the config.json backup first. A +`console` or `federation` key keeps row 203's message, which says the config +holds a console or federation key. So a window re-pins to row 192 or later before it writes the key, and a rollback past row 192 removes the key first. diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 34bc48579..882c5aea4 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -2598,3 +2598,60 @@ test('row196 rehearsal selects socket only for marked copies and uses scratch da assert.equal(untouched(e),before); }); }); + +// #1797: validate every config with the target loader, even without console. +function anyConfigLoader(file,text){ + const old=existsSync(file)?readFileSync(file):null; + if(old!==null)chmodSync(file,0o644); + mkdirSync(dirname(file),{recursive:true});writeFileSync(file,text); + return ()=>{if(old===null)rmSync(file);else{writeFileSync(file,old);chmodSync(file,0o444);}}; +} +const ANY_CONFIG_MSG=(e,to,op,why)=>`${e.cfg} is refused by ${to}'s loader (${why}); remove the refused key first (restore the config.json backup from the window's step 1), then run the ${op} again`; +test('config-any-key: a non-console target refusal stops activate and rollback before any stop',async t=>{ + const e=await mk(t);await seed(e); + let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + const why='mosaic-config: unsupported configuration key: "execution"'; + const undo=anyConfigLoader(join(COPIES,B,'scripts/mosaic-config.mjs'),`console.error(${JSON.stringify(why)});process.exitCode=2;\n`); + try{ + assert.equal(Object.hasOwn(JSON.parse(readFileSync(e.cfg,'utf8')),'console'),false); + for(const op of ['activate','rollback']){ + const args=op==='activate'?['activate',B,'acme']:['rollback','acme']; + await refused(e,args,'config-console-key',ANY_CONFIG_MSG(e,B,op,why)); + } + const out=await planOnly(e,3,'rollback','acme'); + assert.deepEqual(out.filter(l=>l.startsWith('refuses ')),[`refuses config-console-key: ${ANY_CONFIG_MSG(e,B,'rollback',why)}`]); + }finally{undo();} +}); +test('config-any-key: accepting loaders run validate with the selected config on activate and rollback',async t=>{ + const e=await mk(t);await seed(e);const witness=join(e.root,'loader-calls.jsonl'),canary='resolved-config-must-not-leak'; + const undos=[B,C1].map(id=>anyConfigLoader(join(COPIES,id,'scripts/mosaic-config.mjs'),`import fs from 'node:fs';fs.appendFileSync(${JSON.stringify(witness)},JSON.stringify({copy:${JSON.stringify(id)},args:process.argv.slice(2),config:process.env.MOSAIC_CONFIG})+'\\n');console.log(${JSON.stringify(canary)});\n`)); + try{ + for(const args of [['activate',C1,'acme'],['rollback','acme']]){ + const r=await run(e,...args);assert.equal(r.code,0,r.out+r.err);assert.equal((r.out+r.err).includes(canary),false); + } + assert.deepEqual(lines(witness).map(JSON.parse),[ + {copy:C1,args:['validate'],config:e.cfg},{copy:B,args:['validate'],config:e.cfg}, + ]); + }finally{for(const undo of undos)undo();} +}); +function isolatedConfigChecks(e){ + const source=readFileSync(SR,'utf8'),body=source.split('config_checks() {')[1]?.split('\n}\n')[0];assert.ok(body,'config_checks exists'); + return spawnSync('bash',['-c','config_checks() {'+body+'\n}\ncheck_refuse(){ printf "%s: %s\\n" "$1" "$2" >&2; exit 3; }; config_checks'],{ + encoding:'utf8',env:{...e.env,COPIES:join(e.root,'target-copies'),TO:'old',OP:'activate'}, + }); +} +test('config-any-key: missing-loader copies accept only the four oldest keys',async t=>{ + const e=await mk(t);let r=isolatedConfigChecks(e);assert.equal(r.status,0,r.stderr); + const original=JSON.parse(readFileSync(e.cfg,'utf8')); + for(const [key,value] of [['compactionReadBytes',1048576],['compactionStreams',['messages']]]){ + writeFileSync(e.cfg,JSON.stringify({...original,[key]:value}));r=isolatedConfigChecks(e); + assert.equal(r.status,3,r.stderr);assert.match(r.stderr,new RegExp(`configuration key: "${key}"`)); + assert.ok(r.stderr.includes('restore the config.json backup from the window\'s step 1')); + } +}); +test('config-any-key: missing config passes but malformed JSON refuses without calling the loader',async t=>{ + const e=await mk(t),file=join(e.root,'target-copies/old/scripts/mosaic-config.mjs'),witness=join(e.root,'validate-called'); + mkdirSync(dirname(file),{recursive:true});writeFileSync(file,`import fs from 'node:fs';fs.writeFileSync(${JSON.stringify(witness)},'called');\n`); + rmSync(e.cfg);let r=isolatedConfigChecks(e);assert.equal(r.status,0,r.stderr);assert.equal(existsSync(witness),false); + writeFileSync(e.cfg,'{');r=isolatedConfigChecks(e);assert.equal(r.status,3,r.stderr);assert.match(r.stderr,/cannot be read as JSON/);assert.equal(existsSync(witness),false); +}); diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 7a8c347b2..2656f95b0 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -1073,27 +1073,37 @@ slot_problem() { [ "$size" != 0 ] || echo "is empty" } -# Row 200 item 3 and the design's phase 2 rollback order: a config.json with a `console` key must be -# one the target's loader reads, or its bus, Console and seats fail on their next read. Runs the -# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept. +# Row 200 item 3 and #1797: every existing parseable config must be one the +# target loader reads. Older copies without a loader accept only the four keys +# in c3d29e796's first loader. A console or federation key (row 203) keeps its own refusal text. +# Resolved config stdout is never kept. config_checks() { - local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first - # 0: a console or federation key (row 203: a pre-203 loader refuses federation too); 1: neither, or no file; 2: the file doesn't parse, so whether it holds the key - # is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on - # such a file before anything is read or stopped, so this is reached only if the file changes after it. + local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first extra + # 1: missing file; 2: unreadable JSON. This also fences a change after load_config. + # extra: "console" or "federation" first (row 203: a pre-203 loader refuses federation too), + # then the first key outside the oldest set. local key=0 - node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)} -process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.hasOwn(d,"federation"))?0:1)' -- "$cfg" 2>/dev/null || key=$? + extra="$(node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)} +const oldest=["configVersion","environment","dataRoot","execution"]; +if(d===null||typeof d!=="object"||Array.isArray(d)){process.stdout.write(JSON.stringify("[configuration shape]"));process.exit(0)} +const k=Object.hasOwn(d,"console")?"console":Object.hasOwn(d,"federation")?"federation":Object.keys(d).find(k=>!oldest.includes(k));if(k!==undefined)process.stdout.write(JSON.stringify(k))' -- "$cfg" 2>/dev/null)" || key=$? [ "$key" != 1 ] || return 0 if [ "$key" != 0 ]; then check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again" return 0 fi - if [ ! -f "$loader" ]; then out="it has no scripts/mosaic-config.mjs" + if [ ! -f "$loader" ]; then + [ -n "$extra" ] || return 0 + if [ "$extra" = '"console"' ] || [ "$extra" = '"federation"' ]; then out="it has no scripts/mosaic-config.mjs" + else out="it has no scripts/mosaic-config.mjs to validate configuration key: $extra"; fi elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0 fi first="${out%%$'\n'*}" - check_refuse config-console-key "$cfg holds a console or federation key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again" + if [ "$extra" = '"console"' ] || [ "$extra" = '"federation"' ]; then + check_refuse config-console-key "$cfg holds a console or federation key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again" + else + check_refuse config-console-key "$cfg is refused by $TO's loader ($first); remove the refused key first (restore the config.json backup from the window's step 1), then run the $OP again" + fi } # Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose