heddle-refs 0.15.2

An AI-native version control system
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Ref-name validation rules.

use objects::{error::HeddleError, object::is_reserved_heddle_namespace};

/// Ref-name validation error.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid ref name: {name}")]
pub struct RefNameError {
    pub name: String,
}

pub fn validate_ref_name(name: &str) -> Result<(), RefNameError> {
    if name.is_empty() {
        return Err(invalid(name));
    }
    if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
        return Err(invalid(name));
    }
    if name.contains("..") || name.contains("//") {
        return Err(invalid(name));
    }
    if name.contains('\\') || name.starts_with('/') || name.ends_with('/') {
        return Err(invalid(name));
    }
    if name.starts_with('.') {
        return Err(invalid(name));
    }
    // Git refname rule: no path *component* may end in `.lock` (not just
    // the whole ref) — `refs/heads/foo.lock/bar` would collide with the
    // on-disk lockfile of `refs/heads/foo`.
    if name
        .split('/')
        .any(|component| component.ends_with(".lock"))
    {
        return Err(invalid(name));
    }
    if is_reserved_heddle_namespace(name) {
        return Err(invalid(name));
    }
    Ok(())
}

/// Shared reservation chokepoint for every [`crate::refs::CoreRefBackend`].
///
/// Filesystem paths already call [`validate_ref_name`]. Hosted Postgres
/// mutations (and the in-memory test backend) must go through this helper
/// so a `ThreadName`/`MarkerName` minted via `new`/`From` cannot persist a
/// user ref in the reserved `heddle/` namespace.
///
/// Serve-side `ListRefs`/`Pull` gating of synthetic roots remains weft#1728.
pub fn require_user_ref_name(name: impl AsRef<str>) -> objects::error::Result<()> {
    validate_ref_name(name.as_ref()).map_err(|error| HeddleError::InvalidRefName(error.name))
}

fn invalid(name: &str) -> RefNameError {
    RefNameError {
        name: name.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use objects::error::HeddleError;

    use super::{require_user_ref_name, validate_ref_name};

    #[test]
    fn rejects_ref_ending_in_lock() {
        assert!(validate_ref_name("refs/heads/foo.lock").is_err());
    }

    #[test]
    fn rejects_any_component_ending_in_lock() {
        // The git refname rule is per-component: a `.lock` directory
        // collides with the sibling ref's lockfile.
        assert!(validate_ref_name("refs/heads/foo.lock/bar").is_err());
        assert!(validate_ref_name("refs/foo.lock/heads/bar").is_err());
    }

    #[test]
    fn allows_lock_as_non_suffix() {
        // `.lock` must be a component *suffix* to be rejected.
        assert!(validate_ref_name("refs/heads/foo.locker").is_ok());
        assert!(validate_ref_name("refs/heads/lock").is_ok());
        assert!(validate_ref_name("refs/heads/foo.lock.bak").is_ok());
    }

    #[test]
    fn allows_plain_ref() {
        assert!(validate_ref_name("refs/heads/main").is_ok());
    }

    #[test]
    fn reserves_heddle_rooted_names() {
        assert!(validate_ref_name("heddle/frontier/main/hc-abc").is_err());
        assert!(validate_ref_name("Heddle/x").is_err());
        assert!(validate_ref_name("heddle").is_ok());
        assert!(validate_ref_name("heddlefoo").is_ok());
        assert!(validate_ref_name("my/heddle").is_ok());
        assert!(validate_ref_name("main@review").is_ok());
        assert!(validate_ref_name("main@hd-abc").is_ok());
    }

    #[test]
    fn require_user_ref_name_rejects_reserved_namespace() {
        let error = require_user_ref_name("heddle/frontier/main/hc-abc").unwrap_err();
        assert!(
            matches!(error, HeddleError::InvalidRefName(name) if name == "heddle/frontier/main/hc-abc")
        );
        assert!(require_user_ref_name("main").is_ok());
    }
}