use crate::localization::{self, LocalizedMessage, keys};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;
#[derive(Copy, Clone)]
struct HostPatternInput<'a>(&'a str);
impl<'a> HostPatternInput<'a> {
const fn as_str(self) -> &'a str {
self.0
}
}
#[derive(Copy, Clone)]
pub(crate) struct HostCandidate<'a>(pub(crate) &'a str);
impl<'a> HostCandidate<'a> {
const fn as_str(self) -> &'a str {
self.0
}
}
struct ValidationContext<'a> {
original: HostPatternInput<'a>,
}
impl<'a> ValidationContext<'a> {
const fn new(original: HostPatternInput<'a>) -> Self {
Self { original }
}
fn validate_label(&self, label: &str) -> Result<(), HostPatternError> {
let original = self.original.as_str();
if label.is_empty() {
return Err(HostPatternError::EmptyLabel {
pattern: original.to_owned(),
message: localization::message(keys::HOST_PATTERN_EMPTY_LABEL)
.with_arg("pattern", original),
});
}
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(HostPatternError::InvalidCharacters {
pattern: original.to_owned(),
message: localization::message(keys::HOST_PATTERN_INVALID_CHARS)
.with_arg("pattern", original),
});
}
if label.starts_with('-') || label.ends_with('-') {
return Err(HostPatternError::InvalidLabelEdge {
pattern: original.to_owned(),
message: localization::message(keys::HOST_PATTERN_INVALID_LABEL_EDGE)
.with_arg("pattern", original),
});
}
if label.len() > 63 {
return Err(HostPatternError::LabelTooLong {
pattern: original.to_owned(),
message: localization::message(keys::HOST_PATTERN_LABEL_TOO_LONG)
.with_arg("pattern", original),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum HostPatternError {
#[error("{message}")]
Empty {
message: LocalizedMessage,
},
#[error("{message}")]
ContainsScheme {
pattern: String,
message: LocalizedMessage,
},
#[error("{message}")]
ContainsSlash {
pattern: String,
message: LocalizedMessage,
},
#[error("{message}")]
MissingSuffix {
pattern: String,
message: LocalizedMessage,
},
#[error("{message}")]
EmptyLabel {
pattern: String,
message: LocalizedMessage,
},
#[error("{message}")]
InvalidCharacters {
pattern: String,
message: LocalizedMessage,
},
#[error("{message}")]
InvalidLabelEdge {
pattern: String,
message: LocalizedMessage,
},
#[error("{message}")]
LabelTooLong {
pattern: String,
message: LocalizedMessage,
},
#[error("{message}")]
HostTooLong {
pattern: String,
message: LocalizedMessage,
},
}
fn normalise_host_pattern(input: HostPatternInput<'_>) -> Result<(String, bool), HostPatternError> {
let trimmed = input.as_str().trim();
if trimmed.is_empty() {
return Err(HostPatternError::Empty {
message: localization::message(keys::HOST_PATTERN_EMPTY),
});
}
if trimmed.contains("://") {
return Err(HostPatternError::ContainsScheme {
pattern: trimmed.to_owned(),
message: localization::message(keys::HOST_PATTERN_CONTAINS_SCHEME)
.with_arg("pattern", trimmed),
});
}
if trimmed.contains('/') {
return Err(HostPatternError::ContainsSlash {
pattern: trimmed.to_owned(),
message: localization::message(keys::HOST_PATTERN_CONTAINS_SLASH)
.with_arg("pattern", trimmed),
});
}
let (wildcard, host_body) = if let Some(suffix) = trimmed.strip_prefix("*.") {
if suffix.is_empty() {
return Err(HostPatternError::MissingSuffix {
pattern: trimmed.to_owned(),
message: localization::message(keys::HOST_PATTERN_MISSING_SUFFIX)
.with_arg("pattern", trimmed),
});
}
(true, suffix)
} else {
(false, trimmed)
};
let normalised = host_body.to_ascii_lowercase();
let mut total_len = 0usize;
let ctx = ValidationContext::new(HostPatternInput(trimmed));
for (index, label) in normalised.split('.').enumerate() {
ctx.validate_label(label)?;
total_len += label.len() + usize::from(index > 0);
}
if total_len > 255 {
return Err(HostPatternError::HostTooLong {
pattern: trimmed.to_owned(),
message: localization::message(keys::HOST_PATTERN_TOO_LONG)
.with_arg("pattern", trimmed),
});
}
Ok((normalised, wildcard))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostPattern {
pub(crate) pattern: String,
pub(crate) wildcard: bool,
}
impl HostPattern {
pub fn parse(pattern: &str) -> Result<Self, HostPatternError> {
let (normalised, wildcard) = normalise_host_pattern(HostPatternInput(pattern))?;
Ok(Self {
pattern: normalised,
wildcard,
})
}
pub(crate) fn matches(&self, candidate: HostCandidate<'_>) -> bool {
let host = candidate.as_str().to_ascii_lowercase();
if self.wildcard {
host.strip_suffix(&self.pattern)
.and_then(|prefix| prefix.strip_suffix('.'))
.is_some_and(|prefix| !prefix.is_empty())
} else {
host == self.pattern
}
}
}
impl<'a> TryFrom<&'a str> for HostPattern {
type Error = HostPatternError;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl TryFrom<String> for HostPattern {
type Error = HostPatternError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl FromStr for HostPattern {
type Err = HostPatternError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
impl Serialize for HostPattern {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if self.wildcard {
let text = format!("*.{}", self.pattern);
serializer.serialize_str(&text)
} else {
serializer.serialize_str(&self.pattern)
}
}
}
impl<'de> Deserialize<'de> for HostPattern {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let text = String::deserialize(deserializer)?;
Self::parse(&text).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::{Result, ensure};
use rstest::rstest;
use test_support::{localizer_test_lock, set_en_localizer};
#[rstest]
#[case("example.com", false)]
#[case("*.example.com", true)]
fn host_pattern_parse_detects_wildcard(
#[case] pattern: &str,
#[case] wildcard: bool,
) -> Result<()> {
let parsed = HostPattern::parse(pattern)?;
ensure!(
parsed.wildcard == wildcard,
"expected wildcard {wildcard} for pattern {pattern}",
);
Ok(())
}
#[rstest]
#[case("example.com", "example.com", true)]
#[case("example.com", "sub.example.com", false)]
#[case("*.example.com", "sub.example.com", true)]
#[case("*.example.com", "example.com", false)]
#[case("*.example.com", "deep.sub.example.com", true)]
#[case("*.example.com", "other.com", false)]
fn host_pattern_matches_expected(
#[case] pattern: &str,
#[case] host: &str,
#[case] expected: bool,
) -> Result<()> {
let parsed = HostPattern::parse(pattern)?;
ensure!(
parsed.matches(HostCandidate(host)) == expected,
"expected match={expected} for {host} against {pattern}",
);
Ok(())
}
#[rstest]
#[case("-example.com")]
#[case("example-.com")]
#[case("exa mple.com")]
#[case("*.bad-.test")]
fn host_pattern_rejects_invalid_shapes(#[case] pattern: &str) {
let _lock = localizer_test_lock().expect("localizer test lock poisoned");
let _guard = set_en_localizer();
let err = HostPattern::parse(pattern).expect_err("invalid pattern should fail");
let message = err.to_string();
assert!(
message.contains("Host pattern"),
"error message should mention host pattern validation: {message}"
);
}
}