//! Owner and repo name rules. use unicode_normalization::UnicodeNormalization; use crate::error::{Error, Result}; /// Names that may not be used as owners or repos (they collide with routes). pub const RESERVED: &[&str] = &[ "api", "admin", "auth", "assets", "static", "healthz", "login", "logout", "register", "setup", "settings", "new", "explore", "user", "users", "repo", "repos", "org", "orgs", "raw", "git", "internal", "help", "about", "avatars", "search", "notifications", "dashboard", "issues", "pulls", "snippets", // The reserved account that authored content is reassigned to on deletion. "ghost", "favicon.ico", "robots.txt", "security.txt", ".well-known", "-", ".", "..", ]; /// NFKC normalization plus case fold. pub fn normalize_name(input: &str) -> String { input.nfkc().collect::().to_lowercase() } /// Returns the folded name, or `Error::InvalidName`. /// Allowed characters `[a-z0-9._-]`; must not start with `-` or `.`; must not end in `.git` or `.atom`. /// Repo names must also not end in `.wiki`, so that `.wiki.git` names the wiki unambiguously. pub fn validate_name(input: &str) -> Result { let name = normalize_name(input); if !name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')) { return Err(invalid( input, "only a-z, 0-9, '.', '_' and '-' are allowed", )); } check_folded(input, &name)?; if name.ends_with(".wiki") { return Err(invalid(input, "must not end with .wiki")); } Ok(name) } /// The repo name a wiki transport path names: `name.wiki` or `name.wiki.git` gives `name`. `None` /// for any other name. Repo names never end in `.wiki`, so the mapping is unambiguous. pub fn wiki_base(path_name: &str) -> Option<&str> { let base = path_name.strip_suffix(".git").unwrap_or(path_name); base.strip_suffix(".wiki").filter(|b| !b.is_empty()) } /// Validates an owner (user) name. Returns `(display, folded)`: the NFKC-normalized input with /// its case kept, and the lowercase form used for uniqueness and lookup. /// Display characters are `[A-Za-z0-9._-]`; the folded form must pass the same rules as /// `validate_name`. pub fn validate_username(input: &str) -> Result<(String, String)> { let display: String = input.nfkc().collect(); if !display .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) { return Err(invalid( input, "only A-Z, a-z, 0-9, '.', '_' and '-' are allowed", )); } let folded = display.to_ascii_lowercase(); check_folded(input, &folded)?; Ok((display, folded)) } /// Message for a username change that alters more than capitalization. pub const CAPITALIZATION_ONLY: &str = "Only the capitalization can be changed."; /// Validates `new_display` and checks that it only changes the casing of `current`. /// Returns the display form to store. pub fn capitalization_change(current: &str, new_display: &str) -> Result { let (display, folded) = validate_username(new_display)?; if folded != normalize_name(current) { return Err(Error::Invalid(CAPITALIZATION_ONLY.into())); } Ok(display) } fn invalid(input: &str, why: &str) -> Error { Error::InvalidName(format!("{input:?}: {why}")) } /// Length, leading/trailing, `..`, and reserved-name rules, applied to a folded name. fn check_folded(input: &str, name: &str) -> Result<()> { if !(1..=64).contains(&name.chars().count()) { return Err(invalid(input, "length must be 1 to 64 characters")); } if name.starts_with(['-', '.']) { return Err(invalid(input, "must not start with '-' or '.'")); } if name.ends_with(".git") || name.ends_with(".atom") { return Err(invalid(input, "must not end with .git or .atom")); } if name.contains("..") { return Err(invalid(input, "must not contain '..'")); } if RESERVED.contains(&name) { return Err(invalid(input, "name is reserved")); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn normalizes_nfkc_then_lowercase() { assert_eq!(normalize_name("ABC"), "abc"); assert_eq!(normalize_name("Repo.Name"), "repo.name"); // Fullwidth 'fi' ligature folds to "fi" under NFKC. assert_eq!(normalize_name("file"), "file"); } #[test] fn accepts_valid_names_and_returns_folded_form() { assert_eq!(validate_name("Alice").unwrap(), "alice"); assert_eq!(validate_name("my-repo_2.x").unwrap(), "my-repo_2.x"); assert_eq!(validate_name("a").unwrap(), "a"); assert_eq!(validate_name(&"a".repeat(64)).unwrap().len(), 64); assert_eq!(validate_name("x.gitx").unwrap(), "x.gitx"); } #[test] fn rejects_invalid_names() { let bad = [ "".to_string(), "a".repeat(65), "has space".into(), "slash/name".into(), "-leading".into(), ".leading".into(), "trailing.git".into(), "TRAILING.GIT".into(), "x.atom".into(), "a..b".into(), "é".into(), "api".into(), "Settings".into(), "-".into(), ".".into(), "..".into(), ]; for name in bad { assert!( matches!(validate_name(&name), Err(Error::InvalidName(_))), "expected rejection for {name:?}" ); } } #[test] fn username_keeps_display_case_and_folds_key() { assert_eq!( validate_username("Tahinli").unwrap(), ("Tahinli".to_string(), "tahinli".to_string()) ); assert_eq!( validate_username("TAHINLI").unwrap(), ("TAHINLI".to_string(), "tahinli".to_string()) ); } #[test] fn username_rejects_reserved_and_invalid() { for bad in [ "ADMIN", "Settings", "Ta hinli", "Ünal", "Tahinli.GIT", "-Tahinli", "a..B", "", ] { assert!( matches!(validate_username(bad), Err(Error::InvalidName(_))), "expected rejection for {bad:?}" ); } } #[test] fn reserved_list_contains_route_names() { for r in [ "api", "admin", "register", "explore", "orgs", "about", "git", ] { assert!(RESERVED.contains(&r)); } } } #[cfg(test)] mod wiki_name_tests { use super::*; #[test] fn repo_names_may_not_end_in_wiki() { assert!(matches!( validate_name("proj.wiki"), Err(Error::InvalidName(_)) )); assert!(validate_name("wikiproj").is_ok()); } #[test] fn wiki_transport_names_map_to_the_repo() { assert_eq!(wiki_base("proj.wiki.git"), Some("proj")); assert_eq!(wiki_base("proj.wiki"), Some("proj")); assert_eq!(wiki_base("proj.git"), None); assert_eq!(wiki_base("proj"), None); assert_eq!(wiki_base(".wiki"), None); } }