use thiserror::Error;
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("invalid remote host `{value}`: {reason}")]
pub struct HttpHostError {
value: String,
reason: String,
}
impl HttpHostError {
fn new(value: &str, reason: impl Into<String>) -> Self {
Self {
value: if value.is_empty() {
"<empty>".to_string()
} else {
value.to_string()
},
reason: reason.into(),
}
}
}
pub fn normalize_http_hosts(hosts: &[String]) -> Result<Vec<String>, HttpHostError> {
let mut normalized = Vec::new();
for raw in hosts {
for element in raw.split(',') {
let host = element.trim();
if host.is_empty() {
return Err(HttpHostError::new(
raw.trim(),
"host list contains an empty entry",
));
}
parse_http_host_url(host)?;
normalized.push(host.to_string());
}
}
Ok(normalized)
}
pub(crate) fn parse_http_host_url(host: &str) -> Result<Url, HttpHostError> {
if let Some((prefix, _)) = host.split_once(':')
&& matches!(prefix.to_ascii_lowercase().as_str(), "http" | "https")
&& !host.contains("://")
{
return Err(HttpHostError::new(
host,
"malformed URL: HTTP and HTTPS schemes must be followed by `://`",
));
}
let candidate = if let Some((scheme, remainder)) = host.split_once("://") {
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
return Err(HttpHostError::new(
host,
format!("unsupported scheme `{scheme}`; expected http or https"),
));
}
if remainder.is_empty() {
return Err(HttpHostError::new(host, "missing host"));
}
host.to_string()
} else {
format!("http://{host}")
};
let url = Url::parse(&candidate)
.map_err(|error| HttpHostError::new(host, format!("malformed URL: {error}")))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(HttpHostError::new(
host,
format!(
"unsupported scheme `{}`; expected http or https",
url.scheme()
),
));
}
if url.host_str().is_none_or(str::is_empty) {
return Err(HttpHostError::new(host, "missing host"));
}
if url.port() == Some(0) {
return Err(HttpHostError::new(host, "port must be between 1 and 65535"));
}
Ok(url)
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn accepts_whitespace_comma_and_mixed_lists_in_order() {
assert_eq!(
normalize_http_hosts(&strings(&["host-a:9090", "host-b:9090"])).unwrap(),
strings(&["host-a:9090", "host-b:9090"])
);
assert_eq!(
normalize_http_hosts(&strings(&["host-a:9090,host-b:9090"])).unwrap(),
strings(&["host-a:9090", "host-b:9090"])
);
assert_eq!(
normalize_http_hosts(&strings(&[
" host-a:9090, https://host-b:9443 ",
"http://[2001:db8::1]:9090"
]))
.unwrap(),
strings(&[
"host-a:9090",
"https://host-b:9443",
"http://[2001:db8::1]:9090"
])
);
}
#[test]
fn rejects_empty_elements() {
for input in [
"",
",host-a:9090",
"host-a:9090,,host-b:9090",
"host-a:9090,",
] {
let error = normalize_http_hosts(&strings(&[input])).unwrap_err();
assert!(error.to_string().contains("empty entry"), "{error}");
}
}
#[test]
fn rejects_invalid_port_and_names_the_value() {
let error = normalize_http_hosts(&strings(&["host-a:9090,host-b:not-a-port"]))
.unwrap_err()
.to_string();
assert!(error.contains("host-b:not-a-port"), "{error}");
assert!(error.contains("invalid port"), "{error}");
}
#[test]
fn rejects_unsupported_scheme_missing_host_and_malformed_url() {
let unsupported = normalize_http_hosts(&strings(&["ftp://host-a:9090"]))
.unwrap_err()
.to_string();
assert!(unsupported.contains("ftp://host-a:9090"), "{unsupported}");
assert!(unsupported.contains("unsupported scheme"), "{unsupported}");
let missing = normalize_http_hosts(&strings(&["http://"]))
.unwrap_err()
.to_string();
assert!(missing.contains("http://"), "{missing}");
assert!(missing.contains("missing host"), "{missing}");
let malformed = normalize_http_hosts(&strings(&["http://[2001:db8::1"]))
.unwrap_err()
.to_string();
assert!(malformed.contains("http://[2001:db8::1"), "{malformed}");
assert!(malformed.contains("malformed URL"), "{malformed}");
let missing_slashes = normalize_http_hosts(&strings(&["https:/host-a:9090"]))
.unwrap_err()
.to_string();
assert!(
missing_slashes.contains("https:/host-a:9090"),
"{missing_slashes}"
);
assert!(
missing_slashes.contains("must be followed by `://`"),
"{missing_slashes}"
);
}
#[test]
fn preserves_https_and_accepts_unreachable_or_bracketed_ipv6_hosts() {
let hosts = normalize_http_hosts(&strings(&[
"https://does-not-exist.invalid:9443",
"[2001:db8::2]:9090",
]))
.unwrap();
assert_eq!(
hosts,
strings(&["https://does-not-exist.invalid:9443", "[2001:db8::2]:9090"])
);
assert_eq!(parse_http_host_url(&hosts[0]).unwrap().scheme(), "https");
}
}