fabrica

hanna/fabrica

7311 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Validation for user, repository, and group names.
6//!
7//! One implementation, used by the CLI, the API, and the web UI, so the rules
8//! can never drift between entry points. A valid name matches the regular
9//! expression
10//!
11//! ```text
12//! ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$
13//! ```
14//!
15//! and additionally must not be `.` or `..`, must not end in `.git` or `.`, and
16//! must not collide with a route-reserved word (see [`RESERVED_NAMES`]). The
17//! same rules apply to usernames, repository names, and group names — anywhere a
18//! name becomes part of a URL path.
19
20/// The maximum length of a name, in characters (the leading character plus up to
21/// 38 more, matching the `{0,38}` quantifier).
22pub const MAX_NAME_LEN: usize = 39;
23
24/// The deepest a group may nest — the maximum number of group segments in a
25/// group path (`a/b/c/d/e` is five, the limit). Keeps repo paths, breadcrumbs,
26/// and URLs to a sane length; a repo filed under the deepest group has one more
27/// segment than this.
28pub const MAX_GROUP_DEPTH: usize = 5;
29
30/// Names that would collide with a top-level route or a reserved path segment.
31///
32/// Comparison is case-insensitive, so `Admin` is rejected just as `admin` is.
33pub const RESERVED_NAMES: &[&str] = &[
34 "api", "assets", "login", "logout", "search", "settings", "admin", "static", "healthz",
35 "invite", "-",
36];
37
38/// Why a candidate name was rejected. Each variant maps to one actionable
39/// message; callers surface the `Display` form to the user.
40#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41pub enum NameError {
42 /// The name was the empty string.
43 #[error("name must not be empty")]
44 Empty,
45 /// The name exceeded [`MAX_NAME_LEN`] characters.
46 #[error("name must be at most {MAX_NAME_LEN} characters")]
47 TooLong,
48 /// The first character was not an ASCII letter or digit.
49 #[error("name must start with an ASCII letter or digit")]
50 BadStart,
51 /// A character after the first was outside `[A-Za-z0-9._-]`.
52 #[error("name may only contain ASCII letters, digits, '.', '_', or '-' (found {0:?})")]
53 BadChar(char),
54 /// The name was `.` or `..`.
55 #[error("name must not be \".\" or \"..\"")]
56 DotName,
57 /// The name ended in `.git`, which would be ambiguous with a bare repo path.
58 #[error("name must not end in \".git\"")]
59 GitSuffix,
60 /// The name ended in `.`.
61 #[error("name must not end in \".\"")]
62 DotSuffix,
63 /// The name matched a reserved word (case-insensitively).
64 #[error("name {0:?} is reserved")]
65 Reserved(String),
66}
67
68/// Validate a user, repository, or group name against every rule in this module.
69///
70/// # Errors
71///
72/// Returns the first [`NameError`] that applies. Checks run in a fixed order
73/// (emptiness, dot-names, length, character set, suffixes, reserved words) so a
74/// given input always yields the same error.
75pub fn validate_name(name: &str) -> Result<(), NameError> {
76 if name.is_empty() {
77 return Err(NameError::Empty);
78 }
79 if name == "." || name == ".." {
80 return Err(NameError::DotName);
81 }
82 // ASCII-only past this point is guaranteed by the character checks below, so
83 // counting chars is equivalent to counting bytes for any name that could
84 // pass — but count chars so an over-long multibyte input reports TooLong
85 // rather than a confusing character error.
86 if name.chars().count() > MAX_NAME_LEN {
87 return Err(NameError::TooLong);
88 }
89
90 let mut chars = name.chars();
91 // `name` is non-empty, so `next` yields.
92 let first = chars.next().unwrap_or('\0');
93 if !first.is_ascii_alphanumeric() {
94 return Err(NameError::BadStart);
95 }
96 for c in chars {
97 if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) {
98 return Err(NameError::BadChar(c));
99 }
100 }
101
102 // Suffix rules. The character checks above guarantee `name` is pure ASCII
103 // here, so byte-slicing for the `.git` comparison lands on a char boundary.
104 if name.ends_with('.') {
105 return Err(NameError::DotSuffix);
106 }
107 if name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(".git") {
108 return Err(NameError::GitSuffix);
109 }
110
111 let lower = name.to_ascii_lowercase();
112 if RESERVED_NAMES.contains(&lower.as_str()) {
113 return Err(NameError::Reserved(name.to_string()));
114 }
115
116 Ok(())
117}
118
119#[cfg(test)]
120mod tests {
121 #![allow(clippy::unwrap_used, clippy::panic)]
122
123 use super::*;
124
125 #[test]
126 fn accepts_ordinary_names() {
127 for name in [
128 "a", "A", "0", "hanna", "fabrica", "my-repo", "my_repo", "v1.2.3", "a.b_c-d", "Repo123",
129 ] {
130 assert_eq!(validate_name(name), Ok(()), "should accept {name:?}");
131 }
132 }
133
134 #[test]
135 fn rejects_empty() {
136 assert_eq!(validate_name(""), Err(NameError::Empty));
137 }
138
139 #[test]
140 fn rejects_dot_names() {
141 assert_eq!(validate_name("."), Err(NameError::DotName));
142 assert_eq!(validate_name(".."), Err(NameError::DotName));
143 }
144
145 #[test]
146 fn enforces_length_boundary() {
147 let ok = "a".repeat(MAX_NAME_LEN);
148 assert_eq!(
149 validate_name(&ok),
150 Ok(()),
151 "{MAX_NAME_LEN} chars is allowed"
152 );
153 let too_long = "a".repeat(MAX_NAME_LEN + 1);
154 assert_eq!(validate_name(&too_long), Err(NameError::TooLong));
155 }
156
157 #[test]
158 fn rejects_bad_start() {
159 for name in ["_foo", "-foo", ".foo", "!foo"] {
160 assert_eq!(validate_name(name), Err(NameError::BadStart), "{name:?}");
161 }
162 }
163
164 #[test]
165 fn rejects_bad_chars() {
166 assert_eq!(validate_name("a b"), Err(NameError::BadChar(' ')));
167 assert_eq!(validate_name("a/b"), Err(NameError::BadChar('/')));
168 assert_eq!(validate_name("a@b"), Err(NameError::BadChar('@')));
169 }
170
171 #[test]
172 fn rejects_non_ascii() {
173 // A leading non-ASCII letter fails the start check; an interior one fails
174 // the character check.
175 assert_eq!(validate_name("café"), Err(NameError::BadChar('é')));
176 assert!(matches!(
177 validate_name("ünsen"),
178 Err(NameError::BadStart | NameError::BadChar(_))
179 ));
180 }
181
182 #[test]
183 fn rejects_git_suffix_case_insensitively() {
184 for name in ["repo.git", "repo.GIT", "repo.Git", "x.git"] {
185 assert_eq!(validate_name(name), Err(NameError::GitSuffix), "{name:?}");
186 }
187 // `.git` embedded but not trailing is fine.
188 assert_eq!(validate_name("repo.git.bak"), Ok(()));
189 }
190
191 #[test]
192 fn rejects_trailing_dot() {
193 assert_eq!(validate_name("repo."), Err(NameError::DotSuffix));
194 }
195
196 #[test]
197 fn rejects_reserved_case_insensitively() {
198 for name in ["admin", "Admin", "ADMIN", "api", "settings", "healthz"] {
199 assert!(
200 matches!(validate_name(name), Err(NameError::Reserved(_))),
201 "{name:?} should be reserved"
202 );
203 }
204 // A reserved word as a substring is fine.
205 assert_eq!(validate_name("administrator"), Ok(()));
206 }
207}