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());
}
}