#!/usr/bin/env python3 """Resolve every import of the macOS ARM64 slice against the iOS SDK's text stubs. Usage: python3 tools/native_audit.py build/factorio.arm64 [--json out.json] For each LC_LOAD_DYLIB the import is looked up in the iOS library with the same framework name, following re-exported libraries. Imports that do not resolve must be supplied by a shim. Nothing on the device is consulted, so this is a link-level audit only; Objective-C selectors are not checked. """ import json import re import subprocess import sys from pathlib import Path SDK = Path(subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip()) def tbd_documents(): """Yield (install_name, symbols, reexports) for every tbd document in the SDK.""" files = list((SDK / "usr/lib").rglob("*.tbd")) + list((SDK / "System/Library").rglob("*.tbd")) for path in files: text = path.read_text(errors="ignore") for doc in re.split(r"^--- !tapi-tbd", text, flags=re.M): name = re.search(r"^install-name:\s*'?([^'\n]+)'?", doc, re.M) if not name: continue exports = doc.split("\nreexports:")[0].split("\nundefineds:")[0] symbols = set() for kind, prefix in (("symbols", ""), ("weak-symbols", ""), ("thread-local-symbols", ""), ("objc-classes", "class"), ("objc-eh-types", "eh"), ("objc-ivars", "ivar")): for block in re.findall(rf"\b{kind}:\s*\[([^\]]*)\]", exports): for item in block.split(","): item = item.strip().strip("'\"") if not item: continue if prefix == "class": symbols.update({f"_OBJC_CLASS_$_{item}", f"_OBJC_METACLASS_$_{item}"}) elif prefix == "eh": symbols.add(f"_OBJC_EHTYPE_$_{item}") elif prefix == "ivar": symbols.add(f"_OBJC_IVAR_$_{item}") else: symbols.add(item) reexports = set() for block in re.findall(r"reexported-libraries:.*?libraries:\s*\[([^\]]*)\]", doc, re.S): reexports.update(x.strip().strip("'\"") for x in block.split(",") if x.strip()) yield name.group(1), symbols, reexports def main(): binary = sys.argv[1] libs = {} for name, symbols, reexports in tbd_documents(): entry = libs.setdefault(name, [set(), set()]) entry[0] |= symbols entry[1] |= reexports def resolves(install, symbol, seen=()): if install not in libs or install in seen: return False symbols, reexports = libs[install] return symbol in symbols or any(resolves(r, symbol, seen + (install,)) for r in reexports) def ios_path(mac_path): if mac_path in libs: # e.g. IOKit keeps its Versions/A install name on iOS return mac_path m = re.match(r"/System/Library/Frameworks/(\w+)\.framework/", mac_path) if m: return f"/System/Library/Frameworks/{m.group(1)}.framework/{m.group(1)}" return mac_path loads = re.findall(r"cmd LC_LOAD(?:_WEAK)?_DYLIB\n\s+cmdsize \d+\n\s+name (\S+)", subprocess.check_output(["otool", "-l", binary], text=True)) short = {Path(p).name.replace(".dylib", "").split(".")[0]: p for p in loads} undef = subprocess.check_output(["nm", "-m", "-u", binary], text=True) report = {} for line in undef.splitlines(): m = re.search(r"external (\S+) \(from (\S+)\)", line) if not m: continue symbol, lib = m.groups() mac = short.get(lib) target = ios_path(mac) if mac else None status = "resolved" if target and resolves(target, symbol) else "missing" lib_report = report.setdefault(lib, {"mac_path": mac, "ios_path": target, "ios_library_exists": target in libs, "resolved": 0, "missing": []}) if status == "resolved": lib_report["resolved"] += 1 else: lib_report["missing"].append(symbol) for lib, r in sorted(report.items(), key=lambda kv: -len(kv[1]["missing"])): print(f"{lib:20} exists={r['ios_library_exists']!s:5} resolved={r['resolved']:4} missing={len(r['missing']):3} " + " ".join(r["missing"][:12]) + (" ..." if len(r["missing"]) > 12 else "")) if "--json" in sys.argv: Path(sys.argv[sys.argv.index("--json") + 1]).write_text(json.dumps(report, indent=2) + "\n") if __name__ == "__main__": main()