fix: refuse uninitialized Cloudflare backup sources

This commit is contained in:
Forge
2026-09-22 07:07:06 +00:00
parent 52c1b38644
commit e77f011ec3
4 changed files with 35 additions and 3 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ npx tsc
npm ci --prefix marketplace/cloudflare --no-audit --no-fund
npm run build --prefix marketplace/cloudflare
npm test --prefix marketplace/cloudflare
(cd marketplace/cloudflare && node ops-test.mjs)
CLOUDFLARE_REGISTRY=1 PLAYWRIGHT_MODULE=/absolute/path/to/playwright \
node scripts/validation/extensions-compatibility.cjs
CLOUDFLARE_REGISTRY=1 PLAYWRIGHT_MODULE=/absolute/path/to/playwright \
@@ -39,7 +40,7 @@ node ops.mjs backup /absolute/lab/registry /absolute/backups/unique-snapshot
node ops.mjs restore /absolute/new-lab/registry /absolute/backups/unique-snapshot
```
Stop the rehearsal server before using the operator CLI on that same local state. The binding-level backup reads identity/owners/releases/nonces in one D1 batch snapshot, then copies immutable R2 bytes. A completion manifest is written last. Restore verifies checksums, manifest/package/ownership correspondence, refuses an occupied target, copies objects first and publishes metadata in one D1 batch. Partial restores remain detached and must be retried into a new destination. Backups contain public packages and metadata, not publisher private keys. Protect the independent checksum against tampering; checksums alone are not authentication. Local process-kill proof does not establish provider disk/power-loss or off-host retention guarantees.
Stop the rehearsal server before using the operator CLI on that same local state. Backup refuses missing or uninitialized source state and never silently migrates an empty source into a successful snapshot. The binding-level backup reads identity/owners/releases/nonces in one D1 batch snapshot, then copies immutable R2 bytes. A completion manifest is written last. Restore verifies checksums, manifest/package/ownership correspondence, refuses an occupied target, copies objects first and publishes metadata in one D1 batch. Partial restores remain detached and must be retried into a new destination. Backups contain public packages and metadata, not publisher private keys. Protect the independent checksum against tampering; checksums alone are not authentication. Local process-kill proof does not establish provider disk/power-loss or off-host retention guarantees.
## Prepare deployment after access is verified
+2 -1
View File
@@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root=path.dirname(fileURLToPath(import.meta.url));
export async function startLocal({state,testing=false,boundary,port=0,publishing=true}={}) {
export async function startLocal({state,testing=false,boundary,port=0,publishing=true,initialize=true}={}) {
const mf=new Miniflare({...convertV4MiniflareOptions({host:'127.0.0.1',port,workers:[{name:'registry',modules:true,scriptPath:path.join(root,'.build',testing?'test-worker.js':'worker.js'),compatibilityDate:'2026-09-21',
d1Databases:{DB:'registry'},r2Buckets:{PACKAGES:'packages'},
bindings:{PUBLISHING_ENABLED:String(publishing)},serviceBindings:{
@@ -12,6 +12,7 @@ export async function startLocal({state,testing=false,boundary,port=0,publishing
await mf.ready;
const db=await mf.getD1Database('DB');
const exists=await db.prepare("SELECT name FROM sqlite_master WHERE name='registry_meta'").first();
if(!exists&&!initialize){await mf.dispose();throw Error('Source registry is not initialized; verify the source state path before backup');}
if(!exists)for(const line of fs.readFileSync(path.join(root,'migrations/0001_registry.sql'),'utf8').split('\n').filter(Boolean))await db.exec(line);
return {mf,db,bucket:await mf.getR2Bucket('PACKAGES'),port:Number((await mf.ready).port),close:()=>mf.dispose()};
}
+30
View File
@@ -0,0 +1,30 @@
// Real subprocess rehearsal of the documented offline operator commands.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import assert from 'node:assert/strict';
import {spawn,execFileSync} from 'node:child_process';
import {generateKeyPairSync,sign,randomUUID} from 'node:crypto';
import {startLocal} from './local.mjs';
const root=path.resolve('../..'),out=path.resolve(process.env.CLOUDFLARE_OPS_OUTPUT||path.join(root,'.qa/cloudflare/operators'));
fs.mkdirSync(out,{recursive:true});
const lab=fs.mkdtempSync(path.join(os.tmpdir(),'cg-worker-operators-')),checks=[],commands=[];
async function command(args,expected=0){
const result=await new Promise((resolve,reject)=>{const child=spawn(process.execPath,['ops.mjs',...args],{timeout:30000});let log='';child.stdout.on('data',b=>log+=b);child.stderr.on('data',b=>log+=b);child.on('error',reject);child.on('close',(exit,signal)=>resolve({exit,signal,log}));});
const log='command-'+(commands.length+1)+'.log';fs.writeFileSync(path.join(out,log),result.log);commands.push({command:[process.execPath,'ops.mjs',...args],exit:result.exit,signal:result.signal,log});assert.equal(result.exit,expected,result.log);return result;
}
function passed(name){checks.push(name);fs.writeFileSync(path.join(out,'progress.json'),JSON.stringify({checks,commands},null,2));console.log('PASS',name);}
let source,restored;
try{
const empty=path.join(lab,'empty');fs.mkdirSync(empty);
const absent=await command(['backup',path.join(lab,'missing'),path.join(lab,'absent-backup')],1);assert.match(absent.log,/does not exist/);assert.ok(!fs.existsSync(path.join(lab,'absent-backup')));passed('missing source fails before backup');
const uninitialized=await command(['backup',empty,path.join(lab,'empty-backup')],1);assert.match(uninitialized.log,/not initialized/);assert.ok(!fs.existsSync(path.join(lab,'empty-backup')));passed('empty source fails without creating a registry snapshot');
const state=path.join(lab,'source'),backup=path.join(lab,'snapshot'),target=path.join(lab,'restored');source=await startLocal({state});
const artifact=Buffer.from(JSON.stringify({format:'codegraph-extension-1',package:{name:'operator-demo',version:'1.0.0',main:'index.cjs',codegraph:{id:'operator-demo',apiVersion:1,capabilities:['frameworks']}},files:{'index.cjs':'module.exports=()=>({frameworks:[]});'}}));
const keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),payload=JSON.stringify({name:'Operator example',description:'Backup rehearsal',publisher:'Test',readme:'Fixture',source:'https://example.com/source',artifact:artifact.toString('base64'),timestamp:Date.now(),nonce:randomUUID()});
const response=await fetch('http://127.0.0.1:'+source.port+'/api/publish',{method:'POST',body:JSON.stringify({payload,publicKey:keys.publicKey.export({format:'jwk'}),signature:sign('sha256',Buffer.from(payload),{key:keys.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')})});assert.equal(response.status,201);const listing=await response.json();await source.close();source=null;
await command(['backup',state,backup]);await command(['backup',state,backup],1);passed('documented backup command captures a release and refuses overwrite');
await command(['restore',target,backup]);restored=await startLocal({state:target,initialize:false,publishing:false});const catalog=await(await fetch('http://127.0.0.1:'+restored.port+'/api/extensions')).json();assert.deepEqual(catalog,[listing]);const bytes=Buffer.from(await(await fetch('http://127.0.0.1:'+restored.port+'/api/download/operator-demo/1.0.0')).arrayBuffer());assert.deepEqual(bytes,artifact);await restored.close();restored=null;passed('documented restore command preserves publisher release and exact bytes');
const occupied=await command(['restore',target,backup],1);assert.match(occupied.log,/new state directory/);passed('restore refuses an existing target before mutation');
fs.writeFileSync(path.join(out,'result.json'),JSON.stringify({revision:execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),dirty:!!execFileSync('git',['status','--porcelain'],{encoding:'utf8'}).trim(),checks,commands,success:true},null,2));
}finally{await source?.close();await restored?.close();fs.rmSync(lab,{recursive:true,force:true});}
+1 -1
View File
@@ -8,7 +8,7 @@ if(!['serve','backup','restore'].includes(action)||!stateArg||(['backup','restor
if(!path.isAbsolute(stateArg)||(backupArg&&!path.isAbsolute(backupArg)))throw Error('Use explicit absolute state and backup paths');
if(action==='backup'&&!fs.existsSync(stateArg))throw Error('Source state directory does not exist');
if(action==='restore'&&fs.existsSync(stateArg))throw Error('Restore requires a new state directory');
const server=await startLocal({state:stateArg,publishing:action==='serve'});
const server=await startLocal({state:stateArg,publishing:action==='serve',initialize:action!=='backup'});
if(action==='serve'){
console.log(JSON.stringify({local:true,origin:'http://127.0.0.1:'+server.port,state:stateArg}));
for(const signal of ['SIGTERM','SIGINT'])process.once(signal,async()=>{await server.close();process.exit(0);});