this repo has no description
1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::ops::Deref;
4
5pub const MAX_EMAIL_LENGTH: usize = 254;
6pub const MAX_LOCAL_PART_LENGTH: usize = 64;
7pub const MAX_DOMAIN_LENGTH: usize = 253;
8pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
9const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
10
11pub const MIN_HANDLE_LENGTH: usize = 3;
12pub const MAX_HANDLE_LENGTH: usize = 253;
13pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(try_from = "String", into = "String")]
17pub struct ValidatedLocalHandle(String);
18
19impl ValidatedLocalHandle {
20 pub fn new(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
21 let validated = validate_short_handle(handle.as_ref())?;
22 Ok(Self(validated))
23 }
24
25 pub fn new_allow_reserved(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
26 let validated = validate_service_handle(handle.as_ref(), true)?;
27 Ok(Self(validated))
28 }
29
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33
34 pub fn into_inner(self) -> String {
35 self.0
36 }
37}
38
39impl Deref for ValidatedLocalHandle {
40 type Target = str;
41 fn deref(&self) -> &Self::Target {
42 &self.0
43 }
44}
45
46impl fmt::Display for ValidatedLocalHandle {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 write!(f, "{}", self.0)
49 }
50}
51
52impl TryFrom<String> for ValidatedLocalHandle {
53 type Error = HandleValidationError;
54 fn try_from(value: String) -> Result<Self, Self::Error> {
55 Self::new(value)
56 }
57}
58
59impl From<ValidatedLocalHandle> for String {
60 fn from(handle: ValidatedLocalHandle) -> Self {
61 handle.0
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum EmailValidationError {
67 Empty,
68 TooLong,
69 MissingAtSign,
70 EmptyLocalPart,
71 LocalPartTooLong,
72 InvalidLocalPart,
73 EmptyDomain,
74 DomainTooLong,
75 MissingDomainDot,
76 InvalidDomainLabel,
77}
78
79impl fmt::Display for EmailValidationError {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::Empty => write!(f, "Email cannot be empty"),
83 Self::TooLong => write!(
84 f,
85 "Email exceeds maximum length of {} characters",
86 MAX_EMAIL_LENGTH
87 ),
88 Self::MissingAtSign => write!(f, "Email must contain @"),
89 Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"),
90 Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"),
91 Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"),
92 Self::EmptyDomain => write!(f, "Email domain cannot be empty"),
93 Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"),
94 Self::MissingDomainDot => write!(f, "Email domain must contain a dot"),
95 Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"),
96 }
97 }
98}
99
100impl std::error::Error for EmailValidationError {}
101
102#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
103#[serde(try_from = "String", into = "String")]
104pub struct ValidatedEmail(String);
105
106impl ValidatedEmail {
107 pub fn new(email: impl AsRef<str>) -> Result<Self, EmailValidationError> {
108 let email = email.as_ref().trim();
109 validate_email_detailed(email)?;
110 Ok(Self(email.to_string()))
111 }
112
113 pub fn as_str(&self) -> &str {
114 &self.0
115 }
116
117 pub fn into_inner(self) -> String {
118 self.0
119 }
120
121 pub fn local_part(&self) -> &str {
122 self.0
123 .rsplit_once('@')
124 .map(|(local, _)| local)
125 .unwrap_or("")
126 }
127
128 pub fn domain(&self) -> &str {
129 self.0
130 .rsplit_once('@')
131 .map(|(_, domain)| domain)
132 .unwrap_or("")
133 }
134}
135
136impl Deref for ValidatedEmail {
137 type Target = str;
138 fn deref(&self) -> &Self::Target {
139 &self.0
140 }
141}
142
143impl fmt::Display for ValidatedEmail {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 write!(f, "{}", self.0)
146 }
147}
148
149impl TryFrom<String> for ValidatedEmail {
150 type Error = EmailValidationError;
151 fn try_from(value: String) -> Result<Self, Self::Error> {
152 Self::new(value)
153 }
154}
155
156impl From<ValidatedEmail> for String {
157 fn from(email: ValidatedEmail) -> Self {
158 email.0
159 }
160}
161
162fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> {
163 if email.is_empty() {
164 return Err(EmailValidationError::Empty);
165 }
166 if email.len() > MAX_EMAIL_LENGTH {
167 return Err(EmailValidationError::TooLong);
168 }
169 let parts: Vec<&str> = email.rsplitn(2, '@').collect();
170 if parts.len() != 2 {
171 return Err(EmailValidationError::MissingAtSign);
172 }
173 let domain = parts[0];
174 let local = parts[1];
175 if local.is_empty() {
176 return Err(EmailValidationError::EmptyLocalPart);
177 }
178 if local.len() > MAX_LOCAL_PART_LENGTH {
179 return Err(EmailValidationError::LocalPartTooLong);
180 }
181 if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
182 return Err(EmailValidationError::InvalidLocalPart);
183 }
184 if !local
185 .chars()
186 .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c))
187 {
188 return Err(EmailValidationError::InvalidLocalPart);
189 }
190 if domain.is_empty() {
191 return Err(EmailValidationError::EmptyDomain);
192 }
193 if domain.len() > MAX_DOMAIN_LENGTH {
194 return Err(EmailValidationError::DomainTooLong);
195 }
196 if !domain.contains('.') {
197 return Err(EmailValidationError::MissingDomainDot);
198 }
199 if !domain.split('.').all(|label| {
200 !label.is_empty()
201 && label.len() <= MAX_DOMAIN_LABEL_LENGTH
202 && !label.starts_with('-')
203 && !label.ends_with('-')
204 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
205 }) {
206 return Err(EmailValidationError::InvalidDomainLabel);
207 }
208 Ok(())
209}
210
211#[derive(Debug, PartialEq)]
212pub enum HandleValidationError {
213 Empty,
214 TooShort,
215 TooLong,
216 InvalidCharacters,
217 StartsWithInvalidChar,
218 EndsWithInvalidChar,
219 ContainsSpaces,
220 BannedWord,
221 Reserved,
222}
223
224impl std::fmt::Display for HandleValidationError {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 match self {
227 Self::Empty => write!(f, "Handle cannot be empty"),
228 Self::TooShort => write!(
229 f,
230 "Handle must be at least {} characters",
231 MIN_HANDLE_LENGTH
232 ),
233 Self::TooLong => write!(
234 f,
235 "Handle exceeds maximum length of {} characters",
236 MAX_SERVICE_HANDLE_LOCAL_PART
237 ),
238 Self::InvalidCharacters => write!(
239 f,
240 "Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed"
241 ),
242 Self::StartsWithInvalidChar => {
243 write!(f, "Handle cannot start with a hyphen")
244 }
245 Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen"),
246 Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
247 Self::BannedWord => write!(f, "Inappropriate language in handle"),
248 Self::Reserved => write!(f, "Reserved handle"),
249 }
250 }
251}
252
253impl std::error::Error for HandleValidationError {}
254
255pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
256 validate_service_handle(handle, false)
257}
258
259pub fn validate_service_handle(
260 handle: &str,
261 allow_reserved: bool,
262) -> Result<String, HandleValidationError> {
263 let handle = handle.trim();
264
265 if handle.is_empty() {
266 return Err(HandleValidationError::Empty);
267 }
268
269 if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
270 return Err(HandleValidationError::ContainsSpaces);
271 }
272
273 if handle.len() < MIN_HANDLE_LENGTH {
274 return Err(HandleValidationError::TooShort);
275 }
276
277 if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART {
278 return Err(HandleValidationError::TooLong);
279 }
280
281 if let Some(first_char) = handle.chars().next()
282 && first_char == '-'
283 {
284 return Err(HandleValidationError::StartsWithInvalidChar);
285 }
286
287 if let Some(last_char) = handle.chars().last()
288 && last_char == '-'
289 {
290 return Err(HandleValidationError::EndsWithInvalidChar);
291 }
292
293 if !handle
294 .chars()
295 .all(|c| c.is_ascii_alphanumeric() || c == '-')
296 {
297 return Err(HandleValidationError::InvalidCharacters);
298 }
299
300 if crate::moderation::has_explicit_slur(handle) {
301 return Err(HandleValidationError::BannedWord);
302 }
303
304 if !allow_reserved && crate::handle::reserved::is_reserved_subdomain(handle) {
305 return Err(HandleValidationError::Reserved);
306 }
307
308 Ok(handle.to_lowercase())
309}
310
311pub fn is_valid_email(email: &str) -> bool {
312 let email = email.trim();
313 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH {
314 return false;
315 }
316 let parts: Vec<&str> = email.rsplitn(2, '@').collect();
317 if parts.len() != 2 {
318 return false;
319 }
320 let domain = parts[0];
321 let local = parts[1];
322 if local.is_empty() || local.len() > MAX_LOCAL_PART_LENGTH {
323 return false;
324 }
325 if local.starts_with('.') || local.ends_with('.') {
326 return false;
327 }
328 if local.contains("..") {
329 return false;
330 }
331 if !local
332 .chars()
333 .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c))
334 {
335 return false;
336 }
337 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH {
338 return false;
339 }
340 if !domain.contains('.') {
341 return false;
342 }
343 domain.split('.').all(|label| {
344 !label.is_empty()
345 && label.len() <= MAX_DOMAIN_LABEL_LENGTH
346 && !label.starts_with('-')
347 && !label.ends_with('-')
348 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
349 })
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn test_valid_handles() {
358 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
359 assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string()));
360 assert_eq!(
361 validate_short_handle("user-name"),
362 Ok("user-name".to_string())
363 );
364 assert_eq!(
365 validate_short_handle("UPPERCASE"),
366 Ok("uppercase".to_string())
367 );
368 assert_eq!(
369 validate_short_handle("MixedCase123"),
370 Ok("mixedcase123".to_string())
371 );
372 assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
373 }
374
375 #[test]
376 fn test_invalid_handles() {
377 assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
378 assert_eq!(
379 validate_short_handle(" "),
380 Err(HandleValidationError::Empty)
381 );
382 assert_eq!(
383 validate_short_handle("ab"),
384 Err(HandleValidationError::TooShort)
385 );
386 assert_eq!(
387 validate_short_handle("a"),
388 Err(HandleValidationError::TooShort)
389 );
390 assert_eq!(
391 validate_short_handle("test spaces"),
392 Err(HandleValidationError::ContainsSpaces)
393 );
394 assert_eq!(
395 validate_short_handle("test\ttab"),
396 Err(HandleValidationError::ContainsSpaces)
397 );
398 assert_eq!(
399 validate_short_handle("-starts"),
400 Err(HandleValidationError::StartsWithInvalidChar)
401 );
402 assert_eq!(
403 validate_short_handle("_starts"),
404 Err(HandleValidationError::InvalidCharacters)
405 );
406 assert_eq!(
407 validate_short_handle("ends-"),
408 Err(HandleValidationError::EndsWithInvalidChar)
409 );
410 assert_eq!(
411 validate_short_handle("ends_"),
412 Err(HandleValidationError::InvalidCharacters)
413 );
414 assert_eq!(
415 validate_short_handle("user_name"),
416 Err(HandleValidationError::InvalidCharacters)
417 );
418 assert_eq!(
419 validate_short_handle("test@user"),
420 Err(HandleValidationError::InvalidCharacters)
421 );
422 assert_eq!(
423 validate_short_handle("test!user"),
424 Err(HandleValidationError::InvalidCharacters)
425 );
426 assert_eq!(
427 validate_short_handle("test.user"),
428 Err(HandleValidationError::InvalidCharacters)
429 );
430 }
431
432 #[test]
433 fn test_handle_trimming() {
434 assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string()));
435 }
436
437 #[test]
438 fn test_handle_max_length() {
439 assert_eq!(
440 validate_short_handle("exactly18charslol"),
441 Ok("exactly18charslol".to_string())
442 );
443 assert_eq!(
444 validate_short_handle("exactly18charslol1"),
445 Ok("exactly18charslol1".to_string())
446 );
447 assert_eq!(
448 validate_short_handle("exactly19characters"),
449 Err(HandleValidationError::TooLong)
450 );
451 assert_eq!(
452 validate_short_handle("waytoolongusername123456789"),
453 Err(HandleValidationError::TooLong)
454 );
455 }
456
457 #[test]
458 fn test_reserved_subdomains() {
459 assert_eq!(
460 validate_short_handle("admin"),
461 Err(HandleValidationError::Reserved)
462 );
463 assert_eq!(
464 validate_short_handle("api"),
465 Err(HandleValidationError::Reserved)
466 );
467 assert_eq!(
468 validate_short_handle("bsky"),
469 Err(HandleValidationError::Reserved)
470 );
471 assert_eq!(
472 validate_short_handle("barackobama"),
473 Err(HandleValidationError::Reserved)
474 );
475 assert_eq!(
476 validate_short_handle("ADMIN"),
477 Err(HandleValidationError::Reserved)
478 );
479 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
480 assert_eq!(
481 validate_short_handle("notreserved"),
482 Ok("notreserved".to_string())
483 );
484 }
485
486 #[test]
487 fn test_allow_reserved() {
488 assert_eq!(
489 validate_service_handle("admin", true),
490 Ok("admin".to_string())
491 );
492 assert_eq!(validate_service_handle("api", true), Ok("api".to_string()));
493 assert_eq!(
494 validate_service_handle("admin", false),
495 Err(HandleValidationError::Reserved)
496 );
497 }
498
499 #[test]
500 fn test_valid_emails() {
501 assert!(is_valid_email("user@example.com"));
502 assert!(is_valid_email("user.name@example.com"));
503 assert!(is_valid_email("user+tag@example.com"));
504 assert!(is_valid_email("user@sub.example.com"));
505 assert!(is_valid_email("USER@EXAMPLE.COM"));
506 assert!(is_valid_email("user123@example123.com"));
507 assert!(is_valid_email("a@b.co"));
508 }
509 #[test]
510 fn test_invalid_emails() {
511 assert!(!is_valid_email(""));
512 assert!(!is_valid_email("user"));
513 assert!(!is_valid_email("user@"));
514 assert!(!is_valid_email("@example.com"));
515 assert!(!is_valid_email("user@example"));
516 assert!(!is_valid_email("user@@example.com"));
517 assert!(!is_valid_email("user@.example.com"));
518 assert!(!is_valid_email("user@example..com"));
519 assert!(!is_valid_email(".user@example.com"));
520 assert!(!is_valid_email("user.@example.com"));
521 assert!(!is_valid_email("user..name@example.com"));
522 assert!(!is_valid_email("user@-example.com"));
523 assert!(!is_valid_email("user@example-.com"));
524 }
525 #[test]
526 fn test_trimmed_whitespace() {
527 assert!(is_valid_email(" user@example.com "));
528 }
529}