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}