use crate::std::{borrow::Cow, string::String};
use crate::user::Basic;
use rama_core::bytes::Bytes;
use rama_core::error::BoxErrorExt as _;
use rama_core::error::{BoxError, ErrorContext};
use rama_utils::str::NonEmptyStr;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode, utf8_percent_encode};
const USERINFO_PASSWORD_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'/')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
const USERINFO_USERNAME_ENCODE_SET: &AsciiSet = &USERINFO_PASSWORD_ENCODE_SET.add(b':');
fn reject_decoded_control(s: &str) -> Result<(), BoxError> {
if s.as_bytes().iter().any(|&b| b < 0x20 || b == 0x7F) {
return Err(BoxError::from_static_str(
"decoded userinfo component contains a control character",
));
}
Ok(())
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UserInfo {
bytes: Bytes,
}
impl UserInfo {
#[must_use]
pub const fn from_static(s: &'static str) -> Self {
validate_userinfo_static(s.as_bytes());
Self {
bytes: Bytes::from_static(s.as_bytes()),
}
}
#[must_use]
pub(crate) fn from_bytes_unchecked(bytes: Bytes) -> Self {
Self { bytes }
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
#[must_use]
pub fn as_str(&self) -> &str {
unsafe { core::str::from_utf8_unchecked(&self.bytes) }
}
#[must_use]
#[inline]
pub fn view(&self) -> UserInfoRef<'_> {
UserInfoRef::new(&self.bytes)
}
#[must_use]
pub fn split_user_password(&self) -> (&[u8], Option<&[u8]>) {
self.view().split_user_password()
}
#[must_use]
pub fn as_decoded_str(&self) -> Cow<'_, str> {
self.view().as_decoded_str()
}
#[must_use]
pub fn username_decoded(&self) -> Cow<'_, str> {
self.view().username_decoded()
}
#[must_use]
pub fn password_decoded(&self) -> Option<Cow<'_, str>> {
self.view().password_decoded()
}
pub fn to_basic(&self) -> Result<Basic, BoxError> {
self.view().to_basic()
}
}
impl core::fmt::Display for UserInfo {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
impl core::str::FromStr for UserInfo {
type Err = BoxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s)
}
}
impl TryFrom<&str> for UserInfo {
type Error = BoxError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
validate_userinfo_runtime(s.as_bytes())?;
Ok(Self {
bytes: Bytes::copy_from_slice(s.as_bytes()),
})
}
}
impl TryFrom<String> for UserInfo {
type Error = BoxError;
fn try_from(s: String) -> Result<Self, Self::Error> {
validate_userinfo_runtime(s.as_bytes())?;
Ok(Self {
bytes: Bytes::from(s),
})
}
}
#[derive(Clone, Copy)]
enum UserInfoFault {
ControlByte,
PctTruncated,
PctMalformed,
PctDecodesToControl,
DisallowedByte,
}
const fn validate_userinfo_bytes(bytes: &[u8]) -> Result<(), UserInfoFault> {
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b < 0x20 || b == 0x7F {
return Err(UserInfoFault::ControlByte);
}
if b == b'%' {
if i + 2 >= bytes.len() {
return Err(UserInfoFault::PctTruncated);
}
let h1 = bytes[i + 1];
let h2 = bytes[i + 2];
if !h1.is_ascii_hexdigit() || !h2.is_ascii_hexdigit() {
return Err(UserInfoFault::PctMalformed);
}
if crate::byte_sets::pct_decoded_control_byte(h1, h2).is_some() {
return Err(UserInfoFault::PctDecodesToControl);
}
i += 3;
continue;
}
if !crate::byte_sets::is_userinfo_byte(b) {
return Err(UserInfoFault::DisallowedByte);
}
i += 1;
}
Ok(())
}
fn validate_userinfo_runtime(bytes: &[u8]) -> Result<(), BoxError> {
match validate_userinfo_bytes(bytes) {
Ok(()) => Ok(()),
Err(fault) => {
let msg = match fault {
UserInfoFault::ControlByte => "userinfo contains control character",
UserInfoFault::PctTruncated | UserInfoFault::PctMalformed => {
"userinfo contains malformed percent-escape"
}
UserInfoFault::PctDecodesToControl => {
"userinfo pct-escape decodes to a control character"
}
UserInfoFault::DisallowedByte => "userinfo contains disallowed character",
};
Err(BoxError::from_static_str(msg))
}
}
}
#[expect(
clippy::panic,
reason = "static-str invariant: compile-time panic when the static input violates the userinfo grammar"
)]
const fn validate_userinfo_static(bytes: &[u8]) {
match validate_userinfo_bytes(bytes) {
Ok(()) => {}
Err(UserInfoFault::ControlByte) => {
panic!("UserInfo::from_static: control character in input")
}
Err(UserInfoFault::PctTruncated) => {
panic!("UserInfo::from_static: truncated percent-escape")
}
Err(UserInfoFault::PctMalformed) => {
panic!("UserInfo::from_static: malformed percent-escape")
}
Err(UserInfoFault::PctDecodesToControl) => {
panic!("UserInfo::from_static: pct-escape decodes to a control character")
}
Err(UserInfoFault::DisallowedByte) => {
panic!("UserInfo::from_static: disallowed character")
}
}
}
impl From<Basic> for UserInfo {
fn from(basic: Basic) -> Self {
let mut s = String::new();
s.extend(utf8_percent_encode(
basic.username(),
USERINFO_USERNAME_ENCODE_SET,
));
if let Some(password) = basic.password() {
s.push(':');
s.extend(utf8_percent_encode(password, USERINFO_PASSWORD_ENCODE_SET));
}
Self {
bytes: Bytes::from(s),
}
}
}
impl TryFrom<&UserInfo> for Basic {
type Error = BoxError;
fn try_from(value: &UserInfo) -> Result<Self, Self::Error> {
value.to_basic()
}
}
impl TryFrom<UserInfo> for Basic {
type Error = BoxError;
fn try_from(value: UserInfo) -> Result<Self, Self::Error> {
Self::try_from(&value)
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UserInfoRef<'a> {
bytes: &'a [u8],
}
impl<'a> UserInfoRef<'a> {
#[must_use]
#[inline]
pub(crate) const fn new(bytes: &'a [u8]) -> Self {
Self { bytes }
}
#[must_use]
pub fn as_bytes(&self) -> &'a [u8] {
self.bytes
}
#[must_use]
pub fn as_str(&self) -> &'a str {
unsafe { core::str::from_utf8_unchecked(self.bytes) }
}
#[must_use]
pub fn split_user_password(&self) -> (&'a [u8], Option<&'a [u8]>) {
match self.bytes.iter().position(|&b| b == b':') {
Some(i) => (&self.bytes[..i], Some(&self.bytes[i + 1..])),
None => (self.bytes, None),
}
}
#[must_use]
pub fn into_owned(self) -> UserInfo {
UserInfo {
bytes: Bytes::copy_from_slice(self.bytes),
}
}
#[must_use]
pub fn as_decoded_str(&self) -> Cow<'a, str> {
percent_decode(self.bytes).decode_utf8_lossy()
}
#[must_use]
pub fn username_decoded(&self) -> Cow<'a, str> {
let (user, _) = self.split_user_password();
percent_decode(user).decode_utf8_lossy()
}
#[must_use]
pub fn password_decoded(&self) -> Option<Cow<'a, str>> {
let (_, password) = self.split_user_password();
password.map(|p| percent_decode(p).decode_utf8_lossy())
}
pub fn to_basic(&self) -> Result<Basic, BoxError> {
let user = self.username_decoded();
reject_decoded_control(&user)?;
let username =
NonEmptyStr::try_from(user.as_ref()).context("create username from userinfo")?;
let password = match self.password_decoded() {
Some(p) => {
reject_decoded_control(&p)?;
(!p.is_empty())
.then(|| NonEmptyStr::try_from(p.as_ref()))
.transpose()
.context("create password from userinfo")?
}
None => None,
};
Ok(match password {
Some(password) => Basic::new(username, password),
None => Basic::new_insecure(username),
})
}
}
impl<'a> From<&'a UserInfo> for UserInfoRef<'a> {
fn from(u: &'a UserInfo) -> Self {
Self::new(&u.bytes)
}
}
impl core::fmt::Display for UserInfoRef<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
fn fmt_redacted(bytes: &[u8], f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let s = unsafe { core::str::from_utf8_unchecked(bytes) };
let (user, password) = match bytes.iter().position(|&b| b == b':') {
Some(i) => (&s[..i], Some(&s[i + 1..])),
None => (s, None),
};
let mut dbg = f.debug_struct("UserInfo");
dbg.field("user", &user);
if password.is_some() {
dbg.field("password", &"***");
}
dbg.finish()
}
impl core::fmt::Debug for UserInfo {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
fmt_redacted(&self.bytes, f)
}
}
impl core::fmt::Debug for UserInfoRef<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
fmt_redacted(self.bytes, f)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_static_str() {
let u = UserInfo::from_static("alice");
assert_eq!(u.as_bytes(), b"alice");
assert_eq!(u.as_str(), "alice");
}
#[test]
fn split_user_password_user_only() {
let u = UserInfo::from_static("alice");
assert_eq!(u.split_user_password(), (&b"alice"[..], None));
}
#[test]
fn split_user_password_both() {
let u = UserInfo::from_static("alice:secret");
let (user, pass) = u.split_user_password();
assert_eq!(user, b"alice");
assert_eq!(pass, Some(&b"secret"[..]));
}
#[test]
fn split_user_password_empty_user() {
let u = UserInfo::from_static(":secret");
let (user, pass) = u.split_user_password();
assert_eq!(user, b"");
assert_eq!(pass, Some(&b"secret"[..]));
}
#[test]
fn split_user_password_empty_password() {
let u = UserInfo::from_static("alice:");
let (user, pass) = u.split_user_password();
assert_eq!(user, b"alice");
assert_eq!(pass, Some(&b""[..]));
}
#[test]
fn split_user_password_multiple_colons() {
let u = UserInfo::from_static("alice:p:w");
let (user, pass) = u.split_user_password();
assert_eq!(user, b"alice");
assert_eq!(pass, Some(&b"p:w"[..]));
}
#[test]
fn to_basic_user_only() {
let u = UserInfo::from_static("alice");
let b = u.to_basic().unwrap();
assert_eq!(b.username(), "alice");
assert!(b.password().is_none());
}
#[test]
fn to_basic_user_password() {
let u = UserInfo::from_static("alice:secret");
let b = u.to_basic().unwrap();
assert_eq!(b.username(), "alice");
assert_eq!(b.password(), Some("secret"));
}
#[test]
fn debug_redacts_password() {
let u = UserInfo::from_static("alice:secret");
let s = format!("{u:?}");
assert!(!s.contains("secret"), "debug leaked password: {s}");
assert!(s.contains("alice"), "debug missing user: {s}");
assert!(s.contains("***"), "debug missing redaction marker: {s}");
}
#[test]
fn debug_omits_password_field_when_absent() {
let u = UserInfo::from_static("alice");
let s = format!("{u:?}");
assert!(s.contains("alice"));
assert!(
!s.contains("***"),
"debug shouldn't show *** for plain user"
);
assert!(!s.contains("password"), "debug shouldn't mention password");
}
#[test]
fn debug_redacts_empty_password() {
let u = UserInfo::from_static("alice:");
let s = format!("{u:?}");
assert!(s.contains("alice"));
assert!(s.contains("***"), "debug must redact even empty password");
}
#[test]
fn debug_redacts_multiple_colon_password() {
let u = UserInfo::from_static("alice:secret:more");
let s = format!("{u:?}");
assert!(!s.contains("secret"), "debug leaked password: {s}");
assert!(!s.contains("more"), "debug leaked password tail: {s}");
}
#[test]
fn ref_debug_matches_owned_redaction() {
let u = UserInfo::from_static("alice:secret");
let r: UserInfoRef<'_> = (&u).into();
let owned_dbg = format!("{u:?}");
let ref_dbg = format!("{r:?}");
assert_eq!(owned_dbg, ref_dbg);
}
#[test]
fn to_basic_rejects_empty_user() {
let u = UserInfo::from_static(":secret");
u.to_basic().unwrap_err();
}
#[test]
fn try_from_str_rejects_control_chars() {
UserInfo::try_from("alice\r").unwrap_err();
UserInfo::try_from("alice\n").unwrap_err();
UserInfo::try_from("alice\0").unwrap_err();
UserInfo::try_from("alice\x7F").unwrap_err();
}
#[test]
fn try_from_str_accepts_valid() {
UserInfo::try_from("alice").unwrap();
UserInfo::try_from("alice:secret").unwrap();
UserInfo::try_from("us!er$tag").unwrap();
UserInfo::try_from("user%40info").unwrap(); }
#[test]
fn try_from_str_rejects_raw_at_sign() {
UserInfo::try_from("alice@example.com").unwrap_err();
UserInfo::try_from("a@b@c").unwrap_err();
}
#[test]
fn try_from_str_rejects_raw_space() {
UserInfo::try_from("a b").unwrap_err();
UserInfo::try_from("alice secret").unwrap_err();
}
#[test]
fn try_from_str_rejects_gen_delims() {
UserInfo::try_from("user/path").unwrap_err();
UserInfo::try_from("user?query").unwrap_err();
UserInfo::try_from("user#frag").unwrap_err();
UserInfo::try_from("user[bracket").unwrap_err();
}
#[test]
fn try_from_str_rejects_malformed_pct() {
UserInfo::try_from("user%4").unwrap_err(); UserInfo::try_from("user%4Z").unwrap_err(); UserInfo::try_from("user%").unwrap_err(); }
#[test]
fn try_from_str_rejects_pct_decoded_control_byte() {
UserInfo::try_from("user%00").unwrap_err();
UserInfo::try_from("user%0D").unwrap_err();
UserInfo::try_from("user%0A").unwrap_err();
UserInfo::try_from("user%09").unwrap_err();
UserInfo::try_from("user%7F").unwrap_err();
}
#[test]
fn from_static_str_panics_on_invalid_input() {
let bad_inputs = [
"alice@host", "alice bob", "user%4", "user%00", ];
for input in bad_inputs {
let result = std::panic::catch_unwind(|| {
UserInfo::from_static(unsafe {
core::mem::transmute::<&str, &'static str>(input)
})
});
assert!(result.is_err(), "expected panic for {input:?}");
}
}
#[test]
fn from_static_str_accepts_valid_inputs() {
let _u = UserInfo::from_static("alice");
let _u = UserInfo::from_static("alice:secret");
let _u = UserInfo::from_static("user%40info"); }
#[test]
fn from_basic_serializes_canonical() {
use crate::user::credentials::basic;
let b = basic!("alice", "secret");
let u = UserInfo::from(b);
assert_eq!(u.as_str(), "alice:secret");
}
#[test]
fn from_basic_user_only() {
use rama_utils::str::non_empty_str;
let b = Basic::new_insecure(non_empty_str!("alice"));
let u = UserInfo::from(b);
assert_eq!(u.as_str(), "alice");
}
#[test]
fn ref_split_user_password() {
let u = UserInfo::from_static("alice:secret");
let r = u.view();
assert_eq!(
r.split_user_password(),
(&b"alice"[..], Some(&b"secret"[..]))
);
}
#[test]
fn ref_into_owned_roundtrip() {
let u = UserInfo::from_static("alice:secret");
let r = u.view();
let owned = r.into_owned();
assert_eq!(owned, u);
}
#[test]
fn try_from_userinfo_for_basic_user_password() {
let u = UserInfo::from_static("alice:secret");
let b = Basic::try_from(&u).unwrap();
assert_eq!(b.username(), "alice");
assert_eq!(b.password(), Some("secret"));
let b2 = Basic::try_from(u).unwrap();
assert_eq!(b2.username(), "alice");
}
#[test]
fn try_from_userinfo_for_basic_propagates_error() {
let u = UserInfo::from_static(":secret");
Basic::try_from(&u).unwrap_err();
}
#[test]
fn decoded_accessors() {
let ui = UserInfo::from_static("us%20er:p%40ss");
assert_eq!(&*ui.as_decoded_str(), "us er:p@ss");
assert_eq!(&*ui.username_decoded(), "us er");
assert_eq!(ui.password_decoded().as_deref(), Some("p@ss"));
let r = ui.view();
assert_eq!(&*r.as_decoded_str(), "us er:p@ss");
assert_eq!(&*r.username_decoded(), "us er");
assert_eq!(r.password_decoded().as_deref(), Some("p@ss"));
let ui = UserInfo::from_static("alice");
assert_eq!(&*ui.username_decoded(), "alice");
assert!(ui.password_decoded().is_none());
}
#[test]
fn to_basic_percent_decodes_components() {
let ui = UserInfo::from_static("user%40host:p%40ss");
let b = ui.to_basic().unwrap();
assert_eq!(b.username(), "user@host");
assert_eq!(b.password(), Some("p@ss"));
}
#[test]
fn to_basic_username_with_encoded_colon() {
let ui = UserInfo::from_static("a%3Ab:pw");
let b = ui.to_basic().unwrap();
assert_eq!(b.username(), "a:b");
assert_eq!(b.password(), Some("pw"));
}
#[test]
fn to_basic_rejects_pct_decoded_control() {
for raw in [b"a%0Db".as_slice(), b"a%0Ab", b"a%00b", b"user:p%0Dw"] {
let ui = UserInfo::from_bytes_unchecked(Bytes::copy_from_slice(raw));
ui.to_basic().unwrap_err();
}
}
#[test]
fn from_basic_percent_encodes_and_roundtrips() {
let basic = Basic::try_from("user@host:p@ss").unwrap();
let ui: UserInfo = basic.clone().into();
assert_eq!(ui.as_str(), "user%40host:p%40ss");
UserInfo::try_from(ui.as_str()).expect("encoded userinfo must re-parse");
let back = ui.to_basic().unwrap();
assert_eq!(back, basic);
assert_eq!(back.username(), "user@host");
assert_eq!(back.password(), Some("p@ss"));
}
#[test]
fn from_basic_escapes_colon_in_username() {
let basic = Basic::new(
NonEmptyStr::try_from("a:b").unwrap(),
NonEmptyStr::try_from("pw").unwrap(),
);
let ui: UserInfo = basic.into();
assert_eq!(ui.as_str(), "a%3Ab:pw");
let back = ui.to_basic().unwrap();
assert_eq!(back.username(), "a:b");
assert_eq!(back.password(), Some("pw"));
}
#[test]
fn from_basic_control_byte_is_the_residual_divergence() {
let basic = Basic::try_from("a\tb:pw").unwrap();
let ui: UserInfo = basic.into();
assert_eq!(ui.as_str(), "a%09b:pw");
UserInfo::try_from(ui.as_str()).unwrap_err();
}
#[test]
fn userinfo_encode_set_matches_validator_allow_set() {
let escapes = |set: &'static AsciiSet, b: u8| {
let buf = [b];
let s = core::str::from_utf8(&buf).unwrap();
utf8_percent_encode(s, set).to_string().as_str() != s
};
for b in 0u8..=127 {
if b == b'%' {
continue;
}
let pw_escaped = escapes(USERINFO_PASSWORD_ENCODE_SET, b);
assert_eq!(
crate::byte_sets::is_userinfo_byte(b),
!pw_escaped,
"password set disagrees on byte {b:#04x}",
);
assert_eq!(
escapes(USERINFO_USERNAME_ENCODE_SET, b),
pw_escaped || b == b':',
"username set disagrees on byte {b:#04x}",
);
}
}
}