Files
pristontale-eu-linux/ptlinux/self_update.py

3067 lines
97 KiB
Python

"""Signed, atomic self-updates for the native Linux launcher."""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import asdict, dataclass, replace
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
import time
from typing import Iterator, 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, managed_game_process_is_active
from .paths import AppPaths
from .telemetry import (
classify_network_failure,
current_telemetry,
network_http_status,
)
from .update_resolver import (
ACTIVE_STATUS,
PRERELEASE_CHANNEL,
STABLE_CHANNEL,
SUPPORTED_RELEASE_STATUSES,
ReleaseModelError,
ReleaseRecord,
SemanticVersion,
UpdateResolver,
validate_channel,
validate_channel_version,
)
DEFAULT_UPDATE_REPOSITORY_URL = (
"https://git.m4ko.net/m4ko/pristontale-eu-linux"
)
DEFAULT_STABLE_UPDATE_MANIFEST_URL = (
f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/stable.json"
)
DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL = (
f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/prerelease.json"
)
DEFAULT_UPDATE_HISTORY_URL = (
f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/history.json"
)
# Retain the original name for callers importing the stable default.
DEFAULT_UPDATE_MANIFEST_URL = DEFAULT_STABLE_UPDATE_MANIFEST_URL
UPDATE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_MANIFEST_URL"
UPDATE_STABLE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_STABLE_MANIFEST_URL"
UPDATE_PRERELEASE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_PRERELEASE_MANIFEST_URL"
UPDATE_HISTORY_URL_VARIABLE = "PTLINUX_UPDATE_HISTORY_URL"
UPDATE_PUBLIC_KEY_VARIABLE = "PTLINUX_UPDATE_PUBLIC_KEY"
PUBLIC_KEY_FILENAME = "launcher-update-public.pem"
UPDATE_APPLICATION = "PristonTale-EU"
USER_AGENT = f"PT-Linux/{__version__} launcher-updater"
READ_SIZE = 256 * 1024
MAX_MANIFEST_BYTES = 128 * 1024
MAX_HISTORY_BYTES = 512 * 1024
MAX_SIGNATURE_BYTES = 16 * 1024
MAX_EMBEDDED_RELEASE_NOTES_BYTES = 64 * 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"
_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
_ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"}
UPDATE_CHECK_CACHE_SCHEMA = 2
RELEASE_HISTORY_SCHEMA = 1
MAX_RELEASE_HISTORY = 200
LOCAL_OPERATION_HISTORY_SCHEMA = 1
MAX_LOCAL_OPERATION_HISTORY = 30
class LauncherUpdateError(RuntimeError):
"""Raised when a launcher update cannot be trusted or installed safely."""
@dataclass(frozen=True)
class ManifestFile:
path: str
size: int
sha256: str
@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
tag: str | None = None
commit: str | None = None
status: str = ACTIVE_STATUS
title: str | None = None
summary: str | None = None
notes_markdown: str | None = None
archive_name: str | None = None
files: tuple[ManifestFile, ...] = ()
manifest_sha256: str | None = None
config_schema: int = 1
minimum_config_schema: int = 1
@dataclass(frozen=True)
class LauncherUpdateCheck:
current_version: str
latest_version: str
available: bool
supported: bool
reason: str
manifest_url: str
manifest: UpdateManifest
checked_at: str | None = None
selected_channel: str = STABLE_CHANNEL
@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
selected_channel: str = STABLE_CHANNEL
installed_channel: str = STABLE_CHANNEL
tag: str | None = None
manifest_schema: int | None = None
manifest_sha256: str | None = 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
channel: str = STABLE_CHANNEL
tag: str | None = None
manifest_sha256: str | None = None
@dataclass(frozen=True)
class LauncherRollbackResult:
from_version: str
to_version: str
current_release: str
previous_release: str
restart_required: bool
from_channel: str = STABLE_CHANNEL
to_channel: str = STABLE_CHANNEL
state_recorded: bool = True
warning: str | None = None
@dataclass(frozen=True)
class LauncherInstalledRelease:
"""One validated release retained in the launcher's version store."""
version: str
installed_at: str
directory: str
active: bool
previous: bool
channel: str = STABLE_CHANNEL
@dataclass(frozen=True)
class LauncherUpdateHistory:
"""Validated launcher releases available for inspection or rollback."""
current_version: str | None
previous_version: str | None
releases: tuple[LauncherInstalledRelease, ...]
@dataclass(frozen=True)
class LauncherRemoteRelease:
"""One independently installable release from signed remote history."""
version: str
channel: str
tag: str
manifest_url: str
manifest_signature_url: str
release_date: str
release_notes_url: str
status: str
title: str
release_id: int | None = None
config_schema: int | None = None
minimum_config_schema: int | None = None
def resolver_record(self) -> ReleaseRecord:
return ReleaseRecord(
version=self.version,
channel=self.channel,
status=self.status,
tag=self.tag,
manifest_url=self.manifest_url,
manifest_signature_url=self.manifest_signature_url,
release_date=self.release_date,
release_notes_url=self.release_notes_url,
compatibility={
"config_schema": self.config_schema,
"minimum_config_schema": self.minimum_config_schema,
},
)
@dataclass(frozen=True)
class LauncherRemoteHistory:
"""Authenticated release catalog used for history and remote rollback."""
generated_at: str
history_url: str
releases: tuple[LauncherRemoteRelease, ...]
history_sha256: str
def default_public_key_path() -> Path:
return Path(__file__).with_name(PUBLIC_KEY_FILENAME)
def configured_manifest_url(
environment: Mapping[str, str] | None = None,
*,
channel: str = STABLE_CHANNEL,
) -> str:
"""Return the direct channel URL retained for legacy callers."""
urls = configured_manifest_urls(channel=channel, environment=environment)
return urls[-1][1]
def configured_manifest_urls(
*,
channel: str = STABLE_CHANNEL,
environment: Mapping[str, str] | None = None,
) -> tuple[tuple[str, str, bool], ...]:
"""Return channel, URL, and optionality for authenticated discovery."""
try:
validate_channel(channel)
except ReleaseModelError as exc:
raise LauncherUpdateError(str(exc)) from exc
env = os.environ if environment is None else environment
generic_override = env.get(UPDATE_MANIFEST_URL_VARIABLE)
if generic_override:
return ((channel, generic_override, False),)
stable_url = env.get(
UPDATE_STABLE_MANIFEST_URL_VARIABLE,
DEFAULT_STABLE_UPDATE_MANIFEST_URL,
)
if channel == STABLE_CHANNEL:
return ((STABLE_CHANNEL, stable_url, False),)
prerelease_url = env.get(
UPDATE_PRERELEASE_MANIFEST_URL_VARIABLE,
DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL,
)
return (
(STABLE_CHANNEL, stable_url, False),
(PRERELEASE_CHANNEL, prerelease_url, True),
)
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 configured_history_url(
environment: Mapping[str, str] | None = None,
) -> str:
env = os.environ if environment is None else environment
return env.get(UPDATE_HISTORY_URL_VARIABLE, DEFAULT_UPDATE_HISTORY_URL)
def fetch_launcher_release_history(
*,
history_url: str | None = None,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
timeout_seconds: float = 20.0,
) -> LauncherRemoteHistory:
"""Fetch and authenticate the immutable-release catalog used for rollback."""
url = history_url or configured_history_url(environment)
_require_https_url(url, "release history URL")
key = public_key_path or configured_public_key_path(environment)
history_bytes = _fetch_small_file(
url,
maximum_bytes=MAX_HISTORY_BYTES,
timeout_seconds=timeout_seconds,
label="release history",
)
signature_bytes = _fetch_small_file(
_signature_url(url),
maximum_bytes=MAX_SIGNATURE_BYTES,
timeout_seconds=timeout_seconds,
label="release history signature",
)
_verify_manifest_signature(history_bytes, signature_bytes, key)
return parse_launcher_release_history(history_bytes, history_url=url)
def cache_launcher_release_history(
cache_path: Path,
history: LauncherRemoteHistory,
*,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
) -> bool:
"""Cache authenticated history for display; installation always re-fetches it."""
key = public_key_path or configured_public_key_path(environment)
try:
_write_json(
cache_path,
{
"schema": RELEASE_HISTORY_SCHEMA,
"history_url": history.history_url,
"history_sha256": history.history_sha256,
"public_key_sha256": _sha256_file(key.expanduser().resolve()),
"generated_at": history.generated_at,
"releases": [
_remote_release_to_history_dict(release)
for release in history.releases
],
},
)
except (OSError, RuntimeError):
return False
return True
def read_cached_launcher_release_history(
cache_path: Path,
*,
history_url: str | None = None,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
) -> LauncherRemoteHistory | None:
"""Read display-only history previously authenticated with the current key."""
url = history_url or configured_history_url(environment)
key = public_key_path or configured_public_key_path(environment)
try:
_require_https_url(url, "release history URL")
value = json.loads(cache_path.read_text(encoding="utf-8"))
key_sha256 = _sha256_file(key.expanduser().resolve())
except (
OSError,
UnicodeError,
json.JSONDecodeError,
LauncherUpdateError,
RuntimeError,
):
return None
if not isinstance(value, dict):
return None
if value.get("schema") != RELEASE_HISTORY_SCHEMA:
return None
if value.get("history_url") != url or value.get("public_key_sha256") != key_sha256:
return None
stored_digest = value.get("history_sha256")
if not isinstance(stored_digest, str) or _SHA256_PATTERN.fullmatch(stored_digest) is None:
return None
try:
serialized = {
"application": UPDATE_APPLICATION,
"generated_at": value.get("generated_at"),
"releases": value.get("releases"),
"schema": RELEASE_HISTORY_SCHEMA,
}
history = parse_launcher_release_history(
(json.dumps(serialized, sort_keys=True) + "\n").encode("utf-8"),
history_url=url,
)
except (LauncherUpdateError, TypeError, ValueError):
return None
return replace(history, history_sha256=stored_digest)
def _remote_release_to_history_dict(
release: LauncherRemoteRelease,
) -> dict[str, object]:
return {
"channel": release.channel,
"compatibility": {
"config_schema": release.config_schema,
"minimum_config_schema": release.minimum_config_schema,
},
"manifest_signature_url": release.manifest_signature_url,
"manifest_url": release.manifest_url,
"release_date": release.release_date,
"release_id": release.release_id,
"release_notes_url": release.release_notes_url,
"status": release.status,
"tag": release.tag,
"title": release.title,
"version": release.version,
}
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,
channel: str = STABLE_CHANNEL,
) -> LauncherUpdateCheck:
"""Fetch authenticated channel metadata and resolve the greatest candidate."""
_parse_version(current_version, "current launcher version")
try:
validate_channel(channel)
except ReleaseModelError as exc:
raise LauncherUpdateError(str(exc)) from exc
configured = (
((channel, manifest_url, False),)
if manifest_url is not None
else configured_manifest_urls(channel=channel, environment=environment)
)
key = public_key_path or configured_public_key_path(environment)
candidates: list[tuple[UpdateManifest, str]] = []
for expected_channel, url, optional in configured:
_require_https_url(url, "update manifest URL")
try:
manifest = _fetch_authenticated_manifest(
url,
key,
timeout_seconds=timeout_seconds,
)
except LauncherUpdateError as exc:
if optional and "HTTP 404" in str(exc):
continue
raise
if manifest.channel != expected_channel:
raise LauncherUpdateError(
f"{expected_channel} channel URL returned a {manifest.channel} manifest"
)
candidates.append((manifest, url))
if not candidates:
raise LauncherUpdateError(f"no authenticated {channel} release metadata is available")
try:
resolver = UpdateResolver(
ReleaseRecord(
version=manifest.version,
channel=manifest.channel,
status=manifest.status,
tag=manifest.tag,
manifest_url=url,
)
for manifest, url in candidates
)
selected = resolver.latest_eligible(channel)
except ReleaseModelError as exc:
raise LauncherUpdateError(str(exc)) from exc
if selected is None:
# Preserve authenticated metadata for a clear withdrawn/blocked status.
manifest, url = max(
candidates,
key=lambda item: _parse_version(item[0].version, "manifest version"),
)
else:
manifest, url = next(
item for item in candidates if item[0].version == selected.version
)
check = _evaluate_launcher_update(
manifest,
current_version,
url,
checked_at=datetime.now().astimezone().isoformat(timespec="seconds"),
selected_channel=channel,
)
_send_launcher_release_event(
"update_release_discovered",
metadata=_launcher_release_metadata(
current_version=current_version,
target_version=manifest.version,
current_channel=SemanticVersion.parse(current_version).channel,
target_channel=manifest.channel,
selected_channel=channel,
tag=manifest.tag,
manifest_schema=manifest.schema,
manifest_sha256=manifest.manifest_sha256,
rollback=False,
),
operation="launcher_update",
stage="discovery",
)
return check
def _fetch_authenticated_manifest(
url: str,
public_key_path: Path,
*,
timeout_seconds: float,
) -> UpdateManifest:
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, public_key_path)
return parse_update_manifest(manifest_bytes)
def _fetch_historical_manifest(
release: LauncherRemoteRelease,
public_key_path: Path,
*,
timeout_seconds: float,
) -> UpdateManifest:
manifest_bytes = _fetch_small_file(
release.manifest_url,
maximum_bytes=MAX_MANIFEST_BYTES,
timeout_seconds=timeout_seconds,
label=f"launcher {release.version} manifest",
)
signature_bytes = _fetch_small_file(
release.manifest_signature_url,
maximum_bytes=MAX_SIGNATURE_BYTES,
timeout_seconds=timeout_seconds,
label=f"launcher {release.version} manifest signature",
)
_verify_manifest_signature(manifest_bytes, signature_bytes, public_key_path)
manifest = parse_update_manifest(manifest_bytes)
for field, actual, expected in (
("version", manifest.version, release.version),
("channel", manifest.channel, release.channel),
("tag", manifest.tag, release.tag),
):
if actual != expected:
raise LauncherUpdateError(
f"historical manifest {field} {actual!r} does not match "
f"signed history {expected!r}"
)
if (
release.config_schema is not None
or release.minimum_config_schema is not None
):
if release.config_schema is None or release.minimum_config_schema is None:
raise LauncherUpdateError(
"signed history has incomplete config compatibility metadata"
)
manifest = replace(
manifest,
config_schema=release.config_schema,
minimum_config_schema=release.minimum_config_schema,
)
return manifest
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,
channel: str = STABLE_CHANNEL,
) -> LauncherUpdateCheck | None:
"""Read a display-only result from the last authenticated live check."""
try:
validate_channel(channel)
except ReleaseModelError:
return None
allowed_urls = (
{manifest_url}
if manifest_url is not None
else {url for _source_channel, url, _optional in configured_manifest_urls(
channel=channel,
environment=environment,
)}
)
key = public_key_path or configured_public_key_path(environment)
try:
_parse_version(current_version, "current launcher version")
for allowed_url in allowed_urls:
_require_https_url(allowed_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
url = value.get("manifest_url")
if not isinstance(url, str) or url not in allowed_urls:
return None
if value.get("selected_channel") != channel:
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
checked_at = _cached_timestamp(value.get("checked_at"))
if checked_at is None:
return None
try:
manifest = parse_update_manifest(
(json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8")
)
cached_manifest_sha256 = manifest_value.get("manifest_sha256")
if (
isinstance(cached_manifest_sha256, str)
and _SHA256_PATTERN.fullmatch(cached_manifest_sha256) is not None
):
manifest = replace(
manifest,
manifest_sha256=cached_manifest_sha256,
)
except (LauncherUpdateError, TypeError, ValueError):
return None
return _evaluate_launcher_update(
manifest,
current_version,
url,
checked_at=checked_at,
selected_channel=channel,
)
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": check.checked_at
or datetime.now().astimezone().isoformat(timespec="seconds"),
"current_version": check.current_version,
"manifest_url": check.manifest_url,
"selected_channel": check.selected_channel,
"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,
*,
checked_at: str | None = None,
selected_channel: str = STABLE_CHANNEL,
) -> LauncherUpdateCheck:
try:
validate_channel(selected_channel)
except ReleaseModelError as exc:
raise LauncherUpdateError(str(exc)) from exc
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 and manifest.status == ACTIVE_STATUS
supported = current >= minimum
if manifest.status != ACTIVE_STATUS:
reason = f"Launcher {manifest.version} is {manifest.status} and will not be offered."
elif not available:
reason = (
"The selected channel will not downgrade this launcher."
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,
checked_at=checked_at,
selected_channel=selected_channel,
)
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,
channel: str = STABLE_CHANNEL,
) -> 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),
channel=channel,
)
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,
selected_channel=check.selected_channel,
installed_channel=SemanticVersion.parse(check.current_version).channel,
)
if not check.supported:
raise LauncherUpdateError(check.reason)
from_channel = SemanticVersion.parse(check.current_version).channel
event_metadata = _launcher_release_metadata(
current_version=check.current_version,
target_version=check.manifest.version,
current_channel=from_channel,
target_channel=check.manifest.channel,
selected_channel=check.selected_channel,
tag=check.manifest.tag,
manifest_schema=check.manifest.schema,
manifest_sha256=check.manifest.manifest_sha256,
rollback=False,
)
paths.create_base_directories()
with _launcher_mutation_lock(paths, "launcher-update"):
_recover_interrupted_activation(paths)
_precheck_release_install(paths, check.manifest)
archive = paths.launcher_update_archive(check.manifest.version)
_download_archive(
check.manifest,
archive,
timeout_seconds=timeout_seconds,
event_family="update",
event_metadata=event_metadata,
)
_send_launcher_release_event(
"update_apply_started",
metadata=event_metadata,
operation="launcher_update",
stage="apply",
)
try:
release_directory = _stage_and_install_release(paths, archive, check.manifest)
previous = _activate_release(
paths,
release_directory,
telemetry_metadata=event_metadata,
restore_reason="failed_update",
)
except BaseException as exc:
_send_launcher_release_event(
"update_apply_failed",
metadata=event_metadata,
operation="launcher_update",
stage="apply",
severity="error",
message=str(exc),
)
_record_launcher_operation(
paths,
from_version=check.current_version,
to_version=check.manifest.version,
from_channel=from_channel,
to_channel=check.manifest.channel,
selected_channel=check.selected_channel,
result="failed",
rollback=False,
reason="automatic_update",
tag=check.manifest.tag,
manifest_schema=check.manifest.schema,
manifest_sha256=check.manifest.manifest_sha256,
)
raise
_send_launcher_release_event(
"update_apply_success",
metadata=event_metadata,
operation="launcher_update",
stage="apply",
)
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,
"selected_channel": check.selected_channel,
"from_channel": from_channel,
"to_channel": check.manifest.channel,
"tag": check.manifest.tag,
"manifest_schema": check.manifest.schema,
"manifest_sha256": check.manifest.manifest_sha256,
"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}"
history_recorded = _record_launcher_operation(
paths,
from_version=check.current_version,
to_version=check.manifest.version,
from_channel=from_channel,
to_channel=check.manifest.channel,
selected_channel=check.selected_channel,
result="success",
rollback=False,
reason="automatic_update",
tag=check.manifest.tag,
manifest_schema=check.manifest.schema,
manifest_sha256=check.manifest.manifest_sha256,
)
if not history_recorded and state_warning is None:
state_warning = "launcher updated, but the local operation history could not be saved"
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,
selected_channel=check.selected_channel,
installed_channel=check.manifest.channel,
tag=check.manifest.tag,
manifest_schema=check.manifest.schema,
manifest_sha256=check.manifest.manifest_sha256,
)
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,
channel: str = STABLE_CHANNEL,
) -> 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),
channel=channel,
)
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,
event_family="update",
event_metadata=_launcher_release_metadata(
current_version=current_version,
target_version=check.manifest.version,
current_channel=SemanticVersion.parse(current_version).channel,
target_channel=check.manifest.channel,
selected_channel=check.selected_channel,
tag=check.manifest.tag,
manifest_schema=check.manifest.schema,
manifest_sha256=check.manifest.manifest_sha256,
rollback=False,
),
)
staging = root / "release"
staging.mkdir(mode=0o700)
_extract_release_archive(archive, staging, check.manifest.version)
_verify_manifest_files(staging, check.manifest)
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,
channel=check.manifest.channel,
tag=check.manifest.tag,
manifest_sha256=check.manifest.manifest_sha256,
)
def launcher_rollback_candidates(
history: LauncherRemoteHistory,
*,
installed_version: str,
selected_channel: str,
show_prereleases: bool | None = None,
) -> tuple[LauncherRemoteRelease, ...]:
"""Resolve valid rollback choices from authenticated release history."""
try:
resolver = UpdateResolver(release.resolver_record() for release in history.releases)
resolved = resolver.rollback_candidates(
installed_version,
selected_channel,
show_prereleases=show_prereleases,
)
except ReleaseModelError as exc:
raise LauncherUpdateError(str(exc)) from exc
by_version = {release.version: release for release in history.releases}
return tuple(by_version[release.version] for release in resolved)
def rollback_launcher_update(
paths: AppPaths,
*,
target_version: str | None = None,
selected_channel: str = STABLE_CHANNEL,
history_url: str | None = None,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
timeout_seconds: float = 60.0,
reason: str = "manual",
) -> LauncherRollbackResult:
"""Activate a retained or authenticated historical launcher release."""
try:
validate_channel(selected_channel)
except ReleaseModelError as exc:
raise LauncherUpdateError(str(exc)) from exc
if reason not in {
"manual",
"failed_update",
"failed_launch",
"support_recommendation",
"hotfix_recovery",
}:
raise LauncherUpdateError(f"unsupported rollback reason: {reason!r}")
paths.create_base_directories()
with _launcher_mutation_lock(paths, "launcher-rollback"):
_recover_interrupted_activation(paths)
current_release = _release_from_link(
paths.launcher_current,
paths.launcher_releases,
)
if current_release is None:
raise LauncherUpdateError("the current launcher release link is missing")
current_version = _validate_release_directory(current_release)
current_semantic = _parse_version(current_version, "current launcher version")
manifest: UpdateManifest | None = None
archive: Path | None = None
release_id: int | None = None
remote_target = False
retained_compatibility: tuple[int, int] | None = None
if target_version is None:
target_release = _release_from_link(
paths.launcher_previous,
paths.launcher_releases,
)
if target_release is None:
raise LauncherUpdateError("no previous launcher release is available")
target_version = _validate_release_directory(target_release)
retained_compatibility = _verify_retained_release_integrity(
paths,
target_release,
target_version,
)
else:
try:
target_semantic = SemanticVersion.parse(target_version)
except ReleaseModelError as exc:
raise LauncherUpdateError(f"invalid rollback version: {target_version!r}") from exc
if target_semantic >= current_semantic:
raise LauncherUpdateError(
f"rollback target {target_version} must be older than {current_version}"
)
target_release = paths.launcher_releases / target_version
if target_release.is_dir() and not target_release.is_symlink():
if _validate_release_directory(target_release) != target_version:
raise LauncherUpdateError("retained rollback release version does not match")
retained_compatibility = _verify_retained_release_integrity(
paths,
target_release,
target_version,
)
else:
remote_target = True
target_semantic = _parse_version(target_version, "rollback target version")
if target_semantic >= current_semantic:
raise LauncherUpdateError(
f"rollback target {target_version} must be older than {current_version}"
)
from_channel = current_semantic.channel
to_channel = target_semantic.channel
event_metadata = _launcher_release_metadata(
current_version=current_version,
target_version=target_version,
current_channel=from_channel,
target_channel=to_channel,
selected_channel=selected_channel,
tag=manifest.tag if manifest is not None else target_semantic.tag,
manifest_schema=manifest.schema if manifest is not None else None,
manifest_sha256=(
manifest.manifest_sha256 if manifest is not None else None
),
rollback=True,
reason=reason,
release_id=release_id,
)
_send_launcher_release_event(
"rollback_requested",
metadata=event_metadata,
operation="launcher_rollback",
stage="requested",
)
if remote_target:
history = fetch_launcher_release_history(
history_url=history_url,
public_key_path=public_key_path,
environment=environment,
timeout_seconds=min(timeout_seconds, 30.0),
)
candidates = launcher_rollback_candidates(
history,
installed_version=current_version,
selected_channel=selected_channel,
show_prereleases=target_semantic.is_prerelease,
)
record = next(
(release for release in candidates if release.version == target_version),
None,
)
if record is None:
raise LauncherUpdateError(
f"release {target_version} is not an active rollback candidate"
)
release_id = record.release_id
fetched_manifest = _fetch_historical_manifest(
record,
public_key_path or configured_public_key_path(environment),
timeout_seconds=min(timeout_seconds, 30.0),
)
_validate_rollback_config_compatibility(paths, fetched_manifest)
_precheck_release_install(paths, fetched_manifest)
manifest = fetched_manifest
archive = paths.launcher_update_archive(fetched_manifest.version)
event_metadata = _launcher_release_metadata(
current_version=current_version,
target_version=target_version,
current_channel=from_channel,
target_channel=to_channel,
selected_channel=selected_channel,
tag=fetched_manifest.tag,
manifest_schema=fetched_manifest.schema,
manifest_sha256=fetched_manifest.manifest_sha256,
rollback=True,
reason=reason,
release_id=release_id,
)
if manifest is not None and archive is not None:
_download_archive(
manifest,
archive,
timeout_seconds=timeout_seconds,
event_family="rollback",
event_metadata=event_metadata,
)
_send_launcher_release_event(
"rollback_apply_started",
metadata=event_metadata,
operation="launcher_rollback",
stage="apply",
)
try:
if manifest is not None and archive is not None:
target_release = _stage_and_install_release(paths, archive, manifest)
if current_release == target_release.resolve():
raise LauncherUpdateError("current and rollback releases are identical")
if manifest is not None:
_validate_rollback_config_compatibility(paths, manifest)
elif retained_compatibility is not None:
config_schema, minimum_config_schema = retained_compatibility
_validate_config_schema_range(
_current_config_schema(paths),
minimum_config_schema,
config_schema,
f"launcher {target_version}",
)
else:
_validate_rollback_release_compatibility(paths, target_release)
previous = _activate_release(
paths,
target_release,
telemetry_metadata=event_metadata,
restore_reason=reason,
)
if previous is None:
raise LauncherUpdateError("the current launcher release disappeared")
except BaseException as exc:
failed_tag = event_metadata.get("release_tag")
if not isinstance(failed_tag, str):
failed_tag = None
_send_launcher_release_event(
"rollback_apply_failed",
metadata=event_metadata,
operation="launcher_rollback",
stage="apply",
severity="error",
message=str(exc),
)
_record_launcher_operation(
paths,
from_version=current_version,
to_version=target_version,
from_channel=from_channel,
to_channel=to_channel,
selected_channel=selected_channel,
result="failed",
rollback=True,
reason=reason,
tag=failed_tag,
manifest_schema=manifest.schema if manifest is not None else None,
manifest_sha256=(
manifest.manifest_sha256 if manifest is not None else None
),
)
raise
_send_launcher_release_event(
"rollback_apply_success",
metadata=event_metadata,
operation="launcher_rollback",
stage="apply",
)
state_warning = None
try:
_write_json(
paths.launcher_update_state,
{
"schema": 1,
"rolled_back_at": datetime.now().astimezone().isoformat(),
"from_version": current_version,
"to_version": target_version,
"selected_channel": selected_channel,
"from_channel": from_channel,
"to_channel": to_channel,
"reason": reason,
"rollback": True,
"release_directory": str(target_release),
"previous_release": str(previous),
},
)
except OSError as exc:
# The symlink transaction is already durable. Diagnostics must not make
# a completed rollback appear to have failed or encourage a retry.
state_warning = f"launcher rolled back, but update history could not be saved: {exc}"
history_recorded = _record_launcher_operation(
paths,
from_version=current_version,
to_version=target_version,
from_channel=from_channel,
to_channel=to_channel,
selected_channel=selected_channel,
result="success",
rollback=True,
reason=reason,
tag=manifest.tag if manifest is not None else target_semantic.tag,
manifest_schema=manifest.schema if manifest is not None else None,
manifest_sha256=(
manifest.manifest_sha256 if manifest is not None else None
),
)
if not history_recorded and state_warning is None:
state_warning = "launcher rolled back, but the local operation history could not be saved"
return LauncherRollbackResult(
from_version=current_version,
to_version=target_version,
current_release=str(target_release),
previous_release=str(previous),
restart_required=True,
from_channel=from_channel,
to_channel=to_channel,
state_recorded=state_warning is None,
warning=state_warning,
)
def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory:
"""Return validated retained releases without trusting directory names alone."""
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
previous = _release_from_link(paths.launcher_previous, paths.launcher_releases)
try:
candidates = tuple(paths.launcher_releases.iterdir())
except FileNotFoundError:
candidates = ()
except OSError as exc:
raise LauncherUpdateError(f"cannot inspect installed launcher releases: {exc}") from exc
releases: list[LauncherInstalledRelease] = []
for candidate in candidates:
try:
SemanticVersion.parse(candidate.name)
except ReleaseModelError:
continue
try:
version = _validate_release_directory(candidate)
installed_at = datetime.fromtimestamp(
candidate.stat().st_mtime
).astimezone().isoformat(timespec="seconds")
except (LauncherUpdateError, OSError, ValueError, OverflowError):
continue
if version != candidate.name:
continue
resolved = candidate.resolve()
releases.append(
LauncherInstalledRelease(
version=version,
installed_at=installed_at,
directory=str(resolved),
active=resolved == current,
previous=resolved == previous,
channel=SemanticVersion.parse(version).channel,
)
)
releases.sort(
key=lambda release: _parse_version(release.version, "launcher release version"),
reverse=True,
)
return LauncherUpdateHistory(
current_version=_release_version(releases, active=True),
previous_version=_release_version(releases, previous=True),
releases=tuple(releases),
)
def parse_launcher_release_history(
data: bytes,
*,
history_url: str,
) -> LauncherRemoteHistory:
"""Strictly parse an already authenticated remote release catalog."""
if not data or len(data) > MAX_HISTORY_BYTES:
raise LauncherUpdateError("release history has an invalid size")
_require_https_url(history_url, "release history URL")
try:
value = json.loads(
data.decode("utf-8"),
object_pairs_hook=_unique_json_object,
)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
raise LauncherUpdateError(f"release history is invalid JSON: {exc}") from exc
if not isinstance(value, dict):
raise LauncherUpdateError("release history must contain a JSON object")
if value.get("application") != UPDATE_APPLICATION:
raise LauncherUpdateError(
f"release history application must be {UPDATE_APPLICATION!r}"
)
if _required_integer(value, "schema") != RELEASE_HISTORY_SCHEMA:
raise LauncherUpdateError("unsupported release history schema")
generated_at = _required_string(value, "generated_at")
_parse_published_at(generated_at)
releases_value = value.get("releases")
if not isinstance(releases_value, list) or len(releases_value) > MAX_RELEASE_HISTORY:
raise LauncherUpdateError("release history releases must be a bounded array")
releases: list[LauncherRemoteRelease] = []
seen: set[SemanticVersion] = set()
last_version: SemanticVersion | None = None
for item in releases_value:
if not isinstance(item, dict):
raise LauncherUpdateError("release history entry must be an object")
version = _required_string(item, "version")
channel = _required_string(item, "channel")
tag = _required_string(item, "tag")
try:
parsed = validate_channel_version(channel, version)
except ReleaseModelError as exc:
raise LauncherUpdateError(f"invalid release history entry: {exc}") from exc
if tag != parsed.tag:
raise LauncherUpdateError(
f"release history tag {tag!r} does not match version {version}"
)
if parsed in seen:
raise LauncherUpdateError(f"duplicate release history version: {version}")
if last_version is not None and parsed >= last_version:
raise LauncherUpdateError(
"release history entries must be sorted by descending semantic version"
)
seen.add(parsed)
last_version = parsed
status = _required_string(item, "status")
if status not in SUPPORTED_RELEASE_STATUSES:
raise LauncherUpdateError(f"unsupported release status: {status!r}")
manifest_url = _required_string(item, "manifest_url")
manifest_signature_url = _required_string(item, "manifest_signature_url")
release_date = _required_string(item, "release_date")
release_notes_url = _required_string(item, "release_notes_url")
for url, label in (
(manifest_url, "historical manifest URL"),
(manifest_signature_url, "historical manifest signature URL"),
(release_notes_url, "historical release notes URL"),
):
_require_https_url(url, label)
if _resource_url(manifest_signature_url) != _resource_url(
_signature_url(manifest_url)
):
raise LauncherUpdateError(
f"release history signature URL does not match manifest {version}"
)
_parse_published_at(release_date)
title = _required_string(item, "title")
release_id_value = item.get("release_id")
if release_id_value is not None and (
isinstance(release_id_value, bool)
or not isinstance(release_id_value, int)
or release_id_value <= 0
):
raise LauncherUpdateError("release history release_id must be positive or null")
config_schema, minimum_config_schema = _parse_history_compatibility(
item.get("compatibility")
)
releases.append(
LauncherRemoteRelease(
version=version,
channel=channel,
tag=tag,
manifest_url=manifest_url,
manifest_signature_url=manifest_signature_url,
release_date=release_date,
release_notes_url=release_notes_url,
status=status,
title=title,
release_id=release_id_value,
config_schema=config_schema,
minimum_config_schema=minimum_config_schema,
)
)
return LauncherRemoteHistory(
generated_at=generated_at,
history_url=history_url,
releases=tuple(releases),
history_sha256=hashlib.sha256(data).hexdigest(),
)
def _parse_history_compatibility(value: object) -> tuple[int | None, int | None]:
if value is None:
return None, None
if not isinstance(value, dict):
raise LauncherUpdateError("release history compatibility must be an object")
config_schema = _optional_positive_integer(value, "config_schema")
minimum_config_schema = _optional_positive_integer(
value,
"minimum_config_schema",
)
if (config_schema is None) != (minimum_config_schema is None):
raise LauncherUpdateError(
"release history compatibility must include both config schema bounds"
)
if (
config_schema is not None
and minimum_config_schema is not None
and minimum_config_schema > config_schema
):
raise LauncherUpdateError(
"release history minimum_config_schema exceeds config_schema"
)
return config_schema, minimum_config_schema
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")
application = value.get("application")
if application is not None and application != UPDATE_APPLICATION:
raise LauncherUpdateError(
f"update manifest application must be {UPDATE_APPLICATION!r}"
)
schema = _required_integer(value, "schema")
if schema != 1:
raise LauncherUpdateError(f"unsupported update manifest schema: {schema}")
channel = _required_string(value, "channel")
version = _required_string(value, "version")
minimum = _required_string(value, "minimum_updater_version")
try:
parsed_version = validate_channel_version(channel, version)
except ReleaseModelError as exc:
raise LauncherUpdateError(f"invalid update manifest release: {exc}") from exc
_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")
tag_value = value.get("tag", parsed_version.tag)
if not isinstance(tag_value, str) or tag_value != parsed_version.tag:
raise LauncherUpdateError("manifest tag does not match its version")
commit_value = value.get("commit")
if commit_value is not None and (
not isinstance(commit_value, str)
or re.fullmatch(r"[0-9a-f]{7,64}", commit_value) is None
):
raise LauncherUpdateError("manifest commit must be a lowercase Git object ID")
status_value = value.get("status", ACTIVE_STATUS)
if not isinstance(status_value, str) or status_value not in SUPPORTED_RELEASE_STATUSES:
raise LauncherUpdateError(f"unsupported release status: {status_value!r}")
title_value: str | None = None
summary_value: str | None = None
notes_markdown_value: str | None = None
release_value = value.get("release")
if release_value is not None:
if not isinstance(release_value, dict):
raise LauncherUpdateError("manifest release field must be an object")
title_value = _required_string(release_value, "title")
summary_value = _required_string(release_value, "summary")
notes_markdown_candidate = release_value.get("notes_markdown")
if notes_markdown_candidate is not None:
if (
not isinstance(notes_markdown_candidate, str)
or not notes_markdown_candidate
or "\x00" in notes_markdown_candidate
or len(notes_markdown_candidate.encode("utf-8"))
> MAX_EMBEDDED_RELEASE_NOTES_BYTES
):
raise LauncherUpdateError(
"manifest release.notes_markdown is invalid or too large"
)
notes_markdown_value = notes_markdown_candidate
release_notes_value = _required_string(release_value, "release_notes_url")
_require_https_url(release_notes_value, "release notes URL")
if notes_value and notes_value != release_notes_value:
raise LauncherUpdateError("manifest release notes URLs do not match")
notes_value = release_notes_value
archive_name = PurePosixPath(urlsplit(archive_url).path).name
archive_value = value.get("archive")
if archive_value is not None:
if not isinstance(archive_value, dict):
raise LauncherUpdateError("manifest archive field must be an object")
archive_name = _required_string(archive_value, "name")
if PurePosixPath(archive_name).name != archive_name:
raise LauncherUpdateError("manifest archive name must be a filename")
if PurePosixPath(urlsplit(archive_url).path).name != archive_name:
raise LauncherUpdateError("manifest archive name does not match its URL")
if (
_required_string(archive_value, "url") != archive_url
or _required_string(archive_value, "sha256") != archive_sha256
or _required_integer(archive_value, "size") != archive_size
):
raise LauncherUpdateError("manifest archive fields do not match legacy metadata")
config_schema = 1
minimum_config_schema = 1
compatibility_value = value.get("compatibility")
if compatibility_value is not None:
if not isinstance(compatibility_value, dict):
raise LauncherUpdateError("manifest compatibility field must be an object")
config_schema = _required_positive_integer(
compatibility_value,
"config_schema",
)
minimum_config_schema = _required_positive_integer(
compatibility_value,
"minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise LauncherUpdateError(
"manifest minimum_config_schema exceeds config_schema"
)
compatibility_manifest_schema = compatibility_value.get("manifest_schema")
if compatibility_manifest_schema is not None and compatibility_manifest_schema != schema:
raise LauncherUpdateError(
"manifest compatibility schema does not match its schema"
)
compatibility_minimum = compatibility_value.get("minimum_updater_version")
if compatibility_minimum is not None and compatibility_minimum != minimum:
raise LauncherUpdateError(
"manifest compatibility minimum updater version does not match"
)
files = _parse_manifest_files(value.get("files"))
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,
tag=tag_value,
commit=commit_value,
status=status_value,
title=title_value,
summary=summary_value,
notes_markdown=notes_markdown_value,
archive_name=archive_name,
files=files,
manifest_sha256=hashlib.sha256(data).hexdigest(),
config_schema=config_schema,
minimum_config_schema=minimum_config_schema,
)
def _parse_manifest_files(value: object) -> tuple[ManifestFile, ...]:
if value is None:
return ()
if not isinstance(value, list) or len(value) > MAX_ARCHIVE_MEMBERS:
raise LauncherUpdateError("manifest files must be a bounded array")
files: list[ManifestFile] = []
last_path = ""
for item in value:
if not isinstance(item, dict):
raise LauncherUpdateError("manifest file entry must be an object")
path_value = _required_string(item, "path")
raw_parts = path_value.split("/")
path = PurePosixPath(path_value)
if (
"\\" in path_value
or "\x00" in path_value
or any(part in {"", ".", ".."} for part in raw_parts)
or path.is_absolute()
or not path.parts
or path.parts[0] not in _ALLOWED_RELEASE_ROOTS
):
raise LauncherUpdateError(f"unsafe manifest file path: {path_value!r}")
if path_value <= last_path:
raise LauncherUpdateError("manifest files must be uniquely sorted by path")
size = _required_integer(item, "size")
if size < 0 or size > MAX_EXTRACTED_BYTES:
raise LauncherUpdateError(f"invalid manifest file size: {path_value}")
digest = _required_string(item, "sha256")
if _SHA256_PATTERN.fullmatch(digest) is None:
raise LauncherUpdateError(f"invalid manifest file SHA-256: {path_value}")
files.append(ManifestFile(path_value, size, digest))
last_path = path_value
return tuple(files)
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:
status = exc.code
exc.close()
raise LauncherUpdateError(f"{label} request failed: HTTP {status}") 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 / "manifest.json"
signature_path = root / "manifest.json.sig"
manifest_path.write_bytes(manifest)
signature_path.write_bytes(signature)
commands = (
[
openssl,
"dgst",
"-sha256",
"-verify",
str(key),
"-signature",
str(signature_path),
str(manifest_path),
],
[
openssl,
"pkeyutl",
"-verify",
"-rawin",
"-pubin",
"-inkey",
str(key),
"-sigfile",
str(signature_path),
"-in",
str(manifest_path),
],
)
for command in commands:
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
continue
if result.returncode == 0:
return
raise LauncherUpdateError("launcher update manifest signature is invalid")
def _launcher_release_metadata(
*,
current_version: str,
target_version: str,
current_channel: str,
target_channel: str,
selected_channel: str,
tag: str | None,
manifest_schema: int | None,
manifest_sha256: str | None,
rollback: bool,
reason: str | None = None,
release_id: int | None = None,
) -> dict[str, object]:
metadata: dict[str, object] = {
"current_version": current_version,
"target_version": target_version,
"current_channel": current_channel,
"target_channel": target_channel,
"selected_update_channel": selected_channel,
"release_tag": tag,
"manifest_version": manifest_schema,
"manifest_hash": manifest_sha256,
"rollback": rollback,
}
if reason is not None:
metadata["reason"] = reason
if release_id is not None:
metadata["release_id"] = release_id
if rollback:
metadata.update(
{
"from_version": current_version,
"to_version": target_version,
"from_channel": current_channel,
"to_channel": target_channel,
}
)
return metadata
def _send_launcher_release_event(
event: str,
*,
metadata: Mapping[str, object],
operation: str,
stage: str,
severity: str = "info",
message: str | None = None,
) -> None:
telemetry = current_telemetry()
if telemetry is not None:
telemetry.send_event(
event,
severity=severity,
message=message,
metadata=metadata,
context={"operation": operation, "stage": stage},
)
def _download_archive(
manifest: UpdateManifest,
destination: Path,
*,
timeout_seconds: float,
event_family: str | None = None,
event_metadata: Mapping[str, object] | None = None,
) -> None:
if destination.is_file() and _archive_matches(destination, manifest):
return
telemetry = current_telemetry()
download_started_at = time.monotonic()
operation = "launcher_rollback" if event_family == "rollback" else "launcher_update"
started_event = (
f"{event_family}_download_started"
if event_family is not None
else "download_started"
)
success_event = (
f"{event_family}_download_success"
if event_family is not None
else "download_completed"
)
failed_event = (
f"{event_family}_download_failed"
if event_family is not None
else "download_failed"
)
base_metadata = {
"resource_type": "launcher_update",
"content_bytes": manifest.archive_size,
"mirror_id": "launcher_update_channel",
**dict(event_metadata or {}),
}
if telemetry is not None:
telemetry.send_event(
started_event,
metadata=base_metadata,
context={"operation": operation, "stage": "download"},
)
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",
)
written = 0
try:
try:
response = urlopen(request, timeout=timeout_seconds)
except HTTPError as exc:
status = exc.code
exc.close()
raise LauncherUpdateError(
f"launcher archive request failed: HTTP {status}"
) 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()
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 as exc:
temporary.unlink(missing_ok=True)
if telemetry is not None:
duration = max(0.0, time.monotonic() - download_started_at)
telemetry.send_event(
failed_event,
severity="error",
message=str(exc),
metadata={
**base_metadata,
"download_bytes": written,
"duration_ms": round(duration * 1000),
"average_mbps": _average_mbps(written, duration),
"retry_count": 0,
"resume_used": False,
"http_status": network_http_status(exc),
"failure_type": classify_network_failure(exc),
},
context={"operation": operation, "stage": "download"},
)
raise
duration = max(0.0, time.monotonic() - download_started_at)
if telemetry is not None:
telemetry.send_event(
success_event,
metadata={
**base_metadata,
"download_bytes": written,
"duration_ms": round(duration * 1000),
"average_mbps": _average_mbps(written, duration),
"retry_count": 0,
"resume_used": False,
"http_status": 200,
},
context={"operation": operation, "stage": "download"},
)
def _average_mbps(download_bytes: int, duration_seconds: float) -> float | None:
if download_bytes <= 0 or duration_seconds <= 0:
return None
return round((download_bytes * 8) / duration_seconds / 1_000_000, 3)
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)
_verify_manifest_files(staging, manifest)
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}"
)
_validate_release_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)
_write_release_integrity_record(paths, destination, manifest)
return destination
except BaseException:
if staging.exists() and staging.is_dir() and not staging.is_symlink():
shutil.rmtree(staging)
raise
def _verify_manifest_files(release: Path, manifest: UpdateManifest) -> None:
_verify_file_inventory(release, manifest.files)
def _verify_file_inventory(
release: Path,
files: tuple[ManifestFile, ...],
) -> None:
if not files:
return
expected = {entry.path: entry for entry in files}
actual: set[str] = set()
for path in release.rglob("*"):
if not path.is_file():
continue
relative = path.relative_to(release).as_posix()
actual.add(relative)
entry = expected.get(relative)
if entry is None:
raise LauncherUpdateError(
f"launcher archive file is absent from its signed manifest: {relative}"
)
if path.stat().st_size != entry.size or _sha256_file(path) != entry.sha256:
raise LauncherUpdateError(
f"launcher archive file does not match its signed manifest: {relative}"
)
missing = sorted(set(expected).difference(actual))
if missing:
raise LauncherUpdateError(
"launcher archive is missing signed manifest files: " + ", ".join(missing)
)
def _write_release_integrity_record(
paths: AppPaths,
release: Path,
manifest: UpdateManifest,
) -> None:
if not manifest.files:
return
directory = paths.launcher_release_integrity
if directory.is_symlink():
raise LauncherUpdateError(
f"launcher release-integrity directory cannot be a link: {directory}"
)
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
directory.chmod(0o700)
if release.resolve() != (paths.launcher_releases / manifest.version).resolve():
raise LauncherUpdateError("launcher integrity record release path is inconsistent")
_write_json(
directory / f"{manifest.version}.json",
{
"schema": 1,
"version": manifest.version,
"channel": manifest.channel,
"tag": manifest.tag or SemanticVersion.parse(manifest.version).tag,
"manifest_schema": manifest.schema,
"manifest_sha256": manifest.manifest_sha256,
"config_schema": manifest.config_schema,
"minimum_config_schema": manifest.minimum_config_schema,
"files": [asdict(entry) for entry in manifest.files],
},
)
def _verify_retained_release_integrity(
paths: AppPaths,
release: Path,
version: str,
) -> tuple[int, int] | None:
"""Verify a retained release against its install-time signed file inventory."""
record = paths.launcher_release_integrity / f"{version}.json"
try:
if record.is_symlink():
raise LauncherUpdateError(f"launcher integrity record cannot be a link: {record}")
data = record.read_bytes()
except FileNotFoundError:
# Releases installed by an older launcher predate integrity persistence.
return None
except OSError as exc:
raise LauncherUpdateError(f"cannot read launcher integrity record: {exc}") from exc
if not data or len(data) > MAX_MANIFEST_BYTES:
raise LauncherUpdateError("launcher integrity record has an invalid size")
try:
value = json.loads(data.decode("utf-8"), object_pairs_hook=_unique_json_object)
except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
raise LauncherUpdateError(f"launcher integrity record is invalid: {exc}") from exc
if not isinstance(value, dict) or value.get("schema") != 1:
raise LauncherUpdateError("launcher integrity record has an unsupported schema")
semantic = _parse_version(version, "retained launcher version")
if (
value.get("version") != version
or value.get("channel") != semantic.channel
or value.get("tag") != semantic.tag
or value.get("manifest_schema") != 1
):
raise LauncherUpdateError("launcher integrity record identity does not match release")
manifest_sha256 = value.get("manifest_sha256")
if manifest_sha256 is not None and (
not isinstance(manifest_sha256, str)
or _SHA256_PATTERN.fullmatch(manifest_sha256) is None
):
raise LauncherUpdateError("launcher integrity record manifest hash is invalid")
config_schema = _required_positive_integer(value, "config_schema")
minimum_config_schema = _required_positive_integer(
value,
"minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise LauncherUpdateError(
"launcher integrity record has invalid config compatibility"
)
files = _parse_manifest_files(value.get("files"))
if not files:
raise LauncherUpdateError("launcher integrity record has no managed files")
_verify_file_inventory(release, files)
return config_schema, minimum_config_schema
def _extract_release_archive(archive: Path, destination: Path, version: str) -> None:
expected_root = f"{UPDATE_ARCHIVE_PREFIX}{version}"
archive_layout: str | None = None
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:
raise LauncherUpdateError(
f"unsafe launcher archive path: {member.name!r}"
)
if path.parts[0] == expected_root:
member_layout = "wrapped"
elif path.parts[0] in _ALLOWED_RELEASE_ROOTS:
member_layout = "flat"
else:
raise LauncherUpdateError(
f"unexpected launcher archive path: {member.name!r}"
)
if archive_layout is None:
archive_layout = member_layout
elif archive_layout != member_layout:
raise LauncherUpdateError(
"launcher archive mixes flat and legacy wrapped paths"
)
relative = (
PurePosixPath(*path.parts[1:])
if archive_layout == "wrapped"
else path
)
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 (
relative.parts[0] == "release.json"
and (len(relative.parts) != 1 or not member.isfile())
):
raise LauncherUpdateError(
f"unexpected launcher metadata path: {member.name!r}"
)
if (
len(relative.parts) == 1
and relative.parts[0] in {"ptlinux", "ptdiag"}
and not member.isdir()
):
raise LauncherUpdateError(
f"launcher package root must be a directory: {member.name!r}"
)
if member.mode & 0o022:
raise LauncherUpdateError(
f"launcher archive member is group/world writable: {member.name!r}"
)
permissions = member.mode & 0o777
if member.isfile() and permissions not in {0o644, 0o755}:
raise LauncherUpdateError(
f"launcher archive member has unsupported permissions: {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(permissions)
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}")
_remove_generated_bytecode_caches(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 = _read_release_metadata(release)
version = metadata.get("version")
if not isinstance(version, str):
raise LauncherUpdateError("launcher release metadata has no version")
parsed_version = _parse_version(version, "launcher release version")
channel = metadata.get("channel")
if channel is not None and channel != parsed_version.channel:
raise LauncherUpdateError("launcher release channel does not match its version")
tag = metadata.get("tag")
if tag is not None and tag != parsed_version.tag:
raise LauncherUpdateError("launcher release tag does not match its 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}")
bundled_gamescope = (
release
/ "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
)
if bundled_gamescope.exists():
mode = bundled_gamescope.stat().st_mode
if not stat.S_ISREG(mode) or not mode & 0o111:
raise LauncherUpdateError(
"launcher release contains a non-executable Gamescope compatibility binary"
)
return version
def _remove_generated_bytecode_caches(release: Path) -> None:
"""Remove interpreter caches that are never part of a signed release."""
try:
cache_directories = sorted(
release.rglob("__pycache__"),
key=lambda path: len(path.parts),
reverse=True,
)
except OSError as exc:
raise LauncherUpdateError(
f"cannot inspect launcher bytecode caches in {release}: {exc}"
) from exc
for cache in cache_directories:
if cache.is_symlink() or not cache.is_dir():
raise LauncherUpdateError(f"launcher bytecode cache is unsafe: {cache}")
try:
entries = tuple(cache.iterdir())
except OSError as exc:
raise LauncherUpdateError(
f"cannot inspect launcher bytecode cache {cache}: {exc}"
) from exc
for entry in entries:
if entry.is_symlink() or not entry.is_file() or entry.suffix not in {".pyc", ".pyo"}:
raise LauncherUpdateError(
f"launcher bytecode cache contains an unexpected path: {entry}"
)
try:
for entry in entries:
entry.unlink()
cache.rmdir()
except OSError as exc:
raise LauncherUpdateError(
f"cannot remove launcher bytecode cache {cache}: {exc}"
) from exc
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, ptlinux.ui; "
"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 "")
)
@contextmanager
def _launcher_mutation_lock(paths: AppPaths, operation: str) -> Iterator[None]:
"""Exclude game launches and concurrent release-link mutations."""
with launcher_operation_lock(
paths.launcher_operation_lock,
operation,
active_game_probe=lambda: managed_game_process_is_active(
paths.runtime_view,
(paths.proton_prefix, paths.umu_prefix),
),
):
with launcher_operation_lock(paths.launcher_update_lock, operation):
yield
def _precheck_release_install(paths: AppPaths, manifest: UpdateManifest) -> None:
"""Reject a release before download when local transaction prerequisites fail."""
_validate_rollback_config_compatibility(paths, manifest)
for directory, label in (
(paths.downloads, "launcher download cache"),
(paths.app_home, "launcher application directory"),
):
directory.mkdir(parents=True, exist_ok=True)
if directory.is_symlink() or not directory.is_dir() or not os.access(directory, os.W_OK):
raise LauncherUpdateError(f"{label} is not a writable directory: {directory}")
archive = paths.launcher_update_archive(manifest.version)
cache_bytes = 0 if _archive_matches(archive, manifest) else manifest.archive_size
extracted_bytes = (
sum(file.size for file in manifest.files)
if manifest.files
else min(MAX_EXTRACTED_BYTES, manifest.archive_size * 4)
)
_require_free_space(paths.downloads, cache_bytes + 1024 * 1024, "download")
_require_free_space(paths.app_home, extracted_bytes + 1024 * 1024, "staging")
def _require_free_space(path: Path, required_bytes: int, label: str) -> None:
try:
free_bytes = shutil.disk_usage(path).free
except OSError as exc:
raise LauncherUpdateError(f"cannot inspect free space for {path}: {exc}") from exc
if free_bytes < required_bytes:
raise LauncherUpdateError(
f"insufficient free disk space for launcher {label}: "
f"need {required_bytes} bytes, have {free_bytes}"
)
def _current_config_schema(paths: AppPaths) -> int:
from .settings import SETTINGS_SCHEMA
try:
data = paths.settings_file.read_bytes()
except FileNotFoundError:
return SETTINGS_SCHEMA
except OSError as exc:
raise LauncherUpdateError(f"cannot inspect launcher settings schema: {exc}") from exc
if len(data) > 1024 * 1024:
raise LauncherUpdateError("launcher settings file is too large to validate")
try:
value = json.loads(data.decode("utf-8-sig"), object_pairs_hook=_unique_json_object)
except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
raise LauncherUpdateError(f"launcher settings are invalid: {exc}") from exc
if not isinstance(value, dict):
raise LauncherUpdateError("launcher settings must contain an object")
schema = value.get("schema", SETTINGS_SCHEMA)
if isinstance(schema, bool) or not isinstance(schema, int) or schema <= 0:
raise LauncherUpdateError("launcher settings schema is invalid")
return schema
def _validate_rollback_config_compatibility(
paths: AppPaths,
manifest: UpdateManifest,
) -> None:
_validate_config_schema_range(
_current_config_schema(paths),
manifest.minimum_config_schema,
manifest.config_schema,
f"launcher {manifest.version}",
)
def _validate_rollback_release_compatibility(paths: AppPaths, release: Path) -> None:
metadata = _read_release_metadata(release)
config_schema = metadata.get("config_schema", 1)
minimum_config_schema = metadata.get("minimum_config_schema", 1)
if (
isinstance(config_schema, bool)
or not isinstance(config_schema, int)
or isinstance(minimum_config_schema, bool)
or not isinstance(minimum_config_schema, int)
):
raise LauncherUpdateError("launcher release has invalid config compatibility")
_validate_config_schema_range(
_current_config_schema(paths),
minimum_config_schema,
config_schema,
f"launcher {metadata.get('version', release.name)}",
)
def _validate_config_schema_range(
current: int,
minimum: int,
maximum: int,
label: str,
) -> None:
if minimum <= 0 or maximum <= 0 or minimum > maximum:
raise LauncherUpdateError(f"{label} has invalid config schema compatibility")
if current < minimum or current > maximum:
raise LauncherUpdateError(
f"{label} supports config schemas {minimum} through {maximum}, "
f"but the installed configuration uses schema {current}"
)
def _read_release_metadata(release: Path) -> dict[str, object]:
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")
return metadata
def _activate_release(
paths: AppPaths,
release: Path,
*,
telemetry_metadata: Mapping[str, object] | None = None,
restore_reason: str = "failed_update",
) -> 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)
_recover_interrupted_activation(paths)
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
previous = _release_from_link(paths.launcher_previous, 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}"
)
resolved_release = release.resolve()
_validate_release_directory(resolved_release)
if current == resolved_release:
return current
_write_activation_journal(
paths,
state="applying",
current=current,
previous=previous,
target=resolved_release,
)
try:
if current is not None:
_validate_release_directory(current)
_replace_release_link(
paths.launcher_previous,
current,
paths.launcher_releases,
)
_replace_release_link(
paths.launcher_current,
resolved_release,
paths.launcher_releases,
)
_fsync_directory(paths.app_home)
activated = _release_from_link(paths.launcher_current, paths.launcher_releases)
retained_previous = _release_from_link(
paths.launcher_previous,
paths.launcher_releases,
)
expected_previous = current if current is not None else previous
if activated is None or activated != resolved_release or retained_previous != expected_previous:
raise LauncherUpdateError(
"launcher release links failed their post-activation health check"
)
assert activated is not None
_validate_release_directory(activated)
_write_activation_journal(
paths,
state="activated",
current=current,
previous=previous,
target=resolved_release,
)
except BaseException as activation_error:
restore_metadata = {
**dict(telemetry_metadata or {}),
"reason": restore_reason,
}
_send_launcher_release_event(
"rollback_restore_started",
metadata=restore_metadata,
operation="launcher_rollback",
stage="restore",
)
try:
_restore_release_links(paths, current=current, previous=previous)
except BaseException as restore_error:
_send_launcher_release_event(
"rollback_restore_failed",
metadata=restore_metadata,
operation="launcher_rollback",
stage="restore",
severity="critical",
message=str(restore_error),
)
raise LauncherUpdateError(
f"launcher activation failed ({activation_error}); "
f"snapshot restoration also failed ({restore_error})"
) from activation_error
paths.launcher_update_transaction.unlink(missing_ok=True)
_fsync_directory(paths.launcher_update_transaction.parent)
_send_launcher_release_event(
"rollback_restore_success",
metadata=restore_metadata,
operation="launcher_rollback",
stage="restore",
severity="warning",
)
raise
paths.launcher_update_transaction.unlink(missing_ok=True)
_fsync_directory(paths.launcher_update_transaction.parent)
return current
def _recover_interrupted_activation(paths: AppPaths) -> None:
journal = paths.launcher_update_transaction
try:
if journal.is_symlink():
raise LauncherUpdateError(f"launcher transaction journal cannot be a link: {journal}")
value = json.loads(
journal.read_text(encoding="utf-8"),
object_pairs_hook=_unique_json_object,
)
except FileNotFoundError:
return
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
raise LauncherUpdateError(f"launcher transaction journal is invalid: {exc}") from exc
if not isinstance(value, dict) or value.get("schema") != 1:
raise LauncherUpdateError("launcher transaction journal has an unsupported schema")
state = value.get("state")
if state not in {"applying", "activated"}:
raise LauncherUpdateError("launcher transaction journal has an invalid state")
current = _journal_release(paths, value.get("current"), "current")
previous = _journal_release(paths, value.get("previous"), "previous")
target_version = _journal_version(value.get("target"), "target")
target_path = paths.launcher_releases / target_version
try:
_validate_release_directory(target_path)
except LauncherUpdateError:
target = None
else:
target = target_path.resolve()
if state == "activated":
active = _release_from_link(paths.launcher_current, paths.launcher_releases)
retained_previous = _release_from_link(
paths.launcher_previous,
paths.launcher_releases,
)
expected_previous = current if current is not None else previous
if target is not None and active == target and retained_previous == expected_previous:
journal.unlink()
_fsync_directory(journal.parent)
return
metadata = _recovery_telemetry_metadata(current, target_version)
_send_launcher_release_event(
"rollback_restore_started",
metadata=metadata,
operation="launcher_rollback",
stage="restore",
severity="warning",
)
try:
_restore_release_links(paths, current=current, previous=previous)
except BaseException as exc:
_send_launcher_release_event(
"rollback_restore_failed",
metadata=metadata,
operation="launcher_rollback",
stage="restore",
severity="critical",
message=str(exc),
)
raise
journal.unlink()
_fsync_directory(journal.parent)
_send_launcher_release_event(
"rollback_restore_success",
metadata=metadata,
operation="launcher_rollback",
stage="restore",
severity="warning",
)
def _recovery_telemetry_metadata(
current: Path | None,
target_version: str,
) -> dict[str, object]:
current_version = current.name if current is not None else __version__
try:
current_channel = SemanticVersion.parse(current_version).channel
except ReleaseModelError:
current_channel = STABLE_CHANNEL
target_channel = SemanticVersion.parse(target_version).channel
return _launcher_release_metadata(
current_version=current_version,
target_version=target_version,
current_channel=current_channel,
target_channel=target_channel,
selected_channel=target_channel,
tag=SemanticVersion.parse(target_version).tag,
manifest_schema=None,
manifest_sha256=None,
rollback=True,
reason="failed_update",
)
def _journal_version(value: object, label: str) -> str:
if not isinstance(value, str):
raise LauncherUpdateError(f"launcher transaction {label} release is invalid")
try:
SemanticVersion.parse(value)
except ReleaseModelError as exc:
raise LauncherUpdateError(
f"launcher transaction {label} version is invalid"
) from exc
return value
def _journal_release(paths: AppPaths, value: object, label: str) -> Path | None:
if value is None:
return None
version = _journal_version(value, label)
release = paths.launcher_releases / version
_validate_release_directory(release)
return release.resolve()
def _restore_release_links(
paths: AppPaths,
*,
current: Path | None,
previous: Path | None,
) -> None:
_set_release_link(paths.launcher_current, current, paths.launcher_releases)
_set_release_link(paths.launcher_previous, previous, paths.launcher_releases)
_fsync_directory(paths.app_home)
def _set_release_link(link: Path, release: Path | None, releases: Path) -> None:
if release is not None:
_replace_release_link(link, release, releases)
return
if link.is_symlink():
link.unlink()
elif link.exists():
raise LauncherUpdateError(f"launcher release link is not symbolic: {link}")
def _write_activation_journal(
paths: AppPaths,
*,
state: str,
current: Path | None,
previous: Path | None,
target: Path,
) -> None:
journal = paths.launcher_update_transaction
payload = {
"schema": 1,
"state": state,
"current": current.name if current is not None else None,
"previous": previous.name if previous is not None else None,
"target": target.name,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
journal.parent.mkdir(parents=True, exist_ok=True)
temporary = journal.with_name(f".{journal.name}.{os.getpid()}.tmp")
try:
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o600)
os.replace(temporary, journal)
_fsync_directory(journal.parent)
except BaseException:
temporary.unlink(missing_ok=True)
raise
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()
root_mode = root.stat().st_mode
digest.update(b"R\x00" + str(root_mode & 0o777).encode("ascii") + b"\x00")
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}")
state = item.stat()
mode = str(state.st_mode & 0o777).encode("ascii")
if stat.S_ISDIR(state.st_mode):
digest.update(b"D\x00" + relative + b"\x00" + mode + b"\x00")
elif stat.S_ISREG(state.st_mode):
digest.update(b"F\x00" + relative + b"\x00")
digest.update(mode + b"\x00")
digest.update(str(state.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) -> SemanticVersion:
try:
return SemanticVersion.parse(value)
except ReleaseModelError as exc:
raise LauncherUpdateError(f"invalid {label}: {value!r}: {exc}") from exc
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 _cached_timestamp(value: object) -> str | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None or parsed.utcoffset() is None:
return None
return value
def _release_version(
releases: list[LauncherInstalledRelease],
*,
active: bool = False,
previous: bool = False,
) -> str | None:
for release in releases:
if (active and release.active) or (previous and release.previous):
return release.version
return None
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 _required_positive_integer(value: dict[str, object], key: str) -> int:
item = _required_integer(value, key)
if item <= 0:
raise LauncherUpdateError(
f"update manifest field {key!r} must be a positive integer"
)
return item
def _optional_positive_integer(value: dict[str, object], key: str) -> int | None:
item = value.get(key)
if item is None:
return None
if isinstance(item, bool) or not isinstance(item, int) or item <= 0:
raise LauncherUpdateError(
f"release history field {key!r} must be a positive integer or null"
)
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 _record_launcher_operation(
paths: AppPaths,
*,
from_version: str,
to_version: str,
from_channel: str,
to_channel: str,
selected_channel: str,
result: str,
rollback: bool,
reason: str,
tag: str | None = None,
manifest_schema: int | None = None,
manifest_sha256: str | None = None,
) -> bool:
"""Append one bounded diagnostic record without affecting an installation."""
path = paths.launcher_update_operations
try:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
value = {"schema": LOCAL_OPERATION_HISTORY_SCHEMA, "operations": []}
if not isinstance(value, dict) or value.get("schema") != LOCAL_OPERATION_HISTORY_SCHEMA:
return False
operations = value.get("operations")
if not isinstance(operations, list):
return False
operations = [item for item in operations if isinstance(item, dict)]
operations.append(
{
"at": datetime.now().astimezone().isoformat(timespec="seconds"),
"from": from_version,
"from_channel": from_channel,
"manifest_hash": manifest_sha256,
"manifest_version": manifest_schema,
"reason": reason,
"result": result,
"rollback": rollback,
"selected_channel": selected_channel,
"tag": tag,
"to": to_version,
"to_channel": to_channel,
}
)
_write_json(
path,
{
"schema": LOCAL_OPERATION_HISTORY_SCHEMA,
"operations": operations[-MAX_LOCAL_OPERATION_HISTORY:],
},
)
except (OSError, UnicodeError, json.JSONDecodeError, ValueError, RuntimeError):
return False
return True
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)