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

302 lines
16 KiB
JavaScript
Executable File

// Offline, private M0 recovery evidence, NOT a public-readiness certification.
// Only copies are booted. Stop external engines first; locks/process discovery
// cannot prove that a remote machine has not opened a shared world directory.
// SHA256 is integrity evidence, not authentication or encryption. Runtime/backups
// must be on an access-controlled (preferably encrypted) volume, outside webroots.
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createHash, randomUUID } from 'node:crypto';
import dgram from 'node:dgram';
import { absolute, disjoint, ensureDirectory, inventory, json, safePath, withLauncherLock } from './files.mjs';
import { assertWorldOffline, backupWorld, restoreWorld, verifyBackup } from './backup.mjs';
import { packageGame, SOURCE, verifyPackage } from './package.mjs';
import { engineExecutable, runtimeHome, validateEngine } from './launcher.mjs';
const exec = promisify(execFile);
const digest = value => createHash('sha256').update(json(value)).digest('hex');
const same = (left, right) => {
if (json(left) !== json(right)) throw new Error('Recovery SHA256 inventory mismatch.');
};
const privateWrite = (filename, value) => fs.writeFile(filename, value, { flag: 'wx', mode: 0o600 });
class RecoveryError extends Error {
constructor(category) { super(category); this.category = category; }
}
const failure = category => new RecoveryError(category);
async function assertNoEnginesOrWriters() {
// Fail closed on process-discovery errors. Never print command lines: they can
// contain private paths, locations or credentials belonging to other apps.
if (process.platform === 'win32') {
// Image names/PIDs only; no nested shell or private command-line discovery.
// The exclusive runtime lease serializes managed Node writers, not unrelated
// Node previews. External/shared-world writers must still be stopped by owner.
const executable = path.join(absolute(process.env.SystemRoot || 'C:\\Windows'), 'System32/tasklist.exe');
const { stdout } = await exec(executable, ['/fo', 'csv', '/nh'], {
shell: false, windowsHide: true, encoding: 'utf8', timeout: 15_000, maxBuffer: 4 * 1024 * 1024,
});
let rows = 0;
for (const line of stdout.trim().split(/\r?\n/)) {
const match = /^"([^"\r\n]+)","(\d+)",/.exec(line);
if (!match) throw failure('process-discovery-invalid');
rows++;
if (/^(?:luanti|minetest)(?:-?server)?\.exe$/i.test(match[1])) throw failure('engine-process-active');
}
if (!rows) throw failure('process-discovery-empty');
} else {
const { stdout } = await exec('/bin/ps', ['-eo', 'pid=,comm=,args='], {
shell: false, encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024,
});
for (const line of stdout.split('\n')) {
const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
if (!match || Number(match[1]) === process.pid) continue;
if (/^(?:luanti|minetest)(?:-?server)?$/i.test(path.basename(match[2])) ||
(/^node(?:js)?$/i.test(path.basename(match[2])) && /scripts\/(?:run|backup|package|recovery)\.mjs/.test(match[3]))) {
throw new Error('Stop all Luanti engines and other Hearth tooling before recovery.');
}
}
}
}
function parseWorld(text) {
const settings = new Map();
for (const line of text.split(/\r?\n/)) {
if (!line.trim() || /^\s*#/.test(line)) continue;
const match = /^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (!match || settings.has(match[1])) throw new Error('Ambiguous world settings; recovery refused.');
settings.set(match[1], match[2]);
}
if (settings.get('gameid') !== 'last_hearth' ||
['backend', 'player_backend', 'auth_backend', 'mod_storage_backend'].some(key => settings.get(key) !== 'sqlite3')) {
throw new Error('Recovery requires an existing last_hearth world with local SQLite backends.');
}
return settings;
}
async function isolatedWorld(world, gameId) {
// Byte equality is checked BEFORE these copy-only changes. Do not execute old
// worldmods (including stale hearth_smoke), or inherit arbitrary world settings.
await assertWorldOffline(world);
parseWorld(await fs.readFile(path.join(world, 'world.mt'), 'utf8'));
await fs.rm(path.join(world, 'worldmods'), { recursive: true, force: true });
const mod = path.join(world, 'worldmods/hearth_recovery');
await ensureDirectory(mod);
await privateWrite(path.join(mod, 'mod.conf'), 'name = hearth_recovery\ndepends = hearth\ndescription = Private offline M0 recovery probe\n');
await fs.copyFile(await safePath(path.join(SOURCE, 'tests/recovery_probe.lua')), path.join(mod, 'probe.lua'));
await privateWrite(path.join(mod, 'init.lua'), 'dofile(core.get_modpath("hearth_recovery") .. "/probe.lua")("probe")\n');
await fs.writeFile(path.join(world, 'world.mt'), [
`gameid = ${gameId}`, 'backend = sqlite3', 'player_backend = sqlite3',
'auth_backend = sqlite3', 'mod_storage_backend = sqlite3',
'load_mod_hearth = true', 'load_mod_hearth_recovery = true', '',
].join('\n'), { mode: 0o600 });
}
async function availablePort() {
const socket = dgram.createSocket('udp4');
try {
await new Promise((resolve, reject) => {
socket.once('error', reject);
socket.bind(0, '127.0.0.1', resolve);
});
return socket.address().port;
} finally { socket.close(); }
}
async function boot(executable, stage, world, phase, token, env, gameId) {
await assertNoEnginesOrWriters();
await assertWorldOffline(world);
const config = path.join(stage, `recovery-${phase}.conf`);
await privateWrite(config, [
'hearth_environment = staging', 'hearth_latitude = 45', 'hearth_longitude = 0',
`hearth_recovery_phase = ${phase}`, `hearth_recovery_token = ${token}`,
`hearth_recovery_gameid = ${gameId}`,
'bind_address = 127.0.0.1', `port = ${await availablePort()}`, 'enable_ipv6 = false',
'max_users = 0', 'server_announce = false', 'enable_pvp = false',
'secure.enable_security = true', 'secure.trusted_mods =', 'secure.http_mods =',
'enable_mod_channels = false', 'time_speed = 0', 'dedicated_server_step = 0.05', '',
].join('\n'));
// Deliberately do not stream raw engine output. It can contain private player
// names, world positions, parser data and URLs. Keep it bounded in memory only;
// the private temporary logfile is deleted with the stage even after failures.
let result;
try {
result = await exec(executable, ['--server', '--world', world, '--gameid', gameId,
'--config', config, '--logfile', path.join(stage, `engine-${phase}.log`)], {
cwd: stage, env: { ...env, LUANTI_GAME_PATH: path.join(stage, 'games') },
shell: false, windowsHide: true, encoding: 'utf8', timeout: 90_000,
killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
});
} catch (error) {
const output = String(error.stdout || '') + '\n' + String(error.stderr || '');
throw failure(`${phase}-${probeFailure(output) || (error.killed ? 'engine-timeout' :
error.code === 'ENOENT' ? 'engine-not-found' : 'engine-exit')}`);
}
const output = result.stdout + '\n' + result.stderr;
const marker = `LAST_HEARTH_RECOVERY_PASS ${phase} ${token}`;
if (output.split(marker).length !== 2 || output.includes('LAST_HEARTH_RECOVERY_FAIL')) {
throw failure(`${phase}-${probeFailure(output) || 'shutdown-marker-missing'}`);
}
await assertWorldOffline(world);
const summary = JSON.parse(await fs.readFile(path.join(world, `m0-recovery-${phase}.json`), 'utf8'));
if (summary.format !== 1 || summary.phase !== phase || summary.token !== token ||
!Number.isSafeInteger(summary.existingKeys) || summary.existingKeys < 1 || summary.existingKeys > 4 ||
!Number.isSafeInteger(summary.legacyKeys) || summary.legacyKeys < 1 || summary.legacyKeys > 4 ||
!Number.isSafeInteger(summary.claimCount) || summary.claimCount < 0 ||
summary.noClients !== true || summary.fixture !== true || summary.spawn !== true ||
summary.claims !== true || summary.homes !== true) {
throw new Error('Recovery probe summary is incomplete.');
}
if (!Number.isSafeInteger(summary.verifiedAssertions) || summary.verifiedAssertions < 1) throw failure(`${phase}-assertion-count-missing`);
return summary;
}
function probeFailure(output) {
// Only accept a static assertion/checkpoint label, never parser strings,
// filenames, records, or arbitrary engine errors as a diagnostic label.
const match = /LAST_HEARTH_RECOVERY_FAIL (checkpoint-[a-z-]{1,96})(?=\s|$)/.exec(output);
return match ? `probe-${match[1]}` : undefined;
}
export async function recover({ env = process.env } = {}) {
let step = 'runtime-preflight';
try {
const runtime = await safePath(runtimeHome(env));
// HEARTH_HOME changes tooling storage, never which original world is selected.
if (!env.HEARTH_RECOVERY_SOURCE && !env.LOCALAPPDATA) {
throw new Error('Set HEARTH_RECOVERY_SOURCE or LOCALAPPDATA.');
}
const source = await safePath(absolute(env.HEARTH_RECOVERY_SOURCE ||
path.join(absolute(env.LOCALAPPDATA), 'last-hearth-runtime/worlds/last_hearth')));
const executable = engineExecutable(env, process.platform, runtime);
step = 'process-discovery';
await assertNoEnginesOrWriters();
step = 'engine-validation';
const engineVersion = await validateEngine(executable);
// Refuse to run against partially wired code. The Lua probe also checks the
// actual loaded API: this textual gate alone is not evidence of integration.
const init = await fs.readFile(path.join(SOURCE, 'mods/hearth/init.lua'), 'utf8');
if (!/hearth\.persistence\s*=/.test(init)) throw new Error('Recovery deferred: init must wire hearth.persistence first.');
step = 'runtime-lock';
return await withLauncherLock(runtime, async lease => {
step = 'source-offline-inventory';
await assertNoEnginesOrWriters();
const original = await assertWorldOffline(source);
parseWorld(await fs.readFile(path.join(source, 'world.mt'), 'utf8'));
for (const name of ['m0-recovery-expected.json', 'm0-recovery-fixture.json']) {
if (original.some(entry => entry.path === name)) throw new Error('Source already contains recovery instrumentation.');
}
const backups = path.join(runtime, 'backups');
disjoint(source, backups);
await ensureDirectory(backups);
const token = randomUUID();
// Installed games precede LUANTI_GAME_PATH in Luanti's search order.
// A unique disposable ID prevents an old installed game shadowing this
// verified package, without touching the installation or original world.
const gameId = `last_hearth_recovery_${token.replaceAll('-', '')}`;
const retained = path.join(backups, `m0-validation-${new Date().toISOString().replace(/[:.]/g, '-')}-${token}`);
const stage = await fs.mkdtemp(path.join(runtime, 'recovery-'));
await fs.chmod(stage, 0o700);
disjoint(source, stage);
const options = { offline: true, runtime, lease };
try {
step = 'original-backup';
// Retain the untouched original snapshot, even if a later boot fails.
await backupWorld(source, retained, options);
await verifyBackup(retained);
same(original, await inventory(path.join(retained, 'world')));
const first = path.join(stage, 'restored-first');
step = 'first-restore';
await verifyBackup(retained); // Supplied backup SHA256, BEFORE restore.
await restoreWorld(retained, first, options);
same(original, await inventory(first)); // Includes original player DB bytes.
await ensureDirectory(path.join(stage, 'games'));
const game = path.join(stage, 'games', gameId);
const inputs = await inventory(path.join(SOURCE, 'mods'));
step = 'game-package';
await packageGame(game);
same(inputs, await inventory(path.join(SOURCE, 'mods')));
const packaged = await verifyPackage(game);
// Instrument only the disposable package, before its real hearth init.
// This reads the engine's own ModStorageRef in the hearth mod context,
// capturing exact pre-migration bytes without Node SQLite or global mocks.
const gameInit = path.join(game, 'mods/hearth/init.lua');
await fs.writeFile(gameInit,
'dofile(core.get_worldpath() .. "/worldmods/hearth_recovery/probe.lua")("capture")\n' +
await fs.readFile(gameInit, 'utf8'), { mode: 0o600 });
await isolatedWorld(first, gameId);
step = 'seed-boot';
const seeded = await boot(executable, stage, first, 'seed', token, env, gameId);
step = 'second-backup-restore';
const afterFirst = await assertWorldOffline(first);
const secondBackup = path.join(stage, 'after-first-backup');
await backupWorld(first, secondBackup, options);
await verifyBackup(secondBackup);
same(afterFirst, await inventory(path.join(secondBackup, 'world')));
const second = path.join(stage, 'restored-second');
await restoreWorld(secondBackup, second, options);
same(afterFirst, await inventory(second));
step = 'restart-boot';
const restarted = await boot(executable, stage, second, 'restart', token, env, gameId);
if (seeded.existingKeys !== restarted.existingKeys || seeded.legacyKeys !== restarted.legacyKeys ||
seeded.claimCount !== restarted.claimCount) {
throw new Error('Restart migration evidence mismatch.');
}
step = 'final-integrity-verification';
await assertNoEnginesOrWriters();
same(original, await assertWorldOffline(source));
await verifyBackup(retained);
same(original, await inventory(path.join(retained, 'world')));
// No raw records, player names, filesystem paths, coordinates or URLs in
// the retained report. Credentials are neither generated nor required.
const report = {
format: 1, milestone: 'M2', publicReady: false, engineVersion,
sourceUnchanged: true, backupVerified: true, restoresByteExact: 2,
successfulEngineShutdowns: 2, noClients: true,
verifiedAssertions: { seed: seeded.verifiedAssertions, restart: restarted.verifiedAssertions,
total: seeded.verifiedAssertions + restarted.verifiedAssertions },
existingKeys: seeded.existingKeys, legacyKeys: seeded.legacyKeys, claimCount: seeded.claimCount,
migratedRecordsPreserved: true, immutableLegacyBackups: true,
savedSpawnVerified: true, claimsProtectionVerified: true, homesVerified: true,
playerAdapterFixture: true, nodeMetadataInventoryRestored: true, absoluteFireExpiry: true,
sourceFiles: original.filter(entry => entry.type === 'file').length,
sourceInventorySha256: digest(original),
packageFiles: packaged.entries.filter(entry => entry.type === 'file').length,
packageInventorySha256: digest(packaged.entries),
firstBootInventorySha256: digest(afterFirst),
};
// Never add files to the immutable snapshot, even transiently: interruption
// while publishing a report must not invalidate its exact SHA256 manifest.
step = 'report-publication';
await privateWrite(retained + '-report.json', json(report));
return report;
} finally {
// Never delete source, retained backup, or arbitrary shared paths. An
// engine child is synchronously reaped before this private stage is removed.
try { same(original, await assertWorldOffline(source)); }
finally {
await safePath(stage);
await fs.rm(stage, { recursive: true, force: true });
}
}
});
} catch (error) {
if (error instanceof RecoveryError) throw error;
const code = ['EEXIST', 'ENOENT', 'EACCES', 'EPERM', 'EBUSY', 'ETIMEDOUT'].includes(error.code) ? `-${error.code}` : '';
throw failure(step + code);
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
if (process.argv.length !== 2) throw new Error('Recovery accepts no arguments; use HEARTH_RECOVERY_SOURCE.');
const report = await recover();
process.stdout.write(json(report));
} catch (error) {
// Error objects/engine stderr can carry source paths and original save data.
process.stderr.write(`LAST_HEARTH_RECOVERY_FAIL: ${error instanceof RecoveryError ? error.category : 'invalid-invocation'}; original world was never launched.\n`);
process.exitCode = 1;
}
}