'use strict'; const fs=require('fs'); const fsp=fs.promises; const path=require('path'); const crypto=require('crypto'); const PLAYER_FILE=/^[a-f0-9]{16,64}\.json$/i; const BOARD_FILE=/^B(?:0|[1-9][0-9]*)\.[0-9]+\.json$/; async function hashFile(file){const hash=crypto.createHash('sha256'),stream=fs.createReadStream(file);for await(const chunk of stream)hash.update(chunk);return hash.digest('hex')} async function copyVerified(source,destination,{link=false}={}){await fsp.mkdir(path.dirname(destination),{recursive:true,mode:0o700});if(link)try{await fsp.link(source,destination);return await hashFile(destination)}catch(error){if(!['EXDEV','EPERM','EACCES','EEXIST'].includes(error?.code))throw error}await fsp.copyFile(source,destination);await fsp.chmod(destination,0o600).catch(()=>{});return hashFile(destination)} function backupName(date=new Date()){return date.toISOString().replace(/[:.]/g,'-')} function safeBackupName(value){const name=String(value||'');if(!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/.test(name))throw new Error('Invalid LinkField backup name');return name} async function createWorldBackup(dataDirValue,backupRootValue,{retain=7,appVersion='unknown'}={}){ const dataDir=path.resolve(dataDirValue),backupRoot=path.resolve(backupRootValue),name=backupName(),stage=path.join(backupRoot,`.stage-${name}-${process.pid}`),destination=path.join(backupRoot,name); await fsp.mkdir(backupRoot,{recursive:true,mode:0o700});await fsp.rm(stage,{recursive:true,force:true});await fsp.mkdir(path.join(stage,'data','shared-world.boards'),{recursive:true,mode:0o700}); try{ const worldFile=path.join(dataDir,'shared-world.json'),world=JSON.parse(await fsp.readFile(worldFile,'utf8')),files=['shared-world.json']; if(!world||!Number.isSafeInteger(world.revision)||!world.boardVersions||typeof world.boardVersions!=='object')throw new Error('Shared world is not valid enough to back up'); for(const[id,revision]of Object.entries(world.boardVersions)){const name=`${id}.${revision}.json`;if(!BOARD_FILE.test(name))throw new Error(`Invalid board reference in shared world: ${name}`);files.push(path.join('shared-world.boards',name))} const rootNames=await fsp.readdir(dataDir);for(const file of rootNames)if(PLAYER_FILE.test(file))files.push(file); const hashes={};for(const relative of files){const source=path.join(dataDir,relative),target=path.join(stage,'data',relative);hashes[relative.replace(/\\/g,'/')]=await copyVerified(source,target,{link:relative.startsWith(`shared-world.boards${path.sep}`)})} const manifest={app:'LinkField',appVersion,createdAt:new Date().toISOString(),worldRevision:world.revision,fileCount:files.length,hashes}; await fsp.writeFile(path.join(stage,'manifest.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o600}); await fsp.rename(stage,destination); const names=(await fsp.readdir(backupRoot,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse(); for(const retired of names.slice(Math.max(1,retain)))await fsp.rm(path.join(backupRoot,retired),{recursive:true,force:true}); return{destination,name,manifest}; }catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error} } async function verifyWorldBackup(backupRootValue,nameValue){ const backupRoot=path.resolve(backupRootValue),name=safeBackupName(nameValue),directory=path.join(backupRoot,name),manifest=JSON.parse(await fsp.readFile(path.join(directory,'manifest.json'),'utf8')); if(manifest?.app!=='LinkField'||!manifest.hashes||typeof manifest.hashes!=='object')throw new Error('Invalid LinkField backup manifest'); for(const[relative,expected]of Object.entries(manifest.hashes)){if(relative.includes('..')||path.isAbsolute(relative))throw new Error('Unsafe backup path');const actual=await hashFile(path.join(directory,'data',relative));if(actual!==expected)throw new Error(`Backup checksum mismatch: ${relative}`)} return{directory,manifest,name}; } async function restoreWorldBackup(dataDirValue,backupRootValue,nameValue){ const dataDir=path.resolve(dataDirValue),verified=await verifyWorldBackup(backupRootValue,nameValue),parent=path.dirname(dataDir),stamp=Date.now(),stage=path.join(parent,`.${path.basename(dataDir)}.restore-${stamp}`),previous=path.join(parent,`${path.basename(dataDir)}.pre-restore-${stamp}`); await fsp.rm(stage,{recursive:true,force:true});await copyDirectory(path.join(verified.directory,'data'),stage); if(fs.existsSync(dataDir))await fsp.rename(dataDir,previous); try{await fsp.rename(stage,dataDir)}catch(error){if(fs.existsSync(previous)&&!fs.existsSync(dataDir))await fsp.rename(previous,dataDir);throw error} return{restored:verified.name,previous}; } async function copyDirectory(source,destination){const stat=await fsp.lstat(source);if(stat.isSymbolicLink())throw new Error('Backup contains a symbolic link');if(stat.isDirectory()){await fsp.mkdir(destination,{recursive:true,mode:0o700});for(const entry of await fsp.readdir(source))await copyDirectory(path.join(source,entry),path.join(destination,entry));return}if(stat.isFile())await copyVerified(source,destination)} module.exports=Object.freeze({backupName,createWorldBackup,verifyWorldBackup,restoreWorldBackup});