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}