"""Signed, atomic self-updates for the native Linux launcher.""" from __future__ import annotations from dataclasses import asdict, dataclass from datetime import datetime import hashlib import json import os from pathlib import Path, PurePosixPath import re import shutil import stat import subprocess import sys import tarfile import tempfile from typing import Mapping from urllib.error import HTTPError, URLError from urllib.parse import urlsplit, urlunsplit from urllib.request import Request, urlopen from . import __version__ from .locking import launcher_operation_lock from .paths import AppPaths DEFAULT_UPDATE_REPOSITORY_URL = ( "https://git.m4ko.net/m4ko/pristontale-eu-linux" ) DEFAULT_UPDATE_MANIFEST_URL = ( f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/stable.json" ) UPDATE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_MANIFEST_URL" UPDATE_PUBLIC_KEY_VARIABLE = "PTLINUX_UPDATE_PUBLIC_KEY" PUBLIC_KEY_FILENAME = "launcher-update-public.pem" USER_AGENT = f"PT-Linux/{__version__} launcher-updater" READ_SIZE = 256 * 1024 MAX_MANIFEST_BYTES = 128 * 1024 MAX_SIGNATURE_BYTES = 16 * 1024 MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 MAX_EXTRACTED_BYTES = 64 * 1024 * 1024 MAX_ARCHIVE_MEMBERS = 512 UPDATE_ARCHIVE_PREFIX = "pristontale-eu-linux-update-v" _VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z") _SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z") _ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"} UPDATE_CHECK_CACHE_SCHEMA = 1 class LauncherUpdateError(RuntimeError): """Raised when a launcher update cannot be trusted or installed safely.""" @dataclass(frozen=True) class UpdateManifest: schema: int channel: str version: str minimum_updater_version: str archive_url: str archive_sha256: str archive_size: int published_at: str notes_url: str | None @dataclass(frozen=True) class LauncherUpdateCheck: current_version: str latest_version: str available: bool supported: bool reason: str manifest_url: str manifest: UpdateManifest @dataclass(frozen=True) class LauncherUpdateResult: changed: bool current_version: str installed_version: str release_directory: str | None previous_release: str | None archive: str | None archive_sha256: str | None restart_required: bool state_recorded: bool warning: str | None @dataclass(frozen=True) class LauncherUpdateVerification: current_version: str release_version: str manifest_url: str archive_url: str archive_size: int archive_sha256: str payload_files: int @dataclass(frozen=True) class LauncherRollbackResult: from_version: str to_version: str current_release: str previous_release: str restart_required: bool def default_public_key_path() -> Path: return Path(__file__).with_name(PUBLIC_KEY_FILENAME) def configured_manifest_url( environment: Mapping[str, str] | None = None, ) -> str: env = os.environ if environment is None else environment return env.get(UPDATE_MANIFEST_URL_VARIABLE, DEFAULT_UPDATE_MANIFEST_URL) def configured_public_key_path( environment: Mapping[str, str] | None = None, ) -> Path: env = os.environ if environment is None else environment configured = env.get(UPDATE_PUBLIC_KEY_VARIABLE) return Path(configured).expanduser() if configured else default_public_key_path() def check_for_launcher_update( *, current_version: str = __version__, manifest_url: str | None = None, public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, timeout_seconds: float = 20.0, ) -> LauncherUpdateCheck: """Fetch and authenticate the stable update manifest.""" _parse_version(current_version, "current launcher version") url = manifest_url or configured_manifest_url(environment) _require_https_url(url, "update manifest URL") key = public_key_path or configured_public_key_path(environment) manifest_bytes = _fetch_small_file( url, maximum_bytes=MAX_MANIFEST_BYTES, timeout_seconds=timeout_seconds, label="update manifest", ) signature_bytes = _fetch_small_file( _signature_url(url), maximum_bytes=MAX_SIGNATURE_BYTES, timeout_seconds=timeout_seconds, label="update manifest signature", ) _verify_manifest_signature(manifest_bytes, signature_bytes, key) manifest = parse_update_manifest(manifest_bytes) return _evaluate_launcher_update(manifest, current_version, url) def read_cached_launcher_update_check( cache_path: Path, *, current_version: str = __version__, manifest_url: str | None = None, public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, ) -> LauncherUpdateCheck | None: """Read a display-only result from the last authenticated live check.""" url = manifest_url or configured_manifest_url(environment) key = public_key_path or configured_public_key_path(environment) try: _parse_version(current_version, "current launcher version") _require_https_url(url, "update manifest URL") key_sha256 = _sha256_file(key.expanduser().resolve()) value = json.loads(cache_path.read_text(encoding="utf-8")) except ( OSError, UnicodeError, json.JSONDecodeError, LauncherUpdateError, RuntimeError, ): return None if not isinstance(value, dict): return None if value.get("schema") != UPDATE_CHECK_CACHE_SCHEMA: return None if value.get("current_version") != current_version: return None if value.get("manifest_url") != url: return None if value.get("public_key_sha256") != key_sha256: return None manifest_value = value.get("manifest") if not isinstance(manifest_value, dict): return None try: manifest = parse_update_manifest( (json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8") ) except (LauncherUpdateError, TypeError, ValueError): return None return _evaluate_launcher_update(manifest, current_version, url) def cache_launcher_update_check( cache_path: Path, check: LauncherUpdateCheck, *, public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, ) -> bool: """Cache an authenticated result for startup display, never for installation.""" key = public_key_path or configured_public_key_path(environment) try: key_sha256 = _sha256_file(key.expanduser().resolve()) _write_json( cache_path, { "schema": UPDATE_CHECK_CACHE_SCHEMA, "checked_at": datetime.now().astimezone().isoformat(), "current_version": check.current_version, "manifest_url": check.manifest_url, "public_key_sha256": key_sha256, "manifest": asdict(check.manifest), }, ) except (OSError, RuntimeError): return False return True def _evaluate_launcher_update( manifest: UpdateManifest, current_version: str, manifest_url: str, ) -> LauncherUpdateCheck: current = _parse_version(current_version, "current launcher version") latest = _parse_version(manifest.version, "manifest version") minimum = _parse_version( manifest.minimum_updater_version, "minimum updater version", ) available = latest > current supported = current >= minimum if not available: reason = ( "This launcher is newer than the stable update channel." if latest < current else "The launcher is up to date." ) elif not supported: reason = ( f"Version {manifest.version} requires updater " f"{manifest.minimum_updater_version} or newer; install it manually." ) else: reason = f"Launcher {manifest.version} is available." return LauncherUpdateCheck( current_version=current_version, latest_version=manifest.version, available=available, supported=supported, reason=reason, manifest_url=manifest_url, manifest=manifest, ) def install_launcher_update( paths: AppPaths, *, current_version: str = __version__, manifest_url: str | None = None, public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, timeout_seconds: float = 60.0, ) -> LauncherUpdateResult: """Authenticate, stage, validate, and atomically activate one update.""" check = check_for_launcher_update( current_version=current_version, manifest_url=manifest_url, public_key_path=public_key_path, environment=environment, timeout_seconds=min(timeout_seconds, 30.0), ) if not check.available: return LauncherUpdateResult( changed=False, current_version=check.current_version, installed_version=check.current_version, release_directory=None, previous_release=None, archive=None, archive_sha256=None, restart_required=False, state_recorded=False, warning=None, ) if not check.supported: raise LauncherUpdateError(check.reason) paths.create_base_directories() with launcher_operation_lock(paths.launcher_update_lock, "launcher-update"): archive = paths.launcher_update_archive(check.manifest.version) _download_archive( check.manifest, archive, timeout_seconds=timeout_seconds, ) release_directory = _stage_and_install_release(paths, archive, check.manifest) previous = _activate_release(paths, release_directory) state_warning = None try: _write_json( paths.launcher_update_state, { "schema": 1, "updated_at": datetime.now().astimezone().isoformat(), "from_version": check.current_version, "to_version": check.manifest.version, "manifest_url": check.manifest_url, "archive_url": check.manifest.archive_url, "archive_sha256": check.manifest.archive_sha256, "release_directory": str(release_directory), "previous_release": str(previous) if previous else None, }, ) except OSError as exc: # Activation is the durable user-facing operation. A diagnostics-only # state record must not strand the dashboard on the old release. state_warning = f"launcher updated, but update history could not be saved: {exc}" return LauncherUpdateResult( changed=True, current_version=check.current_version, installed_version=check.manifest.version, release_directory=str(release_directory), previous_release=str(previous) if previous else None, archive=str(archive), archive_sha256=check.manifest.archive_sha256, restart_required=True, state_recorded=state_warning is None, warning=state_warning, ) def verify_launcher_update_channel( *, current_version: str = __version__, manifest_url: str | None = None, public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, timeout_seconds: float = 60.0, ) -> LauncherUpdateVerification: """Authenticate, download, extract, and import a release without activating it.""" check = check_for_launcher_update( current_version=current_version, manifest_url=manifest_url, public_key_path=public_key_path, environment=environment, timeout_seconds=min(timeout_seconds, 30.0), ) with tempfile.TemporaryDirectory(prefix="ptlinux-update-verification-") as temporary: root = Path(temporary) archive = root / f"{UPDATE_ARCHIVE_PREFIX}{check.manifest.version}.tar.gz" _download_archive( check.manifest, archive, timeout_seconds=timeout_seconds, ) staging = root / "release" staging.mkdir(mode=0o700) _extract_release_archive(archive, staging, check.manifest.version) staged_version = _validate_release_directory(staging) if staged_version != check.manifest.version: raise LauncherUpdateError( f"verified launcher version is {staged_version}, " f"expected {check.manifest.version}" ) _self_check_release(staging, check.manifest.version) _validate_release_directory(staging) payload_files = sum(1 for item in staging.rglob("*") if item.is_file()) return LauncherUpdateVerification( current_version=current_version, release_version=check.manifest.version, manifest_url=check.manifest_url, archive_url=check.manifest.archive_url, archive_size=check.manifest.archive_size, archive_sha256=check.manifest.archive_sha256, payload_files=payload_files, ) def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult: """Swap the current and previous authenticated launcher releases.""" with launcher_operation_lock(paths.launcher_update_lock, "launcher-rollback"): current = _release_from_link(paths.launcher_current, paths.launcher_releases) previous = _release_from_link(paths.launcher_previous, paths.launcher_releases) if current is None: raise LauncherUpdateError("the current launcher release link is missing") if previous is None: raise LauncherUpdateError("no previous launcher release is available") current_version = _validate_release_directory(current) previous_version = _validate_release_directory(previous) if current == previous: raise LauncherUpdateError("current and previous launcher releases are identical") _replace_release_link(paths.launcher_previous, current, paths.launcher_releases) _replace_release_link(paths.launcher_current, previous, paths.launcher_releases) _fsync_directory(paths.app_home) _write_json( paths.launcher_update_state, { "schema": 1, "rolled_back_at": datetime.now().astimezone().isoformat(), "from_version": current_version, "to_version": previous_version, "release_directory": str(previous), "previous_release": str(current), }, ) return LauncherRollbackResult( from_version=current_version, to_version=previous_version, current_release=str(previous), previous_release=str(current), restart_required=True, ) def parse_update_manifest(data: bytes) -> UpdateManifest: """Strictly parse one already-authenticated manifest.""" if not data or len(data) > MAX_MANIFEST_BYTES: raise LauncherUpdateError("update manifest has an invalid size") try: value = json.loads( data.decode("utf-8"), object_pairs_hook=_unique_json_object, ) except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: raise LauncherUpdateError(f"update manifest is invalid JSON: {exc}") from exc if not isinstance(value, dict): raise LauncherUpdateError("update manifest must contain a JSON object") schema = _required_integer(value, "schema") if schema != 1: raise LauncherUpdateError(f"unsupported update manifest schema: {schema}") channel = _required_string(value, "channel") if channel != "stable": raise LauncherUpdateError(f"unsupported update channel: {channel}") version = _required_string(value, "version") minimum = _required_string(value, "minimum_updater_version") _parse_version(version, "manifest version") _parse_version(minimum, "minimum updater version") archive_url = _required_string(value, "archive_url") _require_https_url(archive_url, "update archive URL") archive_sha256 = _required_string(value, "archive_sha256") if _SHA256_PATTERN.fullmatch(archive_sha256) is None: raise LauncherUpdateError("archive_sha256 must be a lowercase SHA-256 digest") archive_size = _required_integer(value, "archive_size") if archive_size <= 0 or archive_size > MAX_ARCHIVE_BYTES: raise LauncherUpdateError( f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes" ) published_at = _required_string(value, "published_at") _parse_published_at(published_at) notes_value = value.get("notes_url") if notes_value is not None and not isinstance(notes_value, str): raise LauncherUpdateError("notes_url must be a string or null") if notes_value: _require_https_url(notes_value, "release notes URL") return UpdateManifest( schema=schema, channel=channel, version=version, minimum_updater_version=minimum, archive_url=archive_url, archive_sha256=archive_sha256, archive_size=archive_size, published_at=published_at, notes_url=notes_value or None, ) def _fetch_small_file( url: str, *, maximum_bytes: int, timeout_seconds: float, label: str, ) -> bytes: request = Request( url, headers={ "User-Agent": USER_AGENT, "Accept": "application/octet-stream, application/json", "Accept-Encoding": "identity", "Cache-Control": "no-cache", "Pragma": "no-cache", }, method="GET", ) try: response = urlopen(request, timeout=timeout_seconds) except HTTPError as exc: raise LauncherUpdateError(f"{label} request failed: HTTP {exc.code}") from exc except (URLError, OSError) as exc: raise LauncherUpdateError(f"{label} request failed: {exc}") from exc with response: final_url = response.geturl() _require_https_url(final_url, f"final {label} URL") if _resource_url(final_url) != _resource_url(url): raise LauncherUpdateError( f"{label} redirected unexpectedly to {final_url}; " "the launcher update channel must be publicly readable" ) length = _content_length(response.headers.get("Content-Length"), label) if length is not None and length > maximum_bytes: raise LauncherUpdateError(f"{label} exceeds its size limit") data = response.read(maximum_bytes + 1) if len(data) > maximum_bytes: raise LauncherUpdateError(f"{label} exceeds its size limit") if length is not None and len(data) != length: raise LauncherUpdateError(f"{label} response was incomplete") if not data: raise LauncherUpdateError(f"{label} response was empty") return data def _verify_manifest_signature( manifest: bytes, signature: bytes, public_key_path: Path, ) -> None: if not signature or len(signature) > MAX_SIGNATURE_BYTES: raise LauncherUpdateError("update manifest signature has an invalid size") key = public_key_path.expanduser().resolve() if not key.is_file(): raise LauncherUpdateError(f"launcher update public key is missing: {key}") openssl = shutil.which("openssl") if not openssl: raise LauncherUpdateError("OpenSSL is required to verify launcher updates") with tempfile.TemporaryDirectory(prefix="ptlinux-update-signature-") as temporary: root = Path(temporary) manifest_path = root / "stable.json" signature_path = root / "stable.json.sig" manifest_path.write_bytes(manifest) signature_path.write_bytes(signature) try: result = subprocess.run( [ openssl, "dgst", "-sha256", "-verify", str(key), "-signature", str(signature_path), str(manifest_path), ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: raise LauncherUpdateError(f"could not verify update signature: {exc}") from exc if result.returncode != 0: raise LauncherUpdateError("launcher update manifest signature is invalid") def _download_archive( manifest: UpdateManifest, destination: Path, *, timeout_seconds: float, ) -> None: if destination.is_file() and _archive_matches(destination, manifest): return destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part") temporary.unlink(missing_ok=True) request = Request( manifest.archive_url, headers={ "User-Agent": USER_AGENT, "Accept": "application/gzip, application/octet-stream", "Accept-Encoding": "identity", }, method="GET", ) try: try: response = urlopen(request, timeout=timeout_seconds) except HTTPError as exc: raise LauncherUpdateError( f"launcher archive request failed: HTTP {exc.code}" ) from exc except (URLError, OSError) as exc: raise LauncherUpdateError(f"launcher archive request failed: {exc}") from exc with response: _require_https_url(response.geturl(), "final update archive URL") length = _content_length( response.headers.get("Content-Length"), "launcher archive", ) if length is not None and length != manifest.archive_size: raise LauncherUpdateError( "launcher archive Content-Length does not match the signed manifest" ) digest = hashlib.sha256() written = 0 with temporary.open("xb") as handle: while True: chunk = response.read(READ_SIZE) if not chunk: break written += len(chunk) if written > manifest.archive_size or written > MAX_ARCHIVE_BYTES: raise LauncherUpdateError("launcher archive exceeded its signed size") digest.update(chunk) handle.write(chunk) handle.flush() os.fsync(handle.fileno()) if written != manifest.archive_size: raise LauncherUpdateError( f"launcher archive was incomplete: {written}/{manifest.archive_size} bytes" ) if digest.hexdigest() != manifest.archive_sha256: raise LauncherUpdateError("launcher archive SHA-256 does not match the manifest") temporary.replace(destination) _fsync_directory(destination.parent) except BaseException: temporary.unlink(missing_ok=True) raise def _stage_and_install_release( paths: AppPaths, archive: Path, manifest: UpdateManifest, ) -> Path: if paths.app_home.is_symlink(): raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}") paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755) paths.app_home.chmod(0o755) if paths.launcher_releases.is_symlink(): raise LauncherUpdateError( f"launcher release directory cannot be a link: {paths.launcher_releases}" ) paths.launcher_releases.mkdir(parents=True, exist_ok=True, mode=0o755) paths.launcher_releases.chmod(0o755) staging = paths.launcher_releases / f".{manifest.version}.{os.getpid()}.staging" if staging.exists() or staging.is_symlink(): raise LauncherUpdateError(f"launcher staging path already exists: {staging}") staging.mkdir(mode=0o700) try: _extract_release_archive(archive, staging, manifest.version) staged_version = _validate_release_directory(staging) if staged_version != manifest.version: raise LauncherUpdateError( f"staged launcher version is {staged_version}, expected {manifest.version}" ) _self_check_release(staging, manifest.version) _validate_release_directory(staging) staging.chmod(0o755) destination = paths.launcher_releases / manifest.version if destination.exists() or destination.is_symlink(): if not destination.is_dir() or destination.is_symlink(): raise LauncherUpdateError( f"launcher release destination is not a directory: {destination}" ) if _tree_digest(destination) != _tree_digest(staging): raise LauncherUpdateError( f"launcher release {manifest.version} already exists with different contents" ) shutil.rmtree(staging) else: staging.replace(destination) _fsync_directory(paths.launcher_releases) return destination except BaseException: if staging.exists() and staging.is_dir() and not staging.is_symlink(): shutil.rmtree(staging) raise def _extract_release_archive(archive: Path, destination: Path, version: str) -> None: expected_root = f"{UPDATE_ARCHIVE_PREFIX}{version}" seen: set[PurePosixPath] = set() total_bytes = 0 try: bundle = tarfile.open(archive, "r:gz") except (OSError, tarfile.TarError) as exc: raise LauncherUpdateError(f"launcher archive cannot be opened: {exc}") from exc with bundle: members = bundle.getmembers() if not members or len(members) > MAX_ARCHIVE_MEMBERS: raise LauncherUpdateError("launcher archive has an invalid member count") for member in members: name = member.name[:-1] if member.isdir() and member.name.endswith("/") else member.name if not name or "\\" in name or "\x00" in name: raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}") raw_parts = name.split("/") if any(part in {"", ".", ".."} for part in raw_parts): raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}") path = PurePosixPath(name) if path.is_absolute() or not path.parts or path.parts[0] != expected_root: raise LauncherUpdateError( f"launcher archive member is outside {expected_root}: {member.name!r}" ) relative = PurePosixPath(*path.parts[1:]) if not relative.parts: if not member.isdir(): raise LauncherUpdateError("launcher archive root must be a directory") continue if relative.parts[0] not in _ALLOWED_RELEASE_ROOTS: raise LauncherUpdateError( f"unexpected launcher payload path: {relative.as_posix()}" ) if relative in seen: raise LauncherUpdateError( f"duplicate launcher archive path: {relative.as_posix()}" ) seen.add(relative) if not member.isdir() and not member.isfile(): raise LauncherUpdateError( f"unsupported launcher archive member: {member.name!r}" ) if member.mode & 0o022: raise LauncherUpdateError( f"launcher archive member is group/world writable: {member.name!r}" ) target = destination.joinpath(*relative.parts) if member.isdir(): target.mkdir(parents=True, exist_ok=True, mode=0o755) continue if member.size < 0: raise LauncherUpdateError("launcher archive contains a negative file size") total_bytes += member.size if total_bytes > MAX_EXTRACTED_BYTES: raise LauncherUpdateError("launcher archive exceeds its extraction limit") target.parent.mkdir(parents=True, exist_ok=True, mode=0o755) source = bundle.extractfile(member) if source is None: raise LauncherUpdateError(f"cannot read launcher payload: {member.name!r}") written = 0 with source, target.open("xb") as handle: while True: chunk = source.read(READ_SIZE) if not chunk: break written += len(chunk) if written > member.size: raise LauncherUpdateError( f"launcher payload exceeded its declared size: {member.name!r}" ) handle.write(chunk) if written != member.size: raise LauncherUpdateError( f"launcher payload was incomplete: {member.name!r}" ) target.chmod(0o644) def _validate_release_directory(release: Path) -> str: if not release.is_dir() or release.is_symlink(): raise LauncherUpdateError(f"launcher release directory is invalid: {release}") for item in (release, *release.rglob("*")): if item.is_symlink(): raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}") try: mode = item.stat().st_mode except OSError as exc: raise LauncherUpdateError(f"cannot inspect launcher release path {item}: {exc}") from exc if mode & 0o022: raise LauncherUpdateError( f"launcher release path is group/world writable: {item}" ) if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode): raise LauncherUpdateError(f"launcher release contains a special file: {item}") metadata_path = release / "release.json" try: metadata = json.loads( metadata_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_json_object, ) except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: raise LauncherUpdateError(f"launcher release metadata is invalid: {exc}") from exc if not isinstance(metadata, dict) or metadata.get("schema") != 1: raise LauncherUpdateError("launcher release metadata has an unsupported schema") version = metadata.get("version") if not isinstance(version, str): raise LauncherUpdateError("launcher release metadata has no version") _parse_version(version, "launcher release version") for required in ( Path("ptlinux/__init__.py"), Path("ptlinux/__main__.py"), Path("ptlinux/cli.py"), Path("ptlinux/self_update.py"), Path("ptlinux") / PUBLIC_KEY_FILENAME, Path("ptdiag/__init__.py"), Path("ptdiag/assbin.py"), Path("ptdiag/logs.py"), ): if not (release / required).is_file(): raise LauncherUpdateError(f"launcher release is missing {required}") return version def _self_check_release(release: Path, expected_version: str) -> None: check = ( "import sys; " "sys.path.insert(0, sys.argv[1]); " "import ptlinux, ptlinux.cli, ptlinux.self_update; " "raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)" ) try: result = subprocess.run( [sys.executable, "-I", "-B", "-c", check, str(release), expected_version], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: raise LauncherUpdateError(f"could not test the staged launcher: {exc}") from exc if result.returncode != 0: detail = (result.stderr or result.stdout).strip() raise LauncherUpdateError( "staged launcher self-check failed" + (f": {detail}" if detail else "") ) def _activate_release(paths: AppPaths, release: Path) -> Path | None: if paths.app_home.is_symlink(): raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}") paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755) paths.app_home.chmod(0o755) current = _release_from_link(paths.launcher_current, paths.launcher_releases) if paths.launcher_current.exists() and not paths.launcher_current.is_symlink(): raise LauncherUpdateError( f"launcher current path is not a symbolic link: {paths.launcher_current}" ) if paths.launcher_previous.exists() and not paths.launcher_previous.is_symlink(): raise LauncherUpdateError( f"launcher previous path is not a symbolic link: {paths.launcher_previous}" ) if current is not None and current != release: _validate_release_directory(current) _replace_release_link(paths.launcher_previous, current, paths.launcher_releases) _replace_release_link(paths.launcher_current, release, paths.launcher_releases) _fsync_directory(paths.app_home) return current def _replace_release_link(link: Path, release: Path, releases: Path) -> None: resolved_release = release.resolve() resolved_releases = releases.resolve() if resolved_release.parent != resolved_releases: raise LauncherUpdateError(f"release is outside the launcher release directory: {release}") _validate_release_directory(resolved_release) temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp") temporary.unlink(missing_ok=True) try: temporary.symlink_to(Path("releases") / resolved_release.name) os.replace(temporary, link) except BaseException: temporary.unlink(missing_ok=True) raise def _release_from_link(link: Path, releases: Path) -> Path | None: if not link.is_symlink(): if link.exists(): raise LauncherUpdateError(f"launcher release link is not symbolic: {link}") return None try: target_text = os.readlink(link) except OSError as exc: raise LauncherUpdateError(f"cannot read launcher release link {link}: {exc}") from exc target = (link.parent / target_text).resolve() root = releases.resolve() if target.parent != root: raise LauncherUpdateError(f"launcher release link escapes its release directory: {link}") if not target.is_dir() or target.is_symlink(): raise LauncherUpdateError(f"launcher release link is broken: {link}") return target def _archive_matches(path: Path, manifest: UpdateManifest) -> bool: try: return ( path.stat().st_size == manifest.archive_size and _sha256_file(path) == manifest.archive_sha256 ) except OSError: return False def _tree_digest(root: Path) -> str: digest = hashlib.sha256() for item in sorted(root.rglob("*"), key=lambda value: value.relative_to(root).as_posix()): relative = item.relative_to(root).as_posix().encode("utf-8") if item.is_symlink(): raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}") if item.is_dir(): digest.update(b"D\x00" + relative + b"\x00") elif item.is_file(): digest.update(b"F\x00" + relative + b"\x00") digest.update(str(item.stat().st_size).encode("ascii") + b"\x00") with item.open("rb") as handle: for chunk in iter(lambda: handle.read(READ_SIZE), b""): digest.update(chunk) digest.update(b"\x00") else: raise LauncherUpdateError(f"launcher release contains a special file: {item}") return digest.hexdigest() def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(READ_SIZE), b""): digest.update(chunk) return digest.hexdigest() def _signature_url(manifest_url: str) -> str: parts = urlsplit(manifest_url) return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, "")) def _resource_url(value: str) -> str: parts = urlsplit(value) return urlunsplit( (parts.scheme.casefold(), parts.netloc.casefold(), parts.path, parts.query, "") ) def _require_https_url(value: str, label: str) -> None: parts = urlsplit(value) if ( parts.scheme.casefold() != "https" or not parts.netloc or parts.username is not None or parts.password is not None or parts.fragment ): raise LauncherUpdateError(f"{label} must be an HTTPS URL without credentials") def _parse_version(value: str, label: str) -> tuple[int, int, int]: if _VERSION_PATTERN.fullmatch(value) is None: raise LauncherUpdateError(f"{label} must use X.Y.Z format: {value!r}") major, minor, patch = value.split(".") return int(major), int(minor), int(patch) def _parse_published_at(value: str) -> datetime: try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as exc: raise LauncherUpdateError( f"manifest published_at must be an ISO-8601 timestamp: {value!r}" ) from exc if parsed.tzinfo is None or parsed.utcoffset() is None: raise LauncherUpdateError("manifest published_at must include a timezone") return parsed def _required_string(value: dict[str, object], key: str) -> str: item = value.get(key) if not isinstance(item, str) or not item: raise LauncherUpdateError(f"update manifest field {key!r} must be a string") return item def _required_integer(value: dict[str, object], key: str) -> int: item = value.get(key) if isinstance(item, bool) or not isinstance(item, int): raise LauncherUpdateError(f"update manifest field {key!r} must be an integer") return item def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} for key, value in pairs: if key in result: raise ValueError(f"duplicate JSON key: {key}") result[key] = value return result def _content_length(value: str | None, label: str) -> int | None: if value is None: return None try: length = int(value) except ValueError as exc: raise LauncherUpdateError(f"{label} returned an invalid Content-Length") from exc if length < 0: raise LauncherUpdateError(f"{label} returned an invalid Content-Length") return length def _write_json(path: Path, value: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") try: with temporary.open("w", encoding="utf-8") as handle: json.dump(value, handle, indent=2, sort_keys=True) handle.write("\n") handle.flush() os.fsync(handle.fileno()) temporary.replace(path) _fsync_directory(path.parent) except BaseException: temporary.unlink(missing_ok=True) raise def _fsync_directory(path: Path) -> None: try: descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) except OSError: return try: os.fsync(descriptor) except OSError: pass finally: os.close(descriptor) def update_check_to_dict(check: LauncherUpdateCheck) -> dict[str, object]: """Return an explicitly public JSON representation for CLI/UI callers.""" return asdict(check)