gunnar-store 1.6.0

The gunnar server's storage seam: RepoStore (which repository), SecretStore (what has no git equivalent), and the one conformance suite both GitOps backends run
Documentation
//! Reference **names**: the rules, with no store attached.
//!
//! # Why this survived `RefStore` and the rest of `refs.rs` did not
//!
//! `refs.rs` owned two different things wearing one name. One was a *storage
//! contract* — `RefStore`, `Target`, `PreviousValue`, `Change`, `RefEdit` — a
//! second vocabulary for what [`git_storage_trait::GitOps`] now says with
//! `refs()` / `update_ref()` / `put_refs()` and `RefRow` / `RefUpdate`. Two
//! vocabularies for one concept is the disease the storage-abstraction census names,
//! so that half is deleted.
//!
//! The other was a set of **rules about names**, and every one of them was
//! established by measuring stock git rather than by reading a spec. They are
//! not a property of any backend, they are not on the contract, and they are
//! load-bearing:
//!
//! * [`validate`] is a **security boundary**. `refs/heads/../../etc/passwd` is
//!   how a path sandbox gets walked out of, and a git server that takes a
//!   client-supplied refname without this check is one `..` away from writing
//!   outside the repository.
//! * [`MAX_REF_COMPONENT_BYTES`] is git's own effective ceiling, measured.
//! * [`check_no_name_conflict`] is the `refs/heads/x` versus `refs/heads/x/y`
//!   rule, which keeps a repository convertible between backends.
//!
//! [`GitOps`](git_storage_trait::GitOps) takes `&str` names and validates
//! nothing — deliberately: a storage contract should not also be a parser. So
//! this module is what the **caller** applies before it hands a name to a
//! backend, and it is here rather than in a backend for the reason
//! [`check_no_name_conflict`] records: *a rule one backend enforces by accident
//! is not a rule*.

use crate::error::{Error, Result};

/// How many symbolic hops a resolver will follow before giving up.
pub const MAX_SYMREF_DEPTH: usize = 5;

/// The name of the pseudo-ref that names the repository's default branch.
pub const HEAD: &str = "HEAD";

/// The branch a repository gets when nobody names one.
///
/// **The one spelling of this name.** It lives in the lowest crate that needs
/// it and `gunnar_core` re-exports it rather than declaring a second one. It was
/// two constants until 2026-08-06: `gunnar_core::model::DEFAULT_BRANCH` decided
/// what the *catalog* recorded and a `b"ref: refs/heads/main"` literal decided
/// what the repository's `HEAD` actually said. They agreed, so nothing ever
/// caught that they were two decisions, and the moment one of them moved — which
/// is what `set-default-branch` does — the other did not follow and the
/// repository stopped being clonable.
pub const DEFAULT_BRANCH: &str = "refs/heads/main";

/// The largest a single `/`-separated component of a reference name may be.
///
/// **This is git's own effective ceiling, not a number gunnar invented.** A
/// loose reference is a file, so a component cannot exceed `NAME_MAX`, which is
/// 255 bytes on every Linux filesystem gunnar can run on. The reference is not
/// what hits the ceiling first, though: `gix-lock` and git both write
/// `<name>.lock` beside it, so the binding limit is `255 - ".lock".len()`.
///
/// MEASURED on the reference desktop 2026-08-06, ext4, git 2.53.0, `git update-ref
/// refs/heads/<N x 'x'>`: **250 accepted, 251 refused** with *"cannot lock ref
/// … Unable to create … File name too long"*.
///
/// Before this existed, `mem`, the Arrow log and the in-memory Arrow log all
/// accepted a **70 011-byte** name and stored it, so gunnar could hold a
/// reference no loose-ref backend and no stock git could ever hold. That is the
/// same disease [`check_no_name_conflict`] was written for, in the other
/// direction, and the rule lives here for the same reason: a rule one backend
/// enforces by accident is not a rule.
pub const MAX_REF_COMPONENT_BYTES: usize = 255 - ".lock".len();

fn reject(name: &[u8], reason: &'static str) -> Error {
    Error::InvalidRefName {
        name: String::from_utf8_lossy(name).into_owned(),
        reason,
    }
}

/// Is `name` a fully-qualified, legal reference name?
///
/// Either exactly [`HEAD`], or something under `refs/`. Partial names (`main`,
/// `origin/main`) are a porcelain convenience and have no place on a server:
/// the wire protocol only ever carries full names.
///
/// The rules are git's `check-ref-format` reduced to what a server needs. This
/// is a security boundary as much as a correctness one.
///
/// Takes bytes because reference names are bytes in git, not `str`.
pub fn validate(name: impl AsRef<[u8]>) -> Result<()> {
    let name = name.as_ref();
    if name.is_empty() {
        return Err(reject(name, "empty"));
    }
    if name != HEAD.as_bytes() && !name.starts_with(b"refs/") {
        return Err(reject(name, "must be `HEAD` or start with `refs/`"));
    }
    if name.ends_with(b"/") {
        return Err(reject(name, "trailing slash"));
    }
    if name.ends_with(b".") {
        return Err(reject(name, "trailing dot"));
    }
    if name.windows(2).any(|w| w == b"..") {
        return Err(reject(name, "contains `..`"));
    }
    if name.windows(2).any(|w| w == b"@{") {
        return Err(reject(name, "contains `@{`"));
    }
    if name.windows(2).any(|w| w == b"//") {
        return Err(reject(name, "empty path component"));
    }
    for &b in name {
        match b {
            0..=0x20 | 0x7f => return Err(reject(name, "control character or space")),
            b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\' => {
                return Err(reject(name, "forbidden character"))
            }
            _ => {}
        }
    }
    for component in name.split(|&b| b == b'/') {
        if component.is_empty() {
            return Err(reject(name, "empty path component"));
        }
        if component.starts_with(b".") {
            return Err(reject(name, "path component starts with a dot"));
        }
        if component.ends_with(b".lock") {
            return Err(reject(name, "path component ends with `.lock`"));
        }
        if component.len() > MAX_REF_COMPONENT_BYTES {
            return Err(reject(name, "path component longer than 250 bytes"));
        }
    }
    Ok(())
}

/// `true` if [`validate`] accepts `name`.
pub fn is_valid(name: impl AsRef<[u8]>) -> bool {
    validate(name).is_ok()
}

/// Is `candidate` a **directory** prefix of `name` — a prefix ending at a slash
/// boundary?
///
/// The boundary is the whole rule. `refs/heads/x` and `refs/heads/xy` share a
/// prefix and are two perfectly ordinary branches; treating them as a conflict
/// would break branch naming while passing every conflict assertion.
pub fn is_directory_prefix(candidate: &str, name: &str) -> bool {
    name.len() > candidate.len()
        && name.starts_with(candidate)
        && name.as_bytes()[candidate.len()] == b'/'
}

/// **Refuse a set of names that cannot coexist as loose references.**
///
/// `refs/heads/x` and `refs/heads/x/y` are a file and a directory of the same
/// name. git refuses the pair on every backend so a repository stays
/// convertible between them, and `git push` says so per-ref
/// (`! [remote rejected] (refname conflict)`) or, when both arrive together,
/// per-batch (`cannot process 'refs/heads/x' and 'refs/heads/x/y' at the same
/// time`). Both measured against git 2.53.0.
///
/// Written here rather than in each backend because a filesystem store gets the
/// rule for free from the filesystem and an in-process one does not: MEASURED
/// 2026-08-05, `mem`, the Arrow log and the in-memory Arrow log all **accepted**
/// the pair, so gunnar could produce a repository no loose-ref backend can hold
/// and stock git will not clone. A rule one backend enforces by accident is not
/// a rule.
///
/// `writing` is the set of names this transaction creates or updates, `deleting`
/// the set it removes. A name being *deleted by the same transaction* does not
/// conflict: git allows `push :refs/heads/x refs/heads/x/y` in one go, measured,
/// and the delete is applied first.
///
/// `existing` is asked only for the narrow prefix `<name>/` and for the handful
/// of slash-prefixes of each name, so this costs a bounded number of point reads
/// plus one narrow scan per name — not a scan of the ref table.
pub fn check_no_name_conflict(
    writing: &[&str],
    deleting: &[&str],
    mut exists: impl FnMut(&str) -> Result<bool>,
    mut under: impl FnMut(&str) -> Result<Vec<String>>,
) -> Result<()> {
    let gone = |name: &str| deleting.contains(&name);

    for name in writing {
        // Within the batch: git refuses both halves rather than ordering them.
        for other in writing {
            if other != name && is_directory_prefix(other, name) {
                return Err(Error::RefNameConflict {
                    name: (*name).to_owned(),
                    conflicts_with: (*other).to_owned(),
                });
            }
        }

        // A shorter name already in the store: `refs/heads/x` under
        // `refs/heads/x/y`. Bounded by the number of slashes in the name.
        let bytes = name.as_bytes();
        for (i, _) in bytes.iter().enumerate().filter(|(_, b)| **b == b'/') {
            let prefix = &name[..i];
            if !is_valid(prefix) || gone(prefix) {
                continue;
            }
            if exists(prefix)? {
                return Err(Error::RefNameConflict {
                    name: (*name).to_owned(),
                    conflicts_with: prefix.to_owned(),
                });
            }
        }

        // A longer name already in the store: `refs/heads/x/y` under
        // `refs/heads/x`.
        for existing in under(&format!("{name}/"))? {
            if gone(existing.as_str()) {
                continue;
            }
            return Err(Error::RefNameConflict {
                name: (*name).to_owned(),
                conflicts_with: existing,
            });
        }
    }
    Ok(())
}

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

    /// A path escape starts exactly here.
    #[test]
    fn the_names_that_must_never_be_accepted() {
        for bad in [
            "refs/heads/../../etc/passwd",
            "refs/heads/..",
            "/refs/heads/main",
            "refs/heads/",
            "refs/heads//main",
            "refs/heads/x y",
            "refs/heads/x~1",
            "refs/heads/x^",
            "refs/heads/x:y",
            "refs/heads/x?",
            "refs/heads/x*",
            "refs/heads/x[",
            "refs/heads/x\\y",
            "refs/heads/.hidden",
            "refs/heads/x.lock",
            "refs/heads/x@{1}",
            "main",
            "",
        ] {
            assert!(
                validate(bad).is_err(),
                "the reference name {bad:?} was accepted; a path escape starts exactly here"
            );
        }
    }

    /// The identity-value guard: a validator that refuses everything passes the
    /// test above and breaks every repository.
    #[test]
    fn the_names_that_must_be_accepted() {
        for good in [
            "HEAD",
            "refs/heads/main",
            "refs/heads/team/feature/very/deep-name_1.2",
            "refs/tags/v1.0.0",
            &format!("refs/heads/{}", "x".repeat(MAX_REF_COMPONENT_BYTES)),
            &format!("refs/heads/{}", vec!["a"; 60].join("/")),
        ] {
            validate(good).unwrap_or_else(|err| panic!("{good:?} was refused: {err}"));
        }
    }

    /// 250 accepted, 251 refused — git 2.53.0 on ext4, measured.
    #[test]
    fn the_component_ceiling_is_gits_own() {
        assert!(validate(format!(
            "refs/heads/{}",
            "x".repeat(MAX_REF_COMPONENT_BYTES)
        ))
        .is_ok());
        for over in [MAX_REF_COMPONENT_BYTES + 1, 70_000] {
            assert!(
                validate(format!("refs/heads/{}", "x".repeat(over))).is_err(),
                "a {over}-byte path component was accepted; `fs` cannot write it \
                 (ENAMETOOLONG on the `.lock`) and neither can stock git"
            );
        }
    }

    #[test]
    fn a_directory_prefix_is_a_conflict_and_a_shared_prefix_is_not() {
        let held = ["refs/heads/x"];
        let exists = |name: &str| Ok(held.contains(&name));
        let under = |_: &str| Ok(Vec::new());

        assert!(matches!(
            check_no_name_conflict(&["refs/heads/x/y"], &[], exists, under),
            Err(Error::RefNameConflict { .. })
        ));
        // The control. `refs/heads/x` and `refs/heads/xy` must coexist.
        check_no_name_conflict(&["refs/heads/xy"], &[], exists, under)
            .expect("a shared prefix that is not a directory prefix is not a conflict");
    }

    #[test]
    fn both_halves_of_a_batch_are_refused_rather_than_ordered() {
        assert!(matches!(
            check_no_name_conflict(
                &["refs/heads/x", "refs/heads/x/y"],
                &[],
                |_| Ok(false),
                |_| Ok(Vec::new()),
            ),
            Err(Error::RefNameConflict { .. })
        ));
    }

    /// A name the same transaction deletes is not in the way.
    #[test]
    fn a_deleted_name_does_not_conflict() {
        check_no_name_conflict(
            &["refs/heads/x/y"],
            &["refs/heads/x"],
            |name| Ok(name == "refs/heads/x"),
            |_| Ok(Vec::new()),
        )
        .expect("git applies the delete first; measured");
    }
}