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