From 781760a195c9d90c1bd3d57ad9ff55e5461bfcd4 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sat, 24 Oct 2026 01:02:59 -0700 Subject: [PATCH] feat(apple): inspect IPS crash reports (#6511) * feat(apple): inspect IPS crash reports Add inspect_apple_ips for caller-selected Apple .ips files. Parse 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 documented classification rules. Other bug types including 288 stackshots return no crash result. Opened Mach-O and dSYM UUID joins remain unobserved. Related to #4297 * fix(apple): keep IPS register literals exact Use the arm64e fixture integer that matches 0x8527000193ed4944 instead of an IEEE-754-rounded decimal, and count the lossless JSON example length from the source text. * fix(apple): budget IPS parsing and keep typed presence Parse the IPS metadata and crash body through a projection of the documented members. Members the report does not interpret are syntax-checked without being materialized, and thread, frame, image and the new max_registers budgets run before each row is parsed, so a large selected file fails with a typed resource error instead of first expanding every row. Fixed allowances bound JSON nesting, unlisted key names and termination reasons. Present values whose JSON type differs from the documented one are no longer read as absence: they are listed in coverage.type_mismatches and coverage becomes partial, including wrong-type or missing threads and usedImages collections. osVersion keeps the producer dictionary or the string form. Frame roles follow physical frame depth, so the top frame of a non-faulting thread is current_instruction and inlined rows share the following frame's role. SIGKILL classes compare the canonical hex once, so 0xdeadd0cc no longer matches dead10cc. Invalid UTF-8 is refused instead of being replaced, and JSON strings are decoded by copying unescaped spans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ra7LM2NyQ7DumdssxbUq5g * fix(apple): bound integer text expansion and reconcile coverage * refactor(apple): retain image metadata at its owning row * refactor(apple): validate row capacity through reconciled coverage * test(apple): verify resource recovery at the public error boundary * docs(apple): record complete IPS task and corpus measurements * docs(apple): record integrated checks and stdio corpus evidence * docs(apple): record PE integration and baseline cleanup controls * fix(native): bound IPS string decoding and preserve invalid identities * refactor(native): use exact optional IPS source evidence * docs(native): verify IPS escape capacity and complete-task costs * test(native): verify IPS through production stdio MCP * style(tests): format IPS advertised schema bindings * fix(native): preserve unresolved IPS references and recorded frame semantics * docs(native): record IPS reference review evidence * fix(apple-ips): enforce JSON and report consistency * docs(apple-ips): record final consistency verification --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 8 + .../references/native-and-artifacts.md | 12 + README.md | 1 + docs/.vitepress/config.ts | 1 + docs/apple-ips.md | 105 +++ docs/cli.md | 11 + docs/index.md | 2 +- docs/readme/README_ar.md | 1 + docs/readme/README_de.md | 1 + docs/readme/README_el.md | 1 + docs/readme/README_es.md | 1 + docs/readme/README_fa.md | 1 + docs/readme/README_fr.md | 1 + docs/readme/README_id.md | 1 + docs/readme/README_it.md | 1 + docs/readme/README_ja.md | 1 + docs/readme/README_ko.md | 1 + docs/readme/README_ml.md | 1 + docs/readme/README_pl.md | 1 + docs/readme/README_pt-BR.md | 1 + docs/readme/README_ru.md | 1 + docs/readme/README_th.md | 1 + docs/readme/README_tr.md | 1 + docs/readme/README_uk.md | 1 + docs/readme/README_ur-Latn.md | 1 + docs/readme/README_vi.md | 1 + docs/readme/README_zh-TW.md | 1 + docs/readme/README_zh.md | 1 + docs/recorded-crashes.md | 3 +- docs/verification/apple-ips-2026-10-24.md | 329 +++++++++ scripts/lib/catalog-builders.mjs | 7 + src/application/CapabilityInventory.ts | 1 + .../binaryDiagnostics/AppleIpsPort.ts | 20 + .../binaryDiagnostics/AppleIpsService.test.ts | 275 +++++++ .../binaryDiagnostics/AppleIpsService.ts | 140 ++++ src/cli/binaryDiagnosticsCommands.ts | 46 ++ src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 6 + .../native/binaryDiagnosticsToolContracts.ts | 19 + src/contracts/toolEffects.test.ts | 1 + src/contracts/toolEffects.ts | 1 + src/domain/investigationProviders.ts | 7 + src/domain/native/appleIps.test.ts | 74 ++ src/domain/native/appleIps.ts | 328 +++++++++ src/native/apple/AppleIps.errors.ts | 36 + src/native/apple/AppleIpsClassification.ts | 94 +++ src/native/apple/AppleIpsFieldReader.ts | 187 +++++ src/native/apple/AppleIpsJsonShape.ts | 232 ++++++ src/native/apple/AppleIpsParser.test.ts | 672 ++++++++++++++++++ src/native/apple/AppleIpsParser.ts | 434 +++++++++++ src/native/apple/AppleIpsProvider.ts | 292 ++++++++ src/native/apple/losslessJson.test.ts | 128 ++++ src/native/apple/losslessJson.ts | 427 +++++++++++ src/server/createServer.ts | 15 + src/server/registerAppleIpsTool.ts | 31 + tests/acceptance/appleIpsCli.test.ts | 117 +++ .../tools/localToolContracts.test.ts | 1 + tests/boundary/filesystem/appleIps.test.ts | 113 +++ tests/boundary/mcp/appleIpsMcp.test.ts | 148 ++++ tests/fixtures/binaryDiagnostics/appleIps.ts | 168 +++++ .../stableArtifactConsumers.test.ts | 8 + 61 files changed, 4520 insertions(+), 2 deletions(-) create mode 100644 docs/apple-ips.md create mode 100644 docs/verification/apple-ips-2026-10-24.md create mode 100644 src/application/binaryDiagnostics/AppleIpsPort.ts create mode 100644 src/application/binaryDiagnostics/AppleIpsService.test.ts create mode 100644 src/application/binaryDiagnostics/AppleIpsService.ts create mode 100644 src/domain/native/appleIps.test.ts create mode 100644 src/domain/native/appleIps.ts create mode 100644 src/native/apple/AppleIps.errors.ts create mode 100644 src/native/apple/AppleIpsClassification.ts create mode 100644 src/native/apple/AppleIpsFieldReader.ts create mode 100644 src/native/apple/AppleIpsJsonShape.ts create mode 100644 src/native/apple/AppleIpsParser.test.ts create mode 100644 src/native/apple/AppleIpsParser.ts create mode 100644 src/native/apple/AppleIpsProvider.ts create mode 100644 src/native/apple/losslessJson.test.ts create mode 100644 src/native/apple/losslessJson.ts create mode 100644 src/server/registerAppleIpsTool.ts create mode 100644 tests/acceptance/appleIpsCli.test.ts create mode 100644 tests/boundary/filesystem/appleIps.test.ts create mode 100644 tests/boundary/mcp/appleIpsMcp.test.ts create mode 100644 tests/fixtures/binaryDiagnostics/appleIps.ts 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 }),