import contextlib import os import io import re import shutil import struct import sys import tempfile import subprocess import unittest from pathlib import Path from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import annotate_module import nid_names IMPORT_NAME = "sceKernelGetProcessTime" EXPORT_NAME = "sceKernelUsleep" SECOND_IMPORT_NAME = "sceKernelGetTscFrequency" TLS_NAME = "sceKernelGetCurrentCpu" GNU_HASH = 0x6FFFFEF5 CODE_ADDRESS = 0x1000 DATA_ADDRESS = 0x2000 SLOT = 0x2800 CALL_ADDRESS = 0x1006 EXPORT_ADDRESS = 0x100B def nid(name): return nid_names.compute_nid(name) def symbol_name(name): return nid(name) + "#A#A" def code_bytes(prefix=b""): stub = prefix + b"\xff\x25" + struct.pack(" " + IMPORT_NAME)) header = listing.index("; ===== export " + EXPORT_NAME) self.assertEqual(instruction(listing, "ret"), listing[header + 1]) self.assertEqual([], [path.name for path in self.root.iterdir() if path.suffix == ".bin"]) def test_unknown_nid_prints_the_nid(self): empty = self.root / "empty.csv" empty.write_text("", encoding="utf-8") self.run_tool(build_module(), "--db", str(empty)) self.assertEqual("%#x %s %s\n" % (SLOT, symbol_name(IMPORT_NAME), nid(IMPORT_NAME)), self.read(".imports.txt")) self.assertIn("; -> " + nid(IMPORT_NAME), self.read(".asm")) def test_no_names_skips_the_database(self): self.run_tool(build_module(), "--no-names") self.assertIn("; -> " + nid(IMPORT_NAME), self.read(".asm")) def test_mismatching_database_name_is_not_used(self): wrong = self.root / "wrong.csv" wrong.write_text("%s sceKernelOther\n" % nid(IMPORT_NAME), encoding="utf-8") self.run_tool(build_module(), "--db", str(wrong)) self.assertNotIn("sceKernelOther", self.read(".asm")) def test_gnu_hash_gives_the_symbol_count(self): symbols = [(IMPORT_NAME, 0, 0x12, 0), (SECOND_IMPORT_NAME, 0, 0x12, 0), (EXPORT_NAME, 1, 0x12, EXPORT_ADDRESS)] self.annotate(build_module(tags=(GNU_HASH, 5, 6, 10, 11, 23, 2, 20), symbols=symbols, kind="gnu")) self.assertIn(EXPORT_NAME, self.read(".exports.txt")) def test_glob_dat_slot_referenced_by_an_indirect_call(self): code = b"\xff\x15" + struct.pack(" " + IMPORT_NAME)) def test_tls_symbol_is_listed_but_not_an_address(self): symbols = DEFAULT_SYMBOLS + [(TLS_NAME, 1, 0x16, EXPORT_ADDRESS)] summary = self.annotate(build_module(symbols=symbols)) self.assertIn("1 exports, 1 tls", summary) self.assertIn("tls+%#x %s %s\n" % (EXPORT_ADDRESS, symbol_name(TLS_NAME), TLS_NAME), self.read(".exports.txt")) self.assertNotIn("export " + TLS_NAME, self.read(".asm")) def test_export_at_address_zero_is_kept(self): symbols = [(IMPORT_NAME, 0, 0x12, 0), (EXPORT_NAME, 1, 0x12, 0)] self.annotate(build_module(symbols=symbols)) self.assertEqual("0x0 %s %s\n" % (symbol_name(EXPORT_NAME), EXPORT_NAME), self.read(".exports.txt")) def test_every_executable_segment_is_disassembled(self): self.annotate(build_module(extra_code=b"\xc3")) listing = self.read(".asm").splitlines() first, second = listing.index("; ===== segment 0x1000"), listing.index("; ===== segment 0x3000") self.assertLess(first, second) self.assertTrue(any(re.match(r"^\s*3000:\s+ret\b", line) for line in listing[second:])) def test_missing_hash_fails(self): self.assert_fails(build_module(tags=(5, 6, 10, 11, 23, 2, 20)), "neither DT_HASH nor DT_GNU_HASH") def test_missing_symtab_fails(self): self.assert_fails(build_module(tags=(4, 5, 10, 11, 23, 2, 20)), "DT_SYMTAB") def test_missing_strsz_fails(self): self.assert_fails(build_module(tags=(4, 5, 6, 11, 23, 2, 20)), "DT_STRSZ") def test_missing_pltrelsz_fails(self): self.assert_fails(build_module(tags=(4, 5, 6, 10, 11, 23, 20)), "DT_PLTRELSZ") def test_missing_relasz_fails(self): self.assert_fails(build_module(tags=(4, 5, 6, 10, 11, 7)), "DT_RELASZ") def test_pltrel_must_be_rela(self): self.assert_fails(build_module(values={20: 17}), "DT_PLTREL is not DT_RELA") def test_dt_rel_is_rejected(self): self.assert_fails(build_module(tags=DEFAULT_TAGS + (17,)), "DT_REL relocations are not supported") def test_relaent_must_be_24(self): self.assert_fails(build_module(tags=DEFAULT_TAGS + (9,), values={9: 16}), "DT_RELAENT") def test_name_offset_past_strsz_fails(self): self.assert_fails(build_module(values={10: len(symbol_name(IMPORT_NAME)) + 2}), "symbol 2 has a name offset") def test_strsz_past_the_end_of_the_file_fails(self): self.assert_fails(build_module(values={10: 0x100000}), "string table of DT_STRSZ 0x100000 extends past the end of the file") def test_name_unterminated_within_strsz_fails(self): self.assert_fails(build_module(values={10: 2}), "unterminated within DT_STRSZ") def test_relocation_size_not_a_multiple_of_24_fails(self): self.assert_fails(build_module(values={2: 25}), "not a multiple of 24") def test_no_executable_segment_fails(self): self.assert_fails(build_module(code_flags=6), "no executable PT_LOAD segment") def test_wrong_machine_fails(self): self.assert_fails(build_module(machine=0xB7), "not an x86-64 ELF") class InputProtectionTests(unittest.TestCase): def test_cli_rejects_alias_without_success_or_partial_reports(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) module = root / "report.exports.txt" original = build_module() module.write_bytes(original) result = subprocess.run([sys.executable, annotate_module.__file__, str(module), "--out", str(root / "report"), "--no-names"], capture_output=True, text=True, timeout=10) self.assertNotEqual(result.returncode, 0) self.assertIn("separate from the input", result.stderr) self.assertNotIn("Traceback", result.stderr) self.assertEqual(result.stdout, "") self.assertEqual(module.read_bytes(), original) self.assertFalse((root / "report.imports.txt").exists()) self.assertFalse((root / "report.asm").exists()) def test_all_report_outputs_are_checked_before_any_write(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) for suffix in (".imports.txt", ".exports.txt", ".asm"): with self.subTest(suffix=suffix): prefix = root / suffix[1:].replace(".", "_") module = Path(str(prefix) + suffix) original = build_module() module.write_bytes(original) with self.assertRaisesRegex(annotate_module.ModuleError, "separate from the input"): annotate_module.annotate(str(module), str(prefix), annotate_module.Namer({})) self.assertEqual(module.read_bytes(), original) for other in (".imports.txt", ".exports.txt", ".asm"): if other != suffix: self.assertFalse(Path(str(prefix) + other).exists()) def test_hardlinked_outputs_preserve_the_input_and_existing_reports(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) module = root / "input.prx" original = build_module() module.write_bytes(original) prefix = root / "report" first = root / "report.imports.txt" first.write_bytes(b"previous report") os.link(module, root / "report.asm") with self.assertRaisesRegex(annotate_module.ModuleError, "separate from the input"): annotate_module.annotate(str(module), str(prefix), annotate_module.Namer({})) self.assertEqual(module.read_bytes(), original) self.assertEqual(first.read_bytes(), b"previous report") self.assertFalse((root / "report.exports.txt").exists()) def test_symlinked_output_is_rejected_where_available(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) module = root / "input.prx" original = build_module() module.write_bytes(original) output = root / "report.asm" try: output.symlink_to(module) except OSError as error: if os.name == "nt" and getattr(error, "winerror", None) == 1314: self.skipTest("Windows symbolic-link privileges unavailable") raise with self.assertRaisesRegex(annotate_module.ModuleError, "separate from the input"): annotate_module.annotate(str(module), str(root / "report"), annotate_module.Namer({})) self.assertEqual(module.read_bytes(), original) if __name__ == "__main__": unittest.main()