ipa-forge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. ipa_forge/__init__.py +1 -0
  2. ipa_forge/altstore/__init__.py +1 -0
  3. ipa_forge/altstore/source.py +44 -0
  4. ipa_forge/analysis/__init__.py +18 -0
  5. ipa_forge/analysis/classdump.py +103 -0
  6. ipa_forge/analysis/diff.py +127 -0
  7. ipa_forge/analysis/security.py +72 -0
  8. ipa_forge/analysis/strings.py +40 -0
  9. ipa_forge/analysis/symbols.py +42 -0
  10. ipa_forge/analysis/type_encoding.py +164 -0
  11. ipa_forge/bundle/__init__.py +1 -0
  12. ipa_forge/bundle/inventory.py +96 -0
  13. ipa_forge/bundle/ipa.py +81 -0
  14. ipa_forge/bundle/models.py +58 -0
  15. ipa_forge/bundle/plist.py +18 -0
  16. ipa_forge/cli/__init__.py +1 -0
  17. ipa_forge/cli/analysis.py +225 -0
  18. ipa_forge/cli/common.py +41 -0
  19. ipa_forge/cli/hooks.py +357 -0
  20. ipa_forge/cli/main.py +170 -0
  21. ipa_forge/gui/__init__.py +1 -0
  22. ipa_forge/gui/analysis.html +229 -0
  23. ipa_forge/gui/app.py +304 -0
  24. ipa_forge/gui/index.html +244 -0
  25. ipa_forge/hooks/__init__.py +0 -0
  26. ipa_forge/hooks/scan.py +81 -0
  27. ipa_forge/hooks/verify.py +348 -0
  28. ipa_forge/machO/__init__.py +1 -0
  29. ipa_forge/machO/arch.py +94 -0
  30. ipa_forge/machO/detect.py +50 -0
  31. ipa_forge/machO/injector.py +99 -0
  32. ipa_forge/machO/objc.py +534 -0
  33. ipa_forge/manifest.py +74 -0
  34. ipa_forge/patch/__init__.py +1 -0
  35. ipa_forge/patch/base.py +48 -0
  36. ipa_forge/patch/binary.py +131 -0
  37. ipa_forge/patch/dylib.py +59 -0
  38. ipa_forge/patch/engine.py +48 -0
  39. ipa_forge/patch/loader.py +88 -0
  40. ipa_forge/patch/paths.py +19 -0
  41. ipa_forge/patch/plist.py +61 -0
  42. ipa_forge/patch/resolver.py +37 -0
  43. ipa_forge/patch/resource.py +135 -0
  44. ipa_forge/patch/schema.py +118 -0
  45. ipa_forge/patch/version.py +25 -0
  46. ipa_forge/patches.py +76 -0
  47. ipa_forge/pipeline.py +235 -0
  48. ipa_forge/signing/__init__.py +1 -0
  49. ipa_forge/signing/backend.py +111 -0
  50. ipa_forge/signing/pipeline.py +99 -0
  51. ipa_forge/signing/profile.py +131 -0
  52. ipa_forge/signing/provider.py +113 -0
  53. ipa_forge/signing/reconcile.py +27 -0
  54. ipa_forge/validators/__init__.py +1 -0
  55. ipa_forge/validators/archive_validator.py +25 -0
  56. ipa_forge/validators/bundle_validator.py +27 -0
  57. ipa_forge/validators/ipa_validator.py +39 -0
  58. ipa_forge-0.1.0.dist-info/METADATA +914 -0
  59. ipa_forge-0.1.0.dist-info/RECORD +62 -0
  60. ipa_forge-0.1.0.dist-info/WHEEL +4 -0
  61. ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
  62. ipa_forge-0.1.0.dist-info/licenses/LICENSE +674 -0
ipa_forge/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,44 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """AltStore Classic source.json app-entry export.
3
+
4
+ A distribution metadata layer, deliberately kept separate from the signing
5
+ engine (see the architecture doc's AltStore-source-is-optional note) -- it
6
+ only describes an already-patched-and-signed .ipa, it never produces one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from ipa_forge.bundle.ipa import extract_ipa, load_bundle
17
+ from ipa_forge.manifest import sha256_of
18
+ from ipa_forge.validators.ipa_validator import validate_ipa_structure
19
+
20
+
21
+ def build_app_entry(ipa_path: Path, download_url: str) -> dict[str, Any]:
22
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_export_") as tmp:
23
+ validate_ipa_structure(ipa_path)
24
+ app_path = extract_ipa(ipa_path, Path(tmp))
25
+ bundle = load_bundle(app_path)
26
+ name = bundle.info_plist.get("CFBundleName", bundle.main_executable_name)
27
+ bundle_id = bundle.bundle_id
28
+ version = bundle.version
29
+ build = bundle.build
30
+
31
+ return {
32
+ "name": name,
33
+ "bundleIdentifier": bundle_id,
34
+ "version": version,
35
+ "buildVersion": build,
36
+ "downloadURL": download_url,
37
+ "size": ipa_path.stat().st_size,
38
+ "sha256": sha256_of(ipa_path),
39
+ }
40
+
41
+
42
+ def write_source_json(entry: dict[str, Any], path: Path, source_name: str = "ipa-forge patched apps") -> None:
43
+ source = {"name": source_name, "apps": [entry]}
44
+ path.write_text(json.dumps(source, indent=2))
@@ -0,0 +1,18 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """General-purpose static reverse engineering of an IPA -- class-dump,
3
+ strings, symbols, security posture, and version diffing -- built on the same
4
+ Mach-O/ObjC analysis engine `ipa_forge.hooks` uses for hook verification
5
+ (`ipa_forge.machO.objc`). See `forge analysis --help`.
6
+
7
+ Deliberately out of scope, by design, not oversight (see ROADMAP.md):
8
+
9
+ - **FairPlay/App Store DRM decryption.** Every command here assumes an
10
+ already-decrypted `.ipa`, exactly like the rest of ipa-forge; decryption
11
+ is DRM-circumvention tooling, a different risk category from static
12
+ analysis of a binary you already have rights to inspect.
13
+ - **Instruction-level disassembly/decompilation** (capstone/Ghidra-grade).
14
+ A large additional dependency and maintenance surface; tracked as a
15
+ future phase in ROADMAP.md rather than bundled by default.
16
+ """
17
+
18
+ from __future__ import annotations
@@ -0,0 +1,103 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Render a `MachOAnalysis` as `.h`-style class-dump text: `@interface`,
3
+ `@protocol`, and `@interface (Category)` blocks with method signatures,
4
+ ivars, and properties -- the general-purpose reverse-engineering view built
5
+ on the same class table `ipa_forge.hooks` uses for hook verification.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ipa_forge.analysis.type_encoding import decode_method_signature, decode_type, read_one_type
11
+ from ipa_forge.machO.objc import MachOAnalysis, MachOCategory, MachOClass, MachOProtocol
12
+
13
+
14
+ def _method_lines(methods: dict[str, str], prefix: str) -> list[str]:
15
+ return [f"{prefix} {decode_method_signature(sel, enc)};" for sel, enc in sorted(methods.items())]
16
+
17
+
18
+ def _decl(type_str: str, name: str) -> str:
19
+ """`Type name;` for a value type, `Type *name;` for a pointer type --
20
+ decode_type() already renders the trailing `*`, so avoid a stray space
21
+ between it and the identifier."""
22
+ sep = "" if type_str.endswith("*") else " "
23
+ return f"{type_str}{sep}{name}"
24
+
25
+
26
+ def render_class(cls: MachOClass) -> str:
27
+ conforms = f" <{', '.join(sorted(cls.protocols))}>" if cls.protocols else ""
28
+ header = f"@interface {cls.name} : {cls.super_name or 'NSObject'}{conforms}"
29
+ lines = [header]
30
+
31
+ if cls.ivars:
32
+ lines.append("{")
33
+ for iv in cls.ivars:
34
+ lines.append(f" {_decl(decode_type(iv.type_encoding), iv.name)};")
35
+ lines.append("}")
36
+
37
+ for prop in cls.properties:
38
+ # attribute string: "T<type-encoding>,<flag>,<flag>,..." (e.g.
39
+ # 'T@"NSString",C,N,V_label') -- a naive split(",") would break on a
40
+ # comma inside a generic-collection class name, so tokenize the type
41
+ # encoding itself rather than string-splitting the whole attribute.
42
+ type_enc = prop.attributes[1:] if prop.attributes.startswith("T") else prop.attributes
43
+ tok, _ = read_one_type(type_enc, 0) if type_enc else ("", 0)
44
+ decoded = decode_type(tok) if tok else "id"
45
+ lines.append(f"@property {_decl(decoded, prop.name)}; // {prop.attributes}")
46
+
47
+ if cls.properties and (cls.inst or cls.cls):
48
+ lines.append("")
49
+
50
+ lines.extend(_method_lines(cls.cls, "+"))
51
+ lines.extend(_method_lines(cls.inst, "-"))
52
+
53
+ lines.append("")
54
+ lines.append("@end")
55
+ return "\n".join(lines)
56
+
57
+
58
+ def render_protocol(proto: MachOProtocol) -> str:
59
+ conforms = f" <{', '.join(sorted(proto.protocols))}>" if proto.protocols else ""
60
+ lines = [f"@protocol {proto.name}{conforms}"]
61
+ lines.extend(_method_lines(proto.cls, "+"))
62
+ lines.extend(_method_lines(proto.inst, "-"))
63
+ if proto.opt_cls or proto.opt_inst:
64
+ lines.append("@optional")
65
+ lines.extend(_method_lines(proto.opt_cls, "+"))
66
+ lines.extend(_method_lines(proto.opt_inst, "-"))
67
+ lines.append("@end")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def render_category(cat: MachOCategory) -> str:
72
+ lines = [f"@interface {cat.class_name} ({cat.name})"]
73
+ lines.extend(_method_lines(cat.cls, "+"))
74
+ lines.extend(_method_lines(cat.inst, "-"))
75
+ lines.append("@end")
76
+ return "\n".join(lines)
77
+
78
+
79
+ def render_analysis(
80
+ analysis: MachOAnalysis,
81
+ *,
82
+ class_filter: str | None = None,
83
+ search: str | None = None,
84
+ ) -> str:
85
+ """Render every class/protocol/category in `analysis` as class-dump text,
86
+ optionally restricted to one class name or a substring/regex search over
87
+ class names (matches `forge hooks extract`'s --class/--search)."""
88
+ import re as _re
89
+
90
+ classes = sorted(analysis.classes.values(), key=lambda c: c.name)
91
+ if class_filter:
92
+ classes = [c for c in classes if c.name == class_filter]
93
+ elif search:
94
+ pat = _re.compile(search)
95
+ classes = [c for c in classes if pat.search(c.name)]
96
+
97
+ blocks: list[str] = []
98
+ if not class_filter and not search:
99
+ blocks.extend(render_protocol(p) for p in sorted(analysis.protocols.values(), key=lambda p: p.name))
100
+ blocks.extend(render_class(c) for c in classes)
101
+ if not class_filter and not search:
102
+ blocks.extend(render_category(cat) for cat in sorted(analysis.categories, key=lambda c: (c.class_name, c.name)))
103
+ return "\n\n".join(blocks)
@@ -0,0 +1,127 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Version-to-version diffing for porting: what changed between two builds
3
+ of the same app -- classes/protocols added or removed, per-class method
4
+ churn, and Info.plist key changes.
5
+
6
+ Distinct from `forge hooks diff`, which only re-checks a patch definition's
7
+ *declared* hook targets and is a pass/fail gate (did a required hook
8
+ regress?). This is the broader, informational survey: what changed at all,
9
+ independent of any particular patch set. Entitlements are deliberately not
10
+ diffed here -- reading them requires shelling out to `codesign`/`security`,
11
+ and `signing/backend.py` is the only module allowed to do that (see
12
+ architecture.md's hard constraint); a real entitlements diff belongs there,
13
+ not in this read-only static-analysis package.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ from ipa_forge.machO.objc import MachOAnalysis
22
+
23
+ _MISSING = object()
24
+
25
+
26
+ @dataclass
27
+ class AnalysisDiff:
28
+ added_classes: set[str] = field(default_factory=set)
29
+ removed_classes: set[str] = field(default_factory=set)
30
+ added_protocols: set[str] = field(default_factory=set)
31
+ removed_protocols: set[str] = field(default_factory=set)
32
+ # class name -> (added selectors, removed selectors); instance + class
33
+ # methods combined, only for classes present in both builds.
34
+ changed_class_methods: dict[str, tuple[set[str], set[str]]] = field(default_factory=dict)
35
+ changed_plist_keys: dict[str, tuple[Any, Any]] = field(default_factory=dict)
36
+
37
+ @property
38
+ def has_changes(self) -> bool:
39
+ return bool(
40
+ self.added_classes
41
+ or self.removed_classes
42
+ or self.added_protocols
43
+ or self.removed_protocols
44
+ or self.changed_class_methods
45
+ or self.changed_plist_keys
46
+ )
47
+
48
+
49
+ def diff_classes(
50
+ old: MachOAnalysis, new: MachOAnalysis
51
+ ) -> tuple[set[str], set[str], dict[str, tuple[set[str], set[str]]]]:
52
+ old_names = set(old.classes)
53
+ new_names = set(new.classes)
54
+ added = new_names - old_names
55
+ removed = old_names - new_names
56
+
57
+ changed: dict[str, tuple[set[str], set[str]]] = {}
58
+ for name in old_names & new_names:
59
+ old_methods = set(old.classes[name].inst) | set(old.classes[name].cls)
60
+ new_methods = set(new.classes[name].inst) | set(new.classes[name].cls)
61
+ added_m = new_methods - old_methods
62
+ removed_m = old_methods - new_methods
63
+ if added_m or removed_m:
64
+ changed[name] = (added_m, removed_m)
65
+ return added, removed, changed
66
+
67
+
68
+ def diff_plists(old_plist: dict[str, Any], new_plist: dict[str, Any]) -> dict[str, tuple[Any, Any]]:
69
+ """Key -> (old value, new value); a key present on only one side reports
70
+ `None` for the side it's missing from."""
71
+ changed: dict[str, tuple[Any, Any]] = {}
72
+ for key in set(old_plist) | set(new_plist):
73
+ old_val = old_plist.get(key, _MISSING)
74
+ new_val = new_plist.get(key, _MISSING)
75
+ if old_val != new_val:
76
+ changed[key] = (
77
+ None if old_val is _MISSING else old_val,
78
+ None if new_val is _MISSING else new_val,
79
+ )
80
+ return changed
81
+
82
+
83
+ def diff_analyses(
84
+ old: MachOAnalysis,
85
+ new: MachOAnalysis,
86
+ old_plist: dict[str, Any],
87
+ new_plist: dict[str, Any],
88
+ ) -> AnalysisDiff:
89
+ added_classes, removed_classes, changed_methods = diff_classes(old, new)
90
+ return AnalysisDiff(
91
+ added_classes=added_classes,
92
+ removed_classes=removed_classes,
93
+ added_protocols=set(new.protocols) - set(old.protocols),
94
+ removed_protocols=set(old.protocols) - set(new.protocols),
95
+ changed_class_methods=changed_methods,
96
+ changed_plist_keys=diff_plists(old_plist, new_plist),
97
+ )
98
+
99
+
100
+ def render_diff(diff: AnalysisDiff) -> str:
101
+ if not diff.has_changes:
102
+ return "no differences found"
103
+
104
+ lines: list[str] = []
105
+ if diff.added_classes:
106
+ lines.append(f"+ {len(diff.added_classes)} new class(es):")
107
+ lines.extend(f" {c}" for c in sorted(diff.added_classes))
108
+ if diff.removed_classes:
109
+ lines.append(f"- {len(diff.removed_classes)} removed class(es):")
110
+ lines.extend(f" {c}" for c in sorted(diff.removed_classes))
111
+ if diff.added_protocols:
112
+ lines.append(f"+ {len(diff.added_protocols)} new protocol(s): {', '.join(sorted(diff.added_protocols))}")
113
+ if diff.removed_protocols:
114
+ removed_names = ", ".join(sorted(diff.removed_protocols))
115
+ lines.append(f"- {len(diff.removed_protocols)} removed protocol(s): {removed_names}")
116
+ if diff.changed_class_methods:
117
+ lines.append(f"~ {len(diff.changed_class_methods)} class(es) with method changes:")
118
+ for cls, (added_m, removed_m) in sorted(diff.changed_class_methods.items()):
119
+ if added_m:
120
+ lines.append(f" {cls}: + {', '.join(sorted(added_m))}")
121
+ if removed_m:
122
+ lines.append(f" {cls}: - {', '.join(sorted(removed_m))}")
123
+ if diff.changed_plist_keys:
124
+ lines.append(f"~ {len(diff.changed_plist_keys)} Info.plist key(s) changed:")
125
+ for key, (old_v, new_v) in sorted(diff.changed_plist_keys.items()):
126
+ lines.append(f" {key}: {old_v!r} -> {new_v!r}")
127
+ return "\n".join(lines)
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Read-only Mach-O security/build posture: PIE, encryption-flag detection
3
+ (never decryption -- see the module docstring boundary in
4
+ `ipa_forge.analysis`), stack protector, an ARC heuristic, min-OS, and
5
+ platform. The same kind of static check `otool -l`/jtool2's `--sig` print,
6
+ as structured data instead of text to grep."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import lief
14
+
15
+ from ipa_forge.machO.arch import arch_name, load_macho
16
+
17
+
18
+ @dataclass
19
+ class SecurityPosture:
20
+ binary: str
21
+ arch: str
22
+ pie: bool
23
+ # True when LC_ENCRYPTION_INFO(_64)'s cryptid != 0 -- an App Store binary
24
+ # pulled without prior decryption. Detection only; this tool never
25
+ # decrypts it (see ROADMAP.md for why that is out of scope).
26
+ encrypted: bool
27
+ stack_protector: bool # ___stack_chk_fail imported
28
+ arc_heuristic: bool # _objc_storeStrong/_objc_release imported (best-effort, not definitive)
29
+ min_os: str | None
30
+ platform: str | None
31
+
32
+
33
+ def analyze_security(path: Path, arch: str | None = None) -> SecurityPosture:
34
+ binary = load_macho(path, arch)
35
+ imported = {str(s.name) for s in binary.symbols if s.category == lief.MachO.Symbol.CATEGORY.UNDEFINED}
36
+
37
+ enc = binary.encryption_info
38
+ encrypted = enc is not None and enc.crypt_id != 0
39
+
40
+ min_os: str | None = None
41
+ platform: str | None = None
42
+ bv = binary.build_version
43
+ vm = binary.version_min
44
+ if bv is not None:
45
+ min_os = ".".join(str(n) for n in bv.minos)
46
+ platform = str(bv.platform).rsplit(".", 1)[-1]
47
+ elif vm is not None:
48
+ min_os = ".".join(str(n) for n in vm.version)
49
+
50
+ return SecurityPosture(
51
+ binary=path.name,
52
+ arch=arch_name(binary),
53
+ pie=lief.MachO.Header.FLAGS.PIE in binary.header.flags_list,
54
+ encrypted=encrypted,
55
+ stack_protector="___stack_chk_fail" in imported,
56
+ arc_heuristic=bool({"_objc_storeStrong", "_objc_release"} & imported),
57
+ min_os=min_os,
58
+ platform=platform,
59
+ )
60
+
61
+
62
+ def render_security_posture(posture: SecurityPosture) -> str:
63
+ """A short human-readable summary for the CLI."""
64
+ os_bit = f", min {posture.platform} {posture.min_os}" if posture.min_os else ""
65
+ lines = [
66
+ f"{posture.binary} ({posture.arch}){os_bit}",
67
+ f" PIE: {'yes' if posture.pie else 'NO'}",
68
+ f" Encrypted: {'yes (App Store, undecrypted)' if posture.encrypted else 'no'}",
69
+ f" Stack protector: {'yes' if posture.stack_protector else 'no'}",
70
+ f" ARC (heuristic): {'likely' if posture.arc_heuristic else 'unclear / MRC'}",
71
+ ]
72
+ return "\n".join(lines)
@@ -0,0 +1,40 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Printable-string extraction from Mach-O binaries -- `strings <binary>`,
3
+ but IPA-aware: runs over every executable in the bundle (main + frameworks +
4
+ dylibs + extensions) in one pass, tagging each string with the binary it
5
+ came from."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ from ipa_forge.bundle.models import AppBundle
13
+ from ipa_forge.machO.detect import bundle_executable_paths
14
+
15
+
16
+ @dataclass
17
+ class ExtractedString:
18
+ value: str
19
+ binary: str # file name of the executable it was found in
20
+
21
+
22
+ def extract_strings(data: bytes, min_len: int = 4) -> list[str]:
23
+ """Printable-ASCII runs at least `min_len` bytes long, terminated by a
24
+ non-printable/NUL byte -- the same definition classic `strings` uses.
25
+ Wide (UTF-16) string literals are not decoded; documented limitation."""
26
+ pattern = re.compile(rb"[\x20-\x7e]{%d,}" % max(min_len, 1))
27
+ return [m.group().decode("ascii") for m in pattern.finditer(data)]
28
+
29
+
30
+ def strings_in_bundle(bundle: AppBundle, min_len: int = 4) -> list[ExtractedString]:
31
+ """Extract strings from every executable in the bundle, main binary
32
+ first, in inventory order thereafter."""
33
+ out: list[ExtractedString] = []
34
+ for path in bundle_executable_paths(bundle):
35
+ try:
36
+ data = path.read_bytes()
37
+ except OSError:
38
+ continue
39
+ out.extend(ExtractedString(s, path.name) for s in extract_strings(data, min_len))
40
+ return out
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Linked libraries + imported/exported symbols for a Mach-O executable --
3
+ `otool -L` + `nm`, backed by LIEF's structured symbol table instead of
4
+ shelling out and parsing text."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ import lief
12
+
13
+ from ipa_forge.machO.arch import arch_name, load_macho
14
+
15
+
16
+ @dataclass
17
+ class BinarySymbols:
18
+ binary: str
19
+ arch: str
20
+ linked_libraries: list[str] = field(default_factory=list)
21
+ imported_symbols: list[str] = field(default_factory=list)
22
+ exported_symbols: list[str] = field(default_factory=list)
23
+
24
+
25
+ def analyze_symbols(path: Path, arch: str | None = None) -> BinarySymbols:
26
+ """Fat binaries require an explicit `arch` -- same rule `forge patch`'s
27
+ binary operations follow (see `machO/arch.py::load_macho`); a thin
28
+ binary needs none."""
29
+ binary = load_macho(path, arch)
30
+ # CATEGORY.UNDEFINED: referenced here, defined in another image (an
31
+ # import). CATEGORY.EXTERNAL: defined in this image and globally
32
+ # visible (an export). CATEGORY.LOCAL: internal-only, not exported.
33
+ imported = sorted({str(s.name) for s in binary.symbols if s.category == lief.MachO.Symbol.CATEGORY.UNDEFINED})
34
+ exported = sorted({str(s.name) for s in binary.symbols if s.category == lief.MachO.Symbol.CATEGORY.EXTERNAL})
35
+ libraries = sorted({str(lib.name) for lib in binary.libraries})
36
+ return BinarySymbols(
37
+ binary=path.name,
38
+ arch=arch_name(binary),
39
+ linked_libraries=libraries,
40
+ imported_symbols=imported,
41
+ exported_symbols=exported,
42
+ )
@@ -0,0 +1,164 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Best-effort Objective-C type-encoding decoder.
3
+
4
+ Turns runtime type-encoding strings (as found on ivars, properties, and
5
+ method ``types`` fields -- see ``@encode`` in the Objective-C runtime) into
6
+ readable pseudo-header text for `class-dump`-style output. This is
7
+ deliberately not a full encoder/decoder round-trip: structs/unions render as
8
+ just their tag name (no field expansion) and bitfields fall back to the raw
9
+ encoding. Good enough to read a class-dump header; not good enough to
10
+ regenerate a compilable one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ _MODIFIERS = "rnNoORV" # const, in, inout, out, bycopy, byref, oneway
18
+
19
+ _SIMPLE = {
20
+ "c": "char",
21
+ "i": "int",
22
+ "s": "short",
23
+ "l": "long",
24
+ "q": "long long",
25
+ "C": "unsigned char",
26
+ "I": "unsigned int",
27
+ "S": "unsigned short",
28
+ "L": "unsigned long",
29
+ "Q": "unsigned long long",
30
+ "f": "float",
31
+ "d": "double",
32
+ "B": "BOOL",
33
+ "v": "void",
34
+ "*": "char *",
35
+ "#": "Class",
36
+ ":": "SEL",
37
+ "?": "void /* unknown type (block/fn ptr) */",
38
+ }
39
+
40
+ _ARRAY_RE = re.compile(r"^\[(\d+)(.*)\]$")
41
+
42
+
43
+ def read_one_type(encoding: str, i: int) -> tuple[str, int]:
44
+ """Read a single type-encoding token starting at `i`. Returns the token
45
+ text and the index just past it -- callers use this to walk a method's
46
+ full ``types`` string one argument at a time."""
47
+ start = i
48
+ while i < len(encoding) and encoding[i] in _MODIFIERS:
49
+ i += 1
50
+ if i >= len(encoding):
51
+ return encoding[start:i], i
52
+ c = encoding[i]
53
+ if c == "@":
54
+ i += 1
55
+ if i < len(encoding) and encoding[i] == '"':
56
+ end = encoding.find('"', i + 1)
57
+ i = (end + 1) if end != -1 else len(encoding)
58
+ return encoding[start:i], i
59
+ if c in "{(":
60
+ close = "}" if c == "{" else ")"
61
+ open_c = c
62
+ depth = 1
63
+ i += 1
64
+ while i < len(encoding) and depth:
65
+ if encoding[i] == open_c:
66
+ depth += 1
67
+ elif encoding[i] == close:
68
+ depth -= 1
69
+ i += 1
70
+ return encoding[start:i], i
71
+ if c == "[":
72
+ depth = 1
73
+ i += 1
74
+ while i < len(encoding) and depth:
75
+ if encoding[i] == "[":
76
+ depth += 1
77
+ elif encoding[i] == "]":
78
+ depth -= 1
79
+ i += 1
80
+ return encoding[start:i], i
81
+ if c == "^":
82
+ i += 1
83
+ _, i = read_one_type(encoding, i)
84
+ return encoding[start:i], i
85
+ return encoding[start : i + 1], i + 1
86
+
87
+
88
+ def decode_type(encoding: str) -> str:
89
+ """Render one type-encoding token as a readable C/Objective-C type."""
90
+ encoding = encoding.lstrip(_MODIFIERS)
91
+ if not encoding:
92
+ return "?"
93
+ c = encoding[0]
94
+ if c in _SIMPLE:
95
+ return _SIMPLE[c]
96
+ if c == "@":
97
+ if encoding.startswith('@"'):
98
+ end = encoding.find('"', 2)
99
+ cls = encoding[2:end] if end != -1 else ""
100
+ if cls.startswith("<") and cls.endswith(">"):
101
+ return f"id<{cls[1:-1]}>"
102
+ return f"{cls} *" if cls else "id"
103
+ return "id"
104
+ if c == "^":
105
+ inner = decode_type(encoding[1:])
106
+ return inner if inner.endswith("*") else f"{inner} *"
107
+ if c == "{":
108
+ name = encoding[1:].split("=", 1)[0].split("}", 1)[0]
109
+ return f"struct {name}" if name else "struct"
110
+ if c == "(":
111
+ name = encoding[1:].split("=", 1)[0].split(")", 1)[0]
112
+ return f"union {name}" if name else "union"
113
+ if c == "[":
114
+ m = _ARRAY_RE.match(encoding)
115
+ if m:
116
+ count, inner = m.groups()
117
+ return f"{decode_type(inner)}[{count}]"
118
+ return encoding # fallback: bitfields and anything else unrecognized
119
+
120
+
121
+ def decode_method_signature(selector: str, encoding: str) -> str:
122
+ """Render `- (returnType)part1:(argType1)arg1 part2:(argType2)arg2` from
123
+ a selector and its method ``types`` encoding. Falls back to an untyped
124
+ signature (every arg as `id`) when the encoding is missing/unparseable --
125
+ e.g. a hook target the walker found via a string cross-check rather than
126
+ a fully decoded method list."""
127
+ parts = selector.split(":")
128
+ is_multi_arg = selector.endswith(":")
129
+ arg_labels = [p for p in parts if p] if is_multi_arg else []
130
+
131
+ if not encoding:
132
+ if not is_multi_arg:
133
+ return f"(id){selector}"
134
+ return " ".join(f"{label}:(id)arg{i + 1}" for i, label in enumerate(arg_labels))
135
+
136
+ i = 0
137
+ ret_tok, i = read_one_type(encoding, i)
138
+ ret = decode_type(ret_tok)
139
+ j = i
140
+ while j < len(encoding) and (encoding[j].isdigit() or encoding[j] == "-"):
141
+ j += 1
142
+ i = j
143
+
144
+ arg_types: list[str] = []
145
+ while i < len(encoding):
146
+ tok, i = read_one_type(encoding, i)
147
+ j = i
148
+ while j < len(encoding) and (encoding[j].isdigit() or encoding[j] == "-"):
149
+ j += 1
150
+ i = j
151
+ arg_types.append(tok)
152
+
153
+ real_args = arg_types[2:] # skip self (@) and _cmd (:)
154
+ if not is_multi_arg:
155
+ return f"({ret}){selector}"
156
+ if len(real_args) != len(arg_labels):
157
+ # types string didn't decode cleanly (e.g. truncated data) -- keep the
158
+ # return type but fall back to untyped args rather than mismatching
159
+ return f"({ret})" + " ".join(f"{label}:(id)arg{i + 1}" for i, label in enumerate(arg_labels))
160
+ pieces = [
161
+ f"{label}:({decode_type(tok)})arg{i + 1}"
162
+ for i, (label, tok) in enumerate(zip(arg_labels, real_args, strict=True))
163
+ ]
164
+ return f"({ret})" + " ".join(pieces)
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later