Skip to main content

sozu_lib/router/
mod.rs

1pub mod pattern_trie;
2
3use std::{
4    borrow::Cow,
5    fmt::{self, Debug, Write},
6    rc::Rc,
7    str::from_utf8,
8    time::Instant,
9};
10
11use regex::bytes::Regex;
12use sozu_command::{
13    config::validate_header_predicate,
14    logging::CachedTags,
15    proto::command::{
16        HeaderMatchKind, HeaderPosition, HeaderPredicate, HstsConfig, PathRule as CommandPathRule,
17        PathRuleKind, RedirectPolicy, RedirectScheme, RulePosition,
18    },
19    response::HttpFrontend,
20    state::ClusterId,
21};
22
23use crate::metrics::names;
24use crate::{
25    protocol::{http::editor::HeaderEditMode, http::parser::Method},
26    router::pattern_trie::{InlineTrieMatches, InsertResult, TrieNode, TrieSubMatch},
27    sozu_command::logging::ansi_palette,
28};
29
30/// Module-level prefix tag for `lib/src/router/`. Honours the runtime
31/// colored-output flag via [`ansi_palette`]; consumed by the single
32/// `warn!` site in `Frontend::new` (and any future emitter without an
33/// `HttpContext` in scope) so static log-layout regression checks
34/// (`lib/tests/log_layout.rs`) keep router log lines on the canonical
35/// `[ROUTER] >>>` envelope.
36macro_rules! log_module_context {
37    () => {{
38        let (open, reset, _, _, _) = ansi_palette();
39        format!("{open}ROUTER{reset}\t >>>", open = open, reset = reset)
40    }};
41}
42
43/// Upper bound (in bytes) on a frontend hostname accepted by the router,
44/// checked by [`Router::add_http_front_with_hsts_origin`] and
45/// [`Router::remove_http_front`] before anything parses the hostname.
46///
47/// RFC 1035 caps a domain name at 255 octets; Sōzu's regex-segment
48/// grammar (`/re/.example.com`) can legitimately exceed that, so this is
49/// a deliberately generous safety net rather than a strict RFC bound. It
50/// exists because the route-table trie recurses once per label — a
51/// hostname with ~100k labels aborts the worker with an uncatchable
52/// stack overflow — and because the regex compilation a `/`-segment
53/// triggers costs time linear in the pattern size (a 2 MiB hostname
54/// stalls the single-threaded worker for ~855 ms before the regex size
55/// limit rejects it).
56pub const MAX_HOSTNAME_LENGTH: usize = 4096;
57
58#[derive(thiserror::Error, PartialEq)]
59pub enum RouterError {
60    #[error("Could not parse rule from frontend path, path_bytes={}", .0.len())]
61    InvalidPathRule(String),
62    #[error("parsing hostname failed, hostname_bytes={}", .hostname.len())]
63    InvalidDomain { hostname: String },
64    #[error("Could not parse host rewrite, rewrite_host_bytes={}", .0.len())]
65    InvalidHostRewrite(String),
66    #[error("Could not parse path rewrite, rewrite_path_bytes={}", .0.len())]
67    InvalidPathRewrite(String),
68    #[error("Could not add route, route_bytes={}", .0.len())]
69    AddRoute(String),
70    /// A frontend header predicate is not a usable routing condition
71    /// (sozu-proxy/sozu#1253). Carries the predicate's position and a fixed
72    /// reason, never the operator's name or value.
73    #[error("invalid header predicate at index {index}: {reason}")]
74    InvalidHeaderPredicate { index: usize, reason: &'static str },
75    #[error("Could not remove route, route_bytes={}", .0.len())]
76    RemoveRoute(String),
77    #[error("route_not_found method={method:?} host_bytes={} path_bytes={}", .host.len(), .path.len())]
78    RouteNotFound {
79        host: String,
80        path: String,
81        method: Method,
82    },
83}
84
85impl fmt::Debug for RouterError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        fmt::Display::fmt(self, f)
88    }
89}
90
91/// `Clone` lets a listener added next to a sibling on the same address take
92/// over its routes (`HttpProxy::add_listener`, `lib/src/http.rs`).
93#[derive(Clone)]
94pub struct Router {
95    pre: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
96    pub tree: TrieNode<Vec<(PathRule, MethodRule, HeaderRules, Route)>>,
97    post: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
98}
99
100impl Default for Router {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl Router {
107    pub fn new() -> Router {
108        Router {
109            pre: Vec::new(),
110            tree: TrieNode::root(),
111            post: Vec::new(),
112        }
113    }
114
115    /// Resolve a request to a [`RouteResult`].
116    ///
117    /// Looks up `(hostname, path, method)` against the pre, tree, and post
118    /// rule lists. The matched [`Route`] is converted into a [`RouteResult`]:
119    /// legacy variants ([`Route::ClusterId`], [`Route::Deny`]) synthesize a
120    /// minimal `RouteResult` so existing call sites keep working, while
121    /// [`Route::Frontend`] runs the full Frontend → RouteResult pipeline,
122    /// substituting host/path captures into [`RewriteParts`] templates.
123    ///
124    /// A request looked up here carries no header, so a rule with header
125    /// predicates never matches it; see [`Self::lookup_with_headers`].
126    pub fn lookup(
127        &self,
128        hostname: &str,
129        path: &str,
130        method: &Method,
131    ) -> Result<RouteResult, RouterError> {
132        self.lookup_with_headers(hostname, path, method, &NoHeaders)
133    }
134
135    /// [`Self::lookup`], with the request's headers for the frontends that
136    /// carry header predicates (sozu-proxy/sozu#1253).
137    ///
138    /// Within each of the pre list, one trie leaf and the post list, a rule
139    /// whose predicates all hold outranks every rule without predicates;
140    /// among rules of one class the historical precedence applies
141    /// unchanged — declaration order for pre/post, the path/method order of
142    /// the private `select_tree_rule` for the tree. A rule whose predicates do not all
143    /// hold is skipped, exactly like a rule whose method does not match. A
144    /// route table without predicates therefore resolves every request as
145    /// it did before, and `headers` is never read.
146    pub fn lookup_with_headers(
147        &self,
148        hostname: &str,
149        path: &str,
150        method: &Method,
151        headers: &dyn RequestHeaders,
152    ) -> Result<RouteResult, RouterError> {
153        // The route table stores every hostname LABEL ASCII-lowercased
154        // (`add_tree_rule` via `tree_hostname_to_ascii`, and
155        // `DomainRule::from_str` via `idna::domain_to_ascii`; a regex
156        // segment keeps the operator's bytes on both paths and is
157        // compiled case-insensitively instead — see
158        // `tree_hostname_to_ascii`), so the lookup key is normalised the
159        // same way — otherwise the byte-exact trie walk and the
160        // byte-exact `Exact`/`Wildcard` comparisons below can never match
161        // an uppercase `Host:` header. See [`normalize_hostname`] for why
162        // this is a scan and not an allocation on the common path.
163        //
164        // The captures handed to `RouteResult` are taken from this
165        // normalised key, so `$HOST[n]` rewrite templates see the
166        // normalised host; the raw bytes the client sent stay on
167        // `HttpContext.authority`, which feeds the access log,
168        // `X-Forwarded-Host` and the redirect `Location:` and is not
169        // touched here. `RouteNotFound` below deliberately reports the
170        // ORIGINAL spelling, because that is the diagnostic an operator
171        // needs.
172        let normalized_hostname = normalize_hostname(hostname);
173        let hostname_b = normalized_hostname.as_bytes();
174        let path_b = path.as_bytes();
175        if let Some((domain_rule, path_rule, _, _, route)) =
176            select_listed_rule(&self.pre, hostname_b, path_b, method, headers)
177        {
178            return Ok(RouteResult::new_no_trie(
179                hostname_b,
180                domain_rule,
181                path_b,
182                path_rule,
183                route,
184            ));
185        }
186
187        // The hostname candidates the trie holds are tried
188        // most-specific-first — exact name, then regex segments in
189        // declaration order, then the `*` wildcard — and `select_tree_rule`
190        // is what decides whether a candidate serves THIS request. Handing
191        // it to the trie as the acceptance predicate is what makes the
192        // order a search rather than a filter: a hostname whose rules all
193        // reject this (path, method) hands the request to the next
194        // candidate instead of ending the lookup (sozu#1351).
195        //
196        // The segments the walk matched are recorded on the stack, so a
197        // lookup allocates nothing of its own; see `InlineTrieMatches`.
198        let mut trie_matches = InlineTrieMatches::new();
199        if let Some((_, path_rules)) = self.tree.lookup_with_inline_path(
200            hostname_b,
201            true,
202            &mut trie_matches,
203            &mut |(_, path_rules)| select_tree_rule(path_rules, path_b, method, headers).is_some(),
204        ) && let Some((path_rule, route)) = select_tree_rule(path_rules, path_b, method, headers)
205        {
206            // The second call cannot disagree with the predicate: same
207            // pure function, same leaf, same request. Re-running it is how
208            // the winning rule is carried out of a closure that may only
209            // answer yes or no.
210            //
211            // It costs a second scan of the winning leaf's rule list, so
212            // the common single-candidate hit now scans twice where it
213            // scanned once. The list is the rules of ONE hostname and the
214            // scan is a `PathRule::matches` per entry; carrying the
215            // selection out of the closure instead would need `accept` to
216            // borrow for the trie's own `'b`, which ties the predicate to
217            // the tree borrow for a saving of one pass over a short Vec.
218            // Revisit if a leaf ever holds enough rules to matter.
219            return Ok(RouteResult::new_with_trie(
220                hostname_b,
221                &trie_matches,
222                path_b,
223                path_rule,
224                route,
225            ));
226        }
227
228        if let Some((domain_rule, path_rule, _, _, route)) =
229            select_listed_rule(&self.post, hostname_b, path_b, method, headers)
230        {
231            return Ok(RouteResult::new_no_trie(
232                hostname_b,
233                domain_rule,
234                path_b,
235                path_rule,
236                route,
237            ));
238        }
239
240        Err(RouterError::RouteNotFound {
241            host: hostname.to_owned(),
242            path: path.to_owned(),
243            method: method.to_owned(),
244        })
245    }
246
247    /// Add an HTTP/HTTPS frontend whose `hsts` field (if any) came from
248    /// the per-frontend configuration directly. Equivalent to
249    /// [`Self::add_http_front_with_hsts_origin`] called with
250    /// [`HstsOrigin::Explicit`]. The default for callers that don't
251    /// know about listener-default inheritance — e.g. plain HTTP
252    /// listeners (`HttpListenerConfig` has no HSTS field) and tests.
253    pub fn add_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
254        self.add_http_front_with_hsts_origin(front, HstsOrigin::Explicit)
255    }
256
257    /// Add an HTTP/HTTPS frontend, recording whether the resolved
258    /// `front.hsts` was inherited from the listener default. The
259    /// inheritance bit is preserved on the resulting [`Frontend`] so a
260    /// later `UpdateHttpsListenerConfig.hsts` patch can reflow the new
261    /// default onto inheriting entries without disturbing explicit
262    /// per-frontend overrides.
263    pub fn add_http_front_with_hsts_origin(
264        &mut self,
265        front: &HttpFrontend,
266        hsts_origin: HstsOrigin,
267    ) -> Result<(), RouterError> {
268        // Bounded BEFORE any parse: the `DomainRule` parse below compiles
269        // control-plane-supplied regex segments, and the trie recurses
270        // once per label (see `MAX_HOSTNAME_LENGTH`).
271        if front.hostname.len() > MAX_HOSTNAME_LENGTH {
272            return Err(RouterError::InvalidDomain {
273                hostname: front.hostname.clone(),
274            });
275        }
276
277        let path_rule = PathRule::from_config(front.path.clone())
278            .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
279
280        let method_rule = MethodRule::new(front.method.clone());
281
282        let header_rules = HeaderRules::from_config(&front.header_predicates)?;
283
284        // Decide between the legacy `Route::ClusterId`/`Route::Deny` shape
285        // and the rich `Route::Frontend(Rc<Frontend>)` shape: any non-
286        // default policy field flips us onto the rich path so the mux
287        // can honour redirect/rewrite/headers/auth at request time.
288        //
289        // `tags` counts as such a field. The legacy shapes carry no tags
290        // (`RouteResult::forward` / `::deny` both set `tags: None`), so a
291        // tagged frontend stored as `Route::ClusterId` would hand the mux
292        // a tagless routing decision and its access logs would have to
293        // fall back to the authority-keyed listener map — the exact
294        // spelling mismatch of sozu#1379. Only tagged frontends pay the
295        // `Rc<Frontend>`; an untagged one keeps the lightweight shape.
296        let has_policy = front.redirect.is_some()
297            || front.redirect_scheme.is_some()
298            || front.redirect_template.is_some()
299            || front.rewrite_host.is_some()
300            || front.rewrite_path.is_some()
301            || front.rewrite_port.is_some()
302            || front.required_auth.unwrap_or(false)
303            || !front.headers.is_empty()
304            || front.hsts.is_some()
305            || front.tags.is_some();
306
307        let domain =
308            front
309                .hostname
310                .parse::<DomainRule>()
311                .map_err(|_| RouterError::InvalidDomain {
312                    hostname: front.hostname.clone(),
313                })?;
314
315        let route = if has_policy {
316            let redirect = front
317                .redirect
318                .and_then(|r| RedirectPolicy::try_from(r).ok())
319                .unwrap_or(RedirectPolicy::Forward);
320            let redirect_scheme = front
321                .redirect_scheme
322                .and_then(|s| RedirectScheme::try_from(s).ok())
323                .unwrap_or(RedirectScheme::UseSame);
324            let frontend = Frontend::new(
325                &domain,
326                &path_rule,
327                front,
328                redirect,
329                redirect_scheme,
330                front.redirect_template.clone(),
331                front.rewrite_host.clone(),
332                front.rewrite_path.clone(),
333                front.rewrite_port.and_then(|p| u16::try_from(p).ok()),
334                &front.headers,
335                front.required_auth.unwrap_or(false),
336                hsts_origin,
337            )?;
338            Route::Frontend(Rc::new(frontend))
339        } else {
340            match &front.cluster_id {
341                Some(cluster_id) => Route::ClusterId(cluster_id.clone()),
342                None => Route::Deny,
343            }
344        };
345
346        let success = match front.position {
347            RulePosition::Pre => self.add_pre_rule_with_headers(
348                &domain,
349                &path_rule,
350                &method_rule,
351                &header_rules,
352                &route,
353            ),
354            RulePosition::Post => self.add_post_rule_with_headers(
355                &domain,
356                &path_rule,
357                &method_rule,
358                &header_rules,
359                &route,
360            ),
361            RulePosition::Tree => self.add_tree_rule_with_headers(
362                front.hostname.as_bytes(),
363                &path_rule,
364                &method_rule,
365                &header_rules,
366                &route,
367            ),
368        };
369        if !success {
370            return Err(RouterError::AddRoute(format!("{front:?}")));
371        }
372        Ok(())
373    }
374
375    pub fn remove_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
376        // Same bound as `add_http_front_with_hsts_origin`: the Pre/Post
377        // arms below re-parse the hostname into a `DomainRule`.
378        if front.hostname.len() > MAX_HOSTNAME_LENGTH {
379            return Err(RouterError::InvalidDomain {
380                hostname: front.hostname.clone(),
381            });
382        }
383
384        let path_rule = PathRule::from_config(front.path.clone())
385            .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
386
387        let method_rule = MethodRule::new(front.method.clone());
388
389        let header_rules = HeaderRules::from_config(&front.header_predicates)?;
390
391        let remove_success = match front.position {
392            RulePosition::Pre => {
393                let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
394                    RouterError::InvalidDomain {
395                        hostname: front.hostname.clone(),
396                    }
397                })?;
398
399                self.remove_pre_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
400            }
401            RulePosition::Post => {
402                let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
403                    RouterError::InvalidDomain {
404                        hostname: front.hostname.clone(),
405                    }
406                })?;
407
408                self.remove_post_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
409            }
410            RulePosition::Tree => self.remove_tree_rule_with_headers(
411                front.hostname.as_bytes(),
412                &path_rule,
413                &method_rule,
414                &header_rules,
415            ),
416        };
417        if !remove_success {
418            return Err(RouterError::RemoveRoute(format!("{front:?}")));
419        }
420        Ok(())
421    }
422
423    /// [`Self::add_tree_rule_with_headers`] for a rule without header
424    /// predicates.
425    pub fn add_tree_rule(
426        &mut self,
427        hostname: &[u8],
428        path: &PathRule,
429        method: &MethodRule,
430        cluster: &Route,
431    ) -> bool {
432        self.add_tree_rule_with_headers(hostname, path, method, &HeaderRules::default(), cluster)
433    }
434
435    /// Add a tree rule. Its identity in the leaf is `(path, method,
436    /// headers)`: rules that differ only by their header predicates coexist.
437    pub fn add_tree_rule_with_headers(
438        &mut self,
439        hostname: &[u8],
440        path: &PathRule,
441        method: &MethodRule,
442        headers: &HeaderRules,
443        cluster: &Route,
444    ) -> bool {
445        let hostname = match from_utf8(hostname) {
446            Err(_) => return false,
447            Ok(h) => h,
448        };
449
450        match tree_hostname_to_ascii(hostname) {
451            Some(hostname) => {
452                //FIXME: necessary ti build on stable rust (1.35), can be removed once 1.36 is there
453                let mut empty = true;
454                if let Some((_, paths)) = self.tree.domain_lookup_mut(hostname.as_bytes(), false) {
455                    empty = false;
456                    let before = paths.len();
457                    if !paths
458                        .iter()
459                        .any(|(p, m, h, _)| p == path && m == method && h == headers)
460                    {
461                        paths.push((
462                            path.to_owned(),
463                            method.to_owned(),
464                            headers.to_owned(),
465                            cluster.to_owned(),
466                        ));
467                        // Append must add exactly one (path, method) leaf
468                        // and the new rule must now be present.
469                        debug_assert_eq!(
470                            paths.len(),
471                            before + 1,
472                            "appending a tree rule must grow the leaf's rule list by exactly one",
473                        );
474                        debug_assert!(
475                            paths
476                                .iter()
477                                .any(|(p, m, h, _)| p == path && m == method && h == headers),
478                            "the freshly appended (path, method, headers) rule must be present after insert",
479                        );
480                        return true;
481                    }
482                }
483
484                if empty {
485                    // Snapshot the ASCII host bytes before the move so the
486                    // post-insert reachability check can re-look-up the
487                    // domain. Ungated `let` (read only inside the gated
488                    // assert) → dropped by the optimizer in release.
489                    let inserted_host = hostname.clone().into_bytes();
490                    let insert_result = self.tree.domain_insert(
491                        hostname.into_bytes(),
492                        vec![(
493                            path.to_owned(),
494                            method.to_owned(),
495                            headers.to_owned(),
496                            cluster.to_owned(),
497                        )],
498                    );
499                    // A malformed hostname reaches us straight from the
500                    // control plane (`AddHttpFrontend` over the command
501                    // socket, or a `LoadState` replay), so the route table
502                    // rejecting it is an expected outcome, not a Sozu bug:
503                    // report the failure and let the caller answer the
504                    // request with an error. Shapes the trie rejects
505                    // include a host ending in `/` with no openable regex
506                    // segment (`example.com/`), a regex segment that is not
507                    // `.`-anchored (`abc/[0-9]+/.example.com`), a segment
508                    // that is not a valid regex (`/[/.example.com`), and an
509                    // empty label (`.example.com`).
510                    if insert_result == InsertResult::Failed {
511                        // Redacted like every other router log site: the
512                        // shape is what matters here. `RouterError::AddRoute`
513                        // is redacting too (`route_bytes=<len>`), so the
514                        // hostname itself is only visible where the main
515                        // process audit-logs the request it fanned out
516                        // (`bin/src/command/requests.rs`) -- correlate by
517                        // timestamp to identify the offending frontend.
518                        error!(
519                            "{} the route table rejected a malformed hostname, hostname_bytes={}",
520                            log_module_context!(),
521                            inserted_host.len(),
522                        );
523                        return false;
524                    }
525                    // A fresh domain must now be reachable, carrying the
526                    // single rule just inserted. Use `domain_lookup_mut`
527                    // (not the immutable `domain_lookup`): only the `_mut`
528                    // resolver handles a literal wildcard key (`*.sozu.io`)
529                    // via its `partial_key == b"*"` segment case, which is
530                    // exactly the resolution the append branch above relies
531                    // on. The immutable `lookup` lacks that case and would
532                    // miss wildcard entries.
533                    debug_assert!(
534                        self.tree
535                            .domain_lookup_mut(&inserted_host, false)
536                            .is_some_and(|(_, paths)| paths
537                                .iter()
538                                .any(|(p, m, h, _)| p == path && m == method && h == headers)),
539                        "a freshly inserted tree domain must resolve to its inserted rule",
540                    );
541                    return true;
542                }
543
544                false
545            }
546            None => false,
547        }
548    }
549
550    /// [`Self::remove_tree_rule_with_headers`] for a rule without header
551    /// predicates.
552    pub fn remove_tree_rule(
553        &mut self,
554        hostname: &[u8],
555        path: &PathRule,
556        method: &MethodRule,
557        // _cluster: &Route,
558    ) -> bool {
559        self.remove_tree_rule_with_headers(hostname, path, method, &HeaderRules::default())
560    }
561
562    /// Remove the tree rule whose identity is `(path, method, headers)`; a
563    /// rule that differs only by its header predicates stays.
564    pub fn remove_tree_rule_with_headers(
565        &mut self,
566        hostname: &[u8],
567        path: &PathRule,
568        method: &MethodRule,
569        headers: &HeaderRules,
570    ) -> bool {
571        let hostname = match from_utf8(hostname) {
572            Err(_) => return false,
573            Ok(h) => h,
574        };
575
576        match tree_hostname_to_ascii(hostname) {
577            Some(hostname) => {
578                let should_delete = {
579                    let paths_opt = self.tree.domain_lookup_mut(hostname.as_bytes(), false);
580
581                    if let Some((_, paths)) = paths_opt {
582                        paths.retain(|(p, m, h, _)| p != path || m != method || h != headers);
583                        // `retain` evicts every matching (path, method,
584                        // headers) rule; none may survive the filter.
585                        debug_assert!(
586                            !paths
587                                .iter()
588                                .any(|(p, m, h, _)| p == path && m == method && h == headers),
589                            "remove must evict every matching (path, method, headers) rule from the leaf",
590                        );
591                    }
592
593                    paths_opt
594                        .as_ref()
595                        .map(|(_, paths)| paths.is_empty())
596                        .unwrap_or(false)
597                };
598
599                if should_delete {
600                    let removed_host = hostname.clone().into_bytes();
601                    self.tree.domain_remove(&hostname.into_bytes());
602                    // Dropping the last rule must make the whole domain
603                    // unreachable — no stranded empty leaf left behind.
604                    // `domain_lookup_mut` resolves literal wildcard keys
605                    // (`*.sozu.io`), so this genuinely verifies wildcard
606                    // entries are gone too (the immutable `lookup` lacks
607                    // the `partial_key == b"*"` case and would always read
608                    // None for a wildcard host, weakening the check).
609                    debug_assert!(
610                        self.tree.domain_lookup_mut(&removed_host, false).is_none(),
611                        "a domain whose last rule was removed must be unreachable",
612                    );
613                }
614
615                true
616            }
617            None => false,
618        }
619    }
620
621    /// Walk every route and re-materialise the response-side HSTS edit
622    /// on frontends that inherited from the listener default. Operator
623    /// per-frontend HSTS overrides (`inherits_listener_hsts == false`)
624    /// are left untouched.
625    ///
626    /// Called from `lib/src/https.rs::HttpsListener::update_config` when
627    /// an `UpdateHttpsListenerConfig.hsts` patch is applied, and from
628    /// `HttpsProxy::add_listener` (`lib/src/https.rs`) when a listener copies the routes of a
629    /// sibling on the same address, so each listener resolves inheritance
630    /// against its own default.
631    ///
632    /// Two refresh paths:
633    ///
634    /// 1. **`Route::Frontend(rc)` with `inherits_listener_hsts == true`**:
635    ///    rebuild `headers_response` by dropping any existing
636    ///    `Strict-Transport-Security` entry and appending a freshly
637    ///    rendered one when `new_hsts` resolves to an enabled value
638    ///    (`enabled = Some(true)`). The existing operator
639    ///    `Append`/`Set` response headers stay in place. An inheriting
640    ///    frontend added while the listener had no default holds no
641    ///    `Strict-Transport-Security` entry yet and gains one here.
642    ///
643    /// 2. **`Route::ClusterId(id)` and `Route::Deny`** (lightweight
644    ///    "no policy" shapes): when `new_hsts` resolves to enabled,
645    ///    promote in place to a minimal `Route::Frontend(rc)` carrying
646    ///    just the HSTS edit on `headers_response` (and
647    ///    `inherits_listener_hsts == true` so subsequent patches keep
648    ///    refreshing the entry). Routing semantics are preserved — the
649    ///    promoted Frontend forwards / denies identically to the
650    ///    original variant — and the promoted entry now participates
651    ///    in path 1 on every later patch. When `new_hsts` resolves to
652    ///    "no HSTS" (None / disabled), lightweight routes are left
653    ///    untouched (no allocation is created just to hold an empty
654    ///    HSTS edit).
655    ///
656    /// Path 2 fixes the case where a frontend was added without any
657    /// per-frontend policy field (the routing fast path stores it as
658    /// `Route::ClusterId` / `Route::Deny`, NOT `Route::Frontend`). Before
659    /// this two-path walk, listener-default HSTS patches silently
660    /// skipped every such "no-policy" frontend — which on a typical
661    /// Clever Cloud `cleverapps.io` shared listener was 99 % of the
662    /// frontends.
663    ///
664    /// Returns the number of frontends touched. For path 1, refreshed
665    /// frontends where the new policy resolves to "no HSTS" are still
666    /// counted (the existing HSTS edit is stripped). For path 2, only
667    /// frontends actually promoted (i.e. `new_hsts` enabled) are
668    /// counted, since "no HSTS" is a no-op on the lightweight shape.
669    pub fn refresh_inheriting_hsts(&mut self, new_hsts: Option<&HstsConfig>) -> usize {
670        let mut refreshed = 0usize;
671        // Pre-compute the listener-default HSTS edit ONCE so every
672        // visited frontend in this patch shares the same `Rc`-backed
673        // key / val allocation. `Some(_)` doubles as the "promote
674        // lightweight routes" gate — there is no point allocating a
675        // promoted Frontend just to hold an empty headers_response.
676        // See `build_listener_hsts_edit`'s rustdoc for the
677        // ~1.5 M-allocation-per-worker savings on cleverapps.io.
678        let new_edit = build_listener_hsts_edit(new_hsts);
679        let new_edit_ref = new_edit.as_ref();
680        let promote_lightweight = new_edit_ref.is_some();
681        let mut visit = |route: &mut Route| match route {
682            Route::Frontend(rc) => {
683                if rc.inherits_listener_hsts {
684                    let new_frontend = rebuild_with_listener_hsts(rc, new_edit_ref);
685                    *rc = Rc::new(new_frontend);
686                    refreshed += 1;
687                }
688            }
689            Route::ClusterId(id) => {
690                if promote_lightweight {
691                    let promoted = rebuild_with_listener_hsts(
692                        &Frontend::minimal_forward(id.clone()),
693                        new_edit_ref,
694                    );
695                    *route = Route::Frontend(Rc::new(promoted));
696                    refreshed += 1;
697                }
698            }
699            Route::Deny => {
700                if promote_lightweight {
701                    let promoted =
702                        rebuild_with_listener_hsts(&Frontend::minimal_deny(), new_edit_ref);
703                    *route = Route::Frontend(Rc::new(promoted));
704                    refreshed += 1;
705                }
706            }
707        };
708
709        for (_, _, _, _, route) in self.pre.iter_mut() {
710            visit(route);
711        }
712        self.tree.for_each_value_mut(&mut |paths| {
713            for (_, _, _, route) in paths.iter_mut() {
714                visit(route);
715            }
716        });
717        for (_, _, _, _, route) in self.post.iter_mut() {
718            visit(route);
719        }
720        refreshed
721    }
722
723    /// [`Self::add_pre_rule_with_headers`] for a rule without header
724    /// predicates.
725    pub fn add_pre_rule(
726        &mut self,
727        domain: &DomainRule,
728        path: &PathRule,
729        method: &MethodRule,
730        cluster_id: &Route,
731    ) -> bool {
732        self.add_pre_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
733    }
734
735    /// Add a pre-rule. Its identity is `(domain, path, method,
736    /// headers)`: rules that differ only by their header predicates coexist.
737    pub fn add_pre_rule_with_headers(
738        &mut self,
739        domain: &DomainRule,
740        path: &PathRule,
741        method: &MethodRule,
742        headers: &HeaderRules,
743        cluster_id: &Route,
744    ) -> bool {
745        let before = self.pre.len();
746        if !self
747            .pre
748            .iter()
749            .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
750        {
751            self.pre.push((
752                domain.to_owned(),
753                path.to_owned(),
754                method.to_owned(),
755                headers.to_owned(),
756                cluster_id.to_owned(),
757            ));
758            // A new pre-rule grows the list by exactly one and is now
759            // present (dedup of the same triple is the caller's `false`
760            // path, not this one).
761            debug_assert_eq!(
762                self.pre.len(),
763                before + 1,
764                "adding a unique pre-rule must push exactly one entry",
765            );
766            debug_assert!(
767                self.pre
768                    .iter()
769                    .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
770                "the freshly added pre-rule must be present",
771            );
772            true
773        } else {
774            debug_assert_eq!(
775                self.pre.len(),
776                before,
777                "a duplicate pre-rule must not change the list length",
778            );
779            false
780        }
781    }
782
783    /// [`Self::add_post_rule_with_headers`] for a rule without header
784    /// predicates.
785    pub fn add_post_rule(
786        &mut self,
787        domain: &DomainRule,
788        path: &PathRule,
789        method: &MethodRule,
790        cluster_id: &Route,
791    ) -> bool {
792        self.add_post_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
793    }
794
795    /// Add a post-rule. Its identity is `(domain, path, method,
796    /// headers)`: rules that differ only by their header predicates coexist.
797    pub fn add_post_rule_with_headers(
798        &mut self,
799        domain: &DomainRule,
800        path: &PathRule,
801        method: &MethodRule,
802        headers: &HeaderRules,
803        cluster_id: &Route,
804    ) -> bool {
805        let before = self.post.len();
806        if !self
807            .post
808            .iter()
809            .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
810        {
811            self.post.push((
812                domain.to_owned(),
813                path.to_owned(),
814                method.to_owned(),
815                headers.to_owned(),
816                cluster_id.to_owned(),
817            ));
818            debug_assert_eq!(
819                self.post.len(),
820                before + 1,
821                "adding a unique post-rule must push exactly one entry",
822            );
823            debug_assert!(
824                self.post
825                    .iter()
826                    .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
827                "the freshly added post-rule must be present",
828            );
829            true
830        } else {
831            debug_assert_eq!(
832                self.post.len(),
833                before,
834                "a duplicate post-rule must not change the list length",
835            );
836            false
837        }
838    }
839
840    /// [`Self::remove_pre_rule_with_headers`] for a rule without header
841    /// predicates.
842    pub fn remove_pre_rule(
843        &mut self,
844        domain: &DomainRule,
845        path: &PathRule,
846        method: &MethodRule,
847    ) -> bool {
848        self.remove_pre_rule_with_headers(domain, path, method, &HeaderRules::default())
849    }
850
851    /// Remove the pre-rule whose identity is `(domain, path, method,
852    /// headers)`; a rule that differs only by its header predicates stays.
853    pub fn remove_pre_rule_with_headers(
854        &mut self,
855        domain: &DomainRule,
856        path: &PathRule,
857        method: &MethodRule,
858        headers: &HeaderRules,
859    ) -> bool {
860        let before = self.pre.len();
861        match self
862            .pre
863            .iter()
864            .position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
865        {
866            None => {
867                debug_assert_eq!(
868                    self.pre.len(),
869                    before,
870                    "a no-op pre-rule removal must not change the list length",
871                );
872                false
873            }
874            Some(index) => {
875                debug_assert!(index < self.pre.len(), "found index must be in bounds");
876                self.pre.remove(index);
877                // Exactly one entry left, and the triple is now gone.
878                debug_assert_eq!(
879                    self.pre.len() + 1,
880                    before,
881                    "removing a pre-rule must drop exactly one entry",
882                );
883                debug_assert!(
884                    !self.pre.iter().any(|(d, p, m, h, _)| d == domain
885                        && p == path
886                        && m == method
887                        && h == headers),
888                    "the removed pre-rule must no longer be present",
889                );
890                true
891            }
892        }
893    }
894
895    /// [`Self::remove_post_rule_with_headers`] for a rule without header
896    /// predicates.
897    pub fn remove_post_rule(
898        &mut self,
899        domain: &DomainRule,
900        path: &PathRule,
901        method: &MethodRule,
902    ) -> bool {
903        self.remove_post_rule_with_headers(domain, path, method, &HeaderRules::default())
904    }
905
906    /// Remove the post-rule whose identity is `(domain, path, method,
907    /// headers)`; a rule that differs only by its header predicates stays.
908    pub fn remove_post_rule_with_headers(
909        &mut self,
910        domain: &DomainRule,
911        path: &PathRule,
912        method: &MethodRule,
913        headers: &HeaderRules,
914    ) -> bool {
915        let before = self.post.len();
916        match self
917            .post
918            .iter()
919            .position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
920        {
921            None => {
922                debug_assert_eq!(
923                    self.post.len(),
924                    before,
925                    "a no-op post-rule removal must not change the list length",
926                );
927                false
928            }
929            Some(index) => {
930                debug_assert!(index < self.post.len(), "found index must be in bounds");
931                self.post.remove(index);
932                debug_assert_eq!(
933                    self.post.len() + 1,
934                    before,
935                    "removing a post-rule must drop exactly one entry",
936                );
937                debug_assert!(
938                    !self.post.iter().any(|(d, p, m, h, _)| d == domain
939                        && p == path
940                        && m == method
941                        && h == headers),
942                    "the removed post-rule must no longer be present",
943                );
944                true
945            }
946        }
947    }
948
949    /// Returns true if any route (pre, tree, or post) references the given hostname.
950    ///
951    /// This is used after removing a frontend to decide whether the hostname's
952    /// tags should be cleaned up. Tags must only be removed when no routes remain.
953    pub fn has_hostname(&self, hostname: &str) -> bool {
954        // Same normalisation as `lookup`, for the same reason: the
955        // pre/post arms compare byte-exact against `DomainRule`s the add
956        // path already lowercased. Answering `false` for a hostname whose
957        // route is still installed would drop the frontend's cached tags
958        // from under a live route.
959        let normalized_hostname = normalize_hostname(hostname);
960        let hostname = normalized_hostname.as_ref();
961        let hostname_b = hostname.as_bytes();
962
963        // Check pre rules
964        for (domain_rule, _, _, _, _) in &self.pre {
965            if domain_rule.matches(hostname_b) {
966                return true;
967            }
968        }
969
970        // Check tree rules (exact match only, no wildcard resolution)
971        if let Some(ascii_hostname) = tree_hostname_to_ascii(hostname)
972            && self
973                .tree
974                .domain_lookup(ascii_hostname.as_bytes(), false)
975                .is_some()
976        {
977            return true;
978        }
979
980        // Check post rules
981        for (domain_rule, _, _, _, _) in &self.post {
982            if domain_rule.matches(hostname_b) {
983                return true;
984            }
985        }
986
987        false
988    }
989}
990
991/// Pick the rule a trie leaf serves `path`, `method` and `headers` with, or
992/// `None` when the leaf carries nothing for this request.
993///
994/// An `EQUALS`/`REGEX`/`REGEX_PATH` rule whose `method` matches exactly wins
995/// immediately; otherwise the longest matching `PREFIX` wins, with a
996/// method-less rule competing on equal footing. Rules whose `method` is
997/// set and does not match are skipped entirely — which is why a longer
998/// prefix can lose to a shorter one (`doc/configure.md`, "Path matching
999/// precedence within a frontend").
1000///
1001/// Header predicates (sozu-proxy/sozu#1253) split the leaf into two
1002/// classes, each selected by the rule above: a rule whose predicates all
1003/// hold outranks every rule without predicates, and a rule whose
1004/// predicates do not all hold is skipped like a rule whose method does not
1005/// match. A leaf without predicates selects exactly as it always did, and
1006/// declaration order never decides between a canary and its base.
1007///
1008/// Lifted out of [`Router::lookup_with_headers`] so the trie walk can use
1009/// the very same selection as its acceptance predicate: a hostname
1010/// candidate that selects nothing must not end the lookup (sozu#1351). Two
1011/// copies of this would drift, and the drift would be a route that the trie
1012/// accepted and the router then refused to serve.
1013fn select_tree_rule<'a>(
1014    path_rules: &'a [(PathRule, MethodRule, HeaderRules, Route)],
1015    path: &[u8],
1016    method: &Method,
1017    headers: &dyn RequestHeaders,
1018) -> Option<(&'a PathRule, &'a Route)> {
1019    let mut predicated = TreeSelection::default();
1020    let mut unpredicated = TreeSelection::default();
1021
1022    for (rule, method_rule, header_rules, route) in path_rules {
1023        let class = if header_rules.is_empty() {
1024            &mut unpredicated
1025        } else {
1026            &mut predicated
1027        };
1028        // A class that already holds an exact winner takes no other rule:
1029        // this is the historical early return, per class.
1030        if class.exact {
1031            continue;
1032        }
1033        let path_result = rule.matches(path);
1034        if path_result == PathRuleResult::None {
1035            continue;
1036        }
1037        let method_result = method_rule.matches(method);
1038        if method_result == MethodRuleResult::None {
1039            continue;
1040        }
1041        // Headers are read last, and only for a rule that could otherwise
1042        // serve this request.
1043        if !header_rules.is_empty() && !header_rules.matches(headers) {
1044            continue;
1045        }
1046        class.offer(path_result, method_result, path.len(), rule, route);
1047        if predicated.exact {
1048            // Nothing outranks an exact match among satisfied predicates.
1049            break;
1050        }
1051    }
1052
1053    // A predicate-free rule may only be served when no satisfied
1054    // predicated rule exists: the pair below is the class precedence.
1055    debug_assert!(
1056        !predicated.exact || predicated.matched.is_some(),
1057        "an exact selection must carry its rule",
1058    );
1059    predicated.matched.or(unpredicated.matched)
1060}
1061
1062/// One class of [`select_tree_rule`]'s scan: the historical
1063/// longest-prefix / exact-method selection, run over the rules of that
1064/// class only.
1065#[derive(Default)]
1066struct TreeSelection<'a> {
1067    prefix_length: usize,
1068    matched: Option<(&'a PathRule, &'a Route)>,
1069    /// An `EQUALS`/`REGEX`/`REGEX_PATH` rule with an exactly matching method was taken:
1070    /// the class is decided.
1071    exact: bool,
1072}
1073
1074impl<'a> TreeSelection<'a> {
1075    fn offer(
1076        &mut self,
1077        path_result: PathRuleResult,
1078        method_result: MethodRuleResult,
1079        path_len: usize,
1080        rule: &'a PathRule,
1081        route: &'a Route,
1082    ) {
1083        debug_assert!(!self.exact, "a decided class must not take another rule");
1084        debug_assert!(
1085            path_result != PathRuleResult::None && method_result != MethodRuleResult::None,
1086            "only a rule that matches path and method is offered",
1087        );
1088        match path_result {
1089            PathRuleResult::Regex | PathRuleResult::Equals => match method_result {
1090                MethodRuleResult::Equals => {
1091                    self.matched = Some((rule, route));
1092                    self.exact = true;
1093                }
1094                MethodRuleResult::All => {
1095                    self.prefix_length = path_len;
1096                    self.matched = Some((rule, route));
1097                }
1098                MethodRuleResult::None => {}
1099            },
1100            PathRuleResult::Prefix(size) => {
1101                if size >= self.prefix_length {
1102                    // FIXME: the rule order will be important here
1103                    // Longest-prefix wins: the selected length is
1104                    // monotonically non-decreasing across the candidate
1105                    // scan.
1106                    debug_assert!(
1107                        size >= self.prefix_length,
1108                        "longest-prefix selection must never shrink the match length",
1109                    );
1110                    self.prefix_length = size;
1111                    self.matched = Some((rule, route));
1112                }
1113            }
1114            PathRuleResult::None => {}
1115        }
1116    }
1117}
1118
1119/// Pick the rule an ordered pre or post list serves the request with.
1120///
1121/// The first rule in declaration order whose domain, path and method match
1122/// wins — except that a rule whose header predicates all hold outranks
1123/// every rule without predicates (sozu-proxy/sozu#1253), the same class
1124/// precedence [`select_tree_rule`] applies to a trie leaf. A rule whose
1125/// predicates do not all hold is skipped.
1126fn select_listed_rule<'a>(
1127    rules: &'a [(DomainRule, PathRule, MethodRule, HeaderRules, Route)],
1128    hostname: &[u8],
1129    path: &[u8],
1130    method: &Method,
1131    headers: &dyn RequestHeaders,
1132) -> Option<&'a (DomainRule, PathRule, MethodRule, HeaderRules, Route)> {
1133    let mut unpredicated = None;
1134    for entry in rules {
1135        let (domain_rule, path_rule, method_rule, header_rules, _) = entry;
1136        // Once a predicate-free rule is held, only a predicated one can
1137        // still change the answer.
1138        if unpredicated.is_some() && header_rules.is_empty() {
1139            continue;
1140        }
1141        if !(domain_rule.matches(hostname)
1142            && path_rule.matches(path) != PathRuleResult::None
1143            && method_rule.matches(method) != MethodRuleResult::None)
1144        {
1145            continue;
1146        }
1147        if header_rules.is_empty() {
1148            unpredicated = Some(entry);
1149        } else if header_rules.matches(headers) {
1150            return Some(entry);
1151        }
1152    }
1153    debug_assert!(
1154        unpredicated.is_none_or(|(_, _, _, header_rules, _)| header_rules.is_empty()),
1155        "the fallback of a listed selection must be a predicate-free rule",
1156    );
1157    unpredicated
1158}
1159
1160/// The request headers a [`Router`] lookup may read to evaluate header
1161/// predicates (sozu-proxy/sozu#1253).
1162pub trait RequestHeaders {
1163    /// The value of the FIRST occurrence of the header `name`, compared
1164    /// ASCII-case-insensitively (RFC 9110 §5.1), raw as the request carried
1165    /// it; `None` when the request does not carry it.
1166    fn header(&self, name: &[u8]) -> Option<&[u8]>;
1167}
1168
1169/// A request without any header, for lookups that have none to offer.
1170pub struct NoHeaders;
1171
1172impl RequestHeaders for NoHeaders {
1173    fn header(&self, _name: &[u8]) -> Option<&[u8]> {
1174        None
1175    }
1176}
1177
1178/// How one [`HeaderRule`] tests the value of its header.
1179#[derive(Clone, Debug)]
1180enum HeaderMatch {
1181    Exact(Vec<u8>),
1182    /// Anchored at both ends, like a `REGEX` path rule ([`anchored_regex`]).
1183    Regex(Regex),
1184    Present,
1185}
1186
1187impl PartialEq for HeaderMatch {
1188    /// `Regex` carries no `PartialEq`: patterns compare by source, as
1189    /// [`PathRule`]'s do.
1190    fn eq(&self, other: &Self) -> bool {
1191        match (self, other) {
1192            (HeaderMatch::Exact(a), HeaderMatch::Exact(b)) => a == b,
1193            (HeaderMatch::Regex(a), HeaderMatch::Regex(b)) => a.as_str() == b.as_str(),
1194            (HeaderMatch::Present, HeaderMatch::Present) => true,
1195            _ => false,
1196        }
1197    }
1198}
1199
1200/// One compiled header predicate: a lowercased header name and its test.
1201#[derive(Clone, Debug, PartialEq)]
1202struct HeaderRule {
1203    name: Vec<u8>,
1204    matcher: HeaderMatch,
1205}
1206
1207impl HeaderRule {
1208    fn matches(&self, headers: &dyn RequestHeaders) -> bool {
1209        let Some(value) = headers.header(&self.name) else {
1210            return false;
1211        };
1212        match &self.matcher {
1213            HeaderMatch::Exact(expected) => value == expected.as_slice(),
1214            HeaderMatch::Regex(regex) => {
1215                let start = Instant::now();
1216                let is_a_match = regex.is_match(value);
1217                time!(
1218                    names::event_loop::REGEX_MATCHING_TIME,
1219                    start.elapsed().as_millis()
1220                );
1221                is_a_match
1222            }
1223            HeaderMatch::Present => true,
1224        }
1225    }
1226
1227    /// The canonical sort key of a rule within a [`HeaderRules`].
1228    fn sort_key(&self) -> (&[u8], u8, &[u8]) {
1229        match &self.matcher {
1230            HeaderMatch::Exact(value) => (&self.name, 0, value),
1231            HeaderMatch::Regex(regex) => (&self.name, 1, regex.as_str().as_bytes()),
1232            HeaderMatch::Present => (&self.name, 2, b""),
1233        }
1234    }
1235}
1236
1237/// The header predicates of one route-table rule (sozu-proxy/sozu#1253):
1238/// a conjunction, empty for a rule without predicates.
1239///
1240/// It is part of the rule's identity: [`Router`]'s add and remove paths
1241/// compare it alongside the path and method, so a canary rule that differs
1242/// from its base only by a predicate coexists with it, and removing one
1243/// leaves the other. The rules are kept in a canonical order, so two
1244/// spellings of one conjunction (other declaration order, other name case)
1245/// are one rule — the same canonicalisation the `ConfigState` key applies
1246/// (`RequestHttpFrontend`'s `Display` in `command/src/request.rs`).
1247#[derive(Clone, Debug, Default, PartialEq)]
1248pub struct HeaderRules(Vec<HeaderRule>);
1249
1250impl HeaderRules {
1251    /// Compile a frontend's predicates, held to
1252    /// [`validate_header_predicate`] first. A `regex` value is compiled
1253    /// anchored, refused when it does not compile exactly like a `REGEX`
1254    /// path rule (the private `anchored_regex` helper compiles both).
1255    pub fn from_config(predicates: &[HeaderPredicate]) -> Result<Self, RouterError> {
1256        let mut rules = Vec::with_capacity(predicates.len());
1257        for (index, predicate) in predicates.iter().enumerate() {
1258            validate_header_predicate(predicate)
1259                .map_err(|reason| RouterError::InvalidHeaderPredicate { index, reason })?;
1260            let value = predicate.value.as_deref().unwrap_or_default();
1261            let matcher = match HeaderMatchKind::try_from(predicate.kind) {
1262                Ok(HeaderMatchKind::Exact) => HeaderMatch::Exact(value.as_bytes().to_owned()),
1263                Ok(HeaderMatchKind::Regex) => HeaderMatch::Regex(anchored_regex(value).ok_or(
1264                    RouterError::InvalidHeaderPredicate {
1265                        index,
1266                        reason: "the regex value does not compile",
1267                    },
1268                )?),
1269                Ok(HeaderMatchKind::Present) => HeaderMatch::Present,
1270                Err(_) => {
1271                    return Err(RouterError::InvalidHeaderPredicate {
1272                        index,
1273                        reason: "unknown match kind",
1274                    });
1275                }
1276            };
1277            rules.push(HeaderRule {
1278                name: predicate.name.as_bytes().to_ascii_lowercase(),
1279                matcher,
1280            });
1281        }
1282        rules.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
1283        rules.dedup();
1284        debug_assert!(
1285            rules.len() <= predicates.len(),
1286            "compiling predicates never adds a rule",
1287        );
1288        debug_assert!(
1289            rules.is_empty() == predicates.is_empty(),
1290            "a frontend has predicates exactly when its rule does",
1291        );
1292        Ok(HeaderRules(rules))
1293    }
1294
1295    pub fn is_empty(&self) -> bool {
1296        self.0.is_empty()
1297    }
1298
1299    /// Whether every predicate holds for `headers`. True for an empty
1300    /// conjunction, which never reads `headers`.
1301    pub fn matches(&self, headers: &dyn RequestHeaders) -> bool {
1302        self.0.iter().all(|rule| rule.matches(headers))
1303    }
1304}
1305
1306/// ASCII-lowercase a hostname for routing, borrowing it when it already is.
1307///
1308/// RFC 9110 §4.2.3 makes the host case-insensitive, and every configured
1309/// hostname LABEL is lowercased on the way into the route table:
1310/// [`Router::add_tree_rule`] runs it through [`tree_hostname_to_ascii`]
1311/// and [`DomainRule::from_str`] through `idna::domain_to_ascii`. (A
1312/// `/`-delimited regex segment is exempt on both paths and is folded at
1313/// compile time instead; see [`tree_hostname_to_ascii`].) The lookup key
1314/// has to be normalised
1315/// the same way, or [`Router::lookup`]'s byte-exact trie walk and the
1316/// byte-exact `DomainRule::Exact` / `DomainRule::Wildcard` comparisons
1317/// can never match an uppercase `Host:` header — and no configuration
1318/// fixes it, because declaring the frontend in uppercase gets lowercased
1319/// too.
1320///
1321/// The scan is the whole cost on the common path: a host that is already
1322/// lowercase — every host a conforming client sends — is borrowed, so
1323/// the datapath allocates only for the requests that actually carry
1324/// uppercase. This runs once per request, so the distinction is not
1325/// academic.
1326///
1327/// Deliberately ASCII-only and deliberately NOT a full
1328/// `idna::domain_to_ascii` round trip: `hostname_and_port`
1329/// (`lib/src/protocol/kawa_h1/parser.rs`) only admits bytes matching
1330/// `is_hostname_char`, all of which are ASCII, so there is no non-ASCII
1331/// host to punycode here, and a per-request IDNA pass would be a real
1332/// datapath cost for a case that cannot occur.
1333fn normalize_hostname(hostname: &str) -> Cow<'_, str> {
1334    if hostname.as_bytes().iter().any(u8::is_ascii_uppercase) {
1335        Cow::Owned(hostname.to_ascii_lowercase())
1336    } else {
1337        Cow::Borrowed(hostname)
1338    }
1339}
1340
1341/// Normalise a `RulePosition::Tree` hostname for the route table,
1342/// applying IDNA to its literal labels ONLY.
1343///
1344/// `idna::domain_to_ascii` ASCII-lowercases everything it is handed, and
1345/// a tree hostname is not all domain labels: a `/`-delimited segment is
1346/// regex SOURCE, where case is meaning. Folding it inverts every
1347/// uppercase escape — measured, `"/\D+/.example.com"` normalises to
1348/// `"/\d+/.example.com"` and `"/[^\D]/.example.com"` to
1349/// `"/[^\d]/.example.com"`, turning "not a digit" into "a digit", so the
1350/// installed rule matched the exact COMPLEMENT of what the operator
1351/// wrote while `sozu query frontends` still echoed the original spelling
1352/// (sozu#1377). `\W`/`\w` and `\S`/`\s` invert the same way.
1353///
1354/// The split is the one [`convert_regex_domain_rule`] already makes for
1355/// `Pre`/`Post` and the one `pattern_trie`'s `insert_recursive`
1356/// addresses a `regexps` entry by: a label the operator wrapped in
1357/// slashes is a regex segment and is copied BYTE FOR BYTE, delimiters
1358/// included; every other label is a domain label and goes through
1359/// `idna::domain_to_ascii` exactly as before. Per-label and whole-domain
1360/// IDNA agree on a label — measured, `"MÜNCHEN"` maps to
1361/// `"xn--mnchen-3ya"` either way — so a genuine unicode label is still
1362/// punycoded in the very same hostname whose regex segment is left
1363/// alone.
1364///
1365/// Leaving the source unfolded means an uppercase LITERAL inside a
1366/// segment (`/API[0-9]/`) no longer meets an ASCII-lowercased lookup key
1367/// by accident. `pattern_trie`'s `compiled_segment` compiles the stored
1368/// segment case-insensitively instead, which is what
1369/// [`DomainRule::from_str`] already does for `Pre`/`Post`: the two
1370/// positions now treat a hostname regex identically rather than by
1371/// opposite mechanisms.
1372///
1373/// Two inputs deliberately keep the historical whole-string call, both
1374/// byte-for-byte:
1375///
1376/// - a hostname with no `/` at all, which is every ordinary hostname —
1377///   so the retained trailing dot pinned by
1378///   `an_absolute_form_host_does_not_reach_a_relative_frontend_yet` and
1379///   every other whole-string behaviour is untouched;
1380/// - a hostname carrying a `/` that does NOT parse as this grammar, such
1381///   as `abc/[0-9]+/.example.com` (a segment that is not `.`-anchored) or
1382///   `example.com/`. Neither the split nor the trie sees a regex segment
1383///   there — it is one literal label to both — so there is nothing to
1384///   protect, and the trie accepts or rejects it exactly as it did
1385///   before.
1386fn tree_hostname_to_ascii(hostname: &str) -> Option<String> {
1387    /// The `/…/`-aware walk. `None` means "not this grammar", never
1388    /// "invalid": validating the segment is `anchored_segment`'s job at
1389    /// insert time, and this function must not start rejecting hostnames
1390    /// the trie has always answered for itself.
1391    fn split_labels(hostname: &str) -> Option<String> {
1392        let mut result = String::with_capacity(hostname.len());
1393        let s = hostname.as_bytes();
1394        let mut index = 0;
1395
1396        loop {
1397            // A bare trailing `.` leaves `index` one past the end; the
1398            // grammar requires a label after every `.`, so this is not
1399            // the shape — fall back rather than index out of bounds.
1400            if index >= s.len() {
1401                return None;
1402            }
1403
1404            if s[index] == b'/' {
1405                let close = (index + 1..s.len()).find(|&i| s[i] == b'/')?;
1406                // Regex source, copied verbatim. Both delimiters are
1407                // ASCII `/`, so the slice is on char boundaries.
1408                result.push_str(std::str::from_utf8(&s[index..=close]).ok()?);
1409                index = close + 1;
1410            } else {
1411                let end = (index..s.len()).find(|&i| s[i] == b'.').unwrap_or(s.len());
1412                let label = std::str::from_utf8(&s[index..end]).ok()?;
1413                result.push_str(&::idna::domain_to_ascii(label).ok()?);
1414                index = end;
1415            }
1416
1417            if index == s.len() {
1418                return Some(result);
1419            }
1420            if s[index] != b'.' {
1421                return None;
1422            }
1423            result.push('.');
1424            index += 1;
1425        }
1426    }
1427
1428    if hostname.contains('/')
1429        && let Some(normalized) = split_labels(hostname)
1430    {
1431        return Some(normalized);
1432    }
1433    ::idna::domain_to_ascii(hostname).ok()
1434}
1435
1436#[derive(Clone, Debug)]
1437pub enum DomainRule {
1438    Any,
1439    Exact(String),
1440    /// Matches when `hostname` ends with `s[1..]` (the wildcard pattern with
1441    /// the leading `*` stripped) and the remaining leftmost prefix is
1442    /// non-empty and contains no `.`. Comparison is byte-exact and no
1443    /// IDN/punycode normalisation is performed here: the pattern was
1444    /// already lowercased by `DomainRule::from_str`'s
1445    /// `idna::domain_to_ascii`, and the hostname it is handed has been
1446    /// through `normalize_hostname` in [`Router::lookup`], so both
1447    /// sides are ASCII-lowercase by the time they meet. Stored with the
1448    /// leading `*`.
1449    Wildcard(String),
1450    /// Anchored full-host regex built by `convert_regex_domain_rule`.
1451    /// Unlike the other variants its source is NOT lowercased — it is
1452    /// compiled case-insensitively instead, so an uppercase literal
1453    /// still matches the normalised key. See `DomainRule::from_str`
1454    /// for why that folding is Unicode-aware while the hostnames it is
1455    /// ever handed are ASCII.
1456    Regex(Regex),
1457}
1458
1459/// Build the whole-host regex for a `hostname` carrying one or more
1460/// slash-delimited regex segments (`/cdn[0-9]+/.example.com`). Each regex
1461/// segment is emitted as a non-capturing group, each literal label is
1462/// emitted through [`regex::escape`], the `.` separators are escaped, and the
1463/// result is anchored at both ends.
1464///
1465/// The anchors and the groups answer two different failures and both are
1466/// required:
1467///
1468/// - `\A` … `\z` because `Regex::is_match` is a substring search. Without
1469///   them `/example\.com/` matches any hostname CONTAINING `example.com`
1470///   (e.g. `attacker.example.com.evil.org`), letting an attacker-controlled
1471///   domain reach a frontend that should only serve `example.com`.
1472/// - `(?:` … `)` around each regex segment because `|` binds looser than
1473///   concatenation, so an ungrouped segment splits the anchors between its
1474///   branches: `\Aa|b\.example\.com\z` parses as
1475///   `(\Aa)|(b\.example\.com\z)`. Measured, that matches
1476///   `axx.example.com` and `xxb.example.com`; with three branches the middle
1477///   one keeps NEITHER anchor and matches anywhere at all. The group is
1478///   non-capturing, so `captures_len` — and therefore every `$HOST[n]`
1479///   rewrite index, read from `caps.iter().skip(1)` in
1480///   `RouteResult::new_no_trie` — is unchanged. A literal label carrying a
1481///   `|` split the anchors the same way and is closed by the escaping
1482///   below: `/a/.x|y.com` assembled to `\A(?:a)\.x|y\.com\z`, which
1483///   matched `a.xZZZ` and `ZZZy.com`.
1484///
1485/// A label the operator did NOT wrap in slashes is a literal, and the two
1486/// arms must not be confused: grouping it would confine an alternation but
1487/// leave `+`, `?`, `*`, `^` and `$` live inside it, so `b+c` would still
1488/// match `bbbc`. [`regex::escape`] is the treatment that matches the
1489/// grammar. It is a no-op in matching behaviour for every label a real
1490/// hostname can carry — an LDH label is `[A-Za-z0-9-]`, of which `escape`
1491/// touches only `-`, and `\-` is `-` outside a character class. Two
1492/// deliberate behaviour changes fall out of it for labels that are NOT
1493/// valid hostname labels, both narrowing and both measured:
1494/// `*./x/.example.com` assembled to `\A*\.(?:x)\.example\.com\z`, whose
1495/// `\A*` is a repetition of a zero-width assertion and therefore matches
1496/// empty anywhere — it took `evil.attacker.x.example.com`, and now requires
1497/// the literal label `*`; and `/a/.b(c.example.com` was REJECTED because the
1498/// assembly did not compile, and is now accepted, matching exactly the
1499/// literal host the operator typed and nothing else.
1500///
1501/// The `*` change is scoped to what this function feeds, which is `Pre` and
1502/// `Post` matching only. A trie-routed frontend never reaches here for
1503/// matching: `TrieNode` splits the hostname on `.` and keeps a `*` label in
1504/// its own `wildcard` slot, so the same configured hostname now answers
1505/// differently by position. `doc/configure.md` § "Regex hostname segments"
1506/// carries the table and
1507/// `a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post`
1508/// asserts it.
1509///
1510/// Each regex segment is compiled ON ITS OWN before it is wrapped. The
1511/// wrapper supplies one `(` and one `)`, so an unbalanced segment can be
1512/// balanced BY the wrapping: `a)(b` is rejected by `Regex::new`, yet
1513/// `\A(?:a)(b)\.example\.com\z` compiles and matches `ab.example.com`.
1514/// Without that first compile, grouping would silently promote a hostname the
1515/// router rejects today into a live rule. This closes
1516/// invalid-becomes-valid only; the converse is not closed, exactly as on the
1517/// path side — see [`PathRule::anchored_regex`], which carries the same shape
1518/// and the same measurements for a whole path value.
1519fn convert_regex_domain_rule(hostname: &str) -> Option<String> {
1520    let mut result = String::from("\\A");
1521
1522    let s = hostname.as_bytes();
1523    let mut index = 0;
1524    loop {
1525        // A bare trailing `.` after a completed segment (`/a/.`, `x./y/.`)
1526        // leaves `index` one past the end through the `index += 1` in the
1527        // loop tail; indexing `s[index]` here would then panic (slice
1528        // bounds checks never compile out of release). The grammar
1529        // requires a label after every `.`, so reject instead.
1530        if index >= s.len() {
1531            return None;
1532        }
1533        if s[index] == b'/' {
1534            let mut found = false;
1535            for i in index + 1..s.len() {
1536                if s[i] == b'/' {
1537                    match std::str::from_utf8(&s[index + 1..i]) {
1538                        Ok(r) => {
1539                            // Reject a segment that is not a regex on its own,
1540                            // then group it. See this function's doc comment.
1541                            Regex::new(r).ok()?;
1542                            result.push_str("(?:");
1543                            result.push_str(r);
1544                            result.push(')');
1545                        }
1546                        Err(_) => return None,
1547                    }
1548                    index = i + 1;
1549                    found = true;
1550                    break;
1551                }
1552            }
1553
1554            if !found {
1555                return None;
1556            }
1557        } else {
1558            let start = index;
1559            for i in start..s.len() + 1 {
1560                index = i;
1561                if i < s.len() && s[i] == b'.' {
1562                    match std::str::from_utf8(&s[start..i]) {
1563                        // A LITERAL label is not a pattern: escape it so it
1564                        // matches itself. See this function's doc comment.
1565                        Ok(r) => result.push_str(&regex::escape(r)),
1566                        Err(_) => return None,
1567                    }
1568                    break;
1569                }
1570            }
1571            if index == s.len() {
1572                match std::str::from_utf8(&s[start..]) {
1573                    Ok(r) => result.push_str(&regex::escape(r)),
1574                    Err(_) => return None,
1575                }
1576            }
1577        }
1578
1579        if index == s.len() {
1580            result.push_str("\\z");
1581            return Some(result);
1582        } else if s[index] == b'.' {
1583            result.push_str("\\.");
1584            index += 1;
1585        } else {
1586            return None;
1587        }
1588    }
1589}
1590
1591impl DomainRule {
1592    pub fn matches(&self, hostname: &[u8]) -> bool {
1593        match self {
1594            DomainRule::Any => true,
1595            DomainRule::Wildcard(s) => {
1596                // A stored wildcard always keeps its leading `*`, so the
1597                // suffix (pattern minus `*`) is a strict sub-slice and the
1598                // bare `*` (Any) never reaches this arm.
1599                debug_assert_eq!(
1600                    s.as_bytes().first(),
1601                    Some(&b'*'),
1602                    "a Wildcard rule must retain its leading '*'",
1603                );
1604                let suffix = &s.as_bytes()[1..];
1605                let matched = hostname
1606                    .strip_suffix(suffix)
1607                    .is_some_and(|prefix| !prefix.is_empty() && !prefix.contains(&b'.'));
1608                // A wildcard never matches a hostname no longer than its
1609                // own suffix — there is no room left for the mandatory
1610                // single non-empty leftmost label.
1611                debug_assert!(
1612                    !matched || hostname.len() > suffix.len(),
1613                    "a wildcard match requires a non-empty leftmost label before the suffix",
1614                );
1615                matched
1616            }
1617            DomainRule::Exact(s) => s.as_bytes() == hostname,
1618            DomainRule::Regex(r) => {
1619                let start = Instant::now();
1620                let is_a_match = r.is_match(hostname);
1621                let now = Instant::now();
1622                time!(
1623                    names::event_loop::REGEX_MATCHING_TIME,
1624                    (now - start).as_millis()
1625                );
1626                is_a_match
1627            }
1628        }
1629    }
1630}
1631
1632impl std::cmp::PartialEq for DomainRule {
1633    fn eq(&self, other: &Self) -> bool {
1634        match (self, other) {
1635            (DomainRule::Any, DomainRule::Any) => true,
1636            (DomainRule::Wildcard(s1), DomainRule::Wildcard(s2)) => s1 == s2,
1637            (DomainRule::Exact(s1), DomainRule::Exact(s2)) => s1 == s2,
1638            (DomainRule::Regex(r1), DomainRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1639            _ => false,
1640        }
1641    }
1642}
1643
1644impl std::str::FromStr for DomainRule {
1645    type Err = ();
1646
1647    fn from_str(s: &str) -> Result<Self, Self::Err> {
1648        Ok(if s == "*" {
1649            DomainRule::Any
1650        } else if s.contains('/') {
1651            match convert_regex_domain_rule(s) {
1652                // Case-insensitive, because `Router::lookup`
1653                // normalises the host it matches against and a hostname
1654                // is case-insensitive anyway (RFC 9110 §4.2.3). Without
1655                // it an operator's `/API[0-9]/` would compile
1656                // case-sensitively and match nothing at all — it used to
1657                // match `API7.…` only, never the canonical `api7.…`.
1658                //
1659                // Set on the builder rather than by lowercasing the
1660                // source, which would rewrite `\D` into `\d`. The trie
1661                // does exactly the same since sozu#1377:
1662                // `tree_hostname_to_ascii` leaves a segment's bytes alone
1663                // and `pattern_trie::compiled_segment` folds at compile
1664                // time, so the two rule positions now treat a hostname
1665                // regex by the same mechanism. Up to and including 2.2.1
1666                // `RulePosition::Tree` instead ran the WHOLE hostname,
1667                // regex source included, through `idna::domain_to_ascii`
1668                // — which is precisely the escape-inverting fold this
1669                // comment refuses.
1670                //
1671                // The folding is the crate default, i.e. Unicode simple
1672                // case folding, NOT ASCII-only: `.unicode(false)` is
1673                // deliberately left unset. It would make the fold
1674                // ASCII-only, but it also rejects patterns that compile
1675                // and work today — measured, `\p{L}` and `\pL` fail to
1676                // build with it, so `/\p{L}+/.example.com`, which
1677                // matches ASCII letters perfectly well right now, would
1678                // start being refused at add time — pinned by
1679                // `a_unicode_class_hostname_regex_still_compiles_and_routes`.
1680                // The distinction is
1681                // unobservable on the haystack side regardless: the only
1682                // hostnames that reach `matches` come through
1683                // `hostname_and_port`
1684                // (`lib/src/protocol/kawa_h1/parser.rs`), whose
1685                // `is_hostname_char` admits nothing outside ASCII, and
1686                // whose own `if !i.is_empty()` guard then refuses the
1687                // whole authority rather than truncating it — see
1688                // `is_hostname_char_admits_only_ascii`. It IS observable
1689                // on the PATTERN side: a pattern spelling `\u{212A}`
1690                // folds onto `k`. Hence "case-insensitive" here, not
1691                // "ASCII-case-insensitive".
1692                Some(s) => match regex::bytes::RegexBuilder::new(&s)
1693                    .case_insensitive(true)
1694                    .build()
1695                {
1696                    Ok(r) => DomainRule::Regex(r),
1697                    Err(_) => return Err(()),
1698                },
1699                None => return Err(()),
1700            }
1701        } else if s.contains('*') {
1702            if s.starts_with('*') {
1703                match ::idna::domain_to_ascii(s) {
1704                    Ok(r) => DomainRule::Wildcard(r),
1705                    Err(_) => return Err(()),
1706                }
1707            } else {
1708                return Err(());
1709            }
1710        } else {
1711            match ::idna::domain_to_ascii(s) {
1712                Ok(r) => DomainRule::Exact(r),
1713                Err(_) => return Err(()),
1714            }
1715        })
1716    }
1717}
1718
1719#[derive(Clone, Debug)]
1720pub enum PathRule {
1721    Prefix(String),
1722    Regex(Regex),
1723    Equals(String),
1724    /// `REGEX_PATH` (sozu-proxy/sozu#1909): the same anchored regex as
1725    /// [`PathRule::Regex`], tested against the request path up to its first
1726    /// `?` only (`split_query`), the Gateway API `RegularExpression` path
1727    /// match. It shares `Regex`'s precedence class ([`PathRuleResult::Regex`]).
1728    RegexPath(Regex),
1729}
1730
1731/// Split a request-target into its path and its query: everything before
1732/// the first `?`, and the rest INCLUDING that `?` (empty when the target
1733/// carries none). `?` is ASCII, so both halves stay on UTF-8 boundaries of
1734/// a `&str` the bytes came from.
1735fn split_query(target: &[u8]) -> (&[u8], &[u8]) {
1736    let (path, query) = match target.iter().position(|&b| b == b'?') {
1737        Some(at) => target.split_at(at),
1738        None => (target, &target[target.len()..]),
1739    };
1740    debug_assert_eq!(
1741        path.len() + query.len(),
1742        target.len(),
1743        "the path and query halves must cover the request-target exactly",
1744    );
1745    debug_assert!(
1746        !path.contains(&b'?') && (query.is_empty() || query[0] == b'?'),
1747        "the path half holds no `?` and a non-empty query half starts with one",
1748    );
1749    (path, query)
1750}
1751
1752#[derive(PartialEq, Eq)]
1753pub enum PathRuleResult {
1754    Regex,
1755    Prefix(usize),
1756    Equals,
1757    None,
1758}
1759
1760impl PathRule {
1761    pub fn matches(&self, path: &[u8]) -> PathRuleResult {
1762        match self {
1763            PathRule::Prefix(prefix) => {
1764                if path.starts_with(prefix.as_bytes()) {
1765                    // The reported prefix length is the matched-byte count
1766                    // the router uses for longest-prefix tie-breaking; it
1767                    // must equal the prefix and never exceed the path.
1768                    debug_assert!(
1769                        prefix.len() <= path.len(),
1770                        "a matching prefix cannot be longer than the path it matched",
1771                    );
1772                    PathRuleResult::Prefix(prefix.len())
1773                } else {
1774                    PathRuleResult::None
1775                }
1776            }
1777            PathRule::Regex(regex) => {
1778                let start = Instant::now();
1779                let is_a_match = regex.is_match(path);
1780                let now = Instant::now();
1781                time!(
1782                    names::event_loop::REGEX_MATCHING_TIME,
1783                    (now - start).as_millis()
1784                );
1785
1786                if is_a_match {
1787                    PathRuleResult::Regex
1788                } else {
1789                    PathRuleResult::None
1790                }
1791            }
1792            PathRule::RegexPath(regex) => {
1793                let (path_only, _) = split_query(path);
1794                let start = Instant::now();
1795                let is_a_match = regex.is_match(path_only);
1796                let now = Instant::now();
1797                time!(
1798                    names::event_loop::REGEX_MATCHING_TIME,
1799                    (now - start).as_millis()
1800                );
1801
1802                if is_a_match {
1803                    PathRuleResult::Regex
1804                } else {
1805                    PathRuleResult::None
1806                }
1807            }
1808            PathRule::Equals(pattern) => {
1809                if path == pattern.as_bytes() {
1810                    PathRuleResult::Equals
1811                } else {
1812                    PathRuleResult::None
1813                }
1814            }
1815        }
1816    }
1817
1818    /// Compile a configured `path_type = "REGEX"` value anchored at both ends,
1819    /// so it matches the WHOLE request path and nothing else — the behaviour
1820    /// `doc/configure.md` has documented since v2.0.0 and the code did not
1821    /// implement (sozu#1350). `regex::bytes::Regex::is_match` is a substring
1822    /// search, so the previous bare `Regex::new(&rule.value)` let `bc` match
1823    /// `/abcd`.
1824    ///
1825    /// `\A(?:` … `)\z` is the form the hostname side uses too, in
1826    /// [`convert_regex_domain_rule`] around each slash-delimited segment and
1827    /// in `pattern_trie.rs`'s `anchored_segment` at segment-insert time
1828    /// (sozu#1356 closed the hostname half; this path half landed first, as
1829    /// sozu#1357).
1830    ///
1831    /// The non-capturing group is NOT decoration: `|` binds looser than
1832    /// concatenation, so the ungrouped `\Aa|b\z` parses as `(\Aa)|(b\z)`
1833    /// and each branch keeps only one anchor — measured, it still matches
1834    /// `axx`. `\A(?:a|b)\z` does not. `(?:` … `)` is non-capturing, so
1835    /// `captures_len` and therefore every `$PATH[n]` rewrite index are
1836    /// unchanged.
1837    ///
1838    /// The configured value is compiled ON ITS OWN first and only a value
1839    /// that compiles is wrapped. The wrapper supplies one `(` and one `)`, so
1840    /// an unbalanced pattern can be balanced BY the wrapping: `a)(b` is
1841    /// rejected by `Regex::new`, yet `\A(?:a)(b)\z` compiles and matches
1842    /// `ab`. Without the first compile, anchoring would silently promote a
1843    /// rule the router rejects today into a live rule matching something the
1844    /// operator never wrote.
1845    ///
1846    /// This closes invalid-becomes-valid only. The opposite direction is NOT
1847    /// closed and is not claimed to be: a pattern that compiles bare can be
1848    /// rejected once wrapped, because the appended `)\z` has to survive
1849    /// whatever the pattern left open. Measured: `(?x)/api/v1 # v1 only`
1850    /// compiles bare and fails wrapped with `unclosed group` — `(?x)` enables
1851    /// `#` comments, and the appended `)` lands inside one — and a 249-deep
1852    /// nested group compiles bare and fails wrapped against the crate's
1853    /// 250-nesting limit. Both land in the `None` arm of `from_config` and
1854    /// surface as [`RouterError::InvalidPathRule`] at frontend registration,
1855    /// which `bin/src/command/requests.rs`'s `validate_frontend_request` runs
1856    /// through this very same `add_http_front` before fanning out: the
1857    /// frontend is refused loudly and identically in main and worker, never
1858    /// silently mis-routed.
1859    ///
1860    /// Anchors the operator wrote by hand are harmless and are left alone:
1861    /// `regex`'s `^`/`$` are `\A`/`\z` in the default (non-multi-line) mode —
1862    /// `$` does not match before a trailing `\n` — so the repeated zero-width
1863    /// assertions in `\A(?:^/abc$)\z` collapse. Measured: that pattern matches
1864    /// `/abc` and not `/xabcd`, exactly as `\A(?:/abc)\z` does.
1865    fn anchored_regex(value: &str) -> Option<Regex> {
1866        anchored_regex(value)
1867    }
1868
1869    pub fn from_config(rule: CommandPathRule) -> Option<Self> {
1870        match PathRuleKind::try_from(rule.kind) {
1871            Ok(PathRuleKind::Prefix) => Some(PathRule::Prefix(rule.value)),
1872            Ok(PathRuleKind::Regex) => Self::anchored_regex(&rule.value).map(PathRule::Regex),
1873            Ok(PathRuleKind::Equals) => Some(PathRule::Equals(rule.value)),
1874            Ok(PathRuleKind::RegexPath) => {
1875                Self::anchored_regex(&rule.value).map(PathRule::RegexPath)
1876            }
1877            Err(_) => None,
1878        }
1879    }
1880}
1881
1882/// Compile `value` anchored at both ends, refusing a value that does not
1883/// compile on its own. [`PathRule::anchored_regex`] documents why both
1884/// steps are needed; header predicates (sozu-proxy/sozu#1253) reuse it so
1885/// a `regex` header value and a `REGEX` path follow one grammar.
1886fn anchored_regex(value: &str) -> Option<Regex> {
1887    Regex::new(value).ok()?;
1888    Regex::new(&format!("\\A(?:{value})\\z")).ok()
1889}
1890
1891/// `Regex` carries no `PartialEq`, so the comparison is written by hand
1892/// and compares patterns by `as_str()`. Every variant must answer for
1893/// itself: without an `Equals` arm the catch-all made two identical
1894/// `PathRule::Equals` unequal — not even reflexive — and the router's
1895/// bookkeeping (`add_*_rule` dedup, `remove_*_rule` eviction) silently
1896/// lost every `--path-equals` frontend, reporting success either way.
1897impl std::cmp::PartialEq for PathRule {
1898    fn eq(&self, other: &Self) -> bool {
1899        match (self, other) {
1900            (PathRule::Prefix(s1), PathRule::Prefix(s2)) => s1 == s2,
1901            (PathRule::Regex(r1), PathRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1902            (PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2,
1903            (PathRule::RegexPath(r1), PathRule::RegexPath(r2)) => r1.as_str() == r2.as_str(),
1904            _ => false,
1905        }
1906    }
1907}
1908
1909#[derive(Clone, Debug, PartialEq, Eq)]
1910pub struct MethodRule {
1911    pub inner: Option<Method>,
1912}
1913
1914#[derive(PartialEq, Eq)]
1915pub enum MethodRuleResult {
1916    All,
1917    Equals,
1918    None,
1919}
1920
1921impl MethodRule {
1922    pub fn new(method: Option<String>) -> Self {
1923        MethodRule {
1924            inner: method.map(|s| Method::new(s.as_bytes())),
1925        }
1926    }
1927
1928    pub fn matches(&self, method: &Method) -> MethodRuleResult {
1929        match self.inner {
1930            None => MethodRuleResult::All,
1931            Some(ref m) => {
1932                if method == m {
1933                    MethodRuleResult::Equals
1934                } else {
1935                    MethodRuleResult::None
1936                }
1937            }
1938        }
1939    }
1940}
1941
1942/// What to do with a request that matches a frontend.
1943///
1944/// Three variants coexist today; the legacy two will retire once
1945/// `HttpFrontend` itself carries the rich routing fields:
1946///
1947/// - [`Route::ClusterId`] is the legacy "forward to this cluster" variant
1948///   used by call sites that build routes directly from
1949///   [`HttpFrontend::cluster_id`].
1950/// - [`Route::Deny`] is the legacy "send 401" variant used when a frontend
1951///   has no `cluster_id`.
1952/// - [`Route::Frontend`] carries a richer [`Frontend`] decision (redirect
1953///   policy, rewrite templates, header edits, auth gating). Once
1954///   `HttpFrontend` carries the matching proto fields, `add_http_front`
1955///   will build `Route::Frontend` directly and the two legacy variants
1956///   above can retire.
1957///
1958/// `Eq`/`PartialEq` compare `Frontend` variants by `Rc` pointer identity to
1959/// stay consistent with `Hash`/`Ord` on [`Rc`]; this is sufficient for the
1960/// router's de-duplication (`add_pre_rule`, `add_post_rule`,
1961/// `add_tree_rule`) which only checks against routes created from the same
1962/// configuration call.
1963#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1964pub enum Route {
1965    /// send a 401 default answer
1966    Deny,
1967    /// the cluster to which the frontend belongs
1968    ClusterId(ClusterId),
1969    /// rich routing decision carrying redirect, rewrite, header, and auth
1970    /// configuration; supersedes the two legacy variants once the
1971    /// in-memory frontend wiring is migrated to build `Route::Frontend`
1972    /// directly.
1973    Frontend(Rc<Frontend>),
1974}
1975
1976/// Materialise the listener-default HSTS into a single shareable
1977/// [`HeaderEdit`] when the supplied policy resolves to a non-empty
1978/// `Strict-Transport-Security` header.
1979///
1980/// Returns `None` when the listener has no HSTS configured
1981/// (`new_hsts.is_none()`), when HSTS is explicitly disabled
1982/// (`enabled = Some(false)`), or when the render fails because of a
1983/// missing `max_age` (`enabled = Some(true)` with `max_age = None`,
1984/// the malformed-IPC defense-in-depth gate). Mirrors the gate
1985/// previously embedded in `rebuild_with_listener_hsts`.
1986///
1987/// Used by [`Router::refresh_inheriting_hsts`] to:
1988/// - decide whether promoting a lightweight `Route::ClusterId` /
1989///   `Route::Deny` to `Route::Frontend` is worth doing (`Some` =
1990///   promote + counted; `None` = lightweight route untouched, no
1991///   allocation created just to hold an empty edit), AND
1992/// - **share the same `Rc`-backed `key` / `val` allocation across
1993///   every visited frontend in this patch**. Without sharing, each
1994///   refreshed frontend would allocate a fresh
1995///   `Rc::from(b"strict-transport-security")` and a fresh
1996///   `Rc::from(rendered.into_bytes())` — on a 91 k-frontend
1997///   `cleverapps.io` shared listener × 8 workers, one HSTS-enable
1998///   patch produces ~1.5 M identical-content `Rc` allocations per
1999///   worker. With sharing the cost collapses to one allocation per
2000///   patch plus a refcount bump on each frontend (`HeaderEdit::clone`
2001///   is two `Rc::clone`s + a byte copy).
2002fn build_listener_hsts_edit(new_hsts: Option<&HstsConfig>) -> Option<HeaderEdit> {
2003    let cfg = new_hsts?;
2004    if !matches!(cfg.enabled, Some(true)) {
2005        return None;
2006    }
2007    let rendered = render_hsts(cfg)?;
2008    let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2009        HeaderEditMode::Set
2010    } else {
2011        HeaderEditMode::SetIfAbsent
2012    };
2013    Some(HeaderEdit {
2014        key: Rc::from(&b"strict-transport-security"[..]),
2015        val: rendered.into_bytes().into(),
2016        mode,
2017    })
2018}
2019
2020/// Build a new [`Frontend`] cloned from `frontend`, with its
2021/// `headers_response` re-materialised against `new_edit` — the
2022/// shared listener-default HSTS edit pre-built by
2023/// [`build_listener_hsts_edit`]. Used by
2024/// [`Router::refresh_inheriting_hsts`].
2025///
2026/// Preserves every operator-defined response-header edit (`Append`,
2027/// `Set`, legacy empty-`val`-Append delete) and replaces any existing
2028/// `Strict-Transport-Security` entry with `new_edit`. When `new_edit`
2029/// is `None` (listener-default HSTS resolves to "no HSTS"), the
2030/// function strips the existing STS entry and adds nothing.
2031///
2032/// Preserves the existing `inherits_listener_hsts` marker; callers
2033/// ensure it is `true` before invoking this helper (the function
2034/// uses `..frontend.clone()` so it inherits whatever the input has).
2035fn rebuild_with_listener_hsts(frontend: &Frontend, new_edit: Option<&HeaderEdit>) -> Frontend {
2036    // Strip any existing Strict-Transport-Security entry.
2037    let mut headers_response: Vec<HeaderEdit> = frontend
2038        .headers_response
2039        .iter()
2040        .filter(|edit| !edit.key.eq_ignore_ascii_case(b"strict-transport-security"))
2041        .cloned()
2042        .collect();
2043
2044    // `HeaderEdit::clone` here is two `Rc::clone`s on the shared
2045    // key/val plus a one-byte `mode` copy — no buffer allocation.
2046    if let Some(edit) = new_edit {
2047        headers_response.push(edit.clone());
2048    }
2049
2050    Frontend {
2051        headers_response: headers_response.into(),
2052        // every other field is unchanged
2053        ..frontend.clone()
2054    }
2055}
2056
2057/// Render an [`HstsConfig`] into a canonical RFC 6797 §6.1
2058/// `Strict-Transport-Security` header value: `max-age=N` first, then
2059/// optional `; includeSubDomains`, then optional `; preload`. No
2060/// trailing semicolon. `includeSubDomains` is the RFC §6.1 spelling
2061/// (camelCase); `preload` is lowercase per the de-facto Chrome/HSTS
2062/// preload-list convention (<https://hstspreload.org/>).
2063///
2064/// Returns `None` when the config has no `max_age` (the caller should
2065/// have substituted the default at config-load via
2066/// `command/src/config.rs::FileHstsConfig::to_proto` before reaching
2067/// this site; if it didn't, a `None` here suppresses the emission so a
2068/// malformed wire frame can't leak `max-age=0` accidentally).
2069pub fn render_hsts(cfg: &HstsConfig) -> Option<String> {
2070    let max_age = cfg.max_age?;
2071    let mut s = format!("max-age={max_age}");
2072    if matches!(cfg.include_subdomains, Some(true)) {
2073        s.push_str("; includeSubDomains");
2074    }
2075    if matches!(cfg.preload, Some(true)) {
2076        s.push_str("; preload");
2077    }
2078    Some(s)
2079}
2080
2081/// A single header mutation collected from a [`Frontend`] configuration.
2082///
2083/// `key` and `val` are owned via [`Rc`] so a `Frontend` can be held by many
2084/// routing entries (pre, tree, post) without copying the underlying bytes.
2085///
2086/// `mode` controls how the per-stream `apply_response_header_edits` pass
2087/// emits the entry on the wire — see [`HeaderEditMode`]. Operator-supplied
2088/// `[[...frontends.headers]]` entries default to [`HeaderEditMode::Append`]
2089/// (preserving the legacy empty-val-deletes encoding); typed policies
2090/// (HSTS, future RFC-correct response policies) opt into
2091/// [`HeaderEditMode::SetIfAbsent`].
2092#[derive(Clone, PartialEq, Eq)]
2093pub struct HeaderEdit {
2094    pub key: Rc<[u8]>,
2095    pub val: Rc<[u8]>,
2096    pub mode: HeaderEditMode,
2097}
2098
2099impl Debug for HeaderEdit {
2100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2101        f.write_fmt(format_args!(
2102            "({:?}, {:?}, {:?})",
2103            String::from_utf8_lossy(&self.key),
2104            String::from_utf8_lossy(&self.val),
2105            self.mode,
2106        ))
2107    }
2108}
2109
2110/// A parsed segment of a rewrite template.
2111///
2112/// `Host(i)` references the `i`-th host capture: index 0 is the full
2113/// hostname; positive indices are regex or wildcard subgroups. `Path(i)`
2114/// references the `i`-th path capture: index 0 is the full path; positive
2115/// indices are regex groups or prefix tails. `String` holds a literal
2116/// segment between captures.
2117#[derive(Debug, Clone, PartialEq, Eq)]
2118enum RewritePart {
2119    String(String),
2120    Host(usize),
2121    Path(usize),
2122}
2123
2124/// A pre-parsed rewrite template, decomposed into `RewritePart`s.
2125///
2126/// `RewriteParts` is built once at frontend registration time
2127/// ([`Frontend::new`]) and then re-applied at lookup time via
2128/// [`RewriteParts::run`] against the captures collected by the router.
2129///
2130/// Grammar:
2131/// - `$HOST[N]` — substitute the N-th host capture
2132/// - `$PATH[N]` — substitute the N-th path capture
2133/// - any other byte sequence — substitute literally
2134///
2135/// Out-of-bounds capture indices substitute to the empty string at run
2136/// time, but [`RewriteParts::parse`] rejects templates that reference
2137/// capture indices the router cannot produce (the `*_cap_cap` arguments).
2138#[derive(Debug, Clone, PartialEq, Eq)]
2139pub struct RewriteParts(Vec<RewritePart>);
2140
2141impl RewriteParts {
2142    /// Parse `template` against the host/path capture caps the router can
2143    /// produce for the matching domain/path rule.
2144    ///
2145    /// `host_cap_cap` and `path_cap_cap` are upper bounds (exclusive) on the
2146    /// host and path capture indices the router can fill at lookup time.
2147    /// `used_index_host` / `used_index_path` are out parameters tracking the
2148    /// highest index actually referenced — callers use them to short-circuit
2149    /// capture extraction when no template references captures.
2150    ///
2151    /// Returns `None` on syntactically malformed templates: dangling `$`,
2152    /// missing closing `]`, non-digit index, or an index ≥ the cap.
2153    pub fn parse(
2154        template: &str,
2155        host_cap_cap: usize,
2156        path_cap_cap: usize,
2157        used_index_host: &mut usize,
2158        used_index_path: &mut usize,
2159    ) -> Option<Self> {
2160        let mut result = Vec::new();
2161        let mut i = 0;
2162        let pattern = template.as_bytes();
2163        while i < pattern.len() {
2164            if pattern[i] == b'$' {
2165                let is_host = if pattern[i..].starts_with(b"$HOST[") {
2166                    i += 6;
2167                    true
2168                } else if pattern[i..].starts_with(b"$PATH[") {
2169                    i += 6;
2170                    false
2171                } else {
2172                    return None;
2173                };
2174                let mut index = 0usize;
2175                let digits_start = i;
2176                while i < pattern.len() && pattern[i].is_ascii_digit() {
2177                    index = index
2178                        .checked_mul(10)?
2179                        .checked_add((pattern[i] - b'0') as usize)?;
2180                    i += 1;
2181                }
2182                if i == digits_start {
2183                    // no digits between the `[` and the `]`
2184                    return None;
2185                }
2186                if i >= pattern.len() || pattern[i] != b']' {
2187                    return None;
2188                }
2189                if is_host {
2190                    if index >= host_cap_cap {
2191                        return None;
2192                    }
2193                    if index >= *used_index_host {
2194                        *used_index_host = index + 1;
2195                    }
2196                    result.push(RewritePart::Host(index));
2197                } else {
2198                    if index >= path_cap_cap {
2199                        return None;
2200                    }
2201                    if index >= *used_index_path {
2202                        *used_index_path = index + 1;
2203                    }
2204                    result.push(RewritePart::Path(index));
2205                }
2206                i += 1; // consume `]`
2207            } else {
2208                let start = i;
2209                while i < pattern.len() && pattern[i] != b'$' {
2210                    i += 1;
2211                }
2212                // `pattern` is `template.as_bytes()` and the split is on
2213                // the ASCII byte `$` (0x24), which is always a single-byte
2214                // UTF-8 character — so `template[start..i]` lies on char
2215                // boundaries and is safe to index directly.
2216                result.push(RewritePart::String(template[start..i].to_owned()));
2217            }
2218        }
2219        // Every capture reference the parser emitted is within the caps it
2220        // was given; out-of-range indices return None above, never a part.
2221        debug_assert!(
2222            result.iter().all(|part| match part {
2223                RewritePart::Host(idx) => *idx < host_cap_cap,
2224                RewritePart::Path(idx) => *idx < path_cap_cap,
2225                RewritePart::String(_) => true,
2226            }),
2227            "a parsed rewrite template must only reference captures within the rule's caps",
2228        );
2229        debug_assert!(
2230            *used_index_host <= host_cap_cap && *used_index_path <= path_cap_cap,
2231            "the highest referenced capture index cannot exceed the cap",
2232        );
2233        Some(Self(result))
2234    }
2235
2236    /// Substitute `host_captures` and `path_captures` into the template.
2237    ///
2238    /// Out-of-bounds captures substitute to an empty string. The result is
2239    /// allocated in one pass with the exact required capacity.
2240    pub fn run(&self, host_captures: &[&str], path_captures: &[&str]) -> String {
2241        let mut cap = 0usize;
2242        for part in &self.0 {
2243            cap += match part {
2244                RewritePart::String(s) => s.len(),
2245                RewritePart::Host(i) => host_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2246                RewritePart::Path(i) => path_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2247            };
2248        }
2249        let mut result = String::with_capacity(cap);
2250        for part in &self.0 {
2251            // String::write_str cannot fail — ignore the formatter result.
2252            let _ = match part {
2253                RewritePart::String(s) => result.write_str(s),
2254                RewritePart::Host(i) => result.write_str(host_captures.get(*i).unwrap_or(&"")),
2255                RewritePart::Path(i) => result.write_str(path_captures.get(*i).unwrap_or(&"")),
2256            };
2257        }
2258        // The capacity pass and the write pass consult the same parts and
2259        // captures, so the single up-front allocation must be exact — the
2260        // result never reallocates.
2261        debug_assert_eq!(
2262            result.len(),
2263            cap,
2264            "rewrite output length must equal the pre-computed one-pass capacity",
2265        );
2266        result
2267    }
2268}
2269
2270/// What to do with the traffic for a routed frontend.
2271///
2272/// Built once at frontend registration time. The expensive work (parsing
2273/// rewrite templates, resolving headers into [`HeaderEdit`]s) happens here
2274/// so [`Router::lookup`] can run cheaply on the hot path.
2275///
2276/// A clusterless frontend with `redirect == FORWARD` is coerced to
2277/// `UNAUTHORIZED` in [`Frontend::new`] to avoid a forward loop with no
2278/// backend; the explicit `UNAUTHORIZED` policy then renders a 401.
2279///
2280/// Tags are wrapped in [`Rc<CachedTags>`] so the same frontend can be
2281/// referenced from multiple routing slots (pre/tree/post) without copying.
2282#[derive(Debug, Clone)]
2283pub struct Frontend {
2284    pub cluster_id: Option<ClusterId>,
2285    pub redirect: RedirectPolicy,
2286    pub redirect_scheme: RedirectScheme,
2287    pub redirect_template: Option<String>,
2288    /// Number of host captures the router will collect for this frontend.
2289    /// Sized from the matching [`DomainRule`]; the router skips capture
2290    /// extraction entirely when this is 0 (no rewrite references `$HOST[…]`).
2291    pub capture_cap_host: usize,
2292    /// Number of path captures the router will collect for this frontend.
2293    /// Sized from the matching [`PathRule`]; the router skips capture
2294    /// extraction entirely when this is 0 (no rewrite references `$PATH[…]`).
2295    pub capture_cap_path: usize,
2296    pub rewrite_host: Option<RewriteParts>,
2297    pub rewrite_path: Option<RewriteParts>,
2298    pub rewrite_port: Option<u16>,
2299    pub headers_request: Rc<[HeaderEdit]>,
2300    pub headers_response: Rc<[HeaderEdit]>,
2301    pub required_auth: bool,
2302    pub tags: Option<Rc<CachedTags>>,
2303    /// `true` when the frontend declared no per-frontend
2304    /// `RequestHttpFrontend.hsts` block, so the HSTS edit (if any) in
2305    /// [`Self::headers_response`] follows the listener-default
2306    /// `HttpsListenerConfig.hsts` — including when that default was
2307    /// absent at add time, so a default set later, or the default of a
2308    /// listener the frontend is copied into, still reaches it
2309    /// (sozu-proxy/sozu#1691). Consulted by
2310    /// [`Router::refresh_inheriting_hsts`] so a
2311    /// `UpdateHttpsListenerConfig.hsts` patch reflows the new default
2312    /// onto inheriting frontends without overwriting explicit
2313    /// per-frontend HSTS overrides.
2314    pub inherits_listener_hsts: bool,
2315}
2316
2317/// Origin of the per-frontend HSTS policy carried by an
2318/// [`HttpFrontend`] when the router materialises it into a
2319/// [`Frontend`]. Tracked separately because the resolved
2320/// `HttpFrontend.hsts` field is the same shape regardless of how it
2321/// was filled in — the inheritance bit lets later listener-default
2322/// patches refresh inheriting frontends without disturbing explicit
2323/// per-frontend overrides.
2324#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2325pub enum HstsOrigin {
2326    /// `front.hsts` came from the per-frontend configuration directly
2327    /// (operator wrote `[clusters.<id>.frontends.hsts]` in TOML or
2328    /// passed `--hsts-*` on the CLI). Listener-default patches do NOT
2329    /// refresh this entry.
2330    Explicit,
2331    /// The frontend declared no `hsts`, and `add_https_frontend` filled
2332    /// `front.hsts` in from the listener-default
2333    /// `HttpsListenerConfig.hsts` — `None` when the listener has no
2334    /// default. A future
2335    /// `UpdateHttpsListenerConfig.hsts` patch will refresh this entry
2336    /// via [`Router::refresh_inheriting_hsts`].
2337    InheritedFromListenerDefault,
2338}
2339
2340impl PartialEq for Frontend {
2341    fn eq(&self, other: &Self) -> bool {
2342        // Frontend instances share the rest of their fields with the
2343        // originating HttpFrontend; equality is decided by the same fields
2344        // the router uses for de-duplication.
2345        self.cluster_id == other.cluster_id
2346            && self.redirect == other.redirect
2347            && self.redirect_scheme == other.redirect_scheme
2348            && self.redirect_template == other.redirect_template
2349            && self.rewrite_host == other.rewrite_host
2350            && self.rewrite_path == other.rewrite_path
2351            && self.rewrite_port == other.rewrite_port
2352            && self.headers_request == other.headers_request
2353            && self.headers_response == other.headers_response
2354            && self.required_auth == other.required_auth
2355    }
2356}
2357
2358impl Eq for Frontend {}
2359
2360impl std::hash::Hash for Frontend {
2361    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
2362        self.cluster_id.hash(state);
2363        // RedirectPolicy / RedirectScheme are i32-backed proto enums; hash
2364        // them as i32 to avoid requiring a Hash impl on the generated enum.
2365        (self.redirect as i32).hash(state);
2366        (self.redirect_scheme as i32).hash(state);
2367        self.redirect_template.hash(state);
2368        self.required_auth.hash(state);
2369    }
2370}
2371
2372impl PartialOrd for Frontend {
2373    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2374        Some(self.cmp(other))
2375    }
2376}
2377
2378impl Ord for Frontend {
2379    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2380        self.cluster_id
2381            .cmp(&other.cluster_id)
2382            .then_with(|| (self.redirect as i32).cmp(&(other.redirect as i32)))
2383            .then_with(|| (self.redirect_scheme as i32).cmp(&(other.redirect_scheme as i32)))
2384            .then_with(|| self.redirect_template.cmp(&other.redirect_template))
2385            .then_with(|| self.required_auth.cmp(&other.required_auth))
2386    }
2387}
2388
2389impl Frontend {
2390    /// Build a [`Frontend`] from a domain/path rule pair and an
2391    /// [`HttpFrontend`] configuration.
2392    ///
2393    /// The richer proto-level fields (`redirect`, `redirect_scheme`,
2394    /// `redirect_template`, `rewrite_*`, `headers`, `required_auth`) are
2395    /// not yet carried on `HttpFrontend`; until they are, this constructor
2396    /// takes them as explicit arguments so the data flow is testable
2397    /// today and the call sites in `add_http_front` only need a one-line
2398    /// update once the fields are plumbed through.
2399    ///
2400    /// Coercions:
2401    /// - `redirect == UNAUTHORIZED` zeroes out rewrite/headers/auth — the
2402    ///   request will be rejected with a 401 regardless.
2403    /// - `redirect == FORWARD` on a clusterless frontend (`cluster_id ==
2404    ///   None`) is coerced to `UNAUTHORIZED` (logged as a warning) to
2405    ///   avoid a forward loop with no backend.
2406    ///
2407    /// Returns [`RouterError::InvalidHostRewrite`] /
2408    /// [`RouterError::InvalidPathRewrite`] when a rewrite template fails to
2409    /// parse against the rule's capture caps.
2410    #[allow(clippy::too_many_arguments)]
2411    pub fn new(
2412        domain_rule: &DomainRule,
2413        path_rule: &PathRule,
2414        front: &HttpFrontend,
2415        redirect: RedirectPolicy,
2416        redirect_scheme: RedirectScheme,
2417        redirect_template: Option<String>,
2418        rewrite_host: Option<String>,
2419        rewrite_path: Option<String>,
2420        rewrite_port: Option<u16>,
2421        headers: &[sozu_command::proto::command::Header],
2422        required_auth: bool,
2423        hsts_origin: HstsOrigin,
2424    ) -> Result<Self, RouterError> {
2425        // HSTS is read from `front.hsts` directly inside the function;
2426        // an explicit parameter would be redundant since `front` is
2427        // already in scope and the field is the single source of truth.
2428        // The `hsts_origin` parameter records whether the frontend declared
2429        // its own `hsts` (whatever the listener default was at add time) so
2430        // [`Router::refresh_inheriting_hsts`] can reflow listener defaults
2431        // without disturbing explicit per-frontend overrides.
2432        let hsts = front.hsts.as_ref();
2433        let inherits_listener_hsts =
2434            matches!(hsts_origin, HstsOrigin::InheritedFromListenerDefault);
2435        let cluster_id = front.cluster_id.clone();
2436        let tags = front
2437            .tags
2438            .clone()
2439            .map(|tags| Rc::new(CachedTags::new(tags)));
2440
2441        // Coerce clusterless FORWARD to UNAUTHORIZED before doing any
2442        // expensive parsing: those routes can never proceed to a backend, so
2443        // emitting a 401 is the safe default. Empty redirect_template is
2444        // treated as None semantics so we never store an empty Rc<[…]>.
2445        let redirect_template = redirect_template.filter(|s| !s.is_empty());
2446        let rewrite_host = rewrite_host.filter(|s| !s.is_empty());
2447        let rewrite_path = rewrite_path.filter(|s| !s.is_empty());
2448
2449        let deny = match (&cluster_id, redirect) {
2450            (_, RedirectPolicy::Unauthorized) => true,
2451            (None, RedirectPolicy::Forward) => {
2452                let (domain_kind, domain_bytes) = match &domain_rule {
2453                    DomainRule::Any => ("any", 0),
2454                    DomainRule::Exact(value) => ("exact", value.len()),
2455                    DomainRule::Wildcard(value) => ("wildcard", value.len()),
2456                    DomainRule::Regex(value) => ("regex", value.as_str().len()),
2457                };
2458                let (path_kind, path_bytes) = match &path_rule {
2459                    PathRule::Prefix(value) => ("prefix", value.len()),
2460                    PathRule::Regex(value) => ("regex", value.as_str().len()),
2461                    PathRule::Equals(value) => ("equals", value.len()),
2462                    PathRule::RegexPath(value) => ("regex_path", value.as_str().len()),
2463                };
2464                warn!(
2465                    "{} Frontend[domain_kind={}, domain_bytes={}, path_kind={}, path_bytes={}]: forward on clusterless frontends are unauthorized",
2466                    log_module_context!(),
2467                    domain_kind,
2468                    domain_bytes,
2469                    path_kind,
2470                    path_bytes,
2471                );
2472                true
2473            }
2474            _ => false,
2475        };
2476        if deny {
2477            // The Unauthorized policy zeroes out request rewrites and
2478            // header injections (the request never reaches a backend),
2479            // but RFC 6797 §8.1 still requires HSTS on the 401 default
2480            // answer when the frontend is on an HTTPS listener. Build
2481            // the HSTS edit (when configured) so the per-stream
2482            // snapshot copy in `mux/router.rs` carries it through to
2483            // `set_default_answer_with_retry_after`.
2484            let mut deny_headers_response: Vec<HeaderEdit> = Vec::new();
2485            if let Some(cfg) = hsts
2486                && matches!(cfg.enabled, Some(true))
2487                && let Some(rendered) = render_hsts(cfg)
2488            {
2489                let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2490                    HeaderEditMode::Set
2491                } else {
2492                    HeaderEditMode::SetIfAbsent
2493                };
2494                deny_headers_response.push(HeaderEdit {
2495                    key: Rc::from(&b"strict-transport-security"[..]),
2496                    val: rendered.into_bytes().into(),
2497                    mode,
2498                });
2499                crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2500            }
2501
2502            return Ok(Self {
2503                cluster_id,
2504                redirect: RedirectPolicy::Unauthorized,
2505                redirect_scheme,
2506                redirect_template: None,
2507                capture_cap_host: 0,
2508                capture_cap_path: 0,
2509                rewrite_host: None,
2510                rewrite_path: None,
2511                rewrite_port: None,
2512                headers_request: Rc::new([]),
2513                headers_response: deny_headers_response.into(),
2514                required_auth,
2515                tags,
2516                inherits_listener_hsts,
2517            });
2518        }
2519
2520        // Capture caps: the maximum index a `$HOST[N]` / `$PATH[N]`
2521        // template can reference for this rule pair. Index 0 is always the
2522        // full hostname/path; subsequent indices are wildcard tail / regex
2523        // groups.
2524        let mut capture_cap_host = match domain_rule {
2525            DomainRule::Any => 1,
2526            DomainRule::Exact(_) => 1,
2527            DomainRule::Wildcard(_) => 2,
2528            DomainRule::Regex(regex) => regex.captures_len(),
2529        };
2530        let mut capture_cap_path = match path_rule {
2531            PathRule::Equals(_) => 1,
2532            PathRule::Prefix(_) => 2,
2533            PathRule::Regex(regex) | PathRule::RegexPath(regex) => regex.captures_len(),
2534        };
2535        let mut used_capture_host = 0usize;
2536        let mut used_capture_path = 0usize;
2537        let rewrite_host_parts = if let Some(p) = rewrite_host {
2538            Some(
2539                RewriteParts::parse(
2540                    &p,
2541                    capture_cap_host,
2542                    capture_cap_path,
2543                    &mut used_capture_host,
2544                    &mut used_capture_path,
2545                )
2546                .ok_or(RouterError::InvalidHostRewrite(p))?,
2547            )
2548        } else {
2549            None
2550        };
2551        let rewrite_path_parts = if let Some(p) = rewrite_path {
2552            Some(
2553                RewriteParts::parse(
2554                    &p,
2555                    capture_cap_host,
2556                    capture_cap_path,
2557                    &mut used_capture_host,
2558                    &mut used_capture_path,
2559                )
2560                .ok_or(RouterError::InvalidPathRewrite(p))?,
2561            )
2562        } else {
2563            None
2564        };
2565        // Skip capture extraction at lookup time when no template references
2566        // a capture for this dimension.
2567        if used_capture_host == 0 {
2568            capture_cap_host = 0;
2569        }
2570        if used_capture_path == 0 {
2571            capture_cap_path = 0;
2572        }
2573
2574        let mut headers_request = Vec::new();
2575        let mut headers_response = Vec::new();
2576        for header in headers {
2577            let edit = HeaderEdit {
2578                key: header.key.as_bytes().into(),
2579                val: header.val.as_bytes().into(),
2580                mode: HeaderEditMode::Append,
2581            };
2582            match header.position() {
2583                HeaderPosition::Request => headers_request.push(edit),
2584                HeaderPosition::Response => headers_response.push(edit),
2585                HeaderPosition::Both => {
2586                    headers_request.push(edit.clone());
2587                    headers_response.push(edit);
2588                }
2589                // The proto-default-encoded shape (`position: 0`). The TOML
2590                // loader rejects this case in `parse_header_edit` so the
2591                // path is only reachable via a manually-constructed
2592                // `Header { position: 0, … }` from a buggy or older
2593                // client. Drop the edit rather than guessing a position.
2594                HeaderPosition::Unspecified => {
2595                    warn!(
2596                        "{} dropping {:?} with HEADER_POSITION_UNSPECIFIED",
2597                        log_module_context!(),
2598                        header,
2599                    );
2600                }
2601            }
2602        }
2603
2604        // Materialise HSTS (RFC 6797) into the response-side header
2605        // collection as a single `SetIfAbsent` edit so an upstream-emitted
2606        // `Strict-Transport-Security` survives unchanged (RFC 6797 §6.1
2607        // single-header requirement). `enabled = Some(false)` is the
2608        // explicit-disable signal — emit nothing. The §7.2 "no STS over
2609        // plaintext HTTP" gate is enforced by the runtime snapshot site,
2610        // which only copies `headers_response` for HTTPS-served requests.
2611        if let Some(cfg) = hsts
2612            && matches!(cfg.enabled, Some(true))
2613        {
2614            if let Some(rendered) = render_hsts(cfg) {
2615                // RFC 6797 §6.1 default: PRESERVE backend-supplied STS
2616                // (SetIfAbsent). Operator opts into harden-centrally
2617                // override via `force_replace_backend = true`, which
2618                // selects `Set` (delete-then-insert) so any backend
2619                // STS is replaced with sozu's rendered policy.
2620                let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2621                    HeaderEditMode::Set
2622                } else {
2623                    HeaderEditMode::SetIfAbsent
2624                };
2625                headers_response.push(HeaderEdit {
2626                    key: Rc::from(&b"strict-transport-security"[..]),
2627                    val: rendered.into_bytes().into(),
2628                    mode,
2629                });
2630                crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2631            } else {
2632                // Both upstream config layers (FileHstsConfig::to_proto and
2633                // build_hsts_from_cli) substitute DEFAULT_HSTS_MAX_AGE when
2634                // enabled = Some(true) && max_age = None, so reaching this
2635                // branch means a programmatic IPC sender produced an
2636                // ill-formed HstsConfig. Surface it loudly rather than
2637                // silently emitting no header — operators inspecting their
2638                // dashboards for http.hsts.unrendered will catch the bug.
2639                warn!(
2640                    "{} HSTS enabled = true on frontend cluster_id_bytes={:?} but render_hsts \
2641                     returned None (max_age missing). Frontend will not emit \
2642                     Strict-Transport-Security; the config layer that built \
2643                     this HstsConfig must substitute DEFAULT_HSTS_MAX_AGE.",
2644                    log_module_context!(),
2645                    cluster_id.as_deref().map(str::len),
2646                );
2647                crate::incr!(names::http::HSTS_UNRENDERED);
2648            }
2649        }
2650
2651        Ok(Frontend {
2652            cluster_id,
2653            redirect,
2654            redirect_scheme,
2655            redirect_template,
2656            capture_cap_host,
2657            capture_cap_path,
2658            rewrite_host: rewrite_host_parts,
2659            rewrite_path: rewrite_path_parts,
2660            rewrite_port,
2661            headers_request: headers_request.into(),
2662            headers_response: headers_response.into(),
2663            required_auth,
2664            tags,
2665            inherits_listener_hsts,
2666        })
2667    }
2668
2669    /// Build a minimal Frontend that simply forwards to `cluster_id`,
2670    /// with no rewrite / header / auth configuration. Equivalent to a
2671    /// `Route::ClusterId(cluster_id)` lookup-wise (lookup short-circuits
2672    /// to `RouteResult::forward(id)` for both shapes), with the added
2673    /// ability to carry response-header edits — used by
2674    /// [`Router::refresh_inheriting_hsts`] to promote a lightweight
2675    /// `Route::ClusterId` to a `Route::Frontend` carrying just the
2676    /// listener-default HSTS edit. The `inherits_listener_hsts = true`
2677    /// marker lets subsequent listener-default patches keep refreshing
2678    /// the promoted entry.
2679    pub(crate) fn minimal_forward(cluster_id: ClusterId) -> Self {
2680        Self {
2681            cluster_id: Some(cluster_id),
2682            redirect: RedirectPolicy::Forward,
2683            redirect_scheme: RedirectScheme::UseSame,
2684            redirect_template: None,
2685            capture_cap_host: 0,
2686            capture_cap_path: 0,
2687            rewrite_host: None,
2688            rewrite_path: None,
2689            rewrite_port: None,
2690            headers_request: Rc::new([]),
2691            headers_response: Rc::new([]),
2692            required_auth: false,
2693            tags: None,
2694            inherits_listener_hsts: true,
2695        }
2696    }
2697
2698    /// Build a minimal clusterless Frontend with the
2699    /// [`RedirectPolicy::Unauthorized`] policy. Equivalent to a
2700    /// `Route::Deny` lookup-wise (both yield a 401 default answer),
2701    /// with the added ability to carry response-header edits — used by
2702    /// [`Router::refresh_inheriting_hsts`] to promote `Route::Deny`
2703    /// entries when the listener-default HSTS becomes enabled, so the
2704    /// 401 default answer carries the `Strict-Transport-Security`
2705    /// header per RFC 6797 §8.1. The `inherits_listener_hsts = true`
2706    /// marker lets subsequent listener-default patches keep refreshing
2707    /// the promoted entry.
2708    pub(crate) fn minimal_deny() -> Self {
2709        Self {
2710            cluster_id: None,
2711            redirect: RedirectPolicy::Unauthorized,
2712            redirect_scheme: RedirectScheme::UseSame,
2713            redirect_template: None,
2714            capture_cap_host: 0,
2715            capture_cap_path: 0,
2716            rewrite_host: None,
2717            rewrite_path: None,
2718            rewrite_port: None,
2719            headers_request: Rc::new([]),
2720            headers_response: Rc::new([]),
2721            required_auth: false,
2722            tags: None,
2723            inherits_listener_hsts: true,
2724        }
2725    }
2726}
2727
2728/// Carry a `REGEX_PATH` request's query string over to its rewritten path
2729/// (sozu-proxy/sozu#1909). The rule matched, and its `$PATH[n]` captured,
2730/// the path alone, so a template cannot reach the query: it is appended,
2731/// `?` included, unless the rewritten path already carries a `?` of its
2732/// own, in which case the template's query is kept as written and the
2733/// original one is dropped. `original_query` is empty for every other rule
2734/// kind and for a request without a query, and nothing is appended.
2735fn append_original_query(mut rewritten: String, original_query: &[u8]) -> String {
2736    debug_assert!(
2737        original_query.is_empty() || original_query[0] == b'?',
2738        "a non-empty original query starts with its `?`",
2739    );
2740    let had_own_query = rewritten.contains('?');
2741    if !original_query.is_empty() && !had_own_query {
2742        // The query half was split off the `&str` the router was handed, on
2743        // the ASCII `?`, so it is valid UTF-8 and `from_utf8` never fails.
2744        rewritten.push_str(from_utf8(original_query).unwrap_or_default());
2745    }
2746    debug_assert!(
2747        had_own_query || rewritten.ends_with(from_utf8(original_query).unwrap_or_default()),
2748        "absent a query of its own, the rewritten path must end with the original query",
2749    );
2750    rewritten
2751}
2752
2753/// Routing decision returned by [`Router::lookup`] and consumed by the
2754/// session layer.
2755///
2756/// Computed from a matched [`Frontend`] by running [`RewriteParts::run`]
2757/// against the captures collected during routing. Legacy [`Route::ClusterId`]
2758/// and [`Route::Deny`] entries synthesize a minimal `RouteResult` with the
2759/// proto enums set to defaults (`FORWARD` / `UNAUTHORIZED`) so existing
2760/// session code keeps working until the mux layer is updated to read every
2761/// `RouteResult` field directly.
2762///
2763/// Implements `PartialEq` for test parity: existing router tests compare
2764/// `router.lookup(...)` against an expected route. Equality compares every
2765/// public field, including the `Rc<[HeaderEdit]>` slices via pointer-or-content
2766/// equality on the slice contents.
2767#[derive(Debug, Clone, PartialEq)]
2768pub struct RouteResult {
2769    pub cluster_id: Option<ClusterId>,
2770    pub redirect: RedirectPolicy,
2771    pub redirect_scheme: RedirectScheme,
2772    pub redirect_template: Option<String>,
2773    pub rewritten_host: Option<String>,
2774    pub rewritten_path: Option<String>,
2775    pub rewritten_port: Option<u16>,
2776    pub headers_request: Rc<[HeaderEdit]>,
2777    pub headers_response: Rc<[HeaderEdit]>,
2778    pub required_auth: bool,
2779    pub tags: Option<Rc<CachedTags>>,
2780}
2781
2782impl RouteResult {
2783    /// Synthesize a `RouteResult` representing a 401 (Deny) decision.
2784    pub fn deny(cluster_id: Option<ClusterId>) -> Self {
2785        Self {
2786            cluster_id,
2787            redirect: RedirectPolicy::Unauthorized,
2788            redirect_scheme: RedirectScheme::UseSame,
2789            redirect_template: None,
2790            rewritten_host: None,
2791            rewritten_path: None,
2792            rewritten_port: None,
2793            headers_request: Rc::new([]),
2794            headers_response: Rc::new([]),
2795            required_auth: false,
2796            tags: None,
2797        }
2798    }
2799
2800    /// Synthesize a `RouteResult` representing a "forward to this cluster"
2801    /// decision (legacy [`Route::ClusterId`] adapter).
2802    pub fn forward(cluster_id: ClusterId) -> Self {
2803        Self {
2804            cluster_id: Some(cluster_id),
2805            redirect: RedirectPolicy::Forward,
2806            redirect_scheme: RedirectScheme::UseSame,
2807            redirect_template: None,
2808            rewritten_host: None,
2809            rewritten_path: None,
2810            rewritten_port: None,
2811            headers_request: Rc::new([]),
2812            headers_response: Rc::new([]),
2813            required_auth: false,
2814            tags: None,
2815        }
2816    }
2817
2818    /// Build a `RouteResult` from a [`Frontend`] and the captures collected
2819    /// for this lookup.
2820    fn from_frontend(
2821        frontend: &Frontend,
2822        captures_host: Vec<&str>,
2823        path: &[u8],
2824        path_rule: &PathRule,
2825    ) -> Self {
2826        // Unauthorized short-circuit: skip the path-capture extraction
2827        // entirely — the response will not consume any rewrite output.
2828        // `headers_response` IS preserved here (unlike `headers_request`)
2829        // so the per-stream snapshot copy in `mux/router.rs` can still
2830        // pick up the per-frontend HSTS edit and inject it on the 401
2831        // default answer (RFC 6797 §8.1 — HSTS applies to all response
2832        // codes, including the proxy's typed unauthorized answer).
2833        if frontend.redirect == RedirectPolicy::Unauthorized {
2834            return Self {
2835                cluster_id: frontend.cluster_id.clone(),
2836                redirect: RedirectPolicy::Unauthorized,
2837                redirect_scheme: frontend.redirect_scheme,
2838                redirect_template: frontend.redirect_template.clone(),
2839                rewritten_host: None,
2840                rewritten_path: None,
2841                rewritten_port: None,
2842                headers_request: Rc::new([]),
2843                headers_response: frontend.headers_response.clone(),
2844                required_auth: frontend.required_auth,
2845                tags: frontend.tags.clone(),
2846            };
2847        }
2848
2849        // A `REGEX_PATH` rule matched the path alone, so every `$PATH[n]`,
2850        // index 0 included, is taken from the path alone too, and the query
2851        // it never saw is carried over to the rewritten path below.
2852        let (path, original_query) = match path_rule {
2853            PathRule::RegexPath(_) => split_query(path),
2854            _ => (path, &path[path.len()..]),
2855        };
2856        let mut captures_path: Vec<&str> = Vec::with_capacity(frontend.capture_cap_path);
2857        if frontend.capture_cap_path > 0 {
2858            captures_path.push(from_utf8(path).unwrap_or_default());
2859            match path_rule {
2860                PathRule::Prefix(prefix) => {
2861                    let tail_start = prefix.len().min(path.len());
2862                    captures_path.push(from_utf8(&path[tail_start..]).unwrap_or_default());
2863                }
2864                PathRule::Regex(regex) | PathRule::RegexPath(regex) => {
2865                    if let Some(caps) = regex.captures(path) {
2866                        captures_path.extend(caps.iter().skip(1).map(|c| {
2867                            c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2868                                .unwrap_or("")
2869                        }));
2870                    }
2871                }
2872                PathRule::Equals(_) => {}
2873            }
2874        }
2875
2876        Self {
2877            cluster_id: frontend.cluster_id.clone(),
2878            redirect: frontend.redirect,
2879            redirect_scheme: frontend.redirect_scheme,
2880            redirect_template: frontend.redirect_template.clone(),
2881            rewritten_host: frontend
2882                .rewrite_host
2883                .as_ref()
2884                .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2885            rewritten_path: frontend.rewrite_path.as_ref().map(|rewrite| {
2886                append_original_query(rewrite.run(&captures_host, &captures_path), original_query)
2887            }),
2888            rewritten_port: frontend.rewrite_port,
2889            headers_request: frontend.headers_request.clone(),
2890            headers_response: frontend.headers_response.clone(),
2891            required_auth: frontend.required_auth,
2892            tags: frontend.tags.clone(),
2893        }
2894    }
2895
2896    /// Build a `RouteResult` for a pre/post rule match.
2897    ///
2898    /// Pre/post rules carry the matched [`DomainRule`] directly so we can
2899    /// extract host captures from it without going through the trie.
2900    fn new_no_trie<'a>(
2901        domain: &'a [u8],
2902        domain_rule: &DomainRule,
2903        path: &'a [u8],
2904        path_rule: &PathRule,
2905        route: &Route,
2906    ) -> Self {
2907        let frontend = match route {
2908            Route::Frontend(f) => f.clone(),
2909            Route::ClusterId(id) => return Self::forward(id.clone()),
2910            Route::Deny => return Self::deny(None),
2911        };
2912        let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2913        if frontend.capture_cap_host > 0 {
2914            captures_host.push(from_utf8(domain).unwrap_or_default());
2915            match domain_rule {
2916                DomainRule::Wildcard(suffix) => {
2917                    let head_end = domain.len().saturating_sub(suffix.len().saturating_sub(1));
2918                    captures_host.push(from_utf8(&domain[..head_end]).unwrap_or_default());
2919                }
2920                DomainRule::Regex(regex) => {
2921                    if let Some(caps) = regex.captures(domain) {
2922                        captures_host.extend(caps.iter().skip(1).map(|c| {
2923                            c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2924                                .unwrap_or("")
2925                        }));
2926                    }
2927                }
2928                DomainRule::Any | DomainRule::Exact(_) => {}
2929            }
2930        }
2931        Self::from_frontend(&frontend, captures_host, path, path_rule)
2932    }
2933
2934    /// Build a `RouteResult` for a tree-match.
2935    ///
2936    /// Tree matches carry the captures collected by the trie traversal
2937    /// (`InlineTrieMatches`) alongside the matched leaf path rule.
2938    ///
2939    /// The trie records segments in walk order, rightmost label first, so
2940    /// they are read back in reverse: `$HOST[n]` counts left to right, as the
2941    /// whole-host regex of a pre/post rule does (sozu#1595). Each segment's
2942    /// own groups keep their regex order.
2943    fn new_with_trie<'a, 'b>(
2944        domain: &'a [u8],
2945        domain_submatches: &InlineTrieMatches<'a, 'b>,
2946        path: &'a [u8],
2947        path_rule: &PathRule,
2948        route: &Route,
2949    ) -> Self {
2950        let frontend = match route {
2951            Route::Frontend(f) => f.clone(),
2952            Route::ClusterId(id) => return Self::forward(id.clone()),
2953            Route::Deny => return Self::deny(None),
2954        };
2955        let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2956        if frontend.capture_cap_host > 0 {
2957            captures_host.push(from_utf8(domain).unwrap_or_default());
2958            for submatch in domain_submatches.iter().rev() {
2959                match submatch {
2960                    TrieSubMatch::Wildcard(part) => {
2961                        captures_host.push(from_utf8(part).unwrap_or_default());
2962                    }
2963                    TrieSubMatch::Regexp(part, regex) => {
2964                        if let Some(caps) = regex.captures(part) {
2965                            captures_host.extend(caps.iter().skip(1).map(|c| {
2966                                c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2967                                    .unwrap_or("")
2968                            }));
2969                        }
2970                    }
2971                }
2972            }
2973        }
2974        Self::from_frontend(&frontend, captures_host, path, path_rule)
2975    }
2976}
2977
2978#[cfg(test)]
2979mod tests {
2980    use super::*;
2981    use quickcheck::{Arbitrary, Gen, TestResult, quickcheck};
2982
2983    /// A frontend's `method` is classified by the same case-sensitive
2984    /// [`Method::new`] as the request's (sozu-proxy/sozu#1451), so the
2985    /// operator-facing rule is "declare the method in the exact case the
2986    /// client sends". A frontend declared `method = "get"` is a custom-method
2987    /// rule: it no longer answers a canonical `GET` request, and it answers a
2988    /// literal `get` one instead.
2989    #[test]
2990    fn method_rule_is_case_sensitive() {
2991        let declared_lowercase = MethodRule::new(Some("get".to_owned()));
2992
2993        assert_eq!(
2994            declared_lowercase.inner,
2995            Some(Method::Custom("get".to_owned())),
2996            "a lowercase declaration must not collapse onto Method::Get"
2997        );
2998        assert!(
2999            declared_lowercase.matches(&Method::Get) == MethodRuleResult::None,
3000            "a lowercase declaration must not match a canonical GET request"
3001        );
3002        assert!(
3003            declared_lowercase.matches(&Method::new(b"get")) == MethodRuleResult::Equals,
3004            "a lowercase declaration must match a literal lowercase request"
3005        );
3006
3007        let declared_canonical = MethodRule::new(Some("GET".to_owned()));
3008
3009        assert!(
3010            declared_canonical.matches(&Method::Get) == MethodRuleResult::Equals,
3011            "a canonical declaration must still match a canonical GET request"
3012        );
3013        assert!(
3014            declared_canonical.matches(&Method::new(b"get")) == MethodRuleResult::None,
3015            "a canonical declaration must not match a lowercase request"
3016        );
3017    }
3018
3019    fn test_http_frontend() -> HttpFrontend {
3020        HttpFrontend {
3021            cluster_id: Some("cluster".into()),
3022            address: "127.0.0.1:8080"
3023                .parse()
3024                .expect("test frontend address must parse"),
3025            hostname: "example.com".to_owned(),
3026            path: CommandPathRule::prefix("/".to_owned()),
3027            method: None,
3028            position: RulePosition::Tree,
3029            tags: None,
3030            redirect: None,
3031            redirect_scheme: None,
3032            redirect_template: None,
3033            rewrite_host: None,
3034            rewrite_path: None,
3035            rewrite_port: None,
3036            required_auth: None,
3037            headers: Vec::new(),
3038            hsts: None,
3039            header_predicates: Vec::new(),
3040        }
3041    }
3042
3043    #[test]
3044    fn clusterless_forward_warning_redacts_domain_and_path_rules() {
3045        const DOMAIN_SECRET: &str = "clusterless_domain_secret_sentinel";
3046        const PATH_SECRET: &str = "CLUSTERLESS_PATH_SECRET_SENTINEL";
3047
3048        // The hostname stays below `MAX_HOSTNAME_LENGTH` so it reaches the
3049        // code under test instead of the pre-parse bound; the redaction
3050        // property being asserted is length-independent.
3051        let domain = format!("{DOMAIN_SECRET}{}", "x".repeat(2048));
3052        let path = format!("/{PATH_SECRET}{}", "x".repeat(4096));
3053        let domain_len = domain.len();
3054        let path_len = path.len();
3055        let output = crate::capture_test_logs(move || {
3056            let mut router = Router::new();
3057            let mut front = test_http_frontend();
3058            front.cluster_id = None;
3059            front.hostname = domain;
3060            front.path = CommandPathRule::prefix(path);
3061            front.redirect = Some(RedirectPolicy::Forward as i32);
3062            router
3063                .add_http_front(&front)
3064                .expect("clusterless forward must be coerced to unauthorized");
3065        });
3066
3067        for secret in [DOMAIN_SECRET, PATH_SECRET] {
3068            assert!(
3069                !output.contains(secret),
3070                "clusterless-forward warning leaked rule marker {secret}"
3071            );
3072        }
3073        for metadata in [
3074            "domain_kind=exact".to_owned(),
3075            format!("domain_bytes={domain_len}"),
3076            "path_kind=prefix".to_owned(),
3077            format!("path_bytes={path_len}"),
3078        ] {
3079            assert!(
3080                output.contains(&metadata),
3081                "clusterless-forward warning omitted bounded metadata {metadata}: {output}"
3082            );
3083        }
3084        assert!(
3085            output.len() <= 512,
3086            "clusterless-forward warning is not bounded: {} bytes",
3087            output.len()
3088        );
3089    }
3090
3091    #[test]
3092    fn malformed_hsts_warning_redacts_cluster_id() {
3093        const CLUSTER_SECRET: &str = "MALFORMED_HSTS_CLUSTER_SECRET_SENTINEL";
3094
3095        let cluster_id = format!("{CLUSTER_SECRET}{}", "x".repeat(4096));
3096        let cluster_id_len = cluster_id.len();
3097        let output = crate::capture_test_logs(move || {
3098            let mut router = Router::new();
3099            let mut front = test_http_frontend();
3100            front.cluster_id = Some(cluster_id.into());
3101            front.hsts = Some(HstsConfig {
3102                enabled: Some(true),
3103                max_age: None,
3104                include_subdomains: Some(true),
3105                preload: Some(true),
3106                force_replace_backend: Some(false),
3107            });
3108            router
3109                .add_http_front(&front)
3110                .expect("malformed HSTS must preserve routing and omit the header");
3111        });
3112
3113        assert!(
3114            !output.contains(CLUSTER_SECRET),
3115            "malformed-HSTS warning leaked cluster id {CLUSTER_SECRET}"
3116        );
3117        assert!(
3118            output.contains(&format!("cluster_id_bytes=Some({cluster_id_len})")),
3119            "malformed-HSTS warning omitted the bounded cluster id length: {output}"
3120        );
3121        assert!(
3122            output.len() <= 768,
3123            "malformed-HSTS warning is not bounded: {} bytes",
3124            output.len()
3125        );
3126    }
3127
3128    #[test]
3129    fn router_errors_redact_frontend_rule_and_rewrite_fields() {
3130        const PATH_SECRET: &str = "ROUTER_ERROR_PATH_SECRET_SENTINEL";
3131        const HOSTNAME_SECRET: &str = "ROUTER_ERROR_HOSTNAME_SECRET_SENTINEL";
3132        const HOST_REWRITE_SECRET: &str = "ROUTER_ERROR_HOST_REWRITE_SECRET_SENTINEL";
3133        const PATH_REWRITE_SECRET: &str = "ROUTER_ERROR_PATH_REWRITE_SECRET_SENTINEL";
3134
3135        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3136        let cases = [
3137            (
3138                "path_bytes",
3139                PATH_SECRET,
3140                long_value(PATH_SECRET).len(),
3141                RouterError::InvalidPathRule(long_value(PATH_SECRET)),
3142            ),
3143            (
3144                "hostname_bytes",
3145                HOSTNAME_SECRET,
3146                long_value(HOSTNAME_SECRET).len(),
3147                RouterError::InvalidDomain {
3148                    hostname: long_value(HOSTNAME_SECRET),
3149                },
3150            ),
3151            (
3152                "rewrite_host_bytes",
3153                HOST_REWRITE_SECRET,
3154                long_value(HOST_REWRITE_SECRET).len(),
3155                RouterError::InvalidHostRewrite(long_value(HOST_REWRITE_SECRET)),
3156            ),
3157            (
3158                "rewrite_path_bytes",
3159                PATH_REWRITE_SECRET,
3160                long_value(PATH_REWRITE_SECRET).len(),
3161                RouterError::InvalidPathRewrite(long_value(PATH_REWRITE_SECRET)),
3162            ),
3163        ];
3164
3165        for (length_label, secret, value_len, error) in cases {
3166            for (format_label, output) in [
3167                ("Display", error.to_string()),
3168                ("Debug", format!("{error:?}")),
3169            ] {
3170                assert!(
3171                    !output.contains(secret),
3172                    "RouterError {format_label} leaked frontend marker {secret}"
3173                );
3174                let metadata = format!("{length_label}={value_len}");
3175                assert!(
3176                    output.contains(&metadata),
3177                    "RouterError {format_label} omitted bounded metadata {metadata}: {output}"
3178                );
3179                assert!(
3180                    output.len() <= 256,
3181                    "RouterError {format_label} output is not bounded: {} bytes",
3182                    output.len()
3183                );
3184            }
3185        }
3186    }
3187
3188    #[test]
3189    fn route_miss_error_retains_inputs_but_bounds_textual_formatting() {
3190        const HOST_SECRET: &str = "ROUTE_MISS_HOST_SECRET_SENTINEL";
3191        const PATH_SECRET: &str = "ROUTE_MISS_PATH_SECRET_SENTINEL";
3192        const METHOD_SECRET: &str = "ROUTE_MISS_METHOD_SECRET_SENTINEL";
3193
3194        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3195        let host = long_value(HOST_SECRET);
3196        let path = long_value(PATH_SECRET);
3197        let method = Method::Custom(long_value(METHOD_SECRET));
3198        let error = match Router::new().lookup(&host, &path, &method) {
3199            Err(error) => error,
3200            Ok(_) => panic!("empty router must return a route miss"),
3201        };
3202
3203        match &error {
3204            RouterError::RouteNotFound {
3205                host: retained_host,
3206                path: retained_path,
3207                method: retained_method,
3208            } => {
3209                assert_eq!(retained_host, &host);
3210                assert_eq!(retained_path, &path);
3211                assert_eq!(retained_method, &method);
3212            }
3213            other => panic!("expected RouterError::RouteNotFound, got {other:?}"),
3214        }
3215
3216        for output in [error.to_string(), format!("{error:?}")] {
3217            for secret in [HOST_SECRET, PATH_SECRET, METHOD_SECRET] {
3218                assert!(
3219                    !output.contains(secret),
3220                    "route miss formatting leaked {secret}: {output}"
3221                );
3222            }
3223            for metadata in [
3224                format!("host_bytes={}", host.len()),
3225                format!("path_bytes={}", path.len()),
3226                format!("bytes={}", method.as_ref().len()),
3227            ] {
3228                assert!(
3229                    output.contains(&metadata),
3230                    "route miss formatting omitted {metadata}: {output}"
3231                );
3232            }
3233            assert!(
3234                output.len() <= 256,
3235                "route miss formatting is not bounded: {} bytes",
3236                output.len()
3237            );
3238        }
3239    }
3240
3241    #[test]
3242    fn render_hsts_max_age_only() {
3243        let cfg = HstsConfig {
3244            enabled: Some(true),
3245            max_age: Some(31_536_000),
3246            include_subdomains: None,
3247            preload: None,
3248            force_replace_backend: None,
3249        };
3250        assert_eq!(render_hsts(&cfg), Some("max-age=31536000".to_owned()));
3251    }
3252
3253    #[test]
3254    fn render_hsts_with_include_subdomains() {
3255        let cfg = HstsConfig {
3256            enabled: Some(true),
3257            max_age: Some(31_536_000),
3258            include_subdomains: Some(true),
3259            preload: None,
3260            force_replace_backend: None,
3261        };
3262        assert_eq!(
3263            render_hsts(&cfg),
3264            Some("max-age=31536000; includeSubDomains".to_owned())
3265        );
3266    }
3267
3268    #[test]
3269    fn render_hsts_with_preload_only() {
3270        let cfg = HstsConfig {
3271            enabled: Some(true),
3272            max_age: Some(63_072_000),
3273            include_subdomains: None,
3274            preload: Some(true),
3275            force_replace_backend: None,
3276        };
3277        assert_eq!(
3278            render_hsts(&cfg),
3279            Some("max-age=63072000; preload".to_owned())
3280        );
3281    }
3282
3283    #[test]
3284    fn render_hsts_full() {
3285        let cfg = HstsConfig {
3286            enabled: Some(true),
3287            max_age: Some(31_536_000),
3288            include_subdomains: Some(true),
3289            preload: Some(true),
3290            force_replace_backend: None,
3291        };
3292        assert_eq!(
3293            render_hsts(&cfg),
3294            Some("max-age=31536000; includeSubDomains; preload".to_owned())
3295        );
3296    }
3297
3298    #[test]
3299    fn render_hsts_kill_switch_max_age_zero() {
3300        let cfg = HstsConfig {
3301            enabled: Some(true),
3302            max_age: Some(0),
3303            include_subdomains: Some(true),
3304            preload: None,
3305            force_replace_backend: None,
3306        };
3307        // `max_age = 0` is the RFC 6797 §11.4 kill switch and renders
3308        // verbatim — UA receives it and stops treating the host as a
3309        // Known HSTS Host.
3310        assert_eq!(
3311            render_hsts(&cfg),
3312            Some("max-age=0; includeSubDomains".to_owned())
3313        );
3314    }
3315
3316    #[test]
3317    fn render_hsts_omitted_when_max_age_missing() {
3318        let cfg = HstsConfig {
3319            enabled: Some(true),
3320            max_age: None,
3321            include_subdomains: Some(true),
3322            preload: None,
3323            force_replace_backend: None,
3324        };
3325        // The TOML loader substitutes the default at config-load; if the
3326        // field reaches `render_hsts` as `None`, suppress emission so a
3327        // malformed wire frame can't accidentally render `max-age=`.
3328        assert_eq!(render_hsts(&cfg), None);
3329    }
3330
3331    #[test]
3332    fn rebuild_with_listener_hsts_replaces_existing_entry() {
3333        // An inheriting frontend whose listener-default HSTS changed
3334        // from 1y → 2y must end up with the 2y entry on its
3335        // headers_response, with no leftover 1y entry.
3336        let frontend = Frontend {
3337            cluster_id: Some("api".into()),
3338            redirect: RedirectPolicy::Forward,
3339            redirect_scheme: RedirectScheme::UseSame,
3340            redirect_template: None,
3341            capture_cap_host: 0,
3342            capture_cap_path: 0,
3343            rewrite_host: None,
3344            rewrite_path: None,
3345            rewrite_port: None,
3346            headers_request: Rc::new([]),
3347            headers_response: Rc::from(vec![
3348                HeaderEdit {
3349                    key: Rc::from(&b"x-cache"[..]),
3350                    val: Rc::from(&b"hit"[..]),
3351                    mode: HeaderEditMode::Append,
3352                },
3353                HeaderEdit {
3354                    key: Rc::from(&b"strict-transport-security"[..]),
3355                    val: Rc::from(&b"max-age=31536000"[..]),
3356                    mode: HeaderEditMode::SetIfAbsent,
3357                },
3358            ]),
3359            required_auth: false,
3360            tags: None,
3361            inherits_listener_hsts: true,
3362        };
3363        let new_hsts = HstsConfig {
3364            enabled: Some(true),
3365            max_age: Some(63_072_000),
3366            include_subdomains: Some(true),
3367            preload: None,
3368            force_replace_backend: None,
3369        };
3370        let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3371        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3372
3373        let response: Vec<_> = rebuilt.headers_response.iter().collect();
3374        assert_eq!(response.len(), 2, "x-cache + new STS, no leftover STS");
3375        assert_eq!(&*response[0].key, b"x-cache");
3376        assert_eq!(&*response[1].key, b"strict-transport-security");
3377        assert_eq!(
3378            &*response[1].val,
3379            b"max-age=63072000; includeSubDomains".as_slice()
3380        );
3381        assert!(rebuilt.inherits_listener_hsts);
3382    }
3383
3384    #[test]
3385    fn rebuild_with_listener_hsts_strips_when_none() {
3386        // Listener-default HSTS removed → strip the existing STS edit
3387        // and add nothing. Operator response headers stay in place.
3388        let frontend = Frontend {
3389            cluster_id: Some("api".into()),
3390            redirect: RedirectPolicy::Forward,
3391            redirect_scheme: RedirectScheme::UseSame,
3392            redirect_template: None,
3393            capture_cap_host: 0,
3394            capture_cap_path: 0,
3395            rewrite_host: None,
3396            rewrite_path: None,
3397            rewrite_port: None,
3398            headers_request: Rc::new([]),
3399            headers_response: Rc::from(vec![
3400                HeaderEdit {
3401                    key: Rc::from(&b"x-cache"[..]),
3402                    val: Rc::from(&b"hit"[..]),
3403                    mode: HeaderEditMode::Append,
3404                },
3405                HeaderEdit {
3406                    key: Rc::from(&b"strict-transport-security"[..]),
3407                    val: Rc::from(&b"max-age=31536000"[..]),
3408                    mode: HeaderEditMode::SetIfAbsent,
3409                },
3410            ]),
3411            required_auth: false,
3412            tags: None,
3413            inherits_listener_hsts: true,
3414        };
3415        let new_edit = build_listener_hsts_edit(None);
3416        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3417        let response: Vec<_> = rebuilt.headers_response.iter().collect();
3418        assert_eq!(response.len(), 1);
3419        assert_eq!(&*response[0].key, b"x-cache");
3420    }
3421
3422    #[test]
3423    fn rebuild_with_listener_hsts_disabled_strips() {
3424        // `enabled = Some(false)` is the explicit-disable signal; the
3425        // existing STS entry is dropped and no new one is added.
3426        let frontend = Frontend {
3427            cluster_id: Some("api".into()),
3428            redirect: RedirectPolicy::Forward,
3429            redirect_scheme: RedirectScheme::UseSame,
3430            redirect_template: None,
3431            capture_cap_host: 0,
3432            capture_cap_path: 0,
3433            rewrite_host: None,
3434            rewrite_path: None,
3435            rewrite_port: None,
3436            headers_request: Rc::new([]),
3437            headers_response: Rc::from(vec![HeaderEdit {
3438                key: Rc::from(&b"strict-transport-security"[..]),
3439                val: Rc::from(&b"max-age=31536000"[..]),
3440                mode: HeaderEditMode::SetIfAbsent,
3441            }]),
3442            required_auth: false,
3443            tags: None,
3444            inherits_listener_hsts: true,
3445        };
3446        let new_hsts = HstsConfig {
3447            enabled: Some(false),
3448            max_age: None,
3449            include_subdomains: None,
3450            preload: None,
3451            force_replace_backend: None,
3452        };
3453        let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3454        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3455        assert_eq!(rebuilt.headers_response.len(), 0);
3456    }
3457
3458    #[test]
3459    fn refresh_inheriting_hsts_skips_explicit_overrides() {
3460        // Two frontends: one inheriting (gets refreshed), one explicit
3461        // override (must NOT change). `Router::refresh_inheriting_hsts`
3462        // returns the count of refreshed entries.
3463        use crate::router::pattern_trie::TrieNode;
3464        let mut router = Router {
3465            pre: Vec::new(),
3466            tree: TrieNode::root(),
3467            post: Vec::new(),
3468        };
3469        let inheriting = Frontend {
3470            cluster_id: Some("api".into()),
3471            redirect: RedirectPolicy::Forward,
3472            redirect_scheme: RedirectScheme::UseSame,
3473            redirect_template: None,
3474            capture_cap_host: 0,
3475            capture_cap_path: 0,
3476            rewrite_host: None,
3477            rewrite_path: None,
3478            rewrite_port: None,
3479            headers_request: Rc::new([]),
3480            headers_response: Rc::from(vec![HeaderEdit {
3481                key: Rc::from(&b"strict-transport-security"[..]),
3482                val: Rc::from(&b"max-age=31536000"[..]),
3483                mode: HeaderEditMode::SetIfAbsent,
3484            }]),
3485            required_auth: false,
3486            tags: None,
3487            inherits_listener_hsts: true,
3488        };
3489        let explicit = Frontend {
3490            cluster_id: Some("legacy".into()),
3491            redirect: RedirectPolicy::Forward,
3492            redirect_scheme: RedirectScheme::UseSame,
3493            redirect_template: None,
3494            capture_cap_host: 0,
3495            capture_cap_path: 0,
3496            rewrite_host: None,
3497            rewrite_path: None,
3498            rewrite_port: None,
3499            headers_request: Rc::new([]),
3500            headers_response: Rc::from(vec![HeaderEdit {
3501                key: Rc::from(&b"strict-transport-security"[..]),
3502                val: Rc::from(&b"max-age=300"[..]),
3503                mode: HeaderEditMode::SetIfAbsent,
3504            }]),
3505            required_auth: false,
3506            tags: None,
3507            inherits_listener_hsts: false,
3508        };
3509        router.pre.push((
3510            DomainRule::Any,
3511            PathRule::Prefix("/api".to_owned()),
3512            MethodRule::new(None),
3513            HeaderRules::default(),
3514            Route::Frontend(Rc::new(inheriting)),
3515        ));
3516        router.post.push((
3517            DomainRule::Any,
3518            PathRule::Prefix("/legacy".to_owned()),
3519            MethodRule::new(None),
3520            HeaderRules::default(),
3521            Route::Frontend(Rc::new(explicit)),
3522        ));
3523
3524        let new_hsts = HstsConfig {
3525            enabled: Some(true),
3526            max_age: Some(63_072_000),
3527            include_subdomains: Some(true),
3528            preload: None,
3529            force_replace_backend: None,
3530        };
3531        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3532        assert_eq!(count, 1, "only the inheriting frontend should refresh");
3533
3534        if let Route::Frontend(rc) = &router.pre[0].4 {
3535            let response: Vec<_> = rc.headers_response.iter().collect();
3536            assert_eq!(
3537                &*response.last().unwrap().val,
3538                b"max-age=63072000; includeSubDomains".as_slice(),
3539                "inheriting frontend's STS must reflect the new listener default"
3540            );
3541        } else {
3542            panic!("pre[0] should be Route::Frontend");
3543        }
3544        if let Route::Frontend(rc) = &router.post[0].4 {
3545            let response: Vec<_> = rc.headers_response.iter().collect();
3546            assert_eq!(
3547                &*response.last().unwrap().val,
3548                b"max-age=300".as_slice(),
3549                "explicit override must be preserved unchanged"
3550            );
3551        } else {
3552            panic!("post[0] should be Route::Frontend");
3553        }
3554    }
3555
3556    #[test]
3557    fn refresh_inheriting_hsts_promotes_clusterid_on_enable() {
3558        // The "no policy" frontend case observed on cleverapps.io shared
3559        // (91k+ frontends, 99 % stored as `Route::ClusterId` with no
3560        // policy fields). Before the fix, `refresh_inheriting_hsts`
3561        // walked only `Route::Frontend` entries and silently skipped
3562        // these — leaving HSTS unapplied across the entire fleet.
3563        // Now the lightweight route is promoted in place to a
3564        // `Route::Frontend` carrying just the HSTS edit; subsequent
3565        // patches refresh the promoted entry through the normal
3566        // `inherits_listener_hsts == true` path.
3567        use crate::router::pattern_trie::TrieNode;
3568        let mut router = Router {
3569            pre: Vec::new(),
3570            tree: TrieNode::root(),
3571            post: vec![(
3572                DomainRule::Any,
3573                PathRule::Prefix("/".to_owned()),
3574                MethodRule::new(None),
3575                HeaderRules::default(),
3576                Route::ClusterId("api".into()),
3577            )],
3578        };
3579
3580        let new_hsts = HstsConfig {
3581            enabled: Some(true),
3582            max_age: Some(31_536_000),
3583            include_subdomains: Some(true),
3584            preload: None,
3585            force_replace_backend: None,
3586        };
3587        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3588        assert_eq!(count, 1, "the ClusterId entry must be promoted + counted");
3589
3590        let Route::Frontend(rc) = &router.post[0].4 else {
3591            panic!("post[0] should now be Route::Frontend, not the original Route::ClusterId");
3592        };
3593        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3594        assert_eq!(
3595            rc.redirect,
3596            RedirectPolicy::Forward,
3597            "promoted entry must keep Forward semantics so lookup yields the same backend"
3598        );
3599        assert!(
3600            rc.inherits_listener_hsts,
3601            "promoted entry must mark itself inheriting so the next patch refreshes it"
3602        );
3603        let response: Vec<_> = rc.headers_response.iter().collect();
3604        assert_eq!(
3605            response.len(),
3606            1,
3607            "promoted entry carries exactly one STS edit, no operator headers"
3608        );
3609        assert_eq!(&*response[0].key, b"strict-transport-security");
3610        assert_eq!(
3611            &*response[0].val,
3612            b"max-age=31536000; includeSubDomains".as_slice()
3613        );
3614    }
3615
3616    #[test]
3617    fn refresh_inheriting_hsts_promotes_deny_on_enable() {
3618        // RFC 6797 §8.1: HSTS applies to ALL HTTPS responses, including
3619        // proxy-generated 401s. A `Route::Deny` with no policy field at
3620        // add-time would, before the fix, never get HSTS injected onto
3621        // the 401 default answer even when the listener default
3622        // declared HSTS.
3623        use crate::router::pattern_trie::TrieNode;
3624        let mut router = Router {
3625            pre: Vec::new(),
3626            tree: TrieNode::root(),
3627            post: vec![(
3628                DomainRule::Any,
3629                PathRule::Prefix("/forbidden".to_owned()),
3630                MethodRule::new(None),
3631                HeaderRules::default(),
3632                Route::Deny,
3633            )],
3634        };
3635
3636        let new_hsts = HstsConfig {
3637            enabled: Some(true),
3638            max_age: Some(31_536_000),
3639            include_subdomains: None,
3640            preload: None,
3641            force_replace_backend: None,
3642        };
3643        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3644        assert_eq!(count, 1);
3645
3646        let Route::Frontend(rc) = &router.post[0].4 else {
3647            panic!("post[0] should now be Route::Frontend, not the original Route::Deny");
3648        };
3649        assert_eq!(rc.cluster_id, None, "promoted Deny stays clusterless");
3650        assert_eq!(
3651            rc.redirect,
3652            RedirectPolicy::Unauthorized,
3653            "promoted Deny must keep Unauthorized so lookup yields a 401"
3654        );
3655        assert!(rc.inherits_listener_hsts);
3656        let response: Vec<_> = rc.headers_response.iter().collect();
3657        assert_eq!(response.len(), 1);
3658        assert_eq!(&*response[0].key, b"strict-transport-security");
3659        assert_eq!(&*response[0].val, b"max-age=31536000".as_slice());
3660    }
3661
3662    #[test]
3663    fn refresh_inheriting_hsts_skips_lightweight_on_disable() {
3664        // No HSTS to emit → no allocation of a Route::Frontend just to
3665        // hold an empty headers_response. The lightweight route is
3666        // preserved as-is. Three sub-cases cover the disable surface:
3667        //   - new_hsts == None (operator omitted the field — preserve current… but
3668        //     this function is called only when the patch DID carry a value, so
3669        //     None here represents "block was not present"; lightweight stays).
3670        //   - new_hsts == Some(enabled = Some(false)) (explicit kill switch).
3671        //   - new_hsts == Some(enabled = Some(true), max_age = None) (malformed
3672        //     enable — render_hsts returns None, defense-in-depth gate).
3673        use crate::router::pattern_trie::TrieNode;
3674        let make_router = || Router {
3675            pre: vec![(
3676                DomainRule::Any,
3677                PathRule::Prefix("/".to_owned()),
3678                MethodRule::new(None),
3679                HeaderRules::default(),
3680                Route::ClusterId("api".into()),
3681            )],
3682            tree: TrieNode::root(),
3683            post: vec![(
3684                DomainRule::Any,
3685                PathRule::Prefix("/forbidden".to_owned()),
3686                MethodRule::new(None),
3687                HeaderRules::default(),
3688                Route::Deny,
3689            )],
3690        };
3691
3692        for (label, hsts) in [
3693            ("none", None),
3694            (
3695                "disabled",
3696                Some(HstsConfig {
3697                    enabled: Some(false),
3698                    max_age: None,
3699                    include_subdomains: None,
3700                    preload: None,
3701                    force_replace_backend: None,
3702                }),
3703            ),
3704            (
3705                "enabled-without-max-age",
3706                Some(HstsConfig {
3707                    enabled: Some(true),
3708                    max_age: None,
3709                    include_subdomains: None,
3710                    preload: None,
3711                    force_replace_backend: None,
3712                }),
3713            ),
3714        ] {
3715            let mut router = make_router();
3716            let count = router.refresh_inheriting_hsts(hsts.as_ref());
3717            assert_eq!(count, 0, "no promotion expected for {label}");
3718            assert!(
3719                matches!(router.pre[0].4, Route::ClusterId(_)),
3720                "{label}: ClusterId must stay lightweight"
3721            );
3722            assert!(
3723                matches!(router.post[0].4, Route::Deny),
3724                "{label}: Deny must stay lightweight"
3725            );
3726        }
3727    }
3728
3729    #[test]
3730    fn refresh_inheriting_hsts_promoted_entry_refreshes_on_subsequent_patches() {
3731        // First patch promotes ClusterId → Route::Frontend with HSTS.
3732        // Second patch with a different max-age must refresh the
3733        // promoted entry through the normal path-1 branch (no double
3734        // STS edit, no second promotion of an already-promoted entry).
3735        use crate::router::pattern_trie::TrieNode;
3736        let mut router = Router {
3737            pre: Vec::new(),
3738            tree: TrieNode::root(),
3739            post: vec![(
3740                DomainRule::Any,
3741                PathRule::Prefix("/".to_owned()),
3742                MethodRule::new(None),
3743                HeaderRules::default(),
3744                Route::ClusterId("api".into()),
3745            )],
3746        };
3747
3748        let first_patch = HstsConfig {
3749            enabled: Some(true),
3750            max_age: Some(31_536_000),
3751            include_subdomains: None,
3752            preload: None,
3753            force_replace_backend: None,
3754        };
3755        assert_eq!(router.refresh_inheriting_hsts(Some(&first_patch)), 1);
3756
3757        let second_patch = HstsConfig {
3758            enabled: Some(true),
3759            max_age: Some(63_072_000),
3760            include_subdomains: Some(true),
3761            preload: None,
3762            force_replace_backend: None,
3763        };
3764        assert_eq!(
3765            router.refresh_inheriting_hsts(Some(&second_patch)),
3766            1,
3767            "the previously promoted entry must be re-counted via the path-1 branch"
3768        );
3769
3770        let Route::Frontend(rc) = &router.post[0].4 else {
3771            panic!("post[0] should still be Route::Frontend after the second patch");
3772        };
3773        let response: Vec<_> = rc.headers_response.iter().collect();
3774        assert_eq!(
3775            response.len(),
3776            1,
3777            "second patch must REPLACE the existing STS edit, not append a duplicate"
3778        );
3779        assert_eq!(
3780            &*response[0].val,
3781            b"max-age=63072000; includeSubDomains".as_slice()
3782        );
3783    }
3784
3785    #[test]
3786    fn refresh_inheriting_hsts_promoted_entry_loses_hsts_on_disable_patch() {
3787        // After a promotion, a disable patch must strip the STS edit
3788        // through the path-1 branch. The Route::Frontend wrapper stays
3789        // (we don't demote back to Route::ClusterId — the small per-
3790        // request overhead of running through `from_frontend` instead
3791        // of the short-circuit is acceptable, and demotion would
3792        // require carrying additional state).
3793        use crate::router::pattern_trie::TrieNode;
3794        let mut router = Router {
3795            pre: vec![(
3796                DomainRule::Any,
3797                PathRule::Prefix("/".to_owned()),
3798                MethodRule::new(None),
3799                HeaderRules::default(),
3800                Route::ClusterId("api".into()),
3801            )],
3802            tree: TrieNode::root(),
3803            post: Vec::new(),
3804        };
3805
3806        let enable = HstsConfig {
3807            enabled: Some(true),
3808            max_age: Some(31_536_000),
3809            include_subdomains: None,
3810            preload: None,
3811            force_replace_backend: None,
3812        };
3813        assert_eq!(router.refresh_inheriting_hsts(Some(&enable)), 1);
3814
3815        let disable = HstsConfig {
3816            enabled: Some(false),
3817            max_age: None,
3818            include_subdomains: None,
3819            preload: None,
3820            force_replace_backend: None,
3821        };
3822        assert_eq!(
3823            router.refresh_inheriting_hsts(Some(&disable)),
3824            1,
3825            "the promoted entry must still be touched on disable to strip its STS edit"
3826        );
3827
3828        let Route::Frontend(rc) = &router.pre[0].4 else {
3829            panic!("pre[0] should still be Route::Frontend (no demotion)");
3830        };
3831        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3832        assert_eq!(
3833            rc.headers_response.len(),
3834            0,
3835            "disable patch must strip the STS edit from the promoted entry"
3836        );
3837    }
3838
3839    #[test]
3840    fn refresh_inheriting_hsts_promotes_clusterid_in_trie_on_enable() {
3841        // Trie-leaf coverage for path 2: the existing five tests
3842        // exercise the `pre`/`post` Vecs only, but the same `visit`
3843        // closure runs for tree leaves through
3844        // `tree.for_each_value_mut`. Assert that a `Route::ClusterId`
3845        // sitting in the trie is promoted in place on an enable
3846        // patch, with routing semantics preserved.
3847        use crate::router::pattern_trie::TrieNode;
3848        let mut router = Router {
3849            pre: Vec::new(),
3850            tree: TrieNode::root(),
3851            post: Vec::new(),
3852        };
3853        let path_rule = PathRule::Prefix("/".to_owned());
3854        let method_rule = MethodRule::new(None);
3855        assert!(router.add_tree_rule(
3856            b"example.com",
3857            &path_rule,
3858            &method_rule,
3859            &Route::ClusterId("api".into()),
3860        ));
3861
3862        let new_hsts = HstsConfig {
3863            enabled: Some(true),
3864            max_age: Some(31_536_000),
3865            include_subdomains: Some(true),
3866            preload: None,
3867            force_replace_backend: None,
3868        };
3869        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3870        assert_eq!(
3871            count, 1,
3872            "trie-resident ClusterId must be promoted + counted"
3873        );
3874
3875        let (_, paths) = router
3876            .tree
3877            .domain_lookup_mut(b"example.com", false)
3878            .expect("trie leaf still present after refresh");
3879        assert_eq!(paths.len(), 1);
3880        let Route::Frontend(rc) = &paths[0].3 else {
3881            panic!("trie leaf should now be Route::Frontend, not Route::ClusterId");
3882        };
3883        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3884        assert_eq!(rc.redirect, RedirectPolicy::Forward);
3885        assert!(rc.inherits_listener_hsts);
3886        let response: Vec<_> = rc.headers_response.iter().collect();
3887        assert_eq!(response.len(), 1);
3888        assert_eq!(&*response[0].key, b"strict-transport-security");
3889        assert_eq!(
3890            &*response[0].val,
3891            b"max-age=31536000; includeSubDomains".as_slice()
3892        );
3893    }
3894
3895    #[test]
3896    fn convert_regex() {
3897        // Compiled regexes are anchored with `\A` … `\z` so `Regex::is_match`
3898        // (unanchored by default) only succeeds on a full-host match, and each
3899        // slash-delimited regex segment is wrapped in a non-capturing group so
3900        // an alternation inside it cannot split those anchors between its
3901        // branches (sozu#1356). A literal segment is not a pattern and is not
3902        // grouped.
3903        assert_eq!(
3904            convert_regex_domain_rule("www.example.com")
3905                .unwrap()
3906                .as_str(),
3907            "\\Awww\\.example\\.com\\z"
3908        );
3909        // A `*` label reaches this function only when the hostname ALSO
3910        // carries a `/` segment, because `DomainRule::from_str` tests
3911        // `contains('/')` first — these two rows exercise the function
3912        // directly. `*` is a LITERAL label here, not a wildcard, and is
3913        // escaped as one. Unescaped it was a regex quantifier over the
3914        // preceding atom: `\A*` repeats a zero-width assertion, so it
3915        // matched empty at any offset and left the whole pattern unanchored
3916        // at its start.
3917        assert_eq!(
3918            convert_regex_domain_rule("*.example.com").unwrap().as_str(),
3919            "\\A\\*\\.example\\.com\\z"
3920        );
3921        assert_eq!(
3922            convert_regex_domain_rule("test.*.example.com")
3923                .unwrap()
3924                .as_str(),
3925            "\\Atest\\.\\*\\.example\\.com\\z"
3926        );
3927        assert_eq!(
3928            convert_regex_domain_rule("css./cdn[a-z0-9]+/.example.com")
3929                .unwrap()
3930                .as_str(),
3931            "\\Acss\\.(?:cdn[a-z0-9]+)\\.example\\.com\\z"
3932        );
3933
3934        assert_eq!(
3935            convert_regex_domain_rule("css./cdn[a-z0-9]+.example.com"),
3936            None
3937        );
3938        assert_eq!(
3939            convert_regex_domain_rule("css./cdn[a-z0-9]+/a.example.com"),
3940            None
3941        );
3942    }
3943
3944    /// Compiled regex rules must reject suffix / prefix matches. Without
3945    /// `\A` / `\z` anchors, `Regex::is_match` treats the pattern as
3946    /// "match anywhere in the haystack", letting `attacker.example.com.evil.org`
3947    /// reach a frontend that only intends to serve `example.com`.
3948    #[test]
3949    fn regex_domain_rule_rejects_suffix_and_prefix() {
3950        let rule: DomainRule = "/example\\.com/".parse().unwrap();
3951        assert!(rule.matches(b"example.com"));
3952        assert!(!rule.matches(b"attacker.example.com"));
3953        assert!(!rule.matches(b"example.com.evil.org"));
3954        assert!(!rule.matches(b"prefixexample.com"));
3955        assert!(!rule.matches(b"example.commercial"));
3956    }
3957
3958    /// A multi-segment regex hostname (alternating regex and literal
3959    /// subdomains) must keep each segment confined: only the first `/`
3960    /// after the opening delimiter closes a regex segment. A missing
3961    /// `break` collapsed every later `/` into the same segment, swallowing
3962    /// the literal `.` separators between them.
3963    #[test]
3964    fn regex_domain_rule_multi_segment_segments_are_isolated() {
3965        let pattern = convert_regex_domain_rule("/seg1/.foo./seg2/.com")
3966            .expect("multi-segment regex hostname must compile");
3967        assert_eq!(pattern.as_str(), "\\A(?:seg1)\\.foo\\.(?:seg2)\\.com\\z");
3968    }
3969
3970    #[test]
3971    fn parse_domain_rule() {
3972        assert_eq!("*".parse::<DomainRule>().unwrap(), DomainRule::Any);
3973        assert_eq!(
3974            "www.example.com".parse::<DomainRule>().unwrap(),
3975            DomainRule::Exact("www.example.com".to_string())
3976        );
3977        assert_eq!(
3978            "*.example.com".parse::<DomainRule>().unwrap(),
3979            DomainRule::Wildcard("*.example.com".to_string())
3980        );
3981        assert_eq!("test.*.example.com".parse::<DomainRule>(), Err(()));
3982        assert_eq!(
3983            "/cdn[0-9]+/.example.com".parse::<DomainRule>().unwrap(),
3984            DomainRule::Regex(Regex::new("\\A(?:cdn[0-9]+)\\.example\\.com\\z").unwrap())
3985        );
3986    }
3987
3988    #[test]
3989    fn match_domain_rule() {
3990        assert!(DomainRule::Any.matches("www.example.com".as_bytes()));
3991        assert!(
3992            DomainRule::Exact("www.example.com".to_string()).matches("www.example.com".as_bytes())
3993        );
3994        assert!(
3995            DomainRule::Wildcard("*.example.com".to_string()).matches("www.example.com".as_bytes())
3996        );
3997        assert!(
3998            !DomainRule::Wildcard("*.example.com".to_string())
3999                .matches("test.www.example.com".as_bytes())
4000        );
4001        assert!(
4002            "/cdn[0-9]+/.example.com"
4003                .parse::<DomainRule>()
4004                .unwrap()
4005                .matches("cdn1.example.com".as_bytes())
4006        );
4007        assert!(
4008            !"/cdn[0-9]+/.example.com"
4009                .parse::<DomainRule>()
4010                .unwrap()
4011                .matches("www.example.com".as_bytes())
4012        );
4013        assert!(
4014            !"/cdn[0-9]+/.example.com"
4015                .parse::<DomainRule>()
4016                .unwrap()
4017                .matches("cdn10.exampleAcom".as_bytes())
4018        );
4019    }
4020
4021    #[test]
4022    fn match_domain_rule_wildcard_short_hostname_does_not_panic() {
4023        let rule = DomainRule::Wildcard("*.foo.example.com".to_string());
4024
4025        // Regression for issue #1223: an empty hostname must not panic and must not match.
4026        assert!(!rule.matches(b""));
4027
4028        // Hostname strictly shorter than the suffix must not panic and must not match.
4029        assert!(!rule.matches(b"a.b"));
4030        assert!(!rule.matches(b"x"));
4031
4032        // Boundary: hostname equal to the suffix (s without the leading '*') has an empty
4033        // leftmost label. RFC 1035 §3.1 forbids empty labels, so reject.
4034        assert!(!rule.matches(b".foo.example.com"));
4035
4036        // Multi-label leftmost prefix (existing intent — single-label only).
4037        assert!(!rule.matches(b"y.x.foo.example.com"));
4038
4039        // Single-label leftmost prefix — must still match (this is the happy case the
4040        // pre-existing match_domain_rule already covers, repeated here for symmetry).
4041        assert!(rule.matches(b"x.foo.example.com"));
4042    }
4043
4044    #[test]
4045    fn router_lookup_wildcard_pre_rule_short_hostname_does_not_panic() {
4046        let mut router = Router::new();
4047
4048        // Wildcard in a pre-rule routes through DomainRule::Wildcard::matches
4049        // (not the trie). This is the path that panicked on issue #1223.
4050        assert!(router.add_pre_rule(
4051            &"*.foo.example.com".parse::<DomainRule>().unwrap(),
4052            &PathRule::Prefix("/".to_string()),
4053            &MethodRule::new(Some("GET".to_string())),
4054            &Route::ClusterId("wildcard".into()),
4055        ));
4056
4057        let method = Method::new(&b"GET"[..]);
4058
4059        // Issue #1223: short hostnames must not panic Router::lookup.
4060        assert!(router.lookup("", "/", &method).is_err());
4061        assert!(router.lookup("x", "/", &method).is_err());
4062        assert!(router.lookup("a.b", "/", &method).is_err());
4063
4064        // Boundary: hostname equal to the suffix has an empty leftmost label.
4065        assert!(router.lookup(".foo.example.com", "/", &method).is_err());
4066
4067        // Happy case: single-label leftmost matches via the pre-rule path.
4068        assert_eq!(
4069            router.lookup("x.foo.example.com", "/", &method),
4070            Ok(RouteResult::forward("wildcard".into()))
4071        );
4072    }
4073
4074    #[test]
4075    fn match_path_rule() {
4076        assert!(PathRule::Prefix("".to_string()).matches("/".as_bytes()) != PathRuleResult::None);
4077        assert!(
4078            PathRule::Prefix("".to_string()).matches("/hello".as_bytes()) != PathRuleResult::None
4079        );
4080        assert!(
4081            PathRule::Prefix("/hello".to_string()).matches("/hello".as_bytes())
4082                != PathRuleResult::None
4083        );
4084        assert!(
4085            PathRule::Prefix("/hello".to_string()).matches("/hello/world".as_bytes())
4086                != PathRuleResult::None
4087        );
4088        assert!(
4089            PathRule::Prefix("/hello".to_string()).matches("/".as_bytes()) == PathRuleResult::None
4090        );
4091    }
4092
4093    ///  [io]
4094    ///      \
4095    ///       [sozu]
4096    ///             \
4097    ///              [*]  <- this wildcard has multiple children
4098    ///             /   \
4099    ///         (base) (api)
4100    #[test]
4101    fn multiple_children_on_a_wildcard() {
4102        let mut router = Router::new();
4103
4104        assert!(router.add_tree_rule(
4105            b"*.sozu.io",
4106            &PathRule::Prefix("".to_string()),
4107            &MethodRule::new(Some("GET".to_string())),
4108            &Route::ClusterId("base".into())
4109        ));
4110        println!("{:#?}", router.tree);
4111        assert_eq!(
4112            router.lookup("www.sozu.io", "/api", &Method::Get),
4113            Ok(RouteResult::forward("base".into()))
4114        );
4115        assert!(router.add_tree_rule(
4116            b"*.sozu.io",
4117            &PathRule::Prefix("/api".to_string()),
4118            &MethodRule::new(Some("GET".to_string())),
4119            &Route::ClusterId("api".into())
4120        ));
4121        println!("{:#?}", router.tree);
4122        assert_eq!(
4123            router.lookup("www.sozu.io", "/ap", &Method::Get),
4124            Ok(RouteResult::forward("base".into()))
4125        );
4126        assert_eq!(
4127            router.lookup("www.sozu.io", "/api", &Method::Get),
4128            Ok(RouteResult::forward("api".into()))
4129        );
4130    }
4131
4132    ///  [io]
4133    ///      \
4134    ///       [sozu]  <- this node has multiple children including a wildcard
4135    ///      /      \
4136    ///   (api)      [*]  <- this wildcard has multiple children
4137    ///                 \
4138    ///                (base)
4139    #[test]
4140    fn multiple_children_including_one_with_wildcard() {
4141        let mut router = Router::new();
4142
4143        assert!(router.add_tree_rule(
4144            b"*.sozu.io",
4145            &PathRule::Prefix("".to_string()),
4146            &MethodRule::new(Some("GET".to_string())),
4147            &Route::ClusterId("base".into())
4148        ));
4149        println!("{:#?}", router.tree);
4150        assert_eq!(
4151            router.lookup("www.sozu.io", "/api", &Method::Get),
4152            Ok(RouteResult::forward("base".into()))
4153        );
4154        assert!(router.add_tree_rule(
4155            b"api.sozu.io",
4156            &PathRule::Prefix("".to_string()),
4157            &MethodRule::new(Some("GET".to_string())),
4158            &Route::ClusterId("api".into())
4159        ));
4160        println!("{:#?}", router.tree);
4161        assert_eq!(
4162            router.lookup("www.sozu.io", "/api", &Method::Get),
4163            Ok(RouteResult::forward("base".into()))
4164        );
4165        assert_eq!(
4166            router.lookup("api.sozu.io", "/api", &Method::Get),
4167            Ok(RouteResult::forward("api".into()))
4168        );
4169    }
4170
4171    /// A malformed hostname arriving from the control plane
4172    /// (`AddHttpFrontend` over the command socket, or a `LoadState`
4173    /// replay) must be REJECTED, never panic the worker. `TrieNode::insert`
4174    /// used to `assert_ne!` on `InsertResult::Failed`, so a frontend whose
4175    /// hostname ended in `/` killed every worker the master fanned the
4176    /// request out to -- and killed them again on each restart replay.
4177    #[test]
4178    fn add_tree_rule_rejects_malformed_hostnames_without_panicking() {
4179        // Every shape here reached `InsertResult::Failed` (or an empty
4180        // `partial_key`) inside `insert_recursive`.
4181        for hostname in [
4182            &b"example.com/"[..],
4183            b"www.example.com/",
4184            b"foo/",
4185            b"a/*/",
4186            b"/",
4187            b"///",
4188            b"abc/[0-9]+/.example.com",
4189            b"/[/.example.com",
4190            // Named as rejected in `doc/configure.md`'s regex-hostname
4191            // section: a leading `.` before a regex segment is an empty label.
4192            b"./test[0-9]/.example.com",
4193            b".example.com",
4194            b".a.b",
4195            b"..",
4196        ] {
4197            let mut router = Router::new();
4198            assert!(
4199                !router.add_tree_rule(
4200                    hostname,
4201                    &PathRule::Prefix("/".to_string()),
4202                    &MethodRule::new(Some("GET".to_string())),
4203                    &Route::ClusterId("cluster".into()),
4204                ),
4205                "{:?} must be rejected, not inserted",
4206                String::from_utf8_lossy(hostname),
4207            );
4208            // A rejected add must leave NO trace: the route table is
4209            // exactly as empty as it was before the attempt.
4210            assert!(
4211                router.tree.is_empty(),
4212                "{:?} was rejected but still mutated the route table",
4213                String::from_utf8_lossy(hostname),
4214            );
4215        }
4216    }
4217
4218    /// The same rejection, one layer up: `add_http_front` must surface a
4219    /// `RouterError::AddRoute` so the worker answers the master with a
4220    /// failure instead of dying.
4221    #[test]
4222    fn add_http_front_surfaces_a_malformed_hostname_as_an_error() {
4223        let mut router = Router::new();
4224        let front = HttpFrontend {
4225            hostname: "example.com/".to_owned(),
4226            ..test_http_frontend()
4227        };
4228        assert!(matches!(
4229            router.add_http_front(&front),
4230            Err(RouterError::AddRoute(_))
4231        ));
4232        assert!(router.tree.is_empty());
4233
4234        // A hostname whose last segment is followed by a bare trailing `.`
4235        // is rejected one layer earlier still, by the unconditional
4236        // `DomainRule` parse -- which used to walk out of bounds on it
4237        // (see `domain_rule_rejects_a_trailing_dot_after_a_regex_segment`).
4238        for hostname in ["/a/.", "a/b/.", "x./y/."] {
4239            let mut router = Router::new();
4240            let front = HttpFrontend {
4241                hostname: (*hostname).to_owned(),
4242                ..test_http_frontend()
4243            };
4244            assert!(
4245                matches!(
4246                    router.add_http_front(&front),
4247                    Err(RouterError::InvalidDomain { .. })
4248                ),
4249                "{hostname:?} must be rejected as an invalid domain, not panic",
4250            );
4251            assert!(router.tree.is_empty());
4252        }
4253    }
4254
4255    /// `convert_regex_domain_rule` used to walk one byte past the end of a
4256    /// hostname whose last segment is followed by a bare trailing `.`
4257    /// (`/a/.`, `a/b/.`, `x./y/.`): the `.` arm of the loop tail advances
4258    /// `index` to `s.len()` and the next iteration indexed `s[index]` out
4259    /// of bounds -- a release panic (slice bounds checks never compile
4260    /// out) reachable from the control plane through the unconditional
4261    /// `DomainRule` parse in `add_http_front`, sidestepping every trie
4262    /// guard.
4263    #[test]
4264    fn domain_rule_rejects_a_trailing_dot_after_a_regex_segment() {
4265        for hostname in ["/a/.", "a/b/.", "/[/.", "x./y/."] {
4266            assert!(
4267                hostname.parse::<DomainRule>().is_err(),
4268                "{hostname:?} must be rejected, not panic",
4269            );
4270        }
4271        // The guard must not disturb the legitimate `.`-anchored
4272        // regex-segment grammar.
4273        assert!("abc./[0-9]+/.example.com".parse::<DomainRule>().is_ok());
4274    }
4275
4276    /// The trie recurses once per label and the regex-segment grammar
4277    /// compiles control-plane-supplied patterns, so both add and remove
4278    /// bound the hostname to [`MAX_HOSTNAME_LENGTH`] before parsing
4279    /// anything: a ~100k-label hostname otherwise aborts the worker with
4280    /// an uncatchable stack overflow, and regex compilation time grows
4281    /// with the pattern (a 2 MiB hostname stalls the single-threaded
4282    /// worker for hundreds of milliseconds before rejection).
4283    #[test]
4284    fn add_http_front_rejects_an_oversized_hostname() {
4285        // At the bound: accepted.
4286        let mut router = Router::new();
4287        let front = HttpFrontend {
4288            hostname: "a".repeat(MAX_HOSTNAME_LENGTH),
4289            ..test_http_frontend()
4290        };
4291        assert!(router.add_http_front(&front).is_ok());
4292
4293        // One byte over: rejected on add AND on remove, before any parse.
4294        let mut router = Router::new();
4295        let front = HttpFrontend {
4296            hostname: "a".repeat(MAX_HOSTNAME_LENGTH + 1),
4297            ..test_http_frontend()
4298        };
4299        assert!(matches!(
4300            router.add_http_front(&front),
4301            Err(RouterError::InvalidDomain { .. })
4302        ));
4303        assert!(matches!(
4304            router.remove_http_front(&front),
4305            Err(RouterError::InvalidDomain { .. })
4306        ));
4307        assert!(router.tree.is_empty());
4308
4309        // A label-count bomb is caught by the same byte bound.
4310        let front = HttpFrontend {
4311            hostname: "a.".repeat(MAX_HOSTNAME_LENGTH),
4312            ..test_http_frontend()
4313        };
4314        assert!(matches!(
4315            router.add_http_front(&front),
4316            Err(RouterError::InvalidDomain { .. })
4317        ));
4318    }
4319
4320    #[test]
4321    fn router_insert_remove_through_regex() {
4322        let mut router = Router::new();
4323
4324        assert!(router.add_tree_rule(
4325            b"www./.*/.io",
4326            &PathRule::Prefix("".to_string()),
4327            &MethodRule::new(Some("GET".to_string())),
4328            &Route::ClusterId("base".into())
4329        ));
4330        println!("{:#?}", router.tree);
4331        assert!(router.add_tree_rule(
4332            b"www.doc./.*/.io",
4333            &PathRule::Prefix("".to_string()),
4334            &MethodRule::new(Some("GET".to_string())),
4335            &Route::ClusterId("doc".into())
4336        ));
4337        println!("{:#?}", router.tree);
4338        assert_eq!(
4339            router.lookup("www.sozu.io", "/", &Method::Get),
4340            Ok(RouteResult::forward("base".into()))
4341        );
4342        assert_eq!(
4343            router.lookup("www.doc.sozu.io", "/", &Method::Get),
4344            Ok(RouteResult::forward("doc".into()))
4345        );
4346        assert!(router.remove_tree_rule(
4347            b"www./.*/.io",
4348            &PathRule::Prefix("".to_string()),
4349            &MethodRule::new(Some("GET".to_string()))
4350        ));
4351        println!("{:#?}", router.tree);
4352        assert!(router.lookup("www.sozu.io", "/", &Method::Get).is_err());
4353        assert_eq!(
4354            router.lookup("www.doc.sozu.io", "/", &Method::Get),
4355            Ok(RouteResult::forward("doc".into()))
4356        );
4357    }
4358
4359    #[test]
4360    fn match_router() {
4361        let mut router = Router::new();
4362
4363        assert!(router.add_pre_rule(
4364            &"*".parse::<DomainRule>().unwrap(),
4365            &PathRule::Prefix("/.well-known/acme-challenge".to_string()),
4366            &MethodRule::new(Some("GET".to_string())),
4367            &Route::ClusterId("acme".into())
4368        ));
4369        assert!(router.add_tree_rule(
4370            "www.example.com".as_bytes(),
4371            &PathRule::Prefix("/".to_string()),
4372            &MethodRule::new(Some("GET".to_string())),
4373            &Route::ClusterId("example".into())
4374        ));
4375        assert!(router.add_tree_rule(
4376            "*.test.example.com".as_bytes(),
4377            &PathRule::Regex(Regex::new("/hello[A-Z]+/").unwrap()),
4378            &MethodRule::new(Some("GET".to_string())),
4379            &Route::ClusterId("examplewildcard".into())
4380        ));
4381        assert!(router.add_tree_rule(
4382            "/test[0-9]/.example.com".as_bytes(),
4383            &PathRule::Prefix("/".to_string()),
4384            &MethodRule::new(Some("GET".to_string())),
4385            &Route::ClusterId("exampleregex".into())
4386        ));
4387
4388        assert_eq!(
4389            router.lookup("www.example.com", "/helloA", &Method::new(&b"GET"[..])),
4390            Ok(RouteResult::forward("example".into()))
4391        );
4392        assert_eq!(
4393            router.lookup(
4394                "www.example.com",
4395                "/.well-known/acme-challenge",
4396                &Method::new(&b"GET"[..])
4397            ),
4398            Ok(RouteResult::forward("acme".into()))
4399        );
4400        assert!(
4401            router
4402                .lookup("www.test.example.com", "/", &Method::new(&b"GET"[..]))
4403                .is_err()
4404        );
4405        assert_eq!(
4406            router.lookup(
4407                "www.test.example.com",
4408                "/helloAB/",
4409                &Method::new(&b"GET"[..])
4410            ),
4411            Ok(RouteResult::forward("examplewildcard".into()))
4412        );
4413        assert_eq!(
4414            router.lookup("test1.example.com", "/helloAB/", &Method::new(&b"GET"[..])),
4415            Ok(RouteResult::forward("exampleregex".into()))
4416        );
4417    }
4418
4419    #[test]
4420    fn has_hostname_checks_tree_pre_and_post() {
4421        let mut router = Router::new();
4422
4423        // Empty router has no hostnames
4424        assert!(!router.has_hostname("www.example.com"));
4425
4426        // Add a tree rule
4427        assert!(router.add_tree_rule(
4428            b"www.example.com",
4429            &PathRule::Prefix("/".to_string()),
4430            &MethodRule::new(Some("GET".to_string())),
4431            &Route::ClusterId("cluster1".into())
4432        ));
4433        assert!(router.has_hostname("www.example.com"));
4434        assert!(!router.has_hostname("api.example.com"));
4435
4436        // Remove the tree rule — hostname should disappear
4437        assert!(router.remove_tree_rule(
4438            b"www.example.com",
4439            &PathRule::Prefix("/".to_string()),
4440            &MethodRule::new(Some("GET".to_string()))
4441        ));
4442        assert!(!router.has_hostname("www.example.com"));
4443
4444        // Add a pre rule with an exact domain
4445        assert!(router.add_pre_rule(
4446            &DomainRule::Exact("api.example.com".to_string()),
4447            &PathRule::Prefix("/".to_string()),
4448            &MethodRule::new(None),
4449            &Route::ClusterId("cluster2".into())
4450        ));
4451        assert!(router.has_hostname("api.example.com"));
4452        assert!(!router.has_hostname("www.example.com"));
4453
4454        // Add a post rule
4455        assert!(router.add_post_rule(
4456            &DomainRule::Exact("cdn.example.com".to_string()),
4457            &PathRule::Prefix("/".to_string()),
4458            &MethodRule::new(None),
4459            &Route::ClusterId("cluster3".into())
4460        ));
4461        assert!(router.has_hostname("cdn.example.com"));
4462
4463        // Remove pre rule, post rule should still be detected
4464        assert!(router.remove_pre_rule(
4465            &DomainRule::Exact("api.example.com".to_string()),
4466            &PathRule::Prefix("/".to_string()),
4467            &MethodRule::new(None),
4468        ));
4469        assert!(!router.has_hostname("api.example.com"));
4470        assert!(router.has_hostname("cdn.example.com"));
4471    }
4472
4473    #[test]
4474    fn has_hostname_false_after_last_route_removed() {
4475        let mut router = Router::new();
4476
4477        // Add two routes for the same hostname with different paths
4478        assert!(router.add_tree_rule(
4479            b"www.example.com",
4480            &PathRule::Prefix("/".to_string()),
4481            &MethodRule::new(Some("GET".to_string())),
4482            &Route::ClusterId("cluster1".into())
4483        ));
4484        assert!(router.add_tree_rule(
4485            b"www.example.com",
4486            &PathRule::Prefix("/api".to_string()),
4487            &MethodRule::new(Some("GET".to_string())),
4488            &Route::ClusterId("cluster2".into())
4489        ));
4490        assert!(router.has_hostname("www.example.com"));
4491
4492        // Remove first route — hostname should still exist
4493        assert!(router.remove_tree_rule(
4494            b"www.example.com",
4495            &PathRule::Prefix("/".to_string()),
4496            &MethodRule::new(Some("GET".to_string()))
4497        ));
4498        assert!(router.has_hostname("www.example.com"));
4499
4500        // Remove second route — hostname should be gone
4501        assert!(router.remove_tree_rule(
4502            b"www.example.com",
4503            &PathRule::Prefix("/api".to_string()),
4504            &MethodRule::new(Some("GET".to_string()))
4505        ));
4506        assert!(!router.has_hostname("www.example.com"));
4507    }
4508    /// The operator-visible half of the trie's leftmost-regex insert gap.
4509    /// A frontend whose leftmost host segment is a regex
4510    /// (`/test[0-9]/.example.com`) could not be added once a DEEPER
4511    /// frontend sharing that segment (`foo./test[0-9]/.example.com`) had
4512    /// already opened it: `TrieNode::insert_recursive` answered `Existing`
4513    /// for a host it stored nowhere.
4514    ///
4515    /// The two profiles failed differently, which is why this test asserts
4516    /// the STORE rather than the return code alone. In debug the
4517    /// post-insert reachability `debug_assert!` below `domain_insert`
4518    /// killed the worker outright — "a freshly inserted tree domain must
4519    /// resolve to its inserted rule" — on a control-plane `AddHttpFrontend`
4520    /// (and again on every `LoadState` replay). In release that assert is
4521    /// compiled out, so `add_tree_rule` returned `true`, the CLI reported
4522    /// OK and `sozu query frontends` listed the route, while the trie never
4523    /// served it.
4524    ///
4525    /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4526    /// `TrieNode::insert_recursive` (`router/pattern_trie.rs`), replace
4527    /// `return t.1.insert_own_value(key, value);` with
4528    /// `return InsertResult::Existing;`. In release the `lookup` assertion
4529    /// below fails with `Err(route_not_found …)`; in debug the
4530    /// `debug_assert!` in `add_tree_rule` panics first at
4531    /// "a freshly inserted tree domain must resolve to its inserted rule"
4532    /// — comment that `debug_assert!` out and debug lands on the same
4533    /// `lookup` assertion release does.
4534    #[test]
4535    fn a_leftmost_regex_frontend_is_addable_after_a_deeper_sibling() {
4536        let path = PathRule::Prefix("/".to_string());
4537        let method = MethodRule::new(Some("GET".to_string()));
4538
4539        // Order A: deeper frontend first — the order that used to lose the
4540        // leftmost host.
4541        let mut router = Router::new();
4542        assert!(router.add_tree_rule(
4543            b"foo./test[0-9]/.example.com",
4544            &path,
4545            &method,
4546            &Route::ClusterId("deeper".into()),
4547        ));
4548        assert!(
4549            router.add_tree_rule(
4550                b"/test[0-9]/.example.com",
4551                &path,
4552                &method,
4553                &Route::ClusterId("leftmost".into()),
4554            ),
4555            "a leftmost-regex frontend must be accepted after a deeper sibling",
4556        );
4557        assert_eq!(
4558            router.lookup("test4.example.com", "/", &Method::Get),
4559            Ok(RouteResult::forward("leftmost".into())),
4560            "the leftmost-regex frontend must actually route, not just report OK",
4561        );
4562        assert_eq!(
4563            router.lookup("foo.test4.example.com", "/", &Method::Get),
4564            Ok(RouteResult::forward("deeper".into())),
4565            "the deeper frontend must still route",
4566        );
4567
4568        // Order B: leftmost frontend first — the order that already worked.
4569        let mut router = Router::new();
4570        assert!(router.add_tree_rule(
4571            b"/test[0-9]/.example.com",
4572            &path,
4573            &method,
4574            &Route::ClusterId("leftmost".into()),
4575        ));
4576        assert!(router.add_tree_rule(
4577            b"foo./test[0-9]/.example.com",
4578            &path,
4579            &method,
4580            &Route::ClusterId("deeper".into()),
4581        ));
4582        assert_eq!(
4583            router.lookup("test4.example.com", "/", &Method::Get),
4584            Ok(RouteResult::forward("leftmost".into())),
4585        );
4586        assert_eq!(
4587            router.lookup("foo.test4.example.com", "/", &Method::Get),
4588            Ok(RouteResult::forward("deeper".into())),
4589        );
4590
4591        // And removal through the public surface still takes exactly one.
4592        assert!(router.remove_tree_rule(b"/test[0-9]/.example.com", &path, &method));
4593        assert!(
4594            router
4595                .lookup("test4.example.com", "/", &Method::Get)
4596                .is_err()
4597        );
4598        assert_eq!(
4599            router.lookup("foo.test4.example.com", "/", &Method::Get),
4600            Ok(RouteResult::forward("deeper".into())),
4601        );
4602    }
4603
4604    /// `add_tree_rule` returning `true` is a claim that the route table now
4605    /// serves the rule. The trie's leftmost-regex gap broke exactly that
4606    /// contract in RELEASE builds, where the post-insert reachability
4607    /// `debug_assert!` is compiled out: the control plane reported success
4608    /// and the data plane never routed the host.
4609    ///
4610    /// This pins the contract itself, so it fails in a release build too —
4611    /// no `debug_assert` is involved in the assertion. Every shape here is
4612    /// one the trie stores through a different arm (literal, wildcard,
4613    /// leftmost regex, deeper regex, regex opened by a deeper sibling), and
4614    /// each is checked BOTH ways round: a reported success must route, and
4615    /// a reported failure must leave nothing behind.
4616    ///
4617    /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4618    /// `TrieNode::insert_recursive`, replace
4619    /// `return t.1.insert_own_value(key, value);` with
4620    /// `return InsertResult::Existing;`. The `/test[0-9]/.example.com` row
4621    /// added after `foo./test[0-9]/.example.com` then reports `true` while
4622    /// `lookup` answers `Err(route_not_found …)`, failing the
4623    /// "reported success but does not route" assertion in release. In debug
4624    /// the `debug_assert!` in `add_tree_rule` fires first with
4625    /// "a freshly inserted tree domain must resolve to its inserted rule".
4626    #[test]
4627    fn add_tree_rule_never_reports_success_for_a_rule_it_did_not_store() {
4628        let path = PathRule::Prefix("/".to_string());
4629        let method = MethodRule::new(Some("GET".to_string()));
4630
4631        // (hostname to add, hostname that must then route)
4632        let sequence: &[(&[u8], &str)] = &[
4633            (b"www.example.com", "www.example.com"),
4634            (b"*.wild.example.com", "any.wild.example.com"),
4635            (b"foo./test[0-9]/.example.com", "foo.test4.example.com"),
4636            // Opened above by its deeper sibling: the regression.
4637            (b"/test[0-9]/.example.com", "test4.example.com"),
4638            (b"bar./test[0-9]/.example.com", "bar.test7.example.com"),
4639        ];
4640
4641        let mut router = Router::new();
4642        for (index, (hostname, routable)) in sequence.iter().enumerate() {
4643            let cluster = ClusterId::from(format!("cluster{index}"));
4644            let added =
4645                router.add_tree_rule(hostname, &path, &method, &Route::ClusterId(cluster.clone()));
4646            let resolved = router.lookup(routable, "/", &Method::Get);
4647            assert!(
4648                added,
4649                "{:?} must be accepted",
4650                String::from_utf8_lossy(hostname),
4651            );
4652            assert_eq!(
4653                resolved,
4654                Ok(RouteResult::forward(cluster)),
4655                "add_tree_rule reported success for {:?} but it does not route",
4656                String::from_utf8_lossy(hostname),
4657            );
4658        }
4659
4660        // The converse half of the contract: a REJECTED add must not be
4661        // reported as stored either, and must leave no route behind.
4662        for hostname in [
4663            &b"example.com/"[..],
4664            b".example.com",
4665            b"abc/[0-9]+/.example.com",
4666        ] {
4667            let mut router = Router::new();
4668            assert!(
4669                !router.add_tree_rule(
4670                    hostname,
4671                    &path,
4672                    &method,
4673                    &Route::ClusterId("rejected".into()),
4674                ),
4675                "{:?} must be rejected",
4676                String::from_utf8_lossy(hostname),
4677            );
4678            assert!(
4679                router.tree.is_empty(),
4680                "{:?} was rejected but still mutated the route table",
4681                String::from_utf8_lossy(hostname),
4682            );
4683        }
4684    }
4685
4686    // ---- Rule-ordering contract (`doc/configure.md`, "When declaration order
4687    // decides"). The path rules of one hostname are a flat `Vec` at the trie
4688    // leaf, scanned once in declaration order: `Regex`/`Equals` RETURN on the
4689    // first match, while `Prefix` accumulates the longest. Nothing pinned any
4690    // of that, and the documented rule has been wrong three times, so each
4691    // ordering below is its own case.
4692
4693    /// Declare one tree rule for `www.example.com`. `method` is a PARAMETER,
4694    /// never a constant: whether a rule carries one decides if it ends the
4695    /// lookup scan early, so holding it fixed hides half the orderings below.
4696    fn add_path_rule(
4697        router: &mut Router,
4698        path: PathRule,
4699        method: Option<&str>,
4700        cluster: &str,
4701    ) -> bool {
4702        router.add_tree_rule(
4703            b"www.example.com",
4704            &path,
4705            &MethodRule::new(method.map(str::to_owned)),
4706            &Route::ClusterId(cluster.into()),
4707        )
4708    }
4709
4710    /// The cluster a `GET` for `path` on `www.example.com` resolves to.
4711    fn routed_cluster(router: &Router, path: &str) -> Option<String> {
4712        router
4713            .lookup("www.example.com", path, &Method::Get)
4714            .ok()
4715            .and_then(|result| result.cluster_id)
4716            .map(|cluster_id| cluster_id.to_string())
4717    }
4718
4719    /// Declare `first` then `second` — each with its own `method` — and resolve
4720    /// `path`. Declaration order is the variable under test, so callers assert
4721    /// both orders.
4722    fn winner_of(
4723        first: (PathRule, Option<&str>, &str),
4724        second: (PathRule, Option<&str>, &str),
4725        path: &str,
4726    ) -> Option<String> {
4727        let mut router = Router::new();
4728        assert!(add_path_rule(&mut router, first.0, first.1, first.2));
4729        assert!(add_path_rule(&mut router, second.0, second.1, second.2));
4730        routed_cluster(&router, path)
4731    }
4732
4733    /// `Some("GET")` — a rule whose method matches the request, so it ends the
4734    /// scan. Spelled out at every call site so the axis stays visible.
4735    const GET: Option<&str> = Some("GET");
4736    /// `None` — a rule with no method. It matches every request but does NOT
4737    /// end the scan.
4738    const NO_METHOD: Option<&str> = None;
4739    /// `Some("POST")` against a `GET` request — a method that is PRESENT and
4740    /// does NOT match, i.e. `MethodRuleResult::None`. The third value of the
4741    /// method axis: such a rule is skipped entirely, whatever its `path_type`.
4742    /// Covering only `GET` and `NO_METHOD` left every claim about this value
4743    /// untested, which is how two false sentences reached the documentation.
4744    const WRONG_METHOD: Option<&str> = Some("POST");
4745
4746    /// Among `PREFIX` path rules the LONGEST match wins, and that is the one
4747    /// genuinely order-independent case in the router.
4748    ///
4749    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4750    /// `PathRuleResult::Prefix(size) => { if size >= prefix_length {` guard
4751    /// with `if matched.is_none() {` so the FIRST matching prefix wins instead
4752    /// of the longest. The `SHORT`-first case below then resolves to `SHORT`.
4753    #[test]
4754    fn the_longest_prefix_wins_whichever_prefix_is_declared_first() {
4755        let short = || PathRule::Prefix("/a".to_owned());
4756        let long = || PathRule::Prefix("/ab".to_owned());
4757
4758        assert_eq!(
4759            winner_of((short(), GET, "SHORT"), (long(), GET, "LONG"), "/abc").as_deref(),
4760            Some("LONG"),
4761            "the longer prefix must win even when the shorter one was declared first",
4762        );
4763        assert_eq!(
4764            winner_of((long(), GET, "LONG"), (short(), GET, "SHORT"), "/abc").as_deref(),
4765            Some("LONG"),
4766            "the longer prefix must win when it was declared first too",
4767        );
4768    }
4769
4770    /// Two `PREFIX` rules can only tie on length by carrying the SAME prefix
4771    /// string and differing on `method` — `add_tree_rule` dedups on
4772    /// `(path, method)`, so that pair coexists. The scan keeps the LAST such
4773    /// rule, because the guard is `size >= prefix_length`, not `>`.
4774    ///
4775    /// This is the subtlest ordering in the router and the easiest to "fix"
4776    /// by accident: tightening `>=` to `>` looks like a harmless no-op that
4777    /// stops a rule overwriting an equally-good one, and silently flips which
4778    /// cluster serves the request.
4779    ///
4780    /// To SEE THIS RED: change `if size >= prefix_length {` to
4781    /// `if size > prefix_length {` in `Router::lookup`'s path-rule loop. Both
4782    /// assertions below then resolve to the FIRST declared rule.
4783    #[test]
4784    fn equal_length_prefixes_differing_only_by_method_resolve_to_the_last_declared() {
4785        let declare = |first_is_get: bool| {
4786            let mut router = Router::new();
4787            let get = (
4788                MethodRule::new(Some("GET".to_owned())),
4789                Route::ClusterId("GET-RULE".into()),
4790            );
4791            let all = (MethodRule::new(None), Route::ClusterId("ALL-RULE".into()));
4792            let (a, b) = if first_is_get { (get, all) } else { (all, get) };
4793            for (method, route) in [a, b] {
4794                assert!(router.add_tree_rule(
4795                    b"www.example.com",
4796                    &PathRule::Prefix("/ab".to_owned()),
4797                    &method,
4798                    &route,
4799                ));
4800            }
4801            routed_cluster(&router, "/abc")
4802        };
4803
4804        assert_eq!(
4805            declare(true).as_deref(),
4806            Some("ALL-RULE"),
4807            "the LAST equal-length prefix declared must win (`>=`, not `>`)",
4808        );
4809        assert_eq!(
4810            declare(false).as_deref(),
4811            Some("GET-RULE"),
4812            "the LAST equal-length prefix declared must win in the other order too",
4813        );
4814    }
4815
4816    /// Two overlapping `REGEX` path rules are decided by declaration order:
4817    /// the scan returns on the first match, and nothing weighs one pattern as
4818    /// more specific than another. A wider pattern declared first therefore
4819    /// shadows a narrower one declared later.
4820    ///
4821    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4822    /// `MethodRuleResult::Equals => { return Ok(RouteResult::new_with_trie(..)) }`
4823    /// arm under `PathRuleResult::Regex | PathRuleResult::Equals` with the
4824    /// non-returning body its `MethodRuleResult::All` sibling uses
4825    /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). Losing
4826    /// the early return makes the LAST match win, so both assertions flip.
4827    #[test]
4828    fn with_a_matching_method_the_first_declared_regex_path_rule_wins() {
4829        let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4830        let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4831
4832        assert_eq!(
4833            winner_of((wide(), GET, "WIDE"), (narrow(), GET, "NARROW"), "/abc").as_deref(),
4834            Some("WIDE"),
4835            "the first-declared regex must win even though the later one is narrower",
4836        );
4837        assert_eq!(
4838            winner_of((narrow(), GET, "NARROW"), (wide(), GET, "WIDE"), "/abc").as_deref(),
4839            Some("NARROW"),
4840            "reversing the declaration order reverses the winner",
4841        );
4842    }
4843
4844    /// `EQUALS` holds no priority over `REGEX`: they share one match arm and
4845    /// the scan returns on whichever comes first. An exact-match rule does NOT
4846    /// outrank a pattern that was declared before it.
4847    ///
4848    /// To SEE THIS RED: the same mutation as
4849    /// `with_a_matching_method_the_first_declared_regex_path_rule_wins`
4850    /// — drop the early `return` from the `MethodRuleResult::Equals` arm under
4851    /// `PathRuleResult::Regex | PathRuleResult::Equals`. Both assertions flip
4852    /// to the last declared rule.
4853    #[test]
4854    fn with_a_matching_method_neither_equals_nor_regex_outranks_the_other() {
4855        let equals = || PathRule::Equals("/abc".to_owned());
4856        let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4857
4858        assert_eq!(
4859            winner_of((equals(), GET, "EQUALS"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4860            Some("EQUALS"),
4861            "EQUALS declared first must win",
4862        );
4863        assert_eq!(
4864            winner_of((regex(), GET, "REGEX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4865            Some("REGEX"),
4866            "REGEX declared first must win — EQUALS has no inherent priority",
4867        );
4868    }
4869
4870    /// A matching `EQUALS` or `REGEX` beats a `PREFIX` rule in either order,
4871    /// even when the prefix is the longer, more specific pattern: the early
4872    /// return short-circuits the scan before any prefix accumulation is read.
4873    /// This is the one cross-type precedence the router really does have.
4874    ///
4875    /// To SEE THIS RED: the same mutation again — drop the early `return` from
4876    /// the `MethodRuleResult::Equals` arm under
4877    /// `PathRuleResult::Regex | PathRuleResult::Equals`. That arm's
4878    /// replacement sets `prefix_length = path_b.len()`, so the equally-long
4879    /// `PREFIX` rule then satisfies `size >= prefix_length` and overwrites it;
4880    /// the `PREFIX`-declared-second cases below resolve to `PREFIX`.
4881    #[test]
4882    fn with_a_matching_method_equals_or_regex_beats_a_longer_prefix_either_order() {
4883        let prefix = || PathRule::Prefix("/abc".to_owned());
4884        let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4885        let equals = || PathRule::Equals("/abc".to_owned());
4886
4887        assert_eq!(
4888            winner_of((prefix(), GET, "PREFIX"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4889            Some("REGEX"),
4890            "a regex must beat a longer prefix declared before it",
4891        );
4892        assert_eq!(
4893            winner_of((regex(), GET, "REGEX"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4894            Some("REGEX"),
4895            "a regex must beat a longer prefix declared after it",
4896        );
4897        assert_eq!(
4898            winner_of((prefix(), GET, "PREFIX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4899            Some("EQUALS"),
4900            "an exact match must beat an equally long prefix declared before it",
4901        );
4902        assert_eq!(
4903            winner_of((equals(), GET, "EQUALS"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4904            Some("EQUALS"),
4905            "an exact match must beat an equally long prefix declared after it",
4906        );
4907    }
4908
4909    /// Without a `method`, an `EQUALS` or `REGEX` rule does NOT end the scan:
4910    /// its match is only recorded, and a later match overwrites it. So the
4911    /// ordering is the exact REVERSE of the matching-method case — the LAST
4912    /// declared wins.
4913    ///
4914    /// This is the dimension the first six ordering tests all missed, because
4915    /// their helper hard-coded `Some("GET")`. `HttpFrontend.method` is an
4916    /// `Option<String>` and both production call sites build the rule with
4917    /// `MethodRule::new(front.method.clone())`, so a frontend declared without
4918    /// a method lands here — it is the default shape, not an exotic one.
4919    ///
4920    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, give the
4921    /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4922    /// PathRuleResult::Equals` the early `return` its `MethodRuleResult::Equals`
4923    /// sibling has. Method-less rules then short-circuit too and both
4924    /// assertions flip to the FIRST declared.
4925    #[test]
4926    fn without_a_method_the_last_declared_regex_or_equals_wins_not_the_first() {
4927        let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4928        let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4929        let equals = || PathRule::Equals("/abc".to_owned());
4930
4931        assert_eq!(
4932            winner_of(
4933                (wide(), NO_METHOD, "WIDE"),
4934                (narrow(), NO_METHOD, "NARROW"),
4935                "/abc"
4936            )
4937            .as_deref(),
4938            Some("NARROW"),
4939            "without a method the LAST declared regex must win",
4940        );
4941        assert_eq!(
4942            winner_of(
4943                (narrow(), NO_METHOD, "NARROW"),
4944                (wide(), NO_METHOD, "WIDE"),
4945                "/abc"
4946            )
4947            .as_deref(),
4948            Some("WIDE"),
4949            "reversing the order reverses the winner — still the last declared",
4950        );
4951        assert_eq!(
4952            winner_of(
4953                (equals(), NO_METHOD, "EQUALS"),
4954                (wide(), NO_METHOD, "REGEX"),
4955                "/abc"
4956            )
4957            .as_deref(),
4958            Some("REGEX"),
4959            "without a method an EQUALS declared first loses to a later REGEX",
4960        );
4961        assert_eq!(
4962            winner_of(
4963                (wide(), NO_METHOD, "REGEX"),
4964                (equals(), NO_METHOD, "EQUALS"),
4965                "/abc"
4966            )
4967            .as_deref(),
4968            Some("EQUALS"),
4969            "and the reverse order reverses that too",
4970        );
4971    }
4972
4973    /// Without a `method`, a matching `EQUALS`/`REGEX` does not reliably outrank
4974    /// a `PREFIX` either — the documented cross-type precedence INVERTS. Because
4975    /// the non-returning arm records `prefix_length = path_b.len()`, a `PREFIX`
4976    /// declared afterwards overwrites it exactly when its prefix covers the
4977    /// WHOLE request path; a shorter prefix cannot clear that bar and leaves the
4978    /// regex standing.
4979    ///
4980    /// "Whole request path" means the request-target as it arrives, QUERY
4981    /// STRING INCLUDED — kawa's `parse_origin_form` hands the router
4982    /// `/index.html?k=v#h` verbatim. So the identical configuration routes the
4983    /// opposite way once the client appends `?x=1`: `/abc` no longer spans
4984    /// `/abc?x=1`, the prefix stops clearing the bar, and the regex wins. The
4985    /// last case below pins that, because the worked example in
4986    /// `doc/configure.md` would otherwise teach `/abc` as "the whole path".
4987    ///
4988    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, change the
4989    /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4990    /// PathRuleResult::Equals` from `prefix_length = path_b.len();` to
4991    /// `prefix_length = 0;`. The first two assertions still pass; the third
4992    /// fails first, with `left: Some("SHORT-PREFIX"), right: Some("REGEX")`,
4993    /// because the shorter `/a` prefix now clears the bar. That one mutation
4994    /// also breaks the query-string case below it — delete the third assertion
4995    /// and the fourth fails with `left: Some("PREFIX"), right: Some("REGEX")`,
4996    /// the prefix having overwritten the regex it should no longer span.
4997    #[test]
4998    fn without_a_method_only_a_whole_path_prefix_declared_later_beats_a_regex() {
4999        let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
5000        let whole_path = || PathRule::Prefix("/abc".to_owned());
5001        let shorter = || PathRule::Prefix("/a".to_owned());
5002
5003        assert_eq!(
5004            winner_of(
5005                (regex(), NO_METHOD, "REGEX"),
5006                (whole_path(), NO_METHOD, "PREFIX"),
5007                "/abc"
5008            )
5009            .as_deref(),
5010            Some("PREFIX"),
5011            "a whole-path prefix declared after a method-less regex must overwrite it",
5012        );
5013        assert_eq!(
5014            winner_of(
5015                (whole_path(), NO_METHOD, "PREFIX"),
5016                (regex(), NO_METHOD, "REGEX"),
5017                "/abc"
5018            )
5019            .as_deref(),
5020            Some("REGEX"),
5021            "declared before it, the same prefix loses — the last writer wins",
5022        );
5023        assert_eq!(
5024            winner_of(
5025                (regex(), NO_METHOD, "REGEX"),
5026                (shorter(), NO_METHOD, "SHORT-PREFIX"),
5027                "/abc"
5028            )
5029            .as_deref(),
5030            Some("REGEX"),
5031            "a prefix shorter than the request path must NOT displace the regex",
5032        );
5033
5034        // Same rules, same declaration order — only the request gains a query
5035        // string, and the winner inverts. The request path the router matches
5036        // is `/abc?x=1`, which `/abc` no longer covers.
5037        assert_eq!(
5038            winner_of(
5039                (regex(), NO_METHOD, "REGEX"),
5040                (whole_path(), NO_METHOD, "PREFIX"),
5041                "/abc?x=1"
5042            )
5043            .as_deref(),
5044            Some("REGEX"),
5045            "with a query string the prefix no longer spans the request path, so the regex wins",
5046        );
5047    }
5048
5049    /// A rule carrying a matching `method` beats a method-less one in either
5050    /// declaration order: the matching rule returns immediately, and when the
5051    /// method-less rule runs first it has only recorded a candidate that the
5052    /// return then overrides.
5053    ///
5054    /// To SEE THIS RED: drop the early `return` from the
5055    /// `MethodRuleResult::Equals` arm under `PathRuleResult::Regex |
5056    /// PathRuleResult::Equals` in `Router::lookup` (the same mutation the
5057    /// matching-method tests name). Both rules then merely record, so the last
5058    /// declared wins and the first assertion below fails with
5059    /// `left: Some("ANY-REGEX"), right: Some("GET-REGEX")`.
5060    #[test]
5061    fn a_rule_with_a_matching_method_beats_a_method_less_rule_in_either_order() {
5062        let any = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
5063        let getr = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
5064
5065        assert_eq!(
5066            winner_of(
5067                (getr(), GET, "GET-REGEX"),
5068                (any(), NO_METHOD, "ANY-REGEX"),
5069                "/abc"
5070            )
5071            .as_deref(),
5072            Some("GET-REGEX"),
5073            "a matching-method rule declared first must win",
5074        );
5075        assert_eq!(
5076            winner_of(
5077                (any(), NO_METHOD, "ANY-REGEX"),
5078                (getr(), GET, "GET-REGEX"),
5079                "/abc"
5080            )
5081            .as_deref(),
5082            Some("GET-REGEX"),
5083            "a matching-method rule declared second must still win",
5084        );
5085    }
5086
5087    /// A rule whose `method` is PRESENT but does not match the request
5088    /// (`MethodRuleResult::None`) is skipped outright, so it never competes on
5089    /// path length at all. Both consequences contradict the unqualified
5090    /// "longest prefix wins" reading: a LONGER prefix loses to a shorter one
5091    /// when its method mismatches, and an equal-length tie goes to the FIRST
5092    /// declared rather than the last, because the later one is skipped before
5093    /// the `size >= prefix_length` comparison is ever reached.
5094    ///
5095    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5096    /// `MethodRuleResult::None` arm under `PathRuleResult::Prefix(size)`
5097    /// behave like its `MethodRuleResult::All` sibling
5098    /// (`prefix_length = size; matched = Some((rule, route));`). Non-matching
5099    /// methods then compete: the first assertion resolves to `LONG-POST` and
5100    /// the second to `SECOND-POST`.
5101    #[test]
5102    fn a_prefix_whose_method_does_not_match_is_skipped_so_it_cannot_win_on_length() {
5103        assert_eq!(
5104            winner_of(
5105                (PathRule::Prefix("/a".to_owned()), GET, "SHORT-GET"),
5106                (
5107                    PathRule::Prefix("/ab".to_owned()),
5108                    WRONG_METHOD,
5109                    "LONG-POST"
5110                ),
5111                "/abc",
5112            )
5113            .as_deref(),
5114            Some("SHORT-GET"),
5115            "a longer prefix whose method mismatches must not beat a shorter matching one",
5116        );
5117        assert_eq!(
5118            winner_of(
5119                (PathRule::Prefix("/ab".to_owned()), GET, "FIRST-GET"),
5120                (
5121                    PathRule::Prefix("/ab".to_owned()),
5122                    WRONG_METHOD,
5123                    "SECOND-POST"
5124                ),
5125                "/abc",
5126            )
5127            .as_deref(),
5128            Some("FIRST-GET"),
5129            "an equal-length tie goes to the FIRST when the later rule's method mismatches",
5130        );
5131    }
5132
5133    /// The same third method value, one tier up: an `EQUALS`/`REGEX` rule whose
5134    /// method mismatches does not short-circuit and does not even register as a
5135    /// candidate, so a `PREFIX` that would otherwise lose to it wins.
5136    ///
5137    /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5138    /// `MethodRuleResult::None` arm under
5139    /// `PathRuleResult::Regex | PathRuleResult::Equals` behave like its
5140    /// `MethodRuleResult::All` sibling
5141    /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). The
5142    /// mismatching regex then takes the request and the assertion resolves to
5143    /// `WRONG-METHOD-REGEX`.
5144    #[test]
5145    fn a_regex_whose_method_does_not_match_never_beats_a_matching_prefix() {
5146        assert_eq!(
5147            winner_of(
5148                (
5149                    PathRule::Regex(Regex::new("/a.*").expect("test regex must compile")),
5150                    WRONG_METHOD,
5151                    "WRONG-METHOD-REGEX",
5152                ),
5153                (PathRule::Prefix("/a".to_owned()), GET, "MATCHING-PREFIX"),
5154                "/abc",
5155            )
5156            .as_deref(),
5157            Some("MATCHING-PREFIX"),
5158            "a regex whose method mismatches must not short-circuit the scan",
5159        );
5160    }
5161
5162    /// Declare one regex path rule built the way a configured frontend builds
5163    /// it — through `PathRule::from_config`, which is where the anchoring
5164    /// lives — and resolve `path`.
5165    fn regex_rule_routes(pattern: &str, path: &str) -> Option<String> {
5166        let rule = PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
5167            .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"));
5168        let mut router = Router::new();
5169        assert!(add_path_rule(&mut router, rule, GET, "REGEX"));
5170        routed_cluster(&router, path)
5171    }
5172
5173    /// Resolve a table of `(pattern, request path, must route)` triples
5174    /// through [`regex_rule_routes`]. Each row names itself in the failure
5175    /// message, so a red run says which triple broke rather than which
5176    /// assertion index did.
5177    fn assert_regex_routing(cases: &[(&str, &str, bool)]) {
5178        let verdict = |routes: bool| if routes { "match" } else { "no match" };
5179        for &(pattern, path, must_route) in cases {
5180            let routes = regex_rule_routes(pattern, path).is_some();
5181            assert_eq!(
5182                routes,
5183                must_route,
5184                "path regex {pattern:?} against request path {path:?}: \
5185                 expected {}, measured {}",
5186                verdict(must_route),
5187                verdict(routes),
5188            );
5189        }
5190    }
5191
5192    /// `path_type = "REGEX"` is anchored at both ends: the configured pattern
5193    /// must match the WHOLE request path, not a substring of it.
5194    ///
5195    /// This test is the INVERSION of the test formerly named
5196    /// `a_path_regex_is_unanchored_and_matches_anywhere_in_the_request_path`
5197    /// — a name no longer in the tree, this one having replaced it —
5198    /// added by #1352 to pin the opposite. That test was correct about the
5199    /// code and is now deliberately obsolete: sozu#1350 asked whether
5200    /// `doc/configure.md` (which has promised `\A...\z` since v2.0.0) or
5201    /// `PathRule::from_config` (a bare `Regex::new` against the substring
5202    /// search `regex::bytes::Regex::is_match`) was the thing to change, and
5203    /// the answer was the code. The old test's own comment said it existed so
5204    /// that the answer would be "a deliberate change, not a silent one" —
5205    /// this is that deliberate change, kept visible by rewriting the test
5206    /// rather than deleting it.
5207    ///
5208    /// `bc` matching `/abcd` is sozu#1350's own example, so it is the case
5209    /// kept here.
5210    ///
5211    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5212    /// make the body `Regex::new(value).ok()`. The first assertion then fails
5213    /// with `left: Some("REGEX"), right: None`.
5214    #[test]
5215    fn a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path() {
5216        assert_eq!(
5217            regex_rule_routes("bc", "/abcd"),
5218            None,
5219            "an anchored path regex must not match in the middle of the request path",
5220        );
5221        assert_eq!(
5222            regex_rule_routes("/ab[cd]", "/abc").as_deref(),
5223            Some("REGEX"),
5224            "a regex spanning the whole request path must still match",
5225        );
5226        assert_eq!(
5227            regex_rule_routes("/ab[cd]", "/abcd"),
5228            None,
5229            "the trailing `\\z` must reject a longer path with a matching head",
5230        );
5231        assert_eq!(
5232            regex_rule_routes("/ab[cd]", "/x/abc"),
5233            None,
5234            "the leading `\\A` must reject a path with a matching tail",
5235        );
5236
5237        // The matched "request path" is the request-target as it arrives,
5238        // query string included, so anchoring bites a query string the way it
5239        // already bit `path_type = "EQUALS"` — the consequence
5240        // `doc/configure.md` now states.
5241        assert_eq!(
5242            regex_rule_routes("/ab[cd]", "/abc?x=1"),
5243            None,
5244            "an anchored regex must not match a path carrying a query string \
5245             unless the pattern accounts for it",
5246        );
5247        assert_eq!(
5248            regex_rule_routes("/ab[cd].*", "/abc?x=1").as_deref(),
5249            Some("REGEX"),
5250            "appending `.*` is what makes such a rule survive a query string",
5251        );
5252    }
5253
5254    /// The narrowing reaches the degenerate pattern too: an empty `REGEX`
5255    /// value compiles (it always has) and used to match every request path,
5256    /// because an empty match at offset 0 is a match. Anchored, it matches the
5257    /// empty path and nothing else. An operator using `path_type = "REGEX"`
5258    /// with an empty `path` as a catch-all loses every request to it.
5259    ///
5260    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5261    /// make the body `Regex::new(value).ok()`. The assertion then fails with
5262    /// `left: Some("REGEX"), right: None`.
5263    #[test]
5264    fn an_empty_path_regex_no_longer_matches_every_request_path() {
5265        assert!(
5266            Regex::new("").is_ok(),
5267            "the empty pattern must stay a VALID regex — this test is about \
5268             what it matches, not about rejecting it",
5269        );
5270        assert_eq!(
5271            regex_rule_routes("", "/abcd"),
5272            None,
5273            "an anchored empty path regex must not match a non-empty path",
5274        );
5275        // "matched every path", measured against the BARE regex of 2.2.1 — a
5276        // control. An empty match at offset 0 is a match, so `is_match`
5277        // succeeded on every input; that is what made an empty `REGEX` value
5278        // usable as a catch-all in the first place.
5279        let bare_empty = Regex::new("").expect("the empty pattern compiles");
5280        for every_path in ["/anything", "/abcd", "/", ""] {
5281            assert!(
5282                bare_empty.is_match(every_path.as_bytes()),
5283                "up to 2.2.1 the unwrapped empty pattern matched {every_path:?}, \
5284                 as it matched every path",
5285            );
5286        }
5287
5288        assert_eq!(
5289            regex_rule_routes("", "").as_deref(),
5290            Some("REGEX"),
5291            "\"matches only the empty path\" is half a claim without the path \
5292             it does still match",
5293        );
5294    }
5295
5296    /// Anchors an operator wrote by hand keep working, and are neither
5297    /// detected nor stripped: `regex`'s `^`/`$` ARE `\A`/`\z` outside
5298    /// multi-line mode, so the repeated zero-width assertions in
5299    /// `\A(?:^/abc$)\z` collapse to one at each end. Measured rather than
5300    /// assumed — that is what the pattern-string assertion below pins.
5301    ///
5302    /// The third case is the one that changes: a HALF-anchored pattern
5303    /// (`^/abc`, start only) used to match `/abcd` and no longer does. An
5304    /// operator who wrote the leading anchor on the strength of the old
5305    /// unanchored behaviour gets the missing end supplied for them.
5306    ///
5307    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5308    /// make the body `Regex::new(value).ok()`. The half-anchored case then
5309    /// fails with `left: Some("REGEX"), right: None`, and the pattern-string
5310    /// assertion fails too. To see ONLY the pattern-string assertion red,
5311    /// strip a leading `^`/`\A` and a trailing `$`/`\z` from `value` before
5312    /// wrapping it.
5313    #[test]
5314    fn a_hand_written_path_anchor_is_kept_and_double_anchoring_is_harmless() {
5315        for hand_anchored in ["^/abc$", "\\A/abc\\z", "^/abc"] {
5316            assert_eq!(
5317                regex_rule_routes(hand_anchored, "/abc").as_deref(),
5318                Some("REGEX"),
5319                "{hand_anchored} must still match the path it was written for",
5320            );
5321            assert_eq!(
5322                regex_rule_routes(hand_anchored, "/abcd"),
5323                None,
5324                "{hand_anchored} must not match a longer path",
5325            );
5326            assert_eq!(
5327                regex_rule_routes(hand_anchored, "/x/abc"),
5328                None,
5329                "{hand_anchored} must not match a path with a matching tail",
5330            );
5331        }
5332
5333        assert_eq!(
5334            PathRule::anchored_regex("^/abc$")
5335                .expect("a hand-anchored pattern must compile")
5336                .as_str(),
5337            "\\A(?:^/abc$)\\z",
5338            "the configured value is wrapped verbatim; no anchor is stripped",
5339        );
5340
5341        // `CHANGELOG.md` and `PathRule::anchored_regex` both quote `/xabcd`
5342        // as the path `\A(?:^/abc$)\z` must not match. It is a positive
5343        // control rather than a reddenable row — `^/abc$` excludes it with or
5344        // without the wrapping — but the quoted example was asserted nowhere.
5345        assert_eq!(
5346            regex_rule_routes("^/abc$", "/xabcd"),
5347            None,
5348            "the example `CHANGELOG.md` quotes verbatim must hold",
5349        );
5350
5351        // The load-bearing half of "`^`/`$` ARE `\A`/`\z`": in the `regex`
5352        // crate's default mode `$` is `\z` and NOT `\Z`, so it does not
5353        // match before a trailing `\n`. Were it `\Z`, the claim that an
5354        // already-anchored `^/abc$` is unchanged by the wrapping would be
5355        // false for exactly one input — a path ending in a newline. Whether a
5356        // client can drive such a request-target past the parser is NOT
5357        // established here, and deliberately so: the wrapping claim has to
5358        // hold on the bytes the router is handed, whatever produced them.
5359        // Measured against the crate directly, so it is a control.
5360        assert!(
5361            !Regex::new("^/abc$")
5362                .expect("the hand-anchored pattern compiles")
5363                .is_match(b"/abc\n"),
5364            "`$` must be `\\z` and not `\\Z`, or wrapping a hand-anchored \
5365             pattern would not be the no-op this test claims",
5366        );
5367        assert_eq!(
5368            regex_rule_routes("^/abc$", "/abc\n"),
5369            None,
5370            "and the wrapped rule must agree with it",
5371        );
5372    }
5373
5374    /// The anchoring wraps the configured value in a NON-CAPTURING GROUP, and
5375    /// that group is load-bearing: `|` binds looser than concatenation, so the
5376    /// ungrouped `\A/a|/b\z` parses as `(\A/a)|(/b\z)` and leaves each branch
5377    /// anchored at one end only. `/a` would still match `/axx`.
5378    ///
5379    /// Two branches only ever exercise the FIRST and LAST branch, so the
5380    /// three-branch case below is what makes `doc/configure.md`'s "every
5381    /// branch […] not just the first and last" a testable sentence: ungrouped,
5382    /// a MIDDLE branch keeps neither anchor and matches as a bare substring
5383    /// anywhere in the path — worse than the half-anchored ends.
5384    ///
5385    /// The hostname sites carried the same gap when this landed and were
5386    /// closed separately by sozu#1356: `convert_regex_domain_rule` now
5387    /// groups each slash-delimited segment, and `pattern_trie.rs`'s
5388    /// `anchored_segment` builds `\A(?:{segment})\z` for the regex fed to
5389    /// `regexp.is_match(segment)`. See
5390    /// `every_branch_of_an_alternating_regex_hostname_rule_is_anchored` and
5391    /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`.
5392    ///
5393    /// To SEE THIS RED: drop the group in `PathRule::anchored_regex` — wrap as
5394    /// `format!("\\A{value}\\z")`, the ungrouped hostname form. The `/axx`
5395    /// assertion then fails with `left: Some("REGEX"), right: None`; with the
5396    /// two-branch rows removed, the three-branch table fails on
5397    /// `"/a|/b|/c"` against `"zzz/bzzz"` with `left: true, right: false`, the
5398    /// middle branch matching in the middle of the path.
5399    #[test]
5400    fn every_branch_of_an_alternating_path_regex_is_anchored() {
5401        for whole in ["/a", "/b"] {
5402            assert_eq!(
5403                regex_rule_routes("/a|/b", whole).as_deref(),
5404                Some("REGEX"),
5405                "{whole} is a whole-path match for one branch and must route",
5406            );
5407        }
5408        assert_eq!(
5409            regex_rule_routes("/a|/b", "/axx"),
5410            None,
5411            "the first branch must be anchored at its END too",
5412        );
5413        assert_eq!(
5414            regex_rule_routes("/a|/b", "/xx/b"),
5415            None,
5416            "the last branch must be anchored at its START too",
5417        );
5418        assert_eq!(
5419            PathRule::anchored_regex("/a|/b")
5420                .expect("an alternation must compile")
5421                .captures_len(),
5422            1,
5423            "the wrapping group must be non-capturing, so `$PATH[n]` indices \
5424             are untouched",
5425        );
5426
5427        // The exact wrapper string, so "grouped" is asserted and not merely
5428        // implied by the three routing verdicts above.
5429        assert_eq!(
5430            PathRule::anchored_regex("/a|/b")
5431                .expect("an alternation must compile")
5432                .as_str(),
5433            "\\A(?:/a|/b)\\z",
5434            "the wrapper must be the NON-capturing group form",
5435        );
5436
5437        // The control for all of the above, built by hand against the `regex`
5438        // crate rather than through `anchored_regex`: the UNGROUPED wrapper —
5439        // the form the hostname sites use — parses as `(\A/a)|(/b\z)` and
5440        // still matches `/axx`. No mutation of `anchored_regex` can move this
5441        // assertion, which is the point: it measures why the group is there.
5442        let ungrouped = Regex::new("\\A/a|/b\\z").expect("the ungrouped wrapper compiles");
5443        assert!(
5444            ungrouped.is_match(b"/axx"),
5445            "{} leaves the first branch anchored at its start only",
5446            ungrouped.as_str(),
5447        );
5448        assert!(
5449            ungrouped.is_match(b"/xx/b"),
5450            "{} leaves the last branch anchored at its end only",
5451            ungrouped.as_str(),
5452        );
5453
5454        // Two branches cannot show what `doc/configure.md` actually claims —
5455        // "every branch of an alternation is anchored, NOT JUST THE FIRST AND
5456        // LAST" — because with two branches there is no other kind. A third
5457        // branch has one, and it is strictly worse than the half-anchored
5458        // ends above: ungrouped, the MIDDLE branch keeps NEITHER anchor, so
5459        // `/b` matches as a bare substring anywhere in the path. Control,
5460        // measured against the crate.
5461        let three = Regex::new("\\A/a|/b|/c\\z").expect("the ungrouped wrapper compiles");
5462        for anywhere in ["zzz/bzzz", "/xx/b/yy", "xx/byy"] {
5463            assert!(
5464                three.is_match(anywhere.as_bytes()),
5465                "{} leaves the MIDDLE branch unanchored at BOTH ends, so it \
5466                 matches {anywhere} anywhere in the path",
5467                three.as_str(),
5468            );
5469        }
5470
5471        // The grouped form the router actually builds refuses all three, and
5472        // still matches each branch whole. This half IS production.
5473        assert_regex_routing(&[
5474            ("/a|/b|/c", "/a", true),
5475            ("/a|/b|/c", "/b", true),
5476            ("/a|/b|/c", "/c", true),
5477            ("/a|/b|/c", "zzz/bzzz", false),
5478            ("/a|/b|/c", "/xx/b/yy", false),
5479            ("/a|/b|/c", "xx/byy", false),
5480            ("/a|/b|/c", "/axx", false),
5481            ("/a|/b|/c", "/xx/c", false),
5482        ]);
5483        assert_eq!(
5484            PathRule::anchored_regex("/a|/b|/c")
5485                .expect("a three-branch alternation must compile")
5486                .as_str(),
5487            "\\A(?:/a|/b|/c)\\z",
5488            "a middle branch needs the group as much as the outer two do",
5489        );
5490    }
5491
5492    /// Anchoring must not rescue a pattern the router rejects today. The
5493    /// wrapper supplies one `(` and one `)`, so an unbalanced value can be
5494    /// balanced BY the wrapping — `a)(b` is not a regex, yet `\A(?:a)(b)\z`
5495    /// is, and it matches `ab`. `PathRule::anchored_regex` therefore compiles
5496    /// the configured value on its own before wrapping it.
5497    ///
5498    /// This test covers that one direction. The converse is NOT guarded and
5499    /// is not asserted here: wrapping can reject a pattern that compiles
5500    /// bare, measured on `(?x)/api/v1 # v1 only` (the appended `)` lands in
5501    /// the `#` comment `(?x)` enables) and on a 249-deep nested group (the
5502    /// crate's 250-nesting limit). Both are refused at frontend registration
5503    /// as [`RouterError::InvalidPathRule`], so they cost a loud rejection and
5504    /// not a mis-route — see `PathRule::anchored_regex`.
5505    ///
5506    /// To SEE THIS RED: delete the `Regex::new(value).ok()?;` validation line
5507    /// in `PathRule::anchored_regex`. The `a)(b` assertion then fails — the
5508    /// rule builds, and matches `ab`.
5509    #[test]
5510    fn anchoring_never_turns_a_rejected_path_regex_into_an_accepted_one() {
5511        // The hazard is real, not hypothetical. Both patterns are assembled
5512        // at run time rather than written as literals because
5513        // `clippy::invalid_regex` rejects an invalid literal at a `Regex::new`
5514        // call site — and the invalidity is the whole point here, so the
5515        // literal is what moves, not the lint.
5516        let unbalanced = format!("a{}b", ")(");
5517        assert!(
5518            Regex::new(&unbalanced).is_err(),
5519            "{unbalanced:?} must be an invalid regex for this test to mean anything",
5520        );
5521        let wrapped = Regex::new(&format!("\\A(?:{unbalanced})\\z")).expect(
5522            "the wrapped form of the unbalanced pattern compiles — that balancing \
5523             is what the pre-validation exists to defeat",
5524        );
5525        assert!(
5526            wrapped.is_match(b"ab"),
5527            "{} matches `ab`, which the operator never wrote",
5528            wrapped.as_str(),
5529        );
5530
5531        for invalid in [unbalanced.as_str(), "(", ")", "[a-", "*"] {
5532            assert_eq!(
5533                PathRule::from_config(CommandPathRule::regex(invalid.to_owned())),
5534                None,
5535                "{invalid:?} must stay rejected once the value is anchored",
5536            );
5537        }
5538    }
5539
5540    /// Anchoring narrows `Route::Deny` exactly as it narrows a forward, and
5541    /// that is the dangerous edge of the change: a deny rule written loosely
5542    /// stops covering the family of paths it used to cover. `/admin` denied
5543    /// `/admin`, `/admin/secret` and `/administrator`; anchored it denies
5544    /// `/admin` alone and the rest fall through to whatever else matches.
5545    ///
5546    /// Both rules carry a matching `method`, so the deny is tier 1 in
5547    /// `doc/configure.md`'s precedence list and ends the scan when it matches
5548    /// — the fall-through below is the deny genuinely not matching, not the
5549    /// prefix outranking it.
5550    ///
5551    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5552    /// make the body `Regex::new(value).ok()`. Both fall-through assertions
5553    /// then fail with `left: None, right: Some("FALLBACK")`.
5554    #[test]
5555    fn anchoring_narrows_a_deny_path_regex_to_the_exact_path() {
5556        let mut router = Router::new();
5557        let deny = PathRule::from_config(CommandPathRule::regex("/admin".to_owned()))
5558            .expect("the deny regex path rule must build");
5559        assert!(router.add_tree_rule(
5560            b"www.example.com",
5561            &deny,
5562            &MethodRule::new(Some("GET".to_owned())),
5563            &Route::Deny,
5564        ));
5565        assert!(add_path_rule(
5566            &mut router,
5567            PathRule::Prefix("/".to_owned()),
5568            GET,
5569            "FALLBACK",
5570        ));
5571
5572        let resolve = |path: &str| {
5573            router
5574                .lookup("www.example.com", path, &Method::Get)
5575                .expect("every path below is covered by the `/` prefix rule")
5576        };
5577
5578        let denied = resolve("/admin");
5579        assert_eq!(
5580            denied.redirect,
5581            RedirectPolicy::Unauthorized,
5582            "the exact path the deny regex spells must still be denied",
5583        );
5584        assert_eq!(denied.cluster_id, None);
5585
5586        for still_reaching_the_backend in ["/admin/secret", "/administrator"] {
5587            let result = resolve(still_reaching_the_backend);
5588            assert_eq!(
5589                result.cluster_id.as_deref(),
5590                Some("FALLBACK"),
5591                "BREAKING: the anchored deny no longer covers \
5592                 {still_reaching_the_backend}, which now reaches the backend",
5593            );
5594            assert_eq!(result.redirect, RedirectPolicy::Forward);
5595        }
5596    }
5597
5598    /// `doc/configure.md`'s remediation table, rows one and two: a
5599    /// SUBSTRING-shaped or PREFIX-shaped pattern narrows to the exact path it
5600    /// spells, and a TRAILING `.*` is what gives it back — those two shapes
5601    /// are open at the END, so that is the side the wildcard goes on.
5602    ///
5603    /// `bc` against `/abcd` is sozu#1350's own example and is pinned whole in
5604    /// `a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path`;
5605    /// it is repeated here as the table's first row, beside its remediation.
5606    /// The table answers the prefix-shaped row twice, with `.*` or with
5607    /// `path_type = "PREFIX"`; only the regex answer is a regex claim, so only
5608    /// that one is asserted.
5609    ///
5610    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5611    /// make the body `Regex::new(value).ok()`. Every narrowing row below flips
5612    /// to a match; the first failure is `"bc"` against `"/abcd"`.
5613    #[test]
5614    fn a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells() {
5615        assert_regex_routing(&[
5616            // What the two shapes lose.
5617            ("bc", "/abcd", false),
5618            ("/ab", "/abcd", false),
5619            ("/api", "/api/v1", false),
5620            // …and what they keep: the path they spell whole.
5621            ("/api", "/api", true),
5622            // The table says `bc` now matches "only the path `bc`"; that is
5623            // two claims, and this is the half the narrowing rows do not
5624            // cover.
5625            ("bc", "bc", true),
5626            // The documented remediation, on the side each pattern is open on.
5627            (".*bc.*", "/abcd", true),
5628            ("/api.*", "/api/v1", true),
5629            ("/api.*", "/api", true),
5630        ]);
5631
5632        // The table's "used to match" column, measured. These go through the
5633        // BARE, unwrapped regex — the 2.2.1 code — so they are controls: no
5634        // mutation of `anchored_regex` can move them, and that is exactly
5635        // what makes them evidence for what the release took away. Without
5636        // them the whole first column is prose.
5637        for (pattern, used_to_match) in [("bc", "/abcd"), ("/api", "/api/v1")] {
5638            assert!(
5639                Regex::new(pattern)
5640                    .expect("the bare pattern compiles")
5641                    .is_match(used_to_match.as_bytes()),
5642                "up to 2.2.1 the unwrapped {pattern:?} matched {used_to_match:?} \
5643                 as a substring — that is the behaviour this release removes",
5644            );
5645        }
5646    }
5647
5648    /// **The row that breaks silently.** A SUFFIX-shaped pattern is open at
5649    /// the START, so it needs a LEADING `.*`; the trailing one that answers
5650    /// the substring and prefix shapes does nothing for it. Extension routing
5651    /// is plausibly the commonest real use of a path regex, and it does not
5652    /// narrow — it stops matching.
5653    ///
5654    /// Every verdict below is `doc/configure.md`'s third table row and the two
5655    /// paragraphs under it, measured.
5656    ///
5657    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5658    /// make the body `Regex::new(value).ok()`. `"\.js$"` against
5659    /// `"/static/app.js"` fails first, and every other `false` row above the
5660    /// leading-`.*` ones fails with it.
5661    #[test]
5662    fn a_suffix_shaped_path_regex_needs_a_leading_wildcard_not_a_trailing_one() {
5663        assert_regex_routing(&[
5664            // The three the documentation names, all silently dead.
5665            (r"\.js$", "/static/app.js", false),
5666            (r"\.(js|css)$", "/a/b/style.css", false),
5667            ("/v1$", "/api/v1", false),
5668            // `/v1$` is the less dramatic half: it still matches its own path.
5669            ("/v1$", "/v1", true),
5670            // The trailing `.*` that rescues the other two shapes does NOT
5671            // rescue this one.
5672            (r"\.js.*", "/static/app.js", false),
5673            ("/v1.*", "/api/v1", false),
5674            // The leading one does.
5675            (r".*\.js$", "/static/app.js", true),
5676            (r".*\.(js|css)$", "/a/b/style.css", true),
5677            (".*/v1$", "/api/v1", true),
5678        ]);
5679
5680        // "…matches nothing a client can send": an end-anchored pattern with
5681        // nothing in front of it can only match a path that IS its own
5682        // suffix, which for `\.js$` is the literal path `.js`. An origin-form
5683        // request-target always begins with `/`, so no client can send it.
5684        assert_regex_routing(&[(r"\.js$", ".js", true)]);
5685
5686        // The table's third "used to match" cell, measured against the BARE
5687        // regex of 2.2.1 — a control, like the two in
5688        // `a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells`.
5689        // This is the cell that matters most: it is the one whose loss is
5690        // silent.
5691        assert!(
5692            Regex::new(r"\.js$")
5693                .expect("the bare suffix pattern compiles")
5694                .is_match(b"/static/app.js"),
5695            "up to 2.2.1 the unwrapped `\\.js$` DID match `/static/app.js` — \
5696             losing that is the silent break this release documents",
5697        );
5698        for origin_form in ["/", "/.js", "/app.js", "/static/app.js"] {
5699            assert!(
5700                regex_rule_routes(r"\.js$", origin_form).is_none(),
5701                "an origin-form request-target begins with `/`, and {origin_form} \
5702                 must not match the end-anchored `\\.js$`",
5703            );
5704        }
5705    }
5706
5707    /// **The `$PATH[n]` regression test.** The anchoring group is `(?:` … `)`
5708    /// and not `(` … `)` precisely so it adds no capture. `Router::lookup`
5709    /// collects path captures as `caps.iter().skip(1)` and `Frontend::new`
5710    /// sizes the buffer from `PathRule::Regex(regex) => regex.captures_len()`,
5711    /// so a CAPTURING wrapper would bump that cap and shift every configured
5712    /// `$PATH[n]` by one: a frontend asking for its first group would start
5713    /// receiving the whole request path, and keep serving traffic while doing
5714    /// it. This is the one property here that corrupts silently rather than
5715    /// stops matching.
5716    ///
5717    /// Asserted three ways: `captures_len()` parity against the bare pattern
5718    /// for zero, one and two groups; end to end, that `rewrite_path` of
5719    /// `$PATH[1]` still receives the first group's text; and that the CAP
5720    /// itself did not grow, by pinning that a `$PATH[n]` one past the last
5721    /// group the operator wrote is still refused.
5722    ///
5723    /// To SEE THIS RED: make the wrapper capturing in
5724    /// `PathRule::anchored_regex` — `format!("\\A({value})\\z")`. The parity
5725    /// assertion fails first with `left: 2, right: 1` on the group-less
5726    /// pattern; with that loop deleted the rewrite yields `/api/v2/users`
5727    /// instead of `v2`, and the out-of-range `$PATH[2]` registers instead of
5728    /// being refused.
5729    #[test]
5730    fn the_anchoring_group_is_non_capturing_so_path_rewrite_indices_do_not_shift() {
5731        for pattern in ["/api/v2/.*", "/api/(v[0-9]+)/.*", "/api/(v[0-9]+)/(.*)"] {
5732            let bare = Regex::new(pattern).expect("the bare pattern must compile");
5733            let anchored =
5734                PathRule::anchored_regex(pattern).expect("the anchored pattern must compile");
5735            assert_eq!(
5736                anchored.captures_len(),
5737                bare.captures_len(),
5738                "anchoring {pattern:?} must not add a capture group",
5739            );
5740        }
5741
5742        let rewriting_frontend = |pattern: &str, rewrite: &str| {
5743            let mut front = test_http_frontend();
5744            front.hostname = "www.example.com".to_owned();
5745            front.path = CommandPathRule::regex(pattern.to_owned());
5746            front.rewrite_path = Some(rewrite.to_owned());
5747            front
5748        };
5749        let rewritten = |pattern: &str, rewrite: &str, path: &str| {
5750            let mut router = Router::new();
5751            router
5752                .add_http_front(&rewriting_frontend(pattern, rewrite))
5753                .expect("the rewriting frontend must register");
5754            router
5755                .lookup("www.example.com", path, &Method::Get)
5756                .expect("the request must route")
5757                .rewritten_path
5758        };
5759
5760        assert_eq!(
5761            rewritten("/api/(v[0-9]+)/.*", "$PATH[1]", "/api/v2/users").as_deref(),
5762            Some("v2"),
5763            "`$PATH[1]` must still be the FIRST group the operator wrote, not \
5764             the anchoring wrapper",
5765        );
5766        assert_eq!(
5767            rewritten("/api/(v[0-9]+)/(.*)", "/$PATH[2]", "/api/v2/users").as_deref(),
5768            Some("/users"),
5769            "`$PATH[2]` must still be the SECOND group the operator wrote",
5770        );
5771        assert_eq!(
5772            rewritten("/api/(v[0-9]+)/.*", "$PATH[0]", "/api/v2/users").as_deref(),
5773            Some("/api/v2/users"),
5774            "`$PATH[0]` stays the whole request path",
5775        );
5776
5777        let mut router = Router::new();
5778        assert!(
5779            matches!(
5780                router.add_http_front(&rewriting_frontend("/api/(v[0-9]+)/.*", "$PATH[2]")),
5781                Err(RouterError::InvalidPathRewrite(_)),
5782            ),
5783            "the capture CAP is `captures_len()` too: a `$PATH[n]` past the \
5784             last group the operator wrote must stay refused",
5785        );
5786    }
5787
5788    /// The pre-validation in `PathRule::anchored_regex` closes
5789    /// invalid-becomes-valid only. The opposite direction is real, documented
5790    /// and NOT closed: a pattern that compiles BARE can be refused once
5791    /// wrapped, because the appended `)\z` has to survive whatever the pattern
5792    /// left open.
5793    ///
5794    /// Both halves are asserted for each case — `Regex::new(value).is_ok()`
5795    /// AND `from_config(…).is_none()` — because the second alone would not
5796    /// show the wrapping is what rejected it.
5797    ///
5798    /// The `249` both `CHANGELOG.md` and `doc/configure.md` quote is measured
5799    /// here rather than derived from the crate's documented limit, and it is
5800    /// the SMALLEST depth with this property: on `regex` 1.13.1 depth 248
5801    /// still survives the wrapping, depth 249 does not, and depth 251 no
5802    /// longer compiles bare either.
5803    ///
5804    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5805    /// make the body `Regex::new(value).ok()`. Both `from_config` assertions
5806    /// fail, each rule building instead of being refused. (The `Regex::new`
5807    /// rows are controls measuring the crate directly and do not move.)
5808    #[test]
5809    fn a_path_regex_that_compiles_bare_can_be_refused_once_it_is_anchored() {
5810        // `(?x)` turns on extended mode, in which `#` starts a comment that
5811        // runs to end of line — and the wrapper's `)` lands inside it.
5812        let extended_mode_comment = "(?x)/api/v1 # v1 only".to_owned();
5813        let nested = |depth: usize| format!("{}{}", "(".repeat(depth), ")".repeat(depth));
5814
5815        for (value, expected_error) in [
5816            (extended_mode_comment, "unclosed group"),
5817            (
5818                nested(249),
5819                "exceed the maximum number of nested parentheses/brackets (250)",
5820            ),
5821        ] {
5822            assert!(
5823                Regex::new(&value).is_ok(),
5824                "this case only means something while the BARE pattern compiles",
5825            );
5826            let error = Regex::new(&format!("\\A(?:{value})\\z"))
5827                .expect_err("the WRAPPED pattern is the one that must fail");
5828            assert!(
5829                error.to_string().contains(expected_error),
5830                "the documentation quotes {expected_error:?}; the wrapping \
5831                 actually failed with: {error}",
5832            );
5833            assert_eq!(
5834                PathRule::from_config(CommandPathRule::regex(value)),
5835                None,
5836                "a value only the WRAPPING rejects must still be refused",
5837            );
5838        }
5839
5840        // The boundary itself, so the quoted 249 cannot drift unnoticed.
5841        assert!(
5842            Regex::new(&format!("\\A(?:{})\\z", nested(248))).is_ok(),
5843            "depth 248 must still survive the wrapping, or 249 is not the boundary",
5844        );
5845        assert!(
5846            Regex::new(&nested(250)).is_ok() && Regex::new(&nested(251)).is_err(),
5847            "the BARE limit must still sit between 250 and 251, or the wrapper \
5848             is no longer what makes depth 249 fail",
5849        );
5850    }
5851
5852    /// Every pattern `PathRule::from_config` refuses — bare-invalid or
5853    /// invalid-only-once-wrapped — surfaces at the public registration
5854    /// surface as [`RouterError::InvalidPathRule`]. That is the same
5855    /// `Router::add_http_front` the main process runs through
5856    /// `validate_frontend_request` (`bin/src/command/requests.rs`) before
5857    /// fanning a frontend out, which is what makes such a pattern a loudly
5858    /// rejected frontend and never a main/worker divergence.
5859    ///
5860    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5861    /// make the body `Regex::new(value).ok()`. The last two rows then
5862    /// register successfully. To redden the FIRST row instead, delete the
5863    /// `Regex::new(value).ok()?;` validation line: the wrapper balances
5864    /// `a)(b` and the frontend registers.
5865    #[test]
5866    fn a_refused_path_regex_surfaces_as_an_invalid_path_rule_at_registration() {
5867        // Assembled rather than written as a literal: `clippy::invalid_regex`
5868        // rejects an invalid literal at a `Regex::new` call site, and the
5869        // invalidity is the point.
5870        let unbalanced = format!("a{}b", ")(");
5871        for value in [
5872            unbalanced,
5873            "(?x)/api/v1 # v1 only".to_owned(),
5874            format!("{}{}", "(".repeat(249), ")".repeat(249)),
5875        ] {
5876            let mut router = Router::new();
5877            let mut front = test_http_frontend();
5878            front.path = CommandPathRule::regex(value.clone());
5879            assert!(
5880                matches!(
5881                    router.add_http_front(&front),
5882                    Err(RouterError::InvalidPathRule(_))
5883                ),
5884                "the path regex {value:?} must be refused as an invalid path rule",
5885            );
5886        }
5887    }
5888
5889    /// The FIRST of the two 401 shapes `doc/configure.md` names: a frontend
5890    /// with no `cluster_id`. It narrows exactly as the
5891    /// `redirect = "unauthorized"` shape does, which is the substance of
5892    /// "both answer 401 … through the very same `path`/`path_type` matching".
5893    ///
5894    /// Asserted through `Router::add_http_front`, because that is where the
5895    /// shape becomes a route and the claim is about the FRONTEND, not about
5896    /// `Route::Deny`. The two sub-shapes take different branches and both are
5897    /// covered here: a bare clusterless frontend sets no policy field, so
5898    /// `add_http_front`'s `has_policy` is false and it maps to `Route::Deny`
5899    /// directly; one that spells `redirect = "forward"` flips `has_policy`,
5900    /// runs `Frontend::new`, and is coerced to unauthorized there instead.
5901    /// Nothing in the anchoring cares which — that is the point.
5902    ///
5903    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5904    /// make the body `Regex::new(value).ok()`. The unanchored `/admin` covers
5905    /// the family again and both fall-through assertions fail with
5906    /// `left: None, right: Some("FALLBACK")`.
5907    #[test]
5908    fn a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule() {
5909        for spells_forward in [false, true] {
5910            let mut router = Router::new();
5911            let mut clusterless = test_http_frontend();
5912            clusterless.hostname = "www.example.com".to_owned();
5913            clusterless.cluster_id = None;
5914            clusterless.path = CommandPathRule::regex("/admin".to_owned());
5915            clusterless.method = Some("GET".to_owned());
5916            if spells_forward {
5917                clusterless.redirect = Some(RedirectPolicy::Forward as i32);
5918            }
5919            router
5920                .add_http_front(&clusterless)
5921                .expect("a clusterless frontend must register as a deny");
5922            assert!(add_path_rule(
5923                &mut router,
5924                PathRule::Prefix("/".to_owned()),
5925                GET,
5926                "FALLBACK",
5927            ));
5928
5929            let resolve = |path: &str| {
5930                router
5931                    .lookup("www.example.com", path, &Method::Get)
5932                    .expect("every path below is covered by the `/` prefix rule")
5933            };
5934
5935            let denied = resolve("/admin");
5936            assert_eq!(
5937                denied.redirect,
5938                RedirectPolicy::Unauthorized,
5939                "a clusterless frontend must still deny the path it spells \
5940                 (spells_forward={spells_forward})",
5941            );
5942            assert_eq!(denied.cluster_id, None);
5943
5944            for now_reaching_the_backend in ["/admin/secret", "/administrator"] {
5945                assert_eq!(
5946                    resolve(now_reaching_the_backend).cluster_id.as_deref(),
5947                    Some("FALLBACK"),
5948                    "BREAKING: the anchored clusterless deny no longer covers \
5949                     {now_reaching_the_backend} (spells_forward={spells_forward})",
5950                );
5951            }
5952        }
5953    }
5954
5955    /// The documented remediation for the deny narrowing: a deny-shaped
5956    /// `REGEX` meant as a subtree is widened with a trailing `.*` and covers
5957    /// the family again. It is a regex and not a path-segment rule, so
5958    /// `/admin.*` takes `/administrator` back with `/admin/secret` — widening
5959    /// is not surgical, and that is worth seeing.
5960    ///
5961    /// Declared through `Router::add_http_front` carrying
5962    /// `redirect = "unauthorized"`, which is the SECOND of the two 401 shapes
5963    /// `doc/configure.md` names. The first — a frontend with no `cluster_id` —
5964    /// is covered by
5965    /// `a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule`.
5966    /// `anchoring_narrows_a_deny_path_regex_to_the_exact_path` covers NEITHER
5967    /// frontend shape: it builds `Route::Deny` directly through
5968    /// `add_tree_rule`, which is the route those two shapes resolve TO, and
5969    /// says nothing about either resolving to it.
5970    ///
5971    /// To SEE THIS RED: the anchoring mutation does NOT redden this test, and
5972    /// cannot — `/admin.*` covers these paths anchored or not, which is
5973    /// exactly why it is the remediation. Break the wildcard instead: take the
5974    /// configured value literally in `PathRule::anchored_regex`, as
5975    /// `Regex::new(&format!("\\A(?:{})\\z", regex::escape(value)))`. Every
5976    /// denied path below then falls through to `FALLBACK`.
5977    #[test]
5978    fn widening_a_deny_path_regex_with_a_trailing_wildcard_covers_the_subtree_again() {
5979        let mut router = Router::new();
5980        let mut deny = test_http_frontend();
5981        deny.hostname = "www.example.com".to_owned();
5982        deny.cluster_id = Some("DENIED".into());
5983        deny.path = CommandPathRule::regex("/admin.*".to_owned());
5984        deny.method = Some("GET".to_owned());
5985        deny.redirect = Some(RedirectPolicy::Unauthorized as i32);
5986        router
5987            .add_http_front(&deny)
5988            .expect("the widened deny frontend must register");
5989        assert!(add_path_rule(
5990            &mut router,
5991            PathRule::Prefix("/".to_owned()),
5992            GET,
5993            "FALLBACK",
5994        ));
5995
5996        let resolve = |path: &str| {
5997            router
5998                .lookup("www.example.com", path, &Method::Get)
5999                .expect("every path below is covered by the `/` prefix rule")
6000        };
6001
6002        for denied in ["/admin", "/admin/secret", "/administrator"] {
6003            assert_eq!(
6004                resolve(denied).redirect,
6005                RedirectPolicy::Unauthorized,
6006                "the widened deny must cover {denied} again",
6007            );
6008        }
6009        assert_eq!(
6010            resolve("/other").cluster_id.as_deref(),
6011            Some("FALLBACK"),
6012            "widening the deny must not turn it into a catch-all",
6013        );
6014        assert_eq!(
6015            resolve("/other").redirect,
6016            RedirectPolicy::Forward,
6017            "a path outside the widened family must still be forwarded",
6018        );
6019    }
6020
6021    /// The matched request path is the request-target as it arrives, QUERY
6022    /// STRING INCLUDED, so anchoring bites a query string the way it already
6023    /// bit `path_type = "EQUALS"`. A trailing `.*` answers that only for a
6024    /// pattern open at the END: after a `$` it is dead text, because nothing
6025    /// can follow end-of-text. A suffix rule that must tolerate a query string
6026    /// has to spell it out.
6027    ///
6028    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
6029    /// make the body `Regex::new(value).ok()`. `"/abc"` against `"/abc?x=1"`
6030    /// fails first, and `".*\.js([?].*)?"` against `"/static/app.json"` fails
6031    /// with it, the optional group matching empty after a substring `.js`.
6032    #[test]
6033    fn an_anchored_path_regex_must_span_the_query_string_it_is_matched_against() {
6034        assert_regex_routing(&[
6035            ("/abc", "/abc", true),
6036            ("/abc", "/abc?x=1", false),
6037            // Appending `.*` after a `$` does nothing at all.
6038            ("^/abc$.*", "/abc", true),
6039            ("^/abc$.*", "/abc?x=1", false),
6040            // What a suffix rule surviving a query string has to look like.
6041            (r".*\.js([?].*)?", "/static/app.js", true),
6042            (r".*\.js([?].*)?", "/static/app.js?v=2", true),
6043            (r".*\.js([?].*)?", "/static/app.json", false),
6044        ]);
6045
6046        // "not a regression": the plain suffix rule did not match a
6047        // query-string request before the anchoring either. Measured against
6048        // the BARE, unwrapped regex — the 2.2.1 behaviour — so this row is a
6049        // control and does not move when `anchored_regex` does.
6050        assert!(
6051            !Regex::new(r"\.js$")
6052                .expect("the bare suffix pattern compiles")
6053                .is_match(b"/static/app.js?v=2"),
6054            "the unanchored `\\.js$` of 2.2.1 did not match a query-string \
6055             request either, so that half is not a regression",
6056        );
6057    }
6058
6059    /// Precedence between a method-less `REGEX` and a method-less `PREFIX`
6060    /// turns on whether the regex matches the request path AT ALL, and
6061    /// anchoring changes that once a query string is involved.
6062    ///
6063    /// `doc/configure.md`'s third table row is the `/a.*` case: `GET /abc`
6064    /// goes to the prefix, and `GET /abc?x=1` flips to the regex, because
6065    /// `/abc` no longer spans the request path while `/a.*` still does. With
6066    /// the regex `/abc`, which anchored does not match `/abc?x=1` at all, the
6067    /// flip does NOT happen and both requests go to the prefix — the prefix
6068    /// has merely lost its rival. The regex did not lose the contest, it
6069    /// stopped entering it: declared alone it leaves `GET /abc?x=1` unrouted.
6070    /// That distinction is stated in prose and is what these six cases pin.
6071    ///
6072    /// Both rules are method-less and the regex is declared FIRST, the exact
6073    /// shape the documented table measures; the regex is built through
6074    /// `PathRule::from_config`, so the anchoring is in play.
6075    ///
6076    /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
6077    /// make the body `Regex::new(value).ok()`. The unanchored `/abc` matches
6078    /// `/abc?x=1` as a substring and becomes a candidate again: the
6079    /// both-go-to-the-prefix case fails with
6080    /// `left: Some("REGEX"), right: Some("PREFIX")` and the declared-alone
6081    /// case fails with `left: Some("REGEX"), right: None`.
6082    #[test]
6083    fn an_anchored_regex_that_cannot_span_the_query_string_stops_being_a_candidate() {
6084        let configured = |pattern: &str| {
6085            PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
6086                .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"))
6087        };
6088        let against_the_whole_path_prefix = |pattern: &str, path: &str| {
6089            winner_of(
6090                (configured(pattern), NO_METHOD, "REGEX"),
6091                (PathRule::Prefix("/abc".to_owned()), NO_METHOD, "PREFIX"),
6092                path,
6093            )
6094        };
6095
6096        // A regex that spans the query string: the documented flip, unchanged.
6097        assert_eq!(
6098            against_the_whole_path_prefix("/a.*", "/abc").as_deref(),
6099            Some("PREFIX"),
6100            "the whole-path prefix declared after the regex must still win",
6101        );
6102        assert_eq!(
6103            against_the_whole_path_prefix("/a.*", "/abc?x=1").as_deref(),
6104            Some("REGEX"),
6105            "with a query string `/abc` no longer spans the request path, so \
6106             a regex that does wins",
6107        );
6108
6109        // A regex that does not span it: the flip disappears entirely.
6110        assert_eq!(
6111            against_the_whole_path_prefix("/abc", "/abc").as_deref(),
6112            Some("PREFIX"),
6113            "the prefix wins here exactly as it does against `/a.*`",
6114        );
6115        assert_eq!(
6116            against_the_whole_path_prefix("/abc", "/abc?x=1").as_deref(),
6117            Some("PREFIX"),
6118            "an anchored `/abc` cannot match `/abc?x=1`, so adding a query \
6119             string changes nothing and the prefix keeps the request",
6120        );
6121
6122        // …and the reason is candidacy, not defeat.
6123        assert_eq!(
6124            regex_rule_routes("/abc", "/abc").as_deref(),
6125            Some("REGEX"),
6126            "declared alone, the regex answers the request it spells",
6127        );
6128        assert_eq!(
6129            regex_rule_routes("/abc", "/abc?x=1"),
6130            None,
6131            "declared alone, it leaves the query-string request UNROUTED — it \
6132             is not a candidate any more",
6133        );
6134    }
6135
6136    /// The `REGEX_PATH` twin of [`regex_rule_routes`]: one path-only regex
6137    /// rule on `www.example.com`, then a `GET` for `path`.
6138    fn regex_path_rule_routes(pattern: &str, path: &str) -> Option<String> {
6139        let rule = PathRule::from_config(CommandPathRule::regex_path(pattern.to_owned()))
6140            .unwrap_or_else(|| panic!("the path-only regex rule {pattern:?} must build"));
6141        assert!(
6142            matches!(rule, PathRule::RegexPath(_)),
6143            "a REGEX_PATH config rule must build a RegexPath router rule",
6144        );
6145        let mut router = Router::new();
6146        assert!(add_path_rule(&mut router, rule, GET, "REGEX_PATH"));
6147        routed_cluster(&router, path)
6148    }
6149
6150    /// `path_type = "REGEX_PATH"` matches the path up to its first `?` and
6151    /// never the query string (sozu-proxy/sozu#1909), while `REGEX` on the
6152    /// very same pattern and request keeps matching the path WITH its query.
6153    /// Each row names the `REGEX` verdict beside the `REGEX_PATH` one, so the
6154    /// table is also the proof that `REGEX` did not change.
6155    ///
6156    /// To SEE THIS RED: in `PathRule::matches`, make the `RegexPath` arm test
6157    /// `path` instead of the `split_query` path half — every row whose two
6158    /// verdicts differ flips.
6159    #[test]
6160    fn a_path_only_regex_ignores_the_query_string_and_regex_still_spans_it() {
6161        // (pattern, request path, REGEX routes, REGEX_PATH routes)
6162        let cases: &[(&str, &str, bool, bool)] = &[
6163            // The issue's two examples.
6164            ("/api/.+", "/api/?x=1", true, false),
6165            ("^/api/v2$", "/api/v2?x=1", false, true),
6166            // A path-only match ignores whatever query follows.
6167            ("/api/.+", "/api/x?y", true, true),
6168            ("/api/.+", "/api/x", true, true),
6169            ("/api/.+", "/api/", false, false),
6170            // An empty query still splits at its `?`.
6171            ("^/api/v2$", "/api/v2?", false, true),
6172            // Only the FIRST `?` splits: the rest is query, `?` included.
6173            ("/a", "/a?b?c", false, true),
6174            // A pattern spelling a query can never match path-only.
6175            ("/a[?]x=1", "/a?x=1", true, false),
6176            // Anchoring is unchanged: still the WHOLE path half.
6177            ("/ab", "/abc?x=1", false, false),
6178            ("bc", "/abc", false, false),
6179        ];
6180        for &(pattern, path, regex_routes, regex_path_routes) in cases {
6181            assert_eq!(
6182                regex_rule_routes(pattern, path).is_some(),
6183                regex_routes,
6184                "REGEX {pattern:?} against {path:?} must keep its historical verdict",
6185            );
6186            assert_eq!(
6187                regex_path_rule_routes(pattern, path).is_some(),
6188                regex_path_routes,
6189                "REGEX_PATH {pattern:?} against {path:?}",
6190            );
6191        }
6192    }
6193
6194    /// `PREFIX` and `EQUALS` still see the request path WITH its query string;
6195    /// adding the path-only kind did not move them (sozu-proxy/sozu#1909).
6196    #[test]
6197    fn prefix_and_equals_still_match_against_the_query_string() {
6198        let routes = |rule: CommandPathRule, path: &str| {
6199            let rule = PathRule::from_config(rule).expect("the path rule must build");
6200            let mut router = Router::new();
6201            assert!(add_path_rule(&mut router, rule, GET, "RULE"));
6202            routed_cluster(&router, path).is_some()
6203        };
6204        assert!(!routes(CommandPathRule::equals("/abc"), "/abc?x=1"));
6205        assert!(routes(CommandPathRule::equals("/abc?x=1"), "/abc?x=1"));
6206        assert!(routes(CommandPathRule::prefix("/abc?x"), "/abc?x=1"));
6207        assert!(!routes(CommandPathRule::prefix("/abc?y"), "/abc?x=1"));
6208    }
6209
6210    /// A `REGEX` and a `REGEX_PATH` rule on the same pattern are different
6211    /// rules: they compare unequal, and both register on one leaf.
6212    #[test]
6213    fn a_path_only_regex_is_a_distinct_rule_from_the_same_regex() {
6214        let regex = PathRule::from_config(CommandPathRule::regex("/a.*")).expect("REGEX builds");
6215        let regex_path =
6216            PathRule::from_config(CommandPathRule::regex_path("/a.*")).expect("REGEX_PATH builds");
6217        assert_ne!(regex, regex_path);
6218        assert_eq!(regex_path, regex_path.clone());
6219        assert!(
6220            PathRule::from_config(CommandPathRule::regex_path("a)(b")).is_none(),
6221            "a pattern refused bare stays refused for the path-only kind too",
6222        );
6223    }
6224
6225    /// `rewrite_path` on a `REGEX_PATH` frontend: `$PATH[n]`, index 0
6226    /// included, comes from the path-only match, and the request's original
6227    /// `?query` is appended unless the rewritten path carries a `?` of its own
6228    /// (sozu-proxy/sozu#1909). A `REGEX` frontend is untouched: its captures
6229    /// still span the query, and nothing is appended. A permanent redirect
6230    /// builds its `Location` from the same `rewritten_path`, so it follows.
6231    ///
6232    /// To SEE THIS RED: drop the `append_original_query` call in
6233    /// `RouteResult::from_frontend` — the appended-query assertions fail.
6234    #[test]
6235    fn a_path_only_regex_rewrite_captures_the_path_and_carries_the_query_over() {
6236        let rewritten =
6237            |path_rule: CommandPathRule, rewrite: &str, redirect: RedirectPolicy, path: &str| {
6238                let mut front = test_http_frontend();
6239                front.hostname = "www.example.com".to_owned();
6240                front.path = path_rule;
6241                front.rewrite_path = Some(rewrite.to_owned());
6242                front.redirect = Some(redirect as i32);
6243                let mut router = Router::new();
6244                router
6245                    .add_http_front(&front)
6246                    .expect("the rewriting frontend must register");
6247                router
6248                    .lookup("www.example.com", path, &Method::Get)
6249                    .expect("the request must route")
6250                    .rewritten_path
6251            };
6252        let regex_path = || CommandPathRule::regex_path("/api/(v[0-9]+)/(.*)");
6253        for redirect in [RedirectPolicy::Forward, RedirectPolicy::Permanent] {
6254            assert_eq!(
6255                rewritten(
6256                    regex_path(),
6257                    "/$PATH[1]/$PATH[2]",
6258                    redirect,
6259                    "/api/v2/users?id=7"
6260                )
6261                .as_deref(),
6262                Some("/v2/users?id=7"),
6263                "{redirect:?}: captures stop at `?`, and the query is appended",
6264            );
6265            assert_eq!(
6266                rewritten(regex_path(), "$PATH[0]", redirect, "/api/v2/users?id=7").as_deref(),
6267                Some("/api/v2/users?id=7"),
6268                "{redirect:?}: `$PATH[0]` is the path half, and the query follows it once",
6269            );
6270            assert_eq!(
6271                rewritten(regex_path(), "/$PATH[2]", redirect, "/api/v2/users").as_deref(),
6272                Some("/users"),
6273                "{redirect:?}: without a query nothing is appended",
6274            );
6275            assert_eq!(
6276                rewritten(regex_path(), "/$PATH[2]", redirect, "/api/v2/users?").as_deref(),
6277                Some("/users?"),
6278                "{redirect:?}: an empty query is carried over as written",
6279            );
6280            assert_eq!(
6281                rewritten(
6282                    regex_path(),
6283                    "/$PATH[2]?src=v",
6284                    redirect,
6285                    "/api/v2/users?id=7"
6286                )
6287                .as_deref(),
6288                Some("/users?src=v"),
6289                "{redirect:?}: a rewritten path with a query of its own keeps it alone",
6290            );
6291            // `REGEX` unchanged: its last group swallows the query, nothing is
6292            // appended, so the query appears exactly once.
6293            assert_eq!(
6294                rewritten(
6295                    CommandPathRule::regex("/api/(v[0-9]+)/(.*)"),
6296                    "/$PATH[2]",
6297                    redirect,
6298                    "/api/v2/users?id=7",
6299                )
6300                .as_deref(),
6301                Some("/users?id=7"),
6302                "{redirect:?}: a REGEX capture still spans the query string",
6303            );
6304            assert_eq!(
6305                rewritten(
6306                    CommandPathRule::regex("/api/(v[0-9]+)/(.*)"),
6307                    "/$PATH[1]",
6308                    redirect,
6309                    "/api/v2/users?id=7",
6310                )
6311                .as_deref(),
6312                Some("/v2"),
6313                "{redirect:?}: a REGEX rewrite gains no query it did not capture",
6314            );
6315        }
6316    }
6317
6318    /// Every branch of an alternation in a regex hostname SEGMENT is
6319    /// anchored, not just the first and the last.
6320    ///
6321    /// This test is the INVERSION of the test formerly named
6322    /// `an_alternating_regex_hostname_segment_is_still_anchored_at_one_end_only`
6323    /// — a name no longer in the tree, this one having replaced it —
6324    /// which pinned the defect so that closing it would be "a deliberate
6325    /// change, not a silent one" and asked, in its own comment, to be
6326    /// inverted rather than deleted when the fix landed. This is that
6327    /// deliberate change (sozu#1356), and the old name is kept here so the
6328    /// behaviour change leaves a trace.
6329    ///
6330    /// `pattern_trie.rs`'s `anchored_segment` now builds
6331    /// `\A(?:{s})\z`. Ungrouped, `|` binds looser than concatenation, so
6332    /// `\Aa|b\z` parsed as `(\Aa)|(b\z)`: a label merely STARTING with `a`
6333    /// or merely ENDING with `b` reached the frontend.
6334    ///
6335    /// Two branches cannot show what this test claims, because with two
6336    /// branches "first and last" is every branch there is. The three-branch
6337    /// case below has a MIDDLE branch, which ungrouped keeps NEITHER anchor
6338    /// and matches as a bare substring anywhere in the label — strictly
6339    /// worse than the half-anchored ends.
6340    ///
6341    /// To SEE THIS RED: drop the group in `pattern_trie.rs`'s
6342    /// `anchored_segment` — build `format!("\\A{segment}\\z")`, the form
6343    /// this tree carried up to 2.2.1, and relax the `debug_assert!` beside it,
6344    /// which in a debug build fires first. `a.example.com` still resolves,
6345    /// and the `axx.example.com` assertion fails with
6346    /// `left: Some("ALTERNATION"), right: None`.
6347    #[test]
6348    fn an_alternating_regex_hostname_segment_is_anchored_on_every_branch() {
6349        let resolve_with = |pattern: &[u8], hostname: &str| {
6350            let mut router = Router::new();
6351            assert!(router.add_tree_rule(
6352                pattern,
6353                &PathRule::Prefix("/".to_owned()),
6354                &MethodRule::new(Some("GET".to_owned())),
6355                &Route::ClusterId("ALTERNATION".into()),
6356            ));
6357            router
6358                .lookup(hostname, "/", &Method::Get)
6359                .ok()
6360                .and_then(|result| result.cluster_id)
6361        };
6362
6363        // Two branches: each one keeps BOTH anchors now.
6364        assert_eq!(
6365            resolve_with(b"/a|b/.example.com", "a.example.com").as_deref(),
6366            Some("ALTERNATION"),
6367            "the segment must still match what it was written for",
6368        );
6369        assert_eq!(
6370            resolve_with(b"/a|b/.example.com", "b.example.com").as_deref(),
6371            Some("ALTERNATION"),
6372            "and the other branch too",
6373        );
6374        assert_eq!(
6375            resolve_with(b"/a|b/.example.com", "axx.example.com"),
6376            None,
6377            "the first branch must be anchored at its END too",
6378        );
6379        assert_eq!(
6380            resolve_with(b"/a|b/.example.com", "xxb.example.com"),
6381            None,
6382            "the last branch must be anchored at its START too",
6383        );
6384        assert_eq!(
6385            resolve_with(b"/a|b/.example.com", "xx.example.com"),
6386            None,
6387            "a label matching neither branch must still miss",
6388        );
6389
6390        // Three branches. `zzbzz` is the case two branches cannot express:
6391        // ungrouped, the MIDDLE branch carries no anchor at either end, so
6392        // the bare label `b` matches in the middle of any label at all.
6393        for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6394            assert_eq!(
6395                resolve_with(b"/a|b|c/.example.com", whole).as_deref(),
6396                Some("ALTERNATION"),
6397                "{whole} is a whole-label match for one branch and must route",
6398            );
6399        }
6400        for leak in [
6401            "axx.example.com",
6402            "xxc.example.com",
6403            "zzbzz.example.com",
6404            "bzz.example.com",
6405            "zzb.example.com",
6406        ] {
6407            assert_eq!(
6408                resolve_with(b"/a|b|c/.example.com", leak),
6409                None,
6410                "{leak} matches no branch WHOLE and must not route",
6411            );
6412        }
6413
6414        // The control for all of the above, built by hand against the
6415        // `regex` crate rather than through the trie: the UNGROUPED wrapper
6416        // — the form this tree carried up to 2.2.1 — leaves the middle
6417        // branch anchored at neither end. No mutation of `anchored_segment`
6418        // can move this assertion, which is the point: it measures why the
6419        // group is there.
6420        let two = Regex::new("\\Aa|b\\z").expect("the ungrouped wrapper compiles");
6421        for anywhere in ["axx", "xxb"] {
6422            assert!(
6423                two.is_match(anywhere.as_bytes()),
6424                "{} leaks on {anywhere}",
6425                two.as_str(),
6426            );
6427        }
6428        let ungrouped = Regex::new("\\Aa|b|c\\z").expect("the ungrouped wrapper compiles");
6429        for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6430            assert!(
6431                ungrouped.is_match(anywhere.as_bytes()),
6432                "{} leaks on {anywhere}",
6433                ungrouped.as_str(),
6434            );
6435        }
6436        let grouped = Regex::new("\\A(?:a|b|c)\\z").expect("the grouped wrapper compiles");
6437        for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6438            assert!(
6439                !grouped.is_match(anywhere.as_bytes()),
6440                "{} must refuse {anywhere}",
6441                grouped.as_str(),
6442            );
6443        }
6444    }
6445
6446    /// Declare one regex hostname frontend the way a configured frontend
6447    /// declares it — through `Router::add_http_front`, so the hostname goes
6448    /// through `DomainRule::from_str` and `convert_regex_domain_rule` — and
6449    /// resolve `hostname`.
6450    ///
6451    /// `position` picks the code path, and the two are genuinely different
6452    /// code: `Pre`/`Post` match the WHOLE host against the single regex
6453    /// `convert_regex_domain_rule` assembles, while `Tree` matches label by
6454    /// label against `pattern_trie`'s per-segment regexes.
6455    fn regex_host_routes(position: RulePosition, pattern: &str, hostname: &str) -> Option<String> {
6456        let mut front = test_http_frontend();
6457        front.hostname = pattern.to_owned();
6458        front.position = position;
6459        front.cluster_id = Some("REGEX-HOST".into());
6460        let mut router = Router::new();
6461        router
6462            .add_http_front(&front)
6463            .unwrap_or_else(|error| panic!("the regex hostname {pattern:?} must build: {error}"));
6464        router
6465            .lookup(hostname, "/", &Method::Get)
6466            .ok()
6467            .and_then(|result| result.cluster_id)
6468            .map(|cluster_id| cluster_id.to_string())
6469    }
6470
6471    /// The companion of
6472    /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`
6473    /// for the OTHER hostname code path: a pre/post rule never touches the
6474    /// trie, it matches the whole host against the single regex
6475    /// `convert_regex_domain_rule` assembles, and that assembly carried the
6476    /// same ungrouped `\A` … `\z` (sozu#1356).
6477    ///
6478    /// The exact assembled pattern is asserted, so "grouped" is pinned and
6479    /// not merely implied by the routing verdicts.
6480    ///
6481    /// Two branches cannot show what this test claims — with two branches
6482    /// "first and last" is every branch. The three-branch case has a MIDDLE
6483    /// branch, which ungrouped keeps NEITHER anchor: it is a bare `b`,
6484    /// matching any hostname on earth that contains a `b`, including one on
6485    /// a different registrable domain entirely.
6486    ///
6487    /// To SEE THIS RED: drop the group in `convert_regex_domain_rule` —
6488    /// replace the `push_str`/`push` calls in the `/` arm with the single
6489    /// `result.push_str(r)` this tree carried up to 2.2.1. The
6490    /// `axx.example.com` row then fails with
6491    /// `left: Some("REGEX-HOST"), right: None`, and the exact-pattern
6492    /// assertion below it with
6493    /// `left: "\\Aa|b|c\\.example\\.com\\z", right: "\\A(?:a|b|c)\\.example\\.com\\z"`.
6494    #[test]
6495    fn every_branch_of_an_alternating_regex_hostname_rule_is_anchored() {
6496        for position in [RulePosition::Pre, RulePosition::Post] {
6497            for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6498                assert_eq!(
6499                    regex_host_routes(position, "/a|b|c/.example.com", whole).as_deref(),
6500                    Some("REGEX-HOST"),
6501                    "{position:?}: {whole} is a whole-host match for one branch and must route",
6502                );
6503            }
6504            for leak in [
6505                "axx.example.com",
6506                "xxc.example.com",
6507                "zzbzz.example.com",
6508                "b.example.com.evil.org",
6509                "zzbzz.evil.org",
6510            ] {
6511                assert_eq!(
6512                    regex_host_routes(position, "/a|b|c/.example.com", leak),
6513                    None,
6514                    "{position:?}: {leak} matches no branch WHOLE and must not route",
6515                );
6516            }
6517        }
6518
6519        // The exact assembled pattern, so "grouped" is asserted and not
6520        // merely implied by the routing verdicts above.
6521        assert_eq!(
6522            convert_regex_domain_rule("/a|b|c/.example.com")
6523                .expect("an alternating hostname segment must convert"),
6524            "\\A(?:a|b|c)\\.example\\.com\\z",
6525            "each regex segment must be wrapped in a NON-capturing group",
6526        );
6527
6528        // The control, built by hand against the `regex` crate rather than
6529        // through `convert_regex_domain_rule`: the UNGROUPED assembly — the
6530        // form this tree carried up to 2.2.1 — parses as
6531        // `(\Aa)|(b)|(c\.example\.com\z)`. The middle branch is a bare `b`,
6532        // so it matches a host on an unrelated registrable domain. No
6533        // mutation of `convert_regex_domain_rule` can move these
6534        // assertions, which is the point: they measure why the group is
6535        // there.
6536        for (assembly, leaks) in [
6537            (
6538                "\\Aa|b|c\\.example\\.com\\z",
6539                &[
6540                    "axx.example.com",
6541                    "zzbzz.example.com",
6542                    "b.example.com.evil.org",
6543                    "zzbzz.evil.org",
6544                ][..],
6545            ),
6546            (
6547                "\\Aapi|admin\\.example\\.com\\z",
6548                &["apifoo.example.com", "xadmin.example.com"][..],
6549            ),
6550        ] {
6551            let ungrouped = Regex::new(assembly).expect("the ungrouped assembly compiles");
6552            for anywhere in leaks {
6553                assert!(
6554                    ungrouped.is_match(anywhere.as_bytes()),
6555                    "{} leaks on {anywhere}",
6556                    ungrouped.as_str(),
6557                );
6558            }
6559        }
6560
6561        // `doc/configure.md`'s own worked example, asserted here because it
6562        // is written there as a measurement: the frontend
6563        // `/api|admin/.example.com` used to also serve `apifoo.example.com`
6564        // and `xadmin.example.com`, and the remediation the same paragraph
6565        // gives has to keep working.
6566        for (pattern, hostname, must_route) in [
6567            ("/api|admin/.example.com", "api.example.com", true),
6568            ("/api|admin/.example.com", "admin.example.com", true),
6569            ("/api|admin/.example.com", "apifoo.example.com", false),
6570            ("/api|admin/.example.com", "xadmin.example.com", false),
6571            ("/api.*|.*admin/.example.com", "api.example.com", true),
6572            ("/api.*|.*admin/.example.com", "admin.example.com", true),
6573            ("/api.*|.*admin/.example.com", "apifoo.example.com", true),
6574            ("/api.*|.*admin/.example.com", "xadmin.example.com", true),
6575        ] {
6576            assert_eq!(
6577                regex_host_routes(RulePosition::Pre, pattern, hostname).is_some(),
6578                must_route,
6579                "hostname regex {pattern:?} against {hostname:?}",
6580            );
6581        }
6582
6583        // `captures_len` must be untouched by the wrapping — see
6584        // `a_host_capture_rewrite_resolves_to_the_operators_own_group` for
6585        // the consequence when it is not.
6586        let compiled = |pattern: &str| {
6587            Regex::new(&convert_regex_domain_rule(pattern).expect("the hostname must convert"))
6588                .expect("the assembled hostname regex must compile")
6589        };
6590        assert_eq!(
6591            compiled("/a|b|c/.example.com").captures_len(),
6592            1,
6593            "a pattern with no operator group must stay at the implicit group 0 alone",
6594        );
6595        assert_eq!(
6596            compiled("/cdn([0-9]+)/.example.com").captures_len(),
6597            2,
6598            "the operator's own group must stay at index 1",
6599        );
6600    }
6601
6602    /// A `*` label in a hostname that ALSO carries a regex segment means two
6603    /// different things depending on the rule position, and
6604    /// `doc/configure.md` § "Regex hostname segments" now carries the table
6605    /// this test asserts.
6606    ///
6607    /// On the trie a `*` label is the ordinary single-label wildcard: the
6608    /// trie splits on `.` and `TrieNode` stores it in its `wildcard` slot,
6609    /// untouched by anything here. On `Pre`/`Post` the whole host is matched
6610    /// by the single pattern `convert_regex_domain_rule` assembles, where the
6611    /// `*` is now a LITERAL — the sozu#1356 escaping. Up to and including
6612    /// 2.2.1 it was neither: an unescaped `*` was a regex quantifier over the
6613    /// preceding atom, which for a leftmost label is the opening `\A`, so the
6614    /// pattern was not anchored at its start and took any host ending in
6615    /// `.x.example.com` at any depth.
6616    ///
6617    /// The remediation the section gives for `Pre`/`Post` is asserted too,
6618    /// because a documented remediation that does not work is worse than
6619    /// none.
6620    ///
6621    /// To SEE THIS RED: drop the escaping in the literal arm of
6622    /// `convert_regex_domain_rule` — push `r` instead of `&regex::escape(r)`
6623    /// at both exits. The `Pre` row for `zz.x.example.com` then routes and
6624    /// fails with `left: true, right: false`. (That mutation also reddens
6625    /// `a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern` and
6626    /// `convert_regex`.)
6627    #[test]
6628    fn a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post() {
6629        // The table in `doc/configure.md`, both rows, all three positions.
6630        for (position, host, must_route) in [
6631            (RulePosition::Tree, "zz.x.example.com", true),
6632            (RulePosition::Tree, "*.x.example.com", true),
6633            (RulePosition::Pre, "zz.x.example.com", false),
6634            (RulePosition::Pre, "*.x.example.com", true),
6635            (RulePosition::Post, "zz.x.example.com", false),
6636            (RulePosition::Post, "*.x.example.com", true),
6637        ] {
6638            assert_eq!(
6639                regex_host_routes(position, "*./x/.example.com", host).is_some(),
6640                must_route,
6641                "{position:?}: `*./x/.example.com` against {host:?}",
6642            );
6643        }
6644
6645        // Common to every position, and the reason the trie row is a
6646        // wildcard and not a free-for-all: `*` is ONE label.
6647        for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
6648            for miss in ["x.example.com", "a.b.x.example.com"] {
6649                assert_eq!(
6650                    regex_host_routes(position, "*./x/.example.com", miss),
6651                    None,
6652                    "{position:?}: a `*` label is exactly one label, so {miss:?} misses",
6653                );
6654            }
6655        }
6656
6657        // What 2.2.1 did on `Pre`/`Post`, built by hand against the crate:
6658        // `\A*` repeats a zero-width assertion, so the pattern was not
6659        // anchored at its start. No mutation of the escaping can move this.
6660        let quantified =
6661            Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6662        for anywhere in [
6663            "evil.attacker.x.example.com",
6664            "anything.at.all.x.example.com",
6665            "zzz.x.example.com",
6666        ] {
6667            assert!(
6668                quantified.is_match(anywhere.as_bytes()),
6669                "{} took {anywhere} at any depth",
6670                quantified.as_str(),
6671            );
6672        }
6673        assert!(
6674            !quantified.is_match(b"x.example.com"),
6675            "{} still required a leading label",
6676            quantified.as_str(),
6677        );
6678
6679        // The remediation `doc/configure.md` gives for `Pre`/`Post`: a regex
6680        // segment spelling the single label out.
6681        for position in [RulePosition::Pre, RulePosition::Post] {
6682            for (host, must_route) in [
6683                ("zz.x.example.com", true),
6684                ("*.x.example.com", true),
6685                ("a.b.x.example.com", false),
6686                ("x.example.com", false),
6687            ] {
6688                assert_eq!(
6689                    regex_host_routes(position, "/[^.]*/./x/.example.com", host).is_some(),
6690                    must_route,
6691                    "{position:?}: the documented `Pre`/`Post` remediation against {host:?}",
6692                );
6693            }
6694        }
6695    }
6696
6697    /// sozu#633: `hostname = "*"` with `position = "POST"` is the catch-all
6698    /// `doc/configure.md` documents — `DomainRule::Any`, which matches every
6699    /// host, an IP literal or a single-label name included — while the same
6700    /// `*` on the trie (the default position) is the one-label wildcard and
6701    /// matches only a single-label host. The last block pins that a `Post`
6702    /// rule is consulted only once the trie matched nothing.
6703    ///
6704    /// To SEE THIS RED: in `DomainRule::from_str`, change the `s == "*"`
6705    /// test to `s == "**"`. A bare `*` then falls through to the literal
6706    /// arm, and the `127.0.0.1` row fails with `left: None, right:
6707    /// Some("REGEX-HOST")`.
6708    #[test]
6709    fn a_bare_star_hostname_is_a_catch_all_on_post_and_one_label_on_the_trie() {
6710        for host in [
6711            "127.0.0.1",
6712            "localhost",
6713            "penguin.linux.test",
6714            "10.0.0.7",
6715            "www.example.com",
6716        ] {
6717            assert_eq!(
6718                regex_host_routes(RulePosition::Post, "*", host).as_deref(),
6719                Some("REGEX-HOST"),
6720                "Post: `*` is the catch-all and must route {host:?}",
6721            );
6722        }
6723
6724        for (host, must_route) in [
6725            ("localhost", true),
6726            ("127.0.0.1", false),
6727            ("penguin.linux.test", false),
6728            ("www.example.com", false),
6729        ] {
6730            assert_eq!(
6731                regex_host_routes(RulePosition::Tree, "*", host).is_some(),
6732                must_route,
6733                "Tree: `*` is exactly one label, against {host:?}",
6734            );
6735        }
6736
6737        let mut router = Router::new();
6738        for (hostname, position, cluster_id) in [
6739            ("*", RulePosition::Post, "CATCH-ALL"),
6740            ("www.example.com", RulePosition::Tree, "EXAMPLE"),
6741        ] {
6742            let mut front = test_http_frontend();
6743            front.hostname = hostname.to_owned();
6744            front.position = position;
6745            front.cluster_id = Some(cluster_id.into());
6746            router
6747                .add_http_front(&front)
6748                .unwrap_or_else(|error| panic!("{hostname:?} must build: {error}"));
6749        }
6750        for (host, expected) in [
6751            ("www.example.com", "EXAMPLE"),
6752            ("127.0.0.1", "CATCH-ALL"),
6753            ("api.example.com", "CATCH-ALL"),
6754        ] {
6755            assert_eq!(
6756                router
6757                    .lookup(host, "/", &Method::Get)
6758                    .ok()
6759                    .and_then(|result| result.cluster_id)
6760                    .as_deref(),
6761                Some(expected),
6762                "a `Post` catch-all answers only what the trie did not: {host:?}",
6763            );
6764        }
6765    }
6766
6767    /// A `.*` inside a regex segment does not stop at the label boundary, and
6768    /// where it stops depends on the rule POSITION — the two hostname paths
6769    /// genuinely differ here. A trie rule matches each segment against one
6770    /// label, so `.*` cannot leave it. A `Pre`/`Post` rule is matched by the
6771    /// single whole-host pattern `convert_regex_domain_rule` assembles, in
6772    /// which `.` matches the `.` separator like any other character.
6773    ///
6774    /// Pre-existing and NOT introduced by the anchoring, but
6775    /// `doc/configure.md` walks operators into it: the remediation it gives
6776    /// for the narrowing is exactly a `.*`. The section now names the
6777    /// divergence and recommends `[^.]*` on `Pre`/`Post`, so all three
6778    /// measurements it makes are pinned here.
6779    ///
6780    /// To SEE THIS RED: escape the REGEX-segment arm of
6781    /// `convert_regex_domain_rule` as well as the literal arm — push
6782    /// `&regex::escape(r)` in the `/` arm too. Every `Pre` row then goes
6783    /// false, starting with `api.example.com`, because the segment stops
6784    /// being a pattern at all.
6785    #[test]
6786    fn a_wildcard_in_a_hostname_segment_crosses_labels_on_pre_and_post_only() {
6787        // Trie: one segment, one label. `.*` cannot escape the label.
6788        for (hostname, must_route) in [
6789            ("api.example.com", true),
6790            ("admin.example.com", true),
6791            ("apifoo.example.com", true),
6792            ("api.foo.example.com", false),
6793            ("zz.admin.example.com", false),
6794        ] {
6795            assert_eq!(
6796                regex_host_routes(RulePosition::Tree, "/api.*|.*admin/.example.com", hostname)
6797                    .is_some(),
6798                must_route,
6799                "trie rule `/api.*|.*admin/` against {hostname:?}",
6800            );
6801        }
6802
6803        // Pre/Post: one whole-host pattern, in which `.` matches the
6804        // separator. The same rule reaches subdomains the operator did not
6805        // write. This is the clause `doc/configure.md` now carries.
6806        for position in [RulePosition::Pre, RulePosition::Post] {
6807            for hostname in [
6808                "api.example.com",
6809                "admin.example.com",
6810                "apifoo.example.com",
6811                "api.foo.example.com",
6812                "zz.admin.example.com",
6813            ] {
6814                assert!(
6815                    regex_host_routes(position, "/api.*|.*admin/.example.com", hostname).is_some(),
6816                    "{position:?}: `.*` spans the label separator, so {hostname:?} routes",
6817                );
6818            }
6819        }
6820
6821        // ... and the remediation that section recommends for `Pre`/`Post`.
6822        for position in [RulePosition::Pre, RulePosition::Post] {
6823            for (hostname, must_route) in [
6824                ("api.example.com", true),
6825                ("admin.example.com", true),
6826                ("apifoo.example.com", true),
6827                ("xadmin.example.com", true),
6828                ("api.foo.example.com", false),
6829                ("zz.admin.example.com", false),
6830            ] {
6831                assert_eq!(
6832                    regex_host_routes(position, "/api[^.]*|[^.]*admin/.example.com", hostname,)
6833                        .is_some(),
6834                    must_route,
6835                    "{position:?}: `[^.]*` spells the boundary out, {hostname:?}",
6836                );
6837            }
6838        }
6839    }
6840
6841    /// The LITERAL-label arm of `convert_regex_domain_rule` split the anchors
6842    /// exactly as the regex-segment arm did, one `else` away from it: a label
6843    /// the operator never wrapped in slashes was pushed verbatim into the
6844    /// assembly, so a `|` in it was a live alternation. Measured on the tree
6845    /// as it stood, `/a/.x|y.com` assembled to `\A(?:a)\.x|y\.com\z` —
6846    /// `(\A(?:a)\.x)|(y\.com\z)` — and matched `a.xZZZ` and `ZZZy.com`.
6847    ///
6848    /// The fix is `regex::escape`, not a group. A literal label is a literal,
6849    /// so grouping would confine the alternation while leaving `+`, `?`, `*`,
6850    /// `^` and `$` live inside the same arm — `b+c` matching `bbbc` is the
6851    /// same defect with a different operator. Escaping is a no-op in matching
6852    /// behaviour for every label a real hostname can carry: an LDH label is
6853    /// `[A-Za-z0-9-]`, `escape` touches only the `-`, and `\-` is `-` outside
6854    /// a character class. That parity is asserted below rather than asserted
6855    /// of the hostname grammar in the abstract.
6856    ///
6857    /// To SEE THIS RED: drop the escaping in the literal arm of
6858    /// `convert_regex_domain_rule` — push `r` instead of `&regex::escape(r)`
6859    /// at both of its exits. The `a.xZZZ` row then fails with
6860    /// `left: true, right: false`. That mutation reddens THREE tests, not
6861    /// just this one: `convert_regex`'s two `*` rows go back to the
6862    /// unescaped spelling, and
6863    /// `a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post`
6864    /// loses the `Pre`/`Post` half of its table.
6865    #[test]
6866    fn a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern() {
6867        // The three routing verdicts the defect turned on, through the
6868        // frontend surface that carries it.
6869        for (hostname, must_route) in [("a.x|y.com", true), ("a.xZZZ", false), ("ZZZy.com", false)]
6870        {
6871            assert_eq!(
6872                regex_host_routes(RulePosition::Pre, "/a/.x|y.com", hostname).is_some(),
6873                must_route,
6874                "literal label `x|y` against {hostname:?}",
6875            );
6876        }
6877        assert_eq!(
6878            convert_regex_domain_rule("/a/.x|y.com").expect("the hostname must convert"),
6879            "\\A(?:a)\\.x\\|y\\.com\\z",
6880            "a literal label must be escaped, not grouped and not pushed raw",
6881        );
6882
6883        // The control, built by hand: the UNESCAPED assembly is what leaked.
6884        // No mutation of `convert_regex_domain_rule` can move this.
6885        let unescaped = Regex::new("\\A(?:a)\\.x|y\\.com\\z").expect("the raw assembly compiles");
6886        for anywhere in ["a.xZZZ", "ZZZy.com"] {
6887            assert!(
6888                unescaped.is_match(anywhere.as_bytes()),
6889                "{} leaks on {anywhere}",
6890                unescaped.as_str(),
6891            );
6892        }
6893
6894        // Escaping must be inert for every label a hostname can actually
6895        // carry. `-` is the only LDH byte `regex::escape` touches, and `\-`
6896        // is `-` outside a character class.
6897        assert_eq!(
6898            convert_regex_domain_rule("/a/.my-host01.example.com")
6899                .expect("an LDH hostname must convert"),
6900            "\\A(?:a)\\.my\\-host01\\.example\\.com\\z",
6901            "escape only adds a backslash before the `-`",
6902        );
6903        for (hostname, must_route) in [
6904            ("a.my-host01.example.com", true),
6905            ("a.myXhost01.example.com", false),
6906            ("a.my-host01.example.comZZZ", false),
6907        ] {
6908            assert_eq!(
6909                regex_host_routes(RulePosition::Pre, "/a/.my-host01.example.com", hostname)
6910                    .is_some(),
6911                must_route,
6912                "escaped LDH label against {hostname:?}",
6913            );
6914        }
6915
6916        // Grouping instead of escaping would have closed the `|` only. This
6917        // is why the arms are treated differently, measured rather than
6918        // argued: the same three quantifiers stay live under a group.
6919        let grouped_not_escaped =
6920            Regex::new("\\A(?:a)\\.(?:b+c)\\.com\\z").expect("the grouped assembly compiles");
6921        assert!(
6922            grouped_not_escaped.is_match(b"a.bbbc.com"),
6923            "{} still repeats the literal `b`",
6924            grouped_not_escaped.as_str(),
6925        );
6926        for (hostname, must_route) in [("a.b+c.com", true), ("a.bbbc.com", false)] {
6927            assert_eq!(
6928                regex_host_routes(RulePosition::Pre, "/a/.b+c.com", hostname).is_some(),
6929                must_route,
6930                "escaped literal label `b+c` against {hostname:?}",
6931            );
6932        }
6933
6934        // Two deliberate behaviour changes, both narrowing, both stated in
6935        // `convert_regex_domain_rule`'s doc comment.
6936        //
6937        // (1) A literal `*` label was a quantifier over the preceding atom.
6938        // `\A*` repeats a zero-width assertion, which matches empty at any
6939        // offset, so the pattern was not anchored at its start at all.
6940        let star_unescaped =
6941            Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6942        assert!(
6943            star_unescaped.is_match(b"evil.attacker.x.example.com"),
6944            "{} is not anchored at its start",
6945            star_unescaped.as_str(),
6946        );
6947        assert_eq!(
6948            regex_host_routes(
6949                RulePosition::Pre,
6950                "*./x/.example.com",
6951                "evil.attacker.x.example.com",
6952            ),
6953            None,
6954            "a literal `*` label must no longer leave the pattern unanchored",
6955        );
6956        assert_eq!(
6957            regex_host_routes(RulePosition::Pre, "*./x/.example.com", "*.x.example.com").as_deref(),
6958            Some("REGEX-HOST"),
6959            "it matches the literal label the operator wrote, and only that",
6960        );
6961
6962        // (2) A literal label carrying an unbalanced `(` or `[` made the
6963        // assembly fail to compile and the frontend was REJECTED. It is now
6964        // accepted and matches exactly the host that was typed. Assembled at
6965        // run time because the unbalanced literal is the point.
6966        for bracket in ["(", "["] {
6967            let hostname = format!("/a/.b{bracket}c.com");
6968            assert!(
6969                convert_regex_domain_rule(&hostname).is_some(),
6970                "{hostname} must convert",
6971            );
6972            assert_eq!(
6973                regex_host_routes(RulePosition::Pre, &hostname, &format!("a.b{bracket}c.com"))
6974                    .as_deref(),
6975                Some("REGEX-HOST"),
6976                "{hostname} must match the literal host it spells",
6977            );
6978            assert_eq!(
6979                regex_host_routes(RulePosition::Pre, &hostname, "a.bc.com"),
6980                None,
6981                "{hostname} must match nothing else",
6982            );
6983        }
6984    }
6985
6986    /// `$HOST[n]` rewrite indices are read from `caps.iter().skip(1)` with
6987    /// the buffer sized by `captures_len()`, so the anchoring wrapper has to
6988    /// be NON-capturing: a capturing `(` … `)` shifts every index by one and
6989    /// silently rewrites traffic to the wrong target — a wrong `Host` header
6990    /// and a wrong backend, with nothing in the configuration to show for it.
6991    ///
6992    /// Both hostname code paths are asserted, because they read the capture
6993    /// from two DIFFERENT regexes that have to agree: a pre/post rule reads
6994    /// the whole-host regex `convert_regex_domain_rule` assembles
6995    /// (`RouteResult::new_no_trie`), a tree rule reads `pattern_trie`'s
6996    /// per-segment regex (`RouteResult::new_with_trie`) — while
6997    /// `capture_cap_host`, which bounds the template, is taken from the
6998    /// whole-host one in BOTH cases.
6999    ///
7000    /// To SEE THIS RED: make either wrapper capturing — `result.push_str("(")`
7001    /// instead of `"(?:"` in `convert_regex_domain_rule`, or
7002    /// `format!("\\A({segment})\\z")` in `pattern_trie.rs`'s
7003    /// `anchored_segment`. `$HOST[1]` then resolves to the whole segment
7004    /// `cdn42` instead of the operator's own group `42`, failing with
7005    /// `left: Some("cdn42.internal"), right: Some("42.internal")`.
7006    #[test]
7007    fn a_host_capture_rewrite_resolves_to_the_operators_own_group() {
7008        let rewritten_host = |position: RulePosition, template: &str, hostname: &str| {
7009            let mut front = test_http_frontend();
7010            front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
7011            front.position = position;
7012            front.rewrite_host = Some(template.to_owned());
7013            let mut router = Router::new();
7014            router
7015                .add_http_front(&front)
7016                .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
7017            router
7018                .lookup(hostname, "/", &Method::Get)
7019                .ok()
7020                .and_then(|result| result.rewritten_host)
7021        };
7022
7023        for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
7024            assert_eq!(
7025                rewritten_host(position, "$HOST[1].internal", "cdn42.example.com").as_deref(),
7026                Some("42.internal"),
7027                "{position:?}: $HOST[1] must be the operator's own group, not the wrapper's",
7028            );
7029            assert_eq!(
7030                rewritten_host(position, "$HOST[0]", "cdn42.example.com").as_deref(),
7031                Some("cdn42.example.com"),
7032                "{position:?}: $HOST[0] is the whole hostname",
7033            );
7034        }
7035
7036        // The cap itself. `captures_len()` is 2, so index 2 does not exist
7037        // and the frontend must be refused loudly at registration rather
7038        // than rewriting to an empty string. A capturing wrapper would make
7039        // this index legal — which is exactly how a shifted index reaches
7040        // production unnoticed.
7041        let mut front = test_http_frontend();
7042        front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
7043        front.rewrite_host = Some("$HOST[2]".to_owned());
7044        assert!(
7045            matches!(
7046                Router::new().add_http_front(&front),
7047                Err(RouterError::InvalidHostRewrite(_)),
7048            ),
7049            "a $HOST index past captures_len must be refused at registration",
7050        );
7051    }
7052
7053    /// A tree lookup allocates nothing of its own: whatever host shape
7054    /// answers, the only heap work is building the [`RouteResult`] itself,
7055    /// which the control reproduces. The cluster id is not part of it: the
7056    /// result carries a handle on the legacy [`Route::ClusterId`] entry's
7057    /// reference-counted id, and so does the control.
7058    ///
7059    /// The three shapes cover every sink push the walk makes: a literal host
7060    /// records no segment, a wildcard host records one on its leaf, a regex
7061    /// host records one on the way down.
7062    ///
7063    /// TO SEE THIS RED: hand `lookup_with_path` a `Vec::with_capacity(16)`
7064    /// again in `Router::lookup`, one allocation per lookup on every shape;
7065    /// or build the legacy arm of `RouteResult::new_with_trie` from
7066    /// `ClusterId::from(&**id)` instead of `id.clone()`, one more.
7067    #[test]
7068    fn a_tree_lookup_allocates_nothing_past_its_route_result() {
7069        use std::hint::black_box;
7070
7071        use crate::test_allocations::allocations;
7072
7073        let mut router = Router::new();
7074        for hostname in [
7075            "literal.example.com",
7076            "*.wildcard.example.com",
7077            "/api[0-9]+/.regex.example.com",
7078        ] {
7079            let mut front = test_http_frontend();
7080            front.hostname = hostname.to_owned();
7081            router
7082                .add_http_front(&front)
7083                .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
7084        }
7085
7086        let cluster = ClusterId::from("cluster");
7087        let control = || {
7088            let before = allocations();
7089            let result = RouteResult::forward(black_box(&cluster).clone());
7090            let allocated = allocations() - before;
7091            drop(black_box(result));
7092            allocated
7093        };
7094        for host in [
7095            "literal.example.com",
7096            "foo.wildcard.example.com",
7097            "api42.regex.example.com",
7098        ] {
7099            // Warm-up: a regex builds its per-thread search cache on its
7100            // first match.
7101            drop(router.lookup(host, "/", &Method::Get));
7102            let before = allocations();
7103            let result = router.lookup(black_box(host), black_box("/"), &Method::Get);
7104            let allocated = allocations() - before;
7105            assert_eq!(
7106                result.ok().and_then(|result| result.cluster_id).as_deref(),
7107                Some("cluster"),
7108                "{host} must route to the test cluster",
7109            );
7110            assert_eq!(
7111                allocated,
7112                control(),
7113                "{host}: a tree lookup must allocate only its RouteResult",
7114            );
7115        }
7116    }
7117
7118    /// A trie walk records every non-literal segment it matched, in walk
7119    /// order, and `RouteResult::new_with_trie` reads that record back in
7120    /// reverse to number the `$HOST[n]` captures. The record keeps its first
7121    /// 16 entries on the stack; this hostname has 20 regex segments, so four
7122    /// spill past them, and every one of the 20 must still reach the
7123    /// template in the same position.
7124    ///
7125    /// The walk runs from the rightmost label, so the spilled entries are
7126    /// the four LEFTMOST segments, `s1` to `s4`, and the reversed read has to
7127    /// cross from the spill into the inline array. The expected order is
7128    /// left to right, the order a pre/post rule reads its whole-host regex
7129    /// in (sozu#1595); `a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule`
7130    /// holds the two positions against each other.
7131    #[test]
7132    fn a_host_capture_past_sixteen_trie_segments_keeps_every_segment() {
7133        const SEGMENTS: usize = 20;
7134        let hostname = format!(
7135            "{}.example.com",
7136            (1..=SEGMENTS)
7137                .map(|_| "/s([0-9]+)/")
7138                .collect::<Vec<_>>()
7139                .join(".")
7140        );
7141        let request = format!(
7142            "{}.example.com",
7143            (1..=SEGMENTS)
7144                .map(|segment| format!("s{segment}"))
7145                .collect::<Vec<_>>()
7146                .join(".")
7147        );
7148        let template = (1..=SEGMENTS)
7149            .map(|index| format!("$HOST[{index}]"))
7150            .collect::<Vec<_>>()
7151            .join("-");
7152
7153        let mut front = test_http_frontend();
7154        front.hostname = hostname;
7155        front.position = RulePosition::Tree;
7156        front.rewrite_host = Some(template);
7157        let mut router = Router::new();
7158        router
7159            .add_http_front(&front)
7160            .unwrap_or_else(|error| panic!("the tree frontend must build: {error}"));
7161
7162        let hostname_order = (1..=SEGMENTS)
7163            .map(|segment| segment.to_string())
7164            .collect::<Vec<_>>()
7165            .join("-");
7166        assert_eq!(
7167            router
7168                .lookup(&request, "/", &Method::Get)
7169                .ok()
7170                .and_then(|result| result.rewritten_host),
7171            Some(hostname_order),
7172            "every trie segment, inline or spilled, must reach $HOST[n] in hostname order",
7173        );
7174    }
7175
7176    /// The same hostname numbers its `$HOST[n]` captures the same way
7177    /// whichever position compiled it (sozu#1595). A pre/post rule reads
7178    /// one whole-host regex, so its groups come left to right; a tree rule
7179    /// matches one regex per label while the trie walks from the rightmost
7180    /// label, and `RouteResult::new_with_trie` renumbers them left to right.
7181    /// The second segment carries two groups, which keep their order inside
7182    /// the segment: reversing the flattened captures instead of the
7183    /// segments would be caught too.
7184    ///
7185    /// TO SEE THIS RED: drop the `.rev()` in `RouteResult::new_with_trie`;
7186    /// the tree rule then yields `x-9-1` where the pre/post rules yield
7187    /// `1-x-9`.
7188    #[test]
7189    fn a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule() {
7190        let rewritten_host = |position: RulePosition| {
7191            let mut front = test_http_frontend();
7192            front.hostname = "/a([0-9]+)/./([a-z])([0-9])/.example.com".to_owned();
7193            front.position = position;
7194            front.rewrite_host = Some("$HOST[1]-$HOST[2]-$HOST[3]".to_owned());
7195            let mut router = Router::new();
7196            router
7197                .add_http_front(&front)
7198                .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
7199            router
7200                .lookup("a1.x9.example.com", "/", &Method::Get)
7201                .ok()
7202                .and_then(|result| result.rewritten_host)
7203        };
7204
7205        for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
7206            assert_eq!(
7207                rewritten_host(position).as_deref(),
7208                Some("1-x-9"),
7209                "{position:?}: $HOST[n] must number the captures left to right",
7210            );
7211        }
7212    }
7213
7214    /// Grouping must not rescue a hostname the router rejects today. The
7215    /// wrapper supplies one `(` and one `)`, so an unbalanced segment can be
7216    /// balanced BY the wrapping — `a)(b` is not a regex, yet
7217    /// `\A(?:a)(b)\.example\.com\z` is, and it matches `ab.example.com`.
7218    /// Both hostname sites therefore compile the segment on its own before
7219    /// wrapping it, exactly as `PathRule::anchored_regex` does for a path.
7220    ///
7221    /// This covers that one direction. The converse is NOT guarded and is
7222    /// not asserted here: a segment that compiles bare can be rejected once
7223    /// wrapped. Such a hostname lands in `RouterError::InvalidDomain` at
7224    /// frontend registration, so it costs a loud rejection and not a
7225    /// mis-route.
7226    ///
7227    /// To SEE THIS RED: delete the `Regex::new(r).ok()?;` line in
7228    /// `convert_regex_domain_rule` — the first assertion then fails, the
7229    /// hostname builds, and `ab.example.com` routes. Delete the
7230    /// `Regex::new(segment).ok()?;` line in `pattern_trie.rs`'s
7231    /// `anchored_segment` instead and the `add_tree_rule` assertion fails.
7232    #[test]
7233    fn grouping_never_turns_a_rejected_hostname_regex_into_an_accepted_one() {
7234        // Assembled at run time rather than written as a literal because the
7235        // invalidity is the whole point and a literal invites a lint at the
7236        // `Regex::new` call site.
7237        let unbalanced = format!("/a{}b/.example.com", ")(");
7238
7239        assert_eq!(
7240            convert_regex_domain_rule(&unbalanced),
7241            None,
7242            "{unbalanced} carries a segment that is not a regex on its own",
7243        );
7244        assert_eq!(
7245            unbalanced.parse::<DomainRule>(),
7246            Err(()),
7247            "{unbalanced} must not become a usable DomainRule",
7248        );
7249        assert!(
7250            !Router::new().add_tree_rule(
7251                unbalanced.as_bytes(),
7252                &PathRule::Prefix("/".to_owned()),
7253                &MethodRule::new(Some("GET".to_owned())),
7254                &Route::ClusterId("RESCUED".into()),
7255            ),
7256            "{unbalanced} must not be storable in the trie either",
7257        );
7258
7259        // The hazard is real, not hypothetical: the wrapping alone turns it
7260        // into a live pattern matching a host the operator never wrote.
7261        let rescued = Regex::new(&format!("\\A(?:a{}b)\\.example\\.com\\z", ")("))
7262            .expect("the wrapping balances the segment");
7263        assert!(
7264            rescued.is_match(b"ab.example.com"),
7265            "{} is what the missing pre-validation would have built",
7266            rescued.as_str(),
7267        );
7268    }
7269
7270    /// One level up from the path rules, two overlapping regex HOSTNAME
7271    /// segments follow the same first-declared-wins rule: a trie node holds
7272    /// its regex segments in an ordered list, the lookup takes the first
7273    /// that matches AND serves the request, and a new segment is appended.
7274    /// Both segments below serve this request, so the first declared
7275    /// answers;
7276    /// `a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next`
7277    /// covers the case where it does not.
7278    ///
7279    /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7280    /// (`router/pattern_trie.rs`), iterate the regex segments in reverse —
7281    /// `for (regexp, child) in self.regexps.iter().rev()`. The last declared
7282    /// segment then answers and both assertions below flip.
7283    #[test]
7284    fn the_first_declared_regex_hostname_segment_wins_over_a_later_overlapping_one() {
7285        // Both patterns match the segment `test4`.
7286        let numbered: &[u8] = b"/test[0-9]/.example.com";
7287        let any_suffixed: &[u8] = b"/[a-z]+[0-9]/.example.com";
7288
7289        let declare = |first: &[u8], first_cluster: &str, second: &[u8], second_cluster: &str| {
7290            let mut router = Router::new();
7291            for (hostname, cluster) in [(first, first_cluster), (second, second_cluster)] {
7292                assert!(router.add_tree_rule(
7293                    hostname,
7294                    &PathRule::Prefix("/".to_owned()),
7295                    &MethodRule::new(Some("GET".to_owned())),
7296                    &Route::ClusterId(cluster.into()),
7297                ));
7298            }
7299            router
7300                .lookup("test4.example.com", "/", &Method::Get)
7301                .ok()
7302                .and_then(|result| result.cluster_id)
7303        };
7304
7305        assert_eq!(
7306            declare(numbered, "NUMBERED", any_suffixed, "ANY-SUFFIXED").as_deref(),
7307            Some("NUMBERED"),
7308            "the first-declared regex hostname segment must win",
7309        );
7310        assert_eq!(
7311            declare(any_suffixed, "ANY-SUFFIXED", numbered, "NUMBERED").as_deref(),
7312            Some("ANY-SUFFIXED"),
7313            "reversing the declaration order reverses the winner",
7314        );
7315
7316        // The worked example in `doc/configure.md` promises `/test[0-9]/`
7317        // matches `test4` "and not testAB". `[0-9]` is a SINGLE digit and the
7318        // segment is `\A`-`\z` anchored, so `test44` and `xtest4` must miss
7319        // too — the anchoring is pinned generally by
7320        // `segment_regex_rejects_partial_matches`, this pins the doc's own
7321        // example.
7322        let mut router = Router::new();
7323        assert!(router.add_tree_rule(
7324            numbered,
7325            &PathRule::Prefix("/".to_owned()),
7326            &MethodRule::new(Some("GET".to_owned())),
7327            &Route::ClusterId("REGIONAL".into()),
7328        ));
7329        assert_eq!(
7330            router
7331                .lookup("test4.example.com", "/", &Method::Get)
7332                .ok()
7333                .and_then(|result| result.cluster_id)
7334                .as_deref(),
7335            Some("REGIONAL"),
7336        );
7337        for miss in [
7338            "testAB.example.com",
7339            "test44.example.com",
7340            "xtest4.example.com",
7341        ] {
7342            assert!(
7343                router.lookup(miss, "/", &Method::Get).is_err(),
7344                "{miss} must not match the single-digit anchored segment",
7345            );
7346        }
7347    }
7348
7349    /// sozu#1351, shape one — the routing leak. An exact hostname added
7350    /// AFTER a regex segment that matches it used to attach its rule to
7351    /// the REGEX segment's leaf: `add_tree_rule` addresses the leaf
7352    /// through `domain_lookup_mut`, and that resolver walked a literal
7353    /// segment through the first regex segment matching it. The rule the
7354    /// operator wrote for `test4.example.com` was then served for every
7355    /// host `/test[0-9]/` matches, while `sozu query frontends` showed
7356    /// exactly what was typed.
7357    ///
7358    /// `TrieNode::lookup_mut` is key-addressed now: with
7359    /// `accept_wildcard: false` a literal key may not resolve into a
7360    /// non-literal entry (the guard `insert_sni_route` already documented
7361    /// for the wildcard slot, extended to the regex segments), so the add
7362    /// creates `test4.example.com`'s own node.
7363    ///
7364    /// To SEE THIS RED: in `TrieNode::lookup_mut`
7365    /// (`router/pattern_trie.rs`), drop the `accept_wildcard &&` guard in
7366    /// front of the `self.regexps` scan, so a literal segment resolves
7367    /// through a matching regex segment again. `test7` and `test9` then
7368    /// answer with EXACT-SPECIFIC.
7369    #[test]
7370    fn an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family() {
7371        let mut router = Router::new();
7372        assert!(router.add_tree_rule(
7373            b"/test[0-9]/.example.com",
7374            &PathRule::Prefix("/".to_owned()),
7375            &MethodRule::new(None),
7376            &Route::ClusterId("REGEX-FAMILY".into()),
7377        ));
7378        assert!(router.add_tree_rule(
7379            b"test4.example.com",
7380            &PathRule::Prefix("/only-for-test4".to_owned()),
7381            &MethodRule::new(None),
7382            &Route::ClusterId("EXACT-SPECIFIC".into()),
7383        ));
7384
7385        let resolve = |hostname: &str, path: &str| {
7386            router
7387                .lookup(hostname, path, &Method::Get)
7388                .ok()
7389                .and_then(|result| result.cluster_id)
7390        };
7391
7392        assert_eq!(
7393            resolve("test4.example.com", "/only-for-test4").as_deref(),
7394            Some("EXACT-SPECIFIC"),
7395            "the host the rule was written for must get it",
7396        );
7397        for leaked in ["test7.example.com", "test9.example.com"] {
7398            assert_eq!(
7399                resolve(leaked, "/only-for-test4").as_deref(),
7400                Some("REGEX-FAMILY"),
7401                "{leaked} must keep the family route and never reach the \
7402                 rule scoped to test4",
7403            );
7404        }
7405        assert_eq!(
7406            resolve("test4.example.com", "/").as_deref(),
7407            Some("REGEX-FAMILY"),
7408            "the exact node carries only /only-for-test4, so every other \
7409             path on that host falls through to the regex segment that \
7410             also claims it",
7411        );
7412    }
7413
7414    /// sozu#1351 — the clearest statement of the same bug, and of its fix:
7415    /// declaring the exact host and the regex segment that matches it in
7416    /// either order must produce the SAME routing table. Before the fix
7417    /// the regex-first order attached the exact rule to the regex leaf
7418    /// (so `test7` answered with it and `test4/` answered at all), while
7419    /// the exact-first order built two separate nodes.
7420    ///
7421    /// To SEE THIS RED: same mutation as
7422    /// `an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family`
7423    /// — drop the `accept_wildcard &&` guard in front of the
7424    /// `self.regexps` scan of `TrieNode::lookup_mut`. The two arrays then
7425    /// differ on `test4.example.com/` and on `test7.example.com`.
7426    #[test]
7427    fn hostname_routing_does_not_depend_on_declaration_order() {
7428        let exact: (&[u8], &str, &str) =
7429            (b"test4.example.com", "/only-for-test4", "EXACT-SPECIFIC");
7430        let regex: (&[u8], &str, &str) = (b"/test[0-9]/.example.com", "/", "REGEX-FAMILY");
7431
7432        let routes = |declared: [(&[u8], &str, &str); 2]| {
7433            let mut router = Router::new();
7434            for (hostname, path, cluster) in declared {
7435                assert!(router.add_tree_rule(
7436                    hostname,
7437                    &PathRule::Prefix(path.to_owned()),
7438                    &MethodRule::new(None),
7439                    &Route::ClusterId(cluster.into()),
7440                ));
7441            }
7442            [
7443                ("test4.example.com", "/only-for-test4"),
7444                ("test4.example.com", "/"),
7445                ("test7.example.com", "/only-for-test4"),
7446                ("test9.example.com", "/"),
7447            ]
7448            .map(|(hostname, path)| {
7449                router
7450                    .lookup(hostname, path, &Method::Get)
7451                    .ok()
7452                    .and_then(|result| result.cluster_id)
7453            })
7454        };
7455
7456        let regex_first = routes([regex, exact]);
7457        let exact_first = routes([exact, regex]);
7458        assert_eq!(
7459            regex_first, exact_first,
7460            "hostname resolution must not depend on the order the \
7461             frontends were added in",
7462        );
7463        assert_eq!(
7464            regex_first[0].as_deref(),
7465            Some("EXACT-SPECIFIC"),
7466            "and the order-independent answer is the exact host's own rule",
7467        );
7468        assert_eq!(
7469            regex_first[2].as_deref(),
7470            Some("REGEX-FAMILY"),
7471            "while a sibling of the regex family never sees it",
7472        );
7473    }
7474
7475    /// Hostname precedence is most-specific-first: an exact name beats a
7476    /// regex segment, a regex segment beats the `*` wildcard. One
7477    /// assertion per transition, since `doc/configure.md` states the order
7478    /// in prose.
7479    ///
7480    /// Regex-over-wildcard is the half that changed: `lookup_with_path`
7481    /// used to consult the wildcard slot BEFORE the regex list whenever
7482    /// the leftmost label was reached, so `*` outranked the narrower
7483    /// pattern.
7484    ///
7485    /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7486    /// (`router/pattern_trie.rs`), move the wildcard block above the
7487    /// `self.regexps` loop. The REGEX-over-WILDCARD assertions then answer
7488    /// WILDCARD.
7489    #[test]
7490    fn hostname_precedence_is_exact_then_regex_then_wildcard() {
7491        let exact: &[u8] = b"test4.example.com";
7492        let regex: &[u8] = b"/test[0-9]/.example.com";
7493        let wildcard: &[u8] = b"*.example.com";
7494
7495        let declare = |hostnames: &[(&[u8], &str)]| {
7496            let mut router = Router::new();
7497            for (hostname, cluster) in hostnames {
7498                assert!(router.add_tree_rule(
7499                    hostname,
7500                    &PathRule::Prefix("/".to_owned()),
7501                    &MethodRule::new(None),
7502                    &Route::ClusterId((*cluster).into()),
7503                ));
7504            }
7505            router
7506                .lookup("test4.example.com", "/", &Method::Get)
7507                .ok()
7508                .and_then(|result| result.cluster_id)
7509        };
7510
7511        assert_eq!(
7512            declare(&[(regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7513            Some("EXACT"),
7514            "an exact hostname outranks a regex segment that matches it",
7515        );
7516        assert_eq!(
7517            declare(&[(wildcard, "WILDCARD"), (exact, "EXACT")]).as_deref(),
7518            Some("EXACT"),
7519            "an exact hostname outranks the wildcard",
7520        );
7521        assert_eq!(
7522            declare(&[(wildcard, "WILDCARD"), (regex, "REGEX")]).as_deref(),
7523            Some("REGEX"),
7524            "a regex segment outranks the wildcard",
7525        );
7526        assert_eq!(
7527            declare(&[(regex, "REGEX"), (wildcard, "WILDCARD")]).as_deref(),
7528            Some("REGEX"),
7529            "and it outranks it in the other declaration order too",
7530        );
7531        assert_eq!(
7532            declare(&[(wildcard, "WILDCARD"), (regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7533            Some("EXACT"),
7534            "with all three declared the most specific one answers",
7535        );
7536        assert_eq!(
7537            declare(&[(wildcard, "WILDCARD")]).as_deref(),
7538            Some("WILDCARD"),
7539            "the wildcard still answers when nothing narrower claims the host",
7540        );
7541    }
7542
7543    /// The precedence order above is a search order, not a filter: a more
7544    /// specific hostname candidate that serves no rule for THIS request
7545    /// hands the request to the next one instead of ending the lookup.
7546    /// Without this, splitting a host's paths across an exact frontend and
7547    /// a regex family would make every path the exact frontend does not
7548    /// carry unroutable.
7549    ///
7550    /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7551    /// (`router/pattern_trie.rs`), return
7552    /// `child.lookup_recursive(prefix, accept_wildcard, trace, accept)`
7553    /// directly from the `self.children.get(suffix)` arm instead of
7554    /// falling through on `None` — the pre-fix shape. `/regex-only` and
7555    /// `/served-by-neither` on `test4.example.com` then answer `None`.
7556    #[test]
7557    fn a_hostname_candidate_that_serves_no_rule_falls_through_to_the_next() {
7558        let mut router = Router::new();
7559        for (hostname, path, cluster) in [
7560            (&b"test4.example.com"[..], "/exact-only", "EXACT"),
7561            (&b"/test[0-9]/.example.com"[..], "/regex-only", "REGEX"),
7562            (&b"*.example.com"[..], "/", "WILDCARD"),
7563        ] {
7564            assert!(router.add_tree_rule(
7565                hostname,
7566                &PathRule::Prefix(path.to_owned()),
7567                &MethodRule::new(None),
7568                &Route::ClusterId(cluster.into()),
7569            ));
7570        }
7571
7572        let resolve = |hostname: &str, path: &str| {
7573            router
7574                .lookup(hostname, path, &Method::Get)
7575                .ok()
7576                .and_then(|result| result.cluster_id)
7577        };
7578
7579        assert_eq!(
7580            resolve("test4.example.com", "/exact-only").as_deref(),
7581            Some("EXACT"),
7582        );
7583        assert_eq!(
7584            resolve("test4.example.com", "/regex-only").as_deref(),
7585            Some("REGEX"),
7586            "the exact node carries no rule for this path, so the regex \
7587             segment that also claims the host answers",
7588        );
7589        assert_eq!(
7590            resolve("test4.example.com", "/served-by-neither").as_deref(),
7591            Some("WILDCARD"),
7592            "and when neither serves it the wildcard does",
7593        );
7594        assert_eq!(
7595            resolve("testA.example.com", "/regex-only").as_deref(),
7596            Some("WILDCARD"),
7597            "a host the regex segment does not match never reaches it",
7598        );
7599    }
7600
7601    /// sozu#1351, shape two — the 404. Hostname candidates are chosen
7602    /// without looking at the request method, so the first segment to
7603    /// claim a host used to end the lookup even when its only rule was
7604    /// declared for another method. The second segment, which would have
7605    /// answered, was never consulted.
7606    ///
7607    /// The fallback above covers it: `Router::lookup` hands the trie the
7608    /// same (path, method) selection it will run on the winner, so a
7609    /// candidate that selects nothing is skipped.
7610    ///
7611    /// To SEE THIS RED: in `Router::lookup`, pass `&mut |_| true` as the
7612    /// `accept` predicate of `lookup_with_path` instead of
7613    /// `select_tree_rule(...).is_some()`. The GET below then answers
7614    /// `None` because the POST-only segment was declared first.
7615    #[test]
7616    fn a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next() {
7617        let mut router = Router::new();
7618        // Both segments match `test4`; the POST-only one is declared first.
7619        assert!(router.add_tree_rule(
7620            b"/test[0-9]/.example.com",
7621            &PathRule::Prefix("/".to_owned()),
7622            &MethodRule::new(Some("POST".to_owned())),
7623            &Route::ClusterId("POST-ONLY".into()),
7624        ));
7625        assert!(router.add_tree_rule(
7626            b"/[a-z]+[0-9]/.example.com",
7627            &PathRule::Prefix("/".to_owned()),
7628            &MethodRule::new(Some("GET".to_owned())),
7629            &Route::ClusterId("GET-ONLY".into()),
7630        ));
7631
7632        let resolve = |method: &Method| {
7633            router
7634                .lookup("test4.example.com", "/", method)
7635                .ok()
7636                .and_then(|result| result.cluster_id)
7637        };
7638
7639        assert_eq!(
7640            resolve(&Method::Get).as_deref(),
7641            Some("GET-ONLY"),
7642            "the first-declared segment carries no GET rule, so the second \
7643             one answers instead of the request 404ing",
7644        );
7645        assert_eq!(
7646            resolve(&Method::Post).as_deref(),
7647            Some("POST-ONLY"),
7648            "and the first-declared segment still wins whenever it serves \
7649             the request",
7650        );
7651    }
7652
7653    /// `PathRule`'s `PartialEq` carried no `(Equals, Equals)` arm, so two
7654    /// identical `PathRule::Equals` compared unequal — an equality that is
7655    /// not even reflexive. Every router bookkeeping path is written against
7656    /// it: `add_tree_rule` could not see the rule it had just pushed (so the
7657    /// same `--path-equals` frontend was stored again on every re-add
7658    /// instead of being refused), `remove_tree_rule`'s `retain` kept the
7659    /// entry it was asked to drop, and `remove_http_front` still answered
7660    /// `Ok` for a route it had not removed.
7661    ///
7662    /// To SEE THIS RED: drop the
7663    /// `(PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2` arm from
7664    /// `impl std::cmp::PartialEq for PathRule` so the catch-all `_ => false`
7665    /// answers again — the reflexivity assertion below is the first statement
7666    /// and fails on `left: Equals("/exact") / right: Equals("/exact")`.
7667    /// Without that assertion a debug build panics one line later, inside
7668    /// `add_http_front`, on "a freshly inserted tree domain must resolve to
7669    /// its inserted rule".
7670    #[test]
7671    fn an_equals_path_frontend_is_deduplicated_and_removable() {
7672        assert_eq!(
7673            PathRule::Equals("/exact".to_owned()),
7674            PathRule::Equals("/exact".to_owned()),
7675            "PathRule equality must be reflexive for the Equals variant",
7676        );
7677
7678        let mut router = Router::new();
7679        let mut front = test_http_frontend();
7680        front.path = CommandPathRule::equals("/exact".to_owned());
7681
7682        router
7683            .add_http_front(&front)
7684            .expect("an Equals frontend must be added");
7685        assert!(
7686            router.lookup("example.com", "/exact", &Method::Get).is_ok(),
7687            "the Equals frontend must resolve once added",
7688        );
7689
7690        // The identical frontend is already stored, so the router must
7691        // refuse it instead of pushing a second, unremovable copy.
7692        assert!(
7693            matches!(router.add_http_front(&front), Err(RouterError::AddRoute(_))),
7694            "re-adding the same Equals frontend must be refused, not duplicated",
7695        );
7696
7697        router
7698            .remove_http_front(&front)
7699            .expect("an Equals frontend must be removable");
7700        assert!(
7701            router
7702                .lookup("example.com", "/exact", &Method::Get)
7703                .is_err(),
7704            "the Equals frontend must no longer resolve once removed",
7705        );
7706    }
7707
7708    // ── RFC 9110 §4.2.3: the Host is case-insensitive ──────────────────
7709    //
7710    // `add_tree_rule` (via `tree_hostname_to_ascii`) and
7711    // `DomainRule::from_str` both normalise a configured hostname
7712    // through `idna::domain_to_ascii`, which ASCII-lowercases (measured:
7713    // `"WWW.EXAMPLE.COM"` -> `"www.example.com"`, `"*.EXAMPLE.COM"` ->
7714    // `"*.example.com"`). A `/`-delimited regex segment is the one part
7715    // that is NOT normalised — see `tree_hostname_to_ascii` and
7716    // sozu#1377 — and is compiled case-insensitively instead, so an
7717    // uppercase literal inside it still meets the normalised key.
7718    // `Router::lookup` used to walk the
7719    // trie and the pre/post lists with the client's raw bytes, so the two
7720    // sides disagreed and no configuration could make an uppercase
7721    // `Host:` route. The tests below pin the restored invariant: add and
7722    // lookup normalise identically.
7723
7724    /// To SEE THIS RED: in `Router::lookup`, replace
7725    /// `let normalized_hostname = normalize_hostname(hostname);` with
7726    /// `let normalized_hostname = Cow::Borrowed(hostname);`. Every
7727    /// uppercase and mixed-case assertion below then fails with
7728    /// `left: None, right: Some("CASE")` — the byte-exact trie walk the
7729    /// fix removed.
7730    #[test]
7731    fn an_uppercase_host_reaches_a_lowercase_tree_frontend() {
7732        let mut router = Router::new();
7733        assert!(router.add_tree_rule(
7734            b"www.example.com",
7735            &PathRule::Prefix("/".to_owned()),
7736            &MethodRule::new(Some("GET".to_owned())),
7737            &Route::ClusterId("CASE".into()),
7738        ));
7739
7740        let resolve = |hostname: &str| {
7741            router
7742                .lookup(hostname, "/", &Method::Get)
7743                .ok()
7744                .and_then(|result| result.cluster_id)
7745        };
7746
7747        for host in [
7748            "www.example.com",
7749            "WWW.EXAMPLE.COM",
7750            "WwW.ExAmPlE.cOm",
7751            "www.EXAMPLE.com",
7752        ] {
7753            assert_eq!(
7754                resolve(host).as_deref(),
7755                Some("CASE"),
7756                "Host {host:?} names the same frontend as its lowercase form",
7757            );
7758        }
7759    }
7760
7761    /// The other half of the issue: declaring the frontend in uppercase
7762    /// never was a workaround, because the add path lowercases it on the
7763    /// way in. Both spellings of the frontend must route both spellings
7764    /// of the Host.
7765    ///
7766    /// To SEE THIS RED: same mutation as
7767    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7768    #[test]
7769    fn an_uppercase_tree_frontend_is_stored_and_looked_up_lowercase() {
7770        let mut router = Router::new();
7771        assert!(router.add_tree_rule(
7772            b"WWW.EXAMPLE.COM",
7773            &PathRule::Prefix("/".to_owned()),
7774            &MethodRule::new(Some("GET".to_owned())),
7775            &Route::ClusterId("CASE".into()),
7776        ));
7777
7778        for host in ["www.example.com", "WWW.EXAMPLE.COM"] {
7779            assert_eq!(
7780                router
7781                    .lookup(host, "/", &Method::Get)
7782                    .ok()
7783                    .and_then(|result| result.cluster_id)
7784                    .as_deref(),
7785                Some("CASE"),
7786                "an uppercase frontend is stored lowercase, so {host:?} must resolve",
7787            );
7788        }
7789    }
7790
7791    /// Wildcard and regex trie segments walk the same normalised key.
7792    /// The regex segment is the interesting one: its source is stored
7793    /// VERBATIM (`tree_hostname_to_ascii` normalises the literal labels
7794    /// only, because folding the source inverted `\D` into `\d` —
7795    /// sozu#1377), so an uppercase literal inside it meets the
7796    /// lowercased lookup key only because
7797    /// `pattern_trie::compiled_segment` compiles it case-insensitively.
7798    ///
7799    /// To SEE THIS RED: same mutation as
7800    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`. Dropping
7801    /// `.case_insensitive(true)` from `compiled_segment` reddens the
7802    /// `API7` assertion alone, which is the half this test also guards.
7803    #[test]
7804    fn an_uppercase_host_reaches_wildcard_and_regex_tree_segments() {
7805        let mut router = Router::new();
7806        assert!(router.add_tree_rule(
7807            b"*.wild.example.com",
7808            &PathRule::Prefix("/".to_owned()),
7809            &MethodRule::new(Some("GET".to_owned())),
7810            &Route::ClusterId("WILDCARD".into()),
7811        ));
7812        assert!(router.add_tree_rule(
7813            b"/API[0-9]/.rx.example.com",
7814            &PathRule::Prefix("/".to_owned()),
7815            &MethodRule::new(Some("GET".to_owned())),
7816            &Route::ClusterId("REGEX".into()),
7817        ));
7818
7819        let resolve = |hostname: &str| {
7820            router
7821                .lookup(hostname, "/", &Method::Get)
7822                .ok()
7823                .and_then(|result| result.cluster_id)
7824        };
7825
7826        assert_eq!(resolve("FOO.WILD.EXAMPLE.COM").as_deref(), Some("WILDCARD"));
7827        assert_eq!(resolve("FoO.wild.EXAMPLE.com").as_deref(), Some("WILDCARD"));
7828        assert_eq!(
7829            resolve("API7.RX.EXAMPLE.COM").as_deref(),
7830            Some("REGEX"),
7831            "the operator's `/API[0-9]/` is stored verbatim and compiled \
7832             case-insensitively, so the normalised key matches it",
7833        );
7834        assert_eq!(resolve("api7.rx.example.com").as_deref(), Some("REGEX"));
7835    }
7836
7837    /// Pre and post rules carry a `DomainRule` rather than a trie node.
7838    /// `Exact` and `Wildcard` are lowercased by `DomainRule::from_str`
7839    /// (again `idna::domain_to_ascii`) and compared byte-exact, so they
7840    /// had exactly the tree's asymmetry.
7841    ///
7842    /// To SEE THIS RED: same mutation as
7843    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7844    #[test]
7845    fn an_uppercase_host_reaches_exact_and_wildcard_pre_post_rules() {
7846        let mut router = Router::new();
7847        let exact = "pre.example.com"
7848            .parse::<DomainRule>()
7849            .expect("an exact domain rule must parse");
7850        let wildcard = "*.post.example.com"
7851            .parse::<DomainRule>()
7852            .expect("a wildcard domain rule must parse");
7853        assert!(router.add_pre_rule(
7854            &exact,
7855            &PathRule::Prefix("/".to_owned()),
7856            &MethodRule::new(None),
7857            &Route::ClusterId("PRE".into()),
7858        ));
7859        assert!(router.add_post_rule(
7860            &wildcard,
7861            &PathRule::Prefix("/".to_owned()),
7862            &MethodRule::new(None),
7863            &Route::ClusterId("POST".into()),
7864        ));
7865
7866        let resolve = |hostname: &str| {
7867            router
7868                .lookup(hostname, "/", &Method::Get)
7869                .ok()
7870                .and_then(|result| result.cluster_id)
7871        };
7872
7873        assert_eq!(resolve("PRE.EXAMPLE.COM").as_deref(), Some("PRE"));
7874        assert_eq!(resolve("PrE.ExAmPlE.cOm").as_deref(), Some("PRE"));
7875        assert_eq!(resolve("FOO.POST.EXAMPLE.COM").as_deref(), Some("POST"));
7876        assert_eq!(resolve("foo.post.example.com").as_deref(), Some("POST"));
7877    }
7878
7879    /// The interaction that makes normalising the lookup key dangerous on
7880    /// its own: a pre/post hostname REGEX keeps the operator's bytes —
7881    /// `convert_regex_domain_rule` copies the segment source verbatim,
7882    /// as the trie now does too (`tree_hostname_to_ascii`, sozu#1377;
7883    /// up to and including 2.2.1 `domain_to_ascii` lowercased it there).
7884    /// Matching a
7885    /// normalised key against a case-sensitive `/API[0-9]/` would have
7886    /// turned a rule that used to match `API7.…` into one that matches
7887    /// nothing. `DomainRule::from_str` therefore compiles hostname
7888    /// regexes case-insensitively, which is also the only reading
7889    /// that agrees with the trie: `pattern_trie::compiled_segment`
7890    /// folds the same way, on the same stored bytes.
7891    ///
7892    /// To SEE THIS RED: in `DomainRule::from_str`, drop the
7893    /// `.case_insensitive(true)` from the `RegexBuilder` and build with
7894    /// `regex::bytes::Regex::new(&s)` again. Both assertions below fail
7895    /// with `left: None, right: Some("RX")`.
7896    #[test]
7897    fn an_uppercase_pre_rule_hostname_regex_still_matches_its_own_host() {
7898        let mut router = Router::new();
7899        let uppercase_regex = "/API[0-9]/.example.com"
7900            .parse::<DomainRule>()
7901            .expect("an uppercase hostname regex must parse");
7902        assert!(router.add_pre_rule(
7903            &uppercase_regex,
7904            &PathRule::Prefix("/".to_owned()),
7905            &MethodRule::new(None),
7906            &Route::ClusterId("RX".into()),
7907        ));
7908
7909        let resolve = |hostname: &str| {
7910            router
7911                .lookup(hostname, "/", &Method::Get)
7912                .ok()
7913                .and_then(|result| result.cluster_id)
7914        };
7915
7916        assert_eq!(
7917            resolve("API7.example.com").as_deref(),
7918            Some("RX"),
7919            "the spelling the operator wrote must keep routing",
7920        );
7921        assert_eq!(
7922            resolve("api7.example.com").as_deref(),
7923            Some("RX"),
7924            "and its canonical lowercase form, which never routed before",
7925        );
7926    }
7927
7928    /// The regression guard for the `.unicode(false)` that
7929    /// [`DomainRule::from_str`] deliberately does NOT set. Setting it
7930    /// would make the case folding ASCII-only — which is what the
7931    /// hostnames ever reaching `matches` are anyway — but it also
7932    /// refuses Unicode-class syntax at compile time, and `\p{L}` matches
7933    /// ASCII letters perfectly well, so a frontend that installs and
7934    /// routes today would start being rejected at add time.
7935    ///
7936    /// To SEE THIS RED: add `.unicode(false)` to the `RegexBuilder` in
7937    /// `DomainRule::from_str`. The parse fails and the `expect` fires.
7938    #[test]
7939    fn a_unicode_class_hostname_regex_still_compiles_and_routes() {
7940        let mut router = Router::new();
7941        let unicode_class = "/\\p{L}+[0-9]/.example.com"
7942            .parse::<DomainRule>()
7943            .expect("a hostname regex using a Unicode class must still compile");
7944        assert!(router.add_pre_rule(
7945            &unicode_class,
7946            &PathRule::Prefix("/".to_owned()),
7947            &MethodRule::new(None),
7948            &Route::ClusterId("UNICODE".into()),
7949        ));
7950
7951        let resolve = |hostname: &str| {
7952            router
7953                .lookup(hostname, "/", &Method::Get)
7954                .ok()
7955                .and_then(|result| result.cluster_id)
7956        };
7957
7958        assert_eq!(
7959            resolve("api7.example.com").as_deref(),
7960            Some("UNICODE"),
7961            "a Unicode letter class covers ASCII letters, so this rule is live, not decorative",
7962        );
7963        assert_eq!(
7964            resolve("API7.example.com").as_deref(),
7965            Some("UNICODE"),
7966            "and it stays live for the uppercase spelling of the same host",
7967        );
7968    }
7969
7970    /// Normalisation and the hostname precedence order of sozu#1351
7971    /// compose in one direction only: the key is normalised first, then
7972    /// the most-specific-first search runs on it, so case never decides
7973    /// WHICH candidate wins. Stated in `doc/configure.md` under
7974    /// "Hostname case", so it needs an assertion.
7975    ///
7976    /// The shape is the one the precedence section uses: an exact name
7977    /// and a regex segment that also claims it. The exact frontend must
7978    /// win for both spellings — a normalisation applied anywhere after
7979    /// the candidate search would let the uppercase spelling miss the
7980    /// exact node and fall through to the regex family.
7981    ///
7982    /// To SEE THIS RED: in `Router::lookup`, replace
7983    /// `let normalized_hostname = normalize_hostname(hostname);` with
7984    /// `let normalized_hostname = Cow::Borrowed(hostname);`. The
7985    /// uppercase spelling stops resolving at all.
7986    #[test]
7987    fn case_normalisation_runs_before_the_hostname_precedence_search() {
7988        let mut router = Router::new();
7989        assert!(router.add_tree_rule(
7990            b"test4.example.com",
7991            &PathRule::Prefix("/".to_owned()),
7992            &MethodRule::new(Some("GET".to_owned())),
7993            &Route::ClusterId("EXACT".into()),
7994        ));
7995        assert!(router.add_tree_rule(
7996            b"/test[0-9]/.example.com",
7997            &PathRule::Prefix("/".to_owned()),
7998            &MethodRule::new(Some("GET".to_owned())),
7999            &Route::ClusterId("REGEX-FAMILY".into()),
8000        ));
8001
8002        let resolve = |hostname: &str| {
8003            router
8004                .lookup(hostname, "/", &Method::Get)
8005                .ok()
8006                .and_then(|result| result.cluster_id)
8007        };
8008
8009        assert_eq!(
8010            resolve("test4.example.com").as_deref(),
8011            Some("EXACT"),
8012            "the exact name outranks a regex segment that also claims it",
8013        );
8014        for spelling in ["TEST4.EXAMPLE.COM", "TeSt4.ExAmPlE.cOm"] {
8015            assert_eq!(
8016                resolve(spelling).as_deref(),
8017                Some("EXACT"),
8018                "{spelling:?} must pick the same tier as its lowercase form, \
8019                 not fall through to the regex family",
8020            );
8021        }
8022
8023        // A host only the regex family claims still reaches it in either
8024        // spelling: normalising did not collapse the tiers, it fed them.
8025        assert_eq!(
8026            resolve("test7.example.com").as_deref(),
8027            Some("REGEX-FAMILY")
8028        );
8029        assert_eq!(
8030            resolve("TEST7.EXAMPLE.COM").as_deref(),
8031            Some("REGEX-FAMILY")
8032        );
8033    }
8034
8035    /// `has_hostname` decides whether a removed frontend's cached tags may
8036    /// be dropped. It is fed the CONFIGURED hostname, which the pre/post
8037    /// arms then compared byte-exact against a `DomainRule` the add path
8038    /// had already lowercased — so an uppercase frontend answered `false`
8039    /// while its route was still installed, and the tags went away under a
8040    /// live route.
8041    ///
8042    /// To SEE THIS RED: in `has_hostname`, replace
8043    /// `let normalized_hostname = normalize_hostname(hostname);` with
8044    /// `let normalized_hostname = Cow::Borrowed(hostname);`. The two
8045    /// uppercase assertions fail.
8046    #[test]
8047    fn has_hostname_answers_for_an_uppercase_spelling() {
8048        let mut router = Router::new();
8049        assert!(router.add_tree_rule(
8050            b"tree.example.com",
8051            &PathRule::Prefix("/".to_owned()),
8052            &MethodRule::new(Some("GET".to_owned())),
8053            &Route::ClusterId("TREE".into()),
8054        ));
8055        assert!(
8056            router.add_pre_rule(
8057                &"pre.example.com"
8058                    .parse::<DomainRule>()
8059                    .expect("an exact domain rule must parse"),
8060                &PathRule::Prefix("/".to_owned()),
8061                &MethodRule::new(None),
8062                &Route::ClusterId("PRE".into()),
8063            )
8064        );
8065
8066        assert!(router.has_hostname("TREE.EXAMPLE.COM"));
8067        assert!(router.has_hostname("PRE.EXAMPLE.COM"));
8068        assert!(!router.has_hostname("ABSENT.EXAMPLE.COM"));
8069    }
8070
8071    /// `$HOST[n]` captures are taken from the key the router matched on,
8072    /// so a rewrite template now emits the normalised host rather than
8073    /// the client's raw bytes. That is the intended reading — the
8074    /// configured frontend is itself stored lowercase — and it is stated
8075    /// in `doc/configure.md`, so it needs an assertion.
8076    ///
8077    /// To SEE THIS RED: same mutation as
8078    /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`; the rewrite
8079    /// stops resolving at all (`RouteNotFound`) and the `expect` fires.
8080    #[test]
8081    fn a_host_rewrite_capture_sees_the_normalized_host() {
8082        let mut router = Router::new();
8083        router
8084            .add_http_front(&HttpFrontend {
8085                hostname: "rewrite.example.com".to_owned(),
8086                rewrite_host: Some("$HOST[0].internal".to_owned()),
8087                ..test_http_frontend()
8088            })
8089            .expect("the rewrite frontend must install");
8090
8091        let route = router
8092            .lookup("REWRITE.EXAMPLE.COM", "/", &Method::Get)
8093            .expect("an uppercase Host must resolve");
8094        assert_eq!(
8095            route.rewritten_host.as_deref(),
8096            Some("rewrite.example.com.internal"),
8097            "`$HOST[0]` carries the normalised host, not the client's bytes",
8098        );
8099    }
8100
8101    /// The normalisation must cost a scan and not an allocation on the
8102    /// overwhelmingly common path — every conforming client already sends
8103    /// a lowercase Host, and this runs once per request.
8104    ///
8105    /// To SEE THIS RED: make `normalize_hostname` return
8106    /// `Cow::Owned(hostname.to_ascii_lowercase())` unconditionally. The
8107    /// first assertion fails.
8108    #[test]
8109    fn normalize_hostname_borrows_an_already_lowercase_host() {
8110        assert!(
8111            matches!(normalize_hostname("www.example.com"), Cow::Borrowed(_)),
8112            "a lowercase host must not allocate",
8113        );
8114        assert!(
8115            matches!(normalize_hostname("123.example-host.com"), Cow::Borrowed(_)),
8116            "digits and hyphens are not uppercase and must not allocate",
8117        );
8118        assert_eq!(
8119            normalize_hostname("WwW.ExAmPlE.cOm"),
8120            Cow::Owned::<str>("www.example.com".to_owned()),
8121        );
8122    }
8123
8124    /// Known gap, deliberately NOT closed here: RFC 1034 §3.1 makes
8125    /// `www.example.com.` the absolute form of `www.example.com`, but
8126    /// `idna::domain_to_ascii` keeps the trailing dot on the add path
8127    /// (measured: `"www.example.com."` -> `"www.example.com."`) and the
8128    /// trie then splits it into a trailing empty label that no
8129    /// dot-free frontend carries. The TLS side already strips one
8130    /// trailing dot (`lib/src/https.rs`'s `sni_owned`,
8131    /// `lib/src/protocol/tcp_preread/mod.rs`'s `normalize_sni`); HTTP
8132    /// routing does not. This test records the current answer so the day
8133    /// someone fixes it, it fails here and gets inverted rather than
8134    /// silently changing behaviour.
8135    ///
8136    /// To SEE THIS RED: strip a single trailing `.` inside
8137    /// `normalize_hostname`.
8138    #[test]
8139    fn an_absolute_form_host_does_not_reach_a_relative_frontend_yet() {
8140        let mut router = Router::new();
8141        assert!(router.add_tree_rule(
8142            b"www.example.com",
8143            &PathRule::Prefix("/".to_owned()),
8144            &MethodRule::new(Some("GET".to_owned())),
8145            &Route::ClusterId("CASE".into()),
8146        ));
8147
8148        assert!(
8149            router
8150                .lookup("www.example.com.", "/", &Method::Get)
8151                .is_err(),
8152            "absolute-form Host routing is a separate defect, tracked \
8153             apart from the case asymmetry",
8154        );
8155        assert!(
8156            router
8157                .lookup("WWW.EXAMPLE.COM.", "/", &Method::Get)
8158                .is_err(),
8159            "normalising the case must not accidentally close it either",
8160        );
8161    }
8162
8163    // ── sozu#1377: IDNA folding must not reach a regex segment ─────────
8164    //
8165    // `add_tree_rule`, `remove_tree_rule` and `has_hostname` used to run
8166    // the WHOLE configured hostname through `idna::domain_to_ascii`,
8167    // which ASCII-lowercases (measured: `"/\\D+/.example.com"` ->
8168    // `"/\\d+/.example.com"`, `"/[^\\D]/.example.com"` ->
8169    // `"/[^\\d]/.example.com"`, `"/API[0-9]/.rx.example.com"` ->
8170    // `"/api[0-9]/.rx.example.com"`). Folding an uppercase regex escape
8171    // INVERTS the character class it names -- `\D` is "not a digit",
8172    // `\d` is "a digit"; `\W`/`\w` and `\S`/`\s` invert the same way --
8173    // so the stored rule matched the exact COMPLEMENT of what the
8174    // operator wrote while `sozu query frontends` still echoed the
8175    // original spelling.
8176    //
8177    // `Pre`/`Post` never carried it: `convert_regex_domain_rule` copies a
8178    // segment's source verbatim and `DomainRule::from_str` takes its case
8179    // insensitivity from the `RegexBuilder` instead. The tree now agrees:
8180    // `tree_hostname_to_ascii` normalises only the literal labels and
8181    // `pattern_trie::compiled_segment` folds case at compile time.
8182
8183    /// To SEE THIS RED: in `tree_hostname_to_ascii`, replace the whole
8184    /// body with `::idna::domain_to_ascii(hostname).ok()`, the form the
8185    /// three call sites carried up to 2.2.1. Both assertions then fail
8186    /// INVERTED -- `abc.rx.example.com` resolves to `None` and
8187    /// `777.rx.example.com` to `Some("NOT-A-DIGIT")` -- which is the
8188    /// defect itself: the rule matches the complement of its source --
8189    /// `\D` matching a digit.
8190    #[test]
8191    fn a_tree_hostname_regex_escape_is_not_case_folded() {
8192        let mut router = Router::new();
8193        assert!(router.add_tree_rule(
8194            "/\\D+/.rx.example.com".as_bytes(),
8195            &PathRule::Prefix("/".to_owned()),
8196            &MethodRule::new(None),
8197            &Route::ClusterId("NOT-A-DIGIT".into()),
8198        ));
8199
8200        let resolve = |hostname: &str| {
8201            router
8202                .lookup(hostname, "/", &Method::Get)
8203                .ok()
8204                .and_then(|result| result.cluster_id)
8205        };
8206
8207        assert_eq!(
8208            resolve("777.rx.example.com"),
8209            None,
8210            "`\\D+` must NOT match a run of digits; folded to `\\d+` it matched \
8211             exactly this and nothing else",
8212        );
8213        assert_eq!(
8214            resolve("abc.rx.example.com").as_deref(),
8215            Some("NOT-A-DIGIT"),
8216            "`\\D+` is \"one or more non-digits\" and `abc` is three of them",
8217        );
8218    }
8219
8220    /// The negated-class spelling from the report, and the two other
8221    /// uppercase escapes that invert: `\W` (not a word character) and
8222    /// `\S` (not whitespace). `[^\D]` is "not a non-digit", i.e. a digit;
8223    /// folded to `[^\d]` it becomes its own complement.
8224    ///
8225    /// To SEE THIS RED: same mutation as
8226    /// `a_tree_hostname_regex_escape_is_not_case_folded`. Every
8227    /// `matches` assertion below fails, each with its complement.
8228    #[test]
8229    fn every_inverting_uppercase_regex_escape_survives_a_tree_insert() {
8230        // (segment source, a host label it must match, one it must not)
8231        for (segment, hit, miss) in [
8232            ("[^\\D]+", "777", "abc"),
8233            ("\\W+", "---", "abc"),
8234            ("\\S+", "abc", "   "),
8235        ] {
8236            let mut router = Router::new();
8237            let hostname = format!("/{segment}/.rx.example.com");
8238            assert!(
8239                router.add_tree_rule(
8240                    hostname.as_bytes(),
8241                    &PathRule::Prefix("/".to_owned()),
8242                    &MethodRule::new(None),
8243                    &Route::ClusterId("ESCAPE".into()),
8244                ),
8245                "{hostname} must install",
8246            );
8247
8248            let resolve = |hostname: &str| {
8249                router
8250                    .lookup(hostname, "/", &Method::Get)
8251                    .ok()
8252                    .and_then(|result| result.cluster_id)
8253            };
8254
8255            assert_eq!(
8256                resolve(&format!("{hit}.rx.example.com")).as_deref(),
8257                Some("ESCAPE"),
8258                "{segment} must match {hit}",
8259            );
8260            assert_eq!(
8261                resolve(&format!("{miss}.rx.example.com")),
8262                None,
8263                "{segment} must not match {miss}",
8264            );
8265        }
8266    }
8267
8268    /// The other half of the split: a LITERAL label is still a domain
8269    /// label and still goes through IDNA, in the very same hostname whose
8270    /// regex segment is copied verbatim. Only a `/`-delimited segment is
8271    /// exempt -- the same split `convert_regex_domain_rule` and the trie's
8272    /// `insert_recursive` already make.
8273    ///
8274    /// `remove_tree_rule` normalises through the same helper, so the rule
8275    /// stays addressable: a second spelling of the stored key would make
8276    /// the frontend permanently un-removable.
8277    ///
8278    /// To SEE THIS RED: same mutation as
8279    /// `a_tree_hostname_regex_escape_is_not_case_folded`. The first
8280    /// assertion fails with `left: None, right: Some("IDN")` -- the
8281    /// punycode is right, the folded `\d+` segment is what misses.
8282    #[test]
8283    fn a_unicode_label_is_punycoded_while_its_sibling_regex_segment_is_not() {
8284        let hostname = "/\\D+/.MÜNCHEN.example.com";
8285        let mut router = Router::new();
8286        assert!(router.add_tree_rule(
8287            hostname.as_bytes(),
8288            &PathRule::Prefix("/".to_owned()),
8289            &MethodRule::new(None),
8290            &Route::ClusterId("IDN".into()),
8291        ));
8292
8293        // `idna::domain_to_ascii("MÜNCHEN")` -> `"xn--mnchen-3ya"`
8294        // (measured, and identical label-by-label and whole-domain).
8295        assert_eq!(
8296            router
8297                .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8298                .ok()
8299                .and_then(|result| result.cluster_id)
8300                .as_deref(),
8301            Some("IDN"),
8302            "the literal label must still be punycoded and lowercased",
8303        );
8304
8305        assert!(
8306            router.remove_tree_rule(
8307                hostname.as_bytes(),
8308                &PathRule::Prefix("/".to_owned()),
8309                &MethodRule::new(None),
8310            ),
8311            "the frontend must still be addressable by the spelling that \
8312             installed it",
8313        );
8314        assert!(
8315            router
8316                .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8317                .is_err(),
8318            "and removing it must actually unroute it",
8319        );
8320    }
8321
8322    // ═══════════════════════════════════════════════════════════════════
8323    // Property-test harness: sozu#1349, sozu#1351, sozu#1356, sozu#1377
8324    // ═══════════════════════════════════════════════════════════════════
8325    //
8326    // Every regression test above pins ONE fixed example. This harness
8327    // generates many, against an ORACLE that is independent of the trie:
8328    // it does not call `pattern_trie::anchored_segment`,
8329    // `compiled_segment`, `TrieNode::lookup*`, `tree_hostname_to_ascii` or
8330    // `convert_regex_domain_rule` — reusing any of those would make the
8331    // oracle agree with the implementation by construction, proving
8332    // nothing. It is written fresh from `doc/configure.md`'s "Hostname
8333    // precedence" and "Regex hostname segments" sections and the RFC text
8334    // those sections cite (RFC 9110 §4.2.3 for case, RFC 9110's own
8335    // grammar for the wildcard), and it uses the `regex` crate only as a
8336    // primitive — exactly as `pattern_trie` does — never the router's own
8337    // wrapping of it.
8338    //
8339    // Scope, stated explicitly because it is a deliberate narrowing: every
8340    // generated rule and every query share the fixed two-label suffix
8341    // `example.com`, one variable FRONT label, and `MethodRule::new(None)`
8342    // (matches every method). This is what puts every rule in a scenario
8343    // on the SAME trie branch, which is where sozu#1351's leak and
8344    // precedence questions live, and it removes METHOD precedence from
8345    // the oracle's job — a separate concern with its own existing tests
8346    // (`case_normalisation_runs_before_the_hostname_precedence_search`
8347    // and neighbours). PATH does vary — see `PATH_POOL` — because sozu#1351's
8348    // actual production symptom needs it: with every rule pinned to the
8349    // SAME path, a literal whose insert would leak onto a regex leaf finds
8350    // that leaf already carrying an identical `(path, method)`, so
8351    // `add_tree_rule`'s append-skip condition is always false and the
8352    // insert is refused before any lookup happens — the leak is real but
8353    // observable only as `add_tree_rule` returning `false`, never as a
8354    // routing mismatch. Varying path is what lets the SAME leak surface the
8355    // way it did in production: an exact rule at one path answering a
8356    // request for a hostname nobody declared, at a DIFFERENT declared path.
8357    //
8358    // The generator is biased, not uniform: `REGEX_CATALOG` is the fixed
8359    // set of segment shapes that actually bit sozu, `LITERAL_POOL`
8360    // deliberately overlaps those shapes' `hits` so an exact rule and a
8361    // regex rule naturally collide on the same host (sozu#1351), case is
8362    // varied on both the declared rule and the query independently
8363    // (sozu#1349), declaration order is an explicit Fisher-Yates shuffle
8364    // driven by the same `Gen` (sozu#1351 again — order must not move an
8365    // exact-vs-regex verdict, and must move a regex-vs-regex one), and
8366    // queries cross every generated hit/miss hostname against every path
8367    // declared anywhere in the scenario, not just the declaring rule's own
8368    // (sozu#1351's leak crosses fronts, so a query pairing has to as well).
8369    //
8370    // Two honesty notes about this harness's own limits, not the router's:
8371    //
8372    // - **No reproducible seed.** `quickcheck` 1.1.0's `Gen::new` seeds
8373    //   from OS entropy with no seed exposed to reproduce a specific run,
8374    //   unlike the FoundationDB-style simulators `doc/testing.md` §5
8375    //   describes (`SOZU_UDP_SIM_SEED` and friends). A failing CI run here
8376    //   is reproduced by RE-RUNNING locally with a raised `QUICKCHECK_TESTS`
8377    //   (this property's generator is dense enough that a real regression
8378    //   reappears quickly), not by replaying the failing seed — there is
8379    //   none to replay. Not fixed in this changeset.
8380    // - **The anchoring convention is shared, not external.** This oracle's
8381    //   `\A(?:source)\z` case-insensitive anchoring matches
8382    //   `doc/configure.md`'s "Regex hostname segments" section — but that
8383    //   section's prose was itself ADDED by the sozu#1356 fix this harness
8384    //   is supposed to catch a regression of. So for the GROUPING half of
8385    //   that anchoring, the oracle and the implementation share a
8386    //   convention rather than the oracle checking against an independent
8387    //   spec; a regression that un-groups BOTH the doc and the code back to
8388    //   their pre-#1356 form would not be caught by prose comparison alone
8389    //   — it is caught here because the oracle's grouping is also
8390    //   independently reasoned from what `|`'s precedence in a regex
8391    //   grammar requires, the same reasoning #1356's own fix commit gives.
8392    //   The CASE-FOLDING half (`(?i)`) is genuinely independent, derived
8393    //   straight from RFC 9110 §4.2.3, not from anything #1356 or #1377
8394    //   added to `doc/configure.md`.
8395
8396    /// One `/regex/` hostname segment source this harness declares, with
8397    /// concrete labels independently reasoned to match or not match it
8398    /// under `\A(?:source)\z` compiled case-insensitively — the anchoring
8399    /// `doc/configure.md` documents and sozu#1356 restored, built fresh
8400    /// here rather than borrowed from `anchored_segment`.
8401    struct RegexCase {
8402        source: &'static str,
8403        /// Whole-label matches for one branch of the pattern.
8404        hits: &'static [&'static str],
8405        /// Labels that must NOT match under the documented semantics, but
8406        /// that a pre-fix implementation matched: a half-anchored
8407        /// alternation branch (sozu#1356 — `axx`, `xxb`, the bare
8408        /// substring `zzbzz` for a 3-way alternation's ungrouped middle
8409        /// branch) or the inverted class an ASCII-lowercase fold produces
8410        /// (sozu#1377 — folding `\D` into `\d` turns "not a digit" into
8411        /// "a digit").
8412        misses: &'static [&'static str],
8413    }
8414
8415    const REGEX_CATALOG: &[RegexCase] = &[
8416        RegexCase {
8417            source: "a|b",
8418            hits: &["a", "b"],
8419            misses: &["axx", "xxb", "xx"],
8420        },
8421        RegexCase {
8422            source: "a|b|c",
8423            // The 3-branch case two branches cannot express: ungrouped,
8424            // the MIDDLE branch of `\Aa|b|c\z` keeps NEITHER anchor and
8425            // matches as a bare substring anywhere in the label.
8426            hits: &["a", "b", "c"],
8427            misses: &["axx", "xxc", "zzbzz", "bzz", "zzb"],
8428        },
8429        RegexCase {
8430            source: "api|admin",
8431            // `doc/configure.md`'s own worked example for sozu#1356.
8432            hits: &["api", "admin"],
8433            misses: &["apifoo", "xadmin"],
8434        },
8435        RegexCase {
8436            source: "\\D+",
8437            // sozu#1377's own measured shape: folded to `\d+`, this
8438            // matched digits and rejected letters — the complement.
8439            hits: &["abc", "xyz"],
8440            misses: &["777", "42"],
8441        },
8442        RegexCase {
8443            source: "\\W+",
8444            hits: &["---", "..."],
8445            misses: &["abc", "42a"],
8446        },
8447        RegexCase {
8448            source: "\\S+",
8449            hits: &["abc", "777"],
8450            // Folded to `\s`, this would match ONLY whitespace; no ASCII
8451            // hostname label the wire ever admits can carry one, but
8452            // `Router::lookup` takes a plain `&str` and does not itself
8453            // re-validate the charset, so the harness can still probe it.
8454            misses: &[" "],
8455        },
8456        RegexCase {
8457            source: "[^\\D]+",
8458            // sozu#1377's negated-class shape from the issue itself:
8459            // "not a non-digit" is a digit; folded it becomes its own
8460            // complement.
8461            hits: &["777", "42"],
8462            misses: &["abc"],
8463        },
8464        RegexCase {
8465            source: "API[0-9]",
8466            // An uppercase literal INSIDE a class: must still meet the
8467            // lowercased lookup key by folding at compile time, not by
8468            // folding the stored source (which would invert the escapes
8469            // above in the very same pass).
8470            hits: &["api7", "API7", "ApI4"],
8471            misses: &["apiZ"],
8472        },
8473        RegexCase {
8474            source: "test[0-9]",
8475            // sozu#1351's own shape: `test4` is also `LITERAL_POOL`'s
8476            // exact-rule text, so a literal `test4` rule and this regex
8477            // rule collide on the same host with decent probability.
8478            hits: &["test4", "TEST7", "Test9"],
8479            misses: &["testA", "test10x"],
8480        },
8481        RegexCase {
8482            source: "[a-z]+[0-9]",
8483            hits: &["test4", "cdn1"],
8484            misses: &["4test", "TEST"],
8485        },
8486    ];
8487
8488    /// Literal exact-rule candidates. Deliberately overlapping with
8489    /// `REGEX_CATALOG`'s `hits` — `test4`, `api`, `admin`, `a`, `b`, `c`
8490    /// all reappear there — so an exact rule and an overlapping regex
8491    /// rule land in the SAME generated scenario often enough to exercise
8492    /// sozu#1351 without hand-injecting a forced pair.
8493    const LITERAL_POOL: &[&str] = &[
8494        "a", "b", "c", "api", "admin", "test4", "test7", "test9", "TEST4", "API7", "abc", "777",
8495        "42", "cdn1", "zz", "apifoo", "xadmin",
8496    ];
8497
8498    /// Declared-path candidates. Small and deliberately NOT elaborate: the
8499    /// production shape of sozu#1351 needs exactly two rules at two
8500    /// different paths (reproduced live against the reverted fix: an exact
8501    /// `test4.example.com` frontend at `/other` leaking onto the
8502    /// `/test[0-9]/.example.com` regex family, answering `test7.example.com`
8503    /// requests for `/other`). `"/"` is a prefix of every other entry here
8504    /// and is weighted to appear often, so a scenario still frequently
8505    /// declares two fronts at the SAME path (exercising the harness's
8506    /// pre-existing hostname-only coverage) as well as at different ones
8507    /// (exercising the path dimension this fixes). Every entry is used only
8508    /// as a `PathRule::Prefix`; `PathRule::Equals`/`PathRule::Regex` are not
8509    /// generated — see the module doc comment for why method stays fixed
8510    /// too, and the same reasoning applies here: sozu#1351 needs two
8511    /// declared paths to differ, not the full path-matching precedence
8512    /// surface, which has its own existing tests.
8513    const PATH_POOL: &[&str] = &["/", "/", "/other", "/api", "/only"];
8514
8515    /// The dot-separated FRONT label of a generated Tree-position
8516    /// hostname rule. Every rule in a [`Scenario`] shares the fixed
8517    /// trailing `example.com` — see the harness's module-level doc
8518    /// comment for why.
8519    #[derive(Clone, Copy, Debug)]
8520    enum FrontLabel {
8521        /// A literal label, in whatever case the generator chose.
8522        /// `tree_hostname_to_ascii` case-folds it on insert (sozu#1377).
8523        Literal(&'static str),
8524        /// The leftmost-only wildcard, exactly one label.
8525        Wildcard,
8526        /// A `/regex/` segment, addressed into [`REGEX_CATALOG`].
8527        Regex(usize),
8528    }
8529
8530    /// Dedup identity for a [`FrontLabel`] within one scenario. Two
8531    /// literals that differ only by case must be treated as the SAME
8532    /// front: `add_tree_rule` case-folds them to the same trie node, and
8533    /// generating both would silently drop one rule's cluster id from the
8534    /// harness's own model — a harness bug, not something under test.
8535    #[derive(Clone, PartialEq, Eq, Debug)]
8536    enum FrontKey {
8537        Literal(String),
8538        Wildcard,
8539        Regex(usize),
8540    }
8541
8542    impl From<FrontLabel> for FrontKey {
8543        fn from(front: FrontLabel) -> Self {
8544            match front {
8545                FrontLabel::Literal(text) => FrontKey::Literal(text.to_ascii_lowercase()),
8546                FrontLabel::Wildcard => FrontKey::Wildcard,
8547                FrontLabel::Regex(idx) => FrontKey::Regex(idx),
8548            }
8549        }
8550    }
8551
8552    #[derive(Clone, Debug)]
8553    struct GenRule {
8554        front: FrontLabel,
8555        /// This rule's OWN declared path, from [`PATH_POOL`]. Every front
8556        /// carries exactly one `(path, route)` pair in this harness's
8557        /// model — one `add_tree_rule` call per generated front — so there
8558        /// is no within-one-hostname path-precedence list to model; only
8559        /// whether THIS front's path serves a given query path.
8560        path: &'static str,
8561        cluster: String,
8562    }
8563
8564    /// One generated case: a set of Tree-position rules, in DECLARATION
8565    /// order (already shuffled — see [`Scenario::arbitrary`]), and a set
8566    /// of (hostname, path) queries to look up against them.
8567    #[derive(Clone, Debug)]
8568    struct Scenario {
8569        rules: Vec<GenRule>,
8570        queries: Vec<(String, String)>,
8571    }
8572
8573    fn render_hostname(front: FrontLabel) -> String {
8574        match front {
8575            FrontLabel::Literal(text) => format!("{text}.example.com"),
8576            FrontLabel::Wildcard => "*.example.com".to_owned(),
8577            FrontLabel::Regex(idx) => format!("/{}/.example.com", REGEX_CATALOG[idx].source),
8578        }
8579    }
8580
8581    /// The oracle's OWN anchoring: `\A(?:source)\z`, case-insensitive.
8582    /// Built fresh from `doc/configure.md`'s "Regex hostname segments"
8583    /// section and the sozu#1356 fix's stated invariant, not by calling
8584    /// `pattern_trie::anchored_segment` or `compiled_segment`. Using the
8585    /// `regex` crate as a primitive is not reusing the implementation:
8586    /// regex matching itself is not what any of the four issues broke —
8587    /// Sozu's own anchoring, grouping and case handling around it is.
8588    fn oracle_regex_matches(source: &str, label: &str) -> bool {
8589        let pattern = format!("(?i)\\A(?:{source})\\z");
8590        Regex::new(&pattern)
8591            .unwrap_or_else(|error| {
8592                panic!("the harness's own catalog regex {source:?} must compile: {error}")
8593            })
8594            .is_match(label.as_bytes())
8595    }
8596
8597    fn front_label_matches(front: FrontLabel, query_label: &str) -> bool {
8598        match front {
8599            FrontLabel::Literal(text) => text.eq_ignore_ascii_case(query_label),
8600            FrontLabel::Wildcard => !query_label.is_empty(),
8601            FrontLabel::Regex(idx) => oracle_regex_matches(REGEX_CATALOG[idx].source, query_label),
8602        }
8603    }
8604
8605    /// Whether `front` (this harness's stand-in for one declared Tree
8606    /// rule) matches the full hostname `query` — case-insensitively on
8607    /// the fixed `example.com` suffix (RFC 9110 §4.2.3, sozu#1349) and
8608    /// per [`front_label_matches`] on the one variable label.
8609    fn oracle_hostname_matches(front: FrontLabel, query: &str) -> bool {
8610        let mut labels = query.split('.');
8611        let (Some(first), Some(second), Some(third), None) =
8612            (labels.next(), labels.next(), labels.next(), labels.next())
8613        else {
8614            return false;
8615        };
8616        second.eq_ignore_ascii_case("example")
8617            && third.eq_ignore_ascii_case("com")
8618            && front_label_matches(front, first)
8619    }
8620
8621    /// `PathRule::Prefix` semantics (`PathRule::matches`, `router/mod.rs`):
8622    /// a rule matches iff the query path starts with the declared path's
8623    /// bytes. Every generated front carries exactly one path, so there is
8624    /// no longest-prefix tie-break to model here — see [`GenRule::path`].
8625    fn oracle_path_matches(rule_path: &str, query_path: &str) -> bool {
8626        query_path.starts_with(rule_path)
8627    }
8628
8629    /// The oracle's precedence search, stated in `doc/configure.md` under
8630    /// "Hostname precedence" and restored by sozu#1351: the EXACT name,
8631    /// then a REGEX segment (the first declared among those that match),
8632    /// then the `*` WILDCARD — each candidate accepted only when it ALSO
8633    /// serves this request's path (`Router::lookup` hands the trie the
8634    /// same path-serving predicate it uses on the winner, so a hostname
8635    /// candidate that matches but does not serve this path is skipped
8636    /// rather than ending the search — `doc/configure.md`, "Hostname
8637    /// precedence": "a search, not a filter"). Exact-vs-regex hostname
8638    /// precedence is checked in a pass that does not depend on `rules`'
8639    /// order at all — the documented, order-independent half of the
8640    /// invariant sozu#1351 restored. Regex-vs-regex precedence is "first
8641    /// in `rules`' own order that matches (hostname AND path)" — the half
8642    /// that DOES still depend on declaration order, by design
8643    /// (`doc/configure.md`, "Regex hostname segments").
8644    fn oracle_lookup<'r>(rules: &'r [GenRule], hostname: &str, path: &str) -> Option<&'r str> {
8645        let serves = |rule: &&GenRule| {
8646            oracle_hostname_matches(rule.front, hostname) && oracle_path_matches(rule.path, path)
8647        };
8648        rules
8649            .iter()
8650            .find(|rule| matches!(rule.front, FrontLabel::Literal(_)) && serves(rule))
8651            .or_else(|| {
8652                rules
8653                    .iter()
8654                    .find(|rule| matches!(rule.front, FrontLabel::Regex(_)) && serves(rule))
8655            })
8656            .or_else(|| {
8657                rules
8658                    .iter()
8659                    .find(|rule| matches!(rule.front, FrontLabel::Wildcard) && serves(rule))
8660            })
8661            .map(|rule| rule.cluster.as_str())
8662    }
8663
8664    /// Vary the case of `s`: as declared, all-uppercase, all-lowercase, or
8665    /// a per-character random mix — independently of whatever case the
8666    /// OTHER side (rule vs. query) picked, so RFC 9110 §4.2.3
8667    /// case-insensitivity (sozu#1349) is exercised on both sides and not
8668    /// just one.
8669    fn case_variant(g: &mut Gen, s: &str) -> String {
8670        match u8::arbitrary(g) % 4 {
8671            0 => s.to_owned(),
8672            1 => s.to_ascii_uppercase(),
8673            2 => s.to_ascii_lowercase(),
8674            _ => s
8675                .chars()
8676                .map(|c| {
8677                    if bool::arbitrary(g) {
8678                        c.to_ascii_uppercase()
8679                    } else {
8680                        c.to_ascii_lowercase()
8681                    }
8682                })
8683                .collect(),
8684        }
8685    }
8686
8687    fn push_unique_front(
8688        front: FrontLabel,
8689        path: &'static str,
8690        rules: &mut Vec<GenRule>,
8691        seen: &mut Vec<FrontKey>,
8692    ) {
8693        let key = FrontKey::from(front);
8694        if seen.contains(&key) {
8695            return;
8696        }
8697        seen.push(key);
8698        let cluster = format!("C{}", rules.len());
8699        rules.push(GenRule {
8700            front,
8701            path,
8702            cluster,
8703        });
8704    }
8705
8706    /// One hit and one miss hostname sample per declared front (see
8707    /// [`RegexCase`]'s doc comment for what those pin), plus three
8708    /// universal hostname probes every scenario gets regardless of what it
8709    /// declared: an unrelated host that must match nothing, the bare
8710    /// suffix with the front label dropped entirely (must miss even a
8711    /// declared wildcard, which stands for exactly one label and not
8712    /// zero), and its uppercase spelling.
8713    ///
8714    /// Each hit hostname is queried at EVERY distinct path declared
8715    /// anywhere in the scenario, not just its own front's path — sozu#1351's
8716    /// leak crosses fronts (an exact rule's cluster answering a DIFFERENT
8717    /// hostname, at whatever path THAT hostname's own rule declared), so a
8718    /// query pairing that only ever asked a front's own path could not
8719    /// observe it. Miss hostnames and the universal probes are checked only
8720    /// at their own front's path (or `/`): they exist to confirm a mismatch
8721    /// does not appear where none is expected, which does not need the
8722    /// cross product.
8723    fn push_hit_queries(
8724        g: &mut Gen,
8725        label: &str,
8726        declared_paths: &[&str],
8727        queries: &mut Vec<(String, String)>,
8728    ) {
8729        for &path in declared_paths {
8730            queries.push((
8731                format!("{}.example.com", case_variant(g, label)),
8732                path.to_owned(),
8733            ));
8734        }
8735    }
8736
8737    fn generate_queries(g: &mut Gen, rules: &[GenRule]) -> Vec<(String, String)> {
8738        let mut queries = Vec::new();
8739        let mut declared_paths: Vec<&str> = rules.iter().map(|rule| rule.path).collect();
8740        declared_paths.sort_unstable();
8741        declared_paths.dedup();
8742
8743        for rule in rules {
8744            match rule.front {
8745                FrontLabel::Literal(text) => {
8746                    push_hit_queries(g, text, &declared_paths, &mut queries);
8747                }
8748                FrontLabel::Wildcard => {
8749                    let label = *g.choose(LITERAL_POOL).unwrap_or(&"zz");
8750                    push_hit_queries(g, label, &declared_paths, &mut queries);
8751                }
8752                FrontLabel::Regex(idx) => {
8753                    let case = &REGEX_CATALOG[idx];
8754                    if let Some(&hit) = g.choose(case.hits) {
8755                        push_hit_queries(g, hit, &declared_paths, &mut queries);
8756                    }
8757                    if let Some(miss) = g.choose(case.misses) {
8758                        queries.push((
8759                            format!("{}.example.com", case_variant(g, miss)),
8760                            rule.path.to_owned(),
8761                        ));
8762                    }
8763                }
8764            }
8765        }
8766
8767        queries.push((
8768            format!("{}.example.com", case_variant(g, "unmatched-host")),
8769            "/".to_owned(),
8770        ));
8771        queries.push(("example.com".to_owned(), "/".to_owned()));
8772        queries.push(("EXAMPLE.COM".to_owned(), "/".to_owned()));
8773
8774        queries
8775    }
8776
8777    impl Arbitrary for Scenario {
8778        fn arbitrary(g: &mut Gen) -> Self {
8779            let mut rules = Vec::new();
8780            let mut seen_keys = Vec::new();
8781
8782            let pick_path = |g: &mut Gen| {
8783                *g.choose(PATH_POOL)
8784                    .expect("PATH_POOL is a non-empty const slice")
8785            };
8786
8787            // With even odds, include the wildcard: it participates in
8788            // every precedence chain and costs only one extra rule.
8789            if bool::arbitrary(g) {
8790                let path = pick_path(g);
8791                push_unique_front(FrontLabel::Wildcard, path, &mut rules, &mut seen_keys);
8792            }
8793
8794            // 1..=3 additional fronts, each independently a regex segment
8795            // or a literal drawn from the overlap-biased pool, each with
8796            // its own independently chosen path.
8797            let extra = 1 + (u8::arbitrary(g) % 3);
8798            for _ in 0..extra {
8799                if bool::arbitrary(g) {
8800                    let idx = usize::from(u8::arbitrary(g)) % REGEX_CATALOG.len();
8801                    let path = pick_path(g);
8802                    push_unique_front(FrontLabel::Regex(idx), path, &mut rules, &mut seen_keys);
8803                } else {
8804                    let text = *g
8805                        .choose(LITERAL_POOL)
8806                        .expect("LITERAL_POOL is a non-empty const slice");
8807                    let path = pick_path(g);
8808                    push_unique_front(FrontLabel::Literal(text), path, &mut rules, &mut seen_keys);
8809                }
8810            }
8811
8812            // Declaration order is itself under test (sozu#1351): shuffle
8813            // with a Fisher-Yates driven by the same `Gen`, independent of
8814            // insertion order above.
8815            for i in (1..rules.len()).rev() {
8816                let j = usize::from(u8::arbitrary(g)) % (i + 1);
8817                rules.swap(i, j);
8818            }
8819
8820            let queries = generate_queries(g, &rules);
8821            Scenario { rules, queries }
8822        }
8823
8824        fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
8825            let mut shrunk = Vec::new();
8826
8827            for i in 0..self.rules.len() {
8828                let mut rules = self.rules.clone();
8829                rules.remove(i);
8830                shrunk.push(Scenario {
8831                    rules,
8832                    queries: self.queries.clone(),
8833                });
8834            }
8835
8836            for i in 0..self.queries.len() {
8837                let mut queries = self.queries.clone();
8838                queries.remove(i);
8839                shrunk.push(Scenario {
8840                    rules: self.rules.clone(),
8841                    queries,
8842                });
8843            }
8844
8845            Box::new(shrunk.into_iter())
8846        }
8847    }
8848
8849    /// Every catalog entry's own `hits`/`misses` verdicts, checked against
8850    /// nothing but [`oracle_regex_matches`] — a self-check that the
8851    /// harness's table is internally consistent BEFORE it is trusted to
8852    /// judge the router. This does not touch `Router` or `TrieNode` at
8853    /// all.
8854    #[test]
8855    fn regex_catalog_samples_match_their_own_documented_semantics() {
8856        for case in REGEX_CATALOG {
8857            for hit in case.hits {
8858                assert!(
8859                    oracle_regex_matches(case.source, hit),
8860                    "{:?} must match {hit:?} under \\A(?:...)\\z case-insensitive",
8861                    case.source,
8862                );
8863            }
8864            for miss in case.misses {
8865                assert!(
8866                    !oracle_regex_matches(case.source, miss),
8867                    "{:?} must NOT match {miss:?} under \\A(?:...)\\z case-insensitive",
8868                    case.source,
8869                );
8870            }
8871        }
8872    }
8873
8874    /// Build the rules `scenario` declares into a fresh [`Router`], in
8875    /// its own declaration order, then check every query against both the
8876    /// oracle and the router.
8877    fn oracle_matches_router(scenario: Scenario) -> TestResult {
8878        if scenario.rules.is_empty() {
8879            return TestResult::discard();
8880        }
8881
8882        let mut router = Router::new();
8883        for rule in &scenario.rules {
8884            let hostname = render_hostname(rule.front);
8885            // A refused insert here is not automatically dismissed as a
8886            // harness artifact: sozu#1351's own mechanism (a literal
8887            // resolving through a matching regex segment because
8888            // `TrieNode::lookup_mut`'s guard is gone) can make this EXACT
8889            // call return `false`, when the two fronts' generated paths
8890            // happen to coincide -- see the module doc comment. Every
8891            // front here is still unique (`FrontKey`-deduped) and every
8892            // catalog regex compiles standalone, so a refusal is real
8893            // signal either way: fail loudly and report it rather than
8894            // silently skipping the rule, which would just hide the
8895            // question inside a `None` at lookup time instead of at
8896            // insert time.
8897            if !router.add_tree_rule(
8898                hostname.as_bytes(),
8899                &PathRule::Prefix(rule.path.to_owned()),
8900                &MethodRule::new(None),
8901                &Route::ClusterId(rule.cluster.as_str().into()),
8902            ) {
8903                eprintln!(
8904                    "INSERT REFUSED hostname={hostname:?} path={:?} cluster={:?} \
8905                     declared-so-far={:?}",
8906                    rule.path,
8907                    rule.cluster,
8908                    scenario
8909                        .rules
8910                        .iter()
8911                        .map(|r| (render_hostname(r.front), r.path))
8912                        .collect::<Vec<_>>(),
8913                );
8914                return TestResult::failed();
8915            }
8916        }
8917
8918        for (hostname, path) in &scenario.queries {
8919            let expected = oracle_lookup(&scenario.rules, hostname, path);
8920            let actual = router
8921                .lookup(hostname, path, &Method::Get)
8922                .ok()
8923                .and_then(|result| result.cluster_id);
8924
8925            if expected != actual.as_deref() {
8926                let declared: Vec<(String, &str)> = scenario
8927                    .rules
8928                    .iter()
8929                    .map(|rule| (render_hostname(rule.front), rule.path))
8930                    .collect();
8931                eprintln!(
8932                    "MISMATCH host={hostname:?} path={path:?} expected={expected:?} \
8933                     actual={actual:?} declared (in order, with path)={declared:?}",
8934                );
8935                return TestResult::failed();
8936            }
8937        }
8938
8939        TestResult::passed()
8940    }
8941
8942    /// Request headers for a header-predicate lookup: first occurrence,
8943    /// names compared case-insensitively — the contract
8944    /// `FrontRequestHeaders` (`lib/src/protocol/mux/router.rs`) implements
8945    /// over a real request.
8946    struct TestHeaders<'a>(&'a [(&'a str, &'a str)]);
8947
8948    impl RequestHeaders for TestHeaders<'_> {
8949        fn header(&self, name: &[u8]) -> Option<&[u8]> {
8950            self.0
8951                .iter()
8952                .find(|(key, _)| key.as_bytes().eq_ignore_ascii_case(name))
8953                .map(|(_, value)| value.as_bytes())
8954        }
8955    }
8956
8957    fn predicate(name: &str, kind: HeaderMatchKind, value: Option<&str>) -> HeaderPredicate {
8958        HeaderPredicate {
8959            name: name.to_owned(),
8960            kind: kind as i32,
8961            value: value.map(str::to_owned),
8962        }
8963    }
8964
8965    fn routed_by_headers(router: &Router, path: &str, headers: &[(&str, &str)]) -> Option<String> {
8966        router
8967            .lookup_with_headers("example.com", path, &Method::Get, &TestHeaders(headers))
8968            .ok()
8969            .and_then(|route| route.cluster_id)
8970            .map(|id| id.to_string())
8971    }
8972
8973    /// sozu-proxy/sozu#1253: a canary frontend that differs from its base
8974    /// only by a header predicate serves exactly the requests carrying the
8975    /// header, whichever of the two was declared first and in every rule
8976    /// position. Before predicates were a routing input, the two frontends
8977    /// had one identity, so the second addition was refused.
8978    #[test]
8979    fn a_header_predicate_canary_outranks_its_base_in_any_declaration_order() {
8980        let canary_predicates = vec![predicate(
8981            "X-Canary",
8982            HeaderMatchKind::Exact,
8983            Some("always"),
8984        )];
8985        for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
8986            for path in [
8987                CommandPathRule::prefix("/api".to_owned()),
8988                CommandPathRule::equals("/api".to_owned()),
8989            ] {
8990                for canary_first in [false, true] {
8991                    let base = HttpFrontend {
8992                        cluster_id: Some("stable".into()),
8993                        path: path.clone(),
8994                        position,
8995                        ..test_http_frontend()
8996                    };
8997                    let canary = HttpFrontend {
8998                        cluster_id: Some("canary".into()),
8999                        header_predicates: canary_predicates.clone(),
9000                        ..base.clone()
9001                    };
9002                    let mut router = Router::new();
9003                    let order = if canary_first {
9004                        [&canary, &base]
9005                    } else {
9006                        [&base, &canary]
9007                    };
9008                    for front in order {
9009                        router
9010                            .add_http_front(front)
9011                            .expect("a canary and its base must coexist");
9012                    }
9013
9014                    let context = format!("{position:?} {path:?} canary_first={canary_first}");
9015                    assert_eq!(
9016                        routed_by_headers(&router, "/api", &[("x-canary", "always")]).as_deref(),
9017                        Some("canary"),
9018                        "{context}: a request carrying the header must reach the canary",
9019                    );
9020                    assert_eq!(
9021                        routed_by_headers(&router, "/api", &[]).as_deref(),
9022                        Some("stable"),
9023                        "{context}: a request without the header must reach the base",
9024                    );
9025                    assert_eq!(
9026                        routed_by_headers(&router, "/api", &[("X-Canary", "Always")]).as_deref(),
9027                        Some("stable"),
9028                        "{context}: an exact value is compared byte for byte",
9029                    );
9030                    assert_eq!(
9031                        router
9032                            .lookup("example.com", "/api", &Method::Get)
9033                            .ok()
9034                            .and_then(|route| route.cluster_id)
9035                            .as_deref(),
9036                        Some("stable"),
9037                        "{context}: a header-less lookup never matches a predicated rule",
9038                    );
9039                }
9040            }
9041        }
9042    }
9043
9044    /// Predicates are a conjunction; names match case-insensitively; the
9045    /// first occurrence of a header answers; a `regex` value matches the
9046    /// WHOLE value, like a `REGEX` path rule; `present` accepts any value,
9047    /// the empty one included.
9048    #[test]
9049    fn header_predicates_hold_together_with_their_documented_semantics() {
9050        let mut router = Router::new();
9051        let front = HttpFrontend {
9052            cluster_id: Some("eu-beta".into()),
9053            header_predicates: vec![
9054                predicate("x-region", HeaderMatchKind::Regex, Some("eu-.*")),
9055                predicate("X-BETA", HeaderMatchKind::Present, None),
9056            ],
9057            ..test_http_frontend()
9058        };
9059        router.add_http_front(&front).expect("valid predicates");
9060        router
9061            .add_http_front(&test_http_frontend())
9062            .expect("the base frontend");
9063
9064        let route = |headers: &[(&str, &str)]| routed_by_headers(&router, "/", headers);
9065        assert_eq!(
9066            route(&[("X-Region", "eu-west"), ("x-beta", "")]).as_deref(),
9067            Some("eu-beta")
9068        );
9069        assert_eq!(
9070            route(&[("X-Region", "eu-west")]).as_deref(),
9071            Some("cluster"),
9072            "every predicate must hold",
9073        );
9074        assert_eq!(
9075            route(&[("X-Region", "us-eu-west"), ("x-beta", "1")]).as_deref(),
9076            Some("cluster"),
9077            "a regex is anchored at the start of the value",
9078        );
9079        assert_eq!(
9080            route(&[
9081                ("X-Region", "us-east"),
9082                ("X-Region", "eu-west"),
9083                ("x-beta", "1")
9084            ])
9085            .as_deref(),
9086            Some("cluster"),
9087            "only the first occurrence of a header answers",
9088        );
9089    }
9090
9091    /// Removing a frontend removes the rule of exactly its predicates: the
9092    /// base survives the canary's removal and the reverse, and a removal
9093    /// that spells the same conjunction in another order or case finds it.
9094    #[test]
9095    fn removing_a_predicated_frontend_leaves_its_base_and_the_reverse() {
9096        for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
9097            let base = HttpFrontend {
9098                cluster_id: Some("stable".into()),
9099                position,
9100                ..test_http_frontend()
9101            };
9102            let canary = HttpFrontend {
9103                cluster_id: Some("canary".into()),
9104                header_predicates: vec![
9105                    predicate("X-Canary", HeaderMatchKind::Present, None),
9106                    predicate("X-Tenant", HeaderMatchKind::Exact, Some("acme")),
9107                ],
9108                ..base.clone()
9109            };
9110            let canary_respelled = HttpFrontend {
9111                header_predicates: vec![
9112                    predicate("x-tenant", HeaderMatchKind::Exact, Some("acme")),
9113                    predicate("x-canary", HeaderMatchKind::Present, None),
9114                ],
9115                ..canary.clone()
9116            };
9117            let canary_headers = [("X-Canary", "1"), ("X-Tenant", "acme")];
9118
9119            let mut router = Router::new();
9120            router.add_http_front(&base).expect("base");
9121            router.add_http_front(&canary).expect("canary");
9122            assert!(
9123                router.add_http_front(&canary_respelled).is_err(),
9124                "{position:?}: a respelled conjunction is the same rule",
9125            );
9126            router
9127                .remove_http_front(&canary_respelled)
9128                .expect("a respelled removal must find the canary");
9129            assert_eq!(
9130                routed_by_headers(&router, "/", &canary_headers).as_deref(),
9131                Some("stable"),
9132                "{position:?}: the base must survive the canary's removal",
9133            );
9134
9135            router.add_http_front(&canary).expect("canary again");
9136            router.remove_http_front(&base).expect("base removal");
9137            assert_eq!(
9138                routed_by_headers(&router, "/", &canary_headers).as_deref(),
9139                Some("canary"),
9140                "{position:?}: the canary must survive the base's removal",
9141            );
9142            assert_eq!(
9143                routed_by_headers(&router, "/", &[]),
9144                None,
9145                "{position:?}: without its base, a request lacking the header has no route",
9146            );
9147        }
9148    }
9149
9150    /// A hostname whose only rules are predicated, none holding, hands the
9151    /// request to the next hostname candidate instead of ending the lookup
9152    /// (the sozu#1351 shape): the trie's acceptance predicate sees headers.
9153    #[test]
9154    fn an_unsatisfied_predicated_hostname_falls_through_to_the_wildcard() {
9155        let mut router = Router::new();
9156        router
9157            .add_http_front(&HttpFrontend {
9158                cluster_id: Some("wildcard".into()),
9159                hostname: "*.example.com".to_owned(),
9160                ..test_http_frontend()
9161            })
9162            .expect("wildcard");
9163        router
9164            .add_http_front(&HttpFrontend {
9165                cluster_id: Some("exact".into()),
9166                hostname: "www.example.com".to_owned(),
9167                header_predicates: vec![predicate("X-Canary", HeaderMatchKind::Present, None)],
9168                ..test_http_frontend()
9169            })
9170            .expect("predicated exact host");
9171
9172        let route = |headers: &[(&str, &str)]| {
9173            router
9174                .lookup_with_headers("www.example.com", "/", &Method::Get, &TestHeaders(headers))
9175                .ok()
9176                .and_then(|route| route.cluster_id)
9177                .map(|id| id.to_string())
9178        };
9179        assert_eq!(route(&[("x-canary", "")]).as_deref(), Some("exact"));
9180        assert_eq!(route(&[]).as_deref(), Some("wildcard"));
9181    }
9182
9183    /// A predicate no request could satisfy, or that cannot be compiled, is
9184    /// refused at frontend addition — which `validate_frontend_request`
9185    /// (`bin/src/command/requests.rs`) also runs in the main process — and
9186    /// the error carries the predicate's index, never its name or value.
9187    #[test]
9188    fn unusable_header_predicates_are_refused_without_echoing_them() {
9189        let refused = [
9190            predicate("Host", HeaderMatchKind::Exact, Some("example.com")),
9191            predicate("cookie", HeaderMatchKind::Present, None),
9192            predicate("X Bad", HeaderMatchKind::Present, None),
9193            predicate("", HeaderMatchKind::Present, None),
9194            predicate("X-Debug", HeaderMatchKind::Present, Some("1")),
9195            predicate("X-Tenant", HeaderMatchKind::Exact, None),
9196            predicate("X-Tenant", HeaderMatchKind::Regex, None),
9197            predicate("X-Tenant", HeaderMatchKind::Exact, Some("a\r\nb")),
9198            predicate("X-Tenant", HeaderMatchKind::Regex, Some("a)(b")),
9199            HeaderPredicate {
9200                name: "X-Tenant".to_owned(),
9201                kind: 42,
9202                value: None,
9203            },
9204        ];
9205        for bad in refused {
9206            let mut router = Router::new();
9207            let front = HttpFrontend {
9208                header_predicates: vec![
9209                    predicate("X-Valid", HeaderMatchKind::Present, None),
9210                    bad.clone(),
9211                ],
9212                ..test_http_frontend()
9213            };
9214            let error = router
9215                .add_http_front(&front)
9216                .expect_err("an unusable predicate must be refused");
9217            assert!(
9218                matches!(error, RouterError::InvalidHeaderPredicate { index: 1, .. }),
9219                "{bad:?}: unexpected {error}",
9220            );
9221            let message = error.to_string();
9222            assert!(
9223                !message.contains("X-Tenant") && !message.contains("example.com"),
9224                "the error must not echo the predicate: {message}",
9225            );
9226            assert!(router.tree.is_empty(), "a refused frontend adds no rule");
9227        }
9228    }
9229
9230    quickcheck! {
9231        fn qc_router_hostname_resolution_matches_the_documented_semantics(scenario: Scenario) -> TestResult {
9232            oracle_matches_router(scenario)
9233        }
9234    }
9235}