steid

@jamesgill /

8.3 KBCode·Blame·Raw
1use std::fmt;
2
3use super::{DomainError, OrgId};
4
5/// An organisation handle — the `{owner}` segment of every URL, and unique across the
6/// installation.
7///
8/// Rules follow GitHub's: 1–39 characters, ASCII alphanumeric or hyphen, no leading or
9/// trailing hyphen. Stored lowercase so lookups and URLs agree.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct OrgName(String);
12
13/// Handles that would collide with an application route.
14///
15/// Handles live at the root (`/{handle}`), so anything here would shadow a real route
16/// or be shadowed by one. Kept deliberately lean: routes are grouped under functional
17/// prefixes (`/auth/login`, not `/login`), so this grows per *area*, not per route.
18///
19/// **Reserve generously and early.** Adding an entry later is a breaking change for
20/// whoever already holds that handle — their account has to be renamed and their links
21/// break. Entries here cost nothing while unclaimed.
22///
23/// The test for adding one: could this ever be a *top-level* route? Under grouping,
24/// almost nothing is — a health check is `/api/health`, sign-up is `/auth/register` —
25/// so words that would live under a prefix do not belong here. GitHub reserves 590+
26/// because its namespace is flat; copying that list would import the cost without the
27/// cause.
28///
29/// Paths containing characters a handle cannot hold need no entry: `/_topcoat/`,
30/// `/.well-known/`, `robots.txt`, `favicon.ico` are all excluded by the rules above.
31const RESERVED: &[&str] = &[
32 "about",
33 "admin",
34 "api",
35 "assets",
36 "auth",
37 "dashboard",
38 "docs",
39 "explore",
40 "help",
41 "legal",
42 "notifications",
43 "privacy",
44 "search",
45 "security",
46 "settings",
47 "static",
48 "status",
49 // The project's own name, so nobody can hold it and impersonate the software.
50 "steid",
51 "support",
52 "terms",
53];
54
55impl OrgName {
56 pub const MAX_LEN: usize = 39;
57
58 /// Validates and normalises user-supplied input.
59 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
60 let value = value.into();
61 let trimmed = value.trim();
62
63 let invalid = |reason: &str| DomainError::validation("name", reason);
64
65 if trimmed.is_empty() {
66 return Err(invalid("must not be empty"));
67 }
68 if trimmed.chars().count() > Self::MAX_LEN {
69 return Err(invalid("must be at most 39 characters"));
70 }
71 if !trimmed
72 .chars()
73 .all(|c| c.is_ascii_alphanumeric() || c == '-')
74 {
75 return Err(invalid("may only contain letters, digits, and hyphens"));
76 }
77 if trimmed.starts_with('-') || trimmed.ends_with('-') {
78 return Err(invalid("must not start or end with a hyphen"));
79 }
80
81 let normalised = trimmed.to_lowercase();
82
83 // Checked against the normalised form: `API` and `api` are the same handle.
84 if RESERVED.contains(&normalised.as_str()) {
85 return Err(invalid("is reserved"));
86 }
87
88 Ok(Self(normalised))
89 }
90
91 /// Wraps a value already validated on the way into the database.
92 pub fn from_trusted(value: impl Into<String>) -> Self {
93 Self(value.into())
94 }
95
96 pub fn as_str(&self) -> &str {
97 &self.0
98 }
99}
100
101impl fmt::Display for OrgName {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str(&self.0)
104 }
105}
106
107/// An organisation. Every user gets a personal one at registration, and repositories
108/// and content hang off organisations rather than users.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Organization {
111 pub id: OrgId,
112 pub name: OrgName,
113 /// Free-form label shown in the UI. Falls back to `name` when unset.
114 pub display_name: Option<String>,
115}
116
117impl Organization {
118 /// Creates an organisation from user-supplied input.
119 pub fn new(
120 id: OrgId,
121 name: impl Into<String>,
122 display_name: Option<String>,
123 ) -> Result<Self, DomainError> {
124 Ok(Self {
125 id,
126 name: OrgName::new(name)?,
127 display_name: display_name.filter(|value| !value.trim().is_empty()),
128 })
129 }
130
131 /// Reassembles an organisation from storage, skipping validation.
132 pub fn from_trusted(id: OrgId, name: OrgName, display_name: Option<String>) -> Self {
133 Self {
134 id,
135 name,
136 display_name,
137 }
138 }
139
140 /// The label to show in the UI.
141 pub fn label(&self) -> &str {
142 self.display_name
143 .as_deref()
144 .unwrap_or_else(|| self.name.as_str())
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn accepts_ordinary_handles() {
154 for input in ["james", "acme", "a", "my-org", "org2026", "a-b-c"] {
155 assert!(
156 OrgName::new(input).is_ok(),
157 "expected {input:?} to be accepted"
158 );
159 }
160 }
161
162 #[test]
163 fn normalises_case_and_whitespace() {
164 let name = OrgName::new(" JamesGill ").expect("should be valid");
165
166 assert_eq!(name.as_str(), "jamesgill");
167 }
168
169 #[test]
170 fn rejects_malformed_handles() {
171 for input in [
172 "",
173 " ",
174 "-leading",
175 "trailing-",
176 "has space",
177 "under_score",
178 "dot.dot",
179 "slash/slash",
180 "emoji🦀",
181 &"a".repeat(40),
182 ] {
183 assert!(
184 OrgName::new(input).is_err(),
185 "expected {input:?} to be rejected"
186 );
187 }
188 }
189
190 #[test]
191 fn accepts_a_handle_at_the_length_limit() {
192 assert!(OrgName::new("a".repeat(OrgName::MAX_LEN)).is_ok());
193 }
194
195 #[test]
196 fn rejects_reserved_handles() {
197 for reserved in RESERVED {
198 assert!(
199 OrgName::new(*reserved).is_err(),
200 "expected {reserved:?} to be reserved"
201 );
202 }
203 }
204
205 #[test]
206 fn reservation_ignores_case() {
207 // Handles normalise to lowercase, so a differently-cased reserved word is the
208 // same handle and must be refused too.
209 assert!(OrgName::new("API").is_err());
210 assert!(OrgName::new("Auth").is_err());
211 }
212
213 #[test]
214 fn reservation_does_not_leak_into_substrings() {
215 // Only whole handles are reserved. `apidocs` shadows nothing.
216 for allowed in [
217 "apidocs",
218 "authors",
219 "administrator",
220 "helpful",
221 "settings-app",
222 ] {
223 assert!(
224 OrgName::new(allowed).is_ok(),
225 "expected {allowed:?} to be allowed"
226 );
227 }
228 }
229
230 #[test]
231 fn the_reserved_list_is_sorted_and_unique() {
232 // Sorted so additions are easy to review; unique so a duplicate doesn't hide a
233 // typo'd entry that reserves nothing.
234 let mut sorted = RESERVED.to_vec();
235 sorted.sort_unstable();
236 sorted.dedup();
237
238 assert_eq!(
239 sorted.as_slice(),
240 RESERVED,
241 "keep RESERVED sorted and unique"
242 );
243 }
244
245 #[test]
246 fn reserved_entries_are_themselves_valid_handle_shapes() {
247 // A reserved word that could never be a handle anyway is dead weight and
248 // suggests a misunderstanding of what the list is for.
249 for reserved in RESERVED {
250 assert!(
251 !reserved.is_empty()
252 && reserved.len() <= OrgName::MAX_LEN
253 && reserved.chars().all(|c| c.is_ascii_lowercase())
254 && !reserved.starts_with('-')
255 && !reserved.ends_with('-'),
256 "{reserved:?} could never be a handle, so reserving it is pointless"
257 );
258 }
259 }
260
261 #[test]
262 fn label_falls_back_to_the_handle() {
263 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
264
265 assert_eq!(org.label(), "acme");
266 }
267
268 #[test]
269 fn label_prefers_the_display_name() {
270 let org =
271 Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned())).expect("valid");
272
273 assert_eq!(org.label(), "Acme");
274 }
275
276 #[test]
277 fn blank_display_names_are_treated_as_unset() {
278 let org =
279 Organization::new(OrgId::generate(), "acme", Some(" ".to_owned())).expect("valid");
280
281 assert_eq!(org.display_name, None);
282 assert_eq!(org.label(), "acme");
283 }
284}