Files

178 lines
7.8 KiB
Python

import argparse
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import import_audit
import unwrap_self
DESCRIPTION = """Audit the imports of an extracted title before it is launched. Unwraps the dump's eboot.bin and the modules
it ships (sce_module/, sce_modules/ and prx/, Unity's Media/Modules/ and Media/Plugins/, and .prx files beside
eboot.bin) into a work directory, converts them with relinker --registry, and runs tools/import_audit.py on the
registries with the unwrapped modules as --modules. The dump is only read; the source/ and output/ directories of a
reused --work directory are emptied first. Exit code: that of import_audit.py, or 2
when unwrapping or conversion fails."""
STANDARD_DIRECTORIES = ("sce_module", "sce_modules", "prx")
EXTRA_DIRECTORIES = ("Media/Modules", "Media/Plugins")
MODULE_SUFFIXES = (".prx", ".sprx")
ELF_MAGIC = b"\x7fELF"
def stage(source, destination):
try:
unwrap_self.unwrap(source, destination)
return "unwrapped"
except unwrap_self.Refused:
with open(source, "rb") as file:
if file.read(4) != ELF_MAGIC:
raise
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
return "copied"
def modules_of(directory):
return sorted(path for path in directory.iterdir() if (path.is_file() or path.is_symlink()) and path.suffix.lower() in MODULE_SUFFIXES)
def prepare(dump, source, log=print):
stage(dump / "eboot.bin", source / "eboot.elf")
module_directories = []
extra = []
for relative in STANDARD_DIRECTORIES + EXTRA_DIRECTORIES:
directory = dump / relative
if not directory.is_dir():
continue
modules = modules_of(directory)
if not modules:
continue
for module in modules:
stage(module, source / relative / module.name)
module_directories.append(source / relative)
if relative in EXTRA_DIRECTORIES:
extra.append(relative)
beside = modules_of(dump)
if beside:
for module in beside:
stage(module, source / module.name)
module_directories.append(source)
extra.append(".")
log(f"staged eboot.bin and {sum(len(modules_of(directory)) for directory in module_directories)} modules")
return module_directories, extra
def convert(relinker, source, output, extra, windows):
command = [str(relinker), "--registry"]
if windows:
command.append("--windows")
for relative in extra:
command += ["--module-dir", relative]
command += [str(source / "eboot.elf"), str(output)]
return subprocess.run(command, capture_output=True, text=True)
def comparison_path(path, links=frozenset()):
for prefix in (*reversed(path.parents), path):
if not prefix.is_symlink():
continue
identity = prefix.parent.resolve() / prefix.name
if identity in links:
raise RuntimeError("symbolic link cycle")
target = prefix.readlink()
destination = target if target.is_absolute() else prefix.parent / target
resolved_target = comparison_path(destination, links | {identity})
return comparison_path(resolved_target / path.relative_to(prefix), links)
resolved = path.resolve()
drive = resolved.drive
if drive.casefold().startswith("\\\\?\\unc\\"):
return type(resolved)("\\\\" + str(resolved)[8:])
if len(drive) == 6 and drive.startswith("\\\\?\\") and drive.endswith(":"):
return type(resolved)(str(resolved)[4:])
return resolved
def report_overlaps_dump(report, dump):
destination = comparison_path(report)
dump_path = comparison_path(dump)
if report.absolute().is_relative_to(dump.absolute()) or destination.is_relative_to(dump_path) or comparison_path(report.parent).is_relative_to(dump_path):
return True
exists = report.exists()
for path in dump.rglob("*"):
if path.is_symlink() and path.is_dir() and destination.is_relative_to(comparison_path(path)):
return True
if exists and path.is_file() and report.samefile(path):
return True
return False
def main(argv=None):
parser = argparse.ArgumentParser(description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("dump", help="the title's extracted app0 directory, holding eboot.bin")
parser.add_argument("--relinker", required=True, help="the relinker executable")
parser.add_argument("--libs", action="append", required=True, help="built .prx directory, as for import_audit.py (repeatable)")
parser.add_argument("--work", help="directory for the unwrapped files and registries (default: a temporary one, removed afterwards)")
parser.add_argument("--windows", action="store_true", help="convert for Windows, as the title would be run there")
parser.add_argument("--json", help="also write import_audit.py's JSON report to this file outside the input dump")
args = parser.parse_args(argv)
dump = Path(args.dump)
if not (dump / "eboot.bin").is_file():
parser.error(f"{dump}: no eboot.bin")
relinker = Path(args.relinker).resolve()
if not relinker.is_file():
parser.error(f"{args.relinker}: no such relinker")
dump_path = comparison_path(dump)
if args.json:
report = Path(args.json)
try:
overlaps = report_overlaps_dump(report, dump)
except (OSError, RuntimeError) as error:
parser.error(f"{report}: cannot check JSON output against the input dump: {error}")
if overlaps:
parser.error(f"{report}: JSON output overlaps the input dump")
with tempfile.TemporaryDirectory() as temporary:
work = Path(args.work) if args.work else Path(temporary)
source, output = work / "source", work / "output"
for generated in (source, output):
generated_path = comparison_path(generated)
if dump_path.is_relative_to(generated_path) or generated_path.is_relative_to(dump_path):
parser.error(f"{generated}: work directory overlaps the input dump")
for generated in (source, output):
try:
if generated.is_symlink():
generated.unlink()
elif generated.exists():
shutil.rmtree(generated)
except OSError as error:
print(f"FAIL: cannot empty {generated}: {error}", file=sys.stderr)
return 2
if generated.exists() or generated.is_symlink():
print(f"FAIL: cannot empty {generated}: it still exists", file=sys.stderr)
return 2
output.mkdir(parents=True, exist_ok=True)
try:
module_directories, extra = prepare(dump, source)
except (unwrap_self.Refused, OSError) as error:
print(f"FAIL: unwrapping {dump}: {error}", file=sys.stderr)
return 2
result = convert(relinker, source, output / ("app.exe" if args.windows else "app.elf"), extra, args.windows)
if result.returncode != 0:
print(f"FAIL: relinker exited with {result.returncode}", file=sys.stderr)
print((result.stderr or result.stdout).strip(), file=sys.stderr)
return 2
registries = sorted(str(path) for path in output.rglob("*.registry.json"))
audit = registries + [option for library in args.libs for option in ("--libs", library)]
audit += [option for directory in module_directories for option in ("--modules", str(directory))]
if args.json:
audit += ["--json", args.json]
return import_audit.main(audit)
if __name__ == "__main__":
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main())