From aa9af62d1791efbec01fa3aab5b216d3e4afc491 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:27:08 +0800 Subject: [PATCH 01/33] feat: start Python 3.12+ and Windows-safe runtime compatibility --- AGENTS.md | 4 ++-- pyproject.toml | 7 ++++-- src/endstone_endkeep/staging/clone.py | 2 +- src/endstone_endkeep/staging/exact.py | 2 +- src/endstone_endkeep/worker/client.py | 31 ++++++++++++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d9a0337..ab0153e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Working on EndKeep EndKeep is a Python plugin for Endstone that creates incremental backups of -Minecraft Bedrock worlds. This repository targets Linux, Python 3.14 and -Endstone 0.11. For Endstone API usage, see [Endstone's documentation](https://endstone.dev/). +Minecraft Bedrock worlds. This repository targets Python 3.12–3.14 on Linux and Windows x86-64 +(Windows support is in progress) with Endstone 0.11. For Endstone API usage, see [Endstone's documentation](https://endstone.dev/). ## Project layout diff --git a/pyproject.toml b/pyproject.toml index 0175e6f6..6bde9e3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,14 @@ description = "Crash-safe logical incremental world backups for Endstone" readme = "README.md" license = { file = "LICENSE" } keywords = ["endstone", "minecraft", "bedrock", "backup", "leveldb"] -requires-python = ">=3.14" +requires-python = ">=3.12" classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] @@ -47,7 +50,7 @@ endkeep = "endstone_endkeep:EndKeepPlugin" [tool.ruff] line-length = 120 -target-version = "py314" +target-version = "py312" extend-exclude = ["src/endstone_endkeep/_version.py"] [tool.ruff.lint] diff --git a/src/endstone_endkeep/staging/clone.py b/src/endstone_endkeep/staging/clone.py index dc3f4e42..e710dd76 100644 --- a/src/endstone_endkeep/staging/clone.py +++ b/src/endstone_endkeep/staging/clone.py @@ -68,7 +68,7 @@ def clone_world( for dirname in dirnames: child = current_path / dirname info = child.lstat() - if stat.S_ISLNK(info.st_mode): + if stat.S_ISLNK(info.st_mode) or (os.name == "nt" and child.is_junction()): raise CloneError(f"raw snapshot contains directory symlink: {child}") for filename in filenames: diff --git a/src/endstone_endkeep/staging/exact.py b/src/endstone_endkeep/staging/exact.py index d6d84468..12fae744 100644 --- a/src/endstone_endkeep/staging/exact.py +++ b/src/endstone_endkeep/staging/exact.py @@ -72,7 +72,7 @@ def _open_source_portable(root: Path, relative: PurePosixPath) -> int: for component in relative.parts: current = current / component info = current.lstat() - if stat.S_ISLNK(info.st_mode): + if stat.S_ISLNK(info.st_mode) or (os.name == "nt" and current.is_junction()): raise ExactStageError(f"source path contains a symlink: {relative}") resolved = candidate.resolve(strict=True) except OSError as exc: diff --git a/src/endstone_endkeep/worker/client.py b/src/endstone_endkeep/worker/client.py index 5416479d..c32da202 100644 --- a/src/endstone_endkeep/worker/client.py +++ b/src/endstone_endkeep/worker/client.py @@ -108,7 +108,11 @@ def _start(cls, storage_root: Path, runtime_path: Path, priority: str) -> Reposi "stdin": subprocess.DEVNULL, "close_fds": True, } - kwargs["start_new_session"] = True + if os.name == "nt": + # Use a process group on Windows; start_new_session is POSIX-only. + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True log_path = storage_root / "worker.log" with log_path.open("ab", buffering=0) as log: @@ -153,6 +157,31 @@ def _load_runtime(path: Path) -> RuntimeInfo | None: def _pid_alive(pid: int) -> bool: if pid <= 0: return False + if os.name == "nt": + # os.kill(pid, 0) calls TerminateProcess on Windows: never use it + # for liveness checks. Denied process access is treated as alive, + # avoiding a second writer to an active repository. + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD) + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.CloseHandle.argtypes = (wintypes.HANDLE,) + kernel32.CloseHandle.restype = wintypes.BOOL + + handle = kernel32.OpenProcess(0x00100000, False, pid) # SYNCHRONIZE + if not handle: + return ctypes.get_last_error() != 87 # ERROR_INVALID_PARAMETER: PID absent + try: + # WAIT_OBJECT_0 means exited; WAIT_TIMEOUT means still active. + # Treat WAIT_FAILED as alive to preserve single-writer safety. + return kernel32.WaitForSingleObject(handle, 0) != 0 + finally: + kernel32.CloseHandle(handle) + try: os.kill(pid, 0) except ProcessLookupError: From e041fa49502ed5e3e6a8fe2a8704bd7536df2a57 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:29:36 +0800 Subject: [PATCH 02/33] ci: validate EndKeep and Amulet backports on six Python/OS combinations --- .github/scripts/amulet_leveldb_smoke.py | 38 ++++++++ .github/workflows/compatibility.yml | 116 ++++++++++++++++++++++++ tests/test_windows_compatibility.py | 49 ++++++++++ 3 files changed, 203 insertions(+) create mode 100644 .github/scripts/amulet_leveldb_smoke.py create mode 100644 .github/workflows/compatibility.yml create mode 100644 tests/test_windows_compatibility.py diff --git a/.github/scripts/amulet_leveldb_smoke.py b/.github/scripts/amulet_leveldb_smoke.py new file mode 100644 index 00000000..aa092e61 --- /dev/null +++ b/.github/scripts/amulet_leveldb_smoke.py @@ -0,0 +1,38 @@ +"""Isolated smoke test for Amulet-LevelDB 3.0.7a0 experimental manylinux wheels.""" + +from importlib.metadata import version +from pathlib import Path +from tempfile import TemporaryDirectory + +from amulet.leveldb import LevelDB + + +def main() -> None: + assert version("amulet-leveldb").split("+", 1)[0] == "3.0.7a0" + expected = {b"\x00": b"alpha", b"\x02": b"bravo", b"\xff": b"\x00\xff"} + with TemporaryDirectory(prefix="amulet-backport-") as tmp: + location = Path(tmp) / "db" + db = LevelDB(str(location), True) + db.put(b"\x00", b"alpha") + db.put_batch({b"\x02": b"bravo", b"\xff": b"\x00\xff"}) + for key, value in expected.items(): + assert db.get(key) == value, (key, value) + iterator = db.create_iterator() + iterator.seek_to_first() + got = [] + while iterator.valid(): + got.append((iterator.key(), iterator.value())) + iterator.next() + assert got == sorted(expected.items()), got + del iterator + db.close() + + reopened = LevelDB(str(location), False) + for key, value in expected.items(): + assert reopened.get(key) == value + reopened.close() + print("OK: CPython wheel import, KV write/batch, sorted iterator, close/reopen") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 00000000..7d0d99c9 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,116 @@ +name: Python and Windows compatibility + +on: + push: + branches: [feat/python312-windows-compat] + paths: [.github/workflows/compatibility.yml, .github/scripts/amulet_leveldb_smoke.py, pyproject.toml, 'src/**', 'tests/**', 'tools/**'] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + full-matrix: + name: ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + timeout-minutes: 50 + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, python: '3.12', wheel: cp312-manylinux_x86_64 } + - { os: ubuntu-24.04, python: '3.13', wheel: cp313-manylinux_x86_64 } + - { os: ubuntu-24.04, python: '3.14', wheel: '' } + - { os: windows-2025, python: '3.12', wheel: cp312-win_amd64 } + - { os: windows-2025, python: '3.13', wheel: cp313-win_amd64 } + - { os: windows-2025, python: '3.14', wheel: '' } + defaults: + run: + shell: bash + steps: + - name: Checkout EndKeep + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Checkout upstream Amulet 3.0.7a0 at verified commit + if: matrix.python != '3.14' + uses: actions/checkout@v4 + with: + repository: Amulet-Team/Amulet-LevelDB + ref: 736228e375e9f535b6522bd28168fc306a5354b9 + path: amulet-src + fetch-depth: 0 + persist-credentials: false + - name: Patch Amulet Python floor and Windows VS generator + if: matrix.python != '3.14' + run: | + python - <<'PY' + from pathlib import Path + project = Path("amulet-src/pyproject.toml") + content = project.read_text() + needle = 'requires-python = ">=3.14"' + assert content.count(needle) == 1 + project.write_text(content.replace(needle, 'requires-python = ">=3.12"')) + import os + if os.name == "nt": + setup = Path("amulet-src/setup.py") + text = setup.read_text() + assert text.count("Visual Studio 18 2026") == 1 + assert text.count("v145") == 1 + setup.write_text(text.replace("Visual Studio 18 2026", "Visual Studio 17 2022").replace("v145", "v143")) + PY + cp .github/scripts/amulet_leveldb_smoke.py amulet-src/tests/test_backport_smoke.py + git -C amulet-src diff --check + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - name: Install cibuildwheel + if: matrix.python != '3.14' + run: python -m pip install 'cibuildwheel~=4.1' + - name: Build and test Amulet wheel + if: matrix.python != '3.14' + env: + CIBW_BEFORE_BUILD: 'python -m pip install "cmake>=4.1,<5"' + CIBW_BEFORE_TEST: 'python -m pip install "cmake>=4.1,<5"' + CIBW_TEST_COMMAND: 'python {project}/tests/test_backport_smoke.py && python {project}/tests/test_db.py' + CIBW_TEST_SOURCES: tests + run: | + mkdir -p amulet-dist + cd amulet-src + python -m cibuildwheel --only '${{ matrix.wheel }}' --output-dir ../amulet-dist . + - name: Setup uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - name: Test installed EndKeep with matched Amulet + run: | + uv venv .venv --python '${{ matrix.python }}' + if [[ "${{ runner.os }}" == "Windows" ]]; then + PYTHON=.venv/Scripts/python.exe + else + PYTHON=.venv/bin/python + fi + if [[ "${{ matrix.python }}" != "3.14" ]]; then + uv pip install --python "$PYTHON" amulet-dist/*.whl + fi + uv pip install --python "$PYTHON" -e '.[dev]' + uv pip check --python "$PYTHON" + "$PYTHON" - <<'PY' + from importlib.metadata import version + from amulet.leveldb import LevelDB + assert version("amulet-leveldb").split("+")[0] == "3.0.7a0" + print("Amulet:", version("amulet-leveldb")) + PY + "$PYTHON" -m pytest -q + - name: Save tested experimental backports (no Release) + if: matrix.python != '3.14' + uses: actions/upload-artifact@v4 + with: + name: amulet-compat-${{ matrix.os }}-${{ matrix.python }} + path: amulet-dist/*.whl + if-no-files-found: error + retention-days: 7 diff --git a/tests/test_windows_compatibility.py b/tests/test_windows_compatibility.py new file mode 100644 index 00000000..30b89df4 --- /dev/null +++ b/tests/test_windows_compatibility.py @@ -0,0 +1,49 @@ +"""Platform safety regression tests for the Python/Windows compatibility PR.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path, PurePosixPath + +import pytest + +from endstone_endkeep.bds.model import SnapshotEntry, SnapshotManifest +from endstone_endkeep.staging.clone import CloneError, clone_world +from endstone_endkeep.staging.exact import ExactStageError, stage_manifest +from endstone_endkeep.worker.client import RepositoryWorkerClient + + +def test_worker_pid_probe_does_not_terminate_this_process() -> None: + # On Windows, os.kill(os.getpid(), 0) would terminate the test runner. + assert RepositoryWorkerClient._pid_alive(os.getpid()) + assert not RepositoryWorkerClient._pid_alive(0) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows junctions only") +def test_windows_junctions_cannot_escape_staging_or_clone(tmp_path: Path) -> None: + world = tmp_path / "worlds" / "level" + world.mkdir(parents=True) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "level.dat").write_bytes(b"outside") + + junction = world / "escape" + created = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(junction), str(outside)], + capture_output=True, + text=True, + check=False, + ) + if created.returncode: + pytest.skip(f"junction creation unavailable: {created.stderr}") + assert junction.is_junction() + + manifest = SnapshotManifest( + "level", + (SnapshotEntry(PurePosixPath("level/escape/level.dat"), 7),), + ) + with pytest.raises(ExactStageError): + stage_manifest(tmp_path / "worlds", tmp_path / "stage", manifest) + with pytest.raises(CloneError): + clone_world(tmp_path / "worlds", "level", tmp_path / "work", "snapshot") From 237a433c7a4e41851ddc46c5a8c80511fa28e25e Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:30:08 +0800 Subject: [PATCH 03/33] fix(py312): parenthesize multi-exception handlers for Python 3.12/3.13 --- src/endstone_endkeep/repository/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/endstone_endkeep/repository/health.py b/src/endstone_endkeep/repository/health.py index a12312b8..4f436ad5 100644 --- a/src/endstone_endkeep/repository/health.py +++ b/src/endstone_endkeep/repository/health.py @@ -24,7 +24,7 @@ def __init__(self, storage_root: Path) -> None: if not data["reason"]: raise ValueError("empty failure reason") self.reason = data["reason"] - except OSError, ValueError, TypeError, AttributeError: + except (OSError, ValueError, TypeError, AttributeError): # Corrupt health metadata must NEVER be interpreted as healthy. self.reason = "repository health marker is unreadable or invalid" From 0423c27828682a14303e7fa963a503cee8c83b60 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:30:10 +0800 Subject: [PATCH 04/33] fix(py312): parenthesize multi-exception handlers for Python 3.12/3.13 --- src/endstone_endkeep/repository/recovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/endstone_endkeep/repository/recovery.py b/src/endstone_endkeep/repository/recovery.py index 60936d9b..e54edc19 100644 --- a/src/endstone_endkeep/repository/recovery.py +++ b/src/endstone_endkeep/repository/recovery.py @@ -76,7 +76,7 @@ def _recover_authoritative_manifest(self) -> tuple[RepositoryManifest | None, in if self.manifests.head_path.exists(): try: head_generation = int(self.manifests.head_path.read_text(encoding="ascii").strip()) - except OSError, ValueError: + except (OSError, ValueError): head_generation = None generations = self._generation_numbers() @@ -94,7 +94,7 @@ def _recover_authoritative_manifest(self) -> tuple[RepositoryManifest | None, in if self._manifest_structurally_valid(current): self.manifests.discard_unpublished() return current, None - except ManifestError, OSError, ValueError: + except (ManifestError, OSError, ValueError): pass # Only recover from the newest structurally valid generation when HEAD From 2870791edd70615bfb32316ea36d806110442a70 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:30:13 +0800 Subject: [PATCH 05/33] fix(py312): parenthesize multi-exception handlers for Python 3.12/3.13 --- src/endstone_endkeep/worker/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/endstone_endkeep/worker/client.py b/src/endstone_endkeep/worker/client.py index c32da202..8c62e03f 100644 --- a/src/endstone_endkeep/worker/client.py +++ b/src/endstone_endkeep/worker/client.py @@ -150,7 +150,7 @@ def _start(cls, storage_root: Path, runtime_path: Path, priority: str) -> Reposi def _load_runtime(path: Path) -> RuntimeInfo | None: try: return RuntimeInfo.load(path) - except FileNotFoundError, OSError, ValueError, KeyError: + except (FileNotFoundError, OSError, ValueError, KeyError): return None @staticmethod From a030a527efd525c69d652a089f16ff03a94a1ed6 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:32:04 +0800 Subject: [PATCH 06/33] fix(windows): tolerate unsupported directory fsync and escape TOML paths --- .github/workflows/compatibility.yml | 7 ++++--- src/endstone_endkeep/config.py | 8 +++++++- src/endstone_endkeep/repository/health.py | 13 +++++++++++-- src/endstone_endkeep/repository/manifest.py | 8 +++++++- src/endstone_endkeep/repository/objects.py | 8 +++++++- src/endstone_endkeep/scheduler.py | 8 +++++++- tests/test_offline_builder.py | 5 ++++- 7 files changed, 47 insertions(+), 10 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 7d0d99c9..6c0edaa3 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -1,13 +1,14 @@ name: Python and Windows compatibility on: - push: - branches: [feat/python312-windows-compat] - paths: [.github/workflows/compatibility.yml, .github/scripts/amulet_leveldb_smoke.py, pyproject.toml, 'src/**', 'tests/**', 'tools/**'] pull_request: branches: [main] workflow_dispatch: +concurrency: + group: compatibility-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + permissions: contents: read diff --git a/src/endstone_endkeep/config.py b/src/endstone_endkeep/config.py index 06af9d48..59d1d651 100644 --- a/src/endstone_endkeep/config.py +++ b/src/endstone_endkeep/config.py @@ -106,7 +106,13 @@ def reconcile_config_file(path: Path) -> bool: raise else: try: - os.fsync(fd) + try: + os.fsync(fd) + except OSError: + # Windows CRT descriptors do not support directory fsync. + # Data-file fsync and atomic rename remain mandatory. + if os.name == "posix": + raise finally: os.close(fd) return True diff --git a/src/endstone_endkeep/repository/health.py b/src/endstone_endkeep/repository/health.py index 4f436ad5..919f6b12 100644 --- a/src/endstone_endkeep/repository/health.py +++ b/src/endstone_endkeep/repository/health.py @@ -67,8 +67,17 @@ def clear_after_deep_verify(self) -> None: self.reason = None def _fsync_root(self) -> None: - fd = os.open(self.storage_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: - os.fsync(fd) + fd = os.open(self.storage_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + except OSError: + if os.name == "posix": + raise + return + try: + try: + os.fsync(fd) + except OSError: + if os.name == "posix": + raise finally: os.close(fd) diff --git a/src/endstone_endkeep/repository/manifest.py b/src/endstone_endkeep/repository/manifest.py index 0ce22cea..af09e010 100644 --- a/src/endstone_endkeep/repository/manifest.py +++ b/src/endstone_endkeep/repository/manifest.py @@ -203,6 +203,12 @@ def fsync_directory(path: Path) -> None: raise return try: - os.fsync(fd) + try: + os.fsync(fd) + except OSError: + # Windows CRT descriptors do not support directory fsync. + # Data-file fsync and atomic rename remain mandatory. + if os.name == "posix": + raise finally: os.close(fd) diff --git a/src/endstone_endkeep/repository/objects.py b/src/endstone_endkeep/repository/objects.py index fb79f0f7..320b8e6a 100644 --- a/src/endstone_endkeep/repository/objects.py +++ b/src/endstone_endkeep/repository/objects.py @@ -282,6 +282,12 @@ def _fsync_directory(path: Path) -> None: raise return try: - os.fsync(fd) + try: + os.fsync(fd) + except OSError: + # Windows CRT descriptors do not support directory fsync. + # Data-file fsync and atomic rename remain mandatory. + if os.name == "posix": + raise finally: os.close(fd) diff --git a/src/endstone_endkeep/scheduler.py b/src/endstone_endkeep/scheduler.py index 33321a35..ea2faf54 100644 --- a/src/endstone_endkeep/scheduler.py +++ b/src/endstone_endkeep/scheduler.py @@ -138,7 +138,13 @@ def _save_atomic(self) -> None: except OSError: return try: - os.fsync(fd) + try: + os.fsync(fd) + except OSError: + # Windows CRT descriptors do not support directory fsync. + # Data-file fsync and atomic rename remain mandatory. + if os.name == "posix": + raise finally: os.close(fd) diff --git a/tests/test_offline_builder.py b/tests/test_offline_builder.py index e40ca12b..269bdd4c 100644 --- a/tests/test_offline_builder.py +++ b/tests/test_offline_builder.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import subprocess import sys import tomllib @@ -64,7 +65,9 @@ def test_cli_discovers_configured_repository(tmp_path: Path, monkeypatch: pytest plugin_config.write_text('[storage]\npath = "custom-backups"\n', encoding="utf-8") assert tomllib.loads(plugin_config.read_text(encoding="utf-8"))["storage"]["path"] == "custom-backups" assert _default_repository() == tmp_path / "custom-backups" / "repo" - plugin_config.write_text(f'[storage]\npath = "{tmp_path / "absolute"}"\n', encoding="utf-8") + plugin_config.write_text( + f'[storage]\\npath = {json.dumps(str(tmp_path / "absolute"))}\\n', encoding="utf-8" + ) assert _default_repository() == tmp_path / "absolute" / "repo" assert build_parser().parse_args(["--repo", "manual/repo", "list"]).repo == Path("manual/repo") From 192aca2a0ddf23524abefb4ff2f9da261397c3bb Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:32:25 +0800 Subject: [PATCH 07/33] fix(test): emit valid cross-platform TOML basic string escape sequences --- tests/test_offline_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_offline_builder.py b/tests/test_offline_builder.py index 269bdd4c..24d4f89f 100644 --- a/tests/test_offline_builder.py +++ b/tests/test_offline_builder.py @@ -66,7 +66,7 @@ def test_cli_discovers_configured_repository(tmp_path: Path, monkeypatch: pytest assert tomllib.loads(plugin_config.read_text(encoding="utf-8"))["storage"]["path"] == "custom-backups" assert _default_repository() == tmp_path / "custom-backups" / "repo" plugin_config.write_text( - f'[storage]\\npath = {json.dumps(str(tmp_path / "absolute"))}\\n', encoding="utf-8" + f'[storage]\npath = {json.dumps(str(tmp_path / "absolute"))}\n', encoding="utf-8" ) assert _default_repository() == tmp_path / "absolute" / "repo" assert build_parser().parse_args(["--repo", "manual/repo", "list"]).repo == Path("manual/repo") From a749d7ee769817178ef04594e95b20a4b6399a0f Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:33:04 +0800 Subject: [PATCH 08/33] fix(ci): reuse upstream Visual Studio 2026 toolchain on Windows --- .github/workflows/compatibility.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 6c0edaa3..ce9a1e71 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -45,7 +45,7 @@ jobs: path: amulet-src fetch-depth: 0 persist-credentials: false - - name: Patch Amulet Python floor and Windows VS generator + - name: Patch Amulet Python floor if: matrix.python != '3.14' run: | python - <<'PY' @@ -55,13 +55,7 @@ jobs: needle = 'requires-python = ">=3.14"' assert content.count(needle) == 1 project.write_text(content.replace(needle, 'requires-python = ">=3.12"')) - import os - if os.name == "nt": - setup = Path("amulet-src/setup.py") - text = setup.read_text() - assert text.count("Visual Studio 18 2026") == 1 - assert text.count("v145") == 1 - setup.write_text(text.replace("Visual Studio 18 2026", "Visual Studio 17 2022").replace("v145", "v143")) + PY cp .github/scripts/amulet_leveldb_smoke.py amulet-src/tests/test_backport_smoke.py git -C amulet-src diff --check From 43937ff5a439084c05264300aaaca455da5cbcde Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:33:43 +0800 Subject: [PATCH 09/33] style: match Ruff formatter for Windows-safe TOML test --- tests/test_offline_builder.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_offline_builder.py b/tests/test_offline_builder.py index 24d4f89f..a074542f 100644 --- a/tests/test_offline_builder.py +++ b/tests/test_offline_builder.py @@ -65,9 +65,7 @@ def test_cli_discovers_configured_repository(tmp_path: Path, monkeypatch: pytest plugin_config.write_text('[storage]\npath = "custom-backups"\n', encoding="utf-8") assert tomllib.loads(plugin_config.read_text(encoding="utf-8"))["storage"]["path"] == "custom-backups" assert _default_repository() == tmp_path / "custom-backups" / "repo" - plugin_config.write_text( - f'[storage]\npath = {json.dumps(str(tmp_path / "absolute"))}\n', encoding="utf-8" - ) + plugin_config.write_text(f"[storage]\npath = {json.dumps(str(tmp_path / 'absolute'))}\n", encoding="utf-8") assert _default_repository() == tmp_path / "absolute" / "repo" assert build_parser().parse_args(["--repo", "manual/repo", "list"]).repo == Path("manual/repo") From a3b20ea520a09763642976ab62624b17d5c5d538 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:35:37 +0800 Subject: [PATCH 10/33] fix(windows): flush restored and staged file data using writable handles --- src/endstone_endkeep/offline/cli.py | 5 ++++- src/endstone_endkeep/staging/raw.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/endstone_endkeep/offline/cli.py b/src/endstone_endkeep/offline/cli.py index aa239e6d..2529106b 100644 --- a/src/endstone_endkeep/offline/cli.py +++ b/src/endstone_endkeep/offline/cli.py @@ -550,7 +550,10 @@ def _fsync_tree(root: Path) -> None: current_path = Path(current) directories.append(current_path) for filename in filenames: - fd = os.open(current_path / filename, os.O_RDONLY) + # Windows' CRT _commit needs a writable handle to flush file data. + # Do not skip fsync: failed flushes must abort recovery. + flags = os.O_RDWR if os.name == "nt" else os.O_RDONLY + fd = os.open(current_path / filename, flags) try: os.fsync(fd) finally: diff --git a/src/endstone_endkeep/staging/raw.py b/src/endstone_endkeep/staging/raw.py index aa55a3fb..326a2193 100644 --- a/src/endstone_endkeep/staging/raw.py +++ b/src/endstone_endkeep/staging/raw.py @@ -131,7 +131,9 @@ def _fsync_tree(cls, root: Path) -> None: directories.append(current_path) for filename in filenames: path = current_path / filename - fd = os.open(path, os.O_RDONLY) + # Open with write access on Windows so os.fsync can commit data. + flags = os.O_RDWR if os.name == "nt" else os.O_RDONLY + fd = os.open(path, flags) try: os.fsync(fd) finally: From ec1b39833cb4396c40d19895692408093b2f06ba Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:44:14 +0800 Subject: [PATCH 11/33] test: fail early if the third logical snapshot loses records on Windows --- tests/test_online_management.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_online_management.py b/tests/test_online_management.py index e208e86c..9002ec6b 100644 --- a/tests/test_online_management.py +++ b/tests/test_online_management.py @@ -95,7 +95,10 @@ def test_mutating_middle_delta_retains_restore_state(tmp_path: Path) -> None: storage = tmp_path / "backups" build_fixture(storage) add_raw(storage, S3, [(b"a", b"final"), (b"c", b"3")], b"last") - Logicalizer(storage, compression_level=6, compression_threads=1).logicalize(storage / "raw" / S3) + third = Logicalizer(storage, compression_level=6, compression_threads=1).logicalize(storage / "raw" / S3) + # Detect empty/misread Windows clone data before destructive chain edits. + # Otherwise an empty DELTA could look like a successful delete/export. + assert third.records == 2, f"third snapshot unexpectedly empty before mutation: {third}" service = _service(storage) try: preview = service.plan_mutation("delete", S2) From 125ef8e2fe136db43787985f156f164e8daa8c83 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:46:40 +0800 Subject: [PATCH 12/33] test: isolate Windows LevelDB record loss across raw/clone boundary --- tests/test_online_management.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_online_management.py b/tests/test_online_management.py index 9002ec6b..2c8e12a6 100644 --- a/tests/test_online_management.py +++ b/tests/test_online_management.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil import time from pathlib import Path from types import SimpleNamespace @@ -13,6 +14,7 @@ from endstone_endkeep.repository.maintenance import RepositoryService from endstone_endkeep.repository.manifest import ManifestStore from endstone_endkeep.repository.mutation import SnapshotMutator +from endstone_endkeep.staging.clone import clone_world from endstone_endkeep.worker.client import RepositoryWorkerClient, WorkerRequestIndeterminate, WorkerTimeout from endstone_endkeep.worker.server import WorkerApplication from tests.standalone_fixture import add_raw, build_fixture @@ -95,6 +97,17 @@ def test_mutating_middle_delta_retains_restore_state(tmp_path: Path) -> None: storage = tmp_path / "backups" build_fixture(storage) add_raw(storage, S3, [(b"a", b"final"), (b"c", b"3")], b"last") + # Diagnose the Windows corruption at the raw/clone boundary, before + # invoking the logical delta pipeline or altering any repository state. + probe_root = tmp_path / "probe" + probe = clone_world(storage / "raw" / S3, "level", probe_root, S3) + with iter_visible_state(probe / "db") as visible: + probe_state = list(visible) + shutil.rmtree(probe_root) + if probe_state != [(b"a", b"final"), (b"c", b"3")]: + with iter_visible_state(storage / "raw" / S3 / "level" / "db") as visible: + raw_state = list(visible) + pytest.fail(f"cloned DB lost records: raw={raw_state!r}; clone={probe_state!r}") third = Logicalizer(storage, compression_level=6, compression_threads=1).logicalize(storage / "raw" / S3) # Detect empty/misread Windows clone data before destructive chain edits. # Otherwise an empty DELTA could look like a successful delete/export. From a35c7209e25658613e5e306599b2b496c1c31e48 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:49:11 +0800 Subject: [PATCH 13/33] test: compare Windows source and cloned LevelDB file hashes before opening --- tests/test_online_management.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_online_management.py b/tests/test_online_management.py index 2c8e12a6..3164a6d4 100644 --- a/tests/test_online_management.py +++ b/tests/test_online_management.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import shutil import time from pathlib import Path @@ -101,13 +102,31 @@ def test_mutating_middle_delta_retains_restore_state(tmp_path: Path) -> None: # invoking the logical delta pipeline or altering any repository state. probe_root = tmp_path / "probe" probe = clone_world(storage / "raw" / S3, "level", probe_root, S3) + raw_db = storage / "raw" / S3 / "level" / "db" + + def fingerprint(root: Path) -> list[tuple[str, int, str]]: + return sorted( + ( + path.relative_to(root).as_posix(), + path.stat().st_size, + hashlib.sha256(path.read_bytes()).hexdigest()[:16], + ) + for path in root.rglob("*") + if path.is_file() + ) + + raw_files = fingerprint(raw_db) + clone_files = fingerprint(probe / "db") + assert raw_files == clone_files, f"clone files differ: raw={raw_files!r}, clone={clone_files!r}" with iter_visible_state(probe / "db") as visible: probe_state = list(visible) shutil.rmtree(probe_root) if probe_state != [(b"a", b"final"), (b"c", b"3")]: with iter_visible_state(storage / "raw" / S3 / "level" / "db") as visible: raw_state = list(visible) - pytest.fail(f"cloned DB lost records: raw={raw_state!r}; clone={probe_state!r}") + pytest.fail( + f"cloned DB lost records: raw={raw_state!r}; clone={probe_state!r}; files={raw_files!r}" + ) third = Logicalizer(storage, compression_level=6, compression_threads=1).logicalize(storage / "raw" / S3) # Detect empty/misread Windows clone data before destructive chain edits. # Otherwise an empty DELTA could look like a successful delete/export. From 168ab3a837e529c024f5cd96df4f02b5cfcab7a4 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:50:31 +0800 Subject: [PATCH 14/33] fix(windows): use binary CRT handles for byte-exact LevelDB cloning and staging --- src/endstone_endkeep/repository/recovery.py | 2 +- .../repository/transaction.py | 2 +- src/endstone_endkeep/staging/clone.py | 6 ++++-- src/endstone_endkeep/staging/exact.py | 4 ++-- src/endstone_endkeep/staging/raw.py | 2 +- tests/test_windows_compatibility.py | 19 +++++++++++++++++++ 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/endstone_endkeep/repository/recovery.py b/src/endstone_endkeep/repository/recovery.py index e54edc19..cd7202d6 100644 --- a/src/endstone_endkeep/repository/recovery.py +++ b/src/endstone_endkeep/repository/recovery.py @@ -139,7 +139,7 @@ def _write_head(self, generation: int) -> None: self.manifests.repo_root.mkdir(parents=True, exist_ok=True) part = self.manifests.repo_root / "HEAD.recovery.part" part.unlink(missing_ok=True) - fd = os.open(part, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + fd = os.open(part, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600) try: payload = f"{generation}\n".encode("ascii") os.write(fd, payload) diff --git a/src/endstone_endkeep/repository/transaction.py b/src/endstone_endkeep/repository/transaction.py index 6559065c..67fb413c 100644 --- a/src/endstone_endkeep/repository/transaction.py +++ b/src/endstone_endkeep/repository/transaction.py @@ -47,7 +47,7 @@ def commit(self, manifest: RepositoryManifest) -> None: @staticmethod def _write_fsync(path: Path, payload: bytes) -> None: - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600) try: view = memoryview(payload) while view: diff --git a/src/endstone_endkeep/staging/clone.py b/src/endstone_endkeep/staging/clone.py index e710dd76..4c7a010c 100644 --- a/src/endstone_endkeep/staging/clone.py +++ b/src/endstone_endkeep/staging/clone.py @@ -13,8 +13,10 @@ class CloneError(RuntimeError): def _copy_reflink_or_bytes(source: Path, destination: Path) -> None: - source_fd = os.open(source, os.O_RDONLY) - destination_fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + source_fd = os.open(source, os.O_RDONLY | getattr(os, "O_BINARY", 0)) + destination_fd = os.open( + destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600 + ) try: cloned = False if os.name == "posix": diff --git a/src/endstone_endkeep/staging/exact.py b/src/endstone_endkeep/staging/exact.py index 12fae744..c1e93463 100644 --- a/src/endstone_endkeep/staging/exact.py +++ b/src/endstone_endkeep/staging/exact.py @@ -79,7 +79,7 @@ def _open_source_portable(root: Path, relative: PurePosixPath) -> int: raise ExactStageError(f"cannot inspect source {relative}: {exc}") from exc if resolved != root_resolved and root_resolved not in resolved.parents: raise ExactStageError(f"source escapes world root: {relative}") - return os.open(resolved, os.O_RDONLY) + return os.open(resolved, os.O_RDONLY | getattr(os, "O_BINARY", 0)) def _open_source(root: Path, relative: PurePosixPath) -> int: @@ -170,7 +170,7 @@ def stage_manifest( f"{source_info.st_size} < {entry.snapshot_bytes}" ) - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) dst_fd = os.open(target, flags, 0o600) try: _copy_bounded(src_fd, dst_fd, entry.snapshot_bytes, cancel) diff --git a/src/endstone_endkeep/staging/raw.py b/src/endstone_endkeep/staging/raw.py index 326a2193..eb768a97 100644 --- a/src/endstone_endkeep/staging/raw.py +++ b/src/endstone_endkeep/staging/raw.py @@ -91,7 +91,7 @@ def publish(self, staged: StagedRawSnapshot) -> Path: "total_bytes": staged.manifest.total_bytes, } metadata_path = staged.incoming_path / "snapshot.json" - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) fd = os.open(metadata_path, flags, 0o600) try: payload = (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode() diff --git a/tests/test_windows_compatibility.py b/tests/test_windows_compatibility.py index 30b89df4..18d45808 100644 --- a/tests/test_windows_compatibility.py +++ b/tests/test_windows_compatibility.py @@ -47,3 +47,22 @@ def test_windows_junctions_cannot_escape_staging_or_clone(tmp_path: Path) -> Non stage_manifest(tmp_path / "worlds", tmp_path / "stage", manifest) with pytest.raises(CloneError): clone_world(tmp_path / "worlds", "level", tmp_path / "work", "snapshot") + +def test_binary_staging_and_clone_are_byte_exact(tmp_path: Path) -> None: + # LevelDB contains arbitrary binary bytes; Windows CRT text mode may + # truncate at Ctrl-Z or change CRLF unless O_BINARY is explicit. + source = tmp_path / "source" + source_file = source / "level" / "db" / "000004.ldb" + source_file.parent.mkdir(parents=True) + payload = bytes(range(256)) * 2 + b"\r\n\x1a\x00\xff" + source_file.write_bytes(payload) + + clone = clone_world(source, "level", tmp_path / "work", "snapshot") + assert (clone / "db" / "000004.ldb").read_bytes() == payload + + manifest = SnapshotManifest( + "level", + (SnapshotEntry(PurePosixPath("level/db/000004.ldb"), len(payload)),), + ) + stage_manifest(source, tmp_path / "stage", manifest) + assert (tmp_path / "stage" / "level" / "db" / "000004.ldb").read_bytes() == payload From b115172b074bd62ed7c16ccc69307db64ea88831 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 01:51:23 +0800 Subject: [PATCH 15/33] style: satisfy Ruff after Windows clone diagnostics --- tests/test_online_management.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_online_management.py b/tests/test_online_management.py index 3164a6d4..cb06a301 100644 --- a/tests/test_online_management.py +++ b/tests/test_online_management.py @@ -124,9 +124,7 @@ def fingerprint(root: Path) -> list[tuple[str, int, str]]: if probe_state != [(b"a", b"final"), (b"c", b"3")]: with iter_visible_state(storage / "raw" / S3 / "level" / "db") as visible: raw_state = list(visible) - pytest.fail( - f"cloned DB lost records: raw={raw_state!r}; clone={probe_state!r}; files={raw_files!r}" - ) + pytest.fail(f"cloned DB lost records: raw={raw_state!r}; clone={probe_state!r}; files={raw_files!r}") third = Logicalizer(storage, compression_level=6, compression_threads=1).logicalize(storage / "raw" / S3) # Detect empty/misread Windows clone data before destructive chain edits. # Otherwise an empty DELTA could look like a successful delete/export. From 17d886ece5e782b85422639b2606071bd3fe9acd Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:00:17 +0800 Subject: [PATCH 16/33] style: resolve remaining Ruff formatting errors --- src/endstone_endkeep/staging/clone.py | 4 +--- tests/test_windows_compatibility.py | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/endstone_endkeep/staging/clone.py b/src/endstone_endkeep/staging/clone.py index 4c7a010c..e5bc4ec7 100644 --- a/src/endstone_endkeep/staging/clone.py +++ b/src/endstone_endkeep/staging/clone.py @@ -14,9 +14,7 @@ class CloneError(RuntimeError): def _copy_reflink_or_bytes(source: Path, destination: Path) -> None: source_fd = os.open(source, os.O_RDONLY | getattr(os, "O_BINARY", 0)) - destination_fd = os.open( - destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600 - ) + destination_fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600) try: cloned = False if os.name == "posix": diff --git a/tests/test_windows_compatibility.py b/tests/test_windows_compatibility.py index 18d45808..84c49168 100644 --- a/tests/test_windows_compatibility.py +++ b/tests/test_windows_compatibility.py @@ -48,6 +48,7 @@ def test_windows_junctions_cannot_escape_staging_or_clone(tmp_path: Path) -> Non with pytest.raises(CloneError): clone_world(tmp_path / "worlds", "level", tmp_path / "work", "snapshot") + def test_binary_staging_and_clone_are_byte_exact(tmp_path: Path) -> None: # LevelDB contains arbitrary binary bytes; Windows CRT text mode may # truncate at Ctrl-Z or change CRLF unless O_BINARY is explicit. From 3b61344d1a86cca443fe60f18e6dee0c1c0bbd67 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:04:26 +0800 Subject: [PATCH 17/33] ci: collect verified compatibility wheels and document six-platform release gates --- .github/workflows/compatibility.yml | 54 +++++++++++++++++++++++++++++ AGENTS.md | 11 ++++-- README.md | 7 +++- docs/manual-validation.md | 35 +++++++++++++++++-- docs/using-endkeep.md | 8 +++-- pyproject.toml | 2 +- tests/test_online_management.py | 35 ++----------------- tests/test_windows_compatibility.py | 19 ++++++++++ 8 files changed, 129 insertions(+), 42 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index ce9a1e71..661cf867 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -109,3 +109,57 @@ jobs: path: amulet-dist/*.whl if-no-files-found: error retention-days: 7 + + verified-backports: + name: Collect verified CPython 3.12/3.13 Wheels + needs: full-matrix + runs-on: ubuntu-24.04 + steps: + - name: Download four tested wheel artifacts + uses: actions/download-artifact@v4 + with: + pattern: amulet-compat-* + path: dist + merge-multiple: true + + - name: Validate Wheels and create SHA256 manifest + run: | + python - <<'PY' + from hashlib import sha256 + from pathlib import Path + from zipfile import ZipFile + + root = Path("dist") + wheels = sorted(root.glob("*.whl")) + assert len(wheels) == 4, [p.name for p in wheels] + for abi in ("cp312", "cp313"): + for platform in ("manylinux_", "win_amd64"): + matches = [ + p for p in wheels + if f"-{abi}-{abi}-" in p.name and platform in p.name + ] + assert len(matches) == 1, (abi, platform, [p.name for p in wheels]) + for wheel in wheels: + with ZipFile(wheel) as archive: + meta = next(path for path in archive.namelist() if path.endswith(".dist-info/METADATA")) + data = archive.read(meta).decode("utf-8") + assert "Name: amulet-leveldb" in data + assert "Version: 3.0.7a0+0.g736228e.dirty" in data + assert "Requires-Python: >=3.12" in data + assert any(path.endswith(".dist-info/licenses/LICENSE") for path in archive.namelist()) + (root / "SHA256SUMS").write_text("".join( + f"{sha256(path.read_bytes()).hexdigest()} {path.name}\n" + for path in wheels + )) + print((root / "SHA256SUMS").read_text()) + PY + + - name: Upload verified wheel set for review (not a GitHub Release) + uses: actions/upload-artifact@v4 + with: + name: amulet-backports-verified-four-wheels + path: | + dist/*.whl + dist/SHA256SUMS + if-no-files-found: error + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index ab0153e4..67e00425 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,10 @@ # Working on EndKeep EndKeep is a Python plugin for Endstone that creates incremental backups of -Minecraft Bedrock worlds. This repository targets Python 3.12–3.14 on Linux and Windows x86-64 -(Windows support is in progress) with Endstone 0.11. For Endstone API usage, see [Endstone's documentation](https://endstone.dev/). +Minecraft Bedrock worlds. The next version is being tested on Python 3.12–3.14 +with Linux and Windows x86-64 and Endstone 0.11; public releases are still +validated only for Linux / Python 3.14. For Endstone API usage, see +[Endstone's documentation](https://endstone.dev/). ## Project layout @@ -34,6 +36,11 @@ uv run pytest - Update user documentation for visible behavior changes. Explain what server owners need to do; keep internal implementation notes out of usage guides. - Do not create releases or rewrite version tags unless explicitly requested. +- Python 3.12/3.13 requires locally built Amulet-LevelDB 3.0.7a0 Wheels until + separate compatibility distributions are published; the repository's + existing uv.lock was created for the previous published dependency set. +- The six-combination compatibility workflow is not a substitute for + testing real BDS worlds, especially NTFS crash and directory metadata durability. See [Contributing](CONTRIBUTING.md) and the [release checks](docs/manual-validation.md) for the development workflow. diff --git a/README.md b/README.md index 0c873f68..024cb74a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ without storing a complete world copy each time, and lets you restore an earlier ## Installation -Requires **Linux**, **Python 3.14** and **Endstone 0.11**. Tested on Ubuntu 26.04 LTS (x86-64). +**Current public releases** require Linux x86-64, Python 3.14 and Endstone 0.11; +tested on Ubuntu 26.04 LTS. Development CI for the next version also covers +Linux/Windows x86-64 with Python 3.12–3.14. The Python 3.12/3.13 builds require +compatibility Wheels of Amulet-LevelDB that have not been published yet. +Do not install a development build on a production server without the +platform-specific dependency and backup/restore validation. 1. Download the plugin `.whl` from [Releases](https://github.com/ReallocAll/endkeep/releases/latest). 2. Place it in your server's `plugins/` directory. diff --git a/docs/manual-validation.md b/docs/manual-validation.md index fb524090..3d11bc51 100644 --- a/docs/manual-validation.md +++ b/docs/manual-validation.md @@ -3,8 +3,11 @@ These checks complement automated tests. Perform them with a release candidate on a test world before publishing a version. -**Validated environment:** Ubuntu 26.04 LTS x86-64, Python 3.14, Endstone 0.11. -The automated CI runner is not a substitute for testing a real Bedrock server. +**Existing live-server validation:** Ubuntu 26.04 LTS x86-64, Python 3.14, +Endstone 0.11. The cross-platform test matrix covers Python 3.12/3.13/3.14 on +Linux x86-64 and Windows x64, but CI is not a substitute for running a real BDS +instance. Until the steps below succeed on disposable Windows worlds, Windows +support is a development target rather than a production guarantee. ## Installation and backup @@ -29,3 +32,31 @@ The automated CI runner is not a substitute for testing a real Bedrock server. - Check that disabling bStats does not affect backup operation. Keep these tests on disposable worlds or independent copies of backup data. + +## Cross-platform release acceptance (Python 3.12–3.14) + +Complete the following for **each** Linux and Windows platform family. Do not +publish a compatibility claim merely because the six GitHub Actions jobs passed. + +1. Test the exact published plugin Wheel and a clean installation on Endstone + 0.11 for Python 3.12/3.13/3.14. The 3.12/3.13 compatibility Wheels of + Amulet-LevelDB must be publicly retrievable and their checksums verified; + CI artifacts expire and are not a production dependency source. +2. With an isolated BDS world, exercise `save hold`, `save query`, `save resume`, + and two snapshots containing LevelDB keys with binary data. Verify no tick + stall or held-save state persists after a failed capture. +3. Capture BASE and at least two DELTAs. Compare each exported state's records, + world files and SHA-256 with the original world at that recovery point. +4. Test delete-middle, rollover, GC, `verify deep`, and offline restore to a new + directory. Load the restored disposable world in BDS. +5. Crash a disposable backup worker while writing an incoming object, after an + object rename, after manifest publication and while updating HEAD. Restart, + verify the correct recovery point survives, and confirm that suspicious + repository states block destructive operations. +6. On Windows, separately test NTFS file locking/sharing, open LevelDB handles, + reparse points/junctions and interrupted file/directory rename sequences. + Windows' CRT does not provide POSIX directory-fsync behavior; passing unit + tests is not evidence of identical power-failure durability guarantees. + +Only then update README installation claims and enable normal end-user +distribution on the newly supported platform/Python versions. diff --git a/docs/using-endkeep.md b/docs/using-endkeep.md index 583d023c..8214dd43 100644 --- a/docs/using-endkeep.md +++ b/docs/using-endkeep.md @@ -6,9 +6,11 @@ It stores snapshots in the directory configured by `storage.path` (default: `bac ## Getting started Install the plugin wheel from [Releases](https://github.com/ReallocAll/endkeep/releases/latest) -into `plugins/` and restart Endstone. Requires Linux, Python 3.14 and Endstone 0.11. -Endstone downloads the required Python dependencies when the plugin loads; your -host must have internet access and a compatible Python 3.14 build of Amulet-LevelDB. +into `plugins/` and restart Endstone. **Current public releases** require Linux, +Python 3.14 and Endstone 0.11. Cross-platform Python 3.12–3.14 support is +under development; 3.12/3.13 also require unreleased Amulet-LevelDB compatibility +Wheels. Endstone downloads required dependencies during plugin installation, +so published compatible Wheels and internet access are prerequisites. Use `/backup status` to check that EndKeep loaded successfully. Use `/backup create` for an immediate snapshot, then `/backup maintenance` diff --git a/pyproject.toml b/pyproject.toml index 6bde9e3a..e23ccc17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ description = "Crash-safe logical incremental world backups for Endstone" readme = "README.md" license = { file = "LICENSE" } keywords = ["endstone", "minecraft", "bedrock", "backup", "leveldb"] -requires-python = ">=3.12" +requires-python = ">=3.12,<3.15" classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", diff --git a/tests/test_online_management.py b/tests/test_online_management.py index cb06a301..af10168a 100644 --- a/tests/test_online_management.py +++ b/tests/test_online_management.py @@ -1,7 +1,5 @@ from __future__ import annotations -import hashlib -import shutil import time from pathlib import Path from types import SimpleNamespace @@ -15,7 +13,6 @@ from endstone_endkeep.repository.maintenance import RepositoryService from endstone_endkeep.repository.manifest import ManifestStore from endstone_endkeep.repository.mutation import SnapshotMutator -from endstone_endkeep.staging.clone import clone_world from endstone_endkeep.worker.client import RepositoryWorkerClient, WorkerRequestIndeterminate, WorkerTimeout from endstone_endkeep.worker.server import WorkerApplication from tests.standalone_fixture import add_raw, build_fixture @@ -98,37 +95,9 @@ def test_mutating_middle_delta_retains_restore_state(tmp_path: Path) -> None: storage = tmp_path / "backups" build_fixture(storage) add_raw(storage, S3, [(b"a", b"final"), (b"c", b"3")], b"last") - # Diagnose the Windows corruption at the raw/clone boundary, before - # invoking the logical delta pipeline or altering any repository state. - probe_root = tmp_path / "probe" - probe = clone_world(storage / "raw" / S3, "level", probe_root, S3) - raw_db = storage / "raw" / S3 / "level" / "db" - - def fingerprint(root: Path) -> list[tuple[str, int, str]]: - return sorted( - ( - path.relative_to(root).as_posix(), - path.stat().st_size, - hashlib.sha256(path.read_bytes()).hexdigest()[:16], - ) - for path in root.rglob("*") - if path.is_file() - ) - - raw_files = fingerprint(raw_db) - clone_files = fingerprint(probe / "db") - assert raw_files == clone_files, f"clone files differ: raw={raw_files!r}, clone={clone_files!r}" - with iter_visible_state(probe / "db") as visible: - probe_state = list(visible) - shutil.rmtree(probe_root) - if probe_state != [(b"a", b"final"), (b"c", b"3")]: - with iter_visible_state(storage / "raw" / S3 / "level" / "db") as visible: - raw_state = list(visible) - pytest.fail(f"cloned DB lost records: raw={raw_state!r}; clone={probe_state!r}; files={raw_files!r}") third = Logicalizer(storage, compression_level=6, compression_threads=1).logicalize(storage / "raw" / S3) - # Detect empty/misread Windows clone data before destructive chain edits. - # Otherwise an empty DELTA could look like a successful delete/export. - assert third.records == 2, f"third snapshot unexpectedly empty before mutation: {third}" + # A Windows text-mode LevelDB copy previously silently erased this state. + assert third.records == 2 service = _service(storage) try: preview = service.plan_mutation("delete", S2) diff --git a/tests/test_windows_compatibility.py b/tests/test_windows_compatibility.py index 84c49168..06a2cb52 100644 --- a/tests/test_windows_compatibility.py +++ b/tests/test_windows_compatibility.py @@ -4,6 +4,7 @@ import os import subprocess +import sys from pathlib import Path, PurePosixPath import pytest @@ -67,3 +68,21 @@ def test_binary_staging_and_clone_are_byte_exact(tmp_path: Path) -> None: ) stage_manifest(source, tmp_path / "stage", manifest) assert (tmp_path / "stage" / "level" / "db" / "000004.ldb").read_bytes() == payload + + +def test_worker_process_liveness_tracks_real_exit() -> None: + process = subprocess.Popen( + [sys.executable, "-c", "import sys; sys.stdin.buffer.read(1)"], + stdin=subprocess.PIPE, + ) + try: + assert RepositoryWorkerClient._pid_alive(process.pid) + assert process.stdin is not None + process.stdin.write(b"x") + process.stdin.close() + assert process.wait(timeout=5) == 0 + assert not RepositoryWorkerClient._pid_alive(process.pid) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) From b12091e38544d6278054e9f40160a2f3add70844 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:05:28 +0800 Subject: [PATCH 18/33] test: require fatal data-file flush errors on Linux and Windows --- tests/test_windows_compatibility.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_windows_compatibility.py b/tests/test_windows_compatibility.py index 06a2cb52..ab10d968 100644 --- a/tests/test_windows_compatibility.py +++ b/tests/test_windows_compatibility.py @@ -9,9 +9,11 @@ import pytest +import endstone_endkeep.offline.cli as offline_cli from endstone_endkeep.bds.model import SnapshotEntry, SnapshotManifest from endstone_endkeep.staging.clone import CloneError, clone_world from endstone_endkeep.staging.exact import ExactStageError, stage_manifest +from endstone_endkeep.staging.raw import RawSnapshotStore from endstone_endkeep.worker.client import RepositoryWorkerClient @@ -86,3 +88,17 @@ def test_worker_process_liveness_tracks_real_exit() -> None: if process.poll() is None: process.terminate() process.wait(timeout=5) + + +@pytest.mark.parametrize("sync_tree", [offline_cli._fsync_tree, RawSnapshotStore._fsync_tree]) +def test_backup_file_flush_failure_is_fatal_on_every_platform(tmp_path: Path, monkeypatch, sync_tree) -> None: + root = tmp_path / "world" + root.mkdir() + (root / "level.dat").write_bytes(b"data") + + def fail_fsync(_fd: int) -> None: + raise OSError("injected file flush failure") + + monkeypatch.setattr(os, "fsync", fail_fsync) + with pytest.raises(OSError, match="injected file flush failure"): + sync_tree(root) From e4c84830342afb1e0685d68bdd1b183acbd85481 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:21:07 +0800 Subject: [PATCH 19/33] fix(windows): restore SIDECAR files with binary CRT descriptors --- src/endstone_endkeep/logical/sidecar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/endstone_endkeep/logical/sidecar.py b/src/endstone_endkeep/logical/sidecar.py index 13b9b2bb..5a324117 100644 --- a/src/endstone_endkeep/logical/sidecar.py +++ b/src/endstone_endkeep/logical/sidecar.py @@ -108,7 +108,7 @@ def extract_sidecar( if parent != root and root not in parent.parents: raise LogicalFormatError(f"SIDECAR path escapes restore root: {path_text!r}") - fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600) try: remaining = size while remaining: From 9f828ff6693fa473d7b3d52f16657239ea42e90f Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:21:09 +0800 Subject: [PATCH 20/33] test(windows): cover arbitrary binary SIDECAR restore --- tests/test_sidecar.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index f259bac1..5f868c16 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -47,3 +47,26 @@ def test_sidecar_restore_strips_world_prefix(tmp_path: Path) -> None: extract_sidecar(archive, destination, strip_prefix="level") assert (destination / "level.dat").read_bytes() == b"abc" assert not (destination / "level").exists() + + +def test_sidecar_restore_preserves_binary_data(tmp_path: Path) -> None: + # Windows CRT text mode expands LF and can corrupt arbitrary binary data. + raw = tmp_path / "raw" + source = raw / "level" / "level.dat" + source.parent.mkdir(parents=True) + payload = bytes(range(256)) * 2 + b"\r\n\x1a\x00\xff" + source.write_bytes(payload) + + archive = BytesIO() + write_sidecar( + archive, + raw, + (SnapshotEntry(PurePosixPath("level/level.dat"), len(payload)),), + ) + + destination = tmp_path / "restored-world" + destination.mkdir() + archive.seek(0) + restored = extract_sidecar(archive, destination, strip_prefix="level") + assert (restored.files, restored.bytes) == (1, len(payload)) + assert (destination / "level.dat").read_bytes() == payload From f1caa7ad4f60fdd6ea348435eae0fd90a1a4111a Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:23:30 +0800 Subject: [PATCH 21/33] fix(staging): reject unsafe and foreign-world paths before filesystem mutation --- src/endstone_endkeep/staging/exact.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/endstone_endkeep/staging/exact.py b/src/endstone_endkeep/staging/exact.py index c1e93463..d0198664 100644 --- a/src/endstone_endkeep/staging/exact.py +++ b/src/endstone_endkeep/staging/exact.py @@ -27,10 +27,24 @@ def _check_cancelled(cancel: Event | None) -> None: raise ExactStageError("staging cancelled") +def _validate_relative_path(relative: PurePosixPath) -> None: + if ( + not relative.parts + or relative.is_absolute() + or any(part in ("", ".", "..") or "\\" in part or ":" in part for part in relative.parts) + ): + raise ExactStageError(f"unsafe snapshot path: {relative}") + + def _safe_destination(root: Path, relative: PurePosixPath) -> Path: + _validate_relative_path(relative) target = root.joinpath(*relative.parts) root_resolved = root.resolve() target_parent = target.parent + # Validate before mkdir: a malformed path must not create directories outside staging. + parent_resolved = target_parent.resolve(strict=False) + if parent_resolved != root_resolved and root_resolved not in parent_resolved.parents: + raise ExactStageError(f"destination escapes staging root: {relative}") target_parent.mkdir(parents=True, exist_ok=True) try: parent_resolved = target_parent.resolve(strict=True) @@ -83,8 +97,7 @@ def _open_source_portable(root: Path, relative: PurePosixPath) -> int: def _open_source(root: Path, relative: PurePosixPath) -> int: - if not relative.parts or relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts): - raise ExactStageError(f"unsafe source path: {relative}") + _validate_relative_path(relative) try: if os.name == "posix" and os.supports_dir_fd: return _open_source_linux(root, relative) @@ -152,6 +165,10 @@ def stage_manifest( """Copy exactly the save-query byte limits and nothing else.""" started = time.monotonic() + for entry in manifest.entries: + _validate_relative_path(entry.path) + if entry.path.parts[0] != manifest.world_name: + raise ExactStageError(f"snapshot path is outside world prefix: {entry.path}") destination_root.mkdir(parents=True, exist_ok=False) copied_bytes = 0 From 37be46fe9f459740287f59ad0531e7af4151f7ea Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:23:33 +0800 Subject: [PATCH 22/33] fix(bds): reject Windows drive and ADS paths in save query output --- src/endstone_endkeep/bds/query_parser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/endstone_endkeep/bds/query_parser.py b/src/endstone_endkeep/bds/query_parser.py index 904f6929..83cadfe1 100644 --- a/src/endstone_endkeep/bds/query_parser.py +++ b/src/endstone_endkeep/bds/query_parser.py @@ -118,3 +118,6 @@ def _validate_relative_path(path: PurePosixPath) -> None: raise QueryManifestError(f"unsafe relative path: {raw!r}") if "\\" in raw: raise QueryManifestError(f"backslash path is forbidden in query manifest: {raw!r}") + if ":" in raw: + # Windows drive prefixes and alternate data streams are not world files. + raise QueryManifestError(f"colon is forbidden in query manifest path: {raw!r}") From 72624714f67b12a6f8eb7053f2fee3f833d2b7cf Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:23:36 +0800 Subject: [PATCH 23/33] test(staging): fail closed before creating unsafe directories --- tests/test_exact_staging.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_exact_staging.py b/tests/test_exact_staging.py index 2e8dce45..e7470a60 100644 --- a/tests/test_exact_staging.py +++ b/tests/test_exact_staging.py @@ -55,3 +55,21 @@ def test_reject_source_symlink(tmp_path: Path) -> None: ) with pytest.raises(ExactStageError): stage_manifest(source, tmp_path / "stage", manifest) + + +def test_reject_escaping_manifest_before_creating_directories(tmp_path: Path) -> None: + source = tmp_path / "worlds" + malicious = PurePosixPath("level/../../outside-created/level.dat") + manifest = SnapshotManifest("level", (SnapshotEntry(malicious, 1),)) + with pytest.raises(ExactStageError, match="unsafe snapshot path"): + stage_manifest(source, tmp_path / "stage", manifest) + assert not (tmp_path / "outside-created").exists() + assert not (tmp_path / "stage").exists() + + +@pytest.mark.parametrize("path", [r"level\\..\\elsewhere\\level.dat", "level/C:/outside/level.dat", "other/level.dat"]) +def test_reject_nonportable_or_foreign_world_paths_before_staging(tmp_path: Path, path: str) -> None: + manifest = SnapshotManifest("level", (SnapshotEntry(PurePosixPath(path), 1),)) + with pytest.raises(ExactStageError): + stage_manifest(tmp_path / "worlds", tmp_path / "stage", manifest) + assert not (tmp_path / "stage").exists() From 51fd7628abb42f1e0b7603fcd79a866e0628713e Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:23:42 +0800 Subject: [PATCH 24/33] test(bds): reject drive and alternate-stream manifests --- tests/test_query_parser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_query_parser.py b/tests/test_query_parser.py index 89c19347..265b9cfd 100644 --- a/tests/test_query_parser.py +++ b/tests/test_query_parser.py @@ -36,6 +36,8 @@ def test_parse_manifest_from_translation_parameter() -> None: "level/db/A:1, other/db/B:1", "level/db/A:not-a-number", "level/db/A:1, level/db/A:2", + "level/C:/outside/level.dat:1", + "level/level.dat:stream:1", ], ) def test_reject_unsafe_or_malformed_manifest(payload: str) -> None: From 09f8d3fa6b5d60f6a2332d489537edf0b4b72be9 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:24:14 +0800 Subject: [PATCH 25/33] fix(restore): validate SIDECAR paths before creating output directories --- src/endstone_endkeep/logical/sidecar.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/endstone_endkeep/logical/sidecar.py b/src/endstone_endkeep/logical/sidecar.py index 5a324117..67748dae 100644 --- a/src/endstone_endkeep/logical/sidecar.py +++ b/src/endstone_endkeep/logical/sidecar.py @@ -85,7 +85,9 @@ def extract_sidecar( except UnicodeDecodeError as exc: raise LogicalFormatError("SIDECAR contains a non-UTF-8 path") from exc path = PurePosixPath(path_text) - if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts): + if path.is_absolute() or any( + part in ("", ".", "..") or "\\" in part or ":" in part for part in path.parts + ): raise LogicalFormatError(f"unsafe SIDECAR path: {path_text!r}") if previous is not None and path_text <= previous: raise LogicalFormatError("SIDECAR paths are not strictly increasing") @@ -103,8 +105,13 @@ def extract_sidecar( size = _read_uvarint(stream) target = destination_root.joinpath(*relative_parts) + # Resolve existing path components before creating directories; a malicious + # archive must not create anything outside the restore destination. + parent = target.parent.resolve(strict=False) + if parent != root and root not in parent.parents: + raise LogicalFormatError(f"SIDECAR path escapes restore root: {path_text!r}") target.parent.mkdir(parents=True, exist_ok=True) - parent = target.parent.resolve() + parent = target.parent.resolve(strict=True) if parent != root and root not in parent.parents: raise LogicalFormatError(f"SIDECAR path escapes restore root: {path_text!r}") From afe515081001318e332a1b2f53b7f131e5df9b87 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:24:17 +0800 Subject: [PATCH 26/33] test(restore): reject SIDECAR traversal and Windows ADS payloads --- tests/test_sidecar.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index 5f868c16..3b1d7795 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -3,8 +3,12 @@ from io import BytesIO from pathlib import Path, PurePosixPath +import pytest + +from endstone_endkeep.logical.format import LogicalFormatError, encode_uvarint + from endstone_endkeep.bds.model import SnapshotEntry -from endstone_endkeep.logical.sidecar import extract_sidecar, write_sidecar +from endstone_endkeep.logical.sidecar import SIDECAR_MAGIC, extract_sidecar, write_sidecar def test_sidecar_roundtrip(tmp_path: Path) -> None: @@ -70,3 +74,25 @@ def test_sidecar_restore_preserves_binary_data(tmp_path: Path) -> None: restored = extract_sidecar(archive, destination, strip_prefix="level") assert (restored.files, restored.bytes) == (1, len(payload)) assert (destination / "level.dat").read_bytes() == payload + + +@pytest.mark.parametrize( + "path_text", + [ + "level/../../outside-created/level.dat", + r"level\\..\\outside-created\\level.dat", + "level/C:/outside-created/level.dat", + "level/level.dat:stream", + ], +) +def test_sidecar_rejects_escaping_or_windows_paths(tmp_path: Path, path_text: str) -> None: + raw_path = path_text.encode("utf-8") + archive = BytesIO( + SIDECAR_MAGIC + encode_uvarint(len(raw_path)) + raw_path + encode_uvarint(1) + b"x" + ) + destination = tmp_path / "restored-world" + destination.mkdir() + with pytest.raises(LogicalFormatError, match="unsafe SIDECAR path"): + extract_sidecar(archive, destination, strip_prefix="level") + assert not (tmp_path / "outside-created").exists() + assert list(destination.iterdir()) == [] From 5425ab206f394123063af5b3a12b754cd7ef30be Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:25:50 +0800 Subject: [PATCH 27/33] style: organize SIDECAR test imports and formatting --- tests/test_sidecar.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index 3b1d7795..7fff3ab7 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -5,9 +5,8 @@ import pytest -from endstone_endkeep.logical.format import LogicalFormatError, encode_uvarint - from endstone_endkeep.bds.model import SnapshotEntry +from endstone_endkeep.logical.format import LogicalFormatError, encode_uvarint from endstone_endkeep.logical.sidecar import SIDECAR_MAGIC, extract_sidecar, write_sidecar @@ -87,9 +86,7 @@ def test_sidecar_restore_preserves_binary_data(tmp_path: Path) -> None: ) def test_sidecar_rejects_escaping_or_windows_paths(tmp_path: Path, path_text: str) -> None: raw_path = path_text.encode("utf-8") - archive = BytesIO( - SIDECAR_MAGIC + encode_uvarint(len(raw_path)) + raw_path + encode_uvarint(1) + b"x" - ) + archive = BytesIO(SIDECAR_MAGIC + encode_uvarint(len(raw_path)) + raw_path + encode_uvarint(1) + b"x") destination = tmp_path / "restored-world" destination.mkdir() with pytest.raises(LogicalFormatError, match="unsafe SIDECAR path"): From dee5501d04c94e2d5fadfbaf131500157318182e Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:25:52 +0800 Subject: [PATCH 28/33] style: format SIDECAR path validation --- src/endstone_endkeep/logical/sidecar.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/endstone_endkeep/logical/sidecar.py b/src/endstone_endkeep/logical/sidecar.py index 67748dae..ef0958de 100644 --- a/src/endstone_endkeep/logical/sidecar.py +++ b/src/endstone_endkeep/logical/sidecar.py @@ -85,9 +85,7 @@ def extract_sidecar( except UnicodeDecodeError as exc: raise LogicalFormatError("SIDECAR contains a non-UTF-8 path") from exc path = PurePosixPath(path_text) - if path.is_absolute() or any( - part in ("", ".", "..") or "\\" in part or ":" in part for part in path.parts - ): + if path.is_absolute() or any(part in ("", ".", "..") or "\\" in part or ":" in part for part in path.parts): raise LogicalFormatError(f"unsafe SIDECAR path: {path_text!r}") if previous is not None and path_text <= previous: raise LogicalFormatError("SIDECAR paths are not strictly increasing") From 8e5c92d9b2931700d63265ba679612c8764f0cb4 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:51:14 +0800 Subject: [PATCH 29/33] feat(deps): resolve pinned Amulet compatibility Wheels automatically on Python 3.12/3.13 --- .github/workflows/compatibility.yml | 13 ++++++++++--- AGENTS.md | 6 +++--- README.md | 9 +++++---- docs/using-endkeep.md | 10 ++++++---- pyproject.toml | 7 ++++++- requirements-offline.txt | 6 +++++- 6 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 661cf867..34b96c08 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -89,10 +89,17 @@ jobs: else PYTHON=.venv/bin/python fi - if [[ "${{ matrix.python }}" != "3.14" ]]; then - uv pip install --python "$PYTHON" amulet-dist/*.whl - fi + # Fresh dependency resolution: do NOT seed the local Amulet build. + # This must fetch the exact published GitHub Release Wheel on 3.12/3.13. uv pip install --python "$PYTHON" -e '.[dev]' + "$PYTHON" - <<'PY' + from importlib.metadata import distribution + from packaging.requirements import Requirement + requires = distribution("endstone-endkeep").requires or [] + amulet = [Requirement(req) for req in requires if Requirement(req).name == "amulet-leveldb"] + assert len(amulet) == 5, [str(req) for req in amulet] + print("Packaged Amulet selectors:", *amulet, sep="\\n") + PY uv pip check --python "$PYTHON" "$PYTHON" - <<'PY' from importlib.metadata import version diff --git a/AGENTS.md b/AGENTS.md index 67e00425..cfe2df21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,9 +36,9 @@ uv run pytest - Update user documentation for visible behavior changes. Explain what server owners need to do; keep internal implementation notes out of usage guides. - Do not create releases or rewrite version tags unless explicitly requested. -- Python 3.12/3.13 requires locally built Amulet-LevelDB 3.0.7a0 Wheels until - separate compatibility distributions are published; the repository's - existing uv.lock was created for the previous published dependency set. +- Python 3.12/3.13 resolves pinned, SHA256-verified Amulet-LevelDB compatibility + Wheels from the dedicated GitHub Release. Test a clean EndKeep install without + pre-installing the dependency. The uv.lock file predates this dependency change. - The six-combination compatibility workflow is not a substitute for testing real BDS worlds, especially NTFS crash and directory metadata durability. diff --git a/README.md b/README.md index 024cb74a..813e41a2 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ without storing a complete world copy each time, and lets you restore an earlier **Current public releases** require Linux x86-64, Python 3.14 and Endstone 0.11; tested on Ubuntu 26.04 LTS. Development CI for the next version also covers -Linux/Windows x86-64 with Python 3.12–3.14. The Python 3.12/3.13 builds require -compatibility Wheels of Amulet-LevelDB that have not been published yet. -Do not install a development build on a production server without the -platform-specific dependency and backup/restore validation. +Linux/Windows x86-64 with Python 3.12–3.14. For Python 3.12/3.13, the plugin automatically downloads matching, pinned, +unofficial Amulet-LevelDB compatibility Wheels from our +[compatibility Release](https://github.com/ReallocAll/endkeep/releases/tag/amulet-leveldb-3.0.7a0-compat-cp312-cp313). +These builds are for x86-64 Linux and Windows only. Python 3.14 still uses +upstream PyPI. Production rollout still requires live BDS validation. 1. Download the plugin `.whl` from [Releases](https://github.com/ReallocAll/endkeep/releases/latest). 2. Place it in your server's `plugins/` directory. diff --git a/docs/using-endkeep.md b/docs/using-endkeep.md index 8214dd43..af6ed7ce 100644 --- a/docs/using-endkeep.md +++ b/docs/using-endkeep.md @@ -7,10 +7,12 @@ It stores snapshots in the directory configured by `storage.path` (default: `bac Install the plugin wheel from [Releases](https://github.com/ReallocAll/endkeep/releases/latest) into `plugins/` and restart Endstone. **Current public releases** require Linux, -Python 3.14 and Endstone 0.11. Cross-platform Python 3.12–3.14 support is -under development; 3.12/3.13 also require unreleased Amulet-LevelDB compatibility -Wheels. Endstone downloads required dependencies during plugin installation, -so published compatible Wheels and internet access are prerequisites. +Python 3.14 and Endstone 0.11. Cross-platform Python 3.12–3.14 support is under validation. For Python +3.12/3.13 on Linux/Windows x64, the plugin automatically fetches the pinned +Amulet-LevelDB compatibility Wheel from the +[dedicated Release](https://github.com/ReallocAll/endkeep/releases/tag/amulet-leveldb-3.0.7a0-compat-cp312-cp313). +Python 3.14 uses the official upstream dependency. The host must reach +GitHub Releases and PyPI for the initial dependency installation. Use `/backup status` to check that EndKeep loaded successfully. Use `/backup create` for an immediate snapshot, then `/backup maintenance` diff --git a/pyproject.toml b/pyproject.toml index e23ccc17..1afb4010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,12 @@ name = "endstone-endkeep" dynamic = ["version"] dependencies = [ "endstone>=0.11,<0.12", - "amulet-leveldb==3.0.7a0", + # Official upstream Wheel for Python 3.14; pinned, tested backports for 3.12 and 3.13. + "amulet-leveldb==3.0.7a0; python_version == '3.14'", + "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=283e7e57e4d15d7ee59457a89db8c43be9a1cd85c7174844f3a1f1bd14d93bbd ; python_version == "3.12" and sys_platform == "linux" and platform_machine == "x86_64"", + "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-win_amd64.whl#sha256=9033156a570ceef3613486a681b990f442788b636da1018838eb6ffdd3dca73f ; python_version == "3.12" and sys_platform == "win32" and platform_machine == "AMD64"", + "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=718d5ff3887b4d848f811a49ab6a7fe3e0b1f863821029190714c4ea5362d9fc ; python_version == "3.13" and sys_platform == "linux" and platform_machine == "x86_64"", + "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-win_amd64.whl#sha256=28dc6037e3d16355a5120baba1ec61065d020f65500ca9de20d97deaaac0ab2f ; python_version == "3.13" and sys_platform == "win32" and platform_machine == "AMD64"", "zstandard>=0.23,<1", "tomlkit>=0.13,<1", ] diff --git a/requirements-offline.txt b/requirements-offline.txt index 0b38f794..22b64a6e 100644 --- a/requirements-offline.txt +++ b/requirements-offline.txt @@ -1,3 +1,7 @@ -amulet-leveldb==3.0.7a0 +amulet-leveldb==3.0.7a0 ; python_version == '3.14' +amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=283e7e57e4d15d7ee59457a89db8c43be9a1cd85c7174844f3a1f1bd14d93bbd ; python_version == "3.12" and sys_platform == "linux" and platform_machine == "x86_64" +amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-win_amd64.whl#sha256=9033156a570ceef3613486a681b990f442788b636da1018838eb6ffdd3dca73f ; python_version == "3.12" and sys_platform == "win32" and platform_machine == "AMD64" +amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=718d5ff3887b4d848f811a49ab6a7fe3e0b1f863821029190714c4ea5362d9fc ; python_version == "3.13" and sys_platform == "linux" and platform_machine == "x86_64" +amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-win_amd64.whl#sha256=28dc6037e3d16355a5120baba1ec61065d020f65500ca9de20d97deaaac0ab2f ; python_version == "3.13" and sys_platform == "win32" and platform_machine == "AMD64" zstandard>=0.23,<1 tqdm>=4.70,<5 From 8c4c8bb7d7926ab52b861628c7b8f79395227254 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:51:59 +0800 Subject: [PATCH 30/33] fix(deps): use TOML literal strings for PEP 508 marker expressions --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1afb4010..40dff2b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,10 @@ dependencies = [ "endstone>=0.11,<0.12", # Official upstream Wheel for Python 3.14; pinned, tested backports for 3.12 and 3.13. "amulet-leveldb==3.0.7a0; python_version == '3.14'", - "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=283e7e57e4d15d7ee59457a89db8c43be9a1cd85c7174844f3a1f1bd14d93bbd ; python_version == "3.12" and sys_platform == "linux" and platform_machine == "x86_64"", - "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-win_amd64.whl#sha256=9033156a570ceef3613486a681b990f442788b636da1018838eb6ffdd3dca73f ; python_version == "3.12" and sys_platform == "win32" and platform_machine == "AMD64"", - "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=718d5ff3887b4d848f811a49ab6a7fe3e0b1f863821029190714c4ea5362d9fc ; python_version == "3.13" and sys_platform == "linux" and platform_machine == "x86_64"", - "amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-win_amd64.whl#sha256=28dc6037e3d16355a5120baba1ec61065d020f65500ca9de20d97deaaac0ab2f ; python_version == "3.13" and sys_platform == "win32" and platform_machine == "AMD64"", + 'amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=283e7e57e4d15d7ee59457a89db8c43be9a1cd85c7174844f3a1f1bd14d93bbd ; python_version == "3.12" and sys_platform == "linux" and platform_machine == "x86_64"', + 'amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp312-cp312-win_amd64.whl#sha256=9033156a570ceef3613486a681b990f442788b636da1018838eb6ffdd3dca73f ; python_version == "3.12" and sys_platform == "win32" and platform_machine == "AMD64"', + 'amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl#sha256=718d5ff3887b4d848f811a49ab6a7fe3e0b1f863821029190714c4ea5362d9fc ; python_version == "3.13" and sys_platform == "linux" and platform_machine == "x86_64"', + 'amulet-leveldb @ https://github.com/ReallocAll/endkeep/releases/download/amulet-leveldb-3.0.7a0-compat-cp312-cp313/amulet_leveldb-3.0.7a0%2B0.g736228e.dirty-cp313-cp313-win_amd64.whl#sha256=28dc6037e3d16355a5120baba1ec61065d020f65500ca9de20d97deaaac0ab2f ; python_version == "3.13" and sys_platform == "win32" and platform_machine == "AMD64"', "zstandard>=0.23,<1", "tomlkit>=0.13,<1", ] From 21a2389617c8c0e43f90abfde8872b8aca6e0db0 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:52:53 +0800 Subject: [PATCH 31/33] fix(build): ignore non-EndKeep tags during hatch-vcs version resolution --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 40dff2b3..b86f3b4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,11 @@ addopts = "-ra" [tool.hatch.version] source = "vcs" +[tool.hatch.version.raw-options] +# Ignore the separately published Amulet compatibility tag in this repository. +# Only EndKeep vX.Y.Z tags should control the plugin version. +git_describe_command = "git describe --dirty --tags --long --match 'v[0-9]*'" + [tool.hatch.build.hooks.vcs] version-file = "src/endstone_endkeep/_version.py" From e8fc4b325da4a12d7ae671e1aa336609c8d21418 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 02:54:46 +0800 Subject: [PATCH 32/33] fix(build): allow pinned GitHub Wheel references in hatchling metadata --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b86f3b4a..3999881c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,9 @@ select = ["I", "E", "F", "UP", "B"] testpaths = ["tests"] addopts = "-ra" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.version] source = "vcs" From 92d32c50cc076ea5d4f3b82caabf2a081e5648c3 Mon Sep 17 00:00:00 2001 From: ReallocAll Date: Sat, 10 Oct 2026 03:06:29 +0800 Subject: [PATCH 33/33] docs: publish tested platform requirements and BDS validation evidence --- AGENTS.md | 17 +++++++++-------- CHANGELOG.md | 14 ++++++++++++++ CONTRIBUTING.md | 15 ++++++++++----- README.md | 17 +++++++++-------- docs/manual-validation.md | 29 +++++++++++++++++++---------- docs/recovery.md | 9 +++++++-- docs/using-endkeep.md | 13 ++++++------- 7 files changed, 74 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cfe2df21..3cbd7a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,8 @@ # Working on EndKeep EndKeep is a Python plugin for Endstone that creates incremental backups of -Minecraft Bedrock worlds. The next version is being tested on Python 3.12–3.14 -with Linux and Windows x86-64 and Endstone 0.11; public releases are still -validated only for Linux / Python 3.14. For Endstone API usage, see +Minecraft Bedrock worlds. EndKeep targets Python 3.12–3.14 +on Linux and Windows x86-64 with Endstone 0.11. For Endstone API usage, see [Endstone's documentation](https://endstone.dev/). ## Project layout @@ -18,10 +17,11 @@ validated only for Linux / Python 3.14. For Endstone API usage, see ## Development ```sh -uv sync --python 3.14 --extra dev -uv run ruff check src tests tools -uv run ruff format --check src tests tools -uv run pytest +uv venv --python 3.14 +uv pip install --python .venv -e ".[dev]" +uv run --no-sync ruff check src tests tools +uv run --no-sync ruff format --check src tests tools +uv run --no-sync pytest ``` ## Important constraints @@ -38,7 +38,8 @@ uv run pytest - Do not create releases or rewrite version tags unless explicitly requested. - Python 3.12/3.13 resolves pinned, SHA256-verified Amulet-LevelDB compatibility Wheels from the dedicated GitHub Release. Test a clean EndKeep install without - pre-installing the dependency. The uv.lock file predates this dependency change. + pre-installing the dependency. The upstream Amulet version metadata currently prevents regenerating a + universal uv.lock; prefer `uv pip install` instead of `uv sync`. - The six-combination compatibility workflow is not a substitute for testing real BDS worlds, especially NTFS crash and directory metadata durability. diff --git a/CHANGELOG.md b/CHANGELOG.md index b496b71d..1b2825a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Support Python 3.12–3.14 on Linux and Windows x86-64. +- Automatically resolve pinned Amulet-LevelDB compatibility Wheels for Python 3.12/3.13. +- Add cross-platform CI and real BDS tests through bds-test-lab. + +### Fixed +- Preserve binary LevelDB and sidecar data on Windows during backup and recovery. +- Improve worker process handling, path safety and filesystem compatibility. +- Exclude non-EndKeep tags from VCS version generation. + +### Documentation +- Update installation, recovery and platform validation guides. + + ## [0.2.0] - 2026-10-09 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7643ce7e..0add9030 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,15 +2,17 @@ Bug reports, fixes and documentation improvements are welcome. -EndKeep targets Linux, Python 3.14 and Endstone 0.11. To work on the project: +EndKeep supports Linux and Windows x86-64, Python 3.12–3.14 and Endstone 0.11. +To work on the project: ```sh git clone https://github.com/ReallocAll/endkeep.git cd endkeep -uv sync --python 3.14 --extra dev -uv run ruff check src tests tools -uv run ruff format --check src tests tools -uv run pytest +uv venv --python 3.14 +uv pip install --python .venv -e ".[dev]" +uv run --no-sync ruff check src tests tools +uv run --no-sync ruff format --check src tests tools +uv run --no-sync pytest ``` Open a pull request with a short explanation of the change and tests for @@ -19,3 +21,6 @@ on a disposable repository; see [Release checks](docs/manual-validation.md). For bugs, use [GitHub Issues](https://github.com/ReallocAll/endkeep/issues). For security reports, see [SECURITY.md](SECURITY.md). + +The upstream Amulet package currently contains version metadata rejected by +some `uv lock` versions. Use `uv pip install` as above for development. diff --git a/README.md b/README.md index 813e41a2..8c39f772 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,20 @@ without storing a complete world copy each time, and lets you restore an earlier ## Installation -**Current public releases** require Linux x86-64, Python 3.14 and Endstone 0.11; -tested on Ubuntu 26.04 LTS. Development CI for the next version also covers -Linux/Windows x86-64 with Python 3.12–3.14. For Python 3.12/3.13, the plugin automatically downloads matching, pinned, -unofficial Amulet-LevelDB compatibility Wheels from our -[compatibility Release](https://github.com/ReallocAll/endkeep/releases/tag/amulet-leveldb-3.0.7a0-compat-cp312-cp313). -These builds are for x86-64 Linux and Windows only. Python 3.14 still uses -upstream PyPI. Production rollout still requires live BDS validation. +Requires **Linux or Windows x86-64**, **Python 3.12–3.14** and **Endstone 0.11**. +Verified on BDS 1.26.52.3 / Endstone 0.11.13 on Linux and Windows. +Python 3.12/3.13 automatically install SHA-256-pinned, unofficial +Amulet-LevelDB compatibility Wheels from the +[compatibility Release](https://github.com/ReallocAll/endkeep/releases/tag/amulet-leveldb-3.0.7a0-compat-cp312-cp313); Python 3.14 +uses the upstream PyPI package. GitHub Releases and PyPI must be reachable +for the initial installation. 1. Download the plugin `.whl` from [Releases](https://github.com/ReallocAll/endkeep/releases/latest). 2. Place it in your server's `plugins/` directory. 3. Restart the server. Endstone installs the required Python packages automatically. -The server needs internet access and compatible dependency packages for the initial installation. +On Windows, sudden-power-loss durability may differ from POSIX directory fsync. +Keep an independent off-server copy of backups. See [Using EndKeep](docs/using-endkeep.md) for details. ## Quick start diff --git a/docs/manual-validation.md b/docs/manual-validation.md index 3d11bc51..ddfdfcfc 100644 --- a/docs/manual-validation.md +++ b/docs/manual-validation.md @@ -3,11 +3,13 @@ These checks complement automated tests. Perform them with a release candidate on a test world before publishing a version. -**Existing live-server validation:** Ubuntu 26.04 LTS x86-64, Python 3.14, -Endstone 0.11. The cross-platform test matrix covers Python 3.12/3.13/3.14 on -Linux x86-64 and Windows x64, but CI is not a substitute for running a real BDS -instance. Until the steps below succeed on disposable Windows worlds, Windows -support is a development target rather than a production guarantee. +**Automated validation:** Python 3.12/3.13/3.14 on Linux x86-64 and Windows +x64; [six-combination CI](https://github.com/ReallocAll/endkeep/actions/runs/37976573011). +Real BDS 1.26.52.3 / Endstone 0.11.13 with Python 3.14 passed on both platforms +in [bds-test-lab](https://github.com/ReallocAll/bds-test-lab/actions/runs/37977425842): +two online snapshots, maintenance, deep verify, export, graceful BDS stop, +explicit authenticated worker shutdown and offline restore. The tests do +not establish equivalent NTFS/POSIX sudden-power-loss guarantees. ## Installation and backup @@ -35,13 +37,14 @@ Keep these tests on disposable worlds or independent copies of backup data. ## Cross-platform release acceptance (Python 3.12–3.14) -Complete the following for **each** Linux and Windows platform family. Do not -publish a compatibility claim merely because the six GitHub Actions jobs passed. +The Python 3.14 smoke scenarios were completed on disposable Linux and Windows +BDS worlds. Repeat the following when validating additional server versions, +large repositories or production configurations. 1. Test the exact published plugin Wheel and a clean installation on Endstone 0.11 for Python 3.12/3.13/3.14. The 3.12/3.13 compatibility Wheels of Amulet-LevelDB must be publicly retrievable and their checksums verified; - CI artifacts expire and are not a production dependency source. + EndKeep uses the fixed compatibility Release rather than expiring CI artifacts. 2. With an isolated BDS world, exercise `save hold`, `save query`, `save resume`, and two snapshots containing LevelDB keys with binary data. Verify no tick stall or held-save state persists after a failed capture. @@ -58,5 +61,11 @@ publish a compatibility claim merely because the six GitHub Actions jobs passed. Windows' CRT does not provide POSIX directory-fsync behavior; passing unit tests is not evidence of identical power-failure durability guarantees. -Only then update README installation claims and enable normal end-user -distribution on the newly supported platform/Python versions. +The repository worker intentionally stays alive across plugin reloads. +The headless lab explicitly shuts down the authenticated worker after +stopping BDS so the process tree is clean; this is separate from checking +whether BDS itself stops normally. + +Sudden power failure cannot be simulated by clean shutdown. The Windows +NTFS directory-metadata durability guarantees remain different from POSIX +directory fsync; test these separately before making stronger claims. diff --git a/docs/recovery.md b/docs/recovery.md index c09fc942..992b4037 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -22,10 +22,10 @@ Download `endkeep-offline.py` and `requirements-offline.txt` from the same [EndKeep release](https://github.com/ReallocAll/endkeep/releases/latest). The standalone restore tool does not require a working BDS or Endstone installation. -Stop the server before restoring. On a Linux host with Python 3.14, run: +Stop the server before restoring. On Linux with Python 3.12–3.14, run: ```sh -python3.14 -m venv .venv +python3 -m venv .venv .venv/bin/python -m pip install -r requirements-offline.txt REPO=/path/to/backups/repo @@ -35,6 +35,11 @@ REPO=/path/to/backups/repo .venv/bin/python endkeep-offline.py --repo "$REPO" restore /path/to/restored-world --snapshot SNAPSHOT_ID ``` +On Windows, create a virtual environment with `py -3.14 -m venv .venv` +and use `.venv\\Scripts\\python` instead of `.venv/bin/python` in the +commands above. Python 3.12/3.13 uses the matching Amulet-LevelDB +compatibility Wheel from GitHub Releases on either platform. + Replace `REPO` and `SNAPSHOT_ID` with your paths and recovery point ID. Omit `--snapshot SNAPSHOT_ID` to restore the latest recovery point. The destination directory **must not already exist**. diff --git a/docs/using-endkeep.md b/docs/using-endkeep.md index af6ed7ce..9954a1ce 100644 --- a/docs/using-endkeep.md +++ b/docs/using-endkeep.md @@ -6,13 +6,12 @@ It stores snapshots in the directory configured by `storage.path` (default: `bac ## Getting started Install the plugin wheel from [Releases](https://github.com/ReallocAll/endkeep/releases/latest) -into `plugins/` and restart Endstone. **Current public releases** require Linux, -Python 3.14 and Endstone 0.11. Cross-platform Python 3.12–3.14 support is under validation. For Python -3.12/3.13 on Linux/Windows x64, the plugin automatically fetches the pinned -Amulet-LevelDB compatibility Wheel from the -[dedicated Release](https://github.com/ReallocAll/endkeep/releases/tag/amulet-leveldb-3.0.7a0-compat-cp312-cp313). -Python 3.14 uses the official upstream dependency. The host must reach -GitHub Releases and PyPI for the initial dependency installation. +into `plugins/` and restart Endstone. Requires Linux or Windows x86-64, +Python 3.12–3.14 and Endstone 0.11. Python 3.12/3.13 automatically install +the pinned Amulet-LevelDB compatibility Wheel from the +[compatibility Release](https://github.com/ReallocAll/endkeep/releases/tag/amulet-leveldb-3.0.7a0-compat-cp312-cp313); +Python 3.14 uses the official upstream package. The host must reach +GitHub Releases and PyPI during the initial installation. Use `/backup status` to check that EndKeep loaded successfully. Use `/backup create` for an immediate snapshot, then `/backup maintenance`