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

This commit is contained in:
jason.woltje committed 2026-10-23 00:18:01 -05:00
1 parent fdedc56e2a
commit 39bde4fd43
3 files changed
+86 -14

No files matched your search

+8 -3
View File
@@ -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. 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 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 `--sso`, the run refuses `sso-rollback-flags`. Any activate or rollback also
refuses `config-console-key` before anything stops when the config holds a refuses `config-console-key` before anything stops when the target's
`console` key the target's `scripts/mosaic-config.mjs validate` refuses (a copy `scripts/mosaic-config.mjs validate` refuses the config, including a non-console
before row 192 refuses the key). The message says to remove the key first. So a 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 window re-pins to row 192 or later before it writes the key, and a rollback past
row 192 removes the key first. row 192 removes the key first.
+57
View File
@@ -2598,3 +2598,60 @@ test('row196 rehearsal selects socket only for marked copies and uses scratch da
assert.equal(untouched(e),before); 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);
});
+21 -11
View File
@@ -1073,27 +1073,37 @@ slot_problem() {
[ "$size" != 0 ] || echo "is empty" [ "$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 # Row 200 item 3 and #1797: every existing parseable config must be one the
# one the target's loader reads, or its bus, Console and seats fail on their next read. Runs the # target loader reads. Older copies without a loader accept only the four keys
# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept. # 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() { config_checks() {
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first extra
# 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 # 1: missing file; 2: unreadable JSON. This also fences a change after load_config.
# is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on # extra: "console" or "federation" first (row 203: a pre-203 loader refuses federation too),
# such a file before anything is read or stopped, so this is reached only if the file changes after it. # then the first key outside the oldest set.
local key=0 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)} 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)}
process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.hasOwn(d,"federation"))?0:1)' -- "$cfg" 2>/dev/null || key=$? 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 [ "$key" != 1 ] || return 0
if [ "$key" != 0 ]; then 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" 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 return 0
fi 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 elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0
fi fi
first="${out%%$'\n'*}" 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 # Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose