626 lines
19 KiB
Python
626 lines
19 KiB
Python
"""Credential-free diagnostics for the PristonTale EU login path."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass, replace
|
|
from datetime import datetime
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import platform
|
|
import socket
|
|
import sys
|
|
import time
|
|
from typing import Callable, Protocol
|
|
|
|
from . import __version__
|
|
from .command import CommandRunner
|
|
from .identity_probe import (
|
|
IDENTITY_PROBE_PRIVACY,
|
|
IdentityProbeResult,
|
|
probe_client_identity,
|
|
)
|
|
from .login_trace import LOGIN_TRACE_DISABLED_REASON, LOGIN_TRACE_ENABLED
|
|
from .overlay import (
|
|
COMPATIBILITY_BINARY_HASHES,
|
|
CURRENT_V21_BINARY_HASHES,
|
|
PATCHED_V21_GAME_DLL_SHA256,
|
|
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
|
)
|
|
from .paths import AppPaths
|
|
from .runtime import (
|
|
RuntimeSelection,
|
|
detect_distribution_id,
|
|
native_d3d_ready,
|
|
resolve_proton_sync_mode,
|
|
select_runtime,
|
|
)
|
|
from .settings import Settings
|
|
from .world_fix import (
|
|
WORLD_FIX_DISABLED_REASON,
|
|
WORLD_FIX_ENABLED,
|
|
WORLD_FIX_HELPER_SHA256,
|
|
packaged_world_fix_helper,
|
|
)
|
|
|
|
|
|
LOGIN_SERVER_HOST = "5.135.35.188"
|
|
LOGIN_SERVER_PORT = 10019
|
|
DEFAULT_LOGIN_TIMEOUT_SECONDS = 5.0
|
|
VERIFIED_GAME_PROFILES = frozenset(
|
|
{"v21.75.83", "verified-compatibility"}
|
|
)
|
|
SERVER_LOGIN_RESPONSE_OWNERSHIP = (
|
|
"Banned Code 1 and Banned Code 3 are server-provided login response text; "
|
|
"neither string exists in the verified V21.75.83 Game.exe/game.dll pair"
|
|
)
|
|
CLIENT_IDENTITY_INPUTS = (
|
|
"Win32_Processor",
|
|
"Win32_BIOS",
|
|
"Win32_BaseBoard",
|
|
"Win32_VideoController",
|
|
"network-adapter identity",
|
|
"C: volume information",
|
|
"computer name",
|
|
)
|
|
PUBLISHER_COMPARISON_COMMAND = "pristontale-eu wine-update"
|
|
RUNTIME_COMPONENT_PRIVACY = (
|
|
"Only SHA-256 hashes of public runtime executables and DLLs plus expected DLL-"
|
|
"override flags are reported. Registry contents and machine identifiers are not "
|
|
"hashed or returned."
|
|
)
|
|
PREFIX_COMPONENT_PATHS = (
|
|
"drive_c/windows/syswow64/d3d9.dll",
|
|
"drive_c/windows/syswow64/d3dx9_43.dll",
|
|
"drive_c/windows/syswow64/d3dcompiler_43.dll",
|
|
"drive_c/windows/syswow64/iphlpapi.dll",
|
|
"drive_c/windows/syswow64/kernel32.dll",
|
|
"drive_c/windows/syswow64/ole32.dll",
|
|
"drive_c/windows/syswow64/oleaut32.dll",
|
|
"drive_c/windows/syswow64/wbemprox.dll",
|
|
"drive_c/windows/syswow64/wbem/wbemprox.dll",
|
|
)
|
|
|
|
|
|
class _Closable(Protocol):
|
|
def close(self) -> None: ...
|
|
|
|
|
|
LoginConnector = Callable[[tuple[str, int], float], _Closable]
|
|
|
|
|
|
def _create_login_connection(address: tuple[str, int], timeout: float) -> _Closable:
|
|
return socket.create_connection(address, timeout)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoginServerProbe:
|
|
host: str
|
|
port: int
|
|
reachable: bool
|
|
latency_ms: float | None
|
|
error: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeComponentFingerprint:
|
|
privacy: str
|
|
runtime_wrapper_sha256: str | None
|
|
proton_script_sha256: str | None
|
|
proton_version_sha256: str | None
|
|
proton_toolmanifest_sha256: str | None
|
|
proton_wine_sha256: str | None
|
|
proton_wineserver_sha256: str | None
|
|
prefix_dll_sha256: dict[str, str | None]
|
|
registry_overrides: dict[str, bool]
|
|
runtime_marker_matches_selection: bool | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoginDiagnosticReport:
|
|
preflight_ok: bool
|
|
checked_at: str
|
|
launcher_version: str
|
|
launcher_module: str
|
|
python_executable: str
|
|
distribution_id: str
|
|
kernel_release: str
|
|
login_server: LoginServerProbe
|
|
authentication_tested: bool
|
|
login_response_ownership: str
|
|
client_identity_inputs: tuple[str, ...]
|
|
client_identity_values_collected: bool
|
|
client_identity_shape_collected: bool
|
|
client_identity_ready: bool | None
|
|
client_identity_privacy: str
|
|
client_identity_probe: IdentityProbeResult | None
|
|
login_trace_enabled: bool
|
|
login_trace_disabled_reason: str | None
|
|
publisher_comparison_command: str
|
|
install_directory: str
|
|
game_exe: str
|
|
game_exe_sha256: str | None
|
|
game_dll: str
|
|
game_dll_sha256: str | None
|
|
source_game_exe_sha256: str | None
|
|
source_game_dll_sha256: str | None
|
|
runtime_matches_managed_source: bool
|
|
game_profile: str
|
|
depot_build: int | None
|
|
depot_version: str | None
|
|
depot_completed_at: str | None
|
|
world_fix_helper: str
|
|
world_fix_helper_sha256: str | None
|
|
world_fix_helper_ready: bool
|
|
world_fix_enabled: bool
|
|
world_fix_disabled_reason: str | None
|
|
runtime_configured: str
|
|
runtime_requested: str
|
|
runtime_override_applied: bool
|
|
runtime_kind: str | None
|
|
runtime_path: str | None
|
|
runtime_error: str | None
|
|
runtime_components: RuntimeComponentFingerprint | None
|
|
proton_sync_configured: str
|
|
proton_sync_effective: str | None
|
|
prefix: str | None
|
|
prefix_ready: bool
|
|
directx_ready: bool
|
|
latest_game_log: str | None
|
|
latest_game_log_modified_at: str | None
|
|
warnings: tuple[str, ...]
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return asdict(self)
|
|
|
|
|
|
def probe_login_server(
|
|
*,
|
|
host: str = LOGIN_SERVER_HOST,
|
|
port: int = LOGIN_SERVER_PORT,
|
|
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
|
connector: LoginConnector = _create_login_connection,
|
|
clock: Callable[[], float] = time.monotonic,
|
|
) -> LoginServerProbe:
|
|
"""Test only the TCP handshake; never send credentials or protocol data."""
|
|
|
|
if timeout_seconds <= 0:
|
|
raise ValueError("login diagnostic timeout must be greater than zero")
|
|
started = clock()
|
|
try:
|
|
connection = connector((host, port), timeout_seconds)
|
|
connection.close()
|
|
except OSError as exc:
|
|
return LoginServerProbe(
|
|
host=host,
|
|
port=port,
|
|
reachable=False,
|
|
latency_ms=None,
|
|
error=f"{type(exc).__name__}: {exc}",
|
|
)
|
|
return LoginServerProbe(
|
|
host=host,
|
|
port=port,
|
|
reachable=True,
|
|
latency_ms=round((clock() - started) * 1000.0, 1),
|
|
error=None,
|
|
)
|
|
|
|
|
|
def inspect_login_environment(
|
|
paths: AppPaths,
|
|
settings: Settings,
|
|
*,
|
|
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
|
connector: LoginConnector = _create_login_connection,
|
|
runner: CommandRunner | None = None,
|
|
runtime_override: str | None = None,
|
|
) -> LoginDiagnosticReport:
|
|
"""Inspect the installed client and its network route without authenticating."""
|
|
|
|
probe = probe_login_server(
|
|
timeout_seconds=timeout_seconds,
|
|
connector=connector,
|
|
)
|
|
runtime_requested = runtime_override or settings.runtime
|
|
diagnostic_settings = replace(settings, runtime=runtime_requested)
|
|
selection, runtime_error = _runtime_selection(diagnostic_settings)
|
|
distribution_id = detect_distribution_id() or "unknown"
|
|
kernel_release = platform.release()
|
|
proton_sync_effective = (
|
|
resolve_proton_sync_mode(
|
|
settings.proton_sync,
|
|
distribution_id=distribution_id,
|
|
kernel_release=kernel_release,
|
|
)
|
|
if selection is not None
|
|
and selection.kind in {"proton7", "proton7-container"}
|
|
else None
|
|
)
|
|
game_root = _active_game_root(paths, settings)
|
|
game_exe = game_root / "Game.exe"
|
|
game_dll = game_root / "game.dll"
|
|
game_exe_sha256 = _sha256_if_file(game_exe)
|
|
game_dll_sha256 = _sha256_if_file(game_dll)
|
|
source_game_exe_sha256 = _sha256_if_file(paths.source_game / "Game.exe")
|
|
source_game_dll_sha256 = _sha256_if_file(paths.source_game / "game.dll")
|
|
runtime_matches_managed_source = (
|
|
game_exe_sha256 is not None
|
|
and game_dll_sha256 is not None
|
|
and game_exe_sha256 == source_game_exe_sha256
|
|
and game_dll_sha256 == source_game_dll_sha256
|
|
)
|
|
game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256)
|
|
depot_state = _read_json_object(paths.native_depot_state)
|
|
world_fix_helper = packaged_world_fix_helper()
|
|
world_fix_helper_sha256 = _sha256_if_file(world_fix_helper)
|
|
world_fix_helper_ready = (
|
|
WORLD_FIX_ENABLED
|
|
and world_fix_helper_sha256 == WORLD_FIX_HELPER_SHA256
|
|
)
|
|
prefix = _runtime_prefix(paths, selection)
|
|
prefix_ready = _prefix_ready(prefix)
|
|
directx_ready = native_d3d_ready(prefix) if prefix is not None else False
|
|
runtime_components = (
|
|
inspect_runtime_components(paths, selection, prefix)
|
|
if selection is not None and prefix is not None
|
|
else None
|
|
)
|
|
identity_probe = (
|
|
probe_client_identity(paths, selection, runner)
|
|
if selection is not None and prefix_ready and runner is not None
|
|
else None
|
|
)
|
|
latest_log = latest_native_game_log(paths, settings)
|
|
warnings = _diagnostic_warnings(
|
|
probe,
|
|
game_profile,
|
|
runtime_error,
|
|
prefix_ready,
|
|
directx_ready,
|
|
latest_log,
|
|
runtime_matches_managed_source,
|
|
identity_probe,
|
|
)
|
|
identity_preflight_ok = (
|
|
True
|
|
if runner is None
|
|
else identity_probe is not None and identity_probe.ready is True
|
|
)
|
|
return LoginDiagnosticReport(
|
|
preflight_ok=(
|
|
probe.reachable
|
|
and game_profile in VERIFIED_GAME_PROFILES
|
|
and runtime_matches_managed_source
|
|
and selection is not None
|
|
and prefix_ready
|
|
and directx_ready
|
|
and identity_preflight_ok
|
|
),
|
|
checked_at=datetime.now().astimezone().isoformat(),
|
|
launcher_version=__version__,
|
|
launcher_module=str(Path(__file__).resolve()),
|
|
python_executable=sys.executable,
|
|
distribution_id=distribution_id,
|
|
kernel_release=kernel_release,
|
|
login_server=probe,
|
|
authentication_tested=False,
|
|
login_response_ownership=SERVER_LOGIN_RESPONSE_OWNERSHIP,
|
|
client_identity_inputs=CLIENT_IDENTITY_INPUTS,
|
|
client_identity_values_collected=False,
|
|
client_identity_shape_collected=(
|
|
identity_probe is not None and identity_probe.report is not None
|
|
),
|
|
client_identity_ready=(
|
|
identity_probe.ready if identity_probe is not None else None
|
|
),
|
|
client_identity_privacy=IDENTITY_PROBE_PRIVACY,
|
|
client_identity_probe=identity_probe,
|
|
login_trace_enabled=LOGIN_TRACE_ENABLED,
|
|
login_trace_disabled_reason=(
|
|
None if LOGIN_TRACE_ENABLED else LOGIN_TRACE_DISABLED_REASON
|
|
),
|
|
publisher_comparison_command=PUBLISHER_COMPARISON_COMMAND,
|
|
install_directory=str(paths.data),
|
|
game_exe=str(game_exe),
|
|
game_exe_sha256=game_exe_sha256,
|
|
game_dll=str(game_dll),
|
|
game_dll_sha256=game_dll_sha256,
|
|
source_game_exe_sha256=source_game_exe_sha256,
|
|
source_game_dll_sha256=source_game_dll_sha256,
|
|
runtime_matches_managed_source=runtime_matches_managed_source,
|
|
game_profile=game_profile,
|
|
depot_build=_integer_or_none(depot_state.get("build")),
|
|
depot_version=_string_or_none(depot_state.get("version")),
|
|
depot_completed_at=_string_or_none(depot_state.get("completed_at")),
|
|
world_fix_helper=str(world_fix_helper),
|
|
world_fix_helper_sha256=world_fix_helper_sha256,
|
|
world_fix_helper_ready=world_fix_helper_ready,
|
|
world_fix_enabled=WORLD_FIX_ENABLED,
|
|
world_fix_disabled_reason=(
|
|
None if WORLD_FIX_ENABLED else WORLD_FIX_DISABLED_REASON
|
|
),
|
|
runtime_configured=settings.runtime,
|
|
runtime_requested=runtime_requested,
|
|
runtime_override_applied=runtime_override is not None,
|
|
runtime_kind=selection.kind if selection is not None else None,
|
|
runtime_path=_runtime_path(selection),
|
|
runtime_error=runtime_error,
|
|
runtime_components=runtime_components,
|
|
proton_sync_configured=settings.proton_sync,
|
|
proton_sync_effective=proton_sync_effective,
|
|
prefix=str(prefix) if prefix is not None else None,
|
|
prefix_ready=prefix_ready,
|
|
directx_ready=directx_ready,
|
|
latest_game_log=str(latest_log) if latest_log is not None else None,
|
|
latest_game_log_modified_at=_modified_at(latest_log),
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
def inspect_runtime_components(
|
|
paths: AppPaths,
|
|
selection: RuntimeSelection,
|
|
prefix: Path,
|
|
) -> RuntimeComponentFingerprint:
|
|
"""Fingerprint non-private runtime components for side-by-side reports."""
|
|
|
|
proton_script_sha256: str | None = None
|
|
proton_version_sha256: str | None = None
|
|
proton_toolmanifest_sha256: str | None = None
|
|
proton_wine_sha256: str | None = None
|
|
proton_wineserver_sha256: str | None = None
|
|
if selection.proton_path:
|
|
proton = Path(selection.proton_path)
|
|
proton_script_sha256 = _sha256_if_file(proton)
|
|
proton_version_sha256 = _sha256_if_file(proton.parent / "version")
|
|
proton_toolmanifest_sha256 = _sha256_if_file(
|
|
proton.parent / "toolmanifest.vdf"
|
|
)
|
|
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
|
wine = directory / "wine"
|
|
wineserver = directory / "wineserver"
|
|
if wine.is_file() and wineserver.is_file():
|
|
proton_wine_sha256 = _sha256_if_file(wine)
|
|
proton_wineserver_sha256 = _sha256_if_file(wineserver)
|
|
break
|
|
prefix_dll_sha256 = {
|
|
relative: _sha256_if_file(prefix / relative)
|
|
for relative in PREFIX_COMPONENT_PATHS
|
|
}
|
|
runtime_marker = _read_json_object(paths.game_runtime_marker)
|
|
marker_matches = (
|
|
runtime_marker.get("runtime") == asdict(selection)
|
|
if runtime_marker
|
|
else None
|
|
)
|
|
return RuntimeComponentFingerprint(
|
|
privacy=RUNTIME_COMPONENT_PRIVACY,
|
|
runtime_wrapper_sha256=_sha256_if_file(Path(selection.executable)),
|
|
proton_script_sha256=proton_script_sha256,
|
|
proton_version_sha256=proton_version_sha256,
|
|
proton_toolmanifest_sha256=proton_toolmanifest_sha256,
|
|
proton_wine_sha256=proton_wine_sha256,
|
|
proton_wineserver_sha256=proton_wineserver_sha256,
|
|
prefix_dll_sha256=prefix_dll_sha256,
|
|
registry_overrides=_runtime_registry_override_flags(prefix),
|
|
runtime_marker_matches_selection=marker_matches,
|
|
)
|
|
|
|
|
|
def _runtime_registry_override_flags(prefix: Path) -> dict[str, bool]:
|
|
try:
|
|
registry = (prefix / "user.reg").read_text(
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
).casefold()
|
|
except OSError:
|
|
registry = ""
|
|
return {
|
|
"d3d9_native_builtin": '"d3d9"="native,builtin"' in registry,
|
|
"d3dx9_43_native": _native_override_present(registry, "d3dx9_43"),
|
|
"d3dcompiler_43_native": _native_override_present(
|
|
registry,
|
|
"d3dcompiler_43",
|
|
),
|
|
}
|
|
|
|
|
|
def _native_override_present(registry: str, name: str) -> bool:
|
|
return any(
|
|
entry in registry
|
|
for entry in (
|
|
f'"{name}"="native,builtin"',
|
|
f'"{name}"="native"',
|
|
f'"*{name}"="native"',
|
|
)
|
|
)
|
|
|
|
|
|
def classify_game_profile(
|
|
game_exe_sha256: str | None,
|
|
game_dll_sha256: str | None,
|
|
) -> str:
|
|
if game_exe_sha256 is None or game_dll_sha256 is None:
|
|
return "missing"
|
|
pair = {"Game.exe": game_exe_sha256, "game.dll": game_dll_sha256}
|
|
if pair == CURRENT_V21_BINARY_HASHES:
|
|
return "v21.75.83"
|
|
if (
|
|
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
|
and game_dll_sha256 == RETIRED_POST_AUTH_GAME_DLL_SHA256
|
|
):
|
|
return "retired-post-auth-disk-patch"
|
|
if (
|
|
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
|
and game_dll_sha256 == PATCHED_V21_GAME_DLL_SHA256
|
|
):
|
|
return "retired-texture-reclaim"
|
|
if pair == COMPATIBILITY_BINARY_HASHES:
|
|
return "verified-compatibility"
|
|
known_game_exe_hashes = {
|
|
CURRENT_V21_BINARY_HASHES["Game.exe"],
|
|
COMPATIBILITY_BINARY_HASHES["Game.exe"],
|
|
}
|
|
known_game_dll_hashes = {
|
|
CURRENT_V21_BINARY_HASHES["game.dll"],
|
|
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
|
PATCHED_V21_GAME_DLL_SHA256,
|
|
COMPATIBILITY_BINARY_HASHES["game.dll"],
|
|
}
|
|
if game_exe_sha256 in known_game_exe_hashes or game_dll_sha256 in known_game_dll_hashes:
|
|
return "mismatched"
|
|
return "unknown"
|
|
|
|
|
|
def latest_native_game_log(paths: AppPaths, settings: Settings) -> Path | None:
|
|
"""Return the newest native log from the active managed source or runtime view."""
|
|
|
|
directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs")
|
|
logs: dict[Path, Path] = {}
|
|
for directory in directories:
|
|
try:
|
|
candidates = directory.glob("log_*.txt")
|
|
for candidate in candidates:
|
|
if candidate.is_file():
|
|
logs[candidate.resolve()] = candidate
|
|
except OSError:
|
|
continue
|
|
if not logs:
|
|
return None
|
|
try:
|
|
return max(logs.values(), key=lambda item: item.stat().st_mtime_ns)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _active_game_root(paths: AppPaths, settings: Settings) -> Path:
|
|
if paths.runtime_view.is_dir():
|
|
return paths.runtime_view
|
|
return paths.source_game
|
|
|
|
|
|
def _runtime_selection(settings: Settings) -> tuple[RuntimeSelection | None, str | None]:
|
|
try:
|
|
return select_runtime(settings), None
|
|
except RuntimeError as exc:
|
|
return None, str(exc)
|
|
|
|
|
|
def _runtime_path(selection: RuntimeSelection | None) -> str | None:
|
|
if selection is None:
|
|
return None
|
|
return selection.proton_path or selection.executable
|
|
|
|
|
|
def _runtime_prefix(paths: AppPaths, selection: RuntimeSelection | None) -> Path | None:
|
|
if selection is None:
|
|
return None
|
|
return (
|
|
paths.proton_prefix
|
|
if selection.kind in {"proton7", "proton7-container"}
|
|
else paths.umu_prefix
|
|
)
|
|
|
|
|
|
def _prefix_ready(prefix: Path | None) -> bool:
|
|
if prefix is None:
|
|
return False
|
|
return (prefix / "user.reg").is_file() and (
|
|
prefix / "drive_c/windows/system32/cmd.exe"
|
|
).is_file()
|
|
|
|
|
|
def _diagnostic_warnings(
|
|
probe: LoginServerProbe,
|
|
game_profile: str,
|
|
runtime_error: str | None,
|
|
prefix_ready: bool,
|
|
directx_ready: bool,
|
|
latest_log: Path | None,
|
|
runtime_matches_managed_source: bool,
|
|
identity_probe: IdentityProbeResult | None,
|
|
) -> tuple[str, ...]:
|
|
warnings: list[str] = []
|
|
if not probe.reachable:
|
|
warnings.append(
|
|
"The publisher login port is unreachable from this Linux host; check the "
|
|
"network, firewall, VPN, or server status."
|
|
)
|
|
if game_profile == "missing":
|
|
warnings.append("Game.exe or game.dll is missing; install or update the game first.")
|
|
elif game_profile == "mismatched":
|
|
warnings.append(
|
|
"Game.exe and game.dll are from different verified releases; run update --repair."
|
|
)
|
|
elif game_profile == "unknown":
|
|
warnings.append("Game.exe/game.dll do not match a launcher-verified client profile.")
|
|
elif game_profile == "retired-texture-reclaim":
|
|
warnings.append(
|
|
"A retired experimental game.dll patch is active and can stall login; "
|
|
"run prepare or launch the game once to restore the publisher DLL."
|
|
)
|
|
elif game_profile == "retired-post-auth-disk-patch":
|
|
warnings.append(
|
|
"The retired 0.5.8 disk-patched game.dll is active and can stall login; "
|
|
"run prepare or launch with the current launcher to restore the publisher DLL."
|
|
)
|
|
if not runtime_matches_managed_source:
|
|
warnings.append(
|
|
"The runtime Game.exe/game.dll pair does not match the managed updater "
|
|
"source; run prepare after update --repair before testing login again."
|
|
)
|
|
if runtime_error is not None:
|
|
warnings.append(runtime_error)
|
|
elif not prefix_ready:
|
|
warnings.append("The selected game prefix is not initialized; run play once to prepare it.")
|
|
elif not directx_ready:
|
|
warnings.append("The native DirectX helper DLLs are not ready; run play to repair them.")
|
|
if latest_log is None:
|
|
warnings.append("No native game log was found yet.")
|
|
if identity_probe is not None:
|
|
warnings.extend(identity_probe.warnings)
|
|
return tuple(warnings)
|
|
|
|
|
|
def _read_json_object(path: Path) -> dict[str, object]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _integer_or_none(value: object) -> int | None:
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
return None
|
|
return value
|
|
|
|
|
|
def _string_or_none(value: object) -> str | None:
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _sha256_if_file(path: Path) -> str | None:
|
|
try:
|
|
return _sha256(path) if path.is_file() else None
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _modified_at(path: Path | None) -> str | None:
|
|
if path is None:
|
|
return None
|
|
try:
|
|
return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat()
|
|
except OSError:
|
|
return None
|