1use core::fmt;
44use core::str::FromStr;
45
46#[cfg(feature = "serde")]
47use serde::{Deserialize, Serialize};
48
49#[cfg(feature = "zeroize")]
50use zeroize::Zeroize;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55#[non_exhaustive]
56pub enum UrlError {
57 Empty,
61 TooLong(usize),
66 MissingScheme,
70 InvalidScheme,
75 MissingSchemeSeparator,
79 MissingAuthority,
83 InvalidHost,
87 InvalidPort,
91 InvalidChar(char),
96}
97
98impl fmt::Display for UrlError {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 Self::Empty => write!(f, "URL cannot be empty"),
102 Self::TooLong(len) => write!(
103 f,
104 "URL exceeds maximum length of 2048 characters (got {len})"
105 ),
106 Self::MissingScheme => write!(f, "URL must contain a scheme"),
107 Self::InvalidScheme => write!(f, "URL scheme is invalid"),
108 Self::MissingSchemeSeparator => write!(f, "URL scheme must be followed by :"),
109 Self::MissingAuthority => write!(f, "URL has // but no authority"),
110 Self::InvalidHost => write!(f, "URL host is invalid"),
111 Self::InvalidPort => write!(f, "URL port is invalid"),
112 Self::InvalidChar(c) => write!(f, "URL contains invalid character '{c}'"),
113 }
114 }
115}
116
117#[cfg(feature = "std")]
118impl std::error::Error for UrlError {}
119
120#[repr(transparent)]
156#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
157#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
158#[cfg_attr(feature = "zeroize", derive(Zeroize))]
159pub struct Url(heapless::String<2048>);
160
161#[cfg(feature = "arbitrary")]
162impl<'a> arbitrary::Arbitrary<'a> for Url {
163 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
164 const SCHEMES: &[&str] = &["http", "https", "ftp", "file"];
165 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
166 const DIGITS: &[u8] = b"0123456789";
167
168 let scheme_idx = u8::arbitrary(u)? as usize % SCHEMES.len();
170 let scheme = SCHEMES[scheme_idx];
171
172 let label_count = 1 + (u8::arbitrary(u)? % 3);
174 let mut host = heapless::String::<253>::new();
175
176 for label_idx in 0..label_count {
177 let label_len = 1 + (u8::arbitrary(u)? % 20).min(19);
178
179 for _ in 0..label_len {
180 let byte = u8::arbitrary(u)?;
181 let c = match byte % 2 {
182 0 => ALPHABET[(byte % 26) as usize] as char,
183 _ => DIGITS[(byte % 10) as usize] as char,
184 };
185 host.push(c)
186 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
187 }
188
189 if label_idx < label_count - 1 {
190 host.push('.')
191 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
192 }
193 }
194
195 let has_port = bool::arbitrary(u)?;
197 let mut url = heapless::String::<2048>::new();
198
199 url.push_str(scheme)
200 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
201 url.push(':')
202 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
203 url.push('/')
204 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
205 url.push('/')
206 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
207 url.push_str(&host)
208 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
209
210 if has_port {
211 let port = 1 + (u16::arbitrary(u)? % 65535);
212 url.push(':')
213 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
214 url.push_str(&port.to_string())
215 .map_err(|_| arbitrary::Error::IncorrectFormat)?;
216 }
217
218 Ok(Self(url))
219 }
220}
221
222impl Url {
223 #[allow(clippy::missing_panics_doc)]
239 pub fn new(s: &str) -> Result<Self, UrlError> {
240 if s.is_empty() {
241 return Err(UrlError::Empty);
242 }
243
244 if s.len() > 2048 {
245 return Err(UrlError::TooLong(s.len()));
246 }
247
248 let Some(scheme_end) = s.find(':') else {
250 return Err(UrlError::MissingSchemeSeparator);
251 };
252
253 if scheme_end == 0 {
254 return Err(UrlError::MissingScheme);
255 }
256
257 let scheme = &s[..scheme_end];
258
259 if !scheme
261 .chars()
262 .next()
263 .is_some_and(|c| c.is_ascii_alphabetic())
264 {
265 return Err(UrlError::InvalidScheme);
266 }
267
268 for c in scheme.chars() {
269 if !c.is_ascii_alphanumeric() && !matches!(c, '+' | '.' | '-') {
270 return Err(UrlError::InvalidScheme);
271 }
272 }
273
274 let rest = &s[scheme_end + 1..];
276 let has_authority = rest.starts_with("//");
277
278 if has_authority {
279 let authority_start = scheme_end + 3;
280 if authority_start >= s.len() {
281 return Err(UrlError::MissingAuthority);
282 }
283
284 let authority_end = s[authority_start..]
286 .find(['/', '?', '#'])
287 .map_or(s.len(), |pos| authority_start + pos);
288
289 let authority = &s[authority_start..authority_end];
290
291 if authority.is_empty() {
292 return Err(UrlError::MissingAuthority);
293 }
294
295 let host = authority.split(':').next().unwrap_or(authority);
297 if host.is_empty() {
298 return Err(UrlError::InvalidHost);
299 }
300
301 if let Some(port_str) = authority.split(':').nth(1) {
303 if port_str.is_empty() {
304 return Err(UrlError::InvalidPort);
305 }
306 if !port_str.chars().all(|c| c.is_ascii_digit()) {
307 return Err(UrlError::InvalidPort);
308 }
309 }
310 }
311
312 for c in s.chars() {
314 if !Self::is_valid_url_char(c) {
315 return Err(UrlError::InvalidChar(c));
316 }
317 }
318
319 let mut inner = heapless::String::<2048>::new();
321 for c in scheme.chars() {
322 inner
323 .push(c.to_ascii_lowercase())
324 .map_err(|_| UrlError::TooLong(2048))?;
325 }
326 inner.push(':').map_err(|_| UrlError::TooLong(2048))?;
327 inner.push_str(rest).map_err(|_| UrlError::TooLong(2048))?;
328
329 Ok(Self(inner))
330 }
331
332 const fn is_valid_url_char(c: char) -> bool {
334 c.is_ascii_alphanumeric()
335 || matches!(
336 c,
337 ':' | '/'
338 | '?'
339 | '#'
340 | '['
341 | ']'
342 | '@'
343 | '!'
344 | '$'
345 | '&'
346 | '\''
347 | '('
348 | ')'
349 | '*'
350 | '+'
351 | ','
352 | ';'
353 | '='
354 | '-'
355 | '.'
356 | '_'
357 | '~'
358 | '%'
359 )
360 }
361
362 #[must_use]
373 #[inline]
374 pub fn as_str(&self) -> &str {
375 &self.0
376 }
377
378 #[must_use]
390 #[inline]
391 pub const fn as_inner(&self) -> &heapless::String<2048> {
392 &self.0
393 }
394
395 #[must_use]
407 #[inline]
408 pub fn into_inner(self) -> heapless::String<2048> {
409 self.0
410 }
411
412 #[must_use]
423 #[allow(clippy::missing_panics_doc)]
424 pub fn scheme(&self) -> &str {
425 self.as_str()
426 .split(':')
427 .next()
428 .expect("URL always contains :")
429 }
430
431 #[must_use]
442 pub fn host(&self) -> Option<&str> {
443 let rest = self.as_str().split_once(':')?.1;
444 if !rest.starts_with("//") {
445 return None;
446 }
447
448 let authority = &rest[2..];
449 let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
450
451 let authority = &authority[..authority_end];
452 authority.split(':').next()
453 }
454
455 #[must_use]
466 pub fn port(&self) -> Option<&str> {
467 let rest = self.as_str().split_once(':')?.1;
468 if !rest.starts_with("//") {
469 return None;
470 }
471
472 let authority = &rest[2..];
473 let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
474
475 let authority = &authority[..authority_end];
476 authority.split(':').nth(1)
477 }
478
479 #[must_use]
490 pub fn path(&self) -> Option<&str> {
491 let rest = self.as_str().split_once(':')?.1;
492 let authority_end = rest.strip_prefix("//").map_or(0, |authority| {
493 let end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
494 2 + end
495 });
496
497 let path_start = self.as_str().len().saturating_sub(rest.len()) + authority_end;
498 if path_start >= self.as_str().len() {
499 return None;
500 }
501
502 let path_and_query_fragment = &self.as_str()[path_start..];
503 let path_end = path_and_query_fragment
504 .find(['?', '#'])
505 .unwrap_or(path_and_query_fragment.len());
506
507 let path = &path_and_query_fragment[..path_end];
508 if path.is_empty() { None } else { Some(path) }
509 }
510
511 #[must_use]
522 pub fn query(&self) -> Option<&str> {
523 let query_start = self.as_str().find('?')?;
524 let query_and_fragment = &self.as_str()[query_start + 1..];
525 let query_end = query_and_fragment
526 .find('#')
527 .unwrap_or(query_and_fragment.len());
528
529 let query = &query_and_fragment[..query_end];
530 if query.is_empty() { None } else { Some(query) }
531 }
532
533 #[must_use]
544 pub fn fragment(&self) -> Option<&str> {
545 let fragment_start = self.as_str().find('#')?;
546 let fragment = &self.as_str()[fragment_start + 1..];
547 if fragment.is_empty() {
548 None
549 } else {
550 Some(fragment)
551 }
552 }
553}
554
555impl TryFrom<&str> for Url {
556 type Error = UrlError;
557
558 fn try_from(s: &str) -> Result<Self, Self::Error> {
559 Self::new(s)
560 }
561}
562
563impl From<Url> for heapless::String<2048> {
564 fn from(url: Url) -> Self {
565 url.0
566 }
567}
568
569impl FromStr for Url {
570 type Err = UrlError;
571
572 fn from_str(s: &str) -> Result<Self, Self::Err> {
573 Self::new(s)
574 }
575}
576
577impl fmt::Display for Url {
578 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579 write!(f, "{}", self.0)
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn test_new_valid_url() {
589 assert!(Url::new("https://example.com").is_ok());
590 assert!(Url::new("http://example.com").is_ok());
591 assert!(Url::new("ftp://example.com").is_ok());
592 assert!(Url::new("file://localhost/path/to/file").is_ok());
593 }
594
595 #[test]
596 fn test_empty_url() {
597 assert_eq!(Url::new(""), Err(UrlError::Empty));
598 }
599
600 #[test]
601 fn test_too_long_url() {
602 let long = format!("https://{}.com", "a".repeat(3000));
603 assert!(long.len() > 2048);
604 assert_eq!(Url::new(&long), Err(UrlError::TooLong(long.len())));
605 }
606
607 #[test]
608 fn test_missing_scheme() {
609 assert_eq!(Url::new("://example.com"), Err(UrlError::MissingScheme));
610 }
611
612 #[test]
613 fn test_missing_scheme_separator() {
614 assert_eq!(
615 Url::new("https//example.com"),
616 Err(UrlError::MissingSchemeSeparator)
617 );
618 }
619
620 #[test]
621 fn test_invalid_scheme() {
622 assert_eq!(Url::new("123://example.com"), Err(UrlError::InvalidScheme));
623 }
624
625 #[test]
626 fn test_missing_authority() {
627 assert_eq!(Url::new("https://"), Err(UrlError::MissingAuthority));
628 }
629
630 #[test]
631 fn test_as_str() {
632 let url = Url::new("https://example.com").unwrap();
633 assert_eq!(url.as_str(), "https://example.com");
634 }
635
636 #[test]
637 fn test_as_inner() {
638 let url = Url::new("https://example.com").unwrap();
639 let inner = url.as_inner();
640 assert_eq!(inner.as_str(), "https://example.com");
641 }
642
643 #[test]
644 fn test_into_inner() {
645 let url = Url::new("https://example.com").unwrap();
646 let inner = url.into_inner();
647 assert_eq!(inner.as_str(), "https://example.com");
648 }
649
650 #[test]
651 fn test_scheme() {
652 let url = Url::new("https://example.com").unwrap();
653 assert_eq!(url.scheme(), "https");
654 }
655
656 #[test]
657 fn test_scheme_case_insensitive() {
658 let url = Url::new("HTTPS://example.com").unwrap();
659 assert_eq!(url.scheme(), "https");
660 assert_eq!(url.as_str(), "https://example.com");
661 }
662
663 #[test]
664 fn test_host() {
665 let url = Url::new("https://example.com").unwrap();
666 assert_eq!(url.host(), Some("example.com"));
667 }
668
669 #[test]
670 fn test_host_with_port() {
671 let url = Url::new("https://example.com:8080").unwrap();
672 assert_eq!(url.host(), Some("example.com"));
673 }
674
675 #[test]
676 fn test_port() {
677 let url = Url::new("https://example.com:8080").unwrap();
678 assert_eq!(url.port(), Some("8080"));
679 }
680
681 #[test]
682 fn test_port_none() {
683 let url = Url::new("https://example.com").unwrap();
684 assert_eq!(url.port(), None);
685 }
686
687 #[test]
688 fn test_path() {
689 let url = Url::new("https://example.com/path").unwrap();
690 assert_eq!(url.path(), Some("/path"));
691 }
692
693 #[test]
694 fn test_path_none() {
695 let url = Url::new("https://example.com").unwrap();
696 assert_eq!(url.path(), None);
697 }
698
699 #[test]
700 fn test_query() {
701 let url = Url::new("https://example.com?query=value").unwrap();
702 assert_eq!(url.query(), Some("query=value"));
703 }
704
705 #[test]
706 fn test_query_none() {
707 let url = Url::new("https://example.com").unwrap();
708 assert_eq!(url.query(), None);
709 }
710
711 #[test]
712 fn test_fragment() {
713 let url = Url::new("https://example.com#fragment").unwrap();
714 assert_eq!(url.fragment(), Some("fragment"));
715 }
716
717 #[test]
718 fn test_fragment_none() {
719 let url = Url::new("https://example.com").unwrap();
720 assert_eq!(url.fragment(), None);
721 }
722
723 #[test]
724 fn test_full_url() {
725 let url = Url::new("https://example.com:8080/path?query=value#fragment").unwrap();
726 assert_eq!(url.scheme(), "https");
727 assert_eq!(url.host(), Some("example.com"));
728 assert_eq!(url.port(), Some("8080"));
729 assert_eq!(url.path(), Some("/path"));
730 assert_eq!(url.query(), Some("query=value"));
731 assert_eq!(url.fragment(), Some("fragment"));
732 }
733
734 #[test]
735 fn test_try_from_str() {
736 let url = Url::try_from("https://example.com").unwrap();
737 assert_eq!(url.as_str(), "https://example.com");
738 }
739
740 #[test]
741 fn test_from_url_to_string() {
742 let url = Url::new("https://example.com").unwrap();
743 let inner: heapless::String<2048> = url.into();
744 assert_eq!(inner.as_str(), "https://example.com");
745 }
746
747 #[test]
748 fn test_from_str() {
749 let url: Url = "https://example.com".parse().unwrap();
750 assert_eq!(url.as_str(), "https://example.com");
751 }
752
753 #[test]
754 fn test_from_str_invalid() {
755 assert!("".parse::<Url>().is_err());
756 assert!("https://".parse::<Url>().is_err());
757 assert!("://example.com".parse::<Url>().is_err());
758 }
759
760 #[test]
761 fn test_display() {
762 let url = Url::new("https://example.com").unwrap();
763 assert_eq!(format!("{url}"), "https://example.com");
764 }
765
766 #[test]
767 fn test_equality() {
768 let url1 = Url::new("https://example.com").unwrap();
769 let url2 = Url::new("https://example.com").unwrap();
770 let url3 = Url::new("https://example.org").unwrap();
771
772 assert_eq!(url1, url2);
773 assert_ne!(url1, url3);
774 }
775
776 #[test]
777 fn test_ordering() {
778 let url1 = Url::new("https://a.com").unwrap();
779 let url2 = Url::new("https://b.com").unwrap();
780
781 assert!(url1 < url2);
782 }
783
784 #[test]
785 fn test_clone() {
786 let url = Url::new("https://example.com").unwrap();
787 let url2 = url.clone();
788 assert_eq!(url, url2);
789 }
790
791 #[test]
792 fn test_scheme_with_plus() {
793 assert!(Url::new("git+ssh://example.com").is_ok());
794 }
795
796 #[test]
797 fn test_scheme_with_dot() {
798 assert!(Url::new("web+scheme://example.com").is_ok());
799 }
800
801 #[test]
802 fn test_scheme_with_dash() {
803 assert!(Url::new("custom-scheme://example.com").is_ok());
804 }
805
806 #[test]
807 fn test_error_display() {
808 assert_eq!(format!("{}", UrlError::Empty), "URL cannot be empty");
809 assert_eq!(
810 format!("{}", UrlError::TooLong(3000)),
811 "URL exceeds maximum length of 2048 characters (got 3000)"
812 );
813 assert_eq!(
814 format!("{}", UrlError::MissingScheme),
815 "URL must contain a scheme"
816 );
817 assert_eq!(
818 format!("{}", UrlError::InvalidScheme),
819 "URL scheme is invalid"
820 );
821 assert_eq!(
822 format!("{}", UrlError::MissingSchemeSeparator),
823 "URL scheme must be followed by :"
824 );
825 assert_eq!(
826 format!("{}", UrlError::MissingAuthority),
827 "URL has // but no authority"
828 );
829 assert_eq!(format!("{}", UrlError::InvalidHost), "URL host is invalid");
830 assert_eq!(format!("{}", UrlError::InvalidPort), "URL port is invalid");
831 assert_eq!(
832 format!("{}", UrlError::InvalidChar(' ')),
833 "URL contains invalid character ' '"
834 );
835 }
836
837 #[test]
838 fn test_hash() {
839 use core::hash::Hash;
840 use core::hash::Hasher;
841
842 #[derive(Default)]
843 struct SimpleHasher(u64);
844
845 impl Hasher for SimpleHasher {
846 fn finish(&self) -> u64 {
847 self.0
848 }
849
850 fn write(&mut self, bytes: &[u8]) {
851 for byte in bytes {
852 self.0 = self.0.wrapping_mul(31).wrapping_add(*byte as u64);
853 }
854 }
855 }
856
857 let url1 = Url::new("https://example.com").unwrap();
858 let url2 = Url::new("https://example.com").unwrap();
859 let url3 = Url::new("https://example.org").unwrap();
860
861 let mut hasher1 = SimpleHasher::default();
862 let mut hasher2 = SimpleHasher::default();
863 let mut hasher3 = SimpleHasher::default();
864
865 url1.hash(&mut hasher1);
866 url2.hash(&mut hasher2);
867 url3.hash(&mut hasher3);
868
869 assert_eq!(hasher1.finish(), hasher2.finish());
870 assert_ne!(hasher1.finish(), hasher3.finish());
871 }
872
873 #[test]
874 fn test_debug() {
875 let url = Url::new("https://example.com").unwrap();
876 assert_eq!(format!("{:?}", url), "Url(\"https://example.com\")");
877 }
878
879 #[test]
880 fn test_from_into_inner_roundtrip() {
881 let url = Url::new("https://example.com").unwrap();
882 let inner: heapless::String<2048> = url.into();
883 let url2 = Url::new(inner.as_str()).unwrap();
884 assert_eq!(url2.as_str(), "https://example.com");
885 }
886
887 #[test]
888 fn test_url_without_authority() {
889 let url = Url::new("mailto:user@example.com").unwrap();
890 assert_eq!(url.scheme(), "mailto");
891 assert_eq!(url.host(), None);
892 }
893
894 #[test]
895 fn test_ip_host() {
896 assert!(Url::new("https://127.0.0.1").is_ok());
897 }
898
899 #[test]
900 fn test_percent_encoding() {
901 assert!(Url::new("https://example.com/path%20with%20spaces").is_ok());
902 }
903}