This commit is contained in:
2026-08-27 17:50:36 +02:00
commit 5a0e6520f4
51 changed files with 14027 additions and 0 deletions

857
ptlinux/self_update.py Normal file
View File

@@ -0,0 +1,857 @@
"""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"}
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)
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=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"),
):
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)