use core::fmt;
use core::str::FromStr;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub enum UsernameError {
Empty,
TooLong(usize),
InvalidFirstCharacter,
InvalidCharacter,
}
impl fmt::Display for UsernameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "username cannot be empty"),
Self::TooLong(len) => write!(f, "username too long (got {len}, max 32 characters)"),
Self::InvalidFirstCharacter => {
write!(f, "username must start with a letter or underscore")
}
Self::InvalidCharacter => write!(f, "username contains invalid character"),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for UsernameError {}
#[repr(transparent)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Username(heapless::String<32>);
impl Username {
pub const MAX_LEN: usize = 32;
pub fn new(s: &str) -> Result<Self, UsernameError> {
Self::validate(s)?;
let mut value = heapless::String::new();
value
.push_str(s)
.map_err(|_| UsernameError::TooLong(s.len()))?;
Ok(Self(value))
}
fn validate(s: &str) -> Result<(), UsernameError> {
if s.is_empty() {
return Err(UsernameError::Empty);
}
if s.len() > Self::MAX_LEN {
return Err(UsernameError::TooLong(s.len()));
}
let mut chars = s.chars();
if let Some(first) = chars.next()
&& !first.is_ascii_alphabetic()
&& first != '_'
{
return Err(UsernameError::InvalidFirstCharacter);
}
for ch in chars {
if !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-' {
return Err(UsernameError::InvalidCharacter);
}
}
Ok(())
}
#[must_use]
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
#[inline]
pub const fn as_inner(&self) -> &heapless::String<32> {
&self.0
}
#[must_use]
#[inline]
pub fn into_inner(self) -> heapless::String<32> {
self.0
}
#[must_use]
#[inline]
pub fn is_system_user(&self) -> bool {
self.0.starts_with('_') || self.0 == "root" || self.0 == "daemon"
}
#[must_use]
#[inline]
pub fn is_service_account(&self) -> bool {
self.0.starts_with("svc-") || self.0.starts_with("service-")
}
}
impl AsRef<str> for Username {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl TryFrom<&str> for Username {
type Error = UsernameError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl FromStr for Username {
type Err = UsernameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl fmt::Display for Username {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for Username {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const DIGITS: &[u8] = b"0123456789";
let len = 1 + (u8::arbitrary(u)? % 32).min(31);
let mut inner = heapless::String::<32>::new();
let first_byte = u8::arbitrary(u)?;
if first_byte % 10 == 0 {
inner
.push('_')
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
} else {
let first = ALPHABET[(first_byte % 26) as usize] as char;
inner
.push(first)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
for _ in 1..len {
let byte = u8::arbitrary(u)?;
let c = match byte % 4 {
0 => ALPHABET[((byte >> 2) % 26) as usize] as char,
1 => DIGITS[((byte >> 2) % 10) as usize] as char,
2 => '_',
_ => '-',
};
inner
.push(c)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
Ok(Self(inner))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_valid() {
let username = Username::new("root").unwrap();
assert_eq!(username.as_str(), "root");
}
#[test]
fn test_new_empty() {
assert!(matches!(Username::new(""), Err(UsernameError::Empty)));
}
#[test]
fn test_new_too_long() {
let long_name = "a".repeat(33);
assert!(matches!(
Username::new(&long_name),
Err(UsernameError::TooLong(33))
));
}
#[test]
fn test_new_invalid_first_character() {
assert!(matches!(
Username::new("1root"),
Err(UsernameError::InvalidFirstCharacter)
));
assert!(matches!(
Username::new("-root"),
Err(UsernameError::InvalidFirstCharacter)
));
}
#[test]
fn test_new_invalid_character() {
assert!(matches!(
Username::new("root@"),
Err(UsernameError::InvalidCharacter)
));
assert!(matches!(
Username::new("root.user"),
Err(UsernameError::InvalidCharacter)
));
}
#[test]
fn test_is_system_user() {
let root = Username::new("root").unwrap();
assert!(root.is_system_user());
let daemon = Username::new("daemon").unwrap();
assert!(daemon.is_system_user());
let system = Username::new("_system").unwrap();
assert!(system.is_system_user());
let user = Username::new("user").unwrap();
assert!(!user.is_system_user());
}
#[test]
fn test_is_service_account() {
let svc = Username::new("svc-api").unwrap();
assert!(svc.is_service_account());
let service = Username::new("service-api").unwrap();
assert!(service.is_service_account());
let user = Username::new("user").unwrap();
assert!(!user.is_service_account());
}
#[test]
fn test_from_str() {
let username: Username = "root".parse().unwrap();
assert_eq!(username.as_str(), "root");
}
#[test]
fn test_from_str_error() {
assert!("".parse::<Username>().is_err());
assert!("1root".parse::<Username>().is_err());
}
#[test]
fn test_display() {
let username = Username::new("root").unwrap();
assert_eq!(format!("{}", username), "root");
}
#[test]
fn test_as_ref() {
let username = Username::new("root").unwrap();
let s: &str = username.as_ref();
assert_eq!(s, "root");
}
#[test]
fn test_clone() {
let username = Username::new("root").unwrap();
let username2 = username.clone();
assert_eq!(username, username2);
}
#[test]
fn test_equality() {
let u1 = Username::new("root").unwrap();
let u2 = Username::new("root").unwrap();
let u3 = Username::new("user").unwrap();
assert_eq!(u1, u2);
assert_ne!(u1, u3);
}
#[test]
fn test_as_inner() {
let username = Username::new("root").unwrap();
let inner = username.as_inner();
assert_eq!(inner.as_str(), "root");
}
#[test]
fn test_into_inner() {
let username = Username::new("root").unwrap();
let inner = username.into_inner();
assert_eq!(inner.as_str(), "root");
}
}