symbolicator-server 0.0.1__tar.gz

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.
@@ -0,0 +1,35 @@
1
+ name: Python App
2
+
3
+ on:
4
+ push:
5
+ branches: [ '**' ]
6
+ pull_request:
7
+ branches: [ 'main' ]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ${{ matrix.os }}
12
+
13
+ strategy:
14
+ matrix:
15
+ python-version: [ '3.14' ]
16
+ os: [ macos-latest, ubuntu-latest ]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v3
20
+ - name: Set up Python ${{ matrix.python-version }}
21
+ uses: actions/setup-python@v4
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ - name: Install dependencies
25
+ run: |
26
+ python3 -m pip install --upgrade pip
27
+ python3 -m pip install pre-commit
28
+ - name: Run pre-commit hooks
29
+ run: |
30
+ pre-commit run --all-files
31
+ - name: Test install
32
+ run: |
33
+ python -m pip install -U .
34
+ - name: Test show usage
35
+ run: symbolicator-server --help
@@ -0,0 +1,33 @@
1
+ # These workflows will upload a Python Package using Twine when a release is created
2
+ # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
3
+
4
+ name: Upload Python Package
5
+
6
+ on:
7
+ release:
8
+ types: [ created ]
9
+
10
+ jobs:
11
+ deploy:
12
+
13
+ runs-on: macos-latest
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v4
21
+ with:
22
+ python-version: '3.14'
23
+ - name: Install dependencies
24
+ run: |
25
+ python -m pip install --upgrade pip
26
+ pip install -U build setuptools wheel twine
27
+ - name: Build and publish
28
+ env:
29
+ TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
30
+ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
31
+ run: |
32
+ python -m build
33
+ twine upload dist/*
@@ -0,0 +1,50 @@
1
+ # Object files
2
+
3
+ *.ko
4
+ *.obj
5
+ *.elf
6
+
7
+ # Precompiled Headers
8
+ *.gch
9
+ *.pch
10
+
11
+ # Libraries
12
+ *.lib
13
+ *.a
14
+ *.la
15
+ *.lo
16
+ *.o
17
+
18
+ # Shared objects (inc. Windows DLLs)
19
+ *.dll
20
+ *.so
21
+ *.so.*
22
+ *.dylib
23
+
24
+ # Executables
25
+ *.exe
26
+ *.out
27
+ *.app
28
+ *.i*86
29
+ *.x86_64
30
+ *.hex
31
+
32
+ # Caches
33
+ __pycache__
34
+ _CodeSignature
35
+ *.egg-info
36
+
37
+ # Debug files
38
+ *.dSYM/
39
+
40
+ # macOS shit
41
+ .DS_Store
42
+
43
+ # ide files
44
+ .vscode
45
+ .idea
46
+
47
+ # Build
48
+ **/_version.py
49
+ /tests/.pytest_cache/
50
+ dist/
@@ -0,0 +1,15 @@
1
+ # .pre-commit-config.yaml
2
+ repos:
3
+ - repo: https://github.com/pre-commit/mirrors-isort
4
+ rev: v5.9.3
5
+ hooks:
6
+ - id: isort
7
+ args: [ '-m', 'HANGING_INDENT', '-l', '120','--check-only' ]
8
+ files: .
9
+
10
+ - repo: https://github.com/pycqa/flake8
11
+ rev: "7.0.0"
12
+ hooks:
13
+ - id: flake8
14
+ args: [ '--max-line-length=127' ]
15
+ files: \.py$
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: symbolicator-server
3
+ Version: 0.0.1
4
+ Summary: A Python stand-in for ipswd's symbol server
5
+ Author-email: netanelc305 <netanelc305@protonmail.com>
6
+ Maintainer-email: netanelc305 <netanelc305@protonmail.com>
7
+ Project-URL: Homepage, https://github.com/netanelc305/symbolicator-server
8
+ Project-URL: Bug Reports, https://github.com/netanelc305/symbolicator-server/issues
9
+ Keywords: ipsw,ipswd,symbolication,symbols,dyld_shared_cache,server
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: fastapi>=0.162.2
17
+ Requires-Dist: uvicorn>=0.54.0
18
+
19
+ # symbolicator-server
20
+
21
+ A Python stand-in for ipswd's symbol server. It scans IPSWs into a SQLite
22
+ database and serves the same `/v1/...` API, so `ipsw symbolicate --server`
23
+ works against it.
24
+
25
+
26
+ ## How it differs from ipswd
27
+
28
+ The API and the answers are the same: a lookup returns the same symbol ipswd
29
+ would, including which one wins when several cover an address (the first one
30
+ inserted). What changes is how symbols are stored and how scans are fed in.
31
+
32
+ **Symbol storage**
33
+
34
+ | | ipswd | symbolicator-server |
35
+ |---|---|---|
36
+ | Rows per symbol | 2: `symbols (id, name_id, start, end)` plus `macho_syms (macho_uuid, symbol_id)` | 1: `symbols (macho_id, off, seq, size, name_code)`, `WITHOUT ROWID` |
37
+ | Mach-O reference | The 36-character UUID text, once per symbol | A small integer id |
38
+ | Addresses | Absolute `start` and `end` | Offset from the Mach-O's `__TEXT` start, plus a size. Most values fit in 2-4 bytes |
39
+ | Unnamed functions (`func_<addr>`) | A row in `names` each | No name row. Rebuilt from the address when read |
40
+ | Same Mach-O seen again (the same shared cache in another device's IPSW, or a rescan) | All of its symbols are inserted again with new ids | Its symbol list is fingerprinted. A list already stored is skipped, and a list with new symbols only appends the new ones |
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.14
45
+ - The [`ipsw`](https://github.com/blacktop/ipsw) CLI on your `PATH`, which does the scanning (`ipsw symbols --json`)
46
+
47
+ ## Install
48
+
49
+ ```shell
50
+ python3 -m pip install -U .
51
+ ```
52
+
53
+ ## Run the server
54
+
55
+ ```shell
56
+ symbolicator-server --db syms.db
57
+ ```
58
+
59
+ or `python3 -m symbolicator_server --db syms.db`.
60
+
61
+ | Option | Default | |
62
+ |---|---|---|
63
+ | `--db` | (required) | SQLite database file, created if missing |
64
+ | `--host` / `--port` | `localhost` / `3993` | Where to listen |
65
+ | `--sigs-dir` | | Default kernel signatures dir for scans |
66
+ | `--pem-db` | | Default AEA pem DB for scans |
67
+ | `--ipsw-bin` | `ipsw` on `PATH` | The ipsw CLI used to scan |
68
+ | `-v`, `--verbose` | | Log every image of a scan |
69
+
70
+ ## Scan an IPSW
71
+
72
+ ```shell
73
+ curl -X POST "http://localhost:3993/v1/syms/scan?path=/PATH/TO/IPSW.ipsw"
74
+ ```
75
+
76
+ The scan runs as a single transaction, and lookups keep working while it runs.
77
+ Scanning an IPSW that is already in the database returns `409`. To scan it
78
+ again and merge in anything new, use `PUT /v1/syms/rescan?path=...`. Both
79
+ accept optional `sig_dir` and `pem_db` query parameters.
80
+
81
+ ## Symbolicate a crash
82
+
83
+ ```shell
84
+ ipsw symbolicate --server http://localhost:3993 CRASH-REPORT.ips --proc "SpringBoard"
85
+ ```
@@ -0,0 +1,67 @@
1
+ # symbolicator-server
2
+
3
+ A Python stand-in for ipswd's symbol server. It scans IPSWs into a SQLite
4
+ database and serves the same `/v1/...` API, so `ipsw symbolicate --server`
5
+ works against it.
6
+
7
+
8
+ ## How it differs from ipswd
9
+
10
+ The API and the answers are the same: a lookup returns the same symbol ipswd
11
+ would, including which one wins when several cover an address (the first one
12
+ inserted). What changes is how symbols are stored and how scans are fed in.
13
+
14
+ **Symbol storage**
15
+
16
+ | | ipswd | symbolicator-server |
17
+ |---|---|---|
18
+ | Rows per symbol | 2: `symbols (id, name_id, start, end)` plus `macho_syms (macho_uuid, symbol_id)` | 1: `symbols (macho_id, off, seq, size, name_code)`, `WITHOUT ROWID` |
19
+ | Mach-O reference | The 36-character UUID text, once per symbol | A small integer id |
20
+ | Addresses | Absolute `start` and `end` | Offset from the Mach-O's `__TEXT` start, plus a size. Most values fit in 2-4 bytes |
21
+ | Unnamed functions (`func_<addr>`) | A row in `names` each | No name row. Rebuilt from the address when read |
22
+ | Same Mach-O seen again (the same shared cache in another device's IPSW, or a rescan) | All of its symbols are inserted again with new ids | Its symbol list is fingerprinted. A list already stored is skipped, and a list with new symbols only appends the new ones |
23
+
24
+ ## Requirements
25
+
26
+ - Python 3.14
27
+ - The [`ipsw`](https://github.com/blacktop/ipsw) CLI on your `PATH`, which does the scanning (`ipsw symbols --json`)
28
+
29
+ ## Install
30
+
31
+ ```shell
32
+ python3 -m pip install -U .
33
+ ```
34
+
35
+ ## Run the server
36
+
37
+ ```shell
38
+ symbolicator-server --db syms.db
39
+ ```
40
+
41
+ or `python3 -m symbolicator_server --db syms.db`.
42
+
43
+ | Option | Default | |
44
+ |---|---|---|
45
+ | `--db` | (required) | SQLite database file, created if missing |
46
+ | `--host` / `--port` | `localhost` / `3993` | Where to listen |
47
+ | `--sigs-dir` | | Default kernel signatures dir for scans |
48
+ | `--pem-db` | | Default AEA pem DB for scans |
49
+ | `--ipsw-bin` | `ipsw` on `PATH` | The ipsw CLI used to scan |
50
+ | `-v`, `--verbose` | | Log every image of a scan |
51
+
52
+ ## Scan an IPSW
53
+
54
+ ```shell
55
+ curl -X POST "http://localhost:3993/v1/syms/scan?path=/PATH/TO/IPSW.ipsw"
56
+ ```
57
+
58
+ The scan runs as a single transaction, and lookups keep working while it runs.
59
+ Scanning an IPSW that is already in the database returns `409`. To scan it
60
+ again and merge in anything new, use `PUT /v1/syms/rescan?path=...`. Both
61
+ accept optional `sig_dir` and `pem_db` query parameters.
62
+
63
+ ## Symbolicate a crash
64
+
65
+ ```shell
66
+ ipsw symbolicate --server http://localhost:3993 CRASH-REPORT.ips --proc "SpringBoard"
67
+ ```
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "symbolicator-server"
3
+ description = "A Python stand-in for ipswd's symbol server"
4
+ readme = "README.md"
5
+ requires-python = ">=3.14"
6
+ keywords = ["ipsw", "ipswd", "symbolication", "symbols", "dyld_shared_cache", "server"]
7
+ authors = [
8
+ { name = "netanelc305", email = "netanelc305@protonmail.com" }
9
+ ]
10
+ maintainers = [
11
+ { name = "netanelc305", email = "netanelc305@protonmail.com" }
12
+ ]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ ]
19
+ dynamic = ["dependencies", "version"]
20
+
21
+ [project.urls]
22
+ "Homepage" = "https://github.com/netanelc305/symbolicator-server"
23
+ "Bug Reports" = "https://github.com/netanelc305/symbolicator-server/issues"
24
+
25
+ [project.scripts]
26
+ symbolicator-server = "symbolicator_server.__main__:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ exclude = ["docs*", "tests*"]
30
+
31
+ [tool.setuptools.dynamic]
32
+ dependencies = { file = ["requirements.txt"] }
33
+ version = { attr = "symbolicator_server._version.__version__" }
34
+
35
+ [tool.setuptools_scm]
36
+ version_file = "symbolicator_server/_version.py"
37
+
38
+ [build-system]
39
+ requires = ["setuptools>=43.0.0", "setuptools_scm>=8", "wheel"]
40
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,2 @@
1
+ fastapi>=0.162.2
2
+ uvicorn>=0.54.0
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from symbolicator_server.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = 'g026bd4807'
@@ -0,0 +1,99 @@
1
+ """
2
+ Scanning: run the same scanner ipswd uses and feed its output into a store.
3
+
4
+ Taking an IPSW apart (decrypting the AEA cryptexes, mounting the APFS volumes,
5
+ parsing the dyld_shared_cache and kernelcache, finding every function) is done
6
+ by `ipsw symbols --json`, the scan code inside ipswd, exposed as a command.
7
+ It streams one JSON record per line, grouped per Mach-O:
8
+
9
+ {"type":"ipsw", "id":<sha1>, "name", "version", "build", "platform", "devices":[...]}
10
+ {"type":"dsc", "uuid", "shared_region_start"} once per shared cache
11
+ {"type":"image", "uuid", "kind", "path", "text_start", "text_end", "dsc_uuid"?}
12
+ {"type":"symbol", "image_uuid", "name", "start", "end"} ...that image's symbols
13
+
14
+ Each image is handed to the store as soon as its symbols have arrived (memory
15
+ stays bounded), and the whole scan is one transaction, like ipswd's.
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import subprocess
21
+ import tempfile
22
+ import time
23
+
24
+ log = logging.getLogger("scanner")
25
+
26
+
27
+ class AlreadyScanned(Exception):
28
+ pass
29
+
30
+
31
+ class ScanFailed(Exception):
32
+ pass
33
+
34
+
35
+ def scan(store, ipsw_path, *, ipsw_bin="ipsw", sigs_dir=None, pem_db=None, rescan=False):
36
+ cmd = [ipsw_bin, "symbols", "--json", ipsw_path]
37
+ if sigs_dir:
38
+ cmd += ["--signatures", sigs_dir]
39
+ if pem_db:
40
+ cmd += ["--pem-db", pem_db]
41
+ log.info("running %s", " ".join(cmd))
42
+
43
+ started = time.time()
44
+ counts = {"images": 0, "symbols": 0, "skipped": 0}
45
+ conn = store.connect()
46
+ with tempfile.TemporaryFile("w+") as stderr:
47
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=stderr, text=True, bufsize=1 << 20)
48
+ try:
49
+ ipsw_id = None
50
+ image, symbols = None, []
51
+
52
+ def flush():
53
+ # Hand the finished image and its symbols to the store.
54
+ if image is None:
55
+ return
56
+ note = store.save_image(conn, ipsw_id, image, symbols)
57
+ counts["images"] += 1
58
+ counts["symbols"] += len(symbols)
59
+ counts["skipped"] += note.startswith("already stored")
60
+ log.debug("%s %s: %s", image["kind"], image["path"], note)
61
+
62
+ for line in proc.stdout:
63
+ rec = json.loads(line)
64
+ kind = rec["type"]
65
+ if kind == "ipsw":
66
+ ipsw_id = rec["id"]
67
+ if not rescan and store.has_ipsw(conn, ipsw_id):
68
+ raise AlreadyScanned(f"IPSW {rec['name']} ({ipsw_id}) is already in the database")
69
+ store.save_ipsw(conn, rec)
70
+ log.info("scanning %s %s (%s) for %s",
71
+ rec["name"], rec["version"], rec["build"], ", ".join(rec.get("devices", [])))
72
+ elif kind == "dsc":
73
+ store.save_dsc(conn, ipsw_id, rec)
74
+ elif kind == "image":
75
+ flush()
76
+ image, symbols = rec, []
77
+ elif kind == "symbol":
78
+ symbols.append((rec["start"], rec["end"], rec["name"]))
79
+ # other record types (comparison facts) are not part of the symbol database
80
+ flush()
81
+
82
+ if proc.wait() != 0:
83
+ stderr.seek(0)
84
+ raise ScanFailed(stderr.read().strip()[-2000:] or f"ipsw exited with {proc.returncode}")
85
+ conn.commit()
86
+ except BaseException:
87
+ conn.rollback()
88
+ proc.kill()
89
+ proc.wait()
90
+ raise
91
+ finally:
92
+ conn.close()
93
+
94
+ counts["seconds"] = round(time.time() - started, 1)
95
+ log.info(
96
+ "scan done in %ss: %d images, %d symbols, %d images already stored",
97
+ counts["seconds"], counts["images"], counts["symbols"], counts["skipped"],
98
+ )
99
+ return counts
@@ -0,0 +1,170 @@
1
+ """
2
+ A Python stand-in for ipswd's symbol server, storing one all-integer row per
3
+ symbol (see store.py).
4
+
5
+ symbolicator-server --db syms.db
6
+
7
+ Same endpoints as ipswd (/v1/...), so `ipsw symbolicate --server` works against it:
8
+
9
+ POST /v1/syms/scan?path=<ipsw>[&sig_dir=][&pem_db=] scan an IPSW into the database
10
+ PUT /v1/syms/rescan?path=<ipsw> scan again, replacing what changed
11
+ GET /v1/_ping
12
+ GET /v1/syms/ipsw?version=&build=&device= is this IPSW scanned?
13
+ GET /v1/syms/macho/{uuid} a Mach-O (path, __TEXT range)
14
+ GET /v1/syms/dsc/{uuid} a dyld_shared_cache
15
+ GET /v1/syms/dsc/{uuid}/{addr} the dylib of a shared cache containing addr
16
+ GET /v1/syms/{uuid}/{addr} the symbol at addr
17
+ GET /v1/syms/{uuid} all symbols of a Mach-O
18
+
19
+ Plus one that ipswd does not have:
20
+
21
+ GET /v1/stats database size per table
22
+ """
23
+
24
+ import argparse
25
+ import logging
26
+ import os
27
+ import shutil
28
+
29
+ import uvicorn
30
+ from fastapi import FastAPI, Request
31
+ from fastapi.responses import JSONResponse, PlainTextResponse
32
+
33
+ from symbolicator_server import scanner
34
+ from symbolicator_server.store import Store, symbol_json
35
+
36
+ app = FastAPI(title="symbolicator-server")
37
+ STORE = None # set in main()
38
+ CONFIG = {} # ipsw binary, default signatures dir and pem DB
39
+
40
+
41
+ def error(status, message):
42
+ return JSONResponse(status_code=status, content={"error": message})
43
+
44
+
45
+ def parse_addr(addr):
46
+ """ipswd parses the address with cast.ToUint64: decimal or 0x-hex, anything else is 0.
47
+
48
+ SQLite integers are signed 64-bit and stored addresses are below 2**63, so a
49
+ larger address can match nothing: it becomes -1 rather than failing to bind."""
50
+ try:
51
+ n = int(addr, 0)
52
+ except ValueError:
53
+ return 0
54
+ return n if 0 <= n < 1 << 63 else -1
55
+
56
+
57
+ @app.get("/v1/_ping", response_class=PlainTextResponse)
58
+ def ping():
59
+ return "OK"
60
+
61
+
62
+ # --------------------------------------------------------------------- scan
63
+
64
+
65
+ def run_scan(request: Request, rescan: bool):
66
+ q = request.query_params
67
+ path = q.get("path")
68
+ if not path:
69
+ return error(400, "missing path query parameter")
70
+ if q.get("device"):
71
+ return error(400, "device-scoped database ingestion is not supported; use device-scoped JSONL export instead")
72
+ try:
73
+ scanner.scan(
74
+ STORE,
75
+ os.path.normpath(path),
76
+ ipsw_bin=CONFIG["ipsw_bin"],
77
+ sigs_dir=q.get("sig_dir") or CONFIG["sigs_dir"],
78
+ pem_db=q.get("pem_db") or CONFIG["pem_db"],
79
+ rescan=rescan,
80
+ )
81
+ except scanner.AlreadyScanned as e:
82
+ return error(409, str(e))
83
+ except Exception as e: # noqa: BLE001 - ipswd answers 500 with the error text
84
+ logging.exception("scan failed")
85
+ return error(500, str(e))
86
+ return JSONResponse(status_code=201, content={"created": True}) if rescan else {"success": True}
87
+
88
+
89
+ # Plain `def` routes run in FastAPI's thread pool, so lookups keep working during a scan.
90
+ @app.post("/v1/syms/scan")
91
+ def scan(request: Request):
92
+ return run_scan(request, rescan=False)
93
+
94
+
95
+ @app.put("/v1/syms/rescan")
96
+ def rescan(request: Request):
97
+ return run_scan(request, rescan=True)
98
+
99
+
100
+ # ------------------------------------------------------------------- lookups
101
+ # Order matters: the fixed paths (ipsw, macho, dsc) must come before /syms/{uuid}.
102
+
103
+
104
+ @app.get("/v1/syms/ipsw")
105
+ def get_ipsw(version: str = "", build: str = "", device: str = ""):
106
+ if not (version and build and device):
107
+ return error(400, "version, build and device are required")
108
+ ipsw = STORE.get_ipsw(STORE.reader(), version, build, device)
109
+ return ipsw if ipsw else error(404, "not found")
110
+
111
+
112
+ @app.get("/v1/syms/macho/{uuid}")
113
+ def get_macho(uuid: str):
114
+ macho = STORE.get_macho(STORE.reader(), uuid)
115
+ return macho if macho else error(404, "not found")
116
+
117
+
118
+ @app.get("/v1/syms/dsc/{uuid}")
119
+ def get_dsc(uuid: str):
120
+ dsc = STORE.get_dsc(STORE.reader(), uuid)
121
+ return dsc if dsc else error(404, "not found")
122
+
123
+
124
+ @app.get("/v1/syms/dsc/{uuid}/{addr}")
125
+ def get_dsc_image(uuid: str, addr: str):
126
+ dylib = STORE.get_dsc_image(STORE.reader(), uuid, parse_addr(addr))
127
+ return dylib if dylib else error(404, "not found")
128
+
129
+
130
+ @app.get("/v1/stats")
131
+ def stats():
132
+ return STORE.stats(STORE.reader())
133
+
134
+
135
+ @app.get("/v1/syms/{uuid}/{addr}")
136
+ def get_symbol(uuid: str, addr: str):
137
+ sym = STORE.get_symbol(STORE.reader(), uuid, parse_addr(addr))
138
+ return symbol_json(*sym) if sym else error(404, "not found")
139
+
140
+
141
+ @app.get("/v1/syms/{uuid}")
142
+ def get_symbols(uuid: str):
143
+ syms = STORE.get_symbols(STORE.reader(), uuid)
144
+ return [symbol_json(*s) for s in syms]
145
+
146
+
147
+ # ---------------------------------------------------------------------- main
148
+
149
+
150
+ def main():
151
+ global STORE
152
+ p = argparse.ArgumentParser(description="ipswd-compatible symbol server")
153
+ p.add_argument("--db", required=True, help="sqlite database file")
154
+ p.add_argument("--host", default="localhost")
155
+ p.add_argument("--port", type=int, default=3994)
156
+ p.add_argument("--sigs-dir", help="default kernel signatures dir (ipswd: daemon.sigs-dir)")
157
+ p.add_argument("--pem-db", help="default AEA pem DB (ipswd: daemon.pem-db)")
158
+ p.add_argument("--ipsw-bin", default=shutil.which("ipsw") or "ipsw", help="the ipsw CLI used to scan")
159
+ p.add_argument("-v", "--verbose", action="store_true", help="log every image of a scan")
160
+ args = p.parse_args()
161
+
162
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(name)s: %(message)s")
163
+ STORE = Store(args.db)
164
+ CONFIG.update(ipsw_bin=args.ipsw_bin, sigs_dir=args.sigs_dir, pem_db=args.pem_db)
165
+ logging.info("database %s, scanning with %s", args.db, args.ipsw_bin)
166
+ uvicorn.run(app, host=args.host, port=args.port)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
@@ -0,0 +1,385 @@
1
+ """
2
+ Symbol storage: normalized tables, one all-integer row per symbol.
3
+
4
+ ipsws, ipsw_devices, dyld_shared_caches, ipsw_dscs,
5
+ machos, ipsw_machos, dsc_images what was scanned
6
+ names (id, name) name -> id
7
+ macho_ids (id, uuid, base, max_size, next_seq) uuid -> small integer id
8
+ symbols (macho_id, off, seq, size, name_code) WITHOUT ROWID one row per symbol
9
+ symbol_lists (macho_id, list_hash) fingerprints of merged lists
10
+
11
+ Why it is small:
12
+ * The Mach-O is referenced by a small integer instead of its 36-character UUID.
13
+ * The address is stored as an offset from the Mach-O's __TEXT start and the
14
+ end as a size, so most values fit in 2-4 bytes (SQLite stores integers in
15
+ 1-8 bytes as needed).
16
+ * WITHOUT ROWID: the rows live directly in the primary-key B-tree, so there
17
+ is no second index holding the same columns again.
18
+ * A list a scan delivers is fingerprinted first; a list already merged
19
+ (another device's IPSW, a rescan) writes nothing.
20
+ * Placeholder names (func_<address>) are not stored (name_code 0 / 1).
21
+
22
+ Same answers as ipswd: a Mach-O's symbols only grow (new lists are merged in),
23
+ and when several symbols cover an address the first inserted (lowest seq)
24
+ wins, like ipswd's ORDER BY id.
25
+
26
+ The schema is a simplified version of ipswd's. The kernelcache -> kext and
27
+ ipsw -> file-system links are folded into one ipsw_machos table, because they
28
+ do not affect how symbols are stored.
29
+ """
30
+
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import sqlite3
35
+ import threading
36
+
37
+ SCHEMA = """
38
+ CREATE TABLE IF NOT EXISTS ipsws (
39
+ id TEXT PRIMARY KEY, -- sha1 of the .ipsw file
40
+ name TEXT,
41
+ version TEXT,
42
+ build TEXT,
43
+ platform TEXT
44
+ );
45
+ CREATE TABLE IF NOT EXISTS ipsw_devices (
46
+ ipsw_id TEXT,
47
+ device TEXT,
48
+ PRIMARY KEY (ipsw_id, device)
49
+ );
50
+ CREATE TABLE IF NOT EXISTS dyld_shared_caches (
51
+ uuid TEXT PRIMARY KEY,
52
+ shared_region_start INTEGER
53
+ );
54
+ CREATE TABLE IF NOT EXISTS ipsw_dscs (
55
+ ipsw_id TEXT,
56
+ dsc_uuid TEXT,
57
+ PRIMARY KEY (ipsw_id, dsc_uuid)
58
+ );
59
+ CREATE TABLE IF NOT EXISTS machos (
60
+ uuid TEXT PRIMARY KEY,
61
+ kind TEXT, -- kernel / kext / dylib / macho
62
+ path TEXT,
63
+ text_start INTEGER,
64
+ text_end INTEGER
65
+ );
66
+ CREATE TABLE IF NOT EXISTS ipsw_machos (
67
+ ipsw_id TEXT,
68
+ macho_uuid TEXT,
69
+ PRIMARY KEY (ipsw_id, macho_uuid)
70
+ );
71
+ CREATE TABLE IF NOT EXISTS dsc_images (
72
+ dsc_uuid TEXT,
73
+ macho_uuid TEXT,
74
+ PRIMARY KEY (dsc_uuid, macho_uuid)
75
+ );
76
+ CREATE TABLE IF NOT EXISTS names (
77
+ id INTEGER PRIMARY KEY,
78
+ name TEXT UNIQUE
79
+ );
80
+ CREATE TABLE IF NOT EXISTS macho_ids (
81
+ id INTEGER PRIMARY KEY,
82
+ uuid TEXT UNIQUE,
83
+ base INTEGER, -- the Mach-O's __TEXT start; symbol addresses are stored relative to it
84
+ max_size INTEGER, -- its largest symbol; bounds how far back a lookup has to look
85
+ next_seq INTEGER -- insertion counter: keeps "first inserted wins" like the old ids
86
+ );
87
+ CREATE TABLE IF NOT EXISTS symbols (
88
+ macho_id INTEGER,
89
+ off INTEGER, -- start - base
90
+ seq INTEGER, -- insertion order within the Mach-O
91
+ size INTEGER, -- end - start
92
+ name_code INTEGER, -- 0/1 = func_ placeholder, otherwise names.id + 2
93
+ PRIMARY KEY (macho_id, off, seq)
94
+ ) WITHOUT ROWID;
95
+ CREATE TABLE IF NOT EXISTS symbol_lists (
96
+ macho_id INTEGER,
97
+ list_hash BLOB,
98
+ PRIMARY KEY (macho_id, list_hash)
99
+ ) WITHOUT ROWID;
100
+ """
101
+
102
+ HIGH_BIT = 1 << 63 # cleared from kernel addresses, but still part of their func_ names
103
+
104
+ # name_code: 0 and 1 are placeholders, real names are names.id + 2.
105
+ NAME_FUNC = 0 # func_<start> (dylibs, file-system Mach-Os)
106
+ NAME_FUNC_KERNEL = 1 # func_<start | bit 63> (kernel and kexts)
107
+ FIRST_REAL_NAME = 2
108
+
109
+ LIST_HASH_VERSION = b"\x02" # part of every stored fingerprint; changing it re-merges every list
110
+
111
+
112
+ def placeholder_code(name, start):
113
+ """NAME_FUNC / NAME_FUNC_KERNEL if name is the func_ placeholder for start, else None."""
114
+ if name.startswith("func_"):
115
+ if name == f"func_{start:x}":
116
+ return NAME_FUNC
117
+ if name == f"func_{start | HIGH_BIT:x}":
118
+ return NAME_FUNC_KERNEL
119
+ return None
120
+
121
+
122
+ def decode_name(code, start, name):
123
+ """(name_id, name) from a stored name_code and the name joined from the names table."""
124
+ if code == NAME_FUNC:
125
+ return None, f"func_{start:x}"
126
+ if code == NAME_FUNC_KERNEL:
127
+ return None, f"func_{start | HIGH_BIT:x}"
128
+ return code - FIRST_REAL_NAME, name
129
+
130
+
131
+ def list_hash(symbols):
132
+ """Fingerprint of one symbol list as a scan delivered it (addresses + name text, in order)."""
133
+ text = "".join(f"{start}:{end}:{name}\n" for start, end, name in symbols)
134
+ return hashlib.sha256(LIST_HASH_VERSION + text.encode()).digest()
135
+
136
+
137
+ # Symbols of one Mach-O as (name_id, name, start, end), by address then insertion order.
138
+ SYMBOLS_SQL = """
139
+ SELECT m.base + s.off, s.size, s.name_code, n.name
140
+ FROM macho_ids m
141
+ JOIN symbols s ON s.macho_id = m.id
142
+ LEFT JOIN names n ON n.id = s.name_code - 2
143
+ WHERE m.uuid = ?
144
+ ORDER BY s.off, s.seq
145
+ """
146
+
147
+ # Only symbols starting within max_size before addr can cover it: a short range
148
+ # scan of the primary key, then the first inserted among the matches.
149
+ SYMBOL_AT_SQL = """
150
+ SELECT m.base + s.off, s.size, s.name_code, n.name
151
+ FROM macho_ids m
152
+ JOIN symbols s ON s.macho_id = m.id
153
+ AND s.off BETWEEN :addr - m.base - m.max_size AND :addr - m.base
154
+ LEFT JOIN names n ON n.id = s.name_code - 2
155
+ WHERE m.uuid = :uuid AND :addr - m.base < s.off + s.size
156
+ ORDER BY s.seq LIMIT 1
157
+ """
158
+
159
+
160
+ class Store:
161
+ def __init__(self, path):
162
+ self.path = path
163
+ self._local = threading.local()
164
+ with self.connect() as conn:
165
+ conn.execute("PRAGMA journal_mode = WAL") # persistent: lookups keep working while a scan writes
166
+ conn.executescript(SCHEMA)
167
+
168
+ def connect(self):
169
+ conn = sqlite3.connect(self.path, timeout=60)
170
+ conn.execute("PRAGMA synchronous = NORMAL") # durable enough under WAL, much faster commits
171
+ conn.execute("PRAGMA cache_size = -65536") # 64 MiB page cache
172
+ conn.execute("PRAGMA mmap_size = 1073741824") # read pages straight from the mapped file
173
+ return conn
174
+
175
+ def reader(self):
176
+ """A connection for lookups, one per thread and kept open (FastAPI reuses its pool threads)."""
177
+ conn = getattr(self._local, "conn", None)
178
+ if conn is None:
179
+ conn = self._local.conn = self.connect()
180
+ return conn
181
+
182
+ # ------------------------------------------------------------------ saving
183
+
184
+ def has_ipsw(self, conn, ipsw_id):
185
+ return conn.execute("SELECT 1 FROM ipsws WHERE id = ?", (ipsw_id,)).fetchone() is not None
186
+
187
+ def save_ipsw(self, conn, rec):
188
+ conn.execute(
189
+ "INSERT OR REPLACE INTO ipsws (id, name, version, build, platform) VALUES (?, ?, ?, ?, ?)",
190
+ (rec["id"], rec["name"], rec["version"], rec["build"], rec.get("platform")),
191
+ )
192
+ conn.executemany(
193
+ "INSERT OR IGNORE INTO ipsw_devices (ipsw_id, device) VALUES (?, ?)",
194
+ [(rec["id"], dev) for dev in rec.get("devices", [])],
195
+ )
196
+
197
+ def save_dsc(self, conn, ipsw_id, rec):
198
+ conn.execute(
199
+ "INSERT OR IGNORE INTO dyld_shared_caches (uuid, shared_region_start) VALUES (?, ?)",
200
+ (rec["uuid"], rec["shared_region_start"]),
201
+ )
202
+ conn.execute("INSERT OR IGNORE INTO ipsw_dscs VALUES (?, ?)", (ipsw_id, rec["uuid"]))
203
+
204
+ def save_image(self, conn, ipsw_id, image, symbols):
205
+ """Save one Mach-O and its symbols. symbols: [(start, end, name), ...]. Returns a note for the scan log."""
206
+ uuid = image["uuid"]
207
+ conn.execute(
208
+ "INSERT OR IGNORE INTO machos (uuid, kind, path, text_start, text_end) VALUES (?, ?, ?, ?, ?)",
209
+ (uuid, image["kind"], image["path"], image["text_start"], image["text_end"]),
210
+ )
211
+ conn.execute("INSERT OR IGNORE INTO ipsw_machos VALUES (?, ?)", (ipsw_id, uuid))
212
+ if image.get("dsc_uuid"):
213
+ conn.execute("INSERT OR IGNORE INTO dsc_images VALUES (?, ?)", (image["dsc_uuid"], uuid))
214
+ return self.save_symbols(conn, uuid, symbols)
215
+
216
+ def save_symbols(self, conn, uuid, symbols):
217
+ macho_id, base, max_size, next_seq = self._macho_id(conn, uuid)
218
+ fingerprint = list_hash(symbols)
219
+ seen = conn.execute(
220
+ "SELECT 1 FROM symbol_lists WHERE macho_id = ? AND list_hash = ?", (macho_id, fingerprint)
221
+ ).fetchone()
222
+ if seen:
223
+ return "already stored, skipped" # this exact list was merged before: nothing to write
224
+ conn.execute("INSERT INTO symbol_lists (macho_id, list_hash) VALUES (?, ?)", (macho_id, fingerprint))
225
+
226
+ # Encode first, then merge in encoded form: stored rows stay, rows not stored yet are
227
+ # appended after them. Comparing (off, size, name_code) needs no join with names.
228
+ codes = [placeholder_code(name, start) for start, _, name in symbols]
229
+ ids = self.name_ids(conn, {sym[2] for sym, code in zip(symbols, codes) if code is None})
230
+ known = (
231
+ set(conn.execute("SELECT off, size, name_code FROM symbols WHERE macho_id = ?", (macho_id,)))
232
+ if next_seq
233
+ else set()
234
+ )
235
+ rows = []
236
+ for (start, end, name), code in zip(symbols, codes):
237
+ # an inverted range is stored with size 0 and never matches, as in ipswd
238
+ key = (start - base, max(end - start, 0), ids[name] + FIRST_REAL_NAME if code is None else code)
239
+ if key not in known:
240
+ known.add(key)
241
+ rows.append((macho_id, key[0], next_seq + len(rows), key[1], key[2]))
242
+ max_size = max(max_size, key[1])
243
+ if not rows:
244
+ return "already stored, skipped" if next_seq else "no symbols"
245
+
246
+ conn.executemany("INSERT INTO symbols (macho_id, off, seq, size, name_code) VALUES (?, ?, ?, ?, ?)", rows)
247
+ conn.execute(
248
+ "UPDATE macho_ids SET max_size = ?, next_seq = ? WHERE id = ?", (max_size, next_seq + len(rows), macho_id)
249
+ )
250
+ if next_seq:
251
+ return f"merged {len(rows)} new symbols into the {next_seq} stored"
252
+ return f"inserted {len(rows)} symbol rows"
253
+
254
+ def _macho_id(self, conn, uuid):
255
+ """(id, base, max_size, next_seq) for uuid, creating the id on first sight."""
256
+ row = conn.execute("SELECT id, base, max_size, next_seq FROM macho_ids WHERE uuid = ?", (uuid,)).fetchone()
257
+ if row:
258
+ return row
259
+ cur = conn.execute(
260
+ """INSERT INTO macho_ids (uuid, base, max_size, next_seq)
261
+ SELECT uuid, COALESCE(text_start, 0), 0, 0 FROM machos WHERE uuid = ?
262
+ RETURNING id, base, max_size, next_seq""",
263
+ (uuid,),
264
+ )
265
+ return cur.fetchone()
266
+
267
+ def name_ids(self, conn, names):
268
+ """Insert the names that are missing and return {name: id}."""
269
+ if not names:
270
+ return {}
271
+ names = json.dumps(list(names)) # one bound parameter, however many names
272
+ conn.execute("INSERT OR IGNORE INTO names (name) SELECT value FROM json_each(?)", (names,))
273
+ return {
274
+ name: nid
275
+ for nid, name in conn.execute(
276
+ "SELECT id, name FROM names WHERE name IN (SELECT value FROM json_each(?))", (names,)
277
+ )
278
+ }
279
+
280
+ # ----------------------------------------------------------------- lookups
281
+
282
+ def get_symbol(self, conn, uuid, addr):
283
+ """(name_id, name, start, end) of the symbol covering addr, or None."""
284
+ row = conn.execute(SYMBOL_AT_SQL, {"uuid": uuid, "addr": addr}).fetchone()
285
+ if row is None:
286
+ return None
287
+ start, size, code, name = row
288
+ return *decode_name(code, start, name), start, start + size
289
+
290
+ def get_symbols(self, conn, uuid):
291
+ """[(name_id, name, start, end), ...] sorted by start."""
292
+ return [
293
+ (*decode_name(code, start, name), start, start + size)
294
+ for start, size, code, name in conn.execute(SYMBOLS_SQL, (uuid,))
295
+ ]
296
+
297
+ def get_ipsw(self, conn, version, build, device):
298
+ row = conn.execute(
299
+ """SELECT i.id, i.name, i.version, i.build, i.platform FROM ipsws i
300
+ JOIN ipsw_devices d ON d.ipsw_id = i.id
301
+ WHERE i.version = ? AND i.build = ? AND d.device = ?""",
302
+ (version, build, device),
303
+ ).fetchone()
304
+ if row is None:
305
+ return None
306
+ devices = [d for (d,) in conn.execute("SELECT device FROM ipsw_devices WHERE ipsw_id = ?", (row[0],))]
307
+ return ipsw_json(row, devices)
308
+
309
+ def get_macho(self, conn, uuid):
310
+ row = conn.execute("SELECT uuid, path, text_start, text_end FROM machos WHERE uuid = ?", (uuid,)).fetchone()
311
+ return None if row is None else macho_json(row)
312
+
313
+ def get_dsc(self, conn, uuid):
314
+ row = conn.execute("SELECT uuid, shared_region_start FROM dyld_shared_caches WHERE uuid = ?", (uuid,)).fetchone()
315
+ return None if row is None else {"uuid": row[0], "shared_region_start": row[1]}
316
+
317
+ def get_dsc_image(self, conn, dsc_uuid, addr):
318
+ """The dylib of a shared cache whose __TEXT contains addr."""
319
+ row = conn.execute(
320
+ """SELECT m.uuid, m.path, m.text_start, m.text_end FROM machos m
321
+ JOIN dsc_images di ON di.macho_uuid = m.uuid
322
+ WHERE di.dsc_uuid = ? AND m.text_start <= ? AND ? < m.text_end LIMIT 1""",
323
+ (dsc_uuid, addr, addr),
324
+ ).fetchone()
325
+ return None if row is None else macho_json(row)
326
+
327
+ def stats(self, conn):
328
+ files = [self.path, self.path + "-wal"]
329
+ out = {
330
+ "db": self.path,
331
+ "size_bytes": sum(os.path.getsize(f) for f in files if os.path.exists(f)),
332
+ "tables": {},
333
+ }
334
+ tables = [t for (t,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")]
335
+ try: # per-table bytes (tables + their indexes), when sqlite has dbstat compiled in
336
+ sizes = dict(conn.execute(
337
+ """SELECT COALESCE(m.tbl_name, s.name), SUM(s.pgsize) FROM dbstat s
338
+ LEFT JOIN sqlite_master m ON m.name = s.name GROUP BY 1"""
339
+ ))
340
+ except sqlite3.OperationalError:
341
+ sizes = {}
342
+ for t in tables:
343
+ (rows,) = conn.execute(f'SELECT COUNT(*) FROM "{t}"').fetchone()
344
+ out["tables"][t] = {"rows": rows, "bytes": sizes.get(t)}
345
+ return out
346
+
347
+
348
+ # --------------------------------------------------------------------------
349
+ # JSON shapes, matching how ipswd serializes its Go models
350
+ # (field names are what the `ipsw symbolicate --server` client decodes).
351
+ # --------------------------------------------------------------------------
352
+
353
+
354
+ def ipsw_json(row, devices):
355
+ ipsw_id, name, version, build, platform = row
356
+ return {
357
+ "id": ipsw_id,
358
+ "name": name,
359
+ "version": version,
360
+ "buildid": build,
361
+ "platform": platform,
362
+ "devices": [{"name": d} for d in devices],
363
+ }
364
+
365
+
366
+ def macho_json(row):
367
+ uuid, path, text_start, text_end = row
368
+ return {
369
+ "uuid": uuid,
370
+ "PathID": 0,
371
+ "Path": {"id": 0, "path": path},
372
+ "text_start": text_start,
373
+ "text_end": text_end,
374
+ "Symbols": None,
375
+ }
376
+
377
+
378
+ def symbol_json(name_id, name, start, end):
379
+ return {
380
+ "ID": 0,
381
+ "NameID": name_id,
382
+ "Name": {"ID": name_id, "name": name},
383
+ "start": start,
384
+ "end": end,
385
+ }
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: symbolicator-server
3
+ Version: 0.0.1
4
+ Summary: A Python stand-in for ipswd's symbol server
5
+ Author-email: netanelc305 <netanelc305@protonmail.com>
6
+ Maintainer-email: netanelc305 <netanelc305@protonmail.com>
7
+ Project-URL: Homepage, https://github.com/netanelc305/symbolicator-server
8
+ Project-URL: Bug Reports, https://github.com/netanelc305/symbolicator-server/issues
9
+ Keywords: ipsw,ipswd,symbolication,symbols,dyld_shared_cache,server
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: fastapi>=0.162.2
17
+ Requires-Dist: uvicorn>=0.54.0
18
+
19
+ # symbolicator-server
20
+
21
+ A Python stand-in for ipswd's symbol server. It scans IPSWs into a SQLite
22
+ database and serves the same `/v1/...` API, so `ipsw symbolicate --server`
23
+ works against it.
24
+
25
+
26
+ ## How it differs from ipswd
27
+
28
+ The API and the answers are the same: a lookup returns the same symbol ipswd
29
+ would, including which one wins when several cover an address (the first one
30
+ inserted). What changes is how symbols are stored and how scans are fed in.
31
+
32
+ **Symbol storage**
33
+
34
+ | | ipswd | symbolicator-server |
35
+ |---|---|---|
36
+ | Rows per symbol | 2: `symbols (id, name_id, start, end)` plus `macho_syms (macho_uuid, symbol_id)` | 1: `symbols (macho_id, off, seq, size, name_code)`, `WITHOUT ROWID` |
37
+ | Mach-O reference | The 36-character UUID text, once per symbol | A small integer id |
38
+ | Addresses | Absolute `start` and `end` | Offset from the Mach-O's `__TEXT` start, plus a size. Most values fit in 2-4 bytes |
39
+ | Unnamed functions (`func_<addr>`) | A row in `names` each | No name row. Rebuilt from the address when read |
40
+ | Same Mach-O seen again (the same shared cache in another device's IPSW, or a rescan) | All of its symbols are inserted again with new ids | Its symbol list is fingerprinted. A list already stored is skipped, and a list with new symbols only appends the new ones |
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.14
45
+ - The [`ipsw`](https://github.com/blacktop/ipsw) CLI on your `PATH`, which does the scanning (`ipsw symbols --json`)
46
+
47
+ ## Install
48
+
49
+ ```shell
50
+ python3 -m pip install -U .
51
+ ```
52
+
53
+ ## Run the server
54
+
55
+ ```shell
56
+ symbolicator-server --db syms.db
57
+ ```
58
+
59
+ or `python3 -m symbolicator_server --db syms.db`.
60
+
61
+ | Option | Default | |
62
+ |---|---|---|
63
+ | `--db` | (required) | SQLite database file, created if missing |
64
+ | `--host` / `--port` | `localhost` / `3993` | Where to listen |
65
+ | `--sigs-dir` | | Default kernel signatures dir for scans |
66
+ | `--pem-db` | | Default AEA pem DB for scans |
67
+ | `--ipsw-bin` | `ipsw` on `PATH` | The ipsw CLI used to scan |
68
+ | `-v`, `--verbose` | | Log every image of a scan |
69
+
70
+ ## Scan an IPSW
71
+
72
+ ```shell
73
+ curl -X POST "http://localhost:3993/v1/syms/scan?path=/PATH/TO/IPSW.ipsw"
74
+ ```
75
+
76
+ The scan runs as a single transaction, and lookups keep working while it runs.
77
+ Scanning an IPSW that is already in the database returns `409`. To scan it
78
+ again and merge in anything new, use `PUT /v1/syms/rescan?path=...`. Both
79
+ accept optional `sig_dir` and `pem_db` query parameters.
80
+
81
+ ## Symbolicate a crash
82
+
83
+ ```shell
84
+ ipsw symbolicate --server http://localhost:3993 CRASH-REPORT.ips --proc "SpringBoard"
85
+ ```
@@ -0,0 +1,21 @@
1
+ .gitignore
2
+ .pre-commit-config.yaml
3
+ README.md
4
+ pyproject.toml
5
+ requirements.txt
6
+ .github/workflows/python-app.yml
7
+ .github/workflows/python-publish.yml
8
+ symbolicator_server/__init__.py
9
+ symbolicator_server/__main__.py
10
+ symbolicator_server/_version.py
11
+ symbolicator_server/scanner.py
12
+ symbolicator_server/server.py
13
+ symbolicator_server/store.py
14
+ symbolicator_server.egg-info/PKG-INFO
15
+ symbolicator_server.egg-info/SOURCES.txt
16
+ symbolicator_server.egg-info/dependency_links.txt
17
+ symbolicator_server.egg-info/entry_points.txt
18
+ symbolicator_server.egg-info/requires.txt
19
+ symbolicator_server.egg-info/scm_file_list.json
20
+ symbolicator_server.egg-info/scm_version.json
21
+ symbolicator_server.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ symbolicator-server = symbolicator_server.__main__:main
@@ -0,0 +1,2 @@
1
+ fastapi>=0.162.2
2
+ uvicorn>=0.54.0
@@ -0,0 +1,16 @@
1
+ {
2
+ "files": [
3
+ ".github/workflows/python-app.yml",
4
+ ".github/workflows/python-publish.yml",
5
+ ".gitignore",
6
+ ".pre-commit-config.yaml",
7
+ "README.md",
8
+ "pyproject.toml",
9
+ "requirements.txt",
10
+ "symbolicator_server/__init__.py",
11
+ "symbolicator_server/__main__.py",
12
+ "symbolicator_server/scanner.py",
13
+ "symbolicator_server/server.py",
14
+ "symbolicator_server/store.py"
15
+ ]
16
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.0.1",
3
+ "distance": 0,
4
+ "node": "g026bd4807427934e641aa6574dd4e013eb7870bc",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-15"
8
+ }
@@ -0,0 +1,2 @@
1
+ dist
2
+ symbolicator_server