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. When `password()` is `None`,
10/// [`password_state`](Self::password_state) says why. 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    // Why `password` is `None`: never `Present`, and `Absent` while `password` is `Some`, so
31    // the derived `PartialEq` and `Hash` see no difference `password_state` hides.
32    missing: PasswordState,
33}
34
35/// Whether [`ProxyAuth::password`] has a value, and why not when it has none.
36///
37/// ```
38/// # use proxy_watch::{PasswordState, ProxyAuth};
39/// assert_eq!(ProxyAuth::new("alice", Some("hunter2")).password_state(), PasswordState::Present);
40/// assert_eq!(ProxyAuth::from_username("alice").password_state(), PasswordState::Absent);
41/// ```
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[non_exhaustive]
44pub enum PasswordState {
45    /// The source provided a password, and [`ProxyAuth::password`] returns it.
46    Present,
47    /// The source holds no password for this user: `http://alice@proxy/`, or a GNOME
48    /// `authentication-password` that is empty.
49    Absent,
50    /// The source may hold one, and this crate does not read it. GNOME's
51    /// `authentication-password`: a plaintext secret in dconf that every reader of the
52    /// configuration would otherwise carry.
53    NotRead,
54    /// The password lives in the macOS keychain, which this crate does not open. System
55    /// Configuration carries the user name only.
56    InKeychain,
57}
58
59impl ProxyAuth {
60    /// Create credentials from a user name and an optional password.
61    #[must_use]
62    pub fn new(username: impl Into<String>, password: Option<impl Into<String>>) -> Self {
63        Self {
64            username: username.into(),
65            password: password.map(Into::into),
66            missing: PasswordState::Absent,
67        }
68    }
69
70    /// User name only; [`password_state`](Self::password_state) is
71    /// [`Absent`](PasswordState::Absent).
72    #[must_use]
73    pub fn from_username(username: impl Into<String>) -> Self {
74        Self::new(username, None::<String>)
75    }
76
77    // Mark a missing password as one the source holds and this crate did not read.
78    #[cfg_attr(
79        not(all(target_os = "linux", feature = "linux-gnome")),
80        allow(dead_code)
81    )]
82    pub(crate) fn password_not_read(mut self) -> Self {
83        if self.password.is_none() {
84            self.missing = PasswordState::NotRead;
85        }
86        self
87    }
88
89    // Mark a missing password as one kept in the macOS keychain.
90    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
91    pub(crate) fn password_in_keychain(mut self) -> Self {
92        if self.password.is_none() {
93            self.missing = PasswordState::InKeychain;
94        }
95        self
96    }
97
98    /// The user name.
99    #[must_use]
100    pub fn username(&self) -> &str {
101        &self.username
102    }
103
104    /// The password, if the source provided one.
105    #[must_use]
106    pub fn password(&self) -> Option<&str> {
107        self.password.as_deref()
108    }
109
110    /// Whether a password is present (without exposing it).
111    #[must_use]
112    pub fn has_password(&self) -> bool {
113        self.password.is_some()
114    }
115
116    /// [`Present`](PasswordState::Present) when [`password`](Self::password) has a value,
117    /// else why it has none.
118    #[must_use]
119    pub fn password_state(&self) -> PasswordState {
120        match self.password {
121            Some(_) => PasswordState::Present,
122            None => self.missing,
123        }
124    }
125}
126
127impl fmt::Debug for ProxyAuth {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        // Colon-in-username: mask like a password that arrived unsplit.
130        let username = match self.username.split_once(':') {
131            Some((user, _)) => std::borrow::Cow::Owned(format!("{user}:{MASK}")),
132            None => std::borrow::Cow::Borrowed(self.username.as_str()),
133        };
134        f.debug_struct("ProxyAuth")
135            .field("username", &username)
136            .field("password", &self.password.as_ref().map(|_| MASK))
137            .finish()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    // What the doctests above hold is that the secret does not appear; this test holds the
146    // rest of the line, and nothing else notices the printed label going from `username` to
147    // `user`. This type's labels are named after the accessors a caller reads the values
148    // with: a dump whose labels do not match `username()` and `password()` is a dump the
149    // reader has to guess at. The masking itself is `debug_masking`'s registry's business;
150    // the framing is this test's.
151    #[test]
152    fn the_auth_debug_names_its_fields_after_the_accessors() {
153        assert_eq!(
154            format!("{:?}", ProxyAuth::new("alice", Some("hunter2"))),
155            format!("ProxyAuth {{ username: \"alice\", password: Some({MASK:?}) }}")
156        );
157        // The colon spelling: everything from the first colon is a password that arrived
158        // unsplit, so the field still holds a user name and is still labelled one.
159        assert_eq!(
160            format!("{:?}", ProxyAuth::from_username("alice:hunter2")),
161            format!("ProxyAuth {{ username: \"alice:{MASK}\", password: None }}")
162        );
163    }
164
165    // A password that is there outranks any reason recorded for its absence.
166    #[test]
167    fn a_present_password_is_present_whatever_was_marked() {
168        let marked = ProxyAuth::new("alice", Some("hunter2")).password_in_keychain();
169        assert_eq!(marked.password_state(), PasswordState::Present);
170        let marked = ProxyAuth::new("alice", Some("hunter2")).password_not_read();
171        assert_eq!(marked.password_state(), PasswordState::Present);
172        assert_eq!(marked, ProxyAuth::new("alice", Some("hunter2")));
173        assert_eq!(
174            ProxyAuth::from_username("alice")
175                .password_in_keychain()
176                .password_state(),
177            PasswordState::InKeychain
178        );
179    }
180}