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}