mildly_basic_auth/
config.rs1use std::error::Error;
7use std::fmt;
8
9use axum::http::Uri;
10use blake3::Hash;
11
12const ENV_PASSWORD: &str = "MBA_PASSWORD";
14const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
17
18#[derive(Clone)]
24pub struct Config {
25 session: Hash,
31 upstream: String,
36}
37
38impl Config {
39 pub fn from_env() -> Result<Self, ConfigError> {
49 let password = std::env::var(ENV_PASSWORD).unwrap_or_default();
50 let upstream = std::env::var(ENV_UPSTREAM).unwrap_or_default();
51 Self::from_values(&password, &upstream)
52 }
53
54 pub fn from_values(password: &str, upstream: &str) -> Result<Self, ConfigError> {
71 if password.is_empty() {
72 return Err(ConfigError::MissingPassword);
73 }
74 let upstream = validate_upstream(upstream)?;
75 Ok(Self {
76 session: blake3::hash(password.as_bytes()),
77 upstream,
78 })
79 }
80
81 pub(crate) fn session(&self) -> &Hash {
83 &self.session
84 }
85
86 pub(crate) fn upstream(&self) -> &str {
88 &self.upstream
89 }
90}
91
92impl fmt::Debug for Config {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.debug_struct("Config")
97 .field("session", &"<redacted>")
98 .field("upstream", &self.upstream)
99 .finish()
100 }
101}
102
103fn validate_upstream(upstream: &str) -> Result<String, ConfigError> {
120 let upstream = upstream.trim();
121 if upstream.is_empty() {
122 return Err(ConfigError::MissingUpstream);
123 }
124
125 let invalid = |reason: &'static str| ConfigError::InvalidUpstream {
126 value: upstream.to_string(),
127 reason,
128 };
129
130 let uri: Uri = upstream.parse().map_err(|_| invalid("not a valid URL"))?;
131
132 if !matches!(uri.scheme_str(), Some("http" | "https")) {
133 return Err(invalid("scheme must be http or https"));
134 }
135 let host = uri.host().unwrap_or_default();
136 if host.is_empty() {
139 return Err(invalid("missing host"));
140 }
141 if uri.port_u16() == Some(0) {
143 return Err(invalid("invalid port"));
144 }
145 let authority = uri.authority().map_or("", |a| a.as_str());
156 let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
157 let canonical = match uri.port() {
158 Some(port) => format!("{host}:{}", port.as_str()),
159 None => host.to_string(),
160 };
161 if host_port != canonical {
162 return Err(invalid("invalid host or port"));
163 }
164
165 Ok(uri.to_string())
168}
169
170#[derive(Debug, PartialEq, Eq)]
173pub enum ConfigError {
174 MissingPassword,
176 MissingUpstream,
178 InvalidUpstream { value: String, reason: &'static str },
180}
181
182impl fmt::Display for ConfigError {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 match self {
185 Self::MissingPassword => {
186 write!(f, "`{ENV_PASSWORD}` must be set to a non-empty value")
187 }
188 Self::MissingUpstream => write!(f, "`{ENV_UPSTREAM}` must be set"),
189 Self::InvalidUpstream { value, reason } => write!(
190 f,
191 "`{ENV_UPSTREAM}` is not a valid absolute http(s) URL \
192 ({reason}): {value:?}"
193 ),
194 }
195 }
196}
197
198impl Error for ConfigError {}
199
200#[cfg(test)]
201mod tests {
202 use std::assert_matches;
203
204 use super::*;
205
206 #[test]
207 fn valid_values_build_a_config() {
208 assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
209 }
210
211 #[test]
212 fn https_upstream_is_accepted() {
213 assert!(Config::from_values("hunter2", "https://app.example.com").is_ok());
214 }
215
216 #[test]
217 fn empty_password_is_rejected() {
218 assert_matches!(
219 Config::from_values("", "http://app:2001"),
220 Err(ConfigError::MissingPassword)
221 );
222 }
223
224 #[test]
225 fn empty_upstream_is_rejected() {
226 assert_matches!(
227 Config::from_values("hunter2", ""),
228 Err(ConfigError::MissingUpstream)
229 );
230 }
231
232 #[test]
233 fn relative_upstream_is_rejected() {
234 assert_matches!(
235 Config::from_values("hunter2", "/foo"),
236 Err(ConfigError::InvalidUpstream { .. })
237 );
238 }
239
240 #[test]
241 fn scheme_only_upstream_without_authority_is_rejected() {
242 assert_matches!(
243 Config::from_values("hunter2", "example:8080"),
244 Err(ConfigError::InvalidUpstream { .. })
245 );
246 }
247
248 #[test]
249 fn non_http_scheme_is_rejected() {
250 assert_matches!(
251 Config::from_values("hunter2", "ftp://host"),
252 Err(ConfigError::InvalidUpstream { .. })
253 );
254 }
255
256 #[test]
257 fn empty_host_upstream_is_rejected() {
258 assert_matches!(
259 Config::from_values("hunter2", "http://:8080"),
260 Err(ConfigError::InvalidUpstream { .. })
261 );
262 }
263
264 #[test]
265 fn out_of_range_port_is_rejected() {
266 assert_matches!(
267 Config::from_values("hunter2", "http://host:99999"),
268 Err(ConfigError::InvalidUpstream { .. })
269 );
270 }
271
272 #[test]
273 fn non_numeric_port_is_rejected() {
274 assert_matches!(
275 Config::from_values("hunter2", "http://host:abc"),
276 Err(ConfigError::InvalidUpstream { .. })
277 );
278 }
279
280 #[test]
281 fn empty_port_is_rejected() {
282 assert_matches!(
283 Config::from_values("hunter2", "http://host:"),
284 Err(ConfigError::InvalidUpstream { .. })
285 );
286 }
287
288 #[test]
289 fn zero_port_is_rejected() {
290 assert_matches!(
291 Config::from_values("hunter2", "http://host:0"),
292 Err(ConfigError::InvalidUpstream { .. })
293 );
294 }
295
296 #[test]
297 fn leading_zero_port_is_accepted() {
298 assert!(Config::from_values("hunter2", "http://host:080").is_ok());
300 }
301
302 #[test]
303 fn all_zero_port_is_rejected() {
304 assert_matches!(
306 Config::from_values("hunter2", "http://host:00"),
307 Err(ConfigError::InvalidUpstream { .. })
308 );
309 }
310
311 #[test]
312 fn ipv6_upstream_is_accepted() {
313 assert!(Config::from_values("hunter2", "http://[::1]:8080").is_ok());
314 }
315
316 #[test]
317 fn ipv6_with_trailing_junk_is_rejected() {
318 assert_matches!(
319 Config::from_values("hunter2", "http://[::1]junk"),
320 Err(ConfigError::InvalidUpstream { .. })
321 );
322 }
323
324 #[test]
325 fn ipv6_junk_before_port_is_rejected() {
326 assert_matches!(
327 Config::from_values("hunter2", "http://[::1]junk:8080"),
328 Err(ConfigError::InvalidUpstream { .. })
329 );
330 }
331
332 #[test]
333 fn session_digest_matches_blake3_of_password() {
334 let config = Config::from_values("hunter2", "http://app:2001").unwrap();
335 assert_eq!(config.session(), &blake3::hash(b"hunter2"));
336 }
337
338 #[test]
339 fn surrounding_whitespace_in_upstream_is_trimmed() {
340 let config = Config::from_values("hunter2", " http://app:2001 ").unwrap();
341 assert_eq!(config.upstream(), "http://app:2001/");
342 }
343}