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..34b96c08 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,172 @@ +name: Python and Windows compatibility + +on: + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: compatibility-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +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 + 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"')) + + 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 + # 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 + 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 + + 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 9d9a0337..3cbd7a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,9 @@ # 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. 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 @@ -16,10 +17,11 @@ Endstone 0.11. For Endstone API usage, see [Endstone's documentation](https://en ## 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 @@ -34,6 +36,12 @@ 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 resolves pinned, SHA256-verified Amulet-LevelDB compatibility + Wheels from the dedicated GitHub Release. Test a clean EndKeep install without + 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. See [Contributing](CONTRIBUTING.md) and the [release checks](docs/manual-validation.md) for the development workflow. 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 0c873f68..8c39f772 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,20 @@ 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). +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 fb524090..ddfdfcfc 100644 --- a/docs/manual-validation.md +++ b/docs/manual-validation.md @@ -3,8 +3,13 @@ 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. +**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 @@ -29,3 +34,38 @@ 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) + +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; + 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. +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. + +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 583d023c..9954a1ce 100644 --- a/docs/using-endkeep.md +++ b/docs/using-endkeep.md @@ -6,9 +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. 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. 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` diff --git a/pyproject.toml b/pyproject.toml index 0175e6f6..3999881c 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", ] @@ -18,11 +23,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,<3.15" 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 +55,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] @@ -57,9 +65,17 @@ select = ["I", "E", "F", "UP", "B"] testpaths = ["tests"] addopts = "-ra" +[tool.hatch.metadata] +allow-direct-references = true + [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" 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 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}") 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/logical/sidecar.py b/src/endstone_endkeep/logical/sidecar.py index 13b9b2bb..ef0958de 100644 --- a/src/endstone_endkeep/logical/sidecar.py +++ b/src/endstone_endkeep/logical/sidecar.py @@ -85,7 +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 ("", ".", "..") 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,12 +103,17 @@ 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}") - 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: 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/repository/health.py b/src/endstone_endkeep/repository/health.py index a12312b8..919f6b12 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" @@ -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/repository/recovery.py b/src/endstone_endkeep/repository/recovery.py index 60936d9b..cd7202d6 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 @@ -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/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/src/endstone_endkeep/staging/clone.py b/src/endstone_endkeep/staging/clone.py index dc3f4e42..e5bc4ec7 100644 --- a/src/endstone_endkeep/staging/clone.py +++ b/src/endstone_endkeep/staging/clone.py @@ -13,8 +13,8 @@ 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": @@ -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..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) @@ -72,19 +86,18 @@ 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: 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: - 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 @@ -170,7 +187,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 aa55a3fb..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() @@ -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: diff --git a/src/endstone_endkeep/worker/client.py b/src/endstone_endkeep/worker/client.py index 5416479d..8c62e03f 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: @@ -146,13 +150,38 @@ 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 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: 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() diff --git a/tests/test_offline_builder.py b/tests/test_offline_builder.py index e40ca12b..a074542f 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,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 = "{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") diff --git a/tests/test_online_management.py b/tests/test_online_management.py index e208e86c..af10168a 100644 --- a/tests/test_online_management.py +++ b/tests/test_online_management.py @@ -95,7 +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") - 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) + # 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_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: diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index f259bac1..7fff3ab7 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -3,8 +3,11 @@ from io import BytesIO from pathlib import Path, PurePosixPath +import pytest + from endstone_endkeep.bds.model import SnapshotEntry -from endstone_endkeep.logical.sidecar import extract_sidecar, write_sidecar +from endstone_endkeep.logical.format import LogicalFormatError, encode_uvarint +from endstone_endkeep.logical.sidecar import SIDECAR_MAGIC, extract_sidecar, write_sidecar def test_sidecar_roundtrip(tmp_path: Path) -> None: @@ -47,3 +50,46 @@ 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 + + +@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()) == [] diff --git a/tests/test_windows_compatibility.py b/tests/test_windows_compatibility.py new file mode 100644 index 00000000..ab10d968 --- /dev/null +++ b/tests/test_windows_compatibility.py @@ -0,0 +1,104 @@ +"""Platform safety regression tests for the Python/Windows compatibility PR.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path, PurePosixPath + +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 + + +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") + + +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 + + +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) + + +@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)