Skip to main content

mildly_basic_auth/
config.rs

1//! Startup configuration, validated once from the environment.
2//!
3//! `Config` is only constructible when every value is valid, so the rest
4//! of the program never has to defend against a half-configured state.
5
6use std::collections::HashSet;
7use std::error::Error;
8use std::ffi::OsString;
9use std::fmt;
10use std::net::SocketAddr;
11
12use axum::body::Bytes;
13use axum::http::Uri;
14use blake3::Hash;
15
16/// Embedded password-page template. Compiled into the binary so rendering
17/// has no runtime asset dependency.
18const WALL_TEMPLATE: &str = include_str!("index.html");
19/// Default IP socket address the service listens on.
20const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:8000";
21/// Environment variable holding the IP socket address to listen on.
22const ENV_ADDRESS: &str = "MBA_ADDRESS";
23/// Environment variable holding a plain password. At least one of the
24/// `MBA_PASSWORD*` family (this or a `MBA_PASSWORD_<label>`) must be
25/// non-empty.
26const ENV_PASSWORD: &str = "MBA_PASSWORD";
27/// Prefix for additional password variables, e.g. `MBA_PASSWORD_BOB`. The
28/// suffix is a free-form label (typically who the password belongs to);
29/// it carries no meaning at request time. Reserved: future config must
30/// not land under `MBA_PASSWORD_*` or it would be read as a password.
31const ENV_PASSWORD_PREFIX: &str = "MBA_PASSWORD_";
32/// Environment variable overriding the password page's document language.
33const ENV_TEMPLATE_PAGE_LANGUAGE: &str = "MBA_TEMPLATE_PAGE_LANGUAGE";
34/// Environment variable overriding the password page's title.
35const ENV_TEMPLATE_PAGE_TITLE: &str = "MBA_TEMPLATE_PAGE_TITLE";
36/// Environment variable overriding the password field's accessible label.
37const ENV_TEMPLATE_PASSWORD_LABEL: &str = "MBA_TEMPLATE_PASSWORD_LABEL";
38/// Environment variable overriding the password field's placeholder.
39const ENV_TEMPLATE_PASSWORD_PLACEHOLDER: &str = "MBA_TEMPLATE_PASSWORD_PLACEHOLDER";
40/// Environment variable overriding the submit button's text.
41const ENV_TEMPLATE_SUBMIT_BUTTON_TEXT: &str = "MBA_TEMPLATE_SUBMIT_BUTTON_TEXT";
42/// Environment variable holding the upstream URL (required, absolute
43/// `http(s)://host[:port]`).
44const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
45
46/// Immutable runtime configuration, built once at startup.
47///
48/// Cloned per request by the gate middleware (`from_fn_with_state`
49/// requires the state to be `Clone`), which is why fields stay cheap to
50/// clone (`SocketAddr` is `Copy`; `Bytes` is reference-counted; `Vec<Hash>`
51/// and `String` each clone one small heap allocation per request).
52#[derive(Clone)]
53pub struct Config {
54    /// Validated IP socket address the service listens on.
55    bind_address: SocketAddr,
56    /// BLAKE3 digests of the configured passwords. The session cookie
57    /// carries one of these as hex; a request authenticates by presenting
58    /// a cookie that matches **any** of them. `Config` stores only the
59    /// digests, not the plaintext. The cookie is not harmless: it never
60    /// contains the plaintext password, but it holds a password-equivalent
61    /// bearer token and remains sensitive (see the `Debug` redaction
62    /// below). This is not memory scrubbing — the plaintext still lives in
63    /// the process environment.
64    sessions: Vec<Hash>,
65    /// Validated absolute `http(s)://host[:port]` upstream. Stored as a
66    /// `String` (not `Uri`) because `ReverseProxy::new` takes one generic
67    /// `S: Into<String>` for both arguments; a `&Uri` would not satisfy
68    /// that bound.
69    upstream: String,
70    /// Password page rendered once at startup. `Bytes` keeps per-response
71    /// and per-state clones cheap despite the page's size.
72    wall: Bytes,
73}
74
75impl Config {
76    /// Build from the process environment. Delegates to the same pure
77    /// validation as [`Config::from_values`] so tests do not have to mutate
78    /// process-wide env (which is `unsafe` in Rust 2024).
79    ///
80    /// # Errors
81    ///
82    /// Returns [`ConfigError`] if `MBA_ADDRESS` is not a valid non-zero IP
83    /// socket address, no `MBA_PASSWORD*` variable has a non-empty UTF-8
84    /// value, two of them share the same value, a template-text override is
85    /// not valid Unicode, or `MBA_UPSTREAM` is not a valid absolute HTTP(S)
86    /// URL.
87    pub fn from_env() -> Result<Self, ConfigError> {
88        let address = match std::env::var(ENV_ADDRESS) {
89            Ok(address) => address,
90            Err(std::env::VarError::NotPresent) => DEFAULT_BIND_ADDRESS.to_string(),
91            Err(std::env::VarError::NotUnicode(address)) => {
92                return Err(ConfigError::InvalidAddress {
93                    value: address.to_string_lossy().into_owned(),
94                    reason: "not valid Unicode",
95                });
96            }
97        };
98        let passwords = passwords_from_env(std::env::vars_os());
99        let passwords: Vec<&str> = passwords.iter().map(String::as_str).collect();
100        let template_text = TemplateText::from_env(std::env::vars_os())?;
101        let upstream = std::env::var(ENV_UPSTREAM).unwrap_or_default();
102        Self::from_passwords_address_and_template_text(
103            &passwords,
104            &upstream,
105            &address,
106            &template_text,
107        )
108    }
109
110    /// Build and validate from explicit values (pure, env-free).
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// # use mildly_basic_auth::Config;
116    /// assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
117    /// assert!(Config::from_values("", "http://app:2001").is_err()); // No password.
118    /// assert!(Config::from_values("hunter2", "/relative").is_err()); // No host.
119    /// ```
120    ///
121    /// # Errors
122    ///
123    /// Returns [`ConfigError::MissingPassword`] for an empty password, or
124    /// [`ConfigError::MissingUpstream`] / [`ConfigError::InvalidUpstream`]
125    /// if the upstream is not a valid absolute HTTP(S) URL. The bind
126    /// address uses the default `0.0.0.0:8000`.
127    pub fn from_values(password: &str, upstream: &str) -> Result<Self, ConfigError> {
128        Self::from_values_and_address(password, upstream, DEFAULT_BIND_ADDRESS)
129    }
130
131    /// Build and validate from several explicit passwords (pure, env-free).
132    /// Empty entries are ignored; at least one must remain, and all must be
133    /// distinct. Any of them authenticates.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// # use mildly_basic_auth::Config;
139    /// assert!(Config::from_passwords(&["alice", "bob"], "http://app:2001").is_ok());
140    /// assert!(Config::from_passwords(&[], "http://app:2001").is_err()); // No password.
141    /// assert!(Config::from_passwords(&["x", "x"], "http://app:2001").is_err()); // Duplicate.
142    /// ```
143    ///
144    /// # Errors
145    ///
146    /// Returns [`ConfigError::MissingPassword`] if no password is non-empty,
147    /// [`ConfigError::DuplicatePassword`] if two passwords share a value, or
148    /// [`ConfigError::MissingUpstream`] / [`ConfigError::InvalidUpstream`]
149    /// if the upstream is not a valid absolute HTTP(S) URL. The bind
150    /// address uses the default `0.0.0.0:8000`.
151    pub fn from_passwords(passwords: &[&str], upstream: &str) -> Result<Self, ConfigError> {
152        Self::from_passwords_and_address(passwords, upstream, DEFAULT_BIND_ADDRESS)
153    }
154
155    /// Validated IP socket address the service listens on.
156    #[must_use]
157    pub fn bind_address(&self) -> SocketAddr {
158        self.bind_address
159    }
160
161    /// Build and validate the complete runtime configuration from a single
162    /// password.
163    fn from_values_and_address(
164        password: &str,
165        upstream: &str,
166        address: &str,
167    ) -> Result<Self, ConfigError> {
168        Self::from_passwords_and_address(&[password], upstream, address)
169    }
170
171    /// Build and validate the complete runtime configuration.
172    fn from_passwords_and_address(
173        passwords: &[&str],
174        upstream: &str,
175        address: &str,
176    ) -> Result<Self, ConfigError> {
177        Self::from_passwords_address_and_template_text(
178            passwords,
179            upstream,
180            address,
181            &TemplateText::default(),
182        )
183    }
184
185    /// Build and validate the complete runtime configuration with explicit
186    /// password-page text.
187    fn from_passwords_address_and_template_text(
188        passwords: &[&str],
189        upstream: &str,
190        address: &str,
191        template_text: &TemplateText,
192    ) -> Result<Self, ConfigError> {
193        // Keep non-empty values (drop unset/blank vars).
194        let passwords: Vec<&str> = passwords
195            .iter()
196            .copied()
197            .filter(|p| !p.is_empty())
198            .collect();
199        if passwords.is_empty() {
200            return Err(ConfigError::MissingPassword);
201        }
202        // Reject duplicates: two variables sharing a value hash to the same
203        // digest, so removing one would not revoke the other — breaking
204        // independent revocation. Startup-time over operator input, so a
205        // `HashSet` compare (not constant-time) is fine; this is not the
206        // request path.
207        let unique: HashSet<&str> = passwords.iter().copied().collect();
208        if unique.len() != passwords.len() {
209            return Err(ConfigError::DuplicatePassword);
210        }
211        // Only the digests are retained in `Config`; the plaintext stays
212        // with the caller and the process environment (not scrubbed here).
213        let sessions: Vec<Hash> = passwords
214            .iter()
215            .map(|p| blake3::hash(p.as_bytes()))
216            .collect();
217        let upstream = validate_upstream(upstream)?;
218        let address = validate_address(address)?;
219        Ok(Self {
220            bind_address: address,
221            sessions,
222            upstream,
223            wall: render_template(WALL_TEMPLATE, template_text),
224        })
225    }
226
227    /// Expected session-cookie digests. A request authenticates by matching
228    /// any of them.
229    pub(crate) fn sessions(&self) -> &[Hash] {
230        &self.sessions
231    }
232
233    /// Validated upstream URL, ready for `ReverseProxy::new`.
234    pub(crate) fn upstream(&self) -> &str {
235        &self.upstream
236    }
237
238    /// Rendered password page, ready to use as an HTML response body.
239    pub(crate) fn wall(&self) -> &Bytes {
240        &self.wall
241    }
242}
243
244impl fmt::Debug for Config {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        // Never print the session digests: each is a password-equivalent
247        // bearer token, so an accidental log must not leak it.
248        f.debug_struct("Config")
249            .field("bind_address", &self.bind_address)
250            .field("sessions", &"<redacted>")
251            .field("upstream", &self.upstream)
252            .field("wall", &"<rendered HTML>")
253            .finish()
254    }
255}
256
257/// Text substituted into the password-page template.
258struct TemplateText {
259    page_language: String,
260    page_title: String,
261    password_label: String,
262    password_placeholder: String,
263    submit_button_text: String,
264}
265
266impl TemplateText {
267    /// Read fixed template-text overrides from the environment. Unknown
268    /// `MBA_TEMPLATE_*` variables are deliberately ignored: the prefix is
269    /// reserved for explicit settings, including the future template file.
270    fn from_env(vars: impl Iterator<Item = (OsString, OsString)>) -> Result<Self, ConfigError> {
271        let mut text = Self::default();
272        for (name, value) in vars {
273            let Some(name) = name.to_str() else { continue };
274            let target = match name {
275                ENV_TEMPLATE_PAGE_LANGUAGE => &mut text.page_language,
276                ENV_TEMPLATE_PAGE_TITLE => &mut text.page_title,
277                ENV_TEMPLATE_PASSWORD_LABEL => &mut text.password_label,
278                ENV_TEMPLATE_PASSWORD_PLACEHOLDER => &mut text.password_placeholder,
279                ENV_TEMPLATE_SUBMIT_BUTTON_TEXT => &mut text.submit_button_text,
280                _ => continue,
281            };
282            *target = value
283                .into_string()
284                .map_err(|_| ConfigError::InvalidTemplateText {
285                    variable: name.to_owned(),
286                })?;
287        }
288        Ok(text)
289    }
290}
291
292impl Default for TemplateText {
293    fn default() -> Self {
294        Self {
295            page_language: "en".to_owned(),
296            page_title: "Welcome!".to_owned(),
297            password_label: "Password".to_owned(),
298            password_placeholder: "Password".to_owned(),
299            submit_button_text: "Enter".to_owned(),
300        }
301    }
302}
303
304/// Render known markers from `template`, preserving unknown or absent
305/// markers. Scanning only the original template ensures marker-like text
306/// in an override is not interpreted recursively.
307fn render_template(template: &str, text: &TemplateText) -> Bytes {
308    let mut rendered = String::with_capacity(template.len());
309    let mut remaining = template;
310
311    while let Some(marker_start) = remaining.find("{{") {
312        rendered.push_str(&remaining[..marker_start]);
313        remaining = &remaining[marker_start..];
314
315        let replacement = [
316            ("{{PAGE_LANGUAGE}}", text.page_language.as_str()),
317            ("{{PAGE_TITLE}}", text.page_title.as_str()),
318            ("{{PASSWORD_LABEL}}", text.password_label.as_str()),
319            (
320                "{{PASSWORD_PLACEHOLDER}}",
321                text.password_placeholder.as_str(),
322            ),
323            ("{{SUBMIT_BUTTON_TEXT}}", text.submit_button_text.as_str()),
324        ]
325        .into_iter()
326        .find(|(marker, _)| remaining.starts_with(marker));
327
328        if let Some((marker, value)) = replacement {
329            push_html_escaped(&mut rendered, value);
330            remaining = &remaining[marker.len()..];
331        } else {
332            rendered.push_str("{{");
333            remaining = &remaining[2..];
334        }
335    }
336    rendered.push_str(remaining);
337    Bytes::from(rendered)
338}
339
340/// Append plain text escaped for HTML text and double-quoted attributes.
341fn push_html_escaped(rendered: &mut String, value: &str) {
342    for character in value.chars() {
343        match character {
344            '&' => rendered.push_str("&amp;"),
345            '<' => rendered.push_str("&lt;"),
346            '>' => rendered.push_str("&gt;"),
347            '"' => rendered.push_str("&quot;"),
348            '\'' => rendered.push_str("&#39;"),
349            _ => rendered.push(character),
350        }
351    }
352}
353
354/// Every configured password from the environment: `MBA_PASSWORD` and any
355/// name starting with `MBA_PASSWORD_`. Order is irrelevant (any match
356/// authenticates); empty values are dropped downstream. Non-Unicode names
357/// never match; non-Unicode values are skipped (a form-entered password is
358/// always UTF-8, so such a value is unusable anyway).
359///
360/// Pure over the iterator so it is testable without mutating process-wide
361/// env (which is `unsafe` in Rust 2024). `from_env` calls it with
362/// `std::env::vars_os()` — `vars_os` (not `vars`) because `vars` panics on
363/// any non-Unicode variable anywhere in the environment.
364fn passwords_from_env(vars: impl Iterator<Item = (OsString, OsString)>) -> Vec<String> {
365    vars.filter_map(|(name, value)| {
366        // Filter by name *before* touching the value, so an unrelated
367        // non-Unicode variable can never reach `into_string`.
368        let name = name.to_str()?;
369        (name == ENV_PASSWORD || name.starts_with(ENV_PASSWORD_PREFIX))
370            .then(|| value.into_string().ok())
371            .flatten()
372    })
373    .collect()
374}
375
376/// Validate `address` as a non-zero IP socket address.
377///
378/// # Examples
379///
380/// ```ignore
381/// assert!(validate_address("0.0.0.0:4630").is_ok());
382/// assert!(validate_address("[::]:4630").is_ok());
383/// assert!(validate_address("localhost:4630").is_err()); // Not an IP.
384/// assert!(validate_address("0.0.0.0:0").is_err()); // Zero port.
385/// ```
386fn validate_address(address: &str) -> Result<SocketAddr, ConfigError> {
387    let address = address.trim();
388    let invalid = |reason: &'static str| ConfigError::InvalidAddress {
389        value: address.to_string(),
390        reason,
391    };
392    let address: SocketAddr = address
393        .parse()
394        .map_err(|_| invalid("expected an IP address and port"))?;
395    if address.port() == 0 {
396        return Err(invalid("port must not be zero"));
397    }
398    Ok(address)
399}
400
401/// Validate `upstream` as an absolute `http(s)://host[:port]` URL.
402///
403/// Strictness matters: `http::Uri` parses relative refs like `/foo` or
404/// `example:8080` that carry no authority, and `axum-reverse-proxy` then
405/// `.expect()`s an authority and **panics** at request time. We reject
406/// anything that isn't an absolute HTTP(S) URL up front.
407///
408/// # Examples
409///
410/// ```ignore
411/// assert!(validate_upstream("http://app:2001").is_ok());
412/// assert!(validate_upstream("").is_err()); // Empty.
413/// assert!(validate_upstream("/foo").is_err()); // No scheme/host.
414/// assert!(validate_upstream("example:8080").is_err()); // No host.
415/// assert!(validate_upstream("ftp://host").is_err()); // Not HTTP(S).
416/// ```
417fn validate_upstream(upstream: &str) -> Result<String, ConfigError> {
418    let upstream = upstream.trim();
419    if upstream.is_empty() {
420        return Err(ConfigError::MissingUpstream);
421    }
422
423    let invalid = |reason: &'static str| ConfigError::InvalidUpstream {
424        value: upstream.to_string(),
425        reason,
426    };
427
428    let uri: Uri = upstream.parse().map_err(|_| invalid("not a valid URL"))?;
429
430    if !matches!(uri.scheme_str(), Some("http" | "https")) {
431        return Err(invalid("scheme must be http or https"));
432    }
433    let host = uri.host().unwrap_or_default();
434    // An authority can parse with an empty host (`http://:8080`), which the
435    // connector cannot use.
436    if host.is_empty() {
437        return Err(invalid("missing host"));
438    }
439    // Port 0 is never a real destination.
440    if uri.port_u16() == Some(0) {
441        return Err(invalid("invalid port"));
442    }
443    // `http::Uri` silently *drops* malformed detail: a bad port (`:abc`,
444    // `:`, `:99999`) or junk after an IPv6 literal (`[::1]junk`) all parse
445    // with that text discarded, so the connector would target the wrong
446    // place (e.g. the scheme default port). Catch it by reconstructing the
447    // canonical `host[:port]` from the parsed parts and requiring it to
448    // equal the input authority (ignoring any `userinfo@`); a mismatch is
449    // exactly the garbage that was dropped. The port is taken from
450    // `port().as_str()` (its original text), not `port_u16()`, so a valid
451    // leading-zero port like `:080` round-trips instead of collapsing to
452    // `:80` and failing the comparison.
453    let authority = uri.authority().map_or("", |a| a.as_str());
454    let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
455    let canonical = match uri.port() {
456        Some(port) => format!("{host}:{}", port.as_str()),
457        None => host.to_string(),
458    };
459    if host_port != canonical {
460        return Err(invalid("invalid host or port"));
461    }
462
463    // Normalized form. The proxy trims any trailing slash when joining
464    // paths, so the canonical `http://host:port/` cannot produce `//`.
465    Ok(uri.to_string())
466}
467
468/// Why a `Config` could not be built. Surfaced to stderr at startup so a
469/// misconfiguration fails fast with an actionable message.
470#[derive(Debug, PartialEq, Eq)]
471pub enum ConfigError {
472    /// `MBA_ADDRESS` was not a valid non-zero IP socket address.
473    InvalidAddress { value: String, reason: &'static str },
474    /// No `MBA_PASSWORD*` variable had a non-empty UTF-8 value.
475    MissingPassword,
476    /// Two `MBA_PASSWORD*` variables shared the same value, which would
477    /// break independent revocation.
478    DuplicatePassword,
479    /// A configured template-text override was not valid Unicode.
480    InvalidTemplateText { variable: String },
481    /// `MBA_UPSTREAM` was unset or empty.
482    MissingUpstream,
483    /// `MBA_UPSTREAM` was set but is not an absolute HTTP(S) URL.
484    InvalidUpstream { value: String, reason: &'static str },
485}
486
487impl fmt::Display for ConfigError {
488    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
489        match self {
490            Self::InvalidAddress { value, reason } => write!(
491                f,
492                "`{ENV_ADDRESS}` is not a valid IP socket address ({reason}): {value:?}"
493            ),
494            Self::MissingPassword => write!(
495                f,
496                "at least one password variable (`{ENV_PASSWORD}` or \
497                 `{ENV_PASSWORD_PREFIX}<label>`) must have a non-empty UTF-8 value"
498            ),
499            Self::DuplicatePassword => write!(
500                f,
501                "two password variables share the same value; each password \
502                 must be distinct to be revoked independently"
503            ),
504            Self::InvalidTemplateText { variable } => {
505                write!(f, "`{variable}` must contain valid Unicode")
506            }
507            Self::MissingUpstream => write!(f, "`{ENV_UPSTREAM}` must be set"),
508            Self::InvalidUpstream { value, reason } => write!(
509                f,
510                "`{ENV_UPSTREAM}` is not a valid absolute http(s) URL \
511                 ({reason}): {value:?}"
512            ),
513        }
514    }
515}
516
517impl Error for ConfigError {}
518
519#[cfg(test)]
520mod tests {
521    use std::assert_matches;
522
523    use super::*;
524
525    /// Build template text from explicit variables without mutating the
526    /// process-wide environment.
527    fn template_text_from_vars(vars: &[(&str, &str)]) -> TemplateText {
528        TemplateText::from_env(
529            vars.iter()
530                .map(|(name, value)| (OsString::from(name), OsString::from(value))),
531        )
532        .unwrap()
533    }
534
535    #[test]
536    fn valid_values_build_a_config() {
537        assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
538    }
539
540    #[test]
541    fn default_template_text_renders_the_existing_page_text() {
542        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
543        let wall = std::str::from_utf8(config.wall()).unwrap();
544
545        assert!(wall.contains("<html lang=\"en\">"));
546        assert!(wall.contains("<title>Welcome!</title>"));
547        assert!(wall.contains(">Password</label>"));
548        assert!(wall.contains("placeholder=\"Password\""));
549        assert!(wall.contains(">Enter</button>"));
550    }
551
552    #[test]
553    fn page_language_variable_overrides_the_default() {
554        let text = template_text_from_vars(&[(ENV_TEMPLATE_PAGE_LANGUAGE, "fr")]);
555        assert_eq!(text.page_language, "fr");
556    }
557
558    #[test]
559    fn page_title_variable_overrides_the_default() {
560        let text = template_text_from_vars(&[(ENV_TEMPLATE_PAGE_TITLE, "Mon site")]);
561        assert_eq!(text.page_title, "Mon site");
562    }
563
564    #[test]
565    fn password_label_variable_overrides_the_default() {
566        let text = template_text_from_vars(&[(ENV_TEMPLATE_PASSWORD_LABEL, "Mot de passe")]);
567        assert_eq!(text.password_label, "Mot de passe");
568    }
569
570    #[test]
571    fn password_placeholder_variable_overrides_the_default() {
572        let text = template_text_from_vars(&[(ENV_TEMPLATE_PASSWORD_PLACEHOLDER, "Secret")]);
573        assert_eq!(text.password_placeholder, "Secret");
574    }
575
576    #[test]
577    fn submit_button_text_variable_overrides_the_default() {
578        let text = template_text_from_vars(&[(ENV_TEMPLATE_SUBMIT_BUTTON_TEXT, "Entrer")]);
579        assert_eq!(text.submit_button_text, "Entrer");
580    }
581
582    #[test]
583    fn empty_template_text_variable_overrides_the_default() {
584        let text = template_text_from_vars(&[(ENV_TEMPLATE_PAGE_TITLE, "")]);
585        assert!(text.page_title.is_empty());
586    }
587
588    #[test]
589    fn unknown_template_variable_is_ignored() {
590        let text = template_text_from_vars(&[("MBA_TEMPLATE_FILE", "/tmp/wall.html")]);
591        assert_eq!(text.page_title, "Welcome!");
592    }
593
594    #[cfg(unix)]
595    #[test]
596    fn non_unicode_template_text_is_rejected() {
597        use std::os::unix::ffi::OsStringExt;
598
599        let result = TemplateText::from_env(
600            [(
601                OsString::from(ENV_TEMPLATE_PAGE_TITLE),
602                OsString::from_vec(vec![0xff]),
603            )]
604            .into_iter(),
605        );
606
607        let Err(error) = result else {
608            panic!("non-Unicode template text was accepted");
609        };
610        assert_matches!(
611            error,
612            ConfigError::InvalidTemplateText { variable }
613                if variable == ENV_TEMPLATE_PAGE_TITLE
614        );
615    }
616
617    #[test]
618    fn template_values_are_html_escaped() {
619        let text = TemplateText {
620            page_title: "&<>\"'".to_owned(),
621            ..TemplateText::default()
622        };
623
624        let rendered = render_template("{{PAGE_TITLE}}", &text);
625
626        assert_eq!(rendered, "&amp;&lt;&gt;&quot;&#39;");
627    }
628
629    #[test]
630    fn template_values_are_not_rendered_recursively() {
631        let text = TemplateText {
632            page_title: "{{SUBMIT_BUTTON_TEXT}}".to_owned(),
633            submit_button_text: "Submit".to_owned(),
634            ..TemplateText::default()
635        };
636
637        let rendered = render_template("{{PAGE_TITLE}}", &text);
638
639        assert_eq!(rendered, "{{SUBMIT_BUTTON_TEXT}}");
640    }
641
642    #[test]
643    fn template_without_known_markers_is_unchanged() {
644        let rendered = render_template("<p>{{UNKNOWN}}</p>", &TemplateText::default());
645        assert_eq!(rendered, "<p>{{UNKNOWN}}</p>");
646    }
647
648    #[test]
649    fn empty_template_is_valid() {
650        let rendered = render_template("", &TemplateText::default());
651        assert!(rendered.is_empty());
652    }
653
654    #[test]
655    fn values_use_the_default_address() {
656        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
657        assert_eq!(config.bind_address(), "0.0.0.0:8000".parse().unwrap());
658    }
659
660    #[test]
661    fn custom_ipv4_address_is_accepted() {
662        let config =
663            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:4630")
664                .unwrap();
665        assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
666    }
667
668    #[test]
669    fn custom_ipv6_address_is_accepted() {
670        let config =
671            Config::from_values_and_address("hunter2", "http://app:2001", "[::1]:4630").unwrap();
672        assert_eq!(config.bind_address(), "[::1]:4630".parse().unwrap());
673    }
674
675    #[test]
676    fn surrounding_whitespace_in_address_is_trimmed() {
677        let config =
678            Config::from_values_and_address("hunter2", "http://app:2001", "  127.0.0.1:4630  ")
679                .unwrap();
680        assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
681    }
682
683    #[test]
684    fn empty_address_is_rejected() {
685        assert_matches!(
686            Config::from_values_and_address("hunter2", "http://app:2001", ""),
687            Err(ConfigError::InvalidAddress { .. })
688        );
689    }
690
691    #[test]
692    fn hostname_address_is_rejected() {
693        assert_matches!(
694            Config::from_values_and_address("hunter2", "http://app:2001", "localhost:4630"),
695            Err(ConfigError::InvalidAddress { .. })
696        );
697    }
698
699    #[test]
700    fn address_without_port_is_rejected() {
701        assert_matches!(
702            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1"),
703            Err(ConfigError::InvalidAddress { .. })
704        );
705    }
706
707    #[test]
708    fn address_with_non_numeric_port_is_rejected() {
709        assert_matches!(
710            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:abc"),
711            Err(ConfigError::InvalidAddress { .. })
712        );
713    }
714
715    #[test]
716    fn address_with_out_of_range_port_is_rejected() {
717        assert_matches!(
718            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:99999"),
719            Err(ConfigError::InvalidAddress { .. })
720        );
721    }
722
723    #[test]
724    fn address_with_zero_port_is_rejected() {
725        assert_matches!(
726            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:0"),
727            Err(ConfigError::InvalidAddress { .. })
728        );
729    }
730
731    #[test]
732    fn https_upstream_is_accepted() {
733        assert!(Config::from_values("hunter2", "https://app.example.com").is_ok());
734    }
735
736    #[test]
737    fn empty_password_is_rejected() {
738        assert_matches!(
739            Config::from_values("", "http://app:2001"),
740            Err(ConfigError::MissingPassword)
741        );
742    }
743
744    #[test]
745    fn empty_upstream_is_rejected() {
746        assert_matches!(
747            Config::from_values("hunter2", ""),
748            Err(ConfigError::MissingUpstream)
749        );
750    }
751
752    #[test]
753    fn relative_upstream_is_rejected() {
754        assert_matches!(
755            Config::from_values("hunter2", "/foo"),
756            Err(ConfigError::InvalidUpstream { .. })
757        );
758    }
759
760    #[test]
761    fn scheme_only_upstream_without_authority_is_rejected() {
762        assert_matches!(
763            Config::from_values("hunter2", "example:8080"),
764            Err(ConfigError::InvalidUpstream { .. })
765        );
766    }
767
768    #[test]
769    fn non_http_scheme_is_rejected() {
770        assert_matches!(
771            Config::from_values("hunter2", "ftp://host"),
772            Err(ConfigError::InvalidUpstream { .. })
773        );
774    }
775
776    #[test]
777    fn empty_host_upstream_is_rejected() {
778        assert_matches!(
779            Config::from_values("hunter2", "http://:8080"),
780            Err(ConfigError::InvalidUpstream { .. })
781        );
782    }
783
784    #[test]
785    fn out_of_range_port_is_rejected() {
786        assert_matches!(
787            Config::from_values("hunter2", "http://host:99999"),
788            Err(ConfigError::InvalidUpstream { .. })
789        );
790    }
791
792    #[test]
793    fn non_numeric_port_is_rejected() {
794        assert_matches!(
795            Config::from_values("hunter2", "http://host:abc"),
796            Err(ConfigError::InvalidUpstream { .. })
797        );
798    }
799
800    #[test]
801    fn empty_port_is_rejected() {
802        assert_matches!(
803            Config::from_values("hunter2", "http://host:"),
804            Err(ConfigError::InvalidUpstream { .. })
805        );
806    }
807
808    #[test]
809    fn zero_port_is_rejected() {
810        assert_matches!(
811            Config::from_values("hunter2", "http://host:0"),
812            Err(ConfigError::InvalidUpstream { .. })
813        );
814    }
815
816    #[test]
817    fn leading_zero_port_is_accepted() {
818        // `:080` is a valid port (80); the connector resolves it fine.
819        assert!(Config::from_values("hunter2", "http://host:080").is_ok());
820    }
821
822    #[test]
823    fn all_zero_port_is_rejected() {
824        // `:00` is still port 0.
825        assert_matches!(
826            Config::from_values("hunter2", "http://host:00"),
827            Err(ConfigError::InvalidUpstream { .. })
828        );
829    }
830
831    #[test]
832    fn ipv6_upstream_is_accepted() {
833        assert!(Config::from_values("hunter2", "http://[::1]:8080").is_ok());
834    }
835
836    #[test]
837    fn ipv6_with_trailing_junk_is_rejected() {
838        assert_matches!(
839            Config::from_values("hunter2", "http://[::1]junk"),
840            Err(ConfigError::InvalidUpstream { .. })
841        );
842    }
843
844    #[test]
845    fn ipv6_junk_before_port_is_rejected() {
846        assert_matches!(
847            Config::from_values("hunter2", "http://[::1]junk:8080"),
848            Err(ConfigError::InvalidUpstream { .. })
849        );
850    }
851
852    #[test]
853    fn session_digest_matches_blake3_of_password() {
854        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
855        assert_eq!(config.sessions(), &[blake3::hash(b"hunter2")]);
856    }
857
858    #[test]
859    fn multiple_passwords_build_one_digest_each() {
860        let config = Config::from_passwords(&["hunter2", "swordfish"], "http://app:2001").unwrap();
861        assert_eq!(
862            config.sessions(),
863            &[blake3::hash(b"hunter2"), blake3::hash(b"swordfish")]
864        );
865    }
866
867    #[test]
868    fn empty_passwords_are_filtered_out() {
869        let config = Config::from_passwords(&["", "hunter2", ""], "http://app:2001").unwrap();
870        assert_eq!(config.sessions(), &[blake3::hash(b"hunter2")]);
871    }
872
873    #[test]
874    fn all_empty_passwords_are_rejected() {
875        assert_matches!(
876            Config::from_passwords(&["", ""], "http://app:2001"),
877            Err(ConfigError::MissingPassword)
878        );
879    }
880
881    #[test]
882    fn no_passwords_are_rejected() {
883        assert_matches!(
884            Config::from_passwords(&[], "http://app:2001"),
885            Err(ConfigError::MissingPassword)
886        );
887    }
888
889    #[test]
890    fn duplicate_passwords_are_rejected() {
891        assert_matches!(
892            Config::from_passwords(&["hunter2", "hunter2"], "http://app:2001"),
893            Err(ConfigError::DuplicatePassword)
894        );
895    }
896
897    #[test]
898    fn duplicate_check_ignores_filtered_empties() {
899        // The two empties are dropped before the duplicate check, so they
900        // are not treated as duplicates of each other.
901        assert!(Config::from_passwords(&["a", "", ""], "http://app:2001").is_ok());
902    }
903
904    #[test]
905    fn passwords_from_env_collects_base_and_suffixed() {
906        let vars = [
907            ("MBA_PASSWORD", "alpha"),
908            ("MBA_PASSWORD_BOB", "bravo"),
909            ("MBA_ADDRESS", "0.0.0.0:8000"),
910            ("MBA_UPSTREAM", "http://app:2001"),
911            ("PATH", "/usr/bin"),
912        ]
913        .into_iter()
914        .map(|(k, v)| (OsString::from(k), OsString::from(v)));
915        let mut got = passwords_from_env(vars);
916        got.sort();
917        assert_eq!(got, ["alpha".to_string(), "bravo".to_string()]);
918    }
919
920    #[test]
921    fn passwords_from_env_keeps_empty_values_for_later_filtering() {
922        // The scanner is not where empties are dropped; the constructor is.
923        let vars = [("MBA_PASSWORD_BOB", "")]
924            .into_iter()
925            .map(|(k, v)| (OsString::from(k), OsString::from(v)));
926        assert_eq!(passwords_from_env(vars), [String::new()]);
927    }
928
929    #[cfg(unix)]
930    #[test]
931    fn passwords_from_env_skips_non_unicode_without_panicking() {
932        use std::os::unix::ffi::OsStringExt;
933
934        // An unrelated variable with a non-Unicode *name*, and a
935        // `MBA_PASSWORD_*` with a non-Unicode *value*: both must be skipped
936        // (not panic), while a valid entry alongside them still collects.
937        let vars = vec![
938            (OsString::from_vec(vec![0xff]), OsString::from("ignored")),
939            (
940                OsString::from("MBA_PASSWORD_BAD"),
941                OsString::from_vec(vec![0xff]),
942            ),
943            (OsString::from("MBA_PASSWORD"), OsString::from("alpha")),
944        ]
945        .into_iter();
946        assert_eq!(passwords_from_env(vars), ["alpha".to_string()]);
947    }
948
949    #[test]
950    fn surrounding_whitespace_in_upstream_is_trimmed() {
951        let config = Config::from_values("hunter2", "  http://app:2001  ").unwrap();
952        assert_eq!(config.upstream(), "http://app:2001/");
953    }
954}