import fs from 'node:fs'; import path from 'node:path'; import {createHash} from 'node:crypto'; import {DatabaseSync} from 'node:sqlite'; import {fileURLToPath} from 'node:url'; const regular=file=>{if(!fs.lstatSync(file).isFile())throw new Error('Normal dosya gerekli: '+file);}; const inside=(root,target)=>{const relative=path.relative(path.resolve(root),path.resolve(target));return relative==='' || (!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative));}; function safeFile(root,name) { if(typeof name!=='string'||!name||path.isAbsolute(name)||name.split('/').some(p=>!p||p==='.'||p==='..')||name.includes('\\'))throw new Error('Geçersiz manifest yolu.'); let current=root; for(const part of name.split('/')) {current=path.join(current,part);if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())throw new Error('Yedekte sembolik bağlantı kullanılamaz.');} return current; } async function hash(file) { const digest=createHash('sha256'); for await(const chunk of fs.createReadStream(file))digest.update(chunk); return digest.digest('hex'); } function sqlite(file) { const fd=fs.openSync(file,'r'),header=Buffer.alloc(16); try {fs.readSync(fd,header,0,16,0);}finally {fs.closeSync(fd);} return header.equals(Buffer.from('SQLite format 3\0')); } function integrity(file) { const db=new DatabaseSync(file,{readOnly:true}); try {const rows=db.prepare('PRAGMA integrity_check').all();if(rows.length!==1||Object.values(rows[0])[0]!=='ok')throw new Error('SQLite bütünlük kontrolü başarısız.');} finally {db.close();} } function inventory(root,relative='') { const result=[]; for(const entry of fs.readdirSync(path.join(root,relative),{withFileTypes:true})) { const name=relative?relative+'/'+entry.name:entry.name; if(entry.isSymbolicLink())throw new Error('Veri volume içinde sembolik bağlantı var: '+name); if(entry.isDirectory())result.push(...inventory(root,name)); else if(entry.isFile())result.push(name); else throw new Error('Desteklenmeyen veri dosyası: '+name); } return result.sort(); } export async function verifyDataVolume(backup) { if(fs.lstatSync(backup).isSymbolicLink())throw new Error('Yedek dizini bağlantı olamaz.'); const manifestPath=safeFile(backup,'volume-manifest.json');regular(manifestPath); const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8')); if(manifest.version!==1||manifest.scope!=='data-volume'||!Array.isArray(manifest.files))throw new Error('Geçersiz volume manifesti.'); const names=new Set(); for(const entry of manifest.files) { if(names.has(entry.name)||entry.name==='volume-manifest.json')throw new Error('Tekrarlı/geçersiz manifest girdisi.');names.add(entry.name); const file=safeFile(backup,entry.name);regular(file); if(fs.statSync(file).size!==entry.bytes||await hash(file)!==entry.sha256)throw new Error('Yedek değişmiş veya eksik: '+entry.name); if(entry.sqlite)integrity(file); } const actual=inventory(backup).filter(n=>n!=='volume-manifest.json'); if(actual.length!==names.size||actual.some(n=>!names.has(n)))throw new Error('Manifest dışında dosya var.'); return manifest; } export async function backupDataVolume(source,destination,{offline=false}={}) { if(!offline)throw new Error('Uygulamayı ve tüm yazan işleri durdurun; --offline gerekli.'); if(inside(source,destination)||inside(destination,source))throw new Error('Kaynak ve hedef iç içe olamaz.'); if(fs.lstatSync(source).isSymbolicLink())throw new Error('Kaynak gerçek volume dizini olmalı.'); const names=inventory(source); if(names.includes('volume-manifest.json'))throw new Error('Kaynak bir yedek dizini gibi görünüyor.'); const dbNames=new Set(names.filter(name=>sqlite(path.join(source,name)))); fs.mkdirSync(destination,{mode:0o700}); try { const files=[]; for(const name of names) { if(/-(wal|shm)$/.test(name)&&dbNames.has(name.replace(/-(wal|shm)$/,'')))continue; const original=safeFile(source,name),output=safeFile(destination,name); fs.mkdirSync(path.dirname(output),{recursive:true,mode:0o700}); if(dbNames.has(name)) { const db=new DatabaseSync(original,{readOnly:true}); try {db.prepare('VACUUM INTO ?').run(path.resolve(output));}finally {db.close();} integrity(output); } else fs.copyFileSync(original,output,fs.constants.COPYFILE_EXCL); fs.chmodSync(output,0o600); files.push({name,sqlite:dbNames.has(name),bytes:fs.statSync(output).size,sha256:await hash(output)}); } fs.writeFileSync(path.join(destination,'volume-manifest.json'),JSON.stringify({version:1,scope:'data-volume',createdAt:new Date().toISOString(),files},null,2),{flag:'wx',mode:0o600}); return await verifyDataVolume(destination); } catch(error) {fs.rmSync(destination,{recursive:true,force:true});throw error;} } export async function restoreDataVolume(backup,destination) { const manifest=await verifyDataVolume(backup); if(inside(backup,destination)||inside(destination,backup))throw new Error('Yedek ve hedef iç içe olamaz.'); fs.mkdirSync(destination,{mode:0o700}); try { for(const entry of manifest.files) { const target=safeFile(destination,entry.name);fs.mkdirSync(path.dirname(target),{recursive:true,mode:0o700}); fs.copyFileSync(safeFile(backup,entry.name),target,fs.constants.COPYFILE_EXCL);fs.chmodSync(target,0o600); if(await hash(target)!==entry.sha256)throw new Error('Geri yüklenen dosya doğrulanamadı.'); if(entry.sqlite)integrity(target); } return {files:manifest.files.length,destination}; } catch(error) {fs.rmSync(destination,{recursive:true,force:true});throw error;} } if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) { const [command,source,destination,flag]=process.argv.slice(2); try { if(command==='backup'&&source&&destination)await backupDataVolume(source,destination,{offline:flag==='--offline'}); else if(command==='verify'&&source)await verifyDataVolume(source); else if(command==='restore'&&source&&destination)await restoreDataVolume(source,destination); else throw new Error('backup --offline | verify | restore '); console.log('Volume işlemi doğrulandı.'); }catch(error){console.error(error.message);process.exitCode=1;} }