From 2f4a2a818df04d393565015b134d42433052cb82 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 20 Oct 2026 20:42:11 +1100 Subject: [PATCH 1/2] Require explicit originals hashes without accidental ambient CI fallback --- apps/writer/scripts/native-publisher.test.mjs | 35 +++++++++++++++++++ package.json | 2 ++ scripts/lib/native-originals.mjs | 5 ++- scripts/test-writer-ci-unit.mjs | 25 +++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 scripts/test-writer-ci-unit.mjs diff --git a/apps/writer/scripts/native-publisher.test.mjs b/apps/writer/scripts/native-publisher.test.mjs index d615454..d7e1841 100644 --- a/apps/writer/scripts/native-publisher.test.mjs +++ b/apps/writer/scripts/native-publisher.test.mjs @@ -28,6 +28,7 @@ const sha256 = (value) => createHash("sha256").update(value).digest("hex"); const FULL_SHA = "a".repeat(40); import { trustFixture } from "../../../tests/native-originals-fixture.mjs"; +import { digestNames, protectedHashes } from "../../../scripts/lib/native-originals.mjs"; test("manual dispatch is bound to literal target ref and exact workflow/source SHA", () => { const valid = { target: "staging", sha: FULL_SHA, eventName: "workflow_dispatch", ref: "refs/heads/release/native-staging", workflowSha: FULL_SHA }; @@ -117,9 +118,43 @@ test("four protected byte expectations, not descriptor approval, are required", const f=await trustFixture("staging");t.after(()=>rm(f.repo,{recursive:true,force:true})); await assert.rejects(loadSdkTrust({target:"staging",repoRoot:f.repo}), /four protected/); }); +// SYNTHETIC UNIT fixtures, not authenticated originals or real CI identity. +// These must refuse regardless of GITHUB_ACTIONS or valid ambient public pins. +for (const [label,hashes] of [["undefined",undefined],["null",null],["empty",{}]]) test(label+" explicit hash input cannot borrow ambient CI pins",async t=>{ + const f=await trustFixture("staging");t.after(()=>rm(f.repo,{recursive:true,force:true})); + await assert.rejects(loadSdkTrust({target:"staging",repoRoot:f.repo,hashes}),/^Error: Refusing native originals: four protected per-environment original SHA256 values required$/); +}); +for (const target of ["staging","production"]) for (const field of digestNames) test(target+" missing "+field+" refuses without ambient fallback",async t=>{ + const f=await trustFixture(target);t.after(()=>rm(f.repo,{recursive:true,force:true})); + const hashes={...f.hashes};delete hashes[field]; + await assert.rejects(loadSdkTrust({target,repoRoot:f.repo,hashes}),/four protected/); +}); +for (const field of digestNames) test("production "+field+" mismatch refuses",async t=>{ + const f=await trustFixture("production");t.after(()=>rm(f.repo,{recursive:true,force:true})); + await assert.rejects(loadSdkTrust({target:"production",repoRoot:f.repo,hashes:{...f.hashes,[field]:"0".repeat(64)}}),/mismatch|differs/); +}); +for (const target of ["staging","production"]) test(target+" intake cannot become the other environment by path/default",async t=>{ + const f=await trustFixture(target);t.after(()=>rm(f.repo,{recursive:true,force:true})); + const other=target==="staging"?"production":"staging"; + await writeFile(join(f.repo,"config/native-publish",other+".json"),await readFile(join(f.repo,"config/native-publish",target+".json"))); + await assert.rejects(loadSdkTrust({target:other,repoRoot:f.repo,hashes:f.hashes}),/approved source intake input/); +}); +for (const target of [undefined,"","lab","qa","toString","__proto__"]) test(String(target)+" target refuses; no implicit production/ambient target",async()=>{ + await assert.rejects(loadSdkTrust({target,repoRoot:"unread-fixture-path",hashes:{}}),/unknown target; no LAB retarget/); +}); +test("CLI environment-reader default is intentional; explicit empty input never uses it",()=>{ + const ambient=Object.fromEntries(digestNames.map(name=>[name,process.env[name]])); + if(Object.values(ambient).every(value=>/^[a-f0-9]{64}$/.test(value??""))){ + assert.deepEqual(protectedHashes(),ambient);assert.deepEqual(protectedHashes(undefined),ambient); + }else{ + assert.throws(()=>protectedHashes(),/four protected/);assert.throws(()=>protectedHashes(undefined),/four protected/); + } + assert.throws(()=>protectedHashes({}),/four protected/); +}); for (const target of ["staging","production"]) test(target+" canonical original archives, lock, ALL installed members and raw asset bind", async t => { const f=await trustFixture(target);t.after(()=>rm(f.repo,{recursive:true,force:true})); const trust=await loadSdkTrust({target,repoRoot:f.repo,hashes:f.hashes,installed:true}); + assert.deepEqual(trust.protectedHashes,f.hashes); // Explicit fixture inputs, never ambient CI values. assert.equal(trust.release.environment,target); assert.equal(trust.release.assetSha256,f.hashes.NATIVE_TRUST_ASSET_SHA256); assert.equal(trust.nativeAssets["runtime.wasm"].sha256,sha256("runtime.wasm")); diff --git a/package.json b/package.json index 698893d..f01c9e4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "dev": "pnpm --filter @mdbase-writer/app dev", "test": "pnpm -r --if-present test && node --test tests/*.test.mjs", "test:publisher": "node --test apps/writer/scripts/native-publisher.test.mjs tests/*.test.mjs", + "test:publisher:ci-unit": "node scripts/test-writer-ci-unit.mjs publisher", + "check:ci-unit": "node scripts/test-writer-ci-unit.mjs check", "test:publisher:tools": "node scripts/native-tool-compat.mjs", "test:staging:intake": "node --test tests/writer-staging-intake.test.mjs", "test:installer": "node --test tests/writer-installer.test.mjs", diff --git a/scripts/lib/native-originals.mjs b/scripts/lib/native-originals.mjs index 5090af8..b753a34 100644 --- a/scripts/lib/native-originals.mjs +++ b/scripts/lib/native-originals.mjs @@ -49,7 +49,10 @@ export async function installedMembers(root) { } export async function loadOriginals({ target, repoRoot, hashes, installed = false }) { requireValue(['staging','production'].includes(target), 'unknown target; no LAB retarget'); - protectedHashes(hashes); + // Library callers must supply explicit expectations. Only the CLI's separate + // protectedHashes() call intentionally reads process.env; undefined here must + // not trigger that default overload and then leave `hashes` unvalidated. + protectedHashes(hashes ?? {}); repoRoot = await realpath(repoRoot); const approvedBytes = await regular(repoRoot, `config/native-publish/${target}.json`); const approved = JSON.parse(approvedBytes); diff --git a/scripts/test-writer-ci-unit.mjs b/scripts/test-writer-ci-unit.mjs new file mode 100644 index 0000000..2fab86f --- /dev/null +++ b/scripts/test-writer-ci-unit.mjs @@ -0,0 +1,25 @@ +// SYNTHETIC UNIT DATA ONLY: reproduce unprivileged CI's public ambient inputs. +// Not a GitHub run, qualification identity, protected-variable fallback or GO. +// Explicitly allow tests/checks only: NEVER app/Functions/SDK builds or network. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { normalizeWriterInstaller } from './lib/writer-installer.mjs'; +import { loadOriginals,protectedHashes } from './lib/native-originals.mjs'; +const scripts={publisher:'test:publisher',check:'check'},mode=process.argv[2]; +assert.ok(Object.hasOwn(scripts,mode),'Explicit publisher/check UNIT mode required; no build mode'); +for(const name of ['CLOUDFLARE_API_TOKEN','CLOUDFLARE_TOKEN','CLOUDFLARE_ACCOUNT_ID','GITHUB_TOKEN','GH_TOKEN'])assert.ok(!process.env[name],'No private credentials allowed in synthetic unit runner'); +const config=JSON.parse(await readFile('config/native-publish/staging.json','utf8')); +const pins=protectedHashes(config.protectedHashes); +// Same original/install guards as ordinary source qualification, BEFORE tests +// that can import candidate SDK. The pins are already approved public INPUTS, +// never derived from a candidate, authenticated here or changed by this runner. +const options={repoRoot:resolve('.'),target:'staging',hashes:pins}; +await normalizeWriterInstaller(options); +const originals=await loadOriginals({...options,installed:true}); +assert.deepEqual(Object.fromEntries(Object.entries(originals.archives).map(([k,v])=>[k,v.members])),{sdk:896,ui:78,protocol:67}); +console.log(JSON.stringify({kind:'SYNTHETIC-UNIT-DATA-NOT-actual-CI-or-publication',GITHUB_ACTIONS:'true',publicHashInputs:'four unchanged approved staging inputs',installedOriginalMembers:{sdk:896,ui:78,protocol:67},mode,publication:false,admission:false})); +// No fabricated GitHub source/ref/run/repository identity. Existing unit fixture +// subprocesses retain their explicitly synthetic isolated source/event inputs. +execFileSync('pnpm',['run',scripts[mode]],{stdio:'inherit',env:{...process.env,...pins,GITHUB_ACTIONS:'true',TARGET:'staging'}}); From d2b411369c9599b62f11f3742dfe55b39afe5770 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 20 Oct 2026 21:02:41 +1100 Subject: [PATCH 2/2] Retain both post-download observations and primary transport refusal --- package.json | 1 + scripts/lib/native-artifact.mjs | 47 +++++++++++------ tests/writer-failure-evidence.test.mjs | 72 +++++++++++++++++++++++--- 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index f01c9e4..ad2b057 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test": "pnpm -r --if-present test && node --test tests/*.test.mjs", "test:publisher": "node --test apps/writer/scripts/native-publisher.test.mjs tests/*.test.mjs", "test:publisher:ci-unit": "node scripts/test-writer-ci-unit.mjs publisher", + "test:transport-evidence": "node scripts/qualify-native-staging.mjs originals && node --test tests/writer-failure-evidence.test.mjs", "check:ci-unit": "node scripts/test-writer-ci-unit.mjs check", "test:publisher:tools": "node scripts/native-tool-compat.mjs", "test:staging:intake": "node --test tests/writer-staging-intake.test.mjs", diff --git a/scripts/lib/native-artifact.mjs b/scripts/lib/native-artifact.mjs index 719b53c..7e0a6ee 100644 --- a/scripts/lib/native-artifact.mjs +++ b/scripts/lib/native-artifact.mjs @@ -120,11 +120,22 @@ export async function download(expected,root,transport=fetch,repoRoot=resolve('. requireValue(blob.ok&&Number(blob.headers.get('content-length'))<=512*1024*1024,'archive size/status'); const bytes=downloaded.bytes; await checkpoint('download-observed',{downloadedSha256:sha(bytes),downloadedBytes:bytes.length}); - requireValue(`sha256:${observed.downloadedSha256}`===expected.digest,'download digest'); - await checkpoint('archive-extract'); - await mkdir(root,{recursive:true}); - const zip=join(root,'../writer-native.zip');await writeFile(zip,bytes,{flag:'wx'}); - execFileSync('python3',['-B','-c',`import pathlib,stat,sys,zipfile + // A complete accepted download requires BOTH post-lookups, even when digest, + // extraction or the first post-check refuses. Retain the first failure and + // classify later failures safely, never replacing it with an opaque error. + let primaryError; + const failed=async error=>{ + const refusal={stage:observed.stage,reason:error instanceof ArtifactRefusal?error.reason:'operation-failed'}; + if(observed.refusal===null){primaryError=error;await checkpoint(observed.stage,{refusal});} + else await checkpoint(observed.stage,{secondaryRefusals:[...(observed.secondaryRefusals??[]),refusal]}); + }; + const zip=join(root,'../writer-native.zip'); + try{ + requireValue(`sha256:${observed.downloadedSha256}`===expected.digest,'download digest'); + await checkpoint('archive-extract'); + await mkdir(root,{recursive:true}); + await writeFile(zip,bytes,{flag:'wx'}); + try{execFileSync('python3',['-B','-c',`import pathlib,stat,sys,zipfile with zipfile.ZipFile(sys.argv[1]) as z: seen=set(); total=0 for i in z.infolist(): @@ -133,15 +144,21 @@ with zipfile.ZipFile(sys.argv[1]) as z: assert i.filename not in seen and not stat.S_ISLNK(i.external_attr >> 16) seen.add(i.filename); total+=i.file_size assert len(seen)<=20000 and total<=512*1024*1024 - z.extractall(sys.argv[2])`,zip,root]); - await checkpoint('after-lookup'); - const after=await get(`/actions/artifacts/${expected.id}`,'after'); - await checkpoint('after-observed',{after:safeArtifact(after)}); - assertArtifactMetadata(after,expected); - await checkpoint('run-after-lookup'); - const runAfter=await get(`/actions/runs/${expected.run}`,'runAfter'); - await checkpoint('run-after-observed',{runAfter:safeRun(runAfter)}); - assertRunIdentity(runAfter,expected,run.workflow_id); + z.extractall(sys.argv[2])`,zip,root],{stdio:'pipe'});}catch{throw new ArtifactRefusal('archive extraction');} + }catch(error){await failed(error);} + try{ + await checkpoint('after-lookup'); + const after=await get(`/actions/artifacts/${expected.id}`,'after'); + await checkpoint('after-observed',{after:safeArtifact(after)}); + assertArtifactMetadata(after,expected); + }catch(error){await failed(error);} + try{ + await checkpoint('run-after-lookup'); + const runAfter=await get(`/actions/runs/${expected.run}`,'runAfter'); + await checkpoint('run-after-observed',{runAfter:safeRun(runAfter)}); + assertRunIdentity(runAfter,expected,run.workflow_id); + }catch(error){await failed(error);} + if(observed.refusal!==null)throw primaryError; const postDownloadSha256=sha(await readFile(zip)); await checkpoint('post-download-observed',{postDownloadSha256}); requireValue(postDownloadSha256===expected.digest.slice(7),'post-download archive digest'); @@ -150,7 +167,7 @@ with zipfile.ZipFile(sys.argv[1]) as z: await checkpoint('complete'); }catch(error){ // Never persist untrusted error bodies/messages, request headers or URLs. - observed.refusal={stage:observed.stage,reason:error instanceof ArtifactRefusal?error.reason:'operation-failed'}; + observed.refusal??={stage:observed.stage,reason:error instanceof ArtifactRefusal?error.reason:'operation-failed'}; await writeFile(evidence,JSON.stringify(observed,null,2));throw error; } } diff --git a/tests/writer-failure-evidence.test.mjs b/tests/writer-failure-evidence.test.mjs index ab149d4..1184a00 100644 --- a/tests/writer-failure-evidence.test.mjs +++ b/tests/writer-failure-evidence.test.mjs @@ -61,19 +61,25 @@ with zipfile.ZipFile(sys.argv[2],'w') as z: for root,dirs,files in os.walk(sys.argv[1]): for name in files: path=os.path.join(root,name);z.write(path,os.path.relpath(path,sys.argv[1]))`,root,zip]); - const bytes=await readFile(zip);expected.digest=changes.wrongDigest?'sha256:'+'0'.repeat(64):'sha256:'+hash(bytes); + const bytes=changes.invalidZip?Buffer.from('SYNTHETIC UNIT DATA: complete non-ZIP bytes'):await readFile(zip);expected.digest=changes.wrongDigest?'sha256:'+'0'.repeat(64):'sha256:'+hash(bytes); const meta={id:42,digest:expected.digest,expired:false,name:'writer-native-build-staging-23-2',workflow_run:{id:23,repository_id:17,head_repository_id:17,head_sha:source,head_branch:'release/native-staging'},archive_download_url:'https://blob.invalid/?sig=private-metadata'}; const run={id:23,workflow_id:7,path:'.github/workflows/deploy-writer.yml',repository:{id:17,full_name:'fixture/writer'},head_repository:{id:17,full_name:'fixture/writer'},head_sha:source,run_attempt:2,event:'workflow_dispatch',head_branch:'release/native-staging',env:{DONT:'private-context'}}; let metadataCalls=0,runCalls=0,zipCalls=0,blobCalls=0; + const beforePostLookup={}; const transport=async(url,init)=>{ url=String(url); if(url.endsWith('/actions/artifacts/42')){ - metadataCalls++;if(changes.firstLookupError&&metadataCalls===1)throw Error('private-token URL'); + metadataCalls++; + if(metadataCalls===2)beforePostLookup.after=JSON.parse(await readFile(evidencePath,'utf8')); + if(changes.firstLookupError&&metadataCalls===1)throw Error('private-token URL'); + if(changes.afterLookupError&&metadataCalls===2)throw Error('private-opaque-after-error URL'); if(changes.http?.stage===(metadataCalls===1?'before':'after'))return new Response(JSON.stringify(changes.http.body),{status:changes.http.status}); return {ok:true,status:200,json:async()=>({...structuredClone(meta),...(metadataCalls===1?changes.before:changes.after)})}; } if(url.endsWith('/actions/runs/23')){ - runCalls++;if(changes.runAfterError&&runCalls===2)throw Error('private-query'); + runCalls++; + if(runCalls===2)beforePostLookup.runAfter=JSON.parse(await readFile(evidencePath,'utf8')); + if(changes.runAfterError&&runCalls===2)throw Error('private-query'); if(changes.http?.stage===(runCalls===1?'run':'runAfter'))return new Response(JSON.stringify(changes.http.body),{status:changes.http.status}); const result=structuredClone(run); if(runCalls===1)Object.assign(result,changes.initialRun??{}); @@ -83,12 +89,15 @@ with zipfile.ZipFile(sys.argv[2],'w') as z: if(url.endsWith('/zip')){zipCalls++;const headers=new Headers({location:'https://artifact.example.test/blob?sig=private-sas'}); if(changes.redirect==='read-error'||changes.redirect==='timeout')return {status:302,headers,body:new ReadableStream({pull(c){if(changes.redirect==='read-error')c.error(Error('private-reader-error'));else return new Promise(()=>{});}})}; return new Response(changes.redirect==='overflow'?'x'.repeat(256*1024+1):'complete non-JSON redirect body',{status:302,headers});} - if(url.startsWith('https://artifact.example.test/')){blobCalls++;assert.equal(init.headers,undefined);return new Response(bytes);} + if(url.startsWith('https://artifact.example.test/')){blobCalls++;assert.equal(init.headers,undefined); + if(changes.blob){let sent=false;return new Response(new ReadableStream({pull(c){if(!sent){sent=true;c.enqueue(bytes.subarray(0,16));return;}if(changes.blob==='read-error')c.error(Error('private-blob-error'));else return new Promise(()=>{});}}),{headers:{'content-length':String(bytes.length)}});} + return new Response(bytes); + } throw Error('unexpected mocked request'); }; const downloadRoot=join(f.repoRoot,'download/artifact'),evidencePath=join(f.repoRoot,'download/artifact-metadata.json'); const evidence=async()=>{const text=await readFile(evidencePath,'utf8');assert.doesNotMatch(text,/private-|archive_download_url|Authorization|"env"/);return JSON.parse(text);}; - return {...f,expected,bytes,downloadRoot,transport,evidence,counts:()=>({metadataCalls,runCalls,zipCalls,blobCalls})}; + return {...f,expected,bytes,downloadRoot,transport,evidence,beforePostLookup,counts:()=>({metadataCalls,runCalls,zipCalls,blobCalls})}; } test('wrong INITIAL attempt persists observed run before refusing without any ZIP fetch',async t=>{ const f=await transportFixture(t,{initialRun:{run_attempt:1}}); @@ -120,9 +129,10 @@ for(const [name,change] of Object.entries({runId:{id:24},repositoryId:{repositor const e=await f.evidence();assert.ok(e.runAfter);assert.equal(e.after.id,42);assert.equal(f.counts().runCalls,2);assert.ok(e.refusal.reason); for(const [key,value] of Object.entries(change))assert.deepEqual(e.runAfter[key],value); }); -test('wrong after-artifact digest is persisted before refusal and no later lookup',async t=>{ +test('wrong after-artifact digest is persisted before refusal AND independent post-run lookup',async t=>{ const f=await transportFixture(t,{after:{digest:'sha256:'+'d'.repeat(64)}});await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot)); - const e=await f.evidence();assert.equal(e.after.digest,'sha256:'+'d'.repeat(64));assert.equal(f.counts().runCalls,1);assert.ok(e.run);assert.ok(e.downloadedSha256); + const e=await f.evidence();assert.equal(e.after.digest,'sha256:'+'d'.repeat(64));assert.equal(f.counts().runCalls,2);assert.ok(e.run);assert.ok(e.downloadedSha256); + assert.equal(e.runAfter.id,23);assert.equal(f.beforePostLookup.runAfter.after.digest,e.after.digest); }); for(const stage of ['before','run','after','runAfter'])for(const [status,message] of [[403,'Resource not accessible by integration'],[404,'Not Found'],[429,'API rate limit exceeded']])test(stage+' HTTP'+status+' bounded public body/status survive before refusal',async t=>{ const body={message,documentation_url:'https://docs.github.com/rest/actions',status:String(status)}; @@ -143,9 +153,55 @@ test('oversized HTTP error body is bounded and refused, with observed status ret }); for(const redirect of ['overflow','read-error','timeout'])test('otherwise-valid 302 '+redirect+' refuses BEFORE blob/ZIP/SHA and retains incomplete public status',async t=>{ const f=await transportFixture(t,{redirect});await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot,{responseTimeoutMs:20})); - const e=await f.evidence();assert.equal(f.counts().blobCalls,0);assert.equal(e.responses.archive.status,302);assert.equal(e.responses.archive.complete,false);assert.equal(e.downloadedSha256,undefined);await assert.rejects(readFile(join(f.downloadRoot,'../writer-native.zip')),{code:'ENOENT'}); + const e=await f.evidence();assert.equal(e.after,undefined);assert.equal(e.runAfter,undefined);assert.equal(f.counts().metadataCalls,1);assert.equal(f.counts().runCalls,1);assert.equal(f.counts().blobCalls,0);assert.equal(e.responses.archive.status,302);assert.equal(e.responses.archive.complete,false);assert.equal(e.downloadedSha256,undefined);await assert.rejects(readFile(join(f.downloadRoot,'../writer-native.zip')),{code:'ENOENT'}); if(redirect==='overflow')assert.equal(e.responses.archive.truncated,true); }); +// SYNTHETIC UNIT transport only. No GitHub/blob/API requests occur in these tests. +for(const primary of ['digest','extraction'])test(primary+' failure still retains BOTH independent post-download observations',async t=>{ + const f=await transportFixture(t,primary==='digest'?{wrongDigest:true}:{invalidZip:true}); + await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot));const e=await f.evidence(); + assert.equal(e.downloadedSha256,hash(f.bytes));assert.equal(e.downloadedBytes,f.bytes.length); + assert.equal(e.after.id,42);assert.equal(e.runAfter.id,23); + assert.deepEqual(f.counts(),{metadataCalls:2,runCalls:2,zipCalls:1,blobCalls:1}); + for(const prior of Object.values(f.beforePostLookup)){assert.equal(prior.downloadedSha256,hash(f.bytes));assert.equal(prior.downloadedBytes,f.bytes.length);} + assert.equal(e.refusal.reason,primary==='digest'?'download digest':'archive extraction'); + if(primary==='digest')await assert.rejects(readFile(join(f.downloadRoot,'../writer-native.zip')),{code:'ENOENT'}); +}); +for(const kind of ['status','identity'])test('after '+kind+' refusal does not suppress independent runAfter observation',async t=>{ + const f=await transportFixture(t,kind==='status'?{http:{stage:'after',status:403,body:{message:'Forbidden'}}}:{after:{digest:'sha256:'+'d'.repeat(64)}}); + await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot));const e=await f.evidence(); + assert.equal(e.runAfter.id,23);assert.deepEqual(f.counts(),{metadataCalls:2,runCalls:2,zipCalls:1,blobCalls:1}); + assert.equal(e.refusal.reason,kind==='status'?'GitHub metadata unavailable':'immutable ID/digest'); + const prior=f.beforePostLookup.runAfter;assert.equal(prior.responses.after.status,kind==='status'?403:200);assert.equal(prior.responses.after.complete,true);assert.match(prior.responses.after.sha256,/^[a-f0-9]{64}$/); + if(kind==='status')assert.deepEqual(prior.responses.after.body,{message:'Forbidden'});else assert.equal(prior.after.digest,e.after.digest); +}); +for(const primary of ['digest','extraction'])test(primary+' primary refusal survives TWO safely classified secondary post-check failures',async t=>{ + const f=await transportFixture(t,{...(primary==='digest'?{wrongDigest:true}:{invalidZip:true}),http:{stage:'after',status:403,body:{message:'Forbidden'}},runAfterError:true}); + await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot));const e=await f.evidence(); + assert.deepEqual(e.refusal,{stage:primary==='digest'?'download-observed':'archive-extract',reason:primary==='digest'?'download digest':'archive extraction'}); + assert.deepEqual(e.secondaryRefusals,[{stage:'after-lookup',reason:'GitHub metadata unavailable'},{stage:'run-after-lookup',reason:'operation-failed'}]); + assert.equal(e.downloadedSha256,hash(f.bytes));assert.equal(e.downloadedBytes,f.bytes.length);assert.equal(e.responses.after.status,403);assert.equal(e.runAfter,undefined);assert.equal(e.responses.runAfter,undefined); + assert.deepEqual(f.counts(),{metadataCalls:2,runCalls:2,zipCalls:1,blobCalls:1}); + assert.deepEqual(f.beforePostLookup.runAfter.refusal,e.refusal);assert.equal(f.beforePostLookup.runAfter.secondaryRefusals[0].reason,'GitHub metadata unavailable'); +}); +for(const kind of ['status','identity'])test('after '+kind+' PRIMARY stays primary when runAfter also fails with an opaque error',async t=>{ + const f=await transportFixture(t,{...(kind==='status'?{http:{stage:'after',status:403,body:{message:'Forbidden'}}}:{after:{digest:'sha256:'+'d'.repeat(64)}}),runAfterError:true}); + await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot));const e=await f.evidence(); + assert.deepEqual(e.refusal,{stage:kind==='status'?'after-lookup':'after-observed',reason:kind==='status'?'GitHub metadata unavailable':'immutable ID/digest'}); + assert.deepEqual(e.secondaryRefusals,[{stage:'run-after-lookup',reason:'operation-failed'}]); + assert.equal(e.runAfter,undefined);assert.equal(e.responses.runAfter,undefined);assert.equal(f.counts().runCalls,2); +}); +test('opaque after lookup failure still observes runAfter without inventing a metadata HTTP response',async t=>{ + const f=await transportFixture(t,{afterLookupError:true});await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot));const e=await f.evidence(); + assert.deepEqual(e.refusal,{stage:'after-lookup',reason:'operation-failed'});assert.equal(e.responses.after,undefined);assert.equal(e.after,undefined);assert.equal(e.runAfter.id,23); + assert.deepEqual(f.counts(),{metadataCalls:2,runCalls:2,zipCalls:1,blobCalls:1}); +}); +for(const blob of ['read-error','timeout'])test('incomplete blob '+blob+' fingerprint is NOT an actual ZIP identity or post-download observation',async t=>{ + const f=await transportFixture(t,{blob});await assert.rejects(download(f.expected,f.downloadRoot,f.transport,f.repoRoot,{responseTimeoutMs:20}));const e=await f.evidence(); + assert.equal(e.responses.blob.status,200);assert.equal(e.responses.blob.complete,false);assert.equal(e.responses.blob.sha256,hash(f.bytes.subarray(0,16))); + assert.equal(e.downloadedSha256,undefined);assert.equal(e.downloadedBytes,undefined);assert.equal(e.after,undefined);assert.equal(e.runAfter,undefined); + assert.deepEqual(f.counts(),{metadataCalls:1,runCalls:1,zipCalls:1,blobCalls:1});await assert.rejects(readFile(join(f.downloadRoot,'../writer-native.zip')),{code:'ENOENT'}); +}); test('full positive transport preserves every observation including after-run workflow identity',async t=>{ const f=await transportFixture(t);await download(f.expected,f.downloadRoot,f.transport,f.repoRoot); const e=await f.evidence();assert.equal(e.stage,'complete');assert.equal(e.refusal,null);assert.equal(e.run.workflow_id,7);assert.equal(e.runAfter.workflow_id,7);assert.equal(e.runAfter.path,'.github/workflows/deploy-writer.yml');assert.equal(e.after.id,42);assert.equal(e.downloadedSha256,hash(f.bytes));assert.deepEqual(f.counts(),{metadataCalls:2,runCalls:2,zipCalls:1,blobCalls:1});