Lots of content
This commit is contained in:
Executable
+301
@@ -0,0 +1,301 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user