diff --git a/.github/workflows/emulation.yml b/.github/workflows/emulation.yml new file mode 100644 index 00000000..d82e4b84 --- /dev/null +++ b/.github/workflows/emulation.yml @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +name: Sentry emulator end-to-end + +on: + push: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + emulator: + defaults: + run: + shell: bash + runs-on: ubuntu-latest + container: + image: mesonbuild/ubuntu-rolling + steps: + - name: XXX git permission quirk XXX + run: | + git config --global --add safe.directory $GITHUB_WORKSPACE + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + set-safe-directory: true + - name: Clone cross-files + uses: actions/checkout@v6 + with: + ref: 'main' + repository: 'camelot-os/meson-cross-files' + path: crossfiles + - name: Deploy cross-files + run: | + mkdir -p $HOME/.local/share/meson/cross + cp -a $GITHUB_WORKSPACE/crossfiles/*.ini $HOME/.local/share/meson/cross + echo "MESON_CROSS_FILES=$HOME/.local/share/meson/cross" >> $GITHUB_ENV + - name: install prerequisites pkg + uses: camelot-os/action-install-pkg@v1 + with: + packages: 'dtc|device-tree-compiler,libssh2-1|libssh2,curl,lld' + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: 1.86.0 + targets: thumbv7m-none-eabi,thumbv7em-none-eabi,thumbv7em-none-eabihf,thumbv8m.base-none-eabi,thumbv8m.main-none-eabi,thumbv8m.main-none-eabihf,x86_64-unknown-linux-gnu + components: clippy,rustfmt + - name: Setup C toolchain + uses: camelot-os/action-setup-compiler@v1 + with: + compiler: gcc + triple: arm-none-eabi + ref: '12.3.Rel1' + workspace: $GITHUB_WORKSPACE + - name: deploy local deps + run: | + pip install -r requirements.txt + - name: defconfig + run: | + defconfig configs/stm32f429i_disc1_debug_defconfig + - name: Meson setup and compile + uses: camelot-os/action-meson@v1 + with: + cross_files: ${{ format('{0}/{1}', env.MESON_CROSS_FILES, 'cm4-none-eabi-gcc.ini') }} + actions: '["prefetch", "setup", "compile"]' + options: '-Dconfig=.config -Ddts=dts/examples/stm32f429i_disc1_debug.dts -Ddts-include-dirs=dts -Dwith_doc=false -Dwith_tests=false -Dwith_proof=false -Dwith_kernel=false -Dwith_uapi=true -Dwith_idle=false -Dwith_tools=true -Dwith_emulator=true -Dwith_sbom=false -Dwith-install-crates=false' + - name: Run emulator e2e test suite + run: | + cd builddir + meson test --suite emulator --verbose + - name: Meson postcheck + if: failure() + run: | + cat builddir/meson-logs/meson-log.txt || true + cat builddir/meson-logs/testlog.txt || true diff --git a/REUSE.toml b/REUSE.toml index 41465a30..8d5b6f65 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -32,3 +32,36 @@ path = ["uapi/Cargo.lock", "uapi/rust_target.in", "uapi/rustargs.in"] SPDX-FileCopyrightText = "2023-2024 Ledger SAS" SPDX-License-Identifier = "Apache-2.0" +# local build and analysis artifacts +[[annotations]] +path = [ + ".config", + ".config.*", + ".ivette", + ".vscode/settings.json", + ".sonar/**", + "bw_output/**", +] +SPDX-FileCopyrightText = "2026 H2Lab Development Team" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = [ + "config", + "coverage.xml", + "cscope.out", + "cscope.out.in", + "cscope.out.po", + "subprojects.toml", + "results/log.html", + "results/output.xml", + "results/report.html", + "kernel/proof/proof_composition/proof_zlib/.meson.build.swp", + "kernel/src/managers/memory/.memory_mpu.c.swp", + "kernel/proof/tools/gencoverage.py.2", + "kernel/proof/tools/gencoverage.py.metrics", + "kernel/proof/tools/sonarqube.tests.schema", +] +SPDX-FileCopyrightText = "2026 H2Lab Development Team" +SPDX-License-Identifier = "Apache-2.0" + diff --git a/doc/concepts/emulator.rst b/doc/concepts/emulator.rst new file mode 100644 index 00000000..ce667b30 --- /dev/null +++ b/doc/concepts/emulator.rst @@ -0,0 +1,309 @@ +.. _emulator: + +Sentry Emulator +--------------- + +.. index:: + single: emulator; sentry-emulator + single: POSIX; uapi backend + + +Why an emulator? +^^^^^^^^^^^^^^^^ + +The Sentry kernel targets embedded microcontrollers (ARM Cortex-M, etc.), which +are ``no_std`` platforms with no host operating system. Developing and +validating Sentry applications on real hardware involves cross-compilation and +flashing cycles that slow iteration significantly. + +The Sentry emulator allows a Sentry application — written against the +``sentry-uapi`` crate — to run directly on a GNU/Linux x86_64 system *without +any embedded hardware*. It intercepts Sentry system calls and emulates them at +user level, providing: + +* a fast on-host development environment; +* the ability to write automated integration tests covering full end-to-end + application behaviour (IPC, signals, alarms, log, etc.); +* support for continuous integration pipelines that have no access to real + embedded hardware. + + +How the emulator works and how ``sentry-uapi`` integrates with it +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: gRPC; emulator + single: protobuf; emulator + +General architecture +"""""""""""""""""""" + +The emulator is a Python daemon that exposes a gRPC service on +``127.0.0.1:44044`` (configurable). The communication between an application +and the daemon is based on the Protobuf protocol defined under +``tools/sentry-emulator/proto/``. + +When an application compiled in POSIX mode +(``--target x86_64-unknown-linux-gnu`` with the ``std`` feature) issues a Sentry +system call, the POSIX backend of the ``sentry-uapi`` crate +(``uapi/src/posix.rs``) intercepts the call and: + +1. serialises the syscall name, its arguments, and the exchange buffer into a + Protobuf ``DispatchRequest`` message; +2. sends that message to the daemon over gRPC (``Dispatch`` RPC); +3. waits for the ``DispatchResponse`` that carries the Sentry status code and, + when applicable, the updated contents of the exchange buffer. + +The Python daemon receives requests, validates them, traces them in its logs, +and executes the corresponding emulation logic (blocking IPC, signal delivery, +alarm, etc.). Each started application is given a unique *context* identified +by its **label** (a ``u32`` integer). + +.. code-block:: none + :caption: Syscall flow through the emulator + + Rust application + │ + │ using sentry-uapi crate + ▼ + sentry-uapi Rust crate (in native GNU/Linux mode) + │ + │ via sentry-uapi gRPC backend (tonic/gRPC) + ▼ + sentry-emulator daemon (Python / gRPC) + │ + │ DispatchRequest { syscall, args, label, payload } + ▼ + EmulatorServicer.Dispatch() + │ + │ emulation logic + ▼ + DispatchResponse { status, detail, payload } + │ + ▼ + Rust application (syscall result) + +``sentry-uapi`` integration +"""""""""""""""""""""""""""" + +The POSIX backend is activated automatically when the crate is compiled for the +``x86_64-unknown-linux-gnu`` target with the ``std`` feature. Connection +parameters are read from the environment at application startup: + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Environment variable + - Default value + - Purpose + * - ``SENTRY_EMULATOR_HOST`` + - ``127.0.0.1`` + - Daemon listening address. + * - ``SENTRY_EMULATOR_PORT`` + - ``44044`` + - gRPC service port. + * - ``SENTRY_APP_LABEL`` + - ``0`` + - Application label registered in the emulator. + +These variables are set automatically by the daemon when it launches +applications via the ``--start`` option. + +Local syscalls that do not involve exchange with other tasks +(``sched_yield``, ``exit``, etc.) are handled locally by the POSIX backend +without reaching the daemon. + + +Building and installing the emulator as a Python package +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: installation; Python emulator + +The emulator is a standard Python package (``camelot.sentry-emulator``) +managed with ``setuptools`` and ``setuptools-scm``. + +Manual build +"""""""""""" + +From the ``tools/sentry-emulator/`` directory: + +.. code-block:: sh + + # Build the wheel and the sdist + python -m build + +Artifacts (``*.whl`` and ``*.tar.gz``) are written to ``dist/``. + +Installing into a virtual environment +"""""""""""""""""""""""""""""""""""""" + +.. code-block:: sh + + python -m venv .venv + source .venv/bin/activate + pip install dist/camelot.sentry_emulator-*.whl + +Once installed, the ``sentry-emulator`` command is available in the venv +``PATH``: + +.. code-block:: sh + + sentry-emulator --help + +Building via Meson +"""""""""""""""""" + +The Meson build system integrates the emulator build through the +``sentry-emulator-build`` custom target. It is triggered automatically during +the main build (``build_by_default: true``) and writes artifacts to: + +.. code-block:: none + + /tools/sentry-emulator/dist/ + + +Testing the emulator via Meson and the Rust sample applications +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: tests; emulator + single: sample-app-one; sample-app-two + +The two Rust sample applications +"""""""""""""""""""""""""""""""""" + +The ``tools/sentry-emulator/sample-rust-app/`` sub-project produces two +binaries, ``sample-app-one`` and ``sample-app-two``, that together form a +complete end-to-end validation scenario for the emulator: + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Binary + - Role + * - ``sample-app-one`` + - *Sender* — sends a blocking IPC to ``sample-app-two``, then emits a + signal and exercises the ``alarm``, ``random``, and ``get_cycle`` + syscalls. + * - ``sample-app-two`` + - *Receiver* — waits for the IPC via ``wait_for_event``, processes the + payload, handles the incoming signal, then runs the same shared + verification steps. + +Separating sender and receiver allows the set of inter-task communication +primitives (IPC, signals) that form the core of the Sentry execution model to +be tested in isolation. + +Building the sample applications +"""""""""""""""""""""""""""""""""" + +The binaries are compiled automatically by Meson via the +``sentry-emulator-sample-rust-app-build`` target and placed in: + +.. code-block:: none + + /tools/sentry-emulator/sample-rust-target/debug/sample-app-one + /tools/sentry-emulator/sample-rust-target/debug/sample-app-two + +To build them manually: + +.. code-block:: sh + + cd tools/sentry-emulator/sample-rust-app + cargo build + +Running end-to-end tests +""""""""""""""""""""""""" + +End-to-end tests are marked ``emulator`` in pytest and can be run in several +ways. + +With tox (from ``tools/sentry-emulator/``): + +.. code-block:: sh + + tox -e emulator + +With pytest directly (the sample applications must be built first): + +.. code-block:: sh + + pytest -m emulator + +The main test (``test_cli_starts_sample_rust_apps_via_start``): + +1. checks that both binaries are present in ``builddir``; +2. launches the ``camelot.sentry_emulator`` daemon with + ``--start sample-app-one,label=7`` and ``--start sample-app-two,label=8``; +3. waits for the daemon to terminate naturally (when all sample applications + have called ``exit``); +4. asserts that the process return code is ``0``. + +.. note:: + If the Rust binaries are not present in ``builddir``, the test is + automatically skipped (``pytest.skip``) with an explicit message. + + +Using the emulator with your own applications +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. index:: + single: usage; emulator + +Prerequisites +""""""""""""" + +* The emulator is installed (via pip or the Meson venv). +* The application is compiled for ``x86_64-unknown-linux-gnu`` with the ``std`` + feature of ``sentry-uapi``. + +Minimal example +""""""""""""""" + +Assume two applications ``my-task-a`` (label ``10``) and ``my-task-b`` +(label ``11``): + +.. code-block:: sh + + # Start the emulator and register the two tasks to launch + sentry-emulator \ + --log-level INFO \ + --start ./target/x86_64-unknown-linux-gnu/debug/my-task-a,label=10 \ + --start ./target/x86_64-unknown-linux-gnu/debug/my-task-b,label=11 + +The daemon will: + +1. start and listen on ``127.0.0.1:44044``; +2. launch ``my-task-a`` and ``my-task-b``, automatically injecting + ``SENTRY_APP_LABEL``, ``SENTRY_EMULATOR_HOST``, and + ``SENTRY_EMULATOR_PORT`` into their environment; +3. receive and process Sentry syscalls emitted by both tasks; +4. terminate cleanly once all launched tasks have called ``syscall::exit``. + +To run the daemon on its own (without supervised applications), for example +when tests start their own processes, simply omit the ``--start`` options: + +.. code-block:: sh + + sentry-emulator --host 127.0.0.1 --port 44044 --log-level DEBUG + +Verbosity levels +"""""""""""""""" + +.. list-table:: + :header-rows: 1 + :widths: 15 85 + + * - Level + - Information traced + * - ``INFO`` + - Daemon start/stop, application context lifecycle. + * - ``WARNING`` + - Invalid or malformed requests received by the daemon. + * - ``ERROR`` + - Invalid CLI arguments, application launch failures. + * - ``DEBUG`` + - Full detail of every gRPC request/response and task event. diff --git a/doc/concepts/index.rst b/doc/concepts/index.rst index f8699d92..c1ddbc68 100644 --- a/doc/concepts/index.rst +++ b/doc/concepts/index.rst @@ -15,6 +15,7 @@ Welcome to Sentry documentation! sw_architecture/index.rst tests/index.rst proof/index.rst + emulator.rst .. toctree:: :hidden: diff --git a/meson.options b/meson.options index 8161bbf5..1a1b1a40 100644 --- a/meson.options +++ b/meson.options @@ -8,6 +8,7 @@ option('with_kernel', type: 'boolean', value: true, description: 'Sentry kernel option('with_uapi', type: 'boolean', value: true, description: 'Sentry UAPI library is built') option('with_idle', type: 'boolean', value: true, description: 'Sentry Idle task is built') option('with_tools', type: 'boolean', value: true, description: 'compile naitive tools') +option('with_emulator', type: 'boolean', value: false, description: 'build Python Sentry emulator daemon') option('with_sbom', type: 'boolean', value: false, description: 'generate SBOMs for python and subprojects') option('config', type: 'string', description: 'Configuration file to use', yield: true) option('dts', type: 'string', description: 'Top level DTS file', yield: true) diff --git a/requirements.txt b/requirements.txt index 02a36578..717729eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,6 @@ weasyprint lief GitPython>=3.1.0 cyclonedx-bom +build +grpcio>=1.80 +protobuf>=6.31 diff --git a/tools/meson.build b/tools/meson.build index d35a8019..a0a44502 100644 --- a/tools/meson.build +++ b/tools/meson.build @@ -7,3 +7,29 @@ endif subdir('robot') subdir('genmetadata') + +if get_option('with_emulator') + subdir('sentry-emulator') + + emulator_start_app_one = '@0@,label=7'.format(sample_rust_app_one_path) + emulator_start_app_two = '@0@,label=8'.format(sample_rust_app_two_path) + + test( + 'sentry-emulator-rust-apps-e2e', + py3, + args: [ + '-c', + 'from camelot.sentry_emulator.cli import main; raise SystemExit(main())', + '--log-level', + 'ERROR', + '--start', + emulator_start_app_one, + '--start', + emulator_start_app_two, + ], + env: {'PYTHONPATH': meson.project_source_root() / 'tools/sentry-emulator/src'}, + suite: 'emulator', + timeout: 30, + depends: [sample_rust_build], + ) +endif diff --git a/tools/sentry-emulator/.gitignore b/tools/sentry-emulator/.gitignore new file mode 100644 index 00000000..cf5c1478 --- /dev/null +++ b/tools/sentry-emulator/.gitignore @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +# Python caches and bytecode +__pycache__/ +*.py[cod] +*.pyo + +# Virtual environments and test tooling +.venv/ +.tox/ +.mypy_cache/ +.pytest_cache/ + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Rust sample app build artifacts +sample-rust-app/target/ diff --git a/tools/sentry-emulator/README.md b/tools/sentry-emulator/README.md new file mode 100644 index 00000000..4b431c21 --- /dev/null +++ b/tools/sentry-emulator/README.md @@ -0,0 +1,53 @@ + + + +# Camelot Sentry Emulator + +This package provides a gRPC daemon used by the POSIX implementation of the `sentry-uapi` crate. + +## Features + +- listens on gRPC (`127.0.0.1:44044` by default) +- receives syscall commands emitted by the POSIX Rust backend +- protobuf-based serialization/deserialization for syscall payloads +- traces each deserialized command to daemon stderr logs +- validates incoming commands and sorts them by syscall name +- starts applications with labeled contexts (`--start app.elf,label=`) +- assigns one unique context handle per started application + +## Run + +```sh +sentry-emulator --host 127.0.0.1 --port 44044 +``` + +To start applications and register their contexts: + +```sh +sentry-emulator --start ./build/my-app,label=7 --start ./build/my-other-app,label=12 +``` + +Each started app receives `SENTRY_APP_LABEL`, `SENTRY_EMULATOR_HOST`, and +`SENTRY_EMULATOR_PORT` in its environment. + +## Sample Rust tasks + +The sample Rust project now builds two binaries with the same validation flow: + +- `sample-app-one` +- `sample-app-two` + +They are produced under `sample-rust-app/target/debug/` when building the sample +project. + +## Local build + +```sh +python -m build +``` + +## Tox workflow + +```sh +tox +``` diff --git a/tools/sentry-emulator/meson.build b/tools/sentry-emulator/meson.build new file mode 100644 index 00000000..5bd74f36 --- /dev/null +++ b/tools/sentry-emulator/meson.build @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + + +emulator_pyproject = files('pyproject.toml') +emulator_tox = files('tox.ini') + +rustmod = import('rust') + +emulator_build = custom_target( + 'sentry-emulator-build', + input: [emulator_pyproject, emulator_tox], + output: 'sentry-emulator-build.stamp', + command: [ + py3, + '-c', + 'import pathlib, subprocess, sys; out = pathlib.Path(sys.argv[1]); src = pathlib.Path(sys.argv[2]); dist = pathlib.Path(sys.argv[3]); dist.mkdir(parents=True, exist_ok=True); subprocess.run([sys.executable, "-m", "build", "--sdist", "--wheel", "--outdir", str(dist), str(src)], check=True); out.write_text("ok\\n", encoding="utf-8")', + '@OUTPUT@', + meson.current_source_dir(), + meson.current_build_dir() / 'dist', + ], + build_by_default: true, +) + +cargo = find_program('cargo', required: true) + +sample_rust_manifest = files('sample-rust-app/Cargo.toml') +sample_rust_sources = files([ + 'sample-rust-app/src/lib.rs', + 'sample-rust-app/src/bin/sample-app-one.rs', + 'sample-rust-app/src/bin/sample-app-two.rs', +]) +sample_rust_target_dir = meson.current_build_dir() / 'sample-rust-target' +sample_rust_app_one_path = sample_rust_target_dir / 'debug' / 'sample-app-one' +sample_rust_app_two_path = sample_rust_target_dir / 'debug' / 'sample-app-two' + +sample_rust_build = custom_target( + 'sentry-emulator-sample-rust-app-build', + input: [sample_rust_manifest, sample_rust_sources], + output: 'sentry-emulator-sample-rust-app-build.stamp', + command: [ + py3, + '-c', + 'import pathlib, subprocess, sys; out = pathlib.Path(sys.argv[1]); manifest = pathlib.Path(sys.argv[2]); target = pathlib.Path(sys.argv[3]); subprocess.run(["cargo", "build", "--manifest-path", str(manifest), "--target-dir", str(target)], check=True); out.write_text("ok\\n", encoding="utf-8")', + '@OUTPUT@', + sample_rust_manifest[0 ].full_path(), + sample_rust_target_dir, + ], + build_by_default: true, +) + +summary( + { + 'sentry emulator artifacts': meson.current_build_dir() / 'dist', + 'sentry emulator sample rust app one': sample_rust_app_one_path, + 'sentry emulator sample rust app two': sample_rust_app_two_path, + }, + section: 'Tools', +) diff --git a/tools/sentry-emulator/proto/emulator.proto b/tools/sentry-emulator/proto/emulator.proto new file mode 100644 index 00000000..07e32eb2 --- /dev/null +++ b/tools/sentry-emulator/proto/emulator.proto @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package camelot.sentry.emulator; + +service Emulator { + rpc Dispatch(DispatchRequest) returns (DispatchResponse); +} + +message DispatchRequest { + string syscall = 1; + repeated sint64 args = 2; + uint32 label = 3; + bytes payload = 4; +} + +message DispatchResponse { + sint32 status = 1; + string detail = 2; + bytes payload = 3; +} diff --git a/tools/sentry-emulator/pyproject.toml b/tools/sentry-emulator/pyproject.toml new file mode 100644 index 00000000..f90c940f --- /dev/null +++ b/tools/sentry-emulator/pyproject.toml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = [ + "setuptools>=69", + "setuptools-scm[toml]>=8", + "wheel", +] +build-backend = "setuptools.build_meta" + +[project] +name = "camelot.sentry-emulator" +description = "gRPC daemon that emulates Sentry kernel syscalls in POSIX mode" +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +authors = [ + { name = "H2Lab Development Team" }, +] +keywords = ["sentry", "emulator", "grpc", "kernel", "camelot"] +dependencies = [ + "grpcio>=1.80", + "protobuf>=6.31", +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Embedded Systems", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/camelot-os/sentry-kernel" +Repository = "https://github.com/camelot-os/sentry-kernel" + +[project.scripts] +sentry-emulator = "camelot.sentry_emulator.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools_scm] +root = "../.." +fallback_version = "0.0.0+unknown" + +[tool.pytest.ini_options] +pythonpath = ["src"] +addopts = "-q" +testpaths = ["tests"] +markers = [ + "emulator: end-to-end tests that launch the emulator with real startup apps", +] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unused_configs = true +exclude = "(^build/|^dist/|src/camelot/sentry_emulator/grpc/)" +packages = ["camelot.sentry_emulator"] + +[[tool.mypy.overrides]] +module = ["camelot.sentry_emulator.grpc.*"] +ignore_errors = true diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.lock b/tools/sentry-emulator/sample-rust-app/Cargo.lock new file mode 100644 index 00000000..955677a8 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/Cargo.lock @@ -0,0 +1,900 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "sentry-emulator-sample-rust-tasks" +version = "0.1.0" +dependencies = [ + "sentry-uapi", +] + +[[package]] +name = "sentry-uapi" +version = "0.4.2" +dependencies = [ + "prost", + "tokio", + "tonic", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/tools/sentry-emulator/sample-rust-app/Cargo.toml b/tools/sentry-emulator/sample-rust-app/Cargo.toml new file mode 100644 index 00000000..d7912038 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/Cargo.toml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + + +[package] +name = "sentry-emulator-sample-rust-tasks" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false +autobins = false + +[dependencies] +sentry-uapi = { path = "../../../uapi", features = ["std"] } + +[[bin]] +name = "sample-app-one" +path = "src/bin/sample-app-one.rs" + +[[bin]] +name = "sample-app-two" +path = "src/bin/sample-app-two.rs" diff --git a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs new file mode 100644 index 00000000..b0ad5a2a --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-one.rs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +//! Binary entrypoint for sample Rust app one. +//! +//! This task plays the sender role in emulator end-to-end tests. + +/// Run sender scenario and terminate the task context with code `0`. +fn main() { + sentry_emulator_sample_rust_tasks::run_sample_app_one(8); + let _ = sentry_uapi::syscall::exit(0); +} diff --git a/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs new file mode 100644 index 00000000..00ddf018 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/src/bin/sample-app-two.rs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +//! Binary entrypoint for sample Rust app two. +//! +//! This task plays the receiver role in emulator end-to-end tests. + +/// Run receiver scenario and terminate the task context with code `0`. +fn main() { + sentry_emulator_sample_rust_tasks::run_sample_app_two(); + let _ = sentry_uapi::syscall::exit(0); +} diff --git a/tools/sentry-emulator/sample-rust-app/src/lib.rs b/tools/sentry-emulator/sample-rust-app/src/lib.rs new file mode 100644 index 00000000..e6083702 --- /dev/null +++ b/tools/sentry-emulator/sample-rust-app/src/lib.rs @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +//! Sample Rust tasks used to validate the sentry-emulator end-to-end behavior. +//! +//! `sample-app-one` emits a blocking IPC to `sample-app-two`, then sends a signal +//! and runs alarm/random/cycle syscalls. `sample-app-two` first consumes the IPC +//! through `wait_for_event`, then handles the signal path and shared checks. + +use sentry_uapi::systypes::{ + AlarmFlag, EventType, Precision, Signal, SleepDuration, SleepMode, Status, +}; + +/// Copy a UTF-8 log message into exchange memory and emit it with `syscall::log`. +fn emit_app_log(message: &str) { + let payload = message.as_bytes(); + let st_copy = sentry_uapi::copy_to_kernel(&payload).unwrap_or(Status::Invalid); + if st_copy != Status::Ok { + eprintln!("[sample-rust-app][fallback] copy_to_kernel(log) failed: {st_copy:?}"); + return; + } + + let st_log = sentry_uapi::syscall::log(payload.len()); + if st_log != Status::Ok { + eprintln!("[sample-rust-app][fallback] syscall::log failed: {st_log:?}"); + } +} + +/// Emit a structured status line for one syscall step. +fn report_status(context: &str, status: Status) { + emit_app_log(&format!("{context}: {status:?}")); +} + +const EXCHANGE_BUFFER_LEN: usize = 128; +const IPC_EVENT_TYPE: u8 = 1; +const SIGNAL_EVENT_TYPE: u8 = 2; +const SIGNAL_EVENT_MAGIC: u16 = 0x4242; +const TARGET_APP_TWO_HANDLE: u32 = 2; +const IPC_TEST_MESSAGE: &[u8] = b"ipc-from-sample-app-one"; + +/// Read raw exchange bytes as emitted by the daemon after `wait_for_event`. +fn read_raw_event_from_exchange() -> Option<[u8; EXCHANGE_BUFFER_LEN]> { + let mut raw_exchange = [0u8; EXCHANGE_BUFFER_LEN]; + let mut raw_exchange_slice: &mut [u8] = &mut raw_exchange; + let st_copy_event = + sentry_uapi::copy_from_kernel(&mut raw_exchange_slice).unwrap_or(Status::Invalid); + report_status("copy_from_kernel(raw event)", st_copy_event); + if st_copy_event != Status::Ok { + return None; + } + Some(raw_exchange) +} + +/// Decode a signal event header and payload from exchange memory. +/// +/// Returns `Some(signal)` when the buffer contains a valid serialized signal +/// event, otherwise returns `None` and logs the invalid frame details. +fn read_signal_event_from_exchange() -> Option { + let raw_exchange = read_raw_event_from_exchange()?; + + let event_type = raw_exchange[0]; + let event_len = raw_exchange[1]; + let event_magic = u16::from_le_bytes([raw_exchange[2], raw_exchange[3]]); + let event_peer = u32::from_le_bytes([ + raw_exchange[4], + raw_exchange[5], + raw_exchange[6], + raw_exchange[7], + ]); + let signal = u32::from_le_bytes([ + raw_exchange[8], + raw_exchange[9], + raw_exchange[10], + raw_exchange[11], + ]); + + if event_type != SIGNAL_EVENT_TYPE + || event_len != 4 + || event_magic != SIGNAL_EVENT_MAGIC + || event_peer == 0 + { + emit_app_log(&format!( + "invalid signal event header: type={event_type} len={event_len} magic=0x{event_magic:04x} peer={event_peer}" + )); + return None; + } + + emit_app_log(&format!( + "signal event received from peer={event_peer} value={signal}" + )); + Some(signal) +} + +/// Decode an IPC event and verify it carries the expected payload. +fn read_ipc_event_from_exchange(expected_payload: &[u8]) -> bool { + let Some(raw_exchange) = read_raw_event_from_exchange() else { + return false; + }; + + let event_type = raw_exchange[0]; + let event_len = raw_exchange[1] as usize; + let event_magic = u16::from_le_bytes([raw_exchange[2], raw_exchange[3]]); + let event_peer = u32::from_le_bytes([ + raw_exchange[4], + raw_exchange[5], + raw_exchange[6], + raw_exchange[7], + ]); + + if event_type != IPC_EVENT_TYPE || event_magic != SIGNAL_EVENT_MAGIC || event_peer == 0 { + emit_app_log(&format!( + "invalid ipc event header: type={event_type} len={event_len} magic=0x{event_magic:04x} peer={event_peer}" + )); + return false; + } + + let payload_end = 8usize.saturating_add(event_len).min(EXCHANGE_BUFFER_LEN); + let payload = &raw_exchange[8..payload_end]; + if payload != expected_payload { + emit_app_log(&format!( + "unexpected ipc payload: len={} expected_len={}", + payload.len(), + expected_payload.len() + )); + return false; + } + + emit_app_log(&format!( + "ipc event received from peer={event_peer} payload_len={event_len}" + )); + true +} + +/// Exercise alarm, random, and cycle syscalls and log each returned status. +fn run_alarm_random_cycle_checks() { + // Start periodic alarm to guarantee that an immediate stop targets a live registration. + let st_alarm_start = sentry_uapi::syscall::alarm(5000, AlarmFlag::AlarmStartPeriodic); + let st_alarm_stop = sentry_uapi::syscall::alarm(5000, AlarmFlag::AlarmStop); + report_status("alarm(start)", st_alarm_start); + if st_alarm_stop == Status::NoEntity { + emit_app_log("alarm(stop): Ok (already stopped)"); + } else { + report_status("alarm(stop)", st_alarm_stop); + } + + let st_rng = sentry_uapi::syscall::get_random(); + let mut rng_value: u32 = 0; + let st_copy_rng = sentry_uapi::copy_from_kernel(&mut rng_value).unwrap_or(Status::Invalid); + report_status("get_random", st_rng); + report_status("copy_from_kernel(random)", st_copy_rng); + + let st_cycle = sentry_uapi::syscall::get_cycle(Precision::Milliseconds); + let mut cycle_value: u64 = 0; + let st_copy_cycle = + sentry_uapi::copy_from_kernel(&mut cycle_value).unwrap_or(Status::Invalid); + report_status("get_cycle", st_cycle); + report_status("copy_from_kernel(cycle)", st_copy_cycle); +} + +/// Entry routine for sample app one. +/// +/// The routine sends a blocking IPC to app two, then sends `SIGUSR1` to +/// continue receiver-side checks, and finally validates other syscalls. +pub fn run_sample_app_one(peer_label: u32) { + let _ = peer_label; + + // Let the receiver enter its blocking wait path before sending IPC. + let st_sleep = sentry_uapi::syscall::sleep(SleepDuration::ArbitraryMs(100), SleepMode::Deep); + report_status("sleep(100ms)", st_sleep); + + let st_copy_ipc = sentry_uapi::copy_to_kernel(&IPC_TEST_MESSAGE).unwrap_or(Status::Invalid); + if st_copy_ipc != Status::Ok { + report_status("copy_to_kernel(ipc payload)", st_copy_ipc); + } + let st_send_ipc = sentry_uapi::syscall::send_ipc(TARGET_APP_TWO_HANDLE, IPC_TEST_MESSAGE.len() as u8); + report_status("send_ipc(peer)", st_send_ipc); + + let st_sig_peer = sentry_uapi::syscall::send_signal(TARGET_APP_TWO_HANDLE, Signal::Usr1); + report_status("send_signal(peer, SIGUSR1)", st_sig_peer); + + // Emit a second signal to make the emulator e2e startup phase robust against transient ordering. + let st_sig_peer_retry = sentry_uapi::syscall::send_signal(TARGET_APP_TWO_HANDLE, Signal::Usr1); + report_status("send_signal(peer, SIGUSR1, retry)", st_sig_peer_retry); + run_alarm_random_cycle_checks(); +} + +/// Entry routine for sample app two. +/// +/// The routine first blocks on IPC reception, then blocks on signal reception +/// until `SIGUSR1` is received, and finally runs the shared syscall checks. +pub fn run_sample_app_two() { + loop { + let st_wait_ipc = sentry_uapi::syscall::wait_for_event(EventType::Ipc.into(), 0); + if st_wait_ipc != Status::Ok { + report_status("wait_for_event(ipc, no timeout)", st_wait_ipc); + continue; + } + if read_ipc_event_from_exchange(IPC_TEST_MESSAGE) { + report_status("wait_for_event(ipc, no timeout)", st_wait_ipc); + break; + } + } + + // Wait without timeout and return only when SIGUSR1 has been serialized by daemon. + loop { + let st_wait_signal = sentry_uapi::syscall::wait_for_event(EventType::Signal.into(), 0); + if st_wait_signal != Status::Ok { + report_status("wait_for_event(signal, no timeout)", st_wait_signal); + continue; + } + let Some(signal) = read_signal_event_from_exchange() else { + continue; + }; + + report_status("wait_for_event(signal, no timeout)", st_wait_signal); + + if signal == Signal::Usr1 as u32 { + break; + } + + emit_app_log(&format!("ignoring other signal value={signal}")); + } + run_alarm_random_cycle_checks(); +} diff --git a/tools/sentry-emulator/src/camelot/__init__.py b/tools/sentry-emulator/src/camelot/__init__.py new file mode 100644 index 00000000..ac93b158 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Top-level namespace for Camelot Python packages.""" diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py new file mode 100644 index 00000000..21467596 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__init__.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Public API for the Camelot Sentry emulator package. + +This package exposes protocol helpers, in-memory dispatch storage, and daemon +runtime entrypoints for embedding or command-line usage. +""" + +from importlib.metadata import PackageNotFoundError, version + +from .dispatcher import SyscallStore +from .protocol import ProtocolError, SyscallMessage, deserialize_request +from .server import GrpcEmulatorDaemon + +try: + __version__ = version("camelot.sentry-emulator") +except PackageNotFoundError: + __version__ = "0.0.0+dev" + +__all__ = [ + "ProtocolError", + "SyscallMessage", + "SyscallStore", + "GrpcEmulatorDaemon", + "deserialize_request", +] diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py new file mode 100644 index 00000000..44fd01d1 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/__main__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Module entrypoint for ``python -m camelot.sentry_emulator``.""" + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py new file mode 100644 index 00000000..a61b94a7 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/cli.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Command-line interface for the Sentry emulator daemon. + +This module defines the public executable behavior used by the +``sentry-emulator`` console script. It validates CLI arguments, configures +logging, builds startup application specifications, and starts the gRPC daemon. +""" + +import argparse +import logging + +from .server import ( + DEFAULT_HOST, + DEFAULT_PORT, + GrpcEmulatorDaemon, + StartSpec, + parse_start_option, +) + + +def _build_parser() -> argparse.ArgumentParser: + """Create the command-line parser for the emulator daemon. + + Returns + ------- + argparse.ArgumentParser + Parser configured with network, logging, and startup options. + """ + parser = argparse.ArgumentParser( + prog="sentry-emulator", + description="Run the Camelot Sentry userspace emulator daemon.", + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"gRPC listening interface (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + default=DEFAULT_PORT, + type=int, + help=f"gRPC listening port (default: {DEFAULT_PORT})", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help=( + "Daemon verbosity: INFO=start/stop lifecycle, " + "WARNING=invalid request content, ERROR=invalid args/start failures, " + "DEBUG=full request/response and task events" + ), + ) + parser.add_argument( + "--start", + action="append", + default=[], + metavar="APP,label=", + help="Start an application and register context for this label (repeatable)", + ) + return parser + + +def _parse_start_specs(raw_specs: list[str]) -> tuple[StartSpec, ...]: + """Parse and validate ``--start`` values into startup specifications. + + Parameters + ---------- + raw_specs : list[str] + Raw values provided by repeated ``--start`` arguments. + Returns + ------- + tuple[StartSpec, ...] + Validated startup specifications in input order. + + Raises + ------ + ValueError + If any ``--start`` entry has invalid syntax or values. + """ + parsed: list[StartSpec] = [] + for raw in raw_specs: + parsed.append(parse_start_option(raw)) + return tuple(parsed) + + +def main() -> int: + """Run the daemon CLI entrypoint. + + Returns + ------- + int + Process return code (``0`` for graceful termination). + """ + parser = _build_parser() + args = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("camelot.sentry_emulator") + + if args.port < 0 or args.port > 65535: + logger.error("Invalid --port value: %d (expected in [0, 65535])", args.port) + return 2 + + try: + start_specs = _parse_start_specs(args.start) + except ValueError as exc: + logger.error("Invalid startup argument: %s", exc) + return 2 + + daemon = GrpcEmulatorDaemon(host=args.host, port=args.port, start_specs=start_specs) + try: + daemon.serve_forever() + except KeyboardInterrupt: + logger.info("Shutdown requested") + except RuntimeError as exc: + logger.error("Daemon startup failed: %s", exc) + return 1 + + return 0 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py new file mode 100644 index 00000000..296cc99a --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/dispatcher.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""In-memory syscall dispatch bookkeeping. + +The daemon currently stores accepted syscall messages in memory for inspection +and tests. This module centralizes that behavior. +""" + +from collections import defaultdict +from dataclasses import dataclass, field + +from .protocol import SyscallMessage + + +@dataclass(slots=True) +class SyscallStore: + """Store incoming syscall messages grouped by syscall name. + + Attributes + ---------- + buckets : dict[str, list[SyscallMessage]] + Mapping from syscall name to all corresponding messages. + invalid_packets : int + Number of rejected requests due to protocol or routing errors. + """ + + buckets: dict[str, list[SyscallMessage]] = field( + default_factory=lambda: defaultdict(list) + ) + invalid_packets: int = 0 + + def register(self, message: SyscallMessage) -> None: + """Register a valid syscall message. + + Parameters + ---------- + message : SyscallMessage + Validated syscall payload to store. + """ + self.buckets[message.syscall].append(message) + + def register_invalid(self) -> None: + """Increment the invalid packet counter.""" + self.invalid_packets += 1 + + def count_for(self, syscall_name: str) -> int: + """Return the number of stored calls for one syscall name. + + Parameters + ---------- + syscall_name : str + Syscall name key in the store. + + Returns + ------- + int + Number of registered messages for ``syscall_name``. + """ + return len(self.buckets.get(syscall_name, [])) + + def snapshot_counts(self) -> dict[str, int]: + """Return a compact count snapshot for all known syscalls. + + Returns + ------- + dict[str, int] + Mapping from syscall names to message counts. + """ + return {name: len(entries) for name, entries in self.buckets.items()} diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/__init__.py new file mode 100644 index 00000000..74c9941e --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/__init__.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Internal module split for emulator server runtime.""" + +from .constants import ( + DEFAULT_HOST, + DEFAULT_PORT, + EVENT_MAGIC, + EVENT_TYPE_SIGNAL, + EXCHANGE_BUFFER_LEN, + MAX_PENDING_SIGNALS, + PRECISION_CYCLE, + PRECISION_MICROSECONDS, + PRECISION_MILLISECONDS, + PRECISION_NANOSECONDS, + SIGNAL_ABORT, + SIGNAL_ALARM, + SIGNAL_USR2, + STATUS_AGAIN, + STATUS_BUSY, + STATUS_DENIED, + STATUS_INVALID, + STATUS_NO_ENTITY, + STATUS_OK, + STATUS_TIMEOUT, + UINT32_MAX, +) +from .daemon import GrpcEmulatorDaemon +from .models import AlarmRegistration, AppContext, StartSpec, parse_start_option +from .servicer import EmulatorServicer + +__all__ = [ + "AlarmRegistration", + "AppContext", + "DEFAULT_HOST", + "DEFAULT_PORT", + "EVENT_MAGIC", + "EVENT_TYPE_SIGNAL", + "EXCHANGE_BUFFER_LEN", + "EmulatorServicer", + "GrpcEmulatorDaemon", + "MAX_PENDING_SIGNALS", + "PRECISION_CYCLE", + "PRECISION_MICROSECONDS", + "PRECISION_MILLISECONDS", + "PRECISION_NANOSECONDS", + "SIGNAL_ABORT", + "SIGNAL_ALARM", + "SIGNAL_USR2", + "STATUS_AGAIN", + "STATUS_BUSY", + "STATUS_DENIED", + "STATUS_INVALID", + "STATUS_NO_ENTITY", + "STATUS_OK", + "STATUS_TIMEOUT", + "StartSpec", + "UINT32_MAX", + "parse_start_option", +] diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py new file mode 100644 index 00000000..00bf94b4 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/constants.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Constants used by the emulator daemon and gRPC servicer.""" + +from typing import Final + +DEFAULT_HOST: Final[str] = "127.0.0.1" +DEFAULT_PORT: Final[int] = 44044 +UINT32_MAX: Final[int] = (1 << 32) - 1 +EXCHANGE_BUFFER_LEN: Final[int] = 128 +SIGNAL_ABORT: Final[int] = 1 +EVENT_TYPE_IPC: Final[int] = 1 +EVENT_TYPE_SIGNAL: Final[int] = 2 +SIGNAL_USR2: Final[int] = 12 +SIGNAL_ALARM: Final[int] = 2 +EVENT_MAGIC: Final[int] = 0x4242 +PRECISION_CYCLE: Final[int] = 0 +PRECISION_NANOSECONDS: Final[int] = 1 +PRECISION_MICROSECONDS: Final[int] = 2 +PRECISION_MILLISECONDS: Final[int] = 3 + +STATUS_OK: Final[int] = 0 +STATUS_INVALID: Final[int] = 1 +STATUS_DENIED: Final[int] = 2 +STATUS_NO_ENTITY: Final[int] = 3 +STATUS_BUSY: Final[int] = 4 +STATUS_TIMEOUT: Final[int] = 7 +STATUS_AGAIN: Final[int] = 8 +STATUS_INTR: Final[int] = 9 + +MAX_PENDING_SIGNALS: Final[int] = 32 diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py new file mode 100644 index 00000000..f4265d8a --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/daemon.py @@ -0,0 +1,729 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Daemon lifecycle and app context management for the emulator.""" + +from concurrent import futures +from dataclasses import dataclass, field +import logging +import os +import random +import subprocess +import threading +import time + +import grpc + +from ..dispatcher import SyscallStore +from ..grpc import emulator_pb2_grpc +from .constants import ( + DEFAULT_HOST, + DEFAULT_PORT, + EVENT_MAGIC, + EVENT_TYPE_IPC, + EVENT_TYPE_SIGNAL, + EXCHANGE_BUFFER_LEN, + MAX_PENDING_SIGNALS, + PRECISION_CYCLE, + PRECISION_MICROSECONDS, + PRECISION_MILLISECONDS, + PRECISION_NANOSECONDS, + SIGNAL_ALARM, + STATUS_BUSY, + STATUS_INTR, + STATUS_OK, + UINT32_MAX, +) +from .models import AlarmRegistration, AppContext, PendingIPC, StartSpec + + +@dataclass(slots=True) +class GrpcEmulatorDaemon: + """Lifecycle manager for the emulator gRPC daemon.""" + + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + start_specs: tuple[StartSpec, ...] = () + store: SyscallStore = field(default_factory=SyscallStore) + logger: logging.Logger = field( + default_factory=lambda: logging.getLogger("camelot.sentry_emulator") + ) + + _bound_address: tuple[str, int] | None = field(default=None, init=False) + _contexts_by_label: dict[int, AppContext] = field(default_factory=dict, init=False) + _contexts_by_handle: dict[int, AppContext] = field(default_factory=dict, init=False) + _started_processes: list[subprocess.Popen[bytes]] = field(default_factory=list, init=False) + _next_handle: int = field(default=1, init=False) + _rng: random.Random = field(default_factory=random.Random, init=False) + _lock: threading.Lock = field(default_factory=threading.Lock, init=False) + _start_monotonic_ns: int = field(default_factory=time.monotonic_ns, init=False) + + def _all_startup_contexts_stopped(self) -> bool: + """Report whether every configured startup context has exited. + + Returns + ------- + bool + ``True`` only when startup specs exist and no context remains active. + """ + if not self.start_specs: + return False + with self._lock: + return not self._contexts_by_label + + def deactivate_context(self, app_context: AppContext, exit_code: int) -> bool: + """Deactivate an application context after an ``exit`` syscall. + + Parameters + ---------- + app_context : AppContext + Context to remove from active registries. + exit_code : int + Application exit code reported by userspace. + + Returns + ------- + bool + ``True`` when this deactivation leaves no startup contexts alive. + """ + with self._lock: + removed_by_label = self._contexts_by_label.pop(app_context.label, None) + removed_by_handle = self._contexts_by_handle.pop(app_context.handle, None) + contexts_remaining = len(self._contexts_by_label) + + if removed_by_label is None or removed_by_handle is None: + return self._all_startup_contexts_stopped() + + app_context.exit_code = exit_code + with app_context.event_condition: + app_context.pending_signals.clear() + pending_ipcs = list(app_context.pending_ipcs) + app_context.pending_ipcs.clear() + app_context.event_condition.notify_all() + + for pending_ipc in pending_ipcs: + pending_ipc.completion_status = STATUS_INTR + pending_ipc.done.set() + + for registration in app_context.alarms.values(): + registration.timer.cancel() + app_context.alarms.clear() + + self.logger.info( + "App exited label=%d handle=%d code=%d remaining_contexts=%d", + app_context.label, + app_context.handle, + exit_code, + contexts_remaining, + ) + return contexts_remaining == 0 and bool(self.start_specs) + + def _allocate_handle(self) -> int: + """Allocate the next unique app handle. + + Returns + ------- + int + Unsigned 32-bit process handle. + + Raises + ------ + RuntimeError + If the handle space is exhausted. + """ + if self._next_handle > UINT32_MAX: + raise RuntimeError("app context handle overflow") + handle = self._next_handle + self._next_handle += 1 + return handle + + def _prepare_start_specs(self) -> None: + """Validate startup specs and create contexts before process launch. + + Raises + ------ + RuntimeError + If labels collide or one executable path is missing. + """ + prepared_contexts: list[AppContext] = [] + labels: set[int] = set() + for spec in self.start_specs: + if spec.label in labels or spec.label in self._contexts_by_label: + self.logger.error("Duplicate startup label detected: %d", spec.label) + raise RuntimeError(f"duplicate app label: {spec.label}") + + app_path = spec.app_path.expanduser().resolve() + if not app_path.exists(): + self.logger.error("Startup executable does not exist: %s", app_path) + raise RuntimeError(f"app does not exist: {app_path}") + + prepared_contexts.append( + AppContext( + label=spec.label, + handle=self._allocate_handle(), + app_path=app_path, + ) + ) + labels.add(spec.label) + + with self._lock: + for context in prepared_contexts: + self._contexts_by_label[context.label] = context + self._contexts_by_handle[context.handle] = context + + for context in prepared_contexts: + self.logger.debug( + "Initialized app context label=%d handle=%d path=%s", + context.label, + context.handle, + context.app_path, + ) + + def _start_prepared_apps(self) -> None: + """Spawn all prepared startup applications. + + Raises + ------ + RuntimeError + If one prepared context is missing or process creation fails. + """ + for spec in self.start_specs: + with self._lock: + context = self._contexts_by_label.get(spec.label) + + if context is None: + self.logger.error("Missing initialized context for label=%d", spec.label) + raise RuntimeError(f"missing app context for label: {spec.label}") + + child_env = os.environ.copy() + child_env["SENTRY_APP_LABEL"] = str(spec.label) + child_env["SENTRY_EMULATOR_HOST"] = self.host + child_env["SENTRY_EMULATOR_PORT"] = str(self.port) + + try: + process = subprocess.Popen([str(context.app_path)], env=child_env) + except OSError as exc: + self.logger.error( + "Cannot start app label=%d path=%s: %s", + spec.label, + context.app_path, + exc, + ) + raise RuntimeError(f"cannot start app: {context.app_path}") from exc + + context.process = process + with self._lock: + self._started_processes.append(process) + + self.logger.info( + "Started app label=%d handle=%d pid=%d path=%s", + context.label, + context.handle, + process.pid, + context.app_path, + ) + + def _startup_apps(self) -> None: + """Initialize contexts and launch configured startup apps.""" + if not self.start_specs: + self.logger.info("No startup tasks configured") + return + + self._prepare_start_specs() + self._start_prepared_apps() + + def _terminate_started_apps(self) -> None: + """Terminate started apps and clear all in-memory runtime contexts.""" + for process in self._started_processes: + if process.poll() is None: + self.logger.debug("Stopping child process pid=%d", process.pid) + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + self.logger.warning( + "Child process pid=%d did not stop gracefully, killing", + process.pid, + ) + process.kill() + process.wait(timeout=2) + + with self._lock: + for app_context in self._contexts_by_label.values(): + for registration in app_context.alarms.values(): + registration.timer.cancel() + app_context.alarms.clear() + app_context.pending_signals.clear() + for pending_ipc in app_context.pending_ipcs: + pending_ipc.completion_status = STATUS_INTR + pending_ipc.done.set() + app_context.pending_ipcs.clear() + app_context.process = None + self._contexts_by_label.clear() + self._contexts_by_handle.clear() + + def context_for_label(self, label: int) -> AppContext | None: + """Return the active context associated with one application label. + + Parameters + ---------- + label : int + Application label. + + Returns + ------- + AppContext | None + Matching active context or ``None`` when absent. + """ + with self._lock: + return self._contexts_by_label.get(label) + + def context_for_handle(self, handle: int) -> AppContext | None: + """Return the active context associated with one process handle. + + Parameters + ---------- + handle : int + Process handle returned by ``get_process_handle``. + + Returns + ------- + AppContext | None + Matching active context or ``None`` when absent. + """ + with self._lock: + return self._contexts_by_handle.get(handle) + + def write_exchange_buffer(self, app_context: AppContext, payload: bytes) -> None: + """Write bytes into one app exchange buffer with zero-padding. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + payload : bytes + Payload copied into the fixed-size exchange buffer. + """ + with self._lock: + app_context.exchange_buffer[:] = b"\x00" * EXCHANGE_BUFFER_LEN + copy_len = min(len(payload), EXCHANGE_BUFFER_LEN) + app_context.exchange_buffer[:copy_len] = payload[:copy_len] + self.logger.debug( + "exchange_to_kernel label=%d handle=%d bytes=%d", + app_context.label, + app_context.handle, + copy_len, + ) + + def read_exchange_buffer(self, app_context: AppContext) -> bytes: + """Read one app exchange buffer as immutable bytes. + + Parameters + ---------- + app_context : AppContext + Source app runtime context. + + Returns + ------- + bytes + Snapshot of the full fixed-size exchange buffer. + """ + with self._lock: + payload = bytes(app_context.exchange_buffer) + self.logger.debug( + "exchange_from_kernel label=%d handle=%d bytes=%d", + app_context.label, + app_context.handle, + len(payload), + ) + return payload + + def write_u32_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + """Serialize and write one unsigned 32-bit integer to exchange buffer. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + value : int + Value encoded in little-endian unsigned representation. + """ + self.write_exchange_buffer(app_context, int(value).to_bytes(4, "little", signed=False)) + + def write_u64_to_exchange_buffer(self, app_context: AppContext, value: int) -> None: + """Serialize and write one unsigned 64-bit integer to exchange buffer. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + value : int + Value encoded in little-endian unsigned representation. + """ + self.write_exchange_buffer(app_context, int(value).to_bytes(8, "little", signed=False)) + + def queue_signal(self, target: AppContext, signal: int, source_handle: int) -> int: + """Queue a signal event for one application context. + + Parameters + ---------- + target : AppContext + Destination app context receiving the signal. + signal : int + Signal value to enqueue. + source_handle : int + Handle of the sender process. + + Returns + ------- + int + ``STATUS_OK`` on success, ``STATUS_BUSY`` when queue is full. + """ + with target.event_condition: + if len(target.pending_signals) >= MAX_PENDING_SIGNALS: + return STATUS_BUSY + target.pending_signals.append((signal, source_handle)) + target.event_condition.notify_all() + self.logger.debug( + "Queued signal=%d source=%d for label=%d handle=%d", + signal, + source_handle, + target.label, + target.handle, + ) + return STATUS_OK + + def queue_ipc(self, target: AppContext, source_handle: int, payload: bytes) -> PendingIPC: + """Queue an IPC event for one application context. + + Parameters + ---------- + target : AppContext + Destination app context receiving the IPC payload. + source_handle : int + Handle of the sender process. + payload : bytes + Sender payload copied from exchange memory. + + Returns + ------- + PendingIPC + In-flight IPC transfer token used by sender to wait for completion. + """ + pending = PendingIPC(source_handle=source_handle, payload=bytes(payload)) + with target.event_condition: + target.pending_ipcs.append(pending) + target.event_condition.notify_all() + self.logger.debug( + "Queued IPC source=%d for label=%d handle=%d payload_len=%d", + source_handle, + target.label, + target.handle, + len(payload), + ) + return pending + + @staticmethod + def complete_ipc(pending_ipc: PendingIPC, status: int = STATUS_OK) -> None: + """Resolve a pending IPC transfer with final sender status.""" + pending_ipc.completion_status = status + pending_ipc.done.set() + + def _alarm_fire(self, label: int, delay_ms: int) -> None: + """Deliver one alarm tick to the context identified by label. + + Parameters + ---------- + label : int + Target application label. + delay_ms : int + Alarm delay key used for logging context. + """ + with self._lock: + target = self._contexts_by_label.get(label) + if target is None: + return + status = self.queue_signal(target, SIGNAL_ALARM, target.handle) + if status != STATUS_OK: + self.logger.warning( + "Alarm delivery failed label=%d delay_ms=%d status=%d", + label, + delay_ms, + status, + ) + + def _schedule_alarm(self, app_context: AppContext, delay_ms: int, periodic: bool) -> int: + """Schedule a one-shot or periodic alarm for one context. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + delay_ms : int + Alarm delay in milliseconds. + periodic : bool + Whether the alarm should re-arm itself after firing. + + Returns + ------- + int + ``STATUS_OK`` when scheduled, ``STATUS_BUSY`` on duplicate delay. + """ + with self._lock: + if delay_ms in app_context.alarms: + return STATUS_BUSY + + interval_s = max(0.0, delay_ms / 1000.0) + + def _callback() -> None: + self._alarm_fire(app_context.label, delay_ms) + if not periodic: + with self._lock: + app_context.alarms.pop(delay_ms, None) + return + + with self._lock: + still_registered = app_context.alarms.get(delay_ms) + if still_registered is None: + return + + next_timer = threading.Timer(interval_s, _callback) + app_context.alarms[delay_ms] = AlarmRegistration( + delay_ms=delay_ms, + periodic=True, + timer=next_timer, + ) + next_timer.start() + + timer = threading.Timer(interval_s, _callback) + app_context.alarms[delay_ms] = AlarmRegistration( + delay_ms=delay_ms, + periodic=periodic, + timer=timer, + ) + timer.start() + self.logger.debug( + "Scheduled alarm label=%d handle=%d delay_ms=%d periodic=%s", + app_context.label, + app_context.handle, + delay_ms, + periodic, + ) + return STATUS_OK + + def _stop_alarm(self, app_context: AppContext, delay_ms: int) -> int: + """Stop one previously scheduled alarm. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + delay_ms : int + Alarm delay key identifying the registration. + + Returns + ------- + int + ``STATUS_OK`` regardless of prior registration presence. + """ + with self._lock: + registration = app_context.alarms.pop(delay_ms, None) + if registration is None: + self.logger.debug( + "Alarm already stopped label=%d handle=%d delay_ms=%d", + app_context.label, + app_context.handle, + delay_ms, + ) + return STATUS_OK + registration.timer.cancel() + self.logger.debug( + "Stopped alarm label=%d handle=%d delay_ms=%d", + app_context.label, + app_context.handle, + delay_ms, + ) + return STATUS_OK + + def current_cycle_value(self, precision: int) -> int: + """Return monotonic elapsed time encoded for requested precision. + + Parameters + ---------- + precision : int + Precision code from ``PRECISION_*`` constants. + + Returns + ------- + int + Elapsed monotonic time converted to the requested unit. + + Raises + ------ + ValueError + If precision is not supported by the emulator API. + """ + elapsed_ns = max(0, time.monotonic_ns() - self._start_monotonic_ns) + if precision == PRECISION_CYCLE: + return elapsed_ns + if precision == PRECISION_NANOSECONDS: + return elapsed_ns + if precision == PRECISION_MICROSECONDS: + return elapsed_ns // 1_000 + if precision == PRECISION_MILLISECONDS: + return elapsed_ns // 1_000_000 + raise ValueError("invalid precision") + + def _has_matching_signal(self, app_context: AppContext, mask: int) -> bool: + """Return whether one matching signal is immediately available.""" + return bool(mask & EVENT_TYPE_SIGNAL) and bool(app_context.pending_signals) + + def _has_matching_ipc(self, app_context: AppContext, mask: int) -> bool: + """Return whether one matching IPC event is immediately available.""" + return bool(mask & EVENT_TYPE_IPC) and bool(app_context.pending_ipcs) + + def _dequeue_matching_signal( + self, app_context: AppContext, mask: int + ) -> tuple[int, int] | None: + """Pop one pending signal if the provided mask accepts signal events. + + Parameters + ---------- + app_context : AppContext + Source app runtime context. + mask : int + Bitmask of accepted event types. + + Returns + ------- + tuple[int, int] | None + ``(signal, source_handle)`` when available, otherwise ``None``. + """ + if not (mask & EVENT_TYPE_SIGNAL): + return None + + with app_context.event_condition: + if not app_context.pending_signals: + return None + return app_context.pending_signals.pop(0) + + def _dequeue_matching_ipc(self, app_context: AppContext, mask: int) -> PendingIPC | None: + """Pop one pending IPC if the provided mask accepts IPC events.""" + if not (mask & EVENT_TYPE_IPC): + return None + + with app_context.event_condition: + if not app_context.pending_ipcs: + return None + return app_context.pending_ipcs.pop(0) + + def _serialize_signal_event( + self, app_context: AppContext, signal: int, source_handle: int + ) -> None: + """Serialize one signal event into the app exchange buffer. + + Parameters + ---------- + app_context : AppContext + Target app runtime context. + signal : int + Signal value to encode in payload. + source_handle : int + Handle of the sender process. + """ + header = bytes([EVENT_TYPE_SIGNAL, 4]) + EVENT_MAGIC.to_bytes( + 2, "little" + ) + int(source_handle).to_bytes(4, "little", signed=False) + payload = int(signal).to_bytes(4, "little", signed=False) + self.write_exchange_buffer(app_context, header + payload) + + def _serialize_ipc_event( + self, app_context: AppContext, payload: bytes, source_handle: int + ) -> None: + """Serialize one IPC event into the app exchange buffer.""" + header = bytes([EVENT_TYPE_IPC, len(payload)]) + EVENT_MAGIC.to_bytes( + 2, "little" + ) + int(source_handle).to_bytes(4, "little", signed=False) + self.write_exchange_buffer(app_context, header + payload) + + @property + def bound_address(self) -> tuple[str, int]: + """Return bound host and port for the running gRPC server. + + Returns + ------- + tuple[str, int] + Bound ``(host, port)`` pair. + + Raises + ------ + RuntimeError + If server binding has not happened yet. + """ + if self._bound_address is None: + raise RuntimeError("daemon is not bound yet") + return self._bound_address + + def serve_forever( + self, + stop_event: threading.Event | None = None, + ready_event: threading.Event | None = None, + poll_interval: float = 0.2, + ) -> None: + """Run the gRPC server loop until stop criteria are met. + + Parameters + ---------- + stop_event : threading.Event | None, optional + External stop signal, by default a fresh event local to this call. + ready_event : threading.Event | None, optional + Event set once the server is bound and started. + poll_interval : float, optional + Delay in seconds between stop-condition polls. + + Raises + ------ + RuntimeError + If gRPC binding fails or startup app preparation fails. + """ + event = stop_event if stop_event is not None else threading.Event() + + grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) + from .servicer import EmulatorServicer + + emulator_pb2_grpc.add_EmulatorServicer_to_server( + EmulatorServicer(daemon=self, store=self.store, logger=self.logger), grpc_server + ) # type: ignore[no-untyped-call] + + bound_port = grpc_server.add_insecure_port(f"{self.host}:{self.port}") + if bound_port == 0: + self.logger.error("Cannot bind gRPC server on %s:%d", self.host, self.port) + raise RuntimeError(f"cannot bind gRPC server on {self.host}:{self.port}") + + self._bound_address = (self.host, int(bound_port)) + grpc_server.start() + + self._startup_apps() + + if ready_event is not None: + ready_event.set() + + self.logger.info( + "Sentry emulator listening on grpc://%s:%d", + self._bound_address[0], + self._bound_address[1], + ) + + try: + while True: + if event.wait(timeout=poll_interval): + break + if self._all_startup_contexts_stopped(): + self.logger.info( + "All startup tasks have terminated, stopping emulator daemon" + ) + break + finally: + grpc_server.stop(grace=0).wait() + self._terminate_started_apps() + self.logger.info("Sentry emulator stopped") diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py new file mode 100644 index 00000000..5aa49b4c --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/models.py @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Core data models for emulator startup and app runtime contexts.""" + +from dataclasses import dataclass, field +from pathlib import Path +import subprocess +import threading + +from .constants import EXCHANGE_BUFFER_LEN, UINT32_MAX + + +@dataclass(slots=True) +class AlarmRegistration: + """Bookkeeping for one active alarm registration. + + Attributes + ---------- + delay_ms : int + Alarm delay in milliseconds used as identifier in app context maps. + periodic : bool + Whether the alarm is automatically re-scheduled after firing. + timer : threading.Timer + Live timer object that triggers the alarm callback. + """ + + delay_ms: int + periodic: bool + timer: threading.Timer + + +@dataclass(frozen=True, slots=True) +class StartSpec: + """Definition of one application to start with the daemon. + + Attributes + ---------- + app_path : Path + Executable path launched during daemon startup. + label : int + Application label advertised to the app runtime via environment. + """ + + app_path: Path + label: int + + +@dataclass(slots=True) +class PendingIPC: + """One in-flight IPC transfer waiting to be consumed by target. + + Attributes + ---------- + source_handle : int + Sender process handle. + payload : bytes + Copied payload emitted by sender from its exchange buffer. + completion_status : int | None + Final status returned to sender once delivery is resolved. + done : threading.Event + Event set when the transfer is consumed or aborted. + """ + + source_handle: int + payload: bytes + completion_status: int | None = None + done: threading.Event = field(default_factory=threading.Event) + + +@dataclass(slots=True) +class AppContext: + """Runtime context associated with one started application. + + Attributes + ---------- + label : int + Application label used by syscall requests. + handle : int + Stable process handle exposed by ``get_process_handle``. + app_path : Path + Executable path associated with this runtime context. + process : subprocess.Popen[bytes] | None + Child process instance once started, otherwise ``None``. + exchange_buffer : bytearray + Shared 128-byte payload area used by exchange syscalls. + pending_signals : list[tuple[int, int]] + FIFO queue of ``(signal, source_handle)`` waiting to be consumed. + pending_ipcs : list[PendingIPC] + FIFO queue of IPC transfers waiting to be consumed. + alarms : dict[int, AlarmRegistration] + Registered alarms keyed by delay in milliseconds. + event_condition : threading.Condition + Condition variable used to wake waiters when new events arrive. + exit_code : int | None + Exit code captured when the context is deactivated. + """ + + label: int + handle: int + app_path: Path + process: subprocess.Popen[bytes] | None = None + exchange_buffer: bytearray = field( + default_factory=lambda: bytearray(EXCHANGE_BUFFER_LEN) + ) + pending_signals: list[tuple[int, int]] = field(default_factory=list) + pending_ipcs: list[PendingIPC] = field(default_factory=list) + alarms: dict[int, AlarmRegistration] = field(default_factory=dict) + event_condition: threading.Condition = field(default_factory=threading.Condition) + exit_code: int | None = None + + @property + def app_name(self) -> str: + """Return a stable display name for daemon-emitted app logs. + + Returns + ------- + str + Stem of ``app_path`` when available, otherwise full filename. + """ + return self.app_path.stem or self.app_path.name + + +def parse_start_option(value: str) -> StartSpec: + """Parse one ``--start`` argument value (``APP_PATH,label=``). + + Parameters + ---------- + value : str + Raw CLI value passed to ``--start``. + + Returns + ------- + StartSpec + Validated startup specification used by the daemon. + + Raises + ------ + ValueError + If format is invalid, app path is empty, or label is outside ``u32``. + """ + app_part, sep, label_part = value.partition(",") + if sep == "": + raise ValueError("--start expects 'app.elf,label='") + + app = app_part.strip() + if not app: + raise ValueError("--start application path cannot be empty") + + key, eq, raw_label = label_part.partition("=") + if eq == "" or key.strip() != "label": + raise ValueError("--start expects 'label='") + + try: + label = int(raw_label.strip(), 0) + except ValueError as exc: + raise ValueError("label must be an integer in [0, 4294967295]") from exc + + if label < 0 or label > UINT32_MAX: + raise ValueError("label must be an integer in [0, 4294967295]") + + return StartSpec(app_path=Path(app), label=label) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py new file mode 100644 index 00000000..99e87845 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/emulator_server/servicer.py @@ -0,0 +1,340 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""gRPC dispatch servicer for the emulator daemon.""" + +from dataclasses import dataclass +import logging +import time +from typing import Any + +import grpc + +from ..dispatcher import SyscallStore +from ..grpc import emulator_pb2, emulator_pb2_grpc +from ..protocol import ProtocolError, deserialize_request +from .constants import ( + EXCHANGE_BUFFER_LEN, + PRECISION_CYCLE, + PRECISION_MILLISECONDS, + SIGNAL_ABORT, + SIGNAL_USR2, + STATUS_AGAIN, + STATUS_INTR, + STATUS_INVALID, + STATUS_OK, + STATUS_TIMEOUT, +) + +IPC_EVENT_HEADER_LEN = 8 +MAX_IPC_PAYLOAD_LEN = EXCHANGE_BUFFER_LEN - IPC_EVENT_HEADER_LEN + + +@dataclass(slots=True) +class EmulatorServicer(emulator_pb2_grpc.EmulatorServicer): + """Internal gRPC service implementation for syscall dispatch. + + Attributes + ---------- + daemon : Any + Runtime daemon object handling app contexts and buffer operations. + store : SyscallStore + In-memory syscall bookkeeping used by tests and diagnostics. + logger : logging.Logger + Logger used for request-level tracing and warnings. + """ + + daemon: Any + store: SyscallStore + logger: logging.Logger + + def Dispatch(self, request: Any, context: grpc.ServicerContext) -> Any: + """Handle one emulator syscall request. + + Parameters + ---------- + request : Any + gRPC ``DispatchRequest`` protobuf payload. + context : grpc.ServicerContext + gRPC request context used for peer metadata and status reporting. + + Returns + ------- + Any + ``DispatchResponse`` protobuf message carrying status, detail, and + optional payload. + """ + response_cls = getattr(emulator_pb2, "DispatchResponse") + self.logger.debug( + "Received request peer=%s syscall=%s args=%s label=%s payload_len=%d", + context.peer(), + getattr(request, "syscall", ""), + list(getattr(request, "args", [])), + getattr(request, "label", ""), + len(getattr(request, "payload", b"")), + ) + + try: + message = deserialize_request(request) + except ProtocolError as exc: + self.store.register_invalid() + self.logger.warning("Rejected command from %s: %s", context.peer(), exc) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(str(exc)) + return response_cls(status=1, detail=str(exc)) + + app_context = self.daemon.context_for_label(message.label) + if app_context is None: + self.store.register_invalid() + detail = f"unknown app label: {message.label}" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.NOT_FOUND) + context.set_details(detail) + return response_cls(status=1, detail=detail) + + if message.syscall == "exchange_to_kernel": + self.daemon.write_exchange_buffer(app_context, message.payload) + self.logger.debug( + "Responding syscall=%s label=%d status=0", + message.syscall, + message.label, + ) + return response_cls(status=0, detail="ok") + + if message.syscall == "exchange_from_kernel": + payload = self.daemon.read_exchange_buffer(app_context) + self.logger.debug( + "Responding syscall=%s label=%d status=0 payload_len=%d", + message.syscall, + message.label, + len(payload), + ) + return response_cls(status=0, detail="ok", payload=payload) + + if message.syscall == "log": + log_len = int(message.args[0]) if message.args else 0 + log_len = max(0, min(log_len, EXCHANGE_BUFFER_LEN)) + raw = bytes(app_context.exchange_buffer[:log_len]) + text = raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + print(f"[{app_context.app_name}:{message.label}] {text}", flush=True) + self.logger.debug( + "Responding syscall=%s label=%d status=0 printed_len=%d", + message.syscall, + message.label, + len(text), + ) + return response_cls(status=0, detail="ok") + + if message.syscall == "get_process_handle": + if not message.args: + detail = "missing label argument" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_label = int(message.args[0]) + target_context = self.daemon.context_for_label(target_label) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown label") + + self.daemon.write_u32_to_exchange_buffer(app_context, target_context.handle) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "send_signal": + if len(message.args) < 2: + detail = "missing send_signal arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_handle = int(message.args[0]) + signal = int(message.args[1]) + if target_handle == app_context.handle: + return response_cls(status=STATUS_INVALID, detail="self signal forbidden") + if signal < SIGNAL_ABORT or signal > SIGNAL_USR2: + return response_cls(status=STATUS_INVALID, detail="invalid signal") + + target_context = self.daemon.context_for_handle(target_handle) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown target handle") + + status = self.daemon.queue_signal(target_context, signal, app_context.handle) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + + if message.syscall == "send_ipc": + if len(message.args) < 2: + detail = "missing send_ipc arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + target_handle = int(message.args[0]) + ipc_len = int(message.args[1]) + if target_handle == app_context.handle: + return response_cls(status=STATUS_INVALID, detail="self ipc forbidden") + if ipc_len < 0 or ipc_len > MAX_IPC_PAYLOAD_LEN: + return response_cls(status=STATUS_INVALID, detail="invalid ipc length") + + target_context = self.daemon.context_for_handle(target_handle) + if target_context is None: + return response_cls(status=STATUS_INVALID, detail="unknown target handle") + + payload = self.daemon.read_exchange_buffer(app_context)[:ipc_len] + pending_ipc = self.daemon.queue_ipc(target_context, app_context.handle, payload) + pending_ipc.done.wait() + + final_status = pending_ipc.completion_status + if final_status is None: + final_status = STATUS_INTR + return response_cls( + status=final_status, + detail="ok" if final_status == STATUS_OK else "interrupted", + ) + + if message.syscall == "wait_for_event": + if len(message.args) < 2: + detail = "missing wait_for_event arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + mask = int(message.args[0]) + timeout = int(message.args[1]) + + pending_signal = self.daemon._dequeue_matching_signal(app_context, mask) + if pending_signal is not None: + signal, source_handle = pending_signal + self.daemon._serialize_signal_event(app_context, signal, source_handle) + return response_cls(status=STATUS_OK, detail="ok") + + pending_ipc = self.daemon._dequeue_matching_ipc(app_context, mask) + if pending_ipc is not None: + self.daemon._serialize_ipc_event( + app_context, + pending_ipc.payload, + pending_ipc.source_handle, + ) + self.daemon.complete_ipc(pending_ipc) + return response_cls(status=STATUS_OK, detail="ok") + + if timeout == -1: + return response_cls(status=STATUS_AGAIN, detail="again") + + wait_timeout = None if timeout == 0 else max(0.0, timeout / 1000.0) + with app_context.event_condition: + has_event = app_context.event_condition.wait_for( + lambda: self.daemon._has_matching_signal(app_context, mask) + or self.daemon._has_matching_ipc(app_context, mask), + timeout=wait_timeout, + ) + if not has_event: + return response_cls(status=STATUS_TIMEOUT, detail="timeout") + + signal: int | None = None + source_handle: int | None = None + pending_ipc = None + if self.daemon._has_matching_signal(app_context, mask): + signal, source_handle = app_context.pending_signals.pop(0) + elif self.daemon._has_matching_ipc(app_context, mask): + pending_ipc = app_context.pending_ipcs.pop(0) + + if signal is not None and source_handle is not None: + self.daemon._serialize_signal_event(app_context, signal, source_handle) + return response_cls(status=STATUS_OK, detail="ok") + if pending_ipc is not None: + self.daemon._serialize_ipc_event( + app_context, + pending_ipc.payload, + pending_ipc.source_handle, + ) + self.daemon.complete_ipc(pending_ipc) + return response_cls(status=STATUS_OK, detail="ok") + return response_cls(status=STATUS_TIMEOUT, detail="timeout") + + if message.syscall == "sleep": + if len(message.args) < 2: + detail = "missing sleep arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + duration_ms = int(message.args[0]) + if duration_ms < 0: + return response_cls(status=STATUS_INVALID, detail="invalid sleep duration") + + # In emulator mode, sleep completion is controlled by daemon-side timing. + # The RPC returns only when the requested duration has elapsed. + time.sleep(duration_ms / 1000.0) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "alarm": + if len(message.args) < 2: + detail = "missing alarm arguments" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + delay_ms = max(0, int(message.args[0])) + flag = int(message.args[1]) + if flag == 0: + status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=False) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if flag == 1: + status = self.daemon._schedule_alarm(app_context, delay_ms, periodic=True) + return response_cls(status=status, detail="ok" if status == STATUS_OK else "busy") + if flag == 2: + status = self.daemon._stop_alarm(app_context, delay_ms) + return response_cls( + status=status, + detail="ok" if status == STATUS_OK else "no entity", + ) + return response_cls(status=STATUS_INVALID, detail="invalid alarm flag") + + if message.syscall == "get_random": + value = self.daemon._rng.getrandbits(32) + self.daemon.write_u32_to_exchange_buffer(app_context, value) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "get_cycle": + if not message.args: + detail = "missing precision argument" + self.logger.warning("Rejected command from %s: %s", context.peer(), detail) + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details(detail) + return response_cls(status=STATUS_INVALID, detail=detail) + + precision = int(message.args[0]) + if precision < PRECISION_CYCLE or precision > PRECISION_MILLISECONDS: + return response_cls(status=STATUS_INVALID, detail="invalid precision") + + cycle_value = self.daemon.current_cycle_value(precision) + self.daemon.write_u64_to_exchange_buffer(app_context, cycle_value) + return response_cls(status=STATUS_OK, detail="ok") + + if message.syscall == "exit": + exit_code = int(message.args[0]) if message.args else 0 + all_done = self.daemon.deactivate_context(app_context, exit_code) + detail = "all tasks exited" if all_done else "context deactivated" + return response_cls(status=STATUS_OK, detail=detail) + + self.store.register(message) + self.logger.info( + "grpc command label=%d handle=%d syscall=%s args=%s peer=%s", + message.label, + app_context.handle, + message.syscall, + list(message.args), + context.peer(), + ) + self.logger.debug( + "Responding syscall=%s label=%d status=0", + message.syscall, + message.label, + ) + return response_cls(status=0, detail="ok") diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py new file mode 100644 index 00000000..66066271 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py new file mode 100644 index 00000000..c4712104 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: emulator.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'emulator.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x65mulator.proto\x12\x17\x63\x61melot.sentry.emulator\"P\n\x0f\x44ispatchRequest\x12\x0f\n\x07syscall\x18\x01 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x02 \x03(\x12\x12\r\n\x05label\x18\x03 \x01(\r\x12\x0f\n\x07payload\x18\x04 \x01(\x0c\"C\n\x10\x44ispatchResponse\x12\x0e\n\x06status\x18\x01 \x01(\x11\x12\x0e\n\x06\x64\x65tail\x18\x02 \x01(\t\x12\x0f\n\x07payload\x18\x03 \x01(\x0c\x32k\n\x08\x45mulator\x12_\n\x08\x44ispatch\x12(.camelot.sentry.emulator.DispatchRequest\x1a).camelot.sentry.emulator.DispatchResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulator_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_DISPATCHREQUEST']._serialized_start=43 + _globals['_DISPATCHREQUEST']._serialized_end=123 + _globals['_DISPATCHRESPONSE']._serialized_start=125 + _globals['_DISPATCHRESPONSE']._serialized_end=192 + _globals['_EMULATOR']._serialized_start=194 + _globals['_EMULATOR']._serialized_end=301 +# @@protoc_insertion_point(module_scope) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py new file mode 100644 index 00000000..de43cde3 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/grpc/emulator_pb2_grpc.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from . import emulator_pb2 as emulator__pb2 + +GRPC_GENERATED_VERSION = '1.80.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in emulator_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class EmulatorStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Dispatch = channel.unary_unary( + '/camelot.sentry.emulator.Emulator/Dispatch', + request_serializer=emulator__pb2.DispatchRequest.SerializeToString, + response_deserializer=emulator__pb2.DispatchResponse.FromString, + _registered_method=True) + + +class EmulatorServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Dispatch(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_EmulatorServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Dispatch': grpc.unary_unary_rpc_method_handler( + servicer.Dispatch, + request_deserializer=emulator__pb2.DispatchRequest.FromString, + response_serializer=emulator__pb2.DispatchResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'camelot.sentry.emulator.Emulator', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('camelot.sentry.emulator.Emulator', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class Emulator(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Dispatch(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/camelot.sentry.emulator.Emulator/Dispatch', + emulator__pb2.DispatchRequest.SerializeToString, + emulator__pb2.DispatchResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py new file mode 100644 index 00000000..9813d0e4 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/protocol.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Protocol decoding and validation utilities for emulator gRPC messages.""" + +from dataclasses import dataclass +from typing import Any + +from .grpc import emulator_pb2 + + +class ProtocolError(ValueError): + """Error raised when a request does not follow emulator protocol rules.""" + + +@dataclass(frozen=True, slots=True) +class SyscallMessage: + """Normalized syscall request extracted from gRPC payload. + + Attributes + ---------- + syscall : str + Name of the syscall to emulate. + args : tuple[int, ...] + Positional syscall arguments as signed integers. + label : int + Caller application label (expected to fit in ``u32``). + payload : bytes + Raw exchange payload transported with request. + """ + + syscall: str + args: tuple[int, ...] + label: int + payload: bytes + + +def deserialize_request(request: Any) -> SyscallMessage: + """Convert a gRPC ``DispatchRequest`` into a validated syscall message. + + Parameters + ---------- + request : Any + Incoming protobuf request object. It must expose ``syscall``, ``args`` + and ``label`` attributes. + + Returns + ------- + SyscallMessage + Immutable normalized representation used by the daemon core. + + Raises + ------ + ProtocolError + If ``syscall`` is empty or contains only whitespace. + """ + syscall = request.syscall.strip() + if not syscall: + raise ProtocolError("'syscall' must be a non-empty string") + + return SyscallMessage( + syscall=syscall, + args=tuple(int(arg) for arg in request.args), + label=int(request.label), + payload=bytes(request.payload), + ) diff --git a/tools/sentry-emulator/src/camelot/sentry_emulator/server.py b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py new file mode 100644 index 00000000..567c17e0 --- /dev/null +++ b/tools/sentry-emulator/src/camelot/sentry_emulator/server.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Backward-compatible public server API for the emulator. + +The implementation is split into ``camelot.sentry_emulator.emulator_server`` +submodules to keep concerns isolated and source files manageable. +""" + +from .emulator_server import ( + DEFAULT_HOST, + DEFAULT_PORT, + EXCHANGE_BUFFER_LEN, + GrpcEmulatorDaemon, + StartSpec, + parse_start_option, +) + +__all__ = [ + "DEFAULT_HOST", + "DEFAULT_PORT", + "EXCHANGE_BUFFER_LEN", + "GrpcEmulatorDaemon", + "StartSpec", + "parse_start_option", +] diff --git a/tools/sentry-emulator/tests/conftest.py b/tools/sentry-emulator/tests/conftest.py new file mode 100644 index 00000000..8c6bde51 --- /dev/null +++ b/tools/sentry-emulator/tests/conftest.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Pytest bootstrap for local source-tree imports during tests.""" + +import pathlib +import sys + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / "src" + +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) diff --git a/tools/sentry-emulator/tests/test_cli.py b/tools/sentry-emulator/tests/test_cli.py new file mode 100644 index 00000000..02e0acf0 --- /dev/null +++ b/tools/sentry-emulator/tests/test_cli.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""CLI behavior tests for argument validation and error reporting.""" + +import logging +import sys + +from camelot.sentry_emulator import cli + + +def test_parser_accepts_multiple_start_arguments() -> None: + """Repeated --start options should be collected in order.""" + parser = cli._build_parser() + + args = parser.parse_args( + [ + "--start", + "sample-app-one,label=7", + "--start", + "sample-app-two,label=8", + ] + ) + + assert args.start == [ + "sample-app-one,label=7", + "sample-app-two,label=8", + ] + + +def test_main_returns_non_zero_and_logs_error_on_invalid_port( + monkeypatch, caplog +) -> None: + """Invalid port should produce ERROR log and non-zero return code.""" + monkeypatch.setattr( + sys, + "argv", + ["sentry-emulator", "--log-level", "ERROR", "--port", "70000"], + ) + + caplog.set_level(logging.ERROR, logger="camelot.sentry_emulator") + rc = cli.main() + + assert rc != 0 + assert any( + rec.levelno == logging.ERROR and "Invalid --port value" in rec.getMessage() + for rec in caplog.records + ) + + +def test_main_returns_non_zero_and_logs_error_on_invalid_start_arg( + monkeypatch, caplog +) -> None: + """Malformed --start should produce ERROR log and non-zero return code.""" + monkeypatch.setattr( + sys, + "argv", + [ + "sentry-emulator", + "--log-level", + "ERROR", + "--start", + "sample-app-without-label", + ], + ) + + caplog.set_level(logging.ERROR, logger="camelot.sentry_emulator") + rc = cli.main() + + assert rc != 0 + assert any( + rec.levelno == logging.ERROR and "Invalid startup argument" in rec.getMessage() + for rec in caplog.records + ) diff --git a/tools/sentry-emulator/tests/test_emulator.py b/tools/sentry-emulator/tests/test_emulator.py new file mode 100644 index 00000000..4787b7af --- /dev/null +++ b/tools/sentry-emulator/tests/test_emulator.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end emulator tests using the Rust sample applications.""" + +import os +import pathlib +import subprocess +import sys + +import pytest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = PROJECT_ROOT.parents[1] +SAMPLE_RUST_BIN_DIR = ( + REPO_ROOT / "builddir" / "tools" / "sentry-emulator" / "sample-rust-target" / "debug" +) + + +@pytest.mark.emulator +def test_cli_starts_sample_rust_apps_via_start() -> None: + app_one = SAMPLE_RUST_BIN_DIR / "sample-app-one" + app_two = SAMPLE_RUST_BIN_DIR / "sample-app-two" + + missing_binaries = [str(path) for path in (app_one, app_two) if not path.exists()] + if missing_binaries: + pytest.skip( + "sample Rust apps are not built in builddir: " + ", ".join(missing_binaries) + ) + + child_env = os.environ.copy() + pythonpath_entries = [str(PROJECT_ROOT / "src")] + existing_pythonpath = child_env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + child_env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + + completed = subprocess.run( + [ + sys.executable, + "-m", + "camelot.sentry_emulator", + "--log-level", + "INFO", + "--start", + f"{app_one},label=7", + "--start", + f"{app_two},label=8", + ], + cwd=PROJECT_ROOT, + env=child_env, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert completed.returncode == 0, ( + f"stdout:\n{completed.stdout}\n\nstderr:\n{completed.stderr}" + ) + assert "Started app label=7" in completed.stderr + assert "Started app label=8" in completed.stderr + assert "App exited label=7" in completed.stderr + assert "App exited label=8" in completed.stderr + assert "All startup tasks have terminated, stopping emulator daemon" in completed.stderr + assert "Sentry emulator stopped" in completed.stderr + + # IPC path must be exercised by both sample applications. + assert "[sample-app-one:7] send_ipc(peer): Ok" in completed.stdout + assert "[sample-app-two:8] wait_for_event(ipc, no timeout): Ok" in completed.stdout + assert "[sample-app-two:8] ipc event received" in completed.stdout diff --git a/tools/sentry-emulator/tests/test_protocol.py b/tools/sentry-emulator/tests/test_protocol.py new file mode 100644 index 00000000..a71380dd --- /dev/null +++ b/tools/sentry-emulator/tests/test_protocol.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Protocol decoding tests for emulator request deserialization.""" + + +import pathlib +import sys + +import pytest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from camelot.sentry_emulator.grpc import emulator_pb2 +from camelot.sentry_emulator.protocol import ProtocolError, deserialize_request + + +def test_deserialize_request_ok() -> None: + """Deserialize a valid request into normalized immutable fields.""" + message = deserialize_request( + emulator_pb2.DispatchRequest( + syscall="map_dev", args=[1, 2, 3], label=17, payload=b"abc" + ) + ) + + assert message.syscall == "map_dev" + assert message.args == (1, 2, 3) + assert message.label == 17 + assert message.payload == b"abc" + + +def test_deserialize_request_rejects_empty_syscall() -> None: + """Reject requests where syscall name is empty after trimming.""" + with pytest.raises(ProtocolError): + deserialize_request(emulator_pb2.DispatchRequest(syscall=" ", args=[12], label=1)) diff --git a/tools/sentry-emulator/tests/test_server.py b/tools/sentry-emulator/tests/test_server.py new file mode 100644 index 00000000..d3408ed6 --- /dev/null +++ b/tools/sentry-emulator/tests/test_server.py @@ -0,0 +1,489 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +"""Integration tests for emulator server lifecycle and syscalls.""" + + +import pathlib +import importlib +import threading +import time + +import grpc +import pytest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] + +emulator_pb2 = importlib.import_module( + "camelot.sentry_emulator.grpc.emulator_pb2" +) +emulator_pb2_grpc = importlib.import_module( + "camelot.sentry_emulator.grpc.emulator_pb2_grpc" +) +GrpcEmulatorDaemon = importlib.import_module( + "camelot.sentry_emulator.server" +).GrpcEmulatorDaemon +StartSpec = importlib.import_module("camelot.sentry_emulator.server").StartSpec +parse_start_option = importlib.import_module("camelot.sentry_emulator.server").parse_start_option + + + +def test_parse_start_option_ok() -> None: + """Accept a well-formed startup spec with numeric label.""" + spec = parse_start_option("./app.elf,label=123") + + assert str(spec.app_path) == "app.elf" + assert spec.label == 123 + + +def test_parse_start_option_rejects_bad_label() -> None: + """Reject startup labels that exceed unsigned 32-bit bounds.""" + with pytest.raises(ValueError): + parse_start_option("./app.elf,label=50000000000") + + +def test_grpc_server_receives_and_sorts_messages( + tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Exercise end-to-end syscall flow and in-memory accounting.""" + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[10], label=7)) + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[11], label=7)) + stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="gpio_set", args=[12, 3, 1], label=7) + ) + + with pytest.raises(grpc.RpcError): + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="", args=[1], label=7)) + + with pytest.raises(grpc.RpcError): + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[1], label=99)) + + stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="exchange_to_kernel", + label=7, + payload=b"hello from task", + ) + ) + exchange_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + assert exchange_reply.payload.startswith(b"hello from task") + + stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="log", args=[15], label=7) + ) + + proc_self = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[7], label=7) + ) + assert proc_self.status == 0 + self_handle_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + self_handle = int.from_bytes(self_handle_reply.payload[:4], "little") + assert self_handle > 0 + + proc_other = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[8], label=7) + ) + assert proc_other.status == 0 + other_handle_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + other_handle = int.from_bytes(other_handle_reply.payload[:4], "little") + assert other_handle > 0 + assert other_handle != self_handle + + wait_non_blocking = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="wait_for_event", args=[2, -1], label=8) + ) + assert wait_non_blocking.status == 8 + + wait_timeout = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="wait_for_event", args=[2, 25], label=8) + ) + assert wait_timeout.status == 7 + + send_sig = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="send_signal", args=[other_handle, 11], label=7) + ) + assert send_sig.status == 0 + + wait_result: dict[str, object] = {} + + def wait_for_signal() -> None: + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as wait_channel: + wait_stub = emulator_pb2_grpc.EmulatorStub(wait_channel) + wait_result["response"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="wait_for_event", + args=[2, 1000], + label=8, + ) + ) + wait_result["exchange"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=8) + ) + + waiter = threading.Thread(target=wait_for_signal, daemon=True) + waiter.start() + time.sleep(0.05) + + send_sig_wait = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="send_signal", args=[other_handle, 11], label=7) + ) + assert send_sig_wait.status == 0 + waiter.join(timeout=2.0) + assert not waiter.is_alive() + assert "response" in wait_result + assert "exchange" in wait_result + assert wait_result["response"].status == 0 + event_payload = wait_result["exchange"].payload + assert event_payload[0] == 2 + assert int.from_bytes(event_payload[2:4], "little") == 0x4242 + assert int.from_bytes(event_payload[4:8], "little") == self_handle + assert int.from_bytes(event_payload[8:12], "little") == 11 + + alarm_start = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="alarm", args=[50, 1], label=7) + ) + assert alarm_start.status == 0 + alarm_stop = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="alarm", args=[50, 2], label=7) + ) + assert alarm_stop.status == 0 + + rnd = stub.Dispatch(emulator_pb2.DispatchRequest(syscall="get_random", label=7)) + assert rnd.status == 0 + rnd_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + assert len(rnd_reply.payload) >= 4 + + cyc = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_cycle", args=[3], label=7) + ) + assert cyc.status == 0 + cyc_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + assert len(cyc_reply.payload) >= 8 + + deadline = time.time() + 2.0 + while time.time() < deadline: + if daemon.store.count_for("map_dev") == 2 and daemon.store.count_for("gpio_set") == 1: + break + time.sleep(0.02) + + stop_event.set() + thread.join(timeout=2.0) + + assert daemon.store.count_for("map_dev") == 2 + assert daemon.store.count_for("gpio_set") == 1 + assert daemon.store.invalid_packets == 2 + + captured = capsys.readouterr() + assert "[dummy_app_a:7] hello from task" in captured.out + + +def test_exit_deactivates_context_and_stops_daemon(tmp_path: pathlib.Path) -> None: + """Ensure exit syscall removes contexts and stops daemon when all leave.""" + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + + exit_first = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exit", args=[0], label=7) + ) + assert exit_first.status == 0 + assert exit_first.detail == "context deactivated" + + with pytest.raises(grpc.RpcError): + stub.Dispatch(emulator_pb2.DispatchRequest(syscall="map_dev", args=[10], label=7)) + + exit_second = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exit", args=[0], label=8) + ) + assert exit_second.status == 0 + assert exit_second.detail == "all tasks exited" + + thread.join(timeout=2.0) + assert not thread.is_alive() + + +def test_wait_for_event_blocking_wakes_on_signal(tmp_path: pathlib.Path) -> None: + """Ensure blocking wait returns when a matching signal is queued.""" + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + + sender_self = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[7], label=7) + ) + assert sender_self.status == 0 + sender_self_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + sender_handle = int.from_bytes(sender_self_reply.payload[:4], "little") + assert sender_handle > 0 + + target_lookup = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[8], label=7) + ) + assert target_lookup.status == 0 + target_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + target_handle = int.from_bytes(target_reply.payload[:4], "little") + assert target_handle > 0 + assert target_handle != sender_handle + + wait_result: dict[str, object] = {} + + def wait_blocking() -> None: + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as wait_channel: + wait_stub = emulator_pb2_grpc.EmulatorStub(wait_channel) + wait_result["response"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="wait_for_event", + args=[2, 0], + label=8, + ) + ) + wait_result["exchange"] = wait_stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=8) + ) + + waiter = threading.Thread(target=wait_blocking, daemon=True) + waiter.start() + time.sleep(0.05) + assert waiter.is_alive() + + send_sig = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="send_signal", args=[target_handle, 11], label=7) + ) + assert send_sig.status == 0 + + waiter.join(timeout=2.0) + assert not waiter.is_alive() + assert "response" in wait_result + assert "exchange" in wait_result + + response = wait_result["response"] + exchange = wait_result["exchange"] + assert response.status == 0 + payload = exchange.payload + assert payload[0] == 2 + assert payload[1] == 4 + assert int.from_bytes(payload[2:4], "little") == 0x4242 + assert int.from_bytes(payload[4:8], "little") == sender_handle + assert int.from_bytes(payload[8:12], "little") == 11 + + stop_event.set() + thread.join(timeout=2.0) + assert not thread.is_alive() + + +def test_send_ipc_blocks_until_target_reads_event(tmp_path: pathlib.Path) -> None: + """Ensure send_ipc is blocking until target consumes IPC with wait_for_event.""" + app_path_a = tmp_path / "dummy_app_a.sh" + app_path_a.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_a.chmod(0o755) + + app_path_b = tmp_path / "dummy_app_b.sh" + app_path_b.write_text("#!/bin/sh\nwhile true; do sleep 1; done\n", encoding="utf-8") + app_path_b.chmod(0o755) + + daemon = GrpcEmulatorDaemon( + host="127.0.0.1", + port=0, + start_specs=( + StartSpec(app_path=app_path_a, label=7), + StartSpec(app_path=app_path_b, label=8), + ), + ) + stop_event = threading.Event() + ready_event = threading.Event() + + thread = threading.Thread( + target=daemon.serve_forever, + kwargs={"stop_event": stop_event, "ready_event": ready_event, "poll_interval": 0.05}, + daemon=True, + ) + thread.start() + + assert ready_event.wait(timeout=2.0) + + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as channel: + stub = emulator_pb2_grpc.EmulatorStub(channel) + + sender_self = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[7], label=7) + ) + assert sender_self.status == 0 + sender_self_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + sender_handle = int.from_bytes(sender_self_reply.payload[:4], "little") + assert sender_handle > 0 + + target_lookup = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="get_process_handle", args=[8], label=7) + ) + assert target_lookup.status == 0 + target_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=7) + ) + target_handle = int.from_bytes(target_reply.payload[:4], "little") + assert target_handle > 0 + + ipc_payload = b"hello-ipc-blocking" + to_kernel = stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="exchange_to_kernel", + label=7, + payload=ipc_payload, + ) + ) + assert to_kernel.status == 0 + + send_result: dict[str, object] = {} + + def send_ipc_blocking() -> None: + with grpc.insecure_channel( + f"{daemon.bound_address[0]}:{daemon.bound_address[1]}" + ) as send_channel: + send_stub = emulator_pb2_grpc.EmulatorStub(send_channel) + send_result["response"] = send_stub.Dispatch( + emulator_pb2.DispatchRequest( + syscall="send_ipc", + args=[target_handle, len(ipc_payload)], + label=7, + ) + ) + + sender = threading.Thread(target=send_ipc_blocking, daemon=True) + sender.start() + time.sleep(0.05) + assert sender.is_alive() + + wait_ipc = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="wait_for_event", args=[1, 1000], label=8) + ) + assert wait_ipc.status == 0 + event_reply = stub.Dispatch( + emulator_pb2.DispatchRequest(syscall="exchange_from_kernel", label=8) + ) + + sender.join(timeout=2.0) + assert not sender.is_alive() + assert "response" in send_result + assert send_result["response"].status == 0 + + payload = event_reply.payload + assert payload[0] == 1 + assert payload[1] == len(ipc_payload) + assert int.from_bytes(payload[2:4], "little") == 0x4242 + assert int.from_bytes(payload[4:8], "little") == sender_handle + assert payload[8 : 8 + len(ipc_payload)] == ipc_payload + + stop_event.set() + thread.join(timeout=2.0) + assert not thread.is_alive() diff --git a/tools/sentry-emulator/tox.ini b/tools/sentry-emulator/tox.ini new file mode 100644 index 00000000..cdfa1039 --- /dev/null +++ b/tools/sentry-emulator/tox.ini @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 H2Lab Development Team +# SPDX-License-Identifier: Apache-2.0 + +[tox] +envlist = py,mypy,build,emulator +isolated_build = true + +[testenv] +deps = + pytest>=8 + grpcio>=1.80 + protobuf>=6.31 +commands = + pytest + +[testenv:mypy] +deps = + mypy>=1.10 + types-grpcio + types-protobuf +commands = + mypy src + +[testenv:build] +deps = + build>=1.2 +commands = + python -m build --sdist --wheel + +[testenv:emulator] +deps = + pytest>=8 + grpcio>=1.80 + protobuf>=6.31 +commands = + pytest -m emulator diff --git a/uapi/Cargo.lock b/uapi/Cargo.lock index 4eb9be77..681e05dd 100644 --- a/uapi/Cargo.lock +++ b/uapi/Cargo.lock @@ -2,9 +2,891 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "sentry-uapi" version = "0.4.2" dependencies = [ + "prost", "sentry-uapi", + "tokio", + "tonic", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] diff --git a/uapi/Cargo.toml b/uapi/Cargo.toml index 291f0323..5b99c616 100644 --- a/uapi/Cargo.toml +++ b/uapi/Cargo.toml @@ -16,6 +16,11 @@ exclude = [ [dependencies] +[target.'cfg(all(target_arch = "x86_64", target_os = "linux"))'.dependencies] +prost = { version = "0.13", default-features = false, features = ["derive"] } +tokio = { version = "1.44", features = ["rt", "time", "net"] } +tonic = { version = "0.12", default-features = false, features = ["codegen", "prost", "transport"] } + [dev-dependencies] sentry-uapi = { path = '.', features = ['std'] } @@ -24,7 +29,7 @@ default = [] std = [] [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(CONFIG_BUILD_TARGET_AUTOTEST)'] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(CONFIG_BUILD_TARGET_AUTOTEST)', 'cfg(kani)'] } [profile.release] opt-level = "z" diff --git a/uapi/src/arch/mod.rs b/uapi/src/arch/mod.rs index 27ee14c0..cf4f5496 100644 --- a/uapi/src/arch/mod.rs +++ b/uapi/src/arch/mod.rs @@ -5,10 +5,6 @@ #[macro_use] pub mod arm_cortex_m; -#[cfg(target_arch = "x86_64")] -#[macro_use] -pub mod x86_64; - #[cfg(target_arch = "riscv32")] #[macro_use] pub mod riscv32; diff --git a/uapi/src/exchange.rs b/uapi/src/exchange.rs index a2bc31d9..baf65de2 100644 --- a/uapi/src/exchange.rs +++ b/uapi/src/exchange.rs @@ -188,6 +188,11 @@ impl ExchangeHeader { } #[cfg(test)] + /// # Safety + /// - `EXCHANGE_AREA` must be correctly aligned for `ExchangeHeader`. + /// - `EXCHANGE_AREA` must be large enough to contain a full `ExchangeHeader`. + /// - The caller must ensure exclusive mutable access to `EXCHANGE_AREA` + /// for the entire lifetime of the returned reference. pub unsafe fn from_exchange_mut(self) -> &'static mut Self { unsafe { self.from_addr_mut() } } @@ -363,6 +368,24 @@ pub const fn length() -> usize { EXCHANGE_AREA_LEN } +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +#[allow(static_mut_refs)] +fn sync_exchange_to_daemon() -> Result { + match crate::syscall::exchange_to_daemon(unsafe { &EXCHANGE_AREA }) { + Status::Ok => Ok(Status::Ok), + status => Err(status), + } +} + +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +#[allow(static_mut_refs)] +fn sync_exchange_from_daemon() -> Result { + match crate::syscall::exchange_from_daemon(unsafe { &mut EXCHANGE_AREA }) { + Status::Ok => Ok(Status::Ok), + status => Err(status), + } +} + /// copy to kernel generic implementation /// /// This API is a generic implementation in order to allow userspace to kernelspace @@ -381,13 +404,25 @@ pub fn copy_to_kernel(from: &T) -> Result where T: SentryExchangeable + ?Sized, { - from.to_kernel() + let status = from.to_kernel()?; + + #[cfg(all(target_arch = "x86_64", target_os = "linux"))] + { + sync_exchange_to_daemon()?; + } + + Ok(status) } pub fn copy_from_kernel(to: &mut T) -> Result where T: SentryExchangeable + ?Sized, { + #[cfg(all(target_arch = "x86_64", target_os = "linux"))] + { + sync_exchange_from_daemon()?; + } + to.from_kernel() } diff --git a/uapi/src/lib.rs b/uapi/src/lib.rs index 0259af0a..9dc81f9a 100644 --- a/uapi/src/lib.rs +++ b/uapi/src/lib.rs @@ -19,7 +19,11 @@ //! are implemented in the [`mod@syscall`] module, while the upper, easy interface //! is out of the scope of this very crate, and written in the shield crate instead. -#![cfg_attr(not(feature = "std"), no_std)] +#![cfg_attr(not(all(target_arch = "x86_64", target_os = "linux")), no_std)] + +/// Support for std in POSIX mode, only for linux/x86_64 targets +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +extern crate std; #[macro_use] mod arch; @@ -76,7 +80,21 @@ mod exchange; /// but instead with an upper interface such as shield /// /// > **NOTE**: This module may not be kept public forever +// +#[cfg(not(all(target_arch = "x86_64", target_os = "linux")))] +pub mod syscall; + +/// POSIX-based syscall implementation +/// +/// # Usage +/// +/// This module is responsible for making uapi functional in POSIX mode, by providing +/// a syscall implementation that is based on the POSIX API and thus only available +/// for linux/x86_64 targets. /// +// +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +#[path = "posix.rs"] pub mod syscall; /// Sentry kernelspace/userspace shared types and values @@ -116,5 +134,5 @@ pub use self::exchange::SentryExchangeable; /// Re-export Sentry uapi length fonction for Kani tests pub use self::exchange::length; -#[cfg(not(feature = "std"))] +#[cfg(not(all(target_arch = "x86_64", target_os = "linux")))] mod panic; diff --git a/uapi/src/posix.rs b/uapi/src/posix.rs new file mode 100644 index 00000000..c2c47aaf --- /dev/null +++ b/uapi/src/posix.rs @@ -0,0 +1,396 @@ +// SPDX-FileCopyrightText: 2026 H2Lab Development Team +// SPDX-License-Identifier: Apache-2.0 + +/// +/// This module provides a POSIX-compatible implementation of the Sentry kernel +/// interface for Linux/x86_64 targets. +/// It serves as a compatibility layer, allowing applications to behave as if +/// they were interacting with the Sentry kernel, but using standard +/// POSIX system calls and conventions. +/// This implementation is intended for use in environments where the Sentry kernel +/// is not available, such as during development or testing on a standard Linux system. +/// +/// In order to support multi-tasking and inter-process communication (IPC) in a POSIX environment, +/// this module will interact with a local GNU/Linux server that simulates the Sentry kernel's behavior. +/// This server will handle the necessary system calls and manage the state of the simulated kernel, +/// allowing applications to communicate with external objects such as devices, shared memory, +/// and other task. +/// This server will be written as a separate component in this repository, in the tools directory. +/// Interactions with this server will be made through a local socket, and the protocol will be defined +/// in the documentation of the server itself, as well as in the documentation of this module. +/// +/// Each Except for local-only syscalls such as `sched_yield` or `sys_exit`, all the syscalls +/// will behave as a proxy to the server, forwarding the syscall number and arguments to the +/// server and returning the server's response as the syscall result. +/// +use crate::systypes::*; +use std::sync::OnceLock; +use tonic::codegen::http::uri::PathAndQuery; +use tonic::transport::Endpoint; +use tonic::{Request, Status as GrpcStatus}; + +const DEFAULT_EMULATOR_HOST: &str = "127.0.0.1"; +const DEFAULT_EMULATOR_PORT: u16 = 44044; +const DEFAULT_APP_LABEL: u32 = 0; + +static GRPC_RUNTIME: OnceLock> = OnceLock::new(); +static APP_LABEL: OnceLock = OnceLock::new(); + +#[derive(Clone, PartialEq, ::prost::Message)] +struct DispatchRequest { + #[prost(string, tag = "1")] + syscall: String, + #[prost(sint64, repeated, tag = "2")] + args: std::vec::Vec, + #[prost(uint32, tag = "3")] + label: u32, + #[prost(bytes = "vec", tag = "4")] + payload: std::vec::Vec, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +struct DispatchResponse { + #[prost(sint32, tag = "1")] + status: i32, + #[prost(string, tag = "2")] + detail: std::string::String, + #[prost(bytes = "vec", tag = "3")] + payload: std::vec::Vec, +} + +fn grpc_runtime() -> Option<&'static tokio::runtime::Runtime> { + GRPC_RUNTIME + .get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok() + }) + .as_ref() +} + +fn emulator_uri() -> std::string::String { + let host = std::env::var("SENTRY_EMULATOR_HOST") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_EMULATOR_HOST.to_string()); + let port = std::env::var("SENTRY_EMULATOR_PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_EMULATOR_PORT); + format!("http://{host}:{port}") +} + +fn app_label() -> u32 { + *APP_LABEL.get_or_init(|| { + std::env::var("SENTRY_APP_LABEL") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_APP_LABEL) + }) +} + +fn status_from_i32(raw_status: i32) -> Status { + match raw_status { + 0 => Status::Ok, + 1 => Status::Invalid, + 2 => Status::Denied, + 3 => Status::NoEntity, + 4 => Status::Busy, + 5 => Status::AlreadyMapped, + 6 => Status::Critical, + 7 => Status::Timeout, + 8 => Status::Again, + 9 => Status::Intr, + 10 => Status::Deadlk, + _ => Status::Invalid, + } +} + +async fn grpc_dispatch(request: DispatchRequest) -> Result { + let endpoint = Endpoint::from_shared(emulator_uri()) + .map_err(|_| GrpcStatus::unavailable("invalid endpoint"))?; + let channel = endpoint + .connect() + .await + .map_err(|_| GrpcStatus::unavailable("cannot connect to emulator"))?; + + let mut client = tonic::client::Grpc::new(channel); + client + .ready() + .await + .map_err(|_| GrpcStatus::unavailable("emulator client not ready"))?; + let codec = tonic::codec::ProstCodec::default(); + let path = PathAndQuery::from_static("/camelot.sentry.emulator.Emulator/Dispatch"); + let response = client.unary(Request::new(request), path, codec).await?; + Ok(response.into_inner()) +} + +fn dispatch_with_payload( + syscall: &str, + args: &[i128], + payload: &[u8], +) -> Result { + let grpc_args = args + .iter() + .map(|value| i64::try_from(*value).ok()) + .collect::>>(); + + let Some(grpc_args) = grpc_args else { + return Err(Status::Invalid); + }; + + let request = DispatchRequest { + syscall: syscall.to_string(), + args: grpc_args, + label: app_label(), + payload: payload.to_vec(), + }; + + let Some(runtime) = grpc_runtime() else { + return Err(Status::NoEntity); + }; + + match runtime.block_on(grpc_dispatch(request)) { + Ok(response) => Ok(response), + Err(_) => Err(Status::NoEntity), + } +} + +fn forward_syscall(syscall: &str, args: &[i128]) -> Status { + match dispatch_with_payload(syscall, args, &[]) { + Ok(response) => status_from_i32(response.status), + Err(status) => status, + } +} + +pub(crate) fn exchange_to_daemon(data: &[u8]) -> Status { + match dispatch_with_payload("exchange_to_kernel", &[], data) { + Ok(response) => status_from_i32(response.status), + Err(status) => status, + } +} + +pub(crate) fn exchange_from_daemon(data: &mut [u8]) -> Status { + match dispatch_with_payload("exchange_from_kernel", &[], &[]) { + Ok(response) => { + let status = status_from_i32(response.status); + if status != Status::Ok { + return status; + } + + let copy_len = core::cmp::min(data.len(), response.payload.len()); + data[..copy_len].copy_from_slice(&response.payload[..copy_len]); + Status::Ok + } + Err(status) => status, + } +} + +#[inline(always)] +pub fn exit(status: i32) -> Status { + let _ = forward_syscall("exit", &[status as i128]); + std::process::exit(status); +} + +#[inline(always)] +pub fn sleep(duration_ms: SleepDuration, mode: SleepMode) -> Status { + forward_syscall( + "sleep", + &[u32::from(duration_ms) as i128, u32::from(mode) as i128], + ) +} + +#[inline(always)] +pub fn sched_yield() -> Status { + std::thread::yield_now(); + Status::Ok +} + +#[inline(always)] +pub fn get_process_handle(label: TaskLabel) -> Status { + forward_syscall("get_process_handle", &[label as i128]) +} + +#[inline(always)] +pub fn send_ipc(target: TaskHandle, length: u8) -> Status { + forward_syscall("send_ipc", &[target as i128, length as i128]) +} +#[inline(always)] +pub fn wait_for_event(mask: u8, timeout: i32) -> Status { + forward_syscall("wait_for_event", &[mask as i128, timeout as i128]) +} + +#[inline(always)] +pub fn map_dev(handle: DeviceHandle) -> Status { + forward_syscall("map_dev", &[handle as i128]) +} + +#[inline(always)] +pub fn unmap_dev(handle: DeviceHandle) -> Status { + forward_syscall("unmap_dev", &[handle as i128]) +} + +#[inline(always)] +pub fn get_shm_handle(shm: ShmLabel) -> Status { + forward_syscall("get_shm_handle", &[shm as i128]) +} + +#[inline(always)] +pub fn get_device_handle(devlabel: u32) -> Status { + forward_syscall("get_device_handle", &[devlabel as i128]) +} + +#[inline(always)] +pub fn get_dma_stream_handle(stream: StreamLabel) -> Status { + forward_syscall("get_dma_stream_handle", &[stream as i128]) +} + +#[inline(always)] +pub fn start(process: TaskLabel) -> Status { + forward_syscall("start", &[process as i128]) +} + +#[inline(always)] +pub fn map_shm(shm: ShmHandle) -> Status { + forward_syscall("map_shm", &[shm as i128]) +} + +#[inline(always)] +pub fn unmap_shm(shm: ShmHandle) -> Status { + forward_syscall("unmap_shm", &[shm as i128]) +} + +#[inline(always)] +pub fn shm_set_credential(shm: ShmHandle, id: TaskHandle, shm_perm: u32) -> Status { + forward_syscall( + "shm_set_credential", + &[shm as i128, id as i128, shm_perm as i128], + ) +} + +#[inline(always)] +pub fn send_signal(target: u32, sig: Signal) -> Status { + forward_syscall("send_signal", &[target as i128, sig as i128]) +} + +#[inline(always)] +pub fn gpio_get(resource: u32, io: u8) -> Status { + forward_syscall("gpio_get", &[resource as i128, io as i128]) +} + +#[inline(always)] +pub fn gpio_set(resource: u32, io: u8, val: bool) -> Status { + forward_syscall( + "gpio_set", + &[resource as i128, io as i128, i128::from(val as u8)], + ) +} + +#[inline(always)] +pub fn gpio_reset(resource: u32, io: u8) -> Status { + forward_syscall("gpio_reset", &[resource as i128, io as i128]) +} + +#[inline(always)] +pub fn gpio_toggle(resource: u32, io: u8) -> Status { + forward_syscall("gpio_toggle", &[resource as i128, io as i128]) +} + +#[inline(always)] +pub fn gpio_configure(resource: u32, io: u8) -> Status { + forward_syscall("gpio_configure", &[resource as i128, io as i128]) +} + +#[inline(always)] +pub fn irq_acknowledge(irq: u16) -> Status { + forward_syscall("irq_acknowledge", &[irq as i128]) +} + +#[inline(always)] +pub fn irq_enable(irq: u16) -> Status { + forward_syscall("irq_enable", &[irq as i128]) +} + +#[inline(always)] +pub fn irq_disable(irq: u16) -> Status { + forward_syscall("irq_disable", &[irq as i128]) +} + +#[inline(always)] +pub fn pm_manage(mode: CPUSleep) -> Status { + forward_syscall("pm_manage", &[u32::from(mode) as i128]) +} + +#[inline(always)] +pub fn alarm(timeout_ms: u32, flag: AlarmFlag) -> Status { + forward_syscall("alarm", &[timeout_ms as i128, u32::from(flag) as i128]) +} + +#[inline(always)] +pub fn log(_length: usize) -> Status { + forward_syscall("log", &[_length as i128]) +} + +#[inline(always)] +pub fn get_random() -> Status { + forward_syscall("get_random", &[]) +} + +#[inline(always)] +pub fn get_cycle(precision: Precision) -> Status { + forward_syscall("get_cycle", &[precision as i128]) +} + +#[inline(always)] +pub fn pm_set_clock(clk_reg: u32, clkmsk: u32, val: u32) -> Status { + forward_syscall( + "pm_set_clock", + &[clk_reg as i128, clkmsk as i128, val as i128], + ) +} + +#[inline(always)] +pub fn dma_start_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_start_stream", &[dmah as i128]) +} + +#[inline(always)] +pub fn dma_suspend_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_suspend_stream", &[dmah as i128]) +} + +#[inline(always)] +pub fn dma_get_stream_status(dmah: StreamHandle) -> Status { + forward_syscall("dma_get_stream_status", &[dmah as i128]) +} + +#[inline(always)] +pub fn shm_get_infos(shm: ShmHandle) -> Status { + forward_syscall("shm_get_infos", &[shm as i128]) +} + +#[inline(always)] +pub fn dma_assign_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_assign_stream", &[dmah as i128]) +} + +#[inline(always)] +pub fn dma_unassign_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_unassign_stream", &[dmah as i128]) +} + +#[inline(always)] +pub fn dma_get_stream_info(dmah: StreamHandle) -> Status { + forward_syscall("dma_get_stream_info", &[dmah as i128]) +} + +#[inline(always)] +pub fn dma_resume_stream(dmah: StreamHandle) -> Status { + forward_syscall("dma_resume_stream", &[dmah as i128]) +} + +#[inline(always)] +pub fn unsupported() -> Status { + forward_syscall("unsupported", &[]) +}