"""Native, verified updater for the public PristonTale EU depot.""" from __future__ import annotations from base64 import b64decode from binascii import Error as BinAsciiError from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait from dataclasses import asdict, dataclass from datetime import datetime import hashlib from http.client import HTTPException import json import os from pathlib import Path, PurePosixPath import re import stat import threading import time from typing import Callable, Iterable, Mapping, Protocol from urllib.error import HTTPError from urllib.parse import quote, urlparse from urllib.request import Request, urlopen from uuid import uuid4 from . import __version__ from .paths import AppPaths CDN_CONFIGURATION_URL = "https://cdn-main.chatplex.io/pristontale-eu/cdn_configuration.json" DEPOT_ID = 1 FALLBACK_SERVERS = ( "cdn-fr-sbg1.chatplex.io/pristontale-eu", "cdn-us-ore1.chatplex.io/pristontale-eu", ) READ_SIZE = 128 * 1024 HASH_READ_SIZE = 1024 * 1024 DOWNLOAD_WORKERS = 8 DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0) PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075 MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 UNMANAGED_EXECUTABLE_SUFFIXES = frozenset( { ".asi", ".bat", ".cmd", ".com", ".cpl", ".desktop", ".dll", ".drv", ".exe", ".jar", ".js", ".lnk", ".lua", ".msi", ".ocx", ".ps1", ".py", ".reg", ".scr", ".sh", ".so", ".url", ".vbe", ".vbs", ".wsf", } ) ALWAYS_VERIFY_PATHS = frozenset( {"game.exe", "game.dll", "gamese.exe", "gamese.dll"} ) SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) DEPOT_LAUNCH_CATALOG_SCHEMA = 1 class DepotError(RuntimeError): """Raised when a depot response or update operation cannot be trusted.""" class _RetryableDownloadError(DepotError): """One transient network or content-integrity failure.""" def __init__(self, message: str, *, discard_partial: bool = False) -> None: super().__init__(message) self.discard_partial = discard_partial @dataclass(frozen=True) class DepotFile: """One exact file in the published depot build.""" path: str build: int size: int sha1: str @dataclass(frozen=True) class CreateIfMissing: """A small publisher-provided file created only when absent.""" path: str content_base64: str @dataclass(frozen=True) class DepotStartOption: """One publisher-advertised realm launch target.""" name: str path: str arguments: str DEFAULT_DEPOT_START_OPTION = DepotStartOption( name="Babel", path="Game.exe", arguments="PTEU_REBORN", ) @dataclass(frozen=True) class DepotSnapshot: """The current, verified shape of the public game depot.""" region: str server: str name: str build: int version: str files: tuple[DepotFile, ...] deleted_files: tuple[str, ...] create_if_missing: tuple[CreateIfMissing, ...] start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) default_start_option: str = DEFAULT_DEPOT_START_OPTION.name @dataclass(frozen=True) class DepotLaunchCatalog: """Small cached subset of a depot manifest needed for offline Play.""" build: int start_options: tuple[DepotStartOption, ...] default_start_option: str def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog: return DepotLaunchCatalog( build=snapshot.build, start_options=snapshot.start_options, default_start_option=snapshot.default_start_option, ) def select_depot_start_option( catalog: DepotLaunchCatalog | None, preferred_realm: str | None = None, ) -> DepotStartOption: """Choose a published realm, falling back to Babel when possible.""" current = catalog or DepotLaunchCatalog( build=0, start_options=(DEFAULT_DEPOT_START_OPTION,), default_start_option=DEFAULT_DEPOT_START_OPTION.name, ) if preferred_realm: preferred = preferred_realm.casefold() for option in current.start_options: if option.name.casefold() == preferred: return option for option in current.start_options: if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold(): return option for option in current.start_options: if option.name.casefold() == current.default_start_option.casefold(): return option return current.start_options[0] def cache_depot_launch_catalog(path: Path, snapshot: DepotSnapshot) -> None: """Cache only publisher-advertised launch metadata for network-free Play.""" catalog = launch_catalog_from_snapshot(snapshot) _write_json( path, { "schema": DEPOT_LAUNCH_CATALOG_SCHEMA, "build": catalog.build, "default_start_option": catalog.default_start_option, "start_options": [asdict(option) for option in catalog.start_options], }, ) def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None: """Read the last valid launch catalog without performing network I/O.""" try: value = json.loads(path.read_text(encoding="utf-8")) if not isinstance(value, dict): return None if value.get("schema") != DEPOT_LAUNCH_CATALOG_SCHEMA: return None build = _positive_int(value.get("build"), "cached depot build") raw_options = value.get("start_options") if not isinstance(raw_options, list): return None manifest_options = [ { "Name": item.get("name"), "Path": item.get("path"), "Arguments": item.get("arguments"), } if isinstance(item, dict) else item for item in raw_options ] options, default_name = _parse_start_options( manifest_options, value.get("default_start_option"), ) except (OSError, UnicodeError, json.JSONDecodeError, DepotError): return None return DepotLaunchCatalog( build=build, start_options=options, default_start_option=default_name, ) @dataclass(frozen=True) class PlannedDownload: file: DepotFile reason: str @dataclass(frozen=True) class UpdatePlan: """The safe changes needed to make a local tree match one depot snapshot.""" downloads: tuple[PlannedDownload, ...] deletes: tuple[str, ...] files_total: int bytes_total: int @dataclass(frozen=True) class SyncProgress: """A thread-safe progress snapshot emitted by ``sync_managed_game``.""" phase: str files_completed: int files_total: int bytes_completed: int bytes_total: int current_file: str | None bytes_transferred: int = 0 @dataclass(frozen=True) class DepotSyncResult: """Persistent result of one native depot synchronization.""" region: str server: str name: str build: int version: str files_total: int files_downloaded: int bytes_downloaded: int files_deleted: int files_created: int files_quarantined: int quarantine_directory: str | None repair: bool dry_run: bool completed_at: str @dataclass(frozen=True) class QuarantineResult: """Recoverable unmanaged entries removed before authenticating an import.""" files: int directory: str | None ProgressCallback = Callable[[SyncProgress], None] class HttpResponse(Protocol): """The small response surface used by the native depot client.""" headers: Mapping[str, str] def read(self, size: int = -1) -> bytes: ... def close(self) -> None: ... HttpOpener = Callable[..., HttpResponse] class DepotClient: """Small HTTP client for the publisher's documented public depot endpoints.""" def __init__( self, *, timeout_seconds: float = 60.0, opener: HttpOpener = urlopen, retry_delays: tuple[float, ...] = DOWNLOAD_RETRY_DELAYS_SECONDS, sleeper: Callable[[float], None] = time.sleep, ) -> None: if timeout_seconds <= 0: raise ValueError("timeout_seconds must be positive") if any(delay < 0 for delay in retry_delays): raise ValueError("retry delays must not be negative") self.timeout_seconds = timeout_seconds self._opener = opener self._retry_delays = retry_delays self._sleeper = sleeper self._candidate_servers: tuple[str, ...] = () def fetch_snapshot(self, *, preferred_region: str = "Europe") -> DepotSnapshot: """Fetch the live depot and build manifests, trying trusted regional fallbacks.""" configuration: Mapping[str, object] | None = None configuration_error: DepotError | None = None try: configuration = self._get_json(CDN_CONFIGURATION_URL) except DepotError as exc: configuration_error = exc candidates = _server_candidates(configuration, preferred_region) self._candidate_servers = tuple(server for _region, server in candidates) last_error = configuration_error for region, server in candidates: try: depot_manifest = self._get_json(_depot_manifest_url(server)) build = _positive_int(depot_manifest.get("Build"), "depot Build") build_manifest = self._get_json(_build_manifest_url(server, build)) return _parse_snapshot(region, server, depot_manifest, build_manifest) except DepotError as exc: last_error = exc if last_error is not None: raise DepotError(f"could not load the PristonTale EU depot: {last_error}") raise DepotError("could not load the PristonTale EU depot") def download_file( self, server: str, file: DepotFile, destination: Path, *, progress: Callable[[int], None] | None = None, ) -> None: """Download, resume, and verify one file before replacing its destination. The current publisher CDN normalizes a small number of legacy text files from CRLF to LF while its manifest still hashes the CRLF bytes. A candidate is accepted only when restoring bare LF line endings produces the manifest's exact size and SHA-1. """ servers = _ordered_download_servers(server, self._candidate_servers) temporary = _download_partial_path(destination) last_error: _RetryableDownloadError | None = None attempts = len(self._retry_delays) + 1 for attempt_index in range(attempts): attempt_server = servers[attempt_index % len(servers)] try: self._download_file_once( attempt_server, file, destination, progress=progress, ) return except _RetryableDownloadError as exc: last_error = exc if exc.discard_partial: temporary.unlink(missing_ok=True) if attempt_index >= len(self._retry_delays): break self._sleeper(self._retry_delays[attempt_index]) assert last_error is not None resume_hint = ( "; partial data was retained for the next update run" if temporary.is_file() and not temporary.is_symlink() else "" ) raise DepotError( f"download failed for {file.path} after {attempts} attempts: " f"{last_error}{resume_hint}" ) from last_error def _download_file_once( self, server: str, file: DepotFile, destination: Path, *, progress: Callable[[int], None] | None, ) -> None: temporary = _download_partial_path(destination) written = 0 digest = hashlib.sha1(usedforsecurity=False) if temporary.is_symlink(): raise DepotError(f"refusing to use symbolic-link partial file for {file.path}") if temporary.exists(): if not temporary.is_file(): raise DepotError(f"partial download is not a file for {file.path}") try: with temporary.open("rb") as partial_handle: while True: chunk = partial_handle.read(HASH_READ_SIZE) if not chunk: break written += len(chunk) digest.update(chunk) except OSError as exc: raise DepotError(f"cannot inspect partial download for {file.path}: {exc}") from exc if written > file.size: temporary.unlink() written = 0 digest = hashlib.sha1(usedforsecurity=False) elif written == file.size: if digest.hexdigest().casefold() == file.sha1.casefold(): temporary.replace(destination) if progress is not None: progress(written) return temporary.unlink() written = 0 digest = hashlib.sha1(usedforsecurity=False) elif written and _recover_crlf_manifest_file(temporary, destination, file): temporary.unlink(missing_ok=True) if progress is not None: progress(file.size) return if progress is not None: progress(written) requested_offset = written headers = _headers() if requested_offset: headers["Range"] = f"bytes={requested_offset}-" url = _file_url(server, file) request = Request(url, headers=headers, method="GET") try: response = self._opener(request, timeout=self.timeout_seconds) except HTTPError as exc: if exc.code == 416 and requested_offset: raise _RetryableDownloadError( f"server rejected the saved offset for {file.path}", discard_partial=True, ) from exc raise _RetryableDownloadError( f"HTTP {exc.code} from {server} for {file.path}" ) from exc except (OSError, HTTPException) as exc: raise _RetryableDownloadError( f"could not connect to {server} for {file.path}: {exc}" ) from exc try: append = False if requested_offset: content_range = response.headers.get("Content-Range") status = getattr(response, "status", None) if status == 206 or content_range is not None: try: range_start, _range_end, range_total = _content_range(content_range) except DepotError as exc: raise _RetryableDownloadError( f"invalid resume response from {server} for {file.path}: {exc}" ) from exc if range_start != requested_offset or range_total != file.size: raise _RetryableDownloadError( f"resume response did not match {file.path}: " f"offset {range_start}/{requested_offset}, size {range_total}/{file.size}" ) append = True else: # A server may ignore Range and send a normal 200 response. In # that case safely restart this file using the full response body. written = 0 digest = hashlib.sha1(usedforsecurity=False) if progress is not None: progress(written) try: content_length = _content_length(response.headers.get("Content-Length")) except DepotError as exc: raise _RetryableDownloadError( f"invalid response length from {server} for {file.path}: {exc}" ) from exc remaining = file.size - written if content_length is not None and content_length > remaining: raise _RetryableDownloadError( f"download exceeds expected size for {file.path}: " f"server declared {content_length}, expected at most {remaining}" ) mode = "ab" if append else "wb" try: with temporary.open(mode) as handle: while True: try: chunk = response.read(READ_SIZE) except (OSError, HTTPException) as exc: raise _RetryableDownloadError( f"could not read {file.path} from {server}: {exc}" ) from exc if not chunk: break written += len(chunk) if written > file.size: raise _RetryableDownloadError( f"download exceeds expected size for {file.path}", discard_partial=True, ) digest.update(chunk) handle.write(chunk) if progress is not None: progress(written) except _RetryableDownloadError: raise except OSError as exc: raise DepotError(f"cannot write partial download for {file.path}: {exc}") from exc if written != file.size or digest.hexdigest().casefold() != file.sha1.casefold(): if _recover_crlf_manifest_file(temporary, destination, file): temporary.unlink(missing_ok=True) if progress is not None: progress(file.size) return if written != file.size: raise _RetryableDownloadError( f"incomplete response from {server} for {file.path}: " f"{written}/{file.size} bytes" ) raise _RetryableDownloadError( f"SHA-1 mismatch for {file.path} from {server}", discard_partial=True, ) temporary.replace(destination) finally: try: response.close() except (OSError, HTTPException): pass def _get_json(self, url: str) -> Mapping[str, object]: request = Request(url, headers=_headers(), method="GET") try: response = self._opener(request, timeout=self.timeout_seconds) except (OSError, HTTPException) as exc: raise DepotError(f"request failed for {url}: {exc}") from exc payload = b"" try: payload = response.read() except (OSError, HTTPException) as exc: raise DepotError(f"could not read {url}: {exc}") from exc finally: try: response.close() except (OSError, HTTPException): pass try: value = json.loads(payload.decode("utf-8-sig")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise DepotError(f"invalid JSON from {url}") from exc if not isinstance(value, dict): raise DepotError(f"unexpected JSON root from {url}") return value def plan_update( game_directory: Path, snapshot: DepotSnapshot, *, repair: bool = False, ) -> UpdatePlan: """Inspect a local game tree without writing to it.""" root = _prepare_game_root(game_directory, create=False) downloads: list[PlannedDownload] = [] for file in snapshot.files: target = _safe_target(root, file.path) if target.is_symlink(): downloads.append(PlannedDownload(file, "symbolic link")) continue if not target.is_file(): downloads.append(PlannedDownload(file, "missing")) continue try: if target.stat().st_size != file.size: downloads.append(PlannedDownload(file, "size mismatch")) continue except OSError as exc: raise DepotError(f"cannot inspect {file.path}: {exc}") from exc if ( repair or file.build == snapshot.build or file.path.casefold() in ALWAYS_VERIFY_PATHS ): if _sha1(target).casefold() != file.sha1.casefold(): downloads.append(PlannedDownload(file, "hash mismatch")) deletes: list[str] = [] for relative_path in snapshot.deleted_files: target = _safe_target(root, relative_path) if target.is_symlink() or target.is_file(): deletes.append(relative_path) elif target.exists(): raise DepotError( f"refusing to delete non-file depot target: {relative_path}" ) return UpdatePlan( downloads=tuple(downloads), deletes=tuple(deletes), files_total=len(snapshot.files), bytes_total=sum(item.file.size for item in downloads), ) def sync_managed_game( paths: AppPaths, *, repair: bool = False, quarantine_unmanaged: bool = False, dry_run: bool = False, progress: ProgressCallback | None = None, client: DepotClient | None = None, ) -> DepotSyncResult: """Synchronize the managed game directory using only official public data.""" paths.create_base_directories() depot_client = client or DepotClient() _emit_progress(progress, "manifest", 0, 0, 0, 0, None) snapshot = depot_client.fetch_snapshot() quarantine = QuarantineResult(0, None) if quarantine_unmanaged and not dry_run: quarantine = _quarantine_unmanaged_files(paths, snapshot) plan = plan_update(paths.source_game, snapshot, repair=repair) _emit_progress( progress, "planned", 0, len(plan.downloads), 0, plan.bytes_total, None, ) if dry_run: return _sync_result( snapshot, plan, 0, 0, 0, repair, True, quarantine=quarantine, ) root = _prepare_game_root(paths.source_game, create=True) _download_plan(depot_client, snapshot, root, plan, progress) deleted = _apply_deletions(root, plan.deletes) created = _apply_create_if_missing(root, snapshot.create_if_missing) result = _sync_result( snapshot, plan, len(plan.downloads), plan.bytes_total, deleted, repair, False, created=created, quarantine=quarantine, ) _write_json(paths.native_depot_state, asdict(result)) cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot) _emit_progress( progress, "complete", len(plan.downloads), len(plan.downloads), plan.bytes_total, plan.bytes_total, None, ) return result def _quarantine_unmanaged_files( paths: AppPaths, snapshot: DepotSnapshot, ) -> QuarantineResult: """Move non-depot import contents aside without following links.""" root = _prepare_game_root(paths.source_game, create=False) if not root.exists(): return QuarantineResult(0, None) allowed = { file.path for file in snapshot.files } | { entry.path for entry in snapshot.create_if_missing } candidates: list[tuple[Path, str]] = [] def walk_error(error: OSError) -> None: raise DepotError(f"cannot inspect imported game tree: {error}") from error for directory, directory_names, file_names in os.walk( root, topdown=True, followlinks=False, onerror=walk_error, ): parent = Path(directory) for name in tuple(directory_names): path = parent / name try: status = path.lstat() except OSError as exc: raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc if stat.S_ISDIR(status.st_mode): continue directory_names.remove(name) candidates.append((path, path.relative_to(root).as_posix())) for name in file_names: path = parent / name relative = path.relative_to(root).as_posix() try: status = path.lstat() except OSError as exc: raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc if not stat.S_ISREG(status.st_mode): candidates.append((path, relative)) continue if relative in allowed: continue if _safe_imported_user_file(path, relative): continue candidates.append((path, relative)) if not candidates: return QuarantineResult(0, None) base = paths.game_import_quarantine.resolve(strict=False) data_root = paths.data.resolve(strict=False) try: base.relative_to(data_root) except ValueError as exc: raise DepotError(f"import quarantine is outside the data root: {base}") from exc stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") quarantine = base / f"{stamp}-{uuid4().hex}" files_root = quarantine / "files" files_root.mkdir(parents=True, exist_ok=False) manifest = { "schema": 1, "complete": False, "created_at": datetime.now().astimezone().isoformat(), "source": str(root), "files": [relative for _path, relative in candidates], } _write_json(quarantine / "quarantine.json", manifest) for source, relative in candidates: destination = files_root.joinpath(*PurePosixPath(relative).parts) destination.parent.mkdir(parents=True, exist_ok=True) try: source.replace(destination) except OSError as exc: raise DepotError( f"could not quarantine imported entry {relative}: {exc}" ) from exc manifest["complete"] = True _write_json(quarantine / "quarantine.json", manifest) return QuarantineResult(len(candidates), str(quarantine)) def _safe_imported_user_file(path: Path, relative: str) -> bool: parts = PurePosixPath(relative).parts if not parts or parts[0].casefold() != "save": return False if path.suffix.casefold() in UNMANAGED_EXECUTABLE_SUFFIXES: return False try: with path.open("rb") as handle: prefix = handle.read(4) except OSError as exc: raise DepotError(f"cannot inspect imported user file {path}: {exc}") from exc return not ( prefix.startswith(b"MZ") or prefix.startswith(b"\x7fELF") or prefix.startswith(b"#!") ) def _download_plan( client: DepotClient, depot_snapshot: DepotSnapshot, root: Path, plan: UpdatePlan, progress: ProgressCallback | None, ) -> None: if not plan.downloads: return lock = threading.Lock() emit_lock = threading.Lock() current_bytes: dict[str, int] = {} completed_files = 0 completed_bytes = 0 transferred_bytes = 0 last_progress_emitted_at = 0.0 def emit_download_progress( current_file: str, *, force: bool = False, ) -> None: """Throttle progress I/O without holding the download-state lock. The workers can produce many chunk and file-completion updates per second. The callback writes to a subprocess pipe for the native dashboard, so retaining every event would make that pipe and its UI consumer the bottleneck. """ nonlocal last_progress_emitted_at if progress is None: return with emit_lock: now = time.monotonic() if not force and now - last_progress_emitted_at < PROGRESS_UPDATE_INTERVAL_SECONDS: return last_progress_emitted_at = now with lock: event = SyncProgress( "downloading", completed_files, len(plan.downloads), completed_bytes + sum(current_bytes.values()), plan.bytes_total, current_file, transferred_bytes, ) progress(event) def download(planned_download: PlannedDownload) -> None: target = _safe_target(root, planned_download.file.path) _ensure_target_parent(root, target) def report_file_bytes(written: int) -> None: nonlocal transferred_bytes with lock: previous = current_bytes.get(planned_download.file.path) if previous is not None and written > previous: transferred_bytes += written - previous current_bytes[planned_download.file.path] = written emit_download_progress(planned_download.file.path) client.download_file( depot_snapshot.server, planned_download.file, target, progress=report_file_bytes if progress is not None else None, ) worker_count = min(DOWNLOAD_WORKERS, len(plan.downloads)) remaining_items = iter(plan.downloads) with ThreadPoolExecutor(max_workers=worker_count) as executor: future_items: dict[Future[None], PlannedDownload] = {} def submit_next() -> bool: try: next_item = next(remaining_items) except StopIteration: return False future_items[executor.submit(download, next_item)] = next_item return True try: for _worker in range(worker_count): if not submit_next(): break while future_items: done, _pending = wait( tuple(future_items), return_when=FIRST_COMPLETED, ) completed_items: list[PlannedDownload] = [] for future in done: completed_item = future_items.pop(future) future.result() completed_items.append(completed_item) for completed_item in completed_items: with lock: completed_files += 1 completed_bytes += completed_item.file.size current_bytes.pop(completed_item.file.path, None) is_final_download = completed_files == len(plan.downloads) emit_download_progress( completed_item.file.path, force=is_final_download, ) for _item in completed_items: submit_next() except BaseException: for future in future_items: future.cancel() raise def _apply_deletions(root: Path, relative_paths: Iterable[str]) -> int: deleted = 0 for relative_path in relative_paths: target = _safe_target(root, relative_path) if target.is_symlink() or target.is_file(): target.unlink() deleted += 1 return deleted def _apply_create_if_missing( root: Path, entries: Iterable[CreateIfMissing], ) -> int: created = 0 for entry in entries: target = _safe_target(root, entry.path) if target.is_symlink(): raise DepotError(f"refusing to follow symbolic link for {entry.path}") if target.exists(): continue content = _decode_create_if_missing(entry) _ensure_target_parent(root, target) temporary = target.with_name(f".{target.name}.{uuid4().hex}.partial") try: with temporary.open("xb") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) temporary.replace(target) except BaseException: temporary.unlink(missing_ok=True) raise created += 1 return created def _sync_result( snapshot: DepotSnapshot, plan: UpdatePlan, files_downloaded: int, bytes_downloaded: int, files_deleted: int, repair: bool, dry_run: bool, *, created: int = 0, quarantine: QuarantineResult = QuarantineResult(0, None), ) -> DepotSyncResult: return DepotSyncResult( region=snapshot.region, server=snapshot.server, name=snapshot.name, build=snapshot.build, version=snapshot.version, files_total=plan.files_total, files_downloaded=files_downloaded, bytes_downloaded=bytes_downloaded, files_deleted=files_deleted, files_created=created, files_quarantined=quarantine.files, quarantine_directory=quarantine.directory, repair=repair, dry_run=dry_run, completed_at=datetime.now().astimezone().isoformat(), ) def _parse_snapshot( region: str, server: str, depot_manifest: Mapping[str, object], build_manifest: Mapping[str, object], ) -> DepotSnapshot: build = _positive_int(depot_manifest.get("Build"), "depot Build") build_manifest_build = _positive_int(build_manifest.get("b"), "build manifest b") if build_manifest_build != build: raise DepotError( f"depot build {build} does not match build manifest {build_manifest_build}" ) name = _string(depot_manifest.get("Name"), "depot Name") version = _string(depot_manifest.get("BuildVersionText"), "depot BuildVersionText") files_value = build_manifest.get("f") if not isinstance(files_value, list): raise DepotError("build manifest f must be an array") files: list[DepotFile] = [] seen_paths: set[str] = set() for value in files_value: if not isinstance(value, dict): raise DepotError("build manifest contains a non-object file entry") path = _normalise_relative_path(value.get("p"), "file path") if path.casefold() in seen_paths: raise DepotError(f"build manifest has duplicate file path: {path}") seen_paths.add(path.casefold()) file_build = _positive_int(value.get("b"), f"file build for {path}") size = _non_negative_int(value.get("s"), f"file size for {path}") sha1 = _string(value.get("h"), f"file hash for {path}") if not SHA1_PATTERN.fullmatch(sha1): raise DepotError(f"invalid SHA-1 hash for {path}") files.append(DepotFile(path, file_build, size, sha1.casefold())) deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths) create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing")) start_options, default_start_option = _parse_start_options( depot_manifest.get("StartOptions"), depot_manifest.get("DefaultStartOption"), ) canonical_file_paths = {file.path.casefold(): file.path for file in files} start_options = tuple( DepotStartOption( name=option.name, path=canonical_file_paths.get(option.path.casefold(), option.path), arguments=option.arguments, ) for option in start_options ) return DepotSnapshot( region=region, server=server, name=name, build=build, version=version, files=tuple(files), deleted_files=deleted, create_if_missing=create_if_missing, start_options=start_options, default_start_option=default_start_option, ) def _parse_deleted_paths(value: object, known_paths: set[str]) -> tuple[str, ...]: if value is None: return () if not isinstance(value, list): raise DepotError("build manifest df must be an array") paths: list[str] = [] seen: set[str] = set() for item in value: path = _normalise_relative_path(item, "deleted file path") folded = path.casefold() if folded in known_paths: raise DepotError(f"manifest lists a file as both present and deleted: {path}") if folded in seen: continue seen.add(folded) paths.append(path) return tuple(paths) def _parse_create_if_missing(value: object) -> tuple[CreateIfMissing, ...]: if value is None: return () if not isinstance(value, list): raise DepotError("CreateIfMissing must be an array") entries: list[CreateIfMissing] = [] seen: set[str] = set() for item in value: if not isinstance(item, dict): raise DepotError("CreateIfMissing contains a non-object entry") path = _normalise_relative_path(item.get("Path"), "CreateIfMissing path") folded = path.casefold() if folded in seen: raise DepotError(f"CreateIfMissing has duplicate path: {path}") seen.add(folded) content = _string(item.get("Content"), f"CreateIfMissing content for {path}") entry = CreateIfMissing(path, content) _decode_create_if_missing(entry) entries.append(entry) return tuple(entries) def _parse_start_options( value: object, default_value: object, ) -> tuple[tuple[DepotStartOption, ...], str]: """Validate the launch targets published by the official depot manifest.""" # Older test fixtures and historical manifests did not expose StartOptions. # Babel remains the launcher-safe compatibility default for that case. if value is None: return (DEFAULT_DEPOT_START_OPTION,), DEFAULT_DEPOT_START_OPTION.name if not isinstance(value, list) or not value: raise DepotError("StartOptions must be a non-empty array") options: list[DepotStartOption] = [] seen_names: set[str] = set() for item in value: if not isinstance(item, dict): raise DepotError("StartOptions contains a non-object entry") name = _start_option_text(item.get("Name"), "StartOptions Name", 64) folded_name = name.casefold() if folded_name in seen_names: raise DepotError(f"StartOptions has duplicate realm name: {name}") seen_names.add(folded_name) path = _normalise_relative_path(item.get("Path"), f"StartOptions path for {name}") if PurePosixPath(path).suffix.casefold() != ".exe": raise DepotError(f"StartOptions path is not an executable for {name}: {path}") arguments = _start_option_text( item.get("Arguments"), f"StartOptions arguments for {name}", 2048, ) options.append(DepotStartOption(name=name, path=path, arguments=arguments)) if default_value is None: default_name = next( ( option.name for option in options if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold() ), options[0].name, ) else: default_name = _start_option_text( default_value, "DefaultStartOption", 64, ) matching_default = next( (option.name for option in options if option.name.casefold() == default_name.casefold()), None, ) if matching_default is None: raise DepotError( f"DefaultStartOption does not match an advertised realm: {default_name}" ) default_name = matching_default return tuple(options), default_name def _start_option_text(value: object, field: str, maximum_length: int) -> str: if not isinstance(value, str): raise DepotError(f"{field} must be a string") text = value.strip() if not text or len(text) > maximum_length or any(ord(character) < 32 for character in text): raise DepotError(f"invalid {field}") return text def _decode_create_if_missing(entry: CreateIfMissing) -> bytes: try: content = b64decode(entry.content_base64, validate=True) except (ValueError, BinAsciiError) as exc: raise DepotError(f"invalid base64 create-if-missing entry: {entry.path}") from exc if len(content) > MAX_CREATE_IF_MISSING_BYTES: raise DepotError(f"create-if-missing entry is too large: {entry.path}") return content def _server_candidates( configuration: Mapping[str, object] | None, preferred_region: str, ) -> tuple[tuple[str, str], ...]: entries: list[tuple[str, str]] = [] if configuration is not None: regions = configuration.get("Regions") default_region = configuration.get("DefaultRegion") if isinstance(regions, list): ordered = [preferred_region] if isinstance(default_region, str) and default_region not in ordered: ordered.append(default_region) for region_name in ordered: for region in regions: if not isinstance(region, dict) or region.get("Name") != region_name: continue servers = region.get("Servers") if isinstance(servers, list): for server in servers: if not isinstance(server, str): continue try: entries.append((region_name, _normalise_server(server))) except DepotError: continue seen: set[str] = set() unique: list[tuple[str, str]] = [] for region, server in [*entries, *(("Fallback", value) for value in FALLBACK_SERVERS)]: if server in seen: continue seen.add(server) unique.append((region, server)) return tuple(unique) def _normalise_server(value: str) -> str: server = value.strip().strip("/") if not server or "://" in server or any(character.isspace() for character in server): raise DepotError(f"invalid CDN server: {value!r}") parsed = urlparse(f"https://{server}") if not parsed.netloc or parsed.query or parsed.fragment or "@" in parsed.netloc: raise DepotError(f"invalid CDN server: {value!r}") parts = PurePosixPath(parsed.path).parts if any(part in (".", "..") for part in parts): raise DepotError(f"invalid CDN server path: {value!r}") return f"{parsed.netloc}{parsed.path.rstrip('/')}" def _depot_manifest_url(server: str) -> str: return f"https://{server}/depots/{DEPOT_ID}/manifest.json" def _build_manifest_url(server: str, build: int) -> str: return f"https://{server}/depots/{DEPOT_ID}/builds/{build}.json" def _file_url(server: str, file: DepotFile) -> str: return ( f"https://{server}/depots/{DEPOT_ID}/builds/{file.build}/" f"{quote(file.path, safe='/')}" ) def _ordered_download_servers( primary: str, candidates: Iterable[str], ) -> tuple[str, ...]: ordered: list[str] = [] seen: set[str] = set() for value in (primary, *candidates): server = _normalise_server(value) if server in seen: continue seen.add(server) ordered.append(server) return tuple(ordered) def _download_partial_path(destination: Path) -> Path: return destination.with_name(f".{destination.name}.ptlinux.partial") def _recover_crlf_manifest_file( temporary: Path, destination: Path, file: DepotFile, ) -> bool: """Restore publisher-normalized legacy text only on an exact hash match.""" try: if temporary.stat().st_size > MAX_LINE_ENDING_RECOVERY_BYTES: return False data = temporary.read_bytes() except OSError as exc: raise DepotError(f"could not inspect downloaded {file.path}: {exc}") from exc if b"\r" in data: return False recovered = data.replace(b"\n", b"\r\n") if len(recovered) != file.size: return False if ( hashlib.sha1(recovered, usedforsecurity=False).hexdigest().casefold() != file.sha1.casefold() ): return False recovered_temporary = destination.with_name( f".{destination.name}.{os.getpid()}.{uuid4().hex}.recovered" ) try: with recovered_temporary.open("xb") as handle: handle.write(recovered) handle.flush() os.fsync(handle.fileno()) recovered_temporary.replace(destination) except BaseException: recovered_temporary.unlink(missing_ok=True) raise return True def _prepare_game_root(game_directory: Path, *, create: bool) -> Path: root = game_directory.expanduser() if root.is_symlink(): raise DepotError(f"managed game directory must not be a symbolic link: {root}") if root.exists() and not root.is_dir(): raise DepotError(f"managed game path is not a directory: {root}") if create: root.mkdir(parents=True, exist_ok=True) return root.resolve(strict=False) def _safe_target(root: Path, relative_path: str) -> Path: parts = _relative_parts(relative_path, "depot path") target = root.joinpath(*parts) try: target.resolve(strict=False).relative_to(root) except ValueError as exc: raise DepotError(f"depot path escapes the managed game directory: {relative_path}") from exc return target def _ensure_target_parent(root: Path, target: Path) -> None: try: relative_parent = target.parent.relative_to(root) except ValueError as exc: raise DepotError(f"target escapes managed game directory: {target}") from exc cursor = root for part in relative_parent.parts: cursor = cursor / part if cursor.is_symlink(): raise DepotError(f"refusing to traverse symbolic link in game tree: {cursor}") if cursor.exists(): if not cursor.is_dir(): raise DepotError(f"cannot create game directory over file: {cursor}") else: try: cursor.mkdir() except FileExistsError: # Another download worker can create a shared parent after # ``exists()`` and before ``mkdir()``. Accept only the directory # that the losing worker expected; never accept a file or symlink. if cursor.is_symlink(): raise DepotError( f"refusing to traverse symbolic link in game tree: {cursor}" ) if not cursor.is_dir(): raise DepotError(f"cannot create game directory over file: {cursor}") except OSError as exc: raise DepotError(f"cannot create game directory {cursor}: {exc}") from exc def _normalise_relative_path(value: object, field: str) -> str: if not isinstance(value, str): raise DepotError(f"{field} must be a string") parts = _relative_parts(value, field) return "/".join(parts) def _relative_parts(value: str, field: str) -> tuple[str, ...]: if not value or "\x00" in value: raise DepotError(f"invalid {field}") normalised = value.replace("\\", "/") if normalised.startswith("/") or normalised.startswith("//"): raise DepotError(f"{field} must be relative: {value!r}") parts = PurePosixPath(normalised).parts if not parts or any(part in ("", ".", "..") or ":" in part for part in parts): raise DepotError(f"invalid {field}: {value!r}") return tuple(parts) def _positive_int(value: object, field: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise DepotError(f"{field} must be a positive integer") return value def _non_negative_int(value: object, field: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise DepotError(f"{field} must be a non-negative integer") return value def _string(value: object, field: str) -> str: if not isinstance(value, str) or not value.strip(): raise DepotError(f"{field} must be a non-empty string") return value def _sha1(path: Path) -> str: digest = hashlib.sha1(usedforsecurity=False) try: with path.open("rb") as handle: for chunk in iter(lambda: handle.read(HASH_READ_SIZE), b""): digest.update(chunk) except OSError as exc: raise DepotError(f"cannot hash {path}: {exc}") from exc return digest.hexdigest() def _content_length(value: str | None) -> int | None: if value is None: return None try: length = int(value) except ValueError as exc: raise DepotError(f"invalid Content-Length: {value!r}") from exc if length < 0: raise DepotError(f"invalid Content-Length: {value!r}") return length def _content_range(value: str | None) -> tuple[int, int, int]: if value is None: raise DepotError("206 response omitted Content-Range") match = CONTENT_RANGE_PATTERN.fullmatch(value.strip()) if match is None: raise DepotError(f"invalid Content-Range: {value!r}") start, end, total = (int(part) for part in match.groups()) if start > end or end >= total: raise DepotError(f"invalid Content-Range: {value!r}") return start, end, total def _headers() -> dict[str, str]: return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"} 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: temporary.write_text( json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) temporary.replace(path) except BaseException: temporary.unlink(missing_ok=True) raise def _emit_progress( callback: ProgressCallback | None, phase: str, files_completed: int, files_total: int, bytes_completed: int, bytes_total: int, current_file: str | None, ) -> None: if callback is not None: callback( SyncProgress( phase, files_completed, files_total, bytes_completed, bytes_total, current_file, ) )