diff --git a/scripts/candidate-github.mjs b/scripts/candidate-github.mjs index 25b2ca4..b7f0913 100644 --- a/scripts/candidate-github.mjs +++ b/scripts/candidate-github.mjs @@ -23,9 +23,9 @@ export function safeArchiveNames(text) { } function http(token) { demand(typeof token==='string' && token.length>0,'GitHub token required'); - async function call(url,{method='GET',body,octet=false,limit=20*1024**2}={}) { + async function call(url,{method='GET',body,octet=false,accept=octet?'application/octet-stream':'application/vnd.github+json',limit=20*1024**2}={}) { demand(url.startsWith(base+'/') || url.startsWith(`https://uploads.github.com/repos/${repo}/`),'unexpected GitHub request destination'); - const response=await fetch(url,{method,headers:{Authorization:`Bearer ${token}`,Accept:octet?'application/octet-stream':'application/vnd.github+json','X-GitHub-Api-Version':'2022-11-28',...(body?{'Content-Type':Buffer.isBuffer(body)?'application/octet-stream':'application/json'}:{})},body:body?(Buffer.isBuffer(body)?body:JSON.stringify(body)):undefined,signal:AbortSignal.timeout(180000)}); + const response=await fetch(url,{method,headers:{Authorization:`Bearer ${token}`,Accept:accept,'X-GitHub-Api-Version':'2022-11-28',...(body?{'Content-Type':Buffer.isBuffer(body)?'application/octet-stream':'application/json'}:{})},body:body?(Buffer.isBuffer(body)?body:JSON.stringify(body)):undefined,signal:AbortSignal.timeout(180000)}); if(!response.ok)throw Object.assign(Error(`GitHub ${method} operation failed (HTTP ${response.status})`),{status:response.status}); demand(Number(response.headers.get('content-length')??0)<=limit,'GitHub response exceeds size bound'); const chunks=[];let size=0; @@ -38,7 +38,9 @@ export async function acquireCandidate(a,pubkey,token) { demand(positive(a.prepareRunId) && positive(a.prepareAttempt) && positive(a.artifactId) && /^[a-f0-9]{40}$/.test(a.sourceSha) && /^[a-f0-9]{64}$/.test(a.artifactSha256),'explicit acquisition pins required'); const call=http(token),run=await call(`${base}/actions/runs/${a.prepareRunId}`);verifyPrepareRun(run,a); const asset=await call(`${base}/actions/artifacts/${a.artifactId}`);verifyArtifact(asset,a); - const archive=await call(`${base}/actions/artifacts/${a.artifactId}/zip`,{octet:true,limit:2*1024**3}); + // Actions negotiates its archive redirect through the JSON API media type; + // the redirected response is still binary. Release assets use octet-stream. + const archive=await call(`${base}/actions/artifacts/${a.artifactId}/zip`,{octet:true,accept:'application/vnd.github+json',limit:2*1024**3}); demand(sha256(archive)===a.artifactSha256,'downloaded ZIP SHA differs'); const temporary=mkdtempSync(join(tmpdir(),'tenebra-candidate-')),zip=join(temporary,'candidate.zip'); const files=new Map(); diff --git a/scripts/candidate-github.test.mjs b/scripts/candidate-github.test.mjs index efa8b75..7b6cf03 100644 --- a/scripts/candidate-github.test.mjs +++ b/scripts/candidate-github.test.mjs @@ -1,8 +1,42 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { verifyPrepareRun, verifyArtifact, safeArchiveNames, publisher } from './candidate-github.mjs'; +import { verifyPrepareRun, verifyArtifact, safeArchiveNames, publisher, acquireCandidate } from './candidate-github.mjs'; import {sha256} from './signed-candidate.mjs'; const source='a'.repeat(40), acceptance={prepareRunId:123,prepareAttempt:2,sourceSha:source,artifactId:456,artifactSha256:'b'.repeat(64)}; +test('Actions archive uses JSON API negotiation but verifies the downloaded binary before extraction',async t=>{ + const requests=[]; + t.mock.method(globalThis,'fetch',async(url,options)=>{ + requests.push({url,method:options.method,accept:options.headers.Accept}); + assert.equal(options.method,'GET'); + if(url.endsWith('/actions/runs/123'))return new Response(JSON.stringify({id:123,run_attempt:2,event:'workflow_dispatch',head_branch:'main',head_sha:source,path:'.github/workflows/desktop-candidate.yml',status:'completed',conclusion:'success',repository:{full_name:'Divaaaan/tenebra'}})); + if(url.endsWith('/actions/artifacts/456'))return new Response(JSON.stringify({id:456,name:'tenebra-signed-desktop-123-2',expired:false,digest:'sha256:'+acceptance.artifactSha256,size_in_bytes:5,workflow_run:{id:123,head_sha:source,head_branch:'main'}})); + if(url.endsWith('/actions/artifacts/456/zip')){ + // Matches the live Actions endpoint: octet-stream negotiation returns 415. + if(options.headers.Accept!=='application/vnd.github+json')return new Response('{}',{status:415}); + // Deliberately invalid digest; must reach binary SHA verification, never + // parse this non-JSON body or invoke unzip/candidate verification on it. + return new Response(Buffer.from([0x50,0x4b,0xff,0x00,0xfe]),{headers:{'Content-Type':'application/zip'}}); + } + throw Error('unexpected request'); + }); + await assert.rejects(()=>acquireCandidate(acceptance,'unused-key','test-token'),/downloaded ZIP SHA differs/); + assert.equal(requests.length,3); + assert.ok(requests.every(r=>r.accept==='application/vnd.github+json')); +}); +test('release asset byte verification still requests octet-stream',async t=>{ + const notes=Buffer.from('# Tenebra 0.6.0\n\nNotes.\n'),promotionId='c'.repeat(64),bytes=Buffer.from([0xff,0x00,0x7b,0xfe]); + const candidate={version:'0.6.0',sourceSha:source,files:[{name:'release-notes.md',sha256:sha256(notes)}]}; + let downloaded=false; + t.mock.method(globalThis,'fetch',async(url,options)=>{ + assert.equal(options.method,'GET'); + if(url.endsWith('/git/ref/tags/v0.6.0'))return new Response(JSON.stringify({object:{type:'commit',sha:source}})); + if(url.endsWith('/releases/42'))return new Response(JSON.stringify({id:42,tag_name:'v0.6.0',prerelease:false,draft:true,body:`\n${notes}`,assets:[{id:8,name:'file.exe',state:'uploaded',size:bytes.length}]})); + if(url.endsWith('/releases/assets/8')){assert.equal(options.headers.Accept,'application/octet-stream');downloaded=true;return new Response(bytes);} + throw Error('unexpected request'); + }); + await publisher('test-token',candidate,notes).verifyUploaded(42,new Map([['file.exe',bytes]]),{promotionId}); + assert.equal(downloaded,true); +}); test('artifact acquisition pins an actual completed main dispatch, not a PR/tree-equivalent build',()=>{ const run={id:123,run_attempt:2,event:'workflow_dispatch',head_branch:'main',head_sha:source,path:'.github/workflows/desktop-candidate.yml',status:'completed',conclusion:'success',repository:{full_name:'Divaaaan/tenebra'}}; assert.equal(verifyPrepareRun(run,acceptance),true);