c7ddde85
Governance loads cadir/ and the roster rule covers it
a73x 2026-08-18 16:07
Commit message
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 | } |