339 lines
9.4 KiB
Python
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",
|
|
]
|