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