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
@@ -0,0 +1,244 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>ipa-forge — Patch an IPA</title>
8
+ <style>
9
+ :root {
10
+ --accent: #0071e3;
11
+ --accent-hover: #0077ed;
12
+ --accent-press: #0068d6;
13
+ --bg: #f5f5f7;
14
+ --card: #ffffff;
15
+ --text: #1d1d1f;
16
+ --text-secondary: #6e6e73;
17
+ --border: rgba(0, 0, 0, 0.12);
18
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
19
+ --success: #34c759;
20
+ --danger: #ff3b30;
21
+ --success-bg: rgba(52, 199, 89, 0.12);
22
+ --danger-bg: rgba(255, 59, 48, 0.12);
23
+ --danger-border: rgba(255, 59, 48, 0.5);
24
+ }
25
+ @media (prefers-color-scheme: dark) {
26
+ :root {
27
+ --bg: #000000;
28
+ --card: #1c1c1e;
29
+ --text: #f5f5f7;
30
+ --text-secondary: #98989d;
31
+ --border: rgba(255, 255, 255, 0.16);
32
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
33
+ --accent: #0a84ff;
34
+ --accent-hover: #3a9bff;
35
+ }
36
+ }
37
+ * { box-sizing: border-box; }
38
+ body {
39
+ margin: 0;
40
+ font-family: -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif;
41
+ background: var(--bg);
42
+ color: var(--text);
43
+ display: flex;
44
+ min-height: 100vh;
45
+ align-items: center;
46
+ justify-content: center;
47
+ padding: 24px;
48
+ }
49
+ .card {
50
+ background: var(--card);
51
+ border-radius: 18px;
52
+ box-shadow: var(--shadow);
53
+ padding: 32px;
54
+ width: 100%;
55
+ max-width: 460px;
56
+ }
57
+ h1 { font-size: 22px; margin: 0 0 4px; }
58
+ .subtitle { color: var(--text-secondary); font-size: 13px; margin: 0 0 20px; }
59
+ .subtitle a { color: var(--accent); }
60
+ .drop {
61
+ border: 2px dashed var(--border);
62
+ border-radius: 12px;
63
+ padding: 28px 16px;
64
+ text-align: center;
65
+ color: var(--text-secondary);
66
+ cursor: pointer;
67
+ transition: border-color 0.2s, color 0.2s;
68
+ font-size: 14px;
69
+ }
70
+ .drop.dragover, .drop:hover { border-color: var(--accent); color: var(--text); }
71
+ .drop .file-name { color: var(--text); font-weight: 600; word-break: break-all; }
72
+ .drop input { display: none; }
73
+ .status {
74
+ margin-top: 16px;
75
+ border-radius: 10px;
76
+ padding: 12px 14px;
77
+ font-size: 13px;
78
+ display: none;
79
+ }
80
+ .status.error { display: block; background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger); }
81
+ .status.info { display: block; background: var(--success-bg); border: 1px solid rgba(52,199,89,0.5); color: var(--success); }
82
+ .status.warn { display: block; background: rgba(255,149,0,0.12); border: 1px solid rgba(255,149,0,0.5); color: #ff9500; }
83
+ .status.warn b { color: var(--text); }
84
+ .detected { display: none; margin-top: 16px; font-size: 13px; }
85
+ .detected.show { display: block; }
86
+ .detected .row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); }
87
+ .detected .row:last-child { border-bottom: none; }
88
+ .detected .k { color: var(--text-secondary); }
89
+ .detected .v { font-weight: 600; }
90
+ button {
91
+ margin-top: 18px;
92
+ width: 100%;
93
+ padding: 14px;
94
+ border: none;
95
+ border-radius: 12px;
96
+ background: var(--accent);
97
+ color: #fff;
98
+ font-size: 15px;
99
+ font-weight: 600;
100
+ cursor: pointer;
101
+ transition: background 0.2s;
102
+ }
103
+ button:hover { background: var(--accent-hover); }
104
+ button:active { background: var(--accent-press); }
105
+ button:disabled { opacity: 0.5; cursor: default; }
106
+ .footnote { margin-top: 14px; color: var(--text-secondary); font-size: 12px; text-align: center; }
107
+ .download { margin-top: 14px; text-align: center; }
108
+ .download a { color: var(--accent); font-weight: 600; text-decoration: none; }
109
+ </style>
110
+ </head>
111
+ <body>
112
+ <div class="card">
113
+ <h1>ipa-forge</h1>
114
+ <p class="subtitle">
115
+ Select an IPA to patch it with the matching patch set. Output is unsigned — ready for AltStore.
116
+ Just want to inspect an IPA? <a href="/analysis">Reverse engineering tools</a>.
117
+ </p>
118
+
119
+ <div class="drop" id="drop">
120
+ <span id="dropText">Click or drop your .ipa file here</span>
121
+ <input type="file" id="file" accept=".ipa" />
122
+ </div>
123
+
124
+ <div class="status" id="status"></div>
125
+
126
+ <div class="detected" id="detected">
127
+ <div class="row"><span class="k">App</span><span class="v" id="dApp">–</span></div>
128
+ <div class="row"><span class="k">Version</span><span class="v" id="dVersion">–</span></div>
129
+ <div class="row"><span class="k">Patch set</span><span class="v" id="dPatchSet">–</span></div>
130
+ <div class="row"><span class="k">Targets</span><span class="v" id="dTarget">–</span></div>
131
+ </div>
132
+
133
+ <button id="patchBtn" disabled>Patch</button>
134
+
135
+ <div class="download" id="download"></div>
136
+ <p class="footnote">version mismatch shows a warning but patching still runs — hook verification is the safety net.</p>
137
+ </div>
138
+
139
+ <script>
140
+ const fileInput = document.getElementById("file");
141
+ const drop = document.getElementById("drop");
142
+ const dropText = document.getElementById("dropText");
143
+ const status = document.getElementById("status");
144
+ const detected = document.getElementById("detected");
145
+ const patchBtn = document.getElementById("patchBtn");
146
+ const download = document.getElementById("download");
147
+
148
+ let currentFile = null;
149
+ let currentPatchSet = null;
150
+
151
+ function showStatus(kind, html) {
152
+ status.className = "status " + kind;
153
+ status.innerHTML = html;
154
+ }
155
+
156
+ function setDetected(app, version, patchSet, target) {
157
+ detected.classList.add("show");
158
+ document.getElementById("dApp").textContent = app || "–";
159
+ document.getElementById("dVersion").textContent = version || "–";
160
+ document.getElementById("dPatchSet").textContent = patchSet || "none found";
161
+ document.getElementById("dTarget").textContent = target || "–";
162
+ }
163
+
164
+ async function analyze(file) {
165
+ showStatus("info", "Analyzing…");
166
+ download.innerHTML = "";
167
+ const fd = new FormData();
168
+ fd.append("ipa", file);
169
+ const res = await fetch("/detect", { method: "POST", body: fd });
170
+ const data = await res.json();
171
+ if (!res.ok) {
172
+ showStatus("error", data.error || "Could not read the IPA.");
173
+ patchBtn.disabled = true;
174
+ return;
175
+ }
176
+ const ps = data.patch_sets || [];
177
+ currentPatchSet = ps[0] ? ps[0].name : null;
178
+ setDetected(data.bundle_id, data.version, currentPatchSet || "none", ps[0] ? ps[0].target_version : "–");
179
+
180
+ if (!ps.length) {
181
+ showStatus("error", "No patch set found for <b>" + (data.bundle_id || "this app") + "</b>.");
182
+ patchBtn.disabled = true;
183
+ } else if (data.mismatch) {
184
+ showStatus(
185
+ "warn",
186
+ "Heads up: this patch set targets <b>" + ps[0].target_version + "</b> but your IPA is <b>" +
187
+ data.version + "</b>. The hooks gate will still verify everything — patching is allowed."
188
+ );
189
+ patchBtn.disabled = false;
190
+ } else {
191
+ showStatus("info", "Ready — patch set <b>" + currentPatchSet + "</b> matches.");
192
+ patchBtn.disabled = false;
193
+ }
194
+ }
195
+
196
+ drop.addEventListener("click", () => fileInput.click());
197
+ drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("dragover"); });
198
+ drop.addEventListener("dragleave", () => drop.classList.remove("dragover"));
199
+ drop.addEventListener("drop", (e) => {
200
+ e.preventDefault();
201
+ drop.classList.remove("dragover");
202
+ if (e.dataTransfer.files.length) {
203
+ currentFile = e.dataTransfer.files[0];
204
+ dropText.textContent = currentFile.name;
205
+ dropText.classList.add("file-name");
206
+ analyze(currentFile);
207
+ }
208
+ });
209
+ fileInput.addEventListener("change", () => {
210
+ if (fileInput.files.length) {
211
+ currentFile = fileInput.files[0];
212
+ dropText.textContent = currentFile.name;
213
+ dropText.classList.add("file-name");
214
+ analyze(currentFile);
215
+ }
216
+ });
217
+
218
+ patchBtn.addEventListener("click", async () => {
219
+ if (!currentFile || !currentPatchSet) return;
220
+ patchBtn.disabled = true;
221
+ showStatus("info", "Patching… (a 150 MB IPA takes a few minutes)");
222
+ download.innerHTML = "";
223
+ const fd = new FormData();
224
+ fd.append("ipa", currentFile);
225
+ fd.append("patch_set", currentPatchSet);
226
+ fd.append("no_sign", "true");
227
+ const res = await fetch("/patch", { method: "POST", body: fd });
228
+ const data = await res.json();
229
+ if (!res.ok) {
230
+ showStatus("error", data.error || "Patching failed.");
231
+ patchBtn.disabled = false;
232
+ return;
233
+ }
234
+ const manifest = data.manifest || {};
235
+ const applied = (manifest.patches_applied || []).filter((p) => p.status === "applied").length;
236
+ showStatus("info", "Patched — " + applied + " operation(s) applied.");
237
+ if (data.download_url) {
238
+ download.innerHTML = '<a href="' + data.download_url + '">Download patched IPA</a>';
239
+ }
240
+ patchBtn.disabled = false;
241
+ });
242
+ </script>
243
+ </body>
244
+ </html>
File without changes
@@ -0,0 +1,81 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Scan ObjC tweak sources for runtime hook calls.
3
+
4
+ Recognizes ``<prefix>HookInstance/HookClass/AddInstanceMethod`` calls with an
5
+ inline ``NSClassFromString(@"X")`` (or a file-scoped, unambiguous
6
+ ``cls = NSClassFromString(@"X")`` assignment), plus class names passed
7
+ through a local resolver helper — any function whose body calls
8
+ ``NSClassFromString`` and that is invoked as ``resolver("X")`` at the hook
9
+ call site (e.g. ``igHookInstance(igClass("YTSettings"), ...)``).
10
+
11
+ Helper/loop-based hooks whose class is *passed into another function* before
12
+ reaching the hook call (``hookParserNil(igClass("X"))`` → ``igHookInstance(cls,
13
+ ...)`` inside ``hookParserNil``) are not traced across function boundaries —
14
+ declare those manually in the definition's ``hooks:`` block, exactly as the
15
+ docs say.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from pathlib import Path
22
+
23
+ from ipa_forge.hooks.verify import HookDecl
24
+
25
+ _PATTERN = re.compile(
26
+ r"([A-Za-z_][A-Za-z0-9_]*)?(?:Hook(Instance|Class|ConfigBool)|AddInstanceMethod)"
27
+ r"\("
28
+ r"\s*(?:NSClassFromString\(@?\"([^\"]+)\"\)|\[\s*(\w+)\s*class\]|(\w+)|(\w+)\(@?\"([^\"]+)\"\))"
29
+ r"\s*,\s*(?:@selector\(([^)]+)\)|sel_registerName\(\"([^\"]+)\"\))"
30
+ )
31
+ _VAR_RE = re.compile(r"(\w+)\s*=\s*NSClassFromString\(@?\"([^\"]+)\"\)")
32
+ # A class-resolver helper: a function returning Class whose body calls
33
+ # NSClassFromString (simple single-expression bodies). Calls like
34
+ # ``resolver("X")`` resolve X to a class name at the hook call site.
35
+ _RESOLVER_RE = re.compile(r"(?:\bstatic\s+)?Class\s+(\w+)\s*\([^)]*\)\s*\{[^{}]*NSClassFromString")
36
+ _RESOLVER_CALL_RE = re.compile(r"(\w+)\s*\(\s*@?\"([^\"]+)\"\s*\)")
37
+
38
+
39
+ def scan_hook_sources(dylib_src: Path) -> list[HookDecl]:
40
+ """Scan ``*.m`` files under ``dylib_src`` and return hook declarations."""
41
+ decls: list[HookDecl] = []
42
+ for f in sorted(dylib_src.glob("*.m")):
43
+ text = f.read_text(errors="replace")
44
+ # map NSClassFromString assignments to their variable (file-scoped),
45
+ # only when unambiguous (a var assigned two different classes is
46
+ # untrustworthy — skip it)
47
+ var_class: dict[str, str] = {}
48
+ var_classes: dict[str, set[str]] = {}
49
+ for m in _VAR_RE.finditer(text):
50
+ var_classes.setdefault(m.group(1), set()).add(m.group(2))
51
+ for var, classes in var_classes.items():
52
+ if len(classes) == 1:
53
+ var_class[var] = next(iter(classes))
54
+
55
+ # resolver helpers: functions whose body calls NSClassFromString
56
+ resolvers = {m.group(1) for m in _RESOLVER_RE.finditer(text)}
57
+ resolver_class: dict[str, str] = {}
58
+ if resolvers:
59
+ for m in _RESOLVER_CALL_RE.finditer(text):
60
+ if m.group(1) in resolvers:
61
+ resolver_class[m.group(1)] = m.group(2)
62
+
63
+ for m in _PATTERN.finditer(text):
64
+ _prefix, kind, cls, cls2, clsvar, clsfn, clsarg, sel, sel2 = m.groups()
65
+ is_add = kind is None # AddInstanceMethod branch
66
+ cls = cls or cls2 or var_class.get(clsvar or "") or (resolver_class.get(clsfn or "") if clsfn else "")
67
+ sel = sel or sel2
68
+ if not cls or not sel:
69
+ continue
70
+ hook_kind = "class" if kind == "Class" else "instance" # ConfigBool hooks the getter -> instance
71
+ decls.append(HookDecl(cls, sel, hook_kind, added=is_add)) # type: ignore[arg-type]
72
+
73
+ # de-duplicate, keep first-seen order
74
+ seen: set[tuple[str, str]] = set()
75
+ unique: list[HookDecl] = []
76
+ for d in decls:
77
+ if (d.class_name, d.selector) in seen:
78
+ continue
79
+ seen.add((d.class_name, d.selector))
80
+ unique.append(d)
81
+ return unique
@@ -0,0 +1,348 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Hook verification: classify each declared hook against a MachOAnalysis.
3
+
4
+ Statuses:
5
+
6
+ - ``ok`` — class parsed and selector found on the class or an ancestor.
7
+ - ``ok-system`` — class is a system-framework class (not in the app's
8
+ classlist) and the selector is referenced somewhere in the app binary.
9
+ - ``added`` — declared ``added: true`` (the tweak adds the method); absence
10
+ is the expected state and counts as a pass.
11
+ - ``missing-class`` — class absent from the app entirely (renamed/removed).
12
+ - ``missing-selector`` — class present but the selector is nowhere in the
13
+ binary: the hook would attach but never fire.
14
+ - ``elsewhere`` — selector exists in the binary but not on this class or its
15
+ ancestors: the hook would silently no-op on this class.
16
+
17
+ ``required`` hooks that resolve to a failure status fail the run; the rest
18
+ are reported as warnings so porting to a new version is guided, not blocked.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Literal
25
+
26
+ from ipa_forge.machO.objc import MachOAnalysis, contains_string
27
+
28
+ HookStatus = Literal[
29
+ "ok",
30
+ "ok-system",
31
+ "ok-inherited",
32
+ "added",
33
+ "missing-class",
34
+ "missing-selector",
35
+ "elsewhere",
36
+ "referenced-only",
37
+ "unverified",
38
+ ]
39
+
40
+ _SYSTEM_PREFIXES = ("NS", "UI", "CA", "AV", "MP", "CF", "CG", "SK", "_", "TUI")
41
+
42
+ # Selectors hooks commonly target that are defined on system superclasses
43
+ # (UIView/UIButton/UIViewController etc.) — not visible in the app's class
44
+ # method lists, but inherited at runtime, so the hook attaches fine.
45
+ _SYSTEM_SELECTORS = {
46
+ "init",
47
+ "dealloc",
48
+ "layoutSubviews",
49
+ "didMoveToWindow",
50
+ "viewDidAppear:",
51
+ "viewDidLoad",
52
+ "viewDidDisappear:",
53
+ "setHidden:",
54
+ "setFrame:",
55
+ "setBackgroundColor:",
56
+ "setEnabled:",
57
+ "sizeThatFits:",
58
+ "titleLabel",
59
+ "setTitle:forState:",
60
+ "addGestureRecognizer:",
61
+ "setDelegate:",
62
+ "setUserInteractionEnabled:",
63
+ "setAlpha:",
64
+ "setNeedsLayout",
65
+ "removeFromSuperview",
66
+ "setText:",
67
+ "setOn:",
68
+ "setAccessibilityIdentifier:",
69
+ "setValue:forKey:",
70
+ "didMoveToSuperview",
71
+ # UIResponder event-handling methods -- every UIView/UIViewController
72
+ # inherits a default implementation, so hooking one on an app class whose
73
+ # superclass chain ends external (UIViewController/UIView) attaches fine
74
+ # even though no app class overrides it. Found via a real false-positive
75
+ # `elsewhere` classification on IGRootViewController -[motionEnded:
76
+ # withEvent:] (a shake-gesture hook): the selector is only implemented on
77
+ # unrelated UIWindow/UIApplication subclasses in that binary, which the
78
+ # ancestry walk (rightly) doesn't associate with a UIViewController.
79
+ "motionEnded:withEvent:",
80
+ "touchesBegan:withEvent:",
81
+ "touchesMoved:withEvent:",
82
+ "touchesEnded:withEvent:",
83
+ "touchesCancelled:withEvent:",
84
+ "pressesBegan:withEvent:",
85
+ "pressesEnded:withEvent:",
86
+ "pressesCancelled:withEvent:",
87
+ "remoteControlReceivedWithEvent:",
88
+ }
89
+
90
+
91
+ @dataclass
92
+ class HookDecl:
93
+ class_name: str
94
+ selector: str
95
+ kind: Literal["instance", "class"] = "instance"
96
+ added: bool = False
97
+ required: bool = False
98
+
99
+
100
+ @dataclass
101
+ class HookResult:
102
+ class_name: str
103
+ selector: str
104
+ kind: Literal["instance", "class"]
105
+ status: HookStatus
106
+ detail: str = ""
107
+ required: bool = False
108
+
109
+ @property
110
+ def ok(self) -> bool:
111
+ return self.status in ("ok", "ok-system", "ok-inherited", "added")
112
+
113
+
114
+ def _is_system_class(name: str) -> bool:
115
+ return name.startswith(_SYSTEM_PREFIXES) or name in (
116
+ "UITableViewCell",
117
+ "UIColor",
118
+ "UIFont",
119
+ "UIWindow",
120
+ "UIView",
121
+ "UIViewController",
122
+ )
123
+
124
+
125
+ def _ancestry(analysis: MachOAnalysis, name: str) -> list[str]:
126
+ chain: list[str] = []
127
+ seen: set[str] = set()
128
+ cur: str | None = name
129
+ while cur and cur in analysis.classes and cur not in seen:
130
+ seen.add(cur)
131
+ chain.append(cur)
132
+ cur = analysis.classes[cur].super_name
133
+ if cur is None:
134
+ break
135
+ return chain
136
+
137
+
138
+ def verify_hooks(analysis: MachOAnalysis, hooks: list[HookDecl]) -> list[HookResult]:
139
+ results: list[HookResult] = []
140
+ for hook in hooks:
141
+ cls = hook.class_name
142
+ sel = hook.selector
143
+ mc = analysis.classes.get(cls)
144
+
145
+ if hook.added:
146
+ # The tweak ADDS the method. If the binary references the selector
147
+ # (selrefs) but no class implements it, the app is calling into a
148
+ # hole that this add fills — exactly the intended state.
149
+ if sel in analysis.selectors:
150
+ results.append(
151
+ HookResult(
152
+ cls, sel, hook.kind, "ok", "app references this selector; the tweak provides it", hook.required
153
+ )
154
+ )
155
+ else:
156
+ results.append(
157
+ HookResult(
158
+ cls, sel, hook.kind, "added", "method added by the tweak; absence expected", hook.required
159
+ )
160
+ )
161
+ continue
162
+
163
+ if mc is None:
164
+ if cls in analysis.classnames:
165
+ results.append(
166
+ HookResult(
167
+ cls,
168
+ sel,
169
+ hook.kind,
170
+ "unverified",
171
+ "class exists (classname section) but the walk missed it (likely attaches)",
172
+ hook.required,
173
+ )
174
+ )
175
+ elif cls.startswith("_Tt"):
176
+ # Swift-mangled class absent from the parsed table. Not a
177
+ # system class — labelling it ok-system would hide drift. The
178
+ # hook may well attach (the walk misses Swift classes too), so
179
+ # report honestly instead.
180
+ results.append(
181
+ HookResult(
182
+ cls,
183
+ sel,
184
+ hook.kind,
185
+ "unverified",
186
+ "Swift class not in the parsed class table (mangled name; the walk missed it)",
187
+ hook.required,
188
+ )
189
+ )
190
+ elif _is_system_class(cls):
191
+ if sel in analysis.selectors or sel in _SYSTEM_SELECTORS:
192
+ results.append(
193
+ HookResult(
194
+ cls,
195
+ sel,
196
+ hook.kind,
197
+ "ok-system",
198
+ "system class; selector referenced by the app or a known system API",
199
+ hook.required,
200
+ )
201
+ )
202
+ else:
203
+ results.append(
204
+ HookResult(
205
+ cls,
206
+ sel,
207
+ hook.kind,
208
+ "unverified",
209
+ "system class; selector not referenced by the app binary",
210
+ hook.required,
211
+ )
212
+ )
213
+ else:
214
+ if contains_string(analysis, cls):
215
+ # The name exists as a standalone cstring in some image but
216
+ # the class-table walk missed it — a parser gap, not a
217
+ # rename. Soft-fail instead of calling it missing.
218
+ results.append(
219
+ HookResult(
220
+ cls,
221
+ sel,
222
+ hook.kind,
223
+ "unverified",
224
+ "class-name string present in a binary image but the class-table walk missed "
225
+ "it (likely attaches)",
226
+ hook.required,
227
+ )
228
+ )
229
+ else:
230
+ results.append(
231
+ HookResult(cls, sel, hook.kind, "missing-class", "class not found in the binary", hook.required)
232
+ )
233
+ continue
234
+
235
+ # class present — walk the ancestry for the selector
236
+ chain = _ancestry(analysis, cls)
237
+ pool = "cls" if hook.kind == "class" else "inst"
238
+ found = False
239
+ found_at: str | None = None
240
+ for ancestor in chain:
241
+ methods = getattr(analysis.classes[ancestor], pool)
242
+ if sel in methods:
243
+ found = True
244
+ found_at = ancestor
245
+ break
246
+ if found:
247
+ if found_at == cls:
248
+ results.append(HookResult(cls, sel, hook.kind, "ok", f"on {cls}", hook.required))
249
+ else:
250
+ results.append(
251
+ HookResult(cls, sel, hook.kind, "ok-inherited", f"inherited from {found_at}", hook.required)
252
+ )
253
+ continue
254
+
255
+ # Not in the parsed hierarchy. Two soft cases before we call it broken:
256
+ # (a) a system superclass method (UIView etc.) — attaches via inheritance;
257
+ # (b) the selector exists in the binary but the parser missed the method
258
+ # list entry (chained-fixup decode gaps) — likely on this class.
259
+ chain_classes = [analysis.classes[c] for c in chain]
260
+ ends_in_system = any(
261
+ (c.super_name == "«external»" or (c.super_name or "").startswith(("NS", "UI"))) for c in chain_classes
262
+ )
263
+ if sel in _SYSTEM_SELECTORS and ends_in_system:
264
+ results.append(
265
+ HookResult(
266
+ cls,
267
+ sel,
268
+ hook.kind,
269
+ "ok-inherited",
270
+ "system superclass method (attaches via inheritance)",
271
+ hook.required,
272
+ )
273
+ )
274
+ continue
275
+ if sel in analysis.selectors:
276
+ pool_of = "cls" if hook.kind == "class" else "inst"
277
+ parsed_anywhere = any(sel in getattr(c, pool_of) for c in analysis.classes.values())
278
+ if parsed_anywhere:
279
+ results.append(
280
+ HookResult(
281
+ cls,
282
+ sel,
283
+ hook.kind,
284
+ "elsewhere",
285
+ "selector exists in the binary but not on this class/ancestors (hook would no-op)",
286
+ hook.required,
287
+ )
288
+ )
289
+ elif sel in analysis.methnames:
290
+ # Declared as a method name somewhere (a protocol, category or a
291
+ # method list the walker did not decode) — the hook may well
292
+ # attach; keep it soft.
293
+ results.append(
294
+ HookResult(
295
+ cls,
296
+ sel,
297
+ hook.kind,
298
+ "unverified",
299
+ "selector declared as a method somewhere (protocol/category/undecoded list) "
300
+ "but not in any parsed class method list (likely attaches)",
301
+ hook.required,
302
+ )
303
+ )
304
+ else:
305
+ # Referenced (NSSelectorFromString/performSelector/optional
306
+ # protocol sends) but declared as NO method anywhere in the
307
+ # binary — no IMP exists to swizzle, so class_getInstanceMethod
308
+ # returns NULL and the hook cannot attach.
309
+ results.append(
310
+ HookResult(
311
+ cls,
312
+ sel,
313
+ hook.kind,
314
+ "referenced-only",
315
+ "selector referenced by the binary but not declared as a method anywhere "
316
+ "(no IMP to swizzle; the hook cannot attach unless a system superclass provides it)",
317
+ hook.required,
318
+ )
319
+ )
320
+ else:
321
+ if contains_string(analysis, sel):
322
+ results.append(
323
+ HookResult(
324
+ cls,
325
+ sel,
326
+ hook.kind,
327
+ "unverified",
328
+ "selector string present in a binary image but not in any parsed method list "
329
+ "(parser may have missed it)",
330
+ hook.required,
331
+ )
332
+ )
333
+ else:
334
+ results.append(
335
+ HookResult(
336
+ cls,
337
+ sel,
338
+ hook.kind,
339
+ "missing-selector",
340
+ "selector not found anywhere in the binary",
341
+ hook.required,
342
+ )
343
+ )
344
+ return results
345
+
346
+
347
+ def failing(results: list[HookResult]) -> list[HookResult]:
348
+ return [r for r in results if not r.ok]
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later