import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { mkdir, open, unlink, lstat, readFile, writeFile, chmod, copyFile, realpath } from 'node:fs/promises'; import { join, resolve, relative, isAbsolute } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { z } from 'zod'; import { NETWORK, PROFILE } from '../shared/network.ts'; import { CHAIN_ID } from './testnet/consensus.ts'; import { CUSTODY_POLICY } from '../shared/custody.ts'; const files=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const; const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1), network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(), files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict() .refine(m => new Set(m.files.map(f=>f.name)).size === m.files.length && m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate backup database'); async function regular(path:string){const s=await lstat(path);if(!s.isFile()||s.isSymbolicLink())throw new Error('Expected regular non-symlink database file');return s;} async function digest(path:string){const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest('hex');} async function flush(path:string){const handle=await open(path,'r+');try{await handle.sync();}finally{await handle.close();}} async function flushDirectory(path:string){ if(process.platform==='win32')return; // Windows directory durability requires filesystem-specific operational validation. const handle=await open(path,'r');try{await handle.sync();}finally{await handle.close();} } function inspect(file:string,name:typeof files[number]) { const db=new DatabaseSync(file,{readOnly:true}); try { const result=db.prepare('PRAGMA quick_check').all(); if(result.length!==1||Object.values(result[0])[0]!=='ok')throw new Error('SQLite integrity check failed'); if(name==='ledger.sqlite'){ const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value; if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch'); }else if(name==='custody-sandbox.sqlite'){ if(db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) { throw new Error('PostgreSQL cutover source cannot be backed up as current custody; coordinated database backup required'); } const row=db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as {value:string}|undefined; const identity=row?JSON.parse(row.value):undefined; if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||identity.mode!=='sandbox'|| identity.policy!==CUSTODY_POLICY||!z.string().uuid().safeParse(identity.id).success)throw new Error('Custody identity mismatch'); }else{ const row=db.prepare("SELECT value FROM market_meta WHERE key='identity'").get() as {value:string}|undefined; const identity=row?JSON.parse(row.value):undefined; if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||!z.string().uuid().safeParse(identity.id).success)throw new Error('Market identity mismatch'); } }finally{db.close();} } /** Share the exact node startup lock. A stale lock is never removed automatically. */ async function offlineLock(dataDir:string,fn:()=>Promise):Promise{ const lockPath=join(dataDir,`${PROFILE}-node.lock`); const handle=await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Node directory is locked; stop the node and review stale locks manually.');}); try{await handle.writeFile(`maintenance:${process.pid}`);return await fn();} finally{await handle.close();await unlink(lockPath);} } export async function createBackup(dataDir:string,destination:string){ const source=await realpath(dataDir),dest=resolve(destination); const rel=relative(source,dest); if(!rel||(!rel.startsWith('..')&&!isAbsolute(rel)))throw new Error('Backup destination must be outside the node data directory'); return offlineLock(source,async()=>{ // File-only backups cannot represent an external financial database. Fail rather than omit liabilities. try { await lstat(join(source,PROFILE,'custody-postgres.json')); throw new Error('PostgreSQL custody requires coordinated database and node backup; SQLite-only backup refused'); } catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked'); await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination. const entries=[]; for(const name of files){ const src=join(source,PROFILE,name),target=join(dest,name); if(name==='custody-sandbox.sqlite'){ try{await lstat(src);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;} } await regular(src);inspect(src,name); const db=new DatabaseSync(src,{readOnly:true}); try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();} if(process.platform!=='win32')await chmod(target,0o600); await flush(target); inspect(target,name);const info=await regular(target); entries.push({name,bytes:info.size,sha256:await digest(target)}); } const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE, createdAt:new Date().toISOString(),files:entries}); // Completion marker written last. Partial output is preserved for operator inspection, never treated as usable. await writeFile(join(dest,'manifest.json'),JSON.stringify(manifest,null,2),{flag:'wx',mode:0o600}); await flush(join(dest,'manifest.json')); await flushDirectory(dest); return manifest; }); } export async function verifyBackup(directory:string){ const root=await realpath(directory),manifestFile=join(root,'manifest.json'); if((await regular(manifestFile)).size>16384)throw new Error('Backup manifest too large'); const manifest=manifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8'))); if(new Set(manifest.files.map(f=>f.name)).size!==manifest.files.length)throw new Error('Duplicate backup database'); for(const file of manifest.files){ const path=join(root,file.name),info=await regular(path); if(info.size!==file.bytes||await digest(path)!==file.sha256)throw new Error('Backup database hash/size mismatch'); // A completed package has standalone DBs. Sidecars would change what SQLite reads versus the hashed file. for(const suffix of ['-wal','-shm','-journal']){ try{await lstat(path+suffix);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;} throw new Error('Backup database has unexpected SQLite sidecar'); } inspect(path,file.name); } return manifest; } export async function restoreBackup(directory:string,destination:string){ const manifest=await verifyBackup(directory),dest=resolve(destination); await mkdir(dest,{mode:0o700}); // Must not exist, even if empty. Never reset a node in place. return offlineLock(dest,async()=>{ const folder=join(dest,PROFILE);await mkdir(folder,{mode:0o700}); for(const file of manifest.files){ const target=join(folder,file.name); await copyFile(join(directory,file.name),target,1); // COPYFILE_EXCL if(process.platform!=='win32')await chmod(target,0o600); await flush(target); if(await digest(target)!==file.sha256)throw new Error('Restored copy hash mismatch'); inspect(target,file.name); } await flushDirectory(folder);await flushDirectory(dest); return {network:NETWORK,chainId:CHAIN_ID,restored:true,proofsRevalidated:false, warning:'SQLite/hash checks only. Replay with the reference engine in an isolated reviewed rehearsal before production use.'}; }); }