Added asset checks and fixes for missing data in game client.
This commit is contained in:
447
ptlinux/depot.py
447
ptlinux/depot.py
@@ -82,6 +82,8 @@ ALWAYS_VERIFY_PATHS = frozenset(
|
||||
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
|
||||
DEPOT_FILE_CATALOG_SCHEMA = 1
|
||||
MAX_PREFLIGHT_ISSUES = 32
|
||||
|
||||
|
||||
class DepotError(RuntimeError):
|
||||
@@ -424,6 +426,64 @@ class DepotLaunchCatalog:
|
||||
default_start_option: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotFileCatalogEntry:
|
||||
"""One manifest file plus the identity recorded after verified installation."""
|
||||
|
||||
path: str
|
||||
size: int
|
||||
sha1: str
|
||||
device: int
|
||||
inode: int
|
||||
mtime_ns: int
|
||||
ctime_ns: int
|
||||
verified: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotFileCatalog:
|
||||
"""Cached manifest used for fast, network-free launch preflight checks."""
|
||||
|
||||
build: int
|
||||
files: tuple[DepotFileCatalogEntry, ...]
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotIntegrityIssue:
|
||||
path: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotPreflightResult:
|
||||
"""Outcome of validating cached file identities before starting the game."""
|
||||
|
||||
build: int
|
||||
files_total: int
|
||||
verified_files: int
|
||||
unverified_files: int
|
||||
changed_files_hashed: int
|
||||
issue_count: int
|
||||
issues: tuple[DepotIntegrityIssue, ...]
|
||||
duration_ms: int
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self.issue_count == 0
|
||||
|
||||
@property
|
||||
def fully_verified(self) -> bool:
|
||||
return self.ready and self.unverified_files == 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ScannedGameEntry:
|
||||
path: Path
|
||||
state: os.stat_result
|
||||
kind: str
|
||||
|
||||
|
||||
def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog:
|
||||
return DepotLaunchCatalog(
|
||||
build=snapshot.build,
|
||||
@@ -508,6 +568,206 @@ def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None:
|
||||
)
|
||||
|
||||
|
||||
def cache_depot_file_catalog(
|
||||
path: Path,
|
||||
game_directory: Path,
|
||||
snapshot: DepotSnapshot,
|
||||
*,
|
||||
verified_paths: Iterable[str],
|
||||
previous: DepotFileCatalog | None = None,
|
||||
) -> DepotFileCatalog:
|
||||
"""Record identities without rehashing files already verified by the updater."""
|
||||
|
||||
root = _prepare_game_root(game_directory, create=False)
|
||||
scanned_files = _scan_game_tree(root)
|
||||
explicitly_verified = {verified_path.casefold() for verified_path in verified_paths}
|
||||
previous_files = (
|
||||
{entry.path.casefold(): entry for entry in previous.files}
|
||||
if previous is not None
|
||||
else {}
|
||||
)
|
||||
entries: list[DepotFileCatalogEntry] = []
|
||||
for file in snapshot.files:
|
||||
scanned = scanned_files.get(file.path)
|
||||
if scanned is None:
|
||||
raise DepotError(f"cannot cache missing depot file identity: {file.path}")
|
||||
state = scanned.state
|
||||
if scanned.kind != "file" or state.st_size != file.size:
|
||||
raise DepotError(f"cannot cache invalid depot file identity: {file.path}")
|
||||
prior = previous_files.get(file.path.casefold())
|
||||
verified = file.path.casefold() in explicitly_verified
|
||||
if (
|
||||
not verified
|
||||
and prior is not None
|
||||
and prior.verified
|
||||
and prior.size == file.size
|
||||
and prior.sha1 == file.sha1
|
||||
and _catalog_identity_matches(prior, state)
|
||||
):
|
||||
verified = True
|
||||
entries.append(_catalog_entry(file, state, verified=verified))
|
||||
|
||||
catalog = DepotFileCatalog(
|
||||
build=snapshot.build,
|
||||
files=tuple(entries),
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
_write_json(
|
||||
path,
|
||||
_depot_file_catalog_payload(catalog),
|
||||
compact=True,
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
def read_cached_depot_file_catalog(path: Path) -> DepotFileCatalog | None:
|
||||
"""Read and strictly validate the local manifest/identity cache."""
|
||||
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if value.get("schema") != DEPOT_FILE_CATALOG_SCHEMA:
|
||||
return None
|
||||
build = _positive_int(value.get("build"), "cached depot file build")
|
||||
created_at = _string(value.get("created_at"), "cached depot file timestamp")
|
||||
raw_files = value.get("files")
|
||||
if not isinstance(raw_files, list):
|
||||
return None
|
||||
entries: list[DepotFileCatalogEntry] = []
|
||||
seen: set[str] = set()
|
||||
for raw in raw_files:
|
||||
entry = _parse_depot_file_catalog_entry(raw)
|
||||
if entry.path.casefold() in seen:
|
||||
return None
|
||||
seen.add(entry.path.casefold())
|
||||
entries.append(entry)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, DepotError):
|
||||
return None
|
||||
return DepotFileCatalog(build=build, files=tuple(entries), created_at=created_at)
|
||||
|
||||
|
||||
def read_current_depot_file_catalog(paths: AppPaths) -> DepotFileCatalog | None:
|
||||
"""Return the file catalog only when it matches the last completed sync."""
|
||||
|
||||
catalog = read_cached_depot_file_catalog(paths.depot_file_catalog)
|
||||
if catalog is None:
|
||||
return None
|
||||
if _completed_depot_build(paths.native_depot_state) != catalog.build:
|
||||
return None
|
||||
return catalog
|
||||
|
||||
|
||||
def preflight_managed_game(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
catalog: DepotFileCatalog | None = None,
|
||||
) -> DepotPreflightResult | None:
|
||||
"""Check all managed paths cheaply and hash only files whose identity changed."""
|
||||
|
||||
catalog = catalog or read_current_depot_file_catalog(paths)
|
||||
if catalog is None:
|
||||
return None
|
||||
if _completed_depot_build(paths.native_depot_state) != catalog.build:
|
||||
return None
|
||||
started = time.perf_counter()
|
||||
root = _prepare_game_root(paths.source_game, create=False)
|
||||
if not root.is_dir():
|
||||
return DepotPreflightResult(
|
||||
build=catalog.build,
|
||||
files_total=len(catalog.files),
|
||||
verified_files=0,
|
||||
unverified_files=sum(not entry.verified for entry in catalog.files),
|
||||
changed_files_hashed=0,
|
||||
issue_count=1,
|
||||
issues=(DepotIntegrityIssue(".", "managed game directory is missing"),),
|
||||
duration_ms=_elapsed_milliseconds(started),
|
||||
)
|
||||
try:
|
||||
scanned_files = _scan_game_tree(root)
|
||||
except DepotError as exc:
|
||||
return DepotPreflightResult(
|
||||
build=catalog.build,
|
||||
files_total=len(catalog.files),
|
||||
verified_files=0,
|
||||
unverified_files=sum(not entry.verified for entry in catalog.files),
|
||||
changed_files_hashed=0,
|
||||
issue_count=1,
|
||||
issues=(DepotIntegrityIssue(".", str(exc)),),
|
||||
duration_ms=_elapsed_milliseconds(started),
|
||||
)
|
||||
|
||||
verified_files = 0
|
||||
unverified_files = 0
|
||||
changed_files_hashed = 0
|
||||
issue_count = 0
|
||||
issues: list[DepotIntegrityIssue] = []
|
||||
updated_entries: list[DepotFileCatalogEntry] = []
|
||||
cache_changed = False
|
||||
for entry in catalog.files:
|
||||
reason = None
|
||||
state: os.stat_result | None = None
|
||||
scanned = scanned_files.get(entry.path)
|
||||
if scanned is None:
|
||||
reason = "missing"
|
||||
elif scanned.kind != "file":
|
||||
reason = scanned.kind
|
||||
elif scanned.state.st_size != entry.size:
|
||||
reason = "size mismatch"
|
||||
else:
|
||||
state = scanned.state
|
||||
|
||||
updated_entry = entry
|
||||
if reason is None and state is not None:
|
||||
if not entry.verified:
|
||||
unverified_files += 1
|
||||
elif _catalog_identity_matches(entry, state):
|
||||
verified_files += 1
|
||||
else:
|
||||
changed_files_hashed += 1
|
||||
try:
|
||||
assert scanned is not None
|
||||
matches = _sha1(scanned.path).casefold() == entry.sha1
|
||||
except DepotError as exc:
|
||||
reason = f"cannot verify changed file: {exc}"
|
||||
else:
|
||||
if matches:
|
||||
verified_files += 1
|
||||
updated_entry = _catalog_entry_from_cached(entry, state)
|
||||
cache_changed = True
|
||||
else:
|
||||
reason = "hash mismatch"
|
||||
|
||||
if reason is not None:
|
||||
issue_count += 1
|
||||
if len(issues) < MAX_PREFLIGHT_ISSUES:
|
||||
issues.append(DepotIntegrityIssue(entry.path, reason))
|
||||
updated_entries.append(updated_entry)
|
||||
|
||||
if cache_changed:
|
||||
updated_catalog = DepotFileCatalog(
|
||||
build=catalog.build,
|
||||
files=tuple(updated_entries),
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
_write_json(
|
||||
paths.depot_file_catalog,
|
||||
_depot_file_catalog_payload(updated_catalog),
|
||||
compact=True,
|
||||
)
|
||||
|
||||
return DepotPreflightResult(
|
||||
build=catalog.build,
|
||||
files_total=len(catalog.files),
|
||||
verified_files=verified_files,
|
||||
unverified_files=unverified_files,
|
||||
changed_files_hashed=changed_files_hashed,
|
||||
issue_count=issue_count,
|
||||
issues=tuple(issues),
|
||||
duration_ms=_elapsed_milliseconds(started),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlannedDownload:
|
||||
file: DepotFile
|
||||
@@ -522,6 +782,7 @@ class UpdatePlan:
|
||||
deletes: tuple[str, ...]
|
||||
files_total: int
|
||||
bytes_total: int
|
||||
hash_verified_paths: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -564,6 +825,8 @@ class DepotSyncResult:
|
||||
repair: bool
|
||||
dry_run: bool
|
||||
completed_at: str
|
||||
files_planned: int = 0
|
||||
bytes_planned: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -955,6 +1218,7 @@ def plan_update(
|
||||
|
||||
root = _prepare_game_root(game_directory, create=False)
|
||||
downloads: list[PlannedDownload] = []
|
||||
hash_verified_paths: list[str] = []
|
||||
for file in snapshot.files:
|
||||
target = _safe_target(root, file.path)
|
||||
if target.is_symlink():
|
||||
@@ -976,6 +1240,8 @@ def plan_update(
|
||||
):
|
||||
if _sha1(target).casefold() != file.sha1.casefold():
|
||||
downloads.append(PlannedDownload(file, "hash mismatch"))
|
||||
else:
|
||||
hash_verified_paths.append(file.path)
|
||||
|
||||
deletes: list[str] = []
|
||||
for relative_path in snapshot.deleted_files:
|
||||
@@ -992,6 +1258,7 @@ def plan_update(
|
||||
deletes=tuple(deletes),
|
||||
files_total=len(snapshot.files),
|
||||
bytes_total=sum(item.file.size for item in downloads),
|
||||
hash_verified_paths=tuple(hash_verified_paths),
|
||||
)
|
||||
|
||||
|
||||
@@ -1009,6 +1276,7 @@ def sync_managed_game(
|
||||
|
||||
paths.create_base_directories()
|
||||
depot_client = client or DepotClient()
|
||||
previous_file_catalog = read_current_depot_file_catalog(paths)
|
||||
_emit_progress(progress, "manifest", 0, 0, 0, 0, None)
|
||||
snapshot = depot_client.fetch_snapshot()
|
||||
quarantine = QuarantineResult(0, None)
|
||||
@@ -1059,6 +1327,17 @@ def sync_managed_game(
|
||||
created=created,
|
||||
quarantine=quarantine,
|
||||
)
|
||||
verified_paths = {
|
||||
*plan.hash_verified_paths,
|
||||
*(download.file.path for download in plan.downloads),
|
||||
}
|
||||
cache_depot_file_catalog(
|
||||
paths.depot_file_catalog,
|
||||
root,
|
||||
snapshot,
|
||||
verified_paths=verified_paths,
|
||||
previous=previous_file_catalog,
|
||||
)
|
||||
_write_json(paths.native_depot_state, asdict(result))
|
||||
cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot)
|
||||
_emit_progress(
|
||||
@@ -1422,6 +1701,162 @@ def _apply_create_if_missing(
|
||||
return created
|
||||
|
||||
|
||||
def _catalog_entry(
|
||||
file: DepotFile,
|
||||
state: os.stat_result,
|
||||
*,
|
||||
verified: bool,
|
||||
) -> DepotFileCatalogEntry:
|
||||
return DepotFileCatalogEntry(
|
||||
path=file.path,
|
||||
size=file.size,
|
||||
sha1=file.sha1.casefold(),
|
||||
device=state.st_dev,
|
||||
inode=state.st_ino,
|
||||
mtime_ns=state.st_mtime_ns,
|
||||
ctime_ns=state.st_ctime_ns,
|
||||
verified=verified,
|
||||
)
|
||||
|
||||
|
||||
def _scan_game_tree(root: Path) -> dict[str, _ScannedGameEntry]:
|
||||
"""Inventory files without following symlinks or resolving every manifest path."""
|
||||
|
||||
entries: dict[str, _ScannedGameEntry] = {}
|
||||
|
||||
def scan(directory: Path, relative_parent: PurePosixPath) -> None:
|
||||
children = _scan_directory(directory)
|
||||
for child in children:
|
||||
relative = relative_parent / child.name
|
||||
key = relative.as_posix()
|
||||
try:
|
||||
state = child.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect managed game path {key}: {exc}") from exc
|
||||
path = Path(child.path)
|
||||
if stat.S_ISLNK(state.st_mode):
|
||||
entries[key] = _ScannedGameEntry(path, state, "symbolic link")
|
||||
elif stat.S_ISDIR(state.st_mode):
|
||||
scan(path, relative)
|
||||
elif stat.S_ISREG(state.st_mode):
|
||||
entries[key] = _ScannedGameEntry(path, state, "file")
|
||||
else:
|
||||
entries[key] = _ScannedGameEntry(path, state, "not a regular file")
|
||||
|
||||
scan(root, PurePosixPath())
|
||||
return entries
|
||||
|
||||
|
||||
def _scan_directory(directory: Path) -> list[os.DirEntry[str]]:
|
||||
try:
|
||||
with os.scandir(directory) as iterator:
|
||||
return list(iterator)
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot scan managed game directory {directory}: {exc}") from exc
|
||||
|
||||
|
||||
def _catalog_entry_from_cached(
|
||||
entry: DepotFileCatalogEntry,
|
||||
state: os.stat_result,
|
||||
) -> DepotFileCatalogEntry:
|
||||
return DepotFileCatalogEntry(
|
||||
path=entry.path,
|
||||
size=entry.size,
|
||||
sha1=entry.sha1,
|
||||
device=state.st_dev,
|
||||
inode=state.st_ino,
|
||||
mtime_ns=state.st_mtime_ns,
|
||||
ctime_ns=state.st_ctime_ns,
|
||||
verified=True,
|
||||
)
|
||||
|
||||
|
||||
def _catalog_identity_matches(
|
||||
entry: DepotFileCatalogEntry,
|
||||
state: os.stat_result,
|
||||
) -> bool:
|
||||
return (
|
||||
entry.device == state.st_dev
|
||||
and entry.inode == state.st_ino
|
||||
and entry.size == state.st_size
|
||||
and entry.mtime_ns == state.st_mtime_ns
|
||||
and entry.ctime_ns == state.st_ctime_ns
|
||||
)
|
||||
|
||||
|
||||
def _depot_file_catalog_payload(catalog: DepotFileCatalog) -> dict[str, object]:
|
||||
"""Use compact rows because this cache contains tens of thousands of files."""
|
||||
|
||||
return {
|
||||
"schema": DEPOT_FILE_CATALOG_SCHEMA,
|
||||
"build": catalog.build,
|
||||
"created_at": catalog.created_at,
|
||||
"files": [
|
||||
[
|
||||
entry.path,
|
||||
entry.size,
|
||||
entry.sha1,
|
||||
entry.device,
|
||||
entry.inode,
|
||||
entry.mtime_ns,
|
||||
entry.ctime_ns,
|
||||
entry.verified,
|
||||
]
|
||||
for entry in catalog.files
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _parse_depot_file_catalog_entry(value: object) -> DepotFileCatalogEntry:
|
||||
if not isinstance(value, list) or len(value) != 8:
|
||||
raise DepotError("cached depot file entry must be an eight-value row")
|
||||
path = _normalise_relative_path(value[0], "cached depot file path")
|
||||
size = _non_negative_int(value[1], f"cached depot file size for {path}")
|
||||
sha1 = _string(value[2], f"cached depot file hash for {path}").casefold()
|
||||
if not SHA1_PATTERN.fullmatch(sha1):
|
||||
raise DepotError(f"invalid cached SHA-1 hash for {path}")
|
||||
identity: dict[str, int] = {}
|
||||
for field_name, field_value in zip(
|
||||
("device", "inode", "mtime_ns", "ctime_ns"),
|
||||
value[3:7],
|
||||
strict=True,
|
||||
):
|
||||
identity[field_name] = _non_negative_int(
|
||||
field_value,
|
||||
f"cached depot file {field_name} for {path}",
|
||||
)
|
||||
verified = value[7]
|
||||
if not isinstance(verified, bool):
|
||||
raise DepotError(f"cached depot verification flag is invalid for {path}")
|
||||
return DepotFileCatalogEntry(
|
||||
path=path,
|
||||
size=size,
|
||||
sha1=sha1,
|
||||
device=identity["device"],
|
||||
inode=identity["inode"],
|
||||
mtime_ns=identity["mtime_ns"],
|
||||
ctime_ns=identity["ctime_ns"],
|
||||
verified=verified,
|
||||
)
|
||||
|
||||
|
||||
def _completed_depot_build(path: Path) -> int | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("dry_run") is not False:
|
||||
return None
|
||||
build = value.get("build")
|
||||
if isinstance(build, bool) or not isinstance(build, int) or build <= 0:
|
||||
return None
|
||||
return build
|
||||
|
||||
|
||||
def _elapsed_milliseconds(started: float) -> int:
|
||||
return max(0, round((time.perf_counter() - started) * 1000))
|
||||
|
||||
|
||||
def _sync_result(
|
||||
snapshot: DepotSnapshot,
|
||||
plan: UpdatePlan,
|
||||
@@ -1441,6 +1876,8 @@ def _sync_result(
|
||||
build=snapshot.build,
|
||||
version=snapshot.version,
|
||||
files_total=plan.files_total,
|
||||
files_planned=len(plan.downloads),
|
||||
bytes_planned=plan.bytes_total,
|
||||
files_downloaded=files_downloaded,
|
||||
bytes_downloaded=bytes_downloaded,
|
||||
files_deleted=files_deleted,
|
||||
@@ -1919,14 +2356,16 @@ def _headers() -> dict[str, str]:
|
||||
return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"}
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
def _write_json(path: Path, value: object, *, compact: bool = False) -> 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",
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user