a73x

src/server/setup.rs

Ref:   Size: 40.9 KiB   History

//! `git-collab-server setup`: create `settings.git` and put governance in
//! force, the way `gitolite setup` creates `gitolite-admin`.
//!
//! Governance and the exposure model both shipped with nothing able to create
//! the repository that turns them on, so a deployment could run the code, host
//! repositories, and have no `settings.git` anywhere — governance present and
//! entirely inert. This is the missing piece.
//!
//! # Why this is a command and never a startup step
//!
//! Creating `settings.git` is precisely what puts the inverted default in
//! force: under governance a repository is unlisted and unreadable unless a
//! rule says otherwise. A server that seeded one on boot would, at the moment
//! it upgraded, make every repository invisible until somebody wrote rules —
//! the protection undone by the feature meant to make governance usable. So
//! the operator runs this, once, deliberately.
//!
//! # Why the seed reproduces the policy already in force
//!
//! The same reason, from the other end. If enabling governance changed what
//! the server exposes, no operator would dare enable it on anything live, and
//! a bootstrap nobody runs is not a bootstrap. So the seed reads each
//! repository's `server.toml` and writes the rule that means what it means
//! today, including `R = @anonymous` and `option listed = yes` where the
//! repository is public. Enabling governance is then behaviourally a no-op,
//! and `tests/server_setup_test.rs` asserts exactly that against a live
//! server.
//!
//! The claim is also checked here, at run time, rather than only in the test
//! suite: the generated rules are parsed and every repository's decision is
//! compared against the decision `server.toml` gives today. A mismatch is a
//! bug in this file, and it aborts rather than governing a server with rules
//! that mean something other than what it was told they mean.
//!
//! # Why the seed enrols the keys that already have access
//!
//! The same principle again, on the axis where it is easiest to get wrong.
//! `keydir/` supersedes `authorized_keys` the instant `settings.git` exists —
//! `auth_publickey` reads one roster or the other, never both — so a bootstrap
//! that enrolled only the administrator would revoke SSH for everybody else at
//! the moment it ran. That is not a smaller change than hiding every
//! repository; it is a larger one, and it is the change that locks the
//! operator out of the server they are configuring.
//!
//! So every well-formed entry in `authorized_keys` is enrolled, and the rules
//! translate the `server.toml` rosters that named those keys by fingerprint.
//! `--no-enrol-existing` asks for the clean single-administrator bootstrap
//! instead, and says how many keys that costs. The default is the safe one
//! because the two mistakes are not symmetric: an over-broad roster is a
//! `git rm` and a push, and a lockout may need the filesystem.
//!
//! # The one thing governance cannot say
//!
//! `server.toml` has two anonymous switches — `[ui] anonymous` and
//! `[http] anonymous_clone` — and governance has one grant, `R = @anonymous`,
//! covering both. A repository that has them set differently therefore has no
//! faithful translation. Setup writes the closed answer and says so, loudly
//! and by name, in the report and in a comment in the file: a bootstrap may
//! narrow exposure where it must, and must never widen it by guessing.

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

use russh::keys::PublicKey;

use crate::governance::{
    self,
    conf::{Access, AccessConf, Subject},
    keydir, SETTINGS_REPO,
};
use crate::repos::{self, RepoPolicy};

/// The branch the seed lands on. `governance::load` follows HEAD, so this is
/// a convention rather than a requirement — but a convention the operator
/// clones, so it should be the modern one.
const SEED_BRANCH: &str = "main";

/// Where the identity in `keydir/` came from. Reported, because the basename
/// *is* the principal: a wrong one locks the operator out of the repository
/// setup just created, and the failure would not show up until the first push.
enum NameSource {
    Explicit,
    Filename,
}

/// Run the command. Returns the process exit code.
pub fn run(
    repos_dir: &Path,
    authorized_keys: &Path,
    admin_key: &str,
    admin_name: Option<&str>,
    enrol_existing: bool,
    dry_run: bool,
) -> i32 {
    match try_run(
        repos_dir,
        authorized_keys,
        admin_key,
        admin_name,
        enrol_existing,
        dry_run,
    ) {
        Ok(()) => 0,
        Err(reason) => {
            eprintln!("error: {reason}");
            1
        }
    }
}

fn try_run(
    repos_dir: &Path,
    authorized_keys: &Path,
    admin_key: &str,
    admin_name: Option<&str>,
    enrol_existing: bool,
    dry_run: bool,
) -> Result<(), String> {
    if !repos_dir.is_dir() {
        return Err(format!(
            "repos_dir {} does not exist; nothing to govern",
            repos_dir.display()
        ));
    }

    let settings_path = repos_dir.join(format!("{SETTINGS_REPO}.git"));
    if settings_path.exists() {
        return Err(format!(
            "{} already exists; this server is already set up.\n  \
             Setup will not overwrite rules an operator wrote. Clone it and edit it, \
             or move it aside if you meant to start again.",
            settings_path.display()
        ));
    }

    let (key_text, key, name, name_source) = read_admin_key(admin_key, admin_name)?;
    let admin_principal = crate::ssh::session::ssh_key_principal(&key);

    // Read `authorized_keys` whether or not we are enrolling from it: the
    // count is what makes declining an informed choice rather than a silent
    // one.
    let enrolment = Enrolment::plan(authorized_keys, &admin_principal, &name, enrol_existing);
    let roster = enrolment.roster(&admin_principal, &name);

    let entries = repos::discover(repos_dir)
        .map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?;

    // A directory literally named `settings` is the same name as the
    // governance repository, and would collide with the block written for it.
    if let Some(entry) = entries.iter().find(|entry| entry.name == SETTINGS_REPO) {
        return Err(format!(
            "{} is already a repository named {SETTINGS_REPO}, which is the name governance \
             reserves for itself. Rename it before setting up.",
            entry.path.display()
        ));
    }

    let plan = Plan::build(repos_dir, &entries, &name, &roster);
    let conf_text = plan.render();

    // Machine-check the safety claim before anything is written: the rules
    // just generated must give every repository the decision it already has,
    // to an anonymous request and to each key being enrolled.
    verify(&conf_text, &plan, &roster)?;

    if dry_run {
        print!("{conf_text}");
        println!();
        report(&plan, &name, &name_source, &enrolment, &settings_path, true);
        return Ok(());
    }

    seed(&settings_path, &conf_text, &name, &key_text, &enrolment)?;
    report(
        &plan,
        &name,
        &name_source,
        &enrolment,
        &settings_path,
        false,
    );
    Ok(())
}

// ---------------------------------------------------------------------------
// The admin key
// ---------------------------------------------------------------------------

/// Read the admin key from a file or stdin, and work out the identity it
/// belongs to.
///
/// The name is the `keydir/` basename, which follows `gitolite setup -pk`:
/// the file you hand it names the principal. `-` has no filename to read, so
/// it needs `--admin-name` and says so rather than inventing something from
/// the key comment — a comment is free text an operator never chose as an
/// identity, and it is the kind of guess that is wrong exactly once.
fn read_admin_key(
    admin_key: &str,
    admin_name: Option<&str>,
) -> Result<(String, PublicKey, String, NameSource), String> {
    let (text, from_filename) = if admin_key == "-" {
        let mut buffer = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer)
            .map_err(|e| format!("cannot read the admin key from stdin: {e}"))?;
        (buffer, None)
    } else {
        let path = PathBuf::from(admin_key);
        let text = std::fs::read_to_string(&path)
            .map_err(|e| format!("cannot read the admin key from {}: {e}", path.display()))?;
        let stem = path
            .file_name()
            .and_then(|n| n.to_str())
            .map(|n| n.strip_suffix(".pub").unwrap_or(n).to_string());
        (text, stem)
    };

    let text = text.trim().to_string();
    if text.is_empty() {
        return Err(format!(
            "the admin key is empty; expected one OpenSSH public key \
             (`ssh-ed25519 AAAA… you@host`) from {}",
            if admin_key == "-" { "stdin" } else { admin_key }
        ));
    }

    let key = PublicKey::from_openssh(&text).map_err(|e| {
        format!(
            "the admin key is not a well-formed OpenSSH public key: {e}\n  \
             This is the contents of a `.pub` file, not a private key."
        )
    })?;

    let (name, source) = match (admin_name, from_filename) {
        (Some(name), _) => (name.to_string(), NameSource::Explicit),
        (None, Some(stem)) => (stem, NameSource::Filename),
        (None, None) => {
            return Err(
                "a key read from stdin has no filename to take an identity from; \
                 pass --admin-name <name> to say who it belongs to"
                    .to_string(),
            )
        }
    };

    // The basename is written straight into `conf/access.conf` as a
    // principal, so it has to be a name that file can hold.
    keydir::validate_name(&name).map_err(|reason| {
        format!(
            "{name:?} cannot name a principal: {reason}\n  \
             The identity is the key file's basename without `.pub`. \
             Pass --admin-name <name> to choose it directly."
        )
    })?;

    Ok((text, key, name, source))
}

// ---------------------------------------------------------------------------
// The keys that already have access
// ---------------------------------------------------------------------------

/// How an enrolled key got the name it got.
///
/// Reported for every key, because the basename *is* the principal: it goes
/// into `conf/access.conf`, and a name that is merely plausible is worse than
/// one that is obviously provisional. `key3` invites a rename; `alex` does not.
enum Naming {
    /// The entry's trailing comment, used verbatim.
    Comment,
    /// A placeholder. The string says why the comment could not be used.
    Placeholder(&'static str),
}

/// One `authorized_keys` entry, on its way into `keydir/`.
struct EnrolledKey {
    name: String,
    /// The OpenSSH line, written as `keydir/<name>.pub`. Kept verbatim so the
    /// file in git is recognisably the line the operator had.
    text: String,
    fingerprint: String,
    /// Which line of `authorized_keys` it came from, 1-based.
    line: usize,
    naming: Naming,
}

/// What `authorized_keys` yielded.
struct Enrolment {
    path: PathBuf,
    keys: Vec<EnrolledKey>,
    /// Entries that were read but not enrolled, each with the reason. Never
    /// fatal: one unusable line must not cost everybody else their access.
    notes: Vec<String>,
    /// Well-formed entries left out because `--no-enrol-existing` was given.
    /// This is the size of the lockout the operator asked for.
    declined: usize,
}

/// A candidate entry, before names are settled.
struct Candidate {
    line: usize,
    text: String,
    fingerprint: String,
    /// The trailing comment, if the entry has one that could be a name.
    wanted: Option<String>,
    /// Why it could not be, if it could not.
    refusal: Option<&'static str>,
}

impl Enrolment {
    /// Read `authorized_keys` and decide who is enrolled under what name.
    ///
    /// Nothing here is fatal. A file that does not exist, a line that is not a
    /// key, an entry carrying `command=` restrictions — each is reported and
    /// skipped, because the purpose of this pass is to *keep* access, and
    /// aborting over one bad line would keep none of it.
    fn plan(path: &Path, admin_fingerprint: &str, admin_name: &str, enrol: bool) -> Enrolment {
        let mut enrolment = Enrolment {
            path: path.to_path_buf(),
            keys: Vec::new(),
            notes: Vec::new(),
            declined: 0,
        };

        let text = match std::fs::read_to_string(path) {
            Ok(text) => text,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                // Not an error: a server that has never had one has nobody to
                // lock out.
                return enrolment;
            }
            Err(e) => {
                enrolment.notes.push(format!(
                    "{} could not be read ({e}), so no existing key was enrolled. \
                     Every key it holds will be refused SSH once governance is on.",
                    path.display()
                ));
                return enrolment;
            }
        };

        let candidates = enrolment.read_entries(&text, admin_fingerprint);
        if !enrol {
            enrolment.declined = candidates.len();
            return enrolment;
        }
        enrolment.keys = assign_names(candidates, admin_name);
        enrolment
    }

    /// Parse the file into candidates, recording every entry it had to drop.
    fn read_entries(&mut self, text: &str, admin_fingerprint: &str) -> Vec<Candidate> {
        let mut candidates: Vec<Candidate> = Vec::new();
        let mut seen: HashSet<String> = HashSet::new();

        for (index, raw) in text.lines().enumerate() {
            let line = index + 1;
            let entry = raw.trim();
            if entry.is_empty() || entry.starts_with('#') {
                continue;
            }

            // An entry with options (`command="…",no-pty ssh-ed25519 …`) does
            // not parse here — and does not authenticate today either, because
            // `is_authorized` compares the first field against the key type.
            // Skipping it is therefore faithful, not a narrowing.
            let key = match PublicKey::from_openssh(entry) {
                Ok(key) => key,
                Err(e) => {
                    self.notes.push(format!(
                        "{}:{line} is not a well-formed OpenSSH public key ({e}), so it was not \
                         enrolled. An entry carrying `command=` or other options looks like this \
                         too, and does not authenticate today either.",
                        self.path.display()
                    ));
                    continue;
                }
            };

            let fingerprint = crate::ssh::session::ssh_key_principal(&key);
            if fingerprint == admin_fingerprint {
                self.notes.push(format!(
                    "{}:{line} is the administrator's own key, already enrolled under the name \
                     --admin-key gave it. One key cannot be two principals, so it was not \
                     enrolled a second time.",
                    self.path.display()
                ));
                continue;
            }
            if !seen.insert(fingerprint.clone()) {
                self.notes.push(format!(
                    "{}:{line} repeats a key that appears earlier in the file; enrolled once.",
                    self.path.display()
                ));
                continue;
            }

            let comment = comment_of(entry).to_string();
            let (wanted, refusal) = if comment.is_empty() {
                (None, Some("the entry has no comment to take a name from"))
            } else if keydir::validate_name(&comment).is_err() {
                (
                    None,
                    Some("its comment cannot be written as a principal name"),
                )
            } else {
                (Some(comment), None)
            };

            candidates.push(Candidate {
                line,
                text: entry.to_string(),
                fingerprint,
                wanted,
                refusal,
            });
        }
        candidates
    }

    /// Fingerprint-to-name for every identity these rules may mention: the
    /// administrator, and each key being enrolled.
    ///
    /// This is exactly the set of `server.toml` roster entries that can be
    /// translated at all — a fingerprint outside it has no name to write.
    fn roster(&self, admin_fingerprint: &str, admin_name: &str) -> Roster {
        let mut roster = vec![(admin_fingerprint.to_string(), admin_name.to_string())];
        for key in &self.keys {
            roster.push((key.fingerprint.clone(), key.name.clone()));
        }
        Roster(roster)
    }
}

/// The trailing comment of an `authorized_keys` entry: everything after the
/// key type and the key data.
///
/// Taken from the line rather than from the parsed key, so that "the comment"
/// means here what it means in `ssh::auth`, which splits the same way. A
/// comment is free text and may hold spaces; whether it can be a name is a
/// separate question, asked next.
fn comment_of(entry: &str) -> &str {
    let after_type = match entry.trim().split_once(char::is_whitespace) {
        Some((_, rest)) => rest.trim_start(),
        None => return "",
    };
    match after_type.split_once(char::is_whitespace) {
        Some((_, comment)) => comment.trim(),
        None => "",
    }
}

/// Settle a name on every candidate.
///
/// The comment is used **verbatim** when it can be a principal name, and never
/// cut down to a first component. Turning `alex@laptop` into `alex` would be a
/// guess, and the way that guess fails is the expensive way: two entries the
/// operator listed separately — `alex@laptop`, `alex@desktop` — collapse into
/// one principal, and a rule written for one silently covers the other. A
/// bootstrap may narrow access where it must; it must never widen it by
/// inference.
///
/// For the same reason a name two entries both claim is given to neither. One
/// of them winning by file order would put a plausible name on the wrong key,
/// which is the mistake that survives review; `key1` and `key2` do not.
fn assign_names(candidates: Vec<Candidate>, admin_name: &str) -> Vec<EnrolledKey> {
    let mut claimed: HashSet<&str> = HashSet::new();
    let mut contested: HashSet<&str> = HashSet::new();
    for candidate in &candidates {
        if let Some(name) = candidate.wanted.as_deref() {
            if !claimed.insert(name) {
                contested.insert(name);
            }
        }
    }

    let mut taken: HashSet<String> = HashSet::new();
    taken.insert(admin_name.to_string());
    let mut next_placeholder = 1usize;

    let mut enrolled = Vec::new();
    for candidate in &candidates {
        let refusal = match candidate.wanted.as_deref() {
            Some(name) if contested.contains(name) => {
                Some("the same comment appears on more than one entry")
            }
            Some(name) if name == admin_name => {
                Some("its comment is the name the administrator already holds")
            }
            Some(_) => None,
            None => candidate.refusal,
        };

        let (name, naming) = match (candidate.wanted.as_deref(), refusal) {
            (Some(name), None) => (name.to_string(), Naming::Comment),
            (_, reason) => {
                let mut placeholder = format!("key{next_placeholder}");
                while taken.contains(&placeholder) {
                    next_placeholder += 1;
                    placeholder = format!("key{next_placeholder}");
                }
                next_placeholder += 1;
                (
                    placeholder,
                    Naming::Placeholder(reason.unwrap_or("its comment could not name a principal")),
                )
            }
        };
        taken.insert(name.clone());
        enrolled.push(EnrolledKey {
            name,
            text: candidate.text.clone(),
            fingerprint: candidate.fingerprint.clone(),
            line: candidate.line,
            naming,
        });
    }
    enrolled
}

/// Fingerprint-to-name for the identities this seed enrols.
struct Roster(Vec<(String, String)>);

impl Roster {
    fn name_for(&self, fingerprint: &str) -> Option<&str> {
        self.0
            .iter()
            .find(|(f, _)| f == fingerprint)
            .map(|(_, name)| name.as_str())
    }

    fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
        self.0.iter().map(|(f, n)| (f.as_str(), n.as_str()))
    }
}

// ---------------------------------------------------------------------------
// Translating the policy already in force
// ---------------------------------------------------------------------------

/// What one repository's `server.toml` says, and the rules that mean the same.
struct RepoPlan {
    /// The key rules are matched against: the path under `repos_dir` with one
    /// `.git` removed. The same string `repo_key` gives the request path.
    key: String,
    /// Rules, already rendered as `(lhs, rhs)` pairs.
    rules: Vec<(String, String)>,
    /// `option listed = yes`.
    listed: bool,
    /// What an anonymous request may do today, and so must still do.
    anonymous_read: bool,
    /// Notes to write into the file above the block, and to report.
    notes: Vec<String>,
    /// The policy these rules were translated from, kept so `verify` can ask
    /// it the same questions it asks the rule engine.
    policy: RepoPolicy,
}

struct Plan {
    admin: String,
    repos: Vec<RepoPlan>,
    /// Repositories no rule could be written for at all, and why.
    skipped: Vec<String>,
    /// Every place the seed could not say what `server.toml` says, and so
    /// said less. Empty is the good answer, and the usual one; anything here
    /// is a decision the operator now has to make deliberately.
    narrowed: Vec<String>,
}

impl Plan {
    fn build(repos_dir: &Path, entries: &[repos::RepoEntry], admin: &str, roster: &Roster) -> Plan {
        let mut plan = Plan {
            admin: admin.to_string(),
            repos: Vec::new(),
            skipped: Vec::new(),
            narrowed: Vec::new(),
        };

        for entry in entries {
            let Some(key) = governance::repo_key(repos_dir, &entry.path) else {
                plan.skipped.push(format!(
                    "{}: not a name that can appear in the rules",
                    entry.path.display()
                ));
                continue;
            };
            // A name carrying regex metacharacters would compile to a wild
            // pattern and quietly govern repositories other than this one.
            if !AccessConf::is_literal_repo_name(&key) {
                // Left out, so governance closes it. Say plainly whether that
                // is a change anyone will notice: this is the one place the
                // seed cannot keep its no-op promise, and burying it in a
                // sentence about rule syntax would be the wrong emphasis.
                let visible = if entry.policy.allows_anonymous_ui() {
                    "It is public today, so it will disappear from the repository list \
                     until you do."
                } else {
                    "It is not on the anonymous surface today, so nothing visible changes."
                };
                plan.skipped.push(format!(
                    "{key}: this name cannot be written as a literal rule (it would compile \
                     to a pattern matching other repositories), so no block was generated \
                     for it and governance closes it to everyone.\n{visible}"
                ));
                continue;
            }
            plan.repos
                .push(RepoPlan::build(&key, &entry.policy, roster));
        }

        for repo in &plan.repos {
            for note in &repo.notes {
                plan.narrowed.push(format!("{}: {note}", repo.key));
            }
        }
        plan
    }

    /// The whole `conf/access.conf`.
    fn render(&self) -> String {
        let mut out = String::new();
        out.push_str(
            "# conf/access.conf — the rules that govern this server.\n\
             #\n\
             # Generated by `git-collab-server setup` from the policy that was already in\n\
             # force: every block below reproduces what the repository's server.toml grants\n\
             # today, so turning governance on changed nothing. From here on this file is\n\
             # the authority on access, and server.toml's [access], visibility, [ui] and\n\
             # [http] keys are no longer consulted.\n\
             #\n\
             # Push to this repository to change it. The push is validated first, and one\n\
             # that would lock everyone out is refused.\n\n",
        );

        out.push_str(&format!("repo {SETTINGS_REPO}\n"));
        out.push_str(
            "    # Whoever holds this may rewrite the rules, so it is deliberately one\n\
             \x20   # name rather than a group. No anonymous grant: the key roster lives\n\
             \x20   # here, and governance is unlisted and unreadable until a rule says\n\
             \x20   # otherwise.\n",
        );
        out.push_str(&rule("RW+", &self.admin));

        for repo in &self.repos {
            out.push('\n');
            out.push_str(&format!("repo {}\n", repo.key));
            for note in &repo.notes {
                for line in note.lines() {
                    out.push_str(&format!("    # {line}\n"));
                }
            }
            for (lhs, rhs) in &repo.rules {
                out.push_str(&rule(lhs, rhs));
            }
            if repo.listed {
                out.push_str(&rule("option listed", "yes"));
            }
        }
        out
    }
}

/// One rule line, in the column layout the design's examples use.
fn rule(lhs: &str, rhs: &str) -> String {
    format!("    {lhs:<21} =   {rhs}\n")
}

impl RepoPlan {
    fn build(key: &str, policy: &RepoPolicy, roster: &Roster) -> RepoPlan {
        let mut rules = Vec::new();
        let mut notes = Vec::new();

        // --- The authenticated axis -------------------------------------
        //
        // `*` in server.toml means "any principal that authenticated at all",
        // and `@all` means "every key enrolled in keydir/", which is the same
        // sentence in the new language *because* every key that could
        // authenticate is being enrolled. A force-push was never separately
        // gated before governance, so a writer maps to RW+.
        let read_all = policy.access.read.iter().any(|entry| entry == "*");
        let write_all = policy.access.write.iter().any(|entry| entry == "*");

        // An explicit roster names fingerprints, and rules name keydir
        // identities. The ones this command enrols can be translated; a
        // fingerprint belonging to no enrolled key has no name to write, and
        // is recorded so the operator can enrol and grant it.
        let mut untranslatable: Vec<&str> = policy
            .access
            .read
            .iter()
            .chain(policy.access.write.iter())
            .map(String::as_str)
            .filter(|entry| *entry != "*" && roster.name_for(entry).is_none())
            .collect();
        untranslatable.sort();
        untranslatable.dedup();

        // A fingerprint cannot appear in a rule, so each roster entry named by
        // `server.toml` is written under the name it is being enrolled with.
        // Nothing is emitted where `@all` below already says the same thing.
        for (fingerprint, name) in roster.iter() {
            if !write_all && policy.allows_write(fingerprint) {
                rules.push(("RW+".to_string(), name.to_string()));
            } else if !read_all && policy.allows_read(fingerprint) {
                rules.push(("R".to_string(), name.to_string()));
            }
        }
        if write_all {
            rules.push(("RW+".to_string(), "@all".to_string()));
        } else if read_all {
            rules.push(("R".to_string(), "@all".to_string()));
        }

        if !untranslatable.is_empty() {
            notes.push(format!(
                "server.toml granted access to {} by key fingerprint, and rules name\n\
                 keydir identities instead. Enrol each key as keydir/<name>.pub and add\n\
                 the grant here; until then those keys cannot reach this repository:\n  {}",
                if untranslatable.len() == 1 {
                    "a key".to_string()
                } else {
                    format!("{} keys", untranslatable.len())
                },
                untranslatable.join("\n  ")
            ));
        }

        // --- The anonymous axis -----------------------------------------
        //
        // Two switches upstream, one grant here. Where they agree the
        // translation is exact; where they disagree there is no rule that
        // means what server.toml means, so the closed answer is written and
        // named. Narrowing is a thing a bootstrap may do; widening is not.
        let ui = policy.allows_anonymous_ui();
        let http = policy.allows_anonymous_http();
        let anonymous_read = ui && http;
        if ui != http {
            notes.push(format!(
                "server.toml sets [ui] anonymous = {ui} but [http] anonymous_clone = {http},\n\
                 and governance has one anonymous read grant covering both. No rule can\n\
                 mean that, so none was written and this repository is now closed to\n\
                 anonymous requests. Add `R = @anonymous` (and `option listed = yes`) to\n\
                 publish it deliberately."
            ));
        }
        if anonymous_read {
            rules.push(("R".to_string(), "@anonymous".to_string()));
        }

        RepoPlan {
            key: key.to_string(),
            rules,
            listed: anonymous_read,
            anonymous_read,
            notes,
            policy: policy.clone(),
        }
    }
}

// ---------------------------------------------------------------------------
// The self-check
// ---------------------------------------------------------------------------

/// Parse what was generated and confirm it decides every repository the way
/// the plan says it should — for an anonymous request, and for every key being
/// enrolled.
///
/// This is the safety claim, checked against the real evaluator rather than
/// against a second copy of the intent. If it ever fails, this file has a bug
/// and the right thing to do is refuse to govern the server with it.
fn verify(conf_text: &str, plan: &Plan, roster: &Roster) -> Result<(), String> {
    let conf = AccessConf::parse(conf_text).map_err(|e| {
        format!(
            "the generated rules do not parse ({e}); refusing to write them. \
                 This is a bug in `setup`."
        )
    })?;

    for repo in &plan.repos {
        let anonymous_read = conf.anonymous_may_read(&repo.key);
        let listed = conf.is_listed(&repo.key);
        if anonymous_read != repo.anonymous_read || listed != repo.listed {
            return Err(format!(
                "the generated rules would expose {} differently from its server.toml \
                 (anonymous read {} vs {}, listed {} vs {}); refusing to write them. \
                 This is a bug in `setup`.",
                repo.key, anonymous_read, repo.anonymous_read, listed, repo.listed
            ));
        }
    }

    // The other half of the same claim: every key that could authenticate
    // before must reach exactly the repositories it reached before. Asked of
    // the rule engine, against the `server.toml` decision it is replacing.
    //
    // Rewind is compared against the write decision deliberately. Nothing
    // before governance gated a force-push separately from a push, so a
    // writer maps to RW+, and this is where that equivalence is checked
    // rather than assumed.
    for repo in &plan.repos {
        for (fingerprint, name) in roster.iter() {
            let subject = Subject::new(name);
            for (access, expected) in [
                (Access::Read, repo.policy.allows_read(fingerprint)),
                (Access::Write, repo.policy.allows_write(fingerprint)),
                (Access::Rewind, repo.policy.allows_write(fingerprint)),
            ] {
                let granted = conf.allows_repo(&repo.key, &subject, access);
                if granted != expected {
                    return Err(format!(
                        "the generated rules would give {name} {access:?} on {} where its \
                         server.toml gives {expected} (rules say {granted}); refusing to write \
                         them. This is a bug in `setup`.",
                        repo.key
                    ));
                }
            }
        }
    }

    // The settings repository must stay off the anonymous surface: the whole
    // key roster is in it.
    if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) {
        return Err(format!(
            "the generated rules would publish {SETTINGS_REPO}, which holds the key \
             roster; refusing to write them. This is a bug in `setup`."
        ));
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Writing it
// ---------------------------------------------------------------------------

/// Create `settings.git` and land both files in a single commit.
///
/// One commit, not two: the push-time guard refuses a roster with no rules,
/// because that state governs nothing while publishing who has access to the
/// server. Setup must not create by hand the very thing that guard exists to
/// prevent — so the tree is assembled whole, put through the same validation a
/// push would face, and only then committed.
fn seed(
    settings_path: &Path,
    conf_text: &str,
    admin_name: &str,
    key_text: &str,
    enrolment: &Enrolment,
) -> Result<(), String> {
    let mut options = git2::RepositoryInitOptions::new();
    options.bare(true).initial_head(SEED_BRANCH);
    let repo = git2::Repository::init_opts(settings_path, &options)
        .map_err(|e| format!("cannot create {}: {e}", settings_path.display()))?;

    // From here on a failure leaves a half-made repository, which would make
    // the server think it is governed by nothing. Clean it up on every exit.
    match build_and_commit(&repo, conf_text, admin_name, key_text, enrolment) {
        Ok(()) => Ok(()),
        Err(reason) => {
            drop(repo);
            let _ = std::fs::remove_dir_all(settings_path);
            Err(reason)
        }
    }
}

fn build_and_commit(
    repo: &git2::Repository,
    conf_text: &str,
    admin_name: &str,
    key_text: &str,
    enrolment: &Enrolment,
) -> Result<(), String> {
    let git = |e: git2::Error| format!("building the seed commit: {e}");

    let conf_blob = repo.blob(conf_text.as_bytes()).map_err(git)?;

    let mut conf_dir = repo.treebuilder(None).map_err(git)?;
    conf_dir
        .insert("access.conf", conf_blob, 0o100644)
        .map_err(git)?;
    let conf_tree = conf_dir.write().map_err(git)?;

    let mut keydir_tree = repo.treebuilder(None).map_err(git)?;
    for (name, text) in std::iter::once((admin_name, key_text)).chain(
        enrolment
            .keys
            .iter()
            .map(|key| (key.name.as_str(), key.text.as_str())),
    ) {
        let blob = repo
            .blob(format!("{}\n", text.trim()).as_bytes())
            .map_err(git)?;
        keydir_tree
            .insert(format!("{name}.pub"), blob, 0o100644)
            .map_err(git)?;
    }
    let keydir_tree = keydir_tree.write().map_err(git)?;

    let mut root = repo.treebuilder(None).map_err(git)?;
    root.insert("conf", conf_tree, 0o040000).map_err(git)?;
    root.insert("keydir", keydir_tree, 0o040000).map_err(git)?;
    let tree = repo.find_tree(root.write().map_err(git)?).map_err(git)?;

    // Exactly the checks a push to this repository would face: the rules
    // parse, somebody is enrolled, and somebody still holds RW+ on settings.
    // Passing them is what makes the seed a configuration the server would
    // have accepted from an operator.
    let landing_ref = format!("refs/heads/{SEED_BRANCH}");
    governance::check_roster_has_rules(&tree)?;
    governance::validate_settings_tree(repo, &tree, &landing_ref)?;

    let who = git2::Signature::now("git-collab-server setup", "setup@git-collab").map_err(git)?;
    repo.commit(
        Some(&landing_ref),
        &who,
        &who,
        "Seed governance from the policy already in force\n\n\
         Generated by `git-collab-server setup`. Every block reproduces what the\n\
         repository's server.toml granted at this moment, so enabling governance\n\
         changed nothing about what this server exposes.\n",
        &tree,
        &[],
    )
    .map_err(git)?;
    Ok(())
}

// ---------------------------------------------------------------------------
// The report
// ---------------------------------------------------------------------------

fn report(
    plan: &Plan,
    name: &str,
    source: &NameSource,
    enrolment: &Enrolment,
    settings_path: &Path,
    dry_run: bool,
) {
    let derived = match source {
        NameSource::Explicit => "from --admin-name",
        NameSource::Filename => "from the key file's name",
    };
    if dry_run {
        println!("Dry run: nothing was written.");
        println!("Would create {}", settings_path.display());
    } else {
        println!("Created {}", settings_path.display());
    }
    println!("Admin identity: {name}  ({derived}, enrolled as keydir/{name}.pub)");
    println!(
        "  Rules are written against this name, and it holds RW+ on `{SETTINGS_REPO}`. \
         If it is wrong, nobody can push the rules."
    );
    println!(
        "Reproduced the policy of {} {}.",
        plan.repos.len(),
        if plan.repos.len() == 1 {
            "repository"
        } else {
            "repositories"
        }
    );

    report_enrolment(enrolment, dry_run);

    for note in plan.narrowed.iter().chain(plan.skipped.iter()) {
        let mut lines = note.lines();
        if let Some(first) = lines.next() {
            eprintln!("warning: {first}");
        }
        for line in lines {
            eprintln!("         {line}");
        }
    }

    if !dry_run {
        println!();
        println!("Governance is now in force. `authorized_keys` was not touched, and is");
        println!("no longer consulted: only keys in keydir/ authenticate. Add anyone else by");
        println!(
            "cloning {}, adding keydir/<name>.pub",
            settings_path.display()
        );
        println!("and a grant in conf/access.conf, and pushing both together.");
    }
}

/// Say what happened to `authorized_keys`, by name.
///
/// Every derived name is printed, not just the surprising ones. The operator
/// is being handed a set of principals they did not choose, at the one moment
/// renaming them is free — a file rename and a push — and before anything in
/// `conf/access.conf` has come to depend on them.
fn report_enrolment(enrolment: &Enrolment, dry_run: bool) {
    let verb = if dry_run { "Would enrol" } else { "Enrolled" };

    if enrolment.declined > 0 {
        println!();
        println!(
            "--no-enrol-existing: {} {} in {} {} NOT enrolled, and will be refused SSH",
            enrolment.declined,
            if enrolment.declined == 1 {
                "key"
            } else {
                "keys"
            },
            enrolment.path.display(),
            if enrolment.declined == 1 {
                "was"
            } else {
                "were"
            },
        );
        println!("as soon as governance is in force. Only the admin identity above can connect.");
    } else if !enrolment.keys.is_empty() {
        println!();
        println!(
            "{verb} {} {} from {}, so nobody loses access:",
            enrolment.keys.len(),
            if enrolment.keys.len() == 1 {
                "key"
            } else {
                "keys"
            },
            enrolment.path.display()
        );
        for key in &enrolment.keys {
            match key.naming {
                Naming::Comment => println!(
                    "  keydir/{}.pub  (line {}, named after the entry's comment)",
                    key.name, key.line
                ),
                Naming::Placeholder(reason) => {
                    println!(
                        "  keydir/{}.pub  (line {}, PROVISIONAL: {reason})",
                        key.name, key.line
                    );
                    println!("      {}", key.fingerprint);
                }
            }
        }
        if enrolment
            .keys
            .iter()
            .any(|key| matches!(key.naming, Naming::Placeholder(_)))
        {
            println!(
                "  A provisional name is a working principal, not a right one. Rename the file \
                 and the"
            );
            println!(
                "  grants that use it before anyone comes to rely on it; match the key by the \
                 fingerprint above."
            );
        }
    }

    for note in &enrolment.notes {
        eprintln!("warning: {note}");
    }
}