Files
pristontale-eu-linux/src/ptlinux/game/depot/filesystem.py

339 lines
9.4 KiB
Python

"""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",
]