use super::{
CODE_USER_ACCOUNT, CODE_USER_ACCOUNT_STRICT, CODE_USER_EMAIL, CODE_USER_GROUPS,
CODE_USER_PASSWORD, CODE_USER_ROLES,
};
use super::{has_control_or_space, is_disabled, is_identifier_char, new_error};
use validator::{ValidateEmail, ValidationError};
type Result<T> = std::result::Result<T, ValidationError>;
const ACCOUNT_MIN_LEN: usize = 2;
const ACCOUNT_MAX_LEN: usize = 20;
const PASSWORD_MIN_LEN: usize = 32;
const PASSWORD_MAX_LEN: usize = 256;
const ROLE_MAX_LEN: usize = 64;
const ROLE_MAX_COUNT: usize = 64;
pub fn x_user_account(user: &str) -> Result<()> {
if is_disabled(CODE_USER_ACCOUNT) {
return Ok(());
}
if !user.is_ascii() {
return Err(new_error(
CODE_USER_ACCOUNT,
"account must be ASCII".to_string(),
));
}
if user.len() < ACCOUNT_MIN_LEN || user.len() > ACCOUNT_MAX_LEN {
return Err(new_error(
CODE_USER_ACCOUNT,
format!("account must be {ACCOUNT_MIN_LEN}-{ACCOUNT_MAX_LEN} characters"),
));
}
if has_control_or_space(user) {
return Err(new_error(
CODE_USER_ACCOUNT,
"account must not contain control characters or spaces".to_string(),
));
}
Ok(())
}
pub fn x_user_account_strict(user: &str) -> Result<()> {
if is_disabled(CODE_USER_ACCOUNT_STRICT) {
return Ok(());
}
x_user_account(user)?;
if !user.chars().all(is_identifier_char) {
return Err(new_error(
CODE_USER_ACCOUNT_STRICT,
"account may only contain letters, digits, '_', '-' and '.'".to_string(),
));
}
if !user.starts_with(|c: char| c.is_ascii_alphanumeric()) {
return Err(new_error(
CODE_USER_ACCOUNT_STRICT,
"account must start with a letter or digit".to_string(),
));
}
Ok(())
}
pub fn x_user_password(password: &str) -> Result<()> {
if is_disabled(CODE_USER_PASSWORD) {
return Ok(());
}
if !password.is_ascii() {
return Err(new_error(
CODE_USER_PASSWORD,
"password must be ASCII".to_string(),
));
}
if password.len() < PASSWORD_MIN_LEN {
return Err(new_error(
CODE_USER_PASSWORD,
format!("password must be at least {PASSWORD_MIN_LEN} characters"),
));
}
if password.len() > PASSWORD_MAX_LEN {
return Err(new_error(
CODE_USER_PASSWORD,
format!("password must be at most {PASSWORD_MAX_LEN} characters"),
));
}
Ok(())
}
pub fn x_user_email(email: &str) -> Result<()> {
if is_disabled(CODE_USER_EMAIL) {
return Ok(());
}
if !email.validate_email() {
return Err(new_error(
CODE_USER_EMAIL,
"invalid email format".to_string(),
));
}
Ok(())
}
fn validate_identifiers(values: &[String], code: &'static str, field: &str) -> Result<()> {
if values.len() > ROLE_MAX_COUNT {
return Err(new_error(
code,
format!("at most {ROLE_MAX_COUNT} {field} are allowed"),
));
}
for value in values {
if value.is_empty() {
return Err(new_error(code, format!("{field} must not be empty")));
}
if value.len() > ROLE_MAX_LEN {
return Err(new_error(
code,
format!("each of {field} must be at most {ROLE_MAX_LEN} characters"),
));
}
if !value.chars().all(is_identifier_char) {
return Err(new_error(
code,
format!("{field} may only contain letters, digits, '_', '-' and '.'"),
));
}
}
Ok(())
}
pub fn x_user_roles(roles: &[String]) -> Result<()> {
if is_disabled(CODE_USER_ROLES) {
return Ok(());
}
validate_identifiers(roles, CODE_USER_ROLES, "roles")
}
pub fn x_user_groups(groups: &[String]) -> Result<()> {
if is_disabled(CODE_USER_GROUPS) {
return Ok(());
}
validate_identifiers(groups, CODE_USER_GROUPS, "groups")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn account_enforces_ascii_and_length() {
assert!(x_user_account("ab").is_ok());
assert!(x_user_account(&"a".repeat(ACCOUNT_MAX_LEN)).is_ok());
assert!(x_user_account("a").is_err());
assert!(x_user_account(&"a".repeat(ACCOUNT_MAX_LEN + 1)).is_err());
assert!(x_user_account("用户名").is_err());
}
#[test]
fn account_rejects_control_characters_and_spaces() {
for bad in ["ab\ncd", "ab\rcd", "ab\tcd", "ab cd", "ab\0cd"] {
assert!(
x_user_account(bad).is_err(),
"{bad:?} 含控制字符/空格,应被拒绝"
);
}
}
#[test]
fn account_keeps_legacy_shapes_loginable() {
assert!(x_user_account("a@b.com").is_ok());
assert!(x_user_account("user+tag").is_ok());
}
#[test]
fn strict_account_applies_allowlist() {
for good in ["ab", "user_1", "gh_octocat", "a.b-c", "9lives"] {
assert!(x_user_account_strict(good).is_ok(), "{good} 应当通过");
}
for bad in ["a@b.com", "user+tag", "_leading", "-leading", ".dot", "a/b"] {
assert!(x_user_account_strict(bad).is_err(), "{bad} 应当被拒绝");
}
assert!(x_user_account_strict("a").is_err());
assert!(x_user_account_strict("ab cd").is_err());
}
#[test]
fn password_is_bounded_on_both_ends() {
let sha256_hex = "a".repeat(64);
assert!(x_user_password(&sha256_hex).is_ok());
assert!(x_user_password(&"a".repeat(PASSWORD_MIN_LEN - 1)).is_err());
assert!(x_user_password(&"a".repeat(PASSWORD_MAX_LEN + 1)).is_err());
assert!(x_user_password(&"密".repeat(64)).is_err());
}
#[test]
fn roles_and_groups_are_bounded() {
assert!(x_user_roles(&["admin".to_string(), "su".to_string()]).is_ok());
assert!(x_user_groups(&["team-a".to_string()]).is_ok());
assert!(x_user_roles(&["a".repeat(ROLE_MAX_LEN + 1)]).is_err());
assert!(x_user_roles(&vec!["r".to_string(); ROLE_MAX_COUNT + 1]).is_err());
assert!(x_user_roles(&["admin\nsu".to_string()]).is_err());
assert!(x_user_roles(&[String::new()]).is_err());
assert!(x_user_groups(&["team:a".to_string()]).is_err());
}
#[test]
fn email_validation_reports_a_message() {
let err = x_user_email("not-an-email").unwrap_err();
assert!(err.message.is_some(), "校验错误应带上可读信息");
assert!(x_user_email("a@b.com").is_ok());
}
}