forked from
lewis.moe/bspds-sandbox
PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.
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 for c in local.chars() {
185 if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) {
186 return Err(EmailValidationError::InvalidLocalPart);
187 }
188 }
189 if domain.is_empty() {
190 return Err(EmailValidationError::EmptyDomain);
191 }
192 if domain.len() > MAX_DOMAIN_LENGTH {
193 return Err(EmailValidationError::DomainTooLong);
194 }
195 if !domain.contains('.') {
196 return Err(EmailValidationError::MissingDomainDot);
197 }
198 for label in domain.split('.') {
199 if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH {
200 return Err(EmailValidationError::InvalidDomainLabel);
201 }
202 if label.starts_with('-') || label.ends_with('-') {
203 return Err(EmailValidationError::InvalidDomainLabel);
204 }
205 for c in label.chars() {
206 if !c.is_ascii_alphanumeric() && c != '-' {
207 return Err(EmailValidationError::InvalidDomainLabel);
208 }
209 }
210 }
211 Ok(())
212}
213
214#[derive(Debug, PartialEq)]
215pub enum HandleValidationError {
216 Empty,
217 TooShort,
218 TooLong,
219 InvalidCharacters,
220 StartsWithInvalidChar,
221 EndsWithInvalidChar,
222 ContainsSpaces,
223 BannedWord,
224 Reserved,
225}
226
227impl std::fmt::Display for HandleValidationError {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 match self {
230 Self::Empty => write!(f, "Handle cannot be empty"),
231 Self::TooShort => write!(
232 f,
233 "Handle must be at least {} characters",
234 MIN_HANDLE_LENGTH
235 ),
236 Self::TooLong => write!(
237 f,
238 "Handle exceeds maximum length of {} characters",
239 MAX_SERVICE_HANDLE_LOCAL_PART
240 ),
241 Self::InvalidCharacters => write!(
242 f,
243 "Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed"
244 ),
245 Self::StartsWithInvalidChar => {
246 write!(f, "Handle cannot start with a hyphen")
247 }
248 Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen"),
249 Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
250 Self::BannedWord => write!(f, "Inappropriate language in handle"),
251 Self::Reserved => write!(f, "Reserved handle"),
252 }
253 }
254}
255
256impl std::error::Error for HandleValidationError {}
257
258pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
259 validate_service_handle(handle, false)
260}
261
262pub fn validate_service_handle(
263 handle: &str,
264 allow_reserved: bool,
265) -> Result<String, HandleValidationError> {
266 let handle = handle.trim();
267
268 if handle.is_empty() {
269 return Err(HandleValidationError::Empty);
270 }
271
272 if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
273 return Err(HandleValidationError::ContainsSpaces);
274 }
275
276 if handle.len() < MIN_HANDLE_LENGTH {
277 return Err(HandleValidationError::TooShort);
278 }
279
280 if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART {
281 return Err(HandleValidationError::TooLong);
282 }
283
284 if let Some(first_char) = handle.chars().next()
285 && first_char == '-'
286 {
287 return Err(HandleValidationError::StartsWithInvalidChar);
288 }
289
290 if let Some(last_char) = handle.chars().last()
291 && last_char == '-'
292 {
293 return Err(HandleValidationError::EndsWithInvalidChar);
294 }
295
296 for c in handle.chars() {
297 if !c.is_ascii_alphanumeric() && c != '-' {
298 return Err(HandleValidationError::InvalidCharacters);
299 }
300 }
301
302 if crate::moderation::has_explicit_slur(handle) {
303 return Err(HandleValidationError::BannedWord);
304 }
305
306 if !allow_reserved && crate::handle::reserved::is_reserved_subdomain(handle) {
307 return Err(HandleValidationError::Reserved);
308 }
309
310 Ok(handle.to_lowercase())
311}
312
313pub fn is_valid_email(email: &str) -> bool {
314 let email = email.trim();
315 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH {
316 return false;
317 }
318 let parts: Vec<&str> = email.rsplitn(2, '@').collect();
319 if parts.len() != 2 {
320 return false;
321 }
322 let domain = parts[0];
323 let local = parts[1];
324 if local.is_empty() || local.len() > MAX_LOCAL_PART_LENGTH {
325 return false;
326 }
327 if local.starts_with('.') || local.ends_with('.') {
328 return false;
329 }
330 if local.contains("..") {
331 return false;
332 }
333 for c in local.chars() {
334 if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) {
335 return false;
336 }
337 }
338 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH {
339 return false;
340 }
341 if !domain.contains('.') {
342 return false;
343 }
344 for label in domain.split('.') {
345 if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH {
346 return false;
347 }
348 if label.starts_with('-') || label.ends_with('-') {
349 return false;
350 }
351 for c in label.chars() {
352 if !c.is_ascii_alphanumeric() && c != '-' {
353 return false;
354 }
355 }
356 }
357 true
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn test_valid_handles() {
366 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
367 assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string()));
368 assert_eq!(
369 validate_short_handle("user-name"),
370 Ok("user-name".to_string())
371 );
372 assert_eq!(
373 validate_short_handle("UPPERCASE"),
374 Ok("uppercase".to_string())
375 );
376 assert_eq!(
377 validate_short_handle("MixedCase123"),
378 Ok("mixedcase123".to_string())
379 );
380 assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
381 }
382
383 #[test]
384 fn test_invalid_handles() {
385 assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
386 assert_eq!(
387 validate_short_handle(" "),
388 Err(HandleValidationError::Empty)
389 );
390 assert_eq!(
391 validate_short_handle("ab"),
392 Err(HandleValidationError::TooShort)
393 );
394 assert_eq!(
395 validate_short_handle("a"),
396 Err(HandleValidationError::TooShort)
397 );
398 assert_eq!(
399 validate_short_handle("test spaces"),
400 Err(HandleValidationError::ContainsSpaces)
401 );
402 assert_eq!(
403 validate_short_handle("test\ttab"),
404 Err(HandleValidationError::ContainsSpaces)
405 );
406 assert_eq!(
407 validate_short_handle("-starts"),
408 Err(HandleValidationError::StartsWithInvalidChar)
409 );
410 assert_eq!(
411 validate_short_handle("_starts"),
412 Err(HandleValidationError::InvalidCharacters)
413 );
414 assert_eq!(
415 validate_short_handle("ends-"),
416 Err(HandleValidationError::EndsWithInvalidChar)
417 );
418 assert_eq!(
419 validate_short_handle("ends_"),
420 Err(HandleValidationError::InvalidCharacters)
421 );
422 assert_eq!(
423 validate_short_handle("user_name"),
424 Err(HandleValidationError::InvalidCharacters)
425 );
426 assert_eq!(
427 validate_short_handle("test@user"),
428 Err(HandleValidationError::InvalidCharacters)
429 );
430 assert_eq!(
431 validate_short_handle("test!user"),
432 Err(HandleValidationError::InvalidCharacters)
433 );
434 assert_eq!(
435 validate_short_handle("test.user"),
436 Err(HandleValidationError::InvalidCharacters)
437 );
438 }
439
440 #[test]
441 fn test_handle_trimming() {
442 assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string()));
443 }
444
445 #[test]
446 fn test_handle_max_length() {
447 assert_eq!(
448 validate_short_handle("exactly18charslol"),
449 Ok("exactly18charslol".to_string())
450 );
451 assert_eq!(
452 validate_short_handle("exactly18charslol1"),
453 Ok("exactly18charslol1".to_string())
454 );
455 assert_eq!(
456 validate_short_handle("exactly19characters"),
457 Err(HandleValidationError::TooLong)
458 );
459 assert_eq!(
460 validate_short_handle("waytoolongusername123456789"),
461 Err(HandleValidationError::TooLong)
462 );
463 }
464
465 #[test]
466 fn test_reserved_subdomains() {
467 assert_eq!(
468 validate_short_handle("admin"),
469 Err(HandleValidationError::Reserved)
470 );
471 assert_eq!(
472 validate_short_handle("api"),
473 Err(HandleValidationError::Reserved)
474 );
475 assert_eq!(
476 validate_short_handle("bsky"),
477 Err(HandleValidationError::Reserved)
478 );
479 assert_eq!(
480 validate_short_handle("barackobama"),
481 Err(HandleValidationError::Reserved)
482 );
483 assert_eq!(
484 validate_short_handle("ADMIN"),
485 Err(HandleValidationError::Reserved)
486 );
487 assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
488 assert_eq!(
489 validate_short_handle("notreserved"),
490 Ok("notreserved".to_string())
491 );
492 }
493
494 #[test]
495 fn test_allow_reserved() {
496 assert_eq!(
497 validate_service_handle("admin", true),
498 Ok("admin".to_string())
499 );
500 assert_eq!(validate_service_handle("api", true), Ok("api".to_string()));
501 assert_eq!(
502 validate_service_handle("admin", false),
503 Err(HandleValidationError::Reserved)
504 );
505 }
506
507 #[test]
508 fn test_valid_emails() {
509 assert!(is_valid_email("user@example.com"));
510 assert!(is_valid_email("user.name@example.com"));
511 assert!(is_valid_email("user+tag@example.com"));
512 assert!(is_valid_email("user@sub.example.com"));
513 assert!(is_valid_email("USER@EXAMPLE.COM"));
514 assert!(is_valid_email("user123@example123.com"));
515 assert!(is_valid_email("a@b.co"));
516 }
517 #[test]
518 fn test_invalid_emails() {
519 assert!(!is_valid_email(""));
520 assert!(!is_valid_email("user"));
521 assert!(!is_valid_email("user@"));
522 assert!(!is_valid_email("@example.com"));
523 assert!(!is_valid_email("user@example"));
524 assert!(!is_valid_email("user@@example.com"));
525 assert!(!is_valid_email("user@.example.com"));
526 assert!(!is_valid_email("user@example..com"));
527 assert!(!is_valid_email(".user@example.com"));
528 assert!(!is_valid_email("user.@example.com"));
529 assert!(!is_valid_email("user..name@example.com"));
530 assert!(!is_valid_email("user@-example.com"));
531 assert!(!is_valid_email("user@example-.com"));
532 }
533 #[test]
534 fn test_trimmed_whitespace() {
535 assert!(is_valid_email(" user@example.com "));
536 }
537}