From 33952587a3c933dbf1568efd68487b61e126357c Mon Sep 17 00:00:00 2001 From: Alexey Balchunas Date: Sun, 11 Oct 2026 06:57:45 +0700 Subject: [PATCH] Add safe Tiny Block world checkpoint tooling --- SELF_HOSTING.md | 17 +- docs/operations/world-backups.md | 126 +++++ tests/world_backup_restore_verifier.gd | 105 +++++ tests/world_backup_restore_verifier.gd.uid | 1 + tests/world_backup_restore_verifier.tscn | 6 + tools/tests/test_tinyblock_world_backup.py | 264 +++++++++++ tools/tinyblock_world_backup.py | 520 +++++++++++++++++++++ 7 files changed, 1033 insertions(+), 6 deletions(-) create mode 100644 docs/operations/world-backups.md create mode 100644 tests/world_backup_restore_verifier.gd create mode 100644 tests/world_backup_restore_verifier.gd.uid create mode 100644 tests/world_backup_restore_verifier.tscn create mode 100644 tools/tests/test_tinyblock_world_backup.py create mode 100644 tools/tinyblock_world_backup.py diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 869c3d9..6a494c3 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -83,15 +83,20 @@ World saves are stored at: ### Back up your worlds -```bash -tar czf tinyblock-backup-$(date +%Y%m%d).tar.gz ~/.local/share/godot/app_userdata/Tiny\ Block\ Server/ -``` +Do not archive the entire Godot application-data directory while the server is +running: it can contain unrelated state, and a plain tar is neither an atomic +checkpoint nor encrypted. For the four production community worlds, use the +pinned-file-descriptor capture, age encryption, and offline restore check in +[`docs/operations/world-backups.md`](docs/operations/world-backups.md). For a +standalone self-hosted server, stop it first and copy only the selected +`world_.json` save; protect the archive and test a restore before relying on +it. ### Restore a world -1. Stop the server. -2. Copy the saved world file matching the `--world` ID into the data directory. -3. Restart the server. +1. Stop the server before a manual restore. +2. Verify the backup and copy only the saved world file matching the `--world` ID into the data directory. +3. Confirm the configured `--world-mode` matches the save, then restart the server. ## Update diff --git a/docs/operations/world-backups.md b/docs/operations/world-backups.md new file mode 100644 index 0000000..8518878 --- /dev/null +++ b/docs/operations/world-backups.md @@ -0,0 +1,126 @@ +# Tiny Block community world backups + +## Scope + +The dedicated Bare Metal host has four fresh public community worlds. Their +primary save files are independent JSON documents: + +| World | ID | Mode | Host save path below `/opt/tinyblock/fresh/` | +|---|---|---|---| +| Pandora | `world_pandora` | `floating_islands` | `home-fresh-floating-islands/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_pandora.json` | +| B-612 | `world_b612` | `one_block` | `home-fresh-one-block/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_b612.json` | +| Wonderland | `world_wonderland` | `procedural` | `home-fresh-procedural/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_wonderland.json` | +| Skyloft | `world_skyloft` | `skyblock` | `home-fresh-skyblock/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_skyloft.json` | + +This contract covers only those four primary world saves. It intentionally does +not include the Tiny Block bot's achievement/structural state, Godot session +files, tokens, or any product database. Bot files can be rewritten in place and +need their own backup contract. Never include `backend_session.json` in a world +archive. + +## Consistency and freshness + +`WorldStore.save_world` and `save_repaired_world` serialize to `.json.tmp`, +flush and close that file, then publish a new primary by removing and renaming +files. Normal saves also rotate `.bak` by deleting the previous backup and +copying the primary. The backup copy is not a safe source during that rotation. +There are no other in-repository writers to these world primary paths. + +The capture tool opens each exact primary using `O_RDONLY|O_NOFOLLOW`, pins the +opened inode, verifies it is a regular file, reads from that descriptor, checks +the descriptor metadata again, and validates JSON schema version, exact ID, and +mode before emitting any archive bytes. Linux keeps the opened inode readable +after the server unlinks/replaces the pathname. If the brief remove-to-rename +gap makes open return `ENOENT`, the tool retries a bounded number of times; it +never reads `.tmp` or silently substitutes `.bak`. Symlinks, non-regular files, +duplicate JSON keys, corrupt JSON, changed-in-place descriptor size/mtime, and +mismatched world metadata fail the whole capture. `ctime` is recorded but is +not compared because the producer's unlink-before-rename changes the old +inode's ctime even though its already-open contents remain stable. + +Each world is a separate checkpoint taken at its own open time. The four saves +are **not** one globally atomic snapshot, and the manifest records per-world +capture start/completion times and SHA-256. During normal operation, the game +server autosaves dirty worlds with a configured maximum interval of 60 seconds +for ordinary worlds and 120 seconds for worlds at or above 50,000 tiles (with +an idle delay as well). Therefore a capture represents the most recently +published primary observed for each world; it can omit recent unsaved play, +normally bounded by that autosave maximum. This is an operational expectation, +not a hard durability SLA: the current Godot save path flushes the temp file but +does not explicitly fsync the file and containing directory. + +## Capture and encryption + +Run `tools/tinyblock_world_backup.py capture` on the host that owns the save +paths, and stream its tar output directly through the existing `age` recipient +to a private local artifact. Do not create a plaintext tar on disk or pipe its +contents to a terminal. Example from a Mac that has the already-authorized SSH +alias and protected age recipient file: + +```sh +set -o pipefail +artifact_dir="$HOME/.config/hardtab/recovery/tinyblock-worlds" +mkdir -p "$artifact_dir" +chmod 700 "$artifact_dir" +partial="$artifact_dir/tinyblock-worlds-$(date -u +%Y%m%dT%H%M%SZ).tar.age.part" +final="${partial%.part}" +if ssh production-baremetal 'python3 - capture' \ + < tools/tinyblock_world_backup.py \ + | age -R "$HOME/.config/hardtab/recovery/sc-sp-20261010/recipient.txt" -o "$partial"; then + chmod 600 "$partial" + mv "$partial" "$final" +else + rm -f "$partial" + exit 1 +fi +``` + +The script prints only a file-count summary to stderr. The stdout tar stream is +encrypted by `age`; the archive contains four primary files and a manifest with +IDs, modes, file metadata, independent capture timestamps, and hashes. Protect +the age identity as a private credential; never place it in the repository or +the archive. The protected Mac recovery folder is off-host relative to Bare +Metal, so this copy survives loss of the game host. A second cloud copy through +the existing approved Yandex recovery workflow, plus a recurring schedule and +retention policy, are still pending. That workflow's credentials are available +only to GitHub Actions; do not copy them to a Mac or create a new bucket/key. + +For a durable verification record, run `verify` after extracting and restoring +the capture. Pass `--artifact` with the encrypted `.age` file; the private JSON +receipt then records the capture ID and time window, each world's source time +and hash, the offline restore result, and the encrypted artifact's filename, +size, and SHA-256. Keep the receipt beside the ciphertext; it contains metadata +only, not world contents or keys. + +## Offline restore verification + +Do not restore over a running world. Decrypt only into a new mode-0700 scratch +directory, validate the archive allowlist and hashes, then run the dedicated +Godot headless verifier against those copied JSON files. The verifier calls the +real `WorldSim.deserialize_state` for all four worlds, checks the restored ID, +mode, non-empty tile set, and SHA-256 of the exact input bytes; the Python +verifier rejects a Godot receipt whose hashes do not match the archive +manifest. It does not instantiate `ServerMain`, start multiplayer, or call the +backend client. Use a fresh isolated `HOME`/Godot user data directory. Keep the +resulting verification receipt beside the encrypted artifact; delete the +plaintext extraction scratch after validation. + +Restore evidence proves the saved documents can be deserialized by this exact +Tiny Block source revision in isolation. It does not prove live community +joinability or backend health. Before a real restore/cutover, validate backup +age, source/target IDs and modes, hashes, and a complete restore test, then use +the normal reviewed production release/runbook to switch a world. + +## Local tests + +```sh +python3 -m unittest discover -s tools/tests -v +godot --headless --path . --scene res://tests/server_test_runner.tscn +``` + +Capture-contract tests cover inode pinning across replacement, bounded retry in +the unlink/rename gap, rejection of symlinks and temp/backup fallbacks, schema/ +ID/mode validation, and the exact archive allowlist. Synthetic fixtures test +the capture implementation only; they are not evidence that the current four +world saves restore. The separate offline Godot receipt must be generated from +the actual decrypted production capture for that claim. diff --git a/tests/world_backup_restore_verifier.gd b/tests/world_backup_restore_verifier.gd new file mode 100644 index 0000000..d2519c6 --- /dev/null +++ b/tests/world_backup_restore_verifier.gd @@ -0,0 +1,105 @@ +extends Node + +const WorldSim = preload("res://gameplay/scripts/world.gd") +const EXPECTED_WORLDS := { + "world_pandora": "floating_islands", + "world_b612": "one_block", + "world_wonderland": "procedural", + "world_skyloft": "skyblock", +} + + +func _ready() -> void: + _run_verification() + + +func _run_verification() -> void: + var arguments := OS.get_cmdline_user_args() + var world_directory := _argument_value(arguments, "--world-dir") + var receipt_path := _argument_value(arguments, "--receipt") + if world_directory.is_empty() or receipt_path.is_empty(): + push_error("Usage: -- --world-dir= --receipt=") + get_tree().quit(2) + return + + var results: Array[Dictionary] = [] + var failed := false + for world_id: String in EXPECTED_WORLDS: + var path := world_directory.path_join("%s.json" % world_id) + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_error("Unable to open offline checkpoint for %s" % world_id) + failed = true + continue + var parsed := JSON.new() + var parse_error := parsed.parse(file.get_as_text()) + file.close() + if parse_error != OK or not parsed.data is Dictionary: + push_error("Invalid JSON checkpoint for %s" % world_id) + failed = true + continue + var state: Dictionary = parsed.data + var generation: Dictionary = state.get("generation", {}) if state.get("generation", {}) is Dictionary else {} + if int(state.get("schema_version", -1)) != 1: + push_error("Unsupported save schema for %s" % world_id) + failed = true + continue + if str(state.get("world_id", "")) != world_id: + push_error("World ID mismatch for %s" % world_id) + failed = true + continue + var expected_mode: String = EXPECTED_WORLDS[world_id] + if str(generation.get("mode", "")) != expected_mode: + push_error("World mode mismatch for %s" % world_id) + failed = true + continue + + # Only construct the simulation and deserialize saved state. This harness + # never creates ServerMain, starts multiplayer, or calls BackendClient. + var simulation = WorldSim.new() + var restored: bool = simulation.deserialize_state(state) + var tile_count := int(simulation.tiles.size()) if restored else 0 + var restored_id := str(simulation.world_id) if restored else "" + var restored_mode := str(simulation.world_mode) if restored else "" + if not restored or restored_id != world_id or restored_mode != expected_mode or tile_count <= 0: + push_error("WorldSim.deserialize_state did not restore %s from this checkpoint" % world_id) + failed = true + continue + results.append({ + "world_id": world_id, + "mode": expected_mode, + "schema_version": 1, + "sha256": FileAccess.get_sha256(path), + "deserialize_state_ok": true, + "tile_count": tile_count, + }) + print("OFFLINE_WORLD_RESTORE_OK id=%s mode=%s tiles=%d" % [world_id, expected_mode, tile_count]) + + if failed or results.size() != EXPECTED_WORLDS.size(): + get_tree().quit(1) + return + var receipt := { + "format": "tinyblock-offline-restore-verification", + "validator_version": 1, + "godot_version": Engine.get_version_info().get("string", "unknown"), + "verified_at_unix": Time.get_unix_time_from_system(), + "network_calls_requested": 0, + "server_started": false, + "worlds": results, + } + var receipt_file := FileAccess.open(receipt_path, FileAccess.WRITE) + if receipt_file == null: + push_error("Unable to write private offline restore receipt") + get_tree().quit(2) + return + receipt_file.store_string(JSON.stringify(receipt, "\t")) + receipt_file.flush() + receipt_file.close() + get_tree().quit(0) + + +func _argument_value(arguments: PackedStringArray, name: String) -> String: + for argument: String in arguments: + if argument.begins_with("%s=" % name): + return argument.trim_prefix("%s=" % name) + return "" diff --git a/tests/world_backup_restore_verifier.gd.uid b/tests/world_backup_restore_verifier.gd.uid new file mode 100644 index 0000000..4bad5c0 --- /dev/null +++ b/tests/world_backup_restore_verifier.gd.uid @@ -0,0 +1 @@ +uid://5hetdts08fpo diff --git a/tests/world_backup_restore_verifier.tscn b/tests/world_backup_restore_verifier.tscn new file mode 100644 index 0000000..9db2494 --- /dev/null +++ b/tests/world_backup_restore_verifier.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/world_backup_restore_verifier.gd" id="1"] + +[node name="WorldBackupRestoreVerifier" type="Node"] +script = ExtResource("1") diff --git a/tools/tests/test_tinyblock_world_backup.py b/tools/tests/test_tinyblock_world_backup.py new file mode 100644 index 0000000..bfe4fda --- /dev/null +++ b/tools/tests/test_tinyblock_world_backup.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import tinyblock_world_backup as backup + + +def _state(spec: backup.WorldSpec, marker: str = "old") -> bytes: + return json.dumps( + { + "schema_version": 1, + "world_id": spec.world_id, + "generation": {"mode": spec.mode}, + "tiles": [{"x": 1, "y": 2, "content_id": "core:stone", "marker": marker}], + "inventory": {}, + "generated_definitions": [], + "fluids": [], + "tree_growth": [], + "plant_growth": [], + }, + separators=(",", ":"), + ).encode() + + +class WorldBackupCaptureTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(dir="/private/tmp") + # macOS exposes /tmp through /var, which is itself a symlink. The + # production opener intentionally rejects symlink path components, so + # give it the canonical physical temp-root path. + self.root = Path(os.path.realpath(Path(self.temp.name) / "root")) + self.root.mkdir() + for spec in backup.WORLD_SPECS: + source = self.root / spec.relative_path + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(_state(spec)) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_capture_pins_original_inode_across_primary_replacement(self) -> None: + spec = backup.WORLD_SPECS[0] + source = self.root / spec.relative_path + replacement = source.with_name("replacement.json") + replacement.write_bytes(_state(spec, "new")) + swapped = False + + def replace_after_open(path: str, _descriptor: int) -> None: + nonlocal swapped + os.replace(replacement, path) + swapped = True + + captured, record = backup.capture_one( + str(self.root), spec, after_open=replace_after_open, sleep=lambda _seconds: None + ) + self.assertTrue(swapped) + self.assertEqual(json.loads(captured)["tiles"][0]["marker"], "old") + self.assertEqual(json.loads(source.read_bytes())["tiles"][0]["marker"], "new") + self.assertEqual(record["sha256"], hashlib.sha256(captured).hexdigest()) + + def test_capture_never_falls_back_to_tmp_or_bak(self) -> None: + spec = backup.WORLD_SPECS[0] + source = self.root / spec.relative_path + source.unlink() + source.with_suffix(".json.tmp").write_bytes(_state(spec)) + source.with_suffix(".json.bak").write_bytes(_state(spec)) + sleeps: list[float] = [] + with self.assertRaisesRegex(backup.BackupError, "primary save stayed absent"): + backup.capture_one(str(self.root), spec, attempts=3, retry_seconds=0.1, sleep=sleeps.append) + self.assertEqual(sleeps, [0.1, 0.1]) + + def test_missing_primary_retries_and_captures_when_rename_finishes(self) -> None: + spec = backup.WORLD_SPECS[0] + source = self.root / spec.relative_path + source.unlink() + attempts = 0 + + def finish_rename(_seconds: float) -> None: + nonlocal attempts + attempts += 1 + if attempts == 2: + source.write_bytes(_state(spec)) + + captured, _record = backup.capture_one( + str(self.root), spec, attempts=4, retry_seconds=0.01, sleep=finish_rename + ) + self.assertEqual(json.loads(captured)["world_id"], spec.world_id) + self.assertEqual(attempts, 2) + + def test_symlink_primary_is_rejected(self) -> None: + spec = backup.WORLD_SPECS[0] + source = self.root / spec.relative_path + target = source.with_name("target.json") + target.write_bytes(_state(spec)) + source.unlink() + source.symlink_to(target) + with self.assertRaises((OSError, backup.BackupError)): + backup.capture_one(str(self.root), spec, sleep=lambda _seconds: None) + + def test_symlink_parent_is_rejected(self) -> None: + spec = backup.WORLD_SPECS[0] + home = self.root / "home-fresh-floating-islands" + real_home = self.root / "real-home" + home.rename(real_home) + home.symlink_to(real_home, target_is_directory=True) + with self.assertRaises((OSError, backup.BackupError)): + backup.capture_one(str(self.root), spec, sleep=lambda _seconds: None) + + def test_schema_id_mode_and_duplicate_keys_fail_closed(self) -> None: + spec = backup.WORLD_SPECS[0] + source = self.root / spec.relative_path + invalids = [ + b'{"schema_version":true}', + json.dumps({"schema_version": 2, "world_id": spec.world_id, "generation": {"mode": spec.mode}}).encode(), + json.dumps({"schema_version": 1, "world_id": "world_wrong", "generation": {"mode": spec.mode}}).encode(), + json.dumps({"schema_version": 1, "world_id": spec.world_id, "generation": {"mode": "skyblock"}}).encode(), + b'{"schema_version":1,"schema_version":1}', + ] + for raw in invalids: + with self.subTest(raw=raw[:50]): + source.write_bytes(raw) + with self.assertRaises(backup.BackupError): + backup.capture_one(str(self.root), spec, sleep=lambda _seconds: None) + + def test_archive_contains_only_four_primary_worlds_and_manifest(self) -> None: + payload, manifest = backup.capture_payload(str(self.root), sleep=lambda _seconds: None) + self.assertEqual({name for name, _raw in payload}, backup._allowed_archive_names()) + self.assertEqual(manifest["world_count"], 4) + self.assertEqual(manifest["checkpoint_consistency"], "four independent per-world checkpoints; not a single global instant") + for name, raw in payload: + if name.endswith(".json"): + self.assertNotIn(b"achievement_state", raw) + self.assertNotIn(b"backend_session", raw) + + def test_verify_requires_a_valid_godot_restore_receipt_when_requested(self) -> None: + payload, _manifest = backup.capture_payload(str(self.root), sleep=lambda _seconds: None) + directory = Path(self.temp.name) / "extracted" + directory.mkdir(mode=0o700) + archive_path = Path(self.temp.name) / "checkpoint.tar" + with archive_path.open("wb") as archive_file: + backup._write_archive(payload, archive_file) + backup.extract_archive(str(archive_path), directory) + receipt_path = Path(self.temp.name) / "godot.json" + receipt_path.write_text(json.dumps({"format": "not-valid"})) + with self.assertRaisesRegex(backup.BackupError, "unsupported Godot restore receipt"): + backup.verify_directory(directory, receipt_path) + + def test_verify_accepts_exact_four_world_restore_receipt(self) -> None: + payload, _manifest = backup.capture_payload(str(self.root), sleep=lambda _seconds: None) + directory = Path(self.temp.name) / "valid-extracted" + directory.mkdir(mode=0o700) + archive_path = Path(self.temp.name) / "valid-checkpoint.tar" + with archive_path.open("wb") as archive_file: + backup._write_archive(payload, archive_file) + backup.extract_archive(str(archive_path), directory) + godot_receipt_path = Path(self.temp.name) / "valid-godot.json" + godot_receipt_path.write_text( + json.dumps( + { + "format": "tinyblock-offline-restore-verification", + "validator_version": 1, + "godot_version": "test", + "worlds": [ + { + "world_id": spec.world_id, + "mode": spec.mode, + "schema_version": 1, + "sha256": hashlib.sha256(_state(spec)).hexdigest(), + "deserialize_state_ok": True, + "tile_count": 1, + } + for spec in backup.WORLD_SPECS + ], + } + ) + ) + receipt = backup.verify_directory(directory, godot_receipt_path) + self.assertEqual(len(receipt["worlds"]), 4) + self.assertTrue(all(world["godot_offline_restore"] for world in receipt["worlds"])) + self.assertEqual(receipt["godot_version"], "test") + + def test_verify_rejects_godot_receipt_for_different_checkpoint_bytes(self) -> None: + payload, _manifest = backup.capture_payload(str(self.root), sleep=lambda _seconds: None) + directory = Path(self.temp.name) / "mismatched-godot-extracted" + directory.mkdir(mode=0o700) + archive_path = Path(self.temp.name) / "mismatched-godot-checkpoint.tar" + with archive_path.open("wb") as archive_file: + backup._write_archive(payload, archive_file) + backup.extract_archive(str(archive_path), directory) + godot_receipt_path = Path(self.temp.name) / "mismatched-godot.json" + godot_receipt_path.write_text( + json.dumps( + { + "format": "tinyblock-offline-restore-verification", + "validator_version": 1, + "godot_version": "test", + "worlds": [ + { + "world_id": spec.world_id, + "mode": spec.mode, + "schema_version": 1, + "sha256": "0" * 64, + "deserialize_state_ok": True, + "tile_count": 1, + } + for spec in backup.WORLD_SPECS + ], + } + ) + ) + with self.assertRaisesRegex(backup.BackupError, "verified different checkpoint bytes"): + backup.verify_directory(directory, godot_receipt_path) + + def test_verify_records_encrypted_artifact_hash_without_reading_plaintext(self) -> None: + payload, _manifest = backup.capture_payload(str(self.root), sleep=lambda _seconds: None) + directory = Path(self.temp.name) / "artifact-extracted" + directory.mkdir(mode=0o700) + archive_path = Path(self.temp.name) / "artifact-checkpoint.tar" + with archive_path.open("wb") as archive_file: + backup._write_archive(payload, archive_file) + backup.extract_archive(str(archive_path), directory) + encrypted_artifact = Path(self.temp.name) / "checkpoint.tar.age" + cipher_bytes = b"opaque age ciphertext\x00\xff" + encrypted_artifact.write_bytes(cipher_bytes) + + receipt = backup.verify_directory(directory, encrypted_artifact_path=encrypted_artifact) + self.assertEqual( + receipt["encrypted_artifact"], + { + "file_name": encrypted_artifact.name, + "size_bytes": len(cipher_bytes), + "sha256": hashlib.sha256(cipher_bytes).hexdigest(), + "encryption": "age", + }, + ) + manifest = json.loads((directory / "manifest.json").read_text()) + self.assertEqual(receipt["capture_started_at_utc"], manifest["capture_started_at_utc"]) + + def test_verify_rejects_symlinked_encrypted_artifact(self) -> None: + payload, _manifest = backup.capture_payload(str(self.root), sleep=lambda _seconds: None) + directory = Path(self.temp.name) / "symlink-artifact-extracted" + directory.mkdir(mode=0o700) + archive_path = Path(self.temp.name) / "symlink-artifact-checkpoint.tar" + with archive_path.open("wb") as archive_file: + backup._write_archive(payload, archive_file) + backup.extract_archive(str(archive_path), directory) + target = Path(self.temp.name) / "target.age" + target.write_bytes(b"ciphertext") + artifact_link = Path(self.temp.name) / "linked.age" + artifact_link.symlink_to(target) + with self.assertRaises((OSError, backup.BackupError)): + backup.verify_directory(directory, encrypted_artifact_path=artifact_link) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tinyblock_world_backup.py b/tools/tinyblock_world_backup.py new file mode 100644 index 0000000..4b5d9c7 --- /dev/null +++ b/tools/tinyblock_world_backup.py @@ -0,0 +1,520 @@ +#!/usr/bin/env python3 +"""Capture and verify Tiny Block community world checkpoints. + +The capture command is intended to run on the host that owns the save files and +write a tar stream to stdout. Keep that stream encrypted in transit/storage. +Only the four primary JSON saves are read; backups, temporary files, bot state, +and credentials are deliberately outside this contract. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import secrets +import stat +import sys +import tarfile +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Iterable + + +FORMAT_VERSION = 1 +DEFAULT_ROOT_PREFIX = "/opt/tinyblock/fresh" +MAX_WORLD_BYTES = 128 * 1024 * 1024 +OPEN_RETRIES = 8 +OPEN_RETRY_SECONDS = 0.2 +CHUNK_SIZE = 1024 * 1024 + + +@dataclass(frozen=True) +class WorldSpec: + label: str + world_id: str + mode: str + relative_path: str + + @property + def archive_path(self) -> str: + return f"{self.world_id}.json" + + +WORLD_SPECS = ( + WorldSpec( + "pandora", + "world_pandora", + "floating_islands", + "home-fresh-floating-islands/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_pandora.json", + ), + WorldSpec( + "b612", + "world_b612", + "one_block", + "home-fresh-one-block/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_b612.json", + ), + WorldSpec( + "wonderland", + "world_wonderland", + "procedural", + "home-fresh-procedural/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_wonderland.json", + ), + WorldSpec( + "skyloft", + "world_skyloft", + "skyblock", + "home-fresh-skyblock/.local/share/godot/app_userdata/Tiny Block Server/worlds/world_skyloft.json", + ), +) + + +class BackupError(RuntimeError): + """Raised when a checkpoint is unsafe or does not match its contract.""" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise BackupError(f"duplicate JSON object key: {key}") + result[key] = value + return result + + +def _parse_json(raw: bytes, spec: WorldSpec) -> dict[str, object]: + try: + state = json.loads(raw, object_pairs_hook=_reject_duplicate_keys) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc: + raise BackupError(f"{spec.world_id}: invalid JSON: {exc}") from exc + if not isinstance(state, dict): + raise BackupError(f"{spec.world_id}: world root must be an object") + # bool is an int subclass in Python, so check the exact type. + if type(state.get("schema_version")) is not int or state["schema_version"] != FORMAT_VERSION: + raise BackupError(f"{spec.world_id}: unsupported save schema") + if state.get("world_id") != spec.world_id: + raise BackupError(f"{spec.world_id}: save has a different world_id") + generation = state.get("generation") + if not isinstance(generation, dict) or generation.get("mode") != spec.mode: + raise BackupError(f"{spec.world_id}: save has a different world mode") + required_types = { + "tiles": list, + "inventory": dict, + "generated_definitions": list, + "fluids": list, + "tree_growth": list, + "plant_growth": list, + } + for name, expected_type in required_types.items(): + if not isinstance(state.get(name), expected_type): + raise BackupError(f"{spec.world_id}: invalid {name} field") + return state + + +def _open_absolute_regular_no_follow(path: str) -> int: + """Open an absolute path without following any symlink component.""" + if not os.path.isabs(path): + raise BackupError("source path must be absolute") + parts = Path(path).parts + if len(parts) < 2 or any(part in ("", ".", "..") for part in parts[1:]): + raise BackupError("source path contains an unsafe component") + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + directory_fd = os.open("/", directory_flags) + try: + for component in parts[1:-1]: + next_fd = os.open(component, directory_flags, dir_fd=directory_fd) + os.close(directory_fd) + directory_fd = next_fd + return os.open(parts[-1], file_flags, dir_fd=directory_fd) + finally: + os.close(directory_fd) + + +def _same_open_file_state(before: os.stat_result, after: os.stat_result, byte_count: int) -> bool: + return ( + before.st_dev == after.st_dev + and before.st_ino == after.st_ino + and stat.S_ISREG(after.st_mode) + and before.st_size == after.st_size == byte_count + and before.st_mtime_ns == after.st_mtime_ns + ) + + +def capture_one( + root_prefix: str, + spec: WorldSpec, + *, + attempts: int = OPEN_RETRIES, + retry_seconds: float = OPEN_RETRY_SECONDS, + sleep: Callable[[float], None] = time.sleep, + after_open: Callable[[str, int], None] | None = None, +) -> tuple[bytes, dict[str, object]]: + """Read one committed primary through a pinned descriptor. + + A concurrent atomic rename cannot redirect the open descriptor. If the + primary is briefly absent between unlink and rename, retry only that open; + never substitute `.tmp` or `.bak`. + """ + source_path = os.path.join(root_prefix, spec.relative_path) + started_at = _utc_now() + started_ns = time.monotonic_ns() + descriptor = -1 + for attempt in range(attempts): + try: + descriptor = _open_absolute_regular_no_follow(source_path) + break + except FileNotFoundError as exc: + if attempt + 1 >= attempts: + raise BackupError(f"{spec.world_id}: primary save stayed absent after bounded retries") from exc + sleep(retry_seconds) + if descriptor < 0: + raise BackupError(f"{spec.world_id}: could not open primary save") + + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise BackupError(f"{spec.world_id}: primary save is not a regular file") + if before.st_size <= 0 or before.st_size > MAX_WORLD_BYTES: + raise BackupError(f"{spec.world_id}: primary save size is outside safe bounds") + if after_open is not None: + after_open(source_path, descriptor) + chunks: list[bytes] = [] + byte_count = 0 + while True: + chunk = os.read(descriptor, CHUNK_SIZE) + if not chunk: + break + chunks.append(chunk) + byte_count += len(chunk) + if byte_count > MAX_WORLD_BYTES: + raise BackupError(f"{spec.world_id}: primary save exceeded the size limit while reading") + raw = b"".join(chunks) + after = os.fstat(descriptor) + if not _same_open_file_state(before, after, byte_count): + raise BackupError(f"{spec.world_id}: opened primary changed while it was read") + finally: + os.close(descriptor) + + _parse_json(raw, spec) + completed_at = _utc_now() + record: dict[str, object] = { + "world_id": spec.world_id, + "mode": spec.mode, + "archive_path": spec.archive_path, + "source_relative_path": spec.relative_path, + "source_device": before.st_dev, + "source_inode": before.st_ino, + "source_size_bytes": len(raw), + "source_mtime_ns": before.st_mtime_ns, + "source_ctime_ns": before.st_ctime_ns, + "source_mtime_utc": datetime.fromtimestamp(before.st_mtime, timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "capture_started_at_utc": started_at, + "capture_completed_at_utc": completed_at, + "read_duration_ms": round((time.monotonic_ns() - started_ns) / 1_000_000, 3), + "sha256": hashlib.sha256(raw).hexdigest(), + "schema_version": FORMAT_VERSION, + "checkpoint_semantics": "primary inode opened at capture start; independent per-world checkpoint", + } + return raw, record + + +def capture_payload( + root_prefix: str = DEFAULT_ROOT_PREFIX, + *, + sleep: Callable[[float], None] = time.sleep, + after_open: Callable[[str, int], None] | None = None, +) -> tuple[list[tuple[str, bytes]], dict[str, object]]: + payload: list[tuple[str, bytes]] = [] + records: list[dict[str, object]] = [] + capture_started = _utc_now() + for spec in WORLD_SPECS: + raw, record = capture_one(root_prefix, spec, sleep=sleep, after_open=after_open) + payload.append((spec.archive_path, raw)) + records.append(record) + manifest: dict[str, object] = { + "format": "tinyblock-community-world-checkpoint", + "format_version": FORMAT_VERSION, + "capture_id": secrets.token_hex(16), + "capture_started_at_utc": capture_started, + "capture_completed_at_utc": _utc_now(), + "save_schema_version": FORMAT_VERSION, + "world_count": len(records), + "checkpoint_consistency": "four independent per-world checkpoints; not a single global instant", + "source_selection": "primary JSON only; no .tmp or .bak fallback", + "source_consistency": "pinned O_RDONLY|O_NOFOLLOW file descriptor; primary writes use temp then remove/rename replacement", + "worlds": records, + } + payload.append(("manifest.json", (json.dumps(manifest, sort_keys=True, indent=2) + "\n").encode())) + return payload, manifest + + +def _write_archive(payload: Iterable[tuple[str, bytes]], output: io.BufferedWriter) -> None: + with tarfile.open(fileobj=output, mode="w|") as archive: + for name, raw in payload: + info = tarfile.TarInfo(name) + info.size = len(raw) + info.mode = 0o600 + info.mtime = int(time.time()) + archive.addfile(info, io.BytesIO(raw)) + + +def _allowed_archive_names() -> set[str]: + return {spec.archive_path for spec in WORLD_SPECS} | {"manifest.json"} + + +def _read_exact_tar(archive_path: str) -> dict[str, bytes]: + members: dict[str, bytes] = {} + with tarfile.open(archive_path, mode="r:*") as archive: + for member in archive.getmembers(): + if member.name not in _allowed_archive_names() or not member.isfile() or member.issym() or member.islnk(): + raise BackupError("archive contains an unexpected path or non-regular entry") + if member.name in members: + raise BackupError("archive contains a duplicate path") + stream = archive.extractfile(member) + if stream is None: + raise BackupError("archive member could not be read") + raw = stream.read(MAX_WORLD_BYTES + 1) + if len(raw) != member.size or len(raw) > MAX_WORLD_BYTES: + raise BackupError("archive member has an invalid size") + members[member.name] = raw + if set(members) != _allowed_archive_names(): + raise BackupError("archive does not contain exactly the expected four worlds and manifest") + return members + + +def _read_file_no_follow(path: Path, limit: int) -> bytes: + descriptor = _open_absolute_regular_no_follow(str(path)) + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or info.st_size > limit: + raise BackupError(f"unsafe or oversized validation file: {path.name}") + chunks: list[bytes] = [] + size = 0 + while True: + chunk = os.read(descriptor, CHUNK_SIZE) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + if size > limit: + raise BackupError(f"validation file exceeded the size limit: {path.name}") + raw = b"".join(chunks) + if not _same_open_file_state(info, os.fstat(descriptor), len(raw)): + raise BackupError(f"validation file changed while being read: {path.name}") + return raw + finally: + os.close(descriptor) + + +def _hash_file_no_follow(path: Path) -> tuple[int, str]: + descriptor = _open_absolute_regular_no_follow(str(path)) + digest = hashlib.sha256() + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + raise BackupError(f"encrypted artifact is not a regular file: {path.name}") + size = 0 + while True: + chunk = os.read(descriptor, CHUNK_SIZE) + if not chunk: + break + digest.update(chunk) + size += len(chunk) + after = os.fstat(descriptor) + if not _same_open_file_state(before, after, size): + raise BackupError("encrypted artifact changed while it was being hashed") + return size, digest.hexdigest() + finally: + os.close(descriptor) + + +def verify_directory( + directory: Path, + godot_receipt_path: Path | None = None, + encrypted_artifact_path: Path | None = None, +) -> dict[str, object]: + manifest_raw = _read_file_no_follow(directory / "manifest.json", 4 * 1024 * 1024) + try: + manifest = json.loads(manifest_raw, object_pairs_hook=_reject_duplicate_keys) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise BackupError(f"invalid checkpoint manifest: {exc}") from exc + if not isinstance(manifest, dict) or manifest.get("format") != "tinyblock-community-world-checkpoint" or manifest.get("format_version") != FORMAT_VERSION: + raise BackupError("unsupported checkpoint manifest") + raw_records = manifest.get("worlds", []) + if not isinstance(raw_records, list): + raise BackupError("manifest worlds must be a list") + record_map = {record.get("world_id"): record for record in raw_records if isinstance(record, dict)} + if len(record_map) != len(raw_records) or set(record_map) != {spec.world_id for spec in WORLD_SPECS} or manifest.get("world_count") != len(WORLD_SPECS): + raise BackupError("manifest does not name exactly the four contracted worlds") + if manifest.get("checkpoint_consistency") != "four independent per-world checkpoints; not a single global instant": + raise BackupError("manifest does not record independent checkpoint semantics") + for spec in WORLD_SPECS: + raw = _read_file_no_follow(directory / f"{spec.world_id}.json", MAX_WORLD_BYTES) + state = _parse_json(raw, spec) + record = record_map[spec.world_id] + if ( + record.get("mode") != spec.mode + or record.get("schema_version") != FORMAT_VERSION + or record.get("archive_path") != spec.archive_path + or record.get("source_relative_path") != spec.relative_path + or record.get("sha256") != hashlib.sha256(raw).hexdigest() + or record.get("source_size_bytes") != len(raw) + ): + raise BackupError(f"{spec.world_id}: saved bytes do not match the manifest") + if int(state["schema_version"]) != FORMAT_VERSION: + raise BackupError(f"{spec.world_id}: schema mismatch") + + godot_results: list[dict[str, object]] = [] + godot_result_map: dict[str, dict[str, object]] = {} + godot_version: str | None = None + if godot_receipt_path is not None: + receipt_raw = _read_file_no_follow(godot_receipt_path, 1024 * 1024) + try: + receipt = json.loads(receipt_raw, object_pairs_hook=_reject_duplicate_keys) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise BackupError(f"invalid Godot restore receipt: {exc}") from exc + if not isinstance(receipt, dict) or receipt.get("format") != "tinyblock-offline-restore-verification" or receipt.get("validator_version") != 1: + raise BackupError("unsupported Godot restore receipt") + godot_version = str(receipt.get("godot_version", "unknown")) + raw_results = receipt.get("worlds", []) + if not isinstance(raw_results, list): + raise BackupError("Godot restore receipt worlds must be a list") + godot_results = [result for result in raw_results if isinstance(result, dict)] + godot_result_map = {str(result.get("world_id", "")): result for result in godot_results} + if len(godot_results) != len(raw_results) or set(godot_result_map) != {spec.world_id for spec in WORLD_SPECS}: + raise BackupError("Godot did not validate exactly the four contracted worlds") + for spec in WORLD_SPECS: + result = godot_result_map[spec.world_id] + if result.get("mode") != spec.mode or result.get("deserialize_state_ok") is not True or int(result.get("schema_version", -1)) != FORMAT_VERSION: + raise BackupError(f"Godot offline restore failed for {spec.world_id}") + if result.get("sha256") != record_map[spec.world_id].get("sha256"): + raise BackupError(f"Godot verified different checkpoint bytes for {spec.world_id}") + if int(result.get("tile_count", 0)) <= 0: + raise BackupError(f"Godot offline restore returned an empty world for {spec.world_id}") + + encrypted_artifact: dict[str, object] | None = None + if encrypted_artifact_path is not None: + artifact_size, artifact_sha256 = _hash_file_no_follow(encrypted_artifact_path) + encrypted_artifact = { + "file_name": encrypted_artifact_path.name, + "size_bytes": artifact_size, + "sha256": artifact_sha256, + "encryption": "age", + } + + return { + "format": "tinyblock-world-backup-verification-receipt", + "format_version": 1, + "verified_at_utc": _utc_now(), + "capture_id": manifest["capture_id"], + "capture_started_at_utc": manifest["capture_started_at_utc"], + "capture_completed_at_utc": manifest["capture_completed_at_utc"], + "checkpoint_consistency": manifest["checkpoint_consistency"], + "encrypted_artifact": encrypted_artifact, + "worlds": [ + { + "world_id": spec.world_id, + "mode": spec.mode, + "size_bytes": record_map[spec.world_id]["source_size_bytes"], + "sha256": record_map[spec.world_id]["sha256"], + "godot_offline_restore": bool(godot_results), + "tile_count": int(godot_result_map[spec.world_id]["tile_count"]) if godot_results else None, + "source_mtime_utc": record_map[spec.world_id]["source_mtime_utc"], + "capture_started_at_utc": record_map[spec.world_id]["capture_started_at_utc"], + "capture_completed_at_utc": record_map[spec.world_id]["capture_completed_at_utc"], + } + for spec in WORLD_SPECS + ], + "godot_version": godot_version, + "restore_method": "WorldSim.deserialize_state in an isolated headless project; no server or multiplayer started", + } + + +def extract_archive(archive_path: str, destination: Path) -> None: + if destination.is_symlink() or not destination.is_dir() or any(destination.iterdir()): + raise BackupError("extract destination must be an existing empty non-symlink directory") + members = _read_exact_tar(archive_path) + for name, raw in members.items(): + target = destination / name + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + view = memoryview(raw) + while view: + count = os.write(descriptor, view) + view = view[count:] + finally: + os.close(descriptor) + + +def _command_capture(args: argparse.Namespace) -> int: + if sys.stdout.isatty(): + raise BackupError("refusing to write a plaintext world archive to a terminal") + payload, manifest = capture_payload(args.root_prefix) + _write_archive(payload, sys.stdout.buffer) + sys.stdout.buffer.flush() + print( + f"captured {manifest['world_count']} independent world checkpoints; archive bytes are on stdout", + file=sys.stderr, + ) + return 0 + + +def _command_extract(args: argparse.Namespace) -> int: + extract_archive(args.archive, Path(args.destination)) + print("archive extracted into private scratch; contents still require offline Godot validation") + return 0 + + +def _command_verify(args: argparse.Namespace) -> int: + receipt = verify_directory( + Path(args.directory), + Path(args.godot_receipt) if args.godot_receipt else None, + Path(args.artifact) if args.artifact else None, + ) + encoded = (json.dumps(receipt, sort_keys=True, indent=2) + "\n").encode() + if args.receipt_out: + destination = Path(args.receipt_out) + descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + os.write(descriptor, encoded) + finally: + os.close(descriptor) + sys.stdout.buffer.write(encoded) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + capture = subparsers.add_parser("capture", help="emit encrypted-transport-ready tar to stdout") + capture.add_argument("--root-prefix", default=DEFAULT_ROOT_PREFIX) + capture.set_defaults(handler=_command_capture) + extract = subparsers.add_parser("extract", help="safely extract an already decrypted archive") + extract.add_argument("--archive", required=True) + extract.add_argument("--destination", required=True) + extract.set_defaults(handler=_command_extract) + verify = subparsers.add_parser("verify", help="verify hashes/schema and optional Godot receipt") + verify.add_argument("--directory", required=True) + verify.add_argument("--godot-receipt") + verify.add_argument("--artifact", help="optional encrypted archive to hash into the verification receipt") + verify.add_argument("--receipt-out") + verify.set_defaults(handler=_command_verify) + args = parser.parse_args(argv) + try: + return int(args.handler(args)) + except (BackupError, OSError, tarfile.TarError) as exc: + print(f"backup error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main())