a73x

c7ddde85

Governance loads cadir/ and the roster rule covers it

a73x   2026-08-18 16:07

Commit message
Governance loads cadir/ and the roster rule covers it

cadir/ now feeds a per-person CA roster the same way keydir/ feeds the
key roster, and a cadir/-only settings tree is refused for the same
reason a keydir/-only one is: a roster with no rules would publish who
has access to this server through an otherwise-unremarkable repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/server/governance/mod.rs
Old New
@@ -78,6 +78,7 @@ pub mod keydir;
78 78
79 use std::path::{Path, PathBuf}; 79 use std::path::{Path, PathBuf};
80 80
81 use cadir::CaDir;
81 use conf::{Access, AccessConf, Subject}; 82 use conf::{Access, AccessConf, Subject};
82 use keydir::KeyDir; 83 use keydir::KeyDir;
83 84
@@ -86,6 +87,7 @@ pub const SETTINGS_REPO: &str = "settings";
86 87
87 pub(crate) const ACCESS_CONF_PATH: &str = "conf/access.conf"; 88 pub(crate) const ACCESS_CONF_PATH: &str = "conf/access.conf";
88 const KEYDIR: &str = "keydir"; 89 const KEYDIR: &str = "keydir";
90 const CADIR: &str = "cadir";
89 91
90 /// The file inside a repository recording which principal created it, for 92 /// The file inside a repository recording which principal created it, for
91 /// `CREATOR` in wild-repo rules. It lives beside `server.toml` under 93 /// `CREATOR` in wild-repo rules. It lives beside `server.toml` under
@@ -98,6 +100,7 @@ const CREATOR_FILE: &str = "creator";
98 pub struct Governance { 100 pub struct Governance {
99 pub conf: AccessConf, 101 pub conf: AccessConf,
100 pub keys: KeyDir, 102 pub keys: KeyDir,
103 pub cas: CaDir,
101 } 104 }
102 105
103 /// What the server found when it looked for `settings.git`. 106 /// What the server found when it looked for `settings.git`.
@@ -202,7 +205,12 @@ fn read_tree(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<Option<Go
202 let source = blob_text(repo, entry.id()).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?; 205 let source = blob_text(repo, entry.id()).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?;
203 let access = AccessConf::parse(&source).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?; 206 let access = AccessConf::parse(&source).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?;
204 let keys = read_keydir(repo, tree)?; 207 let keys = read_keydir(repo, tree)?;
205 Ok(Some(Governance { conf: access, keys })) 208 let cas = read_cadir(repo, tree)?;
209 Ok(Some(Governance {
210 conf: access,
211 keys,
212 cas,
213 }))
206 } 214 }
207 215
208 fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Result<String, String> { 216 fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Result<String, String> {
@@ -246,6 +254,42 @@ fn read_keydir(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<KeyDir,
246 Ok(keys) 254 Ok(keys)
247 } 255 }
248 256
257 fn read_cadir(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<CaDir, String> {
258 let mut cas = CaDir::new();
259 let cadir = match tree.get_path(Path::new(CADIR)) {
260 Ok(entry) => entry,
261 Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(cas),
262 Err(e) => return Err(format!("{CADIR}: {e}")),
263 };
264 let cadir = match cadir.to_object(repo).and_then(|o| o.peel_to_tree()) {
265 Ok(tree) => tree,
266 Err(e) => return Err(format!("{CADIR}: {e}")),
267 };
268
269 // Collect first, insert after: the walk callback cannot return a Rust
270 // error, and swallowing one would enrol a partial roster.
271 let mut blobs: Vec<(String, git2::Oid)> = Vec::new();
272 cadir
273 .walk(git2::TreeWalkMode::PreOrder, |root, entry| {
274 if entry.kind() == Some(git2::ObjectType::Blob) {
275 if let Some(name) = entry.name() {
276 blobs.push((format!("{CADIR}/{root}{name}"), entry.id()));
277 }
278 }
279 git2::TreeWalkResult::Ok
280 })
281 .map_err(|e| format!("{CADIR}: {e}"))?;
282
283 // Sorted so a roster error is reported deterministically rather than
284 // depending on tree iteration order.
285 blobs.sort();
286 for (path, oid) in blobs {
287 let content = blob_text(repo, oid).map_err(|e| format!("{path}: {e}"))?;
288 cas.insert(&path, &content).map_err(|e| e.to_string())?;
289 }
290 Ok(cas)
291 }
292
249 /// Validate a proposed `settings` tree, as the push hook does. 293 /// Validate a proposed `settings` tree, as the push hook does.
250 /// 294 ///
251 /// `landing_ref` is the ref this push would update; the lockout check asks 295 /// `landing_ref` is the ref this push would update; the lockout check asks
@@ -308,12 +352,13 @@ pub fn validate_settings_tree(
308 /// called `settings`, and stays as pushable as it ever was. 352 /// called `settings`, and stays as pushable as it ever was.
309 pub fn check_roster_has_rules(tree: &git2::Tree<'_>) -> Result<(), String> { 353 pub fn check_roster_has_rules(tree: &git2::Tree<'_>) -> Result<(), String> {
310 let has_rules = tree.get_path(Path::new(ACCESS_CONF_PATH)).is_ok(); 354 let has_rules = tree.get_path(Path::new(ACCESS_CONF_PATH)).is_ok();
311 let has_roster = tree.get_path(Path::new(KEYDIR)).is_ok(); 355 let has_roster =
356 tree.get_path(Path::new(KEYDIR)).is_ok() || tree.get_path(Path::new(CADIR)).is_ok();
312 if has_roster && !has_rules { 357 if has_roster && !has_rules {
313 return Err(format!( 358 return Err(format!(
314 "{KEYDIR}/ without {ACCESS_CONF_PATH}: a roster with no rules governs nothing, \ 359 "{KEYDIR}/ without {ACCESS_CONF_PATH} — the same is true of {CADIR}/: a roster with \
315 so this repository would be served like any other and would publish who has \ 360 no rules governs nothing, so this repository would be served like any other and \
316 access to this server.\n \ 361 would publish who has access to this server.\n \
317 Push both together, or push {ACCESS_CONF_PATH} first." 362 Push both together, or push {ACCESS_CONF_PATH} first."
318 )); 363 ));
319 } 364 }
@@ -483,4 +528,56 @@ mod tests {
483 record_creator(tmp.path(), "claude-a").unwrap(); 528 record_creator(tmp.path(), "claude-a").unwrap();
484 assert_eq!(creator_of(tmp.path()).as_deref(), Some("claude-a")); 529 assert_eq!(creator_of(tmp.path()).as_deref(), Some("claude-a"));
485 } 530 }
531
532 /// KEY_A / its ssh-keygen fingerprint, same constants as cadir.rs tests.
533 const CA_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab";
534 /// KEY_B from the same test suites — a distinct, well-formed key so a
535 /// person's own key and a CA's key are never accidentally identical.
536 const PERSON_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaUXZkX8MTYd4ztPAb11azBoq42VDJowOS3Zuj9jZlg governance-test-b@git-collab";
537
538 #[test]
539 fn read_tree_loads_cadir() {
540 let tmp = tempfile::TempDir::new().unwrap();
541 let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
542 let tree = tree_of(
543 &repo,
544 &[
545 ("conf/access.conf", "repo settings\n RW+ = alex\n"),
546 ("keydir/alex.pub", PERSON_KEY),
547 ("cadir/mint/alex.pub", CA_KEY),
548 ],
549 );
550
551 let governance = read_tree(&repo, &tree)
552 .unwrap()
553 .expect("conf/access.conf is present");
554 assert_eq!(governance.cas.fingerprints_for("alex").len(), 1);
555 assert!(governance.cas.fingerprints_for("bob").is_empty());
556 }
557
558 #[test]
559 fn cadir_without_rules_is_an_unruled_roster() {
560 let tmp = tempfile::TempDir::new().unwrap();
561 let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
562 let tree = tree_of(&repo, &[("cadir/mint/alex.pub", CA_KEY)]);
563
564 assert!(check_roster_has_rules(&tree).is_err());
565 }
566
567 #[test]
568 fn a_malformed_cadir_file_fails_validation() {
569 let tmp = tempfile::TempDir::new().unwrap();
570 let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
571 let tree = tree_of(
572 &repo,
573 &[
574 ("conf/access.conf", "repo settings\n RW+ = alex\n"),
575 ("keydir/alex.pub", PERSON_KEY),
576 ("cadir/alex.pub", "junk"),
577 ],
578 );
579
580 let err = validate_settings_tree(&repo, &tree, "refs/heads/main").unwrap_err();
581 assert!(err.contains("cadir/alex.pub"), "got {err}");
582 }
486 } 583 }