71 lines
3.7 KiB
JavaScript
Executable File
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);
|
|
});
|
|
});
|
|
}
|