From a702f8394d5a3ef4db18cf33f2d7c82ca4cae4ee Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:22:48 +0800 Subject: [PATCH] fix: bind P02 capture to the reviewed R2 baseline Co-Authored-By: Codex --- .../console-p02-baseline-capture.yml | 11 +- scripts/console_m09_release.mjs | 65 ++++++++- scripts/console_m09_rollback.mjs | 65 +++++++++ scripts/console_m09_split_release.mjs | 77 +++++++++++ scripts/console_p02_baseline_capture.mjs | 11 +- scripts/console_p02_baseline_hosted.mjs | 9 +- scripts/console_p02_prepare.mjs | 44 ++++++ scripts/console_p02_prepare_local.mjs | 63 +++++++++ tests/console_m09_rollback.test.mjs | 78 +++++++++++ tests/console_m09_split_release.test.mjs | 83 ++++++++++++ tests/console_p02_baseline_capture.test.mjs | 16 ++- tests/console_p02_baseline_hosted.test.mjs | 1 + tests/console_p02_prepare.test.mjs | 44 ++++++ tests/console_p02_prepare_local.test.mjs | 126 ++++++++++++++++++ 14 files changed, 669 insertions(+), 24 deletions(-) create mode 100644 scripts/console_m09_rollback.mjs create mode 100644 scripts/console_m09_split_release.mjs create mode 100644 scripts/console_p02_prepare.mjs create mode 100644 scripts/console_p02_prepare_local.mjs create mode 100644 tests/console_m09_rollback.test.mjs create mode 100644 tests/console_m09_split_release.test.mjs create mode 100644 tests/console_p02_prepare.test.mjs create mode 100644 tests/console_p02_prepare_local.test.mjs diff --git a/.github/workflows/console-p02-baseline-capture.yml b/.github/workflows/console-p02-baseline-capture.yml index e0b307e0..a7f34a33 100644 --- a/.github/workflows/console-p02-baseline-capture.yml +++ b/.github/workflows/console-p02-baseline-capture.yml @@ -46,15 +46,6 @@ jobs: node --test tests/console_p02_baseline_capture.test.mjs tests/console_p02_baseline_hosted.test.mjs npx --yes wrangler@4.106.0 --version bash scripts/console_p02_candidate_build.sh "$RUNNER_TEMP/qrs-p02-baseline-candidate" - - name: Prepare optional pinned historical module - run: | - set -euo pipefail - if bash scripts/console_946_reviewed_main_build.sh > "$RUNNER_TEMP/qrs-p02-historical-build.log" 2>&1; then - echo 'historical_build_verified' - else - rm -f "$RUNNER_TEMP/qrs-reviewed-main/worker.js" - echo 'historical_build_unavailable' - fi - name: Verify protected exact-object capability env: QRS_P02_STORAGE_CAPABILITY_RECEIPT: ${{ secrets.QRS_P02_STORAGE_CAPABILITY_RECEIPT }} @@ -76,4 +67,4 @@ jobs: run: | set -euo pipefail [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] - node scripts/console_p02_baseline_hosted.mjs capture "$RUNNER_TEMP/qrs-p02-baseline-candidate/worker.js" "$RUNNER_TEMP/qrs-reviewed-main/worker.js" + node scripts/console_p02_baseline_hosted.mjs capture "$RUNNER_TEMP/qrs-p02-baseline-candidate/worker.js" diff --git a/scripts/console_m09_release.mjs b/scripts/console_m09_release.mjs index f76b1f9f..381cf917 100644 --- a/scripts/console_m09_release.mjs +++ b/scripts/console_m09_release.mjs @@ -2,7 +2,8 @@ // deployment and verified rollback version have all been pinned in this file. import {execFileSync} from 'node:child_process'; import {createHash} from 'node:crypto'; -import {readFile} from 'node:fs/promises'; +import {readFile,open} from 'node:fs/promises'; +import {resolve,relative,isAbsolute} from 'node:path'; import {pathToFileURL} from 'node:url'; import {createFixedApi} from './console_946_backend_release.mjs'; import {execute} from './console_full_bundle_release.mjs'; @@ -160,11 +161,13 @@ export async function preflight(api,rollbackTargets={fixed:[],fixedSuccessor:nul hold:true}; } async function main() { - const [action,path,...extra]=process.argv.slice(2); + const [action,path,receiptPath,successorPath,...extra]=process.argv.slice(2); const sourceOnly=['build-source','asset-candidate-source','preflight']; const readOnly=['asset-candidate-source','asset-compare','preflight'].includes(action); - if (extra.length || ![...sourceOnly,'verify-inputs','asset-compare','release'].includes(action) || - (sourceOnly.includes(action) ? path!==undefined : !path)) throw Error('arguments'); + const split=['upload-only','activate-only'].includes(action); + if (extra.length || ![...sourceOnly,'verify-inputs','asset-compare','release','upload-only','activate-only'].includes(action) || + (sourceOnly.includes(action) ? path!==undefined : !path) || (split ? !receiptPath : receiptPath!==undefined) || + (action==='activate-only' ? !successorPath : successorPath!==undefined)) throw Error('arguments'); if (readOnly ? !sourcePinned(POLICY) : !pinned(POLICY)) throw Error('hold'); const git=(...args)=>execFileSync('git',args,{encoding:'utf8'}).trim(); git('merge-base','--is-ancestor',POLICY.source,'HEAD'); @@ -186,6 +189,60 @@ async function main() { const candidate=await readFile(path); if (hash(candidate)!==POLICY.candidate) throw Error('provenance'); if (action==='verify-inputs') return; + if (split) { + const root=git('rev-parse','--show-toplevel'), file=resolve(receiptPath); + const privatePath=path=>{ + if(!isAbsolute(path))throw Error('receipt_location'); + const resolved=resolve(path),rel=relative(root,resolved); + if(!rel||(!rel.startsWith('../')&&!isAbsolute(rel)))throw Error('receipt_location'); + return resolved; + }; + privatePath(receiptPath); + const {uploadOnly,activateOnly}=await import('./console_m09_split_release.mjs'); + let result; + if (action==='upload-only') { + // Reserve a private destination before any provider write. An empty file + // after failure is an explicit recovery signal, never a valid receipt. + const handle=await open(file,'wx',0o600); + try { + const outcome=await uploadOnly({api:createFixedApi(),policy:POLICY,evidence,candidate}); + result=outcome.result; + if(outcome.receipt) { + try { await handle.writeFile(JSON.stringify(outcome.receipt)); await handle.sync(); } + catch { result={ok:false,reason:'receipt_write_failed',upload_attempted:true,deploy_attempted:false,outcome:'UPLOAD_REQUIRES_READBACK',retry:false}; } + } + } finally { + try { await handle.close(); } catch { + result={ok:false,reason:'receipt_close_failed',upload_attempted:result?.upload_attempted===true,deploy_attempted:false, + outcome:result?.upload_attempted?'UPLOAD_REQUIRES_READBACK':'NOT_ATTEMPTED',retry:false}; + } + } + } else { + const handle=await open(file,'r'); + let receipt; + try { + const stat=await handle.stat(); + if(!stat.isFile()||(stat.mode&0o077)!==0||stat.uid!==process.getuid()||stat.size>4096) throw Error('receipt_permissions'); + receipt=JSON.parse(await handle.readFile('utf8')); + } finally { await handle.close(); } + const successor=privatePath(successorPath); + const output=await open(successor,'wx',0o600); + try { + const outcome=await activateOnly({api:createFixedApi(),policy:POLICY,evidence,candidate,receipt}); + result=outcome.result; + if(outcome.receipt) { + try { await output.writeFile(JSON.stringify(outcome.receipt)); await output.sync(); } + catch { result={ok:false,reason:'receipt_write_failed',upload_attempted:false,deploy_attempted:true,outcome:'ACTIVATION_REQUIRES_READBACK',retry:false}; } + } + } finally { + try { await output.close(); } catch { + result={ok:false,reason:'receipt_close_failed',upload_attempted:false,deploy_attempted:result?.deploy_attempted===true, + outcome:result?.deploy_attempted?'ACTIVATION_REQUIRES_READBACK':'NOT_ATTEMPTED',retry:false}; + } + } + } + console.log(JSON.stringify(result));if(!result.ok)process.exitCode=1;return; + } const result=action==='asset-compare' ? await inspectActiveAssets(createPreflightApi(),candidate) : await release({api:createFixedApi(),policy:POLICY,evidence,candidate,release:true}); console.log(JSON.stringify(result)); diff --git a/scripts/console_m09_rollback.mjs b/scripts/console_m09_rollback.mjs new file mode 100644 index 00000000..8d2d9659 --- /dev/null +++ b/scripts/console_m09_rollback.mjs @@ -0,0 +1,65 @@ +// Rollback is closed until the reviewed policy and actual successor are authorized. +import {createHash} from 'node:crypto'; +import {pathToFileURL} from 'node:url'; +import {resources,config} from './console_946_backend_release.mjs'; +import {pinned} from './console_m09_release.mjs'; + +const sha=x=>createHash('sha256').update(x).digest('hex'); +const uuid=x=>typeof x==='string'&&/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(x); +const digest=x=>typeof x==='string'&&/^[a-f0-9]{64}$/.test(x); +function bound(p,r){ + return pinned(p)&&digest(p.configuration_sha256)&&digest(p.migration_tag_sha256)&&r&& + r.source===p.source&&r.tree===p.tree&&r.frontendBlob===p.frontendBlob&&r.before===p.before&&r.candidate===p.candidate&& + r.configuration_sha256===p.configuration_sha256&&r.migration_tag_sha256===p.migration_tag_sha256&& + r.active===p.version&&r.deployment===p.deployment&&uuid(r.previous)&&uuid(r.staged)&& + r.staged!==r.active&&r.staged!==r.previous&&r.successorVersion===r.staged&&uuid(r.successorDeployment)&& + r.successorDeployment!==r.deployment; +} +function active(value,deployment,version){ + const list=value?.deployments,d=list?.[0]; + if(!Array.isArray(list)||!list.length||list.length>10||d?.id!==deployment||d.strategy!=='percentage'|| + !Array.isArray(d.versions)||d.versions.length!==1||d.versions[0]?.version_id!==version||d.versions[0]?.percentage!==100) + throw Error('active'); +} +function moduleMatches(value,id,expected){ + const m=value?.modules?.[0],s=m?.content_base64; + if(value?.id!==id||!Array.isArray(value.modules)||value.modules.length!==1||m?.name!=='worker.js'|| + m.content_type!=='application/javascript+module'||typeof s!=='string'||!s.length||s.length>Math.ceil(10*1024*1024/3)*4) + throw Error('module'); + const bytes=Buffer.from(s,'base64'); + if(!bytes.length||bytes.length>10*1024*1024||bytes.toString('base64')!==s||sha(bytes)!==expected)throw Error('module'); +} +function configurationMatches(value,id,p){ + const r=resources(value,id),tag=r.script_runtime.migration_tag; + if(typeof tag!=='string'||!tag||sha(config(r))!==p.configuration_sha256||sha(tag)!==p.migration_tag_sha256) + throw Error('configuration'); +} +// The optional receipt returned on success must remain in protected storage. +export async function rollback({api,policy,receipt}){ + if(!bound(policy,receipt))return {result:{ok:false,reason:'hold_or_invalid_pins',deploy_attempted:false,retry:false}}; + let attempted=false; + try{ + active(await api.deployments(),receipt.successorDeployment,receipt.successorVersion); + moduleMatches(await api.betaVersion(policy.version),policy.version,policy.before); + configurationMatches(await api.scriptVersion(policy.version),policy.version,policy); + moduleMatches(await api.betaVersion(receipt.successorVersion),receipt.successorVersion,policy.candidate); + configurationMatches(await api.scriptVersion(receipt.successorVersion),receipt.successorVersion,policy); + active(await api.deployments(),receipt.successorDeployment,receipt.successorVersion); + attempted=true; + const result=await api.activate({strategy:'percentage',versions:[{version_id:policy.version,percentage:100}],annotations:{'workers/message':'Reviewed rollback'}}); + if(!uuid(result?.id)||(result.strategy!==undefined&&result.strategy!=='percentage')|| + (result.versions!==undefined&&(!Array.isArray(result.versions)||result.versions.length!==1|| + result.versions[0]?.version_id!==policy.version||result.versions[0]?.percentage!==100)))throw Error('response'); + active(await api.deployments(),result.id,policy.version); + moduleMatches(await api.betaVersion(policy.version),policy.version,policy.before); + configurationMatches(await api.scriptVersion(policy.version),policy.version,policy); + active(await api.deployments(),result.id,policy.version); + return {result:{ok:true,deploy_attempted:true,active_verified:true,retry:false,provider_cas:false}, + receipt:{...receipt,rollbackVersion:policy.version,rollbackDeployment:result.id}}; + }catch{return {result:{ok:false,reason:attempted?'activation_requires_readback':'verification_failed', + deploy_attempted:attempted,outcome:attempted?'UNKNOWN':'NOT_ATTEMPTED',retry:false}};} +} +// No credentials, files or provider client are accessed by the held CLI. +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ + console.error('{"ok":false,"reason":"hold","deploy_attempted":false,"retry":false}');process.exitCode=1; +} diff --git a/scripts/console_m09_split_release.mjs b/scripts/console_m09_split_release.mjs new file mode 100644 index 00000000..fcc8f4f7 --- /dev/null +++ b/scripts/console_m09_split_release.mjs @@ -0,0 +1,77 @@ +// M09/P02 split release. The production caller must supply the fixed policy and +// verified provenance; the existing #946 core remains unchanged. +import {createHash} from 'node:crypto'; +import {execute} from './console_full_bundle_release.mjs'; +import {pinned} from './console_m09_release.mjs'; +import {resources,config} from './console_946_backend_release.mjs'; + +const sha=x=>createHash('sha256').update(x).digest('hex'); +const uuid=x=>typeof x==='string'&&/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(x); +const valid=(p,e,c)=>pinned(p)&&/^[a-f0-9]{64}$/.test(p.configuration_sha256??'')&& + /^[a-f0-9]{64}$/.test(p.migration_tag_sha256??'')&&e?.source===p.source&&e?.tree===p.tree&&e?.frontendBlob===p.frontendBlob&& + Buffer.isBuffer(c)&&c.length>0&&c.length<=10*1024*1024&&sha(c)===p.candidate; +function checkedVersion(api,policy){ + return async id=>{ + const value=await api.scriptVersion(id); + if(id===policy.version){ + const r=resources(value,id),tag=r.script_runtime.migration_tag; + if(typeof tag!=='string'||!tag||sha(config(r))!==policy.configuration_sha256||sha(tag)!==policy.migration_tag_sha256) + throw Error('baseline_configuration_drift'); + } + return value; + }; +} +const ids=x=>{ + const a=x?.items; + if(!Array.isArray(a)||!a.length||a.length>10||a.some(v=>!uuid(v?.id))||new Set(a.map(v=>v.id)).size!==a.length)throw Error('versions'); + return a.map(v=>v.id); +}; +const noWrite={ok:false,reason:'hold_or_input_invalid',upload_attempted:false,deploy_attempted:false}; + +// receipt is returned only to the caller's protected storage, never to public logs. +export async function uploadOnly({api,policy,evidence,candidate}) { + if(!valid(policy,evidence,candidate))return {result:noWrite}; + let uploaded=null,previous=null,verified=false; + const limited={deployments:api.deployments,versions:async()=>{ + const v=await api.versions();if(previous===null)previous=ids(v)[0];return v; + },scriptVersion:checkedVersion(api,policy),betaVersion:api.betaVersion, + upload:async body=>{const value=await api.upload(body);if(uuid(value?.id))uploaded=value.id;return value;}, + // This local sentinel is reached only after the core's complete upload readback, + // configuration, version ordering and active-traffic checks have succeeded. + activate:async()=>{verified=true;throw Error('split_stop');}}; + const result=await execute({api:limited,policy,expected:{version:policy.version,deployment:policy.deployment},candidate,release:true}); + if(!verified||!uploaded||!previous||result.stage!=='activate') + return {result:{...result,deploy_attempted:false,outcome:result.upload_attempted?'UPLOAD_REQUIRES_READBACK':'NOT_ATTEMPTED'}}; + return {result:{ok:true,upload_attempted:true,deploy_attempted:false,staged_verified:true}, + receipt:{source:policy.source,tree:policy.tree,frontendBlob:policy.frontendBlob,before:policy.before,candidate:policy.candidate, + configuration_sha256:policy.configuration_sha256,migration_tag_sha256:policy.migration_tag_sha256, + active:policy.version,deployment:policy.deployment,staged:uploaded,previous}}; +} + +// A synthetic upload response allows the unmodified core to re-run its exact +// candidate/readback checks on the staged version. No upload method is exposed. +export async function activateOnly({api,policy,evidence,candidate,receipt}) { + if(!valid(policy,evidence,candidate)||!receipt||receipt.source!==policy.source||receipt.tree!==policy.tree|| + receipt.frontendBlob!==policy.frontendBlob||receipt.before!==policy.before||receipt.candidate!==policy.candidate|| + receipt.configuration_sha256!==policy.configuration_sha256||receipt.migration_tag_sha256!==policy.migration_tag_sha256|| + receipt.active!==policy.version||receipt.deployment!==policy.deployment||!uuid(receipt.staged)||!uuid(receipt.previous)|| + receipt.staged===receipt.active||receipt.staged===receipt.previous)return {result:noWrite}; + let first=true; + const limited={deployments:api.deployments,scriptVersion:checkedVersion(api,policy),betaVersion:api.betaVersion, + versions:async()=>{ + const value=await api.versions(),list=ids(value); + if(list.length<2||list[0]!==receipt.staged||list[1]!==receipt.previous)throw Error('versions_drift'); + if(first){first=false;return {...value,items:value.items.slice(1)};} + return value; + }, + upload:async body=>{ + if(first||body?.modules?.length!==1||body.modules[0]?.content_base64!==candidate.toString('base64'))throw Error('candidate'); + return {id:receipt.staged}; + },activate:api.activate}; + const result=await execute({api:limited,policy,expected:{version:policy.version,deployment:policy.deployment},candidate,release:true}); + // Core's upload flag denotes the local substitute, never a provider write. + const {version_id,deployment_id,...publicResult}=result; + if(!result.ok) return {result:{...publicResult,upload_attempted:false, + outcome:result.deploy_attempted?'ACTIVATION_REQUIRES_READBACK':'NOT_ATTEMPTED'}}; + return {result:{...publicResult,upload_attempted:false},receipt:{...receipt,successorVersion:version_id,successorDeployment:deployment_id}}; +} diff --git a/scripts/console_p02_baseline_capture.mjs b/scripts/console_p02_baseline_capture.mjs index 9b52c821..f242736a 100644 --- a/scripts/console_p02_baseline_capture.mjs +++ b/scripts/console_p02_baseline_capture.mjs @@ -10,6 +10,7 @@ const uuid=x=>typeof x==='string'&&/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12} export const HISTORICAL='0c423d325873d85f9a6b6506c6a9ae35f44753a157e90e3ef1c049b094e0aec2'; export const HISTORICAL_SOURCE='9b695a4ade90bcc9931f53109e0ad33f63407927'; export const REFERENCE='912ad8b3efb9080b96112750146bdf050b6eefed6fae822b57dfa57e99feb57a'; +export const R2_ACTIVE_MODULE='599b86317957dbc77ed8a307274bdf15109683fa6c735a3263aed23e7aa49244'; const names=['V2_BUILD_ID','V2_PAGE_HTML','V2_ASSETS']; function identity(value){ const ds=value?.deployments,d=ds?.[0],v=d?.versions?.[0]; @@ -82,10 +83,10 @@ export function rebuildWithAssets(candidate,active,ts){ const outside=(text,spans)=>{let p=0;const result=[];for(const span of spans){result.push(text.slice(p,span.start));p=span.end;}result.push(text.slice(p));return result;}; const oldParts=outside(original.text,ordered),newParts=outside(checked.text,after); if(oldParts.some((part,i)=>!Buffer.from(part).equals(Buffer.from(newParts[i]))))throw Error('rebuild'); - return {declarations:observed.declarations,sha256:digest(bytes)}; + return {declarations:observed.declarations,sha256:digest(bytes),bytes}; } // Storage reservation must complete before any Worker reads. A failed or unknown write is never retried. -export async function capture({api,store,candidate,controlSha,alternate,now=()=>new Date(),ts=require('typescript'),reference=REFERENCE,alternateReference=HISTORICAL}){ +export async function capture({api,store,candidate,controlSha,alternate,expectedActive,now=()=>new Date(),ts=require('typescript'),reference=REFERENCE,alternateReference=HISTORICAL}){ let stage='candidate',writeAttempted=false; try{ if(!/^[a-f0-9]{40}$/.test(controlSha??'')||!Buffer.isBuffer(candidate)||digest(candidate)!==reference)throw Error('candidate'); @@ -93,12 +94,14 @@ export async function capture({api,store,candidate,controlSha,alternate,now=()=> stage='storage_preflight'; await store.reserve(); stage='active_before'; const before=identity(await api.deployments()); stage='module'; const active=moduleBytes(await api.betaVersion(before.version),before.version); + const activeHash=digest(active); + if(expectedActive!==undefined&&activeHash!==expectedActive)throw Error('module_drift'); stage='configuration'; const r=resources(await api.scriptVersion(before.version),before.version); if(typeof r.script_runtime.migration_tag!=='string'||!r.script_runtime.migration_tag)throw Error('configuration'); stage='active_after'; const after=identity(await api.deployments()); if(before.version!==after.version||before.deployment!==after.deployment)throw Error('drift'); stage='assets'; const compared=compareAssetDeclarations(active,candidate,ts); - if(!compared.equal)throw Error('unsupported'); + if(!compared.equal||(expectedActive!==undefined&&!compared.ok))throw Error('unsupported'); let rebuilt, historical; if(!compared.ok){ historical={source:HISTORICAL_SOURCE,status:'unavailable',reason:'build_or_read_unavailable'}; @@ -115,7 +118,7 @@ export async function capture({api,store,candidate,controlSha,alternate,now=()=> rebuilt=rebuildWithAssets(candidate,assets,ts); } const capturedAt=now().toISOString(); - const record={schema:'qrs-worker-p02-baseline-v1',control_sha:controlSha,captured_at:capturedAt,...before,active_module_sha256:digest(active), + const record={schema:'qrs-worker-p02-baseline-v1',control_sha:controlSha,captured_at:capturedAt,...before,active_module_sha256:activeHash, configuration_sha256:digest(config(r)),migration_tag_sha256:digest(r.script_runtime.migration_tag), reference_module_sha256:reference,asset_comparison:compared.equal,...(rebuilt?{historical_comparison:historical,asset_declarations:rebuilt.declarations,rebuilt_module_sha256:rebuilt.sha256,release_state:'hold_review_required'}:{release_state:'hold'})}; stage='save'; writeAttempted=true; const bytes=Buffer.from(JSON.stringify(record)); diff --git a/scripts/console_p02_baseline_hosted.mjs b/scripts/console_p02_baseline_hosted.mjs index 8d8c0d30..0e1789ca 100644 --- a/scripts/console_p02_baseline_hosted.mjs +++ b/scripts/console_p02_baseline_hosted.mjs @@ -1,11 +1,11 @@ // Hosted P02 capture: fixed object and routes; provider responses are never logged. import {readFile} from 'node:fs/promises'; import {pathToFileURL} from 'node:url'; -import {capture,REFERENCE} from './console_p02_baseline_capture.mjs'; +import {capture,REFERENCE,R2_ACTIVE_MODULE} from './console_p02_baseline_capture.mjs'; import {createFixedApi} from './console_946_backend_release.mjs'; import {createHash} from 'node:crypto'; -export const KEY='evidence/qrs-worker-p02/p02-20261109-r1/baseline.json'; +export const KEY='evidence/qrs-worker-p02/p02-20261109-r2/baseline.json'; export function verifyCapability(env,now=Date.now()){ const uri=/^gs:\/\/([a-z0-9][a-z0-9._-]{1,220}[a-z0-9])(?:\/[^?#]*)?$/.exec(env.DIGEST_CANDIDATES_GCS_URI??''); const provider=env.DIGEST_CANDIDATES_READER_WIF_PROVIDER||env.DIGEST_GCP_WORKLOAD_IDENTITY_PROVIDER; @@ -51,13 +51,12 @@ export function readOnlyWorkerApi(api){ async function main(){ const action=process.argv[2]; if(action==='verify-capability'){verifyCapability(process.env);console.log('{"ok":true,"capability_verified":true}');return;} - if(action!=='capture'||process.argv.length!==5)throw Error('input'); + if(action!=='capture'||process.argv.length!==4)throw Error('input'); const candidate=await readFile(process.argv[3]); if(createHash('sha256').update(candidate).digest('hex')!==REFERENCE)throw Error('candidate'); - let alternate;try{alternate=await readFile(process.argv[4]);}catch{} const {bucket}=verifyCapability(process.env); const store=storage({bucket,token:process.env.GOOGLE_ACCESS_TOKEN}); - const result=await capture({api:readOnlyWorkerApi(createFixedApi()),store,candidate,alternate,controlSha:process.env.GITHUB_SHA}); + const result=await capture({api:readOnlyWorkerApi(createFixedApi()),store,candidate,expectedActive:R2_ACTIVE_MODULE,controlSha:process.env.GITHUB_SHA}); console.log(JSON.stringify(result));if(!result.ok)process.exitCode=1; } if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ diff --git a/scripts/console_p02_prepare.mjs b/scripts/console_p02_prepare.mjs new file mode 100644 index 00000000..37338a44 --- /dev/null +++ b/scripts/console_p02_prepare.mjs @@ -0,0 +1,44 @@ +// Offline preparation only. The result does not authorize an upload. +import {createHash} from 'node:crypto'; +import {rebuildWithAssets, REFERENCE, HISTORICAL_SOURCE, HISTORICAL, R2_ACTIVE_MODULE} from './console_p02_baseline_capture.mjs'; +import {POLICY} from './console_m09_release.mjs'; + +const digest=x=>createHash('sha256').update(x).digest('hex'); +const hex=(x,n)=>typeof x==='string'&&new RegExp(`^[a-f0-9]{${n}}$`).test(x); +const uuid=x=>typeof x==='string'&&/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(x); +const names=['V2_BUILD_ID','V2_PAGE_HTML','V2_ASSETS']; +export const P02_PREPARE_PINS=Object.freeze({reference:REFERENCE, + active:R2_ACTIVE_MODULE, + referenceFrontendBlob:'5287f259185ed44f3784aa6374cb00412b3c6238', + historical:HISTORICAL_SOURCE,historicalBundle:HISTORICAL}); + +// The caller must obtain the protected record through its authorized channel. +// Dependency injection exists for offline fixtures, not as a production policy override. +export function prepareRecord(record,reference,evidence,ts,pins=P02_PREPARE_PINS){ + if(record?.schema!=='qrs-worker-p02-baseline-v1'||!hex(pins.control,40)||record.control_sha!==pins.control|| + (pins.active!==undefined&&record.active_module_sha256!==pins.active)|| + record.reference_module_sha256!==pins.reference||!Buffer.isBuffer(reference)||digest(reference)!==pins.reference|| + !uuid(record.version)||!uuid(record.deployment)|| + !['active_module_sha256','configuration_sha256','migration_tag_sha256'].every(k=>hex(record[k],64))|| + evidence?.source!==POLICY.source||evidence?.tree!==POLICY.tree||evidence?.frontendBlob!==pins.referenceFrontendBlob) + throw Error('baseline_invalid'); + const comparison=record.asset_comparison; + if(!comparison||Object.keys(comparison).length!==names.length||!names.every(n=>typeof comparison[n]==='boolean')) + throw Error('baseline_invalid'); + let candidate=reference; + if(names.every(n=>comparison[n])) { + if(record.release_state!=='hold'||record.asset_declarations!==undefined)throw Error('baseline_invalid'); + } else { + const declarations=record.asset_declarations; + if(record.release_state!=='hold_review_required'||record.historical_comparison?.status!=='match'|| + record.historical_comparison.source!==pins.historical||evidence.historicalSource!==pins.historical|| + evidence.historicalBundle!==pins.historicalBundle||!declarations||Object.keys(declarations).length!==names.length|| + !names.every(n=>typeof declarations[n]==='string')||!hex(record.rebuilt_module_sha256,64))throw Error('baseline_invalid'); + const rebuilt=rebuildWithAssets(reference,Buffer.from(names.map(n=>declarations[n]).join('\n')),ts); + if(rebuilt.sha256!==record.rebuilt_module_sha256)throw Error('candidate_mismatch'); + candidate=rebuilt.bytes; + } + return {hold:true,candidate,policy:{source:evidence.source,tree:evidence.tree,frontendBlob:evidence.frontendBlob, + before:record.active_module_sha256,candidate:digest(candidate),version:record.version,deployment:record.deployment,rollback:record.version, + configuration_sha256:record.configuration_sha256,migration_tag_sha256:record.migration_tag_sha256}}; +} diff --git a/scripts/console_p02_prepare_local.mjs b/scripts/console_p02_prepare_local.mjs new file mode 100644 index 00000000..1444a9d8 --- /dev/null +++ b/scripts/console_p02_prepare_local.mjs @@ -0,0 +1,63 @@ +// Prepare protected review files offline. This command never contacts a provider. +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {mkdir,open,readFile} from 'node:fs/promises'; +import {isAbsolute,relative,resolve} from 'node:path'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import {POLICY} from './console_m09_release.mjs'; +import {rebuildWithAssets} from './console_p02_baseline_capture.mjs'; +import {P02_PREPARE_PINS,prepareRecord} from './console_p02_prepare.mjs'; + +const asset='web/strategy-switch-console/v2_asset_map.js'; +const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); +const require=createRequire(new URL('../web/strategy-switch-console/frontend/package.json',import.meta.url)); +const root=resolve(fileURLToPath(new URL('..',import.meta.url))); +const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8'}).trim(); + +async function privateRecord(path){ + const file=await open(path,'r'); + try { + const stat=await file.stat(); + if(!stat.isFile()||(stat.mode&0o077)!==0||stat.uid!==process.getuid()||stat.size>12*1024*1024)throw Error('record'); + return JSON.parse(await file.readFile('utf8')); + } finally { await file.close(); } +} + +// Injected dependencies permit synthetic tests; the CLI uses the current batch pins. +export async function prepareLocal({recordPath,referencePath,historicalPath,outputPath,controlSha},deps={}){ + if(![recordPath,referencePath,outputPath].every(p=>typeof p==='string'&&isAbsolute(p)))throw Error('path'); + const repo=deps.root??root,output=resolve(outputPath),rel=relative(repo,output); + if(!rel||(!rel.startsWith('../')&&!isAbsolute(rel)))throw Error('output_location'); + const g=deps.git??git,pins=deps.pins??{...P02_PREPARE_PINS,control:controlSha}; + const evidence={source:g('rev-parse',`${POLICY.source}^{commit}`),tree:g('rev-parse',`${POLICY.source}^{tree}`), + frontendBlob:g('rev-parse',`${POLICY.source}:${asset}`)}; + const [record,reference]=await Promise.all([privateRecord(recordPath),readFile(referencePath)]); + if(reference.length>10*1024*1024)throw Error('bundle'); + let historical; + if(!Object.values(record.asset_comparison??{}).every(value=>value===true)||Object.keys(record.asset_comparison??{}).length!==3){ + if(!deps.pins||typeof historicalPath!=='string'||!isAbsolute(historicalPath))throw Error('historical'); + historical=await readFile(historicalPath); + if(historical.length>10*1024*1024||hash(historical)!==pins.historicalBundle)throw Error('bundle'); + evidence.historicalSource=g('rev-parse',`${pins.historical}^{commit}`); + evidence.historicalBundle=hash(historical); + } + const ts=deps.ts??require('typescript'); + const prepared=prepareRecord(record,reference,evidence,ts,pins); + if(historical&&record.historical_comparison?.status==='match'&&rebuildWithAssets(reference,historical,ts).sha256!==hash(prepared.candidate)) + throw Error('historical_mismatch'); + await mkdir(output,{mode:0o700}); + for(const [name,content] of [['candidate.js',prepared.candidate],['review.json',JSON.stringify({hold:true,policy:prepared.policy})]]){ + const handle=await open(resolve(output,name),'wx',0o600); + try {await handle.writeFile(content);await handle.sync();} finally {await handle.close();} + } + return {ok:true,hold:true}; +} + +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ + try { + if(process.argv.length!==6)throw Error('arguments'); + const [recordPath,referencePath,controlSha,outputPath]=process.argv.slice(2); + console.log(JSON.stringify(await prepareLocal({recordPath,referencePath,controlSha,outputPath}))); + } catch {console.error('{"ok":false,"hold":true}');process.exitCode=1;} +} diff --git a/tests/console_m09_rollback.test.mjs b/tests/console_m09_rollback.test.mjs new file mode 100644 index 00000000..6a030aab --- /dev/null +++ b/tests/console_m09_rollback.test.mjs @@ -0,0 +1,78 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {config} from '../scripts/console_946_backend_release.mjs'; +import {uploadOnly,activateOnly} from '../scripts/console_m09_split_release.mjs'; +import {rollback} from '../scripts/console_m09_rollback.mjs'; + +const sha=x=>createHash('sha256').update(x).digest('hex'); +const prior='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',deployment='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + staged='cccccccc-cccc-4ccc-8ccc-cccccccccccc',successor='dddddddd-dddd-4ddd-8ddd-dddddddddddd',restored='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const old=Buffer.from('prior module'),candidate=Buffer.from('candidate module'); +const base={bindings:[{name:'STRATEGY_SWITCH_CONFIG',type:'kv_namespace',namespace_id:'1'.repeat(32)}, + {name:'STRATEGY_SWITCH_RUNTIME_INSTANCES',type:'durable_object_namespace',namespace_id:'2'.repeat(32),class_name:'RuntimeInstances'}], + script_runtime:{compatibility_date:'2026-06-08',usage_model:'standard',migration_tag:'v1'},script:{handlers:['fetch']}}; +const policy={source:'1'.repeat(40),tree:'2'.repeat(40),frontendBlob:'3'.repeat(40),before:sha(old),candidate:sha(candidate), + version:prior,deployment,rollback:prior,configuration_sha256:sha(config(base)),migration_tag_sha256:sha('v1')}; +const evidence={source:policy.source,tree:policy.tree,frontendBlob:policy.frontendBlob}; +function fixture(){ + let uploaded=false,current={deployment,version:prior},uploads=0,activations=0,drift=null; + const api={deployments:async()=>({deployments:[{id:current.deployment,strategy:'percentage',versions:[{version_id:current.version,percentage:100}]}]}), + versions:async()=>({items:(uploaded?[staged,prior]:[prior]).map(id=>({id}))}), + betaVersion:async id=>({id,modules:[{name:'worker.js',content_type:'application/javascript+module', + content_base64:(drift===`module:${id}`?Buffer.from('changed'):id===prior?old:candidate).toString('base64')}]}), + scriptVersion:async id=>{const r=structuredClone(base);if(drift===`config:${id}`)r.script_runtime.usage_model='unbound'; + if(drift===`tag:${id}`)r.script_runtime.migration_tag='other';return {id,resources:r};}, + upload:async()=>{uploads++;uploaded=true;return {id:staged};}, + activate:async body=>{activations++;const version=body.versions[0].version_id;current={deployment:version===staged?successor:restored,version};return {id:current.deployment};}}; + return {api,counts:()=>({uploads,activations}),setDrift:value=>{drift=value;},setActive:value=>{current=value;}}; +} +async function released(f){ + const args={api:f.api,policy,evidence,candidate},stage=await uploadOnly(args);assert.equal(stage.result.ok,true); + const done=await activateOnly({...args,receipt:stage.receipt});assert.equal(done.result.ok,true); + return done.receipt; +} +test('actual split successor receipt binds a verified rollback and protected outcome',async()=>{ + const f=fixture(),receipt=await released(f),out=await rollback({api:f.api,policy,receipt}); + assert.equal(out.result.ok,true);assert.deepEqual(f.counts(),{uploads:1,activations:2}); + assert.equal(out.receipt.rollbackVersion,prior);assert.equal(out.receipt.rollbackDeployment,restored); + assert.doesNotMatch(JSON.stringify(out.result),new RegExp(prior+'|'+restored)); +}); +test('missing or mismatched receipt and policy reject before any provider access',async()=>{ + const f=fixture(),receipt=await released(f),api=new Proxy({},{get(){throw Error('provider read');}}); + for(const r of [{...receipt,successorVersion:prior},{...receipt,successorDeployment:null}, + {...receipt,migration_tag_sha256:'0'.repeat(64)},{...receipt,active:staged}]) + assert.equal((await rollback({api,policy,receipt:r})).result.deploy_attempted,false); + assert.equal((await rollback({api,policy:{...policy,configuration_sha256:undefined},receipt})).result.deploy_attempted,false); +}); +test('module, configuration and migration drift on either version prevent rollback',async()=>{ + for(const id of [prior,staged])for(const type of ['module','config','tag']){ + const f=fixture(),receipt=await released(f);f.setDrift(`${type}:${id}`); + const out=await rollback({api:f.api,policy,receipt}); + assert.equal(out.result.outcome,'NOT_ATTEMPTED',`${type}:${id}`);assert.deepEqual(f.counts(),{uploads:1,activations:1}); + } +}); +test('successor drift and unknown rollback response never retry',async()=>{ + const f=fixture(),receipt=await released(f);f.setActive({deployment:restored,version:staged}); + assert.equal((await rollback({api:f.api,policy,receipt})).result.outcome,'NOT_ATTEMPTED');assert.equal(f.counts().activations,1); + const g=fixture(),receipt2=await released(g);let calls=0;g.api.activate=async()=>{calls++;throw Error('timeout');}; + const out=await rollback({api:g.api,policy,receipt:receipt2}); + assert.equal(out.result.outcome,'UNKNOWN');assert.equal(out.result.retry,false);assert.equal(calls,1);assert.equal(out.receipt,undefined); +}); +test('last pre-write identity change stops, and failed readback is unknown',async()=>{ + const f=fixture(),receipt=await released(f),read=f.api.deployments;let reads=0; + f.api.deployments=async()=>{const result=await read();if(++reads===2)result.deployments[0].id=restored;return result;}; + assert.equal((await rollback({api:f.api,policy,receipt})).result.outcome,'NOT_ATTEMPTED'); + assert.equal(f.counts().activations,1); + const g=fixture(),receipt2=await released(g),read2=g.api.deployments; + g.api.deployments=async()=>{const result=await read2();if(g.counts().activations===2)result.deployments[0].versions[0].percentage=90;return result;}; + const out=await rollback({api:g.api,policy,receipt:receipt2}); + assert.equal(out.result.outcome,'UNKNOWN');assert.equal(out.result.retry,false);assert.equal(g.counts().activations,2);assert.equal(out.receipt,undefined); +}); +test('rollback CLI stays held before any credential or binding access',()=>{ + const script=fileURLToPath(new URL('../scripts/console_m09_rollback.mjs',import.meta.url)); + const result=spawnSync(process.execPath,[script,'rollback'],{encoding:'utf8',env:{PATH:process.env.PATH}}); + assert.notEqual(result.status,0);assert.match(result.stderr,/"reason":"hold"/);assert.match(result.stderr,/"deploy_attempted":false/); +}); diff --git a/tests/console_m09_split_release.test.mjs b/tests/console_m09_split_release.test.mjs new file mode 100644 index 00000000..814f72da --- /dev/null +++ b/tests/console_m09_split_release.test.mjs @@ -0,0 +1,83 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {uploadOnly,activateOnly} from '../scripts/console_m09_split_release.mjs'; +import {POLICY} from '../scripts/console_m09_release.mjs'; +import {config} from '../scripts/console_946_backend_release.mjs'; +const digest=x=>createHash('sha256').update(x).digest('hex'); +const old=Buffer.from('old fixture'),candidate=Buffer.from('new fixture'); +const active='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',deployment='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',staged='cccccccc-cccc-4ccc-8ccc-cccccccccccc',next='dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const baseResources={bindings:[{name:'STRATEGY_SWITCH_CONFIG',type:'kv_namespace',namespace_id:'1'.repeat(32)},{name:'STRATEGY_SWITCH_RUNTIME_INSTANCES',type:'durable_object_namespace',namespace_id:'2'.repeat(32),class_name:'RuntimeInstances'},{name:'SECRET',type:'secret_text'}],script_runtime:{compatibility_date:'2026-06-08',usage_model:'standard',migration_tag:'v1'},script:{handlers:['fetch']}}; +const policy={source:'1'.repeat(40),tree:'2'.repeat(40),frontendBlob:'3'.repeat(40),before:digest(old),candidate:digest(candidate),version:active,deployment,rollback:active, + configuration_sha256:digest(config(baseResources)),migration_tag_sha256:digest('v1')}; +const evidence={source:policy.source,tree:policy.tree,frontendBlob:policy.frontendBlob}; +function fixture(){ + let uploaded=false,activated=false,uploads=0,activations=0; + const api={deployments:async()=>({deployments:[{id:activated?next:deployment,strategy:'percentage',versions:[{version_id:activated?staged:active,percentage:100}]}]}), + versions:async()=>({items:(uploaded?[staged,active]:[active]).map(id=>({id}))}), + betaVersion:async id=>({id,modules:[{name:'worker.js',content_type:'application/javascript+module',content_base64:(id===staged?candidate:old).toString('base64')}]}), + scriptVersion:async id=>({id,resources:structuredClone(baseResources)}), + upload:async()=>{uploads++;uploaded=true;return {id:staged};},activate:async()=>{activations++;activated=true;return {id:next};}}; + return {api,counts:()=>({uploads,activations})}; +} +test('separate phases verify readback and use only their own write capability',async()=>{ + const f=fixture(), args={api:f.api,policy,evidence,candidate}; + const first=await uploadOnly(args);assert.equal(first.result.ok,true);assert.deepEqual(f.counts(),{uploads:1,activations:0}); + assert.doesNotMatch(JSON.stringify(first.result),new RegExp(staged+'|'+active)); + Object.defineProperty(f.api,'upload',{get(){throw Error('provider upload must never be read');}}); + const second=await activateOnly({...args,receipt:first.receipt});assert.equal(second.result.ok,true);assert.equal(second.result.upload_attempted,false); + assert.equal(second.receipt.successorVersion,staged);assert.equal(second.receipt.successorDeployment,next); + assert.deepEqual(f.counts(),{uploads:1,activations:1});assert.doesNotMatch(JSON.stringify(second.result),new RegExp(staged+'|'+next)); +}); +test('closed hold and bad provenance make no provider call',async()=>{ + const api=new Proxy({},{get(){throw Error('provider called');}}); + assert.equal((await uploadOnly({api,policy:POLICY,evidence,candidate})).result.ok,false); + assert.equal((await uploadOnly({api,policy,evidence:{...evidence,tree:'4'.repeat(40)},candidate})).result.ok,false); + assert.equal((await activateOnly({api,policy,evidence,candidate,receipt:{}})).result.ok,false); +}); +test('changed staged bytes or current version order stop activation',async()=>{ + for(const change of ['bytes','versions','traffic','config']){ + const f=fixture(),args={api:f.api,policy,evidence,candidate},first=await uploadOnly(args);assert.equal(first.result.ok,true); + if(change==='bytes')f.api.betaVersion=async id=>({id,modules:[{name:'worker.js',content_type:'application/javascript+module',content_base64:old.toString('base64')}]}); + if(change==='versions')f.api.versions=async()=>({items:[{id:active},{id:staged}]}); + if(change==='traffic')f.api.deployments=async()=>({deployments:[]}); + if(change==='config'){const original=f.api.scriptVersion;f.api.scriptVersion=async id=>{const out=await original(id);if(id===staged)out.resources.script_runtime.usage_model='unbound';return out;};} + const {result:r,receipt:successor}=await activateOnly({...args,receipt:first.receipt});assert.equal(r.ok,false,change);assert.equal(r.upload_attempted,false);assert.equal(r.outcome,'NOT_ATTEMPTED');assert.equal(successor,undefined);assert.equal(f.counts().activations,0); + } +}); +test('unverified uploaded candidate produces no receipt and cannot activate',async()=>{ + const f=fixture(),original=f.api.betaVersion; + f.api.betaVersion=async id=>id===staged?{id,modules:[]}:original(id); + const out=await uploadOnly({api:f.api,policy,evidence,candidate}); + assert.equal(out.result.ok,false);assert.equal(out.result.upload_attempted,true);assert.equal(out.result.deploy_attempted,false); + assert.equal(out.result.outcome,'UPLOAD_REQUIRES_READBACK');assert.equal(out.receipt,undefined);assert.deepEqual(f.counts(),{uploads:1,activations:0}); +}); +test('activation failure reports only the provider activation attempt',async()=>{ + const f=fixture(),args={api:f.api,policy,evidence,candidate},first=await uploadOnly(args); + f.api.activate=async()=>{throw Error('provider failure');}; + const {result,receipt}=await activateOnly({...args,receipt:first.receipt}); + assert.equal(result.ok,false);assert.equal(result.upload_attempted,false);assert.equal(result.deploy_attempted,true); + assert.equal(result.outcome,'ACTIVATION_REQUIRES_READBACK');assert.equal(receipt,undefined); +}); +test('captured configuration and migration drift stop each phase before its write',async()=>{ + for(const field of ['configuration','migration']){ + const change=api=>{const original=api.scriptVersion;api.scriptVersion=async id=>{const v=await original(id); + if(id===active){if(field==='configuration')v.resources.script_runtime.usage_model='unbound';else v.resources.script_runtime.migration_tag='v2';}return v;};}; + const upload=fixture();change(upload.api); + const before=await uploadOnly({api:upload.api,policy,evidence,candidate}); + assert.equal(before.result.ok,false);assert.deepEqual(upload.counts(),{uploads:0,activations:0}); + const activate=fixture(),args={api:activate.api,policy,evidence,candidate}; + const stagedResult=await uploadOnly(args);assert.equal(stagedResult.result.ok,true); + change(activate.api); + const after=await activateOnly({...args,receipt:stagedResult.receipt}); + assert.equal(after.result.ok,false);assert.deepEqual(activate.counts(),{uploads:1,activations:0}); + } +}); +test('missing digest or a mismatched receipt is rejected before provider access',async()=>{ + const api=new Proxy({},{get(){throw Error('provider called');}}); + assert.equal((await uploadOnly({api,policy:{...policy,configuration_sha256:undefined},evidence,candidate})).result.ok,false); + const f=fixture(),args={api:f.api,policy,evidence,candidate}; + const stagedResult=await uploadOnly(args); + const receipt={...stagedResult.receipt,migration_tag_sha256:'0'.repeat(64)}; + assert.equal((await activateOnly({api,policy,evidence,candidate,receipt})).result.ok,false); +}); diff --git a/tests/console_p02_baseline_capture.test.mjs b/tests/console_p02_baseline_capture.test.mjs index cc41c61e..a12b0b8f 100644 --- a/tests/console_p02_baseline_capture.test.mjs +++ b/tests/console_p02_baseline_capture.test.mjs @@ -13,7 +13,7 @@ function fixture(options={}){ const store={async reserve(){calls.push('reserve');if(options.reserveFail)throw Error('private');},async create(b){calls.push('create');saved=b;if(options.createFail)throw Error('private');return '123';},async read(g){calls.push('read');assert.equal(g,'123');return saved;}}; return {api,store,calls,get record(){return JSON.parse(saved)}}; } -async function run(opt={}){const f=fixture(opt);return {f,result:await capture({api:f.api,store:f.store,candidate,reference,alternate:opt.alternate,alternateReference:opt.alternateReference,controlSha:"a".repeat(40),now:()=>new Date("2026-11-09T00:00:00.000Z")})};} +async function run(opt={}){const f=fixture(opt);return {f,result:await capture({api:f.api,store:f.store,candidate,reference,alternate:opt.alternate,alternateReference:opt.alternateReference,expectedActive:opt.expectedActive,controlSha:"a".repeat(40),now:()=>new Date("2026-11-09T00:00:00.000Z")})};} test('match writes one record after four fixed reads and verifies exact readback',async()=>{const {f,result}=await run();assert.equal(result.ok,true);assert.deepEqual(f.calls,['reserve','deployment','module','config','deployment','create','read']);assert.equal(f.record.asset_declarations,undefined);assert.equal(f.record.control_sha,'a'.repeat(40));assert.equal(f.record.captured_at,'2026-11-09T00:00:00.000Z');assert.equal(JSON.stringify(f.record).includes('private'),false);}); test('mismatch retains only statically literal declarations',async()=>{const active=Buffer.from(candidate.toString().replace('"html"','"other"'));const {f,result}=await run({active});assert.equal(result.ok,true);assert.equal(result.asset_declarations_equal,false);assert.match(f.record.asset_declarations.V2_PAGE_HTML,/other/);assert.match(f.record.rebuilt_module_sha256,/^[a-f0-9]{64}$/);assert.equal(f.record.release_state,'hold_review_required');}); test('unsupported declaration, drift, and storage failures stop without retries',async()=>{ @@ -22,6 +22,20 @@ test('unsupported declaration, drift, and storage failures stop without retries' } }); test('bad candidate causes no storage or Worker reads',async()=>{const f=fixture();const r=await capture({api:f.api,store:f.store,candidate:Buffer.from('bad'),reference,controlSha:'a'.repeat(40)});assert.equal(r.ok,false);assert.deepEqual(f.calls,[]);}); +test('rebind records only the pinned module with equal asset declarations',async()=>{ + const active=Buffer.concat([candidate,Buffer.from('\nexport {};')]); + const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); + const matched=await run({active,expectedActive:hash(active)}); + assert.equal(matched.result.ok,true);assert.equal(matched.f.record.release_state,'hold'); + assert.equal(matched.f.record.active_module_sha256,hash(active));assert.equal(matched.f.record.asset_declarations,undefined); + assert.deepEqual(matched.f.calls,['reserve','deployment','module','config','deployment','create','read']); + const drift=await run({active,expectedActive:reference}); + assert.equal(drift.result.stage,'module');assert.deepEqual(drift.f.calls,['reserve','deployment','module']); + const different=Buffer.from(candidate.toString().replace('"html"','"other"')); + const mismatch=await run({active:different,expectedActive:hash(different)}); + assert.equal(mismatch.result.stage,'assets'); + assert.deepEqual(mismatch.f.calls,['reserve','deployment','module','config','deployment']); +}); test('mismatch removes comments and rejects ambiguous or modified literal declarations',async()=>{ const annotated=Buffer.from(candidate.toString().replace('"html"','/* private-marker */ "other"')); const good=await run({active:annotated});assert.equal(good.result.ok,true);assert.doesNotMatch(JSON.stringify(good.f.record),/private-marker/); diff --git a/tests/console_p02_baseline_hosted.test.mjs b/tests/console_p02_baseline_hosted.test.mjs index c148b55a..7ec4bf0c 100644 --- a/tests/console_p02_baseline_hosted.test.mjs +++ b/tests/console_p02_baseline_hosted.test.mjs @@ -9,6 +9,7 @@ test('requires protected proof bound to exact identity, object, control and shor for(const delta of [{bucket:'wrong-bucket'},{object:'other'},{wif_provider:'other'},{service_account:'other'},{control_sha:'b'.repeat(40)},{policy_readback_reviewed:false},{create_permitted:false},{get_permitted:false},{target_absent:false},{expires_at:'2026-11-08T23:59:59Z'},{expires_at:'2026-11-11T00:00:00Z'},{owner_project:''}]) assert.throws(()=>verifyCapability({...env,QRS_P02_STORAGE_CAPABILITY_RECEIPT:JSON.stringify({...receipt,...delta})},now)); assert.throws(()=>verifyCapability({...env,QRS_P02_STORAGE_CAPABILITY_RECEIPT:''},now)); + assert.throws(()=>verifyCapability({...env,QRS_P02_STORAGE_CAPABILITY_RECEIPT:JSON.stringify({...receipt,object:KEY.replace('-r2/','-r1/')})},now)); }); test('storage addresses exact object, creates once conditionally, reads exact generation',async()=>{ const calls=[]; diff --git a/tests/console_p02_prepare.test.mjs b/tests/console_p02_prepare.test.mjs new file mode 100644 index 00000000..92628276 --- /dev/null +++ b/tests/console_p02_prepare.test.mjs @@ -0,0 +1,44 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {prepareRecord} from '../scripts/console_p02_prepare.mjs'; +import {rebuildWithAssets} from '../scripts/console_p02_baseline_capture.mjs'; +import {POLICY} from '../scripts/console_m09_release.mjs'; + +const require=createRequire(new URL('../web/strategy-switch-console/frontend/package.json',import.meta.url)),ts=require('typescript'); +const hash=x=>createHash('sha256').update(x).digest('hex'); +const reference=Buffer.from('var V2_BUILD_ID = "a";\nvar V2_PAGE_HTML = "html";\nvar V2_ASSETS = Object.freeze({"/x": {base64: "YWJj"}});'); +const historical=Buffer.from(reference.toString().replace('"html"','"old html"')); +const rebuilt=rebuildWithAssets(reference,historical,ts); +const pins={control:'a'.repeat(40),reference:hash(reference),referenceFrontendBlob:'a'.repeat(40), + historical:'b'.repeat(40),historicalBundle:hash(historical)}; +const evidence={source:POLICY.source,tree:POLICY.tree,frontendBlob:pins.referenceFrontendBlob, + historicalSource:pins.historical,historicalBundle:pins.historicalBundle}; +const baseline={schema:'qrs-worker-p02-baseline-v1',control_sha:pins.control,reference_module_sha256:pins.reference, + version:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',deployment:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + active_module_sha256:'c'.repeat(64),configuration_sha256:'d'.repeat(64),migration_tag_sha256:'e'.repeat(64), + asset_comparison:{V2_BUILD_ID:true,V2_PAGE_HTML:false,V2_ASSETS:true},historical_comparison:{source:pins.historical,status:'match'}, + asset_declarations:rebuilt.declarations,rebuilt_module_sha256:rebuilt.sha256,release_state:'hold_review_required'}; +const prepare=r=>prepareRecord(r,reference,evidence,ts,pins); + +test('reconstructs the captured declarations and retains rollback identity on hold',()=>{ + const out=prepare(baseline); + assert.deepEqual(out.candidate,rebuilt.bytes);assert.equal(out.hold,true); + assert.equal(out.policy.rollback,baseline.version);assert.equal(out.policy.before,baseline.active_module_sha256); + assert.equal(out.policy.configuration_sha256,baseline.configuration_sha256);assert.equal(out.policy.migration_tag_sha256,baseline.migration_tag_sha256); +}); +test('rejects missing historical proof, tampering, or identity drift',()=>{ + for(const r of [{...baseline,historical_comparison:{status:'unavailable'}},{...baseline,rebuilt_module_sha256:'0'.repeat(64)}, + {...baseline,control_sha:'0'.repeat(40)},{...baseline,version:null}, + {...baseline,asset_declarations:{...baseline.asset_declarations,V2_PAGE_HTML:'var V2_PAGE_HTML = unsafe();'}}]) + assert.throws(()=>prepare(r)); + for(const changes of [{tree:'0'.repeat(40)},{frontendBlob:POLICY.frontendBlob},{historicalBundle:'0'.repeat(64)}]){ + assert.throws(()=>prepareRecord(baseline,reference,{...evidence,...changes},ts,pins)); + } +}); +test('matching assets retain the reference bundle on hold',()=>{ + const r={...baseline,asset_comparison:{V2_BUILD_ID:true,V2_PAGE_HTML:true,V2_ASSETS:true},release_state:'hold'}; + delete r.asset_declarations;delete r.rebuilt_module_sha256;delete r.historical_comparison; + assert.deepEqual(prepare(r).candidate,reference); +}); diff --git a/tests/console_p02_prepare_local.test.mjs b/tests/console_p02_prepare_local.test.mjs new file mode 100644 index 00000000..07644eea --- /dev/null +++ b/tests/console_p02_prepare_local.test.mjs @@ -0,0 +1,126 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {mkdtemp,readFile,rm,stat,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {prepareLocal} from '../scripts/console_p02_prepare_local.mjs'; +import {rebuildWithAssets} from '../scripts/console_p02_baseline_capture.mjs'; +import {POLICY} from '../scripts/console_m09_release.mjs'; +import {config} from '../scripts/console_946_backend_release.mjs'; +import {uploadOnly,activateOnly} from '../scripts/console_m09_split_release.mjs'; + +const require=createRequire(new URL('../web/strategy-switch-console/frontend/package.json',import.meta.url)),ts=require('typescript'); +const sha=x=>createHash('sha256').update(x).digest('hex'); +const reference=Buffer.from('var V2_BUILD_ID = "a";\nvar V2_PAGE_HTML = "html";\nvar V2_ASSETS = Object.freeze({"/x": {base64: "YWJj"}});'); +const historical=Buffer.from(reference.toString().replace('"html"','"old html"')); +const rebuilt=rebuildWithAssets(reference,historical,ts); +const pins={control:'a'.repeat(40),reference:sha(reference),referenceFrontendBlob:'b'.repeat(40),historical:'c'.repeat(40),historicalBundle:sha(historical)}; +const record={schema:'qrs-worker-p02-baseline-v1',control_sha:pins.control,reference_module_sha256:pins.reference, + version:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',deployment:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + active_module_sha256:'c'.repeat(64),configuration_sha256:'d'.repeat(64),migration_tag_sha256:'e'.repeat(64), + asset_comparison:{V2_BUILD_ID:true,V2_PAGE_HTML:false,V2_ASSETS:true},historical_comparison:{source:pins.historical,status:'match'}, + asset_declarations:rebuilt.declarations,rebuilt_module_sha256:rebuilt.sha256,release_state:'hold_review_required'}; +const git=(_command,object)=>object.endsWith('^{tree}')?POLICY.tree:object.includes(':')?pins.referenceFrontendBlob: + object.startsWith(POLICY.source)?POLICY.source:pins.historical; + +test('local consumer materializes only protected review files and rejects mismatches',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'p02-local-')); + try { + const paths={recordPath:join(dir,'record'),referencePath:join(dir,'reference'),historicalPath:join(dir,'historical'),outputPath:join(dir,'out')}; + await writeFile(paths.recordPath,JSON.stringify(record),{mode:0o600});await writeFile(paths.referencePath,reference);await writeFile(paths.historicalPath,historical); + const deps={root:join(dir,'repo'),git,pins,ts}; + assert.deepEqual(await prepareLocal(paths,deps),{ok:true,hold:true}); + assert.deepEqual(await readFile(join(paths.outputPath,'candidate.js')),rebuilt.bytes); + const review=JSON.parse(await readFile(join(paths.outputPath,'review.json'),'utf8')); + assert.equal(review.hold,true);assert.equal(review.policy.rollback,record.version); + assert.equal((await stat(paths.outputPath)).mode&0o777,0o700); + assert.equal((await stat(join(paths.outputPath,'review.json'))).mode&0o777,0o600); + await assert.rejects(prepareLocal(paths,deps)); + for(const [bad,depsOverride] of [[{historicalPath:paths.referencePath},{}],[{}, {git:(...args)=>args[1]?.endsWith('^{tree}')?'0'.repeat(40):git(...args)}]]){ + const out=join(dir,'bad-'+Object.keys(bad).join('')+Object.keys(depsOverride).join('')); + await assert.rejects(prepareLocal({...paths,...bad,outputPath:out},{...deps,...depsOverride})); + await assert.rejects(stat(out),{code:'ENOENT'}); + } + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test('actual CLI rejects invalid input without revealing it or creating output',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'p02-cli-')); + try { + const r=join(dir,'record'),ref=join(dir,'reference'),hist=join(dir,'historical'),out=join(dir,'out'); + await writeFile(r,'private-marker',{mode:0o600});await writeFile(ref,reference);await writeFile(hist,historical); + const script=fileURLToPath(new URL('../scripts/console_p02_prepare_local.mjs',import.meta.url)); + const result=spawnSync(process.execPath,[script,r,ref,'a'.repeat(40),out],{encoding:'utf8'}); + assert.notEqual(result.status,0);assert.doesNotMatch(result.stdout+result.stderr,/private-marker/); + assert.match(result.stderr,/"hold":true/);await assert.rejects(stat(out),{code:'ENOENT'}); + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test('current batch binds the approved control and active module without historical input',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'p02-current-')); + try { + const currentPins={...pins,active:'f'.repeat(64)}; + const current={...record,active_module_sha256:currentPins.active, + asset_comparison:{V2_BUILD_ID:true,V2_PAGE_HTML:true,V2_ASSETS:true},release_state:'hold'}; + delete current.asset_declarations;delete current.rebuilt_module_sha256;delete current.historical_comparison; + const paths={recordPath:join(dir,'record'),referencePath:join(dir,'reference'),outputPath:join(dir,'out')}; + await writeFile(paths.recordPath,JSON.stringify(current),{mode:0o600});await writeFile(paths.referencePath,reference); + const deps={root:join(dir,'repo'),git,pins:currentPins,ts}; + assert.deepEqual(await prepareLocal(paths,deps),{ok:true,hold:true}); + assert.deepEqual(await readFile(join(paths.outputPath,'candidate.js')),reference); + for(const [label,bad] of [['control',{...current,control_sha:'0'.repeat(40)}],['active',{...current,active_module_sha256:'0'.repeat(64)}]]){ + await writeFile(paths.recordPath,JSON.stringify(bad)); + const out=join(dir,label);await assert.rejects(prepareLocal({...paths,outputPath:out},deps)); + await assert.rejects(stat(out),{code:'ENOENT'}); + } + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test('prepared files pass through separate staged upload and activation with configuration checks',async()=>{ + const dir=await mkdtemp(join(tmpdir(),'p02-handoff-')); + try { + const staged='cccccccc-cccc-4ccc-8ccc-cccccccccccc',successor='dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + const active=record.version,deployment=record.deployment; + const resources={bindings:[{name:'STRATEGY_SWITCH_CONFIG',type:'kv_namespace',namespace_id:'1'.repeat(32)}, + {name:'STRATEGY_SWITCH_RUNTIME_INSTANCES',type:'durable_object_namespace',namespace_id:'2'.repeat(32),class_name:'RuntimeInstances'}, + {name:'SECRET',type:'secret_text'}],script_runtime:{compatibility_date:'2026-06-08',usage_model:'standard',migration_tag:'v1'},script:{handlers:['fetch']}}; + const fullRecord={...record,active_module_sha256:sha(reference),configuration_sha256:sha(config(resources)),migration_tag_sha256:sha('v1')}; + const paths={recordPath:join(dir,'record'),referencePath:join(dir,'reference'),historicalPath:join(dir,'historical'),outputPath:join(dir,'out')}; + await writeFile(paths.recordPath,JSON.stringify(fullRecord),{mode:0o600}); + await writeFile(paths.referencePath,reference);await writeFile(paths.historicalPath,historical); + await prepareLocal(paths,{root:join(dir,'repo'),git,pins,ts}); + const candidate=await readFile(join(paths.outputPath,'candidate.js')); + const {hold,policy}=JSON.parse(await readFile(join(paths.outputPath,'review.json'),'utf8')); + assert.equal(hold,true); + const evidence={source:policy.source,tree:policy.tree,frontendBlob:policy.frontendBlob}; + function fixture(){ + let uploaded=false,activated=false,uploads=0,activations=0,drift=null; + const api={deployments:async()=>({deployments:[{id:activated?successor:deployment,strategy:'percentage',versions:[{version_id:activated?staged:active,percentage:100}]}]}), + versions:async()=>({items:(uploaded?[staged,active]:[active]).map(id=>({id}))}), + betaVersion:async id=>({id,modules:[{name:'worker.js',content_type:'application/javascript+module',content_base64:(id===staged?candidate:reference).toString('base64')}]}), + scriptVersion:async id=>{const r=structuredClone(resources);if(id===staged&&drift==='configuration')r.script_runtime.usage_model='unbound'; + if(id===staged&&drift==='staged_migration')r.script_runtime.migration_tag='v2'; + if(id===active&&drift==='migration')r.script_runtime.migration_tag='v2';return {id,resources:r};}, + upload:async()=>{uploads++;uploaded=true;return {id:staged};},activate:async()=>{activations++;activated=true;return {id:successor};}}; + return {api,counts:()=>({uploads,activations}),setDrift:value=>{drift=value;}}; + } + const args={policy,evidence,candidate},f=fixture(); + const stage=await uploadOnly({...args,api:f.api}); + assert.equal(stage.result.ok,true);assert.deepEqual(f.counts(),{uploads:1,activations:0}); + assert.equal(stage.receipt.configuration_sha256,fullRecord.configuration_sha256); + assert.equal(stage.receipt.migration_tag_sha256,fullRecord.migration_tag_sha256); + const activated=await activateOnly({...args,api:f.api,receipt:stage.receipt}); + assert.equal(activated.result.ok,true);assert.deepEqual(f.counts(),{uploads:1,activations:1}); + assert.equal(activated.receipt.successorVersion,staged);assert.equal(activated.receipt.successorDeployment,successor); + for(const drift of ['configuration','migration','staged_migration']){ + const bad=fixture(),first=await uploadOnly({...args,api:bad.api});assert.equal(first.result.ok,true); + bad.setDrift(drift); + const second=await activateOnly({...args,api:bad.api,receipt:first.receipt}); + assert.equal(second.result.ok,false,drift);assert.deepEqual(bad.counts(),{uploads:1,activations:0}); + } + } finally {await rm(dir,{recursive:true,force:true});} +});