Skip to main content

rama_net/address/
user_info.rs

1//! [`UserInfo`] — the RFC 3986 §3.2.1 userinfo component.
2//!
3//! `userinfo = *( unreserved / pct-encoded / sub-delims / ":" )`.
4//!
5//! Conventionally `user[:password]` but the grammar allows any
6//! pchar-without-`@` byte sequence (and pct-encoded `@`). The raw on-wire
7//! bytes are preserved verbatim; read the percent-decoded logical view
8//! via [`UserInfo::as_decoded_str`] / [`UserInfo::username_decoded`] /
9//! [`UserInfo::password_decoded`], split on the raw `:` via
10//! [`UserInfo::split_user_password`], or cross into HTTP Basic-Auth (which
11//! decodes + validates) via [`UserInfo::to_basic`]. The reverse —
12//! [`From<Basic>`](UserInfo) — percent-encodes back into wire form.
13
14use 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
25/// Bytes percent-encoded inside a userinfo **password** component when
26/// serializing a [`Basic`] back to wire form. Pass-through mirrors the
27/// allow-set in [`crate::byte_sets`] (`unreserved / sub-delims / ":"`);
28/// `%` is encoded so raw content round-trips (a literal `%` → `%25`).
29const 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
48/// Same as [`USERINFO_PASSWORD_ENCODE_SET`] but also escapes `:`, so a `:`
49/// inside a username can't be mistaken for the user/password separator on
50/// the way back in.
51const USERINFO_USERNAME_ENCODE_SET: &AsciiSet = &USERINFO_PASSWORD_ENCODE_SET.add(b':');
52
53/// Reject a percent-decoded userinfo component that contains a control
54/// byte. Decoding is what makes this necessary: the graceful authority
55/// parser screens only *raw* control bytes, so a userinfo like `a%0Db`
56/// reaches [`UserInfoRef::to_basic`]; decoding it into a credential that
57/// round-trips into an `Authorization` header would be CRLF injection.
58fn 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/// Raw RFC 3986 userinfo bytes. Cheap to clone.
68///
69/// # Logging safety
70///
71/// The [`Debug`](core::fmt::Debug) impl redacts the password portion (anything
72/// after the first `:`) as `"***"`, matching [`Basic`]'s logging behaviour.
73/// This is the safe default for tracing spans and log lines, where a raw
74/// `Debug`-print of a [`Uri`](crate::uri::Uri) would otherwise leak
75/// credentials into observability sinks. The user portion is rendered raw;
76/// pct-encoded bytes are not decoded for the Debug view.
77#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct UserInfo {
79    bytes: Bytes,
80}
81
82impl UserInfo {
83    /// Construct from a compile-time string. Zero-allocation.
84    ///
85    /// **Panics at compile time** if `s` contains a byte outside the
86    /// RFC 3986 §3.2.1 userinfo grammar (`unreserved / pct-encoded /
87    /// sub-delims / ":"`). This matches the URI parser's strict-mode
88    /// validation: byte sets stay single-sourced, and typed construction
89    /// can never produce a `UserInfo` that the parser would reject.
90    ///
91    /// Naming: `from_static` matches the `Uri::from_static` /
92    /// `Domain::from_static` precedent across the rest of the crate.
93    #[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    /// Construct from already-validated bytes (parser invariant: UTF-8,
102    /// no `@` or control characters). Skips validation — only callable
103    /// from inside the crate.
104    #[must_use]
105    pub(crate) fn from_bytes_unchecked(bytes: Bytes) -> Self {
106        Self { bytes }
107    }
108
109    /// Raw on-the-wire bytes (possibly pct-encoded — not decoded here).
110    #[must_use]
111    pub fn as_bytes(&self) -> &[u8] {
112        &self.bytes
113    }
114
115    /// `&str` view of the raw bytes. Parser-validated UTF-8.
116    #[must_use]
117    pub fn as_str(&self) -> &str {
118        // Safety: parser only emits UTF-8 (graceful) or ASCII (strict).
119        // `from_static_str` is the other constructor and inputs `&str`.
120        unsafe { core::str::from_utf8_unchecked(&self.bytes) }
121    }
122
123    /// Borrowed view. Named `view` (not `as_ref`) so it doesn't shadow
124    /// the std `AsRef` trait — see the type-level docs.
125    #[must_use]
126    #[inline]
127    pub fn view(&self) -> UserInfoRef<'_> {
128        UserInfoRef::new(&self.bytes)
129    }
130
131    /// Split on the first `:`. Returns `(user_bytes, password_bytes)`
132    /// where the password is `None` if no `:` is present.
133    ///
134    /// **Bytes are raw — not percent-decoded.** Use
135    /// [`crate::uri::util::percent_encoding`] to decode if needed.
136    #[must_use]
137    pub fn split_user_password(&self) -> (&[u8], Option<&[u8]>) {
138        self.view().split_user_password()
139    }
140
141    /// Percent-decoded view of the full userinfo. `Cow::Borrowed` when no
142    /// `%XX` escapes are present; UTF-8 errors fall back to U+FFFD.
143    ///
144    /// **Lossy for re-splitting**: a `%3A` in the user part decodes to a
145    /// literal `:`. Use [`UserInfo::username_decoded`] /
146    /// [`UserInfo::password_decoded`] to decode the components separately.
147    #[must_use]
148    pub fn as_decoded_str(&self) -> Cow<'_, str> {
149        self.view().as_decoded_str()
150    }
151
152    /// Percent-decoded user component (everything before the first raw `:`).
153    #[must_use]
154    pub fn username_decoded(&self) -> Cow<'_, str> {
155        self.view().username_decoded()
156    }
157
158    /// Percent-decoded password component (everything after the first raw
159    /// `:`), or `None` if no `:` is present.
160    #[must_use]
161    pub fn password_decoded(&self) -> Option<Cow<'_, str>> {
162        self.view().password_decoded()
163    }
164
165    /// Convenience: convert this userinfo into a [`Basic`] HTTP
166    /// Basic-Auth credential. The components are **percent-decoded** first
167    /// (so `user%40host` becomes `user@host`), then validated. See
168    /// [`UserInfoRef::to_basic`] for the failure modes.
169    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    /// Validate `s` against the RFC 3986 §3.2.1 userinfo grammar —
191    /// same byte set the URI parser uses in strict mode. Rejects:
192    ///
193    /// - Control bytes anywhere.
194    /// - Raw `@` (must be percent-encoded as `%40`).
195    /// - Raw space, brackets, gen-delims, and other non-userinfo bytes.
196    /// - Malformed pct-escapes (`%X` truncated, `%XY` non-hex).
197    /// - Pct-decoded control bytes (smuggling vector).
198    ///
199    /// Without this guard, `Uri::set_authority(authority_with_loose_userinfo)`
200    /// could embed bytes the parser would otherwise reject — producing
201    /// URIs that round-trip into malformed wire form.
202    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/// First-violation tag returned by the shared const validator below.
221/// Lets the runtime and `from_static_str` entry points decode the same
222/// algorithm into their preferred error mode (boxed error vs panic).
223#[derive(Clone, Copy)]
224enum UserInfoFault {
225    /// Raw control byte (`< 0x20` or `0x7F`).
226    ControlByte,
227    /// `%` followed by fewer than two bytes.
228    PctTruncated,
229    /// `%XY` where `X` or `Y` is not a hex digit.
230    PctMalformed,
231    /// `%XX` that decodes to a control byte (smuggling vector).
232    PctDecodesToControl,
233    /// Byte outside the RFC 3986 §3.2.1 userinfo byte set.
234    DisallowedByte,
235}
236
237/// `const` userinfo-grammar walker — single source of truth for both
238/// [`validate_userinfo_runtime`] (returns [`BoxError`]) and
239/// [`validate_userinfo_static`] (panics). Adding a rejection rule here
240/// can't drift between the two entry points.
241///
242/// Rules (RFC 3986 §3.2.1):
243/// - Each byte must be `unreserved / pct-encoded / sub-delims / ":"`.
244/// - `%XX` must be a well-formed hex pair.
245/// - Pct-decoded byte must not be a control character (smuggling
246///   defense — mirrors the URI parser's reg-name handling).
247const 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
277/// Runtime userinfo-grammar validator. Used by `TryFrom<&str>` /
278/// `TryFrom<String>`; maps the const walker's fault tag into a boxed
279/// error suitable for the `?`-ladder.
280fn 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/// `const` validator for [`UserInfo::from_static`]. Maps the
300/// const walker's fault tag into a `panic!` at compile time so static
301/// inputs that violate the grammar fail the build.
302#[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
327/// Construct a [`UserInfo`] from a [`Basic`] credential by
328/// **percent-encoding** each component into the RFC 3986 §3.2.1 userinfo
329/// wire form. The username escapes `:` (so the first literal `:` is
330/// unambiguously the user/password separator); the password keeps `:`
331/// literal. `@`, space, and every other non-userinfo byte become `%XX`,
332/// so the result re-parses cleanly through [`UserInfo::try_from`] and
333/// decodes back to the original credential via [`UserInfo::to_basic`].
334///
335/// # Round-trip invariant
336///
337/// Encoding always yields grammar-valid userinfo. The `Basic` →
338/// `UserInfo` → [`UserInfo::try_from`] round-trip holds for exactly the
339/// `Basic` values whose components are free of control bytes
340/// (`0x00`–`0x1F` / `0x7F`): any control byte encodes to a `%XX` escape
341/// that strict parsing then refuses as a pct-decoded-control smuggling
342/// vector. [`Basic::try_from`] pre-rejects `\r` / `\n` / NUL, but the
343/// typed constructors (`Basic::new`, the `with_`/`set_` setters,
344/// `clone_with_*`) do not validate, so any control byte can reach this
345/// obscure residual regardless of which entry point built the `Basic`.
346impl 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    /// Parse a [`UserInfo`] into HTTP Basic-Auth credentials. Same
367    /// semantics as [`UserInfo::to_basic`] — kept as a [`TryFrom`]
368    /// impl for the standard `Basic::try_from(&userinfo)?` idiom.
369    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    /// Owned-input form of [`TryFrom<&UserInfo>`](Self#impl-TryFrom<%26UserInfo>-for-Basic).
378    /// Routes through the borrowed impl since [`Basic::try_from`]
379    /// doesn't need to own the bytes.
380    fn try_from(value: UserInfo) -> Result<Self, Self::Error> {
381        Self::try_from(&value)
382    }
383}
384
385/// Borrowed view of a [`UserInfo`]. Carries no ownership of the
386/// underlying bytes.
387///
388/// `Debug` follows [`UserInfo`]'s redacting policy (password portion
389/// rendered as `"***"`).
390#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
391pub struct UserInfoRef<'a> {
392    bytes: &'a [u8],
393}
394
395impl<'a> UserInfoRef<'a> {
396    /// `pub(crate)` constructor — only the parser / accessors should
397    /// produce one.
398    #[must_use]
399    #[inline]
400    pub(crate) const fn new(bytes: &'a [u8]) -> Self {
401        Self { bytes }
402    }
403
404    /// Raw on-the-wire bytes (possibly pct-encoded).
405    #[must_use]
406    pub fn as_bytes(&self) -> &'a [u8] {
407        self.bytes
408    }
409
410    /// `&str` view (parser-validated UTF-8).
411    #[must_use]
412    pub fn as_str(&self) -> &'a str {
413        // Safety: parser invariant.
414        unsafe { core::str::from_utf8_unchecked(self.bytes) }
415    }
416
417    /// Split on the first `:`. Mirrors [`UserInfo::split_user_password`].
418    #[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    /// Construct an owned [`UserInfo`] by copying the bytes. Named
427    /// `into_owned` (matching [`crate::std::borrow::Cow::into_owned`]) so it doesn't
428    /// shadow the std `ToOwned` trait method.
429    #[must_use]
430    pub fn into_owned(self) -> UserInfo {
431        UserInfo {
432            bytes: Bytes::copy_from_slice(self.bytes),
433        }
434    }
435
436    /// Percent-decoded view of the full userinfo. See
437    /// [`UserInfo::as_decoded_str`] (incl. the re-splitting caveat).
438    #[must_use]
439    pub fn as_decoded_str(&self) -> Cow<'a, str> {
440        percent_decode(self.bytes).decode_utf8_lossy()
441    }
442
443    /// Percent-decoded user component (before the first raw `:`).
444    #[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    /// Percent-decoded password component (after the first raw `:`), or
451    /// `None` if no `:` is present.
452    #[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    /// Convert to a [`Basic`] HTTP credential. The components are
459    /// **percent-decoded** then validated.
460    ///
461    /// Fails if the decoded user is empty (`Basic` requires a non-empty
462    /// username) or if a decoded component contains a control byte. The
463    /// latter is load-bearing: the graceful authority parser screens only
464    /// *raw* control bytes, so a userinfo like `a%0Db` can reach here, and
465    /// decoding it into a credential that round-trips into an
466    /// `Authorization` header would be CRLF injection.
467    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                // An empty present password (`user:`) collapses to "no
476                // password", matching `Basic::try_from`'s own semantics.
477                (!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
503// ---- Redacting Debug ------------------------------------------------------
504//
505// Userinfo carries credentials by convention (`user:password`). A raw
506// `Debug` print would leak the password into any tracing span, panic
507// message, or `assert!`/`dbg!` line that touches a `Uri`. Mirror what
508// [`Basic`]'s Debug impl does: username verbatim, password as `"***"`.
509//
510// Shared `fmt_redacted` helper drives both the owned and borrowed views;
511// the borrowed view's impl is the canonical site and the owned one
512// delegates through `UserInfoRef::from(self)`.
513
514fn fmt_redacted(bytes: &[u8], f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
515    // SAFETY: parser/static-validator invariant: UserInfo bytes are valid
516    // UTF-8 (graceful preserves UTF-8; strict is ASCII-only; the
517    // const validator at `from_static_str` rejects non-UTF-8 byte
518    // sequences via its byte-class check, which is ASCII).
519    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    // Render the password field as the literal `"***"` regardless of
527    // whether it's empty — its mere presence is the credential signal
528    // we want to surface in logs.
529    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        // RFC 3986 userinfo allows multiple `:`. First `:` is the split.
591        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    // -- Debug redaction -------------------------------------
614
615    #[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        // No `:` → no credential → no `password` field at all.
627        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        // Empty password is still a credential signal.
640        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        // RFC 3986 allows extra `:` in the password portion. Redaction
649        // covers everything after the first `:`.
650        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        // The borrowed view uses the same redacting helper as the owned
659        // type so logging through either path is safe.
660        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        // `Basic` requires non-empty username.
670        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(); // pct-encoded `@`
688    }
689
690    /// Regression: previously `try_from` only rejected control bytes,
691    /// so `UserInfo::try_from("a b@c")` succeeded and
692    /// `Uri::set_authority(...)` then rendered `//a b@c@host/` — a
693    /// malformed wire URI the parser would never accept. Tightened
694    /// validation now matches the URI parser's strict-mode userinfo
695    /// byte set.
696    #[test]
697    fn try_from_str_rejects_raw_at_sign() {
698        // Raw `@` is the userinfo terminator and MUST be pct-encoded
699        // (`%40`) per RFC 3986 §3.2.1.
700        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        // gen-delims (other than `:` and `@`, which have specific roles)
713        // aren't valid in userinfo.
714        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(); // truncated
723        UserInfo::try_from("user%4Z").unwrap_err(); // non-hex
724        UserInfo::try_from("user%").unwrap_err(); // bare %
725    }
726
727    #[test]
728    fn try_from_str_rejects_pct_decoded_control_byte() {
729        // Smuggling defense: `%00` (NUL), `%0D` (CR), `%0A` (LF), etc.
730        // Even though the wire bytes are printable, the decoded byte
731        // would be a control character — same protection the URI
732        // parser applies to reg-name pct-escapes.
733        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        // `from_static_str` is a const fn that validates at compile
743        // time. Use `catch_unwind` to verify the runtime panic message
744        // for cases that would-be compile errors in actual usage.
745        let bad_inputs = [
746            "alice@host", // raw @
747            "alice bob",  // raw space
748            "user%4",     // truncated pct
749            "user%00",    // pct-decoded NUL
750        ];
751        for input in bad_inputs {
752            let result = std::panic::catch_unwind(|| {
753                UserInfo::from_static(unsafe {
754                    // Safety: the leaked `&'static str` is only used
755                    // inside `catch_unwind`; we never escape it.
756                    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"); // pct-encoded @
768    }
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    // ---- TryFrom<UserInfo> for Basic ------------------------
807
808    #[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        // Owned-input form delegates to the borrowed impl.
816        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        // Empty username — `Basic::try_from(&str)` rejects.
823        let u = UserInfo::from_static(":secret");
824        Basic::try_from(&u).unwrap_err();
825    }
826
827    // ---- decoded accessors + encode/decode round-trip -------
828
829    #[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        // the borrowed view agrees with the owned type
837        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        // no password component
843        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        // `%3A` decodes to ':' inside the username, but the user/password
859        // split happens on the *raw* ':' separator, so the password is
860        // still parsed correctly.
861        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        // The graceful authority parser screens only RAW control bytes, so
870        // a userinfo whose pct-escape *decodes* to CR/LF/NUL can exist.
871        // Decoding it into an `Authorization`-bound credential would be
872        // CRLF injection, so `to_basic` must reject it. (`from_static`
873        // would reject at compile time, so build via the unchecked ctor.)
874        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    // ---- `From<Basic>` now percent-encodes -------
881
882    #[test]
883    fn from_basic_percent_encodes_and_roundtrips() {
884        // `@` in both components: `Basic::try_from` accepts it (only
885        // CR/LF/NUL are rejected), and the encoder now emits valid wire
886        // form that strict `UserInfo::try_from` accepts — the old
887        // divergence is gone — and decodes back to the original credential.
888        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        // A `:` inside the username must be escaped so it isn't read as
901        // the user/password separator on the way back in.
902        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        // Documented residual: `Basic::try_from` allows a tab (only
916        // CR/LF/NUL are rejected), so it encodes to `%09`, which strict
917        // `UserInfo::try_from` still refuses as a pct-decoded control byte.
918        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        // `AsciiSet::contains` is crate-private, so probe each set by
927        // encoding a one-byte string and checking whether it changed.
928        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            // `%` is the one intentional difference: the validator allows
935            // it as the pct-escape lead, but the encoder escapes a raw `%`.
936            if b == b'%' {
937                continue;
938            }
939            // The password set keeps ':' literal, so a byte is escaped by
940            // it iff it's outside the userinfo allow-set.
941            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            // The username set differs only by also escaping ':'.
948            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}