Skip to main content

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}