import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { absolute, verifyRelease, runtime, readSettings, withRuntimeLock } from './runtime.mjs'; const fail = message => { throw new Error(message); }; const contained = (parent, child) => child === parent || child.startsWith(parent + path.sep); const safe = value => typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..'); function hashFile(file) { const digest = crypto.createHash('sha256'), buffer = Buffer.allocUnsafe(1024 * 1024); const handle = fs.openSync(file, 'r'); try { let count; while ((count = fs.readSync(handle, buffer, 0, buffer.length, null))) digest.update(buffer.subarray(0, count)); return digest.digest('hex'); } finally { fs.closeSync(handle); } } function run(command, args, env = process.env) { try { return execFileSync(command, args, { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }); } catch (error) { fail(`${command} failed: ${error.stderr?.toString().trim() || error.message}`); } } export function runtimeInventory(directory) { const entries = []; function walk(file, relative) { if (relative !== '.' && !safe(relative)) fail(`Unsupported runtime path: ${relative}`); const stat = fs.lstatSync(file); if (!stat.isDirectory() && (!stat.isFile() || stat.nlink > 1)) fail(`Backup supports regular files/directories without links: ${relative}`); const record = { path: relative, type: stat.isDirectory() ? 'directory' : 'file', mode: stat.mode & 0o7777, uid: stat.uid, gid: stat.gid }; if (stat.isFile()) Object.assign(record, { size: stat.size, sha256: hashFile(file) }); entries.push(record); if (stat.isDirectory()) for (const child of fs.readdirSync(file).sort()) walk(path.join(file, child), relative === '.' ? child : `${relative}/${child}`); } walk(directory, '.'); return entries.sort((a, b) => a.path.localeCompare(b.path)); } function payloadFiles(directory) { return runtimeInventory(directory).filter(entry => entry.type === 'file').map(entry => entry.path).sort(); } function writeChecksums(directory) { const lines = payloadFiles(directory).filter(file => file !== 'SHA256SUMS').map(file => `${hashFile(path.join(directory, file))} ${file}\n`); fs.writeFileSync(path.join(directory, 'SHA256SUMS'), lines.join('')); } function verifyChecksums(directory) { const expected = new Map(); for (const line of fs.readFileSync(path.join(directory, 'SHA256SUMS'), 'utf8').trim().split('\n')) { const match = /^([a-f0-9]{64}) (.+)$/.exec(line); if (!match || !safe(match[2]) || match[2] === 'SHA256SUMS' || expected.has(match[2])) fail('Invalid snapshot checksum file'); expected.set(match[2], match[1]); } const actual = payloadFiles(directory).filter(file => file !== 'SHA256SUMS'); assert.deepStrictEqual([...expected.keys()].sort(), actual, 'Snapshot checksum coverage differs'); for (const [file, sha256] of expected) if (hashFile(path.join(directory, file)) !== sha256) fail(`Snapshot checksum mismatch: ${file}`); } function archivePaths(archive) { const raw = run('tar', ['-I', 'zstd', '-tf', archive]).trim().split('\n'); const paths = raw.map(entry => { if (entry === './' || entry === '.') return '.'; const normalized = entry.replace(/^\.\//, '').replace(/\/$/, ''); if (!safe(normalized)) fail('Unsafe path in backup archive'); return normalized; }); if (new Set(paths).size !== paths.length) fail('Duplicate archive entry'); const listing = run('tar', ['-I', 'zstd', '-tvf', archive]); if (listing.trim().split('\n').some(line => !['-', 'd'].includes(line[0]))) fail('Backup archive contains unsupported links/special files'); return paths.sort(); } function extract(archive, destination) { if (fs.existsSync(destination)) fail('Extraction destination already exists'); fs.mkdirSync(destination); run('tar', ['--same-permissions', '--numeric-owner', '--same-owner', '-I', 'zstd', '-xf', archive, '-C', destination]); } export function verifySnapshot(directory) { directory = absolute(directory, 'backup directory'); verifyChecksums(directory); const metadata = JSON.parse(fs.readFileSync(path.join(directory, 'metadata.json'), 'utf8')); if (metadata.schema_version !== 1 || metadata.archive?.file !== 'runtime.tar.zst' || !Array.isArray(metadata.runtime_inventory) || metadata.exclusions?.length !== 0 || metadata.stopped_confirmed !== true) fail('Unsupported backup metadata'); const archive = path.join(directory, metadata.archive.file); if (hashFile(archive) !== metadata.archive.sha256 || fs.statSync(archive).size !== metadata.archive.size) fail('Archive differs from backup metadata'); const paths = new Set(); for (const entry of metadata.runtime_inventory) { if ((entry.path !== '.' && !safe(entry.path)) || paths.has(entry.path) || !['file', 'directory'].includes(entry.type)) fail('Invalid snapshot inventory'); paths.add(entry.path); if (![entry.mode, entry.uid, entry.gid].every(value => Number.isInteger(value) && value >= 0)) fail('Invalid snapshot ownership/mode'); if (entry.type === 'file' && (!/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size < 0)) fail('Invalid snapshot file hash/size'); } assert.deepStrictEqual(archivePaths(archive), [...paths].sort(), 'Archive paths differ from runtime inventory'); const release = verifyRelease(path.join(directory, 'release')); if (release.identity !== metadata.source_release?.manifest_sha256 || hashFile(path.join(directory, 'release/SHA256SUMS')) !== metadata.source_release.checksums_sha256 || metadata.runtime_binding?.release_manifest_sha256 !== release.identity || metadata.runtime_binding.schema_version !== 1 || !/^[a-z0-9][a-z0-9_-]*$/.test(metadata.runtime_binding.project)) fail('Backup is not linked to its retained release'); return metadata; } function containers({ repository, env, lockToken }) { const text = runtime({ repository, env, lockToken, args: ['ps', '--all', '--format', 'json'], capture: true }).trim(); const list = !text ? [] : text.startsWith('[') ? JSON.parse(text) : text.split('\n').map(line => JSON.parse(line)); if (!Array.isArray(list) || list.some(item => typeof item.ID !== 'string' || !/^[a-f0-9]{12,64}$/.test(item.ID))) fail('Invalid container status'); return list.length ? JSON.parse(run('docker', ['inspect', ...list.map(item => item.ID)], env)) : []; } function summary(directory, release) { return { name: `${release.manifest.pack.name}-${release.manifest.pack.version}`, manifest_sha256: release.identity, checksums_sha256: hashFile(path.join(directory, 'SHA256SUMS')), pack: release.manifest.pack, image: release.manifest.server.image }; } function checkedLifecycle(options, args) { const status = runtime({ ...options, args }); if (status !== 0) fail(`Compose ${args[0]} failed with exit ${status}`); } function snapshotOperation({ repository, env = process.env, targetRelease, waitTimeout = 120, lockToken: heldToken }, apply) { const root = absolute(env.MC_ROOT, 'MC_ROOT'), data = absolute(path.join(root, 'runtime'), 'runtime'); const sourcePath = absolute(env.MC_RELEASE_DIR, 'MC_RELEASE_DIR'); const sourceRelease = verifyRelease(sourcePath); const target = targetRelease ? summary(absolute(targetRelease, 'target release'), verifyRelease(targetRelease)) : null; if (!Number.isInteger(waitTimeout) || waitTimeout < 1) fail('Invalid backup wait timeout'); const backups = absolute(path.join(root, 'backups'), 'backups'); if (contained(data, backups) || contained(sourcePath, backups)) fail('Backup directory overlaps runtime/release'); const stateFile = absolute(path.join(root, '.mcpack-runtime.json'), 'runtime binding'); if (!fs.existsSync(stateFile)) fail('Backup requires an initialized full-release runtime binding'); const perform = lockToken => { const lifecycle = { repository, env, lockToken }; checkedLifecycle(lifecycle, ['config', '--quiet']); const before = containers(lifecycle); if (before.length > 1 || before.some(container => container.State.Paused || container.State.Restarting || container.State.Dead)) fail('Backup requires one normally running/stopped server (or no container)'); const priorState = before.length ? before[0].State.Running ? 'running' : 'stopped' : 'absent'; if (!apply && priorState === 'running' && readSettings(absolute(env.MC_ENV_FILE ?? path.join(root, '.env'), 'MC_ENV_FILE')).MC_EULA !== 'TRUE') fail('Restoring running state requires MC_EULA=TRUE in local settings'); const id = `${new Date().toISOString().replace(/[-:.]/g, '')}-${crypto.randomUUID()}`; const operationFile = path.join(root, '.mcpack-backup-operation.json'); const operation = { schema_version: 1, id, phase: 'preflight', prior_state: priorState, source_release: sourceRelease.identity, target_release: target?.manifest_sha256 ?? null, stopped_confirmed: false, snapshot: null }; const save = phase => { operation.phase = phase; fs.writeFileSync(operationFile + '.tmp', JSON.stringify(operation, null, 2) + '\n', { mode: 0o600 }); fs.renameSync(operationFile + '.tmp', operationFile); }; let temporary, published, resuming = false; try { save('stopping'); checkedLifecycle(lifecycle, ['stop']); if (containers(lifecycle).some(container => container.State.Running || container.State.Restarting || container.State.Paused)) fail('Server did not stop completely'); operation.stopped_confirmed = true; const inventory = runtimeInventory(data); fs.mkdirSync(backups, { recursive: true }); temporary = fs.mkdtempSync(path.join(backups, `.backup-${id}-`)); save('archiving'); const archive = path.join(temporary, 'runtime.tar.zst'); run('tar', ['--format=pax', '-C', data, '-I', 'zstd', '-cf', archive, '.'], env); assert.deepStrictEqual(runtimeInventory(data), inventory, 'Runtime changed while archiving'); fs.cpSync(sourcePath, path.join(temporary, 'release'), { recursive: true }); if (verifyRelease(path.join(temporary, 'release')).identity !== sourceRelease.identity) fail('Retained release changed during copy'); const metadata = { schema_version: 1, id, created_at: new Date().toISOString(), source_root: root, prior_state: priorState, stopped_confirmed: true, source_release: summary(sourcePath, sourceRelease), target_release: target, runtime_binding: JSON.parse(fs.readFileSync(stateFile, 'utf8')), exclusions: [], archive: { file: 'runtime.tar.zst', sha256: hashFile(archive), size: fs.statSync(archive).size }, runtime_inventory: inventory }; fs.writeFileSync(path.join(temporary, 'metadata.json'), JSON.stringify(metadata, null, 2) + '\n'); save('verifying'); archivePaths(archive); const verification = path.join(temporary, '.verification'); extract(archive, verification); assert.deepStrictEqual(runtimeInventory(verification), inventory, 'Extracted snapshot differs from stopped runtime'); fs.rmSync(verification, { recursive: true }); writeChecksums(temporary); verifySnapshot(temporary); published = path.join(backups, id); if (fs.existsSync(published)) fail('Snapshot ID already exists'); fs.renameSync(temporary, published); temporary = undefined; operation.snapshot = published; save('published'); if (apply) { save('applying'); const result = apply({ snapshot: published, metadata, lockToken }); save('complete'); return result; } if (priorState === 'running') { resuming = true; save('resuming'); checkedLifecycle(lifecycle, ['up', '-d', '--wait', '--wait-timeout', String(waitTimeout)]); operation.stopped_confirmed = false; } save('complete'); return published; } catch (error) { if (resuming) { try { checkedLifecycle(lifecycle, ['stop']); operation.stopped_confirmed = !containers(lifecycle).some(container => container.State.Running); } catch { operation.stopped_confirmed = false; } } else if (apply && published) { // The apply callback may have started a server before failing. Never // report the pre-apply stopped state as the current container state. try { operation.stopped_confirmed = !containers(lifecycle).some(container => container.State.Running || container.State.Restarting || container.State.Paused); } catch { operation.stopped_confirmed = false; } } operation.error = error.message; save('failed'); throw new Error(`${error.message}. ${published ? `Verified snapshot retained: ${published}. ` : 'No snapshot published. '}${operation.stopped_confirmed ? 'Runtime remains stopped.' : 'Inspect server state before continuing.'}`); } finally { if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); } }; return heldToken ? perform(heldToken) : withRuntimeLock(root, perform); } export const backup = options => snapshotOperation(options); // Future deployment can only enter its apply phase under the same lock after // a stopped, verified snapshot. Failure never starts an older pack automatically. export function backupBeforeApply(options, apply) { if (typeof apply !== 'function' || !options.targetRelease) fail('Backup-before-apply requires a verified target release and apply callback'); return snapshotOperation(options, apply); } export function restoreSnapshot({ backupDirectory, destination }) { const snapshot = absolute(backupDirectory, 'backup directory'); const metadata = verifySnapshot(snapshot); destination = absolute(destination, 'restore destination'); if (fs.existsSync(destination)) fail('Restore destination must not exist; never extract over an existing runtime'); if (contained(snapshot, destination) || contained(path.join(metadata.source_root, 'runtime'), destination)) fail('Restore destination overlaps snapshot/source runtime'); const parent = path.dirname(destination); fs.mkdirSync(parent, { recursive: true }); const lock = path.join(parent, `.restore-${path.basename(destination)}.lock`); try { fs.mkdirSync(lock); } catch { fail('Another restore is running (or a stale restore lock remains)'); } let temporary; try { if (fs.existsSync(destination)) fail('Restore destination appeared during verification'); temporary = fs.mkdtempSync(path.join(parent, `.restore-${path.basename(destination)}-`)); extract(path.join(snapshot, 'runtime.tar.zst'), path.join(temporary, 'runtime')); assert.deepStrictEqual(runtimeInventory(path.join(temporary, 'runtime')), metadata.runtime_inventory, 'Restored runtime differs from snapshot inventory'); fs.cpSync(path.join(snapshot, 'release'), path.join(temporary, 'release'), { recursive: true }); if (verifyRelease(path.join(temporary, 'release')).identity !== metadata.source_release.manifest_sha256) fail('Restored release identity mismatch'); fs.writeFileSync(path.join(temporary, '.mcpack-runtime.json'), JSON.stringify(metadata.runtime_binding, null, 2) + '\n', { mode: 0o600 }); fs.writeFileSync(path.join(temporary, 'restore-report.json'), JSON.stringify({ schema_version: 1, backup_id: metadata.id, restored_at: new Date().toISOString(), source_release: metadata.source_release, runtime_entries_verified: metadata.runtime_inventory.length, host_settings_copied: false }, null, 2) + '\n'); if (fs.existsSync(destination)) fail('Restore destination appeared during extraction'); fs.renameSync(temporary, destination); temporary = undefined; return destination; } finally { if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); fs.rmdirSync(lock); } }