// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Validation for user, repository, and group names. //! //! One implementation, used by the CLI, the API, and the web UI, so the rules //! can never drift between entry points. A valid name matches the regular //! expression //! //! ```text //! ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ //! ``` //! //! and additionally must not be `.` or `..`, must not end in `.git` or `.`, and //! must not collide with a route-reserved word (see [`RESERVED_NAMES`]). The //! same rules apply to usernames, repository names, and group names — anywhere a //! name becomes part of a URL path. /// The maximum length of a name, in characters (the leading character plus up to /// 38 more, matching the `{0,38}` quantifier). pub const MAX_NAME_LEN: usize = 39; /// The deepest a group may nest — the maximum number of group segments in a /// group path (`a/b/c/d/e` is five, the limit). Keeps repo paths, breadcrumbs, /// and URLs to a sane length; a repo filed under the deepest group has one more /// segment than this. pub const MAX_GROUP_DEPTH: usize = 5; /// Names that would collide with a top-level route or a reserved path segment. /// /// Comparison is case-insensitive, so `Admin` is rejected just as `admin` is. pub const RESERVED_NAMES: &[&str] = &[ "api", "assets", "login", "logout", "search", "settings", "admin", "static", "healthz", "invite", "-", ]; /// Why a candidate name was rejected. Each variant maps to one actionable /// message; callers surface the `Display` form to the user. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum NameError { /// The name was the empty string. #[error("name must not be empty")] Empty, /// The name exceeded [`MAX_NAME_LEN`] characters. #[error("name must be at most {MAX_NAME_LEN} characters")] TooLong, /// The first character was not an ASCII letter or digit. #[error("name must start with an ASCII letter or digit")] BadStart, /// A character after the first was outside `[A-Za-z0-9._-]`. #[error("name may only contain ASCII letters, digits, '.', '_', or '-' (found {0:?})")] BadChar(char), /// The name was `.` or `..`. #[error("name must not be \".\" or \"..\"")] DotName, /// The name ended in `.git`, which would be ambiguous with a bare repo path. #[error("name must not end in \".git\"")] GitSuffix, /// The name ended in `.`. #[error("name must not end in \".\"")] DotSuffix, /// The name matched a reserved word (case-insensitively). #[error("name {0:?} is reserved")] Reserved(String), } /// Validate a user, repository, or group name against every rule in this module. /// /// # Errors /// /// Returns the first [`NameError`] that applies. Checks run in a fixed order /// (emptiness, dot-names, length, character set, suffixes, reserved words) so a /// given input always yields the same error. pub fn validate_name(name: &str) -> Result<(), NameError> { if name.is_empty() { return Err(NameError::Empty); } if name == "." || name == ".." { return Err(NameError::DotName); } // ASCII-only past this point is guaranteed by the character checks below, so // counting chars is equivalent to counting bytes for any name that could // pass — but count chars so an over-long multibyte input reports TooLong // rather than a confusing character error. if name.chars().count() > MAX_NAME_LEN { return Err(NameError::TooLong); } let mut chars = name.chars(); // `name` is non-empty, so `next` yields. let first = chars.next().unwrap_or('\0'); if !first.is_ascii_alphanumeric() { return Err(NameError::BadStart); } for c in chars { if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) { return Err(NameError::BadChar(c)); } } // Suffix rules. The character checks above guarantee `name` is pure ASCII // here, so byte-slicing for the `.git` comparison lands on a char boundary. if name.ends_with('.') { return Err(NameError::DotSuffix); } if name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(".git") { return Err(NameError::GitSuffix); } let lower = name.to_ascii_lowercase(); if RESERVED_NAMES.contains(&lower.as_str()) { return Err(NameError::Reserved(name.to_string())); } Ok(()) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::panic)] use super::*; #[test] fn accepts_ordinary_names() { for name in [ "a", "A", "0", "hanna", "fabrica", "my-repo", "my_repo", "v1.2.3", "a.b_c-d", "Repo123", ] { assert_eq!(validate_name(name), Ok(()), "should accept {name:?}"); } } #[test] fn rejects_empty() { assert_eq!(validate_name(""), Err(NameError::Empty)); } #[test] fn rejects_dot_names() { assert_eq!(validate_name("."), Err(NameError::DotName)); assert_eq!(validate_name(".."), Err(NameError::DotName)); } #[test] fn enforces_length_boundary() { let ok = "a".repeat(MAX_NAME_LEN); assert_eq!( validate_name(&ok), Ok(()), "{MAX_NAME_LEN} chars is allowed" ); let too_long = "a".repeat(MAX_NAME_LEN + 1); assert_eq!(validate_name(&too_long), Err(NameError::TooLong)); } #[test] fn rejects_bad_start() { for name in ["_foo", "-foo", ".foo", "!foo"] { assert_eq!(validate_name(name), Err(NameError::BadStart), "{name:?}"); } } #[test] fn rejects_bad_chars() { assert_eq!(validate_name("a b"), Err(NameError::BadChar(' '))); assert_eq!(validate_name("a/b"), Err(NameError::BadChar('/'))); assert_eq!(validate_name("a@b"), Err(NameError::BadChar('@'))); } #[test] fn rejects_non_ascii() { // A leading non-ASCII letter fails the start check; an interior one fails // the character check. assert_eq!(validate_name("café"), Err(NameError::BadChar('é'))); assert!(matches!( validate_name("ünsen"), Err(NameError::BadStart | NameError::BadChar(_)) )); } #[test] fn rejects_git_suffix_case_insensitively() { for name in ["repo.git", "repo.GIT", "repo.Git", "x.git"] { assert_eq!(validate_name(name), Err(NameError::GitSuffix), "{name:?}"); } // `.git` embedded but not trailing is fine. assert_eq!(validate_name("repo.git.bak"), Ok(())); } #[test] fn rejects_trailing_dot() { assert_eq!(validate_name("repo."), Err(NameError::DotSuffix)); } #[test] fn rejects_reserved_case_insensitively() { for name in ["admin", "Admin", "ADMIN", "api", "settings", "healthz"] { assert!( matches!(validate_name(name), Err(NameError::Reserved(_))), "{name:?} should be reserved" ); } // A reserved word as a substring is fine. assert_eq!(validate_name("administrator"), Ok(())); } }