Add verified backup and restore for shared data volumes
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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 <source> <new-backup> --offline | verify <backup> | restore <backup> <new-volume>');
|
||||
console.log('Volume işlemi doğrulandı.');
|
||||
}catch(error){console.error(error.message);process.exitCode=1;}
|
||||
}
|
||||
Reference in New Issue
Block a user