Skip to content
156 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/v2/: the container registry; `-` paths hold the others.
45 "v2",
46 // g1t.sh/inbox, and the name it might also go by.
47 "inbox",
48 "notifications",
49];
50
51/// g1t itself, the name its agent once went by, and `ghost`, who wrote what
52/// a deleted account wrote (`account_deletion::GHOST_USERNAME`): everything
53/// g1t does is shown as `g1t`, so nobody else may be called any of these.
54/// Not routes: staff may point one at a workspace as an alias (identity's
55/// `aliases.rs`).
56const OWN: &[&str] = &["g1t", "g1t-agent", "ghost"];
57
58/// Whether `value`, whatever its case, is a name nobody can register or
59/// rename a workspace to: a route, or g1t's own.
60pub fn is_reserved_name(value: &str) -> bool {
61 is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim()))
62}
63
64/// Whether `value`, whatever its case, is one of the site's own routes.
65pub fn is_route_name(value: &str) -> bool {
66 let value = value.trim();
67 ROUTES.iter().any(|route| route.eq_ignore_ascii_case(value))
68}
69
70/// Whether `value` has a namespace's shape: letters, digits and single
71/// hyphens, not starting or ending with a hyphen, at most 39 characters.
72/// Reserved names have it too; see [`is_valid_namespace`].
73pub fn is_namespace_shaped(value: &str) -> bool {
74 let bytes = value.as_bytes();
75 !bytes.is_empty()
76 && bytes.len() <= 39
77 && bytes
78 .iter()
79 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
80 && !value.starts_with('-')
81 && !value.ends_with('-')
82 && !value.contains("--")
83}
84
85/// A workspace alias as staff typed it, trimmed and lowercased, if it can
86/// be one: shaped like a namespace and not one of the site's routes, which
87/// would always answer first. Reserved names such as `g1t` can be; whether
88/// a person or workspace already has it is identity's to check.
89pub fn aliasable_name(value: &str) -> Option<String> {
90 let value = value.trim().to_lowercase();
91 (is_namespace_shaped(&value) && !is_route_name(&value)).then_some(value)
92}
93
94/// A username or workspace slug as someone typed it, trimmed and
95/// lowercased, if it can be registered: what signing up, signing up with
96/// GitHub and creating a workspace each take. None when it is malformed or
97/// reserved.
98pub fn claimable_namespace(value: &str) -> Option<String> {
99 let value = value.trim().to_lowercase();
100 is_valid_namespace(&value).then_some(value)
101}
102
103/// Namespaces follow GitHub's rules: letters, digits and single hyphens,
104/// not starting or ending with a hyphen, at most 39 characters.
105pub fn is_valid_namespace(value: &str) -> bool {
106 is_namespace_shaped(value) && !is_reserved_name(value)
107}
108
109pub fn is_valid_repo_name(value: &str) -> bool {
110 !value.is_empty()
111 && value.len() <= 100
112 && value.bytes().all(|byte| {
113 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
114 })
115 && !value.starts_with('.')
116 && !value.ends_with(".git")
117 // `/<workspace>/-/…` holds the workspace's own pages.
118 && value != "-"
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn g1ts_own_names_are_reserved_in_any_case() {
127 for name in ["g1t", "G1T", "g1t-agent", "G1T-Agent", " g1t "] {
128 assert!(is_reserved_name(name), "{name}");
129 assert_eq!(claimable_namespace(name), None, "{name}");
130 }
131 assert!(!is_valid_namespace("g1t"));
132 assert!(!is_valid_namespace("g1t-agent"));
133 assert!(!is_valid_namespace("ghost"));
134 assert_eq!(claimable_namespace(" Ghost "), None);
135 }
136
137 #[test]
138 fn names_near_g1ts_are_anyones() {
139 assert!(!is_reserved_name("g1t-agents"));
140 assert_eq!(claimable_namespace("G1T-Bot").as_deref(), Some("g1t-bot"));
141 assert_eq!(claimable_namespace(" Ana ").as_deref(), Some("ana"));
142 assert_eq!(claimable_namespace("an--a"), None);
143 }
144
145 #[test]
146 fn g1t_can_be_an_alias_but_a_route_cannot() {
147 assert_eq!(aliasable_name(" G1T ").as_deref(), Some("g1t"));
148 assert_eq!(aliasable_name("acme-corp").as_deref(), Some("acme-corp"));
149 for name in ["settings", "api", "login", "Explore", "-acme", "ac--me", "acme_inc", "", "a.b"] {
150 assert_eq!(aliasable_name(name), None, "{name}");
151 }
152 assert_eq!(aliasable_name(&"a".repeat(40)), None);
153 // Still nobody's to register.
154 assert!(is_reserved_name("g1t") && !is_route_name("g1t"));
155 }
156}