proxy_watch/auth.rs
1//! Proxy credentials, with a password-masking `Debug` implementation.
2
3use std::fmt;
4
5use crate::util::MASK;
6
7/// Credentials for a proxy endpoint (HTTP Basic only).
8///
9/// Negotiate/NTLM/SSPI out of scope. `password()` → `None` is ambiguous (unset, unread
10/// GNOME field, macOS 15+ keychain). Manual [`Debug`] masks secrets.
11///
12/// ```
13/// # use proxy_watch::ProxyAuth;
14/// let auth = ProxyAuth::new("alice", Some("hunter2"));
15/// assert!(!format!("{auth:?}").contains("hunter2"));
16/// assert_eq!(auth.password(), Some("hunter2"));
17/// ```
18///
19/// Colon in username is masked from the first colon:
20///
21/// ```
22/// # use proxy_watch::ProxyAuth;
23/// let auth = ProxyAuth::from_username("alice:hunter2");
24/// assert!(!format!("{auth:?}").contains("hunter2"));
25/// ```
26#[derive(Clone, PartialEq, Eq, Hash)]
27pub struct ProxyAuth {
28 username: String,
29 password: Option<String>,
30}
31
32impl ProxyAuth {
33 /// Create credentials from a user name and an optional password.
34 #[must_use]
35 pub fn new(username: impl Into<String>, password: Option<impl Into<String>>) -> Self {
36 Self {
37 username: username.into(),
38 password: password.map(Into::into),
39 }
40 }
41
42 /// User name only (e.g. macOS `HTTPUser` with password in the keychain).
43 #[must_use]
44 pub fn from_username(username: impl Into<String>) -> Self {
45 Self {
46 username: username.into(),
47 password: None,
48 }
49 }
50
51 /// The user name.
52 #[must_use]
53 pub fn username(&self) -> &str {
54 &self.username
55 }
56
57 /// The password, if the source provided one.
58 #[must_use]
59 pub fn password(&self) -> Option<&str> {
60 self.password.as_deref()
61 }
62
63 /// Whether a password is present (without exposing it).
64 #[must_use]
65 pub fn has_password(&self) -> bool {
66 self.password.is_some()
67 }
68}
69
70impl fmt::Debug for ProxyAuth {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 // Colon-in-username: mask like a password that arrived unsplit.
73 let username = match self.username.split_once(':') {
74 Some((user, _)) => std::borrow::Cow::Owned(format!("{user}:{MASK}")),
75 None => std::borrow::Cow::Borrowed(self.username.as_str()),
76 };
77 f.debug_struct("ProxyAuth")
78 .field("username", &username)
79 .field("password", &self.password.as_ref().map(|_| MASK))
80 .finish()
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 // What the doctests above hold is that the secret does not appear; this test holds the
89 // rest of the line, and nothing else notices the printed label going from `username` to
90 // `user`. This type's labels are named after the accessors a caller reads the values
91 // with — a dump whose labels do not match `username()` and `password()` is a dump the
92 // reader has to guess at. The masking itself is `debug_masking`'s registry's business;
93 // the framing is this test's.
94 #[test]
95 fn the_auth_debug_names_its_fields_after_the_accessors() {
96 assert_eq!(
97 format!("{:?}", ProxyAuth::new("alice", Some("hunter2"))),
98 format!("ProxyAuth {{ username: \"alice\", password: Some({MASK:?}) }}")
99 );
100 // The colon spelling: everything from the first colon is a password that arrived
101 // unsplit, so the field still holds a user name and is still labelled one.
102 assert_eq!(
103 format!("{:?}", ProxyAuth::from_username("alice:hunter2")),
104 format!("ProxyAuth {{ username: \"alice:{MASK}\", password: None }}")
105 );
106 }
107}