1use 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#[derive(Debug)]
63pub struct Resolved {
64 pub declaration: Option<Result<MeshConfig, MeshError>>,
67 pub trunk: Option<String>,
69 pub committed: bool,
71 pub narrowed: Vec<String>,
73}
74
75pub 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 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
140fn 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
158fn looseness(policy: &TrustPolicy) -> u8 {
161 match policy {
162 TrustPolicy::DenyUnknown | TrustPolicy::Allowlist => 0,
163 TrustPolicy::Tofu => 1,
164 }
165}
166
167pub 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
236pub 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
265fn 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
278fn 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 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 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 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 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 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 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 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 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}