config.rs (29044B)
1 use crate::host_nostr::Metadata; 2 use anyhow::{Context, Result, bail}; 3 use radroots_event::profile::{AuthoredProfile, Nip05Identifier}; 4 use serde::{Deserialize, Serialize}; 5 use std::path::{Path, PathBuf}; 6 7 #[cfg(not(test))] 8 use super::paths::process_path_selection; 9 use super::paths::{ 10 PathProfile, PathResolver, RadrootsdRuntimePaths, default_transport_publish_database_path, 11 resolve_runtime_paths_with_resolver, 12 }; 13 14 #[derive(Debug, Serialize, Deserialize, Clone)] 15 pub struct NostrServiceConfig { 16 pub logs_dir: String, 17 #[serde(default)] 18 pub relays: Vec<String>, 19 #[serde(default)] 20 pub nip89_identifier: Option<String>, 21 #[serde(default)] 22 pub nip89_extra_tags: Vec<Vec<String>>, 23 } 24 25 fn default_rpc_addr() -> String { 26 "127.0.0.1:7070".to_string() 27 } 28 29 fn default_max_request_body_size() -> u32 { 30 10 * 1024 * 1024 31 } 32 33 fn default_max_response_body_size() -> u32 { 34 10 * 1024 * 1024 35 } 36 37 fn default_max_connections() -> u32 { 38 100 39 } 40 41 fn default_max_subscriptions_per_connection() -> u32 { 42 1024 43 } 44 45 fn default_message_buffer_capacity() -> u32 { 46 1024 47 } 48 49 fn default_rpc_batch_request_limit() -> Option<u32> { 50 Some(0) 51 } 52 53 fn default_nip46_session_ttl_secs() -> u64 { 54 900 55 } 56 57 fn default_nip46_perms() -> Vec<String> { 58 Vec::new() 59 } 60 61 fn default_nip46_public_jsonrpc_enabled() -> bool { 62 false 63 } 64 65 fn default_transport_publish_enabled() -> bool { 66 true 67 } 68 69 fn default_transport_publish_connect_timeout_secs() -> u64 { 70 10 71 } 72 73 fn default_transport_publish_max_event_bytes() -> usize { 74 128 * 1024 75 } 76 77 fn default_transport_publish_max_targets_per_request() -> usize { 78 20 79 } 80 81 fn default_transport_publish_job_list_limit() -> usize { 82 100 83 } 84 85 fn default_transport_publish_max_concurrent_publish_jobs() -> usize { 86 8 87 } 88 89 fn default_nostr_relay_url_policy() -> NostrRelayUrlPolicy { 90 NostrRelayUrlPolicy::Public 91 } 92 93 #[derive(Debug, Deserialize, Clone, Default)] 94 struct RawServiceConfig { 95 #[serde(default)] 96 pub logs_dir: Option<String>, 97 #[serde(default)] 98 pub relays: Vec<String>, 99 #[serde(default)] 100 pub nip89_identifier: Option<String>, 101 #[serde(default)] 102 pub nip89_extra_tags: Vec<Vec<String>>, 103 } 104 105 impl RawServiceConfig { 106 fn into_service_config(self, paths: &RadrootsdRuntimePaths) -> NostrServiceConfig { 107 NostrServiceConfig { 108 logs_dir: self 109 .logs_dir 110 .unwrap_or_else(|| paths.logs_dir.display().to_string()), 111 relays: self.relays, 112 nip89_identifier: self.nip89_identifier, 113 nip89_extra_tags: self.nip89_extra_tags, 114 } 115 } 116 } 117 118 #[derive(Debug, Deserialize, Clone)] 119 #[serde(deny_unknown_fields)] 120 struct RawTransportPublishConfig { 121 #[serde(default = "default_transport_publish_enabled")] 122 pub enabled: bool, 123 #[serde(default = "default_transport_publish_connect_timeout_secs")] 124 pub connect_timeout_secs: u64, 125 #[serde(default = "default_transport_publish_max_event_bytes")] 126 pub max_event_bytes: usize, 127 #[serde(default = "default_transport_publish_max_targets_per_request")] 128 pub max_targets_per_request: usize, 129 #[serde(default = "default_transport_publish_job_list_limit")] 130 pub job_list_limit: usize, 131 #[serde(default = "default_transport_publish_max_concurrent_publish_jobs")] 132 pub max_concurrent_publish_jobs: usize, 133 #[serde(default)] 134 pub database_path: Option<PathBuf>, 135 #[serde(default)] 136 pub nostr: TransportPublishNostrConfig, 137 } 138 139 impl Default for RawTransportPublishConfig { 140 fn default() -> Self { 141 Self { 142 enabled: default_transport_publish_enabled(), 143 connect_timeout_secs: default_transport_publish_connect_timeout_secs(), 144 max_event_bytes: default_transport_publish_max_event_bytes(), 145 max_targets_per_request: default_transport_publish_max_targets_per_request(), 146 job_list_limit: default_transport_publish_job_list_limit(), 147 max_concurrent_publish_jobs: default_transport_publish_max_concurrent_publish_jobs(), 148 database_path: None, 149 nostr: TransportPublishNostrConfig::default(), 150 } 151 } 152 } 153 154 impl RawTransportPublishConfig { 155 fn into_transport_publish_config( 156 self, 157 paths: &RadrootsdRuntimePaths, 158 ) -> TransportPublishConfig { 159 TransportPublishConfig { 160 enabled: self.enabled, 161 connect_timeout_secs: self.connect_timeout_secs, 162 max_event_bytes: self.max_event_bytes, 163 max_targets_per_request: self.max_targets_per_request, 164 job_list_limit: self.job_list_limit, 165 max_concurrent_publish_jobs: self.max_concurrent_publish_jobs, 166 database_path: self 167 .database_path 168 .unwrap_or_else(|| paths.transport_publish_database_path.clone()), 169 nostr: self.nostr, 170 } 171 } 172 } 173 174 #[derive(Debug, Deserialize, Clone)] 175 #[serde(deny_unknown_fields)] 176 struct RawConfiguration { 177 #[serde(flatten)] 178 pub service: RawServiceConfig, 179 #[serde(default)] 180 pub rpc: RpcConfig, 181 #[serde(default)] 182 pub rpc_addr: Option<String>, 183 #[serde(default)] 184 pub nip46: Nip46Config, 185 #[serde(default)] 186 pub transport_publish: RawTransportPublishConfig, 187 } 188 189 #[derive(Debug, Deserialize, Clone)] 190 struct RawSettings { 191 pub metadata: Metadata, 192 pub config: RawConfiguration, 193 } 194 195 impl RawSettings { 196 fn into_settings(self, paths: &RadrootsdRuntimePaths) -> Settings { 197 Settings { 198 metadata: self.metadata, 199 config: Configuration { 200 service: self.config.service.into_service_config(paths), 201 rpc: self.config.rpc, 202 rpc_addr: self.config.rpc_addr, 203 nip46: self.config.nip46, 204 transport_publish: self 205 .config 206 .transport_publish 207 .into_transport_publish_config(paths), 208 }, 209 } 210 } 211 } 212 213 fn load_settings_from_path_with_resolver( 214 path: &Path, 215 resolver: &PathResolver, 216 profile: PathProfile, 217 repo_local_root: Option<&Path>, 218 ) -> Result<Settings> { 219 let source = std::fs::read_to_string(path) 220 .with_context(|| format!("read configuration from {}", path.display()))?; 221 let raw: RawSettings = toml::from_str(source.as_str()) 222 .with_context(|| format!("parse configuration from {}", path.display()))?; 223 let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?; 224 let settings = raw.into_settings(&paths); 225 settings.validate()?; 226 Ok(settings) 227 } 228 229 #[cfg(not(test))] 230 pub fn load_settings_from_path(path: impl AsRef<Path>) -> Result<Settings> { 231 let path = path.as_ref(); 232 let (profile, repo_local_root) = process_path_selection()?; 233 load_settings_from_path_with_resolver( 234 path, 235 &PathResolver::current(), 236 profile, 237 repo_local_root.as_deref(), 238 ) 239 } 240 241 #[derive(Debug, Serialize, Deserialize, Clone)] 242 pub struct Nip46Config { 243 #[serde(default = "default_nip46_session_ttl_secs")] 244 pub session_ttl_secs: u64, 245 #[serde(default = "default_nip46_perms")] 246 pub perms: Vec<String>, 247 #[serde(default = "default_nip46_public_jsonrpc_enabled")] 248 pub public_jsonrpc_enabled: bool, 249 #[serde(default)] 250 pub nostrconnect_url: Option<String>, 251 } 252 253 impl Default for Nip46Config { 254 fn default() -> Self { 255 Self { 256 session_ttl_secs: default_nip46_session_ttl_secs(), 257 perms: default_nip46_perms(), 258 public_jsonrpc_enabled: default_nip46_public_jsonrpc_enabled(), 259 nostrconnect_url: None, 260 } 261 } 262 } 263 264 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] 265 #[serde(rename_all = "snake_case")] 266 pub enum NostrRelayUrlPolicy { 267 Public, 268 Localhost, 269 } 270 271 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] 272 #[serde(deny_unknown_fields)] 273 pub struct TransportPublishNostrConfig { 274 #[serde(default = "default_nostr_relay_url_policy")] 275 pub relay_url_policy: NostrRelayUrlPolicy, 276 #[serde(default)] 277 pub author_relay_discovery_relays: Vec<String>, 278 #[serde(default)] 279 pub daemon_default_relays: Vec<String>, 280 } 281 282 impl Default for TransportPublishNostrConfig { 283 fn default() -> Self { 284 Self { 285 relay_url_policy: default_nostr_relay_url_policy(), 286 author_relay_discovery_relays: Vec::new(), 287 daemon_default_relays: Vec::new(), 288 } 289 } 290 } 291 292 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] 293 #[serde(deny_unknown_fields)] 294 pub struct TransportPublishConfig { 295 #[serde(default = "default_transport_publish_enabled")] 296 pub enabled: bool, 297 #[serde(default = "default_transport_publish_connect_timeout_secs")] 298 pub connect_timeout_secs: u64, 299 #[serde(default = "default_transport_publish_max_event_bytes")] 300 pub max_event_bytes: usize, 301 #[serde(default = "default_transport_publish_max_targets_per_request")] 302 pub max_targets_per_request: usize, 303 #[serde(default = "default_transport_publish_job_list_limit")] 304 pub job_list_limit: usize, 305 #[serde(default = "default_transport_publish_max_concurrent_publish_jobs")] 306 pub max_concurrent_publish_jobs: usize, 307 #[serde(default = "default_transport_publish_database_path")] 308 pub database_path: PathBuf, 309 #[serde(default)] 310 pub nostr: TransportPublishNostrConfig, 311 } 312 313 impl Default for TransportPublishConfig { 314 fn default() -> Self { 315 Self { 316 enabled: default_transport_publish_enabled(), 317 connect_timeout_secs: default_transport_publish_connect_timeout_secs(), 318 max_event_bytes: default_transport_publish_max_event_bytes(), 319 max_targets_per_request: default_transport_publish_max_targets_per_request(), 320 job_list_limit: default_transport_publish_job_list_limit(), 321 max_concurrent_publish_jobs: default_transport_publish_max_concurrent_publish_jobs(), 322 database_path: default_transport_publish_database_path(), 323 nostr: TransportPublishNostrConfig::default(), 324 } 325 } 326 } 327 328 impl TransportPublishConfig { 329 pub fn validate(&self) -> Result<()> { 330 if self.max_event_bytes == 0 { 331 bail!("transport_publish max_event_bytes must be greater than zero"); 332 } 333 if self.max_targets_per_request == 0 { 334 bail!("transport_publish max_targets_per_request must be greater than zero"); 335 } 336 if self.job_list_limit == 0 { 337 bail!("transport_publish job_list_limit must be greater than zero"); 338 } 339 if self.max_concurrent_publish_jobs == 0 { 340 bail!("transport_publish max_concurrent_publish_jobs must be greater than zero"); 341 } 342 if self.connect_timeout_secs == 0 { 343 bail!("transport_publish connect_timeout_secs must be greater than zero"); 344 } 345 Ok(()) 346 } 347 } 348 349 #[derive(Debug, Serialize, Deserialize, Clone)] 350 pub struct RpcConfig { 351 #[serde(default = "default_rpc_addr")] 352 pub addr: String, 353 #[serde(default = "default_max_request_body_size")] 354 pub max_request_body_size: u32, 355 #[serde(default = "default_max_response_body_size")] 356 pub max_response_body_size: u32, 357 #[serde(default = "default_max_connections")] 358 pub max_connections: u32, 359 #[serde(default = "default_max_subscriptions_per_connection")] 360 pub max_subscriptions_per_connection: u32, 361 #[serde(default = "default_message_buffer_capacity")] 362 pub message_buffer_capacity: u32, 363 #[serde(default = "default_rpc_batch_request_limit")] 364 pub batch_request_limit: Option<u32>, 365 } 366 367 impl Default for RpcConfig { 368 fn default() -> Self { 369 Self { 370 addr: default_rpc_addr(), 371 max_request_body_size: default_max_request_body_size(), 372 max_response_body_size: default_max_response_body_size(), 373 max_connections: default_max_connections(), 374 max_subscriptions_per_connection: default_max_subscriptions_per_connection(), 375 message_buffer_capacity: default_message_buffer_capacity(), 376 batch_request_limit: default_rpc_batch_request_limit(), 377 } 378 } 379 } 380 381 #[derive(Debug, Serialize, Deserialize, Clone)] 382 pub struct Configuration { 383 #[serde(flatten)] 384 pub service: NostrServiceConfig, 385 #[serde(default)] 386 pub rpc: RpcConfig, 387 #[serde(default)] 388 pub rpc_addr: Option<String>, 389 #[serde(default)] 390 pub nip46: Nip46Config, 391 #[serde(default)] 392 pub transport_publish: TransportPublishConfig, 393 } 394 395 impl Configuration { 396 pub fn rpc_addr(&self) -> &str { 397 self.rpc_addr.as_deref().unwrap_or(self.rpc.addr.as_str()) 398 } 399 400 pub fn validate(&self) -> Result<()> { 401 self.transport_publish.validate()?; 402 Ok(()) 403 } 404 } 405 406 #[derive(Debug, Clone, Serialize, Deserialize)] 407 pub struct Settings { 408 pub metadata: Metadata, 409 pub config: Configuration, 410 } 411 412 impl Settings { 413 pub fn authored_profile(&self) -> Result<AuthoredProfile> { 414 let metadata = &self.metadata; 415 let name = metadata 416 .name 417 .as_deref() 418 .ok_or_else(|| anyhow::anyhow!("metadata.name is required for the authored Profile"))?; 419 let mut profile = AuthoredProfile::new(name.to_owned()) 420 .context("metadata.name is invalid for the authored Profile")?; 421 422 if let Some(display_name) = metadata.display_name.as_ref() { 423 profile = profile.with_display_name(display_name.clone()); 424 } 425 if let Some(about) = metadata.about.as_ref() { 426 profile = profile.with_about(about.clone()); 427 } 428 if let Some(nip05) = metadata.nip05.as_deref() { 429 let nip05 = Nip05Identifier::parse(nip05) 430 .context("metadata.nip05 is invalid for the authored Profile")?; 431 profile = profile.with_nip05(nip05); 432 } 433 434 let media_fields = [ 435 metadata.picture.as_ref().map(|_| "picture"), 436 metadata.banner.as_ref().map(|_| "banner"), 437 ] 438 .into_iter() 439 .flatten() 440 .collect::<Vec<_>>(); 441 if !media_fields.is_empty() { 442 bail!( 443 "metadata.{} cannot be authored from URL-only configuration; Profile media requires a byte-verified Blossom descriptor", 444 media_fields.join(" and metadata.") 445 ); 446 } 447 448 let bot = match metadata.custom.get("bot") { 449 Some(serde_json::Value::Bool(value)) => Some(*value), 450 Some(_) => bail!("metadata.bot must be a Boolean for the authored Profile"), 451 None => None, 452 }; 453 if let Some(bot) = bot { 454 profile = profile.with_bot(bot); 455 } 456 457 let mut unsupported_fields = Vec::new(); 458 if metadata.website.is_some() { 459 unsupported_fields.push("website".to_owned()); 460 } 461 if metadata.lud06.is_some() { 462 unsupported_fields.push("lud06".to_owned()); 463 } 464 if metadata.lud16.is_some() { 465 unsupported_fields.push("lud16".to_owned()); 466 } 467 unsupported_fields.extend( 468 metadata 469 .custom 470 .keys() 471 .filter(|key| key.as_str() != "bot") 472 .cloned(), 473 ); 474 if !unsupported_fields.is_empty() { 475 bail!( 476 "metadata fields are not supported by the strict authored Profile contract: {}", 477 unsupported_fields.join(", ") 478 ); 479 } 480 481 Ok(profile) 482 } 483 484 pub fn validate(&self) -> Result<()> { 485 self.config.validate()?; 486 self.authored_profile()?; 487 Ok(()) 488 } 489 } 490 491 #[cfg(test)] 492 mod tests { 493 use std::path::PathBuf; 494 495 use super::NostrServiceConfig; 496 use super::{ 497 Configuration, Nip46Config, NostrRelayUrlPolicy, RpcConfig, TransportPublishConfig, 498 load_settings_from_path_with_resolver, 499 }; 500 use crate::app::paths::{ 501 HostEnvironment, PathProfile, PathResolver, Platform, RuntimePathSelection, 502 }; 503 use crate::app::paths::{ 504 RadrootsdRuntimeContractOutput, default_runtime_paths_for_process, 505 resolve_runtime_paths_with_resolver, runtime_contract_with_selection, 506 }; 507 use radroots_event::profile::Nip05Identifier; 508 use serde_json::json; 509 510 fn linux_resolver(home: &str) -> PathResolver { 511 PathResolver::new( 512 Platform::Linux, 513 HostEnvironment { 514 home_dir: Some(PathBuf::from(home)), 515 ..HostEnvironment::default() 516 }, 517 ) 518 } 519 520 fn service_config() -> NostrServiceConfig { 521 let paths = resolve_runtime_paths_with_resolver( 522 &linux_resolver("/home/treesap"), 523 PathProfile::InteractiveUser, 524 None, 525 ) 526 .expect("resolve interactive-user paths"); 527 NostrServiceConfig { 528 logs_dir: paths.logs_dir.display().to_string(), 529 relays: Vec::new(), 530 nip89_identifier: Some("radrootsd".to_string()), 531 nip89_extra_tags: Vec::new(), 532 } 533 } 534 535 fn runtime_contract_with_resolver( 536 resolver: &PathResolver, 537 profile: PathProfile, 538 repo_local_root: Option<&std::path::Path>, 539 ) -> anyhow::Result<RadrootsdRuntimeContractOutput> { 540 runtime_contract_with_selection( 541 resolver, 542 &RuntimePathSelection::caller(profile, repo_local_root.map(PathBuf::from)), 543 ) 544 } 545 546 #[test] 547 fn nip46_defaults_are_expected() { 548 let cfg = Nip46Config::default(); 549 assert_eq!(cfg.session_ttl_secs, 900); 550 assert!(cfg.perms.is_empty()); 551 assert!(!cfg.public_jsonrpc_enabled); 552 assert!(cfg.nostrconnect_url.is_none()); 553 } 554 555 #[test] 556 fn rpc_defaults_disable_batches() { 557 let cfg = RpcConfig::default(); 558 assert_eq!(cfg.addr, "127.0.0.1:7070"); 559 assert_eq!(cfg.batch_request_limit, Some(0)); 560 } 561 562 #[test] 563 fn transport_publish_defaults_are_expected() { 564 let paths = default_runtime_paths_for_process().expect("resolve process runtime paths"); 565 let cfg = TransportPublishConfig::default(); 566 assert!(cfg.enabled); 567 assert_eq!(cfg.connect_timeout_secs, 10); 568 assert_eq!(cfg.max_event_bytes, 128 * 1024); 569 assert_eq!(cfg.max_targets_per_request, 20); 570 assert_eq!(cfg.job_list_limit, 100); 571 assert_eq!(cfg.max_concurrent_publish_jobs, 8); 572 assert_eq!(cfg.database_path, paths.transport_publish_database_path); 573 assert_eq!(cfg.nostr.relay_url_policy, NostrRelayUrlPolicy::Public); 574 assert!(cfg.nostr.author_relay_discovery_relays.is_empty()); 575 assert!(cfg.nostr.daemon_default_relays.is_empty()); 576 } 577 578 #[test] 579 fn rpc_addr_prefers_override() { 580 let mut cfg = Configuration { 581 service: service_config(), 582 rpc: RpcConfig { 583 addr: "127.0.0.1:1111".to_string(), 584 ..RpcConfig::default() 585 }, 586 rpc_addr: None, 587 nip46: Nip46Config::default(), 588 transport_publish: TransportPublishConfig::default(), 589 }; 590 assert_eq!(cfg.rpc_addr(), "127.0.0.1:1111"); 591 cfg.rpc_addr = Some("127.0.0.1:2222".to_string()); 592 assert_eq!(cfg.rpc_addr(), "127.0.0.1:2222"); 593 } 594 595 #[test] 596 fn transport_publish_validation_rejects_zero_limits() { 597 let cfg = TransportPublishConfig { 598 max_event_bytes: 0, 599 ..TransportPublishConfig::default() 600 }; 601 assert!(cfg.validate().is_err()); 602 let cfg = TransportPublishConfig { 603 max_targets_per_request: 0, 604 ..TransportPublishConfig::default() 605 }; 606 assert!(cfg.validate().is_err()); 607 let cfg = TransportPublishConfig { 608 job_list_limit: 0, 609 ..TransportPublishConfig::default() 610 }; 611 assert!(cfg.validate().is_err()); 612 let cfg = TransportPublishConfig { 613 max_concurrent_publish_jobs: 0, 614 ..TransportPublishConfig::default() 615 }; 616 assert!(cfg.validate().is_err()); 617 let cfg = TransportPublishConfig { 618 connect_timeout_secs: 0, 619 ..TransportPublishConfig::default() 620 }; 621 assert!(cfg.validate().is_err()); 622 } 623 624 #[test] 625 fn authored_profile_accepts_only_strict_metadata_fields() { 626 let mut metadata = crate::host_nostr::Metadata::new() 627 .name("radrootsd") 628 .display_name("Radroots daemon") 629 .about("local relay publishing") 630 .nip05("daemon@radroots.example"); 631 metadata.custom.insert("bot".to_owned(), json!(true)); 632 let settings = super::Settings { 633 metadata, 634 config: Configuration { 635 service: service_config(), 636 rpc: RpcConfig::default(), 637 rpc_addr: None, 638 nip46: Nip46Config::default(), 639 transport_publish: TransportPublishConfig::default(), 640 }, 641 }; 642 643 let profile = settings.authored_profile().expect("authored profile"); 644 assert_eq!(profile.name(), "radrootsd"); 645 assert_eq!(profile.display_name(), Some("Radroots daemon")); 646 assert_eq!(profile.about(), Some("local relay publishing")); 647 assert_eq!( 648 profile.nip05().map(Nip05Identifier::as_str), 649 Some("daemon@radroots.example") 650 ); 651 assert_eq!(profile.bot(), Some(true)); 652 assert!(profile.picture().is_none()); 653 assert!(profile.banner().is_none()); 654 } 655 656 #[test] 657 fn authored_profile_rejects_url_only_media() { 658 let metadata = crate::host_nostr::Metadata::new() 659 .name("radrootsd") 660 .picture(url::Url::parse("https://blossom.example/blob").expect("picture URL")); 661 let settings = super::Settings { 662 metadata, 663 config: Configuration { 664 service: service_config(), 665 rpc: RpcConfig::default(), 666 rpc_addr: None, 667 nip46: Nip46Config::default(), 668 transport_publish: TransportPublishConfig::default(), 669 }, 670 }; 671 672 let error = settings 673 .authored_profile() 674 .expect_err("URL-only media must fail closed"); 675 assert!( 676 error 677 .to_string() 678 .contains("byte-verified Blossom descriptor") 679 ); 680 } 681 682 #[test] 683 fn authored_profile_rejects_missing_invalid_and_unsupported_fields() { 684 let mut settings = super::Settings { 685 metadata: crate::host_nostr::Metadata::new(), 686 config: Configuration { 687 service: service_config(), 688 rpc: RpcConfig::default(), 689 rpc_addr: None, 690 nip46: Nip46Config::default(), 691 transport_publish: TransportPublishConfig::default(), 692 }, 693 }; 694 assert!(settings.authored_profile().is_err()); 695 696 settings.metadata.name = Some("radrootsd".to_owned()); 697 settings.metadata.nip05 = Some("invalid".to_owned()); 698 assert!(settings.authored_profile().is_err()); 699 700 settings.metadata.nip05 = None; 701 settings.metadata.website = Some("https://radroots.example".to_owned()); 702 let error = settings 703 .authored_profile() 704 .expect_err("unsupported fields must fail closed"); 705 assert!(error.to_string().contains("website")); 706 } 707 708 #[test] 709 fn runtime_paths_follow_interactive_user_contract() { 710 let paths = resolve_runtime_paths_with_resolver( 711 &linux_resolver("/home/treesap"), 712 PathProfile::InteractiveUser, 713 None, 714 ) 715 .expect("resolve interactive-user paths"); 716 717 assert_eq!( 718 paths.config_path, 719 PathBuf::from("/home/treesap/.radroots/config/services/radrootsd/config.toml") 720 ); 721 assert_eq!( 722 paths.logs_dir, 723 PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd") 724 ); 725 assert_eq!( 726 paths.identity_path, 727 PathBuf::from( 728 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json" 729 ) 730 ); 731 assert_eq!( 732 paths.transport_publish_database_path, 733 PathBuf::from( 734 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite" 735 ) 736 ); 737 } 738 739 #[test] 740 fn runtime_paths_follow_service_host_contract() { 741 let paths = resolve_runtime_paths_with_resolver( 742 &linux_resolver("/home/treesap"), 743 PathProfile::ServiceHost, 744 None, 745 ) 746 .expect("resolve service-host paths"); 747 748 assert_eq!( 749 paths.config_path, 750 PathBuf::from("/etc/radroots/services/radrootsd/config.toml") 751 ); 752 assert_eq!( 753 paths.logs_dir, 754 PathBuf::from("/var/log/radroots/services/radrootsd") 755 ); 756 assert_eq!( 757 paths.identity_path, 758 PathBuf::from("/etc/radroots/secrets/services/radrootsd/identity.secret.json") 759 ); 760 assert_eq!( 761 paths.transport_publish_database_path, 762 PathBuf::from("/var/lib/radroots/services/radrootsd/transport_publish.sqlite") 763 ); 764 } 765 766 #[test] 767 fn runtime_paths_follow_repo_local_contract() { 768 let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/radrootsd"); 769 let paths = resolve_runtime_paths_with_resolver( 770 &linux_resolver("/home/treesap"), 771 PathProfile::RepoLocal, 772 Some(repo_local_root.as_path()), 773 ) 774 .expect("resolve repo-local paths"); 775 776 assert_eq!( 777 paths.config_path, 778 repo_local_root.join("config/services/radrootsd/config.toml") 779 ); 780 assert_eq!( 781 paths.logs_dir, 782 repo_local_root.join("logs/services/radrootsd") 783 ); 784 assert_eq!( 785 paths.identity_path, 786 repo_local_root.join("secrets/services/radrootsd/identity.secret.json") 787 ); 788 assert_eq!( 789 paths.transport_publish_database_path, 790 repo_local_root.join("data/services/radrootsd/transport_publish.sqlite") 791 ); 792 } 793 794 #[test] 795 fn load_settings_materializes_profile_defaults_when_paths_are_omitted() { 796 let temp = tempfile::tempdir().expect("tempdir"); 797 let config_path = temp.path().join("radrootsd.toml"); 798 std::fs::write( 799 &config_path, 800 r#" 801 [metadata] 802 name = "radrootsd-test" 803 804 [config] 805 relays = ["ws://127.0.0.1:8080"] 806 807 [config.rpc] 808 addr = "127.0.0.1:7070" 809 "#, 810 ) 811 .expect("write config"); 812 813 let settings = load_settings_from_path_with_resolver( 814 &config_path, 815 &linux_resolver("/home/treesap"), 816 PathProfile::InteractiveUser, 817 None, 818 ) 819 .expect("load settings"); 820 821 assert_eq!( 822 settings.config.service.logs_dir, 823 "/home/treesap/.radroots/logs/services/radrootsd" 824 ); 825 assert_eq!( 826 settings.config.transport_publish.database_path, 827 PathBuf::from( 828 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite" 829 ) 830 ); 831 } 832 833 #[test] 834 fn obsolete_transport_publish_config_is_rejected() { 835 let temp = tempfile::tempdir().expect("tempdir"); 836 let config_path = temp.path().join("radrootsd.toml"); 837 std::fs::write( 838 &config_path, 839 r#" 840 [metadata] 841 name = "radrootsd-test" 842 843 [config] 844 relays = [] 845 846 [config.transport_publish] 847 relay_url_policy = "localhost" 848 "#, 849 ) 850 .expect("write config"); 851 852 let err = load_settings_from_path_with_resolver( 853 &config_path, 854 &linux_resolver("/home/treesap"), 855 PathProfile::InteractiveUser, 856 None, 857 ) 858 .expect_err("obsolete transport_publish config should fail"); 859 let err_chain = format!("{err:?}"); 860 assert!(err_chain.contains("unknown field")); 861 assert!(err_chain.contains("relay_url_policy")); 862 } 863 864 #[test] 865 fn runtime_contract_output_matches_interactive_user_contract() { 866 let contract = runtime_contract_with_resolver( 867 &linux_resolver("/home/treesap"), 868 PathProfile::InteractiveUser, 869 None, 870 ) 871 .expect("interactive-user contract"); 872 873 assert_eq!(contract.active_profile, "interactive_user"); 874 assert_eq!( 875 contract.path_overrides.subordinate_path_override_keys, 876 vec![ 877 "config.service.logs_dir".to_owned(), 878 "config.transport_publish.database_path".to_owned(), 879 ] 880 ); 881 assert_eq!( 882 contract.canonical_transport_publish_database_path, 883 PathBuf::from( 884 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite" 885 ) 886 ); 887 } 888 }