Files
2026-11-03 12:41:09 -05:00

71 lines
3.7 KiB
JavaScript
Executable File

// 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);
});
});
}