#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HtuScheme {
Http,
Https,
}
impl HtuScheme {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
}
}
#[must_use]
pub const fn default_port(self) -> u16 {
match self {
Self::Http => 80,
Self::Https => 443,
}
}
#[must_use]
pub fn parse(scheme: &str) -> Option<Self> {
match scheme.to_ascii_lowercase().as_str() {
"http" => Some(Self::Http),
"https" => Some(Self::Https),
_ => None,
}
}
}
#[must_use]
pub fn build_normalized_htu(
scheme: HtuScheme,
host: &str,
port: Option<u16>,
path: &str,
) -> String {
let port_str = match port {
Some(p) if p == scheme.default_port() => String::new(),
Some(p) => decimal_port_str(p),
None => String::new(),
};
let normalized_path = if path.is_empty() { "/" } else { path };
let mut out = String::with_capacity(
scheme.as_str().len() + 3 + host.len() + port_str.len() + normalized_path.len(),
);
out.push_str(scheme.as_str());
out.push_str("://");
out.push_str(host);
out.push_str(&port_str);
out.push_str(normalized_path);
out
}
#[must_use]
pub fn invariants_hold(
scheme: HtuScheme,
host: &str,
port: Option<u16>,
path: &str,
out: &str,
) -> bool {
let expected = build_normalized_htu(scheme, host, port, path);
if out != expected {
return false;
}
if host.bytes().any(|b| b == b'?' || b == b'#') || path.bytes().any(|b| b == b'?' || b == b'#')
{
return false;
}
let prefix = format!("{}://", scheme.as_str());
let lower_host = host.to_ascii_lowercase();
if !out.starts_with(&prefix) || !out[prefix.len()..].starts_with(&lower_host) {
return false;
}
let after_host = &out[prefix.len() + lower_host.len()..];
match port {
None => !starts_with_explicit_port(after_host),
Some(p) if p == scheme.default_port() => !starts_with_explicit_port(after_host),
Some(p) => after_host.starts_with(&decimal_port_str(p)),
}
}
#[must_use]
fn decimal_port_str(port: u16) -> String {
let mut buf = [b'0'; 6];
let mut len = 0;
let mut n = port;
if n == 0 {
buf[0] = b'0';
len = 1;
}
while n > 0 {
buf[len] = b'0' + (n % 10) as u8;
n /= 10;
len += 1;
}
let mut out = String::with_capacity(len + 1);
out.push(':');
for b in buf[..len].iter().rev() {
out.push(*b as char);
}
out
}
#[must_use]
fn starts_with_explicit_port(s: &str) -> bool {
let b = s.as_bytes();
if b.first() != Some(&b':') {
return false;
}
matches!(b.get(1), Some(c) if c.is_ascii_digit())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, missing_docs)]
mod tests {
use super::*;
#[test]
fn default_ports_stripped() {
assert_eq!(
build_normalized_htu(HtuScheme::Https, "example.com", Some(443), "/oauth/token"),
"https://example.com/oauth/token"
);
assert_eq!(
build_normalized_htu(HtuScheme::Http, "example.com", Some(80), "/"),
"http://example.com/"
);
}
#[test]
fn custom_ports_preserved() {
assert_eq!(
build_normalized_htu(HtuScheme::Https, "example.com", Some(8443), "/x"),
"https://example.com:8443/x"
);
}
#[test]
fn absent_port_omitted() {
assert_eq!(
build_normalized_htu(HtuScheme::Https, "example.com", None, "/p"),
"https://example.com/p"
);
}
#[test]
fn scheme_parse_case_insensitive_and_strict() {
assert_eq!(HtuScheme::parse("HTTPS"), Some(HtuScheme::Https));
assert_eq!(HtuScheme::parse("http"), Some(HtuScheme::Http));
assert_eq!(HtuScheme::parse("ftp"), None);
assert_eq!(HtuScheme::parse(""), None);
}
#[test]
fn invariants_hold_conservatively() {
for scheme in [HtuScheme::Http, HtuScheme::Https] {
for port in [None, Some(80), Some(443), Some(8443), Some(1)] {
for path in ["/", "/a/b", "/Token/Path"] {
let out = build_normalized_htu(scheme, "auth.example.com", port, path);
assert!(
invariants_hold(scheme, "auth.example.com", port, path, &out),
"invariants violated for {out}"
);
}
}
}
}
}