"""Filesystem mutation and quarantine operations for the game depot.""" from __future__ import annotations from datetime import datetime import hashlib import json import os from pathlib import Path, PurePosixPath import stat from typing import Callable, Iterable from uuid import uuid4 from .integrity import prepare_game_root, safe_target from .manifest import decode_create_if_missing from .models import CreateIfMissing, DepotError, DepotFile, DepotSnapshot, QuarantineResult DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 DEFAULT_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", } ) def quarantine_unmanaged_files( source_game: Path, quarantine_base: Path, data_root: Path, snapshot: DepotSnapshot, *, unmanaged_executable_suffixes: frozenset[str] = ( DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES ), prepare_root: Callable[..., Path] = prepare_game_root, safe_user_file: Callable[[Path, str], bool] | None = None, write_manifest: Callable[[Path, object], None] | None = None, clock: Callable[[], datetime] | None = None, identifier: Callable[[], str] | None = None, ) -> QuarantineResult: """Move non-depot import contents aside without following links.""" root = prepare_root(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]] = [] user_file_is_safe = safe_user_file or ( lambda candidate_path, candidate_relative: safe_imported_user_file( candidate_path, candidate_relative, executable_suffixes=unmanaged_executable_suffixes, ) ) 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 user_file_is_safe(path, relative): continue candidates.append((path, relative)) if not candidates: return QuarantineResult(0, None) base = quarantine_base.resolve(strict=False) resolved_data_root = data_root.resolve(strict=False) try: base.relative_to(resolved_data_root) except ValueError as exc: raise DepotError(f"import quarantine is outside the data root: {base}") from exc now = clock or (lambda: datetime.now().astimezone()) new_identifier = identifier or (lambda: uuid4().hex) stamp = now().strftime("%Y%m%d-%H%M%S") quarantine = base / f"{stamp}-{new_identifier()}" files_root = quarantine / "files" files_root.mkdir(parents=True, exist_ok=False) manifest = { "schema": 1, "complete": False, "created_at": now().isoformat(), "source": str(root), "files": [relative for _path, relative in candidates], } write = write_manifest or write_json write(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(quarantine / "quarantine.json", manifest) return QuarantineResult(len(candidates), str(quarantine)) def safe_imported_user_file( path: Path, relative: str, *, executable_suffixes: frozenset[str] = DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES, ) -> bool: """Return whether an unmanaged save entry is safe to leave in place.""" parts = PurePosixPath(relative).parts if not parts or parts[0].casefold() != "save": return False if path.suffix.casefold() in 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 apply_deletions( root: Path, relative_paths: Iterable[str], *, target_for: Callable[[Path, str], Path] = safe_target, ) -> int: """Delete publisher-retired files and links within the managed root.""" deleted = 0 for relative_path in relative_paths: target = target_for(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], *, target_for: Callable[[Path, str], Path] = safe_target, decode_entry: Callable[[CreateIfMissing], bytes] = decode_create_if_missing, ensure_parent: Callable[[Path, Path], None] | None = None, identifier: Callable[[], str] | None = None, ) -> int: """Atomically create publisher defaults while preserving existing files.""" created = 0 prepare_parent = ensure_parent or ensure_target_parent new_identifier = identifier or (lambda: uuid4().hex) for entry in entries: target = target_for(root, entry.path) if target.is_symlink(): raise DepotError(f"refusing to follow symbolic link for {entry.path}") if target.exists(): continue content = decode_entry(entry) prepare_parent(root, target) temporary = target.with_name(f".{target.name}.{new_identifier()}.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 recover_crlf_manifest_file( temporary: Path, destination: Path, file: DepotFile, *, maximum_bytes: int = DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES, process_id: Callable[[], int] = os.getpid, identifier: Callable[[], str] | None = None, ) -> bool: """Restore publisher-normalized legacy text only on an exact hash match.""" try: if temporary.stat().st_size > maximum_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 new_identifier = identifier or (lambda: uuid4().hex) recovered_temporary = destination.with_name( f".{destination.name}.{process_id()}.{new_identifier()}.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 ensure_target_parent(root: Path, target: Path) -> None: """Create a target's parent directories without traversing symlinks.""" 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: 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 write_json(path: Path, value: object, *, compact: bool = False) -> None: """Atomically serialize one JSON document beside its destination.""" path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") try: payload = ( json.dumps(value, separators=(",", ":"), sort_keys=True) if compact else json.dumps(value, indent=2, sort_keys=True) ) temporary.write_text(payload + "\n", encoding="utf-8") temporary.replace(path) except BaseException: temporary.unlink(missing_ok=True) raise __all__ = [ "DEFAULT_MAX_LINE_ENDING_RECOVERY_BYTES", "DEFAULT_UNMANAGED_EXECUTABLE_SUFFIXES", "apply_create_if_missing", "apply_deletions", "ensure_target_parent", "quarantine_unmanaged_files", "recover_crlf_manifest_file", "safe_imported_user_file", "write_json", ]