1use 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
16const WALL_TEMPLATE: &str = include_str!("index.html");
19const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:8000";
21const ENV_ADDRESS: &str = "MBA_ADDRESS";
23const ENV_PASSWORD: &str = "MBA_PASSWORD";
27const ENV_PASSWORD_PREFIX: &str = "MBA_PASSWORD_";
32const ENV_TEMPLATE_PAGE_LANGUAGE: &str = "MBA_TEMPLATE_PAGE_LANGUAGE";
34const ENV_TEMPLATE_PAGE_TITLE: &str = "MBA_TEMPLATE_PAGE_TITLE";
36const ENV_TEMPLATE_PASSWORD_LABEL: &str = "MBA_TEMPLATE_PASSWORD_LABEL";
38const ENV_TEMPLATE_PASSWORD_PLACEHOLDER: &str = "MBA_TEMPLATE_PASSWORD_PLACEHOLDER";
40const ENV_TEMPLATE_SUBMIT_BUTTON_TEXT: &str = "MBA_TEMPLATE_SUBMIT_BUTTON_TEXT";
42const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
45
46#[derive(Clone)]
53pub struct Config {
54 bind_address: SocketAddr,
56 sessions: Vec<Hash>,
65 upstream: String,
70 wall: Bytes,
73}
74
75impl Config {
76 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 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 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 #[must_use]
157 pub fn bind_address(&self) -> SocketAddr {
158 self.bind_address
159 }
160
161 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 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 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 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 let unique: HashSet<&str> = passwords.iter().copied().collect();
208 if unique.len() != passwords.len() {
209 return Err(ConfigError::DuplicatePassword);
210 }
211 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 pub(crate) fn sessions(&self) -> &[Hash] {
230 &self.sessions
231 }
232
233 pub(crate) fn upstream(&self) -> &str {
235 &self.upstream
236 }
237
238 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 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
257struct 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 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
304fn 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
340fn push_html_escaped(rendered: &mut String, value: &str) {
342 for character in value.chars() {
343 match character {
344 '&' => rendered.push_str("&"),
345 '<' => rendered.push_str("<"),
346 '>' => rendered.push_str(">"),
347 '"' => rendered.push_str("""),
348 '\'' => rendered.push_str("'"),
349 _ => rendered.push(character),
350 }
351 }
352}
353
354fn passwords_from_env(vars: impl Iterator<Item = (OsString, OsString)>) -> Vec<String> {
365 vars.filter_map(|(name, value)| {
366 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
376fn 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
401fn 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 if host.is_empty() {
437 return Err(invalid("missing host"));
438 }
439 if uri.port_u16() == Some(0) {
441 return Err(invalid("invalid port"));
442 }
443 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 Ok(uri.to_string())
466}
467
468#[derive(Debug, PartialEq, Eq)]
471pub enum ConfigError {
472 InvalidAddress { value: String, reason: &'static str },
474 MissingPassword,
476 DuplicatePassword,
479 InvalidTemplateText { variable: String },
481 MissingUpstream,
483 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 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, "&<>"'");
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 assert!(Config::from_values("hunter2", "http://host:080").is_ok());
820 }
821
822 #[test]
823 fn all_zero_port_is_rejected() {
824 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 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 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 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}