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