diff --git a/scripts/candidate-files.mjs b/scripts/candidate-files.mjs index 2a694211..63e8613f 100644 --- a/scripts/candidate-files.mjs +++ b/scripts/candidate-files.mjs @@ -16,17 +16,17 @@ export function readFlatFiles(dir) { return result; } function find(dir, name) { - const hits=[]; - function visit(path,depth) { - if(depth>8) throw Error('Bundle directory depth exceeded'); - for(const entry of readdirSync(path,{withFileTypes:true})) { - if(entry.isSymbolicLink()) continue; // app symlinks are not release assets. - const next=join(path,entry.name); - if(entry.isDirectory()) { if(!entry.name.endsWith('.app')) visit(next,depth+1); } - else if(entry.isFile() && entry.name===name) hits.push(next); - } + // Tauri puts finished files directly in each bundle-kind directory. Walking + // its sibling AppDir/deb staging trees can exceed bounds and select copies. + let path='.'; + for(const segment of dir.split('/')) { + path=join(path,segment);const stat=lstatSync(path); + if(!stat.isDirectory() || stat.isSymbolicLink()) throw Error('Expected a regular bundle directory'); } - visit(dir,0); if(hits.length!==1) throw Error(`Expected one bundle ${name}, found ${hits.length}`); return hits[0]; + const hits=readdirSync(dir,{withFileTypes:true}).filter(entry=>entry.name.toLowerCase()===name.toLowerCase()); + if(hits.length!==1 || hits[0].name!==name) throw Error(`Expected one exact bundle ${name}, found ${hits.length}`); + if(!hits[0].isFile() || hits[0].isSymbolicLink()) throw Error('Expected a regular bundle'); + return join(dir,name); } export function signFile(path, cliPath=resolve('ui-desktop/node_modules/@tauri-apps/cli/tauri.js')) { try { execFileSync(process.execPath,[cliPath,'signer','sign',resolve(path)],{stdio:'pipe',timeout:120000,maxBuffer:1024*1024}); } @@ -35,15 +35,15 @@ export function signFile(path, cliPath=resolve('ui-desktop/node_modules/@tauri-a export function collect(platform, version, output='candidate-part', operations={signFile}) { const names=bundleNames(version), root='ui-desktop/src-tauri/target'; const sources={ - windows:[[`${root}/release/bundle`,names[0],names[0]]], - macos:[[`${root}/universal-apple-darwin/release/bundle`,names[1],names[1]],[`${root}/universal-apple-darwin/release/bundle`,'Tenebra.app.tar.gz',names[2]]], - linux:[[`${root}/release/bundle`,names[3],names[3]],[`${root}/release/bundle`,names[4],names[4]]], + windows:[[`${root}/release/bundle/nsis`,names[0],names[0]]], + macos:[[`${root}/universal-apple-darwin/release/bundle/dmg`,names[1],names[1]],[`${root}/universal-apple-darwin/release/bundle/macos`,'Tenebra.app.tar.gz',names[2]]], + linux:[[`${root}/release/bundle/deb`,names[3],names[3]],[`${root}/release/bundle/appimage`,names[4],names[4]]], arch:[['packaging/arch',names[5],names[5]]], }; if(!Object.hasOwn(sources,platform)) throw Error('Unsupported platform'); mkdirSync(output); // refuse reuse: same-name uploads must never clobber. for(const [dir,raw,name] of sources[platform]) { - const source=platform==='arch'?join(dir,raw):find(dir,raw); + const source=find(dir,raw); const stat=lstatSync(source);if(!stat.isFile()||stat.isSymbolicLink())throw Error('Expected a regular bundle'); copyFileSync(source,join(output,name)); // Sign the staged filenames, including deb/DMG/Arch. No key in argv/logs. diff --git a/scripts/candidate-files.test.mjs b/scripts/candidate-files.test.mjs index 4c049b70..2a8e804e 100644 --- a/scripts/candidate-files.test.mjs +++ b/scripts/candidate-files.test.mjs @@ -1,9 +1,75 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, rmdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, rmdirSync, symlinkSync, existsSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { collect, readFlatFiles } from './candidate-files.mjs'; +const release='ui-desktop/src-tauri/target/release/bundle'; +const universal='ui-desktop/src-tauri/target/universal-apple-darwin/release/bundle'; +const layouts={ + windows:[[`${release}/nsis/Tenebra_0.6.0_x64-setup.exe`,'Tenebra_0.6.0_x64-setup.exe']], + linux:[[`${release}/deb/Tenebra_0.6.0_amd64.deb`,'Tenebra_0.6.0_amd64.deb'],[`${release}/appimage/Tenebra_0.6.0_amd64.AppImage`,'Tenebra_0.6.0_amd64.AppImage']], + macos:[[`${universal}/dmg/Tenebra_0.6.0_universal.dmg`,'Tenebra_0.6.0_universal.dmg'],[`${universal}/macos/Tenebra.app.tar.gz`,'Tenebra_universal.app.tar.gz']], +}; +function fixture(platform) { + for(const [path,name] of layouts[platform]) {mkdirSync(dirname(path),{recursive:true});writeFileSync(path,`bundle ${name}`);} + for(const id of platform==='macos'?['macos-arm64','macos-amd64']:[platform])writeFileSync(`core-buildinfo-${id}.json`,`report ${id}`); +} +const testSigner={signFile:path=>writeFileSync(path+'.sig','test signature')}; +test('Linux collects finished kind outputs despite the actual deep AppDir and deb staging layout',()=>{ + const dir=mkdtempSync(join(tmpdir(),'candidate-test-')),old=process.cwd(); + try { + process.chdir(dir);fixture('linux'); + for(const staging of ['appimage/Tenebra.AppDir','deb/Tenebra_0.6.0_amd64']) { + const path=`${release}/${staging}/usr/lib/a/b/c/d/e/f/g/h/i/j`; + mkdirSync(path,{recursive:true});writeFileSync(join(path,'cache'),'unrelated staging bytes'); + } + collect('linux','0.6.0','output',testSigner); + for(const [,name] of layouts.linux)assert.equal(readFileSync(join('output',name),'utf8'),`bundle ${name}`); + assert.equal(readFlatFiles('output').size,5); + } finally {process.chdir(old);rmSync(dir,{recursive:true});} +}); +for(const platform of ['windows','linux','macos']) { + test(`${platform} uses only exact final bundle-kind paths`,()=>{ + const dir=mkdtempSync(join(tmpdir(),'candidate-test-')),old=process.cwd(); + try { + process.chdir(dir);fixture(platform);collect(platform,'0.6.0','output',testSigner); + for(const [,name] of layouts[platform])assert.equal(readFileSync(join('output',name),'utf8'),`bundle ${name}`); + rmSync(layouts[platform][0][0]); + const fallback=join(dirname(dirname(layouts[platform][0][0])),'unexpected',layouts[platform][0][1]); + mkdirSync(dirname(fallback),{recursive:true});writeFileSync(fallback,'wrong-location bytes'); + assert.throws(()=>collect(platform,'0.6.0','must-fail',testSigner)); + } finally {process.chdir(old);rmSync(dir,{recursive:true});} + }); +} +test('collection rejects a symlinked bundle-kind directory before reading or signing its target',()=>{ + const dir=mkdtempSync(join(tmpdir(),'candidate-test-')),old=process.cwd();let signed=false; + try { + process.chdir(dir);fixture('windows');rmSync(`${release}/nsis`,{recursive:true}); + mkdirSync('outside');writeFileSync('outside/Tenebra_0.6.0_x64-setup.exe','outside bytes'); + symlinkSync(resolve('outside'),`${release}/nsis`,process.platform==='win32'?'junction':'dir'); + assert.throws(()=>collect('windows','0.6.0','output',{signFile:()=>{signed=true;}}));assert.equal(signed,false); + } finally {process.chdir(old);rmSync(dir,{recursive:true});} +}); +test('collection rejects a symbolic-link leaf instead of signing an alternate target',()=>{ + const dir=mkdtempSync(join(tmpdir(),'candidate-test-')),old=process.cwd();let signed=false; + try { + process.chdir(dir);fixture('windows');rmSync(layouts.windows[0][0]); + // Junctions need no Windows symlink privilege and exercise lstat's link guard. + if(process.platform==='win32'){mkdirSync('outside');symlinkSync(resolve('outside'),layouts.windows[0][0],'junction');} + else{writeFileSync('outside','outside bytes');symlinkSync(resolve('outside'),layouts.windows[0][0]);} + assert.throws(()=>collect('windows','0.6.0','output',{signFile:()=>{signed=true;}}));assert.equal(signed,false); + } finally {process.chdir(old);rmSync(dir,{recursive:true});} +}); +test('case-alias duplicate bundle names are refused on case-sensitive filesystems',t=>{ + const dir=mkdtempSync(join(tmpdir(),'candidate-test-')),old=process.cwd(); + try { + process.chdir(dir);writeFileSync('case-probe-A','probe'); + if(existsSync('case-probe-a')){t.skip('Filesystem cannot contain distinct case-alias entries');return;} + fixture('linux');writeFileSync(`${release}/deb/tenebra_0.6.0_amd64.deb`,'ambiguous bytes'); + assert.throws(()=>collect('linux','0.6.0','output',testSigner)); + } finally {process.chdir(old);rmSync(dir,{recursive:true});} +}); test('Arch collection reads its one package without traversing dependency caches or rebuilt source',()=>{ const dir=mkdtempSync(join(tmpdir(),'candidate-test-')),old=process.cwd(); try {