a73x

src/server/governance/mod.rs

Ref:   Size: 27.4 KiB   History

//! Governance by a settings repository, in the style of gitolite.
//!
//! `<repos_dir>/settings.git` holds two things:
//!
//! ```text
//! conf/access.conf     ordered access rules  (see conf.rs)
//! keydir/**/<name>.pub the key roster        (see keydir.rs)
//! ```
//!
//! Pushing to that repository reconfigures the server. Nothing else does:
//! there is no reload signal, no restart, and no file on the host to edit.
//!
//! # Why the config is validated on push
//!
//! A push to `settings` is validated *before* the ref update is accepted — the
//! rules must parse, every key must be well-formed, and at least one principal
//! must still be able to push the config afterwards. A push that fails any of
//! those is rejected and the previously-live config keeps governing.
//!
//! That is not a nicety. It is what lets the request path assume its config is
//! well-formed, which deletes two whole failure modes: there is no
//! malformed-policy branch to decide at request time, and no bounded-staleness
//! window in which a superseded config is still in force. The validation runs
//! even on a server that is not yet governed, so the *first* config can never
//! be a broken one either.
//!
//! # How this interacts with the per-repo `server.toml`
//!
//! `<repo>.git/.collab/server.toml` already carries per-repo settings, some of
//! which look like access control. The two are split by **axis**, and the split
//! is total, so they cannot disagree:
//!
//! | Axis | Authority when governed | Authority when ungoverned |
//! |---|---|---|
//! | An authenticated principal's access | `conf/access.conf`, alone | `server.toml`'s `[access]` |
//! | Which keys authenticate at all | `keydir/` | the `authorized_keys` file |
//! | The anonymous HTTP surface | `conf/access.conf`, alone | `server.toml` |
//!
//! So when `settings.git` is present, `access.conf` **supersedes**
//! `server.toml` outright: `[access] read`/`write`, `visibility`,
//! `[ui] anonymous` and `[http] anonymous_clone` are not consulted, not
//! intersected, not unioned. `keydir/` likewise supersedes `authorized_keys`.
//!
//! Superseding rather than layering is deliberate. Layering (requiring both to
//! allow) would let a `server.toml` nobody remembers editing silently subtract
//! access that `access.conf` visibly grants — two authorization systems
//! disagreeing, with the disagreement invisible in the file you are reading.
//! Superseding means exactly one file answers the question.
//!
//! # The anonymous surface
//!
//! `access.conf` grants access to *named principals*, and `@all` means every
//! enrolled key rather than the public, so for a while nothing in the language
//! could describe an unauthenticated request and `server.toml` had to keep
//! that axis. It no longer does: `@anonymous` is a reserved principal in the
//! rule table (see `conf.rs`) and the default is inverted, so **a governed
//! repository is unlisted and unreadable without authentication unless a rule
//! says otherwise**. `settings.git` expresses only the exception, which is a
//! positive grant — what the language is already good at.
//!
//! That default is also what keeps the governance repository off the anonymous
//! surface: no rule grants `@anonymous` anything until an operator writes one,
//! so creating `settings.git` cannot publish the key roster by accident. It
//! used to take a hand-coded special case in `repos.rs`; it now falls out of
//! the rule.
//!
//! When `settings.git` does not exist, none of this is on: authentication,
//! authorization, repo creation and the anonymous surface behave exactly as
//! they did before, and no hook is installed on any repository except
//! `settings` itself. The inverted default belongs to the governed world, not
//! to the binary — flipping it globally would silently hide every repository
//! on every deployment that upgraded.

pub mod cadir;
pub mod conf;
pub mod delegate;
pub mod hook;
pub mod keydir;

use std::path::{Path, PathBuf};

use cadir::CaDir;
use conf::{Access, AccessConf, Subject};
use keydir::KeyDir;

/// The repository that governs the server. Not a name an operator may reuse.
pub const SETTINGS_REPO: &str = "settings";

pub(crate) const ACCESS_CONF_PATH: &str = "conf/access.conf";
const KEYDIR: &str = "keydir";
const CADIR: &str = "cadir";

/// The file inside a repository recording which principal created it, for
/// `CREATOR` in wild-repo rules. It lives beside `server.toml` under
/// `.collab/`, which is server-owned and outside every ref namespace, so a
/// principal cannot rewrite its way into ownership of someone else's repo.
const CREATOR_FILE: &str = "creator";

/// A loaded, validated configuration.
#[derive(Debug)]
pub struct Governance {
    pub conf: AccessConf,
    pub keys: KeyDir,
    pub cas: CaDir,
}

/// What the server found when it looked for `settings.git`.
#[derive(Debug)]
pub enum GovernanceState {
    /// No settings repository, or one that has never been populated. The
    /// server is ungoverned and behaves exactly as it did before this feature.
    ///
    /// An unborn HEAD or a missing `conf/access.conf` counts as absent rather
    /// than as an error: those are authoritative absences (`NotFound`), and
    /// treating them as failures would mean a bare `git init settings.git`
    /// bricked the server with no way in.
    Absent,
    Active(Box<Governance>),
    /// The settings repository exists and should be readable, but reading it
    /// failed for a reason that is not an absence. Everything is closed: a
    /// server that cannot read its rules must not guess at them.
    Unreadable(String),
}

impl Governance {
    /// Resolve a connection's key fingerprint to the name rules are written
    /// against. `None` means the key is not enrolled, and so is not a
    /// principal at all.
    pub fn name_for(&self, principal_fingerprint: &str) -> Option<&str> {
        self.keys.name_for(principal_fingerprint)
    }
}

/// The lookup key for a repository: its path relative to the storage
/// directory, with one trailing `.git` removed and `/` separators preserved.
///
/// `private/tools.git` and `tools.git` are therefore distinct keys, matched
/// byte-exactly against the names in `conf/access.conf`.
pub fn repo_key(repos_dir: &Path, repo_path: &Path) -> Option<String> {
    let relative = repo_path.strip_prefix(repos_dir).ok()?;
    let text = relative.to_str()?;
    if text.is_empty() {
        return None;
    }
    Some(text.strip_suffix(".git").unwrap_or(text).to_string())
}

fn settings_repo_path(repos_dir: &Path) -> PathBuf {
    repos_dir.join(format!("{SETTINGS_REPO}.git"))
}

/// Read the current configuration from `settings.git`.
///
/// Called per request rather than cached, which is what makes a config push
/// take effect on the next request with no restart. An already-authenticated
/// connection is not re-authenticated, but every command it goes on to issue
/// is authorized against a freshly read config.
pub fn load(repos_dir: &Path) -> GovernanceState {
    let path = settings_repo_path(repos_dir);
    if !path.exists() {
        return GovernanceState::Absent;
    }

    let repo = match git2::Repository::open_bare(&path) {
        Ok(repo) => repo,
        Err(e) if e.code() == git2::ErrorCode::NotFound => return GovernanceState::Absent,
        Err(e) => {
            return GovernanceState::Unreadable(format!("cannot open {}: {e}", path.display()))
        }
    };

    let tree = match repo.head().and_then(|head| head.peel_to_commit()) {
        Ok(commit) => match commit.tree() {
            Ok(tree) => tree,
            Err(e) => return GovernanceState::Unreadable(format!("settings tree: {e}")),
        },
        // An unborn or missing HEAD is an absence, not a failure.
        Err(e)
            if e.code() == git2::ErrorCode::NotFound
                || e.code() == git2::ErrorCode::UnbornBranch =>
        {
            return GovernanceState::Absent
        }
        Err(e) => return GovernanceState::Unreadable(format!("settings HEAD: {e}")),
    };

    match read_tree(&repo, &tree) {
        Ok(Some(governance)) => GovernanceState::Active(Box::new(governance)),
        Ok(None) => GovernanceState::Absent,
        Err(e) => GovernanceState::Unreadable(e),
    }
}

/// Build a `Governance` from a settings tree.
///
/// `Ok(None)` means there is no `conf/access.conf` in the tree at all, which
/// is an absence. `Err` means there is one and it is unusable — which the push
/// validation is supposed to have made impossible, so reaching it means
/// somebody edited the repository out of band.
fn read_tree(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<Option<Governance>, String> {
    let entry = match tree.get_path(Path::new(ACCESS_CONF_PATH)) {
        Ok(entry) => entry,
        Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(None),
        Err(e) => return Err(format!("{ACCESS_CONF_PATH}: {e}")),
    };
    let source = blob_text(repo, entry.id()).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?;
    let access = AccessConf::parse(&source).map_err(|e| format!("{ACCESS_CONF_PATH}: {e}"))?;
    let keys = read_keydir(repo, tree)?;
    let cas = read_cadir(repo, tree)?;
    Ok(Some(Governance {
        conf: access,
        keys,
        cas,
    }))
}

fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Result<String, String> {
    let blob = repo.find_blob(oid).map_err(|e| e.to_string())?;
    String::from_utf8(blob.content().to_vec()).map_err(|_| "not valid UTF-8".to_string())
}

fn read_keydir(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<KeyDir, String> {
    let mut keys = KeyDir::new();
    let keydir = match tree.get_path(Path::new(KEYDIR)) {
        Ok(entry) => entry,
        Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(keys),
        Err(e) => return Err(format!("{KEYDIR}: {e}")),
    };
    let keydir = match keydir.to_object(repo).and_then(|o| o.peel_to_tree()) {
        Ok(tree) => tree,
        Err(e) => return Err(format!("{KEYDIR}: {e}")),
    };

    // Collect first, insert after: the walk callback cannot return a Rust
    // error, and swallowing one would enrol a partial roster.
    let mut blobs: Vec<(String, git2::Oid)> = Vec::new();
    keydir
        .walk(git2::TreeWalkMode::PreOrder, |root, entry| {
            if entry.kind() == Some(git2::ObjectType::Blob) {
                if let Some(name) = entry.name() {
                    blobs.push((format!("{KEYDIR}/{root}{name}"), entry.id()));
                }
            }
            git2::TreeWalkResult::Ok
        })
        .map_err(|e| format!("{KEYDIR}: {e}"))?;

    // Sorted so a roster error is reported deterministically rather than
    // depending on tree iteration order.
    blobs.sort();
    for (path, oid) in blobs {
        let content = blob_text(repo, oid).map_err(|e| format!("{path}: {e}"))?;
        keys.insert(&path, &content).map_err(|e| e.to_string())?;
    }
    Ok(keys)
}

fn read_cadir(repo: &git2::Repository, tree: &git2::Tree<'_>) -> Result<CaDir, String> {
    let mut cas = CaDir::new();
    let cadir = match tree.get_path(Path::new(CADIR)) {
        Ok(entry) => entry,
        Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(cas),
        Err(e) => return Err(format!("{CADIR}: {e}")),
    };
    let cadir = match cadir.to_object(repo).and_then(|o| o.peel_to_tree()) {
        Ok(tree) => tree,
        Err(e) => return Err(format!("{CADIR}: {e}")),
    };

    // Collect first, insert after: the walk callback cannot return a Rust
    // error, and swallowing one would enrol a partial roster.
    let mut blobs: Vec<(String, git2::Oid)> = Vec::new();
    cadir
        .walk(git2::TreeWalkMode::PreOrder, |root, entry| {
            if entry.kind() == Some(git2::ObjectType::Blob) {
                if let Some(name) = entry.name() {
                    blobs.push((format!("{CADIR}/{root}{name}"), entry.id()));
                }
            }
            git2::TreeWalkResult::Ok
        })
        .map_err(|e| format!("{CADIR}: {e}"))?;

    // Sorted so a roster error is reported deterministically rather than
    // depending on tree iteration order.
    blobs.sort();
    for (path, oid) in blobs {
        let content = blob_text(repo, oid).map_err(|e| format!("{path}: {e}"))?;
        cas.insert(&path, &content).map_err(|e| e.to_string())?;
    }
    Ok(cas)
}

/// Validate a proposed `settings` tree, as the push hook does.
///
/// `landing_ref` is the ref this push would update; the lockout check asks
/// whether anyone could still push *that* ref once this config is live, which
/// is the precise condition for "the operator can still get back in".
pub fn validate_settings_tree(
    repo: &git2::Repository,
    tree: &git2::Tree<'_>,
    landing_ref: &str,
) -> Result<Governance, String> {
    let governance = match read_tree(repo, tree)? {
        Some(governance) => governance,
        None => {
            return Err(format!(
                "{ACCESS_CONF_PATH} is missing; removing it would turn governance off entirely"
            ))
        }
    };

    if governance.keys.is_empty() {
        return Err(format!(
            "{KEYDIR}/ enrols no keys; nobody could authenticate afterwards"
        ));
    }

    let retains_control = governance.keys.names().iter().any(|name| {
        governance.conf.allows_ref(
            SETTINGS_REPO,
            &Subject::new(name),
            landing_ref,
            Access::Rewind,
        )
    });
    if !retains_control {
        return Err(format!(
            "no principal would retain RW+ on {SETTINGS_REPO} at {landing_ref}; \
             this config would lock everyone out"
        ));
    }

    Ok(governance)
}

/// Refuse a settings tree that holds a roster but no rules.
///
/// `keydir/` without `conf/access.conf` is not a half-written configuration,
/// it is an exposure: the repository governs nothing — an absent
/// `conf/access.conf` is an authoritative absence, so the server stays
/// ungoverned — while naming every operator who has access to the forge and
/// how many of them there are. On an ungoverned server that repository is
/// listed and clonable like any other, so the roster is published to anyone.
///
/// It is refused at the push that would create it rather than hidden at the
/// request that would serve it: the mistake is reported where it is made, and
/// the closed-by-default posture stays the one mechanism, with no repository
/// singled out by name on the way out.
///
/// This is deliberately *not* the same thing as an empty `settings.git`. A
/// repository with neither file is an ordinary repository that happens to be
/// called `settings`, and stays as pushable as it ever was.
pub fn check_roster_has_rules(tree: &git2::Tree<'_>) -> Result<(), String> {
    let has_rules = tree.get_path(Path::new(ACCESS_CONF_PATH)).is_ok();
    let has_roster =
        tree.get_path(Path::new(KEYDIR)).is_ok() || tree.get_path(Path::new(CADIR)).is_ok();
    if has_roster && !has_rules {
        return Err(format!(
            "{KEYDIR}/ without {ACCESS_CONF_PATH} — the same is true of {CADIR}/: a roster with \
             no rules governs nothing, so this repository would be served like any other and \
             would publish who has access to this server.\n  \
             Push both together, or push {ACCESS_CONF_PATH} first."
        ));
    }
    Ok(())
}

/// The same check against what is on disk right now, for a repository that
/// reached this state before the check existed. Returns the reason if the
/// server's settings repository is holding a roster with no rules.
///
/// A hook cannot fix an existing repository — it is already there — so this is
/// reported at startup and nothing more: hiding it would be the second
/// mechanism the closed-by-default posture exists to avoid, and refusing to
/// start would take a running server down over a repository it has been
/// serving all along.
pub fn unruled_roster(repos_dir: &Path) -> Option<String> {
    let path = settings_repo_path(repos_dir);
    let repo = git2::Repository::open_bare(&path).ok()?;
    let tree = repo.head().ok()?.peel_to_commit().ok()?.tree().ok()?;
    check_roster_has_rules(&tree).err()
}

/// Operator-visible warnings about `cadir/` entries that are not outright
/// errors but are easy to get wrong silently:
///
/// - A CA fingerprint that is *also* someone's own key in `keydir/`: that
///   person can self-mint a delegate certificate naming whoever the `cadir/`
///   entry names. Legitimate for a solo operator running their own CA;
///   silent privilege escalation for anyone else, so it is surfaced rather
///   than judged here.
/// - A `cadir/` entry for a name with no `keydir/` enrolment: dead config,
///   typo-shaped — no certificate naming it can ever validate, since
///   `delegate::validate` requires the principal to exist in `keydir/`
///   first.
///
/// Warnings only: nothing here is rejected, and the config it describes is
/// otherwise usable as pushed. Deliberately *not* called from `load`, which
/// runs on every request; a caller invokes this once per config change —
/// at push validation and at server startup — the same two moments
/// `check_roster_has_rules` and `unruled_roster` cover for the roster/rules
/// split.
pub fn cadir_warnings(governance: &Governance) -> Vec<String> {
    let mut ca_names: Vec<&str> = governance.cas.names().collect();
    ca_names.sort_unstable();

    let mut warnings = Vec::new();
    for ca_name in &ca_names {
        if !governance.keys.names().iter().any(|name| name == ca_name) {
            warnings.push(format!(
                "cadir/ is enrolled for {ca_name:?}, which is not enrolled in keydir/; \
                 no certificate naming it can ever validate"
            ));
        }
        for fingerprint in governance.cas.fingerprints_for(ca_name) {
            let principal = format!("key:{fingerprint}");
            if let Some(owner) = governance.keys.name_for(&principal) {
                warnings.push(format!(
                    "the CA enrolled in cadir/ for {ca_name:?} is also {owner:?}'s own key in \
                     keydir/: {owner:?} can self-mint delegate certificates naming {ca_name:?}"
                ));
            }
        }
    }
    warnings
}

/// Record which principal created a repository, for `CREATOR` rules.
pub fn record_creator(repo_path: &Path, name: &str) -> std::io::Result<()> {
    let dir = repo_path.join(".collab");
    std::fs::create_dir_all(&dir)?;
    std::fs::write(dir.join(CREATOR_FILE), format!("{name}\n"))
}

/// The recorded creator of a repository, if it has one.
pub fn creator_of(repo_path: &Path) -> Option<String> {
    let text = std::fs::read_to_string(repo_path.join(".collab").join(CREATOR_FILE)).ok()?;
    let name = text.trim();
    if name.is_empty() {
        None
    } else {
        Some(name.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn repo_key_strips_one_dot_git_and_keeps_nesting() {
        let root = Path::new("/srv/git");
        assert_eq!(
            repo_key(root, Path::new("/srv/git/tools.git")).as_deref(),
            Some("tools")
        );
        assert_eq!(
            repo_key(root, Path::new("/srv/git/private/tools.git")).as_deref(),
            Some("private/tools")
        );
        // A working-tree repo has no .git suffix to strip.
        assert_eq!(
            repo_key(root, Path::new("/srv/git/workspace")).as_deref(),
            Some("workspace")
        );
        // Only one suffix comes off.
        assert_eq!(
            repo_key(root, Path::new("/srv/git/odd.git.git")).as_deref(),
            Some("odd.git")
        );
    }

    #[test]
    fn repo_key_refuses_paths_outside_the_storage_directory() {
        assert_eq!(
            repo_key(Path::new("/srv/git"), Path::new("/etc/passwd")),
            None
        );
        assert_eq!(repo_key(Path::new("/srv/git"), Path::new("/srv/git")), None);
    }

    #[test]
    fn a_missing_settings_repo_is_absent() {
        let tmp = tempfile::TempDir::new().unwrap();
        assert!(matches!(load(tmp.path()), GovernanceState::Absent));
    }

    /// Build a settings tree from `path -> content` pairs and return it.
    fn tree_of<'a>(repo: &'a git2::Repository, files: &[(&str, &str)]) -> git2::Tree<'a> {
        let mut index = repo.index().unwrap();
        for (path, content) in files {
            let blob = repo.blob(content.as_bytes()).unwrap();
            let mut entry = git2::IndexEntry {
                ctime: git2::IndexTime::new(0, 0),
                mtime: git2::IndexTime::new(0, 0),
                dev: 0,
                ino: 0,
                mode: 0o100644,
                uid: 0,
                gid: 0,
                file_size: 0,
                id: blob,
                flags: 0,
                flags_extended: 0,
                path: path.as_bytes().to_vec(),
            };
            entry.file_size = content.len() as u32;
            index.add(&entry).unwrap();
        }
        let oid = index.write_tree().unwrap();
        repo.find_tree(oid).unwrap()
    }

    #[test]
    fn a_roster_without_rules_is_refused_and_anything_else_is_not() {
        let tmp = tempfile::TempDir::new().unwrap();
        let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();

        let roster_only = tree_of(&repo, &[("keydir/alex.pub", "ssh-ed25519 AAAA alex")]);
        let reason = check_roster_has_rules(&roster_only)
            .expect_err("a roster with no rules must be refused");
        assert!(reason.contains(KEYDIR) && reason.contains(ACCESS_CONF_PATH));
        assert!(
            reason.contains("Push both together"),
            "the refusal must name the fix; got {reason}"
        );

        // Both halves: the configuration this exists to get to.
        assert!(check_roster_has_rules(&tree_of(
            &repo,
            &[
                ("conf/access.conf", "repo settings\n    RW+ = alex\n"),
                ("keydir/alex.pub", "ssh-ed25519 AAAA alex"),
            ],
        ))
        .is_ok());

        // Neither half: an ordinary repository that happens to be called
        // `settings`, and none of this business.
        assert!(check_roster_has_rules(&tree_of(&repo, &[("README.md", "hi")])).is_ok());

        // Rules without a roster is a different error, caught elsewhere by
        // the lockout check rather than here.
        assert!(check_roster_has_rules(&tree_of(
            &repo,
            &[("conf/access.conf", "repo settings\n    RW+ = alex\n")]
        ))
        .is_ok());
    }

    #[test]
    fn an_existing_unruled_roster_is_reported_and_an_absent_one_is_not() {
        let tmp = tempfile::TempDir::new().unwrap();
        // Nothing there at all.
        assert_eq!(unruled_roster(tmp.path()), None);

        let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
        // There, but empty: no commit, so nothing to report.
        assert_eq!(unruled_roster(tmp.path()), None);

        let tree = tree_of(&repo, &[("keydir/alex.pub", "ssh-ed25519 AAAA alex")]);
        let who = git2::Signature::now("Ops", "ops@example.com").unwrap();
        repo.commit(Some("HEAD"), &who, &who, "roster", &tree, &[])
            .unwrap();
        assert!(unruled_roster(tmp.path()).is_some());
    }

    #[test]
    fn creator_round_trips_and_is_absent_by_default() {
        let tmp = tempfile::TempDir::new().unwrap();
        assert_eq!(creator_of(tmp.path()), None);
        record_creator(tmp.path(), "claude-a").unwrap();
        assert_eq!(creator_of(tmp.path()).as_deref(), Some("claude-a"));
    }

    /// KEY_A / its ssh-keygen fingerprint, same constants as cadir.rs tests.
    const CA_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab";
    /// KEY_B from the same test suites — a distinct, well-formed key so a
    /// person's own key and a CA's key are never accidentally identical.
    const PERSON_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaUXZkX8MTYd4ztPAb11azBoq42VDJowOS3Zuj9jZlg governance-test-b@git-collab";

    #[test]
    fn read_tree_loads_cadir() {
        let tmp = tempfile::TempDir::new().unwrap();
        let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
        let tree = tree_of(
            &repo,
            &[
                ("conf/access.conf", "repo settings\n    RW+ = alex\n"),
                ("keydir/alex.pub", PERSON_KEY),
                ("cadir/mint/alex.pub", CA_KEY),
            ],
        );

        let governance = read_tree(&repo, &tree)
            .unwrap()
            .expect("conf/access.conf is present");
        assert_eq!(governance.cas.fingerprints_for("alex").len(), 1);
        assert!(governance.cas.fingerprints_for("bob").is_empty());
    }

    #[test]
    fn cadir_without_rules_is_an_unruled_roster() {
        let tmp = tempfile::TempDir::new().unwrap();
        let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
        let tree = tree_of(&repo, &[("cadir/mint/alex.pub", CA_KEY)]);

        assert!(check_roster_has_rules(&tree).is_err());
    }

    #[test]
    fn a_malformed_cadir_file_fails_validation() {
        let tmp = tempfile::TempDir::new().unwrap();
        let repo = git2::Repository::init_bare(tmp.path().join("settings.git")).unwrap();
        let tree = tree_of(
            &repo,
            &[
                ("conf/access.conf", "repo settings\n    RW+ = alex\n"),
                ("keydir/alex.pub", PERSON_KEY),
                ("cadir/alex.pub", "junk"),
            ],
        );

        let err = validate_settings_tree(&repo, &tree, "refs/heads/main").unwrap_err();
        assert!(err.contains("cadir/alex.pub"), "got {err}");
    }

    #[test]
    fn a_ca_fingerprint_that_is_also_a_keydir_key_is_warned_about() {
        let mut keys = KeyDir::new();
        keys.insert("keydir/alex.pub", PERSON_KEY).unwrap();
        let mut cas = CaDir::new();
        // alex's own key, enrolled as a CA for bob: alex can self-mint a
        // delegate certificate naming bob.
        cas.insert("cadir/mint/bob.pub", PERSON_KEY).unwrap();
        let conf = AccessConf::parse("repo settings\n    RW+ = alex\n").unwrap();
        let governance = Governance { conf, keys, cas };

        let warnings = cadir_warnings(&governance);
        assert!(
            warnings
                .iter()
                .any(|w| w.contains("alex") && w.contains("bob")),
            "got {warnings:?}"
        );
    }

    #[test]
    fn a_ca_named_for_nobody_in_keydir_is_warned_about() {
        let mut keys = KeyDir::new();
        keys.insert("keydir/alex.pub", PERSON_KEY).unwrap();
        let mut cas = CaDir::new();
        // "ghost" has no keydir/ entry at all — typo-shaped dead config.
        cas.insert("cadir/mint/ghost.pub", CA_KEY).unwrap();
        let conf = AccessConf::parse("repo settings\n    RW+ = alex\n").unwrap();
        let governance = Governance { conf, keys, cas };

        let warnings = cadir_warnings(&governance);
        assert!(
            warnings
                .iter()
                .any(|w| w.contains("ghost") && w.contains("not enrolled in keydir")),
            "got {warnings:?}"
        );
    }

    #[test]
    fn a_well_formed_cadir_has_no_warnings() {
        let mut keys = KeyDir::new();
        keys.insert("keydir/alex.pub", PERSON_KEY).unwrap();
        let mut cas = CaDir::new();
        // A distinct CA key, enrolled for the person it names — the
        // ordinary, unremarkable case.
        cas.insert("cadir/mint/alex.pub", CA_KEY).unwrap();
        let conf = AccessConf::parse("repo settings\n    RW+ = alex\n").unwrap();
        let governance = Governance { conf, keys, cas };

        assert!(cadir_warnings(&governance).is_empty());
    }
}