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