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
35 changes: 35 additions & 0 deletions apps/writer/scripts/native-publisher.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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"));
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"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",
"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",
"test:installer": "node --test tests/writer-installer.test.mjs",
Expand Down
47 changes: 32 additions & 15 deletions scripts/lib/native-artifact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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');
Expand All @@ -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;
}
}
Expand Down
5 changes: 4 additions & 1 deletion scripts/lib/native-originals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions scripts/test-writer-ci-unit.mjs
Original file line number Diff line number Diff line change
@@ -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'}});
Loading