a73x

src/server/governance/conf.rs

Ref:   Size: 45.2 KiB   History

//! Parser and evaluator for `conf/access.conf`, the ordered access rules held
//! in `settings.git`.
//!
//! The syntax and semantics follow gitolite, because the point of the format is
//! that an operator who knows gitolite already knows this:
//!
//! ```text
//! @admins   = alex
//! @agents   = claude-a claude-b
//!
//! repo tools
//!     RW+                   =   @admins
//!     RW      refs/collab/  =   @agents
//!     R                     =   @all
//! ```
//!
//! Evaluation is **ordered, first-match-wins**. Rules are gathered from every
//! `repo` block matching the repository, in file order, filtered to the
//! accessing principal, and the first rule whose refex matches the ref decides:
//! `-` denies, a permission containing the requested access allows, anything
//! else falls through to the next rule. Order is therefore load-bearing, which
//! is why this is line-oriented rather than TOML.

use std::collections::HashMap;

use regex::Regex;

/// The access being requested.
///
/// These are the letters a rule's permission is tested against, so the mapping
/// from permission to access is literal containment: `RW+` grants `R`, `W` and
/// `+` and nothing else.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
    /// Fetch, clone, list releases.
    Read,
    /// Fast-forward a ref, create a ref.
    Write,
    /// Rewind or delete a ref; upload or delete a release.
    Rewind,
    /// Create the repository itself (wild repos).
    Create,
}

/// A rule's permission. Exactly the set the design names, plus `-` for deny.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Perm {
    Deny,
    R,
    Rw,
    RwPlus,
    C,
}

impl Perm {
    fn parse(token: &str) -> Option<Perm> {
        match token {
            "-" => Some(Perm::Deny),
            "R" => Some(Perm::R),
            "RW" => Some(Perm::Rw),
            "RW+" => Some(Perm::RwPlus),
            "C" => Some(Perm::C),
            _ => None,
        }
    }

    /// Whether this permission grants `access`.
    ///
    /// `C` is deliberately disjoint from `RW+`: creating a repository is not
    /// implied by full control over one that already exists, which is what
    /// lets `C = @agents` / `RW+ = CREATOR` mean what it says.
    pub fn grants(self, access: Access) -> bool {
        match self {
            Perm::Deny => false,
            Perm::R => access == Access::Read,
            Perm::Rw => matches!(access, Access::Read | Access::Write),
            Perm::RwPlus => matches!(access, Access::Read | Access::Write | Access::Rewind),
            Perm::C => access == Access::Create,
        }
    }
}

/// The reserved principal standing for a request that carries no identity at
/// all. It is deliberately *not* a member of `@all`, which means every
/// enrolled key: "every key we know" and "anybody at all" are different sets,
/// and the whole exposure model turns on the difference.
pub const ANONYMOUS: &str = "@anonymous";

/// Who is asking, and of which repository.
#[derive(Debug, Clone, Copy)]
pub struct Subject<'a> {
    /// The principal's name — the basename of its key file in `keydir/`.
    pub name: &'a str,
    /// The recorded creator of the repository being accessed, if it has one.
    /// `CREATOR` in a rule's user list matches only when this equals `name`.
    pub creator: Option<&'a str>,
    /// Whether this is the unauthenticated reader rather than a principal.
    anonymous: bool,
}

impl<'a> Subject<'a> {
    pub fn new(name: &'a str) -> Self {
        Self {
            name,
            creator: None,
            anonymous: false,
        }
    }

    pub fn with_creator(name: &'a str, creator: Option<&'a str>) -> Self {
        Self {
            name,
            creator,
            anonymous: false,
        }
    }

    /// A request with no identity. Matched by `@anonymous` and by nothing
    /// else — not by `@all`, not by a group, not by a bare name.
    pub fn anonymous() -> Self {
        Self {
            name: ANONYMOUS,
            creator: None,
            anonymous: true,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ConfError {
    #[error("line {line}: {message}")]
    Syntax { line: usize, message: String },
}

impl ConfError {
    fn syntax(line: usize, message: impl Into<String>) -> Self {
        ConfError::Syntax {
            line,
            message: message.into(),
        }
    }
}

#[derive(Debug)]
struct Rule {
    perm: Perm,
    refex: Regex,
    users: Vec<String>,
}

#[derive(Debug)]
struct Block {
    patterns: Vec<RepoPattern>,
    rules: Vec<Rule>,
    /// `option listed = yes|no`, and the line it was written on. `None` means
    /// the block says nothing about listing.
    listed: Option<(bool, usize)>,
}

impl Block {
    fn new(patterns: Vec<RepoPattern>) -> Self {
        Self {
            patterns,
            rules: Vec::new(),
            listed: None,
        }
    }
}

/// Which question the rules are being asked.
#[derive(Debug, Clone, Copy)]
enum Scope<'a> {
    /// "May this principal do X to *some* ref here?" Refexes are not
    /// consulted and `-` is skipped rather than denying: a deny on one refex
    /// must not make the whole repository unreachable.
    Repository,
    /// "…to this ref?" First-match-wins, and `-` denies.
    Ref(&'a str),
    /// "…to the repository, wholesale?" Refexes are not consulted and `-`
    /// *does* deny. This is the anonymous surface, which is all-or-nothing —
    /// an anonymous clone hands over every ref there is — so a deny anywhere
    /// has to close the door rather than narrow it.
    Whole,
}

#[derive(Debug)]
enum RepoPattern {
    /// A plain name, matched byte-exactly.
    Exact(String),
    /// A pattern with regex metacharacters, anchored at both ends.
    Wild(Regex),
    /// `@all`, or a named group whose members are themselves patterns.
    Group(String),
}

/// A parsed `conf/access.conf`.
#[derive(Debug, Default)]
pub struct AccessConf {
    /// Group name (without `@`) to its members, fully expanded.
    groups: HashMap<String, Vec<String>>,
    blocks: Vec<Block>,
}

/// Characters allowed in a plain (non-wild) repository name or a principal
/// name. Anything else in a repo pattern makes it a regex.
fn is_plain_name(s: &str) -> bool {
    !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | '@' | '+'))
}

/// A repo pattern is "wild" if it contains a regex metacharacter.
fn is_wild_pattern(s: &str) -> bool {
    s.chars().any(|c| {
        matches!(
            c,
            '[' | ']' | '*' | '?' | '(' | ')' | '|' | '\\' | '$' | '^'
        )
    })
}

/// Compile a refex the way gitolite does.
///
/// An omitted refex is `refs/.*`. A refex that does not already name a ref
/// namespace is implicitly under `refs/heads/`. The result anchors at the
/// start but **not** at the end, so `main` also matches `refs/heads/maint`;
/// that is gitolite's documented behaviour and the design adopts it verbatim.
fn compile_refex(refex: Option<&str>, line: usize) -> Result<Regex, ConfError> {
    let raw = refex.unwrap_or("refs/.*");
    let expanded = if raw.starts_with("refs/") || raw.starts_with("VREF/") {
        raw.to_string()
    } else {
        format!("refs/heads/{raw}")
    };
    Regex::new(&format!("^(?:{expanded})"))
        .map_err(|e| ConfError::syntax(line, format!("invalid refex {raw:?}: {e}")))
}

impl AccessConf {
    /// Whether `name` would parse as a `repo` line naming exactly itself.
    ///
    /// A generator writing a block per existing repository needs this: a name
    /// carrying a regex metacharacter compiles to a *wild* pattern, so the
    /// block would silently govern repositories other than the one it was
    /// written for. Better to leave such a repository out and say so.
    pub fn is_literal_repo_name(name: &str) -> bool {
        !name.starts_with('@') && !is_wild_pattern(name) && is_plain_name(name)
    }

    /// Parse an access.conf. Every failure carries a line number, because this
    /// message is what a rejected push shows the person who wrote the file.
    pub fn parse(source: &str) -> Result<AccessConf, ConfError> {
        let mut conf = AccessConf::default();
        // Raw group definitions, in order, so members may name earlier groups.
        let mut current: Option<Block> = None;

        for (index, raw_line) in source.lines().enumerate() {
            let line = index + 1;
            let text = strip_comment(raw_line).trim();
            if text.is_empty() {
                continue;
            }

            if let Some(rest) = text.strip_prefix("repo ") {
                if let Some(block) = current.take() {
                    conf.blocks.push(block);
                }
                let patterns = rest
                    .split_whitespace()
                    .map(|p| conf.compile_repo_pattern(p, line))
                    .collect::<Result<Vec<_>, _>>()?;
                if patterns.is_empty() {
                    return Err(ConfError::syntax(line, "`repo` needs at least one name"));
                }
                current = Some(Block::new(patterns));
                continue;
            }
            if text == "repo" {
                return Err(ConfError::syntax(line, "`repo` needs at least one name"));
            }

            let Some((lhs, rhs)) = text.split_once('=') else {
                return Err(ConfError::syntax(
                    line,
                    format!("expected a `repo` line or a rule, got {text:?}"),
                ));
            };
            let lhs = lhs.trim();
            let rhs = rhs.trim();

            if let Some(group) = lhs.strip_prefix('@') {
                if current.is_some() {
                    return Err(ConfError::syntax(
                        line,
                        "group definitions must come before any `repo` block",
                    ));
                }
                if group.is_empty() || !is_plain_name(group) {
                    return Err(ConfError::syntax(line, format!("bad group name @{group}")));
                }
                if group == "all" {
                    return Err(ConfError::syntax(
                        line,
                        "@all is built in and cannot be defined",
                    ));
                }
                if group == anonymous_group() {
                    return Err(ConfError::syntax(
                        line,
                        format!("{ANONYMOUS} is built in and cannot be defined"),
                    ));
                }
                let members = conf.expand_members(rhs, line)?;
                conf.groups
                    .entry(group.to_string())
                    .or_default()
                    .extend(members);
                continue;
            }

            if lhs == "option" || lhs.starts_with("option ") {
                let block = current.as_mut().ok_or_else(|| {
                    ConfError::syntax(line, "an option must appear inside a `repo` block")
                })?;
                let name = lhs["option".len()..].trim();
                if name != LISTED_OPTION {
                    return Err(ConfError::syntax(
                        line,
                        format!("unknown option {name:?}; the only option is `{LISTED_OPTION}`"),
                    ));
                }
                let value = match rhs {
                    "yes" => true,
                    "no" => false,
                    other => {
                        return Err(ConfError::syntax(
                            line,
                            format!("`option {LISTED_OPTION}` takes yes or no, got {other:?}"),
                        ))
                    }
                };
                block.listed = Some((value, line));
                continue;
            }

            let block = current.as_mut().ok_or_else(|| {
                ConfError::syntax(line, "a rule must appear inside a `repo` block")
            })?;

            let mut lhs_tokens = lhs.split_whitespace();
            let perm_token = lhs_tokens
                .next()
                .ok_or_else(|| ConfError::syntax(line, "rule is missing a permission"))?;
            let perm = Perm::parse(perm_token).ok_or_else(|| {
                ConfError::syntax(
                    line,
                    format!("unknown permission {perm_token:?}; expected one of -, R, RW, RW+, C"),
                )
            })?;
            let refex = lhs_tokens.next();
            if let Some(extra) = lhs_tokens.next() {
                return Err(ConfError::syntax(
                    line,
                    format!("unexpected {extra:?} after the refex; a rule takes at most one"),
                ));
            }
            let refex = compile_refex(refex, line)?;

            let users: Vec<String> = rhs.split_whitespace().map(|u| u.to_string()).collect();
            if users.is_empty() {
                return Err(ConfError::syntax(line, "rule names no principals"));
            }
            for user in &users {
                let bare = user.strip_prefix('@').unwrap_or(user);
                if !is_plain_name(bare) {
                    return Err(ConfError::syntax(line, format!("bad principal {user:?}")));
                }
                // A request with no identity cannot be held responsible for a
                // write, so a permission that grants one to it is a mistake
                // rather than a permissive choice. Rejected here rather than
                // ignored at evaluation time: a rule that silently means
                // nothing is the worst thing a config file can contain.
                if user == ANONYMOUS && !matches!(perm, Perm::Deny | Perm::R) {
                    return Err(ConfError::syntax(
                        line,
                        format!(
                            "{ANONYMOUS} may only be granted R (or denied with -); \
                             {perm_token} would grant write access to an unauthenticated request"
                        ),
                    ));
                }
            }

            block.rules.push(Rule { perm, refex, users });
        }

        if let Some(block) = current.take() {
            conf.blocks.push(block);
        }
        conf.check_listed_repos_are_readable()?;
        Ok(conf)
    }

    /// `option listed = yes` requires `R = @anonymous` to be in force for the
    /// repository, and is a config error without it.
    ///
    /// Web UI authentication is out of scope, so an HTTP request has no
    /// identity: there is no authenticated viewer for a "listed but private"
    /// repository to be listed *to*, and the pair would advertise a name that
    /// 404s. The check is by evaluation rather than by looking inside the
    /// block, so the grant may come from anywhere in the file.
    ///
    /// A wild pattern names no repository this can enumerate, so a block like
    /// `repo agents/[a-z]+` is left to the evaluator, where `is_listed` is
    /// gated on the same read grant and so fails closed.
    fn check_listed_repos_are_readable(&self) -> Result<(), ConfError> {
        for block in &self.blocks {
            let Some((true, line)) = block.listed else {
                continue;
            };
            for repo in block.patterns.iter().flat_map(|p| self.enumerate(p)) {
                if !self.anonymous_may_read(&repo) {
                    return Err(ConfError::syntax(
                        line,
                        format!(
                            "`option {LISTED_OPTION} = yes` on {repo} without `R = {ANONYMOUS}`: \
                             an HTTP request has no identity, so listing a repository nobody \
                             may read would advertise a name that 404s"
                        ),
                    ));
                }
            }
        }
        Ok(())
    }

    /// The concrete repository names a pattern names, where there are any. A
    /// regex names an open set and yields none.
    fn enumerate(&self, pattern: &RepoPattern) -> Vec<String> {
        match pattern {
            RepoPattern::Exact(name) => vec![name.clone()],
            RepoPattern::Wild(_) => Vec::new(),
            RepoPattern::Group(name) if name == "all" => Vec::new(),
            RepoPattern::Group(name) => self
                .groups
                .get(name)
                .map(|members| {
                    members
                        .iter()
                        .filter(|m| !is_wild_pattern(m))
                        .cloned()
                        .collect()
                })
                .unwrap_or_default(),
        }
    }

    /// Expand a group's member list, resolving `@`-references to groups
    /// already defined. An unknown group reference is an error rather than an
    /// empty expansion: a typo'd group name must not silently grant nothing
    /// (or, on the repo side, match nothing).
    fn expand_members(&self, rhs: &str, line: usize) -> Result<Vec<String>, ConfError> {
        let mut out = Vec::new();
        for token in rhs.split_whitespace() {
            if let Some(name) = token.strip_prefix('@') {
                let members = self
                    .groups
                    .get(name)
                    .ok_or_else(|| ConfError::syntax(line, format!("unknown group @{name}")))?;
                out.extend(members.iter().cloned());
            } else {
                out.push(token.to_string());
            }
        }
        if out.is_empty() {
            return Err(ConfError::syntax(line, "group definition has no members"));
        }
        Ok(out)
    }

    fn compile_repo_pattern(&self, pattern: &str, line: usize) -> Result<RepoPattern, ConfError> {
        if let Some(group) = pattern.strip_prefix('@') {
            if group != "all" && !self.groups.contains_key(group) {
                return Err(ConfError::syntax(line, format!("unknown group @{group}")));
            }
            return Ok(RepoPattern::Group(group.to_string()));
        }
        if is_wild_pattern(pattern) {
            let regex = Regex::new(&format!("^(?:{pattern})$")).map_err(|e| {
                ConfError::syntax(line, format!("invalid repo pattern {pattern:?}: {e}"))
            })?;
            return Ok(RepoPattern::Wild(regex));
        }
        if !is_plain_name(pattern) {
            return Err(ConfError::syntax(
                line,
                format!("bad repo name {pattern:?}"),
            ));
        }
        Ok(RepoPattern::Exact(pattern.to_string()))
    }

    fn pattern_matches(&self, pattern: &RepoPattern, repo: &str) -> bool {
        match pattern {
            RepoPattern::Exact(name) => name == repo,
            RepoPattern::Wild(regex) => regex.is_match(repo),
            RepoPattern::Group(name) if name == "all" => true,
            RepoPattern::Group(name) => self
                .groups
                .get(name)
                .is_some_and(|members| members.iter().any(|m| m == repo)),
        }
    }

    fn user_matches(&self, token: &str, subject: &Subject<'_>) -> bool {
        // The unauthenticated reader is matched by its own token and by
        // nothing else. In particular `@all` does not reach it: that is every
        // *enrolled* key, and an anonymous request holds none of them.
        if subject.anonymous {
            return token == ANONYMOUS;
        }
        if token == ANONYMOUS {
            return false;
        }
        if token == "@all" {
            return true;
        }
        if token == "CREATOR" {
            return subject.creator.is_some_and(|c| c == subject.name);
        }
        if let Some(group) = token.strip_prefix('@') {
            return self
                .groups
                .get(group)
                .is_some_and(|members| members.iter().any(|m| m == subject.name));
        }
        token == subject.name
    }

    /// The one evaluation path; `scope` says which question is being asked.
    fn evaluate(
        &self,
        repo: &str,
        subject: &Subject<'_>,
        scope: Scope<'_>,
        access: Access,
    ) -> bool {
        for block in &self.blocks {
            if !block
                .patterns
                .iter()
                .any(|pattern| self.pattern_matches(pattern, repo))
            {
                continue;
            }
            for rule in &block.rules {
                if !rule
                    .users
                    .iter()
                    .any(|token| self.user_matches(token, subject))
                {
                    continue;
                }
                match scope {
                    Scope::Repository => {
                        if rule.perm == Perm::Deny {
                            continue;
                        }
                    }
                    Scope::Whole => {
                        if rule.perm == Perm::Deny {
                            return false;
                        }
                    }
                    Scope::Ref(name) => {
                        if !rule.refex.is_match(name) {
                            continue;
                        }
                        if rule.perm == Perm::Deny {
                            return false;
                        }
                    }
                }
                if rule.perm.grants(access) {
                    return true;
                }
            }
        }
        false
    }

    #[cfg(test)]
    fn evaluate_order_blind(
        &self,
        repo: &str,
        subject: &Subject<'_>,
        refname: Option<&str>,
        access: Access,
    ) -> bool {
        let mut allowed = false;
        let mut denied = false;
        for block in &self.blocks {
            if !block
                .patterns
                .iter()
                .any(|pattern| self.pattern_matches(pattern, repo))
            {
                continue;
            }
            for rule in &block.rules {
                if !rule
                    .users
                    .iter()
                    .any(|token| self.user_matches(token, subject))
                {
                    continue;
                }
                if let Some(name) = refname {
                    if !rule.refex.is_match(name) {
                        continue;
                    }
                } else if rule.perm == Perm::Deny {
                    continue;
                }
                if rule.perm == Perm::Deny {
                    denied = true;
                } else if rule.perm.grants(access) {
                    allowed = true;
                }
            }
        }
        allowed && !denied
    }

    /// May this principal do `access` to *some* ref of this repository? Used
    /// at dispatch, where the verb is known but the refs are not yet.
    pub fn allows_repo(&self, repo: &str, subject: &Subject<'_>, access: Access) -> bool {
        self.evaluate(repo, subject, Scope::Repository, access)
    }

    /// May this principal do `access` to this specific ref? First-match-wins.
    pub fn allows_ref(
        &self,
        repo: &str,
        subject: &Subject<'_>,
        refname: &str,
        access: Access,
    ) -> bool {
        self.evaluate(repo, subject, Scope::Ref(refname), access)
    }

    /// May a request carrying no identity read this repository at all?
    ///
    /// This is the whole anonymous surface — web pages, smart-HTTP clone, and
    /// release downloads alike — because none of them can carry an identity
    /// and none of them can serve half a repository.
    pub fn anonymous_may_read(&self, repo: &str) -> bool {
        self.evaluate(repo, &Subject::anonymous(), Scope::Whole, Access::Read)
    }

    /// Is this repository advertised in the repository list?
    ///
    /// Listing is a display concern rather than an access one, so it is an
    /// `option` rather than a rule — but it can never exceed the access, so
    /// it is gated on the anonymous read grant here as well as at parse time.
    /// Later blocks override earlier ones, which is how `option listed = no`
    /// takes one repository back out of a list a broader block put it in.
    pub fn is_listed(&self, repo: &str) -> bool {
        if !self.anonymous_may_read(repo) {
            return false;
        }
        self.blocks
            .iter()
            .filter(|block| {
                block
                    .patterns
                    .iter()
                    .any(|pattern| self.pattern_matches(pattern, repo))
            })
            .filter_map(|block| block.listed.map(|(value, _)| value))
            .next_back()
            .unwrap_or(false)
    }
}

/// `@anonymous` without its sigil, for the places that compare bare names.
fn anonymous_group() -> &'static str {
    ANONYMOUS.trim_start_matches('@')
}

/// The only `option` this dialect understands.
const LISTED_OPTION: &str = "listed";

fn strip_comment(line: &str) -> &str {
    match line.find('#') {
        Some(index) => &line[..index],
        None => line,
    }
}

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

    fn conf(source: &str) -> AccessConf {
        AccessConf::parse(source).expect("config should parse")
    }

    const SPEC_EXAMPLE: &str = "\
@admins   = alex
@agents   = claude-a claude-b

repo settings
    RW+                   =   @admins

repo tools
    RW+                   =   @admins
    RW      refs/collab/  =   @agents
    R                     =   @all

repo agents/[a-z].*
    C                     =   @agents
    RW+                   =   CREATOR
";

    #[test]
    fn spec_example_parses() {
        let conf = conf(SPEC_EXAMPLE);
        assert!(conf.allows_repo("settings", &Subject::new("alex"), Access::Rewind));
    }

    // ---- The point of the whole design: a contributor's grant is one prefix
    // and refs/heads never appears in it. ----

    #[test]
    fn agent_writes_collab_refs_but_not_branches() {
        let conf = conf(SPEC_EXAMPLE);
        let agent = Subject::new("claude-a");

        assert!(conf.allows_ref("tools", &agent, "refs/collab/patches/abc", Access::Write));
        assert!(conf.allows_ref("tools", &agent, "refs/collab/issues/abc", Access::Write));
        assert!(conf.allows_ref("tools", &agent, "refs/collab/archive/abc", Access::Write));

        assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write));
        assert!(!conf.allows_ref("tools", &agent, "refs/tags/v1", Access::Write));
        // ...but it can still read the branch it is contributing against.
        assert!(conf.allows_ref("tools", &agent, "refs/heads/main", Access::Read));
    }

    #[test]
    fn agent_cannot_rewind_the_refs_it_may_write() {
        let conf = conf(SPEC_EXAMPLE);
        let agent = Subject::new("claude-a");
        // RW, not RW+: fast-forward yes, force-push/delete no.
        assert!(conf.allows_ref("tools", &agent, "refs/collab/patches/x", Access::Write));
        assert!(!conf.allows_ref("tools", &agent, "refs/collab/patches/x", Access::Rewind));
    }

    #[test]
    fn admin_writes_everything() {
        let conf = conf(SPEC_EXAMPLE);
        let admin = Subject::new("alex");
        assert!(conf.allows_ref("tools", &admin, "refs/heads/main", Access::Rewind));
        assert!(conf.allows_ref("settings", &admin, "refs/heads/main", Access::Rewind));
    }

    // ---- Ordering. An ordering bug here is a silent authorization bug, so
    // both directions are asserted against the same two rules. ----

    #[test]
    fn deny_before_a_broader_allow_denies() {
        let conf = conf(
            "\
@agents = claude-a

repo tools
    -       refs/heads/main   =   @agents
    RW+                       =   @agents
",
        );
        let agent = Subject::new("claude-a");
        assert!(
            !conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write),
            "the deny is first, so it must win"
        );
        // The broader allow still governs every other ref.
        assert!(conf.allows_ref("tools", &agent, "refs/heads/topic", Access::Write));
    }

    #[test]
    fn the_same_two_rules_reversed_allow() {
        let conf = conf(
            "\
@agents = claude-a

repo tools
    RW+                       =   @agents
    -       refs/heads/main   =   @agents
",
        );
        let agent = Subject::new("claude-a");
        assert!(
            conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write),
            "the allow is first, so the later deny is never reached"
        );
    }

    /// The two fixtures above hold the *same rules in the other order*, so a
    /// passing pair only proves anything if order is what separates them.
    /// This asserts exactly that: an evaluator that gathered the same matching
    /// rules but ignored their order — the plausible way to write this wrong —
    /// would answer both cases identically and get one of them wrong.
    ///
    /// Without this, `deny_before_a_broader_allow_denies` and
    /// `the_same_two_rules_reversed_allow` could both keep passing under an
    /// order-blind implementation that simply denied whenever any deny rule
    /// matched, and the silent authorization bug would go unnoticed.
    #[test]
    fn the_ordering_fixtures_actually_discriminate_on_order() {
        let deny_first = conf(
            "\
@agents = claude-a

repo tools
    -       refs/heads/main   =   @agents
    RW+                       =   @agents
",
        );
        let allow_first = conf(
            "\
@agents = claude-a

repo tools
    RW+                       =   @agents
    -       refs/heads/main   =   @agents
",
        );
        let agent = Subject::new("claude-a");
        let ref_name = Some("refs/heads/main");

        // What the real evaluator says: order decides, so the answers differ.
        assert!(!deny_first.evaluate(
            "tools",
            &agent,
            Scope::Ref("refs/heads/main"),
            Access::Write
        ));
        assert!(allow_first.evaluate(
            "tools",
            &agent,
            Scope::Ref("refs/heads/main"),
            Access::Write
        ));

        // What an order-blind evaluator says: the same answer to both, and it
        // is the wrong answer for the second.
        assert_eq!(
            deny_first.evaluate_order_blind("tools", &agent, ref_name, Access::Write),
            allow_first.evaluate_order_blind("tools", &agent, ref_name, Access::Write),
            "the order-blind evaluator is supposed to be unable to tell these \
             apart; if it can, this test no longer proves the fixtures depend \
             on order"
        );
        assert!(
            !allow_first.evaluate_order_blind("tools", &agent, ref_name, Access::Write),
            "and it is supposed to get the allow-first case wrong"
        );
    }

    #[test]
    fn ordering_holds_across_repo_blocks_not_only_within_one() {
        // Two blocks both matching `tools`; the deny is in the earlier block.
        let denies = conf(
            "\
repo @all
    -       refs/heads/main   =   claude-a

repo tools
    RW+                       =   claude-a
",
        );
        let allows = conf(
            "\
repo tools
    RW+                       =   claude-a

repo @all
    -       refs/heads/main   =   claude-a
",
        );
        let agent = Subject::new("claude-a");
        assert!(!denies.allows_ref("tools", &agent, "refs/heads/main", Access::Write));
        assert!(allows.allows_ref("tools", &agent, "refs/heads/main", Access::Write));
    }

    #[test]
    fn a_rule_that_does_not_grant_enough_falls_through_rather_than_denying() {
        // The `R` rule matches the ref and the user but does not grant write;
        // evaluation must continue to the RW rule rather than stopping.
        let conf = conf(
            "\
repo tools
    R                    =   claude-a
    RW  refs/collab/     =   claude-a
",
        );
        let agent = Subject::new("claude-a");
        assert!(conf.allows_ref("tools", &agent, "refs/collab/x", Access::Write));
    }

    #[test]
    fn deny_short_circuits_even_when_it_grants_nothing_relevant() {
        let conf = conf(
            "\
repo tools
    -                    =   claude-a
    RW+                  =   claude-a
",
        );
        let agent = Subject::new("claude-a");
        assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write));
        assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Read));
    }

    #[test]
    fn a_deny_on_one_refex_does_not_close_the_repository() {
        // Repo-level questions skip `-` rules: the principal can still open a
        // connection and push *something*, and the per-ref check does the rest.
        let conf = conf(
            "\
repo tools
    -       refs/heads/main   =   claude-a
    RW      refs/collab/      =   claude-a
",
        );
        let agent = Subject::new("claude-a");
        assert!(conf.allows_repo("tools", &agent, Access::Write));
        assert!(!conf.allows_ref("tools", &agent, "refs/heads/main", Access::Write));
    }

    // ---- Refex semantics ----

    #[test]
    fn omitted_refex_is_every_ref() {
        let conf = conf("repo tools\n    RW+ = alex\n");
        let alex = Subject::new("alex");
        assert!(conf.allows_ref("tools", &alex, "refs/heads/main", Access::Write));
        assert!(conf.allows_ref("tools", &alex, "refs/tags/v1", Access::Write));
        assert!(conf.allows_ref("tools", &alex, "refs/collab/issues/x", Access::Write));
    }

    #[test]
    fn a_bare_refex_is_implicitly_under_refs_heads() {
        let conf = conf("repo tools\n    RW main = alex\n");
        let alex = Subject::new("alex");
        assert!(conf.allows_ref("tools", &alex, "refs/heads/main", Access::Write));
        assert!(!conf.allows_ref("tools", &alex, "refs/tags/main", Access::Write));
    }

    #[test]
    fn refexes_anchor_at_the_start_but_not_the_end() {
        // gitolite's documented behaviour, adopted verbatim: `main` also
        // matches `maint`. Asserted so it stays a decision, not an accident.
        let conf = conf("repo tools\n    RW main = alex\n");
        let alex = Subject::new("alex");
        assert!(conf.allows_ref("tools", &alex, "refs/heads/maint", Access::Write));
        assert!(!conf.allows_ref("tools", &alex, "refs/heads/topic/main", Access::Write));
    }

    #[test]
    fn a_refex_may_be_a_regex() {
        let conf = conf("repo tools\n    RW refs/heads/(feature|topic)/ = alex\n");
        let alex = Subject::new("alex");
        assert!(conf.allows_ref("tools", &alex, "refs/heads/feature/x", Access::Write));
        assert!(conf.allows_ref("tools", &alex, "refs/heads/topic/x", Access::Write));
        assert!(!conf.allows_ref("tools", &alex, "refs/heads/main", Access::Write));
    }

    // ---- Groups ----

    #[test]
    fn at_all_matches_any_named_principal() {
        let conf = conf("repo tools\n    R = @all\n");
        assert!(conf.allows_ref(
            "tools",
            &Subject::new("nobody-in-particular"),
            "refs/heads/main",
            Access::Read
        ));
    }

    #[test]
    fn groups_may_reference_earlier_groups() {
        let conf = conf(
            "\
@core   = alex
@agents = claude-a
@staff  = @core @agents

repo tools
    RW+ = @staff
",
        );
        assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Rewind));
        assert!(conf.allows_repo("tools", &Subject::new("claude-a"), Access::Rewind));
        assert!(!conf.allows_repo("tools", &Subject::new("mallory"), Access::Rewind));
    }

    #[test]
    fn a_group_may_be_extended_by_a_second_line() {
        let conf = conf("@agents = claude-a\n@agents = claude-b\nrepo tools\n    RW = @agents\n");
        assert!(conf.allows_repo("tools", &Subject::new("claude-a"), Access::Write));
        assert!(conf.allows_repo("tools", &Subject::new("claude-b"), Access::Write));
    }

    #[test]
    fn an_unknown_group_reference_is_an_error_not_an_empty_set() {
        assert!(AccessConf::parse("@staff = @nope\nrepo t\n R = @staff\n").is_err());
        assert!(AccessConf::parse("repo @nope\n    R = alex\n").is_err());
    }

    // ---- Repository patterns and wild repos ----

    #[test]
    fn plain_repo_names_match_byte_exactly_including_nesting() {
        let conf = conf(
            "\
repo tools
    RW+ = alex
repo private/tools
    RW+ = root
",
        );
        assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Write));
        assert!(!conf.allows_repo("private/tools", &Subject::new("alex"), Access::Write));
        assert!(conf.allows_repo("private/tools", &Subject::new("root"), Access::Write));
        assert!(!conf.allows_repo("tools", &Subject::new("root"), Access::Write));
    }

    #[test]
    fn wild_repo_patterns_anchor_at_both_ends() {
        let conf = conf("repo agents/[a-z].*\n    C = claude-a\n");
        let agent = Subject::new("claude-a");
        assert!(conf.allows_repo("agents/claude-a", &agent, Access::Create));
        assert!(!conf.allows_repo("teams/agents/claude-a", &agent, Access::Create));
        assert!(!conf.allows_repo("agents/Claude", &agent, Access::Create));
    }

    #[test]
    fn creator_owns_what_it_created_and_nothing_else() {
        let conf = conf(SPEC_EXAMPLE);
        let repo = "agents/claude-a";

        let creator = Subject::with_creator("claude-a", Some("claude-a"));
        assert!(conf.allows_repo(repo, &creator, Access::Create));
        assert!(conf.allows_ref(repo, &creator, "refs/heads/main", Access::Rewind));

        // A second agent may create its own, but cannot write this one.
        let other = Subject::with_creator("claude-b", Some("claude-a"));
        assert!(conf.allows_repo(repo, &other, Access::Create));
        assert!(!conf.allows_ref(repo, &other, "refs/heads/main", Access::Write));
    }

    #[test]
    fn creator_matches_nothing_when_the_repo_has_no_recorded_creator() {
        let conf = conf(SPEC_EXAMPLE);
        let subject = Subject::with_creator("claude-a", None);
        assert!(!conf.allows_ref(
            "agents/claude-a",
            &subject,
            "refs/heads/main",
            Access::Write
        ));
    }

    #[test]
    fn create_is_not_implied_by_rw_plus() {
        let conf = conf("repo agents/[a-z].*\n    RW+ = alex\n");
        assert!(!conf.allows_repo("agents/new", &Subject::new("alex"), Access::Create));
    }

    // ---- Closed by default ----

    #[test]
    fn an_empty_config_denies_everything() {
        let conf = conf("");
        let anyone = Subject::new("alex");
        assert!(!conf.allows_repo("tools", &anyone, Access::Read));
        assert!(!conf.allows_ref("tools", &anyone, "refs/heads/main", Access::Read));
    }

    #[test]
    fn a_repository_no_block_names_is_unreachable() {
        let conf = conf(SPEC_EXAMPLE);
        assert!(!conf.allows_repo("secrets", &Subject::new("alex"), Access::Read));
    }

    // ---- Parse errors ----

    #[test]
    fn comments_and_blank_lines_are_ignored() {
        let conf =
            conf("# a comment\n\n  # indented\nrepo tools  # trailing\n    RW+ = alex # here\n");
        assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Write));
    }

    #[test]
    fn unknown_permissions_are_rejected() {
        let err = AccessConf::parse("repo t\n    RWD = alex\n").unwrap_err();
        assert!(err.to_string().contains("line 2"), "got {err}");
        assert!(err.to_string().contains("RWD"), "got {err}");
    }

    #[test]
    fn a_rule_outside_a_repo_block_is_rejected() {
        assert!(AccessConf::parse("RW+ = alex\n").is_err());
    }

    #[test]
    fn a_rule_with_two_refexes_is_rejected() {
        assert!(AccessConf::parse("repo t\n    RW a b = alex\n").is_err());
    }

    #[test]
    fn a_rule_with_no_principals_is_rejected() {
        assert!(AccessConf::parse("repo t\n    RW =\n").is_err());
    }

    #[test]
    fn a_bare_word_that_is_neither_repo_nor_rule_is_rejected() {
        assert!(AccessConf::parse("hello world\n").is_err());
    }

    #[test]
    fn at_all_cannot_be_redefined() {
        assert!(AccessConf::parse("@all = alex\n").is_err());
    }

    // ---- @anonymous: the unauthenticated reader -----------------------
    //
    // The whole exposure model rests on this token meaning something no other
    // token does, so each of its properties is asserted separately.

    #[test]
    fn at_all_does_not_include_the_anonymous_reader() {
        // The distinction the model is built on: `@all` is every *enrolled*
        // key, which is a strictly smaller set than "anybody at all".
        let conf = conf("repo tools\n    R = @all\n");
        assert!(conf.allows_repo("tools", &Subject::new("alex"), Access::Read));
        assert!(!conf.anonymous_may_read("tools"));
    }

    #[test]
    fn an_anonymous_read_grant_opens_the_repository_to_no_one_else() {
        let conf = conf("repo open\n    R = @anonymous\n");
        assert!(conf.anonymous_may_read("open"));
        // `@anonymous` is not a name a key can hold, so it grants an enrolled
        // principal nothing.
        assert!(!conf.allows_repo("open", &Subject::new("alex"), Access::Read));
    }

    #[test]
    fn a_repository_with_no_anonymous_rule_is_closed() {
        let conf = conf(SPEC_EXAMPLE);
        assert!(!conf.anonymous_may_read("tools"));
        assert!(!conf.anonymous_may_read("settings"));
        assert!(!conf.anonymous_may_read("nothing-names-this"));
    }

    /// `-` denies it back, by the same first-match-wins evaluation as any
    /// other principal.
    #[test]
    fn an_earlier_deny_closes_the_anonymous_door_a_later_rule_opens() {
        let denied = conf("repo t\n    - = @anonymous\n    R = @anonymous\n");
        assert!(!denied.anonymous_may_read("t"));

        let allowed = conf("repo t\n    R = @anonymous\n    - = @anonymous\n");
        assert!(
            allowed.anonymous_may_read("t"),
            "the allow is first, so the later deny is never reached"
        );
    }

    /// Unlike the per-ref question, an anonymous deny on *any* refex closes
    /// the repository: an anonymous clone is all-or-nothing, so there is no
    /// way to serve a partial view and pretending otherwise would leak.
    #[test]
    fn a_deny_on_one_refex_closes_the_anonymous_surface_entirely() {
        let conf = conf("repo t\n    - refs/heads/secret = @anonymous\n    R = @anonymous\n");
        assert!(!conf.anonymous_may_read("t"));
    }

    #[test]
    fn a_write_grant_to_the_anonymous_reader_is_a_config_error() {
        for source in [
            "repo t\n    RW = @anonymous\n",
            "repo t\n    RW+ = @anonymous\n",
            "repo t\n    C = @anonymous\n",
            "repo t\n    RW refs/collab/ = alex @anonymous\n",
        ] {
            let err = AccessConf::parse(source)
                .expect_err("a write grant to @anonymous must be rejected, not ignored");
            assert!(err.to_string().contains("line 2"), "got {err}");
            assert!(err.to_string().contains("@anonymous"), "got {err}");
        }
    }

    #[test]
    fn the_anonymous_reader_is_not_a_group_an_operator_may_define() {
        assert!(AccessConf::parse("@anonymous = alex\n").is_err());
        assert!(AccessConf::parse("repo @anonymous\n    R = alex\n").is_err());
    }

    // ---- option listed ------------------------------------------------

    #[test]
    fn a_repository_is_unlisted_until_an_option_says_otherwise() {
        let conf = conf("repo t\n    R = @anonymous\n");
        assert!(conf.anonymous_may_read("t"));
        assert!(
            !conf.is_listed("t"),
            "reachable by name is not the same as advertised"
        );
    }

    #[test]
    fn option_listed_advertises_a_repository_that_anonymous_may_read() {
        let conf = conf("repo t\n    R = @anonymous\n    option listed = yes\n");
        assert!(conf.is_listed("t"));
    }

    #[test]
    fn option_listed_without_an_anonymous_read_grant_is_a_config_error() {
        // Web UI auth is out of scope, so HTTP has no identity: listing a repo
        // nobody may read advertises a name that 404s.
        let err = AccessConf::parse("repo t\n    R = @all\n    option listed = yes\n")
            .expect_err("listed without an anonymous read grant must be rejected");
        assert!(err.to_string().contains("line 3"), "got {err}");
        assert!(err.to_string().contains("@anonymous"), "got {err}");
    }

    /// The grant does not have to be in the same block — it has to be in
    /// force, which is a question about the whole file.
    #[test]
    fn the_anonymous_grant_may_come_from_another_block() {
        let conf = conf("repo @all\n    R = @anonymous\n\nrepo t\n    option listed = yes\n");
        assert!(conf.is_listed("t"));
    }

    #[test]
    fn option_listed_no_takes_a_repository_back_out_of_the_list() {
        let conf = conf(
            "repo @all\n    R = @anonymous\n    option listed = yes\n\n\
             repo secret\n    option listed = no\n",
        );
        assert!(conf.is_listed("public"));
        assert!(!conf.is_listed("secret"));
        assert!(
            conf.anonymous_may_read("secret"),
            "unlisted is not unreadable"
        );
    }

    #[test]
    fn an_unknown_option_is_rejected_rather_than_ignored() {
        let err = AccessConf::parse("repo t\n    option gitweb.owner = alex\n").unwrap_err();
        assert!(err.to_string().contains("line 2"), "got {err}");
    }

    #[test]
    fn an_option_outside_a_repo_block_is_rejected() {
        assert!(AccessConf::parse("option listed = yes\n").is_err());
    }

    #[test]
    fn option_listed_takes_yes_or_no_and_nothing_else() {
        assert!(
            AccessConf::parse("repo t\n    R = @anonymous\n    option listed = maybe\n").is_err()
        );
    }

    #[test]
    fn group_definitions_must_precede_repo_blocks() {
        assert!(AccessConf::parse("repo t\n    RW = @late\n@late = alex\n").is_err());
    }
}