geth/crates/geth-config/src/lib.rs

651 lines
20 KiB
Rust
Raw Normal View History

2026-05-16 14:28:38 +02:00
use std::collections::BTreeMap;
2026-07-18 16:15:13 +02:00
use std::io::Write;
2026-05-15 15:08:20 +02:00
use std::path::{Path, PathBuf};
2026-07-18 16:15:13 +02:00
use std::str::FromStr;
2026-05-15 15:08:20 +02:00
2026-05-16 03:17:45 +02:00
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.
2026-05-16 14:28:38 +02:00
# Values: "default", "staging", "disabled", "custom".
2026-05-16 03:17:45 +02:00
[iroh]
relay_mode = "default"
2026-05-16 14:33:45 +02:00
local_discovery = true
2026-05-16 14:28:38 +02:00
# relay_map = "home"
#
# [iroh.relay_maps.home]
# urls = ["https://relay.example.com"]
2026-05-20 13:34:16 +02:00
#
[sync]
live_sync_enabled = true
live_sync_interval_ms = 30000
2026-05-16 03:17:45 +02:00
"#;
2026-05-15 15:08:20 +02:00
#[derive(Clone, Debug)]
pub struct GethPaths {
home: PathBuf,
}
impl GethPaths {
pub fn resolve() -> Result<Self, ConfigError> {
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<PathBuf>) -> 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")
}
2026-05-16 01:54:00 +02:00
#[must_use]
pub fn iroh_key(&self) -> PathBuf {
self.identity_dir().join("iroh.ed25519")
}
2026-05-15 15:08:20 +02:00
#[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),
2026-05-16 03:17:45 +02:00
#[error("toml parse error: {0}")]
TomlParse(#[from] toml_edit::TomlError),
#[error("invalid iroh relay_mode: {0}")]
InvalidRelayMode(String),
2026-05-16 14:33:45 +02:00
#[error("invalid iroh local_discovery value: {0}")]
InvalidLocalDiscovery(String),
2026-05-16 14:28:38 +02:00
#[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 },
2026-05-20 13:34:16 +02:00
#[error("invalid sync live_sync_enabled value: {0}")]
InvalidLiveSyncEnabled(String),
#[error("invalid sync live_sync_interval_ms value: {0}")]
InvalidLiveSyncInterval(String),
2026-07-18 16:15:13 +02:00
#[error(
"unsupported config key `{0}`; supported keys: iroh.relay_mode, iroh.relay_map, iroh.local_discovery, sync.live_sync_enabled, sync.live_sync_interval_ms"
)]
UnsupportedKey(String),
#[error("invalid value `{value}` for {key}; expected {expected}")]
InvalidSetting {
key: &'static str,
value: String,
expected: &'static str,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigKey {
IrohRelayMode,
IrohRelayMap,
IrohLocalDiscovery,
SyncLiveSyncEnabled,
SyncLiveSyncIntervalMs,
}
impl ConfigKey {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::IrohRelayMode => "iroh.relay_mode",
Self::IrohRelayMap => "iroh.relay_map",
Self::IrohLocalDiscovery => "iroh.local_discovery",
Self::SyncLiveSyncEnabled => "sync.live_sync_enabled",
Self::SyncLiveSyncIntervalMs => "sync.live_sync_interval_ms",
}
}
fn set(
self,
document: &mut toml_edit::DocumentMut,
raw_value: &str,
) -> Result<(), ConfigError> {
match self {
Self::IrohRelayMode => {
if !matches!(raw_value, "default" | "staging" | "disabled" | "custom") {
return Err(invalid_setting(
self,
raw_value,
"default, staging, disabled, or custom",
));
}
document["iroh"]["relay_mode"] = toml_edit::value(raw_value);
}
Self::IrohRelayMap => {
if raw_value.trim().is_empty() {
return Err(invalid_setting(
self,
raw_value,
"a non-empty relay-map name",
));
}
document["iroh"]["relay_map"] = toml_edit::value(raw_value);
}
Self::IrohLocalDiscovery => {
document["iroh"]["local_discovery"] =
toml_edit::value(parse_bool_setting(self, raw_value)?);
}
Self::SyncLiveSyncEnabled => {
document["sync"]["live_sync_enabled"] =
toml_edit::value(parse_bool_setting(self, raw_value)?);
}
Self::SyncLiveSyncIntervalMs => {
let interval = raw_value
.parse::<i64>()
.ok()
.filter(|interval| *interval >= 100)
.ok_or_else(|| {
invalid_setting(self, raw_value, "an integer of at least 100")
})?;
document["sync"]["live_sync_interval_ms"] = toml_edit::value(interval);
}
}
Ok(())
}
}
impl FromStr for ConfigKey {
type Err = ConfigError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"iroh.relay_mode" => Ok(Self::IrohRelayMode),
"iroh.relay_map" => Ok(Self::IrohRelayMap),
"iroh.local_discovery" => Ok(Self::IrohLocalDiscovery),
"sync.live_sync_enabled" => Ok(Self::SyncLiveSyncEnabled),
"sync.live_sync_interval_ms" => Ok(Self::SyncLiveSyncIntervalMs),
other => Err(ConfigError::UnsupportedKey(other.to_owned())),
}
}
}
fn invalid_setting(key: ConfigKey, value: &str, expected: &'static str) -> ConfigError {
ConfigError::InvalidSetting {
key: key.as_str(),
value: value.to_owned(),
expected,
}
}
fn parse_bool_setting(key: ConfigKey, value: &str) -> Result<bool, ConfigError> {
match value {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(invalid_setting(key, value, "true or false")),
}
2026-05-16 03:17:45 +02:00
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GethConfig {
pub iroh: IrohConfig,
2026-05-20 13:34:16 +02:00
pub sync: SyncConfig,
2026-05-16 03:17:45 +02:00
}
impl GethConfig {
#[must_use]
pub fn default_toml() -> &'static str {
DEFAULT_CONFIG_TOML
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
Self::parse(&std::fs::read_to_string(path)?)
}
pub fn parse(text: &str) -> Result<Self, ConfigError> {
let document = text.parse::<toml_edit::DocumentMut>()?;
2026-05-16 14:28:38 +02:00
let relay_maps = parse_relay_maps(&document)?;
2026-05-16 14:33:45 +02:00
let local_discovery = parse_local_discovery(&document)?;
2026-05-20 13:34:16 +02:00
let sync = parse_sync_config(&document)?;
2026-05-16 03:17:45 +02:00
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()))?;
2026-05-16 14:28:38 +02:00
RelayMode::parse(value, &document, &relay_maps)?
2026-05-16 03:17:45 +02:00
}
None => RelayMode::default(),
};
Ok(Self {
2026-05-16 14:28:38 +02:00
iroh: IrohConfig {
relay_mode,
relay_maps,
2026-05-16 14:33:45 +02:00
local_discovery,
2026-05-16 14:28:38 +02:00
},
2026-05-20 13:34:16 +02:00
sync,
2026-05-16 03:17:45 +02:00
})
}
2026-07-18 16:15:13 +02:00
pub fn set(path: &Path, key: ConfigKey, value: &str) -> Result<Self, ConfigError> {
let text = if path.exists() {
std::fs::read_to_string(path)?
} else {
Self::default_toml().to_owned()
};
let mut document = text.parse::<toml_edit::DocumentMut>()?;
key.set(&mut document, value)?;
let updated = document.to_string();
let config = Self::parse(&updated)?;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(updated.as_bytes())?;
temporary.as_file().sync_all()?;
temporary
.persist(path)
.map_err(|error| ConfigError::Io(error.error))?;
Ok(config)
}
2026-05-16 03:17:45 +02:00
}
2026-05-16 14:33:45 +02:00
#[derive(Clone, Debug, PartialEq, Eq)]
2026-05-16 03:17:45 +02:00
pub struct IrohConfig {
pub relay_mode: RelayMode,
2026-05-16 14:28:38 +02:00
pub relay_maps: BTreeMap<String, RelayMapConfig>,
2026-05-16 14:33:45 +02:00
pub local_discovery: bool,
}
impl Default for IrohConfig {
fn default() -> Self {
Self {
relay_mode: RelayMode::Default,
relay_maps: BTreeMap::new(),
local_discovery: true,
}
}
2026-05-16 14:28:38 +02:00
}
2026-05-20 13:34:16 +02:00
#[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,
}
}
}
2026-05-16 14:28:38 +02:00
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RelayMapConfig {
pub urls: Vec<String>,
2026-05-16 03:17:45 +02:00
}
2026-05-16 14:28:38 +02:00
#[derive(Clone, Debug, Default, PartialEq, Eq)]
2026-05-16 03:17:45 +02:00
pub enum RelayMode {
Disabled,
#[default]
Default,
Staging,
2026-05-16 14:28:38 +02:00
Custom {
map: String,
},
2026-05-16 03:17:45 +02:00
}
impl RelayMode {
2026-05-16 14:28:38 +02:00
fn parse(
value: &str,
document: &toml_edit::DocumentMut,
relay_maps: &BTreeMap<String, RelayMapConfig>,
) -> Result<Self, ConfigError> {
2026-05-16 03:17:45 +02:00
match value {
"disabled" => Ok(Self::Disabled),
"default" => Ok(Self::Default),
"staging" => Ok(Self::Staging),
2026-05-16 14:28:38 +02:00
"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 })
}
2026-05-16 03:17:45 +02:00
other => Err(ConfigError::InvalidRelayMode(other.to_owned())),
}
}
#[must_use]
2026-05-16 14:28:38 +02:00
pub fn label(&self) -> String {
2026-05-16 03:17:45 +02:00
match self {
2026-05-16 14:28:38 +02:00
Self::Disabled => "disabled".to_owned(),
Self::Default => "default".to_owned(),
Self::Staging => "staging".to_owned(),
Self::Custom { map } => format!("custom:{map}"),
2026-05-16 03:17:45 +02:00
}
}
}
2026-05-16 14:28:38 +02:00
fn parse_relay_maps(
document: &toml_edit::DocumentMut,
) -> Result<BTreeMap<String, RelayMapConfig>, 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<Vec<String>, 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(),
}),
}
}
2026-05-16 14:33:45 +02:00
fn parse_local_discovery(document: &toml_edit::DocumentMut) -> Result<bool, ConfigError> {
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),
}
}
2026-05-20 13:34:16 +02:00
fn parse_sync_config(document: &toml_edit::DocumentMut) -> Result<SyncConfig, ConfigError> {
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,
})
}
2026-05-16 03:17:45 +02:00
#[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);
2026-05-16 14:33:45 +02:00
assert!(config.iroh.local_discovery);
2026-05-20 13:34:16 +02:00
assert!(config.sync.live_sync_enabled);
assert_eq!(config.sync.live_sync_interval_ms, 30_000);
2026-05-16 03:17:45 +02:00
}
#[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);
}
2026-05-16 14:28:38 +02:00
#[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");
}
2026-05-16 03:17:45 +02:00
#[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(_)));
}
2026-05-16 14:28:38 +02:00
#[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"));
}
2026-05-16 14:33:45 +02:00
#[test]
fn config_parses_local_discovery_toggle() {
let config = GethConfig::parse("[iroh]\nlocal_discovery = false\n").expect("parse");
assert!(!config.iroh.local_discovery);
}
2026-05-20 13:34:16 +02:00
#[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(_)));
}
2026-05-16 14:33:45 +02:00
#[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(_)));
}
2026-07-18 16:15:13 +02:00
#[test]
fn config_set_preserves_comments_and_validates_the_result() {
let home = tempfile::tempdir().expect("tempdir");
let path = home.path().join("config.toml");
std::fs::write(
&path,
"# operator note\n[iroh]\nrelay_mode = \"default\" # keep relays\n\n[sync]\nlive_sync_enabled = true\nlive_sync_interval_ms = 30000\n",
)
.expect("write config");
let config = GethConfig::set(&path, ConfigKey::SyncLiveSyncIntervalMs, "750")
.expect("update config");
assert_eq!(config.sync.live_sync_interval_ms, 750);
let updated = std::fs::read_to_string(&path).expect("read config");
assert!(updated.contains("# operator note"));
assert!(updated.contains("relay_mode = \"default\" # keep relays"));
assert!(updated.contains("live_sync_interval_ms = 750"));
}
#[test]
fn config_set_does_not_replace_a_valid_file_with_an_invalid_update() {
let home = tempfile::tempdir().expect("tempdir");
let path = home.path().join("config.toml");
let original = GethConfig::default_toml();
std::fs::write(&path, original).expect("write config");
let error = GethConfig::set(&path, ConfigKey::IrohRelayMode, "custom")
.expect_err("custom mode needs an existing map");
assert!(matches!(error, ConfigError::MissingRelayMapSelection));
assert_eq!(
std::fs::read_to_string(path).expect("read config"),
original
);
}
#[test]
fn config_set_creates_a_default_config_when_missing() {
let home = tempfile::tempdir().expect("tempdir");
let path = home.path().join("nested/config.toml");
let config =
GethConfig::set(&path, ConfigKey::IrohLocalDiscovery, "false").expect("create config");
assert!(!config.iroh.local_discovery);
assert!(path.is_file());
}
2026-05-15 15:08:20 +02:00
}