Skip to main content

mnem_transport/
remote.rs

1//! [`RemoteConfig`] and the `.mnem/config.toml` `[remote.<name>]`
2//! schema.
3//!
4//! Scope of this module is deliberately narrow: a `RemoteConfig` is a
5//! pure data record describing where a remote mnem repository lives
6//! and which capabilities the local peer will advertise for it. There
7//! is no network code here. The on-disk `config.toml` section is
8//! parsed into a [`RemoteConfigFile`] and serialised back out again;
9//! that map is how `mnem-cli`'s future `remote add / remove / list`
10//! verbs will talk to this crate.
11//!
12//! ## Config file schema (v0)
13//!
14//! ```toml
15//! [remote.origin]
16//! url = "https://example.com/repo/alice/notes"
17//! # Optional capability overrides. When omitted, the client advertises
18//! # every capability this build supports.
19//! capabilities = ["have-set-bloom", "atomic-push"]
20//! # Optional: name of an environment variable holding the bearer
21//! # token. The token itself is NEVER stored in config.toml.
22//! token_env = "MNEM_ORIGIN_TOKEN"
23//!
24//! [remote.backup]
25//! url = "file:///srv/mnem-mirrors/notes.car"
26//! ```
27//!
28//! ## Security model
29//!
30//! `RemoteConfig::token` is populated at run time from the process
31//! environment (or, later, a platform keychain via `mnem remote
32//! set-token`). It MUST NOT appear on disk. A `RemoteConfig` parsed
33//! from a `config.toml` always starts with `token = None`; callers
34//! wire up authentication by reading `token_env`, looking up the
35//! environment variable, and calling
36//! [`RemoteConfig::with_token`]. That step is a CLI-layer
37//! responsibility; this crate deliberately does not touch `std::env`.
38
39// Pedantic doc-length + closure warnings on design-heavy modules
40// are opinionated; prose and explicit match arms are deliberate.
41#![allow(
42    clippy::too_long_first_doc_paragraph,
43    clippy::missing_const_for_fn,
44    clippy::option_if_let_else,
45    clippy::needless_collect
46)]
47
48use std::collections::{BTreeMap, HashSet};
49
50use serde::{Deserialize, Serialize};
51
52use crate::protocol::Capability;
53
54/// On-disk shape of a single `[remote.<name>]` section.
55#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
56pub struct RemoteConfigFile {
57    /// Remote URL. Any URL scheme is accepted at the config layer;
58    /// interpretation (`https://`, `file://`, `mnem+ssh://`, ...) is
59    /// up to the transport driver. PR 2 does not implement any
60    /// drivers.
61    pub url: String,
62    /// Optional capability allow-list. When `None`, the client
63    /// advertises every capability in [`Capability::all`] that this
64    /// build knows. When `Some`, only these capabilities are
65    /// advertised (useful for interop-testing against older servers
66    /// or for opting out of `filter-spec`).
67    ///
68    /// Unknown capability strings on the wire are tolerated and
69    /// silently dropped (forward-compat). A `config.toml` file that
70    /// lists an unknown string will likewise parse into this
71    /// capability set with the unknown dropped. This matches the
72    /// forward-compat rule in [`crate::protocol::parse_capabilities`].
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub capabilities: Option<Vec<String>>,
75    /// Optional name of an environment variable holding the bearer
76    /// token. When set, the CLI layer reads that variable at request
77    /// time and injects the token via [`RemoteConfig::with_token`].
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub token_env: Option<String>,
80}
81
82/// In-memory representation of a single remote. Unlike
83/// [`RemoteConfigFile`] this type holds the parsed capability set,
84/// optionally holds a runtime-only bearer token, and is what the
85/// network layer actually consumes.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct RemoteConfig {
88    /// Short name for this remote (`origin`, `backup`, ...). Matches
89    /// the `[remote.<NAME>]` section header the config was parsed
90    /// from. Used as a key in [`View::remote_refs`][remote_refs] so
91    /// tracking refs render as `origin/main`, `backup/release`, ...
92    ///
93    /// [remote_refs]: mnem_core::objects::View::remote_refs
94    pub name: String,
95    /// Remote URL. See [`RemoteConfigFile::url`].
96    pub url: String,
97    /// Capability allow-list advertised by the local peer when
98    /// talking to this remote. Empty means "advertise every built-in
99    /// capability"; non-empty restricts the ad.
100    pub capabilities: HashSet<Capability>,
101    /// Optional environment variable name from which the CLI layer
102    /// will load the bearer token at run time. Persisted in
103    /// `config.toml`; the token itself never is.
104    pub token_env: Option<String>,
105    /// Optional bearer token. Populated from the environment (or a
106    /// future platform keychain) by the CLI / HTTP client, never by
107    /// reading `config.toml`. `Debug` intentionally redacts this
108    /// field to keep accidental `println!("{cfg:?}")` calls safe.
109    pub token: Option<SecretToken>,
110}
111
112// `SecretToken` lives in [`crate::secret_token`]. Re-exported at the
113// crate root and from this module so historic `use
114// crate::remote::SecretToken` paths keep compiling.
115pub use crate::secret_token::SecretToken;
116
117impl RemoteConfig {
118    /// Build a fresh [`RemoteConfig`] with no capabilities and no
119    /// token. Callers typically go through [`Self::from_file`] or a
120    /// top-level [`parse_config`] call; this constructor is for tests
121    /// and programmatic use.
122    #[must_use]
123    pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
124        Self {
125            name: name.into(),
126            url: url.into(),
127            capabilities: HashSet::new(),
128            token_env: None,
129            token: None,
130        }
131    }
132
133    /// Attach a bearer token at run time. Intended to be called
134    /// exactly once by the CLI after reading `token_env` out of the
135    /// process environment.
136    #[must_use]
137    pub fn with_token(mut self, token: SecretToken) -> Self {
138        self.token = Some(token);
139        self
140    }
141
142    /// Add a capability to the local peer's advertised set.
143    #[must_use]
144    pub fn with_capability(mut self, cap: Capability) -> Self {
145        self.capabilities.insert(cap);
146        self
147    }
148
149    /// Merge a parsed `[remote.<name>]` file section with its name
150    /// (from the section header) into a runtime [`RemoteConfig`].
151    ///
152    /// Unknown capability strings are silently dropped
153    /// (forward-compat). An empty / absent capability list in the
154    /// file becomes the full built-in capability set at runtime.
155    #[must_use]
156    pub fn from_file(name: impl Into<String>, file: RemoteConfigFile) -> Self {
157        let caps = match file.capabilities {
158            None => Capability::all().iter().copied().collect(),
159            Some(list) => list
160                .iter()
161                .filter_map(|s| s.parse::<Capability>().ok())
162                .collect(),
163        };
164        Self {
165            name: name.into(),
166            url: file.url,
167            capabilities: caps,
168            token_env: file.token_env,
169            token: None,
170        }
171    }
172
173    /// Project back into the `[remote.<name>]` on-disk shape, suitable
174    /// for round-tripping through `toml::to_string_pretty`. Tokens are
175    /// never written out.
176    ///
177    /// Capability ordering in the output is wire-string ascending, so
178    /// two configs with the same logical capability set round-trip to
179    /// byte-identical TOML.
180    #[must_use]
181    pub fn to_file(&self) -> RemoteConfigFile {
182        let mut caps: Vec<String> = self
183            .capabilities
184            .iter()
185            .map(|c| c.as_wire_str().to_owned())
186            .collect();
187        caps.sort();
188        RemoteConfigFile {
189            url: self.url.clone(),
190            capabilities: if caps.is_empty() { None } else { Some(caps) },
191            token_env: self.token_env.clone(),
192        }
193    }
194}
195
196/// Parsed `.mnem/config.toml` fragment carrying a `[remote.<name>]`
197/// table. This is just the remote section; the real config in
198/// `mnem-cli` is a superset that flattens over the top.
199#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
200pub struct RemoteSection {
201    /// One entry per `[remote.<name>]` section. `BTreeMap` for
202    /// deterministic iteration and output ordering.
203    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
204    pub remote: BTreeMap<String, RemoteConfigFile>,
205}
206
207impl RemoteSection {
208    /// Convert every parsed `[remote.<name>]` into a runtime
209    /// [`RemoteConfig`]. Iterator order matches `BTreeMap` iteration
210    /// (ascending by remote name).
211    pub fn into_runtime(self) -> impl Iterator<Item = RemoteConfig> {
212        self.remote
213            .into_iter()
214            .map(|(name, file)| RemoteConfig::from_file(name, file))
215    }
216}
217
218/// Parse a `.mnem/config.toml` payload and return every
219/// `[remote.<name>]` section it contains. Other top-level tables are
220/// ignored, so this function is safe to call on the full
221/// `config.toml` and can be combined with other parsers that pick
222/// different sections off the same payload.
223///
224/// # Errors
225///
226/// Returns the underlying [`toml::de::Error`] on malformed TOML.
227pub fn parse_config(s: &str) -> Result<RemoteSection, toml::de::Error> {
228    toml::from_str(s)
229}
230
231/// Inverse of [`parse_config`]: serialise a [`RemoteSection`] back to
232/// TOML. The output is pretty-printed and sorted deterministically
233/// (because `BTreeMap`).
234///
235/// # Errors
236///
237/// Returns the underlying [`toml::ser::Error`] on serialisation
238/// failure; this should not happen for the shapes defined here.
239pub fn serialize_config(section: &RemoteSection) -> Result<String, toml::ser::Error> {
240    toml::to_string_pretty(section)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    const SAMPLE_TOML: &str = r#"
248[remote.origin]
249url = "https://example.com/repo/alice/notes"
250capabilities = ["have-set-bloom", "atomic-push"]
251token_env = "MNEM_ORIGIN_TOKEN"
252
253[remote.backup]
254url = "file:///srv/mnem-mirrors/notes.car"
255"#;
256
257    #[test]
258    fn parse_config_extracts_named_remotes() {
259        let parsed = parse_config(SAMPLE_TOML).unwrap();
260        assert_eq!(parsed.remote.len(), 2);
261        let origin = &parsed.remote["origin"];
262        assert_eq!(origin.url, "https://example.com/repo/alice/notes");
263        assert_eq!(
264            origin.capabilities.as_ref().unwrap(),
265            &vec!["have-set-bloom".to_owned(), "atomic-push".to_owned()],
266        );
267        assert_eq!(origin.token_env.as_deref(), Some("MNEM_ORIGIN_TOKEN"));
268
269        let backup = &parsed.remote["backup"];
270        assert_eq!(backup.url, "file:///srv/mnem-mirrors/notes.car");
271        assert!(backup.capabilities.is_none());
272        assert!(backup.token_env.is_none());
273    }
274
275    #[test]
276    fn from_file_resolves_capabilities() {
277        let file = RemoteConfigFile {
278            url: "https://example.com".into(),
279            capabilities: Some(vec![
280                "have-set-bloom".into(),
281                "atomic-push".into(),
282                "no-such-capability".into(),
283            ]),
284            token_env: None,
285        };
286        let cfg = RemoteConfig::from_file("origin", file);
287        assert_eq!(cfg.name, "origin");
288        // Unknown string is silently dropped; known ones survive.
289        assert_eq!(cfg.capabilities.len(), 2);
290        assert!(cfg.capabilities.contains(&Capability::HaveSetBloom));
291        assert!(cfg.capabilities.contains(&Capability::AtomicPush));
292    }
293
294    #[test]
295    fn from_file_missing_capabilities_means_all() {
296        let file = RemoteConfigFile {
297            url: "https://example.com".into(),
298            capabilities: None,
299            token_env: None,
300        };
301        let cfg = RemoteConfig::from_file("origin", file);
302        // Missing capability list == advertise everything built-in.
303        assert_eq!(cfg.capabilities.len(), Capability::all().len());
304    }
305
306    #[test]
307    fn remote_config_round_trips_through_toml() {
308        // Parse sample, convert to runtime, back to file shape, emit
309        // TOML, re-parse, and check semantic equality.
310        let parsed = parse_config(SAMPLE_TOML).unwrap();
311        let runtime: Vec<RemoteConfig> = parsed.clone().into_runtime().collect();
312        let round_tripped = RemoteSection {
313            remote: runtime
314                .into_iter()
315                .map(|r| (r.name.clone(), r.to_file()))
316                .collect(),
317        };
318        let emitted = serialize_config(&round_tripped).unwrap();
319        let re_parsed = parse_config(&emitted).unwrap();
320        assert_eq!(re_parsed.remote.len(), parsed.remote.len());
321        // URLs survive exactly.
322        for (name, file) in &parsed.remote {
323            assert_eq!(re_parsed.remote[name].url, file.url);
324        }
325        // origin's explicit capability list survives (unknown strings
326        // are never produced by the runtime so nothing is dropped here).
327        let origin_caps = re_parsed.remote["origin"].capabilities.as_ref().unwrap();
328        assert!(origin_caps.contains(&"have-set-bloom".to_owned()));
329        assert!(origin_caps.contains(&"atomic-push".to_owned()));
330    }
331
332    #[test]
333    fn secret_token_debug_redacts() {
334        let tok = SecretToken::new("super-secret-1234");
335        let dbg = format!("{tok:?}");
336        assert!(
337            !dbg.contains("super-secret-1234"),
338            "debug leaked token: {dbg}"
339        );
340        // New Debug shape (see `crate::secret_token`): fixed mask so
341        // even the byte-length doesn't leak.
342        assert_eq!(dbg, "SecretToken(***)");
343        assert_eq!(tok.reveal(), "super-secret-1234");
344    }
345
346    #[test]
347    fn remote_config_debug_redacts_token() {
348        let cfg = RemoteConfig::new("origin", "https://example.com")
349            .with_token(SecretToken::new("abc123"));
350        let dbg = format!("{cfg:?}");
351        assert!(!dbg.contains("abc123"), "token leaked in debug: {dbg}");
352    }
353
354    #[test]
355    fn token_never_serialised_to_toml() {
356        let cfg = RemoteConfig::new("origin", "https://example.com")
357            .with_token(SecretToken::new("abc123"));
358        let file = cfg.to_file();
359        let s = toml::to_string_pretty(&file).unwrap();
360        assert!(!s.contains("abc123"), "token leaked through to_file: {s}");
361    }
362
363    #[test]
364    fn empty_config_parses_to_empty_section() {
365        let parsed = parse_config("").unwrap();
366        assert!(parsed.remote.is_empty());
367    }
368}