fix(release): validate non-console config keys with target loader

This commit is contained in:
jason.woltje committed 2026-10-22 17:16:31 -05:00
1 parent 01983cd3f1
commit 0585b32130
3 files changed
+81 -14

No files matched your search

+6 -3
View File
@@ -632,9 +632,12 @@ 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. 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.
+57
View File
@@ -2585,3 +2585,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);
});
+18 -11
View File
@@ -1073,27 +1073,34 @@ 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. 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 key; 1: no key, 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.
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")?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.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"' ]; 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 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"' ]; then
check_refuse config-console-key "$cfg holds a console 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