from __future__ import annotations import json from pathlib import Path from pathlib import PurePosixPath import shutil import subprocess import tarfile import tempfile import unittest from ptlinux import __version__ from ptlinux.paths import AppPaths from ptlinux.self_update import ( _evaluate_launcher_update, _stage_and_install_release, parse_update_manifest, ) from scripts.release_lib import ( ReleaseToolError, _archive_layout_for_version, _forbid_release_path, _safe_output_directory, build_release, load_config, sha256_file, sign_file, verify_release, verify_signature, ) PROJECT_ROOT = Path(__file__).resolve().parents[1] class ReleaseArtifactTests(unittest.TestCase): def test_build_is_reproducible_and_matches_the_runtime_parser(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) notes = root / "notes.md" notes.write_text("## Highlights\n\nChannel-aware updates.\n", encoding="utf-8") commit = "a" * 40 first = root / "first" second = root / "second" for output in (first, second): build_release( __version__, "stable", output, repository_url="https://gitea.example.test/owner/project", commit=commit, source_date_epoch=1_700_000_000, release_notes_file=notes, ) verify_release(__version__, "stable", output) archive_name = f"PristonTale-EU-{__version__}.tar.gz" self.assertEqual( sha256_file(first / archive_name), sha256_file(second / archive_name), ) self.assertEqual( (first / "manifest.json").read_bytes(), (second / "manifest.json").read_bytes(), ) manifest = parse_update_manifest((first / "manifest.json").read_bytes()) self.assertEqual(manifest.version, __version__) self.assertEqual(manifest.channel, "stable") self.assertEqual(manifest.tag, f"v{__version__}") self.assertGreater(len(manifest.files), 10) self.assertEqual(manifest.archive_name, archive_name) raw_manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8")) self.assertEqual( raw_manifest["archive_url"], f"https://gitea.example.test/owner/project/releases/download/" f"v{__version__}/{archive_name}?sha256={manifest.archive_sha256}", ) expected_layout = _archive_layout_for_version(load_config(), __version__) self.assertEqual(raw_manifest["archive"]["layout"], expected_layout) self.assertIn("Channel-aware updates.", manifest.notes_markdown or "") paths = AppPaths.from_environment({"HOME": str(root / "home")}) installed = _stage_and_install_release( paths, first / archive_name, manifest, ) self.assertEqual(installed, paths.launcher_releases / __version__) self.assertTrue((installed / "ptlinux/update_resolver.py").is_file()) with tarfile.open(first / archive_name, "r:gz") as bundle: names = bundle.getnames() archive_root = ( f"pristontale-eu-linux-update-v{__version__}/" if expected_layout == "wrapped" else "" ) self.assertIn(f"{archive_root}release.json", names) self.assertIn(f"{archive_root}ptlinux/__init__.py", names) self.assertEqual( any(name.startswith("pristontale-eu-linux-update-v") for name in names), expected_layout == "wrapped", ) self.assertFalse(any("__pycache__" in name for name in names)) self.assertFalse(any(name.endswith(".pyc") for name in names)) self.assertFalse(any("/.git/" in name for name in names)) def test_manifest_file_list_rejects_reordering(self) -> None: manifest = { "schema": 1, "channel": "stable", "version": "1.8.0", "minimum_updater_version": "0.5.0", "archive_url": "https://example.test/PristonTale-EU-1.8.0.tar.gz", "archive_sha256": "a" * 64, "archive_size": 100, "published_at": "2026-09-08T00:00:00Z", "notes_url": "https://example.test/releases/v1.8.0", "files": [ {"path": "ptlinux/z.py", "size": 1, "sha256": "b" * 64}, {"path": "ptlinux/a.py", "size": 1, "sha256": "c" * 64}, ], } with self.assertRaisesRegex(RuntimeError, "uniquely sorted"): parse_update_manifest( (json.dumps(manifest, sort_keys=True) + "\n").encode("utf-8") ) def test_manual_build_can_set_only_the_packaged_launcher_version(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) target_version = "9.8.7-beta.2" source_path = PROJECT_ROOT / "ptlinux/__init__.py" source_before = source_path.read_bytes() build_release( target_version, "prerelease", root / "dist", repository_url="https://gitea.example.test/owner/project", commit="b" * 40, source_date_epoch=1_700_000_000, allow_source_version_override=True, ) verify_release(target_version, "prerelease", root / "dist") archive = root / "dist" / f"PristonTale-EU-{target_version}.tar.gz" with tarfile.open(archive, "r:gz") as bundle: member = bundle.extractfile("ptlinux/__init__.py") assert member is not None self.assertIn( f'__version__ = "{target_version}"'.encode(), member.read(), ) self.assertEqual(source_path.read_bytes(), source_before) def test_configured_bridge_release_uses_legacy_wrapper(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) version = "0.9.10" output = root / "dist" build_release( version, "stable", output, repository_url="https://gitea.example.test/owner/project", commit="c" * 40, source_date_epoch=1_700_000_000, allow_source_version_override=True, ) verify_release(version, "stable", output) manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) self.assertEqual(manifest["archive"]["layout"], "wrapped") self.assertEqual(manifest["minimum_updater_version"], "0.5.0") archive = output / f"PristonTale-EU-{version}.tar.gz" archive_root = f"pristontale-eu-linux-update-v{version}" with tarfile.open(archive, "r:gz") as bundle: names = bundle.getnames() self.assertIn(f"{archive_root}/release.json", names) self.assertIn(f"{archive_root}/ptlinux/self_update.py", names) self.assertFalse(any(name == "release.json" for name in names)) parsed = parse_update_manifest((output / "manifest.json").read_bytes()) check = _evaluate_launcher_update( parsed, "0.9.8", "https://gitea.example.test/owner/project/raw/branch/main/updates/stable.json", ) self.assertTrue(check.available) self.assertTrue(check.supported) paths = AppPaths.from_environment({"HOME": str(root / "home")}) installed = _stage_and_install_release(paths, archive, parsed) self.assertEqual(installed.name, version) def test_output_cleaning_cannot_target_source_directories(self) -> None: self.assertEqual( _safe_output_directory(PROJECT_ROOT / "dist/v9.8.7"), (PROJECT_ROOT / "dist/v9.8.7").resolve(), ) with self.assertRaisesRegex(ReleaseToolError, "repository dist directory"): _safe_output_directory(PROJECT_ROOT / "docs") with self.assertRaisesRegex(ReleaseToolError, "unsafe output"): _safe_output_directory(Path(tempfile.gettempdir())) def test_forbidden_release_paths_are_rejected(self) -> None: config = load_config() for path in ( PurePosixPath(".env"), PurePosixPath("private.key"), PurePosixPath("ptlinux/__pycache__/module.pyc"), PurePosixPath("logs/launcher.log"), ): with self.subTest(path=path): with self.assertRaisesRegex(ReleaseToolError, "forbidden"): _forbid_release_path(path, config) _forbid_release_path( PurePosixPath("ptlinux/launcher-update-public.pem"), config, ) def test_signature_verification_rejects_modified_metadata(self) -> None: openssl = shutil.which("openssl") if openssl is None: self.skipTest("OpenSSL is unavailable") with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) private_key = root / "private.pem" public_key = root / "public.pem" metadata = root / "manifest.json" signature = root / "manifest.json.sig" subprocess.run( [ openssl, "genpkey", "-quiet", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(private_key), ], check=True, capture_output=True, ) subprocess.run( [ openssl, "pkey", "-in", str(private_key), "-pubout", "-out", str(public_key), ], check=True, capture_output=True, ) metadata.write_text('{"schema": 1}\n', encoding="utf-8") sign_file(metadata, signature, private_key) verify_signature(metadata, signature, public_key) metadata.write_text('{"schema": 2}\n', encoding="utf-8") with self.assertRaisesRegex(ReleaseToolError, "verification failed"): verify_signature(metadata, signature, public_key) if __name__ == "__main__": unittest.main()