Skip to content
217 linesCodeBlameRaw
1/// The site's own routes: first path segments that are never a workspace's,
2/// so nobody may register them, and no alias can be reached at them.
3const ROUTES: &[&str] = &[
4 "api",
5 "mcp",
6 "login",
7 "logout",
8 "register",
9 "new",
10 "settings",
11 "search",
12 "admin",
13 "auth",
14 // g1t.sh/integrations/github/setup: where GitHub returns after an install.
15 "integrations",
16 "pulls",
17 "issues",
18 "oauth",
19 "assets",
20 "avatars",
21 "docs",
22 "explore",
23 "about",
24 "pricing",
25 "terms",
26 "privacy",
27 "policies",
28 "security",
29 "help",
30 "support",
31 "status",
32 "blog",
33 "verify",
34 "confirm-email",
35 "forgot",
36 "reset",
37 "device",
38 "workspaces",
39 "u",
40 // g1t.sh/invite/<code>: invite links; and the waitlist.
41 "invite",
42 "invites",
43 "waitlist",
44 // g1t.sh/invitations: the workspace invitations waiting for an answer.
45 "invitations",
46 // g1t.sh/v2/: the container registry; `-` paths hold the others.
47 "v2",
48 // g1t.sh/inbox, and the name it might also go by.
49 "inbox",
50 "notifications",
51];
52
53/// g1t itself, the name its agent once went by, and `ghost`, who wrote what
54/// a deleted account wrote (`account_deletion::GHOST_USERNAME`): everything
55/// g1t does is shown as `g1t`, so nobody else may be called any of these.
56/// Not routes: staff may point one at a workspace as an alias (identity's
57/// `aliases.rs`).
58const OWN: &[&str] = &["g1t", "g1t-agent", "ghost"];
59
60/// Whether `value`, whatever its case, is a name nobody can register or
61/// rename a workspace to: a route, or g1t's own.
62pub fn is_reserved_name(value: &str) -> bool {
63 is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim()))
64}
65
66/// Whether `value`, whatever its case, is one of the site's own routes.
67pub fn is_route_name(value: &str) -> bool {
68 let value = value.trim();
69 ROUTES.iter().any(|route| route.eq_ignore_ascii_case(value))
70}
71
72/// Whether `value` has a namespace's shape: letters, digits and single
73/// hyphens, not starting or ending with a hyphen, at most 39 characters.
74/// Reserved names have it too; see [`is_valid_namespace`].
75pub fn is_namespace_shaped(value: &str) -> bool {
76 let bytes = value.as_bytes();
77 !bytes.is_empty()
78 && bytes.len() <= 39
79 && bytes
80 .iter()
81 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
82 && !value.starts_with('-')
83 && !value.ends_with('-')
84 && !value.contains("--")
85}
86
87/// A workspace alias as staff typed it, trimmed and lowercased, if it can
88/// be one: shaped like a namespace and not one of the site's routes, which
89/// would always answer first. Reserved names such as `g1t` can be; whether
90/// a person or workspace already has it is identity's to check.
91pub fn aliasable_name(value: &str) -> Option<String> {
92 let value = value.trim().to_lowercase();
93 (is_namespace_shaped(&value) && !is_route_name(&value)).then_some(value)
94}
95
96/// A username or workspace slug as someone typed it, trimmed and
97/// lowercased, if it can be registered: what signing up, signing up with
98/// GitHub and creating a workspace each take. None when it is malformed or
99/// reserved.
100pub fn claimable_namespace(value: &str) -> Option<String> {
101 let value = value.trim().to_lowercase();
102 is_valid_namespace(&value).then_some(value)
103}
104
105/// A username as someone typed it, if it can be registered: the name
106/// everything finds them by (`canonical`, lowercased: URLs, lookups,
107/// mentions, git) and the name as they wrote it (`display`, trimmed, its
108/// case kept). Letters of either case, digits and single hyphens, not
109/// starting or ending with a hyphen, 1 to 39 characters, never reserved
110/// in any case. ASCII only, so no other letter lowercases into one.
111pub fn claimable_username(value: &str) -> Option<Username> {
112 let display = value.trim();
113 if !display.is_ascii() {
114 return None;
115 }
116 let canonical = display.to_ascii_lowercase();
117 is_valid_namespace(&canonical).then(|| Username { canonical, display: display.to_owned() })
118}
119
120/// A username in its two forms: see [`claimable_username`].
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct Username {
123 /// Lowercased: what it is stored, found, linked and mentioned by.
124 pub canonical: String,
125 /// As its owner chose to write it: what it is shown as.
126 pub display: String,
127}
128
129impl Username {
130 /// The display form, when it differs from the canonical one: what is
131 /// kept beside it (`users.display_username`); none means the same.
132 pub fn display_if_cased(&self) -> Option<&str> {
133 (self.display != self.canonical).then_some(self.display.as_str())
134 }
135}
136
137/// Namespaces follow GitHub's rules: letters, digits and single hyphens,
138/// not starting or ending with a hyphen, at most 39 characters.
139pub fn is_valid_namespace(value: &str) -> bool {
140 is_namespace_shaped(value) && !is_reserved_name(value)
141}
142
143pub fn is_valid_repo_name(value: &str) -> bool {
144 !value.is_empty()
145 && value.len() <= 100
146 && value.bytes().all(|byte| {
147 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
148 })
149 && !value.starts_with('.')
150 && !value.ends_with(".git")
151 // `/<workspace>/-/…` holds the workspace's own pages.
152 && value != "-"
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn g1ts_own_names_are_reserved_in_any_case() {
161 for name in ["g1t", "G1T", "g1t-agent", "G1T-Agent", " g1t "] {
162 assert!(is_reserved_name(name), "{name}");
163 assert_eq!(claimable_namespace(name), None, "{name}");
164 }
165 assert!(!is_valid_namespace("g1t"));
166 assert!(!is_valid_namespace("g1t-agent"));
167 assert!(!is_valid_namespace("ghost"));
168 assert_eq!(claimable_namespace(" Ghost "), None);
169 }
170
171 #[test]
172 fn names_near_g1ts_are_anyones() {
173 assert!(!is_reserved_name("g1t-agents"));
174 assert_eq!(claimable_namespace("G1T-Bot").as_deref(), Some("g1t-bot"));
175 assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana"));
176 assert_eq!(claimable_namespace("an--a"), None);
177 }
178
179 #[test]
180 fn usernames_keep_their_case_and_are_found_without_it() {
181 let name = claimable_username(" Octo-Cat ").unwrap();
182 assert_eq!(name.canonical, "octo-cat");
183 assert_eq!(name.display, "Octo-Cat");
184 assert_eq!(name.display_if_cased(), Some("Octo-Cat"));
185 let plain = claimable_username("ana").unwrap();
186 assert_eq!(plain.display_if_cased(), None);
187 assert_eq!(claimable_username("A").unwrap().canonical, "a");
188 assert_eq!(claimable_username(&"Z".repeat(39)).unwrap().display.len(), 39);
189 }
190
191 #[test]
192 fn usernames_follow_the_common_rules_in_any_case() {
193 let long = "a".repeat(40);
194 for bad in ["", " ", "-Ana", "Ana-", "An--a", "An_a", "Ana.B", "Ana B", long.as_str()] {
195 assert_eq!(claimable_username(bad), None, "{bad:?}");
196 }
197 // Reserved whatever the case: routes and g1t's own.
198 for reserved in ["G1T", "G1t-Agent", "Ghost", "Settings", "API", "U"] {
199 assert_eq!(claimable_username(reserved), None, "{reserved}");
200 }
201 // A letter that lowercases into ASCII is not one: the Kelvin sign is not K.
202 assert_eq!(claimable_username("\u{212A}elvin"), None);
203 assert_eq!(claimable_username("Ana\u{0301}"), None);
204 }
205
206 #[test]
207 fn g1t_can_be_an_alias_but_a_route_cannot() {
208 assert_eq!(aliasable_name(" G1T ").as_deref(), Some("g1t"));
209 assert_eq!(aliasable_name("acme-corp").as_deref(), Some("acme-corp"));
210 for name in ["settings", "api", "login", "Explore", "-acme", "ac--me", "acme_inc", "", "a.b"] {
211 assert_eq!(aliasable_name(name), None, "{name}");
212 }
213 assert_eq!(aliasable_name(&"a".repeat(40)), None);
214 // Still nobody's to register.
215 assert!(is_reserved_name("g1t") && !is_route_name("g1t"));
216 }
217}