kellnr-common 7.1.0

Kellnr is a self-hosted registry for Rust crates with support for rustdocs and crates.io caching.
Documentation
//! Validation of the names of users, groups and auth tokens.

use std::fmt;

use sea_orm::Value;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use utoipa::ToSchema;

/// Maximal length of user and group names in characters.
pub const MAX_NAME_LEN: usize = 64;

/// Maximal length of auth token names in characters.
pub const MAX_TOKEN_NAME_LEN: usize = 128;

#[derive(Debug, Error, PartialEq, Eq)]
pub enum AccountNameError {
    #[error("The name must not be empty")]
    Empty,
    #[error("The name must not be longer than {0} characters")]
    TooLong(usize),
    #[error("The name must not contain control characters")]
    ControlCharacter,
    #[error("The user name must not contain ':'")]
    Colon,
}

/// Checks a new user name. A `:` is rejected because HTTP Basic auth splits
/// the credentials at the first `:`, so such a user could not log in.
pub fn validate_user_name(name: &str) -> Result<(), AccountNameError> {
    validate(name, MAX_NAME_LEN)?;
    if name.contains(':') {
        return Err(AccountNameError::Colon);
    }
    Ok(())
}

/// Checks a new group name.
pub fn validate_group_name(name: &str) -> Result<(), AccountNameError> {
    validate(name, MAX_NAME_LEN)
}

/// Checks a new auth token name.
pub fn validate_token_name(name: &str) -> Result<(), AccountNameError> {
    validate(name, MAX_TOKEN_NAME_LEN)
}

/// Defines a name type. The names of existing accounts are taken as they
/// are, as older versions did not check them, so only names of new accounts
/// are validated. Deserializing only rejects an empty name.
macro_rules! account_name_type {
    ($(#[$doc:meta])* $name:ident, $new_fn:ident, $validate:path) => {
        $(#[$doc])*
        #[derive(
            Default,
            Debug,
            Clone,
            PartialEq,
            Eq,
            PartialOrd,
            Ord,
            Hash,
            Serialize,
            Deserialize,
            ToSchema,
        )]
        #[serde(try_from = "String", into = "String")]
        pub struct $name(String);

        impl $name {
            /// Validates the name of a new account.
            pub fn $new_fn(name: impl Into<String>) -> Result<Self, AccountNameError> {
                let name = name.into();
                $validate(&name)?;
                Ok(Self(name))
            }

            /// Takes the name of an existing account to look it up. Only an
            /// empty name is rejected.
            pub fn new(name: impl Into<String>) -> Result<Self, AccountNameError> {
                let name = name.into();
                if name.is_empty() {
                    return Err(AccountNameError::Empty);
                }
                Ok(Self(name))
            }

            /// Takes a name without checks, for names stored in the database
            /// or generated by Kellnr.
            pub fn from_unchecked(name: String) -> Self {
                Self(name)
            }

            pub fn as_str(&self) -> &str {
                &self.0
            }

            pub fn into_inner(self) -> String {
                self.0
            }
        }

        impl TryFrom<String> for $name {
            type Error = AccountNameError;

            fn try_from(name: String) -> Result<Self, Self::Error> {
                Self::new(name)
            }
        }

        impl TryFrom<&str> for $name {
            type Error = AccountNameError;

            fn try_from(name: &str) -> Result<Self, Self::Error> {
                Self::new(name)
            }
        }

        impl From<$name> for String {
            fn from(name: $name) -> Self {
                name.0
            }
        }

        impl From<&$name> for Value {
            fn from(name: &$name) -> Self {
                Value::String(Some(name.0.clone()))
            }
        }

        impl AsRef<str> for $name {
            fn as_ref(&self) -> &str {
                &self.0
            }
        }

        impl PartialEq<str> for $name {
            fn eq(&self, other: &str) -> bool {
                self.0 == other
            }
        }

        impl PartialEq<&str> for $name {
            fn eq(&self, other: &&str) -> bool {
                self.0 == *other
            }
        }

        impl PartialEq<$name> for str {
            fn eq(&self, other: &$name) -> bool {
                self == other.0
            }
        }

        impl PartialEq<$name> for &str {
            fn eq(&self, other: &$name) -> bool {
                *self == other.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str(&self.0)
            }
        }
    };
}

account_name_type!(
    /// The name of a user.
    UserName,
    for_new_user,
    validate_user_name
);

account_name_type!(
    /// The name of a group.
    GroupName,
    for_new_group,
    validate_group_name
);

fn validate(name: &str, max_len: usize) -> Result<(), AccountNameError> {
    if name.trim().is_empty() {
        return Err(AccountNameError::Empty);
    }
    if name.chars().count() > max_len {
        return Err(AccountNameError::TooLong(max_len));
    }
    if name.chars().any(char::is_control) {
        return Err(AccountNameError::ControlCharacter);
    }
    Ok(())
}

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

    #[test]
    fn valid_names_are_accepted() {
        assert_eq!(validate_user_name("alice.smith"), Ok(()));
        assert_eq!(validate_user_name(&"a".repeat(MAX_NAME_LEN)), Ok(()));
        assert_eq!(validate_group_name("dev team"), Ok(()));
        assert_eq!(validate_token_name("CI token: main"), Ok(()));
    }

    #[test]
    fn empty_and_whitespace_names_are_rejected() {
        assert_eq!(validate_user_name(""), Err(AccountNameError::Empty));
        assert_eq!(validate_group_name("  "), Err(AccountNameError::Empty));
        assert_eq!(validate_token_name("\t"), Err(AccountNameError::Empty));
    }

    #[test]
    fn long_names_are_rejected() {
        assert_eq!(
            validate_user_name(&"a".repeat(MAX_NAME_LEN + 1)),
            Err(AccountNameError::TooLong(MAX_NAME_LEN))
        );
        assert_eq!(
            validate_token_name(&"a".repeat(MAX_TOKEN_NAME_LEN + 1)),
            Err(AccountNameError::TooLong(MAX_TOKEN_NAME_LEN))
        );
    }

    #[test]
    fn control_characters_are_rejected() {
        assert_eq!(
            validate_user_name("ali\nce"),
            Err(AccountNameError::ControlCharacter)
        );
    }

    #[test]
    fn existing_names_are_taken_as_they_are() {
        let name = "a:b".repeat(30);
        assert_eq!(name, UserName::new(name.as_str()).unwrap().as_str());
        assert_eq!(name, GroupName::new(name.as_str()).unwrap().as_str());
        assert_eq!(Err(AccountNameError::Empty), UserName::new(""));
    }

    #[test]
    fn new_names_are_validated() {
        assert_eq!(Err(AccountNameError::Colon), UserName::for_new_user("a:b"));
        assert_eq!(
            Err(AccountNameError::TooLong(MAX_NAME_LEN)),
            GroupName::for_new_group("a".repeat(MAX_NAME_LEN + 1))
        );
        assert_eq!(UserName::for_new_user("alice").unwrap(), "alice");
    }

    #[test]
    fn names_are_strings_in_json() {
        let name: UserName = serde_json::from_str(r#""a:b""#).unwrap();
        assert_eq!(name, "a:b");
        assert_eq!(r#""a:b""#, serde_json::to_string(&name).unwrap());
        assert!(serde_json::from_str::<GroupName>(r#""""#).is_err());
    }

    #[test]
    fn colon_is_rejected_in_user_names_only() {
        assert_eq!(validate_user_name("a:b"), Err(AccountNameError::Colon));
        assert_eq!(validate_group_name("a:b"), Ok(()));
    }
}