Add safe configuration commands

This commit is contained in:
Eric Wendland 2026-07-18 16:15:13 +02:00
commit 443602a30b
7 changed files with 432 additions and 3 deletions

View file

@ -1,5 +1,7 @@
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::str::FromStr;
pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
# Remote node-to-node communication is Iroh-only.
@ -124,6 +126,117 @@ pub enum ConfigError {
InvalidLiveSyncEnabled(String),
#[error("invalid sync live_sync_interval_ms value: {0}")]
InvalidLiveSyncInterval(String),
#[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")),
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@ -169,6 +282,28 @@ impl GethConfig {
sync,
})
}
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)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -467,4 +602,50 @@ mod tests {
let error = GethConfig::parse("[iroh]\nlocal_discovery = \"yes\"\n").expect_err("error");
assert!(matches!(error, ConfigError::InvalidLocalDiscovery(_)));
}
#[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());
}
}