Skip to main content

zentinel_proxy/
routing.rs

1//! Route matching and selection module for Zentinel proxy
2//!
3//! This module implements the routing logic for matching incoming requests
4//! to configured routes based on various criteria (path, host, headers, etc.)
5//! with support for priority-based evaluation.
6
7use dashmap::DashMap;
8use prometheus::{register_int_counter, IntCounter};
9use regex::Regex;
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
12use std::sync::{Arc, LazyLock};
13use tracing::{debug, info, trace, warn};
14
15/// Entries evicted from the route-match cache to enforce `route-cache-size`.
16static ROUTE_CACHE_EVICTIONS: LazyLock<Option<IntCounter>> = LazyLock::new(|| {
17    register_int_counter!(
18        "zentinel_route_cache_evictions_total",
19        "Route-match cache entries evicted to enforce route-cache-size"
20    )
21    .ok()
22});
23
24use zentinel_common::types::Priority;
25use zentinel_common::RouteId;
26use zentinel_config::{MatchCondition, RouteConfig, RoutePolicies};
27
28/// Route matcher for efficient route selection
29pub struct RouteMatcher {
30    /// Routes sorted by priority (highest first)
31    routes: Vec<CompiledRoute>,
32    /// Default route ID if no match found
33    default_route: Option<RouteId>,
34    /// Cache for frequently matched routes (lock-free concurrent access)
35    cache: Arc<RouteCache>,
36    /// Whether any route requires header matching (optimization flag)
37    needs_headers: bool,
38    /// Whether any route requires query param matching (optimization flag)
39    needs_query_params: bool,
40}
41
42/// Compiled route with pre-processed match conditions
43struct CompiledRoute {
44    /// Route configuration
45    config: Arc<RouteConfig>,
46    /// Route ID for quick lookup
47    id: RouteId,
48    /// Priority for ordering
49    priority: Priority,
50    /// Compiled match conditions
51    matchers: Vec<CompiledMatcher>,
52}
53
54/// Compiled match condition for efficient evaluation
55enum CompiledMatcher {
56    /// Exact path match
57    Path(String),
58    /// Path prefix match
59    PathPrefix(String),
60    /// Regex path match
61    PathRegex(Regex),
62    /// Host match (exact or wildcard)
63    Host(HostMatcher),
64    /// Header presence or value match
65    Header { name: String, value: Option<String> },
66    /// HTTP method match
67    Method(Vec<String>),
68    /// Query parameter match
69    QueryParam { name: String, value: Option<String> },
70}
71
72/// Host matching logic
73enum HostMatcher {
74    /// Exact host match
75    Exact(String),
76    /// Wildcard match (*.example.com)
77    Wildcard { suffix: String },
78    /// Regex match
79    Regex(Regex),
80}
81
82/// Route cache for performance (lock-free concurrent access)
83struct RouteCache {
84    /// Cache entries (cache key -> route ID) - lock-free concurrent map
85    entries: DashMap<String, RouteId>,
86    /// Maximum cache size
87    max_size: usize,
88    /// Current entry count (approximate, for eviction decisions)
89    entry_count: AtomicUsize,
90    /// Cache hits counter
91    hits: AtomicU64,
92    /// Cache misses counter
93    misses: AtomicU64,
94}
95
96impl RouteMatcher {
97    /// Create a new route matcher from configuration with the default
98    /// route-cache size (1000 entries).
99    pub fn new(
100        routes: Vec<RouteConfig>,
101        default_route: Option<String>,
102    ) -> Result<Self, RouteError> {
103        Self::with_cache_size(routes, default_route, 1000)
104    }
105
106    /// Create a new route matcher with an explicit route-cache size
107    /// (`system { route-cache-size N }`).
108    pub fn with_cache_size(
109        routes: Vec<RouteConfig>,
110        default_route: Option<String>,
111        cache_size: usize,
112    ) -> Result<Self, RouteError> {
113        info!(
114            route_count = routes.len(),
115            default_route = ?default_route,
116            "Initializing route matcher"
117        );
118
119        let mut compiled_routes = Vec::new();
120
121        for route in routes {
122            trace!(
123                route_id = %route.id,
124                priority = ?route.priority,
125                match_count = route.matches.len(),
126                "Compiling route"
127            );
128            let compiled = CompiledRoute::compile(route)?;
129            compiled_routes.push(compiled);
130        }
131
132        // Sort by priority (highest first), then by specificity
133        compiled_routes.sort_by(|a, b| {
134            b.priority
135                .cmp(&a.priority)
136                .then_with(|| b.specificity().cmp(&a.specificity()))
137        });
138
139        // Log final route order
140        for (index, route) in compiled_routes.iter().enumerate() {
141            debug!(
142                route_id = %route.id,
143                order = index,
144                priority = ?route.priority,
145                specificity = route.specificity(),
146                "Route compiled and ordered"
147            );
148        }
149
150        // Determine if any routes need headers or query params (optimization)
151        let needs_headers = compiled_routes.iter().any(|r| {
152            r.matchers
153                .iter()
154                .any(|m| matches!(m, CompiledMatcher::Header { .. }))
155        });
156        let needs_query_params = compiled_routes.iter().any(|r| {
157            r.matchers
158                .iter()
159                .any(|m| matches!(m, CompiledMatcher::QueryParam { .. }))
160        });
161
162        info!(
163            compiled_routes = compiled_routes.len(),
164            needs_headers, needs_query_params, "Route matcher initialized"
165        );
166
167        Ok(Self {
168            routes: compiled_routes,
169            default_route: default_route.map(RouteId::new),
170            cache: Arc::new(RouteCache::new(cache_size)),
171            needs_headers,
172            needs_query_params,
173        })
174    }
175
176    /// Check if any route requires header matching
177    #[inline]
178    pub fn needs_headers(&self) -> bool {
179        self.needs_headers
180    }
181
182    /// Check if any route requires query param matching
183    #[inline]
184    pub fn needs_query_params(&self) -> bool {
185        self.needs_query_params
186    }
187
188    /// Match a request to a route
189    pub fn match_request(&self, req: &RequestInfo<'_>) -> Option<RouteMatch> {
190        trace!(
191            method = %req.method,
192            path = %req.path,
193            host = %req.host,
194            "Starting route matching"
195        );
196
197        // Check cache first (lock-free read, zero-allocation on hit)
198        let cached = req.with_cache_key(|key| {
199            self.cache.get(key).map(|r| {
200                let route_id = r.clone();
201                drop(r);
202                route_id
203            })
204        });
205        if let Some(route_id) = cached {
206            trace!(
207                route_id = %route_id,
208                "Route cache hit"
209            );
210            if let Some(route) = self.find_route_by_id(&route_id) {
211                debug!(
212                    route_id = %route_id,
213                    method = %req.method,
214                    path = %req.path,
215                    source = "cache",
216                    "Route matched from cache"
217                );
218                return Some(RouteMatch {
219                    route_id,
220                    config: route.config.clone(),
221                });
222            }
223        }
224
225        // Record cache miss
226        self.cache.record_miss();
227
228        trace!(
229            route_count = self.routes.len(),
230            "Cache miss, evaluating routes"
231        );
232
233        // Evaluate routes in priority order
234        for (index, route) in self.routes.iter().enumerate() {
235            trace!(
236                route_id = %route.id,
237                route_index = index,
238                priority = ?route.priority,
239                matcher_count = route.matchers.len(),
240                "Evaluating route"
241            );
242
243            if route.matches(req) {
244                debug!(
245                    route_id = %route.id,
246                    method = %req.method,
247                    path = %req.path,
248                    host = %req.host,
249                    priority = ?route.priority,
250                    route_index = index,
251                    "Route matched"
252                );
253
254                // Update cache — allocate key only on miss (rare after warmup)
255                req.with_cache_key(|key| {
256                    self.cache.insert(key.to_string(), route.id.clone());
257                });
258
259                trace!(
260                    route_id = %route.id,
261                    "Route added to cache"
262                );
263
264                return Some(RouteMatch {
265                    route_id: route.id.clone(),
266                    config: route.config.clone(),
267                });
268            }
269        }
270
271        // Use default route if configured
272        if let Some(ref default_id) = self.default_route {
273            debug!(
274                route_id = %default_id,
275                method = %req.method,
276                path = %req.path,
277                "Using default route (no explicit match)"
278            );
279            if let Some(route) = self.find_route_by_id(default_id) {
280                return Some(RouteMatch {
281                    route_id: default_id.clone(),
282                    config: route.config.clone(),
283                });
284            }
285        }
286
287        debug!(
288            method = %req.method,
289            path = %req.path,
290            host = %req.host,
291            routes_evaluated = self.routes.len(),
292            "No route matched"
293        );
294        None
295    }
296
297    /// Find a route by ID
298    fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
299        self.routes.iter().find(|r| r.id == *id)
300    }
301
302    /// Clear the route cache
303    pub fn clear_cache(&self) {
304        self.cache.clear();
305    }
306
307    /// Get cache statistics
308    pub fn cache_stats(&self) -> CacheStats {
309        CacheStats {
310            entries: self.cache.len(),
311            max_size: self.cache.max_size,
312            hit_rate: self.cache.hit_rate(),
313        }
314    }
315}
316
317impl CompiledRoute {
318    /// Compile a route configuration into an optimized matcher
319    fn compile(config: RouteConfig) -> Result<Self, RouteError> {
320        let mut matchers = Vec::new();
321
322        for condition in &config.matches {
323            let compiled = match condition {
324                MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
325                MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
326                MatchCondition::PathRegex(pattern) => {
327                    let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
328                        pattern: pattern.clone(),
329                        error: e.to_string(),
330                    })?;
331                    CompiledMatcher::PathRegex(regex)
332                }
333                MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
334                MatchCondition::Header { name, value } => CompiledMatcher::Header {
335                    name: name.to_lowercase(),
336                    value: value.clone(),
337                },
338                MatchCondition::Method(methods) => {
339                    CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
340                }
341                MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
342                    name: name.clone(),
343                    value: value.clone(),
344                },
345            };
346            matchers.push(compiled);
347        }
348
349        Ok(Self {
350            id: RouteId::new(&config.id),
351            priority: config.priority,
352            config: Arc::new(config),
353            matchers,
354        })
355    }
356
357    /// Check if this route matches the request.
358    ///
359    /// Host matchers use OR logic (match any host), all other matchers use AND.
360    /// This matches Gateway API semantics where multiple hostnames on an
361    /// HTTPRoute are alternatives, not conjunctions.
362    fn matches(&self, req: &RequestInfo<'_>) -> bool {
363        // Partition matchers into host matchers and non-host matchers
364        let mut has_host_matchers = false;
365        let mut any_host_matched = false;
366
367        for matcher in &self.matchers {
368            match matcher {
369                CompiledMatcher::Host(_) => {
370                    has_host_matchers = true;
371                    if matcher.matches(req) {
372                        any_host_matched = true;
373                    }
374                }
375                _ => {
376                    if !matcher.matches(req) {
377                        trace!(
378                            route_id = %self.id,
379                            matcher_type = ?matcher,
380                            path = %req.path,
381                            "Matcher did not match"
382                        );
383                        return false;
384                    }
385                }
386            }
387        }
388
389        // If there are host matchers, at least one must match (OR logic)
390        if has_host_matchers && !any_host_matched {
391            trace!(
392                route_id = %self.id,
393                host = %req.host,
394                "No host matcher matched"
395            );
396            return false;
397        }
398
399        true
400    }
401
402    /// Calculate route specificity for tie-breaking.
403    ///
404    /// Per Gateway API precedence rules:
405    /// 1. Path specificity is primary (exact > longest prefix > regex)
406    /// 2. Host specificity is secondary (exact > wildcard)
407    /// 3. Header/method/query conditions add specificity
408    ///
409    /// Host matchers use OR logic, so multiple hosts don't increase
410    /// specificity — we use the max host score, not the sum.
411    fn specificity(&self) -> u32 {
412        let mut path_score = 0u32;
413        let mut host_score = 0u32;
414        let mut condition_score = 0u32;
415
416        for matcher in &self.matchers {
417            match matcher {
418                CompiledMatcher::Path(_) => path_score = path_score.max(10000),
419                CompiledMatcher::PathRegex(_) => path_score = path_score.max(5000),
420                CompiledMatcher::PathPrefix(p) => {
421                    path_score = path_score.max(1000 + p.len() as u32)
422                }
423                CompiledMatcher::Host(host) => {
424                    let s = match host {
425                        HostMatcher::Exact(_) => 70,
426                        HostMatcher::Regex(_) => 60,
427                        HostMatcher::Wildcard { .. } => 50,
428                    };
429                    host_score = host_score.max(s);
430                }
431                CompiledMatcher::Header { value, .. } => {
432                    condition_score += if value.is_some() { 30 } else { 20 };
433                }
434                CompiledMatcher::Method(_) => condition_score += 10,
435                CompiledMatcher::QueryParam { value, .. } => {
436                    condition_score += if value.is_some() { 25 } else { 15 };
437                }
438            }
439        }
440
441        path_score + host_score + condition_score
442    }
443}
444
445impl CompiledMatcher {
446    /// Check if this matcher matches the request
447    fn matches(&self, req: &RequestInfo<'_>) -> bool {
448        match self {
449            Self::Path(path) => req.path == *path,
450            Self::PathPrefix(prefix) => {
451                if !req.path.starts_with(prefix) {
452                    return false;
453                }
454                // Enforce segment boundary per Gateway API spec:
455                // PathPrefix "/v2" must NOT match "/v2example", only "/v2", "/v2/", "/v2/anything"
456                prefix == "/"
457                    || req.path.len() == prefix.len()
458                    || prefix.ends_with('/')
459                    || req.path.as_bytes()[prefix.len()] == b'/'
460                    || req.path.as_bytes()[prefix.len()] == b'?'
461            }
462            Self::PathRegex(regex) => regex.is_match(req.path),
463            Self::Host(host_matcher) => host_matcher.matches(req.host),
464            Self::Header { name, value } => {
465                if let Some(header_value) = req.headers().get(name) {
466                    value.as_ref().is_none_or(|v| header_value == v)
467                } else {
468                    false
469                }
470            }
471            Self::Method(methods) => methods.iter().any(|m| m == req.method),
472            Self::QueryParam { name, value } => {
473                if let Some(param_value) = req.query_params().get(name) {
474                    value.as_ref().is_none_or(|v| param_value == v)
475                } else {
476                    false
477                }
478            }
479        }
480    }
481}
482
483/// Normalize a host for comparison.
484///
485/// Three things, each of which was a way for a route to silently not match:
486///
487/// * **Port removed.** `Host: example.com:8080` must match `example.com`, per
488///   the Gateway API spec.
489/// * **Trailing dot removed.** `example.com.` names the same host as
490///   `example.com`. Without this a restrictive host route could be skipped by
491///   appending a dot, falling through to whatever permissive route follows.
492/// * **Lowercased.** Host comparison is case-insensitive (RFC 3986 §3.2.2).
493///   Both sides are normalized, so neither a mixed-case request nor a
494///   mixed-case config silently matches nothing.
495fn normalize_host(host: &str) -> String {
496    // An IPv6 literal is bracketed and full of colons, so its port is
497    // whatever follows the closing bracket. Splitting on the first colon
498    // would reduce `[::1]:8080` to `[`.
499    let without_port = if host.starts_with('[') {
500        match host.find(']') {
501            Some(end) => &host[..=end],
502            None => host,
503        }
504    } else {
505        host.split(':').next().unwrap_or(host)
506    };
507
508    let without_dot = without_port.strip_suffix('.').unwrap_or(without_port);
509    without_dot.to_ascii_lowercase()
510}
511
512impl HostMatcher {
513    /// Parse a host pattern into a matcher.
514    ///
515    /// The pattern is normalized the same way request hosts are, so a route
516    /// written `host "Example.com"` matches `example.com`. Without that it
517    /// matched nothing at all, silently.
518    fn parse(pattern: &str) -> Self {
519        let pattern = normalize_host(pattern);
520        if let Some(suffix) = pattern.strip_prefix("*.") {
521            // Wildcard pattern
522            Self::Wildcard {
523                suffix: suffix.to_string(),
524            }
525        } else if pattern.contains('*') || pattern.contains('[') {
526            // Treat as regex if it contains other special characters.
527            // Built case-insensitively rather than by lowercasing the pattern,
528            // which would corrupt character classes such as [A-Z].
529            match regex::RegexBuilder::new(&pattern)
530                .case_insensitive(true)
531                .build()
532            {
533                Ok(regex) => Self::Regex(regex),
534                Err(_) => {
535                    // Fall back to exact match if regex compilation fails
536                    warn!("Invalid host regex pattern: {}, using exact match", pattern);
537                    Self::Exact(pattern)
538                }
539            }
540        } else {
541            // Exact match
542            Self::Exact(pattern)
543        }
544    }
545
546    /// Check if this matcher matches the host.
547    fn matches(&self, host: &str) -> bool {
548        let host = normalize_host(host);
549        let host = host.as_str();
550        match self {
551            Self::Exact(pattern) => host == pattern,
552            Self::Wildcard { suffix } => {
553                host.ends_with(suffix)
554                    && host.len() > suffix.len()
555                    && host[..host.len() - suffix.len()].ends_with('.')
556            }
557            Self::Regex(regex) => regex.is_match(host),
558        }
559    }
560}
561
562impl RouteCache {
563    /// Create a new route cache
564    fn new(max_size: usize) -> Self {
565        Self {
566            entries: DashMap::with_capacity(max_size),
567            max_size,
568            entry_count: AtomicUsize::new(0),
569            hits: AtomicU64::new(0),
570            misses: AtomicU64::new(0),
571        }
572    }
573
574    /// Get a route from cache (lock-free)
575    fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
576        let result = self.entries.get(key);
577        if result.is_some() {
578            self.hits.fetch_add(1, Ordering::Relaxed);
579        }
580        result
581    }
582
583    /// Record a cache miss
584    fn record_miss(&self) {
585        self.misses.fetch_add(1, Ordering::Relaxed);
586    }
587
588    /// Get the hit rate (0.0 to 1.0)
589    fn hit_rate(&self) -> f64 {
590        let hits = self.hits.load(Ordering::Relaxed);
591        let misses = self.misses.load(Ordering::Relaxed);
592        let total = hits + misses;
593        if total == 0 {
594            0.0
595        } else {
596            hits as f64 / total as f64
597        }
598    }
599
600    /// Insert a route into cache (lock-free)
601    fn insert(&self, key: String, route_id: RouteId) {
602        // Check if we need to evict (approximate check to avoid overhead)
603        let current_count = self.entry_count.load(Ordering::Relaxed);
604        if current_count >= self.max_size {
605            // Evict ~10% of entries randomly for simplicity
606            // This is faster than true LRU and good enough for a cache
607            self.evict_random();
608        }
609
610        if self.entries.insert(key, route_id).is_none() {
611            // Only increment if this was a new entry
612            self.entry_count.fetch_add(1, Ordering::Relaxed);
613        }
614    }
615
616    /// Evict random entries when cache is full
617    fn evict_random(&self) {
618        let to_evict = (self.max_size / 10).max(1); // Evict ~10%
619        let mut evicted = 0;
620
621        // Iterate and remove some entries
622        self.entries.retain(|_, _| {
623            if evicted < to_evict {
624                evicted += 1;
625                false // Remove this entry
626            } else {
627                true // Keep this entry
628            }
629        });
630
631        // Update count (approximate)
632        self.entry_count
633            .store(self.entries.len(), Ordering::Relaxed);
634
635        debug!(
636            evicted = evicted,
637            remaining = self.entries.len(),
638            max_size = self.max_size,
639            "Route cache at capacity; evicted entries"
640        );
641        if let Some(counter) = ROUTE_CACHE_EVICTIONS.as_ref() {
642            counter.inc_by(evicted as u64);
643        }
644    }
645
646    /// Get current cache size
647    fn len(&self) -> usize {
648        self.entries.len()
649    }
650
651    /// Clear all cache entries
652    fn clear(&self) {
653        self.entries.clear();
654        self.entry_count.store(0, Ordering::Relaxed);
655    }
656}
657
658/// Request information for route matching (zero-copy where possible)
659#[derive(Debug)]
660pub struct RequestInfo<'a> {
661    /// HTTP method (borrowed from request header)
662    pub method: &'a str,
663    /// Request path (borrowed from request header)
664    pub path: &'a str,
665    /// Host header value (borrowed from request header)
666    pub host: &'a str,
667    /// Headers for matching (lazy-initialized, only if needed)
668    headers: Option<HashMap<String, String>>,
669    /// Query parameters (lazy-initialized, only if needed)
670    query_params: Option<HashMap<String, String>>,
671}
672
673impl<'a> RequestInfo<'a> {
674    /// Create a new RequestInfo with borrowed references (zero-copy for common case)
675    #[inline]
676    pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
677        Self {
678            method,
679            path,
680            host,
681            headers: None,
682            query_params: None,
683        }
684    }
685
686    /// Set headers for header-based matching (only call if RouteMatcher.needs_headers())
687    #[inline]
688    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
689        self.headers = Some(headers);
690        self
691    }
692
693    /// Set query params for query-based matching (only call if RouteMatcher.needs_query_params())
694    #[inline]
695    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
696        self.query_params = Some(params);
697        self
698    }
699
700    /// Get headers (returns empty map if not set)
701    #[inline]
702    pub fn headers(&self) -> &HashMap<String, String> {
703        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
704        self.headers
705            .as_ref()
706            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
707    }
708
709    /// Get query params (returns empty map if not set)
710    #[inline]
711    pub fn query_params(&self) -> &HashMap<String, String> {
712        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
713        self.query_params
714            .as_ref()
715            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
716    }
717
718    /// Generate a cache key for this request using a thread-local buffer
719    /// to avoid per-request heap allocation.
720    fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
721        use std::cell::RefCell;
722        use std::fmt::Write;
723
724        thread_local! {
725            static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
726        }
727
728        BUF.with(|buf| {
729            let mut buf = buf.borrow_mut();
730            buf.clear();
731            let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
732            // Include headers in cache key when header-based routing is active,
733            // otherwise different header combinations can poison the cache.
734            if let Some(ref headers) = self.headers {
735                let mut pairs: Vec<_> = headers.iter().collect();
736                pairs.sort_by_key(|(k, _)| k.as_str());
737                for (k, v) in pairs {
738                    let _ = write!(buf, "\n{k}={v}");
739                }
740            }
741            // Query parameters, for the same reason. `path` here comes from
742            // `Uri::path()` and carries no query string, so without this two
743            // requests differing only in their query share a cache entry: once
744            // `/api?version=v2` is cached, `/api?version=v1` is served the v2
745            // route and reaches the wrong upstream.
746            //
747            // The separator differs from the header one so a header and a
748            // query parameter with the same name and value cannot produce the
749            // same key.
750            if let Some(ref params) = self.query_params {
751                let mut pairs: Vec<_> = params.iter().collect();
752                pairs.sort_by_key(|(k, _)| k.as_str());
753                for (k, v) in pairs {
754                    let _ = write!(buf, "\t{k}={v}");
755                }
756            }
757            f(&buf)
758        })
759    }
760
761    /// Parse query parameters from path (only call when needed)
762    pub fn parse_query_params(path: &str) -> HashMap<String, String> {
763        let mut params = HashMap::new();
764        if let Some(query_start) = path.find('?') {
765            let query = &path[query_start + 1..];
766            for pair in query.split('&') {
767                if let Some(eq_pos) = pair.find('=') {
768                    let key = &pair[..eq_pos];
769                    let value = &pair[eq_pos + 1..];
770                    params.insert(
771                        urlencoding::decode(key)
772                            .unwrap_or_else(|_| key.into())
773                            .into_owned(),
774                        urlencoding::decode(value)
775                            .unwrap_or_else(|_| value.into())
776                            .into_owned(),
777                    );
778                } else {
779                    params.insert(
780                        urlencoding::decode(pair)
781                            .unwrap_or_else(|_| pair.into())
782                            .into_owned(),
783                        String::new(),
784                    );
785                }
786            }
787        }
788        params
789    }
790
791    /// Build headers map from request header iterator (only call when needed)
792    pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
793    where
794        I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
795    {
796        let mut headers = HashMap::new();
797        for (name, value) in iter {
798            if let Ok(value_str) = value.to_str() {
799                headers.insert(name.as_str().to_lowercase(), value_str.to_string());
800            }
801        }
802        headers
803    }
804}
805
806/// Route match result
807#[derive(Debug, Clone)]
808pub struct RouteMatch {
809    pub route_id: RouteId,
810    pub config: Arc<RouteConfig>,
811}
812
813impl RouteMatch {
814    /// Access route policies (convenience accessor to avoid repeated .config.policies)
815    #[inline]
816    pub fn policies(&self) -> &RoutePolicies {
817        &self.config.policies
818    }
819}
820
821/// Cache statistics
822#[derive(Debug, Clone)]
823pub struct CacheStats {
824    pub entries: usize,
825    pub max_size: usize,
826    pub hit_rate: f64,
827}
828
829/// Route matching errors
830#[derive(Debug, thiserror::Error)]
831pub enum RouteError {
832    #[error("Invalid regex pattern '{pattern}': {error}")]
833    InvalidRegex { pattern: String, error: String },
834
835    #[error("Invalid route configuration: {0}")]
836    InvalidConfig(String),
837
838    #[error("Duplicate route ID: {0}")]
839    DuplicateRouteId(String),
840}
841
842impl std::fmt::Debug for CompiledMatcher {
843    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844        match self {
845            Self::Path(p) => write!(f, "Path({})", p),
846            Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
847            Self::PathRegex(_) => write!(f, "PathRegex(...)"),
848            Self::Host(_) => write!(f, "Host(...)"),
849            Self::Header { name, .. } => write!(f, "Header({})", name),
850            Self::Method(m) => write!(f, "Method({:?})", m),
851            Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
852        }
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use zentinel_common::types::Priority;
860    use zentinel_config::{MatchCondition, RouteConfig};
861
862    #[test]
863    fn route_cache_never_exceeds_max_size() {
864        let cache = RouteCache::new(10);
865        for i in 0..100 {
866            cache.insert(format!("key-{i}"), RouteId::new(format!("route-{i}")));
867            assert!(
868                cache.len() <= 10,
869                "route cache grew past max_size: {}",
870                cache.len()
871            );
872        }
873    }
874
875    fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
876        RouteConfig {
877            id: id.to_string(),
878            priority: Priority::NORMAL,
879            matches,
880            upstream: Some("test_upstream".to_string()),
881            service_type: zentinel_config::ServiceType::Web,
882            policies: Default::default(),
883            filters: vec![],
884            builtin_handler: None,
885            waf_enabled: false,
886            retry_policy: None,
887            static_files: None,
888            api_schema: None,
889            error_pages: None,
890            websocket: false,
891            websocket_inspection: false,
892            inference: None,
893            mcp: None,
894            a2a: None,
895            shadow: None,
896            fallback: None,
897        }
898    }
899
900    #[test]
901    fn test_path_matching() {
902        let routes = vec![
903            create_test_route(
904                "exact",
905                vec![MatchCondition::Path("/api/v1/users".to_string())],
906            ),
907            create_test_route(
908                "prefix",
909                vec![MatchCondition::PathPrefix("/api/".to_string())],
910            ),
911        ];
912
913        let matcher = RouteMatcher::new(routes, None).unwrap();
914
915        let req = RequestInfo {
916            method: "GET",
917            path: "/api/v1/users",
918            host: "example.com",
919            headers: None,
920            query_params: None,
921        };
922
923        let result = matcher.match_request(&req).unwrap();
924        assert_eq!(result.route_id.as_str(), "exact");
925    }
926
927    #[test]
928    fn test_host_wildcard_matching() {
929        let routes = vec![create_test_route(
930            "wildcard",
931            vec![MatchCondition::Host("*.example.com".to_string())],
932        )];
933
934        let matcher = RouteMatcher::new(routes, None).unwrap();
935
936        let req = RequestInfo {
937            method: "GET",
938            path: "/",
939            host: "api.example.com",
940            headers: None,
941            query_params: None,
942        };
943
944        let result = matcher.match_request(&req).unwrap();
945        assert_eq!(result.route_id.as_str(), "wildcard");
946    }
947
948    #[test]
949    fn test_priority_ordering() {
950        let mut route1 =
951            create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
952        route1.priority = Priority::LOW;
953
954        let mut route2 =
955            create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
956        route2.priority = Priority::HIGH;
957
958        let routes = vec![route1, route2];
959        let matcher = RouteMatcher::new(routes, None).unwrap();
960
961        let req = RequestInfo {
962            method: "GET",
963            path: "/test",
964            host: "example.com",
965            headers: None,
966            query_params: None,
967        };
968
969        let result = matcher.match_request(&req).unwrap();
970        assert_eq!(result.route_id.as_str(), "high");
971    }
972
973    #[test]
974    fn test_query_param_parsing() {
975        let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
976        assert_eq!(params.get("foo"), Some(&"bar".to_string()));
977        assert_eq!(params.get("baz"), Some(&"qux".to_string()));
978        assert_eq!(params.get("empty"), Some(&"".to_string()));
979    }
980
981    #[test]
982    fn test_path_prefix_segment_boundary() {
983        let routes = vec![
984            create_test_route("v2", vec![MatchCondition::PathPrefix("/v2".to_string())]),
985            create_test_route(
986                "catch-all",
987                vec![MatchCondition::PathPrefix("/".to_string())],
988            ),
989        ];
990
991        let matcher = RouteMatcher::new(routes, None).unwrap();
992
993        // /v2 exact → v2
994        let req = RequestInfo::new("GET", "/v2", "example.com");
995        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
996
997        // /v2/ with trailing slash → v2
998        let req = RequestInfo::new("GET", "/v2/", "example.com");
999        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
1000
1001        // /v2/anything → v2
1002        let req = RequestInfo::new("GET", "/v2/anything", "example.com");
1003        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
1004
1005        // /v2example must NOT match /v2 prefix — falls to catch-all
1006        let req = RequestInfo::new("GET", "/v2example", "example.com");
1007        assert_eq!(
1008            matcher.match_request(&req).unwrap().route_id.as_str(),
1009            "catch-all"
1010        );
1011
1012        // /v2?query → v2
1013        let req = RequestInfo::new("GET", "/v2?foo=bar", "example.com");
1014        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
1015    }
1016
1017    #[test]
1018    fn test_header_matching_with_specificity() {
1019        let routes = vec![
1020            create_test_route(
1021                "catch-all",
1022                vec![MatchCondition::PathPrefix("/".to_string())],
1023            ),
1024            create_test_route(
1025                "header-v2",
1026                vec![
1027                    MatchCondition::Header {
1028                        name: "version".to_string(),
1029                        value: Some("two".to_string()),
1030                    },
1031                    MatchCondition::PathPrefix("/".to_string()),
1032                ],
1033            ),
1034        ];
1035
1036        let matcher = RouteMatcher::new(routes, None).unwrap();
1037
1038        // Without headers → catch-all
1039        let req = RequestInfo::new("GET", "/", "example.com");
1040        assert_eq!(
1041            matcher.match_request(&req).unwrap().route_id.as_str(),
1042            "catch-all"
1043        );
1044
1045        // With version:two header → header-v2 (more specific)
1046        let mut headers = HashMap::new();
1047        headers.insert("version".to_string(), "two".to_string());
1048        let req = RequestInfo::new("GET", "/", "example.com").with_headers(headers);
1049        assert_eq!(
1050            matcher.match_request(&req).unwrap().route_id.as_str(),
1051            "header-v2"
1052        );
1053    }
1054}
1055
1056#[cfg(test)]
1057mod probe_113 {
1058    use super::*;
1059    use zentinel_common::types::Priority;
1060    use zentinel_config::{MatchCondition, RouteConfig};
1061
1062    fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
1063        RouteConfig {
1064            id: id.to_string(),
1065            priority: Priority::NORMAL,
1066            matches,
1067            upstream: Some("u".to_string()),
1068            service_type: zentinel_config::ServiceType::Web,
1069            policies: Default::default(),
1070            filters: vec![],
1071            builtin_handler: None,
1072            waf_enabled: false,
1073            retry_policy: None,
1074            static_files: None,
1075            api_schema: None,
1076            error_pages: None,
1077            websocket: false,
1078            websocket_inspection: false,
1079            inference: None,
1080            mcp: None,
1081            a2a: None,
1082            shadow: None,
1083            fallback: None,
1084        }
1085    }
1086
1087    #[test]
1088    fn probe_query_param_matching() {
1089        let build = || {
1090            RouteMatcher::new(
1091                vec![
1092                    route(
1093                        "v2",
1094                        vec![
1095                            MatchCondition::PathPrefix("/api".into()),
1096                            MatchCondition::QueryParam {
1097                                name: "version".into(),
1098                                value: Some("v2".into()),
1099                            },
1100                        ],
1101                    ),
1102                    route("fallback", vec![MatchCondition::PathPrefix("/api".into())]),
1103                ],
1104                None,
1105            )
1106            .unwrap()
1107        };
1108
1109        // A: correct param, fresh matcher.
1110        let m = build();
1111        let mut p1 = std::collections::HashMap::new();
1112        p1.insert("version".to_string(), "v2".to_string());
1113        let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(p1));
1114        println!(
1115            "  A version=v2, fresh   -> {:?}",
1116            r.map(|r| r.route_id.to_string())
1117        );
1118
1119        // B: WRONG param, fresh matcher. Should fall through to "fallback".
1120        let m = build();
1121        let mut p2 = std::collections::HashMap::new();
1122        p2.insert("version".to_string(), "v1".to_string());
1123        let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(p2));
1124        println!(
1125            "  B version=v1, fresh   -> {:?}",
1126            r.map(|r| r.route_id.to_string())
1127        );
1128
1129        // C: NO params, fresh matcher. Should fall through to "fallback".
1130        let m = build();
1131        let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h"));
1132        println!(
1133            "  C no params, fresh    -> {:?}",
1134            r.map(|r| r.route_id.to_string())
1135        );
1136
1137        // D: cache poisoning -- same matcher, v2 first then v1.
1138        let m = build();
1139        let mut pa = std::collections::HashMap::new();
1140        pa.insert("version".to_string(), "v2".to_string());
1141        let first = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(pa));
1142        let mut pb = std::collections::HashMap::new();
1143        pb.insert("version".to_string(), "v1".to_string());
1144        let second = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(pb));
1145        println!(
1146            "  D v2 then v1, shared  -> first={:?} second={:?}",
1147            first.map(|r| r.route_id.to_string()),
1148            second.map(|r| r.route_id.to_string())
1149        );
1150    }
1151}
1152
1153#[cfg(test)]
1154mod route_cache_correctness {
1155    use super::*;
1156    use std::collections::HashMap;
1157    use zentinel_common::types::Priority;
1158    use zentinel_config::{MatchCondition, RouteConfig};
1159
1160    fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
1161        RouteConfig {
1162            id: id.to_string(),
1163            priority: Priority::NORMAL,
1164            matches,
1165            upstream: Some("u".to_string()),
1166            service_type: zentinel_config::ServiceType::Web,
1167            policies: Default::default(),
1168            filters: vec![],
1169            builtin_handler: None,
1170            waf_enabled: false,
1171            retry_policy: None,
1172            static_files: None,
1173            api_schema: None,
1174            error_pages: None,
1175            websocket: false,
1176            websocket_inspection: false,
1177            inference: None,
1178            mcp: None,
1179            a2a: None,
1180            shadow: None,
1181            fallback: None,
1182        }
1183    }
1184
1185    fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1186        pairs
1187            .iter()
1188            .map(|(k, v)| (k.to_string(), v.to_string()))
1189            .collect()
1190    }
1191
1192    fn matched(m: &RouteMatcher, req: &RequestInfo<'_>) -> Option<String> {
1193        m.match_request(req).map(|r| r.route_id.to_string())
1194    }
1195
1196    fn query_matcher() -> RouteMatcher {
1197        RouteMatcher::new(
1198            vec![
1199                route(
1200                    "v2",
1201                    vec![
1202                        MatchCondition::PathPrefix("/api".into()),
1203                        MatchCondition::QueryParam {
1204                            name: "version".into(),
1205                            value: Some("v2".into()),
1206                        },
1207                    ],
1208                ),
1209                route("fallback", vec![MatchCondition::PathPrefix("/api".into())]),
1210            ],
1211            None,
1212        )
1213        .unwrap()
1214    }
1215
1216    /// Two requests differing only in a query parameter must not share a cache
1217    /// entry.
1218    ///
1219    /// `path` comes from `Uri::path()` and carries no query string, so before
1220    /// query parameters were part of the cache key, the first request through
1221    /// a path decided the route for every later request to that path. A
1222    /// `?version=v2` request would pin `/api` to the v2 route and a subsequent
1223    /// `?version=v1` would be sent to the wrong upstream.
1224    #[test]
1225    fn a_query_parameter_change_is_not_served_from_cache() {
1226        let m = query_matcher();
1227
1228        let first = matched(
1229            &m,
1230            &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v2")])),
1231        );
1232        let second = matched(
1233            &m,
1234            &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v1")])),
1235        );
1236
1237        assert_eq!(first.as_deref(), Some("v2"));
1238        assert_eq!(
1239            second.as_deref(),
1240            Some("fallback"),
1241            "the second request was served the first request's route from cache"
1242        );
1243    }
1244
1245    /// And in the other order, so the test cannot pass merely because the
1246    /// first result happened to be the fallback.
1247    #[test]
1248    fn the_reverse_order_is_also_correct() {
1249        let m = query_matcher();
1250
1251        let first = matched(
1252            &m,
1253            &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v1")])),
1254        );
1255        let second = matched(
1256            &m,
1257            &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v2")])),
1258        );
1259
1260        assert_eq!(first.as_deref(), Some("fallback"));
1261        assert_eq!(second.as_deref(), Some("v2"));
1262    }
1263
1264    /// A request with no query parameters must not pick up a cached entry from
1265    /// one that had them.
1266    #[test]
1267    fn an_absent_query_parameter_is_distinct_from_a_present_one() {
1268        let m = query_matcher();
1269
1270        assert_eq!(
1271            matched(
1272                &m,
1273                &RequestInfo::new("GET", "/api/x", "h")
1274                    .with_query_params(params(&[("version", "v2")]))
1275            )
1276            .as_deref(),
1277            Some("v2")
1278        );
1279        assert_eq!(
1280            matched(&m, &RequestInfo::new("GET", "/api/x", "h")).as_deref(),
1281            Some("fallback"),
1282            "a request without the parameter must not inherit the parameterised route"
1283        );
1284    }
1285
1286    /// Parameter order in the map must not change the key, or the cache would
1287    /// miss on every request and quietly stop being a cache.
1288    #[test]
1289    fn parameter_order_does_not_affect_the_cache_key() {
1290        let m = query_matcher();
1291
1292        let a = RequestInfo::new("GET", "/api/x", "h")
1293            .with_query_params(params(&[("version", "v2"), ("page", "1")]));
1294        let b = RequestInfo::new("GET", "/api/x", "h")
1295            .with_query_params(params(&[("page", "1"), ("version", "v2")]));
1296
1297        assert_eq!(matched(&m, &a).as_deref(), Some("v2"));
1298        assert_eq!(matched(&m, &b).as_deref(), Some("v2"));
1299
1300        // Both requests are the same request, so they must share one cache
1301        // entry. Two entries would mean the key depends on map iteration
1302        // order, and the cache would miss on almost every request.
1303        assert_eq!(
1304            m.cache_stats().entries,
1305            1,
1306            "parameter order changed the cache key"
1307        );
1308    }
1309
1310    /// A header and a query parameter with the same name and value must not
1311    /// collide, or one could stand in for the other.
1312    #[test]
1313    fn a_header_and_a_query_parameter_do_not_collide() {
1314        let m = RouteMatcher::new(
1315            vec![
1316                route(
1317                    "by-header",
1318                    vec![
1319                        MatchCondition::PathPrefix("/x".into()),
1320                        MatchCondition::Header {
1321                            name: "tenant".into(),
1322                            value: Some("acme".into()),
1323                        },
1324                    ],
1325                ),
1326                route(
1327                    "by-query",
1328                    vec![
1329                        MatchCondition::PathPrefix("/x".into()),
1330                        MatchCondition::QueryParam {
1331                            name: "tenant".into(),
1332                            value: Some("acme".into()),
1333                        },
1334                    ],
1335                ),
1336                route("fallback", vec![MatchCondition::PathPrefix("/x".into())]),
1337            ],
1338            None,
1339        )
1340        .unwrap();
1341
1342        let with_header =
1343            RequestInfo::new("GET", "/x", "h").with_headers(params(&[("tenant", "acme")]));
1344        let with_query =
1345            RequestInfo::new("GET", "/x", "h").with_query_params(params(&[("tenant", "acme")]));
1346
1347        assert_eq!(matched(&m, &with_header).as_deref(), Some("by-header"));
1348        assert_eq!(
1349            matched(&m, &with_query).as_deref(),
1350            Some("by-query"),
1351            "a query parameter was served the header route's cache entry"
1352        );
1353    }
1354}
1355
1356/// Route matching edge cases (#113).
1357///
1358/// The matcher decides which upstream every request reaches, so a wrong
1359/// answer is a wrong backend rather than an error anyone sees. These pin the
1360/// behaviour that is easy to get wrong and impossible to notice.
1361#[cfg(test)]
1362mod route_matching_edges {
1363    use super::*;
1364    use std::collections::HashMap;
1365    use zentinel_common::types::Priority;
1366    use zentinel_config::{MatchCondition, RouteConfig};
1367
1368    fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
1369        route_with_priority(id, matches, Priority::NORMAL)
1370    }
1371
1372    fn route_with_priority(
1373        id: &str,
1374        matches: Vec<MatchCondition>,
1375        priority: Priority,
1376    ) -> RouteConfig {
1377        RouteConfig {
1378            id: id.to_string(),
1379            priority,
1380            matches,
1381            upstream: Some("u".to_string()),
1382            service_type: zentinel_config::ServiceType::Web,
1383            policies: Default::default(),
1384            filters: vec![],
1385            builtin_handler: None,
1386            waf_enabled: false,
1387            retry_policy: None,
1388            static_files: None,
1389            api_schema: None,
1390            error_pages: None,
1391            websocket: false,
1392            websocket_inspection: false,
1393            inference: None,
1394            mcp: None,
1395            a2a: None,
1396            shadow: None,
1397            fallback: None,
1398        }
1399    }
1400
1401    fn matcher(routes: Vec<RouteConfig>) -> RouteMatcher {
1402        RouteMatcher::new(routes, None).expect("routes should compile")
1403    }
1404
1405    fn hit(m: &RouteMatcher, method: &str, path: &str, host: &str) -> Option<String> {
1406        m.match_request(&RequestInfo::new(method, path, host))
1407            .map(|r| r.route_id.to_string())
1408    }
1409
1410    fn pairs(kv: &[(&str, &str)]) -> HashMap<String, String> {
1411        kv.iter()
1412            .map(|(k, v)| (k.to_string(), v.to_string()))
1413            .collect()
1414    }
1415
1416    // -- Host -------------------------------------------------------------
1417
1418    /// Host comparison is case-insensitive (RFC 3986 §3.2.2). Both sides are
1419    /// normalized: a mixed-case request must match, and just as importantly a
1420    /// route written `host "Example.com"` must not silently match nothing.
1421    #[test]
1422    fn host_matching_ignores_case_on_both_sides() {
1423        let m = matcher(vec![route(
1424            "h",
1425            vec![MatchCondition::Host("example.com".into())],
1426        )]);
1427        assert_eq!(hit(&m, "GET", "/", "example.com").as_deref(), Some("h"));
1428        assert_eq!(hit(&m, "GET", "/", "EXAMPLE.COM").as_deref(), Some("h"));
1429        assert_eq!(hit(&m, "GET", "/", "ExAmPlE.cOm").as_deref(), Some("h"));
1430
1431        let m = matcher(vec![route(
1432            "h",
1433            vec![MatchCondition::Host("Example.COM".into())],
1434        )]);
1435        assert_eq!(
1436            hit(&m, "GET", "/", "example.com").as_deref(),
1437            Some("h"),
1438            "a mixed-case config host must still match"
1439        );
1440    }
1441
1442    /// A trailing dot names the same host. Without normalizing it, a
1443    /// restrictive host route can be skipped by appending a dot and falling
1444    /// through to whatever permissive route follows.
1445    #[test]
1446    fn a_trailing_dot_does_not_bypass_a_host_route() {
1447        // Priority is explicit so the outcome turns on the host match alone,
1448        // not on how a host-only route ranks against a path catch-all.
1449        let m = matcher(vec![
1450            route_with_priority(
1451                "restricted",
1452                vec![
1453                    MatchCondition::Host("admin.example.com".into()),
1454                    MatchCondition::PathPrefix("/".into()),
1455                ],
1456                Priority::HIGH,
1457            ),
1458            route_with_priority(
1459                "catchall",
1460                vec![MatchCondition::PathPrefix("/".into())],
1461                Priority::LOW,
1462            ),
1463        ]);
1464
1465        assert_eq!(
1466            hit(&m, "GET", "/", "admin.example.com").as_deref(),
1467            Some("restricted")
1468        );
1469        assert_eq!(
1470            hit(&m, "GET", "/", "admin.example.com.").as_deref(),
1471            Some("restricted"),
1472            "a trailing dot must not route around the host match"
1473        );
1474        // A genuinely different host still falls through.
1475        assert_eq!(
1476            hit(&m, "GET", "/", "other.com").as_deref(),
1477            Some("catchall")
1478        );
1479    }
1480
1481    /// Path specificity outranks host specificity, so a route matching only a
1482    /// host loses to a catch-all that matches a path.
1483    ///
1484    /// This is the documented ordering rather than a defect, but it is
1485    /// surprising enough to pin: an operator adding a host-only route
1486    /// alongside a `path-prefix "/"` catch-all gets the catch-all, and the
1487    /// host route never fires. Give the host route a path condition, or a
1488    /// higher priority.
1489    #[test]
1490    fn a_host_only_route_loses_to_a_path_catchall() {
1491        let m = matcher(vec![
1492            route(
1493                "host_only",
1494                vec![MatchCondition::Host("admin.example.com".into())],
1495            ),
1496            route("catchall", vec![MatchCondition::PathPrefix("/".into())]),
1497        ]);
1498        assert_eq!(
1499            hit(&m, "GET", "/", "admin.example.com").as_deref(),
1500            Some("catchall")
1501        );
1502
1503        // Adding the path condition is what makes it win.
1504        let m = matcher(vec![
1505            route(
1506                "host_and_path",
1507                vec![
1508                    MatchCondition::Host("admin.example.com".into()),
1509                    MatchCondition::PathPrefix("/".into()),
1510                ],
1511            ),
1512            route("catchall", vec![MatchCondition::PathPrefix("/".into())]),
1513        ]);
1514        assert_eq!(
1515            hit(&m, "GET", "/", "admin.example.com").as_deref(),
1516            Some("host_and_path")
1517        );
1518    }
1519
1520    #[test]
1521    fn a_port_is_ignored_when_matching_a_host() {
1522        let m = matcher(vec![route(
1523            "h",
1524            vec![MatchCondition::Host("example.com".into())],
1525        )]);
1526        assert_eq!(
1527            hit(&m, "GET", "/", "example.com:8443").as_deref(),
1528            Some("h")
1529        );
1530        assert_eq!(hit(&m, "GET", "/", "example.com:80").as_deref(), Some("h"));
1531    }
1532
1533    /// An IPv6 literal is bracketed and full of colons, so splitting on the
1534    /// first one to strip a port reduces `[::1]:8080` to `[`.
1535    #[test]
1536    fn an_ipv6_host_is_not_mangled_by_port_stripping() {
1537        assert_eq!(normalize_host("[::1]:8080"), "[::1]");
1538        assert_eq!(normalize_host("[2001:DB8::1]"), "[2001:db8::1]");
1539        assert_eq!(normalize_host("[::1]"), "[::1]");
1540    }
1541
1542    #[test]
1543    fn wildcard_hosts_also_ignore_case_port_and_trailing_dot() {
1544        let m = matcher(vec![route(
1545            "w",
1546            vec![MatchCondition::Host("*.example.com".into())],
1547        )]);
1548        for host in [
1549            "api.example.com",
1550            "API.EXAMPLE.COM",
1551            "api.example.com:8443",
1552            "api.example.com.",
1553        ] {
1554            assert_eq!(
1555                hit(&m, "GET", "/", host).as_deref(),
1556                Some("w"),
1557                "host {host}"
1558            );
1559        }
1560    }
1561
1562    /// A wildcard covers one or more labels beneath the suffix, never the bare
1563    /// suffix itself. `*.example.com` must not match `example.com`.
1564    #[test]
1565    fn a_wildcard_does_not_match_its_own_suffix() {
1566        let m = matcher(vec![route(
1567            "w",
1568            vec![MatchCondition::Host("*.example.com".into())],
1569        )]);
1570        assert_eq!(hit(&m, "GET", "/", "example.com").as_deref(), None);
1571        assert_eq!(hit(&m, "GET", "/", "notexample.com").as_deref(), None);
1572    }
1573
1574    /// Several host conditions on one route are alternatives, while a host and
1575    /// a path condition must both hold. Getting this backwards would either
1576    /// make multi-host routes unreachable or make every route far too broad.
1577    #[test]
1578    fn hosts_are_alternatives_but_other_conditions_are_required() {
1579        let m = matcher(vec![route(
1580            "multi",
1581            vec![
1582                MatchCondition::Host("a.com".into()),
1583                MatchCondition::Host("b.com".into()),
1584                MatchCondition::PathPrefix("/x".into()),
1585            ],
1586        )]);
1587
1588        assert_eq!(hit(&m, "GET", "/x", "a.com").as_deref(), Some("multi"));
1589        assert_eq!(hit(&m, "GET", "/x", "b.com").as_deref(), Some("multi"));
1590        assert_eq!(
1591            hit(&m, "GET", "/x", "c.com").as_deref(),
1592            None,
1593            "an unlisted host must not match"
1594        );
1595        assert_eq!(
1596            hit(&m, "GET", "/y", "a.com").as_deref(),
1597            None,
1598            "the path is still required"
1599        );
1600    }
1601
1602    // -- Path -------------------------------------------------------------
1603
1604    #[test]
1605    fn an_exact_path_does_not_match_children_or_a_trailing_slash() {
1606        let m = matcher(vec![route(
1607            "exact",
1608            vec![MatchCondition::Path("/api".into())],
1609        )]);
1610        assert_eq!(hit(&m, "GET", "/api", "h").as_deref(), Some("exact"));
1611        assert_eq!(hit(&m, "GET", "/api/", "h").as_deref(), None);
1612        assert_eq!(hit(&m, "GET", "/api/users", "h").as_deref(), None);
1613        assert_eq!(hit(&m, "GET", "/apiv2", "h").as_deref(), None);
1614    }
1615
1616    /// A prefix must stop at a path segment boundary, or `/api` would claim
1617    /// `/apikeys` and route it to the wrong backend.
1618    #[test]
1619    fn a_prefix_stops_at_a_segment_boundary() {
1620        let m = matcher(vec![route(
1621            "p",
1622            vec![MatchCondition::PathPrefix("/api".into())],
1623        )]);
1624        assert_eq!(hit(&m, "GET", "/api", "h").as_deref(), Some("p"));
1625        assert_eq!(hit(&m, "GET", "/api/", "h").as_deref(), Some("p"));
1626        assert_eq!(hit(&m, "GET", "/api/users", "h").as_deref(), Some("p"));
1627        assert_eq!(hit(&m, "GET", "/apikeys", "h").as_deref(), None);
1628        assert_eq!(hit(&m, "GET", "/apiv2/users", "h").as_deref(), None);
1629    }
1630
1631    /// An unusable regex must fail the load rather than becoming a route that
1632    /// silently never matches.
1633    #[test]
1634    fn an_invalid_path_regex_is_rejected_at_load() {
1635        for pattern in ["(unclosed", "a{2,1}", "[z-a]"] {
1636            assert!(
1637                RouteMatcher::new(
1638                    vec![route("r", vec![MatchCondition::PathRegex(pattern.into())])],
1639                    None,
1640                )
1641                .is_err(),
1642                "{pattern:?} should be rejected"
1643            );
1644        }
1645    }
1646
1647    #[test]
1648    fn a_path_regex_is_anchored_as_written() {
1649        let m = matcher(vec![route(
1650            "r",
1651            vec![MatchCondition::PathRegex("^/v[0-9]+/users$".into())],
1652        )]);
1653        assert_eq!(hit(&m, "GET", "/v1/users", "h").as_deref(), Some("r"));
1654        assert_eq!(hit(&m, "GET", "/v42/users", "h").as_deref(), Some("r"));
1655        assert_eq!(hit(&m, "GET", "/v1/users/1", "h").as_deref(), None);
1656        assert_eq!(hit(&m, "GET", "/x/v1/users", "h").as_deref(), None);
1657    }
1658
1659    // -- Method, header, query --------------------------------------------
1660
1661    #[test]
1662    fn a_method_condition_accepts_any_of_its_methods() {
1663        let m = matcher(vec![route(
1664            "rw",
1665            vec![MatchCondition::Method(vec!["POST".into(), "PUT".into()])],
1666        )]);
1667        assert_eq!(hit(&m, "POST", "/x", "h").as_deref(), Some("rw"));
1668        assert_eq!(hit(&m, "PUT", "/x", "h").as_deref(), Some("rw"));
1669        assert_eq!(hit(&m, "GET", "/x", "h").as_deref(), None);
1670        assert_eq!(hit(&m, "DELETE", "/x", "h").as_deref(), None);
1671    }
1672
1673    /// A header condition with no value matches on presence alone; with a
1674    /// value it must match exactly. Conflating the two would make a
1675    /// presence check accept any value, or a value check accept none.
1676    #[test]
1677    fn a_header_condition_distinguishes_presence_from_value() {
1678        let present = matcher(vec![route(
1679            "any",
1680            vec![MatchCondition::Header {
1681                name: "x-key".into(),
1682                value: None,
1683            }],
1684        )]);
1685        let exact = matcher(vec![route(
1686            "exact",
1687            vec![MatchCondition::Header {
1688                name: "x-key".into(),
1689                value: Some("secret".into()),
1690            }],
1691        )]);
1692
1693        let with_other =
1694            RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("x-key", "other")]));
1695        let with_secret =
1696            RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("x-key", "secret")]));
1697        let without = RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("y", "1")]));
1698
1699        assert!(present.match_request(&with_other).is_some());
1700        assert!(present.match_request(&without).is_none());
1701        assert!(exact.match_request(&with_secret).is_some());
1702        assert!(exact.match_request(&with_other).is_none());
1703    }
1704
1705    #[test]
1706    fn a_query_condition_distinguishes_presence_from_value() {
1707        let present = matcher(vec![route(
1708            "any",
1709            vec![MatchCondition::QueryParam {
1710                name: "debug".into(),
1711                value: None,
1712            }],
1713        )]);
1714        let exact = matcher(vec![route(
1715            "exact",
1716            vec![MatchCondition::QueryParam {
1717                name: "v".into(),
1718                value: Some("2".into()),
1719            }],
1720        )]);
1721
1722        assert!(present
1723            .match_request(
1724                &RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("debug", "0")]))
1725            )
1726            .is_some());
1727        assert!(present
1728            .match_request(&RequestInfo::new("GET", "/x", "h"))
1729            .is_none());
1730        assert!(exact
1731            .match_request(
1732                &RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("v", "2")]))
1733            )
1734            .is_some());
1735        assert!(exact
1736            .match_request(
1737                &RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("v", "1")]))
1738            )
1739            .is_none());
1740    }
1741
1742    // -- Selection --------------------------------------------------------
1743
1744    #[test]
1745    fn higher_priority_wins_regardless_of_declaration_order() {
1746        let m = matcher(vec![
1747            route_with_priority(
1748                "low",
1749                vec![MatchCondition::PathPrefix("/a".into())],
1750                Priority::LOW,
1751            ),
1752            route_with_priority(
1753                "high",
1754                vec![MatchCondition::PathPrefix("/a".into())],
1755                Priority::HIGH,
1756            ),
1757        ]);
1758        assert_eq!(hit(&m, "GET", "/a", "h").as_deref(), Some("high"));
1759    }
1760
1761    /// Equal priorities must resolve the same way every time. If this depended
1762    /// on hash or iteration order, two identically configured proxies would
1763    /// route the same request differently.
1764    #[test]
1765    fn equal_priorities_resolve_deterministically() {
1766        for _ in 0..10 {
1767            let m = matcher(vec![
1768                route("first", vec![MatchCondition::PathPrefix("/a".into())]),
1769                route("second", vec![MatchCondition::PathPrefix("/a".into())]),
1770            ]);
1771            assert_eq!(hit(&m, "GET", "/a", "h").as_deref(), Some("first"));
1772        }
1773    }
1774
1775    /// A route with no conditions matches everything, so it is only ever
1776    /// correct as a catch-all and must not shadow a more specific route.
1777    #[test]
1778    fn a_route_with_no_conditions_matches_anything() {
1779        let m = matcher(vec![route("catchall", vec![])]);
1780        assert_eq!(
1781            hit(&m, "GET", "/anything", "h").as_deref(),
1782            Some("catchall")
1783        );
1784        assert_eq!(
1785            hit(&m, "POST", "/", "other.host").as_deref(),
1786            Some("catchall")
1787        );
1788    }
1789
1790    #[test]
1791    fn no_match_returns_none_rather_than_an_arbitrary_route() {
1792        let m = matcher(vec![route(
1793            "api",
1794            vec![MatchCondition::PathPrefix("/api".into())],
1795        )]);
1796        assert_eq!(hit(&m, "GET", "/other", "h").as_deref(), None);
1797    }
1798}