use std::error::Error;
use std::fmt;
use axum::http::Uri;
use blake3::Hash;
const ENV_PASSWORD: &str = "MBA_PASSWORD";
const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
#[derive(Clone)]
pub struct Config {
session: Hash,
upstream: String,
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
let password = std::env::var(ENV_PASSWORD).unwrap_or_default();
let upstream = std::env::var(ENV_UPSTREAM).unwrap_or_default();
Self::from_values(&password, &upstream)
}
pub fn from_values(password: &str, upstream: &str) -> Result<Self, ConfigError> {
if password.is_empty() {
return Err(ConfigError::MissingPassword);
}
let upstream = validate_upstream(upstream)?;
Ok(Self {
session: blake3::hash(password.as_bytes()),
upstream,
})
}
pub(crate) fn session(&self) -> &Hash {
&self.session
}
pub(crate) fn upstream(&self) -> &str {
&self.upstream
}
}
impl fmt::Debug for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Config")
.field("session", &"<redacted>")
.field("upstream", &self.upstream)
.finish()
}
}
fn validate_upstream(upstream: &str) -> Result<String, ConfigError> {
let upstream = upstream.trim();
if upstream.is_empty() {
return Err(ConfigError::MissingUpstream);
}
let invalid = |reason: &'static str| ConfigError::InvalidUpstream {
value: upstream.to_string(),
reason,
};
let uri: Uri = upstream.parse().map_err(|_| invalid("not a valid URL"))?;
if !matches!(uri.scheme_str(), Some("http" | "https")) {
return Err(invalid("scheme must be http or https"));
}
let host = uri.host().unwrap_or_default();
if host.is_empty() {
return Err(invalid("missing host"));
}
if uri.port_u16() == Some(0) {
return Err(invalid("invalid port"));
}
let authority = uri.authority().map_or("", |a| a.as_str());
let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
let canonical = match uri.port() {
Some(port) => format!("{host}:{}", port.as_str()),
None => host.to_string(),
};
if host_port != canonical {
return Err(invalid("invalid host or port"));
}
Ok(uri.to_string())
}
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigError {
MissingPassword,
MissingUpstream,
InvalidUpstream { value: String, reason: &'static str },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingPassword => {
write!(f, "`{ENV_PASSWORD}` must be set to a non-empty value")
}
Self::MissingUpstream => write!(f, "`{ENV_UPSTREAM}` must be set"),
Self::InvalidUpstream { value, reason } => write!(
f,
"`{ENV_UPSTREAM}` is not a valid absolute http(s) URL \
({reason}): {value:?}"
),
}
}
}
impl Error for ConfigError {}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
#[test]
fn valid_values_build_a_config() {
assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
}
#[test]
fn https_upstream_is_accepted() {
assert!(Config::from_values("hunter2", "https://app.example.com").is_ok());
}
#[test]
fn empty_password_is_rejected() {
assert_matches!(
Config::from_values("", "http://app:2001"),
Err(ConfigError::MissingPassword)
);
}
#[test]
fn empty_upstream_is_rejected() {
assert_matches!(
Config::from_values("hunter2", ""),
Err(ConfigError::MissingUpstream)
);
}
#[test]
fn relative_upstream_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "/foo"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn scheme_only_upstream_without_authority_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "example:8080"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn non_http_scheme_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "ftp://host"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn empty_host_upstream_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://:8080"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn out_of_range_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:99999"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn non_numeric_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:abc"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn empty_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn zero_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:0"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn leading_zero_port_is_accepted() {
assert!(Config::from_values("hunter2", "http://host:080").is_ok());
}
#[test]
fn all_zero_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://host:00"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn ipv6_upstream_is_accepted() {
assert!(Config::from_values("hunter2", "http://[::1]:8080").is_ok());
}
#[test]
fn ipv6_with_trailing_junk_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://[::1]junk"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn ipv6_junk_before_port_is_rejected() {
assert_matches!(
Config::from_values("hunter2", "http://[::1]junk:8080"),
Err(ConfigError::InvalidUpstream { .. })
);
}
#[test]
fn session_digest_matches_blake3_of_password() {
let config = Config::from_values("hunter2", "http://app:2001").unwrap();
assert_eq!(config.session(), &blake3::hash(b"hunter2"));
}
#[test]
fn surrounding_whitespace_in_upstream_is_trimmed() {
let config = Config::from_values("hunter2", " http://app:2001 ").unwrap();
assert_eq!(config.upstream(), "http://app:2001/");
}
}