config_v1.rs (55596B)
1 //! Strict, bounded RHI configuration document v1 admission. 2 3 use std::collections::{BTreeMap, BTreeSet}; 4 use std::error::Error; 5 use std::fmt; 6 use std::net::SocketAddr; 7 8 use nostr::PublicKey; 9 use serde::Serialize; 10 use serde_json::{Map, Value, json}; 11 use url::Url; 12 13 const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); 14 15 /// Exact schema identity for the production RHI configuration document. 16 pub const RHI_CONFIG_SCHEMA: &str = "radroots.rhi.config"; 17 18 /// Exact supported RHI configuration schema version. 19 pub const RHI_CONFIG_SCHEMA_VERSION: u32 = 1; 20 21 /// Hard cap applied to original bytes before UTF-8 or TOML parsing. 22 pub const RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize = 1_048_576; 23 24 /// Hard cap applied to the deterministic redacted effective projection. 25 pub const RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES: usize = 786_432; 26 27 /// Bootstrap-selected network posture used during relay admission. 28 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 29 pub enum RhiConfigProfile { 30 /// Production and ordinary service-host configurations require WSS relays. 31 Production, 32 /// Explicit repository-local development may also use loopback WS relays. 33 RepoLocal, 34 } 35 36 /// Stable origin classification for one effective configuration value. 37 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 38 #[serde(rename_all = "snake_case")] 39 pub enum RhiConfigValueSource { 40 BootstrapCli, 41 Toml, 42 SafeDefault, 43 DerivedPath, 44 } 45 46 /// Exact governed authority behind a safely defaulted configuration leaf. 47 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 48 #[serde(rename_all = "snake_case")] 49 pub enum RhiConfigDefaultAuthority { 50 RadrootsServiceHost, 51 RadrootsServiceSqlite, 52 RadrootsEvent, 53 RhiEvidencePolicy, 54 AcceptedServiceAuthority, 55 EngineeringSafety, 56 } 57 58 /// Stable source-free classification for configuration admission failures. 59 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 60 pub enum RhiConfigV1ErrorKind { 61 TooLarge, 62 InvalidUtf8, 63 MalformedToml, 64 MissingSchema, 65 InvalidSchema, 66 SchemaMismatch, 67 MissingSchemaVersion, 68 InvalidSchemaVersion, 69 UnsupportedSchemaVersion, 70 InvalidDocument, 71 InvalidRelationship, 72 Encoding, 73 } 74 75 impl RhiConfigV1ErrorKind { 76 const fn message(self) -> &'static str { 77 match self { 78 Self::TooLarge => "configuration document exceeds its size limit", 79 Self::InvalidUtf8 => "configuration document is not valid UTF-8", 80 Self::MalformedToml => "configuration document is not valid TOML", 81 Self::MissingSchema => "configuration document schema is missing", 82 Self::InvalidSchema => "configuration document schema is invalid", 83 Self::SchemaMismatch => "configuration document schema is unsupported", 84 Self::MissingSchemaVersion => "configuration document schema version is missing", 85 Self::InvalidSchemaVersion => "configuration document schema version is invalid", 86 Self::UnsupportedSchemaVersion => { 87 "configuration document schema version is unsupported" 88 } 89 Self::InvalidDocument => "configuration document fields are invalid", 90 Self::InvalidRelationship => "configuration document relationships are invalid", 91 Self::Encoding => "effective configuration could not be encoded", 92 } 93 } 94 } 95 96 /// One source-free configuration admission failure. 97 #[derive(Clone, Copy, PartialEq, Eq)] 98 pub struct RhiConfigV1Error { 99 kind: RhiConfigV1ErrorKind, 100 } 101 102 impl RhiConfigV1Error { 103 const fn new(kind: RhiConfigV1ErrorKind) -> Self { 104 Self { kind } 105 } 106 107 /// Returns the stable failure classification. 108 #[must_use] 109 pub const fn kind(self) -> RhiConfigV1ErrorKind { 110 self.kind 111 } 112 } 113 114 impl fmt::Debug for RhiConfigV1Error { 115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 116 formatter 117 .debug_struct("RhiConfigV1Error") 118 .field("kind", &self.kind) 119 .finish() 120 } 121 } 122 123 impl fmt::Display for RhiConfigV1Error { 124 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 125 formatter.write_str(self.kind.message()) 126 } 127 } 128 129 impl Error for RhiConfigV1Error {} 130 131 /// Deterministic redacted effective configuration with exact leaf provenance. 132 #[derive(Clone, PartialEq, Eq)] 133 pub struct RhiEffectiveConfigV1 { 134 canonical_json: Box<str>, 135 field_count: usize, 136 } 137 138 impl RhiEffectiveConfigV1 { 139 /// Returns compact JSON in deterministic path order. 140 #[must_use] 141 pub fn canonical_json(&self) -> &str { 142 &self.canonical_json 143 } 144 145 /// Returns the number of projected effective leaf values. 146 #[must_use] 147 pub const fn field_count(&self) -> usize { 148 self.field_count 149 } 150 } 151 152 impl fmt::Debug for RhiEffectiveConfigV1 { 153 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 154 formatter 155 .debug_struct("RhiEffectiveConfigV1") 156 .field("canonical_json", &"[redacted]") 157 .field("field_count", &self.field_count) 158 .finish() 159 } 160 } 161 162 /// Validated thread counts for the sole binary-owned Tokio runtime. 163 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 164 pub struct RhiRuntimeThreadLimitsV1 { 165 worker_threads: usize, 166 blocking_threads: usize, 167 } 168 169 impl RhiRuntimeThreadLimitsV1 { 170 /// Returns the configured asynchronous worker count. 171 #[must_use] 172 pub const fn worker_threads(self) -> usize { 173 self.worker_threads 174 } 175 176 /// Returns the configured blocking worker ceiling. 177 #[must_use] 178 pub const fn blocking_threads(self) -> usize { 179 self.blocking_threads 180 } 181 } 182 183 /// A validated immutable RHI configuration document v1. 184 pub struct RhiConfigDocumentV1 { 185 profile: RhiConfigProfile, 186 normalized: Value, 187 effective: RhiEffectiveConfigV1, 188 runtime_thread_limits: RhiRuntimeThreadLimitsV1, 189 } 190 191 impl RhiConfigDocumentV1 { 192 /// Returns the exact admitted schema identity. 193 #[must_use] 194 pub const fn schema(&self) -> &'static str { 195 RHI_CONFIG_SCHEMA 196 } 197 198 /// Returns the exact admitted schema version. 199 #[must_use] 200 pub const fn schema_version(&self) -> u32 { 201 RHI_CONFIG_SCHEMA_VERSION 202 } 203 204 /// Returns the bootstrap-selected network posture used during admission. 205 #[must_use] 206 pub const fn profile(&self) -> RhiConfigProfile { 207 self.profile 208 } 209 210 /// Returns the deterministic redacted effective configuration projection. 211 #[must_use] 212 pub const fn effective(&self) -> &RhiEffectiveConfigV1 { 213 &self.effective 214 } 215 216 pub(crate) const fn normalized(&self) -> &Value { 217 &self.normalized 218 } 219 220 /// Returns the exact number of configured relay bindings. 221 #[must_use] 222 pub fn relay_count(&self) -> usize { 223 self.normalized 224 .pointer("/relays") 225 .and_then(Value::as_array) 226 .map_or(0, Vec::len) 227 } 228 229 /// Returns the exact number of configured evidence sources. 230 #[must_use] 231 pub fn evidence_source_count(&self) -> usize { 232 self.normalized 233 .pointer("/evidence/sources") 234 .and_then(Value::as_array) 235 .map_or(0, Vec::len) 236 } 237 238 /// Returns the validated limits for the sole binary-owned Tokio runtime. 239 #[must_use] 240 pub const fn runtime_thread_limits(&self) -> RhiRuntimeThreadLimitsV1 { 241 self.runtime_thread_limits 242 } 243 } 244 245 impl fmt::Debug for RhiConfigDocumentV1 { 246 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 247 formatter 248 .debug_struct("RhiConfigDocumentV1") 249 .field("schema", &RHI_CONFIG_SCHEMA) 250 .field("schema_version", &RHI_CONFIG_SCHEMA_VERSION) 251 .field("profile", &self.profile) 252 .field("effective", &self.effective) 253 .field("runtime_thread_limits", &self.runtime_thread_limits) 254 .finish() 255 } 256 } 257 258 /// Parses and semantically validates one complete RHI configuration document. 259 pub fn parse_rhi_config_v1( 260 bytes: &[u8], 261 profile: RhiConfigProfile, 262 ) -> Result<RhiConfigDocumentV1, RhiConfigV1Error> { 263 if bytes.len() > RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES { 264 return Err(error(RhiConfigV1ErrorKind::TooLarge)); 265 } 266 let source = 267 std::str::from_utf8(bytes).map_err(|_| error(RhiConfigV1ErrorKind::InvalidUtf8))?; 268 let original = source 269 .parse::<toml::Table>() 270 .map_err(|_| error(RhiConfigV1ErrorKind::MalformedToml))?; 271 validate_header(&original)?; 272 273 let mut normalized = serde_json::to_value(toml::Value::Table(original.clone())) 274 .map_err(|_| error(RhiConfigV1ErrorKind::InvalidDocument))?; 275 let schema: Value = 276 serde_json::from_str(CONFIG_SCHEMA).map_err(|_| error(RhiConfigV1ErrorKind::Encoding))?; 277 let validator = 278 jsonschema::validator_for(&schema).map_err(|_| error(RhiConfigV1ErrorKind::Encoding))?; 279 if !validator.is_valid(&normalized) { 280 return Err(error(RhiConfigV1ErrorKind::InvalidDocument)); 281 } 282 apply_defaults(&mut normalized)?; 283 if !validator.is_valid(&normalized) { 284 return Err(error(RhiConfigV1ErrorKind::InvalidDocument)); 285 } 286 validate_relationships(&normalized, profile)?; 287 let effective = build_effective(&normalized, &original)?; 288 let runtime_thread_limits = RhiRuntimeThreadLimitsV1 { 289 worker_threads: usize::try_from(integer( 290 &normalized, 291 "/resource_limits/runtime/worker_threads", 292 )?) 293 .map_err(|_| error(RhiConfigV1ErrorKind::InvalidRelationship))?, 294 blocking_threads: usize::try_from(integer( 295 &normalized, 296 "/resource_limits/runtime/blocking_threads", 297 )?) 298 .map_err(|_| error(RhiConfigV1ErrorKind::InvalidRelationship))?, 299 }; 300 Ok(RhiConfigDocumentV1 { 301 profile, 302 normalized, 303 effective, 304 runtime_thread_limits, 305 }) 306 } 307 308 fn validate_header(header: &toml::Table) -> Result<(), RhiConfigV1Error> { 309 let schema = header 310 .get("schema") 311 .ok_or_else(|| error(RhiConfigV1ErrorKind::MissingSchema))? 312 .as_str() 313 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidSchema))?; 314 if !valid_schema_id(schema) { 315 return Err(error(RhiConfigV1ErrorKind::InvalidSchema)); 316 } 317 if schema != RHI_CONFIG_SCHEMA { 318 return Err(error(RhiConfigV1ErrorKind::SchemaMismatch)); 319 } 320 let version = header 321 .get("schema_version") 322 .ok_or_else(|| error(RhiConfigV1ErrorKind::MissingSchemaVersion))? 323 .as_integer() 324 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidSchemaVersion))?; 325 let version = 326 u32::try_from(version).map_err(|_| error(RhiConfigV1ErrorKind::InvalidSchemaVersion))?; 327 if version == 0 { 328 return Err(error(RhiConfigV1ErrorKind::InvalidSchemaVersion)); 329 } 330 if version != RHI_CONFIG_SCHEMA_VERSION { 331 return Err(error(RhiConfigV1ErrorKind::UnsupportedSchemaVersion)); 332 } 333 Ok(()) 334 } 335 336 fn valid_schema_id(value: &str) -> bool { 337 let mut bytes = value.bytes(); 338 !value.is_empty() 339 && value.len() <= 128 340 && bytes 341 .next() 342 .is_some_and(|byte| byte.is_ascii_alphanumeric()) 343 && bytes 344 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) 345 } 346 347 #[derive(Clone, Copy)] 348 struct DefaultEntry { 349 path: &'static str, 350 value: DefaultValue, 351 authority: RhiConfigDefaultAuthority, 352 enabled_pointer: Option<&'static str>, 353 } 354 355 #[derive(Clone, Copy)] 356 enum DefaultValue { 357 Integer(u64), 358 String(&'static str), 359 } 360 361 const DEFAULTS: &[DefaultEntry] = &[ 362 default( 363 "/service/shutdown_grace_ms", 364 30_000, 365 RhiConfigDefaultAuthority::AcceptedServiceAuthority, 366 ), 367 default_string( 368 "/logging/level", 369 "info", 370 RhiConfigDefaultAuthority::EngineeringSafety, 371 ), 372 default_string( 373 "/logging/format", 374 "json", 375 RhiConfigDefaultAuthority::EngineeringSafety, 376 ), 377 conditional_default( 378 "/operations/limits/header_count", 379 32, 380 RhiConfigDefaultAuthority::RadrootsServiceHost, 381 "/operations/enabled", 382 ), 383 conditional_default( 384 "/operations/limits/header_bytes", 385 16_384, 386 RhiConfigDefaultAuthority::RadrootsServiceHost, 387 "/operations/enabled", 388 ), 389 conditional_default( 390 "/operations/limits/response_body_utf8_bytes", 391 1_048_576, 392 RhiConfigDefaultAuthority::RadrootsServiceHost, 393 "/operations/enabled", 394 ), 395 conditional_default( 396 "/operations/limits/concurrent_connections", 397 32, 398 RhiConfigDefaultAuthority::RadrootsServiceHost, 399 "/operations/enabled", 400 ), 401 conditional_default( 402 "/operations/limits/request_deadline_ms", 403 15_000, 404 RhiConfigDefaultAuthority::RadrootsServiceHost, 405 "/operations/enabled", 406 ), 407 conditional_default( 408 "/operations/limits/idle_timeout_ms", 409 30_000, 410 RhiConfigDefaultAuthority::RadrootsServiceHost, 411 "/operations/enabled", 412 ), 413 default( 414 "/database/busy_timeout_ms", 415 5_000, 416 RhiConfigDefaultAuthority::RadrootsServiceSqlite, 417 ), 418 default( 419 "/database/max_connections", 420 8, 421 RhiConfigDefaultAuthority::RadrootsServiceSqlite, 422 ), 423 default( 424 "/network/connect_deadline_ms", 425 10_000, 426 RhiConfigDefaultAuthority::EngineeringSafety, 427 ), 428 default( 429 "/network/dns_answer_limit", 430 16, 431 RhiConfigDefaultAuthority::EngineeringSafety, 432 ), 433 default( 434 "/reconciliation/concurrency", 435 8, 436 RhiConfigDefaultAuthority::EngineeringSafety, 437 ), 438 default( 439 "/reconciliation/queue_capacity", 440 4_096, 441 RhiConfigDefaultAuthority::EngineeringSafety, 442 ), 443 default( 444 "/reconciliation/lease_ms", 445 30_000, 446 RhiConfigDefaultAuthority::EngineeringSafety, 447 ), 448 default( 449 "/reconciliation/lease_renewal_ms", 450 10_000, 451 RhiConfigDefaultAuthority::EngineeringSafety, 452 ), 453 default( 454 "/reconciliation/max_attempts", 455 10, 456 RhiConfigDefaultAuthority::EngineeringSafety, 457 ), 458 default( 459 "/reconciliation/initial_backoff_ms", 460 250, 461 RhiConfigDefaultAuthority::EngineeringSafety, 462 ), 463 default( 464 "/reconciliation/maximum_backoff_ms", 465 30_000, 466 RhiConfigDefaultAuthority::EngineeringSafety, 467 ), 468 default( 469 "/reconciliation/attempt_deadline_ms", 470 30_000, 471 RhiConfigDefaultAuthority::EngineeringSafety, 472 ), 473 conditional_default( 474 "/publication/retry/max_attempts", 475 10, 476 RhiConfigDefaultAuthority::EngineeringSafety, 477 "/publication/mode", 478 ), 479 conditional_default( 480 "/publication/retry/initial_backoff_ms", 481 250, 482 RhiConfigDefaultAuthority::EngineeringSafety, 483 "/publication/mode", 484 ), 485 conditional_default( 486 "/publication/retry/maximum_backoff_ms", 487 30_000, 488 RhiConfigDefaultAuthority::EngineeringSafety, 489 "/publication/mode", 490 ), 491 conditional_default( 492 "/publication/retry/attempt_deadline_ms", 493 15_000, 494 RhiConfigDefaultAuthority::EngineeringSafety, 495 "/publication/mode", 496 ), 497 default( 498 "/resource_limits/admin/header_count", 499 32, 500 RhiConfigDefaultAuthority::RadrootsServiceHost, 501 ), 502 default( 503 "/resource_limits/admin/header_bytes", 504 16_384, 505 RhiConfigDefaultAuthority::RadrootsServiceHost, 506 ), 507 default( 508 "/resource_limits/admin/request_body_utf8_bytes", 509 65_536, 510 RhiConfigDefaultAuthority::RadrootsServiceHost, 511 ), 512 default( 513 "/resource_limits/admin/response_body_utf8_bytes", 514 1_048_576, 515 RhiConfigDefaultAuthority::RadrootsServiceHost, 516 ), 517 default( 518 "/resource_limits/admin/concurrent_connections", 519 32, 520 RhiConfigDefaultAuthority::RadrootsServiceHost, 521 ), 522 default( 523 "/resource_limits/admin/request_deadline_ms", 524 15_000, 525 RhiConfigDefaultAuthority::RadrootsServiceHost, 526 ), 527 default( 528 "/resource_limits/admin/idle_timeout_ms", 529 30_000, 530 RhiConfigDefaultAuthority::RadrootsServiceHost, 531 ), 532 default( 533 "/resource_limits/admin/query_items", 534 100, 535 RhiConfigDefaultAuthority::AcceptedServiceAuthority, 536 ), 537 default( 538 "/resource_limits/events/wire_bytes", 539 262_144, 540 RhiConfigDefaultAuthority::RadrootsEvent, 541 ), 542 default( 543 "/resource_limits/events/content_bytes", 544 131_072, 545 RhiConfigDefaultAuthority::RadrootsEvent, 546 ), 547 default( 548 "/resource_limits/events/tag_count", 549 1_024, 550 RhiConfigDefaultAuthority::RadrootsEvent, 551 ), 552 default( 553 "/resource_limits/events/tag_total_elements", 554 4_096, 555 RhiConfigDefaultAuthority::RadrootsEvent, 556 ), 557 default( 558 "/resource_limits/events/tag_element_bytes", 559 4_096, 560 RhiConfigDefaultAuthority::RadrootsEvent, 561 ), 562 default( 563 "/resource_limits/events/tag_total_bytes", 564 131_072, 565 RhiConfigDefaultAuthority::RadrootsEvent, 566 ), 567 default( 568 "/resource_limits/source_results/events", 569 4_096, 570 RhiConfigDefaultAuthority::RhiEvidencePolicy, 571 ), 572 default( 573 "/resource_limits/source_results/bytes", 574 8_388_608, 575 RhiConfigDefaultAuthority::RhiEvidencePolicy, 576 ), 577 default( 578 "/resource_limits/queues/ingress", 579 1_024, 580 RhiConfigDefaultAuthority::EngineeringSafety, 581 ), 582 default( 583 "/resource_limits/queues/reconciliation", 584 4_096, 585 RhiConfigDefaultAuthority::EngineeringSafety, 586 ), 587 default( 588 "/resource_limits/queues/publication", 589 4_096, 590 RhiConfigDefaultAuthority::EngineeringSafety, 591 ), 592 default( 593 "/resource_limits/queues/presence", 594 64, 595 RhiConfigDefaultAuthority::EngineeringSafety, 596 ), 597 default( 598 "/resource_limits/metrics/descriptors", 599 64, 600 RhiConfigDefaultAuthority::RadrootsServiceHost, 601 ), 602 default( 603 "/resource_limits/metrics/samples", 604 512, 605 RhiConfigDefaultAuthority::RadrootsServiceHost, 606 ), 607 default( 608 "/resource_limits/metrics/labels_per_sample", 609 8, 610 RhiConfigDefaultAuthority::RadrootsServiceHost, 611 ), 612 default( 613 "/resource_limits/metrics/render_utf8_bytes", 614 1_048_576, 615 RhiConfigDefaultAuthority::RadrootsServiceHost, 616 ), 617 default( 618 "/resource_limits/runtime/worker_threads", 619 4, 620 RhiConfigDefaultAuthority::EngineeringSafety, 621 ), 622 default( 623 "/resource_limits/runtime/blocking_threads", 624 8, 625 RhiConfigDefaultAuthority::EngineeringSafety, 626 ), 627 ]; 628 629 const fn default( 630 path: &'static str, 631 value: u64, 632 authority: RhiConfigDefaultAuthority, 633 ) -> DefaultEntry { 634 DefaultEntry { 635 path, 636 value: DefaultValue::Integer(value), 637 authority, 638 enabled_pointer: None, 639 } 640 } 641 642 const fn conditional_default( 643 path: &'static str, 644 value: u64, 645 authority: RhiConfigDefaultAuthority, 646 enabled_pointer: &'static str, 647 ) -> DefaultEntry { 648 DefaultEntry { 649 path, 650 value: DefaultValue::Integer(value), 651 authority, 652 enabled_pointer: Some(enabled_pointer), 653 } 654 } 655 656 const fn default_string( 657 path: &'static str, 658 value: &'static str, 659 authority: RhiConfigDefaultAuthority, 660 ) -> DefaultEntry { 661 DefaultEntry { 662 path, 663 value: DefaultValue::String(value), 664 authority, 665 enabled_pointer: None, 666 } 667 } 668 669 fn default_is_enabled(document: &Value, entry: &DefaultEntry) -> bool { 670 match entry.enabled_pointer { 671 Some("/operations/enabled") => { 672 document.pointer("/operations/enabled") == Some(&Value::Bool(true)) 673 } 674 Some("/publication/mode") => { 675 document 676 .pointer("/publication/mode") 677 .and_then(Value::as_str) 678 == Some("required") 679 } 680 Some(_) => false, 681 None => true, 682 } 683 } 684 685 fn apply_defaults(document: &mut Value) -> Result<(), RhiConfigV1Error> { 686 for entry in DEFAULTS { 687 if !default_is_enabled(document, entry) || document.pointer(entry.path).is_some() { 688 continue; 689 } 690 insert_json_pointer(document, entry.path, entry.value)?; 691 } 692 Ok(()) 693 } 694 695 fn insert_json_pointer( 696 root: &mut Value, 697 pointer: &str, 698 value: DefaultValue, 699 ) -> Result<(), RhiConfigV1Error> { 700 let mut parts = pointer 701 .split('/') 702 .filter(|part| !part.is_empty()) 703 .peekable(); 704 let mut current = root; 705 while let Some(part) = parts.next() { 706 let object = current 707 .as_object_mut() 708 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument))?; 709 if parts.peek().is_none() { 710 object.insert( 711 part.to_owned(), 712 match value { 713 DefaultValue::Integer(value) => Value::Number(value.into()), 714 DefaultValue::String(value) => Value::String(value.to_owned()), 715 }, 716 ); 717 return Ok(()); 718 } 719 current = object 720 .entry(part.to_owned()) 721 .or_insert_with(|| Value::Object(Map::new())); 722 } 723 document_error() 724 } 725 726 fn validate_relationships( 727 document: &Value, 728 profile: RhiConfigProfile, 729 ) -> Result<(), RhiConfigV1Error> { 730 validate_utf8_byte_limits(document)?; 731 validate_identity(document)?; 732 let relays = validate_relays(document, profile)?; 733 validate_evidence(document, relays)?; 734 validate_reconciliation(document)?; 735 validate_publication(document, relays)?; 736 validate_presence(document, relays)?; 737 validate_operations(document)?; 738 validate_retention(document)?; 739 validate_exact_policy_limits(document) 740 } 741 742 fn validate_utf8_byte_limits(document: &Value) -> Result<(), RhiConfigV1Error> { 743 for (pointer, minimum, maximum) in [ 744 ("/identity/service/envelope_path", 1, 4_096), 745 ("/identity/service/credential_reference", 1, 128), 746 ("/identity/service/expected_public_key", 64, 64), 747 ("/evidence/policy_id", 1, 64), 748 ] { 749 validate_string_bytes(string(document, pointer)?, minimum, maximum)?; 750 } 751 for relay in array(document, "/relays")? { 752 validate_string_bytes(string_at(relay, "/id")?, 1, 64)?; 753 validate_string_bytes(string_at(relay, "/url")?, 1, 2_048)?; 754 } 755 for source in array(document, "/evidence/sources")? { 756 validate_string_bytes(string_at(source, "/source_id")?, 1, 64)?; 757 validate_string_bytes(string_at(source, "/relay_id")?, 1, 64)?; 758 } 759 if bool_value(document, "/operations/enabled")? { 760 validate_string_bytes(string(document, "/operations/listen")?, 1, 256)?; 761 } 762 Ok(()) 763 } 764 765 fn validate_string_bytes( 766 value: &str, 767 minimum: usize, 768 maximum: usize, 769 ) -> Result<(), RhiConfigV1Error> { 770 if (minimum..=maximum).contains(&value.len()) { 771 Ok(()) 772 } else { 773 document_error() 774 } 775 } 776 777 fn validate_identity(document: &Value) -> Result<(), RhiConfigV1Error> { 778 let raw_path = string(document, "/identity/service/envelope_path")?; 779 if raw_path.as_bytes().contains(&0) 780 || raw_path == "/" 781 || !raw_path.starts_with('/') 782 || raw_path.ends_with('/') 783 || raw_path 784 .split('/') 785 .skip(1) 786 .any(|component| component.is_empty() || matches!(component, "." | "..")) 787 || !valid_nostr_public_key(string(document, "/identity/service/expected_public_key")?) 788 { 789 return relationship_error(); 790 } 791 Ok(()) 792 } 793 794 fn valid_nostr_public_key(value: &str) -> bool { 795 PublicKey::from_hex(value).is_ok_and(|public_key| public_key.xonly().is_ok()) 796 } 797 798 fn validate_relays( 799 document: &Value, 800 profile: RhiConfigProfile, 801 ) -> Result<&[Value], RhiConfigV1Error> { 802 let relays = array(document, "/relays")?; 803 let mut ids = BTreeSet::new(); 804 let mut urls = BTreeSet::new(); 805 for relay in relays { 806 let id = string_at(relay, "/id")?; 807 let raw_url = string_at(relay, "/url")?; 808 canonical_relay_url(raw_url, profile)?; 809 let read = bool_at(relay, "/read")?; 810 let write = bool_at(relay, "/write")?; 811 if !ids.insert(id) || !urls.insert(raw_url) || (!read && !write) { 812 return relationship_error(); 813 } 814 } 815 Ok(relays) 816 } 817 818 fn canonical_relay_url(value: &str, profile: RhiConfigProfile) -> Result<Url, RhiConfigV1Error> { 819 let parsed = Url::parse(value).map_err(|_| error(RhiConfigV1ErrorKind::InvalidDocument))?; 820 if parsed.as_str() != value 821 || !parsed.username().is_empty() 822 || parsed.password().is_some() 823 || parsed.fragment().is_some() 824 { 825 return document_error(); 826 } 827 let allowed = match profile { 828 RhiConfigProfile::Production => parsed.scheme() == "wss", 829 RhiConfigProfile::RepoLocal => match parsed.scheme() { 830 "wss" => true, 831 "ws" => parsed 832 .host_str() 833 .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")), 834 _ => false, 835 }, 836 }; 837 if !allowed { 838 return relationship_error(); 839 } 840 Ok(parsed) 841 } 842 843 fn validate_evidence(document: &Value, relays: &[Value]) -> Result<(), RhiConfigV1Error> { 844 let sources = array(document, "/evidence/sources")?; 845 let mut source_ids = BTreeSet::new(); 846 let mut bindings = BTreeSet::new(); 847 let mut required = false; 848 let mut prior_source_id = None; 849 for source in sources { 850 let source_id = string_at(source, "/source_id")?; 851 let kind = string_at(source, "/kind")?; 852 let relay_id = string_at(source, "/relay_id")?; 853 let selector = string_at(source, "/selector")?; 854 if prior_source_id.is_some_and(|prior| prior >= source_id) 855 || !source_ids.insert(source_id) 856 || !bindings.insert((kind, relay_id, selector)) 857 || !relays.iter().any(|relay| { 858 string_at(relay, "/id") == Ok(relay_id) && bool_at(relay, "/read") == Ok(true) 859 }) 860 || integer_at(source, "/overlap_seconds")? > integer_at(source, "/lookback_seconds")? 861 { 862 return relationship_error(); 863 } 864 prior_source_id = Some(source_id); 865 required |= bool_at(source, "/required")?; 866 } 867 if !required { 868 return relationship_error(); 869 } 870 Ok(()) 871 } 872 873 fn validate_reconciliation(document: &Value) -> Result<(), RhiConfigV1Error> { 874 if integer(document, "/reconciliation/lease_renewal_ms")? 875 >= integer(document, "/reconciliation/lease_ms")? 876 || integer(document, "/reconciliation/initial_backoff_ms")? 877 > integer(document, "/reconciliation/maximum_backoff_ms")? 878 { 879 return relationship_error(); 880 } 881 let attempt_deadline = integer(document, "/reconciliation/attempt_deadline_ms")?; 882 if array(document, "/evidence/sources")?.iter().any(|source| { 883 integer_at(source, "/deadline_ms").map_or(true, |value| value > attempt_deadline) 884 }) { 885 return relationship_error(); 886 } 887 Ok(()) 888 } 889 890 fn validate_publication(document: &Value, relays: &[Value]) -> Result<(), RhiConfigV1Error> { 891 if string(document, "/publication/mode")? == "disabled" { 892 return Ok(()); 893 } 894 validate_relay_targets(document, "/publication/target_relay_ids", relays)?; 895 if integer(document, "/publication/retry/initial_backoff_ms")? 896 > integer(document, "/publication/retry/maximum_backoff_ms")? 897 { 898 return relationship_error(); 899 } 900 Ok(()) 901 } 902 903 fn validate_presence(document: &Value, relays: &[Value]) -> Result<(), RhiConfigV1Error> { 904 if !bool_value(document, "/presence/enabled")? { 905 if bool_value(document, "/presence/profile")? 906 || bool_value(document, "/presence/application_handler")? 907 { 908 return relationship_error(); 909 } 910 return Ok(()); 911 } 912 if !bool_value(document, "/presence/profile")? 913 && !bool_value(document, "/presence/application_handler")? 914 { 915 return relationship_error(); 916 } 917 validate_relay_targets(document, "/presence/target_relay_ids", relays) 918 } 919 920 fn validate_relay_targets( 921 document: &Value, 922 pointer: &str, 923 relays: &[Value], 924 ) -> Result<(), RhiConfigV1Error> { 925 for relay_id in string_set(document, pointer)? { 926 if !relays.iter().any(|relay| { 927 string_at(relay, "/id") == Ok(relay_id) && bool_at(relay, "/write") == Ok(true) 928 }) { 929 return relationship_error(); 930 } 931 } 932 Ok(()) 933 } 934 935 fn validate_operations(document: &Value) -> Result<(), RhiConfigV1Error> { 936 if !bool_value(document, "/operations/enabled")? { 937 return Ok(()); 938 } 939 let address = string(document, "/operations/listen")? 940 .parse::<SocketAddr>() 941 .map_err(|_| error(RhiConfigV1ErrorKind::InvalidDocument))?; 942 if address.port() == 0 943 || (!address.ip().is_loopback() 944 && string(document, "/operations/bind_policy")? != "explicit_public") 945 { 946 return relationship_error(); 947 } 948 Ok(()) 949 } 950 951 fn validate_retention(document: &Value) -> Result<(), RhiConfigV1Error> { 952 let audit = integer(document, "/retention/audit_ms")?; 953 for field in [ 954 "duplicate_observations_ms", 955 "completed_jobs_ms", 956 "terminal_publication_attempts_ms", 957 "terminal_presence_attempts_ms", 958 "operation_dedup_ms", 959 ] { 960 if integer(document, &format!("/retention/{field}"))? > audit { 961 return relationship_error(); 962 } 963 } 964 Ok(()) 965 } 966 967 fn validate_exact_policy_limits(document: &Value) -> Result<(), RhiConfigV1Error> { 968 if integer(document, "/resource_limits/source_results/events")? != 4_096 969 || integer(document, "/resource_limits/source_results/bytes")? != 8_388_608 970 { 971 return relationship_error(); 972 } 973 Ok(()) 974 } 975 976 fn array<'a>(value: &'a Value, pointer: &str) -> Result<&'a [Value], RhiConfigV1Error> { 977 value 978 .pointer(pointer) 979 .and_then(Value::as_array) 980 .map(Vec::as_slice) 981 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) 982 } 983 984 fn string<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, RhiConfigV1Error> { 985 value 986 .pointer(pointer) 987 .and_then(Value::as_str) 988 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) 989 } 990 991 fn string_at<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, RhiConfigV1Error> { 992 string(value, pointer) 993 } 994 995 fn string_set<'a>(value: &'a Value, pointer: &str) -> Result<BTreeSet<&'a str>, RhiConfigV1Error> { 996 array(value, pointer)? 997 .iter() 998 .map(|entry| { 999 entry 1000 .as_str() 1001 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) 1002 }) 1003 .collect() 1004 } 1005 1006 fn bool_value(value: &Value, pointer: &str) -> Result<bool, RhiConfigV1Error> { 1007 bool_at(value, pointer) 1008 } 1009 1010 fn bool_at(value: &Value, pointer: &str) -> Result<bool, RhiConfigV1Error> { 1011 value 1012 .pointer(pointer) 1013 .and_then(Value::as_bool) 1014 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) 1015 } 1016 1017 fn integer(value: &Value, pointer: &str) -> Result<u64, RhiConfigV1Error> { 1018 integer_at(value, pointer) 1019 } 1020 1021 fn integer_at(value: &Value, pointer: &str) -> Result<u64, RhiConfigV1Error> { 1022 value 1023 .pointer(pointer) 1024 .and_then(Value::as_u64) 1025 .ok_or_else(|| error(RhiConfigV1ErrorKind::InvalidDocument)) 1026 } 1027 1028 fn document_error<T>() -> Result<T, RhiConfigV1Error> { 1029 Err(error(RhiConfigV1ErrorKind::InvalidDocument)) 1030 } 1031 1032 fn relationship_error<T>() -> Result<T, RhiConfigV1Error> { 1033 Err(error(RhiConfigV1ErrorKind::InvalidRelationship)) 1034 } 1035 1036 const fn error(kind: RhiConfigV1ErrorKind) -> RhiConfigV1Error { 1037 RhiConfigV1Error::new(kind) 1038 } 1039 1040 #[derive(Serialize)] 1041 struct EffectiveProjection { 1042 schema: &'static str, 1043 schema_version: u32, 1044 fields: Vec<EffectiveField>, 1045 } 1046 1047 #[derive(Serialize)] 1048 struct EffectiveField { 1049 path: String, 1050 source: RhiConfigValueSource, 1051 #[serde(skip_serializing_if = "Option::is_none")] 1052 default_authority: Option<RhiConfigDefaultAuthority>, 1053 value: Value, 1054 } 1055 1056 fn build_effective( 1057 normalized: &Value, 1058 original: &toml::Table, 1059 ) -> Result<RhiEffectiveConfigV1, RhiConfigV1Error> { 1060 let mut flattened = BTreeMap::new(); 1061 flatten_value("", normalized, &mut flattened); 1062 let original = toml::Value::Table(original.clone()); 1063 let fields = flattened 1064 .into_iter() 1065 .map(|(path, value)| { 1066 let default_authority = default_entry(&path).and_then(|entry| { 1067 if toml_path(&original, &path).is_none() { 1068 Some(entry.authority) 1069 } else { 1070 None 1071 } 1072 }); 1073 EffectiveField { 1074 source: if default_authority.is_some() { 1075 RhiConfigValueSource::SafeDefault 1076 } else { 1077 RhiConfigValueSource::Toml 1078 }, 1079 default_authority, 1080 value: redacted_value(&path, value), 1081 path, 1082 } 1083 }) 1084 .collect::<Vec<_>>(); 1085 let field_count = fields.len(); 1086 let canonical_json = serde_json::to_string(&EffectiveProjection { 1087 schema: "radroots.rhi.effective-config", 1088 schema_version: 1, 1089 fields, 1090 }) 1091 .map_err(|_| error(RhiConfigV1ErrorKind::Encoding))?; 1092 if canonical_json.len() > RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES { 1093 return Err(error(RhiConfigV1ErrorKind::Encoding)); 1094 } 1095 Ok(RhiEffectiveConfigV1 { 1096 canonical_json: canonical_json.into_boxed_str(), 1097 field_count, 1098 }) 1099 } 1100 1101 fn flatten_value(path: &str, value: &Value, fields: &mut BTreeMap<String, Value>) { 1102 match value { 1103 Value::Object(object) => { 1104 for (key, child) in object { 1105 flatten_value(&format!("{path}/{key}"), child, fields); 1106 } 1107 } 1108 Value::Array(array) if array.iter().all(Value::is_object) => { 1109 for (index, child) in array.iter().enumerate() { 1110 flatten_value(&format!("{path}/{index}"), child, fields); 1111 } 1112 } 1113 _ => { 1114 fields.insert(path.to_owned(), value.clone()); 1115 } 1116 } 1117 } 1118 1119 fn redacted_value(path: &str, value: Value) -> Value { 1120 let scalar_redaction = if path.ends_with("/envelope_path") { 1121 Some("[redacted-path]") 1122 } else if path.ends_with("/credential_reference") { 1123 Some("[redacted-credential-reference]") 1124 } else if path.ends_with("/expected_public_key") { 1125 Some("[redacted-public-key]") 1126 } else if path == "/operations/listen" { 1127 Some("[redacted-address]") 1128 } else if path == "/evidence/policy_id" { 1129 Some("[redacted-policy-id]") 1130 } else if path.starts_with("/relays/") && path.ends_with("/id") { 1131 Some("[redacted-relay-id]") 1132 } else if path.starts_with("/relays/") && path.ends_with("/url") { 1133 Some("[redacted-url]") 1134 } else if path.starts_with("/evidence/sources/") && path.ends_with("/source_id") { 1135 Some("[redacted-source-id]") 1136 } else if path.starts_with("/evidence/sources/") && path.ends_with("/relay_id") { 1137 Some("[redacted-relay-id]") 1138 } else { 1139 None 1140 }; 1141 if let Some(redaction) = scalar_redaction { 1142 return Value::String(redaction.to_owned()); 1143 } 1144 if matches!( 1145 path, 1146 "/publication/target_relay_ids" | "/presence/target_relay_ids" 1147 ) { 1148 return json!({ 1149 "count": value.as_array().map_or(0, Vec::len), 1150 "values": "[redacted]" 1151 }); 1152 } 1153 value 1154 } 1155 1156 fn default_entry(path: &str) -> Option<&'static DefaultEntry> { 1157 DEFAULTS.iter().find(|entry| entry.path == path) 1158 } 1159 1160 fn toml_path<'a>(root: &'a toml::Value, path: &str) -> Option<&'a toml::Value> { 1161 path.split('/') 1162 .filter(|part| !part.is_empty()) 1163 .try_fold(root, |value, part| { 1164 if let Ok(index) = part.parse::<usize>() { 1165 value.as_array()?.get(index) 1166 } else { 1167 value.as_table()?.get(part) 1168 } 1169 }) 1170 } 1171 1172 #[cfg(test)] 1173 mod tests { 1174 use super::*; 1175 1176 const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 1177 1178 fn parse(source: &str) -> Result<RhiConfigDocumentV1, RhiConfigV1Error> { 1179 parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::Production) 1180 } 1181 1182 fn replace(source: &str, old: &str, new: &str) -> String { 1183 assert!(source.contains(old), "missing fixture fragment: {old}"); 1184 source.replacen(old, new, 1) 1185 } 1186 1187 #[test] 1188 fn canonical_example_is_deterministic_bounded_and_redacted() { 1189 let first = parse(EXAMPLE).expect("canonical example"); 1190 let second = parse(EXAMPLE).expect("canonical example again"); 1191 assert_eq!(first.schema(), RHI_CONFIG_SCHEMA); 1192 assert_eq!(first.schema_version(), RHI_CONFIG_SCHEMA_VERSION); 1193 assert_eq!(first.profile(), RhiConfigProfile::Production); 1194 assert_eq!(first.relay_count(), 2); 1195 assert_eq!(first.evidence_source_count(), 1); 1196 assert_eq!(first.runtime_thread_limits().worker_threads(), 4); 1197 assert_eq!(first.runtime_thread_limits().blocking_threads(), 8); 1198 assert_eq!(first.effective(), second.effective()); 1199 assert!(first.effective().field_count() > 75); 1200 let output = first.effective().canonical_json(); 1201 assert!(output.starts_with( 1202 "{\"schema\":\"radroots.rhi.effective-config\",\"schema_version\":1,\"fields\":[" 1203 )); 1204 assert!(output.len() <= RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES); 1205 let projection: Value = serde_json::from_str(output).expect("effective projection"); 1206 let fields = projection["fields"].as_array().expect("effective fields"); 1207 assert_eq!(fields.len(), first.effective().field_count()); 1208 let paths = fields 1209 .iter() 1210 .map(|field| field["path"].as_str().expect("effective path")) 1211 .collect::<Vec<_>>(); 1212 assert!(paths.windows(2).all(|pair| pair[0] < pair[1])); 1213 assert!(fields.iter().all(|field| { 1214 field["source"] == "toml" && field.get("default_authority").is_none() 1215 })); 1216 for forbidden in [ 1217 "/var/lib/radroots", 1218 "2222222222222222", 1219 "relay.example.com", 1220 "relay-primary", 1221 "production-primary", 1222 "trade-primary", 1223 "service_wrapping_key", 1224 ] { 1225 assert!(!output.contains(forbidden), "leaked {forbidden}"); 1226 assert!(!format!("{first:?}").contains(forbidden)); 1227 } 1228 } 1229 1230 #[test] 1231 fn exact_document_bound_precedes_utf8_and_toml_parsing() { 1232 let mut exact = EXAMPLE.as_bytes().to_vec(); 1233 exact.extend_from_slice(b"\n#"); 1234 exact.resize(RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, b'a'); 1235 assert!(parse_rhi_config_v1(&exact, RhiConfigProfile::Production).is_ok()); 1236 exact.push(0xff); 1237 assert_eq!( 1238 parse_rhi_config_v1(&exact, RhiConfigProfile::Production) 1239 .unwrap_err() 1240 .kind(), 1241 RhiConfigV1ErrorKind::TooLarge 1242 ); 1243 } 1244 1245 #[test] 1246 fn invalid_utf8_duplicate_null_unknown_and_malformed_wire_fail() { 1247 assert_eq!( 1248 parse_rhi_config_v1(&[0xff], RhiConfigProfile::Production) 1249 .unwrap_err() 1250 .kind(), 1251 RhiConfigV1ErrorKind::InvalidUtf8 1252 ); 1253 for source in [ 1254 format!("schema = \"radroots.rhi.config\"\n{EXAMPLE}"), 1255 replace( 1256 EXAMPLE, 1257 "shutdown_grace_ms = 30000", 1258 "shutdown_grace_ms = null", 1259 ), 1260 replace( 1261 EXAMPLE, 1262 "shutdown_grace_ms = 30000", 1263 "shutdown_grace_ms = [null]", 1264 ), 1265 replace( 1266 EXAMPLE, 1267 "busy_timeout_ms = 5000", 1268 "busy_timeout_ms = 5000\nbusy_timeout_ms = 5000", 1269 ), 1270 ] { 1271 assert_eq!( 1272 parse(&source).unwrap_err().kind(), 1273 RhiConfigV1ErrorKind::MalformedToml 1274 ); 1275 } 1276 let unknown = replace( 1277 EXAMPLE, 1278 "shutdown_grace_ms = 30000", 1279 "shutdown_grace_ms = 30000\nsecret = \"do-not-render\"", 1280 ); 1281 assert_eq!( 1282 parse(&unknown).unwrap_err().kind(), 1283 RhiConfigV1ErrorKind::InvalidDocument 1284 ); 1285 } 1286 1287 #[test] 1288 fn header_failures_are_classified_before_document_admission() { 1289 for (source, expected) in [ 1290 ( 1291 EXAMPLE.replace("schema = \"radroots.rhi.config\"\n", ""), 1292 RhiConfigV1ErrorKind::MissingSchema, 1293 ), 1294 ( 1295 replace(EXAMPLE, "schema = \"radroots.rhi.config\"", "schema = 1"), 1296 RhiConfigV1ErrorKind::InvalidSchema, 1297 ), 1298 ( 1299 replace(EXAMPLE, "radroots.rhi.config", "radroots.myc.config"), 1300 RhiConfigV1ErrorKind::SchemaMismatch, 1301 ), 1302 ( 1303 EXAMPLE.replace("schema_version = 1\n", ""), 1304 RhiConfigV1ErrorKind::MissingSchemaVersion, 1305 ), 1306 ( 1307 replace(EXAMPLE, "schema_version = 1", "schema_version = \"1\""), 1308 RhiConfigV1ErrorKind::InvalidSchemaVersion, 1309 ), 1310 ( 1311 replace(EXAMPLE, "schema_version = 1", "schema_version = 2"), 1312 RhiConfigV1ErrorKind::UnsupportedSchemaVersion, 1313 ), 1314 ] { 1315 assert_eq!(parse(&source).unwrap_err().kind(), expected); 1316 } 1317 } 1318 1319 #[test] 1320 fn production_and_repo_local_relay_postures_are_distinct() { 1321 let local = replace(EXAMPLE, "wss://relay.example.com/", "ws://127.0.0.1:7777/"); 1322 assert_eq!( 1323 parse(&local).unwrap_err().kind(), 1324 RhiConfigV1ErrorKind::InvalidRelationship 1325 ); 1326 assert!(parse_rhi_config_v1(local.as_bytes(), RhiConfigProfile::RepoLocal).is_ok()); 1327 let remote = replace(&local, "ws://127.0.0.1:7777/", "ws://relay.example.com/"); 1328 assert_eq!( 1329 parse_rhi_config_v1(remote.as_bytes(), RhiConfigProfile::RepoLocal) 1330 .unwrap_err() 1331 .kind(), 1332 RhiConfigV1ErrorKind::InvalidRelationship 1333 ); 1334 } 1335 1336 #[test] 1337 fn identity_relay_and_evidence_relationships_fail_closed() { 1338 let cases = [ 1339 replace( 1340 EXAMPLE, 1341 "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", 1342 "/var/lib/radroots/../escape.ncrypt", 1343 ), 1344 replace( 1345 EXAMPLE, 1346 "2222222222222222222222222222222222222222222222222222222222222222", 1347 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 1348 ), 1349 replace( 1350 EXAMPLE, 1351 "id = \"relay-secondary\"", 1352 "id = \"relay-primary\"", 1353 ), 1354 replace( 1355 EXAMPLE, 1356 "wss://relay-secondary.example.com/", 1357 "wss://relay.example.com/", 1358 ), 1359 replace( 1360 EXAMPLE, 1361 "read = true\nwrite = true", 1362 "read = false\nwrite = false", 1363 ), 1364 replace( 1365 EXAMPLE, 1366 "required = true\nselector", 1367 "required = false\nselector", 1368 ), 1369 replace( 1370 EXAMPLE, 1371 "relay_id = \"relay-primary\"", 1372 "relay_id = \"missing\"", 1373 ), 1374 replace(EXAMPLE, "overlap_seconds = 300", "overlap_seconds = 86401"), 1375 replace( 1376 EXAMPLE, 1377 "attempt_deadline_ms = 30000", 1378 "attempt_deadline_ms = 9999", 1379 ), 1380 ]; 1381 for source in cases { 1382 assert!(matches!( 1383 parse(&source).unwrap_err().kind(), 1384 RhiConfigV1ErrorKind::InvalidDocument | RhiConfigV1ErrorKind::InvalidRelationship 1385 )); 1386 } 1387 1388 let out_of_order = replace( 1389 &replace( 1390 EXAMPLE, 1391 "read = false\nwrite = true", 1392 "read = true\nwrite = true", 1393 ), 1394 "[reconciliation]", 1395 "[[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]", 1396 ); 1397 assert_eq!( 1398 parse(&out_of_order).unwrap_err().kind(), 1399 RhiConfigV1ErrorKind::InvalidRelationship 1400 ); 1401 } 1402 1403 #[test] 1404 fn publication_presence_operations_retention_and_policy_limits_fail_closed() { 1405 let cases = [ 1406 replace( 1407 EXAMPLE, 1408 "initial_backoff_ms = 250", 1409 "initial_backoff_ms = 30001", 1410 ), 1411 replace( 1412 EXAMPLE, 1413 "target_relay_ids = [\"relay-primary\", \"relay-secondary\"]", 1414 "target_relay_ids = [\"missing\"]", 1415 ), 1416 replace( 1417 EXAMPLE, 1418 "profile = true\napplication_handler = true", 1419 "profile = false\napplication_handler = false", 1420 ), 1421 replace(EXAMPLE, "audit_ms = 31536000000", "audit_ms = 1000"), 1422 replace(EXAMPLE, "events = 4096", "events = 4095"), 1423 replace(EXAMPLE, "bytes = 8388608", "bytes = 8388607"), 1424 ]; 1425 for source in cases { 1426 assert!(matches!( 1427 parse(&source).unwrap_err().kind(), 1428 RhiConfigV1ErrorKind::InvalidDocument | RhiConfigV1ErrorKind::InvalidRelationship 1429 )); 1430 } 1431 1432 let public_loopback_policy = replace( 1433 EXAMPLE, 1434 "[operations]\nenabled = false", 1435 "[operations]\nenabled = true\nlisten = \"0.0.0.0:9460\"\nbind_policy = \"loopback_only\"\n\n[operations.limits]", 1436 ); 1437 assert_eq!( 1438 parse(&public_loopback_policy).unwrap_err().kind(), 1439 RhiConfigV1ErrorKind::InvalidRelationship 1440 ); 1441 } 1442 1443 #[test] 1444 fn conditional_sections_remain_closed_when_disabled_and_default_when_enabled() { 1445 let mut disabled = EXAMPLE.parse::<toml::Table>().expect("example TOML"); 1446 let publication = disabled 1447 .get_mut("publication") 1448 .and_then(toml::Value::as_table_mut) 1449 .expect("publication table"); 1450 publication.insert( 1451 "mode".to_owned(), 1452 toml::Value::String("disabled".to_owned()), 1453 ); 1454 publication.remove("target_relay_ids"); 1455 publication.remove("retry"); 1456 let presence = disabled 1457 .get_mut("presence") 1458 .and_then(toml::Value::as_table_mut) 1459 .expect("presence table"); 1460 presence.insert("enabled".to_owned(), toml::Value::Boolean(false)); 1461 presence.insert("profile".to_owned(), toml::Value::Boolean(false)); 1462 presence.insert( 1463 "application_handler".to_owned(), 1464 toml::Value::Boolean(false), 1465 ); 1466 presence.remove("target_relay_ids"); 1467 let parsed = 1468 parse(&toml::to_string(&disabled).expect("disabled TOML")).expect("disabled sections"); 1469 let effective = parsed.effective().canonical_json(); 1470 assert!(!effective.contains("/publication/retry/")); 1471 assert!(!effective.contains("/operations/limits/")); 1472 1473 let enabled_operations = replace( 1474 EXAMPLE, 1475 "[operations]\nenabled = false", 1476 "[operations]\nenabled = true\nlisten = \"127.0.0.1:9460\"\nbind_policy = \"loopback_only\"\n\n[operations.limits]", 1477 ); 1478 let parsed = parse(&enabled_operations).expect("enabled operations defaults"); 1479 assert!( 1480 parsed 1481 .effective() 1482 .canonical_json() 1483 .contains("/operations/limits/header_count") 1484 ); 1485 } 1486 1487 #[test] 1488 fn all_frozen_defaults_are_applied_with_exact_provenance() { 1489 assert_eq!( 1490 serde_json::to_value([ 1491 RhiConfigValueSource::BootstrapCli, 1492 RhiConfigValueSource::Toml, 1493 RhiConfigValueSource::SafeDefault, 1494 RhiConfigValueSource::DerivedPath, 1495 ]) 1496 .expect("serialize provenance vocabulary"), 1497 json!(["bootstrap_cli", "toml", "safe_default", "derived_path"]) 1498 ); 1499 assert_eq!(DEFAULTS.len(), 51); 1500 let schema: Value = serde_json::from_str(CONFIG_SCHEMA).expect("embedded schema"); 1501 assert_eq!(count_schema_defaults(&schema), DEFAULTS.len()); 1502 for entry in DEFAULTS { 1503 let pointer = schema_default_pointer(entry.path); 1504 let schema_default = schema 1505 .pointer(&format!("{pointer}/default")) 1506 .expect("schema default"); 1507 let expected_default = match entry.value { 1508 DefaultValue::Integer(value) => json!(value), 1509 DefaultValue::String(value) => json!(value), 1510 }; 1511 assert_eq!(schema_default, &expected_default, "{}", entry.path); 1512 let expected_authority = 1513 serde_json::to_value(entry.authority).expect("serialize default authority"); 1514 assert_eq!( 1515 schema.pointer(&format!("{pointer}/x-radroots-default-source")), 1516 Some(&expected_authority), 1517 "{}", 1518 entry.path 1519 ); 1520 } 1521 let mut table = EXAMPLE.parse::<toml::Table>().expect("example TOML"); 1522 for entry in DEFAULTS { 1523 remove_toml_path(&mut table, entry.path); 1524 } 1525 let minimal = toml::to_string(&table).expect("minimal TOML"); 1526 let parsed = parse(&minimal).expect("defaults admitted"); 1527 let output = parsed.effective().canonical_json(); 1528 for source in [ 1529 "accepted_service_authority", 1530 "engineering_safety", 1531 "radroots_service_sqlite", 1532 "radroots_service_host", 1533 "radroots_event", 1534 "rhi_evidence_policy", 1535 ] { 1536 assert!(output.contains(&format!("\"default_authority\":\"{source}\""))); 1537 } 1538 assert!(output.contains("\"source\":\"safe_default\"")); 1539 let projection: Value = serde_json::from_str(output).expect("effective projection"); 1540 assert!( 1541 projection["fields"] 1542 .as_array() 1543 .expect("effective fields") 1544 .iter() 1545 .all(|field| match field["source"].as_str() { 1546 Some("safe_default") => field.get("default_authority").is_some(), 1547 Some("toml") => field.get("default_authority").is_none(), 1548 _ => false, 1549 }) 1550 ); 1551 let explicit = parse(EXAMPLE).expect("explicit example"); 1552 assert!( 1553 !explicit 1554 .effective() 1555 .canonical_json() 1556 .contains("default_authority") 1557 ); 1558 assert!( 1559 explicit 1560 .effective() 1561 .canonical_json() 1562 .matches("\"source\":\"toml\"") 1563 .count() 1564 > output.matches("\"source\":\"toml\"").count() 1565 ); 1566 } 1567 1568 #[test] 1569 fn byte_limits_errors_and_debug_are_safe() { 1570 let exact_reference = "a".repeat(128); 1571 let exact = replace(EXAMPLE, "service_wrapping_key", &exact_reference); 1572 assert!(parse(&exact).is_ok()); 1573 let over_reference = "a".repeat(129); 1574 let over = replace(EXAMPLE, "service_wrapping_key", &over_reference); 1575 assert_eq!( 1576 parse(&over).unwrap_err().kind(), 1577 RhiConfigV1ErrorKind::InvalidDocument 1578 ); 1579 1580 let exact_path = format!("/{}a", "é".repeat(2_047)); 1581 assert_eq!(exact_path.len(), 4_096); 1582 let exact = replace( 1583 EXAMPLE, 1584 "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", 1585 &exact_path, 1586 ); 1587 assert!(parse(&exact).is_ok()); 1588 let over_path = format!("/{}", "é".repeat(2_048)); 1589 assert_eq!(over_path.len(), 4_097); 1590 let over = replace( 1591 EXAMPLE, 1592 "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", 1593 &over_path, 1594 ); 1595 assert_eq!( 1596 parse(&over).unwrap_err().kind(), 1597 RhiConfigV1ErrorKind::InvalidDocument 1598 ); 1599 1600 let secret = "credential-secret-value"; 1601 let source = replace( 1602 EXAMPLE, 1603 "shutdown_grace_ms = 30000", 1604 &format!("unknown = \"{secret}\""), 1605 ); 1606 let failure = parse(&source).unwrap_err(); 1607 let rendered = format!("{failure} {failure:?}"); 1608 assert!(!rendered.contains(secret)); 1609 assert!(Error::source(&failure).is_none()); 1610 } 1611 1612 fn remove_toml_path(table: &mut toml::Table, pointer: &str) { 1613 let mut parts = pointer 1614 .split('/') 1615 .filter(|part| !part.is_empty()) 1616 .peekable(); 1617 let mut current = table; 1618 while let Some(part) = parts.next() { 1619 if parts.peek().is_none() { 1620 current.remove(part); 1621 return; 1622 } 1623 let Some(next) = current.get_mut(part).and_then(toml::Value::as_table_mut) else { 1624 return; 1625 }; 1626 current = next; 1627 } 1628 } 1629 1630 fn count_schema_defaults(value: &Value) -> usize { 1631 match value { 1632 Value::Object(object) => { 1633 usize::from(object.contains_key("default")) 1634 + object.values().map(count_schema_defaults).sum::<usize>() 1635 } 1636 Value::Array(array) => array.iter().map(count_schema_defaults).sum(), 1637 _ => 0, 1638 } 1639 } 1640 1641 fn schema_default_pointer(path: &str) -> String { 1642 for (prefix, definition) in [ 1643 ("/operations/limits/", "operations_limits"), 1644 ("/publication/retry/", "retry"), 1645 ("/resource_limits/admin/", "admin_limits"), 1646 ("/resource_limits/events/", "event_limits"), 1647 ("/resource_limits/source_results/", "source_result_limits"), 1648 ("/resource_limits/queues/", "queue_limits"), 1649 ("/resource_limits/metrics/", "metrics_limits"), 1650 ("/resource_limits/runtime/", "runtime_limits"), 1651 ] { 1652 if let Some(field) = path.strip_prefix(prefix) { 1653 return format!("/$defs/{definition}/properties/{field}"); 1654 } 1655 } 1656 let mut parts = path.split('/').filter(|part| !part.is_empty()); 1657 let definition = parts.next().expect("default definition"); 1658 let field = parts.next().expect("default field"); 1659 assert!(parts.next().is_none(), "unmapped default path: {path}"); 1660 format!("/$defs/{definition}/properties/{field}") 1661 } 1662 }