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