From 91b8f587cd495c509946e69076525eb19dc1ca65 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:59:02 +0800 Subject: [PATCH] feat(signals): decode recordings with bounded sigrok output (#2339) Decode caller-selected protocols from existing recordings through shared CLI/MCP Evidence. Page literal output lines after complete decoding; preserve explicit full output, source identity and failed cleanup ownership. Verified real sigrok-cli 0.7.2 workflows, published UART/SPI/I2C recordings, independent UART values, selector limits, SDK schemas and cleanup regressions. Co-authored-by: Olorinm <59321404+Olorinm@users.noreply.github.com> Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- .../skills/reverse-engineer-anything/SKILL.md | 7 + .github/workflows/real-signal-recording.yml | 50 ++ README.md | 1 + README_ar.md | 1 + README_de.md | 1 + README_es.md | 1 + README_fa.md | 39 +- README_fr.md | 1 + README_id.md | 1 + README_it.md | 1 + README_ja.md | 1 + README_ko.md | 1 + README_pl.md | 1 + README_pt-BR.md | 1 + README_ru.md | 39 +- README_th.md | 1 + README_tr.md | 1 + README_uk.md | 1 + README_vi.md | 1 + README_zh-TW.md | 1 + README_zh.md | 1 + docs/.vitepress/config.ts | 1 + docs/signal-recordings.md | 108 ++++ scripts/lib/catalog-builders.mjs | 5 + scripts/lib/catalog-core.mjs | 2 + scripts/lib/module-boundaries.mjs | 1 + scripts/verify-signal-recording.mjs | 271 ++++++++++ src/application/CapabilityInventory.ts | 9 + .../signals/SignalRecordingService.ts | 93 ++++ src/captures/TsharkCaptureProvider.ts | 272 +++------- src/cli.ts | 2 + src/cli/signalCommands.ts | 99 ++++ src/cliCommandNames.ts | 1 + src/composition/signals.ts | 6 + src/contracts/signals/signalToolContracts.ts | 29 ++ src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.ts | 5 + .../signals/annotationTextWindow.test.ts | 48 ++ src/domain/signals/annotationTextWindow.ts | 35 ++ src/domain/signals/signalRecording.ts | 148 ++++++ src/process/ArtifactAnalysisResources.ts | 272 ++++++++++ src/server/createServer.ts | 17 + src/server/registerSignalTools.ts | 31 ++ src/server/sessionAvailabilityPolicy.ts | 3 + src/signals/SigrokRecordingProvider.ts | 468 ++++++++++++++++++ tests/boundary/cli/signalCleanup.test.ts | 132 +++++ tests/boundary/mcp/signalRecordingMcp.test.ts | 114 +++++ .../recordingDescriptorCleanup.test.ts | 108 ++++ .../signals/recordingProvider.test.ts | 421 ++++++++++++++++ tests/boundary/signals/resourceLimits.test.ts | 112 +++++ 50 files changed, 2715 insertions(+), 252 deletions(-) create mode 100644 .github/workflows/real-signal-recording.yml create mode 100644 docs/signal-recordings.md create mode 100644 scripts/verify-signal-recording.mjs create mode 100644 src/application/signals/SignalRecordingService.ts create mode 100644 src/cli/signalCommands.ts create mode 100644 src/composition/signals.ts create mode 100644 src/contracts/signals/signalToolContracts.ts create mode 100644 src/domain/signals/annotationTextWindow.test.ts create mode 100644 src/domain/signals/annotationTextWindow.ts create mode 100644 src/domain/signals/signalRecording.ts create mode 100644 src/process/ArtifactAnalysisResources.ts create mode 100644 src/server/registerSignalTools.ts create mode 100644 src/signals/SigrokRecordingProvider.ts create mode 100644 tests/boundary/cli/signalCleanup.test.ts create mode 100644 tests/boundary/mcp/signalRecordingMcp.test.ts create mode 100644 tests/boundary/signals/recordingDescriptorCleanup.test.ts create mode 100644 tests/boundary/signals/recordingProvider.test.ts create mode 100644 tests/boundary/signals/resourceLimits.test.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 3e1cac44c..cd50cff64 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -74,6 +74,13 @@ explicit path or endpoint and do not need it. frames; use `match_offset` / `match_limit` for later pages, or explicit null limit for complete matching output. Preserve the source digest across pages. This does not perform live capture. +- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder, + channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through + `REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md). + Output defaults to 50 literal lines; select annotation classes and page with + `line_offset` / `line_limit`, or explicit null for all remaining output. + Window counts are display lines, not annotations or samples. Preserve source digests across pages. + This reads existing files; it does not acquire hardware signals. - Explicit local WebAssembly artifact: `inspect_wasm_artifact`. Supply WABT 1.0.42 through `REA_WABT_BIN_DIRECTORY`; retain exact byte/tool identity and decoded WAT. Select JavaScript glue and candidate paths explicitly and preserve URL/path diff --git a/.github/workflows/real-signal-recording.yml b/.github/workflows/real-signal-recording.yml new file mode 100644 index 000000000..5ec413e72 --- /dev/null +++ b/.github/workflows/real-signal-recording.yml @@ -0,0 +1,50 @@ +name: Real signal recording +on: + workflow_dispatch: + pull_request: + paths: + - "tests/boundary/signals/**" + - "tests/boundary/cli/signalCleanup.test.ts" + - "tests/boundary/mcp/signalRecordingMcp.test.ts" + - "src/signals/**" + - "src/domain/signals/**" + - "src/application/signals/**" + - "src/contracts/signals/**" + - "src/composition/signals.ts" + - "src/cli/signalCommands.ts" + - "src/server/registerSignalTools.ts" + - "src/server/createServer.ts" + - "src/server/sessionAvailabilityPolicy.ts" + - "src/application/CapabilityInventory.ts" + - "src/contracts/toolContracts.ts" + - "src/contracts/toolEffects.ts" + - "src/cli.ts" + - "src/cliCommandNames.ts" + - "src/process/**" + - "src/artifacts/readStableArtifact.ts" + - "scripts/verify-signal-recording.mjs" + - ".github/workflows/real-signal-recording.yml" + - "package*.json" + - ".nvmrc" +permissions: + contents: read +concurrency: + group: real-signal-recording-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + sigrok: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3 + - run: npm ci + - run: npm run build:cached + - run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts + - run: node scripts/verify-signal-recording.mjs + env: + REA_SIGROK_COMMAND: /usr/bin/sigrok-cli diff --git a/README.md b/README.md index 2c30aa8ef..1e14f3a2d 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ Additional tools and host support depend on the target: | Flutter apps | Dart snapshot hash, per-ABI library digests and engine build provenance | None (static APK parsing); [Flutter guide](docs/flutter-analysis.md) | | Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) | | Recorded packets | Selected fields, repeated values, filters and decode-as rules | Linux: TShark 4.x + prlimit; [capture guide](docs/packet-captures.md) | +| Recorded logic signals | Decoder annotations, sample ranges and diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [signal recordings](docs/signal-recordings.md) | | Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) | | Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) | diff --git a/README_ar.md b/README_ar.md index 51fc7ba6e..0693fe67f 100644 --- a/README_ar.md +++ b/README_ar.md @@ -147,6 +147,7 @@ npx rea-agents@latest setup | تطبيقات Flutter | بصمة لقطة Dart وهشوش المكتبات لكل ABI وأصل بناء المحرك | لا شيء (تحليل APK ساكن)؛ [دليل Flutter](docs/flutter-analysis.md) | | البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | Binwalk / Unblob على Linux؛ [دليل البرامج الثابتة](docs/firmware-analysis.md) | | الحزم المسجلة | حقول مختارة وقيم متكررة ومرشحات وقواعد فك الترميز | Linux: TShark 4.x + prlimit; [دليل التقاط الحزم](docs/packet-captures.md) | +| الإشارات المنطقية المسجلة | تعليقات مفكك الترميز ونطاقات العينات ومعلومات التشخيص | Linux: sigrok-cli 0.7.2 + prlimit; [دليل الإشارات](docs/signal-recordings.md) | | الحزم والموارد | قوائم الملفات، والبصمات، وملفات plist، وبنية حزم Apple، والموارد المستخرجة | [دليل تحليل الملفات وJavaScript](docs/javascript-artifact-reconstruction.md)، [تطبيقات Apple](docs/apple-application-analysis.md) | | سلوك العمليات | مخرجات الطرفية، والتفاعلات، وملاحظات الخروج ونظام الملفات، ومقارنات التشغيل | Linux/macOS مع PTY أصلي؛ [تسجيل سلوك العمليات](docs/process-capture.md) | diff --git a/README_de.md b/README_de.md index 2917a7984..1e96cd46b 100644 --- a/README_de.md +++ b/README_de.md @@ -147,6 +147,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | Flutter-Apps | Dart-Snapshot-Hash, Per-ABI-Bibliotheksprüfsummen und Engine-Build-Provenienz | Keine (statische APK-Analyse); [Flutter-Leitfaden](docs/flutter-analysis.md) | | Firmware | Bereiche, Extraktionsergebnisse und Übergaben an die native Analyse | Binwalk / Unblob unter Linux; [Firmware-Leitfaden](docs/firmware-analysis.md) | | Aufgezeichnete Pakete | Ausgewählte Felder, wiederholte Werte, Filter und Decode-as-Regeln | Linux: TShark 4.x + prlimit; [Mitschnitt-Anleitung](docs/packet-captures.md) | +| Aufgezeichnete Logiksignale | Decoder-Anmerkungen, Sample-Bereiche und Diagnosen | Linux: sigrok-cli 0.7.2 + prlimit; [Signalaufzeichnungen](docs/signal-recordings.md) | | Pakete und Ressourcen | Dateiinventare, Hashwerte, plists, Aufbau von Apple-Bundles und extrahierte Ressourcen | [Artefakt- und JavaScript-Leitfaden](docs/javascript-artifact-reconstruction.md), [Apple-Anwendungen](docs/apple-application-analysis.md) | | Prozessverhalten | Terminalausgabe, Interaktionen, Beobachtungen zu Prozessende und Dateisystem sowie Laufvergleiche | Linux/macOS mit nativer PTY; [Prozessaufzeichnung](docs/process-capture.md) | diff --git a/README_es.md b/README_es.md index 69823b0bf..9a59a74d2 100644 --- a/README_es.md +++ b/README_es.md @@ -147,6 +147,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Aplicaciones Flutter | Hash de snapshot de Dart, resúmenes de bibliotecas por ABI y procedencia de compilación del motor | Ninguna (análisis APK estático); [guía de Flutter](docs/flutter-analysis.md) | | Firmware | Regiones, resultados de extracción y derivaciones al análisis nativo | Binwalk / Unblob en Linux; [guía de firmware](docs/firmware-analysis.md) | | Paquetes grabados | Campos seleccionados, valores repetidos, filtros y reglas de decodificación | Linux: TShark 4.x + prlimit; [guía de capturas](docs/packet-captures.md) | +| Señales lógicas grabadas | Anotaciones del decodificador, rangos de muestras y diagnósticos | Linux: sigrok-cli 0.7.2 + prlimit; [guía de señales](docs/signal-recordings.md) | | Paquetes y recursos | Inventarios de archivos, resúmenes criptográficos, plists, estructura de paquetes de Apple y recursos extraídos | [Guía de artefactos y JavaScript](docs/javascript-artifact-reconstruction.md), [aplicaciones de Apple](docs/apple-application-analysis.md) | | Comportamiento de procesos | Salida de la terminal, interacciones, observaciones de salida y del sistema de archivos, y comparaciones entre ejecuciones | Linux/macOS con una PTY nativa; [captura de procesos](docs/process-capture.md) | diff --git a/README_fa.md b/README_fa.md index d8be73583..e34788e7a 100644 --- a/README_fa.md +++ b/README_fa.md @@ -122,25 +122,26 @@ npx rea-agents@latest setup REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه npm نیاز دارد. ابزارهای اضافی و پشتیبانی سیستم میزبان به نوع هدف بستگی دارند: -| هدف | خروجی REA | پیش‌نیازها و راهنما | -| --------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) | -| ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](docs/binary-diagnostics.md) | -| بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](docs/evm-bytecode.md) | -| کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](docs/recorded-crashes.md) | -| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) | -| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) | -| داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](docs/web-network-captures.md) | -| اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](docs/managed-code-analysis.md) | -| فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](docs/android-analysis.md) | -| دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-analysis.md) | -| پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](docs/jeb-analysis.md) | -| منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](docs/apktool-resource-analysis.md) | -| برنامه‌های فلاتر | هش اسنپ‌شات دارت، هش کتابخانه‌ها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](docs/flutter-analysis.md) | -| Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) | -| بسته‌های ضبط‌شده | فیلدهای منتخب، مقادیر تکراری، فیلترها و قواعد رمزگشایی | Linux: TShark 4.x + prlimit; [راهنمای ضبط بسته](docs/packet-captures.md) | -| بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) | -| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) | +| هدف | خروجی REA | پیش‌نیازها و راهنما | +| ---------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) | +| ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](docs/binary-diagnostics.md) | +| بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](docs/evm-bytecode.md) | +| کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](docs/recorded-crashes.md) | +| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) | +| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) | +| داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](docs/web-network-captures.md) | +| اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](docs/managed-code-analysis.md) | +| فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](docs/android-analysis.md) | +| دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-analysis.md) | +| پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](docs/jeb-analysis.md) | +| منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](docs/apktool-resource-analysis.md) | +| برنامه‌های فلاتر | هش اسنپ‌شات دارت، هش کتابخانه‌ها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](docs/flutter-analysis.md) | +| Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) | +| بسته‌های ضبط‌شده | فیلدهای منتخب، مقادیر تکراری، فیلترها و قواعد رمزگشایی | Linux: TShark 4.x + prlimit; [راهنمای ضبط بسته](docs/packet-captures.md) | +| سیگنال‌های منطقی ضبط‌شده | توضیحات رمزگشا، بازه‌های نمونه و اطلاعات عیب‌یابی | Linux: sigrok-cli 0.7.2 + prlimit; [راهنمای سیگنال‌ها](docs/signal-recordings.md) | +| بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) | +| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) | بررسی ایستای JavaScript و .NET فایل‌های ارائه‌شده را بدون اجرای برنامه می‌خواند. ضبط زمان اجرا، هدف انتخاب‌شده را با مجوزهای کاربری شما اجرا می‌کند یا با آن تعامل دارد؛ آثار این عملیات در راهنمای مربوط به هر نوع ضبط توضیح داده شده‌اند. diff --git a/README_fr.md b/README_fr.md index 40510a8ac..f28e53f1a 100644 --- a/README_fr.md +++ b/README_fr.md @@ -151,6 +151,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Applications Flutter | Empreinte de snapshot Dart, condensés de bibliothèques par ABI et provenance de build du moteur | Aucune (analyse APK statique) ; [guide Flutter](docs/flutter-analysis.md) | | Firmware | Régions, résultats d'extraction et passage de relais vers l'analyse native | Binwalk / Unblob sous Linux ; [guide firmware](docs/firmware-analysis.md) | | Paquets enregistrés | Champs sélectionnés, valeurs répétées, filtres et règles de décodage | Linux: TShark 4.x + prlimit; [guide des captures](docs/packet-captures.md) | +| Signaux logiques enregistrés | Annotations du décodeur, plages d’échantillons et diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [guide des signaux](docs/signal-recordings.md) | | Paquets et ressources | Inventaires de fichiers, empreintes, plists, anatomie des bundles Apple et ressources extraites | [Guide artefacts et JavaScript](docs/javascript-artifact-reconstruction.md), [applications Apple](docs/apple-application-analysis.md) | | Comportement des processus | Sortie du terminal, interactions, code de sortie, observations du système de fichiers et comparaisons | Linux/macOS avec un PTY natif ; [capture de processus](docs/process-capture.md) | diff --git a/README_id.md b/README_id.md index ce4461ea6..440a8f81b 100644 --- a/README_id.md +++ b/README_id.md @@ -154,6 +154,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Aplikasi Flutter | Hash snapshot Dart, hash pustaka per ABI, dan asal build engine | Tidak ada (parsing APK statis); [panduan Flutter](docs/flutter-analysis.md) | | Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](docs/firmware-analysis.md) | | Paket rekaman | Kolom terpilih, nilai berulang, filter, dan aturan decode-as | Linux: TShark 4.x + prlimit; [panduan tangkapan](docs/packet-captures.md) | +| Sinyal logika terekam | Anotasi dekoder, rentang sampel, dan diagnostik | Linux: sigrok-cli 0.7.2 + prlimit; [panduan sinyal](docs/signal-recordings.md) | | Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](docs/javascript-artifact-reconstruction.md), [aplikasi Apple](docs/apple-application-analysis.md) | | Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](docs/process-capture.md) | diff --git a/README_it.md b/README_it.md index 2b822f811..24c1d35bc 100644 --- a/README_it.md +++ b/README_it.md @@ -151,6 +151,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | App Flutter | Hash dello snapshot Dart, digest delle librerie per ABI e provenienza della build del motore | Nessuno (analisi APK statica); [guida Flutter](docs/flutter-analysis.md) | | Firmware | Regioni, risultati di estrazione e passaggi all'analisi nativa | Binwalk / Unblob su Linux; [guida firmware](docs/firmware-analysis.md) | | Pacchetti registrati | Campi selezionati, valori ripetuti, filtri e regole di decodifica | Linux: TShark 4.x + prlimit; [guida alle catture](docs/packet-captures.md) | +| Segnali logici registrati | Annotazioni del decoder, intervalli di campioni e diagnostica | Linux: sigrok-cli 0.7.2 + prlimit; [guida ai segnali](docs/signal-recordings.md) | | Pacchetti e risorse | Inventari file, digest, plist, anatomia dei bundle Apple e risorse estratte | [Guida artifact e JavaScript](docs/javascript-artifact-reconstruction.md), [Applicazioni Apple](docs/apple-application-analysis.md) | | Comportamento dei processi | Output del terminale, interazioni, osservazioni su uscita e filesystem ed confronti di esecuzione | Linux/macOS con un PTY nativo; [cattura dei processi](docs/process-capture.md) | diff --git a/README_ja.md b/README_ja.md index ec34d0cee..8b32338aa 100644 --- a/README_ja.md +++ b/README_ja.md @@ -147,6 +147,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | Flutter アプリ | Dart スナップショットハッシュ、ABI ごとのライブラリダイジェスト、エンジンビルドの来歴 | 不要(静的 APK 解析);[Flutter ガイド](docs/flutter-analysis.md) | | ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](docs/firmware-analysis.md) | | 記録済みパケット | 選択フィールド、繰り返し値、フィルター、デコード指定 | Linux: TShark 4.x + prlimit; [キャプチャガイド](docs/packet-captures.md) | +| 記録済みロジック信号 | デコーダ注釈、サンプル範囲、診断情報 | Linux: sigrok-cli 0.7.2 + prlimit; [信号記録ガイド](docs/signal-recordings.md) | | パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](docs/javascript-artifact-reconstruction.md)、[Apple アプリケーション](docs/apple-application-analysis.md) | | プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](docs/process-capture.md) | diff --git a/README_ko.md b/README_ko.md index e8d9bede3..656753d7b 100644 --- a/README_ko.md +++ b/README_ko.md @@ -147,6 +147,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](docs/flutter-analysis.md) | | 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](docs/firmware-analysis.md) | | 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](docs/packet-captures.md) | +| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](docs/signal-recordings.md) | | 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](docs/javascript-artifact-reconstruction.md), [Apple 애플리케이션](docs/apple-application-analysis.md) | | 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](docs/process-capture.md) | diff --git a/README_pl.md b/README_pl.md index efd03514d..f29d5d2fe 100644 --- a/README_pl.md +++ b/README_pl.md @@ -147,6 +147,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Aplikacje Flutter | Hash migawki Dart, sumy bibliotek per ABI i pochodzenie kompilacji silnika | Brak (statyczna analiza APK); [przewodnik Flutter](docs/flutter-analysis.md) | | Oprogramowanie układowe | Regiony, wyniki wyodrębniania i przekazanie do analizy kodu natywnego | Binwalk / Unblob w systemie Linux; [przewodnik oprogramowania układowego](docs/firmware-analysis.md) | | Zapisane pakiety | Wybrane pola, powtarzane wartości, filtry i reguły dekodowania | Linux: TShark 4.x + prlimit; [przewodnik po przechwyconych pakietach](docs/packet-captures.md) | +| Zapisane sygnały logiczne | Adnotacje dekodera, zakresy próbek i diagnostyka | Linux: sigrok-cli 0.7.2 + prlimit; [przewodnik po sygnałach](docs/signal-recordings.md) | | Pakiety i zasoby | Spisy plików, skróty, pliki plist, struktura pakietów Apple i wyodrębnione zasoby | [Przewodnik artefaktów i JavaScript](docs/javascript-artifact-reconstruction.md), [aplikacje Apple](docs/apple-application-analysis.md) | | Zachowanie procesów | Wyjście terminala, interakcje, obserwacje zakończenia i systemu plików oraz porównania uruchomień | Linux/macOS z natywnym PTY; [przechwytywanie procesów](docs/process-capture.md) | diff --git a/README_pt-BR.md b/README_pt-BR.md index 5e86b851e..ec18da8ff 100644 --- a/README_pt-BR.md +++ b/README_pt-BR.md @@ -147,6 +147,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Aplicativos Flutter | Hash de snapshot Dart, hashes de bibliotecas por ABI e procedência de build do motor | Nenhuma (análise estática de APK); [guia Flutter](docs/flutter-analysis.md) | | Firmware | Regiões, resultados de extração e encaminhamentos à análise nativa | Binwalk / Unblob no Linux; [guia de firmware](docs/firmware-analysis.md) | | Pacotes gravados | Campos selecionados, valores repetidos, filtros e regras de decodificação | Linux: TShark 4.x + prlimit; [guia de capturas](docs/packet-captures.md) | +| Sinais lógicos gravados | Anotações do decodificador, intervalos de amostras e diagnósticos | Linux: sigrok-cli 0.7.2 + prlimit; [guia de sinais](docs/signal-recordings.md) | | Pacotes e recursos | Inventários de arquivos, digests, plists, estrutura de bundles Apple e recursos extraídos | [Guia de artefatos e JavaScript](docs/javascript-artifact-reconstruction.md), [aplicativos Apple](docs/apple-application-analysis.md) | | Comportamento de processos | Saída do terminal, interações, observações de encerramento e do sistema de arquivos, e comparações entre execuções | Linux/macOS com PTY nativo; [captura de processos](docs/process-capture.md) | diff --git a/README_ru.md b/README_ru.md index 77731f7f0..2d75af75e 100644 --- a/README_ru.md +++ b/README_ru.md @@ -130,25 +130,26 @@ npx rea-agents@latest setup REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а также npm. Дополнительные инструменты и поддерживаемые системы зависят от цели: -| Цель | Что возвращает REA | Требования и руководство | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) | -| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](docs/binary-diagnostics.md) | -| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](docs/evm-bytecode.md) | -| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](docs/recorded-crashes.md) | -| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) | -| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) | -| Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](docs/web-network-captures.md) | -| Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](docs/managed-code-analysis.md) | -| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](docs/android-analysis.md) | -| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-analysis.md) | -| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](docs/jeb-analysis.md) | -| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](docs/apktool-resource-analysis.md) | -| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](docs/flutter-analysis.md) | -| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) | -| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](docs/packet-captures.md) | -| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) | -| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) | +| Цель | Что возвращает REA | Требования и руководство | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) | +| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](docs/binary-diagnostics.md) | +| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](docs/evm-bytecode.md) | +| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](docs/recorded-crashes.md) | +| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) | +| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) | +| Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](docs/web-network-captures.md) | +| Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](docs/managed-code-analysis.md) | +| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](docs/android-analysis.md) | +| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-analysis.md) | +| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](docs/jeb-analysis.md) | +| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](docs/apktool-resource-analysis.md) | +| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](docs/flutter-analysis.md) | +| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) | +| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](docs/packet-captures.md) | +| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](docs/signal-recordings.md) | +| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) | +| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) | Статическое исследование JavaScript и .NET читает предоставленные файлы, не запуская приложение. Захват во время выполнения запускает выбранную цель или взаимодействует с ней с правами вашего пользователя; соответствующие руководства описывают последствия. diff --git a/README_th.md b/README_th.md index 31ca23a5a..9e0c6831a 100644 --- a/README_th.md +++ b/README_th.md @@ -147,6 +147,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | แอป Flutter | แฮชสแนปชอต Dart, แฮชไลบรารีราย ABI และที่มาของบิลด์เอนจิน | ไม่ต้อง (วิเคราะห์ APK แบบ static); [คู่มือ Flutter](docs/flutter-analysis.md) | | เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | Binwalk / Unblob บน Linux; [คู่มือเฟิร์มแวร์](docs/firmware-analysis.md) | | แพ็กเก็ตที่บันทึกไว้ | ฟิลด์ที่เลือก ค่าซ้ำ ตัวกรอง และกฎการถอดรหัส | Linux: TShark 4.x + prlimit; [คู่มือไฟล์แคปเจอร์](docs/packet-captures.md) | +| สัญญาณลอจิกที่บันทึกไว้ | คำอธิบายจากตัวถอดรหัส ช่วงตัวอย่าง และข้อมูลวินิจฉัย | Linux: sigrok-cli 0.7.2 + prlimit; [คู่มือสัญญาณ](docs/signal-recordings.md) | | แพ็กเกจและทรัพยากร | รายการไฟล์ ค่าแฮช plist โครงสร้างบันเดิลของ Apple และทรัพยากรที่แตกออกมา | [คู่มืออาร์ติแฟกต์และ JavaScript](docs/javascript-artifact-reconstruction.md), [แอปพลิเคชัน Apple](docs/apple-application-analysis.md) | | พฤติกรรมของโปรเซส | เอาต์พุตเทอร์มินัล การโต้ตอบ ข้อมูลการสิ้นสุดและระบบไฟล์ที่สังเกตได้ และการเปรียบเทียบการรัน | Linux/macOS ที่มี PTY เนทีฟ; [การบันทึกโปรเซส](docs/process-capture.md) | diff --git a/README_tr.md b/README_tr.md index 5783286bc..3c6e3f3b6 100644 --- a/README_tr.md +++ b/README_tr.md @@ -147,6 +147,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Flutter uygulamaları | Dart anlık görüntü karması, ABI başına kitaplık özetleri ve motor derleme kökeni | Yok (statik APK çözümleme); [Flutter rehberi](docs/flutter-analysis.md) | | Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](docs/firmware-analysis.md) | | Kaydedilmiş paketler | Seçilen alanlar, yinelenen değerler, filtreler ve çözümleme kuralları | Linux: TShark 4.x + prlimit; [yakalama kılavuzu](docs/packet-captures.md) | +| Kaydedilmiş mantık sinyalleri | Kod çözücü açıklamaları, örnek aralıkları ve tanılama | Linux: sigrok-cli 0.7.2 + prlimit; [sinyal kılavuzu](docs/signal-recordings.md) | | Paketler ve kaynaklar | Dosya envanterleri, özet değerleri, plist'ler, Apple paket yapısı ve çıkarılmış kaynaklar | [Artefakt ve JavaScript rehberi](docs/javascript-artifact-reconstruction.md), [Apple uygulamaları](docs/apple-application-analysis.md) | | Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](docs/process-capture.md) | diff --git a/README_uk.md b/README_uk.md index d64c87c0c..aea416718 100644 --- a/README_uk.md +++ b/README_uk.md @@ -147,6 +147,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Додатки Flutter | Хеш знімка Dart, дайджести бібліотек за ABI та походження збірки рушія | Не потрібні (статичний розбір APK); [підручник Flutter](docs/flutter-analysis.md) | | Прошивки | Області, результати видобування й передавання до нативного аналізу | Binwalk / Unblob у Linux; [посібник прошивок](docs/firmware-analysis.md) | | Записані пакети | Вибрані поля, повторювані значення, фільтри та правила декодування | Linux: TShark 4.x + prlimit; [посібник із записів пакетів](docs/packet-captures.md) | +| Записані логічні сигнали | Анотації декодера, діапазони відліків і діагностика | Linux: sigrok-cli 0.7.2 + prlimit; [посібник із сигналів](docs/signal-recordings.md) | | Пакети й ресурси | Переліки файлів, хеші, plist, структура пакетів Apple та видобуті ресурси | [Посібник артефактів і JavaScript](docs/javascript-artifact-reconstruction.md), [застосунки Apple](docs/apple-application-analysis.md) | | Поведінка процесів | Вивід термінала, взаємодії, спостереження за завершенням і файловою системою та порівняння запусків | Linux/macOS із нативним PTY; [захоплення процесів](docs/process-capture.md) | diff --git a/README_vi.md b/README_vi.md index a98cff4de..ab3fd493d 100644 --- a/README_vi.md +++ b/README_vi.md @@ -147,6 +147,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Ứng dụng Flutter | Băm snapshot Dart, hash thư viện theo ABI và nguồn gốc build engine | Không cần (phân tích APK tĩnh); [hướng dẫn Flutter](docs/flutter-analysis.md) | | Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](docs/firmware-analysis.md) | | Gói tin đã ghi | Trường được chọn, giá trị lặp, bộ lọc và quy tắc giải mã | Linux: TShark 4.x + prlimit; [hướng dẫn bản ghi gói tin](docs/packet-captures.md) | +| Tín hiệu logic đã ghi | Chú thích bộ giải mã, phạm vi mẫu và chẩn đoán | Linux: sigrok-cli 0.7.2 + prlimit; [hướng dẫn tín hiệu](docs/signal-recordings.md) | | Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](docs/javascript-artifact-reconstruction.md), [ứng dụng Apple](docs/apple-application-analysis.md) | | Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](docs/process-capture.md) | diff --git a/README_zh-TW.md b/README_zh-TW.md index 6591c40fa..81371e91d 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -147,6 +147,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | | 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](docs/firmware-analysis.md) | | 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](docs/packet-captures.md) | +| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](docs/signal-recordings.md) | | 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 應用](docs/apple-application-analysis.md) | | 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](docs/process-capture.md) | diff --git a/README_zh.md b/README_zh.md index e12321d38..d802cad50 100644 --- a/README_zh.md +++ b/README_zh.md @@ -147,6 +147,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | | 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](docs/firmware-analysis.md) | | 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](docs/packet-captures.md) | +| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](docs/signal-recordings.md) | | 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 应用](docs/apple-application-analysis.md) | | 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](docs/process-capture.md) | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6031f2fc4..5a357c80f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -45,6 +45,7 @@ export default defineConfig({ { text: "Firmware", link: "/firmware-analysis" }, { text: "SQLite databases", link: "/sqlite-databases" }, { text: "Packet captures", link: "/packet-captures" }, + { text: "Signal recordings", link: "/signal-recordings" }, ], }, { diff --git a/docs/signal-recordings.md b/docs/signal-recordings.md new file mode 100644 index 000000000..520d6e882 --- /dev/null +++ b/docs/signal-recordings.md @@ -0,0 +1,108 @@ +# Recorded signal decoding + +`decode_signal_recording` answers what a selected protocol decoder observes in +an existing recording. It uses caller-supplied **sigrok-cli 0.7.2** with +libsigrokdecode on **Linux**. util-linux `prlimit` is required (default +`/usr/bin/prlimit`, overridable with absolute `REA_SIGROK_PRLIMIT_COMMAND`). +The limiter sets soft and hard address-space limits before the decoder starts. +macOS and Windows are refused because this adapter cannot enforce that limit on +those hosts. It does not acquire samples or communicate with hardware. + +```sh +export REA_SIGROK_COMMAND=/absolute/path/to/sigrok-cli +rea decode-signal-recording /artifacts/uart.vcd \ + --decoders uart:rx=RX:baudrate=9600 --input_format vcd \ + --annotations uart=rx-data --format json +``` + +The corresponding MCP request is: + +```json +{ + "name": "decode_signal_recording", + "arguments": { + "path": "/artifacts/uart.vcd", + "decoders": "uart:rx=RX:baudrate=9600", + "input_format": "vcd", + "annotations": "uart=rx-data" + } +} +``` + +`decoders` uses sigrok's decoder stack and option/channel syntax. Channel names +must match the recording; REA does not guess wiring, baud rate or protocol. +Omitting `input_format` requests sigrok's autodetection. Omitting `annotations` +retains every annotation class. Both omitted selections are recorded as null. +See the [upstream manual](https://sigrok.org/wiki/Sigrok-cli) for formats, +decoder stacking and annotation selectors. No arbitrary command flags are accepted. + +Output defaults to 50 literal LF-separated lines. Use `line_offset` and +`line_limit` for later pages, or MCP `line_limit: null` / CLI `--all_lines` +for all remaining output. `output_window` reports `total_lines`, `returned_lines` +and `has_more`; these count display lines, **not annotations or samples**. +Line endings, blank lines and text within the selected window stay unchanged. +REA does not parse display text into protocol facts, as explained by +[upstream's annotation output guidance](https://sigrok.org/wiki/Protocol_decoder_output). +Paging does not shorten decoding and producer output must still fit its limit. +Use annotation-class selection to reduce producer output. Calls independently +check the source digest; do not combine pages from different digests. + +Each selector is limited to 8,192 UTF-16 code units, and total encoded selector +arguments (UTF-8, option prefixes, terminators and 64-bit argv pointers) to +32 KiB before launching. This reserves headroom below Linux execve's minimum +argument/environment budget; a large inherited environment may still exhaust it. + +The returned Evidence includes the input path/digest, exact selections, tool +configured and resolved paths/digests, full version banner (including library versions), annotation text +with sample-number ranges, and stderr. For example, `1104-1938 uart-1: 52` +is producer display text over a sample range, not a timestamp or an independently +verified protocol fact. Text is retained verbatim; it is not parsed as a lossless +binary payload. Empty output does not establish absence of traffic. Warnings +remain visible even when sigrok exits zero, including `tool.version_diagnostics` +from the separate version probe. Post-run identity failures name the tool, limiter +or private copy involved, rather than blaming the admitted source. Resource-signal +failures retain the signal and effective limits without inferring its exact cause. + +REA decodes a private, read-only copy of the recording, verifies that copy and +the selected executable and resource limiter after decoding, and removes the workspace after owned +process cleanup. Failed descriptor, process or workspace cleanup retains ownership for retry, and the one-shot +CLI awaits a retry without replacing the original failure or completed Evidence. +Completed observations remain in the MCP session after cancellation or cleanup +failure; bounded post-run integrity checks finish before honoring a late abort. The source file is +never passed to sigrok. Caller-supplied tools and their libraries/decoder modules +are trusted, and hashes identify bytes rather than publisher authenticity. + +The input copy is bounded at 32 MiB before reading, each command has a 30-second +deadline, and combined stdout/stderr is bounded at 4 MiB during collection. +Oversized output fails rather than becoming truncated success. These limits do +not form an OS sandbox. Before execution, `prlimit` enforces **1 GiB address +space per process**, **64 MiB per file**, **30 CPU seconds** and no core dumps. +Stricter inherited soft/hard limits are preserved; Evidence records the effective +ceilings. Both limits are set before execution, so decoder allocations and compressed +recording expansion cannot exceed the per-process address-space cap. These are +not aggregate process-memory or filesystem quotas. The selected limiter is +trusted and its path, digest and arguments are retained in Evidence. + +## Verification + +```sh +npm run build:cached +REA_SIGROK_COMMAND=/absolute/path/to/sigrok-cli node scripts/verify-signal-recording.mjs +``` + +The verifier synthesizes a 9600-baud 8N1 UART VCD carrying `REA`, then checks +actual CLI/MCP parity, advertised schemas, stored Evidence, source hashes, +annotation filtering/autodetection, invalid decoder/input failures and cleanup. +An optional positional REA entrypoint verifies an installed package. +The real-tool lane requires Linux, sigrok-cli 0.7.2 and libsigrokdecode. A separate +kernel-limit regression checks the inherited soft/hard limits and verifies an +oversized allocation is refused before it can consume memory. Other formats, +decoders and physical acquisition are not established by the UART fixture. +The workflow result must be checked before claiming a specific Linux build +verified. The earlier macOS tool check does not establish the current bounded +integration, which deliberately refuses unsupported hosts. + +One decode is admitted at a time, including acquisition and cleanup. Excess +requests receive a retryable capacity error before acquiring another file or +worker. Shutdown closes admission and drains accepted work; failed cleanup +remains owned and retryable. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index b32269563..1658c08fa 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -31,6 +31,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.wasmContracts.WASM_TOOL_CONTRACTS, ...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, ...sources.captureContracts.CAPTURE_TOOL_CONTRACTS, + ...sources.signalContracts.SIGNAL_TOOL_CONTRACTS, ], }, { @@ -201,6 +202,10 @@ export const providerCatalog = (sources) => { identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY, contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS, }, + { + identity: sources.signalProvider.SIGROK_PROVIDER_IDENTITY, + contracts: sources.signalContracts.SIGNAL_TOOL_CONTRACTS, + }, { identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY, contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 9771f4cfd..5a3b0e909 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -49,6 +49,8 @@ const SOURCE_PATHS = { nativeContracts: "dist/contracts/native/nativeToolContracts.js", captureContracts: "dist/contracts/captures/captureToolContracts.js", captureProvider: "dist/captures/TsharkCaptureProvider.js", + signalContracts: "dist/contracts/signals/signalToolContracts.js", + signalProvider: "dist/signals/SigrokRecordingProvider.js", wasmContracts: "dist/contracts/wasm/wasmToolContracts.js", wasmProvider: "dist/wasm/WabtRelease.js", evmContracts: "dist/contracts/evm/evmToolContracts.js", diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index e50d22a9b..160dad7c0 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -10,6 +10,7 @@ const MIGRATED_PROVIDER_ROOTS = new Set([ "go", "wasm", "captures", + "signals", "android", "firmware", "ghidra", diff --git a/scripts/verify-signal-recording.mjs b/scripts/verify-signal-recording.mjs new file mode 100644 index 000000000..a095b97ff --- /dev/null +++ b/scripts/verify-signal-recording.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile, writeFile, readdir } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.platform !== "linux") + throw new Error( + "Real signal verification requires Linux with util-linux prlimit.", + ); +if (!isAbsolute(process.env.REA_SIGROK_COMMAND ?? "")) + throw new Error( + "Supply an absolute REA_SIGROK_COMMAND for sigrok-cli 0.7.2.", + ); +const entrypoint = + process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const run = createVerifierRun(); +const root = await PrivateRuntimeRoot.create({ + prefix: "rea-signal-verifier-", +}); +const path = join(root.path, "recording with spaces.vcd"); +const pagedPath = join(root.path, "paged.vcd"); +const execute = promisify(execFile); +const environment = { + ...process.env, + TMPDIR: root.path, + TMP: root.path, + TEMP: root.path, +}; +const client = new Client({ name: "signal-recording-verifier", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +// Independently synthesize 9600-baud 8N1 samples at 1 MHz: bytes 52 45 41. +const lines = [ + "$timescale 1 us $end", + "$scope module logic $end", + "$var wire 1 ! RX $end", + "$upscope $end", + "$enddefinitions $end", + "#0", + "1!", +]; +let tick = 1000; +for (const byte of Buffer.from("REA")) { + for (const bit of [ + 0, + ...Array.from({ length: 8 }, (_, i) => (byte >> i) & 1), + 1, + ]) { + lines.push(`#${tick}`, `${bit}!`); + tick += 104; + } + tick += 208; +} +lines.push(`#${tick + 1000}`, "1!"); +const source = lines.join("\n") + "\n"; +let cases = 0; +try { + await writeFile(path, source); + await client.connect(transport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "decode_signal_recording", + ); + assert.ok(advertised?.outputSchema); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + assert.ok(ajv.validateSchema(advertised.inputSchema)); + assert.ok(ajv.validateSchema(advertised.outputSchema)); + for (const extra of [ + { input_format: "vcd", annotations: "uart=rx-data" }, + {}, + ]) { + const input = { path, decoders: "uart:rx=RX:baudrate=9600", ...extra }; + const response = await client.callTool({ + name: advertised.name, + arguments: input, + }); + assert.notEqual(response.isError, true, JSON.stringify(response)); + assert.ok( + ajv.validate(advertised.outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ); + const evidence = parseEvidence(response.structuredContent); + const report = evidence.normalized_result; + for (const byte of ["52", "45", "41"]) + assert.match( + report.annotations_text, + new RegExp(`\\d+-\\d+ uart-1: ${byte}\\n`), + ); + assert.equal( + report.artifact.sha256, + createHash("sha256").update(source).digest("hex"), + ); + assert.equal(report.hardware_acquisition, "not-performed"); + assert.equal(report.limits.address_space_bytes, 1073741824); + assert.equal(report.tool.configured_path, process.env.REA_SIGROK_COMMAND); + assert.equal( + report.resource_limiter.configured_path, + process.env.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit", + ); + assert.ok( + report.resource_limiter.arguments.includes("--as=1073741824:1073741824"), + ); + assert.equal(report.diagnostics, ""); + const flags = Object.entries(input) + .filter(([key]) => key !== "path") + .flatMap(([key, value]) => [`--${key}`, value]); + const cli = JSON.parse( + ( + await execute( + process.execPath, + [ + entrypoint, + "decode-signal-recording", + path, + ...flags, + "--format", + "json", + ], + { env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 }, + ) + ).stdout, + ); + assert.equal(parseEvidence(cli).evidence_id, evidence.evidence_id); + const bundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + assert.ok(JSON.stringify(bundle).includes(evidence.evidence_id)); + assert.equal(await readFile(path, "utf8"), source); + cases++; + } + const pagedLines = lines.slice(0, 7); + let pagedTick = 1000; + for (const byte of Array.from({ length: 120 }, (_, i) => i)) { + for (const bit of [ + 0, + ...Array.from({ length: 8 }, (_, i) => (byte >> i) & 1), + 1, + ]) { + pagedLines.push(`#${pagedTick}`, `${bit}!`); + pagedTick += 104; + } + pagedTick += 208; + } + pagedLines.push(`#${pagedTick + 1000}`, "1!"); + const pagedSource = pagedLines.join("\n") + "\n"; + await writeFile(pagedPath, pagedSource); + for (const [options, first, count] of [ + [{}, 0, 50], + [{ line_offset: 50, line_limit: 40 }, 50, 40], + [{ line_limit: null }, 0, 120], + ]) { + const input = { + path: pagedPath, + decoders: "uart:rx=RX:baudrate=9600", + input_format: "vcd", + annotations: "uart=rx-data", + ...options, + }; + const response = await client.callTool({ + name: advertised.name, + arguments: input, + }); + assert.notEqual(response.isError, true, JSON.stringify(response)); + assert.ok( + ajv.validate(advertised.outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ); + const evidence = parseEvidence(response.structuredContent); + const report = evidence.normalized_result; + assert.deepEqual(report.output_window, { + total_lines: 120, + returned_lines: count, + has_more: first + count < 120, + }); + const observed = report.annotations_text + .trimEnd() + .split("\n") + .map((line) => { + const match = /^\d+-\d+ uart-1: ([0-9A-F]{2})$/.exec(line); + assert.ok(match, line); + return Number.parseInt(match[1], 16); + }); + assert.deepEqual( + observed, + Array.from({ length: count }, (_, i) => first + i), + ); + const flags = Object.entries(input) + .filter(([key]) => key !== "path") + .flatMap(([key, value]) => + key === "line_limit" && value === null + ? ["--all_lines"] + : [`--${key}`, String(value)], + ); + const cli = JSON.parse( + ( + await execute( + process.execPath, + [ + entrypoint, + "decode-signal-recording", + pagedPath, + ...flags, + "--format", + "json", + ], + { env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 }, + ) + ).stdout, + ); + assert.equal(parseEvidence(cli).evidence_id, evidence.evidence_id); + assert.equal(await readFile(pagedPath, "utf8"), pagedSource); + cases++; + } + for (const input of [ + { path, decoders: "no_such_decoder" }, + { path: join(root.path, "absent.vcd"), decoders: "uart" }, + { path, decoders: "uart", driver: "demo" }, + ]) { + const response = await client.callTool({ + name: advertised.name, + arguments: input, + }); + assert.equal(response.isError, true); + await client.ping(); + cases++; + } + await writeFile(path, "not a recording"); + const invalid = await client.callTool({ + name: advertised.name, + arguments: { path, decoders: "uart" }, + }); + assert.equal(invalid.isError, true); + cases++; + await client.close(); + assert.deepEqual((await readdir(root.path)).sort(), [ + "paged.vcd", + "recording with spaces.vcd", + ]); +} finally { + await client.close(); + await transport.close(); + await root.close(); +} +console.log( + JSON.stringify( + { + status: "passed", + cases, + host: `${process.platform}/${process.arch}`, + scope: + "real sigrok UART VCD through compiled CLI/MCP; no physical acquisition", + ...(await completeVerifierRun(run)), + }, + null, + 2, + ), +); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 751dc57df..c0f341506 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -44,6 +44,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason; type ProviderDescriptor = ProviderCapability; export type AvailabilityPolicy = { readonly packetCaptureEnabled?: boolean; + readonly signalRecordingEnabled?: boolean; readonly wasmArtifactEnabled?: boolean; readonly evmInterfaceEnabled?: boolean; readonly processCaptureEnabled: boolean; @@ -404,6 +405,14 @@ const workflowAvailabilityFor = ({ remediation: "On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", }; + if (name === "decode_signal_recording") + return policy.signalRecordingEnabled === true + ? { reason: "available", remediation: null } + : { + reason: "provider_missing", + remediation: + "On Linux, supply sigrok-cli 0.7.2 via absolute REA_SIGROK_COMMAND and util-linux prlimit (REA_SIGROK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", + }; if (name === "inspect_wasm_artifact") return policy.wasmArtifactEnabled === true ? { reason: "available", remediation: null } diff --git a/src/application/signals/SignalRecordingService.ts b/src/application/signals/SignalRecordingService.ts new file mode 100644 index 000000000..921ac19c7 --- /dev/null +++ b/src/application/signals/SignalRecordingService.ts @@ -0,0 +1,93 @@ +import { isAbsolute } from "node:path"; +import type { + AnalysisExecution, + ExecutionOptions, +} from "../AnalysisProvider.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 { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { + decodeSignalRecordingInputSchema, + signalRecordingSchema, + type DecodeSignalRecordingInput, +} from "../../domain/signals/signalRecording.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +export interface SignalRecordingPort { + /** Release per-call resources before returning, or retain failed cleanup for close/retry. */ + decode( + input: DecodeSignalRecordingInput, + options?: ExecutionOptions, + ): Promise>; + close(): Promise; +} + +/** One shared validation and Evidence owner for CLI and MCP. */ +export class SignalRecordingService { + constructor(readonly provider: SignalRecordingPort) {} + close(): Promise { + return this.provider.close(); + } + async decode( + raw: unknown, + options?: ExecutionOptions, + ): Promise> { + const operation = "decode_signal_recording"; + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(operation)); + const input = decodeSignalRecordingInputSchema.safeParse(raw); + if (!input.success) + return err( + analysisInputErrorFromIssues(operation, input.error.issues, raw, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute local path.", + }, + ]), + ); + const decoded = await this.provider.decode(input.data, options); + if (!decoded.ok) return decoded; + const execution = decoded.value; + const report = signalRecordingSchema.safeParse(execution.result); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + execution.subject?.path !== input.data.path || + execution.subject.sha256 !== report.data.artifact.sha256 || + JSON.stringify(report.data.selection) !== JSON.stringify(input.data) + ) + return err( + new AnalysisOutputError( + operation, + "Decoder returned malformed data or changed the selected recording identity/options.", + ), + ); + const evidence = createEvidence(execution.subject, execution.provider, { + operation, + parameters: input.data, + result: report.data, + confidence: "observed", + locations: execution.locations, + limitations: execution.limitations, + }); + return options?.signal?.aborted + ? err( + new AnalysisCancelledError(operation, { + partialObservation: evidence, + }), + ) + : ok(evidence); + } +} diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 77aae5a6e..13cadd774 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -14,7 +14,10 @@ import { } from "../application/AnalysisProvider.js"; import type { PacketCapturePort } from "../application/captures/PacketCaptureService.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + ArtifactAnalysisResources, + type ArtifactAnalysisRuntime, +} from "../process/ArtifactAnalysisResources.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { AnalysisError, @@ -28,13 +31,9 @@ import { AnalysisInputError, AnalysisOutputError, AnalysisResourceConstraintError, - AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import { createEvidence } from "../domain/evidence.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; -import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { type InspectPacketCaptureInput, packetCaptureSchema, @@ -45,7 +44,6 @@ import { OwnedCommandFailure, } from "../process/OwnedCommand.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; -import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; const OPERATION = "inspect_packet_capture"; export const TSHARK_PROVIDER_IDENTITY = { @@ -62,129 +60,43 @@ const LIMITS = { file_bytes: 64 * 1024 * 1024, cpu_seconds: 30, } as const; -type Runtime = Pick; type Launcher = NonNullable[2]>["launcher"]; /** Inspect selected capture files only, retaining cleanup ownership across failures. */ export class TsharkCaptureProvider implements PacketCapturePort { - readonly #artifactResources = new ArtifactResourceScope(); - readonly #pending = new Map(); - #tail: Promise = Promise.resolve(); - #busy = false; - #closed = false; + readonly #resources: ArtifactAnalysisResources; constructor( readonly environment: Readonly = process.env, readonly launcher?: Launcher, - readonly createRuntime: () => Promise = () => + readonly createRuntime: () => Promise = () => PrivateRuntimeRoot.create({ prefix: "rea-tshark-" }), readonly platform: NodeJS.Platform = process.platform, readonly readArtifact: typeof readStableArtifact = readStableArtifact, readonly readLimits: typeof readLinuxResourceLimits = readLinuxResourceLimits, - ) {} + ) { + this.#resources = new ArtifactAnalysisResources( + TSHARK_PROVIDER_IDENTITY.id, + OPERATION, + readArtifact, + readLimits, + { + message: + "A packet inspection is already active; retry after it completes.", + details: { max_concurrent_inspections: 1 }, + }, + ); + } inspect( input: InspectPacketCaptureInput, options?: ExecutionOptions, ): Promise> { - if (this.#closed || options?.signal?.aborted) - return Promise.resolve(err(new AnalysisCancelledError(OPERATION))); - if (this.#busy) - return Promise.resolve( - err( - new AnalysisResourceConstraintError( - OPERATION, - "capacity", - "A packet inspection is already active; retry after it completes.", - { max_concurrent_inspections: 1 }, - ), - ), - ); - this.#busy = true; - const work = this.#tail - .then(() => - this.#artifactResources.run(() => this.#inspect(input, options)), - ) - .catch((cause: unknown) => - err( - this.#failure( - cause, - "artifact", - input.path, - null, - undefined, - undefined, - ), - ), - ) - .finally(() => { - this.#busy = false; - }); - this.#tail = work.then( - () => undefined, - () => undefined, + return this.#resources.run( + () => this.#inspect(input, options), + options?.signal, ); - return work; } close(): Promise { - this.#closed = true; - const work = this.#tail.then(() => this.#closeResources()); - this.#tail = work.catch(() => undefined); - return work; - } - async #closeResources(): Promise { - let descriptorFailure: AnalysisError | undefined; - try { - await this.#artifactResources.close(); - } catch (cause: unknown) { - descriptorFailure = this.#failure( - cause, - "artifact", - "TShark artifact descriptors", - null, - undefined, - undefined, - ); - } - try { - await this.#release(); - } catch (cause: unknown) { - if (descriptorFailure !== undefined && cause instanceof AnalysisError) - throw analysisErrorWithCleanupFailure( - descriptorFailure, - cause, - OPERATION, - ); - throw cause; - } - if (descriptorFailure !== undefined) throw descriptorFailure; - } - #read(path: string, maximumBytes: number, signal?: AbortSignal) { - return this.readArtifact( - path, - maximumBytes, - signal, - undefined, - this.#artifactResources, - ); - } - async #release(): Promise { - for (const [root, owner] of this.#pending) { - try { - if (owner !== undefined) { - const stopped = await owner.stop(); - if (stopped.status === "incomplete") throw new Error(stopped.reason); - this.#pending.set(root, undefined); - } - await root.close(); - this.#pending.delete(root); - } catch (cause) { - throw new ProviderCleanupError( - TSHARK_PROVIDER_IDENTITY.id, - [root.path], - { reason: String(cause) }, - { operation: OPERATION, cause }, - ); - } - } + return this.#resources.close(); } async #inspect( input: InspectPacketCaptureInput, @@ -199,11 +111,11 @@ export class TsharkCaptureProvider implements PacketCapturePort { let capturedOutput: AnalysisCapturedOutput | undefined; let resourcePath = input.path; let effectiveLimits: LinuxResourceLimits | undefined; - let root: Runtime | undefined; + let root: ArtifactAnalysisRuntime | undefined; let result: Result; try { if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); - await this.#release(); + await this.#resources.release(); if (this.platform !== "linux") throw new Error( "Packet inspection requires Linux with util-linux prlimit and REA_TSHARK_COMMAND so expanded captures are processed under an enforced address-space limit.", @@ -213,19 +125,23 @@ export class TsharkCaptureProvider implements PacketCapturePort { throw new Error("Supply absolute REA_TSHARK_COMMAND for TShark 4.x."); const command = await realpath(configured); await access(command, constants.X_OK); - const tool = await this.#read(command, 64 * 1024 * 1024, options?.signal); + const tool = await this.#resources.read( + command, + 64 * 1024 * 1024, + options?.signal, + ); const configuredLimiter = this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; if (!isAbsolute(configuredLimiter)) throw new Error("REA_TSHARK_PRLIMIT_COMMAND must be absolute."); const limiter = await realpath(configuredLimiter); await access(limiter, constants.X_OK); - const limiterIdentity = await this.#read( + const limiterIdentity = await this.#resources.read( limiter, 64 * 1024 * 1024, options?.signal, ); - effectiveLimits = await this.readLimits(LIMITS, this.#artifactResources); + effectiveLimits = await this.#resources.readLimits(LIMITS); const limitArguments = [ `--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`, `--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`, @@ -236,7 +152,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { ]; phase = "workspace"; root = await this.createRuntime(); - this.#pending.set(root, undefined); + this.#resources.retain(root, undefined); const snapshotPath = join(root.path, "capture"); const configurationPath = join(root.path, "configuration"); await mkdir(configurationPath, { mode: 0o700 }); @@ -276,9 +192,12 @@ export class TsharkCaptureProvider implements PacketCapturePort { const previousPhase = phase; phase = "integrity"; resourcePath = command; - const current = await this.#read(command, 64 * 1024 * 1024); + const current = await this.#resources.read(command, 64 * 1024 * 1024); resourcePath = limiter; - const currentLimiter = await this.#read(limiter, 64 * 1024 * 1024); + const currentLimiter = await this.#resources.read( + limiter, + 64 * 1024 * 1024, + ); if (currentLimiter.sha256 !== limiterIdentity.sha256) throw new AnalysisArtifactChangedError( OPERATION, @@ -320,7 +239,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { "Expected TShark 4.x; inspect the selected installation.", ); phase = "artifact"; - const selected = await this.#read( + const selected = await this.#resources.read( input.path, LIMITS.input_bytes, options?.signal, @@ -354,8 +273,8 @@ export class TsharkCaptureProvider implements PacketCapturePort { phase = "integrity"; resourcePath = snapshotPath; if ( - (await this.#read(snapshotPath, LIMITS.input_bytes)).sha256 !== - selected.sha256 + (await this.#resources.read(snapshotPath, LIMITS.input_bytes)) + .sha256 !== selected.sha256 ) throw new AnalysisArtifactChangedError( OPERATION, @@ -432,7 +351,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { cause instanceof OwnedCommandFailure && cause.cleanupOwner !== undefined ) - this.#pending.set(root, cause.cleanupOwner); + this.#resources.retain(root, cause.cleanupOwner); result = err( this.#failure( cause, @@ -444,42 +363,16 @@ export class TsharkCaptureProvider implements PacketCapturePort { ), ); } - try { - await this.#release(); - } catch (cause) { - const partialObservation = result.ok - ? createEvidence( - result.value.subject ?? undefined, - result.value.provider, - { - operation: OPERATION, - parameters: input, - result: result.value.result, - confidence: "observed", - locations: result.value.locations, - limitations: result.value.limitations, - }, - ) - : result.error.partialObservation; - return err( - new ProviderCleanupError( - TSHARK_PROVIDER_IDENTITY.id, - [...this.#pending.keys()].map((item) => item.path), - { - reason: String(cause), - previous_error: result.ok - ? null - : projectAnalysisError(result.error), - }, - { - operation: OPERATION, - cause, - ...(partialObservation === undefined ? {} : { partialObservation }), - }, - ), - ); - } - return result; + return this.#resources.finish(result, (execution) => + createEvidence(execution.subject ?? undefined, execution.provider, { + operation: OPERATION, + parameters: input, + result: execution.result, + confidence: "observed", + locations: execution.locations, + limitations: execution.limitations, + }), + ); } #failure( cause: unknown, @@ -496,16 +389,10 @@ export class TsharkCaptureProvider implements PacketCapturePort { ): AnalysisError { if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) - return new ProviderCleanupError( - TSHARK_PROVIDER_IDENTITY.id, - cause.cleanup.resources, - { - reason: cause.cleanup.reason, - ...(observedOutput === undefined - ? {} - : { captured_output: { ...observedOutput } }), - }, - { operation: OPERATION, cause }, + return this.#resources.cleanupFailure( + cause.cleanup, + cause, + observedOutput, ); if ( (cause instanceof Error && cause.name === "AbortError") || @@ -513,51 +400,8 @@ export class TsharkCaptureProvider implements PacketCapturePort { ) return new AnalysisCancelledError(OPERATION, { cause }); const reason = cause instanceof Error ? cause.message : String(cause); - if (cause instanceof OwnedCommandFailure) { - const capturedOutput = - cause.snapshot === null - ? undefined - : { - stdout: cause.snapshot.stdout.text, - stderr: cause.snapshot.stderr.text, - truncated: cause.snapshot.diagnosticTruncated ?? false, - exit_code: cause.snapshot.exitCode ?? null, - signal: cause.snapshot.signal ?? null, - }; - const detail = { - cause, - ...(capturedOutput === undefined ? {} : { capturedOutput }), - }; - if (cause.reason === "cancelled") - return new AnalysisCancelledError(OPERATION, detail); - if (cause.reason === "timeout") - return new AnalysisTimeoutError( - OPERATION, - LIMITS.command_timeout_ms, - detail, - ); - if (cause.reason === "output-limit") - return new AnalysisResourceConstraintError( - OPERATION, - "memory", - reason, - { output_bytes: LIMITS.output_bytes }, - detail, - ); - if ( - cause.reason === "process" && - (cause.snapshot?.signal === "SIGXFSZ" || - cause.snapshot?.signal === "SIGXCPU") - ) - return new AnalysisResourceConstraintError( - OPERATION, - cause.snapshot.signal === "SIGXFSZ" ? "file-size" : "cpu", - `Producer terminated with ${cause.snapshot.signal}; the signal's exact cause is not independently established.`, - effectiveLimits === undefined ? null : { ...effectiveLimits }, - detail, - ); - return new AnalysisOutputError(OPERATION, reason, detail); - } + if (cause instanceof OwnedCommandFailure) + return this.#resources.commandFailure(cause, LIMITS, effectiveLimits); if (phase === "workspace") { const code = cause instanceof Error && diff --git a/src/cli.ts b/src/cli.ts index 2e3c34c77..0b545c0ba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import { registerCaptureCommands } from "./cli/captureCommands.js"; +import { registerSignalCommands } from "./cli/signalCommands.js"; import { registerWasmCommands } from "./cli/wasmCommands.js"; import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; @@ -96,6 +97,7 @@ export const createCli = ( registerUtilityCommands(cli, logger, environment, analysis); registerWasmCommands(cli, logger, environment); registerCaptureCommands(cli, logger, environment); + registerSignalCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); registerGoCommands(cli, logger); registerSqliteCommands(cli, logger, environment); diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts new file mode 100644 index 000000000..02e5555f5 --- /dev/null +++ b/src/cli/signalCommands.ts @@ -0,0 +1,99 @@ +import { z } from "incur"; +import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { resolve } from "node:path"; +import { createSignalRecordingService } from "../composition/signals.js"; +import { + signalRecordingOptionsSchema, + signalRecordingSelectorBudgetIssue, + withinSignalRecordingSelectorBudget, +} from "../domain/signals/signalRecording.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +export const registerSignalCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, + service: Pick< + SignalRecordingService, + "decode" | "close" + > = createSignalRecordingService(environment), +): void => { + cli.command(CLI_COMMANDS.decodeSignalRecording, { + description: + "Decode an existing signal recording with caller-supplied sigrok", + args: z.object({ path: z.string().describe("Local signal recording") }), + options: z + .object({ + ...signalRecordingOptionsSchema.shape, + line_limit: z + .number() + .int() + .positive() + .optional() + .describe("Returned output lines per page; default 50"), + all_lines: z + .boolean() + .default(false) + .describe( + "Return all remaining output lines instead of a bounded page", + ), + }) + .superRefine((options, context) => { + if (options.all_lines && options.line_limit !== undefined) + context.addIssue({ + code: "custom", + path: ["all_lines"], + message: "all_lines and line_limit are mutually exclusive", + }); + }) + .refine( + withinSignalRecordingSelectorBudget, + signalRecordingSelectorBudgetIssue, + ), + run: ({ args, options: { all_lines, ...options } }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => { + const result = await service.decode( + { + ...options, + ...(all_lines ? { line_limit: null } : {}), + path: resolve(args.path), + }, + { signal }, + ); + // A one-shot command must retry retained cleanup before discarding its owner. + try { + await service.close(); + } catch (cause) { + const cleanup = + cause instanceof AnalysisError + ? cause + : new ProviderCleanupError( + "sigrok-recording", + [], + { reason: String(cause) }, + { cause, operation: "decode_signal_recording" }, + ); + if (result.ok) cleanup.retainPartialObservation(result.value); + return projectAnalysisError( + result.ok + ? cleanup + : analysisErrorWithCleanupFailure( + result.error, + cleanup, + "decode_signal_recording", + ), + ); + } + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 6f5b5e149..1278cbea8 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -1,6 +1,7 @@ /** Canonical names used by every one-shot CLI command registration. */ export const CLI_COMMANDS = Object.freeze({ inspectPacketCapture: "inspect-packet-capture", + decodeSignalRecording: "decode-signal-recording", inspectWasmArtifact: "inspect-wasm-artifact", inspectSqliteDatabase: "inspect-sqlite-database", inspectEvmInterface: "inspect-evm-interface", diff --git a/src/composition/signals.ts b/src/composition/signals.ts new file mode 100644 index 000000000..d00934e6f --- /dev/null +++ b/src/composition/signals.ts @@ -0,0 +1,6 @@ +import { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; +import { SigrokRecordingProvider } from "../signals/SigrokRecordingProvider.js"; +export const createSignalRecordingService = ( + environment: Readonly = process.env, +): SignalRecordingService => + new SignalRecordingService(new SigrokRecordingProvider(environment)); diff --git a/src/contracts/signals/signalToolContracts.ts b/src/contracts/signals/signalToolContracts.ts new file mode 100644 index 000000000..4a055f12e --- /dev/null +++ b/src/contracts/signals/signalToolContracts.ts @@ -0,0 +1,29 @@ +import { + decodeSignalRecordingInputSchema, + signalRecordingSchema, +} from "../../domain/signals/signalRecording.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +export const SIGNAL_TOOL_CONTRACTS = [ + { + name: "decode_signal_recording", + ...toolContractMetadata("decode_signal_recording"), + kind: "artifact-provider", + description: + "Decode an existing local logic/signal recording on Linux with sigrok-cli 0.7.2 and util-linux prlimit. Supply decoder stack, channels and options explicitly; optionally select input format and annotation classes. Return 50 literal output lines by default, with line_offset/line_limit paging; line_limit:null requests all remaining lines. Output windows count display lines, not annotations or samples, and do not shorten decoding. Preserve selected text, sample ranges, stderr, source/tool identity and coverage inline. Configure absolute REA_SIGROK_COMMAND. File-only analysis; no hardware acquisition or device communication. Empty output does not prove absence of traffic.", + inputSchema: decodeSignalRecordingInputSchema, + outputSchema: evidenceResultOf(signalRecordingSchema), + examples: [ + { + title: "Decode recorded UART RX bytes", + input: { + path: "/artifacts/uart.vcd", + input_format: "vcd", + decoders: "uart:rx=RX:baudrate=9600", + annotations: "uart=rx-data", + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index a2e0c0d72..101cca17e 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,4 +1,5 @@ import { CAPTURE_TOOL_CONTRACTS } from "./captures/captureToolContracts.js"; +import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js"; import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; @@ -41,6 +42,7 @@ export const TOOL_CONTRACTS = [ ...WASM_TOOL_CONTRACTS, ...SQLITE_TOOL_CONTRACTS, ...CAPTURE_TOOL_CONTRACTS, + ...SIGNAL_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...APKTOOL_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index dde8adf18..71b6902c3 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -64,6 +64,11 @@ export const TOOL_EFFECTS: Readonly> = { launchesProcess: true, writesFilesystem: true, }), + decode_signal_recording: effects({ + mutatesSession: true, + launchesProcess: true, + writesFilesystem: true, + }), inspect_wasm_artifact: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/domain/signals/annotationTextWindow.test.ts b/src/domain/signals/annotationTextWindow.test.ts new file mode 100644 index 000000000..04da58a9e --- /dev/null +++ b/src/domain/signals/annotationTextWindow.test.ts @@ -0,0 +1,48 @@ +import { expect, it } from "vitest"; +import { windowAnnotationText } from "./annotationTextWindow.js"; + +it("preserves line terminators, blank lines and literal display text", () => { + const text = "1-2 uart-1: 界\r\n\ntext without a sample range\nlast"; + expect(windowAnnotationText(text, 1, 2)).toEqual({ + annotations_text: "\ntext without a sample range\n", + output_window: { total_lines: 4, returned_lines: 2, has_more: true }, + }); + expect(windowAnnotationText(text, 0, null).annotations_text).toBe(text); + expect(windowAnnotationText(text, 3, 2)).toEqual({ + annotations_text: "last", + output_window: { total_lines: 4, returned_lines: 1, has_more: false }, + }); +}); + +it.each(["", "\n", "line\n", "line"])( + "does not invent a trailing line or normalize %j", + (text) => { + expect(windowAnnotationText(text, 0, null)).toEqual({ + annotations_text: text, + output_window: { + total_lines: text === "" ? 0 : 1, + returned_lines: text === "" ? 0 : 1, + has_more: false, + }, + }); + }, +); + +it("returns an empty covered window beyond the last line without unsafe arithmetic", () => { + expect( + windowAnnotationText( + "one\ntwo", + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + ), + ).toEqual({ + annotations_text: "", + output_window: { total_lines: 2, returned_lines: 0, has_more: false }, + }); +}); + +it("does not replace source UTF-16 code units when copying a partial window", () => { + expect( + windowAnnotationText("first\n\ud800\nlast", 1, 1).annotations_text, + ).toBe("\ud800\n"); +}); diff --git a/src/domain/signals/annotationTextWindow.ts b/src/domain/signals/annotationTextWindow.ts new file mode 100644 index 000000000..b0e84f63d --- /dev/null +++ b/src/domain/signals/annotationTextWindow.ts @@ -0,0 +1,35 @@ +/** Page literal LF-separated display lines without interpreting annotation semantics. */ +export const windowAnnotationText = ( + text: string, + offset: number, + limit: number | null, +) => { + let total = 0; + let returned = 0; + let start = text.length; + let end = text.length; + for (let from = 0; from < text.length;) { + const newline = text.indexOf("\n", from); + const next = newline < 0 ? text.length : newline + 1; + if (total === offset) start = from; + if (total >= offset && (limit === null || returned < limit)) { + end = next; + returned++; + } + total++; + from = next; + } + return { + // Copy partial windows so retained Evidence does not retain the full producer + // string. UTF-16 preserves every source code unit, including lone surrogates. + annotations_text: + start === 0 && end === text.length + ? text + : Buffer.from(text.slice(start, end), "utf16le").toString("utf16le"), + output_window: { + total_lines: total, + returned_lines: returned, + has_more: Math.min(offset, total) + returned < total, + }, + }; +}; diff --git a/src/domain/signals/signalRecording.ts b/src/domain/signals/signalRecording.ts new file mode 100644 index 000000000..d73fe24b6 --- /dev/null +++ b/src/domain/signals/signalRecording.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import { windowAnnotationText } from "./annotationTextWindow.js"; + +// These strings are single sigrok arguments, never shell fragments. +const selection = z + .string() + .min(1) + .max(8192) + .refine((value) => !value.includes("\0"), "NUL is not an argument character"); +const signalInputShape = z.strictObject({ + path: localPathStringSchema.describe("Absolute local recording path"), + decoders: selection.describe( + "sigrok decoder stack, options and channel assignments, e.g. uart:rx=RX:baudrate=9600", + ), + input_format: selection + .nullable() + .default(null) + .describe( + "Explicit sigrok input format and options; omitted means autodetection", + ), + annotations: selection + .nullable() + .default(null) + .describe("Optional annotation selection, e.g. uart=rx-data"), + line_offset: z + .number() + .int() + .nonnegative() + .default(0) + .describe("Skip this many literal LF-separated output lines; default 0"), + line_limit: z + .number() + .int() + .positive() + .nullable() + .default(50) + .describe( + "Return at most this many output lines; default 50. Null explicitly returns all remaining lines within producer limits. Lines are not annotation counts.", + ), +}); +export const withinSignalRecordingSelectorBudget = (input: { + decoders: string; + input_format: string | null; + annotations: string | null; +}): boolean => { + let bytes = 0; + for (const [value, prefix] of [ + [input.decoders, "--protocol-decoders="], + [input.input_format, "--input-format="], + [input.annotations, "--protocol-decoder-annotations="], + ] as const) + if (value !== null) + bytes += + Buffer.byteLength(prefix, "utf8") + + Buffer.byteLength(value, "utf8") + + 9; + return bytes <= 32768; +}; +export const signalRecordingSelectorBudgetIssue = { + message: + "Signal selectors exceed the 32 KiB encoded argument budget; reduce decoder, format or annotation selections.", +}; +export const decodeSignalRecordingInputSchema = signalInputShape.refine( + withinSignalRecordingSelectorBudget, + signalRecordingSelectorBudgetIssue, +); +export const signalRecordingOptionsSchema = signalInputShape + .omit({ path: true }) + .refine( + withinSignalRecordingSelectorBudget, + signalRecordingSelectorBudgetIssue, + ); +export type DecodeSignalRecordingInput = z.infer< + typeof decodeSignalRecordingInputSchema +>; + +export const signalRecordingSchema = z + .strictObject({ + artifact: z.strictObject({ + path: z.string(), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + bytes: z.number().int().nonnegative(), + }), + selection: decodeSignalRecordingInputSchema, + annotations_text: z + .string() + .describe( + "Selected literal lines of sigrok display output with unchanged sample ranges and line endings; not a lossless binary protocol representation", + ), + output_window: z.strictObject({ + total_lines: z + .number() + .int() + .nonnegative() + .describe( + "Literal LF-separated lines in completed producer output, not annotation or sample counts", + ), + returned_lines: z.number().int().nonnegative(), + has_more: z.boolean(), + }), + diagnostics: z.string(), + tool: z.strictObject({ + configured_path: z.string(), + path: z.string(), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + version_banner: z.string(), + version_diagnostics: z.string(), + arguments: z.array(z.string()), + }), + resource_limiter: z.strictObject({ + configured_path: z.string(), + path: z.string(), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + arguments: z.array(z.string()), + }), + limits: z.strictObject({ + input_bytes: z.number().int().positive(), + output_bytes: z.number().int().positive(), + command_timeout_ms: z.number().int().positive(), + address_space_bytes: z.number().int().nonnegative(), + file_bytes: z.number().int().nonnegative(), + cpu_seconds: z.number().int().nonnegative(), + }), + hardware_acquisition: z.literal("not-performed"), + }) + .superRefine((report, context) => { + const remaining = Math.max( + report.output_window.total_lines - report.selection.line_offset, + 0, + ); + const expected = + report.selection.line_limit === null + ? remaining + : Math.min(remaining, report.selection.line_limit); + if ( + report.output_window.returned_lines !== expected || + windowAnnotationText(report.annotations_text, 0, null).output_window + .total_lines !== expected || + report.output_window.has_more !== remaining > expected + ) + context.addIssue({ + code: "custom", + path: ["output_window"], + message: + "Output text and window must agree with examined lines and selected offset/limit", + }); + }); diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts new file mode 100644 index 000000000..5eb90b8e2 --- /dev/null +++ b/src/process/ArtifactAnalysisResources.ts @@ -0,0 +1,272 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, + type AnalysisPartialObservation, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, + AnalysisOutputError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { err, type Result } from "../domain/result.js"; +import { + readLinuxResourceLimits, + type LinuxResourceLimits, +} from "./LinuxResourceLimits.js"; +import type { PrivateRuntimeRoot } from "./PrivateRuntimeRoot.js"; +import type { ProviderProcessSupervisor } from "./ProviderProcess.js"; +import { OwnedCommandFailure } from "./OwnedCommand.js"; + +export type ArtifactAnalysisRuntime = Pick< + PrivateRuntimeRoot, + "path" | "close" +>; + +/** Own admission, artifact descriptors, producer cleanup and private workspaces together. */ +export class ArtifactAnalysisResources { + readonly #artifacts = new ArtifactResourceScope(); + readonly #pending = new Map< + ArtifactAnalysisRuntime, + ProviderProcessSupervisor | undefined + >(); + #tail: Promise = Promise.resolve(); + #busy = false; + #closed = false; + + constructor( + readonly providerId: string, + readonly operation: string, + readonly readArtifact: typeof readStableArtifact, + readonly readKernelLimits: typeof readLinuxResourceLimits, + readonly capacity: { + readonly message: string; + readonly details: Readonly>; + }, + ) {} + + run( + work: () => Promise>, + signal?: AbortSignal, + ): Promise> { + if (this.#closed || signal?.aborted) + return Promise.resolve(err(new AnalysisCancelledError(this.operation))); + if (this.#busy) + return Promise.resolve( + err( + new AnalysisResourceConstraintError( + this.operation, + "capacity", + this.capacity.message, + this.capacity.details, + ), + ), + ); + this.#busy = true; + const running = this.#tail + .then(() => this.#artifacts.run(work)) + .catch((cause: unknown) => err(this.#artifactFailure(cause))) + .finally(() => { + this.#busy = false; + }); + this.#tail = running.then( + () => undefined, + () => undefined, + ); + return running; + } + + read(path: string, maximumBytes: number, signal?: AbortSignal) { + return this.readArtifact( + path, + maximumBytes, + signal, + undefined, + this.#artifacts, + ); + } + + readLimits(limits: LinuxResourceLimits): Promise { + return this.readKernelLimits(limits, this.#artifacts); + } + + retain( + root: ArtifactAnalysisRuntime, + owner?: ProviderProcessSupervisor, + ): void { + this.#pending.set(root, owner); + } + + get pendingPaths(): readonly string[] { + return [...this.#pending.keys()].map((root) => root.path); + } + + async release(): Promise { + for (const [root, owner] of this.#pending) { + try { + if (owner !== undefined) { + const stopped = await owner.stop(); + if (stopped.status === "incomplete") throw new Error(stopped.reason); + this.#pending.set(root, undefined); + } + await root.close(); + this.#pending.delete(root); + } catch (cause: unknown) { + throw new ProviderCleanupError( + this.providerId, + [root.path], + { reason: String(cause) }, + { operation: this.operation, cause }, + ); + } + } + } + + cleanupFailure( + observation: NonNullable, + cause: unknown, + capturedOutput?: AnalysisCapturedOutput, + ): ProviderCleanupError { + return new ProviderCleanupError( + this.providerId, + observation.resources, + { + reason: observation.reason, + ...(capturedOutput === undefined + ? {} + : { captured_output: { ...capturedOutput } }), + }, + { operation: this.operation, cause }, + ); + } + + commandFailure( + cause: OwnedCommandFailure, + limits: { + readonly command_timeout_ms: number; + readonly output_bytes: number; + }, + effectiveLimits: LinuxResourceLimits | undefined, + ): AnalysisError { + const capturedOutput = + cause.snapshot === null + ? undefined + : { + stdout: cause.snapshot.stdout.text, + stderr: cause.snapshot.stderr.text, + truncated: cause.snapshot.diagnosticTruncated ?? false, + exit_code: cause.snapshot.exitCode ?? null, + signal: cause.snapshot.signal ?? null, + }; + const detail = { + cause, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }; + if (cause.reason === "cancelled") + return new AnalysisCancelledError(this.operation, detail); + if (cause.reason === "timeout") + return new AnalysisTimeoutError( + this.operation, + limits.command_timeout_ms, + detail, + ); + if (cause.reason === "output-limit") + return new AnalysisResourceConstraintError( + this.operation, + "memory", + cause.message, + { output_bytes: limits.output_bytes }, + detail, + ); + if ( + cause.reason === "process" && + (cause.snapshot?.signal === "SIGXFSZ" || + cause.snapshot?.signal === "SIGXCPU") + ) + return new AnalysisResourceConstraintError( + this.operation, + cause.snapshot.signal === "SIGXFSZ" ? "file-size" : "cpu", + `Producer terminated with ${cause.snapshot.signal}; the signal's exact cause is not independently established.`, + effectiveLimits === undefined ? null : { ...effectiveLimits }, + detail, + ); + return new AnalysisOutputError(this.operation, cause.message, detail); + } + + async finish( + result: Result, + observation: (value: T) => AnalysisPartialObservation, + ): Promise> { + try { + await this.release(); + return result; + } catch (cause: unknown) { + const partialObservation = result.ok + ? observation(result.value) + : result.error.partialObservation; + return err( + new ProviderCleanupError( + this.providerId, + this.pendingPaths, + { + reason: String(cause), + previous_error: result.ok + ? null + : projectAnalysisError(result.error), + }, + { + operation: this.operation, + cause, + ...(partialObservation === undefined ? {} : { partialObservation }), + }, + ), + ); + } + } + + close(): Promise { + this.#closed = true; + const work = this.#tail.then(() => this.#closeResources()); + this.#tail = work.catch(() => undefined); + return work; + } + + async #closeResources(): Promise { + let descriptorFailure: AnalysisError | undefined; + try { + await this.#artifacts.close(); + } catch (cause: unknown) { + descriptorFailure = this.#artifactFailure(cause); + } + try { + await this.release(); + } catch (cause: unknown) { + if (descriptorFailure !== undefined && cause instanceof AnalysisError) + throw analysisErrorWithCleanupFailure( + descriptorFailure, + cause, + this.operation, + ); + throw cause; + } + if (descriptorFailure !== undefined) throw descriptorFailure; + } + + #artifactFailure(cause: unknown): AnalysisError { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) + return this.cleanupFailure(cause.cleanup, cause); + return new ProviderAdapterError(this.providerId, this.operation, { + cause, + diagnostics: { stage: "artifact-resources", reason: String(cause) }, + }); + } +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 8823b4ea7..a84307c99 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1,6 +1,9 @@ import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js"; import { createPacketCaptureService } from "../composition/captures.js"; import { registerCaptureTools } from "./registerCaptureTools.js"; +import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; +import { createSignalRecordingService } from "../composition/signals.js"; +import { registerSignalTools } from "./registerSignalTools.js"; import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; import { wasmArtifactModule } from "../composition/wasm.js"; import { registerWasmTools } from "./registerWasmTools.js"; @@ -123,6 +126,7 @@ export interface CreateServerOptions { readonly environment?: Readonly; readonly delivery?: ToolResultDelivery; readonly packetCapture?: PacketCaptureService; + readonly signalRecording?: SignalRecordingService; readonly wasmArtifact?: WasmArtifactService; readonly evmInterface?: EvmInterfaceService; readonly sqliteDatabase?: SqliteDatabaseService; @@ -168,6 +172,9 @@ const installSessionToolAvailability = ( packetCaptureEnabled: options.packetCapture !== undefined || (linux && isAbsolute(environment.REA_TSHARK_COMMAND ?? "")), + signalRecordingEnabled: + options.signalRecording !== undefined || + (linux && isAbsolute(environment.REA_SIGROK_COMMAND ?? "")), wasmArtifactEnabled: options.wasmArtifact !== undefined || isAbsolute(environment.REA_WABT_BIN_DIRECTORY ?? ""), @@ -301,6 +308,8 @@ export const createServer = ( ); const packetCapture = options.packetCapture ?? createPacketCaptureService(environment); + const signalRecording = + options.signalRecording ?? createSignalRecordingService(environment); const apktool = options.apktoolAnalysis ?? createApktoolResourceAnalysisProvider(environment); @@ -314,6 +323,7 @@ export const createServer = ( fridaInstrumentation.closeAll(), wasmArtifact.close(), packetCapture.close(), + signalRecording.close(), apktool.close(), jeb.close(), adbDevice.close(), @@ -401,6 +411,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerSignalTools( + server, + signalRecording, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerConfiguredAnalysisTools( toolContext, android, diff --git a/src/server/registerSignalTools.ts b/src/server/registerSignalTools.ts new file mode 100644 index 000000000..87a67361f --- /dev/null +++ b/src/server/registerSignalTools.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind the named signal contract through shared admission and retained Evidence delivery. */ +export const registerSignalTools = ( + server: EvidenceMcpServer, + service: SignalRecordingService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("decode_signal_recording"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.decode(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 5797d52e4..430f88d00 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -9,6 +9,7 @@ export interface SessionAvailabilityDefaults { readonly optionalFeatures?: Pick< AvailabilityPolicy, | "packetCaptureEnabled" + | "signalRecordingEnabled" | "wasmArtifactEnabled" | "evmInterfaceEnabled" | "browserObservationEnabled" @@ -35,6 +36,8 @@ export const sessionAvailabilityPolicy = ( (() => ({ packetCaptureEnabled: defaults.optionalFeatures?.packetCaptureEnabled ?? false, + signalRecordingEnabled: + defaults.optionalFeatures?.signalRecordingEnabled ?? false, wasmArtifactEnabled: defaults.optionalFeatures?.wasmArtifactEnabled ?? false, evmInterfaceEnabled: diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts new file mode 100644 index 000000000..b0eb5c4ba --- /dev/null +++ b/src/signals/SigrokRecordingProvider.ts @@ -0,0 +1,468 @@ +import { + readLinuxResourceLimits, + type LinuxResourceLimits, +} from "../process/LinuxResourceLimits.js"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { access, realpath, writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { + createAnalysisExecution, + type AnalysisExecution, + type ExecutionOptions, +} from "../application/AnalysisProvider.js"; +import type { SignalRecordingPort } from "../application/signals/SignalRecordingService.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + ArtifactAnalysisResources, + type ArtifactAnalysisRuntime, +} from "../process/ArtifactAnalysisResources.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { createEvidence } from "../domain/evidence.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { windowAnnotationText } from "../domain/signals/annotationTextWindow.js"; +import { + type DecodeSignalRecordingInput, + signalRecordingSchema, +} from "../domain/signals/signalRecording.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + runOwnedCommand, + OwnedCommandFailure, +} from "../process/OwnedCommand.js"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; + +const OPERATION = "decode_signal_recording"; +export const SIGROK_PROVIDER_IDENTITY = { + id: "sigrok-recording", + name: "REA sigrok recording adapter", + version: "sigrok-cli@0.7.2", +} as const; +// Bound the resident input copy and retained text before allocation/collection. +const LIMITS = { + input_bytes: 32 * 1024 * 1024, + output_bytes: 4 * 1024 * 1024, + command_timeout_ms: 30_000, + address_space_bytes: 1024 * 1024 * 1024, + file_bytes: 64 * 1024 * 1024, + cpu_seconds: 30, +} as const; +type Launcher = NonNullable[2]>["launcher"]; + +/** Decode selected recordings only, retaining cleanup ownership across failures. */ +export class SigrokRecordingProvider implements SignalRecordingPort { + readonly #resources: ArtifactAnalysisResources; + constructor( + readonly environment: Readonly = process.env, + readonly launcher?: Launcher, + readonly createRuntime: () => Promise = () => + PrivateRuntimeRoot.create({ prefix: "rea-sigrok-" }), + readonly platform: NodeJS.Platform = process.platform, + readonly readArtifact: typeof readStableArtifact = readStableArtifact, + readonly readLimits: typeof readLinuxResourceLimits = readLinuxResourceLimits, + ) { + this.#resources = new ArtifactAnalysisResources( + SIGROK_PROVIDER_IDENTITY.id, + OPERATION, + readArtifact, + readLimits, + { + message: + "A signal decoding operation is already active; retry after it completes.", + details: { max_concurrent_decodes: 1 }, + }, + ); + } + decode( + input: DecodeSignalRecordingInput, + options?: ExecutionOptions, + ): Promise> { + return this.#resources.run( + () => this.#decode(input, options), + options?.signal, + ); + } + close(): Promise { + return this.#resources.close(); + } + async #decode( + input: DecodeSignalRecordingInput, + options?: ExecutionOptions, + ): Promise> { + let phase: + | "configuration" + | "artifact" + | "workspace" + | "producer" + | "integrity" = "configuration"; + let capturedOutput: AnalysisCapturedOutput | undefined; + let resourcePath = input.path; + let effectiveLimits: LinuxResourceLimits | undefined; + let root: ArtifactAnalysisRuntime | undefined; + let result: Result; + try { + if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); + await this.#resources.release(); + if (this.platform !== "linux") + throw new Error( + "Signal decoding requires Linux with util-linux prlimit and REA_SIGROK_COMMAND so expanded recordings are processed under an enforced address-space limit.", + ); + const configured = this.environment.REA_SIGROK_COMMAND ?? ""; + if (!isAbsolute(configured)) + throw new Error( + "Supply absolute REA_SIGROK_COMMAND for sigrok-cli 0.7.2 with libsigrokdecode.", + ); + const command = await realpath(configured); + await access(command, constants.X_OK); + const tool = await this.#resources.read( + command, + 64 * 1024 * 1024, + options?.signal, + ); + const configuredLimiter = + this.environment.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; + if (!isAbsolute(configuredLimiter)) + throw new Error("REA_SIGROK_PRLIMIT_COMMAND must be absolute."); + const limiter = await realpath(configuredLimiter); + await access(limiter, constants.X_OK); + const limiterIdentity = await this.#resources.read( + limiter, + 64 * 1024 * 1024, + options?.signal, + ); + effectiveLimits = await this.#resources.readLimits(LIMITS); + const limitArguments = [ + `--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`, + `--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`, + `--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`, + "--core=0:0", + "--", + command, + ]; + phase = "workspace"; + root = await this.createRuntime(); + this.#resources.retain(root, undefined); + const snapshotPath = join(root.path, "recording"); + const execute = async (args: readonly string[]) => { + const output = await runOwnedCommand( + { + command: limiter, + arguments: [...limitArguments, ...args], + ...(root === undefined ? {} : { cwd: root.path }), + expectedCommand: null, + runId: `rea-sigrok-${randomUUID()}`, + hostEnvironment: this.environment, + }, + { + timeoutMs: LIMITS.command_timeout_ms, + diagnosticBytes: LIMITS.output_bytes, + acceptNonZeroExit: true, + }, + { + ...(options?.signal === undefined + ? {} + : { signal: options.signal }), + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + }, + ); + capturedOutput = { + stdout: output.stdout.text, + stderr: output.stderr.text, + truncated: output.diagnosticTruncated ?? false, + exit_code: output.exitCode ?? null, + signal: output.signal ?? null, + }; + // Finish bounded integrity checks after output; service handles late cancellation with Evidence. + const previousPhase = phase; + phase = "integrity"; + resourcePath = command; + const current = await this.#resources.read(command, 64 * 1024 * 1024); + resourcePath = limiter; + const currentLimiter = await this.#resources.read( + limiter, + 64 * 1024 * 1024, + ); + if (currentLimiter.sha256 !== limiterIdentity.sha256) + throw new AnalysisArtifactChangedError( + OPERATION, + limiter, + "Configured resource limiter changed during analysis.", + { capturedOutput }, + ); + if (current.sha256 !== tool.sha256) + throw new AnalysisArtifactChangedError( + OPERATION, + command, + "Configured decoder executable changed during analysis.", + { capturedOutput }, + ); + phase = previousPhase; + resourcePath = input.path; + if (output.exitCode !== 0) + throw new ProviderAdapterError( + SIGROK_PROVIDER_IDENTITY.id, + OPERATION, + { + diagnostics: { + exit_code: output.exitCode ?? null, + stdout: output.stdout.text, + stderr: output.stderr.text, + }, + }, + ); + return output; + }; + phase = "configuration"; + const version = await execute(["--version"]); + if ( + !/^sigrok-cli 0\.7\.2(?:\r?\n|$)/u.test(version.stdout.text) || + !version.stdout.text.includes("libsigrokdecode") + ) + throw new Error( + "Expected sigrok-cli 0.7.2 with libsigrokdecode; inspect the selected installation.", + ); + phase = "artifact"; + const selected = await this.#resources.read( + input.path, + LIMITS.input_bytes, + options?.signal, + ); + phase = "workspace"; + await writeFile(snapshotPath, selected.bytes, { + flag: "wx", + mode: 0o400, + }); + phase = "producer"; + const args = [ + "--dont-scan", + "--input-file=recording", + `--protocol-decoders=${input.decoders}`, + "--protocol-decoder-samplenum", + ...(input.input_format === null + ? [] + : [`--input-format=${input.input_format}`]), + ...(input.annotations === null + ? [] + : [`--protocol-decoder-annotations=${input.annotations}`]), + ]; + const output = await execute(args); + phase = "integrity"; + resourcePath = snapshotPath; + if ( + (await this.#resources.read(snapshotPath, LIMITS.input_bytes)) + .sha256 !== selected.sha256 + ) + throw new AnalysisArtifactChangedError( + OPERATION, + snapshotPath, + "Decoder changed the private recording copy.", + capturedOutput === undefined ? {} : { capturedOutput }, + ); + phase = "producer"; + resourcePath = input.path; + const report = signalRecordingSchema.parse({ + artifact: { + path: input.path, + sha256: selected.sha256, + bytes: selected.bytes.length, + }, + selection: input, + ...windowAnnotationText( + output.stdout.text, + input.line_offset, + input.line_limit, + ), + diagnostics: output.stderr.text, + tool: { + configured_path: configured, + path: command, + sha256: tool.sha256, + version_banner: version.stdout.text, + version_diagnostics: version.stderr.text, + arguments: args, + }, + resource_limiter: { + configured_path: configuredLimiter, + path: limiter, + sha256: limiterIdentity.sha256, + arguments: limitArguments, + }, + limits: { ...LIMITS, ...effectiveLimits }, + hardware_acquisition: "not-performed", + }); + result = ok( + createAnalysisExecution(report, SIGROK_PROVIDER_IDENTITY, { + subject: { + path: input.path, + sha256: selected.sha256, + format: "file", + }, + locations: [{ kind: "artifact-path", path: input.path }], + limitations: [ + "Annotations are the selected decoder's interpretation of the recorded samples and caller-selected settings, not independently verified protocol semantics. Empty output does not establish absence of traffic.", + "Sample-number ranges and display text are preserved verbatim, not converted to timestamps or parsed as a lossless byte stream. All producer stderr is retained, including warnings on exit zero.", + "Only a private copy of the selected file is decoded. Hardware discovery/acquisition and target execution are not requested. Caller-supplied tools, plugins and decoder modules are trusted; this is not an OS sandbox.", + "Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.", + ], + }), + ); + } catch (cause) { + if ( + root !== undefined && + cause instanceof OwnedCommandFailure && + cause.cleanupOwner !== undefined + ) + this.#resources.retain(root, cause.cleanupOwner); + result = err( + this.#failure( + cause, + phase, + resourcePath, + root?.path ?? null, + effectiveLimits, + capturedOutput, + ), + ); + } + return this.#resources.finish(result, (execution) => + createEvidence(execution.subject ?? undefined, execution.provider, { + operation: OPERATION, + parameters: input, + result: execution.result, + confidence: "observed", + locations: execution.locations, + limitations: execution.limitations, + }), + ); + } + #failure( + cause: unknown, + phase: + | "configuration" + | "artifact" + | "workspace" + | "producer" + | "integrity", + path: string, + workspacePath: string | null, + effectiveLimits: LinuxResourceLimits | undefined, + observedOutput: AnalysisCapturedOutput | undefined, + ): AnalysisError { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) + return this.#resources.cleanupFailure( + cause.cleanup, + cause, + observedOutput, + ); + if ( + (cause instanceof Error && cause.name === "AbortError") || + (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + ) + return new AnalysisCancelledError(OPERATION, { cause }); + const reason = cause instanceof Error ? cause.message : String(cause); + if (cause instanceof OwnedCommandFailure) + return this.#resources.commandFailure(cause, LIMITS, effectiveLimits); + if (phase === "workspace") { + const code = + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : null; + if (code === "ENOSPC" || code === "EDQUOT" || code === "EFBIG") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + `Private analysis workspace failed (${code}): ${reason}`, + { workspace_path: workspacePath, system_code: code }, + { cause }, + ); + return new ProviderAdapterError(SIGROK_PROVIDER_IDENTITY.id, OPERATION, { + cause, + userMessage: `Cannot prepare the private analysis workspace: ${reason}`, + diagnostics: { + stage: "workspace", + workspace_path: workspacePath, + system_code: code, + reason, + }, + }); + } + if (phase === "integrity") { + const detail = { + cause, + ...(observedOutput === undefined + ? {} + : { capturedOutput: observedOutput }), + }; + if ( + cause instanceof ArtifactReaderFailure && + ["limit", "integrity", "path"].includes(cause.reason) + ) + return new AnalysisArtifactChangedError( + OPERATION, + path, + `Post-run identity verification failed for ${path}: ${reason}`, + detail, + ); + return new ProviderAdapterError(SIGROK_PROVIDER_IDENTITY.id, OPERATION, { + ...detail, + diagnostics: { stage: "integrity", resource_path: path, reason }, + }); + } + if (phase === "configuration") + return new AnalysisCapabilityUnavailableError( + SIGROK_PROVIDER_IDENTITY.id, + OPERATION, + reason, + { + cause, + userMessage: `${reason} On Linux, configure sigrok-cli 0.7.2 through REA_SIGROK_COMMAND and util-linux prlimit through REA_SIGROK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`, + }, + ); + if ( + phase === "artifact" && + cause instanceof ArtifactReaderFailure && + cause.reason === "limit" + ) + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + reason, + { input_bytes: LIMITS.input_bytes }, + { cause }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError(OPERATION, path, reason, { + cause, + }); + if ( + phase === "artifact" && + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); + if (phase === "artifact") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_value", message: reason }, + ]); + return new AnalysisOutputError(OPERATION, reason, { cause }); + } +} diff --git a/tests/boundary/cli/signalCleanup.test.ts b/tests/boundary/cli/signalCleanup.test.ts new file mode 100644 index 000000000..594a46cc0 --- /dev/null +++ b/tests/boundary/cli/signalCleanup.test.ts @@ -0,0 +1,132 @@ +import { Cli } from "incur"; +import { expect, it } from "vitest"; +import { registerSignalCommands } from "../../../src/cli/signalCommands.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { err } from "../../../src/domain/result.js"; + +it.each([ + { + flags: ["--decoders", "uart", "--all_lines", "--line_limit", "20"], + message: "mutually exclusive", + }, + { + flags: [ + "--decoders", + "x".repeat(8192), + "--input_format", + "界".repeat(8192), + ], + message: "32 KiB", + }, +])( + "rejects invalid selections before invoking the CLI service: $message", + async ({ flags, message }) => { + const cli = Cli.create("signal-selection-test", { sync: false }); + let called = false; + registerSignalCommands( + cli, + silentLogger, + {}, + { + decode: async () => { + called = true; + throw new Error("Must not decode"); + }, + close: async () => {}, + }, + ); + let stdout = ""; + await cli.serve( + [ + "decode-signal-recording", + "recording.vcd", + ...flags, + "--format", + "json", + ], + { + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }, + ); + expect(called).toBe(false); + expect(stdout).toContain(message); + }, +); + +it.each([false, true])( + "awaits cleanup retry and preserves completed output when retry failure is %s", + async (failAgain) => { + const evidence = createEvidence( + undefined, + { id: "fixture", name: "Completed decoder fixture", version: "1" }, + { + operation: "decode_signal_recording", + parameters: {}, + result: { annotations_text: "retained annotation" }, + confidence: "observed", + }, + ); + const original = new ProviderCleanupError( + "sigrok-recording", + ["original-workspace"], + { reason: "original cleanup failure" }, + { operation: "decode_signal_recording", partialObservation: evidence }, + ); + let closed = false; + const cli = Cli.create("signal-cleanup-test", { sync: false }); + registerSignalCommands( + cli, + silentLogger, + {}, + { + decode: async () => err(original), + close: async () => { + await Promise.resolve(); + closed = true; + if (failAgain) + throw new ProviderCleanupError( + "sigrok-recording", + ["retry-workspace"], + { reason: "retry failed" }, + { operation: "decode_signal_recording" }, + ); + }, + }, + ); + let stdout = ""; + await cli.serve( + [ + "decode-signal-recording", + "recording.vcd", + "--decoders", + "uart", + "--format", + "json", + ], + { + stdout: (text) => { + expect(closed).toBe(true); + stdout += text; + }, + exit: () => {}, + }, + ); + const output: unknown = JSON.parse(stdout); + expect(output).toMatchObject({ + code: "cleanup_incomplete", + details: { + partial_observation: evidence, + resources: expect.arrayContaining(["original-workspace"]), + }, + }); + if (failAgain) + expect(output).toMatchObject({ + details: { resources: expect.arrayContaining(["retry-workspace"]) }, + }); + }, +); diff --git a/tests/boundary/mcp/signalRecordingMcp.test.ts b/tests/boundary/mcp/signalRecordingMcp.test.ts new file mode 100644 index 000000000..ecabfd8e5 --- /dev/null +++ b/tests/boundary/mcp/signalRecordingMcp.test.ts @@ -0,0 +1,114 @@ +import { createEvidence } from "../../../src/domain/evidence.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { SignalRecordingService } from "../../../src/application/signals/SignalRecordingService.js"; +import { err } from "../../../src/domain/result.js"; +import { expect, it, onTestFinished } from "vitest"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; + +it("advertises valid target-free signal decoding schemas and actionable missing-tool diagnostics", async () => { + const harness = await createApplicationMcpHarness({ + serverOptions: { environment: {} }, + }); + onTestFinished(harness.close); + const tool = (await harness.client.listTools()).tools.find( + ({ name }) => name === "decode_signal_recording", + ); + if (tool?.outputSchema === undefined) throw new Error("Signal tool missing"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = tool.inputSchema; + const outputSchema: Record = tool.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const input = { + path: process.platform === "win32" ? "C:\\recording.vcd" : "/recording.vcd", + decoders: "uart", + }; + expect(ajv.validate(inputSchema, input)).toBe(true); + expect(ajv.validate(inputSchema, { ...input, driver: "demo" })).toBe(false); + const result = await harness.client.callTool({ + name: tool.name, + arguments: input, + }); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { + operation: tool.name, + reason: expect.stringContaining("REA_SIGROK_COMMAND"), + }, + }, + }); + await harness.client.ping(); +}); + +it.each(["cancelled", "cleanup"])( + "retains completed Evidence after %s failure", + async (kind) => { + const input = { + path: + process.platform === "win32" ? "C:\\recording.vcd" : "/recording.vcd", + decoders: "uart", + }; + const evidence = createEvidence( + undefined, + { id: "fixture", name: "Completed decoder fixture", version: "1" }, + { + operation: "decode_signal_recording", + parameters: input, + result: { annotations_text: "10-20 uart-1: 42\n" }, + confidence: "observed", + }, + ); + const failure = + kind === "cancelled" + ? new AnalysisCancelledError("decode_signal_recording", { + partialObservation: evidence, + }) + : new ProviderCleanupError( + "sigrok-recording", + ["private-workspace"], + {}, + { + operation: "decode_signal_recording", + partialObservation: evidence, + }, + ); + const signalRecording = new SignalRecordingService({ + decode: async () => err(failure), + close: async () => {}, + }); + const harness = await createApplicationMcpHarness({ + serverOptions: { signalRecording }, + }); + onTestFinished(harness.close); + const response = await harness.client.callTool({ + name: "decode_signal_recording", + arguments: input, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + details: { + partial_observation: { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, + }, + }, + }, + }); + const bundle = await harness.client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(bundle.isError).not.toBe(true); + expect(JSON.stringify(bundle.structuredContent)).toContain( + evidence.evidence_id, + ); + expect(harness.session.evidenceById(evidence.evidence_id)).toEqual( + evidence, + ); + }, +); diff --git a/tests/boundary/signals/recordingDescriptorCleanup.test.ts b/tests/boundary/signals/recordingDescriptorCleanup.test.ts new file mode 100644 index 000000000..3a1927975 --- /dev/null +++ b/tests/boundary/signals/recordingDescriptorCleanup.test.ts @@ -0,0 +1,108 @@ +import { expect, it, onTestFinished, vi } from "vitest"; +import { writeFile } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { SigrokRecordingProvider } from "../../../src/signals/SigrokRecordingProvider.js"; +import { SignalRecordingService } from "../../../src/application/signals/SignalRecordingService.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const cleanup = vi.hoisted(() => ({ + path: "", + denied: false, + handles: [] as FileHandle[], +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const filesystem = await importOriginal(); + return { + ...filesystem, + open: async (...args: Parameters) => { + const handle = await filesystem.open(...args); + if (args[0] === cleanup.path) { + cleanup.handles.push(handle); + const close = handle.close.bind(handle); + handle.close = async () => { + if (cleanup.denied && handle.fd >= 0) + throw new Error("Descriptor close deliberately denied"); + await close(); + }; + } + return handle; + }, + }; +}); + +it + .runIf(process.platform === "linux") + .each(["capture", "tool", "limiter", "kernel"])( + "retains failed %s descriptors and retries them before further acquisition and shutdown", + async (member) => { + const directory = await createTestTempDirectory("rea-signal-descriptor-"); + const path = join(directory, "recording.vcd"); + const command = join(directory, "sigrok.mjs"); + const limiter = join(directory, "limiter"); + await writeFile(path, "recording bytes"); + await writeFile(limiter, "limiter", { mode: 0o700 }); + await writeFile( + command, + `if(process.argv.includes('--version'))console.log('sigrok-cli 0.7.2\\nlibsigrokdecode 0.5.3');else console.log('10-20 uart-1: 42');`, + { mode: 0o700 }, + ); + let launches = 0; + const provider = new SigrokRecordingProvider( + { + ...process.env, + REA_SIGROK_COMMAND: command, + REA_SIGROK_PRLIMIT_COMMAND: limiter, + }, + (input) => { + launches += 1; + return spawnOwnedProviderProcess({ + ...input, + command: process.execPath, + arguments: [command, ...input.arguments], + expectedCommand: process.execPath, + }); + }, + () => PrivateRuntimeRoot.create({ parent: directory }), + ); + cleanup.path = + member === "capture" + ? path + : member === "tool" + ? command + : member === "limiter" + ? limiter + : "/proc/self/limits"; + cleanup.denied = true; + cleanup.handles = []; + onTestFinished(async () => { + cleanup.denied = false; + await provider.close(); + for (const handle of cleanup.handles) await handle.close(); + cleanup.path = ""; + }); + const service = new SignalRecordingService(provider); + const first = await service.decode({ path, decoders: "uart" }); + expect(first).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true, cleanupResources: [cleanup.path] }, + }); + expect(cleanup.handles).toHaveLength(1); + expect(cleanup.handles[0]?.fd).toBeGreaterThanOrEqual(0); + const launchCount = launches; + expect(await service.decode({ path, decoders: "uart" })).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(cleanup.handles).toHaveLength(1); + expect(launches).toBe(launchCount); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + cleanup.denied = false; + await provider.close(); + expect(cleanup.handles[0]?.fd).toBe(-1); + }, +); diff --git a/tests/boundary/signals/recordingProvider.test.ts b/tests/boundary/signals/recordingProvider.test.ts new file mode 100644 index 000000000..9ddf2c21a --- /dev/null +++ b/tests/boundary/signals/recordingProvider.test.ts @@ -0,0 +1,421 @@ +import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js"; +import { it, expect, onTestFinished } from "vitest"; +import { writeFile, readdir, chmod, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js"; +import { SigrokRecordingProvider } from "../../../src/signals/SigrokRecordingProvider.js"; +import { SignalRecordingService } from "../../../src/application/signals/SignalRecordingService.js"; +import { + decodeSignalRecordingInputSchema, + signalRecordingSchema, +} from "../../../src/domain/signals/signalRecording.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createDeferred } from "../../fixtures/binarySession.js"; + +// A command-boundary simulator: real process ownership/filesystem, not real sigrok coverage. +const fixture = async (mode = "ok", failCleanup = false) => { + const directory = await createTestTempDirectory("rea-signal-test-"); + const path = join(directory, "recorded bytes.vcd"); + await writeFile(path, "recording bytes"); + const script = join(directory, "producer.mjs"); + await writeFile( + script, + `import {readFileSync, writeFileSync, chmodSync, truncateSync} from 'node:fs'; +if (process.argv.includes('--version')) { console.log('sigrok-cli 0.7.2\\nlibsigrokdecode 0.5.3'); console.error('version warning'); } +else if (${JSON.stringify(mode)} === 'nonzero') { console.error('invalid decoder'); process.exitCode = 1; } +else if (${JSON.stringify(mode)} === 'malformed-copy') { chmodSync('recording', 0o600); writeFileSync('recording', 'changed'); } +else if (${JSON.stringify(mode)} === 'oversized-copy') { chmodSync('recording', 0o600); truncateSync('recording', 32 * 1024 * 1024 + 1); } +else if (${JSON.stringify(mode)} === 'overflow') process.stdout.write('x'.repeat(5 * 1024 * 1024)); +else if (${JSON.stringify(mode)} === 'wait') { console.log('ready'); setInterval(() => {}, 1000); } +else if (${JSON.stringify(mode)} === 'many') process.stdout.write(Array.from({length:90},(_,i)=>'line '+(i+1)+'\\r\\n').join('')); +else { if(readFileSync('recording', 'utf8') !== 'recording bytes') throw Error('wrong copy'); console.log('10-20 uart-1: 42'); console.error('producer warning'); } +`, + ); + await chmod(script, 0o700); + const configuredCommand = + mode === "symlink" ? join(directory, "configured-tool") : script; + if (mode === "symlink") await symlink(script, configuredCommand); + const controller = new AbortController(); + let calls = 0; + let mayClean = !failCleanup; + const acquired = createDeferred(); + const release = createDeferred(); + const provider = new SigrokRecordingProvider( + { + ...process.env, + REA_SIGROK_COMMAND: configuredCommand, + REA_SIGROK_PRLIMIT_COMMAND: configuredCommand, + }, + async (input) => { + calls++; + if (mode === "file-limit" && !input.arguments.includes("--version")) { + const stdout = { text: "", bytes: 0, observedBytes: 0 }; + const stderr = { + text: "before file limit\n", + bytes: 18, + observedBytes: 18, + }; + throw new OwnedCommandFailure("process", "Synthetic SIGXFSZ snapshot", { + stdout, + stderr, + exitCode: null, + signal: "SIGXFSZ", + }); + } + const spawned = await spawnOwnedProviderProcess({ + ...input, + command: process.execPath, + arguments: [script, ...input.arguments], + expectedCommand: process.execPath, + }); + if (mode === "wait" && !input.arguments.includes("--version")) + spawned.process.stdout?.once("data", () => controller.abort()); + return spawned; + }, + async () => { + if (mode === "workspace-full") + throw Object.assign(new Error("fixture disk full"), { code: "ENOSPC" }); + const root = await PrivateRuntimeRoot.create({ + parent: directory, + prefix: "decoder-", + }); + if (mode === "gated") { + acquired.resolve(); + await release.promise; + } + if (mode === "snapshot-write-failure") + await writeFile(join(root.path, "recording"), "already exists"); + return { + path: root.path, + close: async () => { + if (!mayClean) throw new Error("cleanup fixture failure"); + await root.close(); + }, + }; + }, + "linux", + async (...args) => { + if (mode === "oversized-tool" && calls === 2 && args[0] === script) { + const { truncate } = await import("node:fs/promises"); + await truncate(script, 64 * 1024 * 1024 + 1); + } + if (mode === "post-run-abort" && calls === 2) controller.abort(); + return readStableArtifact(...args); + }, + async (limits) => ({ ...limits, file_bytes: 1048576 }), + ); + onTestFinished(async () => { + release.resolve(); + mayClean = true; + await provider.close(); + }); + const service = new SignalRecordingService(provider); + return { + service, + directory, + path, + controller, + calls: () => calls, + acquired: acquired.promise, + release: () => release.resolve(), + allowCleanup: () => { + mayClean = true; + }, + }; +}; + +it("refuses new acquisition after provider shutdown", async () => { + const f = await fixture(); + await f.service.close(); + expect( + await f.service.decode({ path: f.path, decoders: "uart" }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(f.calls()).toBe(0); +}); + +it("pages literal output without changing decoder arguments or normalizing text", async () => { + const f = await fixture("many"); + for (const [options, first, count] of [ + [{}, 1, 50], + [{ line_offset: 50, line_limit: 30 }, 51, 30], + [{ line_limit: null }, 1, 90], + [{ line_offset: 100 }, 101, 0], + ] as const) { + const result = await f.service.decode({ + path: f.path, + decoders: "uart", + ...options, + }); + if (!result.ok) throw result.error; + const report = signalRecordingSchema.parse(result.value.normalized_result); + expect(report.annotations_text).toBe( + Array.from({ length: count }, (_, i) => `line ${first + i}\r\n`).join(""), + ); + expect(report.output_window.total_lines).toBe(90); + expect(report.output_window.returned_lines).toBe(count); + expect(report.tool.arguments).toEqual([ + "--dont-scan", + "--input-file=recording", + "--protocol-decoders=uart", + "--protocol-decoder-samplenum", + ]); + } +}); + +it("bounds admission without queuing additional snapshots or delaying cancellation", async () => { + const f = await fixture("gated"); + const input = { path: f.path, decoders: "uart" }; + const first = f.service.decode(input); + await f.acquired; + const second = f.service.decode(input); + const cancelled = f.service.decode(input, { signal: AbortSignal.abort() }); + f.release(); + expect(await first).toMatchObject({ ok: true }); + expect(await second).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError", resource: "capacity" }, + }); + expect(await cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(f.calls()).toBe(2); + expect(await f.service.decode(input)).toMatchObject({ ok: true }); +}); + +it("decodes an immutable copy, preserves options and warnings, and releases its directory", async () => { + const f = await fixture(); + const result = await f.service.decode({ + path: f.path, + decoders: "uart:rx=RX:baudrate=9600", + annotations: "uart=rx-data", + }); + if (!result.ok) throw result.error; + const report = signalRecordingSchema.parse(result.value.normalized_result); + expect(report.annotations_text).toBe("10-20 uart-1: 42\n"); + expect(report.diagnostics).toBe("producer warning\n"); + expect(report.tool.version_diagnostics).toBe("version warning\n"); + expect(report.selection.input_format).toBeNull(); + expect(report.tool.arguments).toEqual([ + "--dont-scan", + "--input-file=recording", + "--protocol-decoders=uart:rx=RX:baudrate=9600", + "--protocol-decoder-samplenum", + "--protocol-decoder-annotations=uart=rx-data", + ]); + expect((await readdir(f.directory)).sort()).toEqual([ + "producer.mjs", + "recorded bytes.vcd", + ]); +}); + +it.each([ + ["nonzero", "ProviderAdapterError"], + ["malformed-copy", "AnalysisArtifactChangedError"], + ["overflow", "AnalysisResourceConstraintError"], + ["wait", "AnalysisCancelledError"], +])( + "preserves %s failure and releases the process and snapshot", + async (mode, tag) => { + const f = await fixture(mode); + const result = await f.service.decode( + { path: f.path, decoders: "uart" }, + { signal: f.controller.signal }, + ); + if (result.ok) throw new Error("Expected decoder failure"); + expect(result.error._tag).toBe(tag); + expect(result.error.cleanupIncomplete).toBe(false); + expect( + (await readdir(f.directory)).filter((name) => + name.startsWith("decoder-"), + ), + ).toEqual([]); + }, +); + +it("retains a completed observation and cleanup ownership for retry", async () => { + const f = await fixture("ok", true); + const result = await f.service.decode({ path: f.path, decoders: "uart" }); + if (result.ok) throw new Error("Expected cleanup failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.partialObservation).toMatchObject({ + operation: "decode_signal_recording", + }); + const retry = await f.service.decode({ path: f.path, decoders: "uart" }); + expect(retry.ok).toBe(false); + expect(f.calls()).toBe(2); + f.allowCleanup(); + await f.service.close(); + expect( + (await readdir(f.directory)).filter((name) => name.startsWith("decoder-")), + ).toEqual([]); +}); + +it("rejects cancelled and invalid requests before any provider work", async () => { + const f = await fixture(); + for (const input of [ + { path: "relative.vcd", decoders: "uart" }, + { path: f.path, decoders: "uart", driver: "demo" }, + ]) { + const result = await f.service.decode(input); + expect(result.ok).toBe(false); + } + const result = await f.service.decode( + { path: f.path, decoders: "uart" }, + { signal: AbortSignal.abort() }, + ); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(f.calls()).toBe(0); +}); + +it("retains a completed observation when cancellation arrives at the service boundary", async () => { + const f = await fixture(); + const input = decodeSignalRecordingInputSchema.parse({ + path: f.path, + decoders: "uart", + input_format: null, + annotations: null, + }); + const execution = await f.service.provider.decode(input); + if (!execution.ok) throw execution.error; + const service = new SignalRecordingService({ + decode: async () => { + f.controller.abort(); + return execution; + }, + close: async () => {}, + }); + const result = await service.decode(input, { signal: f.controller.signal }); + if (result.ok) throw new Error("Expected late cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + operation: "decode_signal_recording", + normalized_result: { annotations_text: "10-20 uart-1: 42\n" }, + }); +}); + +it.each([ + ["workspace-full", "AnalysisResourceConstraintError"], + ["snapshot-write-failure", "ProviderAdapterError"], +])( + "reports %s against the private workspace, not the selected input", + async (mode, tag) => { + const f = await fixture(mode); + const result = await f.service.decode({ path: f.path, decoders: "uart" }); + if (result.ok) throw new Error("Expected workspace failure"); + expect(result.error._tag).toBe(tag); + if (result.error._tag === "ProviderAdapterError") + expect(result.error).toMatchObject({ + diagnostics: { + stage: "workspace", + system_code: "EEXIST", + }, + }); + if (result.error._tag === "AnalysisResourceConstraintError") + expect(result.error).toMatchObject({ + reportedLimits: { + system_code: "ENOSPC", + }, + }); + expect(f.calls()).toBe(mode === "workspace-full" ? 0 : 1); + expect( + (await readdir(f.directory)).filter((name) => + name.startsWith("decoder-"), + ), + ).toEqual([]); + }, +); + +it("refuses unsupported hosts before launching or allocating a workspace", async () => { + const provider = new SigrokRecordingProvider( + { REA_SIGROK_COMMAND: process.execPath }, + async () => { + throw new Error("Must not launch"); + }, + async () => { + throw new Error("Must not allocate"); + }, + "darwin", + ); + const result = await new SignalRecordingService(provider).decode({ + path: "/recording.vcd", + decoders: "uart", + }); + if (result.ok) throw new Error("Expected host refusal"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + reason: expect.stringContaining("Linux"), + }); +}); + +it("preserves complete Evidence when aborted during post-run integrity checks", async () => { + const f = await fixture("post-run-abort"); + const result = await f.service.decode( + { path: f.path, decoders: "uart" }, + { signal: f.controller.signal }, + ); + if (result.ok) throw new Error("Expected late cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + annotations_text: "10-20 uart-1: 42\n", + diagnostics: "producer warning\n", + }, + }); + expect( + (await readdir(f.directory)).filter((name) => name.startsWith("decoder-")), + ).toEqual([]); +}); +it("preserves configured symlink paths separately from resolved tool identities", async () => { + const f = await fixture("symlink"); + const result = await f.service.decode({ path: f.path, decoders: "uart" }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + tool: { + configured_path: join(f.directory, "configured-tool"), + path: join(f.directory, "producer.mjs"), + }, + resource_limiter: { + configured_path: join(f.directory, "configured-tool"), + path: join(f.directory, "producer.mjs"), + }, + }); +}); + +it.each(["oversized-copy", "oversized-tool"])( + "identifies the mutated %s without blaming the admitted source", + async (mode) => { + const f = await fixture(mode); + const result = await f.service.decode({ path: f.path, decoders: "uart" }); + if (result.ok) throw new Error("Expected changed artifact"); + expect(result.error).toMatchObject({ + _tag: "AnalysisArtifactChangedError", + path: + mode === "oversized-tool" + ? join(f.directory, "producer.mjs") + : expect.stringMatching(/decoder-.*[/\\]recording$/u), + }); + expect( + (await readdir(f.directory)).filter((name) => + name.startsWith("decoder-"), + ), + ).toEqual([]); + }, +); +it("preserves termination signal and effective file ceiling as a resource failure", async () => { + const f = await fixture("file-limit"); + const result = await f.service.decode({ path: f.path, decoders: "uart" }); + if (result.ok) throw new Error("Expected resource failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "file-size", + reportedLimits: { file_bytes: 1048576 }, + capturedOutput: { signal: "SIGXFSZ", stderr: "before file limit\n" }, + }); +}); diff --git a/tests/boundary/signals/resourceLimits.test.ts b/tests/boundary/signals/resourceLimits.test.ts new file mode 100644 index 000000000..c89489852 --- /dev/null +++ b/tests/boundary/signals/resourceLimits.test.ts @@ -0,0 +1,112 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; +import { SigrokRecordingProvider } from "../../../src/signals/SigrokRecordingProvider.js"; +import { SignalRecordingService } from "../../../src/application/signals/SignalRecordingService.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Real kernel-limit verification with a synthetic producer; real sigrok has its own verifier. +it.skipIf(process.platform !== "linux")( + "enforces both address-space limits before any producer allocation", + async () => { + const directory = await createTestTempDirectory("rea-sigrok-limits-"); + const command = join(directory, "memory-probe"); + await writeFile( + command, + `#!/usr/bin/python3 +import resource, sys +assert resource.getrlimit(resource.RLIMIT_AS) == (1073741824, 1073741824) +assert resource.getrlimit(resource.RLIMIT_FSIZE) == (67108864, 67108864) +assert resource.getrlimit(resource.RLIMIT_CORE) == (0, 0) +if '--version' in sys.argv: + print('sigrok-cli 0.7.2\\nlibsigrokdecode (resource fixture)') +else: + try: + bytearray(1073741825) + except MemoryError: + print('allocation refused by kernel limit') + else: + raise RuntimeError('allocation unexpectedly succeeded') +`, + { mode: 0o700 }, + ); + const path = join(directory, "recording.vcd"); + await writeFile(path, "selected fixture"); + const service = new SignalRecordingService( + new SigrokRecordingProvider({ + ...process.env, + REA_SIGROK_COMMAND: command, + }), + ); + onTestFinished(() => service.close()); + const result = await service.decode({ path, decoders: "uart" }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + annotations_text: "allocation refused by kernel limit\n", + limits: { address_space_bytes: 1073741824 }, + }); + }, +); + +it.skipIf(process.platform !== "linux")( + "preserves lower inherited file/CPU limits in the real launch", + async () => { + const directory = await createTestTempDirectory( + "rea-sigrok-inherited-limits-", + ); + const command = join(directory, "limit-probe"); + const path = join(directory, "recording.vcd"); + await writeFile(path, "selected fixture"); + await writeFile( + command, + `#!/usr/bin/python3 +import resource, sys +assert resource.getrlimit(resource.RLIMIT_FSIZE) == (1048576, 1048576) +assert resource.getrlimit(resource.RLIMIT_CPU) == (20, 20) +if '--version' in sys.argv: + print('sigrok-cli 0.7.2\\nlibsigrokdecode (resource fixture)') +else: + print('10-20 uart-1: 42') +`, + { mode: 0o700 }, + ); + const runner = join(directory, "runner.mjs"); + const serviceModule = new URL( + "../../../dist/application/signals/SignalRecordingService.js", + import.meta.url, + ).href; + const providerModule = new URL( + "../../../dist/signals/SigrokRecordingProvider.js", + import.meta.url, + ).href; + await writeFile( + runner, + `import { SignalRecordingService } from ${JSON.stringify(serviceModule)}; +import { SigrokRecordingProvider } from ${JSON.stringify(providerModule)}; +const service = new SignalRecordingService(new SigrokRecordingProvider({ ...process.env, REA_SIGROK_COMMAND: ${JSON.stringify(command)} })); +try { + const result = await service.decode({ path: ${JSON.stringify(path)}, decoders: 'uart' }); + if (!result.ok) throw result.error; + console.log(JSON.stringify(result.value.normalized_result.limits)); +} finally { await service.close(); } +`, + ); + const { stdout } = await promisify(execFile)( + "/usr/bin/prlimit", + [ + "--fsize=1048576:2097152", + "--cpu=20:25", + "--", + process.execPath, + runner, + ], + { timeout: 30000, maxBuffer: 65536 }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + file_bytes: 1048576, + cpu_seconds: 20, + }); + }, +);