use std::fmt;
use crate::config::ConfigError;
const SCHEME_SEPARATOR: &str = "://";
const INSECURE_SCHEMES: [&str; 2] = ["ws", "http"];
const SECURE_SCHEMES: [&str; 2] = ["wss", "https"];
const AUTHORITY_TERMINATORS: [char; 3] = ['/', '?', '#'];
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ServerAddress {
text: String,
secure: bool,
}
impl ServerAddress {
#[must_use = "a checked address does nothing unless it is used"]
pub fn try_new(address: impl Into<String>) -> Result<Self, ConfigError> {
let address = address.into();
let trimmed = address.trim();
if trimmed.is_empty() {
return Err(ConfigError::EmptyServerAddress);
}
let Some((scheme, rest)) = trimmed.split_once(SCHEME_SEPARATOR) else {
return Err(ConfigError::MissingScheme {
address: without_userinfo(trimmed),
});
};
let scheme = scheme.to_ascii_lowercase();
let secure = if SECURE_SCHEMES.contains(&scheme.as_str()) {
true
} else if INSECURE_SCHEMES.contains(&scheme.as_str()) {
false
} else {
return Err(ConfigError::UnsupportedScheme { scheme });
};
let split = rest.find(AUTHORITY_TERMINATORS).unwrap_or(rest.len());
let authority = rest.get(..split).unwrap_or(rest);
let path = rest.get(split..).unwrap_or("");
if authority.is_empty() {
return Err(ConfigError::MissingHost {
address: without_userinfo(trimmed),
});
}
if authority.contains('@') {
return Err(ConfigError::AddressHasUserinfo);
}
check_authority(authority)?;
let path = check_path(path)?;
Ok(Self {
text: format!("{scheme}{SCHEME_SEPARATOR}{authority}{path}"),
secure,
})
}
#[must_use]
#[inline]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
#[inline]
pub const fn is_secure(&self) -> bool {
self.secure
}
}
fn without_userinfo(address: &str) -> String {
let (scheme, rest) = match address.split_once(SCHEME_SEPARATOR) {
Some((scheme, rest)) => (format!("{scheme}{SCHEME_SEPARATOR}"), rest),
None => (String::new(), address),
};
let end = rest.find(AUTHORITY_TERMINATORS).unwrap_or(rest.len());
let authority = rest.get(..end).unwrap_or(rest);
let tail = rest.get(end..).unwrap_or("");
match authority.rsplit_once('@') {
Some((_, host)) => format!("{scheme}{}@{host}{tail}", crate::REDACTED),
None => address.to_owned(),
}
}
fn check_authority(authority: &str) -> Result<(), ConfigError> {
let (host, port) = match authority.strip_prefix('[') {
Some(bracketed) => match bracketed.split_once(']') {
Some((host, after)) => (host, after.strip_prefix(':')),
None => {
return Err(ConfigError::InvalidHost {
host: authority.to_owned(),
});
}
},
None => match authority.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
},
};
let bracketed = authority.starts_with('[');
if host.is_empty()
|| host
.chars()
.any(|c| c.is_whitespace() || c.is_control() || c == '[' || c == ']' || c == '@')
|| (!bracketed && host.contains(':'))
{
return Err(ConfigError::InvalidHost {
host: host.to_owned(),
});
}
if let Some(port) = port
&& !matches!(port.parse::<u16>(), Ok(1..=u16::MAX))
{
return Err(ConfigError::InvalidPort {
port: port.to_owned(),
});
}
Ok(())
}
fn check_path(path: &str) -> Result<&str, ConfigError> {
if path.starts_with('?') || path.starts_with('#') {
return Err(ConfigError::InvalidAddressPath {
path: path.to_owned(),
});
}
let invalid = path.contains(['?', '#'])
|| path.chars().any(|c| c.is_whitespace() || c.is_control())
|| path.split('/').any(|segment| segment == "..");
if invalid {
return Err(ConfigError::InvalidAddressPath {
path: path.to_owned(),
});
}
Ok(path.trim_end_matches('/'))
}
impl fmt::Display for ServerAddress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_address_accepts_every_supported_scheme() {
for (address, secure) in [
("ws://localhost:8080", false),
("http://localhost:8080", false),
("wss://push.example.com", true),
("https://push.example.com", true),
] {
match ServerAddress::try_new(address) {
Ok(parsed) => {
assert_eq!(parsed.as_str(), address);
assert_eq!(parsed.is_secure(), secure, "{address}");
}
Err(error) => panic!("{address} was rejected: {error}"),
}
}
}
#[test]
fn test_server_address_scheme_is_case_insensitive() {
assert!(matches!(
ServerAddress::try_new("WSS://push.example.com"),
Ok(address) if address.is_secure()
));
}
#[test]
fn test_server_address_trims_surrounding_whitespace() {
assert!(matches!(
ServerAddress::try_new(" wss://push.example.com "),
Ok(address) if address.as_str() == "wss://push.example.com"
));
}
#[test]
fn test_server_address_strips_a_trailing_slash() {
assert!(matches!(
ServerAddress::try_new("wss://push.example.com/"),
Ok(address) if address.as_str() == "wss://push.example.com"
));
}
#[test]
fn test_server_address_rejects_an_empty_string() {
assert!(matches!(
ServerAddress::try_new(" "),
Err(ConfigError::EmptyServerAddress)
));
}
#[test]
fn test_server_address_rejects_a_missing_scheme() {
assert!(matches!(
ServerAddress::try_new("push.example.com"),
Err(ConfigError::MissingScheme { .. })
));
}
#[test]
fn test_server_address_rejects_an_unsupported_scheme() {
assert!(matches!(
ServerAddress::try_new("ftp://push.example.com"),
Err(ConfigError::UnsupportedScheme { scheme }) if scheme == "ftp"
));
}
#[test]
fn test_server_address_rejects_a_missing_host() {
assert!(matches!(
ServerAddress::try_new("wss://"),
Err(ConfigError::MissingHost { .. })
));
assert!(matches!(
ServerAddress::try_new("wss:///"),
Err(ConfigError::MissingHost { .. })
));
}
#[test]
fn test_server_address_displays_what_was_configured() {
match ServerAddress::try_new("wss://push.example.com:443") {
Ok(address) => assert_eq!(address.to_string(), "wss://push.example.com:443"),
Err(error) => panic!("rejected: {error}"),
}
}
#[test]
fn test_server_address_accepts_every_host_form() {
for address in [
"wss://192.0.2.10",
"wss://192.0.2.10:8080",
"wss://[2001:db8::1]",
"wss://[2001:db8::1]:8080",
"wss://push.example.com",
"wss://push.example.com:65535",
"ws://localhost:1",
] {
match ServerAddress::try_new(address) {
Ok(parsed) => assert_eq!(parsed.as_str(), address),
Err(error) => panic!("{address} was rejected: {error}"),
}
}
}
#[test]
fn test_server_address_rejects_userinfo() {
for address in [
"wss://alice:hunter2@push.example.com",
"wss://alice@push.example.com",
"wss://alice:hunter2@push.example.com/lightstreamer",
] {
assert!(
matches!(
ServerAddress::try_new(address),
Err(ConfigError::AddressHasUserinfo)
),
"{address} was accepted"
);
}
}
#[test]
fn test_server_address_never_renders_a_rejected_credential() {
for address in [
"wss://alice:hunter2@push.example.com",
"alice:hunter2@push.example.com",
"wss://alice:hunter2@",
] {
match ServerAddress::try_new(address) {
Err(error) => {
let rendered = error.to_string();
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(!rendered.contains("alice"), "{rendered}");
}
Ok(parsed) => panic!("{address} was accepted as {parsed}"),
}
}
}
#[test]
fn test_server_address_rejects_a_host_that_is_not_one() {
for address in [
"wss:// push.example.com",
"wss://push example.com",
"wss://push.example.com\u{7}",
"wss://:8080",
"wss://[2001:db8::1",
"wss://2001:db8::1",
] {
assert!(
matches!(
ServerAddress::try_new(address),
Err(ConfigError::InvalidHost { .. })
),
"{address} was accepted"
);
}
}
#[test]
fn test_server_address_rejects_a_port_that_is_not_one() {
for address in [
"wss://push.example.com:0",
"wss://push.example.com:65536",
"wss://push.example.com:99999",
"wss://push.example.com:http",
"wss://push.example.com:",
"wss://push.example.com:-1",
"wss://[2001:db8::1]:0",
] {
assert!(
matches!(
ServerAddress::try_new(address),
Err(ConfigError::InvalidPort { .. })
),
"{address} was accepted"
);
}
}
#[test]
fn test_server_address_rejects_a_query_a_fragment_and_a_traversal() {
for address in [
"wss://push.example.com?token=secret",
"wss://push.example.com#fragment",
"wss://push.example.com/push?a=b",
"wss://push.example.com/a/../b",
"wss://push.example.com/..",
"wss://push.example.com/a b",
] {
assert!(
matches!(
ServerAddress::try_new(address),
Err(ConfigError::InvalidAddressPath { .. })
),
"{address} was accepted"
);
}
}
#[test]
fn test_server_address_keeps_a_base_path_and_canonicalizes_the_scheme() {
for (given, canonical) in [
(
"HTTPS://push.example.com/push/",
"https://push.example.com/push",
),
(
"wss://push.example.com/lightstreamer",
"wss://push.example.com/lightstreamer",
),
("Ws://localhost:8080//", "ws://localhost:8080"),
] {
match ServerAddress::try_new(given) {
Ok(parsed) => assert_eq!(parsed.as_str(), canonical, "{given}"),
Err(error) => panic!("{given} was rejected: {error}"),
}
}
}
}