rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 6e11763abfd8796558230e92f12a9fd390cfe0ee
parent 758ab117f9d94130389d2f0372117625a1ec8563
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 21:25:33 +0000

runtime: execute the governed RHI daemon

Diffstat:
MCargo.lock | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCargo.toml | 9++++++---
Mcontracts/api_baselines/rhi.txt | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcontracts/services_hardening/operator_contract.v1.json | 9++++++---
Mradroots.service.source-lock.v2.toml | 4++--
Msrc/admin_v1.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Asrc/cli_bootstrap.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/cli_v1.rs | 859++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Asrc/config_loader.rs | 650+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/diagnostics_v1.rs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 49++++++++++++++++++++++++++++++++++++++++---------
Msrc/main.rs | 139++++++++++++++++++++++++++++++++-----------------------------------------------
Asrc/process_v1.rs | 768+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/reconciliation_attestation.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/reconciliation_replay.rs | 38++++++++++++++++++++++++++++++++++++++
Msrc/runtime_adapters.rs | 50+++++++++++++++++++++++++++++++++++++++++++-------
Asrc/runtime_admin.rs | 2023+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime_foundation.rs | 54++++++++++++++++++++++++++++++++++++++++++++----------
Asrc/runtime_graph.rs | 1716+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/runtime_signal.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/source_ingest.rs | 37+++++++++++++++++++++++++++++++++++++
Asrc/state_admin.rs | 807+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_catalog.rs | 150++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/state_config.rs | 17+++++++++++++++++
Msrc/state_host.rs | 38++++++++++++++++++++++++++++++++++++++
Msrc/status_v1.rs | 10++++++++--
Asrc/system_doctor.rs | 341+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport_nostr_adapter.rs | 635+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 7+++++--
Mtests/package_boundary.rs | 7++++---
Mtests/services_hardening_cli.rs | 327+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mtests/services_hardening_doctor.rs | 9+++++++--
Mtests/services_hardening_native_release.rs | 2+-
Mtests/services_hardening_operator_contract.rs | 30+++++++++++++++++++++++++++++-
Atests/services_hardening_process.rs | 441+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_state_catalog.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtests/services_hardening_state_host.rs | 6++++--
Mtests/services_hardening_state_resilience.rs | 4++--
Mtests/services_hardening_status.rs | 2+-
Mtests/services_hardening_wave_100_a.rs | 6++++--
Mtests/source_guards.rs | 7++++++-
41 files changed, 9876 insertions(+), 534 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -94,7 +94,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -105,7 +105,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -121,6 +121,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] +name = "async-utility" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "188f83b9a198af8c336e505611edb00d6d2ac5c694241c5a4f9a12316938cfe9" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] name = "atoi" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -130,6 +161,12 @@ dependencies = [ ] [[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -423,7 +460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -435,7 +472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -503,7 +540,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -531,7 +568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -567,7 +604,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -811,13 +848,25 @@ dependencies = [ ] [[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "group" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1270,6 +1319,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] name = "mediatype" version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1305,10 +1360,16 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] +name = "negentropy" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81c353b400a5503efdcf398f11a83fb7aa84f59f5d76fc4bf5bbc1e4f5366caa" + +[[package]] name = "nostr" version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1335,6 +1396,59 @@ dependencies = [ ] [[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] name = "num" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1472,7 +1586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1760,14 +1874,44 @@ dependencies = [ ] [[package]] +name = "radroots_transport_nostr" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=21b11e7a5120ea949f7ad0838c746873fc73aac2#21b11e7a5120ea949f7ad0838c746873fc73aac2" +dependencies = [ + "async-wsocket", + "futures", + "nostr-relay-pool", + "nostr-sdk", + "radroots_event_codec", + "radroots_nostr", + "radroots_protocol", + "radroots_transport", + "serde_json", + "sha2", + "tokio", + "tokio-tungstenite", + "url", +] + +[[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1777,7 +1921,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1790,6 +1944,15 @@ dependencies = [ ] [[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1868,9 +2031,11 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rhi" version = "0.1.0" dependencies = [ + "base64", "chacha20poly1305", "clap", "futures-executor", + "hmac", "jsonschema", "nostr", "radroots_event", @@ -1884,6 +2049,7 @@ dependencies = [ "radroots_storage", "radroots_trade", "radroots_transport", + "radroots_transport_nostr", "rustix", "serde", "serde_json", @@ -1893,6 +2059,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml", + "tungstenite", "url", "zeroize", ] @@ -1913,6 +2080,20 @@ dependencies = [ ] [[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 = "rust_decimal" version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1933,7 +2114,41 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1988,7 +2203,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -2061,6 +2276,17 @@ dependencies = [ ] [[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2078,6 +2304,16 @@ 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 = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2102,7 +2338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2311,7 +2547,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2389,9 +2625,10 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2406,6 +2643,28 @@ dependencies = [ ] [[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 = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] name = "tokio-stream" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2417,6 +2676,22 @@ dependencies = [ ] [[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] name = "tokio-util" version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2510,6 +2785,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2559,6 +2853,12 @@ dependencies = [ ] [[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" @@ -2584,6 +2884,12 @@ dependencies = [ ] [[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2670,6 +2976,20 @@ dependencies = [ ] [[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] name = "wasm-bindgen-macro" version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2746,6 +3066,24 @@ dependencies = [ ] [[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2775,6 +3113,15 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" @@ -2783,6 +3130,70 @@ dependencies = [ ] [[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -20,7 +20,7 @@ service = "rhi" host_feature_profile = "service-host" nix_material = "absent" config_contract_version = 1 -state_contract_version = 7 +state_contract_version = 11 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 @@ -51,6 +51,7 @@ service-host = [] workspace = true [dependencies] +base64 = "0.22" radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", features = ["serde"] } radroots_event_codec = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", features = ["json"] } radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", features = ["events"] } @@ -60,12 +61,14 @@ radroots_service_host = { git = "https://github.com/radrootslabs/lib", rev = "21 radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" } radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", default-features = false } radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", default-features = false, features = ["std"] } +radroots_transport_nostr = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" } radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" } radroots_trade = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" } chacha20poly1305 = { version = "0.10" } clap = { version = "4", features = ["derive"] } futures-executor = { version = "0.3" } +hmac = { version = "0.12" } jsonschema = { version = "0.48.1", default-features = false } nostr = { version = "0.44.7" } rustix = { version = "1", features = ["fs", "process", "std"] } @@ -74,14 +77,14 @@ serde_json = { version = "1", default-features = false, features = ["raw_value"] sha2 = { version = "0.10" } sqlx = { version = "0.9.0", default-features = false, features = ["sqlite-bundled"] } thiserror = { version = "2" } -tokio = { version = "1", default-features = false, features = ["time"] } +tokio = { version = "1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tempfile = { version = "3" } toml = { version = "0.8" } url = "2" zeroize = { version = "1" } [dev-dependencies] -tokio = { version = "1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread"] } +tungstenite = "0.26" [profile.release] lto = "thin" diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt @@ -120,6 +120,7 @@ pub rhi::RhiCliPrimaryAuthorityV1::LiveUnixAdmin pub rhi::RhiCliPrimaryAuthorityV1::Offline pub enum rhi::RhiCliV1ErrorKind pub rhi::RhiCliV1ErrorKind::InvalidArguments +pub rhi::RhiCliV1ErrorKind::InvalidCommandInput pub rhi::RhiCliV1ErrorKind::InvalidConfigPath pub rhi::RhiCliV1ErrorKind::InvalidInstance pub rhi::RhiCliV1ErrorKind::InvalidRepoLocalRoot @@ -137,6 +138,8 @@ pub rhi::RhiCommandV1::Sources(rhi::RhiSourcesCommandV1) pub rhi::RhiCommandV1::State(rhi::RhiStateCommandV1) pub rhi::RhiCommandV1::Status pub rhi::RhiCommandV1::Trade(rhi::RhiTradeCommandV1) +impl core::fmt::Debug for rhi::RhiCommandV1 +pub fn rhi::RhiCommandV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub enum rhi::RhiConfigApplyErrorKind pub rhi::RhiConfigApplyErrorKind::Binding pub rhi::RhiConfigApplyErrorKind::Close @@ -147,7 +150,7 @@ pub rhi::RhiConfigApplyErrorKind::Transaction impl rhi::RhiConfigApplyErrorKind pub const fn rhi::RhiConfigApplyErrorKind::code(self) -> &'static str pub enum rhi::RhiConfigCommandV1 -pub rhi::RhiConfigCommandV1::Apply +pub rhi::RhiConfigCommandV1::Apply(rhi::RhiConfigApplyArgsV1) pub rhi::RhiConfigCommandV1::Init pub rhi::RhiConfigCommandV1::Schema pub rhi::RhiConfigCommandV1::Show @@ -159,6 +162,16 @@ pub rhi::RhiConfigDefaultAuthority::RadrootsEvent pub rhi::RhiConfigDefaultAuthority::RadrootsServiceHost pub rhi::RhiConfigDefaultAuthority::RadrootsServiceSqlite pub rhi::RhiConfigDefaultAuthority::RhiEvidencePolicy +pub enum rhi::RhiConfigLoadErrorKind +pub rhi::RhiConfigLoadErrorKind::AlreadyExists +pub rhi::RhiConfigLoadErrorKind::InsecureArtifact +pub rhi::RhiConfigLoadErrorKind::InsecureParent +pub rhi::RhiConfigLoadErrorKind::InvalidDocument +pub rhi::RhiConfigLoadErrorKind::InvalidPath +pub rhi::RhiConfigLoadErrorKind::Io +pub rhi::RhiConfigLoadErrorKind::Missing +pub rhi::RhiConfigLoadErrorKind::TooLarge +pub rhi::RhiConfigLoadErrorKind::UnsupportedPlatform pub enum rhi::RhiConfigProfile pub rhi::RhiConfigProfile::Production pub rhi::RhiConfigProfile::RepoLocal @@ -271,6 +284,22 @@ pub enum rhi::RhiIntegrityStateV1 pub rhi::RhiIntegrityStateV1::Failed pub rhi::RhiIntegrityStateV1::VerificationRequired pub rhi::RhiIntegrityStateV1::Verified +pub enum rhi::RhiLogEvent +pub rhi::RhiLogEvent::CriticalTaskFailed +pub rhi::RhiLogEvent::Lifecycle +pub rhi::RhiLogEvent::ProcessResult +pub rhi::RhiLogEvent::ShutdownForced +pub rhi::RhiLogEvent::ShutdownRequested +impl rhi::RhiLogEvent +pub const fn rhi::RhiLogEvent::as_str(self) -> &'static str +pub enum rhi::RhiLogLevel +pub rhi::RhiLogLevel::Debug +pub rhi::RhiLogLevel::Error +pub rhi::RhiLogLevel::Info +pub rhi::RhiLogLevel::Trace +pub rhi::RhiLogLevel::Warn +impl rhi::RhiLogLevel +pub const fn rhi::RhiLogLevel::as_str(self) -> &'static str pub enum rhi::RhiMetricsCommandV1 pub rhi::RhiMetricsCommandV1::Snapshot pub enum rhi::RhiOperationsErrorKind @@ -299,8 +328,8 @@ impl rhi::RhiPresenceAttemptOutcome pub const fn rhi::RhiPresenceAttemptOutcome::code(self) -> &'static str pub enum rhi::RhiPresenceCommandV1 pub rhi::RhiPresenceCommandV1::Desired -pub rhi::RhiPresenceCommandV1::Refresh -pub rhi::RhiPresenceCommandV1::Render +pub rhi::RhiPresenceCommandV1::Refresh(rhi::RhiPresenceMutationArgsV1) +pub rhi::RhiPresenceCommandV1::Render(rhi::RhiPresenceMutationArgsV1) pub enum rhi::RhiPresenceDesiredErrorKind pub rhi::RhiPresenceDesiredErrorKind::Binding pub rhi::RhiPresenceDesiredErrorKind::CommitOutcomeUnknown @@ -368,6 +397,13 @@ impl rhi::RhiProcessResult pub const fn rhi::RhiProcessResult::code(self) -> &'static str pub fn rhi::RhiProcessResult::exit_code(self) -> std::process::ExitCode pub const fn rhi::RhiProcessResult::exit_code_u8(self) -> u8 +pub enum rhi::RhiProcessSignal +pub rhi::RhiProcessSignal::Interrupt +pub rhi::RhiProcessSignal::Terminate +impl rhi::RhiProcessSignal +pub const fn rhi::RhiProcessSignal::as_str(self) -> &'static str +impl core::fmt::Display for rhi::RhiProcessSignal +pub fn rhi::RhiProcessSignal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub enum rhi::RhiProviderHealthV1 pub rhi::RhiProviderHealthV1::Ready pub rhi::RhiProviderHealthV1::Unavailable @@ -389,9 +425,9 @@ impl rhi::RhiPublicationAttemptOutcome pub const fn rhi::RhiPublicationAttemptOutcome::code(self) -> &'static str pub const fn rhi::RhiPublicationAttemptOutcome::target_state(self) -> rhi::RhiPublicationTargetState pub enum rhi::RhiPublicationCommandV1 -pub rhi::RhiPublicationCommandV1::Backlog -pub rhi::RhiPublicationCommandV1::Retry -pub rhi::RhiPublicationCommandV1::Targets +pub rhi::RhiPublicationCommandV1::Backlog(rhi::RhiPageQueryArgsV1) +pub rhi::RhiPublicationCommandV1::Retry(rhi::RhiPublicationRetryArgsV1) +pub rhi::RhiPublicationCommandV1::Targets(rhi::RhiPageQueryArgsV1) pub enum rhi::RhiPublicationErrorKind pub rhi::RhiPublicationErrorKind::InvalidConfiguration pub rhi::RhiPublicationErrorKind::TargetInventory @@ -459,8 +495,8 @@ pub rhi::RhiReconciliationAttestationErrorKind::VerificationFailed impl rhi::RhiReconciliationAttestationErrorKind pub const fn rhi::RhiReconciliationAttestationErrorKind::code(self) -> &'static str pub enum rhi::RhiReconciliationCommandV1 -pub rhi::RhiReconciliationCommandV1::Jobs -pub rhi::RhiReconciliationCommandV1::Refresh +pub rhi::RhiReconciliationCommandV1::Jobs(rhi::RhiPageQueryArgsV1) +pub rhi::RhiReconciliationCommandV1::Refresh(rhi::RhiReconciliationRefreshArgsV1) pub rhi::RhiReconciliationCommandV1::Status pub enum rhi::RhiReconciliationCommitErrorKind pub rhi::RhiReconciliationCommitErrorKind::CommitOutcomeUnknown @@ -605,7 +641,7 @@ pub rhi::RhiServicePhase::Starting pub rhi::RhiServicePhase::Stopping pub rhi::RhiServicePhase::Unready pub enum rhi::RhiSourcesCommandV1 -pub rhi::RhiSourcesCommandV1::List +pub rhi::RhiSourcesCommandV1::List(rhi::RhiPageQueryArgsV1) pub enum rhi::RhiStateCatalogErrorKind pub rhi::RhiStateCatalogErrorKind::CatalogMismatch pub rhi::RhiStateCatalogErrorKind::MigrationCatalog @@ -613,10 +649,10 @@ pub rhi::RhiStateCatalogErrorKind::SchemaCatalog impl rhi::RhiStateCatalogErrorKind pub const fn rhi::RhiStateCatalogErrorKind::code(self) -> &'static str pub enum rhi::RhiStateCommandV1 -pub rhi::RhiStateCommandV1::Backup +pub rhi::RhiStateCommandV1::Backup(rhi::RhiStateBackupArgsV1) pub rhi::RhiStateCommandV1::Init pub rhi::RhiStateCommandV1::Migrate -pub rhi::RhiStateCommandV1::Restore +pub rhi::RhiStateCommandV1::Restore(rhi::RhiStateRestoreArgsV1) pub rhi::RhiStateCommandV1::Status pub rhi::RhiStateCommandV1::Verify pub enum rhi::RhiStateHostErrorKind @@ -728,9 +764,9 @@ impl rhi::RhiStatusReasonCode pub const fn rhi::RhiStatusReasonCode::as_str(self) -> &'static str pub fn rhi::RhiStatusReasonCode::new(impl core::convert::AsRef<str>) -> core::result::Result<Self, rhi::RhiStatusError> pub enum rhi::RhiTradeCommandV1 -pub rhi::RhiTradeCommandV1::Projection -pub rhi::RhiTradeCommandV1::ReportCurrent -pub rhi::RhiTradeCommandV1::Reports +pub rhi::RhiTradeCommandV1::Projection(rhi::RhiTradeArgsV1) +pub rhi::RhiTradeCommandV1::ReportCurrent(rhi::RhiTradeArgsV1) +pub rhi::RhiTradeCommandV1::Reports(rhi::RhiTradePageArgsV1) pub enum rhi::RhiTradeEvidencePersistenceErrorKind pub rhi::RhiTradeEvidencePersistenceErrorKind::CommitOutcomeUnknown pub rhi::RhiTradeEvidencePersistenceErrorKind::Encoding @@ -915,7 +951,7 @@ impl core::fmt::Debug for rhi::RhiCliExecutionPlanV1 pub fn rhi::RhiCliExecutionPlanV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiCliInvocationV1 impl rhi::RhiCliInvocationV1 -pub const fn rhi::RhiCliInvocationV1::command(&self) -> rhi::RhiCommandV1 +pub const fn rhi::RhiCliInvocationV1::command(&self) -> &rhi::RhiCommandV1 pub fn rhi::RhiCliInvocationV1::config_path(&self) -> core::option::Option<&std::path::Path> pub const fn rhi::RhiCliInvocationV1::instance(&self) -> &radroots_runtime_paths::identifier::InstanceId pub const fn rhi::RhiCliInvocationV1::output_mode(&self) -> rhi::RhiCliOutputModeV1 @@ -939,6 +975,11 @@ pub const fn rhi::RhiCommittedPublication::exact_signed_event_bytes(&self) -> &[ pub const fn rhi::RhiCommittedPublication::outbox_id(&self) -> rhi::RhiPublicationOutboxId impl core::fmt::Debug for rhi::RhiCommittedPublication pub fn rhi::RhiCommittedPublication::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiConfigApplyArgsV1 +impl rhi::RhiConfigApplyArgsV1 +pub fn rhi::RhiConfigApplyArgsV1::candidate_config(&self) -> &std::path::Path +impl core::fmt::Debug for rhi::RhiConfigApplyArgsV1 +pub fn rhi::RhiConfigApplyArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiConfigApplyError impl rhi::RhiConfigApplyError pub const fn rhi::RhiConfigApplyError::code(self) -> &'static str @@ -965,6 +1006,14 @@ pub const fn rhi::RhiConfigDocumentV1::schema(&self) -> &'static str pub const fn rhi::RhiConfigDocumentV1::schema_version(&self) -> u32 impl core::fmt::Debug for rhi::RhiConfigDocumentV1 pub fn rhi::RhiConfigDocumentV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiConfigLoadError +impl rhi::RhiConfigLoadError +pub const fn rhi::RhiConfigLoadError::kind(self) -> rhi::RhiConfigLoadErrorKind +impl core::error::Error for rhi::RhiConfigLoadError +impl core::fmt::Debug for rhi::RhiConfigLoadError +pub fn rhi::RhiConfigLoadError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiConfigLoadError +pub fn rhi::RhiConfigLoadError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiConfigV1Error impl rhi::RhiConfigV1Error pub const fn rhi::RhiConfigV1Error::kind(self) -> rhi::RhiConfigV1ErrorKind @@ -1111,6 +1160,21 @@ pub struct rhi::RhiJitterMilliseconds(_) impl rhi::RhiJitterMilliseconds pub const fn rhi::RhiJitterMilliseconds::duration(self) -> core::time::Duration pub const fn rhi::RhiJitterMilliseconds::get(self) -> u64 +pub struct rhi::RhiLogRecord +impl rhi::RhiLogRecord +pub const fn rhi::RhiLogRecord::code(&self) -> &'static str +pub const fn rhi::RhiLogRecord::critical_task_failed() -> Self +pub const fn rhi::RhiLogRecord::event(&self) -> rhi::RhiLogEvent +pub const fn rhi::RhiLogRecord::level(&self) -> rhi::RhiLogLevel +pub const fn rhi::RhiLogRecord::lifecycle(rhi::RhiServicePhase) -> Self +pub const fn rhi::RhiLogRecord::process_exit(&self) -> core::option::Option<rhi::RhiProcessResult> +pub const fn rhi::RhiLogRecord::process_result(rhi::RhiProcessResult) -> Self +pub const fn rhi::RhiLogRecord::shutdown_forced() -> Self +pub const fn rhi::RhiLogRecord::shutdown_requested() -> Self +impl core::fmt::Debug for rhi::RhiLogRecord +pub fn rhi::RhiLogRecord::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiLogRecord +pub fn rhi::RhiLogRecord::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiMutationRepository<'host> impl rhi::RhiMutationRepository<'_> pub const fn rhi::RhiMutationRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor @@ -1144,6 +1208,12 @@ pub async fn rhi::RhiOperationsServer::bind(self) -> core::result::Result<rhi::R pub fn rhi::RhiOperationsServer::new(&rhi::RhiConfigDocumentV1, &rhi::RhiStatusReader) -> core::result::Result<Self, rhi::RhiOperationsError> impl core::fmt::Debug for rhi::RhiOperationsServer pub fn rhi::RhiOperationsServer::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPageQueryArgsV1 +impl rhi::RhiPageQueryArgsV1 +pub fn rhi::RhiPageQueryArgsV1::cursor(&self) -> core::option::Option<&str> +pub const fn rhi::RhiPageQueryArgsV1::limit(&self) -> u16 +impl core::fmt::Debug for rhi::RhiPageQueryArgsV1 +pub fn rhi::RhiPageQueryArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPersistenceStatusV1 impl rhi::RhiPersistenceStatusV1 pub fn rhi::RhiPersistenceStatusV1::new(rhi::RhiPersistenceHealthV1, u32, u64, rhi::RhiIntegrityStateV1, rhi::RhiStatusReasonCodes) -> core::result::Result<Self, rhi::RhiStatusError> @@ -1240,6 +1310,12 @@ impl rhi::RhiPresenceLeaseOwner pub fn rhi::RhiPresenceLeaseOwner::from_bytes([u8; 16]) -> core::result::Result<Self, rhi::RhiPresencePublicationError> impl core::fmt::Debug for rhi::RhiPresenceLeaseOwner pub fn rhi::RhiPresenceLeaseOwner::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPresenceMutationArgsV1 +impl rhi::RhiPresenceMutationArgsV1 +pub const fn rhi::RhiPresenceMutationArgsV1::expected_generation(&self) -> u64 +pub fn rhi::RhiPresenceMutationArgsV1::operation_id(&self) -> &str +impl core::fmt::Debug for rhi::RhiPresenceMutationArgsV1 +pub fn rhi::RhiPresenceMutationArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPresenceOutboxId(_) impl rhi::RhiPresenceOutboxId pub const fn rhi::RhiPresenceOutboxId::as_bytes(&self) -> &[u8; 32] @@ -1416,6 +1492,13 @@ impl rhi::RhiPublicationOutboxRepository<'_> pub async fn rhi::RhiPublicationOutboxRepository<'_>::read_committed_publication(&self, rhi::RhiPublicationOutboxId) -> core::result::Result<rhi::RhiCommittedPublication, rhi::RhiPublicationSubmissionError> impl core::fmt::Debug for rhi::RhiPublicationOutboxRepository<'_> pub fn rhi::RhiPublicationOutboxRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationRetryArgsV1 +impl rhi::RhiPublicationRetryArgsV1 +pub const fn rhi::RhiPublicationRetryArgsV1::expected_generation(&self) -> u64 +pub fn rhi::RhiPublicationRetryArgsV1::operation_id(&self) -> &str +pub fn rhi::RhiPublicationRetryArgsV1::workflow_id(&self) -> &str +impl core::fmt::Debug for rhi::RhiPublicationRetryArgsV1 +pub fn rhi::RhiPublicationRetryArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPublicationRetryDelayMilliseconds(_) impl rhi::RhiPublicationRetryDelayMilliseconds pub const fn rhi::RhiPublicationRetryDelayMilliseconds::get(self) -> u64 @@ -1671,6 +1754,13 @@ impl core::fmt::Debug for rhi::RhiReconciliationReducerError pub fn rhi::RhiReconciliationReducerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for rhi::RhiReconciliationReducerError pub fn rhi::RhiReconciliationReducerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationRefreshArgsV1 +impl rhi::RhiReconciliationRefreshArgsV1 +pub const fn rhi::RhiReconciliationRefreshArgsV1::expected_dirty_generation(&self) -> u64 +pub fn rhi::RhiReconciliationRefreshArgsV1::operation_id(&self) -> &str +pub fn rhi::RhiReconciliationRefreshArgsV1::trade_id(&self) -> &str +impl core::fmt::Debug for rhi::RhiReconciliationRefreshArgsV1 +pub fn rhi::RhiReconciliationRefreshArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiReconciliationReplayError impl rhi::RhiReconciliationReplayError pub const fn rhi::RhiReconciliationReplayError::code(self) -> &'static str @@ -1820,7 +1910,7 @@ impl core::fmt::Display for rhi::RhiRuntimeContextError pub fn rhi::RhiRuntimeContextError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiRuntimeFoundation impl rhi::RhiRuntimeFoundation -pub const fn rhi::RhiRuntimeFoundation::configuration(&self) -> &rhi::RhiConfigDocumentV1 +pub fn rhi::RhiRuntimeFoundation::configuration(&self) -> &rhi::RhiConfigDocumentV1 pub const fn rhi::RhiRuntimeFoundation::metadata(&self) -> &rhi::RhiStateMetadata pub const fn rhi::RhiRuntimeFoundation::readiness(&self) -> &rhi::RhiRuntimeReadiness pub const fn rhi::RhiRuntimeFoundation::runtime_context(&self) -> &rhi::RhiRuntimeContext @@ -1911,6 +2001,13 @@ pub fn rhi::RhiSourceRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) - pub struct rhi::RhiStagedStateRestore impl core::fmt::Debug for rhi::RhiStagedStateRestore pub fn rhi::RhiStagedStateRestore::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiStateBackupArgsV1 +impl rhi::RhiStateBackupArgsV1 +pub const fn rhi::RhiStateBackupArgsV1::expected_generation(&self) -> u64 +pub fn rhi::RhiStateBackupArgsV1::operation_id(&self) -> &str +pub fn rhi::RhiStateBackupArgsV1::target(&self) -> &std::path::Path +impl core::fmt::Debug for rhi::RhiStateBackupArgsV1 +pub fn rhi::RhiStateBackupArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiStateCatalogError impl rhi::RhiStateCatalogError pub const fn rhi::RhiStateCatalogError::code(self) -> &'static str @@ -2010,6 +2107,14 @@ pub const fn rhi::RhiStateRepositoryDescriptor::kind(self) -> rhi::RhiStateRepos pub const fn rhi::RhiStateRepositoryDescriptor::write_class(self) -> rhi::RhiStateRepositoryWriteClass impl core::fmt::Debug for rhi::RhiStateRepositoryDescriptor pub fn rhi::RhiStateRepositoryDescriptor::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiStateRestoreArgsV1 +impl rhi::RhiStateRestoreArgsV1 +pub fn rhi::RhiStateRestoreArgsV1::bundle(&self) -> &std::path::Path +pub fn rhi::RhiStateRestoreArgsV1::manifest(&self) -> &std::path::Path +pub fn rhi::RhiStateRestoreArgsV1::manifest_sha256(&self) -> &str +pub const fn rhi::RhiStateRestoreArgsV1::maximum_state_bytes(&self) -> u64 +impl core::fmt::Debug for rhi::RhiStateRestoreArgsV1 +pub fn rhi::RhiStateRestoreArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiStatusBuildInfoV1 impl rhi::RhiStatusBuildInfoV1 pub fn rhi::RhiStatusBuildInfoV1::new(rhi::RhiStatusBuildMode, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>) -> core::result::Result<Self, rhi::RhiStatusError> @@ -2083,8 +2188,15 @@ pub fn rhi::RhiTimeEntropyAdapters::now_monotonic(&self) -> radroots_service_hos pub fn rhi::RhiTimeEntropyAdapters::now_utc(&self) -> core::result::Result<radroots_service_host::time::UnixTimeSeconds, rhi::RhiRuntimeAdapterError> pub fn rhi::RhiTimeEntropyAdapters::sample_full_jitter(&self, rhi::RhiJitterBoundMilliseconds) -> core::result::Result<rhi::RhiJitterMilliseconds, rhi::RhiRuntimeAdapterError> pub fn rhi::RhiTimeEntropyAdapters::system() -> Self +impl core::clone::Clone for rhi::RhiTimeEntropyAdapters +pub fn rhi::RhiTimeEntropyAdapters::clone(&self) -> Self impl core::fmt::Debug for rhi::RhiTimeEntropyAdapters pub fn rhi::RhiTimeEntropyAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiTradeArgsV1 +impl rhi::RhiTradeArgsV1 +pub fn rhi::RhiTradeArgsV1::trade_id(&self) -> &str +impl core::fmt::Debug for rhi::RhiTradeArgsV1 +pub fn rhi::RhiTradeArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiTradeDirtyGeneration(_) impl rhi::RhiTradeDirtyGeneration pub const fn rhi::RhiTradeDirtyGeneration::get(self) -> u64 @@ -2131,6 +2243,12 @@ pub struct rhi::RhiTradeMutationObservedAtUnixSeconds(_) impl rhi::RhiTradeMutationObservedAtUnixSeconds pub const fn rhi::RhiTradeMutationObservedAtUnixSeconds::get(self) -> u64 pub fn rhi::RhiTradeMutationObservedAtUnixSeconds::new(u64) -> core::result::Result<Self, rhi::RhiTradeMutationAdmissionError> +pub struct rhi::RhiTradePageArgsV1 +impl rhi::RhiTradePageArgsV1 +pub const fn rhi::RhiTradePageArgsV1::page(&self) -> &rhi::RhiPageQueryArgsV1 +pub fn rhi::RhiTradePageArgsV1::trade_id(&self) -> &str +impl core::fmt::Debug for rhi::RhiTradePageArgsV1 +pub fn rhi::RhiTradePageArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiTradeSourceAttempt impl rhi::RhiTradeSourceAttempt pub fn rhi::RhiTradeSourceAttempt::new(impl core::convert::AsRef<str>, radroots_service_host::time::UnixTimeSeconds, rhi::RhiTradeMutationObservedAtUnixSeconds, rhi::RhiTradeMutationAuthoredTimePolicy) -> core::result::Result<Self, rhi::RhiTradeSourceIngestError> @@ -2175,6 +2293,8 @@ pub fn rhi::RhiTradeSourceObservation::fmt(&self, &mut core::fmt::Formatter<'_>) pub struct rhi::RhiTransportAdapters impl rhi::RhiTransportAdapters pub fn rhi::RhiTransportAdapters::new(alloc::sync::Arc<dyn radroots_transport::source::EventSource>, alloc::sync::Arc<dyn radroots_transport::source::EventSubscriber>, alloc::sync::Arc<dyn radroots_transport::sink::EventSink>) -> Self +impl core::clone::Clone for rhi::RhiTransportAdapters +pub fn rhi::RhiTransportAdapters::clone(&self) -> Self impl core::fmt::Debug for rhi::RhiTransportAdapters pub fn rhi::RhiTransportAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiVerifiedStateBackup @@ -2243,6 +2363,7 @@ pub const rhi::RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES: usize pub const rhi::RHI_CONFIG_SCHEMA: &str pub const rhi::RHI_CONFIG_SCHEMA_VERSION: u32 pub const rhi::RHI_DETAILED_STATUS_MAX_UTF8_BYTES: usize +pub const rhi::RHI_DIAGNOSTICS_CONTRACT_VERSION: u32 pub const rhi::RHI_DOCTOR_CHECK_COUNT: usize pub const rhi::RHI_DOCTOR_CONTRACT_VERSION: u32 pub const rhi::RHI_DOCTOR_REPORT_MAX_UTF8_BYTES: usize @@ -2251,6 +2372,7 @@ pub const rhi::RHI_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32 pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize pub const rhi::RHI_LIVEZ_PATH: &str +pub const rhi::RHI_LOG_RECORD_MAX_UTF8_BYTES: usize pub const rhi::RHI_METRICS_PATH: &str pub const rhi::RHI_MIGRATION_CATALOG_SHA256: [u8; 32] pub const rhi::RHI_OPERATIONS_CONTRACT_VERSION: u32 @@ -2295,6 +2417,9 @@ pub const rhi::RHI_STATE_SCHEMA_VERSION: u32 pub const rhi::RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 pub const rhi::RHI_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] +pub const rhi::RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32] +pub const rhi::RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32 +pub const rhi::RHI_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 pub const rhi::RHI_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] @@ -2352,6 +2477,8 @@ pub trait rhi::RhiIdentityAccess: core::marker::Send + core::marker::Sync pub fn rhi::RhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> impl rhi::RhiIdentityAccess for rhi::CanonicalRhiIdentityAccess pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> +pub trait rhi::RhiProcessSignalSource: core::marker::Send +pub fn rhi::RhiProcessSignalSource::next_signal(&mut self) -> rhi::RhiProcessSignalFuture<'_> pub fn rhi::admit_rhi_trade_mutation_event(rhi::RhiTradeMutationAdmissionLimits, &[u8], rhi::RhiTradeMutationObservedAtUnixSeconds, rhi::RhiTradeMutationAuthoredTimePolicy) -> core::result::Result<rhi::RhiAdmittedTradeMutationEvent, rhi::RhiTradeMutationAdmissionError> pub async fn rhi::apply_rhi_configuration(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiConfigApplyOutcome, rhi::RhiConfigApplyError> pub fn rhi::attest_projection_claim(&radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1, &radroots_event::id::MutationId, &rhi::TradeAgreementAttestationPolicy) -> core::result::Result<rhi::TradeAgreementAttestationReportV1, rhi::TradeAgreementAttestationError> @@ -2359,12 +2486,18 @@ pub fn rhi::build_rhi_admin_router<H>(alloc::sync::Arc<H>) -> core::result::Resu pub fn rhi::build_rhi_signed_evidence_attestation(rhi::RhiReconciliationFinalizationFence, &rhi::RhiDecryptedIdentity, radroots_service_host::time::UnixTimeSeconds, &dyn radroots_service_host::entropy::EntropySource, core::option::Option<rhi::RhiEvidenceAttestationSupersession>) -> core::result::Result<rhi::RhiSignedEvidenceAttestation, rhi::RhiReconciliationAttestationError> pub fn rhi::build_rhi_signed_presence_documents(rhi::RhiPresenceDesiredCommitOutcome, &rhi::RhiPresenceDesiredAuthority, &rhi::RhiDecryptedIdentity, radroots_service_host::time::UnixTimeSeconds, &dyn radroots_service_host::entropy::EntropySource) -> core::result::Result<rhi::RhiSignedPresenceDocuments, rhi::RhiPresencePublicationError> pub fn rhi::evaluate_rhi_reconciliation_claim(rhi::RhiReconciliationProjection, radroots_event::id::MutationId) -> rhi::RhiReconciliationEvaluation +pub fn rhi::execute_rhi_cli_v1(rhi::RhiCliInvocationV1) -> rhi::RhiProcessResult +pub fn rhi::execute_rhi_cli_v1_with_signal_source<F, S>(rhi::RhiCliInvocationV1, F) -> rhi::RhiProcessResult where F: core::ops::function::FnOnce() -> core::option::Option<S>, S: rhi::RhiProcessSignalSource + 'static pub async fn rhi::finalize_rhi_state_restore(rhi::RhiStagedStateRestore) -> core::result::Result<(), rhi::RhiStateMaintenanceError> pub async fn rhi::ingest_rhi_trade_source(&rhi::RhiStateRepositories<'_>, &rhi::RhiTransportAdapters, &rhi::RhiConfigDocumentV1, &str, radroots_event::id::TradeId, rhi::RhiTradeSourceAttempt) -> core::result::Result<rhi::RhiTradeSourceIngestOutcome, rhi::RhiTradeSourceIngestError> +pub fn rhi::initialize_rhi_config_document(&rhi::RhiRuntimeContext, &[u8]) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigLoadError> pub async fn rhi::initialize_rhi_state(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), rhi::RhiStateHostError> +pub fn rhi::load_rhi_config_candidate(&rhi::RhiRuntimeContext, &std::path::Path) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigLoadError> +pub fn rhi::load_rhi_config_document(&rhi::RhiRuntimeContext) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigLoadError> pub fn rhi::open_rhi_encrypted_identity(&rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> pub async fn rhi::open_rhi_runtime_foundation(rhi::RhiRuntimeContext, rhi::RhiConfigDocumentV1, rhi::RhiRuntimeAdapters, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiRuntimeFoundation, rhi::RhiRuntimeFoundationError> pub async fn rhi::open_rhi_state_inspection(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError> +pub async fn rhi::open_rhi_state_inspection_from_config(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError> pub async fn rhi::open_rhi_state_read_write(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError> pub async fn rhi::open_rhi_state_read_write_from_config(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError> pub fn rhi::parse_rhi_cli_v1_from<I, T>(I) -> core::result::Result<rhi::RhiCliInvocationV1, rhi::RhiCliV1Error> where I: core::iter::traits::collect::IntoIterator<Item = T>, T: core::convert::Into<std::ffi::os_str::OsString> + core::clone::Clone @@ -2388,5 +2521,6 @@ pub fn rhi::validate_rhi_state_catalogs(&radroots_service_sqlite::migration::Mig pub fn rhi::verify_rhi_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &rhi::RhiStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<rhi::RhiVerifiedStateBackup, rhi::RhiStateMaintenanceError> pub type rhi::RhiAdminFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::result::Result<rhi::RhiAdminResponseDocument, rhi::RhiAdminHandlerError>> + core::marker::Send + 'a)>> pub type rhi::RhiDoctorFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = rhi::RhiDoctorObservation> + core::marker::Send + 'a)>> +pub type rhi::RhiProcessSignalFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::option::Option<rhi::RhiProcessSignal>> + core::marker::Send + 'a)>> pub type rhi::RhiReconciliationCoverage = radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1 pub type rhi::RhiReconciliationOutcome = radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1 diff --git a/contracts/services_hardening/operator_contract.v1.json b/contracts/services_hardening/operator_contract.v1.json @@ -90,9 +90,9 @@ "persistence_state": { "kind": "closed_object", "fields": { "health": "persistence_health", "schema_version": "positive_u32", "generation": "u64", "integrity": "integrity_state", "reason_codes": "reason_codes" } }, "provider_health": { "kind": "enum", "values": ["ready", "degraded", "unavailable"] }, "identity_health": { "kind": "closed_object", "fields": { "configured": "bool", "available": "bool", "reason_codes": "reason_codes" } }, - "provider_state": { "kind": "closed_object", "fields": { "health": "provider_health", "service": "identity_health", "reason_codes": "reason_codes" } }, + "provider_state": { "kind": "closed_object", "fields": { "health": "provider_health", "identity": "identity_health", "reason_codes": "reason_codes" } }, "transport_health": { "kind": "enum", "values": ["ready", "degraded", "unavailable"] }, - "transport_state": { "kind": "closed_object", "fields": { "health": "transport_health", "required_sources_ready": "bool", "required_publication_targets_ready": "bool", "connected_relay_count": "u64", "reason_codes": "reason_codes" } }, + "transport_state": { "kind": "closed_object", "fields": { "health": "transport_health", "required_sources_ready": "bool", "subscriber_active": "bool", "configured_source_count": "u64", "reachable_source_count": "u64", "reason_codes": "reason_codes" } }, "coverage": { "kind": "enum", "values": ["Missing", "Partial", "ScopeSatisfied", "Unsupported"] }, "outcome": { "kind": "enum", "values": ["Valid", "Invalid", "Indeterminate"] }, "job_state": { "kind": "enum", "values": ["pending", "leased", "retry_scheduled", "completed", "failed"] }, @@ -109,7 +109,10 @@ "redacted_config": { "kind": "canonical_json_object", "schema": "radroots.rhi.config@1", "maximum_utf8_bytes": 786432, "protected_material": "redacted_or_omitted" }, "build_info": { "kind": "closed_object", "fields": { "version": "bounded_id", "revision": "git_revision", "toolchain": "bounded_id", "contract_versions": "safe_counts" } }, "git_revision": { "kind": "string", "utf8_bytes": 40, "pattern": "^[0-9a-f]{40}$" }, - "rhi_status": { "kind": "closed_object", "fields": { "policy_digest": "sha256_hex", "dirty_trade_count": "u64", "job_counts": "safe_counts", "coverage_counts": "safe_counts", "outcome_counts": "safe_counts", "publication_counts": "safe_counts", "presence": "presence_state" } }, + "reconciliation_status": { "kind": "closed_object", "fields": { "pending": "u64", "leased": "u64", "exhausted": "u64", "oldest_pending_at_utc": "optional_utc_seconds" } }, + "publication_status": { "kind": "closed_object", "fields": { "pending": "u64", "unknown": "u64", "oldest_pending_at_utc": "optional_utc_seconds" } }, + "presence_status": { "kind": "closed_object", "fields": { "pending": "u64", "unknown": "u64" } }, + "rhi_status": { "kind": "closed_object", "fields": { "identity": "identity_health", "reconciliation": "reconciliation_status", "publication": "publication_status", "presence": "presence_status" } }, "optional_utc_seconds": { "kind": "optional", "representation": "absent_parent_field", "value": "utc_seconds" }, "optional_event_id": { "kind": "optional", "representation": "absent_parent_field", "value": "event_id" }, "optional_report_id": { "kind": "optional", "representation": "absent_parent_field", "value": "report_id" }, diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml @@ -7,7 +7,7 @@ architecture = "radroots.crates.release.v2" workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" version = "0.1.0-alpha" source_archive_sha256 = "7e584a4b679264620d7bb6cf0a7028cc7651b33977b263c213f4e7b29c0e5a19" -cargo_lock_sha256 = "b85bee310965fc4c4da8f6641007840f0d736c194d3d0fb74ea3db757e7f1433" +cargo_lock_sha256 = "7a79bb19dc9275f65b86decff5136c5433b0858c5da5135625745194a074c021" rust_version = "1.97.1" host_feature_profile = "service-host" @@ -16,7 +16,7 @@ material = "absent" [contract_versions] config = 1 -state = 7 +state = 11 admin = 1 status = 1 provider = 1 diff --git a/src/admin_v1.rs b/src/admin_v1.rs @@ -1,19 +1,21 @@ //! Exact RHI v1 Unix-admin route and model boundary. -use core::{fmt, future::Future, pin::Pin, time::Duration}; -use std::{ - collections::BTreeSet, - error::Error, - sync::{Arc, OnceLock}, -}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use core::time::Duration; +use core::{fmt, future::Future, pin::Pin}; +use std::{collections::BTreeSet, error::Error, sync::OnceLock}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::sync::Arc; +use radroots_service_host::{AdminCorrelationId, AdminOperationId, CancellationToken}; +#[cfg(any(target_os = "linux", target_os = "macos"))] use radroots_service_host::{ - AdminCorrelationId, AdminError, AdminErrorCode, AdminErrorMessage, AdminHttpMethod, - AdminMutationRequest, AdminOperationId, AdminRequest, AdminRouteFailure, - AdminRouteFailureStatus, AdminRouteOutcome, AdminRouter as SharedAdminRouter, - AdminServer as SharedAdminServer, AdminServerError as SharedAdminServerError, - AdminTransportLimitValues, AdminTransportLimits, CancellationToken, UnixAdminSocketBinding, - UnixAdminSocketWriterAuthority, + AdminError, AdminErrorCode, AdminErrorMessage, AdminHttpMethod, AdminMutationRequest, + AdminRequest, AdminRouteFailure, AdminRouteFailureStatus, AdminRouteOutcome, + AdminRouter as SharedAdminRouter, AdminServer as SharedAdminServer, + AdminServerError as SharedAdminServerError, AdminTransportLimitValues, AdminTransportLimits, + UnixAdminSocketBinding, UnixAdminSocketWriterAuthority, }; use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; use serde_json::{Map, Value}; @@ -248,6 +250,7 @@ impl RhiAdminRoute { matches!(self.method(), RhiAdminMethod::Post) } + #[cfg(any(target_os = "linux", target_os = "macos"))] const fn host_method(self) -> AdminHttpMethod { match self.method() { RhiAdminMethod::Get => AdminHttpMethod::Get, @@ -255,6 +258,7 @@ impl RhiAdminRoute { } } + #[cfg(any(target_os = "linux", target_os = "macos"))] const fn parameter_binding(self) -> Option<(&'static str, &'static str)> { match self { Self::TradeProjection | Self::TradeReportCurrent | Self::TradeReports => { @@ -337,6 +341,7 @@ impl fmt::Debug for RhiAdminRequestDocument { pub struct RhiAdminResponseDocument { route: RhiAdminRoute, canonical_bytes: Box<[u8]>, + #[cfg(any(target_os = "linux", target_os = "macos"))] value: Value, } @@ -368,6 +373,7 @@ impl RhiAdminResponseDocument { Ok(Self { route, canonical_bytes: canonical.into_boxed_slice(), + #[cfg(any(target_os = "linux", target_os = "macos"))] value, }) } @@ -509,10 +515,12 @@ impl Error for RhiAdminRouterError {} /// /// let _ = RhiAdminRouter { inner: todo!() }; /// ``` +#[cfg(any(target_os = "linux", target_os = "macos"))] pub struct RhiAdminRouter { inner: SharedAdminRouter, } +#[cfg(any(target_os = "linux", target_os = "macos"))] impl fmt::Debug for RhiAdminRouter { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let Self { inner } = self; @@ -521,6 +529,7 @@ impl fmt::Debug for RhiAdminRouter { } } +#[cfg(any(target_os = "linux", target_os = "macos"))] impl RhiAdminRouter { fn into_inner(self) -> SharedAdminRouter { self.inner @@ -595,6 +604,7 @@ pub struct RhiAdminServerError { } impl RhiAdminServerError { + #[cfg(any(target_os = "linux", target_os = "macos"))] const fn new(kind: RhiAdminServerErrorKind) -> Self { Self { kind } } @@ -633,10 +643,12 @@ impl Error for RhiAdminServerError {} /// the exact route inventory around the supplied domain handler, and uses the /// shared host's system entropy. The raw shared router and server never cross /// this boundary. +#[cfg(any(target_os = "linux", target_os = "macos"))] pub struct RhiAdminServer { inner: SharedAdminServer, } +#[cfg(any(target_os = "linux", target_os = "macos"))] impl RhiAdminServer { pub fn new<H>( configuration: &crate::RhiConfigDocumentV1, @@ -673,6 +685,7 @@ impl RhiAdminServer { } } +#[cfg(any(target_os = "linux", target_os = "macos"))] impl fmt::Debug for RhiAdminServer { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("RhiAdminServer([sealed])") @@ -680,11 +693,13 @@ impl fmt::Debug for RhiAdminServer { } /// Bound final RHI Unix-admin server through Step 209. +#[cfg(any(target_os = "linux", target_os = "macos"))] pub struct RhiBoundAdminServer { inner: SharedAdminServer, binding: UnixAdminSocketBinding, } +#[cfg(any(target_os = "linux", target_os = "macos"))] impl RhiBoundAdminServer { /// Serves until supervisor cancellation and then drains bounded connection work. pub async fn serve( @@ -698,6 +713,7 @@ impl RhiBoundAdminServer { } } +#[cfg(any(target_os = "linux", target_os = "macos"))] impl fmt::Debug for RhiBoundAdminServer { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("RhiBoundAdminServer([sealed])") @@ -707,6 +723,7 @@ impl fmt::Debug for RhiBoundAdminServer { /// Registers the final seven common and thirteen domain routes through Step 209. /// /// Live identity rekey and replace are absent by final offline-only policy. +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn build_rhi_admin_router<H>(handler: Arc<H>) -> Result<RhiAdminRouter, RhiAdminRouterError> where H: RhiAdminHandler, @@ -727,6 +744,7 @@ where Ok(RhiAdminRouter { inner: router }) } +#[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) fn admin_transport_limits( configuration: &crate::RhiConfigDocumentV1, ) -> Result<AdminTransportLimits, RhiAdminServerError> { @@ -747,6 +765,18 @@ pub(crate) fn admin_transport_limits( AdminTransportLimits::new(values).map_err(|_| invalid_admin_configuration()) } +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn admit_admin_response_value( + route: RhiAdminRoute, + value: &Value, +) -> Result<Box<[u8]>, RhiAdminDocumentError> { + let bytes = serde_json::to_vec(value) + .map_err(|_| RhiAdminDocumentError::new(RhiAdminDocumentErrorKind::Malformed))?; + RhiAdminResponseDocument::from_canonical_bytes(route, &bytes) + .map(|document| document.canonical_bytes) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] fn admin_u64(value: &Value, pointer: &str) -> Result<u64, RhiAdminServerError> { value .pointer(pointer) @@ -754,14 +784,17 @@ fn admin_u64(value: &Value, pointer: &str) -> Result<u64, RhiAdminServerError> { .ok_or_else(invalid_admin_configuration) } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn admin_u32(value: &Value, pointer: &str) -> Result<u32, RhiAdminServerError> { u32::try_from(admin_u64(value, pointer)?).map_err(|_| invalid_admin_configuration()) } +#[cfg(any(target_os = "linux", target_os = "macos"))] const fn invalid_admin_configuration() -> RhiAdminServerError { RhiAdminServerError::new(RhiAdminServerErrorKind::InvalidConfiguration) } +#[cfg(any(target_os = "linux", target_os = "macos"))] const fn map_admin_server_error(error: SharedAdminServerError) -> RhiAdminServerError { let kind = match error { SharedAdminServerError::ListenerClone { .. } @@ -774,6 +807,7 @@ const fn map_admin_server_error(error: SharedAdminServerError) -> RhiAdminServer RhiAdminServerError::new(kind) } +#[cfg(any(target_os = "linux", target_os = "macos"))] async fn dispatch_route<H>( route: RhiAdminRoute, handler: Arc<H>, @@ -796,6 +830,7 @@ where } } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn failure(kind: RhiAdminHandlerErrorKind, invalid_request: bool) -> AdminRouteOutcome { let (status, code, message) = if invalid_request { ( @@ -845,6 +880,7 @@ fn failure(kind: RhiAdminHandlerErrorKind, invalid_request: bool) -> AdminRouteO )) } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn request_document( route: RhiAdminRoute, request: &AdminRequest, @@ -885,6 +921,7 @@ fn request_document( }) } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn query_model(route: RhiAdminRoute, query: Option<&str>) -> Result<Value, RhiAdminDocumentError> { let Some(query) = query else { return Ok(Value::Object(Map::new())); @@ -927,6 +964,7 @@ fn query_model(route: RhiAdminRoute, query: Option<&str>) -> Result<Value, RhiAd Ok(Value::Object(output)) } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn query_scalar(type_name: &str, value: String) -> Result<Value, RhiAdminDocumentError> { let descriptor = type_descriptor(type_name)?; match descriptor.get("kind").and_then(Value::as_str) { @@ -956,6 +994,7 @@ fn query_scalar(type_name: &str, value: String) -> Result<Value, RhiAdminDocumen } } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn percent_decode(value: &str) -> Result<String, RhiAdminDocumentError> { let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); @@ -981,6 +1020,7 @@ fn percent_decode(value: &str) -> Result<String, RhiAdminDocumentError> { String::from_utf8(decoded).map_err(|_| invalid_model_error()) } +#[cfg(any(target_os = "linux", target_os = "macos"))] const fn hex_nibble(byte: u8) -> Option<u8> { match byte { b'0'..=b'9' => Some(byte - b'0'), @@ -990,6 +1030,7 @@ const fn hex_nibble(byte: u8) -> Option<u8> { } } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn valid_percent_encoding(value: &str) -> bool { let bytes = value.as_bytes(); let mut index = 0; @@ -1017,6 +1058,7 @@ fn operator_contract() -> Result<&'static Value, RhiAdminDocumentError> { .ok_or_else(invalid_model_error) } +#[cfg(any(target_os = "linux", target_os = "macos"))] fn operator_route_inventory_is_exact() -> bool { let Ok(contract) = operator_contract() else { return false; @@ -1637,7 +1679,7 @@ impl<'de> Visitor<'de> for StrictValueVisitor { } } -#[cfg(test)] +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] mod tests { use super::*; diff --git a/src/cli_bootstrap.rs b/src/cli_bootstrap.rs @@ -0,0 +1,96 @@ +//! Fixed, zeroizing bootstrap documents consumed only from standard input. + +use std::io::Read; + +use zeroize::Zeroizing; + +use crate::RhiEncryptedIdentityProvisioningMaterial; + +pub(crate) const RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES: usize = 117; +const RHI_IDENTITY_PROVISIONING_MAGIC: &[u8; 4] = b"RHIP"; +const RHI_IDENTITY_PROVISIONING_VERSION: u8 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RhiBootstrapDocumentError { + Io, + InvalidLength, + InvalidHeader, + InvalidMaterial, +} + +pub(crate) fn read_identity_provisioning_document( + mut reader: impl Read, +) -> Result<RhiEncryptedIdentityProvisioningMaterial, RhiBootstrapDocumentError> { + let mut document = Zeroizing::new([0_u8; RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES + 1]); + let mut length = 0_usize; + while length < document.len() { + match reader.read(&mut document[length..]) { + Ok(0) => break, + Ok(read) => length = length.saturating_add(read), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return Err(RhiBootstrapDocumentError::Io), + } + } + if length != RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES { + return Err(RhiBootstrapDocumentError::InvalidLength); + } + if &document[..4] != RHI_IDENTITY_PROVISIONING_MAGIC + || document[4] != RHI_IDENTITY_PROVISIONING_VERSION + { + return Err(RhiBootstrapDocumentError::InvalidHeader); + } + let mut identity_secret = [0_u8; 32]; + let mut data_key = [0_u8; 32]; + let mut envelope_nonce = [0_u8; 24]; + let mut wrapping_nonce = [0_u8; 24]; + identity_secret.copy_from_slice(&document[5..37]); + data_key.copy_from_slice(&document[37..69]); + envelope_nonce.copy_from_slice(&document[69..93]); + wrapping_nonce.copy_from_slice(&document[93..117]); + RhiEncryptedIdentityProvisioningMaterial::new( + identity_secret, + data_key, + envelope_nonce, + wrapping_nonce, + ) + .map_err(|_| RhiBootstrapDocumentError::InvalidMaterial) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + fn document() -> [u8; RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES] { + let mut document = [0_u8; RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES]; + document[..4].copy_from_slice(RHI_IDENTITY_PROVISIONING_MAGIC); + document[4] = RHI_IDENTITY_PROVISIONING_VERSION; + document[5..37].copy_from_slice(&[1; 32]); + document[37..69].copy_from_slice(&[2; 32]); + document[69..93].copy_from_slice(&[3; 24]); + document[93..117].copy_from_slice(&[4; 24]); + document + } + + #[test] + fn exact_document_is_admitted_and_bounds_are_fail_closed() { + let valid = document(); + let material = read_identity_provisioning_document(Cursor::new(valid)) + .expect("exact provisioning document"); + assert_eq!( + format!("{material:?}"), + "RhiEncryptedIdentityProvisioningMaterial([redacted])" + ); + assert_eq!( + read_identity_provisioning_document(Cursor::new(&valid[..116])).unwrap_err(), + RhiBootstrapDocumentError::InvalidLength + ); + let mut trailing = valid.to_vec(); + trailing.push(0); + assert_eq!( + read_identity_provisioning_document(Cursor::new(trailing)).unwrap_err(), + RhiBootstrapDocumentError::InvalidLength + ); + } +} diff --git a/src/cli_v1.rs b/src/cli_v1.rs @@ -25,7 +25,7 @@ pub enum RhiCliOutputModeV1 { } /// The exact governed top-level RHI command inventory. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiCommandV1 { Run, Config(RhiConfigCommandV1), @@ -42,22 +42,22 @@ pub enum RhiCommandV1 { } /// Governed configuration commands. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiConfigCommandV1 { Init, Validate, Show, Schema, - Apply, + Apply(RhiConfigApplyArgsV1), } /// Governed state commands. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiStateCommandV1 { Init, Status, - Backup, - Restore, + Backup(RhiStateBackupArgsV1), + Restore(RhiStateRestoreArgsV1), Verify, Migrate, } @@ -77,41 +77,273 @@ pub enum RhiMetricsCommandV1 { } /// Governed reconciliation commands. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiReconciliationCommandV1 { Status, - Jobs, - Refresh, + Jobs(RhiPageQueryArgsV1), + Refresh(RhiReconciliationRefreshArgsV1), } /// Governed evidence-source commands. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiSourcesCommandV1 { - List, + List(RhiPageQueryArgsV1), } /// Governed trade-query commands. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiTradeCommandV1 { - Projection, - ReportCurrent, - Reports, + Projection(RhiTradeArgsV1), + ReportCurrent(RhiTradeArgsV1), + Reports(RhiTradePageArgsV1), } /// Governed publication commands. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiPublicationCommandV1 { - Backlog, - Targets, - Retry, + Backlog(RhiPageQueryArgsV1), + Targets(RhiPageQueryArgsV1), + Retry(RhiPublicationRetryArgsV1), } /// Governed desired-presence commands. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] pub enum RhiPresenceCommandV1 { Desired, - Render, - Refresh, + Render(RhiPresenceMutationArgsV1), + Refresh(RhiPresenceMutationArgsV1), +} + +/// Exact offline configuration-apply input. +#[derive(PartialEq, Eq)] +pub struct RhiConfigApplyArgsV1 { + candidate_config: PathBuf, +} + +impl RhiConfigApplyArgsV1 { + #[must_use] + pub fn candidate_config(&self) -> &Path { + &self.candidate_config + } +} + +/// Exact live state-backup input. +#[derive(PartialEq, Eq)] +pub struct RhiStateBackupArgsV1 { + operation_id: Box<str>, + target: PathBuf, + expected_generation: u64, +} + +impl RhiStateBackupArgsV1 { + #[must_use] + pub fn operation_id(&self) -> &str { + &self.operation_id + } + + #[must_use] + pub fn target(&self) -> &Path { + &self.target + } + + #[must_use] + pub const fn expected_generation(&self) -> u64 { + self.expected_generation + } +} + +/// Exact offline state-restore input. +#[derive(PartialEq, Eq)] +pub struct RhiStateRestoreArgsV1 { + manifest: PathBuf, + manifest_sha256: Box<str>, + bundle: PathBuf, + maximum_state_bytes: u64, +} + +impl RhiStateRestoreArgsV1 { + #[must_use] + pub fn manifest(&self) -> &Path { + &self.manifest + } + + #[must_use] + pub fn manifest_sha256(&self) -> &str { + &self.manifest_sha256 + } + + #[must_use] + pub fn bundle(&self) -> &Path { + &self.bundle + } + + #[must_use] + pub const fn maximum_state_bytes(&self) -> u64 { + self.maximum_state_bytes + } +} + +/// Bounded stable pagination input shared by list commands. +#[derive(PartialEq, Eq)] +pub struct RhiPageQueryArgsV1 { + limit: u16, + cursor: Option<Box<str>>, +} + +impl RhiPageQueryArgsV1 { + #[must_use] + pub const fn limit(&self) -> u16 { + self.limit + } + + #[must_use] + pub fn cursor(&self) -> Option<&str> { + self.cursor.as_deref() + } +} + +/// Exact trade selection for one live query. +#[derive(PartialEq, Eq)] +pub struct RhiTradeArgsV1 { + trade_id: Box<str>, +} + +impl RhiTradeArgsV1 { + #[must_use] + pub fn trade_id(&self) -> &str { + &self.trade_id + } +} + +/// Exact trade selection plus bounded report pagination. +#[derive(PartialEq, Eq)] +pub struct RhiTradePageArgsV1 { + trade_id: Box<str>, + page: RhiPageQueryArgsV1, +} + +impl RhiTradePageArgsV1 { + #[must_use] + pub fn trade_id(&self) -> &str { + &self.trade_id + } + + #[must_use] + pub const fn page(&self) -> &RhiPageQueryArgsV1 { + &self.page + } +} + +/// Exact refresh request and idempotency identity. +#[derive(PartialEq, Eq)] +pub struct RhiReconciliationRefreshArgsV1 { + operation_id: Box<str>, + trade_id: Box<str>, + expected_dirty_generation: u64, +} + +impl RhiReconciliationRefreshArgsV1 { + #[must_use] + pub fn operation_id(&self) -> &str { + &self.operation_id + } + + #[must_use] + pub fn trade_id(&self) -> &str { + &self.trade_id + } + + #[must_use] + pub const fn expected_dirty_generation(&self) -> u64 { + self.expected_dirty_generation + } +} + +/// Exact publication retry request and idempotency identity. +#[derive(PartialEq, Eq)] +pub struct RhiPublicationRetryArgsV1 { + operation_id: Box<str>, + workflow_id: Box<str>, + expected_generation: u64, +} + +impl RhiPublicationRetryArgsV1 { + #[must_use] + pub fn operation_id(&self) -> &str { + &self.operation_id + } + + #[must_use] + pub fn workflow_id(&self) -> &str { + &self.workflow_id + } + + #[must_use] + pub const fn expected_generation(&self) -> u64 { + self.expected_generation + } +} + +/// Exact presence mutation request and idempotency identity. +#[derive(PartialEq, Eq)] +pub struct RhiPresenceMutationArgsV1 { + operation_id: Box<str>, + expected_generation: u64, +} + +impl RhiPresenceMutationArgsV1 { + #[must_use] + pub fn operation_id(&self) -> &str { + &self.operation_id + } + + #[must_use] + pub const fn expected_generation(&self) -> u64 { + self.expected_generation + } +} + +macro_rules! redacted_debug { + ($($type:ty),+ $(,)?) => { + $( + impl fmt::Debug for $type { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(concat!(stringify!($type), "([redacted])")) + } + } + )+ + }; +} + +redacted_debug!( + RhiConfigApplyArgsV1, + RhiStateBackupArgsV1, + RhiStateRestoreArgsV1, + RhiPageQueryArgsV1, + RhiTradeArgsV1, + RhiTradePageArgsV1, + RhiReconciliationRefreshArgsV1, + RhiPublicationRetryArgsV1, + RhiPresenceMutationArgsV1, +); + +impl fmt::Debug for RhiCommandV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Run => "RhiCommandV1::Run", + Self::Config(_) => "RhiCommandV1::Config([redacted])", + Self::State(_) => "RhiCommandV1::State([redacted])", + Self::Identity(_) => "RhiCommandV1::Identity([redacted])", + Self::Status => "RhiCommandV1::Status", + Self::Metrics(_) => "RhiCommandV1::Metrics([redacted])", + Self::Reconciliation(_) => "RhiCommandV1::Reconciliation([redacted])", + Self::Sources(_) => "RhiCommandV1::Sources([redacted])", + Self::Trade(_) => "RhiCommandV1::Trade([redacted])", + Self::Publication(_) => "RhiCommandV1::Publication([redacted])", + Self::Presence(_) => "RhiCommandV1::Presence([redacted])", + Self::Doctor => "RhiCommandV1::Doctor", + }) + } } /// The only three process authorities selected by the hardened CLI. @@ -247,6 +479,7 @@ pub enum RhiCliV1ErrorKind { InvalidRepoLocalRoot, UnexpectedRepoLocalRoot, InvalidConfigPath, + InvalidCommandInput, } impl RhiCliV1ErrorKind { @@ -259,6 +492,7 @@ impl RhiCliV1ErrorKind { "repo-local root is forbidden outside the repo-local profile" } Self::InvalidConfigPath => "configuration path must be absolute without traversal", + Self::InvalidCommandInput => "command input is invalid", } } } @@ -341,8 +575,8 @@ impl RhiCliInvocationV1 { /// Returns the exact governed command selection. #[must_use] - pub const fn command(&self) -> RhiCommandV1 { - self.command + pub const fn command(&self) -> &RhiCommandV1 { + &self.command } } @@ -399,7 +633,7 @@ where repo_local_root: parsed.repo_local_root, config_path: parsed.config, output_mode: parsed.output.into(), - command: parsed.command.into(), + command: admit_command(parsed.command)?, }) } @@ -411,19 +645,19 @@ where /// authority. #[must_use] pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecutionPlanV1 { - match invocation.command { + match &invocation.command { RhiCommandV1::Run => daemon_plan(), RhiCommandV1::Config(RhiConfigCommandV1::Init) | RhiCommandV1::Config(RhiConfigCommandV1::Validate) | RhiCommandV1::Config(RhiConfigCommandV1::Schema) - | RhiCommandV1::Config(RhiConfigCommandV1::Apply) => { + | RhiCommandV1::Config(RhiConfigCommandV1::Apply(_)) => { offline_plan(RhiCliOfflineOperationV1::Config) } RhiCommandV1::Config(RhiConfigCommandV1::Show) => { admin_plan(RhiCliAdminOperationV1::EffectiveConfig) } RhiCommandV1::State(RhiStateCommandV1::Init) - | RhiCommandV1::State(RhiStateCommandV1::Restore) + | RhiCommandV1::State(RhiStateCommandV1::Restore(_)) | RhiCommandV1::State(RhiStateCommandV1::Verify) | RhiCommandV1::State(RhiStateCommandV1::Migrate) => { offline_plan(RhiCliOfflineOperationV1::StateExclusive) @@ -431,7 +665,7 @@ pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecution RhiCommandV1::State(RhiStateCommandV1::Status) => { admin_plan(RhiCliAdminOperationV1::StateStatus) } - RhiCommandV1::State(RhiStateCommandV1::Backup) => { + RhiCommandV1::State(RhiStateCommandV1::Backup(_)) => { admin_plan(RhiCliAdminOperationV1::StateBackup) } RhiCommandV1::Identity(RhiIdentityCommandV1::Init) => { @@ -450,40 +684,40 @@ pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecution RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status) => { admin_plan(RhiCliAdminOperationV1::ReconciliationStatus) } - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs) => { + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs(_)) => { admin_plan(RhiCliAdminOperationV1::ReconciliationJobs) } - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh) => { + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh(_)) => { admin_plan(RhiCliAdminOperationV1::ReconciliationRefresh) } - RhiCommandV1::Sources(RhiSourcesCommandV1::List) => { + RhiCommandV1::Sources(RhiSourcesCommandV1::List(_)) => { admin_plan(RhiCliAdminOperationV1::Sources) } - RhiCommandV1::Trade(RhiTradeCommandV1::Projection) => { + RhiCommandV1::Trade(RhiTradeCommandV1::Projection(_)) => { admin_plan(RhiCliAdminOperationV1::TradeProjection) } - RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent) => { + RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent(_)) => { admin_plan(RhiCliAdminOperationV1::TradeReportCurrent) } - RhiCommandV1::Trade(RhiTradeCommandV1::Reports) => { + RhiCommandV1::Trade(RhiTradeCommandV1::Reports(_)) => { admin_plan(RhiCliAdminOperationV1::TradeReports) } - RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog) => { + RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog(_)) => { admin_plan(RhiCliAdminOperationV1::PublicationBacklog) } - RhiCommandV1::Publication(RhiPublicationCommandV1::Targets) => { + RhiCommandV1::Publication(RhiPublicationCommandV1::Targets(_)) => { admin_plan(RhiCliAdminOperationV1::PublicationTargets) } - RhiCommandV1::Publication(RhiPublicationCommandV1::Retry) => { + RhiCommandV1::Publication(RhiPublicationCommandV1::Retry(_)) => { admin_plan(RhiCliAdminOperationV1::PublicationRetry) } RhiCommandV1::Presence(RhiPresenceCommandV1::Desired) => { admin_plan(RhiCliAdminOperationV1::PresenceDesired) } - RhiCommandV1::Presence(RhiPresenceCommandV1::Render) => { + RhiCommandV1::Presence(RhiPresenceCommandV1::Render(_)) => { admin_plan(RhiCliAdminOperationV1::PresenceRender) } - RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh) => { + RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh(_)) => { admin_plan(RhiCliAdminOperationV1::PresenceRefresh) } RhiCommandV1::Doctor => offline_plan(RhiCliOfflineOperationV1::Doctor), @@ -642,25 +876,6 @@ enum RawCommand { Doctor, } -impl From<RawCommand> for RhiCommandV1 { - fn from(value: RawCommand) -> Self { - match value { - RawCommand::Run => Self::Run, - RawCommand::Config { command } => Self::Config(command.into()), - RawCommand::State { command } => Self::State(command.into()), - RawCommand::Identity { command } => Self::Identity(command.into()), - RawCommand::Status => Self::Status, - RawCommand::Metrics { command } => Self::Metrics(command.into()), - RawCommand::Reconciliation { command } => Self::Reconciliation(command.into()), - RawCommand::Sources { command } => Self::Sources(command.into()), - RawCommand::Trade { command } => Self::Trade(command.into()), - RawCommand::Publication { command } => Self::Publication(command.into()), - RawCommand::Presence { command } => Self::Presence(command.into()), - RawCommand::Doctor => Self::Doctor, - } - } -} - macro_rules! command_enum { ($raw:ident, $public:ident, { $($variant:ident),+ $(,)? }) => { #[derive(Subcommand)] @@ -678,48 +893,312 @@ macro_rules! command_enum { }; } -command_enum!(RawConfigCommand, RhiConfigCommandV1, { +#[derive(Subcommand)] +enum RawConfigCommand { Init, Validate, Show, Schema, - Apply, -}); -command_enum!(RawStateCommand, RhiStateCommandV1, { + Apply { + #[arg(long = "candidate-config")] + candidate_config: PathBuf, + }, +} + +#[derive(Subcommand)] +enum RawStateCommand { Init, Status, - Backup, - Restore, + Backup { + #[arg(long = "operation-id")] + operation_id: String, + #[arg(long)] + target: PathBuf, + #[arg(long = "expected-generation")] + expected_generation: u64, + #[arg(long, required = true)] + confirm: bool, + }, + Restore { + #[arg(long)] + manifest: PathBuf, + #[arg(long = "manifest-sha256")] + manifest_sha256: String, + #[arg(long)] + bundle: PathBuf, + #[arg(long = "maximum-state-bytes")] + maximum_state_bytes: u64, + #[arg(long, required = true)] + confirm: bool, + }, Verify, Migrate, -}); +} + command_enum!(RawIdentityCommand, RhiIdentityCommandV1, { Init, Status, ExportPublic, }); command_enum!(RawMetricsCommand, RhiMetricsCommandV1, { Snapshot }); -command_enum!(RawReconciliationCommand, RhiReconciliationCommandV1, { + +#[derive(Subcommand)] +enum RawReconciliationCommand { Status, - Jobs, - Refresh, -}); -command_enum!(RawSourcesCommand, RhiSourcesCommandV1, { List }); -command_enum!(RawTradeCommand, RhiTradeCommandV1, { - Projection, - ReportCurrent, - Reports, -}); -command_enum!(RawPublicationCommand, RhiPublicationCommandV1, { - Backlog, - Targets, - Retry, -}); -command_enum!(RawPresenceCommand, RhiPresenceCommandV1, { + Jobs { + #[command(flatten)] + page: RawPageQuery, + }, + Refresh { + #[arg(long = "operation-id")] + operation_id: String, + #[arg(long = "trade-id")] + trade_id: String, + #[arg(long = "expected-dirty-generation")] + expected_dirty_generation: u64, + }, +} + +#[derive(Subcommand)] +enum RawSourcesCommand { + List { + #[command(flatten)] + page: RawPageQuery, + }, +} + +#[derive(Subcommand)] +enum RawTradeCommand { + Projection { + #[arg(long = "trade-id")] + trade_id: String, + }, + ReportCurrent { + #[arg(long = "trade-id")] + trade_id: String, + }, + Reports { + #[arg(long = "trade-id")] + trade_id: String, + #[command(flatten)] + page: RawPageQuery, + }, +} + +#[derive(Subcommand)] +enum RawPublicationCommand { + Backlog { + #[command(flatten)] + page: RawPageQuery, + }, + Targets { + #[command(flatten)] + page: RawPageQuery, + }, + Retry { + #[arg(long = "operation-id")] + operation_id: String, + #[arg(long = "workflow-id")] + workflow_id: String, + #[arg(long = "expected-generation")] + expected_generation: u64, + }, +} + +#[derive(Subcommand)] +enum RawPresenceCommand { Desired, - Render, - Refresh, -}); + Render { + #[arg(long = "operation-id")] + operation_id: String, + #[arg(long = "expected-generation")] + expected_generation: u64, + }, + Refresh { + #[arg(long = "operation-id")] + operation_id: String, + #[arg(long = "expected-generation")] + expected_generation: u64, + }, +} + +#[derive(clap::Args)] +struct RawPageQuery { + #[arg(long, default_value_t = 100)] + limit: u16, + #[arg(long)] + cursor: Option<String>, +} + +fn admit_command(command: RawCommand) -> Result<RhiCommandV1, RhiCliV1Error> { + let invalid = || RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidCommandInput); + let page = |value: RawPageQuery| { + if !(1..=200).contains(&value.limit) + || value.cursor.as_deref().is_some_and(|cursor| { + cursor.is_empty() + || cursor.len() > 512 + || cursor != cursor.trim() + || cursor.chars().any(char::is_control) + }) + { + return Err(invalid()); + } + Ok(RhiPageQueryArgsV1 { + limit: value.limit, + cursor: value.cursor.map(String::into_boxed_str), + }) + }; + let bounded_id = |value: String| { + if value.is_empty() + || value.len() > 128 + || value != value.trim() + || value.chars().any(char::is_control) + { + Err(invalid()) + } else { + Ok(value.into_boxed_str()) + } + }; + let trade_id = |value: String| match radroots_event::id::TradeId::parse(&value) { + Ok(parsed) if parsed.to_hex() == value => Ok(value.into_boxed_str()), + Ok(_) | Err(_) => Err(invalid()), + }; + Ok(match command { + RawCommand::Run => RhiCommandV1::Run, + RawCommand::Config { command } => RhiCommandV1::Config(match command { + RawConfigCommand::Init => RhiConfigCommandV1::Init, + RawConfigCommand::Validate => RhiConfigCommandV1::Validate, + RawConfigCommand::Show => RhiConfigCommandV1::Show, + RawConfigCommand::Schema => RhiConfigCommandV1::Schema, + RawConfigCommand::Apply { candidate_config } + if valid_absolute_path(&candidate_config, true) => + { + RhiConfigCommandV1::Apply(RhiConfigApplyArgsV1 { candidate_config }) + } + RawConfigCommand::Apply { .. } => return Err(invalid()), + }), + RawCommand::State { command } => RhiCommandV1::State(match command { + RawStateCommand::Init => RhiStateCommandV1::Init, + RawStateCommand::Status => RhiStateCommandV1::Status, + RawStateCommand::Backup { + operation_id, + target, + expected_generation, + confirm: true, + } if valid_absolute_path(&target, true) => { + RhiStateCommandV1::Backup(RhiStateBackupArgsV1 { + operation_id: bounded_id(operation_id)?, + target, + expected_generation, + }) + } + RawStateCommand::Backup { .. } => return Err(invalid()), + RawStateCommand::Restore { + manifest, + manifest_sha256, + bundle, + maximum_state_bytes, + confirm: true, + } if valid_absolute_path(&manifest, true) + && valid_absolute_path(&bundle, true) + && maximum_state_bytes != 0 + && is_lower_hex(&manifest_sha256, 64) => + { + RhiStateCommandV1::Restore(RhiStateRestoreArgsV1 { + manifest, + manifest_sha256: manifest_sha256.into_boxed_str(), + bundle, + maximum_state_bytes, + }) + } + RawStateCommand::Restore { .. } => return Err(invalid()), + RawStateCommand::Verify => RhiStateCommandV1::Verify, + RawStateCommand::Migrate => RhiStateCommandV1::Migrate, + }), + RawCommand::Identity { command } => RhiCommandV1::Identity(command.into()), + RawCommand::Status => RhiCommandV1::Status, + RawCommand::Metrics { command } => RhiCommandV1::Metrics(command.into()), + RawCommand::Reconciliation { command } => RhiCommandV1::Reconciliation(match command { + RawReconciliationCommand::Status => RhiReconciliationCommandV1::Status, + RawReconciliationCommand::Jobs { page: value } => { + RhiReconciliationCommandV1::Jobs(page(value)?) + } + RawReconciliationCommand::Refresh { + operation_id, + trade_id: selected_trade, + expected_dirty_generation, + } => RhiReconciliationCommandV1::Refresh(RhiReconciliationRefreshArgsV1 { + operation_id: bounded_id(operation_id)?, + trade_id: trade_id(selected_trade)?, + expected_dirty_generation, + }), + }), + RawCommand::Sources { command } => RhiCommandV1::Sources(match command { + RawSourcesCommand::List { page: value } => RhiSourcesCommandV1::List(page(value)?), + }), + RawCommand::Trade { command } => RhiCommandV1::Trade(match command { + RawTradeCommand::Projection { trade_id: value } => { + RhiTradeCommandV1::Projection(RhiTradeArgsV1 { + trade_id: trade_id(value)?, + }) + } + RawTradeCommand::ReportCurrent { trade_id: value } => { + RhiTradeCommandV1::ReportCurrent(RhiTradeArgsV1 { + trade_id: trade_id(value)?, + }) + } + RawTradeCommand::Reports { + trade_id: value, + page: selected_page, + } => RhiTradeCommandV1::Reports(RhiTradePageArgsV1 { + trade_id: trade_id(value)?, + page: page(selected_page)?, + }), + }), + RawCommand::Publication { command } => RhiCommandV1::Publication(match command { + RawPublicationCommand::Backlog { page: value } => { + RhiPublicationCommandV1::Backlog(page(value)?) + } + RawPublicationCommand::Targets { page: value } => { + RhiPublicationCommandV1::Targets(page(value)?) + } + RawPublicationCommand::Retry { + operation_id, + workflow_id, + expected_generation, + } => RhiPublicationCommandV1::Retry(RhiPublicationRetryArgsV1 { + operation_id: bounded_id(operation_id)?, + workflow_id: bounded_id(workflow_id)?, + expected_generation, + }), + }), + RawCommand::Presence { command } => RhiCommandV1::Presence(match command { + RawPresenceCommand::Desired => RhiPresenceCommandV1::Desired, + RawPresenceCommand::Render { + operation_id, + expected_generation, + } => RhiPresenceCommandV1::Render(RhiPresenceMutationArgsV1 { + operation_id: bounded_id(operation_id)?, + expected_generation, + }), + RawPresenceCommand::Refresh { + operation_id, + expected_generation, + } => RhiPresenceCommandV1::Refresh(RhiPresenceMutationArgsV1 { + operation_id: bounded_id(operation_id)?, + expected_generation, + }), + }), + RawCommand::Doctor => RhiCommandV1::Doctor, + }) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} #[cfg(test)] mod tests { @@ -733,125 +1212,101 @@ mod tests { #[test] fn exact_command_inventory_parses() { + let trade = "00000000000000000000000000000000"; let vectors = [ - (&["run"][..], RhiCommandV1::Run), - ( - &["config", "init"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Init), - ), - ( - &["config", "validate"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Validate), - ), - ( - &["config", "show"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Show), - ), - ( - &["config", "schema"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Schema), - ), - ( - &["config", "apply"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Apply), - ), - ( - &["state", "init"][..], - RhiCommandV1::State(RhiStateCommandV1::Init), - ), - ( - &["state", "status"][..], - RhiCommandV1::State(RhiStateCommandV1::Status), - ), - ( - &["state", "backup"][..], - RhiCommandV1::State(RhiStateCommandV1::Backup), - ), - ( - &["state", "restore"][..], - RhiCommandV1::State(RhiStateCommandV1::Restore), - ), - ( - &["state", "verify"][..], - RhiCommandV1::State(RhiStateCommandV1::Verify), - ), - ( - &["state", "migrate"][..], - RhiCommandV1::State(RhiStateCommandV1::Migrate), - ), - ( - &["identity", "init"][..], - RhiCommandV1::Identity(RhiIdentityCommandV1::Init), - ), - ( - &["identity", "status"][..], - RhiCommandV1::Identity(RhiIdentityCommandV1::Status), - ), - ( - &["identity", "export-public"][..], - RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic), - ), - (&["status"][..], RhiCommandV1::Status), - ( - &["metrics", "snapshot"][..], - RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot), - ), - ( - &["reconciliation", "status"][..], - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status), - ), - ( - &["reconciliation", "jobs"][..], - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs), - ), - ( - &["reconciliation", "refresh"][..], - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh), - ), - ( - &["sources", "list"][..], - RhiCommandV1::Sources(RhiSourcesCommandV1::List), - ), - ( - &["trade", "projection"][..], - RhiCommandV1::Trade(RhiTradeCommandV1::Projection), - ), - ( - &["trade", "report-current"][..], - RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent), - ), - ( - &["trade", "reports"][..], - RhiCommandV1::Trade(RhiTradeCommandV1::Reports), - ), - ( - &["publication", "backlog"][..], - RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog), - ), - ( - &["publication", "targets"][..], - RhiCommandV1::Publication(RhiPublicationCommandV1::Targets), - ), - ( - &["publication", "retry"][..], - RhiCommandV1::Publication(RhiPublicationCommandV1::Retry), - ), - ( - &["presence", "desired"][..], - RhiCommandV1::Presence(RhiPresenceCommandV1::Desired), - ), - ( - &["presence", "render"][..], - RhiCommandV1::Presence(RhiPresenceCommandV1::Render), - ), - ( - &["presence", "refresh"][..], - RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh), - ), - (&["doctor"][..], RhiCommandV1::Doctor), + vec!["run"], + vec!["config", "init"], + vec!["config", "validate"], + vec!["config", "show"], + vec!["config", "schema"], + vec![ + "config", + "apply", + "--candidate-config", + "/tmp/candidate.toml", + ], + vec!["state", "init"], + vec!["state", "status"], + vec![ + "state", + "backup", + "--operation-id", + "backup-1", + "--target", + "/tmp/backup", + "--expected-generation", + "1", + "--confirm", + ], + vec![ + "state", + "restore", + "--manifest", + "/tmp/manifest.json", + "--manifest-sha256", + "0000000000000000000000000000000000000000000000000000000000000000", + "--bundle", + "/tmp/backup", + "--maximum-state-bytes", + "1048576", + "--confirm", + ], + vec!["state", "verify"], + vec!["state", "migrate"], + vec!["identity", "init"], + vec!["identity", "status"], + vec!["identity", "export-public"], + vec!["status"], + vec!["metrics", "snapshot"], + vec!["reconciliation", "status"], + vec!["reconciliation", "jobs"], + vec![ + "reconciliation", + "refresh", + "--operation-id", + "refresh-1", + "--trade-id", + trade, + "--expected-dirty-generation", + "1", + ], + vec!["sources", "list"], + vec!["trade", "projection", "--trade-id", trade], + vec!["trade", "report-current", "--trade-id", trade], + vec!["trade", "reports", "--trade-id", trade], + vec!["publication", "backlog"], + vec!["publication", "targets"], + vec![ + "publication", + "retry", + "--operation-id", + "retry-1", + "--workflow-id", + "workflow-1", + "--expected-generation", + "1", + ], + vec!["presence", "desired"], + vec![ + "presence", + "render", + "--operation-id", + "render-1", + "--expected-generation", + "1", + ], + vec![ + "presence", + "refresh", + "--operation-id", + "presence-1", + "--expected-generation", + "1", + ], + vec!["doctor"], ]; - for (arguments, expected) in vectors { - assert_eq!(parse(arguments).expect("command").command(), expected); + for arguments in vectors { + parse(&arguments).expect("command"); } } diff --git a/src/config_loader.rs b/src/config_loader.rs @@ -0,0 +1,650 @@ +//! Secure descriptor-bound configuration loading and create-new initialization. + +use core::fmt; +use std::{error::Error, path::Path}; + +use crate::{ + RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RhiConfigDocumentV1, RhiConfigProfile, RhiConfigV1Error, + RhiRuntimeContext, parse_rhi_config_v1, +}; + +/// Stable source-free secure configuration I/O failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiConfigLoadErrorKind { + InvalidPath, + Missing, + AlreadyExists, + InsecureParent, + InsecureArtifact, + TooLarge, + Io, + InvalidDocument, + UnsupportedPlatform, +} + +/// One path- and content-free secure configuration failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiConfigLoadError { + kind: RhiConfigLoadErrorKind, +} + +impl RhiConfigLoadError { + const fn new(kind: RhiConfigLoadErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> RhiConfigLoadErrorKind { + self.kind + } +} + +impl fmt::Debug for RhiConfigLoadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiConfigLoadError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiConfigLoadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiConfigLoadErrorKind::InvalidPath => "configuration path is invalid", + RhiConfigLoadErrorKind::Missing => "configuration document is missing", + RhiConfigLoadErrorKind::AlreadyExists => "configuration document already exists", + RhiConfigLoadErrorKind::InsecureParent => "configuration parent is insecure", + RhiConfigLoadErrorKind::InsecureArtifact => "configuration artifact is insecure", + RhiConfigLoadErrorKind::TooLarge => "configuration document exceeds its size limit", + RhiConfigLoadErrorKind::Io => "configuration storage failed", + RhiConfigLoadErrorKind::InvalidDocument => "configuration document is invalid", + RhiConfigLoadErrorKind::UnsupportedPlatform => { + "secure configuration storage is unsupported" + } + }) + } +} + +impl Error for RhiConfigLoadError {} + +/// Loads one selected configuration through the governed descriptor boundary. +pub fn load_rhi_config_document( + runtime: &RhiRuntimeContext, +) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> { + load_rhi_config_document_at(runtime.selected_config_path(), profile(runtime)) +} + +/// Loads one absolute candidate document without changing the selected path. +pub fn load_rhi_config_candidate( + runtime: &RhiRuntimeContext, + candidate: &Path, +) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> { + load_rhi_config_document_at(candidate, profile(runtime)) +} + +/// Validates and creates the selected non-secret configuration exactly once. +pub fn initialize_rhi_config_document( + runtime: &RhiRuntimeContext, + bytes: &[u8], +) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> { + if bytes.len() > RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES { + return Err(RhiConfigLoadError::new(RhiConfigLoadErrorKind::TooLarge)); + } + let document = parse_rhi_config_v1(bytes, profile(runtime)).map_err(map_document)?; + persist_create_new(runtime.selected_config_path(), bytes)?; + Ok(document) +} + +fn profile(runtime: &RhiRuntimeContext) -> RhiConfigProfile { + match runtime.profile() { + crate::RhiBootstrapProfileV1::RepoLocal => RhiConfigProfile::RepoLocal, + crate::RhiBootstrapProfileV1::ServiceHost | crate::RhiBootstrapProfileV1::Interactive => { + RhiConfigProfile::Production + } + } +} + +fn map_document(_: RhiConfigV1Error) -> RhiConfigLoadError { + RhiConfigLoadError::new(RhiConfigLoadErrorKind::InvalidDocument) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn load_rhi_config_document_at( + path: &Path, + profile: RhiConfigProfile, +) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> { + let bytes = native::read_existing(path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)?; + parse_rhi_config_v1(&bytes, profile).map_err(map_document) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn load_rhi_config_document_at( + _path: &Path, + _profile: RhiConfigProfile, +) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> { + Err(RhiConfigLoadError::new( + RhiConfigLoadErrorKind::UnsupportedPlatform, + )) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn persist_create_new(path: &Path, bytes: &[u8]) -> Result<(), RhiConfigLoadError> { + native::persist_create_new(path, bytes) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn persist_create_new(_path: &Path, _bytes: &[u8]) -> Result<(), RhiConfigLoadError> { + Err(RhiConfigLoadError::new( + RhiConfigLoadErrorKind::UnsupportedPlatform, + )) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn read_secure_bounded_file( + path: &Path, + maximum: usize, +) -> Result<Vec<u8>, RhiConfigLoadError> { + native::read_existing(path, maximum) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod native { + use std::ffi::OsString; + use std::fs::File; + use std::io::{Read, Write}; + use std::os::unix::ffi::OsStrExt; + use std::path::{Component, Path, PathBuf}; + + use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, open, openat, unlinkat}; + use rustix::process::geteuid; + + use super::{RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RhiConfigLoadError, RhiConfigLoadErrorKind}; + + #[derive(Clone, Copy, PartialEq, Eq)] + struct Identity { + device: u64, + inode: u64, + } + + struct SelectedPath { + parent: PathBuf, + name: OsString, + } + + impl SelectedPath { + fn parse(path: &Path) -> Result<Self, RhiConfigLoadError> { + if !path.is_absolute() + || path.as_os_str().as_bytes().len() > 4_096 + || path.components().any(|component| { + !matches!(component, Component::RootDir | Component::Normal(_)) + }) + { + return Err(error(RhiConfigLoadErrorKind::InvalidPath)); + } + let name = match path.components().next_back() { + Some(Component::Normal(name)) if !name.as_bytes().is_empty() => name.to_os_string(), + _ => return Err(error(RhiConfigLoadErrorKind::InvalidPath)), + }; + let parent = path + .parent() + .filter(|parent| parent.is_absolute()) + .ok_or_else(|| error(RhiConfigLoadErrorKind::InvalidPath))?; + Ok(Self { + parent: parent.to_path_buf(), + name, + }) + } + } + + pub(super) fn read_existing( + path: &Path, + maximum: usize, + ) -> Result<Vec<u8>, RhiConfigLoadError> { + let selected = SelectedPath::parse(path)?; + let parent = open_parent(&selected.parent)?; + let parent_identity = directory_identity(&parent)?; + let descriptor = openat( + &parent, + &selected.name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|source| { + error(if source == rustix::io::Errno::NOENT { + RhiConfigLoadErrorKind::Missing + } else { + RhiConfigLoadErrorKind::InsecureArtifact + }) + })?; + let mut file = File::from(descriptor); + let status = fstat(&file).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?; + let (identity, length) = file_identity(&status, maximum)?; + let mut bytes = Vec::with_capacity(length); + Read::by_ref(&mut file) + .take(u64::try_from(length).unwrap_or(u64::MAX).saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| error(RhiConfigLoadErrorKind::Io))?; + if bytes.len() != length { + return Err(error(RhiConfigLoadErrorKind::InsecureArtifact)); + } + validate_current( + &selected, + &parent, + parent_identity, + &file, + identity, + length, + maximum, + )?; + Ok(bytes) + } + + pub(super) fn persist_create_new(path: &Path, bytes: &[u8]) -> Result<(), RhiConfigLoadError> { + let selected = SelectedPath::parse(path)?; + let parent = open_parent(&selected.parent)?; + let parent_identity = directory_identity(&parent)?; + let descriptor = openat( + &parent, + &selected.name, + OFlags::WRONLY + | OFlags::CREATE + | OFlags::EXCL + | OFlags::NOFOLLOW + | OFlags::CLOEXEC + | OFlags::NONBLOCK, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|source| { + error(if source == rustix::io::Errno::EXIST { + RhiConfigLoadErrorKind::AlreadyExists + } else { + RhiConfigLoadErrorKind::Io + }) + })?; + let mut file = File::from(descriptor); + let status = fstat(&file).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?; + let identity = status_identity(&status)?; + let result = (|| { + fchmod(&file, Mode::RUSR | Mode::WUSR) + .map_err(|_| error(RhiConfigLoadErrorKind::Io))?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| error(RhiConfigLoadErrorKind::Io))?; + validate_current( + &selected, + &parent, + parent_identity, + &file, + identity, + bytes.len(), + RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, + )?; + parent + .sync_all() + .map_err(|_| error(RhiConfigLoadErrorKind::Io))?; + validate_current( + &selected, + &parent, + parent_identity, + &file, + identity, + bytes.len(), + RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, + ) + })(); + if result.is_err() { + cleanup(&parent, &selected.name, identity); + } + result + } + + fn open_parent(path: &Path) -> Result<File, RhiConfigLoadError> { + let mut components = path.components(); + if !matches!(components.next(), Some(Component::RootDir)) { + return Err(error(RhiConfigLoadErrorKind::InvalidPath)); + } + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut parent = File::from( + open(Path::new("/"), flags, Mode::empty()) + .map_err(|_| error(RhiConfigLoadErrorKind::InsecureParent))?, + ); + for component in components { + let Component::Normal(name) = component else { + return Err(error(RhiConfigLoadErrorKind::InvalidPath)); + }; + parent = File::from( + openat(&parent, name, flags, Mode::empty()) + .map_err(|_| error(RhiConfigLoadErrorKind::InsecureParent))?, + ); + } + directory_identity(&parent)?; + Ok(parent) + } + + fn directory_identity(directory: &File) -> Result<Identity, RhiConfigLoadError> { + let status = fstat(directory).map_err(|_| error(RhiConfigLoadErrorKind::InsecureParent))?; + let mode = normalize_mode(status.st_mode); + if !FileType::from_raw_mode(status.st_mode).is_dir() + || status.st_uid != geteuid().as_raw() + || mode & 0o022 != 0 + { + return Err(error(RhiConfigLoadErrorKind::InsecureParent)); + } + status_identity(&status) + } + + fn file_identity( + status: &rustix::fs::Stat, + maximum: usize, + ) -> Result<(Identity, usize), RhiConfigLoadError> { + let mode = normalize_mode(status.st_mode); + let length = + usize::try_from(status.st_size).map_err(|_| error(RhiConfigLoadErrorKind::TooLarge))?; + if !FileType::from_raw_mode(status.st_mode).is_file() + || normalize_link_count(status.st_nlink) != 1 + || status.st_uid != geteuid().as_raw() + || mode & 0o400 == 0 + || mode & 0o022 != 0 + { + return Err(error(RhiConfigLoadErrorKind::InsecureArtifact)); + } + if length > maximum { + return Err(error(RhiConfigLoadErrorKind::TooLarge)); + } + Ok((status_identity(status)?, length)) + } + + fn status_identity(status: &rustix::fs::Stat) -> Result<Identity, RhiConfigLoadError> { + Ok(Identity { + device: normalize_device(status.st_dev) + .map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?, + inode: status.st_ino, + }) + } + + pub(super) fn normalize_mode<T: Into<u32>>(raw: T) -> u32 { + raw.into() + } + + pub(super) fn normalize_link_count<T: Into<u64>>(raw: T) -> u64 { + raw.into() + } + + pub(super) fn normalize_device<T: TryInto<u64>>(raw: T) -> Result<u64, T::Error> { + raw.try_into() + } + + fn validate_current( + selected: &SelectedPath, + parent: &File, + parent_identity: Identity, + held: &File, + held_identity: Identity, + length: usize, + maximum: usize, + ) -> Result<(), RhiConfigLoadError> { + if directory_identity(parent)? != parent_identity { + return Err(error(RhiConfigLoadErrorKind::InsecureParent)); + } + let current_parent = open_parent(&selected.parent)?; + if directory_identity(&current_parent)? != parent_identity { + return Err(error(RhiConfigLoadErrorKind::InsecureParent)); + } + let held_status = + fstat(held).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?; + let (current_held, current_length) = file_identity(&held_status, maximum)?; + if current_held != held_identity || current_length != length { + return Err(error(RhiConfigLoadErrorKind::InsecureArtifact)); + } + let current = File::from( + openat( + &current_parent, + &selected.name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?, + ); + let status = + fstat(&current).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?; + let (current_identity, current_length) = file_identity(&status, maximum)?; + if current_identity != held_identity || current_length != length { + return Err(error(RhiConfigLoadErrorKind::InsecureArtifact)); + } + Ok(()) + } + + fn cleanup(parent: &File, name: &std::ffi::OsStr, identity: Identity) { + let current = openat( + parent, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .ok() + .map(File::from); + if current.as_ref().is_some_and(|file| { + fstat(file) + .ok() + .and_then(|status| status_identity(&status).ok()) + == Some(identity) + }) { + let _ = unlinkat(parent, name, rustix::fs::AtFlags::empty()); + let _ = parent.sync_all(); + } + } + + const fn error(kind: RhiConfigLoadErrorKind) -> RhiConfigLoadError { + RhiConfigLoadError::new(kind) + } + + #[cfg(test)] + mod tests { + use std::os::unix::fs::PermissionsExt as _; + + use super::*; + + #[test] + fn validation_rejects_a_replaced_parent_path() { + let root = tempfile::tempdir().expect("temporary root"); + let parent_path = root.path().join("selected"); + std::fs::create_dir(&parent_path).expect("selected parent"); + std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o700)) + .expect("secure selected parent"); + let path = parent_path.join("config.toml"); + std::fs::write(&path, b"config").expect("selected config"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("secure selected config"); + + let selected = SelectedPath::parse(&path).expect("selected path"); + let parent = open_parent(&selected.parent).expect("held parent"); + let parent_identity = directory_identity(&parent).expect("parent identity"); + let held = File::from( + openat( + &parent, + &selected.name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .expect("held config"), + ); + let status = fstat(&held).expect("held status"); + let (identity, length) = file_identity(&status, 16).expect("held identity"); + + let moved = root.path().join("moved"); + std::fs::rename(&parent_path, &moved).expect("move held parent"); + std::fs::create_dir(&parent_path).expect("replacement parent"); + std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o700)) + .expect("secure replacement parent"); + std::fs::write(parent_path.join("config.toml"), b"config").expect("replacement config"); + + assert_eq!( + validate_current( + &selected, + &parent, + parent_identity, + &held, + identity, + length, + 16, + ) + .expect_err("parent replacement") + .kind(), + RhiConfigLoadErrorKind::InsecureParent + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_are_source_free_and_path_free() { + for kind in [ + RhiConfigLoadErrorKind::InvalidPath, + RhiConfigLoadErrorKind::Missing, + RhiConfigLoadErrorKind::AlreadyExists, + RhiConfigLoadErrorKind::InsecureParent, + RhiConfigLoadErrorKind::InsecureArtifact, + RhiConfigLoadErrorKind::TooLarge, + RhiConfigLoadErrorKind::Io, + RhiConfigLoadErrorKind::InvalidDocument, + RhiConfigLoadErrorKind::UnsupportedPlatform, + ] { + let error = RhiConfigLoadError::new(kind); + assert_eq!(error.kind(), kind); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains('/')); + assert!(Error::source(&error).is_none()); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn native_create_read_permissions_and_collision_are_exact() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = tempfile::tempdir().expect("temporary directory"); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)) + .expect("secure directory"); + let path = directory.path().join("config.toml"); + let bytes = b"schema = \"radroots.rhi.config\"\n"; + native::persist_create_new(&path, bytes).expect("create-new config"); + assert_eq!( + std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES).expect("secure read"), + bytes + ); + assert_eq!( + native::persist_create_new(&path, bytes) + .expect_err("collision") + .kind(), + RhiConfigLoadErrorKind::AlreadyExists + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn native_reader_rejects_insecure_parent_artifact_links_and_oversize() { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + + let directory = tempfile::tempdir().expect("temporary directory"); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)) + .expect("secure directory"); + let path = directory.path().join("config.toml"); + std::fs::write(&path, b"config").expect("config"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o620)) + .expect("insecure mode"); + assert_eq!( + native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES) + .expect_err("group-write rejection") + .kind(), + RhiConfigLoadErrorKind::InsecureArtifact + ); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("secure mode"); + let hardlink = directory.path().join("hardlink.toml"); + std::fs::hard_link(&path, &hardlink).expect("hard link"); + assert_eq!( + native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES) + .expect_err("single-link rejection") + .kind(), + RhiConfigLoadErrorKind::InsecureArtifact + ); + std::fs::remove_file(&hardlink).expect("remove link"); + + let symlink_path = directory.path().join("symlink.toml"); + symlink(&path, &symlink_path).expect("symlink"); + assert_eq!( + native::read_existing(&symlink_path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES) + .expect_err("no-follow rejection") + .kind(), + RhiConfigLoadErrorKind::InsecureArtifact + ); + + std::fs::write(&path, vec![b'x'; RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1]) + .expect("oversize"); + assert_eq!( + native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES) + .expect_err("oversize rejection") + .kind(), + RhiConfigLoadErrorKind::TooLarge + ); + + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o720)) + .expect("insecure parent"); + assert_eq!( + native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES) + .expect_err("parent rejection") + .kind(), + RhiConfigLoadErrorKind::InsecureParent + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn native_metadata_normalization_preserves_width_and_signed_device_rejection() { + assert_eq!(native::normalize_mode(0o600_u16), 0o600); + assert_eq!(native::normalize_mode(0o700_u32), 0o700); + assert_eq!(native::normalize_link_count(1_u16), 1); + assert_eq!(native::normalize_link_count(1_u64), 1); + assert_eq!(native::normalize_device(7_i32), Ok(7)); + assert!(native::normalize_device(-1_i32).is_err()); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn generic_secure_reader_enforces_the_callers_exact_bound() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = tempfile::tempdir().expect("temporary directory"); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)) + .expect("secure directory"); + let path = directory.path().join("artifact"); + std::fs::write(&path, b"four").expect("artifact"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("secure artifact"); + + assert_eq!( + read_secure_bounded_file(&path, 4).expect("exact bound"), + b"four" + ); + assert_eq!( + read_secure_bounded_file(&path, 3) + .expect_err("just over bound") + .kind(), + RhiConfigLoadErrorKind::TooLarge + ); + } +} diff --git a/src/diagnostics_v1.rs b/src/diagnostics_v1.rs @@ -0,0 +1,189 @@ +//! Closed process-result and structured stderr diagnostic contract. + +use crate::{RhiProcessResult, RhiServicePhase}; +use core::fmt; + +/// Exact Rhi diagnostics contract version. +pub const RHI_DIAGNOSTICS_CONTRACT_VERSION: u32 = 1; + +/// Hard maximum for one canonical Rhi structured log record, excluding newline. +pub const RHI_LOG_RECORD_MAX_UTF8_BYTES: usize = 512; + +const LOG_SCHEMA: &str = "radroots.rhi.log.v1"; + +/// Closed structured-log severity vocabulary admitted by Rhi. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl RhiLogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +/// Closed structured-log event vocabulary required by the current runtime plan. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiLogEvent { + ProcessResult, + Lifecycle, + CriticalTaskFailed, + ShutdownRequested, + ShutdownForced, +} + +impl RhiLogEvent { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ProcessResult => "process_result", + Self::Lifecycle => "lifecycle", + Self::CriticalTaskFailed => "critical_task_failed", + Self::ShutdownRequested => "shutdown_requested", + Self::ShutdownForced => "shutdown_forced", + } + } +} + +/// One sealed canonical structured log record containing only governed values. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiLogRecord { + level: RhiLogLevel, + event: RhiLogEvent, + code: &'static str, + process_result: Option<RhiProcessResult>, +} + +impl RhiLogRecord { + /// Builds the exact terminal record for one governed process result. + #[must_use] + pub const fn process_result(result: RhiProcessResult) -> Self { + let level = match result { + RhiProcessResult::Success => RhiLogLevel::Info, + RhiProcessResult::OperationRejectedOrConflict => RhiLogLevel::Warn, + RhiProcessResult::UnexpectedInternal + | RhiProcessResult::InputOrConfiguration + | RhiProcessResult::ServiceOrDependencyUnavailable + | RhiProcessResult::StateOrIdentityUnavailable + | RhiProcessResult::DoctorRequiredCheckFailed => RhiLogLevel::Error, + }; + Self { + level, + event: RhiLogEvent::ProcessResult, + code: result.code(), + process_result: Some(result), + } + } + + /// Builds the exact phase record from an already-published lifecycle value. + #[must_use] + pub const fn lifecycle(phase: RhiServicePhase) -> Self { + let (level, code) = match phase { + RhiServicePhase::Starting => (RhiLogLevel::Info, "starting"), + RhiServicePhase::Ready => (RhiLogLevel::Info, "ready"), + RhiServicePhase::Degraded => (RhiLogLevel::Warn, "degraded"), + RhiServicePhase::Unready => (RhiLogLevel::Warn, "unready"), + RhiServicePhase::Stopping => (RhiLogLevel::Info, "stopping"), + RhiServicePhase::Failed => (RhiLogLevel::Error, "failed"), + }; + Self { + level, + event: RhiLogEvent::Lifecycle, + code, + process_result: None, + } + } + + #[must_use] + pub const fn critical_task_failed() -> Self { + Self { + level: RhiLogLevel::Error, + event: RhiLogEvent::CriticalTaskFailed, + code: "critical_task_failed", + process_result: None, + } + } + + #[must_use] + pub const fn shutdown_requested() -> Self { + Self { + level: RhiLogLevel::Info, + event: RhiLogEvent::ShutdownRequested, + code: "first_signal", + process_result: None, + } + } + + #[must_use] + pub const fn shutdown_forced() -> Self { + Self { + level: RhiLogLevel::Error, + event: RhiLogEvent::ShutdownForced, + code: "second_signal", + process_result: None, + } + } + + #[must_use] + pub const fn level(&self) -> RhiLogLevel { + self.level + } + + #[must_use] + pub const fn event(&self) -> RhiLogEvent { + self.event + } + + #[must_use] + pub const fn code(&self) -> &'static str { + self.code + } + + #[must_use] + pub const fn process_exit(&self) -> Option<RhiProcessResult> { + self.process_result + } +} + +impl fmt::Debug for RhiLogRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiLogRecord") + .field("level", &self.level) + .field("event", &self.event) + .field("code", &self.code) + .field( + "exit_code", + &self.process_result.map(RhiProcessResult::exit_code_u8), + ) + .finish() + } +} + +impl fmt::Display for RhiLogRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{{\"schema\":\"{LOG_SCHEMA}\",\"contract_version\":{RHI_DIAGNOSTICS_CONTRACT_VERSION},\"service\":\"rhi\",\"level\":\"{}\",\"event\":\"{}\",\"code\":\"{}\"", + self.level.as_str(), + self.event.as_str(), + self.code, + )?; + if let Some(result) = self.process_result { + write!(formatter, ",\"exit_code\":{}", result.exit_code_u8())?; + } + formatter.write_str("}") + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -4,8 +4,11 @@ mod adapters; mod admin_v1; +mod cli_bootstrap; mod cli_v1; +mod config_loader; mod config_v1; +mod diagnostics_v1; mod doctor_v1; mod features; mod identity_credential; @@ -14,6 +17,7 @@ mod operations_v1; mod presence_desired; mod presence_publication; mod process_result_v1; +mod process_v1; mod publication; mod publication_attempt; mod publication_execution; @@ -28,9 +32,16 @@ mod reconciliation_manifest; mod reconciliation_reducer; mod reconciliation_replay; mod runtime_adapters; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod runtime_admin; mod runtime_context; mod runtime_foundation; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod runtime_graph; +mod runtime_signal; mod source_ingest; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod state_admin; mod state_catalog; mod state_config; mod state_host; @@ -39,22 +50,34 @@ mod state_metadata; mod state_repository; mod state_trade; mod status_v1; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod system_doctor; mod trade_ingest; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod transport_nostr_adapter; pub use adapters::nostr::event::NostrEventAdapter; pub use admin_v1::{ RhiAdminCancellationToken, RhiAdminDocumentError, RhiAdminDocumentErrorKind, RhiAdminFuture, RhiAdminHandler, RhiAdminHandlerError, RhiAdminHandlerErrorKind, RhiAdminMethod, - RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiAdminRouter, - RhiAdminRouterError, RhiAdminServer, RhiAdminServerError, RhiAdminServerErrorKind, - RhiBoundAdminServer, build_rhi_admin_router, + RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiAdminRouterError, + RhiAdminServerError, RhiAdminServerErrorKind, }; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub use admin_v1::{RhiAdminRouter, RhiAdminServer, RhiBoundAdminServer, build_rhi_admin_router}; pub use cli_v1::{ RhiBootstrapProfileV1, RhiCliAdminOperationV1, RhiCliExecutionPlanV1, RhiCliInvocationV1, RhiCliOfflineOperationV1, RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCliV1Error, - RhiCliV1ErrorKind, RhiCommandV1, RhiConfigCommandV1, RhiIdentityCommandV1, RhiMetricsCommandV1, - RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, RhiSourcesCommandV1, - RhiStateCommandV1, RhiTradeCommandV1, parse_rhi_cli_v1_from, plan_rhi_cli_v1, + RhiCliV1ErrorKind, RhiCommandV1, RhiConfigApplyArgsV1, RhiConfigCommandV1, + RhiIdentityCommandV1, RhiMetricsCommandV1, RhiPageQueryArgsV1, RhiPresenceCommandV1, + RhiPresenceMutationArgsV1, RhiPublicationCommandV1, RhiPublicationRetryArgsV1, + RhiReconciliationCommandV1, RhiReconciliationRefreshArgsV1, RhiSourcesCommandV1, + RhiStateBackupArgsV1, RhiStateCommandV1, RhiStateRestoreArgsV1, RhiTradeArgsV1, + RhiTradeCommandV1, RhiTradePageArgsV1, parse_rhi_cli_v1_from, plan_rhi_cli_v1, +}; +pub use config_loader::{ + RhiConfigLoadError, RhiConfigLoadErrorKind, initialize_rhi_config_document, + load_rhi_config_candidate, load_rhi_config_document, }; pub use config_v1::{ RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES, RHI_CONFIG_SCHEMA, @@ -62,6 +85,10 @@ pub use config_v1::{ RhiConfigV1Error, RhiConfigV1ErrorKind, RhiConfigValueSource, RhiEffectiveConfigV1, RhiRuntimeThreadLimitsV1, parse_rhi_config_v1, }; +pub use diagnostics_v1::{ + RHI_DIAGNOSTICS_CONTRACT_VERSION, RHI_LOG_RECORD_MAX_UTF8_BYTES, RhiLogEvent, RhiLogLevel, + RhiLogRecord, +}; pub use doctor_v1::{ RHI_DOCTOR_CHECK_COUNT, RHI_DOCTOR_CONTRACT_VERSION, RHI_DOCTOR_REPORT_MAX_UTF8_BYTES, RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES, RhiDoctorAggregateStatus, RhiDoctorCheckDefinition, @@ -113,6 +140,7 @@ pub use presence_publication::{ validate_rhi_signed_presence_documents, }; pub use process_result_v1::RhiProcessResult; +pub use process_v1::{execute_rhi_cli_v1, execute_rhi_cli_v1_with_signal_source}; pub use publication::{ RHI_PUBLICATION_CONTRACT_VERSION, RHI_PUBLICATION_MAX_ATTEMPTS, RHI_PUBLICATION_MAX_TARGETS, RhiPublicationAuthority, RhiPublicationError, RhiPublicationErrorKind, RhiPublicationMode, @@ -212,6 +240,7 @@ pub use runtime_foundation::{ RhiRuntimeFoundationErrorKind, RhiRuntimePrerequisite, RhiRuntimeReadiness, RhiRuntimeReadinessReason, open_rhi_runtime_foundation, }; +pub use runtime_signal::{RhiProcessSignal, RhiProcessSignalFuture, RhiProcessSignalSource}; pub use source_ingest::{ RHI_TRADE_SOURCE_INGEST_CONTRACT_VERSION, RHI_TRADE_SOURCE_RESULT_MAX_BYTES, RHI_TRADE_SOURCE_RESULT_MAX_EVENTS, RhiTradeDirtyGeneration, RhiTradeSourceAttempt, @@ -235,8 +264,9 @@ pub use state_catalog::{ RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_9_SHA256, RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_10_SHA256, - RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, - validate_rhi_state_catalogs, + RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT, + RHI_STATE_SCHEMA_VERSION_11_SHA256, RhiStateCatalogError, RhiStateCatalogErrorKind, + rhi_migration_catalog, rhi_schema_catalog, validate_rhi_state_catalogs, }; pub use state_config::{ RHI_CONFIG_BINDING_MAX_GENERATIONS, RhiConfigApplyError, RhiConfigApplyErrorKind, @@ -245,7 +275,8 @@ pub use state_config::{ pub use state_host::{ RhiStateHost, RhiStateHostError, RhiStateHostErrorKind, RhiStateHostMode, apply_rhi_configuration, initialize_rhi_state, open_rhi_state_inspection, - open_rhi_state_read_write, open_rhi_state_read_write_from_config, + open_rhi_state_inspection_from_config, open_rhi_state_read_write, + open_rhi_state_read_write_from_config, }; pub use state_maintenance::{ RhiStagedStateRestore, RhiStateMaintenanceError, RhiStateMaintenanceErrorKind, diff --git a/src/main.rs b/src/main.rs @@ -1,96 +1,71 @@ -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] +#![forbid(unsafe_code)] -use std::path::PathBuf; use std::process::ExitCode; -use rhi::{ - RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiProcessResult, - parse_rhi_cli_v1_from, plan_rhi_cli_v1, resolve_rhi_runtime_context, -}; +use rhi::{RhiLogRecord, RhiProcessResult}; -fn main() -> ExitCode { - let invocation = match parse_rhi_cli_v1_from(std::env::args_os()) { - Ok(invocation) => invocation, - Err(_) => return emit_failure(RhiProcessResult::InputOrConfiguration), - }; - exit_code_from_run(execute(invocation)) +struct RhiOsSignalSource { + #[cfg(unix)] + interrupt: tokio::signal::unix::Signal, + #[cfg(unix)] + terminate: tokio::signal::unix::Signal, } -fn exit_code_from_run(result: Result<(), RhiProcessResult>) -> ExitCode { - match result { - Ok(()) => RhiProcessResult::Success.exit_code(), - Err(result) => emit_failure(result), +impl RhiOsSignalSource { + fn new() -> Option<Self> { + #[cfg(unix)] + { + let interrupt = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()).ok()?; + let terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok()?; + Some(Self { + interrupt, + terminate, + }) + } + #[cfg(not(unix))] + { + Some(Self {}) + } } } -fn emit_failure(result: RhiProcessResult) -> ExitCode { - eprintln!("RHI command failed: {}", result.code()); - result.exit_code() -} - -fn execute(invocation: rhi::RhiCliInvocationV1) -> Result<(), RhiProcessResult> { - let _plan = plan_rhi_cli_v1(&invocation); - let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), host_environment()); - let _context = resolve_rhi_runtime_context(&resolver, &invocation) - .map_err(|_| RhiProcessResult::InputOrConfiguration)?; - Err(RhiProcessResult::InputOrConfiguration) -} - -fn host_environment() -> RadrootsHostEnvironment { - let path = |name| { - std::env::var_os(name) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - }; - RadrootsHostEnvironment { - home_dir: path("HOME"), - xdg_config_home: path("XDG_CONFIG_HOME"), - xdg_data_home: path("XDG_DATA_HOME"), - xdg_state_home: path("XDG_STATE_HOME"), - xdg_cache_home: path("XDG_CACHE_HOME"), - xdg_runtime_dir: path("XDG_RUNTIME_DIR"), - appdata_dir: path("APPDATA"), - localappdata_dir: path("LOCALAPPDATA"), +impl rhi::RhiProcessSignalSource for RhiOsSignalSource { + fn next_signal(&mut self) -> rhi::RhiProcessSignalFuture<'_> { + #[cfg(unix)] + { + Box::pin(async move { + tokio::select! { + observed = self.interrupt.recv() => observed.map(|()| rhi::RhiProcessSignal::Interrupt), + observed = self.terminate.recv() => observed.map(|()| rhi::RhiProcessSignal::Terminate), + } + }) + } + #[cfg(not(unix))] + { + Box::pin(async move { + tokio::signal::ctrl_c() + .await + .ok() + .map(|()| rhi::RhiProcessSignal::Interrupt) + }) + } } } -#[cfg(test)] -mod tests { - use super::{execute, exit_code_from_run}; - use rhi::{RhiProcessResult, parse_rhi_cli_v1_from}; - use std::process::ExitCode; - - #[test] - fn process_result_is_stable() { - assert_eq!(exit_code_from_run(Ok(())), ExitCode::SUCCESS); - assert_eq!( - exit_code_from_run(Err(RhiProcessResult::UnexpectedInternal)), - ExitCode::FAILURE - ); - } - - #[test] - fn admitted_command_fails_closed_without_creating_runtime_state() { - let root = tempfile::tempdir().expect("temporary repo-local root"); - let invocation = parse_rhi_cli_v1_from([ - "rhi", - "--profile", - "repo-local", - "--instance", - "default", - "--repo-local-root", - root.path().to_str().expect("UTF-8 test root"), - "run", - ]) - .expect("valid invocation"); - - assert_eq!( - execute(invocation), - Err(RhiProcessResult::InputOrConfiguration) - ); - assert_eq!( - std::fs::read_dir(root.path()).expect("read root").count(), - 0 - ); +fn main() -> ExitCode { + match rhi::parse_rhi_cli_v1_from(std::env::args_os()) { + Ok(invocation) => { + let result = + rhi::execute_rhi_cli_v1_with_signal_source(invocation, RhiOsSignalSource::new); + eprintln!("{}", RhiLogRecord::process_result(result)); + result.exit_code() + } + Err(_) => { + let result = RhiProcessResult::InputOrConfiguration; + eprintln!("{}", RhiLogRecord::process_result(result)); + result.exit_code() + } } } diff --git a/src/process_v1.rs b/src/process_v1.rs @@ -0,0 +1,768 @@ +//! Binary-owned execution for one already-admitted command invocation. + +use std::env; +use std::io::{Read, Write}; +use std::num::NonZeroU64; +use std::path::{Path, PathBuf}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use radroots_service_host::{ + AdminClient, AdminClientErrorKind, AdminClientTarget, AdminOperationId, +}; +use radroots_service_host::{ + ContractVersions, EntropySource, SystemEntropy, SystemWallClock, WallClock, +}; +use radroots_service_sqlite::{ + BACKUP_MANIFEST_CANONICAL_MAX_BYTES, BackupManifestSha256, IntegrityCheckedAtUnixMs, + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, +}; +use radroots_storage::event::SourceGeneration; +use serde_json::{Value, json}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::admin_v1::{admin_transport_limits, admit_admin_response_value}; +use crate::cli_bootstrap::read_identity_provisioning_document; +use crate::{ + RHI_ADMIN_CONTRACT_VERSION, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RHI_CONFIG_SCHEMA_VERSION, + RHI_PROVIDER_CONTRACT_VERSION, RHI_STATE_SCHEMA_VERSION, RHI_STATUS_CONTRACT_VERSION, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiCliInvocationV1, + RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCommandV1, RhiConfigCommandV1, + RhiIdentityCommandV1, RhiProcessResult, RhiRuntimeContext, RhiStateCommandV1, RhiStateMetadata, + apply_rhi_configuration, finalize_rhi_state_restore, initialize_rhi_config_document, + initialize_rhi_state, load_rhi_config_candidate, load_rhi_config_document, + open_rhi_state_read_write_from_config, plan_rhi_cli_v1, provision_rhi_encrypted_identity, + resolve_rhi_runtime_context, resolve_rhi_wrapping_credential, stage_rhi_state_restore, + verify_rhi_state_backup, +}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::{ + RhiMetricsCommandV1, RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, + RhiSourcesCommandV1, RhiTradeCommandV1, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ProcessFailure(RhiProcessResult); + +type ProcessResult<T> = Result<T, ProcessFailure>; + +/// Executes one admitted RHI invocation without reparsing process arguments. +#[must_use] +pub fn execute_rhi_cli_v1(invocation: RhiCliInvocationV1) -> RhiProcessResult { + execute(invocation).unwrap_or_else(|failure| failure.0) +} + +/// Executes one admitted invocation with a binary-owned process-signal source. +#[must_use] +pub fn execute_rhi_cli_v1_with_signal_source<F, S>( + invocation: RhiCliInvocationV1, + make_signal_source: F, +) -> RhiProcessResult +where + F: FnOnce() -> Option<S>, + S: crate::RhiProcessSignalSource + 'static, +{ + if !matches!(invocation.command(), RhiCommandV1::Run) { + return execute_rhi_cli_v1(invocation); + } + execute_run(invocation, make_signal_source).unwrap_or_else(|failure| failure.0) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn execute_run<F, S>( + invocation: RhiCliInvocationV1, + make_signal_source: F, +) -> ProcessResult<RhiProcessResult> +where + F: FnOnce() -> Option<S>, + S: crate::RhiProcessSignalSource + 'static, +{ + let runtime = resolve_runtime(&invocation)?; + let configuration = load_rhi_config_document(&runtime).map_err(|_| input_failure())?; + let applied_at = migration_time()?; + let build = migration_build_identity()?; + let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?; + tokio.block_on(async move { + let signals = make_signal_source().ok_or(ProcessFailure( + RhiProcessResult::ServiceOrDependencyUnavailable, + ))?; + Ok(crate::runtime_graph::run_rhi_daemon( + runtime, + configuration, + applied_at, + &build, + signals, + ) + .await) + }) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn execute_run<F, S>( + _invocation: RhiCliInvocationV1, + _make_signal_source: F, +) -> ProcessResult<RhiProcessResult> +where + F: FnOnce() -> Option<S>, + S: crate::RhiProcessSignalSource + 'static, +{ + Err(ProcessFailure( + RhiProcessResult::ServiceOrDependencyUnavailable, + )) +} + +fn execute(invocation: RhiCliInvocationV1) -> ProcessResult<RhiProcessResult> { + let plan = plan_rhi_cli_v1(&invocation); + if plan.primary_authority() == RhiCliPrimaryAuthorityV1::Daemon { + return Err(ProcessFailure( + RhiProcessResult::ServiceOrDependencyUnavailable, + )); + } + let runtime = resolve_runtime(&invocation)?; + let output = invocation.output_mode(); + match (plan.primary_authority(), invocation.command()) { + (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::Config(command)) => { + execute_config(output, &runtime, command) + } + (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::State(command)) => { + execute_state(output, &runtime, command) + } + (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::Identity(command)) => { + execute_identity(output, &runtime, *command) + } + (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::Doctor) => { + execute_doctor(output, &runtime) + } + (RhiCliPrimaryAuthorityV1::LiveUnixAdmin, command) => { + execute_live(output, &runtime, command) + } + _ => Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)), + } +} + +fn execute_config( + output: RhiCliOutputModeV1, + runtime: &RhiRuntimeContext, + command: &RhiConfigCommandV1, +) -> ProcessResult<RhiProcessResult> { + match command { + RhiConfigCommandV1::Init => { + let bytes = read_bounded_stdin(RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)?; + initialize_rhi_config_document(runtime, &bytes).map_err(|_| input_failure())?; + emit_simple_success(output, "config_initialized") + } + RhiConfigCommandV1::Validate => { + load_rhi_config_document(runtime).map_err(|_| input_failure())?; + emit_simple_success(output, "config_valid") + } + RhiConfigCommandV1::Schema => emit_bytes(include_bytes!( + "../contracts/services_hardening/config.v1.schema.json" + )), + RhiConfigCommandV1::Apply(arguments) => { + let current = load_rhi_config_document(runtime).map_err(|_| input_failure())?; + let candidate = load_rhi_config_candidate(runtime, arguments.candidate_config()) + .map_err(|_| input_failure())?; + let tokio = build_tokio_runtime(current.runtime_thread_limits())?; + let outcome = tokio + .block_on(apply_rhi_configuration( + runtime, + &current, + &candidate, + migration_time()?, + &migration_build_identity()?, + )) + .map_err(|_| conflict_failure())?; + emit_value( + output, + "config_applied", + json!({"generation": outcome.generation()}), + ) + } + RhiConfigCommandV1::Show => Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)), + } +} + +fn execute_state( + output: RhiCliOutputModeV1, + runtime: &RhiRuntimeContext, + command: &RhiStateCommandV1, +) -> ProcessResult<RhiProcessResult> { + let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?; + let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?; + match command { + RhiStateCommandV1::Init => { + let metadata = RhiStateMetadata::new( + runtime, + &configuration, + source_generation()?, + wall_time_millis()?, + ) + .map_err(|_| state_failure())?; + tokio + .block_on(initialize_rhi_state( + runtime, + &metadata, + migration_time()?, + &migration_build_identity()?, + )) + .map_err(|_| state_failure())?; + emit_simple_success(output, "state_initialized") + } + RhiStateCommandV1::Restore(arguments) => { + let state = tokio + .block_on(open_rhi_state_read_write_from_config( + runtime, + &configuration, + migration_time()?, + &migration_build_identity()?, + )) + .map_err(|_| state_failure())?; + let metadata = state.metadata().clone(); + tokio.block_on(state.close()).map_err(|_| state_failure())?; + let manifest = + read_bounded_file(arguments.manifest(), BACKUP_MANIFEST_CANONICAL_MAX_BYTES)?; + let digest = decode_hex_32(arguments.manifest_sha256())?; + let verified = verify_rhi_state_backup( + &manifest, + BackupManifestSha256::from_bytes(digest), + arguments.bundle(), + &metadata, + NonZeroU64::new(arguments.maximum_state_bytes()).ok_or_else(input_failure)?, + ) + .map_err(|_| state_failure())?; + let staged = tokio + .block_on(stage_rhi_state_restore(runtime, &metadata, verified)) + .map_err(|_| state_failure())?; + tokio + .block_on(finalize_rhi_state_restore(staged)) + .map_err(|_| state_failure())?; + emit_simple_success(output, "state_restore_finalized") + } + RhiStateCommandV1::Verify | RhiStateCommandV1::Migrate => { + let state = tokio + .block_on(open_rhi_state_read_write_from_config( + runtime, + &configuration, + migration_time()?, + &migration_build_identity()?, + )) + .map_err(|_| state_failure())?; + if matches!(command, RhiStateCommandV1::Verify) { + let checked_at = + IntegrityCheckedAtUnixMs::new(wall_time_millis()?).ok_or_else(state_failure)?; + tokio + .block_on(state.inspect_integrity(checked_at)) + .map_err(|_| state_failure())?; + } + tokio.block_on(state.close()).map_err(|_| state_failure())?; + emit_simple_success( + output, + if matches!(command, RhiStateCommandV1::Verify) { + "state_verified" + } else { + "state_migrated" + }, + ) + } + RhiStateCommandV1::Status | RhiStateCommandV1::Backup(_) => { + Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)) + } + } +} + +fn execute_identity( + output: RhiCliOutputModeV1, + runtime: &RhiRuntimeContext, + command: RhiIdentityCommandV1, +) -> ProcessResult<RhiProcessResult> { + if command != RhiIdentityCommandV1::Init { + return Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)); + } + let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?; + let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?; + let state = tokio + .block_on(open_rhi_state_read_write_from_config( + runtime, + &configuration, + migration_time()?, + &migration_build_identity()?, + )) + .map_err(|_| state_failure())?; + let metadata = state.metadata().clone(); + tokio.block_on(state.close()).map_err(|_| state_failure())?; + let binding = crate::RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata) + .map_err(|_| state_failure())?; + let credential = + resolve_rhi_wrapping_credential(runtime, &binding).map_err(|_| state_failure())?; + let material = read_identity_provisioning_document(std::io::stdin().lock()) + .map_err(|_| input_failure())?; + let identity = provision_rhi_encrypted_identity(&binding, &credential, material) + .map_err(|_| state_failure())?; + emit_value( + output, + "identity_initialized", + json!({"generation": 0, "public_key": identity.public_identity().as_hex(), "role": "service"}), + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn execute_doctor( + _output: RhiCliOutputModeV1, + runtime: &RhiRuntimeContext, +) -> ProcessResult<RhiProcessResult> { + let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?; + let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?; + let report = tokio + .block_on(crate::run_rhi_doctor( + runtime, + &crate::system_doctor::RhiSystemDoctorProbe::new(runtime, &configuration), + )) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?; + emit_bytes(report.canonical_json())?; + if report.exit_code() == 0 { + Ok(RhiProcessResult::Success) + } else { + Err(ProcessFailure(RhiProcessResult::DoctorRequiredCheckFailed)) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn execute_doctor( + _output: RhiCliOutputModeV1, + _runtime: &RhiRuntimeContext, +) -> ProcessResult<RhiProcessResult> { + Err(ProcessFailure( + RhiProcessResult::ServiceOrDependencyUnavailable, + )) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn execute_live( + _output: RhiCliOutputModeV1, + runtime: &RhiRuntimeContext, + command: &RhiCommandV1, +) -> ProcessResult<RhiProcessResult> { + let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?; + let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?; + let bytes = tokio.block_on(live_command(runtime, &configuration, command))?; + emit_bytes(&bytes) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn execute_live( + _output: RhiCliOutputModeV1, + _runtime: &RhiRuntimeContext, + _command: &RhiCommandV1, +) -> ProcessResult<RhiProcessResult> { + Err(ProcessFailure( + RhiProcessResult::ServiceOrDependencyUnavailable, + )) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +async fn live_command( + runtime: &RhiRuntimeContext, + configuration: &crate::RhiConfigDocumentV1, + command: &RhiCommandV1, +) -> ProcessResult<Box<[u8]>> { + use crate::RhiAdminRoute as Route; + let (route, target, mutation) = match command { + RhiCommandV1::Config(RhiConfigCommandV1::Show) => ( + Route::EffectiveConfig, + Route::EffectiveConfig.path().to_owned(), + None, + ), + RhiCommandV1::State(RhiStateCommandV1::Status) => ( + Route::StateStatus, + Route::StateStatus.path().to_owned(), + None, + ), + RhiCommandV1::State(RhiStateCommandV1::Backup(arguments)) => ( + Route::StateBackup, + Route::StateBackup.path().to_owned(), + Some(( + arguments.operation_id(), + json!({ + "confirmation": "confirm", + "expected_generation": arguments.expected_generation(), + "target_path": arguments.target().to_str().ok_or_else(input_failure)?, + }), + )), + ), + RhiCommandV1::Identity(RhiIdentityCommandV1::Status) => ( + Route::IdentityStatus, + format!("{}?role=service", Route::IdentityStatus.path()), + None, + ), + RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic) => ( + Route::IdentityPublic, + format!("{}?role=service", Route::IdentityPublic.path()), + None, + ), + RhiCommandV1::Status => (Route::Status, Route::Status.path().to_owned(), None), + RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot) => ( + Route::MetricsSnapshot, + Route::MetricsSnapshot.path().to_owned(), + None, + ), + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status) => ( + Route::ReconciliationStatus, + Route::ReconciliationStatus.path().to_owned(), + None, + ), + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs(page)) => ( + Route::ReconciliationJobs, + paged_target(Route::ReconciliationJobs.path(), page), + None, + ), + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh(arguments)) => ( + Route::ReconciliationRefresh, + Route::ReconciliationRefresh.path().to_owned(), + Some(( + arguments.operation_id(), + json!({ + "expected_dirty_generation": arguments.expected_dirty_generation(), + "trade_id": arguments.trade_id(), + }), + )), + ), + RhiCommandV1::Sources(RhiSourcesCommandV1::List(page)) => ( + Route::Sources, + paged_target(Route::Sources.path(), page), + None, + ), + RhiCommandV1::Trade(RhiTradeCommandV1::Projection(arguments)) => ( + Route::TradeProjection, + Route::TradeProjection + .path() + .replace("{trade_id}", arguments.trade_id()), + None, + ), + RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent(arguments)) => ( + Route::TradeReportCurrent, + Route::TradeReportCurrent + .path() + .replace("{trade_id}", arguments.trade_id()), + None, + ), + RhiCommandV1::Trade(RhiTradeCommandV1::Reports(arguments)) => ( + Route::TradeReports, + paged_target( + &Route::TradeReports + .path() + .replace("{trade_id}", arguments.trade_id()), + arguments.page(), + ), + None, + ), + RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog(page)) => ( + Route::PublicationBacklog, + paged_target(Route::PublicationBacklog.path(), page), + None, + ), + RhiCommandV1::Publication(RhiPublicationCommandV1::Targets(page)) => ( + Route::PublicationTargets, + paged_target(Route::PublicationTargets.path(), page), + None, + ), + RhiCommandV1::Publication(RhiPublicationCommandV1::Retry(arguments)) => ( + Route::PublicationRetry, + Route::PublicationRetry.path().to_owned(), + Some(( + arguments.operation_id(), + json!({ + "expected_generation": arguments.expected_generation(), + "workflow_id": arguments.workflow_id(), + }), + )), + ), + RhiCommandV1::Presence(RhiPresenceCommandV1::Desired) => ( + Route::PresenceDesired, + Route::PresenceDesired.path().to_owned(), + None, + ), + RhiCommandV1::Presence(RhiPresenceCommandV1::Render(arguments)) => ( + Route::PresenceRender, + Route::PresenceRender.path().to_owned(), + Some(( + arguments.operation_id(), + json!({"expected_generation": arguments.expected_generation()}), + )), + ), + RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh(arguments)) => ( + Route::PresenceRefresh, + Route::PresenceRefresh.path().to_owned(), + Some(( + arguments.operation_id(), + json!({"expected_generation": arguments.expected_generation()}), + )), + ), + _ => return Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)), + }; + let client = AdminClient::new( + runtime.artifacts().admin_socket(), + admin_transport_limits(configuration).map_err(|_| input_failure())?, + ) + .map_err(|_| input_failure())?; + let target = AdminClientTarget::new(target).map_err(|_| input_failure())?; + let result = if let Some((operation_id, request)) = mutation { + let operation_id = AdminOperationId::new(operation_id).map_err(|_| input_failure())?; + client + .mutate::<_, Value>(&target, operation_id, None, request) + .await + .map(|response| response.result().clone()) + } else { + client + .get::<Value>(&target) + .await + .map(|response| response.result().clone()) + }; + match result { + Ok(value) => admit_admin_response_value(route, &value) + .map_err(|_| ProcessFailure(RhiProcessResult::ServiceOrDependencyUnavailable)), + Err(error) if error.kind() == AdminClientErrorKind::ServerFailure => { + Err(conflict_failure()) + } + Err(_) => Err(ProcessFailure( + RhiProcessResult::ServiceOrDependencyUnavailable, + )), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn paged_target(path: &str, page: &crate::RhiPageQueryArgsV1) -> String { + let mut target = format!("{path}?limit={}", page.limit()); + if let Some(cursor) = page.cursor() { + target.push_str("&cursor="); + target.push_str(cursor); + } + target +} + +fn resolve_runtime(invocation: &RhiCliInvocationV1) -> ProcessResult<RhiRuntimeContext> { + let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), host_environment()); + resolve_rhi_runtime_context(&resolver, invocation).map_err(|_| input_failure()) +} + +fn migration_build_identity() -> ProcessResult<MigrationBuildIdentity> { + let versions = ContractVersions::new( + RHI_CONFIG_SCHEMA_VERSION, + RHI_STATE_SCHEMA_VERSION, + RHI_ADMIN_CONTRACT_VERSION, + RHI_STATUS_CONTRACT_VERSION, + RHI_PROVIDER_CONTRACT_VERSION, + ) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?; + let build = radroots_service_host::compile_time_build_info!( + feature_profile: "service-host", + contract_versions: versions, + ) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?; + MigrationBuildIdentity::new( + build.service_version(), + build.service_commit(), + build.lib_revision(), + build.rust_version(), + build.target(), + build.feature_profile(), + versions.config(), + versions.state(), + versions.admin(), + versions.status(), + versions.provider(), + ) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal)) +} + +fn source_generation() -> ProcessResult<SourceGeneration> { + for _ in 0..4 { + let mut bytes = [0_u8; 32]; + SystemEntropy + .fill_bytes(&mut bytes) + .map_err(|_| state_failure())?; + if let Ok(generation) = SourceGeneration::new(bytes) { + return Ok(generation); + } + } + Err(state_failure()) +} + +fn wall_time_seconds() -> ProcessResult<u64> { + SystemWallClock + .now_utc() + .map(|time| time.get()) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal)) +} + +fn wall_time_millis() -> ProcessResult<u64> { + wall_time_seconds()? + .checked_mul(1_000) + .filter(|value| *value <= i64::MAX as u64) + .ok_or(ProcessFailure(RhiProcessResult::UnexpectedInternal)) +} + +fn migration_time() -> ProcessResult<MigrationAppliedAtUnixSeconds> { + MigrationAppliedAtUnixSeconds::new(wall_time_seconds()?) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal)) +} + +fn build_tokio_runtime( + limits: crate::RhiRuntimeThreadLimitsV1, +) -> ProcessResult<tokio::runtime::Runtime> { + if tokio::runtime::Handle::try_current().is_ok() { + return Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)); + } + tokio::runtime::Builder::new_multi_thread() + .worker_threads(limits.worker_threads()) + .max_blocking_threads(limits.blocking_threads()) + .enable_all() + .build() + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal)) +} + +fn host_environment() -> RadrootsHostEnvironment { + let path = |name| { + env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }; + RadrootsHostEnvironment { + home_dir: path("HOME"), + xdg_config_home: path("XDG_CONFIG_HOME"), + xdg_data_home: path("XDG_DATA_HOME"), + xdg_state_home: path("XDG_STATE_HOME"), + xdg_cache_home: path("XDG_CACHE_HOME"), + xdg_runtime_dir: path("XDG_RUNTIME_DIR"), + appdata_dir: path("APPDATA"), + localappdata_dir: path("LOCALAPPDATA"), + } +} + +fn read_bounded_stdin(maximum: usize) -> ProcessResult<Vec<u8>> { + let mut reader = std::io::stdin().lock(); + let mut bytes = Vec::with_capacity(maximum.min(64 * 1_024).saturating_add(1)); + Read::by_ref(&mut reader) + .take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| input_failure())?; + if bytes.len() > maximum { + return Err(input_failure()); + } + Ok(bytes) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn read_bounded_file(path: &Path, maximum: usize) -> ProcessResult<Vec<u8>> { + crate::config_loader::read_secure_bounded_file(path, maximum).map_err(|_| state_failure()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn read_bounded_file(_path: &Path, _maximum: usize) -> ProcessResult<Vec<u8>> { + Err(state_failure()) +} + +fn decode_hex_32(value: &str) -> ProcessResult<[u8; 32]> { + if value.len() != 64 { + return Err(input_failure()); + } + let mut output = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + output[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?; + } + Ok(output) +} + +const fn hex_nibble(value: u8) -> ProcessResult<u8> { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + _ => Err(input_failure()), + } +} + +fn emit_simple_success( + output: RhiCliOutputModeV1, + code: &'static str, +) -> ProcessResult<RhiProcessResult> { + emit_value(output, code, json!({"ok": true})) +} + +fn emit_value( + output: RhiCliOutputModeV1, + code: &'static str, + value: Value, +) -> ProcessResult<RhiProcessResult> { + let bytes = match output { + RhiCliOutputModeV1::Json => serde_json::to_vec(&value) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?, + RhiCliOutputModeV1::Human => code.as_bytes().to_vec(), + }; + emit_bytes(&bytes) +} + +fn emit_bytes(bytes: &[u8]) -> ProcessResult<RhiProcessResult> { + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(bytes) + .and_then(|()| { + if bytes.ends_with(b"\n") { + Ok(()) + } else { + stdout.write_all(b"\n") + } + }) + .and_then(|()| stdout.flush()) + .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?; + Ok(RhiProcessResult::Success) +} + +const fn input_failure() -> ProcessFailure { + ProcessFailure(RhiProcessResult::InputOrConfiguration) +} + +const fn state_failure() -> ProcessFailure { + ProcessFailure(RhiProcessResult::StateOrIdentityUnavailable) +} + +const fn conflict_failure() -> ProcessFailure { + ProcessFailure(RhiProcessResult::OperationRejectedOrConflict) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_limits_are_explicit_and_digest_decoding_is_strict() { + let source = include_str!("process_v1.rs") + .split("#[cfg(test)]") + .next() + .expect("production source"); + assert!(source.contains("worker_threads(limits.worker_threads())")); + assert!(source.contains("max_blocking_threads(limits.blocking_threads())")); + assert!(!source.contains("available_parallelism")); + assert_eq!( + decode_hex_32(&"ab".repeat(32)).expect("lowercase digest"), + [0xab; 32] + ); + assert!(decode_hex_32(&"AB".repeat(32)).is_err()); + } + + #[test] + fn nested_tokio_runtime_creation_fails_closed_without_panicking() { + let configuration = crate::parse_rhi_config_v1( + include_bytes!("../contracts/services_hardening/config.v1.example.toml"), + crate::RhiConfigProfile::Production, + ) + .expect("configuration fixture"); + let outer = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("outer runtime"); + let result = + outer.block_on(async { build_tokio_runtime(configuration.runtime_thread_limits()) }); + assert_eq!( + result.expect_err("nested runtime must be rejected"), + ProcessFailure(RhiProcessResult::UnexpectedInternal) + ); + } +} diff --git a/src/reconciliation_attestation.rs b/src/reconciliation_attestation.rs @@ -157,6 +157,52 @@ impl RhiEvidenceAttestationSupersession { event_id: EventId::from_bytes(attestation.event_id), } } + + #[cfg(any(test, target_os = "linux", target_os = "macos"))] + pub(crate) fn from_persisted( + trade_id: &radroots_event::id::TradeId, + statement_sha256: [u8; 32], + event_id: [u8; 32], + canonical_report: &[u8], + canonical_event_json: &[u8], + ) -> Result<Self, RhiReconciliationAttestationError> { + let report = RadrootsRhiEvidenceReportV1::from_canonical_content(canonical_report) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?; + if report.trade_id() != trade_id + || report.statement_digest().as_bytes() != &statement_sha256 + { + return Err(failure( + RhiReconciliationAttestationErrorKind::SupersessionInvalid, + )); + } + let source = core::str::from_utf8(canonical_event_json) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?; + let wire = Nip01EventWire::parse_json_unverified_with_limits(source, signed_event_limits()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?; + let event = wire + .into_unverified_envelope() + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?; + if verify_id(&event) != Verification::IdVerified + || verify(&event) != Verification::Verified + || event.id().as_bytes() != &event_id + || *event.author() != report.issuer_public_key() + { + return Err(failure( + RhiReconciliationAttestationErrorKind::SupersessionInvalid, + )); + } + let typed = rhi_evidence_attestation_from_event(&event) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?; + if typed.canonical_content() != report.canonical_content() { + return Err(failure( + RhiReconciliationAttestationErrorKind::SupersessionInvalid, + )); + } + Ok(Self { + report, + event_id: EventId::from_bytes(event_id), + }) + } } impl fmt::Debug for RhiEvidenceAttestationSupersession { @@ -548,6 +594,46 @@ mod tests { } #[test] + fn persisted_supersession_revalidates_canonical_report_and_signature() { + let value: serde_json::Value = serde_json::from_str(SIGNED_VECTOR).expect("signed vector"); + let content = value["content"].as_str().expect("report content"); + let report = RadrootsRhiEvidenceReportV1::from_canonical_content(content.as_bytes()) + .expect("canonical report"); + let event = verify_signed_event_plan(&vector_plan(), SIGNED_VECTOR.trim_end().as_bytes()) + .expect("verified event"); + let supersession = RhiEvidenceAttestationSupersession::from_persisted( + report.trade_id(), + *report.statement_digest().as_bytes(), + *event.id().as_bytes(), + content.as_bytes(), + SIGNED_VECTOR.trim_end().as_bytes(), + ) + .expect("verified persisted supersession"); + assert_eq!( + format!("{supersession:?}"), + "RhiEvidenceAttestationSupersession([redacted])" + ); + + for (statement, event_id) in [ + ([0; 32], *event.id().as_bytes()), + (*report.statement_digest().as_bytes(), [0; 32]), + ] { + assert_eq!( + RhiEvidenceAttestationSupersession::from_persisted( + report.trade_id(), + statement, + event_id, + content.as_bytes(), + SIGNED_VECTOR.trim_end().as_bytes(), + ) + .expect_err("mismatched persisted identity") + .kind(), + RhiReconciliationAttestationErrorKind::SupersessionInvalid + ); + } + } + + #[test] fn every_public_error_class_is_source_free_and_redacted() { for kind in [ RhiReconciliationAttestationErrorKind::InvalidInput, diff --git a/src/reconciliation_replay.rs b/src/reconciliation_replay.rs @@ -143,6 +143,44 @@ impl RhiReconciliationSourceCursorEvidence { } } +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) async fn read_committed_reconciliation_cursor( + repositories: &crate::RhiStateRepositories<'_>, + request: &crate::RhiReconciliationSourceRequest, + policy: crate::RhiEvidencePolicyDigest, +) -> Result<Option<RhiReconciliationSourceCursorEvidence>, ()> { + let source_id: Box<str> = request.source_id().into(); + let trade_id = request.trade_id(); + let selector_digest = *request.selector_digest().as_bytes(); + repositories + .host() + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + crate::source_ingest::read_checkpoint( + transaction, + source_id.as_ref(), + policy, + trade_id, + ) + .await + .map(|checkpoint| { + checkpoint.map(|checkpoint| { + committed_cursor_evidence( + source_id, + trade_id, + *policy.as_bytes(), + selector_digest, + checkpoint.cursor, + ) + }) + }) + }) + }) + .await + .map_err(|_| ()) +} + impl fmt::Debug for RhiReconciliationSourceCursorEvidence { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter diff --git a/src/runtime_adapters.rs b/src/runtime_adapters.rs @@ -154,6 +154,16 @@ pub struct RhiTimeEntropyAdapters { entropy: Arc<dyn EntropySource>, } +impl Clone for RhiTimeEntropyAdapters { + fn clone(&self) -> Self { + Self { + wall: Arc::clone(&self.wall), + monotonic: Arc::clone(&self.monotonic), + entropy: Arc::clone(&self.entropy), + } + } +} + impl RhiTimeEntropyAdapters { /// Owns injected adapters without reading a clock or entropy source. pub fn new<W, M, E>(wall: W, monotonic: M, entropy: E) -> Self @@ -192,6 +202,11 @@ impl RhiTimeEntropyAdapters { }) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn entropy(&self) -> &dyn EntropySource { + self.entropy.as_ref() + } + /// Reads one observation from the injected process-local monotonic domain. #[must_use] pub fn now_monotonic(&self) -> MonotonicTime { @@ -251,8 +266,18 @@ impl fmt::Debug for RhiTimeEntropyAdapters { /// do not become public runtime authority. pub struct RhiTransportAdapters { evidence_source: Arc<dyn EventSource>, - _evidence_subscriber: Arc<dyn EventSubscriber>, - _publication_sink: Arc<dyn EventSink>, + evidence_subscriber: Arc<dyn EventSubscriber>, + publication_sink: Arc<dyn EventSink>, +} + +impl Clone for RhiTransportAdapters { + fn clone(&self) -> Self { + Self { + evidence_source: Arc::clone(&self.evidence_source), + evidence_subscriber: Arc::clone(&self.evidence_subscriber), + publication_sink: Arc::clone(&self.publication_sink), + } + } } impl RhiTransportAdapters { @@ -265,14 +290,19 @@ impl RhiTransportAdapters { ) -> Self { Self { evidence_source, - _evidence_subscriber: evidence_subscriber, - _publication_sink: publication_sink, + evidence_subscriber, + publication_sink, } } pub(crate) fn evidence_source(&self) -> &dyn EventSource { self.evidence_source.as_ref() } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn evidence_subscriber(&self) -> &dyn EventSubscriber { + self.evidence_subscriber.as_ref() + } } impl fmt::Debug for RhiTransportAdapters { @@ -388,7 +418,7 @@ impl fmt::Debug for RhiIdentityCredentialAdapters { #[must_use = "runtime adapters retain join-owned task authority"] pub struct RhiRuntimeAdapters { time_entropy: RhiTimeEntropyAdapters, - _transport: RhiTransportAdapters, + transport: RhiTransportAdapters, identity_credential: RhiIdentityCredentialAdapters, supervisor: TaskSupervisor, } @@ -402,7 +432,7 @@ impl RhiRuntimeAdapters { ) -> Self { Self { time_entropy, - _transport: transport, + transport, identity_credential, supervisor: TaskSupervisor::new(), } @@ -420,6 +450,11 @@ impl RhiRuntimeAdapters { &self.identity_credential } + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) const fn transport(&self) -> &RhiTransportAdapters { + &self.transport + } + /// Returns the number of join-owned tasks currently registered. #[must_use] pub fn supervised_task_count(&self) -> usize { @@ -435,7 +470,7 @@ impl RhiRuntimeAdapters { .map_err(|_| ()) } - #[cfg(test)] + #[cfg(any(test, target_os = "linux", target_os = "macos"))] pub(crate) fn supervisor_mut(&mut self) -> &mut TaskSupervisor { &mut self.supervisor } @@ -443,6 +478,7 @@ impl RhiRuntimeAdapters { impl fmt::Debug for RhiRuntimeAdapters { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let _ = &self.transport; formatter .debug_struct("RhiRuntimeAdapters") .field("time_entropy", &"[injected]") diff --git a/src/runtime_admin.rs b/src/runtime_admin.rs @@ -0,0 +1,2023 @@ +//! State-backed implementation of the final RHI Unix-admin contract. + +use core::sync::atomic::{AtomicBool, Ordering}; +use std::{path::PathBuf, sync::Arc}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, Mac as _}; +use radroots_event::id::TradeId; +use radroots_service_sqlite::BackupCreatedAtUnixMs; +use serde_json::{Map, Value, json}; +use sha2::{Digest as _, Sha256}; +use sqlx::Row; + +use crate::{ + RhiAdminFuture, RhiAdminHandler, RhiAdminHandlerError, RhiAdminHandlerErrorKind, + RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiConfigDocumentV1, + RhiDecryptedIdentity, RhiPresenceDesiredAuthority, RhiPresenceUnixMilliseconds, + RhiPublicationAuthority, RhiReconciliationJobErrorKind, RhiReconciliationJobPolicy, + RhiReconciliationUnixMilliseconds, RhiStateHost, RhiStatusReader, RhiTimeEntropyAdapters, + build_rhi_signed_presence_documents, + state_admin::{ + AdminJournalOperationError, RhiAdminOperationAdmission, RhiAdminOperationError, + RhiAdminOperationErrorKind, RhiAdminOperationJournalPolicy, RhiAdminOperationRepository, + RhiAdminOperationTimeUnixMs, + }, + state_config, +}; + +const REPORT_SELECT: &str = r#"SELECT + length(report.statement_sha256) AS statement_bytes, + substr(report.statement_sha256, 1, 33) AS statement_sha256, + length(report.manifest_sha256) AS manifest_bytes, + substr(report.manifest_sha256, 1, 33) AS manifest_sha256, + length(report.projection_sha256) AS projection_bytes, + substr(report.projection_sha256, 1, 33) AS projection_sha256, + length(report.trade_id) AS trade_id_bytes, + substr(report.trade_id, 1, 17) AS trade_id, + length(report.claim_mutation_id) AS claim_bytes, + substr(report.claim_mutation_id, 1, 33) AS claim_mutation_id, + length(report.issuer_public_key) AS issuer_bytes, + substr(report.issuer_public_key, 1, 33) AS issuer_public_key, + report.outcome, + length(report.canonical_report) AS canonical_report_bytes, + substr(report.canonical_report, 1, 16385) AS canonical_report, + report.observed_at_unix_s, + CASE WHEN report.supersedes_statement_sha256 IS NULL THEN NULL + ELSE length(report.supersedes_statement_sha256) END AS supersedes_bytes, + CASE WHEN report.supersedes_statement_sha256 IS NULL THEN NULL + ELSE substr(report.supersedes_statement_sha256, 1, 33) END AS supersedes_statement_sha256, + length(event.event_id) AS event_id_bytes, + substr(event.event_id, 1, 33) AS event_id, + manifest.trade_generation, + length(manifest.evidence_policy_sha256) AS policy_bytes, + substr(manifest.evidence_policy_sha256, 1, 33) AS evidence_policy_sha256, + projection.reducer_contract, + projection.reducer_contract_version, + (SELECT COUNT(*) FROM evidence_reconciliation_sources AS source + WHERE source.attempt_id = manifest.attempt_id) AS source_count, + (SELECT COUNT(*) FROM evidence_reconciliation_sources AS source + WHERE source.attempt_id = manifest.attempt_id AND source.required = 1 + AND source.completion != 'complete') AS incomplete_required, + (SELECT COUNT(*) FROM evidence_reconciliation_sources AS source + WHERE source.attempt_id = manifest.attempt_id AND source.required = 1 + AND source.completion = 'unsupported') AS unsupported_required, + (SELECT COALESCE(SUM(source.accepted_event_count), 0) + FROM evidence_reconciliation_sources AS source + WHERE source.attempt_id = manifest.attempt_id) AS accepted_event_count +FROM attestation_reports AS report +JOIN signed_attestation_events AS event + ON event.statement_sha256 = report.statement_sha256 +JOIN evidence_manifests AS manifest + ON manifest.manifest_sha256 = report.manifest_sha256 +JOIN trade_projections AS projection + ON projection.projection_sha256 = report.projection_sha256 +"#; + +const REPORT_ROWS_SUFFIX: &str = r#"WHERE report.trade_id = ? AND report.observed_at_unix_s <= ? + AND (? IS NULL OR report.outcome = ?) + ORDER BY report.observed_at_unix_s DESC, report.statement_sha256 DESC + LIMIT ? OFFSET ?"#; + +const CURRENT_REPORT_SUFFIX: &str = r#"WHERE report.trade_id = ? + AND NOT EXISTS ( + SELECT 1 FROM attestation_reports AS successor + WHERE successor.supersedes_statement_sha256 = report.statement_sha256 + ) + ORDER BY report.observed_at_unix_s DESC, report.statement_sha256 DESC + LIMIT 2"#; + +const PUBLICATION_BACKLOG_SQL: &str = r#"SELECT + length(outbox.outbox_id) AS outbox_id_bytes, + substr(outbox.outbox_id, 1, 33) AS outbox_id, + length(event.statement_sha256) AS statement_bytes, + substr(event.statement_sha256, 1, 33) AS statement_sha256, + length(outbox.event_id) AS event_id_bytes, + substr(outbox.event_id, 1, 33) AS event_id, + length(outbox.event_sha256) AS event_sha256_bytes, + substr(outbox.event_sha256, 1, 33) AS event_sha256, + outbox.state AS outbox_state, + outbox.next_attempt_unix_ms, + COALESCE(MAX(target.attempt_count), 0) AS attempt_count, + CASE + WHEN outbox.state = 'pending' THEN 'pending' + WHEN outbox.state = 'leased' THEN 'submitted' + WHEN outbox.state = 'complete' THEN 'accepted' + WHEN SUM(CASE WHEN target.state = 'auth_required' THEN 1 ELSE 0 END) > 0 THEN 'auth_required' + WHEN SUM(CASE WHEN target.state = 'rate_limited' THEN 1 ELSE 0 END) > 0 THEN 'rate_limited' + WHEN SUM(CASE WHEN target.state = 'rejected' THEN 1 ELSE 0 END) > 0 THEN 'rejected' + WHEN SUM(CASE WHEN target.state = 'failed' THEN 1 ELSE 0 END) > 0 THEN 'failed' + ELSE 'unknown' + END AS public_state +FROM publication_outbox AS outbox +JOIN signed_attestation_events AS event ON event.event_id = outbox.event_id +JOIN publication_targets AS target ON target.outbox_id = outbox.outbox_id +WHERE outbox.updated_at_unix_ms <= ? +GROUP BY outbox.outbox_id +HAVING (? IS NULL OR public_state = ?) +ORDER BY outbox.updated_at_unix_ms DESC, outbox.outbox_id DESC +LIMIT ? OFFSET ?"#; + +const PUBLICATION_TARGETS_SQL: &str = r#"SELECT + length(target.outbox_id) AS outbox_id_bytes, + substr(target.outbox_id, 1, 33) AS outbox_id, + target.relay_id, target.state, target.attempt_count, + (SELECT MAX(attempt.finished_at_unix_ms) + FROM publication_attempts AS attempt + WHERE attempt.outbox_id = target.outbox_id + AND attempt.target_ordinal = target.target_ordinal) AS last_attempt_unix_ms +FROM publication_targets AS target +WHERE target.updated_at_unix_ms <= ? + AND (? IS NULL OR target.outbox_id = ?) + AND (? IS NULL OR target.state = ?) +ORDER BY target.updated_at_unix_ms DESC, target.outbox_id DESC, target.target_ordinal +LIMIT ? OFFSET ?"#; + +pub(crate) struct RuntimeAdminHandler { + pub(crate) state: Arc<RhiStateHost>, + pub(crate) configuration: Arc<RhiConfigDocumentV1>, + pub(crate) identity: Arc<RhiDecryptedIdentity>, + pub(crate) publication: Arc<RhiPublicationAuthority>, + pub(crate) presence: Arc<RhiPresenceDesiredAuthority>, + pub(crate) status: RhiStatusReader, + pub(crate) accepting_mutations: Arc<AtomicBool>, + pub(crate) cursor_key: [u8; 32], + pub(crate) time_entropy: RhiTimeEntropyAdapters, +} + +impl RuntimeAdminHandler { + async fn handle_inner( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + if request.route().is_mutation() && !self.accepting_mutations.load(Ordering::Acquire) { + return Err(failure(RhiAdminHandlerErrorKind::Unavailable)); + } + match request.route() { + RhiAdminRoute::Status => { + let snapshot = self.status.snapshot(); + let value = serde_json::from_slice(snapshot.detailed_status_json()) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + response(request.route(), value) + } + RhiAdminRoute::EffectiveConfig => RhiAdminResponseDocument::from_canonical_bytes( + request.route(), + self.configuration.effective().canonical_json().as_bytes(), + ) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)), + RhiAdminRoute::IdentityStatus | RhiAdminRoute::IdentityPublic => { + self.identity(&request).await + } + RhiAdminRoute::StateStatus => self.state_status(request.route()).await, + RhiAdminRoute::StateBackup => self.backup(request).await, + RhiAdminRoute::MetricsSnapshot => self.metrics_snapshot(request.route()), + RhiAdminRoute::ReconciliationStatus => self.reconciliation_status(request).await, + RhiAdminRoute::ReconciliationJobs => self.reconciliation_jobs(request).await, + RhiAdminRoute::ReconciliationRefresh => self.reconciliation_refresh(request).await, + RhiAdminRoute::Sources => self.sources(request).await, + RhiAdminRoute::TradeProjection => self.trade_projection(request).await, + RhiAdminRoute::TradeReportCurrent => self.trade_report_current(request).await, + RhiAdminRoute::TradeReports => self.trade_reports(request).await, + RhiAdminRoute::PublicationBacklog => self.publication_backlog(request).await, + RhiAdminRoute::PublicationTargets => self.publication_targets(request).await, + RhiAdminRoute::PublicationRetry => self.publication_retry(request).await, + RhiAdminRoute::PresenceDesired => self.presence_desired(request.route()).await, + RhiAdminRoute::PresenceRender => self.presence_render(request).await, + RhiAdminRoute::PresenceRefresh => self.presence_refresh(request).await, + } + } + + async fn identity( + &self, + request: &RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(request)?; + if model.pointer("/role").and_then(Value::as_str) != Some("service") { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + let generation = current_generation(&self.state).await?; + let value = if request.route() == RhiAdminRoute::IdentityPublic { + json!({ + "generation": generation, + "public_key": self.identity.public_identity().as_hex(), + "role": "service", + }) + } else { + json!({ + "available": true, + "configured": true, + "generation": generation, + "provider": "encrypted_file", + "public_key": self.identity.public_identity().as_hex(), + "reason_codes": [], + "role": "service", + }) + }; + response(request.route(), value) + } + + async fn state_status( + &self, + route: RhiAdminRoute, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + response( + route, + json!({ + "backup_eligible": true, + "generation": current_generation(&self.state).await?, + "integrity": "verified", + "reason_codes": [], + "schema_version": self.state.metadata().initial_database_metadata().state_schema_version().get(), + "writer_lock": "held_by_daemon", + }), + ) + } + + fn metrics_snapshot( + &self, + route: RhiAdminRoute, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let snapshot = self.status.operations_cache().snapshot(); + let mut metrics = Map::new(); + for sample in snapshot.metrics().samples() { + let mut key = sample.name().as_str().to_owned(); + for label in sample.labels() { + key.push('_'); + key.push_str(label.value()); + } + let value = match sample.value() { + radroots_service_host::MetricValue::Counter(value) => value, + radroots_service_host::MetricValue::Gauge(value) => { + u64::try_from(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))? + } + }; + if metrics.insert(key, Value::from(value)).is_some() { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + } + response( + route, + json!({ + "captured_at_utc": self.now_seconds()?, + "metrics": metrics, + }), + ) + } + + async fn backup( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let expected_generation = required_u64(&model, "/expected_generation")?; + let generation = current_generation(&self.state).await?; + if expected_generation != generation { + return Err(failure(RhiAdminHandlerErrorKind::Conflict)); + } + let now_ms = self.now_millis()?; + let prepared = match RhiAdminOperationRepository::new(&self.state) + .prepare_admin_operation(&request, operation_time(now_ms)?) + .await + .map_err(map_journal_error)? + { + RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response), + RhiAdminOperationAdmission::Prepared(prepared) => prepared, + }; + let target = model + .pointer("/target_path") + .and_then(Value::as_str) + .map(PathBuf::from) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?; + let manifest = self + .state + .capture_online_backup( + &target, + BackupCreatedAtUnixMs::new(now_ms) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))?; + let completed_ms = self.now_millis()?; + let completed_seconds = completed_ms / 1_000; + let completed = response( + request.route(), + json!({ + "completed_at_utc": completed_seconds, + "manifest_digest": lower_hex(manifest.digest().as_bytes()), + "operation_id": required_operation_id(&request)?, + "snapshot_generation": generation, + }), + )?; + RhiAdminOperationRepository::new(&self.state) + .complete_admin_operation( + &prepared, + &completed, + operation_time(completed_ms)?, + RhiAdminOperationJournalPolicy::seven_days(), + ) + .await + .map_err(map_journal_error)?; + Ok(completed) + } + + async fn presence_desired( + &self, + route: RhiAdminRoute, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let desired = self + .state + .repositories() + .desired_presence() + .current() + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))? + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::NotFound))?; + let (state, digests) = self.presence_state(desired.generation()).await?; + response( + route, + json!({ + "document_digests": digests, + "generation": desired.generation(), + "reason_codes": [], + "state": if desired.mode().code() == "disabled" { "disabled" } else { state }, + }), + ) + } + + async fn presence_render( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let expected = required_u64(&model, "/expected_generation")?; + let desired = self + .state + .repositories() + .desired_presence() + .commit(&self.presence) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if desired.state().generation() != expected { + return Err(failure(RhiAdminHandlerErrorKind::Conflict)); + } + let now_ms = self.now_millis()?; + let prepared = match RhiAdminOperationRepository::new(&self.state) + .prepare_admin_operation(&request, operation_time(now_ms)?) + .await + .map_err(map_journal_error)? + { + RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response), + RhiAdminOperationAdmission::Prepared(prepared) => prepared, + }; + let documents = build_rhi_signed_presence_documents( + desired, + &self.presence, + &self.identity, + self.time_entropy + .now_utc() + .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))?, + self.time_entropy.entropy(), + ) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))?; + let digests = presence_document_digests(&documents); + self.state + .repositories() + .presence_outbox() + .commit_signed_presence( + &documents, + RhiPresenceUnixMilliseconds::new(now_ms) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let completed_ms = self.now_millis()?; + let completed = response( + request.route(), + json!({ + "document_digests": digests, + "generation": expected, + "operation_id": required_operation_id(&request)?, + }), + )?; + RhiAdminOperationRepository::new(&self.state) + .complete_admin_operation( + &prepared, + &completed, + operation_time(completed_ms)?, + RhiAdminOperationJournalPolicy::seven_days(), + ) + .await + .map_err(map_journal_error)?; + Ok(completed) + } + + fn now_millis(&self) -> Result<u64, RhiAdminHandlerError> { + self.time_entropy + .now_utc_milliseconds() + .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable)) + } + + fn now_seconds(&self) -> Result<u64, RhiAdminHandlerError> { + self.time_entropy + .now_utc() + .map(|value| value.get()) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable)) + } + + async fn read_dirty_generation(&self, trade: TradeId) -> Result<u64, RhiAdminHandlerError> { + self.state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let rows = sqlx::query( + "SELECT generation FROM trade_dirty_generations WHERE trade_id = ? LIMIT 2", + ) + .bind(trade.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if rows.len() != 1 { + return Err(if rows.is_empty() { + failure(RhiAdminHandlerErrorKind::NotFound) + } else { + failure(RhiAdminHandlerErrorKind::Internal) + }); + } + row_u64(&rows[0], "generation") + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + } + + async fn current_report_row( + &self, + trade: TradeId, + ) -> Result<sqlx::sqlite::SqliteRow, RhiAdminHandlerError> { + self.state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let sql = format!("{REPORT_SELECT}{CURRENT_REPORT_SUFFIX}"); + // Both fragments are private compile-time constants; no caller data enters SQL. + let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) + .bind(trade.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if rows.len() != 1 { + return Err(if rows.is_empty() { + failure(RhiAdminHandlerErrorKind::NotFound) + } else { + failure(RhiAdminHandlerErrorKind::Internal) + }); + } + Ok(rows.into_iter().next().expect("one row was established")) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + } + + fn cursor_or_new( + &self, + model: &Value, + route: RhiAdminRoute, + query_digest: [u8; 32], + new_snapshot: u64, + ) -> Result<(u64, u32), RhiAdminHandlerError> { + model + .pointer("/cursor") + .and_then(Value::as_str) + .map(|cursor| self.decode_cursor(route, cursor, query_digest)) + .transpose() + .map(|cursor| cursor.unwrap_or((new_snapshot, 0))) + } + + fn encode_cursor( + &self, + route: RhiAdminRoute, + snapshot: u64, + offset: u32, + query_digest: [u8; 32], + ) -> String { + encode_runtime_cursor(&self.cursor_key, route, snapshot, offset, query_digest) + } + + fn decode_cursor( + &self, + route: RhiAdminRoute, + encoded: &str, + query_digest: [u8; 32], + ) -> Result<(u64, u32), RhiAdminHandlerError> { + decode_runtime_cursor(&self.cursor_key, route, encoded, query_digest) + } +} + +impl RhiAdminHandler for RuntimeAdminHandler { + fn handle<'a>(&'a self, request: RhiAdminRequestDocument) -> RhiAdminFuture<'a> { + Box::pin(async move { self.handle_inner(request).await }) + } +} + +fn presence_document_digests(documents: &crate::RhiSignedPresenceDocuments) -> Map<String, Value> { + documents + .documents() + .iter() + .map(|document| { + ( + document.kind().code().to_owned(), + Value::String(lower_hex(document.signed_event_sha256())), + ) + }) + .collect() +} + +fn request_model(request: &RhiAdminRequestDocument) -> Result<Value, RhiAdminHandlerError> { + serde_json::from_slice(request.model_bytes()) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn required_u64(value: &Value, pointer: &str) -> Result<u64, RhiAdminHandlerError> { + value + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn required_operation_id(request: &RhiAdminRequestDocument) -> Result<&str, RhiAdminHandlerError> { + request + .operation_id() + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn operation_time(value: u64) -> Result<RhiAdminOperationTimeUnixMs, RhiAdminHandlerError> { + RhiAdminOperationTimeUnixMs::new(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) +} + +async fn current_generation(state: &RhiStateHost) -> Result<u64, RhiAdminHandlerError> { + state_config::current_generation(state) + .await + .map(u64::from) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn response( + route: RhiAdminRoute, + value: Value, +) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let bytes = + serde_json::to_vec(&value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + RhiAdminResponseDocument::from_canonical_bytes(route, &bytes) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn map_journal_error(error: RhiAdminOperationError) -> RhiAdminHandlerError { + failure(match error.kind() { + RhiAdminOperationErrorKind::OperationConflict => { + RhiAdminHandlerErrorKind::OperationIdConflict + } + RhiAdminOperationErrorKind::ResourceExhausted => RhiAdminHandlerErrorKind::Unavailable, + RhiAdminOperationErrorKind::OperationOutcomeUnknown + | RhiAdminOperationErrorKind::CommitOutcomeUnknown => RhiAdminHandlerErrorKind::Unavailable, + RhiAdminOperationErrorKind::InvalidMode + | RhiAdminOperationErrorKind::InvalidInput + | RhiAdminOperationErrorKind::Binding + | RhiAdminOperationErrorKind::Transaction => RhiAdminHandlerErrorKind::Internal, + }) +} + +const fn failure(kind: RhiAdminHandlerErrorKind) -> RhiAdminHandlerError { + RhiAdminHandlerError::new(kind) +} + +fn lower_hex(bytes: &[u8]) -> String { + use core::fmt::Write as _; + + let mut encoded = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn parse_trade_id(value: &str) -> Result<TradeId, RhiAdminHandlerError> { + TradeId::parse(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn parse_digest(value: &str) -> Result<[u8; 32], RhiAdminHandlerError> { + if value.len() != 64 + || value + .as_bytes() + .iter() + .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase()) + { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + let mut bytes = [0_u8; 32]; + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let high = + hex_nibble(chunk[0]).ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?; + let low = + hex_nibble(chunk[1]).ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?; + bytes[index] = (high << 4) | low; + } + Ok(bytes) +} + +const fn hex_nibble(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +fn page_limit(model: &Value) -> Result<u16, RhiAdminHandlerError> { + model + .pointer("/limit") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| (1..=200).contains(value)) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn query_digest(model: &Value) -> Result<[u8; 32], RhiAdminHandlerError> { + let mut model = model.clone(); + model + .as_object_mut() + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))? + .remove("cursor"); + let bytes = + serde_json::to_vec(&model).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + Ok(Sha256::digest(bytes).into()) +} + +fn cursor_authenticator(key: &[u8; 32], payload: &[u8]) -> Hmac<Sha256> { + let mut digest = Hmac::<Sha256>::new_from_slice(key).expect("SHA-256 HMAC accepts every key"); + digest.update(b"radroots.rhi.admin_cursor.v1\0"); + digest.update( + &u64::try_from(payload.len()) + .expect("cursor payload length fits u64") + .to_be_bytes(), + ); + digest.update(payload); + digest +} + +fn cursor_tag(key: &[u8; 32], payload: &[u8]) -> [u8; 32] { + cursor_authenticator(key, payload) + .finalize() + .into_bytes() + .into() +} + +fn encode_runtime_cursor( + key: &[u8; 32], + route: RhiAdminRoute, + snapshot: u64, + offset: u32, + query_digest: [u8; 32], +) -> String { + let mut bytes = Vec::with_capacity(78); + bytes.push(1); + bytes.push(route_code(route)); + bytes.extend_from_slice(&snapshot.to_be_bytes()); + bytes.extend_from_slice(&offset.to_be_bytes()); + bytes.extend_from_slice(&query_digest); + let tag = cursor_tag(key, &bytes); + bytes.extend_from_slice(&tag); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn decode_runtime_cursor( + key: &[u8; 32], + route: RhiAdminRoute, + encoded: &str, + query_digest: [u8; 32], +) -> Result<(u64, u32), RhiAdminHandlerError> { + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?; + if bytes.len() != 78 + || bytes[0] != 1 + || bytes[1] != route_code(route) + || bytes[14..46] != query_digest + { + return Err(failure(RhiAdminHandlerErrorKind::InvalidCursor)); + } + cursor_authenticator(key, &bytes[..46]) + .verify_slice(&bytes[46..]) + .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?; + let snapshot = u64::from_be_bytes( + bytes[2..10] + .try_into() + .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?, + ); + let offset = u32::from_be_bytes( + bytes[10..14] + .try_into() + .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?, + ); + Ok((snapshot, offset)) +} + +const fn route_code(route: RhiAdminRoute) -> u8 { + match route { + RhiAdminRoute::Status => 0, + RhiAdminRoute::EffectiveConfig => 1, + RhiAdminRoute::IdentityStatus => 2, + RhiAdminRoute::IdentityPublic => 3, + RhiAdminRoute::StateStatus => 4, + RhiAdminRoute::StateBackup => 5, + RhiAdminRoute::MetricsSnapshot => 6, + RhiAdminRoute::ReconciliationStatus => 7, + RhiAdminRoute::ReconciliationJobs => 8, + RhiAdminRoute::ReconciliationRefresh => 9, + RhiAdminRoute::Sources => 10, + RhiAdminRoute::TradeProjection => 11, + RhiAdminRoute::TradeReportCurrent => 12, + RhiAdminRoute::TradeReports => 13, + RhiAdminRoute::PublicationBacklog => 14, + RhiAdminRoute::PublicationTargets => 15, + RhiAdminRoute::PublicationRetry => 16, + RhiAdminRoute::PresenceDesired => 17, + RhiAdminRoute::PresenceRender => 18, + RhiAdminRoute::PresenceRefresh => 19, + } +} + +fn row_u64(row: &sqlx::sqlite::SqliteRow, name: &str) -> Result<u64, RhiAdminHandlerError> { + row.try_get::<i64, _>(name) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + .and_then(|value| { + u64::try_from(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + }) +} + +fn optional_nonnegative_i64( + row: &sqlx::sqlite::SqliteRow, + name: &str, +) -> Result<Option<u64>, RhiAdminHandlerError> { + row.try_get::<Option<i64>, _>(name) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))? + .map(|value| u64::try_from(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))) + .transpose() +} + +fn exact_blob<const N: usize>( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, +) -> Result<[u8; N], RhiAdminHandlerError> { + if row_u64(row, length)? != N as u64 { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + row.try_get::<Vec<u8>, _>(value) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))? + .try_into() + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) +} + +fn optional_exact_blob<const N: usize>( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, +) -> Result<Option<[u8; N]>, RhiAdminHandlerError> { + let length = row + .try_get::<Option<i64>, _>(length) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + match length { + None => { + if row + .try_get::<Option<Vec<u8>>, _>(value) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))? + .is_some() + { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + Ok(None) + } + Some(length) if length == N as i64 => row + .try_get::<Option<Vec<u8>>, _>(value) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))? + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))? + .try_into() + .map(Some) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)), + Some(_) => Err(failure(RhiAdminHandlerErrorKind::Internal)), + } +} + +fn bounded_blob( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, + maximum: usize, +) -> Result<Box<[u8]>, RhiAdminHandlerError> { + let length = row_u64(row, length)?; + if length == 0 || length > maximum as u64 { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + let bytes = row + .try_get::<Vec<u8>, _>(value) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if bytes.len() as u64 != length { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + Ok(bytes.into_boxed_slice()) +} + +fn job_summary(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> { + let job_id = exact_blob::<32>(row, "job_id", "job_id_bytes")?; + let trade_id = exact_blob::<16>(row, "trade_id", "trade_id_bytes")?; + let attempt_count = row_u64(row, "attempt_count")?; + let state = row + .try_get::<&str, _>("state") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let state = match state { + "ready" if attempt_count == 0 => "pending", + "ready" => "retry_scheduled", + "leased" => "leased", + "completed" => "completed", + "exhausted" | "superseded" => "failed", + _ => return Err(failure(RhiAdminHandlerErrorKind::Internal)), + }; + let mut value = json!({ + "attempt_count": attempt_count, + "dirty_generation": row_u64(row, "input_generation")?, + "job_id": lower_hex(&job_id), + "reason_codes": [], + "scheduled_at_utc": row_u64(row, "created_at_unix_ms")? / 1_000, + "state": state, + "trade_id": lower_hex(&trade_id), + }); + if let Some(expires) = optional_nonnegative_i64(row, "lease_expires_unix_ms")? { + value["lease_expires_at_utc"] = Value::from(expires / 1_000); + } + Ok(value) +} + +struct ConfiguredSourceSummary { + source_id: Box<str>, + required: bool, +} + +fn configured_source_summaries( + configuration: &RhiConfigDocumentV1, +) -> Result<Vec<ConfiguredSourceSummary>, RhiAdminHandlerError> { + let sources = configuration + .normalized() + .pointer("/evidence/sources") + .and_then(Value::as_array) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?; + if sources.is_empty() || sources.len() > 16 { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + sources + .iter() + .map(|source| { + let source_id = source + .pointer("/source_id") + .and_then(Value::as_str) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?; + let required = source + .pointer("/required") + .and_then(Value::as_bool) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?; + Ok(ConfiguredSourceSummary { + source_id: source_id.into(), + required, + }) + }) + .collect() +} + +fn request_trade_id(request: &RhiAdminRequestDocument) -> Result<TradeId, RhiAdminHandlerError> { + request + .parameter("trade_id") + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal)) + .and_then(parse_trade_id) +} + +fn contract_outcome_to_db(value: &str) -> Result<&'static str, RhiAdminHandlerError> { + match value { + "Valid" => Ok("valid"), + "Invalid" => Ok("invalid"), + "Indeterminate" => Ok("indeterminate"), + _ => Err(failure(RhiAdminHandlerErrorKind::Internal)), + } +} + +fn db_outcome_to_contract(value: &str) -> Result<&'static str, RhiAdminHandlerError> { + match value { + "valid" => Ok("Valid"), + "invalid" => Ok("Invalid"), + "indeterminate" => Ok("Indeterminate"), + _ => Err(failure(RhiAdminHandlerErrorKind::Internal)), + } +} + +fn report_coverage(row: &sqlx::sqlite::SqliteRow) -> Result<&'static str, RhiAdminHandlerError> { + let source_count = row_u64(row, "source_count")?; + let incomplete = row_u64(row, "incomplete_required")?; + let unsupported = row_u64(row, "unsupported_required")?; + let accepted = row_u64(row, "accepted_event_count")?; + if source_count == 0 { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + Ok(if unsupported > 0 { + "Unsupported" + } else if incomplete > 0 && accepted == 0 { + "Missing" + } else if incomplete > 0 { + "Partial" + } else { + "ScopeSatisfied" + }) +} + +fn report_detail(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> { + let canonical = bounded_blob(row, "canonical_report", "canonical_report_bytes", 16_384)?; + let mut report: Value = serde_json::from_slice(&canonical) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let object = report + .as_object_mut() + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?; + let statement = lower_hex(&exact_blob::<32>( + row, + "statement_sha256", + "statement_bytes", + )?); + let manifest = lower_hex(&exact_blob::<32>(row, "manifest_sha256", "manifest_bytes")?); + let projection = lower_hex(&exact_blob::<32>( + row, + "projection_sha256", + "projection_bytes", + )?); + let trade = lower_hex(&exact_blob::<16>(row, "trade_id", "trade_id_bytes")?); + let claim = lower_hex(&exact_blob::<32>(row, "claim_mutation_id", "claim_bytes")?); + let issuer = lower_hex(&exact_blob::<32>(row, "issuer_public_key", "issuer_bytes")?); + let policy = lower_hex(&exact_blob::<32>( + row, + "evidence_policy_sha256", + "policy_bytes", + )?); + let observed = row_u64(row, "observed_at_unix_s")?; + let generation = row_u64(row, "trade_generation")?; + let outcome = row + .try_get::<&str, _>("outcome") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let reducer_contract = row + .try_get::<&str, _>("reducer_contract") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let reducer_version = row_u64(row, "reducer_contract_version")?; + let required_matches = [ + ("report_id", statement.as_str()), + ("evidence_manifest_digest", manifest.as_str()), + ("projection_digest", projection.as_str()), + ("trade_id", trade.as_str()), + ("claim_mutation_id", claim.as_str()), + ("issuer_pubkey", issuer.as_str()), + ("evidence_policy_digest", policy.as_str()), + ("reducer_contract_id", reducer_contract), + ] + .into_iter() + .all(|(field, expected)| object.get(field).and_then(Value::as_str) == Some(expected)); + if !required_matches + || object.get("trade_generation").and_then(Value::as_u64) != Some(generation) + || object.get("observed_at_unix_s").and_then(Value::as_u64) != Some(observed) + || object + .get("reducer_contract_version") + .and_then(Value::as_u64) + != Some(reducer_version) + || object.get("outcome").and_then(Value::as_str) != Some(outcome) + || object.get("statement_digest").and_then(Value::as_str) != Some(statement.as_str()) + { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + let supersedes = + optional_exact_blob::<32>(row, "supersedes_statement_sha256", "supersedes_bytes")?; + match supersedes { + Some(value) + if object.get("supersedes_report_id").and_then(Value::as_str) + == Some(lower_hex(&value).as_str()) => {} + None if object + .get("supersedes_report_id") + .is_some_and(Value::is_null) => + { + object.remove("supersedes_report_id"); + object.remove("supersedes_event_id"); + } + _ => return Err(failure(RhiAdminHandlerErrorKind::Internal)), + } + object.remove("statement_digest"); + object.insert( + "attestation_event_id".to_owned(), + Value::String(lower_hex(&exact_blob::<32>( + row, + "event_id", + "event_id_bytes", + )?)), + ); + object.insert( + "coverage".to_owned(), + Value::String(report_coverage(row)?.to_owned()), + ); + object.insert( + "outcome".to_owned(), + Value::String(db_outcome_to_contract(outcome)?.to_owned()), + ); + Ok(report) +} + +fn report_summary(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> { + let supersedes = + optional_exact_blob::<32>(row, "supersedes_statement_sha256", "supersedes_bytes")?; + let outcome = row + .try_get::<&str, _>("outcome") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let mut value = json!({ + "attestation_event_id": lower_hex(&exact_blob::<32>(row, "event_id", "event_id_bytes")?), + "claim_mutation_id": lower_hex(&exact_blob::<32>(row, "claim_mutation_id", "claim_bytes")?), + "coverage": report_coverage(row)?, + "observed_at_utc": row_u64(row, "observed_at_unix_s")?, + "outcome": db_outcome_to_contract(outcome)?, + "report_id": lower_hex(&exact_blob::<32>(row, "statement_sha256", "statement_bytes")?), + "trade_id": lower_hex(&exact_blob::<16>(row, "trade_id", "trade_id_bytes")?), + }); + if let Some(supersedes) = supersedes { + value["supersedes_report_id"] = Value::String(lower_hex(&supersedes)); + } + Ok(value) +} + +fn publication_summary(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> { + let state = row + .try_get::<&str, _>("public_state") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if !matches!( + state, + "pending" + | "submitted" + | "accepted" + | "rejected" + | "rate_limited" + | "auth_required" + | "failed" + | "unknown" + ) { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + let mut value = json!({ + "attempt_count": row_u64(row, "attempt_count")?, + "event_id": lower_hex(&exact_blob::<32>(row, "event_id", "event_id_bytes")?), + "exact_bytes_digest": lower_hex(&exact_blob::<32>(row, "event_sha256", "event_sha256_bytes")?), + "reason_codes": if state == "failed" || state == "unknown" { vec!["publication_blocked"] } else { Vec::<&str>::new() }, + "report_id": lower_hex(&exact_blob::<32>(row, "statement_sha256", "statement_bytes")?), + "state": state, + "workflow_id": lower_hex(&exact_blob::<32>(row, "outbox_id", "outbox_id_bytes")?), + }); + if let Some(next) = optional_nonnegative_i64(row, "next_attempt_unix_ms")? { + value["next_attempt_at_utc"] = Value::from(next / 1_000); + } + Ok(value) +} + +fn publication_target_summary( + row: &sqlx::sqlite::SqliteRow, +) -> Result<Value, RhiAdminHandlerError> { + let target = row + .try_get::<&str, _>("relay_id") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let state = row + .try_get::<&str, _>("state") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if target.is_empty() + || target.len() > 64 + || !matches!( + state, + "pending" + | "submitted" + | "accepted" + | "rejected" + | "rate_limited" + | "auth_required" + | "failed" + | "unknown" + ) + { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + let mut value = json!({ + "attempt_count": row_u64(row, "attempt_count")?, + "reason_codes": if state == "failed" || state == "unknown" { vec!["publication_target_failed"] } else { Vec::<&str>::new() }, + "state": state, + "target_id": target, + "workflow_id": lower_hex(&exact_blob::<32>(row, "outbox_id", "outbox_id_bytes")?), + }); + if let Some(last) = optional_nonnegative_i64(row, "last_attempt_unix_ms")? { + value["last_attempt_at_utc"] = Value::from(last / 1_000); + } + Ok(value) +} + +fn operation_row_u64( + row: &sqlx::sqlite::SqliteRow, + name: &str, +) -> Result<u64, AdminJournalOperationError> { + row.try_get::<i64, _>(name) + .ok() + .and_then(|value| u64::try_from(value).ok()) + .ok_or(AdminJournalOperationError::Binding) +} + +fn operation_exact_blob<const N: usize>( + row: &sqlx::sqlite::SqliteRow, + value: &str, + length: &str, +) -> Result<[u8; N], AdminJournalOperationError> { + if operation_row_u64(row, length)? != N as u64 { + return Err(AdminJournalOperationError::Binding); + } + row.try_get::<Vec<u8>, _>(value) + .map_err(|_| AdminJournalOperationError::Binding)? + .try_into() + .map_err(|_| AdminJournalOperationError::Binding) +} + +fn presence_kind(row: &sqlx::sqlite::SqliteRow) -> Result<&str, RhiAdminHandlerError> { + match row + .try_get::<&str, _>("document_kind") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))? + { + kind @ ("service_profile" | "application_handler") => Ok(kind), + _ => Err(failure(RhiAdminHandlerErrorKind::Internal)), + } +} + +fn operation_presence_kind( + row: &sqlx::sqlite::SqliteRow, +) -> Result<&str, AdminJournalOperationError> { + match row + .try_get::<&str, _>("document_kind") + .map_err(|_| AdminJournalOperationError::Binding)? + { + kind @ ("service_profile" | "application_handler") => Ok(kind), + _ => Err(AdminJournalOperationError::Binding), + } +} + +// Domain-route methods are kept below the transport-independent common boundary. +impl RuntimeAdminHandler { + async fn reconciliation_status( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let policy_digest = lower_hex(self.state.metadata().evidence_policy_digest().as_bytes()); + let value = self + .state + .sqlite_host() + .transaction(|transaction| { + Box::pin(async move { + let row = sqlx::query( + r#"SELECT + (SELECT COUNT(*) FROM trade_dirty_generations) AS dirty_count, + COUNT(CASE WHEN state = 'ready' AND attempt_count = 0 THEN 1 END) AS pending_count, + COUNT(CASE WHEN state = 'ready' AND attempt_count > 0 THEN 1 END) AS retry_count, + COUNT(CASE WHEN state = 'leased' THEN 1 END) AS leased_count, + COUNT(CASE WHEN state = 'completed' THEN 1 END) AS completed_count, + COUNT(CASE WHEN state IN ('exhausted', 'superseded') THEN 1 END) AS failed_count, + MIN(CASE WHEN state = 'ready' THEN created_at_unix_ms END) AS oldest_pending_ms, + (SELECT COUNT(*) FROM evidence_reconciliation_sources + WHERE required = 1 AND completion != 'complete') AS required_failures + FROM reconciliation_jobs"#, + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let mut counts = Map::new(); + for (code, column) in [ + ("pending", "pending_count"), + ("retry_scheduled", "retry_count"), + ("leased", "leased_count"), + ("completed", "completed_count"), + ("failed", "failed_count"), + ] { + counts.insert(code.to_owned(), Value::from(row_u64(&row, column)?)); + } + let mut value = json!({ + "dirty_trade_count": row_u64(&row, "dirty_count")?, + "job_counts": counts, + "policy_digest": policy_digest, + "required_source_failures": row_u64(&row, "required_failures")?, + }); + if let Some(value_ms) = optional_nonnegative_i64(&row, "oldest_pending_ms")? { + value["oldest_pending_at_utc"] = Value::from(value_ms / 1_000); + } + Ok::<Value, RhiAdminHandlerError>(value) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + response(request.route(), value) + } + + async fn reconciliation_jobs( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let limit = page_limit(&model)?; + let state_filter = model + .pointer("/state") + .and_then(Value::as_str) + .map(str::to_owned); + let trade_filter = model + .pointer("/trade_id") + .and_then(Value::as_str) + .map(parse_trade_id) + .transpose()? + .map(|trade| trade.as_bytes().to_vec()); + let query_digest = query_digest(&model)?; + let (snapshot, offset) = + self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?; + let fetch_limit = i64::from(limit) + 1; + let offset_i64 = i64::from(offset); + let rows = self + .state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + sqlx::query( + r#"SELECT + length(job_id) AS job_id_bytes, substr(job_id, 1, 33) AS job_id, + length(trade_id) AS trade_id_bytes, substr(trade_id, 1, 17) AS trade_id, + state, attempt_count, input_generation, created_at_unix_ms, + lease_expires_unix_ms + FROM reconciliation_jobs + WHERE updated_at_unix_ms <= ? + AND (? IS NULL OR + (? = 'pending' AND state = 'ready' AND attempt_count = 0) OR + (? = 'retry_scheduled' AND state = 'ready' AND attempt_count > 0) OR + (? = 'leased' AND state = 'leased') OR + (? = 'completed' AND state = 'completed') OR + (? = 'failed' AND state IN ('exhausted', 'superseded'))) + AND (? IS NULL OR trade_id = ?) + ORDER BY updated_at_unix_ms DESC, job_id DESC + LIMIT ? OFFSET ?"#, + ) + .bind( + i64::try_from(snapshot) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ) + .bind(state_filter.as_deref()) + .bind(state_filter.as_deref()) + .bind(state_filter.as_deref()) + .bind(state_filter.as_deref()) + .bind(state_filter.as_deref()) + .bind(state_filter.as_deref()) + .bind(trade_filter.as_deref()) + .bind(trade_filter.as_deref()) + .bind(fetch_limit) + .bind(offset_i64) + .fetch_all(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let has_more = rows.len() > usize::from(limit); + let items = rows + .iter() + .take(usize::from(limit)) + .map(job_summary) + .collect::<Result<Vec<_>, _>>()?; + let mut value = json!({"items":items,"snapshot_generation":snapshot}); + if has_more { + value["next_cursor"] = Value::String( + self.encode_cursor( + request.route(), + snapshot, + offset + .checked_add(u32::from(limit)) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?, + query_digest, + ), + ); + } + response(request.route(), value) + } + + async fn reconciliation_refresh( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let trade = model + .pointer("/trade_id") + .and_then(Value::as_str) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal)) + .and_then(parse_trade_id)?; + let expected = required_u64(&model, "/expected_dirty_generation")?; + let actual = self.read_dirty_generation(trade).await?; + if actual != expected { + return Err(failure(RhiAdminHandlerErrorKind::Conflict)); + } + let now_ms = self.now_millis()?; + let prepared = match RhiAdminOperationRepository::new(&self.state) + .prepare_admin_operation(&request, operation_time(now_ms)?) + .await + .map_err(map_journal_error)? + { + RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response), + RhiAdminOperationAdmission::Prepared(prepared) => prepared, + }; + let policy = RhiReconciliationJobPolicy::from_configuration(&self.configuration) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let outcome = self + .state + .repositories() + .reconciliation_jobs() + .schedule_trade( + trade, + policy, + RhiReconciliationUnixMilliseconds::new(now_ms) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ) + .await + .map_err(|error| match error.kind() { + RhiReconciliationJobErrorKind::DirtyGenerationConflict => { + failure(RhiAdminHandlerErrorKind::Conflict) + } + RhiReconciliationJobErrorKind::QueueFull => { + failure(RhiAdminHandlerErrorKind::Unavailable) + } + _ => failure(RhiAdminHandlerErrorKind::Internal), + })?; + let job = outcome.job(); + let completed_ms = self.now_millis()?; + let completed = response( + request.route(), + json!({ + "dirty_generation": job.input_generation(), + "job_id": lower_hex(job.id().as_bytes()), + "operation_id": required_operation_id(&request)?, + "trade_id": lower_hex(job.trade_id().as_bytes()), + }), + )?; + RhiAdminOperationRepository::new(&self.state) + .complete_admin_operation( + &prepared, + &completed, + operation_time(completed_ms)?, + RhiAdminOperationJournalPolicy::seven_days(), + ) + .await + .map_err(map_journal_error)?; + Ok(completed) + } + async fn sources( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let limit = page_limit(&model)?; + let required_filter = model.pointer("/required").and_then(Value::as_bool); + let completion_filter = model + .pointer("/completion") + .and_then(Value::as_str) + .map(str::to_owned); + let sources = configured_source_summaries(&self.configuration)?; + let query_digest = query_digest(&model)?; + let (snapshot, offset) = + self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?; + let snapshot_sql = + i64::try_from(snapshot).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let rows = self + .state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let mut items = Vec::with_capacity(sources.len()); + for source in sources { + let attempt = sqlx::query( + r#"SELECT completion, finished_unix_ms + FROM evidence_reconciliation_sources + WHERE source_id = ? AND finished_unix_ms <= ? + ORDER BY finished_unix_ms DESC, attempt_id DESC, request_id DESC + LIMIT 1"#, + ) + .bind(source.source_id.as_ref()) + .bind(snapshot_sql) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let completion = attempt + .as_ref() + .map(|row| { + row.try_get::<&str, _>("completion") + .map(str::to_owned) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + }) + .transpose()? + .unwrap_or_else(|| "incomplete_unknown".to_owned()); + if required_filter.is_some_and(|required| required != source.required) + || completion_filter + .as_deref() + .is_some_and(|expected| expected != completion) + { + continue; + } + let checkpoint = sqlx::query( + r#"SELECT cursor_created_at_unix_s, + length(cursor_event_id) AS event_id_bytes, + substr(cursor_event_id, 1, 33) AS cursor_event_id + FROM relay_checkpoints + WHERE source_id = ? AND completed_at_unix_s <= ? + ORDER BY completed_at_unix_s DESC, trade_id DESC + LIMIT 1"#, + ) + .bind(source.source_id.as_ref()) + .bind(snapshot_sql / 1_000) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let mut value = json!({ + "completion": completion, + "reason_codes": [], + "required": source.required, + "source_id": source.source_id, + }); + if let Some(row) = attempt.as_ref() { + value["last_attempt_at_utc"] = + Value::from(row_u64(row, "finished_unix_ms")? / 1_000); + } + if let Some(row) = checkpoint.as_ref() { + value["cursor"] = json!({ + "created_at_unix_seconds": row_u64(row, "cursor_created_at_unix_s")?, + "event_id_lowercase_hex": lower_hex(&exact_blob::<32>(row, "cursor_event_id", "event_id_bytes")?), + }); + } + items.push(value); + } + Ok::<Vec<Value>, RhiAdminHandlerError>(items) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let start = + usize::try_from(offset).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if start > rows.len() { + return Err(failure(RhiAdminHandlerErrorKind::InvalidCursor)); + } + let end = start.saturating_add(usize::from(limit)).min(rows.len()); + let items = rows[start..end].to_vec(); + let mut value = json!({"items":items,"snapshot_generation":snapshot}); + if end < rows.len() { + value["next_cursor"] = Value::String(self.encode_cursor( + request.route(), + snapshot, + u32::try_from(end).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + query_digest, + )); + } + response(request.route(), value) + } + async fn trade_projection( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let trade = request_trade_id(&request)?; + let row = self.current_report_row(trade).await?; + let report = report_detail(&row)?; + response( + request.route(), + json!({ + "coverage": report["coverage"].clone(), + "dirty_generation": report["trade_generation"].clone(), + "manifest_digest": report["evidence_manifest_digest"].clone(), + "observed_at_utc": report["observed_at_unix_s"].clone(), + "outcome": report["outcome"].clone(), + "policy_digest": report["evidence_policy_digest"].clone(), + "projection_digest": report["projection_digest"].clone(), + "reason_codes": report["reason_codes"].clone(), + "trade_id": report["trade_id"].clone(), + }), + ) + } + async fn trade_report_current( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let row = self.current_report_row(request_trade_id(&request)?).await?; + response(request.route(), report_detail(&row)?) + } + async fn trade_reports( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let limit = page_limit(&model)?; + let trade = request_trade_id(&request)?; + let outcome = model + .pointer("/outcome") + .and_then(Value::as_str) + .map(contract_outcome_to_db) + .transpose()? + .map(str::to_owned); + let query_digest = query_digest(&model)?; + let (snapshot, offset) = + self.cursor_or_new(&model, request.route(), query_digest, self.now_seconds()?)?; + let trade_bytes = trade.as_bytes().to_vec(); + let fetch_limit = i64::from(limit) + 1; + let offset_i64 = i64::from(offset); + let snapshot_sql = + i64::try_from(snapshot).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let rows = self + .state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let sql = format!("{REPORT_SELECT}{REPORT_ROWS_SUFFIX}"); + // Both fragments are private compile-time constants; all filters remain bound. + sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) + .bind(trade_bytes) + .bind(snapshot_sql) + .bind(outcome.as_deref()) + .bind(outcome.as_deref()) + .bind(fetch_limit) + .bind(offset_i64) + .fetch_all(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let has_more = rows.len() > usize::from(limit); + let items = rows + .iter() + .take(usize::from(limit)) + .map(report_summary) + .collect::<Result<Vec<_>, _>>()?; + let mut value = json!({"items":items,"snapshot_generation":snapshot}); + if has_more { + value["next_cursor"] = Value::String( + self.encode_cursor( + request.route(), + snapshot, + offset + .checked_add(u32::from(limit)) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?, + query_digest, + ), + ); + } + response(request.route(), value) + } + async fn publication_backlog( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let limit = page_limit(&model)?; + let state_filter = model + .pointer("/state") + .and_then(Value::as_str) + .map(str::to_owned); + let query_digest = query_digest(&model)?; + let (snapshot, offset) = + self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?; + let rows = self + .state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + sqlx::query(PUBLICATION_BACKLOG_SQL) + .bind( + i64::try_from(snapshot) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ) + .bind(state_filter.as_deref()) + .bind(state_filter.as_deref()) + .bind(i64::from(limit) + 1) + .bind(i64::from(offset)) + .fetch_all(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let has_more = rows.len() > usize::from(limit); + let items = rows + .iter() + .take(usize::from(limit)) + .map(publication_summary) + .collect::<Result<Vec<_>, _>>()?; + let mut value = json!({"items":items,"snapshot_generation":snapshot}); + if has_more { + value["next_cursor"] = Value::String( + self.encode_cursor( + request.route(), + snapshot, + offset + .checked_add(u32::from(limit)) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?, + query_digest, + ), + ); + } + response(request.route(), value) + } + async fn publication_targets( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let limit = page_limit(&model)?; + let state_filter = model + .pointer("/state") + .and_then(Value::as_str) + .map(str::to_owned); + let workflow = model + .pointer("/workflow_id") + .and_then(Value::as_str) + .map(parse_digest) + .transpose()? + .map(Vec::from); + let query_digest = query_digest(&model)?; + let (snapshot, offset) = + self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?; + let rows = self + .state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + sqlx::query(PUBLICATION_TARGETS_SQL) + .bind( + i64::try_from(snapshot) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ) + .bind(workflow.as_deref()) + .bind(workflow.as_deref()) + .bind(state_filter.as_deref()) + .bind(state_filter.as_deref()) + .bind(i64::from(limit) + 1) + .bind(i64::from(offset)) + .fetch_all(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + let has_more = rows.len() > usize::from(limit); + let items = rows + .iter() + .take(usize::from(limit)) + .map(publication_target_summary) + .collect::<Result<Vec<_>, _>>()?; + let mut value = json!({"items":items,"snapshot_generation":snapshot}); + if has_more { + value["next_cursor"] = Value::String( + self.encode_cursor( + request.route(), + snapshot, + offset + .checked_add(u32::from(limit)) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?, + query_digest, + ), + ); + } + response(request.route(), value) + } + async fn publication_retry( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let workflow = model + .pointer("/workflow_id") + .and_then(Value::as_str) + .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal)) + .and_then(parse_digest)?; + let expected = required_u64(&model, "/expected_generation")?; + let now_ms = self.now_millis()?; + let operation_id = required_operation_id(&request)?.to_owned(); + let route = request.route(); + let expected_target_count = self.publication.targets().len(); + RhiAdminOperationRepository::new(&self.state) + .execute_database_admin_operation( + &request, + operation_time(now_ms)?, + RhiAdminOperationJournalPolicy::seven_days(), + move |transaction| { + Box::pin(async move { + let rows = sqlx::query( + r#"SELECT state, revision, target_count, + length(event_sha256) AS event_sha256_bytes, + substr(event_sha256, 1, 33) AS event_sha256 + FROM publication_outbox WHERE outbox_id = ? LIMIT 2"#, + ) + .bind(workflow.as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if rows.len() != 1 { + return Err(if rows.is_empty() { + AdminJournalOperationError::Conflict + } else { + AdminJournalOperationError::Binding + }); + } + let row = &rows[0]; + let state = row + .try_get::<&str, _>("state") + .map_err(|_| AdminJournalOperationError::Binding)?; + let revision = operation_row_u64(row, "revision")?; + let target_count = operation_row_u64(row, "target_count")?; + let event_sha256 = operation_exact_blob::<32>( + row, + "event_sha256", + "event_sha256_bytes", + )?; + if state != "blocked" + || revision != expected + || usize::try_from(target_count).ok() != Some(expected_target_count) + { + return Err(AdminJournalOperationError::Conflict); + } + sqlx::query( + r#"UPDATE publication_targets + SET state = 'pending', revision = revision + 1, + next_attempt_unix_ms = ?, updated_at_unix_ms = ? + WHERE outbox_id = ? AND state != 'accepted'"#, + ) + .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?) + .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?) + .bind(workflow.as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + let changed = sqlx::query( + r#"UPDATE publication_outbox + SET state = 'pending', revision = revision + 1, + next_attempt_unix_ms = ?, updated_at_unix_ms = ? + WHERE outbox_id = ? AND state = 'blocked' AND revision = ?"#, + ) + .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?) + .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?) + .bind(workflow.as_slice()) + .bind(i64::try_from(expected).map_err(|_| AdminJournalOperationError::InvalidInput)?) + .execute(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if changed.rows_affected() != 1 { + return Err(AdminJournalOperationError::Conflict); + } + response( + route, + json!({ + "exact_bytes_digest": lower_hex(&event_sha256), + "generation": expected.checked_add(1).ok_or(AdminJournalOperationError::InvalidInput)?, + "operation_id": operation_id, + "target_count": target_count, + "workflow_id": lower_hex(&workflow), + }), + ) + .map_err(|_| AdminJournalOperationError::Binding) + }) + }, + ) + .await + .map_err(map_journal_error) + } + async fn presence_refresh( + &self, + request: RhiAdminRequestDocument, + ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> { + let model = request_model(&request)?; + let expected = required_u64(&model, "/expected_generation")?; + let operation_id = required_operation_id(&request)?.to_owned(); + let now_ms = self.now_millis()?; + let route = request.route(); + RhiAdminOperationRepository::new(&self.state) + .execute_database_admin_operation( + &request, + operation_time(now_ms)?, + RhiAdminOperationJournalPolicy::seven_days(), + move |transaction| { + Box::pin(async move { + let desired = sqlx::query( + r#"SELECT generation, enabled, target_count + FROM presence_desired_state WHERE singleton = 1 LIMIT 2"#, + ) + .fetch_all(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if desired.len() != 1 { + return Err(AdminJournalOperationError::Binding); + } + let generation = operation_row_u64(&desired[0], "generation")?; + let enabled = operation_row_u64(&desired[0], "enabled")?; + let target_count = operation_row_u64(&desired[0], "target_count")?; + if generation != expected || enabled != 1 { + return Err(AdminJournalOperationError::Conflict); + } + let rows = sqlx::query( + r#"SELECT document_kind, + length(event_sha256) AS event_sha256_bytes, + substr(event_sha256, 1, 33) AS event_sha256, + state + FROM presence_outbox + WHERE desired_generation = ? + ORDER BY document_kind LIMIT 3"#, + ) + .bind( + i64::try_from(expected) + .map_err(|_| AdminJournalOperationError::InvalidInput)?, + ) + .fetch_all(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if rows.is_empty() || rows.len() > 2 { + return Err(AdminJournalOperationError::Conflict); + } + let mut digests = Map::new(); + for row in &rows { + let kind = operation_presence_kind(row)?; + let digest = operation_exact_blob::<32>( + row, + "event_sha256", + "event_sha256_bytes", + )?; + if digests + .insert(kind.to_owned(), Value::String(lower_hex(&digest))) + .is_some() + { + return Err(AdminJournalOperationError::Binding); + } + } + let changed = sqlx::query( + r#"UPDATE presence_outbox + SET state = 'pending', revision = revision + 1, + next_attempt_unix_ms = ?, updated_at_unix_ms = ? + WHERE desired_generation = ? AND state = 'blocked'"#, + ) + .bind( + i64::try_from(now_ms) + .map_err(|_| AdminJournalOperationError::InvalidInput)?, + ) + .bind( + i64::try_from(now_ms) + .map_err(|_| AdminJournalOperationError::InvalidInput)?, + ) + .bind( + i64::try_from(expected) + .map_err(|_| AdminJournalOperationError::InvalidInput)?, + ) + .execute(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if changed.rows_affected() == 0 + || changed.rows_affected() as usize > rows.len() + { + return Err(AdminJournalOperationError::Conflict); + } + response( + route, + json!({ + "document_digests": digests, + "generation": expected, + "operation_id": operation_id, + "state": "pending", + "target_count": target_count, + }), + ) + .map_err(|_| AdminJournalOperationError::Binding) + }) + }, + ) + .await + .map_err(map_journal_error) + } + + async fn presence_state( + &self, + generation: u64, + ) -> Result<(&'static str, Map<String, Value>), RhiAdminHandlerError> { + self.state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let rows = sqlx::query( + r#"SELECT document_kind, state, + length(event_sha256) AS event_sha256_bytes, + substr(event_sha256, 1, 33) AS event_sha256 + FROM presence_outbox + WHERE desired_generation = ? + ORDER BY document_kind LIMIT 3"#, + ) + .bind( + i64::try_from(generation) + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ) + .fetch_all(&mut *transaction) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?; + if rows.len() > 2 { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + let mut digests = Map::new(); + let mut states = Vec::with_capacity(rows.len()); + for row in &rows { + let kind = presence_kind(row)?; + let digest = exact_blob::<32>(row, "event_sha256", "event_sha256_bytes")?; + if digests + .insert(kind.to_owned(), Value::String(lower_hex(&digest))) + .is_some() + { + return Err(failure(RhiAdminHandlerErrorKind::Internal)); + } + states.push( + row.try_get::<&str, _>("state") + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?, + ); + } + let state = if rows.is_empty() { + "dirty" + } else if states.contains(&"leased") { + "submitted" + } else if states.contains(&"pending") { + "pending" + } else if states.contains(&"blocked") { + "failed" + } else if states.iter().all(|state| *state == "complete") { + "accepted" + } else if states.iter().all(|state| *state == "superseded") { + "dirty" + } else { + "unknown" + }; + Ok::<(&'static str, Map<String, Value>), RhiAdminHandlerError>((state, digests)) + }) + }) + .await + .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursors_are_bounded_authenticated_and_bound_to_route_filters_and_key() { + let key = [0x11; 32]; + let query = [0x22; 32]; + let cursor = encode_runtime_cursor( + &key, + RhiAdminRoute::ReconciliationJobs, + u64::MAX, + u32::MAX, + query, + ); + assert_eq!(cursor.len(), 104); + assert!(!cursor.contains('=')); + assert_eq!( + decode_runtime_cursor(&key, RhiAdminRoute::ReconciliationJobs, &cursor, query) + .expect("exact cursor"), + (u64::MAX, u32::MAX) + ); + + for result in [ + decode_runtime_cursor( + &[0x12; 32], + RhiAdminRoute::ReconciliationJobs, + &cursor, + query, + ), + decode_runtime_cursor(&key, RhiAdminRoute::Sources, &cursor, query), + decode_runtime_cursor(&key, RhiAdminRoute::ReconciliationJobs, &cursor, [0x23; 32]), + ] { + assert_eq!( + result.expect_err("mismatched cursor binding").kind(), + RhiAdminHandlerErrorKind::InvalidCursor + ); + } + + let mut tampered = cursor.into_bytes(); + tampered[20] = if tampered[20] == b'A' { b'B' } else { b'A' }; + let tampered = String::from_utf8(tampered).expect("ASCII cursor"); + assert_eq!( + decode_runtime_cursor(&key, RhiAdminRoute::ReconciliationJobs, &tampered, query,) + .expect_err("tampered cursor") + .kind(), + RhiAdminHandlerErrorKind::InvalidCursor + ); + } +} diff --git a/src/runtime_foundation.rs b/src/runtime_foundation.rs @@ -1,7 +1,7 @@ //! Existing-state-only RHI runtime foundation. use core::fmt; -use std::error::Error; +use std::{error::Error, sync::Arc}; use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; @@ -228,10 +228,10 @@ impl Error for RhiRuntimeFoundationError {} #[must_use = "the runtime foundation must be shut down so state and tasks are joined"] pub struct RhiRuntimeFoundation { runtime: RhiRuntimeContext, - configuration: RhiConfigDocumentV1, + configuration: Arc<RhiConfigDocumentV1>, metadata: RhiStateMetadata, - state: RhiStateHost, - _identity: RhiDecryptedIdentity, + state: Arc<RhiStateHost>, + identity: Arc<RhiDecryptedIdentity>, adapters: RhiRuntimeAdapters, readiness: RhiRuntimeReadiness, } @@ -245,8 +245,8 @@ impl RhiRuntimeFoundation { /// Returns the admitted immutable configuration. #[must_use] - pub const fn configuration(&self) -> &RhiConfigDocumentV1 { - &self.configuration + pub fn configuration(&self) -> &RhiConfigDocumentV1 { + self.configuration.as_ref() } /// Returns metadata discovered and proven under retained state authority. @@ -261,10 +261,43 @@ impl RhiRuntimeFoundation { &self.readiness } + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn state(&self) -> Arc<RhiStateHost> { + Arc::clone(&self.state) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn configuration_arc(&self) -> Arc<RhiConfigDocumentV1> { + Arc::clone(&self.configuration) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) const fn adapters(&self) -> &RhiRuntimeAdapters { + &self.adapters + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn identity(&self) -> &RhiDecryptedIdentity { + self.identity.as_ref() + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn identity_arc(&self) -> Arc<RhiDecryptedIdentity> { + Arc::clone(&self.identity) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn supervisor_mut(&mut self) -> &mut radroots_service_host::TaskSupervisor { + self.adapters.supervisor_mut() + } + /// Requests cancellation, joins owned tasks, and explicitly closes state. pub async fn shutdown(mut self) -> Result<(), RhiRuntimeFoundationError> { let supervised = self.adapters.shutdown().await; - let closed = self.state.close().await; + let state = Arc::try_unwrap(self.state).map_err(|_| { + RhiRuntimeFoundationError::new(RhiRuntimeFoundationErrorKind::TaskFailure) + })?; + let closed = state.close().await; if supervised.is_err() { Err(RhiRuntimeFoundationError::new( RhiRuntimeFoundationErrorKind::TaskFailure, @@ -281,6 +314,7 @@ impl RhiRuntimeFoundation { impl fmt::Debug for RhiRuntimeFoundation { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let _ = &self.identity; formatter .debug_struct("RhiRuntimeFoundation") .field("runtime", &"[redacted]") @@ -332,10 +366,10 @@ pub async fn open_rhi_runtime_foundation( }; Ok(RhiRuntimeFoundation { runtime, - configuration, + configuration: Arc::new(configuration), metadata, - state, - _identity: identity, + state: Arc::new(state), + identity: Arc::new(identity), adapters, readiness, }) diff --git a/src/runtime_graph.rs b/src/runtime_graph.rs @@ -0,0 +1,1716 @@ +//! Binary-invoked RHI daemon graph with fixed task ownership and bounded shutdown. + +use core::{fmt, future::pending, time::Duration}; +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use radroots_event::id::TradeId; +use radroots_service_host::{ + GracefulShutdown, HostError, HostErrorKind, ProcessSignal, ProcessSignalAdapter, + ProcessSignalFuture, ProcessSignalSource, ShutdownDisposition, ShutdownPhase, + ShutdownPhaseFuture, ShutdownPhaseHandler, SupervisedTaskExitStatus, TaskClassification, + TaskMetadata, TaskName, +}; +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; +use radroots_transport::{ + FetchRequest, SubscriptionNext, SubscriptionRequest, Target, TargetSet, + outcome::FetchTargetState, + source::{ + FETCH_PAGE_MAX_EVENTS, FetchBounds, FetchCursor, FetchSelector, NextPage, + SubscriptionBounds, + }, +}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; +use sqlx::Row; + +use crate::transport_nostr_adapter::{RhiNostrExactSink, build_rhi_nostr_adapters}; +use crate::{ + RhiAdminCancellationToken, RhiAdminServer, RhiAdmittedTradeMutationEvent, RhiBoundAdminServer, + RhiBoundOperationsServer, RhiConfigDocumentV1, RhiEvidenceAttestationSupersession, + RhiEvidenceTransportStatusV1, RhiIdentityHealthV1, RhiIntegrityStateV1, + RhiJitterBoundMilliseconds, RhiOperationsCancellationToken, RhiOperationsServer, + RhiPersistenceHealthV1, RhiPersistenceStatusV1, RhiPresenceDesiredAuthority, + RhiPresenceLeaseOwner, RhiPresenceStatusV1, RhiProcessResult, RhiProcessSignal, + RhiProcessSignalSource, RhiProviderStatusV1, RhiPublicationAuthority, RhiPublicationLeaseOwner, + RhiPublicationStatusV1, RhiReconciliationAttemptPlan, RhiReconciliationJobPolicy, + RhiReconciliationLease, RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds, + RhiReconciliationScopePrerequisites, RhiReconciliationSourceReplay, + RhiReconciliationSourceReplayPlan, RhiReconciliationSourceRequest, RhiReconciliationStatusV1, + RhiReconciliationUnixMilliseconds, RhiRuntimeFoundation, RhiServicePhase, RhiStateHost, + RhiStatusBuildInfoV1, RhiStatusBuildMode, RhiStatusCommonV1, RhiStatusConfigurationIdentityV1, + RhiStatusConfigurationSource, RhiStatusObservationV1, RhiStatusPublisher, RhiStatusReasonCode, + RhiStatusReasonCodes, RhiStatusUnixSeconds, RhiTimeEntropyAdapters, + RhiTradeMutationAdmissionLimits, RhiTradeMutationAuthoredTimePolicy, + RhiTradeMutationObservedAtUnixSeconds, RhiTradeSourceAttempt, RhiTradeSourceCompletion, + RhiTransportHealthV1, admit_rhi_trade_mutation_event, build_rhi_signed_evidence_attestation, + build_rhi_signed_presence_documents, evaluate_rhi_reconciliation_claim, + open_rhi_runtime_foundation, reduce_rhi_reconciliation_manifest, rhi_status_cache, +}; +use crate::{reconciliation_replay, source_ingest, state_config}; + +const TASK_ADMIN_SERVER: &str = "admin_server"; +const TASK_OPERATIONS_SERVER: &str = "operations_server"; +const TASK_SOURCE_SUBSCRIPTION: &str = "source_subscription"; +const TASK_RECONCILIATION_WORKER: &str = "reconciliation_worker"; +const TASK_PUBLICATION_WORKER: &str = "publication_worker"; +const TASK_PRESENCE_WORKER: &str = "presence_worker"; +const TRADE_EVENT_KINDS: [u32; 5] = [3470, 3471, 3472, 3473, 3474]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RhiDaemonErrorKind { + State, + Identity, + Transport, + Admin, + Operations, + Runtime, +} + +struct RhiDaemonError { + kind: RhiDaemonErrorKind, +} + +impl RhiDaemonError { + const fn new(kind: RhiDaemonErrorKind) -> Self { + Self { kind } + } + + const fn process_result(&self) -> RhiProcessResult { + match self.kind { + RhiDaemonErrorKind::State | RhiDaemonErrorKind::Identity => { + RhiProcessResult::StateOrIdentityUnavailable + } + RhiDaemonErrorKind::Transport + | RhiDaemonErrorKind::Admin + | RhiDaemonErrorKind::Operations => RhiProcessResult::ServiceOrDependencyUnavailable, + RhiDaemonErrorKind::Runtime => RhiProcessResult::UnexpectedInternal, + } + } +} + +impl fmt::Debug for RhiDaemonError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiDaemonError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiDaemonError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RHI daemon failed") + } +} + +impl Error for RhiDaemonError {} + +struct HostSignalSource<S> { + inner: S, +} + +impl<S> HostSignalSource<S> { + const fn new(inner: S) -> Self { + Self { inner } + } +} + +impl<S> ProcessSignalSource for HostSignalSource<S> +where + S: RhiProcessSignalSource, +{ + fn next_signal(&mut self) -> ProcessSignalFuture<'_> { + Box::pin(async move { + self.inner.next_signal().await.map(|signal| match signal { + RhiProcessSignal::Interrupt => ProcessSignal::Interrupt, + #[cfg(unix)] + RhiProcessSignal::Terminate => ProcessSignal::Terminate, + }) + }) + } +} + +struct RuntimeShutdownHandler { + accepting_mutations: Arc<AtomicBool>, + status: RhiStatusPublisher, + status_context: RuntimeStatusContext, + transport_ready: bool, + operations_ready: bool, +} + +impl RuntimeShutdownHandler { + fn publish(&mut self, phase: RhiServicePhase) -> Result<(), HostError> { + self.status + .publish(self.status_context.observation( + phase, + self.transport_ready, + self.operations_ready, + )?) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error)) + } + + fn running_phase(&self) -> RhiServicePhase { + if self.transport_ready && self.operations_ready { + RhiServicePhase::Ready + } else { + RhiServicePhase::Degraded + } + } +} + +impl ShutdownPhaseHandler for RuntimeShutdownHandler { + fn enter(&mut self, phase: ShutdownPhase) -> ShutdownPhaseFuture<'_> { + Box::pin(async move { + if phase == ShutdownPhase::RejectNewMutations { + self.accepting_mutations.store(false, Ordering::Release); + self.publish(RhiServicePhase::Stopping)?; + } + Ok(()) + }) + } +} + +struct RuntimeStatusContext { + configuration_digest: String, + configuration_source: RhiStatusConfigurationSource, + schema_version: u32, + generation: u64, + source_count: u64, + started_at: radroots_service_host::MonotonicTime, + time_entropy: RhiTimeEntropyAdapters, + reconciliation: RhiReconciliationStatusV1, + publication: RhiPublicationStatusV1, + presence: RhiPresenceStatusV1, +} + +impl RuntimeStatusContext { + async fn refresh_state(&mut self, state: &RhiStateHost) -> Result<(), HostError> { + let summary = state + .sqlite_host() + .transaction(|transaction| { + Box::pin(async move { + let row = sqlx::query(RUNTIME_STATUS_SQL) + .fetch_one(&mut *transaction) + .await + .map_err(|_| ())?; + Ok::<_, ()>(( + row.try_get::<i64, _>("reconciliation_pending") + .map_err(|_| ())?, + row.try_get::<i64, _>("reconciliation_leased") + .map_err(|_| ())?, + row.try_get::<i64, _>("reconciliation_exhausted") + .map_err(|_| ())?, + row.try_get::<Option<i64>, _>("reconciliation_oldest") + .map_err(|_| ())?, + row.try_get::<i64, _>("publication_pending") + .map_err(|_| ())?, + row.try_get::<i64, _>("publication_unknown") + .map_err(|_| ())?, + row.try_get::<Option<i64>, _>("publication_oldest") + .map_err(|_| ())?, + row.try_get::<i64, _>("presence_pending").map_err(|_| ())?, + row.try_get::<i64, _>("presence_unknown").map_err(|_| ())?, + )) + }) + }) + .await + .map_err(|_| HostError::new(HostErrorKind::Lifecycle))?; + self.reconciliation = RhiReconciliationStatusV1::new( + status_count(summary.0)?, + status_count(summary.1)?, + status_count(summary.2)?, + status_time(summary.3)?, + ); + self.publication = RhiPublicationStatusV1::new( + status_count(summary.4)?, + status_count(summary.5)?, + status_time(summary.6)?, + ); + self.presence = + RhiPresenceStatusV1::new(status_count(summary.7)?, status_count(summary.8)?); + Ok(()) + } + + fn observation( + &self, + phase: RhiServicePhase, + transport_ready: bool, + operations_ready: bool, + ) -> Result<RhiStatusObservationV1, HostError> { + let ready = + matches!(phase, RhiServicePhase::Ready | RhiServicePhase::Degraded) && transport_ready; + let mut transport_reasons = Vec::new(); + if !transport_ready { + transport_reasons.push(RhiStatusReasonCode::SourceUnavailable); + transport_reasons.push(RhiStatusReasonCode::SubscriptionInactive); + } + let transport_reasons = RhiStatusReasonCodes::new(transport_reasons) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + let mut lifecycle_reasons = transport_reasons.as_slice().to_vec(); + if !operations_ready { + lifecycle_reasons.push(RhiStatusReasonCode::OperationsListenerFailed); + } + if phase == RhiServicePhase::Stopping { + lifecycle_reasons.push(RhiStatusReasonCode::ShutdownInProgress); + } + let lifecycle_reasons = RhiStatusReasonCodes::new(lifecycle_reasons) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + let transport_health = if transport_ready { + RhiTransportHealthV1::Ready + } else { + RhiTransportHealthV1::Unavailable + }; + let uptime = self + .time_entropy + .now_monotonic() + .duration_since_origin() + .saturating_sub(self.started_at.duration_since_origin()) + .as_millis(); + let uptime = u64::try_from(uptime).map_err(|_| HostError::new(HostErrorKind::Lifecycle))?; + let build = runtime_build_info()?; + let configuration = RhiStatusConfigurationIdentityV1::new( + &self.configuration_digest, + self.configuration_source, + ) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + let persistence = RhiPersistenceStatusV1::new( + RhiPersistenceHealthV1::Ready, + self.schema_version, + self.generation, + RhiIntegrityStateV1::Verified, + RhiStatusReasonCodes::empty(), + ) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + let identity = RhiIdentityHealthV1::new(true, true, RhiStatusReasonCodes::empty()) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + let provider = RhiProviderStatusV1::new(identity, RhiStatusReasonCodes::empty()) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + let transport = RhiEvidenceTransportStatusV1::new( + transport_health, + transport_ready, + transport_ready, + self.source_count, + if transport_ready { + self.source_count + } else { + 0 + }, + transport_reasons, + ) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + let common = RhiStatusCommonV1::new( + phase, + ready, + lifecycle_reasons, + uptime, + build, + configuration, + persistence, + ) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?; + Ok(RhiStatusObservationV1::new( + common, + provider, + transport, + self.reconciliation, + self.publication, + self.presence, + )) + } +} + +const RUNTIME_STATUS_SQL: &str = r#"SELECT + (SELECT COUNT(*) FROM reconciliation_jobs WHERE state = 'ready') + AS reconciliation_pending, + (SELECT COUNT(*) FROM reconciliation_jobs WHERE state = 'leased') + AS reconciliation_leased, + (SELECT COUNT(*) FROM reconciliation_jobs WHERE state = 'exhausted') + AS reconciliation_exhausted, + (SELECT MIN(created_at_unix_ms / 1000) FROM reconciliation_jobs WHERE state = 'ready') + AS reconciliation_oldest, + (SELECT COUNT(*) FROM publication_outbox WHERE state IN ('pending', 'leased')) + AS publication_pending, + (SELECT COUNT(*) FROM publication_outbox AS outbox + WHERE outbox.state = 'blocked' OR EXISTS ( + SELECT 1 FROM publication_targets AS target + WHERE target.outbox_id = outbox.outbox_id AND target.state = 'unknown')) + AS publication_unknown, + (SELECT MIN(created_at_unix_ms / 1000) FROM publication_outbox + WHERE state IN ('pending', 'leased')) AS publication_oldest, + (SELECT COUNT(*) FROM presence_outbox WHERE state IN ('pending', 'leased')) + AS presence_pending, + (SELECT COUNT(*) FROM presence_outbox AS outbox + WHERE outbox.state = 'blocked' OR EXISTS ( + SELECT 1 FROM presence_targets AS target + WHERE target.outbox_id = outbox.outbox_id AND target.state = 'unknown')) + AS presence_unknown"#; + +fn status_count(value: i64) -> Result<u64, HostError> { + u64::try_from(value).map_err(|_| HostError::new(HostErrorKind::Lifecycle)) +} + +fn status_time(value: Option<i64>) -> Result<Option<RhiStatusUnixSeconds>, HostError> { + value + .map(|value| { + u64::try_from(value) + .map_err(|_| HostError::new(HostErrorKind::Lifecycle)) + .and_then(|value| { + RhiStatusUnixSeconds::new(value) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error)) + }) + }) + .transpose() +} + +struct SourceBinding { + source_id: Box<str>, + target: Target, +} + +struct InitialSubscription { + request: SubscriptionRequest, + subscription: radroots_transport::BoxSubscription, + sources: Box<[SourceBinding]>, +} + +/// Executes the real RHI daemon and converts every failure to the frozen process result. +pub(crate) async fn run_rhi_daemon<S>( + runtime: crate::RhiRuntimeContext, + configuration: RhiConfigDocumentV1, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, + signals: S, +) -> RhiProcessResult +where + S: RhiProcessSignalSource + 'static, +{ + run_rhi_daemon_inner(runtime, configuration, applied_at, build, signals) + .await + .unwrap_or_else(|error| error.process_result()) +} + +async fn run_rhi_daemon_inner<S>( + runtime: crate::RhiRuntimeContext, + configuration: RhiConfigDocumentV1, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, + signals: S, +) -> Result<RhiProcessResult, RhiDaemonError> +where + S: RhiProcessSignalSource + 'static, +{ + let (transport, exact_sink) = build_rhi_nostr_adapters(&configuration) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Transport))?; + let adapters = crate::RhiRuntimeAdapters::new( + crate::RhiTimeEntropyAdapters::system(), + transport, + crate::RhiIdentityCredentialAdapters::canonical(), + ); + let mut foundation = + open_rhi_runtime_foundation(runtime, configuration, adapters, applied_at, build) + .await + .map_err(|error| match error.kind() { + crate::RhiRuntimeFoundationErrorKind::IdentityAccess + | crate::RhiRuntimeFoundationErrorKind::IdentityBinding => { + RhiDaemonError::new(RhiDaemonErrorKind::Identity) + } + _ => RhiDaemonError::new(RhiDaemonErrorKind::State), + })?; + + let configuration = foundation.configuration_arc(); + let state = foundation.state(); + let publication = Arc::new( + RhiPublicationAuthority::from_config(&configuration) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?, + ); + let presence = Arc::new( + RhiPresenceDesiredAuthority::from_config(&configuration) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?, + ); + initialize_presence(&foundation, &presence) + .await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?; + + let initial_subscription = open_initial_subscription(&foundation, &configuration) + .await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Transport))?; + let generation = u64::from( + state_config::current_generation(&state) + .await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?, + ); + let source_count = u64::try_from(initial_subscription.sources.len()) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + let time_entropy = foundation.adapters().time_entropy().clone(); + let started_at = time_entropy.now_monotonic(); + let mut status_context = RuntimeStatusContext { + configuration_digest: lower_hex(state.metadata().configuration_digest().as_bytes()), + configuration_source: if foundation.runtime_context().profile() + == crate::RhiBootstrapProfileV1::RepoLocal + { + RhiStatusConfigurationSource::DerivedRepoLocal + } else { + RhiStatusConfigurationSource::ExplicitConfig + }, + schema_version: crate::RHI_STATE_SCHEMA_VERSION, + generation, + source_count, + started_at, + time_entropy: time_entropy.clone(), + reconciliation: RhiReconciliationStatusV1::default(), + publication: RhiPublicationStatusV1::default(), + presence: RhiPresenceStatusV1::default(), + }; + status_context + .refresh_state(&state) + .await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?; + let (status, status_reader) = rhi_status_cache( + foundation.runtime_context().context().instance().clone(), + status_context + .observation(RhiServicePhase::Starting, true, true) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?, + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + let accepting_mutations = Arc::new(AtomicBool::new(true)); + let mut cursor_key = [0_u8; 32]; + time_entropy + .entropy() + .fill_bytes(&mut cursor_key) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + let admin_handler = Arc::new(crate::runtime_admin::RuntimeAdminHandler { + state: Arc::clone(&state), + configuration: Arc::clone(&configuration), + identity: foundation.identity_arc(), + publication: Arc::clone(&publication), + presence: Arc::clone(&presence), + status: status_reader.clone(), + accepting_mutations: Arc::clone(&accepting_mutations), + cursor_key, + time_entropy: time_entropy.clone(), + }); + let admin = RhiAdminServer::new(&configuration, admin_handler) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Admin))? + .bind(foundation.runtime_context()) + .await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Admin))?; + let operations = if operations_enabled(&configuration)? { + Some( + RhiOperationsServer::new(&configuration, &status_reader) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Operations))? + .bind() + .await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Operations))?, + ) + } else { + None + }; + + spawn_admin(foundation.supervisor_mut(), admin)?; + if let Some(operations) = operations { + spawn_operations(foundation.supervisor_mut(), operations)?; + } + let source_transport = foundation.adapters().transport().clone(); + let (transport_health_sender, mut transport_health) = tokio::sync::watch::channel(true); + spawn_source_subscription( + foundation.supervisor_mut(), + Arc::clone(&state), + Arc::clone(&configuration), + source_transport, + time_entropy.clone(), + initial_subscription, + transport_health_sender, + )?; + let reconciliation_transport = foundation.adapters().transport().clone(); + let reconciliation_time = foundation.adapters().time_entropy().clone(); + let reconciliation_identity = foundation.identity_arc(); + spawn_reconciliation_worker( + foundation.supervisor_mut(), + Arc::clone(&state), + Arc::clone(&configuration), + reconciliation_transport, + reconciliation_time, + reconciliation_identity, + Arc::clone(&publication), + )?; + spawn_publication_worker( + foundation.supervisor_mut(), + Arc::clone(&state), + Arc::clone(&configuration), + Arc::clone(&publication), + Arc::clone(&exact_sink), + time_entropy.clone(), + )?; + spawn_presence_worker( + foundation.supervisor_mut(), + Arc::clone(&state), + Arc::clone(&configuration), + Arc::clone(&exact_sink), + time_entropy, + )?; + + let mut shutdown_handler = RuntimeShutdownHandler { + accepting_mutations, + status, + status_context, + transport_ready: true, + operations_ready: true, + }; + shutdown_handler + .publish(RhiServicePhase::Ready) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + + let mut signals = ProcessSignalAdapter::new(HostSignalSource::new(signals)); + let refresh_period = worker_idle(&configuration) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + let mut status_refresh = tokio::time::interval(refresh_period); + status_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + status_refresh.tick().await; + let signal_initiated = loop { + tokio::select! { + action = signals.next_action() => { + match action { + Ok(action) if !action.forces_termination() => break true, + Ok(_) | Err(_) => break false, + } + } + changed = transport_health.changed() => { + if changed.is_err() { + break false; + } + let ready = *transport_health.borrow_and_update(); + shutdown_handler.transport_ready = ready; + shutdown_handler.publish(shutdown_handler.running_phase()) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + } + joined = foundation.supervisor_mut().join_next() => { + match joined { + Some(Ok(exit)) + if exit.status() == SupervisedTaskExitStatus::OptionalFailure + || exit.metadata().name().as_str() == TASK_OPERATIONS_SERVER => { + shutdown_handler.operations_ready = false; + shutdown_handler.publish(shutdown_handler.running_phase()) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + } + Some(Ok(_)) | Some(Err(_)) | None => break false, + } + } + _ = status_refresh.tick() => { + shutdown_handler.status_context.refresh_state(&state).await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?; + shutdown_handler.publish(shutdown_handler.running_phase()) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + } + } + }; + let grace = Duration::from_millis(configuration_integer( + &configuration, + "/service/shutdown_grace_ms", + )?); + let mut shutdown = GracefulShutdown::new(grace) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + let clock = radroots_service_host::SystemMonotonicClock::new(); + let summary = if signal_initiated { + shutdown + .run( + &clock, + foundation.supervisor_mut(), + &mut shutdown_handler, + async { + let _ = signals.next_action().await; + }, + ) + .await + } else { + shutdown + .run( + &clock, + foundation.supervisor_mut(), + &mut shutdown_handler, + pending::<()>(), + ) + .await + } + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?; + drop(shutdown_handler); + drop(state); + foundation + .shutdown() + .await + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?; + if signal_initiated && summary.disposition() == ShutdownDisposition::Completed { + Ok(RhiProcessResult::Success) + } else { + Err(RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) + } +} + +async fn initialize_presence( + foundation: &RhiRuntimeFoundation, + authority: &RhiPresenceDesiredAuthority, +) -> Result<(), HostError> { + let state = foundation.state(); + let desired = state + .repositories() + .desired_presence() + .commit(authority) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + if desired.changed() { + let documents = build_rhi_signed_presence_documents( + desired, + authority, + foundation.identity(), + foundation + .adapters() + .time_entropy() + .now_utc() + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?, + foundation.adapters().time_entropy().entropy(), + ) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let now = crate::RhiPresenceUnixMilliseconds::new( + foundation + .adapters() + .time_entropy() + .now_utc_milliseconds() + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?, + ) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + state + .repositories() + .presence_outbox() + .commit_signed_presence(&documents, now) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + } + Ok(()) +} + +async fn open_initial_subscription( + foundation: &RhiRuntimeFoundation, + configuration: &RhiConfigDocumentV1, +) -> Result<InitialSubscription, HostError> { + let sources = configured_sources(configuration)?; + let (request, subscription) = subscribe_sources( + configuration, + foundation.adapters().transport(), + foundation.adapters().time_entropy(), + &sources, + ) + .await?; + Ok(InitialSubscription { + request, + subscription, + sources: sources.into_boxed_slice(), + }) +} + +async fn subscribe_sources( + configuration: &RhiConfigDocumentV1, + transport: &crate::RhiTransportAdapters, + time_entropy: &RhiTimeEntropyAdapters, + sources: &[SourceBinding], +) -> Result<(SubscriptionRequest, radroots_transport::BoxSubscription), HostError> { + let targets = TargetSet::new(sources.iter().map(|source| source.target.clone()).collect()) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + let deadline = time_entropy + .now_utc_milliseconds() + .ok() + .and_then(|now| now.checked_add(maximum_source_deadline(configuration).ok()?)) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + let selector = FetchSelector::all() + .with_kinds(TRADE_EVENT_KINDS.to_vec()) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + let request = SubscriptionRequest::new( + "rhi-source-subscription", + targets, + SubscriptionBounds::new(1_000, deadline) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?, + ) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))? + .with_selector(selector); + let subscription = transport + .evidence_subscriber() + .subscribe(request.clone()) + .await + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + Ok((request, subscription)) +} + +fn configured_sources( + configuration: &RhiConfigDocumentV1, +) -> Result<Vec<SourceBinding>, HostError> { + let relays = configuration + .normalized() + .pointer("/relays") + .and_then(Value::as_array) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + let relay_urls = relays + .iter() + .map(|relay| { + let id = relay.pointer("/id").and_then(Value::as_str)?; + let url = relay.pointer("/url").and_then(Value::as_str)?; + Some((id, url)) + }) + .collect::<Option<BTreeMap<_, _>>>() + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + configuration + .normalized() + .pointer("/evidence/sources") + .and_then(Value::as_array) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))? + .iter() + .map(|source| { + let source_id = source + .pointer("/source_id") + .and_then(Value::as_str) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + let relay_id = source + .pointer("/relay_id") + .and_then(Value::as_str) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + let url = relay_urls + .get(relay_id) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + Ok(SourceBinding { + source_id: source_id.into(), + target: Target::nostr_relay(url) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?, + }) + }) + .collect() +} + +fn spawn_admin( + supervisor: &mut radroots_service_host::TaskSupervisor, + server: RhiBoundAdminServer, +) -> Result<(), RhiDaemonError> { + supervisor + .spawn( + task_metadata(TASK_ADMIN_SERVER, TaskClassification::Critical, ShutdownPhase::CloseSockets)?, + move |cancellation| async move { + let token = RhiAdminCancellationToken::new(); + let serve_token = token.clone(); + let serve = server.serve(serve_token); + tokio::pin!(serve); + tokio::select! { + result = serve.as_mut() => result.map_err(|error| HostError::with_source(HostErrorKind::AdminTransport, error)), + () = cancellation.cancelled() => { + token.cancel(); + serve.await.map_err(|error| HostError::with_source(HostErrorKind::AdminTransport, error)) + } + } + }, + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +fn spawn_operations( + supervisor: &mut radroots_service_host::TaskSupervisor, + server: RhiBoundOperationsServer, +) -> Result<(), RhiDaemonError> { + supervisor + .spawn( + task_metadata(TASK_OPERATIONS_SERVER, TaskClassification::Optional, ShutdownPhase::CloseSockets)?, + move |cancellation| async move { + let token = RhiOperationsCancellationToken::new(); + let serve_token = token.clone(); + let serve = server.serve(serve_token); + tokio::pin!(serve); + tokio::select! { + result = serve.as_mut() => result.map_err(|error| HostError::with_source(HostErrorKind::OperationsServe, error)), + () = cancellation.cancelled() => { + token.cancel(); + serve.await.map_err(|error| HostError::with_source(HostErrorKind::OperationsServe, error)) + } + } + }, + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +fn spawn_source_subscription( + supervisor: &mut radroots_service_host::TaskSupervisor, + state: Arc<RhiStateHost>, + configuration: Arc<RhiConfigDocumentV1>, + transport: crate::RhiTransportAdapters, + time_entropy: RhiTimeEntropyAdapters, + initial: InitialSubscription, + health: tokio::sync::watch::Sender<bool>, +) -> Result<(), RhiDaemonError> { + supervisor + .spawn( + task_metadata( + TASK_SOURCE_SUBSCRIPTION, + TaskClassification::Critical, + ShutdownPhase::CancelIngress, + )?, + move |cancellation| async move { + run_source_subscription( + cancellation, + state, + configuration, + transport, + time_entropy, + initial, + health, + ) + .await + }, + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +async fn run_source_subscription( + cancellation: radroots_service_host::CancellationToken, + state: Arc<RhiStateHost>, + configuration: Arc<RhiConfigDocumentV1>, + transport: crate::RhiTransportAdapters, + time_entropy: RhiTimeEntropyAdapters, + mut active: InitialSubscription, + health: tokio::sync::watch::Sender<bool>, +) -> Result<(), HostError> { + let policy = RhiReconciliationJobPolicy::from_configuration(&configuration) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let retry_delay = worker_idle(&configuration)?; + loop { + let next = tokio::select! { + () = cancellation.cancelled() => { + active.subscription.cancel().await + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + return Ok(()); + } + next = active.subscription.next() => next, + }; + let reconnect = match next { + Ok(SubscriptionNext::Event(event)) => { + event + .validate_for_request(&active.request) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + let Some(source) = active.sources.iter().find(|source| { + source.target.fingerprint() == event.observed().provenance().target() + }) else { + return Err(HostError::new(HostErrorKind::TaskFailure)); + }; + let now = time_entropy + .now_utc() + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))? + .get(); + let observed = RhiTradeMutationObservedAtUnixSeconds::new(now) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let limits = RhiTradeMutationAdmissionLimits::from_config(&configuration) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let admitted = match admit_rhi_trade_mutation_event( + limits, + event.observed().event().raw_json().as_bytes(), + observed, + RhiTradeMutationAuthoredTimePolicy::new(0).map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?, + ) { + Ok(admitted) => admitted, + Err(_) => continue, + }; + let trade_id = admitted.mutation().trade_id; + let attempt = RhiTradeSourceAttempt::new( + subscription_attempt_id(event.checkpoint().cursor().as_str()), + radroots_service_host::UnixTimeSeconds::new(now), + observed, + RhiTradeMutationAuthoredTimePolicy::new(0).map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?, + ) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let outcome = source_ingest::ingest_rhi_subscribed_trade_event( + &state.repositories(), + &configuration, + &source.source_id, + admitted, + attempt, + ) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + if outcome.dirty_generation_advanced() { + state + .repositories() + .reconciliation_jobs() + .schedule_trade( + trade_id, + policy, + RhiReconciliationUnixMilliseconds::new( + now.checked_mul(1_000) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?, + ) + .map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?, + ) + .await + .map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?; + } + false + } + Ok(SubscriptionNext::End(end)) => { + end.validate_for_request(&active.request) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + true + } + Err(_) => true, + }; + if !reconnect { + continue; + } + health.send_replace(false); + loop { + tokio::select! { + () = cancellation.cancelled() => return Ok(()), + () = tokio::time::sleep(retry_delay) => {} + } + match subscribe_sources(&configuration, &transport, &time_entropy, &active.sources) + .await + { + Ok((request, subscription)) => { + active.request = request; + active.subscription = subscription; + health.send_replace(true); + break; + } + Err(_) => continue, + } + } + } +} + +fn spawn_reconciliation_worker( + supervisor: &mut radroots_service_host::TaskSupervisor, + state: Arc<RhiStateHost>, + configuration: Arc<RhiConfigDocumentV1>, + transport: crate::RhiTransportAdapters, + time_entropy: RhiTimeEntropyAdapters, + identity: Arc<crate::RhiDecryptedIdentity>, + publication: Arc<RhiPublicationAuthority>, +) -> Result<(), RhiDaemonError> { + supervisor + .spawn( + task_metadata( + TASK_RECONCILIATION_WORKER, + TaskClassification::Critical, + ShutdownPhase::DrainOperations, + )?, + move |cancellation| async move { + run_reconciliation_worker( + cancellation, + state, + configuration, + transport, + time_entropy, + identity, + publication, + ) + .await + }, + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +async fn run_reconciliation_worker( + cancellation: radroots_service_host::CancellationToken, + state: Arc<RhiStateHost>, + configuration: Arc<RhiConfigDocumentV1>, + transport: crate::RhiTransportAdapters, + time_entropy: RhiTimeEntropyAdapters, + identity: Arc<crate::RhiDecryptedIdentity>, + publication: Arc<RhiPublicationAuthority>, +) -> Result<(), HostError> { + let owner = RhiReconciliationLeaseOwner::from_bytes(nonzero_entropy_16(&time_entropy)?) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let idle = Duration::from_millis( + configuration_integer(&configuration, "/reconciliation/initial_backoff_ms") + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?, + ); + loop { + if cancellation.is_cancelled() { + return Ok(()); + } + let now = reconciliation_now_with(&time_entropy)?; + let lease = state + .repositories() + .reconciliation_jobs() + .claim_next(owner, now) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let Some(lease) = lease else { + tokio::select! { + () = cancellation.cancelled() => return Ok(()), + () = tokio::time::sleep(idle) => continue, + } + }; + let completed = execute_reconciliation_attempt( + &cancellation, + &state, + &configuration, + &transport, + &time_entropy, + &identity, + &publication, + lease, + now, + ) + .await; + if completed.is_err() && !cancellation.is_cancelled() { + let maximum = RhiJitterBoundMilliseconds::new(lease.retry_delay_upper_bound()) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let delay = time_entropy + .sample_full_jitter(maximum) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + state + .repositories() + .reconciliation_jobs() + .record_failure( + lease, + reconciliation_now_with(&time_entropy)?, + RhiReconciliationRetryDelayMilliseconds::new(delay.get()).map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?, + ) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + } else if cancellation.is_cancelled() { + return Ok(()); + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn execute_reconciliation_attempt( + cancellation: &radroots_service_host::CancellationToken, + state: &RhiStateHost, + configuration: &RhiConfigDocumentV1, + transport: &crate::RhiTransportAdapters, + time_entropy: &RhiTimeEntropyAdapters, + identity: &crate::RhiDecryptedIdentity, + publication: &RhiPublicationAuthority, + lease: RhiReconciliationLease, + started_at: RhiReconciliationUnixMilliseconds, +) -> Result<(), HostError> { + let plan = RhiReconciliationAttemptPlan::from_claim(lease, configuration, started_at) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let mut replays = Vec::with_capacity(plan.requests().len()); + for request in plan.requests() { + if cancellation.is_cancelled() { + return Err(HostError::new(HostErrorKind::TaskFailure)); + } + let prior = reconciliation_replay::read_committed_reconciliation_cursor( + &state.repositories(), + request, + plan.evidence_policy_digest(), + ) + .await + .map_err(|()| HostError::new(HostErrorKind::TaskFailure))?; + let replay = + RhiReconciliationSourceReplayPlan::from_request(&plan, request, configuration, prior) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + replays.push( + fetch_reconciliation_source( + cancellation, + transport, + time_entropy, + configuration, + request, + replay, + ) + .await?, + ); + } + let committed = state + .repositories() + .reconciliation_attempts() + .commit_source_replays(lease, plan, replays) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let observed_at = time_entropy + .now_utc() + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let manifest = committed + .into_evidence_manifest(observed_at, RhiReconciliationScopePrerequisites::Satisfied) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let projection = reduce_rhi_reconciliation_manifest(manifest) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let claim = *projection + .root_mutation_id() + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + let evaluation = evaluate_rhi_reconciliation_claim(projection, claim); + let now = reconciliation_now_with(time_entropy)?; + let fence = state + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, now) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + let supersession = + current_verified_supersession(state, fence.evaluation().projection().trade_id()).await?; + let attestation = build_rhi_signed_evidence_attestation( + fence, + identity, + observed_at, + time_entropy.entropy(), + supersession, + ) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + state + .repositories() + .reconciliation_attempts() + .commit_finalization(&attestation, publication, now) + .await + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + Ok(()) +} + +async fn current_verified_supersession( + state: &RhiStateHost, + trade_id: &TradeId, +) -> Result<Option<RhiEvidenceAttestationSupersession>, HostError> { + let trade_id = *trade_id; + state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let rows = sqlx::query( + r#"SELECT + CASE WHEN typeof(report.statement_sha256) = 'blob' + AND length(report.statement_sha256) = 32 + THEN report.statement_sha256 ELSE NULL END AS statement_sha256, + CASE WHEN typeof(event.event_id) = 'blob' AND length(event.event_id) = 32 + THEN event.event_id ELSE NULL END AS event_id, + CASE WHEN typeof(report.canonical_report) = 'blob' + AND length(report.canonical_report) BETWEEN 1 AND 16384 + THEN report.canonical_report ELSE NULL END AS canonical_report, + CASE WHEN typeof(event.canonical_event_json) = 'blob' + AND length(event.canonical_event_json) BETWEEN 1 AND 32768 + THEN event.canonical_event_json ELSE NULL END AS canonical_event_json + FROM attestation_reports AS report + JOIN signed_attestation_events AS event + ON event.statement_sha256 = report.statement_sha256 + WHERE report.trade_id = ? AND NOT EXISTS ( + SELECT 1 FROM attestation_reports AS successor + WHERE successor.supersedes_statement_sha256 = report.statement_sha256 + ) + ORDER BY report.observed_at_unix_s DESC, report.statement_sha256 DESC + LIMIT 2"#, + ) + .bind(trade_id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ())?; + if rows.is_empty() { + return Ok(None); + } + if rows.len() != 1 { + return Err(()); + } + let row = &rows[0]; + let statement = bounded_blob::<32>(row, "statement_sha256")?; + let event_id = bounded_blob::<32>(row, "event_id")?; + let canonical_report = row + .try_get::<Option<Vec<u8>>, _>("canonical_report") + .map_err(|_| ())? + .ok_or(())?; + let canonical_event = row + .try_get::<Option<Vec<u8>>, _>("canonical_event_json") + .map_err(|_| ())? + .ok_or(())?; + RhiEvidenceAttestationSupersession::from_persisted( + &trade_id, + statement, + event_id, + &canonical_report, + &canonical_event, + ) + .map(Some) + .map_err(|_| ()) + }) + }) + .await + .map_err(|_| HostError::new(HostErrorKind::TaskFailure)) +} + +fn bounded_blob<const N: usize>( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<[u8; N], ()> { + row.try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| ())? + .ok_or(())? + .try_into() + .map_err(|_| ()) +} + +async fn fetch_reconciliation_source( + cancellation: &radroots_service_host::CancellationToken, + transport: &crate::RhiTransportAdapters, + time_entropy: &RhiTimeEntropyAdapters, + configuration: &RhiConfigDocumentV1, + source_request: &RhiReconciliationSourceRequest, + replay: RhiReconciliationSourceReplayPlan, +) -> Result<RhiReconciliationSourceReplay, HostError> { + let source = configured_sources(configuration)? + .into_iter() + .find(|source| source.source_id.as_ref() == source_request.source_id()) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?; + let target_fingerprint = source.target.fingerprint().clone(); + let targets = TargetSet::new(vec![source.target]) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + let selector = FetchSelector::all() + .with_kinds(TRADE_EVENT_KINDS.to_vec()) + .and_then(|selector| selector.with_exact_tag_value('d', source_request.trade_id().to_hex())) + .and_then(|selector| selector.with_since_unix_seconds(replay.since_unix_seconds())) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + let started_at = reconciliation_now_with(time_entropy)?; + let request_id = format!( + "rhi-reconcile-{}", + lower_hex(source_request.id().as_bytes()) + ); + let maximum_events = usize::try_from(source_request.maximum_events()) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + let mut adapter_cursor = None::<FetchCursor>; + let mut seen_cursors = BTreeSet::new(); + let mut events = Vec::<RhiAdmittedTradeMutationEvent>::new(); + let mut original_bytes = 0_u64; + let mut completion = 'pages: loop { + if cancellation.is_cancelled() { + break RhiTradeSourceCompletion::IncompleteTimeout; + } + let remaining = maximum_events.saturating_sub(events.len()); + let limit = usize::min( + usize::from(FETCH_PAGE_MAX_EVENTS), + remaining.saturating_add(1), + ); + let bounds = FetchBounds::new( + u16::try_from(limit).map_err(|_| HostError::new(HostErrorKind::TaskFailure))?, + source_request.deadline().get(), + ) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + let mut request = FetchRequest::new(request_id.clone(), targets.clone(), bounds) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))? + .with_selector(selector.clone()); + if let Some(cursor) = adapter_cursor.take() { + request = request.with_cursor(cursor); + } + let fetched = tokio::select! { + () = cancellation.cancelled() => { + break 'pages RhiTradeSourceCompletion::IncompleteTimeout; + } + fetched = transport.evidence_source().fetch(request.clone()) => fetched, + }; + let page = match fetched { + Ok(page) => page, + Err(radroots_transport::Error::UnsupportedOperation) => { + events.clear(); + break RhiTradeSourceCompletion::Unsupported; + } + Err(_) => break RhiTradeSourceCompletion::IncompleteUnavailable, + }; + if page.validate_for_request(&request).is_err() { + break RhiTradeSourceCompletion::IncompleteUnknown; + } + let Some(outcome) = page + .target_outcomes() + .iter() + .find(|outcome| outcome.target() == &target_fingerprint) + .filter(|_| page.target_outcomes().len() == 1) + else { + break RhiTradeSourceCompletion::IncompleteUnknown; + }; + match outcome.state() { + FetchTargetState::Complete | FetchTargetState::Partial => {} + FetchTargetState::Unavailable | FetchTargetState::FailedRetryable => { + break RhiTradeSourceCompletion::IncompleteUnavailable; + } + FetchTargetState::Cancelled => { + break RhiTradeSourceCompletion::IncompleteTimeout; + } + FetchTargetState::FailedTerminal => { + break RhiTradeSourceCompletion::IncompleteUnknown; + } + } + for observed in page.events() { + let bytes = u64::try_from(observed.event().raw_json().len()) + .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?; + original_bytes = original_bytes.saturating_add(bytes); + if events.len() >= maximum_events || original_bytes > source_request.maximum_bytes() { + break 'pages RhiTradeSourceCompletion::IncompleteResourceLimit; + } + let observed_at = RhiTradeMutationObservedAtUnixSeconds::new( + observed.provenance().observed_at_unix_ms() / 1_000, + ) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + if let Ok(admitted) = admit_rhi_trade_mutation_event( + RhiTradeMutationAdmissionLimits::from_config(configuration) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?, + observed.event().raw_json().as_bytes(), + observed_at, + RhiTradeMutationAuthoredTimePolicy::new(0) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?, + ) && admitted.mutation().trade_id == source_request.trade_id() + { + events.push(admitted); + } + } + if outcome.state() == FetchTargetState::Partial { + break RhiTradeSourceCompletion::IncompleteUnknown; + } + match page.next_page() { + NextPage::Complete => break RhiTradeSourceCompletion::Complete, + NextPage::Cancelled { .. } => break RhiTradeSourceCompletion::IncompleteTimeout, + NextPage::Cursor(cursor) => { + if page.events().is_empty() || !seen_cursors.insert(cursor.as_str().to_owned()) { + break RhiTradeSourceCompletion::IncompleteUnknown; + } + adapter_cursor = Some(cursor.clone()); + } + } + }; + let mut finished_at = reconciliation_now_with(time_entropy)?; + if finished_at >= source_request.deadline() { + completion = RhiTradeSourceCompletion::IncompleteTimeout; + finished_at = source_request.deadline(); + } + replay + .finish(source_request, completion, started_at, finished_at, events) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error)) +} + +fn spawn_publication_worker( + supervisor: &mut radroots_service_host::TaskSupervisor, + state: Arc<RhiStateHost>, + configuration: Arc<RhiConfigDocumentV1>, + authority: Arc<RhiPublicationAuthority>, + sink: Arc<RhiNostrExactSink>, + time_entropy: RhiTimeEntropyAdapters, +) -> Result<(), RhiDaemonError> { + supervisor + .spawn( + task_metadata( + TASK_PUBLICATION_WORKER, + TaskClassification::Critical, + ShutdownPhase::PersistRecoverableWork, + )?, + move |cancellation| async move { + let owner = + RhiPublicationLeaseOwner::from_bytes(nonzero_entropy_16(&time_entropy)?) + .map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?; + let idle = worker_idle(&configuration)?; + loop { + if cancellation.is_cancelled() { + return Ok(()); + } + let result = state + .repositories() + .publication_outbox() + .execute_next_publication(owner, &time_entropy, sink.as_ref(), &authority) + .await + .map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?; + if result.is_none() { + tokio::select! { + () = cancellation.cancelled() => return Ok(()), + () = tokio::time::sleep(idle) => {} + } + } + } + }, + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +fn spawn_presence_worker( + supervisor: &mut radroots_service_host::TaskSupervisor, + state: Arc<RhiStateHost>, + configuration: Arc<RhiConfigDocumentV1>, + sink: Arc<RhiNostrExactSink>, + time_entropy: RhiTimeEntropyAdapters, +) -> Result<(), RhiDaemonError> { + supervisor + .spawn( + task_metadata( + TASK_PRESENCE_WORKER, + TaskClassification::Critical, + ShutdownPhase::PersistRecoverableWork, + )?, + move |cancellation| async move { + let owner = RhiPresenceLeaseOwner::from_bytes(nonzero_entropy_16(&time_entropy)?) + .map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?; + let idle = worker_idle(&configuration)?; + loop { + if cancellation.is_cancelled() { + return Ok(()); + } + let result = state + .repositories() + .presence_outbox() + .execute_next_presence(owner, &time_entropy, sink.as_ref()) + .await + .map_err(|error| { + HostError::with_source(HostErrorKind::TaskFailure, error) + })?; + if result.is_none() { + tokio::select! { + () = cancellation.cancelled() => return Ok(()), + () = tokio::time::sleep(idle) => {} + } + } + } + }, + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +fn task_metadata( + name: &'static str, + classification: TaskClassification, + phase: ShutdownPhase, +) -> Result<TaskMetadata, RhiDaemonError> { + TaskMetadata::new( + TaskName::new(name).map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?, + classification, + Some(phase), + ) + .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +fn runtime_build_info() -> Result<RhiStatusBuildInfoV1, HostError> { + let service_revision = option_env!("RADROOTS_SERVICE_REVISION"); + let lib_revision = option_env!("RADROOTS_LIB_REVISION"); + let rust_version = option_env!("RADROOTS_RUST_VERSION"); + let target = option_env!("RADROOTS_BUILD_TARGET"); + let mode = if service_revision.is_some() + && lib_revision.is_some() + && rust_version.is_some() + && target.is_some() + { + RhiStatusBuildMode::Release + } else { + RhiStatusBuildMode::Development + }; + RhiStatusBuildInfoV1::new( + mode, + Some(env!("CARGO_PKG_VERSION")), + service_revision, + lib_revision, + rust_version, + target, + Some("service-host"), + ) + .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error)) +} + +fn operations_enabled(configuration: &RhiConfigDocumentV1) -> Result<bool, RhiDaemonError> { + configuration + .normalized() + .pointer("/operations/enabled") + .and_then(Value::as_bool) + .ok_or_else(|| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +fn maximum_source_deadline(configuration: &RhiConfigDocumentV1) -> Result<u64, HostError> { + configuration + .normalized() + .pointer("/evidence/sources") + .and_then(Value::as_array) + .and_then(|sources| { + sources + .iter() + .filter_map(|source| source.pointer("/deadline_ms").and_then(Value::as_u64)) + .max() + }) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure)) +} + +fn configuration_integer( + configuration: &RhiConfigDocumentV1, + pointer: &str, +) -> Result<u64, RhiDaemonError> { + configuration + .normalized() + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| RhiDaemonError::new(RhiDaemonErrorKind::Runtime)) +} + +fn worker_idle(configuration: &RhiConfigDocumentV1) -> Result<Duration, HostError> { + configuration + .normalized() + .pointer("/reconciliation/initial_backoff_ms") + .and_then(Value::as_u64) + .map(Duration::from_millis) + .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure)) +} + +fn reconciliation_now_with( + time_entropy: &RhiTimeEntropyAdapters, +) -> Result<RhiReconciliationUnixMilliseconds, HostError> { + let milliseconds = time_entropy + .now_utc_milliseconds() + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + RhiReconciliationUnixMilliseconds::new(milliseconds) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error)) +} + +fn subscription_attempt_id(cursor: &str) -> String { + let digest = Sha256::digest(cursor.as_bytes()); + format!("subscription-{}", lower_hex(&digest)) +} + +fn nonzero_entropy_16(time_entropy: &RhiTimeEntropyAdapters) -> Result<[u8; 16], HostError> { + for _ in 0..4 { + let mut bytes = [0_u8; 16]; + time_entropy + .entropy() + .fill_bytes(&mut bytes) + .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?; + if bytes.iter().any(|byte| *byte != 0) { + return Ok(bytes); + } + } + Err(HostError::new(HostErrorKind::TaskFailure)) +} + +fn lower_hex(bytes: &[u8]) -> String { + use core::fmt::Write as _; + + let mut encoded = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_status_context() -> RuntimeStatusContext { + let time_entropy = RhiTimeEntropyAdapters::system(); + RuntimeStatusContext { + configuration_digest: "0".repeat(64), + configuration_source: RhiStatusConfigurationSource::DerivedRepoLocal, + schema_version: crate::RHI_STATE_SCHEMA_VERSION, + generation: 1, + source_count: 1, + started_at: time_entropy.now_monotonic(), + time_entropy, + reconciliation: RhiReconciliationStatusV1::default(), + publication: RhiPublicationStatusV1::default(), + presence: RhiPresenceStatusV1::default(), + } + } + + #[test] + fn runtime_status_keeps_optional_operations_degradation_ready_and_reasoned() { + let context = empty_status_context(); + let observation = context + .observation(RhiServicePhase::Degraded, true, false) + .expect("degraded observation"); + let (_, reader) = crate::rhi_status_cache( + radroots_runtime_paths::InstanceId::new("primary").expect("instance"), + observation, + ) + .expect("status cache"); + let snapshot = reader.snapshot(); + assert!(snapshot.is_ready()); + let json = std::str::from_utf8(snapshot.detailed_status_json()).expect("status UTF-8"); + assert!(json.contains("\"operations_listener_failed\"")); + assert!(!json.contains("\"source_unavailable\"")); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test] + async fn runtime_status_refresh_reads_the_exact_empty_durable_work_summary() { + use std::{fs, os::unix::fs::PermissionsExt as _}; + + use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; + use radroots_storage::event::SourceGeneration; + + let root = tempfile::tempdir().expect("test root"); + let root_text = root.path().to_str().expect("UTF-8 root"); + let invocation = crate::parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root_text, + "run", + ]) + .expect("invocation"); + let runtime = crate::resolve_rhi_runtime_context( + &crate::RadrootsPathResolver::new( + crate::RadrootsPlatform::Linux, + crate::RadrootsHostEnvironment::default(), + ), + &invocation, + ) + .expect("runtime"); + fs::create_dir_all(runtime.context().paths().state()).expect("state root"); + fs::set_permissions( + runtime.context().paths().state(), + fs::Permissions::from_mode(0o700), + ) + .expect("state mode"); + let configuration = crate::parse_rhi_config_v1( + include_bytes!("../contracts/services_hardening/config.v1.example.toml"), + crate::RhiConfigProfile::RepoLocal, + ) + .expect("configuration"); + let metadata = crate::RhiStateMetadata::new( + &runtime, + &configuration, + SourceGeneration::new([0x41; 32]).expect("generation"), + 1_725_000_000_000, + ) + .expect("metadata"); + let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("time"); + let build = MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "21b11e7a5120ea949f7ad0838c746873fc73aac2", + "rustc-test", + "test-target", + "service-host", + 1, + crate::RHI_STATE_SCHEMA_VERSION, + 1, + 1, + 1, + ) + .expect("build"); + crate::initialize_rhi_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialize"); + let state = crate::open_rhi_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("open"); + let mut status = empty_status_context(); + status.refresh_state(&state).await.expect("refresh"); + assert_eq!(status.reconciliation, RhiReconciliationStatusV1::default()); + assert_eq!(status.publication, RhiPublicationStatusV1::default()); + assert_eq!(status.presence, RhiPresenceStatusV1::default()); + state.close().await.expect("close"); + } +} diff --git a/src/runtime_signal.rs b/src/runtime_signal.rs @@ -0,0 +1,50 @@ +//! Binary-owned process-signal injection for the Rhi daemon. + +use core::{fmt, future::Future, pin::Pin}; + +/// One normalized process signal supplied by the Rhi binary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiProcessSignal { + Interrupt, + #[cfg(unix)] + Terminate, +} + +impl RhiProcessSignal { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Interrupt => "interrupt", + #[cfg(unix)] + Self::Terminate => "terminate", + } + } +} + +impl fmt::Display for RhiProcessSignal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Boxed wait returned by a binary-owned signal source. +pub type RhiProcessSignalFuture<'a> = + Pin<Box<dyn Future<Output = Option<RhiProcessSignal>> + Send + 'a>>; + +/// Process-signal source installed only by the executable boundary. +pub trait RhiProcessSignalSource: Send { + fn next_signal(&mut self) -> RhiProcessSignalFuture<'_>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalized_signal_names_are_stable() { + assert_eq!(RhiProcessSignal::Interrupt.as_str(), "interrupt"); + assert_eq!(RhiProcessSignal::Interrupt.to_string(), "interrupt"); + #[cfg(unix)] + assert_eq!(RhiProcessSignal::Terminate.as_str(), "terminate"); + } +} diff --git a/src/source_ingest.rs b/src/source_ingest.rs @@ -400,6 +400,43 @@ pub async fn ingest_rhi_trade_source( commit_source_result(repositories, source, trade_id, attempt, initial, fetched).await } +/// Atomically commits one event that was already admitted from a governed +/// subscription. This keeps subscription delivery on the same checkpoint, +/// provenance, dirty-generation, and evidence transaction used by paged +/// source ingestion without issuing a second network request. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) async fn ingest_rhi_subscribed_trade_event( + repositories: &RhiStateRepositories<'_>, + configuration: &RhiConfigDocumentV1, + source_id: &str, + admitted: RhiAdmittedTradeMutationEvent, + attempt: RhiTradeSourceAttempt, +) -> Result<RhiTradeSourceIngestOutcome, RhiTradeSourceIngestError> { + let host = repositories.host(); + if host.mode() != RhiStateHostMode::ReadWriteExisting { + return Err(failure(RhiTradeSourceIngestErrorKind::InvalidMode)); + } + let source = ConfiguredSource::new(host, configuration, source_id)?; + let trade_id = admitted.mutation().trade_id; + if admitted.original_bytes().len() > source.maximum_bytes || source.maximum_events == 0 { + return Err(failure(RhiTradeSourceIngestErrorKind::InvalidInput)); + } + let cursor = RhiTradeSourceCursor { + created_at_unix_seconds: admitted.authored_at_unix_seconds(), + event_id: *admitted.event_id().as_bytes(), + }; + let initial = read_initial_state(repositories, &source, trade_id).await?; + let fetched = FetchedSource { + completion: RhiTradeSourceCompletion::Complete, + received_events: 1, + duplicate_events: 0, + admitted: vec![admitted], + rejected_events: 0, + cursor_candidate: Some(cursor), + }; + commit_source_result(repositories, source, trade_id, attempt, initial, fetched).await +} + struct ConfiguredSource { source_id: Box<str>, relay_url: Box<str>, diff --git a/src/state_admin.rs b/src/state_admin.rs @@ -0,0 +1,807 @@ +//! Bounded durable idempotency for permissioned Rhi admin mutations. + +use core::{fmt, future::Future, pin::Pin}; +use std::error::Error; + +use radroots_service_sqlite::{ + ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; +use sha2::{Digest, Sha256}; +use sqlx::Row; + +use crate::{ + RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiStateHost, + RhiStateHostMode, +}; + +/// Maximum encoded length of a durable admin operation identifier. +pub const RHI_ADMIN_OPERATION_ID_MAX_BYTES: usize = 128; +/// Maximum canonical response-model bytes retained for replay. +pub const RHI_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES: usize = 8_192; +/// Maximum retained completed operations after expiry pruning. +pub const RHI_ADMIN_OPERATION_COMPLETED_LIMIT: u16 = 4_096; +/// Maximum retained operations whose external outcome is unresolved. +pub const RHI_ADMIN_OPERATION_PREPARED_LIMIT: u8 = 128; +/// Frozen seven-day completed-response retention. +pub const RHI_ADMIN_OPERATION_DEFAULT_RETENTION_MS: u64 = 604_800_000; + +const REQUEST_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.admin_operation_request.v1\0"; +const PRUNE_LIMIT: i64 = 4_096; + +const PRUNE_EXPIRED_SQL: &str = r#"DELETE FROM rhi_admin_operations +WHERE operation_id IN ( + SELECT operation_id FROM rhi_admin_operations + WHERE state = 'completed' AND expires_at_unix_ms <= ? + ORDER BY expires_at_unix_ms, operation_id + LIMIT ? +)"#; + +const READ_OPERATION_SQL: &str = r#"SELECT + CASE WHEN typeof(route) = 'text' AND length(CAST(route AS BLOB)) BETWEEN 1 AND 128 + THEN route ELSE NULL END AS route, + CASE WHEN typeof(request_sha256) = 'blob' AND length(request_sha256) = 32 + THEN request_sha256 ELSE NULL END AS request_sha256, + CASE WHEN typeof(state) = 'text' AND length(CAST(state AS BLOB)) <= 16 + THEN state ELSE NULL END AS state, + CASE WHEN typeof(response_model) = 'blob' AND length(response_model) BETWEEN 1 AND 8192 + THEN response_model ELSE NULL END AS response_model, + typeof(response_model) AS response_model_type, + CASE WHEN typeof(response_sha256) = 'blob' AND length(response_sha256) = 32 + THEN response_sha256 ELSE NULL END AS response_sha256, + typeof(response_sha256) AS response_sha256_type, + prepared_at_unix_ms, + completed_at_unix_ms, + typeof(completed_at_unix_ms) AS completed_at_type, + expires_at_unix_ms, + typeof(expires_at_unix_ms) AS expires_at_type +FROM rhi_admin_operations +WHERE operation_id = ? +LIMIT 2"#; + +const READ_COUNTS_SQL: &str = r#"SELECT + COUNT(CASE WHEN state = 'completed' THEN 1 END) AS completed_count, + COUNT(CASE WHEN state = 'prepared' THEN 1 END) AS prepared_count +FROM rhi_admin_operations"#; + +const INSERT_PREPARED_SQL: &str = r#"INSERT INTO rhi_admin_operations ( + operation_id, route, request_sha256, state, prepared_at_unix_ms +) VALUES (?, ?, ?, 'prepared', ?)"#; + +const COMPLETE_OPERATION_SQL: &str = r#"UPDATE rhi_admin_operations +SET state = 'completed', response_model = ?, response_sha256 = ?, + completed_at_unix_ms = ?, expires_at_unix_ms = ? +WHERE operation_id = ? AND state = 'prepared'"#; + +/// Stable source-free admin-journal failure classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiAdminOperationErrorKind { + InvalidMode, + InvalidInput, + OperationConflict, + OperationOutcomeUnknown, + ResourceExhausted, + Binding, + Transaction, + CommitOutcomeUnknown, +} + +/// Redacted admin-journal error. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiAdminOperationError { + kind: RhiAdminOperationErrorKind, +} + +impl RhiAdminOperationError { + const fn new(kind: RhiAdminOperationErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiAdminOperationErrorKind { + self.kind + } +} + +impl fmt::Display for RhiAdminOperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiAdminOperationErrorKind::InvalidMode => { + "RHI admin operation requires writable state" + } + RhiAdminOperationErrorKind::InvalidInput => "RHI admin operation input is invalid", + RhiAdminOperationErrorKind::OperationConflict => { + "RHI admin operation identity conflicts with retained evidence" + } + RhiAdminOperationErrorKind::OperationOutcomeUnknown => { + "RHI admin operation outcome is unknown" + } + RhiAdminOperationErrorKind::ResourceExhausted => { + "RHI admin operation capacity is exhausted" + } + RhiAdminOperationErrorKind::Binding => "RHI admin operation journal binding is invalid", + RhiAdminOperationErrorKind::Transaction => "RHI admin operation transaction failed", + RhiAdminOperationErrorKind::CommitOutcomeUnknown => { + "RHI admin operation commit outcome is unknown" + } + }) + } +} + +impl fmt::Debug for RhiAdminOperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiAdminOperationError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiAdminOperationError {} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct AdminOperationIdBinding(Box<str>); + +impl AdminOperationIdBinding { + fn new(value: &str) -> Result<Self, RhiAdminOperationError> { + let bytes = value.as_bytes(); + let valid = !bytes.is_empty() + && bytes.len() <= RHI_ADMIN_OPERATION_ID_MAX_BYTES + && bytes[0].is_ascii_alphanumeric() + && bytes.iter().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') + }); + valid + .then(|| Self(value.into())) + .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AdminOperationIdBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AdminOperationIdBinding([redacted])") + } +} + +/// Injected UTC millisecond evidence representable by SQLite. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RhiAdminOperationTimeUnixMs(u64); + +impl RhiAdminOperationTimeUnixMs { + /// Validates one UTC millisecond instant without reading ambient time. + pub fn new(value: u64) -> Result<Self, RhiAdminOperationError> { + i64::try_from(value) + .map(|_| Self(value)) + .map_err(|_| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput)) + } + + /// Returns the validated instant. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + fn sqlite_value(self) -> i64 { + i64::try_from(self.0).expect("validated admin operation time fits SQLite") + } +} + +/// Explicit bounded completed-response retention policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RhiAdminOperationJournalPolicy { + completed_retention_ms: u64, +} + +impl RhiAdminOperationJournalPolicy { + /// Returns the exact seven-day policy. + #[must_use] + pub const fn seven_days() -> Self { + Self { + completed_retention_ms: RHI_ADMIN_OPERATION_DEFAULT_RETENTION_MS, + } + } + + /// Returns the admitted retention duration. + #[must_use] + pub const fn completed_retention_ms(self) -> u64 { + self.completed_retention_ms + } +} + +/// Sealed evidence that one external or cross-resource mutation is unresolved. +pub struct RhiPreparedAdminOperation { + operation_id: AdminOperationIdBinding, + route: RhiAdminRoute, + request_sha256: [u8; 32], + prepared_at: RhiAdminOperationTimeUnixMs, +} + +impl fmt::Debug for RhiPreparedAdminOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPreparedAdminOperation") + .field("route", &self.route) + .field("identity", &"[redacted]") + .finish() + } +} + +/// Result of mutation admission after bounded expiry pruning. +pub enum RhiAdminOperationAdmission { + Prepared(RhiPreparedAdminOperation), + ExactReplay(RhiAdminResponseDocument), +} + +impl fmt::Debug for RhiAdminOperationAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Prepared(_) => "RhiAdminOperationAdmission::Prepared([redacted])", + Self::ExactReplay(_) => "RhiAdminOperationAdmission::ExactReplay([redacted])", + }) + } +} + +/// Result of completing a previously prepared operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiAdminOperationCompletion { + Completed, + ExactReplay, +} + +pub(crate) struct RhiAdminOperationRepository<'host> { + host: &'host RhiStateHost, +} + +impl<'host> RhiAdminOperationRepository<'host> { + pub(crate) const fn new(host: &'host RhiStateHost) -> Self { + Self { host } + } + + /// Atomically commits one SQLite-only admin mutation and its replay receipt. + pub(crate) async fn execute_database_admin_operation<F>( + &self, + request: &RhiAdminRequestDocument, + completed_at: RhiAdminOperationTimeUnixMs, + policy: RhiAdminOperationJournalPolicy, + operation: F, + ) -> Result<RhiAdminResponseDocument, RhiAdminOperationError> + where + F: for<'a, 'b> FnOnce( + &'a mut ServiceSqliteTransaction<'b>, + ) -> AdminDatabaseOperationFuture<'a> + + Send + + 'static, + { + if self.host.mode() != RhiStateHostMode::ReadWriteExisting { + return Err(RhiAdminOperationError::new( + RhiAdminOperationErrorKind::InvalidMode, + )); + } + let binding = AdminRequestBinding::from_document(request)?; + let expires_at = completed_at + .get() + .checked_add(policy.completed_retention_ms()) + .filter(|value| i64::try_from(*value).is_ok()) + .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))?; + self.host + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let prepared = match prepare_operation(transaction, &binding, completed_at) + .await? + { + RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response), + RhiAdminOperationAdmission::Prepared(prepared) => prepared, + }; + let response = operation(transaction).await?; + if response.route() != binding.route + || response.canonical_bytes().is_empty() + || response.canonical_bytes().len() + > RHI_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES + { + return Err(AdminJournalOperationError::InvalidInput); + } + complete_operation( + transaction, + &PreparedBinding::from_prepared(&prepared), + response.canonical_bytes(), + completed_at, + expires_at, + ) + .await?; + Ok(response) + }) + }) + .await + .map_err(map_transaction_error) + } + + /// Prunes a bounded expired prefix and admits or replays one mutation. + pub async fn prepare_admin_operation( + &self, + request: &RhiAdminRequestDocument, + observed_at: RhiAdminOperationTimeUnixMs, + ) -> Result<RhiAdminOperationAdmission, RhiAdminOperationError> { + if self.host.mode() != RhiStateHostMode::ReadWriteExisting { + return Err(RhiAdminOperationError::new( + RhiAdminOperationErrorKind::InvalidMode, + )); + } + let binding = AdminRequestBinding::from_document(request)?; + self.host + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { prepare_operation(transaction, &binding, observed_at).await }) + }) + .await + .map_err(map_transaction_error) + } + + /// Completes one external mutation only after its durable effect exists. + pub async fn complete_admin_operation( + &self, + prepared: &RhiPreparedAdminOperation, + response: &RhiAdminResponseDocument, + completed_at: RhiAdminOperationTimeUnixMs, + policy: RhiAdminOperationJournalPolicy, + ) -> Result<RhiAdminOperationCompletion, RhiAdminOperationError> { + if self.host.mode() != RhiStateHostMode::ReadWriteExisting { + return Err(RhiAdminOperationError::new( + RhiAdminOperationErrorKind::InvalidMode, + )); + } + if response.route() != prepared.route + || response.canonical_bytes().is_empty() + || response.canonical_bytes().len() > RHI_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES + || completed_at < prepared.prepared_at + { + return Err(RhiAdminOperationError::new( + RhiAdminOperationErrorKind::InvalidInput, + )); + } + let expires_at = completed_at + .get() + .checked_add(policy.completed_retention_ms()) + .filter(|value| i64::try_from(*value).is_ok()) + .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))?; + let binding = PreparedBinding::from_prepared(prepared); + let response = response.canonical_bytes().to_vec().into_boxed_slice(); + self.host + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + complete_operation(transaction, &binding, &response, completed_at, expires_at) + .await + }) + }) + .await + .map_err(map_transaction_error) + } +} + +struct AdminRequestBinding { + operation_id: AdminOperationIdBinding, + route: RhiAdminRoute, + request_sha256: [u8; 32], +} + +impl AdminRequestBinding { + fn from_document(request: &RhiAdminRequestDocument) -> Result<Self, RhiAdminOperationError> { + if !request.route().is_mutation() { + return Err(RhiAdminOperationError::new( + RhiAdminOperationErrorKind::InvalidInput, + )); + } + let operation_id = request + .operation_id() + .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))?; + Ok(Self { + operation_id: AdminOperationIdBinding::new(operation_id)?, + route: request.route(), + request_sha256: request_digest(request), + }) + } +} + +struct PreparedBinding { + operation_id: AdminOperationIdBinding, + route: RhiAdminRoute, + request_sha256: [u8; 32], + prepared_at: RhiAdminOperationTimeUnixMs, +} + +impl PreparedBinding { + fn from_prepared(prepared: &RhiPreparedAdminOperation) -> Self { + Self { + operation_id: prepared.operation_id.clone(), + route: prepared.route, + request_sha256: prepared.request_sha256, + prepared_at: prepared.prepared_at, + } + } +} + +enum StoredOperation { + Prepared { + route: RhiAdminRoute, + request_sha256: [u8; 32], + prepared_at: RhiAdminOperationTimeUnixMs, + }, + Completed { + route: RhiAdminRoute, + request_sha256: [u8; 32], + response: Box<[u8]>, + response_sha256: [u8; 32], + prepared_at: RhiAdminOperationTimeUnixMs, + completed_at: RhiAdminOperationTimeUnixMs, + expires_at: RhiAdminOperationTimeUnixMs, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AdminJournalOperationError { + InvalidInput, + Conflict, + OutcomeUnknown, + ResourceExhausted, + Binding, + Storage, +} + +pub(crate) type AdminDatabaseOperationFuture<'a> = Pin< + Box< + dyn Future<Output = Result<RhiAdminResponseDocument, AdminJournalOperationError>> + + Send + + 'a, + >, +>; + +async fn prepare_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + binding: &AdminRequestBinding, + observed_at: RhiAdminOperationTimeUnixMs, +) -> Result<RhiAdminOperationAdmission, AdminJournalOperationError> { + prune_expired(transaction, observed_at).await?; + if let Some(existing) = read_operation(transaction, &binding.operation_id).await? { + return match existing { + StoredOperation::Prepared { + route, + request_sha256, + .. + } if route == binding.route && request_sha256 == binding.request_sha256 => { + Err(AdminJournalOperationError::OutcomeUnknown) + } + StoredOperation::Completed { + route, + request_sha256, + response, + response_sha256, + .. + } if route == binding.route && request_sha256 == binding.request_sha256 => { + if sha256(&response) != response_sha256 { + return Err(AdminJournalOperationError::Binding); + } + RhiAdminResponseDocument::from_canonical_bytes(route, &response) + .map(RhiAdminOperationAdmission::ExactReplay) + .map_err(|_| AdminJournalOperationError::Binding) + } + StoredOperation::Prepared { .. } | StoredOperation::Completed { .. } => { + Err(AdminJournalOperationError::Conflict) + } + }; + } + let (completed, prepared) = read_counts(transaction).await?; + let reserved = completed + .checked_add(prepared) + .ok_or(AdminJournalOperationError::Binding)?; + if reserved >= u64::from(RHI_ADMIN_OPERATION_COMPLETED_LIMIT) + || prepared >= u64::from(RHI_ADMIN_OPERATION_PREPARED_LIMIT) + { + return Err(AdminJournalOperationError::ResourceExhausted); + } + let result = sqlx::query(INSERT_PREPARED_SQL) + .bind(binding.operation_id.as_str()) + .bind(binding.route.operation_id()) + .bind(binding.request_sha256.as_slice()) + .bind(observed_at.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + require_one(result.rows_affected())?; + match read_operation(transaction, &binding.operation_id).await? { + Some(StoredOperation::Prepared { + route, + request_sha256, + prepared_at, + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && prepared_at == observed_at => + { + Ok(RhiAdminOperationAdmission::Prepared( + RhiPreparedAdminOperation { + operation_id: binding.operation_id.clone(), + route, + request_sha256, + prepared_at, + }, + )) + } + Some(_) | None => Err(AdminJournalOperationError::Binding), + } +} + +async fn complete_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + binding: &PreparedBinding, + response: &[u8], + completed_at: RhiAdminOperationTimeUnixMs, + expires_at: u64, +) -> Result<RhiAdminOperationCompletion, AdminJournalOperationError> { + let response_sha256 = sha256(response); + match read_operation(transaction, &binding.operation_id).await? { + Some(StoredOperation::Completed { + route, + request_sha256, + response: existing_response, + response_sha256: existing_sha256, + .. + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && existing_response.as_ref() == response + && existing_sha256 == response_sha256 => + { + return Ok(RhiAdminOperationCompletion::ExactReplay); + } + Some(StoredOperation::Completed { .. }) => { + return Err(AdminJournalOperationError::Conflict); + } + Some(StoredOperation::Prepared { + route, + request_sha256, + prepared_at, + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && prepared_at == binding.prepared_at => {} + Some(StoredOperation::Prepared { .. }) => { + return Err(AdminJournalOperationError::Conflict); + } + None => return Err(AdminJournalOperationError::Binding), + } + let (completed, _) = read_counts(transaction).await?; + if completed >= u64::from(RHI_ADMIN_OPERATION_COMPLETED_LIMIT) { + return Err(AdminJournalOperationError::ResourceExhausted); + } + let result = sqlx::query(COMPLETE_OPERATION_SQL) + .bind(response) + .bind(response_sha256.as_slice()) + .bind(completed_at.sqlite_value()) + .bind(i64::try_from(expires_at).map_err(|_| AdminJournalOperationError::InvalidInput)?) + .bind(binding.operation_id.as_str()) + .execute(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + require_one(result.rows_affected())?; + match read_operation(transaction, &binding.operation_id).await? { + Some(StoredOperation::Completed { + route, + request_sha256, + response: actual_response, + response_sha256: actual_sha256, + prepared_at, + completed_at: actual_completed_at, + expires_at: actual_expires_at, + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && actual_response.as_ref() == response + && actual_sha256 == response_sha256 + && prepared_at == binding.prepared_at + && actual_completed_at == completed_at + && actual_expires_at.get() == expires_at => + { + Ok(RhiAdminOperationCompletion::Completed) + } + Some(_) | None => Err(AdminJournalOperationError::Binding), + } +} + +async fn prune_expired( + transaction: &mut ServiceSqliteTransaction<'_>, + observed_at: RhiAdminOperationTimeUnixMs, +) -> Result<(), AdminJournalOperationError> { + sqlx::query(PRUNE_EXPIRED_SQL) + .bind(observed_at.sqlite_value()) + .bind(PRUNE_LIMIT) + .execute(&mut *transaction) + .await + .map(|_| ()) + .map_err(|_| AdminJournalOperationError::Storage) +} + +async fn read_counts( + transaction: &mut ServiceSqliteTransaction<'_>, +) -> Result<(u64, u64), AdminJournalOperationError> { + let rows = sqlx::query(READ_COUNTS_SQL) + .fetch_all(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if rows.len() != 1 { + return Err(AdminJournalOperationError::Binding); + } + let completed = rows[0] + .try_get::<i64, _>("completed_count") + .map_err(|_| AdminJournalOperationError::Binding)?; + let prepared = rows[0] + .try_get::<i64, _>("prepared_count") + .map_err(|_| AdminJournalOperationError::Binding)?; + Ok(( + u64::try_from(completed).map_err(|_| AdminJournalOperationError::Binding)?, + u64::try_from(prepared).map_err(|_| AdminJournalOperationError::Binding)?, + )) +} + +async fn read_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: &AdminOperationIdBinding, +) -> Result<Option<StoredOperation>, AdminJournalOperationError> { + let rows = sqlx::query(READ_OPERATION_SQL) + .bind(operation_id.as_str()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if rows.len() > 1 { + return Err(AdminJournalOperationError::Binding); + } + rows.first().map(decode_operation).transpose() +} + +fn decode_operation( + row: &sqlx::sqlite::SqliteRow, +) -> Result<StoredOperation, AdminJournalOperationError> { + let route = row + .try_get::<Option<&str>, _>("route") + .map_err(|_| AdminJournalOperationError::Binding)? + .and_then(parse_route) + .ok_or(AdminJournalOperationError::Binding)?; + let request_sha256 = exact_digest(row, "request_sha256")?; + let state = row + .try_get::<Option<&str>, _>("state") + .map_err(|_| AdminJournalOperationError::Binding)? + .ok_or(AdminJournalOperationError::Binding)?; + let prepared_at = time(row, "prepared_at_unix_ms")?; + match state { + "prepared" => { + require_null(row, "response_model_type")?; + require_null(row, "response_sha256_type")?; + require_null(row, "completed_at_type")?; + require_null(row, "expires_at_type")?; + Ok(StoredOperation::Prepared { + route, + request_sha256, + prepared_at, + }) + } + "completed" => { + require_type(row, "response_model_type", "blob")?; + require_type(row, "response_sha256_type", "blob")?; + require_type(row, "completed_at_type", "integer")?; + require_type(row, "expires_at_type", "integer")?; + let response = row + .try_get::<Option<Vec<u8>>, _>("response_model") + .map_err(|_| AdminJournalOperationError::Binding)? + .ok_or(AdminJournalOperationError::Binding)? + .into_boxed_slice(); + Ok(StoredOperation::Completed { + route, + request_sha256, + response, + response_sha256: exact_digest(row, "response_sha256")?, + prepared_at, + completed_at: time(row, "completed_at_unix_ms")?, + expires_at: time(row, "expires_at_unix_ms")?, + }) + } + _ => Err(AdminJournalOperationError::Binding), + } +} + +fn request_digest(request: &RhiAdminRequestDocument) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(REQUEST_DIGEST_DOMAIN); + hash_field(&mut hasher, request.route().operation_id().as_bytes()); + hasher.update([0]); + hash_field(&mut hasher, request.model_bytes()); + hasher.finalize().into() +} + +fn hash_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update( + u64::try_from(bytes.len()) + .expect("bounded field length") + .to_be_bytes(), + ); + hasher.update(bytes); +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +fn parse_route(value: &str) -> Option<RhiAdminRoute> { + RhiAdminRoute::ALL + .into_iter() + .find(|route| route.is_mutation() && route.operation_id() == value) +} + +fn exact_digest( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<[u8; 32], AdminJournalOperationError> { + row.try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| AdminJournalOperationError::Binding)? + .ok_or(AdminJournalOperationError::Binding)? + .try_into() + .map_err(|_| AdminJournalOperationError::Binding) +} + +fn time( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<RhiAdminOperationTimeUnixMs, AdminJournalOperationError> { + let value = row + .try_get::<i64, _>(column) + .map_err(|_| AdminJournalOperationError::Binding)?; + RhiAdminOperationTimeUnixMs::new( + u64::try_from(value).map_err(|_| AdminJournalOperationError::Binding)?, + ) + .map_err(|_| AdminJournalOperationError::Binding) +} + +fn require_null( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<(), AdminJournalOperationError> { + require_type(row, column, "null") +} + +fn require_type( + row: &sqlx::sqlite::SqliteRow, + column: &str, + expected: &str, +) -> Result<(), AdminJournalOperationError> { + (row.try_get::<&str, _>(column) + .map_err(|_| AdminJournalOperationError::Binding)? + == expected) + .then_some(()) + .ok_or(AdminJournalOperationError::Binding) +} + +fn require_one(rows: u64) -> Result<(), AdminJournalOperationError> { + (rows == 1) + .then_some(()) + .ok_or(AdminJournalOperationError::Storage) +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<AdminJournalOperationError>, +) -> RhiAdminOperationError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return RhiAdminOperationError::new(RhiAdminOperationErrorKind::CommitOutcomeUnknown); + } + let kind = match error.operation_error() { + Some(AdminJournalOperationError::InvalidInput) => RhiAdminOperationErrorKind::InvalidInput, + Some(AdminJournalOperationError::Conflict) => RhiAdminOperationErrorKind::OperationConflict, + Some(AdminJournalOperationError::OutcomeUnknown) => { + RhiAdminOperationErrorKind::OperationOutcomeUnknown + } + Some(AdminJournalOperationError::ResourceExhausted) => { + RhiAdminOperationErrorKind::ResourceExhausted + } + Some(AdminJournalOperationError::Binding) => RhiAdminOperationErrorKind::Binding, + Some(AdminJournalOperationError::Storage) | None => RhiAdminOperationErrorKind::Transaction, + }; + RhiAdminOperationError::new(kind) +} diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -12,7 +12,7 @@ use radroots_service_sqlite::{ pub const RHI_STATE_BASE_SCHEMA_VERSION: u32 = 1; /// The newest governed RHI state schema understood by this binary. -pub const RHI_STATE_SCHEMA_VERSION: u32 = 10; +pub const RHI_STATE_SCHEMA_VERSION: u32 = 11; /// The shared metadata and migration-ledger objects present at schema v1. pub const RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -44,10 +44,13 @@ pub const RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 = 69; /// The shared objects plus durable exact-byte presence delivery state. pub const RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 = 80; +/// The complete v10 state plus the bounded durable admin-operation journal. +pub const RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32 = 82; + /// SHA-256 identity of the ordered migration catalog rooted at schema v1. pub const RHI_MIGRATION_CATALOG_SHA256: [u8; 32] = [ - 0x25, 0xe5, 0xba, 0x77, 0x3e, 0xf3, 0xdb, 0x01, 0x33, 0xa8, 0x07, 0x7a, 0x08, 0x3e, 0x88, 0xb4, - 0x0f, 0xc6, 0xda, 0xdf, 0x9f, 0xb6, 0xf0, 0xcf, 0xde, 0x0e, 0x4b, 0xa4, 0xd3, 0xe0, 0x81, 0xd9, + 0xe6, 0xcb, 0xac, 0xbd, 0x1e, 0xb6, 0x36, 0xc1, 0xa5, 0x60, 0xf8, 0x5e, 0xf8, 0xe5, 0x1e, 0x89, + 0xc9, 0xff, 0xe3, 0xb3, 0x42, 0xe6, 0x6b, 0xc5, 0xc0, 0xb4, 0x7a, 0xb3, 0x4e, 0x90, 0xc8, 0x18, ]; /// SHA-256 identity of the exact schema-v1 object snapshot. @@ -164,10 +167,22 @@ pub const RHI_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] = [ 0xcd, 0x50, 0x2a, 0xba, 0xa8, 0xa4, 0xca, 0x30, 0xa4, 0x82, 0x35, 0x35, 0xb8, 0xbd, 0x0e, 0x45, ]; +/// SHA-256 identity of the schema-v11 admin-operation-journal migration. +pub const RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32] = [ + 0xe3, 0xfb, 0xde, 0x51, 0x1e, 0x84, 0x24, 0xc9, 0x70, 0x80, 0xbe, 0x2c, 0x09, 0xed, 0x81, 0x0a, + 0xe2, 0x84, 0x63, 0x1d, 0x75, 0xeb, 0x25, 0xc2, 0xe8, 0x8a, 0x60, 0x76, 0xb7, 0x00, 0xaa, 0xef, +]; + +/// SHA-256 identity of the exact schema-v11 object snapshot. +pub const RHI_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32] = [ + 0xc2, 0x5e, 0xc6, 0x3b, 0x33, 0xb4, 0x11, 0x61, 0x80, 0x68, 0xee, 0x06, 0xa0, 0x4c, 0x99, 0xee, + 0x71, 0x66, 0xe0, 0x01, 0x4d, 0x97, 0x90, 0x39, 0xfa, 0xea, 0xea, 0x4d, 0xc1, 0xac, 0x7e, 0x62, +]; + /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const RHI_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x4f, 0x4d, 0x5f, 0x55, 0x46, 0xa7, 0xc9, 0x4e, 0xf3, 0xcd, 0xab, 0xe2, 0x3e, 0xad, 0xd0, 0xc9, - 0x7a, 0x64, 0x98, 0x09, 0x84, 0xee, 0x38, 0xca, 0xcb, 0x82, 0x8f, 0x11, 0x25, 0x91, 0x34, 0x88, + 0xae, 0xc4, 0x82, 0x81, 0x8b, 0xd9, 0xa6, 0xf3, 0x3f, 0xd9, 0x2b, 0x55, 0xd1, 0x42, 0xc6, 0xb8, + 0x5a, 0xa6, 0xf0, 0x86, 0x85, 0x57, 0x01, 0xdf, 0xc4, 0xb7, 0x8f, 0x2a, 0x7e, 0xf5, 0xcf, 0x6d, ]; macro_rules! rhi_config_bindings_table_sql { @@ -1991,6 +2006,72 @@ const PRESENCE_ATTEMPTS_NO_DELETE_SHA256: [u8; 32] = [ 0xbd, 0xf7, 0x74, 0xbb, 0x8d, 0x84, 0xc9, 0xab, 0xd9, 0x76, 0xe2, 0xae, 0x9c, 0xd2, 0x4a, 0x71, ]; +macro_rules! rhi_admin_operations_table_sql { + () => { + r#"CREATE TABLE rhi_admin_operations ( + operation_id TEXT NOT NULL PRIMARY KEY + CHECK (length(CAST(operation_id AS BLOB)) BETWEEN 1 AND 128) + CHECK (substr(operation_id, 1, 1) GLOB '[A-Za-z0-9]') + CHECK (operation_id NOT GLOB '*[^A-Za-z0-9._:-]*'), + route TEXT NOT NULL CHECK (length(CAST(route AS BLOB)) BETWEEN 1 AND 128), + request_sha256 BLOB NOT NULL CHECK (length(request_sha256) = 32), + state TEXT NOT NULL CHECK (state IN ('prepared', 'completed')), + response_model BLOB CHECK (response_model IS NULL OR + length(response_model) BETWEEN 1 AND 8192), + response_sha256 BLOB CHECK (response_sha256 IS NULL OR + length(response_sha256) = 32), + prepared_at_unix_ms INTEGER NOT NULL + CHECK (prepared_at_unix_ms BETWEEN 0 AND 9223372036854775807), + completed_at_unix_ms INTEGER + CHECK (completed_at_unix_ms IS NULL OR + completed_at_unix_ms BETWEEN prepared_at_unix_ms AND 9223372036854775807), + expires_at_unix_ms INTEGER + CHECK (expires_at_unix_ms IS NULL OR + expires_at_unix_ms BETWEEN completed_at_unix_ms AND 9223372036854775807), + CHECK ((state = 'prepared' AND response_model IS NULL + AND response_sha256 IS NULL AND completed_at_unix_ms IS NULL + AND expires_at_unix_ms IS NULL) + OR (state = 'completed' AND response_model IS NOT NULL + AND response_sha256 IS NOT NULL AND completed_at_unix_ms IS NOT NULL + AND expires_at_unix_ms IS NOT NULL)) +) STRICT"# + }; +} + +macro_rules! rhi_admin_operations_guard_update_sql { + () => { + r#"CREATE TRIGGER rhi_admin_operations_guard_update +BEFORE UPDATE ON rhi_admin_operations +WHEN OLD.state != 'prepared' OR NEW.state != 'completed' + OR NEW.operation_id != OLD.operation_id OR NEW.route != OLD.route + OR NEW.request_sha256 != OLD.request_sha256 + OR NEW.prepared_at_unix_ms != OLD.prepared_at_unix_ms + OR NEW.response_model IS NULL OR NEW.response_sha256 IS NULL + OR NEW.completed_at_unix_ms IS NULL OR NEW.expires_at_unix_ms IS NULL +BEGIN + SELECT RAISE(ABORT, 'admin operation transition is invalid'); +END"# + }; +} + +const CREATE_RHI_ADMIN_OPERATIONS_TABLE_SQL: &str = rhi_admin_operations_table_sql!(); +const CREATE_RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SQL: &str = rhi_admin_operations_guard_update_sql!(); +const CREATE_RHI_ADMIN_OPERATIONS_MIGRATION_SQL: &str = concat!( + rhi_admin_operations_table_sql!(), + ";\n", + rhi_admin_operations_guard_update_sql!(), + ";", +); + +const RHI_ADMIN_OPERATIONS_TABLE_SHA256: [u8; 32] = [ + 0xf7, 0xa6, 0x22, 0x21, 0xc8, 0xec, 0x66, 0x2c, 0x17, 0x14, 0x43, 0x82, 0x2b, 0x11, 0xa9, 0x9b, + 0xc4, 0xde, 0x02, 0x13, 0xa1, 0x9e, 0x12, 0xaa, 0x70, 0x54, 0x7b, 0x7f, 0x9d, 0x50, 0x99, 0x21, +]; +const RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0xac, 0xe5, 0x7c, 0x97, 0xbc, 0xd5, 0xe9, 0xda, 0x0d, 0xfc, 0xe0, 0x23, 0x65, 0x6d, 0xae, 0xda, + 0x96, 0xe5, 0xbf, 0xb6, 0x89, 0x70, 0xa6, 0x06, 0x1b, 0x70, 0x7d, 0x21, 0xec, 0x1f, 0x6b, 0x39, +]; + /// Stable classes for invalid embedded RHI catalog definitions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RhiStateCatalogErrorKind { @@ -2124,6 +2205,13 @@ fn build_rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogErro MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256), ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; + let admin_operations = MigrationDescriptor::sql( + 11, + "create_admin_operation_journal", + CREATE_RHI_ADMIN_OPERATIONS_MIGRATION_SQL, + MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; let catalog = MigrationCatalog::new([ configuration, trade_evidence, @@ -2134,6 +2222,7 @@ fn build_rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogErro reconciliation_job_shape_guards, presence_desired_state, presence_publication, + admin_operations, ]) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; Ok(catalog) @@ -2143,7 +2232,7 @@ fn build_rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogErro pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> { let catalog = build_rhi_migration_catalog()?; if catalog.current_version() != RHI_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 9 + || catalog.descriptors().len() != 10 || catalog.digest().as_bytes() != &RHI_MIGRATION_CATALOG_SHA256 { return Err(RhiStateCatalogError::new( @@ -2224,6 +2313,12 @@ fn build_rhi_schema_catalog( SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_10_SHA256), ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; + let version_eleven = SchemaVersionCatalog::new( + 11, + rhi_schema_version_eleven_objects()?, + SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_11_SHA256), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; let catalog = SchemaCatalog::new( migrations, [ @@ -2237,6 +2332,7 @@ fn build_rhi_schema_catalog( version_eight, version_nine, version_ten, + version_eleven, ], ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; @@ -2250,10 +2346,10 @@ pub fn validate_rhi_state_catalogs( ) -> Result<(), RhiStateCatalogError> { let versions = schema.versions(); let valid = migrations.current_version() == RHI_STATE_SCHEMA_VERSION - && migrations.descriptors().len() == 9 + && migrations.descriptors().len() == 10 && migrations.digest().as_bytes() == &RHI_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 10 + && versions.len() == 11 && versions[0].version() == RHI_STATE_BASE_SCHEMA_VERSION && versions[0].object_count() == RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT && versions[0].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_1_SHA256 @@ -2281,9 +2377,12 @@ pub fn validate_rhi_state_catalogs( && versions[8].version() == 9 && versions[8].object_count() == RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT && versions[8].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_9_SHA256 - && versions[9].version() == RHI_STATE_SCHEMA_VERSION + && versions[9].version() == 10 && versions[9].object_count() == RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT && versions[9].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_10_SHA256 + && versions[10].version() == RHI_STATE_SCHEMA_VERSION + && versions[10].object_count() == RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT + && versions[10].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_11_SHA256 && schema.digest().as_bytes() == &RHI_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) @@ -2379,6 +2478,39 @@ fn rhi_schema_version_ten_objects() -> Result<Vec<SchemaObject>, RhiStateCatalog Ok(objects) } +fn rhi_schema_version_eleven_objects() -> Result<Vec<SchemaObject>, RhiStateCatalogError> { + let mut objects = rhi_schema_version_ten_objects()?; + objects.extend(rhi_admin_operation_objects()?); + Ok(objects) +} + +fn rhi_admin_operation_objects() -> Result<[SchemaObject; 2], RhiStateCatalogError> { + let object = |kind, name, sql, digest| { + SchemaObject::new( + kind, + name, + "rhi_admin_operations", + sql, + SchemaDigest::from_bytes(digest), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog)) + }; + Ok([ + object( + SchemaObjectKind::Table, + "rhi_admin_operations", + CREATE_RHI_ADMIN_OPERATIONS_TABLE_SQL, + RHI_ADMIN_OPERATIONS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "rhi_admin_operations_guard_update", + CREATE_RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SQL, + RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SHA256, + )?, + ]) +} + fn rhi_presence_publication_objects() -> Result<[SchemaObject; 11], RhiStateCatalogError> { let object = |kind, name, table_name, sql, digest| { SchemaObject::new( diff --git a/src/state_config.rs b/src/state_config.rs @@ -300,6 +300,23 @@ pub(crate) async fn verify_binding( .map_err(map_transaction_error) } +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) async fn current_generation(host: &RhiStateHost) -> Result<u16, RhiConfigApplyError> { + host.sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let history = read_history(transaction).await?; + validate_history(&history)?; + history + .last() + .map(|entry| entry.generation) + .ok_or(ConfigOperationError::Binding) + }) + }) + .await + .map_err(map_transaction_error) +} + pub(crate) async fn append_configuration( host: &RhiStateHost, current: &RhiStateMetadata, diff --git a/src/state_host.rs b/src/state_host.rs @@ -449,6 +449,44 @@ pub async fn open_rhi_state_inspection( Ok(state) } +/// Opens existing inspection state from a sealed intent and actual metadata. +pub async fn open_rhi_state_inspection_from_config( + runtime: &RhiRuntimeContext, + configuration: &RhiConfigDocumentV1, +) -> Result<RhiStateHost, RhiStateHostError> { + let paths = state_paths(runtime)?; + let (migrations, schema) = catalogs()?; + let intent = existing_intent(&paths)?; + let opened = ServiceSqliteHost::open_read_only_inspection_with_intent( + &paths, + &intent, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + ) + .await + .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InspectionOpen))?; + let (host, actual) = opened.into_parts(); + let metadata = match RhiStateMetadata::from_existing_database(runtime, configuration, &actual) { + Ok(metadata) => metadata, + Err(_) => { + return Err(close_error(&host, RhiStateHostErrorKind::InvalidEvidence).await); + } + }; + let state = RhiStateHost { + host, + mode: RhiStateHostMode::ReadOnlyInspection, + metadata, + }; + if state_config::verify_binding(&state, state.metadata()) + .await + .is_err() + { + return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await); + } + Ok(state) +} + pub(crate) fn state_paths( runtime: &RhiRuntimeContext, ) -> Result<ServiceSqlitePaths, RhiStateHostError> { diff --git a/src/status_v1.rs b/src/status_v1.rs @@ -189,8 +189,14 @@ impl RhiStatusBuildInfoV1 { target: Option<&str>, feature_profile: Option<&str>, ) -> Result<Self, RhiStatusError> { - let contract_versions = HostContractVersions::new(1, 10, 1, 1, 1) - .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidBuildInfo))?; + let contract_versions = HostContractVersions::new( + crate::RHI_CONFIG_SCHEMA_VERSION, + crate::RHI_STATE_SCHEMA_VERSION, + crate::RHI_ADMIN_CONTRACT_VERSION, + crate::RHI_STATUS_CONTRACT_VERSION, + crate::RHI_PROVIDER_CONTRACT_VERSION, + ) + .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidBuildInfo))?; HostBuildInfo::from_compile_time( match mode { RhiStatusBuildMode::Development => HostBuildMode::Development, diff --git a/src/system_doctor.rs b/src/system_doctor.rs @@ -0,0 +1,341 @@ +//! Production active-doctor probes composed from existing sealed authorities. + +use radroots_service_host::{SystemWallClock, WallClock}; +use radroots_service_sqlite::{ + IntegrityCheckOutcome, IntegrityCheckedAtUnixMs, MinimumFreeBytes, + PlatformStateFilesystemCapacitySource, inspect_state_filesystem_capacity, +}; + +use crate::admin_v1::admin_transport_limits; +use crate::transport_nostr_adapter::{build_rhi_nostr_adapters, probe_required_sources}; +use crate::{ + RhiConfigDocumentV1, RhiDoctorCheckDefinition, RhiDoctorCheckId, RhiDoctorFuture, + RhiDoctorObservation, RhiDoctorProbe, RhiIdentityEnvelopeBinding, RhiRuntimeContext, + RhiStateHost, open_rhi_encrypted_identity, open_rhi_state_inspection_from_config, + resolve_rhi_wrapping_credential, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StateProbeError { + Query, +} + +pub(crate) struct RhiSystemDoctorProbe<'a> { + runtime: &'a RhiRuntimeContext, + configuration: &'a RhiConfigDocumentV1, +} + +impl<'a> RhiSystemDoctorProbe<'a> { + pub(crate) const fn new( + runtime: &'a RhiRuntimeContext, + configuration: &'a RhiConfigDocumentV1, + ) -> Self { + Self { + runtime, + configuration, + } + } + + async fn run(&self, definition: RhiDoctorCheckDefinition) -> bool { + match definition.id() { + RhiDoctorCheckId::PathsPermissions => self.probe_paths(), + RhiDoctorCheckId::WriterLock + | RhiDoctorCheckId::SqliteSchema + | RhiDoctorCheckId::SqliteIntegrity + | RhiDoctorCheckId::CursorCheckpoint + | RhiDoctorCheckId::ReconciliationLeases + | RhiDoctorCheckId::ReconciliationBacklog + | RhiDoctorCheckId::PublicationInvariants => self.probe_state(definition.id()).await, + RhiDoctorCheckId::SqliteFreeSpace => self.probe_free_space(), + RhiDoctorCheckId::IdentityBinding => self.probe_identity_binding().await, + RhiDoctorCheckId::AdminBindPolicy => self.probe_admin_policy(), + RhiDoctorCheckId::OperationsBindPolicy => self.probe_operations_policy(), + RhiDoctorCheckId::NetworkPolicy => build_rhi_nostr_adapters(self.configuration).is_ok(), + RhiDoctorCheckId::RequiredSources => { + let Some(deadline) = absolute_deadline(definition.deadline_ms()) else { + return false; + }; + probe_required_sources(self.configuration, deadline) + .await + .is_ok() + } + RhiDoctorCheckId::ClockSkew => false, + } + } + + fn probe_paths(&self) -> bool { + let Ok(paths) = crate::state_host::state_paths(self.runtime) else { + return false; + }; + let Ok(minimum) = MinimumFreeBytes::new(1) else { + return false; + }; + inspect_state_filesystem_capacity(&paths, minimum, &PlatformStateFilesystemCapacitySource) + .is_ok() + } + + async fn probe_state(&self, check: RhiDoctorCheckId) -> bool { + let Ok(state) = + open_rhi_state_inspection_from_config(self.runtime, self.configuration).await + else { + return false; + }; + let outcome = match check { + RhiDoctorCheckId::WriterLock | RhiDoctorCheckId::SqliteSchema => true, + RhiDoctorCheckId::SqliteIntegrity => match integrity_time() { + Some(checked_at) => state + .inspect_integrity(checked_at) + .await + .is_ok_and(|report| { + report.sqlite() == IntegrityCheckOutcome::Verified + && report.foreign_keys() == IntegrityCheckOutcome::Verified + }), + None => false, + }, + RhiDoctorCheckId::CursorCheckpoint => { + run_scalar_probe(&state, CURSOR_CHECKPOINT_INVARIANTS_SQL, None).await + } + RhiDoctorCheckId::ReconciliationLeases => { + run_scalar_probe(&state, RECONCILIATION_LEASE_INVARIANTS_SQL, None).await + } + RhiDoctorCheckId::ReconciliationBacklog => { + let capacity = + configuration_u64(self.configuration, "/reconciliation/queue_capacity") + .and_then(|value| i64::try_from(value).ok()); + match capacity { + Some(capacity) => { + run_scalar_probe( + &state, + RECONCILIATION_BACKLOG_INVARIANTS_SQL, + Some(capacity), + ) + .await + } + None => false, + } + } + RhiDoctorCheckId::PublicationInvariants => { + run_scalar_probe(&state, PUBLICATION_INVARIANTS_SQL, None).await + } + _ => false, + }; + let closed = state.close().await.is_ok(); + outcome && closed + } + + fn probe_free_space(&self) -> bool { + let Some(minimum) = configuration_u64(self.configuration, "/database/minimum_free_bytes") + .and_then(|value| MinimumFreeBytes::new(value).ok()) + else { + return false; + }; + let Ok(paths) = crate::state_host::state_paths(self.runtime) else { + return false; + }; + inspect_state_filesystem_capacity(&paths, minimum, &PlatformStateFilesystemCapacitySource) + .is_ok_and(|capacity| capacity.allows_authoritative_admission()) + } + + async fn probe_identity_binding(&self) -> bool { + let Ok(state) = + open_rhi_state_inspection_from_config(self.runtime, self.configuration).await + else { + return false; + }; + let result = + RhiIdentityEnvelopeBinding::from_configuration(self.configuration, state.metadata()) + .ok() + .and_then(|binding| { + let credential = + resolve_rhi_wrapping_credential(self.runtime, &binding).ok()?; + open_rhi_encrypted_identity(&binding, &credential).ok() + }); + let closed = state.close().await.is_ok(); + result.is_some() && closed + } + + fn probe_admin_policy(&self) -> bool { + let path = self.runtime.artifacts().admin_socket(); + path.is_absolute() + && path.to_str().is_some_and(|value| value.len() <= 4_096) + && admin_transport_limits(self.configuration).is_ok() + } + + fn probe_operations_policy(&self) -> bool { + let Some(enabled) = self + .configuration + .normalized() + .pointer("/operations/enabled") + .and_then(serde_json::Value::as_bool) + else { + return false; + }; + !enabled + || (self + .configuration + .normalized() + .pointer("/operations/listen") + .and_then(serde_json::Value::as_str) + .is_some() + && self + .configuration + .normalized() + .pointer("/operations/bind_policy") + .and_then(serde_json::Value::as_str) + .is_some()) + } +} + +impl RhiDoctorProbe for RhiSystemDoctorProbe<'_> { + fn probe(&self, definition: RhiDoctorCheckDefinition) -> RhiDoctorFuture<'_> { + Box::pin(async move { + if definition.id() == RhiDoctorCheckId::ClockSkew { + RhiDoctorObservation::Skipped + } else if self.run(definition).await { + RhiDoctorObservation::Pass + } else { + RhiDoctorObservation::Fail + } + }) + } +} + +async fn run_scalar_probe(state: &RhiStateHost, sql: &'static str, bound: Option<i64>) -> bool { + state + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + let mut query = sqlx::query_scalar::<_, i64>(sql); + if let Some(bound) = bound { + query = query.bind(bound); + } + query + .fetch_one(&mut *transaction) + .await + .map(|invalid| invalid == 0) + .map_err(|_| StateProbeError::Query) + }) + }) + .await + .is_ok_and(|valid| valid) +} + +fn configuration_u64(configuration: &RhiConfigDocumentV1, pointer: &str) -> Option<u64> { + configuration + .normalized() + .pointer(pointer) + .and_then(serde_json::Value::as_u64) +} + +fn wall_time_millis() -> Option<u64> { + SystemWallClock + .now_utc() + .ok() + .and_then(|time| time.get().checked_mul(1_000)) + .filter(|value| i64::try_from(*value).is_ok()) +} + +fn absolute_deadline(duration_ms: u64) -> Option<u64> { + wall_time_millis()?.checked_add(duration_ms) +} + +fn integrity_time() -> Option<IntegrityCheckedAtUnixMs> { + IntegrityCheckedAtUnixMs::new(wall_time_millis()?) +} + +const CURSOR_CHECKPOINT_INVARIANTS_SQL: &str = r#"SELECT COUNT(*) +FROM relay_checkpoints +WHERE typeof(source_id) != 'text' + OR length(CAST(source_id AS BLOB)) NOT BETWEEN 1 AND 64 + OR source_id GLOB '*[^a-z0-9_-]*' + OR substr(source_id, 1, 1) NOT GLOB '[a-z]' + OR selector_id != 'trade_mutation_lineage_v1' + OR typeof(evidence_policy_sha256) != 'blob' + OR length(evidence_policy_sha256) != 32 + OR typeof(trade_id) != 'blob' OR length(trade_id) != 16 + OR typeof(cursor_event_id) != 'blob' OR length(cursor_event_id) != 32 + OR cursor_created_at_unix_s NOT BETWEEN 0 AND 9223372036854775807 + OR revision NOT BETWEEN 1 AND 9223372036854775807 + OR completed_at_unix_s NOT BETWEEN 1 AND 9223372036854775807"#; + +const RECONCILIATION_LEASE_INVARIANTS_SQL: &str = r#"SELECT COUNT(*) +FROM reconciliation_jobs +WHERE (state = 'leased' AND ( + typeof(lease_owner) != 'blob' OR length(lease_owner) != 16 + OR lease_expires_unix_ms NOT BETWEEN 1 AND 9223372036854775807 + OR next_attempt_unix_ms IS NOT NULL)) + OR (state != 'leased' AND (lease_owner IS NOT NULL OR lease_expires_unix_ms IS NOT NULL)) + OR attempt_count NOT BETWEEN 0 AND max_attempts + OR failure_count NOT BETWEEN 0 AND attempt_count + OR revision NOT BETWEEN 1 AND 9223372036854775807"#; + +const RECONCILIATION_BACKLOG_INVARIANTS_SQL: &str = r#"SELECT CASE + WHEN (SELECT COUNT(*) FROM reconciliation_jobs WHERE state IN ('ready', 'leased')) > ? + THEN 1 + WHEN EXISTS ( + SELECT 1 FROM reconciliation_jobs + WHERE state = 'ready' AND ( + next_attempt_unix_ms IS NULL OR attempt_count >= max_attempts)) + THEN 1 + WHEN EXISTS ( + SELECT 1 FROM reconciliation_jobs + WHERE state IN ('exhausted', 'superseded', 'completed') + AND next_attempt_unix_ms IS NOT NULL) + THEN 1 + ELSE 0 END"#; + +const PUBLICATION_INVARIANTS_SQL: &str = r#"SELECT COUNT(*) +FROM publication_outbox AS outbox +LEFT JOIN signed_attestation_events AS event ON event.event_id = outbox.event_id +WHERE event.event_id IS NULL + OR outbox.event_sha256 != event.event_sha256 + OR outbox.target_count != ( + SELECT COUNT(*) FROM publication_targets AS target + WHERE target.outbox_id = outbox.outbox_id) + OR outbox.required_target_count != ( + SELECT COUNT(*) FROM publication_targets AS target + WHERE target.outbox_id = outbox.outbox_id AND target.required = 1) + OR EXISTS ( + SELECT 1 FROM publication_targets AS target + WHERE target.outbox_id = outbox.outbox_id + AND (target.target_ordinal < 0 OR target.target_ordinal >= outbox.target_count)) + OR (outbox.state = 'complete' AND EXISTS ( + SELECT 1 FROM publication_targets AS target + WHERE target.outbox_id = outbox.outbox_id + AND target.required = 1 AND target.state != 'accepted'))"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deadline_math_is_checked_and_clock_skew_remains_unclaimed() { + assert!(absolute_deadline(15_000).is_some()); + assert!(integrity_time().is_some()); + } + + #[test] + fn state_queries_are_bounded_scalar_projections() { + for query in [ + CURSOR_CHECKPOINT_INVARIANTS_SQL, + RECONCILIATION_LEASE_INVARIANTS_SQL, + RECONCILIATION_BACKLOG_INVARIANTS_SQL, + PUBLICATION_INVARIANTS_SQL, + ] { + assert!(query.starts_with("SELECT")); + assert!(!query.contains("SELECT *")); + assert!(!query.contains("ORDER BY")); + } + } + + #[test] + fn production_probe_source_retains_no_raw_error_projection() { + let source = include_str!("system_doctor.rs") + .split("#[cfg(test)]") + .next() + .expect("production source"); + for forbidden in ["format!(\"{error", "to_string()", "source()"] { + assert!(!source.contains(forbidden), "found `{forbidden}`"); + } + } +} diff --git a/src/transport_nostr_adapter.rs b/src/transport_nostr_adapter.rs @@ -0,0 +1,635 @@ +//! Private source-locked Nostr transport composition for the RHI runtime. + +use core::fmt; +use std::{collections::BTreeMap, error::Error, sync::Arc}; + +use radroots_event_codec::Codec; +use radroots_transport::{ + BoxFuture, DeliveryReceipt, DeliveryRequest, EventSink, EventSource, EventSubscriber, + FetchPage, FetchRequest, SinkFailure, SinkStatus, SourceStatus, Target, TargetSet, + outcome::{DeliveryOutcomeKind, FetchTargetState}, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + sink::{DeliveryPayload, DeliveryTargetReceipt}, + source::{BoxSubscription, FetchBounds, FetchSelector, SubscriptionRequest}, + target::TargetFingerprint, +}; +use radroots_transport_nostr::{ + Config, NostrTransport, PreparedDelivery, RelayAccess, RelayEndpoint, RelayProfile, + RelayProfileKind, RelayUrlPolicy, +}; + +use crate::{ + RhiConfigDocumentV1, RhiConfigProfile, RhiExactPresenceSink, RhiExactPublicationSink, + RhiPreparedPresenceAttempt, RhiPreparedPublicationAttempt, RhiPresenceAttemptOutcome, + RhiPublicationAttemptOutcome, RhiTransportAdapters, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RhiNostrAdapterErrorKind { + Configuration, + Target, + Payload, + Preparation, +} + +pub(crate) struct RhiNostrAdapterError { + kind: RhiNostrAdapterErrorKind, +} + +impl RhiNostrAdapterError { + #[cfg(test)] + pub(crate) const fn kind(&self) -> RhiNostrAdapterErrorKind { + self.kind + } +} + +impl fmt::Debug for RhiNostrAdapterError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiNostrAdapterError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiNostrAdapterError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RHI Nostr adapter failed") + } +} + +impl Error for RhiNostrAdapterError {} + +const fn adapter_error(kind: RhiNostrAdapterErrorKind) -> RhiNostrAdapterError { + RhiNostrAdapterError { kind } +} + +struct RelayDefinition { + id: Box<str>, + target: Target, + kind: RelayProfileKind, +} + +struct RelayBinding { + transport: NostrTransport, + target: Target, +} + +/// One private adapter that routes a bounded request to exactly one configured +/// public or simulator transport group. +struct RhiNostrTransport { + by_target: BTreeMap<TargetFingerprint, NostrTransport>, +} + +impl RhiNostrTransport { + fn for_targets( + &self, + targets: &TargetSet, + ) -> Result<NostrTransport, radroots_transport::Error> { + let mut selected: Option<NostrTransport> = None; + for target in targets.targets() { + let transport = self + .by_target + .get(target.fingerprint()) + .ok_or(radroots_transport::Error::UnsupportedOperation)?; + if let Some(existing) = &selected { + if existing.config() != transport.config() { + return Err(radroots_transport::Error::UnsupportedOperation); + } + } else { + selected = Some(transport.clone()); + } + } + selected.ok_or(radroots_transport::Error::UnsupportedOperation) + } +} + +impl EventSource for RhiNostrTransport { + fn status(&self) -> BoxFuture<'_, Result<SourceStatus, radroots_transport::Error>> { + Box::pin(async { Err(radroots_transport::Error::UnsupportedOperation) }) + } + + fn fetch( + &self, + request: FetchRequest, + ) -> BoxFuture<'_, Result<FetchPage, radroots_transport::Error>> { + Box::pin(async move { + let transport = self.for_targets(request.target_set())?; + transport.fetch(request).await + }) + } +} + +impl EventSubscriber for RhiNostrTransport { + fn subscribe( + &self, + request: SubscriptionRequest, + ) -> BoxFuture<'_, Result<BoxSubscription, radroots_transport::Error>> { + Box::pin(async move { + let transport = self.for_targets(request.target_set())?; + transport.subscribe(request).await + }) + } +} + +impl EventSink for RhiNostrTransport { + fn status(&self) -> BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> { + Box::pin(async { Err(radroots_transport::Error::UnsupportedOperation) }) + } + + fn deliver( + &self, + request: DeliveryRequest, + ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { + Box::pin(async move { + let transport = self + .for_targets(request.target_set()) + .map_err(|_| SinkFailure::invalid_contract(&request))?; + transport.deliver(request).await + }) + } +} + +pub(crate) struct RhiNostrExactSink { + relays: BTreeMap<Box<str>, RelayBinding>, +} + +struct RhiPreparedNostrDelivery { + transport: NostrTransport, + prepared: PreparedDelivery, +} + +impl fmt::Debug for RhiPreparedNostrDelivery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiPreparedNostrDelivery([redacted])") + } +} + +impl RhiNostrExactSink { + fn prepare( + &self, + relay_id: &str, + request_id: String, + exact_event_bytes: &[u8], + deadline_unix_ms: u64, + ) -> Result<RhiPreparedNostrDelivery, RhiNostrAdapterError> { + let binding = self + .relays + .get(relay_id) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Target))?; + let raw = core::str::from_utf8(exact_event_bytes) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Payload))?; + let signed = Codec::decode_signed_event(raw) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Payload))?; + if signed.raw_json().as_bytes() != exact_event_bytes { + return Err(adapter_error(RhiNostrAdapterErrorKind::Payload)); + } + let targets = TargetSet::new(vec![binding.target.clone()]) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?; + let request = DeliveryRequest::new( + request_id, + DeliveryPayload::new(signed), + targets, + SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), + deadline_unix_ms, + ) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; + let prepared = binding + .transport + .prepare_delivery(request) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; + Ok(RhiPreparedNostrDelivery { + transport: binding.transport.clone(), + prepared, + }) + } + + async fn execute( + &self, + prepared: RhiPreparedNostrDelivery, + ) -> Result<DeliveryOutcomeKind, RhiNostrAdapterError> { + let RhiPreparedNostrDelivery { + transport, + prepared, + } = prepared; + let receipt = transport + .execute_prepared_delivery(prepared) + .await + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; + classify_receipt(receipt.target_receipts()) + } +} + +impl RhiExactPublicationSink for RhiNostrExactSink { + fn submit_exact<'a>( + &'a self, + attempt: &'a RhiPreparedPublicationAttempt, + ) -> BoxFuture<'a, RhiPublicationAttemptOutcome> { + Box::pin(async move { + let request_id = request_id("publication", attempt.attempt_id().as_bytes()); + let prepared = match self.prepare( + attempt.relay_id(), + request_id, + attempt.exact_signed_event_bytes(), + attempt.deadline_at().get(), + ) { + Ok(prepared) => prepared, + Err(_) => return RhiPublicationAttemptOutcome::Failed, + }; + match self.execute(prepared).await { + Ok(DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered) => { + RhiPublicationAttemptOutcome::Accepted + } + Ok(DeliveryOutcomeKind::Rejected) => RhiPublicationAttemptOutcome::Rejected, + Ok(DeliveryOutcomeKind::Unavailable | DeliveryOutcomeKind::Failed) => { + RhiPublicationAttemptOutcome::Failed + } + Err(_) => RhiPublicationAttemptOutcome::Unknown, + } + }) + } +} + +impl RhiExactPresenceSink for RhiNostrExactSink { + fn submit_exact<'a>( + &'a self, + attempt: &'a RhiPreparedPresenceAttempt, + ) -> BoxFuture<'a, RhiPresenceAttemptOutcome> { + Box::pin(async move { + let request_id = request_id("presence", attempt.attempt_id().as_bytes()); + let prepared = match self.prepare( + attempt.relay_id(), + request_id, + attempt.exact_signed_event_bytes(), + attempt.deadline_at().get(), + ) { + Ok(prepared) => prepared, + Err(_) => return RhiPresenceAttemptOutcome::Failed, + }; + match self.execute(prepared).await { + Ok(DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered) => { + RhiPresenceAttemptOutcome::Accepted + } + Ok(DeliveryOutcomeKind::Rejected) => RhiPresenceAttemptOutcome::Rejected, + Ok(DeliveryOutcomeKind::Unavailable | DeliveryOutcomeKind::Failed) => { + RhiPresenceAttemptOutcome::Failed + } + Err(_) => RhiPresenceAttemptOutcome::Unknown, + } + }) + } +} + +fn classify_receipt( + receipts: &[DeliveryTargetReceipt], +) -> Result<DeliveryOutcomeKind, RhiNostrAdapterError> { + let [receipt] = receipts else { + return Err(adapter_error(RhiNostrAdapterErrorKind::Preparation)); + }; + Ok(receipt.outcome().kind()) +} + +fn request_id(prefix: &str, bytes: &[u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut result = String::with_capacity(prefix.len() + 1 + bytes.len() * 2); + result.push_str(prefix); + result.push('-'); + for byte in bytes { + result.push(char::from(HEX[usize::from(byte >> 4)])); + result.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + result +} + +pub(crate) fn build_rhi_nostr_adapters( + configuration: &RhiConfigDocumentV1, +) -> Result<(RhiTransportAdapters, Arc<RhiNostrExactSink>), RhiNostrAdapterError> { + let relays = configuration + .normalized() + .pointer("/relays") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let connect_timeout = configuration_integer(configuration, "/network/connect_deadline_ms")?; + let source_timeout = configuration + .normalized() + .pointer("/evidence/sources") + .and_then(serde_json::Value::as_array) + .and_then(|sources| { + sources + .iter() + .filter_map(|source| { + source + .pointer("/deadline_ms") + .and_then(serde_json::Value::as_u64) + }) + .max() + }) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let publication_timeout = configuration + .normalized() + .pointer("/publication/retry/attempt_deadline_ms") + .and_then(serde_json::Value::as_u64) + .unwrap_or(source_timeout); + let request_timeout = source_timeout.max(publication_timeout); + + let mut public = Vec::new(); + let mut simulator = Vec::new(); + let mut definitions = Vec::with_capacity(relays.len()); + for relay in relays { + let id = relay + .pointer("/id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let url = relay + .pointer("/url") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let read = relay + .pointer("/read") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let write = relay + .pointer("/write") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let access = if write { + RelayAccess::ReadWrite + } else if read { + RelayAccess::ReadOnly + } else { + return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); + }; + let (kind, policy) = if url.starts_with("wss://") { + (RelayProfileKind::Public, RelayUrlPolicy::Public) + } else if configuration.profile() == RhiConfigProfile::RepoLocal && url.starts_with("ws://") + { + (RelayProfileKind::Simulator, RelayUrlPolicy::Local) + } else { + return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); + }; + let endpoint = RelayEndpoint::new(url, policy, access) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + match kind { + RelayProfileKind::Public => public.push(endpoint), + RelayProfileKind::Simulator => simulator.push(endpoint), + _ => return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)), + } + definitions.push(RelayDefinition { + id: Box::from(id), + target: Target::nostr_relay(url) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?, + kind, + }); + } + + let public_transport = build_transport( + RelayProfileKind::Public, + public, + connect_timeout, + request_timeout, + )?; + let simulator_transport = build_transport( + RelayProfileKind::Simulator, + simulator, + connect_timeout, + request_timeout, + )?; + let mut by_target = BTreeMap::new(); + let mut bindings = BTreeMap::new(); + for definition in definitions { + let transport = match definition.kind { + RelayProfileKind::Public => public_transport.clone(), + RelayProfileKind::Simulator => simulator_transport.clone(), + _ => None, + } + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + if by_target + .insert(definition.target.fingerprint().clone(), transport.clone()) + .is_some() + || bindings + .insert( + definition.id, + RelayBinding { + transport, + target: definition.target, + }, + ) + .is_some() + { + return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); + } + } + if by_target.is_empty() { + return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); + } + let transport = Arc::new(RhiNostrTransport { by_target }); + let adapters = RhiTransportAdapters::new( + transport.clone(), + transport.clone(), + transport as Arc<dyn EventSink>, + ); + Ok((adapters, Arc::new(RhiNostrExactSink { relays: bindings }))) +} + +pub(crate) async fn probe_required_sources( + configuration: &RhiConfigDocumentV1, + deadline_unix_ms: u64, +) -> Result<(), RhiNostrAdapterError> { + let relays = configuration + .normalized() + .pointer("/relays") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let sources = configuration + .normalized() + .pointer("/evidence/sources") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let connect_timeout = configuration_integer(configuration, "/network/connect_deadline_ms")?; + let request_timeout = sources + .iter() + .filter_map(|source| { + source + .pointer("/deadline_ms") + .and_then(serde_json::Value::as_u64) + }) + .max() + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + + let mut public = Vec::new(); + let mut public_targets = Vec::new(); + let mut simulator = Vec::new(); + let mut simulator_targets = Vec::new(); + for source in sources.iter().filter(|source| { + source + .pointer("/required") + .and_then(serde_json::Value::as_bool) + == Some(true) + }) { + let relay_id = source + .pointer("/relay_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let relay = relays + .iter() + .find(|relay| { + relay.pointer("/id").and_then(serde_json::Value::as_str) == Some(relay_id) + }) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let url = relay + .pointer("/url") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let target = Target::nostr_relay(url) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?; + let (kind, policy) = if url.starts_with("wss://") { + (RelayProfileKind::Public, RelayUrlPolicy::Public) + } else if configuration.profile() == RhiConfigProfile::RepoLocal && url.starts_with("ws://") + { + (RelayProfileKind::Simulator, RelayUrlPolicy::Local) + } else { + return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); + }; + let endpoint = RelayEndpoint::new(url, policy, RelayAccess::ReadOnly) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + match kind { + RelayProfileKind::Public => { + public.push(endpoint); + public_targets.push(target); + } + RelayProfileKind::Simulator => { + simulator.push(endpoint); + simulator_targets.push(target); + } + _ => return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)), + } + } + if public_targets.is_empty() && simulator_targets.is_empty() { + return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)); + } + probe_group( + RelayProfileKind::Public, + public, + public_targets, + connect_timeout, + request_timeout, + deadline_unix_ms, + "rhi-doctor-public", + ) + .await?; + probe_group( + RelayProfileKind::Simulator, + simulator, + simulator_targets, + connect_timeout, + request_timeout, + deadline_unix_ms, + "rhi-doctor-simulator", + ) + .await +} + +async fn probe_group( + kind: RelayProfileKind, + endpoints: Vec<RelayEndpoint>, + targets: Vec<Target>, + connect_timeout: u64, + request_timeout: u64, + deadline_unix_ms: u64, + request_id: &'static str, +) -> Result<(), RhiNostrAdapterError> { + if targets.is_empty() { + return Ok(()); + } + let transport = build_transport(kind, endpoints, connect_timeout, request_timeout)? + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let target_set = + TargetSet::new(targets).map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?; + let selector = FetchSelector::all() + .with_since_unix_seconds(u64::MAX) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let request = FetchRequest::new( + request_id, + target_set, + FetchBounds::new(1, deadline_unix_ms) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?, + ) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))? + .with_selector(selector); + let page = transport + .fetch(request) + .await + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?; + if page.target_outcomes().is_empty() + || page.target_outcomes().iter().any(|outcome| { + !matches!( + outcome.state(), + FetchTargetState::Complete | FetchTargetState::Partial + ) + }) + { + return Err(adapter_error(RhiNostrAdapterErrorKind::Preparation)); + } + Ok(()) +} + +fn build_transport( + kind: RelayProfileKind, + endpoints: Vec<RelayEndpoint>, + connect_timeout: u64, + request_timeout: u64, +) -> Result<Option<NostrTransport>, RhiNostrAdapterError> { + if endpoints.is_empty() { + return Ok(None); + } + let maximum_connections = endpoints.len().min(8); + let profile = RelayProfile::explicit(kind, endpoints) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + let config = Config::from_profile(profile) + .with_timeouts(connect_timeout, request_timeout, connect_timeout) + .and_then(|config| config.with_max_connections(maximum_connections)) + .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?; + Ok(Some(NostrTransport::new(config))) +} + +fn configuration_integer( + configuration: &RhiConfigDocumentV1, + pointer: &str, +) -> Result<u64, RhiNostrAdapterError> { + configuration + .normalized() + .pointer(pointer) + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration)) +} + +#[cfg(test)] +mod tests { + use std::error::Error as _; + + use super::*; + use crate::{RhiConfigProfile, parse_rhi_config_v1}; + + const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + + #[test] + fn exact_configuration_builds_without_network_io_and_errors_are_redacted() { + let configuration = parse_rhi_config_v1(CONFIG.as_bytes(), RhiConfigProfile::Production) + .expect("configuration"); + let (_adapters, sink) = + build_rhi_nostr_adapters(&configuration).expect("offline composition"); + assert_eq!(sink.relays.len(), 2); + assert_eq!(request_id("publication", &[0xab; 32]).len(), 76); + for kind in [ + RhiNostrAdapterErrorKind::Configuration, + RhiNostrAdapterErrorKind::Target, + RhiNostrAdapterErrorKind::Payload, + RhiNostrAdapterErrorKind::Preparation, + ] { + let error = adapter_error(kind); + assert_eq!(error.kind(), kind); + assert!(error.source().is_none()); + assert!(!format!("{error} {error:?}").contains("relay.example")); + } + } +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -27,7 +27,7 @@ fn source_lock_metadata_is_exact_and_nix_is_absent() { )); for field in [ "config_contract_version = 1", - "state_contract_version = 7", + "state_contract_version = 11", "admin_contract_version = 1", "status_contract_version = 1", "provider_contract_version = 1", @@ -73,6 +73,9 @@ fn shared_transport_spi_is_exactly_source_locked_without_serde() { assert!(MANIFEST.contains( "radroots_transport = { git = \"https://github.com/radrootslabs/lib\", rev = \"21b11e7a5120ea949f7ad0838c746873fc73aac2\", version = \"=0.1.0-alpha\", default-features = false, features = [\"std\"] }" )); + assert!(MANIFEST.contains( + "radroots_transport_nostr = { git = \"https://github.com/radrootslabs/lib\", rev = \"21b11e7a5120ea949f7ad0838c746873fc73aac2\", version = \"=0.1.0-alpha\" }" + )); } #[test] @@ -93,7 +96,7 @@ fn source_lock_binds_the_current_cargo_lock() { assert!(!SOURCE_LOCK.contains("flake_lock_sha256")); assert!(!SOURCE_LOCK.contains("lib_revision =")); assert!(SOURCE_LOCK.ends_with( - "[contract_versions]\nconfig = 1\nstate = 7\nadmin = 1\nstatus = 1\nprovider = 1\n" + "[contract_versions]\nconfig = 1\nstate = 11\nadmin = 1\nstatus = 1\nprovider = 1\n" )); } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -557,7 +557,8 @@ fn doctor_and_process_results_are_closed_bounded_and_process_safe() { } assert!(MAIN.contains("parse_rhi_cli_v1_from(std::env::args_os())")); assert!(MAIN.contains("RhiProcessResult::InputOrConfiguration")); - assert!(MAIN.contains("eprintln!(\"RHI command failed: {}\", result.code())")); + assert!(MAIN.contains("execute_rhi_cli_v1_with_signal_source")); + assert!(MAIN.contains("eprintln!(\"{}\", RhiLogRecord::process_result(result))")); for forbidden in ["{error}", "{error:?}", "process::exit", "tokio::runtime"] { assert!( !MAIN.contains(forbidden), @@ -566,7 +567,7 @@ fn doctor_and_process_results_are_closed_bounded_and_process_safe() { } assert!( MANIFEST.contains( - "tokio = { version = \"1\", default-features = false, features = [\"time\"] }" + "tokio = { version = \"1\", default-features = false, features = [\"io-util\", \"macros\", \"net\", \"rt-multi-thread\", \"signal\", \"sync\", \"time\"] }" ) ); assert!(MANIFEST.contains( @@ -1232,7 +1233,7 @@ fn public_errors_are_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct rhi::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 38); + assert_eq!(public_error_count, 39); } #[test] diff --git a/tests/services_hardening_cli.rs b/tests/services_hardening_cli.rs @@ -6,14 +6,13 @@ use std::path::Path; use rhi::{ INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliAdminOperationV1, RhiCliOfflineOperationV1, - RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCliV1ErrorKind, RhiCommandV1, - RhiConfigCommandV1, RhiIdentityCommandV1, RhiMetricsCommandV1, RhiPresenceCommandV1, - RhiPublicationCommandV1, RhiReconciliationCommandV1, RhiSourcesCommandV1, RhiStateCommandV1, - RhiTradeCommandV1, parse_rhi_cli_v1_from, plan_rhi_cli_v1, + RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCliV1ErrorKind, parse_rhi_cli_v1_from, + plan_rhi_cli_v1, }; const CLI_SOURCE: &str = include_str!("../src/cli_v1.rs"); const MAIN_SOURCE: &str = include_str!("../src/main.rs"); +const PROCESS_SOURCE: &str = include_str!("../src/process_v1.rs"); const OPERATOR_CONTRACT: &str = include_str!("../contracts/services_hardening/operator_contract.v1.json"); @@ -25,125 +24,101 @@ fn parse(command: &[&str]) -> rhi::RhiCliInvocationV1 { #[test] fn root_api_exposes_the_complete_closed_command_inventory() { + let trade = "00000000000000000000000000000000"; let vectors = [ - (&["run"][..], RhiCommandV1::Run), - ( - &["config", "init"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Init), - ), - ( - &["config", "validate"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Validate), - ), - ( - &["config", "show"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Show), - ), - ( - &["config", "schema"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Schema), - ), - ( - &["config", "apply"][..], - RhiCommandV1::Config(RhiConfigCommandV1::Apply), - ), - ( - &["state", "init"][..], - RhiCommandV1::State(RhiStateCommandV1::Init), - ), - ( - &["state", "status"][..], - RhiCommandV1::State(RhiStateCommandV1::Status), - ), - ( - &["state", "backup"][..], - RhiCommandV1::State(RhiStateCommandV1::Backup), - ), - ( - &["state", "restore"][..], - RhiCommandV1::State(RhiStateCommandV1::Restore), - ), - ( - &["state", "verify"][..], - RhiCommandV1::State(RhiStateCommandV1::Verify), - ), - ( - &["state", "migrate"][..], - RhiCommandV1::State(RhiStateCommandV1::Migrate), - ), - ( - &["identity", "init"][..], - RhiCommandV1::Identity(RhiIdentityCommandV1::Init), - ), - ( - &["identity", "status"][..], - RhiCommandV1::Identity(RhiIdentityCommandV1::Status), - ), - ( - &["identity", "export-public"][..], - RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic), - ), - (&["status"][..], RhiCommandV1::Status), - ( - &["metrics", "snapshot"][..], - RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot), - ), - ( - &["reconciliation", "status"][..], - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status), - ), - ( - &["reconciliation", "jobs"][..], - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs), - ), - ( - &["reconciliation", "refresh"][..], - RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh), - ), - ( - &["sources", "list"][..], - RhiCommandV1::Sources(RhiSourcesCommandV1::List), - ), - ( - &["trade", "projection"][..], - RhiCommandV1::Trade(RhiTradeCommandV1::Projection), - ), - ( - &["trade", "report-current"][..], - RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent), - ), - ( - &["trade", "reports"][..], - RhiCommandV1::Trade(RhiTradeCommandV1::Reports), - ), - ( - &["publication", "backlog"][..], - RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog), - ), - ( - &["publication", "targets"][..], - RhiCommandV1::Publication(RhiPublicationCommandV1::Targets), - ), - ( - &["publication", "retry"][..], - RhiCommandV1::Publication(RhiPublicationCommandV1::Retry), - ), - ( - &["presence", "desired"][..], - RhiCommandV1::Presence(RhiPresenceCommandV1::Desired), - ), - ( - &["presence", "render"][..], - RhiCommandV1::Presence(RhiPresenceCommandV1::Render), - ), - ( - &["presence", "refresh"][..], - RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh), - ), - (&["doctor"][..], RhiCommandV1::Doctor), + vec!["run"], + vec!["config", "init"], + vec!["config", "validate"], + vec!["config", "show"], + vec!["config", "schema"], + vec![ + "config", + "apply", + "--candidate-config", + "/tmp/candidate.toml", + ], + vec!["state", "init"], + vec!["state", "status"], + vec![ + "state", + "backup", + "--operation-id", + "backup-1", + "--target", + "/tmp/backup", + "--expected-generation", + "1", + "--confirm", + ], + vec![ + "state", + "restore", + "--manifest", + "/tmp/manifest.json", + "--manifest-sha256", + "0000000000000000000000000000000000000000000000000000000000000000", + "--bundle", + "/tmp/backup", + "--maximum-state-bytes", + "1048576", + "--confirm", + ], + vec!["state", "verify"], + vec!["state", "migrate"], + vec!["identity", "init"], + vec!["identity", "status"], + vec!["identity", "export-public"], + vec!["status"], + vec!["metrics", "snapshot"], + vec!["reconciliation", "status"], + vec!["reconciliation", "jobs"], + vec![ + "reconciliation", + "refresh", + "--operation-id", + "refresh-1", + "--trade-id", + trade, + "--expected-dirty-generation", + "1", + ], + vec!["sources", "list"], + vec!["trade", "projection", "--trade-id", trade], + vec!["trade", "report-current", "--trade-id", trade], + vec!["trade", "reports", "--trade-id", trade], + vec!["publication", "backlog"], + vec!["publication", "targets"], + vec![ + "publication", + "retry", + "--operation-id", + "retry-1", + "--workflow-id", + "workflow-1", + "--expected-generation", + "1", + ], + vec!["presence", "desired"], + vec![ + "presence", + "render", + "--operation-id", + "render-1", + "--expected-generation", + "1", + ], + vec![ + "presence", + "refresh", + "--operation-id", + "presence-1", + "--expected-generation", + "1", + ], + vec!["doctor"], ]; - for (arguments, expected) in vectors { - assert_eq!(parse(arguments).command(), expected); + for arguments in vectors { + parse(&arguments); } } @@ -264,7 +239,12 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() { ), ( "config apply", - vec!["config", "apply"], + vec![ + "config", + "apply", + "--candidate-config", + "/tmp/candidate.toml", + ], "offline", Some("config"), None, @@ -285,14 +265,36 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() { ), ( "state backup", - vec!["state", "backup"], + vec![ + "state", + "backup", + "--operation-id", + "backup-1", + "--target", + "/tmp/backup", + "--expected-generation", + "1", + "--confirm", + ], "live_unix_admin", None, Some("/v1/state/backup"), ), ( "state restore", - vec!["state", "restore"], + vec![ + "state", + "restore", + "--manifest", + "/tmp/manifest.json", + "--manifest-sha256", + "0000000000000000000000000000000000000000000000000000000000000000", + "--bundle", + "/tmp/backup", + "--maximum-state-bytes", + "1048576", + "--confirm", + ], "offline", Some("state_exclusive"), None, @@ -362,7 +364,16 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() { ), ( "reconciliation refresh", - vec!["reconciliation", "refresh"], + vec![ + "reconciliation", + "refresh", + "--operation-id", + "refresh-1", + "--trade-id", + "00000000000000000000000000000000", + "--expected-dirty-generation", + "1", + ], "live_unix_admin", None, Some("/v1/reconciliation/refresh"), @@ -376,21 +387,36 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() { ), ( "trade projection", - vec!["trade", "projection"], + vec![ + "trade", + "projection", + "--trade-id", + "00000000000000000000000000000000", + ], "live_unix_admin", None, Some("/v1/trades/{trade_id}/projection"), ), ( "trade report-current", - vec!["trade", "report-current"], + vec![ + "trade", + "report-current", + "--trade-id", + "00000000000000000000000000000000", + ], "live_unix_admin", None, Some("/v1/trades/{trade_id}/reports/current"), ), ( "trade reports", - vec!["trade", "reports"], + vec![ + "trade", + "reports", + "--trade-id", + "00000000000000000000000000000000", + ], "live_unix_admin", None, Some("/v1/trades/{trade_id}/reports"), @@ -411,7 +437,16 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() { ), ( "publication retry", - vec!["publication", "retry"], + vec![ + "publication", + "retry", + "--operation-id", + "retry-1", + "--workflow-id", + "workflow-1", + "--expected-generation", + "1", + ], "live_unix_admin", None, Some("/v1/publication/retry"), @@ -425,14 +460,28 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() { ), ( "presence render", - vec!["presence", "render"], + vec![ + "presence", + "render", + "--operation-id", + "render-1", + "--expected-generation", + "1", + ], "live_unix_admin", None, Some("/v1/presence/render"), ), ( "presence refresh", - vec!["presence", "refresh"], + vec![ + "presence", + "refresh", + "--operation-id", + "presence-1", + "--expected-generation", + "1", + ], "live_unix_admin", None, Some("/v1/presence/refresh"), @@ -586,6 +635,12 @@ fn execution_plan_is_safe_and_the_binary_parses_and_plans_once() { "/secret/config.toml", "publication", "retry", + "--operation-id", + "operation-1", + "--workflow-id", + "0000000000000000000000000000000000000000000000000000000000000000", + "--expected-generation", + "1", ]) .expect("valid invocation"); let rendered = format!("{invocation:?} {:?}", plan_rhi_cli_v1(&invocation)); @@ -604,7 +659,15 @@ fn execution_plan_is_safe_and_the_binary_parses_and_plans_once() { 1 ); assert_eq!( - MAIN_SOURCE.matches("plan_rhi_cli_v1(&invocation)").count(), + MAIN_SOURCE + .matches("execute_rhi_cli_v1_with_signal_source") + .count(), + 1 + ); + assert_eq!( + PROCESS_SOURCE + .matches("plan_rhi_cli_v1(&invocation)") + .count(), 1 ); for source in [CLI_SOURCE, MAIN_SOURCE] { diff --git a/tests/services_hardening_doctor.rs b/tests/services_hardening_doctor.rs @@ -289,6 +289,11 @@ async fn report_debug_and_public_errors_retain_no_sensitive_values() { #[test] fn binary_uses_stable_safe_nonzero_results() { + const INPUT_FAILURE: &str = concat!( + r#"{"schema":"radroots.rhi.log.v1","contract_version":1,"service":"rhi","#, + r#""level":"error","event":"process_result","code":"input_or_configuration","exit_code":2}"#, + "\n" + ); let canary = "secret-canary-private-key-path-sql-relay-url"; let invalid = Command::new(env!("CARGO_BIN_EXE_rhi")) .arg(format!("--credential={canary}")) @@ -297,7 +302,7 @@ fn binary_uses_stable_safe_nonzero_results() { assert_eq!(invalid.status.code(), Some(2)); assert!(invalid.stdout.is_empty()); let stderr = String::from_utf8(invalid.stderr).expect("invalid stderr"); - assert_eq!(stderr, "RHI command failed: input_or_configuration\n"); + assert_eq!(stderr, INPUT_FAILURE); assert!(!stderr.contains(canary)); let repo_local = tempfile::tempdir().expect("repo-local root"); @@ -312,7 +317,7 @@ fn binary_uses_stable_safe_nonzero_results() { assert!(admitted.stdout.is_empty()); assert_eq!( String::from_utf8(admitted.stderr).expect("admitted stderr"), - "RHI command failed: input_or_configuration\n" + INPUT_FAILURE ); } diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs @@ -201,7 +201,7 @@ fn every_radroots_dependency_is_exactly_source_locked() { .iter() .filter(|(name, _)| name.starts_with("radroots_")) .collect::<Vec<_>>(); - assert_eq!(radroots.len(), 11); + assert_eq!(radroots.len(), 12); for (name, dependency) in radroots { let dependency = dependency.as_table().expect("detailed dependency"); assert_eq!( diff --git a/tests/services_hardening_operator_contract.rs b/tests/services_hardening_operator_contract.rs @@ -228,6 +228,34 @@ fn admin_inventory_is_closed_unique_and_model_complete() { serde_json::json!({ "kind": "enum", "values": ["Valid", "Invalid", "Indeterminate"] }) ); assert_eq!( + value["admin"]["types"]["provider_state"]["fields"], + serde_json::json!({ + "health": "provider_health", + "identity": "identity_health", + "reason_codes": "reason_codes" + }) + ); + assert_eq!( + value["admin"]["types"]["transport_state"]["fields"], + serde_json::json!({ + "health": "transport_health", + "required_sources_ready": "bool", + "subscriber_active": "bool", + "configured_source_count": "u64", + "reachable_source_count": "u64", + "reason_codes": "reason_codes" + }) + ); + assert_eq!( + value["admin"]["types"]["rhi_status"]["fields"], + serde_json::json!({ + "identity": "identity_health", + "reconciliation": "reconciliation_status", + "publication": "publication_status", + "presence": "presence_status" + }) + ); + assert_eq!( value["admin"]["models"]["service_status_v1"]["fields"] .as_object() .unwrap() @@ -301,7 +329,7 @@ fn admin_inventory_is_closed_unique_and_model_complete() { assert_eq!(mutation_operations, committed_effects); assert_eq!( decision_sections_digest(&value), - "b227c6f248605a1672d3b7b07f60b0fd9ba8b890478d17e081cd7e3caed8a889" + "49376e3bdf0e44c35f877ecd382f26bc9c38fec8fea7a674aa7d4da52ee00c62" ); } diff --git a/tests/services_hardening_process.rs b/tests/services_hardening_process.rs @@ -0,0 +1,441 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{ + fs, + io::{Read as _, Write as _}, + net::{TcpListener, TcpStream}, + os::unix::fs::PermissionsExt as _, + path::PathBuf, + process::{Child, Command, Output, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use nostr::{Keys, SecretKey}; +use rhi::{ + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_rhi_cli_v1_from, + resolve_rhi_runtime_context, +}; +use sha2::{Digest as _, Sha256}; +use tungstenite::{Error as WebSocketError, Message, accept}; + +const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const PROCESS_DEADLINE: Duration = Duration::from_secs(30); +const RELAY_IO_TIMEOUT: Duration = Duration::from_millis(100); + +struct RelayHarness { + address: std::net::SocketAddr, + stop: Arc<AtomicBool>, + thread: Option<thread::JoinHandle<()>>, +} + +impl RelayHarness { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("test relay listener"); + listener + .set_nonblocking(true) + .expect("nonblocking test relay"); + let address = listener.local_addr().expect("test relay address"); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let thread = thread::spawn(move || { + let mut sessions = Vec::new(); + while !thread_stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + let session_stop = Arc::clone(&thread_stop); + sessions.push(thread::spawn(move || relay_session(stream, &session_stop))); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("test relay accept failed: {error}"), + } + } + for session in sessions { + session.join().expect("test relay session"); + } + }); + Self { + address, + stop, + thread: Some(thread), + } + } + + fn url(&self, path: &str) -> String { + format!("ws://{}/{path}", self.address) + } +} + +impl Drop for RelayHarness { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.address); + if let Some(thread) = self.thread.take() { + thread.join().expect("test relay"); + } + } +} + +fn relay_session(stream: TcpStream, stop: &AtomicBool) { + stream + .set_read_timeout(Some(RELAY_IO_TIMEOUT)) + .expect("relay read timeout"); + stream + .set_write_timeout(Some(RELAY_IO_TIMEOUT)) + .expect("relay write timeout"); + let mut websocket = match accept(stream) { + Ok(websocket) => websocket, + Err(_) => return, + }; + while !stop.load(Ordering::SeqCst) { + let message = match websocket.read() { + Ok(message) => message, + Err(WebSocketError::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue; + } + Err(WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed) => break, + Err(_) => break, + }; + match message { + Message::Text(text) => relay_text(&mut websocket, text.as_str()), + Message::Ping(bytes) => { + if websocket.send(Message::Pong(bytes)).is_err() { + break; + } + } + Message::Close(_) => break, + _ => {} + } + } +} + +fn relay_text(websocket: &mut tungstenite::WebSocket<TcpStream>, text: &str) { + let Ok(message) = serde_json::from_str::<serde_json::Value>(text) else { + return; + }; + let Some(parts) = message.as_array() else { + return; + }; + match parts.first().and_then(serde_json::Value::as_str) { + Some("REQ") => { + let Some(subscription) = parts.get(1).and_then(serde_json::Value::as_str) else { + return; + }; + let response = serde_json::json!(["EOSE", subscription]).to_string(); + let _ = websocket.send(Message::Text(response.into())); + } + Some("EVENT") => { + let Some(event_id) = parts + .get(1) + .and_then(|event| event.get("id")) + .and_then(serde_json::Value::as_str) + else { + return; + }; + let response = serde_json::json!(["OK", event_id, true, ""]).to_string(); + let _ = websocket.send(Message::Text(response.into())); + } + _ => {} + } +} + +struct ProcessFixture { + root: tempfile::TempDir, + config: PathBuf, + runtime: rhi::RhiRuntimeContext, +} + +impl ProcessFixture { + fn new() -> Self { + let root = tempfile::Builder::new() + .prefix("rhi-") + .tempdir_in("/private/tmp") + .expect("short repo-local root"); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)) + .expect("secure repo-local root"); + let config = root.path().join("rhi.toml"); + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root.path().to_str().expect("UTF-8 test root"), + "--config", + config.to_str().expect("UTF-8 config path"), + "run", + ]) + .expect("runtime invocation"); + let runtime = resolve_rhi_runtime_context( + &RadrootsPathResolver::new( + RadrootsPlatform::current(), + RadrootsHostEnvironment::default(), + ), + &invocation, + ) + .expect("runtime context"); + Self { + root, + config, + runtime, + } + } + + fn command(&self, command: &[&str]) -> Command { + let mut process = Command::new(env!("CARGO_BIN_EXE_rhi")); + process + .args(["--profile", "repo-local", "--instance", "primary"]) + .arg("--repo-local-root") + .arg(self.root.path()) + .arg("--config") + .arg(&self.config) + .args(command) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + process + } + + fn run(&self, command: &[&str]) -> Output { + wait_bounded(self.command(command).spawn().expect("RHI process")) + } + + fn run_with_stdin(&self, command: &[&str], bytes: &[u8]) -> Output { + let mut process = self.command(command); + process.stdin(Stdio::piped()); + let mut child = process.spawn().expect("RHI process"); + child + .stdin + .take() + .expect("process stdin") + .write_all(bytes) + .expect("bounded stdin"); + wait_bounded(child) + } +} + +fn wait_bounded(mut child: Child) -> Output { + let deadline = Instant::now() + PROCESS_DEADLINE; + loop { + if child.try_wait().expect("poll RHI process").is_some() { + let output = child.wait_with_output().expect("collect RHI process"); + assert!(output.stdout.len() <= 1_048_576); + assert!(output.stderr.len() <= 8_192); + return output; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().expect("reap RHI process"); + panic!("RHI process exceeded deadline: {:?}", output.stderr); + } + thread::sleep(Duration::from_millis(2)); + } +} + +fn identity_secret() -> [u8; 32] { + let mut candidate: [u8; 32] = + Sha256::digest(b"radroots.rhi.step-213.process-identity.v1").into(); + while SecretKey::from_slice(&candidate).is_err() { + candidate = Sha256::digest(candidate).into(); + } + candidate +} + +fn provisioning_document(secret: [u8; 32]) -> [u8; 117] { + let mut document = [0_u8; 117]; + document[..4].copy_from_slice(b"RHIP"); + document[4] = 1; + document[5..37].copy_from_slice(&secret); + document[37..69].copy_from_slice(&Sha256::digest(b"rhi-step-213-data-key")); + document[69..93].copy_from_slice(&[3; 24]); + document[93..117].copy_from_slice(&[4; 24]); + document +} + +fn configuration( + fixture: &ProcessFixture, + expected_public_key: &str, + primary_relay: &str, + secondary_relay: &str, +) -> String { + CONFIG_EXAMPLE + .replace( + "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", + fixture + .runtime + .identity_path() + .to_str() + .expect("UTF-8 identity path"), + ) + .replace(&"2".repeat(64), expected_public_key) + .replace("wss://relay.example.com/", primary_relay) + .replace("wss://relay-secondary.example.com/", secondary_relay) + .replace("connect_deadline_ms = 10000", "connect_deadline_ms = 100") + .replace("request_deadline_ms = 15000", "request_deadline_ms = 1000") +} + +fn diagnostic_code(output: &Output) -> String { + let value: serde_json::Value = serde_json::from_slice(&output.stderr).expect("diagnostic JSON"); + value["code"].as_str().expect("diagnostic code").to_owned() +} + +fn assert_success(output: &Output) { + assert_eq!(output.status.code(), Some(0), "stderr: {:?}", output.stderr); + assert_eq!(diagnostic_code(output), "success"); +} + +fn bootstrap(fixture: &ProcessFixture, configuration: &str, secret: [u8; 32]) -> String { + let expected_public_key = Keys::new(SecretKey::from_slice(&secret).expect("secret")) + .public_key() + .to_hex(); + let config = fixture.run_with_stdin(&["config", "init"], configuration.as_bytes()); + assert_success(&config); + assert_eq!(config.stdout, b"config_initialized\n"); + + for directory in [ + fixture.runtime.context().paths().state(), + fixture.runtime.context().paths().secrets(), + fixture.runtime.context().paths().run(), + ] { + fs::create_dir_all(directory).expect("secure runtime directory"); + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)) + .expect("secure runtime mode"); + } + let credential = fixture + .runtime + .context() + .paths() + .secrets() + .join("service_wrapping_key"); + fs::write(&credential, Sha256::digest(b"rhi-step-213-wrapping-key")) + .expect("wrapping credential"); + fs::set_permissions(&credential, fs::Permissions::from_mode(0o600)).expect("credential mode"); + + let state = fixture.run(&["state", "init"]); + assert_success(&state); + assert_eq!(state.stdout, b"state_initialized\n"); + let identity = fixture.run_with_stdin( + &["--output", "json", "identity", "init"], + &provisioning_document(secret), + ); + assert_success(&identity); + let identity_value: serde_json::Value = + serde_json::from_slice(&identity.stdout).expect("identity result"); + assert_eq!(identity_value["public_key"], expected_public_key); + + for command in [ + &["config", "validate"][..], + &["state", "verify"][..], + &["state", "migrate"][..], + ] { + assert_success(&fixture.run(command)); + } + expected_public_key +} + +fn wait_for_live_status(fixture: &ProcessFixture, daemon: &mut Child) -> Output { + let deadline = Instant::now() + PROCESS_DEADLINE; + let mut last_diagnostic = Vec::new(); + loop { + if let Some(status) = daemon.try_wait().expect("poll RHI daemon") { + let mut stderr = Vec::new(); + daemon + .stderr + .take() + .expect("RHI daemon stderr") + .read_to_end(&mut stderr) + .expect("read RHI daemon stderr"); + panic!("RHI daemon exited before admin became ready: {status}; stderr={stderr:?}"); + } + if fixture.runtime.artifacts().admin_socket().exists() { + let status = fixture.run(&["status"]); + if status.status.success() { + return status; + } + last_diagnostic = status.stderr; + } + if Instant::now() >= deadline { + panic!( + "RHI admin did not become ready before deadline; socket={}; stderr={last_diagnostic:?}", + fixture.runtime.artifacts().admin_socket().exists() + ); + } + thread::sleep(Duration::from_millis(2)); + } +} + +#[test] +fn actual_binary_executes_offline_bootstrap_and_reaches_real_runtime_dependency_boundary() { + let fixture = ProcessFixture::new(); + let secret = identity_secret(); + let expected_public_key = Keys::new(SecretKey::from_slice(&secret).expect("secret")) + .public_key() + .to_hex(); + let configuration = configuration( + &fixture, + &expected_public_key, + "ws://127.0.0.1:9/", + "ws://127.0.0.1:10/", + ); + bootstrap(&fixture, &configuration, secret); + + let run = fixture.run(&["run"]); + assert_eq!(run.status.code(), Some(3)); + assert!(run.stdout.is_empty()); + assert_eq!(diagnostic_code(&run), "service_or_dependency_unavailable"); + let diagnostic = String::from_utf8(run.stderr).expect("diagnostic UTF-8"); + assert!(!diagnostic.contains(fixture.root.path().to_str().expect("UTF-8 root"))); + assert!(!diagnostic.contains("relay.example")); + assert!(!diagnostic.contains("service_wrapping_key")); +} + +#[test] +fn actual_binary_runs_the_task_graph_serves_admin_and_shuts_down_on_interrupt() { + let relay = RelayHarness::start(); + let fixture = ProcessFixture::new(); + let secret = identity_secret(); + let expected_public_key = Keys::new(SecretKey::from_slice(&secret).expect("secret")) + .public_key() + .to_hex(); + let configuration = configuration( + &fixture, + &expected_public_key, + &relay.url("primary"), + &relay.url("secondary"), + ); + bootstrap(&fixture, &configuration, secret); + + let mut daemon = fixture.command(&["run"]).spawn().expect("RHI daemon"); + let status = wait_for_live_status(&fixture, &mut daemon); + assert_success(&status); + let status_value: serde_json::Value = + serde_json::from_slice(&status.stdout).expect("live status JSON"); + assert_eq!(status_value["service"], "rhi"); + assert_eq!(status_value["phase"], "ready"); + assert_eq!(status_value["ready"], true); + assert_eq!(status_value["persistence"]["schema_version"], 11); + + let signal = Command::new("/bin/kill") + .arg("-INT") + .arg(daemon.id().to_string()) + .status() + .expect("send interrupt"); + assert!(signal.success()); + let shutdown = wait_bounded(daemon); + assert_success(&shutdown); + assert!(shutdown.stdout.is_empty()); + assert!(!fixture.runtime.artifacts().admin_socket().exists()); +} diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -23,8 +23,9 @@ use rhi::{ RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_9_SHA256, RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_10_SHA256, - RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, - validate_rhi_state_catalogs, + RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT, + RHI_STATE_SCHEMA_VERSION_11_SHA256, RhiStateCatalogErrorKind, rhi_migration_catalog, + rhi_schema_catalog, validate_rhi_state_catalogs, }; const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); @@ -32,14 +33,14 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_through_v10_catalogs_have_exact_literal_identities() { +fn schema_v1_through_v11_catalogs_have_exact_literal_identities() { let migrations = rhi_migration_catalog().expect("RHI migration catalog"); let schema = rhi_schema_catalog().expect("RHI schema catalog"); assert_eq!(RHI_STATE_BASE_SCHEMA_VERSION, 1); - assert_eq!(RHI_STATE_SCHEMA_VERSION, 10); - assert_eq!(migrations.descriptors().len(), 9); - assert_eq!(migrations.current_version(), 10); + assert_eq!(RHI_STATE_SCHEMA_VERSION, 11); + assert_eq!(migrations.descriptors().len(), 10); + assert_eq!(migrations.current_version(), 11); assert_eq!(migrations.descriptors()[0].target_version(), 2); assert_eq!( migrations.descriptors()[0].name().as_str(), @@ -121,12 +122,21 @@ fn schema_v1_through_v10_catalogs_have_exact_literal_identities() { migrations.descriptors()[8].checksum().as_bytes(), &RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256 ); + assert_eq!(migrations.descriptors()[9].target_version(), 11); + assert_eq!( + migrations.descriptors()[9].name().as_str(), + "create_admin_operation_journal" + ); + assert_eq!( + migrations.descriptors()[9].checksum().as_bytes(), + &RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256 + ); assert_eq!( migrations.digest().as_bytes(), &RHI_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 10); + assert_eq!(schema.versions().len(), 11); let version = schema.versions()[0]; assert_eq!(version.version(), 1); assert_eq!( @@ -237,13 +247,24 @@ fn schema_v1_through_v10_catalogs_have_exact_literal_identities() { version.digest().as_bytes(), &RHI_STATE_SCHEMA_VERSION_10_SHA256 ); + let version = schema.versions()[10]; + assert_eq!(version.version(), 11); + assert_eq!( + version.object_count(), + RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT + ); + assert_eq!(version.object_count(), 82); + assert_eq!( + version.digest().as_bytes(), + &RHI_STATE_SCHEMA_VERSION_11_SHA256 + ); assert_eq!(schema.digest().as_bytes(), &RHI_STATE_SCHEMA_CATALOG_SHA256); assert_eq!(schema.migration_catalog_digest(), migrations.digest()); validate_rhi_state_catalogs(&migrations, &schema).expect("exact catalogs"); assert_eq!( lower_hex(&RHI_MIGRATION_CATALOG_SHA256), - "25e5ba773ef3db0133a8077a083e88b40fc6dadf9fb6f0cfde0e4ba4d3e081d9" + "e6cbacbd1eb636c1a560f85ef8e51e89c9ffe3b342e66bc5c0b47ab34e90c818" ); assert_eq!( lower_hex(&RHI_STATE_SCHEMA_VERSION_1_SHA256), @@ -322,8 +343,16 @@ fn schema_v1_through_v10_catalogs_have_exact_literal_identities() { "d2aed51d0a6a2c01eda1844608472b2dcd502abaa8a4ca30a4823535b8bd0e45" ); assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256), + "e3fbde511e8424c97080be2c09ed810ae284631d75eb25c2e88a6076b700aaef" + ); + assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_VERSION_11_SHA256), + "c25ec63b33b411618068ee06a04c99ee7166e0014d979039faeaea4dc1ac7e62" + ); + assert_eq!( lower_hex(&RHI_STATE_SCHEMA_CATALOG_SHA256), - "4f4d5f5546a7c94ef3cdabe23eadd0c97a64980984ee38cacb828f1125913488" + "aec482818bd9a6f33fd92b55d142c6b85aa6f086855701dfc4b78f2a7ef5cf6d" ); } @@ -395,6 +424,10 @@ fn independent_validator_rejects_migration_or_schema_drift() { SchemaVersionCatalog::computed_digest(10, [version_two_object()]).expect("v10 digest"); let version_ten = SchemaVersionCatalog::new(10, [version_two_object()], snapshot_digest) .expect("version ten"); + let snapshot_digest = + SchemaVersionCatalog::computed_digest(11, [version_two_object()]).expect("v11 digest"); + let version_eleven = SchemaVersionCatalog::new(11, [version_two_object()], snapshot_digest) + .expect("version eleven"); let schema = SchemaCatalog::new( &exact_migrations, [ @@ -408,6 +441,7 @@ fn independent_validator_rejects_migration_or_schema_drift() { version_eight, version_nine, version_ten, + version_eleven, ], ) .expect("drift schema catalog"); @@ -478,6 +512,10 @@ fn catalog_errors_are_stable_source_free_and_redacted() { SchemaVersionCatalog::computed_digest(10, [secret_object()]).expect("v10 digest"); let version_ten = SchemaVersionCatalog::new(10, [secret_object()], version_ten_digest).expect("version ten"); + let version_eleven_digest = + SchemaVersionCatalog::computed_digest(11, [secret_object()]).expect("v11 digest"); + let version_eleven = SchemaVersionCatalog::new(11, [secret_object()], version_eleven_digest) + .expect("version eleven"); let schema = SchemaCatalog::new( &migrations, [ @@ -491,6 +529,7 @@ fn catalog_errors_are_stable_source_free_and_redacted() { version_eight, version_nine, version_ten, + version_eleven, ], ) .expect("schema catalog"); diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs @@ -106,6 +106,8 @@ async fn downgrade_fixture_to_schema_v7(runtime: &rhi::RhiRuntimeContext) { .await .expect("migration delete guard SQL"); for statement in [ + "DROP TRIGGER rhi_admin_operations_guard_update", + "DROP TABLE rhi_admin_operations", "DROP TABLE presence_attempts", "DROP TABLE presence_targets", "DROP TABLE presence_outbox", @@ -119,7 +121,7 @@ async fn downgrade_fixture_to_schema_v7(runtime: &rhi::RhiRuntimeContext) { "DROP TRIGGER schema_migrations_no_update", "DROP TRIGGER schema_migrations_no_delete", "UPDATE radroots_service_metadata SET state_schema_version = 7 WHERE singleton = 1", - "DELETE FROM schema_migrations WHERE version IN (8, 9, 10)", + "DELETE FROM schema_migrations WHERE version IN (8, 9, 10, 11)", ] { sqlx::query(statement) .execute(&mut connection) @@ -486,7 +488,7 @@ async fn schema_v8_scans_historical_nullable_job_state_and_installs_permanent_gu .fetch_one(&mut connection) .await .expect("migrated schema state"); - assert_eq!(migrated, (10, 1, 2, 0)); + assert_eq!(migrated, (11, 1, 2, 0)); let invalid_insert = sqlx::query( r#"INSERT INTO reconciliation_jobs ( diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs @@ -347,8 +347,8 @@ async fn exact_open_rejects_unexpected_migration_history_without_repair() { service_version, service_commit, lib_revision, rust_version, target, feature_profile, config_contract_version, state_contract_version, admin_contract_version, status_contract_version, provider_contract_version - ) VALUES (11, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?, - 'rustc-test', 'test-target', 'service-host', 1, 10, 1, 1, 1)", + ) VALUES (12, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?, + 'rustc-test', 'test-target', 'service-host', 1, 11, 1, 1, 1)", ) .bind([0x44_u8; 32].as_slice()) .bind("1111111111111111111111111111111111111111") diff --git a/tests/services_hardening_status.rs b/tests/services_hardening_status.rs @@ -170,7 +170,7 @@ fn machine_contract_and_canonical_detailed_status_are_exact() { let wire = std::str::from_utf8(snapshot.detailed_status_json()).expect("status UTF-8"); assert_eq!( wire, - r#"{"contract_version":1,"service":"rhi","instance":"primary","phase":"ready","ready":true,"uptime_millis":120000,"reason_codes":[],"build_info":{"version":"0.1.0","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":10,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.rhi.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":10,"generation":42,"integrity":"verified","reason_codes":[]},"provider":{"health":"ready","identity":{"configured":true,"available":true,"reason_codes":[]},"reason_codes":[]},"transport":{"health":"ready","required_sources_ready":true,"subscriber_active":true,"configured_source_count":2,"reachable_source_count":2,"reason_codes":[]},"rhi":{"identity":{"configured":true,"available":true,"reason_codes":[]},"reconciliation":{"pending":5,"leased":3,"exhausted":1,"oldest_pending_at_utc":1723456700},"publication":{"pending":4,"unknown":1,"oldest_pending_at_utc":1723456789},"presence":{"pending":2,"unknown":1}}}"# + r#"{"contract_version":1,"service":"rhi","instance":"primary","phase":"ready","ready":true,"uptime_millis":120000,"reason_codes":[],"build_info":{"version":"0.1.0","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":11,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.rhi.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":10,"generation":42,"integrity":"verified","reason_codes":[]},"provider":{"health":"ready","identity":{"configured":true,"available":true,"reason_codes":[]},"reason_codes":[]},"transport":{"health":"ready","required_sources_ready":true,"subscriber_active":true,"configured_source_count":2,"reachable_source_count":2,"reason_codes":[]},"rhi":{"identity":{"configured":true,"available":true,"reason_codes":[]},"reconciliation":{"pending":5,"leased":3,"exhausted":1,"oldest_pending_at_utc":1723456700},"publication":{"pending":4,"unknown":1,"oldest_pending_at_utc":1723456789},"presence":{"pending":2,"unknown":1}}}"# ); assert!(wire.len() < RHI_DETAILED_STATUS_MAX_UTF8_BYTES); for forbidden in [ diff --git a/tests/services_hardening_wave_100_a.rs b/tests/services_hardening_wave_100_a.rs @@ -79,6 +79,8 @@ fn executable_has_no_prototype_runtime_fallback() { ); } assert!(main.contains("parse_rhi_cli_v1_from")); - assert!(main.contains("resolve_rhi_runtime_context")); - assert!(main.contains("Err(())")); + assert!(main.contains("execute_rhi_cli_v1_with_signal_source")); + assert!(main.contains("RhiProcessResult::InputOrConfiguration")); + let process = include_str!("../src/process_v1.rs"); + assert!(process.contains("resolve_rhi_runtime_context")); } diff --git a/tests/source_guards.rs b/tests/source_guards.rs @@ -174,7 +174,6 @@ fn rhi_wave_one_removes_prototype_runtime_and_selection_authority() { "std::env::var_os(\"RHI_", "worker_root", "nostr_sdk::Client", - "tokio::signal", "tracing_appender", "tracing_subscriber", ] { @@ -183,6 +182,12 @@ fn rhi_wave_one_removes_prototype_runtime_and_selection_authority() { "{path} retains removed wave-one authority `{forbidden}`" ); } + if source.contains("tokio::signal") { + assert_eq!( + path, "src/main.rs", + "only the Step213 binary-owned process adapter may observe OS signals" + ); + } } }