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