Skip to content
273 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/// The GitHub accounts whose actions workflows use most. A workflow's
61/// `uses: actions/setup-node@v4` means GitHub's `actions`, so nobody may
62/// register these on g1t and be mistaken for them. They are not reserved
63/// as [`is_reserved_name`] is: an account that already has one keeps it,
64/// mentions and invitations still find it, and staff can give one, as an
65/// alias, to the account that proves it owns the name on GitHub.
66const ACTION_OWNERS: &[&str] = &[
67 "actions", "actions-ecosystem", "actions-rs", "amannn", "anchore", "appleboy", "aquasecurity",
68 "arduino", "astral-sh", "aws-actions", "azure", "browser-actions", "bufbuild", "cachix",
69 "changesets", "chromaui", "cloudflare", "codecov", "conda-incubator", "coursier", "crazy-max",
70 "cypress-io", "dawidd6", "denoland", "dependabot", "dessant", "determinatesystems",
71 "devcontainers", "digitalocean", "docker", "dorny", "dtolnay", "endbug", "erlef",
72 "firebaseextended", "fluxcd", "getsentry", "github", "golangci", "google-github-actions",
73 "googleapis", "goreleaser", "gradle", "gradle-update", "hadolint", "hashicorp",
74 "haskell-actions", "hecrj", "helm", "ilammy", "jamesives", "jetbrains", "julia-actions",
75 "kubernetes-sigs", "lukka", "marocchino", "microsoft", "mikepenz", "mozilla-actions", "msys2",
76 "mxschmitt", "ncipollo", "netlify", "nick-fields", "nwtgck", "octokit", "ossf", "oven-sh",
77 "peaceiris", "peter-evans", "pnpm", "pre-commit", "pypa", "r-lib", "release-drafter",
78 "renovatebot", "reviewdog", "rhysd", "ruby", "rust-lang", "sbt", "shivammathur", "sigstore",
79 "slackapi", "slsa-framework", "snyk", "softprops", "sonarsource", "stefanzweifel",
80 "step-security", "styfle", "subosito", "supabase", "super-linter", "superfly", "svenstaro",
81 "swatinem", "taiki-e", "tj-actions", "treosh", "vercel", "webfactory",
82];
83
84/// Whether `value`, whatever its case, is a name nobody can register or
85/// rename a workspace to: a route, or g1t's own.
86pub fn is_reserved_name(value: &str) -> bool {
87 is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim()))
88}
89
90/// Whether `value`, whatever its case, names one of the GitHub accounts
91/// whose actions workflows use most: see [`ACTION_OWNERS`].
92pub fn is_action_owner_name(value: &str) -> bool {
93 ACTION_OWNERS.iter().any(|owner| owner.eq_ignore_ascii_case(value.trim()))
94}
95
96/// Whether a person or workspace may take `value` as its name now, by
97/// signing up, creating a workspace or renaming one: a valid namespace
98/// that no well-known GitHub action owner goes by.
99pub fn is_claimable_name(value: &str) -> bool {
100 is_valid_namespace(value) && !is_action_owner_name(value)
101}
102
103/// Whether `value`, whatever its case, is one of the site's own routes.
104pub fn is_route_name(value: &str) -> bool {
105 let value = value.trim();
106 ROUTES.iter().any(|route| route.eq_ignore_ascii_case(value))
107}
108
109/// Whether `value` has a namespace's shape: letters, digits and single
110/// hyphens, not starting or ending with a hyphen, at most 39 characters.
111/// Reserved names have it too; see [`is_valid_namespace`].
112pub fn is_namespace_shaped(value: &str) -> bool {
113 let bytes = value.as_bytes();
114 !bytes.is_empty()
115 && bytes.len() <= 39
116 && bytes
117 .iter()
118 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
119 && !value.starts_with('-')
120 && !value.ends_with('-')
121 && !value.contains("--")
122}
123
124/// A workspace alias as staff typed it, trimmed and lowercased, if it can
125/// be one: shaped like a namespace and not one of the site's routes, which
126/// would always answer first. Reserved names such as `g1t` can be; whether
127/// a person or workspace already has it is identity's to check.
128pub fn aliasable_name(value: &str) -> Option<String> {
129 let value = value.trim().to_lowercase();
130 (is_namespace_shaped(&value) && !is_route_name(&value)).then_some(value)
131}
132
133/// A username or workspace slug as someone typed it, trimmed and
134/// lowercased, if it can be registered: what signing up, signing up with
135/// GitHub and creating a workspace each take. None when it is malformed or
136/// reserved.
137pub fn claimable_namespace(value: &str) -> Option<String> {
138 let value = value.trim().to_lowercase();
139 is_claimable_name(&value).then_some(value)
140}
141
142/// A username as someone typed it, if it can be registered: the name
143/// everything finds them by (`canonical`, lowercased: URLs, lookups,
144/// mentions, git) and the name as they wrote it (`display`, trimmed, its
145/// case kept). Letters of either case, digits and single hyphens, not
146/// starting or ending with a hyphen, 1 to 39 characters, never reserved
147/// in any case. ASCII only, so no other letter lowercases into one.
148pub fn claimable_username(value: &str) -> Option<Username> {
149 let display = value.trim();
150 if !display.is_ascii() {
151 return None;
152 }
153 let canonical = display.to_ascii_lowercase();
154 is_claimable_name(&canonical).then(|| Username { canonical, display: display.to_owned() })
155}
156
157/// A username in its two forms: see [`claimable_username`].
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct Username {
160 /// Lowercased: what it is stored, found, linked and mentioned by.
161 pub canonical: String,
162 /// As its owner chose to write it: what it is shown as.
163 pub display: String,
164}
165
166impl Username {
167 /// The display form, when it differs from the canonical one: what is
168 /// kept beside it (`users.display_username`); none means the same.
169 pub fn display_if_cased(&self) -> Option<&str> {
170 (self.display != self.canonical).then_some(self.display.as_str())
171 }
172}
173
174/// Namespaces follow GitHub's rules: letters, digits and single hyphens,
175/// not starting or ending with a hyphen, at most 39 characters.
176pub fn is_valid_namespace(value: &str) -> bool {
177 is_namespace_shaped(value) && !is_reserved_name(value)
178}
179
180pub fn is_valid_repo_name(value: &str) -> bool {
181 !value.is_empty()
182 && value.len() <= 100
183 && value.bytes().all(|byte| {
184 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
185 })
186 && !value.starts_with('.')
187 && !value.ends_with(".git")
188 // `/<workspace>/-/…` holds the workspace's own pages.
189 && value != "-"
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn g1ts_own_names_are_reserved_in_any_case() {
198 for name in ["g1t", "G1T", "g1t-agent", "G1T-Agent", " g1t "] {
199 assert!(is_reserved_name(name), "{name}");
200 assert_eq!(claimable_namespace(name), None, "{name}");
201 }
202 assert!(!is_valid_namespace("g1t"));
203 assert!(!is_valid_namespace("g1t-agent"));
204 assert!(!is_valid_namespace("ghost"));
205 assert_eq!(claimable_namespace(" Ghost "), None);
206 }
207
208 #[test]
209 fn github_action_owners_cannot_be_claimed_but_stay_findable() {
210 for name in ["actions", "Actions", " docker ", "aws-actions", "Swatinem"] {
211 assert!(is_action_owner_name(name), "{name}");
212 assert_eq!(claimable_namespace(name), None, "{name}");
213 assert_eq!(claimable_username(name), None, "{name}");
214 }
215 // An account that has one already is still a valid name to find,
216 // mention and invite, can be given one as an alias, and is not
217 // protected the way g1t's own names are.
218 assert!(is_valid_namespace("actions"));
219 assert!(!is_reserved_name("actions"));
220 assert_eq!(aliasable_name("Actions").as_deref(), Some("actions"));
221 assert!(!is_action_owner_name("actions-team"));
222 for owner in ACTION_OWNERS {
223 assert!(is_namespace_shaped(owner) && *owner == owner.to_ascii_lowercase(), "{owner}");
224 }
225 }
226
227 #[test]
228 fn names_near_g1ts_are_anyones() {
229 assert!(!is_reserved_name("g1t-agents"));
230 assert_eq!(claimable_namespace("G1T-Bot").as_deref(), Some("g1t-bot"));
231 assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana"));
232 assert_eq!(claimable_namespace("an--a"), None);
233 }
234
235 #[test]
236 fn usernames_keep_their_case_and_are_found_without_it() {
237 let name = claimable_username(" Octo-Cat ").unwrap();
238 assert_eq!(name.canonical, "octo-cat");
239 assert_eq!(name.display, "Octo-Cat");
240 assert_eq!(name.display_if_cased(), Some("Octo-Cat"));
241 let plain = claimable_username("ana").unwrap();
242 assert_eq!(plain.display_if_cased(), None);
243 assert_eq!(claimable_username("A").unwrap().canonical, "a");
244 assert_eq!(claimable_username(&"Z".repeat(39)).unwrap().display.len(), 39);
245 }
246
247 #[test]
248 fn usernames_follow_the_common_rules_in_any_case() {
249 let long = "a".repeat(40);
250 for bad in ["", " ", "-Ana", "Ana-", "An--a", "An_a", "Ana.B", "Ana B", long.as_str()] {
251 assert_eq!(claimable_username(bad), None, "{bad:?}");
252 }
253 // Reserved whatever the case: routes and g1t's own.
254 for reserved in ["G1T", "G1t-Agent", "Ghost", "Settings", "API", "U"] {
255 assert_eq!(claimable_username(reserved), None, "{reserved}");
256 }
257 // A letter that lowercases into ASCII is not one: the Kelvin sign is not K.
258 assert_eq!(claimable_username("\u{212A}elvin"), None);
259 assert_eq!(claimable_username("Ana\u{0301}"), None);
260 }
261
262 #[test]
263 fn g1t_can_be_an_alias_but_a_route_cannot() {
264 assert_eq!(aliasable_name(" G1T ").as_deref(), Some("g1t"));
265 assert_eq!(aliasable_name("acme-corp").as_deref(), Some("acme-corp"));
266 for name in ["settings", "api", "login", "Explore", "-acme", "ac--me", "acme_inc", "", "a.b"] {
267 assert_eq!(aliasable_name(name), None, "{name}");
268 }
269 assert_eq!(aliasable_name(&"a".repeat(40)), None);
270 // Still nobody's to register.
271 assert!(is_reserved_name("g1t") && !is_route_name("g1t"));
272 }
273}