Skip to content
158 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/// Namespaces follow GitHub's rules: letters, digits and single hyphens,
106/// not starting or ending with a hyphen, at most 39 characters.
107pub fn is_valid_namespace(value: &str) -> bool {
108 is_namespace_shaped(value) && !is_reserved_name(value)
109}
110
111pub fn is_valid_repo_name(value: &str) -> bool {
112 !value.is_empty()
113 && value.len() <= 100
114 && value.bytes().all(|byte| {
115 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
116 })
117 && !value.starts_with('.')
118 && !value.ends_with(".git")
119 // `/<workspace>/-/…` holds the workspace's own pages.
120 && value != "-"
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn g1ts_own_names_are_reserved_in_any_case() {
129 for name in ["g1t", "G1T", "g1t-agent", "G1T-Agent", " g1t "] {
130 assert!(is_reserved_name(name), "{name}");
131 assert_eq!(claimable_namespace(name), None, "{name}");
132 }
133 assert!(!is_valid_namespace("g1t"));
134 assert!(!is_valid_namespace("g1t-agent"));
135 assert!(!is_valid_namespace("ghost"));
136 assert_eq!(claimable_namespace(" Ghost "), None);
137 }
138
139 #[test]
140 fn names_near_g1ts_are_anyones() {
141 assert!(!is_reserved_name("g1t-agents"));
142 assert_eq!(claimable_namespace("G1T-Bot").as_deref(), Some("g1t-bot"));
143 assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana"));
144 assert_eq!(claimable_namespace("an--a"), None);
145 }
146
147 #[test]
148 fn g1t_can_be_an_alias_but_a_route_cannot() {
149 assert_eq!(aliasable_name(" G1T ").as_deref(), Some("g1t"));
150 assert_eq!(aliasable_name("acme-corp").as_deref(), Some("acme-corp"));
151 for name in ["settings", "api", "login", "Explore", "-acme", "ac--me", "acme_inc", "", "a.b"] {
152 assert_eq!(aliasable_name(name), None, "{name}");
153 }
154 assert_eq!(aliasable_name(&"a".repeat(40)), None);
155 // Still nobody's to register.
156 assert!(is_reserved_name("g1t") && !is_route_name("g1t"));
157 }
158}