1use crate::std::{borrow::Cow, string::String};
15
16use crate::user::Basic;
17
18use rama_core::bytes::Bytes;
19use rama_core::error::BoxErrorExt as _;
20use rama_core::error::{BoxError, ErrorContext};
21use rama_utils::str::NonEmptyStr;
22
23use percent_encoding::{AsciiSet, CONTROLS, percent_decode, utf8_percent_encode};
24
25const USERINFO_PASSWORD_ENCODE_SET: &AsciiSet = &CONTROLS
30 .add(b' ')
31 .add(b'"')
32 .add(b'#')
33 .add(b'%')
34 .add(b'/')
35 .add(b'<')
36 .add(b'>')
37 .add(b'?')
38 .add(b'@')
39 .add(b'[')
40 .add(b'\\')
41 .add(b']')
42 .add(b'^')
43 .add(b'`')
44 .add(b'{')
45 .add(b'|')
46 .add(b'}');
47
48const USERINFO_USERNAME_ENCODE_SET: &AsciiSet = &USERINFO_PASSWORD_ENCODE_SET.add(b':');
52
53fn reject_decoded_control(s: &str) -> Result<(), BoxError> {
59 if s.as_bytes().iter().any(|&b| b < 0x20 || b == 0x7F) {
60 return Err(BoxError::from_static_str(
61 "decoded userinfo component contains a control character",
62 ));
63 }
64 Ok(())
65}
66
67#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct UserInfo {
79 bytes: Bytes,
80}
81
82impl UserInfo {
83 #[must_use]
94 pub const fn from_static(s: &'static str) -> Self {
95 validate_userinfo_static(s.as_bytes());
96 Self {
97 bytes: Bytes::from_static(s.as_bytes()),
98 }
99 }
100
101 #[must_use]
105 pub(crate) fn from_bytes_unchecked(bytes: Bytes) -> Self {
106 Self { bytes }
107 }
108
109 #[must_use]
111 pub fn as_bytes(&self) -> &[u8] {
112 &self.bytes
113 }
114
115 #[must_use]
117 pub fn as_str(&self) -> &str {
118 unsafe { core::str::from_utf8_unchecked(&self.bytes) }
121 }
122
123 #[must_use]
126 #[inline]
127 pub fn view(&self) -> UserInfoRef<'_> {
128 UserInfoRef::new(&self.bytes)
129 }
130
131 #[must_use]
137 pub fn split_user_password(&self) -> (&[u8], Option<&[u8]>) {
138 self.view().split_user_password()
139 }
140
141 #[must_use]
148 pub fn as_decoded_str(&self) -> Cow<'_, str> {
149 self.view().as_decoded_str()
150 }
151
152 #[must_use]
154 pub fn username_decoded(&self) -> Cow<'_, str> {
155 self.view().username_decoded()
156 }
157
158 #[must_use]
161 pub fn password_decoded(&self) -> Option<Cow<'_, str>> {
162 self.view().password_decoded()
163 }
164
165 pub fn to_basic(&self) -> Result<Basic, BoxError> {
170 self.view().to_basic()
171 }
172}
173
174impl core::fmt::Display for UserInfo {
175 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
176 f.write_str(self.as_str())
177 }
178}
179
180impl core::str::FromStr for UserInfo {
181 type Err = BoxError;
182 fn from_str(s: &str) -> Result<Self, Self::Err> {
183 Self::try_from(s)
184 }
185}
186
187impl TryFrom<&str> for UserInfo {
188 type Error = BoxError;
189
190 fn try_from(s: &str) -> Result<Self, Self::Error> {
203 validate_userinfo_runtime(s.as_bytes())?;
204 Ok(Self {
205 bytes: Bytes::copy_from_slice(s.as_bytes()),
206 })
207 }
208}
209
210impl TryFrom<String> for UserInfo {
211 type Error = BoxError;
212 fn try_from(s: String) -> Result<Self, Self::Error> {
213 validate_userinfo_runtime(s.as_bytes())?;
214 Ok(Self {
215 bytes: Bytes::from(s),
216 })
217 }
218}
219
220#[derive(Clone, Copy)]
224enum UserInfoFault {
225 ControlByte,
227 PctTruncated,
229 PctMalformed,
231 PctDecodesToControl,
233 DisallowedByte,
235}
236
237const fn validate_userinfo_bytes(bytes: &[u8]) -> Result<(), UserInfoFault> {
248 let mut i = 0;
249 while i < bytes.len() {
250 let b = bytes[i];
251 if b < 0x20 || b == 0x7F {
252 return Err(UserInfoFault::ControlByte);
253 }
254 if b == b'%' {
255 if i + 2 >= bytes.len() {
256 return Err(UserInfoFault::PctTruncated);
257 }
258 let h1 = bytes[i + 1];
259 let h2 = bytes[i + 2];
260 if !h1.is_ascii_hexdigit() || !h2.is_ascii_hexdigit() {
261 return Err(UserInfoFault::PctMalformed);
262 }
263 if crate::byte_sets::pct_decoded_control_byte(h1, h2).is_some() {
264 return Err(UserInfoFault::PctDecodesToControl);
265 }
266 i += 3;
267 continue;
268 }
269 if !crate::byte_sets::is_userinfo_byte(b) {
270 return Err(UserInfoFault::DisallowedByte);
271 }
272 i += 1;
273 }
274 Ok(())
275}
276
277fn validate_userinfo_runtime(bytes: &[u8]) -> Result<(), BoxError> {
281 match validate_userinfo_bytes(bytes) {
282 Ok(()) => Ok(()),
283 Err(fault) => {
284 let msg = match fault {
285 UserInfoFault::ControlByte => "userinfo contains control character",
286 UserInfoFault::PctTruncated | UserInfoFault::PctMalformed => {
287 "userinfo contains malformed percent-escape"
288 }
289 UserInfoFault::PctDecodesToControl => {
290 "userinfo pct-escape decodes to a control character"
291 }
292 UserInfoFault::DisallowedByte => "userinfo contains disallowed character",
293 };
294 Err(BoxError::from_static_str(msg))
295 }
296 }
297}
298
299#[expect(
303 clippy::panic,
304 reason = "static-str invariant: compile-time panic when the static input violates the userinfo grammar"
305)]
306const fn validate_userinfo_static(bytes: &[u8]) {
307 match validate_userinfo_bytes(bytes) {
308 Ok(()) => {}
309 Err(UserInfoFault::ControlByte) => {
310 panic!("UserInfo::from_static: control character in input")
311 }
312 Err(UserInfoFault::PctTruncated) => {
313 panic!("UserInfo::from_static: truncated percent-escape")
314 }
315 Err(UserInfoFault::PctMalformed) => {
316 panic!("UserInfo::from_static: malformed percent-escape")
317 }
318 Err(UserInfoFault::PctDecodesToControl) => {
319 panic!("UserInfo::from_static: pct-escape decodes to a control character")
320 }
321 Err(UserInfoFault::DisallowedByte) => {
322 panic!("UserInfo::from_static: disallowed character")
323 }
324 }
325}
326
327impl From<Basic> for UserInfo {
347 fn from(basic: Basic) -> Self {
348 let mut s = String::new();
349 s.extend(utf8_percent_encode(
350 basic.username(),
351 USERINFO_USERNAME_ENCODE_SET,
352 ));
353 if let Some(password) = basic.password() {
354 s.push(':');
355 s.extend(utf8_percent_encode(password, USERINFO_PASSWORD_ENCODE_SET));
356 }
357 Self {
358 bytes: Bytes::from(s),
359 }
360 }
361}
362
363impl TryFrom<&UserInfo> for Basic {
364 type Error = BoxError;
365
366 fn try_from(value: &UserInfo) -> Result<Self, Self::Error> {
370 value.to_basic()
371 }
372}
373
374impl TryFrom<UserInfo> for Basic {
375 type Error = BoxError;
376
377 fn try_from(value: UserInfo) -> Result<Self, Self::Error> {
381 Self::try_from(&value)
382 }
383}
384
385#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
391pub struct UserInfoRef<'a> {
392 bytes: &'a [u8],
393}
394
395impl<'a> UserInfoRef<'a> {
396 #[must_use]
399 #[inline]
400 pub(crate) const fn new(bytes: &'a [u8]) -> Self {
401 Self { bytes }
402 }
403
404 #[must_use]
406 pub fn as_bytes(&self) -> &'a [u8] {
407 self.bytes
408 }
409
410 #[must_use]
412 pub fn as_str(&self) -> &'a str {
413 unsafe { core::str::from_utf8_unchecked(self.bytes) }
415 }
416
417 #[must_use]
419 pub fn split_user_password(&self) -> (&'a [u8], Option<&'a [u8]>) {
420 match self.bytes.iter().position(|&b| b == b':') {
421 Some(i) => (&self.bytes[..i], Some(&self.bytes[i + 1..])),
422 None => (self.bytes, None),
423 }
424 }
425
426 #[must_use]
430 pub fn into_owned(self) -> UserInfo {
431 UserInfo {
432 bytes: Bytes::copy_from_slice(self.bytes),
433 }
434 }
435
436 #[must_use]
439 pub fn as_decoded_str(&self) -> Cow<'a, str> {
440 percent_decode(self.bytes).decode_utf8_lossy()
441 }
442
443 #[must_use]
445 pub fn username_decoded(&self) -> Cow<'a, str> {
446 let (user, _) = self.split_user_password();
447 percent_decode(user).decode_utf8_lossy()
448 }
449
450 #[must_use]
453 pub fn password_decoded(&self) -> Option<Cow<'a, str>> {
454 let (_, password) = self.split_user_password();
455 password.map(|p| percent_decode(p).decode_utf8_lossy())
456 }
457
458 pub fn to_basic(&self) -> Result<Basic, BoxError> {
468 let user = self.username_decoded();
469 reject_decoded_control(&user)?;
470 let username =
471 NonEmptyStr::try_from(user.as_ref()).context("create username from userinfo")?;
472 let password = match self.password_decoded() {
473 Some(p) => {
474 reject_decoded_control(&p)?;
475 (!p.is_empty())
478 .then(|| NonEmptyStr::try_from(p.as_ref()))
479 .transpose()
480 .context("create password from userinfo")?
481 }
482 None => None,
483 };
484 Ok(match password {
485 Some(password) => Basic::new(username, password),
486 None => Basic::new_insecure(username),
487 })
488 }
489}
490
491impl<'a> From<&'a UserInfo> for UserInfoRef<'a> {
492 fn from(u: &'a UserInfo) -> Self {
493 Self::new(&u.bytes)
494 }
495}
496
497impl core::fmt::Display for UserInfoRef<'_> {
498 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
499 f.write_str(self.as_str())
500 }
501}
502
503fn fmt_redacted(bytes: &[u8], f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
515 let s = unsafe { core::str::from_utf8_unchecked(bytes) };
520 let (user, password) = match bytes.iter().position(|&b| b == b':') {
521 Some(i) => (&s[..i], Some(&s[i + 1..])),
522 None => (s, None),
523 };
524 let mut dbg = f.debug_struct("UserInfo");
525 dbg.field("user", &user);
526 if password.is_some() {
530 dbg.field("password", &"***");
531 }
532 dbg.finish()
533}
534
535impl core::fmt::Debug for UserInfo {
536 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
537 fmt_redacted(&self.bytes, f)
538 }
539}
540
541impl core::fmt::Debug for UserInfoRef<'_> {
542 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
543 fmt_redacted(self.bytes, f)
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 #[test]
552 fn from_static_str() {
553 let u = UserInfo::from_static("alice");
554 assert_eq!(u.as_bytes(), b"alice");
555 assert_eq!(u.as_str(), "alice");
556 }
557
558 #[test]
559 fn split_user_password_user_only() {
560 let u = UserInfo::from_static("alice");
561 assert_eq!(u.split_user_password(), (&b"alice"[..], None));
562 }
563
564 #[test]
565 fn split_user_password_both() {
566 let u = UserInfo::from_static("alice:secret");
567 let (user, pass) = u.split_user_password();
568 assert_eq!(user, b"alice");
569 assert_eq!(pass, Some(&b"secret"[..]));
570 }
571
572 #[test]
573 fn split_user_password_empty_user() {
574 let u = UserInfo::from_static(":secret");
575 let (user, pass) = u.split_user_password();
576 assert_eq!(user, b"");
577 assert_eq!(pass, Some(&b"secret"[..]));
578 }
579
580 #[test]
581 fn split_user_password_empty_password() {
582 let u = UserInfo::from_static("alice:");
583 let (user, pass) = u.split_user_password();
584 assert_eq!(user, b"alice");
585 assert_eq!(pass, Some(&b""[..]));
586 }
587
588 #[test]
589 fn split_user_password_multiple_colons() {
590 let u = UserInfo::from_static("alice:p:w");
592 let (user, pass) = u.split_user_password();
593 assert_eq!(user, b"alice");
594 assert_eq!(pass, Some(&b"p:w"[..]));
595 }
596
597 #[test]
598 fn to_basic_user_only() {
599 let u = UserInfo::from_static("alice");
600 let b = u.to_basic().unwrap();
601 assert_eq!(b.username(), "alice");
602 assert!(b.password().is_none());
603 }
604
605 #[test]
606 fn to_basic_user_password() {
607 let u = UserInfo::from_static("alice:secret");
608 let b = u.to_basic().unwrap();
609 assert_eq!(b.username(), "alice");
610 assert_eq!(b.password(), Some("secret"));
611 }
612
613 #[test]
616 fn debug_redacts_password() {
617 let u = UserInfo::from_static("alice:secret");
618 let s = format!("{u:?}");
619 assert!(!s.contains("secret"), "debug leaked password: {s}");
620 assert!(s.contains("alice"), "debug missing user: {s}");
621 assert!(s.contains("***"), "debug missing redaction marker: {s}");
622 }
623
624 #[test]
625 fn debug_omits_password_field_when_absent() {
626 let u = UserInfo::from_static("alice");
628 let s = format!("{u:?}");
629 assert!(s.contains("alice"));
630 assert!(
631 !s.contains("***"),
632 "debug shouldn't show *** for plain user"
633 );
634 assert!(!s.contains("password"), "debug shouldn't mention password");
635 }
636
637 #[test]
638 fn debug_redacts_empty_password() {
639 let u = UserInfo::from_static("alice:");
641 let s = format!("{u:?}");
642 assert!(s.contains("alice"));
643 assert!(s.contains("***"), "debug must redact even empty password");
644 }
645
646 #[test]
647 fn debug_redacts_multiple_colon_password() {
648 let u = UserInfo::from_static("alice:secret:more");
651 let s = format!("{u:?}");
652 assert!(!s.contains("secret"), "debug leaked password: {s}");
653 assert!(!s.contains("more"), "debug leaked password tail: {s}");
654 }
655
656 #[test]
657 fn ref_debug_matches_owned_redaction() {
658 let u = UserInfo::from_static("alice:secret");
661 let r: UserInfoRef<'_> = (&u).into();
662 let owned_dbg = format!("{u:?}");
663 let ref_dbg = format!("{r:?}");
664 assert_eq!(owned_dbg, ref_dbg);
665 }
666
667 #[test]
668 fn to_basic_rejects_empty_user() {
669 let u = UserInfo::from_static(":secret");
671 u.to_basic().unwrap_err();
672 }
673
674 #[test]
675 fn try_from_str_rejects_control_chars() {
676 UserInfo::try_from("alice\r").unwrap_err();
677 UserInfo::try_from("alice\n").unwrap_err();
678 UserInfo::try_from("alice\0").unwrap_err();
679 UserInfo::try_from("alice\x7F").unwrap_err();
680 }
681
682 #[test]
683 fn try_from_str_accepts_valid() {
684 UserInfo::try_from("alice").unwrap();
685 UserInfo::try_from("alice:secret").unwrap();
686 UserInfo::try_from("us!er$tag").unwrap();
687 UserInfo::try_from("user%40info").unwrap(); }
689
690 #[test]
697 fn try_from_str_rejects_raw_at_sign() {
698 UserInfo::try_from("alice@example.com").unwrap_err();
701 UserInfo::try_from("a@b@c").unwrap_err();
702 }
703
704 #[test]
705 fn try_from_str_rejects_raw_space() {
706 UserInfo::try_from("a b").unwrap_err();
707 UserInfo::try_from("alice secret").unwrap_err();
708 }
709
710 #[test]
711 fn try_from_str_rejects_gen_delims() {
712 UserInfo::try_from("user/path").unwrap_err();
715 UserInfo::try_from("user?query").unwrap_err();
716 UserInfo::try_from("user#frag").unwrap_err();
717 UserInfo::try_from("user[bracket").unwrap_err();
718 }
719
720 #[test]
721 fn try_from_str_rejects_malformed_pct() {
722 UserInfo::try_from("user%4").unwrap_err(); UserInfo::try_from("user%4Z").unwrap_err(); UserInfo::try_from("user%").unwrap_err(); }
726
727 #[test]
728 fn try_from_str_rejects_pct_decoded_control_byte() {
729 UserInfo::try_from("user%00").unwrap_err();
734 UserInfo::try_from("user%0D").unwrap_err();
735 UserInfo::try_from("user%0A").unwrap_err();
736 UserInfo::try_from("user%09").unwrap_err();
737 UserInfo::try_from("user%7F").unwrap_err();
738 }
739
740 #[test]
741 fn from_static_str_panics_on_invalid_input() {
742 let bad_inputs = [
746 "alice@host", "alice bob", "user%4", "user%00", ];
751 for input in bad_inputs {
752 let result = std::panic::catch_unwind(|| {
753 UserInfo::from_static(unsafe {
754 core::mem::transmute::<&str, &'static str>(input)
757 })
758 });
759 assert!(result.is_err(), "expected panic for {input:?}");
760 }
761 }
762
763 #[test]
764 fn from_static_str_accepts_valid_inputs() {
765 let _u = UserInfo::from_static("alice");
766 let _u = UserInfo::from_static("alice:secret");
767 let _u = UserInfo::from_static("user%40info"); }
769
770 #[test]
771 fn from_basic_serializes_canonical() {
772 use crate::user::credentials::basic;
773
774 let b = basic!("alice", "secret");
775 let u = UserInfo::from(b);
776 assert_eq!(u.as_str(), "alice:secret");
777 }
778
779 #[test]
780 fn from_basic_user_only() {
781 use rama_utils::str::non_empty_str;
782
783 let b = Basic::new_insecure(non_empty_str!("alice"));
784 let u = UserInfo::from(b);
785 assert_eq!(u.as_str(), "alice");
786 }
787
788 #[test]
789 fn ref_split_user_password() {
790 let u = UserInfo::from_static("alice:secret");
791 let r = u.view();
792 assert_eq!(
793 r.split_user_password(),
794 (&b"alice"[..], Some(&b"secret"[..]))
795 );
796 }
797
798 #[test]
799 fn ref_into_owned_roundtrip() {
800 let u = UserInfo::from_static("alice:secret");
801 let r = u.view();
802 let owned = r.into_owned();
803 assert_eq!(owned, u);
804 }
805
806 #[test]
809 fn try_from_userinfo_for_basic_user_password() {
810 let u = UserInfo::from_static("alice:secret");
811 let b = Basic::try_from(&u).unwrap();
812 assert_eq!(b.username(), "alice");
813 assert_eq!(b.password(), Some("secret"));
814
815 let b2 = Basic::try_from(u).unwrap();
817 assert_eq!(b2.username(), "alice");
818 }
819
820 #[test]
821 fn try_from_userinfo_for_basic_propagates_error() {
822 let u = UserInfo::from_static(":secret");
824 Basic::try_from(&u).unwrap_err();
825 }
826
827 #[test]
830 fn decoded_accessors() {
831 let ui = UserInfo::from_static("us%20er:p%40ss");
832 assert_eq!(&*ui.as_decoded_str(), "us er:p@ss");
833 assert_eq!(&*ui.username_decoded(), "us er");
834 assert_eq!(ui.password_decoded().as_deref(), Some("p@ss"));
835
836 let r = ui.view();
838 assert_eq!(&*r.as_decoded_str(), "us er:p@ss");
839 assert_eq!(&*r.username_decoded(), "us er");
840 assert_eq!(r.password_decoded().as_deref(), Some("p@ss"));
841
842 let ui = UserInfo::from_static("alice");
844 assert_eq!(&*ui.username_decoded(), "alice");
845 assert!(ui.password_decoded().is_none());
846 }
847
848 #[test]
849 fn to_basic_percent_decodes_components() {
850 let ui = UserInfo::from_static("user%40host:p%40ss");
851 let b = ui.to_basic().unwrap();
852 assert_eq!(b.username(), "user@host");
853 assert_eq!(b.password(), Some("p@ss"));
854 }
855
856 #[test]
857 fn to_basic_username_with_encoded_colon() {
858 let ui = UserInfo::from_static("a%3Ab:pw");
862 let b = ui.to_basic().unwrap();
863 assert_eq!(b.username(), "a:b");
864 assert_eq!(b.password(), Some("pw"));
865 }
866
867 #[test]
868 fn to_basic_rejects_pct_decoded_control() {
869 for raw in [b"a%0Db".as_slice(), b"a%0Ab", b"a%00b", b"user:p%0Dw"] {
875 let ui = UserInfo::from_bytes_unchecked(Bytes::copy_from_slice(raw));
876 ui.to_basic().unwrap_err();
877 }
878 }
879
880 #[test]
883 fn from_basic_percent_encodes_and_roundtrips() {
884 let basic = Basic::try_from("user@host:p@ss").unwrap();
889 let ui: UserInfo = basic.clone().into();
890 assert_eq!(ui.as_str(), "user%40host:p%40ss");
891 UserInfo::try_from(ui.as_str()).expect("encoded userinfo must re-parse");
892 let back = ui.to_basic().unwrap();
893 assert_eq!(back, basic);
894 assert_eq!(back.username(), "user@host");
895 assert_eq!(back.password(), Some("p@ss"));
896 }
897
898 #[test]
899 fn from_basic_escapes_colon_in_username() {
900 let basic = Basic::new(
903 NonEmptyStr::try_from("a:b").unwrap(),
904 NonEmptyStr::try_from("pw").unwrap(),
905 );
906 let ui: UserInfo = basic.into();
907 assert_eq!(ui.as_str(), "a%3Ab:pw");
908 let back = ui.to_basic().unwrap();
909 assert_eq!(back.username(), "a:b");
910 assert_eq!(back.password(), Some("pw"));
911 }
912
913 #[test]
914 fn from_basic_control_byte_is_the_residual_divergence() {
915 let basic = Basic::try_from("a\tb:pw").unwrap();
919 let ui: UserInfo = basic.into();
920 assert_eq!(ui.as_str(), "a%09b:pw");
921 UserInfo::try_from(ui.as_str()).unwrap_err();
922 }
923
924 #[test]
925 fn userinfo_encode_set_matches_validator_allow_set() {
926 let escapes = |set: &'static AsciiSet, b: u8| {
929 let buf = [b];
930 let s = core::str::from_utf8(&buf).unwrap();
931 utf8_percent_encode(s, set).to_string().as_str() != s
932 };
933 for b in 0u8..=127 {
934 if b == b'%' {
937 continue;
938 }
939 let pw_escaped = escapes(USERINFO_PASSWORD_ENCODE_SET, b);
942 assert_eq!(
943 crate::byte_sets::is_userinfo_byte(b),
944 !pw_escaped,
945 "password set disagrees on byte {b:#04x}",
946 );
947 assert_eq!(
949 escapes(USERINFO_USERNAME_ENCODE_SET, b),
950 pw_escaped || b == b':',
951 "username set disagrees on byte {b:#04x}",
952 );
953 }
954 }
955}