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