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,229 @@
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 — Reverse Engineering</title>
8
+ <style>
9
+ :root {
10
+ --accent: #0071e3;
11
+ --accent-hover: #0077ed;
12
+ --bg: #f5f5f7;
13
+ --card: #ffffff;
14
+ --text: #1d1d1f;
15
+ --text-secondary: #6e6e73;
16
+ --border: rgba(0, 0, 0, 0.12);
17
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
18
+ --danger: #ff3b30;
19
+ --danger-bg: rgba(255, 59, 48, 0.12);
20
+ --mono-bg: #f5f5f7;
21
+ }
22
+ @media (prefers-color-scheme: dark) {
23
+ :root {
24
+ --bg: #000000;
25
+ --card: #1c1c1e;
26
+ --text: #f5f5f7;
27
+ --text-secondary: #98989d;
28
+ --border: rgba(255, 255, 255, 0.16);
29
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
30
+ --accent: #0a84ff;
31
+ --accent-hover: #3a9bff;
32
+ --mono-bg: #000000;
33
+ }
34
+ }
35
+ * { box-sizing: border-box; }
36
+ body {
37
+ margin: 0;
38
+ font-family: -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif;
39
+ background: var(--bg);
40
+ color: var(--text);
41
+ padding: 24px;
42
+ }
43
+ .card {
44
+ background: var(--card);
45
+ border-radius: 18px;
46
+ box-shadow: var(--shadow);
47
+ padding: 32px;
48
+ max-width: 900px;
49
+ margin: 0 auto;
50
+ }
51
+ h1 { font-size: 22px; margin: 0 0 4px; }
52
+ .subtitle { color: var(--text-secondary); font-size: 13px; margin: 0 0 20px; }
53
+ .subtitle a { color: var(--accent); }
54
+ label { display: block; font-size: 13px; font-weight: 600; margin: 14px 0 6px; }
55
+ select, input[type="text"], input[type="number"], input[type="file"] {
56
+ width: 100%;
57
+ padding: 10px 12px;
58
+ border-radius: 10px;
59
+ border: 1px solid var(--border);
60
+ background: var(--card);
61
+ color: var(--text);
62
+ font-size: 14px;
63
+ }
64
+ .row { display: flex; gap: 12px; }
65
+ .row > * { flex: 1; }
66
+ .hidden { display: none; }
67
+ button {
68
+ margin-top: 18px;
69
+ padding: 12px 20px;
70
+ border: none;
71
+ border-radius: 12px;
72
+ background: var(--accent);
73
+ color: #fff;
74
+ font-size: 14px;
75
+ font-weight: 600;
76
+ cursor: pointer;
77
+ }
78
+ button:hover { background: var(--accent-hover); }
79
+ button:disabled { opacity: 0.5; cursor: default; }
80
+ .status {
81
+ margin-top: 14px;
82
+ border-radius: 10px;
83
+ padding: 10px 14px;
84
+ font-size: 13px;
85
+ display: none;
86
+ }
87
+ .status.error { display: block; background: var(--danger-bg); color: var(--danger); }
88
+ pre {
89
+ margin-top: 16px;
90
+ background: var(--mono-bg);
91
+ border: 1px solid var(--border);
92
+ border-radius: 10px;
93
+ padding: 16px;
94
+ max-height: 60vh;
95
+ overflow: auto;
96
+ font-size: 12px;
97
+ line-height: 1.5;
98
+ white-space: pre-wrap;
99
+ word-break: break-word;
100
+ }
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <div class="card">
105
+ <h1>ipa-forge — Reverse Engineering</h1>
106
+ <p class="subtitle">Read-only static analysis of an IPA. Back to <a href="/">the patcher</a>.</p>
107
+
108
+ <label for="action">Action</label>
109
+ <select id="action">
110
+ <option value="classdump">Class-dump (Objective-C runtime metadata)</option>
111
+ <option value="strings">Strings</option>
112
+ <option value="security">Security posture</option>
113
+ <option value="diff">Diff two versions</option>
114
+ </select>
115
+
116
+ <div id="fieldsSingle">
117
+ <label for="fileSingle">IPA</label>
118
+ <input type="file" id="fileSingle" accept=".ipa" />
119
+ </div>
120
+
121
+ <div id="fieldsDiff" class="hidden">
122
+ <label for="fileOld">Old-version IPA</label>
123
+ <input type="file" id="fileOld" accept=".ipa" />
124
+ <label for="fileNew">New-version IPA</label>
125
+ <input type="file" id="fileNew" accept=".ipa" />
126
+ </div>
127
+
128
+ <div id="fieldsClassdump" class="row">
129
+ <div>
130
+ <label for="className">Class name (exact)</label>
131
+ <input type="text" id="className" placeholder="e.g. YTPlayerViewController" />
132
+ </div>
133
+ <div>
134
+ <label for="classSearch">Search (regex)</label>
135
+ <input type="text" id="classSearch" placeholder="e.g. ^YT" />
136
+ </div>
137
+ </div>
138
+
139
+ <div id="fieldsStrings" class="row hidden">
140
+ <div>
141
+ <label for="minLen">Min length</label>
142
+ <input type="number" id="minLen" value="4" min="1" />
143
+ </div>
144
+ <div>
145
+ <label for="stringsSearch">Search (regex)</label>
146
+ <input type="text" id="stringsSearch" placeholder="e.g. https?://" />
147
+ </div>
148
+ </div>
149
+
150
+ <button id="runBtn">Run</button>
151
+ <div class="status error" id="status"></div>
152
+ <pre id="output" class="hidden"></pre>
153
+ </div>
154
+
155
+ <script>
156
+ const action = document.getElementById("action");
157
+ const fieldsSingle = document.getElementById("fieldsSingle");
158
+ const fieldsDiff = document.getElementById("fieldsDiff");
159
+ const fieldsClassdump = document.getElementById("fieldsClassdump");
160
+ const fieldsStrings = document.getElementById("fieldsStrings");
161
+ const runBtn = document.getElementById("runBtn");
162
+ const status = document.getElementById("status");
163
+ const output = document.getElementById("output");
164
+
165
+ function updateFields() {
166
+ const a = action.value;
167
+ fieldsSingle.classList.toggle("hidden", a === "diff");
168
+ fieldsDiff.classList.toggle("hidden", a !== "diff");
169
+ fieldsClassdump.classList.toggle("hidden", a !== "classdump");
170
+ fieldsStrings.classList.toggle("hidden", a !== "strings");
171
+ }
172
+ action.addEventListener("change", updateFields);
173
+ updateFields();
174
+
175
+ runBtn.addEventListener("click", async () => {
176
+ status.style.display = "none";
177
+ output.classList.add("hidden");
178
+ const a = action.value;
179
+ const fd = new FormData();
180
+ let url = "/analysis/" + a;
181
+
182
+ if (a === "diff") {
183
+ const oldFile = document.getElementById("fileOld").files[0];
184
+ const newFile = document.getElementById("fileNew").files[0];
185
+ if (!oldFile || !newFile) {
186
+ status.textContent = "Select both IPAs.";
187
+ status.style.display = "block";
188
+ return;
189
+ }
190
+ fd.append("old", oldFile);
191
+ fd.append("new", newFile);
192
+ } else {
193
+ const file = document.getElementById("fileSingle").files[0];
194
+ if (!file) {
195
+ status.textContent = "Select an IPA.";
196
+ status.style.display = "block";
197
+ return;
198
+ }
199
+ fd.append("ipa", file);
200
+ if (a === "classdump") {
201
+ const cn = document.getElementById("className").value.trim();
202
+ const cs = document.getElementById("classSearch").value.trim();
203
+ if (cn) fd.append("class_name", cn);
204
+ if (cs) fd.append("search", cs);
205
+ } else if (a === "strings") {
206
+ fd.append("min_len", document.getElementById("minLen").value || "4");
207
+ const ss = document.getElementById("stringsSearch").value.trim();
208
+ if (ss) fd.append("search", ss);
209
+ }
210
+ }
211
+
212
+ runBtn.disabled = true;
213
+ try {
214
+ const res = await fetch(url, { method: "POST", body: fd });
215
+ const data = await res.json();
216
+ if (!res.ok) {
217
+ status.textContent = data.error || "Request failed.";
218
+ status.style.display = "block";
219
+ return;
220
+ }
221
+ output.textContent = data.text || "(empty)";
222
+ output.classList.remove("hidden");
223
+ } finally {
224
+ runBtn.disabled = false;
225
+ }
226
+ });
227
+ </script>
228
+ </body>
229
+ </html>
ipa_forge/gui/app.py ADDED
@@ -0,0 +1,304 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Local web GUI wrapping the same ipa_forge.pipeline used by the CLI.
3
+
4
+ Novice flow: pick an IPA file -> the GUI auto-detects the app and the
5
+ matching patch set, warns (non-blocking) if the patch set targets a
6
+ different version, then one "Patch" button produces an unsigned IPA for
7
+ AltStore. No signing identity, no profiles, no YAML editing required.
8
+
9
+ Single-user, local-only: request state lives in module-level dicts. The page
10
+ is a self-contained static template (gui/index.html) with an inline fetch
11
+ flow — no build step, works offline.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import shutil
18
+ import tempfile
19
+ import uuid
20
+ from importlib.metadata import PackageNotFoundError
21
+ from importlib.metadata import version as _installed_version
22
+ from pathlib import Path
23
+
24
+ from fastapi import FastAPI, File, Form, UploadFile
25
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
26
+
27
+ from ipa_forge.analysis.classdump import render_analysis
28
+ from ipa_forge.analysis.diff import diff_analyses, render_diff
29
+ from ipa_forge.analysis.security import analyze_security, render_security_posture
30
+ from ipa_forge.analysis.strings import strings_in_bundle
31
+ from ipa_forge.bundle.ipa import load_bundle, validate_and_extract
32
+ from ipa_forge.machO.objc import analyze_bundle
33
+ from ipa_forge.patches import find_patch_sets_for_bundle
34
+ from ipa_forge.pipeline import PipelineError, run_pipeline
35
+ from ipa_forge.validators.ipa_validator import IpaValidationError
36
+
37
+ app = FastAPI(title="ipa-forge")
38
+
39
+ _WORK_DIR = Path(tempfile.mkdtemp(prefix="ipa_forge_gui_"))
40
+ _outputs: dict[str, tuple[Path, str]] = {}
41
+ _OUTPUTS_MAX = 20
42
+ # GUI textarea sanity cap -- the CLI (forge analysis strings) has no limit
43
+ # and pipes to grep instead; a browser tab rendering a multi-million-line
44
+ # response is a different failure mode worth guarding against here only.
45
+ _STRINGS_GUI_LIMIT = 2000
46
+
47
+
48
+ def _package_version() -> str:
49
+ try:
50
+ return _installed_version("ipa-forge")
51
+ except PackageNotFoundError:
52
+ return "0.1.0"
53
+
54
+
55
+ _FORM_HTML = (
56
+ (Path(__file__).parent / "index.html").read_text(encoding="utf-8").replace("__VERSION__", _package_version())
57
+ )
58
+ _ANALYSIS_HTML = (
59
+ (Path(__file__).parent / "analysis.html").read_text(encoding="utf-8").replace("__VERSION__", _package_version())
60
+ )
61
+
62
+
63
+ @app.get("/", response_class=HTMLResponse)
64
+ def index() -> str:
65
+ return _FORM_HTML
66
+
67
+
68
+ @app.get("/analysis", response_class=HTMLResponse)
69
+ def analysis_page() -> str:
70
+ return _ANALYSIS_HTML
71
+
72
+
73
+ @app.post("/detect")
74
+ async def detect(ipa: UploadFile = File(...)) -> JSONResponse:
75
+ """Analyze an uploaded IPA: bundle id, version, matching patch sets, and
76
+ a version-mismatch warning (informational — patching is still allowed)."""
77
+ request_dir = _WORK_DIR / uuid.uuid4().hex
78
+ request_dir.mkdir(parents=True)
79
+ try:
80
+ ipa_path = request_dir / Path(ipa.filename or "upload.ipa").name
81
+ ipa_path.write_bytes(await ipa.read())
82
+ try:
83
+ app_path = validate_and_extract(ipa_path, request_dir / "extracted")
84
+ bundle = load_bundle(app_path)
85
+ except (ValueError, IpaValidationError) as e:
86
+ return JSONResponse(status_code=400, content={"error": str(e)})
87
+
88
+ patch_sets = []
89
+ for ps in find_patch_sets_for_bundle(bundle.bundle_id):
90
+ match = ps.version_exact is None or ps.version_exact == bundle.version
91
+ patch_sets.append(
92
+ {
93
+ "name": ps.name,
94
+ "target_version": ps.version_exact or ps.version_spec,
95
+ "matches": match,
96
+ }
97
+ )
98
+ return JSONResponse(
99
+ {
100
+ "bundle_id": bundle.bundle_id,
101
+ "version": bundle.version,
102
+ "build": bundle.build,
103
+ "patch_sets": patch_sets,
104
+ # a warning when the app has a patch set but the version differs
105
+ "mismatch": bool(patch_sets) and not any(p["matches"] for p in patch_sets),
106
+ }
107
+ )
108
+ finally:
109
+ shutil.rmtree(request_dir, ignore_errors=True)
110
+
111
+
112
+ @app.post("/patch")
113
+ async def patch(
114
+ ipa: UploadFile = File(...),
115
+ patch_set: str = Form(...),
116
+ identity: str | None = Form(None),
117
+ profile: list[UploadFile] | None = File(None),
118
+ dry_run: bool = Form(False),
119
+ no_sign: bool = Form(True),
120
+ ) -> JSONResponse:
121
+ """Patch an IPA with a discovered patch set. Defaults to unsigned output
122
+ for AltStore (no identity/profile needed); the version-mismatch warning
123
+ is non-blocking — the hooks gate still guards what attaches."""
124
+ request_dir = _WORK_DIR / uuid.uuid4().hex
125
+ request_dir.mkdir(parents=True)
126
+ try:
127
+ ipa_path = request_dir / Path(ipa.filename or "upload.ipa").name
128
+ ipa_path.write_bytes(await ipa.read())
129
+
130
+ from ipa_forge.patches import discover_patch_sets
131
+
132
+ definition = None
133
+ for ps in discover_patch_sets():
134
+ if ps.name == patch_set:
135
+ definition = ps.definition
136
+ break
137
+ if definition is None:
138
+ return JSONResponse(status_code=400, content={"error": f"unknown patch set '{patch_set}'"})
139
+
140
+ profile_paths: list[Path] = []
141
+ if not dry_run and not no_sign:
142
+ if not identity or not identity.strip():
143
+ return JSONResponse(status_code=400, content={"error": "identity is required for signed output"})
144
+ if not profile:
145
+ return JSONResponse(
146
+ status_code=400, content={"error": "at least one profile is required for signed output"}
147
+ )
148
+ profile_dir = request_dir / "profiles"
149
+ profile_dir.mkdir()
150
+ for i, upload in enumerate(profile or []):
151
+ dest = profile_dir / f"{i}_{Path(upload.filename or 'profile.mobileprovision').name}"
152
+ dest.write_bytes(await upload.read())
153
+ profile_paths.append(dest)
154
+
155
+ output_path = request_dir / f"patched_{ipa_path.name}"
156
+ try:
157
+ result = run_pipeline(
158
+ ipa_path,
159
+ definition,
160
+ identity or "",
161
+ profile_paths,
162
+ output_path,
163
+ dry_run=dry_run,
164
+ no_sign=no_sign,
165
+ allow_version_mismatch=True,
166
+ )
167
+ except PipelineError as e:
168
+ return JSONResponse(status_code=400, content={"error": str(e)})
169
+
170
+ response: dict = {"manifest": json.loads(result.manifest.to_json())}
171
+ if result.output_path is not None:
172
+ token = uuid.uuid4().hex
173
+ final_path = _WORK_DIR / f"{token}.ipa"
174
+ result.output_path.replace(final_path)
175
+ _outputs[token] = (final_path, f"patched_{ipa_path.name}")
176
+ _evict_outputs()
177
+ response["download_url"] = f"/download/{token}"
178
+ return JSONResponse(response)
179
+ finally:
180
+ shutil.rmtree(request_dir, ignore_errors=True)
181
+
182
+
183
+ def _evict_outputs() -> None:
184
+ while len(_outputs) > _OUTPUTS_MAX:
185
+ token = next(iter(_outputs))
186
+ path, _ = _outputs.pop(token)
187
+ path.unlink(missing_ok=True)
188
+
189
+
190
+ @app.get("/download/{token}", response_model=None)
191
+ def download(token: str) -> FileResponse | JSONResponse:
192
+ entry = _outputs.get(token)
193
+ if entry is None:
194
+ return JSONResponse(status_code=404, content={"error": "not found"})
195
+ path, display_name = entry
196
+ if not path.is_file():
197
+ return JSONResponse(status_code=404, content={"error": "not found"})
198
+ return FileResponse(path, filename=display_name)
199
+
200
+
201
+ async def _extract_uploaded(request_dir: Path, ipa: UploadFile, name: str = "upload.ipa") -> Path | JSONResponse:
202
+ """Save + extract an uploaded IPA; returns the app path or a 400
203
+ JSONResponse on a structural/plist error, for the analysis endpoints
204
+ below to return directly."""
205
+ ipa_path = request_dir / Path(ipa.filename or name).name
206
+ ipa_path.write_bytes(await ipa.read())
207
+ try:
208
+ return validate_and_extract(ipa_path, request_dir / "extracted")
209
+ except (ValueError, IpaValidationError) as e:
210
+ return JSONResponse(status_code=400, content={"error": str(e)})
211
+
212
+
213
+ @app.post("/analysis/classdump")
214
+ async def analysis_classdump(
215
+ ipa: UploadFile = File(...),
216
+ class_name: str | None = Form(None),
217
+ search: str | None = Form(None),
218
+ ) -> JSONResponse:
219
+ """Read-only Objective-C class-dump view -- same engine as
220
+ `forge analysis classdump`, browsable without installing the CLI."""
221
+ request_dir = _WORK_DIR / uuid.uuid4().hex
222
+ request_dir.mkdir(parents=True)
223
+ try:
224
+ app_path = await _extract_uploaded(request_dir, ipa)
225
+ if isinstance(app_path, JSONResponse):
226
+ return app_path
227
+ analysis = analyze_bundle(load_bundle(app_path))
228
+ text = render_analysis(analysis, class_filter=class_name or None, search=search or None)
229
+ return JSONResponse({"text": text or "no matching classes/protocols/categories found"})
230
+ finally:
231
+ shutil.rmtree(request_dir, ignore_errors=True)
232
+
233
+
234
+ @app.post("/analysis/strings")
235
+ async def analysis_strings(
236
+ ipa: UploadFile = File(...),
237
+ min_len: int = Form(4),
238
+ search: str | None = Form(None),
239
+ ) -> JSONResponse:
240
+ """Printable-string extraction across every executable in the bundle,
241
+ capped at `_STRINGS_GUI_LIMIT` matches for the browser (the CLI has no
242
+ such cap)."""
243
+ request_dir = _WORK_DIR / uuid.uuid4().hex
244
+ request_dir.mkdir(parents=True)
245
+ try:
246
+ app_path = await _extract_uploaded(request_dir, ipa)
247
+ if isinstance(app_path, JSONResponse):
248
+ return app_path
249
+ found = strings_in_bundle(load_bundle(app_path), min_len=min_len)
250
+ if search:
251
+ import re
252
+
253
+ pat = re.compile(search)
254
+ found = [s for s in found if pat.search(s.value)]
255
+ total = len(found)
256
+ found = found[:_STRINGS_GUI_LIMIT]
257
+ text = "\n".join(f"[{s.binary}] {s.value}" for s in found)
258
+ if total > len(found):
259
+ text += f"\n… truncated ({total} total; use the CLI for the full list)"
260
+ return JSONResponse({"text": text or "no strings found"})
261
+ finally:
262
+ shutil.rmtree(request_dir, ignore_errors=True)
263
+
264
+
265
+ @app.post("/analysis/security")
266
+ async def analysis_security(ipa: UploadFile = File(...)) -> JSONResponse:
267
+ """PIE/encryption-flag/stack-protector/ARC posture of the main
268
+ executable -- detection only, this never decrypts anything."""
269
+ request_dir = _WORK_DIR / uuid.uuid4().hex
270
+ request_dir.mkdir(parents=True)
271
+ try:
272
+ app_path = await _extract_uploaded(request_dir, ipa)
273
+ if isinstance(app_path, JSONResponse):
274
+ return app_path
275
+ bundle = load_bundle(app_path)
276
+ posture = analyze_security(bundle.root / bundle.main_executable_name)
277
+ return JSONResponse({"text": render_security_posture(posture)})
278
+ finally:
279
+ shutil.rmtree(request_dir, ignore_errors=True)
280
+
281
+
282
+ @app.post("/analysis/diff")
283
+ async def analysis_diff(old: UploadFile = File(...), new: UploadFile = File(...)) -> JSONResponse:
284
+ """Version-to-version survey between two uploaded builds -- same engine
285
+ as `forge analysis diff`."""
286
+ request_dir = _WORK_DIR / uuid.uuid4().hex
287
+ (request_dir / "old").mkdir(parents=True)
288
+ (request_dir / "new").mkdir(parents=True)
289
+ try:
290
+ old_path = await _extract_uploaded(request_dir / "old", old, "old.ipa")
291
+ if isinstance(old_path, JSONResponse):
292
+ return old_path
293
+ new_path = await _extract_uploaded(request_dir / "new", new, "new.ipa")
294
+ if isinstance(new_path, JSONResponse):
295
+ return new_path
296
+ bundle_old = load_bundle(old_path)
297
+ bundle_new = load_bundle(new_path)
298
+ diff = diff_analyses(
299
+ analyze_bundle(bundle_old), analyze_bundle(bundle_new), bundle_old.info_plist, bundle_new.info_plist
300
+ )
301
+ header = f"{bundle_old.bundle_id} {bundle_old.version} -> {bundle_new.version}\n"
302
+ return JSONResponse({"text": header + render_diff(diff)})
303
+ finally:
304
+ shutil.rmtree(request_dir, ignore_errors=True)