Skip to main content

plecto_control/
route.rs

1//! Host-native routing (ADR 000013 / 000034): match a request to a route by its `[route.match]`
2//! dimensions (host, path prefix, method, headers, query), then the fast-path server runs that
3//! route's chain and forwards to its weighted backends. Pure config logic — no I/O, no wasmtime —
4//! so it is unit-tested directly and runs on the async thread (the blocking work is the chain
5//! dispatch, not the match). Matching is allocation-free: the compiled dimensions are pre-normalised
6//! at build, and per request we only scan and compare borrowed slices.
7
8use std::borrow::Cow;
9use std::collections::HashSet;
10use std::net::IpAddr;
11use std::sync::Arc;
12
13use plecto_host::{Header, LoadedFilter};
14
15use crate::error::ControlError;
16use crate::manifest::{CompressionAlgorithm, Route, RouteCompression};
17use crate::ratelimit::{NativeRateLimit, RateLimitDecision};
18use crate::upstream::UpstreamGroup;
19use crate::weighted::{self, WeightedBackends};
20
21/// A route compiled from a manifest [`crate::Route`] into the live config: the match dimensions are
22/// pre-normalised (host + header names lower-cased, method upper-cased), and the forwarding target —
23/// a single upstream or a weighted split — is resolved to a [`WeightedBackends`] whose groups are
24/// shared (`Arc`) with the upstream registry, so the actual instance is chosen by round-robin over
25/// the healthy set at forward time, not here (ADR 000017 / 000024).
26#[derive(Clone)]
27pub(crate) struct CompiledRoute {
28    /// Lower-cased authority to match, or `None` for any host.
29    pub(crate) host: Option<String>,
30    pub(crate) path_prefix: String,
31    /// Upper-cased HTTP method to match (exact), or `None` for any method (ADR 000034).
32    pub(crate) method: Option<String>,
33    /// Header matches (ADR 000034): `(lower-cased name, exact value)`, ANDed. Name is compared
34    /// case-insensitively, value byte-/string-exact. A `Vec` (not a map) since it is iterated, small,
35    /// and order-stable.
36    pub(crate) headers: Vec<(String, String)>,
37    /// Query-parameter matches (ADR 000034): `(name, exact value)`, ANDed. Name is case-sensitive
38    /// (Gateway-API semantics, asymmetric with headers).
39    pub(crate) query: Vec<(String, String)>,
40    /// This route's inline chain (filter ids, in order). Kept for validation / introspection
41    /// (`validate_routes`, `has_filters`); per-request dispatch uses `resolved_chain` instead so
42    /// it never re-hashes these ids against `ActiveConfig::filters`.
43    pub(crate) filters: Vec<String>,
44    /// `filters` resolved to the loaded filter, in order — built once per reload (`build_active`
45    /// already has the id → `Arc<LoadedFilter>` map in scope there). `chain::dispatch_request` /
46    /// `dispatch_request_body` / `dispatch_response` run this directly instead of doing a
47    /// `HashMap::get` per filter id on every single request.
48    pub(crate) resolved_chain: Vec<Arc<LoadedFilter>>,
49    /// Whether any filter on this route reads the request body (exports `on-request-body`, ADR
50    /// 000038). Precomputed at build from the loaded filters so per-request buffering is a single
51    /// bool check. `false` (all filters header-only) keeps the body on the zero-copy stream path.
52    pub(crate) reads_body: bool,
53    /// The route's forwarding target: a weighted set of upstream groups (a single `upstream` is a
54    /// one-element set). The fast path picks a group via [`WeightedBackends::pick`], then the group
55    /// picks a healthy instance. `Arc`-shared so the split cursor persists across a config generation.
56    pub(crate) backends: Arc<WeightedBackends>,
57    /// `Arc<str>`, not `String`: `snapshot.rs`'s `find_route` clones this into a fresh
58    /// [`RouteInfo`] on every single request — the same reason `backends` above is `Arc`-shared
59    /// rather than deep-cloned. An owned `String` here would allocate on every request for any
60    /// route with a `strip_prefix` configured; an `Arc<str>` clone is an atomic refcount bump.
61    pub(crate) strip_prefix: Option<Arc<str>>,
62    /// This route's native rate limiter (ADR 000033), or `None` for unlimited (the default). Shared
63    /// (`Arc`) so every request on the route consults the same token buckets within a config
64    /// generation; a reload builds a fresh limiter (the node-local buckets reset).
65    pub(crate) rate_limit: Option<Arc<NativeRateLimit>>,
66    /// This route's Upgrade opt-in (ADR 000048), or `None` for deny-by-default (strip as today).
67    pub(crate) upgrade: Option<Arc<UpgradeConfig>>,
68    /// This route's compression opt-in (ADR 000074), or `None` for never-transform (the default).
69    pub(crate) compression: Option<Arc<CompressionConfig>>,
70}
71
72impl CompiledRoute {
73    /// Compile one already-validated manifest route (ADR 000013 / 000034): pre-normalise the
74    /// match dimensions, resolve the chain against the loaded filters, and build the per-route
75    /// facilities (native limiter / upgrade / compression). Lives beside the struct so the
76    /// knowledge of a `CompiledRoute`'s shape stays in this file (`build_active` previously
77    /// inlined the whole block).
78    pub(crate) fn compile(
79        r: &Route,
80        backends: WeightedBackends,
81        filters: &std::collections::HashMap<String, Arc<LoadedFilter>>,
82    ) -> Self {
83        Self {
84            // Pre-normalise the compiled match dimensions so per-request matching is
85            // allocation-free (ADR 000034): host + header names lower-cased (case-insensitive),
86            // method upper-cased (exact upper-case token), query names kept as-is
87            // (case-sensitive).
88            host: r.matcher.host.as_ref().map(|h| h.to_ascii_lowercase()),
89            path_prefix: r.matcher.path_prefix.clone(),
90            method: r.matcher.method.as_ref().map(|m| m.to_ascii_uppercase()),
91            headers: r
92                .matcher
93                .headers
94                .iter()
95                .map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
96                .collect(),
97            query: r
98                .matcher
99                .query
100                .iter()
101                .map(|(k, v)| (k.clone(), v.clone()))
102                .collect(),
103            // The route buffers the body iff at least one of its filters exports
104            // `on-request-body` (ADR 000038). Computed from the loaded filters here so the fast
105            // path only checks a bool.
106            reads_body: r
107                .filters
108                .iter()
109                .any(|id| filters.get(id).is_some_and(|f| f.reads_body())),
110            // present: `validate_routes` already checked every id against the loaded set, so
111            // this is always `Some` — `filter_map` stays total (no indexing/unwrap panic)
112            // rather than asserting an invariant that's already enforced one step earlier.
113            resolved_chain: r
114                .filters
115                .iter()
116                .filter_map(|id| filters.get(id).cloned())
117                .collect(),
118            filters: r.filters.clone(),
119            backends: Arc::new(backends),
120            // Built once per reload (unlike the per-request `RouteInfo` clone in `snapshot.rs`),
121            // so allocating here to convert into the per-request-cheap `Arc<str>` is fine.
122            strip_prefix: r.strip_prefix.as_deref().map(Arc::from),
123            // Build the native limiter (ADR 000033) — `rate`/`burst` were validated non-zero.
124            rate_limit: r.rate_limit.map(|rl| Arc::new(NativeRateLimit::new(rl))),
125            // Compile the Upgrade opt-in (ADR 000048) — tokens were validated non-empty/non-h2c.
126            upgrade: r
127                .upgrade
128                .as_ref()
129                .map(|u| Arc::new(UpgradeConfig::new(&u.protocols, u.idle_timeout_ms))),
130            // Compile the compression opt-in (ADR 000074) — codings / allowlist validated.
131            compression: r
132                .compression
133                .as_ref()
134                .map(|c| Arc::new(CompressionConfig::new(c))),
135        }
136    }
137}
138
139// Manual `Debug`: `LoadedFilter` (behind `resolved_chain`'s `Arc`) doesn't implement it, so this
140// can't be `#[derive(Debug)]`. Reports `resolved_chain` by length — the same information `filters`
141// (the id list, printed in full) already carries, just resolved.
142impl std::fmt::Debug for CompiledRoute {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("CompiledRoute")
145            .field("host", &self.host)
146            .field("path_prefix", &self.path_prefix)
147            .field("method", &self.method)
148            .field("headers", &self.headers)
149            .field("query", &self.query)
150            .field("filters", &self.filters)
151            .field("resolved_chain_len", &self.resolved_chain.len())
152            .field("reads_body", &self.reads_body)
153            .field("backends", &self.backends)
154            .field("strip_prefix", &self.strip_prefix)
155            .field("rate_limit", &self.rate_limit)
156            .field("upgrade", &self.upgrade)
157            .field("compression", &self.compression)
158            .finish()
159    }
160}
161
162/// A route's compiled `[route.upgrade]` (ADR 000048): the lower-cased token allowlist and the
163/// tunnel idle timeout. Pre-normalised at build so the per-request check is a scan over a
164/// (typically one-element) list.
165#[derive(Debug)]
166pub struct UpgradeConfig {
167    protocols: Vec<String>,
168    idle_timeout: Option<std::time::Duration>,
169}
170
171impl UpgradeConfig {
172    pub(crate) fn new(protocols: &[String], idle_timeout_ms: u64) -> Self {
173        Self {
174            protocols: protocols
175                .iter()
176                .map(|p| p.trim().to_ascii_lowercase())
177                .collect(),
178            idle_timeout: (idle_timeout_ms > 0)
179                .then(|| std::time::Duration::from_millis(idle_timeout_ms)),
180        }
181    }
182
183    /// The first token of the client's `Upgrade` header value this route allows (lower-cased
184    /// canonical form), or `None` — the request then stays plain HTTP (the header is stripped).
185    /// RFC 9110 §7.8: token comparison is case-insensitive; the header may list alternatives.
186    pub fn allowed_token(&self, upgrade_header: &str) -> Option<&str> {
187        upgrade_header.split(',').map(str::trim).find_map(|tok| {
188            self.protocols
189                .iter()
190                .find(|p| p.eq_ignore_ascii_case(tok))
191                .map(String::as_str)
192        })
193    }
194
195    /// The established tunnel's idle timeout; `None` = the operator disabled it (`0`).
196    pub fn idle_timeout(&self) -> Option<std::time::Duration> {
197        self.idle_timeout
198    }
199}
200
201/// A route's compiled `[route.compression]` (ADR 000074): the offered codings in server-preference
202/// order, the min-length floor, and the content-type allowlist (lower-cased at build so the
203/// per-response check is an allocation-free case-insensitive scan over a short list).
204#[derive(Debug)]
205pub struct CompressionConfig {
206    algorithms: Vec<CompressionAlgorithm>,
207    min_length: u64,
208    content_types: Vec<String>,
209}
210
211impl CompressionConfig {
212    /// Compile a manifest `[route.compression]` block. Public because the fast-path server's
213    /// negotiation unit tests build configs directly, without a manifest parse.
214    pub fn new(rc: &RouteCompression) -> Self {
215        Self {
216            algorithms: rc.algorithms.clone(),
217            min_length: rc.min_length,
218            content_types: rc
219                .content_types
220                .iter()
221                .map(|ct| ct.trim().to_ascii_lowercase())
222                .collect(),
223        }
224    }
225
226    /// The codings this route offers, in server-preference order (the qvalue tie-break).
227    pub fn algorithms(&self) -> &[CompressionAlgorithm] {
228        &self.algorithms
229    }
230
231    /// The declared-length floor: a response shorter than this is not worth a codec header.
232    pub fn min_length(&self) -> u64 {
233        self.min_length
234    }
235
236    /// Is this `Content-Type` essence (`type/subtype`, parameters already stripped) compressible
237    /// on this route? Case-insensitive scan — the list is ~a dozen entries, no per-request alloc.
238    pub fn content_type_eligible(&self, essence: &str) -> bool {
239        let essence = essence.trim();
240        self.content_types
241            .iter()
242            .any(|ct| ct.eq_ignore_ascii_case(essence))
243    }
244}
245
246/// A manifest route validated against the (already-loaded) filter set and upstream names, carrying
247/// its already-resolved forwarding targets — so `build_active`'s later compile pass does not need
248/// to call `targets()` a second time.
249#[derive(Debug)]
250pub(crate) struct ValidatedRoute<'a> {
251    pub(crate) route: &'a Route,
252    pub(crate) targets: Vec<(&'a str, u32)>,
253}
254
255/// Validate every route's forwarding target (`upstream`/`backends`), weighted split, filter
256/// references, and native rate-limit config — PURELY, against the declared filter ids and
257/// upstream names, with no I/O and no registry mutation (ADR 000013 / 000017 / 000033 / 000034).
258/// Fails closed on the first invalid route, mirroring the checks `build_active` used to run
259/// inline. Takes filter IDS (not loaded filters), so the static `validate_manifest` path shares
260/// it without loading any artifact. Directly unit-testable with hand-built sets.
261pub(crate) fn validate_routes<'a>(
262    routes: &'a [Route],
263    filter_ids: &HashSet<&str>,
264    upstream_names: &HashSet<&str>,
265) -> Result<Vec<ValidatedRoute<'a>>, ControlError> {
266    let mut validated = Vec::with_capacity(routes.len());
267    for r in routes {
268        // The route's forwarding targets: the single `upstream` shorthand or weighted `backends`
269        // (ADR 000034). Both-set / neither-set is fail-closed here.
270        let targets = r.targets().map_err(|reason| ControlError::InvalidRoute {
271            path_prefix: r.matcher.path_prefix.clone(),
272            reason: reason.to_string(),
273        })?;
274        for (name, _) in &targets {
275            if !upstream_names.contains(name) {
276                return Err(ControlError::UnknownRouteUpstream {
277                    path_prefix: r.matcher.path_prefix.clone(),
278                    upstream: (*name).to_string(),
279                });
280            }
281        }
282        // Validate the weighted split (empty / all-zero / over-cap weight / oversized reduced
283        // table) before the registry reconcile, so a bad split never mutates persistent state.
284        let weights: Vec<u32> = targets.iter().map(|(_, w)| *w).collect();
285        weighted::validate_split(&weights).map_err(|reason| ControlError::InvalidRoute {
286            path_prefix: r.matcher.path_prefix.clone(),
287            reason,
288        })?;
289        for f in &r.filters {
290            if !filter_ids.contains(f.as_str()) {
291                return Err(ControlError::UnknownRouteFilter {
292                    path_prefix: r.matcher.path_prefix.clone(),
293                    filter: f.clone(),
294                });
295            }
296        }
297        // Reject a native rate limit that can never serve a token (ADR 000033): a zero `rate`
298        // never refills and a zero `burst` holds nothing — a config typo, fail-closed at build
299        // before the limiter arithmetic ever runs (CWE-20).
300        if let Some(rl) = &r.rate_limit {
301            if rl.rate == 0 {
302                return Err(ControlError::InvalidRouteRateLimit {
303                    path_prefix: r.matcher.path_prefix.clone(),
304                    reason: "rate must be non-zero".to_string(),
305                });
306            }
307            if rl.burst == 0 {
308                return Err(ControlError::InvalidRouteRateLimit {
309                    path_prefix: r.matcher.path_prefix.clone(),
310                    reason: "burst must be non-zero".to_string(),
311                });
312            }
313        }
314        // Validate the Upgrade opt-in (ADR 000048): an empty allowlist is a config typo, and
315        // `h2c` is rejected outright — Plecto has no h2c on either side (ADR 000015), and
316        // forwarding `Upgrade: h2c` is the classic smuggling vector the allowlist exists to block.
317        if let Some(up) = &r.upgrade {
318            if up.protocols.is_empty() {
319                return Err(ControlError::InvalidRoute {
320                    path_prefix: r.matcher.path_prefix.clone(),
321                    reason: "upgrade.protocols must be non-empty".to_string(),
322                });
323            }
324            for p in &up.protocols {
325                if p.trim().is_empty() {
326                    return Err(ControlError::InvalidRoute {
327                        path_prefix: r.matcher.path_prefix.clone(),
328                        reason: "upgrade.protocols contains an empty token".to_string(),
329                    });
330                }
331                if p.trim().eq_ignore_ascii_case("h2c") {
332                    return Err(ControlError::InvalidRoute {
333                        path_prefix: r.matcher.path_prefix.clone(),
334                        reason: "h2c upgrade is not supported (h2 is TLS+ALPN only; \
335                                 forwarding h2c enables request smuggling)"
336                            .to_string(),
337                    });
338                }
339            }
340        }
341        // Validate the compression opt-in (ADR 000074): an empty coding list or an empty /
342        // non-`type/subtype` allowlist entry is a config typo — fail closed at build, before a
343        // request ever negotiates against it.
344        if let Some(c) = &r.compression {
345            if c.algorithms.is_empty() {
346                return Err(ControlError::InvalidRoute {
347                    path_prefix: r.matcher.path_prefix.clone(),
348                    reason: "compression.algorithms must be non-empty".to_string(),
349                });
350            }
351            let mut seen = HashSet::new();
352            for a in &c.algorithms {
353                if !seen.insert(a) {
354                    return Err(ControlError::InvalidRoute {
355                        path_prefix: r.matcher.path_prefix.clone(),
356                        reason: format!("compression.algorithms lists `{}` twice", a.token()),
357                    });
358                }
359            }
360            if c.content_types.is_empty() {
361                return Err(ControlError::InvalidRoute {
362                    path_prefix: r.matcher.path_prefix.clone(),
363                    reason: "compression.content_types must be non-empty".to_string(),
364                });
365            }
366            for ct in &c.content_types {
367                let essence = ct.trim();
368                let well_formed = essence
369                    .split_once('/')
370                    .is_some_and(|(t, s)| !t.is_empty() && !s.is_empty())
371                    && !essence.contains([';', ',', ' ']);
372                if !well_formed {
373                    return Err(ControlError::InvalidRoute {
374                        path_prefix: r.matcher.path_prefix.clone(),
375                        reason: format!(
376                            "compression.content_types entry `{essence}` is not a type/subtype"
377                        ),
378                    });
379                }
380            }
381        }
382        validated.push(ValidatedRoute { route: r, targets });
383    }
384    Ok(validated)
385}
386
387/// The request attributes route matching reads (ADR 000034), borrowed so matching stays
388/// allocation-free. `path` may carry `?query`; the query is split out only inside `select`.
389pub(crate) struct RequestParts<'a> {
390    pub(crate) authority: &'a str,
391    pub(crate) path: &'a str,
392    pub(crate) method: &'a str,
393    pub(crate) headers: &'a [Header],
394}
395
396/// What [`crate::ConfigSnapshot::find_route`] hands the fast-path server: which route matched
397/// (`index`, used to dispatch its chain) plus the data needed to forward — the weighted backends
398/// (the server picks a group, then a healthy instance from it) and the optional prefix strip. Owned
399/// / `Arc`-shared so it survives a move into `spawn_blocking`.
400#[derive(Debug, Clone)]
401pub struct RouteInfo {
402    pub index: usize,
403    pub(crate) backends: Arc<WeightedBackends>,
404    /// `Arc<str>` so building this per-request `RouteInfo` from the compiled route (`find_route`,
405    /// called on every request) is an atomic refcount bump, not a heap allocation.
406    pub strip_prefix: Option<Arc<str>>,
407    /// Whether this route has any filters (drives the header-side chain dispatch).
408    pub has_filters: bool,
409    /// Whether any filter on this route reads the request body (exports `on-request-body`, ADR
410    /// 000038). The fast path buffers the body ONLY when this is `true`; a route of header-only
411    /// filters keeps the zero-copy streaming path (the real fix for the body-tax, docs/servey).
412    pub reads_body: bool,
413    /// This route's native rate limiter (ADR 000033), or `None` for unlimited. `Arc`-shared with the
414    /// live config so the per-request `RouteInfo` consults the route's persistent buckets.
415    pub(crate) rate_limit: Option<Arc<NativeRateLimit>>,
416    /// This route's Upgrade opt-in (ADR 000048), or `None` for deny-by-default. The fast path
417    /// tunnels only when the client's token is allowlisted here.
418    pub upgrade: Option<Arc<UpgradeConfig>>,
419    /// This route's compression opt-in (ADR 000074), or `None` for never-transform. The fast path
420    /// negotiates and compresses AFTER the response chain, on the streamed body filters never see.
421    pub compression: Option<Arc<CompressionConfig>>,
422}
423
424impl RouteInfo {
425    /// Pick the upstream group to forward this request to from the route's weighted traffic split
426    /// (ADR 000034): the next backend in the split order that has an eligible instance (renormalize
427    /// over healthy), or `None` when no backend is eligible (the fast path then fails closed 503,
428    /// the same no-healthy fault as a single upstream). Lock-free.
429    pub fn pick_upstream(&self) -> Option<Arc<UpstreamGroup>> {
430        self.backends.pick()
431    }
432
433    /// Apply this route's host-native prefix strip to the path the fast-path server forwards to
434    /// the upstream. The chain already ran against the original path; this only affects what the
435    /// upstream sees. No rule (or a non-matching path) leaves the path unchanged — borrowed, so
436    /// the common no-strip case allocates nothing.
437    pub fn rewrite_path<'a>(&self, path: &'a str) -> Cow<'a, str> {
438        rewrite_path(path, self.strip_prefix.as_deref())
439    }
440
441    /// Consult this route's native rate limiter (ADR 000033) for one request, keyed on the
442    /// connection `peer`. `Allow` when the route has no limiter or a token was available;
443    /// `Limit { retry_after_ms }` when the bucket is empty (the fast path fails closed with 429).
444    /// Called BEFORE the filter chain so a flood is shed without spending WASM CPU.
445    pub fn check_rate_limit(&self, peer: IpAddr) -> RateLimitDecision {
446        match &self.rate_limit {
447            Some(rl) => rl.check(peer),
448            None => RateLimitDecision::Allow,
449        }
450    }
451}
452
453/// Normalise an authority for host matching: drop any `:port` and a trailing dot, borrowed — no
454/// allocation on the request path. (`example.COM:8443` → `example.COM`; case is handled at the
455/// comparison via `eq_ignore_ascii_case` against the pre-lowered route host.) IPv6 literals in
456/// brackets keep their colons inside `[...]`.
457fn normalize_host(authority: &str) -> &str {
458    let host = if let Some(rest) = authority.strip_prefix('[') {
459        // `[::1]:8080` → `[::1]`
460        match rest.split_once(']') {
461            // In-bounds by construction (`authority = "[" + inner + "]" + rest`, ASCII
462            // delimiters), but stay total anyway — this is the request hot path, and `get` keeps
463            // it panic-free under the crate's `indexing_slicing` discipline without an `allow`.
464            Some((inner, _)) => authority.get(..inner.len() + 2).unwrap_or(authority),
465            None => authority,
466        }
467    } else {
468        authority.split(':').next().unwrap_or(authority)
469    };
470    // Absolute-form hosts carry a trailing dot (`example.com.`); strip a single one so they match
471    // the canonical `example.com` route and cannot silently slip to a wildcard route (/
472    // CWE-644). IPv6 literals (`[...]`) never end in a dot, so this only affects DNS names.
473    match host.strip_suffix('.') {
474        Some(stripped) if !host.starts_with('[') => stripped,
475        _ => host,
476    }
477}
478
479/// Normalize a request target's PATH for routing and forwarding so the proxy and the origin agree
480/// on it (CWE-22 Path Traversal / CWE-436 Interpretation Conflict). Per-route filter chains
481/// are an access-control boundary, so route selection IS access control: a `..` segment that selects
482/// a laxer route here but resolves to a stricter path at the upstream would bypass that route's
483/// filters. The fast path normalizes once at ingress and then routes, runs the chain, and forwards
484/// on the SAME normalized path, so the origin cannot re-derive a different path.
485///
486/// Returns the normalized `path[?query]`, or `None` to reject (the server fails closed with 400).
487/// Policy: reject control bytes, backslash, and percent-encoded separators/dots (`%2e`/`%2f`/`%5c`,
488/// ambiguous between front-end and back-end), then lexically remove `.`/`..` segments; a `..` that
489/// escapes the root is rejected. The query string (after `?`) is preserved verbatim.
490/// A path with no `.`/`..` segments — the overwhelming majority — is returned borrowed
491/// (`Cow::Borrowed`), so the per-request common case allocates nothing.
492pub fn normalize_path(target: &str) -> Option<Cow<'_, str>> {
493    let (raw, query) = match target.split_once('?') {
494        Some((p, q)) => (p, Some(q)),
495        None => (target, None),
496    };
497    // A non-origin target (asterisk-form, or no leading `/`) cannot fall under a `/`-prefixed
498    // route, so it needs no traversal handling — pass it through unchanged.
499    if !raw.starts_with('/') {
500        return Some(Cow::Borrowed(target));
501    }
502    // Reject control bytes / backslash and percent-encoded separators or dots: an origin that
503    // decodes `%2f`/`%2e`/`%5c` would re-derive a path different from the one we route on and
504    // forward, re-opening the front-end/back-end normalization gap.
505    if raw.bytes().any(|b| b < 0x20 || b == 0x7f) || raw.contains('\\') {
506        return None;
507    }
508    if contains_encoded_separator(raw) {
509        return None;
510    }
511    // No `.`/`..` segment → the lexical resolution below is the identity (split + join over `/`
512    // reproduces the input byte-for-byte, empty segments included), so return the input borrowed.
513    if !raw.split('/').any(|seg| seg == "." || seg == "..") {
514        return Some(Cow::Borrowed(target));
515    }
516    // Lexically resolve `.` / `..` over `/`-separated segments. `out` always holds the leading ""
517    // (root); a `..` that would pop past it escapes the root and is rejected (fail-closed).
518    let mut out: Vec<&str> = Vec::new();
519    for seg in raw.split('/') {
520        match seg {
521            "." => {}
522            ".." => {
523                if out.len() <= 1 {
524                    return None;
525                }
526                out.pop();
527            }
528            other => out.push(other),
529        }
530    }
531    let mut norm = out.join("/");
532    if norm.is_empty() {
533        norm.push('/');
534    }
535    if let Some(q) = query {
536        norm.push('?');
537        norm.push_str(q);
538    }
539    Some(Cow::Owned(norm))
540}
541
542/// Does the path contain a percent-encoded separator or dot (`%2e`/`%2f`/`%5c`, any hex case)?
543// `.windows(3)` guarantees each `w` has exactly 3 elements, so w[0..=2] are always in bounds.
544#[allow(clippy::indexing_slicing)]
545fn contains_encoded_separator(path: &str) -> bool {
546    path.as_bytes().windows(3).any(|w| {
547        w[0] == b'%'
548            && matches!(
549                (w[1], w[2]),
550                (b'2', b'e' | b'E') | (b'2', b'f' | b'F') | (b'5', b'c' | b'C')
551            )
552    })
553}
554
555/// Does `path` fall under `prefix` on a `/` boundary? `/api` matches `/api` and `/api/x` but not
556/// `/apix`; `/` matches everything. `path` is the BARE path — `select` strips the `?query` /
557/// `#fragment` once per request (not once per route), so a bare prefix with a query
558/// (`/search?q=x` under `/search`) still matches (review f000005 P1#1). Rewriting
559/// (`rewrite_path`) keeps the query; this is purely the selection predicate.
560fn path_under_prefix(prefix: &str, path: &str) -> bool {
561    if !path.starts_with(prefix) {
562        return false;
563    }
564    if path.len() == prefix.len() || prefix.ends_with('/') {
565        return true;
566    }
567    path.as_bytes().get(prefix.len()) == Some(&b'/')
568}
569
570/// Does the request match every dimension this route specifies (ADR 000034)? All specified
571/// dimensions are ANDed; an unspecified one is a wildcard. `host` is pre-normalised, `query` is the
572/// already-split query string. Header name is matched case-insensitively and value exact; query name
573/// is case-sensitive. Byte-/string-exact comparison never panics on untrusted input (no-panic tenet).
574fn route_matches(
575    r: &CompiledRoute,
576    host: &str,
577    path: &str,
578    method: &str,
579    headers: &[Header],
580    query: &str,
581) -> bool {
582    if let Some(h) = &r.host
583        && !h.eq_ignore_ascii_case(host)
584    {
585        return false;
586    }
587    if !path_under_prefix(&r.path_prefix, path) {
588        return false;
589    }
590    if let Some(m) = &r.method
591        && method != m
592    {
593        return false;
594    }
595    for (name, value) in &r.headers {
596        // Match the FIRST header with this name (case-insensitive name); a later duplicate must not
597        // flip the decision (CWE-436): the origin receives every copy and may read a different one,
598        // so deciding on the first occurrence keeps our routing aligned with Gateway-API's
599        // "first match entry decides" and mirrors the query rule below.
600        match headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)) {
601            Some(h) if h.value.as_slice() == value.as_bytes() => {}
602            _ => return false,
603        }
604    }
605    for (name, value) in &r.query {
606        if !query_param_matches(query, name, value) {
607            return false;
608        }
609    }
610    true
611}
612
613/// Does the query string contain `name=value` exactly (ADR 000034)? Case-sensitive name. The FIRST
614/// occurrence of `name` decides (Gateway-API: a repeated key matches its first value); a malformed
615/// pair (no `=`) is skipped; an absent name is no-match.
616fn query_param_matches(query: &str, name: &str, value: &str) -> bool {
617    for pair in query.split('&') {
618        if let Some((k, v)) = pair.split_once('=')
619            && k == name
620        {
621            return v == value;
622        }
623    }
624    false
625}
626
627/// Select the best route for `req` (ADR 000013 / 000034): among all routes whose every specified
628/// match dimension is satisfied, the most specific wins, ordered by host-constrained > longest
629/// `path_prefix` > `method` present > more header matches > more query matches, with the earliest
630/// manifest index the final stable tie-break (Gateway-API v1.5.0 precedence, adapted). Returns the
631/// winner's index, or `None` (no route → the server responds 404).
632pub(crate) fn select(routes: &[CompiledRoute], req: &RequestParts<'_>) -> Option<usize> {
633    let host = normalize_host(req.authority);
634    let query = req.path.split_once('?').map(|(_, q)| q).unwrap_or("");
635    // Strip the query/fragment once here; `path_under_prefix` then compares bare paths per route.
636    let path = req.path.split(['?', '#']).next().unwrap_or(req.path);
637    routes
638        .iter()
639        .enumerate()
640        .filter(|(_, r)| route_matches(r, host, path, req.method, req.headers, query))
641        // Specificity key, most-significant first. `max_by_key` keeps the LAST max on ties, so the
642        // final `usize::MAX - i` (earliest index largest) makes the earliest manifest route win.
643        .max_by_key(|(i, r)| {
644            (
645                r.host.is_some(),
646                r.path_prefix.len(),
647                r.method.is_some(),
648                r.headers.len(),
649                r.query.len(),
650                usize::MAX - i,
651            )
652        })
653        .map(|(i, _)| i)
654}
655
656/// Apply a route's host-native prefix strip to the forwarded path (the chain already saw the
657/// original). Leaves the path unchanged if it does not start with `strip`; always keeps a
658/// leading `/`. `/api` stripped from `/api/users` → `/users`; from `/api` → `/`. The unchanged
659/// and clean-strip cases are borrowed — no allocation on the request path.
660///
661/// The strip is SEGMENT-boundary strict, mirroring `path_under_prefix`'s discipline: `/api`
662/// strips from `/api` and `/api/…` but NOT from `/apix/…` — a mid-segment strip would forward
663/// `/apix/y` as `/x/y`, an origin-side path confusion (CWE-436-adjacent) whenever `strip_prefix`
664/// is laxer than the route's `path_prefix`.
665pub(crate) fn rewrite_path<'a>(path: &'a str, strip: Option<&str>) -> Cow<'a, str> {
666    let Some(strip) = strip else {
667        return Cow::Borrowed(path);
668    };
669    let Some(rest) = path.strip_prefix(strip) else {
670        return Cow::Borrowed(path);
671    };
672    if rest.is_empty() {
673        Cow::Borrowed("/")
674    } else if rest.starts_with('/') {
675        Cow::Borrowed(rest)
676    } else if strip.ends_with('/') {
677        // The strip consumed the boundary slash (`/api/` from `/api/users`): still a segment
678        // match — re-add the leading `/`.
679        Cow::Owned(format!("/{rest}"))
680    } else {
681        // Mid-segment "prefix" (`/api` vs `/apix/y`): not a path-segment match — don't strip.
682        Cow::Borrowed(path)
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::manifest::{
690        AddressSpec, CircuitBreaker, HealthConfig, LbAlgorithm, OutlierDetection, Upstream,
691    };
692    use crate::upstream::UpstreamRegistry;
693
694    /// A throwaway upstream group named after `upstream` — these tests exercise `select` /
695    /// `rewrite_path`, which never touch the group's contents, only its identity.
696    fn group(upstream: &str) -> Arc<UpstreamGroup> {
697        let reg = UpstreamRegistry::new();
698        reg.reconcile(
699            &[Upstream {
700                name: upstream.to_string(),
701                addresses: vec![AddressSpec::Bare("127.0.0.1:9000".to_string())],
702                lb_algorithm: LbAlgorithm::RoundRobin,
703                hash: None,
704                tls: None,
705                resolve_interval_ms: 0,
706                health: HealthConfig {
707                    path: "/healthz".to_string(),
708                    interval_ms: 1000,
709                    timeout_ms: 500,
710                    healthy_threshold: 1,
711                    unhealthy_threshold: 1,
712                    port: None,
713                },
714                request_timeout_ms: 30_000,
715                max_retries: 1,
716                overall_timeout_ms: 0,
717                circuit_breaker: CircuitBreaker::default(),
718                outlier_detection: OutlierDetection::default(),
719            }],
720            std::path::Path::new("."),
721        )
722        .unwrap();
723        reg.group(upstream).unwrap()
724    }
725
726    /// A single-upstream weighted set for a route under test.
727    fn backends(upstream: &str) -> Arc<WeightedBackends> {
728        Arc::new(WeightedBackends::new(vec![(group(upstream), 1)]).unwrap())
729    }
730
731    fn route(host: Option<&str>, prefix: &str, upstream: &str) -> CompiledRoute {
732        CompiledRoute {
733            host: host.map(|h| h.to_ascii_lowercase()),
734            path_prefix: prefix.to_string(),
735            method: None,
736            headers: vec![],
737            query: vec![],
738            filters: vec![],
739            resolved_chain: vec![],
740            reads_body: false,
741            backends: backends(upstream),
742            strip_prefix: None,
743            rate_limit: None,
744            upgrade: None,
745            compression: None,
746        }
747    }
748
749    fn header(name: &str, value: &str) -> Header {
750        Header {
751            name: name.to_string(),
752            value: value.as_bytes().to_vec(),
753        }
754    }
755
756    /// A GET request with no headers, for the path/host-only tests.
757    fn parts<'a>(authority: &'a str, path: &'a str) -> RequestParts<'a> {
758        RequestParts {
759            authority,
760            path,
761            method: "GET",
762            headers: &[],
763        }
764    }
765
766    /// A manifest [`Route`] fixture for `validate_routes` tests — no `Host` / OCI artifact store
767    /// needed, since `validate_routes` never touches the loaded filters' contents, only their ids.
768    fn manifest_route(
769        upstream: Option<&str>,
770        backends: Vec<crate::manifest::Backend>,
771        filters: Vec<&str>,
772        rate_limit: Option<crate::manifest::RouteRateLimit>,
773    ) -> Route {
774        Route {
775            matcher: crate::manifest::RouteMatch {
776                host: None,
777                path_prefix: "/".to_string(),
778                method: None,
779                headers: Default::default(),
780                query: Default::default(),
781            },
782            filters: filters.into_iter().map(str::to_string).collect(),
783            upstream: upstream.map(str::to_string),
784            backends,
785            strip_prefix: None,
786            rate_limit,
787            upgrade: None,
788            compression: None,
789        }
790    }
791
792    #[test]
793    fn validate_routes_rejects_unknown_upstream() {
794        let routes = vec![manifest_route(Some("ghost"), vec![], vec![], None)];
795        let filters = HashSet::new();
796        let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
797        let err = validate_routes(&routes, &filters, &upstream_names).unwrap_err();
798        assert!(matches!(
799            err,
800            ControlError::UnknownRouteUpstream { upstream, .. } if upstream == "ghost"
801        ));
802    }
803
804    #[test]
805    fn validate_routes_rejects_unknown_filter() {
806        let routes = vec![manifest_route(Some("real"), vec![], vec!["missing"], None)];
807        let filters = HashSet::new();
808        let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
809        let err = validate_routes(&routes, &filters, &upstream_names).unwrap_err();
810        assert!(matches!(
811            err,
812            ControlError::UnknownRouteFilter { filter, .. } if filter == "missing"
813        ));
814    }
815
816    #[test]
817    fn validate_routes_rejects_all_zero_backend_weight() {
818        let routes = vec![manifest_route(
819            None,
820            vec![crate::manifest::Backend {
821                upstream: "real".to_string(),
822                weight: 0,
823            }],
824            vec![],
825            None,
826        )];
827        let filters = HashSet::new();
828        let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
829        assert!(matches!(
830            validate_routes(&routes, &filters, &upstream_names),
831            Err(ControlError::InvalidRoute { .. })
832        ));
833    }
834
835    #[test]
836    fn validate_routes_rejects_zero_rate_or_burst() {
837        let filters = HashSet::new();
838        let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
839
840        let zero_rate = vec![manifest_route(
841            Some("real"),
842            vec![],
843            vec![],
844            Some(crate::manifest::RouteRateLimit {
845                rate: 0,
846                burst: 5,
847                key: Default::default(),
848            }),
849        )];
850        assert!(matches!(
851            validate_routes(&zero_rate, &filters, &upstream_names),
852            Err(ControlError::InvalidRouteRateLimit { .. })
853        ));
854
855        let zero_burst = vec![manifest_route(
856            Some("real"),
857            vec![],
858            vec![],
859            Some(crate::manifest::RouteRateLimit {
860                rate: 5,
861                burst: 0,
862                key: Default::default(),
863            }),
864        )];
865        assert!(matches!(
866            validate_routes(&zero_burst, &filters, &upstream_names),
867            Err(ControlError::InvalidRouteRateLimit { .. })
868        ));
869    }
870
871    #[test]
872    fn validate_routes_rejects_h2c_and_empty_upgrade_tokens() {
873        // ADR 000048: `h2c` must never be tunnelable (h2 is TLS+ALPN only, ADR 000015; a
874        // forwarded `Upgrade: h2c` is the classic smuggling vector), and an empty allowlist or
875        // token is a config typo — both fail closed at build.
876        let filters = HashSet::new();
877        let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
878        let with_upgrade = |protocols: Vec<&str>| {
879            let mut r = manifest_route(Some("real"), vec![], vec![], None);
880            r.upgrade = Some(crate::manifest::RouteUpgrade {
881                protocols: protocols.into_iter().map(str::to_string).collect(),
882                idle_timeout_ms: 300_000,
883            });
884            vec![r]
885        };
886
887        for bad in [vec![], vec!["H2C"], vec!["websocket", "h2c"], vec!["  "]] {
888            assert!(
889                matches!(
890                    validate_routes(&with_upgrade(bad.clone()), &filters, &upstream_names),
891                    Err(ControlError::InvalidRoute { .. })
892                ),
893                "{bad:?} must be rejected"
894            );
895        }
896        assert!(
897            validate_routes(&with_upgrade(vec!["websocket"]), &filters, &upstream_names).is_ok()
898        );
899    }
900
901    #[test]
902    fn upgrade_config_matches_tokens_case_insensitively_and_ignores_unlisted() {
903        let cfg = UpgradeConfig::new(&["WebSocket".to_string()], 300_000);
904        assert_eq!(cfg.allowed_token("websocket"), Some("websocket"));
905        assert_eq!(cfg.allowed_token("WEBSOCKET"), Some("websocket"));
906        assert_eq!(
907            cfg.allowed_token("h2c, websocket"),
908            Some("websocket"),
909            "the first ALLOWLISTED token wins; unlisted ones are skipped, never forwarded"
910        );
911        assert_eq!(cfg.allowed_token("h2c"), None);
912        assert_eq!(cfg.allowed_token(""), None);
913
914        assert_eq!(
915            UpgradeConfig::new(&["websocket".to_string()], 0).idle_timeout(),
916            None,
917            "0 disables the idle timer"
918        );
919    }
920
921    #[test]
922    fn validate_routes_accepts_a_valid_route_and_carries_its_resolved_targets() {
923        let routes = vec![manifest_route(Some("real"), vec![], vec![], None)];
924        let filters = HashSet::new();
925        let upstream_names: HashSet<&str> = ["real"].into_iter().collect();
926        let validated = validate_routes(&routes, &filters, &upstream_names).unwrap();
927        assert_eq!(validated.len(), 1);
928        assert_eq!(validated[0].targets, vec![("real", 1)]);
929    }
930
931    #[test]
932    fn longest_prefix_wins() {
933        let routes = vec![
934            route(None, "/", "root"),
935            route(None, "/api", "api"),
936            route(None, "/api/v2", "v2"),
937        ];
938        assert_eq!(select(&routes, &parts("h", "/api/v2/x")), Some(2));
939        assert_eq!(select(&routes, &parts("h", "/api/users")), Some(1));
940        assert_eq!(select(&routes, &parts("h", "/other")), Some(0));
941    }
942
943    #[test]
944    fn prefix_matches_on_boundary_only() {
945        let routes = vec![route(None, "/api", "api")];
946        assert_eq!(select(&routes, &parts("h", "/api")), Some(0));
947        assert_eq!(select(&routes, &parts("h", "/api/x")), Some(0));
948        assert_eq!(
949            select(&routes, &parts("h", "/apix")),
950            None,
951            "no boundary match"
952        );
953    }
954
955    #[test]
956    fn query_or_fragment_acts_as_a_prefix_boundary() {
957        // review f000005 P1#1: the inbound path carries the query (`path_and_query`), so a bare
958        // prefix followed by `?`/`#` must still match — `/search?q=foo` is under `/search` exactly
959        // like `/search` and `/search/x` are. The earlier code treated `?` as path text and 404'd.
960        let routes = vec![route(None, "/search", "s")];
961        assert_eq!(select(&routes, &parts("h", "/search")), Some(0));
962        assert_eq!(
963            select(&routes, &parts("h", "/search?q=foo")),
964            Some(0),
965            "a bare prefix followed by a query must match"
966        );
967        assert_eq!(select(&routes, &parts("h", "/search/x?q=foo")), Some(0));
968        assert_eq!(select(&routes, &parts("h", "/search#frag")), Some(0));
969        // the boundary is still a real boundary: `/searching` is not under `/search`.
970        assert_eq!(
971            select(&routes, &parts("h", "/searching?q=foo")),
972            None,
973            "a longer word is not a boundary match even with a query"
974        );
975    }
976
977    #[test]
978    fn host_constraint_filters_and_breaks_ties() {
979        let routes = vec![
980            route(None, "/api", "wild"),
981            route(Some("example.com"), "/api", "vhost"),
982        ];
983        // request to example.com (with a port) prefers the host-constrained route
984        assert_eq!(
985            select(&routes, &parts("example.com:8443", "/api/x")),
986            Some(1)
987        );
988        // a different host falls back to the wildcard
989        assert_eq!(select(&routes, &parts("other.test", "/api/x")), Some(0));
990    }
991
992    #[test]
993    fn no_match_returns_none() {
994        let routes = vec![route(Some("a.test"), "/api", "u")];
995        assert_eq!(select(&routes, &parts("b.test", "/api")), None);
996        let empty: Vec<CompiledRoute> = vec![];
997        assert_eq!(select(&empty, &parts("a", "/")), None);
998    }
999
1000    #[test]
1001    fn method_match_filters_and_outranks_a_bare_path() {
1002        // Two routes on the same path: one bare, one method-constrained. A POST takes the
1003        // method-constrained route (more specific); a GET falls to the bare one (ADR 000034).
1004        let mut post = route(None, "/api", "writes");
1005        post.method = Some("POST".to_string());
1006        let routes = vec![route(None, "/api", "reads"), post];
1007
1008        let mut p = parts("h", "/api/x");
1009        p.method = "POST";
1010        assert_eq!(select(&routes, &p), Some(1), "POST takes the method route");
1011        p.method = "GET";
1012        assert_eq!(select(&routes, &p), Some(0), "GET falls to the bare route");
1013    }
1014
1015    #[test]
1016    fn header_match_is_case_insensitive_name_exact_value_and_anded() {
1017        // A header-constrained route matches only when the request carries every named header with
1018        // the exact value; the header NAME is case-insensitive, the VALUE exact (ADR 000034).
1019        let mut v2 = route(None, "/api", "v2");
1020        v2.headers = vec![("x-api-version".to_string(), "2".to_string())];
1021        let routes = vec![route(None, "/api", "v1"), v2];
1022
1023        let hdrs = [header("X-Api-Version", "2")];
1024        let mut p = parts("h", "/api");
1025        p.headers = &hdrs;
1026        assert_eq!(
1027            select(&routes, &p),
1028            Some(1),
1029            "a case-different header name still matches, and outranks the bare route"
1030        );
1031
1032        let wrong = [header("x-api-version", "3")];
1033        p.headers = &wrong;
1034        assert_eq!(
1035            select(&routes, &p),
1036            Some(0),
1037            "a different value does not match"
1038        );
1039
1040        p.headers = &[];
1041        assert_eq!(select(&routes, &p), Some(0), "absent header → bare route");
1042    }
1043
1044    #[test]
1045    fn header_match_decides_on_the_first_duplicate() {
1046        // A duplicate header must not let a later copy flip the routing decision (CWE-436): the
1047        // FIRST occurrence of the name decides, like the query rule. Here the first `x-api-version`
1048        // is `1`, so the header route (which wants `2`) must NOT be selected even though a later
1049        // copy is `2` — otherwise our routing would disagree with an origin that reads the first.
1050        let mut v2 = route(None, "/api", "v2");
1051        v2.headers = vec![("x-api-version".to_string(), "2".to_string())];
1052        let routes = vec![route(None, "/api", "v1"), v2];
1053
1054        let hdrs = [header("x-api-version", "1"), header("x-api-version", "2")];
1055        let mut p = parts("h", "/api");
1056        p.headers = &hdrs;
1057        assert_eq!(
1058            select(&routes, &p),
1059            Some(0),
1060            "the first duplicate value (1) decides, so the v2 header route does not match"
1061        );
1062    }
1063
1064    #[test]
1065    fn query_match_is_case_sensitive_name_first_value_and_handles_malformed() {
1066        // Query name is case-sensitive (asymmetric with headers); the first occurrence's value
1067        // decides; a malformed (`=`-less) parameter is skipped (ADR 000034).
1068        let mut beta = route(None, "/api", "beta");
1069        beta.query = vec![("flag".to_string(), "on".to_string())];
1070        let routes = vec![route(None, "/api", "stable"), beta];
1071
1072        assert_eq!(
1073            select(&routes, &parts("h", "/api?flag=on")),
1074            Some(1),
1075            "exact query value matches"
1076        );
1077        assert_eq!(
1078            select(&routes, &parts("h", "/api?flag=off")),
1079            Some(0),
1080            "wrong value → bare route"
1081        );
1082        assert_eq!(
1083            select(&routes, &parts("h", "/api?Flag=on")),
1084            Some(0),
1085            "query name is case-sensitive"
1086        );
1087        assert_eq!(
1088            select(&routes, &parts("h", "/api?flag=on&flag=off")),
1089            Some(1),
1090            "the first occurrence of a repeated key decides"
1091        );
1092        assert_eq!(
1093            select(&routes, &parts("h", "/api?flag&x=1")),
1094            Some(0),
1095            "a malformed (=-less) parameter is skipped, so the constraint is unmet"
1096        );
1097    }
1098
1099    #[test]
1100    fn precedence_orders_method_above_header_count() {
1101        // Gateway-API precedence (ADR 000034 1b): method-present outranks a larger header count.
1102        // Route A: 2 header matches, no method. Route B: 1 header match + a method. A POST that
1103        // satisfies BOTH must take B (method beats header count), not A.
1104        let mut a = route(None, "/api", "a");
1105        a.headers = vec![
1106            ("h1".to_string(), "1".to_string()),
1107            ("h2".to_string(), "2".to_string()),
1108        ];
1109        let mut b = route(None, "/api", "b");
1110        b.headers = vec![("h1".to_string(), "1".to_string())];
1111        b.method = Some("POST".to_string());
1112        let routes = vec![a, b];
1113
1114        let hdrs = [header("h1", "1"), header("h2", "2")];
1115        let mut p = parts("h", "/api");
1116        p.method = "POST";
1117        p.headers = &hdrs;
1118        assert_eq!(
1119            select(&routes, &p),
1120            Some(1),
1121            "method-present outranks the larger header count"
1122        );
1123    }
1124
1125    #[test]
1126    fn strip_prefix_keeps_leading_slash() {
1127        assert_eq!(rewrite_path("/api/users", Some("/api")), "/users");
1128        assert_eq!(rewrite_path("/api", Some("/api")), "/");
1129        assert_eq!(rewrite_path("/api/", Some("/api")), "/");
1130        assert_eq!(rewrite_path("/other", Some("/api")), "/other", "no strip");
1131        assert_eq!(rewrite_path("/api/x", None), "/api/x", "no rule");
1132    }
1133
1134    #[test]
1135    fn strip_prefix_is_segment_boundary_strict() {
1136        // Regression (large-review finding): `/api` must NOT strip mid-segment from `/apix/y` —
1137        // forwarding it as `/x/y` would be an origin-side path confusion (CWE-436-adjacent),
1138        // inconsistent with `path_under_prefix`'s boundary discipline.
1139        assert_eq!(
1140            rewrite_path("/apix/y", Some("/api")),
1141            "/apix/y",
1142            "a mid-segment match must not strip"
1143        );
1144        // A strip ending in `/` consumed the boundary slash — still a segment match.
1145        assert_eq!(rewrite_path("/api/users", Some("/api/")), "/users");
1146    }
1147
1148    #[test]
1149    fn normalize_host_drops_port_and_preserves_case() {
1150        // Host matching is case-insensitive and port-insensitive (CWE-644: the routing decision
1151        // must not be steered by case tricks or a `:port` suffix a client appended). Case is left
1152        // as-is here — the comparison in `route_matches` is `eq_ignore_ascii_case`, so the request
1153        // path stays allocation-free.
1154        assert_eq!(normalize_host("EXAMPLE.com:8443"), "EXAMPLE.com");
1155        assert_eq!(normalize_host("Example.Com"), "Example.Com");
1156        assert_eq!(normalize_host("host:80"), "host");
1157    }
1158
1159    #[test]
1160    fn normalize_host_handles_ipv6_literals() {
1161        // IPv6 literals keep the colons inside `[...]`; only a trailing `:port` is dropped.
1162        assert_eq!(normalize_host("[::1]:8080"), "[::1]");
1163        assert_eq!(normalize_host("[::1]"), "[::1]");
1164        assert_eq!(normalize_host("[2001:DB8::1]:443"), "[2001:DB8::1]");
1165    }
1166
1167    #[test]
1168    fn normalize_host_is_panic_free_on_malformed_authority() {
1169        // A malformed authority (an unclosed bracket, a lone bracket, an empty string) must not
1170        // panic the data plane on the bracket-slice arithmetic — it just yields a non-matching
1171        // host. The fast path normalises EVERY inbound authority, so a single OOB slice here
1172        // would be a remote DoS.
1173        assert_eq!(
1174            normalize_host("[::1"),
1175            "[::1",
1176            "unclosed bracket returned as-is"
1177        );
1178        assert_eq!(normalize_host("["), "[", "lone bracket does not panic");
1179        assert_eq!(
1180            normalize_host("[]"),
1181            "[]",
1182            "empty bracket pair does not panic"
1183        );
1184        assert_eq!(normalize_host(""), "", "empty authority does not panic");
1185        assert_eq!(normalize_host("[]:9"), "[]");
1186    }
1187
1188    #[test]
1189    fn normalize_host_strips_trailing_dot() {
1190        // / CWE-644: an absolute-form host (`example.com.`) must canonicalise to
1191        // `example.com` so it matches the host-constrained route instead of slipping to a wildcard.
1192        assert_eq!(normalize_host("example.com."), "example.com");
1193        assert_eq!(normalize_host("EXAMPLE.COM.:8443"), "EXAMPLE.COM");
1194        assert_eq!(normalize_host("example.com"), "example.com");
1195        // an IPv6 literal is unaffected (never ends in a dot).
1196        assert_eq!(normalize_host("[::1]"), "[::1]");
1197    }
1198
1199    #[test]
1200    fn normalize_path_resolves_dot_segments_and_preserves_query() {
1201        assert_eq!(
1202            normalize_path("/public/../admin").as_deref(),
1203            Some("/admin")
1204        );
1205        assert_eq!(normalize_path("/a/./b").as_deref(), Some("/a/b"));
1206        assert_eq!(normalize_path("/a/b/../c").as_deref(), Some("/a/c"));
1207        assert_eq!(normalize_path("/").as_deref(), Some("/"));
1208        assert_eq!(normalize_path("/api/").as_deref(), Some("/api/"));
1209        assert_eq!(normalize_path("/api").as_deref(), Some("/api"));
1210        // the query is preserved verbatim, dot-resolution applies to the path only.
1211        assert_eq!(
1212            normalize_path("/x/../y?a=../b").as_deref(),
1213            Some("/y?a=../b")
1214        );
1215        // a non-origin target (asterisk-form) is passed through.
1216        assert_eq!(normalize_path("*").as_deref(), Some("*"));
1217    }
1218
1219    #[test]
1220    fn normalize_path_rejects_traversal_and_ambiguous_encodings() {
1221        // root escape → reject (fail-closed).
1222        assert_eq!(normalize_path("/.."), None);
1223        assert_eq!(normalize_path("/a/../.."), None);
1224        // percent-encoded separators/dots are ambiguous between front-end and back-end → reject.
1225        assert_eq!(normalize_path("/public/%2e%2e/admin"), None);
1226        assert_eq!(normalize_path("/x%2fy"), None);
1227        assert_eq!(normalize_path("/x%2Fy"), None);
1228        assert_eq!(normalize_path("/x%5cy"), None);
1229        // backslash and control bytes → reject.
1230        assert_eq!(normalize_path("/a\\b"), None);
1231        assert_eq!(normalize_path("/a\nb"), None);
1232    }
1233
1234    #[test]
1235    fn normalize_path_closes_per_route_filter_bypass() {
1236        // a request crafted to select the laxer `/public` route while resolving to
1237        // `/public/admin` at the upstream must, after ingress normalization, select the stricter
1238        // `/public/admin` route (which carries the auth filter) — no bypass.
1239        let routes = vec![
1240            route(None, "/public", "pub"),
1241            route(None, "/public/admin", "admin"),
1242        ];
1243        let raw = "/public/x/../admin";
1244        let norm = normalize_path(raw).expect("normalizes to a clean path");
1245        assert_eq!(norm, "/public/admin");
1246        assert_eq!(
1247            select(&routes, &parts("h", &norm)),
1248            Some(1),
1249            "the normalized path selects the stricter, filtered route"
1250        );
1251        // (the un-normalized path would have selected the laxer route 0 — the bug.)
1252        assert_eq!(select(&routes, &parts("h", raw)), Some(0));
1253    }
1254}