gix-validate 0.11.1

Validation functions for various kinds of names in git
Documentation
use bstr::{BStr, BString, ByteSlice};

///
pub mod name {
    use bstr::BString;

    /// The error returned by [`name()`][super::name()].
    #[derive(Debug)]
    #[allow(missing_docs)]
    #[non_exhaustive]
    pub enum Error {
        InvalidByte { byte: BString },
        StartsWithSlash,
        RepeatedSlash,
        RepeatedDot,
        LockFileSuffix,
        ReflogPortion,
        Asterisk,
        StartsWithDot,
        EndsWithDot,
        EndsWithSlash,
        Empty,
    }

    impl std::fmt::Display for Error {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            match self {
                Error::InvalidByte { byte } => write!(
                    f,
                    "A ref must not contain invalid bytes or ascii control characters: {byte:?}"
                ),
                Error::StartsWithSlash => write!(f, "A reference name must not start with a slash '/'"),
                Error::RepeatedSlash => write!(
                    f,
                    "Multiple slashes in a row are not allowed as they may change the reference's meaning"
                ),
                Error::RepeatedDot => write!(f, "A ref must not contain '..' as it may be mistaken for a range"),
                Error::LockFileSuffix => write!(f, "A ref must not end with '.lock'"),
                Error::ReflogPortion => write!(f, "A ref must not contain '@{{' which is a part of a ref-log"),
                Error::Asterisk => write!(f, "A ref must not contain '*' character"),
                Error::StartsWithDot => write!(f, "A ref must not start with a '.'"),
                Error::EndsWithDot => write!(f, "A ref must not end with a '.'"),
                Error::EndsWithSlash => write!(f, "A ref must not end with a '/'"),
                Error::Empty => write!(f, "A ref must not be empty"),
            }
        }
    }

    impl std::error::Error for Error {}
}

/// Assure the given `input` resemble a valid git tag name, which is returned unchanged on success.
/// Tag names are provided as names, like `v1.0` or `alpha-1`, without paths.
pub fn name(input: &BStr) -> Result<&BStr, name::Error> {
    match name_inner(input, Mode::Validate)? {
        None => Ok(input),
        Some(_) => {
            unreachable!("When validating, the input isn't changed")
        }
    }
}

#[derive(Eq, PartialEq)]
pub(crate) enum Mode {
    Sanitize,
    Validate,
}

pub(crate) fn name_inner(input: &BStr, mode: Mode) -> Result<Option<BString>, name::Error> {
    let mut out: Option<BString> =
        matches!(mode, Mode::Sanitize).then(|| BString::from(Vec::with_capacity(input.len())));
    if input.is_empty() {
        return if let Some(mut out) = out {
            out.push(b'-');
            Ok(Some(out))
        } else {
            Err(name::Error::Empty)
        };
    }
    if *input.last().expect("non-empty") == b'/' && out.is_none() {
        return Err(name::Error::EndsWithSlash);
    }
    if input.first() == Some(&b'/') && out.is_none() {
        return Err(name::Error::StartsWithSlash);
    }

    let mut previous = 0;
    let mut component_start;
    let mut component_end = 0;
    let last = input.len() - 1;
    for (byte_pos, byte) in input.iter().enumerate() {
        match byte {
            b'\\' | b'^' | b':' | b'[' | b'?' | b' ' | b'~' | b'\0'..=b'\x1F' | b'\x7F' => {
                if let Some(out) = out.as_mut() {
                    out.push(b'-');
                } else {
                    return Err(name::Error::InvalidByte {
                        byte: (&[*byte][..]).into(),
                    });
                }
            }
            b'*' => {
                if let Some(out) = out.as_mut() {
                    out.push(b'-');
                } else {
                    return Err(name::Error::Asterisk);
                }
            }

            b'.' if previous == b'.' => {
                if out.is_none() {
                    return Err(name::Error::RepeatedDot);
                }
            }
            b'.' if previous == b'/' => {
                if let Some(out) = out.as_mut() {
                    out.push(b'-');
                } else {
                    return Err(name::Error::StartsWithDot);
                }
            }
            b'{' if previous == b'@' => {
                if let Some(out) = out.as_mut() {
                    out.push(b'-');
                } else {
                    return Err(name::Error::ReflogPortion);
                }
            }
            b'/' if previous == b'/' => {
                if out.is_none() {
                    return Err(name::Error::RepeatedSlash);
                }
            }
            c => {
                if *c == b'/' {
                    component_start = component_end;
                    component_end = byte_pos;

                    if input[component_start..component_end].ends_with_str(".lock") {
                        if let Some(out) = out.as_mut() {
                            while out.ends_with(b".lock") {
                                let len_without_suffix = out.len() - b".lock".len();
                                out.truncate(len_without_suffix);
                            }
                        } else {
                            return Err(name::Error::LockFileSuffix);
                        }
                    }
                }

                if let Some(out) = out.as_mut() {
                    out.push(*c);
                }

                if byte_pos == last && input[component_end + 1..].ends_with_str(".lock") {
                    if let Some(out) = out.as_mut() {
                        while out.ends_with(b".lock") {
                            let len_without_suffix = out.len() - b".lock".len();
                            out.truncate(len_without_suffix);
                        }
                    } else {
                        return Err(name::Error::LockFileSuffix);
                    }
                }
            }
        }
        previous = *byte;
    }

    if let Some(out) = out.as_mut() {
        while out.last() == Some(&b'/') {
            out.pop();
        }
        while out.first() == Some(&b'/') {
            out.remove(0);
        }
    }
    if out.as_ref().map_or(input, |b| b.as_bstr())[0] == b'.' {
        if let Some(out) = out.as_mut() {
            out[0] = b'-';
        } else {
            return Err(name::Error::StartsWithDot);
        }
    }
    let last = out.as_ref().map_or(input, |b| b.as_bstr()).len() - 1;
    if out.as_ref().map_or(input, |b| b.as_bstr())[last] == b'.' {
        if let Some(out) = out.as_mut() {
            let last = out.len() - 1;
            out[last] = b'-';
        } else {
            return Err(name::Error::EndsWithDot);
        }
    }
    Ok(out)
}