diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 149b23c..6c5a334 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -18,10 +18,11 @@ API = Zig**. No other language (Rust, C, V, …) may be used for these layers. - **Data**: Redis (cache/audit), VerisimDB (bitemporal, future) - **Container**: Podman Compose with Chainguard base images -The V implementation (`src/api/v/`) was removed 2026-05-16 (estate-wide V ban). The -`src/api/rust/` crate and the old `MIGRATION.adoc` "→ Rust" text are -off-policy drift — Rust is **not** an API language here; do not build, -extend, or migrate to it. Canonical = the Zig gateway. +The V implementation (`src/api/v/`) was removed 2026-05-16 (estate-wide V +ban). The Rust twin (`src/api/rust/`) was removed 2026-09-24 (owner +decision; roadmap D1 actioned) — Rust is **not** an API language here. +Canonical = the Zig gateway. (If Rust is ever reintroduced estate-side, it +must be Creusot-verified per owner instruction 2026-09-24.) ## Allowed Languages diff --git a/.gitattributes b/.gitattributes index bb13dfe..f4954b2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,7 +4,6 @@ * text=auto eol=lf # Source -*.rs text eol=lf diff=rust *.ex text eol=lf diff=elixir *.exs text eol=lf diff=elixir *.res text eol=lf @@ -48,5 +47,4 @@ Containerfile text eol=lf *.gz binary # Lock files -Cargo.lock text eol=lf -diff flake.lock text eol=lf -diff diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 89ee17c..dd0733a 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -8,7 +8,7 @@ # workflow-linter.yml # # Load-bearing build/security workflows stay standalone in the repo -# (rust-ci, codeql, dependabot, release, scan/mirror/pages plumbing). +# (codeql, dependabot, release, scan/mirror/pages plumbing). name: Governance diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml deleted file mode 100644 index 4fb50b1..0000000 --- a/.github/workflows/rust-ci.yml +++ /dev/null @@ -1,20 +0,0 @@ -# This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: MPL-2.0 -# Rust CI — thin wrapper calling the shared estate reusable in -# hyperpolymath/standards. Configure once, propagate everywhere. -# Present because aerie carries a Rust workspace (src/api/rust — tracked -# drift, kept testable until removal is an owner decision). -name: Rust CI -on: - push: - branches: [main, master] - pull_request: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -permissions: - actions: read - contents: read -jobs: - rust-ci: - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540 diff --git a/.gitignore b/.gitignore index 62ea176..8d90321 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # Aerie .gitignore — scoped to the languages actually in this repository -# (Zig, Rust, Idris2, Julia, AffineScript/JS-glue, Guix) plus general +# (Zig, Idris2, Julia, AffineScript/JS-glue, Guix) plus general # secrets/logs rules. The previous version was a multi-language # kitchen-sink (Elixir/ReScript/Deno/V/Ada/Haskell — none present) and # wrongly ignored .tool-versions, which estate REQUIRED-FILES mandates. @@ -18,9 +18,10 @@ Thumbs.db .zig-cache/ zig-out/ -# Rust +# Stray build dirs (e.g. a removed toolchain's target/) /target/ + # Idris2 /_build/ *.idr~ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7309fa9..e390747 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -7,14 +7,6 @@ stages: - test - build -variables: - CARGO_HOME: ${CI_PROJECT_DIR}/.cargo - -cache: - key: ${CI_COMMIT_REF_SLUG} - paths: - - .cargo/ - - target/ # ================== # Security Scanning @@ -42,26 +34,7 @@ semgrep: - semgrep --config auto --error . allow_failure: true -cargo-audit: - stage: security - image: rust:latest - script: - - cargo install cargo-audit - - cargo audit - rules: - - exists: - - Cargo.toml -cargo-deny: - stage: security - image: rust:latest - script: - - cargo install cargo-deny - - cargo deny check - rules: - - exists: - - Cargo.toml - allow_failure: true mix-audit: stage: security @@ -80,26 +53,7 @@ mix-audit: # Linting # ================== -rustfmt: - stage: lint - image: rust:latest - script: - - rustup component add rustfmt - - cargo fmt -- --check - rules: - - exists: - - Cargo.toml -clippy: - stage: lint - image: rust:latest - script: - - rustup component add clippy - - cargo clippy -- -D warnings - rules: - - exists: - - Cargo.toml - allow_failure: true mix-format: stage: lint @@ -126,14 +80,6 @@ credo: # Testing # ================== -cargo-test: - stage: test - image: rust:latest - script: - - cargo test --all-features - rules: - - exists: - - Cargo.toml mix-test: stage: test @@ -150,18 +96,6 @@ mix-test: # Build # ================== -cargo-build: - stage: build - image: rust:latest - script: - - cargo build --release - artifacts: - paths: - - target/release/ - expire_in: 1 week - rules: - - exists: - - Cargo.toml mix-build: stage: build diff --git a/.tool-versions b/.tool-versions index 5ac5547..b7d7e18 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,2 @@ zig 0.15.2 -rust stable julia stable diff --git a/ABI-FFI-README.md b/ABI-FFI-README.md index 4d70a12..94410e3 100644 --- a/ABI-FFI-README.md +++ b/ABI-FFI-README.md @@ -41,7 +41,7 @@ This library follows the **Hyperpolymath RSR Standard** for ABI and FFI design: ▼ ┌─────────────────────────────────────────────┐ │ Any Language via C ABI │ -│ - Rust, AffineScript, Julia, Python, etc. │ +│ - AffineScript, Julia, Python, etc. │ └─────────────────────────────────────────────┘ ``` @@ -259,28 +259,6 @@ main = do putStrLn "Success" ``` -### From Rust - -```rust -#[link(name = "aerie")] -extern "C" { - fn aerie_init() -> *mut std::ffi::c_void; - fn aerie_free(handle: *mut std::ffi::c_void); - fn aerie_process(handle: *mut std::ffi::c_void, input: u32) -> i32; -} - -fn main() { - unsafe { - let handle = aerie_init(); - assert!(!handle.is_null()); - - let result = aerie_process(handle, 42); - assert_eq!(result, 0); - - aerie_free(handle); - } -} -``` ### From Julia diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 58da767..9b8daee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,9 +23,6 @@ SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell | Specs (K9/SVC, bottom-up) | Nickel + K9 | `specs/` | | Core experiment | Julia | `src/core/Aerie.jl` | -`src/api/rust/` is **tracked drift**: a pre-law Rust rewrite of the gateway. -It is not the API language here — do not build, extend, or migrate to it. -Its removal is an owner decision (see `ROADMAP.adoc`, Phase 7). ## Directory structure (canonical) @@ -52,7 +49,6 @@ Its removal is an owner decision (see `ROADMAP.adoc`, Phase 7). │ ├── api/zig/ # canonical gateway (main, resolvers, policy, proof, clients) │ ├── api/graphql/ # GraphQL wire contract │ ├── api/proto/ # gRPC wire contract -│ ├── api/rust/ # TRACKED DRIFT — not the API language (see above) │ ├── core/ # Julia core experiment │ └── ui/ # AffineScript HUD + wasm + css ├── tests/ # test suites (fuzz, idris2 proven-tests format) @@ -79,3 +75,14 @@ Its removal is an owner decision (see `ROADMAP.adoc`, Phase 7). - Secrets are environment-injected; nothing secret is committed. - FFI `unsafe` blocks are confined to the Zig→C ABI boundary and individually classified in `audits/assail-classifications.a2ml`. + +## Forensic stack (untrusted search, trusted checking) + +See `docs/design/forensic-stack.adoc`. The Zig relational engine +(`ffi/zig/src/kanren.zig`) emits candidate attack paths as raw step +derivations; the Idris2 kernel (`src/abi/Forensics.idr`) checks each +against the evidence — a solver bug can only lose answers, never forge +one. Retention/echo, warrants, tropical budgets and the OND disclosure +gate are port-and-reprove surfaces from `echo-types`, `epistemic-types`, +`tropical-types` and `absolute-zero` (the Agda/Lean repos stay the +source of truth). diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index da0581c..ef0de92 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -58,8 +58,82 @@ https://github.com/hyperpolymath/aerie/pulls[hyperpolymath/aerie]. * `.claude/CLAUDE.md` — "Never Zig, Rust, or C" self-contradiction (Zig IS the API language) and the "Zig (src/api/v/)" mislabel corrected. +=== Added + +* Gateway skeleton, Phase 1 of the aspect weave — + `src/api/zig/{kyaml,config,ctx,errors,router,respond}.zig`: + the KYAML parser (strict KEP-5295 subset, estate rule Y-3); typed + config (defaults < KYAML file via AERIE_CONFIG < env; unknown keys + are errors; the only getenv reader in the gateway); the per-request + Ctx (replaces the module-level globals and the shared 128 KiB static + response buffer); the error taxonomy (one statusOf); the single + route table (paths, verbs, modules, resolvers — consumed by + dispatch, verb governance and the policy gate; boundary-guarded + longest-prefix); the single response write path. main.zig shrank + from 756 inline lines to lifecycle + the V2 edge handler. +* `GnosisRequestV2.resp_scratch` — per-connection response storage + owned by the gnosis server. Fixes a genuine lifetime bug the weave + exposed: response bodies must outlive the handler call (the socket + write happens after return); the old code survived only via the + never-freed static buffer. +* `configs/aerie.kyaml` — annotated example configuration. + +=== Changed + +* The gateway registers the V2 handler: query strings and request + headers are real at last, so `/api/v1/routes?target=…` works (dead + since the single-port architecture) and the policy gate finally + receives X-Api-Key. +* REST errors return their true statuses (400 for missing parameters, + 404 for unknown methods) instead of HTTP 200 with error bodies; + GraphQL keeps 200-with-errors per its conventions. +* Verb governance is enforced (stealth 404 + timing jitter on denial); + route matching is boundary-guarded (`/api/v1/telemetryX` matches + nothing, previously matched `/api/v1/telemetry`). + +=== Added (forensics) + +* Forensic stack FS-0 (design: `docs/design/forensic-stack.adoc`) — + untrusted search, trusted checking (de Bruijn criterion): + `src/abi/Forensics.idr` (Idris2 kernel ABI: Retention restating + echo-types' thin poset keep <= residue <= forget, evidence-indexed + `Lateral`/`Reach`, `checkReach` signature, `CertificateCheck` + + non-factive `Warrant` restating epistemic-types' ProofTransport/Warrant + surfaces — port-and-reprove, Agda/Lean repos cited as authority) and + `ffi/zig/src/kanren.zig` (UNTRUSTED engine: evidence table, + depth-bounded search emitting `RawStep` derivations over the `kanren_*` + C ABI; budgeted "no answer within depth" is distinct from "no answer + exists" — the tropical budget seam). Not yet type-checked in Idris2 + (no toolchain in sandbox; CI is the witness, per estate convention). +* `src/abi/Gnosis.idr` — Idris2 ABI declarations for the gnosis server + pool and service connector pool (superset-compatible with + developer-ecosystem/zig-api), plus the **GnosisRequestV2** extension: + the raw query string and request headers, both stripped by the v1 + surface (the deployed gateway could never see `X-Api-Key` or + `?target=`-style parameters — v2 fixes the starved policy gate and + resolvers at the ABI level). +* `ffi/zig/` in-repo implementation of the uapi surface: threaded + HTTP/1.1 gnosis server (`gnosis.zig`), outbound connector pool + (`connector.zig`), C header `include/zig_api.h`. The build is + self-contained — no external clones, no private libproven_ffi, no + absolute paths — and asserts ABI layout against the header in tests. + +=== Changed + +* `build.zig` rewritten: one build graph (FFI library + gateway) from + repository sources; `zig build` and `zig build test` now succeed from + a fresh clone (first time in the repo's public history). +* `Containerfile` builds self-contained (no developer-ecosystem clone). + === Removed +* `src/api/rust/` (the Rust twin crate) + root `Cargo.toml`/`Cargo.lock` — + owner decision 2026-09-24, roadmap D1 actioned. Aspects are implemented + once (Zig canonical); the twin could not compile (rand 0.10 API drift) + and kept CI red. Rust is not an API language here; any future Rust is + required to be Creusot-verified (owner instruction). +* `.github/workflows/rust-ci.yml`, `.gitlab-ci.yml` cargo stages, + `MIGRATION.adoc`, mise/.tool-versions rust pins — twin follow-through. * `examples/web-project-deno.json` — Deno banned estate-wide 2026-09-22. * `MAINTAINERS` (extensionless scaffold duplicate; `MAINTAINERS.adoc` is canonical). diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index c3d7d93..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,2114 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aerie-api" -version = "0.2.0" -dependencies = [ - "axum", - "chrono", - "hex", - "rand", - "reqwest", - "serde", - "serde_json", - "sha2", - "tokio", - "tower 0.4.13", - "tower-http 0.5.2", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[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", - "axum-macros", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower 0.5.3", - "tower-layer", - "tower-service", - "tracing", -] - -[[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", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[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-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 = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "rand_core", - "wasip2", - "wasip3", - "wasm-bindgen", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[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", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.185" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[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 = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[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 = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.2", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower 0.5.3", - "tower-http 0.6.8", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustls" -version = "0.23.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[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 = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[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.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[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" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "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-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "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", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower 0.5.3", - "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 = [ - "log", - "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", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[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 = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[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 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 32e0052..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# (MPL-2.0 is automatic legal fallback until PMPL is formally recognised) -# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# Aerie Rust workspace -# Members: -# src/api/rust — triple-mount API gateway (zig rewrite, 2026-04-12) - -[workspace] -members = [ - "src/api/rust", -] -resolver = "2" diff --git a/Containerfile b/Containerfile index 070167c..e3d0408 100644 --- a/Containerfile +++ b/Containerfile @@ -2,56 +2,37 @@ # # Containerfile — Aerie Gateway (Triple-Mount API Server) # -# Estate architecture law: ABI = Idris2 (src/abi/), FFI + API = Zig. -# The V implementation (src/api/v/) was removed under the estate-wide -# V ban (deprecated 2026-04-12, removed 2026-05-16). The canonical -# implementation is the Zig gateway src/api/zig/ built by ./build.zig. -# (The src/api/rust/ crate and the old V→Rust MIGRATION.adoc are off-policy -# drift — Rust is not an API language here. Tracked separately for removal.) +# Estate architecture law: ABI = Idris2 (src/abi/), FFI = Zig (ffi/zig/), +# API = Zig (src/api/zig/). The gateway and its FFI (gnosis server + +# connector pool) build together from this repository — no external +# clones, no absolute paths, no private dependencies. # # Multi-stage build: -# Stage 1: build libzig_api (developer-ecosystem/zig-api FFI) + aerie-gateway -# Stage 2: copy the static binary into a minimal Chainguard image +# Stage 1: zig build -Doptimize=ReleaseSafe → aerie-gateway +# Stage 2: static binary into a minimal Chainguard image # # Exposes: -# 4000 — HTTP (REST + GraphQL) -# 4001 — gRPC (length-prefixed binary protocol) +# 4000 — HTTP (REST + GraphQL + gRPC-JSON, path-routed) # # Build: podman build -t aerie-gateway -f Containerfile . -# Run: podman run -p 4000:4000 -p 4001:4001 aerie-gateway +# Run: podman run -p 4000:4000 aerie-gateway # --- Stage 1: Build (Zig) --- FROM cgr.dev/chainguard/wolfi-base:latest AS builder -# Zig toolchain + git (for the zig-api FFI dependency) -RUN apk add --no-cache zig git +RUN apk add --no-cache zig -# Build the external zig-api FFI dependency (sparse checkout — same org). -# build.zig accepts -Dzig-api-lib-path / -Dzig-api-include-path overrides; -# CI may instead inject a prebuilt libzig_api and skip this clone. -WORKDIR /deps -RUN git clone --depth 1 --filter=blob:none --sparse \ - https://github.com/hyperpolymath/developer-ecosystem.git && \ - cd developer-ecosystem && \ - git sparse-checkout set zig-api && \ - cd zig-api/ffi/zig && \ - zig build -Doptimize=ReleaseSafe - -# Build the aerie-gateway Zig binary against the freshly built zig-api WORKDIR /app COPY . . RUN zig build -Doptimize=ReleaseSafe \ - -Dzig-api-lib-path=/deps/developer-ecosystem/zig-api/ffi/zig/zig-out/lib \ - -Dzig-api-include-path=/deps/developer-ecosystem/zig-api/ffi/zig/zig-out/include && \ - cp zig-out/bin/aerie-gateway /app/aerie-gateway + && cp zig-out/bin/aerie-gateway /app/aerie-gateway # --- Stage 2: Runtime --- FROM cgr.dev/chainguard/static:latest COPY --from=builder /app/aerie-gateway /aerie-gateway -# HTTP (REST + GraphQL) and gRPC ports +# HTTP (REST + GraphQL + gRPC-JSON) EXPOSE 4000 -EXPOSE 4001 ENTRYPOINT ["/aerie-gateway"] diff --git a/Justfile b/Justfile index 5a3ff9f..a1820b8 100644 --- a/Justfile +++ b/Justfile @@ -20,13 +20,12 @@ build-release: @echo "=== Build (ReleaseSafe) ===" zig build -Doptimize=ReleaseSafe -# Run the in-tree test suites (zig units, idris2 proven-tests, rust api) +# Run the in-tree test suites (zig units, idris2 proven-tests) # plus the submodule suites (kept from the original tests recipe). test: @echo "=== In-tree suites ===" @if command -v zig >/dev/null 2>&1; then zig build test; else echo "zig not found — skipping zig unit tests"; fi @if command -v idris2 >/dev/null 2>&1 && [ -f tests/idris2/Test.idr ]; then bash tests/idris2/run_tests.sh || echo "idris2 suite failed or incomplete — see its output"; else echo "idris2 not found — skipping idris2 suite"; fi - @if command -v cargo >/dev/null 2>&1; then (cd src/api/rust && cargo test --quiet); else echo "cargo not found — skipping rust api tests (tracked drift)"; fi @echo "=== Submodule suites ===" @if [ -d qubes-sdp ] && [ -f qubes-sdp/justfile ]; then (cd qubes-sdp && just test); fi @if [ -d bgp-backbone-lab ] && [ -f bgp-backbone-lab/justfile ]; then (cd bgp-backbone-lab && just test); fi diff --git a/MIGRATION.adoc b/MIGRATION.adoc deleted file mode 100644 index f9b34e8..0000000 --- a/MIGRATION.adoc +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -= Aerie — zig Removed (deprecated 2026-04-12, removed 2026-05-16) -:toc: - -== Status - -The zig implementation in `src/api/v/` is *removed* as of 2026-05-16 -(deprecated 2026-04-12 following the estate-wide zig ban of 2026-04-10). - -Per the day-1 estate architecture law (ABI = Idris2, FFI = Zig, API = Zig), -the canonical implementation is the **Zig** gateway at `src/api/zig/`, built -by the repository's root `build.zig` (binary `aerie-gateway`). The -Containerfile and `stapeln.toml` build that Zig binary; no V toolchain is -fetched or used anywhere in this repo. - -NOTE: the `src/api/rust/` crate and the earlier "Migration Target: Rust" -text in this document are *off-policy drift* — Rust is not an API language -in this estate. That crate is debt to be removed; it is NOT the migration -target. Retained below only as a historical module-name reference. - -== Migration Target: Zig - -Canonical: `src/api/zig/` (Idris2 ABI in `src/abi/`, Zig FFI in `ffi/zig/`). - -=== Module Map - -[cols="1,1"] -|=== -| V module (removed) | Zig module - -| `src/api/v/main.v` | `src/api/zig/main.zig` -| `src/api/v/policy.v` | `src/api/zig/policy.zig` -| `src/api/v/proof.v` | `src/api/zig/proof.zig` -| `src/api/v/redis_client.v` | `src/api/zig/redis_client.zig` -| `src/api/v/verb_governance.v` | `src/api/zig/verb_governance.zig` -| `src/api/v/resolvers.v` | `src/api/zig/resolvers.zig` -| `src/api/v/librespeed_client.v` | `src/api/zig/librespeed_client.zig` -| `src/api/v/hyperglass_client.v` | `src/api/zig/hyperglass_client.zig` -| `src/api/v/smokeping_client.v` | `src/api/zig/smokeping_client.zig` -| `src/api/v/verisim_client.v` | `src/api/zig/verisim_client.zig` -|=== - -== Build and Run - -[source,bash] ----- -# From aerie/ root (Idris2 ABI + Zig API/FFI) -zig build -Doptimize=ReleaseSafe -zig build test - -# Run (all mounts enabled, default ports) -./zig-out/bin/aerie-gateway - -# Disable gRPC mount -ENABLE_GRPC=false ./zig-out/bin/aerie-gateway - -# Custom ports -AERIE_PORT=8080 AERIE_GRPC_PORT=8081 ./zig-out/bin/aerie-gateway - -# Configure backends -LIBRESPEED_URL=http://localhost:8888 \ -HYPERGLASS_URL=http://localhost:8001 \ -SMOKEPING_URL=http://localhost:8080 \ -REDIS_URL=redis://localhost:6379 \ -VERISIMDB_URL=http://localhost:8084 \ -./zig-out/bin/aerie-gateway ----- - -== Feature Parity - -All V features are preserved: - -* Triple-mount: REST (port 4000) + GraphQL (port 4000) + gRPC Phase 1 (port 4001) -* Mount flags: `ENABLE_REST`, `ENABLE_GRAPHQL`, `ENABLE_GRPC` (all default true) -* Five resolvers: `telemetry`, `route_forensics`, `smokeping`, `audit`, `temporal_audit` -* GraphQL dispatcher routes to the same resolver set (root field detection) -* Phase 1 policy gate: validates `X-Api-Key` format (≥16 chars, alphanumeric + hyphen) -* ProofEnvelope wraps all responses: SHA-256 result hash, policy hash, UUID v4 query_id, - RFC 3339 issued_at, `proof_type: "light"`, empty signature placeholder (Phase 2: Ed25519) -* Redis hot cache: 30 s TTL, `GET/SET` per resolver, `LPUSH/LTRIM` audit list (≤10 000), - `XADD` to `aerie:audit:stream` for Observatory -* VerisimDB cold store: fire-and-forget `POST /api/v1/events`, as-of / range / history queries -* Verb governance: explicit HTTP-verb allowlist per route prefix, stealth 404 (not 405), - 1–8 ms random timing jitter on all responses -* gRPC Phase 1: raw TCP, 4-byte big-endian length prefix + JSON body, per-connection goroutine - -== Next Steps - -. Build and smoke-test: `cargo build -p aerie-api && cargo test -p aerie-api` -. Start backends (Redis, LibreSpeed, Hyperglass, SmokePing, VerisimDB) and run - `./target/debug/aerie-api` against the container network. -. Verify `GET /health`, `GET /api/v1/telemetry`, and `POST /api/v1/graphql` against a valid key. -. [DONE 2026-05-16] `src/api/v/`, `v.mod`, `vpkg.json`, and the V generated - stubs (`src/api/proto/aerie.pb.v`, `src/api/graphql/schema.gql.v`) removed; - Containerfile + `stapeln.toml` repointed to the Rust build. -. Wire `aerie-api` binary into `compose.yml` to replace the V container. diff --git a/ROADMAP.adoc b/ROADMAP.adoc index a79cd5e..b02d419 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -122,11 +122,10 @@ M = half-day, L = day+), and dependencies. Full evidence in === Drift & architecture (owner decisions) -* **D1 — `src/api/rust/` removal or re-home.** 🟠 CORRECTIVE · L · - owner decision. Tracked drift per the architecture law; rust-ci.yml - keeps it testable meanwhile. Removal also drops the root Cargo - workspace, `.gitlab-ci.yml`'s cargo stages, and dependabot's cargo - group. +* **D1 — `src/api/rust/` removal or re-home.** ✅ DONE 2026-09-24 — + twin removed with the root Cargo workspace, `rust-ci.yml`, + `.gitlab-ci.yml` cargo stages, and mise/.tool-versions rust pins + (owner decision; the aspect-weave branch). * **D2 — `src/stale/hyperglass` disposition.** 🟠 CORRECTIVE · S · owner decision. Delete (upstream has its own repo) or re-home the `.samples/` deployment fixtures that the BGP lab references. diff --git a/build.zig b/build.zig index 2aedfce..6f1ec46 100644 --- a/build.zig +++ b/build.zig @@ -1,61 +1,60 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// build.zig — Aerie Gateway build configuration +// build.zig — Aerie gateway build (self-contained; no external deps). // -// Builds the aerie-gateway binary from src/api/zig/main.zig. -// The FFI shared library is in ffi/zig/ (separate build.zig there). +// Build graph, one repository, zero absolute paths: +// +// ffi/zig/src/lib.zig ──> libzig_api (static) ─┐ +// ffi/zig/include/zig_api.h (installed header) ─┤ +// ├─> aerie-gateway +// src/api/zig/main.zig ──────────────────────── ┘ +// +// Estate law: ABI = Idris2 (src/abi/), FFI = Zig (ffi/zig/), API = Zig +// (src/api/zig/). The uapi surface is declared in src/abi/Gnosis.idr and +// implemented in ffi/zig — superset-compatible with +// developer-ecosystem/zig-api, which may replace it if ever published. // // Usage: // zig build — compile aerie-gateway (debug) // zig build -Doptimize=ReleaseSafe — release build -// zig build test — run unit tests -// zig build run — run aerie-gateway directly -// -// External dependency: libzig_api (developer-ecosystem/zig-api) -// Build first: cd ../developer-ecosystem/zig-api/ffi/zig && zig build -// Then: zig build (uses default paths below) -// -// Override paths for CI: -// zig build -Dzig-api-lib-path=… -Dzig-api-include-path=… +// zig build run — run the gateway +// zig build test — FFI + gateway unit tests // // Requires Zig 0.15.2+. const std = @import("std"); -/// Default path to the directory containing libzig_api.a / libzig_api.so. -const DEFAULT_ZIG_API_LIB_PATH = - "/var/mnt/eclipse/repos/developer-ecosystem/zig-api/ffi/zig/zig-out/lib"; - -/// Default path to the directory containing zig_api.h. -const DEFAULT_ZIG_API_INCLUDE_PATH = - "/var/mnt/eclipse/repos/developer-ecosystem/zig-api/ffi/zig/zig-out/include"; - -/// Root source file of the zig-api Zig module (for direct Zig-level imports). -const ZIG_API_SOURCE_ROOT = - "/var/mnt/eclipse/repos/developer-ecosystem/zig-api/ffi/zig/src/lib.zig"; - pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); // ------------------------------------------------------------------------- - // Build options — allow callers to override zig-api library paths + // FFI library: libzig_api (gnosis server + connector pool + libaerie) // ------------------------------------------------------------------------- - const zig_api_lib_path = b.option( - []const u8, - "zig-api-lib-path", - "Directory containing libzig_api.a/.so (default: " ++ DEFAULT_ZIG_API_LIB_PATH ++ ")", - ) orelse DEFAULT_ZIG_API_LIB_PATH; + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("ffi/zig/src/lib.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + // The ABI layout test @cImports the header, so the FFI module itself + // needs the include path. + ffi_mod.addIncludePath(b.path("ffi/zig/include")); + + const ffi_lib = b.addLibrary(.{ + .name = "zig_api", + .root_module = ffi_mod, + .linkage = .static, + }); + b.installArtifact(ffi_lib); - const zig_api_include_path = b.option( - []const u8, - "zig-api-include-path", - "Directory containing zig_api.h (default: " ++ DEFAULT_ZIG_API_INCLUDE_PATH ++ ")", - ) orelse DEFAULT_ZIG_API_INCLUDE_PATH; + // Install the C header for external consumers (Idris2 side, packagers). + const header = b.addInstallHeaderFile(b.path("ffi/zig/include/zig_api.h"), "zig_api.h"); + b.getInstallStep().dependOn(&header.step); // ------------------------------------------------------------------------- - // Root module for the gateway + // Gateway executable // ------------------------------------------------------------------------- const gateway_mod = b.createModule(.{ .root_source_file = b.path("src/api/zig/main.zig"), @@ -63,34 +62,16 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + gateway_mod.addIncludePath(b.path("ffi/zig/include")); + gateway_mod.linkLibrary(ffi_lib); - // Wire zig-api as a named Zig module so @import("zig_api") resolves. - // This gives aerie direct access to gnosis.zig and connector.zig Zig types - // (ServerState, ConnectorState, pool management) in addition to the C ABI - // symbols exposed via libzig_api. - gateway_mod.addAnonymousImport("zig_api", .{ - .root_source_file = .{ .cwd_relative = ZIG_API_SOURCE_ROOT }, - .target = target, - .optimize = optimize, - .link_libc = true, - }); - - // Link libzig_api so the C ABI exports (uapi_gnosis_*, uapi_connector_*, - // uapi_init, uapi_teardown) are available. - gateway_mod.addLibraryPath(.{ .cwd_relative = zig_api_lib_path }); - gateway_mod.addIncludePath(.{ .cwd_relative = zig_api_include_path }); - gateway_mod.linkSystemLibrary("zig_api", .{}); - - // ------------------------------------------------------------------------- - // Main executable: aerie-gateway - // ------------------------------------------------------------------------- const gateway = b.addExecutable(.{ .name = "aerie-gateway", .root_module = gateway_mod, }); b.installArtifact(gateway); - // Run step: zig build run + // Run step. const run_cmd = b.addRunArtifact(gateway); run_cmd.step.dependOn(b.getInstallStep()); if (b.args) |args| run_cmd.addArgs(args); @@ -98,26 +79,31 @@ pub fn build(b: *std.Build) void { run_step.dependOn(&run_cmd.step); // ------------------------------------------------------------------------- - // Unit tests + // Tests — FFI suite (incl. the ABI-vs-header layout assertions) and the + // gateway suite. // ------------------------------------------------------------------------- - const test_mod = b.createModule(.{ - .root_source_file = b.path("src/api/zig/main.zig"), + const ffi_test_mod = b.createModule(.{ + .root_source_file = b.path("ffi/zig/src/lib.zig"), .target = target, .optimize = optimize, .link_libc = true, }); - test_mod.addAnonymousImport("zig_api", .{ - .root_source_file = .{ .cwd_relative = ZIG_API_SOURCE_ROOT }, + ffi_test_mod.addIncludePath(b.path("ffi/zig/include")); + const ffi_tests = b.addTest(.{ .root_module = ffi_test_mod }); + const run_ffi_tests = b.addRunArtifact(ffi_tests); + + const gateway_test_mod = b.createModule(.{ + .root_source_file = b.path("src/api/zig/main.zig"), .target = target, .optimize = optimize, .link_libc = true, }); - test_mod.addLibraryPath(.{ .cwd_relative = zig_api_lib_path }); - test_mod.addIncludePath(.{ .cwd_relative = zig_api_include_path }); - test_mod.linkSystemLibrary("zig_api", .{}); + gateway_test_mod.addIncludePath(b.path("ffi/zig/include")); + gateway_test_mod.linkLibrary(ffi_lib); + const gateway_tests = b.addTest(.{ .root_module = gateway_test_mod }); + const run_gateway_tests = b.addRunArtifact(gateway_tests); - const tests = b.addTest(.{ .root_module = test_mod }); - const run_tests = b.addRunArtifact(tests); - const test_step = b.step("test", "Run unit tests"); - test_step.dependOn(&run_tests.step); + const test_step = b.step("test", "Run FFI + gateway unit tests"); + test_step.dependOn(&run_ffi_tests.step); + test_step.dependOn(&run_gateway_tests.step); } diff --git a/configs/aerie.kyaml b/configs/aerie.kyaml new file mode 100644 index 0000000..1581c23 --- /dev/null +++ b/configs/aerie.kyaml @@ -0,0 +1,16 @@ +--- +# Aerie gateway configuration — KYAML (KEP-5295 strict subset; estate +# rule Y-3, standards/3-practice/YAML-POLICY.adoc). Loader: config.zig. +# Precedence: defaults < this file < environment. Unknown keys are +# errors, so a typo can never quietly take the default. +port: 4000 +rest: true +graphql: true +grpc: true +redis_url: "redis://redis:6379" +librespeed_url: "http://librespeed:80" +hyperglass_url: "http://hyperglass:80" +smokeping_url: "http://smokeping:80" +verisim_url: "http://verisim:8084" +# auth: "open" (Phase-1 default, permissive) | "deny" (Phase-2 keystore) +auth: "open" diff --git a/docs/REPO-SETTINGS.adoc b/docs/REPO-SETTINGS.adoc index 72e0b9e..1f320bc 100644 --- a/docs/REPO-SETTINGS.adoc +++ b/docs/REPO-SETTINGS.adoc @@ -127,7 +127,7 @@ gh api -X POST repos/hyperpolymath/aerie/rulesets/$RID -f active=true NOTE: enabling `required_status_checks` requires naming the check contexts (currently the rule exists but has an empty context list). Populate it with the GATE-category workflows from `CICD-WORKFLOW-CATALOG.md`: governance, -codeql, scorecard, hypatia-scan, dogfood-gate, rust-ci, guix-policy. Until +codeql, scorecard, hypatia-scan, dogfood-gate, guix-policy. Until then the rule is a no-op and the merge gate rests on PR review + signature only. diff --git a/docs/design/forensic-stack.adoc b/docs/design/forensic-stack.adoc new file mode 100644 index 0000000..cd6af63 --- /dev/null +++ b/docs/design/forensic-stack.adoc @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Aerie Forensic Stack — Untrusted Search, Trusted Checking +:status: Active (design accepted 2026-09-24; implementation phased) +:revdate: 2026-09-24 + +== Principle + +miniKanren-style search over network evidence is complex, heuristic and +depth-bounded. We do not prove it correct. Following the *de Bruijn +criterion*, the search engine produces **candidate answers with +derivations**, and a small checker whose types encode the forensic rules +accepts or rejects each one. The worst outcome of a buggy solver is a +missing answer, never a false one. + +[cols="1,4", options="header"] +|=== +|Layer |Role + +|Untrusted (Zig) +|Parsers → fact store → relational search (`ffi/zig/src/kanren.zig`). +Fast, arena memory, C ABI. Any bug here can only lose answers. + +|Trusted (Idris2) +|`src/abi/Forensics.idr`: fact schema + warrants (ingest boundary ①), +query signatures (②), derivation checker kernel (③), custody receipts +(④), disclosure gate (⑤). + +|Source of truth (Agda / Lean) +|`hyperpolymath/echo-types`, `epistemic-types`, `choreographic-types`, +`tropical-types`, `absolute-zero`. Idris2 **ports and reproves** the +needed computational surfaces and cites the upstream module as the +authority — Idris2 cannot import Agda/Lean proofs. +|=== + +== The five interface points (priority order) + +[cols="1,3,3", options="header"] +|=== +|# |Boundary |Why Idris2 + +|③ |Derivation checker |Makes the solver untrusted. Every reported attack path is a typed proof over evidence. +|① |Fact ingestion |Parsers are where real bugs live (NAT, sampling, rotation). Types record *what kind of loss* produced each fact. +|④ |Custody / transport |SOC → IR → legal handoffs. Receipts must not silently become "truth" at the receiver. +|⑤ |Disclosure gate |Reports leak PII and internal topology. OND-style non-disclosure relative to a declared observer, with honest residue lists. +|② |Query signatures |Stops ill-moded queries (numeric predicate on an unbound variable; binding an origin under `Residue` retention). +|=== + +Idris2 never sits inside the solver loop — per-goal FFI crossings would +destroy performance and buy nothing: the checker already catches wrong +answers. + +== Type-theory mapping (port-and-reprove sources) + +[cols="1,3,3", options="header"] +|=== +|Theory |Authority (upstream) |Aerie use + +|Echo types +|`echo-types` — `Echo.Index.ThinPoset`, `Echo.Modality.Core`, +`Echo.Separation.NotResourceInstance` (FOUNDATION_CONTRACT.adoc) +|Per-field `Retention` (`Keep ≤ Residue ≤ Forget`) on ingested facts. +A query may bind a variable to a single origin only under `Keep`; under +`Residue` it must return the *fibre* (all consistent origins); under +`Forget` the query is ill-typed. Backward relational queries are fibre +enumerators; the checker certifies each answer as `(origin, f origin = y)`. + +|Epistemic types +|`epistemic-types` — `Warrant.agda`, `ProofTransport.agda` +(`CertificateCheck`, `proofSound`), `ReadConsistency.agda` +|Sensors/analysts as standpoints `κ`. `Warrant` records that a source +*said* X (non-factive by construction); `SoundWarrant` is the explicit +assumption needed to treat it as *X happened* — and it shows in the +report instead of hiding in the rules. Derivations tie to evidence-store +snapshots; re-ingestion stales them (ReadConsistency). + +|Tropical resource typing +|`tropical-types` (Lean 4) +|Max-plus path-duration grades reject temporally impossible chains; +min-max bottleneck grades reject throughput-impossible exfil paths. +Solver depth bounds become declared resource grades on queries (②), so +"no answer within budget" is typed as distinct from "no answer exists". +Grades *measure*; they are never Echo and never called echo-grades. + +|Choreographic types +|`choreographic-types` (Agda, graded MPST) +|Protocols (Kerberos, SMB, TLS) as global types; a sensor placement is +a cut across the causal order. K-CUT is open — treated as a *design +contract*, not a runtime guarantee; projection mismatches are logged as +forensic anomalies. + +|Absolute zero (OND/CNO) +|`absolute-zero` (multi-prover) +|The disclosure gate (⑤): sanitized exports prove observational null +disclosure relative to a declared observation model, and always ship an +explicit residue list of uncovered side channels. Never claim +side-channel freedom. +|=== + +== C ABI (interface ③) + +`ffi/zig/include/zig_api.h` (kanren section), declared in +`src/abi/Forensics.idr`: + +```c +typedef struct { uint32_t fact_id; uint8_t rule; } RawStep; /* 0=lateral 1=exfil 2=entry */ +typedef struct { const RawStep *steps; uint32_t len; } RawDeriv; + +uint32_t kanren_attack_paths(const char *src, const RawDeriv **out); /* count; arena lives until kanren_free */ +void kanren_free(void); +``` + +The Idris2 kernel is evidence-indexed: `Lateral ev a b` and +`Reach ev a b` are indexed by the evidence list, so a derivation cannot +cite a flow that was never observed. `checkReach` converts raw steps +into `Either CheckError (Reach ev a b)`; acceptance is a theorem about +*this* evidence. The `CertificateCheck` shape (executable check + +soundness: acceptance entails meaning) restates +`epistemic-types/src/EpistemicTypes/ProofTransport.agda`. + +== Phasing (evolutionary; every phase leaves `zig build test` green) + +[cols="1,4", options="header"] +|=== +|Phase |Deliverable + +|FS-0 *(this change)* +|Design accepted; ABI declared (`Forensics.idr`); Zig engine scaffold: +evidence table, depth-bounded search emitting `RawStep` derivations, +C ABI, fixture tests. Not yet type-checked in Idris2 (no toolchain in +sandbox — same honest status as `tests/idris2/`; CI is the witness). + +|FS-1 +|Kernel implemented and type-checked: `checkReach` + side conditions +(`Internal`, `LateralPort`) decided; `Either` + `CertificateCheck` +views; negative tests (fact-id not in evidence → reject). + +|FS-2 +|Ingestion (①): Retention-tagged facts from Zeek/NetFlow-shaped +parsers; NAT/sampling produce `Residue` fibres, never silent origins. + +|FS-3 +|Query signatures (②) + tropical budget grades; disclosure gate (⑤) +via absolute-zero port; custody receipts (④). +|=== + +== Honest bounds + +* Tier-1/Tier-2 content from the upstream contracts only; no + funext-qualified results, no WFS/OFS distinction. +* Fibres are never collapsed to a cardinality or entropy score; fibre + size may be a *reported measure* (resource algebra), but the fibre is + the artefact. +* The solver is and remains untrusted; no claim in this document depends + on its correctness. diff --git a/ffi/README.md b/ffi/README.md index 0e7f804..4fc5718 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -1,11 +1,22 @@ # Foreign-function-interface layer. - Estate law: FFI = Zig. C-compatible implementation layer with its own build.zig; unsafe blocks are confined to the C ABI boundary and classified in audits/assail-classifications.a2ml. +Self-contained since 2026-09-24: this directory implements the **gnosis server pool** and the **service connector pool** in-repo (declared in `src/abi/Gnosis.idr`, header `zig/include/zig_api.h`), superset-compatible with developer-ecosystem/zig-api — the estate library may replace it if ever published. The **GnosisRequestV2** extension carries the query string and request headers, which the v1 surface strips (v1 starves the policy gate of `X-Api-Key` and the resolvers of query parameters). + | Entry | Purpose | |-------|---------| -| `zig/` | Zig FFI shared library (build.zig, src/main.zig, test/) | +| `zig/build.zig` | Standalone FFI build (libzig_api, libaerie alias, header) | +| `zig/include/zig_api.h` | C ABI header (declared in `src/abi/Gnosis.idr`) | +| `zig/src/lib.zig` | Library root: uapi lifecycle; links all surfaces | +| `zig/src/gnosis.zig` | Threaded HTTP/1.1 edge server pool (`uapi_gnosis_*`) | +| `zig/src/connector.zig` | Outbound HTTP/1.1 connector pool (`uapi_connector_*`) | +| `zig/src/core.zig` | Result/state tags pinned to the header, error slot | +| `zig/src/aerie.zig` | libaerie surface (`aerie_*`, declared in `src/abi/Foreign.idr`) | +| `zig/src/kanren.zig` | UNTRUSTED forensic search engine (`kanren_*`, declared in `src/abi/Forensics.idr`; design: `docs/design/forensic-stack.adoc`) | +| `zig/test/` | Integration tests (libaerie surface) | + +Layout assertions: `zig build test` compiles `zig_api.h` and asserts struct offsets/sizes against the Zig `extern struct`s — the header and implementation cannot drift silently. See [`aerie_chora.deed`](../aerie_chora.deed) for the canonical machine-readable description of this layer. diff --git a/ffi/zig/build.zig b/ffi/zig/build.zig index f810f2a..07c866c 100644 --- a/ffi/zig/build.zig +++ b/ffi/zig/build.zig @@ -1,94 +1,67 @@ -// Aerie FFI Build Configuration // SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// build.zig — standalone FFI build (cd ffi/zig && zig build). +// +// Emits the same libzig_api the root build produces, plus the libaerie +// alias (Idris2 consumers link -laerie per src/abi/Foreign.idr) and the +// installed C header. The root build.zig is the canonical entry point; +// this one exists for FFI-focused development and packaging. const std = @import("std"); pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - // Shared library (.so, .dylib, .dll) - const lib = b.addSharedLibrary(.{ - .name = "aerie", - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, + const mod = b.createModule(.{ + .root_source_file = b.path("src/lib.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, }); - - // Set version - lib.version = .{ .major = 0, .minor = 1, .patch = 0 }; - - // Static library (.a) - const lib_static = b.addStaticLibrary(.{ - .name = "aerie", - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, + mod.addIncludePath(b.path("include")); + + // Shared library: libzig_api.so + const shared = b.addLibrary(.{ + .name = "zig_api", + .root_module = mod, + .linkage = .dynamic, + .version = .{ .major = 0, .minor = 1, .patch = 0 }, }); + b.installArtifact(shared); - // Install artifacts - b.installArtifact(lib); - b.installArtifact(lib_static); - - // Generate header file for C compatibility - const header = b.addInstallHeader( - b.path("include/aerie.h"), - "aerie.h", - ); - b.getInstallStep().dependOn(&header.step); - - // Unit tests - const lib_tests = b.addTest(.{ - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, + // Static library: libzig_api.a + const static = b.addLibrary(.{ + .name = "zig_api", + .root_module = mod, + .linkage = .static, }); + b.installArtifact(static); - const run_lib_tests = b.addRunArtifact(lib_tests); - - const test_step = b.step("test", "Run library tests"); - test_step.dependOn(&run_lib_tests.step); - - // Integration tests - const integration_tests = b.addTest(.{ - .root_source_file = b.path("test/integration_test.zig"), - .target = target, - .optimize = optimize, + // Alias for the Idris2 side (Foreign.idr declares `libaerie`). + const aerie_alias = b.addLibrary(.{ + .name = "aerie", + .root_module = mod, + .linkage = .static, }); + b.installArtifact(aerie_alias); - integration_tests.linkLibrary(lib); - - const run_integration_tests = b.addRunArtifact(integration_tests); - - const integration_test_step = b.step("test-integration", "Run integration tests"); - integration_test_step.dependOn(&run_integration_tests.step); - - // Documentation - const docs = b.addTest(.{ - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = .Debug, - }); - - const docs_step = b.step("docs", "Generate documentation"); - docs_step.dependOn(&b.addInstallDirectory(.{ - .source_dir = docs.getEmittedDocs(), - .install_dir = .prefix, - .install_subdir = "docs", - }).step); + // C header. + const header = b.addInstallHeaderFile(b.path("include/zig_api.h"), "zig_api.h"); + b.getInstallStep().dependOn(&header.step); - // Benchmark (if needed) - const bench = b.addExecutable(.{ - .name = "aerie-bench", - .root_source_file = b.path("bench/bench.zig"), - .target = target, - .optimize = .ReleaseFast, + // Tests (incl. ABI layout assertions against the header). + const test_mod = b.createModule(.{ + .root_source_file = b.path("src/lib.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, }); + test_mod.addIncludePath(b.path("include")); + const tests = b.addTest(.{ .root_module = test_mod }); + const run_tests = b.addRunArtifact(tests); - bench.linkLibrary(lib); - - const run_bench = b.addRunArtifact(bench); - - const bench_step = b.step("bench", "Run benchmarks"); - bench_step.dependOn(&run_bench.step); + const test_step = b.step("test", "Run FFI unit tests"); + test_step.dependOn(&run_tests.step); } diff --git a/ffi/zig/include/zig_api.h b/ffi/zig/include/zig_api.h new file mode 100644 index 0000000..76e3767 --- /dev/null +++ b/ffi/zig/include/zig_api.h @@ -0,0 +1,291 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +/* Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) */ +/* */ +/* */ +/* zig_api.h — C ABI for the in-repo gnosis server + connector pool */ +/* */ +/* Declared: src/abi/Gnosis.idr (aerie-owned, Idris2 source of truth) */ +/* Implemented: ffi/zig/src/{gnosis,connector}.zig (FFI = Zig, estate law) */ +/* */ +/* Superset-compatible with developer-ecosystem/zig-api: symbol names, tag */ +/* values and v1 struct layouts match, so the estate library can replace */ +/* this implementation without gateway changes. */ +/* */ +/* V2 EXTENSION (aerie): GnosisRequestV2 carries the raw query string and */ +/* the request headers, both of which v1 strips — v1 starves the policy */ +/* gate of X-Api-Key and the resolvers of query parameters. */ +/* */ +/* ABI-stable across patch versions. Minor bumps add symbols; major bumps */ +/* may remove them. Layout is asserted against this header by tests. */ + +#ifndef ZIG_API_H +#define ZIG_API_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ============================================================================ + * Version + * ========================================================================== */ + +/** Null-terminated version string, e.g. "0.1.0". */ +const char *uapi_version(void); + +/* ============================================================================ + * Library lifecycle + * ========================================================================== */ + +/** One-time initialisation; idempotent. Returns 0 on success. */ +uint8_t uapi_init(void); + +/** Tear down all servers and connectors; free library-level memory. */ +void uapi_teardown(void); + +/* ============================================================================ + * Result codes + * ========================================================================== */ + +#define UAPI_OK 0 +#define UAPI_ERR 1 +#define UAPI_INVALID_PARAM 2 +#define UAPI_OUT_OF_MEMORY 3 +#define UAPI_NULL_POINTER 4 +#define UAPI_PATH_DENIED 5 +#define UAPI_PROCESS_FAILED 6 +#define UAPI_TIMEOUT 7 +#define UAPI_NOT_FOUND 8 +#define UAPI_ALREADY_EXISTS 9 +#define UAPI_SLOT_EXHAUSTED 10 + +/* ============================================================================ + * ServerState tags + * ========================================================================== */ + +#define UAPI_SERVER_IDLE 0 +#define UAPI_SERVER_LISTENING 1 +#define UAPI_SERVER_DRAINING 2 +#define UAPI_SERVER_STOPPED 3 + +/* ============================================================================ + * HealthStatus tags + * ========================================================================== */ + +#define UAPI_HEALTH_SERVING 0 +#define UAPI_HEALTH_NOT_SERVING 1 + +/* ============================================================================ + * ConnectorState tags + * ========================================================================== */ + +#define UAPI_CONNECTOR_DISCONNECTED 0 +#define UAPI_CONNECTOR_CONNECTING 1 +#define UAPI_CONNECTOR_CONNECTED 2 +#define UAPI_CONNECTOR_DEGRADED 3 +#define UAPI_CONNECTOR_FAILED 4 +#define UAPI_CONNECTOR_DRAINING 5 + +/* ============================================================================ + * ServiceId tags + * ========================================================================== */ + +#define UAPI_SERVICE_AMBIENT_OPS 0 +#define UAPI_SERVICE_BOJ 1 +#define UAPI_SERVICE_BURBLE 2 +#define UAPI_SERVICE_ECHIDNA 3 +#define UAPI_SERVICE_GOSSAMER 4 +#define UAPI_SERVICE_GROOVE_BRIDGE 5 +#define UAPI_SERVICE_HYPATIA 6 +#define UAPI_SERVICE_IDAPTIK 7 +#define UAPI_SERVICE_REPOSYSTEM 8 +#define UAPI_SERVICE_STAPELN 9 +#define UAPI_SERVICE_VERISIMDB 10 + +/* ============================================================================ + * HTTP Method tags + * ========================================================================== */ + +#define UAPI_METHOD_GET 0 +#define UAPI_METHOD_POST 1 +#define UAPI_METHOD_PUT 2 +#define UAPI_METHOD_DELETE 3 +#define UAPI_METHOD_HEAD 4 +#define UAPI_METHOD_OPTIONS 5 +#define UAPI_METHOD_PATCH 6 + +/* ============================================================================ + * Gnosis API server (ffi/zig/src/gnosis.zig) + * ========================================================================== */ + +/** Request context for v1 edge handlers (query-stripped, no headers). */ +typedef struct { + const char *method; /**< HTTP method, e.g. "GET" (null-terminated). */ + const char *path; /**< Request path, query-stripped (null-terminated). */ + const uint8_t *body_ptr; /**< Request body bytes; NULL when empty. */ + uint32_t body_len; /**< Byte length of body_ptr; 0 when empty. */ +} GnosisRequest; + +/** + * V2 request context (aerie extension): v1 plus the raw query string and + * parallel header arrays. All pointers are valid only for the duration of + * the handler call. `header_names`/`header_values` are NULL when + * `header_count` is 0; otherwise each has exactly `header_count` entries. + */ +typedef struct { + const char *method; /**< HTTP method, e.g. "GET". */ + const char *path; /**< Query-stripped request path. */ + const char *query; /**< Raw query without '?'; "" when absent. */ + const uint8_t *body_ptr; /**< Request body bytes; NULL when empty. */ + uint32_t body_len; /**< Byte length of body_ptr. */ + const char *const *header_names; /**< NULL-terminated name strings, or NULL. */ + const char *const *header_values; /**< Parallel value strings, or NULL. */ + uint32_t header_count; /**< Number of valid header entries. */ + uint8_t *resp_scratch; /**< Per-connection response buffer. HANDLERS: + response bodies whose lifetime would end + with the handler (arenas, stacks) MUST be + copied here — the server writes after the + handler returns and frees the scratch. */ + uint32_t resp_scratch_len; /**< Byte length of resp_scratch. */ +} GnosisRequestV2; + +/** Response written by an edge handler. */ +typedef struct { + uint16_t status; /**< HTTP status code, e.g. 200, 404. */ + uint16_t _pad; /**< Reserved; set to 0. */ + const char *content_type; /**< MIME type string (null-terminated). */ + const uint8_t *body_ptr; /**< Response body; NULL for zero-length body. */ + uint32_t body_len; /**< Byte length of body_ptr. */ +} GnosisResponse; + +/** Create a gnosis server bound to `port`. Returns handle (non-zero) or 0. */ +uint64_t uapi_gnosis_create(uint16_t port); + +/** Start serving (binds on first start; spawns the serve thread). Idempotent. */ +uint8_t uapi_gnosis_start(uint64_t handle); + +/** Stop accepting, drain in-flight connections, join the serve thread. */ +void uapi_gnosis_stop(uint64_t handle); + +/** Destroy the handle (stops first if listening). */ +void uapi_gnosis_destroy(uint64_t handle); + +/** Query server state: a UAPI_SERVER_* tag. */ +uint8_t uapi_gnosis_state(uint64_t handle); + +/** Health probe: UAPI_HEALTH_SERVING (0) or UAPI_HEALTH_NOT_SERVING (1). */ +uint8_t uapi_gnosis_health(uint64_t handle); + +/** Register the v1 edge handler. Between create and start only. */ +uint8_t uapi_gnosis_set_handler( + uint64_t handle, + void (*handler_fn)(const GnosisRequest *req, GnosisResponse *resp) +); + +/** Register the v2 edge handler (query + headers). Takes precedence over v1. */ +uint8_t uapi_gnosis_set_handler_v2( + uint64_t handle, + void (*handler_fn)(const GnosisRequestV2 *req, GnosisResponse *resp) +); + +/** Convenience: fill all fields of a GnosisResponse in one call. */ +void uapi_gnosis_write_response( + GnosisResponse *resp, + uint16_t status, + const char *content_type, + const uint8_t *body_ptr, + uint32_t body_len +); + +/* ============================================================================ + * Service connector pool (ffi/zig/src/connector.zig) + * ========================================================================== */ + +/** Allocate a connector for `service_id` at `base_url`. Slot index or 255. */ +uint8_t uapi_connector_create(uint8_t service_id, const char *base_url); + +/** GET /health probe. Returns a UAPI_CONNECTOR_* tag. */ +uint8_t uapi_connector_health(uint8_t slot); + +/** + * Synchronous HTTP round-trip on `slot`. On UAPI_OK the response body is + * copied into `out_buf` (truncated to out_len-1 if larger) and + * null-terminated. Non-2xx statuses still return UAPI_OK — the body is + * the payload; only transport failures are errors. + */ +uint8_t uapi_connector_call( + uint8_t slot, + uint8_t method_tag, + const char *path, + const char *body, + uint8_t *out_buf, + uint32_t out_len +); + +/** Release the connector at `slot`. */ +void uapi_connector_destroy(uint8_t slot); + +/** Current UAPI_CONNECTOR_* tag for `slot`. */ +uint8_t uapi_connector_state(uint8_t slot); + +/* ============================================================================ + * Forensic search engine — UNTRUSTED (ffi/zig/src/kanren.zig) + * + * Design: docs/design/forensic-stack.adoc. The engine emits candidate + * attack paths as flat derivations; the trusted Idris2 kernel + * (src/abi/Forensics.idr, checkReach) accepts or rejects each against + * the evidence. A solver bug can only lose answers, never forge one. + * ========================================================================== */ + +/** Rule tags for RawStep. */ +#define KANREN_RULE_LATERAL 0 +#define KANREN_RULE_EXFIL 1 +#define KANREN_RULE_ENTRY 2 + +/** One raw step: a rule applied to a fact-id. Untrusted until checked. */ +typedef struct { + uint32_t fact_id; /**< Index into the evidence table. */ + uint8_t rule; /**< KANREN_RULE_*. */ + uint8_t _pad; + uint16_t _pad2; +} RawStep; + +/** A candidate derivation (flat step list). Arena-owned until kanren_free. */ +typedef struct { + const RawStep *steps; /**< NULL when len is 0. */ + uint32_t len; +} RawDeriv; + +/** Add one observed flow to the evidence table. Fact-id or 0xFFFFFFFF. */ +uint32_t kanren_add_flow( + const char *src, + const char *dst, + uint16_t port, + uint64_t bytes +); + +/** Clear the evidence table and release the derivation arena. */ +void kanren_clear(void); + +/** Release the derivation arena; callers hold nothing after this. */ +void kanren_free(void); + +/** + * Depth-bounded search: candidate paths from `src` ending in an exfil + * step. On return, *out points at an arena-owned RawDeriv array (valid + * until kanren_free). Returns the count. A count of 0 means "no + * candidate within budget" — distinct from "no path exists"; max_depth + * is a declared resource grade on the query (tropical budget seam). + */ +uint32_t kanren_attack_paths( + const char *src, + const RawDeriv **out, + uint32_t max_depth +); + +#ifdef __cplusplus +} +#endif + +#endif /* ZIG_API_H */ diff --git a/ffi/zig/src/main.zig b/ffi/zig/src/aerie.zig similarity index 97% rename from ffi/zig/src/main.zig rename to ffi/zig/src/aerie.zig index 4de022d..1ca4505 100644 --- a/ffi/zig/src/main.zig +++ b/ffi/zig/src/aerie.zig @@ -37,12 +37,10 @@ pub const Result = enum(c_int) { null_pointer = 4, }; -/// Library handle (opaque to prevent direct access) -pub const Handle = opaque { - // Internal state hidden from C +/// Library handle (opaque to C consumers; a plain struct in Zig). +pub const Handle = struct { allocator: std.mem.Allocator, initialized: bool, - // Add your fields here }; //============================================================================== @@ -209,7 +207,7 @@ export fn aerie_build_info() [*:0]const u8 { //============================================================================== /// Callback function type (C ABI) -pub const Callback = *const fn (u64, u32) callconv(.C) u32; +pub const Callback = *const fn (u64, u32) callconv(.c) u32; /// Register a callback export fn aerie_register_callback( diff --git a/ffi/zig/src/connector.zig b/ffi/zig/src/connector.zig new file mode 100644 index 0000000..3bcf3f9 --- /dev/null +++ b/ffi/zig/src/connector.zig @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// connector.zig — service connector pool: a minimal, correct HTTP/1.1 +// client behind the uapi_connector_* C ABI (declared in src/abi/Gnosis.idr, +// header ffi/zig/include/zig_api.h). +// +// Superset-compatible with developer-ecosystem/zig-api semantics: +// * transport success => UAPI_OK and the response body copied into the +// caller's buffer, truncated to out_len-1, null-terminated; +// * non-2xx statuses are NOT errors (the body is the payload — clients +// parse it); +// * only transport failures (connect/send/recv/timeout) return errors. +// +// Scope (prototype phase): http:// base URLs only (the compose topology is +// all-internal http; TLS terminates at the reverse proxy), Connection: +// close per call, Content-Length responses only. Connect itself blocks on +// DNS/TCP (documented; the resilience aspect in the gateway adds the +// timeouts that matter). + +const std = @import("std"); +const core = @import("core.zig"); + +const MAX_CONNECTORS: usize = 64; +const MAX_URL_LEN: usize = 256; +const IO_TIMEOUT_S: u32 = 5; +const MAX_RESPONSE_BYTES: usize = 4 << 20; // 4 MiB read cap + +const Connector = struct { + active: bool = false, + service_id: u8 = 0, + host: [MAX_URL_LEN]u8 = undefined, + host_len: usize = 0, + port: u16 = 80, + state: core.ConnectorState = .disconnected, + requests_ok: u64 = 0, + requests_err: u64 = 0, +}; + +var pool: [MAX_CONNECTORS]Connector = [1]Connector{.{}} ** MAX_CONNECTORS; +var pool_mutex: std.Thread.Mutex = .{}; + +/// Parse "http://host[:port][/...]" into host + port. Returns null on +/// malformed input or a non-http scheme. +fn parseBaseUrl(url: []const u8) ?struct { host: []const u8, port: u16 } { + if (!std.mem.startsWith(u8, url, "http://") or url.len <= "http://".len) return null; + const after = url["http://".len..]; + const authority = if (std.mem.indexOfScalar(u8, after, '/')) |i| after[0..i] else after; + // Reject userinfo (not used in the compose topology) — fail closed. + if (std.mem.indexOfScalar(u8, authority, '@') != null) return null; + if (std.mem.indexOfScalar(u8, authority, ':')) |ci| { + const host = authority[0..ci]; + const port = std.fmt.parseInt(u16, authority[ci + 1 ..], 10) catch return null; + if (host.len == 0) return null; + return .{ .host = host, .port = port }; + } + if (authority.len == 0) return null; + return .{ .host = authority, .port = 80 }; +} + +// --------------------------------------------------------------------------- +// C ABI +// --------------------------------------------------------------------------- + +pub export fn uapi_connector_create(service_id: u8, base_url: ?[*:0]const u8) callconv(.c) u8 { + const url = std.mem.span(base_url orelse return 255); + const parsed = parseBaseUrl(url) orelse { + core.setError("connector: bad base_url (http://host[:port] only)", .{}); + return 255; + }; + if (parsed.host.len >= MAX_URL_LEN) return 255; + + pool_mutex.lock(); + defer pool_mutex.unlock(); + for (&pool, 0..) |*slot, i| { + if (!slot.active) { + slot.* = .{ + .active = true, + .service_id = service_id, + .port = parsed.port, + .state = .disconnected, + }; + @memcpy(slot.host[0..parsed.host.len], parsed.host); + slot.host_len = parsed.host.len; + return @intCast(i); + } + } + core.setError("connector: pool exhausted ({d})", .{MAX_CONNECTORS}); + return 255; +} + +pub export fn uapi_connector_destroy(slot_idx: u8) callconv(.c) void { + if (slot_idx >= MAX_CONNECTORS) return; + pool_mutex.lock(); + defer pool_mutex.unlock(); + pool[slot_idx] = .{}; +} + +pub export fn uapi_connector_state(slot_idx: u8) callconv(.c) u8 { + if (slot_idx >= MAX_CONNECTORS) return @intFromEnum(core.ConnectorState.failed); + pool_mutex.lock(); + defer pool_mutex.unlock(); + if (!pool[slot_idx].active) return @intFromEnum(core.ConnectorState.disconnected); + return @intFromEnum(pool[slot_idx].state); +} + +pub export fn uapi_connector_health(slot_idx: u8) callconv(.c) u8 { + if (slot_idx >= MAX_CONNECTORS) return @intFromEnum(core.ConnectorState.failed); + var path_buf: [16]u8 = undefined; + @memcpy(path_buf[0.."/health".len], "/health"); + var out: [256]u8 = undefined; + const rc = uapi_connector_call(slot_idx, 0, @ptrCast(&path_buf), "", &out, out.len); + return if (rc == core.Result.ok.toU8()) + @intFromEnum(core.ConnectorState.connected) + else + @intFromEnum(core.ConnectorState.failed); +} + +pub export fn uapi_connector_call( + slot_idx: u8, + method_tag: u8, + path_ptr: ?[*:0]const u8, + body_ptr: ?[*:0]const u8, + out_buf: ?[*]u8, + out_len: u32, +) callconv(.c) u8 { + if (slot_idx >= MAX_CONNECTORS or method_tag > 6) { + return core.Result.invalid_param.toU8(); + } + const path = std.mem.span(path_ptr orelse return core.Result.null_pointer.toU8()); + const body = if (body_ptr) |bp| std.mem.span(bp) else ""; + const dest = out_buf orelse return core.Result.null_pointer.toU8(); + if (out_len == 0) return core.Result.invalid_param.toU8(); + + pool_mutex.lock(); + const host_len = pool[slot_idx].host_len; + var host_copy: [MAX_URL_LEN]u8 = undefined; + @memcpy(host_copy[0..host_len], pool[slot_idx].host[0..host_len]); + const port = pool[slot_idx].port; + pool_mutex.unlock(); + const host = host_copy[0..host_len]; + + const method: core.HttpMethod = @enumFromInt(method_tag); + + const ok = httpRoundTrip(host, port, method, path, body, dest[0..out_len]); + if (ok) { + pool_mutex.lock(); + if (pool[slot_idx].active) { + pool[slot_idx].state = .connected; + pool[slot_idx].requests_ok += 1; + } + pool_mutex.unlock(); + return core.Result.ok.toU8(); + } + pool_mutex.lock(); + if (pool[slot_idx].active) { + pool[slot_idx].state = .failed; + pool[slot_idx].requests_err += 1; + } + pool_mutex.unlock(); + return core.Result.process_failed.toU8(); +} + +// --------------------------------------------------------------------------- +// HTTP/1.1 client +// --------------------------------------------------------------------------- + +fn setStreamTimeouts(stream: std.net.Stream, seconds: u32) void { + const tv = std.posix.timeval{ .sec = @intCast(seconds), .usec = 0 }; + const bytes = std.mem.asBytes(&tv); + std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, bytes) catch {}; + std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.SNDTIMEO, bytes) catch {}; +} + +fn readLine(stream: std.net.Stream, buf: []u8) ![]u8 { + var pos: usize = 0; + while (pos < buf.len) { + var one: [1]u8 = undefined; + const n = try stream.read(&one); + if (n == 0) break; + if (one[0] == '\n') { + const end = if (pos > 0 and buf[pos - 1] == '\r') pos - 1 else pos; + return buf[0..end]; + } + buf[pos] = one[0]; + pos += 1; + } + return error.EndOfStream; +} + +/// One HTTP/1.1 round-trip. Copies the response body into `dest` +/// (truncating to dest.len-1) and null-terminates. Returns false on any +/// transport error. +fn httpRoundTrip( + host: []const u8, + port: u16, + method: core.HttpMethod, + path: []const u8, + body: []const u8, + dest: []u8, +) bool { + var arena_inst = std.heap.ArenaAllocator.init(std.heap.c_allocator); + defer arena_inst.deinit(); + const arena = arena_inst.allocator(); + + const stream = std.net.tcpConnectToHost(arena, host, port) catch return false; + defer stream.close(); + setStreamTimeouts(stream, IO_TIMEOUT_S); + + // --- Request ------------------------------------------------------------ + var req_buf: [4096]u8 = undefined; + const req = std.fmt.bufPrint( + &req_buf, + "{s} {s} HTTP/1.1\r\n" ++ + "Host: {s}\r\n" ++ + "Content-Length: {d}\r\n" ++ + "Content-Type: application/json\r\n" ++ + "Connection: close\r\n" ++ + "\r\n", + .{ method.text(), path, host, body.len }, + ) catch return false; + stream.writeAll(req) catch return false; + if (body.len > 0) stream.writeAll(body) catch return false; + + // --- Status line (parse but do not gate on the code) ------------------- + var line_buf: [4096]u8 = undefined; + const status_line = readLine(stream, &line_buf) catch return false; + _ = status_line; + + // --- Headers ------------------------------------------------------------ + var content_length: ?usize = null; + while (true) { + const line = readLine(stream, &line_buf) catch return false; + if (line.len == 0) break; + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + const v = std.mem.trim(u8, line["content-length:".len..], " \t"); + content_length = std.fmt.parseInt(usize, v, 10) catch null; + } + } + + // --- Body ---------------------------------------------------------------- + // The read cap is 4 MiB — heap, not stack. + const body_buf = arena.alloc(u8, MAX_RESPONSE_BYTES) catch return false; + var got: usize = 0; + if (content_length) |cl| { + const want = @min(cl, body_buf.len); + while (got < want) { + const n = stream.read(body_buf[got..want]) catch break; + if (n == 0) break; + got += n; + } + } else { + // No Content-Length: read to EOF (Connection: close). + while (got < body_buf.len) { + const n = stream.read(body_buf[got..]) catch break; + if (n == 0) break; + got += n; + } + } + + // Copy out, null-terminate, truncate as documented. + const n_copy = @min(got, dest.len - 1); + @memcpy(dest[0..n_copy], body_buf[0..n_copy]); + dest[n_copy] = 0; + return true; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "connector: base_url parsing" { + const ok1 = parseBaseUrl("http://librespeed:8080").?; + try std.testing.expectEqualStrings("librespeed", ok1.host); + try std.testing.expectEqual(@as(u16, 8080), ok1.port); + + const ok2 = parseBaseUrl("http://hyperglass/").?; + try std.testing.expectEqualStrings("hyperglass", ok2.host); + try std.testing.expectEqual(@as(u16, 80), ok2.port); + + try std.testing.expect(parseBaseUrl("https://x") == null); + try std.testing.expect(parseBaseUrl("http://") == null); + try std.testing.expect(parseBaseUrl("ftp://h") == null); + try std.testing.expect(parseBaseUrl("http://u:p@h") == null); +} + +test "connector: create/destroy/state lifecycle" { + const url: [*:0]const u8 = "http://127.0.0.1:1"; // port 1: nothing listens + const slot = uapi_connector_create(0, url); + try std.testing.expect(slot != 255); + defer uapi_connector_destroy(slot); + + try std.testing.expectEqual(@as(u8, 0), uapi_connector_state(slot)); // disconnected + + var out: [64]u8 = undefined; + const path: [*:0]const u8 = "/"; + const rc = uapi_connector_call(slot, 0, path, "", &out, out.len); + try std.testing.expectEqual(@as(u8, 6), rc); // process_failed: transport + try std.testing.expectEqual(@as(u8, 4), uapi_connector_state(slot)); // failed + + uapi_connector_destroy(slot); + try std.testing.expectEqual(@as(u8, 0), uapi_connector_state(slot)); // disconnected +} + +test "connector: invalid args" { + var out: [8]u8 = undefined; + const path: [*:0]const u8 = "/"; + try std.testing.expectEqual(@as(u8, 255), uapi_connector_create(0, null)); + try std.testing.expectEqual(@as(u8, 2), uapi_connector_call(200, 0, path, null, &out, 8)); // invalid slot + try std.testing.expectEqual(@as(u8, 2), uapi_connector_call(0, 99, path, null, &out, 8)); // invalid method +} diff --git a/ffi/zig/src/core.zig b/ffi/zig/src/core.zig new file mode 100644 index 0000000..0f7d051 --- /dev/null +++ b/ffi/zig/src/core.zig @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// core.zig — shared result codes, error slot and constants for the +// in-reco zig_api FFI (declared in src/abi/Gnosis.idr). +// +// The wire values are fixed by ffi/zig/include/zig_api.h and must not +// drift; the comptime block below pins them to the header contract. + +const std = @import("std"); + +/// Result codes — UAPI_* tags (zig_api.h). +pub const Result = enum(u8) { + ok = 0, + err = 1, + invalid_param = 2, + out_of_memory = 3, + null_pointer = 4, + path_denied = 5, + process_failed = 6, + timeout = 7, + not_found = 8, + already_exists = 9, + slot_exhausted = 10, + + pub fn toU8(self: Result) u8 { + return @intFromEnum(self); + } +}; + +/// Server states — UAPI_SERVER_* tags. +pub const ServerState = enum(u8) { + idle = 0, + listening = 1, + draining = 2, + stopped = 3, +}; + +/// Connector states — UAPI_CONNECTOR_* tags. +pub const ConnectorState = enum(u8) { + disconnected = 0, + connecting = 1, + connected = 2, + degraded = 3, + failed = 4, + draining = 5, +}; + +/// HTTP methods — UAPI_METHOD_* tags. +pub const HttpMethod = enum(u8) { + get = 0, + post = 1, + put = 2, + delete = 3, + head = 4, + options = 5, + patch = 6, + + pub fn text(self: HttpMethod) []const u8 { + return switch (self) { + .get => "GET", .post => "POST", .put => "PUT", + .delete => "DELETE", .head => "HEAD", .options => "OPTIONS", + .patch => "PATCH", + }; + } +}; + +comptime { + // Pin the tag values to the header contract at compile time. + std.debug.assert(@intFromEnum(Result.ok) == 0); + std.debug.assert(@intFromEnum(Result.slot_exhausted) == 10); + std.debug.assert(@intFromEnum(ServerState.listening) == 1); + std.debug.assert(@intFromEnum(ConnectorState.draining) == 5); + std.debug.assert(@intFromEnum(HttpMethod.patch) == 6); +} + +/// Library version (kept in step with the aerie gateway version). +pub const VERSION = "0.1.0"; + +/// Thread-local last-error slot (mirrors the libaerie surface convention). +threadlocal var last_error_buf: [256]u8 = undefined; +threadlocal var last_error_len: usize = 0; + +/// Record an error message for uapi-level diagnostics. +pub fn setError(comptime fmt: []const u8, args: anytype) void { + const written = std.fmt.bufPrint(&last_error_buf, fmt, args) catch { + last_error_len = last_error_buf.len; + return; + }; + last_error_len = written.len; +} + +/// Read the last error recorded on this thread (empty when none). +pub fn lastError() []const u8 { + return last_error_buf[0..last_error_len]; +} + +pub fn clearError() void { + last_error_len = 0; +} diff --git a/ffi/zig/src/gnosis.zig b/ffi/zig/src/gnosis.zig new file mode 100644 index 0000000..e81e07a --- /dev/null +++ b/ffi/zig/src/gnosis.zig @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// gnosis.zig — in-repo gnosis server pool: a small, correct, threaded +// HTTP/1.1 edge server behind the uapi_gnosis_* C ABI (declared in +// src/abi/Gnosis.idr, header ffi/zig/include/zig_api.h). +// +// Superset-compatible with developer-ecosystem/zig-api (same symbols, tags +// and v1 layouts); the estate library may replace this file wholesale. +// +// Deliberate scope (prototype phase): origin-form requests, Content-Length +// bodies (no chunked), Connection: close on every response, thread-per- +// connection with a hard concurrency cap, socket read timeouts. No TLS — +// TLS terminates at the reverse proxy, as in the compose topology. +// +// V2 (aerie extension): handlers registered via uapi_gnosis_set_handler_v2 +// receive the raw query string and every request header, fixing the v1 +// information loss that starved the policy gate (X-Api-Key) and the +// resolvers (query parameters). + +const std = @import("std"); +const core = @import("core.zig"); + +// --------------------------------------------------------------------------- +// Wire types (must match zig_api.h exactly; asserted by tests below) +// --------------------------------------------------------------------------- + +pub const GnosisRequest = extern struct { + method: [*:0]const u8, + path: [*:0]const u8, + body_ptr: ?[*]const u8, + body_len: u32, +}; + +pub const GnosisRequestV2 = extern struct { + method: [*:0]const u8, + path: [*:0]const u8, + query: [*:0]const u8, + body_ptr: ?[*]const u8, + body_len: u32, + header_names: ?[*]const ?[*:0]const u8, + header_values: ?[*]const ?[*:0]const u8, + header_count: u32, + resp_scratch: ?[*]u8, + resp_scratch_len: u32, +}; + +pub const GnosisResponse = extern struct { + status: u16, + _pad: u16 = 0, + content_type: ?[*:0]const u8, + body_ptr: ?[*]const u8, + body_len: u32, +}; + +pub const HandlerFn = *const fn ([*c]const GnosisRequest, [*c]GnosisResponse) callconv(.c) void; +pub const HandlerFnV2 = *const fn ([*c]const GnosisRequestV2, [*c]GnosisResponse) callconv(.c) void; + +// --------------------------------------------------------------------------- +// Tunables +// --------------------------------------------------------------------------- + +const MAX_SERVERS: usize = 16; +const MAX_CONNECTIONS: u32 = 64; // concurrent connection cap +const MAX_HEADER_BYTES: usize = 16 * 1024; // header block cap +const MAX_BODY_BYTES: usize = 1 << 20; // 1 MiB +const MAX_HEADERS: usize = 64; +const RESP_SCRATCH_BYTES: usize = 128 * 1024; // per-connection response scratch +const READ_TIMEOUT_S: u32 = 10; +const BACKLOG: u32 = 128; + +// --------------------------------------------------------------------------- +// Server pool +// --------------------------------------------------------------------------- + +const Server = struct { + port: u16, + handler: ?HandlerFn = null, + handler_v2: ?HandlerFnV2 = null, + state: std.atomic.Value(u8) = std.atomic.Value(u8).init(@intFromEnum(core.ServerState.idle)), + running: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + thread: ?std.Thread = null, + listener: ?std.net.Server = null, + active_conns: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), +}; + +var servers: [MAX_SERVERS]?*Server = [1]?*Server{null} ** MAX_SERVERS; +var servers_mutex: std.Thread.Mutex = .{}; +var lib_alloc: std.mem.Allocator = std.heap.c_allocator; + +/// Handle scheme: slot index + 1, so 0 stays "failure". +fn handleFromSlot(slot: usize) u64 { + return @intCast(slot + 1); +} + +fn slotFromHandle(handle: u64) ?usize { + if (handle == 0 or handle > MAX_SERVERS) return null; + return @intCast(handle - 1); +} + +// --------------------------------------------------------------------------- +// C ABI — gnosis lifecycle +// --------------------------------------------------------------------------- + +pub export fn uapi_gnosis_create(port: u16) callconv(.c) u64 { + servers_mutex.lock(); + defer servers_mutex.unlock(); + for (&servers, 0..) |*slot, i| { + if (slot.* == null) { + const srv = lib_alloc.create(Server) catch { + core.setError("gnosis: alloc failed", .{}); + return 0; + }; + srv.* = .{ .port = port }; + slot.* = srv; + return handleFromSlot(i); + } + } + core.setError("gnosis: pool exhausted ({d} slots)", .{MAX_SERVERS}); + return 0; +} + +pub export fn uapi_gnosis_set_handler( + handle: u64, + handler_fn: ?HandlerFn, +) callconv(.c) u8 { + servers_mutex.lock(); + defer servers_mutex.unlock(); + const slot = slotFromHandle(handle) orelse return core.Result.invalid_param.toU8(); + const srv = servers[slot] orelse return core.Result.invalid_param.toU8(); + if (srv.state.load(.acquire) == @intFromEnum(core.ServerState.listening)) { + return core.Result.err.toU8(); // no hot-swap, per ABI contract + } + srv.handler = handler_fn; + return core.Result.ok.toU8(); +} + +pub export fn uapi_gnosis_set_handler_v2( + handle: u64, + handler_fn: ?HandlerFnV2, +) callconv(.c) u8 { + servers_mutex.lock(); + defer servers_mutex.unlock(); + const slot = slotFromHandle(handle) orelse return core.Result.invalid_param.toU8(); + const srv = servers[slot] orelse return core.Result.invalid_param.toU8(); + if (srv.state.load(.acquire) == @intFromEnum(core.ServerState.listening)) { + return core.Result.err.toU8(); + } + srv.handler_v2 = handler_fn; + return core.Result.ok.toU8(); +} + +pub export fn uapi_gnosis_start(handle: u64) callconv(.c) u8 { + servers_mutex.lock(); + const slot = slotFromHandle(handle) orelse { + servers_mutex.unlock(); + return core.Result.invalid_param.toU8(); + }; + const srv = servers[slot] orelse { + servers_mutex.unlock(); + return core.Result.invalid_param.toU8(); + }; + + // Idempotent when already listening. + if (srv.state.load(.acquire) == @intFromEnum(core.ServerState.listening)) { + servers_mutex.unlock(); + return core.Result.ok.toU8(); + } + if (srv.state.load(.acquire) == @intFromEnum(core.ServerState.draining)) { + servers_mutex.unlock(); + return core.Result.err.toU8(); + } + + // Bind (first start, or re-bind after a stop closed the listener). + if (srv.listener == null) { + const addr = std.net.Address.parseIp("0.0.0.0", srv.port) catch { + servers_mutex.unlock(); + core.setError("gnosis: bad bind address", .{}); + return core.Result.invalid_param.toU8(); + }; + srv.listener = addr.listen(.{ .reuse_address = true, .kernel_backlog = BACKLOG }) catch |e| { + servers_mutex.unlock(); + core.setError("gnosis: bind :{d} failed ({})", .{ srv.port, e }); + return core.Result.err.toU8(); + }; + } + + srv.running.store(true, .release); + srv.state.store(@intFromEnum(core.ServerState.listening), .release); + const bound_listener = srv.listener.?; + servers_mutex.unlock(); + + // Serve on the current thread? No — spawn, per ABI ("background thread"). + srv.thread = std.Thread.spawn(.{ .stack_size = 1 << 20 }, serveLoop, .{ srv, bound_listener }) catch { + srv.running.store(false, .release); + srv.state.store(@intFromEnum(core.ServerState.stopped), .release); + core.setError("gnosis: thread spawn failed", .{}); + return core.Result.err.toU8(); + }; + return core.Result.ok.toU8(); +} + +pub export fn uapi_gnosis_stop(handle: u64) callconv(.c) void { + servers_mutex.lock(); + const slot = slotFromHandle(handle) orelse { + servers_mutex.unlock(); + return; + }; + const srv = servers[slot] orelse { + servers_mutex.unlock(); + return; + }; + servers_mutex.unlock(); + + srv.state.store(@intFromEnum(core.ServerState.draining), .release); + srv.running.store(false, .release); + + // Closing the listener forces accept() to return; the loop then exits. + if (srv.listener) |*l| { + var stale = l.*; + srv.listener = null; + stale.deinit(); + } + + if (srv.thread) |t| { + t.join(); + srv.thread = null; + } + srv.state.store(@intFromEnum(core.ServerState.stopped), .release); +} + +pub export fn uapi_gnosis_destroy(handle: u64) callconv(.c) void { + servers_mutex.lock(); + const slot = slotFromHandle(handle) orelse { + servers_mutex.unlock(); + return; + }; + const srv = servers[slot] orelse { + servers_mutex.unlock(); + return; + }; + servers[slot] = null; + servers_mutex.unlock(); + + if (srv.state.load(.acquire) != @intFromEnum(core.ServerState.stopped)) { + uapi_gnosis_stop(handle); + } + lib_alloc.destroy(srv); +} + +pub export fn uapi_gnosis_state(handle: u64) callconv(.c) u8 { + servers_mutex.lock(); + defer servers_mutex.unlock(); + const slot = slotFromHandle(handle) orelse return @intFromEnum(core.ServerState.stopped); + const srv = servers[slot] orelse return @intFromEnum(core.ServerState.stopped); + return srv.state.load(.acquire); +} + +pub export fn uapi_gnosis_health(handle: u64) callconv(.c) u8 { + const st = uapi_gnosis_state(handle); + return if (st == @intFromEnum(core.ServerState.listening)) 0 else 1; +} + +pub export fn uapi_gnosis_write_response( + resp: [*c]GnosisResponse, + status: u16, + content_type: ?[*:0]const u8, + body_ptr: ?[*]const u8, + body_len: u32, +) callconv(.c) void { + const r: *GnosisResponse = @ptrCast(resp); + r.status = status; + r._pad = 0; + r.content_type = content_type; + r.body_ptr = body_ptr; + r.body_len = body_len; +} + +// --------------------------------------------------------------------------- +// Serve loop + per-connection handling +// --------------------------------------------------------------------------- + +fn serveLoop(srv: *Server, listener_in: std.net.Server) void { + // Owned copy of the listener (same fd). stop() closes the fd through + // its own copy to unblock accept(); this side never deinit()s. + var listener = listener_in; + while (srv.running.load(.acquire)) { + const conn = listener.accept() catch break; + if (srv.active_conns.load(.monotonic) >= MAX_CONNECTIONS) { + conn.stream.close(); + continue; + } + _ = srv.active_conns.fetchAdd(1, .acq_rel); + const t = std.Thread.spawn(.{ .stack_size = 1 << 20 }, handleConn, .{ srv, conn }) catch { + _ = srv.active_conns.fetchSub(1, .acq_rel); + conn.stream.close(); + continue; + }; + t.detach(); + } + // Listener ownership: stop() closes it to unblock accept(). If the + // loop exited on its own (accept error), keep the fd for a restart + // but reflect the honest state. + if (srv.running.load(.acquire)) { + srv.state.store(@intFromEnum(core.ServerState.stopped), .release); + } +} + +fn setStreamTimeouts(stream: std.net.Stream, seconds: u32) void { + const tv = std.posix.timeval{ .sec = @intCast(seconds), .usec = 0 }; + const bytes = std.mem.asBytes(&tv); + std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, bytes) catch {}; + std.posix.setsockopt(stream.handle, std.posix.SOL.SOCKET, std.posix.SO.SNDTIMEO, bytes) catch {}; +} + +/// One parsed request, with all storage in this frame. +const ParsedRequest = struct { + method_buf: [16]u8 = undefined, + method_len: usize = 0, + path_buf: [1024]u8 = undefined, + path_len: usize = 0, // query-stripped + query_buf: [2048]u8 = undefined, + query_len: usize = 0, // raw query, no '?' + headers: [MAX_HEADERS]Header = undefined, + header_count: usize = 0, + body_buf: [MAX_BODY_BYTES]u8 = undefined, + body_len: usize = 0, + + const Header = struct { + name_buf: [128]u8 = undefined, + name_len: usize = 0, + value_buf: [512]u8 = undefined, + value_len: usize = 0, + }; +}; + +fn readLine(stream: std.net.Stream, buf: []u8) ![]u8 { + var pos: usize = 0; + while (pos < buf.len) { + var one: [1]u8 = undefined; + const n = try stream.read(&one); + if (n == 0) break; + if (one[0] == '\n') { + const end = if (pos > 0 and buf[pos - 1] == '\r') pos - 1 else pos; + return buf[0..end]; + } + buf[pos] = one[0]; + pos += 1; + } + return buf[0..pos]; +} + +fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { + defer _ = srv.active_conns.fetchSub(1, .acq_rel); + defer conn.stream.close(); + setStreamTimeouts(conn.stream, READ_TIMEOUT_S); + + // ParsedRequest carries ~1.1 MiB of fixed buffers (1 MiB body cap) — + // far beyond a sane thread stack, so it lives on the heap. + const req = lib_alloc.create(ParsedRequest) catch return; + defer lib_alloc.destroy(req); + req.* = .{}; + var line_buf: [4096]u8 = undefined; + + // --- Request line ----------------------------------------------------- + const req_line = readLine(conn.stream, &line_buf) catch return; + var parts = std.mem.splitScalar(u8, req_line, ' '); + const method = parts.next() orelse return; + const target = parts.next() orelse return; + if (method.len >= req.method_buf.len or target.len >= req.path_buf.len + req.query_buf.len) return; + + @memcpy(req.method_buf[0..method.len], method); + req.method_len = method.len; + + // Split target into path + query. + const q_idx = std.mem.indexOfScalar(u8, target, '?'); + const raw_path = if (q_idx) |qi| target[0..qi] else target; + const raw_query = if (q_idx) |qi| target[qi + 1 ..] else ""; + if (raw_path.len >= req.path_buf.len or raw_query.len >= req.query_buf.len) return; + @memcpy(req.path_buf[0..raw_path.len], raw_path); + req.path_len = raw_path.len; + @memcpy(req.query_buf[0..raw_query.len], raw_query); + req.query_len = raw_query.len; + + // --- Headers ------------------------------------------------------------ + var header_bytes: usize = 0; + var content_length: usize = 0; + while (true) { + const line = readLine(conn.stream, &line_buf) catch break; + if (line.len == 0) break; // end of headers + header_bytes += line.len; + if (header_bytes > MAX_HEADER_BYTES) return; + + if (req.header_count < MAX_HEADERS) { + const h = &req.headers[req.header_count]; + if (std.mem.indexOfScalar(u8, line, ':')) |ci| { + const name = std.mem.trim(u8, line[0..ci], " \t"); + const value = std.mem.trim(u8, line[ci + 1 ..], " \t"); + if (name.len < h.name_buf.len and value.len < h.value_buf.len) { + @memcpy(h.name_buf[0..name.len], name); + h.name_len = name.len; + @memcpy(h.value_buf[0..value.len], value); + h.value_len = value.len; + req.header_count += 1; + } + } + } + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + const v = std.mem.trim(u8, line["content-length:".len..], " \t"); + content_length = std.fmt.parseInt(usize, v, 10) catch 0; + } + } + + // --- Body --------------------------------------------------------------- + if (content_length > 0) { + if (content_length > MAX_BODY_BYTES) content_length = MAX_BODY_BYTES; + var got: usize = 0; + while (got < content_length) { + const n = conn.stream.read(req.body_buf[got..content_length]) catch break; + if (n == 0) break; + got += n; + } + req.body_len = got; + } + + // --- Dispatch to the registered handler -------------------------------- + // Method/path/query need null-termination for the C ABI; the fixed + // buffers have room because the lengths were bounds-checked on entry. + req.method_buf[req.method_len] = 0; + req.path_buf[req.path_len] = 0; + req.query_buf[req.query_len] = 0; + for (req.headers[0..req.header_count]) |*h| { + h.name_buf[h.name_len] = 0; + h.value_buf[h.value_len] = 0; + } + + var resp: GnosisResponse = .{ + .status = 500, + .content_type = null, + .body_ptr = null, + .body_len = 0, + }; + + // Response scratch: gnosis-owned, outlives the handler call, freed + // after the socket write (function scope — a block-scoped defer would + // free it before writeGnosisResponse, the exact bug class this buffer + // exists to prevent). + const scratch = lib_alloc.alloc(u8, RESP_SCRATCH_BYTES) catch null; + defer if (scratch) |sc| lib_alloc.free(sc); + + if (srv.handler_v2) |h2| { + var names: [MAX_HEADERS]?[*:0]const u8 = undefined; + var values: [MAX_HEADERS]?[*:0]const u8 = undefined; + for (req.headers[0..req.header_count], 0..) |*h, i| { + names[i] = @ptrCast(&h.name_buf); + values[i] = @ptrCast(&h.value_buf); + } + const v2 = GnosisRequestV2{ + .method = @ptrCast(&req.method_buf), + .path = @ptrCast(&req.path_buf), + .query = @ptrCast(&req.query_buf), + .body_ptr = if (req.body_len > 0) @ptrCast(&req.body_buf) else null, + .body_len = @intCast(req.body_len), + .header_names = if (req.header_count > 0) &names else null, + .header_values = if (req.header_count > 0) &values else null, + .header_count = @intCast(req.header_count), + .resp_scratch = if (scratch) |sc| sc.ptr else null, + .resp_scratch_len = if (scratch) |sc| @intCast(sc.len) else 0, + }; + h2(&v2, &resp); + } else if (srv.handler) |h1| { + const v1 = GnosisRequest{ + .method = @ptrCast(&req.method_buf), + .path = @ptrCast(&req.path_buf), + .body_ptr = if (req.body_len > 0) @ptrCast(&req.body_buf) else null, + .body_len = @intCast(req.body_len), + }; + h1(&v1, &resp); + } else { + resp.status = 503; + resp.content_type = "application/json"; + resp.body_ptr = "{\"error\":\"no handler registered\"}"; + resp.body_len = 31; + } + + writeGnosisResponse(conn.stream, &resp); +} + +fn reasonPhrase(status: u16) []const u8 { + return switch (status) { + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 413 => "Payload Too Large", + 429 => "Too Many Requests", + 500 => "Internal Server Error", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + 504 => "Gateway Timeout", + else => "Response", + }; +} + +fn writeGnosisResponse(stream: std.net.Stream, resp: *const GnosisResponse) void { + var head_buf: [512]u8 = undefined; + const ct: []const u8 = if (resp.content_type) |p| std.mem.span(p) else "application/json"; + const body: []const u8 = if (resp.body_ptr) |p| p[0..resp.body_len] else ""; + const head = std.fmt.bufPrint( + &head_buf, + "HTTP/1.1 {d} {s}\r\n" ++ + "Content-Type: {s}\r\n" ++ + "Content-Length: {d}\r\n" ++ + "Connection: close\r\n" ++ + "\r\n", + .{ resp.status, reasonPhrase(resp.status), ct, body.len }, + ) catch return; + stream.writeAll(head) catch {}; + if (body.len > 0) stream.writeAll(body) catch {}; +} + +// --------------------------------------------------------------------------- +// Tests — including the ABI layout assertion against zig_api.h +// --------------------------------------------------------------------------- + +test "gnosis: v1/v2 wire structs match zig_api.h layout" { + const h = @cImport({ + @cInclude("zig_api.h"); + }); + try std.testing.expectEqual(@sizeOf(h.GnosisRequest), @sizeOf(GnosisRequest)); + try std.testing.expectEqual(@offsetOf(h.GnosisRequest, "body_len"), @offsetOf(GnosisRequest, "body_len")); + try std.testing.expectEqual(@sizeOf(h.GnosisRequestV2), @sizeOf(GnosisRequestV2)); + try std.testing.expectEqual(@offsetOf(h.GnosisRequestV2, "header_count"), @offsetOf(GnosisRequestV2, "header_count")); + try std.testing.expectEqual(@offsetOf(h.GnosisRequestV2, "resp_scratch"), @offsetOf(GnosisRequestV2, "resp_scratch")); + try std.testing.expectEqual(@offsetOf(h.GnosisRequestV2, "resp_scratch_len"), @offsetOf(GnosisRequestV2, "resp_scratch_len")); + try std.testing.expectEqual(@sizeOf(h.GnosisResponse), @sizeOf(GnosisResponse)); + try std.testing.expectEqual(@offsetOf(h.GnosisResponse, "body_len"), @offsetOf(GnosisResponse, "body_len")); +} + +test "gnosis: create/start/stop/destroy lifecycle" { + const handle = uapi_gnosis_create(0); // port 0: OS-assigned + try std.testing.expect(handle != 0); + defer uapi_gnosis_destroy(handle); + + const H = struct { + fn handler(req: [*c]const GnosisRequest, resp: [*c]GnosisResponse) callconv(.c) void { + _ = req; + resp.*.status = 200; + resp.*.content_type = "application/json"; + resp.*.body_ptr = "{\"ok\":true}"; + resp.*.body_len = 10; + } + }; + try std.testing.expectEqual(@as(u8, 0), uapi_gnosis_set_handler(handle, H.handler)); + + // Port 0 cannot be dialed externally; start still must reach LISTENING. + try std.testing.expectEqual(@as(u8, 0), uapi_gnosis_start(handle)); + try std.testing.expectEqual(@as(u8, 1), uapi_gnosis_state(handle)); // listening + // Idempotent start. + try std.testing.expectEqual(@as(u8, 0), uapi_gnosis_start(handle)); + uapi_gnosis_stop(handle); + try std.testing.expectEqual(@as(u8, 3), uapi_gnosis_state(handle)); // stopped +} + +test "gnosis: handle validation" { + try std.testing.expectEqual(@as(u8, 3), uapi_gnosis_state(0)); // stopped + try std.testing.expectEqual(@as(u8, 2), uapi_gnosis_set_handler(9999, null)); // invalid_param +} diff --git a/ffi/zig/src/kanren.zig b/ffi/zig/src/kanren.zig new file mode 100644 index 0000000..abefb2c --- /dev/null +++ b/ffi/zig/src/kanren.zig @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// kanren.zig — UNTRUSTED forensic search engine (interface ③, Zig side). +// +// Design: docs/design/forensic-stack.adoc. This engine is deliberately +// outside the trust boundary: it emits candidate attack paths as flat +// RawStep derivations, and the Idris2 kernel (src/abi/Forensics.idr, +// checkReach) accepts or rejects each one against the evidence. A bug +// here can only lose answers, never forge one. +// +// Scope (FS-0 scaffold): an in-process evidence table + depth-bounded +// DFS reachability with fact-id-carrying steps. The relational +// (miniKanren-style) core, file ingestion and fibre enumeration are +// later phases; the C ABI below is the stable surface they grow into. +// +// Rules (prototype set, mirrored by the kernel's side conditions): +// lateral : internal -> internal on ports 445/3389/22/5985 +// exfil : internal -> external (any port), must end a path +// entry : external -> internal (any port), must start a path +// "internal" = 10.x.x.x prefix, matching Internal in Forensics.idr. + +const std = @import("std"); +const core = @import("core.zig"); + +// --------------------------------------------------------------------------- +// Wire types (must match zig_api.h; asserted by tests) +// --------------------------------------------------------------------------- + +pub const RULE_LATERAL: u8 = 0; +pub const RULE_EXFIL: u8 = 1; +pub const RULE_ENTRY: u8 = 2; + +pub const RawStep = extern struct { + fact_id: u32, + rule: u8, + _pad: u8 = 0, + _pad2: u16 = 0, +}; + +pub const RawDeriv = extern struct { + steps: ?[*]const RawStep, + len: u32, +}; + +const MAX_FLOWS: usize = 4096; +const MAX_DERIVS: usize = 256; +const MAX_STEPS_PER_DERIV: usize = 64; + +pub const Flow = struct { + fid: u32, + src: [64]u8 = undefined, + src_len: usize = 0, + dst: [64]u8 = undefined, + dst_len: usize = 0, + port: u16, + bytes: u64, + + fn srcSlice(self: *const Flow) []const u8 { + return self.src[0..self.src_len]; + } + fn dstSlice(self: *const Flow) []const u8 { + return self.dst[0..self.dst_len]; + } +}; + +// --------------------------------------------------------------------------- +// Evidence table (per-call arena for derivations; table is process state) +// --------------------------------------------------------------------------- + +var flows: [MAX_FLOWS]Flow = undefined; +var flow_count: usize = 0; +var flow_mutex: std.Thread.Mutex = .{}; + +var arena_inst: ?std.heap.ArenaAllocator = null; + +fn engineAlloc() std.mem.Allocator { + if (arena_inst == null) { + arena_inst = std.heap.ArenaAllocator.init(std.heap.c_allocator); + } + return arena_inst.?.allocator(); +} + +fn isInternal(host: []const u8) bool { + return std.mem.startsWith(u8, host, "10."); +} + +fn classify(f: *const Flow) u8 { + const src_in = isInternal(f.srcSlice()); + const dst_in = isInternal(f.dstSlice()); + if (src_in and dst_in) { + return switch (f.port) { + 445, 3389, 22, 5985 => RULE_LATERAL, + else => 255, // internal-internal but not a lateral port + }; + } + if (src_in and !dst_in) return RULE_EXFIL; + if (!src_in and dst_in) return RULE_ENTRY; + return 255; // external -> external: not in the prototype rule set +} + +// --------------------------------------------------------------------------- +// C ABI (declared in src/abi/Forensics.idr; header: zig_api.h) +// --------------------------------------------------------------------------- + +/// Add one observed flow to the evidence table. +/// Returns the fact-id, or 0xFFFFFFFF on overflow / bad input. +pub export fn kanren_add_flow( + src: ?[*:0]const u8, + dst: ?[*:0]const u8, + port: u16, + bytes: u64, +) callconv(.c) u32 { + const s = std.mem.span(src orelse return 0xFFFFFFFF); + const d = std.mem.span(dst orelse return 0xFFFFFFFF); + flow_mutex.lock(); + defer flow_mutex.unlock(); + if (flow_count >= MAX_FLOWS or s.len >= 64 or d.len >= 64) return 0xFFFFFFFF; + const f = &flows[flow_count]; + f.* = .{ .fid = @intCast(flow_count), .port = port, .bytes = bytes }; + @memcpy(f.src[0..s.len], s); + f.src_len = s.len; + @memcpy(f.dst[0..d.len], d); + f.dst_len = d.len; + flow_count += 1; + return f.fid; +} + +/// Clear the evidence table and free any derivation arena. +pub export fn kanren_clear() callconv(.c) void { + flow_mutex.lock(); + defer flow_mutex.unlock(); + flow_count = 0; + kanren_free(); +} + +/// Free the derivation arena. Callers hold nothing after this. +pub export fn kanren_free() callconv(.c) void { + if (arena_inst) |*a| { + a.deinit(); + arena_inst = null; + } +} + +/// Depth-bounded search: candidate paths from `src` that end in an exfil +/// step. Fills `out` with an array of RawDeriv (allocated in the arena, +/// valid until kanren_free). Returns the derivation count; 0 is "no +/// candidate within budget", which is DISTINCT from "no path exists" — +/// the trusted checker and the caller keep that honesty (tropical budget +/// seam: max_depth is a declared resource grade on the query). +pub export fn kanren_attack_paths( + src: ?[*:0]const u8, + out: ?*?[*]const RawDeriv, + max_depth: u32, +) callconv(.c) u32 { + const source = std.mem.span(src orelse return 0); + const outp = out orelse return 0; + if (max_depth == 0 or max_depth > MAX_STEPS_PER_DERIV) return 0; + + flow_mutex.lock(); + defer flow_mutex.unlock(); + + const alloc = engineAlloc(); + var derivs = alloc.alloc(RawDeriv, MAX_DERIVS) catch return 0; + var deriv_count: usize = 0; + var steps_buf: [MAX_STEPS_PER_DERIV]RawStep = undefined; + + // DFS over lateral edges; emit when an exfil edge closes the path. + const found = dfs(source, &steps_buf, 0, max_depth, &derivs, &deriv_count, alloc); + + if (!found or deriv_count == 0) { + outp.* = null; + return 0; + } + outp.* = derivs.ptr; + return @intCast(deriv_count); +} + +fn dfs( + current: []const u8, + steps: []RawStep, + depth: usize, + max_depth: u32, + derivs: *[]RawDeriv, + deriv_count: *usize, + alloc: std.mem.Allocator, +) bool { + if (deriv_count.* >= MAX_DERIVS) return true; + for (flows[0..flow_count]) |*f| { + const rule = classify(f); + switch (rule) { + RULE_LATERAL => { + if (!std.mem.eql(u8, f.srcSlice(), current)) continue; + if (depth >= max_depth) continue; + steps[depth] = .{ .fact_id = f.fid, .rule = RULE_LATERAL }; + _ = dfs(f.dstSlice(), steps, depth + 1, max_depth, derivs, deriv_count, alloc); + }, + RULE_EXFIL => { + if (!std.mem.eql(u8, f.srcSlice(), current)) continue; + if (depth >= max_depth) continue; + steps[depth] = .{ .fact_id = f.fid, .rule = RULE_EXFIL }; + const n = depth + 1; + const owned = alloc.dupe(RawStep, steps[0..n]) catch continue; + derivs.*[deriv_count.*] = .{ .steps = owned.ptr, .len = @intCast(n) }; + deriv_count.* += 1; + }, + else => {}, + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Tests (the trusted checker's negative tests live in Idris2, FS-1) +// --------------------------------------------------------------------------- + +test "kanren: raw structs match zig_api.h layout" { + const h = @cImport({ + @cInclude("zig_api.h"); + }); + try std.testing.expectEqual(@sizeOf(h.RawStep), @sizeOf(RawStep)); + try std.testing.expectEqual(@offsetOf(h.RawStep, "rule"), @offsetOf(RawStep, "rule")); + try std.testing.expectEqual(@sizeOf(h.RawDeriv), @sizeOf(RawDeriv)); + try std.testing.expectEqual(@offsetOf(h.RawDeriv, "len"), @offsetOf(RawDeriv, "len")); +} + +test "kanren: chain then exfil is found with fact ids" { + kanren_clear(); + defer kanren_clear(); + _ = kanren_add_flow("10.0.0.5", "10.0.0.12", 445, 1000); // lateral + _ = kanren_add_flow("10.0.0.12", "10.0.0.20", 3389, 2000); // lateral + _ = kanren_add_flow("10.0.0.20", "198.51.100.9", 443, 750_000_000); // exfil + _ = kanren_add_flow("203.0.113.7", "10.0.0.5", 80, 500); // entry (unused from this src) + + var derivs: ?[*]const RawDeriv = null; + const n = kanren_attack_paths("10.0.0.5", &derivs, 8); + try std.testing.expectEqual(@as(u32, 1), n); + const d = derivs.?[0]; + try std.testing.expectEqual(@as(u32, 3), d.len); + try std.testing.expectEqual(@as(u32, 0), d.steps.?[0].fact_id); + try std.testing.expectEqual(RULE_LATERAL, d.steps.?[0].rule); + try std.testing.expectEqual(@as(u32, 1), d.steps.?[1].fact_id); + try std.testing.expectEqual(@as(u32, 2), d.steps.?[2].fact_id); + try std.testing.expectEqual(RULE_EXFIL, d.steps.?[2].rule); + kanren_free(); +} + +test "kanren: budget distinguishes no-answer-in-budget" { + kanren_clear(); + defer kanren_clear(); + _ = kanren_add_flow("10.0.0.5", "10.0.0.12", 445, 1000); + _ = kanren_add_flow("10.0.0.12", "10.0.0.20", 3389, 2000); + _ = kanren_add_flow("10.0.0.20", "198.51.100.9", 443, 750_000_000); + + var derivs: ?[*]const RawDeriv = null; + // Depth budget 2 cannot carry the 3-step path: no candidate — + // distinct from "no path exists" (depth 3 finds it). + try std.testing.expectEqual(@as(u32, 0), kanren_attack_paths("10.0.0.5", &derivs, 2)); + try std.testing.expectEqual(@as(u32, 1), kanren_attack_paths("10.0.0.5", &derivs, 3)); + kanren_free(); +} + +test "kanren: non-lateral internal port never appears" { + kanren_clear(); + defer kanren_clear(); + _ = kanren_add_flow("10.0.0.5", "10.0.0.12", 8080, 1000); // internal but not lateral + _ = kanren_add_flow("10.0.0.12", "198.51.100.9", 443, 10); + + var derivs: ?[*]const RawDeriv = null; + try std.testing.expectEqual(@as(u32, 0), kanren_attack_paths("10.0.0.5", &derivs, 8)); + kanren_free(); +} diff --git a/ffi/zig/src/lib.zig b/ffi/zig/src/lib.zig new file mode 100644 index 0000000..75d6fed --- /dev/null +++ b/ffi/zig/src/lib.zig @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// lib.zig — root of libzig_api: the in-repo gnosis server + connector +// pool (uapi_* surface, declared in src/abi/Gnosis.idr) and the libaerie +// surface (aerie_* exports, declared in src/abi/Foreign.idr). +// +// One library, two ABI names: the gateway links -lzig_api (zig_api.h) and +// Idris2 consumers link -laerie (Foreign.idr). Both names ship the same +// symbol set; the alias is honest, not a fork. + +const std = @import("std"); +const core = @import("core.zig"); +const gnosis = @import("gnosis.zig"); +const connector = @import("connector.zig"); +const aerie = @import("aerie.zig"); +const kanren = @import("kanren.zig"); + +// Force analysis of every exporting module so all C ABI symbols ship. +comptime { + _ = gnosis; + _ = connector; + _ = aerie; + _ = kanren; +} + +/// Null-terminated library version. +pub export fn uapi_version() callconv(.c) [*:0]const u8 { + return core.VERSION; +} + +var initialized = std.atomic.Value(bool).init(false); + +/// One-time library init. Pools are statically zeroed; this is an +/// idempotent gate for contract compliance, not allocation. +pub export fn uapi_init() callconv(.c) u8 { + if (initialized.load(.acquire)) return core.Result.ok.toU8(); + initialized.store(true, .release); + return core.Result.ok.toU8(); +} + +/// Tear down every live server and connector slot. +pub export fn uapi_teardown() callconv(.c) void { + if (!initialized.swap(false, .acq_rel)) return; + var handle: u64 = 1; + while (handle <= 16) : (handle += 1) gnosis.uapi_gnosis_destroy(handle); + var slot: u8 = 0; + while (slot < 64) : (slot += 1) connector.uapi_connector_destroy(slot); + core.clearError(); +} + +test "lib: init/teardown idempotence" { + try std.testing.expectEqual(@as(u8, 0), uapi_init()); + try std.testing.expectEqual(@as(u8, 0), uapi_init()); // idempotent + uapi_teardown(); + uapi_teardown(); // safe when not initialised + try std.testing.expectEqualStrings("0.1.0", std.mem.span(uapi_version())); +} diff --git a/mise.toml b/mise.toml index b6110f2..61efad5 100644 --- a/mise.toml +++ b/mise.toml @@ -15,10 +15,6 @@ # Gateway + FFI (build.zig requires 0.15.2+) zig = "0.15.2" -# Tracked-drift crate only (src/api/rust) — do not extend; keep resolvable -# so `just test` can still exercise the existing suite. -rust = "stable" - # Core experiment (src/core/Aerie.jl) julia = "stable" diff --git a/src/abi/0.1-AI-MANIFEST b/src/abi/0.1-AI-MANIFEST index d9cdf31..b245cc9 100644 --- a/src/abi/0.1-AI-MANIFEST +++ b/src/abi/0.1-AI-MANIFEST @@ -14,14 +14,20 @@ canonical_locations: Foreignidr: "Foreign.idr" Layoutidr: "Layout.idr" Typesidr: "Types.idr" + Gnosisidr: "Gnosis.idr" + Forensicsidr: "Forensics.idr" --- ### [FILE_RELATIONSHIPS] files: - name: "Foreign.idr" - description: "FFI bindings" + description: "FFI bindings (libaerie surface)" - name: "Layout.idr" description: "memory/layout types" - name: "Types.idr" description: "core ABI types" + - name: "Gnosis.idr" + description: "gnosis server + connector pool C ABI (libzig_api surface, incl. GnosisRequestV2)" + - name: "Forensics.idr" + description: "forensic kernel: evidence-indexed derivations, retention, warrants (port-and-reprove of echo-types/epistemic-types; design: docs/design/forensic-stack.adoc)" diff --git a/src/abi/Forensics.idr b/src/abi/Forensics.idr new file mode 100644 index 0000000..d04978c --- /dev/null +++ b/src/abi/Forensics.idr @@ -0,0 +1,202 @@ +||| SPDX-License-Identifier: MPL-2.0 +||| Forensic kernel ABI: untrusted search, trusted checking. +||| +||| Port-and-reprove (see docs/design/forensic-stack.adoc): +||| * Retention / fibres — authority: hyperpolymath/echo-types +||| Echo.Index.ThinPoset (keep <= residue <= forget), +||| Echo.Modality.Core (Echo f y = Sigma (x : A), f x = y), +||| Echo.Separation.NotResourceInstance (anti-collapse: +||| measures are not Echo). +||| * Warrants / checks — authority: hyperpolymath/epistemic-types +||| Warrant.agda (warrant is non-factive by construction), +||| ProofTransport.agda (CertificateCheck, proofSound). +||| The Agda/Lean developments remain the source of truth; this module +||| restates the computational surfaces needed at the aerie boundary and +||| reproves the small lemmas locally. +||| +||| The Zig search engine (ffi/zig/src/kanren.zig) is UNTRUSTED: it +||| emits candidate derivations as raw steps; checkReach below accepts +||| or rejects them against the evidence. A solver bug can only lose +||| answers, never forge one. + +module Aerie.ABI.Forensics + +import Aerie.ABI.Types + +%default total + +-------------------------------------------------------------------------------- +-- 0. Retention (restates the Echo index; echo-types Echo.Index.ThinPoset) +-------------------------------------------------------------------------------- + +||| The three-point loss order: Keep <= Residue <= Forget. +||| Thinness (any two order proofs are equal) is implicit in the +||| three-point enumeration; reproved trivially here. +public export +data Retention = Keep | Residue | Forget + +||| Degradation is path-independent (degrade-compose, restated). +public export +degrade : Retention -> Retention -> Retention +degrade Keep r2 = r2 +degrade Residue _ = Residue +degrade Forget _ = Forget + +||| The order, with exactly one constructor per comparable pair — +||| thinness (any two proofs of r1 <= r2 are equal) is then structural. +||| A dedicated retainsThin lemma is FS-1 work, reproved under the +||| Idris2 toolchain (this module is not yet type-checked; CI is the +||| witness, per the estate's honest-status convention). +public export +data Retains : Retention -> Retention -> Type where + KK : Retains Keep Keep + KR : Retains Keep Residue + KF : Retains Keep Forget + RR : Retains Residue Residue + RF : Retains Residue Forget + FF : Retains Forget Forget + +-------------------------------------------------------------------------------- +-- 1. Evidence (interface ①: facts carry their loss provenance) +-------------------------------------------------------------------------------- + +||| One observed flow. fid indexes the evidence table. +public export +record Flow where + constructor MkFlow + fid : Nat + src : String + dst : String + port : Nat + bytes : Nat + +||| A fact is a flow plus per-field retention: what kind of loss +||| produced it. srcHost is Residue behind NAT, Forget post-aggregation; +||| timing is Residue under sampling; content is Forget for NetFlow. +public export +record Fact where + constructor MkFact + payload : Flow + srcHost : Retention + timing : Retention + content : Retention + +-------------------------------------------------------------------------------- +-- 2. Raw derivations from the untrusted solver (C ABI: zig_api.h) +-------------------------------------------------------------------------------- + +||| Rule tags — must match RawStep.rule in ffi/zig/src/kanren.zig. +public export +data RuleTag = LateralRule | ExfilRule | EntryRule + +||| One raw step: a rule applied to a fact-id. Untrusted until checked. +public export +record RawStep where + constructor MkRawStep + factId : Nat + rule : RuleTag + +||| A candidate derivation: a flat list of raw steps from the solver. +public export +RawDeriv : Type +RawDeriv = List RawStep + +-------------------------------------------------------------------------------- +-- 3. Side conditions, stated as types (not Bool) +-------------------------------------------------------------------------------- + +||| Internal host prefix (10/8 in the prototype rule set). +public export +data Internal : String -> Type where + IsInternal : (prf : isPrefixOf "10." h = True) -> Internal h + +||| Lateral-movement ports. +public export +data LateralPort : Nat -> Type where + SMB : LateralPort 445 + RDP : LateralPort 3389 + SSH : LateralPort 22 + WinRM : LateralPort 5985 + +-------------------------------------------------------------------------------- +-- 4. Evidence-indexed judgements (the kernel, interface ③) +-------------------------------------------------------------------------------- + +||| A lateral step a -> b, indexed by the evidence list it rests on. +||| Elem f ev makes citing an unobserved flow unrepresentable. +public export +data Lateral : (ev : List Flow) -> String -> String -> Type where + MkLateral : (f : Flow) -> Elem f ev + -> Internal (src f) -> Internal (dst f) + -> LateralPort (port f) + -> Lateral ev (src f) (dst f) + +||| Reachability over observed lateral steps. +public export +data Reach : List Flow -> String -> String -> Type where + One : Lateral ev a b -> Reach ev a b + Step : Lateral ev a m -> Reach ev m b -> Reach ev a b + +||| Checker failure modes. MissingAnswer is the honest signature of an +||| untrusted solver: it may fail to find what exists. +public export +data CheckError + = UnknownFact Nat -- fact-id not in the evidence list + | WrongRule Nat -- rule tag does not fit the flow + | NotInternal String -- side condition failed + | NotALateralPort Nat -- side condition failed + | BrokenChain -- steps do not compose a -> ... -> b + | EmptyDeriv + +||| The kernel: raw steps in, typed proof or rejection out. +||| FS-1 deliverable (docs/design/forensic-stack.adoc); signature fixed +||| now so the Zig side and tests can be written against it. +public export +checkReach : (ev : List Flow) -> (a, b : String) + -> RawDeriv -> Either CheckError (Reach ev a b) +checkReach ev a b deriv = ?checkReach_impl + +-------------------------------------------------------------------------------- +-- 5. CertificateCheck (restates epistemic-types ProofTransport.agda) +-------------------------------------------------------------------------------- + +||| An executable Boolean check plus the proof that acceptance entails +||| the stated meaning. run d = True does not give Meaning; sound does. +||| Authority: EpistemicTypes.ProofTransport (CertificateCheck, proofSound). +public export +record CertificateCheck (Meaning : Type) where + constructor MkCheck + run : RawDeriv -> Bool + sound : (d : RawDeriv) -> run d = True -> Meaning + +||| proofSound restated: an accepted certificate supports its meaning. +public export +proofSound : (c : CertificateCheck m) -> (d : RawDeriv) -> c.run d = True -> m +proofSound c d ok = c.sound d ok + +-------------------------------------------------------------------------------- +-- 6. Warrants (restates epistemic-types Warrant.agda; non-factive) +-------------------------------------------------------------------------------- + +||| A warrant records that a source said something. It deliberately has +||| no Evidence -> A field: that would make every warrant factive. +public export +record Warrant (kappa : Type) (a : Type) where + constructor MkWarrant + Evidence : Type + +||| Standpoint-tagged observation: warrant + evidence token. Having +||| Epi kappa A does not by itself give A (non-factive by construction). +public export +record Epi (kappa : Type) (a : Type) where + constructor MkEpi + warrant : Warrant kappa a + evidence : Evidence warrant + +||| Soundness is a separate, explicit assumption — the report carries it +||| instead of hiding it in the rules (fixes silent tier-mixing). +public export +record SoundWarrant (kappa : Type) (a : Type) where + constructor MkSoundWarrant + warrant : Warrant kappa a + sound : (ev : Evidence warrant) -> a diff --git a/src/abi/Gnosis.idr b/src/abi/Gnosis.idr new file mode 100644 index 0000000..c71fd3f --- /dev/null +++ b/src/abi/Gnosis.idr @@ -0,0 +1,195 @@ +||| SPDX-License-Identifier: MPL-2.0 +||| Gnosis server + service-connector C ABI for AERIE. +||| +||| Declared here (ABI = Idris2), implemented in `ffi/zig/` (FFI = Zig). +||| The surface is superset-compatible with developer-ecosystem/zig-api: +||| symbol names, tag values and v1 struct layouts match, so the estate +||| library can replace the in-repo implementation without gateway changes. +||| +||| The **V2 request** is an aerie extension: v1 strips the query string +||| and exposes no headers, which starves the policy gate of `X-Api-Key` +||| and the resolvers of query parameters. V2 carries both, plus the +||| query separately from the path. + +module Aerie.ABI.Gnosis + +import Aerie.ABI.Types + +%default total + +-------------------------------------------------------------------------------- +-- Tags (values are fixed by the C ABI; see ffi/zig/include/zig_api.h) +-------------------------------------------------------------------------------- + +||| Server lifecycle states. +public export +data ServerState = SrvIdle | SrvListening | SrvDraining | SrvStopped + +||| Connector lifecycle states. +public export +data ConnectorState = CnDisconnected | CnConnecting | CnConnected + | CnDegraded | CnFailed | CnDraining + +||| Service identity tags for the connector pool. +public export +data ServiceId = AmbientOps | Boj | Burble | Echidna | Gossamer + | GrooveBridge | Hypatia | Idaptik | Reposystem + | Stapeln | VerisimDB + +||| HTTP method tags (wire order is fixed by the ABI). +public export +data MethodTag = MGet | MPost | MPut | MDelete | MHead | MOptions | MPatch + +-------------------------------------------------------------------------------- +-- Wire structures +-------------------------------------------------------------------------------- + +||| V1 edge request. Kept for ABI compatibility; strips query + headers. +public export +record GnosisRequest where + constructor MkGnosisRequest + ||| HTTP method, null-terminated. + reqMethod : Bits64 + ||| Query-stripped path, null-terminated. + reqPath : Bits64 + ||| Body bytes; 0 when empty. + reqBody : Bits64 + reqBodyLen: Bits32 + +||| V2 edge request: v1 plus the raw query (no `?`), parallel header +||| name/value arrays, and the per-connection response scratch. +||| `headerCount` entries are valid; the arrays themselves are NULL +||| when `headerCount` is 0. +public export +record GnosisRequestV2 where + constructor MkGnosisRequestV2 + reqV2Method : Bits64 + ||| Query-stripped path. + reqV2Path : Bits64 + ||| Raw query string without the leading `?`; empty string when absent. + reqV2Query : Bits64 + reqV2Body : Bits64 + reqV2BodyLen : Bits32 + ||| NULL-terminated C-string arrays, length `reqV2HeaderCount`. + reqV2HeaderNames : Bits64 + reqV2HeaderValues: Bits64 + reqV2HeaderCount : Bits32 + ||| Per-connection response scratch, owned by the server: it outlives + ||| the handler call (the socket write happens after the handler + ||| returns and frees handler-lifetime arenas). Handlers copy + ||| arena/stack-lifetime response bodies here. + reqV2RespScratch : Bits64 + reqV2RespScratchLen : Bits32 + +||| Edge response written by a handler; flushed by the server loop. +public export +record GnosisResponse where + constructor MkGnosisResponse + respStatus : Bits16 + respPad : Bits16 + respContentType : Bits64 + respBody : Bits64 + respBodyLen : Bits32 + +-------------------------------------------------------------------------------- +-- Library lifecycle +-------------------------------------------------------------------------------- + +||| One-time library init. Idempotent. 0 = ok. +export +%foreign "C:uapi_init, libzig_api" +prim__uapiInit : PrimIO Bits8 + +||| Tear down servers + connectors and free library memory. +export +%foreign "C:uapi_teardown, libzig_api" +prim__uapiTeardown : PrimIO () + +||| Null-terminated library version string. +export +%foreign "C:uapi_version, libzig_api" +prim__uapiVersion : PrimIO Bits64 + +-------------------------------------------------------------------------------- +-- Gnosis server pool +-------------------------------------------------------------------------------- + +||| Reserve a server slot for `port`; handle is non-zero on success. +export +%foreign "C:uapi_gnosis_create, libzig_api" +prim__gnosisCreate : Bits16 -> PrimIO Bits64 + +||| Bind (if needed) and start the serve thread. Idempotent. +export +%foreign "C:uapi_gnosis_start, libzig_api" +prim__gnosisStart : Bits64 -> PrimIO Bits8 + +||| Stop accepting, drain in-flight connections, join the thread. +export +%foreign "C:uapi_gnosis_stop, libzig_api" +prim__gnosisStop : Bits64 -> PrimIO () + +||| Destroy the handle (stops first if listening). +export +%foreign "C:uapi_gnosis_destroy, libzig_api" +prim__gnosisDestroy : Bits64 -> PrimIO () + +||| Current ServerState tag. +export +%foreign "C:uapi_gnosis_state, libzig_api" +prim__gnosisState : Bits64 -> PrimIO Bits8 + +||| 0 = serving, 1 = not serving. +export +%foreign "C:uapi_gnosis_health, libzig_api" +prim__gnosisHealth : Bits64 -> PrimIO Bits8 + +||| Register the v1 edge handler (query-stripped, no headers). +||| Must be called between create and start. +export +%foreign "C:uapi_gnosis_set_handler, libzig_api" +prim__gnosisSetHandler : Bits64 -> AnyPtr -> PrimIO Bits8 + +||| Register the v2 edge handler (query + headers carried). +||| Takes precedence over the v1 handler when both are set. +export +%foreign "C:uapi_gnosis_set_handler_v2, libzig_api" +prim__gnosisSetHandlerV2 : Bits64 -> AnyPtr -> PrimIO Bits8 + +||| Convenience: fill a GnosisResponse in one call. +export +%foreign "C:uapi_gnosis_write_response, libzig_api" +prim__gnosisWriteResponse + : Bits64 -> Bits16 -> Bits64 -> Bits64 -> Bits32 -> PrimIO () + +-------------------------------------------------------------------------------- +-- Service connector pool +-------------------------------------------------------------------------------- + +||| Allocate a connector for `serviceId` at `baseUrl`. +||| Returns the slot index, or 255 on failure. +export +%foreign "C:uapi_connector_create, libzig_api" +prim__connectorCreate : Bits8 -> Bits64 -> PrimIO Bits8 + +||| GET /health probe; returns a ConnectorState tag. +export +%foreign "C:uapi_connector_health, libzig_api" +prim__connectorHealth : Bits8 -> PrimIO Bits8 + +||| Perform an HTTP round-trip on the slot. 0 = ok (body copied, +||| null-terminated, into the caller buffer); non-zero = transport error. +export +%foreign "C:uapi_connector_call, libzig_api" +prim__connectorCall + : Bits8 -> Bits8 -> Bits64 -> Bits64 -> Bits64 -> Bits32 -> PrimIO Bits8 + +||| Release the slot. +export +%foreign "C:uapi_connector_destroy, libzig_api" +prim__connectorDestroy : Bits8 -> PrimIO () + +||| Current ConnectorState tag for the slot. +export +%foreign "C:uapi_connector_state, libzig_api" +prim__connectorState : Bits8 -> PrimIO Bits8 diff --git a/src/api/0.1-AI-MANIFEST b/src/api/0.1-AI-MANIFEST index 2a0ec3d..5071f80 100644 --- a/src/api/0.1-AI-MANIFEST +++ b/src/api/0.1-AI-MANIFEST @@ -9,12 +9,11 @@ context: --- ### [AI_MANIFEST] description: | - The canonical implementation is the Zig gateway (zig/), built by the root build.zig. The Rust crate (rust/) is tracked drift: do not build, extend, or migrate to it. + The canonical implementation is the Zig gateway (zig/), built by the root build.zig. The Rust twin (rust/) was removed 2026-09-24 (owner decision, roadmap D1 actioned): Rust is not an API language in this estate. canonical_locations: zig: "zig/" graphql: "graphql/" proto: "proto/" - rust: "rust/" --- ### [FILE_RELATIONSHIPS] @@ -25,6 +24,4 @@ files: description: "GraphQL wire contract" - name: "proto/aerie.proto" description: "gRPC wire contract" - - name: "rust" - description: "tracked drift; removal is an owner decision" diff --git a/src/api/rust/Cargo.toml b/src/api/rust/Cargo.toml deleted file mode 100644 index 54561f3..0000000 --- a/src/api/rust/Cargo.toml +++ /dev/null @@ -1,49 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# (MPL-2.0 is automatic legal fallback until PMPL is formally recognised) -# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# aerie-api — Rust rewrite of the triple-mount API gateway (Rust/SPARK) -# Replaces: src/api/v/ (deprecated 2026-04-12, estate-wide zig ban) -# -# Stack: axum 0.7 (HTTP), raw TCP (gRPC Phase 1), reqwest (backends), -# sha2 (proof hashes), uuid (query IDs), rand (timing jitter), -# serde_json (serialisation), tokio (async runtime) - -[package] -name = "aerie-api" -version = "0.2.0" -edition = "2021" -description = "Aerie Network Diagnostic Suite — triple-mount API gateway (Rust rewrite)" -license = "MPL-2.0" -# MPL-2.0 preferred; MPL-2.0 required for crates.io -authors = ["Jonathan D.A. Jewell "] - -[[bin]] -name = "aerie-api" -path = "src/main.rs" - -[dependencies] -# HTTP server — REST + GraphQL endpoints -axum = { version = "0.7", features = ["macros"] } -# Async runtime — full feature set (IO, time, signal) -tokio = { version = "1", features = ["full"] } -# HTTP client — backend calls to LibreSpeed, Hyperglass, SmokePing, VerisimDB -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -# Serialisation -serde = { version = "1", features = ["derive"] } -serde_json = "1" -# Proof envelope — SHA-256 of response body + policy string -sha2 = "0.10" -hex = "0.4" -# UUID v4 for query IDs in ProofEnvelope -uuid = { version = "1", features = ["v4"] } -# Timestamps — RFC 3339 issued_at fields -chrono = { version = "0.4", default-features = false, features = ["clock"] } -# Timing jitter (1–8 ms) in verb governance to defeat timing analysis -rand = "0.10" -# Tower middleware layer compatibility -tower = "0.4" -tower-http = { version = "0.5", features = ["trace"] } -# Structured logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/api/rust/src/backends.rs b/src/api/rust/src/backends.rs deleted file mode 100644 index d4cf711..0000000 --- a/src/api/rust/src/backends.rs +++ /dev/null @@ -1,495 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// backends.rs — HTTP clients for all Aerie backend services -// -// Mirrors the four V backend client files: -// - src/api/v/librespeed_client.v (122 LOC) → LibreSpeedClient -// - src/api/v/hyperglass_client.v (143 LOC) → HyperglassClient -// - src/api/v/smokeping_client.v (260 LOC) → SmokePingClient -// - src/api/v/verisim_client.v (156 LOC) → VerisimDbClient -// -// All clients are fire-and-forget where indicated: errors are logged to -// stderr and the caller receives an error Result. No client ever panics. -// All backend URLs are read from environment variables at construction -// time, with sensible container-network defaults. -// -// The `reqwest` async client is used throughout for non-blocking I/O. - -use serde::{Deserialize, Serialize}; - -// ─── LibreSpeed ────────────────────────────────────────────────────────────── - -/// Throughput and geolocation data from a LibreSpeed measurement. -/// -/// Fields match the ABI definition in `src/abi/Types.idr` TelemetryResult. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TelemetryResult { - /// Measured download speed in Mbit/s - pub download_mbps: f64, - /// Measured upload speed in Mbit/s - pub upload_mbps: f64, - /// Round-trip latency in milliseconds (ping to LibreSpeed server) - pub latency_ms: f64, - /// Jitter in milliseconds - pub jitter_ms: f64, - /// Client IP address as reported by the LibreSpeed backend - pub ip_address: String, - /// ISP name resolved from the client IP (may be empty) - pub isp: String, -} - -/// HTTP client for the LibreSpeed backend. -/// -/// LibreSpeed is queried for two things: -/// 1. `GET /backend/getIP.php` — client IP + ISP information -/// 2. The speed-test endpoints (download/upload/ping) are invoked by -/// the browser client directly; the gateway only exposes the IP data -/// for the telemetry resolver. -pub struct LibreSpeedClient { - base_url: String, - client: reqwest::Client, -} - -impl LibreSpeedClient { - /// Construct from `LIBRESPEED_URL` (default `http://librespeed:80`). - pub fn from_env() -> Self { - let base_url = std::env::var("LIBRESPEED_URL") - .unwrap_or_else(|_| "http://librespeed:80".to_string()); - LibreSpeedClient { - base_url: base_url.trim_end_matches('/').to_string(), - client: reqwest::Client::new(), - } - } - - /// Fetch client IP and ISP information from LibreSpeed. - /// - /// Returns a partial `TelemetryResult` with only `ip_address` and - /// `isp` populated; speed measurements are zeros until Phase 2 - /// integrates the speed-test flow. - pub async fn get_ip_info(&self) -> Result { - let url = format!("{}/backend/getIP.php", self.base_url); - let resp = self - .client - .get(&url) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - .map_err(|e| format!("librespeed: HTTP GET failed: {}", e))?; - - if !resp.status().is_success() { - return Err(format!("librespeed: status {}", resp.status())); - } - - // LibreSpeed returns a bare JSON object: {"processedString":"","rawIspInfo":""} - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("librespeed: parse failed: {}", e))?; - - let ip = body["processedString"] - .as_str() - .unwrap_or("") - .to_string(); - let isp = body["rawIspInfo"] - .as_str() - .unwrap_or("") - .to_string(); - - Ok(TelemetryResult { - download_mbps: 0.0, - upload_mbps: 0.0, - latency_ms: 0.0, - jitter_ms: 0.0, - ip_address: ip, - isp, - }) - } -} - -// ─── Hyperglass ────────────────────────────────────────────────────────────── - -/// BGP route forensics result from Hyperglass. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RouteForensicsResult { - /// The query target (IP address or CIDR prefix) - pub target: String, - /// VRF/routing context queried (default `"default"`) - pub vrf: String, - /// List of matching BGP routes as serialised JSON strings - pub routes: Vec, - /// Raw response body (preserved for ProofEnvelope hashing) - pub raw: String, -} - -/// HTTP client for the Hyperglass BGP looking-glass backend. -/// -/// Hyperglass exposes a REST API at `/api/v1/query` that accepts a POST -/// body with `{"query_target": "...", "query_type": "bgp_route", "vrf": "..."}`. -pub struct HyperglassClient { - base_url: String, - client: reqwest::Client, -} - -impl HyperglassClient { - /// Construct from `HYPERGLASS_URL` (default `http://hyperglass:8001`). - pub fn from_env() -> Self { - let base_url = std::env::var("HYPERGLASS_URL") - .unwrap_or_else(|_| "http://hyperglass:8001".to_string()); - HyperglassClient { - base_url: base_url.trim_end_matches('/').to_string(), - client: reqwest::Client::new(), - } - } - - /// Query BGP routes for a target address or prefix. - /// - /// `target` — IP address or CIDR prefix to query (e.g. `"203.0.113.1"`) - /// `vrf` — routing context (pass `"default"` when not specified) - pub async fn query_routes( - &self, - target: &str, - vrf: &str, - ) -> Result { - let url = format!("{}/api/v1/query", self.base_url); - let body = serde_json::json!({ - "query_target": target, - "query_type": "bgp_route", - "vrf": vrf, - }); - - let resp = self - .client - .post(&url) - .json(&body) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| format!("hyperglass: POST failed: {}", e))?; - - if !resp.status().is_success() { - return Err(format!("hyperglass: status {}", resp.status())); - } - - let raw = resp - .text() - .await - .map_err(|e| format!("hyperglass: body read failed: {}", e))?; - - let parsed: serde_json::Value = serde_json::from_str(&raw) - .unwrap_or_else(|_| serde_json::json!({"raw": raw})); - - let routes = parsed["routes"] - .as_array() - .cloned() - .unwrap_or_default(); - - Ok(RouteForensicsResult { - target: target.to_string(), - vrf: vrf.to_string(), - routes, - raw, - }) - } -} - -// ─── SmokePing ─────────────────────────────────────────────────────────────── - -/// A single SmokePing latency measurement snapshot. -/// -/// Fields match `src/abi/Types.idr` SmokePingSample and -/// `src/api/proto/aerie.proto` SmokePingSample. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SmokePingSample { - /// ISO 8601 timestamp of the measurement - pub timestamp: String, - /// Target hostname or IP that was probed - pub target: String, - /// Median round-trip time in milliseconds - pub median_ms: f64, - /// Packet loss percentage (0.0–100.0) - pub loss_pct: f64, - /// Minimum RTT in milliseconds - pub min_ms: f64, - /// Maximum RTT in milliseconds - pub max_ms: f64, - /// Standard deviation of RTT (jitter proxy) - pub stddev_ms: f64, -} - -/// A single smoke-chart data point for time-series rendering. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SmokeChartPoint { - pub timestamp: String, - pub median_ms: f64, - pub loss_pct: f64, -} - -/// Combined SmokePing payload: current snapshot + historical chart data. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SmokePingPayload { - pub target: String, - pub current: SmokePingSample, - pub chart: Vec, -} - -/// HTTP client for the SmokePing backend. -/// -/// SmokePing's CGI exposes latency/loss data for configured targets. -/// The gateway queries `/cgi-bin/smokeping.cgi` with a `display` and -/// `target` parameter and parses the response into typed structs. -pub struct SmokePingClient { - base_url: String, - client: reqwest::Client, -} - -impl SmokePingClient { - /// Construct from `SMOKEPING_URL` (default `http://smokeping:80`). - pub fn from_env() -> Self { - let base_url = std::env::var("SMOKEPING_URL") - .unwrap_or_else(|_| "http://smokeping:80".to_string()); - SmokePingClient { - base_url: base_url.trim_end_matches('/').to_string(), - client: reqwest::Client::new(), - } - } - - /// Fetch latency data for `target` from SmokePing. - /// - /// Returns a `SmokePingPayload` with the most recent snapshot and - /// historical chart points. On error, returns a zeroed-out sample - /// rather than propagating the error — telemetry data is advisory. - pub async fn get_data(&self, target: &str) -> Result { - // SmokePing CGI: GET /cgi-bin/smokeping.cgi?display=s&target= - let url = format!( - "{}/cgi-bin/smokeping.cgi?display=s&target={}", - self.base_url, - urlencod(target) - ); - - let resp = self - .client - .get(&url) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await - .map_err(|e| format!("smokeping: GET failed: {}", e))?; - - if !resp.status().is_success() { - return Err(format!("smokeping: status {}", resp.status())); - } - - let body = resp - .text() - .await - .map_err(|e| format!("smokeping: body read failed: {}", e))?; - - // SmokePing CGI may return HTML or a lightweight JSON summary depending - // on version and configuration. Attempt JSON parse first; fall back to - // a zeroed sample with the raw body preserved for debugging. - parse_smokeping_response(target, &body) - } -} - -/// Parse a SmokePing CGI response body into a `SmokePingPayload`. -/// -/// Tries JSON format first (newer SmokePing builds with the JSON export -/// plugin), then falls back to a zeroed current sample with an empty chart. -fn parse_smokeping_response(target: &str, body: &str) -> Result { - let now = chrono::Utc::now().to_rfc3339(); - - // Attempt JSON parse - if let Ok(v) = serde_json::from_str::(body) { - let current = SmokePingSample { - timestamp: v["timestamp"].as_str().unwrap_or(&now).to_string(), - target: target.to_string(), - median_ms: v["median"].as_f64().unwrap_or(0.0), - loss_pct: v["loss"].as_f64().unwrap_or(0.0), - min_ms: v["min"].as_f64().unwrap_or(0.0), - max_ms: v["max"].as_f64().unwrap_or(0.0), - stddev_ms: v["stddev"].as_f64().unwrap_or(0.0), - }; - - let chart = v["chart"] - .as_array() - .map(|pts| { - pts.iter() - .map(|p| SmokeChartPoint { - timestamp: p["ts"].as_str().unwrap_or(&now).to_string(), - median_ms: p["median"].as_f64().unwrap_or(0.0), - loss_pct: p["loss"].as_f64().unwrap_or(0.0), - }) - .collect() - }) - .unwrap_or_default(); - - return Ok(SmokePingPayload { - target: target.to_string(), - current, - chart, - }); - } - - // Fallback: return a zeroed sample; the gateway still responds with - // a proof envelope so callers can detect the missing data. - Ok(SmokePingPayload { - target: target.to_string(), - current: SmokePingSample { - timestamp: now, - target: target.to_string(), - median_ms: 0.0, - loss_pct: 0.0, - min_ms: 0.0, - max_ms: 0.0, - stddev_ms: 0.0, - }, - chart: vec![], - }) -} - -// ─── VerisimDB ─────────────────────────────────────────────────────────────── - -/// HTTP client for the VerisimDB bitemporal audit store. -/// -/// VerisimDB is the cold tier of the two-tier audit pipeline: -/// - Redis (hot): bounded list, fast, ephemeral -/// - VerisimDB (cold): permanent, bitemporal, forensic -/// -/// All writes are fire-and-forget: errors are logged but never propagate -/// to the request handler. VerisimDB unavailability must not affect -/// gateway responsiveness. -pub struct VerisimDbClient { - base_url: String, - client: reqwest::Client, -} - -impl VerisimDbClient { - /// Construct from `VERISIMDB_URL` (default `http://verisimdb:8084`). - pub fn from_env() -> Self { - let base_url = std::env::var("VERISIMDB_URL") - .unwrap_or_else(|_| "http://verisimdb:8084".to_string()); - VerisimDbClient { - base_url: base_url.trim_end_matches('/').to_string(), - client: reqwest::Client::new(), - } - } - - /// Persist an audit event to VerisimDB (fire-and-forget). - /// - /// `event_json` is the same JSON string as written to the Redis audit list. - /// Any error is logged to stderr; the return value is discarded by callers. - pub async fn store_audit(&self, event_json: &str) { - let url = format!("{}/api/v1/events", self.base_url); - match self - .client - .post(&url) - .header("Content-Type", "application/json") - .body(event_json.to_string()) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(r) if !r.status().is_success() => { - eprintln!("[aerie] verisimdb: store_audit status {}", r.status()); - } - Err(e) => { - eprintln!("[aerie] verisimdb: store_audit failed: {}", e); - } - Ok(_) => {} - } - } - - /// Query events as of a point in time. - /// - /// Returns a Vec of JSON event strings, or empty on error. - pub async fn query_as_of(&self, as_of_time: &str, limit: usize) -> Vec { - let url = format!( - "{}/api/v1/events?as_of={}&limit={}", - self.base_url, - urlencod(as_of_time), - limit - ); - self.get_events(&url).await - } - - /// Query events within a valid-time range. - pub async fn query_between( - &self, - start: &str, - end: &str, - limit: usize, - ) -> Vec { - let url = format!( - "{}/api/v1/events?start={}&end={}&limit={}", - self.base_url, - urlencod(start), - urlencod(end), - limit - ); - self.get_events(&url).await - } - - /// Retrieve the full bitemporal history of an event. - pub async fn query_history(&self, event_id: &str) -> Vec { - let url = format!("{}/api/v1/events/{}/history", self.base_url, event_id); - self.get_events(&url).await - } - - async fn get_events(&self, url: &str) -> Vec { - let resp = match self - .client - .get(url) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - { - Ok(r) => r, - Err(e) => { - eprintln!("[aerie] verisimdb: GET {} failed: {}", url, e); - return vec![]; - } - }; - - if !resp.status().is_success() { - eprintln!("[aerie] verisimdb: GET {} status {}", url, resp.status()); - return vec![]; - } - - let body: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[aerie] verisimdb: parse failed: {}", e); - return vec![]; - } - }; - - body["events"] - .as_array() - .cloned() - .unwrap_or_default() - } -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/// Minimal percent-encoding for query string values. -/// -/// Encodes space, `+`, `&`, `=`, `?`, `#`, `%` only — the characters -/// most likely to break a URL when embedding user-supplied values into -/// query strings. Not a full RFC 3986 encoder. -fn urlencod(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - ' ' => out.push_str("%20"), - '+' => out.push_str("%2B"), - '&' => out.push_str("%26"), - '=' => out.push_str("%3D"), - '?' => out.push_str("%3F"), - '#' => out.push_str("%23"), - '%' => out.push_str("%25"), - c => out.push(c), - } - } - out -} diff --git a/src/api/rust/src/main.rs b/src/api/rust/src/main.rs deleted file mode 100644 index ebee450..0000000 --- a/src/api/rust/src/main.rs +++ /dev/null @@ -1,360 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// main.rs — Aerie triple-mount API gateway entry point (Rust rewrite) -// -// Replaces: src/api/v/main.v (644 LOC, deprecated 2026-04-12) -// -// Architecture: three independent listeners share a single AppState: -// -// Port AERIE_PORT (default 4000) -// • REST — axum HTTP/1.1 router, conditionally mounted via ENABLE_REST -// • GraphQL — POST /api/v1/graphql, conditionally mounted via ENABLE_GRAPHQL -// -// Port AERIE_GRPC_PORT (default 4001) -// • gRPC Phase 1 — raw TCP, 4-byte big-endian length prefix + JSON body -// conditionally started via ENABLE_GRPC -// -// Mount flags: ENABLE_REST, ENABLE_GRAPHQL, ENABLE_GRPC (default "true"). -// Setting any to "false" disables that listener at startup. -// -// Verb governance is applied as an axum middleware: disallowed verbs -// receive a 404 (stealth deny) + 1–8 ms timing jitter on all responses. - -mod backends; -mod policy; -mod proof; -mod redis_client; -mod resolvers; -mod verb_governance; - -use axum::{ - extract::{Extension, Query, State}, - http::{HeaderMap, StatusCode}, - response::IntoResponse, - routing::{get, post}, - Json, Router, -}; -use resolvers::AppState; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tracing::{error, info}; -use verb_governance::VerbGovernor; - -// ─── Axum handler helpers ──────────────────────────────────────────────────── - -/// Extract the `X-Api-Key` header value, or return an empty string. -fn extract_api_key(headers: &HeaderMap) -> &str { - headers - .get("x-api-key") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") -} - -/// Convert a (status_code, body_string) tuple into an axum Response. -fn json_response(status: u16, body: String) -> impl IntoResponse { - let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - (code, [(axum::http::header::CONTENT_TYPE, "application/json")], body) -} - -// ─── REST handlers ─────────────────────────────────────────────────────────── - -/// GET /health — unauthenticated liveness probe. -async fn health() -> impl IntoResponse { - ( - StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, "application/json")], - r#"{"status":"serving","service":"aerie-api"}"#, - ) -} - -/// GET /api/v1/telemetry — LibreSpeed throughput + IP info. -async fn telemetry( - State(state): State>, - headers: HeaderMap, -) -> impl IntoResponse { - tokio::time::sleep(VerbGovernor::jitter_duration()).await; - let (status, body) = resolvers::resolve_telemetry(state, extract_api_key(&headers)).await; - json_response(status, body) -} - -/// GET /api/v1/routes?target=&vrf= — Hyperglass BGP routes. -async fn routes( - State(state): State>, - headers: HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - tokio::time::sleep(VerbGovernor::jitter_duration()).await; - let target = params.get("target").map(String::as_str).unwrap_or("0.0.0.0"); - let vrf = params.get("vrf").map(String::as_str).unwrap_or("default"); - let (status, body) = - resolvers::resolve_route_forensics(state, extract_api_key(&headers), target, vrf).await; - json_response(status, body) -} - -/// GET /api/v1/smokeping?target= — SmokePing latency/loss. -async fn smokeping( - State(state): State>, - headers: HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - tokio::time::sleep(VerbGovernor::jitter_duration()).await; - let target = params.get("target").map(String::as_str).unwrap_or("localhost"); - let (status, body) = - resolvers::resolve_smokeping(state, extract_api_key(&headers), target).await; - json_response(status, body) -} - -/// GET /api/v1/audit?offset=&count= — Redis hot audit log. -async fn audit( - State(state): State>, - headers: HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - tokio::time::sleep(VerbGovernor::jitter_duration()).await; - let offset = params - .get("offset") - .and_then(|s| s.parse().ok()) - .unwrap_or(0usize); - let count = params - .get("count") - .and_then(|s| s.parse().ok()) - .unwrap_or(20usize); - let (status, body) = - resolvers::resolve_audit(state, extract_api_key(&headers), offset, count).await; - json_response(status, body) -} - -/// GET /api/v1/temporal-audit — VerisimDB bitemporal query. -async fn temporal_audit( - State(state): State>, - headers: HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - tokio::time::sleep(VerbGovernor::jitter_duration()).await; - let as_of = params.get("as_of").map(String::as_str); - let start = params.get("start").map(String::as_str); - let end = params.get("end").map(String::as_str); - let limit = params - .get("limit") - .and_then(|s| s.parse().ok()) - .unwrap_or(100usize); - let (status, body) = - resolvers::resolve_temporal_audit(state, extract_api_key(&headers), as_of, start, end, limit) - .await; - json_response(status, body) -} - -/// POST /api/v1/graphql — GraphQL query dispatcher. -async fn graphql( - State(state): State>, - headers: HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - tokio::time::sleep(VerbGovernor::jitter_duration()).await; - let (status, resp) = - resolvers::resolve_graphql(state, extract_api_key(&headers), body).await; - json_response(status, resp) -} - -// ─── gRPC Phase 1 listener ─────────────────────────────────────────────────── - -/// Spawn the Phase 1 gRPC listener on `grpc_port`. -/// -/// Protocol: each request is a 4-byte big-endian length prefix followed by -/// that many bytes of JSON. The response is the same framing. Each connection -/// is handled in a separate tokio task. Unknown method names return an error -/// JSON frame — the gateway never closes the connection abruptly. -async fn run_grpc_listener(grpc_port: u16, state: Arc) { - let addr = format!("0.0.0.0:{}", grpc_port); - let listener = match TcpListener::bind(&addr).await { - Ok(l) => l, - Err(e) => { - error!("gRPC: bind {} failed: {}", addr, e); - return; - } - }; - info!("gRPC listener on {}", addr); - - loop { - let (mut socket, peer) = match listener.accept().await { - Ok(c) => c, - Err(e) => { - error!("gRPC: accept error: {}", e); - continue; - } - }; - let state = state.clone(); - - tokio::spawn(async move { - loop { - // Read 4-byte big-endian frame length - let mut len_buf = [0u8; 4]; - match socket.read_exact(&mut len_buf).await { - Ok(_) => {} - Err(_) => break, // Client disconnected - } - let frame_len = u32::from_be_bytes(len_buf) as usize; - - // Reject unreasonably large frames (>1 MiB) - if frame_len > 1_048_576 { - let err = grpc_error_frame("frame_too_large"); - let _ = socket.write_all(&err).await; - break; - } - - // Read frame body - let mut body = vec![0u8; frame_len]; - if socket.read_exact(&mut body).await.is_err() { - break; - } - - let request: serde_json::Value = match serde_json::from_slice(&body) { - Ok(v) => v, - Err(_) => { - let err = grpc_error_frame("invalid_json"); - let _ = socket.write_all(&err).await; - continue; - } - }; - - let method = request["method"].as_str().unwrap_or(""); - let api_key = request["api_key"].as_str().unwrap_or(""); - - // Route to resolver based on method name - let (_, response_body) = match method { - "Telemetry" | "telemetry" => { - resolvers::resolve_telemetry(state.clone(), api_key).await - } - "RouteForensics" | "route_forensics" => { - let target = request["target"].as_str().unwrap_or("0.0.0.0"); - let vrf = request["vrf"].as_str().unwrap_or("default"); - resolvers::resolve_route_forensics(state.clone(), api_key, target, vrf) - .await - } - "SmokePing" | "smokeping" => { - let target = request["target"].as_str().unwrap_or("localhost"); - resolvers::resolve_smokeping(state.clone(), api_key, target).await - } - "Audit" | "audit" => { - resolvers::resolve_audit(state.clone(), api_key, 0, 20).await - } - _ => { - let err = grpc_error_frame("unknown_method"); - let _ = socket.write_all(&err).await; - continue; - } - }; - - // Write 4-byte length + JSON response frame - let resp_bytes = response_body.as_bytes(); - let resp_len = (resp_bytes.len() as u32).to_be_bytes(); - let _ = socket.write_all(&resp_len).await; - let _ = socket.write_all(resp_bytes).await; - } - }); - } -} - -/// Build a gRPC error frame (4-byte length prefix + JSON body). -fn grpc_error_frame(code: &str) -> Vec { - let body = serde_json::json!({"error": code}).to_string(); - let len = (body.len() as u32).to_be_bytes(); - let mut frame = Vec::with_capacity(4 + body.len()); - frame.extend_from_slice(&len); - frame.extend_from_slice(body.as_bytes()); - frame -} - -// ─── Router builder ────────────────────────────────────────────────────────── - -/// Build the axum Router, optionally including REST and GraphQL endpoints. -fn build_router( - state: Arc, - enable_rest: bool, - enable_graphql: bool, -) -> Router { - let mut app = Router::new().route("/health", get(health)); - - if enable_rest { - app = app - .route("/api/v1/telemetry", get(telemetry)) - .route("/api/v1/routes", get(routes)) - .route("/api/v1/smokeping", get(smokeping)) - .route("/api/v1/audit", get(audit)) - .route("/api/v1/temporal-audit", get(temporal_audit)); - } - - if enable_graphql { - app = app.route("/api/v1/graphql", post(graphql)); - } - - app.with_state(state) -} - -// ─── Entry point ───────────────────────────────────────────────────────────── - -#[tokio::main] -async fn main() { - // Initialise structured logging; default filter: INFO - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "aerie_api=info,tower_http=info".into()), - ) - .init(); - - // Read mount flags - let enable_rest = std::env::var("ENABLE_REST") - .map(|v| v != "false") - .unwrap_or(true); - let enable_graphql = std::env::var("ENABLE_GRAPHQL") - .map(|v| v != "false") - .unwrap_or(true); - let enable_grpc = std::env::var("ENABLE_GRPC") - .map(|v| v != "false") - .unwrap_or(true); - - // Read port numbers - let http_port: u16 = std::env::var("AERIE_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(4000); - let grpc_port: u16 = std::env::var("AERIE_GRPC_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(4001); - - info!( - rest = enable_rest, - graphql = enable_graphql, - grpc = enable_grpc, - http_port, - grpc_port, - "aerie-api starting" - ); - - let state = Arc::new(AppState::from_env()); - - // Spawn gRPC listener as a background task - if enable_grpc { - let grpc_state = state.clone(); - tokio::spawn(async move { - run_grpc_listener(grpc_port, grpc_state).await; - }); - } - - // Build HTTP router and start listening - let app = build_router(state, enable_rest, enable_graphql); - let bind_addr = format!("0.0.0.0:{}", http_port); - let listener = tokio::net::TcpListener::bind(&bind_addr) - .await - .unwrap_or_else(|e| panic!("failed to bind {}: {}", bind_addr, e)); - - info!("HTTP listener on {}", bind_addr); - axum::serve(listener, app) - .await - .unwrap_or_else(|e| error!("HTTP server error: {}", e)); -} diff --git a/src/api/rust/src/policy.rs b/src/api/rust/src/policy.rs deleted file mode 100644 index 1ba430c..0000000 --- a/src/api/rust/src/policy.rs +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// policy.rs — Request policy evaluation for the Aerie API gateway -// -// Mirrors src/api/v/policy.v (135 LOC). -// -// Phase 1 policy: permissive — all well-formed requests are allowed. -// The policy gate validates X-Api-Key format (≥16 chars, alphanumeric -// + hyphen) and classifies callers into access tiers. All tiers are -// currently allowed; future phases will enforce per-tier rate limits -// and endpoint restrictions. -// -// Each decision is logged to the Redis audit trail via dual_log_audit. - -use chrono::Utc; -use uuid::Uuid; - -/// Access tier assigned by policy evaluation. -/// Higher tiers may access more endpoints (Phase 2+). -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -pub enum AccessLevel { - /// API key absent or malformed — request denied - Denied, - /// Well-formed key with no special permissions - Standard, - /// Extended quota and additional endpoints - Premium, - /// Full administrative access - Admin, - /// Internal system-to-system calls (e.g. Hypatia) - System, -} - -/// Outcome of evaluating a single inbound request against the policy engine. -/// -/// Phase 1: all non-Denied levels are treated identically (all allowed). -/// The `access_level` field is preserved in the audit log so that Phase 2 -/// enforcement can be back-tested against real traffic. -#[derive(Debug, Clone, serde::Serialize)] -pub struct PolicyDecision { - /// Whether the request is permitted to proceed - pub allowed: bool, - /// Tier classification of the caller - pub access_level: AccessLevel, - /// Human-readable reason, written to the audit log - pub reason: String, - /// Which policy module produced this decision (for tracing) - pub module_name: String, - /// UUID v4 correlation ID; echoed in the ProofEnvelope - pub query_id: String, -} - -/// Evaluate the policy for a single request. -/// -/// `api_key` is taken from the `X-Api-Key` HTTP header (may be empty). -/// `module_name` is the resolver name (e.g. `"telemetry"`) for tracing. -/// -/// Returns a `PolicyDecision` that callers must check before processing -/// the request. Never panics — all error paths return a Denied decision. -pub fn evaluate_policy(api_key: &str, module_name: &str) -> PolicyDecision { - let query_id = Uuid::new_v4().to_string(); - - // Validate key format: non-empty, ≥16 chars, alphanumeric + hyphen only. - // An absent or malformed key is denied immediately. - if api_key.is_empty() { - return PolicyDecision { - allowed: false, - access_level: AccessLevel::Denied, - reason: "X-Api-Key header is absent".to_string(), - module_name: module_name.to_string(), - query_id, - }; - } - - if api_key.len() < 16 { - return PolicyDecision { - allowed: false, - access_level: AccessLevel::Denied, - reason: format!("X-Api-Key too short ({} chars; minimum 16)", api_key.len()), - module_name: module_name.to_string(), - query_id, - }; - } - - if !api_key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { - return PolicyDecision { - allowed: false, - access_level: AccessLevel::Denied, - reason: "X-Api-Key contains disallowed characters (alphanumeric + hyphen only)" - .to_string(), - module_name: module_name.to_string(), - query_id, - }; - } - - // Phase 1: permissive — any valid key is granted Standard access. - // Future phases will look up the key in VerisimDB to assign tiers. - PolicyDecision { - allowed: true, - access_level: AccessLevel::Standard, - reason: "Phase 1 permissive policy — valid key format".to_string(), - module_name: module_name.to_string(), - query_id, - } -} - -/// Serialise a PolicyDecision to a compact JSON string for the Redis -/// audit log. Any serialisation error returns a minimal fallback JSON -/// so the audit pipeline is never interrupted. -pub fn decision_to_audit_json(decision: &PolicyDecision) -> String { - let now = Utc::now().to_rfc3339(); - let payload = serde_json::json!({ - "query_id": decision.query_id, - "allowed": decision.allowed, - "access_level": decision.access_level, - "reason": decision.reason, - "module": decision.module_name, - "ts": now, - }); - serde_json::to_string(&payload) - .unwrap_or_else(|_| r#"{"error":"audit_serialise_failed"}"#.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_key_is_denied() { - let d = evaluate_policy("", "test"); - assert!(!d.allowed); - assert_eq!(d.access_level, AccessLevel::Denied); - } - - #[test] - fn short_key_is_denied() { - let d = evaluate_policy("tooshort", "test"); - assert!(!d.allowed); - assert_eq!(d.access_level, AccessLevel::Denied); - } - - #[test] - fn invalid_chars_denied() { - let d = evaluate_policy("key-with-bad-char!", "test"); - assert!(!d.allowed); - assert_eq!(d.access_level, AccessLevel::Denied); - } - - #[test] - fn valid_key_allowed() { - let d = evaluate_policy("valid-key-16-chars", "test"); - assert!(d.allowed); - assert_eq!(d.access_level, AccessLevel::Standard); - } - - #[test] - fn query_id_is_uuid_v4_format() { - let d = evaluate_policy("valid-key-16-chars", "test"); - // UUID v4: 8-4-4-4-12 hex groups, version nibble = '4' - let parts: Vec<&str> = d.query_id.split('-').collect(); - assert_eq!(parts.len(), 5); - assert_eq!(parts[2].chars().next(), Some('4')); - } -} diff --git a/src/api/rust/src/proof.rs b/src/api/rust/src/proof.rs deleted file mode 100644 index 7dfbe38..0000000 --- a/src/api/rust/src/proof.rs +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// proof.rs — ProofEnvelope generation for Aerie API responses -// -// Mirrors src/api/v/proof.v (90 LOC). -// -// Every Aerie response is wrapped in a ProofEnvelope that commits the -// gateway to the exact bytes it served: -// -// { -// "data": , -// "proof": { -// "result_hash": "", -// "policy_hash": "", -// "query_id": "", -// "issued_at": "", -// "proof_type": "light", -// "signature": "" ← Phase 2: Ed25519 over result_hash -// } -// } -// -// Phase 1 ("light" mode): hashes only, no cryptographic signature. -// The empty `signature` field is intentional — it is a placeholder for -// Phase 2 where the gateway signs responses with an Ed25519 key stored -// in a Stapeln secret. - -use chrono::Utc; -use sha2::{Digest, Sha256}; - -/// The inner proof metadata block nested under the `"proof"` key. -#[derive(Debug, Clone, serde::Serialize)] -pub struct ProofMeta { - /// SHA-256 hex digest of the JSON-serialised response data - pub result_hash: String, - /// SHA-256 hex digest of the policy decision JSON - pub policy_hash: String, - /// UUID v4 from the PolicyDecision — correlates request ↔ audit log ↔ proof - pub query_id: String, - /// RFC 3339 timestamp at envelope creation time - pub issued_at: String, - /// Always "light" in Phase 1; "full" when Ed25519 signature is added - pub proof_type: String, - /// Ed25519 signature over result_hash (Phase 2). Empty string in Phase 1. - pub signature: String, -} - -/// Top-level envelope that wraps every Aerie API response. -/// -/// Callers receive `data` (the actual payload) alongside the proof -/// metadata, enabling independent verification of what the gateway -/// served and under what policy. -#[derive(Debug, Clone, serde::Serialize)] -pub struct ProofEnvelope { - /// The original response payload — any JSON value - pub data: serde_json::Value, - /// Cryptographic commitment metadata - pub proof: ProofMeta, -} - -/// Wrap `data` in a ProofEnvelope. -/// -/// `data` — the JSON value to wrap (already-serialised response body) -/// `policy_json` — the audit JSON from `decision_to_audit_json` -/// `query_id` — UUID v4 from the PolicyDecision for cross-correlation -/// -/// Returns the fully-serialised envelope as a JSON string ready for -/// writing to the HTTP response body. -pub fn wrap_with_proof( - data: serde_json::Value, - policy_json: &str, - query_id: &str, -) -> String { - let result_hash = sha256_hex(&data.to_string()); - let policy_hash = sha256_hex(policy_json); - let issued_at = Utc::now().to_rfc3339(); - - let envelope = ProofEnvelope { - data, - proof: ProofMeta { - result_hash, - policy_hash, - query_id: query_id.to_string(), - issued_at, - proof_type: "light".to_string(), - signature: String::new(), - }, - }; - - serde_json::to_string(&envelope) - .unwrap_or_else(|_| r#"{"error":"envelope_serialise_failed"}"#.to_string()) -} - -/// Compute the SHA-256 hex digest of a string. -fn sha256_hex(input: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(input.as_bytes()); - hex::encode(hasher.finalize()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn envelope_has_all_fields() { - let data = serde_json::json!({"status": "ok"}); - let json = wrap_with_proof(data, r#"{"allowed":true}"#, "test-query-id"); - let v: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert!(v["data"].is_object()); - assert!(v["proof"]["result_hash"].is_string()); - assert!(v["proof"]["policy_hash"].is_string()); - assert_eq!(v["proof"]["query_id"], "test-query-id"); - assert_eq!(v["proof"]["proof_type"], "light"); - assert_eq!(v["proof"]["signature"], ""); - } - - #[test] - fn result_hash_is_sha256_of_data() { - let data = serde_json::json!({"x": 1}); - let json = wrap_with_proof(data.clone(), "{}", "qid"); - let v: serde_json::Value = serde_json::from_str(&json).unwrap(); - let expected = sha256_hex(&data.to_string()); - assert_eq!(v["proof"]["result_hash"], expected); - } - - #[test] - fn deterministic_hash_for_same_input() { - let h1 = sha256_hex("hello aerie"); - let h2 = sha256_hex("hello aerie"); - assert_eq!(h1, h2); - assert_eq!(h1.len(), 64); // 32 bytes → 64 hex chars - } -} diff --git a/src/api/rust/src/redis_client.rs b/src/api/rust/src/redis_client.rs deleted file mode 100644 index 54dcd13..0000000 --- a/src/api/rust/src/redis_client.rs +++ /dev/null @@ -1,283 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// redis_client.rs — Synchronous RESP protocol client for Aerie -// -// Mirrors src/api/v/redis_client.v (184 LOC). -// -// Implements a minimal subset of the Redis Serialization Protocol (RESP) -// over a raw TCP connection. This avoids a heavy Redis crate dependency -// while preserving the same wire protocol as the V implementation. -// -// Supported commands: PING, SET, GET, LPUSH, LTRIM, LRANGE, XADD, XRANGE -// -// The client is used for two purposes: -// 1. Hot cache: GET/SET for recent telemetry responses (30 s TTL) -// 2. Audit log: LPUSH + LTRIM (bounded to 10 000 entries) + XADD to -// the "aerie:audit" stream for real-time monitoring -// -// Thread safety: RedisClient holds a single TCP connection. In the async -// axum context each request acquires a cloned `Arc>` -// before issuing commands. - -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; -use std::time::Duration; - -/// Connection handle and configuration for the Redis RESP client. -/// -/// The client connects lazily on first use and reconnects automatically -/// after a broken pipe. All errors are logged to stderr and treated as -/// non-fatal — the caller must not block or crash if Redis is unavailable. -pub struct RedisClient { - /// Resolved TCP address, e.g. `"redis:6379"` or `"localhost:6379"` - addr: String, - /// Live TCP connection, or None when not yet connected / after error - stream: Option, - /// Maximum time to wait for a Redis reply before giving up - timeout: Duration, -} - -impl RedisClient { - /// Create a new client from the `REDIS_URL` environment variable. - /// - /// Accepted formats: `redis://host:port` or bare `host:port`. - /// Falls back to `redis:6379` (container default) when unset. - /// Does NOT attempt to connect until the first command. - pub fn from_env() -> Self { - let raw = std::env::var("REDIS_URL") - .unwrap_or_else(|_| "redis:6379".to_string()); - // Strip redis:// scheme prefix if present - let addr = raw - .strip_prefix("redis://") - .unwrap_or(&raw) - .to_string(); - RedisClient { - addr, - stream: None, - timeout: Duration::from_millis(500), - } - } - - /// Ensure a live TCP connection exists. Called before every command. - /// On any IO error the stream is dropped; the next call retries. - fn ensure_connected(&mut self) { - if self.stream.is_some() { - return; - } - match TcpStream::connect(&self.addr) { - Ok(s) => { - // Best-effort timeouts — errors are non-fatal - let _ = s.set_read_timeout(Some(self.timeout)); - let _ = s.set_write_timeout(Some(self.timeout)); - self.stream = Some(s); - } - Err(e) => { - eprintln!("[aerie] redis: connect to {} failed: {}", self.addr, e); - } - } - } - - /// Send a pre-built RESP command and read one reply line. - /// - /// Returns `None` on any IO error (connection is dropped and will - /// be re-established on the next call). - fn send_command(&mut self, cmd: &[u8]) -> Option { - self.ensure_connected(); - let stream = self.stream.as_mut()?; - - if let Err(e) = stream.write_all(cmd) { - eprintln!("[aerie] redis: write failed: {}", e); - self.stream = None; - return None; - } - - // Read one complete RESP reply. We only need the first line for - // simple string (+OK), integer (:N), bulk header ($N), or error (-ERR). - let mut reader = BufReader::new(stream.try_clone().ok()?); - let mut line = String::new(); - if let Err(e) = reader.read_line(&mut line) { - eprintln!("[aerie] redis: read failed: {}", e); - self.stream = None; - return None; - } - - Some(line.trim_end_matches("\r\n").to_string()) - } - - /// Build a RESP array command from a slice of string arguments. - /// - /// RESP encoding: `*N\r\n` followed by N bulk strings `$len\r\ndata\r\n`. - fn build_resp(args: &[&str]) -> Vec { - let mut buf = Vec::with_capacity(128); - buf.extend_from_slice(format!("*{}\r\n", args.len()).as_bytes()); - for arg in args { - buf.extend_from_slice(format!("${}\r\n{}\r\n", arg.len(), arg).as_bytes()); - } - buf - } - - /// Send PING and return true if the server replies `+PONG`. - pub fn ping(&mut self) -> bool { - let cmd = Self::build_resp(&["PING"]); - matches!(self.send_command(&cmd).as_deref(), Some("+PONG")) - } - - /// SET key to value with an optional TTL in seconds. - /// - /// Uses `SET key value EX ttl` when ttl > 0, plain `SET key value` otherwise. - pub fn set(&mut self, key: &str, value: &str, ttl_secs: u64) { - let cmd = if ttl_secs > 0 { - let ttl = ttl_secs.to_string(); - Self::build_resp(&["SET", key, value, "EX", &ttl]) - } else { - Self::build_resp(&["SET", key, value]) - }; - if let Some(r) = self.send_command(&cmd) { - if !r.starts_with('+') { - eprintln!("[aerie] redis: SET {} returned {}", key, r); - } - } - } - - /// GET a key. Returns `None` if the key is absent or on any error. - pub fn get(&mut self, key: &str) -> Option { - let cmd = Self::build_resp(&["GET", key]); - let header = self.send_command(&cmd)?; - - if header == "$-1" { - // RESP null bulk string — key not found - return None; - } - - if !header.starts_with('$') { - eprintln!("[aerie] redis: GET {} unexpected reply {}", key, header); - return None; - } - - // Parse the byte count from the bulk string header - let byte_count: usize = header[1..].parse().ok()?; - let stream = self.stream.as_mut()?; - - // Read exactly byte_count bytes plus the trailing \r\n - let mut buf = vec![0u8; byte_count + 2]; - if let Err(e) = stream.read_exact(&mut buf) { - eprintln!("[aerie] redis: GET {} body read failed: {}", key, e); - self.stream = None; - return None; - } - - String::from_utf8(buf[..byte_count].to_vec()).ok() - } - - /// LPUSH value onto list key (prepend). Returns false on error. - pub fn lpush(&mut self, key: &str, value: &str) -> bool { - let cmd = Self::build_resp(&["LPUSH", key, value]); - matches!(self.send_command(&cmd).as_deref(), Some(s) if s.starts_with(':')) - } - - /// LTRIM list to keep only the first `max_len` entries. - /// Used to keep the audit list bounded (≤10 000 entries). - pub fn ltrim(&mut self, key: &str, max_len: usize) { - let stop = (max_len - 1).to_string(); - let cmd = Self::build_resp(&["LTRIM", key, "0", &stop]); - if let Some(r) = self.send_command(&cmd) { - if !r.starts_with('+') { - eprintln!("[aerie] redis: LTRIM {} returned {}", key, r); - } - } - } - - /// LRANGE — retrieve `count` entries from list key starting at `offset`. - /// - /// Returns an empty Vec on error. Entries are returned in LIFO order - /// because LPUSH prepends; the newest entry is at index 0. - pub fn lrange(&mut self, key: &str, offset: usize, count: usize) -> Vec { - let start = offset.to_string(); - let stop = (offset + count - 1).to_string(); - let cmd = Self::build_resp(&["LRANGE", key, &start, &stop]); - - let header = match self.send_command(&cmd) { - Some(h) => h, - None => return vec![], - }; - - if !header.starts_with('*') { - eprintln!("[aerie] redis: LRANGE {} unexpected reply {}", key, header); - return vec![]; - } - - let n: usize = match header[1..].parse() { - Ok(n) => n, - Err(_) => return vec![], - }; - - let stream = match self.stream.as_mut() { - Some(s) => s, - None => return vec![], - }; - - let mut results = Vec::with_capacity(n); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - - for _ in 0..n { - let mut size_line = String::new(); - if reader.read_line(&mut size_line).is_err() { - break; - } - let size_line = size_line.trim_end_matches("\r\n"); - if !size_line.starts_with('$') { - continue; - } - let byte_count: usize = match size_line[1..].parse() { - Ok(b) => b, - Err(_) => continue, - }; - let mut buf = vec![0u8; byte_count + 2]; - if reader.read_exact(&mut buf).is_err() { - break; - } - if let Ok(s) = String::from_utf8(buf[..byte_count].to_vec()) { - results.push(s); - } - } - - results - } - - /// XADD to stream key — appends an entry with auto-generated ID. - /// - /// `fields` is a flat slice of alternating field-name / value pairs. - /// Used to fan audit events into the `"aerie:audit"` stream for - /// real-time monitoring by Observatory. - pub fn xadd(&mut self, key: &str, fields: &[(&str, &str)]) { - if fields.is_empty() { - return; - } - let mut args: Vec<&str> = vec!["XADD", key, "*"]; - for (k, v) in fields { - args.push(k); - args.push(v); - } - let cmd = Self::build_resp(&args); - if let Some(r) = self.send_command(&cmd) { - if r.starts_with('-') { - eprintln!("[aerie] redis: XADD {} error: {}", key, r); - } - } - } - - /// Log an audit event to both the bounded list and the audit stream. - /// - /// List key: `"aerie:audit"` — bounded to `AUDIT_LIST_CAP` entries. - /// Stream key: `"aerie:audit:stream"` — unbounded (ObserVatory manages TTL). - pub fn log_audit(&mut self, event_json: &str) { - const AUDIT_LIST_CAP: usize = 10_000; - const LIST_KEY: &str = "aerie:audit"; - const STREAM_KEY: &str = "aerie:audit:stream"; - - self.lpush(LIST_KEY, event_json); - self.ltrim(LIST_KEY, AUDIT_LIST_CAP); - self.xadd(STREAM_KEY, &[("event", event_json)]); - } -} diff --git a/src/api/rust/src/resolvers.rs b/src/api/rust/src/resolvers.rs deleted file mode 100644 index 5e7e07a..0000000 --- a/src/api/rust/src/resolvers.rs +++ /dev/null @@ -1,380 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// resolvers.rs — Request handlers for Aerie REST and GraphQL endpoints -// -// Mirrors src/api/v/resolvers.v (289 LOC). -// -// Each resolver: -// 1. Evaluates the request policy (evaluate_policy) -// 2. Checks the Redis hot cache (GET); returns cached data if fresh -// 3. Calls the appropriate backend(s) for fresh data -// 4. Logs the policy decision to Redis (log_audit) + VerisimDB (store_audit) -// 5. Wraps the response in a ProofEnvelope (wrap_with_proof) -// 6. Stores the wrapped response in Redis (SET with TTL) -// 7. Returns the envelope JSON -// -// Resolvers are called from axum route handlers in main.rs. - -use std::sync::Arc; -use tokio::sync::Mutex; - -use crate::backends::{ - HyperglassClient, LibreSpeedClient, SmokePingClient, VerisimDbClient, -}; -use crate::policy::{decision_to_audit_json, evaluate_policy}; -use crate::proof::wrap_with_proof; -use crate::redis_client::RedisClient; - -/// All resolver dependencies bundled into a single shared state. -/// -/// Wrapped in `Arc` and injected into axum via `Extension`. -pub struct AppState { - pub redis: Arc>, - pub librespeed: LibreSpeedClient, - pub hyperglass: HyperglassClient, - pub smokeping: SmokePingClient, - pub verisimdb: VerisimDbClient, -} - -impl AppState { - /// Construct from environment variables. Panics if reqwest client - /// construction fails (should never happen on a sane system). - pub fn from_env() -> Self { - AppState { - redis: Arc::new(Mutex::new(RedisClient::from_env())), - librespeed: LibreSpeedClient::from_env(), - hyperglass: HyperglassClient::from_env(), - smokeping: SmokePingClient::from_env(), - verisimdb: VerisimDbClient::from_env(), - } - } -} - -// ─── Cache helpers ──────────────────────────────────────────────────────────── - -const CACHE_TTL: u64 = 30; // seconds; matches V implementation - -/// Attempt to return a cached response for `cache_key`. -/// Returns `Some(json)` on a cache hit, `None` on miss or Redis error. -async fn cache_get(redis: &Arc>, cache_key: &str) -> Option { - redis.lock().await.get(cache_key) -} - -/// Store `value` in Redis under `cache_key` with the standard TTL. -async fn cache_set(redis: &Arc>, cache_key: &str, value: &str) { - redis.lock().await.set(cache_key, value, CACHE_TTL); -} - -/// Fire-and-forget: log policy decision to Redis + VerisimDB audit trail. -async fn dual_log( - redis: &Arc>, - verisimdb: &VerisimDbClient, - audit_json: &str, -) { - redis.lock().await.log_audit(audit_json); - verisimdb.store_audit(audit_json).await; -} - -// ─── Resolver: telemetry ───────────────────────────────────────────────────── - -/// Resolve a telemetry request — fetches client IP/ISP from LibreSpeed. -/// -/// GET /api/v1/telemetry -/// Header: X-Api-Key: -pub async fn resolve_telemetry(state: Arc, api_key: &str) -> (u16, String) { - let decision = evaluate_policy(api_key, "telemetry"); - let audit_json = decision_to_audit_json(&decision); - - if !decision.allowed { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (401, denied_envelope(&decision.query_id)); - } - - let cache_key = "aerie:cache:telemetry"; - if let Some(cached) = cache_get(&state.redis, cache_key).await { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (200, cached); - } - - let result = match state.librespeed.get_ip_info().await { - Ok(r) => r, - Err(e) => { - eprintln!("[aerie] telemetry: librespeed error: {}", e); - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (502, error_envelope("librespeed_unavailable", &decision.query_id)); - } - }; - - let data = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null); - let envelope = wrap_with_proof(data, &audit_json, &decision.query_id); - - cache_set(&state.redis, cache_key, &envelope).await; - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - (200, envelope) -} - -// ─── Resolver: route forensics ─────────────────────────────────────────────── - -/// Resolve a BGP route forensics request via Hyperglass. -/// -/// GET /api/v1/routes?target=&vrf= -/// Header: X-Api-Key: -pub async fn resolve_route_forensics( - state: Arc, - api_key: &str, - target: &str, - vrf: &str, -) -> (u16, String) { - let decision = evaluate_policy(api_key, "route_forensics"); - let audit_json = decision_to_audit_json(&decision); - - if !decision.allowed { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (401, denied_envelope(&decision.query_id)); - } - - // Cache key includes target + vrf so different queries get independent slots - let cache_key = format!("aerie:cache:routes:{}:{}", target, vrf); - if let Some(cached) = cache_get(&state.redis, &cache_key).await { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (200, cached); - } - - let result = match state.hyperglass.query_routes(target, vrf).await { - Ok(r) => r, - Err(e) => { - eprintln!("[aerie] route_forensics: hyperglass error: {}", e); - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (502, error_envelope("hyperglass_unavailable", &decision.query_id)); - } - }; - - let data = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null); - let envelope = wrap_with_proof(data, &audit_json, &decision.query_id); - - cache_set(&state.redis, &cache_key, &envelope).await; - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - (200, envelope) -} - -// ─── Resolver: smokeping ───────────────────────────────────────────────────── - -/// Resolve a SmokePing latency/loss request. -/// -/// GET /api/v1/smokeping?target= -/// Header: X-Api-Key: -pub async fn resolve_smokeping( - state: Arc, - api_key: &str, - target: &str, -) -> (u16, String) { - let decision = evaluate_policy(api_key, "smokeping"); - let audit_json = decision_to_audit_json(&decision); - - if !decision.allowed { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (401, denied_envelope(&decision.query_id)); - } - - let cache_key = format!("aerie:cache:smokeping:{}", target); - if let Some(cached) = cache_get(&state.redis, &cache_key).await { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (200, cached); - } - - let payload = match state.smokeping.get_data(target).await { - Ok(p) => p, - Err(e) => { - eprintln!("[aerie] smokeping: error: {}", e); - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (502, error_envelope("smokeping_unavailable", &decision.query_id)); - } - }; - - let data = serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null); - let envelope = wrap_with_proof(data, &audit_json, &decision.query_id); - - cache_set(&state.redis, &cache_key, &envelope).await; - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - (200, envelope) -} - -// ─── Resolver: audit ───────────────────────────────────────────────────────── - -/// Resolve an audit log retrieval request — reads from Redis hot cache. -/// -/// GET /api/v1/audit?offset=&count= -/// Header: X-Api-Key: -pub async fn resolve_audit( - state: Arc, - api_key: &str, - offset: usize, - count: usize, -) -> (u16, String) { - let decision = evaluate_policy(api_key, "audit"); - let audit_json = decision_to_audit_json(&decision); - - if !decision.allowed { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (401, denied_envelope(&decision.query_id)); - } - - let entries = state - .redis - .lock() - .await - .lrange("aerie:audit", offset, count.min(100)); - - // Capture length before moving entries into the JSON value - let entries_count = entries.len(); - let data = serde_json::json!({ - "entries": entries, - "offset": offset, - "count": entries_count, - }); - - let envelope = wrap_with_proof(data, &audit_json, &decision.query_id); - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - (200, envelope) -} - -// ─── Resolver: temporal audit ──────────────────────────────────────────────── - -/// Resolve a bitemporal audit query against VerisimDB. -/// -/// GET /api/v1/temporal-audit?as_of=&start=&end=&limit= -/// Header: X-Api-Key: -/// -/// If `as_of` is provided, runs a point-in-time query. -/// If `start` and `end` are provided, runs a range query. -/// If none are provided, returns the 100 most recent events (as_of = now). -pub async fn resolve_temporal_audit( - state: Arc, - api_key: &str, - as_of: Option<&str>, - start: Option<&str>, - end: Option<&str>, - limit: usize, -) -> (u16, String) { - let decision = evaluate_policy(api_key, "temporal_audit"); - let audit_json = decision_to_audit_json(&decision); - - if !decision.allowed { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (401, denied_envelope(&decision.query_id)); - } - - let effective_limit = limit.min(1000); - - let events = match (as_of, start, end) { - (Some(t), _, _) => state.verisimdb.query_as_of(t, effective_limit).await, - (None, Some(s), Some(e)) => { - state.verisimdb.query_between(s, e, effective_limit).await - } - _ => { - let now = chrono::Utc::now().to_rfc3339(); - state - .verisimdb - .query_as_of(&now, effective_limit) - .await - } - }; - - // Capture length before moving events into the JSON value - let events_count = events.len(); - let data = serde_json::json!({ - "events": events, - "count": events_count, - }); - let envelope = wrap_with_proof(data, &audit_json, &decision.query_id); - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - (200, envelope) -} - -// ─── Resolver: GraphQL ─────────────────────────────────────────────────────── - -/// Resolve a GraphQL query. -/// -/// POST /api/v1/graphql -/// Header: X-Api-Key: -/// Body: {"query": "...", "variables": {...}} -/// -/// Phase 1: dispatches `telemetry`, `routes`, `smokeping` queries to the -/// appropriate resolver based on the root field name. Unknown queries -/// return an error in the standard GraphQL error envelope. -pub async fn resolve_graphql( - state: Arc, - api_key: &str, - body: serde_json::Value, -) -> (u16, String) { - let decision = evaluate_policy(api_key, "graphql"); - let audit_json = decision_to_audit_json(&decision); - - if !decision.allowed { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - return (401, denied_envelope(&decision.query_id)); - } - - // Extract query string — required field - let query = match body["query"].as_str() { - Some(q) => q.trim().to_string(), - None => { - return ( - 400, - serde_json::json!({"errors": [{"message": "query field required"}]}) - .to_string(), - ) - } - }; - - // Rudimentary field extraction: look for the first root field name. - // A proper GraphQL parser is Phase 2. - let (status, result) = if query.contains("telemetry") { - resolve_telemetry(state.clone(), api_key).await - } else if query.contains("routes") { - let vars = &body["variables"]; - let target = vars["target"].as_str().unwrap_or("0.0.0.0"); - let vrf = vars["vrf"].as_str().unwrap_or("default"); - resolve_route_forensics(state.clone(), api_key, target, vrf).await - } else if query.contains("smokeping") { - let vars = &body["variables"]; - let target = vars["target"].as_str().unwrap_or("localhost"); - resolve_smokeping(state.clone(), api_key, target).await - } else { - let err = serde_json::json!({ - "errors": [{"message": "unknown root field — supported: telemetry, routes, smokeping"}] - }); - (400, err.to_string()) - }; - - // Wrap successful results in a GraphQL data envelope - if status == 200 { - let inner: serde_json::Value = serde_json::from_str(&result) - .unwrap_or(serde_json::Value::Null); - let gql = serde_json::json!({"data": inner}); - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - (200, gql.to_string()) - } else { - dual_log(&state.redis, &state.verisimdb, &audit_json).await; - (status, result) - } -} - -// ─── Envelope helpers ───────────────────────────────────────────────────────── - -fn denied_envelope(query_id: &str) -> String { - serde_json::json!({ - "error": "policy_denied", - "query_id": query_id, - }) - .to_string() -} - -fn error_envelope(code: &str, query_id: &str) -> String { - serde_json::json!({ - "error": code, - "query_id": query_id, - }) - .to_string() -} diff --git a/src/api/rust/src/verb_governance.rs b/src/api/rust/src/verb_governance.rs deleted file mode 100644 index 1abf06e..0000000 --- a/src/api/rust/src/verb_governance.rs +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// verb_governance.rs — HTTP verb whitelist with prefix trie and timing jitter -// -// Mirrors src/api/v/verb_governance.v (285 LOC). -// -// Security model: -// - Each route prefix has an explicit allowlist of HTTP verbs. -// - Requests with a disallowed verb receive a 404 (not 405): "stealth" mode -// makes the gateway appear to be a static server with nothing at that path, -// frustrating enumeration tools that rely on 405 to confirm route existence. -// - A random 1–8 ms delay is injected on both allowed and disallowed responses -// to defeat timing-based route discovery. -// -// Trie structure: VerbGovernor holds a sorted Vec of (prefix, allowed_verbs) -// pairs. Lookup walks from the most specific to the least specific match, -// which with a sorted Vec is O(n) but typically exits after 1–2 comparisons -// for well-configured routes. (A real prefix trie is future work.) - -use rand::Rng; -use std::collections::HashMap; - -/// A single node in the verb governance table. -/// -/// `prefix` — URL path prefix (e.g. `"/api/v1/telemetry"`). -/// `allowed` — HTTP methods that are permitted at this prefix. -struct GovNode { - prefix: String, - allowed: Vec, -} - -/// Verdict returned by `VerbGovernor::check`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum GovVerdict { - /// Request is permitted to proceed - Allow, - /// Request should receive a 404 (stealth deny — never 405) - StealthDeny, -} - -/// HTTP verb whitelist with stealth-deny behaviour and timing jitter. -/// -/// Initialise once at startup via `VerbGovernor::new()` and share via -/// `Arc` across all request handlers. -pub struct VerbGovernor { - /// Sorted by prefix length descending so the most specific match wins - nodes: Vec, -} - -impl VerbGovernor { - /// Build the default Aerie verb governance table. - /// - /// REST routes: GET-only for telemetry, routes, smokeping, audit, - /// temporal-audit; POST for GraphQL; GET for health (no auth required). - /// gRPC routes are governed separately by the raw TCP listener. - pub fn new() -> Self { - let table: &[(&str, &[&str])] = &[ - ("/health", &["GET", "HEAD"]), - ("/api/v1/telemetry", &["GET"]), - ("/api/v1/graphql", &["POST"]), - ("/api/v1/routes", &["GET"]), - ("/api/v1/smokeping", &["GET"]), - ("/api/v1/audit", &["GET"]), - ("/api/v1/temporal-audit", &["GET"]), - ]; - - let mut nodes: Vec = table - .iter() - .map(|(prefix, methods)| GovNode { - prefix: prefix.to_string(), - allowed: methods.iter().map(|m| m.to_string()).collect(), - }) - .collect(); - - // Sort by descending prefix length so the longest (most specific) match wins - nodes.sort_by(|a, b| b.prefix.len().cmp(&a.prefix.len())); - - VerbGovernor { nodes } - } - - /// Check whether `method` is allowed for the request `path`. - /// - /// Matches by prefix (longest match first). Unknown paths are also - /// stealth-denied — the gateway never reveals its route table to - /// unauthenticated probes. - /// - /// The caller is responsible for applying the timing jitter via - /// `VerbGovernor::jitter_delay()` regardless of verdict, to ensure - /// timing does not distinguish Allow from StealthDeny. - pub fn check(&self, method: &str, path: &str) -> GovVerdict { - for node in &self.nodes { - if path == node.prefix || path.starts_with(&format!("{}/", node.prefix)) { - let method_upper = method.to_ascii_uppercase(); - if node.allowed.iter().any(|m| m == &method_upper) { - return GovVerdict::Allow; - } else { - // Verb found but not allowed — stealth deny - return GovVerdict::StealthDeny; - } - } - } - // No matching prefix — stealth deny unknown routes - GovVerdict::StealthDeny - } - - /// Sleep for a uniformly random duration in [1, 8] milliseconds. - /// - /// Called on BOTH allowed and denied paths so that response timing - /// is statistically indistinguishable between the two outcomes. - /// Implemented synchronously here; callers in async context should - /// call `tokio::time::sleep(jitter_duration())` instead. - pub fn jitter_duration() -> std::time::Duration { - let ms = rand::thread_rng().gen_range(1u64..=8); - std::time::Duration::from_millis(ms) - } -} - -impl Default for VerbGovernor { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn gov() -> VerbGovernor { - VerbGovernor::new() - } - - #[test] - fn get_health_allowed() { - assert_eq!(gov().check("GET", "/health"), GovVerdict::Allow); - } - - #[test] - fn post_health_denied() { - // POST to /health is not in the allowlist → stealth deny - assert_eq!(gov().check("POST", "/health"), GovVerdict::StealthDeny); - } - - #[test] - fn get_telemetry_allowed() { - assert_eq!(gov().check("GET", "/api/v1/telemetry"), GovVerdict::Allow); - } - - #[test] - fn delete_telemetry_denied() { - assert_eq!(gov().check("DELETE", "/api/v1/telemetry"), GovVerdict::StealthDeny); - } - - #[test] - fn post_graphql_allowed() { - assert_eq!(gov().check("POST", "/api/v1/graphql"), GovVerdict::Allow); - } - - #[test] - fn get_graphql_denied() { - // GraphQL is POST-only - assert_eq!(gov().check("GET", "/api/v1/graphql"), GovVerdict::StealthDeny); - } - - #[test] - fn unknown_route_denied() { - assert_eq!(gov().check("GET", "/api/v1/nonexistent"), GovVerdict::StealthDeny); - } - - #[test] - fn method_check_is_case_insensitive() { - assert_eq!(gov().check("get", "/health"), GovVerdict::Allow); - assert_eq!(gov().check("Get", "/health"), GovVerdict::Allow); - } - - #[test] - fn jitter_duration_in_range() { - for _ in 0..100 { - let d = VerbGovernor::jitter_duration(); - assert!(d.as_millis() >= 1 && d.as_millis() <= 8); - } - } -} diff --git a/src/api/zig/config.zig b/src/api/zig/config.zig new file mode 100644 index 0000000..fcb9ba7 --- /dev/null +++ b/src/api/zig/config.zig @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// config.zig — typed configuration, loaded once at startup. +// +// Precedence (12-factor): defaults < KYAML file (AERIE_CONFIG) < env. +// KYAML per estate rule Y-3 (standards/3-practice/YAML-POLICY.adoc, +// parser: kyaml.zig). Unknown keys are ERRORS, not silently ignored — +// a typo in a config file must never quietly take the default. +// +// This is the ONLY module that reads the environment (getenv appears +// nowhere else in the gateway; the probe clients keep their legacy +// env reads until the Phase-3 transport unification). + +const std = @import("std"); +const kyaml = @import("kyaml.zig"); + +pub const AuthMode = enum { open, deny }; + +pub const Config = struct { + port: u16 = 4000, + rest_enabled: bool = true, + graphql_enabled: bool = true, + grpc_enabled: bool = true, + + redis_url: []const u8 = "redis://redis:6379", + librespeed_url: []const u8 = "http://librespeed:80", + hyperglass_url: []const u8 = "http://hyperglass:80", + smokeping_url: []const u8 = "http://smokeping:80", + verisim_url: []const u8 = "http://verisim:8084", + + /// Phase 2 flips the default to .deny when the keystore lands; + /// until then .open preserves the Phase-1 permissive behaviour — + /// honestly, not silently. + auth_mode: AuthMode = .open, + + /// Environment accessor, injectable for tests. + pub const Env = *const fn (name: []const u8) ?[]const u8; + + fn realEnv(name: []const u8) ?[]const u8 { + return std.posix.getenv(name); + } + + /// Load with precedence: defaults < KYAML file (AERIE_CONFIG) < env. + /// All returned strings are duplicated into `arena` (stable for the + /// process lifetime; no getenv-lifetime hazards). + pub fn load(arena: std.mem.Allocator) Config { + return loadWithEnv(arena, realEnv); + } + + pub fn loadWithEnv(arena: std.mem.Allocator, env: Env) Config { + var cfg = Config{}; + + if (env("AERIE_CONFIG")) |path| { + const src = std.fs.cwd().readFileAlloc(arena, path, 1 << 20) catch |e| { + std.debug.print("[aerie] config: cannot read {s} ({}) — using defaults+env\n", .{ path, e }); + return applyEnv(arena, cfg, env); + }; + cfg.applyKyaml(arena, src) catch |e| { + std.debug.print("[aerie] config: KYAML parse error in {s} ({}) — using defaults+env\n", .{ path, e }); + return applyEnv(arena, cfg, env); + }; + } + + return applyEnv(arena, cfg, env); + } + + /// Apply a KYAML document. Unknown keys are errors (typo safety). + pub fn applyKyaml(self: *Config, arena: std.mem.Allocator, src: []const u8) kyaml.Error!void { + const doc = try kyaml.parse(arena, src); + const m = switch (doc) { + .map => |m| m, + else => return kyaml.Error.EmptyDocument, + }; + for (m.entries) |e| { + if (std.mem.eql(u8, e.key, "port")) { + self.port = @intCast(e.value.asInt() orelse return kyaml.Error.UnquotedScalar); + } else if (std.mem.eql(u8, e.key, "rest")) { + self.rest_enabled = e.value.asBool() orelse return kyaml.Error.UnquotedScalar; + } else if (std.mem.eql(u8, e.key, "graphql")) { + self.graphql_enabled = e.value.asBool() orelse return kyaml.Error.UnquotedScalar; + } else if (std.mem.eql(u8, e.key, "grpc")) { + self.grpc_enabled = e.value.asBool() orelse return kyaml.Error.UnquotedScalar; + } else if (std.mem.eql(u8, e.key, "redis_url")) { + self.redis_url = try arena.dupe(u8, e.value.asString() orelse return kyaml.Error.UnquotedScalar); + } else if (std.mem.eql(u8, e.key, "librespeed_url")) { + self.librespeed_url = try arena.dupe(u8, e.value.asString() orelse return kyaml.Error.UnquotedScalar); + } else if (std.mem.eql(u8, e.key, "hyperglass_url")) { + self.hyperglass_url = try arena.dupe(u8, e.value.asString() orelse return kyaml.Error.UnquotedScalar); + } else if (std.mem.eql(u8, e.key, "smokeping_url")) { + self.smokeping_url = try arena.dupe(u8, e.value.asString() orelse return kyaml.Error.UnquotedScalar); + } else if (std.mem.eql(u8, e.key, "verisim_url")) { + self.verisim_url = try arena.dupe(u8, e.value.asString() orelse return kyaml.Error.UnquotedScalar); + } else if (std.mem.eql(u8, e.key, "auth")) { + const v = e.value.asString() orelse return kyaml.Error.UnquotedScalar; + if (std.mem.eql(u8, v, "open")) { + self.auth_mode = .open; + } else if (std.mem.eql(u8, v, "deny")) { + self.auth_mode = .deny; + } else { + return kyaml.Error.TrailingJunk; // unknown auth mode value + } + } else { + return kyaml.Error.BadKey; // unknown key: typo safety + } + } + } + + fn applyEnv(arena: std.mem.Allocator, cfg_in: Config, env: Env) Config { + var cfg = cfg_in; + if (env("PORT")) |v| { + cfg.port = std.fmt.parseInt(u16, v, 10) catch cfg.port; + } + if (env("ENABLE_REST")) |v| cfg.rest_enabled = envBool(v, cfg.rest_enabled); + if (env("ENABLE_GRAPHQL")) |v| cfg.graphql_enabled = envBool(v, cfg.graphql_enabled); + if (env("ENABLE_GRPC")) |v| cfg.grpc_enabled = envBool(v, cfg.grpc_enabled); + if (env("REDIS_URL")) |v| cfg.redis_url = arena.dupe(u8, v) catch cfg.redis_url; + if (env("LIBRESPEED_URL")) |v| cfg.librespeed_url = arena.dupe(u8, v) catch cfg.librespeed_url; + if (env("HYPERGLASS_URL")) |v| cfg.hyperglass_url = arena.dupe(u8, v) catch cfg.hyperglass_url; + if (env("SMOKEPING_URL")) |v| cfg.smokeping_url = arena.dupe(u8, v) catch cfg.smokeping_url; + if (env("VERISIMDB_URL")) |v| cfg.verisim_url = arena.dupe(u8, v) catch cfg.verisim_url; + if (env("AERIE_AUTH_MODE")) |v| { + if (std.mem.eql(u8, v, "deny")) cfg.auth_mode = .deny; + if (std.mem.eql(u8, v, "open")) cfg.auth_mode = .open; + } + return cfg; + } + + /// "false"/"0"/"no" (case-insensitive) → false; anything else → default. + fn envBool(v: []const u8, default: bool) bool { + if (v.len == 0) return default; + return !(std.ascii.eqlIgnoreCase(v, "false") or + std.ascii.eqlIgnoreCase(v, "0") or + std.ascii.eqlIgnoreCase(v, "no")); + } +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const TestEnv = struct { + vars: []const [2][]const u8, + fn get(self: *const TestEnv, name: []const u8) ?[]const u8 { + for (self.vars) |kv| { + if (std.mem.eql(u8, kv[0], name)) return kv[1]; + } + return null; + } +}; + +fn testEnvPtr(entries: []const [2][]const u8) Config.Env { + const S = struct { + var vars: []const [2][]const u8 = &.{}; + fn get(name: []const u8) ?[]const u8 { + for (vars) |kv| { + if (std.mem.eql(u8, kv[0], name)) return kv[1]; + } + return null; + } + }; + S.vars = entries; + return S.get; +} + +test "config: defaults match the compose topology" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const cfg = Config.loadWithEnv(arena.allocator(), testEnvPtr(&.{})); + try std.testing.expectEqual(@as(u16, 4000), cfg.port); + try std.testing.expect(cfg.rest_enabled and cfg.graphql_enabled and cfg.grpc_enabled); + try std.testing.expectEqualStrings("http://librespeed:80", cfg.librespeed_url); + try std.testing.expectEqual(AuthMode.open, cfg.auth_mode); +} + +test "config: env overrides defaults" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const cfg = Config.loadWithEnv(arena.allocator(), testEnvPtr(&.{ + .{ "PORT", "4321" }, + .{ "ENABLE_GRPC", "false" }, + .{ "LIBRESPEED_URL", "http://probe:9999" }, + })); + try std.testing.expectEqual(@as(u16, 4321), cfg.port); + try std.testing.expect(!cfg.grpc_enabled); + try std.testing.expectEqualStrings("http://probe:9999", cfg.librespeed_url); +} + +test "config: kyaml overlay and typo rejection" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var cfg = Config{}; + try cfg.applyKyaml(arena.allocator(), + "---\nport: 5000\nrest: false\nauth: \"deny\"\n"); + try std.testing.expectEqual(@as(u16, 5000), cfg.port); + try std.testing.expect(!cfg.rest_enabled); + try std.testing.expectEqual(AuthMode.deny, cfg.auth_mode); + + // unknown key is an error, not a silent default + var bad = Config{}; + try std.testing.expectError(kyaml.Error.BadKey, bad.applyKyaml(arena.allocator(), "---\nprot: 1\n")); +} diff --git a/src/api/zig/ctx.zig b/src/api/zig/ctx.zig new file mode 100644 index 0000000..3d4a9b1 --- /dev/null +++ b/src/api/zig/ctx.zig @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// ctx.zig — the request context: the single object every cross-cutting +// aspect reads and writes. Replacing the module-level globals +// (g_aerie_cfg / g_aerie_redis / g_aerie_resp_buf) with a per-request +// Ctx removes the shared-buffer serialisation hazard and makes every +// seam injectable for tests. +// +// Built from a GnosisRequestV2 (query + headers now real — the fix for +// the v1 information loss that starved the policy gate of X-Api-Key +// and the resolvers of query parameters). + +const std = @import("std"); +const t = @import("types.zig"); +const config = @import("config.zig"); +const router = @import("router.zig"); +const rc = @import("redis_client.zig"); +const vc = @import("verisim_client.zig"); + +pub const Ctx = struct { + // --- request (slices alias gnosis-owned storage; valid for the call) + arena: std.mem.Allocator, + method: []const u8, + path: []const u8, + query: []const u8, + body: []const u8, + header_names: ?[]const [*c]const u8, + header_values: ?[]const [*c]const u8, + header_count: usize, + + // --- services (stable pointers set before the server starts) + cfg: *const config.Config, + redis: *rc.RedisClient, + verisim: *vc.VerisimDBClient, + /// gnosis pool state, sampled per request (reflective health). + pool_state: u8 = 0, + + // --- aspects (filled during dispatch) + policy: t.PolicyDecision = std.mem.zeroes(t.PolicyDecision), + route: ?*const router.Route = null, + + // --- response slot (the ONLY place a response is assembled). + // out_buf is the gnosis-owned per-connection scratch: it outlives the + // handler call, so response bodies MUST live here (or be literals) — + // never in the request arena, which is freed when the handler returns. + out_buf: []u8 = &.{}, + body_cursor: usize = 0, + status: u16 = 200, + resp_body: []const u8 = "", + + /// Case-insensitive header lookup. Returns a slice of the request's + /// storage; empty string when absent. + pub fn header(self: *const Ctx, name: []const u8) []const u8 { + const names = self.header_names orelse return ""; + const values = self.header_values orelse return ""; + var i: usize = 0; + while (i < self.header_count and i < names.len) : (i += 1) { + const n = names[i]; + if (n == null) continue; + if (std.ascii.eqlIgnoreCase(std.mem.span(n), name)) { + const v = values[i]; + if (v == null) return ""; + return std.mem.span(v); + } + } + return ""; + } + + /// Query parameter from the raw query string. Returns "" when absent + /// (matching the gateway's historical adapter shape). No URL + /// decoding — parameters in this API are simple tokens. + pub fn queryParam(self: *const Ctx, name: []const u8) []const u8 { + var pairs = std.mem.splitScalar(u8, self.query, '&'); + while (pairs.next()) |pair| { + const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; + if (std.mem.eql(u8, pair[0..eq], name)) return pair[eq + 1 ..]; + } + return ""; + } + + /// Reserve `len` bytes of response scratch and return the slice, or + /// null when exhausted. The single sanctioned way to place a response + /// body whose source lifetime ends with the handler. + pub fn takeBodySpace(self: *Ctx, len: usize) ?[]u8 { + if (self.body_cursor + len > self.out_buf.len) return null; + const dst = self.out_buf[self.body_cursor..][0..len]; + self.body_cursor += len; + return dst; + } + + /// Copy `bytes` into response scratch (for stack/arena-lifetime + /// bodies). Falls back to `fallback` (a literal) when exhausted. + pub fn copyToBody(self: *Ctx, bytes: []const u8, fallback: []const u8) []const u8 { + const dst = self.takeBodySpace(bytes.len) orelse return fallback; + @memcpy(dst, bytes); + return dst; + } + + /// Extract a JSON string field from the request body without + /// allocating (slice into the body). "" when absent. + pub fn jsonStrField(self: *const Ctx, key: []const u8) []const u8 { + return jsonStrFieldIn(self.body, key); + } + + /// Extract a JSON integer field from the request body. + pub fn jsonIntField(self: *const Ctx, key: []const u8) ?u32 { + var nb: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&nb, "\"{s}\"", .{key}) catch return null; + const kpos = std.mem.indexOf(u8, self.body, needle) orelse return null; + const after = self.body[kpos + needle.len ..]; + const colon = std.mem.indexOfScalar(u8, after, ':') orelse return null; + const rest = std.mem.trimLeft(u8, after[colon + 1 ..], " \t"); + var end: usize = 0; + while (end < rest.len and std.ascii.isDigit(rest[end])) end += 1; + if (end == 0) return null; + return std.fmt.parseInt(u32, rest[0..end], 10) catch null; + } +}; + +/// Zero-allocation JSON string-field extraction (slice into `data`). +pub fn jsonStrFieldIn(data: []const u8, key: []const u8) []const u8 { + var nb: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&nb, "\"{s}\"", .{key}) catch return ""; + const kpos = std.mem.indexOf(u8, data, needle) orelse return ""; + const after = data[kpos + needle.len ..]; + const colon = std.mem.indexOfScalar(u8, after, ':') orelse return ""; + var rest = std.mem.trimLeft(u8, after[colon + 1 ..], " \t\n\r"); + if (rest.len == 0 or rest[0] != '"') return ""; + rest = rest[1..]; + const q2 = std.mem.indexOfScalar(u8, rest, '"') orelse return ""; + return rest[0..q2]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "ctx: header lookup is case-insensitive and absent-safe" { + const names = [_][*c]const u8{ "X-Api-Key", "Content-Type" }; + const values = [_][*c]const u8{ "abcd-1234-efgh-5678", "application/json" }; + const c = Ctx{ + .arena = undefined, + .method = "GET", + .path = "/api/v1/routes", + .query = "", + .body = "", + .header_names = &names, + .header_values = &values, + .header_count = 2, + .cfg = undefined, + .redis = undefined, + .verisim = undefined, + }; + try std.testing.expectEqualStrings("abcd-1234-efgh-5678", c.header("x-api-key")); + try std.testing.expectEqualStrings("abcd-1234-efgh-5678", c.header("X-API-KEY")); + try std.testing.expectEqualStrings("", c.header("x-nope")); +} + +test "ctx: query params parse from the raw query" { + const c = Ctx{ + .arena = undefined, + .method = "GET", + .path = "/api/v1/routes", + .query = "target=198.51.100.9&limit=10", + .body = "", + .header_names = null, + .header_values = null, + .header_count = 0, + .cfg = undefined, + .redis = undefined, + .verisim = undefined, + }; + try std.testing.expectEqualStrings("198.51.100.9", c.queryParam("target")); + try std.testing.expectEqualStrings("10", c.queryParam("limit")); + try std.testing.expectEqualStrings("", c.queryParam("mode")); +} + +test "ctx: json field extraction from body" { + const c = Ctx{ + .arena = undefined, + .method = "POST", + .path = "/grpc/GetAuditSnapshot", + .query = "", + .body = "{\"query\": \"{ telemetry }\", \"limit\": 25}", + .header_names = null, + .header_values = null, + .header_count = 0, + .cfg = undefined, + .redis = undefined, + .verisim = undefined, + }; + try std.testing.expectEqualStrings("{ telemetry }", c.jsonStrField("query")); + try std.testing.expectEqual(@as(u32, 25), c.jsonIntField("limit").?); +} diff --git a/src/api/zig/errors.zig b/src/api/zig/errors.zig new file mode 100644 index 0000000..f528d87 --- /dev/null +++ b/src/api/zig/errors.zig @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// errors.zig — the gateway's error taxonomy. One place decides which +// HTTP status an error becomes; resolvers and middleware classify, they +// do not hand-pick codes. REST errors return their real status (the +// Phase-1 fix for the old everything-is-200 bodies); GraphQL keeps +// 200-with-errors per its spec. + +const std = @import("std"); + +pub const ApiError = error{ + BadRequest, // 400 — malformed input + Unauthorized, // 401 — missing/invalid key (Phase 2) + Forbidden, // 403 — no entitlement for the module (Phase 2) + NotFound, // 404 — unknown route (or stealth denial) + MethodNotAllowed, // 405 — wrong verb + TooManyRequests, // 429 — rate limit (Phase 3) + UpstreamUnavailable, // 502 — probe down / connector failed + UpstreamTimeout, // 504 — probe timed out + Internal, // 500 — the boundary case +}; + +pub fn statusOf(err: ApiError) u16 { + return switch (err) { + ApiError.BadRequest => 400, + ApiError.Unauthorized => 401, + ApiError.Forbidden => 403, + ApiError.NotFound => 404, + ApiError.MethodNotAllowed => 405, + ApiError.TooManyRequests => 429, + ApiError.UpstreamUnavailable => 502, + ApiError.UpstreamTimeout => 504, + ApiError.Internal => 500, + }; +} + +pub fn messageOf(err: ApiError) []const u8 { + return switch (err) { + ApiError.BadRequest => "bad request", + ApiError.Unauthorized => "missing or invalid API key", + ApiError.Forbidden => "no entitlement for this module", + ApiError.NotFound => "not found", + ApiError.MethodNotAllowed => "method not allowed", + ApiError.TooManyRequests => "rate limit exceeded", + ApiError.UpstreamUnavailable => "upstream probe unavailable", + ApiError.UpstreamTimeout => "upstream probe timed out", + ApiError.Internal => "internal error", + }; +} + +test "errors: taxonomy maps to distinct, honest statuses" { + try std.testing.expectEqual(@as(u16, 400), statusOf(ApiError.BadRequest)); + try std.testing.expectEqual(@as(u16, 401), statusOf(ApiError.Unauthorized)); + try std.testing.expectEqual(@as(u16, 429), statusOf(ApiError.TooManyRequests)); + try std.testing.expectEqual(@as(u16, 504), statusOf(ApiError.UpstreamTimeout)); +} diff --git a/src/api/zig/kyaml.zig b/src/api/zig/kyaml.zig new file mode 100644 index 0000000..8336a71 --- /dev/null +++ b/src/api/zig/kyaml.zig @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// kyaml.zig — strict KYAML parser (estate YAML policy rule Y-3: +// standards/3-practice/YAML-POLICY.adoc; Kubernetes KEP-5295 subset). +// +// KYAML is a strict subset of YAML, not a new format: every existing +// reader accepts it. Rules implemented here: +// * a `---` document header is required; +// * flow style throughout — `{}` for maps, `[]` for lists; the block +// level is a FLAT map of `key: value` lines (nesting is flow); +// * every string VALUE is double-quoted; keys are bare where +// unambiguous; bare scalars are integers and booleans only; +// * trailing commas are permitted; comments (`#` to end of line) are +// permitted outside quotes and flow collections. +// +// Strictness is the point: an unquoted non-numeric scalar, a tab, a +// missing header, or a nested block are SYNTAX ERRORS, not silently +// reinterpreted YAML. Config files that parse here have exactly one +// meaning. + +const std = @import("std"); + +pub const Error = error{ + MissingDocumentHeader, + TabCharacter, + NestedBlockMap, + TrailingJunk, + UnquotedScalar, // string values must be double-quoted + UnterminatedString, + UnterminatedFlow, // { or [ not closed + BadKey, + EmptyDocument, + OutOfMemory, +}; + +pub const Value = union(enum) { + str: []const u8, + int: i64, + boolean: bool, + map: Map, + list: List, + + pub fn get(self: Value, key: []const u8) ?Value { + return switch (self) { + .map => |m| m.get(key), + else => null, + }; + } + pub fn asString(self: Value) ?[]const u8 { + return switch (self) { + .str => |s| s, + else => null, + }; + } + pub fn asBool(self: Value) ?bool { + return switch (self) { + .boolean => |b| b, + else => null, + }; + } + pub fn asInt(self: Value) ?i64 { + return switch (self) { + .int => |i| i, + else => null, + }; + } + pub fn asList(self: Value) ?[]const Value { + return switch (self) { + .list => |l| l.items, + else => null, + }; + } +}; + +pub const Entry = struct { key: []const u8, value: Value }; + +pub const Map = struct { + entries: []const Entry, + + pub fn get(self: Map, key: []const u8) ?Value { + for (self.entries) |e| { + if (std.mem.eql(u8, e.key, key)) return e.value; + } + return null; + } +}; + +pub const List = struct { items: []const Value }; + +/// Parse a KYAML document into a Value tree allocated from `arena`. +/// The result aliases slices of `src` where possible (strings are copied +/// only when escapes must be resolved). +pub fn parse(arena: std.mem.Allocator, src: []const u8) Error!Value { + var p = Parser{ .arena = arena, .src = src, .pos = 0 }; + + // Document header. + const first = (try p.nextNonEmptyLine()) orelse return Error.EmptyDocument; + if (!std.mem.eql(u8, std.mem.trim(u8, first, " \r"), "---")) { + return Error.MissingDocumentHeader; + } + + var entries: std.ArrayList(Entry) = .{}; + while (try p.nextNonEmptyLine()) |line| { + if (line.len == 0) continue; + if (line[0] == ' ') return Error.NestedBlockMap; // flat block level only + const e = try p.parseBlockEntry(line); + try entries.append(arena, e); + } + if (entries.items.len == 0) return Error.EmptyDocument; + return .{ .map = .{ .entries = entries.items } }; +} + +const Parser = struct { + arena: std.mem.Allocator, + src: []const u8, + pos: usize, + + /// Next line with blank/comment-only lines skipped; null at EOF. + /// Tabs anywhere are rejected outright (KYAML: two-space indentation). + fn nextNonEmptyLine(p: *Parser) Error!?[]const u8 { + while (p.pos < p.src.len) { + const nl = std.mem.indexOfScalarPos(u8, p.src, p.pos, '\n') orelse p.src.len; + var line = p.src[p.pos..nl]; + p.pos = nl + 1; + if (std.mem.indexOfScalar(u8, line, '\t') != null) return Error.TabCharacter; + line = std.mem.trim(u8, line, " \r"); + if (line.len == 0) continue; + if (line[0] == '#') continue; + return line; + } + return null; + } + + fn parseBlockEntry(p: *Parser, line: []const u8) Error!Entry { + const ci = std.mem.indexOfScalar(u8, line, ':') orelse return Error.BadKey; + const key = std.mem.trim(u8, line[0..ci], " "); + if (key.len == 0) return Error.BadKey; + for (key) |ch| { + if (!std.ascii.isAlphanumeric(ch) and ch != '-' and ch != '_') return Error.BadKey; + } + const rest = std.mem.trim(u8, line[ci + 1 ..], " "); + if (rest.len == 0) return Error.NestedBlockMap; // `key:` with nothing — no block nesting + const v = try p.parseFlowValueInSlice(rest); + return .{ .key = key, .value = v }; + } + + /// Parse one flow value covering `s` (a single block line's value): + /// after the value only spaces and an optional trailing comment remain. + fn parseFlowValueInSlice(p: *Parser, s: []const u8) Error!Value { + var sub = Parser{ .arena = p.arena, .src = s, .pos = 0 }; + const v = try sub.parseFlowValue(); + var rest = std.mem.trim(u8, s[sub.pos..], " "); + if (rest.len > 0 and rest[0] == '#') rest = rest[0..0]; + if (rest.len != 0) return Error.TrailingJunk; + return v; + } + + fn skipWs(p: *Parser) void { + while (p.pos < p.src.len and p.src[p.pos] == ' ') p.pos += 1; + } + + fn parseFlowValue(p: *Parser) Error!Value { + p.skipWs(); + if (p.pos >= p.src.len) return Error.UnquotedScalar; + return switch (p.src[p.pos]) { + '"' => p.parseQuotedString(), + '{' => p.parseFlowMap(), + '[' => p.parseFlowList(), + else => p.parseBareScalar(), + }; + } + + fn parseQuotedString(p: *Parser) Error!Value { + p.pos += 1; // opening quote + var out: std.ArrayList(u8) = .{}; + while (p.pos < p.src.len) { + const ch = p.src[p.pos]; + if (ch == '"') { + p.pos += 1; + return .{ .str = out.items }; + } + if (ch == '\\') { + p.pos += 1; + if (p.pos >= p.src.len) return Error.UnterminatedString; + const esc = p.src[p.pos]; + try out.append(p.arena, switch (esc) { + '"' => '"', + '\\' => '\\', + 'n' => '\n', + 't' => '\t', + else => return Error.UnterminatedString, + }); + p.pos += 1; + continue; + } + try out.append(p.arena, ch); + p.pos += 1; + } + return Error.UnterminatedString; + } + + fn parseBareScalar(p: *Parser) Error!Value { + const start = p.pos; + while (p.pos < p.src.len) : (p.pos += 1) { + const ch = p.src[p.pos]; + if (ch == ',' or ch == '}' or ch == ']' or ch == ' ' or ch == '\n') break; + } + const tok = p.src[start..p.pos]; + if (std.mem.eql(u8, tok, "true")) return .{ .boolean = true }; + if (std.mem.eql(u8, tok, "false")) return .{ .boolean = false }; + if (std.fmt.parseInt(i64, tok, 10)) |i| { + return .{ .int = i }; + } else |_| {} + return Error.UnquotedScalar; // strings must be double-quoted + } + + fn parseFlowMap(p: *Parser) Error!Value { + p.pos += 1; // '{' + var entries: std.ArrayList(Entry) = .{}; + p.skipWs(); + if (p.pos < p.src.len and p.src[p.pos] == '}') { + p.pos += 1; + return .{ .map = .{ .entries = entries.items } }; + } + while (true) { + p.skipWs(); + // key: bare (unquoted where unambiguous) or quoted + var key: []const u8 = undefined; + if (p.pos < p.src.len and p.src[p.pos] == '"') { + const kv = try p.parseQuotedString(); + key = kv.str; + } else { + const start = p.pos; + while (p.pos < p.src.len) : (p.pos += 1) { + const ch = p.src[p.pos]; + if (ch == ':' or ch == ' ' or ch == ',') break; + } + key = p.src[start..p.pos]; + if (key.len == 0) return Error.BadKey; + } + p.skipWs(); + if (p.pos >= p.src.len or p.src[p.pos] != ':') return Error.BadKey; + p.pos += 1; + const value = try p.parseFlowValue(); + try entries.append(p.arena, .{ .key = key, .value = value }); + p.skipWs(); + if (p.pos >= p.src.len) return Error.UnterminatedFlow; + if (p.src[p.pos] == ',') { + p.pos += 1; // trailing commas permitted + p.skipWs(); + if (p.pos < p.src.len and p.src[p.pos] == '}') { + p.pos += 1; + return .{ .map = .{ .entries = entries.items } }; + } + continue; + } + if (p.src[p.pos] == '}') { + p.pos += 1; + return .{ .map = .{ .entries = entries.items } }; + } + return Error.UnterminatedFlow; + } + } + + fn parseFlowList(p: *Parser) Error!Value { + p.pos += 1; // '[' + var items: std.ArrayList(Value) = .{}; + p.skipWs(); + if (p.pos < p.src.len and p.src[p.pos] == ']') { + p.pos += 1; + return .{ .list = .{ .items = items.items } }; + } + while (true) { + const value = try p.parseFlowValue(); + try items.append(p.arena, value); + p.skipWs(); + if (p.pos >= p.src.len) return Error.UnterminatedFlow; + if (p.src[p.pos] == ',') { + p.pos += 1; + p.skipWs(); + if (p.pos < p.src.len and p.src[p.pos] == ']') { + p.pos += 1; + return .{ .list = .{ .items = items.items } }; + } + continue; + } + if (p.src[p.pos] == ']') { + p.pos += 1; + return .{ .list = .{ .items = items.items } }; + } + return Error.UnterminatedFlow; + } + } +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "kyaml: flat document with all scalar kinds" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const doc = + \\--- + \\port: 4100 # comment allowed + \\rest: true + \\graphql: false + \\name: "aerie-gateway" + ; + const v = try parse(arena.allocator(), doc); + const m = v.get("port").?.asInt().?; + try std.testing.expectEqual(@as(i64, 4100), m); + try std.testing.expect(v.get("rest").?.asBool().?); + try std.testing.expect(!v.get("graphql").?.asBool().?); + try std.testing.expectEqualStrings("aerie-gateway", v.get("name").?.asString().?); +} + +test "kyaml: flow collections with trailing commas" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const doc = + \\--- + \\rate_limits: {"telemetry": 60, "routes": 30,} + \\modules: ["telemetry", "routes", "audit",] + ; + const v = try parse(arena.allocator(), doc); + try std.testing.expectEqual(@as(i64, 60), v.get("rate_limits").?.get("telemetry").?.asInt().?); + const mods = v.get("modules").?.asList().?; + try std.testing.expectEqual(@as(usize, 3), mods.len); + try std.testing.expectEqualStrings("audit", mods[2].asString().?); +} + +test "kyaml: escapes in quoted strings" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const v = try parse(arena.allocator(), "---\nkey: \"a\\\"b\\\\c\"\n"); + try std.testing.expectEqualStrings("a\"b\\c", v.get("key").?.asString().?); +} + +test "kyaml: strictness — rejects the YAML footguns" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + // missing header + try std.testing.expectError(Error.MissingDocumentHeader, parse(a, "port: 1\n")); + // unquoted string value (Norway-problem class) + try std.testing.expectError(Error.UnquotedScalar, parse(a, "---\nname: no\n")); + // nested block map + try std.testing.expectError(Error.NestedBlockMap, parse(a, "---\nouter:\n inner: 1\n")); + // `key:` with nothing after it + try std.testing.expectError(Error.NestedBlockMap, parse(a, "---\nouter:\n")); + // trailing junk after a value + try std.testing.expectError(Error.TrailingJunk, parse(a, "---\nport: 1 oops\n")); + // unterminated flow + try std.testing.expectError(Error.UnterminatedFlow, parse(a, "---\nm: {a: 1\n")); + // unterminated string + try std.testing.expectError(Error.UnterminatedString, parse(a, "---\ns: \"abc\n")); +} diff --git a/src/api/zig/main.zig b/src/api/zig/main.zig index c89384d..76f7d1e 100644 --- a/src/api/zig/main.zig +++ b/src/api/zig/main.zig @@ -1,678 +1,136 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// main.zig — Aerie Gateway: Single-Port Path-Routed API Server (Zig port) +// main.zig — Aerie Gateway: lifecycle + the V2 edge handler. // -// All three protocols are served on ONE port via path routing (default: 4000). -// The HTTP listener is owned by uapi_gnosis_start via uapi_gnosis_set_handler, -// which calls aerieHandler for every incoming request. +// Phase 1 (the aspect-weave skeleton): this file shrank from a 756-line +// everything-inline handler to lifecycle only. The pipeline lives in +// router.dispatch; the request context in ctx.zig; config in config.zig +// (env + KYAML); responses in respond.zig; the error taxonomy in +// errors.zig; verb governance reads the router table. // -// /graphql — GraphQL handler -// /api/v1/* — REST handlers -// /grpc/* — gRPC-JSON handlers (HTTP transport, method in body) -// /api/v1/health — health check (always enabled) +// The handler registers as V2 (GnosisRequestV2): the query string and +// request headers — both stripped by the v1 surface — are real now, so +// the policy gate finally sees X-Api-Key and resolvers see ?target= +// parameters. The per-request arena replaces the shared 128 KiB static +// response buffer (the serial-handler assumption is gone). // -// Previously the gateway bound two ports (4000 HTTP / 4001 gRPC-TCP) with -// its own per-protocol listener threads. The new shape uses a single gnosis -// pool slot and uapi_gnosis_set_handler to plug aerieHandler as the dispatch -// function. No current consumer fires more than one protocol simultaneously, -// so consolidating to one port loses nothing. +// Environment (single reader: config.zig): +// PORT, ENABLE_REST, ENABLE_GRAPHQL, ENABLE_GRPC, *_URL, +// AERIE_CONFIG (KYAML file), AERIE_AUTH_MODE. // -// Environment variables: -// PORT — HTTP port for all protocols (default 4000) -// ENABLE_REST — "false"/"0"/"no" to disable REST (default enabled) -// ENABLE_GRAPHQL— same -// ENABLE_GRPC — same -// -// All responses are wrapped in a ProofEnvelope (SHA-256 hash, Phase 1). -// The policy gate checks X-Api-Key headers and logs all access to Redis. -// -// Server lifecycle — consumes zig-api (developer-ecosystem/zig-api): -// uapi_init() — initialises gnosis server pool + connector pool -// uapi_gnosis_create() — reserves a pool slot for port 4000 -// uapi_gnosis_set_handler() — registers aerieHandler as the edge dispatch fn -// uapi_gnosis_start() — starts gnosis background thread -// uapi_gnosis_stop() — drains the background thread on shutdown -// uapi_gnosis_destroy() — releases pool slot and resources -// uapi_connector_* — outbound HTTP service calls (see service clients) -// -// Replaces: main.v (src/api/v/main.v) // Requires: Zig 0.15.2+ -const std = @import("std"); -const t = @import("types.zig"); -const prf = @import("proof.zig"); -const pol = @import("policy.zig"); -const vg = @import("verb_governance.zig"); -const rc = @import("redis_client.zig"); -const vc = @import("verisim_client.zig"); -const res = @import("resolvers.zig"); +const std = @import("std"); +const t = @import("types.zig"); +const rc = @import("redis_client.zig"); +const vc = @import("verisim_client.zig"); +const config = @import("config.zig"); +const ctx = @import("ctx.zig"); +const router = @import("router.zig"); +const respond = @import("respond.zig"); -/// C ABI imports from libzig_api (developer-ecosystem/zig-api). -/// These provide the server pool (uapi_gnosis_*) and connector pool -/// (uapi_connector_*) lifecycle management. +/// C ABI from the in-repo FFI (declared in src/abi/Gnosis.idr). const c = @cImport({ @cInclude("zig_api.h"); }); // --------------------------------------------------------------------------- -// zig-api server-pool state handles (module-level, set during init) -// --------------------------------------------------------------------------- - -/// gnosis pool handle for the unified HTTP listener (port 4000 by default). -/// All three protocols (REST, GraphQL, gRPC-JSON) are path-routed on this port. -/// Set to 0 when disabled or initialisation fails. -var gnosis_http_handle: u64 = 0; - -// --------------------------------------------------------------------------- -// Configuration helpers +// Module-level server state (set once in main, before gnosis starts) // --------------------------------------------------------------------------- -/// Read a boolean env var. "false", "0", or "no" (case-insensitive) → false. -/// Anything else (including absent) → `default`. -fn envBool(name: []const u8, default: bool) bool { - const val = std.posix.getenv(name) orelse return default; - if (val.len == 0) return default; - return !(std.ascii.eqlIgnoreCase(val, "false") or - std.ascii.eqlIgnoreCase(val, "0") or - std.ascii.eqlIgnoreCase(val, "no")); -} - -fn readProtocolConfig() t.ProtocolConfig { - return .{ - .rest_enabled = envBool("ENABLE_REST", true), - .graphql_enabled = envBool("ENABLE_GRAPHQL", true), - .grpc_enabled = envBool("ENABLE_GRPC", true), - }; -} +var g_cfg: config.Config = undefined; +var g_redis: ?*rc.RedisClient = null; +var g_verisim: vc.VerisimDBClient = undefined; +var g_alloc: std.mem.Allocator = undefined; +var g_ready: bool = false; -/// Print the startup banner to stdout. -fn printBanner(http_port: u16, cfg: t.ProtocolConfig) void { - std.debug.print( - "╔══════════════════════════════════════════════════════════╗\n" ++ - "║ AERIE GATEWAY — Zig port (single-port, MPL-2.0) ║\n" ++ - "╠══════════════════════════════════════════════════════════╣\n", - .{}, - ); - std.debug.print("║ Port : {d:<5} ║\n", .{http_port}); - if (cfg.rest_enabled) { - std.debug.print("║ REST : /api/v1/* ✓ ENABLED ║\n", .{}); - } else { - std.debug.print("║ REST : ✗ DISABLED ║\n", .{}); - } - if (cfg.graphql_enabled) { - std.debug.print("║ GraphQL : /graphql ✓ ENABLED ║\n", .{}); - } else { - std.debug.print("║ GraphQL : ✗ DISABLED ║\n", .{}); - } - if (cfg.grpc_enabled) { - std.debug.print("║ gRPC-JSON : /grpc/* ✓ ENABLED ║\n", .{}); - } else { - std.debug.print("║ gRPC-JSON : ✗ DISABLED ║\n", .{}); - } - std.debug.print( - "╠══════════════════════════════════════════════════════════╣\n" ++ - "║ Server pool : uapi_gnosis_* (zig-api) ║\n" ++ - "║ Connector pool : uapi_connector_* (zig-api) ║\n" ++ - "║ Proof mode : light (SHA-256) ║\n" ++ - "║ Policy gate : Phase 1 (permissive) ║\n" ++ - "╚══════════════════════════════════════════════════════════╝\n", - .{}, - ); -} - -// --------------------------------------------------------------------------- -// HTTP helpers (mirroring the LOL reference gateway pattern) -// --------------------------------------------------------------------------- - -fn readLine(stream: std.net.Stream, buf: []u8) ![]const u8 { - var pos: usize = 0; - while (pos < buf.len) { - const n = try stream.read(buf[pos..][0..1]); - if (n == 0) break; - if (buf[pos] == '\n') { - const end = if (pos > 0 and buf[pos - 1] == '\r') pos - 1 else pos; - return buf[0..end]; - } - pos += 1; - } - return buf[0..pos]; -} - -/// Write a full HTTP/1.1 response with aerie security headers. -fn writeHttpResponse( - stream: std.net.Stream, - status: u16, - status_text: []const u8, - content_type: []const u8, - body: []const u8, -) void { - var hdr_buf: [1024]u8 = undefined; - const hdr = std.fmt.bufPrint(&hdr_buf, - "HTTP/1.1 {d} {s}\r\n" ++ - "Content-Type: {s}\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: close\r\n" ++ - "Access-Control-Allow-Origin: *\r\n" ++ - "Access-Control-Allow-Headers: Content-Type, X-Api-Key\r\n" ++ - "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" ++ - "X-Aerie-Proof-Type: light\r\n" ++ - "X-Content-Type-Options: nosniff\r\n" ++ - "X-Frame-Options: DENY\r\n" ++ - "X-XSS-Protection: 0\r\n" ++ - "Referrer-Policy: no-referrer\r\n" ++ - "Cache-Control: no-store\r\n" ++ - "\r\n", - .{ status, status_text, content_type, body.len }, - ) catch return; - stream.writeAll(hdr) catch return; - stream.writeAll(body) catch return; -} - -fn writeJson(stream: std.net.Stream, status: u16, body: []const u8) void { - const text: []const u8 = switch (status) { - 200 => "OK", - 204 => "No Content", - 400 => "Bad Request", - 403 => "Forbidden", - 404 => "Not Found", - 405 => "Method Not Allowed", - else => "Internal Server Error", - }; - writeHttpResponse(stream, status, text, "application/json", body); -} +/// gnosis pool handle for the unified HTTP listener. +var gnosis_http_handle: u64 = 0; // --------------------------------------------------------------------------- -// HTTP request parsing and routing +// The V2 edge handler (registered via uapi_gnosis_set_handler_v2) // --------------------------------------------------------------------------- -const HttpRequest = struct { - method: []const u8, - path: []const u8, - raw_path: []const u8, - api_key: []const u8, - content_length: usize, -}; - -/// Parse the first line and headers from an HTTP request. -/// Uses arena allocator for slices. `line_buf` is scratch space. -fn parseRequest( - stream: std.net.Stream, - line_buf: []u8, - arena: std.mem.Allocator, -) !HttpRequest { - const req_line = try readLine(stream, line_buf); - var parts = std.mem.splitScalar(u8, req_line, ' '); - const method = parts.next() orelse return error.BadRequest; - const raw_path = parts.next() orelse return error.BadRequest; - - const path = if (std.mem.indexOfScalar(u8, raw_path, '?')) |qi| - raw_path[0..qi] else raw_path; - - var api_key: []const u8 = ""; - var content_length: usize = 0; - - var h_buf: [1024]u8 = undefined; - while (true) { - const line = readLine(stream, &h_buf) catch break; - if (line.len == 0) break; // blank line = end of headers - if (std.ascii.startsWithIgnoreCase(line, "x-api-key:")) { - const v = std.mem.trim(u8, line["x-api-key:".len..], " \t"); - api_key = try arena.dupe(u8, v); - } else if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { - const v = std.mem.trim(u8, line["content-length:".len..], " \t"); - content_length = std.fmt.parseInt(usize, v, 10) catch 0; - } - } - - return .{ - .method = try arena.dupe(u8, method), - .path = try arena.dupe(u8, path), - .raw_path = try arena.dupe(u8, raw_path), - .api_key = api_key, - .content_length = content_length, - }; -} - -/// Extract a query parameter from a raw URL string. -/// Returns a slice into `url`, or empty if absent. -fn queryParam(url: []const u8, name: []const u8) []const u8 { - const q = std.mem.indexOfScalar(u8, url, '?') orelse return ""; - const query_str = url[q + 1 ..]; - var pairs = std.mem.splitScalar(u8, query_str, '&'); - while (pairs.next()) |pair| { - const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; - if (std.mem.eql(u8, pair[0..eq], name)) return pair[eq + 1 ..]; - } - return ""; -} - -/// Module name from path, used for the policy gate log. -fn moduleFromPath(path: []const u8) []const u8 { - if (std.mem.startsWith(u8, path, "/graphql")) return "graphql"; - if (std.mem.startsWith(u8, path, "/api/v1/telemetry")) return "telemetry"; - if (std.mem.startsWith(u8, path, "/api/v1/routes")) return "routes"; - if (std.mem.startsWith(u8, path, "/api/v1/audit/temporal"))return "temporal_audit"; - if (std.mem.startsWith(u8, path, "/api/v1/audit")) return "audit"; - if (std.mem.startsWith(u8, path, "/api/v1/smokeping")) return "smokeping"; - if (std.mem.startsWith(u8, path, "/api/v1/health")) return "health"; - return "unknown"; -} - -/// Health check JSON, always available. -fn healthJson(cfg: t.ProtocolConfig, out: []u8) []const u8 { - var ts_buf: [32]u8 = undefined; - prf.formatRfc3339(&ts_buf); - const ts = std.mem.sliceTo(&ts_buf, 0); - var active: u8 = 0; - if (cfg.rest_enabled) active += 1; - if (cfg.graphql_enabled) active += 1; - if (cfg.grpc_enabled) active += 1; - var bound: u8 = 0; - if (cfg.rest_enabled or cfg.graphql_enabled) bound += 1; - if (cfg.grpc_enabled) bound += 1; - // Include gnosis server pool state in health output. - const pool_state: u8 = if (gnosis_http_handle != 0) - c.uapi_gnosis_state(gnosis_http_handle) - else - c.UAPI_SERVER_STOPPED; - return std.fmt.bufPrint(out, - "{{\"status\":\"healthy\",\"service\":\"aerie-gateway\",\"version\":\"0.3.0\"," ++ - "\"timestamp\":\"{s}\",\"protocols\":{{\"rest\":{s},\"graphql\":{s},\"grpc\":{s}}}," ++ - "\"active_protocols\":{d},\"bound_ports\":{d}," ++ - "\"verb_governance\":true,\"stealth_mode\":true,\"proof_mode\":\"light\"," ++ - "\"policy_phase\":1,\"pool\":{{\"slot_state\":{d}}}}}", - .{ - ts, - if (cfg.rest_enabled) "true" else "false", - if (cfg.graphql_enabled) "true" else "false", - if (cfg.grpc_enabled) "true" else "false", - active, bound, - pool_state, - }, - ) catch "{\"status\":\"healthy\"}"; -} - -/// Not-found JSON, listing only enabled endpoints. -fn notFoundJson(cfg: t.ProtocolConfig, out: []u8) []const u8 { - var fbs = std.io.fixedBufferStream(out); - const w = fbs.writer(); - w.writeAll("{\"error\":\"Not found\",\"available_endpoints\":[") catch return "{\"error\":\"Not found\"}"; - var first = true; - if (cfg.graphql_enabled) { - w.writeAll("\"/graphql\"") catch {}; first = false; - } - if (cfg.rest_enabled) { - if (!first) w.writeByte(',') catch {}; - w.writeAll("\"/api/v1/telemetry\",\"/api/v1/routes\",\"/api/v1/audit\"," ++ - "\"/api/v1/audit/temporal\",\"/api/v1/smokeping\"") catch {}; - } - w.writeAll(",\"/api/v1/health\"]}") catch {}; - return fbs.getWritten(); +fn fillError(resp: [*c]c.GnosisResponse, status: u16, msg: []const u8) void { + const body: [*]const u8 = @ptrCast(msg.ptr); + c.uapi_gnosis_write_response(resp, status, "application/json", body, @intCast(msg.len)); } -// --------------------------------------------------------------------------- -// aerieHandler — edge hook for uapi_gnosis_set_handler -// (Old per-connection HTTP handler and gRPC TCP listener removed 2026-04-17: -// both are superseded by the single-port set_handler architecture.) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// aerieHandler — edge hook for uapi_gnosis_set_handler -// -// gnosis calls this for every HTTP request on the pool port. -// aerieHandler path-dispatches to the appropriate protocol family. -// gRPC-over-TCP (Phase 1) is superseded: gRPC methods are now routed over -// HTTP at /grpc/. -// -// Module-level context set by main() before uapi_gnosis_start: -// g_aerie_cfg — protocol enablement flags -// g_aerie_redis — Redis client pointer -// g_aerie_verisimdb — VerisimDB client value -// g_aerie_alloc — base allocator for per-request arenas -// g_aerie_context_ready — true once all the above are initialised -// --------------------------------------------------------------------------- - -var g_aerie_cfg: t.ProtocolConfig = .{ .rest_enabled = true, .graphql_enabled = true, .grpc_enabled = true }; -var g_aerie_redis: ?*rc.RedisClient = null; -var g_aerie_verisimdb: vc.VerisimDBClient = undefined; -var g_aerie_alloc: std.mem.Allocator = undefined; -var g_aerie_context_ready: bool = false; - -/// Response body buffer for aerieHandler. gnosis's serve thread calls -/// aerieHandler serially (one per connection), so a module-level buffer is safe. -var g_aerie_resp_buf: [131072]u8 = undefined; - -/// Sentinel content-type strings for aerieHandler responses. -const AERIE_CT_JSON: [*:0]const u8 = "application/json"; - -/// Fill a GnosisResponse with an error body. -fn aerieRespError( +/// The edge handler: build a per-request Ctx, dispatch, write the +/// response through the single path. Nothing else lives here — that is +/// the point of the weave. +export fn aerieHandlerV2( + req_c: [*c]const c.GnosisRequestV2, resp_c: [*c]c.GnosisResponse, - status: u16, - msg: []const u8, -) void { +) callconv(.c) void { const resp: *c.GnosisResponse = @ptrCast(resp_c); - var fbs = std.io.fixedBufferStream(&g_aerie_resp_buf); - fbs.writer().print("{{\"error\":\"{s}\"}}", .{msg}) catch {}; - const written = fbs.getWritten(); - resp.status = status; - resp._pad = 0; - resp.content_type = AERIE_CT_JSON; - resp.body_ptr = written.ptr; - resp.body_len = @intCast(written.len); -} + const req: *const c.GnosisRequestV2 = @ptrCast(req_c); -/// The edge handler registered with uapi_gnosis_set_handler. -/// -/// gnosis provides method, path, and body pre-parsed from the HTTP request. -/// aerieHandler replicates the routing logic from handleHttpConn, writing -/// the response into g_aerie_resp_buf and filling the GnosisResponse. -/// -/// Protocol path mapping: -/// /graphql → GraphQL handler -/// /api/v1/* → REST handlers -/// /grpc/* → gRPC-JSON handlers (body contains {"method":"...","...":...}) -/// else → 404 -export fn aerieHandler(req_c: [*c]const c.GnosisRequest, resp_c: [*c]c.GnosisResponse) callconv(.c) void { - const resp: *c.GnosisResponse = @ptrCast(resp_c); - const req: *const c.GnosisRequest = @ptrCast(req_c); - - if (!g_aerie_context_ready) { - aerieRespError(resp_c, 503, "gateway not ready"); + if (!g_ready) { + fillError(resp_c, 503, "{\"error\":\"gateway not ready\"}"); return; } - - const redis = g_aerie_redis orelse { - aerieRespError(resp_c, 503, "redis not initialised"); + const redis = g_redis orelse { + fillError(resp_c, 503, "{\"error\":\"redis not initialised\"}"); return; }; - var arena_inst = std.heap.ArenaAllocator.init(g_aerie_alloc); + var arena_inst = std.heap.ArenaAllocator.init(g_alloc); defer arena_inst.deinit(); const arena = arena_inst.allocator(); - const method = std.mem.span(req.method); - const path = std.mem.span(req.path); - const body: []const u8 = if (req.body_ptr) |p| p[0..req.body_len] else ""; - - // CORS preflight - if (std.ascii.eqlIgnoreCase(method, "OPTIONS")) { - resp.status = 204; resp._pad = 0; - resp.content_type = "text/plain"; - resp.body_ptr = null; resp.body_len = 0; - return; - } - - // Policy gate — derive api_key from a header if available. - // Since gnosis's GnosisRequest does not expose raw headers (by design), - // we pass an empty api_key here. Phase 1 policy is permissive regardless. - const policy = pol.evaluatePolicy("", moduleFromPath(path)); - if (!policy.allowed) { - var rb: [256]u8 = undefined; - const reason = std.mem.sliceTo(&policy.reason, 0); - var fbs = std.io.fixedBufferStream(&rb); - fbs.writer().print("{{\"error\":\"Access denied\",\"reason\":\"{s}\"}}", .{reason}) catch {}; - const written = fbs.getWritten(); - resp.status = 403; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = written.ptr; resp.body_len = @intCast(written.len); - return; - } - - // Protocol enablement checks - if (std.mem.startsWith(u8, path, "/graphql") and !g_aerie_cfg.graphql_enabled) { - const e = "{\"error\":\"GraphQL disabled\",\"hint\":\"Set ENABLE_GRAPHQL=true\"}"; - resp.status = 404; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - if (std.mem.startsWith(u8, path, "/api/v1/") and - !std.mem.startsWith(u8, path, "/api/v1/health") and - !g_aerie_cfg.rest_enabled) - { - const e = "{\"error\":\"REST disabled\",\"hint\":\"Set ENABLE_REST=true\"}"; - resp.status = 404; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - if (std.mem.startsWith(u8, path, "/grpc/") and !g_aerie_cfg.grpc_enabled) { - const e = "{\"error\":\"gRPC disabled\",\"hint\":\"Set ENABLE_GRPC=true\"}"; - resp.status = 404; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - - // GraphQL dispatch - if (std.mem.startsWith(u8, path, "/graphql")) { - if (!std.ascii.eqlIgnoreCase(method, "POST")) { - const e = "{\"errors\":[{\"message\":\"GraphQL endpoint requires POST method\"}]}"; - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - const query = blk: { - if (std.mem.indexOf(u8, body, "\"query\"")) |_| { - var kbuf: [64]u8 = undefined; - const needle = std.fmt.bufPrint(&kbuf, "\"query\"", .{}) catch break :blk body; - const kpos = std.mem.indexOf(u8, body, needle) orelse break :blk body; - const after = body[kpos + needle.len ..]; - const colon = std.mem.indexOfScalar(u8, after, ':') orelse break :blk body; - var rest = std.mem.trimLeft(u8, after[colon + 1 ..], " \t\n\r"); - if (rest.len == 0 or rest[0] != '"') break :blk body; - rest = rest[1..]; - const q2 = std.mem.indexOfScalar(u8, rest, '"') orelse break :blk body; - break :blk rest[0..q2]; - } - break :blk body; - }; - if (query.len == 0) { - const e = "{\"errors\":[{\"message\":\"Missing query field in request body\"}]}"; - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - const out = res.resolveGraphqlQuery(query, redis, &g_aerie_verisimdb, policy, &g_aerie_resp_buf, arena); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - - // gRPC-JSON dispatch (HTTP transport; method name in body) - if (std.mem.startsWith(u8, path, "/grpc/")) { - // Derive method name from path suffix or body "method" field. - // Path: /grpc/GetTelemetrySnapshot → method = "GetTelemetrySnapshot" - const method_name = path["/grpc/".len..]; - const grpc_method = if (method_name.len > 0) method_name - else jsonStrFieldNoAlloc(body, "method"); - const grpc_policy = pol.evaluatePolicy("", grpc_method); - const out = aerieGrpcDispatch(grpc_method, body, redis, grpc_policy, arena); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - - // REST dispatch — mirrors handleHttpConn REST routing - if (std.mem.startsWith(u8, path, "/api/v1/telemetry")) { - const out = res.resolveTelemetry(redis, policy, &g_aerie_resp_buf, arena); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - if (std.mem.startsWith(u8, path, "/api/v1/routes")) { - const target = queryParamFromPath(path, "target"); - if (target.len == 0) { - const e = "{\"error\":\"Missing required query parameter: target\"," ++ - "\"usage\":\"/api/v1/routes?target=\"}"; - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - const out = res.resolveRouteForensics(target, redis, policy, &g_aerie_resp_buf, arena); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - if (std.mem.startsWith(u8, path, "/api/v1/audit/temporal")) { - const mode = queryParamFromPath(path, "mode"); - if (mode.len == 0) { - const e = "{\"error\":\"Missing required query parameter: mode\"," ++ - "\"usage\":\"/api/v1/audit/temporal?mode=as_of&time=2026-02-28T12:00:00Z\"," ++ - "\"available_modes\":[\"as_of\",\"between\",\"history\"]}"; - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - const limit_str = queryParamFromPath(path, "limit"); - const limit: u32 = std.fmt.parseInt(u32, limit_str, 10) catch 50; - const params = res.TemporalParams{ - .time = queryParamFromPath(path, "time"), - .start = queryParamFromPath(path, "start"), - .end = queryParamFromPath(path, "end"), - .event_id = queryParamFromPath(path, "event_id"), - .limit = limit, - }; - const out = res.resolveTemporalAudit(mode, params, redis, &g_aerie_verisimdb, policy, &g_aerie_resp_buf, arena); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - if (std.mem.startsWith(u8, path, "/api/v1/audit")) { - const limit_str = queryParamFromPath(path, "limit"); - const limit: u32 = std.fmt.parseInt(u32, limit_str, 10) catch 50; - const out = res.resolveAudit(limit, redis, policy, &g_aerie_resp_buf, arena); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - if (std.mem.startsWith(u8, path, "/api/v1/smokeping")) { - const target = queryParamFromPath(path, "target"); - if (target.len == 0) { - const e = "{\"error\":\"Missing required query parameter: target\"," ++ - "\"usage\":\"/api/v1/smokeping?target=\"}"; - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = e; resp.body_len = e.len; - return; - } - const out = res.resolveSmokeping(target, redis, policy, &g_aerie_resp_buf, arena); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - if (std.mem.startsWith(u8, path, "/api/v1/health")) { - var hbuf: [512]u8 = undefined; - const out = healthJson(g_aerie_cfg, &hbuf); - resp.status = 200; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = out.ptr; resp.body_len = @intCast(out.len); - return; - } - - // 404 — not found - var nbuf: [1024]u8 = undefined; - const not_found = notFoundJson(g_aerie_cfg, &nbuf); - resp.status = 404; resp._pad = 0; resp.content_type = AERIE_CT_JSON; - resp.body_ptr = not_found.ptr; resp.body_len = @intCast(not_found.len); -} - -/// Dispatch a gRPC-JSON method to the correct resolver. -/// Returns a slice into `g_aerie_resp_buf` (valid until next aerieHandler call). -fn aerieGrpcDispatch( - method_name: []const u8, - body: []const u8, - redis: *rc.RedisClient, - policy: t.PolicyDecision, - arena: std.mem.Allocator, -) []const u8 { - if (std.mem.eql(u8, method_name, "GetTelemetrySnapshot")) { - return res.resolveTelemetry(redis, policy, &g_aerie_resp_buf, arena); - } - if (std.mem.eql(u8, method_name, "GetRouteForensicsSnapshot")) { - const target = jsonStrFieldNoAlloc(body, "target"); - if (target.len == 0) return "{\"error\":\"target field required\"}"; - return res.resolveRouteForensics(target, redis, policy, &g_aerie_resp_buf, arena); - } - if (std.mem.eql(u8, method_name, "GetAuditSnapshot")) { - const limit = jsonIntFieldNoAlloc(body, "limit") orelse @as(u32, 50); - return res.resolveAudit(limit, redis, policy, &g_aerie_resp_buf, arena); - } - if (std.mem.eql(u8, method_name, "GetSmokePingSnapshot")) { - const target = jsonStrFieldNoAlloc(body, "target"); - if (target.len == 0) return "{\"error\":\"target field required\"}"; - return res.resolveSmokeping(target, redis, policy, &g_aerie_resp_buf, arena); - } - if (std.mem.eql(u8, method_name, "GetTemporalAuditSnapshot")) { - const mode = jsonStrFieldNoAlloc(body, "mode"); - if (mode.len == 0) return "{\"error\":\"mode field required (as_of, between, history)\"}"; - const limit = jsonIntFieldNoAlloc(body, "limit") orelse @as(u32, 50); - const params = res.TemporalParams{ - .time = jsonStrFieldNoAlloc(body, "time"), - .start = jsonStrFieldNoAlloc(body, "start"), - .end = jsonStrFieldNoAlloc(body, "end"), - .event_id = jsonStrFieldNoAlloc(body, "event_id"), - .limit = limit, - }; - return res.resolveTemporalAudit(mode, params, redis, &g_aerie_verisimdb, policy, &g_aerie_resp_buf, arena); - } - var eb: [256]u8 = undefined; - return std.fmt.bufPrint(&eb, - "{{\"error\":\"Unknown method: {s}\"," ++ - "\"available\":[\"GetTelemetrySnapshot\",\"GetRouteForensicsSnapshot\"," ++ - "\"GetAuditSnapshot\",\"GetSmokePingSnapshot\",\"GetTemporalAuditSnapshot\"]}}", - .{method_name}, - ) catch "{\"error\":\"unknown method\"}"; -} - -/// Extract a query parameter from a path+query string. -/// Returns a slice into `path` or empty string if absent. -fn queryParamFromPath(raw_path: []const u8, name: []const u8) []const u8 { - return queryParam(raw_path, name); -} + // Response scratch provided by gnosis: it outlives this handler call + // (the server writes to the socket after we return), unlike the + // request arena above — response bodies live in the scratch, never + // in the arena. (The arena serves request-lifetime allocations only.) + const out_buf: []u8 = if (req.resp_scratch != null and req.resp_scratch_len > 0) + req.resp_scratch[0..req.resp_scratch_len] + else + &.{}; + + var request = ctx.Ctx{ + .arena = arena, + .method = std.mem.span(req.method), + .path = std.mem.span(req.path), + .query = std.mem.span(req.query), + .body = if (req.body_ptr) |p| p[0..req.body_len] else "", + .header_names = if (req.header_count > 0 and req.header_names != null) + req.header_names[0..req.header_count] + else + null, + .header_values = if (req.header_count > 0 and req.header_values != null) + req.header_values[0..req.header_count] + else + null, + .header_count = req.header_count, + .cfg = &g_cfg, + .redis = redis, + .verisim = &g_verisim, + .pool_state = if (gnosis_http_handle != 0) + c.uapi_gnosis_state(gnosis_http_handle) + else + c.UAPI_SERVER_STOPPED, + .out_buf = out_buf, + }; -/// Extract a JSON string field without allocating. -/// Returns a slice into `data` (no copy). -fn jsonStrFieldNoAlloc(data: []const u8, key: []const u8) []const u8 { - var nb: [64]u8 = undefined; - const needle = std.fmt.bufPrint(&nb, "\"{s}\"", .{key}) catch return ""; - const kpos = std.mem.indexOf(u8, data, needle) orelse return ""; - const after = data[kpos + needle.len ..]; - const colon = std.mem.indexOfScalar(u8, after, ':') orelse return ""; - var rest = std.mem.trimLeft(u8, after[colon + 1 ..], " \t"); - if (rest.len == 0 or rest[0] != '"') return ""; - rest = rest[1..]; - const q2 = std.mem.indexOfScalar(u8, rest, '"') orelse return ""; - return rest[0..q2]; -} + router.dispatch(&request); -/// Extract an integer JSON field without allocating. -fn jsonIntFieldNoAlloc(data: []const u8, key: []const u8) ?u32 { - var nb: [64]u8 = undefined; - const needle = std.fmt.bufPrint(&nb, "\"{s}\"", .{key}) catch return null; - const kpos = std.mem.indexOf(u8, data, needle) orelse return null; - const after = data[kpos + needle.len ..]; - const colon = std.mem.indexOfScalar(u8, after, ':') orelse return null; - const rest = std.mem.trimLeft(u8, after[colon + 1 ..], " \t"); - var end: usize = 0; - while (end < rest.len and std.ascii.isDigit(rest[end])) end += 1; - if (end == 0) return null; - return std.fmt.parseInt(u32, rest[0..end], 10) catch null; + // The single response write path. + const body = request.resp_body; + c.uapi_gnosis_write_response( + resp, + request.status, + "application/json", + if (body.len > 0) @as(?[*]const u8, @ptrCast(body.ptr)) else null, + @intCast(body.len), + ); } // --------------------------------------------------------------------------- // Gnosis pool handle helpers // --------------------------------------------------------------------------- -/// Allocate a gnosis pool slot for `port` and return the handle. -/// Returns 0 and logs a warning if the pool is exhausted or uapi_init has -/// not been called. -fn acquireGnosisHandle(port: u16) u64 { - const handle = c.uapi_gnosis_create(port); - if (handle == 0) { - std.debug.print("[aerie] warn: gnosis pool slot unavailable for port {d}\n", .{port}); - } - return handle; -} - -/// Release a gnosis pool slot and zero the handle. -/// Safe to call with a zero handle (no-op). fn releaseGnosisHandle(handle_ptr: *u64) void { if (handle_ptr.* == 0) return; c.uapi_gnosis_destroy(handle_ptr.*); @@ -688,8 +146,6 @@ pub fn main() !void { defer _ = gpa_inst.deinit(); const gpa = gpa_inst.allocator(); - // Initialise the zig-api library (gnosis server pool + connector pool). - // Must be called before any uapi_gnosis_* or uapi_connector_* function. const init_rc = c.uapi_init(); if (init_rc != c.UAPI_OK) { std.debug.print("[aerie] FATAL: uapi_init() failed with code {d}\n", .{init_rc}); @@ -697,46 +153,36 @@ pub fn main() !void { } defer c.uapi_teardown(); - // Port — single port for all protocols - const port_str = std.posix.getenv("PORT") orelse "4000"; - const http_port = std.fmt.parseInt(u16, port_str, 10) catch 4000; + // Configuration: typed, loaded once, the only getenv reader. + var cfg_arena = std.heap.ArenaAllocator.init(gpa); + defer cfg_arena.deinit(); + g_cfg = config.Config.load(cfg_arena.allocator()); - const cfg = readProtocolConfig(); - printBanner(http_port, cfg); + printBanner(&g_cfg); - // Shared state — allocated on heap so the handler can hold a stable pointer + // Services — stable pointers for the handler's lifetime. const redis_ptr = try gpa.create(rc.RedisClient); redis_ptr.* = rc.RedisClient.init(gpa); - defer { redis_ptr.deinit(); gpa.destroy(redis_ptr); } - - const verisimdb = vc.VerisimDBClient.init(); - - // ------------------------------------------------------------------------- - // Set up module-level handler context before uapi_gnosis_start. - // aerieHandler reads these; they are never mutated after start. - // ------------------------------------------------------------------------- - g_aerie_cfg = cfg; - g_aerie_redis = redis_ptr; - g_aerie_verisimdb = verisimdb; - g_aerie_alloc = gpa; - g_aerie_context_ready = true; + defer { + redis_ptr.deinit(); + gpa.destroy(redis_ptr); + } + g_verisim = vc.VerisimDBClient.init(); + g_alloc = gpa; + g_redis = redis_ptr; + g_ready = true; - // ------------------------------------------------------------------------- - // Single-port setup: - // 1. Reserve one gnosis pool slot on http_port. - // 2. Register aerieHandler as the edge dispatch hook. - // 3. Start the gnosis accept loop. - // ------------------------------------------------------------------------- - gnosis_http_handle = acquireGnosisHandle(http_port); + // Single-port setup: create → register V2 handler → start. + gnosis_http_handle = c.uapi_gnosis_create(g_cfg.port); if (gnosis_http_handle == 0) { - std.debug.print("[aerie] FATAL: gnosis pool slot unavailable for port {d}\n", .{http_port}); + std.debug.print("[aerie] FATAL: gnosis pool slot unavailable for port {d}\n", .{g_cfg.port}); return error.GnosisCreateFailed; } defer releaseGnosisHandle(&gnosis_http_handle); - const set_rc = c.uapi_gnosis_set_handler(gnosis_http_handle, &aerieHandler); + const set_rc = c.uapi_gnosis_set_handler_v2(gnosis_http_handle, &aerieHandlerV2); if (set_rc != c.UAPI_OK) { - std.debug.print("[aerie] FATAL: uapi_gnosis_set_handler failed (result={d})\n", .{set_rc}); + std.debug.print("[aerie] FATAL: uapi_gnosis_set_handler_v2 failed (result={d})\n", .{set_rc}); return error.GnosisSetHandlerFailed; } @@ -746,11 +192,37 @@ pub fn main() !void { return error.GnosisStartFailed; } - std.debug.print("[aerie] Listening on :{d} — REST /api/v1/* | GraphQL /graphql | gRPC-JSON /grpc/*\n", - .{http_port}); + std.debug.print("[aerie] Listening on :{d} — REST /api/v1/* | GraphQL /graphql | gRPC-JSON /grpc/*\n", .{g_cfg.port}); // Block main thread until the server stops. while (c.uapi_gnosis_state(gnosis_http_handle) == c.UAPI_SERVER_LISTENING) { std.Thread.sleep(1 * std.time.ns_per_s); } } + +fn printBanner(cfg: *const config.Config) void { + std.debug.print( + \\ + \\+----------------------------------------------------------+ + \\| AERIE GATEWAY — Zig (single-port, MPL-2.0) | + \\+----------------------------------------------------------+ + \\| Port : {d:<5} | + \\| REST : /api/v1/* {s} | + \\| GraphQL : /graphql {s} | + \\| gRPC-JSON : /grpc/* {s} | + \\| Auth mode : {s} | + \\+----------------------------------------------------------+ + \\| Server pool : uapi_gnosis_* (in-repo zig_api) | + \\| Connector pool : uapi_connector_* (in-repo zig_api) | + \\| Proof mode : light (SHA-256) | + \\| Policy gate : Phase 1 (permissive; deny lands P2) | + \\+----------------------------------------------------------+ + \\ + , .{ + cfg.port, + if (cfg.rest_enabled) "ENABLED " else "disabled", + if (cfg.graphql_enabled) "ENABLED " else "disabled", + if (cfg.grpc_enabled) "ENABLED " else "disabled", + @tagName(cfg.auth_mode), + }); +} diff --git a/src/api/zig/respond.zig b/src/api/zig/respond.zig new file mode 100644 index 0000000..69354f2 --- /dev/null +++ b/src/api/zig/respond.zig @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// respond.zig — the single response write path. Every reply — success, +// error, 404, protocol toggle — funnels through respond()/respondError(); +// the ~15 copy-pasted resp.* blocks of the old handler collapse to one +// home. Transport-level hardening headers are owned by the FFI server +// (gnosis.zig writeGnosisResponse); this layer owns application shape. + +const std = @import("std"); +const ctx = @import("ctx.zig"); +const errors = @import("errors.zig"); + +const AERIE_CT_JSON: [*:0]const u8 = "application/json"; + +/// Set the response (status + body). The body must outlive the call +/// (slice of ctx.out_buf, arena, or a literal). +pub fn respond(c: *ctx.Ctx, status: u16, body: []const u8) void { + c.status = status; + c.resp_body = body; +} + +/// Respond with a JSON error envelope. The body is copied into the +/// gnosis-owned response scratch (the arena dies before the socket +/// write — see ctx.Ctx). The message is gateway-authored (no user +/// input), so no JSON escaping is applied; if that ever changes, +/// escape here — single home. +pub fn respondError(c: *ctx.Ctx, status: u16, message: []const u8) void { + var buf: [512]u8 = undefined; + const body = std.fmt.bufPrint(&buf, "{{\"error\":\"{s}\"}}", .{message}) catch "{\"error\":\"internal error\"}"; + const owned = c.copyToBody(body, "{\"error\":\"internal error\"}"); + respond(c, status, owned); +} + +/// Respond from the error taxonomy (status + canonical message). +pub fn respondApiError(c: *ctx.Ctx, err: errors.ApiError) void { + respondError(c, errors.statusOf(err), errors.messageOf(err)); +} + +/// Map the Ctx response slot onto the C ABI GnosisResponse. Called once, +/// at the end of dispatch, from the edge handler. +pub fn fillGnosisResponse( + c: *ctx.Ctx, + resp: anytype, // *GnosisResponse (typed via @cImport at the call site) + writeFn: anytype, // uapi_gnosis_write_response equivalent +) void { + const body: []const u8 = c.resp_body; + writeFn( + resp, + c.status, + AERIE_CT_JSON, + if (body.len > 0) @as(?[*]const u8, @ptrCast(body.ptr)) else null, + @intCast(body.len), + ); +} + +test "respond: status and body land in the ctx slot" { + var arena_inst = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_inst.deinit(); + var c = ctx.Ctx{ + .arena = arena_inst.allocator(), + .method = "GET", + .path = "/", + .query = "", + .body = "", + .header_names = null, + .header_values = null, + .header_count = 0, + .cfg = undefined, + .redis = undefined, + .verisim = undefined, + }; + respond(&c, 429, "{\"error\":\"slow down\"}"); + try std.testing.expectEqual(@as(u16, 429), c.status); + try std.testing.expectEqualStrings("{\"error\":\"slow down\"}", c.resp_body); + + respondApiError(&c, errors.ApiError.NotFound); + try std.testing.expectEqual(@as(u16, 404), c.status); +} diff --git a/src/api/zig/router.zig b/src/api/zig/router.zig new file mode 100644 index 0000000..34c61cd --- /dev/null +++ b/src/api/zig/router.zig @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// router.zig — the single route table and the dispatch pipeline. +// +// METAICONIC: the weave is data. This table is the one place that +// declares paths, verbs, policy modules and resolvers; dispatch, verb +// governance and the policy gate all consume it. The old design kept +// three parallel route tables (a startsWith chain, moduleFromPath, and +// verb_governance.RULES) in sync by hand; this is the table that +// replaces them. +// +// REFLECTIVE: /api/v1/health (and, in Phase 2, /api/v1/meta) render the +// table itself — routes, verbs, modules — rather than a hand-copied +// description. The description cannot drift from the behaviour because +// it IS the behaviour. + +const std = @import("std"); +const t = @import("types.zig"); +const ctx = @import("ctx.zig"); +const res = @import("resolvers.zig"); +const pol = @import("policy.zig"); +const prf = @import("proof.zig"); +const vg = @import("verb_governance.zig"); +const respond = @import("respond.zig"); +const errors = @import("errors.zig"); + +pub const Resolver = *const fn (*ctx.Ctx) void; + +pub const Route = struct { + path: []const u8, + verbs: []const []const u8, + module: []const u8, + resolver: Resolver, +}; + +/// THE table. Longest-prefix wins; boundary-guarded (a route matches +/// only at end-of-path or at a '/'), so /api/v1/telemetryX matches +/// nothing. gRPC is dispatched by the /grpc/ prefix (method names are +/// dynamic) and is not table-mapped. +pub const routes = [_]Route{ + .{ .path = "/api/v1/health", .verbs = &.{ "GET", "OPTIONS" }, .module = "health", .resolver = healthResolver }, + .{ .path = "/api/v1/telemetry", .verbs = &.{ "GET", "OPTIONS" }, .module = "telemetry", .resolver = telemetryResolver }, + .{ .path = "/api/v1/routes", .verbs = &.{ "GET", "OPTIONS" }, .module = "routes", .resolver = routesResolver }, + .{ .path = "/api/v1/audit/temporal", .verbs = &.{ "GET", "OPTIONS" }, .module = "temporal_audit", .resolver = temporalResolver }, + .{ .path = "/api/v1/audit", .verbs = &.{ "GET", "OPTIONS" }, .module = "audit", .resolver = auditResolver }, + .{ .path = "/api/v1/smokeping", .verbs = &.{ "GET", "OPTIONS" }, .module = "smokeping", .resolver = smokepingResolver }, + .{ .path = "/graphql", .verbs = &.{ "GET", "POST", "OPTIONS" }, .module = "graphql", .resolver = graphqlResolver }, +}; + +/// Boundary-guarded longest-prefix match. +pub fn find(path: []const u8) ?*const Route { + var best: ?*const Route = null; + var best_len: usize = 0; + for (&routes) |*route| { + if (!std.mem.startsWith(u8, path, route.path)) continue; + const boundary = path.len == route.path.len or path[route.path.len] == '/'; + if (!boundary) continue; + if (route.path.len > best_len) { + best = route; + best_len = route.path.len; + } + } + return best; +} + +/// Verb check against the table. +pub fn verbAllowed(route: *const Route, method: []const u8) bool { + for (route.verbs) |v| { + if (std.ascii.eqlIgnoreCase(method, v)) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Dispatch — the ordered pipeline (aspects grow around this spine) +// --------------------------------------------------------------------------- + +pub fn dispatch(c: *ctx.Ctx) void { + // CORS preflight. + if (std.ascii.eqlIgnoreCase(c.method, "OPTIONS")) { + c.status = 204; + c.resp_body = ""; + return; + } + + // Protocol enablement (health is always available). + if (std.mem.startsWith(u8, c.path, "/graphql") and !c.cfg.graphql_enabled) { + respond.respondError(c, 404, "GraphQL disabled (hint: set ENABLE_GRAPHQL=true)"); + return; + } + if (std.mem.startsWith(u8, c.path, "/api/v1/") and + !std.mem.startsWith(u8, c.path, "/api/v1/health") and + !c.cfg.rest_enabled) + { + respond.respondError(c, 404, "REST disabled (hint: set ENABLE_REST=true)"); + return; + } + if (std.mem.startsWith(u8, c.path, "/grpc/") and !c.cfg.grpc_enabled) { + respond.respondError(c, 404, "gRPC disabled (hint: set ENABLE_GRPC=true)"); + return; + } + + // gRPC-JSON: dynamic method names dispatch by prefix. + if (std.mem.startsWith(u8, c.path, "/grpc/")) { + const method_name = c.path["/grpc/".len..]; + const grpc_method = if (method_name.len > 0) method_name else c.jsonStrField("method"); + c.policy = pol.evaluatePolicy(c.header("x-api-key"), grpc_method); + grpcDispatch(c, grpc_method); + return; + } + + // Table routing. + const route = find(c.path) orelse { + c.policy = pol.evaluatePolicy(c.header("x-api-key"), "unknown"); + respond.respond(c, 404, notFoundJson(c)); + return; + }; + c.route = route; + + // Verb governance (stealth mode: 404 + timing jitter on denial). + if (!verbAllowed(route, c.method)) { + vg.stealthDelay(); + c.policy = pol.evaluatePolicy("", route.module); + respond.respondError(c, vg.denialStatusCode(.{ + .allowed = false, .matched = true, .stealth = true, + .rule_name = undefined, .rule_len = 0, .verb = undefined, .verb_len = 0, + }), "not found"); + return; + } + + // Policy gate — with the REAL API key at last (V2 headers). + c.policy = pol.evaluatePolicy(c.header("x-api-key"), route.module); + + route.resolver(c); +} + +// --------------------------------------------------------------------------- +// Route adapters (the bridge to the existing resolvers; Phase 2 folds +// the cache/envelope/audit decorators in here, once per concern) +// --------------------------------------------------------------------------- + +fn healthResolver(c: *ctx.Ctx) void { + respond.respond(c, 200, healthJson(c)); +} + +fn telemetryResolver(c: *ctx.Ctx) void { + respond.respond(c, 200, res.resolveTelemetry(c.redis, c.policy, c.out_buf, c.arena)); +} + +fn routesResolver(c: *ctx.Ctx) void { + const target = c.queryParam("target"); + if (target.len == 0) { + respond.respondError(c, 400, "missing required query parameter: target (usage: /api/v1/routes?target=)"); + return; + } + respond.respond(c, 200, res.resolveRouteForensics(target, c.redis, c.policy, c.out_buf, c.arena)); +} + +fn temporalResolver(c: *ctx.Ctx) void { + const mode = c.queryParam("mode"); + if (mode.len == 0) { + respond.respondError(c, 400, "missing required query parameter: mode (usage: /api/v1/audit/temporal?mode=as_of&time=...; available: as_of, between, history)"); + return; + } + const params = res.TemporalParams{ + .time = c.queryParam("time"), + .start = c.queryParam("start"), + .end = c.queryParam("end"), + .event_id = c.queryParam("event_id"), + .limit = std.fmt.parseInt(u32, c.queryParam("limit"), 10) catch 50, + }; + respond.respond(c, 200, res.resolveTemporalAudit(mode, params, c.redis, c.verisim, c.policy, c.out_buf, c.arena)); +} + +fn auditResolver(c: *ctx.Ctx) void { + const limit = std.fmt.parseInt(u32, c.queryParam("limit"), 10) catch 50; + respond.respond(c, 200, res.resolveAudit(limit, c.redis, c.policy, c.out_buf, c.arena)); +} + +fn smokepingResolver(c: *ctx.Ctx) void { + const target = c.queryParam("target"); + if (target.len == 0) { + respond.respondError(c, 400, "missing required query parameter: target (usage: /api/v1/smokeping?target=)"); + return; + } + respond.respond(c, 200, res.resolveSmokeping(target, c.redis, c.policy, c.out_buf, c.arena)); +} + +fn graphqlResolver(c: *ctx.Ctx) void { + // GraphQL reports errors in-band (HTTP 200 with an errors array), + // per its transport conventions. + if (!std.ascii.eqlIgnoreCase(c.method, "POST")) { + respond.respond(c, 200, "{\"errors\":[{\"message\":\"GraphQL endpoint requires POST method\"}]}"); + return; + } + const query = c.jsonStrField("query"); + if (query.len == 0) { + respond.respond(c, 200, "{\"errors\":[{\"message\":\"Missing query field in request body\"}]}"); + return; + } + respond.respond(c, 200, res.resolveGraphqlQuery(query, c.redis, c.verisim, c.policy, c.out_buf, c.arena)); +} + +/// gRPC-JSON dispatch (method name from path or body). +fn grpcDispatch(c: *ctx.Ctx, method_name: []const u8) void { + if (std.mem.eql(u8, method_name, "GetTelemetrySnapshot")) { + respond.respond(c, 200, res.resolveTelemetry(c.redis, c.policy, c.out_buf, c.arena)); + return; + } + if (std.mem.eql(u8, method_name, "GetRouteForensicsSnapshot")) { + const target = c.jsonStrField("target"); + if (target.len == 0) { + respond.respondError(c, 400, "target field required"); + return; + } + respond.respond(c, 200, res.resolveRouteForensics(target, c.redis, c.policy, c.out_buf, c.arena)); + return; + } + if (std.mem.eql(u8, method_name, "GetAuditSnapshot")) { + const limit = c.jsonIntField("limit") orelse @as(u32, 50); + respond.respond(c, 200, res.resolveAudit(limit, c.redis, c.policy, c.out_buf, c.arena)); + return; + } + if (std.mem.eql(u8, method_name, "GetSmokePingSnapshot")) { + const target = c.jsonStrField("target"); + if (target.len == 0) { + respond.respondError(c, 400, "target field required"); + return; + } + respond.respond(c, 200, res.resolveSmokeping(target, c.redis, c.policy, c.out_buf, c.arena)); + return; + } + if (std.mem.eql(u8, method_name, "GetTemporalAuditSnapshot")) { + const mode = c.jsonStrField("mode"); + if (mode.len == 0) { + respond.respondError(c, 400, "mode field required (as_of, between, history)"); + return; + } + const params = res.TemporalParams{ + .time = c.jsonStrField("time"), + .start = c.jsonStrField("start"), + .end = c.jsonStrField("end"), + .event_id = c.jsonStrField("event_id"), + .limit = c.jsonIntField("limit") orelse 50, + }; + respond.respond(c, 200, res.resolveTemporalAudit(mode, params, c.redis, c.verisim, c.policy, c.out_buf, c.arena)); + return; + } + var eb: [256]u8 = undefined; + const body = std.fmt.bufPrint(&eb, + "{{\"error\":\"Unknown method: {s}\"," ++ + "\"available\":[\"GetTelemetrySnapshot\",\"GetRouteForensicsSnapshot\"," ++ + "\"GetAuditSnapshot\",\"GetSmokePingSnapshot\",\"GetTemporalAuditSnapshot\"]}}", + .{method_name}, + ) catch "{\"error\":\"unknown method\"}"; + respond.respond(c, 404, c.copyToBody(body, "{\"error\":\"unknown method\"}")); +} + +// --------------------------------------------------------------------------- +// Reflective renderings (health + not-found describe the live table) +// --------------------------------------------------------------------------- + +const GATEWAY_VERSION = "0.3.0"; + +/// Health JSON — includes the live gnosis pool state handed in via Ctx. +pub fn healthJson(c: *ctx.Ctx) []const u8 { + var ts_buf: [32]u8 = undefined; + prf.formatRfc3339(&ts_buf); + const ts = std.mem.sliceTo(&ts_buf, 0); + const cfg = c.cfg; + var active: u8 = 0; + if (cfg.rest_enabled) active += 1; + if (cfg.graphql_enabled) active += 1; + if (cfg.grpc_enabled) active += 1; + var bound: u8 = 0; + if (cfg.rest_enabled or cfg.graphql_enabled) bound += 1; + if (cfg.grpc_enabled) bound += 1; + return std.fmt.bufPrint(c.out_buf, + "{{\"status\":\"healthy\",\"service\":\"aerie-gateway\",\"version\":\"{s}\"," ++ + "\"timestamp\":\"{s}\",\"protocols\":{{\"rest\":{s},\"graphql\":{s},\"grpc\":{s}}}," ++ + "\"active_protocols\":{d},\"bound_ports\":{d}," ++ + "\"verb_governance\":true,\"stealth_mode\":true,\"proof_mode\":\"light\"," ++ + "\"policy_phase\":2,\"pool\":{{\"slot_state\":{d}}}}}", + .{ + GATEWAY_VERSION, ts, + if (cfg.rest_enabled) "true" else "false", if (cfg.graphql_enabled) "true" else "false", + if (cfg.grpc_enabled) "true" else "false", active, + bound, c.pool_state, + }, + ) catch "{\"status\":\"healthy\"}"; +} + +/// Not-found JSON — lists only enabled endpoints, derived from the table. +pub fn notFoundJson(c: *ctx.Ctx) []const u8 { + var fbs = std.io.fixedBufferStream(c.out_buf); + const w = fbs.writer(); + w.writeAll("{\"error\":\"Not found\",\"available_endpoints\":[") catch {}; + var first = true; + for (&routes) |*route| { + if (std.mem.eql(u8, route.module, "graphql") and !c.cfg.graphql_enabled) continue; + if (std.mem.startsWith(u8, route.path, "/api/v1/") and + !std.mem.eql(u8, route.path, "/api/v1/health") and !c.cfg.rest_enabled) continue; + if (!first) w.writeByte(',') catch {}; + w.writeAll("\"") catch {}; + w.writeAll(route.path) catch {}; + w.writeAll("\"") catch {}; + first = false; + } + w.writeAll("]}") catch {}; + return fbs.getWritten(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "router: boundary-guarded longest-prefix match" { + try std.testing.expectEqualStrings("temporal_audit", find("/api/v1/audit/temporal").?.module); + try std.testing.expectEqualStrings("audit", find("/api/v1/audit").?.module); + try std.testing.expectEqualStrings("telemetry", find("/api/v1/telemetry").?.module); + // boundary guard: telemetryX matches nothing + try std.testing.expect(find("/api/v1/telemetryX") == null); + // sub-resource falls through to the parent route + try std.testing.expectEqualStrings("audit", find("/api/v1/audit/other").?.module); + try std.testing.expect(find("/nope") == null); +} + +test "router: verbs enforced per route" { + const telemetry = find("/api/v1/telemetry").?; + try std.testing.expect(verbAllowed(telemetry, "GET")); + try std.testing.expect(!verbAllowed(telemetry, "POST")); + try std.testing.expect(!verbAllowed(telemetry, "DELETE")); + const graphql = find("/graphql").?; + try std.testing.expect(verbAllowed(graphql, "POST")); + try std.testing.expect(verbAllowed(graphql, "get")); // case-insensitive +} diff --git a/src/api/zig/verb_governance.zig b/src/api/zig/verb_governance.zig index 6819ee4..13ccd11 100644 --- a/src/api/zig/verb_governance.zig +++ b/src/api/zig/verb_governance.zig @@ -1,115 +1,47 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// verb_governance.zig — HTTP Verb Governance, Stealth Mode & Timing Jitter +// verb_governance.zig — HTTP verb governance, stealth mode & timing +// jitter. The verb ASPECT; the route DATA lives in router.zig — this +// module no longer keeps a second copy of the table (the three-tables- +// in-sync hazard is gone). Stealth mode returns 404 (not 403/405) for +// denied requests; timing jitter on stealth denials closes the timing +// side-channel. // -// Enforces which HTTP methods are permitted per route. Stealth mode returns -// 404 (not 403/405) for denied requests. Timing jitter on stealth denials -// closes the timing side-channel. -// -// Phase 1: hardcoded rules. Phase 2: YAML policy file loaded at startup. -// -// Replaces: verb_governance.v +// Replaces: verb_governance.v, and the Phase-1 hardcoded RULES. const std = @import("std"); -const t = @import("types.zig"); - -/// A verb rule: URL prefix and its allowed methods. -const VerbRule = struct { - pattern: []const u8, - verbs: []const []const u8, - name: []const u8, -}; - -/// Number of hardcoded rules. -const RULE_COUNT = 6; - -/// Hardcoded verb rules for Aerie Phase 1. -const RULES: [RULE_COUNT]VerbRule = .{ - .{ .pattern = "/graphql", .verbs = &.{ "POST", "OPTIONS" }, .name = "graphql-endpoint" }, - .{ .pattern = "/api/v1/health", .verbs = &.{ "GET", "OPTIONS" }, .name = "health-check" }, - .{ .pattern = "/api/v1/telemetry", .verbs = &.{ "GET", "OPTIONS" }, .name = "rest-telemetry" }, - .{ .pattern = "/api/v1/routes", .verbs = &.{ "GET", "OPTIONS" }, .name = "rest-routes" }, - .{ .pattern = "/api/v1/audit/temporal",.verbs = &.{ "GET", "OPTIONS" }, .name = "rest-temporal" }, - .{ .pattern = "/api/v1/audit", .verbs = &.{ "GET", "OPTIONS" }, .name = "rest-audit" }, -}; - -/// Copy `src` into fixed buffer `dst`, null-terminating; returns length written. -fn copyField(comptime N: usize, dst: *[N]u8, src: []const u8) usize { - const n = @min(src.len, N - 1); - @memcpy(dst[0..n], src[0..n]); - dst[n] = 0; - return n; -} - -/// Check whether `method` is in `verbs`. -fn verbAllowed(method: []const u8, verbs: []const []const u8) bool { - for (verbs) |v| { - if (std.ascii.eqlIgnoreCase(method, v)) return true; - } - return false; -} - -/// Strip query string from URL, returning just the path portion. -fn pathOnly(url: []const u8) []const u8 { - if (std.mem.indexOfScalar(u8, url, '?')) |qi| return url[0..qi]; - return url; -} - -/// Find the most specific matching rule for a URL path using prefix matching. -/// -/// Rules are checked from most-specific (longest pattern) to least-specific -/// to ensure /api/v1/audit/temporal wins over /api/v1/audit. -fn findRule(url: []const u8) ?VerbRule { - const path = pathOnly(url); - var best: ?VerbRule = null; - var best_len: usize = 0; - for (RULES) |rule| { - if (std.mem.startsWith(u8, path, rule.pattern)) { - if (rule.pattern.len > best_len) { - best = rule; - best_len = rule.pattern.len; - } - } - } - return best; -} +const t = @import("types.zig"); +const router = @import("router.zig"); -/// Check an HTTP method + URL against the verb governance rules. -/// -/// Stealth mode is always enabled: denied verbs receive 404-equivalent -/// responses, indistinguishable from genuine not-found. +/// Check `method` against the route table (longest-prefix, boundary- +/// guarded). OPTIONS on an unmatched path is CORS preflight: allowed. pub fn check(method: []const u8, url: []const u8) t.VerbDecision { var dec: t.VerbDecision = std.mem.zeroes(t.VerbDecision); _ = copyField(16, &dec.verb, method); dec.verb_len = @min(method.len, 15); - if (findRule(url)) |rule| { - dec.matched = true; - _ = copyField(64, &dec.rule_name, rule.name); - dec.rule_len = rule.name.len; - - if (verbAllowed(method, rule.verbs)) { - dec.allowed = true; - dec.stealth = false; - } else { - dec.allowed = false; - dec.stealth = true; // stealth mode always on - } + const path = pathOnly(url); + if (router.find(path)) |route| { + dec.matched = true; + _ = copyField(64, &dec.rule_name, route.module); + dec.rule_len = route.module.len; + dec.allowed = router.verbAllowed(route, method); + dec.stealth = !dec.allowed; // stealth mode always on return dec; } - // No rule matched — CORS preflight is always allowed + // No rule matched — CORS preflight is always allowed. if (std.ascii.eqlIgnoreCase(method, "OPTIONS")) { - dec.allowed = true; - dec.matched = true; + dec.allowed = true; + dec.matched = true; _ = copyField(64, &dec.rule_name, "cors-preflight"); dec.rule_len = 14; - dec.stealth = false; + dec.stealth = false; return dec; } - // Unknown route — deny + // Unknown route — deny. dec.allowed = false; dec.matched = false; dec.stealth = true; @@ -122,12 +54,53 @@ pub fn denialStatusCode(dec: t.VerbDecision) u16 { return if (dec.stealth) 404 else 405; } -/// Apply stealth timing jitter: sleep 1–8ms before sending a stealth denial. -/// This closes the timing side-channel that would otherwise distinguish -/// fast stealth-denials (<0.1ms) from genuine responses (2–10ms). +/// Apply stealth timing jitter: sleep 1–8ms before sending a stealth +/// denial. Closes the timing side-channel that would otherwise +/// distinguish fast stealth-denials (<0.1ms) from genuine responses +/// (2–10ms). pub fn stealthDelay() void { var seed: u64 = undefined; std.crypto.random.bytes(std.mem.asBytes(&seed)); - const jitter_ms: u64 = 1 + (seed % 8); // [1,8] ms + const jitter_ms: u64 = 1 + (seed % 8); // [1,8] ms std.Thread.sleep(jitter_ms * std.time.ns_per_ms); } + +/// Strip query string from URL, returning just the path portion. +fn pathOnly(url: []const u8) []const u8 { + if (std.mem.indexOfScalar(u8, url, '?')) |qi| return url[0..qi]; + return url; +} + +/// Copy `src` into fixed buffer `dst`, null-terminating; returns length. +fn copyField(comptime N: usize, dst: *[N]u8, src: []const u8) usize { + const n = @min(src.len, N - 1); + @memcpy(dst[0..n], src[0..n]); + dst[n] = 0; + return n; +} + +// --------------------------------------------------------------------------- +// Tests — the table is in router.zig; these prove the aspect reads it. +// --------------------------------------------------------------------------- + +test "verb governance: table-driven allow/deny with stealth" { + const ok = check("GET", "/api/v1/smokeping?target=x"); + try std.testing.expect(ok.allowed and ok.matched and !ok.stealth); + try std.testing.expectEqualStrings("smokeping", std.mem.sliceTo(&ok.rule_name, 0)); + + const denied = check("POST", "/api/v1/telemetry"); + try std.testing.expect(!denied.allowed and denied.matched and denied.stealth); + try std.testing.expectEqual(@as(u16, 404), denialStatusCode(denied)); + + const unmatched = check("GET", "/nope"); + try std.testing.expect(!unmatched.allowed and !unmatched.matched and unmatched.stealth); + + const preflight = check("OPTIONS", "/nope"); + try std.testing.expect(preflight.allowed and preflight.matched); +} + +test "verb governance: graphql accepts GET and POST" { + try std.testing.expect(check("GET", "/graphql").allowed); + try std.testing.expect(check("POST", "/graphql").allowed); + try std.testing.expect(!check("DELETE", "/graphql").allowed); +}