mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-27 00:28:14 +00:00
feat(tools): list verified patch catalog entries
This commit is contained in:
1 parent
2fade7ef4e
commit
20f1039d89
4 files changed
+113
-20
No files matched your search
@@ -25,27 +25,38 @@ def main():
|
||||
metadata = root / "param.json"
|
||||
metadata.write_text(json.dumps({"titleId": "TEST00001", "contentVersion": "01.000.000"}))
|
||||
|
||||
def advise(digest, patches=("patch.toml",)):
|
||||
def advise(digest, patches=("patch.toml",), *options):
|
||||
(catalog / "index.json").write_text(json.dumps({"format": 1, "entries": [{
|
||||
"titleId": "TEST00001", "contentVersion": "01.000.000", "patch": name,
|
||||
"targets": {"eboot.bin": digest}
|
||||
} for name in patches]}))
|
||||
return subprocess.run([sys.executable, str(advisor), "--catalog", str(catalog),
|
||||
"--param-json", str(metadata), "--input", str(source),
|
||||
"--relinker", f"./{relinker.name}", "--skip-sce-module"],
|
||||
"--relinker", f"./{relinker.name}", "--skip-sce-module", *options],
|
||||
cwd=relinker.parent, capture_output=True, text=True, timeout=30)
|
||||
|
||||
result = advise(hashlib.sha256(image).hexdigest())
|
||||
digest = hashlib.sha256(image).hexdigest()
|
||||
result = advise(digest)
|
||||
assert result.returncode == 0 and f"Recommended: {patch}" in result.stdout, (result.stdout, result.stderr)
|
||||
second = catalog / "second patch.toml"
|
||||
second.write_text(patch_text(address, expected, "flip_bit"))
|
||||
result = advise(digest, ("patch.toml", second.name))
|
||||
assert result.returncode == 1 and "multiple verified catalog entries" in result.stdout, (result.stdout, result.stderr)
|
||||
result = advise(digest, ("patch.toml", second.name), "--list-verified")
|
||||
assert result.returncode == 0 and "Verified catalog entries: 2" in result.stdout, (result.stdout, result.stderr)
|
||||
assert f"Catalog entry 1: {patch}" in result.stdout and f"Catalog entry 2: {second}" in result.stdout, (result.stdout, result.stderr)
|
||||
assert "Recommended:" not in result.stdout, (result.stdout, result.stderr)
|
||||
broken = catalog / "broken.toml"
|
||||
broken.write_text(patch_text(address, expected, "broken").replace('description = "flip one bit"',
|
||||
r'description = "flip \"one\" bit"'))
|
||||
result = advise(hashlib.sha256(image).hexdigest(), ("broken.toml", "patch.toml"))
|
||||
result = advise(digest, ("broken.toml", "patch.toml"))
|
||||
assert result.returncode == 0 and "relinker error (exit 2)" in result.stdout and f"Recommended: {patch}" in result.stdout, (result.stdout, result.stderr)
|
||||
result = advise(hashlib.sha256(image).hexdigest(), ("broken.toml",))
|
||||
result = advise(digest, ("broken.toml", "patch.toml"), "--list-verified")
|
||||
assert result.returncode == 0 and "relinker error (exit 2)" in result.stdout and "Verified catalog entries: 1" in result.stdout, (result.stdout, result.stderr)
|
||||
result = advise(digest, ("broken.toml",), "--list-verified")
|
||||
assert result.returncode == 2 and "relinker error (exit 2)" in result.stdout, (result.stdout, result.stderr)
|
||||
patch.write_text(patch_text(address, expected ^ 1, "wrong_byte"))
|
||||
result = advise(hashlib.sha256(image).hexdigest())
|
||||
result = advise(digest)
|
||||
assert result.returncode == 1 and "rejected by relinker" in result.stdout, (result.stdout, result.stderr)
|
||||
patch.write_text(patch_text(address, expected, "flip_bit"))
|
||||
result = advise(hashlib.sha256(b"other build").hexdigest())
|
||||
|
||||
+3
-1
@@ -103,7 +103,7 @@ A patch file changes bytes of the input executable or of a bundled module before
|
||||
The advisor requires Python 3.11 or newer. To check a local patch catalog, run:
|
||||
|
||||
```sh
|
||||
python3 tools/suggest_patches.py --catalog <directory> --param-json <sce_sys/param.json> --input <input.elf> --relinker <relinker> [--sce-module-path <directory>] [--module-dir <directory>]... [--executable <path>]... [--skip-sce-module] [--timeout-seconds <seconds>]
|
||||
python3 tools/suggest_patches.py --catalog <directory> --param-json <sce_sys/param.json> --input <input.elf> --relinker <relinker> [--sce-module-path <directory>] [--module-dir <directory>]... [--executable <path>]... [--skip-sce-module] [--timeout-seconds <seconds>] [--list-verified]
|
||||
```
|
||||
|
||||
The advisor reads `titleId` and `contentVersion` from the specified `param.json`, checks the SHA-256 of every file targeted by a candidate patch, then calls the relinker specified by `--relinker` to run `--verify-patches`. A relative path such as `--relinker ./relinker` resolves to that local file. The explicit metadata path also works when `param.json` and the unwrapped ELF files are stored separately. The catalog directory must contain an `index.json` with `format: 1` and an `entries` array. Each entry has `titleId`, `contentVersion`, a relative `patch` path, and `targets` mapping every patch target to its SHA-256. Hexadecimal digests are case-insensitive. For example:
|
||||
@@ -122,6 +122,8 @@ The advisor reads `titleId` and `contentVersion` from the specified `param.json`
|
||||
|
||||
The advisor validates patch files only for entries matching the requested title and version. It reports and skips a malformed matching entry or a relinker verification error, so another valid entry can still be recommended. Verification times out after 120 seconds per entry by default; use `--timeout-seconds <positive integer>` to change the limit. For synthetic inputs without system modules, use `--skip-sce-module` if conversion also uses it; this option conflicts with `--sce-module-path`. No matching entry or no verified patch exits `1`; invalid input or no verified patch after a relinker error exits `2`. If multiple entries verify, the advisor recommends none because it cannot choose or combine them. The tool does not fetch or apply patches. A recommendation prints `--patches` and the module and executable options to use when converting. Keep catalog patch files outside the input executable's directory to avoid automatic `relinker-patches.toml` discovery.
|
||||
|
||||
With `--list-verified`, the advisor prints every entry that passes metadata, hash and relinker checks, in catalog order. Each result includes its catalog number, patch file, target hashes and conversion options. It exits `0` when at least one entry verifies, even if another entry was rejected; with no verified entries, the existing `1` or `2` exit status still applies. Without this option, a single verified entry is recommended and multiple verified entries remain ambiguous. Listing entries does not combine patch files or apply them during conversion; choose a patch explicitly after reviewing it.
|
||||
|
||||
The target hashes identify the exact unwrapped files changed by a patch, which might not include the main executable. Different dump tools could produce different bytes for the same title version; the advisor then rejects a patch even if it would work. It has not been confirmed that all dump tools produce identical files. The hashes do not authenticate a catalog or establish that a patch is safe or that the title is fully playable. Use catalogs from sources you trust and review the patch before applying it.
|
||||
|
||||
If the patch targets another title executable, pass `--executable <path>` to the advisor for each additional executable, using paths relative to the module parent directory, such as `picker/picker.bin`. The advisor hashes those target files and forwards the same options to `relinker --verify-patches`. Use the same `--executable` options when converting with `--patches`.
|
||||
|
||||
+29
-13
@@ -38,6 +38,19 @@ 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):
|
||||
@@ -79,6 +92,7 @@ def main(argv=None):
|
||||
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)
|
||||
try:
|
||||
metadata = json.loads(args.param_json.read_text(encoding="utf-8"))
|
||||
@@ -102,7 +116,8 @@ def main(argv=None):
|
||||
matches = []
|
||||
entries = load_catalog(args.catalog.resolve(), title, version)
|
||||
if not entries:
|
||||
print(f"No recommendation: no catalog entry for {title} {version}.")
|
||||
prefix = "No verified catalog entries" if args.list_verified else "No recommendation"
|
||||
print(f"{prefix}: no catalog entry for {title} {version}.")
|
||||
return 1
|
||||
relinker_errors = False
|
||||
for number, entry in entries:
|
||||
@@ -157,29 +172,30 @@ def main(argv=None):
|
||||
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)}")
|
||||
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}")
|
||||
conversion = ["--patches", str(patch)]
|
||||
if args.skip_sce_module:
|
||||
conversion.append("--skip-sce-module")
|
||||
if args.sce_module_path:
|
||||
conversion.extend(("--sce-module-path", str(args.sce_module_path)))
|
||||
for module_dir in args.module_dir:
|
||||
conversion.extend(("--module-dir", module_dir))
|
||||
for executable in args.executable:
|
||||
conversion.extend(("--executable", executable))
|
||||
print(f"Use these options when converting:\n {command_line(conversion)}")
|
||||
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.")
|
||||
return 1
|
||||
if relinker_errors:
|
||||
print("No recommendation: relinker errors occurred; see catalog entries above.", file=sys.stderr)
|
||||
prefix = "No verified catalog entries" if args.list_verified else "No recommendation"
|
||||
print(f"{prefix}: relinker errors occurred; see catalog entries above.", file=sys.stderr)
|
||||
return 2
|
||||
print("No recommendation: no catalog entry passed all checks.")
|
||||
prefix = "No verified catalog entries" if args.list_verified else "No recommendation"
|
||||
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)
|
||||
|
||||
@@ -192,6 +192,70 @@ class PatchAdvisorTests(unittest.TestCase):
|
||||
self.assertIn("multiple verified catalog entries", output.getvalue())
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
@patch.object(suggest_patches.subprocess, "run")
|
||||
def test_list_verified_shows_each_match_and_quoted_patch_path(self, run):
|
||||
second = self.catalog / "second patch.toml"
|
||||
second.write_bytes((self.catalog / "patch.toml").read_bytes())
|
||||
self.write_index([self.entry, dict(self.entry, patch=second.name)])
|
||||
run.return_value.returncode = 0
|
||||
run.return_value.stdout = VERIFIED
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(self.advise("--list-verified"), 0)
|
||||
self.assertIn("Verified catalog entries: 2", output.getvalue())
|
||||
self.assertIn(f"Catalog entry 1: {self.catalog / 'patch.toml'}", output.getvalue())
|
||||
self.assertIn(f"Catalog entry 2: {second}", output.getvalue())
|
||||
self.assertIn(suggest_patches.command_line(["--patches", str(second), "--module-dir", "Media/Modules"]), output.getvalue())
|
||||
self.assertEqual(output.getvalue().count("SHA-256"), 2)
|
||||
self.assertNotIn("Recommended:", output.getvalue())
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
@patch.object(suggest_patches.subprocess, "run")
|
||||
def test_list_verified_single_match_preserves_conversion_options(self, run):
|
||||
self.additional_executable()
|
||||
run.return_value.returncode = 0
|
||||
run.return_value.stdout = VERIFIED
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(self.advise("--list-verified", "--executable", "picker/picker.bin",
|
||||
"--sce-module-path", str(self.root)), 0)
|
||||
expected = suggest_patches.command_line(["--patches", str(self.catalog / "patch.toml"),
|
||||
"--sce-module-path", str(self.root), "--module-dir",
|
||||
"Media/Modules", "--executable", "picker/picker.bin"])
|
||||
self.assertIn(expected, output.getvalue())
|
||||
self.assertIn("Verified catalog entries: 1", output.getvalue())
|
||||
self.assertNotIn("Recommended:", output.getvalue())
|
||||
|
||||
@patch.object(suggest_patches.subprocess, "run")
|
||||
def test_list_verified_skips_individual_errors(self, run):
|
||||
bad = dict(self.entry, patch="missing.toml")
|
||||
self.write_index([bad, self.entry, self.entry])
|
||||
run.side_effect = [subprocess.CompletedProcess([], 2, "", "FAIL: invalid patch"),
|
||||
subprocess.CompletedProcess([], 0, VERIFIED, "")]
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(self.advise("--list-verified"), 0)
|
||||
self.assertIn("Catalog entry 1 ('missing.toml'): rejected", output.getvalue())
|
||||
self.assertIn("Catalog entry 2 ('patch.toml'): relinker error (exit 2)", output.getvalue())
|
||||
self.assertIn("Verified catalog entries: 1", output.getvalue())
|
||||
self.assertIn(f"Catalog entry 3: {self.catalog / 'patch.toml'}", output.getvalue())
|
||||
self.assertEqual(run.call_count, 2)
|
||||
|
||||
@patch.object(suggest_patches.subprocess, "run")
|
||||
def test_list_verified_no_match_keeps_exit_statuses(self, run):
|
||||
self.write_index([])
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(self.advise("--list-verified"), 1)
|
||||
self.assertIn("No verified catalog entries: no catalog entry", output.getvalue())
|
||||
run.assert_not_called()
|
||||
self.write_index()
|
||||
run.return_value = subprocess.CompletedProcess([], 2, "", "FAIL: invalid patch")
|
||||
errors = io.StringIO()
|
||||
with contextlib.redirect_stderr(errors):
|
||||
self.assertEqual(self.advise("--list-verified"), 2)
|
||||
self.assertIn("No verified catalog entries: relinker errors occurred", errors.getvalue())
|
||||
|
||||
@patch.object(suggest_patches.subprocess, "run")
|
||||
def test_not_checked_patch_is_not_recommended(self, run):
|
||||
run.return_value.returncode = 0
|
||||
|
||||
Reference in new issue
Block a user