Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/user/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ The audit also walks the built libraries' own host dependencies: each `.prx` und
| `--names <file>` | NID and name per line, as in `aerolib.csv` from [`tools/nid_names.py`](../../tools/nid_names.py); names the absent imports. Never downloaded; a name that does not hash to its NID is marked. |
| `--json <file>` | Full result, stamped with the repository's commit and whether its tree was modified. |

Registry `nid` fields may contain either encoded NIDs or ordinary ELF symbol names such as `strcmp`. Imports are matched against literal exports first, then against their computed NIDs when the literal lookup misses. Reports retain the original import name. Export availability does not establish that every platform loader supports that lookup path.

Exit codes: `0` nothing blocks loading, `1` there are `absent` imports or needed libraries without a file, `2` an input could not be read; the message names the file and the value.

A module registry lists `nid`, `library` and `targetOffset` for each relocation against a function that no other bundled module exports, without the `#` suffix of the executable's NIDs.
16 changes: 10 additions & 6 deletions tools/import_audit.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import argparse
import hashlib
import json
import re
import struct
import subprocess
import sys
Expand All @@ -14,7 +13,6 @@

ROOT = Path(__file__).resolve().parent.parent
PRX = ROOT / "core" / "libs" / "prx"
NID = re.compile(r"[A-Za-z0-9+\-]{11}")
NID_POSTFIX = "_nid_postfix"
MODULE_SUFFIX = "#"
CLASSES = ("implemented", "stub", "absent", "module")
Expand Down Expand Up @@ -490,8 +488,8 @@ def read_registry(path):
if not isinstance(entry, dict) or not isinstance(entry.get("nid"), str) or not isinstance(entry.get("library"), str):
raise AuditError(f"{path}: entry {index} needs string fields 'nid' and 'library': {entry!r}")
nid = entry["nid"].split("#", 1)[0]
if not NID.fullmatch(nid):
raise AuditError(f"{path}: entry {index} has an invalid NID {entry['nid']!r}")
if not nid or any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in nid):
raise AuditError(f"{path}: entry {index} has an invalid import symbol {entry['nid']!r}")
weak = entry.get("weak", False)
if not isinstance(weak, bool):
raise AuditError(f"{path}: entry {index} needs a boolean 'weak' field")
Expand Down Expand Up @@ -545,11 +543,17 @@ def audit(imports, exports, stubs, libraries, modules, module_imports=None, modu
providers = exports.get(nid, [])
if library is None:
providers = sorted(set(providers) | set((module_exports or {}).get(nid, [])))
kind = classify(nid, library, providers, stubs, modules)
resolved_nid = nid
if not providers:
resolved_nid = nid_names.compute_nid(nid)
providers = exports.get(resolved_nid, [])
if library is None:
providers = sorted(set(providers) | set((module_exports or {}).get(resolved_nid, [])))
kind = classify(resolved_nid, library, providers, stubs, modules)
record["class"] = kind
record["providers"] = providers
record["library_mismatch"] = kind in ("implemented", "stub") and bool(library) and library not in providers and "libc.prx" not in providers
record["name"] = next((stubs[(provider, nid)] for provider in providers if (provider, nid) in stubs), None) if kind == "stub" else None
record["name"] = next((stubs[(provider, resolved_nid)] for provider in providers if (provider, resolved_nid) in stubs), None) if kind == "stub" else None
ordered = sorted(records.values(), key=lambda record: (record["class"], record["library"] or "", record.get("module") or "", record["nid"]))
check_conservation(len(imports) + sum(len(symbols) for symbols, _ in (module_imports or {}).values()), ordered)
missing = defaultdict(int)
Expand Down
78 changes: 75 additions & 3 deletions tools/tests/test_import_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,78 @@ def setUp(self):
self.work = Workspace()
self.addCleanup(self.work.close)

def test_named_imports_use_literal_exports_and_keep_unresolved_names(self):
work = self.work
(work.libs / "libc.prx").write_bytes(exporting("strcmp", "_Z3foov", "AAAAAAAAAAA"))
registry, report = work.root / "homebrew.registry.json", work.root / "audit.json"
write_registry(registry, [("strcmp", ""), ("strcmp", ""), ("_Z3foov", "libc.prx"),
("AAAAAAAAAAA#A#B", "libc.prx"), ("missing_function", "", True)])
code, _, err = work.run(registry, "--json", str(report))
self.assertEqual((code, err), (1, ""))
data = json.loads(report.read_text(encoding="utf-8"))
records = {record["nid"]: record for record in data["imports"]}
self.assertEqual(data["references"], 5)
self.assertEqual(data["unique_by_class"], {"implemented": 3, "stub": 0, "absent": 1, "module": 0})
self.assertEqual(records["strcmp"]["references"], 2)
self.assertEqual(records["strcmp"]["providers"], ["libc.prx"])
self.assertTrue(records["missing_function"]["weak"])
self.assertIsNone(data["stops"][0]["nid"])

def test_named_imports_fall_back_to_nid_exports(self):
work = self.work
(work.libs / "libc.prx").write_bytes(exporting("j4ViWNHEgww"))
registry, report = work.root / "homebrew.registry.json", work.root / "audit.json"
write_registry(registry, [("strlen", ""), ("strlen", ""),
("missing_function", "", True)])
code, _, err = work.run(registry, "--json", str(report))
self.assertEqual((code, err), (1, ""))
data = json.loads(report.read_text(encoding="utf-8"))
records = {record["nid"]: record for record in data["imports"]}
self.assertEqual(data["references"], 3)
self.assertEqual(records["strlen"]["references"], 2)
self.assertEqual(records["strlen"]["class"], "implemented")
self.assertEqual(records["strlen"]["providers"], ["libc.prx"])
self.assertEqual(records["missing_function"]["class"], "absent")
self.assertTrue(records["missing_function"]["weak"])
self.assertIsNone(data["stops"][0]["nid"])

def test_named_nid_fallback_preserves_stub_and_library_classification(self):
nid = nid_names.compute_nid(MESSAGE_NAME)
imports = [(MESSAGE_NAME, "libSceOther.prx", False)]
records, missing = import_audit.audit(imports, {nid: ["libSceMsgDialog.prx"]},
{("libSceMsgDialog.prx", nid): MESSAGE_NAME},
{"libSceMsgDialog.prx", "libSceOther.prx"}, set())
self.assertEqual(missing, {})
self.assertEqual(records[0]["class"], "stub")
self.assertEqual(records[0]["nid"], MESSAGE_NAME)
self.assertEqual(records[0]["name"], MESSAGE_NAME)
self.assertTrue(records[0]["library_mismatch"])

def test_literal_exports_take_precedence_over_nid_fallback(self):
imports = [("strlen", "", False)]
stubs = {("literal.prx", "strlen"): "strlen"}
exports = {"strlen": ["literal.prx"], "j4ViWNHEgww": ["libc.prx"]}
records, _ = import_audit.audit(imports, exports, stubs, set(exports), set())
self.assertEqual(records[0]["class"], "stub")
self.assertEqual(records[0]["providers"], ["literal.prx"])
module_imports = {"guest.so": (["strlen"], [])}
records, _ = import_audit.audit([], {"j4ViWNHEgww": ["libc.prx"]}, {},
{"libc.prx"}, set(module_imports), module_imports,
{"strlen": ["other.so"]})
self.assertEqual(records[0]["providers"], ["other.so"])
records, _ = import_audit.audit([], {"j4ViWNHEgww": ["libc.prx"]}, {},
{"libc.prx"}, set(module_imports), module_imports)
self.assertEqual(records[0]["class"], "implemented")
self.assertEqual(records[0]["providers"], ["libc.prx"])

def test_empty_and_control_character_imports_remain_invalid(self):
registry = self.work.root / "homebrew.registry.json"
for name in ("", "#A#B", "bad\x00name", "bad\nname", "bad\x7fname", "bad name"):
with self.subTest(name=name):
write_registry(registry, [(name, "")])
with self.assertRaisesRegex(import_audit.AuditError, "invalid import symbol"):
import_audit.read_registry(registry)

def test_failed_json_write_preserves_existing_report_and_leaves_no_partial_report(self):
work = self.work
(work.libs / "libSceA.prx").write_bytes(exporting("AAAAAAAAAAA"))
Expand Down Expand Up @@ -1501,7 +1573,7 @@ def test_registry_errors(self):
with self.assertRaisesRegex(import_audit.AuditError, r"entry 0 needs string fields"):
import_audit.read_registry(path)
write_registry(path, [("AAAAAAAAAAA", ""), ("bad\x01nid", "")])
with self.assertRaisesRegex(import_audit.AuditError, r"entry 1 has an invalid NID 'bad\\x01nid'"):
with self.assertRaisesRegex(import_audit.AuditError, r"entry 1 has an invalid import symbol 'bad\\x01nid'"):
import_audit.read_registry(path)
write_registry(path, [("AAAAAAAAAAA#A#B", "libSceA.prx")], encoding="utf-8-sig")
self.assertEqual(import_audit.read_registry(path), [("AAAAAAAAAAA", "libSceA.prx", False)])
Expand Down Expand Up @@ -1535,10 +1607,10 @@ def test_errors_exit_two_with_the_failing_value(self):
work = self.work
(work.libs / "libSceA.prx").write_bytes(exporting("AAAAAAAAAAA"))
registry = work.root / "game.json"
write_registry(registry, [("short", "libSceA.prx")])
write_registry(registry, [("bad name", "libSceA.prx")])
code, out, err = work.run(registry)
self.assertEqual((code, out), (2, ""))
self.assertIn("game.json: entry 0 has an invalid NID 'short'", err)
self.assertIn("game.json: entry 0 has an invalid import symbol 'bad name'", err)

def test_unreadable_library_and_unwritable_json_exit_two(self):
work = self.work
Expand Down
Loading