diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 3499424275..b23248073e 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -265,6 +265,14 @@ explicit path or endpoint and do not need it. build-id or ABI-tag fields. A missing table is observed absence of those declarations, not proof the image never carried notes. See the [ELF notes guide](https://github.com/morluto/rea/blob/main/docs/elf-notes.md). +- Apple `.ips` crash reports: `inspect_apple_ips` with the explicit local + file. This built-in target-free inspection requires no Xcode, atos, or + pwntools. Preserve first-line metadata, `bug_type` as a string, process and + exception facts, lossless integer registers, usedImages UUIDs, and + documented classification matches. Only `bug_type` `"309"` yields a crash + body; other types, including `"288"` stackshots, return no crash result. + Opened Mach-O and dSYM UUID joins remain unobserved. See the + [Apple IPS guide](https://github.com/morluto/rea/blob/main/docs/apple-ips.md). - CPython 3.7–3.13 `.pyc` header, nested marshal code objects and bytecode identity: `inspect_python_pyc` with the explicit local file. This built-in target-free inspection requires no Python installation. Preserve magic, diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 1dc7b97d40..7e0645a9b5 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -136,6 +136,18 @@ absence of those declarations, not proof the image never carried notes in another container. See [ELF note tables](https://github.com/morluto/rea/blob/main/docs/elf-notes.md). +## Apple IPS crash reports + +Use `inspect_apple_ips` for an explicit local Apple `.ips` file when the +question concerns crash metadata, exception and termination facts, threads, +usedImages, lossless registers, or documented crash classes. No `open_binary`, +Xcode, atos, or target execution is required. The CLI equivalent is +`inspect-apple-ips PATH`. Preserve artifact SHA-256, `bug_type` as a string, +uppercase image UUIDs, and integer literals that exceed IEEE-754 range. Only +`bug_type` `"309"` yields a crash body; other DiagnosticReports types return +no crash result. Opened Mach-O and dSYM UUID joins remain unobserved. See +[Apple IPS crash reports](https://github.com/morluto/rea/blob/main/docs/apple-ips.md). + ## CPython pyc files Use `inspect_python_pyc` for an explicit local CPython 3.7–3.13 `.pyc` when the diff --git a/README.md b/README.md index 5afad7d976..8c71d24bac 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ Additional tools and host support depend on the target: | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](docs/pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](docs/elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and documented crash classes | Built-in static JSON inspection; [Apple IPS](docs/apple-ips.md) | | Erlang BEAM | IFF chunks, atoms, exports, imports, lambdas and the Code header | Built-in static BEAM inspection; [Erlang BEAM](docs/erlang-beam.md) | | WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7120eab1fe..9a07734f37 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -35,6 +35,7 @@ export default defineConfig({ { text: "PE certificates", link: "/pe-certificates" }, { text: "PE imports", link: "/pe-imports" }, { text: "ELF notes", link: "/elf-notes" }, + { text: "Apple IPS crash reports", link: "/apple-ips" }, { text: "CPython pyc files", link: "/python-pyc" }, { text: "Erlang BEAM", link: "/erlang-beam" }, { diff --git a/docs/apple-ips.md b/docs/apple-ips.md new file mode 100644 index 0000000000..fa5fbd62d6 --- /dev/null +++ b/docs/apple-ips.md @@ -0,0 +1,105 @@ +# Apple IPS crash reports + +`inspect_apple_ips` / `inspect-apple-ips` answers whether one selected local +Apple `.ips` file is a crash report (`bug_type` string `"309"`), which process, +exception, and termination facts that report records, which threads and +`usedImages` rows it lists, and which documented crash classes its strings +match. It reads the file without executing a target and does not require Xcode, +atos, a disassembler, or pwntools. + +```bash +rea inspect-apple-ips /absolute/path/to/report.ips --json +``` + +The CLI resolves relative paths against the caller's working directory. MCP +requires an absolute filesystem path and preserves that exact selection: + +```json +{ + "name": "inspect_apple_ips", + "arguments": { "path": "/artifacts/report.ips" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, and byte length. +`metadata` is the first-line JSON object. `bug_type` stays a string. The crash +body is parsed only when that string is `"309"`. Other types, including `"288"` +stackshots, return no crash result. + +`process`, `exception`, `termination`, `faulting_thread`, `threads`, and +`images` reproduce documented body fields when present. Integers keep their +decimal literals and a hexadecimal form, including arm64e registers above +2^53. Frame roles follow the recorded frame index: row zero of the resolved +faulting thread is `faulting_instruction`, row zero of other threads is +`current_instruction`, and later rows are `return_address`. Each runtime +address uses that row's own image base and offset. The recorded `inline` flag +does not supply a missing offset or authorize borrowing another row's address. +When `faultingThread` is missing or does not resolve to a recorded thread +object, row-zero roles are `unknown`; the recorded lookup remains available. +`process.os_version` keeps the producer's dictionary (`train`, `build`, +`release_type`, `is_embedded`) or the older string form. Image UUIDs are uppercase 8-4-4-4-12; an all-zero UUID +or a missing `arch` is `uuid_unknown`. Image `source` letters keep their +documented meaning (`P` process, `S` shared cache, `C` shared cache library, +`K` kernel, `U` kernel cache, `T` kernel text exec, `A` absolute). Keys the +documentation does not list remain in `unlisted_keys` or `unlisted_body_keys`. + +Each frame's `image_index` refers to its entry in `images`. Image architecture, +UUID and `uuid_unknown` are retained once on that image. Resolve the reference +when using those facts; an absent or out-of-range index leaves the image +unknown. This avoids repeating image text for every stack frame. + +A present value whose JSON type differs from the documented one is never read +as absent: it is listed in `coverage.type_mismatches` with its path, the +expected type, and the observed type, and `coverage.status` becomes +`partial`. A crash body without `threads` or `usedImages` is also `partial`. +Malformed image UUID strings likewise make coverage partial; their exact spelling +is retained in the mismatch's `recorded_value`, while the canonical image UUID +remains unknown. Both 32 hexadecimal digits and canonical hyphenated UUIDs are accepted. +Unresolved thread/image references also make coverage partial. A rejected +integer lookup keeps its exact decimal literal in `recorded_integer`, including +negative values and values above uint32. Valid uint32 lookup values remain in +`faulting_thread` or `image_index` even when the referenced row is unavailable. + +`classification` matches only documented SIGKILL codes (compared by their +canonical hexadecimal form), `EXC_GUARD` subtypes, +pointer-authentication suffixes, and dyld missing-library strings. A report +that matches none of those rules is `unclassified`. + +Opened Mach-O and dSYM UUID joins remain unobserved. Symbol names printed +inside the report are recorded claims and are not verified. Linux ELF cores +use the separate [recorded crash](recorded-crashes.md) workflow. + +## Limits and failures + +The selected file is limited to 1 MiB by default (16 MiB maximum). At most 256 +threads, 8192 frames, 1024 `usedImages` rows, and 16384 `threadState` +register slots are examined by default (4096 / 65536 / 4096 / 262144 +maximum). Budgets are checked before each row is parsed, and body members the +report does not interpret are syntax-checked without being kept. Fixed +allowances bound JSON nesting (64 levels), unlisted key names per object +(1024), and `termination.reasons` entries (256). Malformed metadata, a +truncated crash body, invalid UTF-8, a NUL in the text, and exhausted budgets +fail without partial success. + +`max_integer_text_bytes` (`--max-integer-text-bytes` in the CLI) separately +bounds retained decimal and hexadecimal integer text, including every derived +runtime address and rejected lookup literal. Its default is 8 MiB of UTF-16 text storage and its maximum is +64 MiB. Each appearance counts, even when an object could be shared in memory +but would be serialized more than once. The parser reserves a conservative +upper bound before numeric conversion or address expansion, then releases the +unused reservation after each value. It reuses the parsed source integer when +the same image base contributes to many frames. + +Raise this per-call allowance on the next call when more room is needed; no +restart is required. File-size and row budgets do not raise it. This allowance +is separate from JSON wire size, MCP response limits and the client's receive +buffer. An exhausted allowance reports a typed resource error without partial +success or truncated integer facts. + +```bash +rea inspect-apple-ips /absolute/path/to/report.ips --max-integer-text-bytes 16777216 --json +``` + +This workflow does not execute a target, launch atos, or search for dSYMs. diff --git a/docs/cli.md b/docs/cli.md index 20b8ac866b..5328c16a60 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -311,6 +311,17 @@ The workflow reports note owners, types, descriptor hashes, and GNU build-id or ABI-tag fields when those descriptors match. A missing note table is observed absence of those declarations. See [ELF note tables](elf-notes.md). +Inspect an Apple `.ips` crash report without executing a target: + +```bash +rea inspect-apple-ips /absolute/path/to/report.ips --json +``` + +The workflow reports first-line metadata, process/exception/termination facts, +threads, usedImages, lossless integer registers, and documented crash classes +when `bug_type` is `"309"`. Other DiagnosticReports types return no crash +result. See [Apple IPS crash reports](apple-ips.md). + Inspect a CPython 3.7–3.13 `.pyc` header and nested marshal code objects without executing bytecode or installing Python: diff --git a/docs/index.md b/docs/index.md index 1a83f408fa..fa6d547c3a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Task | Guides | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [ELF notes](elf-notes.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | | Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 164e69b363..793c531a55 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -159,6 +159,7 @@ npx rea-agents@latest setup | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) | | تثبيتات سطح المكتب | عائلات وقت التشغيل، والملفات الداعمة، وعلامات المكونات، والخطوات التالية | فحص محدود للملفات والترويسات;[تطبيقات سطح المكتب](../desktop-applications.md) | | JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index f3f0f40b52..99f700606e 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -159,6 +159,7 @@ REA benötigt Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) oder 26+ (>=26.12.1) so | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) | | Desktop-Installationen | Laufzeitkandidaten, Quelldateien, Komponenten und nächste Schritte | Begrenzte Verzeichnis- und Headerprüfung;[Desktop-Anwendungen](../desktop-applications.md) | | JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index fe21e44cfb..c4f49c8b27 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -158,6 +158,7 @@ npx rea-agents@latest setup | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Αρχείο WASM | Έλεγχο εγκυρότητας, ενότητες, ακριβείς μορφές εισαγωγών/εξαγωγών σε WAT και αποκωδικοποιημένο WAT | WABT 1.0.42 που παρέχει ο χρήστης· `inspect_wasm_artifact` | | Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index dd4b489ba2..50a93748e9 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -159,6 +159,7 @@ REA requiere Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), adem | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) | | Instalaciones de escritorio | Posibles entornos de ejecución, archivos de evidencia, componentes y siguientes pasos | Inspección acotada de directorios y cabeceras;[Aplicaciones de escritorio](../desktop-applications.md) | | JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index 2b830fccbc..ae156da3b8 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -150,6 +150,7 @@ REA به Node.js 22.x (>=22.23.4)، 24.x (>=24.22.1) یا 26+ (>=26.12.1)، به | بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](../recorded-crashes.md) | | نصب‌های دسکتاپ | نامزدهای زمان اجرا، فایل‌های شاهد، نشانه‌های مؤلفه‌ها و مراحل بعدی | بررسی محدود پوشه‌ها و سرآیندها;[برنامه‌های دسکتاپ](../desktop-applications.md) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index d33c322cd9..cf1ffe6270 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -159,6 +159,7 @@ REA nécessite Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), ai | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) | | Installations de bureau | Environnements candidats, fichiers justificatifs, composants et étapes suivantes | Inspection bornée des répertoires et en-têtes;[Applications de bureau](../desktop-applications.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index 8715456c94..f92e932408 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -161,6 +161,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) | | Instalasi desktop | Kandidat runtime, berkas pendukung, penanda komponen, dan langkah berikutnya | Pemeriksaan direktori dan header terbatas;[Aplikasi desktop](../desktop-applications.md) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index ed3e7a201a..3417bced99 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -159,6 +159,7 @@ REA richiede Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), oltre | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) | | Installazioni desktop | Runtime candidati, file di supporto, componenti e passi successivi | Ispezione limitata di directory e intestazioni;[Applicazioni desktop](../desktop-applications.md) | | JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index f37aa8ae52..0241ef4c14 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -158,6 +158,7 @@ REA には Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)、または 26+ (>= | EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) | | デスクトップのインストール先 | ランタイム候補、根拠ファイル、コンポーネントの目印、次の調査手順 | 上限付きのディレクトリ・ヘッダー検査;[デスクトップアプリ](../desktop-applications.md) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 8af51c5f15..ae5e791ce3 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -158,6 +158,7 @@ REA에는 Node.js 22.x(>=22.23.4), 24.x(>=24.22.1) 또는 26+ (>=26.12.1)와 npm | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` | | EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index 11fbe51174..b98b122c42 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -162,6 +162,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.23.4), 24.x (>=24.22.1), അല്ല | WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, അനുമാനിച്ച arguments, mutability | Linux x64-ൽ local raw/hex carrier; [offline EVM guide](../evm-bytecode.md) | | SQLite databases | Tables, columns, indexes, views, triggers, തിരഞ്ഞെടുത്ത സാധാരണ table rows | Local database/WAL snapshot; native SQLite പരിമിതികളോടെ Node.js 24.x >=24.15 അല്ലെങ്കിൽ 26+ | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Recorded Linux crashes | Raw notes, രേഖപ്പെടുത്തിയ ഓരോ thread-ന്റെയും registers/signals, ഐച്ഛിക mapping candidates | Linux x64-ൽ caller നൽകുന്ന pwntools; ഐച്ഛിക GDB/pwndbg; [recorded crashes](../recorded-crashes.md) | | ഡെസ്ക്ടോപ്പ് ഇൻസ്റ്റലേഷനുകൾ | റൺടൈം സാധ്യതകൾ, തെളിവ് ഫയലുകൾ, ഘടക സൂചനകൾ, തുടർ നടപടികൾ | പരിധിയുള്ള ഡയറക്ടറി, ഹെഡർ പരിശോധന;[ഡെസ്ക്ടോപ്പ് ആപ്ലിക്കേഷനുകൾ](../desktop-applications.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC, native add-on ബന്ധങ്ങൾ | Node.js, npm; Linux: procps-compatible `ps`; [application analysis](../javascript-application-workflows.md) | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 2b509cfeee..787156a27e 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -159,6 +159,7 @@ REA wymaga Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) lub 26+ (>=26.12.1) oraz n | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) | | Instalacje desktopowe | Kandydaci środowisk uruchomieniowych, pliki źródłowe dowodów, komponenty i dalsze kroki | Ograniczona inspekcja katalogów i nagłówków;[Aplikacje desktopowe](../desktop-applications.md) | | JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 26bf804f94..1d7cd06bda 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -159,6 +159,7 @@ O REA requer Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), alé | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) | | Instalações desktop | Ambientes candidatos, arquivos de evidência, componentes e próximos passos | Inspeção limitada de diretórios e cabeçalhos;[Aplicações desktop](../desktop-applications.md) | | JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 90f210a068..3f0e69ff92 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -159,6 +159,7 @@ REA требует Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) или 26+ (>=26.1 | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | | Установленные настольные приложения | Возможные среды выполнения, файлы с доказательствами, компоненты и дальнейшие шаги | Ограниченная проверка каталогов и заголовков;[Настольные приложения](../desktop-applications.md) | | JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 02b428cb1a..a9988b829d 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -159,6 +159,7 @@ REA ต้องใช้ Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) หรื | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) | | ไดเรกทอรีติดตั้งแอปเดสก์ท็อป | รันไทม์ที่เป็นไปได้ ไฟล์หลักฐาน ตัวบ่งชี้ส่วนประกอบ และขั้นตอนถัดไป | ตรวจสอบไดเรกทอรีและส่วนหัวโดยจำกัดขอบเขต;[แอปเดสก์ท็อป](../desktop-applications.md) | | JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index c280e828e2..1d464f8d12 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -156,6 +156,7 @@ REA; Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) veya 26+ (>=26.12.1) ve npm gere | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` | | EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 7907ed37ec..767f168108 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -159,6 +159,7 @@ REA потребує Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) або 26+ (>=26 | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) | | Встановлені настільні програми | Можливі середовища виконання, файли доказів, компоненти та наступні кроки | Обмежена перевірка каталогів і заголовків;[Настільні програми](../desktop-applications.md) | | JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index 0dc7604067..0422fba7f3 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -202,6 +202,7 @@ Mazeed tools aur host support target par depend karte hain: | WASM artifact | Validation, sections, exact import/export WAT forms aur decoded WAT | Aapka diya hua WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments aur mutability | Linux x64 par local raw/hex carrier; [offline EVM guide](../evm-bytecode.md) | | SQLite databases | Tables, columns, indexes, views, triggers aur selected ordinary-table rows | Local database/WAL snapshot; native SQLite limits ke saath Node.js 24.x >=24.15 ya 26+ | +| Apple IPS crash reports | Apple .ips metadata, threads, images aur crash classification | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Recorded Linux crashes | Raw notes, har recorded thread ke registers/signals aur optional mapping candidates | Linux x64 par aapka diya hua pwntools; optional GDB/pwndbg; [recorded crashes](../recorded-crashes.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC aur native add-on relationships | Node.js aur npm; Linux: procps-compatible `ps`; [application analysis](../javascript-application-workflows.md) | | Websites | Page structure, scripts, network observations aur requested screenshots | Chrome-family browser; [browser analysis](../browser-observation.md) | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index 66f357e7ac..f2155875dc 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -159,6 +159,7 @@ REA yêu cầu Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) hoặc 26+ (>=26.12.1) | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | | Thư mục cài đặt ứng dụng máy tính | Các runtime có khả năng được dùng, tệp bằng chứng, dấu hiệu thành phần và bước tiếp theo | Kiểm tra thư mục và phần đầu tệp có giới hạn;[Ứng dụng máy tính](../desktop-applications.md) | | JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index ca9000bfda..5ad6f6057c 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -159,6 +159,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | | 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) | | JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 39f16fb696..6bb85ef171 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -159,6 +159,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | | 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) | | JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md); Linux: procps `ps` | diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index 5d8a98c55e..10f2b9d4fd 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -1,7 +1,8 @@ # Recorded crash evidence `inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected -Linux ELF64 little-endian core independently of an active binary target. Two +Linux ELF64 little-endian core independently of an active binary target. Apple +`.ips` crash reports use the separate [Apple IPS](apple-ips.md) workflow. Two architecture profiles are interpreted, x86-64 and AArch64; each binds registers to its own `NT_PRSTATUS` layout, and any other `e_machine` is refused as `unsupported_target` before note interpretation. diff --git a/docs/verification/apple-ips-2026-10-24.md b/docs/verification/apple-ips-2026-10-24.md new file mode 100644 index 0000000000..b85cbe427d --- /dev/null +++ b/docs/verification/apple-ips-2026-10-24.md @@ -0,0 +1,329 @@ +# Apple IPS static inspection verification — 2026-10-24 + +## Scope + +PR #6511 adds portable `inspect_apple_ips` / `inspect-apple-ips` over one +caller-selected local file. The metadata must identify string `bug_type` +`"309"`; stackshots and other report kinds remain unsupported. No target is +executed, no deep engine starts, and no atos/dSYM lookup is performed. These +checks do not qualify live macOS state, symbolication or Mach-O UUID joins. + +The source contract preserves integer literals beyond IEEE-754 precision, +observed type mismatches and partial coverage. The frame address is derived +from its image's base plus its recorded offset, as described in +[Apple's crash JSON documentation](https://developer.apple.com/documentation/xcode/interpreting-the-json-format-of-a-crash-report). +That documentation does not provide a 64-bit bound for every numeric field; +the parser therefore bounds representation cost without truncating values. + +## Review corrections and ownership + +The contributor's `80ad2a02e9c6cbe248d58d78a7a79753bcc09f79` already projects +documented JSON members and checks thread/frame/image/register allowances +before materializing each row. Unknown members are syntax-checked without +retaining their values. Producer controls include a budget error before an +invalid next item, wrong-type collections, frame-role cases, and the DEAD10CC +decimal near miss. Inline-role assumptions are corrected below. Source inspection alone does not +establish the runtime checks below. + +Further negative controls expose three remaining boundaries: + +- Two contradictory thread/image coverage reports were accepted by the + service. Coverage now agrees with the retained arrays before Evidence is + issued. Already cancelled requests also preserve the exact caller reason. +- A 20,001-digit image base expanded into 512 derived frame addresses despite + a small input file. The field reader now owns an 8 MiB default / 64 MiB + maximum UTF-16 allowance for recorded decimal/hex fields. Conservative + reservations precede conversion and arithmetic. Source BigInts are reused; + derived sums are not retained in a cache. Repeated appearances count + separately, including a service control using the same object twice. +- A 20,013-character image architecture appeared 513 times in a 512-frame + report. Frames now retain their recorded `image_index`; architecture, UUID + and `uuid_unknown` belong to the image once. The old per-frame copies and + superseded field-access helpers are removed. + +Each defect has a failing control before its correction. Raising the integer +allowance to 64 MiB preserves the 512-frame decimal facts exactly. The +allowance is per call, requires no restart, and is separate from file-size, +row, wire and client-buffer limits. Refusal is a typed capacity error without +partial success. A refused task is not treated as a completed-task speedup. + +## Public corpus provenance + +Two pinned upstream reports are valid two-object JSON and are used without +editing their bytes. Their original generation history has not been verified; +these are public repository corpus files, not a claim of fresh OS capture. + +| Report | Repository revision | Bytes | SHA-256 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | -----: | ------------------------------------------------------------------ | +| [LLDB multithread-test.ips](https://github.com/llvm/llvm-project/blob/16dde0db3ce0b9c5eae14178780cc6db1eec7ac8/lldb/test/Shell/ScriptInterpreter/Python/Crashlog/Inputs/interactive_crashlog/multithread-test.ips) | `16dde0db3ce0b9c5eae14178780cc6db1eec7ac8` | 16,093 | `567cadb22af19be798390ab2021ed929fb782c69b2d3b18ff5bb3f39a3741d8c` | +| [pycrashreport Monterey report](https://github.com/doronz88/pycrashreport/blob/634dff127fa6d4d3df948a64a3e254fe208a99dc/tests/user_mode_crash_report_monterey_non_symbolicated.ips) | `634dff127fa6d4d3df948a64a3e254fe208a99dc` | 7,047 | `7de3d3c3515e2ac1bb6ed4c00b70cb12c1ff6ad4317617d258d58346e00264b7` | + +Three other LLDB inputs (`a.out.ips`, `a.out.inline.ips`, `no_threadState.ips`) +contain invalid JSON at their pinned revision. They were not repaired or +counted as successful report qualifications. Files remain temporary local +verification inputs and are not committed to this repository. + +## Completed-task comparison + +Compare `d4930ed57c0b175a632fd8572732b1e88a73cb2f` (original IPS behavior +with main `cc18e9ad` provider/API integration repairs) against +`135cab1234c3663e839f5fbea6bf833737cd140a`. Three alternating pairs of five +public CLI tasks complete, **30 tasks total**. Every task returns complete +coverage. An independent Python JSON/integer oracle checks all ordered images, +threads, registers, frame offsets, derived addresses and the then-selected +physical-depth roles, as well as process facts and artifact digests. The later +source-backed correction below replaces that role assumption; neither public +report contains an inline row, so its role expectations remain unchanged. The two public reports contain +3/1 threads, 8/4 images, 30/4 frames and 36/22 retained registers respectively. + +Every Evidence field agrees after excluding the changed Evidence ID, the new +default allowance parameter and the removed per-frame copies. Before excluding +those copies, the oracle verifies each against its owning image. Image facts, +uncertainty, other report fields, provenance, coverage and limitations remain +equal. Artifact digests are unchanged across all runs. + +| Complete CLI task | Estimated stdout + invocation tokens, before → after | Elapsed median s | CPU median s | Sampled family RSS median MiB | +| ------------------------------------ | ---------------------------------------------------: | ---------------: | ------------: | ----------------------------: | +| Small synthetic report | 934 → 923 | 2.069 → 1.969 | 2.309 → 2.212 | 368.8 → 369.6 | +| LLDB multithread report | 6,500 → 5,851 | 3.180 → 2.878 | 2.292 → 2.340 | 370.4 → 373.5 | +| Monterey report | 2,377 → 2,309 | 3.330 → 3.029 | 2.230 → 2.208 | 369.6 → 368.8 | +| 20,001-digit base / 64 frames | 402,476 → 401,783 | 2.725 → 2.879 | 2.354 → 2.319 | 375.1 → 371.8 | +| Long image architecture / 512 frames | 1,760,820 → 41,023 | 3.231 → 2.928 | 2.468 → 2.261 | 387.3 → 372.7 | + +The long-image task reduces estimated whole-task context by **97.67%** while +preserving joined facts. Ordinary reports save less. No latency speedup is +claimed: elapsed ranges overlap, including the long-image ranges 2.070–3.381 s +before and 2.928–3.633 s after. Small-report ranges reach about 9.2 s in both +lanes during shared-host contention. The wide-integer elapsed median rises +about 0.154 s, while its CPU and sampled RSS medians decrease slightly. Three +pairs do not establish universal performance or maximum-capacity behavior. + +Each task uses a fresh Node 24.22.1 CLI/provider process, with CPU affinity 0,1. +OS caches were not flushed; these are fresh-process runs with uncontrolled +warm filesystem caches, not measured cold-disk runs. No result cache or +persistent provider session participates. Warm long-lived process behavior is +unmeasured. Tokenx 1.3.0 estimates the entire Evidence stdout and invocation; +these are estimates, not billed/model token counts. The analyst already knows +the single CLI command, so no discovery, paging or extra lookup call is +required. Image metadata is inline in the same result. MCP catalog costs, +prompts, framing, rendering and client JSON parsing are unmeasured. + +CPU is waited-child user plus system time. RSS is a 50 ms recursive +process-family sample; it can double-count shared pages and miss short-lived +or unobserved descendants. Token estimation and the oracle run outside the +timed command. Later main integrations are outside these pinned timings. + +Synthetic recipes: `small.ips` has one frame/register; `wide-integer.ips` uses +base `10^20000`, 64 frames with image offset 1; `wide-image-text.ips` uses +architecture `unknown_arch_` plus 20,000 `x` characters and 512 frames. Their +SHA-256 values are respectively +`1178a013b86f8a6c11abef633fdee5fefd23e010c4af92710321cb0f369ed857`, +`b455598694d9953031e52450f1a4a6655e16abd683a2cec59e393899883b6994`, and +`4d3d2f3e4946cbbeed7e14e1f12cd97bd2f119af1b3a33496dd3e8d079c3f999`. + +## Final integration checks + +Main `cd10ee1f8a8e124be4864e77750a16e76c561809` integration passes **167** +selected domain/service/parser/filesystem/CLI/MCP/shared lifecycle checks in +74.312 s (80.355 s waited-child CPU; 1,902.0 MiB sampled family peak). Full +`npm run check`, `npm run docs:check` and test discovery pass. All 2,324 test +files are discovered exactly once. Earlier source selections overlap these +checks. Two initial public-error assertions placed remediation at the wrong +level; the tests now check the existing top-level error contract. No production +error shape was changed to accommodate them. + +Both pinned public reports additionally pass through the actual stdio MCP +server and SDK client, with the advertised input/output JSON Schemas validated +in AJV 2020. The same independent oracle checks their returned facts. The +client explicitly admits a 64 MiB receive buffer for the complete tool catalog; +this does not qualify every client's default buffer. Target execution and +symbolication remain unverified. The 30-task cost table remains pinned to its +recorded revisions, without reusing these integration costs as speed evidence. + +## Integration after PE imports + +The following integration includes main `4ef916da03d93729041f79cec0ee243cd2d3097d` +and the newly merged PE imports tool. The documentation index retains links to +both static formats. Full static/docs/discovery gates pass, together with +**191** selected checks, including the upstream native-plist cleanup test. +The selection takes 49.939 s, 52.461 s waited-child CPU and 2,079.8 MiB sampled +family RSS. It overlaps earlier selections and is not a performance comparison. + +Previous-head CI run `40058145277` had one functional failure in +`nativePlistFileCleanup.test.ts`, repeated by the aggregate lane; seven other +shards were cancelled. Exact main `d662fa54a67bf24bd4800d1867231662019d91c6` +and actual CI merge `440029745397d78103a623fd1fd91cfa09fc6df5` each reproduce +11 passing / 1 failing cases with identical statuses and normalized failure +messages. The failing test and selected producer sources are byte-identical. +The subsequent main integration fixes that closing race, and all 12 cleanup +cases pass within the 191 checks. Cancelled CI coverage was not executed. + +## Follow-up string capacity and identity review + +Baseline `45a570656cb3b9a7a8bf41302e1f01f8662bc884` and candidate +`6fa8083fc6974bc18c7d227ded33db28eb590f03` isolate three further corrections: + +- Retained JSON strings are syntax-checked by the same scanner used for skipped + strings, then escaped literals are decoded by the native JSON string parser. + Unescaped strings retain the slice fast path. The superseded per-escape + fragment array is gone; decoded length is bounded by the selected input's + literal length. Malformed escapes and controls still fail before projection. +- UUID interpretation owns format validation before canonicalization. Both + 32 hexadecimal digits and canonical hyphenation are accepted. Malformed + nonempty spellings remain in `coverage.type_mismatches[].recorded_value`, + canonical identity stays unknown and coverage becomes partial. Absent UUIDs + and the documented all-zero unknown sentinel remain distinct from that + malformed-value outcome. The field uses the existing exact-optional contract + convention; it does not admit an explicit JavaScript `undefined` value. +- `metadata.bug_type` is literal `"309"` in the domain and advertised schema. + The service rejects unsupported reports from defective/custom ports too. + +Seven new cases failed against the earlier production code: four malformed +UUID spellings, two unsupported port kinds and a public CLI capacity case. +The latter selects a file below the 16 MiB allowance containing 7,500,000 +newline escapes. Earlier decoding exhausts a 256 MiB Node old-space limit; +the corrected CLI completes with every character verified by length and +SHA-256. This is an old-space setting, not a 256 MiB total RSS claim. + +The first expanded run passed 182 of 183 cases; the advertised-schema MCP case +hit its 30-second test deadline. Its isolated two-case file then passed, and +the expanded schema assertions passed again. After switching to the final +exact-optional representation, both MCP cases passed once more: actual SDK +schemas reject a forged `bug_type: "288"` and admit the partial result with its +exact malformed UUID spelling. `check:fast` passed. The two public crash +reports also passed again through an actual stdio MCP client and Ajv2020. + +Twelve further completed CLI tasks used three alternating pairs for the same +public LLVM report and a generated 4,000,063-byte report containing 2,000,000 +newline escapes (SHA-256 +`5c4a9282da37091973774bf98c6822caeecbc1d74d7c44cd4eda1937ad05e350`). +Every complete Evidence field, including IDs, is identical within each case. +The escape case explicitly selects an 8 MiB read allowance; both revisions +complete under the same default 2 GiB old-space task environment. + +| Completed task | Estimated tokens, both revisions | Elapsed seconds, before → after | CPU seconds, before → after | Peak family RSS MiB, before → after | +| ------------------ | -------------------------------- | ------------------------------- | --------------------------- | ----------------------------------- | +| LLVM public report | 5,851 | 2.071 → 1.970 | 2.378 → 2.328 | 365.3 → 366.0 | +| 2,000,000 escapes | 4,000,703 | 2.425 → 2.374 | 2.457 → 2.289 | 459.3 → 372.1 | + +Values are medians. Escape-case elapsed ranges overlap: 2.120–2.930 seconds +before and 2.324–2.424 after, so no elapsed-time improvement is established. +Its CPU ranges are 2.318–2.521 versus 2.245–2.297 seconds; sampled peak RSS +ranges are 457.4–462.9 versus 371.5–372.9 MiB. The measured dense-escape task +reduces median CPU by 6.8% and sampled memory by 19.0%; those observations are +specific to this case. LLVM ranges overlap and show no general speed gain. +Complete output and estimated tokens are unchanged. + +Tokens use tokenx 1.3.0 estimates for the invocation plus all stdout, not model +billing. These are known CLI tasks with existing inputs, fresh processes and +warm OS caches. Input creation, cold disk, discovery, client parsing, prompts, +schemas, MCP envelopes and retries are outside that comparison. The same +50 ms descendant RSS sampling and waited-child CPU limitations above apply. +Later main integration is not part of these cost measurements. + +## Final public MCP workflow and main integration + +Main `21a20306b4887ff8e6b614b6ae34a387cfbdf1d8` was integrated in +`6d77f265027d34154145e807b1a5c8fd26a1e567`. Its static/documentation checks +and discovery of all 2,339 test files passed. The combined 183-case selection +again reached the in-memory MCP test's 30-second deadline while its other +182 cases passed. Isolated passes did not establish that the setup was robust +under the combined run. + +The MCP test now exercises the compiled production stdio entrypoint through +the existing local-tools fixture, which owns client/transport cleanup and +validates the SDK-advertised schemas. It no longer reconstructs the full source +server/catalog inside the shared test worker. One public workflow retains all +the prior schema, annotation, capacity, register, malformed-UUID and relative-path +assertions; the redundant second connection case was consolidated. No test +deadline was increased. The same combined selection then passed all 182 cases, +including the large escaped-text CLI case and all producer/service regressions. +This is stronger public-path verification, not a claimed runtime speedup from +a before/after test-suite timing comparison. + +## Reference and inline review follow-up + +Commit `174c7496a2f007a1ef0ab5da0a341b2f801277c3` separates a recorded +uint32 lookup from whether it resolves to an object row. An out-of-range or +malformed `faultingThread` reference preserves the lookup, marks coverage +partial and leaves row-zero roles unknown. Image lookups use the same owner; +unresolved image references preserve `image_index` and leave the derived +address unknown. Negative or oversized integer lookups retain their exact +literal in `coverage.type_mismatches[].recorded_integer`. Reservation happens +before retention, and the service counts these literals against the same +integer-text allowance even for custom ports. No BigInt conversion is needed +for rejected indexes. + +The previous comment and guide incorrectly asserted that inline rows share a +subsequent physical row's address. Apple's per-frame `imageOffset` description +and [the pinned LLDB parser](https://github.com/llvm/llvm-project/blob/16dde0db3ce0b9c5eae14178780cc6db1eec7ac8/lldb/examples/python/crashlog.py#L716) +support each row's own base-plus-offset; that parser increments its frame index +for every recorded row. REA therefore keeps its existing address derivation, +leaves a missing offset unknown and corrects roles to use the recorded row +index. The inline flag remains recorded evidence and does not reconstruct a +physical stack. This is source-derived validation; LLDB was not executed. + +Nine source controls fail before this correction: two in-range-but-unresolved +faulting indexes, a reference to a malformed thread row, three rejected integer +spellings, the 19-byte rejected-literal allowance, unresolved image coverage and +the corrected row-role oracle. A separate differing/missing-inline-offset case +already passes before the correction and protects the existing address rule; +it is not evidence of a fixed address bug. All **193 selected checks pass** +afterward, including CLI, filesystem, custom-provider allowance checks and +production stdio MCP. The actual SDK validates retained lookup literals against +the advertised schema. `check:fast`, complete formatting, documentation and +cached build also pass. Both pinned public reports pass the real stdio SDK/Ajv +workflow again (13.510 s elapsed, 14.909 s waited CPU, 907.4 MiB sampled family +RSS); the independent integer/JSON oracle checks the CLI comparison below. + +Six completed alternating CLI tasks compare the prior actual CI tree +`acb837583d8346016fd8b46d4fcf1fdf09fb0b06` with the corrected commit on the +pinned LLDB multithread report. All full Evidence, including IDs, agrees. +Estimated invocation plus full stdout tokens remain **5,851** (tokenx 1.3.0). +Elapsed medians are 2.071 -> 1.969 s (ranges 2.071–2.271 / 1.919–2.069), CPU +2.295 -> 2.278 s (2.140–2.471 / 2.231–2.283), and sampled process-family RSS +366.8 -> 367.0 MiB (348.2–370.9 / 348.9–368.4). CPU/memory ranges overlap; +this small warm-cache sample does not establish a general speed or memory +improvement. Processes are fresh; the OS file cache is warm. Caller input +creation, schemas/discovery, prompts, client parsing and MCP transport are not +included. The same 50 ms family-RSS limitations apply. + +## JSON admission and output consistency follow-up + +Three later review findings were reproduced with 19 failing controls before the +correction (49 other selected checks pass). Broad JavaScript trimming admitted +form-feed, vertical-tab, NBSP and BOM around JSON objects; those are not JSON +whitespace. The IPS adapter now passes the original body to the lossless parser, +and the complete-value boundary permits only space, tab, CR and LF after the +value. Twelve controls cover metadata suffixes and body prefixes/suffixes; a +positive control retains JSON whitespace around both objects. Caller bytes and +artifact identity stay unchanged. + +The shared service no longer accepts a reported faulting-thread index without +an established retained owner or its corresponding reference mismatch. Unknown +or out-of-range recorded indexes remain available with partial coverage; an +unrelated mismatch cannot justify the unresolved lookup. The old empty service +fixture incorrectly asserted faulting thread zero and now records null. This is +output admission, not reinterpretation of provider source JSON. + +Classification is now a named discriminated schema: `classified` requires at +least one match and `unclassified` permits none. The source schema rejects both +contradictions. Production SDK/Ajv checks also test both contradictions and both +valid alternatives against the advertised JSON Schema; an actual stdio call +checks that non-JSON trailing whitespace returns a typed caller-input failure. +These are correctness changes. Prior CLI/performance results retain their exact +pins and are not a fresh measurement of the expanded classification schema or +agent catalog costs. No speed or token reduction is claimed for this follow-up. + +Final executed source pin `38b5a3c44824282b55041389b7cbb4d9c261cc8c` +incorporates main `98ec00f4068bab894639d0d1d83ac0bfc428bcbf`. All **216** +selected checks pass after the runtime rebuild: 52.922 s elapsed, 51.098 CPU s, +1,347.9 MiB sampled peak family RSS. Type/lint, complete formatting and generated +documentation checks pass. Both pinned public LLVM/pycrashreport inputs were +repeated through the actual stdio SDK/Ajv: 20.231 s elapsed, 20.602 CPU s, +934.0 MiB sampled family RSS, with complete coverage. Full Evidence and IDs agree +with the saved pre-correction public-corpus outputs for both valid inputs. +This repeat is a fresh client/server process with primed OS/file caches; it is +not a cold-system measurement. Client/schema work is included in runtime costs; +model/catalog token usage was not measured in this repeat. The family sampler's +shared-page and short-lived-child limitations described above still apply. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 52f3177125..066b11ae0d 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -274,6 +274,13 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_elf_notes", ), }, + { + identity: sources.artifactProviders.APPLE_IPS_PROVIDER, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_apple_ips", + ), + }, { identity: sources.artifactProviders.BYTE_ENTROPY_PROVIDER, contracts: diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 7c5e65eb1b..f26de62a3d 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -450,6 +450,7 @@ const workflowAvailabilityFor = ({ name === "inspect_gpu_code_objects" || name === "inspect_rust_binary" || name === "inspect_python_pyc" || + name === "inspect_apple_ips" || name === "inspect_erlang_beam" || name === "inspect_electron_fuses" ) diff --git a/src/application/binaryDiagnostics/AppleIpsPort.ts b/src/application/binaryDiagnostics/AppleIpsPort.ts new file mode 100644 index 0000000000..56d6b15ff1 --- /dev/null +++ b/src/application/binaryDiagnostics/AppleIpsPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + AppleIps, + InspectAppleIpsInput, +} from "../../domain/native/appleIps.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and Apple .ips decoding, independent of a disassembler session. */ +export interface AppleIpsPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectAppleIpsInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/AppleIpsService.test.ts b/src/application/binaryDiagnostics/AppleIpsService.test.ts new file mode 100644 index 0000000000..da84cdcb29 --- /dev/null +++ b/src/application/binaryDiagnostics/AppleIpsService.test.ts @@ -0,0 +1,275 @@ +import { expect, it } from "vitest"; +import { AppleIpsService } from "./AppleIpsService.js"; +import { APPLE_IPS_PROVIDER } from "../../domain/investigationProviders.js"; +import type { AppleIps } from "../../domain/native/appleIps.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; + +const identity = APPLE_IPS_PROVIDER; +const report: AppleIps = { + artifact: { + path: "/artifacts/report.ips", + sha256: "a".repeat(64), + bytes: 128, + }, + metadata: { + bug_type: "309", + timestamp: null, + os_version: null, + bundle_id: null, + incident_id: null, + name: null, + unlisted_keys: [], + }, + process: { + name: "rea-crash-fixture", + path: null, + pid: null, + parent: null, + cpu_type: null, + translated: null, + os_version: null, + }, + exception: null, + termination: null, + faulting_thread: null, + threads: [], + images: [], + classification: { status: "unclassified", matches: [] }, + coverage: { + status: "complete", + reason: null, + threads: 0, + frames: 0, + images: 0, + registers: 0, + type_mismatches: [], + }, + unlisted_body_keys: [], + limitations: ["Opened Mach-O and dSYM UUID joins remain unobserved."], +}; + +it.each([0, 5])( + "rejects an unresolved faulting thread %s without its mismatch", + async (index) => { + const unresolved: AppleIps = { ...report, faulting_thread: index }; + const service = new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(unresolved)), + }); + const result = await service.inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); + }, +); + +it.each(["body.faultingThread", "body.other"])( + "requires the matching reference diagnostic: %s", + async (path) => { + const unresolved: AppleIps = { + ...report, + faulting_thread: 5, + coverage: { + ...report.coverage, + status: "partial", + reason: "Unresolved reference", + type_mismatches: [ + { path, expected: "index into threads", observed: "out of range" }, + ], + }, + }; + const result = await new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(unresolved)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(path === "body.faultingThread"); + }, +); + +it.each([ + { status: "classified", matches: [] }, + { + status: "unclassified", + matches: [ + { + rule: "watchdog", + fields: [{ name: "termination.code", value: "2343432205" }], + }, + ], + }, +])( + "rejects contradictory provider classification $status", + async (classification) => { + const malformed = structuredClone(report); + Reflect.set(malformed, "classification", classification); + const result = await new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(malformed)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); + }, +); + +it.each(["288", "309 "])( + "rejects unsupported crash kind %s from a custom provider", + async (bugType) => { + const malformed = structuredClone(report); + Reflect.set(malformed.metadata, "bug_type", bugType); + const result = await new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(malformed)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected unsupported provider crash kind"); + expect(result.error._tag).toBe("AnalysisOutputError"); + }, +); + +it.each([null, { caller: "selected IPS request" }])( + "preserves early cancellation cause %j before validation", + async (reason) => { + const result = await new AppleIpsService({ + identity, + inspect: () => { + throw new Error( + "An already cancelled request must not reach the provider", + ); + }, + }).inspect(null, { signal: AbortSignal.abort(reason) }); + if (result.ok) throw new Error("Expected early cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + }, +); + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new AppleIpsService({ + identity, + inspect: () => { + calls += 1; + return Promise.resolve(ok(report)); + }, + }); + const relative = await service.inspect({ path: "report.ips" }); + const extra = await service.inspect({ + path: report.artifact.path, + extra: true, + }); + expect(relative.ok).toBe(false); + expect(extra.ok).toBe(false); + if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError); + if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); + +it("records the selected report as a file subject", async () => { + const result = await new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(report)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "observed", + subject: { format: "file" }, + normalized_result: report, + }); +}); + +it.each(["threads", "images"] as const)( + "rejects %s coverage counts that disagree with retained rows", + async (field) => { + const inconsistent: AppleIps = { + ...report, + coverage: { ...report.coverage, [field]: 1 }, + }; + const result = await new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(inconsistent)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (result.ok) + throw new Error("Expected contradictory provider output to fail"); + expect(result.error._tag).toBe("AnalysisOutputError"); + }, +); + +it.each([ + [43, false], + [44, true], +] as const)( + "checks repeated integer text against %s UTF-16 bytes", + async (maximum, accepted) => { + const value = { decimal: "12345", hex: "0x3039" }; + const repeated: AppleIps = { + ...report, + process: { ...report.process, pid: value }, + termination: { + namespace: null, + code: value, + indicator: null, + reasons: null, + unlisted_keys: [], + }, + }; + const result = await new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(repeated)), + }).inspect({ path: report.artifact.path, max_integer_text_bytes: maximum }); + expect(result.ok).toBe(accepted); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); + }, +); + +it("discards an observation if the caller cancels while the provider completes", async () => { + const controller = new AbortController(); + const service = new AppleIpsService({ + identity, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report)); + }, + }); + const result = await service.inspect( + { path: report.artifact.path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); + +it.each([ + [19, false], + [20, true], +] as const)( + "charges rejected integer literals from custom providers against %s bytes", + async (maximum, accepted) => { + const partial: AppleIps = { + ...report, + coverage: { + ...report.coverage, + status: "partial", + reason: "Unresolved index", + type_mismatches: [ + { + path: "body.faultingThread", + expected: "unsigned 32-bit integer", + observed: "integer out of range", + recorded_integer: "4294967296", + }, + ], + }, + }; + const result = await new AppleIpsService({ + identity, + inspect: () => Promise.resolve(ok(partial)), + }).inspect({ path: report.artifact.path, max_integer_text_bytes: maximum }); + expect(result.ok).toBe(accepted); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); + }, +); diff --git a/src/application/binaryDiagnostics/AppleIpsService.ts b/src/application/binaryDiagnostics/AppleIpsService.ts new file mode 100644 index 0000000000..de4dfb31ba --- /dev/null +++ b/src/application/binaryDiagnostics/AppleIpsService.ts @@ -0,0 +1,140 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { AppleIpsPort } from "./AppleIpsPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + appleIpsSchema, + appleIpsReportIntegerTextBytes, + inspectAppleIpsInputSchema, + type AppleIps, +} from "../../domain/native/appleIps.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_apple_ips"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class AppleIpsService { + constructor(readonly provider: AppleIpsPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + const input = inspectAppleIpsInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const result = await this.provider.inspect(input.data, options); + if (!result.ok) return result; + const report = appleIpsSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + !validReport(report.data, input.data) + ) + return err( + new AnalysisOutputError( + OPERATION, + "Apple IPS provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ), + options?.signal, + ); + } +} + +const validReport = ( + report: AppleIps, + input: InspectAppleIpsInputLike, +): boolean => { + if ( + report.artifact.bytes > input.max_file_bytes || + report.coverage.threads > input.max_threads || + report.coverage.frames > input.max_frames || + report.coverage.images > input.max_images || + report.coverage.registers > input.max_registers || + report.coverage.threads !== report.threads.length || + report.coverage.images !== report.images.length + ) + return false; + const faultingThread = report.faulting_thread; + const mismatches = report.coverage.type_mismatches; + if ( + faultingThread !== null && + (report.threads[faultingThread]?.index !== faultingThread || + mismatches.some( + ({ path }) => path === `body.threads[${String(faultingThread)}]`, + )) && + !mismatches.some(({ path }) => path === "body.faultingThread") + ) + return false; + let frames = 0; + let registers = 0; + for (const thread of report.threads) { + frames += thread.frames.length; + registers += thread.registers.length; + } + return ( + frames === report.coverage.frames && + registers === report.coverage.registers && + appleIpsReportIntegerTextBytes(report) <= input.max_integer_text_bytes && + (report.coverage.status === "complete") === + (report.coverage.type_mismatches.length === 0) + ); +}; + +type InspectAppleIpsInputLike = { + readonly max_file_bytes: number; + readonly max_threads: number; + readonly max_frames: number; + readonly max_images: number; + readonly max_registers: number; + readonly max_integer_text_bytes: number; +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index fb5ecbe6b5..5d9e7472e4 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -5,6 +5,7 @@ import { createRecordedCrashService, createBinaryLayoutService, createElfNotesService, + createAppleIpsService, createPeResourcesService, createPeCertificatesService, createPeImportsService, @@ -22,6 +23,7 @@ import { inspectPeCertificatesInputSchema } from "../domain/native/peCertificate import { inspectPeImportsInputSchema } from "../domain/native/peImports.js"; import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; +import { inspectAppleIpsInputSchema } from "../domain/native/appleIps.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import { localPathStringSchema } from "../domain/localPath.js"; @@ -72,6 +74,50 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectAppleIps, { + description: + "Inspect an Apple .ips crash report without executing a target", + args: z.object({ + path: z.string().describe("Local Apple .ips crash report"), + }), + options: z.object({ + maxFileBytes: inspectAppleIpsInputSchema.shape.max_file_bytes, + maxThreads: inspectAppleIpsInputSchema.shape.max_threads, + maxFrames: inspectAppleIpsInputSchema.shape.max_frames, + maxImages: inspectAppleIpsInputSchema.shape.max_images, + maxRegisters: inspectAppleIpsInputSchema.shape.max_registers, + maxIntegerTextBytes: + inspectAppleIpsInputSchema.shape.max_integer_text_bytes, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const ips = createAppleIpsService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectAppleIps, + async () => + finishAnalysisCommand( + () => + ips.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_threads: options.maxThreads, + max_frames: options.maxFrames, + max_images: options.maxImages, + max_registers: options.maxRegisters, + max_integer_text_bytes: options.maxIntegerTextBytes, + }, + { signal }, + ), + ips, + "rea-apple-ips", + "inspect_apple_ips", + signal, + ), + ); + }), + }); const entropy = createByteEntropyService(); cli.command(CLI_COMMANDS.inspectByteEntropy, { description: diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 7a577663eb..c898e4f6ad 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -74,6 +74,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectPeImports: "inspect-pe-imports", inspectNativeExports: "inspect-native-exports", inspectElfNotes: "inspect-elf-notes", + inspectAppleIps: "inspect-apple-ips", inspectAnalysisView: "inspect-analysis-view", listFridaDevices: "list-frida-devices", listFridaProcesses: "list-frida-processes", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index df34d4ee53..12e933aee1 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -12,6 +12,8 @@ import { PeImportsService } from "../application/binaryDiagnostics/PeImportsServ import { PeImportsProvider } from "../native/pe/PeImportsProvider.js"; import { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import { ElfNotesProvider } from "../native/elf/ElfNotesProvider.js"; +import { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsService.js"; +import { AppleIpsProvider } from "../native/apple/AppleIpsProvider.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { NativeExportsProvider } from "../native/exports/NativeExportsProvider.js"; @@ -37,6 +39,10 @@ export const createNativeExportsService = (): NativeExportsService => export const createElfNotesService = (): ElfNotesService => new ElfNotesService(new ElfNotesProvider()); +/** Portable Apple .ips inspection with no engine, Xcode or target acquisition. */ +export const createAppleIpsService = (): AppleIpsService => + new AppleIpsService(new AppleIpsProvider()); + /** Compose the offline adapter without import-time I/O or dependency acquisition. */ export const createBinaryLayoutService = ( environment: Readonly = process.env, diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 646b16f0b4..f48581ba9d 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -40,6 +40,10 @@ import { inspectElfNotesInputSchema, elfNotesSchema, } from "../../domain/native/elfNotes.js"; +import { + inspectAppleIpsInputSchema, + appleIpsSchema, +} from "../../domain/native/appleIps.js"; /** Caller-selected delivery of completed binary layout Evidence. */ export const inspectBinaryLayoutRequestSchema = @@ -73,6 +77,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_apple_ips", + ...toolContractMetadata("inspect_apple_ips"), + kind: "native-provider", + description: + "Inspect one explicit local Apple .ips crash report without executing a target, launching atos, or opening a disassembler. Parses the first-line metadata object and, when bug_type is the string 309, the crash body: process identity, exception and termination facts, threads, usedImages, lossless integer registers, and deterministic classification from documented SIGKILL, EXC_GUARD, pointer-authentication and dyld strings. Other bug_type values, including 288 stackshots, return no crash result. A missing or truncated body fails without partial success. Opened Mach-O and dSYM UUID joins remain unobserved. Portable on supported Node hosts; no Xcode or pwntools required.", + inputSchema: inspectAppleIpsInputSchema, + outputSchema: evidenceResultOf(appleIpsSchema), + examples: [ + { + title: "Inspect an Apple .ips crash report", + input: { path: "/artifacts/app.ips" }, + }, + ], + }, { name: "inspect_byte_entropy", ...toolContractMetadata("inspect_byte_entropy"), diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 2761ed5cd3..c56796bb45 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -29,6 +29,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_pe_resources: "states that it does not execute the image", inspect_native_exports: "reads the export table without executing it", inspect_elf_notes: "states that it does not execute the image", + inspect_apple_ips: "states that it does not execute a target", inspect_desktop_application: "states that directory triage performs no target execution", inspect_pe_certificates: "states that it does not execute the image", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index fc5ee54690..ac0c2a5ac2 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -261,6 +261,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_pe_imports: evidence, inspect_native_exports: evidence, inspect_elf_notes: evidence, + inspect_apple_ips: evidence, inspect_analysis_view: evidence, inspect_firmware_regions: effects({ mutatesSession: true, diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index a0e3c23126..a480c515d6 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -47,6 +47,13 @@ export const ELF_NOTES_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free Apple .ips crash-report decoding. */ +export const APPLE_IPS_PROVIDER = Object.freeze({ + id: "rea-apple-ips", + name: "REA Apple IPS parser", + version: "1", +} as const); + /** Provider identity for execution-free Shannon byte-entropy profiling. */ export const BYTE_ENTROPY_PROVIDER = Object.freeze({ id: "byte-entropy", diff --git a/src/domain/native/appleIps.test.ts b/src/domain/native/appleIps.test.ts new file mode 100644 index 0000000000..aab4c4d692 --- /dev/null +++ b/src/domain/native/appleIps.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from "vitest"; +import { appleIpsSchema, inspectAppleIpsInputSchema } from "./appleIps.js"; + +it.each([ + ["classified", 0, false], + ["classified", 1, true], + ["unclassified", 0, true], + ["unclassified", 1, false], +] as const)( + "advertises classification %s with %s matches consistently", + (status, count, accepted) => { + const matches = Array.from({ length: count }, () => ({ + rule: "watchdog", + fields: [{ name: "termination.code", value: "2343432205" }], + })); + expect( + appleIpsSchema.shape.classification.safeParse({ status, matches }) + .success, + ).toBe(accepted); + }, +); + +it("rejects extra caller arguments and admits an empty crash inventory", () => { + expect( + inspectAppleIpsInputSchema.safeParse({ + path: "/artifacts/report.ips", + extra: true, + }).success, + ).toBe(false); + const report = appleIpsSchema.parse({ + artifact: { + path: "/artifacts/report.ips", + sha256: "a".repeat(64), + bytes: 128, + }, + metadata: { + bug_type: "309", + timestamp: null, + os_version: null, + bundle_id: null, + incident_id: null, + name: null, + unlisted_keys: [], + }, + process: { + name: null, + path: null, + pid: null, + parent: null, + cpu_type: null, + translated: null, + os_version: null, + }, + exception: null, + termination: null, + faulting_thread: null, + threads: [], + images: [], + classification: { status: "unclassified", matches: [] }, + coverage: { + status: "complete", + reason: null, + threads: 0, + frames: 0, + images: 0, + registers: 0, + type_mismatches: [], + }, + unlisted_body_keys: [], + limitations: ["Opened Mach-O and dSYM UUID joins remain unobserved."], + }); + expect(report.metadata.bug_type).toBe("309"); + expect(report.classification.status).toBe("unclassified"); +}); diff --git a/src/domain/native/appleIps.ts b/src/domain/native/appleIps.ts new file mode 100644 index 0000000000..01cd609e57 --- /dev/null +++ b/src/domain/native/appleIps.ts @@ -0,0 +1,328 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const recordedIntegerSchema = z.strictObject({ + decimal: z.string().regex(/^-?(?:0|[1-9][0-9]*)$/u), + hex: z + .string() + .regex(/^0x[0-9a-f]+$/u) + .nullable(), +}); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_APPLE_IPS_FILE_BYTES = 16777216; +/** Hard ceiling on threads examined in one report. */ +export const MAX_INSPECT_APPLE_IPS_THREADS = 4096; +/** Hard ceiling on frames across every thread. */ +export const MAX_INSPECT_APPLE_IPS_FRAMES = 65536; +/** Hard ceiling on usedImages rows. */ +export const MAX_INSPECT_APPLE_IPS_IMAGES = 4096; +/** Hard ceiling on threadState register slots across every thread. */ +export const MAX_INSPECT_APPLE_IPS_REGISTERS = 262144; +/** Conservative retained decimal/hex text capacity, measured as UTF-16 bytes. */ +export const DEFAULT_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES = 8 * 1024 * 1024; +export const MAX_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES = 64 * 1024 * 1024; + +export const APPLE_IPS_IMAGE_SOURCES = [ + "P", + "S", + "C", + "K", + "U", + "T", + "A", +] as const; +export type AppleIpsImageSource = (typeof APPLE_IPS_IMAGE_SOURCES)[number]; + +/** Documented meaning of usedImages source letters. */ +export const APPLE_IPS_IMAGE_SOURCE_MEANING: Readonly< + Record +> = { + P: "process", + S: "shared cache", + C: "shared cache library", + K: "kernel", + U: "kernel cache", + T: "kernel text exec", + A: "absolute", +}; + +/** Complete bounded inspection of one Apple .ips crash report. */ +export const inspectAppleIpsInputSchema = z.strictObject({ + path: localPathStringSchema.describe( + "Absolute filesystem path to an Apple .ips crash report", + ), + max_file_bytes: z + .number() + .int() + .min(2) + .max(MAX_INSPECT_APPLE_IPS_FILE_BYTES) + .default(1048576) + .describe("Maximum stable artifact bytes to read (default 1 MiB)"), + max_threads: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_APPLE_IPS_THREADS) + .default(256) + .describe( + "Maximum threads to examine; exceeding the budget fails without partial success", + ), + max_frames: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_APPLE_IPS_FRAMES) + .default(8192) + .describe( + "Maximum frames across every thread; exceeding the budget fails without partial success", + ), + max_images: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_APPLE_IPS_IMAGES) + .default(1024) + .describe( + "Maximum usedImages rows to examine; exceeding the budget fails without partial success", + ), + max_registers: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_APPLE_IPS_REGISTERS) + .default(16384) + .describe( + "Maximum threadState register slots across every thread; exceeding the budget fails without partial success", + ), + max_integer_text_bytes: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES) + .default(DEFAULT_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES) + .describe( + "Conservative UTF-16 byte allowance for all retained decimal/hex integer text, including derived addresses (default 8 MiB, maximum 64 MiB); checked before conversion and allocation", + ), +}); +export type InspectAppleIpsInput = z.infer; + +const uuidSchema = z + .string() + .regex(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u); + +const metadataSchema = z.strictObject({ + bug_type: z.literal("309"), + timestamp: z.string().min(1).nullable(), + os_version: z.string().min(1).nullable(), + bundle_id: z.string().min(1).nullable(), + incident_id: z.string().min(1).nullable(), + name: z.string().min(1).nullable(), + unlisted_keys: z.array(z.string().min(1)), +}); + +const exceptionSchema = z + .strictObject({ + type: z.string().min(1).nullable(), + signal: z.string().min(1).nullable(), + subtype: z.string().min(1).nullable(), + codes: z.string().min(1).nullable(), + message: z.string().min(1).nullable(), + unlisted_keys: z.array(z.string().min(1)), + }) + .nullable(); + +const terminationSchema = z + .strictObject({ + namespace: z.string().min(1).nullable(), + code: recordedIntegerSchema.nullable(), + indicator: z.string().min(1).nullable(), + reasons: z.array(z.string().min(1)).nullable(), + unlisted_keys: z.array(z.string().min(1)), + }) + .nullable(); + +const registerSchema = z.strictObject({ + name: z.string().min(1), + value: recordedIntegerSchema, + description: z.string().min(1).nullable(), +}); + +const frameSchema = z.strictObject({ + thread_index: uint, + frame_index: uint, + image_index: uint + .nullable() + .describe( + "Recorded index into images; image metadata and its uncertainty belong to that row.", + ), + image_offset: recordedIntegerSchema.nullable(), + runtime_address: recordedIntegerSchema.nullable(), + role: z.enum([ + "faulting_instruction", + "current_instruction", + "return_address", + "unknown", + ]), + inline: z.boolean().nullable(), + recorded_symbol: z.string().min(1).nullable(), +}); + +const threadSchema = z.strictObject({ + index: uint, + id: recordedIntegerSchema.nullable(), + triggered: z.boolean().nullable(), + name: z.string().min(1).nullable(), + queue: z.string().min(1).nullable(), + frames: z.array(frameSchema), + state_flavor: z.string().min(1).nullable(), + registers: z.array(registerSchema), +}); + +const imageSchema = z.strictObject({ + index: uint, + source: z.string().min(1).nullable(), + source_meaning: z.string().min(1).nullable(), + arch: z.string().min(1).nullable(), + base: recordedIntegerSchema.nullable(), + size: recordedIntegerSchema.nullable(), + uuid: uuidSchema.nullable(), + uuid_unknown: z.boolean(), + path: z.string().min(1).nullable(), +}); + +const classificationMatchSchema = z.strictObject({ + rule: z.enum([ + "watchdog", + "dead10cc", + "thermal", + "guarded_file_descriptor", + "guarded_mach_port", + "possible_pointer_authentication_failure", + "dyld_missing_library", + ]), + fields: z.array( + z.strictObject({ + name: z.string().min(1), + value: z.string().min(1), + }), + ), +}); + +const typeMismatchSchema = z.strictObject({ + path: z.string().min(1), + expected: z.string().min(1), + observed: z.string().min(1), + recorded_value: z + .string() + .exactOptional() + .describe( + "Exact source spelling when a string fails its documented format", + ), + recorded_integer: z + .string() + .regex(/^-?(?:0|[1-9][0-9]*)$/u) + .exactOptional() + .describe( + "Exact decimal literal of a rejected integer lookup; charged to the integer text allowance", + ), +}); + +const coverageSchema = z.strictObject({ + status: z.enum(["complete", "partial"]), + reason: z.string().min(1).nullable(), + threads: uint, + frames: uint, + images: uint, + registers: uint, + type_mismatches: z.array(typeMismatchSchema), +}); + +const osVersionSchema = z + .discriminatedUnion("form", [ + z.strictObject({ form: z.literal("text"), text: z.string().min(1) }), + z.strictObject({ + form: z.literal("structured"), + train: z.string().min(1).nullable(), + build: z.string().min(1).nullable(), + release_type: z.string().min(1).nullable(), + is_embedded: z.boolean().nullable(), + unlisted_keys: z.array(z.string().min(1)), + }), + ]) + .nullable(); + +/** Static Apple .ips crash-report inventory, read without execution. */ +export const appleIpsSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, + bytes: uint, + }), + metadata: metadataSchema, + process: z.strictObject({ + name: z.string().min(1).nullable(), + path: z.string().min(1).nullable(), + pid: recordedIntegerSchema.nullable(), + parent: z.string().min(1).nullable(), + cpu_type: z.string().min(1).nullable(), + translated: z.boolean().nullable(), + os_version: osVersionSchema, + }), + exception: exceptionSchema, + termination: terminationSchema, + faulting_thread: uint.nullable(), + threads: z.array(threadSchema), + images: z.array(imageSchema), + classification: z.discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("classified"), + matches: z.array(classificationMatchSchema).min(1), + }), + z.strictObject({ + status: z.literal("unclassified"), + matches: z.array(classificationMatchSchema).max(0), + }), + ]), + coverage: coverageSchema, + unlisted_body_keys: z.array(z.string().min(1)), + limitations: z.array(z.string().min(1)), +}); +export type AppleIps = z.infer; +export type AppleIpsThread = z.infer; +export type AppleIpsImage = z.infer; +export type AppleIpsFrame = z.infer; +export type AppleIpsTypeMismatch = z.infer; +export type RecordedInteger = z.infer; + +/** Count each appearance, including repeated objects serialized at different paths. */ +export const appleIpsIntegerTextBytes = ( + value: RecordedInteger | null, +): number => + value === null ? 0 : 2 * (value.decimal.length + (value.hex?.length ?? 0)); + +/** The named report fields own which observed and derived integers are retained. */ +export const appleIpsReportIntegerTextBytes = (report: AppleIps): number => { + let bytes = + appleIpsIntegerTextBytes(report.process.pid) + + appleIpsIntegerTextBytes(report.termination?.code ?? null); + for (const image of report.images) + bytes += + appleIpsIntegerTextBytes(image.base) + + appleIpsIntegerTextBytes(image.size); + for (const thread of report.threads) { + bytes += appleIpsIntegerTextBytes(thread.id); + for (const register of thread.registers) + bytes += appleIpsIntegerTextBytes(register.value); + for (const frame of thread.frames) + bytes += + appleIpsIntegerTextBytes(frame.image_offset) + + appleIpsIntegerTextBytes(frame.runtime_address); + } + for (const mismatch of report.coverage.type_mismatches) + bytes += 2 * (mismatch.recorded_integer?.length ?? 0); + return bytes; +}; diff --git a/src/native/apple/AppleIps.errors.ts b/src/native/apple/AppleIps.errors.ts new file mode 100644 index 0000000000..a2c2ea2c19 --- /dev/null +++ b/src/native/apple/AppleIps.errors.ts @@ -0,0 +1,36 @@ +/** Distinguish corrupt recognized IPS files from unsupported bug types. */ +export class AppleIpsFormatFailure extends Error { + constructor( + readonly kind: "malformed" | "unsupported", + message: string, + ) { + super(message); + this.name = "AppleIpsFormatFailure"; + } +} + +/** Caller-selected budgets that bound IPS row expansion. */ +export type AppleIpsSelectedBoundary = + | "threads" + | "frames" + | "images" + | "registers" + | "integer_text"; + +/** Fixed format ceilings that no caller argument raises. */ +export type AppleIpsFixedBoundary = + | "termination_reasons" + | "unlisted_keys" + | "nesting"; + +/** Resource boundary exceeded while walking an IPS crash report. */ +export class AppleIpsResourceFailure extends Error { + constructor( + readonly boundary: AppleIpsSelectedBoundary | AppleIpsFixedBoundary, + readonly maximum: number, + message: string, + ) { + super(message); + this.name = "AppleIpsResourceFailure"; + } +} diff --git a/src/native/apple/AppleIpsClassification.ts b/src/native/apple/AppleIpsClassification.ts new file mode 100644 index 0000000000..c9957b0581 --- /dev/null +++ b/src/native/apple/AppleIpsClassification.ts @@ -0,0 +1,94 @@ +import type { AppleIps } from "../../domain/native/appleIps.js"; + +type Match = AppleIps["classification"]["matches"][number]; + +/** SIGKILL codes are compared once, on the canonical hexadecimal form. */ +const TERMINATION_CODE_RULES: readonly { + readonly hex: string; + readonly rule: Match["rule"]; +}[] = [ + { hex: "0x8badf00d", rule: "watchdog" }, + { hex: "0xdead10cc", rule: "dead10cc" }, + { hex: "0xc00010ff", rule: "thermal" }, +]; + +const GUARD_RULES: readonly { + readonly marker: string; + readonly rule: Match["rule"]; +}[] = [ + { marker: "GUARD_TYPE_FD", rule: "guarded_file_descriptor" }, + { marker: "GUARD_TYPE_MACH_PORT", rule: "guarded_mach_port" }, +]; + +const terminationCodeMatches = ( + code: NonNullable["code"], +): Match[] => + code === null + ? [] + : TERMINATION_CODE_RULES.filter(({ hex }) => code.hex === hex).map( + ({ rule, hex }) => ({ + rule, + fields: [ + { name: "termination.code", value: code.decimal }, + { name: "termination.code_hex", value: hex }, + ], + }), + ); + +const exceptionMatches = (exception: AppleIps["exception"]): Match[] => { + const type = exception?.type ?? null; + const subtype = exception?.subtype ?? null; + if (subtype === null) return []; + const typeField = + type === null ? [] : [{ name: "exception.type", value: type }]; + const fields = [...typeField, { name: "exception.subtype", value: subtype }]; + const matches: Match[] = + type === "EXC_GUARD" + ? GUARD_RULES.filter(({ marker }) => subtype.includes(marker)).map( + ({ rule }) => ({ rule, fields }), + ) + : []; + if (subtype.endsWith("(possible pointer authentication failure)")) + matches.push({ rule: "possible_pointer_authentication_failure", fields }); + return matches; +}; + +const dyldMatches = (termination: AppleIps["termination"]): Match[] => { + if (termination?.namespace !== "DYLD") return []; + const { indicator } = termination; + const missingLibrary = (termination.reasons ?? []).find((reason) => + reason.includes("Library not loaded"), + ); + if (indicator !== "Library missing" && missingLibrary === undefined) + return []; + return [ + { + rule: "dyld_missing_library", + fields: [ + { name: "termination.namespace", value: "DYLD" }, + ...(indicator === null + ? [] + : [{ name: "termination.indicator", value: indicator }]), + ...(missingLibrary === undefined + ? [] + : [{ name: "termination.reasons", value: missingLibrary }]), + ], + }, + ]; +}; + +/** Match only documented crash classes against already-parsed report facts. */ +export const classify = ( + exception: AppleIps["exception"], + termination: AppleIps["termination"], +): AppleIps["classification"] => { + const matches = [ + ...terminationCodeMatches(termination?.code ?? null), + ...exceptionMatches(exception), + ...dyldMatches(termination), + ]; + return { + status: matches.length === 0 ? "unclassified" : "classified", + matches, + }; +}; diff --git a/src/native/apple/AppleIpsFieldReader.ts b/src/native/apple/AppleIpsFieldReader.ts new file mode 100644 index 0000000000..87ed2e99d3 --- /dev/null +++ b/src/native/apple/AppleIpsFieldReader.ts @@ -0,0 +1,187 @@ +import { + appleIpsIntegerTextBytes, + type AppleIpsTypeMismatch, + type RecordedInteger, +} from "../../domain/native/appleIps.js"; +import { AppleIpsResourceFailure } from "./AppleIps.errors.js"; +import type { LosslessJson } from "./losslessJson.js"; + +export type Members = Readonly>; + +const UINT32_MAX = 0xffffffff; + +const observedType = (value: LosslessJson | undefined): string => + value === undefined + ? "absent" + : value.kind === "omitted" + ? value.type + : value.kind; + +/** + * Typed field access that records every present value whose JSON type differs + * from the documented one, so wrong-type input never reads as observed absence. + * JSON `null` and a missing member both mean "not recorded". + */ +export class FieldReader { + readonly mismatches: AppleIpsTypeMismatch[] = []; + readonly #integers = new WeakMap(); + #integerTextBytes = 0; + + constructor(readonly maximumIntegerTextBytes: number) {} + + /** Reserve a safe decimal/hex upper bound before BigInt or string expansion. */ + #reserve(decimalCharacters: number, hexadecimal: boolean): number { + // log16(10) < 5/6. Include the 0x prefix; signs only enlarge this bound. + const hexCharacters = hexadecimal + ? Math.ceil((decimalCharacters * 5) / 6) + 2 + : 0; + const bytes = 2 * (decimalCharacters + hexCharacters); + if (bytes > this.maximumIntegerTextBytes - this.#integerTextBytes) + throw new AppleIpsResourceFailure( + "integer_text", + this.maximumIntegerTextBytes, + `IPS integer text reservation exceeds the ${String(this.maximumIntegerTextBytes)}-byte UTF-16 allowance`, + ); + this.#integerTextBytes += bytes; + return bytes; + } + + #retain(value: bigint, decimal: string, reserved: number): RecordedInteger { + const recorded = { + decimal, + hex: value < 0n ? null : `0x${value.toString(16)}`, + }; + this.#integerTextBytes -= reserved - appleIpsIntegerTextBytes(recorded); + return recorded; + } + + recordedInteger(decimal: string): RecordedInteger { + const hexadecimal = !decimal.startsWith("-") || decimal === "-0"; + const reserved = this.#reserve(decimal.length, hexadecimal); + const value = BigInt(decimal); + const recorded = this.#retain(value, decimal, reserved); + this.#integers.set(recorded, value); + return recorded; + } + + add( + left: RecordedInteger | null, + right: RecordedInteger | null, + ): RecordedInteger | null { + if (left === null || right === null) return null; + const a = this.#integers.get(left); + const b = this.#integers.get(right); + if (a === undefined || b === undefined) + throw new Error("IPS integer facts do not belong to this field reader"); + const reserved = this.#reserve( + Math.max(left.decimal.length, right.decimal.length) + 1, + true, + ); + const value = a + b; + return this.#retain(value, value.toString(), reserved); + } + + mismatch(path: string, expected: string, value: LosslessJson | undefined) { + this.mismatches.push({ path, expected, observed: observedType(value) }); + } + + text(value: LosslessJson | undefined, path: string): string | null { + if (value === undefined || value.kind === "null") return null; + if (value.kind === "string") + return value.value.length === 0 ? null : value.value; + this.mismatch(path, "string", value); + return null; + } + + integer( + value: LosslessJson | undefined, + path: string, + ): RecordedInteger | null { + if (value === undefined || value.kind === "null") return null; + if (value.kind === "integer") return this.recordedInteger(value.decimal); + this.mismatch(path, "integer", value); + return null; + } + + index(value: LosslessJson | undefined, path: string): number | null { + if (value?.kind === "integer") { + // Every uint32 fits in ten decimal digits, safely below Number's exact + // integer limit. Oversized source indices need no BigInt allocation. + const index = value.decimal.length <= 10 ? Number(value.decimal) : NaN; + if (Number.isInteger(index) && index >= 0 && index <= UINT32_MAX) + return index === 0 ? 0 : index; + this.#reserve(value.decimal.length, false); + this.mismatches.push({ + path, + expected: "unsigned 32-bit integer", + observed: "integer out of range", + recorded_integer: value.decimal, + }); + return null; + } + if (value === undefined || value.kind === "null") return null; + this.mismatch(path, "unsigned 32-bit integer", value); + return null; + } + + /** Keep a recorded lookup separate from whether it resolves to an object. */ + resolveIndex( + index: number | null, + rows: readonly LosslessJson[], + path: string, + ): number | null { + if (index === null) return null; + const row = rows[index]; + if (row?.kind === "object") return index; + const decimal = String(index); + this.#reserve(decimal.length, false); + this.mismatches.push({ + path, + expected: "index of a recorded object row", + observed: + row === undefined + ? "integer reference out of range" + : "integer references a non-object row", + recorded_integer: decimal, + }); + return null; + } + + boolean(value: LosslessJson | undefined, path: string): boolean | null { + if (value === undefined || value.kind === "null") return null; + if (value.kind === "boolean") return value.value; + this.mismatch(path, "boolean", value); + return null; + } + + object( + value: LosslessJson | undefined, + path: string, + ): { members: Members; unlisted: readonly string[] } | null { + if (value === undefined || value.kind === "null") return null; + if (value.kind === "object") return value; + this.mismatch(path, "object", value); + return null; + } + + /** An array element is always recorded, so `null` there is a mismatch too. */ + row(value: LosslessJson, path: string): Members { + if (value.kind === "object") return value.members; + this.mismatch(path, "object", value); + return {}; + } + + array( + value: LosslessJson | undefined, + path: string, + required: boolean, + ): readonly LosslessJson[] | null { + if (value?.kind === "array") return value.items; + if (value === undefined || value.kind === "null") { + if (required) this.mismatch(path, "array", value); + return null; + } + this.mismatch(path, "array", value); + return null; + } +} diff --git a/src/native/apple/AppleIpsJsonShape.ts b/src/native/apple/AppleIpsJsonShape.ts new file mode 100644 index 0000000000..5ae8b57d52 --- /dev/null +++ b/src/native/apple/AppleIpsJsonShape.ts @@ -0,0 +1,232 @@ +import type { InspectAppleIpsInput } from "../../domain/native/appleIps.js"; +import { + AppleIpsFormatFailure, + AppleIpsResourceFailure, + type AppleIpsFixedBoundary, +} from "./AppleIps.errors.js"; +import { + parseLosslessJson, + LosslessJsonFailure, + type JsonShape, + type LosslessJson, +} from "./losslessJson.js"; + +/** IPS JSON is at most a handful of levels deep; deeper input is not a crash report shape. */ +const MAX_IPS_JSON_DEPTH = 64; +/** Unlisted member names retained per documented object. */ +const MAX_UNLISTED_KEYS = 1024; +/** termination.reasons entries; real reports carry a few. */ +const MAX_TERMINATION_REASONS = 256; + +const SCALAR: JsonShape = { kind: "scalar" }; + +const scalarMembers = ( + ...names: readonly string[] +): Record => + Object.fromEntries(names.map((name) => [name, SCALAR])); + +const fixedAllowance = + (boundary: AppleIpsFixedBoundary, maximum: number, noun: string) => + (count: number) => { + if (count > maximum) + throw new AppleIpsResourceFailure( + boundary, + maximum, + `Crash report lists more than ${String(maximum)} ${noun}`, + ); + }; + +const unlistedKeys = fixedAllowance( + "unlisted_keys", + MAX_UNLISTED_KEYS, + "unlisted keys in one object", +); + +/** One running count per selected row collection, checked before each row. */ +const selectedAllowances = (input: InspectAppleIpsInput) => { + const counts = { threads: 0, frames: 0, images: 0, registers: 0 }; + const take = + (boundary: keyof typeof counts, maximum: number, noun: string) => () => { + counts[boundary] += 1; + if (counts[boundary] > maximum) + throw new AppleIpsResourceFailure( + boundary, + maximum, + `Crash report lists more than ${String(maximum)} ${noun}, above the selected ${String(maximum)}-entry ${boundary} allowance`, + ); + }; + return { + threads: take("threads", input.max_threads, "threads"), + frames: take("frames", input.max_frames, "frames"), + images: take("images", input.max_images, "usedImages rows"), + registers: take( + "registers", + input.max_registers, + "threadState register slots", + ), + }; +}; + +const threadShape = ( + take: ReturnType, +): JsonShape => { + const register: JsonShape = { + kind: "object", + members: scalarMembers("value", "description"), + }; + return { + kind: "object", + members: { + ...scalarMembers("id", "triggered", "name", "queue"), + frames: { + kind: "array", + items: { + kind: "object", + members: scalarMembers( + "imageIndex", + "imageOffset", + "symbol", + "inline", + ), + }, + beforeItem: take.frames, + }, + threadState: { + kind: "object", + members: { + flavor: SCALAR, + x: { kind: "array", items: register, beforeItem: take.registers }, + }, + rest: register, + beforeRest: take.registers, + }, + }, + }; +}; + +const terminationShape = (): JsonShape => { + const reasonLimit = fixedAllowance( + "termination_reasons", + MAX_TERMINATION_REASONS, + "termination reasons", + ); + let reasons = 0; + return { + kind: "object", + members: { + ...scalarMembers("namespace", "code", "indicator"), + reasons: { + kind: "array", + items: SCALAR, + beforeItem: () => { + reasons += 1; + reasonLimit(reasons); + }, + }, + }, + beforeUnlisted: unlistedKeys, + }; +}; + +/** + * Projection of the documented IPS crash body. Budget callbacks fire before + * each row is parsed, so selected allowances bound allocation rather than + * output, and members the report does not interpret are never materialized. + */ +export const ipsBodyShape = (input: InspectAppleIpsInput): JsonShape => { + const take = selectedAllowances(input); + return { + kind: "object", + members: { + ...scalarMembers( + "procName", + "procPath", + "pid", + "parentProc", + "cpuType", + "translated", + "faultingThread", + ), + osVersion: { + kind: "object", + members: scalarMembers("train", "build", "releaseType", "isEmbedded"), + beforeUnlisted: unlistedKeys, + }, + exception: { + kind: "object", + members: scalarMembers("type", "signal", "subtype", "codes", "message"), + beforeUnlisted: unlistedKeys, + }, + termination: terminationShape(), + threads: { + kind: "array", + items: threadShape(take), + beforeItem: take.threads, + }, + usedImages: { + kind: "array", + items: { + kind: "object", + members: scalarMembers( + "source", + "arch", + "base", + "size", + "uuid", + "path", + ), + }, + beforeItem: take.images, + }, + }, + beforeUnlisted: unlistedKeys, + }; +}; + +/** Projection of the first-line IPS metadata object. */ +export const IPS_METADATA_SHAPE: JsonShape = { + kind: "object", + members: scalarMembers( + "bug_type", + "timestamp", + "os_version", + "bundleID", + "incident_id", + "name", + ), + beforeUnlisted: unlistedKeys, +}; + +/** Parse one complete JSON value under a projection, mapping failures to IPS errors. */ +export const consumeJson = ( + text: string, + label: string, + shape: JsonShape, +): LosslessJson => { + try { + const parsed = parseLosslessJson(text, 0, shape, { + maxDepth: MAX_IPS_JSON_DEPTH, + }); + if (!/^[\u0020\u0009\u000a\u000d]*$/u.test(text.slice(parsed.end))) + throw new AppleIpsFormatFailure( + "malformed", + `${label} has trailing JSON text after the first complete value`, + ); + return parsed.value; + } catch (cause) { + if ( + cause instanceof AppleIpsFormatFailure || + cause instanceof AppleIpsResourceFailure + ) + throw cause; + if (cause instanceof LosslessJsonFailure && cause.reason === "depth") + throw new AppleIpsResourceFailure( + "nesting", + cause.maximum ?? MAX_IPS_JSON_DEPTH, + `${label}: ${cause.message}`, + ); + const message = + cause instanceof LosslessJsonFailure ? cause.message : String(cause); + throw new AppleIpsFormatFailure("malformed", `${label}: ${message}`); + } +}; diff --git a/src/native/apple/AppleIpsParser.test.ts b/src/native/apple/AppleIpsParser.test.ts new file mode 100644 index 0000000000..487b5cd573 --- /dev/null +++ b/src/native/apple/AppleIpsParser.test.ts @@ -0,0 +1,672 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { inspectAppleIpsInputSchema } from "../../domain/native/appleIps.js"; +import { + appleIpsBytes, + appleIpsMetadataLine, + appleIpsReport, +} from "../../../tests/fixtures/binaryDiagnostics/appleIps.js"; +import { + AppleIpsFormatFailure, + AppleIpsResourceFailure, +} from "./AppleIps.errors.js"; +import { parseAppleIps } from "./AppleIpsParser.js"; + +const input = inspectAppleIpsInputSchema.parse({ + path: "/artifacts/report.ips", +}); + +const artifact = (bytes: Buffer) => ({ + path: "/artifacts/report.ips", + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, +}); + +it.each( + ["\f", "\v", "\u00a0", "\ufeff"].flatMap((character) => + ["metadata suffix", "body prefix", "body suffix"].map((position) => ({ + character, + position, + })), + ), +)( + "rejects non-JSON whitespace at $position: $character", + ({ character, position }) => { + const metadata = appleIpsMetadataLine(); + const body = '{"threads":[],"usedImages":[]}'; + const bytes = Buffer.from( + position === "metadata suffix" + ? `${metadata}${character}\n${body}` + : position === "body prefix" + ? `${metadata}\n${character}${body}` + : `${metadata}\n${body}${character}`, + ); + expect(() => parseAppleIps(bytes, artifact(bytes), input)).toThrow( + AppleIpsFormatFailure, + ); + }, +); + +it("accepts only JSON whitespace around each object", () => { + const bytes = Buffer.from( + ` \t${appleIpsMetadataLine()} \t\r\n \t\r\n{"threads":[],"usedImages":[]} \t\r\n`, + ); + expect(parseAppleIps(bytes, artifact(bytes), input).coverage.status).toBe( + "complete", + ); +}); + +it.each([ + "not-a-uuid", + "Z".repeat(32), + `A-${"A".repeat(31)}`, + ` ${"A".repeat(32)}`, +])("retains malformed UUID spelling %s in partial coverage", (recorded) => { + const parsed = parse( + withBody( + JSON.stringify({ + threads: [], + usedImages: [{ uuid: recorded, arch: "arm64" }], + }), + ), + ); + expect(parsed.images[0]).toMatchObject({ uuid: null, uuid_unknown: true }); + expect(parsed.coverage.status).toBe("partial"); + expect(parsed.coverage.type_mismatches).toEqual([ + { + path: "body.usedImages[0].uuid", + expected: "32 hexadecimal UUID digits with optional canonical hyphens", + observed: "malformed UUID string", + recorded_value: recorded, + }, + ]); +}); + +const integerAmplificationBytes = (): Buffer => { + const base = `1${"0".repeat(20000)}`; + const frames = Array.from( + { length: 512 }, + () => '{"imageIndex":0,"imageOffset":1}', + ).join(","); + return Buffer.from( + `${appleIpsMetadataLine()}\n{"faultingThread":0,"threads":[{"frames":[${frames}]}],"usedImages":[{"base":${base},"size":4096,"arch":"arm64"}]}`, + ); +}; + +it("retains image metadata once while frames refer to its owner", () => { + const arch = "unknown_arch_" + "x".repeat(20000); + const uuid = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"; + const frames = Array.from( + { length: 512 }, + () => '{"imageIndex":0,"imageOffset":1}', + ).join(","); + const bytes = Buffer.from( + `${appleIpsMetadataLine()}\n{"faultingThread":0,"threads":[{"frames":[${frames}]}],"usedImages":[{"base":4294967296,"size":4096,"arch":${JSON.stringify(arch)},"uuid":${JSON.stringify(uuid)}}]}`, + ); + const report = parseAppleIps(bytes, artifact(bytes), input); + expect(report.images[0]).toMatchObject({ arch, uuid }); + expect(report.threads[0]?.frames).toHaveLength(512); + expect(report.threads[0]?.frames[0]?.image_index).toBe(0); + const serialized = JSON.stringify(report); + expect(serialized.split(arch)).toHaveLength(2); + expect(serialized.split(uuid)).toHaveLength(2); +}); + +it("refuses integer text amplification before retaining every derived address", () => { + const bytes = integerAmplificationBytes(); + expect(bytes.length).toBeLessThan(input.max_file_bytes); + expect(() => parseAppleIps(bytes, artifact(bytes), input)).toThrow( + expect.objectContaining({ + boundary: "integer_text", + maximum: 8 * 1024 * 1024, + }), + ); +}); + +it("honors a larger integer text allowance without changing decimal facts", () => { + const bytes = integerAmplificationBytes(); + const options = inspectAppleIpsInputSchema.parse({ + path: input.path, + max_integer_text_bytes: 64 * 1024 * 1024, + }); + const report = parseAppleIps(bytes, artifact(bytes), options); + expect(report.threads[0]?.frames).toHaveLength(512); + expect(report.images[0]?.base?.decimal).toBe(`1${"0".repeat(20000)}`); + expect(report.threads[0]?.frames[511]?.runtime_address?.decimal).toBe( + `1${"0".repeat(19999)}1`, + ); +}); + +it("reads a 309 crash report, uppercase UUID, and lossless register", () => { + const bytes = appleIpsBytes(); + const report = parseAppleIps(bytes, artifact(bytes), input); + expect(report.metadata.bug_type).toBe("309"); + expect(report.exception?.type).toBe("EXC_BAD_ACCESS"); + expect(report.exception?.signal).toBe("SIGSEGV"); + expect(report.images[0]?.uuid).toBe("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"); + expect(report.images[0]?.source_meaning).toBe("process"); + expect(report.images[1]?.uuid_unknown).toBe(true); + expect(report.threads[0]?.frames[0]).toMatchObject({ + role: "faulting_instruction", + recorded_symbol: "rea_fixture_fault", + }); + expect(report.threads[0]?.registers[0]).toMatchObject({ + name: "x0", + value: { + decimal: "9594637537912899908", + hex: "0x8527000193ed4944", + }, + }); + expect(report.classification.status).toBe("unclassified"); + expect(report.threads[0]?.frames[0]?.runtime_address?.hex).toBe( + "0x100000338", + ); +}); + +it("refuses a stackshot bug_type without a crash body", () => { + const bytes = appleIpsBytes({ bugType: "288" }); + expect(() => parseAppleIps(bytes, artifact(bytes), input)).toThrow( + AppleIpsFormatFailure, + ); + try { + parseAppleIps(bytes, artifact(bytes), input); + } catch (cause) { + expect(cause).toMatchObject({ + kind: "unsupported", + message: expect.stringContaining("288"), + }); + } +}); + +it("fails closed on a truncated crash body", () => { + const bytes = appleIpsBytes({ truncateBody: true }); + expect(() => parseAppleIps(bytes, artifact(bytes), input)).toThrow( + AppleIpsFormatFailure, + ); +}); + +it("matches documented watchdog, guard, PAC and dyld rules", () => { + const watchdog = parseAppleIps( + appleIpsBytes({ terminationCode: "2343432205" }), + artifact(appleIpsBytes({ terminationCode: "2343432205" })), + input, + ); + expect(watchdog.classification.matches.map((row) => row.rule)).toEqual([ + "watchdog", + ]); + const guard = parseAppleIps( + appleIpsBytes({ + exceptionType: "EXC_GUARD", + subtype: "GUARD_TYPE_FD", + }), + artifact( + appleIpsBytes({ exceptionType: "EXC_GUARD", subtype: "GUARD_TYPE_FD" }), + ), + input, + ); + expect(guard.classification.matches[0]?.rule).toBe("guarded_file_descriptor"); + const pac = parseAppleIps( + appleIpsBytes({ + subtype: "KERN_INVALID_ADDRESS (possible pointer authentication failure)", + }), + artifact( + appleIpsBytes({ + subtype: + "KERN_INVALID_ADDRESS (possible pointer authentication failure)", + }), + ), + input, + ); + expect(pac.classification.matches[0]?.rule).toBe( + "possible_pointer_authentication_failure", + ); + const dyld = parseAppleIps( + appleIpsBytes({ + terminationNamespace: "DYLD", + terminationIndicator: "Library missing", + terminationReasons: ["Library not loaded: @rpath/libreamissing.dylib"], + }), + artifact( + appleIpsBytes({ + terminationNamespace: "DYLD", + terminationIndicator: "Library missing", + terminationReasons: ["Library not loaded: @rpath/libreamissing.dylib"], + }), + ), + input, + ); + expect(dyld.classification.matches[0]).toMatchObject({ + rule: "dyld_missing_library", + }); +}); + +it("treats an empty image path as omitted and accepts CRLF line endings", () => { + const bytes = appleIpsBytes(); + const crlf = Buffer.from(appleIpsReport().replaceAll("\n", "\r\n"), "utf8"); + const report = parseAppleIps(bytes, artifact(bytes), input); + expect(report.images[1]?.path).toBeNull(); + const crlfReport = parseAppleIps(crlf, artifact(crlf), input); + expect(crlfReport.metadata.bug_type).toBe("309"); + expect(crlfReport.threads[0]?.frames[0]?.role).toBe("faulting_instruction"); +}); + +it("fails closed when the image budget is exhausted", () => { + const bytes = appleIpsBytes(); + expect(() => + parseAppleIps( + bytes, + artifact(bytes), + inspectAppleIpsInputSchema.parse({ + path: "/artifacts/report.ips", + max_images: 1, + }), + ), + ).toThrow(AppleIpsResourceFailure); +}); + +const parse = (bytes: Buffer, options: Record = {}) => + parseAppleIps( + bytes, + artifact(bytes), + inspectAppleIpsInputSchema.parse({ + path: "/artifacts/report.ips", + ...options, + }), + ); + +const withBody = (body: string): Buffer => + Buffer.from(`${appleIpsMetadataLine()}\n${body}`, "utf8"); + +const report = (options: Parameters[0]) => + parse(appleIpsBytes(options)); + +it("keeps ReportCrash-shaped producer facts and reports complete coverage", () => { + const parsed = report({}); + expect(parsed.process.os_version).toEqual({ + form: "structured", + train: "macOS 15.0", + build: "24A335", + release_type: "User", + is_embedded: null, + unlisted_keys: [], + }); + expect( + report({ osVersionText: "Version 15.0 (Build 24A335)" }).process.os_version, + ).toEqual({ + form: "text", + text: "Version 15.0 (Build 24A335)", + }); + expect(parsed.threads[0]?.state_flavor).toBe("ARM_THREAD_STATE64"); + expect(parsed.threads[0]?.registers.map((row) => row.name)).toEqual([ + "x0", + "x1", + "lr", + "esr", + "pc", + "far", + ]); + expect(parsed.threads[0]?.registers[3]).toEqual({ + name: "esr", + value: { decimal: "2449473606", hex: "0x92000046" }, + description: "(Data Abort) byte read Translation fault", + }); + expect(parsed.exception?.unlisted_keys).toEqual(["rawCodes"]); + expect(parsed.termination?.unlisted_keys).toEqual([ + "flags", + "byProc", + "byPid", + ]); + expect(parsed.unlisted_body_keys).toContain("sharedCache"); + expect(parsed.unlisted_body_keys).not.toContain("osVersion"); + expect(parsed.metadata.unlisted_keys).toContain("slice_uuid"); + expect(parsed.coverage).toEqual({ + status: "complete", + reason: null, + threads: 2, + frames: 5, + images: 2, + registers: 6, + type_mismatches: [], + }); +}); + +it("assigns frame roles by recorded row without inventing inline stack addresses", () => { + const roles = (parsed: ReturnType) => + parsed.threads.map((thread) => thread.frames.map((frame) => frame.role)); + expect(roles(report({}))).toEqual([ + ["faulting_instruction", "return_address"], + ["current_instruction", "return_address", "return_address"], + ]); + expect(report({}).threads[1]?.frames[0]?.inline).toBe(true); + const unknownFault = parse( + Buffer.from(appleIpsReport().replace('"faultingThread": 0,', ""), "utf8"), + ); + expect(unknownFault.faulting_thread).toBeNull(); + expect(roles(unknownFault)).toEqual([ + ["unknown", "return_address"], + ["unknown", "return_address", "return_address"], + ]); +}); + +it.each(["5", "4294967295"])( + "preserves unresolved faulting index %s without establishing top-frame roles", + (index) => { + const parsed = parse( + withBody( + `{"faultingThread":${index},"threads":[{"frames":[{},{}]}],"usedImages":[]}`, + ), + ); + expect(parsed.faulting_thread).toBe(Number(index)); + expect(parsed.threads[0]?.frames.map((frame) => frame.role)).toEqual([ + "unknown", + "return_address", + ]); + expect(parsed.coverage.status).toBe("partial"); + expect(parsed.coverage.type_mismatches).toContainEqual( + expect.objectContaining({ + path: "body.faultingThread", + recorded_integer: index, + }), + ); + }, +); + +it("does not resolve a faulting reference to a malformed thread row", () => { + const parsed = parse( + withBody( + '{"faultingThread":0,"threads":[null,{"frames":[{}]}],"usedImages":[]}', + ), + ); + expect(parsed.faulting_thread).toBe(0); + expect(parsed.threads[1]?.frames[0]?.role).toBe("unknown"); + expect(parsed.coverage.type_mismatches).toContainEqual( + expect.objectContaining({ + path: "body.faultingThread", + recorded_integer: "0", + }), + ); +}); + +it.each(["-1", "4294967296", `1${"0".repeat(1000)}`])( + "preserves rejected index spelling (case %#)", + (index) => { + const parsed = parse( + withBody( + `{"faultingThread":${index},"threads":[{"frames":[{"imageIndex":${index}}]}],"usedImages":[]}`, + ), + ); + expect(parsed.faulting_thread).toBeNull(); + expect(parsed.threads[0]?.frames[0]?.image_index).toBeNull(); + expect( + parsed.coverage.type_mismatches.map( + (mismatch) => mismatch.recorded_integer, + ), + ).toEqual([index, index]); + }, +); + +it("bounds rejected index text before retaining it", () => { + const bytes = withBody( + '{"faultingThread":4294967296,"threads":[],"usedImages":[]}', + ); + expect(() => + parseAppleIps(bytes, artifact(bytes), { + ...input, + max_integer_text_bytes: 19, + }), + ).toThrow(expect.objectContaining({ boundary: "integer_text", maximum: 19 })); + const parsed = parseAppleIps(bytes, artifact(bytes), { + ...input, + max_integer_text_bytes: 20, + }); + expect(parsed.coverage.type_mismatches[0]).toMatchObject({ + recorded_integer: "4294967296", + }); +}); + +it("leaves inline addresses unknown when their own offset is absent", () => { + const parsed = parse( + withBody( + '{"faultingThread":0,"threads":[{"frames":[{"imageIndex":0,"inline":true},{"imageIndex":0,"imageOffset":8,"inline":true},{"imageIndex":1,"imageOffset":16}]}],"usedImages":[{"base":4096},{"base":8192}]}', + ), + ); + expect( + parsed.threads[0]?.frames.map( + (frame) => frame.runtime_address?.decimal ?? null, + ), + ).toEqual([null, "4104", "8208"]); + expect( + parsed.threads[0]?.frames.map( + (frame) => frame.image_offset?.decimal ?? null, + ), + ).toEqual([null, "8", "16"]); +}); + +it("preserves an unresolved image index without claiming complete coverage", () => { + const parsed = parse( + withBody( + '{"threads":[{"frames":[{"imageIndex":7,"imageOffset":8}]}],"usedImages":[]}', + ), + ); + expect(parsed.threads[0]?.frames[0]).toMatchObject({ + image_index: 7, + image_offset: { decimal: "8" }, + runtime_address: null, + }); + expect(parsed.coverage.status).toBe("partial"); + expect(parsed.coverage.type_mismatches[0]).toMatchObject({ + path: "body.threads[0].frames[0].imageIndex", + recorded_integer: "7", + }); +}); + +it("classifies SIGKILL codes by canonical hex, not a near-miss decimal", () => { + const rules = (code: string) => + report({ terminationCode: code }).classification.matches; + expect(rules("3735933132")).toEqual([]); + expect(rules("3735883980")).toEqual([ + { + rule: "dead10cc", + fields: [ + { name: "termination.code", value: "3735883980" }, + { name: "termination.code_hex", value: "0xdead10cc" }, + ], + }, + ]); + expect(rules("3221229823").map((row) => row.rule)).toEqual(["thermal"]); +}); + +it("reports wrong-type collections as partial instead of empty and complete", () => { + const parsed = parse( + withBody( + '{"procName":"x","threads":{"0":{}},"usedImages":"none","exception":{"type":"EXC_CRASH"}}', + ), + ); + expect(parsed.threads).toEqual([]); + expect(parsed.images).toEqual([]); + expect(parsed.exception?.type).toBe("EXC_CRASH"); + expect(parsed.coverage.status).toBe("partial"); + expect(parsed.coverage.reason).toMatch(/type_mismatches/u); + expect(parsed.coverage.type_mismatches).toEqual([ + { path: "body.usedImages", expected: "array", observed: "string" }, + { path: "body.threads", expected: "array", observed: "object" }, + ]); + const absent = parse(withBody('{"procName":"x"}')); + expect(absent.coverage.type_mismatches).toEqual([ + { path: "body.usedImages", expected: "array", observed: "absent" }, + { path: "body.threads", expected: "array", observed: "absent" }, + ]); + const empty = parse(withBody('{"threads":[],"usedImages":[]}')); + expect(empty.coverage.status).toBe("complete"); +}); + +it("records wrong-type rows, scalars and registers with their source path", () => { + const parsed = parse( + withBody(`{ + "pid": "4242", + "osVersion": 15, + "faultingThread": -1, + "termination": { "code": 1.5, "reasons": ["ok", 7] }, + "threads": [ + 7, + { + "frames": [{ "imageIndex": 99999999999, "imageOffset": "8" }, []], + "threadState": { "flavor": 1, "x": [1, "two", { "value": null }, {}], "rosetta": { "tmp0": 1 } } + } + ], + "usedImages": [null, { "base": 1.0 }] + }`), + ); + expect(parsed.process.pid).toBeNull(); + expect(parsed.faulting_thread).toBeNull(); + expect(parsed.termination?.reasons).toEqual(["ok"]); + expect(parsed.threads).toHaveLength(2); + expect(parsed.threads[1]?.registers).toEqual([ + { name: "x0", value: { decimal: "1", hex: "0x1" }, description: null }, + ]); + expect(parsed.images).toHaveLength(2); + expect(parsed.coverage.status).toBe("partial"); + expect(parsed.coverage.type_mismatches).toEqual([ + { path: "body.usedImages[0]", expected: "object", observed: "null" }, + { + path: "body.usedImages[1].base", + expected: "integer", + observed: "number", + }, + { + path: "body.faultingThread", + expected: "unsigned 32-bit integer", + observed: "integer out of range", + recorded_integer: "-1", + }, + { path: "body.threads[0]", expected: "object", observed: "integer" }, + { + path: "body.threads[1].frames[0].imageIndex", + expected: "unsigned 32-bit integer", + observed: "integer out of range", + recorded_integer: "99999999999", + }, + { + path: "body.threads[1].frames[0].imageOffset", + expected: "integer", + observed: "string", + }, + { + path: "body.threads[1].frames[1]", + expected: "object", + observed: "array", + }, + { + path: "body.threads[1].threadState.x[1]", + expected: "integer or object with integer value", + observed: "string", + }, + { + path: "body.threads[1].threadState.x[3].value", + expected: "integer", + observed: "absent", + }, + { + path: "body.threads[1].threadState.rosetta.value", + expected: "integer", + observed: "absent", + }, + { + path: "body.threads[1].threadState.flavor", + expected: "string", + observed: "integer", + }, + { + path: "body.termination.code", + expected: "integer", + observed: "number", + }, + { + path: "body.termination.reasons[1]", + expected: "string", + observed: "integer", + }, + { path: "body.pid", expected: "integer", observed: "string" }, + { + path: "body.osVersion", + expected: "string or object", + observed: "integer", + }, + ]); +}); + +it("refuses invalid UTF-8 instead of replacing bytes", () => { + const valid = appleIpsBytes({ procName: "rea-é" }); + const offset = valid.indexOf(Buffer.from("é", "utf8")); + const corrupt = Buffer.from(valid); + corrupt[offset] = 0xff; + expect(parse(valid).process.name).toBe("rea-é"); + expect(() => parse(corrupt)).toThrow( + expect.objectContaining({ + kind: "malformed", + message: expect.stringMatching(/UTF-8/u), + }), + ); +}); + +const registerFlood = (count: number): Buffer => + withBody( + `{"threads":[{"threadState":{"x":[${Array.from({ length: count }, () => "0").join(",")}]}}],"usedImages":[]}`, + ); + +it("budgets register slots before expanding threadState rows", () => { + const flood = registerFlood(150_000); + expect(flood.length).toBeLessThan(400_000); + expect(() => parse(flood)).toThrow( + expect.objectContaining({ boundary: "registers", maximum: 16384 }), + ); + expect(parse(registerFlood(4), { max_registers: 4 }).coverage.registers).toBe( + 4, + ); + expect(() => parse(registerFlood(5), { max_registers: 4 })).toThrow( + AppleIpsResourceFailure, + ); +}); + +it("refuses an over-budget collection before parsing the rows past the allowance", () => { + // Rows after the allowance are invalid JSON; the budget must refuse first. + const threads = withBody(`{"threads":[{},{},{},?],"usedImages":[]}`); + expect(() => parse(threads, { max_threads: 3 })).toThrow( + expect.objectContaining({ boundary: "threads", maximum: 3 }), + ); + const frames = withBody( + `{"threads":[{"frames":[{},{}]},{"frames":[{},?]}],"usedImages":[]}`, + ); + expect(() => parse(frames, { max_frames: 3 })).toThrow( + expect.objectContaining({ boundary: "frames", maximum: 3 }), + ); + const images = withBody(`{"threads":[],"usedImages":[{},?]}`); + expect(() => parse(images, { max_images: 1 })).toThrow( + expect.objectContaining({ boundary: "images", maximum: 1 }), + ); +}); + +it("skips unconsumed producer members and bounds fixed structural allowances", () => { + const skipped = parse( + withBody( + `{"threads":[],"usedImages":[],"lastExceptionBacktrace":[${Array.from({ length: 100_000 }, () => "{}").join(",")}]}`, + ), + ); + expect(skipped.coverage.status).toBe("complete"); + expect(skipped.unlisted_body_keys).toEqual(["lastExceptionBacktrace"]); + const keys = Array.from( + { length: 1025 }, + (_, index) => `"k${String(index)}":0`, + ).join(","); + expect(() => parse(withBody(`{${keys}}`))).toThrow( + expect.objectContaining({ boundary: "unlisted_keys", maximum: 1024 }), + ); + const deep = `${"[".repeat(100)}${"]".repeat(100)}`; + expect(() => parse(withBody(`{"vmSummary":${deep}}`))).toThrow( + expect.objectContaining({ boundary: "nesting", maximum: 64 }), + ); + const reasons = JSON.stringify(Array.from({ length: 257 }, () => "r")); + expect(() => + parse(withBody(`{"termination":{"reasons":${reasons}}}`)), + ).toThrow(expect.objectContaining({ boundary: "termination_reasons" })); +}); diff --git a/src/native/apple/AppleIpsParser.ts b/src/native/apple/AppleIpsParser.ts new file mode 100644 index 0000000000..359cb14170 --- /dev/null +++ b/src/native/apple/AppleIpsParser.ts @@ -0,0 +1,434 @@ +import { + APPLE_IPS_IMAGE_SOURCE_MEANING, + APPLE_IPS_IMAGE_SOURCES, + type AppleIps, + type AppleIpsFrame, + type AppleIpsImage, + type AppleIpsImageSource, + type AppleIpsThread, + type InspectAppleIpsInput, +} from "../../domain/native/appleIps.js"; +import { AppleIpsFormatFailure } from "./AppleIps.errors.js"; +import { classify } from "./AppleIpsClassification.js"; +import { FieldReader } from "./AppleIpsFieldReader.js"; +import { + consumeJson, + ipsBodyShape, + IPS_METADATA_SHAPE, +} from "./AppleIpsJsonShape.js"; +import type { LosslessJson } from "./losslessJson.js"; + +type Artifact = AppleIps["artifact"]; + +const ZERO_UUID = "00000000000000000000000000000000"; +const recordedUuidPattern = + /^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/u; +const IPS_LIMITATIONS = [ + "Rows reproduce the selected .ips JSON; they do not establish that the process still exists or that a live kernel agrees with the recorded state.", + "Opened Mach-O and dSYM UUID joins remain unobserved.", + "Symbol names printed inside the report are recorded claims and are not verified.", +]; + +const utf8 = new TextDecoder("utf-8", { fatal: true }); + +const canonicalUuid = ( + raw: string | null, +): { uuid: string | null; unknown: boolean; malformed: boolean } => { + if (raw === null) return { uuid: null, unknown: true, malformed: false }; + if (!recordedUuidPattern.test(raw)) + return { uuid: null, unknown: true, malformed: true }; + const hex = raw.replaceAll("-", "").toUpperCase(); + if (hex === ZERO_UUID) return { uuid: null, unknown: true, malformed: false }; + return { + uuid: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`, + unknown: false, + malformed: false, + }; +}; + +const sourceMeaning = (source: string | null): string | null => { + if (source === null) return null; + return (APPLE_IPS_IMAGE_SOURCES as readonly string[]).includes(source) + ? APPLE_IPS_IMAGE_SOURCE_MEANING[source as AppleIpsImageSource] + : null; +}; + +const exceptionOf = ( + value: LosslessJson | undefined, + read: FieldReader, +): AppleIps["exception"] => { + const object = read.object(value, "body.exception"); + if (object === null) return null; + const { members } = object; + return { + type: read.text(members.type, "body.exception.type"), + signal: read.text(members.signal, "body.exception.signal"), + subtype: read.text(members.subtype, "body.exception.subtype"), + codes: read.text(members.codes, "body.exception.codes"), + message: read.text(members.message, "body.exception.message"), + unlisted_keys: [...object.unlisted], + }; +}; + +const terminationOf = ( + value: LosslessJson | undefined, + read: FieldReader, +): AppleIps["termination"] => { + const object = read.object(value, "body.termination"); + if (object === null) return null; + const { members } = object; + const reasonItems = read.array( + members.reasons, + "body.termination.reasons", + false, + ); + return { + namespace: read.text(members.namespace, "body.termination.namespace"), + code: read.integer(members.code, "body.termination.code"), + indicator: read.text(members.indicator, "body.termination.indicator"), + reasons: + reasonItems === null + ? null + : reasonItems.flatMap((item, index) => { + const reason = read.text( + item, + `body.termination.reasons[${String(index)}]`, + ); + return reason === null ? [] : [reason]; + }), + unlisted_keys: [...object.unlisted], + }; +}; + +const osVersionOf = ( + value: LosslessJson | undefined, + read: FieldReader, +): AppleIps["process"]["os_version"] => { + if (value === undefined || value.kind === "null") return null; + if (value.kind === "string") + return value.value.length === 0 + ? null + : { form: "text", text: value.value }; + if (value.kind !== "object") { + read.mismatch("body.osVersion", "string or object", value); + return null; + } + const { members } = value; + return { + form: "structured", + train: read.text(members.train, "body.osVersion.train"), + build: read.text(members.build, "body.osVersion.build"), + release_type: read.text(members.releaseType, "body.osVersion.releaseType"), + is_embedded: read.boolean(members.isEmbedded, "body.osVersion.isEmbedded"), + unlisted_keys: [...value.unlisted], + }; +}; + +const registerValue = ( + name: string, + value: LosslessJson, + path: string, + read: FieldReader, +): AppleIpsThread["registers"][number] | null => { + if (value.kind === "null") return null; + if (value.kind === "integer") + return { + name, + value: read.recordedInteger(value.decimal), + description: null, + }; + if (value.kind !== "object") { + read.mismatch(path, "integer or object with integer value", value); + return null; + } + const integer = read.integer(value.members.value, `${path}.value`); + if (integer === null) { + if (value.members.value === undefined) + read.mismatch(`${path}.value`, "integer", undefined); + return null; + } + return { + name, + value: integer, + description: read.text(value.members.description, `${path}.description`), + }; +}; + +const threadStateOf = ( + value: LosslessJson | undefined, + path: string, + read: FieldReader, +): Pick => { + const object = read.object(value, path); + if (object === null) return { state_flavor: null, registers: [] }; + const { members } = object; + const registers: AppleIpsThread["registers"] = []; + const vector = read.array(members.x, `${path}.x`, false); + vector?.forEach((entry, index) => { + const register = registerValue( + `x${String(index)}`, + entry, + `${path}.x[${String(index)}]`, + read, + ); + if (register !== null) registers.push(register); + }); + for (const [key, entry] of Object.entries(members)) { + if (key === "x" || key === "flavor") continue; + const register = registerValue(key, entry, `${path}.${key}`, read); + if (register !== null) registers.push(register); + } + return { + state_flavor: read.text(members.flavor, `${path}.flavor`), + registers, + }; +}; + +const imagesOf = ( + rows: readonly LosslessJson[], + read: FieldReader, +): AppleIpsImage[] => + rows.map((row, index) => { + const path = `body.usedImages[${String(index)}]`; + const members = read.row(row, path); + const source = read.text(members.source, `${path}.source`); + const recordedUuid = read.text(members.uuid, `${path}.uuid`); + const uuid = canonicalUuid(recordedUuid); + if (uuid.malformed && recordedUuid !== null) + read.mismatches.push({ + path: `${path}.uuid`, + expected: "32 hexadecimal UUID digits with optional canonical hyphens", + observed: "malformed UUID string", + recorded_value: recordedUuid, + }); + const arch = read.text(members.arch, `${path}.arch`); + return { + index, + source, + source_meaning: sourceMeaning(source), + arch, + base: read.integer(members.base, `${path}.base`), + size: read.integer(members.size, `${path}.size`), + uuid: uuid.uuid, + uuid_unknown: uuid.unknown || arch === null, + path: read.text(members.path, `${path}.path`), + }; + }); + +/** + * Frame 0 is the thread's sampled program counter; later frames are unwound + * return addresses. Keep each row's own recorded address, including inline + * rows: the inline flag does not establish a replacement address or row index. + */ +const frameRole = ( + frameIndex: number, + threadIndex: number, + faultingThread: number | null, +): AppleIpsFrame["role"] => { + if (frameIndex > 0) return "return_address"; + if (faultingThread === null) return "unknown"; + return threadIndex === faultingThread + ? "faulting_instruction" + : "current_instruction"; +}; + +const threadsOf = ( + rows: readonly LosslessJson[], + imageRows: readonly LosslessJson[], + images: readonly AppleIpsImage[], + faultingThread: number | null, + read: FieldReader, +): AppleIpsThread[] => + rows.map((row, threadIndex) => { + const path = `body.threads[${String(threadIndex)}]`; + const members = read.row(row, path); + const frames = ( + read.array(members.frames, `${path}.frames`, false) ?? [] + ).map((frame, frameIndex): AppleIpsFrame => { + const framePath = `${path}.frames[${String(frameIndex)}]`; + const frameMembers = read.row(frame, framePath); + const imageIndex = read.index( + frameMembers.imageIndex, + `${framePath}.imageIndex`, + ); + const resolvedImage = read.resolveIndex( + imageIndex, + imageRows, + `${framePath}.imageIndex`, + ); + const image = resolvedImage === null ? undefined : images[resolvedImage]; + const imageOffset = read.integer( + frameMembers.imageOffset, + `${framePath}.imageOffset`, + ); + const inline = read.boolean(frameMembers.inline, `${framePath}.inline`); + const role = frameRole(frameIndex, threadIndex, faultingThread); + return { + thread_index: threadIndex, + frame_index: frameIndex, + image_index: imageIndex, + image_offset: imageOffset, + runtime_address: read.add(image?.base ?? null, imageOffset), + role, + inline, + recorded_symbol: read.text(frameMembers.symbol, `${framePath}.symbol`), + }; + }); + return { + index: threadIndex, + id: read.integer(members.id, `${path}.id`), + triggered: read.boolean(members.triggered, `${path}.triggered`), + name: read.text(members.name, `${path}.name`), + queue: read.text(members.queue, `${path}.queue`), + frames, + ...threadStateOf(members.threadState, `${path}.threadState`, read), + }; + }); + +/** Split the UTF-8 text into the first-line metadata object and the crash body. */ +const crashText = (bytes: Buffer): { metadata: string; body: string } => { + let text: string; + try { + text = utf8.decode(bytes); + } catch { + throw new AppleIpsFormatFailure( + "malformed", + "IPS crash reports are UTF-8 JSON text; the selected bytes are not valid UTF-8", + ); + } + if (text.includes("\u0000")) + throw new AppleIpsFormatFailure( + "malformed", + "IPS crash reports are UTF-8 JSON text and cannot contain NUL", + ); + const newline = text.search(/\r\n|\n|\r/u); + if (newline <= 0) + throw new AppleIpsFormatFailure( + "malformed", + "IPS crash report must start with a metadata JSON object on the first line", + ); + const lineEnd = text.startsWith("\r\n", newline) ? newline + 2 : newline + 1; + return { metadata: text.slice(0, newline), body: text.slice(lineEnd) }; +}; + +const metadataOf = (text: string, read: FieldReader): AppleIps["metadata"] => { + const json = consumeJson(text, "IPS metadata", IPS_METADATA_SHAPE); + if (json.kind !== "object") + throw new AppleIpsFormatFailure( + "malformed", + "IPS metadata is not a JSON object", + ); + const { members } = json; + const bugType = members.bug_type; + if (bugType?.kind !== "string" || bugType.value.length === 0) + throw new AppleIpsFormatFailure( + "malformed", + "IPS metadata bug_type is missing or not a string", + ); + if (bugType.value !== "309") + throw new AppleIpsFormatFailure( + "unsupported", + `IPS bug_type ${JSON.stringify(bugType.value)} is not a crash report (309); this operation does not decode other DiagnosticReports types`, + ); + return { + bug_type: bugType.value, + timestamp: read.text(members.timestamp, "metadata.timestamp"), + os_version: read.text(members.os_version, "metadata.os_version"), + bundle_id: read.text(members.bundleID, "metadata.bundleID"), + incident_id: read.text(members.incident_id, "metadata.incident_id"), + name: read.text(members.name, "metadata.name"), + unlisted_keys: [...json.unlisted], + }; +}; + +const coverageOf = ( + threads: readonly AppleIpsThread[], + images: readonly AppleIpsImage[], + mismatches: AppleIps["coverage"]["type_mismatches"], +): AppleIps["coverage"] => { + let frames = 0; + let registers = 0; + for (const thread of threads) { + frames += thread.frames.length; + registers += thread.registers.length; + } + return { + status: mismatches.length === 0 ? "complete" : "partial", + reason: + mismatches.length === 0 + ? null + : `${String(mismatches.length)} recorded value(s) did not satisfy the documented type, format or reference and were not interpreted; see type_mismatches`, + threads: threads.length, + frames, + images: images.length, + registers, + type_mismatches: mismatches, + }; +}; + +/** Read one Apple .ips crash report without executing a target. */ +export const parseAppleIps = ( + bytes: Buffer, + artifact: Artifact, + input: InspectAppleIpsInput, +): AppleIps => { + const text = crashText(bytes); + const read = new FieldReader(input.max_integer_text_bytes); + const metadata = metadataOf(text.metadata, read); + if (text.body.length === 0) + throw new AppleIpsFormatFailure( + "malformed", + "IPS crash report body is missing after metadata line", + ); + const bodyJson = consumeJson( + text.body, + "IPS crash body", + ipsBodyShape(input), + ); + if (bodyJson.kind !== "object") + throw new AppleIpsFormatFailure( + "malformed", + "IPS crash body is not a JSON object", + ); + const body = bodyJson.members; + const imageRows = read.array(body.usedImages, "body.usedImages", true) ?? []; + const images = imagesOf(imageRows, read); + const threadRows = read.array(body.threads, "body.threads", true) ?? []; + const faultingThread = read.index(body.faultingThread, "body.faultingThread"); + const resolvedFaultingThread = read.resolveIndex( + faultingThread, + threadRows, + "body.faultingThread", + ); + const threads = threadsOf( + threadRows, + imageRows, + images, + resolvedFaultingThread, + read, + ); + const exception = exceptionOf(body.exception, read); + const termination = terminationOf(body.termination, read); + return { + artifact, + metadata, + process: { + name: read.text(body.procName, "body.procName"), + path: read.text(body.procPath, "body.procPath"), + pid: read.integer(body.pid, "body.pid"), + parent: read.text(body.parentProc, "body.parentProc"), + cpu_type: read.text(body.cpuType, "body.cpuType"), + translated: read.boolean(body.translated, "body.translated"), + os_version: osVersionOf(body.osVersion, read), + }, + exception, + termination, + faulting_thread: faultingThread, + threads, + images, + classification: classify(exception, termination), + coverage: coverageOf(threads, images, read.mismatches), + unlisted_body_keys: [...bodyJson.unlisted], + limitations: IPS_LIMITATIONS, + }; +}; diff --git a/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts new file mode 100644 index 0000000000..28d8b40c37 --- /dev/null +++ b/src/native/apple/AppleIpsProvider.ts @@ -0,0 +1,292 @@ +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, + assertArtifactReadActive, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import type { AppleIpsPort } from "../../application/binaryDiagnostics/AppleIpsPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; +import { + MAX_INSPECT_APPLE_IPS_FILE_BYTES, + MAX_INSPECT_APPLE_IPS_FRAMES, + MAX_INSPECT_APPLE_IPS_IMAGES, + MAX_INSPECT_APPLE_IPS_REGISTERS, + MAX_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES, + DEFAULT_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES, + MAX_INSPECT_APPLE_IPS_THREADS, + type InspectAppleIpsInput, +} from "../../domain/native/appleIps.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { err, ok } from "../../domain/result.js"; +import { APPLE_IPS_PROVIDER } from "../../domain/investigationProviders.js"; +import { + AppleIpsFormatFailure, + AppleIpsResourceFailure, + type AppleIpsSelectedBoundary, +} from "./AppleIps.errors.js"; +import { parseAppleIps } from "./AppleIpsParser.js"; + +const OPERATION = "inspect_apple_ips"; + +/** Portable .ips reader over one bounded, identity-checked regular file. */ +export class AppleIpsProvider implements AppleIpsPort { + readonly identity = APPLE_IPS_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + APPLE_IPS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect(input: InspectAppleIpsInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + assertArtifactReadActive(options?.signal); + const report = parseAppleIps( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + ); + assertArtifactReadActive(options?.signal); + return ok(report); + }); + } catch (cause) { + return err(appleIpsFailure(cause, input)); + } + } +} + +const fileSizeRemediation = (input: InspectAppleIpsInput): string => + input.max_file_bytes >= MAX_INSPECT_APPLE_IPS_FILE_BYTES + ? `The report exceeds the ${String(MAX_INSPECT_APPLE_IPS_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + +const SELECTED_BUDGETS = { + threads: { + argument: "max_threads", + maximum: MAX_INSPECT_APPLE_IPS_THREADS, + unit: "entries", + }, + frames: { + argument: "max_frames", + maximum: MAX_INSPECT_APPLE_IPS_FRAMES, + unit: "entries", + }, + images: { + argument: "max_images", + maximum: MAX_INSPECT_APPLE_IPS_IMAGES, + unit: "entries", + }, + registers: { + argument: "max_registers", + maximum: MAX_INSPECT_APPLE_IPS_REGISTERS, + unit: "entries", + }, + integer_text: { + argument: "max_integer_text_bytes", + maximum: MAX_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES, + unit: "UTF-16 bytes", + }, +} as const satisfies Record< + AppleIpsSelectedBoundary, + { + readonly argument: keyof InspectAppleIpsInput; + readonly maximum: number; + readonly unit: string; + } +>; + +const isSelectedBoundary = ( + boundary: AppleIpsResourceFailure["boundary"], +): boundary is AppleIpsSelectedBoundary => + Object.hasOwn(SELECTED_BUDGETS, boundary); + +const budgetRemediation = ( + input: InspectAppleIpsInput, + boundary: AppleIpsSelectedBoundary, +): string => { + const { argument, maximum, unit } = SELECTED_BUDGETS[boundary]; + const selected = input[argument]; + const recovery = + selected >= maximum + ? `This release has no override above the ${String(maximum)} ${unit} ceiling; select a smaller report.` + : `Raise ${argument} on the next call, up to ${String(maximum)} ${unit}, or select a smaller report; no restart is required.`; + const explanation = + boundary === "integer_text" + ? ` The default is ${String(DEFAULT_INSPECT_APPLE_IPS_INTEGER_TEXT_BYTES)} UTF-16 bytes. Conservative reservation precedes numeric conversion and address expansion; max_file_bytes and row budgets do not raise it.` + : ""; + return `Effective ${argument} is ${String(selected)} ${unit}. ${recovery}${explanation}`; +}; + +const resourceRecovery = ( + input: InspectAppleIpsInput, + cause: AppleIpsResourceFailure, +) => { + const { boundary } = cause; + return isSelectedBoundary(boundary) + ? { + retryable: false, + remediationAction: budgetRemediation(input, boundary), + } + : fixedAllowanceOptions( + `The IPS ${boundary.replaceAll("_", " ")} allowance of ${String(cause.maximum)}`, + "parser", + ); +}; + +const appleIpsFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectAppleIpsInput, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof AppleIpsResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `Selected artifact ${input.path}: ${cause.message}`, + { boundary: cause.boundary, maximum: cause.maximum }, + { cause, ...resourceRecovery(input, cause) }, + ); + if (cause instanceof AppleIpsFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${input.path}: ${cause.message}`, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: fileSizeRemediation(input), + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected report could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ProviderAdapterError(APPLE_IPS_PROVIDER.id, OPERATION, { + cause, + diagnostics: { + path: input.path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const appleIpsFailure = ( + cause: unknown, + input: InspectAppleIpsInput, +): AnalysisError => { + const primary = appleIpsFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + APPLE_IPS_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/src/native/apple/losslessJson.test.ts b/src/native/apple/losslessJson.test.ts new file mode 100644 index 0000000000..7ad511ee9f --- /dev/null +++ b/src/native/apple/losslessJson.test.ts @@ -0,0 +1,128 @@ +import { expect, it } from "vitest"; +import { + LosslessJsonFailure, + parseLosslessJson, + type JsonShape, +} from "./losslessJson.js"; + +it("keeps integers above 2^53 as decimal text", () => { + const { value, end } = parseLosslessJson('{"far":9587749362346101060}', 0); + expect(end).toBe(27); + expect(value).toEqual({ + kind: "object", + members: { far: { kind: "integer", decimal: "9587749362346101060" } }, + unlisted: [], + }); +}); + +it("rejects duplicate object keys", () => { + expect(() => parseLosslessJson('{"a":1,"a":2}')).toThrow(/Duplicate/u); +}); + +it("decodes long and escaped strings exactly as JSON.parse does", () => { + const plain = "a".repeat(1_040_000); + const escaped = `${"b".repeat(5000)}\\n\\u00e9\\"${"c".repeat(5000)}\\\\`; + for (const literal of [`"${plain}"`, `"${escaped}"`, '""', '"\\u2028"']) { + const { value } = parseLosslessJson(literal); + expect(value).toEqual({ kind: "string", value: JSON.parse(literal) }); + } + expect(() => parseLosslessJson('"a\u0001"')).toThrow(/control character/u); + expect(() => parseLosslessJson('"abc')).toThrow(/Unterminated/u); + expect(() => parseLosslessJson('"\\x"')).toThrow(/escape/u); +}); + +it("keeps only projected members and names the unlisted ones", () => { + const shape: JsonShape = { + kind: "object", + members: { keep: { kind: "scalar" } }, + beforeUnlisted: () => undefined, + }; + const { value } = parseLosslessJson( + '{"drop":[1,{"deep":"x"}],"keep":"y","other":"z"}', + 0, + shape, + ); + expect(value).toEqual({ + kind: "object", + members: { keep: { kind: "string", value: "y" } }, + unlisted: ["drop", "other"], + }); +}); + +it("marks a container of the wrong type as omitted without materializing it", () => { + const shape: JsonShape = { + kind: "object", + members: { + rows: { kind: "array", items: { kind: "scalar" } }, + name: { kind: "scalar" }, + }, + }; + const { value } = parseLosslessJson( + '{"rows":{"a":1},"name":[1,2]}', + 0, + shape, + ); + expect(value).toEqual({ + kind: "object", + members: { + rows: { kind: "omitted", type: "object" }, + name: { kind: "omitted", type: "array" }, + }, + unlisted: [], + }); +}); + +it("runs item budgets before parsing each item", () => { + let parsed = 0; + const shape: JsonShape = { + kind: "array", + items: { kind: "scalar" }, + beforeItem: () => { + parsed += 1; + if (parsed > 3) throw new RangeError("budget"); + }, + }; + // The fourth item is syntactically invalid; the budget must refuse it first. + expect(() => parseLosslessJson("[1,2,3,?]", 0, shape)).toThrow(RangeError); + expect(parsed).toBe(4); +}); + +it("does not resolve inherited names as projected members", () => { + const shape: JsonShape = { kind: "object", members: {} }; + const { value } = parseLosslessJson( + '{"constructor":1,"__proto__":{"x":1},"toString":[]}', + 0, + shape, + ); + expect(value).toEqual({ kind: "object", members: {}, unlisted: [] }); +}); + +it("still checks syntax inside skipped values", () => { + const shape: JsonShape = { kind: "object", members: {} }; + for (const text of [ + '{"skip":[1,]}', + '{"skip":{"a" 1}}', + '{"skip":"\\q"}', + '{"skip":tru}', + '{"skip":01}', + ]) + expect(() => parseLosslessJson(text, 0, shape)).toThrow( + LosslessJsonFailure, + ); +}); + +it("refuses nesting beyond the selected depth in kept and skipped values", () => { + const deep = `${"[".repeat(20)}${"]".repeat(20)}`; + expect(() => parseLosslessJson(deep, 0, undefined, { maxDepth: 10 })).toThrow( + expect.objectContaining({ reason: "depth", maximum: 10 }), + ); + expect(() => + parseLosslessJson( + `{"skip":${deep}}`, + 0, + { kind: "object", members: {} }, + { maxDepth: 10 }, + ), + ).toThrow(expect.objectContaining({ reason: "depth" })); + expect(parseLosslessJson(deep, 0, undefined, { maxDepth: 20 }).end).toBe(40); +}); diff --git a/src/native/apple/losslessJson.ts b/src/native/apple/losslessJson.ts new file mode 100644 index 0000000000..f3777ad848 --- /dev/null +++ b/src/native/apple/losslessJson.ts @@ -0,0 +1,427 @@ +/** JSON container type a projection declined to materialize. */ +export type JsonContainerType = "array" | "object"; + +/** A JSON value that keeps integer literals as decimal text. */ +export type LosslessJson = + | { readonly kind: "null" } + | { readonly kind: "boolean"; readonly value: boolean } + | { readonly kind: "string"; readonly value: string } + | { readonly kind: "integer"; readonly decimal: string } + | { readonly kind: "number"; readonly recorded: string } + | { readonly kind: "array"; readonly items: readonly LosslessJson[] } + | { + readonly kind: "object"; + readonly members: Readonly>; + /** Names of members the projection did not select, in source order. */ + readonly unlisted: readonly string[]; + } + /** A container whose type did not match the projection; syntax was checked, content was not kept. */ + | { readonly kind: "omitted"; readonly type: JsonContainerType }; + +/** + * Projection that selects which parts of a JSON value are materialized. + * + * Values outside the projection are syntax-checked without being retained, so + * callers can bound allocation by the facts they actually consume. Budget + * callbacks run before the corresponding item is parsed and may throw. + */ +export type JsonShape = + | { readonly kind: "any" } + | { readonly kind: "scalar" } + | { + readonly kind: "array"; + readonly items: JsonShape; + readonly beforeItem?: () => void; + } + | { + readonly kind: "object"; + readonly members: Readonly>; + /** Shape for members not listed in `members`; absent means they are skipped. */ + readonly rest?: JsonShape; + readonly beforeRest?: () => void; + /** When present, names of skipped members are kept; called with the new count first. */ + readonly beforeUnlisted?: (count: number) => void; + }; + +/** JSON text could not be parsed as a single complete value. */ +export class LosslessJsonFailure extends Error { + constructor( + message: string, + readonly reason: "syntax" | "depth" = "syntax", + readonly maximum: number | null = null, + ) { + super(message); + this.name = "LosslessJsonFailure"; + } +} + +const ANY: JsonShape = { kind: "any" }; +const OMITTED_ARRAY: LosslessJson = Object.freeze({ + kind: "omitted", + type: "array", +}); +const OMITTED_OBJECT: LosslessJson = Object.freeze({ + kind: "omitted", + type: "object", +}); +const DEFAULT_MAX_DEPTH = 512; + +const QUOTE = 0x22; +const BACKSLASH = 0x5c; +const isDigit = (code: number): boolean => code >= 48 && code <= 57; + +/** Parse one JSON value, preserving integer literals that exceed IEEE-754 range. */ +export const parseLosslessJson = ( + text: string, + start = 0, + shape: JsonShape = ANY, + options: { readonly maxDepth?: number } = {}, +): { readonly value: LosslessJson; readonly end: number } => { + const parser = new Parser(text, start, options.maxDepth ?? DEFAULT_MAX_DEPTH); + const value = parser.parseValue(shape, 0); + return { value, end: parser.position }; +}; + +class Parser { + position: number; + + constructor( + readonly text: string, + start: number, + readonly maxDepth: number, + ) { + this.position = start; + } + + parseValue(shape: JsonShape, depth: number): LosslessJson { + this.skipWhitespace(); + const ch = this.text[this.position]; + if (ch === "{") { + if (shape.kind === "object" || shape.kind === "any") + return this.parseObject(shape, depth + 1); + this.skipValue(depth); + return OMITTED_OBJECT; + } + if (ch === "[") { + if (shape.kind === "array" || shape.kind === "any") + return this.parseArray(shape, depth + 1); + this.skipValue(depth); + return OMITTED_ARRAY; + } + return this.parseScalar(); + } + + private enter(depth: number): void { + if (depth > this.maxDepth) + throw new LosslessJsonFailure( + `JSON nesting exceeds ${String(this.maxDepth)} levels at offset ${String(this.position)}`, + "depth", + this.maxDepth, + ); + } + + private parseScalar(): LosslessJson { + const ch = this.text[this.position]; + if (ch === undefined) + throw new LosslessJsonFailure("Unexpected end of JSON input"); + if (ch === '"') return { kind: "string", value: this.parseString() }; + if (ch === "t") + return this.literal("true", { kind: "boolean", value: true }); + if (ch === "f") + return this.literal("false", { kind: "boolean", value: false }); + if (ch === "n") return this.literal("null", { kind: "null" }); + if (ch === "-" || isDigit(ch.charCodeAt(0))) { + const start = this.position; + const fractional = this.scanNumber(); + const recorded = this.text.slice(start, this.position); + return fractional + ? { kind: "number", recorded } + : { kind: "integer", decimal: recorded }; + } + throw new LosslessJsonFailure( + `Unexpected ${JSON.stringify(ch)} at JSON offset ${String(this.position)}`, + ); + } + + private parseObject( + shape: Extract, + depth: number, + ): LosslessJson { + this.enter(depth); + this.position += 1; + const members: Record = Object.create(null) as Record< + string, + LosslessJson + >; + const unlisted: string[] = []; + const unlistedSeen = new Set(); + this.skipWhitespace(); + if (this.text[this.position] === "}") { + this.position += 1; + return { kind: "object", members, unlisted }; + } + for (;;) { + this.skipWhitespace(); + if (this.text[this.position] !== '"') + throw new LosslessJsonFailure( + `Expected a JSON object key at offset ${String(this.position)}`, + ); + const key = this.parseString(); + this.skipWhitespace(); + this.expect(":"); + const listed = shape.kind === "any" || Object.hasOwn(shape.members, key); + const memberShape = + shape.kind === "any" ? ANY : listed ? shape.members[key] : shape.rest; + if (memberShape === undefined) { + if (shape.kind === "object" && shape.beforeUnlisted !== undefined) { + if (unlistedSeen.has(key)) + throw new LosslessJsonFailure( + `Duplicate JSON object key ${JSON.stringify(key)}`, + ); + shape.beforeUnlisted(unlisted.length + 1); + unlistedSeen.add(key); + unlisted.push(key); + } + this.skipValue(depth); + } else { + if (Object.hasOwn(members, key)) + throw new LosslessJsonFailure( + `Duplicate JSON object key ${JSON.stringify(key)}`, + ); + if (!listed && shape.kind === "object") shape.beforeRest?.(); + members[key] = this.parseValue(memberShape, depth); + } + this.skipWhitespace(); + const next = this.text[this.position]; + if (next === "}") { + this.position += 1; + return { kind: "object", members, unlisted }; + } + if (next !== ",") + throw new LosslessJsonFailure( + `Expected ',' or '}' in JSON object at offset ${String(this.position)}`, + ); + this.position += 1; + } + } + + private parseArray( + shape: Extract, + depth: number, + ): LosslessJson { + this.enter(depth); + this.position += 1; + const items: LosslessJson[] = []; + const itemShape = shape.kind === "any" ? ANY : shape.items; + const beforeItem = shape.kind === "array" ? shape.beforeItem : undefined; + this.skipWhitespace(); + if (this.text[this.position] === "]") { + this.position += 1; + return { kind: "array", items }; + } + for (;;) { + beforeItem?.(); + items.push(this.parseValue(itemShape, depth)); + this.skipWhitespace(); + const next = this.text[this.position]; + if (next === "]") { + this.position += 1; + return { kind: "array", items }; + } + if (next !== ",") + throw new LosslessJsonFailure( + `Expected ',' or ']' in JSON array at offset ${String(this.position)}`, + ); + this.position += 1; + } + } + + /** Validate one value's syntax without retaining it. */ + private skipValue(depth: number): void { + this.skipWhitespace(); + const ch = this.text[this.position]; + if (ch === "{") { + this.enter(depth + 1); + this.position += 1; + this.skipWhitespace(); + if (this.text[this.position] === "}") { + this.position += 1; + return; + } + for (;;) { + this.skipWhitespace(); + if (this.text[this.position] !== '"') + throw new LosslessJsonFailure( + `Expected a JSON object key at offset ${String(this.position)}`, + ); + this.skipString(); + this.skipWhitespace(); + this.expect(":"); + this.skipValue(depth + 1); + this.skipWhitespace(); + const next = this.text[this.position]; + if (next === "}") { + this.position += 1; + return; + } + if (next !== ",") + throw new LosslessJsonFailure( + `Expected ',' or '}' in JSON object at offset ${String(this.position)}`, + ); + this.position += 1; + } + } + if (ch === "[") { + this.enter(depth + 1); + this.position += 1; + this.skipWhitespace(); + if (this.text[this.position] === "]") { + this.position += 1; + return; + } + for (;;) { + this.skipValue(depth + 1); + this.skipWhitespace(); + const next = this.text[this.position]; + if (next === "]") { + this.position += 1; + return; + } + if (next !== ",") + throw new LosslessJsonFailure( + `Expected ',' or ']' in JSON array at offset ${String(this.position)}`, + ); + this.position += 1; + } + } + if (ch === '"') { + this.skipString(); + return; + } + if (ch === "-" || (ch !== undefined && isDigit(ch.charCodeAt(0)))) { + this.scanNumber(); + return; + } + this.parseScalar(); + } + + /** Validate once, then let V8 decode escapes without a per-escape fragment array. */ + private parseString(): string { + const start = this.position; + const escaped = this.skipString(); + if (!escaped) return this.text.slice(start + 1, this.position - 1); + // A decoded JSON string cannot contain more code units than its literal. + // The input's byte allowance bounds this allocation, including dense escapes. + const decoded: unknown = JSON.parse(this.text.slice(start, this.position)); + if (typeof decoded !== "string") + throw new LosslessJsonFailure("Expected a JSON string"); + return decoded; + } + + private skipString(): boolean { + const text = this.text; + let escaped = false; + this.position += 1; + while (this.position < text.length) { + const code = text.charCodeAt(this.position); + if (code === QUOTE) { + this.position += 1; + return escaped; + } + if (code === BACKSLASH) { + escaped = true; + this.position += 1; + this.escape(); + continue; + } + if (code < 0x20) + throw new LosslessJsonFailure( + "Unescaped control character in JSON string", + ); + this.position += 1; + } + throw new LosslessJsonFailure("Unterminated JSON string"); + } + + private escape(): string { + const ch = this.text[this.position]; + this.position += 1; + switch (ch) { + case '"': + case "\\": + case "/": + return ch; + case "b": + return "\b"; + case "f": + return "\f"; + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + case "u": { + const hex = this.text.slice(this.position, this.position + 4); + if (!/^[0-9a-fA-F]{4}$/u.test(hex)) + throw new LosslessJsonFailure("Invalid JSON Unicode escape"); + this.position += 4; + return String.fromCharCode(Number.parseInt(hex, 16)); + } + default: + throw new LosslessJsonFailure("Invalid JSON string escape"); + } + } + + /** Advance over one number; returns whether it has a fraction or exponent. */ + private scanNumber(): boolean { + if (this.text[this.position] === "-") this.position += 1; + if (this.text[this.position] === "0") this.position += 1; + else { + if (!isDigit(this.text.charCodeAt(this.position))) + throw new LosslessJsonFailure("Invalid JSON number"); + while (isDigit(this.text.charCodeAt(this.position))) this.position += 1; + } + let fractional = false; + if (this.text[this.position] === ".") { + fractional = true; + this.position += 1; + if (!isDigit(this.text.charCodeAt(this.position))) + throw new LosslessJsonFailure("Invalid JSON number fraction"); + while (isDigit(this.text.charCodeAt(this.position))) this.position += 1; + } + if (this.text[this.position] === "e" || this.text[this.position] === "E") { + fractional = true; + this.position += 1; + if (this.text[this.position] === "+" || this.text[this.position] === "-") + this.position += 1; + if (!isDigit(this.text.charCodeAt(this.position))) + throw new LosslessJsonFailure("Invalid JSON number exponent"); + while (isDigit(this.text.charCodeAt(this.position))) this.position += 1; + } + return fractional; + } + + private literal(token: string, value: T): T { + if (!this.text.startsWith(token, this.position)) + throw new LosslessJsonFailure(`Expected ${token} in JSON`); + this.position += token.length; + return value; + } + + private expect(token: string): void { + if (!this.text.startsWith(token, this.position)) + throw new LosslessJsonFailure( + `Expected ${JSON.stringify(token)} at JSON offset ${String(this.position)}`, + ); + this.position += token.length; + } + + private skipWhitespace(): void { + while (this.position < this.text.length) { + const ch = this.text[this.position]; + if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") return; + this.position += 1; + } + } +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 3e474a11b0..e1ff4c68f3 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -24,17 +24,20 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createElfNotesService, + createAppleIpsService, createPeCertificatesService, createPeImportsService, createNativeExportsService, createPeResourcesService, } from "../composition/binaryDiagnostics.js"; import type { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; +import type { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsService.js"; import type { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js"; import type { PeImportsService } from "../application/binaryDiagnostics/PeImportsService.js"; import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import type { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { registerElfNotesTool } from "./registerElfNotesTool.js"; +import { registerAppleIpsTool } from "./registerAppleIpsTool.js"; import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js"; import { registerPeImportsTool } from "./registerPeImportsTool.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; @@ -470,6 +473,7 @@ export const createServer = ( const peResources = createPeResourcesService(); const nativeExports = createNativeExportsService(); const elfNotes = createElfNotesService(); + const appleIps = createAppleIpsService(); const peCertificates = createPeCertificatesService(); const peImports = createPeImportsService(); const electronFuses = options.electronFuses ?? createElectronFuseService(); @@ -582,6 +586,7 @@ export const createServer = ( peResources.close(), nativeExports.close(), elfNotes.close(), + appleIps.close(), peCertificates.close(), peImports.close(), electronFuses.close(), @@ -651,6 +656,7 @@ export const createServer = ( peResources, nativeExports, elfNotes, + appleIps, peCertificates, peImports, electronFuses, @@ -889,6 +895,7 @@ const registerConfiguredAnalysisTools = ( peResources, nativeExports, elfNotes, + appleIps, peCertificates, peImports, pythonPyc, @@ -957,6 +964,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerAppleIpsTool( + server, + appleIps, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerPeCertificatesTool( server, peCertificates, @@ -1127,6 +1141,7 @@ interface ServerToolContext extends ReturnType { readonly peResources: PeResourcesService; readonly nativeExports: NativeExportsService; readonly elfNotes: ElfNotesService; + readonly appleIps: AppleIpsService; readonly peCertificates: PeCertificatesService; readonly peImports: PeImportsService; readonly electronFuses: ElectronFuseService; diff --git a/src/server/registerAppleIpsTool.ts b/src/server/registerAppleIpsTool.ts new file mode 100644 index 0000000000..50ed1c1ab0 --- /dev/null +++ b/src/server/registerAppleIpsTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file Apple .ips workflow and retain its complete Evidence. */ +export const registerAppleIpsTool = ( + server: EvidenceMcpServer, + service: AppleIpsService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_apple_ips"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/appleIpsCli.test.ts b/tests/acceptance/appleIpsCli.test.ts new file mode 100644 index 0000000000..a529c00761 --- /dev/null +++ b/tests/acceptance/appleIpsCli.test.ts @@ -0,0 +1,117 @@ +import { writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { appleIpsBytes } from "../fixtures/binaryDiagnostics/appleIps.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "decodes escape-dense IPS text within the selected Node heap", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-ips-escaped-"); + const path = join(root, "escaped.ips"); + const name = "\n".repeat(7_500_000); + await writeFile( + path, + `{"bug_type":"309"}\n${JSON.stringify({ procName: name, threads: [], usedImages: [] })}`, + ); + const result = await cli.run({ + arguments: [ + "inspect-apple-ips", + path, + "--max-file-bytes", + "16777216", + "--json", + ], + environment: { NODE_OPTIONS: "--max-old-space-size=256" }, + timeoutMs: 30000, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_apple_ips").outputSchema.parse( + result.json, + ); + const actual = evidence.normalized_result.process.name; + expect(actual?.length).toBe(name.length); + expect( + createHash("sha256") + .update(actual ?? "") + .digest("hex"), + ).toBe(createHash("sha256").update(name).digest("hex")); + expect(evidence.normalized_result.coverage.status).toBe("complete"); + }, + 45000, +); + +cliTest( + "reports Apple IPS crash facts through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-ips-cli-"); + const path = join(root, "report.ips"); + await writeFile(path, appleIpsBytes()); + const constrained = await cli.run({ + arguments: [ + "inspect-apple-ips", + path, + "--max-integer-text-bytes", + "1", + "--json", + ], + }); + expect(constrained.exitCode, constrained.stderr).toBe(1); + expect(constrained.json).toMatchObject({ + code: "resource_constraint", + remediation: { + action: expect.stringContaining("max_integer_text_bytes"), + }, + details: { + reported_limits: { boundary: "integer_text", maximum: 1 }, + }, + }); + const result = await cli.run({ + arguments: ["inspect-apple-ips", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_apple_ips").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.metadata.bug_type).toBe("309"); + expect(evidence.normalized_result.exception?.type).toBe("EXC_BAD_ACCESS"); + expect(evidence.normalized_result.images[0]?.uuid).toBe( + "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + ); + }, +); + +cliTest( + "resolves a relative IPS path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-ips-cli-rel-"); + await writeFile(join(root, "report.ips"), appleIpsBytes()); + const result = await cli.run({ + arguments: ["inspect-apple-ips", "report.ips", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_apple_ips").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.process.name).toBe("rea-crash-fixture"); + expect(evidence.subject?.local_path).toBe(join(root, "report.ips")); + }, +); + +cliTest("reports a truncated IPS as invalid input", async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-ips-cli-bad-"); + const path = join(root, "broken.ips"); + await writeFile(path, appleIpsBytes({ truncateBody: true })); + const result = await cli.run({ + arguments: ["inspect-apple-ips", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_apple_ips" }, + }); +}); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index acf88f37e0..df5f3238f9 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -222,6 +222,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_pe_imports", { path: nul }], ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], + ["inspect_apple_ips", { path: nul }], ["inspect_python_pyc", { path: nul }], ["inspect_erlang_beam", { path: nul }], ["inspect_binary_layout", { path: nul }], diff --git a/tests/boundary/filesystem/appleIps.test.ts b/tests/boundary/filesystem/appleIps.test.ts new file mode 100644 index 0000000000..1171f82418 --- /dev/null +++ b/tests/boundary/filesystem/appleIps.test.ts @@ -0,0 +1,113 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createAppleIpsService } from "../../../src/composition/binaryDiagnostics.js"; +import { + appleIpsBytes, + appleIpsMetadataLine, +} from "../../fixtures/binaryDiagnostics/appleIps.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("Apple IPS stable filesystem boundary", () => { + it("reports non-file and malformed IPS selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-apple-ips-files-"); + const path = join(root, "report.ips"); + const alias = join(root, "alias.ips"); + const malformed = join(root, "malformed.ips"); + await writeFile(path, appleIpsBytes()); + await writeFile(malformed, Buffer.from("not an ips report")); + await symlink(path, alias); + const service = createAppleIpsService(); + for (const [candidate, reason] of [ + [root, "invalid_format"], + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing.ips"), "invalid_value"], + [join(path, "child.ips"), "invalid_value"], + ] as const) { + const result = await service.inspect({ path: candidate }); + if (result.ok) throw new Error("Expected file acquisition failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_apple_ips", + issues: [ + { + path: ["path"], + reason, + message: expect.any(String), + }, + ], + }); + } + }); + + it("returns typed resource and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-apple-ips-budget-"); + const path = join(root, "report.ips"); + await writeFile(path, appleIpsBytes()); + const service = createAppleIpsService(); + const tooSmall = await service.inspect({ path, max_file_bytes: 64 }); + if (tooSmall.ok) throw new Error("Expected resource budget failure"); + expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError"); + const cancelled = await service.inspect( + { path }, + { signal: AbortSignal.abort() }, + ); + if (cancelled.ok) throw new Error("Expected cancellation"); + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + }); + + it("maps register, fixed structural and UTF-8 refusals to typed errors", async () => { + const root = await createTestTempDirectory("rea-apple-ips-shape-"); + const report = join(root, "report.ips"); + const deep = join(root, "deep.ips"); + const corrupt = join(root, "corrupt.ips"); + await writeFile(report, appleIpsBytes()); + await writeFile( + deep, + `${appleIpsMetadataLine()}\n{"vmSummary":${"[".repeat(100)}${"]".repeat(100)}}`, + ); + const bytes = appleIpsBytes(); + bytes[bytes.indexOf(Buffer.from("rea-crash-fixture"))] = 0xc3; + await writeFile(corrupt, bytes); + const service = createAppleIpsService(); + + const registers = await service.inspect({ path: report, max_registers: 2 }); + if (registers.ok) throw new Error("Expected register budget failure"); + expect(registers.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "capacity", + reportedLimits: { boundary: "registers", maximum: 2 }, + retryable: false, + remediationAction: expect.stringContaining("max_registers"), + }); + + const nesting = await service.inspect({ path: deep }); + if (nesting.ok) throw new Error("Expected nesting allowance failure"); + expect(nesting.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reportedLimits: { boundary: "nesting", maximum: 64 }, + retryable: false, + fixedAllowance: { kind: "parser" }, + }); + + const invalid = await service.inspect({ path: corrupt }); + if (invalid.ok) throw new Error("Expected invalid UTF-8 failure"); + expect(invalid.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: expect.stringContaining("UTF-8"), + }, + ], + }); + + const complete = await service.inspect({ path: report }); + if (!complete.ok) throw complete.error; + expect(complete.value.normalized_result).toMatchObject({ + coverage: { status: "complete", registers: 6, type_mismatches: [] }, + }); + }); +}); diff --git a/tests/boundary/mcp/appleIpsMcp.test.ts b/tests/boundary/mcp/appleIpsMcp.test.ts new file mode 100644 index 0000000000..015e61d7e7 --- /dev/null +++ b/tests/boundary/mcp/appleIpsMcp.test.ts @@ -0,0 +1,148 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; +import { appleIpsSchema } from "../../../src/domain/native/appleIps.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { appleIpsBytes } from "../../fixtures/binaryDiagnostics/appleIps.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +it("advertises and validates IPS facts and failures through production stdio MCP", async () => { + const { client, call } = await connectLocalToolsMcp(); + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_apple_ips", + ); + if (advertised?.outputSchema === undefined) + throw new Error("Missing advertised IPS output schema"); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-apple-ips-mcp-"); + const path = join(root, "report.ips"); + await writeFile(path, appleIpsBytes()); + expect(ajv.validate(inputSchema, { path, max_integer_text_bytes: 1 })).toBe( + true, + ); + const constrained = await call("inspect_apple_ips", { + path, + max_integer_text_bytes: 1, + }); + expect(constrained.isError).toBe(true); + expect(parseMcpToolError(constrained).error).toMatchObject({ + code: "resource_constraint", + remediation: { + action: expect.stringContaining( + "Raise max_integer_text_bytes on the next call", + ), + }, + details: { + reported_limits: { boundary: "integer_text", maximum: 1 }, + }, + }); + const response = await call("inspect_apple_ips", { path }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + const report = appleIpsSchema.parse(evidence.normalized_result); + expect(evidence.operation).toBe("inspect_apple_ips"); + expect(report.metadata.bug_type).toBe("309"); + expect(report.threads[0]?.registers[0]).toMatchObject({ + name: "x0", + value: { + decimal: "9594637537912899908", + hex: "0x8527000193ed4944", + }, + }); + expect(report.classification.status).toBe("unclassified"); + const unsupported = { + ...evidence, + normalized_result: { + ...report, + metadata: { ...report.metadata, bug_type: "288" }, + }, + }; + expect(ajv.validate(outputSchema, unsupported)).toBe(false); + const match = { + rule: "watchdog", + fields: [{ name: "termination.code", value: "2343432205" }], + }; + for (const [status, matches, accepted] of [ + ["classified", [], false], + ["classified", [match], true], + ["unclassified", [match], false], + ["unclassified", [], true], + ] as const) { + expect( + ajv.validate(outputSchema, { + ...evidence, + normalized_result: { ...report, classification: { status, matches } }, + }), + ).toBe(accepted); + } + + const whitespacePath = join(root, "non-json-whitespace.ips"); + await writeFile( + whitespacePath, + Buffer.concat([appleIpsBytes(), Buffer.from("\f")]), + ); + const whitespace = await call("inspect_apple_ips", { path: whitespacePath }); + expect(whitespace.isError).toBe(true); + expect(parseMcpToolError(whitespace).error.code).toBe("invalid_request"); + + const malformedPath = join(root, "malformed-uuid.ips"); + await writeFile(malformedPath, appleIpsBytes({ uuid: "malformed/report" })); + const malformed = await call("inspect_apple_ips", { path: malformedPath }); + expect(malformed.isError, JSON.stringify(malformed.content)).not.toBe(true); + expect( + ajv.validate(outputSchema, malformed.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const partial = appleIpsSchema.parse( + parseEvidence(malformed.structuredContent).normalized_result, + ); + expect(partial.coverage.status).toBe("partial"); + expect(partial.coverage.type_mismatches).toEqual([ + { + path: "body.usedImages[0].uuid", + expected: "32 hexadecimal UUID digits with optional canonical hyphens", + observed: "malformed UUID string", + recorded_value: "malformed/report", + }, + ]); + const indexPath = join(root, "unresolved-indexes.ips"); + await writeFile( + indexPath, + '{"bug_type":"309"}\n{"faultingThread":5,"threads":[{"frames":[{"imageIndex":-1},{"imageIndex":4294967296}]}],"usedImages":[]}', + ); + const indexes = await call("inspect_apple_ips", { path: indexPath }); + expect(indexes.isError, JSON.stringify(indexes.content)).not.toBe(true); + expect( + ajv.validate(outputSchema, indexes.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const unresolved = appleIpsSchema.parse( + parseEvidence(indexes.structuredContent).normalized_result, + ); + expect(unresolved.faulting_thread).toBe(5); + expect(unresolved.threads[0]?.frames[0]?.role).toBe("unknown"); + expect(unresolved.coverage.status).toBe("partial"); + expect( + unresolved.coverage.type_mismatches.map( + (mismatch) => mismatch.recorded_integer, + ), + ).toEqual(["5", "-1", "4294967296"]); + const relative = await call("inspect_apple_ips", { path: "report.ips" }); + expect(relative.isError).toBe(true); + expect(parseMcpToolError(relative).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_apple_ips" }, + }); +}); diff --git a/tests/fixtures/binaryDiagnostics/appleIps.ts b/tests/fixtures/binaryDiagnostics/appleIps.ts new file mode 100644 index 0000000000..29b1c6e007 --- /dev/null +++ b/tests/fixtures/binaryDiagnostics/appleIps.ts @@ -0,0 +1,168 @@ +export interface AppleIpsFixtureOptions { + readonly bugType?: string; + readonly procName?: string; + readonly uuid?: string; + readonly arch?: string | null; + readonly imageOffset?: number; + readonly base?: number; + readonly exceptionType?: string; + readonly signal?: string; + readonly subtype?: string; + readonly terminationNamespace?: string; + readonly terminationCode?: string; + readonly terminationIndicator?: string; + readonly terminationReasons?: readonly string[]; + readonly registerValue?: string; + /** Emit the older string `osVersion` form instead of the producer dictionary. */ + readonly osVersionText?: string; + readonly extraBody?: string; + readonly truncateBody?: boolean; +} + +const defaultUuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + +/** First-line metadata object as macOS ReportCrash writes it. */ +export const appleIpsMetadataLine = ( + options: Pick = {}, +): string => + JSON.stringify({ + app_name: options.procName ?? "rea-crash-fixture", + timestamp: "2026-01-01 00:00:00.000 +0000", + app_version: "", + slice_uuid: defaultUuid, + build_version: "", + platform: 1, + share_with_app_devs: 0, + is_first_party: 1, + bug_type: options.bugType ?? "309", + os_version: "macOS 15.0 (24A335)", + roots_installed: 0, + bundleID: "test.rea.fixture", + incident_id: "AAAA-BBBB", + name: options.procName ?? "rea-crash-fixture", + }); + +/** + * Two-object Apple .ips crash report with a 309 metadata line, shaped like + * ReportCrash output: dictionary `osVersion`, `threadState.flavor`, register + * descriptions, a non-faulting thread with an inlined top frame, and unlisted + * producer keys. + */ +export const appleIpsReport = ( + options: AppleIpsFixtureOptions = {}, +): string => { + const metadata = appleIpsMetadataLine(options); + const uuid = options.uuid ?? defaultUuid; + const arch = options.arch === undefined ? "arm64" : options.arch; + const base = options.base ?? 4294967296; + const imageOffset = options.imageOffset ?? 824; + const register = options.registerValue ?? "9594637537912899908"; + const reasons = + options.terminationReasons === undefined + ? "" + : `,"reasons":${JSON.stringify([...options.terminationReasons])}`; + const extra = options.extraBody ?? ""; + const archJson = arch === null ? "null" : JSON.stringify(arch); + const osVersion = + options.osVersionText === undefined + ? `{ "train": "macOS 15.0", "build": "24A335", "releaseType": "User" }` + : JSON.stringify(options.osVersionText); + const body = `{ + "uptime": 1000, + "procRole": "Foreground", + "version": 2, + "userID": 501, + "deployVersion": 210, + "modelCode": "Mac14,2", + "osVersion": ${osVersion}, + "captureTime": "2026-01-01 00:00:00.0000 +0000", + "incident": "AAAA-BBBB", + "pid": 4242, + "translated": false, + "cpuType": "ARM-64", + "procName": ${JSON.stringify(options.procName ?? "rea-crash-fixture")}, + "procPath": "/tmp/rea-crash-fixture", + "parentProc": "zsh", + "parentPid": 100, + "coalitionName": "com.apple.Terminal", + "instructionByteStream": { "beforePC": "AAAA", "atPC": "AAAA" }, + "sip": "enabled", + "vmRegionInfo": "0x10 is not in any region.", + "exception": { + "codes": "0x0000000000000001, 0x0000000000000010", + "rawCodes": [1, 16], + "type": ${JSON.stringify(options.exceptionType ?? "EXC_BAD_ACCESS")}, + "signal": ${JSON.stringify(options.signal ?? "SIGSEGV")}, + "subtype": ${JSON.stringify(options.subtype ?? "KERN_INVALID_ADDRESS at 0x0000000000000010")} + }, + "termination": { + "flags": 0, + "code": ${options.terminationCode ?? "11"}, + "namespace": ${JSON.stringify(options.terminationNamespace ?? "SIGNAL")}, + "indicator": ${JSON.stringify(options.terminationIndicator ?? "Segmentation fault: 11")}, + "byProc": "exc handler", + "byPid": 4242${reasons} + }, + "extMods": { "caller": { "thread_create": 0 }, "warnings": 0 }, + "faultingThread": 0, + "threads": [ + { + "triggered": true, + "id": 1, + "threadState": { + "x": [ + { "value": ${register} }, + { "value": 16 } + ], + "flavor": "ARM_THREAD_STATE64", + "lr": { "value": ${String(base + 900)} }, + "esr": { "value": 2449473606, "description": "(Data Abort) byte read Translation fault" }, + "pc": { "value": ${String(base + imageOffset)}, "matchesCrashFrame": 1 }, + "far": { "value": 16 } + }, + "queue": "com.apple.main-thread", + "frames": [ + { "imageOffset": ${String(imageOffset)}, "symbol": "rea_fixture_fault", "symbolLocation": 12, "imageIndex": 0 }, + { "imageOffset": 900, "symbol": "main", "symbolLocation": 40, "imageIndex": 0 } + ] + }, + { + "id": 2, + "name": "worker", + "frames": [ + { "imageOffset": 1000, "symbol": "rea_fixture_inlined", "symbolLocation": 0, "imageIndex": 0, "inline": true }, + { "imageOffset": 1000, "symbol": "rea_fixture_wait", "symbolLocation": 8, "imageIndex": 0 }, + { "imageOffset": 1100, "symbol": "rea_fixture_worker", "symbolLocation": 20, "imageIndex": 0 } + ] + } + ], + "usedImages": [ + { + "source": "P", + "arch": ${archJson}, + "base": ${String(base)}, + "size": 16384, + "uuid": ${JSON.stringify(uuid)}, + "path": "/tmp/rea-crash-fixture", + "name": "rea-crash-fixture" + }, + { + "source": "A", + "arch": null, + "base": 0, + "size": 0, + "uuid": "00000000-0000-0000-0000-000000000000", + "path": "" + } + ], + "sharedCache": { "base": 6442450944, "size": 4294967296, "uuid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" }, + "vmSummary": "ReadOnly portion of Libraries: Total=1.0G", + "legacyInfo": { "threadTriggered": { "queue": "com.apple.main-thread" } }, + "trialInfo": { "rollouts": [], "experiments": [] }${extra} +}`; + if (options.truncateBody === true) return `${metadata}\n${body.slice(0, 40)}`; + return `${metadata}\n${body}`; +}; + +export const appleIpsBytes = (options: AppleIpsFixtureOptions = {}): Buffer => + Buffer.from(appleIpsReport(options), "utf8"); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index 5ef18a45a1..37cc9d6f3b 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -96,11 +96,13 @@ import { inspectPythonPycInputSchema } from "../../src/domain/python/pythonPyc.j import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js"; import { Il2cppMetadataProvider } from "../../src/il2cpp/Il2cppMetadataProvider.js"; import { ElfNotesProvider } from "../../src/native/elf/ElfNotesProvider.js"; +import { AppleIpsProvider } from "../../src/native/apple/AppleIpsProvider.js"; import { PeCertificatesProvider } from "../../src/native/pe/PeCertificatesProvider.js"; import { PeImportsProvider } from "../../src/native/pe/PeImportsProvider.js"; import { NativeExportsProvider } from "../../src/native/exports/NativeExportsProvider.js"; import { BeamProvider } from "../../src/erlang/BeamProvider.js"; import { inspectElfNotesInputSchema } from "../../src/domain/native/elfNotes.js"; +import { inspectAppleIpsInputSchema } from "../../src/domain/native/appleIps.js"; import { inspectPeCertificatesInputSchema } from "../../src/domain/native/peCertificates.js"; import { inspectPeImportsInputSchema } from "../../src/domain/native/peImports.js"; import { inspectNativeExportsInputSchema } from "../../src/domain/native/nativeExports.js"; @@ -856,6 +858,7 @@ it.each([ const stableArtifactProviders = [ ...staticProviders, ["ELF notes", () => new ElfNotesProvider()], + ["Apple IPS", () => new AppleIpsProvider()], ["PE certificates", () => new PeCertificatesProvider()], ["PE imports", () => new PeImportsProvider()], ["native exports", () => new NativeExportsProvider()], @@ -883,6 +886,11 @@ const inspectFileFailure = ( inspectElfNotesInputSchema.parse({ path }), options, ); + if (provider instanceof AppleIpsProvider) + return provider.inspect( + inspectAppleIpsInputSchema.parse({ path }), + options, + ); if (provider instanceof PeCertificatesProvider) return provider.inspect( inspectPeCertificatesInputSchema.parse({ path }),