config_v1.rs (45905B)
1 //! Strict, bounded Myc 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 use crate::provider_contract::MycProviderContract; 14 15 const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); 16 17 #[cfg(any(target_os = "linux", target_os = "macos"))] 18 pub(crate) const fn config_schema_document() -> &'static str { 19 CONFIG_SCHEMA 20 } 21 22 /// Exact schema identity for the production Myc configuration document. 23 pub const MYC_CONFIG_SCHEMA: &str = "radroots.myc.config"; 24 25 /// Exact supported Myc configuration schema version. 26 pub const MYC_CONFIG_SCHEMA_VERSION: u32 = 1; 27 28 /// Hard cap applied to original bytes before UTF-8 or TOML parsing. 29 pub const MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize = 1_048_576; 30 31 /// Bootstrap-selected network posture used during relay admission. 32 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 33 pub enum MycConfigProfile { 34 /// Production and ordinary service-host configurations require WSS relays. 35 Production, 36 /// Explicit repository-local development may also use loopback WS relays. 37 RepoLocal, 38 } 39 40 /// Stable source classification for an effective configuration value. 41 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 42 #[serde(rename_all = "snake_case")] 43 pub enum MycConfigValueSource { 44 Document, 45 RadrootsServiceHost, 46 RadrootsServiceSqlite, 47 RadrootsEvent, 48 RadrootsNostrConnect, 49 AcceptedServiceAuthority, 50 EngineeringSafety, 51 } 52 53 /// Stable source-free classification for configuration admission failures. 54 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 55 pub enum MycConfigV1ErrorKind { 56 TooLarge, 57 InvalidUtf8, 58 MalformedToml, 59 MissingSchema, 60 InvalidSchema, 61 SchemaMismatch, 62 MissingSchemaVersion, 63 InvalidSchemaVersion, 64 UnsupportedSchemaVersion, 65 InvalidDocument, 66 InvalidRelationship, 67 Encoding, 68 } 69 70 impl MycConfigV1ErrorKind { 71 const fn message(self) -> &'static str { 72 match self { 73 Self::TooLarge => "configuration document exceeds its size limit", 74 Self::InvalidUtf8 => "configuration document is not valid UTF-8", 75 Self::MalformedToml => "configuration document is not valid TOML", 76 Self::MissingSchema => "configuration document schema is missing", 77 Self::InvalidSchema => "configuration document schema is invalid", 78 Self::SchemaMismatch => "configuration document schema is unsupported", 79 Self::MissingSchemaVersion => "configuration document schema version is missing", 80 Self::InvalidSchemaVersion => "configuration document schema version is invalid", 81 Self::UnsupportedSchemaVersion => { 82 "configuration document schema version is unsupported" 83 } 84 Self::InvalidDocument => "configuration document fields are invalid", 85 Self::InvalidRelationship => "configuration document relationships are invalid", 86 Self::Encoding => "effective configuration could not be encoded", 87 } 88 } 89 } 90 91 /// One source-free configuration admission failure. 92 #[derive(Clone, Copy, PartialEq, Eq)] 93 pub struct MycConfigV1Error { 94 kind: MycConfigV1ErrorKind, 95 } 96 97 impl MycConfigV1Error { 98 const fn new(kind: MycConfigV1ErrorKind) -> Self { 99 Self { kind } 100 } 101 102 /// Returns the stable failure classification. 103 #[must_use] 104 pub const fn kind(self) -> MycConfigV1ErrorKind { 105 self.kind 106 } 107 } 108 109 impl fmt::Debug for MycConfigV1Error { 110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 111 formatter 112 .debug_struct("MycConfigV1Error") 113 .field("kind", &self.kind) 114 .finish() 115 } 116 } 117 118 impl fmt::Display for MycConfigV1Error { 119 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 120 formatter.write_str(self.kind.message()) 121 } 122 } 123 124 impl Error for MycConfigV1Error {} 125 126 /// Deterministic redacted effective configuration with exact leaf provenance. 127 #[derive(Clone, PartialEq, Eq)] 128 pub struct MycEffectiveConfigV1 { 129 canonical_json: Box<str>, 130 field_count: usize, 131 } 132 133 impl MycEffectiveConfigV1 { 134 /// Returns compact JSON in deterministic path order. 135 #[must_use] 136 pub fn canonical_json(&self) -> &str { 137 &self.canonical_json 138 } 139 140 /// Returns the number of projected effective leaf values. 141 #[must_use] 142 pub const fn field_count(&self) -> usize { 143 self.field_count 144 } 145 } 146 147 impl fmt::Debug for MycEffectiveConfigV1 { 148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 149 formatter 150 .debug_struct("MycEffectiveConfigV1") 151 .field("canonical_json", &"[redacted]") 152 .field("field_count", &self.field_count) 153 .finish() 154 } 155 } 156 157 /// A validated immutable Myc configuration document v1. 158 pub struct MycConfigDocumentV1 { 159 profile: MycConfigProfile, 160 normalized: Value, 161 effective: MycEffectiveConfigV1, 162 provider_contract: MycProviderContract, 163 runtime_thread_limits: MycRuntimeThreadLimitsV1, 164 } 165 166 /// Validated thread counts for the sole binary-owned Tokio runtime. 167 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 168 pub struct MycRuntimeThreadLimitsV1 { 169 worker_threads: usize, 170 blocking_threads: usize, 171 } 172 173 impl MycRuntimeThreadLimitsV1 { 174 #[must_use] 175 pub const fn worker_threads(self) -> usize { 176 self.worker_threads 177 } 178 179 #[must_use] 180 pub const fn blocking_threads(self) -> usize { 181 self.blocking_threads 182 } 183 } 184 185 impl MycConfigDocumentV1 { 186 /// Returns the exact admitted schema identity. 187 #[must_use] 188 pub const fn schema(&self) -> &'static str { 189 MYC_CONFIG_SCHEMA 190 } 191 192 /// Returns the exact admitted schema version. 193 #[must_use] 194 pub const fn schema_version(&self) -> u32 { 195 MYC_CONFIG_SCHEMA_VERSION 196 } 197 198 /// Returns the bootstrap-selected network posture used during admission. 199 #[must_use] 200 pub const fn profile(&self) -> MycConfigProfile { 201 self.profile 202 } 203 204 /// Returns the deterministic redacted effective configuration projection. 205 #[must_use] 206 pub const fn effective(&self) -> &MycEffectiveConfigV1 { 207 &self.effective 208 } 209 210 /// Returns the exact number of configured relay bindings. 211 #[must_use] 212 pub fn relay_count(&self) -> usize { 213 self.normalized 214 .pointer("/relays") 215 .and_then(Value::as_array) 216 .map_or(0, Vec::len) 217 } 218 219 /// Returns the immutable provider assignments derived from this document. 220 #[must_use] 221 pub const fn provider_contract(&self) -> &MycProviderContract { 222 &self.provider_contract 223 } 224 225 /// Returns the validated limits for the sole binary-owned Tokio runtime. 226 #[must_use] 227 pub const fn runtime_thread_limits(&self) -> MycRuntimeThreadLimitsV1 { 228 self.runtime_thread_limits 229 } 230 231 pub(crate) const fn normalized(&self) -> &Value { 232 &self.normalized 233 } 234 } 235 236 impl fmt::Debug for MycConfigDocumentV1 { 237 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 238 formatter 239 .debug_struct("MycConfigDocumentV1") 240 .field("schema", &MYC_CONFIG_SCHEMA) 241 .field("schema_version", &MYC_CONFIG_SCHEMA_VERSION) 242 .field("profile", &self.profile) 243 .field("effective", &self.effective) 244 .field("provider_contract", &self.provider_contract) 245 .finish() 246 } 247 } 248 249 /// Parses and semantically validates one complete Myc configuration document. 250 pub fn parse_myc_config_v1( 251 bytes: &[u8], 252 profile: MycConfigProfile, 253 ) -> Result<MycConfigDocumentV1, MycConfigV1Error> { 254 if bytes.len() > MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES { 255 return Err(error(MycConfigV1ErrorKind::TooLarge)); 256 } 257 let source = 258 std::str::from_utf8(bytes).map_err(|_| error(MycConfigV1ErrorKind::InvalidUtf8))?; 259 let original = source 260 .parse::<toml::Table>() 261 .map_err(|_| error(MycConfigV1ErrorKind::MalformedToml))?; 262 validate_header(&original)?; 263 let mut normalized = serde_json::to_value(toml::Value::Table(original.clone())) 264 .map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument))?; 265 let schema: Value = 266 serde_json::from_str(CONFIG_SCHEMA).map_err(|_| error(MycConfigV1ErrorKind::Encoding))?; 267 let validator = 268 jsonschema::validator_for(&schema).map_err(|_| error(MycConfigV1ErrorKind::Encoding))?; 269 if !validator.is_valid(&normalized) { 270 return Err(error(MycConfigV1ErrorKind::InvalidDocument)); 271 } 272 apply_defaults(&mut normalized)?; 273 if !validator.is_valid(&normalized) { 274 return Err(error(MycConfigV1ErrorKind::InvalidDocument)); 275 } 276 validate_relationships(&normalized, profile)?; 277 let effective = build_effective(&normalized, &original)?; 278 let provider_contract = MycProviderContract::from_normalized(&normalized) 279 .map_err(|_| error(MycConfigV1ErrorKind::InvalidRelationship))?; 280 let runtime_thread_limits = MycRuntimeThreadLimitsV1 { 281 worker_threads: usize::try_from(integer( 282 &normalized, 283 "/resource_limits/runtime/worker_threads", 284 )?) 285 .map_err(|_| error(MycConfigV1ErrorKind::InvalidRelationship))?, 286 blocking_threads: usize::try_from(integer( 287 &normalized, 288 "/resource_limits/runtime/blocking_threads", 289 )?) 290 .map_err(|_| error(MycConfigV1ErrorKind::InvalidRelationship))?, 291 }; 292 Ok(MycConfigDocumentV1 { 293 profile, 294 normalized, 295 effective, 296 provider_contract, 297 runtime_thread_limits, 298 }) 299 } 300 301 fn validate_header(header: &toml::Table) -> Result<(), MycConfigV1Error> { 302 let schema = header 303 .get("schema") 304 .ok_or_else(|| error(MycConfigV1ErrorKind::MissingSchema))? 305 .as_str() 306 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidSchema))?; 307 if !valid_schema_id(schema) { 308 return Err(error(MycConfigV1ErrorKind::InvalidSchema)); 309 } 310 if schema != MYC_CONFIG_SCHEMA { 311 return Err(error(MycConfigV1ErrorKind::SchemaMismatch)); 312 } 313 let version = header 314 .get("schema_version") 315 .ok_or_else(|| error(MycConfigV1ErrorKind::MissingSchemaVersion))? 316 .as_integer() 317 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidSchemaVersion))?; 318 let version = 319 u32::try_from(version).map_err(|_| error(MycConfigV1ErrorKind::InvalidSchemaVersion))?; 320 if version == 0 { 321 return Err(error(MycConfigV1ErrorKind::InvalidSchemaVersion)); 322 } 323 if version != MYC_CONFIG_SCHEMA_VERSION { 324 return Err(error(MycConfigV1ErrorKind::UnsupportedSchemaVersion)); 325 } 326 Ok(()) 327 } 328 329 fn valid_schema_id(value: &str) -> bool { 330 let mut bytes = value.bytes(); 331 !value.is_empty() 332 && value.len() <= 128 333 && bytes 334 .next() 335 .is_some_and(|byte| byte.is_ascii_alphanumeric()) 336 && bytes 337 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) 338 } 339 340 #[derive(Clone, Copy)] 341 struct DefaultEntry { 342 path: &'static str, 343 value: DefaultValue, 344 source: MycConfigValueSource, 345 enabled_pointer: Option<&'static str>, 346 } 347 348 #[derive(Clone, Copy)] 349 enum DefaultValue { 350 Integer(u64), 351 String(&'static str), 352 } 353 354 const DEFAULTS: &[DefaultEntry] = &[ 355 default( 356 "/service/shutdown_grace_ms", 357 30_000, 358 MycConfigValueSource::EngineeringSafety, 359 ), 360 default_string( 361 "/logging/level", 362 "info", 363 MycConfigValueSource::EngineeringSafety, 364 ), 365 default_string( 366 "/logging/format", 367 "json", 368 MycConfigValueSource::AcceptedServiceAuthority, 369 ), 370 conditional_default( 371 "/operations/limits/header_count", 372 32, 373 MycConfigValueSource::RadrootsServiceHost, 374 "/operations/enabled", 375 ), 376 conditional_default( 377 "/operations/limits/header_bytes", 378 16_384, 379 MycConfigValueSource::RadrootsServiceHost, 380 "/operations/enabled", 381 ), 382 conditional_default( 383 "/operations/limits/response_body_utf8_bytes", 384 1_048_576, 385 MycConfigValueSource::RadrootsServiceHost, 386 "/operations/enabled", 387 ), 388 conditional_default( 389 "/operations/limits/concurrent_connections", 390 32, 391 MycConfigValueSource::RadrootsServiceHost, 392 "/operations/enabled", 393 ), 394 conditional_default( 395 "/operations/limits/request_deadline_ms", 396 15_000, 397 MycConfigValueSource::RadrootsServiceHost, 398 "/operations/enabled", 399 ), 400 conditional_default( 401 "/operations/limits/idle_timeout_ms", 402 30_000, 403 MycConfigValueSource::RadrootsServiceHost, 404 "/operations/enabled", 405 ), 406 default( 407 "/database/busy_timeout_ms", 408 5_000, 409 MycConfigValueSource::RadrootsServiceSqlite, 410 ), 411 default( 412 "/database/max_connections", 413 8, 414 MycConfigValueSource::RadrootsServiceSqlite, 415 ), 416 default( 417 "/transport/connect_deadline_ms", 418 10_000, 419 MycConfigValueSource::EngineeringSafety, 420 ), 421 default( 422 "/transport/publish_retry/max_attempts", 423 5, 424 MycConfigValueSource::EngineeringSafety, 425 ), 426 default( 427 "/transport/publish_retry/initial_backoff_ms", 428 250, 429 MycConfigValueSource::EngineeringSafety, 430 ), 431 default( 432 "/transport/publish_retry/maximum_backoff_ms", 433 30_000, 434 MycConfigValueSource::EngineeringSafety, 435 ), 436 default( 437 "/transport/publish_retry/attempt_deadline_ms", 438 15_000, 439 MycConfigValueSource::RadrootsServiceHost, 440 ), 441 default( 442 "/resource_limits/admin/header_count", 443 32, 444 MycConfigValueSource::RadrootsServiceHost, 445 ), 446 default( 447 "/resource_limits/admin/header_bytes", 448 16_384, 449 MycConfigValueSource::RadrootsServiceHost, 450 ), 451 default( 452 "/resource_limits/admin/request_body_utf8_bytes", 453 65_536, 454 MycConfigValueSource::RadrootsServiceHost, 455 ), 456 default( 457 "/resource_limits/admin/response_body_utf8_bytes", 458 1_048_576, 459 MycConfigValueSource::RadrootsServiceHost, 460 ), 461 default( 462 "/resource_limits/admin/concurrent_connections", 463 32, 464 MycConfigValueSource::RadrootsServiceHost, 465 ), 466 default( 467 "/resource_limits/admin/request_deadline_ms", 468 15_000, 469 MycConfigValueSource::RadrootsServiceHost, 470 ), 471 default( 472 "/resource_limits/admin/idle_timeout_ms", 473 30_000, 474 MycConfigValueSource::RadrootsServiceHost, 475 ), 476 default( 477 "/resource_limits/admin/query_items", 478 100, 479 MycConfigValueSource::RadrootsServiceHost, 480 ), 481 default( 482 "/resource_limits/events/wire_bytes", 483 262_144, 484 MycConfigValueSource::RadrootsEvent, 485 ), 486 default( 487 "/resource_limits/events/content_bytes", 488 131_072, 489 MycConfigValueSource::RadrootsEvent, 490 ), 491 default( 492 "/resource_limits/events/tag_count", 493 1_024, 494 MycConfigValueSource::RadrootsEvent, 495 ), 496 default( 497 "/resource_limits/events/tag_total_elements", 498 4_096, 499 MycConfigValueSource::RadrootsEvent, 500 ), 501 default( 502 "/resource_limits/events/tag_element_bytes", 503 4_096, 504 MycConfigValueSource::RadrootsEvent, 505 ), 506 default( 507 "/resource_limits/events/tag_total_bytes", 508 131_072, 509 MycConfigValueSource::RadrootsEvent, 510 ), 511 default( 512 "/resource_limits/events/decrypted_plaintext_bytes", 513 262_144, 514 MycConfigValueSource::RadrootsNostrConnect, 515 ), 516 default( 517 "/resource_limits/queues/ingress", 518 1_024, 519 MycConfigValueSource::EngineeringSafety, 520 ), 521 default( 522 "/resource_limits/queues/provider", 523 64, 524 MycConfigValueSource::EngineeringSafety, 525 ), 526 default( 527 "/resource_limits/queues/outbox", 528 4_096, 529 MycConfigValueSource::EngineeringSafety, 530 ), 531 default( 532 "/resource_limits/queues/discovery", 533 64, 534 MycConfigValueSource::EngineeringSafety, 535 ), 536 default( 537 "/resource_limits/metrics/descriptors", 538 64, 539 MycConfigValueSource::RadrootsServiceHost, 540 ), 541 default( 542 "/resource_limits/metrics/samples", 543 512, 544 MycConfigValueSource::RadrootsServiceHost, 545 ), 546 default( 547 "/resource_limits/metrics/labels_per_sample", 548 8, 549 MycConfigValueSource::RadrootsServiceHost, 550 ), 551 default( 552 "/resource_limits/metrics/render_utf8_bytes", 553 1_048_576, 554 MycConfigValueSource::RadrootsServiceHost, 555 ), 556 default( 557 "/resource_limits/runtime/worker_threads", 558 4, 559 MycConfigValueSource::EngineeringSafety, 560 ), 561 default( 562 "/resource_limits/runtime/blocking_threads", 563 8, 564 MycConfigValueSource::EngineeringSafety, 565 ), 566 ]; 567 568 const fn default(path: &'static str, value: u64, source: MycConfigValueSource) -> DefaultEntry { 569 DefaultEntry { 570 path, 571 value: DefaultValue::Integer(value), 572 source, 573 enabled_pointer: None, 574 } 575 } 576 577 const fn conditional_default( 578 path: &'static str, 579 value: u64, 580 source: MycConfigValueSource, 581 enabled_pointer: &'static str, 582 ) -> DefaultEntry { 583 DefaultEntry { 584 path, 585 value: DefaultValue::Integer(value), 586 source, 587 enabled_pointer: Some(enabled_pointer), 588 } 589 } 590 591 const fn default_string( 592 path: &'static str, 593 value: &'static str, 594 source: MycConfigValueSource, 595 ) -> DefaultEntry { 596 DefaultEntry { 597 path, 598 value: DefaultValue::String(value), 599 source, 600 enabled_pointer: None, 601 } 602 } 603 604 fn apply_defaults(document: &mut Value) -> Result<(), MycConfigV1Error> { 605 for entry in DEFAULTS { 606 if entry 607 .enabled_pointer 608 .is_some_and(|pointer| document.pointer(pointer) != Some(&Value::Bool(true))) 609 || document.pointer(entry.path).is_some() 610 { 611 continue; 612 } 613 insert_json_pointer(document, entry.path, entry.value)?; 614 } 615 Ok(()) 616 } 617 618 fn insert_json_pointer( 619 root: &mut Value, 620 pointer: &str, 621 value: DefaultValue, 622 ) -> Result<(), MycConfigV1Error> { 623 let mut parts = pointer 624 .split('/') 625 .filter(|part| !part.is_empty()) 626 .peekable(); 627 let mut current = root; 628 while let Some(part) = parts.next() { 629 let object = current 630 .as_object_mut() 631 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument))?; 632 if parts.peek().is_none() { 633 object.insert( 634 part.to_owned(), 635 match value { 636 DefaultValue::Integer(value) => Value::Number(value.into()), 637 DefaultValue::String(value) => Value::String(value.to_owned()), 638 }, 639 ); 640 return Ok(()); 641 } 642 current = object 643 .entry(part.to_owned()) 644 .or_insert_with(|| Value::Object(Map::new())); 645 } 646 Err(error(MycConfigV1ErrorKind::InvalidDocument)) 647 } 648 649 fn validate_relationships( 650 document: &Value, 651 profile: MycConfigProfile, 652 ) -> Result<(), MycConfigV1Error> { 653 validate_utf8_byte_limits(document)?; 654 let relays = array(document, "/relays")?; 655 let mut ids = BTreeSet::new(); 656 let mut urls = BTreeSet::new(); 657 let mut has_read = false; 658 let mut has_write = false; 659 let mut has_required_read = false; 660 let mut has_required_write = false; 661 for relay in relays { 662 let id = string_at(relay, "/id")?; 663 let raw_url = string_at(relay, "/url")?; 664 canonical_relay_url(raw_url, profile)?; 665 let read = bool_at(relay, "/read")?; 666 let write = bool_at(relay, "/write")?; 667 let required = bool_at(relay, "/required")?; 668 if !ids.insert(id) || !urls.insert(raw_url) || (!read && !write) { 669 return relationship_error(); 670 } 671 has_read |= read; 672 has_write |= write; 673 has_required_read |= required && read; 674 has_required_write |= required && write; 675 } 676 if !(has_read && has_write && has_required_read && has_required_write) { 677 return relationship_error(); 678 } 679 validate_unique_role_keys(document)?; 680 validate_client_policy(document)?; 681 validate_challenges(document)?; 682 validate_rate_limits(document)?; 683 validate_transport(document, relays)?; 684 validate_discovery(document, relays)?; 685 validate_operations(document) 686 } 687 688 fn validate_utf8_byte_limits(document: &Value) -> Result<(), MycConfigV1Error> { 689 validate_provider_bytes(document, "/identity/transport")?; 690 validate_provider_bytes(document, "/identity/user")?; 691 if bool_value(document, "/identity/discovery/enabled")? { 692 validate_provider_bytes(document, "/identity/discovery/binding")?; 693 } 694 for relay in array(document, "/relays")? { 695 validate_string_bytes(string_at(relay, "/id")?, 1, 64)?; 696 validate_string_bytes(string_at(relay, "/url")?, 1, 2_048)?; 697 } 698 if bool_value(document, "/operations/enabled")? { 699 validate_string_bytes(string(document, "/operations/listen")?, 1, 128)?; 700 } 701 if bool_value(document, "/policy/challenges/enabled")? { 702 validate_string_bytes(string(document, "/policy/challenges/url")?, 1, 2_048)?; 703 } 704 if bool_value(document, "/discovery/enabled")? { 705 for (pointer, minimum, maximum) in [ 706 ("/discovery/domain", 1, 253), 707 ("/discovery/handler_identifier", 1, 128), 708 ("/discovery/nostrconnect_url_template", 1, 2_048), 709 ("/discovery/metadata/name", 1, 128), 710 ("/discovery/metadata/display_name", 1, 128), 711 ("/discovery/metadata/about", 0, 1_024), 712 ("/discovery/metadata/website", 1, 2_048), 713 ("/discovery/metadata/picture", 1, 2_048), 714 ] { 715 validate_string_bytes(string(document, pointer)?, minimum, maximum)?; 716 } 717 } 718 Ok(()) 719 } 720 721 fn validate_provider_bytes(document: &Value, prefix: &str) -> Result<(), MycConfigV1Error> { 722 match string(document, &format!("{prefix}/provider"))? { 723 "encrypted_file" => { 724 validate_string_bytes( 725 string(document, &format!("{prefix}/envelope_path"))?, 726 1, 727 4_096, 728 )?; 729 validate_string_bytes( 730 string(document, &format!("{prefix}/credential_reference"))?, 731 1, 732 128, 733 ) 734 } 735 "local_signer" => validate_string_bytes( 736 string(document, &format!("{prefix}/socket_path"))?, 737 1, 738 4_096, 739 ), 740 _ => document_error(), 741 } 742 } 743 744 fn validate_string_bytes( 745 value: &str, 746 minimum: usize, 747 maximum: usize, 748 ) -> Result<(), MycConfigV1Error> { 749 if (minimum..=maximum).contains(&value.len()) { 750 Ok(()) 751 } else { 752 document_error() 753 } 754 } 755 756 fn validate_unique_role_keys(document: &Value) -> Result<(), MycConfigV1Error> { 757 let mut keys = vec![ 758 string(document, "/identity/transport/expected_public_key")?, 759 string(document, "/identity/user/expected_public_key")?, 760 ]; 761 if bool_value(document, "/identity/discovery/enabled")? { 762 keys.push(string( 763 document, 764 "/identity/discovery/binding/expected_public_key", 765 )?); 766 } 767 if keys.iter().any(|key| !valid_nostr_public_key(key)) 768 || keys.iter().copied().collect::<BTreeSet<_>>().len() != keys.len() 769 { 770 return relationship_error(); 771 } 772 Ok(()) 773 } 774 775 fn validate_client_policy(document: &Value) -> Result<(), MycConfigV1Error> { 776 let trusted = string_set(document, "/policy/trusted_clients")?; 777 let denied = string_set(document, "/policy/denied_clients")?; 778 if trusted 779 .iter() 780 .chain(denied.iter()) 781 .any(|key| !valid_nostr_public_key(key)) 782 || !trusted.is_disjoint(&denied) 783 { 784 return relationship_error(); 785 } 786 let permission_kinds = string_set(document, "/policy/permission_ceiling")? 787 .into_iter() 788 .filter_map(|permission| permission.strip_prefix("sign_event:kind:")) 789 .map(|kind| { 790 kind.parse::<u32>() 791 .map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument)) 792 }) 793 .collect::<Result<BTreeSet<_>, _>>()?; 794 let allowed_kinds = array(document, "/policy/allowed_sign_event_kinds")? 795 .iter() 796 .map(|kind| kind.as_u64().and_then(|value| u32::try_from(value).ok())) 797 .collect::<Option<BTreeSet<_>>>() 798 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument))?; 799 if permission_kinds != allowed_kinds { 800 return relationship_error(); 801 } 802 Ok(()) 803 } 804 805 fn valid_nostr_public_key(value: &str) -> bool { 806 PublicKey::from_hex(value).is_ok_and(|public_key| public_key.xonly().is_ok()) 807 } 808 809 fn validate_challenges(document: &Value) -> Result<(), MycConfigV1Error> { 810 if bool_value(document, "/policy/challenges/enabled")? 811 && integer(document, "/policy/challenges/authorized_lifetime_ms")? 812 < integer(document, "/policy/challenges/pending_lifetime_ms")? 813 { 814 return relationship_error(); 815 } 816 Ok(()) 817 } 818 819 fn validate_rate_limits(document: &Value) -> Result<(), MycConfigV1Error> { 820 for (name, expected_scope) in [ 821 ("connection_admission", "global_and_relay"), 822 ("challenge_creation", "connection"), 823 ("challenge_authorization", "connection"), 824 ] { 825 let prefix = format!("/rate_limits/{name}"); 826 if string(document, &format!("{prefix}/scope"))? != expected_scope 827 || integer(document, &format!("{prefix}/retention_ms"))? 828 < integer(document, &format!("{prefix}/window_ms"))? 829 { 830 return relationship_error(); 831 } 832 } 833 Ok(()) 834 } 835 836 fn validate_transport(document: &Value, relays: &[Value]) -> Result<(), MycConfigV1Error> { 837 if integer(document, "/transport/publish_retry/initial_backoff_ms")? 838 > integer(document, "/transport/publish_retry/maximum_backoff_ms")? 839 { 840 return relationship_error(); 841 } 842 if string(document, "/transport/delivery_policy/mode")? == "required_quorum" { 843 let required = integer( 844 document, 845 "/transport/delivery_policy/required_acknowledgements", 846 )?; 847 let required_writers = relays 848 .iter() 849 .filter(|relay| { 850 bool_at(relay, "/required") == Ok(true) && bool_at(relay, "/write") == Ok(true) 851 }) 852 .count() as u64; 853 if required > required_writers { 854 return relationship_error(); 855 } 856 } 857 Ok(()) 858 } 859 860 fn validate_discovery(document: &Value, relays: &[Value]) -> Result<(), MycConfigV1Error> { 861 let enabled = bool_value(document, "/discovery/enabled")?; 862 if enabled != bool_value(document, "/identity/discovery/enabled")? { 863 return relationship_error(); 864 } 865 if !enabled { 866 return Ok(()); 867 } 868 for (pointer, capability) in [ 869 ("/discovery/public_relay_ids", "read"), 870 ("/discovery/publish_relay_ids", "write"), 871 ] { 872 for relay_id in string_set(document, pointer)? { 873 if !relays.iter().any(|relay| { 874 string_at(relay, "/id") == Ok(relay_id) 875 && bool_at(relay, &format!("/{capability}")) == Ok(true) 876 }) { 877 return relationship_error(); 878 } 879 } 880 } 881 Ok(()) 882 } 883 884 fn validate_operations(document: &Value) -> Result<(), MycConfigV1Error> { 885 if !bool_value(document, "/operations/enabled")? { 886 return Ok(()); 887 } 888 let address = string(document, "/operations/listen")? 889 .parse::<SocketAddr>() 890 .map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument))?; 891 if address.port() == 0 892 || (string(document, "/operations/bind_policy")? == "loopback_only" 893 && !address.ip().is_loopback()) 894 { 895 return relationship_error(); 896 } 897 Ok(()) 898 } 899 900 fn canonical_relay_url(value: &str, profile: MycConfigProfile) -> Result<Url, MycConfigV1Error> { 901 let parsed = Url::parse(value).map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument))?; 902 if parsed.as_str() != value 903 || !parsed.username().is_empty() 904 || parsed.password().is_some() 905 || parsed.fragment().is_some() 906 { 907 return document_error(); 908 } 909 let allowed = match profile { 910 MycConfigProfile::Production => parsed.scheme() == "wss", 911 MycConfigProfile::RepoLocal => match parsed.scheme() { 912 "wss" => true, 913 "ws" => parsed 914 .host_str() 915 .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")), 916 _ => false, 917 }, 918 }; 919 if !allowed { 920 return relationship_error(); 921 } 922 Ok(parsed) 923 } 924 925 fn array<'a>(value: &'a Value, pointer: &str) -> Result<&'a [Value], MycConfigV1Error> { 926 value 927 .pointer(pointer) 928 .and_then(Value::as_array) 929 .map(Vec::as_slice) 930 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) 931 } 932 933 fn string<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, MycConfigV1Error> { 934 value 935 .pointer(pointer) 936 .and_then(Value::as_str) 937 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) 938 } 939 940 fn string_at<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, MycConfigV1Error> { 941 string(value, pointer) 942 } 943 944 fn string_set<'a>(value: &'a Value, pointer: &str) -> Result<BTreeSet<&'a str>, MycConfigV1Error> { 945 array(value, pointer)? 946 .iter() 947 .map(|entry| { 948 entry 949 .as_str() 950 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) 951 }) 952 .collect() 953 } 954 955 fn bool_value(value: &Value, pointer: &str) -> Result<bool, MycConfigV1Error> { 956 bool_at(value, pointer) 957 } 958 959 fn bool_at(value: &Value, pointer: &str) -> Result<bool, MycConfigV1Error> { 960 value 961 .pointer(pointer) 962 .and_then(Value::as_bool) 963 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) 964 } 965 966 fn integer(value: &Value, pointer: &str) -> Result<u64, MycConfigV1Error> { 967 value 968 .pointer(pointer) 969 .and_then(Value::as_u64) 970 .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) 971 } 972 973 fn document_error<T>() -> Result<T, MycConfigV1Error> { 974 Err(error(MycConfigV1ErrorKind::InvalidDocument)) 975 } 976 977 fn relationship_error<T>() -> Result<T, MycConfigV1Error> { 978 Err(error(MycConfigV1ErrorKind::InvalidRelationship)) 979 } 980 981 const fn error(kind: MycConfigV1ErrorKind) -> MycConfigV1Error { 982 MycConfigV1Error::new(kind) 983 } 984 985 #[derive(Serialize)] 986 struct EffectiveProjection { 987 schema: &'static str, 988 schema_version: u32, 989 fields: Vec<EffectiveField>, 990 } 991 992 #[derive(Serialize)] 993 struct EffectiveField { 994 path: String, 995 source: MycConfigValueSource, 996 value: Value, 997 } 998 999 fn build_effective( 1000 normalized: &Value, 1001 original: &toml::Table, 1002 ) -> Result<MycEffectiveConfigV1, MycConfigV1Error> { 1003 let mut flattened = BTreeMap::new(); 1004 flatten_value("", normalized, &mut flattened); 1005 let original = toml::Value::Table(original.clone()); 1006 let fields = flattened 1007 .into_iter() 1008 .map(|(path, value)| EffectiveField { 1009 source: default_entry(&path).map_or(MycConfigValueSource::Document, |entry| { 1010 if toml_path(&original, &path).is_some() { 1011 MycConfigValueSource::Document 1012 } else { 1013 entry.source 1014 } 1015 }), 1016 value: redacted_value(&path, value), 1017 path, 1018 }) 1019 .collect::<Vec<_>>(); 1020 let field_count = fields.len(); 1021 let canonical_json = serde_json::to_string(&EffectiveProjection { 1022 schema: "radroots.myc.effective-config", 1023 schema_version: 1, 1024 fields, 1025 }) 1026 .map_err(|_| error(MycConfigV1ErrorKind::Encoding))? 1027 .into_boxed_str(); 1028 Ok(MycEffectiveConfigV1 { 1029 canonical_json, 1030 field_count, 1031 }) 1032 } 1033 1034 fn flatten_value(path: &str, value: &Value, fields: &mut BTreeMap<String, Value>) { 1035 match value { 1036 Value::Object(object) => { 1037 for (key, child) in object { 1038 flatten_value(&format!("{path}/{key}"), child, fields); 1039 } 1040 } 1041 Value::Array(array) if array.iter().all(Value::is_object) => { 1042 for (index, child) in array.iter().enumerate() { 1043 flatten_value(&format!("{path}/{index}"), child, fields); 1044 } 1045 } 1046 _ => { 1047 fields.insert(path.to_owned(), value.clone()); 1048 } 1049 } 1050 } 1051 1052 fn redacted_value(path: &str, value: Value) -> Value { 1053 let scalar_redaction = if path.ends_with("/envelope_path") || path.ends_with("/socket_path") { 1054 Some("[redacted-path]") 1055 } else if path.ends_with("/credential_reference") { 1056 Some("[redacted-credential-reference]") 1057 } else if path.ends_with("/expected_public_key") { 1058 Some("[redacted-public-key]") 1059 } else if path == "/operations/listen" { 1060 Some("[redacted-address]") 1061 } else if path.starts_with("/relays/") && path.ends_with("/id") { 1062 Some("[redacted-relay-id]") 1063 } else if (path.starts_with("/relays/") && path.ends_with("/url")) 1064 || path == "/policy/challenges/url" 1065 { 1066 Some("[redacted-url]") 1067 } else if matches!( 1068 path, 1069 "/discovery/domain" 1070 | "/discovery/handler_identifier" 1071 | "/discovery/nostrconnect_url_template" 1072 | "/discovery/metadata/name" 1073 | "/discovery/metadata/display_name" 1074 | "/discovery/metadata/about" 1075 | "/discovery/metadata/website" 1076 | "/discovery/metadata/picture" 1077 ) { 1078 Some("[redacted]") 1079 } else { 1080 None 1081 }; 1082 if let Some(redaction) = scalar_redaction { 1083 return Value::String(redaction.to_owned()); 1084 } 1085 if matches!( 1086 path, 1087 "/policy/trusted_clients" 1088 | "/policy/denied_clients" 1089 | "/discovery/public_relay_ids" 1090 | "/discovery/publish_relay_ids" 1091 ) { 1092 return json!({ 1093 "count": value.as_array().map_or(0, Vec::len), 1094 "values": "[redacted]" 1095 }); 1096 } 1097 value 1098 } 1099 1100 fn default_entry(path: &str) -> Option<&'static DefaultEntry> { 1101 DEFAULTS.iter().find(|entry| entry.path == path) 1102 } 1103 1104 fn toml_path<'a>(root: &'a toml::Value, path: &str) -> Option<&'a toml::Value> { 1105 path.split('/') 1106 .filter(|part| !part.is_empty()) 1107 .try_fold(root, |value, part| { 1108 if let Ok(index) = part.parse::<usize>() { 1109 value.as_array()?.get(index) 1110 } else { 1111 value.as_table()?.get(part) 1112 } 1113 }) 1114 } 1115 1116 #[cfg(test)] 1117 mod tests { 1118 use super::*; 1119 1120 const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); 1121 1122 fn parse(source: &str) -> Result<MycConfigDocumentV1, MycConfigV1Error> { 1123 parse_myc_config_v1(source.as_bytes(), MycConfigProfile::Production) 1124 } 1125 1126 fn replace(source: &str, old: &str, new: &str) -> String { 1127 assert!(source.contains(old), "missing fixture fragment: {old}"); 1128 source.replacen(old, new, 1) 1129 } 1130 1131 #[test] 1132 fn canonical_example_is_deterministic_and_redacted() { 1133 let first = parse(EXAMPLE).expect("canonical example"); 1134 let second = parse(EXAMPLE).expect("canonical example again"); 1135 assert_eq!(first.schema(), MYC_CONFIG_SCHEMA); 1136 assert_eq!(first.schema_version(), MYC_CONFIG_SCHEMA_VERSION); 1137 assert_eq!(first.profile(), MycConfigProfile::Production); 1138 assert_eq!(first.relay_count(), 2); 1139 assert_eq!(first.effective(), second.effective()); 1140 assert!(first.effective().field_count() > 80); 1141 let output = first.effective().canonical_json(); 1142 assert!(output.starts_with( 1143 "{\"schema\":\"radroots.myc.effective-config\",\"schema_version\":1,\"fields\":[" 1144 )); 1145 for forbidden in [ 1146 "/var/lib/radroots", 1147 "/run/radroots", 1148 "4444444444444444", 1149 "7777777777777777", 1150 "relay-primary.example.test", 1151 "myc.example.test", 1152 "transport_wrapping_key", 1153 "Radroots Myc", 1154 ] { 1155 assert!(!output.contains(forbidden), "leaked {forbidden}"); 1156 assert!(!format!("{first:?}").contains(forbidden)); 1157 } 1158 } 1159 1160 #[test] 1161 fn exact_document_bound_precedes_parsing() { 1162 let mut exact = EXAMPLE.as_bytes().to_vec(); 1163 exact.extend_from_slice(b"\n#"); 1164 exact.resize(MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, b'a'); 1165 assert!(parse_myc_config_v1(&exact, MycConfigProfile::Production).is_ok()); 1166 exact.push(b'a'); 1167 assert_eq!( 1168 parse_myc_config_v1(&exact, MycConfigProfile::Production) 1169 .unwrap_err() 1170 .kind(), 1171 MycConfigV1ErrorKind::TooLarge 1172 ); 1173 } 1174 1175 #[test] 1176 fn invalid_utf8_duplicate_null_unknown_and_malformed_wire_fail() { 1177 assert_eq!( 1178 parse_myc_config_v1(&[0xff], MycConfigProfile::Production) 1179 .unwrap_err() 1180 .kind(), 1181 MycConfigV1ErrorKind::InvalidUtf8 1182 ); 1183 for source in [ 1184 format!("schema = \"radroots.myc.config\"\n{EXAMPLE}"), 1185 replace( 1186 EXAMPLE, 1187 "shutdown_grace_ms = 30000", 1188 "shutdown_grace_ms = null", 1189 ), 1190 replace( 1191 EXAMPLE, 1192 "shutdown_grace_ms = 30000", 1193 "shutdown_grace_ms = [null]", 1194 ), 1195 ] { 1196 assert_eq!( 1197 parse(&source).unwrap_err().kind(), 1198 MycConfigV1ErrorKind::MalformedToml 1199 ); 1200 } 1201 let unknown = replace( 1202 EXAMPLE, 1203 "shutdown_grace_ms = 30000", 1204 "shutdown_grace_ms = 30000\nsecret = \"do-not-render\"", 1205 ); 1206 assert_eq!( 1207 parse(&unknown).unwrap_err().kind(), 1208 MycConfigV1ErrorKind::InvalidDocument 1209 ); 1210 let nested_duplicate = replace( 1211 EXAMPLE, 1212 "busy_timeout_ms = 5000", 1213 "busy_timeout_ms = 5000\nbusy_timeout_ms = 5000", 1214 ); 1215 assert_eq!( 1216 parse(&nested_duplicate).unwrap_err().kind(), 1217 MycConfigV1ErrorKind::MalformedToml 1218 ); 1219 let mut table = EXAMPLE.parse::<toml::Table>().expect("example table"); 1220 table.remove("resource_limits"); 1221 assert_eq!( 1222 parse(&toml::to_string(&table).expect("missing-table TOML")) 1223 .unwrap_err() 1224 .kind(), 1225 MycConfigV1ErrorKind::InvalidDocument 1226 ); 1227 } 1228 1229 #[test] 1230 fn header_failures_are_classified_before_document_admission() { 1231 for (source, expected) in [ 1232 ( 1233 EXAMPLE.replace("schema = \"radroots.myc.config\"\n", ""), 1234 MycConfigV1ErrorKind::MissingSchema, 1235 ), 1236 ( 1237 replace(EXAMPLE, "schema = \"radroots.myc.config\"", "schema = 1"), 1238 MycConfigV1ErrorKind::InvalidSchema, 1239 ), 1240 ( 1241 replace(EXAMPLE, "radroots.myc.config", "radroots.rhi.config"), 1242 MycConfigV1ErrorKind::SchemaMismatch, 1243 ), 1244 ( 1245 EXAMPLE.replace("schema_version = 1\n", ""), 1246 MycConfigV1ErrorKind::MissingSchemaVersion, 1247 ), 1248 ( 1249 replace(EXAMPLE, "schema_version = 1", "schema_version = \"1\""), 1250 MycConfigV1ErrorKind::InvalidSchemaVersion, 1251 ), 1252 ( 1253 replace(EXAMPLE, "schema_version = 1", "schema_version = 2"), 1254 MycConfigV1ErrorKind::UnsupportedSchemaVersion, 1255 ), 1256 ] { 1257 assert_eq!(parse(&source).unwrap_err().kind(), expected); 1258 } 1259 } 1260 1261 #[test] 1262 fn production_and_repo_local_relay_postures_are_distinct() { 1263 let local = replace( 1264 EXAMPLE, 1265 "wss://relay-primary.example.test/", 1266 "ws://127.0.0.1:7777/", 1267 ); 1268 assert_eq!( 1269 parse(&local).unwrap_err().kind(), 1270 MycConfigV1ErrorKind::InvalidRelationship 1271 ); 1272 assert!(parse_myc_config_v1(local.as_bytes(), MycConfigProfile::RepoLocal).is_ok()); 1273 let remote = replace(&local, "ws://127.0.0.1:7777/", "ws://relay.example.test/"); 1274 assert_eq!( 1275 parse_myc_config_v1(remote.as_bytes(), MycConfigProfile::RepoLocal) 1276 .unwrap_err() 1277 .kind(), 1278 MycConfigV1ErrorKind::InvalidDocument 1279 ); 1280 } 1281 1282 #[test] 1283 fn semantic_relationship_failures_are_rejected() { 1284 let cases = [ 1285 replace(EXAMPLE, "id = \"secondary\"", "id = \"primary\""), 1286 replace( 1287 EXAMPLE, 1288 "url = \"wss://relay-secondary.example.test/\"", 1289 "url = \"wss://relay-primary.example.test/\"", 1290 ), 1291 replace( 1292 EXAMPLE, 1293 "trusted_clients = [\"7777777777777777777777777777777777777777777777777777777777777777\"]", 1294 "trusted_clients = [\"8888888888888888888888888888888888888888888888888888888888888888\"]", 1295 ), 1296 replace( 1297 EXAMPLE, 1298 "expected_public_key = \"3333333333333333333333333333333333333333333333333333333333333333\"", 1299 "expected_public_key = \"2222222222222222222222222222222222222222222222222222222222222222\"", 1300 ), 1301 replace( 1302 EXAMPLE, 1303 "allowed_sign_event_kinds = [1]", 1304 "allowed_sign_event_kinds = [2]", 1305 ), 1306 replace( 1307 EXAMPLE, 1308 "authorized_lifetime_ms = 3600000", 1309 "authorized_lifetime_ms = 1000", 1310 ), 1311 replace(EXAMPLE, "retention_ms = 3600000", "retention_ms = 1"), 1312 replace( 1313 EXAMPLE, 1314 "initial_backoff_ms = 250", 1315 "initial_backoff_ms = 30001", 1316 ), 1317 replace( 1318 EXAMPLE, 1319 "public_relay_ids = [\"primary\", \"secondary\"]", 1320 "public_relay_ids = [\"missing\"]", 1321 ), 1322 replace( 1323 EXAMPLE, 1324 "[discovery]\nenabled = true", 1325 "[discovery]\nenabled = false", 1326 ), 1327 ]; 1328 for source in cases { 1329 assert!(matches!( 1330 parse(&source).unwrap_err().kind(), 1331 MycConfigV1ErrorKind::InvalidDocument | MycConfigV1ErrorKind::InvalidRelationship 1332 )); 1333 } 1334 } 1335 1336 #[test] 1337 fn defaults_have_exact_sources_and_explicit_values_override_them() { 1338 assert_eq!(DEFAULTS.len(), 41); 1339 let mut table = EXAMPLE.parse::<toml::Table>().expect("example TOML"); 1340 for entry in DEFAULTS { 1341 remove_toml_path(&mut table, entry.path); 1342 } 1343 let minimal = toml::to_string(&table).expect("minimal TOML"); 1344 let parsed = parse(&minimal).expect("defaults admitted"); 1345 let output = parsed.effective().canonical_json(); 1346 for source in [ 1347 "engineering_safety", 1348 "accepted_service_authority", 1349 "radroots_service_sqlite", 1350 "radroots_service_host", 1351 "radroots_event", 1352 "radroots_nostr_connect", 1353 ] { 1354 assert!(output.contains(&format!("\"source\":\"{source}\""))); 1355 } 1356 let explicit = parse(EXAMPLE).expect("explicit example"); 1357 assert!( 1358 explicit 1359 .effective() 1360 .canonical_json() 1361 .matches("\"source\":\"document\"") 1362 .count() 1363 > output.matches("\"source\":\"document\"").count() 1364 ); 1365 } 1366 1367 #[test] 1368 fn errors_and_debug_are_source_free() { 1369 let secret = "credential-secret-value"; 1370 let source = replace( 1371 EXAMPLE, 1372 "shutdown_grace_ms = 30000", 1373 &format!("unknown = \"{secret}\""), 1374 ); 1375 let failure = parse(&source).unwrap_err(); 1376 let rendered = format!("{failure} {failure:?}"); 1377 assert!(!rendered.contains(secret)); 1378 assert!(Error::source(&failure).is_none()); 1379 } 1380 1381 #[test] 1382 fn annotated_utf8_limits_are_measured_in_bytes() { 1383 let exact_name = "é".repeat(64); 1384 let exact = replace( 1385 EXAMPLE, 1386 "name = \"myc\"", 1387 &format!("name = \"{exact_name}\""), 1388 ); 1389 assert!(parse(&exact).is_ok()); 1390 let over_name = "é".repeat(65); 1391 let over = replace( 1392 EXAMPLE, 1393 "name = \"myc\"", 1394 &format!("name = \"{over_name}\""), 1395 ); 1396 assert_eq!( 1397 parse(&over).unwrap_err().kind(), 1398 MycConfigV1ErrorKind::InvalidDocument 1399 ); 1400 } 1401 1402 fn remove_toml_path(table: &mut toml::Table, pointer: &str) { 1403 let mut parts = pointer 1404 .split('/') 1405 .filter(|part| !part.is_empty()) 1406 .peekable(); 1407 let mut current = table; 1408 while let Some(part) = parts.next() { 1409 if parts.peek().is_none() { 1410 current.remove(part); 1411 return; 1412 } 1413 let Some(next) = current.get_mut(part).and_then(toml::Value::as_table_mut) else { 1414 return; 1415 }; 1416 current = next; 1417 } 1418 } 1419 }