use std::borrow::Cow;
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::Arc;
use plecto_host::{Header, LoadedFilter};
use crate::error::ControlError;
use crate::manifest::{CompressionAlgorithm, Route, RouteCompression};
use crate::ratelimit::{NativeRateLimit, RateLimitDecision};
use crate::upstream::UpstreamGroup;
use crate::weighted::{self, WeightedBackends};
#[derive(Clone)]
pub(crate) struct CompiledRoute {
pub(crate) host: Option<String>,
pub(crate) path_prefix: String,
pub(crate) method: Option<String>,
pub(crate) headers: Vec<(String, String)>,
pub(crate) query: Vec<(String, String)>,
pub(crate) filters: Vec<String>,
pub(crate) resolved_chain: Vec<Arc<LoadedFilter>>,
pub(crate) reads_body: bool,
pub(crate) backends: Arc<WeightedBackends>,
pub(crate) strip_prefix: Option<Arc<str>>,
pub(crate) rate_limit: Option<Arc<NativeRateLimit>>,
pub(crate) upgrade: Option<Arc<UpgradeConfig>>,
pub(crate) compression: Option<Arc<CompressionConfig>>,
}
impl CompiledRoute {
pub(crate) fn compile(
r: &Route,
backends: WeightedBackends,
filters: &std::collections::HashMap<String, Arc<LoadedFilter>>,
) -> Self {
Self {
host: r.matcher.host.as_ref().map(|h| h.to_ascii_lowercase()),
path_prefix: r.matcher.path_prefix.clone(),
method: r.matcher.method.as_ref().map(|m| m.to_ascii_uppercase()),
headers: r
.matcher
.headers
.iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
.collect(),
query: r
.matcher
.query
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
reads_body: r
.filters
.iter()
.any(|id| filters.get(id).is_some_and(|f| f.reads_body())),
resolved_chain: r
.filters
.iter()
.filter_map(|id| filters.get(id).cloned())
.collect(),
filters: r.filters.clone(),
backends: Arc::new(backends),
strip_prefix: r.strip_prefix.as_deref().map(Arc::from),
rate_limit: r.rate_limit.map(|rl| Arc::new(NativeRateLimit::new(rl))),
upgrade: r
.upgrade
.as_ref()
.map(|u| Arc::new(UpgradeConfig::new(&u.protocols, u.idle_timeout_ms))),
compression: r
.compression
.as_ref()
.map(|c| Arc::new(CompressionConfig::new(c))),
}
}
}
impl std::fmt::Debug for CompiledRoute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledRoute")
.field("host", &self.host)
.field("path_prefix", &self.path_prefix)
.field("method", &self.method)
.field("headers", &self.headers)
.field("query", &self.query)
.field("filters", &self.filters)
.field("resolved_chain_len", &self.resolved_chain.len())
.field("reads_body", &self.reads_body)
.field("backends", &self.backends)
.field("strip_prefix", &self.strip_prefix)
.field("rate_limit", &self.rate_limit)
.field("upgrade", &self.upgrade)
.field("compression", &self.compression)
.finish()
}
}
#[derive(Debug)]
pub struct UpgradeConfig {
protocols: Vec<String>,
idle_timeout: Option<std::time::Duration>,
}
impl UpgradeConfig {
pub(crate) fn new(protocols: &[String], idle_timeout_ms: u64) -> Self {
Self {
protocols: protocols
.iter()
.map(|p| p.trim().to_ascii_lowercase())
.collect(),
idle_timeout: (idle_timeout_ms > 0)
.then(|| std::time::Duration::from_millis(idle_timeout_ms)),
}
}
pub fn allowed_token(&self, upgrade_header: &str) -> Option<&str> {
upgrade_header.split(',').map(str::trim).find_map(|tok| {
self.protocols
.iter()
.find(|p| p.eq_ignore_ascii_case(tok))
.map(String::as_str)
})
}
pub fn idle_timeout(&self) -> Option<std::time::Duration> {
self.idle_timeout
}
}
#[derive(Debug)]
pub struct CompressionConfig {
algorithms: Vec<CompressionAlgorithm>,
min_length: u64,
content_types: Vec<String>,
}
impl CompressionConfig {
pub fn new(rc: &RouteCompression) -> Self {
Self {
algorithms: rc.algorithms.clone(),
min_length: rc.min_length,
content_types: rc
.content_types
.iter()
.map(|ct| ct.trim().to_ascii_lowercase())
.collect(),
}
}
pub fn algorithms(&self) -> &[CompressionAlgorithm] {
&self.algorithms
}
pub fn min_length(&self) -> u64 {
self.min_length
}
pub fn content_type_eligible(&self, essence: &str) -> bool {
let essence = essence.trim();
self.content_types
.iter()
.any(|ct| ct.eq_ignore_ascii_case(essence))
}
}
#[derive(Debug)]
pub(crate) struct ValidatedRoute<'a> {
pub(crate) route: &'a Route,
pub(crate) targets: Vec<(&'a str, u32)>,
}
pub(crate) fn validate_routes<'a>(
routes: &'a [Route],
filter_ids: &HashSet<&str>,
upstream_names: &HashSet<&str>,
) -> Result<Vec<ValidatedRoute<'a>>, ControlError> {
let mut validated = Vec::with_capacity(routes.len());
for r in routes {
let targets = r.targets().map_err(|reason| ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: reason.to_string(),
})?;
for (name, _) in &targets {
if !upstream_names.contains(name) {
return Err(ControlError::UnknownRouteUpstream {
path_prefix: r.matcher.path_prefix.clone(),
upstream: (*name).to_string(),
});
}
}
let weights: Vec<u32> = targets.iter().map(|(_, w)| *w).collect();
weighted::validate_split(&weights).map_err(|reason| ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason,
})?;
for f in &r.filters {
if !filter_ids.contains(f.as_str()) {
return Err(ControlError::UnknownRouteFilter {
path_prefix: r.matcher.path_prefix.clone(),
filter: f.clone(),
});
}
}
if let Some(rl) = &r.rate_limit {
if rl.rate == 0 {
return Err(ControlError::InvalidRouteRateLimit {
path_prefix: r.matcher.path_prefix.clone(),
reason: "rate must be non-zero".to_string(),
});
}
if rl.burst == 0 {
return Err(ControlError::InvalidRouteRateLimit {
path_prefix: r.matcher.path_prefix.clone(),
reason: "burst must be non-zero".to_string(),
});
}
}
if let Some(up) = &r.upgrade {
if up.protocols.is_empty() {
return Err(ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: "upgrade.protocols must be non-empty".to_string(),
});
}
for p in &up.protocols {
if p.trim().is_empty() {
return Err(ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: "upgrade.protocols contains an empty token".to_string(),
});
}
if p.trim().eq_ignore_ascii_case("h2c") {
return Err(ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: "h2c upgrade is not supported (h2 is TLS+ALPN only; \
forwarding h2c enables request smuggling)"
.to_string(),
});
}
}
}
if let Some(c) = &r.compression {
if c.algorithms.is_empty() {
return Err(ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: "compression.algorithms must be non-empty".to_string(),
});
}
let mut seen = HashSet::new();
for a in &c.algorithms {
if !seen.insert(a) {
return Err(ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: format!("compression.algorithms lists `{}` twice", a.token()),
});
}
}
if c.content_types.is_empty() {
return Err(ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: "compression.content_types must be non-empty".to_string(),
});
}
for ct in &c.content_types {
let essence = ct.trim();
let well_formed = essence
.split_once('/')
.is_some_and(|(t, s)| !t.is_empty() && !s.is_empty())
&& !essence.contains([';', ',', ' ']);
if !well_formed {
return Err(ControlError::InvalidRoute {
path_prefix: r.matcher.path_prefix.clone(),
reason: format!(
"compression.content_types entry `{essence}` is not a type/subtype"
),
});
}
}
}
validated.push(ValidatedRoute { route: r, targets });
}
Ok(validated)
}
pub(crate) struct RequestParts<'a> {
pub(crate) authority: &'a str,
pub(crate) path: &'a str,
pub(crate) method: &'a str,
pub(crate) headers: &'a [Header],
}
#[derive(Debug, Clone)]
pub struct RouteInfo {
pub index: usize,
pub(crate) backends: Arc<WeightedBackends>,
pub strip_prefix: Option<Arc<str>>,
pub has_filters: bool,
pub reads_body: bool,
pub(crate) rate_limit: Option<Arc<NativeRateLimit>>,
pub upgrade: Option<Arc<UpgradeConfig>>,
pub compression: Option<Arc<CompressionConfig>>,
}
impl RouteInfo {
pub fn pick_upstream(&self) -> Option<Arc<UpstreamGroup>> {
self.backends.pick()
}
pub fn rewrite_path<'a>(&self, path: &'a str) -> Cow<'a, str> {
rewrite_path(path, self.strip_prefix.as_deref())
}
pub fn check_rate_limit(&self, peer: IpAddr) -> RateLimitDecision {
match &self.rate_limit {
Some(rl) => rl.check(peer),
None => RateLimitDecision::Allow,
}
}
}
fn normalize_host(authority: &str) -> &str {
let host = if let Some(rest) = authority.strip_prefix('[') {
match rest.split_once(']') {
Some((inner, _)) => authority.get(..inner.len() + 2).unwrap_or(authority),
None => authority,
}
} else {
authority.split(':').next().unwrap_or(authority)
};
match host.strip_suffix('.') {
Some(stripped) if !host.starts_with('[') => stripped,
_ => host,
}
}
pub fn normalize_path(target: &str) -> Option<Cow<'_, str>> {
let (raw, query) = match target.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (target, None),
};
if !raw.starts_with('/') {
return Some(Cow::Borrowed(target));
}
if raw.bytes().any(|b| b < 0x20 || b == 0x7f) || raw.contains('\\') {
return None;
}
if contains_encoded_separator(raw) {
return None;
}
if !raw.split('/').any(|seg| seg == "." || seg == "..") {
return Some(Cow::Borrowed(target));
}
let mut out: Vec<&str> = Vec::new();
for seg in raw.split('/') {
match seg {
"." => {}
".." => {
if out.len() <= 1 {
return None;
}
out.pop();
}
other => out.push(other),
}
}
let mut norm = out.join("/");
if norm.is_empty() {
norm.push('/');
}
if let Some(q) = query {
norm.push('?');
norm.push_str(q);
}
Some(Cow::Owned(norm))
}
#[allow(clippy::indexing_slicing)]
fn contains_encoded_separator(path: &str) -> bool {
path.as_bytes().windows(3).any(|w| {
w[0] == b'%'
&& matches!(
(w[1], w[2]),
(b'2', b'e' | b'E') | (b'2', b'f' | b'F') | (b'5', b'c' | b'C')
)
})
}
fn path_under_prefix(prefix: &str, path: &str) -> bool {
if !path.starts_with(prefix) {
return false;
}
if path.len() == prefix.len() || prefix.ends_with('/') {
return true;
}
path.as_bytes().get(prefix.len()) == Some(&b'/')
}
fn route_matches(
r: &CompiledRoute,
host: &str,
path: &str,
method: &str,
headers: &[Header],
query: &str,
) -> bool {
if let Some(h) = &r.host
&& !h.eq_ignore_ascii_case(host)
{
return false;
}
if !path_under_prefix(&r.path_prefix, path) {
return false;
}
if let Some(m) = &r.method
&& method != m
{
return false;
}
for (name, value) in &r.headers {
match headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)) {
Some(h) if h.value.as_slice() == value.as_bytes() => {}
_ => return false,
}
}
for (name, value) in &r.query {
if !query_param_matches(query, name, value) {
return false;
}
}
true
}
fn query_param_matches(query: &str, name: &str, value: &str) -> bool {
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=')
&& k == name
{
return v == value;
}
}
false
}
pub(crate) fn select(routes: &[CompiledRoute], req: &RequestParts<'_>) -> Option<usize> {
let host = normalize_host(req.authority);
let query = req.path.split_once('?').map(|(_, q)| q).unwrap_or("");
let path = req.path.split(['?', '#']).next().unwrap_or(req.path);
routes
.iter()
.enumerate()
.filter(|(_, r)| route_matches(r, host, path, req.method, req.headers, query))
.max_by_key(|(i, r)| {
(
r.host.is_some(),
r.path_prefix.len(),
r.method.is_some(),
r.headers.len(),
r.query.len(),
usize::MAX - i,
)
})
.map(|(i, _)| i)
}
pub(crate) fn rewrite_path<'a>(path: &'a str, strip: Option<&str>) -> Cow<'a, str> {
let Some(strip) = strip else {
return Cow::Borrowed(path);
};
let Some(rest) = path.strip_prefix(strip) else {
return Cow::Borrowed(path);
};
if rest.is_empty() {
Cow::Borrowed("/")
} else if rest.starts_with('/') {
Cow::Borrowed(rest)
} else if strip.ends_with('/') {
Cow::Owned(format!("/{rest}"))
} else {
Cow::Borrowed(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{
AddressSpec, CircuitBreaker, HealthConfig, LbAlgorithm, OutlierDetection, Upstream,
};
use crate::upstream::UpstreamRegistry;
fn group(upstream: &str) -> Arc<UpstreamGroup> {
let reg = UpstreamRegistry::new();
reg.reconcile(
&[Upstream {
name: upstream.to_string(),
addresses: vec![AddressSpec::Bare("127.0.0.1:9000".to_string())],
lb_algorithm: LbAlgorithm::RoundRobin,
hash: None,
tls: None,
resolve_interval_ms: 0,
health: HealthConfig {
path: "/healthz".to_string(),
interval_ms: 1000,
timeout_ms: 500,
healthy_threshold: 1,
unhealthy_threshold: 1,
port: None,
},
request_timeout_ms: 30_000,
max_retries: 1,
overall_timeout_ms: 0,
circuit_breaker: CircuitBreaker::default(),
outlier_detection: OutlierDetection::default(),
}],
std::path::Path::new("."),
)
.unwrap();
reg.group(upstream).unwrap()
}
fn backends(upstream: &str) -> Arc<WeightedBackends> {
Arc::new(WeightedBackends::new(vec![(group(upstream), 1)]).unwrap())
}
fn route(host: Option<&str>, prefix: &str, upstream: &str) -> CompiledRoute {
CompiledRoute {
host: host.map(|h| h.to_ascii_lowercase()),
path_prefix: prefix.to_string(),
method: None,
headers: vec![],
query: vec![],
filters: vec![],
resolved_chain: vec![],
reads_body: false,
backends: backends(upstream),
strip_prefix: None,
rate_limit: None,
upgrade: None,
compression: None,
}
}
fn header(name: &str, value: &str) -> Header {
Header {
name: name.to_string(),
value: value.as_bytes().to_vec(),
}
}
fn parts<'a>(authority: &'a str, path: &'a str) -> RequestParts<'a> {
RequestParts {
authority,
path,
method: "GET",
headers: &[],
}
}
fn manifest_route(
upstream: Option<&str>,
backends: Vec<crate::manifest::Backend>,
filters: Vec<&str>,
rate_limit: Option<crate::manifest::RouteRateLimit>,
) -> Route {
Route {
matcher: crate::manifest::RouteMatch {
host: None,
path_prefix: "/".to_string(),
method: None,
headers: Default::default(),
query: Default::default(),
},
filters: filters.into_iter().map(str::to_string).collect(),
upstream: upstream.map(str::to_string),
backends,
strip_prefix: None,
rate_limit,
upgrade: None,
compression: None,
}
}
#[test]
fn validate_routes_rejects_unknown_upstream() {
let routes = vec![manifest_route(Some("ghost"), vec![], vec![], None)];
let filters = HashSet::new();
let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
let err = validate_routes(&routes, &filters, &upstream_names).unwrap_err();
assert!(matches!(
err,
ControlError::UnknownRouteUpstream { upstream, .. } if upstream == "ghost"
));
}
#[test]
fn validate_routes_rejects_unknown_filter() {
let routes = vec![manifest_route(Some("real"), vec![], vec!["missing"], None)];
let filters = HashSet::new();
let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
let err = validate_routes(&routes, &filters, &upstream_names).unwrap_err();
assert!(matches!(
err,
ControlError::UnknownRouteFilter { filter, .. } if filter == "missing"
));
}
#[test]
fn validate_routes_rejects_all_zero_backend_weight() {
let routes = vec![manifest_route(
None,
vec![crate::manifest::Backend {
upstream: "real".to_string(),
weight: 0,
}],
vec![],
None,
)];
let filters = HashSet::new();
let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
assert!(matches!(
validate_routes(&routes, &filters, &upstream_names),
Err(ControlError::InvalidRoute { .. })
));
}
#[test]
fn validate_routes_rejects_zero_rate_or_burst() {
let filters = HashSet::new();
let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
let zero_rate = vec![manifest_route(
Some("real"),
vec![],
vec![],
Some(crate::manifest::RouteRateLimit {
rate: 0,
burst: 5,
key: Default::default(),
}),
)];
assert!(matches!(
validate_routes(&zero_rate, &filters, &upstream_names),
Err(ControlError::InvalidRouteRateLimit { .. })
));
let zero_burst = vec![manifest_route(
Some("real"),
vec![],
vec![],
Some(crate::manifest::RouteRateLimit {
rate: 5,
burst: 0,
key: Default::default(),
}),
)];
assert!(matches!(
validate_routes(&zero_burst, &filters, &upstream_names),
Err(ControlError::InvalidRouteRateLimit { .. })
));
}
#[test]
fn validate_routes_rejects_h2c_and_empty_upgrade_tokens() {
let filters = HashSet::new();
let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
let with_upgrade = |protocols: Vec<&str>| {
let mut r = manifest_route(Some("real"), vec![], vec![], None);
r.upgrade = Some(crate::manifest::RouteUpgrade {
protocols: protocols.into_iter().map(str::to_string).collect(),
idle_timeout_ms: 300_000,
});
vec![r]
};
for bad in [vec![], vec!["H2C"], vec!["websocket", "h2c"], vec![" "]] {
assert!(
matches!(
validate_routes(&with_upgrade(bad.clone()), &filters, &upstream_names),
Err(ControlError::InvalidRoute { .. })
),
"{bad:?} must be rejected"
);
}
assert!(
validate_routes(&with_upgrade(vec!["websocket"]), &filters, &upstream_names).is_ok()
);
}
#[test]
fn upgrade_config_matches_tokens_case_insensitively_and_ignores_unlisted() {
let cfg = UpgradeConfig::new(&["WebSocket".to_string()], 300_000);
assert_eq!(cfg.allowed_token("websocket"), Some("websocket"));
assert_eq!(cfg.allowed_token("WEBSOCKET"), Some("websocket"));
assert_eq!(
cfg.allowed_token("h2c, websocket"),
Some("websocket"),
"the first ALLOWLISTED token wins; unlisted ones are skipped, never forwarded"
);
assert_eq!(cfg.allowed_token("h2c"), None);
assert_eq!(cfg.allowed_token(""), None);
assert_eq!(
UpgradeConfig::new(&["websocket".to_string()], 0).idle_timeout(),
None,
"0 disables the idle timer"
);
}
#[test]
fn validate_routes_accepts_a_valid_route_and_carries_its_resolved_targets() {
let routes = vec![manifest_route(Some("real"), vec![], vec![], None)];
let filters = HashSet::new();
let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
let validated = validate_routes(&routes, &filters, &upstream_names).unwrap();
assert_eq!(validated.len(), 1);
assert_eq!(validated[0].targets, vec![("real", 1)]);
}
#[test]
fn longest_prefix_wins() {
let routes = vec![
route(None, "/", "root"),
route(None, "/api", "api"),
route(None, "/api/v2", "v2"),
];
assert_eq!(select(&routes, &parts("h", "/api/v2/x")), Some(2));
assert_eq!(select(&routes, &parts("h", "/api/users")), Some(1));
assert_eq!(select(&routes, &parts("h", "/other")), Some(0));
}
#[test]
fn prefix_matches_on_boundary_only() {
let routes = vec![route(None, "/api", "api")];
assert_eq!(select(&routes, &parts("h", "/api")), Some(0));
assert_eq!(select(&routes, &parts("h", "/api/x")), Some(0));
assert_eq!(
select(&routes, &parts("h", "/apix")),
None,
"no boundary match"
);
}
#[test]
fn query_or_fragment_acts_as_a_prefix_boundary() {
let routes = vec![route(None, "/search", "s")];
assert_eq!(select(&routes, &parts("h", "/search")), Some(0));
assert_eq!(
select(&routes, &parts("h", "/search?q=foo")),
Some(0),
"a bare prefix followed by a query must match"
);
assert_eq!(select(&routes, &parts("h", "/search/x?q=foo")), Some(0));
assert_eq!(select(&routes, &parts("h", "/search#frag")), Some(0));
assert_eq!(
select(&routes, &parts("h", "/searching?q=foo")),
None,
"a longer word is not a boundary match even with a query"
);
}
#[test]
fn host_constraint_filters_and_breaks_ties() {
let routes = vec![
route(None, "/api", "wild"),
route(Some("example.com"), "/api", "vhost"),
];
assert_eq!(
select(&routes, &parts("example.com:8443", "/api/x")),
Some(1)
);
assert_eq!(select(&routes, &parts("other.test", "/api/x")), Some(0));
}
#[test]
fn no_match_returns_none() {
let routes = vec![route(Some("a.test"), "/api", "u")];
assert_eq!(select(&routes, &parts("b.test", "/api")), None);
let empty: Vec<CompiledRoute> = vec![];
assert_eq!(select(&empty, &parts("a", "/")), None);
}
#[test]
fn method_match_filters_and_outranks_a_bare_path() {
let mut post = route(None, "/api", "writes");
post.method = Some("POST".to_string());
let routes = vec![route(None, "/api", "reads"), post];
let mut p = parts("h", "/api/x");
p.method = "POST";
assert_eq!(select(&routes, &p), Some(1), "POST takes the method route");
p.method = "GET";
assert_eq!(select(&routes, &p), Some(0), "GET falls to the bare route");
}
#[test]
fn header_match_is_case_insensitive_name_exact_value_and_anded() {
let mut v2 = route(None, "/api", "v2");
v2.headers = vec![("x-api-version".to_string(), "2".to_string())];
let routes = vec![route(None, "/api", "v1"), v2];
let hdrs = [header("X-Api-Version", "2")];
let mut p = parts("h", "/api");
p.headers = &hdrs;
assert_eq!(
select(&routes, &p),
Some(1),
"a case-different header name still matches, and outranks the bare route"
);
let wrong = [header("x-api-version", "3")];
p.headers = &wrong;
assert_eq!(
select(&routes, &p),
Some(0),
"a different value does not match"
);
p.headers = &[];
assert_eq!(select(&routes, &p), Some(0), "absent header → bare route");
}
#[test]
fn header_match_decides_on_the_first_duplicate() {
let mut v2 = route(None, "/api", "v2");
v2.headers = vec![("x-api-version".to_string(), "2".to_string())];
let routes = vec![route(None, "/api", "v1"), v2];
let hdrs = [header("x-api-version", "1"), header("x-api-version", "2")];
let mut p = parts("h", "/api");
p.headers = &hdrs;
assert_eq!(
select(&routes, &p),
Some(0),
"the first duplicate value (1) decides, so the v2 header route does not match"
);
}
#[test]
fn query_match_is_case_sensitive_name_first_value_and_handles_malformed() {
let mut beta = route(None, "/api", "beta");
beta.query = vec![("flag".to_string(), "on".to_string())];
let routes = vec![route(None, "/api", "stable"), beta];
assert_eq!(
select(&routes, &parts("h", "/api?flag=on")),
Some(1),
"exact query value matches"
);
assert_eq!(
select(&routes, &parts("h", "/api?flag=off")),
Some(0),
"wrong value → bare route"
);
assert_eq!(
select(&routes, &parts("h", "/api?Flag=on")),
Some(0),
"query name is case-sensitive"
);
assert_eq!(
select(&routes, &parts("h", "/api?flag=on&flag=off")),
Some(1),
"the first occurrence of a repeated key decides"
);
assert_eq!(
select(&routes, &parts("h", "/api?flag&x=1")),
Some(0),
"a malformed (=-less) parameter is skipped, so the constraint is unmet"
);
}
#[test]
fn precedence_orders_method_above_header_count() {
let mut a = route(None, "/api", "a");
a.headers = vec![
("h1".to_string(), "1".to_string()),
("h2".to_string(), "2".to_string()),
];
let mut b = route(None, "/api", "b");
b.headers = vec![("h1".to_string(), "1".to_string())];
b.method = Some("POST".to_string());
let routes = vec![a, b];
let hdrs = [header("h1", "1"), header("h2", "2")];
let mut p = parts("h", "/api");
p.method = "POST";
p.headers = &hdrs;
assert_eq!(
select(&routes, &p),
Some(1),
"method-present outranks the larger header count"
);
}
#[test]
fn strip_prefix_keeps_leading_slash() {
assert_eq!(rewrite_path("/api/users", Some("/api")), "/users");
assert_eq!(rewrite_path("/api", Some("/api")), "/");
assert_eq!(rewrite_path("/api/", Some("/api")), "/");
assert_eq!(rewrite_path("/other", Some("/api")), "/other", "no strip");
assert_eq!(rewrite_path("/api/x", None), "/api/x", "no rule");
}
#[test]
fn strip_prefix_is_segment_boundary_strict() {
assert_eq!(
rewrite_path("/apix/y", Some("/api")),
"/apix/y",
"a mid-segment match must not strip"
);
assert_eq!(rewrite_path("/api/users", Some("/api/")), "/users");
}
#[test]
fn normalize_host_drops_port_and_preserves_case() {
assert_eq!(normalize_host("EXAMPLE.com:8443"), "EXAMPLE.com");
assert_eq!(normalize_host("Example.Com"), "Example.Com");
assert_eq!(normalize_host("host:80"), "host");
}
#[test]
fn normalize_host_handles_ipv6_literals() {
assert_eq!(normalize_host("[::1]:8080"), "[::1]");
assert_eq!(normalize_host("[::1]"), "[::1]");
assert_eq!(normalize_host("[2001:DB8::1]:443"), "[2001:DB8::1]");
}
#[test]
fn normalize_host_is_panic_free_on_malformed_authority() {
assert_eq!(
normalize_host("[::1"),
"[::1",
"unclosed bracket returned as-is"
);
assert_eq!(normalize_host("["), "[", "lone bracket does not panic");
assert_eq!(
normalize_host("[]"),
"[]",
"empty bracket pair does not panic"
);
assert_eq!(normalize_host(""), "", "empty authority does not panic");
assert_eq!(normalize_host("[]:9"), "[]");
}
#[test]
fn normalize_host_strips_trailing_dot() {
assert_eq!(normalize_host("example.com."), "example.com");
assert_eq!(normalize_host("EXAMPLE.COM.:8443"), "EXAMPLE.COM");
assert_eq!(normalize_host("example.com"), "example.com");
assert_eq!(normalize_host("[::1]"), "[::1]");
}
#[test]
fn normalize_path_resolves_dot_segments_and_preserves_query() {
assert_eq!(
normalize_path("/public/../admin").as_deref(),
Some("/admin")
);
assert_eq!(normalize_path("/a/./b").as_deref(), Some("/a/b"));
assert_eq!(normalize_path("/a/b/../c").as_deref(), Some("/a/c"));
assert_eq!(normalize_path("/").as_deref(), Some("/"));
assert_eq!(normalize_path("/api/").as_deref(), Some("/api/"));
assert_eq!(normalize_path("/api").as_deref(), Some("/api"));
assert_eq!(
normalize_path("/x/../y?a=../b").as_deref(),
Some("/y?a=../b")
);
assert_eq!(normalize_path("*").as_deref(), Some("*"));
}
#[test]
fn normalize_path_rejects_traversal_and_ambiguous_encodings() {
assert_eq!(normalize_path("/.."), None);
assert_eq!(normalize_path("/a/../.."), None);
assert_eq!(normalize_path("/public/%2e%2e/admin"), None);
assert_eq!(normalize_path("/x%2fy"), None);
assert_eq!(normalize_path("/x%2Fy"), None);
assert_eq!(normalize_path("/x%5cy"), None);
assert_eq!(normalize_path("/a\\b"), None);
assert_eq!(normalize_path("/a\nb"), None);
}
#[test]
fn normalize_path_closes_per_route_filter_bypass() {
let routes = vec![
route(None, "/public", "pub"),
route(None, "/public/admin", "admin"),
];
let raw = "/public/x/../admin";
let norm = normalize_path(raw).expect("normalizes to a clean path");
assert_eq!(norm, "/public/admin");
assert_eq!(
select(&routes, &parts("h", &norm)),
Some(1),
"the normalized path selects the stricter, filtered route"
);
assert_eq!(select(&routes, &parts("h", raw)), Some(0));
}
}