forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.2 KiB
1//! Owner and repo name rules.
2
3use unicode_normalization::UnicodeNormalization;
4
5use crate::error::{Error, Result};
6
7/// Names that may not be used as owners or repos (they collide with routes).
8pub const RESERVED: &[&str] = &[
9 "api",
10 "admin",
11 "auth",
12 "assets",
13 "static",
14 "healthz",
15 "login",
16 "logout",
17 "register",
18 "setup",
19 "settings",
20 "new",
21 "explore",
22 "user",
23 "users",
24 "repo",
25 "repos",
26 "org",
27 "orgs",
28 "raw",
29 "git",
30 "internal",
31 "help",
32 "about",
33 "avatars",
34 "search",
35 "notifications",
36 "dashboard",
37 "issues",
38 "pulls",
39 "snippets",
40 // The reserved account that authored content is reassigned to on deletion.
41 "ghost",
42 "favicon.ico",
43 "robots.txt",
44 "security.txt",
45 ".well-known",
46 "-",
47 ".",
48 "..",
49];
50
51/// NFKC normalization plus case fold.
52pub fn normalize_name(input: &str) -> String {
53 input.nfkc().collect::<String>().to_lowercase()
54}
55
56/// Returns the folded name, or `Error::InvalidName`.
57/// Allowed characters `[a-z0-9._-]`; must not start with `-` or `.`; must not end in `.git` or `.atom`.
58/// Repo names must also not end in `.wiki`, so that `<name>.wiki.git` names the wiki unambiguously.
59pub fn validate_name(input: &str) -> Result<String> {
60 let name = normalize_name(input);
61 if !name
62 .chars()
63 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
64 {
65 return Err(invalid(
66 input,
67 "only a-z, 0-9, '.', '_' and '-' are allowed",
68 ));
69 }
70 check_folded(input, &name)?;
71 if name.ends_with(".wiki") {
72 return Err(invalid(input, "must not end with .wiki"));
73 }
74 Ok(name)
75}
76
77/// The repo name a wiki transport path names: `name.wiki` or `name.wiki.git` gives `name`. `None`
78/// for any other name. Repo names never end in `.wiki`, so the mapping is unambiguous.
79pub fn wiki_base(path_name: &str) -> Option<&str> {
80 let base = path_name.strip_suffix(".git").unwrap_or(path_name);
81 base.strip_suffix(".wiki").filter(|b| !b.is_empty())
82}
83
84/// Validates an owner (user) name. Returns `(display, folded)`: the NFKC-normalized input with
85/// its case kept, and the lowercase form used for uniqueness and lookup.
86/// Display characters are `[A-Za-z0-9._-]`; the folded form must pass the same rules as
87/// `validate_name`.
88pub fn validate_username(input: &str) -> Result<(String, String)> {
89 let display: String = input.nfkc().collect();
90 if !display
91 .chars()
92 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
93 {
94 return Err(invalid(
95 input,
96 "only A-Z, a-z, 0-9, '.', '_' and '-' are allowed",
97 ));
98 }
99 let folded = display.to_ascii_lowercase();
100 check_folded(input, &folded)?;
101 Ok((display, folded))
102}
103
104/// Message for a username change that alters more than capitalization.
105pub const CAPITALIZATION_ONLY: &str = "Only the capitalization can be changed.";
106
107/// Validates `new_display` and checks that it only changes the casing of `current`.
108/// Returns the display form to store.
109pub fn capitalization_change(current: &str, new_display: &str) -> Result<String> {
110 let (display, folded) = validate_username(new_display)?;
111 if folded != normalize_name(current) {
112 return Err(Error::Invalid(CAPITALIZATION_ONLY.into()));
113 }
114 Ok(display)
115}
116
117fn invalid(input: &str, why: &str) -> Error {
118 Error::InvalidName(format!("{input:?}: {why}"))
119}
120
121/// Length, leading/trailing, `..`, and reserved-name rules, applied to a folded name.
122fn check_folded(input: &str, name: &str) -> Result<()> {
123 if !(1..=64).contains(&name.chars().count()) {
124 return Err(invalid(input, "length must be 1 to 64 characters"));
125 }
126 if name.starts_with(['-', '.']) {
127 return Err(invalid(input, "must not start with '-' or '.'"));
128 }
129 if name.ends_with(".git") || name.ends_with(".atom") {
130 return Err(invalid(input, "must not end with .git or .atom"));
131 }
132 if name.contains("..") {
133 return Err(invalid(input, "must not contain '..'"));
134 }
135 if RESERVED.contains(&name) {
136 return Err(invalid(input, "name is reserved"));
137 }
138 Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn normalizes_nfkc_then_lowercase() {
147 assert_eq!(normalize_name("ABC"), "abc");
148 assert_eq!(normalize_name("Repo.Name"), "repo.name");
149 // Fullwidth 'fi' ligature folds to "fi" under NFKC.
150 assert_eq!(normalize_name("file"), "file");
151 }
152
153 #[test]
154 fn accepts_valid_names_and_returns_folded_form() {
155 assert_eq!(validate_name("Alice").unwrap(), "alice");
156 assert_eq!(validate_name("my-repo_2.x").unwrap(), "my-repo_2.x");
157 assert_eq!(validate_name("a").unwrap(), "a");
158 assert_eq!(validate_name(&"a".repeat(64)).unwrap().len(), 64);
159 assert_eq!(validate_name("x.gitx").unwrap(), "x.gitx");
160 }
161
162 #[test]
163 fn rejects_invalid_names() {
164 let bad = [
165 "".to_string(),
166 "a".repeat(65),
167 "has space".into(),
168 "slash/name".into(),
169 "-leading".into(),
170 ".leading".into(),
171 "trailing.git".into(),
172 "TRAILING.GIT".into(),
173 "x.atom".into(),
174 "a..b".into(),
175 "é".into(),
176 "api".into(),
177 "Settings".into(),
178 "-".into(),
179 ".".into(),
180 "..".into(),
181 ];
182 for name in bad {
183 assert!(
184 matches!(validate_name(&name), Err(Error::InvalidName(_))),
185 "expected rejection for {name:?}"
186 );
187 }
188 }
189
190 #[test]
191 fn username_keeps_display_case_and_folds_key() {
192 assert_eq!(
193 validate_username("Tahinli").unwrap(),
194 ("Tahinli".to_string(), "tahinli".to_string())
195 );
196 assert_eq!(
197 validate_username("TAHINLI").unwrap(),
198 ("TAHINLI".to_string(), "tahinli".to_string())
199 );
200 }
201
202 #[test]
203 fn username_rejects_reserved_and_invalid() {
204 for bad in [
205 "ADMIN",
206 "Settings",
207 "Ta hinli",
208 "Ünal",
209 "Tahinli.GIT",
210 "-Tahinli",
211 "a..B",
212 "",
213 ] {
214 assert!(
215 matches!(validate_username(bad), Err(Error::InvalidName(_))),
216 "expected rejection for {bad:?}"
217 );
218 }
219 }
220
221 #[test]
222 fn reserved_list_contains_route_names() {
223 for r in [
224 "api", "admin", "register", "explore", "orgs", "about", "git",
225 ] {
226 assert!(RESERVED.contains(&r));
227 }
228 }
229}
230
231#[cfg(test)]
232mod wiki_name_tests {
233 use super::*;
234
235 #[test]
236 fn repo_names_may_not_end_in_wiki() {
237 assert!(matches!(
238 validate_name("proj.wiki"),
239 Err(Error::InvalidName(_))
240 ));
241 assert!(validate_name("wikiproj").is_ok());
242 }
243
244 #[test]
245 fn wiki_transport_names_map_to_the_repo() {
246 assert_eq!(wiki_base("proj.wiki.git"), Some("proj"));
247 assert_eq!(wiki_base("proj.wiki"), Some("proj"));
248 assert_eq!(wiki_base("proj.git"), None);
249 assert_eq!(wiki_base("proj"), None);
250 assert_eq!(wiki_base(".wiki"), None);
251 }
252}