rhi

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

commit b46df3d991f4f7bedd9b4cb1ff0eb1f37d13ae84
parent fda4c3c98f0b3ac6818e0e4924a11554c48d8ee1
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 19:06:45 +0000

config: implement strict RHI v1 admission

- bound original TOML and validate the frozen schema header first
- enforce relay, evidence, identity, retention, and resource relationships
- expose deterministic redacted effective configuration with exact provenance
- add source-free boundary, default-drift, wire, and redaction regressions

Diffstat:
MCargo.toml | 4++--
MREADME | 9+++++++++
Asrc/config_v1.rs | 1658+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 7+++++++
Mtests/services_hardening_config_contract.rs | 40++++++++++++++++++++++++++++++++++------
Atests/services_hardening_config_parser.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1760 insertions(+), 8 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -38,6 +38,7 @@ anyhow = { version = "1" } chacha20poly1305 = { version = "0.10" } clap = { version = "4", features = ["derive"] } futures-executor = { version = "0.3" } +jsonschema = { version = "0.48.1", default-features = false } nostr = { version = "0.44.7", features = ["nip49"] } nostr-sdk = { version = "0.44.1" } rand = { version = "0.9" } @@ -51,8 +52,7 @@ toml = { version = "0.8" } tracing = { version = "0.1" } tracing-appender = { version = "0.2" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" zeroize = { version = "1" } [dev-dependencies] -jsonschema = { version = "0.48.1", default-features = false } -url = "2" diff --git a/README b/README @@ -25,6 +25,15 @@ their ordered replacement steps are complete. Existing environment and worker selectors are prototype evidence, not authority to change or weaken the v1 contract. +`parse_rhi_config_v1` is the strict in-memory admission boundary for the target +document. It bounds original bytes before parsing, rejects malformed duplicate +or null TOML and unknown fields, validates the bootstrap-selected relay posture +and all cross-field relationships, and returns an immutable document with a +deterministic bounded redacted effective projection. The projection records the +exact `toml` or `safe_default` origin for every leaf and the governed authority +behind each safe default. Parsing performs no filesystem, environment, +identity, database, clock, entropy, logging, DNS, or network operation. + Use the repository-owned Nix lanes for validation: ```text diff --git a/src/config_v1.rs b/src/config_v1.rs @@ -0,0 +1,1658 @@ +//! Strict, bounded RHI configuration document v1 admission. + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; +use std::net::SocketAddr; + +use nostr::PublicKey; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use url::Url; + +const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); + +/// Exact schema identity for the production RHI configuration document. +pub const RHI_CONFIG_SCHEMA: &str = "radroots.rhi.config"; + +/// Exact supported RHI configuration schema version. +pub const RHI_CONFIG_SCHEMA_VERSION: u32 = 1; + +/// Hard cap applied to original bytes before UTF-8 or TOML parsing. +pub const RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize = 1_048_576; + +/// Hard cap applied to the deterministic redacted effective projection. +pub const RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES: usize = 786_432; + +/// Bootstrap-selected network posture used during relay admission. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiConfigProfile { + /// Production and ordinary service-host configurations require WSS relays. + Production, + /// Explicit repository-local development may also use loopback WS relays. + RepoLocal, +} + +/// Stable origin classification for one effective configuration value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RhiConfigValueSource { + BootstrapCli, + Toml, + SafeDefault, + DerivedPath, +} + +/// Exact governed authority behind a safely defaulted configuration leaf. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RhiConfigDefaultAuthority { + RadrootsServiceHost, + RadrootsServiceSqlite, + RadrootsEvent, + RhiEvidencePolicy, + AcceptedServiceAuthority, + EngineeringSafety, +} + +/// Stable source-free classification for configuration admission failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiConfigV1ErrorKind { + TooLarge, + InvalidUtf8, + MalformedToml, + MissingSchema, + InvalidSchema, + SchemaMismatch, + MissingSchemaVersion, + InvalidSchemaVersion, + UnsupportedSchemaVersion, + InvalidDocument, + InvalidRelationship, + Encoding, +} + +impl RhiConfigV1ErrorKind { + const fn message(self) -> &'static str { + match self { + Self::TooLarge => "configuration document exceeds its size limit", + Self::InvalidUtf8 => "configuration document is not valid UTF-8", + Self::MalformedToml => "configuration document is not valid TOML", + Self::MissingSchema => "configuration document schema is missing", + Self::InvalidSchema => "configuration document schema is invalid", + Self::SchemaMismatch => "configuration document schema is unsupported", + Self::MissingSchemaVersion => "configuration document schema version is missing", + Self::InvalidSchemaVersion => "configuration document schema version is invalid", + Self::UnsupportedSchemaVersion => { + "configuration document schema version is unsupported" + } + Self::InvalidDocument => "configuration document fields are invalid", + Self::InvalidRelationship => "configuration document relationships are invalid", + Self::Encoding => "effective configuration could not be encoded", + } + } +} + +/// One source-free configuration admission failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiConfigV1Error { + kind: RhiConfigV1ErrorKind, +} + +impl RhiConfigV1Error { + const fn new(kind: RhiConfigV1ErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> RhiConfigV1ErrorKind { + self.kind + } +} + +impl fmt::Debug for RhiConfigV1Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiConfigV1Error") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiConfigV1Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for RhiConfigV1Error {} + +/// Deterministic redacted effective configuration with exact leaf provenance. +#[derive(Clone, PartialEq, Eq)] +pub struct RhiEffectiveConfigV1 { + canonical_json: Box<str>, + field_count: usize, +} + +impl RhiEffectiveConfigV1 { + /// Returns compact JSON in deterministic path order. + #[must_use] + pub fn canonical_json(&self) -> &str { + &self.canonical_json + } + + /// Returns the number of projected effective leaf values. + #[must_use] + pub const fn field_count(&self) -> usize { + self.field_count + } +} + +impl fmt::Debug for RhiEffectiveConfigV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiEffectiveConfigV1") + .field("canonical_json", &"[redacted]") + .field("field_count", &self.field_count) + .finish() + } +} + +/// Validated thread counts for the sole binary-owned Tokio runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RhiRuntimeThreadLimitsV1 { + worker_threads: usize, + blocking_threads: usize, +} + +impl RhiRuntimeThreadLimitsV1 { + /// Returns the configured asynchronous worker count. + #[must_use] + pub const fn worker_threads(self) -> usize { + self.worker_threads + } + + /// Returns the configured blocking worker ceiling. + #[must_use] + pub const fn blocking_threads(self) -> usize { + self.blocking_threads + } +} + +/// A validated immutable RHI configuration document v1. +pub struct RhiConfigDocumentV1 { + profile: RhiConfigProfile, + normalized: Value, + effective: RhiEffectiveConfigV1, + runtime_thread_limits: RhiRuntimeThreadLimitsV1, +} + +impl RhiConfigDocumentV1 { + /// Returns the exact admitted schema identity. + #[must_use] + pub const fn schema(&self) -> &'static str { + RHI_CONFIG_SCHEMA + } + + /// Returns the exact admitted schema version. + #[must_use] + pub const fn schema_version(&self) -> u32 { + RHI_CONFIG_SCHEMA_VERSION + } + + /// Returns the bootstrap-selected network posture used during admission. + #[must_use] + pub const fn profile(&self) -> RhiConfigProfile { + self.profile + } + + /// Returns the deterministic redacted effective configuration projection. + #[must_use] + pub const fn effective(&self) -> &RhiEffectiveConfigV1 { + &self.effective + } + + /// Returns the exact number of configured relay bindings. + #[must_use] + pub fn relay_count(&self) -> usize { + self.normalized + .pointer("/relays") + .and_then(Value::as_array) + .map_or(0, Vec::len) + } + + /// Returns the exact number of configured evidence sources. + #[must_use] + pub fn evidence_source_count(&self) -> usize { + self.normalized + .pointer("/evidence/sources") + .and_then(Value::as_array) + .map_or(0, Vec::len) + } + + /// Returns the validated limits for the sole binary-owned Tokio runtime. + #[must_use] + pub const fn runtime_thread_limits(&self) -> RhiRuntimeThreadLimitsV1 { + self.runtime_thread_limits + } +} + +impl fmt::Debug for RhiConfigDocumentV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiConfigDocumentV1") + .field("schema", &RHI_CONFIG_SCHEMA) + .field("schema_version", &RHI_CONFIG_SCHEMA_VERSION) + .field("profile", &self.profile) + .field("effective", &self.effective) + .field("runtime_thread_limits", &self.runtime_thread_limits) + .finish() + } +} + +/// Parses and semantically validates one complete RHI configuration document. +pub fn parse_rhi_config_v1( + bytes: &[u8], + profile: RhiConfigProfile, +) -> Result<RhiConfigDocumentV1, RhiConfigV1Error> { + if bytes.len() > RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES { + return Err(error(RhiConfigV1ErrorKind::TooLarge)); + } + let source = + std::str::from_utf8(bytes).map_err(|_| error(RhiConfigV1ErrorKind::InvalidUtf8))?; + let original = source + .parse::<toml::Table>() + .map_err(|_| error(RhiConfigV1ErrorKind::MalformedToml))?; + validate_header(&original)?; + + let mut normalized = serde_json::to_value(toml::Value::Table(original.clone())) + .map_err(|_| error(RhiConfigV1ErrorKind::InvalidDocument))?; + let schema: Value = + serde_json::from_str(CONFIG_SCHEMA).map_err(|_| error(RhiConfigV1ErrorKind::Encoding))?; + let validator = + jsonschema::validator_for(&schema).map_err(|_| error(RhiConfigV1ErrorKind::Encoding))?; + if !validator.is_valid(&normalized) { + return Err(error(RhiConfigV1ErrorKind::InvalidDocument)); + } + apply_defaults(&mut normalized)?; + if !validator.is_valid(&normalized) { + return Err(error(RhiConfigV1ErrorKind::InvalidDocument)); + } + validate_relationships(&normalized, profile)?; + let effective = build_effective(&normalized, &original)?; + let runtime_thread_limits = RhiRuntimeThreadLimitsV1 { + worker_threads: usize::try_from(integer( + &normalized, + "/resource_limits/runtime/worker_threads", + )?) + .map_err(|_| error(RhiConfigV1ErrorKind::InvalidRelationship))?, + blocking_threads: usize::try_from(integer( + &normalized, + "/resource_limits/runtime/blocking_threads", + )?) + .map_err(|_| error(RhiConfigV1ErrorKind::InvalidRelationship))?, + }; + Ok(RhiConfigDocumentV1 { + profile, + normalized, + effective, + runtime_thread_limits, + }) +} + +fn validate_header(header: &toml::Table) -> Result<(), RhiConfigV1Error> { + let schema = header + .get("schema") + .ok_or_else(|| error(RhiConfigV1ErrorKind::MissingSchema))? + .as_str() + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidSchema))?; + if !valid_schema_id(schema) { + return Err(error(RhiConfigV1ErrorKind::InvalidSchema)); + } + if schema != RHI_CONFIG_SCHEMA { + return Err(error(RhiConfigV1ErrorKind::SchemaMismatch)); + } + let version = header + .get("schema_version") + .ok_or_else(|| error(RhiConfigV1ErrorKind::MissingSchemaVersion))? + .as_integer() + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidSchemaVersion))?; + let version = + u32::try_from(version).map_err(|_| error(RhiConfigV1ErrorKind::InvalidSchemaVersion))?; + if version == 0 { + return Err(error(RhiConfigV1ErrorKind::InvalidSchemaVersion)); + } + if version != RHI_CONFIG_SCHEMA_VERSION { + return Err(error(RhiConfigV1ErrorKind::UnsupportedSchemaVersion)); + } + Ok(()) +} + +fn valid_schema_id(value: &str) -> bool { + let mut bytes = value.bytes(); + !value.is_empty() + && value.len() <= 128 + && bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && bytes + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +#[derive(Clone, Copy)] +struct DefaultEntry { + path: &'static str, + value: DefaultValue, + authority: RhiConfigDefaultAuthority, + enabled_pointer: Option<&'static str>, +} + +#[derive(Clone, Copy)] +enum DefaultValue { + Integer(u64), + String(&'static str), +} + +const DEFAULTS: &[DefaultEntry] = &[ + default( + "/service/shutdown_grace_ms", + 30_000, + RhiConfigDefaultAuthority::AcceptedServiceAuthority, + ), + default_string( + "/logging/level", + "info", + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default_string( + "/logging/format", + "json", + RhiConfigDefaultAuthority::EngineeringSafety, + ), + conditional_default( + "/operations/limits/header_count", + 32, + RhiConfigDefaultAuthority::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/header_bytes", + 16_384, + RhiConfigDefaultAuthority::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/response_body_utf8_bytes", + 1_048_576, + RhiConfigDefaultAuthority::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/concurrent_connections", + 32, + RhiConfigDefaultAuthority::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/request_deadline_ms", + 15_000, + RhiConfigDefaultAuthority::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/idle_timeout_ms", + 30_000, + RhiConfigDefaultAuthority::RadrootsServiceHost, + "/operations/enabled", + ), + default( + "/database/busy_timeout_ms", + 5_000, + RhiConfigDefaultAuthority::RadrootsServiceSqlite, + ), + default( + "/database/max_connections", + 8, + RhiConfigDefaultAuthority::RadrootsServiceSqlite, + ), + default( + "/network/connect_deadline_ms", + 10_000, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/network/dns_answer_limit", + 16, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/concurrency", + 8, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/queue_capacity", + 4_096, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/lease_ms", + 30_000, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/lease_renewal_ms", + 10_000, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/max_attempts", + 10, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/initial_backoff_ms", + 250, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/maximum_backoff_ms", + 30_000, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/reconciliation/attempt_deadline_ms", + 30_000, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + conditional_default( + "/publication/retry/max_attempts", + 10, + RhiConfigDefaultAuthority::EngineeringSafety, + "/publication/mode", + ), + conditional_default( + "/publication/retry/initial_backoff_ms", + 250, + RhiConfigDefaultAuthority::EngineeringSafety, + "/publication/mode", + ), + conditional_default( + "/publication/retry/maximum_backoff_ms", + 30_000, + RhiConfigDefaultAuthority::EngineeringSafety, + "/publication/mode", + ), + conditional_default( + "/publication/retry/attempt_deadline_ms", + 15_000, + RhiConfigDefaultAuthority::EngineeringSafety, + "/publication/mode", + ), + default( + "/resource_limits/admin/header_count", + 32, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/header_bytes", + 16_384, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/request_body_utf8_bytes", + 65_536, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/response_body_utf8_bytes", + 1_048_576, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/concurrent_connections", + 32, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/request_deadline_ms", + 15_000, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/idle_timeout_ms", + 30_000, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/query_items", + 100, + RhiConfigDefaultAuthority::AcceptedServiceAuthority, + ), + default( + "/resource_limits/events/wire_bytes", + 262_144, + RhiConfigDefaultAuthority::RadrootsEvent, + ), + default( + "/resource_limits/events/content_bytes", + 131_072, + RhiConfigDefaultAuthority::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_count", + 1_024, + RhiConfigDefaultAuthority::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_total_elements", + 4_096, + RhiConfigDefaultAuthority::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_element_bytes", + 4_096, + RhiConfigDefaultAuthority::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_total_bytes", + 131_072, + RhiConfigDefaultAuthority::RadrootsEvent, + ), + default( + "/resource_limits/source_results/events", + 4_096, + RhiConfigDefaultAuthority::RhiEvidencePolicy, + ), + default( + "/resource_limits/source_results/bytes", + 8_388_608, + RhiConfigDefaultAuthority::RhiEvidencePolicy, + ), + default( + "/resource_limits/queues/ingress", + 1_024, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/resource_limits/queues/reconciliation", + 4_096, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/resource_limits/queues/publication", + 4_096, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/resource_limits/queues/presence", + 64, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/resource_limits/metrics/descriptors", + 64, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/metrics/samples", + 512, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/metrics/labels_per_sample", + 8, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/metrics/render_utf8_bytes", + 1_048_576, + RhiConfigDefaultAuthority::RadrootsServiceHost, + ), + default( + "/resource_limits/runtime/worker_threads", + 4, + RhiConfigDefaultAuthority::EngineeringSafety, + ), + default( + "/resource_limits/runtime/blocking_threads", + 8, + RhiConfigDefaultAuthority::EngineeringSafety, + ), +]; + +const fn default( + path: &'static str, + value: u64, + authority: RhiConfigDefaultAuthority, +) -> DefaultEntry { + DefaultEntry { + path, + value: DefaultValue::Integer(value), + authority, + enabled_pointer: None, + } +} + +const fn conditional_default( + path: &'static str, + value: u64, + authority: RhiConfigDefaultAuthority, + enabled_pointer: &'static str, +) -> DefaultEntry { + DefaultEntry { + path, + value: DefaultValue::Integer(value), + authority, + enabled_pointer: Some(enabled_pointer), + } +} + +const fn default_string( + path: &'static str, + value: &'static str, + authority: RhiConfigDefaultAuthority, +) -> DefaultEntry { + DefaultEntry { + path, + value: DefaultValue::String(value), + authority, + enabled_pointer: None, + } +} + +fn default_is_enabled(document: &Value, entry: &DefaultEntry) -> bool { + match entry.enabled_pointer { + Some("/operations/enabled") => { + document.pointer("/operations/enabled") == Some(&Value::Bool(true)) + } + Some("/publication/mode") => { + document + .pointer("/publication/mode") + .and_then(Value::as_str) + == Some("required") + } + Some(_) => false, + None => true, + } +} + +fn apply_defaults(document: &mut Value) -> Result<(), RhiConfigV1Error> { + for entry in DEFAULTS { + if !default_is_enabled(document, entry) || document.pointer(entry.path).is_some() { + continue; + } + insert_json_pointer(document, entry.path, entry.value)?; + } + Ok(()) +} + +fn insert_json_pointer( + root: &mut Value, + pointer: &str, + value: DefaultValue, +) -> Result<(), RhiConfigV1Error> { + let mut parts = pointer + .split('/') + .filter(|part| !part.is_empty()) + .peekable(); + let mut current = root; + while let Some(part) = parts.next() { + let object = current + .as_object_mut() + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument))?; + if parts.peek().is_none() { + object.insert( + part.to_owned(), + match value { + DefaultValue::Integer(value) => Value::Number(value.into()), + DefaultValue::String(value) => Value::String(value.to_owned()), + }, + ); + return Ok(()); + } + current = object + .entry(part.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + } + document_error() +} + +fn validate_relationships( + document: &Value, + profile: RhiConfigProfile, +) -> Result<(), RhiConfigV1Error> { + validate_utf8_byte_limits(document)?; + validate_identity(document)?; + let relays = validate_relays(document, profile)?; + validate_evidence(document, relays)?; + validate_reconciliation(document)?; + validate_publication(document, relays)?; + validate_presence(document, relays)?; + validate_operations(document)?; + validate_retention(document)?; + validate_exact_policy_limits(document) +} + +fn validate_utf8_byte_limits(document: &Value) -> Result<(), RhiConfigV1Error> { + for (pointer, minimum, maximum) in [ + ("/identity/service/envelope_path", 1, 4_096), + ("/identity/service/credential_reference", 1, 128), + ("/identity/service/expected_public_key", 64, 64), + ("/evidence/policy_id", 1, 64), + ] { + validate_string_bytes(string(document, pointer)?, minimum, maximum)?; + } + for relay in array(document, "/relays")? { + validate_string_bytes(string_at(relay, "/id")?, 1, 64)?; + validate_string_bytes(string_at(relay, "/url")?, 1, 2_048)?; + } + for source in array(document, "/evidence/sources")? { + validate_string_bytes(string_at(source, "/source_id")?, 1, 64)?; + validate_string_bytes(string_at(source, "/relay_id")?, 1, 64)?; + } + if bool_value(document, "/operations/enabled")? { + validate_string_bytes(string(document, "/operations/listen")?, 1, 256)?; + } + Ok(()) +} + +fn validate_string_bytes( + value: &str, + minimum: usize, + maximum: usize, +) -> Result<(), RhiConfigV1Error> { + if (minimum..=maximum).contains(&value.len()) { + Ok(()) + } else { + document_error() + } +} + +fn validate_identity(document: &Value) -> Result<(), RhiConfigV1Error> { + let raw_path = string(document, "/identity/service/envelope_path")?; + if raw_path.as_bytes().contains(&0) + || raw_path == "/" + || !raw_path.starts_with('/') + || raw_path.ends_with('/') + || raw_path + .split('/') + .skip(1) + .any(|component| component.is_empty() || matches!(component, "." | "..")) + || !valid_nostr_public_key(string(document, "/identity/service/expected_public_key")?) + { + return relationship_error(); + } + Ok(()) +} + +fn valid_nostr_public_key(value: &str) -> bool { + PublicKey::from_hex(value).is_ok_and(|public_key| public_key.xonly().is_ok()) +} + +fn validate_relays( + document: &Value, + profile: RhiConfigProfile, +) -> Result<&[Value], RhiConfigV1Error> { + let relays = array(document, "/relays")?; + let mut ids = BTreeSet::new(); + let mut urls = BTreeSet::new(); + for relay in relays { + let id = string_at(relay, "/id")?; + let raw_url = string_at(relay, "/url")?; + canonical_relay_url(raw_url, profile)?; + let read = bool_at(relay, "/read")?; + let write = bool_at(relay, "/write")?; + if !ids.insert(id) || !urls.insert(raw_url) || (!read && !write) { + return relationship_error(); + } + } + Ok(relays) +} + +fn canonical_relay_url(value: &str, profile: RhiConfigProfile) -> Result<Url, RhiConfigV1Error> { + let parsed = Url::parse(value).map_err(|_| error(RhiConfigV1ErrorKind::InvalidDocument))?; + if parsed.as_str() != value + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return document_error(); + } + let allowed = match profile { + RhiConfigProfile::Production => parsed.scheme() == "wss", + RhiConfigProfile::RepoLocal => match parsed.scheme() { + "wss" => true, + "ws" => parsed + .host_str() + .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")), + _ => false, + }, + }; + if !allowed { + return relationship_error(); + } + Ok(parsed) +} + +fn validate_evidence(document: &Value, relays: &[Value]) -> Result<(), RhiConfigV1Error> { + let sources = array(document, "/evidence/sources")?; + let mut source_ids = BTreeSet::new(); + let mut bindings = BTreeSet::new(); + let mut required = false; + let mut prior_source_id = None; + for source in sources { + let source_id = string_at(source, "/source_id")?; + let kind = string_at(source, "/kind")?; + let relay_id = string_at(source, "/relay_id")?; + let selector = string_at(source, "/selector")?; + if prior_source_id.is_some_and(|prior| prior >= source_id) + || !source_ids.insert(source_id) + || !bindings.insert((kind, relay_id, selector)) + || !relays.iter().any(|relay| { + string_at(relay, "/id") == Ok(relay_id) && bool_at(relay, "/read") == Ok(true) + }) + || integer_at(source, "/overlap_seconds")? > integer_at(source, "/lookback_seconds")? + { + return relationship_error(); + } + prior_source_id = Some(source_id); + required |= bool_at(source, "/required")?; + } + if !required { + return relationship_error(); + } + Ok(()) +} + +fn validate_reconciliation(document: &Value) -> Result<(), RhiConfigV1Error> { + if integer(document, "/reconciliation/lease_renewal_ms")? + >= integer(document, "/reconciliation/lease_ms")? + || integer(document, "/reconciliation/initial_backoff_ms")? + > integer(document, "/reconciliation/maximum_backoff_ms")? + { + return relationship_error(); + } + let attempt_deadline = integer(document, "/reconciliation/attempt_deadline_ms")?; + if array(document, "/evidence/sources")?.iter().any(|source| { + integer_at(source, "/deadline_ms").map_or(true, |value| value > attempt_deadline) + }) { + return relationship_error(); + } + Ok(()) +} + +fn validate_publication(document: &Value, relays: &[Value]) -> Result<(), RhiConfigV1Error> { + if string(document, "/publication/mode")? == "disabled" { + return Ok(()); + } + validate_relay_targets(document, "/publication/target_relay_ids", relays)?; + if integer(document, "/publication/retry/initial_backoff_ms")? + > integer(document, "/publication/retry/maximum_backoff_ms")? + { + return relationship_error(); + } + Ok(()) +} + +fn validate_presence(document: &Value, relays: &[Value]) -> Result<(), RhiConfigV1Error> { + if !bool_value(document, "/presence/enabled")? { + if bool_value(document, "/presence/profile")? + || bool_value(document, "/presence/application_handler")? + { + return relationship_error(); + } + return Ok(()); + } + if !bool_value(document, "/presence/profile")? + && !bool_value(document, "/presence/application_handler")? + { + return relationship_error(); + } + validate_relay_targets(document, "/presence/target_relay_ids", relays) +} + +fn validate_relay_targets( + document: &Value, + pointer: &str, + relays: &[Value], +) -> Result<(), RhiConfigV1Error> { + for relay_id in string_set(document, pointer)? { + if !relays.iter().any(|relay| { + string_at(relay, "/id") == Ok(relay_id) && bool_at(relay, "/write") == Ok(true) + }) { + return relationship_error(); + } + } + Ok(()) +} + +fn validate_operations(document: &Value) -> Result<(), RhiConfigV1Error> { + if !bool_value(document, "/operations/enabled")? { + return Ok(()); + } + let address = string(document, "/operations/listen")? + .parse::<SocketAddr>() + .map_err(|_| error(RhiConfigV1ErrorKind::InvalidDocument))?; + if address.port() == 0 + || (!address.ip().is_loopback() + && string(document, "/operations/bind_policy")? != "explicit_public") + { + return relationship_error(); + } + Ok(()) +} + +fn validate_retention(document: &Value) -> Result<(), RhiConfigV1Error> { + let audit = integer(document, "/retention/audit_ms")?; + for field in [ + "duplicate_observations_ms", + "completed_jobs_ms", + "terminal_publication_attempts_ms", + "terminal_presence_attempts_ms", + "operation_dedup_ms", + ] { + if integer(document, &format!("/retention/{field}"))? > audit { + return relationship_error(); + } + } + Ok(()) +} + +fn validate_exact_policy_limits(document: &Value) -> Result<(), RhiConfigV1Error> { + if integer(document, "/resource_limits/source_results/events")? != 4_096 + || integer(document, "/resource_limits/source_results/bytes")? != 8_388_608 + { + return relationship_error(); + } + Ok(()) +} + +fn array<'a>(value: &'a Value, pointer: &str) -> Result<&'a [Value], RhiConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_array) + .map(Vec::as_slice) + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) +} + +fn string<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, RhiConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) +} + +fn string_at<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, RhiConfigV1Error> { + string(value, pointer) +} + +fn string_set<'a>(value: &'a Value, pointer: &str) -> Result<BTreeSet<&'a str>, RhiConfigV1Error> { + array(value, pointer)? + .iter() + .map(|entry| { + entry + .as_str() + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) + }) + .collect() +} + +fn bool_value(value: &Value, pointer: &str) -> Result<bool, RhiConfigV1Error> { + bool_at(value, pointer) +} + +fn bool_at(value: &Value, pointer: &str) -> Result<bool, RhiConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_bool) + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) +} + +fn integer(value: &Value, pointer: &str) -> Result<u64, RhiConfigV1Error> { + integer_at(value, pointer) +} + +fn integer_at(value: &Value, pointer: &str) -> Result<u64, RhiConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) +} + +fn document_error<T>() -> Result<T, RhiConfigV1Error> { + Err(error(RhiConfigV1ErrorKind::InvalidDocument)) +} + +fn relationship_error<T>() -> Result<T, RhiConfigV1Error> { + Err(error(RhiConfigV1ErrorKind::InvalidRelationship)) +} + +const fn error(kind: RhiConfigV1ErrorKind) -> RhiConfigV1Error { + RhiConfigV1Error::new(kind) +} + +#[derive(Serialize)] +struct EffectiveProjection { + schema: &'static str, + schema_version: u32, + fields: Vec<EffectiveField>, +} + +#[derive(Serialize)] +struct EffectiveField { + path: String, + source: RhiConfigValueSource, + #[serde(skip_serializing_if = "Option::is_none")] + default_authority: Option<RhiConfigDefaultAuthority>, + value: Value, +} + +fn build_effective( + normalized: &Value, + original: &toml::Table, +) -> Result<RhiEffectiveConfigV1, RhiConfigV1Error> { + let mut flattened = BTreeMap::new(); + flatten_value("", normalized, &mut flattened); + let original = toml::Value::Table(original.clone()); + let fields = flattened + .into_iter() + .map(|(path, value)| { + let default_authority = default_entry(&path).and_then(|entry| { + if toml_path(&original, &path).is_none() { + Some(entry.authority) + } else { + None + } + }); + EffectiveField { + source: if default_authority.is_some() { + RhiConfigValueSource::SafeDefault + } else { + RhiConfigValueSource::Toml + }, + default_authority, + value: redacted_value(&path, value), + path, + } + }) + .collect::<Vec<_>>(); + let field_count = fields.len(); + let canonical_json = serde_json::to_string(&EffectiveProjection { + schema: "radroots.rhi.effective-config", + schema_version: 1, + fields, + }) + .map_err(|_| error(RhiConfigV1ErrorKind::Encoding))?; + if canonical_json.len() > RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES { + return Err(error(RhiConfigV1ErrorKind::Encoding)); + } + Ok(RhiEffectiveConfigV1 { + canonical_json: canonical_json.into_boxed_str(), + field_count, + }) +} + +fn flatten_value(path: &str, value: &Value, fields: &mut BTreeMap<String, Value>) { + match value { + Value::Object(object) => { + for (key, child) in object { + flatten_value(&format!("{path}/{key}"), child, fields); + } + } + Value::Array(array) if array.iter().all(Value::is_object) => { + for (index, child) in array.iter().enumerate() { + flatten_value(&format!("{path}/{index}"), child, fields); + } + } + _ => { + fields.insert(path.to_owned(), value.clone()); + } + } +} + +fn redacted_value(path: &str, value: Value) -> Value { + let scalar_redaction = if path.ends_with("/envelope_path") { + Some("[redacted-path]") + } else if path.ends_with("/credential_reference") { + Some("[redacted-credential-reference]") + } else if path.ends_with("/expected_public_key") { + Some("[redacted-public-key]") + } else if path == "/operations/listen" { + Some("[redacted-address]") + } else if path == "/evidence/policy_id" { + Some("[redacted-policy-id]") + } else if path.starts_with("/relays/") && path.ends_with("/id") { + Some("[redacted-relay-id]") + } else if path.starts_with("/relays/") && path.ends_with("/url") { + Some("[redacted-url]") + } else if path.starts_with("/evidence/sources/") && path.ends_with("/source_id") { + Some("[redacted-source-id]") + } else if path.starts_with("/evidence/sources/") && path.ends_with("/relay_id") { + Some("[redacted-relay-id]") + } else { + None + }; + if let Some(redaction) = scalar_redaction { + return Value::String(redaction.to_owned()); + } + if matches!( + path, + "/publication/target_relay_ids" | "/presence/target_relay_ids" + ) { + return json!({ + "count": value.as_array().map_or(0, Vec::len), + "values": "[redacted]" + }); + } + value +} + +fn default_entry(path: &str) -> Option<&'static DefaultEntry> { + DEFAULTS.iter().find(|entry| entry.path == path) +} + +fn toml_path<'a>(root: &'a toml::Value, path: &str) -> Option<&'a toml::Value> { + path.split('/') + .filter(|part| !part.is_empty()) + .try_fold(root, |value, part| { + if let Ok(index) = part.parse::<usize>() { + value.as_array()?.get(index) + } else { + value.as_table()?.get(part) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + + fn parse(source: &str) -> Result<RhiConfigDocumentV1, RhiConfigV1Error> { + parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::Production) + } + + fn replace(source: &str, old: &str, new: &str) -> String { + assert!(source.contains(old), "missing fixture fragment: {old}"); + source.replacen(old, new, 1) + } + + #[test] + fn canonical_example_is_deterministic_bounded_and_redacted() { + let first = parse(EXAMPLE).expect("canonical example"); + let second = parse(EXAMPLE).expect("canonical example again"); + assert_eq!(first.schema(), RHI_CONFIG_SCHEMA); + assert_eq!(first.schema_version(), RHI_CONFIG_SCHEMA_VERSION); + assert_eq!(first.profile(), RhiConfigProfile::Production); + assert_eq!(first.relay_count(), 2); + assert_eq!(first.evidence_source_count(), 1); + assert_eq!(first.runtime_thread_limits().worker_threads(), 4); + assert_eq!(first.runtime_thread_limits().blocking_threads(), 8); + assert_eq!(first.effective(), second.effective()); + assert!(first.effective().field_count() > 75); + let output = first.effective().canonical_json(); + assert!(output.starts_with( + "{\"schema\":\"radroots.rhi.effective-config\",\"schema_version\":1,\"fields\":[" + )); + assert!(output.len() <= RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES); + let projection: Value = serde_json::from_str(output).expect("effective projection"); + let fields = projection["fields"].as_array().expect("effective fields"); + assert_eq!(fields.len(), first.effective().field_count()); + let paths = fields + .iter() + .map(|field| field["path"].as_str().expect("effective path")) + .collect::<Vec<_>>(); + assert!(paths.windows(2).all(|pair| pair[0] < pair[1])); + assert!(fields.iter().all(|field| { + field["source"] == "toml" && field.get("default_authority").is_none() + })); + for forbidden in [ + "/var/lib/radroots", + "2222222222222222", + "relay.example.com", + "relay-primary", + "production-primary", + "trade-primary", + "service_wrapping_key", + ] { + assert!(!output.contains(forbidden), "leaked {forbidden}"); + assert!(!format!("{first:?}").contains(forbidden)); + } + } + + #[test] + fn exact_document_bound_precedes_utf8_and_toml_parsing() { + let mut exact = EXAMPLE.as_bytes().to_vec(); + exact.extend_from_slice(b"\n#"); + exact.resize(RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, b'a'); + assert!(parse_rhi_config_v1(&exact, RhiConfigProfile::Production).is_ok()); + exact.push(0xff); + assert_eq!( + parse_rhi_config_v1(&exact, RhiConfigProfile::Production) + .unwrap_err() + .kind(), + RhiConfigV1ErrorKind::TooLarge + ); + } + + #[test] + fn invalid_utf8_duplicate_null_unknown_and_malformed_wire_fail() { + assert_eq!( + parse_rhi_config_v1(&[0xff], RhiConfigProfile::Production) + .unwrap_err() + .kind(), + RhiConfigV1ErrorKind::InvalidUtf8 + ); + for source in [ + format!("schema = \"radroots.rhi.config\"\n{EXAMPLE}"), + replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + "shutdown_grace_ms = null", + ), + replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + "shutdown_grace_ms = [null]", + ), + replace( + EXAMPLE, + "busy_timeout_ms = 5000", + "busy_timeout_ms = 5000\nbusy_timeout_ms = 5000", + ), + ] { + assert_eq!( + parse(&source).unwrap_err().kind(), + RhiConfigV1ErrorKind::MalformedToml + ); + } + let unknown = replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + "shutdown_grace_ms = 30000\nsecret = \"do-not-render\"", + ); + assert_eq!( + parse(&unknown).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidDocument + ); + } + + #[test] + fn header_failures_are_classified_before_document_admission() { + for (source, expected) in [ + ( + EXAMPLE.replace("schema = \"radroots.rhi.config\"\n", ""), + RhiConfigV1ErrorKind::MissingSchema, + ), + ( + replace(EXAMPLE, "schema = \"radroots.rhi.config\"", "schema = 1"), + RhiConfigV1ErrorKind::InvalidSchema, + ), + ( + replace(EXAMPLE, "radroots.rhi.config", "radroots.myc.config"), + RhiConfigV1ErrorKind::SchemaMismatch, + ), + ( + EXAMPLE.replace("schema_version = 1\n", ""), + RhiConfigV1ErrorKind::MissingSchemaVersion, + ), + ( + replace(EXAMPLE, "schema_version = 1", "schema_version = \"1\""), + RhiConfigV1ErrorKind::InvalidSchemaVersion, + ), + ( + replace(EXAMPLE, "schema_version = 1", "schema_version = 2"), + RhiConfigV1ErrorKind::UnsupportedSchemaVersion, + ), + ] { + assert_eq!(parse(&source).unwrap_err().kind(), expected); + } + } + + #[test] + fn production_and_repo_local_relay_postures_are_distinct() { + let local = replace(EXAMPLE, "wss://relay.example.com/", "ws://127.0.0.1:7777/"); + assert_eq!( + parse(&local).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidRelationship + ); + assert!(parse_rhi_config_v1(local.as_bytes(), RhiConfigProfile::RepoLocal).is_ok()); + let remote = replace(&local, "ws://127.0.0.1:7777/", "ws://relay.example.com/"); + assert_eq!( + parse_rhi_config_v1(remote.as_bytes(), RhiConfigProfile::RepoLocal) + .unwrap_err() + .kind(), + RhiConfigV1ErrorKind::InvalidRelationship + ); + } + + #[test] + fn identity_relay_and_evidence_relationships_fail_closed() { + let cases = [ + replace( + EXAMPLE, + "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", + "/var/lib/radroots/../escape.ncrypt", + ), + replace( + EXAMPLE, + "2222222222222222222222222222222222222222222222222222222222222222", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ), + replace( + EXAMPLE, + "id = \"relay-secondary\"", + "id = \"relay-primary\"", + ), + replace( + EXAMPLE, + "wss://relay-secondary.example.com/", + "wss://relay.example.com/", + ), + replace( + EXAMPLE, + "read = true\nwrite = true", + "read = false\nwrite = false", + ), + replace( + EXAMPLE, + "required = true\nselector", + "required = false\nselector", + ), + replace( + EXAMPLE, + "relay_id = \"relay-primary\"", + "relay_id = \"missing\"", + ), + replace(EXAMPLE, "overlap_seconds = 300", "overlap_seconds = 86401"), + replace( + EXAMPLE, + "attempt_deadline_ms = 30000", + "attempt_deadline_ms = 9999", + ), + ]; + for source in cases { + assert!(matches!( + parse(&source).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidDocument | RhiConfigV1ErrorKind::InvalidRelationship + )); + } + + let out_of_order = replace( + &replace( + EXAMPLE, + "read = false\nwrite = true", + "read = true\nwrite = true", + ), + "[reconciliation]", + "[[evidence.sources]]\nsource_id = \"a-secondary\"\nkind = \"nostr_relay\"\nrelay_id = \"relay-secondary\"\nrequired = false\nselector = \"trade_mutation_lineage_v1\"\ndeadline_ms = 10000\nlookback_seconds = 86400\noverlap_seconds = 300\n\n[reconciliation]", + ); + assert_eq!( + parse(&out_of_order).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidRelationship + ); + } + + #[test] + fn publication_presence_operations_retention_and_policy_limits_fail_closed() { + let cases = [ + replace( + EXAMPLE, + "initial_backoff_ms = 250", + "initial_backoff_ms = 30001", + ), + replace( + EXAMPLE, + "target_relay_ids = [\"relay-primary\", \"relay-secondary\"]", + "target_relay_ids = [\"missing\"]", + ), + replace( + EXAMPLE, + "profile = true\napplication_handler = true", + "profile = false\napplication_handler = false", + ), + replace(EXAMPLE, "audit_ms = 31536000000", "audit_ms = 1000"), + replace(EXAMPLE, "events = 4096", "events = 4095"), + replace(EXAMPLE, "bytes = 8388608", "bytes = 8388607"), + ]; + for source in cases { + assert!(matches!( + parse(&source).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidDocument | RhiConfigV1ErrorKind::InvalidRelationship + )); + } + + let public_loopback_policy = replace( + EXAMPLE, + "[operations]\nenabled = false", + "[operations]\nenabled = true\nlisten = \"0.0.0.0:9460\"\nbind_policy = \"loopback_only\"\n\n[operations.limits]", + ); + assert_eq!( + parse(&public_loopback_policy).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidRelationship + ); + } + + #[test] + fn conditional_sections_remain_closed_when_disabled_and_default_when_enabled() { + let mut disabled = EXAMPLE.parse::<toml::Table>().expect("example TOML"); + let publication = disabled + .get_mut("publication") + .and_then(toml::Value::as_table_mut) + .expect("publication table"); + publication.insert( + "mode".to_owned(), + toml::Value::String("disabled".to_owned()), + ); + publication.remove("target_relay_ids"); + publication.remove("retry"); + let presence = disabled + .get_mut("presence") + .and_then(toml::Value::as_table_mut) + .expect("presence table"); + presence.insert("enabled".to_owned(), toml::Value::Boolean(false)); + presence.insert("profile".to_owned(), toml::Value::Boolean(false)); + presence.insert( + "application_handler".to_owned(), + toml::Value::Boolean(false), + ); + presence.remove("target_relay_ids"); + let parsed = + parse(&toml::to_string(&disabled).expect("disabled TOML")).expect("disabled sections"); + let effective = parsed.effective().canonical_json(); + assert!(!effective.contains("/publication/retry/")); + assert!(!effective.contains("/operations/limits/")); + + let enabled_operations = replace( + EXAMPLE, + "[operations]\nenabled = false", + "[operations]\nenabled = true\nlisten = \"127.0.0.1:9460\"\nbind_policy = \"loopback_only\"\n\n[operations.limits]", + ); + let parsed = parse(&enabled_operations).expect("enabled operations defaults"); + assert!( + parsed + .effective() + .canonical_json() + .contains("/operations/limits/header_count") + ); + } + + #[test] + fn all_frozen_defaults_are_applied_with_exact_provenance() { + assert_eq!( + serde_json::to_value([ + RhiConfigValueSource::BootstrapCli, + RhiConfigValueSource::Toml, + RhiConfigValueSource::SafeDefault, + RhiConfigValueSource::DerivedPath, + ]) + .expect("serialize provenance vocabulary"), + json!(["bootstrap_cli", "toml", "safe_default", "derived_path"]) + ); + assert_eq!(DEFAULTS.len(), 51); + let schema: Value = serde_json::from_str(CONFIG_SCHEMA).expect("embedded schema"); + assert_eq!(count_schema_defaults(&schema), DEFAULTS.len()); + for entry in DEFAULTS { + let pointer = schema_default_pointer(entry.path); + let schema_default = schema + .pointer(&format!("{pointer}/default")) + .expect("schema default"); + let expected_default = match entry.value { + DefaultValue::Integer(value) => json!(value), + DefaultValue::String(value) => json!(value), + }; + assert_eq!(schema_default, &expected_default, "{}", entry.path); + let expected_authority = + serde_json::to_value(entry.authority).expect("serialize default authority"); + assert_eq!( + schema.pointer(&format!("{pointer}/x-radroots-default-source")), + Some(&expected_authority), + "{}", + entry.path + ); + } + let mut table = EXAMPLE.parse::<toml::Table>().expect("example TOML"); + for entry in DEFAULTS { + remove_toml_path(&mut table, entry.path); + } + let minimal = toml::to_string(&table).expect("minimal TOML"); + let parsed = parse(&minimal).expect("defaults admitted"); + let output = parsed.effective().canonical_json(); + for source in [ + "accepted_service_authority", + "engineering_safety", + "radroots_service_sqlite", + "radroots_service_host", + "radroots_event", + "rhi_evidence_policy", + ] { + assert!(output.contains(&format!("\"default_authority\":\"{source}\""))); + } + assert!(output.contains("\"source\":\"safe_default\"")); + let projection: Value = serde_json::from_str(output).expect("effective projection"); + assert!( + projection["fields"] + .as_array() + .expect("effective fields") + .iter() + .all(|field| match field["source"].as_str() { + Some("safe_default") => field.get("default_authority").is_some(), + Some("toml") => field.get("default_authority").is_none(), + _ => false, + }) + ); + let explicit = parse(EXAMPLE).expect("explicit example"); + assert!( + !explicit + .effective() + .canonical_json() + .contains("default_authority") + ); + assert!( + explicit + .effective() + .canonical_json() + .matches("\"source\":\"toml\"") + .count() + > output.matches("\"source\":\"toml\"").count() + ); + } + + #[test] + fn byte_limits_errors_and_debug_are_safe() { + let exact_reference = "a".repeat(128); + let exact = replace(EXAMPLE, "service_wrapping_key", &exact_reference); + assert!(parse(&exact).is_ok()); + let over_reference = "a".repeat(129); + let over = replace(EXAMPLE, "service_wrapping_key", &over_reference); + assert_eq!( + parse(&over).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidDocument + ); + + let exact_path = format!("/{}a", "é".repeat(2_047)); + assert_eq!(exact_path.len(), 4_096); + let exact = replace( + EXAMPLE, + "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", + &exact_path, + ); + assert!(parse(&exact).is_ok()); + let over_path = format!("/{}", "é".repeat(2_048)); + assert_eq!(over_path.len(), 4_097); + let over = replace( + EXAMPLE, + "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", + &over_path, + ); + assert_eq!( + parse(&over).unwrap_err().kind(), + RhiConfigV1ErrorKind::InvalidDocument + ); + + let secret = "credential-secret-value"; + let source = replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + &format!("unknown = \"{secret}\""), + ); + let failure = parse(&source).unwrap_err(); + let rendered = format!("{failure} {failure:?}"); + assert!(!rendered.contains(secret)); + assert!(Error::source(&failure).is_none()); + } + + fn remove_toml_path(table: &mut toml::Table, pointer: &str) { + let mut parts = pointer + .split('/') + .filter(|part| !part.is_empty()) + .peekable(); + let mut current = table; + while let Some(part) = parts.next() { + if parts.peek().is_none() { + current.remove(part); + return; + } + let Some(next) = current.get_mut(part).and_then(toml::Value::as_table_mut) else { + return; + }; + current = next; + } + } + + fn count_schema_defaults(value: &Value) -> usize { + match value { + Value::Object(object) => { + usize::from(object.contains_key("default")) + + object.values().map(count_schema_defaults).sum::<usize>() + } + Value::Array(array) => array.iter().map(count_schema_defaults).sum(), + _ => 0, + } + } + + fn schema_default_pointer(path: &str) -> String { + for (prefix, definition) in [ + ("/operations/limits/", "operations_limits"), + ("/publication/retry/", "retry"), + ("/resource_limits/admin/", "admin_limits"), + ("/resource_limits/events/", "event_limits"), + ("/resource_limits/source_results/", "source_result_limits"), + ("/resource_limits/queues/", "queue_limits"), + ("/resource_limits/metrics/", "metrics_limits"), + ("/resource_limits/runtime/", "runtime_limits"), + ] { + if let Some(field) = path.strip_prefix(prefix) { + return format!("/$defs/{definition}/properties/{field}"); + } + } + let mut parts = path.split('/').filter(|part| !part.is_empty()); + let definition = parts.next().expect("default definition"); + let field = parts.next().expect("default field"); + assert!(parts.next().is_none(), "unmapped default path: {path}"); + format!("/$defs/{definition}/properties/{field}") + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -3,6 +3,7 @@ pub mod adapters; pub mod cli; pub mod config; +mod config_v1; pub mod features; pub mod host_identity; pub mod host_nostr; @@ -13,6 +14,12 @@ pub mod paths; pub mod rhi; pub use cli::Args as cli_args; +pub use config_v1::{ + RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES, RHI_CONFIG_SCHEMA, + RHI_CONFIG_SCHEMA_VERSION, RhiConfigDefaultAuthority, RhiConfigDocumentV1, RhiConfigProfile, + RhiConfigV1Error, RhiConfigV1ErrorKind, RhiConfigValueSource, RhiEffectiveConfigV1, + RhiRuntimeThreadLimitsV1, parse_rhi_config_v1, +}; use anyhow::{Context, Result, anyhow, bail}; use radroots_event::{ diff --git a/tests/services_hardening_config_contract.rs b/tests/services_hardening_config_contract.rs @@ -236,6 +236,17 @@ fn semantic_valid(value: &Value, profile: Profile) -> bool { return false; } } + if value["resource_limits"]["source_results"]["events"] + .as_u64() + .unwrap_or(4_096) + != 4_096 + || value["resource_limits"]["source_results"]["bytes"] + .as_u64() + .unwrap_or(8_388_608) + != 8_388_608 + { + return false; + } true } @@ -702,12 +713,6 @@ fn exact_resource_boundaries_and_safe_defaults_are_frozen() { ("/resource_limits/admin/header_count", 64, 65), ("/resource_limits/admin/query_items", 200, 201), ("/resource_limits/events/wire_bytes", 524_288, 524_289), - ("/resource_limits/source_results/events", 4_096, 4_097), - ( - "/resource_limits/source_results/bytes", - 8_388_608, - 8_388_609, - ), ("/resource_limits/metrics/samples", 512, 513), ("/resource_limits/runtime/worker_threads", 32, 33), ] { @@ -733,6 +738,29 @@ fn exact_resource_boundaries_and_safe_defaults_are_frozen() { *excessive.pointer_mut(pointer).expect("bounded field") = json!(over); assert_rejected(&excessive, Profile::Production); } + for (pointer, exact, lower, over) in [ + ( + "/resource_limits/source_results/events", + 4_096, + 4_095, + 4_097, + ), + ( + "/resource_limits/source_results/bytes", + 8_388_608, + 8_388_607, + 8_388_609, + ), + ] { + let mut exact_candidate = value.clone(); + *exact_candidate.pointer_mut(pointer).expect("bounded field") = json!(exact); + assert!(semantic_valid(&exact_candidate, Profile::Production)); + for invalid in [0, lower, over] { + let mut candidate = value.clone(); + *candidate.pointer_mut(pointer).expect("bounded field") = json!(invalid); + assert_rejected(&candidate, Profile::Production); + } + } let mut exact_admin_response = value.clone(); exact_admin_response["resource_limits"]["admin"]["response_body_utf8_bytes"] = json!(512); assert!(semantic_valid(&exact_admin_response, Profile::Production)); diff --git a/tests/services_hardening_config_parser.rs b/tests/services_hardening_config_parser.rs @@ -0,0 +1,50 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use rhi::{ + RHI_CONFIG_SCHEMA, RHI_CONFIG_SCHEMA_VERSION, RhiConfigProfile, RhiConfigV1ErrorKind, + parse_rhi_config_v1, +}; + +const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + +#[test] +fn root_api_admits_the_canonical_document_and_exposes_only_redacted_effective_output() { + let document = parse_rhi_config_v1(EXAMPLE.as_bytes(), RhiConfigProfile::Production) + .expect("canonical configuration"); + assert_eq!(document.schema(), RHI_CONFIG_SCHEMA); + assert_eq!(document.schema_version(), RHI_CONFIG_SCHEMA_VERSION); + assert_eq!(document.relay_count(), 2); + assert_eq!(document.evidence_source_count(), 1); + + let effective = document.effective().canonical_json(); + assert!(effective.contains("\"source\":\"toml\"")); + assert!(effective.contains("[redacted-public-key]")); + for forbidden in [ + "/var/lib/radroots", + "relay.example.com", + "relay-primary", + "trade-primary", + "2222222222222222", + "service_wrapping_key", + ] { + assert!(!effective.contains(forbidden)); + assert!(!format!("{document:?}").contains(forbidden)); + } +} + +#[test] +fn root_api_failure_is_stable_and_source_free() { + let secret = "never-render-this-value"; + let invalid = EXAMPLE.replacen( + "shutdown_grace_ms = 30000", + &format!("unknown = \"{secret}\""), + 1, + ); + let failure = parse_rhi_config_v1(invalid.as_bytes(), RhiConfigProfile::Production) + .expect_err("unknown field must fail"); + assert_eq!(failure.kind(), RhiConfigV1ErrorKind::InvalidDocument); + assert!(Error::source(&failure).is_none()); + assert!(!format!("{failure} {failure:?}").contains(secret)); +}