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