Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions scripts/candidate-github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down
36 changes: 35 additions & 1 deletion scripts/candidate-github.test.mjs
Original file line number Diff line number Diff line change
@@ -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:`<!-- tenebra-promotion-sha256:${promotionId} -->\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);
Expand Down
Loading