Add safe configuration commands
This commit is contained in:
parent
c20cc47914
commit
443602a30b
7 changed files with 432 additions and 3 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue