mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-30 12:25:49 +00:00
213 lines
11 KiB
Python
213 lines
11 KiB
Python
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
|
|
SHA256 = re.compile(r"[0-9a-fA-F]{64}\Z")
|
|
VERIFY_SUMMARY = re.compile(r"^Verified (\d+) patches: (\d+) ok, (\d+) not-checked, (\d+) mismatch$", re.MULTILINE)
|
|
|
|
|
|
def confined(root, name):
|
|
if not isinstance(name, str) or not name or "\\" in name:
|
|
raise ValueError(f"invalid relative path: {name!r}")
|
|
path = PurePosixPath(name)
|
|
if path.is_absolute() or any(part in (".", "..") for part in name.split("/")):
|
|
raise ValueError(f"invalid relative path: {name!r}")
|
|
try:
|
|
resolved = (root / Path(*path.parts)).resolve()
|
|
except RuntimeError as error:
|
|
raise ValueError(f"invalid relative path: {name!r}: {error}") from error
|
|
if not resolved.is_relative_to(root.resolve()) or not resolved.is_file():
|
|
raise ValueError(f"file missing or outside directory: {name}")
|
|
return resolved
|
|
|
|
|
|
def sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def command_line(arguments):
|
|
return subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments)
|
|
|
|
|
|
def conversion_options(args, patch):
|
|
options = ["--patches", str(patch)]
|
|
if args.skip_sce_module:
|
|
options.append("--skip-sce-module")
|
|
if args.sce_module_path:
|
|
options.extend(("--sce-module-path", str(args.sce_module_path)))
|
|
for module_dir in args.module_dir:
|
|
options.extend(("--module-dir", module_dir))
|
|
for executable in args.executable:
|
|
options.extend(("--executable", executable))
|
|
return command_line(options)
|
|
|
|
|
|
def load_catalog(directory, title, version):
|
|
data = json.loads(confined(directory, "index.json").read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict) or set(data) != {"format", "entries"} or type(data["format"]) is not int or data["format"] != 1 or not isinstance(data["entries"], list):
|
|
raise ValueError("catalog must have format 1 and an entries array")
|
|
entries = []
|
|
for number, entry in enumerate(data["entries"], 1):
|
|
if not isinstance(entry, dict) or (entry.get("titleId"), entry.get("contentVersion")) != (title, version):
|
|
continue
|
|
entries.append((number, entry))
|
|
return entries
|
|
|
|
|
|
def validate_entry(directory, entry):
|
|
if set(entry) != {"titleId", "contentVersion", "patch", "targets"}:
|
|
raise ValueError("invalid fields")
|
|
patch = confined(directory, entry["patch"])
|
|
targets = entry["targets"]
|
|
if not isinstance(targets, dict) or not targets:
|
|
raise ValueError("targets must be a nonempty object")
|
|
if any(not isinstance(name, str) or not isinstance(digest, str) or not SHA256.fullmatch(digest) for name, digest in targets.items()):
|
|
raise ValueError("invalid target digest")
|
|
parsed = tomllib.loads(patch.read_text(encoding="utf-8"))
|
|
patches = parsed.get("patch")
|
|
if not isinstance(patches, list) or not patches or any(not isinstance(item, dict) or not isinstance(item.get("target"), str) for item in patches):
|
|
raise ValueError("patch file has no valid targets")
|
|
if set(targets) != {item["target"] for item in patches}:
|
|
raise ValueError("target hashes must cover exactly the patch targets")
|
|
return patch, {name: digest.lower() for name, digest in targets.items()}
|
|
|
|
|
|
def main(argv=None):
|
|
for stream in (sys.stdout, sys.stderr):
|
|
if hasattr(stream, "reconfigure"):
|
|
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
parser = argparse.ArgumentParser(description="Recommend a local patch only after metadata, hashes and relinker verification match")
|
|
parser.add_argument("--catalog", required=True, type=Path, help="directory containing index.json and patch files")
|
|
parser.add_argument("--param-json", required=True, type=Path, help="title metadata file")
|
|
parser.add_argument("--input", required=True, type=Path, help="unwrapped main ELF")
|
|
parser.add_argument("--relinker", required=True, type=Path, help="relinker executable to run")
|
|
parser.add_argument("--sce-module-path", type=Path, help="parent directory of bundled modules")
|
|
parser.add_argument("--module-dir", action="append", default=[], help="module directory relative to the module parent; repeatable")
|
|
parser.add_argument("--executable", action="append", default=[], help="additional title executable relative to the module parent; repeatable")
|
|
parser.add_argument("--skip-sce-module", action="store_true", help="skip bundled modules during verification")
|
|
parser.add_argument("--timeout-seconds", type=int, default=120, help="maximum seconds per relinker verification (default: 120)")
|
|
parser.add_argument("--list-verified", action="store_true", help="list every verified catalog entry without choosing one")
|
|
args = parser.parse_args(argv)
|
|
prefix = "No verified catalog entries" if args.list_verified else "No recommendation"
|
|
try:
|
|
metadata = json.loads(args.param_json.read_text(encoding="utf-8"))
|
|
if not isinstance(metadata, dict):
|
|
raise ValueError("param.json must be an object")
|
|
title = metadata.get("titleId")
|
|
version = metadata.get("contentVersion")
|
|
if not isinstance(title, str) or not title or not isinstance(version, str) or not version:
|
|
raise ValueError("param.json needs nonempty titleId and contentVersion")
|
|
if not args.input.is_file() or not args.relinker.is_file():
|
|
raise ValueError("input and relinker must be files")
|
|
if args.skip_sce_module and args.sce_module_path:
|
|
raise ValueError("--skip-sce-module conflicts with --sce-module-path")
|
|
if args.timeout_seconds <= 0:
|
|
raise ValueError("timeout-seconds must be positive")
|
|
root = (args.sce_module_path or args.input.parent).resolve()
|
|
if not root.is_dir():
|
|
raise ValueError("module parent must be a directory")
|
|
for executable in args.executable:
|
|
confined(root, executable)
|
|
matches = []
|
|
entries = load_catalog(args.catalog.resolve(), title, version)
|
|
if not entries:
|
|
print(f"{prefix}: no catalog entry for {title} {version}.")
|
|
return 1
|
|
relinker_errors = False
|
|
for number, entry in entries:
|
|
label = f"Catalog entry {number} ({entry.get('patch')!r})"
|
|
try:
|
|
patch, targets = validate_entry(args.catalog.resolve(), entry)
|
|
except (OSError, ValueError, tomllib.TOMLDecodeError) as error:
|
|
print(f"{label}: rejected: {error}")
|
|
continue
|
|
errors = []
|
|
for name, expected in targets.items():
|
|
try:
|
|
source = args.input.resolve() if name == args.input.name else confined(root, name)
|
|
actual = sha256(source)
|
|
if actual != expected:
|
|
errors.append(f"{name}: SHA-256 mismatch ({actual})")
|
|
except (OSError, ValueError) as error:
|
|
errors.append(f"{name}: {error}")
|
|
if errors:
|
|
print(f"{label}: rejected: {'; '.join(errors)}")
|
|
continue
|
|
command = [str(args.relinker.resolve()), "--verify-patches", str(patch)]
|
|
if args.skip_sce_module:
|
|
command.append("--skip-sce-module")
|
|
if args.sce_module_path:
|
|
command.extend(("--sce-module-path", str(args.sce_module_path)))
|
|
for module_dir in args.module_dir:
|
|
command.extend(("--module-dir", module_dir))
|
|
for executable in args.executable:
|
|
command.extend(("--executable", executable))
|
|
command.append(str(args.input))
|
|
try:
|
|
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
timeout=args.timeout_seconds)
|
|
except subprocess.TimeoutExpired:
|
|
print(f"{label}: rejected: relinker verification exceeded {args.timeout_seconds} seconds")
|
|
continue
|
|
summary = VERIFY_SUMMARY.search(result.stdout)
|
|
verified = tuple(map(int, summary.groups())) if summary else None
|
|
if result.returncode:
|
|
detail = (result.stdout + result.stderr).strip()
|
|
if result.returncode == 1 and verified and verified[3] > 0:
|
|
print(f"{label}: rejected by relinker: {detail}")
|
|
continue
|
|
if result.returncode == 2:
|
|
print(f"{label}: relinker error (exit 2): {detail}")
|
|
relinker_errors = True
|
|
continue
|
|
raise ValueError(f"{label}: relinker error (exit {result.returncode}): {detail}")
|
|
if not verified:
|
|
raise ValueError(f"{label}: relinker produced no verification summary")
|
|
if verified[0] == 0 or verified[1] != verified[0] or verified[2] or verified[3]:
|
|
print(f"{label}: rejected: relinker did not confirm every patch")
|
|
continue
|
|
matches.append((number, entry, patch, targets))
|
|
if args.list_verified and matches:
|
|
print(f"Verified catalog entries: {len(matches)}")
|
|
print("Choose one patch file; listed entries are not combined.")
|
|
for number, entry, patch, targets in matches:
|
|
print(f"Catalog entry {number}: {patch}")
|
|
for name, digest in targets.items():
|
|
print(f" {name} SHA-256 {digest}")
|
|
print(f"Use these options when converting:\n {conversion_options(args, patch)}")
|
|
return 0
|
|
if len(matches) == 1:
|
|
number, entry, patch, targets = matches[0]
|
|
print(f"Recommended: {patch} (catalog entry {number})")
|
|
for name, digest in targets.items():
|
|
print(f" {name} SHA-256 {digest}")
|
|
print(f"Use these options when converting:\n {conversion_options(args, patch)}")
|
|
return 0
|
|
if len(matches) > 1:
|
|
print("No recommendation: multiple verified catalog entries match; use --list-verified to see them.")
|
|
return 1
|
|
if relinker_errors:
|
|
print(f"{prefix}: relinker errors occurred; see catalog entries above.", file=sys.stderr)
|
|
return 2
|
|
print(f"{prefix}: no catalog entry passed all checks.")
|
|
return 1
|
|
except (OSError, ValueError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error:
|
|
print(f"Patch advisor: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|