diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 1a2d5c5ba..b5f9c2119 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -4,7 +4,7 @@ import test, {after} from 'node:test'; import assert from 'node:assert/strict'; import {execFileSync,spawn,spawnSync} from 'node:child_process'; -import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,appendFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,lstatSync,symlinkSync,readdirSync,realpathSync} from 'node:fs'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,appendFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,lstatSync,symlinkSync,linkSync,readdirSync,realpathSync} from 'node:fs'; import {createServer} from 'node:http'; import {createHash} from 'node:crypto'; import {DatabaseSync} from 'node:sqlite'; @@ -1449,9 +1449,9 @@ print(n,flush=True)`; // A subtest that fails before noLeak leaves its watches running; they end with the file. const WATCHERS=new Set(); after(()=>{for(const ch of WATCHERS)ch.kill('SIGKILL')}); -// A proof file under e.root, outside the data root, with its read watch running. -async function proofFile(e,name='proof.187',mode=0o600){ - const f=join(e.root,name);writeFileSync(f,SENTINEL+'\n',{mode});chmodSync(f,mode); +// A proof file under dir (e.root, outside the data root, unless given), with its read watch running. +async function proofFile(e,name='proof.187',mode=0o600,dir=e.root){ + const f=join(dir,name);writeFileSync(f,SENTINEL+'\n',{mode});chmodSync(f,mode); const ch=spawn('python3',['-I','-c',WATCH,f],{stdio:['pipe','pipe','inherit']}); let out='';ch.stdout.on('data',(d)=>{out+=d}); await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))}); @@ -1650,3 +1650,58 @@ test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{ await noLeak(e); }); }); + +// Row 188 (#1686, P19 409 and 410): the checks on a carried pair, and a proof file inside REPO. +// A file gone from its path keeps a hard link under e.root, so the inode and its read watch +// outlive the path: inotify sends IN_IGNORED, which the watch would count, when a watched inode goes. +const carried=['proof: carried, header x-console-proof']; +test('T54 row 188: a carried pair gets the proof file checks, and a proof file inside the repository refuses',async(t)=>{ + const changes=[ + ['chmod 644',(e,f)=>{chmodSync(f,0o644);return 'has mode 644, not 600 or 400'}], + ['a symlink to a mode-600 file',async(e,f)=>{const g=await proofFile(e,'swap.188');linkSync(f,join(e.root,'kept.188'));rmSync(f);symlinkSync(g,f);return 'is a symlink'}], + ['removal',(e,f)=>{linkSync(f,join(e.root,'kept.188'));rmSync(f);return 'cannot be found'}], + ]; + for(const [name,change] of changes){ + await t.test(`test 1: an activate that carries the pair refuses after ${name}, in the plan and the run, before anything stops`,async(t)=>{ + const e=await mk(t),base=await seedPreview(e),f=await proofFile(e); + const r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + const why=`--proof-file ${f} ${await change(e,f)}`; + const out=await planOnly(e,3,'activate',C1,'acme'); + assert.deepEqual(proofLines(out),carried);assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]); + await refused(e,['activate',C1,'acme'],'proof-file-invalid',why); + assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,[...base,...pair(f)]); + await noLeak(e); + }); + } + await t.test('test 2: a rollback that carries the pair refuses after chmod 644, in the plan and the run, before anything stops',async(t)=>{ + const e=await mk(t),base=await seedPreview(e),f=await proofFile(e); + const r=await prun(e,'activate',C1,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); + chmodSync(f,0o644);const why=`--proof-file ${f} has mode 644, not 600 or 400`; + const out=await planOnly(e,3,'rollback','acme'); + assert.ok(out.includes(`to: ${B}`),out.join('\n')); + assert.deepEqual(proofLines(out),carried);assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]); + await refused(e,['rollback','acme'],'proof-file-invalid',why); + assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,[...base,...pair(f)]); + await noLeak(e); + }); + // Tests 3 and 4: a mode-600 file of this uid, so only the realpath check can refuse it. + const inRepo=async(t,e,dir)=>{t.after(()=>rmSync(dir,{recursive:true,force:true}));mkdirSync(dir);return proofFile(e,'proof.188',0o600,dir)}; + await t.test('test 3: a proof file inside the repository refuses, in the plan and the run, before anything stops',async(t)=>{ + const e=await mk(t);await seedPreview(e);const f=await inRepo(t,e,join(repo,'proof-r188')); + const why=`--proof-file ${f} is inside the repository or the copies`; + const out=await planOnly(e,3,'activate',C1,'acme',...pair(f)); + assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]); + await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',why); + await noLeak(e); + }); + await t.test('test 4: a path outside the repository through a directory symlink into it refuses on its realpath',async(t)=>{ + const e=await mk(t);await seedPreview(e);await inRepo(t,e,join(repo,'proof-r188')); + const link=join(e.root,'repo-link.188');symlinkSync(join(repo,'proof-r188'),link); + const f=join(link,'proof.188');assert.ok(!lstatSync(f).isSymbolicLink());assert.ok(!f.startsWith(`${repo}/`)); + const why=`--proof-file ${f} is inside the repository or the copies`; + const out=await planOnly(e,3,'activate',C1,'acme',...pair(f)); + assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]); + await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',why); + await noLeak(e); + }); +});