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}