use std::fmt;
use sea_orm::Value;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use utoipa::ToSchema;
pub const MAX_NAME_LEN: usize = 64;
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,
}
pub fn validate_user_name(name: &str) -> Result<(), AccountNameError> {
validate(name, MAX_NAME_LEN)?;
if name.contains(':') {
return Err(AccountNameError::Colon);
}
Ok(())
}
pub fn validate_group_name(name: &str) -> Result<(), AccountNameError> {
validate(name, MAX_NAME_LEN)
}
pub fn validate_token_name(name: &str) -> Result<(), AccountNameError> {
validate(name, MAX_TOKEN_NAME_LEN)
}
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 {
pub fn $new_fn(name: impl Into<String>) -> Result<Self, AccountNameError> {
let name = name.into();
$validate(&name)?;
Ok(Self(name))
}
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))
}
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!(
UserName,
for_new_user,
validate_user_name
);
account_name_type!(
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(()));
}
}