use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config # Remote node-to-node communication is Iroh-only. # # relay_mode controls Iroh relay use for practical internet connectivity. # Values: "default", "staging", "disabled", "custom". [iroh] relay_mode = "default" local_discovery = true # relay_map = "home" # # [iroh.relay_maps.home] # urls = ["https://relay.example.com"] # [sync] live_sync_enabled = true live_sync_interval_ms = 30000 "#; #[derive(Clone, Debug)] pub struct GethPaths { home: PathBuf, } impl GethPaths { pub fn resolve() -> Result { if let Some(home) = std::env::var_os("GETH_HOME") { return Ok(Self { home: PathBuf::from(home), }); } let project_dirs = directories::ProjectDirs::from("local", "geth", "geth") .ok_or(ConfigError::NoDataDirectory)?; Ok(Self { home: project_dirs.data_dir().to_path_buf(), }) } #[must_use] pub fn from_home(home: impl Into) -> Self { Self { home: home.into() } } #[must_use] pub fn home(&self) -> &Path { &self.home } #[must_use] pub fn config_file(&self) -> PathBuf { self.home.join("config.toml") } #[must_use] pub fn metadata_db(&self) -> PathBuf { self.home.join("geth.sqlite") } #[must_use] pub fn identity_dir(&self) -> PathBuf { self.home.join("identity") } #[must_use] pub fn agent_key(&self) -> PathBuf { self.identity_dir().join("agent.ed25519") } #[must_use] pub fn iroh_key(&self) -> PathBuf { self.identity_dir().join("iroh.ed25519") } #[must_use] pub fn cas_dir(&self) -> PathBuf { self.home.join("cas") } #[must_use] pub fn run_dir(&self) -> PathBuf { self.home.join("run") } #[must_use] pub fn socket_path(&self) -> PathBuf { self.run_dir().join("geth.sock") } pub fn ensure_base_dirs(&self) -> Result<(), ConfigError> { std::fs::create_dir_all(self.identity_dir())?; std::fs::create_dir_all(self.cas_dir().join("blobs"))?; std::fs::create_dir_all(self.run_dir())?; Ok(()) } } #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("could not determine OS data directory and GETH_HOME is unset")] NoDataDirectory, #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("toml parse error: {0}")] TomlParse(#[from] toml_edit::TomlError), #[error("invalid iroh relay_mode: {0}")] InvalidRelayMode(String), #[error("invalid iroh local_discovery value: {0}")] InvalidLocalDiscovery(String), #[error("custom iroh relay_mode requires iroh.relay_map")] MissingRelayMapSelection, #[error("selected iroh relay map does not exist: {0}")] UnknownRelayMap(String), #[error("invalid iroh relay_maps table")] InvalidRelayMapsTable, #[error("invalid iroh relay map `{0}`")] InvalidRelayMap(String), #[error("iroh relay map `{0}` must include at least one URL")] EmptyRelayMap(String), #[error("invalid iroh relay URL in map `{map}`: {url}")] InvalidRelayUrl { map: String, url: String }, #[error("invalid sync live_sync_enabled value: {0}")] InvalidLiveSyncEnabled(String), #[error("invalid sync live_sync_interval_ms value: {0}")] InvalidLiveSyncInterval(String), } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GethConfig { pub iroh: IrohConfig, pub sync: SyncConfig, } impl GethConfig { #[must_use] pub fn default_toml() -> &'static str { DEFAULT_CONFIG_TOML } pub fn load(path: &Path) -> Result { if !path.exists() { return Ok(Self::default()); } Self::parse(&std::fs::read_to_string(path)?) } pub fn parse(text: &str) -> Result { let document = text.parse::()?; let relay_maps = parse_relay_maps(&document)?; let local_discovery = parse_local_discovery(&document)?; let sync = parse_sync_config(&document)?; let relay_mode = match document.get("iroh").and_then(|iroh| iroh.get("relay_mode")) { Some(item) => { let value = item .as_str() .ok_or_else(|| ConfigError::InvalidRelayMode(item.to_string()))?; RelayMode::parse(value, &document, &relay_maps)? } None => RelayMode::default(), }; Ok(Self { iroh: IrohConfig { relay_mode, relay_maps, local_discovery, }, sync, }) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct IrohConfig { pub relay_mode: RelayMode, pub relay_maps: BTreeMap, pub local_discovery: bool, } impl Default for IrohConfig { fn default() -> Self { Self { relay_mode: RelayMode::Default, relay_maps: BTreeMap::new(), local_discovery: true, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SyncConfig { pub live_sync_enabled: bool, pub live_sync_interval_ms: u64, } impl Default for SyncConfig { fn default() -> Self { Self { live_sync_enabled: true, live_sync_interval_ms: 30_000, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RelayMapConfig { pub urls: Vec, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum RelayMode { Disabled, #[default] Default, Staging, Custom { map: String, }, } impl RelayMode { fn parse( value: &str, document: &toml_edit::DocumentMut, relay_maps: &BTreeMap, ) -> Result { match value { "disabled" => Ok(Self::Disabled), "default" => Ok(Self::Default), "staging" => Ok(Self::Staging), "custom" => { let map = document .get("iroh") .and_then(|iroh| iroh.get("relay_map")) .and_then(toml_edit::Item::as_str) .ok_or(ConfigError::MissingRelayMapSelection)? .to_owned(); if !relay_maps.contains_key(&map) { return Err(ConfigError::UnknownRelayMap(map)); } Ok(Self::Custom { map }) } other => Err(ConfigError::InvalidRelayMode(other.to_owned())), } } #[must_use] pub fn label(&self) -> String { match self { Self::Disabled => "disabled".to_owned(), Self::Default => "default".to_owned(), Self::Staging => "staging".to_owned(), Self::Custom { map } => format!("custom:{map}"), } } } fn parse_relay_maps( document: &toml_edit::DocumentMut, ) -> Result, ConfigError> { let Some(relay_maps_item) = document.get("iroh").and_then(|iroh| iroh.get("relay_maps")) else { return Ok(BTreeMap::new()); }; let relay_maps_table = relay_maps_item .as_table_like() .ok_or(ConfigError::InvalidRelayMapsTable)?; let mut relay_maps = BTreeMap::new(); for (name, item) in relay_maps_table.iter() { let urls = parse_relay_map_urls(name, item)?; relay_maps.insert(name.to_owned(), RelayMapConfig { urls }); } Ok(relay_maps) } fn parse_relay_map_urls(name: &str, item: &toml_edit::Item) -> Result, ConfigError> { let table = item .as_table_like() .ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?; let urls_item = table .get("urls") .ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?; let urls_array = urls_item .as_array() .ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?; let mut urls = Vec::new(); for value in urls_array.iter() { let url = value .as_str() .ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?; validate_relay_url(name, url)?; urls.push(url.to_owned()); } if urls.is_empty() { return Err(ConfigError::EmptyRelayMap(name.to_owned())); } Ok(urls) } fn validate_relay_url(map: &str, relay_url: &str) -> Result<(), ConfigError> { let url = url::Url::parse(relay_url).map_err(|_| ConfigError::InvalidRelayUrl { map: map.to_owned(), url: relay_url.to_owned(), })?; match url.scheme() { "http" | "https" => Ok(()), _ => Err(ConfigError::InvalidRelayUrl { map: map.to_owned(), url: relay_url.to_owned(), }), } } fn parse_local_discovery(document: &toml_edit::DocumentMut) -> Result { match document .get("iroh") .and_then(|iroh| iroh.get("local_discovery")) { Some(item) => item .as_bool() .ok_or_else(|| ConfigError::InvalidLocalDiscovery(item.to_string())), None => Ok(true), } } fn parse_sync_config(document: &toml_edit::DocumentMut) -> Result { let live_sync_enabled = match document .get("sync") .and_then(|sync| sync.get("live_sync_enabled")) { Some(item) => item .as_bool() .ok_or_else(|| ConfigError::InvalidLiveSyncEnabled(item.to_string()))?, None => true, }; let live_sync_interval_ms = match document .get("sync") .and_then(|sync| sync.get("live_sync_interval_ms")) { Some(item) => { let interval = item .as_integer() .ok_or_else(|| ConfigError::InvalidLiveSyncInterval(item.to_string()))?; if interval < 100 { return Err(ConfigError::InvalidLiveSyncInterval(interval.to_string())); } interval as u64 } None => 30_000, }; Ok(SyncConfig { live_sync_enabled, live_sync_interval_ms, }) } #[cfg(test)] mod tests { use super::*; #[test] fn default_config_uses_iroh_relays() { let config = GethConfig::parse(GethConfig::default_toml()).expect("parse config"); assert_eq!(config.iroh.relay_mode, RelayMode::Default); assert!(config.iroh.local_discovery); assert!(config.sync.live_sync_enabled); assert_eq!(config.sync.live_sync_interval_ms, 30_000); } #[test] fn config_parses_disabled_relay_mode() { let config = GethConfig::parse("[iroh]\nrelay_mode = \"disabled\"\n").expect("parse"); assert_eq!(config.iroh.relay_mode, RelayMode::Disabled); } #[test] fn config_parses_custom_relay_map() { let config = GethConfig::parse( r#" [iroh] relay_mode = "custom" relay_map = "home" [iroh.relay_maps.home] urls = ["https://relay.example.com"] "#, ) .expect("parse"); assert_eq!( config.iroh.relay_mode, RelayMode::Custom { map: "home".to_owned() } ); assert_eq!( config.iroh.relay_maps["home"].urls, vec!["https://relay.example.com".to_owned()] ); assert_eq!(config.iroh.relay_mode.label(), "custom:home"); } #[test] fn config_rejects_unknown_relay_mode() { let error = GethConfig::parse("[iroh]\nrelay_mode = \"magic\"\n").expect_err("error"); assert!(matches!(error, ConfigError::InvalidRelayMode(value) if value == "magic")); } #[test] fn config_rejects_non_string_relay_mode() { let error = GethConfig::parse("[iroh]\nrelay_mode = true\n").expect_err("error"); assert!(matches!(error, ConfigError::InvalidRelayMode(_))); } #[test] fn config_rejects_invalid_custom_relay_url() { let error = GethConfig::parse( r#" [iroh] relay_mode = "custom" relay_map = "home" [iroh.relay_maps.home] urls = ["not a url"] "#, ) .expect_err("error"); assert!(matches!(error, ConfigError::InvalidRelayUrl { map, .. } if map == "home")); } #[test] fn config_rejects_missing_custom_relay_map() { let error = GethConfig::parse("[iroh]\nrelay_mode = \"custom\"\nrelay_map = \"home\"\n") .expect_err("error"); assert!(matches!(error, ConfigError::UnknownRelayMap(map) if map == "home")); } #[test] fn config_parses_local_discovery_toggle() { let config = GethConfig::parse("[iroh]\nlocal_discovery = false\n").expect("parse"); assert!(!config.iroh.local_discovery); } #[test] fn config_parses_live_sync_settings() { let config = GethConfig::parse("[sync]\nlive_sync_enabled = false\nlive_sync_interval_ms = 250\n") .expect("parse"); assert!(!config.sync.live_sync_enabled); assert_eq!(config.sync.live_sync_interval_ms, 250); } #[test] fn config_rejects_invalid_live_sync_settings() { let error = GethConfig::parse("[sync]\nlive_sync_enabled = \"yes\"\n").expect_err("error"); assert!(matches!(error, ConfigError::InvalidLiveSyncEnabled(_))); let error = GethConfig::parse("[sync]\nlive_sync_interval_ms = 99\n").expect_err("error"); assert!(matches!(error, ConfigError::InvalidLiveSyncInterval(_))); let error = GethConfig::parse("[sync]\nlive_sync_interval_ms = \"fast\"\n").expect_err("error"); assert!(matches!(error, ConfigError::InvalidLiveSyncInterval(_))); } #[test] fn config_rejects_non_bool_local_discovery() { let error = GethConfig::parse("[iroh]\nlocal_discovery = \"yes\"\n").expect_err("error"); assert!(matches!(error, ConfigError::InvalidLocalDiscovery(_))); } }