1use std::error::Error;
2use std::fmt;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TokenKind {
7 Account,
8 AgentId,
9 SessionId,
10 RoomId,
11 ModuleId,
12 RosterHostId,
13 CredentialPublic,
14 RootProvider,
15 EventName,
17 EventVersion,
19}
20
21impl TokenKind {
22 pub const fn label(self) -> &'static str {
23 match self {
24 Self::Account => "acct",
25 Self::AgentId => "agent_id",
26 Self::SessionId => "session_id",
27 Self::RoomId => "room_id",
28 Self::ModuleId => "module_id",
29 Self::RosterHostId => "roster_host_id",
30 Self::CredentialPublic => "credential_public",
31 Self::RootProvider => "root_provider",
32 Self::EventName => "event",
33 Self::EventVersion => "event_version",
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct NamingError {
41 kind: TokenKind,
42 token: String,
43 reason: &'static str,
44}
45
46impl NamingError {
47 pub fn kind(&self) -> TokenKind {
48 self.kind
49 }
50
51 pub fn token(&self) -> &str {
52 &self.token
53 }
54
55 pub fn reason(&self) -> &'static str {
56 self.reason
57 }
58
59 fn new(kind: TokenKind, token: &str, reason: &'static str) -> Self {
60 Self {
61 kind,
62 token: token.to_owned(),
63 reason,
64 }
65 }
66}
67
68impl fmt::Display for NamingError {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 write!(
71 f,
72 "invalid {} token {:?}: {}",
73 self.kind.label(),
74 self.token,
75 self.reason
76 )
77 }
78}
79
80impl Error for NamingError {}
81
82pub fn validate_account_token(token: &str) -> Result<(), NamingError> {
84 validate_no_hyphen_token(
85 TokenKind::Account,
86 token,
87 "expected [a-z0-9][a-z0-9_]{0,62}; hyphens and normalization are forbidden",
88 )
89}
90
91fn validate_event_name(token: &str) -> Result<(), NamingError> {
98 validate_no_hyphen_token(
99 TokenKind::EventName,
100 token,
101 "expected [a-z0-9][a-z0-9_]{0,62}; an event name is one subject token, so dots, hyphens, wildcards and whitespace are forbidden",
102 )
103}
104
105pub fn validate_event_version(token: &str) -> Result<u32, NamingError> {
111 let refuse = || {
112 NamingError::new(
113 TokenKind::EventVersion,
114 token,
115 "expected v{n} with n a decimal integer of at least 1 and no leading zero",
116 )
117 };
118 let digits = token.strip_prefix('v').ok_or_else(refuse)?;
119 if digits.is_empty()
120 || digits.starts_with('0')
121 || !digits.bytes().all(|byte| byte.is_ascii_digit())
122 {
123 return Err(refuse());
124 }
125 digits.parse::<u32>().map_err(|_| refuse())
126}
127
128fn validate_no_hyphen_token(
130 kind: TokenKind,
131 token: &str,
132 reason: &'static str,
133) -> Result<(), NamingError> {
134 validate_len_and_first(kind, token)?;
135 if token
136 .bytes()
137 .skip(1)
138 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
139 {
140 Ok(())
141 } else {
142 Err(NamingError::new(kind, token, reason))
143 }
144}
145
146pub fn validate_token(kind: TokenKind, token: &str) -> Result<(), NamingError> {
153 match kind {
154 TokenKind::Account => return validate_account_token(token),
155 TokenKind::CredentialPublic => return validate_credential_public(token),
156 TokenKind::EventName => return validate_event_name(token),
157 TokenKind::EventVersion => return validate_event_version(token).map(|_| ()),
158 _ => {}
159 }
160
161 validate_len_and_first(kind, token)?;
162 if token.bytes().skip(1).all(|byte| {
163 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-'
164 }) {
165 Ok(())
166 } else {
167 Err(NamingError::new(
168 kind,
169 token,
170 "expected [a-z0-9][a-z0-9_-]{0,62}; dots, wildcards, whitespace, and normalization are forbidden",
171 ))
172 }
173}
174
175fn validate_len_and_first(kind: TokenKind, token: &str) -> Result<(), NamingError> {
176 if token.is_empty() || token.len() > 63 {
177 return Err(NamingError::new(
178 kind,
179 token,
180 "token must contain between 1 and 63 ASCII bytes",
181 ));
182 }
183 let first = token.as_bytes()[0];
184 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
185 return Err(NamingError::new(
186 kind,
187 token,
188 "token must start with a lowercase ASCII letter or digit",
189 ));
190 }
191 Ok(())
192}
193
194fn validate_credential_public(token: &str) -> Result<(), NamingError> {
195 if token.is_empty() || token.len() > 63 {
196 return Err(NamingError::new(
197 TokenKind::CredentialPublic,
198 token,
199 "inbox component must contain between 1 and 63 ASCII bytes",
200 ));
201 }
202 if token
203 .bytes()
204 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
205 {
206 Ok(())
207 } else {
208 Err(NamingError::new(
209 TokenKind::CredentialPublic,
210 token,
211 "inbox component may contain only ASCII letters, digits, underscore, or hyphen",
212 ))
213 }
214}