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