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,534 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Mach-O Objective-C runtime analysis: classes, protocols, categories,
3
+ ivars, properties, method lists, and selectors.
4
+
5
+ Parses the ``__objc_classlist``/``__objc_protolist``/``__objc_catlist``/
6
+ ``__objc_classname``/``__objc_selrefs`` sections of an arm64 Mach-O
7
+ (chained-fixup aware). This is the shared ground truth for two consumers:
8
+ ``ipa_forge.hooks`` (does this hook target exist and attach?) and
9
+ ``ipa_forge.analysis`` (class-dump/strings/diff — general reverse
10
+ engineering of an IPA).
11
+
12
+ Fat binaries are thinned to the arm64 slice first (lipo); section file
13
+ offsets are then correct for direct data reads.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import struct
19
+ import subprocess
20
+ import tempfile
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from ipa_forge.bundle.models import AppBundle
25
+ from ipa_forge.machO.detect import bundle_executable_paths
26
+
27
+
28
+ @dataclass
29
+ class MachOIvar:
30
+ name: str
31
+ type_encoding: str # raw Objective-C type-encoding string, e.g. '@"NSString"', 'i', 'q'
32
+
33
+
34
+ @dataclass
35
+ class MachOProperty:
36
+ name: str
37
+ attributes: str # raw property attribute string, e.g. 'T@"NSString",C,N'
38
+
39
+
40
+ @dataclass
41
+ class MachOProtocol:
42
+ name: str
43
+ protocols: set[str] = field(default_factory=set) # inherited protocols
44
+ inst: dict[str, str] = field(default_factory=dict) # required instance methods -> type encoding
45
+ cls: dict[str, str] = field(default_factory=dict) # required class methods -> type encoding
46
+ opt_inst: dict[str, str] = field(default_factory=dict) # @optional instance methods -> type encoding
47
+ opt_cls: dict[str, str] = field(default_factory=dict) # @optional class methods -> type encoding
48
+
49
+
50
+ @dataclass
51
+ class MachOCategory:
52
+ name: str
53
+ class_name: str # class being extended; "«external»" for a system-class
54
+ # category (Foundation/UIKit); "" only if the pointer itself is absent
55
+ inst: dict[str, str] = field(default_factory=dict) # instance methods -> type encoding
56
+ cls: dict[str, str] = field(default_factory=dict) # class (metaclass) methods -> type encoding
57
+ protocols: set[str] = field(default_factory=set)
58
+
59
+
60
+ @dataclass
61
+ class MachOClass:
62
+ name: str
63
+ super_name: str | None = None # None = root; "«external»" = defined in another image
64
+ inst: dict[str, str] = field(default_factory=dict) # instance methods -> type encoding
65
+ cls: dict[str, str] = field(default_factory=dict) # class (metaclass) methods -> type encoding
66
+ protocols: set[str] = field(default_factory=set)
67
+ ivars: list[MachOIvar] = field(default_factory=list)
68
+ properties: list[MachOProperty] = field(default_factory=list)
69
+
70
+
71
+ @dataclass
72
+ class MachOAnalysis:
73
+ classes: dict[str, MachOClass]
74
+ classnames: set[str] # every class-name string defined in the image
75
+ selectors: set[str] # every selector the image references (selrefs + method lists)
76
+ main_executable: Path | None
77
+ methnames: set[str] = field(default_factory=set) # every selector DECLARED as a method name (__objc_methname)
78
+ protocols: dict[str, MachOProtocol] = field(default_factory=dict) # __objc_protolist, by name
79
+ categories: list[MachOCategory] = field(default_factory=list) # __objc_catlist
80
+ # Raw bytes of every analyzed binary, kept so the verifier can cross-check
81
+ # a missing class/selector against the actual string table (a class name or
82
+ # selector present as a cstring but absent from the parsed method lists is
83
+ # a parser gap, not a rename — see verify.py). Short-lived per-command
84
+ # data; held in memory only for the duration of the analysis.
85
+ raw_data: list[bytes] = field(default_factory=list)
86
+
87
+
88
+ def _run(*args: str) -> str:
89
+ return subprocess.run(args, capture_output=True, text=True, check=True).stdout
90
+
91
+
92
+ def analyze_macho(path: Path) -> MachOAnalysis:
93
+ """Analyze a Mach-O executable (thin or fat; fat gets thinned to arm64)."""
94
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_hooks_") as tmp:
95
+ info = _run("lipo", "-info", str(path))
96
+ bin_path = path
97
+ if "are:" in info and "arm64" in info:
98
+ bin_path = Path(tmp) / f"{path.name}.arm64"
99
+ _run("lipo", "-thin", "arm64", str(path), "-output", str(bin_path))
100
+ return _analyze_thin(bin_path, original=path)
101
+
102
+
103
+ def analyze_bundle(bundle: AppBundle) -> MachOAnalysis:
104
+ """Analyze every executable in an AppBundle (main binary + embedded
105
+ frameworks/dylibs) and merge the results. Hook targets routinely live in
106
+ frameworks (Spotify's SPTDataLoaderService is in SpotifyShared.framework),
107
+ so verification must cover all of them, not just the main executable."""
108
+ paths = bundle_executable_paths(bundle, kinds={"framework"})
109
+
110
+ merged: MachOAnalysis | None = None
111
+ for binary_path in paths:
112
+ try:
113
+ analysis = analyze_macho(binary_path)
114
+ except (ValueError, OSError, subprocess.CalledProcessError):
115
+ continue # not a Mach-O or unreadable; skip
116
+ if merged is None:
117
+ merged = analysis
118
+ else:
119
+ merged.classes.update(analysis.classes)
120
+ merged.classnames |= analysis.classnames
121
+ merged.selectors |= analysis.selectors
122
+ merged.methnames |= analysis.methnames
123
+ merged.protocols.update(analysis.protocols)
124
+ merged.categories.extend(analysis.categories)
125
+ merged.raw_data.extend(analysis.raw_data)
126
+ if merged is None:
127
+ raise ValueError("no analyzable Mach-O found in the app bundle")
128
+ return merged
129
+
130
+
131
+ def _analyze_thin(bin_path: Path, original: Path) -> MachOAnalysis:
132
+ otool = _run("otool", "-l", str(bin_path))
133
+
134
+ sections: dict[str, tuple[int, int, int]] = {}
135
+ cur: str | None = None
136
+ addr = size = off = 0
137
+ seg_bases: list[int] = []
138
+ for line in otool.splitlines():
139
+ s = line.strip()
140
+ if s.startswith("vmaddr 0x") and "segment" not in s:
141
+ seg_bases.append(int(s.split()[1], 16))
142
+ if s.startswith("sectname "):
143
+ cur = s.split()[1]
144
+ elif s.startswith("addr 0x") and cur:
145
+ addr = int(s.split()[1], 16)
146
+ elif s.startswith("size 0x") and cur:
147
+ size = int(s.split()[1], 16)
148
+ elif s.startswith("offset ") and cur:
149
+ off = int(s.split()[1], 10)
150
+ sections[cur] = (addr, size, off)
151
+ cur = None
152
+ if not seg_bases:
153
+ raise ValueError("no __TEXT segment found (not a Mach-O?)")
154
+ ib = min(b for b in seg_bases if b)
155
+
156
+ data = bin_path.read_bytes()
157
+
158
+ def vm2off(a: int) -> int | None:
159
+ for _n, (va, sz, fo) in sections.items():
160
+ if va <= a < va + sz:
161
+ return fo + (a - va)
162
+ return None
163
+
164
+ def rd64(a: int) -> int | None:
165
+ o = vm2off(a)
166
+ if o is None or o + 8 > len(data):
167
+ return None
168
+ return struct.unpack_from("<Q", data, o)[0]
169
+
170
+ def cstr(a: int) -> str | None:
171
+ o = vm2off(a)
172
+ if o is None:
173
+ return None
174
+ e = data.find(b"\x00", o)
175
+ if e <= o or e - o > 4000:
176
+ return None
177
+ try:
178
+ return data[o:e].decode("utf-8")
179
+ except (UnicodeDecodeError, ValueError):
180
+ return None
181
+
182
+ def chained(raw: int | None) -> tuple[str, int | None]:
183
+ if raw is None:
184
+ return "none", None
185
+ if raw >> 32 == 0:
186
+ return "plain", raw
187
+ if raw & (1 << 63):
188
+ return "bind", None
189
+ return "rebase", raw & 0xFFFFFFFFF
190
+
191
+ def resolve_ptr(raw: int | None) -> int | None:
192
+ """Resolve a (possibly chained) pointer to a vm address.
193
+
194
+ Three addressing modes occur in the wild:
195
+ - PIE executables: chained rebase targets (and plain small pointers)
196
+ are offsets from the image base (ib) -- add ib.
197
+ - Mergeable/zero-based dylibs: targets are already absolute vms.
198
+ - Plain absolute pointers: already complete.
199
+ Try absolute first (cheap section lookup), then offset-from-base.
200
+ """
201
+ kind, val = chained(raw)
202
+ if kind == "plain":
203
+ val = raw
204
+ if val is None:
205
+ return None
206
+ if vm2off(val) is not None:
207
+ return val
208
+ return val + ib
209
+
210
+ classnames: set[str] = set()
211
+ if "__objc_classname" in sections:
212
+ _a, sz, o = sections["__objc_classname"]
213
+ classnames.update(s.decode("utf-8", "replace") for s in data[o : o + sz].split(b"\x00") if s)
214
+
215
+ # ---- pass 1: class table (name, superclass, ro ptr, metaclass ptr) ----
216
+ ro_by_name: dict[str, int] = {}
217
+ meta_by_name: dict[str, int] = {}
218
+
219
+ def class_ptr(raw: int) -> int | None:
220
+ return resolve_ptr(raw)
221
+
222
+ if "__objc_classlist" in sections:
223
+ _a, sz, o = sections["__objc_classlist"]
224
+ for i in range(sz // 8):
225
+ raw = struct.unpack_from("<Q", data, o + i * 8)[0]
226
+ p = class_ptr(raw)
227
+ if p is None:
228
+ continue
229
+ dp = resolve_ptr(rd64(p + 32))
230
+ if not dp:
231
+ continue
232
+ np = resolve_ptr(rd64(dp + 24))
233
+ if not np:
234
+ continue
235
+ name = cstr(np)
236
+ if not name:
237
+ continue
238
+ ro_by_name[name] = dp
239
+ ip_ = resolve_ptr(rd64(p)) # isa -> metaclass
240
+ if ip_:
241
+ meta_by_name[name] = ip_
242
+
243
+ classes: dict[str, MachOClass] = {n: MachOClass(name=n) for n in ro_by_name}
244
+
245
+ if "__objc_classlist" in sections:
246
+ _a, sz, o = sections["__objc_classlist"]
247
+ for i in range(sz // 8):
248
+ raw = struct.unpack_from("<Q", data, o + i * 8)[0]
249
+ p = class_ptr(raw)
250
+ if p is None:
251
+ continue
252
+ dp = resolve_ptr(rd64(p + 32))
253
+ if not dp:
254
+ continue
255
+ np = resolve_ptr(rd64(dp + 24))
256
+ if not np:
257
+ continue
258
+ name = cstr(np)
259
+ if not name or name not in classes:
260
+ continue
261
+ sp = resolve_ptr(rd64(p + 8))
262
+ super_name: str | None = None
263
+ if sp:
264
+ sdp = resolve_ptr(rd64(sp + 32))
265
+ if sdp:
266
+ snp = resolve_ptr(rd64(sdp + 24))
267
+ if snp:
268
+ super_name = cstr(snp)
269
+ if super_name is None:
270
+ # Superclass pointer exists but the name could not be
271
+ # resolved (external bind, chained-fixup variant we do not
272
+ # decode) — the class still inherits from SOMETHING, so
273
+ # mark it external rather than pretending it is a root.
274
+ super_name = "«external»"
275
+ else:
276
+ # a NULL/absent superclass pointer means it binds to an external
277
+ # superclass (defined in another image)
278
+ super_name = "«external»"
279
+ classes[name].super_name = super_name
280
+
281
+ # ---- pass 2: method lists ----
282
+ # Returns {selector: type_encoding} rather than a bare set of selectors --
283
+ # `sel in methods` still works unchanged (dict key containment), but the
284
+ # type encoding lets class-dump reconstruct full method signatures
285
+ # instead of bare selector names.
286
+ def parse_methods(list_raw: int | None) -> dict[str, str]:
287
+ if not list_raw:
288
+ return {}
289
+ lp = resolve_ptr(list_raw)
290
+ if not lp:
291
+ return {}
292
+ eo = vm2off(lp)
293
+ if eo is None or eo + 8 > len(data):
294
+ return {}
295
+ eaf = struct.unpack_from("<I", data, eo)[0]
296
+ entsize = eaf & 0xFFFF or eaf & 0x3FFFFF
297
+ rel = bool(eaf & 0x80000000)
298
+ count = struct.unpack_from("<I", data, eo + 4)[0]
299
+ if count > 5_000_000:
300
+ return {}
301
+ methods: dict[str, str] = {}
302
+ for i in range(count):
303
+ e = lp + 8 + i * entsize
304
+ eo_i = vm2off(e)
305
+ if eo_i is None or eo_i + 12 > len(data):
306
+ break
307
+ if rel and entsize == 12:
308
+ # name/types are self-relative: a signed 32-bit offset from
309
+ # the FIELD's own address. `name` additionally indirects
310
+ # through a selector-reference slot (uniquing); `types`
311
+ # points straight at the encoding cstring (no uniquing table
312
+ # exists for them), no second resolve_ptr needed.
313
+ name_off = struct.unpack_from("<i", data, eo_i)[0]
314
+ sel_ptr = resolve_ptr(rd64(e + name_off))
315
+ sel = cstr(sel_ptr) if sel_ptr else None
316
+ types_off = struct.unpack_from("<i", data, eo_i + 4)[0]
317
+ types = cstr(e + 4 + types_off)
318
+ elif not rel:
319
+ sel_ptr = resolve_ptr(rd64(e))
320
+ sel = cstr(sel_ptr) if sel_ptr else None
321
+ types_ptr = resolve_ptr(rd64(e + 8))
322
+ types = cstr(types_ptr) if types_ptr else None
323
+ else:
324
+ continue
325
+ if sel:
326
+ methods[sel] = types or ""
327
+ return methods
328
+
329
+ # ---- pass 2b: protocol/ivar/property lists shared by classes, protocols,
330
+ # and categories (protocol_list_t / ivar_list_t / property_list_t) ----
331
+ def parse_ptr_list(list_raw: int | None) -> list[int]:
332
+ """protocol_list_t: a size_t count followed by `count` pointers."""
333
+ if not list_raw:
334
+ return []
335
+ lp = resolve_ptr(list_raw)
336
+ if not lp:
337
+ return []
338
+ co = vm2off(lp)
339
+ if co is None or co + 8 > len(data):
340
+ return []
341
+ count = struct.unpack_from("<Q", data, co)[0]
342
+ if count > 100_000:
343
+ return []
344
+ ptrs: list[int] = []
345
+ for i in range(count):
346
+ raw = rd64(lp + 8 + i * 8)
347
+ p = resolve_ptr(raw) if raw else None
348
+ if p:
349
+ ptrs.append(p)
350
+ return ptrs
351
+
352
+ def protocol_name(proto_ptr: int) -> str | None:
353
+ np = resolve_ptr(rd64(proto_ptr + 8)) # protocol_t.name
354
+ return cstr(np) if np else None
355
+
356
+ def parse_protocol_names(list_raw: int | None) -> set[str]:
357
+ return {n for p in parse_ptr_list(list_raw) if (n := protocol_name(p))}
358
+
359
+ def parse_ivars(list_raw: int | None) -> list[MachOIvar]:
360
+ if not list_raw:
361
+ return []
362
+ lp = resolve_ptr(list_raw)
363
+ if not lp:
364
+ return []
365
+ ho = vm2off(lp)
366
+ if ho is None or ho + 8 > len(data):
367
+ return []
368
+ entsize = struct.unpack_from("<I", data, ho)[0]
369
+ count = struct.unpack_from("<I", data, ho + 4)[0]
370
+ if entsize == 0 or count > 100_000:
371
+ return []
372
+ ivars: list[MachOIvar] = []
373
+ for i in range(count):
374
+ eo = vm2off(lp + 8 + i * entsize)
375
+ if eo is None or eo + 24 > len(data):
376
+ break
377
+ name_ptr = resolve_ptr(rd64(lp + 8 + i * entsize + 8)) # ivar_t.name
378
+ type_ptr = resolve_ptr(rd64(lp + 8 + i * entsize + 16)) # ivar_t.type
379
+ name = cstr(name_ptr) if name_ptr else None
380
+ if name:
381
+ ivars.append(MachOIvar(name, (cstr(type_ptr) if type_ptr else None) or ""))
382
+ return ivars
383
+
384
+ def parse_properties(list_raw: int | None) -> list[MachOProperty]:
385
+ if not list_raw:
386
+ return []
387
+ lp = resolve_ptr(list_raw)
388
+ if not lp:
389
+ return []
390
+ ho = vm2off(lp)
391
+ if ho is None or ho + 8 > len(data):
392
+ return []
393
+ entsize = struct.unpack_from("<I", data, ho)[0]
394
+ count = struct.unpack_from("<I", data, ho + 4)[0]
395
+ if entsize == 0 or count > 100_000:
396
+ return []
397
+ props: list[MachOProperty] = []
398
+ for i in range(count):
399
+ eo = vm2off(lp + 8 + i * entsize)
400
+ if eo is None or eo + 16 > len(data):
401
+ break
402
+ name_ptr = resolve_ptr(rd64(lp + 8 + i * entsize)) # property_t.name
403
+ attr_ptr = resolve_ptr(rd64(lp + 8 + i * entsize + 8)) # property_t.attributes
404
+ name = cstr(name_ptr) if name_ptr else None
405
+ if name:
406
+ props.append(MachOProperty(name, (cstr(attr_ptr) if attr_ptr else None) or ""))
407
+ return props
408
+
409
+ def class_name_from_ptr(raw: int | None) -> str | None:
410
+ """Resolve a class_t* (isa/superclass/cache/vtable/data) to its name,
411
+ for category_t.cls -- the same chain pass 1 walks for the classlist.
412
+ Returns "«external»" (not None) when the pointer is a bind to a class
413
+ defined in another image -- routine for a category on a system class
414
+ (NSObject, UIView, ...) and distinct from a genuinely absent pointer."""
415
+ if not raw:
416
+ return None
417
+ p = resolve_ptr(raw)
418
+ if not p:
419
+ return "«external»"
420
+ dp = resolve_ptr(rd64(p + 32))
421
+ if not dp:
422
+ return "«external»"
423
+ np = resolve_ptr(rd64(dp + 24))
424
+ name = cstr(np) if np else None
425
+ return name or "«external»"
426
+
427
+ for name, ro in ro_by_name.items():
428
+ cls = classes[name]
429
+ cls.inst = parse_methods(rd64(ro + 32)) # ro.baseMethods
430
+ cls.protocols = parse_protocol_names(rd64(ro + 40)) # ro.baseProtocols
431
+ cls.ivars = parse_ivars(rd64(ro + 48)) # ro.ivars
432
+ cls.properties = parse_properties(rd64(ro + 64)) # ro.baseProperties
433
+ meta = meta_by_name.get(name)
434
+ if meta:
435
+ mdp = resolve_ptr(rd64(meta + 32))
436
+ if mdp:
437
+ cls.cls = parse_methods(rd64(mdp + 32)) # metaclass ro.baseMethods
438
+
439
+ # ---- pass 3: selrefs (every selector the image references) ----
440
+ selectors: set[str] = set()
441
+ if "__objc_selrefs" in sections:
442
+ _a, sz, o = sections["__objc_selrefs"]
443
+ for i in range(sz // 8):
444
+ raw = struct.unpack_from("<Q", data, o + i * 8)[0]
445
+ sp = resolve_ptr(raw)
446
+ if sp:
447
+ sel_str = cstr(sp)
448
+ if sel_str:
449
+ selectors.add(sel_str)
450
+ for cls in classes.values():
451
+ selectors |= set(cls.inst) | set(cls.cls)
452
+
453
+ # ---- pass 4: methname (every selector DECLARED as a method name) ----
454
+ # Distinct from selrefs: a selector can be *referenced* (NSSelectorFromString,
455
+ # performSelector, optional protocol sends) without being declared as a
456
+ # method anywhere. A hook on such a selector cannot attach (no IMP exists to
457
+ # swizzle) — the verifier uses methnames to distinguish that dead case from
458
+ # a parser gap on a real method.
459
+ methnames: set[str] = set()
460
+ if "__objc_methname" in sections:
461
+ _a, sz, o = sections["__objc_methname"]
462
+ methnames.update(s.decode("utf-8", "replace") for s in data[o : o + sz].split(b"\x00") if s)
463
+
464
+ # ---- pass 5: protocol table (__objc_protolist) ----
465
+ # protocol_t: isa@0, name@8, protocols(protocol_list_t*)@16,
466
+ # instanceMethods@24, classMethods@32, optionalInstanceMethods@40,
467
+ # optionalClassMethods@48.
468
+ protocols: dict[str, MachOProtocol] = {}
469
+ if "__objc_protolist" in sections:
470
+ _a, sz, o = sections["__objc_protolist"]
471
+ for i in range(sz // 8):
472
+ raw = struct.unpack_from("<Q", data, o + i * 8)[0]
473
+ pp = resolve_ptr(raw)
474
+ if not pp:
475
+ continue
476
+ name = protocol_name(pp)
477
+ if not name:
478
+ continue
479
+ protocols[name] = MachOProtocol(
480
+ name=name,
481
+ protocols=parse_protocol_names(rd64(pp + 16)),
482
+ inst=parse_methods(rd64(pp + 24)),
483
+ cls=parse_methods(rd64(pp + 32)),
484
+ opt_inst=parse_methods(rd64(pp + 40)),
485
+ opt_cls=parse_methods(rd64(pp + 48)),
486
+ )
487
+
488
+ # ---- pass 6: categories (__objc_catlist) ----
489
+ # category_t: name@0, cls(class_t*)@8, instanceMethods@16, classMethods@24,
490
+ # protocols(protocol_list_t*)@32, instanceProperties@40.
491
+ categories: list[MachOCategory] = []
492
+ if "__objc_catlist" in sections:
493
+ _a, sz, o = sections["__objc_catlist"]
494
+ for i in range(sz // 8):
495
+ raw = struct.unpack_from("<Q", data, o + i * 8)[0]
496
+ cp = resolve_ptr(raw)
497
+ if not cp:
498
+ continue
499
+ name_ptr = resolve_ptr(rd64(cp))
500
+ cat_name = cstr(name_ptr) if name_ptr else None
501
+ if not cat_name:
502
+ continue
503
+ categories.append(
504
+ MachOCategory(
505
+ name=cat_name,
506
+ class_name=class_name_from_ptr(rd64(cp + 8)) or "",
507
+ inst=parse_methods(rd64(cp + 16)),
508
+ cls=parse_methods(rd64(cp + 24)),
509
+ protocols=parse_protocol_names(rd64(cp + 32)),
510
+ )
511
+ )
512
+
513
+ return MachOAnalysis(
514
+ classes=classes,
515
+ classnames=classnames,
516
+ selectors=selectors,
517
+ methnames=methnames,
518
+ protocols=protocols,
519
+ categories=categories,
520
+ main_executable=original,
521
+ raw_data=[data],
522
+ )
523
+
524
+
525
+ def contains_string(analysis: MachOAnalysis, s: str) -> bool:
526
+ """True when ``s`` appears as a standalone cstring (\0-delimited) in any
527
+ analyzed image — i.e. the string is *present* in the binary even if the
528
+ class-table/method-list walk missed it. The \0-preceded, \0-terminated
529
+ pattern matches how string tables lay consecutive cstrings out, so a match
530
+ means the string exists on its own rather than inside a longer name."""
531
+ if not s:
532
+ return False
533
+ target = b"\x00" + s.encode("utf-8") + b"\x00"
534
+ return any(target in data for data in analysis.raw_data)
ipa_forge/manifest.py ADDED
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Structured manifest emitted after patch application, before signing --
3
+ makes debugging a failed install possible independent of signing outcome.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from dataclasses import asdict, dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ipa_forge.patch.base import PatchResult
15
+
16
+
17
+ def sha256_of(path: Path) -> str:
18
+ h = hashlib.sha256()
19
+ try:
20
+ with open(path, "rb") as f:
21
+ for chunk in iter(lambda: f.read(1 << 20), b""):
22
+ h.update(chunk)
23
+ except OSError as e:
24
+ raise OSError(f"cannot hash '{path}': {e}") from e
25
+ return h.hexdigest()
26
+
27
+
28
+ @dataclass
29
+ class ProfileManifestEntry:
30
+ uuid: str
31
+ team_id: str
32
+ expiration: str
33
+
34
+
35
+ @dataclass
36
+ class Manifest:
37
+ input_sha256: str
38
+ bundle_id: str
39
+ version: str
40
+ build: str
41
+ patches_applied: list[dict[str, Any]] = field(default_factory=list)
42
+ files_added: list[str] = field(default_factory=list)
43
+ files_modified: list[str] = field(default_factory=list)
44
+ files_removed: list[str] = field(default_factory=list)
45
+ macho_modified: list[str] = field(default_factory=list)
46
+ hook_report: list[dict[str, Any]] = field(default_factory=list)
47
+ profile: ProfileManifestEntry | None = None
48
+ output_sha256: str | None = None
49
+
50
+ @classmethod
51
+ def from_patch_results(
52
+ cls,
53
+ input_ipa: Path,
54
+ bundle_id: str,
55
+ version: str,
56
+ build: str,
57
+ results: list[PatchResult],
58
+ ) -> Manifest:
59
+ manifest = cls(input_sha256=sha256_of(input_ipa), bundle_id=bundle_id, version=version, build=build)
60
+ for result in results:
61
+ manifest.patches_applied.append({"id": result.op_id, "status": result.status, "message": result.message})
62
+ touched = [str(p) for p in result.files_touched]
63
+ if result.category == "added":
64
+ manifest.files_added.extend(touched)
65
+ elif result.category == "removed":
66
+ manifest.files_removed.extend(touched)
67
+ elif result.category == "modified":
68
+ manifest.files_modified.extend(touched)
69
+ if result.macho_modified:
70
+ manifest.macho_modified.extend(touched)
71
+ return manifest
72
+
73
+ def to_json(self) -> str:
74
+ return json.dumps(asdict(self), indent=2, default=str)
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,48 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Shared patch-operation contract. The core engine only knows this interface --
3
+ never a specific app, bundle id, or byte pattern."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Literal, Protocol, runtime_checkable
10
+
11
+ from ipa_forge.bundle.models import AppBundle
12
+
13
+ PatchStatus = Literal["applied", "skipped", "failed", "dry_run_ok"]
14
+
15
+
16
+ @dataclass
17
+ class PatchContext:
18
+ bundle: AppBundle
19
+ patch_source_dir: Path
20
+ """Directory that a patch definition's `source:`/`dylib:` fields resolve relative to."""
21
+
22
+
23
+ FileCategory = Literal["added", "modified", "removed"]
24
+
25
+
26
+ @dataclass
27
+ class PatchResult:
28
+ op_id: str
29
+ status: PatchStatus
30
+ message: str = ""
31
+ files_touched: list[Path] = field(default_factory=list)
32
+ macho_modified: bool = False
33
+ category: FileCategory | None = None
34
+ """How files_touched changed, for the manifest's added/modified/removed breakdown."""
35
+
36
+
37
+ @runtime_checkable
38
+ class PatchOperation(Protocol):
39
+ op_id: str
40
+
41
+ def dry_run(self, ctx: PatchContext) -> PatchResult:
42
+ """Validate the operation can be applied without mutating anything."""
43
+ ...
44
+
45
+ def apply(self, ctx: PatchContext) -> PatchResult:
46
+ """Perform the mutation. Callers must have already run dry_run and
47
+ confirmed dry_run_ok across all operations before calling this."""
48
+ ...