Skip to main content

sozu_lib/router/
mod.rs

1pub mod pattern_trie;
2
3use std::{
4    fmt::{self, Debug, Write},
5    rc::Rc,
6    str::from_utf8,
7    time::Instant,
8};
9
10use regex::bytes::Regex;
11use sozu_command::{
12    logging::CachedTags,
13    proto::command::{
14        HeaderPosition, HstsConfig, PathRule as CommandPathRule, PathRuleKind, RedirectPolicy,
15        RedirectScheme, RulePosition,
16    },
17    response::HttpFrontend,
18    state::ClusterId,
19};
20
21use crate::metrics::names;
22use crate::{
23    protocol::{http::editor::HeaderEditMode, http::parser::Method},
24    router::pattern_trie::{InsertResult, TrieMatches, TrieNode, TrieSubMatch},
25    sozu_command::logging::ansi_palette,
26};
27
28/// Module-level prefix tag for `lib/src/router/`. Honours the runtime
29/// colored-output flag via [`ansi_palette`]; consumed by the single
30/// `warn!` site in `Frontend::new` (and any future emitter without an
31/// `HttpContext` in scope) so static log-layout regression checks
32/// (`lib/tests/log_layout.rs`) keep router log lines on the canonical
33/// `[ROUTER] >>>` envelope.
34macro_rules! log_module_context {
35    () => {{
36        let (open, reset, _, _, _) = ansi_palette();
37        format!("{open}ROUTER{reset}\t >>>", open = open, reset = reset)
38    }};
39}
40
41/// Upper bound (in bytes) on a frontend hostname accepted by the router,
42/// checked by [`Router::add_http_front_with_hsts_origin`] and
43/// [`Router::remove_http_front`] before anything parses the hostname.
44///
45/// RFC 1035 caps a domain name at 255 octets; Sōzu's regex-segment
46/// grammar (`/re/.example.com`) can legitimately exceed that, so this is
47/// a deliberately generous safety net rather than a strict RFC bound. It
48/// exists because the route-table trie recurses once per label — a
49/// hostname with ~100k labels aborts the worker with an uncatchable
50/// stack overflow — and because the regex compilation a `/`-segment
51/// triggers costs time linear in the pattern size (a 2 MiB hostname
52/// stalls the single-threaded worker for ~855 ms before the regex size
53/// limit rejects it).
54pub const MAX_HOSTNAME_LENGTH: usize = 4096;
55
56#[derive(thiserror::Error, PartialEq)]
57pub enum RouterError {
58    #[error("Could not parse rule from frontend path, path_bytes={}", .0.len())]
59    InvalidPathRule(String),
60    #[error("parsing hostname failed, hostname_bytes={}", .hostname.len())]
61    InvalidDomain { hostname: String },
62    #[error("Could not parse host rewrite, rewrite_host_bytes={}", .0.len())]
63    InvalidHostRewrite(String),
64    #[error("Could not parse path rewrite, rewrite_path_bytes={}", .0.len())]
65    InvalidPathRewrite(String),
66    #[error("Could not add route, route_bytes={}", .0.len())]
67    AddRoute(String),
68    #[error("Could not remove route, route_bytes={}", .0.len())]
69    RemoveRoute(String),
70    #[error("route_not_found method={method:?} host_bytes={} path_bytes={}", .host.len(), .path.len())]
71    RouteNotFound {
72        host: String,
73        path: String,
74        method: Method,
75    },
76}
77
78impl fmt::Debug for RouterError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        fmt::Display::fmt(self, f)
81    }
82}
83
84pub struct Router {
85    pre: Vec<(DomainRule, PathRule, MethodRule, Route)>,
86    pub tree: TrieNode<Vec<(PathRule, MethodRule, Route)>>,
87    post: Vec<(DomainRule, PathRule, MethodRule, Route)>,
88}
89
90impl Default for Router {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl Router {
97    pub fn new() -> Router {
98        Router {
99            pre: Vec::new(),
100            tree: TrieNode::root(),
101            post: Vec::new(),
102        }
103    }
104
105    /// Resolve a request to a [`RouteResult`].
106    ///
107    /// Looks up `(hostname, path, method)` against the pre, tree, and post
108    /// rule lists. The matched [`Route`] is converted into a [`RouteResult`]:
109    /// legacy variants ([`Route::ClusterId`], [`Route::Deny`]) synthesize a
110    /// minimal `RouteResult` so existing call sites keep working, while
111    /// [`Route::Frontend`] runs the full Frontend → RouteResult pipeline,
112    /// substituting host/path captures into [`RewriteParts`] templates.
113    pub fn lookup(
114        &self,
115        hostname: &str,
116        path: &str,
117        method: &Method,
118    ) -> Result<RouteResult, RouterError> {
119        let hostname_b = hostname.as_bytes();
120        let path_b = path.as_bytes();
121        for (domain_rule, path_rule, method_rule, route) in &self.pre {
122            if domain_rule.matches(hostname_b)
123                && path_rule.matches(path_b) != PathRuleResult::None
124                && method_rule.matches(method) != MethodRuleResult::None
125            {
126                return Ok(RouteResult::new_no_trie(
127                    hostname_b,
128                    domain_rule,
129                    path_b,
130                    path_rule,
131                    route,
132                ));
133            }
134        }
135
136        let trie_path: TrieMatches<'_, '_> = Vec::with_capacity(16);
137        if let Some(((_, path_rules), trie_matches)) =
138            self.tree.lookup_with_path(hostname_b, true, trie_path)
139        {
140            let mut prefix_length = 0;
141            let mut matched: Option<(&PathRule, &Route)> = None;
142
143            for (rule, method_rule, route) in path_rules {
144                match rule.matches(path_b) {
145                    PathRuleResult::Regex | PathRuleResult::Equals => {
146                        match method_rule.matches(method) {
147                            MethodRuleResult::Equals => {
148                                return Ok(RouteResult::new_with_trie(
149                                    hostname_b,
150                                    trie_matches,
151                                    path_b,
152                                    rule,
153                                    route,
154                                ));
155                            }
156                            MethodRuleResult::All => {
157                                prefix_length = path_b.len();
158                                matched = Some((rule, route));
159                            }
160                            MethodRuleResult::None => {}
161                        }
162                    }
163                    PathRuleResult::Prefix(size) => {
164                        if size >= prefix_length {
165                            match method_rule.matches(method) {
166                                // FIXME: the rule order will be important here
167                                MethodRuleResult::Equals => {
168                                    // Longest-prefix wins: the selected
169                                    // length is monotonically non-decreasing
170                                    // across the candidate scan.
171                                    debug_assert!(
172                                        size >= prefix_length,
173                                        "longest-prefix selection must never shrink the match length",
174                                    );
175                                    prefix_length = size;
176                                    matched = Some((rule, route));
177                                }
178                                MethodRuleResult::All => {
179                                    debug_assert!(
180                                        size >= prefix_length,
181                                        "longest-prefix selection must never shrink the match length",
182                                    );
183                                    prefix_length = size;
184                                    matched = Some((rule, route));
185                                }
186                                MethodRuleResult::None => {}
187                            }
188                        }
189                    }
190                    PathRuleResult::None => {}
191                }
192            }
193
194            if let Some((path_rule, route)) = matched {
195                return Ok(RouteResult::new_with_trie(
196                    hostname_b,
197                    trie_matches,
198                    path_b,
199                    path_rule,
200                    route,
201                ));
202            }
203        }
204
205        for (domain_rule, path_rule, method_rule, route) in self.post.iter() {
206            if domain_rule.matches(hostname_b)
207                && path_rule.matches(path_b) != PathRuleResult::None
208                && method_rule.matches(method) != MethodRuleResult::None
209            {
210                return Ok(RouteResult::new_no_trie(
211                    hostname_b,
212                    domain_rule,
213                    path_b,
214                    path_rule,
215                    route,
216                ));
217            }
218        }
219
220        Err(RouterError::RouteNotFound {
221            host: hostname.to_owned(),
222            path: path.to_owned(),
223            method: method.to_owned(),
224        })
225    }
226
227    /// Add an HTTP/HTTPS frontend whose `hsts` field (if any) came from
228    /// the per-frontend configuration directly. Equivalent to
229    /// [`Self::add_http_front_with_hsts_origin`] called with
230    /// [`HstsOrigin::Explicit`]. The default for callers that don't
231    /// know about listener-default inheritance — e.g. plain HTTP
232    /// listeners (`HttpListenerConfig` has no HSTS field) and tests.
233    pub fn add_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
234        self.add_http_front_with_hsts_origin(front, HstsOrigin::Explicit)
235    }
236
237    /// Add an HTTP/HTTPS frontend, recording whether the resolved
238    /// `front.hsts` was inherited from the listener default. The
239    /// inheritance bit is preserved on the resulting [`Frontend`] so a
240    /// later `UpdateHttpsListenerConfig.hsts` patch can reflow the new
241    /// default onto inheriting entries without disturbing explicit
242    /// per-frontend overrides.
243    pub fn add_http_front_with_hsts_origin(
244        &mut self,
245        front: &HttpFrontend,
246        hsts_origin: HstsOrigin,
247    ) -> Result<(), RouterError> {
248        // Bounded BEFORE any parse: the `DomainRule` parse below compiles
249        // control-plane-supplied regex segments, and the trie recurses
250        // once per label (see `MAX_HOSTNAME_LENGTH`).
251        if front.hostname.len() > MAX_HOSTNAME_LENGTH {
252            return Err(RouterError::InvalidDomain {
253                hostname: front.hostname.clone(),
254            });
255        }
256
257        let path_rule = PathRule::from_config(front.path.clone())
258            .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
259
260        let method_rule = MethodRule::new(front.method.clone());
261
262        // Decide between the legacy `Route::ClusterId`/`Route::Deny` shape
263        // and the rich `Route::Frontend(Rc<Frontend>)` shape: any non-
264        // default policy field flips us onto the rich path so the mux
265        // can honour redirect/rewrite/headers/auth at request time.
266        let has_policy = front.redirect.is_some()
267            || front.redirect_scheme.is_some()
268            || front.redirect_template.is_some()
269            || front.rewrite_host.is_some()
270            || front.rewrite_path.is_some()
271            || front.rewrite_port.is_some()
272            || front.required_auth.unwrap_or(false)
273            || !front.headers.is_empty()
274            || front.hsts.is_some();
275
276        let domain =
277            front
278                .hostname
279                .parse::<DomainRule>()
280                .map_err(|_| RouterError::InvalidDomain {
281                    hostname: front.hostname.clone(),
282                })?;
283
284        let route = if has_policy {
285            let redirect = front
286                .redirect
287                .and_then(|r| RedirectPolicy::try_from(r).ok())
288                .unwrap_or(RedirectPolicy::Forward);
289            let redirect_scheme = front
290                .redirect_scheme
291                .and_then(|s| RedirectScheme::try_from(s).ok())
292                .unwrap_or(RedirectScheme::UseSame);
293            let frontend = Frontend::new(
294                &domain,
295                &path_rule,
296                front,
297                redirect,
298                redirect_scheme,
299                front.redirect_template.clone(),
300                front.rewrite_host.clone(),
301                front.rewrite_path.clone(),
302                front.rewrite_port.and_then(|p| u16::try_from(p).ok()),
303                &front.headers,
304                front.required_auth.unwrap_or(false),
305                hsts_origin,
306            )?;
307            Route::Frontend(Rc::new(frontend))
308        } else {
309            match &front.cluster_id {
310                Some(cluster_id) => Route::ClusterId(cluster_id.clone()),
311                None => Route::Deny,
312            }
313        };
314
315        let success = match front.position {
316            RulePosition::Pre => self.add_pre_rule(&domain, &path_rule, &method_rule, &route),
317            RulePosition::Post => self.add_post_rule(&domain, &path_rule, &method_rule, &route),
318            RulePosition::Tree => {
319                self.add_tree_rule(front.hostname.as_bytes(), &path_rule, &method_rule, &route)
320            }
321        };
322        if !success {
323            return Err(RouterError::AddRoute(format!("{front:?}")));
324        }
325        Ok(())
326    }
327
328    pub fn remove_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
329        // Same bound as `add_http_front_with_hsts_origin`: the Pre/Post
330        // arms below re-parse the hostname into a `DomainRule`.
331        if front.hostname.len() > MAX_HOSTNAME_LENGTH {
332            return Err(RouterError::InvalidDomain {
333                hostname: front.hostname.clone(),
334            });
335        }
336
337        let path_rule = PathRule::from_config(front.path.clone())
338            .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
339
340        let method_rule = MethodRule::new(front.method.clone());
341
342        let remove_success = match front.position {
343            RulePosition::Pre => {
344                let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
345                    RouterError::InvalidDomain {
346                        hostname: front.hostname.clone(),
347                    }
348                })?;
349
350                self.remove_pre_rule(&domain, &path_rule, &method_rule)
351            }
352            RulePosition::Post => {
353                let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
354                    RouterError::InvalidDomain {
355                        hostname: front.hostname.clone(),
356                    }
357                })?;
358
359                self.remove_post_rule(&domain, &path_rule, &method_rule)
360            }
361            RulePosition::Tree => {
362                self.remove_tree_rule(front.hostname.as_bytes(), &path_rule, &method_rule)
363            }
364        };
365        if !remove_success {
366            return Err(RouterError::RemoveRoute(format!("{front:?}")));
367        }
368        Ok(())
369    }
370
371    pub fn add_tree_rule(
372        &mut self,
373        hostname: &[u8],
374        path: &PathRule,
375        method: &MethodRule,
376        cluster: &Route,
377    ) -> bool {
378        let hostname = match from_utf8(hostname) {
379            Err(_) => return false,
380            Ok(h) => h,
381        };
382
383        match ::idna::domain_to_ascii(hostname) {
384            Ok(hostname) => {
385                //FIXME: necessary ti build on stable rust (1.35), can be removed once 1.36 is there
386                let mut empty = true;
387                if let Some((_, paths)) = self.tree.domain_lookup_mut(hostname.as_bytes(), false) {
388                    empty = false;
389                    let before = paths.len();
390                    if !paths.iter().any(|(p, m, _)| p == path && m == method) {
391                        paths.push((path.to_owned(), method.to_owned(), cluster.to_owned()));
392                        // Append must add exactly one (path, method) leaf
393                        // and the new rule must now be present.
394                        debug_assert_eq!(
395                            paths.len(),
396                            before + 1,
397                            "appending a tree rule must grow the leaf's rule list by exactly one",
398                        );
399                        debug_assert!(
400                            paths.iter().any(|(p, m, _)| p == path && m == method),
401                            "the freshly appended (path, method) rule must be present after insert",
402                        );
403                        return true;
404                    }
405                }
406
407                if empty {
408                    // Snapshot the ASCII host bytes before the move so the
409                    // post-insert reachability check can re-look-up the
410                    // domain. Ungated `let` (read only inside the gated
411                    // assert) → dropped by the optimizer in release.
412                    let inserted_host = hostname.clone().into_bytes();
413                    let insert_result = self.tree.domain_insert(
414                        hostname.into_bytes(),
415                        vec![(path.to_owned(), method.to_owned(), cluster.to_owned())],
416                    );
417                    // A malformed hostname reaches us straight from the
418                    // control plane (`AddHttpFrontend` over the command
419                    // socket, or a `LoadState` replay), so the route table
420                    // rejecting it is an expected outcome, not a Sozu bug:
421                    // report the failure and let the caller answer the
422                    // request with an error. Shapes the trie rejects
423                    // include a host ending in `/` with no openable regex
424                    // segment (`example.com/`), a regex segment that is not
425                    // `.`-anchored (`abc/[0-9]+/.example.com`), a segment
426                    // that is not a valid regex (`/[/.example.com`), and an
427                    // empty label (`.example.com`).
428                    if insert_result == InsertResult::Failed {
429                        // Redacted like every other router log site: the
430                        // shape is what matters here. `RouterError::AddRoute`
431                        // is redacting too (`route_bytes=<len>`), so the
432                        // hostname itself is only visible where the main
433                        // process audit-logs the request it fanned out
434                        // (`bin/src/command/requests.rs`) -- correlate by
435                        // timestamp to identify the offending frontend.
436                        error!(
437                            "{} the route table rejected a malformed hostname, hostname_bytes={}",
438                            log_module_context!(),
439                            inserted_host.len(),
440                        );
441                        return false;
442                    }
443                    // A fresh domain must now be reachable, carrying the
444                    // single rule just inserted. Use `domain_lookup_mut`
445                    // (not the immutable `domain_lookup`): only the `_mut`
446                    // resolver handles a literal wildcard key (`*.sozu.io`)
447                    // via its `partial_key == b"*"` segment case, which is
448                    // exactly the resolution the append branch above relies
449                    // on. The immutable `lookup` lacks that case and would
450                    // miss wildcard entries.
451                    debug_assert!(
452                        self.tree
453                            .domain_lookup_mut(&inserted_host, false)
454                            .is_some_and(|(_, paths)| paths
455                                .iter()
456                                .any(|(p, m, _)| p == path && m == method)),
457                        "a freshly inserted tree domain must resolve to its inserted rule",
458                    );
459                    return true;
460                }
461
462                false
463            }
464            Err(_) => false,
465        }
466    }
467
468    pub fn remove_tree_rule(
469        &mut self,
470        hostname: &[u8],
471        path: &PathRule,
472        method: &MethodRule,
473        // _cluster: &Route,
474    ) -> bool {
475        let hostname = match from_utf8(hostname) {
476            Err(_) => return false,
477            Ok(h) => h,
478        };
479
480        match ::idna::domain_to_ascii(hostname) {
481            Ok(hostname) => {
482                let should_delete = {
483                    let paths_opt = self.tree.domain_lookup_mut(hostname.as_bytes(), false);
484
485                    if let Some((_, paths)) = paths_opt {
486                        paths.retain(|(p, m, _)| p != path || m != method);
487                        // `retain` evicts every matching (path, method)
488                        // rule; none may survive the filter.
489                        debug_assert!(
490                            !paths.iter().any(|(p, m, _)| p == path && m == method),
491                            "remove must evict every matching (path, method) rule from the leaf",
492                        );
493                    }
494
495                    paths_opt
496                        .as_ref()
497                        .map(|(_, paths)| paths.is_empty())
498                        .unwrap_or(false)
499                };
500
501                if should_delete {
502                    let removed_host = hostname.clone().into_bytes();
503                    self.tree.domain_remove(&hostname.into_bytes());
504                    // Dropping the last rule must make the whole domain
505                    // unreachable — no stranded empty leaf left behind.
506                    // `domain_lookup_mut` resolves literal wildcard keys
507                    // (`*.sozu.io`), so this genuinely verifies wildcard
508                    // entries are gone too (the immutable `lookup` lacks
509                    // the `partial_key == b"*"` case and would always read
510                    // None for a wildcard host, weakening the check).
511                    debug_assert!(
512                        self.tree.domain_lookup_mut(&removed_host, false).is_none(),
513                        "a domain whose last rule was removed must be unreachable",
514                    );
515                }
516
517                true
518            }
519            Err(_) => false,
520        }
521    }
522
523    /// Walk every route and re-materialise the response-side HSTS edit
524    /// on frontends that inherited from the listener default. Operator
525    /// per-frontend HSTS overrides (`inherits_listener_hsts == false`)
526    /// are left untouched.
527    ///
528    /// Called from `lib/src/https.rs::HttpsListener::update_config` when
529    /// an `UpdateHttpsListenerConfig.hsts` patch is applied.
530    ///
531    /// Two refresh paths:
532    ///
533    /// 1. **`Route::Frontend(rc)` with `inherits_listener_hsts == true`**:
534    ///    rebuild `headers_response` by dropping any existing
535    ///    `Strict-Transport-Security` entry and appending a freshly
536    ///    rendered one when `new_hsts` resolves to an enabled value
537    ///    (`enabled = Some(true)`). The existing operator
538    ///    `Append`/`Set` response headers stay in place.
539    ///
540    /// 2. **`Route::ClusterId(id)` and `Route::Deny`** (lightweight
541    ///    "no policy" shapes): when `new_hsts` resolves to enabled,
542    ///    promote in place to a minimal `Route::Frontend(rc)` carrying
543    ///    just the HSTS edit on `headers_response` (and
544    ///    `inherits_listener_hsts == true` so subsequent patches keep
545    ///    refreshing the entry). Routing semantics are preserved — the
546    ///    promoted Frontend forwards / denies identically to the
547    ///    original variant — and the promoted entry now participates
548    ///    in path 1 on every later patch. When `new_hsts` resolves to
549    ///    "no HSTS" (None / disabled), lightweight routes are left
550    ///    untouched (no allocation is created just to hold an empty
551    ///    HSTS edit).
552    ///
553    /// Path 2 fixes the case where a frontend was added without any
554    /// per-frontend policy field (the routing fast path stores it as
555    /// `Route::ClusterId` / `Route::Deny`, NOT `Route::Frontend`). Before
556    /// this two-path walk, listener-default HSTS patches silently
557    /// skipped every such "no-policy" frontend — which on a typical
558    /// Clever Cloud `cleverapps.io` shared listener was 99 % of the
559    /// frontends.
560    ///
561    /// Returns the number of frontends touched. For path 1, refreshed
562    /// frontends where the new policy resolves to "no HSTS" are still
563    /// counted (the existing HSTS edit is stripped). For path 2, only
564    /// frontends actually promoted (i.e. `new_hsts` enabled) are
565    /// counted, since "no HSTS" is a no-op on the lightweight shape.
566    pub fn refresh_inheriting_hsts(&mut self, new_hsts: Option<&HstsConfig>) -> usize {
567        let mut refreshed = 0usize;
568        // Pre-compute the listener-default HSTS edit ONCE so every
569        // visited frontend in this patch shares the same `Rc`-backed
570        // key / val allocation. `Some(_)` doubles as the "promote
571        // lightweight routes" gate — there is no point allocating a
572        // promoted Frontend just to hold an empty headers_response.
573        // See `build_listener_hsts_edit`'s rustdoc for the
574        // ~1.5 M-allocation-per-worker savings on cleverapps.io.
575        let new_edit = build_listener_hsts_edit(new_hsts);
576        let new_edit_ref = new_edit.as_ref();
577        let promote_lightweight = new_edit_ref.is_some();
578        let mut visit = |route: &mut Route| match route {
579            Route::Frontend(rc) => {
580                if rc.inherits_listener_hsts {
581                    let new_frontend = rebuild_with_listener_hsts(rc, new_edit_ref);
582                    *rc = Rc::new(new_frontend);
583                    refreshed += 1;
584                }
585            }
586            Route::ClusterId(id) => {
587                if promote_lightweight {
588                    let promoted = rebuild_with_listener_hsts(
589                        &Frontend::minimal_forward(id.clone()),
590                        new_edit_ref,
591                    );
592                    *route = Route::Frontend(Rc::new(promoted));
593                    refreshed += 1;
594                }
595            }
596            Route::Deny => {
597                if promote_lightweight {
598                    let promoted =
599                        rebuild_with_listener_hsts(&Frontend::minimal_deny(), new_edit_ref);
600                    *route = Route::Frontend(Rc::new(promoted));
601                    refreshed += 1;
602                }
603            }
604        };
605
606        for (_, _, _, route) in self.pre.iter_mut() {
607            visit(route);
608        }
609        self.tree.for_each_value_mut(&mut |paths| {
610            for (_, _, route) in paths.iter_mut() {
611                visit(route);
612            }
613        });
614        for (_, _, _, route) in self.post.iter_mut() {
615            visit(route);
616        }
617        refreshed
618    }
619
620    pub fn add_pre_rule(
621        &mut self,
622        domain: &DomainRule,
623        path: &PathRule,
624        method: &MethodRule,
625        cluster_id: &Route,
626    ) -> bool {
627        let before = self.pre.len();
628        if !self
629            .pre
630            .iter()
631            .any(|(d, p, m, _)| d == domain && p == path && m == method)
632        {
633            self.pre.push((
634                domain.to_owned(),
635                path.to_owned(),
636                method.to_owned(),
637                cluster_id.to_owned(),
638            ));
639            // A new pre-rule grows the list by exactly one and is now
640            // present (dedup of the same triple is the caller's `false`
641            // path, not this one).
642            debug_assert_eq!(
643                self.pre.len(),
644                before + 1,
645                "adding a unique pre-rule must push exactly one entry",
646            );
647            debug_assert!(
648                self.pre
649                    .iter()
650                    .any(|(d, p, m, _)| d == domain && p == path && m == method),
651                "the freshly added pre-rule must be present",
652            );
653            true
654        } else {
655            debug_assert_eq!(
656                self.pre.len(),
657                before,
658                "a duplicate pre-rule must not change the list length",
659            );
660            false
661        }
662    }
663
664    pub fn add_post_rule(
665        &mut self,
666        domain: &DomainRule,
667        path: &PathRule,
668        method: &MethodRule,
669        cluster_id: &Route,
670    ) -> bool {
671        let before = self.post.len();
672        if !self
673            .post
674            .iter()
675            .any(|(d, p, m, _)| d == domain && p == path && m == method)
676        {
677            self.post.push((
678                domain.to_owned(),
679                path.to_owned(),
680                method.to_owned(),
681                cluster_id.to_owned(),
682            ));
683            debug_assert_eq!(
684                self.post.len(),
685                before + 1,
686                "adding a unique post-rule must push exactly one entry",
687            );
688            debug_assert!(
689                self.post
690                    .iter()
691                    .any(|(d, p, m, _)| d == domain && p == path && m == method),
692                "the freshly added post-rule must be present",
693            );
694            true
695        } else {
696            debug_assert_eq!(
697                self.post.len(),
698                before,
699                "a duplicate post-rule must not change the list length",
700            );
701            false
702        }
703    }
704
705    pub fn remove_pre_rule(
706        &mut self,
707        domain: &DomainRule,
708        path: &PathRule,
709        method: &MethodRule,
710    ) -> bool {
711        let before = self.pre.len();
712        match self
713            .pre
714            .iter()
715            .position(|(d, p, m, _)| d == domain && p == path && m == method)
716        {
717            None => {
718                debug_assert_eq!(
719                    self.pre.len(),
720                    before,
721                    "a no-op pre-rule removal must not change the list length",
722                );
723                false
724            }
725            Some(index) => {
726                debug_assert!(index < self.pre.len(), "found index must be in bounds");
727                self.pre.remove(index);
728                // Exactly one entry left, and the triple is now gone.
729                debug_assert_eq!(
730                    self.pre.len() + 1,
731                    before,
732                    "removing a pre-rule must drop exactly one entry",
733                );
734                debug_assert!(
735                    !self
736                        .pre
737                        .iter()
738                        .any(|(d, p, m, _)| d == domain && p == path && m == method),
739                    "the removed pre-rule must no longer be present",
740                );
741                true
742            }
743        }
744    }
745
746    pub fn remove_post_rule(
747        &mut self,
748        domain: &DomainRule,
749        path: &PathRule,
750        method: &MethodRule,
751    ) -> bool {
752        let before = self.post.len();
753        match self
754            .post
755            .iter()
756            .position(|(d, p, m, _)| d == domain && p == path && m == method)
757        {
758            None => {
759                debug_assert_eq!(
760                    self.post.len(),
761                    before,
762                    "a no-op post-rule removal must not change the list length",
763                );
764                false
765            }
766            Some(index) => {
767                debug_assert!(index < self.post.len(), "found index must be in bounds");
768                self.post.remove(index);
769                debug_assert_eq!(
770                    self.post.len() + 1,
771                    before,
772                    "removing a post-rule must drop exactly one entry",
773                );
774                debug_assert!(
775                    !self
776                        .post
777                        .iter()
778                        .any(|(d, p, m, _)| d == domain && p == path && m == method),
779                    "the removed post-rule must no longer be present",
780                );
781                true
782            }
783        }
784    }
785
786    /// Returns true if any route (pre, tree, or post) references the given hostname.
787    ///
788    /// This is used after removing a frontend to decide whether the hostname's
789    /// tags should be cleaned up. Tags must only be removed when no routes remain.
790    pub fn has_hostname(&self, hostname: &str) -> bool {
791        let hostname_b = hostname.as_bytes();
792
793        // Check pre rules
794        for (domain_rule, _, _, _) in &self.pre {
795            if domain_rule.matches(hostname_b) {
796                return true;
797            }
798        }
799
800        // Check tree rules (exact match only, no wildcard resolution)
801        if let Ok(ascii_hostname) = ::idna::domain_to_ascii(hostname)
802            && self
803                .tree
804                .domain_lookup(ascii_hostname.as_bytes(), false)
805                .is_some()
806        {
807            return true;
808        }
809
810        // Check post rules
811        for (domain_rule, _, _, _) in &self.post {
812            if domain_rule.matches(hostname_b) {
813                return true;
814            }
815        }
816
817        false
818    }
819}
820
821#[derive(Clone, Debug)]
822pub enum DomainRule {
823    Any,
824    Exact(String),
825    /// Matches when `hostname` ends with `s[1..]` (the wildcard pattern with
826    /// the leading `*` stripped) and the remaining leftmost prefix is
827    /// non-empty and contains no `.`. Comparison is byte-exact and
828    /// case-sensitive; no IDN/punycode normalisation is performed here.
829    /// Stored with the leading `*`.
830    Wildcard(String),
831    Regex(Regex),
832}
833
834fn convert_regex_domain_rule(hostname: &str) -> Option<String> {
835    // Anchor at both ends so `Regex::is_match` only succeeds on a full-host
836    // match. Without `\A` the pattern `/example\.com/` matches any hostname
837    // containing `example.com` as a substring (e.g. `attacker.example.com.evil.org`),
838    // letting an attacker-controlled domain reach a frontend that should only
839    // serve `example.com`.
840    let mut result = String::from("\\A");
841
842    let s = hostname.as_bytes();
843    let mut index = 0;
844    loop {
845        // A bare trailing `.` after a completed segment (`/a/.`, `x./y/.`)
846        // leaves `index` one past the end through the `index += 1` in the
847        // loop tail; indexing `s[index]` here would then panic (slice
848        // bounds checks never compile out of release). The grammar
849        // requires a label after every `.`, so reject instead.
850        if index >= s.len() {
851            return None;
852        }
853        if s[index] == b'/' {
854            let mut found = false;
855            for i in index + 1..s.len() {
856                if s[i] == b'/' {
857                    match std::str::from_utf8(&s[index + 1..i]) {
858                        Ok(r) => result.push_str(r),
859                        Err(_) => return None,
860                    }
861                    index = i + 1;
862                    found = true;
863                    break;
864                }
865            }
866
867            if !found {
868                return None;
869            }
870        } else {
871            let start = index;
872            for i in start..s.len() + 1 {
873                index = i;
874                if i < s.len() && s[i] == b'.' {
875                    match std::str::from_utf8(&s[start..i]) {
876                        Ok(r) => result.push_str(r),
877                        Err(_) => return None,
878                    }
879                    break;
880                }
881            }
882            if index == s.len() {
883                match std::str::from_utf8(&s[start..]) {
884                    Ok(r) => result.push_str(r),
885                    Err(_) => return None,
886                }
887            }
888        }
889
890        if index == s.len() {
891            result.push_str("\\z");
892            return Some(result);
893        } else if s[index] == b'.' {
894            result.push_str("\\.");
895            index += 1;
896        } else {
897            return None;
898        }
899    }
900}
901
902impl DomainRule {
903    pub fn matches(&self, hostname: &[u8]) -> bool {
904        match self {
905            DomainRule::Any => true,
906            DomainRule::Wildcard(s) => {
907                // A stored wildcard always keeps its leading `*`, so the
908                // suffix (pattern minus `*`) is a strict sub-slice and the
909                // bare `*` (Any) never reaches this arm.
910                debug_assert_eq!(
911                    s.as_bytes().first(),
912                    Some(&b'*'),
913                    "a Wildcard rule must retain its leading '*'",
914                );
915                let suffix = &s.as_bytes()[1..];
916                let matched = hostname
917                    .strip_suffix(suffix)
918                    .is_some_and(|prefix| !prefix.is_empty() && !prefix.contains(&b'.'));
919                // A wildcard never matches a hostname no longer than its
920                // own suffix — there is no room left for the mandatory
921                // single non-empty leftmost label.
922                debug_assert!(
923                    !matched || hostname.len() > suffix.len(),
924                    "a wildcard match requires a non-empty leftmost label before the suffix",
925                );
926                matched
927            }
928            DomainRule::Exact(s) => s.as_bytes() == hostname,
929            DomainRule::Regex(r) => {
930                let start = Instant::now();
931                let is_a_match = r.is_match(hostname);
932                let now = Instant::now();
933                time!(
934                    names::event_loop::REGEX_MATCHING_TIME,
935                    (now - start).as_millis()
936                );
937                is_a_match
938            }
939        }
940    }
941}
942
943impl std::cmp::PartialEq for DomainRule {
944    fn eq(&self, other: &Self) -> bool {
945        match (self, other) {
946            (DomainRule::Any, DomainRule::Any) => true,
947            (DomainRule::Wildcard(s1), DomainRule::Wildcard(s2)) => s1 == s2,
948            (DomainRule::Exact(s1), DomainRule::Exact(s2)) => s1 == s2,
949            (DomainRule::Regex(r1), DomainRule::Regex(r2)) => r1.as_str() == r2.as_str(),
950            _ => false,
951        }
952    }
953}
954
955impl std::str::FromStr for DomainRule {
956    type Err = ();
957
958    fn from_str(s: &str) -> Result<Self, Self::Err> {
959        Ok(if s == "*" {
960            DomainRule::Any
961        } else if s.contains('/') {
962            match convert_regex_domain_rule(s) {
963                Some(s) => match regex::bytes::Regex::new(&s) {
964                    Ok(r) => DomainRule::Regex(r),
965                    Err(_) => return Err(()),
966                },
967                None => return Err(()),
968            }
969        } else if s.contains('*') {
970            if s.starts_with('*') {
971                match ::idna::domain_to_ascii(s) {
972                    Ok(r) => DomainRule::Wildcard(r),
973                    Err(_) => return Err(()),
974                }
975            } else {
976                return Err(());
977            }
978        } else {
979            match ::idna::domain_to_ascii(s) {
980                Ok(r) => DomainRule::Exact(r),
981                Err(_) => return Err(()),
982            }
983        })
984    }
985}
986
987#[derive(Clone, Debug)]
988pub enum PathRule {
989    Prefix(String),
990    Regex(Regex),
991    Equals(String),
992}
993
994#[derive(PartialEq, Eq)]
995pub enum PathRuleResult {
996    Regex,
997    Prefix(usize),
998    Equals,
999    None,
1000}
1001
1002impl PathRule {
1003    pub fn matches(&self, path: &[u8]) -> PathRuleResult {
1004        match self {
1005            PathRule::Prefix(prefix) => {
1006                if path.starts_with(prefix.as_bytes()) {
1007                    // The reported prefix length is the matched-byte count
1008                    // the router uses for longest-prefix tie-breaking; it
1009                    // must equal the prefix and never exceed the path.
1010                    debug_assert!(
1011                        prefix.len() <= path.len(),
1012                        "a matching prefix cannot be longer than the path it matched",
1013                    );
1014                    PathRuleResult::Prefix(prefix.len())
1015                } else {
1016                    PathRuleResult::None
1017                }
1018            }
1019            PathRule::Regex(regex) => {
1020                let start = Instant::now();
1021                let is_a_match = regex.is_match(path);
1022                let now = Instant::now();
1023                time!(
1024                    names::event_loop::REGEX_MATCHING_TIME,
1025                    (now - start).as_millis()
1026                );
1027
1028                if is_a_match {
1029                    PathRuleResult::Regex
1030                } else {
1031                    PathRuleResult::None
1032                }
1033            }
1034            PathRule::Equals(pattern) => {
1035                if path == pattern.as_bytes() {
1036                    PathRuleResult::Equals
1037                } else {
1038                    PathRuleResult::None
1039                }
1040            }
1041        }
1042    }
1043
1044    pub fn from_config(rule: CommandPathRule) -> Option<Self> {
1045        match PathRuleKind::try_from(rule.kind) {
1046            Ok(PathRuleKind::Prefix) => Some(PathRule::Prefix(rule.value)),
1047            Ok(PathRuleKind::Regex) => Regex::new(&rule.value).ok().map(PathRule::Regex),
1048            Ok(PathRuleKind::Equals) => Some(PathRule::Equals(rule.value)),
1049            Err(_) => None,
1050        }
1051    }
1052}
1053
1054impl std::cmp::PartialEq for PathRule {
1055    fn eq(&self, other: &Self) -> bool {
1056        match (self, other) {
1057            (PathRule::Prefix(s1), PathRule::Prefix(s2)) => s1 == s2,
1058            (PathRule::Regex(r1), PathRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1059            _ => false,
1060        }
1061    }
1062}
1063
1064#[derive(Clone, Debug, PartialEq, Eq)]
1065pub struct MethodRule {
1066    pub inner: Option<Method>,
1067}
1068
1069#[derive(PartialEq, Eq)]
1070pub enum MethodRuleResult {
1071    All,
1072    Equals,
1073    None,
1074}
1075
1076impl MethodRule {
1077    pub fn new(method: Option<String>) -> Self {
1078        MethodRule {
1079            inner: method.map(|s| Method::new(s.as_bytes())),
1080        }
1081    }
1082
1083    pub fn matches(&self, method: &Method) -> MethodRuleResult {
1084        match self.inner {
1085            None => MethodRuleResult::All,
1086            Some(ref m) => {
1087                if method == m {
1088                    MethodRuleResult::Equals
1089                } else {
1090                    MethodRuleResult::None
1091                }
1092            }
1093        }
1094    }
1095}
1096
1097/// What to do with a request that matches a frontend.
1098///
1099/// Three variants coexist today; the legacy two will retire once
1100/// `HttpFrontend` itself carries the rich routing fields:
1101///
1102/// - [`Route::ClusterId`] is the legacy "forward to this cluster" variant
1103///   used by call sites that build routes directly from
1104///   [`HttpFrontend::cluster_id`].
1105/// - [`Route::Deny`] is the legacy "send 401" variant used when a frontend
1106///   has no `cluster_id`.
1107/// - [`Route::Frontend`] carries a richer [`Frontend`] decision (redirect
1108///   policy, rewrite templates, header edits, auth gating). Once
1109///   `HttpFrontend` carries the matching proto fields, `add_http_front`
1110///   will build `Route::Frontend` directly and the two legacy variants
1111///   above can retire.
1112///
1113/// `Eq`/`PartialEq` compare `Frontend` variants by `Rc` pointer identity to
1114/// stay consistent with `Hash`/`Ord` on [`Rc`]; this is sufficient for the
1115/// router's de-duplication (`add_pre_rule`, `add_post_rule`,
1116/// `add_tree_rule`) which only checks against routes created from the same
1117/// configuration call.
1118#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1119pub enum Route {
1120    /// send a 401 default answer
1121    Deny,
1122    /// the cluster to which the frontend belongs
1123    ClusterId(ClusterId),
1124    /// rich routing decision carrying redirect, rewrite, header, and auth
1125    /// configuration; supersedes the two legacy variants once the
1126    /// in-memory frontend wiring is migrated to build `Route::Frontend`
1127    /// directly.
1128    Frontend(Rc<Frontend>),
1129}
1130
1131/// Materialise the listener-default HSTS into a single shareable
1132/// [`HeaderEdit`] when the supplied policy resolves to a non-empty
1133/// `Strict-Transport-Security` header.
1134///
1135/// Returns `None` when the listener has no HSTS configured
1136/// (`new_hsts.is_none()`), when HSTS is explicitly disabled
1137/// (`enabled = Some(false)`), or when the render fails because of a
1138/// missing `max_age` (`enabled = Some(true)` with `max_age = None`,
1139/// the malformed-IPC defense-in-depth gate). Mirrors the gate
1140/// previously embedded in `rebuild_with_listener_hsts`.
1141///
1142/// Used by [`Router::refresh_inheriting_hsts`] to:
1143/// - decide whether promoting a lightweight `Route::ClusterId` /
1144///   `Route::Deny` to `Route::Frontend` is worth doing (`Some` =
1145///   promote + counted; `None` = lightweight route untouched, no
1146///   allocation created just to hold an empty edit), AND
1147/// - **share the same `Rc`-backed `key` / `val` allocation across
1148///   every visited frontend in this patch**. Without sharing, each
1149///   refreshed frontend would allocate a fresh
1150///   `Rc::from(b"strict-transport-security")` and a fresh
1151///   `Rc::from(rendered.into_bytes())` — on a 91 k-frontend
1152///   `cleverapps.io` shared listener × 8 workers, one HSTS-enable
1153///   patch produces ~1.5 M identical-content `Rc` allocations per
1154///   worker. With sharing the cost collapses to one allocation per
1155///   patch plus a refcount bump on each frontend (`HeaderEdit::clone`
1156///   is two `Rc::clone`s + a byte copy).
1157fn build_listener_hsts_edit(new_hsts: Option<&HstsConfig>) -> Option<HeaderEdit> {
1158    let cfg = new_hsts?;
1159    if !matches!(cfg.enabled, Some(true)) {
1160        return None;
1161    }
1162    let rendered = render_hsts(cfg)?;
1163    let mode = if matches!(cfg.force_replace_backend, Some(true)) {
1164        HeaderEditMode::Set
1165    } else {
1166        HeaderEditMode::SetIfAbsent
1167    };
1168    Some(HeaderEdit {
1169        key: Rc::from(&b"strict-transport-security"[..]),
1170        val: rendered.into_bytes().into(),
1171        mode,
1172    })
1173}
1174
1175/// Build a new [`Frontend`] cloned from `frontend`, with its
1176/// `headers_response` re-materialised against `new_edit` — the
1177/// shared listener-default HSTS edit pre-built by
1178/// [`build_listener_hsts_edit`]. Used by
1179/// [`Router::refresh_inheriting_hsts`].
1180///
1181/// Preserves every operator-defined response-header edit (`Append`,
1182/// `Set`, legacy empty-`val`-Append delete) and replaces any existing
1183/// `Strict-Transport-Security` entry with `new_edit`. When `new_edit`
1184/// is `None` (listener-default HSTS resolves to "no HSTS"), the
1185/// function strips the existing STS entry and adds nothing.
1186///
1187/// Preserves the existing `inherits_listener_hsts` marker; callers
1188/// ensure it is `true` before invoking this helper (the function
1189/// uses `..frontend.clone()` so it inherits whatever the input has).
1190fn rebuild_with_listener_hsts(frontend: &Frontend, new_edit: Option<&HeaderEdit>) -> Frontend {
1191    // Strip any existing Strict-Transport-Security entry.
1192    let mut headers_response: Vec<HeaderEdit> = frontend
1193        .headers_response
1194        .iter()
1195        .filter(|edit| !edit.key.eq_ignore_ascii_case(b"strict-transport-security"))
1196        .cloned()
1197        .collect();
1198
1199    // `HeaderEdit::clone` here is two `Rc::clone`s on the shared
1200    // key/val plus a one-byte `mode` copy — no buffer allocation.
1201    if let Some(edit) = new_edit {
1202        headers_response.push(edit.clone());
1203    }
1204
1205    Frontend {
1206        headers_response: headers_response.into(),
1207        // every other field is unchanged
1208        ..frontend.clone()
1209    }
1210}
1211
1212/// Render an [`HstsConfig`] into a canonical RFC 6797 §6.1
1213/// `Strict-Transport-Security` header value: `max-age=N` first, then
1214/// optional `; includeSubDomains`, then optional `; preload`. No
1215/// trailing semicolon. `includeSubDomains` is the RFC §6.1 spelling
1216/// (camelCase); `preload` is lowercase per the de-facto Chrome/HSTS
1217/// preload-list convention (https://hstspreload.org/).
1218///
1219/// Returns `None` when the config has no `max_age` (the caller should
1220/// have substituted the default at config-load via
1221/// `command/src/config.rs::FileHstsConfig::to_proto` before reaching
1222/// this site; if it didn't, a `None` here suppresses the emission so a
1223/// malformed wire frame can't leak `max-age=0` accidentally).
1224pub fn render_hsts(cfg: &HstsConfig) -> Option<String> {
1225    let max_age = cfg.max_age?;
1226    let mut s = format!("max-age={max_age}");
1227    if matches!(cfg.include_subdomains, Some(true)) {
1228        s.push_str("; includeSubDomains");
1229    }
1230    if matches!(cfg.preload, Some(true)) {
1231        s.push_str("; preload");
1232    }
1233    Some(s)
1234}
1235
1236/// A single header mutation collected from a [`Frontend`] configuration.
1237///
1238/// `key` and `val` are owned via [`Rc`] so a `Frontend` can be held by many
1239/// routing entries (pre, tree, post) without copying the underlying bytes.
1240///
1241/// `mode` controls how the per-stream `apply_response_header_edits` pass
1242/// emits the entry on the wire — see [`HeaderEditMode`]. Operator-supplied
1243/// `[[...frontends.headers]]` entries default to [`HeaderEditMode::Append`]
1244/// (preserving the legacy empty-val-deletes encoding); typed policies
1245/// (HSTS, future RFC-correct response policies) opt into
1246/// [`HeaderEditMode::SetIfAbsent`].
1247#[derive(Clone, PartialEq, Eq)]
1248pub struct HeaderEdit {
1249    pub key: Rc<[u8]>,
1250    pub val: Rc<[u8]>,
1251    pub mode: HeaderEditMode,
1252}
1253
1254impl Debug for HeaderEdit {
1255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1256        f.write_fmt(format_args!(
1257            "({:?}, {:?}, {:?})",
1258            String::from_utf8_lossy(&self.key),
1259            String::from_utf8_lossy(&self.val),
1260            self.mode,
1261        ))
1262    }
1263}
1264
1265/// A parsed segment of a rewrite template.
1266///
1267/// `Host(i)` references the `i`-th host capture: index 0 is the full
1268/// hostname; positive indices are regex or wildcard subgroups. `Path(i)`
1269/// references the `i`-th path capture: index 0 is the full path; positive
1270/// indices are regex groups or prefix tails. `String` holds a literal
1271/// segment between captures.
1272#[derive(Debug, Clone, PartialEq, Eq)]
1273enum RewritePart {
1274    String(String),
1275    Host(usize),
1276    Path(usize),
1277}
1278
1279/// A pre-parsed rewrite template, decomposed into [`RewritePart`]s.
1280///
1281/// `RewriteParts` is built once at frontend registration time
1282/// ([`Frontend::new`]) and then re-applied at lookup time via
1283/// [`RewriteParts::run`] against the captures collected by the router.
1284///
1285/// Grammar:
1286/// - `$HOST[N]` — substitute the N-th host capture
1287/// - `$PATH[N]` — substitute the N-th path capture
1288/// - any other byte sequence — substitute literally
1289///
1290/// Out-of-bounds capture indices substitute to the empty string at run
1291/// time, but [`RewriteParts::parse`] rejects templates that reference
1292/// capture indices the router cannot produce (the `*_cap_cap` arguments).
1293#[derive(Debug, Clone, PartialEq, Eq)]
1294pub struct RewriteParts(Vec<RewritePart>);
1295
1296impl RewriteParts {
1297    /// Parse `template` against the host/path capture caps the router can
1298    /// produce for the matching domain/path rule.
1299    ///
1300    /// `host_cap_cap` and `path_cap_cap` are upper bounds (exclusive) on the
1301    /// host and path capture indices the router can fill at lookup time.
1302    /// `used_index_host` / `used_index_path` are out parameters tracking the
1303    /// highest index actually referenced — callers use them to short-circuit
1304    /// capture extraction when no template references captures.
1305    ///
1306    /// Returns `None` on syntactically malformed templates: dangling `$`,
1307    /// missing closing `]`, non-digit index, or an index ≥ the cap.
1308    pub fn parse(
1309        template: &str,
1310        host_cap_cap: usize,
1311        path_cap_cap: usize,
1312        used_index_host: &mut usize,
1313        used_index_path: &mut usize,
1314    ) -> Option<Self> {
1315        let mut result = Vec::new();
1316        let mut i = 0;
1317        let pattern = template.as_bytes();
1318        while i < pattern.len() {
1319            if pattern[i] == b'$' {
1320                let is_host = if pattern[i..].starts_with(b"$HOST[") {
1321                    i += 6;
1322                    true
1323                } else if pattern[i..].starts_with(b"$PATH[") {
1324                    i += 6;
1325                    false
1326                } else {
1327                    return None;
1328                };
1329                let mut index = 0usize;
1330                let digits_start = i;
1331                while i < pattern.len() && pattern[i].is_ascii_digit() {
1332                    index = index
1333                        .checked_mul(10)?
1334                        .checked_add((pattern[i] - b'0') as usize)?;
1335                    i += 1;
1336                }
1337                if i == digits_start {
1338                    // no digits between the `[` and the `]`
1339                    return None;
1340                }
1341                if i >= pattern.len() || pattern[i] != b']' {
1342                    return None;
1343                }
1344                if is_host {
1345                    if index >= host_cap_cap {
1346                        return None;
1347                    }
1348                    if index >= *used_index_host {
1349                        *used_index_host = index + 1;
1350                    }
1351                    result.push(RewritePart::Host(index));
1352                } else {
1353                    if index >= path_cap_cap {
1354                        return None;
1355                    }
1356                    if index >= *used_index_path {
1357                        *used_index_path = index + 1;
1358                    }
1359                    result.push(RewritePart::Path(index));
1360                }
1361                i += 1; // consume `]`
1362            } else {
1363                let start = i;
1364                while i < pattern.len() && pattern[i] != b'$' {
1365                    i += 1;
1366                }
1367                // `pattern` is `template.as_bytes()` and the split is on
1368                // the ASCII byte `$` (0x24), which is always a single-byte
1369                // UTF-8 character — so `template[start..i]` lies on char
1370                // boundaries and is safe to index directly.
1371                result.push(RewritePart::String(template[start..i].to_owned()));
1372            }
1373        }
1374        // Every capture reference the parser emitted is within the caps it
1375        // was given; out-of-range indices return None above, never a part.
1376        debug_assert!(
1377            result.iter().all(|part| match part {
1378                RewritePart::Host(idx) => *idx < host_cap_cap,
1379                RewritePart::Path(idx) => *idx < path_cap_cap,
1380                RewritePart::String(_) => true,
1381            }),
1382            "a parsed rewrite template must only reference captures within the rule's caps",
1383        );
1384        debug_assert!(
1385            *used_index_host <= host_cap_cap && *used_index_path <= path_cap_cap,
1386            "the highest referenced capture index cannot exceed the cap",
1387        );
1388        Some(Self(result))
1389    }
1390
1391    /// Substitute `host_captures` and `path_captures` into the template.
1392    ///
1393    /// Out-of-bounds captures substitute to an empty string. The result is
1394    /// allocated in one pass with the exact required capacity.
1395    pub fn run(&self, host_captures: &[&str], path_captures: &[&str]) -> String {
1396        let mut cap = 0usize;
1397        for part in &self.0 {
1398            cap += match part {
1399                RewritePart::String(s) => s.len(),
1400                RewritePart::Host(i) => host_captures.get(*i).map(|s| s.len()).unwrap_or(0),
1401                RewritePart::Path(i) => path_captures.get(*i).map(|s| s.len()).unwrap_or(0),
1402            };
1403        }
1404        let mut result = String::with_capacity(cap);
1405        for part in &self.0 {
1406            // String::write_str cannot fail — ignore the formatter result.
1407            let _ = match part {
1408                RewritePart::String(s) => result.write_str(s),
1409                RewritePart::Host(i) => result.write_str(host_captures.get(*i).unwrap_or(&"")),
1410                RewritePart::Path(i) => result.write_str(path_captures.get(*i).unwrap_or(&"")),
1411            };
1412        }
1413        // The capacity pass and the write pass consult the same parts and
1414        // captures, so the single up-front allocation must be exact — the
1415        // result never reallocates.
1416        debug_assert_eq!(
1417            result.len(),
1418            cap,
1419            "rewrite output length must equal the pre-computed one-pass capacity",
1420        );
1421        result
1422    }
1423}
1424
1425/// What to do with the traffic for a routed frontend.
1426///
1427/// Built once at frontend registration time. The expensive work (parsing
1428/// rewrite templates, resolving headers into [`HeaderEdit`]s) happens here
1429/// so [`Router::lookup`] can run cheaply on the hot path.
1430///
1431/// A clusterless frontend with `redirect == FORWARD` is coerced to
1432/// `UNAUTHORIZED` in [`Frontend::new`] to avoid a forward loop with no
1433/// backend; the explicit `UNAUTHORIZED` policy then renders a 401.
1434///
1435/// Tags are wrapped in [`Rc<CachedTags>`] so the same frontend can be
1436/// referenced from multiple routing slots (pre/tree/post) without copying.
1437#[derive(Debug, Clone)]
1438pub struct Frontend {
1439    pub cluster_id: Option<ClusterId>,
1440    pub redirect: RedirectPolicy,
1441    pub redirect_scheme: RedirectScheme,
1442    pub redirect_template: Option<String>,
1443    /// Number of host captures the router will collect for this frontend.
1444    /// Sized from the matching [`DomainRule`]; the router skips capture
1445    /// extraction entirely when this is 0 (no rewrite references `$HOST[…]`).
1446    pub capture_cap_host: usize,
1447    /// Number of path captures the router will collect for this frontend.
1448    /// Sized from the matching [`PathRule`]; the router skips capture
1449    /// extraction entirely when this is 0 (no rewrite references `$PATH[…]`).
1450    pub capture_cap_path: usize,
1451    pub rewrite_host: Option<RewriteParts>,
1452    pub rewrite_path: Option<RewriteParts>,
1453    pub rewrite_port: Option<u16>,
1454    pub headers_request: Rc<[HeaderEdit]>,
1455    pub headers_response: Rc<[HeaderEdit]>,
1456    pub required_auth: bool,
1457    pub tags: Option<Rc<CachedTags>>,
1458    /// `true` when the materialised HSTS edit (if any) in
1459    /// [`Self::headers_response`] came from the listener-default
1460    /// `HttpsListenerConfig.hsts` rather than the per-frontend
1461    /// `RequestHttpFrontend.hsts` block. Consulted by
1462    /// [`Router::refresh_inheriting_hsts`] so a
1463    /// `UpdateHttpsListenerConfig.hsts` patch reflows the new default
1464    /// onto inheriting frontends without overwriting explicit
1465    /// per-frontend HSTS overrides.
1466    pub inherits_listener_hsts: bool,
1467}
1468
1469/// Origin of the per-frontend HSTS policy carried by an
1470/// [`HttpFrontend`] when the router materialises it into a
1471/// [`Frontend`]. Tracked separately because the resolved
1472/// `HttpFrontend.hsts` field is the same shape regardless of how it
1473/// was filled in — the inheritance bit lets later listener-default
1474/// patches refresh inheriting frontends without disturbing explicit
1475/// per-frontend overrides.
1476#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1477pub enum HstsOrigin {
1478    /// `front.hsts` came from the per-frontend configuration directly
1479    /// (operator wrote `[clusters.<id>.frontends.hsts]` in TOML or
1480    /// passed `--hsts-*` on the CLI). Listener-default patches do NOT
1481    /// refresh this entry.
1482    Explicit,
1483    /// `front.hsts` was filled in by `add_https_frontend` from the
1484    /// listener-default `HttpsListenerConfig.hsts`. A future
1485    /// `UpdateHttpsListenerConfig.hsts` patch will refresh this entry
1486    /// via [`Router::refresh_inheriting_hsts`].
1487    InheritedFromListenerDefault,
1488}
1489
1490impl PartialEq for Frontend {
1491    fn eq(&self, other: &Self) -> bool {
1492        // Frontend instances share the rest of their fields with the
1493        // originating HttpFrontend; equality is decided by the same fields
1494        // the router uses for de-duplication.
1495        self.cluster_id == other.cluster_id
1496            && self.redirect == other.redirect
1497            && self.redirect_scheme == other.redirect_scheme
1498            && self.redirect_template == other.redirect_template
1499            && self.rewrite_host == other.rewrite_host
1500            && self.rewrite_path == other.rewrite_path
1501            && self.rewrite_port == other.rewrite_port
1502            && self.headers_request == other.headers_request
1503            && self.headers_response == other.headers_response
1504            && self.required_auth == other.required_auth
1505    }
1506}
1507
1508impl Eq for Frontend {}
1509
1510impl std::hash::Hash for Frontend {
1511    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1512        self.cluster_id.hash(state);
1513        // RedirectPolicy / RedirectScheme are i32-backed proto enums; hash
1514        // them as i32 to avoid requiring a Hash impl on the generated enum.
1515        (self.redirect as i32).hash(state);
1516        (self.redirect_scheme as i32).hash(state);
1517        self.redirect_template.hash(state);
1518        self.required_auth.hash(state);
1519    }
1520}
1521
1522impl PartialOrd for Frontend {
1523    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1524        Some(self.cmp(other))
1525    }
1526}
1527
1528impl Ord for Frontend {
1529    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1530        self.cluster_id
1531            .cmp(&other.cluster_id)
1532            .then_with(|| (self.redirect as i32).cmp(&(other.redirect as i32)))
1533            .then_with(|| (self.redirect_scheme as i32).cmp(&(other.redirect_scheme as i32)))
1534            .then_with(|| self.redirect_template.cmp(&other.redirect_template))
1535            .then_with(|| self.required_auth.cmp(&other.required_auth))
1536    }
1537}
1538
1539impl Frontend {
1540    /// Build a [`Frontend`] from a domain/path rule pair and an
1541    /// [`HttpFrontend`] configuration.
1542    ///
1543    /// The richer proto-level fields (`redirect`, `redirect_scheme`,
1544    /// `redirect_template`, `rewrite_*`, `headers`, `required_auth`) are
1545    /// not yet carried on `HttpFrontend`; until they are, this constructor
1546    /// takes them as explicit arguments so the data flow is testable
1547    /// today and the call sites in `add_http_front` only need a one-line
1548    /// update once the fields are plumbed through.
1549    ///
1550    /// Coercions:
1551    /// - `redirect == UNAUTHORIZED` zeroes out rewrite/headers/auth — the
1552    ///   request will be rejected with a 401 regardless.
1553    /// - `redirect == FORWARD` on a clusterless frontend (`cluster_id ==
1554    ///   None`) is coerced to `UNAUTHORIZED` (logged as a warning) to
1555    ///   avoid a forward loop with no backend.
1556    ///
1557    /// Returns [`RouterError::InvalidHostRewrite`] /
1558    /// [`RouterError::InvalidPathRewrite`] when a rewrite template fails to
1559    /// parse against the rule's capture caps.
1560    #[allow(clippy::too_many_arguments)]
1561    pub fn new(
1562        domain_rule: &DomainRule,
1563        path_rule: &PathRule,
1564        front: &HttpFrontend,
1565        redirect: RedirectPolicy,
1566        redirect_scheme: RedirectScheme,
1567        redirect_template: Option<String>,
1568        rewrite_host: Option<String>,
1569        rewrite_path: Option<String>,
1570        rewrite_port: Option<u16>,
1571        headers: &[sozu_command::proto::command::Header],
1572        required_auth: bool,
1573        hsts_origin: HstsOrigin,
1574    ) -> Result<Self, RouterError> {
1575        // HSTS is read from `front.hsts` directly inside the function;
1576        // an explicit parameter would be redundant since `front` is
1577        // already in scope and the field is the single source of truth.
1578        // The `hsts_origin` parameter records *where* `front.hsts` came
1579        // from so [`Router::refresh_inheriting_hsts`] can reflow listener
1580        // defaults without disturbing explicit per-frontend overrides.
1581        let hsts = front.hsts.as_ref();
1582        let inherits_listener_hsts =
1583            matches!(hsts_origin, HstsOrigin::InheritedFromListenerDefault) && hsts.is_some();
1584        let cluster_id = front.cluster_id.clone();
1585        let tags = front
1586            .tags
1587            .clone()
1588            .map(|tags| Rc::new(CachedTags::new(tags)));
1589
1590        // Coerce clusterless FORWARD to UNAUTHORIZED before doing any
1591        // expensive parsing: those routes can never proceed to a backend, so
1592        // emitting a 401 is the safe default. Empty redirect_template is
1593        // treated as None semantics so we never store an empty Rc<[…]>.
1594        let redirect_template = redirect_template.filter(|s| !s.is_empty());
1595        let rewrite_host = rewrite_host.filter(|s| !s.is_empty());
1596        let rewrite_path = rewrite_path.filter(|s| !s.is_empty());
1597
1598        let deny = match (&cluster_id, redirect) {
1599            (_, RedirectPolicy::Unauthorized) => true,
1600            (None, RedirectPolicy::Forward) => {
1601                let (domain_kind, domain_bytes) = match &domain_rule {
1602                    DomainRule::Any => ("any", 0),
1603                    DomainRule::Exact(value) => ("exact", value.len()),
1604                    DomainRule::Wildcard(value) => ("wildcard", value.len()),
1605                    DomainRule::Regex(value) => ("regex", value.as_str().len()),
1606                };
1607                let (path_kind, path_bytes) = match &path_rule {
1608                    PathRule::Prefix(value) => ("prefix", value.len()),
1609                    PathRule::Regex(value) => ("regex", value.as_str().len()),
1610                    PathRule::Equals(value) => ("equals", value.len()),
1611                };
1612                warn!(
1613                    "{} Frontend[domain_kind={}, domain_bytes={}, path_kind={}, path_bytes={}]: forward on clusterless frontends are unauthorized",
1614                    log_module_context!(),
1615                    domain_kind,
1616                    domain_bytes,
1617                    path_kind,
1618                    path_bytes,
1619                );
1620                true
1621            }
1622            _ => false,
1623        };
1624        if deny {
1625            // The Unauthorized policy zeroes out request rewrites and
1626            // header injections (the request never reaches a backend),
1627            // but RFC 6797 §8.1 still requires HSTS on the 401 default
1628            // answer when the frontend is on an HTTPS listener. Build
1629            // the HSTS edit (when configured) so the per-stream
1630            // snapshot copy in `mux/router.rs` carries it through to
1631            // `set_default_answer_with_retry_after`.
1632            let mut deny_headers_response: Vec<HeaderEdit> = Vec::new();
1633            if let Some(cfg) = hsts
1634                && matches!(cfg.enabled, Some(true))
1635                && let Some(rendered) = render_hsts(cfg)
1636            {
1637                let mode = if matches!(cfg.force_replace_backend, Some(true)) {
1638                    HeaderEditMode::Set
1639                } else {
1640                    HeaderEditMode::SetIfAbsent
1641                };
1642                deny_headers_response.push(HeaderEdit {
1643                    key: Rc::from(&b"strict-transport-security"[..]),
1644                    val: rendered.into_bytes().into(),
1645                    mode,
1646                });
1647                crate::incr!(names::http::HSTS_FRONTEND_ADDED);
1648            }
1649
1650            return Ok(Self {
1651                cluster_id,
1652                redirect: RedirectPolicy::Unauthorized,
1653                redirect_scheme,
1654                redirect_template: None,
1655                capture_cap_host: 0,
1656                capture_cap_path: 0,
1657                rewrite_host: None,
1658                rewrite_path: None,
1659                rewrite_port: None,
1660                headers_request: Rc::new([]),
1661                headers_response: deny_headers_response.into(),
1662                required_auth,
1663                tags,
1664                inherits_listener_hsts,
1665            });
1666        }
1667
1668        // Capture caps: the maximum index a `$HOST[N]` / `$PATH[N]`
1669        // template can reference for this rule pair. Index 0 is always the
1670        // full hostname/path; subsequent indices are wildcard tail / regex
1671        // groups.
1672        let mut capture_cap_host = match domain_rule {
1673            DomainRule::Any => 1,
1674            DomainRule::Exact(_) => 1,
1675            DomainRule::Wildcard(_) => 2,
1676            DomainRule::Regex(regex) => regex.captures_len(),
1677        };
1678        let mut capture_cap_path = match path_rule {
1679            PathRule::Equals(_) => 1,
1680            PathRule::Prefix(_) => 2,
1681            PathRule::Regex(regex) => regex.captures_len(),
1682        };
1683        let mut used_capture_host = 0usize;
1684        let mut used_capture_path = 0usize;
1685        let rewrite_host_parts = if let Some(p) = rewrite_host {
1686            Some(
1687                RewriteParts::parse(
1688                    &p,
1689                    capture_cap_host,
1690                    capture_cap_path,
1691                    &mut used_capture_host,
1692                    &mut used_capture_path,
1693                )
1694                .ok_or(RouterError::InvalidHostRewrite(p))?,
1695            )
1696        } else {
1697            None
1698        };
1699        let rewrite_path_parts = if let Some(p) = rewrite_path {
1700            Some(
1701                RewriteParts::parse(
1702                    &p,
1703                    capture_cap_host,
1704                    capture_cap_path,
1705                    &mut used_capture_host,
1706                    &mut used_capture_path,
1707                )
1708                .ok_or(RouterError::InvalidPathRewrite(p))?,
1709            )
1710        } else {
1711            None
1712        };
1713        // Skip capture extraction at lookup time when no template references
1714        // a capture for this dimension.
1715        if used_capture_host == 0 {
1716            capture_cap_host = 0;
1717        }
1718        if used_capture_path == 0 {
1719            capture_cap_path = 0;
1720        }
1721
1722        let mut headers_request = Vec::new();
1723        let mut headers_response = Vec::new();
1724        for header in headers {
1725            let edit = HeaderEdit {
1726                key: header.key.as_bytes().into(),
1727                val: header.val.as_bytes().into(),
1728                mode: HeaderEditMode::Append,
1729            };
1730            match header.position() {
1731                HeaderPosition::Request => headers_request.push(edit),
1732                HeaderPosition::Response => headers_response.push(edit),
1733                HeaderPosition::Both => {
1734                    headers_request.push(edit.clone());
1735                    headers_response.push(edit);
1736                }
1737                // The proto-default-encoded shape (`position: 0`). The TOML
1738                // loader rejects this case in `parse_header_edit` so the
1739                // path is only reachable via a manually-constructed
1740                // `Header { position: 0, … }` from a buggy or older
1741                // client. Drop the edit rather than guessing a position.
1742                HeaderPosition::Unspecified => {
1743                    warn!(
1744                        "{} dropping {:?} with HEADER_POSITION_UNSPECIFIED",
1745                        log_module_context!(),
1746                        header,
1747                    );
1748                }
1749            }
1750        }
1751
1752        // Materialise HSTS (RFC 6797) into the response-side header
1753        // collection as a single `SetIfAbsent` edit so an upstream-emitted
1754        // `Strict-Transport-Security` survives unchanged (RFC 6797 §6.1
1755        // single-header requirement). `enabled = Some(false)` is the
1756        // explicit-disable signal — emit nothing. The §7.2 "no STS over
1757        // plaintext HTTP" gate is enforced by the runtime snapshot site,
1758        // which only copies `headers_response` for HTTPS-served requests.
1759        if let Some(cfg) = hsts
1760            && matches!(cfg.enabled, Some(true))
1761        {
1762            if let Some(rendered) = render_hsts(cfg) {
1763                // RFC 6797 §6.1 default: PRESERVE backend-supplied STS
1764                // (SetIfAbsent). Operator opts into harden-centrally
1765                // override via `force_replace_backend = true`, which
1766                // selects `Set` (delete-then-insert) so any backend
1767                // STS is replaced with sozu's rendered policy.
1768                let mode = if matches!(cfg.force_replace_backend, Some(true)) {
1769                    HeaderEditMode::Set
1770                } else {
1771                    HeaderEditMode::SetIfAbsent
1772                };
1773                headers_response.push(HeaderEdit {
1774                    key: Rc::from(&b"strict-transport-security"[..]),
1775                    val: rendered.into_bytes().into(),
1776                    mode,
1777                });
1778                crate::incr!(names::http::HSTS_FRONTEND_ADDED);
1779            } else {
1780                // Both upstream config layers (FileHstsConfig::to_proto and
1781                // build_hsts_from_cli) substitute DEFAULT_HSTS_MAX_AGE when
1782                // enabled = Some(true) && max_age = None, so reaching this
1783                // branch means a programmatic IPC sender produced an
1784                // ill-formed HstsConfig. Surface it loudly rather than
1785                // silently emitting no header — operators inspecting their
1786                // dashboards for http.hsts.unrendered will catch the bug.
1787                warn!(
1788                    "{} HSTS enabled = true on frontend cluster_id_bytes={:?} but render_hsts \
1789                     returned None (max_age missing). Frontend will not emit \
1790                     Strict-Transport-Security; the config layer that built \
1791                     this HstsConfig must substitute DEFAULT_HSTS_MAX_AGE.",
1792                    log_module_context!(),
1793                    cluster_id.as_ref().map(String::len),
1794                );
1795                crate::incr!(names::http::HSTS_UNRENDERED);
1796            }
1797        }
1798
1799        Ok(Frontend {
1800            cluster_id,
1801            redirect,
1802            redirect_scheme,
1803            redirect_template,
1804            capture_cap_host,
1805            capture_cap_path,
1806            rewrite_host: rewrite_host_parts,
1807            rewrite_path: rewrite_path_parts,
1808            rewrite_port,
1809            headers_request: headers_request.into(),
1810            headers_response: headers_response.into(),
1811            required_auth,
1812            tags,
1813            inherits_listener_hsts,
1814        })
1815    }
1816
1817    /// Build a minimal Frontend that simply forwards to `cluster_id`,
1818    /// with no rewrite / header / auth configuration. Equivalent to a
1819    /// `Route::ClusterId(cluster_id)` lookup-wise (lookup short-circuits
1820    /// to `RouteResult::forward(id)` for both shapes), with the added
1821    /// ability to carry response-header edits — used by
1822    /// [`Router::refresh_inheriting_hsts`] to promote a lightweight
1823    /// `Route::ClusterId` to a `Route::Frontend` carrying just the
1824    /// listener-default HSTS edit. The `inherits_listener_hsts = true`
1825    /// marker lets subsequent listener-default patches keep refreshing
1826    /// the promoted entry.
1827    pub(crate) fn minimal_forward(cluster_id: ClusterId) -> Self {
1828        Self {
1829            cluster_id: Some(cluster_id),
1830            redirect: RedirectPolicy::Forward,
1831            redirect_scheme: RedirectScheme::UseSame,
1832            redirect_template: None,
1833            capture_cap_host: 0,
1834            capture_cap_path: 0,
1835            rewrite_host: None,
1836            rewrite_path: None,
1837            rewrite_port: None,
1838            headers_request: Rc::new([]),
1839            headers_response: Rc::new([]),
1840            required_auth: false,
1841            tags: None,
1842            inherits_listener_hsts: true,
1843        }
1844    }
1845
1846    /// Build a minimal clusterless Frontend with the
1847    /// [`RedirectPolicy::Unauthorized`] policy. Equivalent to a
1848    /// `Route::Deny` lookup-wise (both yield a 401 default answer),
1849    /// with the added ability to carry response-header edits — used by
1850    /// [`Router::refresh_inheriting_hsts`] to promote `Route::Deny`
1851    /// entries when the listener-default HSTS becomes enabled, so the
1852    /// 401 default answer carries the `Strict-Transport-Security`
1853    /// header per RFC 6797 §8.1. The `inherits_listener_hsts = true`
1854    /// marker lets subsequent listener-default patches keep refreshing
1855    /// the promoted entry.
1856    pub(crate) fn minimal_deny() -> Self {
1857        Self {
1858            cluster_id: None,
1859            redirect: RedirectPolicy::Unauthorized,
1860            redirect_scheme: RedirectScheme::UseSame,
1861            redirect_template: None,
1862            capture_cap_host: 0,
1863            capture_cap_path: 0,
1864            rewrite_host: None,
1865            rewrite_path: None,
1866            rewrite_port: None,
1867            headers_request: Rc::new([]),
1868            headers_response: Rc::new([]),
1869            required_auth: false,
1870            tags: None,
1871            inherits_listener_hsts: true,
1872        }
1873    }
1874}
1875
1876/// Routing decision returned by [`Router::lookup`] and consumed by the
1877/// session layer.
1878///
1879/// Computed from a matched [`Frontend`] by running [`RewriteParts::run`]
1880/// against the captures collected during routing. Legacy [`Route::ClusterId`]
1881/// and [`Route::Deny`] entries synthesize a minimal `RouteResult` with the
1882/// proto enums set to defaults (`FORWARD` / `UNAUTHORIZED`) so existing
1883/// session code keeps working until the mux layer is updated to read every
1884/// `RouteResult` field directly.
1885///
1886/// Implements `PartialEq` for test parity: existing router tests compare
1887/// `router.lookup(...)` against an expected route. Equality compares every
1888/// public field, including the `Rc<[HeaderEdit]>` slices via pointer-or-content
1889/// equality on the slice contents.
1890#[derive(Debug, Clone, PartialEq)]
1891pub struct RouteResult {
1892    pub cluster_id: Option<ClusterId>,
1893    pub redirect: RedirectPolicy,
1894    pub redirect_scheme: RedirectScheme,
1895    pub redirect_template: Option<String>,
1896    pub rewritten_host: Option<String>,
1897    pub rewritten_path: Option<String>,
1898    pub rewritten_port: Option<u16>,
1899    pub headers_request: Rc<[HeaderEdit]>,
1900    pub headers_response: Rc<[HeaderEdit]>,
1901    pub required_auth: bool,
1902    pub tags: Option<Rc<CachedTags>>,
1903}
1904
1905impl RouteResult {
1906    /// Synthesize a `RouteResult` representing a 401 (Deny) decision.
1907    pub fn deny(cluster_id: Option<ClusterId>) -> Self {
1908        Self {
1909            cluster_id,
1910            redirect: RedirectPolicy::Unauthorized,
1911            redirect_scheme: RedirectScheme::UseSame,
1912            redirect_template: None,
1913            rewritten_host: None,
1914            rewritten_path: None,
1915            rewritten_port: None,
1916            headers_request: Rc::new([]),
1917            headers_response: Rc::new([]),
1918            required_auth: false,
1919            tags: None,
1920        }
1921    }
1922
1923    /// Synthesize a `RouteResult` representing a "forward to this cluster"
1924    /// decision (legacy [`Route::ClusterId`] adapter).
1925    pub fn forward(cluster_id: ClusterId) -> Self {
1926        Self {
1927            cluster_id: Some(cluster_id),
1928            redirect: RedirectPolicy::Forward,
1929            redirect_scheme: RedirectScheme::UseSame,
1930            redirect_template: None,
1931            rewritten_host: None,
1932            rewritten_path: None,
1933            rewritten_port: None,
1934            headers_request: Rc::new([]),
1935            headers_response: Rc::new([]),
1936            required_auth: false,
1937            tags: None,
1938        }
1939    }
1940
1941    /// Build a `RouteResult` from a [`Frontend`] and the captures collected
1942    /// for this lookup.
1943    fn from_frontend(
1944        frontend: &Frontend,
1945        captures_host: Vec<&str>,
1946        path: &[u8],
1947        path_rule: &PathRule,
1948    ) -> Self {
1949        // Unauthorized short-circuit: skip the path-capture extraction
1950        // entirely — the response will not consume any rewrite output.
1951        // `headers_response` IS preserved here (unlike `headers_request`)
1952        // so the per-stream snapshot copy in `mux/router.rs` can still
1953        // pick up the per-frontend HSTS edit and inject it on the 401
1954        // default answer (RFC 6797 §8.1 — HSTS applies to all response
1955        // codes, including the proxy's typed unauthorized answer).
1956        if frontend.redirect == RedirectPolicy::Unauthorized {
1957            return Self {
1958                cluster_id: frontend.cluster_id.clone(),
1959                redirect: RedirectPolicy::Unauthorized,
1960                redirect_scheme: frontend.redirect_scheme,
1961                redirect_template: frontend.redirect_template.clone(),
1962                rewritten_host: None,
1963                rewritten_path: None,
1964                rewritten_port: None,
1965                headers_request: Rc::new([]),
1966                headers_response: frontend.headers_response.clone(),
1967                required_auth: frontend.required_auth,
1968                tags: frontend.tags.clone(),
1969            };
1970        }
1971
1972        let mut captures_path: Vec<&str> = Vec::with_capacity(frontend.capture_cap_path);
1973        if frontend.capture_cap_path > 0 {
1974            captures_path.push(from_utf8(path).unwrap_or_default());
1975            match path_rule {
1976                PathRule::Prefix(prefix) => {
1977                    let tail_start = prefix.len().min(path.len());
1978                    captures_path.push(from_utf8(&path[tail_start..]).unwrap_or_default());
1979                }
1980                PathRule::Regex(regex) => {
1981                    if let Some(caps) = regex.captures(path) {
1982                        captures_path.extend(caps.iter().skip(1).map(|c| {
1983                            c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
1984                                .unwrap_or("")
1985                        }));
1986                    }
1987                }
1988                PathRule::Equals(_) => {}
1989            }
1990        }
1991
1992        Self {
1993            cluster_id: frontend.cluster_id.clone(),
1994            redirect: frontend.redirect,
1995            redirect_scheme: frontend.redirect_scheme,
1996            redirect_template: frontend.redirect_template.clone(),
1997            rewritten_host: frontend
1998                .rewrite_host
1999                .as_ref()
2000                .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2001            rewritten_path: frontend
2002                .rewrite_path
2003                .as_ref()
2004                .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2005            rewritten_port: frontend.rewrite_port,
2006            headers_request: frontend.headers_request.clone(),
2007            headers_response: frontend.headers_response.clone(),
2008            required_auth: frontend.required_auth,
2009            tags: frontend.tags.clone(),
2010        }
2011    }
2012
2013    /// Build a `RouteResult` for a pre/post rule match.
2014    ///
2015    /// Pre/post rules carry the matched [`DomainRule`] directly so we can
2016    /// extract host captures from it without going through the trie.
2017    fn new_no_trie<'a>(
2018        domain: &'a [u8],
2019        domain_rule: &DomainRule,
2020        path: &'a [u8],
2021        path_rule: &PathRule,
2022        route: &Route,
2023    ) -> Self {
2024        let frontend = match route {
2025            Route::Frontend(f) => f.clone(),
2026            Route::ClusterId(id) => return Self::forward(id.clone()),
2027            Route::Deny => return Self::deny(None),
2028        };
2029        let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2030        if frontend.capture_cap_host > 0 {
2031            captures_host.push(from_utf8(domain).unwrap_or_default());
2032            match domain_rule {
2033                DomainRule::Wildcard(suffix) => {
2034                    let head_end = domain.len().saturating_sub(suffix.len().saturating_sub(1));
2035                    captures_host.push(from_utf8(&domain[..head_end]).unwrap_or_default());
2036                }
2037                DomainRule::Regex(regex) => {
2038                    if let Some(caps) = regex.captures(domain) {
2039                        captures_host.extend(caps.iter().skip(1).map(|c| {
2040                            c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2041                                .unwrap_or("")
2042                        }));
2043                    }
2044                }
2045                DomainRule::Any | DomainRule::Exact(_) => {}
2046            }
2047        }
2048        Self::from_frontend(&frontend, captures_host, path, path_rule)
2049    }
2050
2051    /// Build a `RouteResult` for a tree-match.
2052    ///
2053    /// Tree matches carry the captures collected by the trie traversal
2054    /// (`TrieMatches`) alongside the matched leaf path rule.
2055    fn new_with_trie<'a, 'b>(
2056        domain: &'a [u8],
2057        domain_submatches: TrieMatches<'a, 'b>,
2058        path: &'a [u8],
2059        path_rule: &PathRule,
2060        route: &Route,
2061    ) -> Self {
2062        let frontend = match route {
2063            Route::Frontend(f) => f.clone(),
2064            Route::ClusterId(id) => return Self::forward(id.clone()),
2065            Route::Deny => return Self::deny(None),
2066        };
2067        let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2068        if frontend.capture_cap_host > 0 {
2069            captures_host.push(from_utf8(domain).unwrap_or_default());
2070            for submatch in &domain_submatches {
2071                match submatch {
2072                    TrieSubMatch::Wildcard(part) => {
2073                        captures_host.push(from_utf8(part).unwrap_or_default());
2074                    }
2075                    TrieSubMatch::Regexp(part, regex) => {
2076                        if let Some(caps) = regex.captures(part) {
2077                            captures_host.extend(caps.iter().skip(1).map(|c| {
2078                                c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2079                                    .unwrap_or("")
2080                            }));
2081                        }
2082                    }
2083                }
2084            }
2085        }
2086        Self::from_frontend(&frontend, captures_host, path, path_rule)
2087    }
2088}
2089
2090#[cfg(test)]
2091mod tests {
2092    use super::*;
2093
2094    fn test_http_frontend() -> HttpFrontend {
2095        HttpFrontend {
2096            cluster_id: Some("cluster".to_owned()),
2097            address: "127.0.0.1:8080"
2098                .parse()
2099                .expect("test frontend address must parse"),
2100            hostname: "example.com".to_owned(),
2101            path: CommandPathRule::prefix("/".to_owned()),
2102            method: None,
2103            position: RulePosition::Tree,
2104            tags: None,
2105            redirect: None,
2106            redirect_scheme: None,
2107            redirect_template: None,
2108            rewrite_host: None,
2109            rewrite_path: None,
2110            rewrite_port: None,
2111            required_auth: None,
2112            headers: Vec::new(),
2113            hsts: None,
2114        }
2115    }
2116
2117    #[test]
2118    fn clusterless_forward_warning_redacts_domain_and_path_rules() {
2119        const DOMAIN_SECRET: &str = "clusterless_domain_secret_sentinel";
2120        const PATH_SECRET: &str = "CLUSTERLESS_PATH_SECRET_SENTINEL";
2121
2122        // The hostname stays below `MAX_HOSTNAME_LENGTH` so it reaches the
2123        // code under test instead of the pre-parse bound; the redaction
2124        // property being asserted is length-independent.
2125        let domain = format!("{DOMAIN_SECRET}{}", "x".repeat(2048));
2126        let path = format!("/{PATH_SECRET}{}", "x".repeat(4096));
2127        let domain_len = domain.len();
2128        let path_len = path.len();
2129        let output = crate::capture_test_logs(move || {
2130            let mut router = Router::new();
2131            let mut front = test_http_frontend();
2132            front.cluster_id = None;
2133            front.hostname = domain;
2134            front.path = CommandPathRule::prefix(path);
2135            front.redirect = Some(RedirectPolicy::Forward as i32);
2136            router
2137                .add_http_front(&front)
2138                .expect("clusterless forward must be coerced to unauthorized");
2139        });
2140
2141        for secret in [DOMAIN_SECRET, PATH_SECRET] {
2142            assert!(
2143                !output.contains(secret),
2144                "clusterless-forward warning leaked rule marker {secret}"
2145            );
2146        }
2147        for metadata in [
2148            "domain_kind=exact".to_owned(),
2149            format!("domain_bytes={domain_len}"),
2150            "path_kind=prefix".to_owned(),
2151            format!("path_bytes={path_len}"),
2152        ] {
2153            assert!(
2154                output.contains(&metadata),
2155                "clusterless-forward warning omitted bounded metadata {metadata}: {output}"
2156            );
2157        }
2158        assert!(
2159            output.len() <= 512,
2160            "clusterless-forward warning is not bounded: {} bytes",
2161            output.len()
2162        );
2163    }
2164
2165    #[test]
2166    fn malformed_hsts_warning_redacts_cluster_id() {
2167        const CLUSTER_SECRET: &str = "MALFORMED_HSTS_CLUSTER_SECRET_SENTINEL";
2168
2169        let cluster_id = format!("{CLUSTER_SECRET}{}", "x".repeat(4096));
2170        let cluster_id_len = cluster_id.len();
2171        let output = crate::capture_test_logs(move || {
2172            let mut router = Router::new();
2173            let mut front = test_http_frontend();
2174            front.cluster_id = Some(cluster_id);
2175            front.hsts = Some(HstsConfig {
2176                enabled: Some(true),
2177                max_age: None,
2178                include_subdomains: Some(true),
2179                preload: Some(true),
2180                force_replace_backend: Some(false),
2181            });
2182            router
2183                .add_http_front(&front)
2184                .expect("malformed HSTS must preserve routing and omit the header");
2185        });
2186
2187        assert!(
2188            !output.contains(CLUSTER_SECRET),
2189            "malformed-HSTS warning leaked cluster id {CLUSTER_SECRET}"
2190        );
2191        assert!(
2192            output.contains(&format!("cluster_id_bytes=Some({cluster_id_len})")),
2193            "malformed-HSTS warning omitted the bounded cluster id length: {output}"
2194        );
2195        assert!(
2196            output.len() <= 768,
2197            "malformed-HSTS warning is not bounded: {} bytes",
2198            output.len()
2199        );
2200    }
2201
2202    #[test]
2203    fn router_errors_redact_frontend_rule_and_rewrite_fields() {
2204        const PATH_SECRET: &str = "ROUTER_ERROR_PATH_SECRET_SENTINEL";
2205        const HOSTNAME_SECRET: &str = "ROUTER_ERROR_HOSTNAME_SECRET_SENTINEL";
2206        const HOST_REWRITE_SECRET: &str = "ROUTER_ERROR_HOST_REWRITE_SECRET_SENTINEL";
2207        const PATH_REWRITE_SECRET: &str = "ROUTER_ERROR_PATH_REWRITE_SECRET_SENTINEL";
2208
2209        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
2210        let cases = [
2211            (
2212                "path_bytes",
2213                PATH_SECRET,
2214                long_value(PATH_SECRET).len(),
2215                RouterError::InvalidPathRule(long_value(PATH_SECRET)),
2216            ),
2217            (
2218                "hostname_bytes",
2219                HOSTNAME_SECRET,
2220                long_value(HOSTNAME_SECRET).len(),
2221                RouterError::InvalidDomain {
2222                    hostname: long_value(HOSTNAME_SECRET),
2223                },
2224            ),
2225            (
2226                "rewrite_host_bytes",
2227                HOST_REWRITE_SECRET,
2228                long_value(HOST_REWRITE_SECRET).len(),
2229                RouterError::InvalidHostRewrite(long_value(HOST_REWRITE_SECRET)),
2230            ),
2231            (
2232                "rewrite_path_bytes",
2233                PATH_REWRITE_SECRET,
2234                long_value(PATH_REWRITE_SECRET).len(),
2235                RouterError::InvalidPathRewrite(long_value(PATH_REWRITE_SECRET)),
2236            ),
2237        ];
2238
2239        for (length_label, secret, value_len, error) in cases {
2240            for (format_label, output) in [
2241                ("Display", error.to_string()),
2242                ("Debug", format!("{error:?}")),
2243            ] {
2244                assert!(
2245                    !output.contains(secret),
2246                    "RouterError {format_label} leaked frontend marker {secret}"
2247                );
2248                let metadata = format!("{length_label}={value_len}");
2249                assert!(
2250                    output.contains(&metadata),
2251                    "RouterError {format_label} omitted bounded metadata {metadata}: {output}"
2252                );
2253                assert!(
2254                    output.len() <= 256,
2255                    "RouterError {format_label} output is not bounded: {} bytes",
2256                    output.len()
2257                );
2258            }
2259        }
2260    }
2261
2262    #[test]
2263    fn route_miss_error_retains_inputs_but_bounds_textual_formatting() {
2264        const HOST_SECRET: &str = "ROUTE_MISS_HOST_SECRET_SENTINEL";
2265        const PATH_SECRET: &str = "ROUTE_MISS_PATH_SECRET_SENTINEL";
2266        const METHOD_SECRET: &str = "ROUTE_MISS_METHOD_SECRET_SENTINEL";
2267
2268        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
2269        let host = long_value(HOST_SECRET);
2270        let path = long_value(PATH_SECRET);
2271        let method = Method::Custom(long_value(METHOD_SECRET));
2272        let error = match Router::new().lookup(&host, &path, &method) {
2273            Err(error) => error,
2274            Ok(_) => panic!("empty router must return a route miss"),
2275        };
2276
2277        match &error {
2278            RouterError::RouteNotFound {
2279                host: retained_host,
2280                path: retained_path,
2281                method: retained_method,
2282            } => {
2283                assert_eq!(retained_host, &host);
2284                assert_eq!(retained_path, &path);
2285                assert_eq!(retained_method, &method);
2286            }
2287            other => panic!("expected RouterError::RouteNotFound, got {other:?}"),
2288        }
2289
2290        for output in [error.to_string(), format!("{error:?}")] {
2291            for secret in [HOST_SECRET, PATH_SECRET, METHOD_SECRET] {
2292                assert!(
2293                    !output.contains(secret),
2294                    "route miss formatting leaked {secret}: {output}"
2295                );
2296            }
2297            for metadata in [
2298                format!("host_bytes={}", host.len()),
2299                format!("path_bytes={}", path.len()),
2300                format!("bytes={}", method.as_ref().len()),
2301            ] {
2302                assert!(
2303                    output.contains(&metadata),
2304                    "route miss formatting omitted {metadata}: {output}"
2305                );
2306            }
2307            assert!(
2308                output.len() <= 256,
2309                "route miss formatting is not bounded: {} bytes",
2310                output.len()
2311            );
2312        }
2313    }
2314
2315    #[test]
2316    fn render_hsts_max_age_only() {
2317        let cfg = HstsConfig {
2318            enabled: Some(true),
2319            max_age: Some(31_536_000),
2320            include_subdomains: None,
2321            preload: None,
2322            force_replace_backend: None,
2323        };
2324        assert_eq!(render_hsts(&cfg), Some("max-age=31536000".to_owned()));
2325    }
2326
2327    #[test]
2328    fn render_hsts_with_include_subdomains() {
2329        let cfg = HstsConfig {
2330            enabled: Some(true),
2331            max_age: Some(31_536_000),
2332            include_subdomains: Some(true),
2333            preload: None,
2334            force_replace_backend: None,
2335        };
2336        assert_eq!(
2337            render_hsts(&cfg),
2338            Some("max-age=31536000; includeSubDomains".to_owned())
2339        );
2340    }
2341
2342    #[test]
2343    fn render_hsts_with_preload_only() {
2344        let cfg = HstsConfig {
2345            enabled: Some(true),
2346            max_age: Some(63_072_000),
2347            include_subdomains: None,
2348            preload: Some(true),
2349            force_replace_backend: None,
2350        };
2351        assert_eq!(
2352            render_hsts(&cfg),
2353            Some("max-age=63072000; preload".to_owned())
2354        );
2355    }
2356
2357    #[test]
2358    fn render_hsts_full() {
2359        let cfg = HstsConfig {
2360            enabled: Some(true),
2361            max_age: Some(31_536_000),
2362            include_subdomains: Some(true),
2363            preload: Some(true),
2364            force_replace_backend: None,
2365        };
2366        assert_eq!(
2367            render_hsts(&cfg),
2368            Some("max-age=31536000; includeSubDomains; preload".to_owned())
2369        );
2370    }
2371
2372    #[test]
2373    fn render_hsts_kill_switch_max_age_zero() {
2374        let cfg = HstsConfig {
2375            enabled: Some(true),
2376            max_age: Some(0),
2377            include_subdomains: Some(true),
2378            preload: None,
2379            force_replace_backend: None,
2380        };
2381        // `max_age = 0` is the RFC 6797 §11.4 kill switch and renders
2382        // verbatim — UA receives it and stops treating the host as a
2383        // Known HSTS Host.
2384        assert_eq!(
2385            render_hsts(&cfg),
2386            Some("max-age=0; includeSubDomains".to_owned())
2387        );
2388    }
2389
2390    #[test]
2391    fn render_hsts_omitted_when_max_age_missing() {
2392        let cfg = HstsConfig {
2393            enabled: Some(true),
2394            max_age: None,
2395            include_subdomains: Some(true),
2396            preload: None,
2397            force_replace_backend: None,
2398        };
2399        // The TOML loader substitutes the default at config-load; if the
2400        // field reaches `render_hsts` as `None`, suppress emission so a
2401        // malformed wire frame can't accidentally render `max-age=`.
2402        assert_eq!(render_hsts(&cfg), None);
2403    }
2404
2405    #[test]
2406    fn rebuild_with_listener_hsts_replaces_existing_entry() {
2407        // An inheriting frontend whose listener-default HSTS changed
2408        // from 1y → 2y must end up with the 2y entry on its
2409        // headers_response, with no leftover 1y entry.
2410        let frontend = Frontend {
2411            cluster_id: Some("api".to_owned()),
2412            redirect: RedirectPolicy::Forward,
2413            redirect_scheme: RedirectScheme::UseSame,
2414            redirect_template: None,
2415            capture_cap_host: 0,
2416            capture_cap_path: 0,
2417            rewrite_host: None,
2418            rewrite_path: None,
2419            rewrite_port: None,
2420            headers_request: Rc::new([]),
2421            headers_response: Rc::from(vec![
2422                HeaderEdit {
2423                    key: Rc::from(&b"x-cache"[..]),
2424                    val: Rc::from(&b"hit"[..]),
2425                    mode: HeaderEditMode::Append,
2426                },
2427                HeaderEdit {
2428                    key: Rc::from(&b"strict-transport-security"[..]),
2429                    val: Rc::from(&b"max-age=31536000"[..]),
2430                    mode: HeaderEditMode::SetIfAbsent,
2431                },
2432            ]),
2433            required_auth: false,
2434            tags: None,
2435            inherits_listener_hsts: true,
2436        };
2437        let new_hsts = HstsConfig {
2438            enabled: Some(true),
2439            max_age: Some(63_072_000),
2440            include_subdomains: Some(true),
2441            preload: None,
2442            force_replace_backend: None,
2443        };
2444        let new_edit = build_listener_hsts_edit(Some(&new_hsts));
2445        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
2446
2447        let response: Vec<_> = rebuilt.headers_response.iter().collect();
2448        assert_eq!(response.len(), 2, "x-cache + new STS, no leftover STS");
2449        assert_eq!(&*response[0].key, b"x-cache");
2450        assert_eq!(&*response[1].key, b"strict-transport-security");
2451        assert_eq!(
2452            &*response[1].val,
2453            b"max-age=63072000; includeSubDomains".as_slice()
2454        );
2455        assert!(rebuilt.inherits_listener_hsts);
2456    }
2457
2458    #[test]
2459    fn rebuild_with_listener_hsts_strips_when_none() {
2460        // Listener-default HSTS removed → strip the existing STS edit
2461        // and add nothing. Operator response headers stay in place.
2462        let frontend = Frontend {
2463            cluster_id: Some("api".to_owned()),
2464            redirect: RedirectPolicy::Forward,
2465            redirect_scheme: RedirectScheme::UseSame,
2466            redirect_template: None,
2467            capture_cap_host: 0,
2468            capture_cap_path: 0,
2469            rewrite_host: None,
2470            rewrite_path: None,
2471            rewrite_port: None,
2472            headers_request: Rc::new([]),
2473            headers_response: Rc::from(vec![
2474                HeaderEdit {
2475                    key: Rc::from(&b"x-cache"[..]),
2476                    val: Rc::from(&b"hit"[..]),
2477                    mode: HeaderEditMode::Append,
2478                },
2479                HeaderEdit {
2480                    key: Rc::from(&b"strict-transport-security"[..]),
2481                    val: Rc::from(&b"max-age=31536000"[..]),
2482                    mode: HeaderEditMode::SetIfAbsent,
2483                },
2484            ]),
2485            required_auth: false,
2486            tags: None,
2487            inherits_listener_hsts: true,
2488        };
2489        let new_edit = build_listener_hsts_edit(None);
2490        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
2491        let response: Vec<_> = rebuilt.headers_response.iter().collect();
2492        assert_eq!(response.len(), 1);
2493        assert_eq!(&*response[0].key, b"x-cache");
2494    }
2495
2496    #[test]
2497    fn rebuild_with_listener_hsts_disabled_strips() {
2498        // `enabled = Some(false)` is the explicit-disable signal; the
2499        // existing STS entry is dropped and no new one is added.
2500        let frontend = Frontend {
2501            cluster_id: Some("api".to_owned()),
2502            redirect: RedirectPolicy::Forward,
2503            redirect_scheme: RedirectScheme::UseSame,
2504            redirect_template: None,
2505            capture_cap_host: 0,
2506            capture_cap_path: 0,
2507            rewrite_host: None,
2508            rewrite_path: None,
2509            rewrite_port: None,
2510            headers_request: Rc::new([]),
2511            headers_response: Rc::from(vec![HeaderEdit {
2512                key: Rc::from(&b"strict-transport-security"[..]),
2513                val: Rc::from(&b"max-age=31536000"[..]),
2514                mode: HeaderEditMode::SetIfAbsent,
2515            }]),
2516            required_auth: false,
2517            tags: None,
2518            inherits_listener_hsts: true,
2519        };
2520        let new_hsts = HstsConfig {
2521            enabled: Some(false),
2522            max_age: None,
2523            include_subdomains: None,
2524            preload: None,
2525            force_replace_backend: None,
2526        };
2527        let new_edit = build_listener_hsts_edit(Some(&new_hsts));
2528        let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
2529        assert_eq!(rebuilt.headers_response.len(), 0);
2530    }
2531
2532    #[test]
2533    fn refresh_inheriting_hsts_skips_explicit_overrides() {
2534        // Two frontends: one inheriting (gets refreshed), one explicit
2535        // override (must NOT change). `Router::refresh_inheriting_hsts`
2536        // returns the count of refreshed entries.
2537        use crate::router::pattern_trie::TrieNode;
2538        let mut router = Router {
2539            pre: Vec::new(),
2540            tree: TrieNode::root(),
2541            post: Vec::new(),
2542        };
2543        let inheriting = Frontend {
2544            cluster_id: Some("api".to_owned()),
2545            redirect: RedirectPolicy::Forward,
2546            redirect_scheme: RedirectScheme::UseSame,
2547            redirect_template: None,
2548            capture_cap_host: 0,
2549            capture_cap_path: 0,
2550            rewrite_host: None,
2551            rewrite_path: None,
2552            rewrite_port: None,
2553            headers_request: Rc::new([]),
2554            headers_response: Rc::from(vec![HeaderEdit {
2555                key: Rc::from(&b"strict-transport-security"[..]),
2556                val: Rc::from(&b"max-age=31536000"[..]),
2557                mode: HeaderEditMode::SetIfAbsent,
2558            }]),
2559            required_auth: false,
2560            tags: None,
2561            inherits_listener_hsts: true,
2562        };
2563        let explicit = Frontend {
2564            cluster_id: Some("legacy".to_owned()),
2565            redirect: RedirectPolicy::Forward,
2566            redirect_scheme: RedirectScheme::UseSame,
2567            redirect_template: None,
2568            capture_cap_host: 0,
2569            capture_cap_path: 0,
2570            rewrite_host: None,
2571            rewrite_path: None,
2572            rewrite_port: None,
2573            headers_request: Rc::new([]),
2574            headers_response: Rc::from(vec![HeaderEdit {
2575                key: Rc::from(&b"strict-transport-security"[..]),
2576                val: Rc::from(&b"max-age=300"[..]),
2577                mode: HeaderEditMode::SetIfAbsent,
2578            }]),
2579            required_auth: false,
2580            tags: None,
2581            inherits_listener_hsts: false,
2582        };
2583        router.pre.push((
2584            DomainRule::Any,
2585            PathRule::Prefix("/api".to_owned()),
2586            MethodRule::new(None),
2587            Route::Frontend(Rc::new(inheriting)),
2588        ));
2589        router.post.push((
2590            DomainRule::Any,
2591            PathRule::Prefix("/legacy".to_owned()),
2592            MethodRule::new(None),
2593            Route::Frontend(Rc::new(explicit)),
2594        ));
2595
2596        let new_hsts = HstsConfig {
2597            enabled: Some(true),
2598            max_age: Some(63_072_000),
2599            include_subdomains: Some(true),
2600            preload: None,
2601            force_replace_backend: None,
2602        };
2603        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
2604        assert_eq!(count, 1, "only the inheriting frontend should refresh");
2605
2606        if let Route::Frontend(rc) = &router.pre[0].3 {
2607            let response: Vec<_> = rc.headers_response.iter().collect();
2608            assert_eq!(
2609                &*response.last().unwrap().val,
2610                b"max-age=63072000; includeSubDomains".as_slice(),
2611                "inheriting frontend's STS must reflect the new listener default"
2612            );
2613        } else {
2614            panic!("pre[0] should be Route::Frontend");
2615        }
2616        if let Route::Frontend(rc) = &router.post[0].3 {
2617            let response: Vec<_> = rc.headers_response.iter().collect();
2618            assert_eq!(
2619                &*response.last().unwrap().val,
2620                b"max-age=300".as_slice(),
2621                "explicit override must be preserved unchanged"
2622            );
2623        } else {
2624            panic!("post[0] should be Route::Frontend");
2625        }
2626    }
2627
2628    #[test]
2629    fn refresh_inheriting_hsts_promotes_clusterid_on_enable() {
2630        // The "no policy" frontend case observed on cleverapps.io shared
2631        // (91k+ frontends, 99 % stored as `Route::ClusterId` with no
2632        // policy fields). Before the fix, `refresh_inheriting_hsts`
2633        // walked only `Route::Frontend` entries and silently skipped
2634        // these — leaving HSTS unapplied across the entire fleet.
2635        // Now the lightweight route is promoted in place to a
2636        // `Route::Frontend` carrying just the HSTS edit; subsequent
2637        // patches refresh the promoted entry through the normal
2638        // `inherits_listener_hsts == true` path.
2639        use crate::router::pattern_trie::TrieNode;
2640        let mut router = Router {
2641            pre: Vec::new(),
2642            tree: TrieNode::root(),
2643            post: vec![(
2644                DomainRule::Any,
2645                PathRule::Prefix("/".to_owned()),
2646                MethodRule::new(None),
2647                Route::ClusterId("api".to_owned()),
2648            )],
2649        };
2650
2651        let new_hsts = HstsConfig {
2652            enabled: Some(true),
2653            max_age: Some(31_536_000),
2654            include_subdomains: Some(true),
2655            preload: None,
2656            force_replace_backend: None,
2657        };
2658        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
2659        assert_eq!(count, 1, "the ClusterId entry must be promoted + counted");
2660
2661        let Route::Frontend(rc) = &router.post[0].3 else {
2662            panic!("post[0] should now be Route::Frontend, not the original Route::ClusterId");
2663        };
2664        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
2665        assert_eq!(
2666            rc.redirect,
2667            RedirectPolicy::Forward,
2668            "promoted entry must keep Forward semantics so lookup yields the same backend"
2669        );
2670        assert!(
2671            rc.inherits_listener_hsts,
2672            "promoted entry must mark itself inheriting so the next patch refreshes it"
2673        );
2674        let response: Vec<_> = rc.headers_response.iter().collect();
2675        assert_eq!(
2676            response.len(),
2677            1,
2678            "promoted entry carries exactly one STS edit, no operator headers"
2679        );
2680        assert_eq!(&*response[0].key, b"strict-transport-security");
2681        assert_eq!(
2682            &*response[0].val,
2683            b"max-age=31536000; includeSubDomains".as_slice()
2684        );
2685    }
2686
2687    #[test]
2688    fn refresh_inheriting_hsts_promotes_deny_on_enable() {
2689        // RFC 6797 §8.1: HSTS applies to ALL HTTPS responses, including
2690        // proxy-generated 401s. A `Route::Deny` with no policy field at
2691        // add-time would, before the fix, never get HSTS injected onto
2692        // the 401 default answer even when the listener default
2693        // declared HSTS.
2694        use crate::router::pattern_trie::TrieNode;
2695        let mut router = Router {
2696            pre: Vec::new(),
2697            tree: TrieNode::root(),
2698            post: vec![(
2699                DomainRule::Any,
2700                PathRule::Prefix("/forbidden".to_owned()),
2701                MethodRule::new(None),
2702                Route::Deny,
2703            )],
2704        };
2705
2706        let new_hsts = HstsConfig {
2707            enabled: Some(true),
2708            max_age: Some(31_536_000),
2709            include_subdomains: None,
2710            preload: None,
2711            force_replace_backend: None,
2712        };
2713        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
2714        assert_eq!(count, 1);
2715
2716        let Route::Frontend(rc) = &router.post[0].3 else {
2717            panic!("post[0] should now be Route::Frontend, not the original Route::Deny");
2718        };
2719        assert_eq!(rc.cluster_id, None, "promoted Deny stays clusterless");
2720        assert_eq!(
2721            rc.redirect,
2722            RedirectPolicy::Unauthorized,
2723            "promoted Deny must keep Unauthorized so lookup yields a 401"
2724        );
2725        assert!(rc.inherits_listener_hsts);
2726        let response: Vec<_> = rc.headers_response.iter().collect();
2727        assert_eq!(response.len(), 1);
2728        assert_eq!(&*response[0].key, b"strict-transport-security");
2729        assert_eq!(&*response[0].val, b"max-age=31536000".as_slice());
2730    }
2731
2732    #[test]
2733    fn refresh_inheriting_hsts_skips_lightweight_on_disable() {
2734        // No HSTS to emit → no allocation of a Route::Frontend just to
2735        // hold an empty headers_response. The lightweight route is
2736        // preserved as-is. Three sub-cases cover the disable surface:
2737        //   - new_hsts == None (operator omitted the field — preserve current… but
2738        //     this function is called only when the patch DID carry a value, so
2739        //     None here represents "block was not present"; lightweight stays).
2740        //   - new_hsts == Some(enabled = Some(false)) (explicit kill switch).
2741        //   - new_hsts == Some(enabled = Some(true), max_age = None) (malformed
2742        //     enable — render_hsts returns None, defense-in-depth gate).
2743        use crate::router::pattern_trie::TrieNode;
2744        let make_router = || Router {
2745            pre: vec![(
2746                DomainRule::Any,
2747                PathRule::Prefix("/".to_owned()),
2748                MethodRule::new(None),
2749                Route::ClusterId("api".to_owned()),
2750            )],
2751            tree: TrieNode::root(),
2752            post: vec![(
2753                DomainRule::Any,
2754                PathRule::Prefix("/forbidden".to_owned()),
2755                MethodRule::new(None),
2756                Route::Deny,
2757            )],
2758        };
2759
2760        for (label, hsts) in [
2761            ("none", None),
2762            (
2763                "disabled",
2764                Some(HstsConfig {
2765                    enabled: Some(false),
2766                    max_age: None,
2767                    include_subdomains: None,
2768                    preload: None,
2769                    force_replace_backend: None,
2770                }),
2771            ),
2772            (
2773                "enabled-without-max-age",
2774                Some(HstsConfig {
2775                    enabled: Some(true),
2776                    max_age: None,
2777                    include_subdomains: None,
2778                    preload: None,
2779                    force_replace_backend: None,
2780                }),
2781            ),
2782        ] {
2783            let mut router = make_router();
2784            let count = router.refresh_inheriting_hsts(hsts.as_ref());
2785            assert_eq!(count, 0, "no promotion expected for {label}");
2786            assert!(
2787                matches!(router.pre[0].3, Route::ClusterId(_)),
2788                "{label}: ClusterId must stay lightweight"
2789            );
2790            assert!(
2791                matches!(router.post[0].3, Route::Deny),
2792                "{label}: Deny must stay lightweight"
2793            );
2794        }
2795    }
2796
2797    #[test]
2798    fn refresh_inheriting_hsts_promoted_entry_refreshes_on_subsequent_patches() {
2799        // First patch promotes ClusterId → Route::Frontend with HSTS.
2800        // Second patch with a different max-age must refresh the
2801        // promoted entry through the normal path-1 branch (no double
2802        // STS edit, no second promotion of an already-promoted entry).
2803        use crate::router::pattern_trie::TrieNode;
2804        let mut router = Router {
2805            pre: Vec::new(),
2806            tree: TrieNode::root(),
2807            post: vec![(
2808                DomainRule::Any,
2809                PathRule::Prefix("/".to_owned()),
2810                MethodRule::new(None),
2811                Route::ClusterId("api".to_owned()),
2812            )],
2813        };
2814
2815        let first_patch = HstsConfig {
2816            enabled: Some(true),
2817            max_age: Some(31_536_000),
2818            include_subdomains: None,
2819            preload: None,
2820            force_replace_backend: None,
2821        };
2822        assert_eq!(router.refresh_inheriting_hsts(Some(&first_patch)), 1);
2823
2824        let second_patch = HstsConfig {
2825            enabled: Some(true),
2826            max_age: Some(63_072_000),
2827            include_subdomains: Some(true),
2828            preload: None,
2829            force_replace_backend: None,
2830        };
2831        assert_eq!(
2832            router.refresh_inheriting_hsts(Some(&second_patch)),
2833            1,
2834            "the previously promoted entry must be re-counted via the path-1 branch"
2835        );
2836
2837        let Route::Frontend(rc) = &router.post[0].3 else {
2838            panic!("post[0] should still be Route::Frontend after the second patch");
2839        };
2840        let response: Vec<_> = rc.headers_response.iter().collect();
2841        assert_eq!(
2842            response.len(),
2843            1,
2844            "second patch must REPLACE the existing STS edit, not append a duplicate"
2845        );
2846        assert_eq!(
2847            &*response[0].val,
2848            b"max-age=63072000; includeSubDomains".as_slice()
2849        );
2850    }
2851
2852    #[test]
2853    fn refresh_inheriting_hsts_promoted_entry_loses_hsts_on_disable_patch() {
2854        // After a promotion, a disable patch must strip the STS edit
2855        // through the path-1 branch. The Route::Frontend wrapper stays
2856        // (we don't demote back to Route::ClusterId — the small per-
2857        // request overhead of running through `from_frontend` instead
2858        // of the short-circuit is acceptable, and demotion would
2859        // require carrying additional state).
2860        use crate::router::pattern_trie::TrieNode;
2861        let mut router = Router {
2862            pre: vec![(
2863                DomainRule::Any,
2864                PathRule::Prefix("/".to_owned()),
2865                MethodRule::new(None),
2866                Route::ClusterId("api".to_owned()),
2867            )],
2868            tree: TrieNode::root(),
2869            post: Vec::new(),
2870        };
2871
2872        let enable = HstsConfig {
2873            enabled: Some(true),
2874            max_age: Some(31_536_000),
2875            include_subdomains: None,
2876            preload: None,
2877            force_replace_backend: None,
2878        };
2879        assert_eq!(router.refresh_inheriting_hsts(Some(&enable)), 1);
2880
2881        let disable = HstsConfig {
2882            enabled: Some(false),
2883            max_age: None,
2884            include_subdomains: None,
2885            preload: None,
2886            force_replace_backend: None,
2887        };
2888        assert_eq!(
2889            router.refresh_inheriting_hsts(Some(&disable)),
2890            1,
2891            "the promoted entry must still be touched on disable to strip its STS edit"
2892        );
2893
2894        let Route::Frontend(rc) = &router.pre[0].3 else {
2895            panic!("pre[0] should still be Route::Frontend (no demotion)");
2896        };
2897        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
2898        assert_eq!(
2899            rc.headers_response.len(),
2900            0,
2901            "disable patch must strip the STS edit from the promoted entry"
2902        );
2903    }
2904
2905    #[test]
2906    fn refresh_inheriting_hsts_promotes_clusterid_in_trie_on_enable() {
2907        // Trie-leaf coverage for path 2: the existing five tests
2908        // exercise the `pre`/`post` Vecs only, but the same `visit`
2909        // closure runs for tree leaves through
2910        // `tree.for_each_value_mut`. Assert that a `Route::ClusterId`
2911        // sitting in the trie is promoted in place on an enable
2912        // patch, with routing semantics preserved.
2913        use crate::router::pattern_trie::TrieNode;
2914        let mut router = Router {
2915            pre: Vec::new(),
2916            tree: TrieNode::root(),
2917            post: Vec::new(),
2918        };
2919        let path_rule = PathRule::Prefix("/".to_owned());
2920        let method_rule = MethodRule::new(None);
2921        assert!(router.add_tree_rule(
2922            b"example.com",
2923            &path_rule,
2924            &method_rule,
2925            &Route::ClusterId("api".to_owned()),
2926        ));
2927
2928        let new_hsts = HstsConfig {
2929            enabled: Some(true),
2930            max_age: Some(31_536_000),
2931            include_subdomains: Some(true),
2932            preload: None,
2933            force_replace_backend: None,
2934        };
2935        let count = router.refresh_inheriting_hsts(Some(&new_hsts));
2936        assert_eq!(
2937            count, 1,
2938            "trie-resident ClusterId must be promoted + counted"
2939        );
2940
2941        let (_, paths) = router
2942            .tree
2943            .domain_lookup_mut(b"example.com", false)
2944            .expect("trie leaf still present after refresh");
2945        assert_eq!(paths.len(), 1);
2946        let Route::Frontend(rc) = &paths[0].2 else {
2947            panic!("trie leaf should now be Route::Frontend, not Route::ClusterId");
2948        };
2949        assert_eq!(rc.cluster_id.as_deref(), Some("api"));
2950        assert_eq!(rc.redirect, RedirectPolicy::Forward);
2951        assert!(rc.inherits_listener_hsts);
2952        let response: Vec<_> = rc.headers_response.iter().collect();
2953        assert_eq!(response.len(), 1);
2954        assert_eq!(&*response[0].key, b"strict-transport-security");
2955        assert_eq!(
2956            &*response[0].val,
2957            b"max-age=31536000; includeSubDomains".as_slice()
2958        );
2959    }
2960
2961    #[test]
2962    fn convert_regex() {
2963        // Compiled regexes are anchored with `\A` … `\z` so `Regex::is_match`
2964        // (unanchored by default) only succeeds on a full-host match.
2965        assert_eq!(
2966            convert_regex_domain_rule("www.example.com")
2967                .unwrap()
2968                .as_str(),
2969            "\\Awww\\.example\\.com\\z"
2970        );
2971        assert_eq!(
2972            convert_regex_domain_rule("*.example.com").unwrap().as_str(),
2973            "\\A*\\.example\\.com\\z"
2974        );
2975        assert_eq!(
2976            convert_regex_domain_rule("test.*.example.com")
2977                .unwrap()
2978                .as_str(),
2979            "\\Atest\\.*\\.example\\.com\\z"
2980        );
2981        assert_eq!(
2982            convert_regex_domain_rule("css./cdn[a-z0-9]+/.example.com")
2983                .unwrap()
2984                .as_str(),
2985            "\\Acss\\.cdn[a-z0-9]+\\.example\\.com\\z"
2986        );
2987
2988        assert_eq!(
2989            convert_regex_domain_rule("css./cdn[a-z0-9]+.example.com"),
2990            None
2991        );
2992        assert_eq!(
2993            convert_regex_domain_rule("css./cdn[a-z0-9]+/a.example.com"),
2994            None
2995        );
2996    }
2997
2998    /// Compiled regex rules must reject suffix / prefix matches. Without
2999    /// `\A` / `\z` anchors, `Regex::is_match` treats the pattern as
3000    /// "match anywhere in the haystack", letting `attacker.example.com.evil.org`
3001    /// reach a frontend that only intends to serve `example.com`.
3002    #[test]
3003    fn regex_domain_rule_rejects_suffix_and_prefix() {
3004        let rule: DomainRule = "/example\\.com/".parse().unwrap();
3005        assert!(rule.matches(b"example.com"));
3006        assert!(!rule.matches(b"attacker.example.com"));
3007        assert!(!rule.matches(b"example.com.evil.org"));
3008        assert!(!rule.matches(b"prefixexample.com"));
3009        assert!(!rule.matches(b"example.commercial"));
3010    }
3011
3012    /// A multi-segment regex hostname (alternating regex and literal
3013    /// subdomains) must keep each segment confined: only the first `/`
3014    /// after the opening delimiter closes a regex segment. A missing
3015    /// `break` collapsed every later `/` into the same segment, swallowing
3016    /// the literal `.` separators between them.
3017    #[test]
3018    fn regex_domain_rule_multi_segment_segments_are_isolated() {
3019        let pattern = convert_regex_domain_rule("/seg1/.foo./seg2/.com")
3020            .expect("multi-segment regex hostname must compile");
3021        assert_eq!(pattern.as_str(), "\\Aseg1\\.foo\\.seg2\\.com\\z");
3022    }
3023
3024    #[test]
3025    fn parse_domain_rule() {
3026        assert_eq!("*".parse::<DomainRule>().unwrap(), DomainRule::Any);
3027        assert_eq!(
3028            "www.example.com".parse::<DomainRule>().unwrap(),
3029            DomainRule::Exact("www.example.com".to_string())
3030        );
3031        assert_eq!(
3032            "*.example.com".parse::<DomainRule>().unwrap(),
3033            DomainRule::Wildcard("*.example.com".to_string())
3034        );
3035        assert_eq!("test.*.example.com".parse::<DomainRule>(), Err(()));
3036        assert_eq!(
3037            "/cdn[0-9]+/.example.com".parse::<DomainRule>().unwrap(),
3038            DomainRule::Regex(Regex::new("\\Acdn[0-9]+\\.example\\.com\\z").unwrap())
3039        );
3040    }
3041
3042    #[test]
3043    fn match_domain_rule() {
3044        assert!(DomainRule::Any.matches("www.example.com".as_bytes()));
3045        assert!(
3046            DomainRule::Exact("www.example.com".to_string()).matches("www.example.com".as_bytes())
3047        );
3048        assert!(
3049            DomainRule::Wildcard("*.example.com".to_string()).matches("www.example.com".as_bytes())
3050        );
3051        assert!(
3052            !DomainRule::Wildcard("*.example.com".to_string())
3053                .matches("test.www.example.com".as_bytes())
3054        );
3055        assert!(
3056            "/cdn[0-9]+/.example.com"
3057                .parse::<DomainRule>()
3058                .unwrap()
3059                .matches("cdn1.example.com".as_bytes())
3060        );
3061        assert!(
3062            !"/cdn[0-9]+/.example.com"
3063                .parse::<DomainRule>()
3064                .unwrap()
3065                .matches("www.example.com".as_bytes())
3066        );
3067        assert!(
3068            !"/cdn[0-9]+/.example.com"
3069                .parse::<DomainRule>()
3070                .unwrap()
3071                .matches("cdn10.exampleAcom".as_bytes())
3072        );
3073    }
3074
3075    #[test]
3076    fn match_domain_rule_wildcard_short_hostname_does_not_panic() {
3077        let rule = DomainRule::Wildcard("*.foo.example.com".to_string());
3078
3079        // Regression for issue #1223: an empty hostname must not panic and must not match.
3080        assert!(!rule.matches(b""));
3081
3082        // Hostname strictly shorter than the suffix must not panic and must not match.
3083        assert!(!rule.matches(b"a.b"));
3084        assert!(!rule.matches(b"x"));
3085
3086        // Boundary: hostname equal to the suffix (s without the leading '*') has an empty
3087        // leftmost label. RFC 1035 §3.1 forbids empty labels, so reject.
3088        assert!(!rule.matches(b".foo.example.com"));
3089
3090        // Multi-label leftmost prefix (existing intent — single-label only).
3091        assert!(!rule.matches(b"y.x.foo.example.com"));
3092
3093        // Single-label leftmost prefix — must still match (this is the happy case the
3094        // pre-existing match_domain_rule already covers, repeated here for symmetry).
3095        assert!(rule.matches(b"x.foo.example.com"));
3096    }
3097
3098    #[test]
3099    fn router_lookup_wildcard_pre_rule_short_hostname_does_not_panic() {
3100        let mut router = Router::new();
3101
3102        // Wildcard in a pre-rule routes through DomainRule::Wildcard::matches
3103        // (not the trie). This is the path that panicked on issue #1223.
3104        assert!(router.add_pre_rule(
3105            &"*.foo.example.com".parse::<DomainRule>().unwrap(),
3106            &PathRule::Prefix("/".to_string()),
3107            &MethodRule::new(Some("GET".to_string())),
3108            &Route::ClusterId("wildcard".to_string()),
3109        ));
3110
3111        let method = Method::new(&b"GET"[..]);
3112
3113        // Issue #1223: short hostnames must not panic Router::lookup.
3114        assert!(router.lookup("", "/", &method).is_err());
3115        assert!(router.lookup("x", "/", &method).is_err());
3116        assert!(router.lookup("a.b", "/", &method).is_err());
3117
3118        // Boundary: hostname equal to the suffix has an empty leftmost label.
3119        assert!(router.lookup(".foo.example.com", "/", &method).is_err());
3120
3121        // Happy case: single-label leftmost matches via the pre-rule path.
3122        assert_eq!(
3123            router.lookup("x.foo.example.com", "/", &method),
3124            Ok(RouteResult::forward("wildcard".to_string()))
3125        );
3126    }
3127
3128    #[test]
3129    fn match_path_rule() {
3130        assert!(PathRule::Prefix("".to_string()).matches("/".as_bytes()) != PathRuleResult::None);
3131        assert!(
3132            PathRule::Prefix("".to_string()).matches("/hello".as_bytes()) != PathRuleResult::None
3133        );
3134        assert!(
3135            PathRule::Prefix("/hello".to_string()).matches("/hello".as_bytes())
3136                != PathRuleResult::None
3137        );
3138        assert!(
3139            PathRule::Prefix("/hello".to_string()).matches("/hello/world".as_bytes())
3140                != PathRuleResult::None
3141        );
3142        assert!(
3143            PathRule::Prefix("/hello".to_string()).matches("/".as_bytes()) == PathRuleResult::None
3144        );
3145    }
3146
3147    ///  [io]
3148    ///      \
3149    ///       [sozu]
3150    ///             \
3151    ///              [*]  <- this wildcard has multiple children
3152    ///             /   \
3153    ///         (base) (api)
3154    #[test]
3155    fn multiple_children_on_a_wildcard() {
3156        let mut router = Router::new();
3157
3158        assert!(router.add_tree_rule(
3159            b"*.sozu.io",
3160            &PathRule::Prefix("".to_string()),
3161            &MethodRule::new(Some("GET".to_string())),
3162            &Route::ClusterId("base".to_string())
3163        ));
3164        println!("{:#?}", router.tree);
3165        assert_eq!(
3166            router.lookup("www.sozu.io", "/api", &Method::Get),
3167            Ok(RouteResult::forward("base".to_string()))
3168        );
3169        assert!(router.add_tree_rule(
3170            b"*.sozu.io",
3171            &PathRule::Prefix("/api".to_string()),
3172            &MethodRule::new(Some("GET".to_string())),
3173            &Route::ClusterId("api".to_string())
3174        ));
3175        println!("{:#?}", router.tree);
3176        assert_eq!(
3177            router.lookup("www.sozu.io", "/ap", &Method::Get),
3178            Ok(RouteResult::forward("base".to_string()))
3179        );
3180        assert_eq!(
3181            router.lookup("www.sozu.io", "/api", &Method::Get),
3182            Ok(RouteResult::forward("api".to_string()))
3183        );
3184    }
3185
3186    ///  [io]
3187    ///      \
3188    ///       [sozu]  <- this node has multiple children including a wildcard
3189    ///      /      \
3190    ///   (api)      [*]  <- this wildcard has multiple children
3191    ///                 \
3192    ///                (base)
3193    #[test]
3194    fn multiple_children_including_one_with_wildcard() {
3195        let mut router = Router::new();
3196
3197        assert!(router.add_tree_rule(
3198            b"*.sozu.io",
3199            &PathRule::Prefix("".to_string()),
3200            &MethodRule::new(Some("GET".to_string())),
3201            &Route::ClusterId("base".to_string())
3202        ));
3203        println!("{:#?}", router.tree);
3204        assert_eq!(
3205            router.lookup("www.sozu.io", "/api", &Method::Get),
3206            Ok(RouteResult::forward("base".to_string()))
3207        );
3208        assert!(router.add_tree_rule(
3209            b"api.sozu.io",
3210            &PathRule::Prefix("".to_string()),
3211            &MethodRule::new(Some("GET".to_string())),
3212            &Route::ClusterId("api".to_string())
3213        ));
3214        println!("{:#?}", router.tree);
3215        assert_eq!(
3216            router.lookup("www.sozu.io", "/api", &Method::Get),
3217            Ok(RouteResult::forward("base".to_string()))
3218        );
3219        assert_eq!(
3220            router.lookup("api.sozu.io", "/api", &Method::Get),
3221            Ok(RouteResult::forward("api".to_string()))
3222        );
3223    }
3224
3225    /// A malformed hostname arriving from the control plane
3226    /// (`AddHttpFrontend` over the command socket, or a `LoadState`
3227    /// replay) must be REJECTED, never panic the worker. `TrieNode::insert`
3228    /// used to `assert_ne!` on `InsertResult::Failed`, so a frontend whose
3229    /// hostname ended in `/` killed every worker the master fanned the
3230    /// request out to -- and killed them again on each restart replay.
3231    #[test]
3232    fn add_tree_rule_rejects_malformed_hostnames_without_panicking() {
3233        // Every shape here reached `InsertResult::Failed` (or an empty
3234        // `partial_key`) inside `insert_recursive`.
3235        for hostname in [
3236            &b"example.com/"[..],
3237            b"www.example.com/",
3238            b"foo/",
3239            b"a/*/",
3240            b"/",
3241            b"///",
3242            b"abc/[0-9]+/.example.com",
3243            b"/[/.example.com",
3244            b".example.com",
3245            b".a.b",
3246            b"..",
3247        ] {
3248            let mut router = Router::new();
3249            assert!(
3250                !router.add_tree_rule(
3251                    hostname,
3252                    &PathRule::Prefix("/".to_string()),
3253                    &MethodRule::new(Some("GET".to_string())),
3254                    &Route::ClusterId("cluster".to_string()),
3255                ),
3256                "{:?} must be rejected, not inserted",
3257                String::from_utf8_lossy(hostname),
3258            );
3259            // A rejected add must leave NO trace: the route table is
3260            // exactly as empty as it was before the attempt.
3261            assert!(
3262                router.tree.is_empty(),
3263                "{:?} was rejected but still mutated the route table",
3264                String::from_utf8_lossy(hostname),
3265            );
3266        }
3267    }
3268
3269    /// The same rejection, one layer up: `add_http_front` must surface a
3270    /// `RouterError::AddRoute` so the worker answers the master with a
3271    /// failure instead of dying.
3272    #[test]
3273    fn add_http_front_surfaces_a_malformed_hostname_as_an_error() {
3274        let mut router = Router::new();
3275        let front = HttpFrontend {
3276            hostname: "example.com/".to_owned(),
3277            ..test_http_frontend()
3278        };
3279        assert!(matches!(
3280            router.add_http_front(&front),
3281            Err(RouterError::AddRoute(_))
3282        ));
3283        assert!(router.tree.is_empty());
3284
3285        // A hostname whose last segment is followed by a bare trailing `.`
3286        // is rejected one layer earlier still, by the unconditional
3287        // `DomainRule` parse -- which used to walk out of bounds on it
3288        // (see `domain_rule_rejects_a_trailing_dot_after_a_regex_segment`).
3289        for hostname in ["/a/.", "a/b/.", "x./y/."] {
3290            let mut router = Router::new();
3291            let front = HttpFrontend {
3292                hostname: (*hostname).to_owned(),
3293                ..test_http_frontend()
3294            };
3295            assert!(
3296                matches!(
3297                    router.add_http_front(&front),
3298                    Err(RouterError::InvalidDomain { .. })
3299                ),
3300                "{hostname:?} must be rejected as an invalid domain, not panic",
3301            );
3302            assert!(router.tree.is_empty());
3303        }
3304    }
3305
3306    /// `convert_regex_domain_rule` used to walk one byte past the end of a
3307    /// hostname whose last segment is followed by a bare trailing `.`
3308    /// (`/a/.`, `a/b/.`, `x./y/.`): the `.` arm of the loop tail advances
3309    /// `index` to `s.len()` and the next iteration indexed `s[index]` out
3310    /// of bounds -- a release panic (slice bounds checks never compile
3311    /// out) reachable from the control plane through the unconditional
3312    /// `DomainRule` parse in `add_http_front`, sidestepping every trie
3313    /// guard.
3314    #[test]
3315    fn domain_rule_rejects_a_trailing_dot_after_a_regex_segment() {
3316        for hostname in ["/a/.", "a/b/.", "/[/.", "x./y/."] {
3317            assert!(
3318                hostname.parse::<DomainRule>().is_err(),
3319                "{hostname:?} must be rejected, not panic",
3320            );
3321        }
3322        // The guard must not disturb the legitimate `.`-anchored
3323        // regex-segment grammar.
3324        assert!("abc./[0-9]+/.example.com".parse::<DomainRule>().is_ok());
3325    }
3326
3327    /// The trie recurses once per label and the regex-segment grammar
3328    /// compiles control-plane-supplied patterns, so both add and remove
3329    /// bound the hostname to [`MAX_HOSTNAME_LENGTH`] before parsing
3330    /// anything: a ~100k-label hostname otherwise aborts the worker with
3331    /// an uncatchable stack overflow, and regex compilation time grows
3332    /// with the pattern (a 2 MiB hostname stalls the single-threaded
3333    /// worker for hundreds of milliseconds before rejection).
3334    #[test]
3335    fn add_http_front_rejects_an_oversized_hostname() {
3336        // At the bound: accepted.
3337        let mut router = Router::new();
3338        let front = HttpFrontend {
3339            hostname: "a".repeat(MAX_HOSTNAME_LENGTH),
3340            ..test_http_frontend()
3341        };
3342        assert!(router.add_http_front(&front).is_ok());
3343
3344        // One byte over: rejected on add AND on remove, before any parse.
3345        let mut router = Router::new();
3346        let front = HttpFrontend {
3347            hostname: "a".repeat(MAX_HOSTNAME_LENGTH + 1),
3348            ..test_http_frontend()
3349        };
3350        assert!(matches!(
3351            router.add_http_front(&front),
3352            Err(RouterError::InvalidDomain { .. })
3353        ));
3354        assert!(matches!(
3355            router.remove_http_front(&front),
3356            Err(RouterError::InvalidDomain { .. })
3357        ));
3358        assert!(router.tree.is_empty());
3359
3360        // A label-count bomb is caught by the same byte bound.
3361        let front = HttpFrontend {
3362            hostname: "a.".repeat(MAX_HOSTNAME_LENGTH),
3363            ..test_http_frontend()
3364        };
3365        assert!(matches!(
3366            router.add_http_front(&front),
3367            Err(RouterError::InvalidDomain { .. })
3368        ));
3369    }
3370
3371    #[test]
3372    fn router_insert_remove_through_regex() {
3373        let mut router = Router::new();
3374
3375        assert!(router.add_tree_rule(
3376            b"www./.*/.io",
3377            &PathRule::Prefix("".to_string()),
3378            &MethodRule::new(Some("GET".to_string())),
3379            &Route::ClusterId("base".to_string())
3380        ));
3381        println!("{:#?}", router.tree);
3382        assert!(router.add_tree_rule(
3383            b"www.doc./.*/.io",
3384            &PathRule::Prefix("".to_string()),
3385            &MethodRule::new(Some("GET".to_string())),
3386            &Route::ClusterId("doc".to_string())
3387        ));
3388        println!("{:#?}", router.tree);
3389        assert_eq!(
3390            router.lookup("www.sozu.io", "/", &Method::Get),
3391            Ok(RouteResult::forward("base".to_string()))
3392        );
3393        assert_eq!(
3394            router.lookup("www.doc.sozu.io", "/", &Method::Get),
3395            Ok(RouteResult::forward("doc".to_string()))
3396        );
3397        assert!(router.remove_tree_rule(
3398            b"www./.*/.io",
3399            &PathRule::Prefix("".to_string()),
3400            &MethodRule::new(Some("GET".to_string()))
3401        ));
3402        println!("{:#?}", router.tree);
3403        assert!(router.lookup("www.sozu.io", "/", &Method::Get).is_err());
3404        assert_eq!(
3405            router.lookup("www.doc.sozu.io", "/", &Method::Get),
3406            Ok(RouteResult::forward("doc".to_string()))
3407        );
3408    }
3409
3410    #[test]
3411    fn match_router() {
3412        let mut router = Router::new();
3413
3414        assert!(router.add_pre_rule(
3415            &"*".parse::<DomainRule>().unwrap(),
3416            &PathRule::Prefix("/.well-known/acme-challenge".to_string()),
3417            &MethodRule::new(Some("GET".to_string())),
3418            &Route::ClusterId("acme".to_string())
3419        ));
3420        assert!(router.add_tree_rule(
3421            "www.example.com".as_bytes(),
3422            &PathRule::Prefix("/".to_string()),
3423            &MethodRule::new(Some("GET".to_string())),
3424            &Route::ClusterId("example".to_string())
3425        ));
3426        assert!(router.add_tree_rule(
3427            "*.test.example.com".as_bytes(),
3428            &PathRule::Regex(Regex::new("/hello[A-Z]+/").unwrap()),
3429            &MethodRule::new(Some("GET".to_string())),
3430            &Route::ClusterId("examplewildcard".to_string())
3431        ));
3432        assert!(router.add_tree_rule(
3433            "/test[0-9]/.example.com".as_bytes(),
3434            &PathRule::Prefix("/".to_string()),
3435            &MethodRule::new(Some("GET".to_string())),
3436            &Route::ClusterId("exampleregex".to_string())
3437        ));
3438
3439        assert_eq!(
3440            router.lookup("www.example.com", "/helloA", &Method::new(&b"GET"[..])),
3441            Ok(RouteResult::forward("example".to_string()))
3442        );
3443        assert_eq!(
3444            router.lookup(
3445                "www.example.com",
3446                "/.well-known/acme-challenge",
3447                &Method::new(&b"GET"[..])
3448            ),
3449            Ok(RouteResult::forward("acme".to_string()))
3450        );
3451        assert!(
3452            router
3453                .lookup("www.test.example.com", "/", &Method::new(&b"GET"[..]))
3454                .is_err()
3455        );
3456        assert_eq!(
3457            router.lookup(
3458                "www.test.example.com",
3459                "/helloAB/",
3460                &Method::new(&b"GET"[..])
3461            ),
3462            Ok(RouteResult::forward("examplewildcard".to_string()))
3463        );
3464        assert_eq!(
3465            router.lookup("test1.example.com", "/helloAB/", &Method::new(&b"GET"[..])),
3466            Ok(RouteResult::forward("exampleregex".to_string()))
3467        );
3468    }
3469
3470    #[test]
3471    fn has_hostname_checks_tree_pre_and_post() {
3472        let mut router = Router::new();
3473
3474        // Empty router has no hostnames
3475        assert!(!router.has_hostname("www.example.com"));
3476
3477        // Add a tree rule
3478        assert!(router.add_tree_rule(
3479            b"www.example.com",
3480            &PathRule::Prefix("/".to_string()),
3481            &MethodRule::new(Some("GET".to_string())),
3482            &Route::ClusterId("cluster1".to_string())
3483        ));
3484        assert!(router.has_hostname("www.example.com"));
3485        assert!(!router.has_hostname("api.example.com"));
3486
3487        // Remove the tree rule — hostname should disappear
3488        assert!(router.remove_tree_rule(
3489            b"www.example.com",
3490            &PathRule::Prefix("/".to_string()),
3491            &MethodRule::new(Some("GET".to_string()))
3492        ));
3493        assert!(!router.has_hostname("www.example.com"));
3494
3495        // Add a pre rule with an exact domain
3496        assert!(router.add_pre_rule(
3497            &DomainRule::Exact("api.example.com".to_string()),
3498            &PathRule::Prefix("/".to_string()),
3499            &MethodRule::new(None),
3500            &Route::ClusterId("cluster2".to_string())
3501        ));
3502        assert!(router.has_hostname("api.example.com"));
3503        assert!(!router.has_hostname("www.example.com"));
3504
3505        // Add a post rule
3506        assert!(router.add_post_rule(
3507            &DomainRule::Exact("cdn.example.com".to_string()),
3508            &PathRule::Prefix("/".to_string()),
3509            &MethodRule::new(None),
3510            &Route::ClusterId("cluster3".to_string())
3511        ));
3512        assert!(router.has_hostname("cdn.example.com"));
3513
3514        // Remove pre rule, post rule should still be detected
3515        assert!(router.remove_pre_rule(
3516            &DomainRule::Exact("api.example.com".to_string()),
3517            &PathRule::Prefix("/".to_string()),
3518            &MethodRule::new(None),
3519        ));
3520        assert!(!router.has_hostname("api.example.com"));
3521        assert!(router.has_hostname("cdn.example.com"));
3522    }
3523
3524    #[test]
3525    fn has_hostname_false_after_last_route_removed() {
3526        let mut router = Router::new();
3527
3528        // Add two routes for the same hostname with different paths
3529        assert!(router.add_tree_rule(
3530            b"www.example.com",
3531            &PathRule::Prefix("/".to_string()),
3532            &MethodRule::new(Some("GET".to_string())),
3533            &Route::ClusterId("cluster1".to_string())
3534        ));
3535        assert!(router.add_tree_rule(
3536            b"www.example.com",
3537            &PathRule::Prefix("/api".to_string()),
3538            &MethodRule::new(Some("GET".to_string())),
3539            &Route::ClusterId("cluster2".to_string())
3540        ));
3541        assert!(router.has_hostname("www.example.com"));
3542
3543        // Remove first route — hostname should still exist
3544        assert!(router.remove_tree_rule(
3545            b"www.example.com",
3546            &PathRule::Prefix("/".to_string()),
3547            &MethodRule::new(Some("GET".to_string()))
3548        ));
3549        assert!(router.has_hostname("www.example.com"));
3550
3551        // Remove second route — hostname should be gone
3552        assert!(router.remove_tree_rule(
3553            b"www.example.com",
3554            &PathRule::Prefix("/api".to_string()),
3555            &MethodRule::new(Some("GET".to_string()))
3556        ));
3557        assert!(!router.has_hostname("www.example.com"));
3558    }
3559}