// OFFLINE ONLY. Sidecar/launcher locks are conservative signals, not proof that // an external/local/remote engine is stopped. The operator must stop all engines. // Snapshots contain private world data: keep them on an access-controlled volume. // SHA256 detects corruption, not malicious replacement; it is not a signature. import * as fs from 'node:fs/promises'; import path from 'node:path'; import { absolute, atomicDirectory, copyTree, disjoint, exists, inventory, json, safePath, validLease, verifyManifest, withLauncherLock, writeManifest } from './files.mjs'; export async function assertWorldOffline(world) { const entries = await inventory(world); if (!entries.some(entry => entry.path === 'world.mt' && entry.type === 'file')) throw new Error('Not a world: missing world.mt.'); if (entries.some(entry => /(?:^|\/)(?:lock|\.lock|world\.lock|launcher\.lock|.*\.lock)$|\.sqlite(?:3)?-(?:wal|shm|journal)$/i.test(entry.path))) { throw new Error('World/SQLite lock or sidecar exists; stop all engines and resolve it before copying.'); } return entries; } async function offlineOperation(options, action) { if (options.offline !== true) throw new Error('Explicit --offline confirmation required. Stop every external engine; locks cannot prove remote idleness.'); const runtime = absolute(options.runtime); if (validLease(options.lease, runtime)) return action(); return withLauncherLock(runtime, action); } export async function backupWorld(world, destination, options = {}) { world = absolute(world); destination = absolute(destination); disjoint(world, destination); return offlineOperation(options, async () => { const before = await assertWorldOffline(world); return atomicDirectory(destination, async stage => { await copyTree(world, path.join(stage, 'world')); const after = await assertWorldOffline(world); if (json(before) !== json(after) || json(before) !== json(await inventory(path.join(stage, 'world')))) { throw new Error('World changed during snapshot; stop all engines.'); } await fs.writeFile(path.join(stage, 'backup.json'), json({ format: 1, gameId: 'last_hearth', engineVersion: '5.18.0' }), { flag: 'wx', mode: 0o600 }); await writeManifest(stage, 'world-backup'); await verifyBackup(stage); }); }); } export async function verifyBackup(backup) { backup = await safePath(absolute(backup)); const manifest = await verifyManifest(backup, 'world-backup'); const metadata = JSON.parse(await fs.readFile(path.join(backup, 'backup.json'), 'utf8')); if (metadata.format !== 1 || metadata.gameId !== 'last_hearth' || metadata.engineVersion !== '5.18.0' || manifest.entries.some(entry => entry.path !== 'backup.json' && entry.path !== 'world' && !entry.path.startsWith('world/'))) { throw new Error('Unsupported world backup.'); } await assertWorldOffline(path.join(backup, 'world')); return manifest; } export async function restoreWorld(backup, destination, options = {}) { backup = absolute(backup); destination = absolute(destination); disjoint(backup, destination); return offlineOperation(options, async () => { await verifyBackup(backup); if (await exists(destination)) throw new Error('Restore requires a new world destination; existing worlds are never overwritten.'); return atomicDirectory(destination, async stage => { await copyTree(path.join(backup, 'world'), stage, { intoEmpty: true }); const expected = await inventory(path.join(backup, 'world')); if (json(await inventory(stage)) !== json(expected)) throw new Error('Restored world checksum mismatch.'); await verifyBackup(backup); await assertWorldOffline(stage); }); }); }