Skip to main content

sozu_lib/router/
mod.rs

1pub mod pattern_trie;
2
3use std::{
4    borrow::Cow,
5    fmt::{self, Debug, Write},
6    rc::Rc,
7    str::from_utf8,
8    time::Instant,
9};
10
11use regex::bytes::Regex;
12use sozu_command::{
13    config::validate_header_predicate,
14    logging::CachedTags,
15    proto::command::{
16        HeaderMatchKind, HeaderPosition, HeaderPredicate, HstsConfig, PathRule as CommandPathRule,
17        PathRuleKind, RedirectPolicy, RedirectScheme, RulePosition,
18    },
19    response::HttpFrontend,
20    state::ClusterId,
21};
22
23use crate::metrics::names;
24use crate::{
25    protocol::{http::editor::HeaderEditMode, http::parser::Method},
26    router::pattern_trie::{InlineTrieMatches, InsertResult, TrieNode, TrieSubMatch},
27    sozu_command::logging::ansi_palette,
28};
29
30/// Module-level prefix tag for `lib/src/router/`. Honours the runtime
31/// colored-output flag via [`ansi_palette`]; consumed by the single
32/// `warn!` site in `Frontend::new` (and any future emitter without an
33/// `HttpContext` in scope) so static log-layout regression checks
34/// (`lib/tests/log_layout.rs`) keep router log lines on the canonical
35/// `[ROUTER] >>>` envelope.
36macro_rules! log_module_context {
37    () => {{
38        let (open, reset, _, _, _) = ansi_palette();
39        format!("{open}ROUTER{reset}\t >>>", open = open, reset = reset)
40    }};
41}
42
43/// Upper bound (in bytes) on a frontend hostname accepted by the router,
44/// checked by [`Router::add_http_front_with_hsts_origin`] and
45/// [`Router::remove_http_front`] before anything parses the hostname.
46///
47/// RFC 1035 caps a domain name at 255 octets; Sōzu's regex-segment
48/// grammar (`/re/.example.com`) can legitimately exceed that, so this is
49/// a deliberately generous safety net rather than a strict RFC bound. It
50/// exists because the route-table trie recurses once per label — a
51/// hostname with ~100k labels aborts the worker with an uncatchable
52/// stack overflow — and because the regex compilation a `/`-segment
53/// triggers costs time linear in the pattern size (a 2 MiB hostname
54/// stalls the single-threaded worker for ~855 ms before the regex size
55/// limit rejects it).
56pub const MAX_HOSTNAME_LENGTH: usize = 4096;
57
58#[derive(thiserror::Error, PartialEq)]
59pub enum RouterError {
60    #[error("Could not parse rule from frontend path, path_bytes={}", .0.len())]
61    InvalidPathRule(String),
62    #[error("parsing hostname failed, hostname_bytes={}", .hostname.len())]
63    InvalidDomain { hostname: String },
64    #[error("Could not parse host rewrite, rewrite_host_bytes={}", .0.len())]
65    InvalidHostRewrite(String),
66    #[error("Could not parse path rewrite, rewrite_path_bytes={}", .0.len())]
67    InvalidPathRewrite(String),
68    #[error("Could not add route, route_bytes={}", .0.len())]
69    AddRoute(String),
70    /// A frontend header predicate is not a usable routing condition
71    /// (sozu-proxy/sozu#1253). Carries the predicate's position and a fixed
72    /// reason, never the operator's name or value.
73    #[error("invalid header predicate at index {index}: {reason}")]
74    InvalidHeaderPredicate { index: usize, reason: &'static str },
75    #[error("Could not remove route, route_bytes={}", .0.len())]
76    RemoveRoute(String),
77    #[error("route_not_found method={method:?} host_bytes={} path_bytes={}", .host.len(), .path.len())]
78    RouteNotFound {
79        host: String,
80        path: String,
81        method: Method,
82    },
83}
84
85impl fmt::Debug for RouterError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        fmt::Display::fmt(self, f)
88    }
89}
90
91/// `Clone` lets a listener added next to a sibling on the same address take
92/// over its routes (`HttpProxy::add_listener`, `lib/src/http.rs`).
93#[derive(Clone)]
94pub struct Router {
95    pre: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
96    pub tree: TrieNode<Vec<(PathRule, MethodRule, HeaderRules, Route)>>,
97    post: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
98}
99
100impl Default for Router {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl Router {
107    pub fn new() -> Router {
108        Router {
109            pre: Vec::new(),
110            tree: TrieNode::root(),
111            post: Vec::new(),
112        }
113    }
114
115    /// Resolve a request to a [`RouteResult`].
116    ///
117    /// Looks up `(hostname, path, method)` against the pre, tree, and post
118    /// rule lists. The matched [`Route`] is converted into a [`RouteResult`]:
119    /// legacy variants ([`Route::ClusterId`], [`Route::Deny`]) synthesize a
120    /// minimal `RouteResult` so existing call sites keep working, while
121    /// [`Route::Frontend`] runs the full Frontend → RouteResult pipeline,
122    /// substituting host/path captures into [`RewriteParts`] templates.
123    ///
124    /// A request looked up here carries no header, so a rule with header
125    /// predicates never matches it; see [`Self::lookup_with_headers`].
126    pub fn lookup(
127        &self,
128        hostname: &str,
129        path: &str,
130        method: &Method,
131    ) -> Result<RouteResult, RouterError> {
132        self.lookup_with_headers(hostname, path, method, &NoHeaders)
133    }
134
135    /// [`Self::lookup`], with the request's headers for the frontends that
136    /// carry header predicates (sozu-proxy/sozu#1253).
137    ///
138    /// Within each of the pre list, one trie leaf and the post list, a rule
139    /// whose predicates all hold outranks every rule without predicates;
140    /// among rules of one class the historical precedence applies
141    /// unchanged — declaration order for pre/post, the path/method order of
142    /// the private `select_tree_rule` for the tree. A rule whose predicates do not all
143    /// hold is skipped, exactly like a rule whose method does not match. A
144    /// route table without predicates therefore resolves every request as
145    /// it did before, and `headers` is never read.
146    pub fn lookup_with_headers(
147        &self,
148        hostname: &str,
149        path: &str,
150        method: &Method,
151        headers: &dyn RequestHeaders,
152    ) -> Result<RouteResult, RouterError> {
153        // The route table stores every hostname LABEL ASCII-lowercased
154        // (`add_tree_rule` via `tree_hostname_to_ascii`, and
155        // `DomainRule::from_str` via `idna::domain_to_ascii`; a regex
156        // segment keeps the operator's bytes on both paths and is
157        // compiled case-insensitively instead — see
158        // `tree_hostname_to_ascii`), so the lookup key is normalised the
159        // same way — otherwise the byte-exact trie walk and the
160        // byte-exact `Exact`/`Wildcard` comparisons below can never match
161        // an uppercase `Host:` header. See [`normalize_hostname`] for why
162        // this is a scan and not an allocation on the common path.
163        //
164        // The captures handed to `RouteResult` are taken from this
165        // normalised key, so `$HOST[n]` rewrite templates see the
166        // normalised host; the raw bytes the client sent stay on
167        // `HttpContext.authority`, which feeds the access log,
168        // `X-Forwarded-Host` and the redirect `Location:` and is not
169        // touched here. `RouteNotFound` below deliberately reports the
170        // ORIGINAL spelling, because that is the diagnostic an operator
171        // needs.
172        let normalized_hostname = normalize_hostname(hostname);
173        let hostname_b = normalized_hostname.as_bytes();
174        let path_b = path.as_bytes();
175        if let Some((domain_rule, path_rule, _, _, route)) =
176            select_listed_rule(&self.pre, hostname_b, path_b, method, headers)
177        {
178            return Ok(RouteResult::new_no_trie(
179                hostname_b,
180                domain_rule,
181                path_b,
182                path_rule,
183                route,
184            ));
185        }
186
187        // The hostname candidates the trie holds are tried
188        // most-specific-first — exact name, then regex segments in
189        // declaration order, then the `*` wildcard — and `select_tree_rule`
190        // is what decides whether a candidate serves THIS request. Handing
191        // it to the trie as the acceptance predicate is what makes the
192        // order a search rather than a filter: a hostname whose rules all
193        // reject this (path, method) hands the request to the next
194        // candidate instead of ending the lookup (sozu#1351).
195        //
196        // The segments the walk matched are recorded on the stack, so a
197        // lookup allocates nothing of its own; see `InlineTrieMatches`.
198        let mut trie_matches = InlineTrieMatches::new();
199        if let Some((_, path_rules)) = self.tree.lookup_with_inline_path(
200            hostname_b,
201            true,
202            &mut trie_matches,
203            &mut |(_, path_rules)| select_tree_rule(path_rules, path_b, method, headers).is_some(),
204        ) && let Some((path_rule, route)) = select_tree_rule(path_rules, path_b, method, headers)
205        {
206            // The second call cannot disagree with the predicate: same
207            // pure function, same leaf, same request. Re-running it is how
208            // the winning rule is carried out of a closure that may only
209            // answer yes or no.
210            //
211            // It costs a second scan of the winning leaf's rule list, so
212            // the common single-candidate hit now scans twice where it
213            // scanned once. The list is the rules of ONE hostname and the
214            // scan is a `PathRule::matches` per entry; carrying the
215            // selection out of the closure instead would need `accept` to
216            // borrow for the trie's own `'b`, which ties the predicate to
217            // the tree borrow for a saving of one pass over a short Vec.
218            // Revisit if a leaf ever holds enough rules to matter.
219            return Ok(RouteResult::new_with_trie(
220                hostname_b,
221                &trie_matches,
222                path_b,
223                path_rule,
224                route,
225            ));
226        }
227
228        if let Some((domain_rule, path_rule, _, _, route)) =
229            select_listed_rule(&self.post, hostname_b, path_b, method, headers)
230        {
231            return Ok(RouteResult::new_no_trie(
232                hostname_b,
233                domain_rule,
234                path_b,
235                path_rule,
236                route,
237            ));
238        }
239
240        Err(RouterError::RouteNotFound {
241            host: hostname.to_owned(),
242            path: path.to_owned(),
243            method: method.to_owned(),
244        })
245    }
246
247    /// Add an HTTP/HTTPS frontend whose `hsts` field (if any) came from
248    /// the per-frontend configuration directly. Equivalent to
249    /// [`Self::add_http_front_with_hsts_origin`] called with
250    /// [`HstsOrigin::Explicit`]. The default for callers that don't
251    /// know about listener-default inheritance — e.g. plain HTTP
252    /// listeners (`HttpListenerConfig` has no HSTS field) and tests.
253    pub fn add_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
254        self.add_http_front_with_hsts_origin(front, HstsOrigin::Explicit)
255    }
256
257    /// Add an HTTP/HTTPS frontend, recording whether the resolved
258    /// `front.hsts` was inherited from the listener default. The
259    /// inheritance bit is preserved on the resulting [`Frontend`] so a
260    /// later `UpdateHttpsListenerConfig.hsts` patch can reflow the new
261    /// default onto inheriting entries without disturbing explicit
262    /// per-frontend overrides.
263    pub fn add_http_front_with_hsts_origin(
264        &mut self,
265        front: &HttpFrontend,
266        hsts_origin: HstsOrigin,
267    ) -> Result<(), RouterError> {
268        // Bounded BEFORE any parse: the `DomainRule` parse below compiles
269        // control-plane-supplied regex segments, and the trie recurses
270        // once per label (see `MAX_HOSTNAME_LENGTH`).
271        if front.hostname.len() > MAX_HOSTNAME_LENGTH {
272            return Err(RouterError::InvalidDomain {
273                hostname: front.hostname.clone(),
274            });
275        }
276
277        let path_rule = PathRule::from_config(front.path.clone())
278            .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
279
280        let method_rule = MethodRule::new(front.method.clone());
281
282        let header_rules = HeaderRules::from_config(&front.header_predicates)?;
283
284        // Decide between the legacy `Route::ClusterId`/`Route::Deny` shape
285        // and the rich `Route::Frontend(Rc<Frontend>)` shape: any non-
286        // default policy field flips us onto the rich path so the mux
287        // can honour redirect/rewrite/headers/auth at request time.
288        //
289        // `tags` counts as such a field. The legacy shapes carry no tags
290        // (`RouteResult::forward` / `::deny` both set `tags: None`), so a
291        // tagged frontend stored as `Route::ClusterId` would hand the mux
292        // a tagless routing decision and its access logs would have to
293        // fall back to the authority-keyed listener map — the exact
294        // spelling mismatch of sozu#1379. Only tagged frontends pay the
295        // `Rc<Frontend>`; an untagged one keeps the lightweight shape.
296        let has_policy = front.redirect.is_some()
297            || front.redirect_scheme.is_some()
298            || front.redirect_template.is_some()
299            || front.rewrite_host.is_some()
300            || front.rewrite_path.is_some()
301            || front.rewrite_port.is_some()
302            || front.required_auth.unwrap_or(false)
303            || !front.headers.is_empty()
304            || front.hsts.is_some()
305            || front.tags.is_some();
306
307        let domain =
308            front
309                .hostname
310                .parse::<DomainRule>()
311                .map_err(|_| RouterError::InvalidDomain {
312                    hostname: front.hostname.clone(),
313                })?;
314
315        let route = if has_policy {
316            let redirect = front
317                .redirect
318                .and_then(|r| RedirectPolicy::try_from(r).ok())
319                .unwrap_or(RedirectPolicy::Forward);
320            let redirect_scheme = front
321                .redirect_scheme
322                .and_then(|s| RedirectScheme::try_from(s).ok())
323                .unwrap_or(RedirectScheme::UseSame);
324            let frontend = Frontend::new(
325                &domain,
326                &path_rule,
327                front,
328                redirect,
329                redirect_scheme,
330                front.redirect_template.clone(),
331                front.rewrite_host.clone(),
332                front.rewrite_path.clone(),
333                front.rewrite_port.and_then(|p| u16::try_from(p).ok()),
334                &front.headers,
335                front.required_auth.unwrap_or(false),
336                hsts_origin,
337            )?;
338            Route::Frontend(Rc::new(frontend))
339        } else {
340            match &front.cluster_id {
341                Some(cluster_id) => Route::ClusterId(cluster_id.clone()),
342                None => Route::Deny,
343            }
344        };
345
346        let success = match front.position {
347            RulePosition::Pre => self.add_pre_rule_with_headers(
348                &domain,
349                &path_rule,
350                &method_rule,
351                &header_rules,
352                &route,
353            ),
354            RulePosition::Post => self.add_post_rule_with_headers(
355                &domain,
356                &path_rule,
357                &method_rule,
358                &header_rules,
359                &route,
360            ),
361            RulePosition::Tree => self.add_tree_rule_with_headers(
362                front.hostname.as_bytes(),
363                &path_rule,
364                &method_rule,
365                &header_rules,
366                &route,
367            ),
368        };
369        if !success {
370            return Err(RouterError::AddRoute(format!("{front:?}")));
371        }
372        Ok(())
373    }
374
375    pub fn remove_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
376        // Same bound as `add_http_front_with_hsts_origin`: the Pre/Post
377        // arms below re-parse the hostname into a `DomainRule`.
378        if front.hostname.len() > MAX_HOSTNAME_LENGTH {
379            return Err(RouterError::InvalidDomain {
380                hostname: front.hostname.clone(),
381            });
382        }
383
384        let path_rule = PathRule::from_config(front.path.clone())
385            .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
386
387        let method_rule = MethodRule::new(front.method.clone());
388
389        let header_rules = HeaderRules::from_config(&front.header_predicates)?;
390
391        let remove_success = match front.position {
392            RulePosition::Pre => {
393                let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
394                    RouterError::InvalidDomain {
395                        hostname: front.hostname.clone(),
396                    }
397                })?;
398
399                self.remove_pre_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
400            }
401            RulePosition::Post => {
402                let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
403                    RouterError::InvalidDomain {
404                        hostname: front.hostname.clone(),
405                    }
406                })?;
407
408                self.remove_post_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
409            }
410            RulePosition::Tree => self.remove_tree_rule_with_headers(
411                front.hostname.as_bytes(),
412                &path_rule,
413                &method_rule,
414                &header_rules,
415            ),
416        };
417        if !remove_success {
418            return Err(RouterError::RemoveRoute(format!("{front:?}")));
419        }
420        Ok(())
421    }
422
423    /// [`Self::add_tree_rule_with_headers`] for a rule without header
424    /// predicates.
425    pub fn add_tree_rule(
426        &mut self,
427        hostname: &[u8],
428        path: &PathRule,
429        method: &MethodRule,
430        cluster: &Route,
431    ) -> bool {
432        self.add_tree_rule_with_headers(hostname, path, method, &HeaderRules::default(), cluster)
433    }
434
435    /// Add a tree rule. Its identity in the leaf is `(path, method,
436    /// headers)`: rules that differ only by their header predicates coexist.
437    pub fn add_tree_rule_with_headers(
438        &mut self,
439        hostname: &[u8],
440        path: &PathRule,
441        method: &MethodRule,
442        headers: &HeaderRules,
443        cluster: &Route,
444    ) -> bool {
445        let hostname = match from_utf8(hostname) {
446            Err(_) => return false,
447            Ok(h) => h,
448        };
449
450        match tree_hostname_to_ascii(hostname) {
451            Some(hostname) => {
452                //FIXME: necessary ti build on stable rust (1.35), can be removed once 1.36 is there
453                let mut empty = true;
454                if let Some((_, paths)) = self.tree.domain_lookup_mut(hostname.as_bytes(), false) {
455                    empty = false;
456                    let before = paths.len();
457                    if !paths
458                        .iter()
459                        .any(|(p, m, h, _)| p == path && m == method && h == headers)
460                    {
461                        paths.push((
462                            path.to_owned(),
463                            method.to_owned(),
464                            headers.to_owned(),
465                            cluster.to_owned(),
466                        ));
467                        // Append must add exactly one (path, method) leaf
468                        // and the new rule must now be present.
469                        debug_assert_eq!(
470                            paths.len(),
471                            before + 1,
472                            "appending a tree rule must grow the leaf's rule list by exactly one",
473                        );
474                        debug_assert!(
475                            paths
476                                .iter()
477                                .any(|(p, m, h, _)| p == path && m == method && h == headers),
478                            "the freshly appended (path, method, headers) rule must be present after insert",
479                        );
480                        return true;
481                    }
482                }
483
484                if empty {
485                    // Snapshot the ASCII host bytes before the move so the
486                    // post-insert reachability check can re-look-up the
487                    // domain. Ungated `let` (read only inside the gated
488                    // assert) → dropped by the optimizer in release.
489                    let inserted_host = hostname.clone().into_bytes();
490                    let insert_result = self.tree.domain_insert(
491                        hostname.into_bytes(),
492                        vec![(
493                            path.to_owned(),
494                            method.to_owned(),
495                            headers.to_owned(),
496                            cluster.to_owned(),
497                        )],
498                    );
499                    // A malformed hostname reaches us straight from the
500                    // control plane (`AddHttpFrontend` over the command
501                    // socket, or a `LoadState` replay), so the route table
502                    // rejecting it is an expected outcome, not a Sozu bug:
503                    // report the failure and let the caller answer the
504                    // request with an error. Shapes the trie rejects
505                    // include a host ending in `/` with no openable regex
506                    // segment (`example.com/`), a regex segment that is not
507                    // `.`-anchored (`abc/[0-9]+/.example.com`), a segment
508                    // that is not a valid regex (`/[/.example.com`), and an
509                    // empty label (`.example.com`).
510                    if insert_result == InsertResult::Failed {
511                        // Redacted like every other router log site: the
512                        // shape is what matters here. `RouterError::AddRoute`
513                        // is redacting too (`route_bytes=<len>`), so the
514                        // hostname itself is only visible where the main
515                        // process audit-logs the request it fanned out
516                        // (`bin/src/command/requests.rs`) -- correlate by
517                        // timestamp to identify the offending frontend.
518                        error!(
519                            "{} the route table rejected a malformed hostname, hostname_bytes={}",
520                            log_module_context!(),
521                            inserted_host.len(),
522                        );
523                        return false;
524                    }
525                    // A fresh domain must now be reachable, carrying the
526                    // single rule just inserted. Use `domain_lookup_mut`
527                    // (not the immutable `domain_lookup`): only the `_mut`
528                    // resolver handles a literal wildcard key (`*.sozu.io`)
529                    // via its `partial_key == b"*"` segment case, which is
530                    // exactly the resolution the append branch above relies
531                    // on. The immutable `lookup` lacks that case and would
532                    // miss wildcard entries.
533                    debug_assert!(
534                        self.tree
535                            .domain_lookup_mut(&inserted_host, false)
536                            .is_some_and(|(_, paths)| paths
537                                .iter()
538                                .any(|(p, m, h, _)| p == path && m == method && h == headers)),
539                        "a freshly inserted tree domain must resolve to its inserted rule",
540                    );
541                    return true;
542                }
543
544                false
545            }
546            None => false,
547        }
548    }
549
550    /// [`Self::remove_tree_rule_with_headers`] for a rule without header
551    /// predicates.
552    pub fn remove_tree_rule(
553        &mut self,
554        hostname: &[u8],
555        path: &PathRule,
556        method: &MethodRule,
557        // _cluster: &Route,
558    ) -> bool {
559        self.remove_tree_rule_with_headers(hostname, path, method, &HeaderRules::default())
560    }
561
562    /// Remove the tree rule whose identity is `(path, method, headers)`; a
563    /// rule that differs only by its header predicates stays.
564    pub fn remove_tree_rule_with_headers(
565        &mut self,
566        hostname: &[u8],
567        path: &PathRule,
568        method: &MethodRule,
569        headers: &HeaderRules,
570    ) -> bool {
571        let hostname = match from_utf8(hostname) {
572            Err(_) => return false,
573            Ok(h) => h,
574        };
575
576        match tree_hostname_to_ascii(hostname) {
577            Some(hostname) => {
578                let should_delete = {
579                    let paths_opt = self.tree.domain_lookup_mut(hostname.as_bytes(), false);
580
581                    if let Some((_, paths)) = paths_opt {
582                        paths.retain(|(p, m, h, _)| p != path || m != method || h != headers);
583                        // `retain` evicts every matching (path, method,
584                        // headers) rule; none may survive the filter.
585                        debug_assert!(
586                            !paths
587                                .iter()
588                                .any(|(p, m, h, _)| p == path && m == method && h == headers),
589                            "remove must evict every matching (path, method, headers) rule from the leaf",
590                        );
591                    }
592
593                    paths_opt
594                        .as_ref()
595                        .map(|(_, paths)| paths.is_empty())
596                        .unwrap_or(false)
597                };
598
599                if should_delete {
600                    let removed_host = hostname.clone().into_bytes();
601                    self.tree.domain_remove(&hostname.into_bytes());
602                    // Dropping the last rule must make the whole domain
603                    // unreachable — no stranded empty leaf left behind.
604                    // `domain_lookup_mut` resolves literal wildcard keys
605                    // (`*.sozu.io`), so this genuinely verifies wildcard
606                    // entries are gone too (the immutable `lookup` lacks
607                    // the `partial_key == b"*"` case and would always read
608                    // None for a wildcard host, weakening the check).
609                    debug_assert!(
610                        self.tree.domain_lookup_mut(&removed_host, false).is_none(),
611                        "a domain whose last rule was removed must be unreachable",
612                    );
613                }
614
615                true
616            }
617            None => false,
618        }
619    }
620
621    /// Walk every route and re-materialise the response-side HSTS edit
622    /// on frontends that inherited from the listener default. Operator
623    /// per-frontend HSTS overrides (`inherits_listener_hsts == false`)
624    /// are left untouched.
625    ///
626    /// Called from `lib/src/https.rs::HttpsListener::update_config` when
627    /// an `UpdateHttpsListenerConfig.hsts` patch is applied, and from
628    /// `HttpsProxy::add_listener` (`lib/src/https.rs`) when a listener copies the routes of a
629    /// sibling on the same address, so each listener resolves inheritance
630    /// against its own default.
631    ///
632    /// Two refresh paths:
633    ///
634    /// 1. **`Route::Frontend(rc)` with `inherits_listener_hsts == true`**:
635    ///    rebuild `headers_response` by dropping any existing
636    ///    `Strict-Transport-Security` entry and appending a freshly
637    ///    rendered one when `new_hsts` resolves to an enabled value
638    ///    (`enabled = Some(true)`). The existing operator
639    ///    `Append`/`Set` response headers stay in place. An inheriting
640    ///    frontend added while the listener had no default holds no
641    ///    `Strict-Transport-Security` entry yet and gains one here.
642    ///
643    /// 2. **`Route::ClusterId(id)` and `Route::Deny`** (lightweight
644    ///    "no policy" shapes): when `new_hsts` resolves to enabled,
645    ///    promote in place to a minimal `Route::Frontend(rc)` carrying
646    ///    just the HSTS edit on `headers_response` (and
647    ///    `inherits_listener_hsts == true` so subsequent patches keep
648    ///    refreshing the entry). Routing semantics are preserved — the
649    ///    promoted Frontend forwards / denies identically to the
650    ///    original variant — and the promoted entry now participates
651    ///    in path 1 on every later patch. When `new_hsts` resolves to
652    ///    "no HSTS" (None / disabled), lightweight routes are left
653    ///    untouched (no allocation is created just to hold an empty
654    ///    HSTS edit).
655    ///
656    /// Path 2 fixes the case where a frontend was added without any
657    /// per-frontend policy field (the routing fast path stores it as
658    /// `Route::ClusterId` / `Route::Deny`, NOT `Route::Frontend`). Before
659    /// this two-path walk, listener-default HSTS patches silently
660    /// skipped every such "no-policy" frontend — which on a typical
661    /// Clever Cloud `cleverapps.io` shared listener was 99 % of the
662    /// frontends.
663    ///
664    /// Returns the number of frontends touched. For path 1, refreshed
665    /// frontends where the new policy resolves to "no HSTS" are still
666    /// counted (the existing HSTS edit is stripped). For path 2, only
667    /// frontends actually promoted (i.e. `new_hsts` enabled) are
668    /// counted, since "no HSTS" is a no-op on the lightweight shape.
669    pub fn refresh_inheriting_hsts(&mut self, new_hsts: Option<&HstsConfig>) -> usize {
670        let mut refreshed = 0usize;
671        // Pre-compute the listener-default HSTS edit ONCE so every
672        // visited frontend in this patch shares the same `Rc`-backed
673        // key / val allocation. `Some(_)` doubles as the "promote
674        // lightweight routes" gate — there is no point allocating a
675        // promoted Frontend just to hold an empty headers_response.
676        // See `build_listener_hsts_edit`'s rustdoc for the
677        // ~1.5 M-allocation-per-worker savings on cleverapps.io.
678        let new_edit = build_listener_hsts_edit(new_hsts);
679        let new_edit_ref = new_edit.as_ref();
680        let promote_lightweight = new_edit_ref.is_some();
681        let mut visit = |route: &mut Route| match route {
682            Route::Frontend(rc) => {
683                if rc.inherits_listener_hsts {
684                    let new_frontend = rebuild_with_listener_hsts(rc, new_edit_ref);
685                    *rc = Rc::new(new_frontend);
686                    refreshed += 1;
687                }
688            }
689            Route::ClusterId(id) => {
690                if promote_lightweight {
691                    let promoted = rebuild_with_listener_hsts(
692                        &Frontend::minimal_forward(id.clone()),
693                        new_edit_ref,
694                    );
695                    *route = Route::Frontend(Rc::new(promoted));
696                    refreshed += 1;
697                }
698            }
699            Route::Deny => {
700                if promote_lightweight {
701                    let promoted =
702                        rebuild_with_listener_hsts(&Frontend::minimal_deny(), new_edit_ref);
703                    *route = Route::Frontend(Rc::new(promoted));
704                    refreshed += 1;
705                }
706            }
707        };
708
709        for (_, _, _, _, route) in self.pre.iter_mut() {
710            visit(route);
711        }
712        self.tree.for_each_value_mut(&mut |paths| {
713            for (_, _, _, route) in paths.iter_mut() {
714                visit(route);
715            }
716        });
717        for (_, _, _, _, route) in self.post.iter_mut() {
718            visit(route);
719        }
720        refreshed
721    }
722
723    /// [`Self::add_pre_rule_with_headers`] for a rule without header
724    /// predicates.
725    pub fn add_pre_rule(
726        &mut self,
727        domain: &DomainRule,
728        path: &PathRule,
729        method: &MethodRule,
730        cluster_id: &Route,
731    ) -> bool {
732        self.add_pre_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
733    }
734
735    /// Add a pre-rule. Its identity is `(domain, path, method,
736    /// headers)`: rules that differ only by their header predicates coexist.
737    pub fn add_pre_rule_with_headers(
738        &mut self,
739        domain: &DomainRule,
740        path: &PathRule,
741        method: &MethodRule,
742        headers: &HeaderRules,
743        cluster_id: &Route,
744    ) -> bool {
745        let before = self.pre.len();
746        if !self
747            .pre
748            .iter()
749            .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
750        {
751            self.pre.push((
752                domain.to_owned(),
753                path.to_owned(),
754                method.to_owned(),
755                headers.to_owned(),
756                cluster_id.to_owned(),
757            ));
758            // A new pre-rule grows the list by exactly one and is now
759            // present (dedup of the same triple is the caller's `false`
760            // path, not this one).
761            debug_assert_eq!(
762                self.pre.len(),
763                before + 1,
764                "adding a unique pre-rule must push exactly one entry",
765            );
766            debug_assert!(
767                self.pre
768                    .iter()
769                    .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
770                "the freshly added pre-rule must be present",
771            );
772            true
773        } else {
774            debug_assert_eq!(
775                self.pre.len(),
776                before,
777                "a duplicate pre-rule must not change the list length",
778            );
779            false
780        }
781    }
782
783    /// [`Self::add_post_rule_with_headers`] for a rule without header
784    /// predicates.
785    pub fn add_post_rule(
786        &mut self,
787        domain: &DomainRule,
788        path: &PathRule,
789        method: &MethodRule,
790        cluster_id: &Route,
791    ) -> bool {
792        self.add_post_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
793    }
794
795    /// Add a post-rule. Its identity is `(domain, path, method,
796    /// headers)`: rules that differ only by their header predicates coexist.
797    pub fn add_post_rule_with_headers(
798        &mut self,
799        domain: &DomainRule,
800        path: &PathRule,
801        method: &MethodRule,
802        headers: &HeaderRules,
803        cluster_id: &Route,
804    ) -> bool {
805        let before = self.post.len();
806        if !self
807            .post
808            .iter()
809            .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
810        {
811            self.post.push((
812                domain.to_owned(),
813                path.to_owned(),
814                method.to_owned(),
815                headers.to_owned(),
816                cluster_id.to_owned(),
817            ));
818            debug_assert_eq!(
819                self.post.len(),
820                before + 1,
821                "adding a unique post-rule must push exactly one entry",
822            );
823            debug_assert!(
824                self.post
825                    .iter()
826                    .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
827                "the freshly added post-rule must be present",
828            );
829            true
830        } else {
831            debug_assert_eq!(
832                self.post.len(),
833                before,
834                "a duplicate post-rule must not change the list length",
835            );
836            false
837        }
838    }
839
840    /// [`Self::remove_pre_rule_with_headers`] for a rule without header
841    /// predicates.
842    pub fn remove_pre_rule(
843        &mut self,
844        domain: &DomainRule,
845        path: &PathRule,
846        method: &MethodRule,
847    ) -> bool {
848        self.remove_pre_rule_with_headers(domain, path, method, &HeaderRules::default())
849    }
850
851    /// Remove the pre-rule whose identity is `(domain, path, method,
852    /// headers)`; a rule that differs only by its header predicates stays.
853    pub fn remove_pre_rule_with_headers(
854        &mut self,
855        domain: &DomainRule,
856        path: &PathRule,
857        method: &MethodRule,
858        headers: &HeaderRules,
859    ) -> bool {
860        let before = self.pre.len();
861        match self
862            .pre
863            .iter()
864            .position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
865        {
866            None => {
867                debug_assert_eq!(
868                    self.pre.len(),
869                    before,
870                    "a no-op pre-rule removal must not change the list length",
871                );
872                false
873            }
874            Some(index) => {
875                debug_assert!(index < self.pre.len(), "found index must be in bounds");
876                self.pre.remove(index);
877                // Exactly one entry left, and the triple is now gone.
878                debug_assert_eq!(
879                    self.pre.len() + 1,
880                    before,
881                    "removing a pre-rule must drop exactly one entry",
882                );
883                debug_assert!(
884                    !self.pre.iter().any(|(d, p, m, h, _)| d == domain
885                        && p == path
886                        && m == method
887                        && h == headers),
888                    "the removed pre-rule must no longer be present",
889                );
890                true
891            }
892        }
893    }
894
895    /// [`Self::remove_post_rule_with_headers`] for a rule without header
896    /// predicates.
897    pub fn remove_post_rule(
898        &mut self,
899        domain: &DomainRule,
900        path: &PathRule,
901        method: &MethodRule,
902    ) -> bool {
903        self.remove_post_rule_with_headers(domain, path, method, &HeaderRules::default())
904    }
905
906    /// Remove the post-rule whose identity is `(domain, path, method,
907    /// headers)`; a rule that differs only by its header predicates stays.
908    pub fn remove_post_rule_with_headers(
909        &mut self,
910        domain: &DomainRule,
911        path: &PathRule,
912        method: &MethodRule,
913        headers: &HeaderRules,
914    ) -> bool {
915        let before = self.post.len();
916        match self
917            .post
918            .iter()
919            .position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
920        {
921            None => {
922                debug_assert_eq!(
923                    self.post.len(),
924                    before,
925                    "a no-op post-rule removal must not change the list length",
926                );
927                false
928            }
929            Some(index) => {
930                debug_assert!(index < self.post.len(), "found index must be in bounds");
931                self.post.remove(index);
932                debug_assert_eq!(
933                    self.post.len() + 1,
934                    before,
935                    "removing a post-rule must drop exactly one entry",
936                );
937                debug_assert!(
938                    !self.post.iter().any(|(d, p, m, h, _)| d == domain
939                        && p == path
940                        && m == method
941                        && h == headers),
942                    "the removed post-rule must no longer be present",
943                );
944                true
945            }
946        }
947    }
948
949    /// Returns true if any route (pre, tree, or post) references the given hostname.
950    ///
951    /// This is used after removing a frontend to decide whether the hostname's
952    /// tags should be cleaned up. Tags must only be removed when no routes remain.
953    pub fn has_hostname(&self, hostname: &str) -> bool {
954        // Same normalisation as `lookup`, for the same reason: the
955        // pre/post arms compare byte-exact against `DomainRule`s the add
956        // path already lowercased. Answering `false` for a hostname whose
957        // route is still installed would drop the frontend's cached tags
958        // from under a live route.
959        let normalized_hostname = normalize_hostname(hostname);
960        let hostname = normalized_hostname.as_ref();
961        let hostname_b = hostname.as_bytes();
962
963        // Check pre rules
964        for (domain_rule, _, _, _, _) in &self.pre {
965            if domain_rule.matches(hostname_b) {
966                return true;
967            }
968        }
969
970        // Check tree rules (exact match only, no wildcard resolution)
971        if let Some(ascii_hostname) = tree_hostname_to_ascii(hostname)
972            && self
973                .tree
974                .domain_lookup(ascii_hostname.as_bytes(), false)
975                .is_some()
976        {
977            return true;
978        }
979
980        // Check post rules
981        for (domain_rule, _, _, _, _) in &self.post {
982            if domain_rule.matches(hostname_b) {
983                return true;
984            }
985        }
986
987        false
988    }
989}
990
991/// Pick the rule a trie leaf serves `path`, `method` and `headers` with, or
992/// `None` when the leaf carries nothing for this request.
993///
994/// An `EQUALS`/`REGEX` rule whose `method` matches exactly wins
995/// immediately; otherwise the longest matching `PREFIX` wins, with a
996/// method-less rule competing on equal footing. Rules whose `method` is
997/// set and does not match are skipped entirely — which is why a longer
998/// prefix can lose to a shorter one (`doc/configure.md`, "Path matching
999/// precedence within a frontend").
1000///
1001/// Header predicates (sozu-proxy/sozu#1253) split the leaf into two
1002/// classes, each selected by the rule above: a rule whose predicates all
1003/// hold outranks every rule without predicates, and a rule whose
1004/// predicates do not all hold is skipped like a rule whose method does not
1005/// match. A leaf without predicates selects exactly as it always did, and
1006/// declaration order never decides between a canary and its base.
1007///
1008/// Lifted out of [`Router::lookup_with_headers`] so the trie walk can use
1009/// the very same selection as its acceptance predicate: a hostname
1010/// candidate that selects nothing must not end the lookup (sozu#1351). Two
1011/// copies of this would drift, and the drift would be a route that the trie
1012/// accepted and the router then refused to serve.
1013fn select_tree_rule<'a>(
1014    path_rules: &'a [(PathRule, MethodRule, HeaderRules, Route)],
1015    path: &[u8],
1016    method: &Method,
1017    headers: &dyn RequestHeaders,
1018) -> Option<(&'a PathRule, &'a Route)> {
1019    let mut predicated = TreeSelection::default();
1020    let mut unpredicated = TreeSelection::default();
1021
1022    for (rule, method_rule, header_rules, route) in path_rules {
1023        let class = if header_rules.is_empty() {
1024            &mut unpredicated
1025        } else {
1026            &mut predicated
1027        };
1028        // A class that already holds an exact winner takes no other rule:
1029        // this is the historical early return, per class.
1030        if class.exact {
1031            continue;
1032        }
1033        let path_result = rule.matches(path);
1034        if path_result == PathRuleResult::None {
1035            continue;
1036        }
1037        let method_result = method_rule.matches(method);
1038        if method_result == MethodRuleResult::None {
1039            continue;
1040        }
1041        // Headers are read last, and only for a rule that could otherwise
1042        // serve this request.
1043        if !header_rules.is_empty() && !header_rules.matches(headers) {
1044            continue;
1045        }
1046        class.offer(path_result, method_result, path.len(), rule, route);
1047        if predicated.exact {
1048            // Nothing outranks an exact match among satisfied predicates.
1049            break;
1050        }
1051    }
1052
1053    // A predicate-free rule may only be served when no satisfied
1054    // predicated rule exists: the pair below is the class precedence.
1055    debug_assert!(
1056        !predicated.exact || predicated.matched.is_some(),
1057        "an exact selection must carry its rule",
1058    );
1059    predicated.matched.or(unpredicated.matched)
1060}
1061
1062/// One class of [`select_tree_rule`]'s scan: the historical
1063/// longest-prefix / exact-method selection, run over the rules of that
1064/// class only.
1065#[derive(Default)]
1066struct TreeSelection<'a> {
1067    prefix_length: usize,
1068    matched: Option<(&'a PathRule, &'a Route)>,
1069    /// An `EQUALS`/`REGEX` rule with an exactly matching method was taken:
1070    /// the class is decided.
1071    exact: bool,
1072}
1073
1074impl<'a> TreeSelection<'a> {
1075    fn offer(
1076        &mut self,
1077        path_result: PathRuleResult,
1078        method_result: MethodRuleResult,
1079        path_len: usize,
1080        rule: &'a PathRule,
1081        route: &'a Route,
1082    ) {
1083        debug_assert!(!self.exact, "a decided class must not take another rule");
1084        debug_assert!(
1085            path_result != PathRuleResult::None && method_result != MethodRuleResult::None,
1086            "only a rule that matches path and method is offered",
1087        );
1088        match path_result {
1089            PathRuleResult::Regex | PathRuleResult::Equals => match method_result {
1090                MethodRuleResult::Equals => {
1091                    self.matched = Some((rule, route));
1092                    self.exact = true;
1093                }
1094                MethodRuleResult::All => {
1095                    self.prefix_length = path_len;
1096                    self.matched = Some((rule, route));
1097                }
1098                MethodRuleResult::None => {}
1099            },
1100            PathRuleResult::Prefix(size) => {
1101                if size >= self.prefix_length {
1102                    // FIXME: the rule order will be important here
1103                    // Longest-prefix wins: the selected length is
1104                    // monotonically non-decreasing across the candidate
1105                    // scan.
1106                    debug_assert!(
1107                        size >= self.prefix_length,
1108                        "longest-prefix selection must never shrink the match length",
1109                    );
1110                    self.prefix_length = size;
1111                    self.matched = Some((rule, route));
1112                }
1113            }
1114            PathRuleResult::None => {}
1115        }
1116    }
1117}
1118
1119/// Pick the rule an ordered pre or post list serves the request with.
1120///
1121/// The first rule in declaration order whose domain, path and method match
1122/// wins — except that a rule whose header predicates all hold outranks
1123/// every rule without predicates (sozu-proxy/sozu#1253), the same class
1124/// precedence [`select_tree_rule`] applies to a trie leaf. A rule whose
1125/// predicates do not all hold is skipped.
1126fn select_listed_rule<'a>(
1127    rules: &'a [(DomainRule, PathRule, MethodRule, HeaderRules, Route)],
1128    hostname: &[u8],
1129    path: &[u8],
1130    method: &Method,
1131    headers: &dyn RequestHeaders,
1132) -> Option<&'a (DomainRule, PathRule, MethodRule, HeaderRules, Route)> {
1133    let mut unpredicated = None;
1134    for entry in rules {
1135        let (domain_rule, path_rule, method_rule, header_rules, _) = entry;
1136        // Once a predicate-free rule is held, only a predicated one can
1137        // still change the answer.
1138        if unpredicated.is_some() && header_rules.is_empty() {
1139            continue;
1140        }
1141        if !(domain_rule.matches(hostname)
1142            && path_rule.matches(path) != PathRuleResult::None
1143            && method_rule.matches(method) != MethodRuleResult::None)
1144        {
1145            continue;
1146        }
1147        if header_rules.is_empty() {
1148            unpredicated = Some(entry);
1149        } else if header_rules.matches(headers) {
1150            return Some(entry);
1151        }
1152    }
1153    debug_assert!(
1154        unpredicated.is_none_or(|(_, _, _, header_rules, _)| header_rules.is_empty()),
1155        "the fallback of a listed selection must be a predicate-free rule",
1156    );
1157    unpredicated
1158}
1159
1160/// The request headers a [`Router`] lookup may read to evaluate header
1161/// predicates (sozu-proxy/sozu#1253).
1162pub trait RequestHeaders {
1163    /// The value of the FIRST occurrence of the header `name`, compared
1164    /// ASCII-case-insensitively (RFC 9110 §5.1), raw as the request carried
1165    /// it; `None` when the request does not carry it.
1166    fn header(&self, name: &[u8]) -> Option<&[u8]>;
1167}
1168
1169/// A request without any header, for lookups that have none to offer.
1170pub struct NoHeaders;
1171
1172impl RequestHeaders for NoHeaders {
1173    fn header(&self, _name: &[u8]) -> Option<&[u8]> {
1174        None
1175    }
1176}
1177
1178/// How one [`HeaderRule`] tests the value of its header.
1179#[derive(Clone, Debug)]
1180enum HeaderMatch {
1181    Exact(Vec<u8>),
1182    /// Anchored at both ends, like a `REGEX` path rule ([`anchored_regex`]).
1183    Regex(Regex),
1184    Present,
1185}
1186
1187impl PartialEq for HeaderMatch {
1188    /// `Regex` carries no `PartialEq`: patterns compare by source, as
1189    /// [`PathRule`]'s do.
1190    fn eq(&self, other: &Self) -> bool {
1191        match (self, other) {
1192            (HeaderMatch::Exact(a), HeaderMatch::Exact(b)) => a == b,
1193            (HeaderMatch::Regex(a), HeaderMatch::Regex(b)) => a.as_str() == b.as_str(),
1194            (HeaderMatch::Present, HeaderMatch::Present) => true,
1195            _ => false,
1196        }
1197    }
1198}
1199
1200/// One compiled header predicate: a lowercased header name and its test.
1201#[derive(Clone, Debug, PartialEq)]
1202struct HeaderRule {
1203    name: Vec<u8>,
1204    matcher: HeaderMatch,
1205}
1206
1207impl HeaderRule {
1208    fn matches(&self, headers: &dyn RequestHeaders) -> bool {
1209        let Some(value) = headers.header(&self.name) else {
1210            return false;
1211        };
1212        match &self.matcher {
1213            HeaderMatch::Exact(expected) => value == expected.as_slice(),
1214            HeaderMatch::Regex(regex) => {
1215                let start = Instant::now();
1216                let is_a_match = regex.is_match(value);
1217                time!(
1218                    names::event_loop::REGEX_MATCHING_TIME,
1219                    start.elapsed().as_millis()
1220                );
1221                is_a_match
1222            }
1223            HeaderMatch::Present => true,
1224        }
1225    }
1226
1227    /// The canonical sort key of a rule within a [`HeaderRules`].
1228    fn sort_key(&self) -> (&[u8], u8, &[u8]) {
1229        match &self.matcher {
1230            HeaderMatch::Exact(value) => (&self.name, 0, value),
1231            HeaderMatch::Regex(regex) => (&self.name, 1, regex.as_str().as_bytes()),
1232            HeaderMatch::Present => (&self.name, 2, b""),
1233        }
1234    }
1235}
1236
1237/// The header predicates of one route-table rule (sozu-proxy/sozu#1253):
1238/// a conjunction, empty for a rule without predicates.
1239///
1240/// It is part of the rule's identity: [`Router`]'s add and remove paths
1241/// compare it alongside the path and method, so a canary rule that differs
1242/// from its base only by a predicate coexists with it, and removing one
1243/// leaves the other. The rules are kept in a canonical order, so two
1244/// spellings of one conjunction (other declaration order, other name case)
1245/// are one rule — the same canonicalisation the `ConfigState` key applies
1246/// (`RequestHttpFrontend`'s `Display` in `command/src/request.rs`).
1247#[derive(Clone, Debug, Default, PartialEq)]
1248pub struct HeaderRules(Vec<HeaderRule>);
1249
1250impl HeaderRules {
1251    /// Compile a frontend's predicates, held to
1252    /// [`validate_header_predicate`] first. A `regex` value is compiled
1253    /// anchored, refused when it does not compile exactly like a `REGEX`
1254    /// path rule (the private `anchored_regex` helper compiles both).
1255    pub fn from_config(predicates: &[HeaderPredicate]) -> Result<Self, RouterError> {
1256        let mut rules = Vec::with_capacity(predicates.len());
1257        for (index, predicate) in predicates.iter().enumerate() {
1258            validate_header_predicate(predicate)
1259                .map_err(|reason| RouterError::InvalidHeaderPredicate { index, reason })?;
1260            let value = predicate.value.as_deref().unwrap_or_default();
1261            let matcher = match HeaderMatchKind::try_from(predicate.kind) {
1262                Ok(HeaderMatchKind::Exact) => HeaderMatch::Exact(value.as_bytes().to_owned()),
1263                Ok(HeaderMatchKind::Regex) => HeaderMatch::Regex(anchored_regex(value).ok_or(
1264                    RouterError::InvalidHeaderPredicate {
1265                        index,
1266                        reason: "the regex value does not compile",
1267                    },
1268                )?),
1269                Ok(HeaderMatchKind::Present) => HeaderMatch::Present,
1270                Err(_) => {
1271                    return Err(RouterError::InvalidHeaderPredicate {
1272                        index,
1273                        reason: "unknown match kind",
1274                    });
1275                }
1276            };
1277            rules.push(HeaderRule {
1278                name: predicate.name.as_bytes().to_ascii_lowercase(),
1279                matcher,
1280            });
1281        }
1282        rules.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
1283        rules.dedup();
1284        debug_assert!(
1285            rules.len() <= predicates.len(),
1286            "compiling predicates never adds a rule",
1287        );
1288        debug_assert!(
1289            rules.is_empty() == predicates.is_empty(),
1290            "a frontend has predicates exactly when its rule does",
1291        );
1292        Ok(HeaderRules(rules))
1293    }
1294
1295    pub fn is_empty(&self) -> bool {
1296        self.0.is_empty()
1297    }
1298
1299    /// Whether every predicate holds for `headers`. True for an empty
1300    /// conjunction, which never reads `headers`.
1301    pub fn matches(&self, headers: &dyn RequestHeaders) -> bool {
1302        self.0.iter().all(|rule| rule.matches(headers))
1303    }
1304}
1305
1306/// ASCII-lowercase a hostname for routing, borrowing it when it already is.
1307///
1308/// RFC 9110 §4.2.3 makes the host case-insensitive, and every configured
1309/// hostname LABEL is lowercased on the way into the route table:
1310/// [`Router::add_tree_rule`] runs it through [`tree_hostname_to_ascii`]
1311/// and [`DomainRule::from_str`] through `idna::domain_to_ascii`. (A
1312/// `/`-delimited regex segment is exempt on both paths and is folded at
1313/// compile time instead; see [`tree_hostname_to_ascii`].) The lookup key
1314/// has to be normalised
1315/// the same way, or [`Router::lookup`]'s byte-exact trie walk and the
1316/// byte-exact `DomainRule::Exact` / `DomainRule::Wildcard` comparisons
1317/// can never match an uppercase `Host:` header — and no configuration
1318/// fixes it, because declaring the frontend in uppercase gets lowercased
1319/// too.
1320///
1321/// The scan is the whole cost on the common path: a host that is already
1322/// lowercase — every host a conforming client sends — is borrowed, so
1323/// the datapath allocates only for the requests that actually carry
1324/// uppercase. This runs once per request, so the distinction is not
1325/// academic.
1326///
1327/// Deliberately ASCII-only and deliberately NOT a full
1328/// `idna::domain_to_ascii` round trip: `hostname_and_port`
1329/// (`lib/src/protocol/kawa_h1/parser.rs`) only admits bytes matching
1330/// `is_hostname_char`, all of which are ASCII, so there is no non-ASCII
1331/// host to punycode here, and a per-request IDNA pass would be a real
1332/// datapath cost for a case that cannot occur.
1333fn normalize_hostname(hostname: &str) -> Cow<'_, str> {
1334    if hostname.as_bytes().iter().any(u8::is_ascii_uppercase) {
1335        Cow::Owned(hostname.to_ascii_lowercase())
1336    } else {
1337        Cow::Borrowed(hostname)
1338    }
1339}
1340
1341/// Normalise a `RulePosition::Tree` hostname for the route table,
1342/// applying IDNA to its literal labels ONLY.
1343///
1344/// `idna::domain_to_ascii` ASCII-lowercases everything it is handed, and
1345/// a tree hostname is not all domain labels: a `/`-delimited segment is
1346/// regex SOURCE, where case is meaning. Folding it inverts every
1347/// uppercase escape — measured, `"/\D+/.example.com"` normalises to
1348/// `"/\d+/.example.com"` and `"/[^\D]/.example.com"` to
1349/// `"/[^\d]/.example.com"`, turning "not a digit" into "a digit", so the
1350/// installed rule matched the exact COMPLEMENT of what the operator
1351/// wrote while `sozu query frontends` still echoed the original spelling
1352/// (sozu#1377). `\W`/`\w` and `\S`/`\s` invert the same way.
1353///
1354/// The split is the one [`convert_regex_domain_rule`] already makes for
1355/// `Pre`/`Post` and the one `pattern_trie`'s `insert_recursive`
1356/// addresses a `regexps` entry by: a label the operator wrapped in
1357/// slashes is a regex segment and is copied BYTE FOR BYTE, delimiters
1358/// included; every other label is a domain label and goes through
1359/// `idna::domain_to_ascii` exactly as before. Per-label and whole-domain
1360/// IDNA agree on a label — measured, `"MÜNCHEN"` maps to
1361/// `"xn--mnchen-3ya"` either way — so a genuine unicode label is still
1362/// punycoded in the very same hostname whose regex segment is left
1363/// alone.
1364///
1365/// Leaving the source unfolded means an uppercase LITERAL inside a
1366/// segment (`/API[0-9]/`) no longer meets an ASCII-lowercased lookup key
1367/// by accident. `pattern_trie`'s `compiled_segment` compiles the stored
1368/// segment case-insensitively instead, which is what
1369/// [`DomainRule::from_str`] already does for `Pre`/`Post`: the two
1370/// positions now treat a hostname regex identically rather than by
1371/// opposite mechanisms.
1372///
1373/// Two inputs deliberately keep the historical whole-string call, both
1374/// byte-for-byte:
1375///
1376/// - a hostname with no `/` at all, which is every ordinary hostname —
1377///   so the retained trailing dot pinned by
1378///   `an_absolute_form_host_does_not_reach_a_relative_frontend_yet` and
1379///   every other whole-string behaviour is untouched;
1380/// - a hostname carrying a `/` that does NOT parse as this grammar, such
1381///   as `abc/[0-9]+/.example.com` (a segment that is not `.`-anchored) or
1382///   `example.com/`. Neither the split nor the trie sees a regex segment
1383///   there — it is one literal label to both — so there is nothing to
1384///   protect, and the trie accepts or rejects it exactly as it did
1385///   before.
1386fn tree_hostname_to_ascii(hostname: &str) -> Option<String> {
1387    /// The `/…/`-aware walk. `None` means "not this grammar", never
1388    /// "invalid": validating the segment is `anchored_segment`'s job at
1389    /// insert time, and this function must not start rejecting hostnames
1390    /// the trie has always answered for itself.
1391    fn split_labels(hostname: &str) -> Option<String> {
1392        let mut result = String::with_capacity(hostname.len());
1393        let s = hostname.as_bytes();
1394        let mut index = 0;
1395
1396        loop {
1397            // A bare trailing `.` leaves `index` one past the end; the
1398            // grammar requires a label after every `.`, so this is not
1399            // the shape — fall back rather than index out of bounds.
1400            if index >= s.len() {
1401                return None;
1402            }
1403
1404            if s[index] == b'/' {
1405                let close = (index + 1..s.len()).find(|&i| s[i] == b'/')?;
1406                // Regex source, copied verbatim. Both delimiters are
1407                // ASCII `/`, so the slice is on char boundaries.
1408                result.push_str(std::str::from_utf8(&s[index..=close]).ok()?);
1409                index = close + 1;
1410            } else {
1411                let end = (index..s.len()).find(|&i| s[i] == b'.').unwrap_or(s.len());
1412                let label = std::str::from_utf8(&s[index..end]).ok()?;
1413                result.push_str(&::idna::domain_to_ascii(label).ok()?);
1414                index = end;
1415            }
1416
1417            if index == s.len() {
1418                return Some(result);
1419            }
1420            if s[index] != b'.' {
1421                return None;
1422            }
1423            result.push('.');
1424            index += 1;
1425        }
1426    }
1427
1428    if hostname.contains('/')
1429        && let Some(normalized) = split_labels(hostname)
1430    {
1431        return Some(normalized);
1432    }
1433    ::idna::domain_to_ascii(hostname).ok()
1434}
1435
1436#[derive(Clone, Debug)]
1437pub enum DomainRule {
1438    Any,
1439    Exact(String),
1440    /// Matches when `hostname` ends with `s[1..]` (the wildcard pattern with
1441    /// the leading `*` stripped) and the remaining leftmost prefix is
1442    /// non-empty and contains no `.`. Comparison is byte-exact and no
1443    /// IDN/punycode normalisation is performed here: the pattern was
1444    /// already lowercased by `DomainRule::from_str`'s
1445    /// `idna::domain_to_ascii`, and the hostname it is handed has been
1446    /// through `normalize_hostname` in [`Router::lookup`], so both
1447    /// sides are ASCII-lowercase by the time they meet. Stored with the
1448    /// leading `*`.
1449    Wildcard(String),
1450    /// Anchored full-host regex built by `convert_regex_domain_rule`.
1451    /// Unlike the other variants its source is NOT lowercased — it is
1452    /// compiled case-insensitively instead, so an uppercase literal
1453    /// still matches the normalised key. See `DomainRule::from_str`
1454    /// for why that folding is Unicode-aware while the hostnames it is
1455    /// ever handed are ASCII.
1456    Regex(Regex),
1457}
1458
1459/// Build the whole-host regex for a `hostname` carrying one or more
1460/// slash-delimited regex segments (`/cdn[0-9]+/.example.com`). Each regex
1461/// segment is emitted as a non-capturing group, each literal label is
1462/// emitted through [`regex::escape`], the `.` separators are escaped, and the
1463/// result is anchored at both ends.
1464///
1465/// The anchors and the groups answer two different failures and both are
1466/// required:
1467///
1468/// - `\A` … `\z` because `Regex::is_match` is a substring search. Without
1469///   them `/example\.com/` matches any hostname CONTAINING `example.com`
1470///   (e.g. `attacker.example.com.evil.org`), letting an attacker-controlled
1471///   domain reach a frontend that should only serve `example.com`.
1472/// - `(?:` … `)` around each regex segment because `|` binds looser than
1473///   concatenation, so an ungrouped segment splits the anchors between its
1474///   branches: `\Aa|b\.example\.com\z` parses as
1475///   `(\Aa)|(b\.example\.com\z)`. Measured, that matches
1476///   `axx.example.com` and `xxb.example.com`; with three branches the middle
1477///   one keeps NEITHER anchor and matches anywhere at all. The group is
1478///   non-capturing, so `captures_len` — and therefore every `$HOST[n]`
1479///   rewrite index, read from `caps.iter().skip(1)` in
1480///   `RouteResult::new_no_trie` — is unchanged. A literal label carrying a
1481///   `|` split the anchors the same way and is closed by the escaping
1482///   below: `/a/.x|y.com` assembled to `\A(?:a)\.x|y\.com\z`, which
1483///   matched `a.xZZZ` and `ZZZy.com`.
1484///
1485/// A label the operator did NOT wrap in slashes is a literal, and the two
1486/// arms must not be confused: grouping it would confine an alternation but
1487/// leave `+`, `?`, `*`, `^` and `$` live inside it, so `b+c` would still
1488/// match `bbbc`. [`regex::escape`] is the treatment that matches the
1489/// grammar. It is a no-op in matching behaviour for every label a real
1490/// hostname can carry — an LDH label is `[A-Za-z0-9-]`, of which `escape`
1491/// touches only `-`, and `\-` is `-` outside a character class. Two
1492/// deliberate behaviour changes fall out of it for labels that are NOT
1493/// valid hostname labels, both narrowing and both measured:
1494/// `*./x/.example.com` assembled to `\A*\.(?:x)\.example\.com\z`, whose
1495/// `\A*` is a repetition of a zero-width assertion and therefore matches
1496/// empty anywhere — it took `evil.attacker.x.example.com`, and now requires
1497/// the literal label `*`; and `/a/.b(c.example.com` was REJECTED because the
1498/// assembly did not compile, and is now accepted, matching exactly the
1499/// literal host the operator typed and nothing else.
1500///
1501/// The `*` change is scoped to what this function feeds, which is `Pre` and
1502/// `Post` matching only. A trie-routed frontend never reaches here for
1503/// matching: `TrieNode` splits the hostname on `.` and keeps a `*` label in
1504/// its own `wildcard` slot, so the same configured hostname now answers
1505/// differently by position. `doc/configure.md` § "Regex hostname segments"
1506/// carries the table and
1507/// `a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post`
1508/// asserts it.
1509///
1510/// Each regex segment is compiled ON ITS OWN before it is wrapped. The
1511/// wrapper supplies one `(` and one `)`, so an unbalanced segment can be
1512/// balanced BY the wrapping: `a)(b` is rejected by `Regex::new`, yet
1513/// `\A(?:a)(b)\.example\.com\z` compiles and matches `ab.example.com`.
1514/// Without that first compile, grouping would silently promote a hostname the
1515/// router rejects today into a live rule. This closes
1516/// invalid-becomes-valid only; the converse is not closed, exactly as on the
1517/// path side — see [`PathRule::anchored_regex`], which carries the same shape
1518/// and the same measurements for a whole path value.
1519fn convert_regex_domain_rule(hostname: &str) -> Option<String> {
1520    let mut result = String::from("\\A");
1521
1522    let s = hostname.as_bytes();
1523    let mut index = 0;
1524    loop {
1525        // A bare trailing `.` after a completed segment (`/a/.`, `x./y/.`)
1526        // leaves `index` one past the end through the `index += 1` in the
1527        // loop tail; indexing `s[index]` here would then panic (slice
1528        // bounds checks never compile out of release). The grammar
1529        // requires a label after every `.`, so reject instead.
1530        if index >= s.len() {
1531            return None;
1532        }
1533        if s[index] == b'/' {
1534            let mut found = false;
1535            for i in index + 1..s.len() {
1536                if s[i] == b'/' {
1537                    match std::str::from_utf8(&s[index + 1..i]) {
1538                        Ok(r) => {
1539                            // Reject a segment that is not a regex on its own,
1540                            // then group it. See this function's doc comment.
1541                            Regex::new(r).ok()?;
1542                            result.push_str("(?:");
1543                            result.push_str(r);
1544                            result.push(')');
1545                        }
1546                        Err(_) => return None,
1547                    }
1548                    index = i + 1;
1549                    found = true;
1550                    break;
1551                }
1552            }
1553
1554            if !found {
1555                return None;
1556            }
1557        } else {
1558            let start = index;
1559            for i in start..s.len() + 1 {
1560                index = i;
1561                if i < s.len() && s[i] == b'.' {
1562                    match std::str::from_utf8(&s[start..i]) {
1563                        // A LITERAL label is not a pattern: escape it so it
1564                        // matches itself. See this function's doc comment.
1565                        Ok(r) => result.push_str(&regex::escape(r)),
1566                        Err(_) => return None,
1567                    }
1568                    break;
1569                }
1570            }
1571            if index == s.len() {
1572                match std::str::from_utf8(&s[start..]) {
1573                    Ok(r) => result.push_str(&regex::escape(r)),
1574                    Err(_) => return None,
1575                }
1576            }
1577        }
1578
1579        if index == s.len() {
1580            result.push_str("\\z");
1581            return Some(result);
1582        } else if s[index] == b'.' {
1583            result.push_str("\\.");
1584            index += 1;
1585        } else {
1586            return None;
1587        }
1588    }
1589}
1590
1591impl DomainRule {
1592    pub fn matches(&self, hostname: &[u8]) -> bool {
1593        match self {
1594            DomainRule::Any => true,
1595            DomainRule::Wildcard(s) => {
1596                // A stored wildcard always keeps its leading `*`, so the
1597                // suffix (pattern minus `*`) is a strict sub-slice and the
1598                // bare `*` (Any) never reaches this arm.
1599                debug_assert_eq!(
1600                    s.as_bytes().first(),
1601                    Some(&b'*'),
1602                    "a Wildcard rule must retain its leading '*'",
1603                );
1604                let suffix = &s.as_bytes()[1..];
1605                let matched = hostname
1606                    .strip_suffix(suffix)
1607                    .is_some_and(|prefix| !prefix.is_empty() && !prefix.contains(&b'.'));
1608                // A wildcard never matches a hostname no longer than its
1609                // own suffix — there is no room left for the mandatory
1610                // single non-empty leftmost label.
1611                debug_assert!(
1612                    !matched || hostname.len() > suffix.len(),
1613                    "a wildcard match requires a non-empty leftmost label before the suffix",
1614                );
1615                matched
1616            }
1617            DomainRule::Exact(s) => s.as_bytes() == hostname,
1618            DomainRule::Regex(r) => {
1619                let start = Instant::now();
1620                let is_a_match = r.is_match(hostname);
1621                let now = Instant::now();
1622                time!(
1623                    names::event_loop::REGEX_MATCHING_TIME,
1624                    (now - start).as_millis()
1625                );
1626                is_a_match
1627            }
1628        }
1629    }
1630}
1631
1632impl std::cmp::PartialEq for DomainRule {
1633    fn eq(&self, other: &Self) -> bool {
1634        match (self, other) {
1635            (DomainRule::Any, DomainRule::Any) => true,
1636            (DomainRule::Wildcard(s1), DomainRule::Wildcard(s2)) => s1 == s2,
1637            (DomainRule::Exact(s1), DomainRule::Exact(s2)) => s1 == s2,
1638            (DomainRule::Regex(r1), DomainRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1639            _ => false,
1640        }
1641    }
1642}
1643
1644impl std::str::FromStr for DomainRule {
1645    type Err = ();
1646
1647    fn from_str(s: &str) -> Result<Self, Self::Err> {
1648        Ok(if s == "*" {
1649            DomainRule::Any
1650        } else if s.contains('/') {
1651            match convert_regex_domain_rule(s) {
1652                // Case-insensitive, because `Router::lookup`
1653                // normalises the host it matches against and a hostname
1654                // is case-insensitive anyway (RFC 9110 §4.2.3). Without
1655                // it an operator's `/API[0-9]/` would compile
1656                // case-sensitively and match nothing at all — it used to
1657                // match `API7.…` only, never the canonical `api7.…`.
1658                //
1659                // Set on the builder rather than by lowercasing the
1660                // source, which would rewrite `\D` into `\d`. The trie
1661                // does exactly the same since sozu#1377:
1662                // `tree_hostname_to_ascii` leaves a segment's bytes alone
1663                // and `pattern_trie::compiled_segment` folds at compile
1664                // time, so the two rule positions now treat a hostname
1665                // regex by the same mechanism. Up to and including 2.2.1
1666                // `RulePosition::Tree` instead ran the WHOLE hostname,
1667                // regex source included, through `idna::domain_to_ascii`
1668                // — which is precisely the escape-inverting fold this
1669                // comment refuses.
1670                //
1671                // The folding is the crate default, i.e. Unicode simple
1672                // case folding, NOT ASCII-only: `.unicode(false)` is
1673                // deliberately left unset. It would make the fold
1674                // ASCII-only, but it also rejects patterns that compile
1675                // and work today — measured, `\p{L}` and `\pL` fail to
1676                // build with it, so `/\p{L}+/.example.com`, which
1677                // matches ASCII letters perfectly well right now, would
1678                // start being refused at add time — pinned by
1679                // `a_unicode_class_hostname_regex_still_compiles_and_routes`.
1680                // The distinction is
1681                // unobservable on the haystack side regardless: the only
1682                // hostnames that reach `matches` come through
1683                // `hostname_and_port`
1684                // (`lib/src/protocol/kawa_h1/parser.rs`), whose
1685                // `is_hostname_char` admits nothing outside ASCII, and
1686                // whose own `if !i.is_empty()` guard then refuses the
1687                // whole authority rather than truncating it — see
1688                // `is_hostname_char_admits_only_ascii`. It IS observable
1689                // on the PATTERN side: a pattern spelling `\u{212A}`
1690                // folds onto `k`. Hence "case-insensitive" here, not
1691                // "ASCII-case-insensitive".
1692                Some(s) => match regex::bytes::RegexBuilder::new(&s)
1693                    .case_insensitive(true)
1694                    .build()
1695                {
1696                    Ok(r) => DomainRule::Regex(r),
1697                    Err(_) => return Err(()),
1698                },
1699                None => return Err(()),
1700            }
1701        } else if s.contains('*') {
1702            if s.starts_with('*') {
1703                match ::idna::domain_to_ascii(s) {
1704                    Ok(r) => DomainRule::Wildcard(r),
1705                    Err(_) => return Err(()),
1706                }
1707            } else {
1708                return Err(());
1709            }
1710        } else {
1711            match ::idna::domain_to_ascii(s) {
1712                Ok(r) => DomainRule::Exact(r),
1713                Err(_) => return Err(()),
1714            }
1715        })
1716    }
1717}
1718
1719#[derive(Clone, Debug)]
1720pub enum PathRule {
1721    Prefix(String),
1722    Regex(Regex),
1723    Equals(String),
1724}
1725
1726#[derive(PartialEq, Eq)]
1727pub enum PathRuleResult {
1728    Regex,
1729    Prefix(usize),
1730    Equals,
1731    None,
1732}
1733
1734impl PathRule {
1735    pub fn matches(&self, path: &[u8]) -> PathRuleResult {
1736        match self {
1737            PathRule::Prefix(prefix) => {
1738                if path.starts_with(prefix.as_bytes()) {
1739                    // The reported prefix length is the matched-byte count
1740                    // the router uses for longest-prefix tie-breaking; it
1741                    // must equal the prefix and never exceed the path.
1742                    debug_assert!(
1743                        prefix.len() <= path.len(),
1744                        "a matching prefix cannot be longer than the path it matched",
1745                    );
1746                    PathRuleResult::Prefix(prefix.len())
1747                } else {
1748                    PathRuleResult::None
1749                }
1750            }
1751            PathRule::Regex(regex) => {
1752                let start = Instant::now();
1753                let is_a_match = regex.is_match(path);
1754                let now = Instant::now();
1755                time!(
1756                    names::event_loop::REGEX_MATCHING_TIME,
1757                    (now - start).as_millis()
1758                );
1759
1760                if is_a_match {
1761                    PathRuleResult::Regex
1762                } else {
1763                    PathRuleResult::None
1764                }
1765            }
1766            PathRule::Equals(pattern) => {
1767                if path == pattern.as_bytes() {
1768                    PathRuleResult::Equals
1769                } else {
1770                    PathRuleResult::None
1771                }
1772            }
1773        }
1774    }
1775
1776    /// Compile a configured `path_type = "REGEX"` value anchored at both ends,
1777    /// so it matches the WHOLE request path and nothing else — the behaviour
1778    /// `doc/configure.md` has documented since v2.0.0 and the code did not
1779    /// implement (sozu#1350). `regex::bytes::Regex::is_match` is a substring
1780    /// search, so the previous bare `Regex::new(&rule.value)` let `bc` match
1781    /// `/abcd`.
1782    ///
1783    /// `\A(?:` … `)\z` is the form the hostname side uses too, in
1784    /// [`convert_regex_domain_rule`] around each slash-delimited segment and
1785    /// in `pattern_trie.rs`'s `anchored_segment` at segment-insert time
1786    /// (sozu#1356 closed the hostname half; this path half landed first, as
1787    /// sozu#1357).
1788    ///
1789    /// The non-capturing group is NOT decoration: `|` binds looser than
1790    /// concatenation, so the ungrouped `\Aa|b\z` parses as `(\Aa)|(b\z)`
1791    /// and each branch keeps only one anchor — measured, it still matches
1792    /// `axx`. `\A(?:a|b)\z` does not. `(?:` … `)` is non-capturing, so
1793    /// `captures_len` and therefore every `$PATH[n]` rewrite index are
1794    /// unchanged.
1795    ///
1796    /// The configured value is compiled ON ITS OWN first and only a value
1797    /// that compiles is wrapped. The wrapper supplies one `(` and one `)`, so
1798    /// an unbalanced pattern can be balanced BY the wrapping: `a)(b` is
1799    /// rejected by `Regex::new`, yet `\A(?:a)(b)\z` compiles and matches
1800    /// `ab`. Without the first compile, anchoring would silently promote a
1801    /// rule the router rejects today into a live rule matching something the
1802    /// operator never wrote.
1803    ///
1804    /// This closes invalid-becomes-valid only. The opposite direction is NOT
1805    /// closed and is not claimed to be: a pattern that compiles bare can be
1806    /// rejected once wrapped, because the appended `)\z` has to survive
1807    /// whatever the pattern left open. Measured: `(?x)/api/v1 # v1 only`
1808    /// compiles bare and fails wrapped with `unclosed group` — `(?x)` enables
1809    /// `#` comments, and the appended `)` lands inside one — and a 249-deep
1810    /// nested group compiles bare and fails wrapped against the crate's
1811    /// 250-nesting limit. Both land in the `None` arm of `from_config` and
1812    /// surface as [`RouterError::InvalidPathRule`] at frontend registration,
1813    /// which `bin/src/command/requests.rs`'s `validate_frontend_request` runs
1814    /// through this very same `add_http_front` before fanning out: the
1815    /// frontend is refused loudly and identically in main and worker, never
1816    /// silently mis-routed.
1817    ///
1818    /// Anchors the operator wrote by hand are harmless and are left alone:
1819    /// `regex`'s `^`/`$` are `\A`/`\z` in the default (non-multi-line) mode —
1820    /// `$` does not match before a trailing `\n` — so the repeated zero-width
1821    /// assertions in `\A(?:^/abc$)\z` collapse. Measured: that pattern matches
1822    /// `/abc` and not `/xabcd`, exactly as `\A(?:/abc)\z` does.
1823    fn anchored_regex(value: &str) -> Option<Regex> {
1824        anchored_regex(value)
1825    }
1826
1827    pub fn from_config(rule: CommandPathRule) -> Option<Self> {
1828        match PathRuleKind::try_from(rule.kind) {
1829            Ok(PathRuleKind::Prefix) => Some(PathRule::Prefix(rule.value)),
1830            Ok(PathRuleKind::Regex) => Self::anchored_regex(&rule.value).map(PathRule::Regex),
1831            Ok(PathRuleKind::Equals) => Some(PathRule::Equals(rule.value)),
1832            Err(_) => None,
1833        }
1834    }
1835}
1836
1837/// Compile `value` anchored at both ends, refusing a value that does not
1838/// compile on its own. [`PathRule::anchored_regex`] documents why both
1839/// steps are needed; header predicates (sozu-proxy/sozu#1253) reuse it so
1840/// a `regex` header value and a `REGEX` path follow one grammar.
1841fn anchored_regex(value: &str) -> Option<Regex> {
1842    Regex::new(value).ok()?;
1843    Regex::new(&format!("\\A(?:{value})\\z")).ok()
1844}
1845
1846/// `Regex` carries no `PartialEq`, so the comparison is written by hand
1847/// and compares patterns by `as_str()`. Every variant must answer for
1848/// itself: without an `Equals` arm the catch-all made two identical
1849/// `PathRule::Equals` unequal — not even reflexive — and the router's
1850/// bookkeeping (`add_*_rule` dedup, `remove_*_rule` eviction) silently
1851/// lost every `--path-equals` frontend, reporting success either way.
1852impl std::cmp::PartialEq for PathRule {
1853    fn eq(&self, other: &Self) -> bool {
1854        match (self, other) {
1855            (PathRule::Prefix(s1), PathRule::Prefix(s2)) => s1 == s2,
1856            (PathRule::Regex(r1), PathRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1857            (PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2,
1858            _ => false,
1859        }
1860    }
1861}
1862
1863#[derive(Clone, Debug, PartialEq, Eq)]
1864pub struct MethodRule {
1865    pub inner: Option<Method>,
1866}
1867
1868#[derive(PartialEq, Eq)]
1869pub enum MethodRuleResult {
1870    All,
1871    Equals,
1872    None,
1873}
1874
1875impl MethodRule {
1876    pub fn new(method: Option<String>) -> Self {
1877        MethodRule {
1878            inner: method.map(|s| Method::new(s.as_bytes())),
1879        }
1880    }
1881
1882    pub fn matches(&self, method: &Method) -> MethodRuleResult {
1883        match self.inner {
1884            None => MethodRuleResult::All,
1885            Some(ref m) => {
1886                if method == m {
1887                    MethodRuleResult::Equals
1888                } else {
1889                    MethodRuleResult::None
1890                }
1891            }
1892        }
1893    }
1894}
1895
1896/// What to do with a request that matches a frontend.
1897///
1898/// Three variants coexist today; the legacy two will retire once
1899/// `HttpFrontend` itself carries the rich routing fields:
1900///
1901/// - [`Route::ClusterId`] is the legacy "forward to this cluster" variant
1902///   used by call sites that build routes directly from
1903///   [`HttpFrontend::cluster_id`].
1904/// - [`Route::Deny`] is the legacy "send 401" variant used when a frontend
1905///   has no `cluster_id`.
1906/// - [`Route::Frontend`] carries a richer [`Frontend`] decision (redirect
1907///   policy, rewrite templates, header edits, auth gating). Once
1908///   `HttpFrontend` carries the matching proto fields, `add_http_front`
1909///   will build `Route::Frontend` directly and the two legacy variants
1910///   above can retire.
1911///
1912/// `Eq`/`PartialEq` compare `Frontend` variants by `Rc` pointer identity to
1913/// stay consistent with `Hash`/`Ord` on [`Rc`]; this is sufficient for the
1914/// router's de-duplication (`add_pre_rule`, `add_post_rule`,
1915/// `add_tree_rule`) which only checks against routes created from the same
1916/// configuration call.
1917#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1918pub enum Route {
1919    /// send a 401 default answer
1920    Deny,
1921    /// the cluster to which the frontend belongs
1922    ClusterId(ClusterId),
1923    /// rich routing decision carrying redirect, rewrite, header, and auth
1924    /// configuration; supersedes the two legacy variants once the
1925    /// in-memory frontend wiring is migrated to build `Route::Frontend`
1926    /// directly.
1927    Frontend(Rc<Frontend>),
1928}
1929
1930/// Materialise the listener-default HSTS into a single shareable
1931/// [`HeaderEdit`] when the supplied policy resolves to a non-empty
1932/// `Strict-Transport-Security` header.
1933///
1934/// Returns `None` when the listener has no HSTS configured
1935/// (`new_hsts.is_none()`), when HSTS is explicitly disabled
1936/// (`enabled = Some(false)`), or when the render fails because of a
1937/// missing `max_age` (`enabled = Some(true)` with `max_age = None`,
1938/// the malformed-IPC defense-in-depth gate). Mirrors the gate
1939/// previously embedded in `rebuild_with_listener_hsts`.
1940///
1941/// Used by [`Router::refresh_inheriting_hsts`] to:
1942/// - decide whether promoting a lightweight `Route::ClusterId` /
1943///   `Route::Deny` to `Route::Frontend` is worth doing (`Some` =
1944///   promote + counted; `None` = lightweight route untouched, no
1945///   allocation created just to hold an empty edit), AND
1946/// - **share the same `Rc`-backed `key` / `val` allocation across
1947///   every visited frontend in this patch**. Without sharing, each
1948///   refreshed frontend would allocate a fresh
1949///   `Rc::from(b"strict-transport-security")` and a fresh
1950///   `Rc::from(rendered.into_bytes())` — on a 91 k-frontend
1951///   `cleverapps.io` shared listener × 8 workers, one HSTS-enable
1952///   patch produces ~1.5 M identical-content `Rc` allocations per
1953///   worker. With sharing the cost collapses to one allocation per
1954///   patch plus a refcount bump on each frontend (`HeaderEdit::clone`
1955///   is two `Rc::clone`s + a byte copy).
1956fn build_listener_hsts_edit(new_hsts: Option<&HstsConfig>) -> Option<HeaderEdit> {
1957    let cfg = new_hsts?;
1958    if !matches!(cfg.enabled, Some(true)) {
1959        return None;
1960    }
1961    let rendered = render_hsts(cfg)?;
1962    let mode = if matches!(cfg.force_replace_backend, Some(true)) {
1963        HeaderEditMode::Set
1964    } else {
1965        HeaderEditMode::SetIfAbsent
1966    };
1967    Some(HeaderEdit {
1968        key: Rc::from(&b"strict-transport-security"[..]),
1969        val: rendered.into_bytes().into(),
1970        mode,
1971    })
1972}
1973
1974/// Build a new [`Frontend`] cloned from `frontend`, with its
1975/// `headers_response` re-materialised against `new_edit` — the
1976/// shared listener-default HSTS edit pre-built by
1977/// [`build_listener_hsts_edit`]. Used by
1978/// [`Router::refresh_inheriting_hsts`].
1979///
1980/// Preserves every operator-defined response-header edit (`Append`,
1981/// `Set`, legacy empty-`val`-Append delete) and replaces any existing
1982/// `Strict-Transport-Security` entry with `new_edit`. When `new_edit`
1983/// is `None` (listener-default HSTS resolves to "no HSTS"), the
1984/// function strips the existing STS entry and adds nothing.
1985///
1986/// Preserves the existing `inherits_listener_hsts` marker; callers
1987/// ensure it is `true` before invoking this helper (the function
1988/// uses `..frontend.clone()` so it inherits whatever the input has).
1989fn rebuild_with_listener_hsts(frontend: &Frontend, new_edit: Option<&HeaderEdit>) -> Frontend {
1990    // Strip any existing Strict-Transport-Security entry.
1991    let mut headers_response: Vec<HeaderEdit> = frontend
1992        .headers_response
1993        .iter()
1994        .filter(|edit| !edit.key.eq_ignore_ascii_case(b"strict-transport-security"))
1995        .cloned()
1996        .collect();
1997
1998    // `HeaderEdit::clone` here is two `Rc::clone`s on the shared
1999    // key/val plus a one-byte `mode` copy — no buffer allocation.
2000    if let Some(edit) = new_edit {
2001        headers_response.push(edit.clone());
2002    }
2003
2004    Frontend {
2005        headers_response: headers_response.into(),
2006        // every other field is unchanged
2007        ..frontend.clone()
2008    }
2009}
2010
2011/// Render an [`HstsConfig`] into a canonical RFC 6797 §6.1
2012/// `Strict-Transport-Security` header value: `max-age=N` first, then
2013/// optional `; includeSubDomains`, then optional `; preload`. No
2014/// trailing semicolon. `includeSubDomains` is the RFC §6.1 spelling
2015/// (camelCase); `preload` is lowercase per the de-facto Chrome/HSTS
2016/// preload-list convention (<https://hstspreload.org/>).
2017///
2018/// Returns `None` when the config has no `max_age` (the caller should
2019/// have substituted the default at config-load via
2020/// `command/src/config.rs::FileHstsConfig::to_proto` before reaching
2021/// this site; if it didn't, a `None` here suppresses the emission so a
2022/// malformed wire frame can't leak `max-age=0` accidentally).
2023pub fn render_hsts(cfg: &HstsConfig) -> Option<String> {
2024    let max_age = cfg.max_age?;
2025    let mut s = format!("max-age={max_age}");
2026    if matches!(cfg.include_subdomains, Some(true)) {
2027        s.push_str("; includeSubDomains");
2028    }
2029    if matches!(cfg.preload, Some(true)) {
2030        s.push_str("; preload");
2031    }
2032    Some(s)
2033}
2034
2035/// A single header mutation collected from a [`Frontend`] configuration.
2036///
2037/// `key` and `val` are owned via [`Rc`] so a `Frontend` can be held by many
2038/// routing entries (pre, tree, post) without copying the underlying bytes.
2039///
2040/// `mode` controls how the per-stream `apply_response_header_edits` pass
2041/// emits the entry on the wire — see [`HeaderEditMode`]. Operator-supplied
2042/// `[[...frontends.headers]]` entries default to [`HeaderEditMode::Append`]
2043/// (preserving the legacy empty-val-deletes encoding); typed policies
2044/// (HSTS, future RFC-correct response policies) opt into
2045/// [`HeaderEditMode::SetIfAbsent`].
2046#[derive(Clone, PartialEq, Eq)]
2047pub struct HeaderEdit {
2048    pub key: Rc<[u8]>,
2049    pub val: Rc<[u8]>,
2050    pub mode: HeaderEditMode,
2051}
2052
2053impl Debug for HeaderEdit {
2054    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2055        f.write_fmt(format_args!(
2056            "({:?}, {:?}, {:?})",
2057            String::from_utf8_lossy(&self.key),
2058            String::from_utf8_lossy(&self.val),
2059            self.mode,
2060        ))
2061    }
2062}
2063
2064/// A parsed segment of a rewrite template.
2065///
2066/// `Host(i)` references the `i`-th host capture: index 0 is the full
2067/// hostname; positive indices are regex or wildcard subgroups. `Path(i)`
2068/// references the `i`-th path capture: index 0 is the full path; positive
2069/// indices are regex groups or prefix tails. `String` holds a literal
2070/// segment between captures.
2071#[derive(Debug, Clone, PartialEq, Eq)]
2072enum RewritePart {
2073    String(String),
2074    Host(usize),
2075    Path(usize),
2076}
2077
2078/// A pre-parsed rewrite template, decomposed into `RewritePart`s.
2079///
2080/// `RewriteParts` is built once at frontend registration time
2081/// ([`Frontend::new`]) and then re-applied at lookup time via
2082/// [`RewriteParts::run`] against the captures collected by the router.
2083///
2084/// Grammar:
2085/// - `$HOST[N]` — substitute the N-th host capture
2086/// - `$PATH[N]` — substitute the N-th path capture
2087/// - any other byte sequence — substitute literally
2088///
2089/// Out-of-bounds capture indices substitute to the empty string at run
2090/// time, but [`RewriteParts::parse`] rejects templates that reference
2091/// capture indices the router cannot produce (the `*_cap_cap` arguments).
2092#[derive(Debug, Clone, PartialEq, Eq)]
2093pub struct RewriteParts(Vec<RewritePart>);
2094
2095impl RewriteParts {
2096    /// Parse `template` against the host/path capture caps the router can
2097    /// produce for the matching domain/path rule.
2098    ///
2099    /// `host_cap_cap` and `path_cap_cap` are upper bounds (exclusive) on the
2100    /// host and path capture indices the router can fill at lookup time.
2101    /// `used_index_host` / `used_index_path` are out parameters tracking the
2102    /// highest index actually referenced — callers use them to short-circuit
2103    /// capture extraction when no template references captures.
2104    ///
2105    /// Returns `None` on syntactically malformed templates: dangling `$`,
2106    /// missing closing `]`, non-digit index, or an index ≥ the cap.
2107    pub fn parse(
2108        template: &str,
2109        host_cap_cap: usize,
2110        path_cap_cap: usize,
2111        used_index_host: &mut usize,
2112        used_index_path: &mut usize,
2113    ) -> Option<Self> {
2114        let mut result = Vec::new();
2115        let mut i = 0;
2116        let pattern = template.as_bytes();
2117        while i < pattern.len() {
2118            if pattern[i] == b'$' {
2119                let is_host = if pattern[i..].starts_with(b"$HOST[") {
2120                    i += 6;
2121                    true
2122                } else if pattern[i..].starts_with(b"$PATH[") {
2123                    i += 6;
2124                    false
2125                } else {
2126                    return None;
2127                };
2128                let mut index = 0usize;
2129                let digits_start = i;
2130                while i < pattern.len() && pattern[i].is_ascii_digit() {
2131                    index = index
2132                        .checked_mul(10)?
2133                        .checked_add((pattern[i] - b'0') as usize)?;
2134                    i += 1;
2135                }
2136                if i == digits_start {
2137                    // no digits between the `[` and the `]`
2138                    return None;
2139                }
2140                if i >= pattern.len() || pattern[i] != b']' {
2141                    return None;
2142                }
2143                if is_host {
2144                    if index >= host_cap_cap {
2145                        return None;
2146                    }
2147                    if index >= *used_index_host {
2148                        *used_index_host = index + 1;
2149                    }
2150                    result.push(RewritePart::Host(index));
2151                } else {
2152                    if index >= path_cap_cap {
2153                        return None;
2154                    }
2155                    if index >= *used_index_path {
2156                        *used_index_path = index + 1;
2157                    }
2158                    result.push(RewritePart::Path(index));
2159                }
2160                i += 1; // consume `]`
2161            } else {
2162                let start = i;
2163                while i < pattern.len() && pattern[i] != b'$' {
2164                    i += 1;
2165                }
2166                // `pattern` is `template.as_bytes()` and the split is on
2167                // the ASCII byte `$` (0x24), which is always a single-byte
2168                // UTF-8 character — so `template[start..i]` lies on char
2169                // boundaries and is safe to index directly.
2170                result.push(RewritePart::String(template[start..i].to_owned()));
2171            }
2172        }
2173        // Every capture reference the parser emitted is within the caps it
2174        // was given; out-of-range indices return None above, never a part.
2175        debug_assert!(
2176            result.iter().all(|part| match part {
2177                RewritePart::Host(idx) => *idx < host_cap_cap,
2178                RewritePart::Path(idx) => *idx < path_cap_cap,
2179                RewritePart::String(_) => true,
2180            }),
2181            "a parsed rewrite template must only reference captures within the rule's caps",
2182        );
2183        debug_assert!(
2184            *used_index_host <= host_cap_cap && *used_index_path <= path_cap_cap,
2185            "the highest referenced capture index cannot exceed the cap",
2186        );
2187        Some(Self(result))
2188    }
2189
2190    /// Substitute `host_captures` and `path_captures` into the template.
2191    ///
2192    /// Out-of-bounds captures substitute to an empty string. The result is
2193    /// allocated in one pass with the exact required capacity.
2194    pub fn run(&self, host_captures: &[&str], path_captures: &[&str]) -> String {
2195        let mut cap = 0usize;
2196        for part in &self.0 {
2197            cap += match part {
2198                RewritePart::String(s) => s.len(),
2199                RewritePart::Host(i) => host_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2200                RewritePart::Path(i) => path_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2201            };
2202        }
2203        let mut result = String::with_capacity(cap);
2204        for part in &self.0 {
2205            // String::write_str cannot fail — ignore the formatter result.
2206            let _ = match part {
2207                RewritePart::String(s) => result.write_str(s),
2208                RewritePart::Host(i) => result.write_str(host_captures.get(*i).unwrap_or(&"")),
2209                RewritePart::Path(i) => result.write_str(path_captures.get(*i).unwrap_or(&"")),
2210            };
2211        }
2212        // The capacity pass and the write pass consult the same parts and
2213        // captures, so the single up-front allocation must be exact — the
2214        // result never reallocates.
2215        debug_assert_eq!(
2216            result.len(),
2217            cap,
2218            "rewrite output length must equal the pre-computed one-pass capacity",
2219        );
2220        result
2221    }
2222}
2223
2224/// What to do with the traffic for a routed frontend.
2225///
2226/// Built once at frontend registration time. The expensive work (parsing
2227/// rewrite templates, resolving headers into [`HeaderEdit`]s) happens here
2228/// so [`Router::lookup`] can run cheaply on the hot path.
2229///
2230/// A clusterless frontend with `redirect == FORWARD` is coerced to
2231/// `UNAUTHORIZED` in [`Frontend::new`] to avoid a forward loop with no
2232/// backend; the explicit `UNAUTHORIZED` policy then renders a 401.
2233///
2234/// Tags are wrapped in [`Rc<CachedTags>`] so the same frontend can be
2235/// referenced from multiple routing slots (pre/tree/post) without copying.
2236#[derive(Debug, Clone)]
2237pub struct Frontend {
2238    pub cluster_id: Option<ClusterId>,
2239    pub redirect: RedirectPolicy,
2240    pub redirect_scheme: RedirectScheme,
2241    pub redirect_template: Option<String>,
2242    /// Number of host captures the router will collect for this frontend.
2243    /// Sized from the matching [`DomainRule`]; the router skips capture
2244    /// extraction entirely when this is 0 (no rewrite references `$HOST[…]`).
2245    pub capture_cap_host: usize,
2246    /// Number of path captures the router will collect for this frontend.
2247    /// Sized from the matching [`PathRule`]; the router skips capture
2248    /// extraction entirely when this is 0 (no rewrite references `$PATH[…]`).
2249    pub capture_cap_path: usize,
2250    pub rewrite_host: Option<RewriteParts>,
2251    pub rewrite_path: Option<RewriteParts>,
2252    pub rewrite_port: Option<u16>,
2253    pub headers_request: Rc<[HeaderEdit]>,
2254    pub headers_response: Rc<[HeaderEdit]>,
2255    pub required_auth: bool,
2256    pub tags: Option<Rc<CachedTags>>,
2257    /// `true` when the frontend declared no per-frontend
2258    /// `RequestHttpFrontend.hsts` block, so the HSTS edit (if any) in
2259    /// [`Self::headers_response`] follows the listener-default
2260    /// `HttpsListenerConfig.hsts` — including when that default was
2261    /// absent at add time, so a default set later, or the default of a
2262    /// listener the frontend is copied into, still reaches it
2263    /// (sozu-proxy/sozu#1691). Consulted by
2264    /// [`Router::refresh_inheriting_hsts`] so a
2265    /// `UpdateHttpsListenerConfig.hsts` patch reflows the new default
2266    /// onto inheriting frontends without overwriting explicit
2267    /// per-frontend HSTS overrides.
2268    pub inherits_listener_hsts: bool,
2269}
2270
2271/// Origin of the per-frontend HSTS policy carried by an
2272/// [`HttpFrontend`] when the router materialises it into a
2273/// [`Frontend`]. Tracked separately because the resolved
2274/// `HttpFrontend.hsts` field is the same shape regardless of how it
2275/// was filled in — the inheritance bit lets later listener-default
2276/// patches refresh inheriting frontends without disturbing explicit
2277/// per-frontend overrides.
2278#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2279pub enum HstsOrigin {
2280    /// `front.hsts` came from the per-frontend configuration directly
2281    /// (operator wrote `[clusters.<id>.frontends.hsts]` in TOML or
2282    /// passed `--hsts-*` on the CLI). Listener-default patches do NOT
2283    /// refresh this entry.
2284    Explicit,
2285    /// The frontend declared no `hsts`, and `add_https_frontend` filled
2286    /// `front.hsts` in from the listener-default
2287    /// `HttpsListenerConfig.hsts` — `None` when the listener has no
2288    /// default. A future
2289    /// `UpdateHttpsListenerConfig.hsts` patch will refresh this entry
2290    /// via [`Router::refresh_inheriting_hsts`].
2291    InheritedFromListenerDefault,
2292}
2293
2294impl PartialEq for Frontend {
2295    fn eq(&self, other: &Self) -> bool {
2296        // Frontend instances share the rest of their fields with the
2297        // originating HttpFrontend; equality is decided by the same fields
2298        // the router uses for de-duplication.
2299        self.cluster_id == other.cluster_id
2300            && self.redirect == other.redirect
2301            && self.redirect_scheme == other.redirect_scheme
2302            && self.redirect_template == other.redirect_template
2303            && self.rewrite_host == other.rewrite_host
2304            && self.rewrite_path == other.rewrite_path
2305            && self.rewrite_port == other.rewrite_port
2306            && self.headers_request == other.headers_request
2307            && self.headers_response == other.headers_response
2308            && self.required_auth == other.required_auth
2309    }
2310}
2311
2312impl Eq for Frontend {}
2313
2314impl std::hash::Hash for Frontend {
2315    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
2316        self.cluster_id.hash(state);
2317        // RedirectPolicy / RedirectScheme are i32-backed proto enums; hash
2318        // them as i32 to avoid requiring a Hash impl on the generated enum.
2319        (self.redirect as i32).hash(state);
2320        (self.redirect_scheme as i32).hash(state);
2321        self.redirect_template.hash(state);
2322        self.required_auth.hash(state);
2323    }
2324}
2325
2326impl PartialOrd for Frontend {
2327    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2328        Some(self.cmp(other))
2329    }
2330}
2331
2332impl Ord for Frontend {
2333    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2334        self.cluster_id
2335            .cmp(&other.cluster_id)
2336            .then_with(|| (self.redirect as i32).cmp(&(other.redirect as i32)))
2337            .then_with(|| (self.redirect_scheme as i32).cmp(&(other.redirect_scheme as i32)))
2338            .then_with(|| self.redirect_template.cmp(&other.redirect_template))
2339            .then_with(|| self.required_auth.cmp(&other.required_auth))
2340    }
2341}
2342
2343impl Frontend {
2344    /// Build a [`Frontend`] from a domain/path rule pair and an
2345    /// [`HttpFrontend`] configuration.
2346    ///
2347    /// The richer proto-level fields (`redirect`, `redirect_scheme`,
2348    /// `redirect_template`, `rewrite_*`, `headers`, `required_auth`) are
2349    /// not yet carried on `HttpFrontend`; until they are, this constructor
2350    /// takes them as explicit arguments so the data flow is testable
2351    /// today and the call sites in `add_http_front` only need a one-line
2352    /// update once the fields are plumbed through.
2353    ///
2354    /// Coercions:
2355    /// - `redirect == UNAUTHORIZED` zeroes out rewrite/headers/auth — the
2356    ///   request will be rejected with a 401 regardless.
2357    /// - `redirect == FORWARD` on a clusterless frontend (`cluster_id ==
2358    ///   None`) is coerced to `UNAUTHORIZED` (logged as a warning) to
2359    ///   avoid a forward loop with no backend.
2360    ///
2361    /// Returns [`RouterError::InvalidHostRewrite`] /
2362    /// [`RouterError::InvalidPathRewrite`] when a rewrite template fails to
2363    /// parse against the rule's capture caps.
2364    #[allow(clippy::too_many_arguments)]
2365    pub fn new(
2366        domain_rule: &DomainRule,
2367        path_rule: &PathRule,
2368        front: &HttpFrontend,
2369        redirect: RedirectPolicy,
2370        redirect_scheme: RedirectScheme,
2371        redirect_template: Option<String>,
2372        rewrite_host: Option<String>,
2373        rewrite_path: Option<String>,
2374        rewrite_port: Option<u16>,
2375        headers: &[sozu_command::proto::command::Header],
2376        required_auth: bool,
2377        hsts_origin: HstsOrigin,
2378    ) -> Result<Self, RouterError> {
2379        // HSTS is read from `front.hsts` directly inside the function;
2380        // an explicit parameter would be redundant since `front` is
2381        // already in scope and the field is the single source of truth.
2382        // The `hsts_origin` parameter records whether the frontend declared
2383        // its own `hsts` (whatever the listener default was at add time) so
2384        // [`Router::refresh_inheriting_hsts`] can reflow listener defaults
2385        // without disturbing explicit per-frontend overrides.
2386        let hsts = front.hsts.as_ref();
2387        let inherits_listener_hsts =
2388            matches!(hsts_origin, HstsOrigin::InheritedFromListenerDefault);
2389        let cluster_id = front.cluster_id.clone();
2390        let tags = front
2391            .tags
2392            .clone()
2393            .map(|tags| Rc::new(CachedTags::new(tags)));
2394
2395        // Coerce clusterless FORWARD to UNAUTHORIZED before doing any
2396        // expensive parsing: those routes can never proceed to a backend, so
2397        // emitting a 401 is the safe default. Empty redirect_template is
2398        // treated as None semantics so we never store an empty Rc<[…]>.
2399        let redirect_template = redirect_template.filter(|s| !s.is_empty());
2400        let rewrite_host = rewrite_host.filter(|s| !s.is_empty());
2401        let rewrite_path = rewrite_path.filter(|s| !s.is_empty());
2402
2403        let deny = match (&cluster_id, redirect) {
2404            (_, RedirectPolicy::Unauthorized) => true,
2405            (None, RedirectPolicy::Forward) => {
2406                let (domain_kind, domain_bytes) = match &domain_rule {
2407                    DomainRule::Any => ("any", 0),
2408                    DomainRule::Exact(value) => ("exact", value.len()),
2409                    DomainRule::Wildcard(value) => ("wildcard", value.len()),
2410                    DomainRule::Regex(value) => ("regex", value.as_str().len()),
2411                };
2412                let (path_kind, path_bytes) = match &path_rule {
2413                    PathRule::Prefix(value) => ("prefix", value.len()),
2414                    PathRule::Regex(value) => ("regex", value.as_str().len()),
2415                    PathRule::Equals(value) => ("equals", value.len()),
2416                };
2417                warn!(
2418                    "{} Frontend[domain_kind={}, domain_bytes={}, path_kind={}, path_bytes={}]: forward on clusterless frontends are unauthorized",
2419                    log_module_context!(),
2420                    domain_kind,
2421                    domain_bytes,
2422                    path_kind,
2423                    path_bytes,
2424                );
2425                true
2426            }
2427            _ => false,
2428        };
2429        if deny {
2430            // The Unauthorized policy zeroes out request rewrites and
2431            // header injections (the request never reaches a backend),
2432            // but RFC 6797 §8.1 still requires HSTS on the 401 default
2433            // answer when the frontend is on an HTTPS listener. Build
2434            // the HSTS edit (when configured) so the per-stream
2435            // snapshot copy in `mux/router.rs` carries it through to
2436            // `set_default_answer_with_retry_after`.
2437            let mut deny_headers_response: Vec<HeaderEdit> = Vec::new();
2438            if let Some(cfg) = hsts
2439                && matches!(cfg.enabled, Some(true))
2440                && let Some(rendered) = render_hsts(cfg)
2441            {
2442                let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2443                    HeaderEditMode::Set
2444                } else {
2445                    HeaderEditMode::SetIfAbsent
2446                };
2447                deny_headers_response.push(HeaderEdit {
2448                    key: Rc::from(&b"strict-transport-security"[..]),
2449                    val: rendered.into_bytes().into(),
2450                    mode,
2451                });
2452                crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2453            }
2454
2455            return Ok(Self {
2456                cluster_id,
2457                redirect: RedirectPolicy::Unauthorized,
2458                redirect_scheme,
2459                redirect_template: None,
2460                capture_cap_host: 0,
2461                capture_cap_path: 0,
2462                rewrite_host: None,
2463                rewrite_path: None,
2464                rewrite_port: None,
2465                headers_request: Rc::new([]),
2466                headers_response: deny_headers_response.into(),
2467                required_auth,
2468                tags,
2469                inherits_listener_hsts,
2470            });
2471        }
2472
2473        // Capture caps: the maximum index a `$HOST[N]` / `$PATH[N]`
2474        // template can reference for this rule pair. Index 0 is always the
2475        // full hostname/path; subsequent indices are wildcard tail / regex
2476        // groups.
2477        let mut capture_cap_host = match domain_rule {
2478            DomainRule::Any => 1,
2479            DomainRule::Exact(_) => 1,
2480            DomainRule::Wildcard(_) => 2,
2481            DomainRule::Regex(regex) => regex.captures_len(),
2482        };
2483        let mut capture_cap_path = match path_rule {
2484            PathRule::Equals(_) => 1,
2485            PathRule::Prefix(_) => 2,
2486            PathRule::Regex(regex) => regex.captures_len(),
2487        };
2488        let mut used_capture_host = 0usize;
2489        let mut used_capture_path = 0usize;
2490        let rewrite_host_parts = if let Some(p) = rewrite_host {
2491            Some(
2492                RewriteParts::parse(
2493                    &p,
2494                    capture_cap_host,
2495                    capture_cap_path,
2496                    &mut used_capture_host,
2497                    &mut used_capture_path,
2498                )
2499                .ok_or(RouterError::InvalidHostRewrite(p))?,
2500            )
2501        } else {
2502            None
2503        };
2504        let rewrite_path_parts = if let Some(p) = rewrite_path {
2505            Some(
2506                RewriteParts::parse(
2507                    &p,
2508                    capture_cap_host,
2509                    capture_cap_path,
2510                    &mut used_capture_host,
2511                    &mut used_capture_path,
2512                )
2513                .ok_or(RouterError::InvalidPathRewrite(p))?,
2514            )
2515        } else {
2516            None
2517        };
2518        // Skip capture extraction at lookup time when no template references
2519        // a capture for this dimension.
2520        if used_capture_host == 0 {
2521            capture_cap_host = 0;
2522        }
2523        if used_capture_path == 0 {
2524            capture_cap_path = 0;
2525        }
2526
2527        let mut headers_request = Vec::new();
2528        let mut headers_response = Vec::new();
2529        for header in headers {
2530            let edit = HeaderEdit {
2531                key: header.key.as_bytes().into(),
2532                val: header.val.as_bytes().into(),
2533                mode: HeaderEditMode::Append,
2534            };
2535            match header.position() {
2536                HeaderPosition::Request => headers_request.push(edit),
2537                HeaderPosition::Response => headers_response.push(edit),
2538                HeaderPosition::Both => {
2539                    headers_request.push(edit.clone());
2540                    headers_response.push(edit);
2541                }
2542                // The proto-default-encoded shape (`position: 0`). The TOML
2543                // loader rejects this case in `parse_header_edit` so the
2544                // path is only reachable via a manually-constructed
2545                // `Header { position: 0, … }` from a buggy or older
2546                // client. Drop the edit rather than guessing a position.
2547                HeaderPosition::Unspecified => {
2548                    warn!(
2549                        "{} dropping {:?} with HEADER_POSITION_UNSPECIFIED",
2550                        log_module_context!(),
2551                        header,
2552                    );
2553                }
2554            }
2555        }
2556
2557        // Materialise HSTS (RFC 6797) into the response-side header
2558        // collection as a single `SetIfAbsent` edit so an upstream-emitted
2559        // `Strict-Transport-Security` survives unchanged (RFC 6797 §6.1
2560        // single-header requirement). `enabled = Some(false)` is the
2561        // explicit-disable signal — emit nothing. The §7.2 "no STS over
2562        // plaintext HTTP" gate is enforced by the runtime snapshot site,
2563        // which only copies `headers_response` for HTTPS-served requests.
2564        if let Some(cfg) = hsts
2565            && matches!(cfg.enabled, Some(true))
2566        {
2567            if let Some(rendered) = render_hsts(cfg) {
2568                // RFC 6797 §6.1 default: PRESERVE backend-supplied STS
2569                // (SetIfAbsent). Operator opts into harden-centrally
2570                // override via `force_replace_backend = true`, which
2571                // selects `Set` (delete-then-insert) so any backend
2572                // STS is replaced with sozu's rendered policy.
2573                let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2574                    HeaderEditMode::Set
2575                } else {
2576                    HeaderEditMode::SetIfAbsent
2577                };
2578                headers_response.push(HeaderEdit {
2579                    key: Rc::from(&b"strict-transport-security"[..]),
2580                    val: rendered.into_bytes().into(),
2581                    mode,
2582                });
2583                crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2584            } else {
2585                // Both upstream config layers (FileHstsConfig::to_proto and
2586                // build_hsts_from_cli) substitute DEFAULT_HSTS_MAX_AGE when
2587                // enabled = Some(true) && max_age = None, so reaching this
2588                // branch means a programmatic IPC sender produced an
2589                // ill-formed HstsConfig. Surface it loudly rather than
2590                // silently emitting no header — operators inspecting their
2591                // dashboards for http.hsts.unrendered will catch the bug.
2592                warn!(
2593                    "{} HSTS enabled = true on frontend cluster_id_bytes={:?} but render_hsts \
2594                     returned None (max_age missing). Frontend will not emit \
2595                     Strict-Transport-Security; the config layer that built \
2596                     this HstsConfig must substitute DEFAULT_HSTS_MAX_AGE.",
2597                    log_module_context!(),
2598                    cluster_id.as_deref().map(str::len),
2599                );
2600                crate::incr!(names::http::HSTS_UNRENDERED);
2601            }
2602        }
2603
2604        Ok(Frontend {
2605            cluster_id,
2606            redirect,
2607            redirect_scheme,
2608            redirect_template,
2609            capture_cap_host,
2610            capture_cap_path,
2611            rewrite_host: rewrite_host_parts,
2612            rewrite_path: rewrite_path_parts,
2613            rewrite_port,
2614            headers_request: headers_request.into(),
2615            headers_response: headers_response.into(),
2616            required_auth,
2617            tags,
2618            inherits_listener_hsts,
2619        })
2620    }
2621
2622    /// Build a minimal Frontend that simply forwards to `cluster_id`,
2623    /// with no rewrite / header / auth configuration. Equivalent to a
2624    /// `Route::ClusterId(cluster_id)` lookup-wise (lookup short-circuits
2625    /// to `RouteResult::forward(id)` for both shapes), with the added
2626    /// ability to carry response-header edits — used by
2627    /// [`Router::refresh_inheriting_hsts`] to promote a lightweight
2628    /// `Route::ClusterId` to a `Route::Frontend` carrying just the
2629    /// listener-default HSTS edit. The `inherits_listener_hsts = true`
2630    /// marker lets subsequent listener-default patches keep refreshing
2631    /// the promoted entry.
2632    pub(crate) fn minimal_forward(cluster_id: ClusterId) -> Self {
2633        Self {
2634            cluster_id: Some(cluster_id),
2635            redirect: RedirectPolicy::Forward,
2636            redirect_scheme: RedirectScheme::UseSame,
2637            redirect_template: None,
2638            capture_cap_host: 0,
2639            capture_cap_path: 0,
2640            rewrite_host: None,
2641            rewrite_path: None,
2642            rewrite_port: None,
2643            headers_request: Rc::new([]),
2644            headers_response: Rc::new([]),
2645            required_auth: false,
2646            tags: None,
2647            inherits_listener_hsts: true,
2648        }
2649    }
2650
2651    /// Build a minimal clusterless Frontend with the
2652    /// [`RedirectPolicy::Unauthorized`] policy. Equivalent to a
2653    /// `Route::Deny` lookup-wise (both yield a 401 default answer),
2654    /// with the added ability to carry response-header edits — used by
2655    /// [`Router::refresh_inheriting_hsts`] to promote `Route::Deny`
2656    /// entries when the listener-default HSTS becomes enabled, so the
2657    /// 401 default answer carries the `Strict-Transport-Security`
2658    /// header per RFC 6797 §8.1. The `inherits_listener_hsts = true`
2659    /// marker lets subsequent listener-default patches keep refreshing
2660    /// the promoted entry.
2661    pub(crate) fn minimal_deny() -> Self {
2662        Self {
2663            cluster_id: None,
2664            redirect: RedirectPolicy::Unauthorized,
2665            redirect_scheme: RedirectScheme::UseSame,
2666            redirect_template: None,
2667            capture_cap_host: 0,
2668            capture_cap_path: 0,
2669            rewrite_host: None,
2670            rewrite_path: None,
2671            rewrite_port: None,
2672            headers_request: Rc::new([]),
2673            headers_response: Rc::new([]),
2674            required_auth: false,
2675            tags: None,
2676            inherits_listener_hsts: true,
2677        }
2678    }
2679}
2680
2681/// Routing decision returned by [`Router::lookup`] and consumed by the
2682/// session layer.
2683///
2684/// Computed from a matched [`Frontend`] by running [`RewriteParts::run`]
2685/// against the captures collected during routing. Legacy [`Route::ClusterId`]
2686/// and [`Route::Deny`] entries synthesize a minimal `RouteResult` with the
2687/// proto enums set to defaults (`FORWARD` / `UNAUTHORIZED`) so existing
2688/// session code keeps working until the mux layer is updated to read every
2689/// `RouteResult` field directly.
2690///
2691/// Implements `PartialEq` for test parity: existing router tests compare
2692/// `router.lookup(...)` against an expected route. Equality compares every
2693/// public field, including the `Rc<[HeaderEdit]>` slices via pointer-or-content
2694/// equality on the slice contents.
2695#[derive(Debug, Clone, PartialEq)]
2696pub struct RouteResult {
2697    pub cluster_id: Option<ClusterId>,
2698    pub redirect: RedirectPolicy,
2699    pub redirect_scheme: RedirectScheme,
2700    pub redirect_template: Option<String>,
2701    pub rewritten_host: Option<String>,
2702    pub rewritten_path: Option<String>,
2703    pub rewritten_port: Option<u16>,
2704    pub headers_request: Rc<[HeaderEdit]>,
2705    pub headers_response: Rc<[HeaderEdit]>,
2706    pub required_auth: bool,
2707    pub tags: Option<Rc<CachedTags>>,
2708}
2709
2710impl RouteResult {
2711    /// Synthesize a `RouteResult` representing a 401 (Deny) decision.
2712    pub fn deny(cluster_id: Option<ClusterId>) -> Self {
2713        Self {
2714            cluster_id,
2715            redirect: RedirectPolicy::Unauthorized,
2716            redirect_scheme: RedirectScheme::UseSame,
2717            redirect_template: None,
2718            rewritten_host: None,
2719            rewritten_path: None,
2720            rewritten_port: None,
2721            headers_request: Rc::new([]),
2722            headers_response: Rc::new([]),
2723            required_auth: false,
2724            tags: None,
2725        }
2726    }
2727
2728    /// Synthesize a `RouteResult` representing a "forward to this cluster"
2729    /// decision (legacy [`Route::ClusterId`] adapter).
2730    pub fn forward(cluster_id: ClusterId) -> Self {
2731        Self {
2732            cluster_id: Some(cluster_id),
2733            redirect: RedirectPolicy::Forward,
2734            redirect_scheme: RedirectScheme::UseSame,
2735            redirect_template: None,
2736            rewritten_host: None,
2737            rewritten_path: None,
2738            rewritten_port: None,
2739            headers_request: Rc::new([]),
2740            headers_response: Rc::new([]),
2741            required_auth: false,
2742            tags: None,
2743        }
2744    }
2745
2746    /// Build a `RouteResult` from a [`Frontend`] and the captures collected
2747    /// for this lookup.
2748    fn from_frontend(
2749        frontend: &Frontend,
2750        captures_host: Vec<&str>,
2751        path: &[u8],
2752        path_rule: &PathRule,
2753    ) -> Self {
2754        // Unauthorized short-circuit: skip the path-capture extraction
2755        // entirely — the response will not consume any rewrite output.
2756        // `headers_response` IS preserved here (unlike `headers_request`)
2757        // so the per-stream snapshot copy in `mux/router.rs` can still
2758        // pick up the per-frontend HSTS edit and inject it on the 401
2759        // default answer (RFC 6797 §8.1 — HSTS applies to all response
2760        // codes, including the proxy's typed unauthorized answer).
2761        if frontend.redirect == RedirectPolicy::Unauthorized {
2762            return Self {
2763                cluster_id: frontend.cluster_id.clone(),
2764                redirect: RedirectPolicy::Unauthorized,
2765                redirect_scheme: frontend.redirect_scheme,
2766                redirect_template: frontend.redirect_template.clone(),
2767                rewritten_host: None,
2768                rewritten_path: None,
2769                rewritten_port: None,
2770                headers_request: Rc::new([]),
2771                headers_response: frontend.headers_response.clone(),
2772                required_auth: frontend.required_auth,
2773                tags: frontend.tags.clone(),
2774            };
2775        }
2776
2777        let mut captures_path: Vec<&str> = Vec::with_capacity(frontend.capture_cap_path);
2778        if frontend.capture_cap_path > 0 {
2779            captures_path.push(from_utf8(path).unwrap_or_default());
2780            match path_rule {
2781                PathRule::Prefix(prefix) => {
2782                    let tail_start = prefix.len().min(path.len());
2783                    captures_path.push(from_utf8(&path[tail_start..]).unwrap_or_default());
2784                }
2785                PathRule::Regex(regex) => {
2786                    if let Some(caps) = regex.captures(path) {
2787                        captures_path.extend(caps.iter().skip(1).map(|c| {
2788                            c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2789                                .unwrap_or("")
2790                        }));
2791                    }
2792                }
2793                PathRule::Equals(_) => {}
2794            }
2795        }
2796
2797        Self {
2798            cluster_id: frontend.cluster_id.clone(),
2799            redirect: frontend.redirect,
2800            redirect_scheme: frontend.redirect_scheme,
2801            redirect_template: frontend.redirect_template.clone(),
2802            rewritten_host: frontend
2803                .rewrite_host
2804                .as_ref()
2805                .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2806            rewritten_path: frontend
2807                .rewrite_path
2808                .as_ref()
2809                .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2810            rewritten_port: frontend.rewrite_port,
2811            headers_request: frontend.headers_request.clone(),
2812            headers_response: frontend.headers_response.clone(),
2813            required_auth: frontend.required_auth,
2814            tags: frontend.tags.clone(),
2815        }
2816    }
2817
2818    /// Build a `RouteResult` for a pre/post rule match.
2819    ///
2820    /// Pre/post rules carry the matched [`DomainRule`] directly so we can
2821    /// extract host captures from it without going through the trie.
2822    fn new_no_trie<'a>(
2823        domain: &'a [u8],
2824        domain_rule: &DomainRule,
2825        path: &'a [u8],
2826        path_rule: &PathRule,
2827        route: &Route,
2828    ) -> Self {
2829        let frontend = match route {
2830            Route::Frontend(f) => f.clone(),
2831            Route::ClusterId(id) => return Self::forward(id.clone()),
2832            Route::Deny => return Self::deny(None),
2833        };
2834        let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2835        if frontend.capture_cap_host > 0 {
2836            captures_host.push(from_utf8(domain).unwrap_or_default());
2837            match domain_rule {
2838                DomainRule::Wildcard(suffix) => {
2839                    let head_end = domain.len().saturating_sub(suffix.len().saturating_sub(1));
2840                    captures_host.push(from_utf8(&domain[..head_end]).unwrap_or_default());
2841                }
2842                DomainRule::Regex(regex) => {
2843                    if let Some(caps) = regex.captures(domain) {
2844                        captures_host.extend(caps.iter().skip(1).map(|c| {
2845                            c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2846                                .unwrap_or("")
2847                        }));
2848                    }
2849                }
2850                DomainRule::Any | DomainRule::Exact(_) => {}
2851            }
2852        }
2853        Self::from_frontend(&frontend, captures_host, path, path_rule)
2854    }
2855
2856    /// Build a `RouteResult` for a tree-match.
2857    ///
2858    /// Tree matches carry the captures collected by the trie traversal
2859    /// (`InlineTrieMatches`) alongside the matched leaf path rule.
2860    ///
2861    /// The trie records segments in walk order, rightmost label first, so
2862    /// they are read back in reverse: `$HOST[n]` counts left to right, as the
2863    /// whole-host regex of a pre/post rule does (sozu#1595). Each segment's
2864    /// own groups keep their regex order.
2865    fn new_with_trie<'a, 'b>(
2866        domain: &'a [u8],
2867        domain_submatches: &InlineTrieMatches<'a, 'b>,
2868        path: &'a [u8],
2869        path_rule: &PathRule,
2870        route: &Route,
2871    ) -> Self {
2872        let frontend = match route {
2873            Route::Frontend(f) => f.clone(),
2874            Route::ClusterId(id) => return Self::forward(id.clone()),
2875            Route::Deny => return Self::deny(None),
2876        };
2877        let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2878        if frontend.capture_cap_host > 0 {
2879            captures_host.push(from_utf8(domain).unwrap_or_default());
2880            for submatch in domain_submatches.iter().rev() {
2881                match submatch {
2882                    TrieSubMatch::Wildcard(part) => {
2883                        captures_host.push(from_utf8(part).unwrap_or_default());
2884                    }
2885                    TrieSubMatch::Regexp(part, regex) => {
2886                        if let Some(caps) = regex.captures(part) {
2887                            captures_host.extend(caps.iter().skip(1).map(|c| {
2888                                c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2889                                    .unwrap_or("")
2890                            }));
2891                        }
2892                    }
2893                }
2894            }
2895        }
2896        Self::from_frontend(&frontend, captures_host, path, path_rule)
2897    }
2898}
2899
2900#[cfg(test)]
2901mod tests {
2902    use super::*;
2903    use quickcheck::{Arbitrary, Gen, TestResult, quickcheck};
2904
2905    /// A frontend's `method` is classified by the same case-sensitive
2906    /// [`Method::new`] as the request's (sozu-proxy/sozu#1451), so the
2907    /// operator-facing rule is "declare the method in the exact case the
2908    /// client sends". A frontend declared `method = "get"` is a custom-method
2909    /// rule: it no longer answers a canonical `GET` request, and it answers a
2910    /// literal `get` one instead.
2911    #[test]
2912    fn method_rule_is_case_sensitive() {
2913        let declared_lowercase = MethodRule::new(Some("get".to_owned()));
2914
2915        assert_eq!(
2916            declared_lowercase.inner,
2917            Some(Method::Custom("get".to_owned())),
2918            "a lowercase declaration must not collapse onto Method::Get"
2919        );
2920        assert!(
2921            declared_lowercase.matches(&Method::Get) == MethodRuleResult::None,
2922            "a lowercase declaration must not match a canonical GET request"
2923        );
2924        assert!(
2925            declared_lowercase.matches(&Method::new(b"get")) == MethodRuleResult::Equals,
2926            "a lowercase declaration must match a literal lowercase request"
2927        );
2928
2929        let declared_canonical = MethodRule::new(Some("GET".to_owned()));
2930
2931        assert!(
2932            declared_canonical.matches(&Method::Get) == MethodRuleResult::Equals,
2933            "a canonical declaration must still match a canonical GET request"
2934        );
2935        assert!(
2936            declared_canonical.matches(&Method::new(b"get")) == MethodRuleResult::None,
2937            "a canonical declaration must not match a lowercase request"
2938        );
2939    }
2940
2941    fn test_http_frontend() -> HttpFrontend {
2942        HttpFrontend {
2943            cluster_id: Some("cluster".into()),
2944            address: "127.0.0.1:8080"
2945                .parse()
2946                .expect("test frontend address must parse"),
2947            hostname: "example.com".to_owned(),
2948            path: CommandPathRule::prefix("/".to_owned()),
2949            method: None,
2950            position: RulePosition::Tree,
2951            tags: None,
2952            redirect: None,
2953            redirect_scheme: None,
2954            redirect_template: None,
2955            rewrite_host: None,
2956            rewrite_path: None,
2957            rewrite_port: None,
2958            required_auth: None,
2959            headers: Vec::new(),
2960            hsts: None,
2961            header_predicates: Vec::new(),
2962        }
2963    }
2964
2965    #[test]
2966    fn clusterless_forward_warning_redacts_domain_and_path_rules() {
2967        const DOMAIN_SECRET: &str = "clusterless_domain_secret_sentinel";
2968        const PATH_SECRET: &str = "CLUSTERLESS_PATH_SECRET_SENTINEL";
2969
2970        // The hostname stays below `MAX_HOSTNAME_LENGTH` so it reaches the
2971        // code under test instead of the pre-parse bound; the redaction
2972        // property being asserted is length-independent.
2973        let domain = format!("{DOMAIN_SECRET}{}", "x".repeat(2048));
2974        let path = format!("/{PATH_SECRET}{}", "x".repeat(4096));
2975        let domain_len = domain.len();
2976        let path_len = path.len();
2977        let output = crate::capture_test_logs(move || {
2978            let mut router = Router::new();
2979            let mut front = test_http_frontend();
2980            front.cluster_id = None;
2981            front.hostname = domain;
2982            front.path = CommandPathRule::prefix(path);
2983            front.redirect = Some(RedirectPolicy::Forward as i32);
2984            router
2985                .add_http_front(&front)
2986                .expect("clusterless forward must be coerced to unauthorized");
2987        });
2988
2989        for secret in [DOMAIN_SECRET, PATH_SECRET] {
2990            assert!(
2991                !output.contains(secret),
2992                "clusterless-forward warning leaked rule marker {secret}"
2993            );
2994        }
2995        for metadata in [
2996            "domain_kind=exact".to_owned(),
2997            format!("domain_bytes={domain_len}"),
2998            "path_kind=prefix".to_owned(),
2999            format!("path_bytes={path_len}"),
3000        ] {
3001            assert!(
3002                output.contains(&metadata),
3003                "clusterless-forward warning omitted bounded metadata {metadata}: {output}"
3004            );
3005        }
3006        assert!(
3007            output.len() <= 512,
3008            "clusterless-forward warning is not bounded: {} bytes",
3009            output.len()
3010        );
3011    }
3012
3013    #[test]
3014    fn malformed_hsts_warning_redacts_cluster_id() {
3015        const CLUSTER_SECRET: &str = "MALFORMED_HSTS_CLUSTER_SECRET_SENTINEL";
3016
3017        let cluster_id = format!("{CLUSTER_SECRET}{}", "x".repeat(4096));
3018        let cluster_id_len = cluster_id.len();
3019        let output = crate::capture_test_logs(move || {
3020            let mut router = Router::new();
3021            let mut front = test_http_frontend();
3022            front.cluster_id = Some(cluster_id.into());
3023            front.hsts = Some(HstsConfig {
3024                enabled: Some(true),
3025                max_age: None,
3026                include_subdomains: Some(true),
3027                preload: Some(true),
3028                force_replace_backend: Some(false),
3029            });
3030            router
3031                .add_http_front(&front)
3032                .expect("malformed HSTS must preserve routing and omit the header");
3033        });
3034
3035        assert!(
3036            !output.contains(CLUSTER_SECRET),
3037            "malformed-HSTS warning leaked cluster id {CLUSTER_SECRET}"
3038        );
3039        assert!(
3040            output.contains(&format!("cluster_id_bytes=Some({cluster_id_len})")),
3041            "malformed-HSTS warning omitted the bounded cluster id length: {output}"
3042        );
3043        assert!(
3044            output.len() <= 768,
3045            "malformed-HSTS warning is not bounded: {} bytes",
3046            output.len()
3047        );
3048    }
3049
3050    #[test]
3051    fn router_errors_redact_frontend_rule_and_rewrite_fields() {
3052        const PATH_SECRET: &str = "ROUTER_ERROR_PATH_SECRET_SENTINEL";
3053        const HOSTNAME_SECRET: &str = "ROUTER_ERROR_HOSTNAME_SECRET_SENTINEL";
3054        const HOST_REWRITE_SECRET: &str = "ROUTER_ERROR_HOST_REWRITE_SECRET_SENTINEL";
3055        const PATH_REWRITE_SECRET: &str = "ROUTER_ERROR_PATH_REWRITE_SECRET_SENTINEL";
3056
3057        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3058        let cases = [
3059            (
3060                "path_bytes",
3061                PATH_SECRET,
3062                long_value(PATH_SECRET).len(),
3063                RouterError::InvalidPathRule(long_value(PATH_SECRET)),
3064            ),
3065            (
3066                "hostname_bytes",
3067                HOSTNAME_SECRET,
3068                long_value(HOSTNAME_SECRET).len(),
3069                RouterError::InvalidDomain {
3070                    hostname: long_value(HOSTNAME_SECRET),
3071                },
3072            ),
3073            (
3074                "rewrite_host_bytes",
3075                HOST_REWRITE_SECRET,
3076                long_value(HOST_REWRITE_SECRET).len(),
3077                RouterError::InvalidHostRewrite(long_value(HOST_REWRITE_SECRET)),
3078            ),
3079            (
3080                "rewrite_path_bytes",
3081                PATH_REWRITE_SECRET,
3082                long_value(PATH_REWRITE_SECRET).len(),
3083                RouterError::InvalidPathRewrite(long_value(PATH_REWRITE_SECRET)),
3084            ),
3085        ];
3086
3087        for (length_label, secret, value_len, error) in cases {
3088            for (format_label, output) in [
3089                ("Display", error.to_string()),
3090                ("Debug", format!("{error:?}")),
3091            ] {
3092                assert!(
3093                    !output.contains(secret),
3094                    "RouterError {format_label} leaked frontend marker {secret}"
3095                );
3096                let metadata = format!("{length_label}={value_len}");
3097                assert!(
3098                    output.contains(&metadata),
3099                    "RouterError {format_label} omitted bounded metadata {metadata}: {output}"
3100                );
3101                assert!(
3102                    output.len() <= 256,
3103                    "RouterError {format_label} output is not bounded: {} bytes",
3104                    output.len()
3105                );
3106            }
3107        }
3108    }
3109
3110    #[test]
3111    fn route_miss_error_retains_inputs_but_bounds_textual_formatting() {
3112        const HOST_SECRET: &str = "ROUTE_MISS_HOST_SECRET_SENTINEL";
3113        const PATH_SECRET: &str = "ROUTE_MISS_PATH_SECRET_SENTINEL";
3114        const METHOD_SECRET: &str = "ROUTE_MISS_METHOD_SECRET_SENTINEL";
3115
3116        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3117        let host = long_value(HOST_SECRET);
3118        let path = long_value(PATH_SECRET);
3119        let method = Method::Custom(long_value(METHOD_SECRET));
3120        let error = match Router::new().lookup(&host, &path, &method) {
3121            Err(error) => error,
3122            Ok(_) => panic!("empty router must return a route miss"),
3123        };
3124
3125        match &error {
3126            RouterError::RouteNotFound {
3127                host: retained_host,
3128                path: retained_path,
3129                method: retained_method,
3130            } => {
3131                assert_eq!(retained_host, &host);
3132                assert_eq!(retained_path, &path);
3133                assert_eq!(retained_method, &method);
3134            }
3135            other => panic!("expected RouterError::RouteNotFound, got {other:?}"),
3136        }
3137
3138        for output in [error.to_string(), format!("{error:?}")] {
3139            for secret in [HOST_SECRET, PATH_SECRET, METHOD_SECRET] {
3140                assert!(
3141                    !output.contains(secret),
3142                    "route miss formatting leaked {secret}: {output}"
3143                );
3144            }
3145            for metadata in [
3146                format!("host_bytes={}", host.len()),
3147                format!("path_bytes={}", path.len()),
3148                format!("bytes={}", method.as_ref().len()),
3149            ] {
3150                assert!(
3151                    output.contains(&metadata),
3152                    "route miss formatting omitted {metadata}: {output}"
3153                );
3154            }
3155            assert!(
3156                output.len() <= 256,
3157                "route miss formatting is not bounded: {} bytes",
3158                output.len()
3159            );
3160        }
3161    }
3162
3163    #[test]
3164    fn render_hsts_max_age_only() {
3165        let cfg = HstsConfig {
3166            enabled: Some(true),
3167            max_age: Some(31_536_000),
3168            include_subdomains: None,
3169            preload: None,
3170            force_replace_backend: None,
3171        };
3172        assert_eq!(render_hsts(&cfg), Some("max-age=31536000".to_owned()));
3173    }
3174
3175    #[test]
3176    fn render_hsts_with_include_subdomains() {
3177        let cfg = HstsConfig {
3178            enabled: Some(true),
3179            max_age: Some(31_536_000),
3180            include_subdomains: Some(true),
3181            preload: None,
3182            force_replace_backend: None,
3183        };
3184        assert_eq!(
3185            render_hsts(&cfg),
3186            Some("max-age=31536000; includeSubDomains".to_owned())
3187        );
3188    }
3189
3190    #[test]
3191    fn render_hsts_with_preload_only() {
3192        let cfg = HstsConfig {
3193            enabled: Some(true),
3194            max_age: Some(63_072_000),
3195            include_subdomains: None,
3196            preload: Some(true),
3197            force_replace_backend: None,
3198        };
3199        assert_eq!(
3200            render_hsts(&cfg),
3201            Some("max-age=63072000; preload".to_owned())
3202        );
3203    }
3204
3205    #[test]
3206    fn render_hsts_full() {
3207        let cfg = HstsConfig {
3208            enabled: Some(true),
3209            max_age: Some(31_536_000),
3210            include_subdomains: Some(true),
3211            preload: Some(true),
3212            force_replace_backend: None,
3213        };
3214        assert_eq!(
3215            render_hsts(&cfg),
3216            Some("max-age=31536000; includeSubDomains; preload".to_owned())
3217        );
3218    }
3219
3220    #[test]
3221    fn render_hsts_kill_switch_max_age_zero() {
3222        let cfg = HstsConfig {
3223            enabled: Some(true),
3224            max_age: Some(0),
3225            include_subdomains: Some(true),
3226            preload: None,
3227            force_replace_backend: None,
3228        };
3229        // `max_age = 0` is the RFC 6797 §11.4 kill switch and renders
3230        // verbatim — UA receives it and stops treating the host as a
3231        // Known HSTS Host.
3232        assert_eq!(
3233            render_hsts(&cfg),
3234            Some("max-age=0; includeSubDomains".to_owned())
3235        );
3236    }
3237
3238    #[test]
3239    fn render_hsts_omitted_when_max_age_missing() {
3240        let cfg = HstsConfig {
3241            enabled: Some(true),
3242            max_age: None,
3243            include_subdomains: Some(true),
3244            preload: None,
3245            force_replace_backend: None,
3246        };
3247        // The TOML loader substitutes the default at config-load; if the
3248        // field reaches `render_hsts` as `None`, suppress emission so a
3249        // malformed wire frame can't accidentally render `max-age=`.
3250        assert_eq!(render_hsts(&cfg), None);
3251    }
3252
3253    #[test]
3254    fn rebuild_with_listener_hsts_replaces_existing_entry() {
3255        // An inheriting frontend whose listener-default HSTS changed
3256        // from 1y → 2y must end up with the 2y entry on its
3257        // headers_response, with no leftover 1y entry.
3258        let frontend = Frontend {
3259            cluster_id: Some("api".into()),
3260            redirect: RedirectPolicy::Forward,
3261            redirect_scheme: RedirectScheme::UseSame,
3262            redirect_template: None,
3263            capture_cap_host: 0,
3264            capture_cap_path: 0,
3265            rewrite_host: None,
3266            rewrite_path: None,
3267            rewrite_port: None,
3268            headers_request: Rc::new([]),
3269            headers_response: Rc::from(vec![
3270                HeaderEdit {
3271                    key: Rc::from(&b"x-cache"[..]),
3272                    val: Rc::from(&b"hit"[..]),
3273                    mode: HeaderEditMode::Append,
3274                },
3275                HeaderEdit {
3276                    key: Rc::from(&b"strict-transport-security"[..]),
3277                    val: Rc::from(&b"max-age=31536000"[..]),
3278                    mode: HeaderEditMode::SetIfAbsent,
3279                },
3280            ]),
3281            required_auth: false,
3282            tags: None,
3283            inherits_listener_hsts: true,
3284        };
3285        let new_hsts = HstsConfig {
3286            enabled: Some(true),
3287            max_age: Some(63_072_000),
3288            include_subdomains: Some(true),
3289            preload: None,
3290            force_replace_backend: None,
3291        };
3292        let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3293        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3294
3295        let response: Vec<_> = rebuilt.headers_response.iter().collect();
3296        assert_eq!(response.len(), 2, "x-cache + new STS, no leftover STS");
3297        assert_eq!(&*response[0].key, b"x-cache");
3298        assert_eq!(&*response[1].key, b"strict-transport-security");
3299        assert_eq!(
3300            &*response[1].val,
3301            b"max-age=63072000; includeSubDomains".as_slice()
3302        );
3303        assert!(rebuilt.inherits_listener_hsts);
3304    }
3305
3306    #[test]
3307    fn rebuild_with_listener_hsts_strips_when_none() {
3308        // Listener-default HSTS removed → strip the existing STS edit
3309        // and add nothing. Operator response headers stay in place.
3310        let frontend = Frontend {
3311            cluster_id: Some("api".into()),
3312            redirect: RedirectPolicy::Forward,
3313            redirect_scheme: RedirectScheme::UseSame,
3314            redirect_template: None,
3315            capture_cap_host: 0,
3316            capture_cap_path: 0,
3317            rewrite_host: None,
3318            rewrite_path: None,
3319            rewrite_port: None,
3320            headers_request: Rc::new([]),
3321            headers_response: Rc::from(vec![
3322                HeaderEdit {
3323                    key: Rc::from(&b"x-cache"[..]),
3324                    val: Rc::from(&b"hit"[..]),
3325                    mode: HeaderEditMode::Append,
3326                },
3327                HeaderEdit {
3328                    key: Rc::from(&b"strict-transport-security"[..]),
3329                    val: Rc::from(&b"max-age=31536000"[..]),
3330                    mode: HeaderEditMode::SetIfAbsent,
3331                },
3332            ]),
3333            required_auth: false,
3334            tags: None,
3335            inherits_listener_hsts: true,
3336        };
3337        let new_edit = build_listener_hsts_edit(None);
3338        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3339        let response: Vec<_> = rebuilt.headers_response.iter().collect();
3340        assert_eq!(response.len(), 1);
3341        assert_eq!(&*response[0].key, b"x-cache");
3342    }
3343
3344    #[test]
3345    fn rebuild_with_listener_hsts_disabled_strips() {
3346        // `enabled = Some(false)` is the explicit-disable signal; the
3347        // existing STS entry is dropped and no new one is added.
3348        let frontend = Frontend {
3349            cluster_id: Some("api".into()),
3350            redirect: RedirectPolicy::Forward,
3351            redirect_scheme: RedirectScheme::UseSame,
3352            redirect_template: None,
3353            capture_cap_host: 0,
3354            capture_cap_path: 0,
3355            rewrite_host: None,
3356            rewrite_path: None,
3357            rewrite_port: None,
3358            headers_request: Rc::new([]),
3359            headers_response: Rc::from(vec![HeaderEdit {
3360                key: Rc::from(&b"strict-transport-security"[..]),
3361                val: Rc::from(&b"max-age=31536000"[..]),
3362                mode: HeaderEditMode::SetIfAbsent,
3363            }]),
3364            required_auth: false,
3365            tags: None,
3366            inherits_listener_hsts: true,
3367        };
3368        let new_hsts = HstsConfig {
3369            enabled: Some(false),
3370            max_age: None,
3371            include_subdomains: None,
3372            preload: None,
3373            force_replace_backend: None,
3374        };
3375        let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3376        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3377        assert_eq!(rebuilt.headers_response.len(), 0);
3378    }
3379
3380    #[test]
3381    fn refresh_inheriting_hsts_skips_explicit_overrides() {
3382        // Two frontends: one inheriting (gets refreshed), one explicit
3383        // override (must NOT change). `Router::refresh_inheriting_hsts`
3384        // returns the count of refreshed entries.
3385        use crate::router::pattern_trie::TrieNode;
3386        let mut router = Router {
3387            pre: Vec::new(),
3388            tree: TrieNode::root(),
3389            post: Vec::new(),
3390        };
3391        let inheriting = Frontend {
3392            cluster_id: Some("api".into()),
3393            redirect: RedirectPolicy::Forward,
3394            redirect_scheme: RedirectScheme::UseSame,
3395            redirect_template: None,
3396            capture_cap_host: 0,
3397            capture_cap_path: 0,
3398            rewrite_host: None,
3399            rewrite_path: None,
3400            rewrite_port: None,
3401            headers_request: Rc::new([]),
3402            headers_response: Rc::from(vec![HeaderEdit {
3403                key: Rc::from(&b"strict-transport-security"[..]),
3404                val: Rc::from(&b"max-age=31536000"[..]),
3405                mode: HeaderEditMode::SetIfAbsent,
3406            }]),
3407            required_auth: false,
3408            tags: None,
3409            inherits_listener_hsts: true,
3410        };
3411        let explicit = Frontend {
3412            cluster_id: Some("legacy".into()),
3413            redirect: RedirectPolicy::Forward,
3414            redirect_scheme: RedirectScheme::UseSame,
3415            redirect_template: None,
3416            capture_cap_host: 0,
3417            capture_cap_path: 0,
3418            rewrite_host: None,
3419            rewrite_path: None,
3420            rewrite_port: None,
3421            headers_request: Rc::new([]),
3422            headers_response: Rc::from(vec![HeaderEdit {
3423                key: Rc::from(&b"strict-transport-security"[..]),
3424                val: Rc::from(&b"max-age=300"[..]),
3425                mode: HeaderEditMode::SetIfAbsent,
3426            }]),
3427            required_auth: false,
3428            tags: None,
3429            inherits_listener_hsts: false,
3430        };
3431        router.pre.push((
3432            DomainRule::Any,
3433            PathRule::Prefix("/api".to_owned()),
3434            MethodRule::new(None),
3435            HeaderRules::default(),
3436            Route::Frontend(Rc::new(inheriting)),
3437        ));
3438        router.post.push((
3439            DomainRule::Any,
3440            PathRule::Prefix("/legacy".to_owned()),
3441            MethodRule::new(None),
3442            HeaderRules::default(),
3443            Route::Frontend(Rc::new(explicit)),
3444        ));
3445
3446        let new_hsts = HstsConfig {
3447            enabled: Some(true),
3448            max_age: Some(63_072_000),
3449            include_subdomains: Some(true),
3450            preload: None,
3451            force_replace_backend: None,
3452        };
3453        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3454        assert_eq!(count, 1, "only the inheriting frontend should refresh");
3455
3456        if let Route::Frontend(rc) = &router.pre[0].4 {
3457            let response: Vec<_> = rc.headers_response.iter().collect();
3458            assert_eq!(
3459                &*response.last().unwrap().val,
3460                b"max-age=63072000; includeSubDomains".as_slice(),
3461                "inheriting frontend's STS must reflect the new listener default"
3462            );
3463        } else {
3464            panic!("pre[0] should be Route::Frontend");
3465        }
3466        if let Route::Frontend(rc) = &router.post[0].4 {
3467            let response: Vec<_> = rc.headers_response.iter().collect();
3468            assert_eq!(
3469                &*response.last().unwrap().val,
3470                b"max-age=300".as_slice(),
3471                "explicit override must be preserved unchanged"
3472            );
3473        } else {
3474            panic!("post[0] should be Route::Frontend");
3475        }
3476    }
3477
3478    #[test]
3479    fn refresh_inheriting_hsts_promotes_clusterid_on_enable() {
3480        // The "no policy" frontend case observed on cleverapps.io shared
3481        // (91k+ frontends, 99 % stored as `Route::ClusterId` with no
3482        // policy fields). Before the fix, `refresh_inheriting_hsts`
3483        // walked only `Route::Frontend` entries and silently skipped
3484        // these — leaving HSTS unapplied across the entire fleet.
3485        // Now the lightweight route is promoted in place to a
3486        // `Route::Frontend` carrying just the HSTS edit; subsequent
3487        // patches refresh the promoted entry through the normal
3488        // `inherits_listener_hsts == true` path.
3489        use crate::router::pattern_trie::TrieNode;
3490        let mut router = Router {
3491            pre: Vec::new(),
3492            tree: TrieNode::root(),
3493            post: vec![(
3494                DomainRule::Any,
3495                PathRule::Prefix("/".to_owned()),
3496                MethodRule::new(None),
3497                HeaderRules::default(),
3498                Route::ClusterId("api".into()),
3499            )],
3500        };
3501
3502        let new_hsts = HstsConfig {
3503            enabled: Some(true),
3504            max_age: Some(31_536_000),
3505            include_subdomains: Some(true),
3506            preload: None,
3507            force_replace_backend: None,
3508        };
3509        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3510        assert_eq!(count, 1, "the ClusterId entry must be promoted + counted");
3511
3512        let Route::Frontend(rc) = &router.post[0].4 else {
3513            panic!("post[0] should now be Route::Frontend, not the original Route::ClusterId");
3514        };
3515        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3516        assert_eq!(
3517            rc.redirect,
3518            RedirectPolicy::Forward,
3519            "promoted entry must keep Forward semantics so lookup yields the same backend"
3520        );
3521        assert!(
3522            rc.inherits_listener_hsts,
3523            "promoted entry must mark itself inheriting so the next patch refreshes it"
3524        );
3525        let response: Vec<_> = rc.headers_response.iter().collect();
3526        assert_eq!(
3527            response.len(),
3528            1,
3529            "promoted entry carries exactly one STS edit, no operator headers"
3530        );
3531        assert_eq!(&*response[0].key, b"strict-transport-security");
3532        assert_eq!(
3533            &*response[0].val,
3534            b"max-age=31536000; includeSubDomains".as_slice()
3535        );
3536    }
3537
3538    #[test]
3539    fn refresh_inheriting_hsts_promotes_deny_on_enable() {
3540        // RFC 6797 §8.1: HSTS applies to ALL HTTPS responses, including
3541        // proxy-generated 401s. A `Route::Deny` with no policy field at
3542        // add-time would, before the fix, never get HSTS injected onto
3543        // the 401 default answer even when the listener default
3544        // declared HSTS.
3545        use crate::router::pattern_trie::TrieNode;
3546        let mut router = Router {
3547            pre: Vec::new(),
3548            tree: TrieNode::root(),
3549            post: vec![(
3550                DomainRule::Any,
3551                PathRule::Prefix("/forbidden".to_owned()),
3552                MethodRule::new(None),
3553                HeaderRules::default(),
3554                Route::Deny,
3555            )],
3556        };
3557
3558        let new_hsts = HstsConfig {
3559            enabled: Some(true),
3560            max_age: Some(31_536_000),
3561            include_subdomains: None,
3562            preload: None,
3563            force_replace_backend: None,
3564        };
3565        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3566        assert_eq!(count, 1);
3567
3568        let Route::Frontend(rc) = &router.post[0].4 else {
3569            panic!("post[0] should now be Route::Frontend, not the original Route::Deny");
3570        };
3571        assert_eq!(rc.cluster_id, None, "promoted Deny stays clusterless");
3572        assert_eq!(
3573            rc.redirect,
3574            RedirectPolicy::Unauthorized,
3575            "promoted Deny must keep Unauthorized so lookup yields a 401"
3576        );
3577        assert!(rc.inherits_listener_hsts);
3578        let response: Vec<_> = rc.headers_response.iter().collect();
3579        assert_eq!(response.len(), 1);
3580        assert_eq!(&*response[0].key, b"strict-transport-security");
3581        assert_eq!(&*response[0].val, b"max-age=31536000".as_slice());
3582    }
3583
3584    #[test]
3585    fn refresh_inheriting_hsts_skips_lightweight_on_disable() {
3586        // No HSTS to emit → no allocation of a Route::Frontend just to
3587        // hold an empty headers_response. The lightweight route is
3588        // preserved as-is. Three sub-cases cover the disable surface:
3589        //   - new_hsts == None (operator omitted the field — preserve current… but
3590        //     this function is called only when the patch DID carry a value, so
3591        //     None here represents "block was not present"; lightweight stays).
3592        //   - new_hsts == Some(enabled = Some(false)) (explicit kill switch).
3593        //   - new_hsts == Some(enabled = Some(true), max_age = None) (malformed
3594        //     enable — render_hsts returns None, defense-in-depth gate).
3595        use crate::router::pattern_trie::TrieNode;
3596        let make_router = || Router {
3597            pre: vec![(
3598                DomainRule::Any,
3599                PathRule::Prefix("/".to_owned()),
3600                MethodRule::new(None),
3601                HeaderRules::default(),
3602                Route::ClusterId("api".into()),
3603            )],
3604            tree: TrieNode::root(),
3605            post: vec![(
3606                DomainRule::Any,
3607                PathRule::Prefix("/forbidden".to_owned()),
3608                MethodRule::new(None),
3609                HeaderRules::default(),
3610                Route::Deny,
3611            )],
3612        };
3613
3614        for (label, hsts) in [
3615            ("none", None),
3616            (
3617                "disabled",
3618                Some(HstsConfig {
3619                    enabled: Some(false),
3620                    max_age: None,
3621                    include_subdomains: None,
3622                    preload: None,
3623                    force_replace_backend: None,
3624                }),
3625            ),
3626            (
3627                "enabled-without-max-age",
3628                Some(HstsConfig {
3629                    enabled: Some(true),
3630                    max_age: None,
3631                    include_subdomains: None,
3632                    preload: None,
3633                    force_replace_backend: None,
3634                }),
3635            ),
3636        ] {
3637            let mut router = make_router();
3638            let count = router.refresh_inheriting_hsts(hsts.as_ref());
3639            assert_eq!(count, 0, "no promotion expected for {label}");
3640            assert!(
3641                matches!(router.pre[0].4, Route::ClusterId(_)),
3642                "{label}: ClusterId must stay lightweight"
3643            );
3644            assert!(
3645                matches!(router.post[0].4, Route::Deny),
3646                "{label}: Deny must stay lightweight"
3647            );
3648        }
3649    }
3650
3651    #[test]
3652    fn refresh_inheriting_hsts_promoted_entry_refreshes_on_subsequent_patches() {
3653        // First patch promotes ClusterId → Route::Frontend with HSTS.
3654        // Second patch with a different max-age must refresh the
3655        // promoted entry through the normal path-1 branch (no double
3656        // STS edit, no second promotion of an already-promoted entry).
3657        use crate::router::pattern_trie::TrieNode;
3658        let mut router = Router {
3659            pre: Vec::new(),
3660            tree: TrieNode::root(),
3661            post: vec![(
3662                DomainRule::Any,
3663                PathRule::Prefix("/".to_owned()),
3664                MethodRule::new(None),
3665                HeaderRules::default(),
3666                Route::ClusterId("api".into()),
3667            )],
3668        };
3669
3670        let first_patch = HstsConfig {
3671            enabled: Some(true),
3672            max_age: Some(31_536_000),
3673            include_subdomains: None,
3674            preload: None,
3675            force_replace_backend: None,
3676        };
3677        assert_eq!(router.refresh_inheriting_hsts(Some(&first_patch)), 1);
3678
3679        let second_patch = HstsConfig {
3680            enabled: Some(true),
3681            max_age: Some(63_072_000),
3682            include_subdomains: Some(true),
3683            preload: None,
3684            force_replace_backend: None,
3685        };
3686        assert_eq!(
3687            router.refresh_inheriting_hsts(Some(&second_patch)),
3688            1,
3689            "the previously promoted entry must be re-counted via the path-1 branch"
3690        );
3691
3692        let Route::Frontend(rc) = &router.post[0].4 else {
3693            panic!("post[0] should still be Route::Frontend after the second patch");
3694        };
3695        let response: Vec<_> = rc.headers_response.iter().collect();
3696        assert_eq!(
3697            response.len(),
3698            1,
3699            "second patch must REPLACE the existing STS edit, not append a duplicate"
3700        );
3701        assert_eq!(
3702            &*response[0].val,
3703            b"max-age=63072000; includeSubDomains".as_slice()
3704        );
3705    }
3706
3707    #[test]
3708    fn refresh_inheriting_hsts_promoted_entry_loses_hsts_on_disable_patch() {
3709        // After a promotion, a disable patch must strip the STS edit
3710        // through the path-1 branch. The Route::Frontend wrapper stays
3711        // (we don't demote back to Route::ClusterId — the small per-
3712        // request overhead of running through `from_frontend` instead
3713        // of the short-circuit is acceptable, and demotion would
3714        // require carrying additional state).
3715        use crate::router::pattern_trie::TrieNode;
3716        let mut router = Router {
3717            pre: vec![(
3718                DomainRule::Any,
3719                PathRule::Prefix("/".to_owned()),
3720                MethodRule::new(None),
3721                HeaderRules::default(),
3722                Route::ClusterId("api".into()),
3723            )],
3724            tree: TrieNode::root(),
3725            post: Vec::new(),
3726        };
3727
3728        let enable = HstsConfig {
3729            enabled: Some(true),
3730            max_age: Some(31_536_000),
3731            include_subdomains: None,
3732            preload: None,
3733            force_replace_backend: None,
3734        };
3735        assert_eq!(router.refresh_inheriting_hsts(Some(&enable)), 1);
3736
3737        let disable = HstsConfig {
3738            enabled: Some(false),
3739            max_age: None,
3740            include_subdomains: None,
3741            preload: None,
3742            force_replace_backend: None,
3743        };
3744        assert_eq!(
3745            router.refresh_inheriting_hsts(Some(&disable)),
3746            1,
3747            "the promoted entry must still be touched on disable to strip its STS edit"
3748        );
3749
3750        let Route::Frontend(rc) = &router.pre[0].4 else {
3751            panic!("pre[0] should still be Route::Frontend (no demotion)");
3752        };
3753        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3754        assert_eq!(
3755            rc.headers_response.len(),
3756            0,
3757            "disable patch must strip the STS edit from the promoted entry"
3758        );
3759    }
3760
3761    #[test]
3762    fn refresh_inheriting_hsts_promotes_clusterid_in_trie_on_enable() {
3763        // Trie-leaf coverage for path 2: the existing five tests
3764        // exercise the `pre`/`post` Vecs only, but the same `visit`
3765        // closure runs for tree leaves through
3766        // `tree.for_each_value_mut`. Assert that a `Route::ClusterId`
3767        // sitting in the trie is promoted in place on an enable
3768        // patch, with routing semantics preserved.
3769        use crate::router::pattern_trie::TrieNode;
3770        let mut router = Router {
3771            pre: Vec::new(),
3772            tree: TrieNode::root(),
3773            post: Vec::new(),
3774        };
3775        let path_rule = PathRule::Prefix("/".to_owned());
3776        let method_rule = MethodRule::new(None);
3777        assert!(router.add_tree_rule(
3778            b"example.com",
3779            &path_rule,
3780            &method_rule,
3781            &Route::ClusterId("api".into()),
3782        ));
3783
3784        let new_hsts = HstsConfig {
3785            enabled: Some(true),
3786            max_age: Some(31_536_000),
3787            include_subdomains: Some(true),
3788            preload: None,
3789            force_replace_backend: None,
3790        };
3791        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3792        assert_eq!(
3793            count, 1,
3794            "trie-resident ClusterId must be promoted + counted"
3795        );
3796
3797        let (_, paths) = router
3798            .tree
3799            .domain_lookup_mut(b"example.com", false)
3800            .expect("trie leaf still present after refresh");
3801        assert_eq!(paths.len(), 1);
3802        let Route::Frontend(rc) = &paths[0].3 else {
3803            panic!("trie leaf should now be Route::Frontend, not Route::ClusterId");
3804        };
3805        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3806        assert_eq!(rc.redirect, RedirectPolicy::Forward);
3807        assert!(rc.inherits_listener_hsts);
3808        let response: Vec<_> = rc.headers_response.iter().collect();
3809        assert_eq!(response.len(), 1);
3810        assert_eq!(&*response[0].key, b"strict-transport-security");
3811        assert_eq!(
3812            &*response[0].val,
3813            b"max-age=31536000; includeSubDomains".as_slice()
3814        );
3815    }
3816
3817    #[test]
3818    fn convert_regex() {
3819        // Compiled regexes are anchored with `\A` … `\z` so `Regex::is_match`
3820        // (unanchored by default) only succeeds on a full-host match, and each
3821        // slash-delimited regex segment is wrapped in a non-capturing group so
3822        // an alternation inside it cannot split those anchors between its
3823        // branches (sozu#1356). A literal segment is not a pattern and is not
3824        // grouped.
3825        assert_eq!(
3826            convert_regex_domain_rule("www.example.com")
3827                .unwrap()
3828                .as_str(),
3829            "\\Awww\\.example\\.com\\z"
3830        );
3831        // A `*` label reaches this function only when the hostname ALSO
3832        // carries a `/` segment, because `DomainRule::from_str` tests
3833        // `contains('/')` first — these two rows exercise the function
3834        // directly. `*` is a LITERAL label here, not a wildcard, and is
3835        // escaped as one. Unescaped it was a regex quantifier over the
3836        // preceding atom: `\A*` repeats a zero-width assertion, so it
3837        // matched empty at any offset and left the whole pattern unanchored
3838        // at its start.
3839        assert_eq!(
3840            convert_regex_domain_rule("*.example.com").unwrap().as_str(),
3841            "\\A\\*\\.example\\.com\\z"
3842        );
3843        assert_eq!(
3844            convert_regex_domain_rule("test.*.example.com")
3845                .unwrap()
3846                .as_str(),
3847            "\\Atest\\.\\*\\.example\\.com\\z"
3848        );
3849        assert_eq!(
3850            convert_regex_domain_rule("css./cdn[a-z0-9]+/.example.com")
3851                .unwrap()
3852                .as_str(),
3853            "\\Acss\\.(?:cdn[a-z0-9]+)\\.example\\.com\\z"
3854        );
3855
3856        assert_eq!(
3857            convert_regex_domain_rule("css./cdn[a-z0-9]+.example.com"),
3858            None
3859        );
3860        assert_eq!(
3861            convert_regex_domain_rule("css./cdn[a-z0-9]+/a.example.com"),
3862            None
3863        );
3864    }
3865
3866    /// Compiled regex rules must reject suffix / prefix matches. Without
3867    /// `\A` / `\z` anchors, `Regex::is_match` treats the pattern as
3868    /// "match anywhere in the haystack", letting `attacker.example.com.evil.org`
3869    /// reach a frontend that only intends to serve `example.com`.
3870    #[test]
3871    fn regex_domain_rule_rejects_suffix_and_prefix() {
3872        let rule: DomainRule = "/example\\.com/".parse().unwrap();
3873        assert!(rule.matches(b"example.com"));
3874        assert!(!rule.matches(b"attacker.example.com"));
3875        assert!(!rule.matches(b"example.com.evil.org"));
3876        assert!(!rule.matches(b"prefixexample.com"));
3877        assert!(!rule.matches(b"example.commercial"));
3878    }
3879
3880    /// A multi-segment regex hostname (alternating regex and literal
3881    /// subdomains) must keep each segment confined: only the first `/`
3882    /// after the opening delimiter closes a regex segment. A missing
3883    /// `break` collapsed every later `/` into the same segment, swallowing
3884    /// the literal `.` separators between them.
3885    #[test]
3886    fn regex_domain_rule_multi_segment_segments_are_isolated() {
3887        let pattern = convert_regex_domain_rule("/seg1/.foo./seg2/.com")
3888            .expect("multi-segment regex hostname must compile");
3889        assert_eq!(pattern.as_str(), "\\A(?:seg1)\\.foo\\.(?:seg2)\\.com\\z");
3890    }
3891
3892    #[test]
3893    fn parse_domain_rule() {
3894        assert_eq!("*".parse::<DomainRule>().unwrap(), DomainRule::Any);
3895        assert_eq!(
3896            "www.example.com".parse::<DomainRule>().unwrap(),
3897            DomainRule::Exact("www.example.com".to_string())
3898        );
3899        assert_eq!(
3900            "*.example.com".parse::<DomainRule>().unwrap(),
3901            DomainRule::Wildcard("*.example.com".to_string())
3902        );
3903        assert_eq!("test.*.example.com".parse::<DomainRule>(), Err(()));
3904        assert_eq!(
3905            "/cdn[0-9]+/.example.com".parse::<DomainRule>().unwrap(),
3906            DomainRule::Regex(Regex::new("\\A(?:cdn[0-9]+)\\.example\\.com\\z").unwrap())
3907        );
3908    }
3909
3910    #[test]
3911    fn match_domain_rule() {
3912        assert!(DomainRule::Any.matches("www.example.com".as_bytes()));
3913        assert!(
3914            DomainRule::Exact("www.example.com".to_string()).matches("www.example.com".as_bytes())
3915        );
3916        assert!(
3917            DomainRule::Wildcard("*.example.com".to_string()).matches("www.example.com".as_bytes())
3918        );
3919        assert!(
3920            !DomainRule::Wildcard("*.example.com".to_string())
3921                .matches("test.www.example.com".as_bytes())
3922        );
3923        assert!(
3924            "/cdn[0-9]+/.example.com"
3925                .parse::<DomainRule>()
3926                .unwrap()
3927                .matches("cdn1.example.com".as_bytes())
3928        );
3929        assert!(
3930            !"/cdn[0-9]+/.example.com"
3931                .parse::<DomainRule>()
3932                .unwrap()
3933                .matches("www.example.com".as_bytes())
3934        );
3935        assert!(
3936            !"/cdn[0-9]+/.example.com"
3937                .parse::<DomainRule>()
3938                .unwrap()
3939                .matches("cdn10.exampleAcom".as_bytes())
3940        );
3941    }
3942
3943    #[test]
3944    fn match_domain_rule_wildcard_short_hostname_does_not_panic() {
3945        let rule = DomainRule::Wildcard("*.foo.example.com".to_string());
3946
3947        // Regression for issue #1223: an empty hostname must not panic and must not match.
3948        assert!(!rule.matches(b""));
3949
3950        // Hostname strictly shorter than the suffix must not panic and must not match.
3951        assert!(!rule.matches(b"a.b"));
3952        assert!(!rule.matches(b"x"));
3953
3954        // Boundary: hostname equal to the suffix (s without the leading '*') has an empty
3955        // leftmost label. RFC 1035 §3.1 forbids empty labels, so reject.
3956        assert!(!rule.matches(b".foo.example.com"));
3957
3958        // Multi-label leftmost prefix (existing intent — single-label only).
3959        assert!(!rule.matches(b"y.x.foo.example.com"));
3960
3961        // Single-label leftmost prefix — must still match (this is the happy case the
3962        // pre-existing match_domain_rule already covers, repeated here for symmetry).
3963        assert!(rule.matches(b"x.foo.example.com"));
3964    }
3965
3966    #[test]
3967    fn router_lookup_wildcard_pre_rule_short_hostname_does_not_panic() {
3968        let mut router = Router::new();
3969
3970        // Wildcard in a pre-rule routes through DomainRule::Wildcard::matches
3971        // (not the trie). This is the path that panicked on issue #1223.
3972        assert!(router.add_pre_rule(
3973            &"*.foo.example.com".parse::<DomainRule>().unwrap(),
3974            &PathRule::Prefix("/".to_string()),
3975            &MethodRule::new(Some("GET".to_string())),
3976            &Route::ClusterId("wildcard".into()),
3977        ));
3978
3979        let method = Method::new(&b"GET"[..]);
3980
3981        // Issue #1223: short hostnames must not panic Router::lookup.
3982        assert!(router.lookup("", "/", &method).is_err());
3983        assert!(router.lookup("x", "/", &method).is_err());
3984        assert!(router.lookup("a.b", "/", &method).is_err());
3985
3986        // Boundary: hostname equal to the suffix has an empty leftmost label.
3987        assert!(router.lookup(".foo.example.com", "/", &method).is_err());
3988
3989        // Happy case: single-label leftmost matches via the pre-rule path.
3990        assert_eq!(
3991            router.lookup("x.foo.example.com", "/", &method),
3992            Ok(RouteResult::forward("wildcard".into()))
3993        );
3994    }
3995
3996    #[test]
3997    fn match_path_rule() {
3998        assert!(PathRule::Prefix("".to_string()).matches("/".as_bytes()) != PathRuleResult::None);
3999        assert!(
4000            PathRule::Prefix("".to_string()).matches("/hello".as_bytes()) != PathRuleResult::None
4001        );
4002        assert!(
4003            PathRule::Prefix("/hello".to_string()).matches("/hello".as_bytes())
4004                != PathRuleResult::None
4005        );
4006        assert!(
4007            PathRule::Prefix("/hello".to_string()).matches("/hello/world".as_bytes())
4008                != PathRuleResult::None
4009        );
4010        assert!(
4011            PathRule::Prefix("/hello".to_string()).matches("/".as_bytes()) == PathRuleResult::None
4012        );
4013    }
4014
4015    ///  [io]
4016    ///      \
4017    ///       [sozu]
4018    ///             \
4019    ///              [*]  <- this wildcard has multiple children
4020    ///             /   \
4021    ///         (base) (api)
4022    #[test]
4023    fn multiple_children_on_a_wildcard() {
4024        let mut router = Router::new();
4025
4026        assert!(router.add_tree_rule(
4027            b"*.sozu.io",
4028            &PathRule::Prefix("".to_string()),
4029            &MethodRule::new(Some("GET".to_string())),
4030            &Route::ClusterId("base".into())
4031        ));
4032        println!("{:#?}", router.tree);
4033        assert_eq!(
4034            router.lookup("www.sozu.io", "/api", &Method::Get),
4035            Ok(RouteResult::forward("base".into()))
4036        );
4037        assert!(router.add_tree_rule(
4038            b"*.sozu.io",
4039            &PathRule::Prefix("/api".to_string()),
4040            &MethodRule::new(Some("GET".to_string())),
4041            &Route::ClusterId("api".into())
4042        ));
4043        println!("{:#?}", router.tree);
4044        assert_eq!(
4045            router.lookup("www.sozu.io", "/ap", &Method::Get),
4046            Ok(RouteResult::forward("base".into()))
4047        );
4048        assert_eq!(
4049            router.lookup("www.sozu.io", "/api", &Method::Get),
4050            Ok(RouteResult::forward("api".into()))
4051        );
4052    }
4053
4054    ///  [io]
4055    ///      \
4056    ///       [sozu]  <- this node has multiple children including a wildcard
4057    ///      /      \
4058    ///   (api)      [*]  <- this wildcard has multiple children
4059    ///                 \
4060    ///                (base)
4061    #[test]
4062    fn multiple_children_including_one_with_wildcard() {
4063        let mut router = Router::new();
4064
4065        assert!(router.add_tree_rule(
4066            b"*.sozu.io",
4067            &PathRule::Prefix("".to_string()),
4068            &MethodRule::new(Some("GET".to_string())),
4069            &Route::ClusterId("base".into())
4070        ));
4071        println!("{:#?}", router.tree);
4072        assert_eq!(
4073            router.lookup("www.sozu.io", "/api", &Method::Get),
4074            Ok(RouteResult::forward("base".into()))
4075        );
4076        assert!(router.add_tree_rule(
4077            b"api.sozu.io",
4078            &PathRule::Prefix("".to_string()),
4079            &MethodRule::new(Some("GET".to_string())),
4080            &Route::ClusterId("api".into())
4081        ));
4082        println!("{:#?}", router.tree);
4083        assert_eq!(
4084            router.lookup("www.sozu.io", "/api", &Method::Get),
4085            Ok(RouteResult::forward("base".into()))
4086        );
4087        assert_eq!(
4088            router.lookup("api.sozu.io", "/api", &Method::Get),
4089            Ok(RouteResult::forward("api".into()))
4090        );
4091    }
4092
4093    /// A malformed hostname arriving from the control plane
4094    /// (`AddHttpFrontend` over the command socket, or a `LoadState`
4095    /// replay) must be REJECTED, never panic the worker. `TrieNode::insert`
4096    /// used to `assert_ne!` on `InsertResult::Failed`, so a frontend whose
4097    /// hostname ended in `/` killed every worker the master fanned the
4098    /// request out to -- and killed them again on each restart replay.
4099    #[test]
4100    fn add_tree_rule_rejects_malformed_hostnames_without_panicking() {
4101        // Every shape here reached `InsertResult::Failed` (or an empty
4102        // `partial_key`) inside `insert_recursive`.
4103        for hostname in [
4104            &b"example.com/"[..],
4105            b"www.example.com/",
4106            b"foo/",
4107            b"a/*/",
4108            b"/",
4109            b"///",
4110            b"abc/[0-9]+/.example.com",
4111            b"/[/.example.com",
4112            // Named as rejected in `doc/configure.md`'s regex-hostname
4113            // section: a leading `.` before a regex segment is an empty label.
4114            b"./test[0-9]/.example.com",
4115            b".example.com",
4116            b".a.b",
4117            b"..",
4118        ] {
4119            let mut router = Router::new();
4120            assert!(
4121                !router.add_tree_rule(
4122                    hostname,
4123                    &PathRule::Prefix("/".to_string()),
4124                    &MethodRule::new(Some("GET".to_string())),
4125                    &Route::ClusterId("cluster".into()),
4126                ),
4127                "{:?} must be rejected, not inserted",
4128                String::from_utf8_lossy(hostname),
4129            );
4130            // A rejected add must leave NO trace: the route table is
4131            // exactly as empty as it was before the attempt.
4132            assert!(
4133                router.tree.is_empty(),
4134                "{:?} was rejected but still mutated the route table",
4135                String::from_utf8_lossy(hostname),
4136            );
4137        }
4138    }
4139
4140    /// The same rejection, one layer up: `add_http_front` must surface a
4141    /// `RouterError::AddRoute` so the worker answers the master with a
4142    /// failure instead of dying.
4143    #[test]
4144    fn add_http_front_surfaces_a_malformed_hostname_as_an_error() {
4145        let mut router = Router::new();
4146        let front = HttpFrontend {
4147            hostname: "example.com/".to_owned(),
4148            ..test_http_frontend()
4149        };
4150        assert!(matches!(
4151            router.add_http_front(&front),
4152            Err(RouterError::AddRoute(_))
4153        ));
4154        assert!(router.tree.is_empty());
4155
4156        // A hostname whose last segment is followed by a bare trailing `.`
4157        // is rejected one layer earlier still, by the unconditional
4158        // `DomainRule` parse -- which used to walk out of bounds on it
4159        // (see `domain_rule_rejects_a_trailing_dot_after_a_regex_segment`).
4160        for hostname in ["/a/.", "a/b/.", "x./y/."] {
4161            let mut router = Router::new();
4162            let front = HttpFrontend {
4163                hostname: (*hostname).to_owned(),
4164                ..test_http_frontend()
4165            };
4166            assert!(
4167                matches!(
4168                    router.add_http_front(&front),
4169                    Err(RouterError::InvalidDomain { .. })
4170                ),
4171                "{hostname:?} must be rejected as an invalid domain, not panic",
4172            );
4173            assert!(router.tree.is_empty());
4174        }
4175    }
4176
4177    /// `convert_regex_domain_rule` used to walk one byte past the end of a
4178    /// hostname whose last segment is followed by a bare trailing `.`
4179    /// (`/a/.`, `a/b/.`, `x./y/.`): the `.` arm of the loop tail advances
4180    /// `index` to `s.len()` and the next iteration indexed `s[index]` out
4181    /// of bounds -- a release panic (slice bounds checks never compile
4182    /// out) reachable from the control plane through the unconditional
4183    /// `DomainRule` parse in `add_http_front`, sidestepping every trie
4184    /// guard.
4185    #[test]
4186    fn domain_rule_rejects_a_trailing_dot_after_a_regex_segment() {
4187        for hostname in ["/a/.", "a/b/.", "/[/.", "x./y/."] {
4188            assert!(
4189                hostname.parse::<DomainRule>().is_err(),
4190                "{hostname:?} must be rejected, not panic",
4191            );
4192        }
4193        // The guard must not disturb the legitimate `.`-anchored
4194        // regex-segment grammar.
4195        assert!("abc./[0-9]+/.example.com".parse::<DomainRule>().is_ok());
4196    }
4197
4198    /// The trie recurses once per label and the regex-segment grammar
4199    /// compiles control-plane-supplied patterns, so both add and remove
4200    /// bound the hostname to [`MAX_HOSTNAME_LENGTH`] before parsing
4201    /// anything: a ~100k-label hostname otherwise aborts the worker with
4202    /// an uncatchable stack overflow, and regex compilation time grows
4203    /// with the pattern (a 2 MiB hostname stalls the single-threaded
4204    /// worker for hundreds of milliseconds before rejection).
4205    #[test]
4206    fn add_http_front_rejects_an_oversized_hostname() {
4207        // At the bound: accepted.
4208        let mut router = Router::new();
4209        let front = HttpFrontend {
4210            hostname: "a".repeat(MAX_HOSTNAME_LENGTH),
4211            ..test_http_frontend()
4212        };
4213        assert!(router.add_http_front(&front).is_ok());
4214
4215        // One byte over: rejected on add AND on remove, before any parse.
4216        let mut router = Router::new();
4217        let front = HttpFrontend {
4218            hostname: "a".repeat(MAX_HOSTNAME_LENGTH + 1),
4219            ..test_http_frontend()
4220        };
4221        assert!(matches!(
4222            router.add_http_front(&front),
4223            Err(RouterError::InvalidDomain { .. })
4224        ));
4225        assert!(matches!(
4226            router.remove_http_front(&front),
4227            Err(RouterError::InvalidDomain { .. })
4228        ));
4229        assert!(router.tree.is_empty());
4230
4231        // A label-count bomb is caught by the same byte bound.
4232        let front = HttpFrontend {
4233            hostname: "a.".repeat(MAX_HOSTNAME_LENGTH),
4234            ..test_http_frontend()
4235        };
4236        assert!(matches!(
4237            router.add_http_front(&front),
4238            Err(RouterError::InvalidDomain { .. })
4239        ));
4240    }
4241
4242    #[test]
4243    fn router_insert_remove_through_regex() {
4244        let mut router = Router::new();
4245
4246        assert!(router.add_tree_rule(
4247            b"www./.*/.io",
4248            &PathRule::Prefix("".to_string()),
4249            &MethodRule::new(Some("GET".to_string())),
4250            &Route::ClusterId("base".into())
4251        ));
4252        println!("{:#?}", router.tree);
4253        assert!(router.add_tree_rule(
4254            b"www.doc./.*/.io",
4255            &PathRule::Prefix("".to_string()),
4256            &MethodRule::new(Some("GET".to_string())),
4257            &Route::ClusterId("doc".into())
4258        ));
4259        println!("{:#?}", router.tree);
4260        assert_eq!(
4261            router.lookup("www.sozu.io", "/", &Method::Get),
4262            Ok(RouteResult::forward("base".into()))
4263        );
4264        assert_eq!(
4265            router.lookup("www.doc.sozu.io", "/", &Method::Get),
4266            Ok(RouteResult::forward("doc".into()))
4267        );
4268        assert!(router.remove_tree_rule(
4269            b"www./.*/.io",
4270            &PathRule::Prefix("".to_string()),
4271            &MethodRule::new(Some("GET".to_string()))
4272        ));
4273        println!("{:#?}", router.tree);
4274        assert!(router.lookup("www.sozu.io", "/", &Method::Get).is_err());
4275        assert_eq!(
4276            router.lookup("www.doc.sozu.io", "/", &Method::Get),
4277            Ok(RouteResult::forward("doc".into()))
4278        );
4279    }
4280
4281    #[test]
4282    fn match_router() {
4283        let mut router = Router::new();
4284
4285        assert!(router.add_pre_rule(
4286            &"*".parse::<DomainRule>().unwrap(),
4287            &PathRule::Prefix("/.well-known/acme-challenge".to_string()),
4288            &MethodRule::new(Some("GET".to_string())),
4289            &Route::ClusterId("acme".into())
4290        ));
4291        assert!(router.add_tree_rule(
4292            "www.example.com".as_bytes(),
4293            &PathRule::Prefix("/".to_string()),
4294            &MethodRule::new(Some("GET".to_string())),
4295            &Route::ClusterId("example".into())
4296        ));
4297        assert!(router.add_tree_rule(
4298            "*.test.example.com".as_bytes(),
4299            &PathRule::Regex(Regex::new("/hello[A-Z]+/").unwrap()),
4300            &MethodRule::new(Some("GET".to_string())),
4301            &Route::ClusterId("examplewildcard".into())
4302        ));
4303        assert!(router.add_tree_rule(
4304            "/test[0-9]/.example.com".as_bytes(),
4305            &PathRule::Prefix("/".to_string()),
4306            &MethodRule::new(Some("GET".to_string())),
4307            &Route::ClusterId("exampleregex".into())
4308        ));
4309
4310        assert_eq!(
4311            router.lookup("www.example.com", "/helloA", &Method::new(&b"GET"[..])),
4312            Ok(RouteResult::forward("example".into()))
4313        );
4314        assert_eq!(
4315            router.lookup(
4316                "www.example.com",
4317                "/.well-known/acme-challenge",
4318                &Method::new(&b"GET"[..])
4319            ),
4320            Ok(RouteResult::forward("acme".into()))
4321        );
4322        assert!(
4323            router
4324                .lookup("www.test.example.com", "/", &Method::new(&b"GET"[..]))
4325                .is_err()
4326        );
4327        assert_eq!(
4328            router.lookup(
4329                "www.test.example.com",
4330                "/helloAB/",
4331                &Method::new(&b"GET"[..])
4332            ),
4333            Ok(RouteResult::forward("examplewildcard".into()))
4334        );
4335        assert_eq!(
4336            router.lookup("test1.example.com", "/helloAB/", &Method::new(&b"GET"[..])),
4337            Ok(RouteResult::forward("exampleregex".into()))
4338        );
4339    }
4340
4341    #[test]
4342    fn has_hostname_checks_tree_pre_and_post() {
4343        let mut router = Router::new();
4344
4345        // Empty router has no hostnames
4346        assert!(!router.has_hostname("www.example.com"));
4347
4348        // Add a tree rule
4349        assert!(router.add_tree_rule(
4350            b"www.example.com",
4351            &PathRule::Prefix("/".to_string()),
4352            &MethodRule::new(Some("GET".to_string())),
4353            &Route::ClusterId("cluster1".into())
4354        ));
4355        assert!(router.has_hostname("www.example.com"));
4356        assert!(!router.has_hostname("api.example.com"));
4357
4358        // Remove the tree rule — hostname should disappear
4359        assert!(router.remove_tree_rule(
4360            b"www.example.com",
4361            &PathRule::Prefix("/".to_string()),
4362            &MethodRule::new(Some("GET".to_string()))
4363        ));
4364        assert!(!router.has_hostname("www.example.com"));
4365
4366        // Add a pre rule with an exact domain
4367        assert!(router.add_pre_rule(
4368            &DomainRule::Exact("api.example.com".to_string()),
4369            &PathRule::Prefix("/".to_string()),
4370            &MethodRule::new(None),
4371            &Route::ClusterId("cluster2".into())
4372        ));
4373        assert!(router.has_hostname("api.example.com"));
4374        assert!(!router.has_hostname("www.example.com"));
4375
4376        // Add a post rule
4377        assert!(router.add_post_rule(
4378            &DomainRule::Exact("cdn.example.com".to_string()),
4379            &PathRule::Prefix("/".to_string()),
4380            &MethodRule::new(None),
4381            &Route::ClusterId("cluster3".into())
4382        ));
4383        assert!(router.has_hostname("cdn.example.com"));
4384
4385        // Remove pre rule, post rule should still be detected
4386        assert!(router.remove_pre_rule(
4387            &DomainRule::Exact("api.example.com".to_string()),
4388            &PathRule::Prefix("/".to_string()),
4389            &MethodRule::new(None),
4390        ));
4391        assert!(!router.has_hostname("api.example.com"));
4392        assert!(router.has_hostname("cdn.example.com"));
4393    }
4394
4395    #[test]
4396    fn has_hostname_false_after_last_route_removed() {
4397        let mut router = Router::new();
4398
4399        // Add two routes for the same hostname with different paths
4400        assert!(router.add_tree_rule(
4401            b"www.example.com",
4402            &PathRule::Prefix("/".to_string()),
4403            &MethodRule::new(Some("GET".to_string())),
4404            &Route::ClusterId("cluster1".into())
4405        ));
4406        assert!(router.add_tree_rule(
4407            b"www.example.com",
4408            &PathRule::Prefix("/api".to_string()),
4409            &MethodRule::new(Some("GET".to_string())),
4410            &Route::ClusterId("cluster2".into())
4411        ));
4412        assert!(router.has_hostname("www.example.com"));
4413
4414        // Remove first route — hostname should still exist
4415        assert!(router.remove_tree_rule(
4416            b"www.example.com",
4417            &PathRule::Prefix("/".to_string()),
4418            &MethodRule::new(Some("GET".to_string()))
4419        ));
4420        assert!(router.has_hostname("www.example.com"));
4421
4422        // Remove second route — hostname should be gone
4423        assert!(router.remove_tree_rule(
4424            b"www.example.com",
4425            &PathRule::Prefix("/api".to_string()),
4426            &MethodRule::new(Some("GET".to_string()))
4427        ));
4428        assert!(!router.has_hostname("www.example.com"));
4429    }
4430    /// The operator-visible half of the trie's leftmost-regex insert gap.
4431    /// A frontend whose leftmost host segment is a regex
4432    /// (`/test[0-9]/.example.com`) could not be added once a DEEPER
4433    /// frontend sharing that segment (`foo./test[0-9]/.example.com`) had
4434    /// already opened it: `TrieNode::insert_recursive` answered `Existing`
4435    /// for a host it stored nowhere.
4436    ///
4437    /// The two profiles failed differently, which is why this test asserts
4438    /// the STORE rather than the return code alone. In debug the
4439    /// post-insert reachability `debug_assert!` below `domain_insert`
4440    /// killed the worker outright — "a freshly inserted tree domain must
4441    /// resolve to its inserted rule" — on a control-plane `AddHttpFrontend`
4442    /// (and again on every `LoadState` replay). In release that assert is
4443    /// compiled out, so `add_tree_rule` returned `true`, the CLI reported
4444    /// OK and `sozu query frontends` listed the route, while the trie never
4445    /// served it.
4446    ///
4447    /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4448    /// `TrieNode::insert_recursive` (`router/pattern_trie.rs`), replace
4449    /// `return t.1.insert_own_value(key, value);` with
4450    /// `return InsertResult::Existing;`. In release the `lookup` assertion
4451    /// below fails with `Err(route_not_found …)`; in debug the
4452    /// `debug_assert!` in `add_tree_rule` panics first at
4453    /// "a freshly inserted tree domain must resolve to its inserted rule"
4454    /// — comment that `debug_assert!` out and debug lands on the same
4455    /// `lookup` assertion release does.
4456    #[test]
4457    fn a_leftmost_regex_frontend_is_addable_after_a_deeper_sibling() {
4458        let path = PathRule::Prefix("/".to_string());
4459        let method = MethodRule::new(Some("GET".to_string()));
4460
4461        // Order A: deeper frontend first — the order that used to lose the
4462        // leftmost host.
4463        let mut router = Router::new();
4464        assert!(router.add_tree_rule(
4465            b"foo./test[0-9]/.example.com",
4466            &path,
4467            &method,
4468            &Route::ClusterId("deeper".into()),
4469        ));
4470        assert!(
4471            router.add_tree_rule(
4472                b"/test[0-9]/.example.com",
4473                &path,
4474                &method,
4475                &Route::ClusterId("leftmost".into()),
4476            ),
4477            "a leftmost-regex frontend must be accepted after a deeper sibling",
4478        );
4479        assert_eq!(
4480            router.lookup("test4.example.com", "/", &Method::Get),
4481            Ok(RouteResult::forward("leftmost".into())),
4482            "the leftmost-regex frontend must actually route, not just report OK",
4483        );
4484        assert_eq!(
4485            router.lookup("foo.test4.example.com", "/", &Method::Get),
4486            Ok(RouteResult::forward("deeper".into())),
4487            "the deeper frontend must still route",
4488        );
4489
4490        // Order B: leftmost frontend first — the order that already worked.
4491        let mut router = Router::new();
4492        assert!(router.add_tree_rule(
4493            b"/test[0-9]/.example.com",
4494            &path,
4495            &method,
4496            &Route::ClusterId("leftmost".into()),
4497        ));
4498        assert!(router.add_tree_rule(
4499            b"foo./test[0-9]/.example.com",
4500            &path,
4501            &method,
4502            &Route::ClusterId("deeper".into()),
4503        ));
4504        assert_eq!(
4505            router.lookup("test4.example.com", "/", &Method::Get),
4506            Ok(RouteResult::forward("leftmost".into())),
4507        );
4508        assert_eq!(
4509            router.lookup("foo.test4.example.com", "/", &Method::Get),
4510            Ok(RouteResult::forward("deeper".into())),
4511        );
4512
4513        // And removal through the public surface still takes exactly one.
4514        assert!(router.remove_tree_rule(b"/test[0-9]/.example.com", &path, &method));
4515        assert!(
4516            router
4517                .lookup("test4.example.com", "/", &Method::Get)
4518                .is_err()
4519        );
4520        assert_eq!(
4521            router.lookup("foo.test4.example.com", "/", &Method::Get),
4522            Ok(RouteResult::forward("deeper".into())),
4523        );
4524    }
4525
4526    /// `add_tree_rule` returning `true` is a claim that the route table now
4527    /// serves the rule. The trie's leftmost-regex gap broke exactly that
4528    /// contract in RELEASE builds, where the post-insert reachability
4529    /// `debug_assert!` is compiled out: the control plane reported success
4530    /// and the data plane never routed the host.
4531    ///
4532    /// This pins the contract itself, so it fails in a release build too —
4533    /// no `debug_assert` is involved in the assertion. Every shape here is
4534    /// one the trie stores through a different arm (literal, wildcard,
4535    /// leftmost regex, deeper regex, regex opened by a deeper sibling), and
4536    /// each is checked BOTH ways round: a reported success must route, and
4537    /// a reported failure must leave nothing behind.
4538    ///
4539    /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4540    /// `TrieNode::insert_recursive`, replace
4541    /// `return t.1.insert_own_value(key, value);` with
4542    /// `return InsertResult::Existing;`. The `/test[0-9]/.example.com` row
4543    /// added after `foo./test[0-9]/.example.com` then reports `true` while
4544    /// `lookup` answers `Err(route_not_found …)`, failing the
4545    /// "reported success but does not route" assertion in release. In debug
4546    /// the `debug_assert!` in `add_tree_rule` fires first with
4547    /// "a freshly inserted tree domain must resolve to its inserted rule".
4548    #[test]
4549    fn add_tree_rule_never_reports_success_for_a_rule_it_did_not_store() {
4550        let path = PathRule::Prefix("/".to_string());
4551        let method = MethodRule::new(Some("GET".to_string()));
4552
4553        // (hostname to add, hostname that must then route)
4554        let sequence: &[(&[u8], &str)] = &[
4555            (b"www.example.com", "www.example.com"),
4556            (b"*.wild.example.com", "any.wild.example.com"),
4557            (b"foo./test[0-9]/.example.com", "foo.test4.example.com"),
4558            // Opened above by its deeper sibling: the regression.
4559            (b"/test[0-9]/.example.com", "test4.example.com"),
4560            (b"bar./test[0-9]/.example.com", "bar.test7.example.com"),
4561        ];
4562
4563        let mut router = Router::new();
4564        for (index, (hostname, routable)) in sequence.iter().enumerate() {
4565            let cluster = ClusterId::from(format!("cluster{index}"));
4566            let added =
4567                router.add_tree_rule(hostname, &path, &method, &Route::ClusterId(cluster.clone()));
4568            let resolved = router.lookup(routable, "/", &Method::Get);
4569            assert!(
4570                added,
4571                "{:?} must be accepted",
4572                String::from_utf8_lossy(hostname),
4573            );
4574            assert_eq!(
4575                resolved,
4576                Ok(RouteResult::forward(cluster)),
4577                "add_tree_rule reported success for {:?} but it does not route",
4578                String::from_utf8_lossy(hostname),
4579            );
4580        }
4581
4582        // The converse half of the contract: a REJECTED add must not be
4583        // reported as stored either, and must leave no route behind.
4584        for hostname in [
4585            &b"example.com/"[..],
4586            b".example.com",
4587            b"abc/[0-9]+/.example.com",
4588        ] {
4589            let mut router = Router::new();
4590            assert!(
4591                !router.add_tree_rule(
4592                    hostname,
4593                    &path,
4594                    &method,
4595                    &Route::ClusterId("rejected".into()),
4596                ),
4597                "{:?} must be rejected",
4598                String::from_utf8_lossy(hostname),
4599            );
4600            assert!(
4601                router.tree.is_empty(),
4602                "{:?} was rejected but still mutated the route table",
4603                String::from_utf8_lossy(hostname),
4604            );
4605        }
4606    }
4607
4608    // ---- Rule-ordering contract (`doc/configure.md`, "When declaration order
4609    // decides"). The path rules of one hostname are a flat `Vec` at the trie
4610    // leaf, scanned once in declaration order: `Regex`/`Equals` RETURN on the
4611    // first match, while `Prefix` accumulates the longest. Nothing pinned any
4612    // of that, and the documented rule has been wrong three times, so each
4613    // ordering below is its own case.
4614
4615    /// Declare one tree rule for `www.example.com`. `method` is a PARAMETER,
4616    /// never a constant: whether a rule carries one decides if it ends the
4617    /// lookup scan early, so holding it fixed hides half the orderings below.
4618    fn add_path_rule(
4619        router: &mut Router,
4620        path: PathRule,
4621        method: Option<&str>,
4622        cluster: &str,
4623    ) -> bool {
4624        router.add_tree_rule(
4625            b"www.example.com",
4626            &path,
4627            &MethodRule::new(method.map(str::to_owned)),
4628            &Route::ClusterId(cluster.into()),
4629        )
4630    }
4631
4632    /// The cluster a `GET` for `path` on `www.example.com` resolves to.
4633    fn routed_cluster(router: &Router, path: &str) -> Option<String> {
4634        router
4635            .lookup("www.example.com", path, &Method::Get)
4636            .ok()
4637            .and_then(|result| result.cluster_id)
4638            .map(|cluster_id| cluster_id.to_string())
4639    }
4640
4641    /// Declare `first` then `second` — each with its own `method` — and resolve
4642    /// `path`. Declaration order is the variable under test, so callers assert
4643    /// both orders.
4644    fn winner_of(
4645        first: (PathRule, Option<&str>, &str),
4646        second: (PathRule, Option<&str>, &str),
4647        path: &str,
4648    ) -> Option<String> {
4649        let mut router = Router::new();
4650        assert!(add_path_rule(&mut router, first.0, first.1, first.2));
4651        assert!(add_path_rule(&mut router, second.0, second.1, second.2));
4652        routed_cluster(&router, path)
4653    }
4654
4655    /// `Some("GET")` — a rule whose method matches the request, so it ends the
4656    /// scan. Spelled out at every call site so the axis stays visible.
4657    const GET: Option<&str> = Some("GET");
4658    /// `None` — a rule with no method. It matches every request but does NOT
4659    /// end the scan.
4660    const NO_METHOD: Option<&str> = None;
4661    /// `Some("POST")` against a `GET` request — a method that is PRESENT and
4662    /// does NOT match, i.e. `MethodRuleResult::None`. The third value of the
4663    /// method axis: such a rule is skipped entirely, whatever its `path_type`.
4664    /// Covering only `GET` and `NO_METHOD` left every claim about this value
4665    /// untested, which is how two false sentences reached the documentation.
4666    const WRONG_METHOD: Option<&str> = Some("POST");
4667
4668    /// Among `PREFIX` path rules the LONGEST match wins, and that is the one
4669    /// genuinely order-independent case in the router.
4670    ///
4671    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4672    /// `PathRuleResult::Prefix(size) => { if size >= prefix_length {` guard
4673    /// with `if matched.is_none() {` so the FIRST matching prefix wins instead
4674    /// of the longest. The `SHORT`-first case below then resolves to `SHORT`.
4675    #[test]
4676    fn the_longest_prefix_wins_whichever_prefix_is_declared_first() {
4677        let short = || PathRule::Prefix("/a".to_owned());
4678        let long = || PathRule::Prefix("/ab".to_owned());
4679
4680        assert_eq!(
4681            winner_of((short(), GET, "SHORT"), (long(), GET, "LONG"), "/abc").as_deref(),
4682            Some("LONG"),
4683            "the longer prefix must win even when the shorter one was declared first",
4684        );
4685        assert_eq!(
4686            winner_of((long(), GET, "LONG"), (short(), GET, "SHORT"), "/abc").as_deref(),
4687            Some("LONG"),
4688            "the longer prefix must win when it was declared first too",
4689        );
4690    }
4691
4692    /// Two `PREFIX` rules can only tie on length by carrying the SAME prefix
4693    /// string and differing on `method` — `add_tree_rule` dedups on
4694    /// `(path, method)`, so that pair coexists. The scan keeps the LAST such
4695    /// rule, because the guard is `size >= prefix_length`, not `>`.
4696    ///
4697    /// This is the subtlest ordering in the router and the easiest to "fix"
4698    /// by accident: tightening `>=` to `>` looks like a harmless no-op that
4699    /// stops a rule overwriting an equally-good one, and silently flips which
4700    /// cluster serves the request.
4701    ///
4702    /// To SEE THIS RED: change `if size >= prefix_length {` to
4703    /// `if size > prefix_length {` in `Router::lookup`'s path-rule loop. Both
4704    /// assertions below then resolve to the FIRST declared rule.
4705    #[test]
4706    fn equal_length_prefixes_differing_only_by_method_resolve_to_the_last_declared() {
4707        let declare = |first_is_get: bool| {
4708            let mut router = Router::new();
4709            let get = (
4710                MethodRule::new(Some("GET".to_owned())),
4711                Route::ClusterId("GET-RULE".into()),
4712            );
4713            let all = (MethodRule::new(None), Route::ClusterId("ALL-RULE".into()));
4714            let (a, b) = if first_is_get { (get, all) } else { (all, get) };
4715            for (method, route) in [a, b] {
4716                assert!(router.add_tree_rule(
4717                    b"www.example.com",
4718                    &PathRule::Prefix("/ab".to_owned()),
4719                    &method,
4720                    &route,
4721                ));
4722            }
4723            routed_cluster(&router, "/abc")
4724        };
4725
4726        assert_eq!(
4727            declare(true).as_deref(),
4728            Some("ALL-RULE"),
4729            "the LAST equal-length prefix declared must win (`>=`, not `>`)",
4730        );
4731        assert_eq!(
4732            declare(false).as_deref(),
4733            Some("GET-RULE"),
4734            "the LAST equal-length prefix declared must win in the other order too",
4735        );
4736    }
4737
4738    /// Two overlapping `REGEX` path rules are decided by declaration order:
4739    /// the scan returns on the first match, and nothing weighs one pattern as
4740    /// more specific than another. A wider pattern declared first therefore
4741    /// shadows a narrower one declared later.
4742    ///
4743    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4744    /// `MethodRuleResult::Equals => { return Ok(RouteResult::new_with_trie(..)) }`
4745    /// arm under `PathRuleResult::Regex | PathRuleResult::Equals` with the
4746    /// non-returning body its `MethodRuleResult::All` sibling uses
4747    /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). Losing
4748    /// the early return makes the LAST match win, so both assertions flip.
4749    #[test]
4750    fn with_a_matching_method_the_first_declared_regex_path_rule_wins() {
4751        let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4752        let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4753
4754        assert_eq!(
4755            winner_of((wide(), GET, "WIDE"), (narrow(), GET, "NARROW"), "/abc").as_deref(),
4756            Some("WIDE"),
4757            "the first-declared regex must win even though the later one is narrower",
4758        );
4759        assert_eq!(
4760            winner_of((narrow(), GET, "NARROW"), (wide(), GET, "WIDE"), "/abc").as_deref(),
4761            Some("NARROW"),
4762            "reversing the declaration order reverses the winner",
4763        );
4764    }
4765
4766    /// `EQUALS` holds no priority over `REGEX`: they share one match arm and
4767    /// the scan returns on whichever comes first. An exact-match rule does NOT
4768    /// outrank a pattern that was declared before it.
4769    ///
4770    /// To SEE THIS RED: the same mutation as
4771    /// `with_a_matching_method_the_first_declared_regex_path_rule_wins`
4772    /// — drop the early `return` from the `MethodRuleResult::Equals` arm under
4773    /// `PathRuleResult::Regex | PathRuleResult::Equals`. Both assertions flip
4774    /// to the last declared rule.
4775    #[test]
4776    fn with_a_matching_method_neither_equals_nor_regex_outranks_the_other() {
4777        let equals = || PathRule::Equals("/abc".to_owned());
4778        let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4779
4780        assert_eq!(
4781            winner_of((equals(), GET, "EQUALS"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4782            Some("EQUALS"),
4783            "EQUALS declared first must win",
4784        );
4785        assert_eq!(
4786            winner_of((regex(), GET, "REGEX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4787            Some("REGEX"),
4788            "REGEX declared first must win — EQUALS has no inherent priority",
4789        );
4790    }
4791
4792    /// A matching `EQUALS` or `REGEX` beats a `PREFIX` rule in either order,
4793    /// even when the prefix is the longer, more specific pattern: the early
4794    /// return short-circuits the scan before any prefix accumulation is read.
4795    /// This is the one cross-type precedence the router really does have.
4796    ///
4797    /// To SEE THIS RED: the same mutation again — drop the early `return` from
4798    /// the `MethodRuleResult::Equals` arm under
4799    /// `PathRuleResult::Regex | PathRuleResult::Equals`. That arm's
4800    /// replacement sets `prefix_length = path_b.len()`, so the equally-long
4801    /// `PREFIX` rule then satisfies `size >= prefix_length` and overwrites it;
4802    /// the `PREFIX`-declared-second cases below resolve to `PREFIX`.
4803    #[test]
4804    fn with_a_matching_method_equals_or_regex_beats_a_longer_prefix_either_order() {
4805        let prefix = || PathRule::Prefix("/abc".to_owned());
4806        let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4807        let equals = || PathRule::Equals("/abc".to_owned());
4808
4809        assert_eq!(
4810            winner_of((prefix(), GET, "PREFIX"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4811            Some("REGEX"),
4812            "a regex must beat a longer prefix declared before it",
4813        );
4814        assert_eq!(
4815            winner_of((regex(), GET, "REGEX"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4816            Some("REGEX"),
4817            "a regex must beat a longer prefix declared after it",
4818        );
4819        assert_eq!(
4820            winner_of((prefix(), GET, "PREFIX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4821            Some("EQUALS"),
4822            "an exact match must beat an equally long prefix declared before it",
4823        );
4824        assert_eq!(
4825            winner_of((equals(), GET, "EQUALS"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4826            Some("EQUALS"),
4827            "an exact match must beat an equally long prefix declared after it",
4828        );
4829    }
4830
4831    /// Without a `method`, an `EQUALS` or `REGEX` rule does NOT end the scan:
4832    /// its match is only recorded, and a later match overwrites it. So the
4833    /// ordering is the exact REVERSE of the matching-method case — the LAST
4834    /// declared wins.
4835    ///
4836    /// This is the dimension the first six ordering tests all missed, because
4837    /// their helper hard-coded `Some("GET")`. `HttpFrontend.method` is an
4838    /// `Option<String>` and both production call sites build the rule with
4839    /// `MethodRule::new(front.method.clone())`, so a frontend declared without
4840    /// a method lands here — it is the default shape, not an exotic one.
4841    ///
4842    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, give the
4843    /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4844    /// PathRuleResult::Equals` the early `return` its `MethodRuleResult::Equals`
4845    /// sibling has. Method-less rules then short-circuit too and both
4846    /// assertions flip to the FIRST declared.
4847    #[test]
4848    fn without_a_method_the_last_declared_regex_or_equals_wins_not_the_first() {
4849        let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4850        let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4851        let equals = || PathRule::Equals("/abc".to_owned());
4852
4853        assert_eq!(
4854            winner_of(
4855                (wide(), NO_METHOD, "WIDE"),
4856                (narrow(), NO_METHOD, "NARROW"),
4857                "/abc"
4858            )
4859            .as_deref(),
4860            Some("NARROW"),
4861            "without a method the LAST declared regex must win",
4862        );
4863        assert_eq!(
4864            winner_of(
4865                (narrow(), NO_METHOD, "NARROW"),
4866                (wide(), NO_METHOD, "WIDE"),
4867                "/abc"
4868            )
4869            .as_deref(),
4870            Some("WIDE"),
4871            "reversing the order reverses the winner — still the last declared",
4872        );
4873        assert_eq!(
4874            winner_of(
4875                (equals(), NO_METHOD, "EQUALS"),
4876                (wide(), NO_METHOD, "REGEX"),
4877                "/abc"
4878            )
4879            .as_deref(),
4880            Some("REGEX"),
4881            "without a method an EQUALS declared first loses to a later REGEX",
4882        );
4883        assert_eq!(
4884            winner_of(
4885                (wide(), NO_METHOD, "REGEX"),
4886                (equals(), NO_METHOD, "EQUALS"),
4887                "/abc"
4888            )
4889            .as_deref(),
4890            Some("EQUALS"),
4891            "and the reverse order reverses that too",
4892        );
4893    }
4894
4895    /// Without a `method`, a matching `EQUALS`/`REGEX` does not reliably outrank
4896    /// a `PREFIX` either — the documented cross-type precedence INVERTS. Because
4897    /// the non-returning arm records `prefix_length = path_b.len()`, a `PREFIX`
4898    /// declared afterwards overwrites it exactly when its prefix covers the
4899    /// WHOLE request path; a shorter prefix cannot clear that bar and leaves the
4900    /// regex standing.
4901    ///
4902    /// "Whole request path" means the request-target as it arrives, QUERY
4903    /// STRING INCLUDED — kawa's `parse_origin_form` hands the router
4904    /// `/index.html?k=v#h` verbatim. So the identical configuration routes the
4905    /// opposite way once the client appends `?x=1`: `/abc` no longer spans
4906    /// `/abc?x=1`, the prefix stops clearing the bar, and the regex wins. The
4907    /// last case below pins that, because the worked example in
4908    /// `doc/configure.md` would otherwise teach `/abc` as "the whole path".
4909    ///
4910    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, change the
4911    /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4912    /// PathRuleResult::Equals` from `prefix_length = path_b.len();` to
4913    /// `prefix_length = 0;`. The first two assertions still pass; the third
4914    /// fails first, with `left: Some("SHORT-PREFIX"), right: Some("REGEX")`,
4915    /// because the shorter `/a` prefix now clears the bar. That one mutation
4916    /// also breaks the query-string case below it — delete the third assertion
4917    /// and the fourth fails with `left: Some("PREFIX"), right: Some("REGEX")`,
4918    /// the prefix having overwritten the regex it should no longer span.
4919    #[test]
4920    fn without_a_method_only_a_whole_path_prefix_declared_later_beats_a_regex() {
4921        let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4922        let whole_path = || PathRule::Prefix("/abc".to_owned());
4923        let shorter = || PathRule::Prefix("/a".to_owned());
4924
4925        assert_eq!(
4926            winner_of(
4927                (regex(), NO_METHOD, "REGEX"),
4928                (whole_path(), NO_METHOD, "PREFIX"),
4929                "/abc"
4930            )
4931            .as_deref(),
4932            Some("PREFIX"),
4933            "a whole-path prefix declared after a method-less regex must overwrite it",
4934        );
4935        assert_eq!(
4936            winner_of(
4937                (whole_path(), NO_METHOD, "PREFIX"),
4938                (regex(), NO_METHOD, "REGEX"),
4939                "/abc"
4940            )
4941            .as_deref(),
4942            Some("REGEX"),
4943            "declared before it, the same prefix loses — the last writer wins",
4944        );
4945        assert_eq!(
4946            winner_of(
4947                (regex(), NO_METHOD, "REGEX"),
4948                (shorter(), NO_METHOD, "SHORT-PREFIX"),
4949                "/abc"
4950            )
4951            .as_deref(),
4952            Some("REGEX"),
4953            "a prefix shorter than the request path must NOT displace the regex",
4954        );
4955
4956        // Same rules, same declaration order — only the request gains a query
4957        // string, and the winner inverts. The request path the router matches
4958        // is `/abc?x=1`, which `/abc` no longer covers.
4959        assert_eq!(
4960            winner_of(
4961                (regex(), NO_METHOD, "REGEX"),
4962                (whole_path(), NO_METHOD, "PREFIX"),
4963                "/abc?x=1"
4964            )
4965            .as_deref(),
4966            Some("REGEX"),
4967            "with a query string the prefix no longer spans the request path, so the regex wins",
4968        );
4969    }
4970
4971    /// A rule carrying a matching `method` beats a method-less one in either
4972    /// declaration order: the matching rule returns immediately, and when the
4973    /// method-less rule runs first it has only recorded a candidate that the
4974    /// return then overrides.
4975    ///
4976    /// To SEE THIS RED: drop the early `return` from the
4977    /// `MethodRuleResult::Equals` arm under `PathRuleResult::Regex |
4978    /// PathRuleResult::Equals` in `Router::lookup` (the same mutation the
4979    /// matching-method tests name). Both rules then merely record, so the last
4980    /// declared wins and the first assertion below fails with
4981    /// `left: Some("ANY-REGEX"), right: Some("GET-REGEX")`.
4982    #[test]
4983    fn a_rule_with_a_matching_method_beats_a_method_less_rule_in_either_order() {
4984        let any = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4985        let getr = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4986
4987        assert_eq!(
4988            winner_of(
4989                (getr(), GET, "GET-REGEX"),
4990                (any(), NO_METHOD, "ANY-REGEX"),
4991                "/abc"
4992            )
4993            .as_deref(),
4994            Some("GET-REGEX"),
4995            "a matching-method rule declared first must win",
4996        );
4997        assert_eq!(
4998            winner_of(
4999                (any(), NO_METHOD, "ANY-REGEX"),
5000                (getr(), GET, "GET-REGEX"),
5001                "/abc"
5002            )
5003            .as_deref(),
5004            Some("GET-REGEX"),
5005            "a matching-method rule declared second must still win",
5006        );
5007    }
5008
5009    /// A rule whose `method` is PRESENT but does not match the request
5010    /// (`MethodRuleResult::None`) is skipped outright, so it never competes on
5011    /// path length at all. Both consequences contradict the unqualified
5012    /// "longest prefix wins" reading: a LONGER prefix loses to a shorter one
5013    /// when its method mismatches, and an equal-length tie goes to the FIRST
5014    /// declared rather than the last, because the later one is skipped before
5015    /// the `size >= prefix_length` comparison is ever reached.
5016    ///
5017    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5018    /// `MethodRuleResult::None` arm under `PathRuleResult::Prefix(size)`
5019    /// behave like its `MethodRuleResult::All` sibling
5020    /// (`prefix_length = size; matched = Some((rule, route));`). Non-matching
5021    /// methods then compete: the first assertion resolves to `LONG-POST` and
5022    /// the second to `SECOND-POST`.
5023    #[test]
5024    fn a_prefix_whose_method_does_not_match_is_skipped_so_it_cannot_win_on_length() {
5025        assert_eq!(
5026            winner_of(
5027                (PathRule::Prefix("/a".to_owned()), GET, "SHORT-GET"),
5028                (
5029                    PathRule::Prefix("/ab".to_owned()),
5030                    WRONG_METHOD,
5031                    "LONG-POST"
5032                ),
5033                "/abc",
5034            )
5035            .as_deref(),
5036            Some("SHORT-GET"),
5037            "a longer prefix whose method mismatches must not beat a shorter matching one",
5038        );
5039        assert_eq!(
5040            winner_of(
5041                (PathRule::Prefix("/ab".to_owned()), GET, "FIRST-GET"),
5042                (
5043                    PathRule::Prefix("/ab".to_owned()),
5044                    WRONG_METHOD,
5045                    "SECOND-POST"
5046                ),
5047                "/abc",
5048            )
5049            .as_deref(),
5050            Some("FIRST-GET"),
5051            "an equal-length tie goes to the FIRST when the later rule's method mismatches",
5052        );
5053    }
5054
5055    /// The same third method value, one tier up: an `EQUALS`/`REGEX` rule whose
5056    /// method mismatches does not short-circuit and does not even register as a
5057    /// candidate, so a `PREFIX` that would otherwise lose to it wins.
5058    ///
5059    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5060    /// `MethodRuleResult::None` arm under
5061    /// `PathRuleResult::Regex | PathRuleResult::Equals` behave like its
5062    /// `MethodRuleResult::All` sibling
5063    /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). The
5064    /// mismatching regex then takes the request and the assertion resolves to
5065    /// `WRONG-METHOD-REGEX`.
5066    #[test]
5067    fn a_regex_whose_method_does_not_match_never_beats_a_matching_prefix() {
5068        assert_eq!(
5069            winner_of(
5070                (
5071                    PathRule::Regex(Regex::new("/a.*").expect("test regex must compile")),
5072                    WRONG_METHOD,
5073                    "WRONG-METHOD-REGEX",
5074                ),
5075                (PathRule::Prefix("/a".to_owned()), GET, "MATCHING-PREFIX"),
5076                "/abc",
5077            )
5078            .as_deref(),
5079            Some("MATCHING-PREFIX"),
5080            "a regex whose method mismatches must not short-circuit the scan",
5081        );
5082    }
5083
5084    /// Declare one regex path rule built the way a configured frontend builds
5085    /// it — through `PathRule::from_config`, which is where the anchoring
5086    /// lives — and resolve `path`.
5087    fn regex_rule_routes(pattern: &str, path: &str) -> Option<String> {
5088        let rule = PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
5089            .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"));
5090        let mut router = Router::new();
5091        assert!(add_path_rule(&mut router, rule, GET, "REGEX"));
5092        routed_cluster(&router, path)
5093    }
5094
5095    /// Resolve a table of `(pattern, request path, must route)` triples
5096    /// through [`regex_rule_routes`]. Each row names itself in the failure
5097    /// message, so a red run says which triple broke rather than which
5098    /// assertion index did.
5099    fn assert_regex_routing(cases: &[(&str, &str, bool)]) {
5100        let verdict = |routes: bool| if routes { "match" } else { "no match" };
5101        for &(pattern, path, must_route) in cases {
5102            let routes = regex_rule_routes(pattern, path).is_some();
5103            assert_eq!(
5104                routes,
5105                must_route,
5106                "path regex {pattern:?} against request path {path:?}: \
5107                 expected {}, measured {}",
5108                verdict(must_route),
5109                verdict(routes),
5110            );
5111        }
5112    }
5113
5114    /// `path_type = "REGEX"` is anchored at both ends: the configured pattern
5115    /// must match the WHOLE request path, not a substring of it.
5116    ///
5117    /// This test is the INVERSION of the test formerly named
5118    /// `a_path_regex_is_unanchored_and_matches_anywhere_in_the_request_path`
5119    /// — a name no longer in the tree, this one having replaced it —
5120    /// added by #1352 to pin the opposite. That test was correct about the
5121    /// code and is now deliberately obsolete: sozu#1350 asked whether
5122    /// `doc/configure.md` (which has promised `\A...\z` since v2.0.0) or
5123    /// `PathRule::from_config` (a bare `Regex::new` against the substring
5124    /// search `regex::bytes::Regex::is_match`) was the thing to change, and
5125    /// the answer was the code. The old test's own comment said it existed so
5126    /// that the answer would be "a deliberate change, not a silent one" —
5127    /// this is that deliberate change, kept visible by rewriting the test
5128    /// rather than deleting it.
5129    ///
5130    /// `bc` matching `/abcd` is sozu#1350's own example, so it is the case
5131    /// kept here.
5132    ///
5133    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5134    /// make the body `Regex::new(value).ok()`. The first assertion then fails
5135    /// with `left: Some("REGEX"), right: None`.
5136    #[test]
5137    fn a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path() {
5138        assert_eq!(
5139            regex_rule_routes("bc", "/abcd"),
5140            None,
5141            "an anchored path regex must not match in the middle of the request path",
5142        );
5143        assert_eq!(
5144            regex_rule_routes("/ab[cd]", "/abc").as_deref(),
5145            Some("REGEX"),
5146            "a regex spanning the whole request path must still match",
5147        );
5148        assert_eq!(
5149            regex_rule_routes("/ab[cd]", "/abcd"),
5150            None,
5151            "the trailing `\\z` must reject a longer path with a matching head",
5152        );
5153        assert_eq!(
5154            regex_rule_routes("/ab[cd]", "/x/abc"),
5155            None,
5156            "the leading `\\A` must reject a path with a matching tail",
5157        );
5158
5159        // The matched "request path" is the request-target as it arrives,
5160        // query string included, so anchoring bites a query string the way it
5161        // already bit `path_type = "EQUALS"` — the consequence
5162        // `doc/configure.md` now states.
5163        assert_eq!(
5164            regex_rule_routes("/ab[cd]", "/abc?x=1"),
5165            None,
5166            "an anchored regex must not match a path carrying a query string \
5167             unless the pattern accounts for it",
5168        );
5169        assert_eq!(
5170            regex_rule_routes("/ab[cd].*", "/abc?x=1").as_deref(),
5171            Some("REGEX"),
5172            "appending `.*` is what makes such a rule survive a query string",
5173        );
5174    }
5175
5176    /// The narrowing reaches the degenerate pattern too: an empty `REGEX`
5177    /// value compiles (it always has) and used to match every request path,
5178    /// because an empty match at offset 0 is a match. Anchored, it matches the
5179    /// empty path and nothing else. An operator using `path_type = "REGEX"`
5180    /// with an empty `path` as a catch-all loses every request to it.
5181    ///
5182    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5183    /// make the body `Regex::new(value).ok()`. The assertion then fails with
5184    /// `left: Some("REGEX"), right: None`.
5185    #[test]
5186    fn an_empty_path_regex_no_longer_matches_every_request_path() {
5187        assert!(
5188            Regex::new("").is_ok(),
5189            "the empty pattern must stay a VALID regex — this test is about \
5190             what it matches, not about rejecting it",
5191        );
5192        assert_eq!(
5193            regex_rule_routes("", "/abcd"),
5194            None,
5195            "an anchored empty path regex must not match a non-empty path",
5196        );
5197        // "matched every path", measured against the BARE regex of 2.2.1 — a
5198        // control. An empty match at offset 0 is a match, so `is_match`
5199        // succeeded on every input; that is what made an empty `REGEX` value
5200        // usable as a catch-all in the first place.
5201        let bare_empty = Regex::new("").expect("the empty pattern compiles");
5202        for every_path in ["/anything", "/abcd", "/", ""] {
5203            assert!(
5204                bare_empty.is_match(every_path.as_bytes()),
5205                "up to 2.2.1 the unwrapped empty pattern matched {every_path:?}, \
5206                 as it matched every path",
5207            );
5208        }
5209
5210        assert_eq!(
5211            regex_rule_routes("", "").as_deref(),
5212            Some("REGEX"),
5213            "\"matches only the empty path\" is half a claim without the path \
5214             it does still match",
5215        );
5216    }
5217
5218    /// Anchors an operator wrote by hand keep working, and are neither
5219    /// detected nor stripped: `regex`'s `^`/`$` ARE `\A`/`\z` outside
5220    /// multi-line mode, so the repeated zero-width assertions in
5221    /// `\A(?:^/abc$)\z` collapse to one at each end. Measured rather than
5222    /// assumed — that is what the pattern-string assertion below pins.
5223    ///
5224    /// The third case is the one that changes: a HALF-anchored pattern
5225    /// (`^/abc`, start only) used to match `/abcd` and no longer does. An
5226    /// operator who wrote the leading anchor on the strength of the old
5227    /// unanchored behaviour gets the missing end supplied for them.
5228    ///
5229    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5230    /// make the body `Regex::new(value).ok()`. The half-anchored case then
5231    /// fails with `left: Some("REGEX"), right: None`, and the pattern-string
5232    /// assertion fails too. To see ONLY the pattern-string assertion red,
5233    /// strip a leading `^`/`\A` and a trailing `$`/`\z` from `value` before
5234    /// wrapping it.
5235    #[test]
5236    fn a_hand_written_path_anchor_is_kept_and_double_anchoring_is_harmless() {
5237        for hand_anchored in ["^/abc$", "\\A/abc\\z", "^/abc"] {
5238            assert_eq!(
5239                regex_rule_routes(hand_anchored, "/abc").as_deref(),
5240                Some("REGEX"),
5241                "{hand_anchored} must still match the path it was written for",
5242            );
5243            assert_eq!(
5244                regex_rule_routes(hand_anchored, "/abcd"),
5245                None,
5246                "{hand_anchored} must not match a longer path",
5247            );
5248            assert_eq!(
5249                regex_rule_routes(hand_anchored, "/x/abc"),
5250                None,
5251                "{hand_anchored} must not match a path with a matching tail",
5252            );
5253        }
5254
5255        assert_eq!(
5256            PathRule::anchored_regex("^/abc$")
5257                .expect("a hand-anchored pattern must compile")
5258                .as_str(),
5259            "\\A(?:^/abc$)\\z",
5260            "the configured value is wrapped verbatim; no anchor is stripped",
5261        );
5262
5263        // `CHANGELOG.md` and `PathRule::anchored_regex` both quote `/xabcd`
5264        // as the path `\A(?:^/abc$)\z` must not match. It is a positive
5265        // control rather than a reddenable row — `^/abc$` excludes it with or
5266        // without the wrapping — but the quoted example was asserted nowhere.
5267        assert_eq!(
5268            regex_rule_routes("^/abc$", "/xabcd"),
5269            None,
5270            "the example `CHANGELOG.md` quotes verbatim must hold",
5271        );
5272
5273        // The load-bearing half of "`^`/`$` ARE `\A`/`\z`": in the `regex`
5274        // crate's default mode `$` is `\z` and NOT `\Z`, so it does not
5275        // match before a trailing `\n`. Were it `\Z`, the claim that an
5276        // already-anchored `^/abc$` is unchanged by the wrapping would be
5277        // false for exactly one input — a path ending in a newline. Whether a
5278        // client can drive such a request-target past the parser is NOT
5279        // established here, and deliberately so: the wrapping claim has to
5280        // hold on the bytes the router is handed, whatever produced them.
5281        // Measured against the crate directly, so it is a control.
5282        assert!(
5283            !Regex::new("^/abc$")
5284                .expect("the hand-anchored pattern compiles")
5285                .is_match(b"/abc\n"),
5286            "`$` must be `\\z` and not `\\Z`, or wrapping a hand-anchored \
5287             pattern would not be the no-op this test claims",
5288        );
5289        assert_eq!(
5290            regex_rule_routes("^/abc$", "/abc\n"),
5291            None,
5292            "and the wrapped rule must agree with it",
5293        );
5294    }
5295
5296    /// The anchoring wraps the configured value in a NON-CAPTURING GROUP, and
5297    /// that group is load-bearing: `|` binds looser than concatenation, so the
5298    /// ungrouped `\A/a|/b\z` parses as `(\A/a)|(/b\z)` and leaves each branch
5299    /// anchored at one end only. `/a` would still match `/axx`.
5300    ///
5301    /// Two branches only ever exercise the FIRST and LAST branch, so the
5302    /// three-branch case below is what makes `doc/configure.md`'s "every
5303    /// branch […] not just the first and last" a testable sentence: ungrouped,
5304    /// a MIDDLE branch keeps neither anchor and matches as a bare substring
5305    /// anywhere in the path — worse than the half-anchored ends.
5306    ///
5307    /// The hostname sites carried the same gap when this landed and were
5308    /// closed separately by sozu#1356: `convert_regex_domain_rule` now
5309    /// groups each slash-delimited segment, and `pattern_trie.rs`'s
5310    /// `anchored_segment` builds `\A(?:{segment})\z` for the regex fed to
5311    /// `regexp.is_match(segment)`. See
5312    /// `every_branch_of_an_alternating_regex_hostname_rule_is_anchored` and
5313    /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`.
5314    ///
5315    /// To SEE THIS RED: drop the group in `PathRule::anchored_regex` — wrap as
5316    /// `format!("\\A{value}\\z")`, the ungrouped hostname form. The `/axx`
5317    /// assertion then fails with `left: Some("REGEX"), right: None`; with the
5318    /// two-branch rows removed, the three-branch table fails on
5319    /// `"/a|/b|/c"` against `"zzz/bzzz"` with `left: true, right: false`, the
5320    /// middle branch matching in the middle of the path.
5321    #[test]
5322    fn every_branch_of_an_alternating_path_regex_is_anchored() {
5323        for whole in ["/a", "/b"] {
5324            assert_eq!(
5325                regex_rule_routes("/a|/b", whole).as_deref(),
5326                Some("REGEX"),
5327                "{whole} is a whole-path match for one branch and must route",
5328            );
5329        }
5330        assert_eq!(
5331            regex_rule_routes("/a|/b", "/axx"),
5332            None,
5333            "the first branch must be anchored at its END too",
5334        );
5335        assert_eq!(
5336            regex_rule_routes("/a|/b", "/xx/b"),
5337            None,
5338            "the last branch must be anchored at its START too",
5339        );
5340        assert_eq!(
5341            PathRule::anchored_regex("/a|/b")
5342                .expect("an alternation must compile")
5343                .captures_len(),
5344            1,
5345            "the wrapping group must be non-capturing, so `$PATH[n]` indices \
5346             are untouched",
5347        );
5348
5349        // The exact wrapper string, so "grouped" is asserted and not merely
5350        // implied by the three routing verdicts above.
5351        assert_eq!(
5352            PathRule::anchored_regex("/a|/b")
5353                .expect("an alternation must compile")
5354                .as_str(),
5355            "\\A(?:/a|/b)\\z",
5356            "the wrapper must be the NON-capturing group form",
5357        );
5358
5359        // The control for all of the above, built by hand against the `regex`
5360        // crate rather than through `anchored_regex`: the UNGROUPED wrapper —
5361        // the form the hostname sites use — parses as `(\A/a)|(/b\z)` and
5362        // still matches `/axx`. No mutation of `anchored_regex` can move this
5363        // assertion, which is the point: it measures why the group is there.
5364        let ungrouped = Regex::new("\\A/a|/b\\z").expect("the ungrouped wrapper compiles");
5365        assert!(
5366            ungrouped.is_match(b"/axx"),
5367            "{} leaves the first branch anchored at its start only",
5368            ungrouped.as_str(),
5369        );
5370        assert!(
5371            ungrouped.is_match(b"/xx/b"),
5372            "{} leaves the last branch anchored at its end only",
5373            ungrouped.as_str(),
5374        );
5375
5376        // Two branches cannot show what `doc/configure.md` actually claims —
5377        // "every branch of an alternation is anchored, NOT JUST THE FIRST AND
5378        // LAST" — because with two branches there is no other kind. A third
5379        // branch has one, and it is strictly worse than the half-anchored
5380        // ends above: ungrouped, the MIDDLE branch keeps NEITHER anchor, so
5381        // `/b` matches as a bare substring anywhere in the path. Control,
5382        // measured against the crate.
5383        let three = Regex::new("\\A/a|/b|/c\\z").expect("the ungrouped wrapper compiles");
5384        for anywhere in ["zzz/bzzz", "/xx/b/yy", "xx/byy"] {
5385            assert!(
5386                three.is_match(anywhere.as_bytes()),
5387                "{} leaves the MIDDLE branch unanchored at BOTH ends, so it \
5388                 matches {anywhere} anywhere in the path",
5389                three.as_str(),
5390            );
5391        }
5392
5393        // The grouped form the router actually builds refuses all three, and
5394        // still matches each branch whole. This half IS production.
5395        assert_regex_routing(&[
5396            ("/a|/b|/c", "/a", true),
5397            ("/a|/b|/c", "/b", true),
5398            ("/a|/b|/c", "/c", true),
5399            ("/a|/b|/c", "zzz/bzzz", false),
5400            ("/a|/b|/c", "/xx/b/yy", false),
5401            ("/a|/b|/c", "xx/byy", false),
5402            ("/a|/b|/c", "/axx", false),
5403            ("/a|/b|/c", "/xx/c", false),
5404        ]);
5405        assert_eq!(
5406            PathRule::anchored_regex("/a|/b|/c")
5407                .expect("a three-branch alternation must compile")
5408                .as_str(),
5409            "\\A(?:/a|/b|/c)\\z",
5410            "a middle branch needs the group as much as the outer two do",
5411        );
5412    }
5413
5414    /// Anchoring must not rescue a pattern the router rejects today. The
5415    /// wrapper supplies one `(` and one `)`, so an unbalanced value can be
5416    /// balanced BY the wrapping — `a)(b` is not a regex, yet `\A(?:a)(b)\z`
5417    /// is, and it matches `ab`. `PathRule::anchored_regex` therefore compiles
5418    /// the configured value on its own before wrapping it.
5419    ///
5420    /// This test covers that one direction. The converse is NOT guarded and
5421    /// is not asserted here: wrapping can reject a pattern that compiles
5422    /// bare, measured on `(?x)/api/v1 # v1 only` (the appended `)` lands in
5423    /// the `#` comment `(?x)` enables) and on a 249-deep nested group (the
5424    /// crate's 250-nesting limit). Both are refused at frontend registration
5425    /// as [`RouterError::InvalidPathRule`], so they cost a loud rejection and
5426    /// not a mis-route — see `PathRule::anchored_regex`.
5427    ///
5428    /// To SEE THIS RED: delete the `Regex::new(value).ok()?;` validation line
5429    /// in `PathRule::anchored_regex`. The `a)(b` assertion then fails — the
5430    /// rule builds, and matches `ab`.
5431    #[test]
5432    fn anchoring_never_turns_a_rejected_path_regex_into_an_accepted_one() {
5433        // The hazard is real, not hypothetical. Both patterns are assembled
5434        // at run time rather than written as literals because
5435        // `clippy::invalid_regex` rejects an invalid literal at a `Regex::new`
5436        // call site — and the invalidity is the whole point here, so the
5437        // literal is what moves, not the lint.
5438        let unbalanced = format!("a{}b", ")(");
5439        assert!(
5440            Regex::new(&unbalanced).is_err(),
5441            "{unbalanced:?} must be an invalid regex for this test to mean anything",
5442        );
5443        let wrapped = Regex::new(&format!("\\A(?:{unbalanced})\\z")).expect(
5444            "the wrapped form of the unbalanced pattern compiles — that balancing \
5445             is what the pre-validation exists to defeat",
5446        );
5447        assert!(
5448            wrapped.is_match(b"ab"),
5449            "{} matches `ab`, which the operator never wrote",
5450            wrapped.as_str(),
5451        );
5452
5453        for invalid in [unbalanced.as_str(), "(", ")", "[a-", "*"] {
5454            assert_eq!(
5455                PathRule::from_config(CommandPathRule::regex(invalid.to_owned())),
5456                None,
5457                "{invalid:?} must stay rejected once the value is anchored",
5458            );
5459        }
5460    }
5461
5462    /// Anchoring narrows `Route::Deny` exactly as it narrows a forward, and
5463    /// that is the dangerous edge of the change: a deny rule written loosely
5464    /// stops covering the family of paths it used to cover. `/admin` denied
5465    /// `/admin`, `/admin/secret` and `/administrator`; anchored it denies
5466    /// `/admin` alone and the rest fall through to whatever else matches.
5467    ///
5468    /// Both rules carry a matching `method`, so the deny is tier 1 in
5469    /// `doc/configure.md`'s precedence list and ends the scan when it matches
5470    /// — the fall-through below is the deny genuinely not matching, not the
5471    /// prefix outranking it.
5472    ///
5473    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5474    /// make the body `Regex::new(value).ok()`. Both fall-through assertions
5475    /// then fail with `left: None, right: Some("FALLBACK")`.
5476    #[test]
5477    fn anchoring_narrows_a_deny_path_regex_to_the_exact_path() {
5478        let mut router = Router::new();
5479        let deny = PathRule::from_config(CommandPathRule::regex("/admin".to_owned()))
5480            .expect("the deny regex path rule must build");
5481        assert!(router.add_tree_rule(
5482            b"www.example.com",
5483            &deny,
5484            &MethodRule::new(Some("GET".to_owned())),
5485            &Route::Deny,
5486        ));
5487        assert!(add_path_rule(
5488            &mut router,
5489            PathRule::Prefix("/".to_owned()),
5490            GET,
5491            "FALLBACK",
5492        ));
5493
5494        let resolve = |path: &str| {
5495            router
5496                .lookup("www.example.com", path, &Method::Get)
5497                .expect("every path below is covered by the `/` prefix rule")
5498        };
5499
5500        let denied = resolve("/admin");
5501        assert_eq!(
5502            denied.redirect,
5503            RedirectPolicy::Unauthorized,
5504            "the exact path the deny regex spells must still be denied",
5505        );
5506        assert_eq!(denied.cluster_id, None);
5507
5508        for still_reaching_the_backend in ["/admin/secret", "/administrator"] {
5509            let result = resolve(still_reaching_the_backend);
5510            assert_eq!(
5511                result.cluster_id.as_deref(),
5512                Some("FALLBACK"),
5513                "BREAKING: the anchored deny no longer covers \
5514                 {still_reaching_the_backend}, which now reaches the backend",
5515            );
5516            assert_eq!(result.redirect, RedirectPolicy::Forward);
5517        }
5518    }
5519
5520    /// `doc/configure.md`'s remediation table, rows one and two: a
5521    /// SUBSTRING-shaped or PREFIX-shaped pattern narrows to the exact path it
5522    /// spells, and a TRAILING `.*` is what gives it back — those two shapes
5523    /// are open at the END, so that is the side the wildcard goes on.
5524    ///
5525    /// `bc` against `/abcd` is sozu#1350's own example and is pinned whole in
5526    /// `a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path`;
5527    /// it is repeated here as the table's first row, beside its remediation.
5528    /// The table answers the prefix-shaped row twice, with `.*` or with
5529    /// `path_type = "PREFIX"`; only the regex answer is a regex claim, so only
5530    /// that one is asserted.
5531    ///
5532    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5533    /// make the body `Regex::new(value).ok()`. Every narrowing row below flips
5534    /// to a match; the first failure is `"bc"` against `"/abcd"`.
5535    #[test]
5536    fn a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells() {
5537        assert_regex_routing(&[
5538            // What the two shapes lose.
5539            ("bc", "/abcd", false),
5540            ("/ab", "/abcd", false),
5541            ("/api", "/api/v1", false),
5542            // …and what they keep: the path they spell whole.
5543            ("/api", "/api", true),
5544            // The table says `bc` now matches "only the path `bc`"; that is
5545            // two claims, and this is the half the narrowing rows do not
5546            // cover.
5547            ("bc", "bc", true),
5548            // The documented remediation, on the side each pattern is open on.
5549            (".*bc.*", "/abcd", true),
5550            ("/api.*", "/api/v1", true),
5551            ("/api.*", "/api", true),
5552        ]);
5553
5554        // The table's "used to match" column, measured. These go through the
5555        // BARE, unwrapped regex — the 2.2.1 code — so they are controls: no
5556        // mutation of `anchored_regex` can move them, and that is exactly
5557        // what makes them evidence for what the release took away. Without
5558        // them the whole first column is prose.
5559        for (pattern, used_to_match) in [("bc", "/abcd"), ("/api", "/api/v1")] {
5560            assert!(
5561                Regex::new(pattern)
5562                    .expect("the bare pattern compiles")
5563                    .is_match(used_to_match.as_bytes()),
5564                "up to 2.2.1 the unwrapped {pattern:?} matched {used_to_match:?} \
5565                 as a substring — that is the behaviour this release removes",
5566            );
5567        }
5568    }
5569
5570    /// **The row that breaks silently.** A SUFFIX-shaped pattern is open at
5571    /// the START, so it needs a LEADING `.*`; the trailing one that answers
5572    /// the substring and prefix shapes does nothing for it. Extension routing
5573    /// is plausibly the commonest real use of a path regex, and it does not
5574    /// narrow — it stops matching.
5575    ///
5576    /// Every verdict below is `doc/configure.md`'s third table row and the two
5577    /// paragraphs under it, measured.
5578    ///
5579    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5580    /// make the body `Regex::new(value).ok()`. `"\.js$"` against
5581    /// `"/static/app.js"` fails first, and every other `false` row above the
5582    /// leading-`.*` ones fails with it.
5583    #[test]
5584    fn a_suffix_shaped_path_regex_needs_a_leading_wildcard_not_a_trailing_one() {
5585        assert_regex_routing(&[
5586            // The three the documentation names, all silently dead.
5587            (r"\.js$", "/static/app.js", false),
5588            (r"\.(js|css)$", "/a/b/style.css", false),
5589            ("/v1$", "/api/v1", false),
5590            // `/v1$` is the less dramatic half: it still matches its own path.
5591            ("/v1$", "/v1", true),
5592            // The trailing `.*` that rescues the other two shapes does NOT
5593            // rescue this one.
5594            (r"\.js.*", "/static/app.js", false),
5595            ("/v1.*", "/api/v1", false),
5596            // The leading one does.
5597            (r".*\.js$", "/static/app.js", true),
5598            (r".*\.(js|css)$", "/a/b/style.css", true),
5599            (".*/v1$", "/api/v1", true),
5600        ]);
5601
5602        // "…matches nothing a client can send": an end-anchored pattern with
5603        // nothing in front of it can only match a path that IS its own
5604        // suffix, which for `\.js$` is the literal path `.js`. An origin-form
5605        // request-target always begins with `/`, so no client can send it.
5606        assert_regex_routing(&[(r"\.js$", ".js", true)]);
5607
5608        // The table's third "used to match" cell, measured against the BARE
5609        // regex of 2.2.1 — a control, like the two in
5610        // `a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells`.
5611        // This is the cell that matters most: it is the one whose loss is
5612        // silent.
5613        assert!(
5614            Regex::new(r"\.js$")
5615                .expect("the bare suffix pattern compiles")
5616                .is_match(b"/static/app.js"),
5617            "up to 2.2.1 the unwrapped `\\.js$` DID match `/static/app.js` — \
5618             losing that is the silent break this release documents",
5619        );
5620        for origin_form in ["/", "/.js", "/app.js", "/static/app.js"] {
5621            assert!(
5622                regex_rule_routes(r"\.js$", origin_form).is_none(),
5623                "an origin-form request-target begins with `/`, and {origin_form} \
5624                 must not match the end-anchored `\\.js$`",
5625            );
5626        }
5627    }
5628
5629    /// **The `$PATH[n]` regression test.** The anchoring group is `(?:` … `)`
5630    /// and not `(` … `)` precisely so it adds no capture. `Router::lookup`
5631    /// collects path captures as `caps.iter().skip(1)` and `Frontend::new`
5632    /// sizes the buffer from `PathRule::Regex(regex) => regex.captures_len()`,
5633    /// so a CAPTURING wrapper would bump that cap and shift every configured
5634    /// `$PATH[n]` by one: a frontend asking for its first group would start
5635    /// receiving the whole request path, and keep serving traffic while doing
5636    /// it. This is the one property here that corrupts silently rather than
5637    /// stops matching.
5638    ///
5639    /// Asserted three ways: `captures_len()` parity against the bare pattern
5640    /// for zero, one and two groups; end to end, that `rewrite_path` of
5641    /// `$PATH[1]` still receives the first group's text; and that the CAP
5642    /// itself did not grow, by pinning that a `$PATH[n]` one past the last
5643    /// group the operator wrote is still refused.
5644    ///
5645    /// To SEE THIS RED: make the wrapper capturing in
5646    /// `PathRule::anchored_regex` — `format!("\\A({value})\\z")`. The parity
5647    /// assertion fails first with `left: 2, right: 1` on the group-less
5648    /// pattern; with that loop deleted the rewrite yields `/api/v2/users`
5649    /// instead of `v2`, and the out-of-range `$PATH[2]` registers instead of
5650    /// being refused.
5651    #[test]
5652    fn the_anchoring_group_is_non_capturing_so_path_rewrite_indices_do_not_shift() {
5653        for pattern in ["/api/v2/.*", "/api/(v[0-9]+)/.*", "/api/(v[0-9]+)/(.*)"] {
5654            let bare = Regex::new(pattern).expect("the bare pattern must compile");
5655            let anchored =
5656                PathRule::anchored_regex(pattern).expect("the anchored pattern must compile");
5657            assert_eq!(
5658                anchored.captures_len(),
5659                bare.captures_len(),
5660                "anchoring {pattern:?} must not add a capture group",
5661            );
5662        }
5663
5664        let rewriting_frontend = |pattern: &str, rewrite: &str| {
5665            let mut front = test_http_frontend();
5666            front.hostname = "www.example.com".to_owned();
5667            front.path = CommandPathRule::regex(pattern.to_owned());
5668            front.rewrite_path = Some(rewrite.to_owned());
5669            front
5670        };
5671        let rewritten = |pattern: &str, rewrite: &str, path: &str| {
5672            let mut router = Router::new();
5673            router
5674                .add_http_front(&rewriting_frontend(pattern, rewrite))
5675                .expect("the rewriting frontend must register");
5676            router
5677                .lookup("www.example.com", path, &Method::Get)
5678                .expect("the request must route")
5679                .rewritten_path
5680        };
5681
5682        assert_eq!(
5683            rewritten("/api/(v[0-9]+)/.*", "$PATH[1]", "/api/v2/users").as_deref(),
5684            Some("v2"),
5685            "`$PATH[1]` must still be the FIRST group the operator wrote, not \
5686             the anchoring wrapper",
5687        );
5688        assert_eq!(
5689            rewritten("/api/(v[0-9]+)/(.*)", "/$PATH[2]", "/api/v2/users").as_deref(),
5690            Some("/users"),
5691            "`$PATH[2]` must still be the SECOND group the operator wrote",
5692        );
5693        assert_eq!(
5694            rewritten("/api/(v[0-9]+)/.*", "$PATH[0]", "/api/v2/users").as_deref(),
5695            Some("/api/v2/users"),
5696            "`$PATH[0]` stays the whole request path",
5697        );
5698
5699        let mut router = Router::new();
5700        assert!(
5701            matches!(
5702                router.add_http_front(&rewriting_frontend("/api/(v[0-9]+)/.*", "$PATH[2]")),
5703                Err(RouterError::InvalidPathRewrite(_)),
5704            ),
5705            "the capture CAP is `captures_len()` too: a `$PATH[n]` past the \
5706             last group the operator wrote must stay refused",
5707        );
5708    }
5709
5710    /// The pre-validation in `PathRule::anchored_regex` closes
5711    /// invalid-becomes-valid only. The opposite direction is real, documented
5712    /// and NOT closed: a pattern that compiles BARE can be refused once
5713    /// wrapped, because the appended `)\z` has to survive whatever the pattern
5714    /// left open.
5715    ///
5716    /// Both halves are asserted for each case — `Regex::new(value).is_ok()`
5717    /// AND `from_config(…).is_none()` — because the second alone would not
5718    /// show the wrapping is what rejected it.
5719    ///
5720    /// The `249` both `CHANGELOG.md` and `doc/configure.md` quote is measured
5721    /// here rather than derived from the crate's documented limit, and it is
5722    /// the SMALLEST depth with this property: on `regex` 1.13.1 depth 248
5723    /// still survives the wrapping, depth 249 does not, and depth 251 no
5724    /// longer compiles bare either.
5725    ///
5726    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5727    /// make the body `Regex::new(value).ok()`. Both `from_config` assertions
5728    /// fail, each rule building instead of being refused. (The `Regex::new`
5729    /// rows are controls measuring the crate directly and do not move.)
5730    #[test]
5731    fn a_path_regex_that_compiles_bare_can_be_refused_once_it_is_anchored() {
5732        // `(?x)` turns on extended mode, in which `#` starts a comment that
5733        // runs to end of line — and the wrapper's `)` lands inside it.
5734        let extended_mode_comment = "(?x)/api/v1 # v1 only".to_owned();
5735        let nested = |depth: usize| format!("{}{}", "(".repeat(depth), ")".repeat(depth));
5736
5737        for (value, expected_error) in [
5738            (extended_mode_comment, "unclosed group"),
5739            (
5740                nested(249),
5741                "exceed the maximum number of nested parentheses/brackets (250)",
5742            ),
5743        ] {
5744            assert!(
5745                Regex::new(&value).is_ok(),
5746                "this case only means something while the BARE pattern compiles",
5747            );
5748            let error = Regex::new(&format!("\\A(?:{value})\\z"))
5749                .expect_err("the WRAPPED pattern is the one that must fail");
5750            assert!(
5751                error.to_string().contains(expected_error),
5752                "the documentation quotes {expected_error:?}; the wrapping \
5753                 actually failed with: {error}",
5754            );
5755            assert_eq!(
5756                PathRule::from_config(CommandPathRule::regex(value)),
5757                None,
5758                "a value only the WRAPPING rejects must still be refused",
5759            );
5760        }
5761
5762        // The boundary itself, so the quoted 249 cannot drift unnoticed.
5763        assert!(
5764            Regex::new(&format!("\\A(?:{})\\z", nested(248))).is_ok(),
5765            "depth 248 must still survive the wrapping, or 249 is not the boundary",
5766        );
5767        assert!(
5768            Regex::new(&nested(250)).is_ok() && Regex::new(&nested(251)).is_err(),
5769            "the BARE limit must still sit between 250 and 251, or the wrapper \
5770             is no longer what makes depth 249 fail",
5771        );
5772    }
5773
5774    /// Every pattern `PathRule::from_config` refuses — bare-invalid or
5775    /// invalid-only-once-wrapped — surfaces at the public registration
5776    /// surface as [`RouterError::InvalidPathRule`]. That is the same
5777    /// `Router::add_http_front` the main process runs through
5778    /// `validate_frontend_request` (`bin/src/command/requests.rs`) before
5779    /// fanning a frontend out, which is what makes such a pattern a loudly
5780    /// rejected frontend and never a main/worker divergence.
5781    ///
5782    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5783    /// make the body `Regex::new(value).ok()`. The last two rows then
5784    /// register successfully. To redden the FIRST row instead, delete the
5785    /// `Regex::new(value).ok()?;` validation line: the wrapper balances
5786    /// `a)(b` and the frontend registers.
5787    #[test]
5788    fn a_refused_path_regex_surfaces_as_an_invalid_path_rule_at_registration() {
5789        // Assembled rather than written as a literal: `clippy::invalid_regex`
5790        // rejects an invalid literal at a `Regex::new` call site, and the
5791        // invalidity is the point.
5792        let unbalanced = format!("a{}b", ")(");
5793        for value in [
5794            unbalanced,
5795            "(?x)/api/v1 # v1 only".to_owned(),
5796            format!("{}{}", "(".repeat(249), ")".repeat(249)),
5797        ] {
5798            let mut router = Router::new();
5799            let mut front = test_http_frontend();
5800            front.path = CommandPathRule::regex(value.clone());
5801            assert!(
5802                matches!(
5803                    router.add_http_front(&front),
5804                    Err(RouterError::InvalidPathRule(_))
5805                ),
5806                "the path regex {value:?} must be refused as an invalid path rule",
5807            );
5808        }
5809    }
5810
5811    /// The FIRST of the two 401 shapes `doc/configure.md` names: a frontend
5812    /// with no `cluster_id`. It narrows exactly as the
5813    /// `redirect = "unauthorized"` shape does, which is the substance of
5814    /// "both answer 401 … through the very same `path`/`path_type` matching".
5815    ///
5816    /// Asserted through `Router::add_http_front`, because that is where the
5817    /// shape becomes a route and the claim is about the FRONTEND, not about
5818    /// `Route::Deny`. The two sub-shapes take different branches and both are
5819    /// covered here: a bare clusterless frontend sets no policy field, so
5820    /// `add_http_front`'s `has_policy` is false and it maps to `Route::Deny`
5821    /// directly; one that spells `redirect = "forward"` flips `has_policy`,
5822    /// runs `Frontend::new`, and is coerced to unauthorized there instead.
5823    /// Nothing in the anchoring cares which — that is the point.
5824    ///
5825    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5826    /// make the body `Regex::new(value).ok()`. The unanchored `/admin` covers
5827    /// the family again and both fall-through assertions fail with
5828    /// `left: None, right: Some("FALLBACK")`.
5829    #[test]
5830    fn a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule() {
5831        for spells_forward in [false, true] {
5832            let mut router = Router::new();
5833            let mut clusterless = test_http_frontend();
5834            clusterless.hostname = "www.example.com".to_owned();
5835            clusterless.cluster_id = None;
5836            clusterless.path = CommandPathRule::regex("/admin".to_owned());
5837            clusterless.method = Some("GET".to_owned());
5838            if spells_forward {
5839                clusterless.redirect = Some(RedirectPolicy::Forward as i32);
5840            }
5841            router
5842                .add_http_front(&clusterless)
5843                .expect("a clusterless frontend must register as a deny");
5844            assert!(add_path_rule(
5845                &mut router,
5846                PathRule::Prefix("/".to_owned()),
5847                GET,
5848                "FALLBACK",
5849            ));
5850
5851            let resolve = |path: &str| {
5852                router
5853                    .lookup("www.example.com", path, &Method::Get)
5854                    .expect("every path below is covered by the `/` prefix rule")
5855            };
5856
5857            let denied = resolve("/admin");
5858            assert_eq!(
5859                denied.redirect,
5860                RedirectPolicy::Unauthorized,
5861                "a clusterless frontend must still deny the path it spells \
5862                 (spells_forward={spells_forward})",
5863            );
5864            assert_eq!(denied.cluster_id, None);
5865
5866            for now_reaching_the_backend in ["/admin/secret", "/administrator"] {
5867                assert_eq!(
5868                    resolve(now_reaching_the_backend).cluster_id.as_deref(),
5869                    Some("FALLBACK"),
5870                    "BREAKING: the anchored clusterless deny no longer covers \
5871                     {now_reaching_the_backend} (spells_forward={spells_forward})",
5872                );
5873            }
5874        }
5875    }
5876
5877    /// The documented remediation for the deny narrowing: a deny-shaped
5878    /// `REGEX` meant as a subtree is widened with a trailing `.*` and covers
5879    /// the family again. It is a regex and not a path-segment rule, so
5880    /// `/admin.*` takes `/administrator` back with `/admin/secret` — widening
5881    /// is not surgical, and that is worth seeing.
5882    ///
5883    /// Declared through `Router::add_http_front` carrying
5884    /// `redirect = "unauthorized"`, which is the SECOND of the two 401 shapes
5885    /// `doc/configure.md` names. The first — a frontend with no `cluster_id` —
5886    /// is covered by
5887    /// `a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule`.
5888    /// `anchoring_narrows_a_deny_path_regex_to_the_exact_path` covers NEITHER
5889    /// frontend shape: it builds `Route::Deny` directly through
5890    /// `add_tree_rule`, which is the route those two shapes resolve TO, and
5891    /// says nothing about either resolving to it.
5892    ///
5893    /// To SEE THIS RED: the anchoring mutation does NOT redden this test, and
5894    /// cannot — `/admin.*` covers these paths anchored or not, which is
5895    /// exactly why it is the remediation. Break the wildcard instead: take the
5896    /// configured value literally in `PathRule::anchored_regex`, as
5897    /// `Regex::new(&format!("\\A(?:{})\\z", regex::escape(value)))`. Every
5898    /// denied path below then falls through to `FALLBACK`.
5899    #[test]
5900    fn widening_a_deny_path_regex_with_a_trailing_wildcard_covers_the_subtree_again() {
5901        let mut router = Router::new();
5902        let mut deny = test_http_frontend();
5903        deny.hostname = "www.example.com".to_owned();
5904        deny.cluster_id = Some("DENIED".into());
5905        deny.path = CommandPathRule::regex("/admin.*".to_owned());
5906        deny.method = Some("GET".to_owned());
5907        deny.redirect = Some(RedirectPolicy::Unauthorized as i32);
5908        router
5909            .add_http_front(&deny)
5910            .expect("the widened deny frontend must register");
5911        assert!(add_path_rule(
5912            &mut router,
5913            PathRule::Prefix("/".to_owned()),
5914            GET,
5915            "FALLBACK",
5916        ));
5917
5918        let resolve = |path: &str| {
5919            router
5920                .lookup("www.example.com", path, &Method::Get)
5921                .expect("every path below is covered by the `/` prefix rule")
5922        };
5923
5924        for denied in ["/admin", "/admin/secret", "/administrator"] {
5925            assert_eq!(
5926                resolve(denied).redirect,
5927                RedirectPolicy::Unauthorized,
5928                "the widened deny must cover {denied} again",
5929            );
5930        }
5931        assert_eq!(
5932            resolve("/other").cluster_id.as_deref(),
5933            Some("FALLBACK"),
5934            "widening the deny must not turn it into a catch-all",
5935        );
5936        assert_eq!(
5937            resolve("/other").redirect,
5938            RedirectPolicy::Forward,
5939            "a path outside the widened family must still be forwarded",
5940        );
5941    }
5942
5943    /// The matched request path is the request-target as it arrives, QUERY
5944    /// STRING INCLUDED, so anchoring bites a query string the way it already
5945    /// bit `path_type = "EQUALS"`. A trailing `.*` answers that only for a
5946    /// pattern open at the END: after a `$` it is dead text, because nothing
5947    /// can follow end-of-text. A suffix rule that must tolerate a query string
5948    /// has to spell it out.
5949    ///
5950    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5951    /// make the body `Regex::new(value).ok()`. `"/abc"` against `"/abc?x=1"`
5952    /// fails first, and `".*\.js([?].*)?"` against `"/static/app.json"` fails
5953    /// with it, the optional group matching empty after a substring `.js`.
5954    #[test]
5955    fn an_anchored_path_regex_must_span_the_query_string_it_is_matched_against() {
5956        assert_regex_routing(&[
5957            ("/abc", "/abc", true),
5958            ("/abc", "/abc?x=1", false),
5959            // Appending `.*` after a `$` does nothing at all.
5960            ("^/abc$.*", "/abc", true),
5961            ("^/abc$.*", "/abc?x=1", false),
5962            // What a suffix rule surviving a query string has to look like.
5963            (r".*\.js([?].*)?", "/static/app.js", true),
5964            (r".*\.js([?].*)?", "/static/app.js?v=2", true),
5965            (r".*\.js([?].*)?", "/static/app.json", false),
5966        ]);
5967
5968        // "not a regression": the plain suffix rule did not match a
5969        // query-string request before the anchoring either. Measured against
5970        // the BARE, unwrapped regex — the 2.2.1 behaviour — so this row is a
5971        // control and does not move when `anchored_regex` does.
5972        assert!(
5973            !Regex::new(r"\.js$")
5974                .expect("the bare suffix pattern compiles")
5975                .is_match(b"/static/app.js?v=2"),
5976            "the unanchored `\\.js$` of 2.2.1 did not match a query-string \
5977             request either, so that half is not a regression",
5978        );
5979    }
5980
5981    /// Precedence between a method-less `REGEX` and a method-less `PREFIX`
5982    /// turns on whether the regex matches the request path AT ALL, and
5983    /// anchoring changes that once a query string is involved.
5984    ///
5985    /// `doc/configure.md`'s third table row is the `/a.*` case: `GET /abc`
5986    /// goes to the prefix, and `GET /abc?x=1` flips to the regex, because
5987    /// `/abc` no longer spans the request path while `/a.*` still does. With
5988    /// the regex `/abc`, which anchored does not match `/abc?x=1` at all, the
5989    /// flip does NOT happen and both requests go to the prefix — the prefix
5990    /// has merely lost its rival. The regex did not lose the contest, it
5991    /// stopped entering it: declared alone it leaves `GET /abc?x=1` unrouted.
5992    /// That distinction is stated in prose and is what these six cases pin.
5993    ///
5994    /// Both rules are method-less and the regex is declared FIRST, the exact
5995    /// shape the documented table measures; the regex is built through
5996    /// `PathRule::from_config`, so the anchoring is in play.
5997    ///
5998    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5999    /// make the body `Regex::new(value).ok()`. The unanchored `/abc` matches
6000    /// `/abc?x=1` as a substring and becomes a candidate again: the
6001    /// both-go-to-the-prefix case fails with
6002    /// `left: Some("REGEX"), right: Some("PREFIX")` and the declared-alone
6003    /// case fails with `left: Some("REGEX"), right: None`.
6004    #[test]
6005    fn an_anchored_regex_that_cannot_span_the_query_string_stops_being_a_candidate() {
6006        let configured = |pattern: &str| {
6007            PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
6008                .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"))
6009        };
6010        let against_the_whole_path_prefix = |pattern: &str, path: &str| {
6011            winner_of(
6012                (configured(pattern), NO_METHOD, "REGEX"),
6013                (PathRule::Prefix("/abc".to_owned()), NO_METHOD, "PREFIX"),
6014                path,
6015            )
6016        };
6017
6018        // A regex that spans the query string: the documented flip, unchanged.
6019        assert_eq!(
6020            against_the_whole_path_prefix("/a.*", "/abc").as_deref(),
6021            Some("PREFIX"),
6022            "the whole-path prefix declared after the regex must still win",
6023        );
6024        assert_eq!(
6025            against_the_whole_path_prefix("/a.*", "/abc?x=1").as_deref(),
6026            Some("REGEX"),
6027            "with a query string `/abc` no longer spans the request path, so \
6028             a regex that does wins",
6029        );
6030
6031        // A regex that does not span it: the flip disappears entirely.
6032        assert_eq!(
6033            against_the_whole_path_prefix("/abc", "/abc").as_deref(),
6034            Some("PREFIX"),
6035            "the prefix wins here exactly as it does against `/a.*`",
6036        );
6037        assert_eq!(
6038            against_the_whole_path_prefix("/abc", "/abc?x=1").as_deref(),
6039            Some("PREFIX"),
6040            "an anchored `/abc` cannot match `/abc?x=1`, so adding a query \
6041             string changes nothing and the prefix keeps the request",
6042        );
6043
6044        // …and the reason is candidacy, not defeat.
6045        assert_eq!(
6046            regex_rule_routes("/abc", "/abc").as_deref(),
6047            Some("REGEX"),
6048            "declared alone, the regex answers the request it spells",
6049        );
6050        assert_eq!(
6051            regex_rule_routes("/abc", "/abc?x=1"),
6052            None,
6053            "declared alone, it leaves the query-string request UNROUTED — it \
6054             is not a candidate any more",
6055        );
6056    }
6057
6058    /// Every branch of an alternation in a regex hostname SEGMENT is
6059    /// anchored, not just the first and the last.
6060    ///
6061    /// This test is the INVERSION of the test formerly named
6062    /// `an_alternating_regex_hostname_segment_is_still_anchored_at_one_end_only`
6063    /// — a name no longer in the tree, this one having replaced it —
6064    /// which pinned the defect so that closing it would be "a deliberate
6065    /// change, not a silent one" and asked, in its own comment, to be
6066    /// inverted rather than deleted when the fix landed. This is that
6067    /// deliberate change (sozu#1356), and the old name is kept here so the
6068    /// behaviour change leaves a trace.
6069    ///
6070    /// `pattern_trie.rs`'s `anchored_segment` now builds
6071    /// `\A(?:{s})\z`. Ungrouped, `|` binds looser than concatenation, so
6072    /// `\Aa|b\z` parsed as `(\Aa)|(b\z)`: a label merely STARTING with `a`
6073    /// or merely ENDING with `b` reached the frontend.
6074    ///
6075    /// Two branches cannot show what this test claims, because with two
6076    /// branches "first and last" is every branch there is. The three-branch
6077    /// case below has a MIDDLE branch, which ungrouped keeps NEITHER anchor
6078    /// and matches as a bare substring anywhere in the label — strictly
6079    /// worse than the half-anchored ends.
6080    ///
6081    /// To SEE THIS RED: drop the group in `pattern_trie.rs`'s
6082    /// `anchored_segment` — build `format!("\\A{segment}\\z")`, the form
6083    /// this tree carried up to 2.2.1, and relax the `debug_assert!` beside it,
6084    /// which in a debug build fires first. `a.example.com` still resolves,
6085    /// and the `axx.example.com` assertion fails with
6086    /// `left: Some("ALTERNATION"), right: None`.
6087    #[test]
6088    fn an_alternating_regex_hostname_segment_is_anchored_on_every_branch() {
6089        let resolve_with = |pattern: &[u8], hostname: &str| {
6090            let mut router = Router::new();
6091            assert!(router.add_tree_rule(
6092                pattern,
6093                &PathRule::Prefix("/".to_owned()),
6094                &MethodRule::new(Some("GET".to_owned())),
6095                &Route::ClusterId("ALTERNATION".into()),
6096            ));
6097            router
6098                .lookup(hostname, "/", &Method::Get)
6099                .ok()
6100                .and_then(|result| result.cluster_id)
6101        };
6102
6103        // Two branches: each one keeps BOTH anchors now.
6104        assert_eq!(
6105            resolve_with(b"/a|b/.example.com", "a.example.com").as_deref(),
6106            Some("ALTERNATION"),
6107            "the segment must still match what it was written for",
6108        );
6109        assert_eq!(
6110            resolve_with(b"/a|b/.example.com", "b.example.com").as_deref(),
6111            Some("ALTERNATION"),
6112            "and the other branch too",
6113        );
6114        assert_eq!(
6115            resolve_with(b"/a|b/.example.com", "axx.example.com"),
6116            None,
6117            "the first branch must be anchored at its END too",
6118        );
6119        assert_eq!(
6120            resolve_with(b"/a|b/.example.com", "xxb.example.com"),
6121            None,
6122            "the last branch must be anchored at its START too",
6123        );
6124        assert_eq!(
6125            resolve_with(b"/a|b/.example.com", "xx.example.com"),
6126            None,
6127            "a label matching neither branch must still miss",
6128        );
6129
6130        // Three branches. `zzbzz` is the case two branches cannot express:
6131        // ungrouped, the MIDDLE branch carries no anchor at either end, so
6132        // the bare label `b` matches in the middle of any label at all.
6133        for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6134            assert_eq!(
6135                resolve_with(b"/a|b|c/.example.com", whole).as_deref(),
6136                Some("ALTERNATION"),
6137                "{whole} is a whole-label match for one branch and must route",
6138            );
6139        }
6140        for leak in [
6141            "axx.example.com",
6142            "xxc.example.com",
6143            "zzbzz.example.com",
6144            "bzz.example.com",
6145            "zzb.example.com",
6146        ] {
6147            assert_eq!(
6148                resolve_with(b"/a|b|c/.example.com", leak),
6149                None,
6150                "{leak} matches no branch WHOLE and must not route",
6151            );
6152        }
6153
6154        // The control for all of the above, built by hand against the
6155        // `regex` crate rather than through the trie: the UNGROUPED wrapper
6156        // — the form this tree carried up to 2.2.1 — leaves the middle
6157        // branch anchored at neither end. No mutation of `anchored_segment`
6158        // can move this assertion, which is the point: it measures why the
6159        // group is there.
6160        let two = Regex::new("\\Aa|b\\z").expect("the ungrouped wrapper compiles");
6161        for anywhere in ["axx", "xxb"] {
6162            assert!(
6163                two.is_match(anywhere.as_bytes()),
6164                "{} leaks on {anywhere}",
6165                two.as_str(),
6166            );
6167        }
6168        let ungrouped = Regex::new("\\Aa|b|c\\z").expect("the ungrouped wrapper compiles");
6169        for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6170            assert!(
6171                ungrouped.is_match(anywhere.as_bytes()),
6172                "{} leaks on {anywhere}",
6173                ungrouped.as_str(),
6174            );
6175        }
6176        let grouped = Regex::new("\\A(?:a|b|c)\\z").expect("the grouped wrapper compiles");
6177        for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6178            assert!(
6179                !grouped.is_match(anywhere.as_bytes()),
6180                "{} must refuse {anywhere}",
6181                grouped.as_str(),
6182            );
6183        }
6184    }
6185
6186    /// Declare one regex hostname frontend the way a configured frontend
6187    /// declares it — through `Router::add_http_front`, so the hostname goes
6188    /// through `DomainRule::from_str` and `convert_regex_domain_rule` — and
6189    /// resolve `hostname`.
6190    ///
6191    /// `position` picks the code path, and the two are genuinely different
6192    /// code: `Pre`/`Post` match the WHOLE host against the single regex
6193    /// `convert_regex_domain_rule` assembles, while `Tree` matches label by
6194    /// label against `pattern_trie`'s per-segment regexes.
6195    fn regex_host_routes(position: RulePosition, pattern: &str, hostname: &str) -> Option<String> {
6196        let mut front = test_http_frontend();
6197        front.hostname = pattern.to_owned();
6198        front.position = position;
6199        front.cluster_id = Some("REGEX-HOST".into());
6200        let mut router = Router::new();
6201        router
6202            .add_http_front(&front)
6203            .unwrap_or_else(|error| panic!("the regex hostname {pattern:?} must build: {error}"));
6204        router
6205            .lookup(hostname, "/", &Method::Get)
6206            .ok()
6207            .and_then(|result| result.cluster_id)
6208            .map(|cluster_id| cluster_id.to_string())
6209    }
6210
6211    /// The companion of
6212    /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`
6213    /// for the OTHER hostname code path: a pre/post rule never touches the
6214    /// trie, it matches the whole host against the single regex
6215    /// `convert_regex_domain_rule` assembles, and that assembly carried the
6216    /// same ungrouped `\A` … `\z` (sozu#1356).
6217    ///
6218    /// The exact assembled pattern is asserted, so "grouped" is pinned and
6219    /// not merely implied by the routing verdicts.
6220    ///
6221    /// Two branches cannot show what this test claims — with two branches
6222    /// "first and last" is every branch. The three-branch case has a MIDDLE
6223    /// branch, which ungrouped keeps NEITHER anchor: it is a bare `b`,
6224    /// matching any hostname on earth that contains a `b`, including one on
6225    /// a different registrable domain entirely.
6226    ///
6227    /// To SEE THIS RED: drop the group in `convert_regex_domain_rule` —
6228    /// replace the `push_str`/`push` calls in the `/` arm with the single
6229    /// `result.push_str(r)` this tree carried up to 2.2.1. The
6230    /// `axx.example.com` row then fails with
6231    /// `left: Some("REGEX-HOST"), right: None`, and the exact-pattern
6232    /// assertion below it with
6233    /// `left: "\\Aa|b|c\\.example\\.com\\z", right: "\\A(?:a|b|c)\\.example\\.com\\z"`.
6234    #[test]
6235    fn every_branch_of_an_alternating_regex_hostname_rule_is_anchored() {
6236        for position in [RulePosition::Pre, RulePosition::Post] {
6237            for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6238                assert_eq!(
6239                    regex_host_routes(position, "/a|b|c/.example.com", whole).as_deref(),
6240                    Some("REGEX-HOST"),
6241                    "{position:?}: {whole} is a whole-host match for one branch and must route",
6242                );
6243            }
6244            for leak in [
6245                "axx.example.com",
6246                "xxc.example.com",
6247                "zzbzz.example.com",
6248                "b.example.com.evil.org",
6249                "zzbzz.evil.org",
6250            ] {
6251                assert_eq!(
6252                    regex_host_routes(position, "/a|b|c/.example.com", leak),
6253                    None,
6254                    "{position:?}: {leak} matches no branch WHOLE and must not route",
6255                );
6256            }
6257        }
6258
6259        // The exact assembled pattern, so "grouped" is asserted and not
6260        // merely implied by the routing verdicts above.
6261        assert_eq!(
6262            convert_regex_domain_rule("/a|b|c/.example.com")
6263                .expect("an alternating hostname segment must convert"),
6264            "\\A(?:a|b|c)\\.example\\.com\\z",
6265            "each regex segment must be wrapped in a NON-capturing group",
6266        );
6267
6268        // The control, built by hand against the `regex` crate rather than
6269        // through `convert_regex_domain_rule`: the UNGROUPED assembly — the
6270        // form this tree carried up to 2.2.1 — parses as
6271        // `(\Aa)|(b)|(c\.example\.com\z)`. The middle branch is a bare `b`,
6272        // so it matches a host on an unrelated registrable domain. No
6273        // mutation of `convert_regex_domain_rule` can move these
6274        // assertions, which is the point: they measure why the group is
6275        // there.
6276        for (assembly, leaks) in [
6277            (
6278                "\\Aa|b|c\\.example\\.com\\z",
6279                &[
6280                    "axx.example.com",
6281                    "zzbzz.example.com",
6282                    "b.example.com.evil.org",
6283                    "zzbzz.evil.org",
6284                ][..],
6285            ),
6286            (
6287                "\\Aapi|admin\\.example\\.com\\z",
6288                &["apifoo.example.com", "xadmin.example.com"][..],
6289            ),
6290        ] {
6291            let ungrouped = Regex::new(assembly).expect("the ungrouped assembly compiles");
6292            for anywhere in leaks {
6293                assert!(
6294                    ungrouped.is_match(anywhere.as_bytes()),
6295                    "{} leaks on {anywhere}",
6296                    ungrouped.as_str(),
6297                );
6298            }
6299        }
6300
6301        // `doc/configure.md`'s own worked example, asserted here because it
6302        // is written there as a measurement: the frontend
6303        // `/api|admin/.example.com` used to also serve `apifoo.example.com`
6304        // and `xadmin.example.com`, and the remediation the same paragraph
6305        // gives has to keep working.
6306        for (pattern, hostname, must_route) in [
6307            ("/api|admin/.example.com", "api.example.com", true),
6308            ("/api|admin/.example.com", "admin.example.com", true),
6309            ("/api|admin/.example.com", "apifoo.example.com", false),
6310            ("/api|admin/.example.com", "xadmin.example.com", false),
6311            ("/api.*|.*admin/.example.com", "api.example.com", true),
6312            ("/api.*|.*admin/.example.com", "admin.example.com", true),
6313            ("/api.*|.*admin/.example.com", "apifoo.example.com", true),
6314            ("/api.*|.*admin/.example.com", "xadmin.example.com", true),
6315        ] {
6316            assert_eq!(
6317                regex_host_routes(RulePosition::Pre, pattern, hostname).is_some(),
6318                must_route,
6319                "hostname regex {pattern:?} against {hostname:?}",
6320            );
6321        }
6322
6323        // `captures_len` must be untouched by the wrapping — see
6324        // `a_host_capture_rewrite_resolves_to_the_operators_own_group` for
6325        // the consequence when it is not.
6326        let compiled = |pattern: &str| {
6327            Regex::new(&convert_regex_domain_rule(pattern).expect("the hostname must convert"))
6328                .expect("the assembled hostname regex must compile")
6329        };
6330        assert_eq!(
6331            compiled("/a|b|c/.example.com").captures_len(),
6332            1,
6333            "a pattern with no operator group must stay at the implicit group 0 alone",
6334        );
6335        assert_eq!(
6336            compiled("/cdn([0-9]+)/.example.com").captures_len(),
6337            2,
6338            "the operator's own group must stay at index 1",
6339        );
6340    }
6341
6342    /// A `*` label in a hostname that ALSO carries a regex segment means two
6343    /// different things depending on the rule position, and
6344    /// `doc/configure.md` § "Regex hostname segments" now carries the table
6345    /// this test asserts.
6346    ///
6347    /// On the trie a `*` label is the ordinary single-label wildcard: the
6348    /// trie splits on `.` and `TrieNode` stores it in its `wildcard` slot,
6349    /// untouched by anything here. On `Pre`/`Post` the whole host is matched
6350    /// by the single pattern `convert_regex_domain_rule` assembles, where the
6351    /// `*` is now a LITERAL — the sozu#1356 escaping. Up to and including
6352    /// 2.2.1 it was neither: an unescaped `*` was a regex quantifier over the
6353    /// preceding atom, which for a leftmost label is the opening `\A`, so the
6354    /// pattern was not anchored at its start and took any host ending in
6355    /// `.x.example.com` at any depth.
6356    ///
6357    /// The remediation the section gives for `Pre`/`Post` is asserted too,
6358    /// because a documented remediation that does not work is worse than
6359    /// none.
6360    ///
6361    /// To SEE THIS RED: drop the escaping in the literal arm of
6362    /// `convert_regex_domain_rule` — push `r` instead of `&regex::escape(r)`
6363    /// at both exits. The `Pre` row for `zz.x.example.com` then routes and
6364    /// fails with `left: true, right: false`. (That mutation also reddens
6365    /// `a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern` and
6366    /// `convert_regex`.)
6367    #[test]
6368    fn a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post() {
6369        // The table in `doc/configure.md`, both rows, all three positions.
6370        for (position, host, must_route) in [
6371            (RulePosition::Tree, "zz.x.example.com", true),
6372            (RulePosition::Tree, "*.x.example.com", true),
6373            (RulePosition::Pre, "zz.x.example.com", false),
6374            (RulePosition::Pre, "*.x.example.com", true),
6375            (RulePosition::Post, "zz.x.example.com", false),
6376            (RulePosition::Post, "*.x.example.com", true),
6377        ] {
6378            assert_eq!(
6379                regex_host_routes(position, "*./x/.example.com", host).is_some(),
6380                must_route,
6381                "{position:?}: `*./x/.example.com` against {host:?}",
6382            );
6383        }
6384
6385        // Common to every position, and the reason the trie row is a
6386        // wildcard and not a free-for-all: `*` is ONE label.
6387        for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
6388            for miss in ["x.example.com", "a.b.x.example.com"] {
6389                assert_eq!(
6390                    regex_host_routes(position, "*./x/.example.com", miss),
6391                    None,
6392                    "{position:?}: a `*` label is exactly one label, so {miss:?} misses",
6393                );
6394            }
6395        }
6396
6397        // What 2.2.1 did on `Pre`/`Post`, built by hand against the crate:
6398        // `\A*` repeats a zero-width assertion, so the pattern was not
6399        // anchored at its start. No mutation of the escaping can move this.
6400        let quantified =
6401            Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6402        for anywhere in [
6403            "evil.attacker.x.example.com",
6404            "anything.at.all.x.example.com",
6405            "zzz.x.example.com",
6406        ] {
6407            assert!(
6408                quantified.is_match(anywhere.as_bytes()),
6409                "{} took {anywhere} at any depth",
6410                quantified.as_str(),
6411            );
6412        }
6413        assert!(
6414            !quantified.is_match(b"x.example.com"),
6415            "{} still required a leading label",
6416            quantified.as_str(),
6417        );
6418
6419        // The remediation `doc/configure.md` gives for `Pre`/`Post`: a regex
6420        // segment spelling the single label out.
6421        for position in [RulePosition::Pre, RulePosition::Post] {
6422            for (host, must_route) in [
6423                ("zz.x.example.com", true),
6424                ("*.x.example.com", true),
6425                ("a.b.x.example.com", false),
6426                ("x.example.com", false),
6427            ] {
6428                assert_eq!(
6429                    regex_host_routes(position, "/[^.]*/./x/.example.com", host).is_some(),
6430                    must_route,
6431                    "{position:?}: the documented `Pre`/`Post` remediation against {host:?}",
6432                );
6433            }
6434        }
6435    }
6436
6437    /// sozu#633: `hostname = "*"` with `position = "POST"` is the catch-all
6438    /// `doc/configure.md` documents — `DomainRule::Any`, which matches every
6439    /// host, an IP literal or a single-label name included — while the same
6440    /// `*` on the trie (the default position) is the one-label wildcard and
6441    /// matches only a single-label host. The last block pins that a `Post`
6442    /// rule is consulted only once the trie matched nothing.
6443    ///
6444    /// To SEE THIS RED: in `DomainRule::from_str`, change the `s == "*"`
6445    /// test to `s == "**"`. A bare `*` then falls through to the literal
6446    /// arm, and the `127.0.0.1` row fails with `left: None, right:
6447    /// Some("REGEX-HOST")`.
6448    #[test]
6449    fn a_bare_star_hostname_is_a_catch_all_on_post_and_one_label_on_the_trie() {
6450        for host in [
6451            "127.0.0.1",
6452            "localhost",
6453            "penguin.linux.test",
6454            "10.0.0.7",
6455            "www.example.com",
6456        ] {
6457            assert_eq!(
6458                regex_host_routes(RulePosition::Post, "*", host).as_deref(),
6459                Some("REGEX-HOST"),
6460                "Post: `*` is the catch-all and must route {host:?}",
6461            );
6462        }
6463
6464        for (host, must_route) in [
6465            ("localhost", true),
6466            ("127.0.0.1", false),
6467            ("penguin.linux.test", false),
6468            ("www.example.com", false),
6469        ] {
6470            assert_eq!(
6471                regex_host_routes(RulePosition::Tree, "*", host).is_some(),
6472                must_route,
6473                "Tree: `*` is exactly one label, against {host:?}",
6474            );
6475        }
6476
6477        let mut router = Router::new();
6478        for (hostname, position, cluster_id) in [
6479            ("*", RulePosition::Post, "CATCH-ALL"),
6480            ("www.example.com", RulePosition::Tree, "EXAMPLE"),
6481        ] {
6482            let mut front = test_http_frontend();
6483            front.hostname = hostname.to_owned();
6484            front.position = position;
6485            front.cluster_id = Some(cluster_id.into());
6486            router
6487                .add_http_front(&front)
6488                .unwrap_or_else(|error| panic!("{hostname:?} must build: {error}"));
6489        }
6490        for (host, expected) in [
6491            ("www.example.com", "EXAMPLE"),
6492            ("127.0.0.1", "CATCH-ALL"),
6493            ("api.example.com", "CATCH-ALL"),
6494        ] {
6495            assert_eq!(
6496                router
6497                    .lookup(host, "/", &Method::Get)
6498                    .ok()
6499                    .and_then(|result| result.cluster_id)
6500                    .as_deref(),
6501                Some(expected),
6502                "a `Post` catch-all answers only what the trie did not: {host:?}",
6503            );
6504        }
6505    }
6506
6507    /// A `.*` inside a regex segment does not stop at the label boundary, and
6508    /// where it stops depends on the rule POSITION — the two hostname paths
6509    /// genuinely differ here. A trie rule matches each segment against one
6510    /// label, so `.*` cannot leave it. A `Pre`/`Post` rule is matched by the
6511    /// single whole-host pattern `convert_regex_domain_rule` assembles, in
6512    /// which `.` matches the `.` separator like any other character.
6513    ///
6514    /// Pre-existing and NOT introduced by the anchoring, but
6515    /// `doc/configure.md` walks operators into it: the remediation it gives
6516    /// for the narrowing is exactly a `.*`. The section now names the
6517    /// divergence and recommends `[^.]*` on `Pre`/`Post`, so all three
6518    /// measurements it makes are pinned here.
6519    ///
6520    /// To SEE THIS RED: escape the REGEX-segment arm of
6521    /// `convert_regex_domain_rule` as well as the literal arm — push
6522    /// `&regex::escape(r)` in the `/` arm too. Every `Pre` row then goes
6523    /// false, starting with `api.example.com`, because the segment stops
6524    /// being a pattern at all.
6525    #[test]
6526    fn a_wildcard_in_a_hostname_segment_crosses_labels_on_pre_and_post_only() {
6527        // Trie: one segment, one label. `.*` cannot escape the label.
6528        for (hostname, must_route) in [
6529            ("api.example.com", true),
6530            ("admin.example.com", true),
6531            ("apifoo.example.com", true),
6532            ("api.foo.example.com", false),
6533            ("zz.admin.example.com", false),
6534        ] {
6535            assert_eq!(
6536                regex_host_routes(RulePosition::Tree, "/api.*|.*admin/.example.com", hostname)
6537                    .is_some(),
6538                must_route,
6539                "trie rule `/api.*|.*admin/` against {hostname:?}",
6540            );
6541        }
6542
6543        // Pre/Post: one whole-host pattern, in which `.` matches the
6544        // separator. The same rule reaches subdomains the operator did not
6545        // write. This is the clause `doc/configure.md` now carries.
6546        for position in [RulePosition::Pre, RulePosition::Post] {
6547            for hostname in [
6548                "api.example.com",
6549                "admin.example.com",
6550                "apifoo.example.com",
6551                "api.foo.example.com",
6552                "zz.admin.example.com",
6553            ] {
6554                assert!(
6555                    regex_host_routes(position, "/api.*|.*admin/.example.com", hostname).is_some(),
6556                    "{position:?}: `.*` spans the label separator, so {hostname:?} routes",
6557                );
6558            }
6559        }
6560
6561        // ... and the remediation that section recommends for `Pre`/`Post`.
6562        for position in [RulePosition::Pre, RulePosition::Post] {
6563            for (hostname, must_route) in [
6564                ("api.example.com", true),
6565                ("admin.example.com", true),
6566                ("apifoo.example.com", true),
6567                ("xadmin.example.com", true),
6568                ("api.foo.example.com", false),
6569                ("zz.admin.example.com", false),
6570            ] {
6571                assert_eq!(
6572                    regex_host_routes(position, "/api[^.]*|[^.]*admin/.example.com", hostname,)
6573                        .is_some(),
6574                    must_route,
6575                    "{position:?}: `[^.]*` spells the boundary out, {hostname:?}",
6576                );
6577            }
6578        }
6579    }
6580
6581    /// The LITERAL-label arm of `convert_regex_domain_rule` split the anchors
6582    /// exactly as the regex-segment arm did, one `else` away from it: a label
6583    /// the operator never wrapped in slashes was pushed verbatim into the
6584    /// assembly, so a `|` in it was a live alternation. Measured on the tree
6585    /// as it stood, `/a/.x|y.com` assembled to `\A(?:a)\.x|y\.com\z` —
6586    /// `(\A(?:a)\.x)|(y\.com\z)` — and matched `a.xZZZ` and `ZZZy.com`.
6587    ///
6588    /// The fix is `regex::escape`, not a group. A literal label is a literal,
6589    /// so grouping would confine the alternation while leaving `+`, `?`, `*`,
6590    /// `^` and `$` live inside the same arm — `b+c` matching `bbbc` is the
6591    /// same defect with a different operator. Escaping is a no-op in matching
6592    /// behaviour for every label a real hostname can carry: an LDH label is
6593    /// `[A-Za-z0-9-]`, `escape` touches only the `-`, and `\-` is `-` outside
6594    /// a character class. That parity is asserted below rather than asserted
6595    /// of the hostname grammar in the abstract.
6596    ///
6597    /// To SEE THIS RED: drop the escaping in the literal arm of
6598    /// `convert_regex_domain_rule` — push `r` instead of `&regex::escape(r)`
6599    /// at both of its exits. The `a.xZZZ` row then fails with
6600    /// `left: true, right: false`. That mutation reddens THREE tests, not
6601    /// just this one: `convert_regex`'s two `*` rows go back to the
6602    /// unescaped spelling, and
6603    /// `a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post`
6604    /// loses the `Pre`/`Post` half of its table.
6605    #[test]
6606    fn a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern() {
6607        // The three routing verdicts the defect turned on, through the
6608        // frontend surface that carries it.
6609        for (hostname, must_route) in [("a.x|y.com", true), ("a.xZZZ", false), ("ZZZy.com", false)]
6610        {
6611            assert_eq!(
6612                regex_host_routes(RulePosition::Pre, "/a/.x|y.com", hostname).is_some(),
6613                must_route,
6614                "literal label `x|y` against {hostname:?}",
6615            );
6616        }
6617        assert_eq!(
6618            convert_regex_domain_rule("/a/.x|y.com").expect("the hostname must convert"),
6619            "\\A(?:a)\\.x\\|y\\.com\\z",
6620            "a literal label must be escaped, not grouped and not pushed raw",
6621        );
6622
6623        // The control, built by hand: the UNESCAPED assembly is what leaked.
6624        // No mutation of `convert_regex_domain_rule` can move this.
6625        let unescaped = Regex::new("\\A(?:a)\\.x|y\\.com\\z").expect("the raw assembly compiles");
6626        for anywhere in ["a.xZZZ", "ZZZy.com"] {
6627            assert!(
6628                unescaped.is_match(anywhere.as_bytes()),
6629                "{} leaks on {anywhere}",
6630                unescaped.as_str(),
6631            );
6632        }
6633
6634        // Escaping must be inert for every label a hostname can actually
6635        // carry. `-` is the only LDH byte `regex::escape` touches, and `\-`
6636        // is `-` outside a character class.
6637        assert_eq!(
6638            convert_regex_domain_rule("/a/.my-host01.example.com")
6639                .expect("an LDH hostname must convert"),
6640            "\\A(?:a)\\.my\\-host01\\.example\\.com\\z",
6641            "escape only adds a backslash before the `-`",
6642        );
6643        for (hostname, must_route) in [
6644            ("a.my-host01.example.com", true),
6645            ("a.myXhost01.example.com", false),
6646            ("a.my-host01.example.comZZZ", false),
6647        ] {
6648            assert_eq!(
6649                regex_host_routes(RulePosition::Pre, "/a/.my-host01.example.com", hostname)
6650                    .is_some(),
6651                must_route,
6652                "escaped LDH label against {hostname:?}",
6653            );
6654        }
6655
6656        // Grouping instead of escaping would have closed the `|` only. This
6657        // is why the arms are treated differently, measured rather than
6658        // argued: the same three quantifiers stay live under a group.
6659        let grouped_not_escaped =
6660            Regex::new("\\A(?:a)\\.(?:b+c)\\.com\\z").expect("the grouped assembly compiles");
6661        assert!(
6662            grouped_not_escaped.is_match(b"a.bbbc.com"),
6663            "{} still repeats the literal `b`",
6664            grouped_not_escaped.as_str(),
6665        );
6666        for (hostname, must_route) in [("a.b+c.com", true), ("a.bbbc.com", false)] {
6667            assert_eq!(
6668                regex_host_routes(RulePosition::Pre, "/a/.b+c.com", hostname).is_some(),
6669                must_route,
6670                "escaped literal label `b+c` against {hostname:?}",
6671            );
6672        }
6673
6674        // Two deliberate behaviour changes, both narrowing, both stated in
6675        // `convert_regex_domain_rule`'s doc comment.
6676        //
6677        // (1) A literal `*` label was a quantifier over the preceding atom.
6678        // `\A*` repeats a zero-width assertion, which matches empty at any
6679        // offset, so the pattern was not anchored at its start at all.
6680        let star_unescaped =
6681            Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6682        assert!(
6683            star_unescaped.is_match(b"evil.attacker.x.example.com"),
6684            "{} is not anchored at its start",
6685            star_unescaped.as_str(),
6686        );
6687        assert_eq!(
6688            regex_host_routes(
6689                RulePosition::Pre,
6690                "*./x/.example.com",
6691                "evil.attacker.x.example.com",
6692            ),
6693            None,
6694            "a literal `*` label must no longer leave the pattern unanchored",
6695        );
6696        assert_eq!(
6697            regex_host_routes(RulePosition::Pre, "*./x/.example.com", "*.x.example.com").as_deref(),
6698            Some("REGEX-HOST"),
6699            "it matches the literal label the operator wrote, and only that",
6700        );
6701
6702        // (2) A literal label carrying an unbalanced `(` or `[` made the
6703        // assembly fail to compile and the frontend was REJECTED. It is now
6704        // accepted and matches exactly the host that was typed. Assembled at
6705        // run time because the unbalanced literal is the point.
6706        for bracket in ["(", "["] {
6707            let hostname = format!("/a/.b{bracket}c.com");
6708            assert!(
6709                convert_regex_domain_rule(&hostname).is_some(),
6710                "{hostname} must convert",
6711            );
6712            assert_eq!(
6713                regex_host_routes(RulePosition::Pre, &hostname, &format!("a.b{bracket}c.com"))
6714                    .as_deref(),
6715                Some("REGEX-HOST"),
6716                "{hostname} must match the literal host it spells",
6717            );
6718            assert_eq!(
6719                regex_host_routes(RulePosition::Pre, &hostname, "a.bc.com"),
6720                None,
6721                "{hostname} must match nothing else",
6722            );
6723        }
6724    }
6725
6726    /// `$HOST[n]` rewrite indices are read from `caps.iter().skip(1)` with
6727    /// the buffer sized by `captures_len()`, so the anchoring wrapper has to
6728    /// be NON-capturing: a capturing `(` … `)` shifts every index by one and
6729    /// silently rewrites traffic to the wrong target — a wrong `Host` header
6730    /// and a wrong backend, with nothing in the configuration to show for it.
6731    ///
6732    /// Both hostname code paths are asserted, because they read the capture
6733    /// from two DIFFERENT regexes that have to agree: a pre/post rule reads
6734    /// the whole-host regex `convert_regex_domain_rule` assembles
6735    /// (`RouteResult::new_no_trie`), a tree rule reads `pattern_trie`'s
6736    /// per-segment regex (`RouteResult::new_with_trie`) — while
6737    /// `capture_cap_host`, which bounds the template, is taken from the
6738    /// whole-host one in BOTH cases.
6739    ///
6740    /// To SEE THIS RED: make either wrapper capturing — `result.push_str("(")`
6741    /// instead of `"(?:"` in `convert_regex_domain_rule`, or
6742    /// `format!("\\A({segment})\\z")` in `pattern_trie.rs`'s
6743    /// `anchored_segment`. `$HOST[1]` then resolves to the whole segment
6744    /// `cdn42` instead of the operator's own group `42`, failing with
6745    /// `left: Some("cdn42.internal"), right: Some("42.internal")`.
6746    #[test]
6747    fn a_host_capture_rewrite_resolves_to_the_operators_own_group() {
6748        let rewritten_host = |position: RulePosition, template: &str, hostname: &str| {
6749            let mut front = test_http_frontend();
6750            front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
6751            front.position = position;
6752            front.rewrite_host = Some(template.to_owned());
6753            let mut router = Router::new();
6754            router
6755                .add_http_front(&front)
6756                .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
6757            router
6758                .lookup(hostname, "/", &Method::Get)
6759                .ok()
6760                .and_then(|result| result.rewritten_host)
6761        };
6762
6763        for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
6764            assert_eq!(
6765                rewritten_host(position, "$HOST[1].internal", "cdn42.example.com").as_deref(),
6766                Some("42.internal"),
6767                "{position:?}: $HOST[1] must be the operator's own group, not the wrapper's",
6768            );
6769            assert_eq!(
6770                rewritten_host(position, "$HOST[0]", "cdn42.example.com").as_deref(),
6771                Some("cdn42.example.com"),
6772                "{position:?}: $HOST[0] is the whole hostname",
6773            );
6774        }
6775
6776        // The cap itself. `captures_len()` is 2, so index 2 does not exist
6777        // and the frontend must be refused loudly at registration rather
6778        // than rewriting to an empty string. A capturing wrapper would make
6779        // this index legal — which is exactly how a shifted index reaches
6780        // production unnoticed.
6781        let mut front = test_http_frontend();
6782        front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
6783        front.rewrite_host = Some("$HOST[2]".to_owned());
6784        assert!(
6785            matches!(
6786                Router::new().add_http_front(&front),
6787                Err(RouterError::InvalidHostRewrite(_)),
6788            ),
6789            "a $HOST index past captures_len must be refused at registration",
6790        );
6791    }
6792
6793    /// A tree lookup allocates nothing of its own: whatever host shape
6794    /// answers, the only heap work is building the [`RouteResult`] itself,
6795    /// which the control reproduces. The cluster id is not part of it: the
6796    /// result carries a handle on the legacy [`Route::ClusterId`] entry's
6797    /// reference-counted id, and so does the control.
6798    ///
6799    /// The three shapes cover every sink push the walk makes: a literal host
6800    /// records no segment, a wildcard host records one on its leaf, a regex
6801    /// host records one on the way down.
6802    ///
6803    /// TO SEE THIS RED: hand `lookup_with_path` a `Vec::with_capacity(16)`
6804    /// again in `Router::lookup`, one allocation per lookup on every shape;
6805    /// or build the legacy arm of `RouteResult::new_with_trie` from
6806    /// `ClusterId::from(&**id)` instead of `id.clone()`, one more.
6807    #[test]
6808    fn a_tree_lookup_allocates_nothing_past_its_route_result() {
6809        use std::hint::black_box;
6810
6811        use crate::test_allocations::allocations;
6812
6813        let mut router = Router::new();
6814        for hostname in [
6815            "literal.example.com",
6816            "*.wildcard.example.com",
6817            "/api[0-9]+/.regex.example.com",
6818        ] {
6819            let mut front = test_http_frontend();
6820            front.hostname = hostname.to_owned();
6821            router
6822                .add_http_front(&front)
6823                .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
6824        }
6825
6826        let cluster = ClusterId::from("cluster");
6827        let control = || {
6828            let before = allocations();
6829            let result = RouteResult::forward(black_box(&cluster).clone());
6830            let allocated = allocations() - before;
6831            drop(black_box(result));
6832            allocated
6833        };
6834        for host in [
6835            "literal.example.com",
6836            "foo.wildcard.example.com",
6837            "api42.regex.example.com",
6838        ] {
6839            // Warm-up: a regex builds its per-thread search cache on its
6840            // first match.
6841            drop(router.lookup(host, "/", &Method::Get));
6842            let before = allocations();
6843            let result = router.lookup(black_box(host), black_box("/"), &Method::Get);
6844            let allocated = allocations() - before;
6845            assert_eq!(
6846                result.ok().and_then(|result| result.cluster_id).as_deref(),
6847                Some("cluster"),
6848                "{host} must route to the test cluster",
6849            );
6850            assert_eq!(
6851                allocated,
6852                control(),
6853                "{host}: a tree lookup must allocate only its RouteResult",
6854            );
6855        }
6856    }
6857
6858    /// A trie walk records every non-literal segment it matched, in walk
6859    /// order, and `RouteResult::new_with_trie` reads that record back in
6860    /// reverse to number the `$HOST[n]` captures. The record keeps its first
6861    /// 16 entries on the stack; this hostname has 20 regex segments, so four
6862    /// spill past them, and every one of the 20 must still reach the
6863    /// template in the same position.
6864    ///
6865    /// The walk runs from the rightmost label, so the spilled entries are
6866    /// the four LEFTMOST segments, `s1` to `s4`, and the reversed read has to
6867    /// cross from the spill into the inline array. The expected order is
6868    /// left to right, the order a pre/post rule reads its whole-host regex
6869    /// in (sozu#1595); `a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule`
6870    /// holds the two positions against each other.
6871    #[test]
6872    fn a_host_capture_past_sixteen_trie_segments_keeps_every_segment() {
6873        const SEGMENTS: usize = 20;
6874        let hostname = format!(
6875            "{}.example.com",
6876            (1..=SEGMENTS)
6877                .map(|_| "/s([0-9]+)/")
6878                .collect::<Vec<_>>()
6879                .join(".")
6880        );
6881        let request = format!(
6882            "{}.example.com",
6883            (1..=SEGMENTS)
6884                .map(|segment| format!("s{segment}"))
6885                .collect::<Vec<_>>()
6886                .join(".")
6887        );
6888        let template = (1..=SEGMENTS)
6889            .map(|index| format!("$HOST[{index}]"))
6890            .collect::<Vec<_>>()
6891            .join("-");
6892
6893        let mut front = test_http_frontend();
6894        front.hostname = hostname;
6895        front.position = RulePosition::Tree;
6896        front.rewrite_host = Some(template);
6897        let mut router = Router::new();
6898        router
6899            .add_http_front(&front)
6900            .unwrap_or_else(|error| panic!("the tree frontend must build: {error}"));
6901
6902        let hostname_order = (1..=SEGMENTS)
6903            .map(|segment| segment.to_string())
6904            .collect::<Vec<_>>()
6905            .join("-");
6906        assert_eq!(
6907            router
6908                .lookup(&request, "/", &Method::Get)
6909                .ok()
6910                .and_then(|result| result.rewritten_host),
6911            Some(hostname_order),
6912            "every trie segment, inline or spilled, must reach $HOST[n] in hostname order",
6913        );
6914    }
6915
6916    /// The same hostname numbers its `$HOST[n]` captures the same way
6917    /// whichever position compiled it (sozu#1595). A pre/post rule reads
6918    /// one whole-host regex, so its groups come left to right; a tree rule
6919    /// matches one regex per label while the trie walks from the rightmost
6920    /// label, and `RouteResult::new_with_trie` renumbers them left to right.
6921    /// The second segment carries two groups, which keep their order inside
6922    /// the segment: reversing the flattened captures instead of the
6923    /// segments would be caught too.
6924    ///
6925    /// TO SEE THIS RED: drop the `.rev()` in `RouteResult::new_with_trie`;
6926    /// the tree rule then yields `x-9-1` where the pre/post rules yield
6927    /// `1-x-9`.
6928    #[test]
6929    fn a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule() {
6930        let rewritten_host = |position: RulePosition| {
6931            let mut front = test_http_frontend();
6932            front.hostname = "/a([0-9]+)/./([a-z])([0-9])/.example.com".to_owned();
6933            front.position = position;
6934            front.rewrite_host = Some("$HOST[1]-$HOST[2]-$HOST[3]".to_owned());
6935            let mut router = Router::new();
6936            router
6937                .add_http_front(&front)
6938                .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
6939            router
6940                .lookup("a1.x9.example.com", "/", &Method::Get)
6941                .ok()
6942                .and_then(|result| result.rewritten_host)
6943        };
6944
6945        for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
6946            assert_eq!(
6947                rewritten_host(position).as_deref(),
6948                Some("1-x-9"),
6949                "{position:?}: $HOST[n] must number the captures left to right",
6950            );
6951        }
6952    }
6953
6954    /// Grouping must not rescue a hostname the router rejects today. The
6955    /// wrapper supplies one `(` and one `)`, so an unbalanced segment can be
6956    /// balanced BY the wrapping — `a)(b` is not a regex, yet
6957    /// `\A(?:a)(b)\.example\.com\z` is, and it matches `ab.example.com`.
6958    /// Both hostname sites therefore compile the segment on its own before
6959    /// wrapping it, exactly as `PathRule::anchored_regex` does for a path.
6960    ///
6961    /// This covers that one direction. The converse is NOT guarded and is
6962    /// not asserted here: a segment that compiles bare can be rejected once
6963    /// wrapped. Such a hostname lands in `RouterError::InvalidDomain` at
6964    /// frontend registration, so it costs a loud rejection and not a
6965    /// mis-route.
6966    ///
6967    /// To SEE THIS RED: delete the `Regex::new(r).ok()?;` line in
6968    /// `convert_regex_domain_rule` — the first assertion then fails, the
6969    /// hostname builds, and `ab.example.com` routes. Delete the
6970    /// `Regex::new(segment).ok()?;` line in `pattern_trie.rs`'s
6971    /// `anchored_segment` instead and the `add_tree_rule` assertion fails.
6972    #[test]
6973    fn grouping_never_turns_a_rejected_hostname_regex_into_an_accepted_one() {
6974        // Assembled at run time rather than written as a literal because the
6975        // invalidity is the whole point and a literal invites a lint at the
6976        // `Regex::new` call site.
6977        let unbalanced = format!("/a{}b/.example.com", ")(");
6978
6979        assert_eq!(
6980            convert_regex_domain_rule(&unbalanced),
6981            None,
6982            "{unbalanced} carries a segment that is not a regex on its own",
6983        );
6984        assert_eq!(
6985            unbalanced.parse::<DomainRule>(),
6986            Err(()),
6987            "{unbalanced} must not become a usable DomainRule",
6988        );
6989        assert!(
6990            !Router::new().add_tree_rule(
6991                unbalanced.as_bytes(),
6992                &PathRule::Prefix("/".to_owned()),
6993                &MethodRule::new(Some("GET".to_owned())),
6994                &Route::ClusterId("RESCUED".into()),
6995            ),
6996            "{unbalanced} must not be storable in the trie either",
6997        );
6998
6999        // The hazard is real, not hypothetical: the wrapping alone turns it
7000        // into a live pattern matching a host the operator never wrote.
7001        let rescued = Regex::new(&format!("\\A(?:a{}b)\\.example\\.com\\z", ")("))
7002            .expect("the wrapping balances the segment");
7003        assert!(
7004            rescued.is_match(b"ab.example.com"),
7005            "{} is what the missing pre-validation would have built",
7006            rescued.as_str(),
7007        );
7008    }
7009
7010    /// One level up from the path rules, two overlapping regex HOSTNAME
7011    /// segments follow the same first-declared-wins rule: a trie node holds
7012    /// its regex segments in an ordered list, the lookup takes the first
7013    /// that matches AND serves the request, and a new segment is appended.
7014    /// Both segments below serve this request, so the first declared
7015    /// answers;
7016    /// `a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next`
7017    /// covers the case where it does not.
7018    ///
7019    /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7020    /// (`router/pattern_trie.rs`), iterate the regex segments in reverse —
7021    /// `for (regexp, child) in self.regexps.iter().rev()`. The last declared
7022    /// segment then answers and both assertions below flip.
7023    #[test]
7024    fn the_first_declared_regex_hostname_segment_wins_over_a_later_overlapping_one() {
7025        // Both patterns match the segment `test4`.
7026        let numbered: &[u8] = b"/test[0-9]/.example.com";
7027        let any_suffixed: &[u8] = b"/[a-z]+[0-9]/.example.com";
7028
7029        let declare = |first: &[u8], first_cluster: &str, second: &[u8], second_cluster: &str| {
7030            let mut router = Router::new();
7031            for (hostname, cluster) in [(first, first_cluster), (second, second_cluster)] {
7032                assert!(router.add_tree_rule(
7033                    hostname,
7034                    &PathRule::Prefix("/".to_owned()),
7035                    &MethodRule::new(Some("GET".to_owned())),
7036                    &Route::ClusterId(cluster.into()),
7037                ));
7038            }
7039            router
7040                .lookup("test4.example.com", "/", &Method::Get)
7041                .ok()
7042                .and_then(|result| result.cluster_id)
7043        };
7044
7045        assert_eq!(
7046            declare(numbered, "NUMBERED", any_suffixed, "ANY-SUFFIXED").as_deref(),
7047            Some("NUMBERED"),
7048            "the first-declared regex hostname segment must win",
7049        );
7050        assert_eq!(
7051            declare(any_suffixed, "ANY-SUFFIXED", numbered, "NUMBERED").as_deref(),
7052            Some("ANY-SUFFIXED"),
7053            "reversing the declaration order reverses the winner",
7054        );
7055
7056        // The worked example in `doc/configure.md` promises `/test[0-9]/`
7057        // matches `test4` "and not testAB". `[0-9]` is a SINGLE digit and the
7058        // segment is `\A`-`\z` anchored, so `test44` and `xtest4` must miss
7059        // too — the anchoring is pinned generally by
7060        // `segment_regex_rejects_partial_matches`, this pins the doc's own
7061        // example.
7062        let mut router = Router::new();
7063        assert!(router.add_tree_rule(
7064            numbered,
7065            &PathRule::Prefix("/".to_owned()),
7066            &MethodRule::new(Some("GET".to_owned())),
7067            &Route::ClusterId("REGIONAL".into()),
7068        ));
7069        assert_eq!(
7070            router
7071                .lookup("test4.example.com", "/", &Method::Get)
7072                .ok()
7073                .and_then(|result| result.cluster_id)
7074                .as_deref(),
7075            Some("REGIONAL"),
7076        );
7077        for miss in [
7078            "testAB.example.com",
7079            "test44.example.com",
7080            "xtest4.example.com",
7081        ] {
7082            assert!(
7083                router.lookup(miss, "/", &Method::Get).is_err(),
7084                "{miss} must not match the single-digit anchored segment",
7085            );
7086        }
7087    }
7088
7089    /// sozu#1351, shape one — the routing leak. An exact hostname added
7090    /// AFTER a regex segment that matches it used to attach its rule to
7091    /// the REGEX segment's leaf: `add_tree_rule` addresses the leaf
7092    /// through `domain_lookup_mut`, and that resolver walked a literal
7093    /// segment through the first regex segment matching it. The rule the
7094    /// operator wrote for `test4.example.com` was then served for every
7095    /// host `/test[0-9]/` matches, while `sozu query frontends` showed
7096    /// exactly what was typed.
7097    ///
7098    /// `TrieNode::lookup_mut` is key-addressed now: with
7099    /// `accept_wildcard: false` a literal key may not resolve into a
7100    /// non-literal entry (the guard `insert_sni_route` already documented
7101    /// for the wildcard slot, extended to the regex segments), so the add
7102    /// creates `test4.example.com`'s own node.
7103    ///
7104    /// To SEE THIS RED: in `TrieNode::lookup_mut`
7105    /// (`router/pattern_trie.rs`), drop the `accept_wildcard &&` guard in
7106    /// front of the `self.regexps` scan, so a literal segment resolves
7107    /// through a matching regex segment again. `test7` and `test9` then
7108    /// answer with EXACT-SPECIFIC.
7109    #[test]
7110    fn an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family() {
7111        let mut router = Router::new();
7112        assert!(router.add_tree_rule(
7113            b"/test[0-9]/.example.com",
7114            &PathRule::Prefix("/".to_owned()),
7115            &MethodRule::new(None),
7116            &Route::ClusterId("REGEX-FAMILY".into()),
7117        ));
7118        assert!(router.add_tree_rule(
7119            b"test4.example.com",
7120            &PathRule::Prefix("/only-for-test4".to_owned()),
7121            &MethodRule::new(None),
7122            &Route::ClusterId("EXACT-SPECIFIC".into()),
7123        ));
7124
7125        let resolve = |hostname: &str, path: &str| {
7126            router
7127                .lookup(hostname, path, &Method::Get)
7128                .ok()
7129                .and_then(|result| result.cluster_id)
7130        };
7131
7132        assert_eq!(
7133            resolve("test4.example.com", "/only-for-test4").as_deref(),
7134            Some("EXACT-SPECIFIC"),
7135            "the host the rule was written for must get it",
7136        );
7137        for leaked in ["test7.example.com", "test9.example.com"] {
7138            assert_eq!(
7139                resolve(leaked, "/only-for-test4").as_deref(),
7140                Some("REGEX-FAMILY"),
7141                "{leaked} must keep the family route and never reach the \
7142                 rule scoped to test4",
7143            );
7144        }
7145        assert_eq!(
7146            resolve("test4.example.com", "/").as_deref(),
7147            Some("REGEX-FAMILY"),
7148            "the exact node carries only /only-for-test4, so every other \
7149             path on that host falls through to the regex segment that \
7150             also claims it",
7151        );
7152    }
7153
7154    /// sozu#1351 — the clearest statement of the same bug, and of its fix:
7155    /// declaring the exact host and the regex segment that matches it in
7156    /// either order must produce the SAME routing table. Before the fix
7157    /// the regex-first order attached the exact rule to the regex leaf
7158    /// (so `test7` answered with it and `test4/` answered at all), while
7159    /// the exact-first order built two separate nodes.
7160    ///
7161    /// To SEE THIS RED: same mutation as
7162    /// `an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family`
7163    /// — drop the `accept_wildcard &&` guard in front of the
7164    /// `self.regexps` scan of `TrieNode::lookup_mut`. The two arrays then
7165    /// differ on `test4.example.com/` and on `test7.example.com`.
7166    #[test]
7167    fn hostname_routing_does_not_depend_on_declaration_order() {
7168        let exact: (&[u8], &str, &str) =
7169            (b"test4.example.com", "/only-for-test4", "EXACT-SPECIFIC");
7170        let regex: (&[u8], &str, &str) = (b"/test[0-9]/.example.com", "/", "REGEX-FAMILY");
7171
7172        let routes = |declared: [(&[u8], &str, &str); 2]| {
7173            let mut router = Router::new();
7174            for (hostname, path, cluster) in declared {
7175                assert!(router.add_tree_rule(
7176                    hostname,
7177                    &PathRule::Prefix(path.to_owned()),
7178                    &MethodRule::new(None),
7179                    &Route::ClusterId(cluster.into()),
7180                ));
7181            }
7182            [
7183                ("test4.example.com", "/only-for-test4"),
7184                ("test4.example.com", "/"),
7185                ("test7.example.com", "/only-for-test4"),
7186                ("test9.example.com", "/"),
7187            ]
7188            .map(|(hostname, path)| {
7189                router
7190                    .lookup(hostname, path, &Method::Get)
7191                    .ok()
7192                    .and_then(|result| result.cluster_id)
7193            })
7194        };
7195
7196        let regex_first = routes([regex, exact]);
7197        let exact_first = routes([exact, regex]);
7198        assert_eq!(
7199            regex_first, exact_first,
7200            "hostname resolution must not depend on the order the \
7201             frontends were added in",
7202        );
7203        assert_eq!(
7204            regex_first[0].as_deref(),
7205            Some("EXACT-SPECIFIC"),
7206            "and the order-independent answer is the exact host's own rule",
7207        );
7208        assert_eq!(
7209            regex_first[2].as_deref(),
7210            Some("REGEX-FAMILY"),
7211            "while a sibling of the regex family never sees it",
7212        );
7213    }
7214
7215    /// Hostname precedence is most-specific-first: an exact name beats a
7216    /// regex segment, a regex segment beats the `*` wildcard. One
7217    /// assertion per transition, since `doc/configure.md` states the order
7218    /// in prose.
7219    ///
7220    /// Regex-over-wildcard is the half that changed: `lookup_with_path`
7221    /// used to consult the wildcard slot BEFORE the regex list whenever
7222    /// the leftmost label was reached, so `*` outranked the narrower
7223    /// pattern.
7224    ///
7225    /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7226    /// (`router/pattern_trie.rs`), move the wildcard block above the
7227    /// `self.regexps` loop. The REGEX-over-WILDCARD assertions then answer
7228    /// WILDCARD.
7229    #[test]
7230    fn hostname_precedence_is_exact_then_regex_then_wildcard() {
7231        let exact: &[u8] = b"test4.example.com";
7232        let regex: &[u8] = b"/test[0-9]/.example.com";
7233        let wildcard: &[u8] = b"*.example.com";
7234
7235        let declare = |hostnames: &[(&[u8], &str)]| {
7236            let mut router = Router::new();
7237            for (hostname, cluster) in hostnames {
7238                assert!(router.add_tree_rule(
7239                    hostname,
7240                    &PathRule::Prefix("/".to_owned()),
7241                    &MethodRule::new(None),
7242                    &Route::ClusterId((*cluster).into()),
7243                ));
7244            }
7245            router
7246                .lookup("test4.example.com", "/", &Method::Get)
7247                .ok()
7248                .and_then(|result| result.cluster_id)
7249        };
7250
7251        assert_eq!(
7252            declare(&[(regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7253            Some("EXACT"),
7254            "an exact hostname outranks a regex segment that matches it",
7255        );
7256        assert_eq!(
7257            declare(&[(wildcard, "WILDCARD"), (exact, "EXACT")]).as_deref(),
7258            Some("EXACT"),
7259            "an exact hostname outranks the wildcard",
7260        );
7261        assert_eq!(
7262            declare(&[(wildcard, "WILDCARD"), (regex, "REGEX")]).as_deref(),
7263            Some("REGEX"),
7264            "a regex segment outranks the wildcard",
7265        );
7266        assert_eq!(
7267            declare(&[(regex, "REGEX"), (wildcard, "WILDCARD")]).as_deref(),
7268            Some("REGEX"),
7269            "and it outranks it in the other declaration order too",
7270        );
7271        assert_eq!(
7272            declare(&[(wildcard, "WILDCARD"), (regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7273            Some("EXACT"),
7274            "with all three declared the most specific one answers",
7275        );
7276        assert_eq!(
7277            declare(&[(wildcard, "WILDCARD")]).as_deref(),
7278            Some("WILDCARD"),
7279            "the wildcard still answers when nothing narrower claims the host",
7280        );
7281    }
7282
7283    /// The precedence order above is a search order, not a filter: a more
7284    /// specific hostname candidate that serves no rule for THIS request
7285    /// hands the request to the next one instead of ending the lookup.
7286    /// Without this, splitting a host's paths across an exact frontend and
7287    /// a regex family would make every path the exact frontend does not
7288    /// carry unroutable.
7289    ///
7290    /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7291    /// (`router/pattern_trie.rs`), return
7292    /// `child.lookup_recursive(prefix, accept_wildcard, trace, accept)`
7293    /// directly from the `self.children.get(suffix)` arm instead of
7294    /// falling through on `None` — the pre-fix shape. `/regex-only` and
7295    /// `/served-by-neither` on `test4.example.com` then answer `None`.
7296    #[test]
7297    fn a_hostname_candidate_that_serves_no_rule_falls_through_to_the_next() {
7298        let mut router = Router::new();
7299        for (hostname, path, cluster) in [
7300            (&b"test4.example.com"[..], "/exact-only", "EXACT"),
7301            (&b"/test[0-9]/.example.com"[..], "/regex-only", "REGEX"),
7302            (&b"*.example.com"[..], "/", "WILDCARD"),
7303        ] {
7304            assert!(router.add_tree_rule(
7305                hostname,
7306                &PathRule::Prefix(path.to_owned()),
7307                &MethodRule::new(None),
7308                &Route::ClusterId(cluster.into()),
7309            ));
7310        }
7311
7312        let resolve = |hostname: &str, path: &str| {
7313            router
7314                .lookup(hostname, path, &Method::Get)
7315                .ok()
7316                .and_then(|result| result.cluster_id)
7317        };
7318
7319        assert_eq!(
7320            resolve("test4.example.com", "/exact-only").as_deref(),
7321            Some("EXACT"),
7322        );
7323        assert_eq!(
7324            resolve("test4.example.com", "/regex-only").as_deref(),
7325            Some("REGEX"),
7326            "the exact node carries no rule for this path, so the regex \
7327             segment that also claims the host answers",
7328        );
7329        assert_eq!(
7330            resolve("test4.example.com", "/served-by-neither").as_deref(),
7331            Some("WILDCARD"),
7332            "and when neither serves it the wildcard does",
7333        );
7334        assert_eq!(
7335            resolve("testA.example.com", "/regex-only").as_deref(),
7336            Some("WILDCARD"),
7337            "a host the regex segment does not match never reaches it",
7338        );
7339    }
7340
7341    /// sozu#1351, shape two — the 404. Hostname candidates are chosen
7342    /// without looking at the request method, so the first segment to
7343    /// claim a host used to end the lookup even when its only rule was
7344    /// declared for another method. The second segment, which would have
7345    /// answered, was never consulted.
7346    ///
7347    /// The fallback above covers it: `Router::lookup` hands the trie the
7348    /// same (path, method) selection it will run on the winner, so a
7349    /// candidate that selects nothing is skipped.
7350    ///
7351    /// To SEE THIS RED: in `Router::lookup`, pass `&mut |_| true` as the
7352    /// `accept` predicate of `lookup_with_path` instead of
7353    /// `select_tree_rule(...).is_some()`. The GET below then answers
7354    /// `None` because the POST-only segment was declared first.
7355    #[test]
7356    fn a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next() {
7357        let mut router = Router::new();
7358        // Both segments match `test4`; the POST-only one is declared first.
7359        assert!(router.add_tree_rule(
7360            b"/test[0-9]/.example.com",
7361            &PathRule::Prefix("/".to_owned()),
7362            &MethodRule::new(Some("POST".to_owned())),
7363            &Route::ClusterId("POST-ONLY".into()),
7364        ));
7365        assert!(router.add_tree_rule(
7366            b"/[a-z]+[0-9]/.example.com",
7367            &PathRule::Prefix("/".to_owned()),
7368            &MethodRule::new(Some("GET".to_owned())),
7369            &Route::ClusterId("GET-ONLY".into()),
7370        ));
7371
7372        let resolve = |method: &Method| {
7373            router
7374                .lookup("test4.example.com", "/", method)
7375                .ok()
7376                .and_then(|result| result.cluster_id)
7377        };
7378
7379        assert_eq!(
7380            resolve(&Method::Get).as_deref(),
7381            Some("GET-ONLY"),
7382            "the first-declared segment carries no GET rule, so the second \
7383             one answers instead of the request 404ing",
7384        );
7385        assert_eq!(
7386            resolve(&Method::Post).as_deref(),
7387            Some("POST-ONLY"),
7388            "and the first-declared segment still wins whenever it serves \
7389             the request",
7390        );
7391    }
7392
7393    /// `PathRule`'s `PartialEq` carried no `(Equals, Equals)` arm, so two
7394    /// identical `PathRule::Equals` compared unequal — an equality that is
7395    /// not even reflexive. Every router bookkeeping path is written against
7396    /// it: `add_tree_rule` could not see the rule it had just pushed (so the
7397    /// same `--path-equals` frontend was stored again on every re-add
7398    /// instead of being refused), `remove_tree_rule`'s `retain` kept the
7399    /// entry it was asked to drop, and `remove_http_front` still answered
7400    /// `Ok` for a route it had not removed.
7401    ///
7402    /// To SEE THIS RED: drop the
7403    /// `(PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2` arm from
7404    /// `impl std::cmp::PartialEq for PathRule` so the catch-all `_ => false`
7405    /// answers again — the reflexivity assertion below is the first statement
7406    /// and fails on `left: Equals("/exact") / right: Equals("/exact")`.
7407    /// Without that assertion a debug build panics one line later, inside
7408    /// `add_http_front`, on "a freshly inserted tree domain must resolve to
7409    /// its inserted rule".
7410    #[test]
7411    fn an_equals_path_frontend_is_deduplicated_and_removable() {
7412        assert_eq!(
7413            PathRule::Equals("/exact".to_owned()),
7414            PathRule::Equals("/exact".to_owned()),
7415            "PathRule equality must be reflexive for the Equals variant",
7416        );
7417
7418        let mut router = Router::new();
7419        let mut front = test_http_frontend();
7420        front.path = CommandPathRule::equals("/exact".to_owned());
7421
7422        router
7423            .add_http_front(&front)
7424            .expect("an Equals frontend must be added");
7425        assert!(
7426            router.lookup("example.com", "/exact", &Method::Get).is_ok(),
7427            "the Equals frontend must resolve once added",
7428        );
7429
7430        // The identical frontend is already stored, so the router must
7431        // refuse it instead of pushing a second, unremovable copy.
7432        assert!(
7433            matches!(router.add_http_front(&front), Err(RouterError::AddRoute(_))),
7434            "re-adding the same Equals frontend must be refused, not duplicated",
7435        );
7436
7437        router
7438            .remove_http_front(&front)
7439            .expect("an Equals frontend must be removable");
7440        assert!(
7441            router
7442                .lookup("example.com", "/exact", &Method::Get)
7443                .is_err(),
7444            "the Equals frontend must no longer resolve once removed",
7445        );
7446    }
7447
7448    // ── RFC 9110 §4.2.3: the Host is case-insensitive ──────────────────
7449    //
7450    // `add_tree_rule` (via `tree_hostname_to_ascii`) and
7451    // `DomainRule::from_str` both normalise a configured hostname
7452    // through `idna::domain_to_ascii`, which ASCII-lowercases (measured:
7453    // `"WWW.EXAMPLE.COM"` -> `"www.example.com"`, `"*.EXAMPLE.COM"` ->
7454    // `"*.example.com"`). A `/`-delimited regex segment is the one part
7455    // that is NOT normalised — see `tree_hostname_to_ascii` and
7456    // sozu#1377 — and is compiled case-insensitively instead, so an
7457    // uppercase literal inside it still meets the normalised key.
7458    // `Router::lookup` used to walk the
7459    // trie and the pre/post lists with the client's raw bytes, so the two
7460    // sides disagreed and no configuration could make an uppercase
7461    // `Host:` route. The tests below pin the restored invariant: add and
7462    // lookup normalise identically.
7463
7464    /// To SEE THIS RED: in `Router::lookup`, replace
7465    /// `let normalized_hostname = normalize_hostname(hostname);` with
7466    /// `let normalized_hostname = Cow::Borrowed(hostname);`. Every
7467    /// uppercase and mixed-case assertion below then fails with
7468    /// `left: None, right: Some("CASE")` — the byte-exact trie walk the
7469    /// fix removed.
7470    #[test]
7471    fn an_uppercase_host_reaches_a_lowercase_tree_frontend() {
7472        let mut router = Router::new();
7473        assert!(router.add_tree_rule(
7474            b"www.example.com",
7475            &PathRule::Prefix("/".to_owned()),
7476            &MethodRule::new(Some("GET".to_owned())),
7477            &Route::ClusterId("CASE".into()),
7478        ));
7479
7480        let resolve = |hostname: &str| {
7481            router
7482                .lookup(hostname, "/", &Method::Get)
7483                .ok()
7484                .and_then(|result| result.cluster_id)
7485        };
7486
7487        for host in [
7488            "www.example.com",
7489            "WWW.EXAMPLE.COM",
7490            "WwW.ExAmPlE.cOm",
7491            "www.EXAMPLE.com",
7492        ] {
7493            assert_eq!(
7494                resolve(host).as_deref(),
7495                Some("CASE"),
7496                "Host {host:?} names the same frontend as its lowercase form",
7497            );
7498        }
7499    }
7500
7501    /// The other half of the issue: declaring the frontend in uppercase
7502    /// never was a workaround, because the add path lowercases it on the
7503    /// way in. Both spellings of the frontend must route both spellings
7504    /// of the Host.
7505    ///
7506    /// To SEE THIS RED: same mutation as
7507    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7508    #[test]
7509    fn an_uppercase_tree_frontend_is_stored_and_looked_up_lowercase() {
7510        let mut router = Router::new();
7511        assert!(router.add_tree_rule(
7512            b"WWW.EXAMPLE.COM",
7513            &PathRule::Prefix("/".to_owned()),
7514            &MethodRule::new(Some("GET".to_owned())),
7515            &Route::ClusterId("CASE".into()),
7516        ));
7517
7518        for host in ["www.example.com", "WWW.EXAMPLE.COM"] {
7519            assert_eq!(
7520                router
7521                    .lookup(host, "/", &Method::Get)
7522                    .ok()
7523                    .and_then(|result| result.cluster_id)
7524                    .as_deref(),
7525                Some("CASE"),
7526                "an uppercase frontend is stored lowercase, so {host:?} must resolve",
7527            );
7528        }
7529    }
7530
7531    /// Wildcard and regex trie segments walk the same normalised key.
7532    /// The regex segment is the interesting one: its source is stored
7533    /// VERBATIM (`tree_hostname_to_ascii` normalises the literal labels
7534    /// only, because folding the source inverted `\D` into `\d` —
7535    /// sozu#1377), so an uppercase literal inside it meets the
7536    /// lowercased lookup key only because
7537    /// `pattern_trie::compiled_segment` compiles it case-insensitively.
7538    ///
7539    /// To SEE THIS RED: same mutation as
7540    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`. Dropping
7541    /// `.case_insensitive(true)` from `compiled_segment` reddens the
7542    /// `API7` assertion alone, which is the half this test also guards.
7543    #[test]
7544    fn an_uppercase_host_reaches_wildcard_and_regex_tree_segments() {
7545        let mut router = Router::new();
7546        assert!(router.add_tree_rule(
7547            b"*.wild.example.com",
7548            &PathRule::Prefix("/".to_owned()),
7549            &MethodRule::new(Some("GET".to_owned())),
7550            &Route::ClusterId("WILDCARD".into()),
7551        ));
7552        assert!(router.add_tree_rule(
7553            b"/API[0-9]/.rx.example.com",
7554            &PathRule::Prefix("/".to_owned()),
7555            &MethodRule::new(Some("GET".to_owned())),
7556            &Route::ClusterId("REGEX".into()),
7557        ));
7558
7559        let resolve = |hostname: &str| {
7560            router
7561                .lookup(hostname, "/", &Method::Get)
7562                .ok()
7563                .and_then(|result| result.cluster_id)
7564        };
7565
7566        assert_eq!(resolve("FOO.WILD.EXAMPLE.COM").as_deref(), Some("WILDCARD"));
7567        assert_eq!(resolve("FoO.wild.EXAMPLE.com").as_deref(), Some("WILDCARD"));
7568        assert_eq!(
7569            resolve("API7.RX.EXAMPLE.COM").as_deref(),
7570            Some("REGEX"),
7571            "the operator's `/API[0-9]/` is stored verbatim and compiled \
7572             case-insensitively, so the normalised key matches it",
7573        );
7574        assert_eq!(resolve("api7.rx.example.com").as_deref(), Some("REGEX"));
7575    }
7576
7577    /// Pre and post rules carry a `DomainRule` rather than a trie node.
7578    /// `Exact` and `Wildcard` are lowercased by `DomainRule::from_str`
7579    /// (again `idna::domain_to_ascii`) and compared byte-exact, so they
7580    /// had exactly the tree's asymmetry.
7581    ///
7582    /// To SEE THIS RED: same mutation as
7583    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7584    #[test]
7585    fn an_uppercase_host_reaches_exact_and_wildcard_pre_post_rules() {
7586        let mut router = Router::new();
7587        let exact = "pre.example.com"
7588            .parse::<DomainRule>()
7589            .expect("an exact domain rule must parse");
7590        let wildcard = "*.post.example.com"
7591            .parse::<DomainRule>()
7592            .expect("a wildcard domain rule must parse");
7593        assert!(router.add_pre_rule(
7594            &exact,
7595            &PathRule::Prefix("/".to_owned()),
7596            &MethodRule::new(None),
7597            &Route::ClusterId("PRE".into()),
7598        ));
7599        assert!(router.add_post_rule(
7600            &wildcard,
7601            &PathRule::Prefix("/".to_owned()),
7602            &MethodRule::new(None),
7603            &Route::ClusterId("POST".into()),
7604        ));
7605
7606        let resolve = |hostname: &str| {
7607            router
7608                .lookup(hostname, "/", &Method::Get)
7609                .ok()
7610                .and_then(|result| result.cluster_id)
7611        };
7612
7613        assert_eq!(resolve("PRE.EXAMPLE.COM").as_deref(), Some("PRE"));
7614        assert_eq!(resolve("PrE.ExAmPlE.cOm").as_deref(), Some("PRE"));
7615        assert_eq!(resolve("FOO.POST.EXAMPLE.COM").as_deref(), Some("POST"));
7616        assert_eq!(resolve("foo.post.example.com").as_deref(), Some("POST"));
7617    }
7618
7619    /// The interaction that makes normalising the lookup key dangerous on
7620    /// its own: a pre/post hostname REGEX keeps the operator's bytes —
7621    /// `convert_regex_domain_rule` copies the segment source verbatim,
7622    /// as the trie now does too (`tree_hostname_to_ascii`, sozu#1377;
7623    /// up to and including 2.2.1 `domain_to_ascii` lowercased it there).
7624    /// Matching a
7625    /// normalised key against a case-sensitive `/API[0-9]/` would have
7626    /// turned a rule that used to match `API7.…` into one that matches
7627    /// nothing. `DomainRule::from_str` therefore compiles hostname
7628    /// regexes case-insensitively, which is also the only reading
7629    /// that agrees with the trie: `pattern_trie::compiled_segment`
7630    /// folds the same way, on the same stored bytes.
7631    ///
7632    /// To SEE THIS RED: in `DomainRule::from_str`, drop the
7633    /// `.case_insensitive(true)` from the `RegexBuilder` and build with
7634    /// `regex::bytes::Regex::new(&s)` again. Both assertions below fail
7635    /// with `left: None, right: Some("RX")`.
7636    #[test]
7637    fn an_uppercase_pre_rule_hostname_regex_still_matches_its_own_host() {
7638        let mut router = Router::new();
7639        let uppercase_regex = "/API[0-9]/.example.com"
7640            .parse::<DomainRule>()
7641            .expect("an uppercase hostname regex must parse");
7642        assert!(router.add_pre_rule(
7643            &uppercase_regex,
7644            &PathRule::Prefix("/".to_owned()),
7645            &MethodRule::new(None),
7646            &Route::ClusterId("RX".into()),
7647        ));
7648
7649        let resolve = |hostname: &str| {
7650            router
7651                .lookup(hostname, "/", &Method::Get)
7652                .ok()
7653                .and_then(|result| result.cluster_id)
7654        };
7655
7656        assert_eq!(
7657            resolve("API7.example.com").as_deref(),
7658            Some("RX"),
7659            "the spelling the operator wrote must keep routing",
7660        );
7661        assert_eq!(
7662            resolve("api7.example.com").as_deref(),
7663            Some("RX"),
7664            "and its canonical lowercase form, which never routed before",
7665        );
7666    }
7667
7668    /// The regression guard for the `.unicode(false)` that
7669    /// [`DomainRule::from_str`] deliberately does NOT set. Setting it
7670    /// would make the case folding ASCII-only — which is what the
7671    /// hostnames ever reaching `matches` are anyway — but it also
7672    /// refuses Unicode-class syntax at compile time, and `\p{L}` matches
7673    /// ASCII letters perfectly well, so a frontend that installs and
7674    /// routes today would start being rejected at add time.
7675    ///
7676    /// To SEE THIS RED: add `.unicode(false)` to the `RegexBuilder` in
7677    /// `DomainRule::from_str`. The parse fails and the `expect` fires.
7678    #[test]
7679    fn a_unicode_class_hostname_regex_still_compiles_and_routes() {
7680        let mut router = Router::new();
7681        let unicode_class = "/\\p{L}+[0-9]/.example.com"
7682            .parse::<DomainRule>()
7683            .expect("a hostname regex using a Unicode class must still compile");
7684        assert!(router.add_pre_rule(
7685            &unicode_class,
7686            &PathRule::Prefix("/".to_owned()),
7687            &MethodRule::new(None),
7688            &Route::ClusterId("UNICODE".into()),
7689        ));
7690
7691        let resolve = |hostname: &str| {
7692            router
7693                .lookup(hostname, "/", &Method::Get)
7694                .ok()
7695                .and_then(|result| result.cluster_id)
7696        };
7697
7698        assert_eq!(
7699            resolve("api7.example.com").as_deref(),
7700            Some("UNICODE"),
7701            "a Unicode letter class covers ASCII letters, so this rule is live, not decorative",
7702        );
7703        assert_eq!(
7704            resolve("API7.example.com").as_deref(),
7705            Some("UNICODE"),
7706            "and it stays live for the uppercase spelling of the same host",
7707        );
7708    }
7709
7710    /// Normalisation and the hostname precedence order of sozu#1351
7711    /// compose in one direction only: the key is normalised first, then
7712    /// the most-specific-first search runs on it, so case never decides
7713    /// WHICH candidate wins. Stated in `doc/configure.md` under
7714    /// "Hostname case", so it needs an assertion.
7715    ///
7716    /// The shape is the one the precedence section uses: an exact name
7717    /// and a regex segment that also claims it. The exact frontend must
7718    /// win for both spellings — a normalisation applied anywhere after
7719    /// the candidate search would let the uppercase spelling miss the
7720    /// exact node and fall through to the regex family.
7721    ///
7722    /// To SEE THIS RED: in `Router::lookup`, replace
7723    /// `let normalized_hostname = normalize_hostname(hostname);` with
7724    /// `let normalized_hostname = Cow::Borrowed(hostname);`. The
7725    /// uppercase spelling stops resolving at all.
7726    #[test]
7727    fn case_normalisation_runs_before_the_hostname_precedence_search() {
7728        let mut router = Router::new();
7729        assert!(router.add_tree_rule(
7730            b"test4.example.com",
7731            &PathRule::Prefix("/".to_owned()),
7732            &MethodRule::new(Some("GET".to_owned())),
7733            &Route::ClusterId("EXACT".into()),
7734        ));
7735        assert!(router.add_tree_rule(
7736            b"/test[0-9]/.example.com",
7737            &PathRule::Prefix("/".to_owned()),
7738            &MethodRule::new(Some("GET".to_owned())),
7739            &Route::ClusterId("REGEX-FAMILY".into()),
7740        ));
7741
7742        let resolve = |hostname: &str| {
7743            router
7744                .lookup(hostname, "/", &Method::Get)
7745                .ok()
7746                .and_then(|result| result.cluster_id)
7747        };
7748
7749        assert_eq!(
7750            resolve("test4.example.com").as_deref(),
7751            Some("EXACT"),
7752            "the exact name outranks a regex segment that also claims it",
7753        );
7754        for spelling in ["TEST4.EXAMPLE.COM", "TeSt4.ExAmPlE.cOm"] {
7755            assert_eq!(
7756                resolve(spelling).as_deref(),
7757                Some("EXACT"),
7758                "{spelling:?} must pick the same tier as its lowercase form, \
7759                 not fall through to the regex family",
7760            );
7761        }
7762
7763        // A host only the regex family claims still reaches it in either
7764        // spelling: normalising did not collapse the tiers, it fed them.
7765        assert_eq!(
7766            resolve("test7.example.com").as_deref(),
7767            Some("REGEX-FAMILY")
7768        );
7769        assert_eq!(
7770            resolve("TEST7.EXAMPLE.COM").as_deref(),
7771            Some("REGEX-FAMILY")
7772        );
7773    }
7774
7775    /// `has_hostname` decides whether a removed frontend's cached tags may
7776    /// be dropped. It is fed the CONFIGURED hostname, which the pre/post
7777    /// arms then compared byte-exact against a `DomainRule` the add path
7778    /// had already lowercased — so an uppercase frontend answered `false`
7779    /// while its route was still installed, and the tags went away under a
7780    /// live route.
7781    ///
7782    /// To SEE THIS RED: in `has_hostname`, replace
7783    /// `let normalized_hostname = normalize_hostname(hostname);` with
7784    /// `let normalized_hostname = Cow::Borrowed(hostname);`. The two
7785    /// uppercase assertions fail.
7786    #[test]
7787    fn has_hostname_answers_for_an_uppercase_spelling() {
7788        let mut router = Router::new();
7789        assert!(router.add_tree_rule(
7790            b"tree.example.com",
7791            &PathRule::Prefix("/".to_owned()),
7792            &MethodRule::new(Some("GET".to_owned())),
7793            &Route::ClusterId("TREE".into()),
7794        ));
7795        assert!(
7796            router.add_pre_rule(
7797                &"pre.example.com"
7798                    .parse::<DomainRule>()
7799                    .expect("an exact domain rule must parse"),
7800                &PathRule::Prefix("/".to_owned()),
7801                &MethodRule::new(None),
7802                &Route::ClusterId("PRE".into()),
7803            )
7804        );
7805
7806        assert!(router.has_hostname("TREE.EXAMPLE.COM"));
7807        assert!(router.has_hostname("PRE.EXAMPLE.COM"));
7808        assert!(!router.has_hostname("ABSENT.EXAMPLE.COM"));
7809    }
7810
7811    /// `$HOST[n]` captures are taken from the key the router matched on,
7812    /// so a rewrite template now emits the normalised host rather than
7813    /// the client's raw bytes. That is the intended reading — the
7814    /// configured frontend is itself stored lowercase — and it is stated
7815    /// in `doc/configure.md`, so it needs an assertion.
7816    ///
7817    /// To SEE THIS RED: same mutation as
7818    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`; the rewrite
7819    /// stops resolving at all (`RouteNotFound`) and the `expect` fires.
7820    #[test]
7821    fn a_host_rewrite_capture_sees_the_normalized_host() {
7822        let mut router = Router::new();
7823        router
7824            .add_http_front(&HttpFrontend {
7825                hostname: "rewrite.example.com".to_owned(),
7826                rewrite_host: Some("$HOST[0].internal".to_owned()),
7827                ..test_http_frontend()
7828            })
7829            .expect("the rewrite frontend must install");
7830
7831        let route = router
7832            .lookup("REWRITE.EXAMPLE.COM", "/", &Method::Get)
7833            .expect("an uppercase Host must resolve");
7834        assert_eq!(
7835            route.rewritten_host.as_deref(),
7836            Some("rewrite.example.com.internal"),
7837            "`$HOST[0]` carries the normalised host, not the client's bytes",
7838        );
7839    }
7840
7841    /// The normalisation must cost a scan and not an allocation on the
7842    /// overwhelmingly common path — every conforming client already sends
7843    /// a lowercase Host, and this runs once per request.
7844    ///
7845    /// To SEE THIS RED: make `normalize_hostname` return
7846    /// `Cow::Owned(hostname.to_ascii_lowercase())` unconditionally. The
7847    /// first assertion fails.
7848    #[test]
7849    fn normalize_hostname_borrows_an_already_lowercase_host() {
7850        assert!(
7851            matches!(normalize_hostname("www.example.com"), Cow::Borrowed(_)),
7852            "a lowercase host must not allocate",
7853        );
7854        assert!(
7855            matches!(normalize_hostname("123.example-host.com"), Cow::Borrowed(_)),
7856            "digits and hyphens are not uppercase and must not allocate",
7857        );
7858        assert_eq!(
7859            normalize_hostname("WwW.ExAmPlE.cOm"),
7860            Cow::Owned::<str>("www.example.com".to_owned()),
7861        );
7862    }
7863
7864    /// Known gap, deliberately NOT closed here: RFC 1034 §3.1 makes
7865    /// `www.example.com.` the absolute form of `www.example.com`, but
7866    /// `idna::domain_to_ascii` keeps the trailing dot on the add path
7867    /// (measured: `"www.example.com."` -> `"www.example.com."`) and the
7868    /// trie then splits it into a trailing empty label that no
7869    /// dot-free frontend carries. The TLS side already strips one
7870    /// trailing dot (`lib/src/https.rs`'s `sni_owned`,
7871    /// `lib/src/protocol/tcp_preread/mod.rs`'s `normalize_sni`); HTTP
7872    /// routing does not. This test records the current answer so the day
7873    /// someone fixes it, it fails here and gets inverted rather than
7874    /// silently changing behaviour.
7875    ///
7876    /// To SEE THIS RED: strip a single trailing `.` inside
7877    /// `normalize_hostname`.
7878    #[test]
7879    fn an_absolute_form_host_does_not_reach_a_relative_frontend_yet() {
7880        let mut router = Router::new();
7881        assert!(router.add_tree_rule(
7882            b"www.example.com",
7883            &PathRule::Prefix("/".to_owned()),
7884            &MethodRule::new(Some("GET".to_owned())),
7885            &Route::ClusterId("CASE".into()),
7886        ));
7887
7888        assert!(
7889            router
7890                .lookup("www.example.com.", "/", &Method::Get)
7891                .is_err(),
7892            "absolute-form Host routing is a separate defect, tracked \
7893             apart from the case asymmetry",
7894        );
7895        assert!(
7896            router
7897                .lookup("WWW.EXAMPLE.COM.", "/", &Method::Get)
7898                .is_err(),
7899            "normalising the case must not accidentally close it either",
7900        );
7901    }
7902
7903    // ── sozu#1377: IDNA folding must not reach a regex segment ─────────
7904    //
7905    // `add_tree_rule`, `remove_tree_rule` and `has_hostname` used to run
7906    // the WHOLE configured hostname through `idna::domain_to_ascii`,
7907    // which ASCII-lowercases (measured: `"/\\D+/.example.com"` ->
7908    // `"/\\d+/.example.com"`, `"/[^\\D]/.example.com"` ->
7909    // `"/[^\\d]/.example.com"`, `"/API[0-9]/.rx.example.com"` ->
7910    // `"/api[0-9]/.rx.example.com"`). Folding an uppercase regex escape
7911    // INVERTS the character class it names -- `\D` is "not a digit",
7912    // `\d` is "a digit"; `\W`/`\w` and `\S`/`\s` invert the same way --
7913    // so the stored rule matched the exact COMPLEMENT of what the
7914    // operator wrote while `sozu query frontends` still echoed the
7915    // original spelling.
7916    //
7917    // `Pre`/`Post` never carried it: `convert_regex_domain_rule` copies a
7918    // segment's source verbatim and `DomainRule::from_str` takes its case
7919    // insensitivity from the `RegexBuilder` instead. The tree now agrees:
7920    // `tree_hostname_to_ascii` normalises only the literal labels and
7921    // `pattern_trie::compiled_segment` folds case at compile time.
7922
7923    /// To SEE THIS RED: in `tree_hostname_to_ascii`, replace the whole
7924    /// body with `::idna::domain_to_ascii(hostname).ok()`, the form the
7925    /// three call sites carried up to 2.2.1. Both assertions then fail
7926    /// INVERTED -- `abc.rx.example.com` resolves to `None` and
7927    /// `777.rx.example.com` to `Some("NOT-A-DIGIT")` -- which is the
7928    /// defect itself: the rule matches the complement of its source --
7929    /// `\D` matching a digit.
7930    #[test]
7931    fn a_tree_hostname_regex_escape_is_not_case_folded() {
7932        let mut router = Router::new();
7933        assert!(router.add_tree_rule(
7934            "/\\D+/.rx.example.com".as_bytes(),
7935            &PathRule::Prefix("/".to_owned()),
7936            &MethodRule::new(None),
7937            &Route::ClusterId("NOT-A-DIGIT".into()),
7938        ));
7939
7940        let resolve = |hostname: &str| {
7941            router
7942                .lookup(hostname, "/", &Method::Get)
7943                .ok()
7944                .and_then(|result| result.cluster_id)
7945        };
7946
7947        assert_eq!(
7948            resolve("777.rx.example.com"),
7949            None,
7950            "`\\D+` must NOT match a run of digits; folded to `\\d+` it matched \
7951             exactly this and nothing else",
7952        );
7953        assert_eq!(
7954            resolve("abc.rx.example.com").as_deref(),
7955            Some("NOT-A-DIGIT"),
7956            "`\\D+` is \"one or more non-digits\" and `abc` is three of them",
7957        );
7958    }
7959
7960    /// The negated-class spelling from the report, and the two other
7961    /// uppercase escapes that invert: `\W` (not a word character) and
7962    /// `\S` (not whitespace). `[^\D]` is "not a non-digit", i.e. a digit;
7963    /// folded to `[^\d]` it becomes its own complement.
7964    ///
7965    /// To SEE THIS RED: same mutation as
7966    /// `a_tree_hostname_regex_escape_is_not_case_folded`. Every
7967    /// `matches` assertion below fails, each with its complement.
7968    #[test]
7969    fn every_inverting_uppercase_regex_escape_survives_a_tree_insert() {
7970        // (segment source, a host label it must match, one it must not)
7971        for (segment, hit, miss) in [
7972            ("[^\\D]+", "777", "abc"),
7973            ("\\W+", "---", "abc"),
7974            ("\\S+", "abc", "   "),
7975        ] {
7976            let mut router = Router::new();
7977            let hostname = format!("/{segment}/.rx.example.com");
7978            assert!(
7979                router.add_tree_rule(
7980                    hostname.as_bytes(),
7981                    &PathRule::Prefix("/".to_owned()),
7982                    &MethodRule::new(None),
7983                    &Route::ClusterId("ESCAPE".into()),
7984                ),
7985                "{hostname} must install",
7986            );
7987
7988            let resolve = |hostname: &str| {
7989                router
7990                    .lookup(hostname, "/", &Method::Get)
7991                    .ok()
7992                    .and_then(|result| result.cluster_id)
7993            };
7994
7995            assert_eq!(
7996                resolve(&format!("{hit}.rx.example.com")).as_deref(),
7997                Some("ESCAPE"),
7998                "{segment} must match {hit}",
7999            );
8000            assert_eq!(
8001                resolve(&format!("{miss}.rx.example.com")),
8002                None,
8003                "{segment} must not match {miss}",
8004            );
8005        }
8006    }
8007
8008    /// The other half of the split: a LITERAL label is still a domain
8009    /// label and still goes through IDNA, in the very same hostname whose
8010    /// regex segment is copied verbatim. Only a `/`-delimited segment is
8011    /// exempt -- the same split `convert_regex_domain_rule` and the trie's
8012    /// `insert_recursive` already make.
8013    ///
8014    /// `remove_tree_rule` normalises through the same helper, so the rule
8015    /// stays addressable: a second spelling of the stored key would make
8016    /// the frontend permanently un-removable.
8017    ///
8018    /// To SEE THIS RED: same mutation as
8019    /// `a_tree_hostname_regex_escape_is_not_case_folded`. The first
8020    /// assertion fails with `left: None, right: Some("IDN")` -- the
8021    /// punycode is right, the folded `\d+` segment is what misses.
8022    #[test]
8023    fn a_unicode_label_is_punycoded_while_its_sibling_regex_segment_is_not() {
8024        let hostname = "/\\D+/.MÜNCHEN.example.com";
8025        let mut router = Router::new();
8026        assert!(router.add_tree_rule(
8027            hostname.as_bytes(),
8028            &PathRule::Prefix("/".to_owned()),
8029            &MethodRule::new(None),
8030            &Route::ClusterId("IDN".into()),
8031        ));
8032
8033        // `idna::domain_to_ascii("MÜNCHEN")` -> `"xn--mnchen-3ya"`
8034        // (measured, and identical label-by-label and whole-domain).
8035        assert_eq!(
8036            router
8037                .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8038                .ok()
8039                .and_then(|result| result.cluster_id)
8040                .as_deref(),
8041            Some("IDN"),
8042            "the literal label must still be punycoded and lowercased",
8043        );
8044
8045        assert!(
8046            router.remove_tree_rule(
8047                hostname.as_bytes(),
8048                &PathRule::Prefix("/".to_owned()),
8049                &MethodRule::new(None),
8050            ),
8051            "the frontend must still be addressable by the spelling that \
8052             installed it",
8053        );
8054        assert!(
8055            router
8056                .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8057                .is_err(),
8058            "and removing it must actually unroute it",
8059        );
8060    }
8061
8062    // ═══════════════════════════════════════════════════════════════════
8063    // Property-test harness: sozu#1349, sozu#1351, sozu#1356, sozu#1377
8064    // ═══════════════════════════════════════════════════════════════════
8065    //
8066    // Every regression test above pins ONE fixed example. This harness
8067    // generates many, against an ORACLE that is independent of the trie:
8068    // it does not call `pattern_trie::anchored_segment`,
8069    // `compiled_segment`, `TrieNode::lookup*`, `tree_hostname_to_ascii` or
8070    // `convert_regex_domain_rule` — reusing any of those would make the
8071    // oracle agree with the implementation by construction, proving
8072    // nothing. It is written fresh from `doc/configure.md`'s "Hostname
8073    // precedence" and "Regex hostname segments" sections and the RFC text
8074    // those sections cite (RFC 9110 §4.2.3 for case, RFC 9110's own
8075    // grammar for the wildcard), and it uses the `regex` crate only as a
8076    // primitive — exactly as `pattern_trie` does — never the router's own
8077    // wrapping of it.
8078    //
8079    // Scope, stated explicitly because it is a deliberate narrowing: every
8080    // generated rule and every query share the fixed two-label suffix
8081    // `example.com`, one variable FRONT label, and `MethodRule::new(None)`
8082    // (matches every method). This is what puts every rule in a scenario
8083    // on the SAME trie branch, which is where sozu#1351's leak and
8084    // precedence questions live, and it removes METHOD precedence from
8085    // the oracle's job — a separate concern with its own existing tests
8086    // (`case_normalisation_runs_before_the_hostname_precedence_search`
8087    // and neighbours). PATH does vary — see `PATH_POOL` — because sozu#1351's
8088    // actual production symptom needs it: with every rule pinned to the
8089    // SAME path, a literal whose insert would leak onto a regex leaf finds
8090    // that leaf already carrying an identical `(path, method)`, so
8091    // `add_tree_rule`'s append-skip condition is always false and the
8092    // insert is refused before any lookup happens — the leak is real but
8093    // observable only as `add_tree_rule` returning `false`, never as a
8094    // routing mismatch. Varying path is what lets the SAME leak surface the
8095    // way it did in production: an exact rule at one path answering a
8096    // request for a hostname nobody declared, at a DIFFERENT declared path.
8097    //
8098    // The generator is biased, not uniform: `REGEX_CATALOG` is the fixed
8099    // set of segment shapes that actually bit sozu, `LITERAL_POOL`
8100    // deliberately overlaps those shapes' `hits` so an exact rule and a
8101    // regex rule naturally collide on the same host (sozu#1351), case is
8102    // varied on both the declared rule and the query independently
8103    // (sozu#1349), declaration order is an explicit Fisher-Yates shuffle
8104    // driven by the same `Gen` (sozu#1351 again — order must not move an
8105    // exact-vs-regex verdict, and must move a regex-vs-regex one), and
8106    // queries cross every generated hit/miss hostname against every path
8107    // declared anywhere in the scenario, not just the declaring rule's own
8108    // (sozu#1351's leak crosses fronts, so a query pairing has to as well).
8109    //
8110    // Two honesty notes about this harness's own limits, not the router's:
8111    //
8112    // - **No reproducible seed.** `quickcheck` 1.1.0's `Gen::new` seeds
8113    //   from OS entropy with no seed exposed to reproduce a specific run,
8114    //   unlike the FoundationDB-style simulators `doc/testing.md` §5
8115    //   describes (`SOZU_UDP_SIM_SEED` and friends). A failing CI run here
8116    //   is reproduced by RE-RUNNING locally with a raised `QUICKCHECK_TESTS`
8117    //   (this property's generator is dense enough that a real regression
8118    //   reappears quickly), not by replaying the failing seed — there is
8119    //   none to replay. Not fixed in this changeset.
8120    // - **The anchoring convention is shared, not external.** This oracle's
8121    //   `\A(?:source)\z` case-insensitive anchoring matches
8122    //   `doc/configure.md`'s "Regex hostname segments" section — but that
8123    //   section's prose was itself ADDED by the sozu#1356 fix this harness
8124    //   is supposed to catch a regression of. So for the GROUPING half of
8125    //   that anchoring, the oracle and the implementation share a
8126    //   convention rather than the oracle checking against an independent
8127    //   spec; a regression that un-groups BOTH the doc and the code back to
8128    //   their pre-#1356 form would not be caught by prose comparison alone
8129    //   — it is caught here because the oracle's grouping is also
8130    //   independently reasoned from what `|`'s precedence in a regex
8131    //   grammar requires, the same reasoning #1356's own fix commit gives.
8132    //   The CASE-FOLDING half (`(?i)`) is genuinely independent, derived
8133    //   straight from RFC 9110 §4.2.3, not from anything #1356 or #1377
8134    //   added to `doc/configure.md`.
8135
8136    /// One `/regex/` hostname segment source this harness declares, with
8137    /// concrete labels independently reasoned to match or not match it
8138    /// under `\A(?:source)\z` compiled case-insensitively — the anchoring
8139    /// `doc/configure.md` documents and sozu#1356 restored, built fresh
8140    /// here rather than borrowed from `anchored_segment`.
8141    struct RegexCase {
8142        source: &'static str,
8143        /// Whole-label matches for one branch of the pattern.
8144        hits: &'static [&'static str],
8145        /// Labels that must NOT match under the documented semantics, but
8146        /// that a pre-fix implementation matched: a half-anchored
8147        /// alternation branch (sozu#1356 — `axx`, `xxb`, the bare
8148        /// substring `zzbzz` for a 3-way alternation's ungrouped middle
8149        /// branch) or the inverted class an ASCII-lowercase fold produces
8150        /// (sozu#1377 — folding `\D` into `\d` turns "not a digit" into
8151        /// "a digit").
8152        misses: &'static [&'static str],
8153    }
8154
8155    const REGEX_CATALOG: &[RegexCase] = &[
8156        RegexCase {
8157            source: "a|b",
8158            hits: &["a", "b"],
8159            misses: &["axx", "xxb", "xx"],
8160        },
8161        RegexCase {
8162            source: "a|b|c",
8163            // The 3-branch case two branches cannot express: ungrouped,
8164            // the MIDDLE branch of `\Aa|b|c\z` keeps NEITHER anchor and
8165            // matches as a bare substring anywhere in the label.
8166            hits: &["a", "b", "c"],
8167            misses: &["axx", "xxc", "zzbzz", "bzz", "zzb"],
8168        },
8169        RegexCase {
8170            source: "api|admin",
8171            // `doc/configure.md`'s own worked example for sozu#1356.
8172            hits: &["api", "admin"],
8173            misses: &["apifoo", "xadmin"],
8174        },
8175        RegexCase {
8176            source: "\\D+",
8177            // sozu#1377's own measured shape: folded to `\d+`, this
8178            // matched digits and rejected letters — the complement.
8179            hits: &["abc", "xyz"],
8180            misses: &["777", "42"],
8181        },
8182        RegexCase {
8183            source: "\\W+",
8184            hits: &["---", "..."],
8185            misses: &["abc", "42a"],
8186        },
8187        RegexCase {
8188            source: "\\S+",
8189            hits: &["abc", "777"],
8190            // Folded to `\s`, this would match ONLY whitespace; no ASCII
8191            // hostname label the wire ever admits can carry one, but
8192            // `Router::lookup` takes a plain `&str` and does not itself
8193            // re-validate the charset, so the harness can still probe it.
8194            misses: &[" "],
8195        },
8196        RegexCase {
8197            source: "[^\\D]+",
8198            // sozu#1377's negated-class shape from the issue itself:
8199            // "not a non-digit" is a digit; folded it becomes its own
8200            // complement.
8201            hits: &["777", "42"],
8202            misses: &["abc"],
8203        },
8204        RegexCase {
8205            source: "API[0-9]",
8206            // An uppercase literal INSIDE a class: must still meet the
8207            // lowercased lookup key by folding at compile time, not by
8208            // folding the stored source (which would invert the escapes
8209            // above in the very same pass).
8210            hits: &["api7", "API7", "ApI4"],
8211            misses: &["apiZ"],
8212        },
8213        RegexCase {
8214            source: "test[0-9]",
8215            // sozu#1351's own shape: `test4` is also `LITERAL_POOL`'s
8216            // exact-rule text, so a literal `test4` rule and this regex
8217            // rule collide on the same host with decent probability.
8218            hits: &["test4", "TEST7", "Test9"],
8219            misses: &["testA", "test10x"],
8220        },
8221        RegexCase {
8222            source: "[a-z]+[0-9]",
8223            hits: &["test4", "cdn1"],
8224            misses: &["4test", "TEST"],
8225        },
8226    ];
8227
8228    /// Literal exact-rule candidates. Deliberately overlapping with
8229    /// `REGEX_CATALOG`'s `hits` — `test4`, `api`, `admin`, `a`, `b`, `c`
8230    /// all reappear there — so an exact rule and an overlapping regex
8231    /// rule land in the SAME generated scenario often enough to exercise
8232    /// sozu#1351 without hand-injecting a forced pair.
8233    const LITERAL_POOL: &[&str] = &[
8234        "a", "b", "c", "api", "admin", "test4", "test7", "test9", "TEST4", "API7", "abc", "777",
8235        "42", "cdn1", "zz", "apifoo", "xadmin",
8236    ];
8237
8238    /// Declared-path candidates. Small and deliberately NOT elaborate: the
8239    /// production shape of sozu#1351 needs exactly two rules at two
8240    /// different paths (reproduced live against the reverted fix: an exact
8241    /// `test4.example.com` frontend at `/other` leaking onto the
8242    /// `/test[0-9]/.example.com` regex family, answering `test7.example.com`
8243    /// requests for `/other`). `"/"` is a prefix of every other entry here
8244    /// and is weighted to appear often, so a scenario still frequently
8245    /// declares two fronts at the SAME path (exercising the harness's
8246    /// pre-existing hostname-only coverage) as well as at different ones
8247    /// (exercising the path dimension this fixes). Every entry is used only
8248    /// as a `PathRule::Prefix`; `PathRule::Equals`/`PathRule::Regex` are not
8249    /// generated — see the module doc comment for why method stays fixed
8250    /// too, and the same reasoning applies here: sozu#1351 needs two
8251    /// declared paths to differ, not the full path-matching precedence
8252    /// surface, which has its own existing tests.
8253    const PATH_POOL: &[&str] = &["/", "/", "/other", "/api", "/only"];
8254
8255    /// The dot-separated FRONT label of a generated Tree-position
8256    /// hostname rule. Every rule in a [`Scenario`] shares the fixed
8257    /// trailing `example.com` — see the harness's module-level doc
8258    /// comment for why.
8259    #[derive(Clone, Copy, Debug)]
8260    enum FrontLabel {
8261        /// A literal label, in whatever case the generator chose.
8262        /// `tree_hostname_to_ascii` case-folds it on insert (sozu#1377).
8263        Literal(&'static str),
8264        /// The leftmost-only wildcard, exactly one label.
8265        Wildcard,
8266        /// A `/regex/` segment, addressed into [`REGEX_CATALOG`].
8267        Regex(usize),
8268    }
8269
8270    /// Dedup identity for a [`FrontLabel`] within one scenario. Two
8271    /// literals that differ only by case must be treated as the SAME
8272    /// front: `add_tree_rule` case-folds them to the same trie node, and
8273    /// generating both would silently drop one rule's cluster id from the
8274    /// harness's own model — a harness bug, not something under test.
8275    #[derive(Clone, PartialEq, Eq, Debug)]
8276    enum FrontKey {
8277        Literal(String),
8278        Wildcard,
8279        Regex(usize),
8280    }
8281
8282    impl From<FrontLabel> for FrontKey {
8283        fn from(front: FrontLabel) -> Self {
8284            match front {
8285                FrontLabel::Literal(text) => FrontKey::Literal(text.to_ascii_lowercase()),
8286                FrontLabel::Wildcard => FrontKey::Wildcard,
8287                FrontLabel::Regex(idx) => FrontKey::Regex(idx),
8288            }
8289        }
8290    }
8291
8292    #[derive(Clone, Debug)]
8293    struct GenRule {
8294        front: FrontLabel,
8295        /// This rule's OWN declared path, from [`PATH_POOL`]. Every front
8296        /// carries exactly one `(path, route)` pair in this harness's
8297        /// model — one `add_tree_rule` call per generated front — so there
8298        /// is no within-one-hostname path-precedence list to model; only
8299        /// whether THIS front's path serves a given query path.
8300        path: &'static str,
8301        cluster: String,
8302    }
8303
8304    /// One generated case: a set of Tree-position rules, in DECLARATION
8305    /// order (already shuffled — see [`Scenario::arbitrary`]), and a set
8306    /// of (hostname, path) queries to look up against them.
8307    #[derive(Clone, Debug)]
8308    struct Scenario {
8309        rules: Vec<GenRule>,
8310        queries: Vec<(String, String)>,
8311    }
8312
8313    fn render_hostname(front: FrontLabel) -> String {
8314        match front {
8315            FrontLabel::Literal(text) => format!("{text}.example.com"),
8316            FrontLabel::Wildcard => "*.example.com".to_owned(),
8317            FrontLabel::Regex(idx) => format!("/{}/.example.com", REGEX_CATALOG[idx].source),
8318        }
8319    }
8320
8321    /// The oracle's OWN anchoring: `\A(?:source)\z`, case-insensitive.
8322    /// Built fresh from `doc/configure.md`'s "Regex hostname segments"
8323    /// section and the sozu#1356 fix's stated invariant, not by calling
8324    /// `pattern_trie::anchored_segment` or `compiled_segment`. Using the
8325    /// `regex` crate as a primitive is not reusing the implementation:
8326    /// regex matching itself is not what any of the four issues broke —
8327    /// Sozu's own anchoring, grouping and case handling around it is.
8328    fn oracle_regex_matches(source: &str, label: &str) -> bool {
8329        let pattern = format!("(?i)\\A(?:{source})\\z");
8330        Regex::new(&pattern)
8331            .unwrap_or_else(|error| {
8332                panic!("the harness's own catalog regex {source:?} must compile: {error}")
8333            })
8334            .is_match(label.as_bytes())
8335    }
8336
8337    fn front_label_matches(front: FrontLabel, query_label: &str) -> bool {
8338        match front {
8339            FrontLabel::Literal(text) => text.eq_ignore_ascii_case(query_label),
8340            FrontLabel::Wildcard => !query_label.is_empty(),
8341            FrontLabel::Regex(idx) => oracle_regex_matches(REGEX_CATALOG[idx].source, query_label),
8342        }
8343    }
8344
8345    /// Whether `front` (this harness's stand-in for one declared Tree
8346    /// rule) matches the full hostname `query` — case-insensitively on
8347    /// the fixed `example.com` suffix (RFC 9110 §4.2.3, sozu#1349) and
8348    /// per [`front_label_matches`] on the one variable label.
8349    fn oracle_hostname_matches(front: FrontLabel, query: &str) -> bool {
8350        let mut labels = query.split('.');
8351        let (Some(first), Some(second), Some(third), None) =
8352            (labels.next(), labels.next(), labels.next(), labels.next())
8353        else {
8354            return false;
8355        };
8356        second.eq_ignore_ascii_case("example")
8357            && third.eq_ignore_ascii_case("com")
8358            && front_label_matches(front, first)
8359    }
8360
8361    /// `PathRule::Prefix` semantics (`PathRule::matches`, `router/mod.rs`):
8362    /// a rule matches iff the query path starts with the declared path's
8363    /// bytes. Every generated front carries exactly one path, so there is
8364    /// no longest-prefix tie-break to model here — see [`GenRule::path`].
8365    fn oracle_path_matches(rule_path: &str, query_path: &str) -> bool {
8366        query_path.starts_with(rule_path)
8367    }
8368
8369    /// The oracle's precedence search, stated in `doc/configure.md` under
8370    /// "Hostname precedence" and restored by sozu#1351: the EXACT name,
8371    /// then a REGEX segment (the first declared among those that match),
8372    /// then the `*` WILDCARD — each candidate accepted only when it ALSO
8373    /// serves this request's path (`Router::lookup` hands the trie the
8374    /// same path-serving predicate it uses on the winner, so a hostname
8375    /// candidate that matches but does not serve this path is skipped
8376    /// rather than ending the search — `doc/configure.md`, "Hostname
8377    /// precedence": "a search, not a filter"). Exact-vs-regex hostname
8378    /// precedence is checked in a pass that does not depend on `rules`'
8379    /// order at all — the documented, order-independent half of the
8380    /// invariant sozu#1351 restored. Regex-vs-regex precedence is "first
8381    /// in `rules`' own order that matches (hostname AND path)" — the half
8382    /// that DOES still depend on declaration order, by design
8383    /// (`doc/configure.md`, "Regex hostname segments").
8384    fn oracle_lookup<'r>(rules: &'r [GenRule], hostname: &str, path: &str) -> Option<&'r str> {
8385        let serves = |rule: &&GenRule| {
8386            oracle_hostname_matches(rule.front, hostname) && oracle_path_matches(rule.path, path)
8387        };
8388        rules
8389            .iter()
8390            .find(|rule| matches!(rule.front, FrontLabel::Literal(_)) && serves(rule))
8391            .or_else(|| {
8392                rules
8393                    .iter()
8394                    .find(|rule| matches!(rule.front, FrontLabel::Regex(_)) && serves(rule))
8395            })
8396            .or_else(|| {
8397                rules
8398                    .iter()
8399                    .find(|rule| matches!(rule.front, FrontLabel::Wildcard) && serves(rule))
8400            })
8401            .map(|rule| rule.cluster.as_str())
8402    }
8403
8404    /// Vary the case of `s`: as declared, all-uppercase, all-lowercase, or
8405    /// a per-character random mix — independently of whatever case the
8406    /// OTHER side (rule vs. query) picked, so RFC 9110 §4.2.3
8407    /// case-insensitivity (sozu#1349) is exercised on both sides and not
8408    /// just one.
8409    fn case_variant(g: &mut Gen, s: &str) -> String {
8410        match u8::arbitrary(g) % 4 {
8411            0 => s.to_owned(),
8412            1 => s.to_ascii_uppercase(),
8413            2 => s.to_ascii_lowercase(),
8414            _ => s
8415                .chars()
8416                .map(|c| {
8417                    if bool::arbitrary(g) {
8418                        c.to_ascii_uppercase()
8419                    } else {
8420                        c.to_ascii_lowercase()
8421                    }
8422                })
8423                .collect(),
8424        }
8425    }
8426
8427    fn push_unique_front(
8428        front: FrontLabel,
8429        path: &'static str,
8430        rules: &mut Vec<GenRule>,
8431        seen: &mut Vec<FrontKey>,
8432    ) {
8433        let key = FrontKey::from(front);
8434        if seen.contains(&key) {
8435            return;
8436        }
8437        seen.push(key);
8438        let cluster = format!("C{}", rules.len());
8439        rules.push(GenRule {
8440            front,
8441            path,
8442            cluster,
8443        });
8444    }
8445
8446    /// One hit and one miss hostname sample per declared front (see
8447    /// [`RegexCase`]'s doc comment for what those pin), plus three
8448    /// universal hostname probes every scenario gets regardless of what it
8449    /// declared: an unrelated host that must match nothing, the bare
8450    /// suffix with the front label dropped entirely (must miss even a
8451    /// declared wildcard, which stands for exactly one label and not
8452    /// zero), and its uppercase spelling.
8453    ///
8454    /// Each hit hostname is queried at EVERY distinct path declared
8455    /// anywhere in the scenario, not just its own front's path — sozu#1351's
8456    /// leak crosses fronts (an exact rule's cluster answering a DIFFERENT
8457    /// hostname, at whatever path THAT hostname's own rule declared), so a
8458    /// query pairing that only ever asked a front's own path could not
8459    /// observe it. Miss hostnames and the universal probes are checked only
8460    /// at their own front's path (or `/`): they exist to confirm a mismatch
8461    /// does not appear where none is expected, which does not need the
8462    /// cross product.
8463    fn push_hit_queries(
8464        g: &mut Gen,
8465        label: &str,
8466        declared_paths: &[&str],
8467        queries: &mut Vec<(String, String)>,
8468    ) {
8469        for &path in declared_paths {
8470            queries.push((
8471                format!("{}.example.com", case_variant(g, label)),
8472                path.to_owned(),
8473            ));
8474        }
8475    }
8476
8477    fn generate_queries(g: &mut Gen, rules: &[GenRule]) -> Vec<(String, String)> {
8478        let mut queries = Vec::new();
8479        let mut declared_paths: Vec<&str> = rules.iter().map(|rule| rule.path).collect();
8480        declared_paths.sort_unstable();
8481        declared_paths.dedup();
8482
8483        for rule in rules {
8484            match rule.front {
8485                FrontLabel::Literal(text) => {
8486                    push_hit_queries(g, text, &declared_paths, &mut queries);
8487                }
8488                FrontLabel::Wildcard => {
8489                    let label = *g.choose(LITERAL_POOL).unwrap_or(&"zz");
8490                    push_hit_queries(g, label, &declared_paths, &mut queries);
8491                }
8492                FrontLabel::Regex(idx) => {
8493                    let case = &REGEX_CATALOG[idx];
8494                    if let Some(&hit) = g.choose(case.hits) {
8495                        push_hit_queries(g, hit, &declared_paths, &mut queries);
8496                    }
8497                    if let Some(miss) = g.choose(case.misses) {
8498                        queries.push((
8499                            format!("{}.example.com", case_variant(g, miss)),
8500                            rule.path.to_owned(),
8501                        ));
8502                    }
8503                }
8504            }
8505        }
8506
8507        queries.push((
8508            format!("{}.example.com", case_variant(g, "unmatched-host")),
8509            "/".to_owned(),
8510        ));
8511        queries.push(("example.com".to_owned(), "/".to_owned()));
8512        queries.push(("EXAMPLE.COM".to_owned(), "/".to_owned()));
8513
8514        queries
8515    }
8516
8517    impl Arbitrary for Scenario {
8518        fn arbitrary(g: &mut Gen) -> Self {
8519            let mut rules = Vec::new();
8520            let mut seen_keys = Vec::new();
8521
8522            let pick_path = |g: &mut Gen| {
8523                *g.choose(PATH_POOL)
8524                    .expect("PATH_POOL is a non-empty const slice")
8525            };
8526
8527            // With even odds, include the wildcard: it participates in
8528            // every precedence chain and costs only one extra rule.
8529            if bool::arbitrary(g) {
8530                let path = pick_path(g);
8531                push_unique_front(FrontLabel::Wildcard, path, &mut rules, &mut seen_keys);
8532            }
8533
8534            // 1..=3 additional fronts, each independently a regex segment
8535            // or a literal drawn from the overlap-biased pool, each with
8536            // its own independently chosen path.
8537            let extra = 1 + (u8::arbitrary(g) % 3);
8538            for _ in 0..extra {
8539                if bool::arbitrary(g) {
8540                    let idx = usize::from(u8::arbitrary(g)) % REGEX_CATALOG.len();
8541                    let path = pick_path(g);
8542                    push_unique_front(FrontLabel::Regex(idx), path, &mut rules, &mut seen_keys);
8543                } else {
8544                    let text = *g
8545                        .choose(LITERAL_POOL)
8546                        .expect("LITERAL_POOL is a non-empty const slice");
8547                    let path = pick_path(g);
8548                    push_unique_front(FrontLabel::Literal(text), path, &mut rules, &mut seen_keys);
8549                }
8550            }
8551
8552            // Declaration order is itself under test (sozu#1351): shuffle
8553            // with a Fisher-Yates driven by the same `Gen`, independent of
8554            // insertion order above.
8555            for i in (1..rules.len()).rev() {
8556                let j = usize::from(u8::arbitrary(g)) % (i + 1);
8557                rules.swap(i, j);
8558            }
8559
8560            let queries = generate_queries(g, &rules);
8561            Scenario { rules, queries }
8562        }
8563
8564        fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
8565            let mut shrunk = Vec::new();
8566
8567            for i in 0..self.rules.len() {
8568                let mut rules = self.rules.clone();
8569                rules.remove(i);
8570                shrunk.push(Scenario {
8571                    rules,
8572                    queries: self.queries.clone(),
8573                });
8574            }
8575
8576            for i in 0..self.queries.len() {
8577                let mut queries = self.queries.clone();
8578                queries.remove(i);
8579                shrunk.push(Scenario {
8580                    rules: self.rules.clone(),
8581                    queries,
8582                });
8583            }
8584
8585            Box::new(shrunk.into_iter())
8586        }
8587    }
8588
8589    /// Every catalog entry's own `hits`/`misses` verdicts, checked against
8590    /// nothing but [`oracle_regex_matches`] — a self-check that the
8591    /// harness's table is internally consistent BEFORE it is trusted to
8592    /// judge the router. This does not touch `Router` or `TrieNode` at
8593    /// all.
8594    #[test]
8595    fn regex_catalog_samples_match_their_own_documented_semantics() {
8596        for case in REGEX_CATALOG {
8597            for hit in case.hits {
8598                assert!(
8599                    oracle_regex_matches(case.source, hit),
8600                    "{:?} must match {hit:?} under \\A(?:...)\\z case-insensitive",
8601                    case.source,
8602                );
8603            }
8604            for miss in case.misses {
8605                assert!(
8606                    !oracle_regex_matches(case.source, miss),
8607                    "{:?} must NOT match {miss:?} under \\A(?:...)\\z case-insensitive",
8608                    case.source,
8609                );
8610            }
8611        }
8612    }
8613
8614    /// Build the rules `scenario` declares into a fresh [`Router`], in
8615    /// its own declaration order, then check every query against both the
8616    /// oracle and the router.
8617    fn oracle_matches_router(scenario: Scenario) -> TestResult {
8618        if scenario.rules.is_empty() {
8619            return TestResult::discard();
8620        }
8621
8622        let mut router = Router::new();
8623        for rule in &scenario.rules {
8624            let hostname = render_hostname(rule.front);
8625            // A refused insert here is not automatically dismissed as a
8626            // harness artifact: sozu#1351's own mechanism (a literal
8627            // resolving through a matching regex segment because
8628            // `TrieNode::lookup_mut`'s guard is gone) can make this EXACT
8629            // call return `false`, when the two fronts' generated paths
8630            // happen to coincide -- see the module doc comment. Every
8631            // front here is still unique (`FrontKey`-deduped) and every
8632            // catalog regex compiles standalone, so a refusal is real
8633            // signal either way: fail loudly and report it rather than
8634            // silently skipping the rule, which would just hide the
8635            // question inside a `None` at lookup time instead of at
8636            // insert time.
8637            if !router.add_tree_rule(
8638                hostname.as_bytes(),
8639                &PathRule::Prefix(rule.path.to_owned()),
8640                &MethodRule::new(None),
8641                &Route::ClusterId(rule.cluster.as_str().into()),
8642            ) {
8643                eprintln!(
8644                    "INSERT REFUSED hostname={hostname:?} path={:?} cluster={:?} \
8645                     declared-so-far={:?}",
8646                    rule.path,
8647                    rule.cluster,
8648                    scenario
8649                        .rules
8650                        .iter()
8651                        .map(|r| (render_hostname(r.front), r.path))
8652                        .collect::<Vec<_>>(),
8653                );
8654                return TestResult::failed();
8655            }
8656        }
8657
8658        for (hostname, path) in &scenario.queries {
8659            let expected = oracle_lookup(&scenario.rules, hostname, path);
8660            let actual = router
8661                .lookup(hostname, path, &Method::Get)
8662                .ok()
8663                .and_then(|result| result.cluster_id);
8664
8665            if expected != actual.as_deref() {
8666                let declared: Vec<(String, &str)> = scenario
8667                    .rules
8668                    .iter()
8669                    .map(|rule| (render_hostname(rule.front), rule.path))
8670                    .collect();
8671                eprintln!(
8672                    "MISMATCH host={hostname:?} path={path:?} expected={expected:?} \
8673                     actual={actual:?} declared (in order, with path)={declared:?}",
8674                );
8675                return TestResult::failed();
8676            }
8677        }
8678
8679        TestResult::passed()
8680    }
8681
8682    /// Request headers for a header-predicate lookup: first occurrence,
8683    /// names compared case-insensitively — the contract
8684    /// `FrontRequestHeaders` (`lib/src/protocol/mux/router.rs`) implements
8685    /// over a real request.
8686    struct TestHeaders<'a>(&'a [(&'a str, &'a str)]);
8687
8688    impl RequestHeaders for TestHeaders<'_> {
8689        fn header(&self, name: &[u8]) -> Option<&[u8]> {
8690            self.0
8691                .iter()
8692                .find(|(key, _)| key.as_bytes().eq_ignore_ascii_case(name))
8693                .map(|(_, value)| value.as_bytes())
8694        }
8695    }
8696
8697    fn predicate(name: &str, kind: HeaderMatchKind, value: Option<&str>) -> HeaderPredicate {
8698        HeaderPredicate {
8699            name: name.to_owned(),
8700            kind: kind as i32,
8701            value: value.map(str::to_owned),
8702        }
8703    }
8704
8705    fn routed_by_headers(router: &Router, path: &str, headers: &[(&str, &str)]) -> Option<String> {
8706        router
8707            .lookup_with_headers("example.com", path, &Method::Get, &TestHeaders(headers))
8708            .ok()
8709            .and_then(|route| route.cluster_id)
8710            .map(|id| id.to_string())
8711    }
8712
8713    /// sozu-proxy/sozu#1253: a canary frontend that differs from its base
8714    /// only by a header predicate serves exactly the requests carrying the
8715    /// header, whichever of the two was declared first and in every rule
8716    /// position. Before predicates were a routing input, the two frontends
8717    /// had one identity, so the second addition was refused.
8718    #[test]
8719    fn a_header_predicate_canary_outranks_its_base_in_any_declaration_order() {
8720        let canary_predicates = vec![predicate(
8721            "X-Canary",
8722            HeaderMatchKind::Exact,
8723            Some("always"),
8724        )];
8725        for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
8726            for path in [
8727                CommandPathRule::prefix("/api".to_owned()),
8728                CommandPathRule::equals("/api".to_owned()),
8729            ] {
8730                for canary_first in [false, true] {
8731                    let base = HttpFrontend {
8732                        cluster_id: Some("stable".into()),
8733                        path: path.clone(),
8734                        position,
8735                        ..test_http_frontend()
8736                    };
8737                    let canary = HttpFrontend {
8738                        cluster_id: Some("canary".into()),
8739                        header_predicates: canary_predicates.clone(),
8740                        ..base.clone()
8741                    };
8742                    let mut router = Router::new();
8743                    let order = if canary_first {
8744                        [&canary, &base]
8745                    } else {
8746                        [&base, &canary]
8747                    };
8748                    for front in order {
8749                        router
8750                            .add_http_front(front)
8751                            .expect("a canary and its base must coexist");
8752                    }
8753
8754                    let context = format!("{position:?} {path:?} canary_first={canary_first}");
8755                    assert_eq!(
8756                        routed_by_headers(&router, "/api", &[("x-canary", "always")]).as_deref(),
8757                        Some("canary"),
8758                        "{context}: a request carrying the header must reach the canary",
8759                    );
8760                    assert_eq!(
8761                        routed_by_headers(&router, "/api", &[]).as_deref(),
8762                        Some("stable"),
8763                        "{context}: a request without the header must reach the base",
8764                    );
8765                    assert_eq!(
8766                        routed_by_headers(&router, "/api", &[("X-Canary", "Always")]).as_deref(),
8767                        Some("stable"),
8768                        "{context}: an exact value is compared byte for byte",
8769                    );
8770                    assert_eq!(
8771                        router
8772                            .lookup("example.com", "/api", &Method::Get)
8773                            .ok()
8774                            .and_then(|route| route.cluster_id)
8775                            .as_deref(),
8776                        Some("stable"),
8777                        "{context}: a header-less lookup never matches a predicated rule",
8778                    );
8779                }
8780            }
8781        }
8782    }
8783
8784    /// Predicates are a conjunction; names match case-insensitively; the
8785    /// first occurrence of a header answers; a `regex` value matches the
8786    /// WHOLE value, like a `REGEX` path rule; `present` accepts any value,
8787    /// the empty one included.
8788    #[test]
8789    fn header_predicates_hold_together_with_their_documented_semantics() {
8790        let mut router = Router::new();
8791        let front = HttpFrontend {
8792            cluster_id: Some("eu-beta".into()),
8793            header_predicates: vec![
8794                predicate("x-region", HeaderMatchKind::Regex, Some("eu-.*")),
8795                predicate("X-BETA", HeaderMatchKind::Present, None),
8796            ],
8797            ..test_http_frontend()
8798        };
8799        router.add_http_front(&front).expect("valid predicates");
8800        router
8801            .add_http_front(&test_http_frontend())
8802            .expect("the base frontend");
8803
8804        let route = |headers: &[(&str, &str)]| routed_by_headers(&router, "/", headers);
8805        assert_eq!(
8806            route(&[("X-Region", "eu-west"), ("x-beta", "")]).as_deref(),
8807            Some("eu-beta")
8808        );
8809        assert_eq!(
8810            route(&[("X-Region", "eu-west")]).as_deref(),
8811            Some("cluster"),
8812            "every predicate must hold",
8813        );
8814        assert_eq!(
8815            route(&[("X-Region", "us-eu-west"), ("x-beta", "1")]).as_deref(),
8816            Some("cluster"),
8817            "a regex is anchored at the start of the value",
8818        );
8819        assert_eq!(
8820            route(&[
8821                ("X-Region", "us-east"),
8822                ("X-Region", "eu-west"),
8823                ("x-beta", "1")
8824            ])
8825            .as_deref(),
8826            Some("cluster"),
8827            "only the first occurrence of a header answers",
8828        );
8829    }
8830
8831    /// Removing a frontend removes the rule of exactly its predicates: the
8832    /// base survives the canary's removal and the reverse, and a removal
8833    /// that spells the same conjunction in another order or case finds it.
8834    #[test]
8835    fn removing_a_predicated_frontend_leaves_its_base_and_the_reverse() {
8836        for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
8837            let base = HttpFrontend {
8838                cluster_id: Some("stable".into()),
8839                position,
8840                ..test_http_frontend()
8841            };
8842            let canary = HttpFrontend {
8843                cluster_id: Some("canary".into()),
8844                header_predicates: vec![
8845                    predicate("X-Canary", HeaderMatchKind::Present, None),
8846                    predicate("X-Tenant", HeaderMatchKind::Exact, Some("acme")),
8847                ],
8848                ..base.clone()
8849            };
8850            let canary_respelled = HttpFrontend {
8851                header_predicates: vec![
8852                    predicate("x-tenant", HeaderMatchKind::Exact, Some("acme")),
8853                    predicate("x-canary", HeaderMatchKind::Present, None),
8854                ],
8855                ..canary.clone()
8856            };
8857            let canary_headers = [("X-Canary", "1"), ("X-Tenant", "acme")];
8858
8859            let mut router = Router::new();
8860            router.add_http_front(&base).expect("base");
8861            router.add_http_front(&canary).expect("canary");
8862            assert!(
8863                router.add_http_front(&canary_respelled).is_err(),
8864                "{position:?}: a respelled conjunction is the same rule",
8865            );
8866            router
8867                .remove_http_front(&canary_respelled)
8868                .expect("a respelled removal must find the canary");
8869            assert_eq!(
8870                routed_by_headers(&router, "/", &canary_headers).as_deref(),
8871                Some("stable"),
8872                "{position:?}: the base must survive the canary's removal",
8873            );
8874
8875            router.add_http_front(&canary).expect("canary again");
8876            router.remove_http_front(&base).expect("base removal");
8877            assert_eq!(
8878                routed_by_headers(&router, "/", &canary_headers).as_deref(),
8879                Some("canary"),
8880                "{position:?}: the canary must survive the base's removal",
8881            );
8882            assert_eq!(
8883                routed_by_headers(&router, "/", &[]),
8884                None,
8885                "{position:?}: without its base, a request lacking the header has no route",
8886            );
8887        }
8888    }
8889
8890    /// A hostname whose only rules are predicated, none holding, hands the
8891    /// request to the next hostname candidate instead of ending the lookup
8892    /// (the sozu#1351 shape): the trie's acceptance predicate sees headers.
8893    #[test]
8894    fn an_unsatisfied_predicated_hostname_falls_through_to_the_wildcard() {
8895        let mut router = Router::new();
8896        router
8897            .add_http_front(&HttpFrontend {
8898                cluster_id: Some("wildcard".into()),
8899                hostname: "*.example.com".to_owned(),
8900                ..test_http_frontend()
8901            })
8902            .expect("wildcard");
8903        router
8904            .add_http_front(&HttpFrontend {
8905                cluster_id: Some("exact".into()),
8906                hostname: "www.example.com".to_owned(),
8907                header_predicates: vec![predicate("X-Canary", HeaderMatchKind::Present, None)],
8908                ..test_http_frontend()
8909            })
8910            .expect("predicated exact host");
8911
8912        let route = |headers: &[(&str, &str)]| {
8913            router
8914                .lookup_with_headers("www.example.com", "/", &Method::Get, &TestHeaders(headers))
8915                .ok()
8916                .and_then(|route| route.cluster_id)
8917                .map(|id| id.to_string())
8918        };
8919        assert_eq!(route(&[("x-canary", "")]).as_deref(), Some("exact"));
8920        assert_eq!(route(&[]).as_deref(), Some("wildcard"));
8921    }
8922
8923    /// A predicate no request could satisfy, or that cannot be compiled, is
8924    /// refused at frontend addition — which `validate_frontend_request`
8925    /// (`bin/src/command/requests.rs`) also runs in the main process — and
8926    /// the error carries the predicate's index, never its name or value.
8927    #[test]
8928    fn unusable_header_predicates_are_refused_without_echoing_them() {
8929        let refused = [
8930            predicate("Host", HeaderMatchKind::Exact, Some("example.com")),
8931            predicate("cookie", HeaderMatchKind::Present, None),
8932            predicate("X Bad", HeaderMatchKind::Present, None),
8933            predicate("", HeaderMatchKind::Present, None),
8934            predicate("X-Debug", HeaderMatchKind::Present, Some("1")),
8935            predicate("X-Tenant", HeaderMatchKind::Exact, None),
8936            predicate("X-Tenant", HeaderMatchKind::Regex, None),
8937            predicate("X-Tenant", HeaderMatchKind::Exact, Some("a\r\nb")),
8938            predicate("X-Tenant", HeaderMatchKind::Regex, Some("a)(b")),
8939            HeaderPredicate {
8940                name: "X-Tenant".to_owned(),
8941                kind: 42,
8942                value: None,
8943            },
8944        ];
8945        for bad in refused {
8946            let mut router = Router::new();
8947            let front = HttpFrontend {
8948                header_predicates: vec![
8949                    predicate("X-Valid", HeaderMatchKind::Present, None),
8950                    bad.clone(),
8951                ],
8952                ..test_http_frontend()
8953            };
8954            let error = router
8955                .add_http_front(&front)
8956                .expect_err("an unusable predicate must be refused");
8957            assert!(
8958                matches!(error, RouterError::InvalidHeaderPredicate { index: 1, .. }),
8959                "{bad:?}: unexpected {error}",
8960            );
8961            let message = error.to_string();
8962            assert!(
8963                !message.contains("X-Tenant") && !message.contains("example.com"),
8964                "the error must not echo the predicate: {message}",
8965            );
8966            assert!(router.tree.is_empty(), "a refused frontend adds no rule");
8967        }
8968    }
8969
8970    quickcheck! {
8971        fn qc_router_hostname_resolution_matches_the_documented_semantics(scenario: Scenario) -> TestResult {
8972            oracle_matches_router(scenario)
8973        }
8974    }
8975}