radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

config.rs (22330B)


      1 use anyhow::{Context, Result, bail};
      2 use radroots_nostr::prelude::RadrootsNostrMetadata;
      3 use radroots_runtime::RadrootsNostrServiceConfig;
      4 use serde::{Deserialize, Serialize};
      5 use std::path::{Path, PathBuf};
      6 
      7 use super::paths::{
      8     RadrootsdRuntimePaths, default_publish_proxy_database_path, process_path_selection,
      9     resolve_runtime_paths_with_resolver,
     10 };
     11 
     12 fn default_rpc_addr() -> String {
     13     "127.0.0.1:7070".to_string()
     14 }
     15 
     16 fn default_max_request_body_size() -> u32 {
     17     10 * 1024 * 1024
     18 }
     19 
     20 fn default_max_response_body_size() -> u32 {
     21     10 * 1024 * 1024
     22 }
     23 
     24 fn default_max_connections() -> u32 {
     25     100
     26 }
     27 
     28 fn default_max_subscriptions_per_connection() -> u32 {
     29     1024
     30 }
     31 
     32 fn default_message_buffer_capacity() -> u32 {
     33     1024
     34 }
     35 
     36 fn default_rpc_batch_request_limit() -> Option<u32> {
     37     Some(0)
     38 }
     39 
     40 fn default_nip46_session_ttl_secs() -> u64 {
     41     900
     42 }
     43 
     44 fn default_nip46_perms() -> Vec<String> {
     45     Vec::new()
     46 }
     47 
     48 fn default_nip46_public_jsonrpc_enabled() -> bool {
     49     false
     50 }
     51 
     52 fn default_publish_proxy_enabled() -> bool {
     53     true
     54 }
     55 
     56 fn default_publish_proxy_connect_timeout_secs() -> u64 {
     57     10
     58 }
     59 
     60 fn default_publish_proxy_max_event_bytes() -> usize {
     61     128 * 1024
     62 }
     63 
     64 fn default_publish_proxy_max_relays_per_request() -> usize {
     65     20
     66 }
     67 
     68 fn default_publish_proxy_job_list_limit() -> usize {
     69     100
     70 }
     71 
     72 fn default_publish_proxy_max_concurrent_publish_jobs() -> usize {
     73     8
     74 }
     75 
     76 fn default_publish_proxy_relay_url_policy() -> PublishProxyRelayUrlPolicy {
     77     PublishProxyRelayUrlPolicy::Public
     78 }
     79 
     80 #[derive(Debug, Deserialize, Clone, Default)]
     81 struct RawServiceConfig {
     82     #[serde(default)]
     83     pub logs_dir: Option<String>,
     84     #[serde(default)]
     85     pub relays: Vec<String>,
     86     #[serde(default)]
     87     pub nip89_identifier: Option<String>,
     88     #[serde(default)]
     89     pub nip89_extra_tags: Vec<Vec<String>>,
     90 }
     91 
     92 impl RawServiceConfig {
     93     fn into_service_config(self, paths: &RadrootsdRuntimePaths) -> RadrootsNostrServiceConfig {
     94         RadrootsNostrServiceConfig {
     95             logs_dir: self
     96                 .logs_dir
     97                 .unwrap_or_else(|| paths.logs_dir.display().to_string()),
     98             relays: self.relays,
     99             nip89_identifier: self.nip89_identifier,
    100             nip89_extra_tags: self.nip89_extra_tags,
    101         }
    102     }
    103 }
    104 
    105 #[derive(Debug, Deserialize, Clone)]
    106 struct RawPublishProxyConfig {
    107     #[serde(default = "default_publish_proxy_enabled")]
    108     pub enabled: bool,
    109     #[serde(default = "default_publish_proxy_connect_timeout_secs")]
    110     pub connect_timeout_secs: u64,
    111     #[serde(default = "default_publish_proxy_max_event_bytes")]
    112     pub max_event_bytes: usize,
    113     #[serde(default = "default_publish_proxy_max_relays_per_request")]
    114     pub max_relays_per_request: usize,
    115     #[serde(default = "default_publish_proxy_job_list_limit")]
    116     pub job_list_limit: usize,
    117     #[serde(default = "default_publish_proxy_max_concurrent_publish_jobs")]
    118     pub max_concurrent_publish_jobs: usize,
    119     #[serde(default)]
    120     pub database_path: Option<PathBuf>,
    121     #[serde(default = "default_publish_proxy_relay_url_policy")]
    122     pub relay_url_policy: PublishProxyRelayUrlPolicy,
    123     #[serde(default)]
    124     pub author_relay_discovery_relays: Vec<String>,
    125     #[serde(default)]
    126     pub daemon_default_publish_relays: Vec<String>,
    127 }
    128 
    129 impl Default for RawPublishProxyConfig {
    130     fn default() -> Self {
    131         Self {
    132             enabled: default_publish_proxy_enabled(),
    133             connect_timeout_secs: default_publish_proxy_connect_timeout_secs(),
    134             max_event_bytes: default_publish_proxy_max_event_bytes(),
    135             max_relays_per_request: default_publish_proxy_max_relays_per_request(),
    136             job_list_limit: default_publish_proxy_job_list_limit(),
    137             max_concurrent_publish_jobs: default_publish_proxy_max_concurrent_publish_jobs(),
    138             database_path: None,
    139             relay_url_policy: default_publish_proxy_relay_url_policy(),
    140             author_relay_discovery_relays: Vec::new(),
    141             daemon_default_publish_relays: Vec::new(),
    142         }
    143     }
    144 }
    145 
    146 impl RawPublishProxyConfig {
    147     fn into_publish_proxy_config(self, paths: &RadrootsdRuntimePaths) -> PublishProxyConfig {
    148         PublishProxyConfig {
    149             enabled: self.enabled,
    150             connect_timeout_secs: self.connect_timeout_secs,
    151             max_event_bytes: self.max_event_bytes,
    152             max_relays_per_request: self.max_relays_per_request,
    153             job_list_limit: self.job_list_limit,
    154             max_concurrent_publish_jobs: self.max_concurrent_publish_jobs,
    155             database_path: self
    156                 .database_path
    157                 .unwrap_or_else(|| paths.publish_proxy_database_path.clone()),
    158             relay_url_policy: self.relay_url_policy,
    159             author_relay_discovery_relays: self.author_relay_discovery_relays,
    160             daemon_default_publish_relays: self.daemon_default_publish_relays,
    161         }
    162     }
    163 }
    164 
    165 #[derive(Debug, Deserialize, Clone)]
    166 struct RawConfiguration {
    167     #[serde(flatten)]
    168     pub service: RawServiceConfig,
    169     #[serde(default)]
    170     pub rpc: RpcConfig,
    171     #[serde(default)]
    172     pub rpc_addr: Option<String>,
    173     #[serde(default)]
    174     pub nip46: Nip46Config,
    175     #[serde(default)]
    176     pub publish_proxy: RawPublishProxyConfig,
    177     #[serde(default, rename = "bridge")]
    178     pub obsolete_publish_bridge_config: Option<serde::de::IgnoredAny>,
    179 }
    180 
    181 #[derive(Debug, Deserialize, Clone)]
    182 struct RawSettings {
    183     pub metadata: RadrootsNostrMetadata,
    184     pub config: RawConfiguration,
    185 }
    186 
    187 impl RawSettings {
    188     fn into_settings(self, paths: &RadrootsdRuntimePaths) -> Settings {
    189         Settings {
    190             metadata: self.metadata,
    191             config: Configuration {
    192                 service: self.config.service.into_service_config(paths),
    193                 rpc: self.config.rpc,
    194                 rpc_addr: self.config.rpc_addr,
    195                 nip46: self.config.nip46,
    196                 publish_proxy: self.config.publish_proxy.into_publish_proxy_config(paths),
    197                 obsolete_bridge_config_present: self
    198                     .config
    199                     .obsolete_publish_bridge_config
    200                     .is_some(),
    201             },
    202         }
    203     }
    204 }
    205 
    206 fn load_settings_from_path_with_resolver(
    207     path: &Path,
    208     resolver: &radroots_runtime_paths::RadrootsPathResolver,
    209     profile: radroots_runtime_paths::RadrootsPathProfile,
    210     repo_local_root: Option<&Path>,
    211 ) -> Result<Settings> {
    212     let raw: RawSettings = radroots_runtime::load_required_file(path)
    213         .with_context(|| format!("load configuration from {}", path.display()))?;
    214     let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?;
    215     let settings = raw.into_settings(&paths);
    216     settings.validate()?;
    217     Ok(settings)
    218 }
    219 
    220 pub fn load_settings_from_path(path: impl AsRef<Path>) -> Result<Settings> {
    221     let path = path.as_ref();
    222     let (profile, repo_local_root) = process_path_selection()?;
    223     load_settings_from_path_with_resolver(
    224         path,
    225         &radroots_runtime_paths::RadrootsPathResolver::current(),
    226         profile,
    227         repo_local_root.as_deref(),
    228     )
    229 }
    230 
    231 #[derive(Debug, Serialize, Deserialize, Clone)]
    232 pub struct Nip46Config {
    233     #[serde(default = "default_nip46_session_ttl_secs")]
    234     pub session_ttl_secs: u64,
    235     #[serde(default = "default_nip46_perms")]
    236     pub perms: Vec<String>,
    237     #[serde(default = "default_nip46_public_jsonrpc_enabled")]
    238     pub public_jsonrpc_enabled: bool,
    239     #[serde(default)]
    240     pub nostrconnect_url: Option<String>,
    241 }
    242 
    243 impl Default for Nip46Config {
    244     fn default() -> Self {
    245         Self {
    246             session_ttl_secs: default_nip46_session_ttl_secs(),
    247             perms: default_nip46_perms(),
    248             public_jsonrpc_enabled: default_nip46_public_jsonrpc_enabled(),
    249             nostrconnect_url: None,
    250         }
    251     }
    252 }
    253 
    254 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
    255 #[serde(rename_all = "snake_case")]
    256 pub enum PublishProxyRelayUrlPolicy {
    257     Public,
    258     Localhost,
    259 }
    260 
    261 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
    262 pub struct PublishProxyConfig {
    263     #[serde(default = "default_publish_proxy_enabled")]
    264     pub enabled: bool,
    265     #[serde(default = "default_publish_proxy_connect_timeout_secs")]
    266     pub connect_timeout_secs: u64,
    267     #[serde(default = "default_publish_proxy_max_event_bytes")]
    268     pub max_event_bytes: usize,
    269     #[serde(default = "default_publish_proxy_max_relays_per_request")]
    270     pub max_relays_per_request: usize,
    271     #[serde(default = "default_publish_proxy_job_list_limit")]
    272     pub job_list_limit: usize,
    273     #[serde(default = "default_publish_proxy_max_concurrent_publish_jobs")]
    274     pub max_concurrent_publish_jobs: usize,
    275     #[serde(default = "default_publish_proxy_database_path")]
    276     pub database_path: PathBuf,
    277     #[serde(default = "default_publish_proxy_relay_url_policy")]
    278     pub relay_url_policy: PublishProxyRelayUrlPolicy,
    279     #[serde(default)]
    280     pub author_relay_discovery_relays: Vec<String>,
    281     #[serde(default)]
    282     pub daemon_default_publish_relays: Vec<String>,
    283 }
    284 
    285 impl Default for PublishProxyConfig {
    286     fn default() -> Self {
    287         Self {
    288             enabled: default_publish_proxy_enabled(),
    289             connect_timeout_secs: default_publish_proxy_connect_timeout_secs(),
    290             max_event_bytes: default_publish_proxy_max_event_bytes(),
    291             max_relays_per_request: default_publish_proxy_max_relays_per_request(),
    292             job_list_limit: default_publish_proxy_job_list_limit(),
    293             max_concurrent_publish_jobs: default_publish_proxy_max_concurrent_publish_jobs(),
    294             database_path: default_publish_proxy_database_path(),
    295             relay_url_policy: default_publish_proxy_relay_url_policy(),
    296             author_relay_discovery_relays: Vec::new(),
    297             daemon_default_publish_relays: Vec::new(),
    298         }
    299     }
    300 }
    301 
    302 impl PublishProxyConfig {
    303     pub fn validate(&self) -> Result<()> {
    304         if self.max_event_bytes == 0 {
    305             bail!("publish_proxy max_event_bytes must be greater than zero");
    306         }
    307         if self.max_relays_per_request == 0 {
    308             bail!("publish_proxy max_relays_per_request must be greater than zero");
    309         }
    310         if self.job_list_limit == 0 {
    311             bail!("publish_proxy job_list_limit must be greater than zero");
    312         }
    313         if self.max_concurrent_publish_jobs == 0 {
    314             bail!("publish_proxy max_concurrent_publish_jobs must be greater than zero");
    315         }
    316         if self.connect_timeout_secs == 0 {
    317             bail!("publish_proxy connect_timeout_secs must be greater than zero");
    318         }
    319         Ok(())
    320     }
    321 }
    322 
    323 #[derive(Debug, Serialize, Deserialize, Clone)]
    324 pub struct RpcConfig {
    325     #[serde(default = "default_rpc_addr")]
    326     pub addr: String,
    327     #[serde(default = "default_max_request_body_size")]
    328     pub max_request_body_size: u32,
    329     #[serde(default = "default_max_response_body_size")]
    330     pub max_response_body_size: u32,
    331     #[serde(default = "default_max_connections")]
    332     pub max_connections: u32,
    333     #[serde(default = "default_max_subscriptions_per_connection")]
    334     pub max_subscriptions_per_connection: u32,
    335     #[serde(default = "default_message_buffer_capacity")]
    336     pub message_buffer_capacity: u32,
    337     #[serde(default = "default_rpc_batch_request_limit")]
    338     pub batch_request_limit: Option<u32>,
    339 }
    340 
    341 impl Default for RpcConfig {
    342     fn default() -> Self {
    343         Self {
    344             addr: default_rpc_addr(),
    345             max_request_body_size: default_max_request_body_size(),
    346             max_response_body_size: default_max_response_body_size(),
    347             max_connections: default_max_connections(),
    348             max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
    349             message_buffer_capacity: default_message_buffer_capacity(),
    350             batch_request_limit: default_rpc_batch_request_limit(),
    351         }
    352     }
    353 }
    354 
    355 #[derive(Debug, Serialize, Deserialize, Clone)]
    356 pub struct Configuration {
    357     #[serde(flatten)]
    358     pub service: RadrootsNostrServiceConfig,
    359     #[serde(default)]
    360     pub rpc: RpcConfig,
    361     #[serde(default)]
    362     pub rpc_addr: Option<String>,
    363     #[serde(default)]
    364     pub nip46: Nip46Config,
    365     #[serde(default)]
    366     pub publish_proxy: PublishProxyConfig,
    367     #[serde(default, skip_serializing)]
    368     pub(crate) obsolete_bridge_config_present: bool,
    369 }
    370 
    371 impl Configuration {
    372     pub fn rpc_addr(&self) -> &str {
    373         self.rpc_addr.as_deref().unwrap_or(self.rpc.addr.as_str())
    374     }
    375 
    376     pub fn validate(&self) -> Result<()> {
    377         if self.obsolete_bridge_config_present {
    378             bail!("config.bridge is obsolete; use config.publish_proxy");
    379         }
    380         self.publish_proxy.validate()?;
    381         Ok(())
    382     }
    383 }
    384 
    385 #[derive(Debug, Clone, Serialize, Deserialize)]
    386 pub struct Settings {
    387     pub metadata: RadrootsNostrMetadata,
    388     pub config: Configuration,
    389 }
    390 
    391 impl Settings {
    392     pub fn validate(&self) -> Result<()> {
    393         self.config.validate()
    394     }
    395 }
    396 
    397 #[cfg(test)]
    398 mod tests {
    399     use std::path::PathBuf;
    400 
    401     use super::{
    402         Configuration, Nip46Config, PublishProxyConfig, PublishProxyRelayUrlPolicy, RpcConfig,
    403         load_settings_from_path_with_resolver,
    404     };
    405     use crate::app::paths::{
    406         default_runtime_paths_for_process, resolve_runtime_paths_with_resolver,
    407         runtime_contract_with_resolver,
    408     };
    409     use radroots_runtime::RadrootsNostrServiceConfig;
    410     use radroots_runtime_paths::{
    411         RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform,
    412     };
    413 
    414     fn linux_resolver(home: &str) -> RadrootsPathResolver {
    415         RadrootsPathResolver::new(
    416             RadrootsPlatform::Linux,
    417             RadrootsHostEnvironment {
    418                 home_dir: Some(PathBuf::from(home)),
    419                 ..RadrootsHostEnvironment::default()
    420             },
    421         )
    422     }
    423 
    424     fn service_config() -> RadrootsNostrServiceConfig {
    425         let paths = resolve_runtime_paths_with_resolver(
    426             &linux_resolver("/home/treesap"),
    427             RadrootsPathProfile::InteractiveUser,
    428             None,
    429         )
    430         .expect("resolve interactive-user paths");
    431         RadrootsNostrServiceConfig {
    432             logs_dir: paths.logs_dir.display().to_string(),
    433             relays: Vec::new(),
    434             nip89_identifier: Some("radrootsd".to_string()),
    435             nip89_extra_tags: Vec::new(),
    436         }
    437     }
    438 
    439     #[test]
    440     fn nip46_defaults_are_expected() {
    441         let cfg = Nip46Config::default();
    442         assert_eq!(cfg.session_ttl_secs, 900);
    443         assert!(cfg.perms.is_empty());
    444         assert!(!cfg.public_jsonrpc_enabled);
    445         assert!(cfg.nostrconnect_url.is_none());
    446     }
    447 
    448     #[test]
    449     fn rpc_defaults_disable_batches() {
    450         let cfg = RpcConfig::default();
    451         assert_eq!(cfg.addr, "127.0.0.1:7070");
    452         assert_eq!(cfg.batch_request_limit, Some(0));
    453     }
    454 
    455     #[test]
    456     fn publish_proxy_defaults_are_expected() {
    457         let paths = default_runtime_paths_for_process().expect("resolve process runtime paths");
    458         let cfg = PublishProxyConfig::default();
    459         assert!(cfg.enabled);
    460         assert_eq!(cfg.connect_timeout_secs, 10);
    461         assert_eq!(cfg.max_event_bytes, 128 * 1024);
    462         assert_eq!(cfg.max_relays_per_request, 20);
    463         assert_eq!(cfg.job_list_limit, 100);
    464         assert_eq!(cfg.max_concurrent_publish_jobs, 8);
    465         assert_eq!(cfg.database_path, paths.publish_proxy_database_path);
    466         assert_eq!(cfg.relay_url_policy, PublishProxyRelayUrlPolicy::Public);
    467         assert!(cfg.author_relay_discovery_relays.is_empty());
    468         assert!(cfg.daemon_default_publish_relays.is_empty());
    469     }
    470 
    471     #[test]
    472     fn rpc_addr_prefers_override() {
    473         let mut cfg = Configuration {
    474             service: service_config(),
    475             rpc: RpcConfig {
    476                 addr: "127.0.0.1:1111".to_string(),
    477                 ..RpcConfig::default()
    478             },
    479             rpc_addr: None,
    480             nip46: Nip46Config::default(),
    481             publish_proxy: PublishProxyConfig::default(),
    482             obsolete_bridge_config_present: false,
    483         };
    484         assert_eq!(cfg.rpc_addr(), "127.0.0.1:1111");
    485         cfg.rpc_addr = Some("127.0.0.1:2222".to_string());
    486         assert_eq!(cfg.rpc_addr(), "127.0.0.1:2222");
    487     }
    488 
    489     #[test]
    490     fn publish_proxy_validation_rejects_zero_limits() {
    491         let mut cfg = PublishProxyConfig::default();
    492         cfg.max_event_bytes = 0;
    493         assert!(cfg.validate().is_err());
    494         let mut cfg = PublishProxyConfig::default();
    495         cfg.max_relays_per_request = 0;
    496         assert!(cfg.validate().is_err());
    497         let mut cfg = PublishProxyConfig::default();
    498         cfg.job_list_limit = 0;
    499         assert!(cfg.validate().is_err());
    500         let mut cfg = PublishProxyConfig::default();
    501         cfg.max_concurrent_publish_jobs = 0;
    502         assert!(cfg.validate().is_err());
    503         let mut cfg = PublishProxyConfig::default();
    504         cfg.connect_timeout_secs = 0;
    505         assert!(cfg.validate().is_err());
    506     }
    507 
    508     #[test]
    509     fn runtime_paths_follow_interactive_user_contract() {
    510         let paths = resolve_runtime_paths_with_resolver(
    511             &linux_resolver("/home/treesap"),
    512             RadrootsPathProfile::InteractiveUser,
    513             None,
    514         )
    515         .expect("resolve interactive-user paths");
    516 
    517         assert_eq!(
    518             paths.config_path,
    519             PathBuf::from("/home/treesap/.radroots/config/services/radrootsd/config.toml")
    520         );
    521         assert_eq!(
    522             paths.logs_dir,
    523             PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd")
    524         );
    525         assert_eq!(
    526             paths.identity_path,
    527             PathBuf::from(
    528                 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json"
    529             )
    530         );
    531         assert_eq!(
    532             paths.publish_proxy_database_path,
    533             PathBuf::from("/home/treesap/.radroots/data/services/radrootsd/publish_proxy.sqlite")
    534         );
    535     }
    536 
    537     #[test]
    538     fn runtime_paths_follow_service_host_contract() {
    539         let paths = resolve_runtime_paths_with_resolver(
    540             &linux_resolver("/home/treesap"),
    541             RadrootsPathProfile::ServiceHost,
    542             None,
    543         )
    544         .expect("resolve service-host paths");
    545 
    546         assert_eq!(
    547             paths.config_path,
    548             PathBuf::from("/etc/radroots/services/radrootsd/config.toml")
    549         );
    550         assert_eq!(
    551             paths.logs_dir,
    552             PathBuf::from("/var/log/radroots/services/radrootsd")
    553         );
    554         assert_eq!(
    555             paths.identity_path,
    556             PathBuf::from("/etc/radroots/secrets/services/radrootsd/identity.secret.json")
    557         );
    558         assert_eq!(
    559             paths.publish_proxy_database_path,
    560             PathBuf::from("/var/lib/radroots/services/radrootsd/publish_proxy.sqlite")
    561         );
    562     }
    563 
    564     #[test]
    565     fn runtime_paths_follow_repo_local_contract() {
    566         let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/radrootsd");
    567         let paths = resolve_runtime_paths_with_resolver(
    568             &linux_resolver("/home/treesap"),
    569             RadrootsPathProfile::RepoLocal,
    570             Some(repo_local_root.as_path()),
    571         )
    572         .expect("resolve repo-local paths");
    573 
    574         assert_eq!(
    575             paths.config_path,
    576             repo_local_root.join("config/services/radrootsd/config.toml")
    577         );
    578         assert_eq!(
    579             paths.logs_dir,
    580             repo_local_root.join("logs/services/radrootsd")
    581         );
    582         assert_eq!(
    583             paths.identity_path,
    584             repo_local_root.join("secrets/services/radrootsd/identity.secret.json")
    585         );
    586         assert_eq!(
    587             paths.publish_proxy_database_path,
    588             repo_local_root.join("data/services/radrootsd/publish_proxy.sqlite")
    589         );
    590     }
    591 
    592     #[test]
    593     fn load_settings_materializes_profile_defaults_when_paths_are_omitted() {
    594         let temp = tempfile::tempdir().expect("tempdir");
    595         let config_path = temp.path().join("radrootsd.toml");
    596         std::fs::write(
    597             &config_path,
    598             r#"
    599 [metadata]
    600 name = "radrootsd-test"
    601 
    602 [config]
    603 relays = ["ws://127.0.0.1:8080"]
    604 
    605 [config.rpc]
    606 addr = "127.0.0.1:7070"
    607 "#,
    608         )
    609         .expect("write config");
    610 
    611         let settings = load_settings_from_path_with_resolver(
    612             &config_path,
    613             &linux_resolver("/home/treesap"),
    614             RadrootsPathProfile::InteractiveUser,
    615             None,
    616         )
    617         .expect("load settings");
    618 
    619         assert_eq!(
    620             settings.config.service.logs_dir,
    621             "/home/treesap/.radroots/logs/services/radrootsd"
    622         );
    623         assert_eq!(
    624             settings.config.publish_proxy.database_path,
    625             PathBuf::from("/home/treesap/.radroots/data/services/radrootsd/publish_proxy.sqlite")
    626         );
    627     }
    628 
    629     #[test]
    630     fn obsolete_config_is_rejected() {
    631         let temp = tempfile::tempdir().expect("tempdir");
    632         let config_path = temp.path().join("radrootsd.toml");
    633         std::fs::write(
    634             &config_path,
    635             r#"
    636 [metadata]
    637 name = "radrootsd-test"
    638 
    639 [config]
    640 relays = []
    641 
    642 [config.bridge]
    643 enabled = true
    644 "#,
    645         )
    646         .expect("write config");
    647 
    648         let err = load_settings_from_path_with_resolver(
    649             &config_path,
    650             &linux_resolver("/home/treesap"),
    651             RadrootsPathProfile::InteractiveUser,
    652             None,
    653         )
    654         .expect_err("obsolete config should fail");
    655         assert!(err.to_string().contains("config.bridge"));
    656     }
    657 
    658     #[test]
    659     fn runtime_contract_output_matches_interactive_user_contract() {
    660         let contract = runtime_contract_with_resolver(
    661             &linux_resolver("/home/treesap"),
    662             RadrootsPathProfile::InteractiveUser,
    663             None,
    664         )
    665         .expect("interactive-user contract");
    666 
    667         assert_eq!(contract.active_profile, "interactive_user");
    668         assert_eq!(
    669             contract.path_overrides.subordinate_path_override_keys,
    670             vec![
    671                 "config.service.logs_dir".to_owned(),
    672                 "config.publish_proxy.database_path".to_owned(),
    673             ]
    674         );
    675         assert_eq!(
    676             contract.canonical_publish_proxy_database_path,
    677             PathBuf::from("/home/treesap/.radroots/data/services/radrootsd/publish_proxy.sqlite")
    678         );
    679     }
    680 }