Added more folders and restructured classes inside them.
This commit is contained in:
338
src/ptlinux/game/depot/filesystem.py
Normal file
338
src/ptlinux/game/depot/filesystem.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user