Skip to main content

majordomus_cli/mesh/
root.rs

1//! The trust root: which copy of the mesh declaration a runtime obeys (ADR 0050 rule 6,
2//! I2135).
3//!
4//! The declaration names who this runtime trusts (`trust.policy`, `trust.allow`) and where it
5//! sends its card and envelope (`rendezvous.endpoints`, `cooperation.seeds`,
6//! `broadcast.networks`). Until this module, the server read it from the checkout's working
7//! tree, so a branch — or an uncommitted edit — could add a key, point the runtime at a host
8//! of its author's choosing, or switch the policy to trust on first use, and that checkout's
9//! runtime obeyed. A reviewer running a contributor's branch is exactly the person that
10//! reaches.
11//!
12//! The rule is that a working tree may **narrow** trust and never **widen** it. When the
13//! trunk holds a committed copy of the declaration at the same path, the working tree's
14//! declaration is used with every widening removed: the mesh is on only if the trunk's copy
15//! is on as well, the policy is no looser than the trunk's, and every key and every address
16//! is one the trunk's copy also lists — or a loopback address, which reaches no other host. When the trunk holds no copy — a repository that has
17//! never committed one, or a directory that is not a git repository — the working tree is
18//! all there is, and the one widening that needs no address at all, trust on first use, is
19//! refused. Every widening removed is named, so `mesh doctor` can say what this checkout's
20//! runtime ignores and why.
21//!
22//! The trunk is the repository's, as [`crate::worktree::RepositoryIdentity`] names it: the
23//! local branch of that name. A stale local trunk is older trust, which is the safe
24//! direction.
25//!
26//! ```
27//! use majordomus_cli::mesh::root::{narrow, resolve};
28//! use majordomus_cli::mesh::MeshConfig;
29//! // no declaration in the working tree: nothing is obeyed and nothing was narrowed
30//! let nowhere = tempfile::tempdir().unwrap();
31//! let none = resolve(nowhere.path(), None);
32//! assert!(none.declaration.is_none() && none.narrowed.is_empty());
33//! // a declaration held against the trunk's copy keeps only what the trunk also says
34//! let parse = |v: serde_json::Value| MeshConfig::from_metadata(&v, "x").unwrap();
35//! let trunk = parse(serde_json::json!({"schema": "mesh/v1", "kind": "mesh-declaration", "id": "x",
36//!     "trust": {"allow": ["aa"]}}));
37//! let branch = parse(serde_json::json!({"schema": "mesh/v1", "kind": "mesh-declaration", "id": "x",
38//!     "trust": {"allow": ["aa", "bb"]}}));
39//! let (held, narrowed) = narrow(branch, &trunk);
40//! assert_eq!(held.trust.allow, vec!["aa".to_string()]);
41//! assert_eq!(narrowed, vec!["trust.allow: bb is not on the trunk".to_string()]);
42//! ```
43
44use std::path::Path;
45
46use serde_json::Value;
47
48use super::config::MeshConfig;
49use super::trust::TrustPolicy;
50use super::MeshError;
51use crate::model::Object;
52
53/// The declaration in force, and how it was arrived at.
54///
55/// ```
56/// use majordomus_cli::mesh::root::{resolve, Resolved};
57/// let nowhere = tempfile::tempdir().unwrap();
58/// let resolved: Resolved = resolve(nowhere.path(), None);
59/// assert!(resolved.declaration.is_none());
60/// assert!(!resolved.committed && resolved.trunk.is_none());
61/// ```
62#[derive(Debug)]
63pub struct Resolved {
64    /// What the runtime obeys: `None` when there is no declaration, an error when the one
65    /// there is (or the trunk's copy) does not parse.
66    pub declaration: Option<Result<MeshConfig, MeshError>>,
67    /// The trunk that was consulted, when one could be named.
68    pub trunk: Option<String>,
69    /// Whether the trunk holds a committed copy at the declaration's path.
70    pub committed: bool,
71    /// Each widening of the working tree that was not admitted, as one line naming the key.
72    pub narrowed: Vec<String>,
73}
74
75/// Resolve the declaration in force for the repository at `root`, given the working tree's
76/// declaration object as the index found it. `None` means the index found none, and then
77/// there is nothing to obey and nothing to ask git.
78///
79/// ```
80/// use majordomus_cli::mesh::root::resolve;
81/// let nowhere = tempfile::tempdir().unwrap();
82/// assert!(resolve(nowhere.path(), None).narrowed.is_empty());
83/// ```
84pub fn resolve(root: &Path, working: Option<&Object>) -> Resolved {
85    let Some(object) = working else {
86        return Resolved {
87            declaration: None,
88            trunk: None,
89            committed: false,
90            narrowed: Vec::new(),
91        };
92    };
93    let trunk = crate::worktree::RepositoryIdentity::discover(root)
94        .ok()
95        .and_then(|identity| identity.trunk().branch.clone());
96    let config = match MeshConfig::parse(object) {
97        Ok(config) => config,
98        Err(e) => {
99            return Resolved {
100                declaration: Some(Err(e)),
101                trunk,
102                committed: false,
103                narrowed: Vec::new(),
104            }
105        }
106    };
107    let held = trunk
108        .as_deref()
109        .and_then(|branch| committed(root, branch, &object.provenance.path));
110    match held {
111        Some(Ok(on_trunk)) => {
112            let (config, narrowed) = narrow(config, &on_trunk);
113            Resolved {
114                declaration: Some(Ok(config)),
115                trunk,
116                committed: true,
117                narrowed,
118            }
119        }
120        // A trunk copy that does not parse is not a reason to trust the working tree
121        // instead: the mesh stays off, with the trunk's own refusal as the reason.
122        Some(Err(e)) => Resolved {
123            declaration: Some(Err(e)),
124            trunk,
125            committed: true,
126            narrowed: Vec::new(),
127        },
128        None => {
129            let (config, narrowed) = uncommitted(config);
130            Resolved {
131                declaration: Some(Ok(config)),
132                trunk,
133                committed: false,
134                narrowed,
135            }
136        }
137    }
138}
139
140/// The trunk's committed copy of the declaration at `path`, parsed; `None` when the trunk
141/// holds no file there or git cannot be asked.
142fn committed(root: &Path, branch: &str, path: &str) -> Option<Result<MeshConfig, MeshError>> {
143    let spec = format!("refs/heads/{branch}:{path}");
144    let out = crate::git::read_only(root)
145        .args(["show", &spec])
146        .output()
147        .ok()
148        .filter(|out| out.status.success())?;
149    let named = format!("{branch}:{path}");
150    let parsed = String::from_utf8(out.stdout)
151        .map_err(|e| e.to_string())
152        .and_then(|text| crate::metadata::yaml::parse_mapping(&text))
153        .map_err(|e| MeshError::Config(format!("{named}: {e}")))
154        .and_then(|map| MeshConfig::from_metadata(&Value::Object(map), &named));
155    Some(parsed)
156}
157
158/// How much a policy trusts a key it has not been told about: nothing, or its first
159/// appearance.
160fn looseness(policy: &TrustPolicy) -> u8 {
161    match policy {
162        TrustPolicy::DenyUnknown | TrustPolicy::Allowlist => 0,
163        TrustPolicy::Tofu => 1,
164    }
165}
166
167/// The working tree's declaration with every widening of `trunk` removed, and a line for each.
168///
169/// ```
170/// use majordomus_cli::mesh::root::narrow;
171/// use majordomus_cli::mesh::MeshConfig;
172/// let parse = |v: serde_json::Value| MeshConfig::from_metadata(&v, "x").unwrap();
173/// let trunk = parse(serde_json::json!({
174///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "enabled": true,
175///     "trust": { "policy": "deny_unknown", "allow": ["aa"] },
176/// }));
177/// let branch = parse(serde_json::json!({
178///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "enabled": true,
179///     "trust": { "policy": "tofu", "allow": ["aa", "bb"] },
180///     "rendezvous": { "endpoints": ["http://198.51.100.7:8791"] },
181/// }));
182/// let (held, narrowed) = narrow(branch, &trunk);
183/// assert_eq!(held.trust.allow, vec!["aa".to_string()]);
184/// assert_eq!(held.trust.policy, trunk.trust.policy);
185/// assert!(held.rendezvous.endpoints.is_empty());
186/// assert_eq!(narrowed.len(), 3, "{narrowed:?}");
187/// // narrowing is always admitted: a branch that trusts less is obeyed as it is
188/// let (same, none) = narrow(trunk.clone(), &trunk);
189/// assert!(none.is_empty() && same.trust.allow == trunk.trust.allow);
190/// ```
191pub fn narrow(mut working: MeshConfig, trunk: &MeshConfig) -> (MeshConfig, Vec<String>) {
192    let mut narrowed = Vec::new();
193    if working.enabled && !trunk.enabled {
194        working.enabled = false;
195        narrowed.push("enabled: the trunk's declaration is disabled".to_string());
196    }
197    if looseness(&working.trust.policy) > looseness(&trunk.trust.policy) {
198        narrowed.push(format!(
199            "trust.policy: {} is looser than the trunk's {}",
200            working.trust.policy.as_str(),
201            trunk.trust.policy.as_str()
202        ));
203        working.trust.policy = trunk.trust.policy.clone();
204    }
205    keep(
206        &mut working.trust.allow,
207        &trunk.trust.allow,
208        "trust.allow",
209        &mut narrowed,
210        |_| false,
211    );
212    keep(
213        &mut working.rendezvous.endpoints,
214        &trunk.rendezvous.endpoints,
215        "rendezvous.endpoints",
216        &mut narrowed,
217        is_loopback,
218    );
219    keep(
220        &mut working.cooperation.seeds,
221        &trunk.cooperation.seeds,
222        "cooperation.seeds",
223        &mut narrowed,
224        is_loopback,
225    );
226    keep(
227        &mut working.broadcast.networks,
228        &trunk.broadcast.networks,
229        "broadcast.networks",
230        &mut narrowed,
231        |_| false,
232    );
233    (working, narrowed)
234}
235
236/// Does an endpoint or a seed (`http://host:port`, or `host:port`) reach only this machine?
237/// A loopback address carries the card and the envelope nowhere a person on another host
238/// can read them, which is how two worktrees of one machine find each other without a hub,
239/// so a working tree may name one without the trunk's say-so.
240///
241/// ```
242/// use majordomus_cli::mesh::root::is_loopback;
243/// assert!(is_loopback("http://127.0.0.1:8791"));
244/// assert!(is_loopback("http://localhost:7"));
245/// assert!(is_loopback("[::1]:8791"));
246/// assert!(!is_loopback("http://192.168.100.30:8791"));
247/// assert!(!is_loopback("http://127.0.0.1.attacker.example:8791"));
248/// ```
249pub fn is_loopback(endpoint: &str) -> bool {
250    let rest = endpoint
251        .strip_prefix("http://")
252        .or_else(|| endpoint.strip_prefix("https://"))
253        .unwrap_or(endpoint);
254    let authority = rest.split('/').next().unwrap_or_default();
255    let host = match authority.strip_prefix('[') {
256        Some(v6) => v6.split(']').next().unwrap_or_default(),
257        None => authority.rsplit_once(':').map_or(authority, |(h, _)| h),
258    };
259    host.eq_ignore_ascii_case("localhost")
260        || host
261            .parse::<std::net::IpAddr>()
262            .is_ok_and(|ip| ip.to_canonical().is_loopback())
263}
264
265/// A declaration no trunk holds: obeyed as written, except trust on first use.
266fn uncommitted(mut working: MeshConfig) -> (MeshConfig, Vec<String>) {
267    let mut narrowed = Vec::new();
268    if working.trust.policy == TrustPolicy::Tofu {
269        working.trust.policy = TrustPolicy::DenyUnknown;
270        narrowed.push(
271            "trust.policy: tofu is honoured only from a declaration committed on the trunk"
272                .to_string(),
273        );
274    }
275    (working, narrowed)
276}
277
278/// Keep the entries of `working` that `trunk` also lists, or that `local` says reach only
279/// this machine, naming each one dropped.
280fn keep(
281    working: &mut Vec<String>,
282    trunk: &[String],
283    key: &str,
284    narrowed: &mut Vec<String>,
285    local: fn(&str) -> bool,
286) {
287    working.retain(|entry| {
288        let held = trunk.contains(entry) || local(entry);
289        if !held {
290            narrowed.push(format!("{key}: {entry} is not on the trunk"));
291        }
292        held
293    });
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn config(v: serde_json::Value) -> MeshConfig {
301        MeshConfig::from_metadata(&v, "test").unwrap()
302    }
303
304    #[test]
305    fn an_uncommitted_tofu_is_refused_and_everything_else_is_obeyed() {
306        let (held, narrowed) = uncommitted(config(serde_json::json!({
307            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "enabled": true,
308            "trust": { "policy": "tofu", "allow": ["aa"] },
309        })));
310        assert_eq!(held.trust.policy, TrustPolicy::DenyUnknown);
311        assert_eq!(held.trust.allow, vec!["aa".to_string()]);
312        assert!(held.enabled);
313        assert_eq!(narrowed.len(), 1);
314    }
315
316    /// The declaration the index found in the working tree.
317    fn working(metadata: serde_json::Value) -> Object {
318        Object {
319            kind: "mesh-declaration".into(),
320            identity: "majordomus".into(),
321            uri: "majordomus://mesh-declaration/majordomus".into(),
322            title: None,
323            description: None,
324            metadata,
325            body: String::new(),
326            content: String::new(),
327            media_type: "application/yaml",
328            provenance: crate::model::Provenance {
329                path: ".ai/repo/mesh/majordomus.yaml".into(),
330                directory: ".ai/repo/mesh".into(),
331                source_class: "mesh-declaration".into(),
332                section: None,
333                bytes: 0,
334                member: None,
335            },
336        }
337    }
338
339    /// A repository whose trunk, `master`, holds `committed` at the declaration's path, or
340    /// holds no declaration at all.
341    fn trunk_holding(committed: Option<&str>) -> tempfile::TempDir {
342        let dir = tempfile::tempdir().unwrap();
343        let git = |args: &[&str]| {
344            let status = std::process::Command::new("git")
345                .args(["-c", "user.email=t@t.invalid", "-c", "user.name=t"])
346                .args(args)
347                .current_dir(dir.path())
348                .output()
349                .unwrap()
350                .status;
351            assert!(status.success(), "git {args:?}");
352        };
353        git(&["init", "-q", "-b", "master"]);
354        std::fs::write(dir.path().join("README.md"), "r\n").unwrap();
355        if let Some(text) = committed {
356            std::fs::create_dir_all(dir.path().join(".ai/repo/mesh")).unwrap();
357            std::fs::write(dir.path().join(".ai/repo/mesh/majordomus.yaml"), text).unwrap();
358        }
359        git(&["add", "-A"]);
360        git(&["commit", "-q", "-m", "trunk"]);
361        dir
362    }
363
364    const TRUNK: &str =
365        "schema: mesh/v1\nkind: mesh-declaration\nid: majordomus\nenabled: true\ntrust:\n  policy: deny_unknown\n";
366
367    fn tofu() -> serde_json::Value {
368        serde_json::json!({
369            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "majordomus",
370            "enabled": true, "trust": { "policy": "tofu" },
371        })
372    }
373
374    #[test]
375    fn the_trunk_copy_narrows_the_working_tree_and_a_copy_that_does_not_parse_refuses() {
376        // committed: the trunk's deny_unknown holds against the working tree's tofu
377        let repo = trunk_holding(Some(TRUNK));
378        let resolved = resolve(repo.path(), Some(&working(tofu())));
379        assert_eq!(resolved.trunk.as_deref(), Some("master"));
380        assert!(resolved.committed);
381        let held = resolved.declaration.unwrap().unwrap();
382        assert_eq!(held.trust.policy, TrustPolicy::DenyUnknown);
383        assert_eq!(resolved.narrowed.len(), 1, "{:?}", resolved.narrowed);
384
385        // a working tree that does not parse is refused, whatever the trunk says
386        let broken = resolve(
387            repo.path(),
388            Some(&working(serde_json::json!({ "schema": "mesh/v0" }))),
389        );
390        assert!(matches!(broken.declaration, Some(Err(_))));
391        assert!(!broken.committed);
392
393        // a trunk copy that does not parse turns the mesh off with the trunk's refusal,
394        // rather than trusting the working tree instead
395        let repo = trunk_holding(Some("schema: mesh/v0\nkind: mesh-declaration\nid: x\n"));
396        let resolved = resolve(repo.path(), Some(&working(tofu())));
397        assert!(resolved.committed);
398        let refusal = resolved.declaration.unwrap().unwrap_err().to_string();
399        assert!(
400            refusal.contains("master:.ai/repo/mesh/majordomus.yaml"),
401            "{refusal}"
402        );
403    }
404
405    #[test]
406    fn a_trunk_copy_that_is_not_text_or_not_yaml_is_a_refusal_naming_it() {
407        // not UTF-8: refused as the trunk's copy, never read as absent
408        let repo = trunk_holding(None);
409        std::fs::create_dir_all(repo.path().join(".ai/repo/mesh")).unwrap();
410        std::fs::write(
411            repo.path().join(".ai/repo/mesh/majordomus.yaml"),
412            [0xff, 0xfe, 0x00],
413        )
414        .unwrap();
415        let git = |args: &[&str]| {
416            let ok = std::process::Command::new("git")
417                .args(["-c", "user.email=t@t.invalid", "-c", "user.name=t"])
418                .args(args)
419                .current_dir(repo.path())
420                .output()
421                .unwrap()
422                .status
423                .success();
424            assert!(ok, "git {args:?}");
425        };
426        git(&["add", "-A"]);
427        git(&["commit", "-q", "-m", "binary"]);
428        let refused = committed(repo.path(), "master", ".ai/repo/mesh/majordomus.yaml")
429            .expect("the trunk holds a file there")
430            .unwrap_err()
431            .to_string();
432        assert!(
433            refused.contains("master:.ai/repo/mesh/majordomus.yaml"),
434            "{refused}"
435        );
436
437        // not YAML: the parser's refusal, named by the trunk's path
438        let repo = trunk_holding(Some("schema: [mesh/v1\nkind: : :\n"));
439        let refused = committed(repo.path(), "master", ".ai/repo/mesh/majordomus.yaml")
440            .expect("the trunk holds a file there")
441            .unwrap_err()
442            .to_string();
443        assert!(
444            refused.contains("master:.ai/repo/mesh/majordomus.yaml"),
445            "{refused}"
446        );
447    }
448
449    #[test]
450    fn a_trunk_git_cannot_be_asked_about_holds_nothing() {
451        let gone = std::env::temp_dir()
452            .join("majordomus-no-such-checkout")
453            .join("x");
454        assert!(committed(&gone, "master", ".ai/repo/mesh/majordomus.yaml").is_none());
455        // a branch the repository does not have holds nothing either
456        let repo = trunk_holding(Some(TRUNK));
457        assert!(committed(
458            repo.path(),
459            "no-such-branch",
460            ".ai/repo/mesh/majordomus.yaml"
461        )
462        .is_none());
463    }
464
465    #[test]
466    fn a_declaration_the_trunk_does_not_hold_is_read_as_uncommitted() {
467        let repo = trunk_holding(None);
468        let resolved = resolve(repo.path(), Some(&working(tofu())));
469        assert!(!resolved.committed);
470        let held = resolved.declaration.unwrap().unwrap();
471        assert_eq!(held.trust.policy, TrustPolicy::DenyUnknown);
472    }
473
474    #[test]
475    fn loopback_is_named_by_scheme_bracket_or_bare_authority() {
476        assert!(is_loopback("https://127.0.0.1:8791/x"));
477        assert!(is_loopback("http://[::1]:8791"));
478        assert!(is_loopback("localhost:8791"));
479        assert!(!is_loopback("https://[2001:db8::1]:8791"));
480    }
481
482    #[test]
483    fn a_branch_cannot_switch_on_a_mesh_the_trunk_switched_off() {
484        let trunk = config(serde_json::json!({
485            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "enabled": false,
486        }));
487        let branch = config(serde_json::json!({
488            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "enabled": true,
489        }));
490        let (held, narrowed) = narrow(branch, &trunk);
491        assert!(!held.enabled);
492        assert_eq!(
493            narrowed,
494            vec!["enabled: the trunk's declaration is disabled"]
495        );
496    }
497}