Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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