#![allow(
clippy::too_long_first_doc_paragraph,
clippy::missing_const_for_fn,
clippy::option_if_let_else,
clippy::needless_collect
)]
use std::collections::{BTreeMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::protocol::Capability;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RemoteConfigFile {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteConfig {
pub name: String,
pub url: String,
pub capabilities: HashSet<Capability>,
pub token_env: Option<String>,
pub token: Option<SecretToken>,
}
pub use crate::secret_token::SecretToken;
impl RemoteConfig {
#[must_use]
pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
url: url.into(),
capabilities: HashSet::new(),
token_env: None,
token: None,
}
}
#[must_use]
pub fn with_token(mut self, token: SecretToken) -> Self {
self.token = Some(token);
self
}
#[must_use]
pub fn with_capability(mut self, cap: Capability) -> Self {
self.capabilities.insert(cap);
self
}
#[must_use]
pub fn from_file(name: impl Into<String>, file: RemoteConfigFile) -> Self {
let caps = match file.capabilities {
None => Capability::all().iter().copied().collect(),
Some(list) => list
.iter()
.filter_map(|s| s.parse::<Capability>().ok())
.collect(),
};
Self {
name: name.into(),
url: file.url,
capabilities: caps,
token_env: file.token_env,
token: None,
}
}
#[must_use]
pub fn to_file(&self) -> RemoteConfigFile {
let mut caps: Vec<String> = self
.capabilities
.iter()
.map(|c| c.as_wire_str().to_owned())
.collect();
caps.sort();
RemoteConfigFile {
url: self.url.clone(),
capabilities: if caps.is_empty() { None } else { Some(caps) },
token_env: self.token_env.clone(),
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct RemoteSection {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub remote: BTreeMap<String, RemoteConfigFile>,
}
impl RemoteSection {
pub fn into_runtime(self) -> impl Iterator<Item = RemoteConfig> {
self.remote
.into_iter()
.map(|(name, file)| RemoteConfig::from_file(name, file))
}
}
pub fn parse_config(s: &str) -> Result<RemoteSection, toml::de::Error> {
toml::from_str(s)
}
pub fn serialize_config(section: &RemoteSection) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(section)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_TOML: &str = r#"
[remote.origin]
url = "https://example.com/repo/alice/notes"
capabilities = ["have-set-bloom", "atomic-push"]
token_env = "MNEM_ORIGIN_TOKEN"
[remote.backup]
url = "file:///srv/mnem-mirrors/notes.car"
"#;
#[test]
fn parse_config_extracts_named_remotes() {
let parsed = parse_config(SAMPLE_TOML).unwrap();
assert_eq!(parsed.remote.len(), 2);
let origin = &parsed.remote["origin"];
assert_eq!(origin.url, "https://example.com/repo/alice/notes");
assert_eq!(
origin.capabilities.as_ref().unwrap(),
&vec!["have-set-bloom".to_owned(), "atomic-push".to_owned()],
);
assert_eq!(origin.token_env.as_deref(), Some("MNEM_ORIGIN_TOKEN"));
let backup = &parsed.remote["backup"];
assert_eq!(backup.url, "file:///srv/mnem-mirrors/notes.car");
assert!(backup.capabilities.is_none());
assert!(backup.token_env.is_none());
}
#[test]
fn from_file_resolves_capabilities() {
let file = RemoteConfigFile {
url: "https://example.com".into(),
capabilities: Some(vec![
"have-set-bloom".into(),
"atomic-push".into(),
"no-such-capability".into(),
]),
token_env: None,
};
let cfg = RemoteConfig::from_file("origin", file);
assert_eq!(cfg.name, "origin");
assert_eq!(cfg.capabilities.len(), 2);
assert!(cfg.capabilities.contains(&Capability::HaveSetBloom));
assert!(cfg.capabilities.contains(&Capability::AtomicPush));
}
#[test]
fn from_file_missing_capabilities_means_all() {
let file = RemoteConfigFile {
url: "https://example.com".into(),
capabilities: None,
token_env: None,
};
let cfg = RemoteConfig::from_file("origin", file);
assert_eq!(cfg.capabilities.len(), Capability::all().len());
}
#[test]
fn remote_config_round_trips_through_toml() {
let parsed = parse_config(SAMPLE_TOML).unwrap();
let runtime: Vec<RemoteConfig> = parsed.clone().into_runtime().collect();
let round_tripped = RemoteSection {
remote: runtime
.into_iter()
.map(|r| (r.name.clone(), r.to_file()))
.collect(),
};
let emitted = serialize_config(&round_tripped).unwrap();
let re_parsed = parse_config(&emitted).unwrap();
assert_eq!(re_parsed.remote.len(), parsed.remote.len());
for (name, file) in &parsed.remote {
assert_eq!(re_parsed.remote[name].url, file.url);
}
let origin_caps = re_parsed.remote["origin"].capabilities.as_ref().unwrap();
assert!(origin_caps.contains(&"have-set-bloom".to_owned()));
assert!(origin_caps.contains(&"atomic-push".to_owned()));
}
#[test]
fn secret_token_debug_redacts() {
let tok = SecretToken::new("super-secret-1234");
let dbg = format!("{tok:?}");
assert!(
!dbg.contains("super-secret-1234"),
"debug leaked token: {dbg}"
);
assert_eq!(dbg, "SecretToken(***)");
assert_eq!(tok.reveal(), "super-secret-1234");
}
#[test]
fn remote_config_debug_redacts_token() {
let cfg = RemoteConfig::new("origin", "https://example.com")
.with_token(SecretToken::new("abc123"));
let dbg = format!("{cfg:?}");
assert!(!dbg.contains("abc123"), "token leaked in debug: {dbg}");
}
#[test]
fn token_never_serialised_to_toml() {
let cfg = RemoteConfig::new("origin", "https://example.com")
.with_token(SecretToken::new("abc123"));
let file = cfg.to_file();
let s = toml::to_string_pretty(&file).unwrap();
assert!(!s.contains("abc123"), "token leaked through to_file: {s}");
}
#[test]
fn empty_config_parses_to_empty_section() {
let parsed = parse_config("").unwrap();
assert!(parsed.remote.is_empty());
}
}