ec323e8c
Debug-quote unauthenticated cert input and warn on cadir/keydir drift
a73x 2026-08-18 19:09
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -488,6 +488,11 @@ under two names is an authorization coin-flip): a certificate names its | |||
| 488 | principal, so the lookup runs the other way, and a shared CA is two explicit | 488 | principal, so the lookup runs the other way, and a shared CA is two explicit |
| 489 | opt-ins. | 489 | opt-ins. |
| 490 | 490 | ||
| 491 | The clip is write-only: a delegate reads whatever the person it acts for | ||
| 492 | reads, with no narrowing — it could not otherwise prepare a patch against | ||
| 493 | anything the person can see — so a leaked certificate exposes the person's | ||
| 494 | whole read surface until it expires. | ||
| 495 | |||
| 491 | Revocation is the roster: remove `cadir/mint/alex.pub` and the delegates it | 496 | Revocation is the roster: remove `cadir/mint/alex.pub` and the delegates it |
| 492 | minted die on their next command; remove the person's keys and their | 497 | minted die on their next command; remove the person's keys and their |
| 493 | delegates die with them. The cert's own expiry does the rest — there is no | 498 | delegates die with them. The cert's own expiry does the rest — there is no |
src/server/governance/cadir.rs
| Old | New | ||
|---|---|---|---|
| @@ -71,6 +71,12 @@ impl CaDir { | |||
| 71 | pub fn fingerprints_for(&self, name: &str) -> &[Fingerprint] { | 71 | pub fn fingerprints_for(&self, name: &str) -> &[Fingerprint] { |
| 72 | self.by_name.get(name).map(Vec::as_slice).unwrap_or(&[]) | 72 | self.by_name.get(name).map(Vec::as_slice).unwrap_or(&[]) |
| 73 | } | 73 | } |
| 74 | |||
| 75 | /// Every name `cadir/` enrolls a CA for, for hygiene checks that walk the | ||
| 76 | /// whole roster (e.g. a name with no matching `keydir/` entry). | ||
| 77 | pub fn names(&self) -> impl Iterator<Item = &str> { | ||
| 78 | self.by_name.keys().map(String::as_str) | ||
| 79 | } | ||
| 74 | } | 80 | } |
| 75 | 81 | ||
| 76 | #[cfg(test)] | 82 | #[cfg(test)] |
src/server/governance/delegate.rs
| Old | New | ||
|---|---|---|---|
| @@ -35,9 +35,13 @@ pub fn validate( | |||
| 35 | many => return Err(format!("certificate names {} principals", many.len())), | 35 | many => return Err(format!("certificate names {} principals", many.len())), |
| 36 | }; | 36 | }; |
| 37 | 37 | ||
| 38 | // Everything from here up to validate_at below is unauthenticated input — | ||
| 39 | // the cert's own claims, taken before its signature is checked — so it | ||
| 40 | // must be Debug-quoted wherever it reaches an error message, the same as | ||
| 41 | // `name` is at the critical-options check below. | ||
| 38 | // cadir/ delegates identity; it never creates it. The person must exist. | 42 | // cadir/ delegates identity; it never creates it. The person must exist. |
| 39 | if !governance.keys.names().contains(&person) { | 43 | if !governance.keys.names().contains(&person) { |
| 40 | return Err(format!("{person} is not enrolled in keydir/")); | 44 | return Err(format!("{person:?} is not enrolled in keydir/")); |
| 41 | } | 45 | } |
| 42 | 46 | ||
| 43 | // Per PROTOCOL.certkeys, an implementation MUST refuse a certificate | 47 | // Per PROTOCOL.certkeys, an implementation MUST refuse a certificate |
| @@ -50,7 +54,7 @@ pub fn validate( | |||
| 50 | // timestamp is inside the validity window — all three via validate_at. | 54 | // timestamp is inside the validity window — all three via validate_at. |
| 51 | let fingerprints = governance.cas.fingerprints_for(&person); | 55 | let fingerprints = governance.cas.fingerprints_for(&person); |
| 52 | if fingerprints.is_empty() { | 56 | if fingerprints.is_empty() { |
| 53 | return Err(format!("no CA is enrolled in cadir/ for {person}")); | 57 | return Err(format!("no CA is enrolled in cadir/ for {person:?}")); |
| 54 | } | 58 | } |
| 55 | cert.validate_at(unix_now, fingerprints.iter()) | 59 | cert.validate_at(unix_now, fingerprints.iter()) |
| 56 | .map_err(|e| format!("certificate did not validate for {person}: {e}"))?; | 60 | .map_err(|e| format!("certificate did not validate for {person}: {e}"))?; |
src/server/governance/hook.rs
| Old | New | ||
|---|---|---|---|
| @@ -129,6 +129,14 @@ pub fn run(refname: &str, old: &str, new: &str) -> Result<(), String> { | |||
| 129 | // goes briefly unreadable-as-Absent between the session's regime check | 129 | // goes briefly unreadable-as-Absent between the session's regime check |
| 130 | // and this hook's own re-read skip the ceiling entirely — hard-coded | 130 | // and this hook's own re-read skip the ceiling entirely — hard-coded |
| 131 | // rather than configured, so no line in access.conf can widen it either. | 131 | // rather than configured, so no line in access.conf can widen it either. |
| 132 | // | ||
| 133 | // Assumption written down: `starts_with("refs/collab/")` is sufficient to | ||
| 134 | // confine a delegate only because a refname containing `..` — e.g. | ||
| 135 | // `refs/collab/../heads/main` — can never reach this comparison in the | ||
| 136 | // first place. `git-receive-pack` validates every pushed refname with | ||
| 137 | // `check_refname_format` (`ref_name_is_safe` further down that call | ||
| 138 | // chain) before invoking the update hook at all, so a path-traversing | ||
| 139 | // refname is rejected upstream of this code, not by it. | ||
| 132 | let delegate = std::env::var(ENV_DELEGATE).ok().filter(|v| !v.is_empty()); | 140 | let delegate = std::env::var(ENV_DELEGATE).ok().filter(|v| !v.is_empty()); |
| 133 | if let Some(key_id) = &delegate { | 141 | if let Some(key_id) = &delegate { |
| 134 | if !refname.starts_with("refs/collab/") { | 142 | if !refname.starts_with("refs/collab/") { |
| @@ -244,8 +252,18 @@ fn validate_settings_push( | |||
| 244 | return Ok(()); | 252 | return Ok(()); |
| 245 | } | 253 | } |
| 246 | 254 | ||
| 247 | validate_settings_tree(repo, &tree, refname) | 255 | let governance = validate_settings_tree(repo, &tree, refname) |
| 248 | .map_err(|e| format!("rejecting this configuration; the previous one stays live\n {e}"))?; | 256 | .map_err(|e| format!("rejecting this configuration; the previous one stays live\n {e}"))?; |
| 257 | |||
| 258 | // Warnings, not errors: this push still lands. Printed here (rather than | ||
| 259 | // returned as part of the Err path above) because a valid config that | ||
| 260 | // merely looks suspicious should reach the operator without blocking | ||
| 261 | // them — stderr from the update hook is relayed to the pusher as | ||
| 262 | // `remote:` lines regardless of the hook's exit status. | ||
| 263 | for warning in super::cadir_warnings(&governance) { | ||
| 264 | eprintln!("warning: {warning}"); | ||
| 265 | } | ||
| 266 | |||
| 249 | Ok(()) | 267 | Ok(()) |
| 250 | } | 268 | } |
| 251 | 269 | ||
src/server/governance/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -382,6 +382,50 @@ pub fn unruled_roster(repos_dir: &Path) -> Option<String> { | |||
| 382 | check_roster_has_rules(&tree).err() | 382 | check_roster_has_rules(&tree).err() |
| 383 | } | 383 | } |
| 384 | 384 | ||
| 385 | /// Operator-visible warnings about `cadir/` entries that are not outright | ||
| 386 | /// errors but are easy to get wrong silently: | ||
| 387 | /// | ||
| 388 | /// - A CA fingerprint that is *also* someone's own key in `keydir/`: that | ||
| 389 | /// person can self-mint a delegate certificate naming whoever the `cadir/` | ||
| 390 | /// entry names. Legitimate for a solo operator running their own CA; | ||
| 391 | /// silent privilege escalation for anyone else, so it is surfaced rather | ||
| 392 | /// than judged here. | ||
| 393 | /// - A `cadir/` entry for a name with no `keydir/` enrolment: dead config, | ||
| 394 | /// typo-shaped — no certificate naming it can ever validate, since | ||
| 395 | /// `delegate::validate` requires the principal to exist in `keydir/` | ||
| 396 | /// first. | ||
| 397 | /// | ||
| 398 | /// Warnings only: nothing here is rejected, and the config it describes is | ||
| 399 | /// otherwise usable as pushed. Deliberately *not* called from `load`, which | ||
| 400 | /// runs on every request; a caller invokes this once per config change — | ||
| 401 | /// at push validation and at server startup — the same two moments | ||
| 402 | /// `check_roster_has_rules` and `unruled_roster` cover for the roster/rules | ||
| 403 | /// split. | ||
| 404 | pub fn cadir_warnings(governance: &Governance) -> Vec<String> { | ||
| 405 | let mut ca_names: Vec<&str> = governance.cas.names().collect(); | ||
| 406 | ca_names.sort_unstable(); | ||
| 407 | |||
| 408 | let mut warnings = Vec::new(); | ||
| 409 | for ca_name in &ca_names { | ||
| 410 | if !governance.keys.names().iter().any(|name| name == ca_name) { | ||
| 411 | warnings.push(format!( | ||
| 412 | "cadir/ is enrolled for {ca_name:?}, which is not enrolled in keydir/; \ | ||
| 413 | no certificate naming it can ever validate" | ||
| 414 | )); | ||
| 415 | } | ||
| 416 | for fingerprint in governance.cas.fingerprints_for(ca_name) { | ||
| 417 | let principal = format!("key:{fingerprint}"); | ||
| 418 | if let Some(owner) = governance.keys.name_for(&principal) { | ||
| 419 | warnings.push(format!( | ||
| 420 | "the CA enrolled in cadir/ for {ca_name:?} is also {owner:?}'s own key in \ | ||
| 421 | keydir/: {owner:?} can self-mint delegate certificates naming {ca_name:?}" | ||
| 422 | )); | ||
| 423 | } | ||
| 424 | } | ||
| 425 | } | ||
| 426 | warnings | ||
| 427 | } | ||
| 428 | |||
| 385 | /// Record which principal created a repository, for `CREATOR` rules. | 429 | /// Record which principal created a repository, for `CREATOR` rules. |
| 386 | pub fn record_creator(repo_path: &Path, name: &str) -> std::io::Result<()> { | 430 | pub fn record_creator(repo_path: &Path, name: &str) -> std::io::Result<()> { |
| 387 | let dir = repo_path.join(".collab"); | 431 | let dir = repo_path.join(".collab"); |
| @@ -581,4 +625,57 @@ mod tests { | |||
| 581 | let err = validate_settings_tree(&repo, &tree, "refs/heads/main").unwrap_err(); | 625 | let err = validate_settings_tree(&repo, &tree, "refs/heads/main").unwrap_err(); |
| 582 | assert!(err.contains("cadir/alex.pub"), "got {err}"); | 626 | assert!(err.contains("cadir/alex.pub"), "got {err}"); |
| 583 | } | 627 | } |
| 628 | |||
| 629 | #[test] | ||
| 630 | fn a_ca_fingerprint_that_is_also_a_keydir_key_is_warned_about() { | ||
| 631 | let mut keys = KeyDir::new(); | ||
| 632 | keys.insert("keydir/alex.pub", PERSON_KEY).unwrap(); | ||
| 633 | let mut cas = CaDir::new(); | ||
| 634 | // alex's own key, enrolled as a CA for bob: alex can self-mint a | ||
| 635 | // delegate certificate naming bob. | ||
| 636 | cas.insert("cadir/mint/bob.pub", PERSON_KEY).unwrap(); | ||
| 637 | let conf = AccessConf::parse("repo settings\n RW+ = alex\n").unwrap(); | ||
| 638 | let governance = Governance { conf, keys, cas }; | ||
| 639 | |||
| 640 | let warnings = cadir_warnings(&governance); | ||
| 641 | assert!( | ||
| 642 | warnings | ||
| 643 | .iter() | ||
| 644 | .any(|w| w.contains("alex") && w.contains("bob")), | ||
| 645 | "got {warnings:?}" | ||
| 646 | ); | ||
| 647 | } | ||
| 648 | |||
| 649 | #[test] | ||
| 650 | fn a_ca_named_for_nobody_in_keydir_is_warned_about() { | ||
| 651 | let mut keys = KeyDir::new(); | ||
| 652 | keys.insert("keydir/alex.pub", PERSON_KEY).unwrap(); | ||
| 653 | let mut cas = CaDir::new(); | ||
| 654 | // "ghost" has no keydir/ entry at all — typo-shaped dead config. | ||
| 655 | cas.insert("cadir/mint/ghost.pub", CA_KEY).unwrap(); | ||
| 656 | let conf = AccessConf::parse("repo settings\n RW+ = alex\n").unwrap(); | ||
| 657 | let governance = Governance { conf, keys, cas }; | ||
| 658 | |||
| 659 | let warnings = cadir_warnings(&governance); | ||
| 660 | assert!( | ||
| 661 | warnings | ||
| 662 | .iter() | ||
| 663 | .any(|w| w.contains("ghost") && w.contains("not enrolled in keydir")), | ||
| 664 | "got {warnings:?}" | ||
| 665 | ); | ||
| 666 | } | ||
| 667 | |||
| 668 | #[test] | ||
| 669 | fn a_well_formed_cadir_has_no_warnings() { | ||
| 670 | let mut keys = KeyDir::new(); | ||
| 671 | keys.insert("keydir/alex.pub", PERSON_KEY).unwrap(); | ||
| 672 | let mut cas = CaDir::new(); | ||
| 673 | // A distinct CA key, enrolled for the person it names — the | ||
| 674 | // ordinary, unremarkable case. | ||
| 675 | cas.insert("cadir/mint/alex.pub", CA_KEY).unwrap(); | ||
| 676 | let conf = AccessConf::parse("repo settings\n RW+ = alex\n").unwrap(); | ||
| 677 | let governance = Governance { conf, keys, cas }; | ||
| 678 | |||
| 679 | assert!(cadir_warnings(&governance).is_empty()); | ||
| 680 | } | ||
| 584 | } | 681 | } |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -217,6 +217,15 @@ async fn main() { | |||
| 217 | ); | 217 | ); |
| 218 | } | 218 | } |
| 219 | 219 | ||
| 220 | // cadir/ hygiene: a config already on disk gets no push to validate | ||
| 221 | // against, so it is checked here too, once, at the only moment an | ||
| 222 | // operator is looking — the same reasoning as `unruled_roster` above. | ||
| 223 | if let governance::GovernanceState::Active(governance) = governance::load(&config.repos_dir) { | ||
| 224 | for warning in governance::cadir_warnings(&governance) { | ||
| 225 | tracing::warn!("{warning}"); | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 220 | let app_state = http::AppState { | 229 | let app_state = http::AppState { |
| 221 | repos_dir: config.repos_dir.clone(), | 230 | repos_dir: config.repos_dir.clone(), |
| 222 | site_title: config.site_title.clone(), | 231 | site_title: config.site_title.clone(), |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -246,7 +246,11 @@ impl SshHandler { | |||
| 246 | ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind, | 246 | ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind, |
| 247 | }; | 247 | }; |
| 248 | // Artifacts are not collab refs. A delegate may list what its person | 248 | // Artifacts are not collab refs. A delegate may list what its person |
| 249 | // may see; publishing and deleting are outside the ceiling. | 249 | // may see; publishing and deleting are outside the ceiling. Unlike the |
| 250 | // unknown/unauthorized-repo case above, a delegate has already | ||
| 251 | // authenticated and can `release list` this same repo, so there is no | ||
| 252 | // repository existence to hide here — naming the ceiling is honest, | ||
| 253 | // not a probe. | ||
| 250 | if let Regime::Governed { | 254 | if let Regime::Governed { |
| 251 | delegate: Some(key_id), | 255 | delegate: Some(key_id), |
| 252 | .. | 256 | .. |
| @@ -254,7 +258,15 @@ impl SshHandler { | |||
| 254 | { | 258 | { |
| 255 | if needed != Access::Read { | 259 | if needed != Access::Read { |
| 256 | warn!("Rejected release command from delegate {key_id}"); | 260 | warn!("Rejected release command from delegate {key_id}"); |
| 257 | return reply_and_close(session, channel, NOT_FOUND, 1); | 261 | return reply_and_close( |
| 262 | session, | ||
| 263 | channel, | ||
| 264 | &format!( | ||
| 265 | "error: delegate {key_id} may only write refs/collab/*; \ | ||
| 266 | releases are out of reach\n" | ||
| 267 | ), | ||
| 268 | 1, | ||
| 269 | ); | ||
| 258 | } | 270 | } |
| 259 | } | 271 | } |
| 260 | let authorized = match regime { | 272 | let authorized = match regime { |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1092,10 +1092,9 @@ impl ServerHarness { | |||
| 1092 | 1092 | ||
| 1093 | let mut server = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) | 1093 | let mut server = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) |
| 1094 | .args(["--config", config_path.to_str().unwrap()]) | 1094 | .args(["--config", config_path.to_str().unwrap()]) |
| 1095 | // The server's default level is already INFO, so this is a | 1095 | // Tests assert on `server_log()` content, so the level is |
| 1096 | // no-op today — it's here so a test asserting on | 1096 | // pinned here rather than left to inherit whatever the |
| 1097 | // `server_log()` content keeps working if that default ever | 1097 | // binary's own default happens to be. |
| 1098 | // tightens. | ||
| 1099 | .env("RUST_LOG", "info") | 1098 | .env("RUST_LOG", "info") |
| 1100 | .stdout(Stdio::piped()) | 1099 | .stdout(Stdio::piped()) |
| 1101 | .stderr(Stdio::piped()) | 1100 | .stderr(Stdio::piped()) |
| @@ -1308,14 +1307,10 @@ impl ServerHarness { | |||
| 1308 | self.repos_dir().join("settings.git") | 1307 | self.repos_dir().join("settings.git") |
| 1309 | } | 1308 | } |
| 1310 | 1309 | ||
| 1311 | fn settings_work(&self) -> PathBuf { | ||
| 1312 | self.root.path().join("settings-work") | ||
| 1313 | } | ||
| 1314 | |||
| 1315 | /// The settings working tree, for tests that stage a change by hand | 1310 | /// The settings working tree, for tests that stage a change by hand |
| 1316 | /// rather than through one of the `stage_*`/`bootstrap_*` helpers. | 1311 | /// rather than through one of the `stage_*`/`bootstrap_*` helpers. |
| 1317 | pub fn settings_work_dir(&self) -> PathBuf { | 1312 | pub fn settings_work(&self) -> PathBuf { |
| 1318 | self.settings_work() | 1313 | self.root.path().join("settings-work") |
| 1319 | } | 1314 | } |
| 1320 | 1315 | ||
| 1321 | /// Create `settings.git` and a working tree for it, the way an init | 1316 | /// Create `settings.git` and a working tree for it, the way an init |
| @@ -1344,6 +1339,11 @@ impl ServerHarness { | |||
| 1344 | /// `keys` maps a path *within* `keydir/` (e.g. `laptop/alex.pub`) to the | 1339 | /// `keys` maps a path *within* `keydir/` (e.g. `laptop/alex.pub`) to the |
| 1345 | /// name of a `named_key`, so a test can put one key at two paths and see | 1340 | /// name of a `named_key`, so a test can put one key at two paths and see |
| 1346 | /// them collapse to one identity. | 1341 | /// them collapse to one identity. |
| 1342 | /// | ||
| 1343 | /// A call describes the roster *in full*: `keydir/` and `cadir/` are | ||
| 1344 | /// cleared before writing, so a key or CA omitted from the arguments | ||
| 1345 | /// actually disappears from the tree rather than merely failing to be | ||
| 1346 | /// added — the same call can therefore both enrol and revoke. | ||
| 1347 | pub fn stage_settings(&self, access_conf: &str, keys: &[(&str, &str)]) { | 1347 | pub fn stage_settings(&self, access_conf: &str, keys: &[(&str, &str)]) { |
| 1348 | self.stage_settings_with_cas(access_conf, keys, &[]); | 1348 | self.stage_settings_with_cas(access_conf, keys, &[]); |
| 1349 | } | 1349 | } |
| @@ -1364,9 +1364,8 @@ impl ServerHarness { | |||
| 1364 | std::fs::create_dir_all(conf_path.parent().unwrap()).unwrap(); | 1364 | std::fs::create_dir_all(conf_path.parent().unwrap()).unwrap(); |
| 1365 | std::fs::write(&conf_path, access_conf).unwrap(); | 1365 | std::fs::write(&conf_path, access_conf).unwrap(); |
| 1366 | 1366 | ||
| 1367 | // Clear both dirs first: this call describes the roster and the CA | 1367 | // See stage_settings's doc comment: both dirs are cleared first so |
| 1368 | // enrolment *in full*, so a key or CA dropped from the arguments | 1368 | // this call fully describes the roster and CA enrolment. |
| 1369 | // must actually disappear from the tree, not just fail to be added. | ||
| 1370 | let keydir = work.join("keydir"); | 1369 | let keydir = work.join("keydir"); |
| 1371 | if keydir.exists() { | 1370 | if keydir.exists() { |
| 1372 | std::fs::remove_dir_all(&keydir).unwrap(); | 1371 | std::fs::remove_dir_all(&keydir).unwrap(); |
tests/delegate_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -343,7 +343,7 @@ fn a_malformed_cadir_file_rejects_the_settings_push() { | |||
| 343 | ); | 343 | ); |
| 344 | 344 | ||
| 345 | // Stage a broken CA file in the settings work tree and push over SSH. | 345 | // Stage a broken CA file in the settings work tree and push over SSH. |
| 346 | let work = harness.settings_work_dir(); | 346 | let work = harness.settings_work(); |
| 347 | std::fs::write(work.join("cadir").join("junk.pub"), "not a key").unwrap(); | 347 | std::fs::write(work.join("cadir").join("junk.pub"), "not a key").unwrap(); |
| 348 | common::git_cmd(&work, &["add", "-A"]); | 348 | common::git_cmd(&work, &["add", "-A"]); |
| 349 | common::git_cmd(&work, &["commit", "-q", "-m", "break cadir"]); | 349 | common::git_cmd(&work, &["commit", "-q", "-m", "break cadir"]); |
| @@ -379,7 +379,7 @@ fn a_delegate_may_not_push_settings_itself() { | |||
| 379 | 379 | ||
| 380 | // A harmless change in the settings work tree — the push itself, not its | 380 | // A harmless change in the settings work tree — the push itself, not its |
| 381 | // content, is what must be refused. | 381 | // content, is what must be refused. |
| 382 | let work = harness.settings_work_dir(); | 382 | let work = harness.settings_work(); |
| 383 | std::fs::write(work.join("note.txt"), "n/a").unwrap(); | 383 | std::fs::write(work.join("note.txt"), "n/a").unwrap(); |
| 384 | common::git_cmd(&work, &["add", "-A"]); | 384 | common::git_cmd(&work, &["add", "-A"]); |
| 385 | common::git_cmd(&work, &["commit", "-q", "-m", "settings tweak"]); | 385 | common::git_cmd(&work, &["commit", "-q", "-m", "settings tweak"]); |
| @@ -439,3 +439,153 @@ fn a_delegate_may_list_releases_but_not_publish_them() { | |||
| 439 | stderr(&list) | 439 | stderr(&list) |
| 440 | ); | 440 | ); |
| 441 | } | 441 | } |
| 442 | |||
| 443 | /// Mint a delegate cert of `alex` for `harness`, having bootstrapped settings | ||
| 444 | /// with `access_conf`. Shared by the RW/RW+ collab-ref pair below, which | ||
| 445 | /// differ only in that one line of config. | ||
| 446 | fn delegate_of_alex( | ||
| 447 | harness: &ServerHarness, | ||
| 448 | access_conf: &str, | ||
| 449 | ) -> (std::path::PathBuf, std::path::PathBuf) { | ||
| 450 | harness.bootstrap_settings_with_cas( | ||
| 451 | access_conf, | ||
| 452 | &[("alex.pub", "alex")], | ||
| 453 | &[("mint/alex.pub", "mint")], | ||
| 454 | ); | ||
| 455 | let agent_key = harness.named_key("agent-key"); | ||
| 456 | let ca = harness.delegate_ca("mint"); | ||
| 457 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 458 | (agent_key, cert) | ||
| 459 | } | ||
| 460 | |||
| 461 | /// The README's claim that `RW refs/collab/` (as opposed to `RW+`) keeps | ||
| 462 | /// rewind and delete of collab refs out of a delegate's reach, even though | ||
| 463 | /// the delegate's write into that namespace is otherwise unrestricted. | ||
| 464 | #[test] | ||
| 465 | fn a_delegate_under_rw_may_write_but_not_rewind_or_delete_collab_refs() { | ||
| 466 | let harness = ServerHarness::new("delegate-rw-ceiling"); | ||
| 467 | harness.push_head(); | ||
| 468 | let conf = format!( | ||
| 469 | "repo settings\n RW+ = alex\n\nrepo {}\n RW refs/collab/ = alex\n", | ||
| 470 | harness.repo_name() | ||
| 471 | ); | ||
| 472 | let (agent_key, cert) = delegate_of_alex(&harness, &conf); | ||
| 473 | |||
| 474 | harness.work_repo().issue_open("RW ceiling"); | ||
| 475 | let push = harness.ssh_push_from_cert( | ||
| 476 | harness.work_repo().dir.path(), | ||
| 477 | &agent_key, | ||
| 478 | &cert, | ||
| 479 | harness.repo_name(), | ||
| 480 | "refs/collab/*:refs/collab/*", | ||
| 481 | ); | ||
| 482 | assert!( | ||
| 483 | push.status.success(), | ||
| 484 | "a delegate under RW could not write a collab ref: {}", | ||
| 485 | stderr(&push) | ||
| 486 | ); | ||
| 487 | |||
| 488 | let victim = harness | ||
| 489 | .work_repo() | ||
| 490 | .git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]) | ||
| 491 | .lines() | ||
| 492 | .next() | ||
| 493 | .expect("a collab ref to target") | ||
| 494 | .trim() | ||
| 495 | .to_string(); | ||
| 496 | |||
| 497 | // Rewind: force-push an unrelated commit onto the same ref name, which is | ||
| 498 | // not a fast-forward of it. | ||
| 499 | harness | ||
| 500 | .work_repo() | ||
| 501 | .commit_file("unrelated.txt", "x", "unrelated commit"); | ||
| 502 | let rewind = harness.ssh_push_from_cert( | ||
| 503 | harness.work_repo().dir.path(), | ||
| 504 | &agent_key, | ||
| 505 | &cert, | ||
| 506 | harness.repo_name(), | ||
| 507 | &format!("+HEAD:{victim}"), | ||
| 508 | ); | ||
| 509 | assert!( | ||
| 510 | !rewind.status.success(), | ||
| 511 | "a delegate under RW rewound a collab ref" | ||
| 512 | ); | ||
| 513 | |||
| 514 | let delete = harness.ssh_push_from_cert( | ||
| 515 | harness.work_repo().dir.path(), | ||
| 516 | &agent_key, | ||
| 517 | &cert, | ||
| 518 | harness.repo_name(), | ||
| 519 | &format!(":{victim}"), | ||
| 520 | ); | ||
| 521 | assert!( | ||
| 522 | !delete.status.success(), | ||
| 523 | "a delegate under RW deleted a collab ref" | ||
| 524 | ); | ||
| 525 | } | ||
| 526 | |||
| 527 | /// The contrast: under `RW+`, the same operations succeed — a delegate | ||
| 528 | /// inherits rewind and delete inside `refs/collab/*` along with everything | ||
| 529 | /// else the person holds there. | ||
| 530 | #[test] | ||
| 531 | fn a_delegate_under_rw_plus_may_rewind_and_delete_collab_refs() { | ||
| 532 | let harness = ServerHarness::new("delegate-rw-plus-ceiling"); | ||
| 533 | harness.push_head(); | ||
| 534 | let conf = format!( | ||
| 535 | "repo settings\n RW+ = alex\n\nrepo {}\n RW+ refs/collab/ = alex\n", | ||
| 536 | harness.repo_name() | ||
| 537 | ); | ||
| 538 | let (agent_key, cert) = delegate_of_alex(&harness, &conf); | ||
| 539 | |||
| 540 | harness.work_repo().issue_open("RW+ ceiling"); | ||
| 541 | let push = harness.ssh_push_from_cert( | ||
| 542 | harness.work_repo().dir.path(), | ||
| 543 | &agent_key, | ||
| 544 | &cert, | ||
| 545 | harness.repo_name(), | ||
| 546 | "refs/collab/*:refs/collab/*", | ||
| 547 | ); | ||
| 548 | assert!( | ||
| 549 | push.status.success(), | ||
| 550 | "a delegate under RW+ could not write a collab ref: {}", | ||
| 551 | stderr(&push) | ||
| 552 | ); | ||
| 553 | |||
| 554 | let victim = harness | ||
| 555 | .work_repo() | ||
| 556 | .git(&["for-each-ref", "--format=%(refname)", "refs/collab/"]) | ||
| 557 | .lines() | ||
| 558 | .next() | ||
| 559 | .expect("a collab ref to target") | ||
| 560 | .trim() | ||
| 561 | .to_string(); | ||
| 562 | |||
| 563 | harness | ||
| 564 | .work_repo() | ||
| 565 | .commit_file("unrelated.txt", "x", "unrelated commit"); | ||
| 566 | let rewind = harness.ssh_push_from_cert( | ||
| 567 | harness.work_repo().dir.path(), | ||
| 568 | &agent_key, | ||
| 569 | &cert, | ||
| 570 | harness.repo_name(), | ||
| 571 | &format!("+HEAD:{victim}"), | ||
| 572 | ); | ||
| 573 | assert!( | ||
| 574 | rewind.status.success(), | ||
| 575 | "a delegate under RW+ could not rewind a collab ref: {}", | ||
| 576 | stderr(&rewind) | ||
| 577 | ); | ||
| 578 | |||
| 579 | let delete = harness.ssh_push_from_cert( | ||
| 580 | harness.work_repo().dir.path(), | ||
| 581 | &agent_key, | ||
| 582 | &cert, | ||
| 583 | harness.repo_name(), | ||
| 584 | &format!(":{victim}"), | ||
| 585 | ); | ||
| 586 | assert!( | ||
| 587 | delete.status.success(), | ||
| 588 | "a delegate under RW+ could not delete a collab ref: {}", | ||
| 589 | stderr(&delete) | ||
| 590 | ); | ||
| 591 | } | ||