alkgit 0.1.0

Git smart protocol for the alk family: pkt-line substrates, protocol V2 serving (upload-pack/receive-pack), producer/consumer halves on alkcall channels, and the backend seam traits — the shipped alkstore+alkblobs composition is the alkgit-substrate crate (ADR-010/025)
Documentation
//! Ref-name validation + the reserved-namespace deny-list (ADR-013 §9)
//! — the transport-side home.
//!
//! [`validate_ref_name`] is the per-ref name check the push machine
//! runs after unpack (ADR-013 §7's order: name, CAS, policy — the
//! wiring lands in wave 3's push machine, which consumes
//! `PreparedPush.updates` and builds the `apply_updates` call), ahead
//! of the backend CAS (backend.md §Pinned signatures — the seam types
//! carry no validation; this is the check's only home). It composes
//! `gix_validate::reference::name` (the `git check-ref-format` rules)
//! with the deny-list — the crate's reserved-namespace constants below.
//!
//! Rejection-set-only: the function validates and rejects, never
//! normalizes or repairs a name — the repo-id grammar's posture
//! (registry.rs) applied to ref names. Every rejection carries the one
//! pinned denial text ([`FUNNY_REFNAME_REASON`], observed verbatim from
//! real receive-pack — ADR-013 §7/§8): rejections are outcomes with a
//! single reason, never errors with payloads.

use bstr::ByteSlice;

/// The pinned per-ref denial reason (ADR-013 §7/§9): the
/// `ng <ref> <reason>` text for every [`validate_ref_name`] rejection,
/// observed verbatim from real receive-pack (`docs/research/
/// push-captures.md`) — needs no config, exactly as upstream's.
pub const FUNNY_REFNAME_REASON: &str = "funny refname";

/// The reserved `refs/` prefixes of the deny-list (ADR-013 §9): the
/// namespaces alkgit's own host metadata reserves inside the ref table.
/// Wire pushes can never create a ref under them — the reservation is
/// the point. Exact contents are the ADR's implementation-time detail,
/// not an architecture variable.
pub const RESERVED_REF_NAMESPACES: &[&str] = &["refs/alkgit"];

/// The `.lock` deny-list rule (ADR-013 §9): a ref name ending in the
/// lock suffix is the receive lockfile itself — always rejected. The
/// rule rides the deny-list explicitly (see [`validate_ref_name`] for
/// the double enforcement).
pub const LOCK_REFNAME_SUFFIX: &str = ".lock";

/// The transport-side ref-name validation (ADR-013 §9): pass iff the
/// name is a valid git refname under `gix_validate::reference::name`
/// and outside alkgit's deny-list — the reserved
/// [`RESERVED_REF_NAMESPACES`] and the [`LOCK_REFNAME_SUFFIX`] rule.
///
/// Rejection carries the single pinned denial text
/// ([`FUNNY_REFNAME_REASON`]) — no normalization, no repair, no detail
/// payload: the reason string is the client-displayed `ng` text and
/// stays the one constant.
pub fn validate_ref_name(name: &str) -> Result<(), String> {
    let rejected = gix_validate::reference::name(name.as_bytes().as_bstr()).is_err()
        || name.ends_with(LOCK_REFNAME_SUFFIX)
        || RESERVED_REF_NAMESPACES.iter().any(|prefix| {
            name.len() >= prefix.len()
                && name.starts_with(prefix)
                && (name.len() == prefix.len() || name.as_bytes()[prefix.len()] == b'/')
        });
    if rejected {
        Err(FUNNY_REFNAME_REASON.to_string())
    } else {
        Ok(())
    }
}

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

    fn is_allowed(name: &str) -> bool {
        validate_ref_name(name).is_ok()
    }

    #[test]
    fn ordinary_refs_are_accepted() {
        for name in [
            "refs/heads/main",
            "refs/tags/v1.0",
            "refs/heads/feature/deep",
            "refs/remotes/origin/main",
            "refs/notes/mirror/notes/x",
        ] {
            assert!(is_allowed(name), "{name} should pass");
        }
    }

    #[test]
    fn git_refname_invalid_names_are_rejected() {
        for name in [
            "",
            "refs/heads/../evil",
            "refs/heads/foo bar",
            "refs/heads/foo..bar",
            "@",
            "refs/heads/foo@{x}",
            "refs/heads/.starts-with-dot",
            "refs/heads/ends-with-dot.",
            "refs/heads/ends-with-slash/",
            "//no-leading-slash",
            "refs//repeated-slash",
            "refs/heads/asterisk*",
        ] {
            assert!(!is_allowed(name), "{name:?} should be rejected");
        }
    }

    #[test]
    fn the_lock_suffix_rule_is_enforced() {
        assert!(!is_allowed("refs/heads/foo.lock"));
        assert!(!is_allowed("refs/heads/main.lock"));
        assert!(!is_allowed("refs/heads/x/.lock"));
    }

    #[test]
    fn the_reserved_namespace_rule_is_enforced() {
        assert!(!is_allowed("refs/alkgit"));
        assert!(!is_allowed("refs/alkgit/"));
        assert!(!is_allowed("refs/alkgit/any/thing"));
        assert!(is_allowed("refs/heads/alkgit"));
        assert!(is_allowed("refs/tags/alkgit/x"));
    }

    #[test]
    fn every_rejection_carries_the_pinned_reason() {
        for name in [
            "refs/heads/../evil",
            "refs/heads/foo.lock",
            "refs/alkgit/x",
            "",
        ] {
            assert_eq!(
                validate_ref_name(name),
                Err(FUNNY_REFNAME_REASON.to_string()),
                "{name:?}"
            );
        }
    }

    #[test]
    fn the_pin_is_the_observed_upstream_text() {
        assert_eq!(FUNNY_REFNAME_REASON, "funny refname");
    }

    #[test]
    fn the_deny_list_constant_holds_the_rules() {
        assert!(RESERVED_REF_NAMESPACES
            .iter()
            .all(|p| p.starts_with("refs/")));
        assert_eq!(LOCK_REFNAME_SUFFIX, ".lock");
    }

    #[test]
    fn near_miss_names_are_rejected_not_cleaned() {
        for name in [" refs/heads/main", "refs/heads/main ", "refs/heads/ma..in"] {
            assert_eq!(
                validate_ref_name(name),
                Err(FUNNY_REFNAME_REASON.to_string()),
                "{name:?} is a near miss of a valid ref — rejected verbatim, never repaired"
            );
        }
    }
}