#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Https,
Http,
}
fn scheme_of(url: &str) -> Option<&str> {
let scheme = url.split(':').next()?;
if scheme.is_empty() || scheme.len() == url.len() {
return None;
}
let mut chars = scheme.chars();
if !chars.next()?.is_ascii_alphabetic() {
return None;
}
if chars.any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))) {
return None;
}
Some(scheme)
}
pub fn validate_scheme(url: &str, allow_http: bool) -> Result<Transport, String> {
let Some(scheme) = scheme_of(url) else {
return Err(format!(
"URL '{url}' has no usable scheme; only https:// URLs are supported."
));
};
match scheme.to_ascii_lowercase().as_str() {
"https" => Ok(Transport::Https),
"http" if allow_http => Ok(Transport::Http),
"http" => Err(format!(
"URL '{url}' uses insecure HTTP, which a network-level attacker can \
intercept and replace. Use https://, or enable the explicit HTTP \
opt-in if you accept that risk."
)),
other => Err(format!(
"URL '{url}' uses the '{other}' scheme, which is not permitted; only \
https:// (or http:// with an explicit opt-in) is supported."
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_is_always_accepted() {
assert_eq!(
validate_scheme("https://example.com/profiles.yaml", false),
Ok(Transport::Https)
);
assert_eq!(
validate_scheme("https://example.com/profiles.yaml", true),
Ok(Transport::Https)
);
}
#[test]
fn scheme_matching_is_case_insensitive() {
assert_eq!(
validate_scheme("HTTPS://example.com/x", false),
Ok(Transport::Https)
);
assert_eq!(
validate_scheme("HtTp://example.com/x", true),
Ok(Transport::Http)
);
}
#[test]
fn http_requires_the_opt_in() {
let err = validate_scheme("http://example.com/x", false).expect_err("must reject");
assert!(err.contains("insecure HTTP"), "unexpected error: {err}");
assert_eq!(
validate_scheme("http://example.com/x", true),
Ok(Transport::Http)
);
}
#[test]
fn every_other_scheme_is_rejected_even_with_the_http_opt_in() {
for url in [
"file:///etc/passwd",
"FILE://localhost/etc/passwd",
"file:/etc/passwd",
"ftp://example.com/x",
"data:text/yaml;base64,LQo=",
"javascript:alert(1)",
] {
let Err(err) = validate_scheme(url, true) else {
panic!("{url} must be rejected even with the HTTP opt-in");
};
assert!(
err.contains("not permitted"),
"unexpected error for {url}: {err}"
);
}
}
#[test]
fn schemeless_and_malformed_urls_are_rejected() {
for url in [
"example.com/profiles.yaml",
"",
"://example.com",
"1http://example.com",
"/tmp/local:file.yaml",
] {
let Err(err) = validate_scheme(url, true) else {
panic!("{url:?} must be rejected");
};
assert!(
err.contains("no usable scheme"),
"unexpected error for {url:?}: {err}"
);
}
}
#[test]
fn scheme_extraction_stops_at_the_first_colon() {
assert_eq!(scheme_of("https://user:pass@example.com"), Some("https"));
assert_eq!(scheme_of("http://example.com:8443/x"), Some("http"));
assert_eq!(scheme_of("no-colon-here"), None);
}
}