271 lines
8.6 KiB
Python
271 lines
8.6 KiB
Python
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()
|