pub mod pattern_trie;
use std::{
borrow::Cow,
fmt::{self, Debug, Write},
rc::Rc,
str::from_utf8,
time::Instant,
};
use regex::bytes::Regex;
use sozu_command::{
config::validate_header_predicate,
logging::CachedTags,
proto::command::{
HeaderMatchKind, HeaderPosition, HeaderPredicate, HstsConfig, PathRule as CommandPathRule,
PathRuleKind, RedirectPolicy, RedirectScheme, RulePosition,
},
response::HttpFrontend,
state::ClusterId,
};
use crate::metrics::names;
use crate::{
protocol::{http::editor::HeaderEditMode, http::parser::Method},
router::pattern_trie::{InlineTrieMatches, InsertResult, TrieNode, TrieSubMatch},
sozu_command::logging::ansi_palette,
};
macro_rules! log_module_context {
() => {{
let (open, reset, _, _, _) = ansi_palette();
format!("{open}ROUTER{reset}\t >>>", open = open, reset = reset)
}};
}
pub const MAX_HOSTNAME_LENGTH: usize = 4096;
#[derive(thiserror::Error, PartialEq)]
pub enum RouterError {
#[error("Could not parse rule from frontend path, path_bytes={}", .0.len())]
InvalidPathRule(String),
#[error("parsing hostname failed, hostname_bytes={}", .hostname.len())]
InvalidDomain { hostname: String },
#[error("Could not parse host rewrite, rewrite_host_bytes={}", .0.len())]
InvalidHostRewrite(String),
#[error("Could not parse path rewrite, rewrite_path_bytes={}", .0.len())]
InvalidPathRewrite(String),
#[error("Could not add route, route_bytes={}", .0.len())]
AddRoute(String),
#[error("invalid header predicate at index {index}: {reason}")]
InvalidHeaderPredicate { index: usize, reason: &'static str },
#[error("Could not remove route, route_bytes={}", .0.len())]
RemoveRoute(String),
#[error("route_not_found method={method:?} host_bytes={} path_bytes={}", .host.len(), .path.len())]
RouteNotFound {
host: String,
path: String,
method: Method,
},
}
impl fmt::Debug for RouterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
#[derive(Clone)]
pub struct Router {
pre: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
pub tree: TrieNode<Vec<(PathRule, MethodRule, HeaderRules, Route)>>,
post: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
}
impl Default for Router {
fn default() -> Self {
Self::new()
}
}
impl Router {
pub fn new() -> Router {
Router {
pre: Vec::new(),
tree: TrieNode::root(),
post: Vec::new(),
}
}
pub fn lookup(
&self,
hostname: &str,
path: &str,
method: &Method,
) -> Result<RouteResult, RouterError> {
self.lookup_with_headers(hostname, path, method, &NoHeaders)
}
pub fn lookup_with_headers(
&self,
hostname: &str,
path: &str,
method: &Method,
headers: &dyn RequestHeaders,
) -> Result<RouteResult, RouterError> {
let normalized_hostname = normalize_hostname(hostname);
let hostname_b = normalized_hostname.as_bytes();
let path_b = path.as_bytes();
if let Some((domain_rule, path_rule, _, _, route)) =
select_listed_rule(&self.pre, hostname_b, path_b, method, headers)
{
return Ok(RouteResult::new_no_trie(
hostname_b,
domain_rule,
path_b,
path_rule,
route,
));
}
let mut trie_matches = InlineTrieMatches::new();
if let Some((_, path_rules)) = self.tree.lookup_with_inline_path(
hostname_b,
true,
&mut trie_matches,
&mut |(_, path_rules)| select_tree_rule(path_rules, path_b, method, headers).is_some(),
) && let Some((path_rule, route)) = select_tree_rule(path_rules, path_b, method, headers)
{
return Ok(RouteResult::new_with_trie(
hostname_b,
&trie_matches,
path_b,
path_rule,
route,
));
}
if let Some((domain_rule, path_rule, _, _, route)) =
select_listed_rule(&self.post, hostname_b, path_b, method, headers)
{
return Ok(RouteResult::new_no_trie(
hostname_b,
domain_rule,
path_b,
path_rule,
route,
));
}
Err(RouterError::RouteNotFound {
host: hostname.to_owned(),
path: path.to_owned(),
method: method.to_owned(),
})
}
pub fn add_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
self.add_http_front_with_hsts_origin(front, HstsOrigin::Explicit)
}
pub fn add_http_front_with_hsts_origin(
&mut self,
front: &HttpFrontend,
hsts_origin: HstsOrigin,
) -> Result<(), RouterError> {
if front.hostname.len() > MAX_HOSTNAME_LENGTH {
return Err(RouterError::InvalidDomain {
hostname: front.hostname.clone(),
});
}
let path_rule = PathRule::from_config(front.path.clone())
.ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
let method_rule = MethodRule::new(front.method.clone());
let header_rules = HeaderRules::from_config(&front.header_predicates)?;
let has_policy = front.redirect.is_some()
|| front.redirect_scheme.is_some()
|| front.redirect_template.is_some()
|| front.rewrite_host.is_some()
|| front.rewrite_path.is_some()
|| front.rewrite_port.is_some()
|| front.required_auth.unwrap_or(false)
|| !front.headers.is_empty()
|| front.hsts.is_some()
|| front.tags.is_some();
let domain =
front
.hostname
.parse::<DomainRule>()
.map_err(|_| RouterError::InvalidDomain {
hostname: front.hostname.clone(),
})?;
let route = if has_policy {
let redirect = front
.redirect
.and_then(|r| RedirectPolicy::try_from(r).ok())
.unwrap_or(RedirectPolicy::Forward);
let redirect_scheme = front
.redirect_scheme
.and_then(|s| RedirectScheme::try_from(s).ok())
.unwrap_or(RedirectScheme::UseSame);
let frontend = Frontend::new(
&domain,
&path_rule,
front,
redirect,
redirect_scheme,
front.redirect_template.clone(),
front.rewrite_host.clone(),
front.rewrite_path.clone(),
front.rewrite_port.and_then(|p| u16::try_from(p).ok()),
&front.headers,
front.required_auth.unwrap_or(false),
hsts_origin,
)?;
Route::Frontend(Rc::new(frontend))
} else {
match &front.cluster_id {
Some(cluster_id) => Route::ClusterId(cluster_id.clone()),
None => Route::Deny,
}
};
let success = match front.position {
RulePosition::Pre => self.add_pre_rule_with_headers(
&domain,
&path_rule,
&method_rule,
&header_rules,
&route,
),
RulePosition::Post => self.add_post_rule_with_headers(
&domain,
&path_rule,
&method_rule,
&header_rules,
&route,
),
RulePosition::Tree => self.add_tree_rule_with_headers(
front.hostname.as_bytes(),
&path_rule,
&method_rule,
&header_rules,
&route,
),
};
if !success {
return Err(RouterError::AddRoute(format!("{front:?}")));
}
Ok(())
}
pub fn remove_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
if front.hostname.len() > MAX_HOSTNAME_LENGTH {
return Err(RouterError::InvalidDomain {
hostname: front.hostname.clone(),
});
}
let path_rule = PathRule::from_config(front.path.clone())
.ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
let method_rule = MethodRule::new(front.method.clone());
let header_rules = HeaderRules::from_config(&front.header_predicates)?;
let remove_success = match front.position {
RulePosition::Pre => {
let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
RouterError::InvalidDomain {
hostname: front.hostname.clone(),
}
})?;
self.remove_pre_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
}
RulePosition::Post => {
let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
RouterError::InvalidDomain {
hostname: front.hostname.clone(),
}
})?;
self.remove_post_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
}
RulePosition::Tree => self.remove_tree_rule_with_headers(
front.hostname.as_bytes(),
&path_rule,
&method_rule,
&header_rules,
),
};
if !remove_success {
return Err(RouterError::RemoveRoute(format!("{front:?}")));
}
Ok(())
}
pub fn add_tree_rule(
&mut self,
hostname: &[u8],
path: &PathRule,
method: &MethodRule,
cluster: &Route,
) -> bool {
self.add_tree_rule_with_headers(hostname, path, method, &HeaderRules::default(), cluster)
}
pub fn add_tree_rule_with_headers(
&mut self,
hostname: &[u8],
path: &PathRule,
method: &MethodRule,
headers: &HeaderRules,
cluster: &Route,
) -> bool {
let hostname = match from_utf8(hostname) {
Err(_) => return false,
Ok(h) => h,
};
match tree_hostname_to_ascii(hostname) {
Some(hostname) => {
let mut empty = true;
if let Some((_, paths)) = self.tree.domain_lookup_mut(hostname.as_bytes(), false) {
empty = false;
let before = paths.len();
if !paths
.iter()
.any(|(p, m, h, _)| p == path && m == method && h == headers)
{
paths.push((
path.to_owned(),
method.to_owned(),
headers.to_owned(),
cluster.to_owned(),
));
debug_assert_eq!(
paths.len(),
before + 1,
"appending a tree rule must grow the leaf's rule list by exactly one",
);
debug_assert!(
paths
.iter()
.any(|(p, m, h, _)| p == path && m == method && h == headers),
"the freshly appended (path, method, headers) rule must be present after insert",
);
return true;
}
}
if empty {
let inserted_host = hostname.clone().into_bytes();
let insert_result = self.tree.domain_insert(
hostname.into_bytes(),
vec![(
path.to_owned(),
method.to_owned(),
headers.to_owned(),
cluster.to_owned(),
)],
);
if insert_result == InsertResult::Failed {
error!(
"{} the route table rejected a malformed hostname, hostname_bytes={}",
log_module_context!(),
inserted_host.len(),
);
return false;
}
debug_assert!(
self.tree
.domain_lookup_mut(&inserted_host, false)
.is_some_and(|(_, paths)| paths
.iter()
.any(|(p, m, h, _)| p == path && m == method && h == headers)),
"a freshly inserted tree domain must resolve to its inserted rule",
);
return true;
}
false
}
None => false,
}
}
pub fn remove_tree_rule(
&mut self,
hostname: &[u8],
path: &PathRule,
method: &MethodRule,
) -> bool {
self.remove_tree_rule_with_headers(hostname, path, method, &HeaderRules::default())
}
pub fn remove_tree_rule_with_headers(
&mut self,
hostname: &[u8],
path: &PathRule,
method: &MethodRule,
headers: &HeaderRules,
) -> bool {
let hostname = match from_utf8(hostname) {
Err(_) => return false,
Ok(h) => h,
};
match tree_hostname_to_ascii(hostname) {
Some(hostname) => {
let should_delete = {
let paths_opt = self.tree.domain_lookup_mut(hostname.as_bytes(), false);
if let Some((_, paths)) = paths_opt {
paths.retain(|(p, m, h, _)| p != path || m != method || h != headers);
debug_assert!(
!paths
.iter()
.any(|(p, m, h, _)| p == path && m == method && h == headers),
"remove must evict every matching (path, method, headers) rule from the leaf",
);
}
paths_opt
.as_ref()
.map(|(_, paths)| paths.is_empty())
.unwrap_or(false)
};
if should_delete {
let removed_host = hostname.clone().into_bytes();
self.tree.domain_remove(&hostname.into_bytes());
debug_assert!(
self.tree.domain_lookup_mut(&removed_host, false).is_none(),
"a domain whose last rule was removed must be unreachable",
);
}
true
}
None => false,
}
}
pub fn refresh_inheriting_hsts(&mut self, new_hsts: Option<&HstsConfig>) -> usize {
let mut refreshed = 0usize;
let new_edit = build_listener_hsts_edit(new_hsts);
let new_edit_ref = new_edit.as_ref();
let promote_lightweight = new_edit_ref.is_some();
let mut visit = |route: &mut Route| match route {
Route::Frontend(rc) => {
if rc.inherits_listener_hsts {
let new_frontend = rebuild_with_listener_hsts(rc, new_edit_ref);
*rc = Rc::new(new_frontend);
refreshed += 1;
}
}
Route::ClusterId(id) => {
if promote_lightweight {
let promoted = rebuild_with_listener_hsts(
&Frontend::minimal_forward(id.clone()),
new_edit_ref,
);
*route = Route::Frontend(Rc::new(promoted));
refreshed += 1;
}
}
Route::Deny => {
if promote_lightweight {
let promoted =
rebuild_with_listener_hsts(&Frontend::minimal_deny(), new_edit_ref);
*route = Route::Frontend(Rc::new(promoted));
refreshed += 1;
}
}
};
for (_, _, _, _, route) in self.pre.iter_mut() {
visit(route);
}
self.tree.for_each_value_mut(&mut |paths| {
for (_, _, _, route) in paths.iter_mut() {
visit(route);
}
});
for (_, _, _, _, route) in self.post.iter_mut() {
visit(route);
}
refreshed
}
pub fn add_pre_rule(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
cluster_id: &Route,
) -> bool {
self.add_pre_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
}
pub fn add_pre_rule_with_headers(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
headers: &HeaderRules,
cluster_id: &Route,
) -> bool {
let before = self.pre.len();
if !self
.pre
.iter()
.any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
{
self.pre.push((
domain.to_owned(),
path.to_owned(),
method.to_owned(),
headers.to_owned(),
cluster_id.to_owned(),
));
debug_assert_eq!(
self.pre.len(),
before + 1,
"adding a unique pre-rule must push exactly one entry",
);
debug_assert!(
self.pre
.iter()
.any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
"the freshly added pre-rule must be present",
);
true
} else {
debug_assert_eq!(
self.pre.len(),
before,
"a duplicate pre-rule must not change the list length",
);
false
}
}
pub fn add_post_rule(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
cluster_id: &Route,
) -> bool {
self.add_post_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
}
pub fn add_post_rule_with_headers(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
headers: &HeaderRules,
cluster_id: &Route,
) -> bool {
let before = self.post.len();
if !self
.post
.iter()
.any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
{
self.post.push((
domain.to_owned(),
path.to_owned(),
method.to_owned(),
headers.to_owned(),
cluster_id.to_owned(),
));
debug_assert_eq!(
self.post.len(),
before + 1,
"adding a unique post-rule must push exactly one entry",
);
debug_assert!(
self.post
.iter()
.any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
"the freshly added post-rule must be present",
);
true
} else {
debug_assert_eq!(
self.post.len(),
before,
"a duplicate post-rule must not change the list length",
);
false
}
}
pub fn remove_pre_rule(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
) -> bool {
self.remove_pre_rule_with_headers(domain, path, method, &HeaderRules::default())
}
pub fn remove_pre_rule_with_headers(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
headers: &HeaderRules,
) -> bool {
let before = self.pre.len();
match self
.pre
.iter()
.position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
{
None => {
debug_assert_eq!(
self.pre.len(),
before,
"a no-op pre-rule removal must not change the list length",
);
false
}
Some(index) => {
debug_assert!(index < self.pre.len(), "found index must be in bounds");
self.pre.remove(index);
debug_assert_eq!(
self.pre.len() + 1,
before,
"removing a pre-rule must drop exactly one entry",
);
debug_assert!(
!self.pre.iter().any(|(d, p, m, h, _)| d == domain
&& p == path
&& m == method
&& h == headers),
"the removed pre-rule must no longer be present",
);
true
}
}
}
pub fn remove_post_rule(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
) -> bool {
self.remove_post_rule_with_headers(domain, path, method, &HeaderRules::default())
}
pub fn remove_post_rule_with_headers(
&mut self,
domain: &DomainRule,
path: &PathRule,
method: &MethodRule,
headers: &HeaderRules,
) -> bool {
let before = self.post.len();
match self
.post
.iter()
.position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
{
None => {
debug_assert_eq!(
self.post.len(),
before,
"a no-op post-rule removal must not change the list length",
);
false
}
Some(index) => {
debug_assert!(index < self.post.len(), "found index must be in bounds");
self.post.remove(index);
debug_assert_eq!(
self.post.len() + 1,
before,
"removing a post-rule must drop exactly one entry",
);
debug_assert!(
!self.post.iter().any(|(d, p, m, h, _)| d == domain
&& p == path
&& m == method
&& h == headers),
"the removed post-rule must no longer be present",
);
true
}
}
}
pub fn has_hostname(&self, hostname: &str) -> bool {
let normalized_hostname = normalize_hostname(hostname);
let hostname = normalized_hostname.as_ref();
let hostname_b = hostname.as_bytes();
for (domain_rule, _, _, _, _) in &self.pre {
if domain_rule.matches(hostname_b) {
return true;
}
}
if let Some(ascii_hostname) = tree_hostname_to_ascii(hostname)
&& self
.tree
.domain_lookup(ascii_hostname.as_bytes(), false)
.is_some()
{
return true;
}
for (domain_rule, _, _, _, _) in &self.post {
if domain_rule.matches(hostname_b) {
return true;
}
}
false
}
}
fn select_tree_rule<'a>(
path_rules: &'a [(PathRule, MethodRule, HeaderRules, Route)],
path: &[u8],
method: &Method,
headers: &dyn RequestHeaders,
) -> Option<(&'a PathRule, &'a Route)> {
let mut predicated = TreeSelection::default();
let mut unpredicated = TreeSelection::default();
for (rule, method_rule, header_rules, route) in path_rules {
let class = if header_rules.is_empty() {
&mut unpredicated
} else {
&mut predicated
};
if class.exact {
continue;
}
let path_result = rule.matches(path);
if path_result == PathRuleResult::None {
continue;
}
let method_result = method_rule.matches(method);
if method_result == MethodRuleResult::None {
continue;
}
if !header_rules.is_empty() && !header_rules.matches(headers) {
continue;
}
class.offer(path_result, method_result, path.len(), rule, route);
if predicated.exact {
break;
}
}
debug_assert!(
!predicated.exact || predicated.matched.is_some(),
"an exact selection must carry its rule",
);
predicated.matched.or(unpredicated.matched)
}
#[derive(Default)]
struct TreeSelection<'a> {
prefix_length: usize,
matched: Option<(&'a PathRule, &'a Route)>,
exact: bool,
}
impl<'a> TreeSelection<'a> {
fn offer(
&mut self,
path_result: PathRuleResult,
method_result: MethodRuleResult,
path_len: usize,
rule: &'a PathRule,
route: &'a Route,
) {
debug_assert!(!self.exact, "a decided class must not take another rule");
debug_assert!(
path_result != PathRuleResult::None && method_result != MethodRuleResult::None,
"only a rule that matches path and method is offered",
);
match path_result {
PathRuleResult::Regex | PathRuleResult::Equals => match method_result {
MethodRuleResult::Equals => {
self.matched = Some((rule, route));
self.exact = true;
}
MethodRuleResult::All => {
self.prefix_length = path_len;
self.matched = Some((rule, route));
}
MethodRuleResult::None => {}
},
PathRuleResult::Prefix(size) => {
if size >= self.prefix_length {
debug_assert!(
size >= self.prefix_length,
"longest-prefix selection must never shrink the match length",
);
self.prefix_length = size;
self.matched = Some((rule, route));
}
}
PathRuleResult::None => {}
}
}
}
fn select_listed_rule<'a>(
rules: &'a [(DomainRule, PathRule, MethodRule, HeaderRules, Route)],
hostname: &[u8],
path: &[u8],
method: &Method,
headers: &dyn RequestHeaders,
) -> Option<&'a (DomainRule, PathRule, MethodRule, HeaderRules, Route)> {
let mut unpredicated = None;
for entry in rules {
let (domain_rule, path_rule, method_rule, header_rules, _) = entry;
if unpredicated.is_some() && header_rules.is_empty() {
continue;
}
if !(domain_rule.matches(hostname)
&& path_rule.matches(path) != PathRuleResult::None
&& method_rule.matches(method) != MethodRuleResult::None)
{
continue;
}
if header_rules.is_empty() {
unpredicated = Some(entry);
} else if header_rules.matches(headers) {
return Some(entry);
}
}
debug_assert!(
unpredicated.is_none_or(|(_, _, _, header_rules, _)| header_rules.is_empty()),
"the fallback of a listed selection must be a predicate-free rule",
);
unpredicated
}
pub trait RequestHeaders {
fn header(&self, name: &[u8]) -> Option<&[u8]>;
}
pub struct NoHeaders;
impl RequestHeaders for NoHeaders {
fn header(&self, _name: &[u8]) -> Option<&[u8]> {
None
}
}
#[derive(Clone, Debug)]
enum HeaderMatch {
Exact(Vec<u8>),
Regex(Regex),
Present,
}
impl PartialEq for HeaderMatch {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(HeaderMatch::Exact(a), HeaderMatch::Exact(b)) => a == b,
(HeaderMatch::Regex(a), HeaderMatch::Regex(b)) => a.as_str() == b.as_str(),
(HeaderMatch::Present, HeaderMatch::Present) => true,
_ => false,
}
}
}
#[derive(Clone, Debug, PartialEq)]
struct HeaderRule {
name: Vec<u8>,
matcher: HeaderMatch,
}
impl HeaderRule {
fn matches(&self, headers: &dyn RequestHeaders) -> bool {
let Some(value) = headers.header(&self.name) else {
return false;
};
match &self.matcher {
HeaderMatch::Exact(expected) => value == expected.as_slice(),
HeaderMatch::Regex(regex) => {
let start = Instant::now();
let is_a_match = regex.is_match(value);
time!(
names::event_loop::REGEX_MATCHING_TIME,
start.elapsed().as_millis()
);
is_a_match
}
HeaderMatch::Present => true,
}
}
fn sort_key(&self) -> (&[u8], u8, &[u8]) {
match &self.matcher {
HeaderMatch::Exact(value) => (&self.name, 0, value),
HeaderMatch::Regex(regex) => (&self.name, 1, regex.as_str().as_bytes()),
HeaderMatch::Present => (&self.name, 2, b""),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HeaderRules(Vec<HeaderRule>);
impl HeaderRules {
pub fn from_config(predicates: &[HeaderPredicate]) -> Result<Self, RouterError> {
let mut rules = Vec::with_capacity(predicates.len());
for (index, predicate) in predicates.iter().enumerate() {
validate_header_predicate(predicate)
.map_err(|reason| RouterError::InvalidHeaderPredicate { index, reason })?;
let value = predicate.value.as_deref().unwrap_or_default();
let matcher = match HeaderMatchKind::try_from(predicate.kind) {
Ok(HeaderMatchKind::Exact) => HeaderMatch::Exact(value.as_bytes().to_owned()),
Ok(HeaderMatchKind::Regex) => HeaderMatch::Regex(anchored_regex(value).ok_or(
RouterError::InvalidHeaderPredicate {
index,
reason: "the regex value does not compile",
},
)?),
Ok(HeaderMatchKind::Present) => HeaderMatch::Present,
Err(_) => {
return Err(RouterError::InvalidHeaderPredicate {
index,
reason: "unknown match kind",
});
}
};
rules.push(HeaderRule {
name: predicate.name.as_bytes().to_ascii_lowercase(),
matcher,
});
}
rules.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
rules.dedup();
debug_assert!(
rules.len() <= predicates.len(),
"compiling predicates never adds a rule",
);
debug_assert!(
rules.is_empty() == predicates.is_empty(),
"a frontend has predicates exactly when its rule does",
);
Ok(HeaderRules(rules))
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn matches(&self, headers: &dyn RequestHeaders) -> bool {
self.0.iter().all(|rule| rule.matches(headers))
}
}
fn normalize_hostname(hostname: &str) -> Cow<'_, str> {
if hostname.as_bytes().iter().any(u8::is_ascii_uppercase) {
Cow::Owned(hostname.to_ascii_lowercase())
} else {
Cow::Borrowed(hostname)
}
}
fn tree_hostname_to_ascii(hostname: &str) -> Option<String> {
fn split_labels(hostname: &str) -> Option<String> {
let mut result = String::with_capacity(hostname.len());
let s = hostname.as_bytes();
let mut index = 0;
loop {
if index >= s.len() {
return None;
}
if s[index] == b'/' {
let close = (index + 1..s.len()).find(|&i| s[i] == b'/')?;
result.push_str(std::str::from_utf8(&s[index..=close]).ok()?);
index = close + 1;
} else {
let end = (index..s.len()).find(|&i| s[i] == b'.').unwrap_or(s.len());
let label = std::str::from_utf8(&s[index..end]).ok()?;
result.push_str(&::idna::domain_to_ascii(label).ok()?);
index = end;
}
if index == s.len() {
return Some(result);
}
if s[index] != b'.' {
return None;
}
result.push('.');
index += 1;
}
}
if hostname.contains('/')
&& let Some(normalized) = split_labels(hostname)
{
return Some(normalized);
}
::idna::domain_to_ascii(hostname).ok()
}
#[derive(Clone, Debug)]
pub enum DomainRule {
Any,
Exact(String),
Wildcard(String),
Regex(Regex),
}
fn convert_regex_domain_rule(hostname: &str) -> Option<String> {
let mut result = String::from("\\A");
let s = hostname.as_bytes();
let mut index = 0;
loop {
if index >= s.len() {
return None;
}
if s[index] == b'/' {
let mut found = false;
for i in index + 1..s.len() {
if s[i] == b'/' {
match std::str::from_utf8(&s[index + 1..i]) {
Ok(r) => {
Regex::new(r).ok()?;
result.push_str("(?:");
result.push_str(r);
result.push(')');
}
Err(_) => return None,
}
index = i + 1;
found = true;
break;
}
}
if !found {
return None;
}
} else {
let start = index;
for i in start..s.len() + 1 {
index = i;
if i < s.len() && s[i] == b'.' {
match std::str::from_utf8(&s[start..i]) {
Ok(r) => result.push_str(®ex::escape(r)),
Err(_) => return None,
}
break;
}
}
if index == s.len() {
match std::str::from_utf8(&s[start..]) {
Ok(r) => result.push_str(®ex::escape(r)),
Err(_) => return None,
}
}
}
if index == s.len() {
result.push_str("\\z");
return Some(result);
} else if s[index] == b'.' {
result.push_str("\\.");
index += 1;
} else {
return None;
}
}
}
impl DomainRule {
pub fn matches(&self, hostname: &[u8]) -> bool {
match self {
DomainRule::Any => true,
DomainRule::Wildcard(s) => {
debug_assert_eq!(
s.as_bytes().first(),
Some(&b'*'),
"a Wildcard rule must retain its leading '*'",
);
let suffix = &s.as_bytes()[1..];
let matched = hostname
.strip_suffix(suffix)
.is_some_and(|prefix| !prefix.is_empty() && !prefix.contains(&b'.'));
debug_assert!(
!matched || hostname.len() > suffix.len(),
"a wildcard match requires a non-empty leftmost label before the suffix",
);
matched
}
DomainRule::Exact(s) => s.as_bytes() == hostname,
DomainRule::Regex(r) => {
let start = Instant::now();
let is_a_match = r.is_match(hostname);
let now = Instant::now();
time!(
names::event_loop::REGEX_MATCHING_TIME,
(now - start).as_millis()
);
is_a_match
}
}
}
}
impl std::cmp::PartialEq for DomainRule {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(DomainRule::Any, DomainRule::Any) => true,
(DomainRule::Wildcard(s1), DomainRule::Wildcard(s2)) => s1 == s2,
(DomainRule::Exact(s1), DomainRule::Exact(s2)) => s1 == s2,
(DomainRule::Regex(r1), DomainRule::Regex(r2)) => r1.as_str() == r2.as_str(),
_ => false,
}
}
}
impl std::str::FromStr for DomainRule {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(if s == "*" {
DomainRule::Any
} else if s.contains('/') {
match convert_regex_domain_rule(s) {
Some(s) => match regex::bytes::RegexBuilder::new(&s)
.case_insensitive(true)
.build()
{
Ok(r) => DomainRule::Regex(r),
Err(_) => return Err(()),
},
None => return Err(()),
}
} else if s.contains('*') {
if s.starts_with('*') {
match ::idna::domain_to_ascii(s) {
Ok(r) => DomainRule::Wildcard(r),
Err(_) => return Err(()),
}
} else {
return Err(());
}
} else {
match ::idna::domain_to_ascii(s) {
Ok(r) => DomainRule::Exact(r),
Err(_) => return Err(()),
}
})
}
}
#[derive(Clone, Debug)]
pub enum PathRule {
Prefix(String),
Regex(Regex),
Equals(String),
RegexPath(Regex),
}
fn split_query(target: &[u8]) -> (&[u8], &[u8]) {
let (path, query) = match target.iter().position(|&b| b == b'?') {
Some(at) => target.split_at(at),
None => (target, &target[target.len()..]),
};
debug_assert_eq!(
path.len() + query.len(),
target.len(),
"the path and query halves must cover the request-target exactly",
);
debug_assert!(
!path.contains(&b'?') && (query.is_empty() || query[0] == b'?'),
"the path half holds no `?` and a non-empty query half starts with one",
);
(path, query)
}
#[derive(PartialEq, Eq)]
pub enum PathRuleResult {
Regex,
Prefix(usize),
Equals,
None,
}
impl PathRule {
pub fn matches(&self, path: &[u8]) -> PathRuleResult {
match self {
PathRule::Prefix(prefix) => {
if path.starts_with(prefix.as_bytes()) {
debug_assert!(
prefix.len() <= path.len(),
"a matching prefix cannot be longer than the path it matched",
);
PathRuleResult::Prefix(prefix.len())
} else {
PathRuleResult::None
}
}
PathRule::Regex(regex) => {
let start = Instant::now();
let is_a_match = regex.is_match(path);
let now = Instant::now();
time!(
names::event_loop::REGEX_MATCHING_TIME,
(now - start).as_millis()
);
if is_a_match {
PathRuleResult::Regex
} else {
PathRuleResult::None
}
}
PathRule::RegexPath(regex) => {
let (path_only, _) = split_query(path);
let start = Instant::now();
let is_a_match = regex.is_match(path_only);
let now = Instant::now();
time!(
names::event_loop::REGEX_MATCHING_TIME,
(now - start).as_millis()
);
if is_a_match {
PathRuleResult::Regex
} else {
PathRuleResult::None
}
}
PathRule::Equals(pattern) => {
if path == pattern.as_bytes() {
PathRuleResult::Equals
} else {
PathRuleResult::None
}
}
}
}
fn anchored_regex(value: &str) -> Option<Regex> {
anchored_regex(value)
}
pub fn from_config(rule: CommandPathRule) -> Option<Self> {
match PathRuleKind::try_from(rule.kind) {
Ok(PathRuleKind::Prefix) => Some(PathRule::Prefix(rule.value)),
Ok(PathRuleKind::Regex) => Self::anchored_regex(&rule.value).map(PathRule::Regex),
Ok(PathRuleKind::Equals) => Some(PathRule::Equals(rule.value)),
Ok(PathRuleKind::RegexPath) => {
Self::anchored_regex(&rule.value).map(PathRule::RegexPath)
}
Err(_) => None,
}
}
}
fn anchored_regex(value: &str) -> Option<Regex> {
Regex::new(value).ok()?;
Regex::new(&format!("\\A(?:{value})\\z")).ok()
}
impl std::cmp::PartialEq for PathRule {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(PathRule::Prefix(s1), PathRule::Prefix(s2)) => s1 == s2,
(PathRule::Regex(r1), PathRule::Regex(r2)) => r1.as_str() == r2.as_str(),
(PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2,
(PathRule::RegexPath(r1), PathRule::RegexPath(r2)) => r1.as_str() == r2.as_str(),
_ => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MethodRule {
pub inner: Option<Method>,
}
#[derive(PartialEq, Eq)]
pub enum MethodRuleResult {
All,
Equals,
None,
}
impl MethodRule {
pub fn new(method: Option<String>) -> Self {
MethodRule {
inner: method.map(|s| Method::new(s.as_bytes())),
}
}
pub fn matches(&self, method: &Method) -> MethodRuleResult {
match self.inner {
None => MethodRuleResult::All,
Some(ref m) => {
if method == m {
MethodRuleResult::Equals
} else {
MethodRuleResult::None
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Route {
Deny,
ClusterId(ClusterId),
Frontend(Rc<Frontend>),
}
fn build_listener_hsts_edit(new_hsts: Option<&HstsConfig>) -> Option<HeaderEdit> {
let cfg = new_hsts?;
if !matches!(cfg.enabled, Some(true)) {
return None;
}
let rendered = render_hsts(cfg)?;
let mode = if matches!(cfg.force_replace_backend, Some(true)) {
HeaderEditMode::Set
} else {
HeaderEditMode::SetIfAbsent
};
Some(HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: rendered.into_bytes().into(),
mode,
})
}
fn rebuild_with_listener_hsts(frontend: &Frontend, new_edit: Option<&HeaderEdit>) -> Frontend {
let mut headers_response: Vec<HeaderEdit> = frontend
.headers_response
.iter()
.filter(|edit| !edit.key.eq_ignore_ascii_case(b"strict-transport-security"))
.cloned()
.collect();
if let Some(edit) = new_edit {
headers_response.push(edit.clone());
}
Frontend {
headers_response: headers_response.into(),
..frontend.clone()
}
}
pub fn render_hsts(cfg: &HstsConfig) -> Option<String> {
let max_age = cfg.max_age?;
let mut s = format!("max-age={max_age}");
if matches!(cfg.include_subdomains, Some(true)) {
s.push_str("; includeSubDomains");
}
if matches!(cfg.preload, Some(true)) {
s.push_str("; preload");
}
Some(s)
}
#[derive(Clone, PartialEq, Eq)]
pub struct HeaderEdit {
pub key: Rc<[u8]>,
pub val: Rc<[u8]>,
pub mode: HeaderEditMode,
}
impl Debug for HeaderEdit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_fmt(format_args!(
"({:?}, {:?}, {:?})",
String::from_utf8_lossy(&self.key),
String::from_utf8_lossy(&self.val),
self.mode,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RewritePart {
String(String),
Host(usize),
Path(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RewriteParts(Vec<RewritePart>);
impl RewriteParts {
pub fn parse(
template: &str,
host_cap_cap: usize,
path_cap_cap: usize,
used_index_host: &mut usize,
used_index_path: &mut usize,
) -> Option<Self> {
let mut result = Vec::new();
let mut i = 0;
let pattern = template.as_bytes();
while i < pattern.len() {
if pattern[i] == b'$' {
let is_host = if pattern[i..].starts_with(b"$HOST[") {
i += 6;
true
} else if pattern[i..].starts_with(b"$PATH[") {
i += 6;
false
} else {
return None;
};
let mut index = 0usize;
let digits_start = i;
while i < pattern.len() && pattern[i].is_ascii_digit() {
index = index
.checked_mul(10)?
.checked_add((pattern[i] - b'0') as usize)?;
i += 1;
}
if i == digits_start {
return None;
}
if i >= pattern.len() || pattern[i] != b']' {
return None;
}
if is_host {
if index >= host_cap_cap {
return None;
}
if index >= *used_index_host {
*used_index_host = index + 1;
}
result.push(RewritePart::Host(index));
} else {
if index >= path_cap_cap {
return None;
}
if index >= *used_index_path {
*used_index_path = index + 1;
}
result.push(RewritePart::Path(index));
}
i += 1; } else {
let start = i;
while i < pattern.len() && pattern[i] != b'$' {
i += 1;
}
result.push(RewritePart::String(template[start..i].to_owned()));
}
}
debug_assert!(
result.iter().all(|part| match part {
RewritePart::Host(idx) => *idx < host_cap_cap,
RewritePart::Path(idx) => *idx < path_cap_cap,
RewritePart::String(_) => true,
}),
"a parsed rewrite template must only reference captures within the rule's caps",
);
debug_assert!(
*used_index_host <= host_cap_cap && *used_index_path <= path_cap_cap,
"the highest referenced capture index cannot exceed the cap",
);
Some(Self(result))
}
pub fn run(&self, host_captures: &[&str], path_captures: &[&str]) -> String {
let mut cap = 0usize;
for part in &self.0 {
cap += match part {
RewritePart::String(s) => s.len(),
RewritePart::Host(i) => host_captures.get(*i).map(|s| s.len()).unwrap_or(0),
RewritePart::Path(i) => path_captures.get(*i).map(|s| s.len()).unwrap_or(0),
};
}
let mut result = String::with_capacity(cap);
for part in &self.0 {
let _ = match part {
RewritePart::String(s) => result.write_str(s),
RewritePart::Host(i) => result.write_str(host_captures.get(*i).unwrap_or(&"")),
RewritePart::Path(i) => result.write_str(path_captures.get(*i).unwrap_or(&"")),
};
}
debug_assert_eq!(
result.len(),
cap,
"rewrite output length must equal the pre-computed one-pass capacity",
);
result
}
}
#[derive(Debug, Clone)]
pub struct Frontend {
pub cluster_id: Option<ClusterId>,
pub redirect: RedirectPolicy,
pub redirect_scheme: RedirectScheme,
pub redirect_template: Option<String>,
pub capture_cap_host: usize,
pub capture_cap_path: usize,
pub rewrite_host: Option<RewriteParts>,
pub rewrite_path: Option<RewriteParts>,
pub rewrite_port: Option<u16>,
pub headers_request: Rc<[HeaderEdit]>,
pub headers_response: Rc<[HeaderEdit]>,
pub required_auth: bool,
pub tags: Option<Rc<CachedTags>>,
pub inherits_listener_hsts: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum HstsOrigin {
Explicit,
InheritedFromListenerDefault,
}
impl PartialEq for Frontend {
fn eq(&self, other: &Self) -> bool {
self.cluster_id == other.cluster_id
&& self.redirect == other.redirect
&& self.redirect_scheme == other.redirect_scheme
&& self.redirect_template == other.redirect_template
&& self.rewrite_host == other.rewrite_host
&& self.rewrite_path == other.rewrite_path
&& self.rewrite_port == other.rewrite_port
&& self.headers_request == other.headers_request
&& self.headers_response == other.headers_response
&& self.required_auth == other.required_auth
}
}
impl Eq for Frontend {}
impl std::hash::Hash for Frontend {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.cluster_id.hash(state);
(self.redirect as i32).hash(state);
(self.redirect_scheme as i32).hash(state);
self.redirect_template.hash(state);
self.required_auth.hash(state);
}
}
impl PartialOrd for Frontend {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Frontend {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.cluster_id
.cmp(&other.cluster_id)
.then_with(|| (self.redirect as i32).cmp(&(other.redirect as i32)))
.then_with(|| (self.redirect_scheme as i32).cmp(&(other.redirect_scheme as i32)))
.then_with(|| self.redirect_template.cmp(&other.redirect_template))
.then_with(|| self.required_auth.cmp(&other.required_auth))
}
}
impl Frontend {
#[allow(clippy::too_many_arguments)]
pub fn new(
domain_rule: &DomainRule,
path_rule: &PathRule,
front: &HttpFrontend,
redirect: RedirectPolicy,
redirect_scheme: RedirectScheme,
redirect_template: Option<String>,
rewrite_host: Option<String>,
rewrite_path: Option<String>,
rewrite_port: Option<u16>,
headers: &[sozu_command::proto::command::Header],
required_auth: bool,
hsts_origin: HstsOrigin,
) -> Result<Self, RouterError> {
let hsts = front.hsts.as_ref();
let inherits_listener_hsts =
matches!(hsts_origin, HstsOrigin::InheritedFromListenerDefault);
let cluster_id = front.cluster_id.clone();
let tags = front
.tags
.clone()
.map(|tags| Rc::new(CachedTags::new(tags)));
let redirect_template = redirect_template.filter(|s| !s.is_empty());
let rewrite_host = rewrite_host.filter(|s| !s.is_empty());
let rewrite_path = rewrite_path.filter(|s| !s.is_empty());
let deny = match (&cluster_id, redirect) {
(_, RedirectPolicy::Unauthorized) => true,
(None, RedirectPolicy::Forward) => {
let (domain_kind, domain_bytes) = match &domain_rule {
DomainRule::Any => ("any", 0),
DomainRule::Exact(value) => ("exact", value.len()),
DomainRule::Wildcard(value) => ("wildcard", value.len()),
DomainRule::Regex(value) => ("regex", value.as_str().len()),
};
let (path_kind, path_bytes) = match &path_rule {
PathRule::Prefix(value) => ("prefix", value.len()),
PathRule::Regex(value) => ("regex", value.as_str().len()),
PathRule::Equals(value) => ("equals", value.len()),
PathRule::RegexPath(value) => ("regex_path", value.as_str().len()),
};
warn!(
"{} Frontend[domain_kind={}, domain_bytes={}, path_kind={}, path_bytes={}]: forward on clusterless frontends are unauthorized",
log_module_context!(),
domain_kind,
domain_bytes,
path_kind,
path_bytes,
);
true
}
_ => false,
};
if deny {
let mut deny_headers_response: Vec<HeaderEdit> = Vec::new();
if let Some(cfg) = hsts
&& matches!(cfg.enabled, Some(true))
&& let Some(rendered) = render_hsts(cfg)
{
let mode = if matches!(cfg.force_replace_backend, Some(true)) {
HeaderEditMode::Set
} else {
HeaderEditMode::SetIfAbsent
};
deny_headers_response.push(HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: rendered.into_bytes().into(),
mode,
});
crate::incr!(names::http::HSTS_FRONTEND_ADDED);
}
return Ok(Self {
cluster_id,
redirect: RedirectPolicy::Unauthorized,
redirect_scheme,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: deny_headers_response.into(),
required_auth,
tags,
inherits_listener_hsts,
});
}
let mut capture_cap_host = match domain_rule {
DomainRule::Any => 1,
DomainRule::Exact(_) => 1,
DomainRule::Wildcard(_) => 2,
DomainRule::Regex(regex) => regex.captures_len(),
};
let mut capture_cap_path = match path_rule {
PathRule::Equals(_) => 1,
PathRule::Prefix(_) => 2,
PathRule::Regex(regex) | PathRule::RegexPath(regex) => regex.captures_len(),
};
let mut used_capture_host = 0usize;
let mut used_capture_path = 0usize;
let rewrite_host_parts = if let Some(p) = rewrite_host {
Some(
RewriteParts::parse(
&p,
capture_cap_host,
capture_cap_path,
&mut used_capture_host,
&mut used_capture_path,
)
.ok_or(RouterError::InvalidHostRewrite(p))?,
)
} else {
None
};
let rewrite_path_parts = if let Some(p) = rewrite_path {
Some(
RewriteParts::parse(
&p,
capture_cap_host,
capture_cap_path,
&mut used_capture_host,
&mut used_capture_path,
)
.ok_or(RouterError::InvalidPathRewrite(p))?,
)
} else {
None
};
if used_capture_host == 0 {
capture_cap_host = 0;
}
if used_capture_path == 0 {
capture_cap_path = 0;
}
let mut headers_request = Vec::new();
let mut headers_response = Vec::new();
for header in headers {
let edit = HeaderEdit {
key: header.key.as_bytes().into(),
val: header.val.as_bytes().into(),
mode: HeaderEditMode::Append,
};
match header.position() {
HeaderPosition::Request => headers_request.push(edit),
HeaderPosition::Response => headers_response.push(edit),
HeaderPosition::Both => {
headers_request.push(edit.clone());
headers_response.push(edit);
}
HeaderPosition::Unspecified => {
warn!(
"{} dropping {:?} with HEADER_POSITION_UNSPECIFIED",
log_module_context!(),
header,
);
}
}
}
if let Some(cfg) = hsts
&& matches!(cfg.enabled, Some(true))
{
if let Some(rendered) = render_hsts(cfg) {
let mode = if matches!(cfg.force_replace_backend, Some(true)) {
HeaderEditMode::Set
} else {
HeaderEditMode::SetIfAbsent
};
headers_response.push(HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: rendered.into_bytes().into(),
mode,
});
crate::incr!(names::http::HSTS_FRONTEND_ADDED);
} else {
warn!(
"{} HSTS enabled = true on frontend cluster_id_bytes={:?} but render_hsts \
returned None (max_age missing). Frontend will not emit \
Strict-Transport-Security; the config layer that built \
this HstsConfig must substitute DEFAULT_HSTS_MAX_AGE.",
log_module_context!(),
cluster_id.as_deref().map(str::len),
);
crate::incr!(names::http::HSTS_UNRENDERED);
}
}
Ok(Frontend {
cluster_id,
redirect,
redirect_scheme,
redirect_template,
capture_cap_host,
capture_cap_path,
rewrite_host: rewrite_host_parts,
rewrite_path: rewrite_path_parts,
rewrite_port,
headers_request: headers_request.into(),
headers_response: headers_response.into(),
required_auth,
tags,
inherits_listener_hsts,
})
}
pub(crate) fn minimal_forward(cluster_id: ClusterId) -> Self {
Self {
cluster_id: Some(cluster_id),
redirect: RedirectPolicy::Forward,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: Rc::new([]),
required_auth: false,
tags: None,
inherits_listener_hsts: true,
}
}
pub(crate) fn minimal_deny() -> Self {
Self {
cluster_id: None,
redirect: RedirectPolicy::Unauthorized,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: Rc::new([]),
required_auth: false,
tags: None,
inherits_listener_hsts: true,
}
}
}
fn append_original_query(mut rewritten: String, original_query: &[u8]) -> String {
debug_assert!(
original_query.is_empty() || original_query[0] == b'?',
"a non-empty original query starts with its `?`",
);
let had_own_query = rewritten.contains('?');
if !original_query.is_empty() && !had_own_query {
rewritten.push_str(from_utf8(original_query).unwrap_or_default());
}
debug_assert!(
had_own_query || rewritten.ends_with(from_utf8(original_query).unwrap_or_default()),
"absent a query of its own, the rewritten path must end with the original query",
);
rewritten
}
#[derive(Debug, Clone, PartialEq)]
pub struct RouteResult {
pub cluster_id: Option<ClusterId>,
pub redirect: RedirectPolicy,
pub redirect_scheme: RedirectScheme,
pub redirect_template: Option<String>,
pub rewritten_host: Option<String>,
pub rewritten_path: Option<String>,
pub rewritten_port: Option<u16>,
pub headers_request: Rc<[HeaderEdit]>,
pub headers_response: Rc<[HeaderEdit]>,
pub required_auth: bool,
pub tags: Option<Rc<CachedTags>>,
}
impl RouteResult {
pub fn deny(cluster_id: Option<ClusterId>) -> Self {
Self {
cluster_id,
redirect: RedirectPolicy::Unauthorized,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
rewritten_host: None,
rewritten_path: None,
rewritten_port: None,
headers_request: Rc::new([]),
headers_response: Rc::new([]),
required_auth: false,
tags: None,
}
}
pub fn forward(cluster_id: ClusterId) -> Self {
Self {
cluster_id: Some(cluster_id),
redirect: RedirectPolicy::Forward,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
rewritten_host: None,
rewritten_path: None,
rewritten_port: None,
headers_request: Rc::new([]),
headers_response: Rc::new([]),
required_auth: false,
tags: None,
}
}
fn from_frontend(
frontend: &Frontend,
captures_host: Vec<&str>,
path: &[u8],
path_rule: &PathRule,
) -> Self {
if frontend.redirect == RedirectPolicy::Unauthorized {
return Self {
cluster_id: frontend.cluster_id.clone(),
redirect: RedirectPolicy::Unauthorized,
redirect_scheme: frontend.redirect_scheme,
redirect_template: frontend.redirect_template.clone(),
rewritten_host: None,
rewritten_path: None,
rewritten_port: None,
headers_request: Rc::new([]),
headers_response: frontend.headers_response.clone(),
required_auth: frontend.required_auth,
tags: frontend.tags.clone(),
};
}
let (path, original_query) = match path_rule {
PathRule::RegexPath(_) => split_query(path),
_ => (path, &path[path.len()..]),
};
let mut captures_path: Vec<&str> = Vec::with_capacity(frontend.capture_cap_path);
if frontend.capture_cap_path > 0 {
captures_path.push(from_utf8(path).unwrap_or_default());
match path_rule {
PathRule::Prefix(prefix) => {
let tail_start = prefix.len().min(path.len());
captures_path.push(from_utf8(&path[tail_start..]).unwrap_or_default());
}
PathRule::Regex(regex) | PathRule::RegexPath(regex) => {
if let Some(caps) = regex.captures(path) {
captures_path.extend(caps.iter().skip(1).map(|c| {
c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
.unwrap_or("")
}));
}
}
PathRule::Equals(_) => {}
}
}
Self {
cluster_id: frontend.cluster_id.clone(),
redirect: frontend.redirect,
redirect_scheme: frontend.redirect_scheme,
redirect_template: frontend.redirect_template.clone(),
rewritten_host: frontend
.rewrite_host
.as_ref()
.map(|rewrite| rewrite.run(&captures_host, &captures_path)),
rewritten_path: frontend.rewrite_path.as_ref().map(|rewrite| {
append_original_query(rewrite.run(&captures_host, &captures_path), original_query)
}),
rewritten_port: frontend.rewrite_port,
headers_request: frontend.headers_request.clone(),
headers_response: frontend.headers_response.clone(),
required_auth: frontend.required_auth,
tags: frontend.tags.clone(),
}
}
fn new_no_trie<'a>(
domain: &'a [u8],
domain_rule: &DomainRule,
path: &'a [u8],
path_rule: &PathRule,
route: &Route,
) -> Self {
let frontend = match route {
Route::Frontend(f) => f.clone(),
Route::ClusterId(id) => return Self::forward(id.clone()),
Route::Deny => return Self::deny(None),
};
let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
if frontend.capture_cap_host > 0 {
captures_host.push(from_utf8(domain).unwrap_or_default());
match domain_rule {
DomainRule::Wildcard(suffix) => {
let head_end = domain.len().saturating_sub(suffix.len().saturating_sub(1));
captures_host.push(from_utf8(&domain[..head_end]).unwrap_or_default());
}
DomainRule::Regex(regex) => {
if let Some(caps) = regex.captures(domain) {
captures_host.extend(caps.iter().skip(1).map(|c| {
c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
.unwrap_or("")
}));
}
}
DomainRule::Any | DomainRule::Exact(_) => {}
}
}
Self::from_frontend(&frontend, captures_host, path, path_rule)
}
fn new_with_trie<'a, 'b>(
domain: &'a [u8],
domain_submatches: &InlineTrieMatches<'a, 'b>,
path: &'a [u8],
path_rule: &PathRule,
route: &Route,
) -> Self {
let frontend = match route {
Route::Frontend(f) => f.clone(),
Route::ClusterId(id) => return Self::forward(id.clone()),
Route::Deny => return Self::deny(None),
};
let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
if frontend.capture_cap_host > 0 {
captures_host.push(from_utf8(domain).unwrap_or_default());
for submatch in domain_submatches.iter().rev() {
match submatch {
TrieSubMatch::Wildcard(part) => {
captures_host.push(from_utf8(part).unwrap_or_default());
}
TrieSubMatch::Regexp(part, regex) => {
if let Some(caps) = regex.captures(part) {
captures_host.extend(caps.iter().skip(1).map(|c| {
c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
.unwrap_or("")
}));
}
}
}
}
}
Self::from_frontend(&frontend, captures_host, path, path_rule)
}
}
#[cfg(test)]
mod tests {
use super::*;
use quickcheck::{Arbitrary, Gen, TestResult, quickcheck};
#[test]
fn method_rule_is_case_sensitive() {
let declared_lowercase = MethodRule::new(Some("get".to_owned()));
assert_eq!(
declared_lowercase.inner,
Some(Method::Custom("get".to_owned())),
"a lowercase declaration must not collapse onto Method::Get"
);
assert!(
declared_lowercase.matches(&Method::Get) == MethodRuleResult::None,
"a lowercase declaration must not match a canonical GET request"
);
assert!(
declared_lowercase.matches(&Method::new(b"get")) == MethodRuleResult::Equals,
"a lowercase declaration must match a literal lowercase request"
);
let declared_canonical = MethodRule::new(Some("GET".to_owned()));
assert!(
declared_canonical.matches(&Method::Get) == MethodRuleResult::Equals,
"a canonical declaration must still match a canonical GET request"
);
assert!(
declared_canonical.matches(&Method::new(b"get")) == MethodRuleResult::None,
"a canonical declaration must not match a lowercase request"
);
}
fn test_http_frontend() -> HttpFrontend {
HttpFrontend {
cluster_id: Some("cluster".into()),
address: "127.0.0.1:8080"
.parse()
.expect("test frontend address must parse"),
hostname: "example.com".to_owned(),
path: CommandPathRule::prefix("/".to_owned()),
method: None,
position: RulePosition::Tree,
tags: None,
redirect: None,
redirect_scheme: None,
redirect_template: None,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
required_auth: None,
headers: Vec::new(),
hsts: None,
header_predicates: Vec::new(),
}
}
#[test]
fn clusterless_forward_warning_redacts_domain_and_path_rules() {
const DOMAIN_SECRET: &str = "clusterless_domain_secret_sentinel";
const PATH_SECRET: &str = "CLUSTERLESS_PATH_SECRET_SENTINEL";
let domain = format!("{DOMAIN_SECRET}{}", "x".repeat(2048));
let path = format!("/{PATH_SECRET}{}", "x".repeat(4096));
let domain_len = domain.len();
let path_len = path.len();
let output = crate::capture_test_logs(move || {
let mut router = Router::new();
let mut front = test_http_frontend();
front.cluster_id = None;
front.hostname = domain;
front.path = CommandPathRule::prefix(path);
front.redirect = Some(RedirectPolicy::Forward as i32);
router
.add_http_front(&front)
.expect("clusterless forward must be coerced to unauthorized");
});
for secret in [DOMAIN_SECRET, PATH_SECRET] {
assert!(
!output.contains(secret),
"clusterless-forward warning leaked rule marker {secret}"
);
}
for metadata in [
"domain_kind=exact".to_owned(),
format!("domain_bytes={domain_len}"),
"path_kind=prefix".to_owned(),
format!("path_bytes={path_len}"),
] {
assert!(
output.contains(&metadata),
"clusterless-forward warning omitted bounded metadata {metadata}: {output}"
);
}
assert!(
output.len() <= 512,
"clusterless-forward warning is not bounded: {} bytes",
output.len()
);
}
#[test]
fn malformed_hsts_warning_redacts_cluster_id() {
const CLUSTER_SECRET: &str = "MALFORMED_HSTS_CLUSTER_SECRET_SENTINEL";
let cluster_id = format!("{CLUSTER_SECRET}{}", "x".repeat(4096));
let cluster_id_len = cluster_id.len();
let output = crate::capture_test_logs(move || {
let mut router = Router::new();
let mut front = test_http_frontend();
front.cluster_id = Some(cluster_id.into());
front.hsts = Some(HstsConfig {
enabled: Some(true),
max_age: None,
include_subdomains: Some(true),
preload: Some(true),
force_replace_backend: Some(false),
});
router
.add_http_front(&front)
.expect("malformed HSTS must preserve routing and omit the header");
});
assert!(
!output.contains(CLUSTER_SECRET),
"malformed-HSTS warning leaked cluster id {CLUSTER_SECRET}"
);
assert!(
output.contains(&format!("cluster_id_bytes=Some({cluster_id_len})")),
"malformed-HSTS warning omitted the bounded cluster id length: {output}"
);
assert!(
output.len() <= 768,
"malformed-HSTS warning is not bounded: {} bytes",
output.len()
);
}
#[test]
fn router_errors_redact_frontend_rule_and_rewrite_fields() {
const PATH_SECRET: &str = "ROUTER_ERROR_PATH_SECRET_SENTINEL";
const HOSTNAME_SECRET: &str = "ROUTER_ERROR_HOSTNAME_SECRET_SENTINEL";
const HOST_REWRITE_SECRET: &str = "ROUTER_ERROR_HOST_REWRITE_SECRET_SENTINEL";
const PATH_REWRITE_SECRET: &str = "ROUTER_ERROR_PATH_REWRITE_SECRET_SENTINEL";
let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
let cases = [
(
"path_bytes",
PATH_SECRET,
long_value(PATH_SECRET).len(),
RouterError::InvalidPathRule(long_value(PATH_SECRET)),
),
(
"hostname_bytes",
HOSTNAME_SECRET,
long_value(HOSTNAME_SECRET).len(),
RouterError::InvalidDomain {
hostname: long_value(HOSTNAME_SECRET),
},
),
(
"rewrite_host_bytes",
HOST_REWRITE_SECRET,
long_value(HOST_REWRITE_SECRET).len(),
RouterError::InvalidHostRewrite(long_value(HOST_REWRITE_SECRET)),
),
(
"rewrite_path_bytes",
PATH_REWRITE_SECRET,
long_value(PATH_REWRITE_SECRET).len(),
RouterError::InvalidPathRewrite(long_value(PATH_REWRITE_SECRET)),
),
];
for (length_label, secret, value_len, error) in cases {
for (format_label, output) in [
("Display", error.to_string()),
("Debug", format!("{error:?}")),
] {
assert!(
!output.contains(secret),
"RouterError {format_label} leaked frontend marker {secret}"
);
let metadata = format!("{length_label}={value_len}");
assert!(
output.contains(&metadata),
"RouterError {format_label} omitted bounded metadata {metadata}: {output}"
);
assert!(
output.len() <= 256,
"RouterError {format_label} output is not bounded: {} bytes",
output.len()
);
}
}
}
#[test]
fn route_miss_error_retains_inputs_but_bounds_textual_formatting() {
const HOST_SECRET: &str = "ROUTE_MISS_HOST_SECRET_SENTINEL";
const PATH_SECRET: &str = "ROUTE_MISS_PATH_SECRET_SENTINEL";
const METHOD_SECRET: &str = "ROUTE_MISS_METHOD_SECRET_SENTINEL";
let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
let host = long_value(HOST_SECRET);
let path = long_value(PATH_SECRET);
let method = Method::Custom(long_value(METHOD_SECRET));
let error = match Router::new().lookup(&host, &path, &method) {
Err(error) => error,
Ok(_) => panic!("empty router must return a route miss"),
};
match &error {
RouterError::RouteNotFound {
host: retained_host,
path: retained_path,
method: retained_method,
} => {
assert_eq!(retained_host, &host);
assert_eq!(retained_path, &path);
assert_eq!(retained_method, &method);
}
other => panic!("expected RouterError::RouteNotFound, got {other:?}"),
}
for output in [error.to_string(), format!("{error:?}")] {
for secret in [HOST_SECRET, PATH_SECRET, METHOD_SECRET] {
assert!(
!output.contains(secret),
"route miss formatting leaked {secret}: {output}"
);
}
for metadata in [
format!("host_bytes={}", host.len()),
format!("path_bytes={}", path.len()),
format!("bytes={}", method.as_ref().len()),
] {
assert!(
output.contains(&metadata),
"route miss formatting omitted {metadata}: {output}"
);
}
assert!(
output.len() <= 256,
"route miss formatting is not bounded: {} bytes",
output.len()
);
}
}
#[test]
fn render_hsts_max_age_only() {
let cfg = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: None,
preload: None,
force_replace_backend: None,
};
assert_eq!(render_hsts(&cfg), Some("max-age=31536000".to_owned()));
}
#[test]
fn render_hsts_with_include_subdomains() {
let cfg = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
assert_eq!(
render_hsts(&cfg),
Some("max-age=31536000; includeSubDomains".to_owned())
);
}
#[test]
fn render_hsts_with_preload_only() {
let cfg = HstsConfig {
enabled: Some(true),
max_age: Some(63_072_000),
include_subdomains: None,
preload: Some(true),
force_replace_backend: None,
};
assert_eq!(
render_hsts(&cfg),
Some("max-age=63072000; preload".to_owned())
);
}
#[test]
fn render_hsts_full() {
let cfg = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: Some(true),
preload: Some(true),
force_replace_backend: None,
};
assert_eq!(
render_hsts(&cfg),
Some("max-age=31536000; includeSubDomains; preload".to_owned())
);
}
#[test]
fn render_hsts_kill_switch_max_age_zero() {
let cfg = HstsConfig {
enabled: Some(true),
max_age: Some(0),
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
assert_eq!(
render_hsts(&cfg),
Some("max-age=0; includeSubDomains".to_owned())
);
}
#[test]
fn render_hsts_omitted_when_max_age_missing() {
let cfg = HstsConfig {
enabled: Some(true),
max_age: None,
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
assert_eq!(render_hsts(&cfg), None);
}
#[test]
fn rebuild_with_listener_hsts_replaces_existing_entry() {
let frontend = Frontend {
cluster_id: Some("api".into()),
redirect: RedirectPolicy::Forward,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: Rc::from(vec![
HeaderEdit {
key: Rc::from(&b"x-cache"[..]),
val: Rc::from(&b"hit"[..]),
mode: HeaderEditMode::Append,
},
HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: Rc::from(&b"max-age=31536000"[..]),
mode: HeaderEditMode::SetIfAbsent,
},
]),
required_auth: false,
tags: None,
inherits_listener_hsts: true,
};
let new_hsts = HstsConfig {
enabled: Some(true),
max_age: Some(63_072_000),
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
let new_edit = build_listener_hsts_edit(Some(&new_hsts));
let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
let response: Vec<_> = rebuilt.headers_response.iter().collect();
assert_eq!(response.len(), 2, "x-cache + new STS, no leftover STS");
assert_eq!(&*response[0].key, b"x-cache");
assert_eq!(&*response[1].key, b"strict-transport-security");
assert_eq!(
&*response[1].val,
b"max-age=63072000; includeSubDomains".as_slice()
);
assert!(rebuilt.inherits_listener_hsts);
}
#[test]
fn rebuild_with_listener_hsts_strips_when_none() {
let frontend = Frontend {
cluster_id: Some("api".into()),
redirect: RedirectPolicy::Forward,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: Rc::from(vec![
HeaderEdit {
key: Rc::from(&b"x-cache"[..]),
val: Rc::from(&b"hit"[..]),
mode: HeaderEditMode::Append,
},
HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: Rc::from(&b"max-age=31536000"[..]),
mode: HeaderEditMode::SetIfAbsent,
},
]),
required_auth: false,
tags: None,
inherits_listener_hsts: true,
};
let new_edit = build_listener_hsts_edit(None);
let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
let response: Vec<_> = rebuilt.headers_response.iter().collect();
assert_eq!(response.len(), 1);
assert_eq!(&*response[0].key, b"x-cache");
}
#[test]
fn rebuild_with_listener_hsts_disabled_strips() {
let frontend = Frontend {
cluster_id: Some("api".into()),
redirect: RedirectPolicy::Forward,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: Rc::from(vec![HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: Rc::from(&b"max-age=31536000"[..]),
mode: HeaderEditMode::SetIfAbsent,
}]),
required_auth: false,
tags: None,
inherits_listener_hsts: true,
};
let new_hsts = HstsConfig {
enabled: Some(false),
max_age: None,
include_subdomains: None,
preload: None,
force_replace_backend: None,
};
let new_edit = build_listener_hsts_edit(Some(&new_hsts));
let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
assert_eq!(rebuilt.headers_response.len(), 0);
}
#[test]
fn refresh_inheriting_hsts_skips_explicit_overrides() {
use crate::router::pattern_trie::TrieNode;
let mut router = Router {
pre: Vec::new(),
tree: TrieNode::root(),
post: Vec::new(),
};
let inheriting = Frontend {
cluster_id: Some("api".into()),
redirect: RedirectPolicy::Forward,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: Rc::from(vec![HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: Rc::from(&b"max-age=31536000"[..]),
mode: HeaderEditMode::SetIfAbsent,
}]),
required_auth: false,
tags: None,
inherits_listener_hsts: true,
};
let explicit = Frontend {
cluster_id: Some("legacy".into()),
redirect: RedirectPolicy::Forward,
redirect_scheme: RedirectScheme::UseSame,
redirect_template: None,
capture_cap_host: 0,
capture_cap_path: 0,
rewrite_host: None,
rewrite_path: None,
rewrite_port: None,
headers_request: Rc::new([]),
headers_response: Rc::from(vec![HeaderEdit {
key: Rc::from(&b"strict-transport-security"[..]),
val: Rc::from(&b"max-age=300"[..]),
mode: HeaderEditMode::SetIfAbsent,
}]),
required_auth: false,
tags: None,
inherits_listener_hsts: false,
};
router.pre.push((
DomainRule::Any,
PathRule::Prefix("/api".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::Frontend(Rc::new(inheriting)),
));
router.post.push((
DomainRule::Any,
PathRule::Prefix("/legacy".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::Frontend(Rc::new(explicit)),
));
let new_hsts = HstsConfig {
enabled: Some(true),
max_age: Some(63_072_000),
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
let count = router.refresh_inheriting_hsts(Some(&new_hsts));
assert_eq!(count, 1, "only the inheriting frontend should refresh");
if let Route::Frontend(rc) = &router.pre[0].4 {
let response: Vec<_> = rc.headers_response.iter().collect();
assert_eq!(
&*response.last().unwrap().val,
b"max-age=63072000; includeSubDomains".as_slice(),
"inheriting frontend's STS must reflect the new listener default"
);
} else {
panic!("pre[0] should be Route::Frontend");
}
if let Route::Frontend(rc) = &router.post[0].4 {
let response: Vec<_> = rc.headers_response.iter().collect();
assert_eq!(
&*response.last().unwrap().val,
b"max-age=300".as_slice(),
"explicit override must be preserved unchanged"
);
} else {
panic!("post[0] should be Route::Frontend");
}
}
#[test]
fn refresh_inheriting_hsts_promotes_clusterid_on_enable() {
use crate::router::pattern_trie::TrieNode;
let mut router = Router {
pre: Vec::new(),
tree: TrieNode::root(),
post: vec![(
DomainRule::Any,
PathRule::Prefix("/".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::ClusterId("api".into()),
)],
};
let new_hsts = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
let count = router.refresh_inheriting_hsts(Some(&new_hsts));
assert_eq!(count, 1, "the ClusterId entry must be promoted + counted");
let Route::Frontend(rc) = &router.post[0].4 else {
panic!("post[0] should now be Route::Frontend, not the original Route::ClusterId");
};
assert_eq!(rc.cluster_id.as_deref(), Some("api"));
assert_eq!(
rc.redirect,
RedirectPolicy::Forward,
"promoted entry must keep Forward semantics so lookup yields the same backend"
);
assert!(
rc.inherits_listener_hsts,
"promoted entry must mark itself inheriting so the next patch refreshes it"
);
let response: Vec<_> = rc.headers_response.iter().collect();
assert_eq!(
response.len(),
1,
"promoted entry carries exactly one STS edit, no operator headers"
);
assert_eq!(&*response[0].key, b"strict-transport-security");
assert_eq!(
&*response[0].val,
b"max-age=31536000; includeSubDomains".as_slice()
);
}
#[test]
fn refresh_inheriting_hsts_promotes_deny_on_enable() {
use crate::router::pattern_trie::TrieNode;
let mut router = Router {
pre: Vec::new(),
tree: TrieNode::root(),
post: vec![(
DomainRule::Any,
PathRule::Prefix("/forbidden".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::Deny,
)],
};
let new_hsts = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: None,
preload: None,
force_replace_backend: None,
};
let count = router.refresh_inheriting_hsts(Some(&new_hsts));
assert_eq!(count, 1);
let Route::Frontend(rc) = &router.post[0].4 else {
panic!("post[0] should now be Route::Frontend, not the original Route::Deny");
};
assert_eq!(rc.cluster_id, None, "promoted Deny stays clusterless");
assert_eq!(
rc.redirect,
RedirectPolicy::Unauthorized,
"promoted Deny must keep Unauthorized so lookup yields a 401"
);
assert!(rc.inherits_listener_hsts);
let response: Vec<_> = rc.headers_response.iter().collect();
assert_eq!(response.len(), 1);
assert_eq!(&*response[0].key, b"strict-transport-security");
assert_eq!(&*response[0].val, b"max-age=31536000".as_slice());
}
#[test]
fn refresh_inheriting_hsts_skips_lightweight_on_disable() {
use crate::router::pattern_trie::TrieNode;
let make_router = || Router {
pre: vec![(
DomainRule::Any,
PathRule::Prefix("/".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::ClusterId("api".into()),
)],
tree: TrieNode::root(),
post: vec![(
DomainRule::Any,
PathRule::Prefix("/forbidden".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::Deny,
)],
};
for (label, hsts) in [
("none", None),
(
"disabled",
Some(HstsConfig {
enabled: Some(false),
max_age: None,
include_subdomains: None,
preload: None,
force_replace_backend: None,
}),
),
(
"enabled-without-max-age",
Some(HstsConfig {
enabled: Some(true),
max_age: None,
include_subdomains: None,
preload: None,
force_replace_backend: None,
}),
),
] {
let mut router = make_router();
let count = router.refresh_inheriting_hsts(hsts.as_ref());
assert_eq!(count, 0, "no promotion expected for {label}");
assert!(
matches!(router.pre[0].4, Route::ClusterId(_)),
"{label}: ClusterId must stay lightweight"
);
assert!(
matches!(router.post[0].4, Route::Deny),
"{label}: Deny must stay lightweight"
);
}
}
#[test]
fn refresh_inheriting_hsts_promoted_entry_refreshes_on_subsequent_patches() {
use crate::router::pattern_trie::TrieNode;
let mut router = Router {
pre: Vec::new(),
tree: TrieNode::root(),
post: vec![(
DomainRule::Any,
PathRule::Prefix("/".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::ClusterId("api".into()),
)],
};
let first_patch = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: None,
preload: None,
force_replace_backend: None,
};
assert_eq!(router.refresh_inheriting_hsts(Some(&first_patch)), 1);
let second_patch = HstsConfig {
enabled: Some(true),
max_age: Some(63_072_000),
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
assert_eq!(
router.refresh_inheriting_hsts(Some(&second_patch)),
1,
"the previously promoted entry must be re-counted via the path-1 branch"
);
let Route::Frontend(rc) = &router.post[0].4 else {
panic!("post[0] should still be Route::Frontend after the second patch");
};
let response: Vec<_> = rc.headers_response.iter().collect();
assert_eq!(
response.len(),
1,
"second patch must REPLACE the existing STS edit, not append a duplicate"
);
assert_eq!(
&*response[0].val,
b"max-age=63072000; includeSubDomains".as_slice()
);
}
#[test]
fn refresh_inheriting_hsts_promoted_entry_loses_hsts_on_disable_patch() {
use crate::router::pattern_trie::TrieNode;
let mut router = Router {
pre: vec![(
DomainRule::Any,
PathRule::Prefix("/".to_owned()),
MethodRule::new(None),
HeaderRules::default(),
Route::ClusterId("api".into()),
)],
tree: TrieNode::root(),
post: Vec::new(),
};
let enable = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: None,
preload: None,
force_replace_backend: None,
};
assert_eq!(router.refresh_inheriting_hsts(Some(&enable)), 1);
let disable = HstsConfig {
enabled: Some(false),
max_age: None,
include_subdomains: None,
preload: None,
force_replace_backend: None,
};
assert_eq!(
router.refresh_inheriting_hsts(Some(&disable)),
1,
"the promoted entry must still be touched on disable to strip its STS edit"
);
let Route::Frontend(rc) = &router.pre[0].4 else {
panic!("pre[0] should still be Route::Frontend (no demotion)");
};
assert_eq!(rc.cluster_id.as_deref(), Some("api"));
assert_eq!(
rc.headers_response.len(),
0,
"disable patch must strip the STS edit from the promoted entry"
);
}
#[test]
fn refresh_inheriting_hsts_promotes_clusterid_in_trie_on_enable() {
use crate::router::pattern_trie::TrieNode;
let mut router = Router {
pre: Vec::new(),
tree: TrieNode::root(),
post: Vec::new(),
};
let path_rule = PathRule::Prefix("/".to_owned());
let method_rule = MethodRule::new(None);
assert!(router.add_tree_rule(
b"example.com",
&path_rule,
&method_rule,
&Route::ClusterId("api".into()),
));
let new_hsts = HstsConfig {
enabled: Some(true),
max_age: Some(31_536_000),
include_subdomains: Some(true),
preload: None,
force_replace_backend: None,
};
let count = router.refresh_inheriting_hsts(Some(&new_hsts));
assert_eq!(
count, 1,
"trie-resident ClusterId must be promoted + counted"
);
let (_, paths) = router
.tree
.domain_lookup_mut(b"example.com", false)
.expect("trie leaf still present after refresh");
assert_eq!(paths.len(), 1);
let Route::Frontend(rc) = &paths[0].3 else {
panic!("trie leaf should now be Route::Frontend, not Route::ClusterId");
};
assert_eq!(rc.cluster_id.as_deref(), Some("api"));
assert_eq!(rc.redirect, RedirectPolicy::Forward);
assert!(rc.inherits_listener_hsts);
let response: Vec<_> = rc.headers_response.iter().collect();
assert_eq!(response.len(), 1);
assert_eq!(&*response[0].key, b"strict-transport-security");
assert_eq!(
&*response[0].val,
b"max-age=31536000; includeSubDomains".as_slice()
);
}
#[test]
fn convert_regex() {
assert_eq!(
convert_regex_domain_rule("www.example.com")
.unwrap()
.as_str(),
"\\Awww\\.example\\.com\\z"
);
assert_eq!(
convert_regex_domain_rule("*.example.com").unwrap().as_str(),
"\\A\\*\\.example\\.com\\z"
);
assert_eq!(
convert_regex_domain_rule("test.*.example.com")
.unwrap()
.as_str(),
"\\Atest\\.\\*\\.example\\.com\\z"
);
assert_eq!(
convert_regex_domain_rule("css./cdn[a-z0-9]+/.example.com")
.unwrap()
.as_str(),
"\\Acss\\.(?:cdn[a-z0-9]+)\\.example\\.com\\z"
);
assert_eq!(
convert_regex_domain_rule("css./cdn[a-z0-9]+.example.com"),
None
);
assert_eq!(
convert_regex_domain_rule("css./cdn[a-z0-9]+/a.example.com"),
None
);
}
#[test]
fn regex_domain_rule_rejects_suffix_and_prefix() {
let rule: DomainRule = "/example\\.com/".parse().unwrap();
assert!(rule.matches(b"example.com"));
assert!(!rule.matches(b"attacker.example.com"));
assert!(!rule.matches(b"example.com.evil.org"));
assert!(!rule.matches(b"prefixexample.com"));
assert!(!rule.matches(b"example.commercial"));
}
#[test]
fn regex_domain_rule_multi_segment_segments_are_isolated() {
let pattern = convert_regex_domain_rule("/seg1/.foo./seg2/.com")
.expect("multi-segment regex hostname must compile");
assert_eq!(pattern.as_str(), "\\A(?:seg1)\\.foo\\.(?:seg2)\\.com\\z");
}
#[test]
fn parse_domain_rule() {
assert_eq!("*".parse::<DomainRule>().unwrap(), DomainRule::Any);
assert_eq!(
"www.example.com".parse::<DomainRule>().unwrap(),
DomainRule::Exact("www.example.com".to_string())
);
assert_eq!(
"*.example.com".parse::<DomainRule>().unwrap(),
DomainRule::Wildcard("*.example.com".to_string())
);
assert_eq!("test.*.example.com".parse::<DomainRule>(), Err(()));
assert_eq!(
"/cdn[0-9]+/.example.com".parse::<DomainRule>().unwrap(),
DomainRule::Regex(Regex::new("\\A(?:cdn[0-9]+)\\.example\\.com\\z").unwrap())
);
}
#[test]
fn match_domain_rule() {
assert!(DomainRule::Any.matches("www.example.com".as_bytes()));
assert!(
DomainRule::Exact("www.example.com".to_string()).matches("www.example.com".as_bytes())
);
assert!(
DomainRule::Wildcard("*.example.com".to_string()).matches("www.example.com".as_bytes())
);
assert!(
!DomainRule::Wildcard("*.example.com".to_string())
.matches("test.www.example.com".as_bytes())
);
assert!(
"/cdn[0-9]+/.example.com"
.parse::<DomainRule>()
.unwrap()
.matches("cdn1.example.com".as_bytes())
);
assert!(
!"/cdn[0-9]+/.example.com"
.parse::<DomainRule>()
.unwrap()
.matches("www.example.com".as_bytes())
);
assert!(
!"/cdn[0-9]+/.example.com"
.parse::<DomainRule>()
.unwrap()
.matches("cdn10.exampleAcom".as_bytes())
);
}
#[test]
fn match_domain_rule_wildcard_short_hostname_does_not_panic() {
let rule = DomainRule::Wildcard("*.foo.example.com".to_string());
assert!(!rule.matches(b""));
assert!(!rule.matches(b"a.b"));
assert!(!rule.matches(b"x"));
assert!(!rule.matches(b".foo.example.com"));
assert!(!rule.matches(b"y.x.foo.example.com"));
assert!(rule.matches(b"x.foo.example.com"));
}
#[test]
fn router_lookup_wildcard_pre_rule_short_hostname_does_not_panic() {
let mut router = Router::new();
assert!(router.add_pre_rule(
&"*.foo.example.com".parse::<DomainRule>().unwrap(),
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("wildcard".into()),
));
let method = Method::new(&b"GET"[..]);
assert!(router.lookup("", "/", &method).is_err());
assert!(router.lookup("x", "/", &method).is_err());
assert!(router.lookup("a.b", "/", &method).is_err());
assert!(router.lookup(".foo.example.com", "/", &method).is_err());
assert_eq!(
router.lookup("x.foo.example.com", "/", &method),
Ok(RouteResult::forward("wildcard".into()))
);
}
#[test]
fn match_path_rule() {
assert!(PathRule::Prefix("".to_string()).matches("/".as_bytes()) != PathRuleResult::None);
assert!(
PathRule::Prefix("".to_string()).matches("/hello".as_bytes()) != PathRuleResult::None
);
assert!(
PathRule::Prefix("/hello".to_string()).matches("/hello".as_bytes())
!= PathRuleResult::None
);
assert!(
PathRule::Prefix("/hello".to_string()).matches("/hello/world".as_bytes())
!= PathRuleResult::None
);
assert!(
PathRule::Prefix("/hello".to_string()).matches("/".as_bytes()) == PathRuleResult::None
);
}
#[test]
fn multiple_children_on_a_wildcard() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"*.sozu.io",
&PathRule::Prefix("".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("base".into())
));
println!("{:#?}", router.tree);
assert_eq!(
router.lookup("www.sozu.io", "/api", &Method::Get),
Ok(RouteResult::forward("base".into()))
);
assert!(router.add_tree_rule(
b"*.sozu.io",
&PathRule::Prefix("/api".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("api".into())
));
println!("{:#?}", router.tree);
assert_eq!(
router.lookup("www.sozu.io", "/ap", &Method::Get),
Ok(RouteResult::forward("base".into()))
);
assert_eq!(
router.lookup("www.sozu.io", "/api", &Method::Get),
Ok(RouteResult::forward("api".into()))
);
}
#[test]
fn multiple_children_including_one_with_wildcard() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"*.sozu.io",
&PathRule::Prefix("".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("base".into())
));
println!("{:#?}", router.tree);
assert_eq!(
router.lookup("www.sozu.io", "/api", &Method::Get),
Ok(RouteResult::forward("base".into()))
);
assert!(router.add_tree_rule(
b"api.sozu.io",
&PathRule::Prefix("".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("api".into())
));
println!("{:#?}", router.tree);
assert_eq!(
router.lookup("www.sozu.io", "/api", &Method::Get),
Ok(RouteResult::forward("base".into()))
);
assert_eq!(
router.lookup("api.sozu.io", "/api", &Method::Get),
Ok(RouteResult::forward("api".into()))
);
}
#[test]
fn add_tree_rule_rejects_malformed_hostnames_without_panicking() {
for hostname in [
&b"example.com/"[..],
b"www.example.com/",
b"foo/",
b"a/*/",
b"/",
b"///",
b"abc/[0-9]+/.example.com",
b"/[/.example.com",
b"./test[0-9]/.example.com",
b".example.com",
b".a.b",
b"..",
] {
let mut router = Router::new();
assert!(
!router.add_tree_rule(
hostname,
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("cluster".into()),
),
"{:?} must be rejected, not inserted",
String::from_utf8_lossy(hostname),
);
assert!(
router.tree.is_empty(),
"{:?} was rejected but still mutated the route table",
String::from_utf8_lossy(hostname),
);
}
}
#[test]
fn add_http_front_surfaces_a_malformed_hostname_as_an_error() {
let mut router = Router::new();
let front = HttpFrontend {
hostname: "example.com/".to_owned(),
..test_http_frontend()
};
assert!(matches!(
router.add_http_front(&front),
Err(RouterError::AddRoute(_))
));
assert!(router.tree.is_empty());
for hostname in ["/a/.", "a/b/.", "x./y/."] {
let mut router = Router::new();
let front = HttpFrontend {
hostname: (*hostname).to_owned(),
..test_http_frontend()
};
assert!(
matches!(
router.add_http_front(&front),
Err(RouterError::InvalidDomain { .. })
),
"{hostname:?} must be rejected as an invalid domain, not panic",
);
assert!(router.tree.is_empty());
}
}
#[test]
fn domain_rule_rejects_a_trailing_dot_after_a_regex_segment() {
for hostname in ["/a/.", "a/b/.", "/[/.", "x./y/."] {
assert!(
hostname.parse::<DomainRule>().is_err(),
"{hostname:?} must be rejected, not panic",
);
}
assert!("abc./[0-9]+/.example.com".parse::<DomainRule>().is_ok());
}
#[test]
fn add_http_front_rejects_an_oversized_hostname() {
let mut router = Router::new();
let front = HttpFrontend {
hostname: "a".repeat(MAX_HOSTNAME_LENGTH),
..test_http_frontend()
};
assert!(router.add_http_front(&front).is_ok());
let mut router = Router::new();
let front = HttpFrontend {
hostname: "a".repeat(MAX_HOSTNAME_LENGTH + 1),
..test_http_frontend()
};
assert!(matches!(
router.add_http_front(&front),
Err(RouterError::InvalidDomain { .. })
));
assert!(matches!(
router.remove_http_front(&front),
Err(RouterError::InvalidDomain { .. })
));
assert!(router.tree.is_empty());
let front = HttpFrontend {
hostname: "a.".repeat(MAX_HOSTNAME_LENGTH),
..test_http_frontend()
};
assert!(matches!(
router.add_http_front(&front),
Err(RouterError::InvalidDomain { .. })
));
}
#[test]
fn router_insert_remove_through_regex() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"www./.*/.io",
&PathRule::Prefix("".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("base".into())
));
println!("{:#?}", router.tree);
assert!(router.add_tree_rule(
b"www.doc./.*/.io",
&PathRule::Prefix("".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("doc".into())
));
println!("{:#?}", router.tree);
assert_eq!(
router.lookup("www.sozu.io", "/", &Method::Get),
Ok(RouteResult::forward("base".into()))
);
assert_eq!(
router.lookup("www.doc.sozu.io", "/", &Method::Get),
Ok(RouteResult::forward("doc".into()))
);
assert!(router.remove_tree_rule(
b"www./.*/.io",
&PathRule::Prefix("".to_string()),
&MethodRule::new(Some("GET".to_string()))
));
println!("{:#?}", router.tree);
assert!(router.lookup("www.sozu.io", "/", &Method::Get).is_err());
assert_eq!(
router.lookup("www.doc.sozu.io", "/", &Method::Get),
Ok(RouteResult::forward("doc".into()))
);
}
#[test]
fn match_router() {
let mut router = Router::new();
assert!(router.add_pre_rule(
&"*".parse::<DomainRule>().unwrap(),
&PathRule::Prefix("/.well-known/acme-challenge".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("acme".into())
));
assert!(router.add_tree_rule(
"www.example.com".as_bytes(),
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("example".into())
));
assert!(router.add_tree_rule(
"*.test.example.com".as_bytes(),
&PathRule::Regex(Regex::new("/hello[A-Z]+/").unwrap()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("examplewildcard".into())
));
assert!(router.add_tree_rule(
"/test[0-9]/.example.com".as_bytes(),
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("exampleregex".into())
));
assert_eq!(
router.lookup("www.example.com", "/helloA", &Method::new(&b"GET"[..])),
Ok(RouteResult::forward("example".into()))
);
assert_eq!(
router.lookup(
"www.example.com",
"/.well-known/acme-challenge",
&Method::new(&b"GET"[..])
),
Ok(RouteResult::forward("acme".into()))
);
assert!(
router
.lookup("www.test.example.com", "/", &Method::new(&b"GET"[..]))
.is_err()
);
assert_eq!(
router.lookup(
"www.test.example.com",
"/helloAB/",
&Method::new(&b"GET"[..])
),
Ok(RouteResult::forward("examplewildcard".into()))
);
assert_eq!(
router.lookup("test1.example.com", "/helloAB/", &Method::new(&b"GET"[..])),
Ok(RouteResult::forward("exampleregex".into()))
);
}
#[test]
fn has_hostname_checks_tree_pre_and_post() {
let mut router = Router::new();
assert!(!router.has_hostname("www.example.com"));
assert!(router.add_tree_rule(
b"www.example.com",
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("cluster1".into())
));
assert!(router.has_hostname("www.example.com"));
assert!(!router.has_hostname("api.example.com"));
assert!(router.remove_tree_rule(
b"www.example.com",
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string()))
));
assert!(!router.has_hostname("www.example.com"));
assert!(router.add_pre_rule(
&DomainRule::Exact("api.example.com".to_string()),
&PathRule::Prefix("/".to_string()),
&MethodRule::new(None),
&Route::ClusterId("cluster2".into())
));
assert!(router.has_hostname("api.example.com"));
assert!(!router.has_hostname("www.example.com"));
assert!(router.add_post_rule(
&DomainRule::Exact("cdn.example.com".to_string()),
&PathRule::Prefix("/".to_string()),
&MethodRule::new(None),
&Route::ClusterId("cluster3".into())
));
assert!(router.has_hostname("cdn.example.com"));
assert!(router.remove_pre_rule(
&DomainRule::Exact("api.example.com".to_string()),
&PathRule::Prefix("/".to_string()),
&MethodRule::new(None),
));
assert!(!router.has_hostname("api.example.com"));
assert!(router.has_hostname("cdn.example.com"));
}
#[test]
fn has_hostname_false_after_last_route_removed() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"www.example.com",
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("cluster1".into())
));
assert!(router.add_tree_rule(
b"www.example.com",
&PathRule::Prefix("/api".to_string()),
&MethodRule::new(Some("GET".to_string())),
&Route::ClusterId("cluster2".into())
));
assert!(router.has_hostname("www.example.com"));
assert!(router.remove_tree_rule(
b"www.example.com",
&PathRule::Prefix("/".to_string()),
&MethodRule::new(Some("GET".to_string()))
));
assert!(router.has_hostname("www.example.com"));
assert!(router.remove_tree_rule(
b"www.example.com",
&PathRule::Prefix("/api".to_string()),
&MethodRule::new(Some("GET".to_string()))
));
assert!(!router.has_hostname("www.example.com"));
}
#[test]
fn a_leftmost_regex_frontend_is_addable_after_a_deeper_sibling() {
let path = PathRule::Prefix("/".to_string());
let method = MethodRule::new(Some("GET".to_string()));
let mut router = Router::new();
assert!(router.add_tree_rule(
b"foo./test[0-9]/.example.com",
&path,
&method,
&Route::ClusterId("deeper".into()),
));
assert!(
router.add_tree_rule(
b"/test[0-9]/.example.com",
&path,
&method,
&Route::ClusterId("leftmost".into()),
),
"a leftmost-regex frontend must be accepted after a deeper sibling",
);
assert_eq!(
router.lookup("test4.example.com", "/", &Method::Get),
Ok(RouteResult::forward("leftmost".into())),
"the leftmost-regex frontend must actually route, not just report OK",
);
assert_eq!(
router.lookup("foo.test4.example.com", "/", &Method::Get),
Ok(RouteResult::forward("deeper".into())),
"the deeper frontend must still route",
);
let mut router = Router::new();
assert!(router.add_tree_rule(
b"/test[0-9]/.example.com",
&path,
&method,
&Route::ClusterId("leftmost".into()),
));
assert!(router.add_tree_rule(
b"foo./test[0-9]/.example.com",
&path,
&method,
&Route::ClusterId("deeper".into()),
));
assert_eq!(
router.lookup("test4.example.com", "/", &Method::Get),
Ok(RouteResult::forward("leftmost".into())),
);
assert_eq!(
router.lookup("foo.test4.example.com", "/", &Method::Get),
Ok(RouteResult::forward("deeper".into())),
);
assert!(router.remove_tree_rule(b"/test[0-9]/.example.com", &path, &method));
assert!(
router
.lookup("test4.example.com", "/", &Method::Get)
.is_err()
);
assert_eq!(
router.lookup("foo.test4.example.com", "/", &Method::Get),
Ok(RouteResult::forward("deeper".into())),
);
}
#[test]
fn add_tree_rule_never_reports_success_for_a_rule_it_did_not_store() {
let path = PathRule::Prefix("/".to_string());
let method = MethodRule::new(Some("GET".to_string()));
let sequence: &[(&[u8], &str)] = &[
(b"www.example.com", "www.example.com"),
(b"*.wild.example.com", "any.wild.example.com"),
(b"foo./test[0-9]/.example.com", "foo.test4.example.com"),
(b"/test[0-9]/.example.com", "test4.example.com"),
(b"bar./test[0-9]/.example.com", "bar.test7.example.com"),
];
let mut router = Router::new();
for (index, (hostname, routable)) in sequence.iter().enumerate() {
let cluster = ClusterId::from(format!("cluster{index}"));
let added =
router.add_tree_rule(hostname, &path, &method, &Route::ClusterId(cluster.clone()));
let resolved = router.lookup(routable, "/", &Method::Get);
assert!(
added,
"{:?} must be accepted",
String::from_utf8_lossy(hostname),
);
assert_eq!(
resolved,
Ok(RouteResult::forward(cluster)),
"add_tree_rule reported success for {:?} but it does not route",
String::from_utf8_lossy(hostname),
);
}
for hostname in [
&b"example.com/"[..],
b".example.com",
b"abc/[0-9]+/.example.com",
] {
let mut router = Router::new();
assert!(
!router.add_tree_rule(
hostname,
&path,
&method,
&Route::ClusterId("rejected".into()),
),
"{:?} must be rejected",
String::from_utf8_lossy(hostname),
);
assert!(
router.tree.is_empty(),
"{:?} was rejected but still mutated the route table",
String::from_utf8_lossy(hostname),
);
}
}
fn add_path_rule(
router: &mut Router,
path: PathRule,
method: Option<&str>,
cluster: &str,
) -> bool {
router.add_tree_rule(
b"www.example.com",
&path,
&MethodRule::new(method.map(str::to_owned)),
&Route::ClusterId(cluster.into()),
)
}
fn routed_cluster(router: &Router, path: &str) -> Option<String> {
router
.lookup("www.example.com", path, &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
.map(|cluster_id| cluster_id.to_string())
}
fn winner_of(
first: (PathRule, Option<&str>, &str),
second: (PathRule, Option<&str>, &str),
path: &str,
) -> Option<String> {
let mut router = Router::new();
assert!(add_path_rule(&mut router, first.0, first.1, first.2));
assert!(add_path_rule(&mut router, second.0, second.1, second.2));
routed_cluster(&router, path)
}
const GET: Option<&str> = Some("GET");
const NO_METHOD: Option<&str> = None;
const WRONG_METHOD: Option<&str> = Some("POST");
#[test]
fn the_longest_prefix_wins_whichever_prefix_is_declared_first() {
let short = || PathRule::Prefix("/a".to_owned());
let long = || PathRule::Prefix("/ab".to_owned());
assert_eq!(
winner_of((short(), GET, "SHORT"), (long(), GET, "LONG"), "/abc").as_deref(),
Some("LONG"),
"the longer prefix must win even when the shorter one was declared first",
);
assert_eq!(
winner_of((long(), GET, "LONG"), (short(), GET, "SHORT"), "/abc").as_deref(),
Some("LONG"),
"the longer prefix must win when it was declared first too",
);
}
#[test]
fn equal_length_prefixes_differing_only_by_method_resolve_to_the_last_declared() {
let declare = |first_is_get: bool| {
let mut router = Router::new();
let get = (
MethodRule::new(Some("GET".to_owned())),
Route::ClusterId("GET-RULE".into()),
);
let all = (MethodRule::new(None), Route::ClusterId("ALL-RULE".into()));
let (a, b) = if first_is_get { (get, all) } else { (all, get) };
for (method, route) in [a, b] {
assert!(router.add_tree_rule(
b"www.example.com",
&PathRule::Prefix("/ab".to_owned()),
&method,
&route,
));
}
routed_cluster(&router, "/abc")
};
assert_eq!(
declare(true).as_deref(),
Some("ALL-RULE"),
"the LAST equal-length prefix declared must win (`>=`, not `>`)",
);
assert_eq!(
declare(false).as_deref(),
Some("GET-RULE"),
"the LAST equal-length prefix declared must win in the other order too",
);
}
#[test]
fn with_a_matching_method_the_first_declared_regex_path_rule_wins() {
let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
assert_eq!(
winner_of((wide(), GET, "WIDE"), (narrow(), GET, "NARROW"), "/abc").as_deref(),
Some("WIDE"),
"the first-declared regex must win even though the later one is narrower",
);
assert_eq!(
winner_of((narrow(), GET, "NARROW"), (wide(), GET, "WIDE"), "/abc").as_deref(),
Some("NARROW"),
"reversing the declaration order reverses the winner",
);
}
#[test]
fn with_a_matching_method_neither_equals_nor_regex_outranks_the_other() {
let equals = || PathRule::Equals("/abc".to_owned());
let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
assert_eq!(
winner_of((equals(), GET, "EQUALS"), (regex(), GET, "REGEX"), "/abc").as_deref(),
Some("EQUALS"),
"EQUALS declared first must win",
);
assert_eq!(
winner_of((regex(), GET, "REGEX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
Some("REGEX"),
"REGEX declared first must win — EQUALS has no inherent priority",
);
}
#[test]
fn with_a_matching_method_equals_or_regex_beats_a_longer_prefix_either_order() {
let prefix = || PathRule::Prefix("/abc".to_owned());
let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
let equals = || PathRule::Equals("/abc".to_owned());
assert_eq!(
winner_of((prefix(), GET, "PREFIX"), (regex(), GET, "REGEX"), "/abc").as_deref(),
Some("REGEX"),
"a regex must beat a longer prefix declared before it",
);
assert_eq!(
winner_of((regex(), GET, "REGEX"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
Some("REGEX"),
"a regex must beat a longer prefix declared after it",
);
assert_eq!(
winner_of((prefix(), GET, "PREFIX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
Some("EQUALS"),
"an exact match must beat an equally long prefix declared before it",
);
assert_eq!(
winner_of((equals(), GET, "EQUALS"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
Some("EQUALS"),
"an exact match must beat an equally long prefix declared after it",
);
}
#[test]
fn without_a_method_the_last_declared_regex_or_equals_wins_not_the_first() {
let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
let equals = || PathRule::Equals("/abc".to_owned());
assert_eq!(
winner_of(
(wide(), NO_METHOD, "WIDE"),
(narrow(), NO_METHOD, "NARROW"),
"/abc"
)
.as_deref(),
Some("NARROW"),
"without a method the LAST declared regex must win",
);
assert_eq!(
winner_of(
(narrow(), NO_METHOD, "NARROW"),
(wide(), NO_METHOD, "WIDE"),
"/abc"
)
.as_deref(),
Some("WIDE"),
"reversing the order reverses the winner — still the last declared",
);
assert_eq!(
winner_of(
(equals(), NO_METHOD, "EQUALS"),
(wide(), NO_METHOD, "REGEX"),
"/abc"
)
.as_deref(),
Some("REGEX"),
"without a method an EQUALS declared first loses to a later REGEX",
);
assert_eq!(
winner_of(
(wide(), NO_METHOD, "REGEX"),
(equals(), NO_METHOD, "EQUALS"),
"/abc"
)
.as_deref(),
Some("EQUALS"),
"and the reverse order reverses that too",
);
}
#[test]
fn without_a_method_only_a_whole_path_prefix_declared_later_beats_a_regex() {
let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
let whole_path = || PathRule::Prefix("/abc".to_owned());
let shorter = || PathRule::Prefix("/a".to_owned());
assert_eq!(
winner_of(
(regex(), NO_METHOD, "REGEX"),
(whole_path(), NO_METHOD, "PREFIX"),
"/abc"
)
.as_deref(),
Some("PREFIX"),
"a whole-path prefix declared after a method-less regex must overwrite it",
);
assert_eq!(
winner_of(
(whole_path(), NO_METHOD, "PREFIX"),
(regex(), NO_METHOD, "REGEX"),
"/abc"
)
.as_deref(),
Some("REGEX"),
"declared before it, the same prefix loses — the last writer wins",
);
assert_eq!(
winner_of(
(regex(), NO_METHOD, "REGEX"),
(shorter(), NO_METHOD, "SHORT-PREFIX"),
"/abc"
)
.as_deref(),
Some("REGEX"),
"a prefix shorter than the request path must NOT displace the regex",
);
assert_eq!(
winner_of(
(regex(), NO_METHOD, "REGEX"),
(whole_path(), NO_METHOD, "PREFIX"),
"/abc?x=1"
)
.as_deref(),
Some("REGEX"),
"with a query string the prefix no longer spans the request path, so the regex wins",
);
}
#[test]
fn a_rule_with_a_matching_method_beats_a_method_less_rule_in_either_order() {
let any = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
let getr = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
assert_eq!(
winner_of(
(getr(), GET, "GET-REGEX"),
(any(), NO_METHOD, "ANY-REGEX"),
"/abc"
)
.as_deref(),
Some("GET-REGEX"),
"a matching-method rule declared first must win",
);
assert_eq!(
winner_of(
(any(), NO_METHOD, "ANY-REGEX"),
(getr(), GET, "GET-REGEX"),
"/abc"
)
.as_deref(),
Some("GET-REGEX"),
"a matching-method rule declared second must still win",
);
}
#[test]
fn a_prefix_whose_method_does_not_match_is_skipped_so_it_cannot_win_on_length() {
assert_eq!(
winner_of(
(PathRule::Prefix("/a".to_owned()), GET, "SHORT-GET"),
(
PathRule::Prefix("/ab".to_owned()),
WRONG_METHOD,
"LONG-POST"
),
"/abc",
)
.as_deref(),
Some("SHORT-GET"),
"a longer prefix whose method mismatches must not beat a shorter matching one",
);
assert_eq!(
winner_of(
(PathRule::Prefix("/ab".to_owned()), GET, "FIRST-GET"),
(
PathRule::Prefix("/ab".to_owned()),
WRONG_METHOD,
"SECOND-POST"
),
"/abc",
)
.as_deref(),
Some("FIRST-GET"),
"an equal-length tie goes to the FIRST when the later rule's method mismatches",
);
}
#[test]
fn a_regex_whose_method_does_not_match_never_beats_a_matching_prefix() {
assert_eq!(
winner_of(
(
PathRule::Regex(Regex::new("/a.*").expect("test regex must compile")),
WRONG_METHOD,
"WRONG-METHOD-REGEX",
),
(PathRule::Prefix("/a".to_owned()), GET, "MATCHING-PREFIX"),
"/abc",
)
.as_deref(),
Some("MATCHING-PREFIX"),
"a regex whose method mismatches must not short-circuit the scan",
);
}
fn regex_rule_routes(pattern: &str, path: &str) -> Option<String> {
let rule = PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
.unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"));
let mut router = Router::new();
assert!(add_path_rule(&mut router, rule, GET, "REGEX"));
routed_cluster(&router, path)
}
fn assert_regex_routing(cases: &[(&str, &str, bool)]) {
let verdict = |routes: bool| if routes { "match" } else { "no match" };
for &(pattern, path, must_route) in cases {
let routes = regex_rule_routes(pattern, path).is_some();
assert_eq!(
routes,
must_route,
"path regex {pattern:?} against request path {path:?}: \
expected {}, measured {}",
verdict(must_route),
verdict(routes),
);
}
}
#[test]
fn a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path() {
assert_eq!(
regex_rule_routes("bc", "/abcd"),
None,
"an anchored path regex must not match in the middle of the request path",
);
assert_eq!(
regex_rule_routes("/ab[cd]", "/abc").as_deref(),
Some("REGEX"),
"a regex spanning the whole request path must still match",
);
assert_eq!(
regex_rule_routes("/ab[cd]", "/abcd"),
None,
"the trailing `\\z` must reject a longer path with a matching head",
);
assert_eq!(
regex_rule_routes("/ab[cd]", "/x/abc"),
None,
"the leading `\\A` must reject a path with a matching tail",
);
assert_eq!(
regex_rule_routes("/ab[cd]", "/abc?x=1"),
None,
"an anchored regex must not match a path carrying a query string \
unless the pattern accounts for it",
);
assert_eq!(
regex_rule_routes("/ab[cd].*", "/abc?x=1").as_deref(),
Some("REGEX"),
"appending `.*` is what makes such a rule survive a query string",
);
}
#[test]
fn an_empty_path_regex_no_longer_matches_every_request_path() {
assert!(
Regex::new("").is_ok(),
"the empty pattern must stay a VALID regex — this test is about \
what it matches, not about rejecting it",
);
assert_eq!(
regex_rule_routes("", "/abcd"),
None,
"an anchored empty path regex must not match a non-empty path",
);
let bare_empty = Regex::new("").expect("the empty pattern compiles");
for every_path in ["/anything", "/abcd", "/", ""] {
assert!(
bare_empty.is_match(every_path.as_bytes()),
"up to 2.2.1 the unwrapped empty pattern matched {every_path:?}, \
as it matched every path",
);
}
assert_eq!(
regex_rule_routes("", "").as_deref(),
Some("REGEX"),
"\"matches only the empty path\" is half a claim without the path \
it does still match",
);
}
#[test]
fn a_hand_written_path_anchor_is_kept_and_double_anchoring_is_harmless() {
for hand_anchored in ["^/abc$", "\\A/abc\\z", "^/abc"] {
assert_eq!(
regex_rule_routes(hand_anchored, "/abc").as_deref(),
Some("REGEX"),
"{hand_anchored} must still match the path it was written for",
);
assert_eq!(
regex_rule_routes(hand_anchored, "/abcd"),
None,
"{hand_anchored} must not match a longer path",
);
assert_eq!(
regex_rule_routes(hand_anchored, "/x/abc"),
None,
"{hand_anchored} must not match a path with a matching tail",
);
}
assert_eq!(
PathRule::anchored_regex("^/abc$")
.expect("a hand-anchored pattern must compile")
.as_str(),
"\\A(?:^/abc$)\\z",
"the configured value is wrapped verbatim; no anchor is stripped",
);
assert_eq!(
regex_rule_routes("^/abc$", "/xabcd"),
None,
"the example `CHANGELOG.md` quotes verbatim must hold",
);
assert!(
!Regex::new("^/abc$")
.expect("the hand-anchored pattern compiles")
.is_match(b"/abc\n"),
"`$` must be `\\z` and not `\\Z`, or wrapping a hand-anchored \
pattern would not be the no-op this test claims",
);
assert_eq!(
regex_rule_routes("^/abc$", "/abc\n"),
None,
"and the wrapped rule must agree with it",
);
}
#[test]
fn every_branch_of_an_alternating_path_regex_is_anchored() {
for whole in ["/a", "/b"] {
assert_eq!(
regex_rule_routes("/a|/b", whole).as_deref(),
Some("REGEX"),
"{whole} is a whole-path match for one branch and must route",
);
}
assert_eq!(
regex_rule_routes("/a|/b", "/axx"),
None,
"the first branch must be anchored at its END too",
);
assert_eq!(
regex_rule_routes("/a|/b", "/xx/b"),
None,
"the last branch must be anchored at its START too",
);
assert_eq!(
PathRule::anchored_regex("/a|/b")
.expect("an alternation must compile")
.captures_len(),
1,
"the wrapping group must be non-capturing, so `$PATH[n]` indices \
are untouched",
);
assert_eq!(
PathRule::anchored_regex("/a|/b")
.expect("an alternation must compile")
.as_str(),
"\\A(?:/a|/b)\\z",
"the wrapper must be the NON-capturing group form",
);
let ungrouped = Regex::new("\\A/a|/b\\z").expect("the ungrouped wrapper compiles");
assert!(
ungrouped.is_match(b"/axx"),
"{} leaves the first branch anchored at its start only",
ungrouped.as_str(),
);
assert!(
ungrouped.is_match(b"/xx/b"),
"{} leaves the last branch anchored at its end only",
ungrouped.as_str(),
);
let three = Regex::new("\\A/a|/b|/c\\z").expect("the ungrouped wrapper compiles");
for anywhere in ["zzz/bzzz", "/xx/b/yy", "xx/byy"] {
assert!(
three.is_match(anywhere.as_bytes()),
"{} leaves the MIDDLE branch unanchored at BOTH ends, so it \
matches {anywhere} anywhere in the path",
three.as_str(),
);
}
assert_regex_routing(&[
("/a|/b|/c", "/a", true),
("/a|/b|/c", "/b", true),
("/a|/b|/c", "/c", true),
("/a|/b|/c", "zzz/bzzz", false),
("/a|/b|/c", "/xx/b/yy", false),
("/a|/b|/c", "xx/byy", false),
("/a|/b|/c", "/axx", false),
("/a|/b|/c", "/xx/c", false),
]);
assert_eq!(
PathRule::anchored_regex("/a|/b|/c")
.expect("a three-branch alternation must compile")
.as_str(),
"\\A(?:/a|/b|/c)\\z",
"a middle branch needs the group as much as the outer two do",
);
}
#[test]
fn anchoring_never_turns_a_rejected_path_regex_into_an_accepted_one() {
let unbalanced = format!("a{}b", ")(");
assert!(
Regex::new(&unbalanced).is_err(),
"{unbalanced:?} must be an invalid regex for this test to mean anything",
);
let wrapped = Regex::new(&format!("\\A(?:{unbalanced})\\z")).expect(
"the wrapped form of the unbalanced pattern compiles — that balancing \
is what the pre-validation exists to defeat",
);
assert!(
wrapped.is_match(b"ab"),
"{} matches `ab`, which the operator never wrote",
wrapped.as_str(),
);
for invalid in [unbalanced.as_str(), "(", ")", "[a-", "*"] {
assert_eq!(
PathRule::from_config(CommandPathRule::regex(invalid.to_owned())),
None,
"{invalid:?} must stay rejected once the value is anchored",
);
}
}
#[test]
fn anchoring_narrows_a_deny_path_regex_to_the_exact_path() {
let mut router = Router::new();
let deny = PathRule::from_config(CommandPathRule::regex("/admin".to_owned()))
.expect("the deny regex path rule must build");
assert!(router.add_tree_rule(
b"www.example.com",
&deny,
&MethodRule::new(Some("GET".to_owned())),
&Route::Deny,
));
assert!(add_path_rule(
&mut router,
PathRule::Prefix("/".to_owned()),
GET,
"FALLBACK",
));
let resolve = |path: &str| {
router
.lookup("www.example.com", path, &Method::Get)
.expect("every path below is covered by the `/` prefix rule")
};
let denied = resolve("/admin");
assert_eq!(
denied.redirect,
RedirectPolicy::Unauthorized,
"the exact path the deny regex spells must still be denied",
);
assert_eq!(denied.cluster_id, None);
for still_reaching_the_backend in ["/admin/secret", "/administrator"] {
let result = resolve(still_reaching_the_backend);
assert_eq!(
result.cluster_id.as_deref(),
Some("FALLBACK"),
"BREAKING: the anchored deny no longer covers \
{still_reaching_the_backend}, which now reaches the backend",
);
assert_eq!(result.redirect, RedirectPolicy::Forward);
}
}
#[test]
fn a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells() {
assert_regex_routing(&[
("bc", "/abcd", false),
("/ab", "/abcd", false),
("/api", "/api/v1", false),
("/api", "/api", true),
("bc", "bc", true),
(".*bc.*", "/abcd", true),
("/api.*", "/api/v1", true),
("/api.*", "/api", true),
]);
for (pattern, used_to_match) in [("bc", "/abcd"), ("/api", "/api/v1")] {
assert!(
Regex::new(pattern)
.expect("the bare pattern compiles")
.is_match(used_to_match.as_bytes()),
"up to 2.2.1 the unwrapped {pattern:?} matched {used_to_match:?} \
as a substring — that is the behaviour this release removes",
);
}
}
#[test]
fn a_suffix_shaped_path_regex_needs_a_leading_wildcard_not_a_trailing_one() {
assert_regex_routing(&[
(r"\.js$", "/static/app.js", false),
(r"\.(js|css)$", "/a/b/style.css", false),
("/v1$", "/api/v1", false),
("/v1$", "/v1", true),
(r"\.js.*", "/static/app.js", false),
("/v1.*", "/api/v1", false),
(r".*\.js$", "/static/app.js", true),
(r".*\.(js|css)$", "/a/b/style.css", true),
(".*/v1$", "/api/v1", true),
]);
assert_regex_routing(&[(r"\.js$", ".js", true)]);
assert!(
Regex::new(r"\.js$")
.expect("the bare suffix pattern compiles")
.is_match(b"/static/app.js"),
"up to 2.2.1 the unwrapped `\\.js$` DID match `/static/app.js` — \
losing that is the silent break this release documents",
);
for origin_form in ["/", "/.js", "/app.js", "/static/app.js"] {
assert!(
regex_rule_routes(r"\.js$", origin_form).is_none(),
"an origin-form request-target begins with `/`, and {origin_form} \
must not match the end-anchored `\\.js$`",
);
}
}
#[test]
fn the_anchoring_group_is_non_capturing_so_path_rewrite_indices_do_not_shift() {
for pattern in ["/api/v2/.*", "/api/(v[0-9]+)/.*", "/api/(v[0-9]+)/(.*)"] {
let bare = Regex::new(pattern).expect("the bare pattern must compile");
let anchored =
PathRule::anchored_regex(pattern).expect("the anchored pattern must compile");
assert_eq!(
anchored.captures_len(),
bare.captures_len(),
"anchoring {pattern:?} must not add a capture group",
);
}
let rewriting_frontend = |pattern: &str, rewrite: &str| {
let mut front = test_http_frontend();
front.hostname = "www.example.com".to_owned();
front.path = CommandPathRule::regex(pattern.to_owned());
front.rewrite_path = Some(rewrite.to_owned());
front
};
let rewritten = |pattern: &str, rewrite: &str, path: &str| {
let mut router = Router::new();
router
.add_http_front(&rewriting_frontend(pattern, rewrite))
.expect("the rewriting frontend must register");
router
.lookup("www.example.com", path, &Method::Get)
.expect("the request must route")
.rewritten_path
};
assert_eq!(
rewritten("/api/(v[0-9]+)/.*", "$PATH[1]", "/api/v2/users").as_deref(),
Some("v2"),
"`$PATH[1]` must still be the FIRST group the operator wrote, not \
the anchoring wrapper",
);
assert_eq!(
rewritten("/api/(v[0-9]+)/(.*)", "/$PATH[2]", "/api/v2/users").as_deref(),
Some("/users"),
"`$PATH[2]` must still be the SECOND group the operator wrote",
);
assert_eq!(
rewritten("/api/(v[0-9]+)/.*", "$PATH[0]", "/api/v2/users").as_deref(),
Some("/api/v2/users"),
"`$PATH[0]` stays the whole request path",
);
let mut router = Router::new();
assert!(
matches!(
router.add_http_front(&rewriting_frontend("/api/(v[0-9]+)/.*", "$PATH[2]")),
Err(RouterError::InvalidPathRewrite(_)),
),
"the capture CAP is `captures_len()` too: a `$PATH[n]` past the \
last group the operator wrote must stay refused",
);
}
#[test]
fn a_path_regex_that_compiles_bare_can_be_refused_once_it_is_anchored() {
let extended_mode_comment = "(?x)/api/v1 # v1 only".to_owned();
let nested = |depth: usize| format!("{}{}", "(".repeat(depth), ")".repeat(depth));
for (value, expected_error) in [
(extended_mode_comment, "unclosed group"),
(
nested(249),
"exceed the maximum number of nested parentheses/brackets (250)",
),
] {
assert!(
Regex::new(&value).is_ok(),
"this case only means something while the BARE pattern compiles",
);
let error = Regex::new(&format!("\\A(?:{value})\\z"))
.expect_err("the WRAPPED pattern is the one that must fail");
assert!(
error.to_string().contains(expected_error),
"the documentation quotes {expected_error:?}; the wrapping \
actually failed with: {error}",
);
assert_eq!(
PathRule::from_config(CommandPathRule::regex(value)),
None,
"a value only the WRAPPING rejects must still be refused",
);
}
assert!(
Regex::new(&format!("\\A(?:{})\\z", nested(248))).is_ok(),
"depth 248 must still survive the wrapping, or 249 is not the boundary",
);
assert!(
Regex::new(&nested(250)).is_ok() && Regex::new(&nested(251)).is_err(),
"the BARE limit must still sit between 250 and 251, or the wrapper \
is no longer what makes depth 249 fail",
);
}
#[test]
fn a_refused_path_regex_surfaces_as_an_invalid_path_rule_at_registration() {
let unbalanced = format!("a{}b", ")(");
for value in [
unbalanced,
"(?x)/api/v1 # v1 only".to_owned(),
format!("{}{}", "(".repeat(249), ")".repeat(249)),
] {
let mut router = Router::new();
let mut front = test_http_frontend();
front.path = CommandPathRule::regex(value.clone());
assert!(
matches!(
router.add_http_front(&front),
Err(RouterError::InvalidPathRule(_))
),
"the path regex {value:?} must be refused as an invalid path rule",
);
}
}
#[test]
fn a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule() {
for spells_forward in [false, true] {
let mut router = Router::new();
let mut clusterless = test_http_frontend();
clusterless.hostname = "www.example.com".to_owned();
clusterless.cluster_id = None;
clusterless.path = CommandPathRule::regex("/admin".to_owned());
clusterless.method = Some("GET".to_owned());
if spells_forward {
clusterless.redirect = Some(RedirectPolicy::Forward as i32);
}
router
.add_http_front(&clusterless)
.expect("a clusterless frontend must register as a deny");
assert!(add_path_rule(
&mut router,
PathRule::Prefix("/".to_owned()),
GET,
"FALLBACK",
));
let resolve = |path: &str| {
router
.lookup("www.example.com", path, &Method::Get)
.expect("every path below is covered by the `/` prefix rule")
};
let denied = resolve("/admin");
assert_eq!(
denied.redirect,
RedirectPolicy::Unauthorized,
"a clusterless frontend must still deny the path it spells \
(spells_forward={spells_forward})",
);
assert_eq!(denied.cluster_id, None);
for now_reaching_the_backend in ["/admin/secret", "/administrator"] {
assert_eq!(
resolve(now_reaching_the_backend).cluster_id.as_deref(),
Some("FALLBACK"),
"BREAKING: the anchored clusterless deny no longer covers \
{now_reaching_the_backend} (spells_forward={spells_forward})",
);
}
}
}
#[test]
fn widening_a_deny_path_regex_with_a_trailing_wildcard_covers_the_subtree_again() {
let mut router = Router::new();
let mut deny = test_http_frontend();
deny.hostname = "www.example.com".to_owned();
deny.cluster_id = Some("DENIED".into());
deny.path = CommandPathRule::regex("/admin.*".to_owned());
deny.method = Some("GET".to_owned());
deny.redirect = Some(RedirectPolicy::Unauthorized as i32);
router
.add_http_front(&deny)
.expect("the widened deny frontend must register");
assert!(add_path_rule(
&mut router,
PathRule::Prefix("/".to_owned()),
GET,
"FALLBACK",
));
let resolve = |path: &str| {
router
.lookup("www.example.com", path, &Method::Get)
.expect("every path below is covered by the `/` prefix rule")
};
for denied in ["/admin", "/admin/secret", "/administrator"] {
assert_eq!(
resolve(denied).redirect,
RedirectPolicy::Unauthorized,
"the widened deny must cover {denied} again",
);
}
assert_eq!(
resolve("/other").cluster_id.as_deref(),
Some("FALLBACK"),
"widening the deny must not turn it into a catch-all",
);
assert_eq!(
resolve("/other").redirect,
RedirectPolicy::Forward,
"a path outside the widened family must still be forwarded",
);
}
#[test]
fn an_anchored_path_regex_must_span_the_query_string_it_is_matched_against() {
assert_regex_routing(&[
("/abc", "/abc", true),
("/abc", "/abc?x=1", false),
("^/abc$.*", "/abc", true),
("^/abc$.*", "/abc?x=1", false),
(r".*\.js([?].*)?", "/static/app.js", true),
(r".*\.js([?].*)?", "/static/app.js?v=2", true),
(r".*\.js([?].*)?", "/static/app.json", false),
]);
assert!(
!Regex::new(r"\.js$")
.expect("the bare suffix pattern compiles")
.is_match(b"/static/app.js?v=2"),
"the unanchored `\\.js$` of 2.2.1 did not match a query-string \
request either, so that half is not a regression",
);
}
#[test]
fn an_anchored_regex_that_cannot_span_the_query_string_stops_being_a_candidate() {
let configured = |pattern: &str| {
PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
.unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"))
};
let against_the_whole_path_prefix = |pattern: &str, path: &str| {
winner_of(
(configured(pattern), NO_METHOD, "REGEX"),
(PathRule::Prefix("/abc".to_owned()), NO_METHOD, "PREFIX"),
path,
)
};
assert_eq!(
against_the_whole_path_prefix("/a.*", "/abc").as_deref(),
Some("PREFIX"),
"the whole-path prefix declared after the regex must still win",
);
assert_eq!(
against_the_whole_path_prefix("/a.*", "/abc?x=1").as_deref(),
Some("REGEX"),
"with a query string `/abc` no longer spans the request path, so \
a regex that does wins",
);
assert_eq!(
against_the_whole_path_prefix("/abc", "/abc").as_deref(),
Some("PREFIX"),
"the prefix wins here exactly as it does against `/a.*`",
);
assert_eq!(
against_the_whole_path_prefix("/abc", "/abc?x=1").as_deref(),
Some("PREFIX"),
"an anchored `/abc` cannot match `/abc?x=1`, so adding a query \
string changes nothing and the prefix keeps the request",
);
assert_eq!(
regex_rule_routes("/abc", "/abc").as_deref(),
Some("REGEX"),
"declared alone, the regex answers the request it spells",
);
assert_eq!(
regex_rule_routes("/abc", "/abc?x=1"),
None,
"declared alone, it leaves the query-string request UNROUTED — it \
is not a candidate any more",
);
}
fn regex_path_rule_routes(pattern: &str, path: &str) -> Option<String> {
let rule = PathRule::from_config(CommandPathRule::regex_path(pattern.to_owned()))
.unwrap_or_else(|| panic!("the path-only regex rule {pattern:?} must build"));
assert!(
matches!(rule, PathRule::RegexPath(_)),
"a REGEX_PATH config rule must build a RegexPath router rule",
);
let mut router = Router::new();
assert!(add_path_rule(&mut router, rule, GET, "REGEX_PATH"));
routed_cluster(&router, path)
}
#[test]
fn a_path_only_regex_ignores_the_query_string_and_regex_still_spans_it() {
let cases: &[(&str, &str, bool, bool)] = &[
("/api/.+", "/api/?x=1", true, false),
("^/api/v2$", "/api/v2?x=1", false, true),
("/api/.+", "/api/x?y", true, true),
("/api/.+", "/api/x", true, true),
("/api/.+", "/api/", false, false),
("^/api/v2$", "/api/v2?", false, true),
("/a", "/a?b?c", false, true),
("/a[?]x=1", "/a?x=1", true, false),
("/ab", "/abc?x=1", false, false),
("bc", "/abc", false, false),
];
for &(pattern, path, regex_routes, regex_path_routes) in cases {
assert_eq!(
regex_rule_routes(pattern, path).is_some(),
regex_routes,
"REGEX {pattern:?} against {path:?} must keep its historical verdict",
);
assert_eq!(
regex_path_rule_routes(pattern, path).is_some(),
regex_path_routes,
"REGEX_PATH {pattern:?} against {path:?}",
);
}
}
#[test]
fn prefix_and_equals_still_match_against_the_query_string() {
let routes = |rule: CommandPathRule, path: &str| {
let rule = PathRule::from_config(rule).expect("the path rule must build");
let mut router = Router::new();
assert!(add_path_rule(&mut router, rule, GET, "RULE"));
routed_cluster(&router, path).is_some()
};
assert!(!routes(CommandPathRule::equals("/abc"), "/abc?x=1"));
assert!(routes(CommandPathRule::equals("/abc?x=1"), "/abc?x=1"));
assert!(routes(CommandPathRule::prefix("/abc?x"), "/abc?x=1"));
assert!(!routes(CommandPathRule::prefix("/abc?y"), "/abc?x=1"));
}
#[test]
fn a_path_only_regex_is_a_distinct_rule_from_the_same_regex() {
let regex = PathRule::from_config(CommandPathRule::regex("/a.*")).expect("REGEX builds");
let regex_path =
PathRule::from_config(CommandPathRule::regex_path("/a.*")).expect("REGEX_PATH builds");
assert_ne!(regex, regex_path);
assert_eq!(regex_path, regex_path.clone());
assert!(
PathRule::from_config(CommandPathRule::regex_path("a)(b")).is_none(),
"a pattern refused bare stays refused for the path-only kind too",
);
}
#[test]
fn a_path_only_regex_rewrite_captures_the_path_and_carries_the_query_over() {
let rewritten =
|path_rule: CommandPathRule, rewrite: &str, redirect: RedirectPolicy, path: &str| {
let mut front = test_http_frontend();
front.hostname = "www.example.com".to_owned();
front.path = path_rule;
front.rewrite_path = Some(rewrite.to_owned());
front.redirect = Some(redirect as i32);
let mut router = Router::new();
router
.add_http_front(&front)
.expect("the rewriting frontend must register");
router
.lookup("www.example.com", path, &Method::Get)
.expect("the request must route")
.rewritten_path
};
let regex_path = || CommandPathRule::regex_path("/api/(v[0-9]+)/(.*)");
for redirect in [RedirectPolicy::Forward, RedirectPolicy::Permanent] {
assert_eq!(
rewritten(
regex_path(),
"/$PATH[1]/$PATH[2]",
redirect,
"/api/v2/users?id=7"
)
.as_deref(),
Some("/v2/users?id=7"),
"{redirect:?}: captures stop at `?`, and the query is appended",
);
assert_eq!(
rewritten(regex_path(), "$PATH[0]", redirect, "/api/v2/users?id=7").as_deref(),
Some("/api/v2/users?id=7"),
"{redirect:?}: `$PATH[0]` is the path half, and the query follows it once",
);
assert_eq!(
rewritten(regex_path(), "/$PATH[2]", redirect, "/api/v2/users").as_deref(),
Some("/users"),
"{redirect:?}: without a query nothing is appended",
);
assert_eq!(
rewritten(regex_path(), "/$PATH[2]", redirect, "/api/v2/users?").as_deref(),
Some("/users?"),
"{redirect:?}: an empty query is carried over as written",
);
assert_eq!(
rewritten(
regex_path(),
"/$PATH[2]?src=v",
redirect,
"/api/v2/users?id=7"
)
.as_deref(),
Some("/users?src=v"),
"{redirect:?}: a rewritten path with a query of its own keeps it alone",
);
assert_eq!(
rewritten(
CommandPathRule::regex("/api/(v[0-9]+)/(.*)"),
"/$PATH[2]",
redirect,
"/api/v2/users?id=7",
)
.as_deref(),
Some("/users?id=7"),
"{redirect:?}: a REGEX capture still spans the query string",
);
assert_eq!(
rewritten(
CommandPathRule::regex("/api/(v[0-9]+)/(.*)"),
"/$PATH[1]",
redirect,
"/api/v2/users?id=7",
)
.as_deref(),
Some("/v2"),
"{redirect:?}: a REGEX rewrite gains no query it did not capture",
);
}
}
#[test]
fn an_alternating_regex_hostname_segment_is_anchored_on_every_branch() {
let resolve_with = |pattern: &[u8], hostname: &str| {
let mut router = Router::new();
assert!(router.add_tree_rule(
pattern,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("ALTERNATION".into()),
));
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve_with(b"/a|b/.example.com", "a.example.com").as_deref(),
Some("ALTERNATION"),
"the segment must still match what it was written for",
);
assert_eq!(
resolve_with(b"/a|b/.example.com", "b.example.com").as_deref(),
Some("ALTERNATION"),
"and the other branch too",
);
assert_eq!(
resolve_with(b"/a|b/.example.com", "axx.example.com"),
None,
"the first branch must be anchored at its END too",
);
assert_eq!(
resolve_with(b"/a|b/.example.com", "xxb.example.com"),
None,
"the last branch must be anchored at its START too",
);
assert_eq!(
resolve_with(b"/a|b/.example.com", "xx.example.com"),
None,
"a label matching neither branch must still miss",
);
for whole in ["a.example.com", "b.example.com", "c.example.com"] {
assert_eq!(
resolve_with(b"/a|b|c/.example.com", whole).as_deref(),
Some("ALTERNATION"),
"{whole} is a whole-label match for one branch and must route",
);
}
for leak in [
"axx.example.com",
"xxc.example.com",
"zzbzz.example.com",
"bzz.example.com",
"zzb.example.com",
] {
assert_eq!(
resolve_with(b"/a|b|c/.example.com", leak),
None,
"{leak} matches no branch WHOLE and must not route",
);
}
let two = Regex::new("\\Aa|b\\z").expect("the ungrouped wrapper compiles");
for anywhere in ["axx", "xxb"] {
assert!(
two.is_match(anywhere.as_bytes()),
"{} leaks on {anywhere}",
two.as_str(),
);
}
let ungrouped = Regex::new("\\Aa|b|c\\z").expect("the ungrouped wrapper compiles");
for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
assert!(
ungrouped.is_match(anywhere.as_bytes()),
"{} leaks on {anywhere}",
ungrouped.as_str(),
);
}
let grouped = Regex::new("\\A(?:a|b|c)\\z").expect("the grouped wrapper compiles");
for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
assert!(
!grouped.is_match(anywhere.as_bytes()),
"{} must refuse {anywhere}",
grouped.as_str(),
);
}
}
fn regex_host_routes(position: RulePosition, pattern: &str, hostname: &str) -> Option<String> {
let mut front = test_http_frontend();
front.hostname = pattern.to_owned();
front.position = position;
front.cluster_id = Some("REGEX-HOST".into());
let mut router = Router::new();
router
.add_http_front(&front)
.unwrap_or_else(|error| panic!("the regex hostname {pattern:?} must build: {error}"));
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
.map(|cluster_id| cluster_id.to_string())
}
#[test]
fn every_branch_of_an_alternating_regex_hostname_rule_is_anchored() {
for position in [RulePosition::Pre, RulePosition::Post] {
for whole in ["a.example.com", "b.example.com", "c.example.com"] {
assert_eq!(
regex_host_routes(position, "/a|b|c/.example.com", whole).as_deref(),
Some("REGEX-HOST"),
"{position:?}: {whole} is a whole-host match for one branch and must route",
);
}
for leak in [
"axx.example.com",
"xxc.example.com",
"zzbzz.example.com",
"b.example.com.evil.org",
"zzbzz.evil.org",
] {
assert_eq!(
regex_host_routes(position, "/a|b|c/.example.com", leak),
None,
"{position:?}: {leak} matches no branch WHOLE and must not route",
);
}
}
assert_eq!(
convert_regex_domain_rule("/a|b|c/.example.com")
.expect("an alternating hostname segment must convert"),
"\\A(?:a|b|c)\\.example\\.com\\z",
"each regex segment must be wrapped in a NON-capturing group",
);
for (assembly, leaks) in [
(
"\\Aa|b|c\\.example\\.com\\z",
&[
"axx.example.com",
"zzbzz.example.com",
"b.example.com.evil.org",
"zzbzz.evil.org",
][..],
),
(
"\\Aapi|admin\\.example\\.com\\z",
&["apifoo.example.com", "xadmin.example.com"][..],
),
] {
let ungrouped = Regex::new(assembly).expect("the ungrouped assembly compiles");
for anywhere in leaks {
assert!(
ungrouped.is_match(anywhere.as_bytes()),
"{} leaks on {anywhere}",
ungrouped.as_str(),
);
}
}
for (pattern, hostname, must_route) in [
("/api|admin/.example.com", "api.example.com", true),
("/api|admin/.example.com", "admin.example.com", true),
("/api|admin/.example.com", "apifoo.example.com", false),
("/api|admin/.example.com", "xadmin.example.com", false),
("/api.*|.*admin/.example.com", "api.example.com", true),
("/api.*|.*admin/.example.com", "admin.example.com", true),
("/api.*|.*admin/.example.com", "apifoo.example.com", true),
("/api.*|.*admin/.example.com", "xadmin.example.com", true),
] {
assert_eq!(
regex_host_routes(RulePosition::Pre, pattern, hostname).is_some(),
must_route,
"hostname regex {pattern:?} against {hostname:?}",
);
}
let compiled = |pattern: &str| {
Regex::new(&convert_regex_domain_rule(pattern).expect("the hostname must convert"))
.expect("the assembled hostname regex must compile")
};
assert_eq!(
compiled("/a|b|c/.example.com").captures_len(),
1,
"a pattern with no operator group must stay at the implicit group 0 alone",
);
assert_eq!(
compiled("/cdn([0-9]+)/.example.com").captures_len(),
2,
"the operator's own group must stay at index 1",
);
}
#[test]
fn a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post() {
for (position, host, must_route) in [
(RulePosition::Tree, "zz.x.example.com", true),
(RulePosition::Tree, "*.x.example.com", true),
(RulePosition::Pre, "zz.x.example.com", false),
(RulePosition::Pre, "*.x.example.com", true),
(RulePosition::Post, "zz.x.example.com", false),
(RulePosition::Post, "*.x.example.com", true),
] {
assert_eq!(
regex_host_routes(position, "*./x/.example.com", host).is_some(),
must_route,
"{position:?}: `*./x/.example.com` against {host:?}",
);
}
for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
for miss in ["x.example.com", "a.b.x.example.com"] {
assert_eq!(
regex_host_routes(position, "*./x/.example.com", miss),
None,
"{position:?}: a `*` label is exactly one label, so {miss:?} misses",
);
}
}
let quantified =
Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
for anywhere in [
"evil.attacker.x.example.com",
"anything.at.all.x.example.com",
"zzz.x.example.com",
] {
assert!(
quantified.is_match(anywhere.as_bytes()),
"{} took {anywhere} at any depth",
quantified.as_str(),
);
}
assert!(
!quantified.is_match(b"x.example.com"),
"{} still required a leading label",
quantified.as_str(),
);
for position in [RulePosition::Pre, RulePosition::Post] {
for (host, must_route) in [
("zz.x.example.com", true),
("*.x.example.com", true),
("a.b.x.example.com", false),
("x.example.com", false),
] {
assert_eq!(
regex_host_routes(position, "/[^.]*/./x/.example.com", host).is_some(),
must_route,
"{position:?}: the documented `Pre`/`Post` remediation against {host:?}",
);
}
}
}
#[test]
fn a_bare_star_hostname_is_a_catch_all_on_post_and_one_label_on_the_trie() {
for host in [
"127.0.0.1",
"localhost",
"penguin.linux.test",
"10.0.0.7",
"www.example.com",
] {
assert_eq!(
regex_host_routes(RulePosition::Post, "*", host).as_deref(),
Some("REGEX-HOST"),
"Post: `*` is the catch-all and must route {host:?}",
);
}
for (host, must_route) in [
("localhost", true),
("127.0.0.1", false),
("penguin.linux.test", false),
("www.example.com", false),
] {
assert_eq!(
regex_host_routes(RulePosition::Tree, "*", host).is_some(),
must_route,
"Tree: `*` is exactly one label, against {host:?}",
);
}
let mut router = Router::new();
for (hostname, position, cluster_id) in [
("*", RulePosition::Post, "CATCH-ALL"),
("www.example.com", RulePosition::Tree, "EXAMPLE"),
] {
let mut front = test_http_frontend();
front.hostname = hostname.to_owned();
front.position = position;
front.cluster_id = Some(cluster_id.into());
router
.add_http_front(&front)
.unwrap_or_else(|error| panic!("{hostname:?} must build: {error}"));
}
for (host, expected) in [
("www.example.com", "EXAMPLE"),
("127.0.0.1", "CATCH-ALL"),
("api.example.com", "CATCH-ALL"),
] {
assert_eq!(
router
.lookup(host, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
.as_deref(),
Some(expected),
"a `Post` catch-all answers only what the trie did not: {host:?}",
);
}
}
#[test]
fn a_wildcard_in_a_hostname_segment_crosses_labels_on_pre_and_post_only() {
for (hostname, must_route) in [
("api.example.com", true),
("admin.example.com", true),
("apifoo.example.com", true),
("api.foo.example.com", false),
("zz.admin.example.com", false),
] {
assert_eq!(
regex_host_routes(RulePosition::Tree, "/api.*|.*admin/.example.com", hostname)
.is_some(),
must_route,
"trie rule `/api.*|.*admin/` against {hostname:?}",
);
}
for position in [RulePosition::Pre, RulePosition::Post] {
for hostname in [
"api.example.com",
"admin.example.com",
"apifoo.example.com",
"api.foo.example.com",
"zz.admin.example.com",
] {
assert!(
regex_host_routes(position, "/api.*|.*admin/.example.com", hostname).is_some(),
"{position:?}: `.*` spans the label separator, so {hostname:?} routes",
);
}
}
for position in [RulePosition::Pre, RulePosition::Post] {
for (hostname, must_route) in [
("api.example.com", true),
("admin.example.com", true),
("apifoo.example.com", true),
("xadmin.example.com", true),
("api.foo.example.com", false),
("zz.admin.example.com", false),
] {
assert_eq!(
regex_host_routes(position, "/api[^.]*|[^.]*admin/.example.com", hostname,)
.is_some(),
must_route,
"{position:?}: `[^.]*` spells the boundary out, {hostname:?}",
);
}
}
}
#[test]
fn a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern() {
for (hostname, must_route) in [("a.x|y.com", true), ("a.xZZZ", false), ("ZZZy.com", false)]
{
assert_eq!(
regex_host_routes(RulePosition::Pre, "/a/.x|y.com", hostname).is_some(),
must_route,
"literal label `x|y` against {hostname:?}",
);
}
assert_eq!(
convert_regex_domain_rule("/a/.x|y.com").expect("the hostname must convert"),
"\\A(?:a)\\.x\\|y\\.com\\z",
"a literal label must be escaped, not grouped and not pushed raw",
);
let unescaped = Regex::new("\\A(?:a)\\.x|y\\.com\\z").expect("the raw assembly compiles");
for anywhere in ["a.xZZZ", "ZZZy.com"] {
assert!(
unescaped.is_match(anywhere.as_bytes()),
"{} leaks on {anywhere}",
unescaped.as_str(),
);
}
assert_eq!(
convert_regex_domain_rule("/a/.my-host01.example.com")
.expect("an LDH hostname must convert"),
"\\A(?:a)\\.my\\-host01\\.example\\.com\\z",
"escape only adds a backslash before the `-`",
);
for (hostname, must_route) in [
("a.my-host01.example.com", true),
("a.myXhost01.example.com", false),
("a.my-host01.example.comZZZ", false),
] {
assert_eq!(
regex_host_routes(RulePosition::Pre, "/a/.my-host01.example.com", hostname)
.is_some(),
must_route,
"escaped LDH label against {hostname:?}",
);
}
let grouped_not_escaped =
Regex::new("\\A(?:a)\\.(?:b+c)\\.com\\z").expect("the grouped assembly compiles");
assert!(
grouped_not_escaped.is_match(b"a.bbbc.com"),
"{} still repeats the literal `b`",
grouped_not_escaped.as_str(),
);
for (hostname, must_route) in [("a.b+c.com", true), ("a.bbbc.com", false)] {
assert_eq!(
regex_host_routes(RulePosition::Pre, "/a/.b+c.com", hostname).is_some(),
must_route,
"escaped literal label `b+c` against {hostname:?}",
);
}
let star_unescaped =
Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
assert!(
star_unescaped.is_match(b"evil.attacker.x.example.com"),
"{} is not anchored at its start",
star_unescaped.as_str(),
);
assert_eq!(
regex_host_routes(
RulePosition::Pre,
"*./x/.example.com",
"evil.attacker.x.example.com",
),
None,
"a literal `*` label must no longer leave the pattern unanchored",
);
assert_eq!(
regex_host_routes(RulePosition::Pre, "*./x/.example.com", "*.x.example.com").as_deref(),
Some("REGEX-HOST"),
"it matches the literal label the operator wrote, and only that",
);
for bracket in ["(", "["] {
let hostname = format!("/a/.b{bracket}c.com");
assert!(
convert_regex_domain_rule(&hostname).is_some(),
"{hostname} must convert",
);
assert_eq!(
regex_host_routes(RulePosition::Pre, &hostname, &format!("a.b{bracket}c.com"))
.as_deref(),
Some("REGEX-HOST"),
"{hostname} must match the literal host it spells",
);
assert_eq!(
regex_host_routes(RulePosition::Pre, &hostname, "a.bc.com"),
None,
"{hostname} must match nothing else",
);
}
}
#[test]
fn a_host_capture_rewrite_resolves_to_the_operators_own_group() {
let rewritten_host = |position: RulePosition, template: &str, hostname: &str| {
let mut front = test_http_frontend();
front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
front.position = position;
front.rewrite_host = Some(template.to_owned());
let mut router = Router::new();
router
.add_http_front(&front)
.unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.rewritten_host)
};
for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
assert_eq!(
rewritten_host(position, "$HOST[1].internal", "cdn42.example.com").as_deref(),
Some("42.internal"),
"{position:?}: $HOST[1] must be the operator's own group, not the wrapper's",
);
assert_eq!(
rewritten_host(position, "$HOST[0]", "cdn42.example.com").as_deref(),
Some("cdn42.example.com"),
"{position:?}: $HOST[0] is the whole hostname",
);
}
let mut front = test_http_frontend();
front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
front.rewrite_host = Some("$HOST[2]".to_owned());
assert!(
matches!(
Router::new().add_http_front(&front),
Err(RouterError::InvalidHostRewrite(_)),
),
"a $HOST index past captures_len must be refused at registration",
);
}
#[test]
fn a_tree_lookup_allocates_nothing_past_its_route_result() {
use std::hint::black_box;
use crate::test_allocations::allocations;
let mut router = Router::new();
for hostname in [
"literal.example.com",
"*.wildcard.example.com",
"/api[0-9]+/.regex.example.com",
] {
let mut front = test_http_frontend();
front.hostname = hostname.to_owned();
router
.add_http_front(&front)
.unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
}
let cluster = ClusterId::from("cluster");
let control = || {
let before = allocations();
let result = RouteResult::forward(black_box(&cluster).clone());
let allocated = allocations() - before;
drop(black_box(result));
allocated
};
for host in [
"literal.example.com",
"foo.wildcard.example.com",
"api42.regex.example.com",
] {
drop(router.lookup(host, "/", &Method::Get));
let before = allocations();
let result = router.lookup(black_box(host), black_box("/"), &Method::Get);
let allocated = allocations() - before;
assert_eq!(
result.ok().and_then(|result| result.cluster_id).as_deref(),
Some("cluster"),
"{host} must route to the test cluster",
);
assert_eq!(
allocated,
control(),
"{host}: a tree lookup must allocate only its RouteResult",
);
}
}
#[test]
fn a_host_capture_past_sixteen_trie_segments_keeps_every_segment() {
const SEGMENTS: usize = 20;
let hostname = format!(
"{}.example.com",
(1..=SEGMENTS)
.map(|_| "/s([0-9]+)/")
.collect::<Vec<_>>()
.join(".")
);
let request = format!(
"{}.example.com",
(1..=SEGMENTS)
.map(|segment| format!("s{segment}"))
.collect::<Vec<_>>()
.join(".")
);
let template = (1..=SEGMENTS)
.map(|index| format!("$HOST[{index}]"))
.collect::<Vec<_>>()
.join("-");
let mut front = test_http_frontend();
front.hostname = hostname;
front.position = RulePosition::Tree;
front.rewrite_host = Some(template);
let mut router = Router::new();
router
.add_http_front(&front)
.unwrap_or_else(|error| panic!("the tree frontend must build: {error}"));
let hostname_order = (1..=SEGMENTS)
.map(|segment| segment.to_string())
.collect::<Vec<_>>()
.join("-");
assert_eq!(
router
.lookup(&request, "/", &Method::Get)
.ok()
.and_then(|result| result.rewritten_host),
Some(hostname_order),
"every trie segment, inline or spilled, must reach $HOST[n] in hostname order",
);
}
#[test]
fn a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule() {
let rewritten_host = |position: RulePosition| {
let mut front = test_http_frontend();
front.hostname = "/a([0-9]+)/./([a-z])([0-9])/.example.com".to_owned();
front.position = position;
front.rewrite_host = Some("$HOST[1]-$HOST[2]-$HOST[3]".to_owned());
let mut router = Router::new();
router
.add_http_front(&front)
.unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
router
.lookup("a1.x9.example.com", "/", &Method::Get)
.ok()
.and_then(|result| result.rewritten_host)
};
for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
assert_eq!(
rewritten_host(position).as_deref(),
Some("1-x-9"),
"{position:?}: $HOST[n] must number the captures left to right",
);
}
}
#[test]
fn grouping_never_turns_a_rejected_hostname_regex_into_an_accepted_one() {
let unbalanced = format!("/a{}b/.example.com", ")(");
assert_eq!(
convert_regex_domain_rule(&unbalanced),
None,
"{unbalanced} carries a segment that is not a regex on its own",
);
assert_eq!(
unbalanced.parse::<DomainRule>(),
Err(()),
"{unbalanced} must not become a usable DomainRule",
);
assert!(
!Router::new().add_tree_rule(
unbalanced.as_bytes(),
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("RESCUED".into()),
),
"{unbalanced} must not be storable in the trie either",
);
let rescued = Regex::new(&format!("\\A(?:a{}b)\\.example\\.com\\z", ")("))
.expect("the wrapping balances the segment");
assert!(
rescued.is_match(b"ab.example.com"),
"{} is what the missing pre-validation would have built",
rescued.as_str(),
);
}
#[test]
fn the_first_declared_regex_hostname_segment_wins_over_a_later_overlapping_one() {
let numbered: &[u8] = b"/test[0-9]/.example.com";
let any_suffixed: &[u8] = b"/[a-z]+[0-9]/.example.com";
let declare = |first: &[u8], first_cluster: &str, second: &[u8], second_cluster: &str| {
let mut router = Router::new();
for (hostname, cluster) in [(first, first_cluster), (second, second_cluster)] {
assert!(router.add_tree_rule(
hostname,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId(cluster.into()),
));
}
router
.lookup("test4.example.com", "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
declare(numbered, "NUMBERED", any_suffixed, "ANY-SUFFIXED").as_deref(),
Some("NUMBERED"),
"the first-declared regex hostname segment must win",
);
assert_eq!(
declare(any_suffixed, "ANY-SUFFIXED", numbered, "NUMBERED").as_deref(),
Some("ANY-SUFFIXED"),
"reversing the declaration order reverses the winner",
);
let mut router = Router::new();
assert!(router.add_tree_rule(
numbered,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("REGIONAL".into()),
));
assert_eq!(
router
.lookup("test4.example.com", "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
.as_deref(),
Some("REGIONAL"),
);
for miss in [
"testAB.example.com",
"test44.example.com",
"xtest4.example.com",
] {
assert!(
router.lookup(miss, "/", &Method::Get).is_err(),
"{miss} must not match the single-digit anchored segment",
);
}
}
#[test]
fn an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"/test[0-9]/.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("REGEX-FAMILY".into()),
));
assert!(router.add_tree_rule(
b"test4.example.com",
&PathRule::Prefix("/only-for-test4".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("EXACT-SPECIFIC".into()),
));
let resolve = |hostname: &str, path: &str| {
router
.lookup(hostname, path, &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve("test4.example.com", "/only-for-test4").as_deref(),
Some("EXACT-SPECIFIC"),
"the host the rule was written for must get it",
);
for leaked in ["test7.example.com", "test9.example.com"] {
assert_eq!(
resolve(leaked, "/only-for-test4").as_deref(),
Some("REGEX-FAMILY"),
"{leaked} must keep the family route and never reach the \
rule scoped to test4",
);
}
assert_eq!(
resolve("test4.example.com", "/").as_deref(),
Some("REGEX-FAMILY"),
"the exact node carries only /only-for-test4, so every other \
path on that host falls through to the regex segment that \
also claims it",
);
}
#[test]
fn hostname_routing_does_not_depend_on_declaration_order() {
let exact: (&[u8], &str, &str) =
(b"test4.example.com", "/only-for-test4", "EXACT-SPECIFIC");
let regex: (&[u8], &str, &str) = (b"/test[0-9]/.example.com", "/", "REGEX-FAMILY");
let routes = |declared: [(&[u8], &str, &str); 2]| {
let mut router = Router::new();
for (hostname, path, cluster) in declared {
assert!(router.add_tree_rule(
hostname,
&PathRule::Prefix(path.to_owned()),
&MethodRule::new(None),
&Route::ClusterId(cluster.into()),
));
}
[
("test4.example.com", "/only-for-test4"),
("test4.example.com", "/"),
("test7.example.com", "/only-for-test4"),
("test9.example.com", "/"),
]
.map(|(hostname, path)| {
router
.lookup(hostname, path, &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
})
};
let regex_first = routes([regex, exact]);
let exact_first = routes([exact, regex]);
assert_eq!(
regex_first, exact_first,
"hostname resolution must not depend on the order the \
frontends were added in",
);
assert_eq!(
regex_first[0].as_deref(),
Some("EXACT-SPECIFIC"),
"and the order-independent answer is the exact host's own rule",
);
assert_eq!(
regex_first[2].as_deref(),
Some("REGEX-FAMILY"),
"while a sibling of the regex family never sees it",
);
}
#[test]
fn hostname_precedence_is_exact_then_regex_then_wildcard() {
let exact: &[u8] = b"test4.example.com";
let regex: &[u8] = b"/test[0-9]/.example.com";
let wildcard: &[u8] = b"*.example.com";
let declare = |hostnames: &[(&[u8], &str)]| {
let mut router = Router::new();
for (hostname, cluster) in hostnames {
assert!(router.add_tree_rule(
hostname,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId((*cluster).into()),
));
}
router
.lookup("test4.example.com", "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
declare(&[(regex, "REGEX"), (exact, "EXACT")]).as_deref(),
Some("EXACT"),
"an exact hostname outranks a regex segment that matches it",
);
assert_eq!(
declare(&[(wildcard, "WILDCARD"), (exact, "EXACT")]).as_deref(),
Some("EXACT"),
"an exact hostname outranks the wildcard",
);
assert_eq!(
declare(&[(wildcard, "WILDCARD"), (regex, "REGEX")]).as_deref(),
Some("REGEX"),
"a regex segment outranks the wildcard",
);
assert_eq!(
declare(&[(regex, "REGEX"), (wildcard, "WILDCARD")]).as_deref(),
Some("REGEX"),
"and it outranks it in the other declaration order too",
);
assert_eq!(
declare(&[(wildcard, "WILDCARD"), (regex, "REGEX"), (exact, "EXACT")]).as_deref(),
Some("EXACT"),
"with all three declared the most specific one answers",
);
assert_eq!(
declare(&[(wildcard, "WILDCARD")]).as_deref(),
Some("WILDCARD"),
"the wildcard still answers when nothing narrower claims the host",
);
}
#[test]
fn a_hostname_candidate_that_serves_no_rule_falls_through_to_the_next() {
let mut router = Router::new();
for (hostname, path, cluster) in [
(&b"test4.example.com"[..], "/exact-only", "EXACT"),
(&b"/test[0-9]/.example.com"[..], "/regex-only", "REGEX"),
(&b"*.example.com"[..], "/", "WILDCARD"),
] {
assert!(router.add_tree_rule(
hostname,
&PathRule::Prefix(path.to_owned()),
&MethodRule::new(None),
&Route::ClusterId(cluster.into()),
));
}
let resolve = |hostname: &str, path: &str| {
router
.lookup(hostname, path, &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve("test4.example.com", "/exact-only").as_deref(),
Some("EXACT"),
);
assert_eq!(
resolve("test4.example.com", "/regex-only").as_deref(),
Some("REGEX"),
"the exact node carries no rule for this path, so the regex \
segment that also claims the host answers",
);
assert_eq!(
resolve("test4.example.com", "/served-by-neither").as_deref(),
Some("WILDCARD"),
"and when neither serves it the wildcard does",
);
assert_eq!(
resolve("testA.example.com", "/regex-only").as_deref(),
Some("WILDCARD"),
"a host the regex segment does not match never reaches it",
);
}
#[test]
fn a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"/test[0-9]/.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("POST".to_owned())),
&Route::ClusterId("POST-ONLY".into()),
));
assert!(router.add_tree_rule(
b"/[a-z]+[0-9]/.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("GET-ONLY".into()),
));
let resolve = |method: &Method| {
router
.lookup("test4.example.com", "/", method)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve(&Method::Get).as_deref(),
Some("GET-ONLY"),
"the first-declared segment carries no GET rule, so the second \
one answers instead of the request 404ing",
);
assert_eq!(
resolve(&Method::Post).as_deref(),
Some("POST-ONLY"),
"and the first-declared segment still wins whenever it serves \
the request",
);
}
#[test]
fn an_equals_path_frontend_is_deduplicated_and_removable() {
assert_eq!(
PathRule::Equals("/exact".to_owned()),
PathRule::Equals("/exact".to_owned()),
"PathRule equality must be reflexive for the Equals variant",
);
let mut router = Router::new();
let mut front = test_http_frontend();
front.path = CommandPathRule::equals("/exact".to_owned());
router
.add_http_front(&front)
.expect("an Equals frontend must be added");
assert!(
router.lookup("example.com", "/exact", &Method::Get).is_ok(),
"the Equals frontend must resolve once added",
);
assert!(
matches!(router.add_http_front(&front), Err(RouterError::AddRoute(_))),
"re-adding the same Equals frontend must be refused, not duplicated",
);
router
.remove_http_front(&front)
.expect("an Equals frontend must be removable");
assert!(
router
.lookup("example.com", "/exact", &Method::Get)
.is_err(),
"the Equals frontend must no longer resolve once removed",
);
}
#[test]
fn an_uppercase_host_reaches_a_lowercase_tree_frontend() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"www.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("CASE".into()),
));
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
for host in [
"www.example.com",
"WWW.EXAMPLE.COM",
"WwW.ExAmPlE.cOm",
"www.EXAMPLE.com",
] {
assert_eq!(
resolve(host).as_deref(),
Some("CASE"),
"Host {host:?} names the same frontend as its lowercase form",
);
}
}
#[test]
fn an_uppercase_tree_frontend_is_stored_and_looked_up_lowercase() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"WWW.EXAMPLE.COM",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("CASE".into()),
));
for host in ["www.example.com", "WWW.EXAMPLE.COM"] {
assert_eq!(
router
.lookup(host, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
.as_deref(),
Some("CASE"),
"an uppercase frontend is stored lowercase, so {host:?} must resolve",
);
}
}
#[test]
fn an_uppercase_host_reaches_wildcard_and_regex_tree_segments() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"*.wild.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("WILDCARD".into()),
));
assert!(router.add_tree_rule(
b"/API[0-9]/.rx.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("REGEX".into()),
));
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(resolve("FOO.WILD.EXAMPLE.COM").as_deref(), Some("WILDCARD"));
assert_eq!(resolve("FoO.wild.EXAMPLE.com").as_deref(), Some("WILDCARD"));
assert_eq!(
resolve("API7.RX.EXAMPLE.COM").as_deref(),
Some("REGEX"),
"the operator's `/API[0-9]/` is stored verbatim and compiled \
case-insensitively, so the normalised key matches it",
);
assert_eq!(resolve("api7.rx.example.com").as_deref(), Some("REGEX"));
}
#[test]
fn an_uppercase_host_reaches_exact_and_wildcard_pre_post_rules() {
let mut router = Router::new();
let exact = "pre.example.com"
.parse::<DomainRule>()
.expect("an exact domain rule must parse");
let wildcard = "*.post.example.com"
.parse::<DomainRule>()
.expect("a wildcard domain rule must parse");
assert!(router.add_pre_rule(
&exact,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("PRE".into()),
));
assert!(router.add_post_rule(
&wildcard,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("POST".into()),
));
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(resolve("PRE.EXAMPLE.COM").as_deref(), Some("PRE"));
assert_eq!(resolve("PrE.ExAmPlE.cOm").as_deref(), Some("PRE"));
assert_eq!(resolve("FOO.POST.EXAMPLE.COM").as_deref(), Some("POST"));
assert_eq!(resolve("foo.post.example.com").as_deref(), Some("POST"));
}
#[test]
fn an_uppercase_pre_rule_hostname_regex_still_matches_its_own_host() {
let mut router = Router::new();
let uppercase_regex = "/API[0-9]/.example.com"
.parse::<DomainRule>()
.expect("an uppercase hostname regex must parse");
assert!(router.add_pre_rule(
&uppercase_regex,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("RX".into()),
));
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve("API7.example.com").as_deref(),
Some("RX"),
"the spelling the operator wrote must keep routing",
);
assert_eq!(
resolve("api7.example.com").as_deref(),
Some("RX"),
"and its canonical lowercase form, which never routed before",
);
}
#[test]
fn a_unicode_class_hostname_regex_still_compiles_and_routes() {
let mut router = Router::new();
let unicode_class = "/\\p{L}+[0-9]/.example.com"
.parse::<DomainRule>()
.expect("a hostname regex using a Unicode class must still compile");
assert!(router.add_pre_rule(
&unicode_class,
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("UNICODE".into()),
));
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve("api7.example.com").as_deref(),
Some("UNICODE"),
"a Unicode letter class covers ASCII letters, so this rule is live, not decorative",
);
assert_eq!(
resolve("API7.example.com").as_deref(),
Some("UNICODE"),
"and it stays live for the uppercase spelling of the same host",
);
}
#[test]
fn case_normalisation_runs_before_the_hostname_precedence_search() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"test4.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("EXACT".into()),
));
assert!(router.add_tree_rule(
b"/test[0-9]/.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("REGEX-FAMILY".into()),
));
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve("test4.example.com").as_deref(),
Some("EXACT"),
"the exact name outranks a regex segment that also claims it",
);
for spelling in ["TEST4.EXAMPLE.COM", "TeSt4.ExAmPlE.cOm"] {
assert_eq!(
resolve(spelling).as_deref(),
Some("EXACT"),
"{spelling:?} must pick the same tier as its lowercase form, \
not fall through to the regex family",
);
}
assert_eq!(
resolve("test7.example.com").as_deref(),
Some("REGEX-FAMILY")
);
assert_eq!(
resolve("TEST7.EXAMPLE.COM").as_deref(),
Some("REGEX-FAMILY")
);
}
#[test]
fn has_hostname_answers_for_an_uppercase_spelling() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"tree.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("TREE".into()),
));
assert!(
router.add_pre_rule(
&"pre.example.com"
.parse::<DomainRule>()
.expect("an exact domain rule must parse"),
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("PRE".into()),
)
);
assert!(router.has_hostname("TREE.EXAMPLE.COM"));
assert!(router.has_hostname("PRE.EXAMPLE.COM"));
assert!(!router.has_hostname("ABSENT.EXAMPLE.COM"));
}
#[test]
fn a_host_rewrite_capture_sees_the_normalized_host() {
let mut router = Router::new();
router
.add_http_front(&HttpFrontend {
hostname: "rewrite.example.com".to_owned(),
rewrite_host: Some("$HOST[0].internal".to_owned()),
..test_http_frontend()
})
.expect("the rewrite frontend must install");
let route = router
.lookup("REWRITE.EXAMPLE.COM", "/", &Method::Get)
.expect("an uppercase Host must resolve");
assert_eq!(
route.rewritten_host.as_deref(),
Some("rewrite.example.com.internal"),
"`$HOST[0]` carries the normalised host, not the client's bytes",
);
}
#[test]
fn normalize_hostname_borrows_an_already_lowercase_host() {
assert!(
matches!(normalize_hostname("www.example.com"), Cow::Borrowed(_)),
"a lowercase host must not allocate",
);
assert!(
matches!(normalize_hostname("123.example-host.com"), Cow::Borrowed(_)),
"digits and hyphens are not uppercase and must not allocate",
);
assert_eq!(
normalize_hostname("WwW.ExAmPlE.cOm"),
Cow::Owned::<str>("www.example.com".to_owned()),
);
}
#[test]
fn an_absolute_form_host_does_not_reach_a_relative_frontend_yet() {
let mut router = Router::new();
assert!(router.add_tree_rule(
b"www.example.com",
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(Some("GET".to_owned())),
&Route::ClusterId("CASE".into()),
));
assert!(
router
.lookup("www.example.com.", "/", &Method::Get)
.is_err(),
"absolute-form Host routing is a separate defect, tracked \
apart from the case asymmetry",
);
assert!(
router
.lookup("WWW.EXAMPLE.COM.", "/", &Method::Get)
.is_err(),
"normalising the case must not accidentally close it either",
);
}
#[test]
fn a_tree_hostname_regex_escape_is_not_case_folded() {
let mut router = Router::new();
assert!(router.add_tree_rule(
"/\\D+/.rx.example.com".as_bytes(),
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("NOT-A-DIGIT".into()),
));
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve("777.rx.example.com"),
None,
"`\\D+` must NOT match a run of digits; folded to `\\d+` it matched \
exactly this and nothing else",
);
assert_eq!(
resolve("abc.rx.example.com").as_deref(),
Some("NOT-A-DIGIT"),
"`\\D+` is \"one or more non-digits\" and `abc` is three of them",
);
}
#[test]
fn every_inverting_uppercase_regex_escape_survives_a_tree_insert() {
for (segment, hit, miss) in [
("[^\\D]+", "777", "abc"),
("\\W+", "---", "abc"),
("\\S+", "abc", " "),
] {
let mut router = Router::new();
let hostname = format!("/{segment}/.rx.example.com");
assert!(
router.add_tree_rule(
hostname.as_bytes(),
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("ESCAPE".into()),
),
"{hostname} must install",
);
let resolve = |hostname: &str| {
router
.lookup(hostname, "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
};
assert_eq!(
resolve(&format!("{hit}.rx.example.com")).as_deref(),
Some("ESCAPE"),
"{segment} must match {hit}",
);
assert_eq!(
resolve(&format!("{miss}.rx.example.com")),
None,
"{segment} must not match {miss}",
);
}
}
#[test]
fn a_unicode_label_is_punycoded_while_its_sibling_regex_segment_is_not() {
let hostname = "/\\D+/.MÜNCHEN.example.com";
let mut router = Router::new();
assert!(router.add_tree_rule(
hostname.as_bytes(),
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
&Route::ClusterId("IDN".into()),
));
assert_eq!(
router
.lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
.ok()
.and_then(|result| result.cluster_id)
.as_deref(),
Some("IDN"),
"the literal label must still be punycoded and lowercased",
);
assert!(
router.remove_tree_rule(
hostname.as_bytes(),
&PathRule::Prefix("/".to_owned()),
&MethodRule::new(None),
),
"the frontend must still be addressable by the spelling that \
installed it",
);
assert!(
router
.lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
.is_err(),
"and removing it must actually unroute it",
);
}
struct RegexCase {
source: &'static str,
hits: &'static [&'static str],
misses: &'static [&'static str],
}
const REGEX_CATALOG: &[RegexCase] = &[
RegexCase {
source: "a|b",
hits: &["a", "b"],
misses: &["axx", "xxb", "xx"],
},
RegexCase {
source: "a|b|c",
hits: &["a", "b", "c"],
misses: &["axx", "xxc", "zzbzz", "bzz", "zzb"],
},
RegexCase {
source: "api|admin",
hits: &["api", "admin"],
misses: &["apifoo", "xadmin"],
},
RegexCase {
source: "\\D+",
hits: &["abc", "xyz"],
misses: &["777", "42"],
},
RegexCase {
source: "\\W+",
hits: &["---", "..."],
misses: &["abc", "42a"],
},
RegexCase {
source: "\\S+",
hits: &["abc", "777"],
misses: &[" "],
},
RegexCase {
source: "[^\\D]+",
hits: &["777", "42"],
misses: &["abc"],
},
RegexCase {
source: "API[0-9]",
hits: &["api7", "API7", "ApI4"],
misses: &["apiZ"],
},
RegexCase {
source: "test[0-9]",
hits: &["test4", "TEST7", "Test9"],
misses: &["testA", "test10x"],
},
RegexCase {
source: "[a-z]+[0-9]",
hits: &["test4", "cdn1"],
misses: &["4test", "TEST"],
},
];
const LITERAL_POOL: &[&str] = &[
"a", "b", "c", "api", "admin", "test4", "test7", "test9", "TEST4", "API7", "abc", "777",
"42", "cdn1", "zz", "apifoo", "xadmin",
];
const PATH_POOL: &[&str] = &["/", "/", "/other", "/api", "/only"];
#[derive(Clone, Copy, Debug)]
enum FrontLabel {
Literal(&'static str),
Wildcard,
Regex(usize),
}
#[derive(Clone, PartialEq, Eq, Debug)]
enum FrontKey {
Literal(String),
Wildcard,
Regex(usize),
}
impl From<FrontLabel> for FrontKey {
fn from(front: FrontLabel) -> Self {
match front {
FrontLabel::Literal(text) => FrontKey::Literal(text.to_ascii_lowercase()),
FrontLabel::Wildcard => FrontKey::Wildcard,
FrontLabel::Regex(idx) => FrontKey::Regex(idx),
}
}
}
#[derive(Clone, Debug)]
struct GenRule {
front: FrontLabel,
path: &'static str,
cluster: String,
}
#[derive(Clone, Debug)]
struct Scenario {
rules: Vec<GenRule>,
queries: Vec<(String, String)>,
}
fn render_hostname(front: FrontLabel) -> String {
match front {
FrontLabel::Literal(text) => format!("{text}.example.com"),
FrontLabel::Wildcard => "*.example.com".to_owned(),
FrontLabel::Regex(idx) => format!("/{}/.example.com", REGEX_CATALOG[idx].source),
}
}
fn oracle_regex_matches(source: &str, label: &str) -> bool {
let pattern = format!("(?i)\\A(?:{source})\\z");
Regex::new(&pattern)
.unwrap_or_else(|error| {
panic!("the harness's own catalog regex {source:?} must compile: {error}")
})
.is_match(label.as_bytes())
}
fn front_label_matches(front: FrontLabel, query_label: &str) -> bool {
match front {
FrontLabel::Literal(text) => text.eq_ignore_ascii_case(query_label),
FrontLabel::Wildcard => !query_label.is_empty(),
FrontLabel::Regex(idx) => oracle_regex_matches(REGEX_CATALOG[idx].source, query_label),
}
}
fn oracle_hostname_matches(front: FrontLabel, query: &str) -> bool {
let mut labels = query.split('.');
let (Some(first), Some(second), Some(third), None) =
(labels.next(), labels.next(), labels.next(), labels.next())
else {
return false;
};
second.eq_ignore_ascii_case("example")
&& third.eq_ignore_ascii_case("com")
&& front_label_matches(front, first)
}
fn oracle_path_matches(rule_path: &str, query_path: &str) -> bool {
query_path.starts_with(rule_path)
}
fn oracle_lookup<'r>(rules: &'r [GenRule], hostname: &str, path: &str) -> Option<&'r str> {
let serves = |rule: &&GenRule| {
oracle_hostname_matches(rule.front, hostname) && oracle_path_matches(rule.path, path)
};
rules
.iter()
.find(|rule| matches!(rule.front, FrontLabel::Literal(_)) && serves(rule))
.or_else(|| {
rules
.iter()
.find(|rule| matches!(rule.front, FrontLabel::Regex(_)) && serves(rule))
})
.or_else(|| {
rules
.iter()
.find(|rule| matches!(rule.front, FrontLabel::Wildcard) && serves(rule))
})
.map(|rule| rule.cluster.as_str())
}
fn case_variant(g: &mut Gen, s: &str) -> String {
match u8::arbitrary(g) % 4 {
0 => s.to_owned(),
1 => s.to_ascii_uppercase(),
2 => s.to_ascii_lowercase(),
_ => s
.chars()
.map(|c| {
if bool::arbitrary(g) {
c.to_ascii_uppercase()
} else {
c.to_ascii_lowercase()
}
})
.collect(),
}
}
fn push_unique_front(
front: FrontLabel,
path: &'static str,
rules: &mut Vec<GenRule>,
seen: &mut Vec<FrontKey>,
) {
let key = FrontKey::from(front);
if seen.contains(&key) {
return;
}
seen.push(key);
let cluster = format!("C{}", rules.len());
rules.push(GenRule {
front,
path,
cluster,
});
}
fn push_hit_queries(
g: &mut Gen,
label: &str,
declared_paths: &[&str],
queries: &mut Vec<(String, String)>,
) {
for &path in declared_paths {
queries.push((
format!("{}.example.com", case_variant(g, label)),
path.to_owned(),
));
}
}
fn generate_queries(g: &mut Gen, rules: &[GenRule]) -> Vec<(String, String)> {
let mut queries = Vec::new();
let mut declared_paths: Vec<&str> = rules.iter().map(|rule| rule.path).collect();
declared_paths.sort_unstable();
declared_paths.dedup();
for rule in rules {
match rule.front {
FrontLabel::Literal(text) => {
push_hit_queries(g, text, &declared_paths, &mut queries);
}
FrontLabel::Wildcard => {
let label = *g.choose(LITERAL_POOL).unwrap_or(&"zz");
push_hit_queries(g, label, &declared_paths, &mut queries);
}
FrontLabel::Regex(idx) => {
let case = ®EX_CATALOG[idx];
if let Some(&hit) = g.choose(case.hits) {
push_hit_queries(g, hit, &declared_paths, &mut queries);
}
if let Some(miss) = g.choose(case.misses) {
queries.push((
format!("{}.example.com", case_variant(g, miss)),
rule.path.to_owned(),
));
}
}
}
}
queries.push((
format!("{}.example.com", case_variant(g, "unmatched-host")),
"/".to_owned(),
));
queries.push(("example.com".to_owned(), "/".to_owned()));
queries.push(("EXAMPLE.COM".to_owned(), "/".to_owned()));
queries
}
impl Arbitrary for Scenario {
fn arbitrary(g: &mut Gen) -> Self {
let mut rules = Vec::new();
let mut seen_keys = Vec::new();
let pick_path = |g: &mut Gen| {
*g.choose(PATH_POOL)
.expect("PATH_POOL is a non-empty const slice")
};
if bool::arbitrary(g) {
let path = pick_path(g);
push_unique_front(FrontLabel::Wildcard, path, &mut rules, &mut seen_keys);
}
let extra = 1 + (u8::arbitrary(g) % 3);
for _ in 0..extra {
if bool::arbitrary(g) {
let idx = usize::from(u8::arbitrary(g)) % REGEX_CATALOG.len();
let path = pick_path(g);
push_unique_front(FrontLabel::Regex(idx), path, &mut rules, &mut seen_keys);
} else {
let text = *g
.choose(LITERAL_POOL)
.expect("LITERAL_POOL is a non-empty const slice");
let path = pick_path(g);
push_unique_front(FrontLabel::Literal(text), path, &mut rules, &mut seen_keys);
}
}
for i in (1..rules.len()).rev() {
let j = usize::from(u8::arbitrary(g)) % (i + 1);
rules.swap(i, j);
}
let queries = generate_queries(g, &rules);
Scenario { rules, queries }
}
fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
let mut shrunk = Vec::new();
for i in 0..self.rules.len() {
let mut rules = self.rules.clone();
rules.remove(i);
shrunk.push(Scenario {
rules,
queries: self.queries.clone(),
});
}
for i in 0..self.queries.len() {
let mut queries = self.queries.clone();
queries.remove(i);
shrunk.push(Scenario {
rules: self.rules.clone(),
queries,
});
}
Box::new(shrunk.into_iter())
}
}
#[test]
fn regex_catalog_samples_match_their_own_documented_semantics() {
for case in REGEX_CATALOG {
for hit in case.hits {
assert!(
oracle_regex_matches(case.source, hit),
"{:?} must match {hit:?} under \\A(?:...)\\z case-insensitive",
case.source,
);
}
for miss in case.misses {
assert!(
!oracle_regex_matches(case.source, miss),
"{:?} must NOT match {miss:?} under \\A(?:...)\\z case-insensitive",
case.source,
);
}
}
}
fn oracle_matches_router(scenario: Scenario) -> TestResult {
if scenario.rules.is_empty() {
return TestResult::discard();
}
let mut router = Router::new();
for rule in &scenario.rules {
let hostname = render_hostname(rule.front);
if !router.add_tree_rule(
hostname.as_bytes(),
&PathRule::Prefix(rule.path.to_owned()),
&MethodRule::new(None),
&Route::ClusterId(rule.cluster.as_str().into()),
) {
eprintln!(
"INSERT REFUSED hostname={hostname:?} path={:?} cluster={:?} \
declared-so-far={:?}",
rule.path,
rule.cluster,
scenario
.rules
.iter()
.map(|r| (render_hostname(r.front), r.path))
.collect::<Vec<_>>(),
);
return TestResult::failed();
}
}
for (hostname, path) in &scenario.queries {
let expected = oracle_lookup(&scenario.rules, hostname, path);
let actual = router
.lookup(hostname, path, &Method::Get)
.ok()
.and_then(|result| result.cluster_id);
if expected != actual.as_deref() {
let declared: Vec<(String, &str)> = scenario
.rules
.iter()
.map(|rule| (render_hostname(rule.front), rule.path))
.collect();
eprintln!(
"MISMATCH host={hostname:?} path={path:?} expected={expected:?} \
actual={actual:?} declared (in order, with path)={declared:?}",
);
return TestResult::failed();
}
}
TestResult::passed()
}
struct TestHeaders<'a>(&'a [(&'a str, &'a str)]);
impl RequestHeaders for TestHeaders<'_> {
fn header(&self, name: &[u8]) -> Option<&[u8]> {
self.0
.iter()
.find(|(key, _)| key.as_bytes().eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_bytes())
}
}
fn predicate(name: &str, kind: HeaderMatchKind, value: Option<&str>) -> HeaderPredicate {
HeaderPredicate {
name: name.to_owned(),
kind: kind as i32,
value: value.map(str::to_owned),
}
}
fn routed_by_headers(router: &Router, path: &str, headers: &[(&str, &str)]) -> Option<String> {
router
.lookup_with_headers("example.com", path, &Method::Get, &TestHeaders(headers))
.ok()
.and_then(|route| route.cluster_id)
.map(|id| id.to_string())
}
#[test]
fn a_header_predicate_canary_outranks_its_base_in_any_declaration_order() {
let canary_predicates = vec![predicate(
"X-Canary",
HeaderMatchKind::Exact,
Some("always"),
)];
for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
for path in [
CommandPathRule::prefix("/api".to_owned()),
CommandPathRule::equals("/api".to_owned()),
] {
for canary_first in [false, true] {
let base = HttpFrontend {
cluster_id: Some("stable".into()),
path: path.clone(),
position,
..test_http_frontend()
};
let canary = HttpFrontend {
cluster_id: Some("canary".into()),
header_predicates: canary_predicates.clone(),
..base.clone()
};
let mut router = Router::new();
let order = if canary_first {
[&canary, &base]
} else {
[&base, &canary]
};
for front in order {
router
.add_http_front(front)
.expect("a canary and its base must coexist");
}
let context = format!("{position:?} {path:?} canary_first={canary_first}");
assert_eq!(
routed_by_headers(&router, "/api", &[("x-canary", "always")]).as_deref(),
Some("canary"),
"{context}: a request carrying the header must reach the canary",
);
assert_eq!(
routed_by_headers(&router, "/api", &[]).as_deref(),
Some("stable"),
"{context}: a request without the header must reach the base",
);
assert_eq!(
routed_by_headers(&router, "/api", &[("X-Canary", "Always")]).as_deref(),
Some("stable"),
"{context}: an exact value is compared byte for byte",
);
assert_eq!(
router
.lookup("example.com", "/api", &Method::Get)
.ok()
.and_then(|route| route.cluster_id)
.as_deref(),
Some("stable"),
"{context}: a header-less lookup never matches a predicated rule",
);
}
}
}
}
#[test]
fn header_predicates_hold_together_with_their_documented_semantics() {
let mut router = Router::new();
let front = HttpFrontend {
cluster_id: Some("eu-beta".into()),
header_predicates: vec![
predicate("x-region", HeaderMatchKind::Regex, Some("eu-.*")),
predicate("X-BETA", HeaderMatchKind::Present, None),
],
..test_http_frontend()
};
router.add_http_front(&front).expect("valid predicates");
router
.add_http_front(&test_http_frontend())
.expect("the base frontend");
let route = |headers: &[(&str, &str)]| routed_by_headers(&router, "/", headers);
assert_eq!(
route(&[("X-Region", "eu-west"), ("x-beta", "")]).as_deref(),
Some("eu-beta")
);
assert_eq!(
route(&[("X-Region", "eu-west")]).as_deref(),
Some("cluster"),
"every predicate must hold",
);
assert_eq!(
route(&[("X-Region", "us-eu-west"), ("x-beta", "1")]).as_deref(),
Some("cluster"),
"a regex is anchored at the start of the value",
);
assert_eq!(
route(&[
("X-Region", "us-east"),
("X-Region", "eu-west"),
("x-beta", "1")
])
.as_deref(),
Some("cluster"),
"only the first occurrence of a header answers",
);
}
#[test]
fn removing_a_predicated_frontend_leaves_its_base_and_the_reverse() {
for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
let base = HttpFrontend {
cluster_id: Some("stable".into()),
position,
..test_http_frontend()
};
let canary = HttpFrontend {
cluster_id: Some("canary".into()),
header_predicates: vec![
predicate("X-Canary", HeaderMatchKind::Present, None),
predicate("X-Tenant", HeaderMatchKind::Exact, Some("acme")),
],
..base.clone()
};
let canary_respelled = HttpFrontend {
header_predicates: vec![
predicate("x-tenant", HeaderMatchKind::Exact, Some("acme")),
predicate("x-canary", HeaderMatchKind::Present, None),
],
..canary.clone()
};
let canary_headers = [("X-Canary", "1"), ("X-Tenant", "acme")];
let mut router = Router::new();
router.add_http_front(&base).expect("base");
router.add_http_front(&canary).expect("canary");
assert!(
router.add_http_front(&canary_respelled).is_err(),
"{position:?}: a respelled conjunction is the same rule",
);
router
.remove_http_front(&canary_respelled)
.expect("a respelled removal must find the canary");
assert_eq!(
routed_by_headers(&router, "/", &canary_headers).as_deref(),
Some("stable"),
"{position:?}: the base must survive the canary's removal",
);
router.add_http_front(&canary).expect("canary again");
router.remove_http_front(&base).expect("base removal");
assert_eq!(
routed_by_headers(&router, "/", &canary_headers).as_deref(),
Some("canary"),
"{position:?}: the canary must survive the base's removal",
);
assert_eq!(
routed_by_headers(&router, "/", &[]),
None,
"{position:?}: without its base, a request lacking the header has no route",
);
}
}
#[test]
fn an_unsatisfied_predicated_hostname_falls_through_to_the_wildcard() {
let mut router = Router::new();
router
.add_http_front(&HttpFrontend {
cluster_id: Some("wildcard".into()),
hostname: "*.example.com".to_owned(),
..test_http_frontend()
})
.expect("wildcard");
router
.add_http_front(&HttpFrontend {
cluster_id: Some("exact".into()),
hostname: "www.example.com".to_owned(),
header_predicates: vec![predicate("X-Canary", HeaderMatchKind::Present, None)],
..test_http_frontend()
})
.expect("predicated exact host");
let route = |headers: &[(&str, &str)]| {
router
.lookup_with_headers("www.example.com", "/", &Method::Get, &TestHeaders(headers))
.ok()
.and_then(|route| route.cluster_id)
.map(|id| id.to_string())
};
assert_eq!(route(&[("x-canary", "")]).as_deref(), Some("exact"));
assert_eq!(route(&[]).as_deref(), Some("wildcard"));
}
#[test]
fn unusable_header_predicates_are_refused_without_echoing_them() {
let refused = [
predicate("Host", HeaderMatchKind::Exact, Some("example.com")),
predicate("cookie", HeaderMatchKind::Present, None),
predicate("X Bad", HeaderMatchKind::Present, None),
predicate("", HeaderMatchKind::Present, None),
predicate("X-Debug", HeaderMatchKind::Present, Some("1")),
predicate("X-Tenant", HeaderMatchKind::Exact, None),
predicate("X-Tenant", HeaderMatchKind::Regex, None),
predicate("X-Tenant", HeaderMatchKind::Exact, Some("a\r\nb")),
predicate("X-Tenant", HeaderMatchKind::Regex, Some("a)(b")),
HeaderPredicate {
name: "X-Tenant".to_owned(),
kind: 42,
value: None,
},
];
for bad in refused {
let mut router = Router::new();
let front = HttpFrontend {
header_predicates: vec![
predicate("X-Valid", HeaderMatchKind::Present, None),
bad.clone(),
],
..test_http_frontend()
};
let error = router
.add_http_front(&front)
.expect_err("an unusable predicate must be refused");
assert!(
matches!(error, RouterError::InvalidHeaderPredicate { index: 1, .. }),
"{bad:?}: unexpected {error}",
);
let message = error.to_string();
assert!(
!message.contains("X-Tenant") && !message.contains("example.com"),
"the error must not echo the predicate: {message}",
);
assert!(router.tree.is_empty(), "a refused frontend adds no rule");
}
}
quickcheck! {
fn qc_router_hostname_resolution_matches_the_documented_semantics(scenario: Scenario) -> TestResult {
oracle_matches_router(scenario)
}
}
}