sozu_lib/router/mod.rs
1pub mod pattern_trie;
2
3use std::{
4 borrow::Cow,
5 fmt::{self, Debug, Write},
6 rc::Rc,
7 str::from_utf8,
8 time::Instant,
9};
10
11use regex::bytes::Regex;
12use sozu_command::{
13 config::validate_header_predicate,
14 logging::CachedTags,
15 proto::command::{
16 HeaderMatchKind, HeaderPosition, HeaderPredicate, HstsConfig, PathRule as CommandPathRule,
17 PathRuleKind, RedirectPolicy, RedirectScheme, RulePosition,
18 },
19 response::HttpFrontend,
20 state::ClusterId,
21};
22
23use crate::metrics::names;
24use crate::{
25 protocol::{http::editor::HeaderEditMode, http::parser::Method},
26 router::pattern_trie::{InlineTrieMatches, InsertResult, TrieNode, TrieSubMatch},
27 sozu_command::logging::ansi_palette,
28};
29
30/// Module-level prefix tag for `lib/src/router/`. Honours the runtime
31/// colored-output flag via [`ansi_palette`]; consumed by the single
32/// `warn!` site in `Frontend::new` (and any future emitter without an
33/// `HttpContext` in scope) so static log-layout regression checks
34/// (`lib/tests/log_layout.rs`) keep router log lines on the canonical
35/// `[ROUTER] >>>` envelope.
36macro_rules! log_module_context {
37 () => {{
38 let (open, reset, _, _, _) = ansi_palette();
39 format!("{open}ROUTER{reset}\t >>>", open = open, reset = reset)
40 }};
41}
42
43/// Upper bound (in bytes) on a frontend hostname accepted by the router,
44/// checked by [`Router::add_http_front_with_hsts_origin`] and
45/// [`Router::remove_http_front`] before anything parses the hostname.
46///
47/// RFC 1035 caps a domain name at 255 octets; Sōzu's regex-segment
48/// grammar (`/re/.example.com`) can legitimately exceed that, so this is
49/// a deliberately generous safety net rather than a strict RFC bound. It
50/// exists because the route-table trie recurses once per label — a
51/// hostname with ~100k labels aborts the worker with an uncatchable
52/// stack overflow — and because the regex compilation a `/`-segment
53/// triggers costs time linear in the pattern size (a 2 MiB hostname
54/// stalls the single-threaded worker for ~855 ms before the regex size
55/// limit rejects it).
56pub const MAX_HOSTNAME_LENGTH: usize = 4096;
57
58#[derive(thiserror::Error, PartialEq)]
59pub enum RouterError {
60 #[error("Could not parse rule from frontend path, path_bytes={}", .0.len())]
61 InvalidPathRule(String),
62 #[error("parsing hostname failed, hostname_bytes={}", .hostname.len())]
63 InvalidDomain { hostname: String },
64 #[error("Could not parse host rewrite, rewrite_host_bytes={}", .0.len())]
65 InvalidHostRewrite(String),
66 #[error("Could not parse path rewrite, rewrite_path_bytes={}", .0.len())]
67 InvalidPathRewrite(String),
68 #[error("Could not add route, route_bytes={}", .0.len())]
69 AddRoute(String),
70 /// A frontend header predicate is not a usable routing condition
71 /// (sozu-proxy/sozu#1253). Carries the predicate's position and a fixed
72 /// reason, never the operator's name or value.
73 #[error("invalid header predicate at index {index}: {reason}")]
74 InvalidHeaderPredicate { index: usize, reason: &'static str },
75 #[error("Could not remove route, route_bytes={}", .0.len())]
76 RemoveRoute(String),
77 #[error("route_not_found method={method:?} host_bytes={} path_bytes={}", .host.len(), .path.len())]
78 RouteNotFound {
79 host: String,
80 path: String,
81 method: Method,
82 },
83}
84
85impl fmt::Debug for RouterError {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 fmt::Display::fmt(self, f)
88 }
89}
90
91/// `Clone` lets a listener added next to a sibling on the same address take
92/// over its routes (`HttpProxy::add_listener`, `lib/src/http.rs`).
93#[derive(Clone)]
94pub struct Router {
95 pre: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
96 pub tree: TrieNode<Vec<(PathRule, MethodRule, HeaderRules, Route)>>,
97 post: Vec<(DomainRule, PathRule, MethodRule, HeaderRules, Route)>,
98}
99
100impl Default for Router {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl Router {
107 pub fn new() -> Router {
108 Router {
109 pre: Vec::new(),
110 tree: TrieNode::root(),
111 post: Vec::new(),
112 }
113 }
114
115 /// Resolve a request to a [`RouteResult`].
116 ///
117 /// Looks up `(hostname, path, method)` against the pre, tree, and post
118 /// rule lists. The matched [`Route`] is converted into a [`RouteResult`]:
119 /// legacy variants ([`Route::ClusterId`], [`Route::Deny`]) synthesize a
120 /// minimal `RouteResult` so existing call sites keep working, while
121 /// [`Route::Frontend`] runs the full Frontend → RouteResult pipeline,
122 /// substituting host/path captures into [`RewriteParts`] templates.
123 ///
124 /// A request looked up here carries no header, so a rule with header
125 /// predicates never matches it; see [`Self::lookup_with_headers`].
126 pub fn lookup(
127 &self,
128 hostname: &str,
129 path: &str,
130 method: &Method,
131 ) -> Result<RouteResult, RouterError> {
132 self.lookup_with_headers(hostname, path, method, &NoHeaders)
133 }
134
135 /// [`Self::lookup`], with the request's headers for the frontends that
136 /// carry header predicates (sozu-proxy/sozu#1253).
137 ///
138 /// Within each of the pre list, one trie leaf and the post list, a rule
139 /// whose predicates all hold outranks every rule without predicates;
140 /// among rules of one class the historical precedence applies
141 /// unchanged — declaration order for pre/post, the path/method order of
142 /// the private `select_tree_rule` for the tree. A rule whose predicates do not all
143 /// hold is skipped, exactly like a rule whose method does not match. A
144 /// route table without predicates therefore resolves every request as
145 /// it did before, and `headers` is never read.
146 pub fn lookup_with_headers(
147 &self,
148 hostname: &str,
149 path: &str,
150 method: &Method,
151 headers: &dyn RequestHeaders,
152 ) -> Result<RouteResult, RouterError> {
153 // The route table stores every hostname LABEL ASCII-lowercased
154 // (`add_tree_rule` via `tree_hostname_to_ascii`, and
155 // `DomainRule::from_str` via `idna::domain_to_ascii`; a regex
156 // segment keeps the operator's bytes on both paths and is
157 // compiled case-insensitively instead — see
158 // `tree_hostname_to_ascii`), so the lookup key is normalised the
159 // same way — otherwise the byte-exact trie walk and the
160 // byte-exact `Exact`/`Wildcard` comparisons below can never match
161 // an uppercase `Host:` header. See [`normalize_hostname`] for why
162 // this is a scan and not an allocation on the common path.
163 //
164 // The captures handed to `RouteResult` are taken from this
165 // normalised key, so `$HOST[n]` rewrite templates see the
166 // normalised host; the raw bytes the client sent stay on
167 // `HttpContext.authority`, which feeds the access log,
168 // `X-Forwarded-Host` and the redirect `Location:` and is not
169 // touched here. `RouteNotFound` below deliberately reports the
170 // ORIGINAL spelling, because that is the diagnostic an operator
171 // needs.
172 let normalized_hostname = normalize_hostname(hostname);
173 let hostname_b = normalized_hostname.as_bytes();
174 let path_b = path.as_bytes();
175 if let Some((domain_rule, path_rule, _, _, route)) =
176 select_listed_rule(&self.pre, hostname_b, path_b, method, headers)
177 {
178 return Ok(RouteResult::new_no_trie(
179 hostname_b,
180 domain_rule,
181 path_b,
182 path_rule,
183 route,
184 ));
185 }
186
187 // The hostname candidates the trie holds are tried
188 // most-specific-first — exact name, then regex segments in
189 // declaration order, then the `*` wildcard — and `select_tree_rule`
190 // is what decides whether a candidate serves THIS request. Handing
191 // it to the trie as the acceptance predicate is what makes the
192 // order a search rather than a filter: a hostname whose rules all
193 // reject this (path, method) hands the request to the next
194 // candidate instead of ending the lookup (sozu#1351).
195 //
196 // The segments the walk matched are recorded on the stack, so a
197 // lookup allocates nothing of its own; see `InlineTrieMatches`.
198 let mut trie_matches = InlineTrieMatches::new();
199 if let Some((_, path_rules)) = self.tree.lookup_with_inline_path(
200 hostname_b,
201 true,
202 &mut trie_matches,
203 &mut |(_, path_rules)| select_tree_rule(path_rules, path_b, method, headers).is_some(),
204 ) && let Some((path_rule, route)) = select_tree_rule(path_rules, path_b, method, headers)
205 {
206 // The second call cannot disagree with the predicate: same
207 // pure function, same leaf, same request. Re-running it is how
208 // the winning rule is carried out of a closure that may only
209 // answer yes or no.
210 //
211 // It costs a second scan of the winning leaf's rule list, so
212 // the common single-candidate hit now scans twice where it
213 // scanned once. The list is the rules of ONE hostname and the
214 // scan is a `PathRule::matches` per entry; carrying the
215 // selection out of the closure instead would need `accept` to
216 // borrow for the trie's own `'b`, which ties the predicate to
217 // the tree borrow for a saving of one pass over a short Vec.
218 // Revisit if a leaf ever holds enough rules to matter.
219 return Ok(RouteResult::new_with_trie(
220 hostname_b,
221 &trie_matches,
222 path_b,
223 path_rule,
224 route,
225 ));
226 }
227
228 if let Some((domain_rule, path_rule, _, _, route)) =
229 select_listed_rule(&self.post, hostname_b, path_b, method, headers)
230 {
231 return Ok(RouteResult::new_no_trie(
232 hostname_b,
233 domain_rule,
234 path_b,
235 path_rule,
236 route,
237 ));
238 }
239
240 Err(RouterError::RouteNotFound {
241 host: hostname.to_owned(),
242 path: path.to_owned(),
243 method: method.to_owned(),
244 })
245 }
246
247 /// Add an HTTP/HTTPS frontend whose `hsts` field (if any) came from
248 /// the per-frontend configuration directly. Equivalent to
249 /// [`Self::add_http_front_with_hsts_origin`] called with
250 /// [`HstsOrigin::Explicit`]. The default for callers that don't
251 /// know about listener-default inheritance — e.g. plain HTTP
252 /// listeners (`HttpListenerConfig` has no HSTS field) and tests.
253 pub fn add_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
254 self.add_http_front_with_hsts_origin(front, HstsOrigin::Explicit)
255 }
256
257 /// Add an HTTP/HTTPS frontend, recording whether the resolved
258 /// `front.hsts` was inherited from the listener default. The
259 /// inheritance bit is preserved on the resulting [`Frontend`] so a
260 /// later `UpdateHttpsListenerConfig.hsts` patch can reflow the new
261 /// default onto inheriting entries without disturbing explicit
262 /// per-frontend overrides.
263 pub fn add_http_front_with_hsts_origin(
264 &mut self,
265 front: &HttpFrontend,
266 hsts_origin: HstsOrigin,
267 ) -> Result<(), RouterError> {
268 // Bounded BEFORE any parse: the `DomainRule` parse below compiles
269 // control-plane-supplied regex segments, and the trie recurses
270 // once per label (see `MAX_HOSTNAME_LENGTH`).
271 if front.hostname.len() > MAX_HOSTNAME_LENGTH {
272 return Err(RouterError::InvalidDomain {
273 hostname: front.hostname.clone(),
274 });
275 }
276
277 let path_rule = PathRule::from_config(front.path.clone())
278 .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
279
280 let method_rule = MethodRule::new(front.method.clone());
281
282 let header_rules = HeaderRules::from_config(&front.header_predicates)?;
283
284 // Decide between the legacy `Route::ClusterId`/`Route::Deny` shape
285 // and the rich `Route::Frontend(Rc<Frontend>)` shape: any non-
286 // default policy field flips us onto the rich path so the mux
287 // can honour redirect/rewrite/headers/auth at request time.
288 //
289 // `tags` counts as such a field. The legacy shapes carry no tags
290 // (`RouteResult::forward` / `::deny` both set `tags: None`), so a
291 // tagged frontend stored as `Route::ClusterId` would hand the mux
292 // a tagless routing decision and its access logs would have to
293 // fall back to the authority-keyed listener map — the exact
294 // spelling mismatch of sozu#1379. Only tagged frontends pay the
295 // `Rc<Frontend>`; an untagged one keeps the lightweight shape.
296 let has_policy = front.redirect.is_some()
297 || front.redirect_scheme.is_some()
298 || front.redirect_template.is_some()
299 || front.rewrite_host.is_some()
300 || front.rewrite_path.is_some()
301 || front.rewrite_port.is_some()
302 || front.required_auth.unwrap_or(false)
303 || !front.headers.is_empty()
304 || front.hsts.is_some()
305 || front.tags.is_some();
306
307 let domain =
308 front
309 .hostname
310 .parse::<DomainRule>()
311 .map_err(|_| RouterError::InvalidDomain {
312 hostname: front.hostname.clone(),
313 })?;
314
315 let route = if has_policy {
316 let redirect = front
317 .redirect
318 .and_then(|r| RedirectPolicy::try_from(r).ok())
319 .unwrap_or(RedirectPolicy::Forward);
320 let redirect_scheme = front
321 .redirect_scheme
322 .and_then(|s| RedirectScheme::try_from(s).ok())
323 .unwrap_or(RedirectScheme::UseSame);
324 let frontend = Frontend::new(
325 &domain,
326 &path_rule,
327 front,
328 redirect,
329 redirect_scheme,
330 front.redirect_template.clone(),
331 front.rewrite_host.clone(),
332 front.rewrite_path.clone(),
333 front.rewrite_port.and_then(|p| u16::try_from(p).ok()),
334 &front.headers,
335 front.required_auth.unwrap_or(false),
336 hsts_origin,
337 )?;
338 Route::Frontend(Rc::new(frontend))
339 } else {
340 match &front.cluster_id {
341 Some(cluster_id) => Route::ClusterId(cluster_id.clone()),
342 None => Route::Deny,
343 }
344 };
345
346 let success = match front.position {
347 RulePosition::Pre => self.add_pre_rule_with_headers(
348 &domain,
349 &path_rule,
350 &method_rule,
351 &header_rules,
352 &route,
353 ),
354 RulePosition::Post => self.add_post_rule_with_headers(
355 &domain,
356 &path_rule,
357 &method_rule,
358 &header_rules,
359 &route,
360 ),
361 RulePosition::Tree => self.add_tree_rule_with_headers(
362 front.hostname.as_bytes(),
363 &path_rule,
364 &method_rule,
365 &header_rules,
366 &route,
367 ),
368 };
369 if !success {
370 return Err(RouterError::AddRoute(format!("{front:?}")));
371 }
372 Ok(())
373 }
374
375 pub fn remove_http_front(&mut self, front: &HttpFrontend) -> Result<(), RouterError> {
376 // Same bound as `add_http_front_with_hsts_origin`: the Pre/Post
377 // arms below re-parse the hostname into a `DomainRule`.
378 if front.hostname.len() > MAX_HOSTNAME_LENGTH {
379 return Err(RouterError::InvalidDomain {
380 hostname: front.hostname.clone(),
381 });
382 }
383
384 let path_rule = PathRule::from_config(front.path.clone())
385 .ok_or(RouterError::InvalidPathRule(front.path.to_string()))?;
386
387 let method_rule = MethodRule::new(front.method.clone());
388
389 let header_rules = HeaderRules::from_config(&front.header_predicates)?;
390
391 let remove_success = match front.position {
392 RulePosition::Pre => {
393 let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
394 RouterError::InvalidDomain {
395 hostname: front.hostname.clone(),
396 }
397 })?;
398
399 self.remove_pre_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
400 }
401 RulePosition::Post => {
402 let domain = front.hostname.parse::<DomainRule>().map_err(|_| {
403 RouterError::InvalidDomain {
404 hostname: front.hostname.clone(),
405 }
406 })?;
407
408 self.remove_post_rule_with_headers(&domain, &path_rule, &method_rule, &header_rules)
409 }
410 RulePosition::Tree => self.remove_tree_rule_with_headers(
411 front.hostname.as_bytes(),
412 &path_rule,
413 &method_rule,
414 &header_rules,
415 ),
416 };
417 if !remove_success {
418 return Err(RouterError::RemoveRoute(format!("{front:?}")));
419 }
420 Ok(())
421 }
422
423 /// [`Self::add_tree_rule_with_headers`] for a rule without header
424 /// predicates.
425 pub fn add_tree_rule(
426 &mut self,
427 hostname: &[u8],
428 path: &PathRule,
429 method: &MethodRule,
430 cluster: &Route,
431 ) -> bool {
432 self.add_tree_rule_with_headers(hostname, path, method, &HeaderRules::default(), cluster)
433 }
434
435 /// Add a tree rule. Its identity in the leaf is `(path, method,
436 /// headers)`: rules that differ only by their header predicates coexist.
437 pub fn add_tree_rule_with_headers(
438 &mut self,
439 hostname: &[u8],
440 path: &PathRule,
441 method: &MethodRule,
442 headers: &HeaderRules,
443 cluster: &Route,
444 ) -> bool {
445 let hostname = match from_utf8(hostname) {
446 Err(_) => return false,
447 Ok(h) => h,
448 };
449
450 match tree_hostname_to_ascii(hostname) {
451 Some(hostname) => {
452 //FIXME: necessary ti build on stable rust (1.35), can be removed once 1.36 is there
453 let mut empty = true;
454 if let Some((_, paths)) = self.tree.domain_lookup_mut(hostname.as_bytes(), false) {
455 empty = false;
456 let before = paths.len();
457 if !paths
458 .iter()
459 .any(|(p, m, h, _)| p == path && m == method && h == headers)
460 {
461 paths.push((
462 path.to_owned(),
463 method.to_owned(),
464 headers.to_owned(),
465 cluster.to_owned(),
466 ));
467 // Append must add exactly one (path, method) leaf
468 // and the new rule must now be present.
469 debug_assert_eq!(
470 paths.len(),
471 before + 1,
472 "appending a tree rule must grow the leaf's rule list by exactly one",
473 );
474 debug_assert!(
475 paths
476 .iter()
477 .any(|(p, m, h, _)| p == path && m == method && h == headers),
478 "the freshly appended (path, method, headers) rule must be present after insert",
479 );
480 return true;
481 }
482 }
483
484 if empty {
485 // Snapshot the ASCII host bytes before the move so the
486 // post-insert reachability check can re-look-up the
487 // domain. Ungated `let` (read only inside the gated
488 // assert) → dropped by the optimizer in release.
489 let inserted_host = hostname.clone().into_bytes();
490 let insert_result = self.tree.domain_insert(
491 hostname.into_bytes(),
492 vec![(
493 path.to_owned(),
494 method.to_owned(),
495 headers.to_owned(),
496 cluster.to_owned(),
497 )],
498 );
499 // A malformed hostname reaches us straight from the
500 // control plane (`AddHttpFrontend` over the command
501 // socket, or a `LoadState` replay), so the route table
502 // rejecting it is an expected outcome, not a Sozu bug:
503 // report the failure and let the caller answer the
504 // request with an error. Shapes the trie rejects
505 // include a host ending in `/` with no openable regex
506 // segment (`example.com/`), a regex segment that is not
507 // `.`-anchored (`abc/[0-9]+/.example.com`), a segment
508 // that is not a valid regex (`/[/.example.com`), and an
509 // empty label (`.example.com`).
510 if insert_result == InsertResult::Failed {
511 // Redacted like every other router log site: the
512 // shape is what matters here. `RouterError::AddRoute`
513 // is redacting too (`route_bytes=<len>`), so the
514 // hostname itself is only visible where the main
515 // process audit-logs the request it fanned out
516 // (`bin/src/command/requests.rs`) -- correlate by
517 // timestamp to identify the offending frontend.
518 error!(
519 "{} the route table rejected a malformed hostname, hostname_bytes={}",
520 log_module_context!(),
521 inserted_host.len(),
522 );
523 return false;
524 }
525 // A fresh domain must now be reachable, carrying the
526 // single rule just inserted. Use `domain_lookup_mut`
527 // (not the immutable `domain_lookup`): only the `_mut`
528 // resolver handles a literal wildcard key (`*.sozu.io`)
529 // via its `partial_key == b"*"` segment case, which is
530 // exactly the resolution the append branch above relies
531 // on. The immutable `lookup` lacks that case and would
532 // miss wildcard entries.
533 debug_assert!(
534 self.tree
535 .domain_lookup_mut(&inserted_host, false)
536 .is_some_and(|(_, paths)| paths
537 .iter()
538 .any(|(p, m, h, _)| p == path && m == method && h == headers)),
539 "a freshly inserted tree domain must resolve to its inserted rule",
540 );
541 return true;
542 }
543
544 false
545 }
546 None => false,
547 }
548 }
549
550 /// [`Self::remove_tree_rule_with_headers`] for a rule without header
551 /// predicates.
552 pub fn remove_tree_rule(
553 &mut self,
554 hostname: &[u8],
555 path: &PathRule,
556 method: &MethodRule,
557 // _cluster: &Route,
558 ) -> bool {
559 self.remove_tree_rule_with_headers(hostname, path, method, &HeaderRules::default())
560 }
561
562 /// Remove the tree rule whose identity is `(path, method, headers)`; a
563 /// rule that differs only by its header predicates stays.
564 pub fn remove_tree_rule_with_headers(
565 &mut self,
566 hostname: &[u8],
567 path: &PathRule,
568 method: &MethodRule,
569 headers: &HeaderRules,
570 ) -> bool {
571 let hostname = match from_utf8(hostname) {
572 Err(_) => return false,
573 Ok(h) => h,
574 };
575
576 match tree_hostname_to_ascii(hostname) {
577 Some(hostname) => {
578 let should_delete = {
579 let paths_opt = self.tree.domain_lookup_mut(hostname.as_bytes(), false);
580
581 if let Some((_, paths)) = paths_opt {
582 paths.retain(|(p, m, h, _)| p != path || m != method || h != headers);
583 // `retain` evicts every matching (path, method,
584 // headers) rule; none may survive the filter.
585 debug_assert!(
586 !paths
587 .iter()
588 .any(|(p, m, h, _)| p == path && m == method && h == headers),
589 "remove must evict every matching (path, method, headers) rule from the leaf",
590 );
591 }
592
593 paths_opt
594 .as_ref()
595 .map(|(_, paths)| paths.is_empty())
596 .unwrap_or(false)
597 };
598
599 if should_delete {
600 let removed_host = hostname.clone().into_bytes();
601 self.tree.domain_remove(&hostname.into_bytes());
602 // Dropping the last rule must make the whole domain
603 // unreachable — no stranded empty leaf left behind.
604 // `domain_lookup_mut` resolves literal wildcard keys
605 // (`*.sozu.io`), so this genuinely verifies wildcard
606 // entries are gone too (the immutable `lookup` lacks
607 // the `partial_key == b"*"` case and would always read
608 // None for a wildcard host, weakening the check).
609 debug_assert!(
610 self.tree.domain_lookup_mut(&removed_host, false).is_none(),
611 "a domain whose last rule was removed must be unreachable",
612 );
613 }
614
615 true
616 }
617 None => false,
618 }
619 }
620
621 /// Walk every route and re-materialise the response-side HSTS edit
622 /// on frontends that inherited from the listener default. Operator
623 /// per-frontend HSTS overrides (`inherits_listener_hsts == false`)
624 /// are left untouched.
625 ///
626 /// Called from `lib/src/https.rs::HttpsListener::update_config` when
627 /// an `UpdateHttpsListenerConfig.hsts` patch is applied, and from
628 /// `HttpsProxy::add_listener` (`lib/src/https.rs`) when a listener copies the routes of a
629 /// sibling on the same address, so each listener resolves inheritance
630 /// against its own default.
631 ///
632 /// Two refresh paths:
633 ///
634 /// 1. **`Route::Frontend(rc)` with `inherits_listener_hsts == true`**:
635 /// rebuild `headers_response` by dropping any existing
636 /// `Strict-Transport-Security` entry and appending a freshly
637 /// rendered one when `new_hsts` resolves to an enabled value
638 /// (`enabled = Some(true)`). The existing operator
639 /// `Append`/`Set` response headers stay in place. An inheriting
640 /// frontend added while the listener had no default holds no
641 /// `Strict-Transport-Security` entry yet and gains one here.
642 ///
643 /// 2. **`Route::ClusterId(id)` and `Route::Deny`** (lightweight
644 /// "no policy" shapes): when `new_hsts` resolves to enabled,
645 /// promote in place to a minimal `Route::Frontend(rc)` carrying
646 /// just the HSTS edit on `headers_response` (and
647 /// `inherits_listener_hsts == true` so subsequent patches keep
648 /// refreshing the entry). Routing semantics are preserved — the
649 /// promoted Frontend forwards / denies identically to the
650 /// original variant — and the promoted entry now participates
651 /// in path 1 on every later patch. When `new_hsts` resolves to
652 /// "no HSTS" (None / disabled), lightweight routes are left
653 /// untouched (no allocation is created just to hold an empty
654 /// HSTS edit).
655 ///
656 /// Path 2 fixes the case where a frontend was added without any
657 /// per-frontend policy field (the routing fast path stores it as
658 /// `Route::ClusterId` / `Route::Deny`, NOT `Route::Frontend`). Before
659 /// this two-path walk, listener-default HSTS patches silently
660 /// skipped every such "no-policy" frontend — which on a typical
661 /// Clever Cloud `cleverapps.io` shared listener was 99 % of the
662 /// frontends.
663 ///
664 /// Returns the number of frontends touched. For path 1, refreshed
665 /// frontends where the new policy resolves to "no HSTS" are still
666 /// counted (the existing HSTS edit is stripped). For path 2, only
667 /// frontends actually promoted (i.e. `new_hsts` enabled) are
668 /// counted, since "no HSTS" is a no-op on the lightweight shape.
669 pub fn refresh_inheriting_hsts(&mut self, new_hsts: Option<&HstsConfig>) -> usize {
670 let mut refreshed = 0usize;
671 // Pre-compute the listener-default HSTS edit ONCE so every
672 // visited frontend in this patch shares the same `Rc`-backed
673 // key / val allocation. `Some(_)` doubles as the "promote
674 // lightweight routes" gate — there is no point allocating a
675 // promoted Frontend just to hold an empty headers_response.
676 // See `build_listener_hsts_edit`'s rustdoc for the
677 // ~1.5 M-allocation-per-worker savings on cleverapps.io.
678 let new_edit = build_listener_hsts_edit(new_hsts);
679 let new_edit_ref = new_edit.as_ref();
680 let promote_lightweight = new_edit_ref.is_some();
681 let mut visit = |route: &mut Route| match route {
682 Route::Frontend(rc) => {
683 if rc.inherits_listener_hsts {
684 let new_frontend = rebuild_with_listener_hsts(rc, new_edit_ref);
685 *rc = Rc::new(new_frontend);
686 refreshed += 1;
687 }
688 }
689 Route::ClusterId(id) => {
690 if promote_lightweight {
691 let promoted = rebuild_with_listener_hsts(
692 &Frontend::minimal_forward(id.clone()),
693 new_edit_ref,
694 );
695 *route = Route::Frontend(Rc::new(promoted));
696 refreshed += 1;
697 }
698 }
699 Route::Deny => {
700 if promote_lightweight {
701 let promoted =
702 rebuild_with_listener_hsts(&Frontend::minimal_deny(), new_edit_ref);
703 *route = Route::Frontend(Rc::new(promoted));
704 refreshed += 1;
705 }
706 }
707 };
708
709 for (_, _, _, _, route) in self.pre.iter_mut() {
710 visit(route);
711 }
712 self.tree.for_each_value_mut(&mut |paths| {
713 for (_, _, _, route) in paths.iter_mut() {
714 visit(route);
715 }
716 });
717 for (_, _, _, _, route) in self.post.iter_mut() {
718 visit(route);
719 }
720 refreshed
721 }
722
723 /// [`Self::add_pre_rule_with_headers`] for a rule without header
724 /// predicates.
725 pub fn add_pre_rule(
726 &mut self,
727 domain: &DomainRule,
728 path: &PathRule,
729 method: &MethodRule,
730 cluster_id: &Route,
731 ) -> bool {
732 self.add_pre_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
733 }
734
735 /// Add a pre-rule. Its identity is `(domain, path, method,
736 /// headers)`: rules that differ only by their header predicates coexist.
737 pub fn add_pre_rule_with_headers(
738 &mut self,
739 domain: &DomainRule,
740 path: &PathRule,
741 method: &MethodRule,
742 headers: &HeaderRules,
743 cluster_id: &Route,
744 ) -> bool {
745 let before = self.pre.len();
746 if !self
747 .pre
748 .iter()
749 .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
750 {
751 self.pre.push((
752 domain.to_owned(),
753 path.to_owned(),
754 method.to_owned(),
755 headers.to_owned(),
756 cluster_id.to_owned(),
757 ));
758 // A new pre-rule grows the list by exactly one and is now
759 // present (dedup of the same triple is the caller's `false`
760 // path, not this one).
761 debug_assert_eq!(
762 self.pre.len(),
763 before + 1,
764 "adding a unique pre-rule must push exactly one entry",
765 );
766 debug_assert!(
767 self.pre
768 .iter()
769 .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
770 "the freshly added pre-rule must be present",
771 );
772 true
773 } else {
774 debug_assert_eq!(
775 self.pre.len(),
776 before,
777 "a duplicate pre-rule must not change the list length",
778 );
779 false
780 }
781 }
782
783 /// [`Self::add_post_rule_with_headers`] for a rule without header
784 /// predicates.
785 pub fn add_post_rule(
786 &mut self,
787 domain: &DomainRule,
788 path: &PathRule,
789 method: &MethodRule,
790 cluster_id: &Route,
791 ) -> bool {
792 self.add_post_rule_with_headers(domain, path, method, &HeaderRules::default(), cluster_id)
793 }
794
795 /// Add a post-rule. Its identity is `(domain, path, method,
796 /// headers)`: rules that differ only by their header predicates coexist.
797 pub fn add_post_rule_with_headers(
798 &mut self,
799 domain: &DomainRule,
800 path: &PathRule,
801 method: &MethodRule,
802 headers: &HeaderRules,
803 cluster_id: &Route,
804 ) -> bool {
805 let before = self.post.len();
806 if !self
807 .post
808 .iter()
809 .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
810 {
811 self.post.push((
812 domain.to_owned(),
813 path.to_owned(),
814 method.to_owned(),
815 headers.to_owned(),
816 cluster_id.to_owned(),
817 ));
818 debug_assert_eq!(
819 self.post.len(),
820 before + 1,
821 "adding a unique post-rule must push exactly one entry",
822 );
823 debug_assert!(
824 self.post
825 .iter()
826 .any(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers),
827 "the freshly added post-rule must be present",
828 );
829 true
830 } else {
831 debug_assert_eq!(
832 self.post.len(),
833 before,
834 "a duplicate post-rule must not change the list length",
835 );
836 false
837 }
838 }
839
840 /// [`Self::remove_pre_rule_with_headers`] for a rule without header
841 /// predicates.
842 pub fn remove_pre_rule(
843 &mut self,
844 domain: &DomainRule,
845 path: &PathRule,
846 method: &MethodRule,
847 ) -> bool {
848 self.remove_pre_rule_with_headers(domain, path, method, &HeaderRules::default())
849 }
850
851 /// Remove the pre-rule whose identity is `(domain, path, method,
852 /// headers)`; a rule that differs only by its header predicates stays.
853 pub fn remove_pre_rule_with_headers(
854 &mut self,
855 domain: &DomainRule,
856 path: &PathRule,
857 method: &MethodRule,
858 headers: &HeaderRules,
859 ) -> bool {
860 let before = self.pre.len();
861 match self
862 .pre
863 .iter()
864 .position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
865 {
866 None => {
867 debug_assert_eq!(
868 self.pre.len(),
869 before,
870 "a no-op pre-rule removal must not change the list length",
871 );
872 false
873 }
874 Some(index) => {
875 debug_assert!(index < self.pre.len(), "found index must be in bounds");
876 self.pre.remove(index);
877 // Exactly one entry left, and the triple is now gone.
878 debug_assert_eq!(
879 self.pre.len() + 1,
880 before,
881 "removing a pre-rule must drop exactly one entry",
882 );
883 debug_assert!(
884 !self.pre.iter().any(|(d, p, m, h, _)| d == domain
885 && p == path
886 && m == method
887 && h == headers),
888 "the removed pre-rule must no longer be present",
889 );
890 true
891 }
892 }
893 }
894
895 /// [`Self::remove_post_rule_with_headers`] for a rule without header
896 /// predicates.
897 pub fn remove_post_rule(
898 &mut self,
899 domain: &DomainRule,
900 path: &PathRule,
901 method: &MethodRule,
902 ) -> bool {
903 self.remove_post_rule_with_headers(domain, path, method, &HeaderRules::default())
904 }
905
906 /// Remove the post-rule whose identity is `(domain, path, method,
907 /// headers)`; a rule that differs only by its header predicates stays.
908 pub fn remove_post_rule_with_headers(
909 &mut self,
910 domain: &DomainRule,
911 path: &PathRule,
912 method: &MethodRule,
913 headers: &HeaderRules,
914 ) -> bool {
915 let before = self.post.len();
916 match self
917 .post
918 .iter()
919 .position(|(d, p, m, h, _)| d == domain && p == path && m == method && h == headers)
920 {
921 None => {
922 debug_assert_eq!(
923 self.post.len(),
924 before,
925 "a no-op post-rule removal must not change the list length",
926 );
927 false
928 }
929 Some(index) => {
930 debug_assert!(index < self.post.len(), "found index must be in bounds");
931 self.post.remove(index);
932 debug_assert_eq!(
933 self.post.len() + 1,
934 before,
935 "removing a post-rule must drop exactly one entry",
936 );
937 debug_assert!(
938 !self.post.iter().any(|(d, p, m, h, _)| d == domain
939 && p == path
940 && m == method
941 && h == headers),
942 "the removed post-rule must no longer be present",
943 );
944 true
945 }
946 }
947 }
948
949 /// Returns true if any route (pre, tree, or post) references the given hostname.
950 ///
951 /// This is used after removing a frontend to decide whether the hostname's
952 /// tags should be cleaned up. Tags must only be removed when no routes remain.
953 pub fn has_hostname(&self, hostname: &str) -> bool {
954 // Same normalisation as `lookup`, for the same reason: the
955 // pre/post arms compare byte-exact against `DomainRule`s the add
956 // path already lowercased. Answering `false` for a hostname whose
957 // route is still installed would drop the frontend's cached tags
958 // from under a live route.
959 let normalized_hostname = normalize_hostname(hostname);
960 let hostname = normalized_hostname.as_ref();
961 let hostname_b = hostname.as_bytes();
962
963 // Check pre rules
964 for (domain_rule, _, _, _, _) in &self.pre {
965 if domain_rule.matches(hostname_b) {
966 return true;
967 }
968 }
969
970 // Check tree rules (exact match only, no wildcard resolution)
971 if let Some(ascii_hostname) = tree_hostname_to_ascii(hostname)
972 && self
973 .tree
974 .domain_lookup(ascii_hostname.as_bytes(), false)
975 .is_some()
976 {
977 return true;
978 }
979
980 // Check post rules
981 for (domain_rule, _, _, _, _) in &self.post {
982 if domain_rule.matches(hostname_b) {
983 return true;
984 }
985 }
986
987 false
988 }
989}
990
991/// Pick the rule a trie leaf serves `path`, `method` and `headers` with, or
992/// `None` when the leaf carries nothing for this request.
993///
994/// An `EQUALS`/`REGEX`/`REGEX_PATH` rule whose `method` matches exactly wins
995/// immediately; otherwise the longest matching `PREFIX` wins, with a
996/// method-less rule competing on equal footing. Rules whose `method` is
997/// set and does not match are skipped entirely — which is why a longer
998/// prefix can lose to a shorter one (`doc/configure.md`, "Path matching
999/// precedence within a frontend").
1000///
1001/// Header predicates (sozu-proxy/sozu#1253) split the leaf into two
1002/// classes, each selected by the rule above: a rule whose predicates all
1003/// hold outranks every rule without predicates, and a rule whose
1004/// predicates do not all hold is skipped like a rule whose method does not
1005/// match. A leaf without predicates selects exactly as it always did, and
1006/// declaration order never decides between a canary and its base.
1007///
1008/// Lifted out of [`Router::lookup_with_headers`] so the trie walk can use
1009/// the very same selection as its acceptance predicate: a hostname
1010/// candidate that selects nothing must not end the lookup (sozu#1351). Two
1011/// copies of this would drift, and the drift would be a route that the trie
1012/// accepted and the router then refused to serve.
1013fn select_tree_rule<'a>(
1014 path_rules: &'a [(PathRule, MethodRule, HeaderRules, Route)],
1015 path: &[u8],
1016 method: &Method,
1017 headers: &dyn RequestHeaders,
1018) -> Option<(&'a PathRule, &'a Route)> {
1019 let mut predicated = TreeSelection::default();
1020 let mut unpredicated = TreeSelection::default();
1021
1022 for (rule, method_rule, header_rules, route) in path_rules {
1023 let class = if header_rules.is_empty() {
1024 &mut unpredicated
1025 } else {
1026 &mut predicated
1027 };
1028 // A class that already holds an exact winner takes no other rule:
1029 // this is the historical early return, per class.
1030 if class.exact {
1031 continue;
1032 }
1033 let path_result = rule.matches(path);
1034 if path_result == PathRuleResult::None {
1035 continue;
1036 }
1037 let method_result = method_rule.matches(method);
1038 if method_result == MethodRuleResult::None {
1039 continue;
1040 }
1041 // Headers are read last, and only for a rule that could otherwise
1042 // serve this request.
1043 if !header_rules.is_empty() && !header_rules.matches(headers) {
1044 continue;
1045 }
1046 class.offer(path_result, method_result, path.len(), rule, route);
1047 if predicated.exact {
1048 // Nothing outranks an exact match among satisfied predicates.
1049 break;
1050 }
1051 }
1052
1053 // A predicate-free rule may only be served when no satisfied
1054 // predicated rule exists: the pair below is the class precedence.
1055 debug_assert!(
1056 !predicated.exact || predicated.matched.is_some(),
1057 "an exact selection must carry its rule",
1058 );
1059 predicated.matched.or(unpredicated.matched)
1060}
1061
1062/// One class of [`select_tree_rule`]'s scan: the historical
1063/// longest-prefix / exact-method selection, run over the rules of that
1064/// class only.
1065#[derive(Default)]
1066struct TreeSelection<'a> {
1067 prefix_length: usize,
1068 matched: Option<(&'a PathRule, &'a Route)>,
1069 /// An `EQUALS`/`REGEX`/`REGEX_PATH` rule with an exactly matching method was taken:
1070 /// the class is decided.
1071 exact: bool,
1072}
1073
1074impl<'a> TreeSelection<'a> {
1075 fn offer(
1076 &mut self,
1077 path_result: PathRuleResult,
1078 method_result: MethodRuleResult,
1079 path_len: usize,
1080 rule: &'a PathRule,
1081 route: &'a Route,
1082 ) {
1083 debug_assert!(!self.exact, "a decided class must not take another rule");
1084 debug_assert!(
1085 path_result != PathRuleResult::None && method_result != MethodRuleResult::None,
1086 "only a rule that matches path and method is offered",
1087 );
1088 match path_result {
1089 PathRuleResult::Regex | PathRuleResult::Equals => match method_result {
1090 MethodRuleResult::Equals => {
1091 self.matched = Some((rule, route));
1092 self.exact = true;
1093 }
1094 MethodRuleResult::All => {
1095 self.prefix_length = path_len;
1096 self.matched = Some((rule, route));
1097 }
1098 MethodRuleResult::None => {}
1099 },
1100 PathRuleResult::Prefix(size) => {
1101 if size >= self.prefix_length {
1102 // FIXME: the rule order will be important here
1103 // Longest-prefix wins: the selected length is
1104 // monotonically non-decreasing across the candidate
1105 // scan.
1106 debug_assert!(
1107 size >= self.prefix_length,
1108 "longest-prefix selection must never shrink the match length",
1109 );
1110 self.prefix_length = size;
1111 self.matched = Some((rule, route));
1112 }
1113 }
1114 PathRuleResult::None => {}
1115 }
1116 }
1117}
1118
1119/// Pick the rule an ordered pre or post list serves the request with.
1120///
1121/// The first rule in declaration order whose domain, path and method match
1122/// wins — except that a rule whose header predicates all hold outranks
1123/// every rule without predicates (sozu-proxy/sozu#1253), the same class
1124/// precedence [`select_tree_rule`] applies to a trie leaf. A rule whose
1125/// predicates do not all hold is skipped.
1126fn select_listed_rule<'a>(
1127 rules: &'a [(DomainRule, PathRule, MethodRule, HeaderRules, Route)],
1128 hostname: &[u8],
1129 path: &[u8],
1130 method: &Method,
1131 headers: &dyn RequestHeaders,
1132) -> Option<&'a (DomainRule, PathRule, MethodRule, HeaderRules, Route)> {
1133 let mut unpredicated = None;
1134 for entry in rules {
1135 let (domain_rule, path_rule, method_rule, header_rules, _) = entry;
1136 // Once a predicate-free rule is held, only a predicated one can
1137 // still change the answer.
1138 if unpredicated.is_some() && header_rules.is_empty() {
1139 continue;
1140 }
1141 if !(domain_rule.matches(hostname)
1142 && path_rule.matches(path) != PathRuleResult::None
1143 && method_rule.matches(method) != MethodRuleResult::None)
1144 {
1145 continue;
1146 }
1147 if header_rules.is_empty() {
1148 unpredicated = Some(entry);
1149 } else if header_rules.matches(headers) {
1150 return Some(entry);
1151 }
1152 }
1153 debug_assert!(
1154 unpredicated.is_none_or(|(_, _, _, header_rules, _)| header_rules.is_empty()),
1155 "the fallback of a listed selection must be a predicate-free rule",
1156 );
1157 unpredicated
1158}
1159
1160/// The request headers a [`Router`] lookup may read to evaluate header
1161/// predicates (sozu-proxy/sozu#1253).
1162pub trait RequestHeaders {
1163 /// The value of the FIRST occurrence of the header `name`, compared
1164 /// ASCII-case-insensitively (RFC 9110 §5.1), raw as the request carried
1165 /// it; `None` when the request does not carry it.
1166 fn header(&self, name: &[u8]) -> Option<&[u8]>;
1167}
1168
1169/// A request without any header, for lookups that have none to offer.
1170pub struct NoHeaders;
1171
1172impl RequestHeaders for NoHeaders {
1173 fn header(&self, _name: &[u8]) -> Option<&[u8]> {
1174 None
1175 }
1176}
1177
1178/// How one [`HeaderRule`] tests the value of its header.
1179#[derive(Clone, Debug)]
1180enum HeaderMatch {
1181 Exact(Vec<u8>),
1182 /// Anchored at both ends, like a `REGEX` path rule ([`anchored_regex`]).
1183 Regex(Regex),
1184 Present,
1185}
1186
1187impl PartialEq for HeaderMatch {
1188 /// `Regex` carries no `PartialEq`: patterns compare by source, as
1189 /// [`PathRule`]'s do.
1190 fn eq(&self, other: &Self) -> bool {
1191 match (self, other) {
1192 (HeaderMatch::Exact(a), HeaderMatch::Exact(b)) => a == b,
1193 (HeaderMatch::Regex(a), HeaderMatch::Regex(b)) => a.as_str() == b.as_str(),
1194 (HeaderMatch::Present, HeaderMatch::Present) => true,
1195 _ => false,
1196 }
1197 }
1198}
1199
1200/// One compiled header predicate: a lowercased header name and its test.
1201#[derive(Clone, Debug, PartialEq)]
1202struct HeaderRule {
1203 name: Vec<u8>,
1204 matcher: HeaderMatch,
1205}
1206
1207impl HeaderRule {
1208 fn matches(&self, headers: &dyn RequestHeaders) -> bool {
1209 let Some(value) = headers.header(&self.name) else {
1210 return false;
1211 };
1212 match &self.matcher {
1213 HeaderMatch::Exact(expected) => value == expected.as_slice(),
1214 HeaderMatch::Regex(regex) => {
1215 let start = Instant::now();
1216 let is_a_match = regex.is_match(value);
1217 time!(
1218 names::event_loop::REGEX_MATCHING_TIME,
1219 start.elapsed().as_millis()
1220 );
1221 is_a_match
1222 }
1223 HeaderMatch::Present => true,
1224 }
1225 }
1226
1227 /// The canonical sort key of a rule within a [`HeaderRules`].
1228 fn sort_key(&self) -> (&[u8], u8, &[u8]) {
1229 match &self.matcher {
1230 HeaderMatch::Exact(value) => (&self.name, 0, value),
1231 HeaderMatch::Regex(regex) => (&self.name, 1, regex.as_str().as_bytes()),
1232 HeaderMatch::Present => (&self.name, 2, b""),
1233 }
1234 }
1235}
1236
1237/// The header predicates of one route-table rule (sozu-proxy/sozu#1253):
1238/// a conjunction, empty for a rule without predicates.
1239///
1240/// It is part of the rule's identity: [`Router`]'s add and remove paths
1241/// compare it alongside the path and method, so a canary rule that differs
1242/// from its base only by a predicate coexists with it, and removing one
1243/// leaves the other. The rules are kept in a canonical order, so two
1244/// spellings of one conjunction (other declaration order, other name case)
1245/// are one rule — the same canonicalisation the `ConfigState` key applies
1246/// (`RequestHttpFrontend`'s `Display` in `command/src/request.rs`).
1247#[derive(Clone, Debug, Default, PartialEq)]
1248pub struct HeaderRules(Vec<HeaderRule>);
1249
1250impl HeaderRules {
1251 /// Compile a frontend's predicates, held to
1252 /// [`validate_header_predicate`] first. A `regex` value is compiled
1253 /// anchored, refused when it does not compile exactly like a `REGEX`
1254 /// path rule (the private `anchored_regex` helper compiles both).
1255 pub fn from_config(predicates: &[HeaderPredicate]) -> Result<Self, RouterError> {
1256 let mut rules = Vec::with_capacity(predicates.len());
1257 for (index, predicate) in predicates.iter().enumerate() {
1258 validate_header_predicate(predicate)
1259 .map_err(|reason| RouterError::InvalidHeaderPredicate { index, reason })?;
1260 let value = predicate.value.as_deref().unwrap_or_default();
1261 let matcher = match HeaderMatchKind::try_from(predicate.kind) {
1262 Ok(HeaderMatchKind::Exact) => HeaderMatch::Exact(value.as_bytes().to_owned()),
1263 Ok(HeaderMatchKind::Regex) => HeaderMatch::Regex(anchored_regex(value).ok_or(
1264 RouterError::InvalidHeaderPredicate {
1265 index,
1266 reason: "the regex value does not compile",
1267 },
1268 )?),
1269 Ok(HeaderMatchKind::Present) => HeaderMatch::Present,
1270 Err(_) => {
1271 return Err(RouterError::InvalidHeaderPredicate {
1272 index,
1273 reason: "unknown match kind",
1274 });
1275 }
1276 };
1277 rules.push(HeaderRule {
1278 name: predicate.name.as_bytes().to_ascii_lowercase(),
1279 matcher,
1280 });
1281 }
1282 rules.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
1283 rules.dedup();
1284 debug_assert!(
1285 rules.len() <= predicates.len(),
1286 "compiling predicates never adds a rule",
1287 );
1288 debug_assert!(
1289 rules.is_empty() == predicates.is_empty(),
1290 "a frontend has predicates exactly when its rule does",
1291 );
1292 Ok(HeaderRules(rules))
1293 }
1294
1295 pub fn is_empty(&self) -> bool {
1296 self.0.is_empty()
1297 }
1298
1299 /// Whether every predicate holds for `headers`. True for an empty
1300 /// conjunction, which never reads `headers`.
1301 pub fn matches(&self, headers: &dyn RequestHeaders) -> bool {
1302 self.0.iter().all(|rule| rule.matches(headers))
1303 }
1304}
1305
1306/// ASCII-lowercase a hostname for routing, borrowing it when it already is.
1307///
1308/// RFC 9110 §4.2.3 makes the host case-insensitive, and every configured
1309/// hostname LABEL is lowercased on the way into the route table:
1310/// [`Router::add_tree_rule`] runs it through [`tree_hostname_to_ascii`]
1311/// and [`DomainRule::from_str`] through `idna::domain_to_ascii`. (A
1312/// `/`-delimited regex segment is exempt on both paths and is folded at
1313/// compile time instead; see [`tree_hostname_to_ascii`].) The lookup key
1314/// has to be normalised
1315/// the same way, or [`Router::lookup`]'s byte-exact trie walk and the
1316/// byte-exact `DomainRule::Exact` / `DomainRule::Wildcard` comparisons
1317/// can never match an uppercase `Host:` header — and no configuration
1318/// fixes it, because declaring the frontend in uppercase gets lowercased
1319/// too.
1320///
1321/// The scan is the whole cost on the common path: a host that is already
1322/// lowercase — every host a conforming client sends — is borrowed, so
1323/// the datapath allocates only for the requests that actually carry
1324/// uppercase. This runs once per request, so the distinction is not
1325/// academic.
1326///
1327/// Deliberately ASCII-only and deliberately NOT a full
1328/// `idna::domain_to_ascii` round trip: `hostname_and_port`
1329/// (`lib/src/protocol/kawa_h1/parser.rs`) only admits bytes matching
1330/// `is_hostname_char`, all of which are ASCII, so there is no non-ASCII
1331/// host to punycode here, and a per-request IDNA pass would be a real
1332/// datapath cost for a case that cannot occur.
1333fn normalize_hostname(hostname: &str) -> Cow<'_, str> {
1334 if hostname.as_bytes().iter().any(u8::is_ascii_uppercase) {
1335 Cow::Owned(hostname.to_ascii_lowercase())
1336 } else {
1337 Cow::Borrowed(hostname)
1338 }
1339}
1340
1341/// Normalise a `RulePosition::Tree` hostname for the route table,
1342/// applying IDNA to its literal labels ONLY.
1343///
1344/// `idna::domain_to_ascii` ASCII-lowercases everything it is handed, and
1345/// a tree hostname is not all domain labels: a `/`-delimited segment is
1346/// regex SOURCE, where case is meaning. Folding it inverts every
1347/// uppercase escape — measured, `"/\D+/.example.com"` normalises to
1348/// `"/\d+/.example.com"` and `"/[^\D]/.example.com"` to
1349/// `"/[^\d]/.example.com"`, turning "not a digit" into "a digit", so the
1350/// installed rule matched the exact COMPLEMENT of what the operator
1351/// wrote while `sozu query frontends` still echoed the original spelling
1352/// (sozu#1377). `\W`/`\w` and `\S`/`\s` invert the same way.
1353///
1354/// The split is the one [`convert_regex_domain_rule`] already makes for
1355/// `Pre`/`Post` and the one `pattern_trie`'s `insert_recursive`
1356/// addresses a `regexps` entry by: a label the operator wrapped in
1357/// slashes is a regex segment and is copied BYTE FOR BYTE, delimiters
1358/// included; every other label is a domain label and goes through
1359/// `idna::domain_to_ascii` exactly as before. Per-label and whole-domain
1360/// IDNA agree on a label — measured, `"MÜNCHEN"` maps to
1361/// `"xn--mnchen-3ya"` either way — so a genuine unicode label is still
1362/// punycoded in the very same hostname whose regex segment is left
1363/// alone.
1364///
1365/// Leaving the source unfolded means an uppercase LITERAL inside a
1366/// segment (`/API[0-9]/`) no longer meets an ASCII-lowercased lookup key
1367/// by accident. `pattern_trie`'s `compiled_segment` compiles the stored
1368/// segment case-insensitively instead, which is what
1369/// [`DomainRule::from_str`] already does for `Pre`/`Post`: the two
1370/// positions now treat a hostname regex identically rather than by
1371/// opposite mechanisms.
1372///
1373/// Two inputs deliberately keep the historical whole-string call, both
1374/// byte-for-byte:
1375///
1376/// - a hostname with no `/` at all, which is every ordinary hostname —
1377/// so the retained trailing dot pinned by
1378/// `an_absolute_form_host_does_not_reach_a_relative_frontend_yet` and
1379/// every other whole-string behaviour is untouched;
1380/// - a hostname carrying a `/` that does NOT parse as this grammar, such
1381/// as `abc/[0-9]+/.example.com` (a segment that is not `.`-anchored) or
1382/// `example.com/`. Neither the split nor the trie sees a regex segment
1383/// there — it is one literal label to both — so there is nothing to
1384/// protect, and the trie accepts or rejects it exactly as it did
1385/// before.
1386fn tree_hostname_to_ascii(hostname: &str) -> Option<String> {
1387 /// The `/…/`-aware walk. `None` means "not this grammar", never
1388 /// "invalid": validating the segment is `anchored_segment`'s job at
1389 /// insert time, and this function must not start rejecting hostnames
1390 /// the trie has always answered for itself.
1391 fn split_labels(hostname: &str) -> Option<String> {
1392 let mut result = String::with_capacity(hostname.len());
1393 let s = hostname.as_bytes();
1394 let mut index = 0;
1395
1396 loop {
1397 // A bare trailing `.` leaves `index` one past the end; the
1398 // grammar requires a label after every `.`, so this is not
1399 // the shape — fall back rather than index out of bounds.
1400 if index >= s.len() {
1401 return None;
1402 }
1403
1404 if s[index] == b'/' {
1405 let close = (index + 1..s.len()).find(|&i| s[i] == b'/')?;
1406 // Regex source, copied verbatim. Both delimiters are
1407 // ASCII `/`, so the slice is on char boundaries.
1408 result.push_str(std::str::from_utf8(&s[index..=close]).ok()?);
1409 index = close + 1;
1410 } else {
1411 let end = (index..s.len()).find(|&i| s[i] == b'.').unwrap_or(s.len());
1412 let label = std::str::from_utf8(&s[index..end]).ok()?;
1413 result.push_str(&::idna::domain_to_ascii(label).ok()?);
1414 index = end;
1415 }
1416
1417 if index == s.len() {
1418 return Some(result);
1419 }
1420 if s[index] != b'.' {
1421 return None;
1422 }
1423 result.push('.');
1424 index += 1;
1425 }
1426 }
1427
1428 if hostname.contains('/')
1429 && let Some(normalized) = split_labels(hostname)
1430 {
1431 return Some(normalized);
1432 }
1433 ::idna::domain_to_ascii(hostname).ok()
1434}
1435
1436#[derive(Clone, Debug)]
1437pub enum DomainRule {
1438 Any,
1439 Exact(String),
1440 /// Matches when `hostname` ends with `s[1..]` (the wildcard pattern with
1441 /// the leading `*` stripped) and the remaining leftmost prefix is
1442 /// non-empty and contains no `.`. Comparison is byte-exact and no
1443 /// IDN/punycode normalisation is performed here: the pattern was
1444 /// already lowercased by `DomainRule::from_str`'s
1445 /// `idna::domain_to_ascii`, and the hostname it is handed has been
1446 /// through `normalize_hostname` in [`Router::lookup`], so both
1447 /// sides are ASCII-lowercase by the time they meet. Stored with the
1448 /// leading `*`.
1449 Wildcard(String),
1450 /// Anchored full-host regex built by `convert_regex_domain_rule`.
1451 /// Unlike the other variants its source is NOT lowercased — it is
1452 /// compiled case-insensitively instead, so an uppercase literal
1453 /// still matches the normalised key. See `DomainRule::from_str`
1454 /// for why that folding is Unicode-aware while the hostnames it is
1455 /// ever handed are ASCII.
1456 Regex(Regex),
1457}
1458
1459/// Build the whole-host regex for a `hostname` carrying one or more
1460/// slash-delimited regex segments (`/cdn[0-9]+/.example.com`). Each regex
1461/// segment is emitted as a non-capturing group, each literal label is
1462/// emitted through [`regex::escape`], the `.` separators are escaped, and the
1463/// result is anchored at both ends.
1464///
1465/// The anchors and the groups answer two different failures and both are
1466/// required:
1467///
1468/// - `\A` … `\z` because `Regex::is_match` is a substring search. Without
1469/// them `/example\.com/` matches any hostname CONTAINING `example.com`
1470/// (e.g. `attacker.example.com.evil.org`), letting an attacker-controlled
1471/// domain reach a frontend that should only serve `example.com`.
1472/// - `(?:` … `)` around each regex segment because `|` binds looser than
1473/// concatenation, so an ungrouped segment splits the anchors between its
1474/// branches: `\Aa|b\.example\.com\z` parses as
1475/// `(\Aa)|(b\.example\.com\z)`. Measured, that matches
1476/// `axx.example.com` and `xxb.example.com`; with three branches the middle
1477/// one keeps NEITHER anchor and matches anywhere at all. The group is
1478/// non-capturing, so `captures_len` — and therefore every `$HOST[n]`
1479/// rewrite index, read from `caps.iter().skip(1)` in
1480/// `RouteResult::new_no_trie` — is unchanged. A literal label carrying a
1481/// `|` split the anchors the same way and is closed by the escaping
1482/// below: `/a/.x|y.com` assembled to `\A(?:a)\.x|y\.com\z`, which
1483/// matched `a.xZZZ` and `ZZZy.com`.
1484///
1485/// A label the operator did NOT wrap in slashes is a literal, and the two
1486/// arms must not be confused: grouping it would confine an alternation but
1487/// leave `+`, `?`, `*`, `^` and `$` live inside it, so `b+c` would still
1488/// match `bbbc`. [`regex::escape`] is the treatment that matches the
1489/// grammar. It is a no-op in matching behaviour for every label a real
1490/// hostname can carry — an LDH label is `[A-Za-z0-9-]`, of which `escape`
1491/// touches only `-`, and `\-` is `-` outside a character class. Two
1492/// deliberate behaviour changes fall out of it for labels that are NOT
1493/// valid hostname labels, both narrowing and both measured:
1494/// `*./x/.example.com` assembled to `\A*\.(?:x)\.example\.com\z`, whose
1495/// `\A*` is a repetition of a zero-width assertion and therefore matches
1496/// empty anywhere — it took `evil.attacker.x.example.com`, and now requires
1497/// the literal label `*`; and `/a/.b(c.example.com` was REJECTED because the
1498/// assembly did not compile, and is now accepted, matching exactly the
1499/// literal host the operator typed and nothing else.
1500///
1501/// The `*` change is scoped to what this function feeds, which is `Pre` and
1502/// `Post` matching only. A trie-routed frontend never reaches here for
1503/// matching: `TrieNode` splits the hostname on `.` and keeps a `*` label in
1504/// its own `wildcard` slot, so the same configured hostname now answers
1505/// differently by position. `doc/configure.md` § "Regex hostname segments"
1506/// carries the table and
1507/// `a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post`
1508/// asserts it.
1509///
1510/// Each regex segment is compiled ON ITS OWN before it is wrapped. The
1511/// wrapper supplies one `(` and one `)`, so an unbalanced segment can be
1512/// balanced BY the wrapping: `a)(b` is rejected by `Regex::new`, yet
1513/// `\A(?:a)(b)\.example\.com\z` compiles and matches `ab.example.com`.
1514/// Without that first compile, grouping would silently promote a hostname the
1515/// router rejects today into a live rule. This closes
1516/// invalid-becomes-valid only; the converse is not closed, exactly as on the
1517/// path side — see [`PathRule::anchored_regex`], which carries the same shape
1518/// and the same measurements for a whole path value.
1519fn convert_regex_domain_rule(hostname: &str) -> Option<String> {
1520 let mut result = String::from("\\A");
1521
1522 let s = hostname.as_bytes();
1523 let mut index = 0;
1524 loop {
1525 // A bare trailing `.` after a completed segment (`/a/.`, `x./y/.`)
1526 // leaves `index` one past the end through the `index += 1` in the
1527 // loop tail; indexing `s[index]` here would then panic (slice
1528 // bounds checks never compile out of release). The grammar
1529 // requires a label after every `.`, so reject instead.
1530 if index >= s.len() {
1531 return None;
1532 }
1533 if s[index] == b'/' {
1534 let mut found = false;
1535 for i in index + 1..s.len() {
1536 if s[i] == b'/' {
1537 match std::str::from_utf8(&s[index + 1..i]) {
1538 Ok(r) => {
1539 // Reject a segment that is not a regex on its own,
1540 // then group it. See this function's doc comment.
1541 Regex::new(r).ok()?;
1542 result.push_str("(?:");
1543 result.push_str(r);
1544 result.push(')');
1545 }
1546 Err(_) => return None,
1547 }
1548 index = i + 1;
1549 found = true;
1550 break;
1551 }
1552 }
1553
1554 if !found {
1555 return None;
1556 }
1557 } else {
1558 let start = index;
1559 for i in start..s.len() + 1 {
1560 index = i;
1561 if i < s.len() && s[i] == b'.' {
1562 match std::str::from_utf8(&s[start..i]) {
1563 // A LITERAL label is not a pattern: escape it so it
1564 // matches itself. See this function's doc comment.
1565 Ok(r) => result.push_str(®ex::escape(r)),
1566 Err(_) => return None,
1567 }
1568 break;
1569 }
1570 }
1571 if index == s.len() {
1572 match std::str::from_utf8(&s[start..]) {
1573 Ok(r) => result.push_str(®ex::escape(r)),
1574 Err(_) => return None,
1575 }
1576 }
1577 }
1578
1579 if index == s.len() {
1580 result.push_str("\\z");
1581 return Some(result);
1582 } else if s[index] == b'.' {
1583 result.push_str("\\.");
1584 index += 1;
1585 } else {
1586 return None;
1587 }
1588 }
1589}
1590
1591impl DomainRule {
1592 pub fn matches(&self, hostname: &[u8]) -> bool {
1593 match self {
1594 DomainRule::Any => true,
1595 DomainRule::Wildcard(s) => {
1596 // A stored wildcard always keeps its leading `*`, so the
1597 // suffix (pattern minus `*`) is a strict sub-slice and the
1598 // bare `*` (Any) never reaches this arm.
1599 debug_assert_eq!(
1600 s.as_bytes().first(),
1601 Some(&b'*'),
1602 "a Wildcard rule must retain its leading '*'",
1603 );
1604 let suffix = &s.as_bytes()[1..];
1605 let matched = hostname
1606 .strip_suffix(suffix)
1607 .is_some_and(|prefix| !prefix.is_empty() && !prefix.contains(&b'.'));
1608 // A wildcard never matches a hostname no longer than its
1609 // own suffix — there is no room left for the mandatory
1610 // single non-empty leftmost label.
1611 debug_assert!(
1612 !matched || hostname.len() > suffix.len(),
1613 "a wildcard match requires a non-empty leftmost label before the suffix",
1614 );
1615 matched
1616 }
1617 DomainRule::Exact(s) => s.as_bytes() == hostname,
1618 DomainRule::Regex(r) => {
1619 let start = Instant::now();
1620 let is_a_match = r.is_match(hostname);
1621 let now = Instant::now();
1622 time!(
1623 names::event_loop::REGEX_MATCHING_TIME,
1624 (now - start).as_millis()
1625 );
1626 is_a_match
1627 }
1628 }
1629 }
1630}
1631
1632impl std::cmp::PartialEq for DomainRule {
1633 fn eq(&self, other: &Self) -> bool {
1634 match (self, other) {
1635 (DomainRule::Any, DomainRule::Any) => true,
1636 (DomainRule::Wildcard(s1), DomainRule::Wildcard(s2)) => s1 == s2,
1637 (DomainRule::Exact(s1), DomainRule::Exact(s2)) => s1 == s2,
1638 (DomainRule::Regex(r1), DomainRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1639 _ => false,
1640 }
1641 }
1642}
1643
1644impl std::str::FromStr for DomainRule {
1645 type Err = ();
1646
1647 fn from_str(s: &str) -> Result<Self, Self::Err> {
1648 Ok(if s == "*" {
1649 DomainRule::Any
1650 } else if s.contains('/') {
1651 match convert_regex_domain_rule(s) {
1652 // Case-insensitive, because `Router::lookup`
1653 // normalises the host it matches against and a hostname
1654 // is case-insensitive anyway (RFC 9110 §4.2.3). Without
1655 // it an operator's `/API[0-9]/` would compile
1656 // case-sensitively and match nothing at all — it used to
1657 // match `API7.…` only, never the canonical `api7.…`.
1658 //
1659 // Set on the builder rather than by lowercasing the
1660 // source, which would rewrite `\D` into `\d`. The trie
1661 // does exactly the same since sozu#1377:
1662 // `tree_hostname_to_ascii` leaves a segment's bytes alone
1663 // and `pattern_trie::compiled_segment` folds at compile
1664 // time, so the two rule positions now treat a hostname
1665 // regex by the same mechanism. Up to and including 2.2.1
1666 // `RulePosition::Tree` instead ran the WHOLE hostname,
1667 // regex source included, through `idna::domain_to_ascii`
1668 // — which is precisely the escape-inverting fold this
1669 // comment refuses.
1670 //
1671 // The folding is the crate default, i.e. Unicode simple
1672 // case folding, NOT ASCII-only: `.unicode(false)` is
1673 // deliberately left unset. It would make the fold
1674 // ASCII-only, but it also rejects patterns that compile
1675 // and work today — measured, `\p{L}` and `\pL` fail to
1676 // build with it, so `/\p{L}+/.example.com`, which
1677 // matches ASCII letters perfectly well right now, would
1678 // start being refused at add time — pinned by
1679 // `a_unicode_class_hostname_regex_still_compiles_and_routes`.
1680 // The distinction is
1681 // unobservable on the haystack side regardless: the only
1682 // hostnames that reach `matches` come through
1683 // `hostname_and_port`
1684 // (`lib/src/protocol/kawa_h1/parser.rs`), whose
1685 // `is_hostname_char` admits nothing outside ASCII, and
1686 // whose own `if !i.is_empty()` guard then refuses the
1687 // whole authority rather than truncating it — see
1688 // `is_hostname_char_admits_only_ascii`. It IS observable
1689 // on the PATTERN side: a pattern spelling `\u{212A}`
1690 // folds onto `k`. Hence "case-insensitive" here, not
1691 // "ASCII-case-insensitive".
1692 Some(s) => match regex::bytes::RegexBuilder::new(&s)
1693 .case_insensitive(true)
1694 .build()
1695 {
1696 Ok(r) => DomainRule::Regex(r),
1697 Err(_) => return Err(()),
1698 },
1699 None => return Err(()),
1700 }
1701 } else if s.contains('*') {
1702 if s.starts_with('*') {
1703 match ::idna::domain_to_ascii(s) {
1704 Ok(r) => DomainRule::Wildcard(r),
1705 Err(_) => return Err(()),
1706 }
1707 } else {
1708 return Err(());
1709 }
1710 } else {
1711 match ::idna::domain_to_ascii(s) {
1712 Ok(r) => DomainRule::Exact(r),
1713 Err(_) => return Err(()),
1714 }
1715 })
1716 }
1717}
1718
1719#[derive(Clone, Debug)]
1720pub enum PathRule {
1721 Prefix(String),
1722 Regex(Regex),
1723 Equals(String),
1724 /// `REGEX_PATH` (sozu-proxy/sozu#1909): the same anchored regex as
1725 /// [`PathRule::Regex`], tested against the request path up to its first
1726 /// `?` only (`split_query`), the Gateway API `RegularExpression` path
1727 /// match. It shares `Regex`'s precedence class ([`PathRuleResult::Regex`]).
1728 RegexPath(Regex),
1729}
1730
1731/// Split a request-target into its path and its query: everything before
1732/// the first `?`, and the rest INCLUDING that `?` (empty when the target
1733/// carries none). `?` is ASCII, so both halves stay on UTF-8 boundaries of
1734/// a `&str` the bytes came from.
1735fn split_query(target: &[u8]) -> (&[u8], &[u8]) {
1736 let (path, query) = match target.iter().position(|&b| b == b'?') {
1737 Some(at) => target.split_at(at),
1738 None => (target, &target[target.len()..]),
1739 };
1740 debug_assert_eq!(
1741 path.len() + query.len(),
1742 target.len(),
1743 "the path and query halves must cover the request-target exactly",
1744 );
1745 debug_assert!(
1746 !path.contains(&b'?') && (query.is_empty() || query[0] == b'?'),
1747 "the path half holds no `?` and a non-empty query half starts with one",
1748 );
1749 (path, query)
1750}
1751
1752#[derive(PartialEq, Eq)]
1753pub enum PathRuleResult {
1754 Regex,
1755 Prefix(usize),
1756 Equals,
1757 None,
1758}
1759
1760impl PathRule {
1761 pub fn matches(&self, path: &[u8]) -> PathRuleResult {
1762 match self {
1763 PathRule::Prefix(prefix) => {
1764 if path.starts_with(prefix.as_bytes()) {
1765 // The reported prefix length is the matched-byte count
1766 // the router uses for longest-prefix tie-breaking; it
1767 // must equal the prefix and never exceed the path.
1768 debug_assert!(
1769 prefix.len() <= path.len(),
1770 "a matching prefix cannot be longer than the path it matched",
1771 );
1772 PathRuleResult::Prefix(prefix.len())
1773 } else {
1774 PathRuleResult::None
1775 }
1776 }
1777 PathRule::Regex(regex) => {
1778 let start = Instant::now();
1779 let is_a_match = regex.is_match(path);
1780 let now = Instant::now();
1781 time!(
1782 names::event_loop::REGEX_MATCHING_TIME,
1783 (now - start).as_millis()
1784 );
1785
1786 if is_a_match {
1787 PathRuleResult::Regex
1788 } else {
1789 PathRuleResult::None
1790 }
1791 }
1792 PathRule::RegexPath(regex) => {
1793 let (path_only, _) = split_query(path);
1794 let start = Instant::now();
1795 let is_a_match = regex.is_match(path_only);
1796 let now = Instant::now();
1797 time!(
1798 names::event_loop::REGEX_MATCHING_TIME,
1799 (now - start).as_millis()
1800 );
1801
1802 if is_a_match {
1803 PathRuleResult::Regex
1804 } else {
1805 PathRuleResult::None
1806 }
1807 }
1808 PathRule::Equals(pattern) => {
1809 if path == pattern.as_bytes() {
1810 PathRuleResult::Equals
1811 } else {
1812 PathRuleResult::None
1813 }
1814 }
1815 }
1816 }
1817
1818 /// Compile a configured `path_type = "REGEX"` value anchored at both ends,
1819 /// so it matches the WHOLE request path and nothing else — the behaviour
1820 /// `doc/configure.md` has documented since v2.0.0 and the code did not
1821 /// implement (sozu#1350). `regex::bytes::Regex::is_match` is a substring
1822 /// search, so the previous bare `Regex::new(&rule.value)` let `bc` match
1823 /// `/abcd`.
1824 ///
1825 /// `\A(?:` … `)\z` is the form the hostname side uses too, in
1826 /// [`convert_regex_domain_rule`] around each slash-delimited segment and
1827 /// in `pattern_trie.rs`'s `anchored_segment` at segment-insert time
1828 /// (sozu#1356 closed the hostname half; this path half landed first, as
1829 /// sozu#1357).
1830 ///
1831 /// The non-capturing group is NOT decoration: `|` binds looser than
1832 /// concatenation, so the ungrouped `\Aa|b\z` parses as `(\Aa)|(b\z)`
1833 /// and each branch keeps only one anchor — measured, it still matches
1834 /// `axx`. `\A(?:a|b)\z` does not. `(?:` … `)` is non-capturing, so
1835 /// `captures_len` and therefore every `$PATH[n]` rewrite index are
1836 /// unchanged.
1837 ///
1838 /// The configured value is compiled ON ITS OWN first and only a value
1839 /// that compiles is wrapped. The wrapper supplies one `(` and one `)`, so
1840 /// an unbalanced pattern can be balanced BY the wrapping: `a)(b` is
1841 /// rejected by `Regex::new`, yet `\A(?:a)(b)\z` compiles and matches
1842 /// `ab`. Without the first compile, anchoring would silently promote a
1843 /// rule the router rejects today into a live rule matching something the
1844 /// operator never wrote.
1845 ///
1846 /// This closes invalid-becomes-valid only. The opposite direction is NOT
1847 /// closed and is not claimed to be: a pattern that compiles bare can be
1848 /// rejected once wrapped, because the appended `)\z` has to survive
1849 /// whatever the pattern left open. Measured: `(?x)/api/v1 # v1 only`
1850 /// compiles bare and fails wrapped with `unclosed group` — `(?x)` enables
1851 /// `#` comments, and the appended `)` lands inside one — and a 249-deep
1852 /// nested group compiles bare and fails wrapped against the crate's
1853 /// 250-nesting limit. Both land in the `None` arm of `from_config` and
1854 /// surface as [`RouterError::InvalidPathRule`] at frontend registration,
1855 /// which `bin/src/command/requests.rs`'s `validate_frontend_request` runs
1856 /// through this very same `add_http_front` before fanning out: the
1857 /// frontend is refused loudly and identically in main and worker, never
1858 /// silently mis-routed.
1859 ///
1860 /// Anchors the operator wrote by hand are harmless and are left alone:
1861 /// `regex`'s `^`/`$` are `\A`/`\z` in the default (non-multi-line) mode —
1862 /// `$` does not match before a trailing `\n` — so the repeated zero-width
1863 /// assertions in `\A(?:^/abc$)\z` collapse. Measured: that pattern matches
1864 /// `/abc` and not `/xabcd`, exactly as `\A(?:/abc)\z` does.
1865 fn anchored_regex(value: &str) -> Option<Regex> {
1866 anchored_regex(value)
1867 }
1868
1869 pub fn from_config(rule: CommandPathRule) -> Option<Self> {
1870 match PathRuleKind::try_from(rule.kind) {
1871 Ok(PathRuleKind::Prefix) => Some(PathRule::Prefix(rule.value)),
1872 Ok(PathRuleKind::Regex) => Self::anchored_regex(&rule.value).map(PathRule::Regex),
1873 Ok(PathRuleKind::Equals) => Some(PathRule::Equals(rule.value)),
1874 Ok(PathRuleKind::RegexPath) => {
1875 Self::anchored_regex(&rule.value).map(PathRule::RegexPath)
1876 }
1877 Err(_) => None,
1878 }
1879 }
1880}
1881
1882/// Compile `value` anchored at both ends, refusing a value that does not
1883/// compile on its own. [`PathRule::anchored_regex`] documents why both
1884/// steps are needed; header predicates (sozu-proxy/sozu#1253) reuse it so
1885/// a `regex` header value and a `REGEX` path follow one grammar.
1886fn anchored_regex(value: &str) -> Option<Regex> {
1887 Regex::new(value).ok()?;
1888 Regex::new(&format!("\\A(?:{value})\\z")).ok()
1889}
1890
1891/// `Regex` carries no `PartialEq`, so the comparison is written by hand
1892/// and compares patterns by `as_str()`. Every variant must answer for
1893/// itself: without an `Equals` arm the catch-all made two identical
1894/// `PathRule::Equals` unequal — not even reflexive — and the router's
1895/// bookkeeping (`add_*_rule` dedup, `remove_*_rule` eviction) silently
1896/// lost every `--path-equals` frontend, reporting success either way.
1897impl std::cmp::PartialEq for PathRule {
1898 fn eq(&self, other: &Self) -> bool {
1899 match (self, other) {
1900 (PathRule::Prefix(s1), PathRule::Prefix(s2)) => s1 == s2,
1901 (PathRule::Regex(r1), PathRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1902 (PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2,
1903 (PathRule::RegexPath(r1), PathRule::RegexPath(r2)) => r1.as_str() == r2.as_str(),
1904 _ => false,
1905 }
1906 }
1907}
1908
1909#[derive(Clone, Debug, PartialEq, Eq)]
1910pub struct MethodRule {
1911 pub inner: Option<Method>,
1912}
1913
1914#[derive(PartialEq, Eq)]
1915pub enum MethodRuleResult {
1916 All,
1917 Equals,
1918 None,
1919}
1920
1921impl MethodRule {
1922 pub fn new(method: Option<String>) -> Self {
1923 MethodRule {
1924 inner: method.map(|s| Method::new(s.as_bytes())),
1925 }
1926 }
1927
1928 pub fn matches(&self, method: &Method) -> MethodRuleResult {
1929 match self.inner {
1930 None => MethodRuleResult::All,
1931 Some(ref m) => {
1932 if method == m {
1933 MethodRuleResult::Equals
1934 } else {
1935 MethodRuleResult::None
1936 }
1937 }
1938 }
1939 }
1940}
1941
1942/// What to do with a request that matches a frontend.
1943///
1944/// Three variants coexist today; the legacy two will retire once
1945/// `HttpFrontend` itself carries the rich routing fields:
1946///
1947/// - [`Route::ClusterId`] is the legacy "forward to this cluster" variant
1948/// used by call sites that build routes directly from
1949/// [`HttpFrontend::cluster_id`].
1950/// - [`Route::Deny`] is the legacy "send 401" variant used when a frontend
1951/// has no `cluster_id`.
1952/// - [`Route::Frontend`] carries a richer [`Frontend`] decision (redirect
1953/// policy, rewrite templates, header edits, auth gating). Once
1954/// `HttpFrontend` carries the matching proto fields, `add_http_front`
1955/// will build `Route::Frontend` directly and the two legacy variants
1956/// above can retire.
1957///
1958/// `Eq`/`PartialEq` compare `Frontend` variants by `Rc` pointer identity to
1959/// stay consistent with `Hash`/`Ord` on [`Rc`]; this is sufficient for the
1960/// router's de-duplication (`add_pre_rule`, `add_post_rule`,
1961/// `add_tree_rule`) which only checks against routes created from the same
1962/// configuration call.
1963#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1964pub enum Route {
1965 /// send a 401 default answer
1966 Deny,
1967 /// the cluster to which the frontend belongs
1968 ClusterId(ClusterId),
1969 /// rich routing decision carrying redirect, rewrite, header, and auth
1970 /// configuration; supersedes the two legacy variants once the
1971 /// in-memory frontend wiring is migrated to build `Route::Frontend`
1972 /// directly.
1973 Frontend(Rc<Frontend>),
1974}
1975
1976/// Materialise the listener-default HSTS into a single shareable
1977/// [`HeaderEdit`] when the supplied policy resolves to a non-empty
1978/// `Strict-Transport-Security` header.
1979///
1980/// Returns `None` when the listener has no HSTS configured
1981/// (`new_hsts.is_none()`), when HSTS is explicitly disabled
1982/// (`enabled = Some(false)`), or when the render fails because of a
1983/// missing `max_age` (`enabled = Some(true)` with `max_age = None`,
1984/// the malformed-IPC defense-in-depth gate). Mirrors the gate
1985/// previously embedded in `rebuild_with_listener_hsts`.
1986///
1987/// Used by [`Router::refresh_inheriting_hsts`] to:
1988/// - decide whether promoting a lightweight `Route::ClusterId` /
1989/// `Route::Deny` to `Route::Frontend` is worth doing (`Some` =
1990/// promote + counted; `None` = lightweight route untouched, no
1991/// allocation created just to hold an empty edit), AND
1992/// - **share the same `Rc`-backed `key` / `val` allocation across
1993/// every visited frontend in this patch**. Without sharing, each
1994/// refreshed frontend would allocate a fresh
1995/// `Rc::from(b"strict-transport-security")` and a fresh
1996/// `Rc::from(rendered.into_bytes())` — on a 91 k-frontend
1997/// `cleverapps.io` shared listener × 8 workers, one HSTS-enable
1998/// patch produces ~1.5 M identical-content `Rc` allocations per
1999/// worker. With sharing the cost collapses to one allocation per
2000/// patch plus a refcount bump on each frontend (`HeaderEdit::clone`
2001/// is two `Rc::clone`s + a byte copy).
2002fn build_listener_hsts_edit(new_hsts: Option<&HstsConfig>) -> Option<HeaderEdit> {
2003 let cfg = new_hsts?;
2004 if !matches!(cfg.enabled, Some(true)) {
2005 return None;
2006 }
2007 let rendered = render_hsts(cfg)?;
2008 let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2009 HeaderEditMode::Set
2010 } else {
2011 HeaderEditMode::SetIfAbsent
2012 };
2013 Some(HeaderEdit {
2014 key: Rc::from(&b"strict-transport-security"[..]),
2015 val: rendered.into_bytes().into(),
2016 mode,
2017 })
2018}
2019
2020/// Build a new [`Frontend`] cloned from `frontend`, with its
2021/// `headers_response` re-materialised against `new_edit` — the
2022/// shared listener-default HSTS edit pre-built by
2023/// [`build_listener_hsts_edit`]. Used by
2024/// [`Router::refresh_inheriting_hsts`].
2025///
2026/// Preserves every operator-defined response-header edit (`Append`,
2027/// `Set`, legacy empty-`val`-Append delete) and replaces any existing
2028/// `Strict-Transport-Security` entry with `new_edit`. When `new_edit`
2029/// is `None` (listener-default HSTS resolves to "no HSTS"), the
2030/// function strips the existing STS entry and adds nothing.
2031///
2032/// Preserves the existing `inherits_listener_hsts` marker; callers
2033/// ensure it is `true` before invoking this helper (the function
2034/// uses `..frontend.clone()` so it inherits whatever the input has).
2035fn rebuild_with_listener_hsts(frontend: &Frontend, new_edit: Option<&HeaderEdit>) -> Frontend {
2036 // Strip any existing Strict-Transport-Security entry.
2037 let mut headers_response: Vec<HeaderEdit> = frontend
2038 .headers_response
2039 .iter()
2040 .filter(|edit| !edit.key.eq_ignore_ascii_case(b"strict-transport-security"))
2041 .cloned()
2042 .collect();
2043
2044 // `HeaderEdit::clone` here is two `Rc::clone`s on the shared
2045 // key/val plus a one-byte `mode` copy — no buffer allocation.
2046 if let Some(edit) = new_edit {
2047 headers_response.push(edit.clone());
2048 }
2049
2050 Frontend {
2051 headers_response: headers_response.into(),
2052 // every other field is unchanged
2053 ..frontend.clone()
2054 }
2055}
2056
2057/// Render an [`HstsConfig`] into a canonical RFC 6797 §6.1
2058/// `Strict-Transport-Security` header value: `max-age=N` first, then
2059/// optional `; includeSubDomains`, then optional `; preload`. No
2060/// trailing semicolon. `includeSubDomains` is the RFC §6.1 spelling
2061/// (camelCase); `preload` is lowercase per the de-facto Chrome/HSTS
2062/// preload-list convention (<https://hstspreload.org/>).
2063///
2064/// Returns `None` when the config has no `max_age` (the caller should
2065/// have substituted the default at config-load via
2066/// `command/src/config.rs::FileHstsConfig::to_proto` before reaching
2067/// this site; if it didn't, a `None` here suppresses the emission so a
2068/// malformed wire frame can't leak `max-age=0` accidentally).
2069pub fn render_hsts(cfg: &HstsConfig) -> Option<String> {
2070 let max_age = cfg.max_age?;
2071 let mut s = format!("max-age={max_age}");
2072 if matches!(cfg.include_subdomains, Some(true)) {
2073 s.push_str("; includeSubDomains");
2074 }
2075 if matches!(cfg.preload, Some(true)) {
2076 s.push_str("; preload");
2077 }
2078 Some(s)
2079}
2080
2081/// A single header mutation collected from a [`Frontend`] configuration.
2082///
2083/// `key` and `val` are owned via [`Rc`] so a `Frontend` can be held by many
2084/// routing entries (pre, tree, post) without copying the underlying bytes.
2085///
2086/// `mode` controls how the per-stream `apply_response_header_edits` pass
2087/// emits the entry on the wire — see [`HeaderEditMode`]. Operator-supplied
2088/// `[[...frontends.headers]]` entries default to [`HeaderEditMode::Append`]
2089/// (preserving the legacy empty-val-deletes encoding); typed policies
2090/// (HSTS, future RFC-correct response policies) opt into
2091/// [`HeaderEditMode::SetIfAbsent`].
2092#[derive(Clone, PartialEq, Eq)]
2093pub struct HeaderEdit {
2094 pub key: Rc<[u8]>,
2095 pub val: Rc<[u8]>,
2096 pub mode: HeaderEditMode,
2097}
2098
2099impl Debug for HeaderEdit {
2100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2101 f.write_fmt(format_args!(
2102 "({:?}, {:?}, {:?})",
2103 String::from_utf8_lossy(&self.key),
2104 String::from_utf8_lossy(&self.val),
2105 self.mode,
2106 ))
2107 }
2108}
2109
2110/// A parsed segment of a rewrite template.
2111///
2112/// `Host(i)` references the `i`-th host capture: index 0 is the full
2113/// hostname; positive indices are regex or wildcard subgroups. `Path(i)`
2114/// references the `i`-th path capture: index 0 is the full path; positive
2115/// indices are regex groups or prefix tails. `String` holds a literal
2116/// segment between captures.
2117#[derive(Debug, Clone, PartialEq, Eq)]
2118enum RewritePart {
2119 String(String),
2120 Host(usize),
2121 Path(usize),
2122}
2123
2124/// A pre-parsed rewrite template, decomposed into `RewritePart`s.
2125///
2126/// `RewriteParts` is built once at frontend registration time
2127/// ([`Frontend::new`]) and then re-applied at lookup time via
2128/// [`RewriteParts::run`] against the captures collected by the router.
2129///
2130/// Grammar:
2131/// - `$HOST[N]` — substitute the N-th host capture
2132/// - `$PATH[N]` — substitute the N-th path capture
2133/// - any other byte sequence — substitute literally
2134///
2135/// Out-of-bounds capture indices substitute to the empty string at run
2136/// time, but [`RewriteParts::parse`] rejects templates that reference
2137/// capture indices the router cannot produce (the `*_cap_cap` arguments).
2138#[derive(Debug, Clone, PartialEq, Eq)]
2139pub struct RewriteParts(Vec<RewritePart>);
2140
2141impl RewriteParts {
2142 /// Parse `template` against the host/path capture caps the router can
2143 /// produce for the matching domain/path rule.
2144 ///
2145 /// `host_cap_cap` and `path_cap_cap` are upper bounds (exclusive) on the
2146 /// host and path capture indices the router can fill at lookup time.
2147 /// `used_index_host` / `used_index_path` are out parameters tracking the
2148 /// highest index actually referenced — callers use them to short-circuit
2149 /// capture extraction when no template references captures.
2150 ///
2151 /// Returns `None` on syntactically malformed templates: dangling `$`,
2152 /// missing closing `]`, non-digit index, or an index ≥ the cap.
2153 pub fn parse(
2154 template: &str,
2155 host_cap_cap: usize,
2156 path_cap_cap: usize,
2157 used_index_host: &mut usize,
2158 used_index_path: &mut usize,
2159 ) -> Option<Self> {
2160 let mut result = Vec::new();
2161 let mut i = 0;
2162 let pattern = template.as_bytes();
2163 while i < pattern.len() {
2164 if pattern[i] == b'$' {
2165 let is_host = if pattern[i..].starts_with(b"$HOST[") {
2166 i += 6;
2167 true
2168 } else if pattern[i..].starts_with(b"$PATH[") {
2169 i += 6;
2170 false
2171 } else {
2172 return None;
2173 };
2174 let mut index = 0usize;
2175 let digits_start = i;
2176 while i < pattern.len() && pattern[i].is_ascii_digit() {
2177 index = index
2178 .checked_mul(10)?
2179 .checked_add((pattern[i] - b'0') as usize)?;
2180 i += 1;
2181 }
2182 if i == digits_start {
2183 // no digits between the `[` and the `]`
2184 return None;
2185 }
2186 if i >= pattern.len() || pattern[i] != b']' {
2187 return None;
2188 }
2189 if is_host {
2190 if index >= host_cap_cap {
2191 return None;
2192 }
2193 if index >= *used_index_host {
2194 *used_index_host = index + 1;
2195 }
2196 result.push(RewritePart::Host(index));
2197 } else {
2198 if index >= path_cap_cap {
2199 return None;
2200 }
2201 if index >= *used_index_path {
2202 *used_index_path = index + 1;
2203 }
2204 result.push(RewritePart::Path(index));
2205 }
2206 i += 1; // consume `]`
2207 } else {
2208 let start = i;
2209 while i < pattern.len() && pattern[i] != b'$' {
2210 i += 1;
2211 }
2212 // `pattern` is `template.as_bytes()` and the split is on
2213 // the ASCII byte `$` (0x24), which is always a single-byte
2214 // UTF-8 character — so `template[start..i]` lies on char
2215 // boundaries and is safe to index directly.
2216 result.push(RewritePart::String(template[start..i].to_owned()));
2217 }
2218 }
2219 // Every capture reference the parser emitted is within the caps it
2220 // was given; out-of-range indices return None above, never a part.
2221 debug_assert!(
2222 result.iter().all(|part| match part {
2223 RewritePart::Host(idx) => *idx < host_cap_cap,
2224 RewritePart::Path(idx) => *idx < path_cap_cap,
2225 RewritePart::String(_) => true,
2226 }),
2227 "a parsed rewrite template must only reference captures within the rule's caps",
2228 );
2229 debug_assert!(
2230 *used_index_host <= host_cap_cap && *used_index_path <= path_cap_cap,
2231 "the highest referenced capture index cannot exceed the cap",
2232 );
2233 Some(Self(result))
2234 }
2235
2236 /// Substitute `host_captures` and `path_captures` into the template.
2237 ///
2238 /// Out-of-bounds captures substitute to an empty string. The result is
2239 /// allocated in one pass with the exact required capacity.
2240 pub fn run(&self, host_captures: &[&str], path_captures: &[&str]) -> String {
2241 let mut cap = 0usize;
2242 for part in &self.0 {
2243 cap += match part {
2244 RewritePart::String(s) => s.len(),
2245 RewritePart::Host(i) => host_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2246 RewritePart::Path(i) => path_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2247 };
2248 }
2249 let mut result = String::with_capacity(cap);
2250 for part in &self.0 {
2251 // String::write_str cannot fail — ignore the formatter result.
2252 let _ = match part {
2253 RewritePart::String(s) => result.write_str(s),
2254 RewritePart::Host(i) => result.write_str(host_captures.get(*i).unwrap_or(&"")),
2255 RewritePart::Path(i) => result.write_str(path_captures.get(*i).unwrap_or(&"")),
2256 };
2257 }
2258 // The capacity pass and the write pass consult the same parts and
2259 // captures, so the single up-front allocation must be exact — the
2260 // result never reallocates.
2261 debug_assert_eq!(
2262 result.len(),
2263 cap,
2264 "rewrite output length must equal the pre-computed one-pass capacity",
2265 );
2266 result
2267 }
2268}
2269
2270/// What to do with the traffic for a routed frontend.
2271///
2272/// Built once at frontend registration time. The expensive work (parsing
2273/// rewrite templates, resolving headers into [`HeaderEdit`]s) happens here
2274/// so [`Router::lookup`] can run cheaply on the hot path.
2275///
2276/// A clusterless frontend with `redirect == FORWARD` is coerced to
2277/// `UNAUTHORIZED` in [`Frontend::new`] to avoid a forward loop with no
2278/// backend; the explicit `UNAUTHORIZED` policy then renders a 401.
2279///
2280/// Tags are wrapped in [`Rc<CachedTags>`] so the same frontend can be
2281/// referenced from multiple routing slots (pre/tree/post) without copying.
2282#[derive(Debug, Clone)]
2283pub struct Frontend {
2284 pub cluster_id: Option<ClusterId>,
2285 pub redirect: RedirectPolicy,
2286 pub redirect_scheme: RedirectScheme,
2287 pub redirect_template: Option<String>,
2288 /// Number of host captures the router will collect for this frontend.
2289 /// Sized from the matching [`DomainRule`]; the router skips capture
2290 /// extraction entirely when this is 0 (no rewrite references `$HOST[…]`).
2291 pub capture_cap_host: usize,
2292 /// Number of path captures the router will collect for this frontend.
2293 /// Sized from the matching [`PathRule`]; the router skips capture
2294 /// extraction entirely when this is 0 (no rewrite references `$PATH[…]`).
2295 pub capture_cap_path: usize,
2296 pub rewrite_host: Option<RewriteParts>,
2297 pub rewrite_path: Option<RewriteParts>,
2298 pub rewrite_port: Option<u16>,
2299 pub headers_request: Rc<[HeaderEdit]>,
2300 pub headers_response: Rc<[HeaderEdit]>,
2301 pub required_auth: bool,
2302 pub tags: Option<Rc<CachedTags>>,
2303 /// `true` when the frontend declared no per-frontend
2304 /// `RequestHttpFrontend.hsts` block, so the HSTS edit (if any) in
2305 /// [`Self::headers_response`] follows the listener-default
2306 /// `HttpsListenerConfig.hsts` — including when that default was
2307 /// absent at add time, so a default set later, or the default of a
2308 /// listener the frontend is copied into, still reaches it
2309 /// (sozu-proxy/sozu#1691). Consulted by
2310 /// [`Router::refresh_inheriting_hsts`] so a
2311 /// `UpdateHttpsListenerConfig.hsts` patch reflows the new default
2312 /// onto inheriting frontends without overwriting explicit
2313 /// per-frontend HSTS overrides.
2314 pub inherits_listener_hsts: bool,
2315}
2316
2317/// Origin of the per-frontend HSTS policy carried by an
2318/// [`HttpFrontend`] when the router materialises it into a
2319/// [`Frontend`]. Tracked separately because the resolved
2320/// `HttpFrontend.hsts` field is the same shape regardless of how it
2321/// was filled in — the inheritance bit lets later listener-default
2322/// patches refresh inheriting frontends without disturbing explicit
2323/// per-frontend overrides.
2324#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2325pub enum HstsOrigin {
2326 /// `front.hsts` came from the per-frontend configuration directly
2327 /// (operator wrote `[clusters.<id>.frontends.hsts]` in TOML or
2328 /// passed `--hsts-*` on the CLI). Listener-default patches do NOT
2329 /// refresh this entry.
2330 Explicit,
2331 /// The frontend declared no `hsts`, and `add_https_frontend` filled
2332 /// `front.hsts` in from the listener-default
2333 /// `HttpsListenerConfig.hsts` — `None` when the listener has no
2334 /// default. A future
2335 /// `UpdateHttpsListenerConfig.hsts` patch will refresh this entry
2336 /// via [`Router::refresh_inheriting_hsts`].
2337 InheritedFromListenerDefault,
2338}
2339
2340impl PartialEq for Frontend {
2341 fn eq(&self, other: &Self) -> bool {
2342 // Frontend instances share the rest of their fields with the
2343 // originating HttpFrontend; equality is decided by the same fields
2344 // the router uses for de-duplication.
2345 self.cluster_id == other.cluster_id
2346 && self.redirect == other.redirect
2347 && self.redirect_scheme == other.redirect_scheme
2348 && self.redirect_template == other.redirect_template
2349 && self.rewrite_host == other.rewrite_host
2350 && self.rewrite_path == other.rewrite_path
2351 && self.rewrite_port == other.rewrite_port
2352 && self.headers_request == other.headers_request
2353 && self.headers_response == other.headers_response
2354 && self.required_auth == other.required_auth
2355 }
2356}
2357
2358impl Eq for Frontend {}
2359
2360impl std::hash::Hash for Frontend {
2361 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
2362 self.cluster_id.hash(state);
2363 // RedirectPolicy / RedirectScheme are i32-backed proto enums; hash
2364 // them as i32 to avoid requiring a Hash impl on the generated enum.
2365 (self.redirect as i32).hash(state);
2366 (self.redirect_scheme as i32).hash(state);
2367 self.redirect_template.hash(state);
2368 self.required_auth.hash(state);
2369 }
2370}
2371
2372impl PartialOrd for Frontend {
2373 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2374 Some(self.cmp(other))
2375 }
2376}
2377
2378impl Ord for Frontend {
2379 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2380 self.cluster_id
2381 .cmp(&other.cluster_id)
2382 .then_with(|| (self.redirect as i32).cmp(&(other.redirect as i32)))
2383 .then_with(|| (self.redirect_scheme as i32).cmp(&(other.redirect_scheme as i32)))
2384 .then_with(|| self.redirect_template.cmp(&other.redirect_template))
2385 .then_with(|| self.required_auth.cmp(&other.required_auth))
2386 }
2387}
2388
2389impl Frontend {
2390 /// Build a [`Frontend`] from a domain/path rule pair and an
2391 /// [`HttpFrontend`] configuration.
2392 ///
2393 /// The richer proto-level fields (`redirect`, `redirect_scheme`,
2394 /// `redirect_template`, `rewrite_*`, `headers`, `required_auth`) are
2395 /// not yet carried on `HttpFrontend`; until they are, this constructor
2396 /// takes them as explicit arguments so the data flow is testable
2397 /// today and the call sites in `add_http_front` only need a one-line
2398 /// update once the fields are plumbed through.
2399 ///
2400 /// Coercions:
2401 /// - `redirect == UNAUTHORIZED` zeroes out rewrite/headers/auth — the
2402 /// request will be rejected with a 401 regardless.
2403 /// - `redirect == FORWARD` on a clusterless frontend (`cluster_id ==
2404 /// None`) is coerced to `UNAUTHORIZED` (logged as a warning) to
2405 /// avoid a forward loop with no backend.
2406 ///
2407 /// Returns [`RouterError::InvalidHostRewrite`] /
2408 /// [`RouterError::InvalidPathRewrite`] when a rewrite template fails to
2409 /// parse against the rule's capture caps.
2410 #[allow(clippy::too_many_arguments)]
2411 pub fn new(
2412 domain_rule: &DomainRule,
2413 path_rule: &PathRule,
2414 front: &HttpFrontend,
2415 redirect: RedirectPolicy,
2416 redirect_scheme: RedirectScheme,
2417 redirect_template: Option<String>,
2418 rewrite_host: Option<String>,
2419 rewrite_path: Option<String>,
2420 rewrite_port: Option<u16>,
2421 headers: &[sozu_command::proto::command::Header],
2422 required_auth: bool,
2423 hsts_origin: HstsOrigin,
2424 ) -> Result<Self, RouterError> {
2425 // HSTS is read from `front.hsts` directly inside the function;
2426 // an explicit parameter would be redundant since `front` is
2427 // already in scope and the field is the single source of truth.
2428 // The `hsts_origin` parameter records whether the frontend declared
2429 // its own `hsts` (whatever the listener default was at add time) so
2430 // [`Router::refresh_inheriting_hsts`] can reflow listener defaults
2431 // without disturbing explicit per-frontend overrides.
2432 let hsts = front.hsts.as_ref();
2433 let inherits_listener_hsts =
2434 matches!(hsts_origin, HstsOrigin::InheritedFromListenerDefault);
2435 let cluster_id = front.cluster_id.clone();
2436 let tags = front
2437 .tags
2438 .clone()
2439 .map(|tags| Rc::new(CachedTags::new(tags)));
2440
2441 // Coerce clusterless FORWARD to UNAUTHORIZED before doing any
2442 // expensive parsing: those routes can never proceed to a backend, so
2443 // emitting a 401 is the safe default. Empty redirect_template is
2444 // treated as None semantics so we never store an empty Rc<[…]>.
2445 let redirect_template = redirect_template.filter(|s| !s.is_empty());
2446 let rewrite_host = rewrite_host.filter(|s| !s.is_empty());
2447 let rewrite_path = rewrite_path.filter(|s| !s.is_empty());
2448
2449 let deny = match (&cluster_id, redirect) {
2450 (_, RedirectPolicy::Unauthorized) => true,
2451 (None, RedirectPolicy::Forward) => {
2452 let (domain_kind, domain_bytes) = match &domain_rule {
2453 DomainRule::Any => ("any", 0),
2454 DomainRule::Exact(value) => ("exact", value.len()),
2455 DomainRule::Wildcard(value) => ("wildcard", value.len()),
2456 DomainRule::Regex(value) => ("regex", value.as_str().len()),
2457 };
2458 let (path_kind, path_bytes) = match &path_rule {
2459 PathRule::Prefix(value) => ("prefix", value.len()),
2460 PathRule::Regex(value) => ("regex", value.as_str().len()),
2461 PathRule::Equals(value) => ("equals", value.len()),
2462 PathRule::RegexPath(value) => ("regex_path", value.as_str().len()),
2463 };
2464 warn!(
2465 "{} Frontend[domain_kind={}, domain_bytes={}, path_kind={}, path_bytes={}]: forward on clusterless frontends are unauthorized",
2466 log_module_context!(),
2467 domain_kind,
2468 domain_bytes,
2469 path_kind,
2470 path_bytes,
2471 );
2472 true
2473 }
2474 _ => false,
2475 };
2476 if deny {
2477 // The Unauthorized policy zeroes out request rewrites and
2478 // header injections (the request never reaches a backend),
2479 // but RFC 6797 §8.1 still requires HSTS on the 401 default
2480 // answer when the frontend is on an HTTPS listener. Build
2481 // the HSTS edit (when configured) so the per-stream
2482 // snapshot copy in `mux/router.rs` carries it through to
2483 // `set_default_answer_with_retry_after`.
2484 let mut deny_headers_response: Vec<HeaderEdit> = Vec::new();
2485 if let Some(cfg) = hsts
2486 && matches!(cfg.enabled, Some(true))
2487 && let Some(rendered) = render_hsts(cfg)
2488 {
2489 let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2490 HeaderEditMode::Set
2491 } else {
2492 HeaderEditMode::SetIfAbsent
2493 };
2494 deny_headers_response.push(HeaderEdit {
2495 key: Rc::from(&b"strict-transport-security"[..]),
2496 val: rendered.into_bytes().into(),
2497 mode,
2498 });
2499 crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2500 }
2501
2502 return Ok(Self {
2503 cluster_id,
2504 redirect: RedirectPolicy::Unauthorized,
2505 redirect_scheme,
2506 redirect_template: None,
2507 capture_cap_host: 0,
2508 capture_cap_path: 0,
2509 rewrite_host: None,
2510 rewrite_path: None,
2511 rewrite_port: None,
2512 headers_request: Rc::new([]),
2513 headers_response: deny_headers_response.into(),
2514 required_auth,
2515 tags,
2516 inherits_listener_hsts,
2517 });
2518 }
2519
2520 // Capture caps: the maximum index a `$HOST[N]` / `$PATH[N]`
2521 // template can reference for this rule pair. Index 0 is always the
2522 // full hostname/path; subsequent indices are wildcard tail / regex
2523 // groups.
2524 let mut capture_cap_host = match domain_rule {
2525 DomainRule::Any => 1,
2526 DomainRule::Exact(_) => 1,
2527 DomainRule::Wildcard(_) => 2,
2528 DomainRule::Regex(regex) => regex.captures_len(),
2529 };
2530 let mut capture_cap_path = match path_rule {
2531 PathRule::Equals(_) => 1,
2532 PathRule::Prefix(_) => 2,
2533 PathRule::Regex(regex) | PathRule::RegexPath(regex) => regex.captures_len(),
2534 };
2535 let mut used_capture_host = 0usize;
2536 let mut used_capture_path = 0usize;
2537 let rewrite_host_parts = if let Some(p) = rewrite_host {
2538 Some(
2539 RewriteParts::parse(
2540 &p,
2541 capture_cap_host,
2542 capture_cap_path,
2543 &mut used_capture_host,
2544 &mut used_capture_path,
2545 )
2546 .ok_or(RouterError::InvalidHostRewrite(p))?,
2547 )
2548 } else {
2549 None
2550 };
2551 let rewrite_path_parts = if let Some(p) = rewrite_path {
2552 Some(
2553 RewriteParts::parse(
2554 &p,
2555 capture_cap_host,
2556 capture_cap_path,
2557 &mut used_capture_host,
2558 &mut used_capture_path,
2559 )
2560 .ok_or(RouterError::InvalidPathRewrite(p))?,
2561 )
2562 } else {
2563 None
2564 };
2565 // Skip capture extraction at lookup time when no template references
2566 // a capture for this dimension.
2567 if used_capture_host == 0 {
2568 capture_cap_host = 0;
2569 }
2570 if used_capture_path == 0 {
2571 capture_cap_path = 0;
2572 }
2573
2574 let mut headers_request = Vec::new();
2575 let mut headers_response = Vec::new();
2576 for header in headers {
2577 let edit = HeaderEdit {
2578 key: header.key.as_bytes().into(),
2579 val: header.val.as_bytes().into(),
2580 mode: HeaderEditMode::Append,
2581 };
2582 match header.position() {
2583 HeaderPosition::Request => headers_request.push(edit),
2584 HeaderPosition::Response => headers_response.push(edit),
2585 HeaderPosition::Both => {
2586 headers_request.push(edit.clone());
2587 headers_response.push(edit);
2588 }
2589 // The proto-default-encoded shape (`position: 0`). The TOML
2590 // loader rejects this case in `parse_header_edit` so the
2591 // path is only reachable via a manually-constructed
2592 // `Header { position: 0, … }` from a buggy or older
2593 // client. Drop the edit rather than guessing a position.
2594 HeaderPosition::Unspecified => {
2595 warn!(
2596 "{} dropping {:?} with HEADER_POSITION_UNSPECIFIED",
2597 log_module_context!(),
2598 header,
2599 );
2600 }
2601 }
2602 }
2603
2604 // Materialise HSTS (RFC 6797) into the response-side header
2605 // collection as a single `SetIfAbsent` edit so an upstream-emitted
2606 // `Strict-Transport-Security` survives unchanged (RFC 6797 §6.1
2607 // single-header requirement). `enabled = Some(false)` is the
2608 // explicit-disable signal — emit nothing. The §7.2 "no STS over
2609 // plaintext HTTP" gate is enforced by the runtime snapshot site,
2610 // which only copies `headers_response` for HTTPS-served requests.
2611 if let Some(cfg) = hsts
2612 && matches!(cfg.enabled, Some(true))
2613 {
2614 if let Some(rendered) = render_hsts(cfg) {
2615 // RFC 6797 §6.1 default: PRESERVE backend-supplied STS
2616 // (SetIfAbsent). Operator opts into harden-centrally
2617 // override via `force_replace_backend = true`, which
2618 // selects `Set` (delete-then-insert) so any backend
2619 // STS is replaced with sozu's rendered policy.
2620 let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2621 HeaderEditMode::Set
2622 } else {
2623 HeaderEditMode::SetIfAbsent
2624 };
2625 headers_response.push(HeaderEdit {
2626 key: Rc::from(&b"strict-transport-security"[..]),
2627 val: rendered.into_bytes().into(),
2628 mode,
2629 });
2630 crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2631 } else {
2632 // Both upstream config layers (FileHstsConfig::to_proto and
2633 // build_hsts_from_cli) substitute DEFAULT_HSTS_MAX_AGE when
2634 // enabled = Some(true) && max_age = None, so reaching this
2635 // branch means a programmatic IPC sender produced an
2636 // ill-formed HstsConfig. Surface it loudly rather than
2637 // silently emitting no header — operators inspecting their
2638 // dashboards for http.hsts.unrendered will catch the bug.
2639 warn!(
2640 "{} HSTS enabled = true on frontend cluster_id_bytes={:?} but render_hsts \
2641 returned None (max_age missing). Frontend will not emit \
2642 Strict-Transport-Security; the config layer that built \
2643 this HstsConfig must substitute DEFAULT_HSTS_MAX_AGE.",
2644 log_module_context!(),
2645 cluster_id.as_deref().map(str::len),
2646 );
2647 crate::incr!(names::http::HSTS_UNRENDERED);
2648 }
2649 }
2650
2651 Ok(Frontend {
2652 cluster_id,
2653 redirect,
2654 redirect_scheme,
2655 redirect_template,
2656 capture_cap_host,
2657 capture_cap_path,
2658 rewrite_host: rewrite_host_parts,
2659 rewrite_path: rewrite_path_parts,
2660 rewrite_port,
2661 headers_request: headers_request.into(),
2662 headers_response: headers_response.into(),
2663 required_auth,
2664 tags,
2665 inherits_listener_hsts,
2666 })
2667 }
2668
2669 /// Build a minimal Frontend that simply forwards to `cluster_id`,
2670 /// with no rewrite / header / auth configuration. Equivalent to a
2671 /// `Route::ClusterId(cluster_id)` lookup-wise (lookup short-circuits
2672 /// to `RouteResult::forward(id)` for both shapes), with the added
2673 /// ability to carry response-header edits — used by
2674 /// [`Router::refresh_inheriting_hsts`] to promote a lightweight
2675 /// `Route::ClusterId` to a `Route::Frontend` carrying just the
2676 /// listener-default HSTS edit. The `inherits_listener_hsts = true`
2677 /// marker lets subsequent listener-default patches keep refreshing
2678 /// the promoted entry.
2679 pub(crate) fn minimal_forward(cluster_id: ClusterId) -> Self {
2680 Self {
2681 cluster_id: Some(cluster_id),
2682 redirect: RedirectPolicy::Forward,
2683 redirect_scheme: RedirectScheme::UseSame,
2684 redirect_template: None,
2685 capture_cap_host: 0,
2686 capture_cap_path: 0,
2687 rewrite_host: None,
2688 rewrite_path: None,
2689 rewrite_port: None,
2690 headers_request: Rc::new([]),
2691 headers_response: Rc::new([]),
2692 required_auth: false,
2693 tags: None,
2694 inherits_listener_hsts: true,
2695 }
2696 }
2697
2698 /// Build a minimal clusterless Frontend with the
2699 /// [`RedirectPolicy::Unauthorized`] policy. Equivalent to a
2700 /// `Route::Deny` lookup-wise (both yield a 401 default answer),
2701 /// with the added ability to carry response-header edits — used by
2702 /// [`Router::refresh_inheriting_hsts`] to promote `Route::Deny`
2703 /// entries when the listener-default HSTS becomes enabled, so the
2704 /// 401 default answer carries the `Strict-Transport-Security`
2705 /// header per RFC 6797 §8.1. The `inherits_listener_hsts = true`
2706 /// marker lets subsequent listener-default patches keep refreshing
2707 /// the promoted entry.
2708 pub(crate) fn minimal_deny() -> Self {
2709 Self {
2710 cluster_id: None,
2711 redirect: RedirectPolicy::Unauthorized,
2712 redirect_scheme: RedirectScheme::UseSame,
2713 redirect_template: None,
2714 capture_cap_host: 0,
2715 capture_cap_path: 0,
2716 rewrite_host: None,
2717 rewrite_path: None,
2718 rewrite_port: None,
2719 headers_request: Rc::new([]),
2720 headers_response: Rc::new([]),
2721 required_auth: false,
2722 tags: None,
2723 inherits_listener_hsts: true,
2724 }
2725 }
2726}
2727
2728/// Carry a `REGEX_PATH` request's query string over to its rewritten path
2729/// (sozu-proxy/sozu#1909). The rule matched, and its `$PATH[n]` captured,
2730/// the path alone, so a template cannot reach the query: it is appended,
2731/// `?` included, unless the rewritten path already carries a `?` of its
2732/// own, in which case the template's query is kept as written and the
2733/// original one is dropped. `original_query` is empty for every other rule
2734/// kind and for a request without a query, and nothing is appended.
2735fn append_original_query(mut rewritten: String, original_query: &[u8]) -> String {
2736 debug_assert!(
2737 original_query.is_empty() || original_query[0] == b'?',
2738 "a non-empty original query starts with its `?`",
2739 );
2740 let had_own_query = rewritten.contains('?');
2741 if !original_query.is_empty() && !had_own_query {
2742 // The query half was split off the `&str` the router was handed, on
2743 // the ASCII `?`, so it is valid UTF-8 and `from_utf8` never fails.
2744 rewritten.push_str(from_utf8(original_query).unwrap_or_default());
2745 }
2746 debug_assert!(
2747 had_own_query || rewritten.ends_with(from_utf8(original_query).unwrap_or_default()),
2748 "absent a query of its own, the rewritten path must end with the original query",
2749 );
2750 rewritten
2751}
2752
2753/// Routing decision returned by [`Router::lookup`] and consumed by the
2754/// session layer.
2755///
2756/// Computed from a matched [`Frontend`] by running [`RewriteParts::run`]
2757/// against the captures collected during routing. Legacy [`Route::ClusterId`]
2758/// and [`Route::Deny`] entries synthesize a minimal `RouteResult` with the
2759/// proto enums set to defaults (`FORWARD` / `UNAUTHORIZED`) so existing
2760/// session code keeps working until the mux layer is updated to read every
2761/// `RouteResult` field directly.
2762///
2763/// Implements `PartialEq` for test parity: existing router tests compare
2764/// `router.lookup(...)` against an expected route. Equality compares every
2765/// public field, including the `Rc<[HeaderEdit]>` slices via pointer-or-content
2766/// equality on the slice contents.
2767#[derive(Debug, Clone, PartialEq)]
2768pub struct RouteResult {
2769 pub cluster_id: Option<ClusterId>,
2770 pub redirect: RedirectPolicy,
2771 pub redirect_scheme: RedirectScheme,
2772 pub redirect_template: Option<String>,
2773 pub rewritten_host: Option<String>,
2774 pub rewritten_path: Option<String>,
2775 pub rewritten_port: Option<u16>,
2776 pub headers_request: Rc<[HeaderEdit]>,
2777 pub headers_response: Rc<[HeaderEdit]>,
2778 pub required_auth: bool,
2779 pub tags: Option<Rc<CachedTags>>,
2780}
2781
2782impl RouteResult {
2783 /// Synthesize a `RouteResult` representing a 401 (Deny) decision.
2784 pub fn deny(cluster_id: Option<ClusterId>) -> Self {
2785 Self {
2786 cluster_id,
2787 redirect: RedirectPolicy::Unauthorized,
2788 redirect_scheme: RedirectScheme::UseSame,
2789 redirect_template: None,
2790 rewritten_host: None,
2791 rewritten_path: None,
2792 rewritten_port: None,
2793 headers_request: Rc::new([]),
2794 headers_response: Rc::new([]),
2795 required_auth: false,
2796 tags: None,
2797 }
2798 }
2799
2800 /// Synthesize a `RouteResult` representing a "forward to this cluster"
2801 /// decision (legacy [`Route::ClusterId`] adapter).
2802 pub fn forward(cluster_id: ClusterId) -> Self {
2803 Self {
2804 cluster_id: Some(cluster_id),
2805 redirect: RedirectPolicy::Forward,
2806 redirect_scheme: RedirectScheme::UseSame,
2807 redirect_template: None,
2808 rewritten_host: None,
2809 rewritten_path: None,
2810 rewritten_port: None,
2811 headers_request: Rc::new([]),
2812 headers_response: Rc::new([]),
2813 required_auth: false,
2814 tags: None,
2815 }
2816 }
2817
2818 /// Build a `RouteResult` from a [`Frontend`] and the captures collected
2819 /// for this lookup.
2820 fn from_frontend(
2821 frontend: &Frontend,
2822 captures_host: Vec<&str>,
2823 path: &[u8],
2824 path_rule: &PathRule,
2825 ) -> Self {
2826 // Unauthorized short-circuit: skip the path-capture extraction
2827 // entirely — the response will not consume any rewrite output.
2828 // `headers_response` IS preserved here (unlike `headers_request`)
2829 // so the per-stream snapshot copy in `mux/router.rs` can still
2830 // pick up the per-frontend HSTS edit and inject it on the 401
2831 // default answer (RFC 6797 §8.1 — HSTS applies to all response
2832 // codes, including the proxy's typed unauthorized answer).
2833 if frontend.redirect == RedirectPolicy::Unauthorized {
2834 return Self {
2835 cluster_id: frontend.cluster_id.clone(),
2836 redirect: RedirectPolicy::Unauthorized,
2837 redirect_scheme: frontend.redirect_scheme,
2838 redirect_template: frontend.redirect_template.clone(),
2839 rewritten_host: None,
2840 rewritten_path: None,
2841 rewritten_port: None,
2842 headers_request: Rc::new([]),
2843 headers_response: frontend.headers_response.clone(),
2844 required_auth: frontend.required_auth,
2845 tags: frontend.tags.clone(),
2846 };
2847 }
2848
2849 // A `REGEX_PATH` rule matched the path alone, so every `$PATH[n]`,
2850 // index 0 included, is taken from the path alone too, and the query
2851 // it never saw is carried over to the rewritten path below.
2852 let (path, original_query) = match path_rule {
2853 PathRule::RegexPath(_) => split_query(path),
2854 _ => (path, &path[path.len()..]),
2855 };
2856 let mut captures_path: Vec<&str> = Vec::with_capacity(frontend.capture_cap_path);
2857 if frontend.capture_cap_path > 0 {
2858 captures_path.push(from_utf8(path).unwrap_or_default());
2859 match path_rule {
2860 PathRule::Prefix(prefix) => {
2861 let tail_start = prefix.len().min(path.len());
2862 captures_path.push(from_utf8(&path[tail_start..]).unwrap_or_default());
2863 }
2864 PathRule::Regex(regex) | PathRule::RegexPath(regex) => {
2865 if let Some(caps) = regex.captures(path) {
2866 captures_path.extend(caps.iter().skip(1).map(|c| {
2867 c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2868 .unwrap_or("")
2869 }));
2870 }
2871 }
2872 PathRule::Equals(_) => {}
2873 }
2874 }
2875
2876 Self {
2877 cluster_id: frontend.cluster_id.clone(),
2878 redirect: frontend.redirect,
2879 redirect_scheme: frontend.redirect_scheme,
2880 redirect_template: frontend.redirect_template.clone(),
2881 rewritten_host: frontend
2882 .rewrite_host
2883 .as_ref()
2884 .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2885 rewritten_path: frontend.rewrite_path.as_ref().map(|rewrite| {
2886 append_original_query(rewrite.run(&captures_host, &captures_path), original_query)
2887 }),
2888 rewritten_port: frontend.rewrite_port,
2889 headers_request: frontend.headers_request.clone(),
2890 headers_response: frontend.headers_response.clone(),
2891 required_auth: frontend.required_auth,
2892 tags: frontend.tags.clone(),
2893 }
2894 }
2895
2896 /// Build a `RouteResult` for a pre/post rule match.
2897 ///
2898 /// Pre/post rules carry the matched [`DomainRule`] directly so we can
2899 /// extract host captures from it without going through the trie.
2900 fn new_no_trie<'a>(
2901 domain: &'a [u8],
2902 domain_rule: &DomainRule,
2903 path: &'a [u8],
2904 path_rule: &PathRule,
2905 route: &Route,
2906 ) -> Self {
2907 let frontend = match route {
2908 Route::Frontend(f) => f.clone(),
2909 Route::ClusterId(id) => return Self::forward(id.clone()),
2910 Route::Deny => return Self::deny(None),
2911 };
2912 let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2913 if frontend.capture_cap_host > 0 {
2914 captures_host.push(from_utf8(domain).unwrap_or_default());
2915 match domain_rule {
2916 DomainRule::Wildcard(suffix) => {
2917 let head_end = domain.len().saturating_sub(suffix.len().saturating_sub(1));
2918 captures_host.push(from_utf8(&domain[..head_end]).unwrap_or_default());
2919 }
2920 DomainRule::Regex(regex) => {
2921 if let Some(caps) = regex.captures(domain) {
2922 captures_host.extend(caps.iter().skip(1).map(|c| {
2923 c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2924 .unwrap_or("")
2925 }));
2926 }
2927 }
2928 DomainRule::Any | DomainRule::Exact(_) => {}
2929 }
2930 }
2931 Self::from_frontend(&frontend, captures_host, path, path_rule)
2932 }
2933
2934 /// Build a `RouteResult` for a tree-match.
2935 ///
2936 /// Tree matches carry the captures collected by the trie traversal
2937 /// (`InlineTrieMatches`) alongside the matched leaf path rule.
2938 ///
2939 /// The trie records segments in walk order, rightmost label first, so
2940 /// they are read back in reverse: `$HOST[n]` counts left to right, as the
2941 /// whole-host regex of a pre/post rule does (sozu#1595). Each segment's
2942 /// own groups keep their regex order.
2943 fn new_with_trie<'a, 'b>(
2944 domain: &'a [u8],
2945 domain_submatches: &InlineTrieMatches<'a, 'b>,
2946 path: &'a [u8],
2947 path_rule: &PathRule,
2948 route: &Route,
2949 ) -> Self {
2950 let frontend = match route {
2951 Route::Frontend(f) => f.clone(),
2952 Route::ClusterId(id) => return Self::forward(id.clone()),
2953 Route::Deny => return Self::deny(None),
2954 };
2955 let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2956 if frontend.capture_cap_host > 0 {
2957 captures_host.push(from_utf8(domain).unwrap_or_default());
2958 for submatch in domain_submatches.iter().rev() {
2959 match submatch {
2960 TrieSubMatch::Wildcard(part) => {
2961 captures_host.push(from_utf8(part).unwrap_or_default());
2962 }
2963 TrieSubMatch::Regexp(part, regex) => {
2964 if let Some(caps) = regex.captures(part) {
2965 captures_host.extend(caps.iter().skip(1).map(|c| {
2966 c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2967 .unwrap_or("")
2968 }));
2969 }
2970 }
2971 }
2972 }
2973 }
2974 Self::from_frontend(&frontend, captures_host, path, path_rule)
2975 }
2976}
2977
2978#[cfg(test)]
2979mod tests {
2980 use super::*;
2981 use quickcheck::{Arbitrary, Gen, TestResult, quickcheck};
2982
2983 /// A frontend's `method` is classified by the same case-sensitive
2984 /// [`Method::new`] as the request's (sozu-proxy/sozu#1451), so the
2985 /// operator-facing rule is "declare the method in the exact case the
2986 /// client sends". A frontend declared `method = "get"` is a custom-method
2987 /// rule: it no longer answers a canonical `GET` request, and it answers a
2988 /// literal `get` one instead.
2989 #[test]
2990 fn method_rule_is_case_sensitive() {
2991 let declared_lowercase = MethodRule::new(Some("get".to_owned()));
2992
2993 assert_eq!(
2994 declared_lowercase.inner,
2995 Some(Method::Custom("get".to_owned())),
2996 "a lowercase declaration must not collapse onto Method::Get"
2997 );
2998 assert!(
2999 declared_lowercase.matches(&Method::Get) == MethodRuleResult::None,
3000 "a lowercase declaration must not match a canonical GET request"
3001 );
3002 assert!(
3003 declared_lowercase.matches(&Method::new(b"get")) == MethodRuleResult::Equals,
3004 "a lowercase declaration must match a literal lowercase request"
3005 );
3006
3007 let declared_canonical = MethodRule::new(Some("GET".to_owned()));
3008
3009 assert!(
3010 declared_canonical.matches(&Method::Get) == MethodRuleResult::Equals,
3011 "a canonical declaration must still match a canonical GET request"
3012 );
3013 assert!(
3014 declared_canonical.matches(&Method::new(b"get")) == MethodRuleResult::None,
3015 "a canonical declaration must not match a lowercase request"
3016 );
3017 }
3018
3019 fn test_http_frontend() -> HttpFrontend {
3020 HttpFrontend {
3021 cluster_id: Some("cluster".into()),
3022 address: "127.0.0.1:8080"
3023 .parse()
3024 .expect("test frontend address must parse"),
3025 hostname: "example.com".to_owned(),
3026 path: CommandPathRule::prefix("/".to_owned()),
3027 method: None,
3028 position: RulePosition::Tree,
3029 tags: None,
3030 redirect: None,
3031 redirect_scheme: None,
3032 redirect_template: None,
3033 rewrite_host: None,
3034 rewrite_path: None,
3035 rewrite_port: None,
3036 required_auth: None,
3037 headers: Vec::new(),
3038 hsts: None,
3039 header_predicates: Vec::new(),
3040 }
3041 }
3042
3043 #[test]
3044 fn clusterless_forward_warning_redacts_domain_and_path_rules() {
3045 const DOMAIN_SECRET: &str = "clusterless_domain_secret_sentinel";
3046 const PATH_SECRET: &str = "CLUSTERLESS_PATH_SECRET_SENTINEL";
3047
3048 // The hostname stays below `MAX_HOSTNAME_LENGTH` so it reaches the
3049 // code under test instead of the pre-parse bound; the redaction
3050 // property being asserted is length-independent.
3051 let domain = format!("{DOMAIN_SECRET}{}", "x".repeat(2048));
3052 let path = format!("/{PATH_SECRET}{}", "x".repeat(4096));
3053 let domain_len = domain.len();
3054 let path_len = path.len();
3055 let output = crate::capture_test_logs(move || {
3056 let mut router = Router::new();
3057 let mut front = test_http_frontend();
3058 front.cluster_id = None;
3059 front.hostname = domain;
3060 front.path = CommandPathRule::prefix(path);
3061 front.redirect = Some(RedirectPolicy::Forward as i32);
3062 router
3063 .add_http_front(&front)
3064 .expect("clusterless forward must be coerced to unauthorized");
3065 });
3066
3067 for secret in [DOMAIN_SECRET, PATH_SECRET] {
3068 assert!(
3069 !output.contains(secret),
3070 "clusterless-forward warning leaked rule marker {secret}"
3071 );
3072 }
3073 for metadata in [
3074 "domain_kind=exact".to_owned(),
3075 format!("domain_bytes={domain_len}"),
3076 "path_kind=prefix".to_owned(),
3077 format!("path_bytes={path_len}"),
3078 ] {
3079 assert!(
3080 output.contains(&metadata),
3081 "clusterless-forward warning omitted bounded metadata {metadata}: {output}"
3082 );
3083 }
3084 assert!(
3085 output.len() <= 512,
3086 "clusterless-forward warning is not bounded: {} bytes",
3087 output.len()
3088 );
3089 }
3090
3091 #[test]
3092 fn malformed_hsts_warning_redacts_cluster_id() {
3093 const CLUSTER_SECRET: &str = "MALFORMED_HSTS_CLUSTER_SECRET_SENTINEL";
3094
3095 let cluster_id = format!("{CLUSTER_SECRET}{}", "x".repeat(4096));
3096 let cluster_id_len = cluster_id.len();
3097 let output = crate::capture_test_logs(move || {
3098 let mut router = Router::new();
3099 let mut front = test_http_frontend();
3100 front.cluster_id = Some(cluster_id.into());
3101 front.hsts = Some(HstsConfig {
3102 enabled: Some(true),
3103 max_age: None,
3104 include_subdomains: Some(true),
3105 preload: Some(true),
3106 force_replace_backend: Some(false),
3107 });
3108 router
3109 .add_http_front(&front)
3110 .expect("malformed HSTS must preserve routing and omit the header");
3111 });
3112
3113 assert!(
3114 !output.contains(CLUSTER_SECRET),
3115 "malformed-HSTS warning leaked cluster id {CLUSTER_SECRET}"
3116 );
3117 assert!(
3118 output.contains(&format!("cluster_id_bytes=Some({cluster_id_len})")),
3119 "malformed-HSTS warning omitted the bounded cluster id length: {output}"
3120 );
3121 assert!(
3122 output.len() <= 768,
3123 "malformed-HSTS warning is not bounded: {} bytes",
3124 output.len()
3125 );
3126 }
3127
3128 #[test]
3129 fn router_errors_redact_frontend_rule_and_rewrite_fields() {
3130 const PATH_SECRET: &str = "ROUTER_ERROR_PATH_SECRET_SENTINEL";
3131 const HOSTNAME_SECRET: &str = "ROUTER_ERROR_HOSTNAME_SECRET_SENTINEL";
3132 const HOST_REWRITE_SECRET: &str = "ROUTER_ERROR_HOST_REWRITE_SECRET_SENTINEL";
3133 const PATH_REWRITE_SECRET: &str = "ROUTER_ERROR_PATH_REWRITE_SECRET_SENTINEL";
3134
3135 let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3136 let cases = [
3137 (
3138 "path_bytes",
3139 PATH_SECRET,
3140 long_value(PATH_SECRET).len(),
3141 RouterError::InvalidPathRule(long_value(PATH_SECRET)),
3142 ),
3143 (
3144 "hostname_bytes",
3145 HOSTNAME_SECRET,
3146 long_value(HOSTNAME_SECRET).len(),
3147 RouterError::InvalidDomain {
3148 hostname: long_value(HOSTNAME_SECRET),
3149 },
3150 ),
3151 (
3152 "rewrite_host_bytes",
3153 HOST_REWRITE_SECRET,
3154 long_value(HOST_REWRITE_SECRET).len(),
3155 RouterError::InvalidHostRewrite(long_value(HOST_REWRITE_SECRET)),
3156 ),
3157 (
3158 "rewrite_path_bytes",
3159 PATH_REWRITE_SECRET,
3160 long_value(PATH_REWRITE_SECRET).len(),
3161 RouterError::InvalidPathRewrite(long_value(PATH_REWRITE_SECRET)),
3162 ),
3163 ];
3164
3165 for (length_label, secret, value_len, error) in cases {
3166 for (format_label, output) in [
3167 ("Display", error.to_string()),
3168 ("Debug", format!("{error:?}")),
3169 ] {
3170 assert!(
3171 !output.contains(secret),
3172 "RouterError {format_label} leaked frontend marker {secret}"
3173 );
3174 let metadata = format!("{length_label}={value_len}");
3175 assert!(
3176 output.contains(&metadata),
3177 "RouterError {format_label} omitted bounded metadata {metadata}: {output}"
3178 );
3179 assert!(
3180 output.len() <= 256,
3181 "RouterError {format_label} output is not bounded: {} bytes",
3182 output.len()
3183 );
3184 }
3185 }
3186 }
3187
3188 #[test]
3189 fn route_miss_error_retains_inputs_but_bounds_textual_formatting() {
3190 const HOST_SECRET: &str = "ROUTE_MISS_HOST_SECRET_SENTINEL";
3191 const PATH_SECRET: &str = "ROUTE_MISS_PATH_SECRET_SENTINEL";
3192 const METHOD_SECRET: &str = "ROUTE_MISS_METHOD_SECRET_SENTINEL";
3193
3194 let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3195 let host = long_value(HOST_SECRET);
3196 let path = long_value(PATH_SECRET);
3197 let method = Method::Custom(long_value(METHOD_SECRET));
3198 let error = match Router::new().lookup(&host, &path, &method) {
3199 Err(error) => error,
3200 Ok(_) => panic!("empty router must return a route miss"),
3201 };
3202
3203 match &error {
3204 RouterError::RouteNotFound {
3205 host: retained_host,
3206 path: retained_path,
3207 method: retained_method,
3208 } => {
3209 assert_eq!(retained_host, &host);
3210 assert_eq!(retained_path, &path);
3211 assert_eq!(retained_method, &method);
3212 }
3213 other => panic!("expected RouterError::RouteNotFound, got {other:?}"),
3214 }
3215
3216 for output in [error.to_string(), format!("{error:?}")] {
3217 for secret in [HOST_SECRET, PATH_SECRET, METHOD_SECRET] {
3218 assert!(
3219 !output.contains(secret),
3220 "route miss formatting leaked {secret}: {output}"
3221 );
3222 }
3223 for metadata in [
3224 format!("host_bytes={}", host.len()),
3225 format!("path_bytes={}", path.len()),
3226 format!("bytes={}", method.as_ref().len()),
3227 ] {
3228 assert!(
3229 output.contains(&metadata),
3230 "route miss formatting omitted {metadata}: {output}"
3231 );
3232 }
3233 assert!(
3234 output.len() <= 256,
3235 "route miss formatting is not bounded: {} bytes",
3236 output.len()
3237 );
3238 }
3239 }
3240
3241 #[test]
3242 fn render_hsts_max_age_only() {
3243 let cfg = HstsConfig {
3244 enabled: Some(true),
3245 max_age: Some(31_536_000),
3246 include_subdomains: None,
3247 preload: None,
3248 force_replace_backend: None,
3249 };
3250 assert_eq!(render_hsts(&cfg), Some("max-age=31536000".to_owned()));
3251 }
3252
3253 #[test]
3254 fn render_hsts_with_include_subdomains() {
3255 let cfg = HstsConfig {
3256 enabled: Some(true),
3257 max_age: Some(31_536_000),
3258 include_subdomains: Some(true),
3259 preload: None,
3260 force_replace_backend: None,
3261 };
3262 assert_eq!(
3263 render_hsts(&cfg),
3264 Some("max-age=31536000; includeSubDomains".to_owned())
3265 );
3266 }
3267
3268 #[test]
3269 fn render_hsts_with_preload_only() {
3270 let cfg = HstsConfig {
3271 enabled: Some(true),
3272 max_age: Some(63_072_000),
3273 include_subdomains: None,
3274 preload: Some(true),
3275 force_replace_backend: None,
3276 };
3277 assert_eq!(
3278 render_hsts(&cfg),
3279 Some("max-age=63072000; preload".to_owned())
3280 );
3281 }
3282
3283 #[test]
3284 fn render_hsts_full() {
3285 let cfg = HstsConfig {
3286 enabled: Some(true),
3287 max_age: Some(31_536_000),
3288 include_subdomains: Some(true),
3289 preload: Some(true),
3290 force_replace_backend: None,
3291 };
3292 assert_eq!(
3293 render_hsts(&cfg),
3294 Some("max-age=31536000; includeSubDomains; preload".to_owned())
3295 );
3296 }
3297
3298 #[test]
3299 fn render_hsts_kill_switch_max_age_zero() {
3300 let cfg = HstsConfig {
3301 enabled: Some(true),
3302 max_age: Some(0),
3303 include_subdomains: Some(true),
3304 preload: None,
3305 force_replace_backend: None,
3306 };
3307 // `max_age = 0` is the RFC 6797 §11.4 kill switch and renders
3308 // verbatim — UA receives it and stops treating the host as a
3309 // Known HSTS Host.
3310 assert_eq!(
3311 render_hsts(&cfg),
3312 Some("max-age=0; includeSubDomains".to_owned())
3313 );
3314 }
3315
3316 #[test]
3317 fn render_hsts_omitted_when_max_age_missing() {
3318 let cfg = HstsConfig {
3319 enabled: Some(true),
3320 max_age: None,
3321 include_subdomains: Some(true),
3322 preload: None,
3323 force_replace_backend: None,
3324 };
3325 // The TOML loader substitutes the default at config-load; if the
3326 // field reaches `render_hsts` as `None`, suppress emission so a
3327 // malformed wire frame can't accidentally render `max-age=`.
3328 assert_eq!(render_hsts(&cfg), None);
3329 }
3330
3331 #[test]
3332 fn rebuild_with_listener_hsts_replaces_existing_entry() {
3333 // An inheriting frontend whose listener-default HSTS changed
3334 // from 1y → 2y must end up with the 2y entry on its
3335 // headers_response, with no leftover 1y entry.
3336 let frontend = Frontend {
3337 cluster_id: Some("api".into()),
3338 redirect: RedirectPolicy::Forward,
3339 redirect_scheme: RedirectScheme::UseSame,
3340 redirect_template: None,
3341 capture_cap_host: 0,
3342 capture_cap_path: 0,
3343 rewrite_host: None,
3344 rewrite_path: None,
3345 rewrite_port: None,
3346 headers_request: Rc::new([]),
3347 headers_response: Rc::from(vec![
3348 HeaderEdit {
3349 key: Rc::from(&b"x-cache"[..]),
3350 val: Rc::from(&b"hit"[..]),
3351 mode: HeaderEditMode::Append,
3352 },
3353 HeaderEdit {
3354 key: Rc::from(&b"strict-transport-security"[..]),
3355 val: Rc::from(&b"max-age=31536000"[..]),
3356 mode: HeaderEditMode::SetIfAbsent,
3357 },
3358 ]),
3359 required_auth: false,
3360 tags: None,
3361 inherits_listener_hsts: true,
3362 };
3363 let new_hsts = HstsConfig {
3364 enabled: Some(true),
3365 max_age: Some(63_072_000),
3366 include_subdomains: Some(true),
3367 preload: None,
3368 force_replace_backend: None,
3369 };
3370 let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3371 let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3372
3373 let response: Vec<_> = rebuilt.headers_response.iter().collect();
3374 assert_eq!(response.len(), 2, "x-cache + new STS, no leftover STS");
3375 assert_eq!(&*response[0].key, b"x-cache");
3376 assert_eq!(&*response[1].key, b"strict-transport-security");
3377 assert_eq!(
3378 &*response[1].val,
3379 b"max-age=63072000; includeSubDomains".as_slice()
3380 );
3381 assert!(rebuilt.inherits_listener_hsts);
3382 }
3383
3384 #[test]
3385 fn rebuild_with_listener_hsts_strips_when_none() {
3386 // Listener-default HSTS removed → strip the existing STS edit
3387 // and add nothing. Operator response headers stay in place.
3388 let frontend = Frontend {
3389 cluster_id: Some("api".into()),
3390 redirect: RedirectPolicy::Forward,
3391 redirect_scheme: RedirectScheme::UseSame,
3392 redirect_template: None,
3393 capture_cap_host: 0,
3394 capture_cap_path: 0,
3395 rewrite_host: None,
3396 rewrite_path: None,
3397 rewrite_port: None,
3398 headers_request: Rc::new([]),
3399 headers_response: Rc::from(vec![
3400 HeaderEdit {
3401 key: Rc::from(&b"x-cache"[..]),
3402 val: Rc::from(&b"hit"[..]),
3403 mode: HeaderEditMode::Append,
3404 },
3405 HeaderEdit {
3406 key: Rc::from(&b"strict-transport-security"[..]),
3407 val: Rc::from(&b"max-age=31536000"[..]),
3408 mode: HeaderEditMode::SetIfAbsent,
3409 },
3410 ]),
3411 required_auth: false,
3412 tags: None,
3413 inherits_listener_hsts: true,
3414 };
3415 let new_edit = build_listener_hsts_edit(None);
3416 let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3417 let response: Vec<_> = rebuilt.headers_response.iter().collect();
3418 assert_eq!(response.len(), 1);
3419 assert_eq!(&*response[0].key, b"x-cache");
3420 }
3421
3422 #[test]
3423 fn rebuild_with_listener_hsts_disabled_strips() {
3424 // `enabled = Some(false)` is the explicit-disable signal; the
3425 // existing STS entry is dropped and no new one is added.
3426 let frontend = Frontend {
3427 cluster_id: Some("api".into()),
3428 redirect: RedirectPolicy::Forward,
3429 redirect_scheme: RedirectScheme::UseSame,
3430 redirect_template: None,
3431 capture_cap_host: 0,
3432 capture_cap_path: 0,
3433 rewrite_host: None,
3434 rewrite_path: None,
3435 rewrite_port: None,
3436 headers_request: Rc::new([]),
3437 headers_response: Rc::from(vec![HeaderEdit {
3438 key: Rc::from(&b"strict-transport-security"[..]),
3439 val: Rc::from(&b"max-age=31536000"[..]),
3440 mode: HeaderEditMode::SetIfAbsent,
3441 }]),
3442 required_auth: false,
3443 tags: None,
3444 inherits_listener_hsts: true,
3445 };
3446 let new_hsts = HstsConfig {
3447 enabled: Some(false),
3448 max_age: None,
3449 include_subdomains: None,
3450 preload: None,
3451 force_replace_backend: None,
3452 };
3453 let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3454 let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3455 assert_eq!(rebuilt.headers_response.len(), 0);
3456 }
3457
3458 #[test]
3459 fn refresh_inheriting_hsts_skips_explicit_overrides() {
3460 // Two frontends: one inheriting (gets refreshed), one explicit
3461 // override (must NOT change). `Router::refresh_inheriting_hsts`
3462 // returns the count of refreshed entries.
3463 use crate::router::pattern_trie::TrieNode;
3464 let mut router = Router {
3465 pre: Vec::new(),
3466 tree: TrieNode::root(),
3467 post: Vec::new(),
3468 };
3469 let inheriting = Frontend {
3470 cluster_id: Some("api".into()),
3471 redirect: RedirectPolicy::Forward,
3472 redirect_scheme: RedirectScheme::UseSame,
3473 redirect_template: None,
3474 capture_cap_host: 0,
3475 capture_cap_path: 0,
3476 rewrite_host: None,
3477 rewrite_path: None,
3478 rewrite_port: None,
3479 headers_request: Rc::new([]),
3480 headers_response: Rc::from(vec![HeaderEdit {
3481 key: Rc::from(&b"strict-transport-security"[..]),
3482 val: Rc::from(&b"max-age=31536000"[..]),
3483 mode: HeaderEditMode::SetIfAbsent,
3484 }]),
3485 required_auth: false,
3486 tags: None,
3487 inherits_listener_hsts: true,
3488 };
3489 let explicit = Frontend {
3490 cluster_id: Some("legacy".into()),
3491 redirect: RedirectPolicy::Forward,
3492 redirect_scheme: RedirectScheme::UseSame,
3493 redirect_template: None,
3494 capture_cap_host: 0,
3495 capture_cap_path: 0,
3496 rewrite_host: None,
3497 rewrite_path: None,
3498 rewrite_port: None,
3499 headers_request: Rc::new([]),
3500 headers_response: Rc::from(vec![HeaderEdit {
3501 key: Rc::from(&b"strict-transport-security"[..]),
3502 val: Rc::from(&b"max-age=300"[..]),
3503 mode: HeaderEditMode::SetIfAbsent,
3504 }]),
3505 required_auth: false,
3506 tags: None,
3507 inherits_listener_hsts: false,
3508 };
3509 router.pre.push((
3510 DomainRule::Any,
3511 PathRule::Prefix("/api".to_owned()),
3512 MethodRule::new(None),
3513 HeaderRules::default(),
3514 Route::Frontend(Rc::new(inheriting)),
3515 ));
3516 router.post.push((
3517 DomainRule::Any,
3518 PathRule::Prefix("/legacy".to_owned()),
3519 MethodRule::new(None),
3520 HeaderRules::default(),
3521 Route::Frontend(Rc::new(explicit)),
3522 ));
3523
3524 let new_hsts = HstsConfig {
3525 enabled: Some(true),
3526 max_age: Some(63_072_000),
3527 include_subdomains: Some(true),
3528 preload: None,
3529 force_replace_backend: None,
3530 };
3531 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3532 assert_eq!(count, 1, "only the inheriting frontend should refresh");
3533
3534 if let Route::Frontend(rc) = &router.pre[0].4 {
3535 let response: Vec<_> = rc.headers_response.iter().collect();
3536 assert_eq!(
3537 &*response.last().unwrap().val,
3538 b"max-age=63072000; includeSubDomains".as_slice(),
3539 "inheriting frontend's STS must reflect the new listener default"
3540 );
3541 } else {
3542 panic!("pre[0] should be Route::Frontend");
3543 }
3544 if let Route::Frontend(rc) = &router.post[0].4 {
3545 let response: Vec<_> = rc.headers_response.iter().collect();
3546 assert_eq!(
3547 &*response.last().unwrap().val,
3548 b"max-age=300".as_slice(),
3549 "explicit override must be preserved unchanged"
3550 );
3551 } else {
3552 panic!("post[0] should be Route::Frontend");
3553 }
3554 }
3555
3556 #[test]
3557 fn refresh_inheriting_hsts_promotes_clusterid_on_enable() {
3558 // The "no policy" frontend case observed on cleverapps.io shared
3559 // (91k+ frontends, 99 % stored as `Route::ClusterId` with no
3560 // policy fields). Before the fix, `refresh_inheriting_hsts`
3561 // walked only `Route::Frontend` entries and silently skipped
3562 // these — leaving HSTS unapplied across the entire fleet.
3563 // Now the lightweight route is promoted in place to a
3564 // `Route::Frontend` carrying just the HSTS edit; subsequent
3565 // patches refresh the promoted entry through the normal
3566 // `inherits_listener_hsts == true` path.
3567 use crate::router::pattern_trie::TrieNode;
3568 let mut router = Router {
3569 pre: Vec::new(),
3570 tree: TrieNode::root(),
3571 post: vec![(
3572 DomainRule::Any,
3573 PathRule::Prefix("/".to_owned()),
3574 MethodRule::new(None),
3575 HeaderRules::default(),
3576 Route::ClusterId("api".into()),
3577 )],
3578 };
3579
3580 let new_hsts = HstsConfig {
3581 enabled: Some(true),
3582 max_age: Some(31_536_000),
3583 include_subdomains: Some(true),
3584 preload: None,
3585 force_replace_backend: None,
3586 };
3587 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3588 assert_eq!(count, 1, "the ClusterId entry must be promoted + counted");
3589
3590 let Route::Frontend(rc) = &router.post[0].4 else {
3591 panic!("post[0] should now be Route::Frontend, not the original Route::ClusterId");
3592 };
3593 assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3594 assert_eq!(
3595 rc.redirect,
3596 RedirectPolicy::Forward,
3597 "promoted entry must keep Forward semantics so lookup yields the same backend"
3598 );
3599 assert!(
3600 rc.inherits_listener_hsts,
3601 "promoted entry must mark itself inheriting so the next patch refreshes it"
3602 );
3603 let response: Vec<_> = rc.headers_response.iter().collect();
3604 assert_eq!(
3605 response.len(),
3606 1,
3607 "promoted entry carries exactly one STS edit, no operator headers"
3608 );
3609 assert_eq!(&*response[0].key, b"strict-transport-security");
3610 assert_eq!(
3611 &*response[0].val,
3612 b"max-age=31536000; includeSubDomains".as_slice()
3613 );
3614 }
3615
3616 #[test]
3617 fn refresh_inheriting_hsts_promotes_deny_on_enable() {
3618 // RFC 6797 §8.1: HSTS applies to ALL HTTPS responses, including
3619 // proxy-generated 401s. A `Route::Deny` with no policy field at
3620 // add-time would, before the fix, never get HSTS injected onto
3621 // the 401 default answer even when the listener default
3622 // declared HSTS.
3623 use crate::router::pattern_trie::TrieNode;
3624 let mut router = Router {
3625 pre: Vec::new(),
3626 tree: TrieNode::root(),
3627 post: vec![(
3628 DomainRule::Any,
3629 PathRule::Prefix("/forbidden".to_owned()),
3630 MethodRule::new(None),
3631 HeaderRules::default(),
3632 Route::Deny,
3633 )],
3634 };
3635
3636 let new_hsts = HstsConfig {
3637 enabled: Some(true),
3638 max_age: Some(31_536_000),
3639 include_subdomains: None,
3640 preload: None,
3641 force_replace_backend: None,
3642 };
3643 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3644 assert_eq!(count, 1);
3645
3646 let Route::Frontend(rc) = &router.post[0].4 else {
3647 panic!("post[0] should now be Route::Frontend, not the original Route::Deny");
3648 };
3649 assert_eq!(rc.cluster_id, None, "promoted Deny stays clusterless");
3650 assert_eq!(
3651 rc.redirect,
3652 RedirectPolicy::Unauthorized,
3653 "promoted Deny must keep Unauthorized so lookup yields a 401"
3654 );
3655 assert!(rc.inherits_listener_hsts);
3656 let response: Vec<_> = rc.headers_response.iter().collect();
3657 assert_eq!(response.len(), 1);
3658 assert_eq!(&*response[0].key, b"strict-transport-security");
3659 assert_eq!(&*response[0].val, b"max-age=31536000".as_slice());
3660 }
3661
3662 #[test]
3663 fn refresh_inheriting_hsts_skips_lightweight_on_disable() {
3664 // No HSTS to emit → no allocation of a Route::Frontend just to
3665 // hold an empty headers_response. The lightweight route is
3666 // preserved as-is. Three sub-cases cover the disable surface:
3667 // - new_hsts == None (operator omitted the field — preserve current… but
3668 // this function is called only when the patch DID carry a value, so
3669 // None here represents "block was not present"; lightweight stays).
3670 // - new_hsts == Some(enabled = Some(false)) (explicit kill switch).
3671 // - new_hsts == Some(enabled = Some(true), max_age = None) (malformed
3672 // enable — render_hsts returns None, defense-in-depth gate).
3673 use crate::router::pattern_trie::TrieNode;
3674 let make_router = || Router {
3675 pre: vec![(
3676 DomainRule::Any,
3677 PathRule::Prefix("/".to_owned()),
3678 MethodRule::new(None),
3679 HeaderRules::default(),
3680 Route::ClusterId("api".into()),
3681 )],
3682 tree: TrieNode::root(),
3683 post: vec![(
3684 DomainRule::Any,
3685 PathRule::Prefix("/forbidden".to_owned()),
3686 MethodRule::new(None),
3687 HeaderRules::default(),
3688 Route::Deny,
3689 )],
3690 };
3691
3692 for (label, hsts) in [
3693 ("none", None),
3694 (
3695 "disabled",
3696 Some(HstsConfig {
3697 enabled: Some(false),
3698 max_age: None,
3699 include_subdomains: None,
3700 preload: None,
3701 force_replace_backend: None,
3702 }),
3703 ),
3704 (
3705 "enabled-without-max-age",
3706 Some(HstsConfig {
3707 enabled: Some(true),
3708 max_age: None,
3709 include_subdomains: None,
3710 preload: None,
3711 force_replace_backend: None,
3712 }),
3713 ),
3714 ] {
3715 let mut router = make_router();
3716 let count = router.refresh_inheriting_hsts(hsts.as_ref());
3717 assert_eq!(count, 0, "no promotion expected for {label}");
3718 assert!(
3719 matches!(router.pre[0].4, Route::ClusterId(_)),
3720 "{label}: ClusterId must stay lightweight"
3721 );
3722 assert!(
3723 matches!(router.post[0].4, Route::Deny),
3724 "{label}: Deny must stay lightweight"
3725 );
3726 }
3727 }
3728
3729 #[test]
3730 fn refresh_inheriting_hsts_promoted_entry_refreshes_on_subsequent_patches() {
3731 // First patch promotes ClusterId → Route::Frontend with HSTS.
3732 // Second patch with a different max-age must refresh the
3733 // promoted entry through the normal path-1 branch (no double
3734 // STS edit, no second promotion of an already-promoted entry).
3735 use crate::router::pattern_trie::TrieNode;
3736 let mut router = Router {
3737 pre: Vec::new(),
3738 tree: TrieNode::root(),
3739 post: vec![(
3740 DomainRule::Any,
3741 PathRule::Prefix("/".to_owned()),
3742 MethodRule::new(None),
3743 HeaderRules::default(),
3744 Route::ClusterId("api".into()),
3745 )],
3746 };
3747
3748 let first_patch = HstsConfig {
3749 enabled: Some(true),
3750 max_age: Some(31_536_000),
3751 include_subdomains: None,
3752 preload: None,
3753 force_replace_backend: None,
3754 };
3755 assert_eq!(router.refresh_inheriting_hsts(Some(&first_patch)), 1);
3756
3757 let second_patch = HstsConfig {
3758 enabled: Some(true),
3759 max_age: Some(63_072_000),
3760 include_subdomains: Some(true),
3761 preload: None,
3762 force_replace_backend: None,
3763 };
3764 assert_eq!(
3765 router.refresh_inheriting_hsts(Some(&second_patch)),
3766 1,
3767 "the previously promoted entry must be re-counted via the path-1 branch"
3768 );
3769
3770 let Route::Frontend(rc) = &router.post[0].4 else {
3771 panic!("post[0] should still be Route::Frontend after the second patch");
3772 };
3773 let response: Vec<_> = rc.headers_response.iter().collect();
3774 assert_eq!(
3775 response.len(),
3776 1,
3777 "second patch must REPLACE the existing STS edit, not append a duplicate"
3778 );
3779 assert_eq!(
3780 &*response[0].val,
3781 b"max-age=63072000; includeSubDomains".as_slice()
3782 );
3783 }
3784
3785 #[test]
3786 fn refresh_inheriting_hsts_promoted_entry_loses_hsts_on_disable_patch() {
3787 // After a promotion, a disable patch must strip the STS edit
3788 // through the path-1 branch. The Route::Frontend wrapper stays
3789 // (we don't demote back to Route::ClusterId — the small per-
3790 // request overhead of running through `from_frontend` instead
3791 // of the short-circuit is acceptable, and demotion would
3792 // require carrying additional state).
3793 use crate::router::pattern_trie::TrieNode;
3794 let mut router = Router {
3795 pre: vec![(
3796 DomainRule::Any,
3797 PathRule::Prefix("/".to_owned()),
3798 MethodRule::new(None),
3799 HeaderRules::default(),
3800 Route::ClusterId("api".into()),
3801 )],
3802 tree: TrieNode::root(),
3803 post: Vec::new(),
3804 };
3805
3806 let enable = HstsConfig {
3807 enabled: Some(true),
3808 max_age: Some(31_536_000),
3809 include_subdomains: None,
3810 preload: None,
3811 force_replace_backend: None,
3812 };
3813 assert_eq!(router.refresh_inheriting_hsts(Some(&enable)), 1);
3814
3815 let disable = HstsConfig {
3816 enabled: Some(false),
3817 max_age: None,
3818 include_subdomains: None,
3819 preload: None,
3820 force_replace_backend: None,
3821 };
3822 assert_eq!(
3823 router.refresh_inheriting_hsts(Some(&disable)),
3824 1,
3825 "the promoted entry must still be touched on disable to strip its STS edit"
3826 );
3827
3828 let Route::Frontend(rc) = &router.pre[0].4 else {
3829 panic!("pre[0] should still be Route::Frontend (no demotion)");
3830 };
3831 assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3832 assert_eq!(
3833 rc.headers_response.len(),
3834 0,
3835 "disable patch must strip the STS edit from the promoted entry"
3836 );
3837 }
3838
3839 #[test]
3840 fn refresh_inheriting_hsts_promotes_clusterid_in_trie_on_enable() {
3841 // Trie-leaf coverage for path 2: the existing five tests
3842 // exercise the `pre`/`post` Vecs only, but the same `visit`
3843 // closure runs for tree leaves through
3844 // `tree.for_each_value_mut`. Assert that a `Route::ClusterId`
3845 // sitting in the trie is promoted in place on an enable
3846 // patch, with routing semantics preserved.
3847 use crate::router::pattern_trie::TrieNode;
3848 let mut router = Router {
3849 pre: Vec::new(),
3850 tree: TrieNode::root(),
3851 post: Vec::new(),
3852 };
3853 let path_rule = PathRule::Prefix("/".to_owned());
3854 let method_rule = MethodRule::new(None);
3855 assert!(router.add_tree_rule(
3856 b"example.com",
3857 &path_rule,
3858 &method_rule,
3859 &Route::ClusterId("api".into()),
3860 ));
3861
3862 let new_hsts = HstsConfig {
3863 enabled: Some(true),
3864 max_age: Some(31_536_000),
3865 include_subdomains: Some(true),
3866 preload: None,
3867 force_replace_backend: None,
3868 };
3869 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3870 assert_eq!(
3871 count, 1,
3872 "trie-resident ClusterId must be promoted + counted"
3873 );
3874
3875 let (_, paths) = router
3876 .tree
3877 .domain_lookup_mut(b"example.com", false)
3878 .expect("trie leaf still present after refresh");
3879 assert_eq!(paths.len(), 1);
3880 let Route::Frontend(rc) = &paths[0].3 else {
3881 panic!("trie leaf should now be Route::Frontend, not Route::ClusterId");
3882 };
3883 assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3884 assert_eq!(rc.redirect, RedirectPolicy::Forward);
3885 assert!(rc.inherits_listener_hsts);
3886 let response: Vec<_> = rc.headers_response.iter().collect();
3887 assert_eq!(response.len(), 1);
3888 assert_eq!(&*response[0].key, b"strict-transport-security");
3889 assert_eq!(
3890 &*response[0].val,
3891 b"max-age=31536000; includeSubDomains".as_slice()
3892 );
3893 }
3894
3895 #[test]
3896 fn convert_regex() {
3897 // Compiled regexes are anchored with `\A` … `\z` so `Regex::is_match`
3898 // (unanchored by default) only succeeds on a full-host match, and each
3899 // slash-delimited regex segment is wrapped in a non-capturing group so
3900 // an alternation inside it cannot split those anchors between its
3901 // branches (sozu#1356). A literal segment is not a pattern and is not
3902 // grouped.
3903 assert_eq!(
3904 convert_regex_domain_rule("www.example.com")
3905 .unwrap()
3906 .as_str(),
3907 "\\Awww\\.example\\.com\\z"
3908 );
3909 // A `*` label reaches this function only when the hostname ALSO
3910 // carries a `/` segment, because `DomainRule::from_str` tests
3911 // `contains('/')` first — these two rows exercise the function
3912 // directly. `*` is a LITERAL label here, not a wildcard, and is
3913 // escaped as one. Unescaped it was a regex quantifier over the
3914 // preceding atom: `\A*` repeats a zero-width assertion, so it
3915 // matched empty at any offset and left the whole pattern unanchored
3916 // at its start.
3917 assert_eq!(
3918 convert_regex_domain_rule("*.example.com").unwrap().as_str(),
3919 "\\A\\*\\.example\\.com\\z"
3920 );
3921 assert_eq!(
3922 convert_regex_domain_rule("test.*.example.com")
3923 .unwrap()
3924 .as_str(),
3925 "\\Atest\\.\\*\\.example\\.com\\z"
3926 );
3927 assert_eq!(
3928 convert_regex_domain_rule("css./cdn[a-z0-9]+/.example.com")
3929 .unwrap()
3930 .as_str(),
3931 "\\Acss\\.(?:cdn[a-z0-9]+)\\.example\\.com\\z"
3932 );
3933
3934 assert_eq!(
3935 convert_regex_domain_rule("css./cdn[a-z0-9]+.example.com"),
3936 None
3937 );
3938 assert_eq!(
3939 convert_regex_domain_rule("css./cdn[a-z0-9]+/a.example.com"),
3940 None
3941 );
3942 }
3943
3944 /// Compiled regex rules must reject suffix / prefix matches. Without
3945 /// `\A` / `\z` anchors, `Regex::is_match` treats the pattern as
3946 /// "match anywhere in the haystack", letting `attacker.example.com.evil.org`
3947 /// reach a frontend that only intends to serve `example.com`.
3948 #[test]
3949 fn regex_domain_rule_rejects_suffix_and_prefix() {
3950 let rule: DomainRule = "/example\\.com/".parse().unwrap();
3951 assert!(rule.matches(b"example.com"));
3952 assert!(!rule.matches(b"attacker.example.com"));
3953 assert!(!rule.matches(b"example.com.evil.org"));
3954 assert!(!rule.matches(b"prefixexample.com"));
3955 assert!(!rule.matches(b"example.commercial"));
3956 }
3957
3958 /// A multi-segment regex hostname (alternating regex and literal
3959 /// subdomains) must keep each segment confined: only the first `/`
3960 /// after the opening delimiter closes a regex segment. A missing
3961 /// `break` collapsed every later `/` into the same segment, swallowing
3962 /// the literal `.` separators between them.
3963 #[test]
3964 fn regex_domain_rule_multi_segment_segments_are_isolated() {
3965 let pattern = convert_regex_domain_rule("/seg1/.foo./seg2/.com")
3966 .expect("multi-segment regex hostname must compile");
3967 assert_eq!(pattern.as_str(), "\\A(?:seg1)\\.foo\\.(?:seg2)\\.com\\z");
3968 }
3969
3970 #[test]
3971 fn parse_domain_rule() {
3972 assert_eq!("*".parse::<DomainRule>().unwrap(), DomainRule::Any);
3973 assert_eq!(
3974 "www.example.com".parse::<DomainRule>().unwrap(),
3975 DomainRule::Exact("www.example.com".to_string())
3976 );
3977 assert_eq!(
3978 "*.example.com".parse::<DomainRule>().unwrap(),
3979 DomainRule::Wildcard("*.example.com".to_string())
3980 );
3981 assert_eq!("test.*.example.com".parse::<DomainRule>(), Err(()));
3982 assert_eq!(
3983 "/cdn[0-9]+/.example.com".parse::<DomainRule>().unwrap(),
3984 DomainRule::Regex(Regex::new("\\A(?:cdn[0-9]+)\\.example\\.com\\z").unwrap())
3985 );
3986 }
3987
3988 #[test]
3989 fn match_domain_rule() {
3990 assert!(DomainRule::Any.matches("www.example.com".as_bytes()));
3991 assert!(
3992 DomainRule::Exact("www.example.com".to_string()).matches("www.example.com".as_bytes())
3993 );
3994 assert!(
3995 DomainRule::Wildcard("*.example.com".to_string()).matches("www.example.com".as_bytes())
3996 );
3997 assert!(
3998 !DomainRule::Wildcard("*.example.com".to_string())
3999 .matches("test.www.example.com".as_bytes())
4000 );
4001 assert!(
4002 "/cdn[0-9]+/.example.com"
4003 .parse::<DomainRule>()
4004 .unwrap()
4005 .matches("cdn1.example.com".as_bytes())
4006 );
4007 assert!(
4008 !"/cdn[0-9]+/.example.com"
4009 .parse::<DomainRule>()
4010 .unwrap()
4011 .matches("www.example.com".as_bytes())
4012 );
4013 assert!(
4014 !"/cdn[0-9]+/.example.com"
4015 .parse::<DomainRule>()
4016 .unwrap()
4017 .matches("cdn10.exampleAcom".as_bytes())
4018 );
4019 }
4020
4021 #[test]
4022 fn match_domain_rule_wildcard_short_hostname_does_not_panic() {
4023 let rule = DomainRule::Wildcard("*.foo.example.com".to_string());
4024
4025 // Regression for issue #1223: an empty hostname must not panic and must not match.
4026 assert!(!rule.matches(b""));
4027
4028 // Hostname strictly shorter than the suffix must not panic and must not match.
4029 assert!(!rule.matches(b"a.b"));
4030 assert!(!rule.matches(b"x"));
4031
4032 // Boundary: hostname equal to the suffix (s without the leading '*') has an empty
4033 // leftmost label. RFC 1035 §3.1 forbids empty labels, so reject.
4034 assert!(!rule.matches(b".foo.example.com"));
4035
4036 // Multi-label leftmost prefix (existing intent — single-label only).
4037 assert!(!rule.matches(b"y.x.foo.example.com"));
4038
4039 // Single-label leftmost prefix — must still match (this is the happy case the
4040 // pre-existing match_domain_rule already covers, repeated here for symmetry).
4041 assert!(rule.matches(b"x.foo.example.com"));
4042 }
4043
4044 #[test]
4045 fn router_lookup_wildcard_pre_rule_short_hostname_does_not_panic() {
4046 let mut router = Router::new();
4047
4048 // Wildcard in a pre-rule routes through DomainRule::Wildcard::matches
4049 // (not the trie). This is the path that panicked on issue #1223.
4050 assert!(router.add_pre_rule(
4051 &"*.foo.example.com".parse::<DomainRule>().unwrap(),
4052 &PathRule::Prefix("/".to_string()),
4053 &MethodRule::new(Some("GET".to_string())),
4054 &Route::ClusterId("wildcard".into()),
4055 ));
4056
4057 let method = Method::new(&b"GET"[..]);
4058
4059 // Issue #1223: short hostnames must not panic Router::lookup.
4060 assert!(router.lookup("", "/", &method).is_err());
4061 assert!(router.lookup("x", "/", &method).is_err());
4062 assert!(router.lookup("a.b", "/", &method).is_err());
4063
4064 // Boundary: hostname equal to the suffix has an empty leftmost label.
4065 assert!(router.lookup(".foo.example.com", "/", &method).is_err());
4066
4067 // Happy case: single-label leftmost matches via the pre-rule path.
4068 assert_eq!(
4069 router.lookup("x.foo.example.com", "/", &method),
4070 Ok(RouteResult::forward("wildcard".into()))
4071 );
4072 }
4073
4074 #[test]
4075 fn match_path_rule() {
4076 assert!(PathRule::Prefix("".to_string()).matches("/".as_bytes()) != PathRuleResult::None);
4077 assert!(
4078 PathRule::Prefix("".to_string()).matches("/hello".as_bytes()) != PathRuleResult::None
4079 );
4080 assert!(
4081 PathRule::Prefix("/hello".to_string()).matches("/hello".as_bytes())
4082 != PathRuleResult::None
4083 );
4084 assert!(
4085 PathRule::Prefix("/hello".to_string()).matches("/hello/world".as_bytes())
4086 != PathRuleResult::None
4087 );
4088 assert!(
4089 PathRule::Prefix("/hello".to_string()).matches("/".as_bytes()) == PathRuleResult::None
4090 );
4091 }
4092
4093 /// [io]
4094 /// \
4095 /// [sozu]
4096 /// \
4097 /// [*] <- this wildcard has multiple children
4098 /// / \
4099 /// (base) (api)
4100 #[test]
4101 fn multiple_children_on_a_wildcard() {
4102 let mut router = Router::new();
4103
4104 assert!(router.add_tree_rule(
4105 b"*.sozu.io",
4106 &PathRule::Prefix("".to_string()),
4107 &MethodRule::new(Some("GET".to_string())),
4108 &Route::ClusterId("base".into())
4109 ));
4110 println!("{:#?}", router.tree);
4111 assert_eq!(
4112 router.lookup("www.sozu.io", "/api", &Method::Get),
4113 Ok(RouteResult::forward("base".into()))
4114 );
4115 assert!(router.add_tree_rule(
4116 b"*.sozu.io",
4117 &PathRule::Prefix("/api".to_string()),
4118 &MethodRule::new(Some("GET".to_string())),
4119 &Route::ClusterId("api".into())
4120 ));
4121 println!("{:#?}", router.tree);
4122 assert_eq!(
4123 router.lookup("www.sozu.io", "/ap", &Method::Get),
4124 Ok(RouteResult::forward("base".into()))
4125 );
4126 assert_eq!(
4127 router.lookup("www.sozu.io", "/api", &Method::Get),
4128 Ok(RouteResult::forward("api".into()))
4129 );
4130 }
4131
4132 /// [io]
4133 /// \
4134 /// [sozu] <- this node has multiple children including a wildcard
4135 /// / \
4136 /// (api) [*] <- this wildcard has multiple children
4137 /// \
4138 /// (base)
4139 #[test]
4140 fn multiple_children_including_one_with_wildcard() {
4141 let mut router = Router::new();
4142
4143 assert!(router.add_tree_rule(
4144 b"*.sozu.io",
4145 &PathRule::Prefix("".to_string()),
4146 &MethodRule::new(Some("GET".to_string())),
4147 &Route::ClusterId("base".into())
4148 ));
4149 println!("{:#?}", router.tree);
4150 assert_eq!(
4151 router.lookup("www.sozu.io", "/api", &Method::Get),
4152 Ok(RouteResult::forward("base".into()))
4153 );
4154 assert!(router.add_tree_rule(
4155 b"api.sozu.io",
4156 &PathRule::Prefix("".to_string()),
4157 &MethodRule::new(Some("GET".to_string())),
4158 &Route::ClusterId("api".into())
4159 ));
4160 println!("{:#?}", router.tree);
4161 assert_eq!(
4162 router.lookup("www.sozu.io", "/api", &Method::Get),
4163 Ok(RouteResult::forward("base".into()))
4164 );
4165 assert_eq!(
4166 router.lookup("api.sozu.io", "/api", &Method::Get),
4167 Ok(RouteResult::forward("api".into()))
4168 );
4169 }
4170
4171 /// A malformed hostname arriving from the control plane
4172 /// (`AddHttpFrontend` over the command socket, or a `LoadState`
4173 /// replay) must be REJECTED, never panic the worker. `TrieNode::insert`
4174 /// used to `assert_ne!` on `InsertResult::Failed`, so a frontend whose
4175 /// hostname ended in `/` killed every worker the master fanned the
4176 /// request out to -- and killed them again on each restart replay.
4177 #[test]
4178 fn add_tree_rule_rejects_malformed_hostnames_without_panicking() {
4179 // Every shape here reached `InsertResult::Failed` (or an empty
4180 // `partial_key`) inside `insert_recursive`.
4181 for hostname in [
4182 &b"example.com/"[..],
4183 b"www.example.com/",
4184 b"foo/",
4185 b"a/*/",
4186 b"/",
4187 b"///",
4188 b"abc/[0-9]+/.example.com",
4189 b"/[/.example.com",
4190 // Named as rejected in `doc/configure.md`'s regex-hostname
4191 // section: a leading `.` before a regex segment is an empty label.
4192 b"./test[0-9]/.example.com",
4193 b".example.com",
4194 b".a.b",
4195 b"..",
4196 ] {
4197 let mut router = Router::new();
4198 assert!(
4199 !router.add_tree_rule(
4200 hostname,
4201 &PathRule::Prefix("/".to_string()),
4202 &MethodRule::new(Some("GET".to_string())),
4203 &Route::ClusterId("cluster".into()),
4204 ),
4205 "{:?} must be rejected, not inserted",
4206 String::from_utf8_lossy(hostname),
4207 );
4208 // A rejected add must leave NO trace: the route table is
4209 // exactly as empty as it was before the attempt.
4210 assert!(
4211 router.tree.is_empty(),
4212 "{:?} was rejected but still mutated the route table",
4213 String::from_utf8_lossy(hostname),
4214 );
4215 }
4216 }
4217
4218 /// The same rejection, one layer up: `add_http_front` must surface a
4219 /// `RouterError::AddRoute` so the worker answers the master with a
4220 /// failure instead of dying.
4221 #[test]
4222 fn add_http_front_surfaces_a_malformed_hostname_as_an_error() {
4223 let mut router = Router::new();
4224 let front = HttpFrontend {
4225 hostname: "example.com/".to_owned(),
4226 ..test_http_frontend()
4227 };
4228 assert!(matches!(
4229 router.add_http_front(&front),
4230 Err(RouterError::AddRoute(_))
4231 ));
4232 assert!(router.tree.is_empty());
4233
4234 // A hostname whose last segment is followed by a bare trailing `.`
4235 // is rejected one layer earlier still, by the unconditional
4236 // `DomainRule` parse -- which used to walk out of bounds on it
4237 // (see `domain_rule_rejects_a_trailing_dot_after_a_regex_segment`).
4238 for hostname in ["/a/.", "a/b/.", "x./y/."] {
4239 let mut router = Router::new();
4240 let front = HttpFrontend {
4241 hostname: (*hostname).to_owned(),
4242 ..test_http_frontend()
4243 };
4244 assert!(
4245 matches!(
4246 router.add_http_front(&front),
4247 Err(RouterError::InvalidDomain { .. })
4248 ),
4249 "{hostname:?} must be rejected as an invalid domain, not panic",
4250 );
4251 assert!(router.tree.is_empty());
4252 }
4253 }
4254
4255 /// `convert_regex_domain_rule` used to walk one byte past the end of a
4256 /// hostname whose last segment is followed by a bare trailing `.`
4257 /// (`/a/.`, `a/b/.`, `x./y/.`): the `.` arm of the loop tail advances
4258 /// `index` to `s.len()` and the next iteration indexed `s[index]` out
4259 /// of bounds -- a release panic (slice bounds checks never compile
4260 /// out) reachable from the control plane through the unconditional
4261 /// `DomainRule` parse in `add_http_front`, sidestepping every trie
4262 /// guard.
4263 #[test]
4264 fn domain_rule_rejects_a_trailing_dot_after_a_regex_segment() {
4265 for hostname in ["/a/.", "a/b/.", "/[/.", "x./y/."] {
4266 assert!(
4267 hostname.parse::<DomainRule>().is_err(),
4268 "{hostname:?} must be rejected, not panic",
4269 );
4270 }
4271 // The guard must not disturb the legitimate `.`-anchored
4272 // regex-segment grammar.
4273 assert!("abc./[0-9]+/.example.com".parse::<DomainRule>().is_ok());
4274 }
4275
4276 /// The trie recurses once per label and the regex-segment grammar
4277 /// compiles control-plane-supplied patterns, so both add and remove
4278 /// bound the hostname to [`MAX_HOSTNAME_LENGTH`] before parsing
4279 /// anything: a ~100k-label hostname otherwise aborts the worker with
4280 /// an uncatchable stack overflow, and regex compilation time grows
4281 /// with the pattern (a 2 MiB hostname stalls the single-threaded
4282 /// worker for hundreds of milliseconds before rejection).
4283 #[test]
4284 fn add_http_front_rejects_an_oversized_hostname() {
4285 // At the bound: accepted.
4286 let mut router = Router::new();
4287 let front = HttpFrontend {
4288 hostname: "a".repeat(MAX_HOSTNAME_LENGTH),
4289 ..test_http_frontend()
4290 };
4291 assert!(router.add_http_front(&front).is_ok());
4292
4293 // One byte over: rejected on add AND on remove, before any parse.
4294 let mut router = Router::new();
4295 let front = HttpFrontend {
4296 hostname: "a".repeat(MAX_HOSTNAME_LENGTH + 1),
4297 ..test_http_frontend()
4298 };
4299 assert!(matches!(
4300 router.add_http_front(&front),
4301 Err(RouterError::InvalidDomain { .. })
4302 ));
4303 assert!(matches!(
4304 router.remove_http_front(&front),
4305 Err(RouterError::InvalidDomain { .. })
4306 ));
4307 assert!(router.tree.is_empty());
4308
4309 // A label-count bomb is caught by the same byte bound.
4310 let front = HttpFrontend {
4311 hostname: "a.".repeat(MAX_HOSTNAME_LENGTH),
4312 ..test_http_frontend()
4313 };
4314 assert!(matches!(
4315 router.add_http_front(&front),
4316 Err(RouterError::InvalidDomain { .. })
4317 ));
4318 }
4319
4320 #[test]
4321 fn router_insert_remove_through_regex() {
4322 let mut router = Router::new();
4323
4324 assert!(router.add_tree_rule(
4325 b"www./.*/.io",
4326 &PathRule::Prefix("".to_string()),
4327 &MethodRule::new(Some("GET".to_string())),
4328 &Route::ClusterId("base".into())
4329 ));
4330 println!("{:#?}", router.tree);
4331 assert!(router.add_tree_rule(
4332 b"www.doc./.*/.io",
4333 &PathRule::Prefix("".to_string()),
4334 &MethodRule::new(Some("GET".to_string())),
4335 &Route::ClusterId("doc".into())
4336 ));
4337 println!("{:#?}", router.tree);
4338 assert_eq!(
4339 router.lookup("www.sozu.io", "/", &Method::Get),
4340 Ok(RouteResult::forward("base".into()))
4341 );
4342 assert_eq!(
4343 router.lookup("www.doc.sozu.io", "/", &Method::Get),
4344 Ok(RouteResult::forward("doc".into()))
4345 );
4346 assert!(router.remove_tree_rule(
4347 b"www./.*/.io",
4348 &PathRule::Prefix("".to_string()),
4349 &MethodRule::new(Some("GET".to_string()))
4350 ));
4351 println!("{:#?}", router.tree);
4352 assert!(router.lookup("www.sozu.io", "/", &Method::Get).is_err());
4353 assert_eq!(
4354 router.lookup("www.doc.sozu.io", "/", &Method::Get),
4355 Ok(RouteResult::forward("doc".into()))
4356 );
4357 }
4358
4359 #[test]
4360 fn match_router() {
4361 let mut router = Router::new();
4362
4363 assert!(router.add_pre_rule(
4364 &"*".parse::<DomainRule>().unwrap(),
4365 &PathRule::Prefix("/.well-known/acme-challenge".to_string()),
4366 &MethodRule::new(Some("GET".to_string())),
4367 &Route::ClusterId("acme".into())
4368 ));
4369 assert!(router.add_tree_rule(
4370 "www.example.com".as_bytes(),
4371 &PathRule::Prefix("/".to_string()),
4372 &MethodRule::new(Some("GET".to_string())),
4373 &Route::ClusterId("example".into())
4374 ));
4375 assert!(router.add_tree_rule(
4376 "*.test.example.com".as_bytes(),
4377 &PathRule::Regex(Regex::new("/hello[A-Z]+/").unwrap()),
4378 &MethodRule::new(Some("GET".to_string())),
4379 &Route::ClusterId("examplewildcard".into())
4380 ));
4381 assert!(router.add_tree_rule(
4382 "/test[0-9]/.example.com".as_bytes(),
4383 &PathRule::Prefix("/".to_string()),
4384 &MethodRule::new(Some("GET".to_string())),
4385 &Route::ClusterId("exampleregex".into())
4386 ));
4387
4388 assert_eq!(
4389 router.lookup("www.example.com", "/helloA", &Method::new(&b"GET"[..])),
4390 Ok(RouteResult::forward("example".into()))
4391 );
4392 assert_eq!(
4393 router.lookup(
4394 "www.example.com",
4395 "/.well-known/acme-challenge",
4396 &Method::new(&b"GET"[..])
4397 ),
4398 Ok(RouteResult::forward("acme".into()))
4399 );
4400 assert!(
4401 router
4402 .lookup("www.test.example.com", "/", &Method::new(&b"GET"[..]))
4403 .is_err()
4404 );
4405 assert_eq!(
4406 router.lookup(
4407 "www.test.example.com",
4408 "/helloAB/",
4409 &Method::new(&b"GET"[..])
4410 ),
4411 Ok(RouteResult::forward("examplewildcard".into()))
4412 );
4413 assert_eq!(
4414 router.lookup("test1.example.com", "/helloAB/", &Method::new(&b"GET"[..])),
4415 Ok(RouteResult::forward("exampleregex".into()))
4416 );
4417 }
4418
4419 #[test]
4420 fn has_hostname_checks_tree_pre_and_post() {
4421 let mut router = Router::new();
4422
4423 // Empty router has no hostnames
4424 assert!(!router.has_hostname("www.example.com"));
4425
4426 // Add a tree rule
4427 assert!(router.add_tree_rule(
4428 b"www.example.com",
4429 &PathRule::Prefix("/".to_string()),
4430 &MethodRule::new(Some("GET".to_string())),
4431 &Route::ClusterId("cluster1".into())
4432 ));
4433 assert!(router.has_hostname("www.example.com"));
4434 assert!(!router.has_hostname("api.example.com"));
4435
4436 // Remove the tree rule — hostname should disappear
4437 assert!(router.remove_tree_rule(
4438 b"www.example.com",
4439 &PathRule::Prefix("/".to_string()),
4440 &MethodRule::new(Some("GET".to_string()))
4441 ));
4442 assert!(!router.has_hostname("www.example.com"));
4443
4444 // Add a pre rule with an exact domain
4445 assert!(router.add_pre_rule(
4446 &DomainRule::Exact("api.example.com".to_string()),
4447 &PathRule::Prefix("/".to_string()),
4448 &MethodRule::new(None),
4449 &Route::ClusterId("cluster2".into())
4450 ));
4451 assert!(router.has_hostname("api.example.com"));
4452 assert!(!router.has_hostname("www.example.com"));
4453
4454 // Add a post rule
4455 assert!(router.add_post_rule(
4456 &DomainRule::Exact("cdn.example.com".to_string()),
4457 &PathRule::Prefix("/".to_string()),
4458 &MethodRule::new(None),
4459 &Route::ClusterId("cluster3".into())
4460 ));
4461 assert!(router.has_hostname("cdn.example.com"));
4462
4463 // Remove pre rule, post rule should still be detected
4464 assert!(router.remove_pre_rule(
4465 &DomainRule::Exact("api.example.com".to_string()),
4466 &PathRule::Prefix("/".to_string()),
4467 &MethodRule::new(None),
4468 ));
4469 assert!(!router.has_hostname("api.example.com"));
4470 assert!(router.has_hostname("cdn.example.com"));
4471 }
4472
4473 #[test]
4474 fn has_hostname_false_after_last_route_removed() {
4475 let mut router = Router::new();
4476
4477 // Add two routes for the same hostname with different paths
4478 assert!(router.add_tree_rule(
4479 b"www.example.com",
4480 &PathRule::Prefix("/".to_string()),
4481 &MethodRule::new(Some("GET".to_string())),
4482 &Route::ClusterId("cluster1".into())
4483 ));
4484 assert!(router.add_tree_rule(
4485 b"www.example.com",
4486 &PathRule::Prefix("/api".to_string()),
4487 &MethodRule::new(Some("GET".to_string())),
4488 &Route::ClusterId("cluster2".into())
4489 ));
4490 assert!(router.has_hostname("www.example.com"));
4491
4492 // Remove first route — hostname should still exist
4493 assert!(router.remove_tree_rule(
4494 b"www.example.com",
4495 &PathRule::Prefix("/".to_string()),
4496 &MethodRule::new(Some("GET".to_string()))
4497 ));
4498 assert!(router.has_hostname("www.example.com"));
4499
4500 // Remove second route — hostname should be gone
4501 assert!(router.remove_tree_rule(
4502 b"www.example.com",
4503 &PathRule::Prefix("/api".to_string()),
4504 &MethodRule::new(Some("GET".to_string()))
4505 ));
4506 assert!(!router.has_hostname("www.example.com"));
4507 }
4508 /// The operator-visible half of the trie's leftmost-regex insert gap.
4509 /// A frontend whose leftmost host segment is a regex
4510 /// (`/test[0-9]/.example.com`) could not be added once a DEEPER
4511 /// frontend sharing that segment (`foo./test[0-9]/.example.com`) had
4512 /// already opened it: `TrieNode::insert_recursive` answered `Existing`
4513 /// for a host it stored nowhere.
4514 ///
4515 /// The two profiles failed differently, which is why this test asserts
4516 /// the STORE rather than the return code alone. In debug the
4517 /// post-insert reachability `debug_assert!` below `domain_insert`
4518 /// killed the worker outright — "a freshly inserted tree domain must
4519 /// resolve to its inserted rule" — on a control-plane `AddHttpFrontend`
4520 /// (and again on every `LoadState` replay). In release that assert is
4521 /// compiled out, so `add_tree_rule` returned `true`, the CLI reported
4522 /// OK and `sozu query frontends` listed the route, while the trie never
4523 /// served it.
4524 ///
4525 /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4526 /// `TrieNode::insert_recursive` (`router/pattern_trie.rs`), replace
4527 /// `return t.1.insert_own_value(key, value);` with
4528 /// `return InsertResult::Existing;`. In release the `lookup` assertion
4529 /// below fails with `Err(route_not_found …)`; in debug the
4530 /// `debug_assert!` in `add_tree_rule` panics first at
4531 /// "a freshly inserted tree domain must resolve to its inserted rule"
4532 /// — comment that `debug_assert!` out and debug lands on the same
4533 /// `lookup` assertion release does.
4534 #[test]
4535 fn a_leftmost_regex_frontend_is_addable_after_a_deeper_sibling() {
4536 let path = PathRule::Prefix("/".to_string());
4537 let method = MethodRule::new(Some("GET".to_string()));
4538
4539 // Order A: deeper frontend first — the order that used to lose the
4540 // leftmost host.
4541 let mut router = Router::new();
4542 assert!(router.add_tree_rule(
4543 b"foo./test[0-9]/.example.com",
4544 &path,
4545 &method,
4546 &Route::ClusterId("deeper".into()),
4547 ));
4548 assert!(
4549 router.add_tree_rule(
4550 b"/test[0-9]/.example.com",
4551 &path,
4552 &method,
4553 &Route::ClusterId("leftmost".into()),
4554 ),
4555 "a leftmost-regex frontend must be accepted after a deeper sibling",
4556 );
4557 assert_eq!(
4558 router.lookup("test4.example.com", "/", &Method::Get),
4559 Ok(RouteResult::forward("leftmost".into())),
4560 "the leftmost-regex frontend must actually route, not just report OK",
4561 );
4562 assert_eq!(
4563 router.lookup("foo.test4.example.com", "/", &Method::Get),
4564 Ok(RouteResult::forward("deeper".into())),
4565 "the deeper frontend must still route",
4566 );
4567
4568 // Order B: leftmost frontend first — the order that already worked.
4569 let mut router = Router::new();
4570 assert!(router.add_tree_rule(
4571 b"/test[0-9]/.example.com",
4572 &path,
4573 &method,
4574 &Route::ClusterId("leftmost".into()),
4575 ));
4576 assert!(router.add_tree_rule(
4577 b"foo./test[0-9]/.example.com",
4578 &path,
4579 &method,
4580 &Route::ClusterId("deeper".into()),
4581 ));
4582 assert_eq!(
4583 router.lookup("test4.example.com", "/", &Method::Get),
4584 Ok(RouteResult::forward("leftmost".into())),
4585 );
4586 assert_eq!(
4587 router.lookup("foo.test4.example.com", "/", &Method::Get),
4588 Ok(RouteResult::forward("deeper".into())),
4589 );
4590
4591 // And removal through the public surface still takes exactly one.
4592 assert!(router.remove_tree_rule(b"/test[0-9]/.example.com", &path, &method));
4593 assert!(
4594 router
4595 .lookup("test4.example.com", "/", &Method::Get)
4596 .is_err()
4597 );
4598 assert_eq!(
4599 router.lookup("foo.test4.example.com", "/", &Method::Get),
4600 Ok(RouteResult::forward("deeper".into())),
4601 );
4602 }
4603
4604 /// `add_tree_rule` returning `true` is a claim that the route table now
4605 /// serves the rule. The trie's leftmost-regex gap broke exactly that
4606 /// contract in RELEASE builds, where the post-insert reachability
4607 /// `debug_assert!` is compiled out: the control plane reported success
4608 /// and the data plane never routed the host.
4609 ///
4610 /// This pins the contract itself, so it fails in a release build too —
4611 /// no `debug_assert` is involved in the assertion. Every shape here is
4612 /// one the trie stores through a different arm (literal, wildcard,
4613 /// leftmost regex, deeper regex, regex opened by a deeper sibling), and
4614 /// each is checked BOTH ways round: a reported success must route, and
4615 /// a reported failure must leave nothing behind.
4616 ///
4617 /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4618 /// `TrieNode::insert_recursive`, replace
4619 /// `return t.1.insert_own_value(key, value);` with
4620 /// `return InsertResult::Existing;`. The `/test[0-9]/.example.com` row
4621 /// added after `foo./test[0-9]/.example.com` then reports `true` while
4622 /// `lookup` answers `Err(route_not_found …)`, failing the
4623 /// "reported success but does not route" assertion in release. In debug
4624 /// the `debug_assert!` in `add_tree_rule` fires first with
4625 /// "a freshly inserted tree domain must resolve to its inserted rule".
4626 #[test]
4627 fn add_tree_rule_never_reports_success_for_a_rule_it_did_not_store() {
4628 let path = PathRule::Prefix("/".to_string());
4629 let method = MethodRule::new(Some("GET".to_string()));
4630
4631 // (hostname to add, hostname that must then route)
4632 let sequence: &[(&[u8], &str)] = &[
4633 (b"www.example.com", "www.example.com"),
4634 (b"*.wild.example.com", "any.wild.example.com"),
4635 (b"foo./test[0-9]/.example.com", "foo.test4.example.com"),
4636 // Opened above by its deeper sibling: the regression.
4637 (b"/test[0-9]/.example.com", "test4.example.com"),
4638 (b"bar./test[0-9]/.example.com", "bar.test7.example.com"),
4639 ];
4640
4641 let mut router = Router::new();
4642 for (index, (hostname, routable)) in sequence.iter().enumerate() {
4643 let cluster = ClusterId::from(format!("cluster{index}"));
4644 let added =
4645 router.add_tree_rule(hostname, &path, &method, &Route::ClusterId(cluster.clone()));
4646 let resolved = router.lookup(routable, "/", &Method::Get);
4647 assert!(
4648 added,
4649 "{:?} must be accepted",
4650 String::from_utf8_lossy(hostname),
4651 );
4652 assert_eq!(
4653 resolved,
4654 Ok(RouteResult::forward(cluster)),
4655 "add_tree_rule reported success for {:?} but it does not route",
4656 String::from_utf8_lossy(hostname),
4657 );
4658 }
4659
4660 // The converse half of the contract: a REJECTED add must not be
4661 // reported as stored either, and must leave no route behind.
4662 for hostname in [
4663 &b"example.com/"[..],
4664 b".example.com",
4665 b"abc/[0-9]+/.example.com",
4666 ] {
4667 let mut router = Router::new();
4668 assert!(
4669 !router.add_tree_rule(
4670 hostname,
4671 &path,
4672 &method,
4673 &Route::ClusterId("rejected".into()),
4674 ),
4675 "{:?} must be rejected",
4676 String::from_utf8_lossy(hostname),
4677 );
4678 assert!(
4679 router.tree.is_empty(),
4680 "{:?} was rejected but still mutated the route table",
4681 String::from_utf8_lossy(hostname),
4682 );
4683 }
4684 }
4685
4686 // ---- Rule-ordering contract (`doc/configure.md`, "When declaration order
4687 // decides"). The path rules of one hostname are a flat `Vec` at the trie
4688 // leaf, scanned once in declaration order: `Regex`/`Equals` RETURN on the
4689 // first match, while `Prefix` accumulates the longest. Nothing pinned any
4690 // of that, and the documented rule has been wrong three times, so each
4691 // ordering below is its own case.
4692
4693 /// Declare one tree rule for `www.example.com`. `method` is a PARAMETER,
4694 /// never a constant: whether a rule carries one decides if it ends the
4695 /// lookup scan early, so holding it fixed hides half the orderings below.
4696 fn add_path_rule(
4697 router: &mut Router,
4698 path: PathRule,
4699 method: Option<&str>,
4700 cluster: &str,
4701 ) -> bool {
4702 router.add_tree_rule(
4703 b"www.example.com",
4704 &path,
4705 &MethodRule::new(method.map(str::to_owned)),
4706 &Route::ClusterId(cluster.into()),
4707 )
4708 }
4709
4710 /// The cluster a `GET` for `path` on `www.example.com` resolves to.
4711 fn routed_cluster(router: &Router, path: &str) -> Option<String> {
4712 router
4713 .lookup("www.example.com", path, &Method::Get)
4714 .ok()
4715 .and_then(|result| result.cluster_id)
4716 .map(|cluster_id| cluster_id.to_string())
4717 }
4718
4719 /// Declare `first` then `second` — each with its own `method` — and resolve
4720 /// `path`. Declaration order is the variable under test, so callers assert
4721 /// both orders.
4722 fn winner_of(
4723 first: (PathRule, Option<&str>, &str),
4724 second: (PathRule, Option<&str>, &str),
4725 path: &str,
4726 ) -> Option<String> {
4727 let mut router = Router::new();
4728 assert!(add_path_rule(&mut router, first.0, first.1, first.2));
4729 assert!(add_path_rule(&mut router, second.0, second.1, second.2));
4730 routed_cluster(&router, path)
4731 }
4732
4733 /// `Some("GET")` — a rule whose method matches the request, so it ends the
4734 /// scan. Spelled out at every call site so the axis stays visible.
4735 const GET: Option<&str> = Some("GET");
4736 /// `None` — a rule with no method. It matches every request but does NOT
4737 /// end the scan.
4738 const NO_METHOD: Option<&str> = None;
4739 /// `Some("POST")` against a `GET` request — a method that is PRESENT and
4740 /// does NOT match, i.e. `MethodRuleResult::None`. The third value of the
4741 /// method axis: such a rule is skipped entirely, whatever its `path_type`.
4742 /// Covering only `GET` and `NO_METHOD` left every claim about this value
4743 /// untested, which is how two false sentences reached the documentation.
4744 const WRONG_METHOD: Option<&str> = Some("POST");
4745
4746 /// Among `PREFIX` path rules the LONGEST match wins, and that is the one
4747 /// genuinely order-independent case in the router.
4748 ///
4749 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4750 /// `PathRuleResult::Prefix(size) => { if size >= prefix_length {` guard
4751 /// with `if matched.is_none() {` so the FIRST matching prefix wins instead
4752 /// of the longest. The `SHORT`-first case below then resolves to `SHORT`.
4753 #[test]
4754 fn the_longest_prefix_wins_whichever_prefix_is_declared_first() {
4755 let short = || PathRule::Prefix("/a".to_owned());
4756 let long = || PathRule::Prefix("/ab".to_owned());
4757
4758 assert_eq!(
4759 winner_of((short(), GET, "SHORT"), (long(), GET, "LONG"), "/abc").as_deref(),
4760 Some("LONG"),
4761 "the longer prefix must win even when the shorter one was declared first",
4762 );
4763 assert_eq!(
4764 winner_of((long(), GET, "LONG"), (short(), GET, "SHORT"), "/abc").as_deref(),
4765 Some("LONG"),
4766 "the longer prefix must win when it was declared first too",
4767 );
4768 }
4769
4770 /// Two `PREFIX` rules can only tie on length by carrying the SAME prefix
4771 /// string and differing on `method` — `add_tree_rule` dedups on
4772 /// `(path, method)`, so that pair coexists. The scan keeps the LAST such
4773 /// rule, because the guard is `size >= prefix_length`, not `>`.
4774 ///
4775 /// This is the subtlest ordering in the router and the easiest to "fix"
4776 /// by accident: tightening `>=` to `>` looks like a harmless no-op that
4777 /// stops a rule overwriting an equally-good one, and silently flips which
4778 /// cluster serves the request.
4779 ///
4780 /// To SEE THIS RED: change `if size >= prefix_length {` to
4781 /// `if size > prefix_length {` in `Router::lookup`'s path-rule loop. Both
4782 /// assertions below then resolve to the FIRST declared rule.
4783 #[test]
4784 fn equal_length_prefixes_differing_only_by_method_resolve_to_the_last_declared() {
4785 let declare = |first_is_get: bool| {
4786 let mut router = Router::new();
4787 let get = (
4788 MethodRule::new(Some("GET".to_owned())),
4789 Route::ClusterId("GET-RULE".into()),
4790 );
4791 let all = (MethodRule::new(None), Route::ClusterId("ALL-RULE".into()));
4792 let (a, b) = if first_is_get { (get, all) } else { (all, get) };
4793 for (method, route) in [a, b] {
4794 assert!(router.add_tree_rule(
4795 b"www.example.com",
4796 &PathRule::Prefix("/ab".to_owned()),
4797 &method,
4798 &route,
4799 ));
4800 }
4801 routed_cluster(&router, "/abc")
4802 };
4803
4804 assert_eq!(
4805 declare(true).as_deref(),
4806 Some("ALL-RULE"),
4807 "the LAST equal-length prefix declared must win (`>=`, not `>`)",
4808 );
4809 assert_eq!(
4810 declare(false).as_deref(),
4811 Some("GET-RULE"),
4812 "the LAST equal-length prefix declared must win in the other order too",
4813 );
4814 }
4815
4816 /// Two overlapping `REGEX` path rules are decided by declaration order:
4817 /// the scan returns on the first match, and nothing weighs one pattern as
4818 /// more specific than another. A wider pattern declared first therefore
4819 /// shadows a narrower one declared later.
4820 ///
4821 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4822 /// `MethodRuleResult::Equals => { return Ok(RouteResult::new_with_trie(..)) }`
4823 /// arm under `PathRuleResult::Regex | PathRuleResult::Equals` with the
4824 /// non-returning body its `MethodRuleResult::All` sibling uses
4825 /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). Losing
4826 /// the early return makes the LAST match win, so both assertions flip.
4827 #[test]
4828 fn with_a_matching_method_the_first_declared_regex_path_rule_wins() {
4829 let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4830 let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4831
4832 assert_eq!(
4833 winner_of((wide(), GET, "WIDE"), (narrow(), GET, "NARROW"), "/abc").as_deref(),
4834 Some("WIDE"),
4835 "the first-declared regex must win even though the later one is narrower",
4836 );
4837 assert_eq!(
4838 winner_of((narrow(), GET, "NARROW"), (wide(), GET, "WIDE"), "/abc").as_deref(),
4839 Some("NARROW"),
4840 "reversing the declaration order reverses the winner",
4841 );
4842 }
4843
4844 /// `EQUALS` holds no priority over `REGEX`: they share one match arm and
4845 /// the scan returns on whichever comes first. An exact-match rule does NOT
4846 /// outrank a pattern that was declared before it.
4847 ///
4848 /// To SEE THIS RED: the same mutation as
4849 /// `with_a_matching_method_the_first_declared_regex_path_rule_wins`
4850 /// — drop the early `return` from the `MethodRuleResult::Equals` arm under
4851 /// `PathRuleResult::Regex | PathRuleResult::Equals`. Both assertions flip
4852 /// to the last declared rule.
4853 #[test]
4854 fn with_a_matching_method_neither_equals_nor_regex_outranks_the_other() {
4855 let equals = || PathRule::Equals("/abc".to_owned());
4856 let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4857
4858 assert_eq!(
4859 winner_of((equals(), GET, "EQUALS"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4860 Some("EQUALS"),
4861 "EQUALS declared first must win",
4862 );
4863 assert_eq!(
4864 winner_of((regex(), GET, "REGEX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4865 Some("REGEX"),
4866 "REGEX declared first must win — EQUALS has no inherent priority",
4867 );
4868 }
4869
4870 /// A matching `EQUALS` or `REGEX` beats a `PREFIX` rule in either order,
4871 /// even when the prefix is the longer, more specific pattern: the early
4872 /// return short-circuits the scan before any prefix accumulation is read.
4873 /// This is the one cross-type precedence the router really does have.
4874 ///
4875 /// To SEE THIS RED: the same mutation again — drop the early `return` from
4876 /// the `MethodRuleResult::Equals` arm under
4877 /// `PathRuleResult::Regex | PathRuleResult::Equals`. That arm's
4878 /// replacement sets `prefix_length = path_b.len()`, so the equally-long
4879 /// `PREFIX` rule then satisfies `size >= prefix_length` and overwrites it;
4880 /// the `PREFIX`-declared-second cases below resolve to `PREFIX`.
4881 #[test]
4882 fn with_a_matching_method_equals_or_regex_beats_a_longer_prefix_either_order() {
4883 let prefix = || PathRule::Prefix("/abc".to_owned());
4884 let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4885 let equals = || PathRule::Equals("/abc".to_owned());
4886
4887 assert_eq!(
4888 winner_of((prefix(), GET, "PREFIX"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4889 Some("REGEX"),
4890 "a regex must beat a longer prefix declared before it",
4891 );
4892 assert_eq!(
4893 winner_of((regex(), GET, "REGEX"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4894 Some("REGEX"),
4895 "a regex must beat a longer prefix declared after it",
4896 );
4897 assert_eq!(
4898 winner_of((prefix(), GET, "PREFIX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4899 Some("EQUALS"),
4900 "an exact match must beat an equally long prefix declared before it",
4901 );
4902 assert_eq!(
4903 winner_of((equals(), GET, "EQUALS"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4904 Some("EQUALS"),
4905 "an exact match must beat an equally long prefix declared after it",
4906 );
4907 }
4908
4909 /// Without a `method`, an `EQUALS` or `REGEX` rule does NOT end the scan:
4910 /// its match is only recorded, and a later match overwrites it. So the
4911 /// ordering is the exact REVERSE of the matching-method case — the LAST
4912 /// declared wins.
4913 ///
4914 /// This is the dimension the first six ordering tests all missed, because
4915 /// their helper hard-coded `Some("GET")`. `HttpFrontend.method` is an
4916 /// `Option<String>` and both production call sites build the rule with
4917 /// `MethodRule::new(front.method.clone())`, so a frontend declared without
4918 /// a method lands here — it is the default shape, not an exotic one.
4919 ///
4920 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, give the
4921 /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4922 /// PathRuleResult::Equals` the early `return` its `MethodRuleResult::Equals`
4923 /// sibling has. Method-less rules then short-circuit too and both
4924 /// assertions flip to the FIRST declared.
4925 #[test]
4926 fn without_a_method_the_last_declared_regex_or_equals_wins_not_the_first() {
4927 let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4928 let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4929 let equals = || PathRule::Equals("/abc".to_owned());
4930
4931 assert_eq!(
4932 winner_of(
4933 (wide(), NO_METHOD, "WIDE"),
4934 (narrow(), NO_METHOD, "NARROW"),
4935 "/abc"
4936 )
4937 .as_deref(),
4938 Some("NARROW"),
4939 "without a method the LAST declared regex must win",
4940 );
4941 assert_eq!(
4942 winner_of(
4943 (narrow(), NO_METHOD, "NARROW"),
4944 (wide(), NO_METHOD, "WIDE"),
4945 "/abc"
4946 )
4947 .as_deref(),
4948 Some("WIDE"),
4949 "reversing the order reverses the winner — still the last declared",
4950 );
4951 assert_eq!(
4952 winner_of(
4953 (equals(), NO_METHOD, "EQUALS"),
4954 (wide(), NO_METHOD, "REGEX"),
4955 "/abc"
4956 )
4957 .as_deref(),
4958 Some("REGEX"),
4959 "without a method an EQUALS declared first loses to a later REGEX",
4960 );
4961 assert_eq!(
4962 winner_of(
4963 (wide(), NO_METHOD, "REGEX"),
4964 (equals(), NO_METHOD, "EQUALS"),
4965 "/abc"
4966 )
4967 .as_deref(),
4968 Some("EQUALS"),
4969 "and the reverse order reverses that too",
4970 );
4971 }
4972
4973 /// Without a `method`, a matching `EQUALS`/`REGEX` does not reliably outrank
4974 /// a `PREFIX` either — the documented cross-type precedence INVERTS. Because
4975 /// the non-returning arm records `prefix_length = path_b.len()`, a `PREFIX`
4976 /// declared afterwards overwrites it exactly when its prefix covers the
4977 /// WHOLE request path; a shorter prefix cannot clear that bar and leaves the
4978 /// regex standing.
4979 ///
4980 /// "Whole request path" means the request-target as it arrives, QUERY
4981 /// STRING INCLUDED — kawa's `parse_origin_form` hands the router
4982 /// `/index.html?k=v#h` verbatim. So the identical configuration routes the
4983 /// opposite way once the client appends `?x=1`: `/abc` no longer spans
4984 /// `/abc?x=1`, the prefix stops clearing the bar, and the regex wins. The
4985 /// last case below pins that, because the worked example in
4986 /// `doc/configure.md` would otherwise teach `/abc` as "the whole path".
4987 ///
4988 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, change the
4989 /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4990 /// PathRuleResult::Equals` from `prefix_length = path_b.len();` to
4991 /// `prefix_length = 0;`. The first two assertions still pass; the third
4992 /// fails first, with `left: Some("SHORT-PREFIX"), right: Some("REGEX")`,
4993 /// because the shorter `/a` prefix now clears the bar. That one mutation
4994 /// also breaks the query-string case below it — delete the third assertion
4995 /// and the fourth fails with `left: Some("PREFIX"), right: Some("REGEX")`,
4996 /// the prefix having overwritten the regex it should no longer span.
4997 #[test]
4998 fn without_a_method_only_a_whole_path_prefix_declared_later_beats_a_regex() {
4999 let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
5000 let whole_path = || PathRule::Prefix("/abc".to_owned());
5001 let shorter = || PathRule::Prefix("/a".to_owned());
5002
5003 assert_eq!(
5004 winner_of(
5005 (regex(), NO_METHOD, "REGEX"),
5006 (whole_path(), NO_METHOD, "PREFIX"),
5007 "/abc"
5008 )
5009 .as_deref(),
5010 Some("PREFIX"),
5011 "a whole-path prefix declared after a method-less regex must overwrite it",
5012 );
5013 assert_eq!(
5014 winner_of(
5015 (whole_path(), NO_METHOD, "PREFIX"),
5016 (regex(), NO_METHOD, "REGEX"),
5017 "/abc"
5018 )
5019 .as_deref(),
5020 Some("REGEX"),
5021 "declared before it, the same prefix loses — the last writer wins",
5022 );
5023 assert_eq!(
5024 winner_of(
5025 (regex(), NO_METHOD, "REGEX"),
5026 (shorter(), NO_METHOD, "SHORT-PREFIX"),
5027 "/abc"
5028 )
5029 .as_deref(),
5030 Some("REGEX"),
5031 "a prefix shorter than the request path must NOT displace the regex",
5032 );
5033
5034 // Same rules, same declaration order — only the request gains a query
5035 // string, and the winner inverts. The request path the router matches
5036 // is `/abc?x=1`, which `/abc` no longer covers.
5037 assert_eq!(
5038 winner_of(
5039 (regex(), NO_METHOD, "REGEX"),
5040 (whole_path(), NO_METHOD, "PREFIX"),
5041 "/abc?x=1"
5042 )
5043 .as_deref(),
5044 Some("REGEX"),
5045 "with a query string the prefix no longer spans the request path, so the regex wins",
5046 );
5047 }
5048
5049 /// A rule carrying a matching `method` beats a method-less one in either
5050 /// declaration order: the matching rule returns immediately, and when the
5051 /// method-less rule runs first it has only recorded a candidate that the
5052 /// return then overrides.
5053 ///
5054 /// To SEE THIS RED: drop the early `return` from the
5055 /// `MethodRuleResult::Equals` arm under `PathRuleResult::Regex |
5056 /// PathRuleResult::Equals` in `Router::lookup` (the same mutation the
5057 /// matching-method tests name). Both rules then merely record, so the last
5058 /// declared wins and the first assertion below fails with
5059 /// `left: Some("ANY-REGEX"), right: Some("GET-REGEX")`.
5060 #[test]
5061 fn a_rule_with_a_matching_method_beats_a_method_less_rule_in_either_order() {
5062 let any = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
5063 let getr = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
5064
5065 assert_eq!(
5066 winner_of(
5067 (getr(), GET, "GET-REGEX"),
5068 (any(), NO_METHOD, "ANY-REGEX"),
5069 "/abc"
5070 )
5071 .as_deref(),
5072 Some("GET-REGEX"),
5073 "a matching-method rule declared first must win",
5074 );
5075 assert_eq!(
5076 winner_of(
5077 (any(), NO_METHOD, "ANY-REGEX"),
5078 (getr(), GET, "GET-REGEX"),
5079 "/abc"
5080 )
5081 .as_deref(),
5082 Some("GET-REGEX"),
5083 "a matching-method rule declared second must still win",
5084 );
5085 }
5086
5087 /// A rule whose `method` is PRESENT but does not match the request
5088 /// (`MethodRuleResult::None`) is skipped outright, so it never competes on
5089 /// path length at all. Both consequences contradict the unqualified
5090 /// "longest prefix wins" reading: a LONGER prefix loses to a shorter one
5091 /// when its method mismatches, and an equal-length tie goes to the FIRST
5092 /// declared rather than the last, because the later one is skipped before
5093 /// the `size >= prefix_length` comparison is ever reached.
5094 ///
5095 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5096 /// `MethodRuleResult::None` arm under `PathRuleResult::Prefix(size)`
5097 /// behave like its `MethodRuleResult::All` sibling
5098 /// (`prefix_length = size; matched = Some((rule, route));`). Non-matching
5099 /// methods then compete: the first assertion resolves to `LONG-POST` and
5100 /// the second to `SECOND-POST`.
5101 #[test]
5102 fn a_prefix_whose_method_does_not_match_is_skipped_so_it_cannot_win_on_length() {
5103 assert_eq!(
5104 winner_of(
5105 (PathRule::Prefix("/a".to_owned()), GET, "SHORT-GET"),
5106 (
5107 PathRule::Prefix("/ab".to_owned()),
5108 WRONG_METHOD,
5109 "LONG-POST"
5110 ),
5111 "/abc",
5112 )
5113 .as_deref(),
5114 Some("SHORT-GET"),
5115 "a longer prefix whose method mismatches must not beat a shorter matching one",
5116 );
5117 assert_eq!(
5118 winner_of(
5119 (PathRule::Prefix("/ab".to_owned()), GET, "FIRST-GET"),
5120 (
5121 PathRule::Prefix("/ab".to_owned()),
5122 WRONG_METHOD,
5123 "SECOND-POST"
5124 ),
5125 "/abc",
5126 )
5127 .as_deref(),
5128 Some("FIRST-GET"),
5129 "an equal-length tie goes to the FIRST when the later rule's method mismatches",
5130 );
5131 }
5132
5133 /// The same third method value, one tier up: an `EQUALS`/`REGEX` rule whose
5134 /// method mismatches does not short-circuit and does not even register as a
5135 /// candidate, so a `PREFIX` that would otherwise lose to it wins.
5136 ///
5137 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5138 /// `MethodRuleResult::None` arm under
5139 /// `PathRuleResult::Regex | PathRuleResult::Equals` behave like its
5140 /// `MethodRuleResult::All` sibling
5141 /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). The
5142 /// mismatching regex then takes the request and the assertion resolves to
5143 /// `WRONG-METHOD-REGEX`.
5144 #[test]
5145 fn a_regex_whose_method_does_not_match_never_beats_a_matching_prefix() {
5146 assert_eq!(
5147 winner_of(
5148 (
5149 PathRule::Regex(Regex::new("/a.*").expect("test regex must compile")),
5150 WRONG_METHOD,
5151 "WRONG-METHOD-REGEX",
5152 ),
5153 (PathRule::Prefix("/a".to_owned()), GET, "MATCHING-PREFIX"),
5154 "/abc",
5155 )
5156 .as_deref(),
5157 Some("MATCHING-PREFIX"),
5158 "a regex whose method mismatches must not short-circuit the scan",
5159 );
5160 }
5161
5162 /// Declare one regex path rule built the way a configured frontend builds
5163 /// it — through `PathRule::from_config`, which is where the anchoring
5164 /// lives — and resolve `path`.
5165 fn regex_rule_routes(pattern: &str, path: &str) -> Option<String> {
5166 let rule = PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
5167 .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"));
5168 let mut router = Router::new();
5169 assert!(add_path_rule(&mut router, rule, GET, "REGEX"));
5170 routed_cluster(&router, path)
5171 }
5172
5173 /// Resolve a table of `(pattern, request path, must route)` triples
5174 /// through [`regex_rule_routes`]. Each row names itself in the failure
5175 /// message, so a red run says which triple broke rather than which
5176 /// assertion index did.
5177 fn assert_regex_routing(cases: &[(&str, &str, bool)]) {
5178 let verdict = |routes: bool| if routes { "match" } else { "no match" };
5179 for &(pattern, path, must_route) in cases {
5180 let routes = regex_rule_routes(pattern, path).is_some();
5181 assert_eq!(
5182 routes,
5183 must_route,
5184 "path regex {pattern:?} against request path {path:?}: \
5185 expected {}, measured {}",
5186 verdict(must_route),
5187 verdict(routes),
5188 );
5189 }
5190 }
5191
5192 /// `path_type = "REGEX"` is anchored at both ends: the configured pattern
5193 /// must match the WHOLE request path, not a substring of it.
5194 ///
5195 /// This test is the INVERSION of the test formerly named
5196 /// `a_path_regex_is_unanchored_and_matches_anywhere_in_the_request_path`
5197 /// — a name no longer in the tree, this one having replaced it —
5198 /// added by #1352 to pin the opposite. That test was correct about the
5199 /// code and is now deliberately obsolete: sozu#1350 asked whether
5200 /// `doc/configure.md` (which has promised `\A...\z` since v2.0.0) or
5201 /// `PathRule::from_config` (a bare `Regex::new` against the substring
5202 /// search `regex::bytes::Regex::is_match`) was the thing to change, and
5203 /// the answer was the code. The old test's own comment said it existed so
5204 /// that the answer would be "a deliberate change, not a silent one" —
5205 /// this is that deliberate change, kept visible by rewriting the test
5206 /// rather than deleting it.
5207 ///
5208 /// `bc` matching `/abcd` is sozu#1350's own example, so it is the case
5209 /// kept here.
5210 ///
5211 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5212 /// make the body `Regex::new(value).ok()`. The first assertion then fails
5213 /// with `left: Some("REGEX"), right: None`.
5214 #[test]
5215 fn a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path() {
5216 assert_eq!(
5217 regex_rule_routes("bc", "/abcd"),
5218 None,
5219 "an anchored path regex must not match in the middle of the request path",
5220 );
5221 assert_eq!(
5222 regex_rule_routes("/ab[cd]", "/abc").as_deref(),
5223 Some("REGEX"),
5224 "a regex spanning the whole request path must still match",
5225 );
5226 assert_eq!(
5227 regex_rule_routes("/ab[cd]", "/abcd"),
5228 None,
5229 "the trailing `\\z` must reject a longer path with a matching head",
5230 );
5231 assert_eq!(
5232 regex_rule_routes("/ab[cd]", "/x/abc"),
5233 None,
5234 "the leading `\\A` must reject a path with a matching tail",
5235 );
5236
5237 // The matched "request path" is the request-target as it arrives,
5238 // query string included, so anchoring bites a query string the way it
5239 // already bit `path_type = "EQUALS"` — the consequence
5240 // `doc/configure.md` now states.
5241 assert_eq!(
5242 regex_rule_routes("/ab[cd]", "/abc?x=1"),
5243 None,
5244 "an anchored regex must not match a path carrying a query string \
5245 unless the pattern accounts for it",
5246 );
5247 assert_eq!(
5248 regex_rule_routes("/ab[cd].*", "/abc?x=1").as_deref(),
5249 Some("REGEX"),
5250 "appending `.*` is what makes such a rule survive a query string",
5251 );
5252 }
5253
5254 /// The narrowing reaches the degenerate pattern too: an empty `REGEX`
5255 /// value compiles (it always has) and used to match every request path,
5256 /// because an empty match at offset 0 is a match. Anchored, it matches the
5257 /// empty path and nothing else. An operator using `path_type = "REGEX"`
5258 /// with an empty `path` as a catch-all loses every request to it.
5259 ///
5260 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5261 /// make the body `Regex::new(value).ok()`. The assertion then fails with
5262 /// `left: Some("REGEX"), right: None`.
5263 #[test]
5264 fn an_empty_path_regex_no_longer_matches_every_request_path() {
5265 assert!(
5266 Regex::new("").is_ok(),
5267 "the empty pattern must stay a VALID regex — this test is about \
5268 what it matches, not about rejecting it",
5269 );
5270 assert_eq!(
5271 regex_rule_routes("", "/abcd"),
5272 None,
5273 "an anchored empty path regex must not match a non-empty path",
5274 );
5275 // "matched every path", measured against the BARE regex of 2.2.1 — a
5276 // control. An empty match at offset 0 is a match, so `is_match`
5277 // succeeded on every input; that is what made an empty `REGEX` value
5278 // usable as a catch-all in the first place.
5279 let bare_empty = Regex::new("").expect("the empty pattern compiles");
5280 for every_path in ["/anything", "/abcd", "/", ""] {
5281 assert!(
5282 bare_empty.is_match(every_path.as_bytes()),
5283 "up to 2.2.1 the unwrapped empty pattern matched {every_path:?}, \
5284 as it matched every path",
5285 );
5286 }
5287
5288 assert_eq!(
5289 regex_rule_routes("", "").as_deref(),
5290 Some("REGEX"),
5291 "\"matches only the empty path\" is half a claim without the path \
5292 it does still match",
5293 );
5294 }
5295
5296 /// Anchors an operator wrote by hand keep working, and are neither
5297 /// detected nor stripped: `regex`'s `^`/`$` ARE `\A`/`\z` outside
5298 /// multi-line mode, so the repeated zero-width assertions in
5299 /// `\A(?:^/abc$)\z` collapse to one at each end. Measured rather than
5300 /// assumed — that is what the pattern-string assertion below pins.
5301 ///
5302 /// The third case is the one that changes: a HALF-anchored pattern
5303 /// (`^/abc`, start only) used to match `/abcd` and no longer does. An
5304 /// operator who wrote the leading anchor on the strength of the old
5305 /// unanchored behaviour gets the missing end supplied for them.
5306 ///
5307 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5308 /// make the body `Regex::new(value).ok()`. The half-anchored case then
5309 /// fails with `left: Some("REGEX"), right: None`, and the pattern-string
5310 /// assertion fails too. To see ONLY the pattern-string assertion red,
5311 /// strip a leading `^`/`\A` and a trailing `$`/`\z` from `value` before
5312 /// wrapping it.
5313 #[test]
5314 fn a_hand_written_path_anchor_is_kept_and_double_anchoring_is_harmless() {
5315 for hand_anchored in ["^/abc$", "\\A/abc\\z", "^/abc"] {
5316 assert_eq!(
5317 regex_rule_routes(hand_anchored, "/abc").as_deref(),
5318 Some("REGEX"),
5319 "{hand_anchored} must still match the path it was written for",
5320 );
5321 assert_eq!(
5322 regex_rule_routes(hand_anchored, "/abcd"),
5323 None,
5324 "{hand_anchored} must not match a longer path",
5325 );
5326 assert_eq!(
5327 regex_rule_routes(hand_anchored, "/x/abc"),
5328 None,
5329 "{hand_anchored} must not match a path with a matching tail",
5330 );
5331 }
5332
5333 assert_eq!(
5334 PathRule::anchored_regex("^/abc$")
5335 .expect("a hand-anchored pattern must compile")
5336 .as_str(),
5337 "\\A(?:^/abc$)\\z",
5338 "the configured value is wrapped verbatim; no anchor is stripped",
5339 );
5340
5341 // `CHANGELOG.md` and `PathRule::anchored_regex` both quote `/xabcd`
5342 // as the path `\A(?:^/abc$)\z` must not match. It is a positive
5343 // control rather than a reddenable row — `^/abc$` excludes it with or
5344 // without the wrapping — but the quoted example was asserted nowhere.
5345 assert_eq!(
5346 regex_rule_routes("^/abc$", "/xabcd"),
5347 None,
5348 "the example `CHANGELOG.md` quotes verbatim must hold",
5349 );
5350
5351 // The load-bearing half of "`^`/`$` ARE `\A`/`\z`": in the `regex`
5352 // crate's default mode `$` is `\z` and NOT `\Z`, so it does not
5353 // match before a trailing `\n`. Were it `\Z`, the claim that an
5354 // already-anchored `^/abc$` is unchanged by the wrapping would be
5355 // false for exactly one input — a path ending in a newline. Whether a
5356 // client can drive such a request-target past the parser is NOT
5357 // established here, and deliberately so: the wrapping claim has to
5358 // hold on the bytes the router is handed, whatever produced them.
5359 // Measured against the crate directly, so it is a control.
5360 assert!(
5361 !Regex::new("^/abc$")
5362 .expect("the hand-anchored pattern compiles")
5363 .is_match(b"/abc\n"),
5364 "`$` must be `\\z` and not `\\Z`, or wrapping a hand-anchored \
5365 pattern would not be the no-op this test claims",
5366 );
5367 assert_eq!(
5368 regex_rule_routes("^/abc$", "/abc\n"),
5369 None,
5370 "and the wrapped rule must agree with it",
5371 );
5372 }
5373
5374 /// The anchoring wraps the configured value in a NON-CAPTURING GROUP, and
5375 /// that group is load-bearing: `|` binds looser than concatenation, so the
5376 /// ungrouped `\A/a|/b\z` parses as `(\A/a)|(/b\z)` and leaves each branch
5377 /// anchored at one end only. `/a` would still match `/axx`.
5378 ///
5379 /// Two branches only ever exercise the FIRST and LAST branch, so the
5380 /// three-branch case below is what makes `doc/configure.md`'s "every
5381 /// branch […] not just the first and last" a testable sentence: ungrouped,
5382 /// a MIDDLE branch keeps neither anchor and matches as a bare substring
5383 /// anywhere in the path — worse than the half-anchored ends.
5384 ///
5385 /// The hostname sites carried the same gap when this landed and were
5386 /// closed separately by sozu#1356: `convert_regex_domain_rule` now
5387 /// groups each slash-delimited segment, and `pattern_trie.rs`'s
5388 /// `anchored_segment` builds `\A(?:{segment})\z` for the regex fed to
5389 /// `regexp.is_match(segment)`. See
5390 /// `every_branch_of_an_alternating_regex_hostname_rule_is_anchored` and
5391 /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`.
5392 ///
5393 /// To SEE THIS RED: drop the group in `PathRule::anchored_regex` — wrap as
5394 /// `format!("\\A{value}\\z")`, the ungrouped hostname form. The `/axx`
5395 /// assertion then fails with `left: Some("REGEX"), right: None`; with the
5396 /// two-branch rows removed, the three-branch table fails on
5397 /// `"/a|/b|/c"` against `"zzz/bzzz"` with `left: true, right: false`, the
5398 /// middle branch matching in the middle of the path.
5399 #[test]
5400 fn every_branch_of_an_alternating_path_regex_is_anchored() {
5401 for whole in ["/a", "/b"] {
5402 assert_eq!(
5403 regex_rule_routes("/a|/b", whole).as_deref(),
5404 Some("REGEX"),
5405 "{whole} is a whole-path match for one branch and must route",
5406 );
5407 }
5408 assert_eq!(
5409 regex_rule_routes("/a|/b", "/axx"),
5410 None,
5411 "the first branch must be anchored at its END too",
5412 );
5413 assert_eq!(
5414 regex_rule_routes("/a|/b", "/xx/b"),
5415 None,
5416 "the last branch must be anchored at its START too",
5417 );
5418 assert_eq!(
5419 PathRule::anchored_regex("/a|/b")
5420 .expect("an alternation must compile")
5421 .captures_len(),
5422 1,
5423 "the wrapping group must be non-capturing, so `$PATH[n]` indices \
5424 are untouched",
5425 );
5426
5427 // The exact wrapper string, so "grouped" is asserted and not merely
5428 // implied by the three routing verdicts above.
5429 assert_eq!(
5430 PathRule::anchored_regex("/a|/b")
5431 .expect("an alternation must compile")
5432 .as_str(),
5433 "\\A(?:/a|/b)\\z",
5434 "the wrapper must be the NON-capturing group form",
5435 );
5436
5437 // The control for all of the above, built by hand against the `regex`
5438 // crate rather than through `anchored_regex`: the UNGROUPED wrapper —
5439 // the form the hostname sites use — parses as `(\A/a)|(/b\z)` and
5440 // still matches `/axx`. No mutation of `anchored_regex` can move this
5441 // assertion, which is the point: it measures why the group is there.
5442 let ungrouped = Regex::new("\\A/a|/b\\z").expect("the ungrouped wrapper compiles");
5443 assert!(
5444 ungrouped.is_match(b"/axx"),
5445 "{} leaves the first branch anchored at its start only",
5446 ungrouped.as_str(),
5447 );
5448 assert!(
5449 ungrouped.is_match(b"/xx/b"),
5450 "{} leaves the last branch anchored at its end only",
5451 ungrouped.as_str(),
5452 );
5453
5454 // Two branches cannot show what `doc/configure.md` actually claims —
5455 // "every branch of an alternation is anchored, NOT JUST THE FIRST AND
5456 // LAST" — because with two branches there is no other kind. A third
5457 // branch has one, and it is strictly worse than the half-anchored
5458 // ends above: ungrouped, the MIDDLE branch keeps NEITHER anchor, so
5459 // `/b` matches as a bare substring anywhere in the path. Control,
5460 // measured against the crate.
5461 let three = Regex::new("\\A/a|/b|/c\\z").expect("the ungrouped wrapper compiles");
5462 for anywhere in ["zzz/bzzz", "/xx/b/yy", "xx/byy"] {
5463 assert!(
5464 three.is_match(anywhere.as_bytes()),
5465 "{} leaves the MIDDLE branch unanchored at BOTH ends, so it \
5466 matches {anywhere} anywhere in the path",
5467 three.as_str(),
5468 );
5469 }
5470
5471 // The grouped form the router actually builds refuses all three, and
5472 // still matches each branch whole. This half IS production.
5473 assert_regex_routing(&[
5474 ("/a|/b|/c", "/a", true),
5475 ("/a|/b|/c", "/b", true),
5476 ("/a|/b|/c", "/c", true),
5477 ("/a|/b|/c", "zzz/bzzz", false),
5478 ("/a|/b|/c", "/xx/b/yy", false),
5479 ("/a|/b|/c", "xx/byy", false),
5480 ("/a|/b|/c", "/axx", false),
5481 ("/a|/b|/c", "/xx/c", false),
5482 ]);
5483 assert_eq!(
5484 PathRule::anchored_regex("/a|/b|/c")
5485 .expect("a three-branch alternation must compile")
5486 .as_str(),
5487 "\\A(?:/a|/b|/c)\\z",
5488 "a middle branch needs the group as much as the outer two do",
5489 );
5490 }
5491
5492 /// Anchoring must not rescue a pattern the router rejects today. The
5493 /// wrapper supplies one `(` and one `)`, so an unbalanced value can be
5494 /// balanced BY the wrapping — `a)(b` is not a regex, yet `\A(?:a)(b)\z`
5495 /// is, and it matches `ab`. `PathRule::anchored_regex` therefore compiles
5496 /// the configured value on its own before wrapping it.
5497 ///
5498 /// This test covers that one direction. The converse is NOT guarded and
5499 /// is not asserted here: wrapping can reject a pattern that compiles
5500 /// bare, measured on `(?x)/api/v1 # v1 only` (the appended `)` lands in
5501 /// the `#` comment `(?x)` enables) and on a 249-deep nested group (the
5502 /// crate's 250-nesting limit). Both are refused at frontend registration
5503 /// as [`RouterError::InvalidPathRule`], so they cost a loud rejection and
5504 /// not a mis-route — see `PathRule::anchored_regex`.
5505 ///
5506 /// To SEE THIS RED: delete the `Regex::new(value).ok()?;` validation line
5507 /// in `PathRule::anchored_regex`. The `a)(b` assertion then fails — the
5508 /// rule builds, and matches `ab`.
5509 #[test]
5510 fn anchoring_never_turns_a_rejected_path_regex_into_an_accepted_one() {
5511 // The hazard is real, not hypothetical. Both patterns are assembled
5512 // at run time rather than written as literals because
5513 // `clippy::invalid_regex` rejects an invalid literal at a `Regex::new`
5514 // call site — and the invalidity is the whole point here, so the
5515 // literal is what moves, not the lint.
5516 let unbalanced = format!("a{}b", ")(");
5517 assert!(
5518 Regex::new(&unbalanced).is_err(),
5519 "{unbalanced:?} must be an invalid regex for this test to mean anything",
5520 );
5521 let wrapped = Regex::new(&format!("\\A(?:{unbalanced})\\z")).expect(
5522 "the wrapped form of the unbalanced pattern compiles — that balancing \
5523 is what the pre-validation exists to defeat",
5524 );
5525 assert!(
5526 wrapped.is_match(b"ab"),
5527 "{} matches `ab`, which the operator never wrote",
5528 wrapped.as_str(),
5529 );
5530
5531 for invalid in [unbalanced.as_str(), "(", ")", "[a-", "*"] {
5532 assert_eq!(
5533 PathRule::from_config(CommandPathRule::regex(invalid.to_owned())),
5534 None,
5535 "{invalid:?} must stay rejected once the value is anchored",
5536 );
5537 }
5538 }
5539
5540 /// Anchoring narrows `Route::Deny` exactly as it narrows a forward, and
5541 /// that is the dangerous edge of the change: a deny rule written loosely
5542 /// stops covering the family of paths it used to cover. `/admin` denied
5543 /// `/admin`, `/admin/secret` and `/administrator`; anchored it denies
5544 /// `/admin` alone and the rest fall through to whatever else matches.
5545 ///
5546 /// Both rules carry a matching `method`, so the deny is tier 1 in
5547 /// `doc/configure.md`'s precedence list and ends the scan when it matches
5548 /// — the fall-through below is the deny genuinely not matching, not the
5549 /// prefix outranking it.
5550 ///
5551 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5552 /// make the body `Regex::new(value).ok()`. Both fall-through assertions
5553 /// then fail with `left: None, right: Some("FALLBACK")`.
5554 #[test]
5555 fn anchoring_narrows_a_deny_path_regex_to_the_exact_path() {
5556 let mut router = Router::new();
5557 let deny = PathRule::from_config(CommandPathRule::regex("/admin".to_owned()))
5558 .expect("the deny regex path rule must build");
5559 assert!(router.add_tree_rule(
5560 b"www.example.com",
5561 &deny,
5562 &MethodRule::new(Some("GET".to_owned())),
5563 &Route::Deny,
5564 ));
5565 assert!(add_path_rule(
5566 &mut router,
5567 PathRule::Prefix("/".to_owned()),
5568 GET,
5569 "FALLBACK",
5570 ));
5571
5572 let resolve = |path: &str| {
5573 router
5574 .lookup("www.example.com", path, &Method::Get)
5575 .expect("every path below is covered by the `/` prefix rule")
5576 };
5577
5578 let denied = resolve("/admin");
5579 assert_eq!(
5580 denied.redirect,
5581 RedirectPolicy::Unauthorized,
5582 "the exact path the deny regex spells must still be denied",
5583 );
5584 assert_eq!(denied.cluster_id, None);
5585
5586 for still_reaching_the_backend in ["/admin/secret", "/administrator"] {
5587 let result = resolve(still_reaching_the_backend);
5588 assert_eq!(
5589 result.cluster_id.as_deref(),
5590 Some("FALLBACK"),
5591 "BREAKING: the anchored deny no longer covers \
5592 {still_reaching_the_backend}, which now reaches the backend",
5593 );
5594 assert_eq!(result.redirect, RedirectPolicy::Forward);
5595 }
5596 }
5597
5598 /// `doc/configure.md`'s remediation table, rows one and two: a
5599 /// SUBSTRING-shaped or PREFIX-shaped pattern narrows to the exact path it
5600 /// spells, and a TRAILING `.*` is what gives it back — those two shapes
5601 /// are open at the END, so that is the side the wildcard goes on.
5602 ///
5603 /// `bc` against `/abcd` is sozu#1350's own example and is pinned whole in
5604 /// `a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path`;
5605 /// it is repeated here as the table's first row, beside its remediation.
5606 /// The table answers the prefix-shaped row twice, with `.*` or with
5607 /// `path_type = "PREFIX"`; only the regex answer is a regex claim, so only
5608 /// that one is asserted.
5609 ///
5610 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5611 /// make the body `Regex::new(value).ok()`. Every narrowing row below flips
5612 /// to a match; the first failure is `"bc"` against `"/abcd"`.
5613 #[test]
5614 fn a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells() {
5615 assert_regex_routing(&[
5616 // What the two shapes lose.
5617 ("bc", "/abcd", false),
5618 ("/ab", "/abcd", false),
5619 ("/api", "/api/v1", false),
5620 // …and what they keep: the path they spell whole.
5621 ("/api", "/api", true),
5622 // The table says `bc` now matches "only the path `bc`"; that is
5623 // two claims, and this is the half the narrowing rows do not
5624 // cover.
5625 ("bc", "bc", true),
5626 // The documented remediation, on the side each pattern is open on.
5627 (".*bc.*", "/abcd", true),
5628 ("/api.*", "/api/v1", true),
5629 ("/api.*", "/api", true),
5630 ]);
5631
5632 // The table's "used to match" column, measured. These go through the
5633 // BARE, unwrapped regex — the 2.2.1 code — so they are controls: no
5634 // mutation of `anchored_regex` can move them, and that is exactly
5635 // what makes them evidence for what the release took away. Without
5636 // them the whole first column is prose.
5637 for (pattern, used_to_match) in [("bc", "/abcd"), ("/api", "/api/v1")] {
5638 assert!(
5639 Regex::new(pattern)
5640 .expect("the bare pattern compiles")
5641 .is_match(used_to_match.as_bytes()),
5642 "up to 2.2.1 the unwrapped {pattern:?} matched {used_to_match:?} \
5643 as a substring — that is the behaviour this release removes",
5644 );
5645 }
5646 }
5647
5648 /// **The row that breaks silently.** A SUFFIX-shaped pattern is open at
5649 /// the START, so it needs a LEADING `.*`; the trailing one that answers
5650 /// the substring and prefix shapes does nothing for it. Extension routing
5651 /// is plausibly the commonest real use of a path regex, and it does not
5652 /// narrow — it stops matching.
5653 ///
5654 /// Every verdict below is `doc/configure.md`'s third table row and the two
5655 /// paragraphs under it, measured.
5656 ///
5657 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5658 /// make the body `Regex::new(value).ok()`. `"\.js$"` against
5659 /// `"/static/app.js"` fails first, and every other `false` row above the
5660 /// leading-`.*` ones fails with it.
5661 #[test]
5662 fn a_suffix_shaped_path_regex_needs_a_leading_wildcard_not_a_trailing_one() {
5663 assert_regex_routing(&[
5664 // The three the documentation names, all silently dead.
5665 (r"\.js$", "/static/app.js", false),
5666 (r"\.(js|css)$", "/a/b/style.css", false),
5667 ("/v1$", "/api/v1", false),
5668 // `/v1$` is the less dramatic half: it still matches its own path.
5669 ("/v1$", "/v1", true),
5670 // The trailing `.*` that rescues the other two shapes does NOT
5671 // rescue this one.
5672 (r"\.js.*", "/static/app.js", false),
5673 ("/v1.*", "/api/v1", false),
5674 // The leading one does.
5675 (r".*\.js$", "/static/app.js", true),
5676 (r".*\.(js|css)$", "/a/b/style.css", true),
5677 (".*/v1$", "/api/v1", true),
5678 ]);
5679
5680 // "…matches nothing a client can send": an end-anchored pattern with
5681 // nothing in front of it can only match a path that IS its own
5682 // suffix, which for `\.js$` is the literal path `.js`. An origin-form
5683 // request-target always begins with `/`, so no client can send it.
5684 assert_regex_routing(&[(r"\.js$", ".js", true)]);
5685
5686 // The table's third "used to match" cell, measured against the BARE
5687 // regex of 2.2.1 — a control, like the two in
5688 // `a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells`.
5689 // This is the cell that matters most: it is the one whose loss is
5690 // silent.
5691 assert!(
5692 Regex::new(r"\.js$")
5693 .expect("the bare suffix pattern compiles")
5694 .is_match(b"/static/app.js"),
5695 "up to 2.2.1 the unwrapped `\\.js$` DID match `/static/app.js` — \
5696 losing that is the silent break this release documents",
5697 );
5698 for origin_form in ["/", "/.js", "/app.js", "/static/app.js"] {
5699 assert!(
5700 regex_rule_routes(r"\.js$", origin_form).is_none(),
5701 "an origin-form request-target begins with `/`, and {origin_form} \
5702 must not match the end-anchored `\\.js$`",
5703 );
5704 }
5705 }
5706
5707 /// **The `$PATH[n]` regression test.** The anchoring group is `(?:` … `)`
5708 /// and not `(` … `)` precisely so it adds no capture. `Router::lookup`
5709 /// collects path captures as `caps.iter().skip(1)` and `Frontend::new`
5710 /// sizes the buffer from `PathRule::Regex(regex) => regex.captures_len()`,
5711 /// so a CAPTURING wrapper would bump that cap and shift every configured
5712 /// `$PATH[n]` by one: a frontend asking for its first group would start
5713 /// receiving the whole request path, and keep serving traffic while doing
5714 /// it. This is the one property here that corrupts silently rather than
5715 /// stops matching.
5716 ///
5717 /// Asserted three ways: `captures_len()` parity against the bare pattern
5718 /// for zero, one and two groups; end to end, that `rewrite_path` of
5719 /// `$PATH[1]` still receives the first group's text; and that the CAP
5720 /// itself did not grow, by pinning that a `$PATH[n]` one past the last
5721 /// group the operator wrote is still refused.
5722 ///
5723 /// To SEE THIS RED: make the wrapper capturing in
5724 /// `PathRule::anchored_regex` — `format!("\\A({value})\\z")`. The parity
5725 /// assertion fails first with `left: 2, right: 1` on the group-less
5726 /// pattern; with that loop deleted the rewrite yields `/api/v2/users`
5727 /// instead of `v2`, and the out-of-range `$PATH[2]` registers instead of
5728 /// being refused.
5729 #[test]
5730 fn the_anchoring_group_is_non_capturing_so_path_rewrite_indices_do_not_shift() {
5731 for pattern in ["/api/v2/.*", "/api/(v[0-9]+)/.*", "/api/(v[0-9]+)/(.*)"] {
5732 let bare = Regex::new(pattern).expect("the bare pattern must compile");
5733 let anchored =
5734 PathRule::anchored_regex(pattern).expect("the anchored pattern must compile");
5735 assert_eq!(
5736 anchored.captures_len(),
5737 bare.captures_len(),
5738 "anchoring {pattern:?} must not add a capture group",
5739 );
5740 }
5741
5742 let rewriting_frontend = |pattern: &str, rewrite: &str| {
5743 let mut front = test_http_frontend();
5744 front.hostname = "www.example.com".to_owned();
5745 front.path = CommandPathRule::regex(pattern.to_owned());
5746 front.rewrite_path = Some(rewrite.to_owned());
5747 front
5748 };
5749 let rewritten = |pattern: &str, rewrite: &str, path: &str| {
5750 let mut router = Router::new();
5751 router
5752 .add_http_front(&rewriting_frontend(pattern, rewrite))
5753 .expect("the rewriting frontend must register");
5754 router
5755 .lookup("www.example.com", path, &Method::Get)
5756 .expect("the request must route")
5757 .rewritten_path
5758 };
5759
5760 assert_eq!(
5761 rewritten("/api/(v[0-9]+)/.*", "$PATH[1]", "/api/v2/users").as_deref(),
5762 Some("v2"),
5763 "`$PATH[1]` must still be the FIRST group the operator wrote, not \
5764 the anchoring wrapper",
5765 );
5766 assert_eq!(
5767 rewritten("/api/(v[0-9]+)/(.*)", "/$PATH[2]", "/api/v2/users").as_deref(),
5768 Some("/users"),
5769 "`$PATH[2]` must still be the SECOND group the operator wrote",
5770 );
5771 assert_eq!(
5772 rewritten("/api/(v[0-9]+)/.*", "$PATH[0]", "/api/v2/users").as_deref(),
5773 Some("/api/v2/users"),
5774 "`$PATH[0]` stays the whole request path",
5775 );
5776
5777 let mut router = Router::new();
5778 assert!(
5779 matches!(
5780 router.add_http_front(&rewriting_frontend("/api/(v[0-9]+)/.*", "$PATH[2]")),
5781 Err(RouterError::InvalidPathRewrite(_)),
5782 ),
5783 "the capture CAP is `captures_len()` too: a `$PATH[n]` past the \
5784 last group the operator wrote must stay refused",
5785 );
5786 }
5787
5788 /// The pre-validation in `PathRule::anchored_regex` closes
5789 /// invalid-becomes-valid only. The opposite direction is real, documented
5790 /// and NOT closed: a pattern that compiles BARE can be refused once
5791 /// wrapped, because the appended `)\z` has to survive whatever the pattern
5792 /// left open.
5793 ///
5794 /// Both halves are asserted for each case — `Regex::new(value).is_ok()`
5795 /// AND `from_config(…).is_none()` — because the second alone would not
5796 /// show the wrapping is what rejected it.
5797 ///
5798 /// The `249` both `CHANGELOG.md` and `doc/configure.md` quote is measured
5799 /// here rather than derived from the crate's documented limit, and it is
5800 /// the SMALLEST depth with this property: on `regex` 1.13.1 depth 248
5801 /// still survives the wrapping, depth 249 does not, and depth 251 no
5802 /// longer compiles bare either.
5803 ///
5804 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5805 /// make the body `Regex::new(value).ok()`. Both `from_config` assertions
5806 /// fail, each rule building instead of being refused. (The `Regex::new`
5807 /// rows are controls measuring the crate directly and do not move.)
5808 #[test]
5809 fn a_path_regex_that_compiles_bare_can_be_refused_once_it_is_anchored() {
5810 // `(?x)` turns on extended mode, in which `#` starts a comment that
5811 // runs to end of line — and the wrapper's `)` lands inside it.
5812 let extended_mode_comment = "(?x)/api/v1 # v1 only".to_owned();
5813 let nested = |depth: usize| format!("{}{}", "(".repeat(depth), ")".repeat(depth));
5814
5815 for (value, expected_error) in [
5816 (extended_mode_comment, "unclosed group"),
5817 (
5818 nested(249),
5819 "exceed the maximum number of nested parentheses/brackets (250)",
5820 ),
5821 ] {
5822 assert!(
5823 Regex::new(&value).is_ok(),
5824 "this case only means something while the BARE pattern compiles",
5825 );
5826 let error = Regex::new(&format!("\\A(?:{value})\\z"))
5827 .expect_err("the WRAPPED pattern is the one that must fail");
5828 assert!(
5829 error.to_string().contains(expected_error),
5830 "the documentation quotes {expected_error:?}; the wrapping \
5831 actually failed with: {error}",
5832 );
5833 assert_eq!(
5834 PathRule::from_config(CommandPathRule::regex(value)),
5835 None,
5836 "a value only the WRAPPING rejects must still be refused",
5837 );
5838 }
5839
5840 // The boundary itself, so the quoted 249 cannot drift unnoticed.
5841 assert!(
5842 Regex::new(&format!("\\A(?:{})\\z", nested(248))).is_ok(),
5843 "depth 248 must still survive the wrapping, or 249 is not the boundary",
5844 );
5845 assert!(
5846 Regex::new(&nested(250)).is_ok() && Regex::new(&nested(251)).is_err(),
5847 "the BARE limit must still sit between 250 and 251, or the wrapper \
5848 is no longer what makes depth 249 fail",
5849 );
5850 }
5851
5852 /// Every pattern `PathRule::from_config` refuses — bare-invalid or
5853 /// invalid-only-once-wrapped — surfaces at the public registration
5854 /// surface as [`RouterError::InvalidPathRule`]. That is the same
5855 /// `Router::add_http_front` the main process runs through
5856 /// `validate_frontend_request` (`bin/src/command/requests.rs`) before
5857 /// fanning a frontend out, which is what makes such a pattern a loudly
5858 /// rejected frontend and never a main/worker divergence.
5859 ///
5860 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5861 /// make the body `Regex::new(value).ok()`. The last two rows then
5862 /// register successfully. To redden the FIRST row instead, delete the
5863 /// `Regex::new(value).ok()?;` validation line: the wrapper balances
5864 /// `a)(b` and the frontend registers.
5865 #[test]
5866 fn a_refused_path_regex_surfaces_as_an_invalid_path_rule_at_registration() {
5867 // Assembled rather than written as a literal: `clippy::invalid_regex`
5868 // rejects an invalid literal at a `Regex::new` call site, and the
5869 // invalidity is the point.
5870 let unbalanced = format!("a{}b", ")(");
5871 for value in [
5872 unbalanced,
5873 "(?x)/api/v1 # v1 only".to_owned(),
5874 format!("{}{}", "(".repeat(249), ")".repeat(249)),
5875 ] {
5876 let mut router = Router::new();
5877 let mut front = test_http_frontend();
5878 front.path = CommandPathRule::regex(value.clone());
5879 assert!(
5880 matches!(
5881 router.add_http_front(&front),
5882 Err(RouterError::InvalidPathRule(_))
5883 ),
5884 "the path regex {value:?} must be refused as an invalid path rule",
5885 );
5886 }
5887 }
5888
5889 /// The FIRST of the two 401 shapes `doc/configure.md` names: a frontend
5890 /// with no `cluster_id`. It narrows exactly as the
5891 /// `redirect = "unauthorized"` shape does, which is the substance of
5892 /// "both answer 401 … through the very same `path`/`path_type` matching".
5893 ///
5894 /// Asserted through `Router::add_http_front`, because that is where the
5895 /// shape becomes a route and the claim is about the FRONTEND, not about
5896 /// `Route::Deny`. The two sub-shapes take different branches and both are
5897 /// covered here: a bare clusterless frontend sets no policy field, so
5898 /// `add_http_front`'s `has_policy` is false and it maps to `Route::Deny`
5899 /// directly; one that spells `redirect = "forward"` flips `has_policy`,
5900 /// runs `Frontend::new`, and is coerced to unauthorized there instead.
5901 /// Nothing in the anchoring cares which — that is the point.
5902 ///
5903 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5904 /// make the body `Regex::new(value).ok()`. The unanchored `/admin` covers
5905 /// the family again and both fall-through assertions fail with
5906 /// `left: None, right: Some("FALLBACK")`.
5907 #[test]
5908 fn a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule() {
5909 for spells_forward in [false, true] {
5910 let mut router = Router::new();
5911 let mut clusterless = test_http_frontend();
5912 clusterless.hostname = "www.example.com".to_owned();
5913 clusterless.cluster_id = None;
5914 clusterless.path = CommandPathRule::regex("/admin".to_owned());
5915 clusterless.method = Some("GET".to_owned());
5916 if spells_forward {
5917 clusterless.redirect = Some(RedirectPolicy::Forward as i32);
5918 }
5919 router
5920 .add_http_front(&clusterless)
5921 .expect("a clusterless frontend must register as a deny");
5922 assert!(add_path_rule(
5923 &mut router,
5924 PathRule::Prefix("/".to_owned()),
5925 GET,
5926 "FALLBACK",
5927 ));
5928
5929 let resolve = |path: &str| {
5930 router
5931 .lookup("www.example.com", path, &Method::Get)
5932 .expect("every path below is covered by the `/` prefix rule")
5933 };
5934
5935 let denied = resolve("/admin");
5936 assert_eq!(
5937 denied.redirect,
5938 RedirectPolicy::Unauthorized,
5939 "a clusterless frontend must still deny the path it spells \
5940 (spells_forward={spells_forward})",
5941 );
5942 assert_eq!(denied.cluster_id, None);
5943
5944 for now_reaching_the_backend in ["/admin/secret", "/administrator"] {
5945 assert_eq!(
5946 resolve(now_reaching_the_backend).cluster_id.as_deref(),
5947 Some("FALLBACK"),
5948 "BREAKING: the anchored clusterless deny no longer covers \
5949 {now_reaching_the_backend} (spells_forward={spells_forward})",
5950 );
5951 }
5952 }
5953 }
5954
5955 /// The documented remediation for the deny narrowing: a deny-shaped
5956 /// `REGEX` meant as a subtree is widened with a trailing `.*` and covers
5957 /// the family again. It is a regex and not a path-segment rule, so
5958 /// `/admin.*` takes `/administrator` back with `/admin/secret` — widening
5959 /// is not surgical, and that is worth seeing.
5960 ///
5961 /// Declared through `Router::add_http_front` carrying
5962 /// `redirect = "unauthorized"`, which is the SECOND of the two 401 shapes
5963 /// `doc/configure.md` names. The first — a frontend with no `cluster_id` —
5964 /// is covered by
5965 /// `a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule`.
5966 /// `anchoring_narrows_a_deny_path_regex_to_the_exact_path` covers NEITHER
5967 /// frontend shape: it builds `Route::Deny` directly through
5968 /// `add_tree_rule`, which is the route those two shapes resolve TO, and
5969 /// says nothing about either resolving to it.
5970 ///
5971 /// To SEE THIS RED: the anchoring mutation does NOT redden this test, and
5972 /// cannot — `/admin.*` covers these paths anchored or not, which is
5973 /// exactly why it is the remediation. Break the wildcard instead: take the
5974 /// configured value literally in `PathRule::anchored_regex`, as
5975 /// `Regex::new(&format!("\\A(?:{})\\z", regex::escape(value)))`. Every
5976 /// denied path below then falls through to `FALLBACK`.
5977 #[test]
5978 fn widening_a_deny_path_regex_with_a_trailing_wildcard_covers_the_subtree_again() {
5979 let mut router = Router::new();
5980 let mut deny = test_http_frontend();
5981 deny.hostname = "www.example.com".to_owned();
5982 deny.cluster_id = Some("DENIED".into());
5983 deny.path = CommandPathRule::regex("/admin.*".to_owned());
5984 deny.method = Some("GET".to_owned());
5985 deny.redirect = Some(RedirectPolicy::Unauthorized as i32);
5986 router
5987 .add_http_front(&deny)
5988 .expect("the widened deny frontend must register");
5989 assert!(add_path_rule(
5990 &mut router,
5991 PathRule::Prefix("/".to_owned()),
5992 GET,
5993 "FALLBACK",
5994 ));
5995
5996 let resolve = |path: &str| {
5997 router
5998 .lookup("www.example.com", path, &Method::Get)
5999 .expect("every path below is covered by the `/` prefix rule")
6000 };
6001
6002 for denied in ["/admin", "/admin/secret", "/administrator"] {
6003 assert_eq!(
6004 resolve(denied).redirect,
6005 RedirectPolicy::Unauthorized,
6006 "the widened deny must cover {denied} again",
6007 );
6008 }
6009 assert_eq!(
6010 resolve("/other").cluster_id.as_deref(),
6011 Some("FALLBACK"),
6012 "widening the deny must not turn it into a catch-all",
6013 );
6014 assert_eq!(
6015 resolve("/other").redirect,
6016 RedirectPolicy::Forward,
6017 "a path outside the widened family must still be forwarded",
6018 );
6019 }
6020
6021 /// The matched request path is the request-target as it arrives, QUERY
6022 /// STRING INCLUDED, so anchoring bites a query string the way it already
6023 /// bit `path_type = "EQUALS"`. A trailing `.*` answers that only for a
6024 /// pattern open at the END: after a `$` it is dead text, because nothing
6025 /// can follow end-of-text. A suffix rule that must tolerate a query string
6026 /// has to spell it out.
6027 ///
6028 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
6029 /// make the body `Regex::new(value).ok()`. `"/abc"` against `"/abc?x=1"`
6030 /// fails first, and `".*\.js([?].*)?"` against `"/static/app.json"` fails
6031 /// with it, the optional group matching empty after a substring `.js`.
6032 #[test]
6033 fn an_anchored_path_regex_must_span_the_query_string_it_is_matched_against() {
6034 assert_regex_routing(&[
6035 ("/abc", "/abc", true),
6036 ("/abc", "/abc?x=1", false),
6037 // Appending `.*` after a `$` does nothing at all.
6038 ("^/abc$.*", "/abc", true),
6039 ("^/abc$.*", "/abc?x=1", false),
6040 // What a suffix rule surviving a query string has to look like.
6041 (r".*\.js([?].*)?", "/static/app.js", true),
6042 (r".*\.js([?].*)?", "/static/app.js?v=2", true),
6043 (r".*\.js([?].*)?", "/static/app.json", false),
6044 ]);
6045
6046 // "not a regression": the plain suffix rule did not match a
6047 // query-string request before the anchoring either. Measured against
6048 // the BARE, unwrapped regex — the 2.2.1 behaviour — so this row is a
6049 // control and does not move when `anchored_regex` does.
6050 assert!(
6051 !Regex::new(r"\.js$")
6052 .expect("the bare suffix pattern compiles")
6053 .is_match(b"/static/app.js?v=2"),
6054 "the unanchored `\\.js$` of 2.2.1 did not match a query-string \
6055 request either, so that half is not a regression",
6056 );
6057 }
6058
6059 /// Precedence between a method-less `REGEX` and a method-less `PREFIX`
6060 /// turns on whether the regex matches the request path AT ALL, and
6061 /// anchoring changes that once a query string is involved.
6062 ///
6063 /// `doc/configure.md`'s third table row is the `/a.*` case: `GET /abc`
6064 /// goes to the prefix, and `GET /abc?x=1` flips to the regex, because
6065 /// `/abc` no longer spans the request path while `/a.*` still does. With
6066 /// the regex `/abc`, which anchored does not match `/abc?x=1` at all, the
6067 /// flip does NOT happen and both requests go to the prefix — the prefix
6068 /// has merely lost its rival. The regex did not lose the contest, it
6069 /// stopped entering it: declared alone it leaves `GET /abc?x=1` unrouted.
6070 /// That distinction is stated in prose and is what these six cases pin.
6071 ///
6072 /// Both rules are method-less and the regex is declared FIRST, the exact
6073 /// shape the documented table measures; the regex is built through
6074 /// `PathRule::from_config`, so the anchoring is in play.
6075 ///
6076 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
6077 /// make the body `Regex::new(value).ok()`. The unanchored `/abc` matches
6078 /// `/abc?x=1` as a substring and becomes a candidate again: the
6079 /// both-go-to-the-prefix case fails with
6080 /// `left: Some("REGEX"), right: Some("PREFIX")` and the declared-alone
6081 /// case fails with `left: Some("REGEX"), right: None`.
6082 #[test]
6083 fn an_anchored_regex_that_cannot_span_the_query_string_stops_being_a_candidate() {
6084 let configured = |pattern: &str| {
6085 PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
6086 .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"))
6087 };
6088 let against_the_whole_path_prefix = |pattern: &str, path: &str| {
6089 winner_of(
6090 (configured(pattern), NO_METHOD, "REGEX"),
6091 (PathRule::Prefix("/abc".to_owned()), NO_METHOD, "PREFIX"),
6092 path,
6093 )
6094 };
6095
6096 // A regex that spans the query string: the documented flip, unchanged.
6097 assert_eq!(
6098 against_the_whole_path_prefix("/a.*", "/abc").as_deref(),
6099 Some("PREFIX"),
6100 "the whole-path prefix declared after the regex must still win",
6101 );
6102 assert_eq!(
6103 against_the_whole_path_prefix("/a.*", "/abc?x=1").as_deref(),
6104 Some("REGEX"),
6105 "with a query string `/abc` no longer spans the request path, so \
6106 a regex that does wins",
6107 );
6108
6109 // A regex that does not span it: the flip disappears entirely.
6110 assert_eq!(
6111 against_the_whole_path_prefix("/abc", "/abc").as_deref(),
6112 Some("PREFIX"),
6113 "the prefix wins here exactly as it does against `/a.*`",
6114 );
6115 assert_eq!(
6116 against_the_whole_path_prefix("/abc", "/abc?x=1").as_deref(),
6117 Some("PREFIX"),
6118 "an anchored `/abc` cannot match `/abc?x=1`, so adding a query \
6119 string changes nothing and the prefix keeps the request",
6120 );
6121
6122 // …and the reason is candidacy, not defeat.
6123 assert_eq!(
6124 regex_rule_routes("/abc", "/abc").as_deref(),
6125 Some("REGEX"),
6126 "declared alone, the regex answers the request it spells",
6127 );
6128 assert_eq!(
6129 regex_rule_routes("/abc", "/abc?x=1"),
6130 None,
6131 "declared alone, it leaves the query-string request UNROUTED — it \
6132 is not a candidate any more",
6133 );
6134 }
6135
6136 /// The `REGEX_PATH` twin of [`regex_rule_routes`]: one path-only regex
6137 /// rule on `www.example.com`, then a `GET` for `path`.
6138 fn regex_path_rule_routes(pattern: &str, path: &str) -> Option<String> {
6139 let rule = PathRule::from_config(CommandPathRule::regex_path(pattern.to_owned()))
6140 .unwrap_or_else(|| panic!("the path-only regex rule {pattern:?} must build"));
6141 assert!(
6142 matches!(rule, PathRule::RegexPath(_)),
6143 "a REGEX_PATH config rule must build a RegexPath router rule",
6144 );
6145 let mut router = Router::new();
6146 assert!(add_path_rule(&mut router, rule, GET, "REGEX_PATH"));
6147 routed_cluster(&router, path)
6148 }
6149
6150 /// `path_type = "REGEX_PATH"` matches the path up to its first `?` and
6151 /// never the query string (sozu-proxy/sozu#1909), while `REGEX` on the
6152 /// very same pattern and request keeps matching the path WITH its query.
6153 /// Each row names the `REGEX` verdict beside the `REGEX_PATH` one, so the
6154 /// table is also the proof that `REGEX` did not change.
6155 ///
6156 /// To SEE THIS RED: in `PathRule::matches`, make the `RegexPath` arm test
6157 /// `path` instead of the `split_query` path half — every row whose two
6158 /// verdicts differ flips.
6159 #[test]
6160 fn a_path_only_regex_ignores_the_query_string_and_regex_still_spans_it() {
6161 // (pattern, request path, REGEX routes, REGEX_PATH routes)
6162 let cases: &[(&str, &str, bool, bool)] = &[
6163 // The issue's two examples.
6164 ("/api/.+", "/api/?x=1", true, false),
6165 ("^/api/v2$", "/api/v2?x=1", false, true),
6166 // A path-only match ignores whatever query follows.
6167 ("/api/.+", "/api/x?y", true, true),
6168 ("/api/.+", "/api/x", true, true),
6169 ("/api/.+", "/api/", false, false),
6170 // An empty query still splits at its `?`.
6171 ("^/api/v2$", "/api/v2?", false, true),
6172 // Only the FIRST `?` splits: the rest is query, `?` included.
6173 ("/a", "/a?b?c", false, true),
6174 // A pattern spelling a query can never match path-only.
6175 ("/a[?]x=1", "/a?x=1", true, false),
6176 // Anchoring is unchanged: still the WHOLE path half.
6177 ("/ab", "/abc?x=1", false, false),
6178 ("bc", "/abc", false, false),
6179 ];
6180 for &(pattern, path, regex_routes, regex_path_routes) in cases {
6181 assert_eq!(
6182 regex_rule_routes(pattern, path).is_some(),
6183 regex_routes,
6184 "REGEX {pattern:?} against {path:?} must keep its historical verdict",
6185 );
6186 assert_eq!(
6187 regex_path_rule_routes(pattern, path).is_some(),
6188 regex_path_routes,
6189 "REGEX_PATH {pattern:?} against {path:?}",
6190 );
6191 }
6192 }
6193
6194 /// `PREFIX` and `EQUALS` still see the request path WITH its query string;
6195 /// adding the path-only kind did not move them (sozu-proxy/sozu#1909).
6196 #[test]
6197 fn prefix_and_equals_still_match_against_the_query_string() {
6198 let routes = |rule: CommandPathRule, path: &str| {
6199 let rule = PathRule::from_config(rule).expect("the path rule must build");
6200 let mut router = Router::new();
6201 assert!(add_path_rule(&mut router, rule, GET, "RULE"));
6202 routed_cluster(&router, path).is_some()
6203 };
6204 assert!(!routes(CommandPathRule::equals("/abc"), "/abc?x=1"));
6205 assert!(routes(CommandPathRule::equals("/abc?x=1"), "/abc?x=1"));
6206 assert!(routes(CommandPathRule::prefix("/abc?x"), "/abc?x=1"));
6207 assert!(!routes(CommandPathRule::prefix("/abc?y"), "/abc?x=1"));
6208 }
6209
6210 /// A `REGEX` and a `REGEX_PATH` rule on the same pattern are different
6211 /// rules: they compare unequal, and both register on one leaf.
6212 #[test]
6213 fn a_path_only_regex_is_a_distinct_rule_from_the_same_regex() {
6214 let regex = PathRule::from_config(CommandPathRule::regex("/a.*")).expect("REGEX builds");
6215 let regex_path =
6216 PathRule::from_config(CommandPathRule::regex_path("/a.*")).expect("REGEX_PATH builds");
6217 assert_ne!(regex, regex_path);
6218 assert_eq!(regex_path, regex_path.clone());
6219 assert!(
6220 PathRule::from_config(CommandPathRule::regex_path("a)(b")).is_none(),
6221 "a pattern refused bare stays refused for the path-only kind too",
6222 );
6223 }
6224
6225 /// `rewrite_path` on a `REGEX_PATH` frontend: `$PATH[n]`, index 0
6226 /// included, comes from the path-only match, and the request's original
6227 /// `?query` is appended unless the rewritten path carries a `?` of its own
6228 /// (sozu-proxy/sozu#1909). A `REGEX` frontend is untouched: its captures
6229 /// still span the query, and nothing is appended. A permanent redirect
6230 /// builds its `Location` from the same `rewritten_path`, so it follows.
6231 ///
6232 /// To SEE THIS RED: drop the `append_original_query` call in
6233 /// `RouteResult::from_frontend` — the appended-query assertions fail.
6234 #[test]
6235 fn a_path_only_regex_rewrite_captures_the_path_and_carries_the_query_over() {
6236 let rewritten =
6237 |path_rule: CommandPathRule, rewrite: &str, redirect: RedirectPolicy, path: &str| {
6238 let mut front = test_http_frontend();
6239 front.hostname = "www.example.com".to_owned();
6240 front.path = path_rule;
6241 front.rewrite_path = Some(rewrite.to_owned());
6242 front.redirect = Some(redirect as i32);
6243 let mut router = Router::new();
6244 router
6245 .add_http_front(&front)
6246 .expect("the rewriting frontend must register");
6247 router
6248 .lookup("www.example.com", path, &Method::Get)
6249 .expect("the request must route")
6250 .rewritten_path
6251 };
6252 let regex_path = || CommandPathRule::regex_path("/api/(v[0-9]+)/(.*)");
6253 for redirect in [RedirectPolicy::Forward, RedirectPolicy::Permanent] {
6254 assert_eq!(
6255 rewritten(
6256 regex_path(),
6257 "/$PATH[1]/$PATH[2]",
6258 redirect,
6259 "/api/v2/users?id=7"
6260 )
6261 .as_deref(),
6262 Some("/v2/users?id=7"),
6263 "{redirect:?}: captures stop at `?`, and the query is appended",
6264 );
6265 assert_eq!(
6266 rewritten(regex_path(), "$PATH[0]", redirect, "/api/v2/users?id=7").as_deref(),
6267 Some("/api/v2/users?id=7"),
6268 "{redirect:?}: `$PATH[0]` is the path half, and the query follows it once",
6269 );
6270 assert_eq!(
6271 rewritten(regex_path(), "/$PATH[2]", redirect, "/api/v2/users").as_deref(),
6272 Some("/users"),
6273 "{redirect:?}: without a query nothing is appended",
6274 );
6275 assert_eq!(
6276 rewritten(regex_path(), "/$PATH[2]", redirect, "/api/v2/users?").as_deref(),
6277 Some("/users?"),
6278 "{redirect:?}: an empty query is carried over as written",
6279 );
6280 assert_eq!(
6281 rewritten(
6282 regex_path(),
6283 "/$PATH[2]?src=v",
6284 redirect,
6285 "/api/v2/users?id=7"
6286 )
6287 .as_deref(),
6288 Some("/users?src=v"),
6289 "{redirect:?}: a rewritten path with a query of its own keeps it alone",
6290 );
6291 // `REGEX` unchanged: its last group swallows the query, nothing is
6292 // appended, so the query appears exactly once.
6293 assert_eq!(
6294 rewritten(
6295 CommandPathRule::regex("/api/(v[0-9]+)/(.*)"),
6296 "/$PATH[2]",
6297 redirect,
6298 "/api/v2/users?id=7",
6299 )
6300 .as_deref(),
6301 Some("/users?id=7"),
6302 "{redirect:?}: a REGEX capture still spans the query string",
6303 );
6304 assert_eq!(
6305 rewritten(
6306 CommandPathRule::regex("/api/(v[0-9]+)/(.*)"),
6307 "/$PATH[1]",
6308 redirect,
6309 "/api/v2/users?id=7",
6310 )
6311 .as_deref(),
6312 Some("/v2"),
6313 "{redirect:?}: a REGEX rewrite gains no query it did not capture",
6314 );
6315 }
6316 }
6317
6318 /// Every branch of an alternation in a regex hostname SEGMENT is
6319 /// anchored, not just the first and the last.
6320 ///
6321 /// This test is the INVERSION of the test formerly named
6322 /// `an_alternating_regex_hostname_segment_is_still_anchored_at_one_end_only`
6323 /// — a name no longer in the tree, this one having replaced it —
6324 /// which pinned the defect so that closing it would be "a deliberate
6325 /// change, not a silent one" and asked, in its own comment, to be
6326 /// inverted rather than deleted when the fix landed. This is that
6327 /// deliberate change (sozu#1356), and the old name is kept here so the
6328 /// behaviour change leaves a trace.
6329 ///
6330 /// `pattern_trie.rs`'s `anchored_segment` now builds
6331 /// `\A(?:{s})\z`. Ungrouped, `|` binds looser than concatenation, so
6332 /// `\Aa|b\z` parsed as `(\Aa)|(b\z)`: a label merely STARTING with `a`
6333 /// or merely ENDING with `b` reached the frontend.
6334 ///
6335 /// Two branches cannot show what this test claims, because with two
6336 /// branches "first and last" is every branch there is. The three-branch
6337 /// case below has a MIDDLE branch, which ungrouped keeps NEITHER anchor
6338 /// and matches as a bare substring anywhere in the label — strictly
6339 /// worse than the half-anchored ends.
6340 ///
6341 /// To SEE THIS RED: drop the group in `pattern_trie.rs`'s
6342 /// `anchored_segment` — build `format!("\\A{segment}\\z")`, the form
6343 /// this tree carried up to 2.2.1, and relax the `debug_assert!` beside it,
6344 /// which in a debug build fires first. `a.example.com` still resolves,
6345 /// and the `axx.example.com` assertion fails with
6346 /// `left: Some("ALTERNATION"), right: None`.
6347 #[test]
6348 fn an_alternating_regex_hostname_segment_is_anchored_on_every_branch() {
6349 let resolve_with = |pattern: &[u8], hostname: &str| {
6350 let mut router = Router::new();
6351 assert!(router.add_tree_rule(
6352 pattern,
6353 &PathRule::Prefix("/".to_owned()),
6354 &MethodRule::new(Some("GET".to_owned())),
6355 &Route::ClusterId("ALTERNATION".into()),
6356 ));
6357 router
6358 .lookup(hostname, "/", &Method::Get)
6359 .ok()
6360 .and_then(|result| result.cluster_id)
6361 };
6362
6363 // Two branches: each one keeps BOTH anchors now.
6364 assert_eq!(
6365 resolve_with(b"/a|b/.example.com", "a.example.com").as_deref(),
6366 Some("ALTERNATION"),
6367 "the segment must still match what it was written for",
6368 );
6369 assert_eq!(
6370 resolve_with(b"/a|b/.example.com", "b.example.com").as_deref(),
6371 Some("ALTERNATION"),
6372 "and the other branch too",
6373 );
6374 assert_eq!(
6375 resolve_with(b"/a|b/.example.com", "axx.example.com"),
6376 None,
6377 "the first branch must be anchored at its END too",
6378 );
6379 assert_eq!(
6380 resolve_with(b"/a|b/.example.com", "xxb.example.com"),
6381 None,
6382 "the last branch must be anchored at its START too",
6383 );
6384 assert_eq!(
6385 resolve_with(b"/a|b/.example.com", "xx.example.com"),
6386 None,
6387 "a label matching neither branch must still miss",
6388 );
6389
6390 // Three branches. `zzbzz` is the case two branches cannot express:
6391 // ungrouped, the MIDDLE branch carries no anchor at either end, so
6392 // the bare label `b` matches in the middle of any label at all.
6393 for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6394 assert_eq!(
6395 resolve_with(b"/a|b|c/.example.com", whole).as_deref(),
6396 Some("ALTERNATION"),
6397 "{whole} is a whole-label match for one branch and must route",
6398 );
6399 }
6400 for leak in [
6401 "axx.example.com",
6402 "xxc.example.com",
6403 "zzbzz.example.com",
6404 "bzz.example.com",
6405 "zzb.example.com",
6406 ] {
6407 assert_eq!(
6408 resolve_with(b"/a|b|c/.example.com", leak),
6409 None,
6410 "{leak} matches no branch WHOLE and must not route",
6411 );
6412 }
6413
6414 // The control for all of the above, built by hand against the
6415 // `regex` crate rather than through the trie: the UNGROUPED wrapper
6416 // — the form this tree carried up to 2.2.1 — leaves the middle
6417 // branch anchored at neither end. No mutation of `anchored_segment`
6418 // can move this assertion, which is the point: it measures why the
6419 // group is there.
6420 let two = Regex::new("\\Aa|b\\z").expect("the ungrouped wrapper compiles");
6421 for anywhere in ["axx", "xxb"] {
6422 assert!(
6423 two.is_match(anywhere.as_bytes()),
6424 "{} leaks on {anywhere}",
6425 two.as_str(),
6426 );
6427 }
6428 let ungrouped = Regex::new("\\Aa|b|c\\z").expect("the ungrouped wrapper compiles");
6429 for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6430 assert!(
6431 ungrouped.is_match(anywhere.as_bytes()),
6432 "{} leaks on {anywhere}",
6433 ungrouped.as_str(),
6434 );
6435 }
6436 let grouped = Regex::new("\\A(?:a|b|c)\\z").expect("the grouped wrapper compiles");
6437 for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6438 assert!(
6439 !grouped.is_match(anywhere.as_bytes()),
6440 "{} must refuse {anywhere}",
6441 grouped.as_str(),
6442 );
6443 }
6444 }
6445
6446 /// Declare one regex hostname frontend the way a configured frontend
6447 /// declares it — through `Router::add_http_front`, so the hostname goes
6448 /// through `DomainRule::from_str` and `convert_regex_domain_rule` — and
6449 /// resolve `hostname`.
6450 ///
6451 /// `position` picks the code path, and the two are genuinely different
6452 /// code: `Pre`/`Post` match the WHOLE host against the single regex
6453 /// `convert_regex_domain_rule` assembles, while `Tree` matches label by
6454 /// label against `pattern_trie`'s per-segment regexes.
6455 fn regex_host_routes(position: RulePosition, pattern: &str, hostname: &str) -> Option<String> {
6456 let mut front = test_http_frontend();
6457 front.hostname = pattern.to_owned();
6458 front.position = position;
6459 front.cluster_id = Some("REGEX-HOST".into());
6460 let mut router = Router::new();
6461 router
6462 .add_http_front(&front)
6463 .unwrap_or_else(|error| panic!("the regex hostname {pattern:?} must build: {error}"));
6464 router
6465 .lookup(hostname, "/", &Method::Get)
6466 .ok()
6467 .and_then(|result| result.cluster_id)
6468 .map(|cluster_id| cluster_id.to_string())
6469 }
6470
6471 /// The companion of
6472 /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`
6473 /// for the OTHER hostname code path: a pre/post rule never touches the
6474 /// trie, it matches the whole host against the single regex
6475 /// `convert_regex_domain_rule` assembles, and that assembly carried the
6476 /// same ungrouped `\A` … `\z` (sozu#1356).
6477 ///
6478 /// The exact assembled pattern is asserted, so "grouped" is pinned and
6479 /// not merely implied by the routing verdicts.
6480 ///
6481 /// Two branches cannot show what this test claims — with two branches
6482 /// "first and last" is every branch. The three-branch case has a MIDDLE
6483 /// branch, which ungrouped keeps NEITHER anchor: it is a bare `b`,
6484 /// matching any hostname on earth that contains a `b`, including one on
6485 /// a different registrable domain entirely.
6486 ///
6487 /// To SEE THIS RED: drop the group in `convert_regex_domain_rule` —
6488 /// replace the `push_str`/`push` calls in the `/` arm with the single
6489 /// `result.push_str(r)` this tree carried up to 2.2.1. The
6490 /// `axx.example.com` row then fails with
6491 /// `left: Some("REGEX-HOST"), right: None`, and the exact-pattern
6492 /// assertion below it with
6493 /// `left: "\\Aa|b|c\\.example\\.com\\z", right: "\\A(?:a|b|c)\\.example\\.com\\z"`.
6494 #[test]
6495 fn every_branch_of_an_alternating_regex_hostname_rule_is_anchored() {
6496 for position in [RulePosition::Pre, RulePosition::Post] {
6497 for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6498 assert_eq!(
6499 regex_host_routes(position, "/a|b|c/.example.com", whole).as_deref(),
6500 Some("REGEX-HOST"),
6501 "{position:?}: {whole} is a whole-host match for one branch and must route",
6502 );
6503 }
6504 for leak in [
6505 "axx.example.com",
6506 "xxc.example.com",
6507 "zzbzz.example.com",
6508 "b.example.com.evil.org",
6509 "zzbzz.evil.org",
6510 ] {
6511 assert_eq!(
6512 regex_host_routes(position, "/a|b|c/.example.com", leak),
6513 None,
6514 "{position:?}: {leak} matches no branch WHOLE and must not route",
6515 );
6516 }
6517 }
6518
6519 // The exact assembled pattern, so "grouped" is asserted and not
6520 // merely implied by the routing verdicts above.
6521 assert_eq!(
6522 convert_regex_domain_rule("/a|b|c/.example.com")
6523 .expect("an alternating hostname segment must convert"),
6524 "\\A(?:a|b|c)\\.example\\.com\\z",
6525 "each regex segment must be wrapped in a NON-capturing group",
6526 );
6527
6528 // The control, built by hand against the `regex` crate rather than
6529 // through `convert_regex_domain_rule`: the UNGROUPED assembly — the
6530 // form this tree carried up to 2.2.1 — parses as
6531 // `(\Aa)|(b)|(c\.example\.com\z)`. The middle branch is a bare `b`,
6532 // so it matches a host on an unrelated registrable domain. No
6533 // mutation of `convert_regex_domain_rule` can move these
6534 // assertions, which is the point: they measure why the group is
6535 // there.
6536 for (assembly, leaks) in [
6537 (
6538 "\\Aa|b|c\\.example\\.com\\z",
6539 &[
6540 "axx.example.com",
6541 "zzbzz.example.com",
6542 "b.example.com.evil.org",
6543 "zzbzz.evil.org",
6544 ][..],
6545 ),
6546 (
6547 "\\Aapi|admin\\.example\\.com\\z",
6548 &["apifoo.example.com", "xadmin.example.com"][..],
6549 ),
6550 ] {
6551 let ungrouped = Regex::new(assembly).expect("the ungrouped assembly compiles");
6552 for anywhere in leaks {
6553 assert!(
6554 ungrouped.is_match(anywhere.as_bytes()),
6555 "{} leaks on {anywhere}",
6556 ungrouped.as_str(),
6557 );
6558 }
6559 }
6560
6561 // `doc/configure.md`'s own worked example, asserted here because it
6562 // is written there as a measurement: the frontend
6563 // `/api|admin/.example.com` used to also serve `apifoo.example.com`
6564 // and `xadmin.example.com`, and the remediation the same paragraph
6565 // gives has to keep working.
6566 for (pattern, hostname, must_route) in [
6567 ("/api|admin/.example.com", "api.example.com", true),
6568 ("/api|admin/.example.com", "admin.example.com", true),
6569 ("/api|admin/.example.com", "apifoo.example.com", false),
6570 ("/api|admin/.example.com", "xadmin.example.com", false),
6571 ("/api.*|.*admin/.example.com", "api.example.com", true),
6572 ("/api.*|.*admin/.example.com", "admin.example.com", true),
6573 ("/api.*|.*admin/.example.com", "apifoo.example.com", true),
6574 ("/api.*|.*admin/.example.com", "xadmin.example.com", true),
6575 ] {
6576 assert_eq!(
6577 regex_host_routes(RulePosition::Pre, pattern, hostname).is_some(),
6578 must_route,
6579 "hostname regex {pattern:?} against {hostname:?}",
6580 );
6581 }
6582
6583 // `captures_len` must be untouched by the wrapping — see
6584 // `a_host_capture_rewrite_resolves_to_the_operators_own_group` for
6585 // the consequence when it is not.
6586 let compiled = |pattern: &str| {
6587 Regex::new(&convert_regex_domain_rule(pattern).expect("the hostname must convert"))
6588 .expect("the assembled hostname regex must compile")
6589 };
6590 assert_eq!(
6591 compiled("/a|b|c/.example.com").captures_len(),
6592 1,
6593 "a pattern with no operator group must stay at the implicit group 0 alone",
6594 );
6595 assert_eq!(
6596 compiled("/cdn([0-9]+)/.example.com").captures_len(),
6597 2,
6598 "the operator's own group must stay at index 1",
6599 );
6600 }
6601
6602 /// A `*` label in a hostname that ALSO carries a regex segment means two
6603 /// different things depending on the rule position, and
6604 /// `doc/configure.md` § "Regex hostname segments" now carries the table
6605 /// this test asserts.
6606 ///
6607 /// On the trie a `*` label is the ordinary single-label wildcard: the
6608 /// trie splits on `.` and `TrieNode` stores it in its `wildcard` slot,
6609 /// untouched by anything here. On `Pre`/`Post` the whole host is matched
6610 /// by the single pattern `convert_regex_domain_rule` assembles, where the
6611 /// `*` is now a LITERAL — the sozu#1356 escaping. Up to and including
6612 /// 2.2.1 it was neither: an unescaped `*` was a regex quantifier over the
6613 /// preceding atom, which for a leftmost label is the opening `\A`, so the
6614 /// pattern was not anchored at its start and took any host ending in
6615 /// `.x.example.com` at any depth.
6616 ///
6617 /// The remediation the section gives for `Pre`/`Post` is asserted too,
6618 /// because a documented remediation that does not work is worse than
6619 /// none.
6620 ///
6621 /// To SEE THIS RED: drop the escaping in the literal arm of
6622 /// `convert_regex_domain_rule` — push `r` instead of `®ex::escape(r)`
6623 /// at both exits. The `Pre` row for `zz.x.example.com` then routes and
6624 /// fails with `left: true, right: false`. (That mutation also reddens
6625 /// `a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern` and
6626 /// `convert_regex`.)
6627 #[test]
6628 fn a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post() {
6629 // The table in `doc/configure.md`, both rows, all three positions.
6630 for (position, host, must_route) in [
6631 (RulePosition::Tree, "zz.x.example.com", true),
6632 (RulePosition::Tree, "*.x.example.com", true),
6633 (RulePosition::Pre, "zz.x.example.com", false),
6634 (RulePosition::Pre, "*.x.example.com", true),
6635 (RulePosition::Post, "zz.x.example.com", false),
6636 (RulePosition::Post, "*.x.example.com", true),
6637 ] {
6638 assert_eq!(
6639 regex_host_routes(position, "*./x/.example.com", host).is_some(),
6640 must_route,
6641 "{position:?}: `*./x/.example.com` against {host:?}",
6642 );
6643 }
6644
6645 // Common to every position, and the reason the trie row is a
6646 // wildcard and not a free-for-all: `*` is ONE label.
6647 for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
6648 for miss in ["x.example.com", "a.b.x.example.com"] {
6649 assert_eq!(
6650 regex_host_routes(position, "*./x/.example.com", miss),
6651 None,
6652 "{position:?}: a `*` label is exactly one label, so {miss:?} misses",
6653 );
6654 }
6655 }
6656
6657 // What 2.2.1 did on `Pre`/`Post`, built by hand against the crate:
6658 // `\A*` repeats a zero-width assertion, so the pattern was not
6659 // anchored at its start. No mutation of the escaping can move this.
6660 let quantified =
6661 Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6662 for anywhere in [
6663 "evil.attacker.x.example.com",
6664 "anything.at.all.x.example.com",
6665 "zzz.x.example.com",
6666 ] {
6667 assert!(
6668 quantified.is_match(anywhere.as_bytes()),
6669 "{} took {anywhere} at any depth",
6670 quantified.as_str(),
6671 );
6672 }
6673 assert!(
6674 !quantified.is_match(b"x.example.com"),
6675 "{} still required a leading label",
6676 quantified.as_str(),
6677 );
6678
6679 // The remediation `doc/configure.md` gives for `Pre`/`Post`: a regex
6680 // segment spelling the single label out.
6681 for position in [RulePosition::Pre, RulePosition::Post] {
6682 for (host, must_route) in [
6683 ("zz.x.example.com", true),
6684 ("*.x.example.com", true),
6685 ("a.b.x.example.com", false),
6686 ("x.example.com", false),
6687 ] {
6688 assert_eq!(
6689 regex_host_routes(position, "/[^.]*/./x/.example.com", host).is_some(),
6690 must_route,
6691 "{position:?}: the documented `Pre`/`Post` remediation against {host:?}",
6692 );
6693 }
6694 }
6695 }
6696
6697 /// sozu#633: `hostname = "*"` with `position = "POST"` is the catch-all
6698 /// `doc/configure.md` documents — `DomainRule::Any`, which matches every
6699 /// host, an IP literal or a single-label name included — while the same
6700 /// `*` on the trie (the default position) is the one-label wildcard and
6701 /// matches only a single-label host. The last block pins that a `Post`
6702 /// rule is consulted only once the trie matched nothing.
6703 ///
6704 /// To SEE THIS RED: in `DomainRule::from_str`, change the `s == "*"`
6705 /// test to `s == "**"`. A bare `*` then falls through to the literal
6706 /// arm, and the `127.0.0.1` row fails with `left: None, right:
6707 /// Some("REGEX-HOST")`.
6708 #[test]
6709 fn a_bare_star_hostname_is_a_catch_all_on_post_and_one_label_on_the_trie() {
6710 for host in [
6711 "127.0.0.1",
6712 "localhost",
6713 "penguin.linux.test",
6714 "10.0.0.7",
6715 "www.example.com",
6716 ] {
6717 assert_eq!(
6718 regex_host_routes(RulePosition::Post, "*", host).as_deref(),
6719 Some("REGEX-HOST"),
6720 "Post: `*` is the catch-all and must route {host:?}",
6721 );
6722 }
6723
6724 for (host, must_route) in [
6725 ("localhost", true),
6726 ("127.0.0.1", false),
6727 ("penguin.linux.test", false),
6728 ("www.example.com", false),
6729 ] {
6730 assert_eq!(
6731 regex_host_routes(RulePosition::Tree, "*", host).is_some(),
6732 must_route,
6733 "Tree: `*` is exactly one label, against {host:?}",
6734 );
6735 }
6736
6737 let mut router = Router::new();
6738 for (hostname, position, cluster_id) in [
6739 ("*", RulePosition::Post, "CATCH-ALL"),
6740 ("www.example.com", RulePosition::Tree, "EXAMPLE"),
6741 ] {
6742 let mut front = test_http_frontend();
6743 front.hostname = hostname.to_owned();
6744 front.position = position;
6745 front.cluster_id = Some(cluster_id.into());
6746 router
6747 .add_http_front(&front)
6748 .unwrap_or_else(|error| panic!("{hostname:?} must build: {error}"));
6749 }
6750 for (host, expected) in [
6751 ("www.example.com", "EXAMPLE"),
6752 ("127.0.0.1", "CATCH-ALL"),
6753 ("api.example.com", "CATCH-ALL"),
6754 ] {
6755 assert_eq!(
6756 router
6757 .lookup(host, "/", &Method::Get)
6758 .ok()
6759 .and_then(|result| result.cluster_id)
6760 .as_deref(),
6761 Some(expected),
6762 "a `Post` catch-all answers only what the trie did not: {host:?}",
6763 );
6764 }
6765 }
6766
6767 /// A `.*` inside a regex segment does not stop at the label boundary, and
6768 /// where it stops depends on the rule POSITION — the two hostname paths
6769 /// genuinely differ here. A trie rule matches each segment against one
6770 /// label, so `.*` cannot leave it. A `Pre`/`Post` rule is matched by the
6771 /// single whole-host pattern `convert_regex_domain_rule` assembles, in
6772 /// which `.` matches the `.` separator like any other character.
6773 ///
6774 /// Pre-existing and NOT introduced by the anchoring, but
6775 /// `doc/configure.md` walks operators into it: the remediation it gives
6776 /// for the narrowing is exactly a `.*`. The section now names the
6777 /// divergence and recommends `[^.]*` on `Pre`/`Post`, so all three
6778 /// measurements it makes are pinned here.
6779 ///
6780 /// To SEE THIS RED: escape the REGEX-segment arm of
6781 /// `convert_regex_domain_rule` as well as the literal arm — push
6782 /// `®ex::escape(r)` in the `/` arm too. Every `Pre` row then goes
6783 /// false, starting with `api.example.com`, because the segment stops
6784 /// being a pattern at all.
6785 #[test]
6786 fn a_wildcard_in_a_hostname_segment_crosses_labels_on_pre_and_post_only() {
6787 // Trie: one segment, one label. `.*` cannot escape the label.
6788 for (hostname, must_route) in [
6789 ("api.example.com", true),
6790 ("admin.example.com", true),
6791 ("apifoo.example.com", true),
6792 ("api.foo.example.com", false),
6793 ("zz.admin.example.com", false),
6794 ] {
6795 assert_eq!(
6796 regex_host_routes(RulePosition::Tree, "/api.*|.*admin/.example.com", hostname)
6797 .is_some(),
6798 must_route,
6799 "trie rule `/api.*|.*admin/` against {hostname:?}",
6800 );
6801 }
6802
6803 // Pre/Post: one whole-host pattern, in which `.` matches the
6804 // separator. The same rule reaches subdomains the operator did not
6805 // write. This is the clause `doc/configure.md` now carries.
6806 for position in [RulePosition::Pre, RulePosition::Post] {
6807 for hostname in [
6808 "api.example.com",
6809 "admin.example.com",
6810 "apifoo.example.com",
6811 "api.foo.example.com",
6812 "zz.admin.example.com",
6813 ] {
6814 assert!(
6815 regex_host_routes(position, "/api.*|.*admin/.example.com", hostname).is_some(),
6816 "{position:?}: `.*` spans the label separator, so {hostname:?} routes",
6817 );
6818 }
6819 }
6820
6821 // ... and the remediation that section recommends for `Pre`/`Post`.
6822 for position in [RulePosition::Pre, RulePosition::Post] {
6823 for (hostname, must_route) in [
6824 ("api.example.com", true),
6825 ("admin.example.com", true),
6826 ("apifoo.example.com", true),
6827 ("xadmin.example.com", true),
6828 ("api.foo.example.com", false),
6829 ("zz.admin.example.com", false),
6830 ] {
6831 assert_eq!(
6832 regex_host_routes(position, "/api[^.]*|[^.]*admin/.example.com", hostname,)
6833 .is_some(),
6834 must_route,
6835 "{position:?}: `[^.]*` spells the boundary out, {hostname:?}",
6836 );
6837 }
6838 }
6839 }
6840
6841 /// The LITERAL-label arm of `convert_regex_domain_rule` split the anchors
6842 /// exactly as the regex-segment arm did, one `else` away from it: a label
6843 /// the operator never wrapped in slashes was pushed verbatim into the
6844 /// assembly, so a `|` in it was a live alternation. Measured on the tree
6845 /// as it stood, `/a/.x|y.com` assembled to `\A(?:a)\.x|y\.com\z` —
6846 /// `(\A(?:a)\.x)|(y\.com\z)` — and matched `a.xZZZ` and `ZZZy.com`.
6847 ///
6848 /// The fix is `regex::escape`, not a group. A literal label is a literal,
6849 /// so grouping would confine the alternation while leaving `+`, `?`, `*`,
6850 /// `^` and `$` live inside the same arm — `b+c` matching `bbbc` is the
6851 /// same defect with a different operator. Escaping is a no-op in matching
6852 /// behaviour for every label a real hostname can carry: an LDH label is
6853 /// `[A-Za-z0-9-]`, `escape` touches only the `-`, and `\-` is `-` outside
6854 /// a character class. That parity is asserted below rather than asserted
6855 /// of the hostname grammar in the abstract.
6856 ///
6857 /// To SEE THIS RED: drop the escaping in the literal arm of
6858 /// `convert_regex_domain_rule` — push `r` instead of `®ex::escape(r)`
6859 /// at both of its exits. The `a.xZZZ` row then fails with
6860 /// `left: true, right: false`. That mutation reddens THREE tests, not
6861 /// just this one: `convert_regex`'s two `*` rows go back to the
6862 /// unescaped spelling, and
6863 /// `a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post`
6864 /// loses the `Pre`/`Post` half of its table.
6865 #[test]
6866 fn a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern() {
6867 // The three routing verdicts the defect turned on, through the
6868 // frontend surface that carries it.
6869 for (hostname, must_route) in [("a.x|y.com", true), ("a.xZZZ", false), ("ZZZy.com", false)]
6870 {
6871 assert_eq!(
6872 regex_host_routes(RulePosition::Pre, "/a/.x|y.com", hostname).is_some(),
6873 must_route,
6874 "literal label `x|y` against {hostname:?}",
6875 );
6876 }
6877 assert_eq!(
6878 convert_regex_domain_rule("/a/.x|y.com").expect("the hostname must convert"),
6879 "\\A(?:a)\\.x\\|y\\.com\\z",
6880 "a literal label must be escaped, not grouped and not pushed raw",
6881 );
6882
6883 // The control, built by hand: the UNESCAPED assembly is what leaked.
6884 // No mutation of `convert_regex_domain_rule` can move this.
6885 let unescaped = Regex::new("\\A(?:a)\\.x|y\\.com\\z").expect("the raw assembly compiles");
6886 for anywhere in ["a.xZZZ", "ZZZy.com"] {
6887 assert!(
6888 unescaped.is_match(anywhere.as_bytes()),
6889 "{} leaks on {anywhere}",
6890 unescaped.as_str(),
6891 );
6892 }
6893
6894 // Escaping must be inert for every label a hostname can actually
6895 // carry. `-` is the only LDH byte `regex::escape` touches, and `\-`
6896 // is `-` outside a character class.
6897 assert_eq!(
6898 convert_regex_domain_rule("/a/.my-host01.example.com")
6899 .expect("an LDH hostname must convert"),
6900 "\\A(?:a)\\.my\\-host01\\.example\\.com\\z",
6901 "escape only adds a backslash before the `-`",
6902 );
6903 for (hostname, must_route) in [
6904 ("a.my-host01.example.com", true),
6905 ("a.myXhost01.example.com", false),
6906 ("a.my-host01.example.comZZZ", false),
6907 ] {
6908 assert_eq!(
6909 regex_host_routes(RulePosition::Pre, "/a/.my-host01.example.com", hostname)
6910 .is_some(),
6911 must_route,
6912 "escaped LDH label against {hostname:?}",
6913 );
6914 }
6915
6916 // Grouping instead of escaping would have closed the `|` only. This
6917 // is why the arms are treated differently, measured rather than
6918 // argued: the same three quantifiers stay live under a group.
6919 let grouped_not_escaped =
6920 Regex::new("\\A(?:a)\\.(?:b+c)\\.com\\z").expect("the grouped assembly compiles");
6921 assert!(
6922 grouped_not_escaped.is_match(b"a.bbbc.com"),
6923 "{} still repeats the literal `b`",
6924 grouped_not_escaped.as_str(),
6925 );
6926 for (hostname, must_route) in [("a.b+c.com", true), ("a.bbbc.com", false)] {
6927 assert_eq!(
6928 regex_host_routes(RulePosition::Pre, "/a/.b+c.com", hostname).is_some(),
6929 must_route,
6930 "escaped literal label `b+c` against {hostname:?}",
6931 );
6932 }
6933
6934 // Two deliberate behaviour changes, both narrowing, both stated in
6935 // `convert_regex_domain_rule`'s doc comment.
6936 //
6937 // (1) A literal `*` label was a quantifier over the preceding atom.
6938 // `\A*` repeats a zero-width assertion, which matches empty at any
6939 // offset, so the pattern was not anchored at its start at all.
6940 let star_unescaped =
6941 Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6942 assert!(
6943 star_unescaped.is_match(b"evil.attacker.x.example.com"),
6944 "{} is not anchored at its start",
6945 star_unescaped.as_str(),
6946 );
6947 assert_eq!(
6948 regex_host_routes(
6949 RulePosition::Pre,
6950 "*./x/.example.com",
6951 "evil.attacker.x.example.com",
6952 ),
6953 None,
6954 "a literal `*` label must no longer leave the pattern unanchored",
6955 );
6956 assert_eq!(
6957 regex_host_routes(RulePosition::Pre, "*./x/.example.com", "*.x.example.com").as_deref(),
6958 Some("REGEX-HOST"),
6959 "it matches the literal label the operator wrote, and only that",
6960 );
6961
6962 // (2) A literal label carrying an unbalanced `(` or `[` made the
6963 // assembly fail to compile and the frontend was REJECTED. It is now
6964 // accepted and matches exactly the host that was typed. Assembled at
6965 // run time because the unbalanced literal is the point.
6966 for bracket in ["(", "["] {
6967 let hostname = format!("/a/.b{bracket}c.com");
6968 assert!(
6969 convert_regex_domain_rule(&hostname).is_some(),
6970 "{hostname} must convert",
6971 );
6972 assert_eq!(
6973 regex_host_routes(RulePosition::Pre, &hostname, &format!("a.b{bracket}c.com"))
6974 .as_deref(),
6975 Some("REGEX-HOST"),
6976 "{hostname} must match the literal host it spells",
6977 );
6978 assert_eq!(
6979 regex_host_routes(RulePosition::Pre, &hostname, "a.bc.com"),
6980 None,
6981 "{hostname} must match nothing else",
6982 );
6983 }
6984 }
6985
6986 /// `$HOST[n]` rewrite indices are read from `caps.iter().skip(1)` with
6987 /// the buffer sized by `captures_len()`, so the anchoring wrapper has to
6988 /// be NON-capturing: a capturing `(` … `)` shifts every index by one and
6989 /// silently rewrites traffic to the wrong target — a wrong `Host` header
6990 /// and a wrong backend, with nothing in the configuration to show for it.
6991 ///
6992 /// Both hostname code paths are asserted, because they read the capture
6993 /// from two DIFFERENT regexes that have to agree: a pre/post rule reads
6994 /// the whole-host regex `convert_regex_domain_rule` assembles
6995 /// (`RouteResult::new_no_trie`), a tree rule reads `pattern_trie`'s
6996 /// per-segment regex (`RouteResult::new_with_trie`) — while
6997 /// `capture_cap_host`, which bounds the template, is taken from the
6998 /// whole-host one in BOTH cases.
6999 ///
7000 /// To SEE THIS RED: make either wrapper capturing — `result.push_str("(")`
7001 /// instead of `"(?:"` in `convert_regex_domain_rule`, or
7002 /// `format!("\\A({segment})\\z")` in `pattern_trie.rs`'s
7003 /// `anchored_segment`. `$HOST[1]` then resolves to the whole segment
7004 /// `cdn42` instead of the operator's own group `42`, failing with
7005 /// `left: Some("cdn42.internal"), right: Some("42.internal")`.
7006 #[test]
7007 fn a_host_capture_rewrite_resolves_to_the_operators_own_group() {
7008 let rewritten_host = |position: RulePosition, template: &str, hostname: &str| {
7009 let mut front = test_http_frontend();
7010 front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
7011 front.position = position;
7012 front.rewrite_host = Some(template.to_owned());
7013 let mut router = Router::new();
7014 router
7015 .add_http_front(&front)
7016 .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
7017 router
7018 .lookup(hostname, "/", &Method::Get)
7019 .ok()
7020 .and_then(|result| result.rewritten_host)
7021 };
7022
7023 for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
7024 assert_eq!(
7025 rewritten_host(position, "$HOST[1].internal", "cdn42.example.com").as_deref(),
7026 Some("42.internal"),
7027 "{position:?}: $HOST[1] must be the operator's own group, not the wrapper's",
7028 );
7029 assert_eq!(
7030 rewritten_host(position, "$HOST[0]", "cdn42.example.com").as_deref(),
7031 Some("cdn42.example.com"),
7032 "{position:?}: $HOST[0] is the whole hostname",
7033 );
7034 }
7035
7036 // The cap itself. `captures_len()` is 2, so index 2 does not exist
7037 // and the frontend must be refused loudly at registration rather
7038 // than rewriting to an empty string. A capturing wrapper would make
7039 // this index legal — which is exactly how a shifted index reaches
7040 // production unnoticed.
7041 let mut front = test_http_frontend();
7042 front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
7043 front.rewrite_host = Some("$HOST[2]".to_owned());
7044 assert!(
7045 matches!(
7046 Router::new().add_http_front(&front),
7047 Err(RouterError::InvalidHostRewrite(_)),
7048 ),
7049 "a $HOST index past captures_len must be refused at registration",
7050 );
7051 }
7052
7053 /// A tree lookup allocates nothing of its own: whatever host shape
7054 /// answers, the only heap work is building the [`RouteResult`] itself,
7055 /// which the control reproduces. The cluster id is not part of it: the
7056 /// result carries a handle on the legacy [`Route::ClusterId`] entry's
7057 /// reference-counted id, and so does the control.
7058 ///
7059 /// The three shapes cover every sink push the walk makes: a literal host
7060 /// records no segment, a wildcard host records one on its leaf, a regex
7061 /// host records one on the way down.
7062 ///
7063 /// TO SEE THIS RED: hand `lookup_with_path` a `Vec::with_capacity(16)`
7064 /// again in `Router::lookup`, one allocation per lookup on every shape;
7065 /// or build the legacy arm of `RouteResult::new_with_trie` from
7066 /// `ClusterId::from(&**id)` instead of `id.clone()`, one more.
7067 #[test]
7068 fn a_tree_lookup_allocates_nothing_past_its_route_result() {
7069 use std::hint::black_box;
7070
7071 use crate::test_allocations::allocations;
7072
7073 let mut router = Router::new();
7074 for hostname in [
7075 "literal.example.com",
7076 "*.wildcard.example.com",
7077 "/api[0-9]+/.regex.example.com",
7078 ] {
7079 let mut front = test_http_frontend();
7080 front.hostname = hostname.to_owned();
7081 router
7082 .add_http_front(&front)
7083 .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
7084 }
7085
7086 let cluster = ClusterId::from("cluster");
7087 let control = || {
7088 let before = allocations();
7089 let result = RouteResult::forward(black_box(&cluster).clone());
7090 let allocated = allocations() - before;
7091 drop(black_box(result));
7092 allocated
7093 };
7094 for host in [
7095 "literal.example.com",
7096 "foo.wildcard.example.com",
7097 "api42.regex.example.com",
7098 ] {
7099 // Warm-up: a regex builds its per-thread search cache on its
7100 // first match.
7101 drop(router.lookup(host, "/", &Method::Get));
7102 let before = allocations();
7103 let result = router.lookup(black_box(host), black_box("/"), &Method::Get);
7104 let allocated = allocations() - before;
7105 assert_eq!(
7106 result.ok().and_then(|result| result.cluster_id).as_deref(),
7107 Some("cluster"),
7108 "{host} must route to the test cluster",
7109 );
7110 assert_eq!(
7111 allocated,
7112 control(),
7113 "{host}: a tree lookup must allocate only its RouteResult",
7114 );
7115 }
7116 }
7117
7118 /// A trie walk records every non-literal segment it matched, in walk
7119 /// order, and `RouteResult::new_with_trie` reads that record back in
7120 /// reverse to number the `$HOST[n]` captures. The record keeps its first
7121 /// 16 entries on the stack; this hostname has 20 regex segments, so four
7122 /// spill past them, and every one of the 20 must still reach the
7123 /// template in the same position.
7124 ///
7125 /// The walk runs from the rightmost label, so the spilled entries are
7126 /// the four LEFTMOST segments, `s1` to `s4`, and the reversed read has to
7127 /// cross from the spill into the inline array. The expected order is
7128 /// left to right, the order a pre/post rule reads its whole-host regex
7129 /// in (sozu#1595); `a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule`
7130 /// holds the two positions against each other.
7131 #[test]
7132 fn a_host_capture_past_sixteen_trie_segments_keeps_every_segment() {
7133 const SEGMENTS: usize = 20;
7134 let hostname = format!(
7135 "{}.example.com",
7136 (1..=SEGMENTS)
7137 .map(|_| "/s([0-9]+)/")
7138 .collect::<Vec<_>>()
7139 .join(".")
7140 );
7141 let request = format!(
7142 "{}.example.com",
7143 (1..=SEGMENTS)
7144 .map(|segment| format!("s{segment}"))
7145 .collect::<Vec<_>>()
7146 .join(".")
7147 );
7148 let template = (1..=SEGMENTS)
7149 .map(|index| format!("$HOST[{index}]"))
7150 .collect::<Vec<_>>()
7151 .join("-");
7152
7153 let mut front = test_http_frontend();
7154 front.hostname = hostname;
7155 front.position = RulePosition::Tree;
7156 front.rewrite_host = Some(template);
7157 let mut router = Router::new();
7158 router
7159 .add_http_front(&front)
7160 .unwrap_or_else(|error| panic!("the tree frontend must build: {error}"));
7161
7162 let hostname_order = (1..=SEGMENTS)
7163 .map(|segment| segment.to_string())
7164 .collect::<Vec<_>>()
7165 .join("-");
7166 assert_eq!(
7167 router
7168 .lookup(&request, "/", &Method::Get)
7169 .ok()
7170 .and_then(|result| result.rewritten_host),
7171 Some(hostname_order),
7172 "every trie segment, inline or spilled, must reach $HOST[n] in hostname order",
7173 );
7174 }
7175
7176 /// The same hostname numbers its `$HOST[n]` captures the same way
7177 /// whichever position compiled it (sozu#1595). A pre/post rule reads
7178 /// one whole-host regex, so its groups come left to right; a tree rule
7179 /// matches one regex per label while the trie walks from the rightmost
7180 /// label, and `RouteResult::new_with_trie` renumbers them left to right.
7181 /// The second segment carries two groups, which keep their order inside
7182 /// the segment: reversing the flattened captures instead of the
7183 /// segments would be caught too.
7184 ///
7185 /// TO SEE THIS RED: drop the `.rev()` in `RouteResult::new_with_trie`;
7186 /// the tree rule then yields `x-9-1` where the pre/post rules yield
7187 /// `1-x-9`.
7188 #[test]
7189 fn a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule() {
7190 let rewritten_host = |position: RulePosition| {
7191 let mut front = test_http_frontend();
7192 front.hostname = "/a([0-9]+)/./([a-z])([0-9])/.example.com".to_owned();
7193 front.position = position;
7194 front.rewrite_host = Some("$HOST[1]-$HOST[2]-$HOST[3]".to_owned());
7195 let mut router = Router::new();
7196 router
7197 .add_http_front(&front)
7198 .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
7199 router
7200 .lookup("a1.x9.example.com", "/", &Method::Get)
7201 .ok()
7202 .and_then(|result| result.rewritten_host)
7203 };
7204
7205 for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
7206 assert_eq!(
7207 rewritten_host(position).as_deref(),
7208 Some("1-x-9"),
7209 "{position:?}: $HOST[n] must number the captures left to right",
7210 );
7211 }
7212 }
7213
7214 /// Grouping must not rescue a hostname the router rejects today. The
7215 /// wrapper supplies one `(` and one `)`, so an unbalanced segment can be
7216 /// balanced BY the wrapping — `a)(b` is not a regex, yet
7217 /// `\A(?:a)(b)\.example\.com\z` is, and it matches `ab.example.com`.
7218 /// Both hostname sites therefore compile the segment on its own before
7219 /// wrapping it, exactly as `PathRule::anchored_regex` does for a path.
7220 ///
7221 /// This covers that one direction. The converse is NOT guarded and is
7222 /// not asserted here: a segment that compiles bare can be rejected once
7223 /// wrapped. Such a hostname lands in `RouterError::InvalidDomain` at
7224 /// frontend registration, so it costs a loud rejection and not a
7225 /// mis-route.
7226 ///
7227 /// To SEE THIS RED: delete the `Regex::new(r).ok()?;` line in
7228 /// `convert_regex_domain_rule` — the first assertion then fails, the
7229 /// hostname builds, and `ab.example.com` routes. Delete the
7230 /// `Regex::new(segment).ok()?;` line in `pattern_trie.rs`'s
7231 /// `anchored_segment` instead and the `add_tree_rule` assertion fails.
7232 #[test]
7233 fn grouping_never_turns_a_rejected_hostname_regex_into_an_accepted_one() {
7234 // Assembled at run time rather than written as a literal because the
7235 // invalidity is the whole point and a literal invites a lint at the
7236 // `Regex::new` call site.
7237 let unbalanced = format!("/a{}b/.example.com", ")(");
7238
7239 assert_eq!(
7240 convert_regex_domain_rule(&unbalanced),
7241 None,
7242 "{unbalanced} carries a segment that is not a regex on its own",
7243 );
7244 assert_eq!(
7245 unbalanced.parse::<DomainRule>(),
7246 Err(()),
7247 "{unbalanced} must not become a usable DomainRule",
7248 );
7249 assert!(
7250 !Router::new().add_tree_rule(
7251 unbalanced.as_bytes(),
7252 &PathRule::Prefix("/".to_owned()),
7253 &MethodRule::new(Some("GET".to_owned())),
7254 &Route::ClusterId("RESCUED".into()),
7255 ),
7256 "{unbalanced} must not be storable in the trie either",
7257 );
7258
7259 // The hazard is real, not hypothetical: the wrapping alone turns it
7260 // into a live pattern matching a host the operator never wrote.
7261 let rescued = Regex::new(&format!("\\A(?:a{}b)\\.example\\.com\\z", ")("))
7262 .expect("the wrapping balances the segment");
7263 assert!(
7264 rescued.is_match(b"ab.example.com"),
7265 "{} is what the missing pre-validation would have built",
7266 rescued.as_str(),
7267 );
7268 }
7269
7270 /// One level up from the path rules, two overlapping regex HOSTNAME
7271 /// segments follow the same first-declared-wins rule: a trie node holds
7272 /// its regex segments in an ordered list, the lookup takes the first
7273 /// that matches AND serves the request, and a new segment is appended.
7274 /// Both segments below serve this request, so the first declared
7275 /// answers;
7276 /// `a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next`
7277 /// covers the case where it does not.
7278 ///
7279 /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7280 /// (`router/pattern_trie.rs`), iterate the regex segments in reverse —
7281 /// `for (regexp, child) in self.regexps.iter().rev()`. The last declared
7282 /// segment then answers and both assertions below flip.
7283 #[test]
7284 fn the_first_declared_regex_hostname_segment_wins_over_a_later_overlapping_one() {
7285 // Both patterns match the segment `test4`.
7286 let numbered: &[u8] = b"/test[0-9]/.example.com";
7287 let any_suffixed: &[u8] = b"/[a-z]+[0-9]/.example.com";
7288
7289 let declare = |first: &[u8], first_cluster: &str, second: &[u8], second_cluster: &str| {
7290 let mut router = Router::new();
7291 for (hostname, cluster) in [(first, first_cluster), (second, second_cluster)] {
7292 assert!(router.add_tree_rule(
7293 hostname,
7294 &PathRule::Prefix("/".to_owned()),
7295 &MethodRule::new(Some("GET".to_owned())),
7296 &Route::ClusterId(cluster.into()),
7297 ));
7298 }
7299 router
7300 .lookup("test4.example.com", "/", &Method::Get)
7301 .ok()
7302 .and_then(|result| result.cluster_id)
7303 };
7304
7305 assert_eq!(
7306 declare(numbered, "NUMBERED", any_suffixed, "ANY-SUFFIXED").as_deref(),
7307 Some("NUMBERED"),
7308 "the first-declared regex hostname segment must win",
7309 );
7310 assert_eq!(
7311 declare(any_suffixed, "ANY-SUFFIXED", numbered, "NUMBERED").as_deref(),
7312 Some("ANY-SUFFIXED"),
7313 "reversing the declaration order reverses the winner",
7314 );
7315
7316 // The worked example in `doc/configure.md` promises `/test[0-9]/`
7317 // matches `test4` "and not testAB". `[0-9]` is a SINGLE digit and the
7318 // segment is `\A`-`\z` anchored, so `test44` and `xtest4` must miss
7319 // too — the anchoring is pinned generally by
7320 // `segment_regex_rejects_partial_matches`, this pins the doc's own
7321 // example.
7322 let mut router = Router::new();
7323 assert!(router.add_tree_rule(
7324 numbered,
7325 &PathRule::Prefix("/".to_owned()),
7326 &MethodRule::new(Some("GET".to_owned())),
7327 &Route::ClusterId("REGIONAL".into()),
7328 ));
7329 assert_eq!(
7330 router
7331 .lookup("test4.example.com", "/", &Method::Get)
7332 .ok()
7333 .and_then(|result| result.cluster_id)
7334 .as_deref(),
7335 Some("REGIONAL"),
7336 );
7337 for miss in [
7338 "testAB.example.com",
7339 "test44.example.com",
7340 "xtest4.example.com",
7341 ] {
7342 assert!(
7343 router.lookup(miss, "/", &Method::Get).is_err(),
7344 "{miss} must not match the single-digit anchored segment",
7345 );
7346 }
7347 }
7348
7349 /// sozu#1351, shape one — the routing leak. An exact hostname added
7350 /// AFTER a regex segment that matches it used to attach its rule to
7351 /// the REGEX segment's leaf: `add_tree_rule` addresses the leaf
7352 /// through `domain_lookup_mut`, and that resolver walked a literal
7353 /// segment through the first regex segment matching it. The rule the
7354 /// operator wrote for `test4.example.com` was then served for every
7355 /// host `/test[0-9]/` matches, while `sozu query frontends` showed
7356 /// exactly what was typed.
7357 ///
7358 /// `TrieNode::lookup_mut` is key-addressed now: with
7359 /// `accept_wildcard: false` a literal key may not resolve into a
7360 /// non-literal entry (the guard `insert_sni_route` already documented
7361 /// for the wildcard slot, extended to the regex segments), so the add
7362 /// creates `test4.example.com`'s own node.
7363 ///
7364 /// To SEE THIS RED: in `TrieNode::lookup_mut`
7365 /// (`router/pattern_trie.rs`), drop the `accept_wildcard &&` guard in
7366 /// front of the `self.regexps` scan, so a literal segment resolves
7367 /// through a matching regex segment again. `test7` and `test9` then
7368 /// answer with EXACT-SPECIFIC.
7369 #[test]
7370 fn an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family() {
7371 let mut router = Router::new();
7372 assert!(router.add_tree_rule(
7373 b"/test[0-9]/.example.com",
7374 &PathRule::Prefix("/".to_owned()),
7375 &MethodRule::new(None),
7376 &Route::ClusterId("REGEX-FAMILY".into()),
7377 ));
7378 assert!(router.add_tree_rule(
7379 b"test4.example.com",
7380 &PathRule::Prefix("/only-for-test4".to_owned()),
7381 &MethodRule::new(None),
7382 &Route::ClusterId("EXACT-SPECIFIC".into()),
7383 ));
7384
7385 let resolve = |hostname: &str, path: &str| {
7386 router
7387 .lookup(hostname, path, &Method::Get)
7388 .ok()
7389 .and_then(|result| result.cluster_id)
7390 };
7391
7392 assert_eq!(
7393 resolve("test4.example.com", "/only-for-test4").as_deref(),
7394 Some("EXACT-SPECIFIC"),
7395 "the host the rule was written for must get it",
7396 );
7397 for leaked in ["test7.example.com", "test9.example.com"] {
7398 assert_eq!(
7399 resolve(leaked, "/only-for-test4").as_deref(),
7400 Some("REGEX-FAMILY"),
7401 "{leaked} must keep the family route and never reach the \
7402 rule scoped to test4",
7403 );
7404 }
7405 assert_eq!(
7406 resolve("test4.example.com", "/").as_deref(),
7407 Some("REGEX-FAMILY"),
7408 "the exact node carries only /only-for-test4, so every other \
7409 path on that host falls through to the regex segment that \
7410 also claims it",
7411 );
7412 }
7413
7414 /// sozu#1351 — the clearest statement of the same bug, and of its fix:
7415 /// declaring the exact host and the regex segment that matches it in
7416 /// either order must produce the SAME routing table. Before the fix
7417 /// the regex-first order attached the exact rule to the regex leaf
7418 /// (so `test7` answered with it and `test4/` answered at all), while
7419 /// the exact-first order built two separate nodes.
7420 ///
7421 /// To SEE THIS RED: same mutation as
7422 /// `an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family`
7423 /// — drop the `accept_wildcard &&` guard in front of the
7424 /// `self.regexps` scan of `TrieNode::lookup_mut`. The two arrays then
7425 /// differ on `test4.example.com/` and on `test7.example.com`.
7426 #[test]
7427 fn hostname_routing_does_not_depend_on_declaration_order() {
7428 let exact: (&[u8], &str, &str) =
7429 (b"test4.example.com", "/only-for-test4", "EXACT-SPECIFIC");
7430 let regex: (&[u8], &str, &str) = (b"/test[0-9]/.example.com", "/", "REGEX-FAMILY");
7431
7432 let routes = |declared: [(&[u8], &str, &str); 2]| {
7433 let mut router = Router::new();
7434 for (hostname, path, cluster) in declared {
7435 assert!(router.add_tree_rule(
7436 hostname,
7437 &PathRule::Prefix(path.to_owned()),
7438 &MethodRule::new(None),
7439 &Route::ClusterId(cluster.into()),
7440 ));
7441 }
7442 [
7443 ("test4.example.com", "/only-for-test4"),
7444 ("test4.example.com", "/"),
7445 ("test7.example.com", "/only-for-test4"),
7446 ("test9.example.com", "/"),
7447 ]
7448 .map(|(hostname, path)| {
7449 router
7450 .lookup(hostname, path, &Method::Get)
7451 .ok()
7452 .and_then(|result| result.cluster_id)
7453 })
7454 };
7455
7456 let regex_first = routes([regex, exact]);
7457 let exact_first = routes([exact, regex]);
7458 assert_eq!(
7459 regex_first, exact_first,
7460 "hostname resolution must not depend on the order the \
7461 frontends were added in",
7462 );
7463 assert_eq!(
7464 regex_first[0].as_deref(),
7465 Some("EXACT-SPECIFIC"),
7466 "and the order-independent answer is the exact host's own rule",
7467 );
7468 assert_eq!(
7469 regex_first[2].as_deref(),
7470 Some("REGEX-FAMILY"),
7471 "while a sibling of the regex family never sees it",
7472 );
7473 }
7474
7475 /// Hostname precedence is most-specific-first: an exact name beats a
7476 /// regex segment, a regex segment beats the `*` wildcard. One
7477 /// assertion per transition, since `doc/configure.md` states the order
7478 /// in prose.
7479 ///
7480 /// Regex-over-wildcard is the half that changed: `lookup_with_path`
7481 /// used to consult the wildcard slot BEFORE the regex list whenever
7482 /// the leftmost label was reached, so `*` outranked the narrower
7483 /// pattern.
7484 ///
7485 /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7486 /// (`router/pattern_trie.rs`), move the wildcard block above the
7487 /// `self.regexps` loop. The REGEX-over-WILDCARD assertions then answer
7488 /// WILDCARD.
7489 #[test]
7490 fn hostname_precedence_is_exact_then_regex_then_wildcard() {
7491 let exact: &[u8] = b"test4.example.com";
7492 let regex: &[u8] = b"/test[0-9]/.example.com";
7493 let wildcard: &[u8] = b"*.example.com";
7494
7495 let declare = |hostnames: &[(&[u8], &str)]| {
7496 let mut router = Router::new();
7497 for (hostname, cluster) in hostnames {
7498 assert!(router.add_tree_rule(
7499 hostname,
7500 &PathRule::Prefix("/".to_owned()),
7501 &MethodRule::new(None),
7502 &Route::ClusterId((*cluster).into()),
7503 ));
7504 }
7505 router
7506 .lookup("test4.example.com", "/", &Method::Get)
7507 .ok()
7508 .and_then(|result| result.cluster_id)
7509 };
7510
7511 assert_eq!(
7512 declare(&[(regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7513 Some("EXACT"),
7514 "an exact hostname outranks a regex segment that matches it",
7515 );
7516 assert_eq!(
7517 declare(&[(wildcard, "WILDCARD"), (exact, "EXACT")]).as_deref(),
7518 Some("EXACT"),
7519 "an exact hostname outranks the wildcard",
7520 );
7521 assert_eq!(
7522 declare(&[(wildcard, "WILDCARD"), (regex, "REGEX")]).as_deref(),
7523 Some("REGEX"),
7524 "a regex segment outranks the wildcard",
7525 );
7526 assert_eq!(
7527 declare(&[(regex, "REGEX"), (wildcard, "WILDCARD")]).as_deref(),
7528 Some("REGEX"),
7529 "and it outranks it in the other declaration order too",
7530 );
7531 assert_eq!(
7532 declare(&[(wildcard, "WILDCARD"), (regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7533 Some("EXACT"),
7534 "with all three declared the most specific one answers",
7535 );
7536 assert_eq!(
7537 declare(&[(wildcard, "WILDCARD")]).as_deref(),
7538 Some("WILDCARD"),
7539 "the wildcard still answers when nothing narrower claims the host",
7540 );
7541 }
7542
7543 /// The precedence order above is a search order, not a filter: a more
7544 /// specific hostname candidate that serves no rule for THIS request
7545 /// hands the request to the next one instead of ending the lookup.
7546 /// Without this, splitting a host's paths across an exact frontend and
7547 /// a regex family would make every path the exact frontend does not
7548 /// carry unroutable.
7549 ///
7550 /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7551 /// (`router/pattern_trie.rs`), return
7552 /// `child.lookup_recursive(prefix, accept_wildcard, trace, accept)`
7553 /// directly from the `self.children.get(suffix)` arm instead of
7554 /// falling through on `None` — the pre-fix shape. `/regex-only` and
7555 /// `/served-by-neither` on `test4.example.com` then answer `None`.
7556 #[test]
7557 fn a_hostname_candidate_that_serves_no_rule_falls_through_to_the_next() {
7558 let mut router = Router::new();
7559 for (hostname, path, cluster) in [
7560 (&b"test4.example.com"[..], "/exact-only", "EXACT"),
7561 (&b"/test[0-9]/.example.com"[..], "/regex-only", "REGEX"),
7562 (&b"*.example.com"[..], "/", "WILDCARD"),
7563 ] {
7564 assert!(router.add_tree_rule(
7565 hostname,
7566 &PathRule::Prefix(path.to_owned()),
7567 &MethodRule::new(None),
7568 &Route::ClusterId(cluster.into()),
7569 ));
7570 }
7571
7572 let resolve = |hostname: &str, path: &str| {
7573 router
7574 .lookup(hostname, path, &Method::Get)
7575 .ok()
7576 .and_then(|result| result.cluster_id)
7577 };
7578
7579 assert_eq!(
7580 resolve("test4.example.com", "/exact-only").as_deref(),
7581 Some("EXACT"),
7582 );
7583 assert_eq!(
7584 resolve("test4.example.com", "/regex-only").as_deref(),
7585 Some("REGEX"),
7586 "the exact node carries no rule for this path, so the regex \
7587 segment that also claims the host answers",
7588 );
7589 assert_eq!(
7590 resolve("test4.example.com", "/served-by-neither").as_deref(),
7591 Some("WILDCARD"),
7592 "and when neither serves it the wildcard does",
7593 );
7594 assert_eq!(
7595 resolve("testA.example.com", "/regex-only").as_deref(),
7596 Some("WILDCARD"),
7597 "a host the regex segment does not match never reaches it",
7598 );
7599 }
7600
7601 /// sozu#1351, shape two — the 404. Hostname candidates are chosen
7602 /// without looking at the request method, so the first segment to
7603 /// claim a host used to end the lookup even when its only rule was
7604 /// declared for another method. The second segment, which would have
7605 /// answered, was never consulted.
7606 ///
7607 /// The fallback above covers it: `Router::lookup` hands the trie the
7608 /// same (path, method) selection it will run on the winner, so a
7609 /// candidate that selects nothing is skipped.
7610 ///
7611 /// To SEE THIS RED: in `Router::lookup`, pass `&mut |_| true` as the
7612 /// `accept` predicate of `lookup_with_path` instead of
7613 /// `select_tree_rule(...).is_some()`. The GET below then answers
7614 /// `None` because the POST-only segment was declared first.
7615 #[test]
7616 fn a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next() {
7617 let mut router = Router::new();
7618 // Both segments match `test4`; the POST-only one is declared first.
7619 assert!(router.add_tree_rule(
7620 b"/test[0-9]/.example.com",
7621 &PathRule::Prefix("/".to_owned()),
7622 &MethodRule::new(Some("POST".to_owned())),
7623 &Route::ClusterId("POST-ONLY".into()),
7624 ));
7625 assert!(router.add_tree_rule(
7626 b"/[a-z]+[0-9]/.example.com",
7627 &PathRule::Prefix("/".to_owned()),
7628 &MethodRule::new(Some("GET".to_owned())),
7629 &Route::ClusterId("GET-ONLY".into()),
7630 ));
7631
7632 let resolve = |method: &Method| {
7633 router
7634 .lookup("test4.example.com", "/", method)
7635 .ok()
7636 .and_then(|result| result.cluster_id)
7637 };
7638
7639 assert_eq!(
7640 resolve(&Method::Get).as_deref(),
7641 Some("GET-ONLY"),
7642 "the first-declared segment carries no GET rule, so the second \
7643 one answers instead of the request 404ing",
7644 );
7645 assert_eq!(
7646 resolve(&Method::Post).as_deref(),
7647 Some("POST-ONLY"),
7648 "and the first-declared segment still wins whenever it serves \
7649 the request",
7650 );
7651 }
7652
7653 /// `PathRule`'s `PartialEq` carried no `(Equals, Equals)` arm, so two
7654 /// identical `PathRule::Equals` compared unequal — an equality that is
7655 /// not even reflexive. Every router bookkeeping path is written against
7656 /// it: `add_tree_rule` could not see the rule it had just pushed (so the
7657 /// same `--path-equals` frontend was stored again on every re-add
7658 /// instead of being refused), `remove_tree_rule`'s `retain` kept the
7659 /// entry it was asked to drop, and `remove_http_front` still answered
7660 /// `Ok` for a route it had not removed.
7661 ///
7662 /// To SEE THIS RED: drop the
7663 /// `(PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2` arm from
7664 /// `impl std::cmp::PartialEq for PathRule` so the catch-all `_ => false`
7665 /// answers again — the reflexivity assertion below is the first statement
7666 /// and fails on `left: Equals("/exact") / right: Equals("/exact")`.
7667 /// Without that assertion a debug build panics one line later, inside
7668 /// `add_http_front`, on "a freshly inserted tree domain must resolve to
7669 /// its inserted rule".
7670 #[test]
7671 fn an_equals_path_frontend_is_deduplicated_and_removable() {
7672 assert_eq!(
7673 PathRule::Equals("/exact".to_owned()),
7674 PathRule::Equals("/exact".to_owned()),
7675 "PathRule equality must be reflexive for the Equals variant",
7676 );
7677
7678 let mut router = Router::new();
7679 let mut front = test_http_frontend();
7680 front.path = CommandPathRule::equals("/exact".to_owned());
7681
7682 router
7683 .add_http_front(&front)
7684 .expect("an Equals frontend must be added");
7685 assert!(
7686 router.lookup("example.com", "/exact", &Method::Get).is_ok(),
7687 "the Equals frontend must resolve once added",
7688 );
7689
7690 // The identical frontend is already stored, so the router must
7691 // refuse it instead of pushing a second, unremovable copy.
7692 assert!(
7693 matches!(router.add_http_front(&front), Err(RouterError::AddRoute(_))),
7694 "re-adding the same Equals frontend must be refused, not duplicated",
7695 );
7696
7697 router
7698 .remove_http_front(&front)
7699 .expect("an Equals frontend must be removable");
7700 assert!(
7701 router
7702 .lookup("example.com", "/exact", &Method::Get)
7703 .is_err(),
7704 "the Equals frontend must no longer resolve once removed",
7705 );
7706 }
7707
7708 // ── RFC 9110 §4.2.3: the Host is case-insensitive ──────────────────
7709 //
7710 // `add_tree_rule` (via `tree_hostname_to_ascii`) and
7711 // `DomainRule::from_str` both normalise a configured hostname
7712 // through `idna::domain_to_ascii`, which ASCII-lowercases (measured:
7713 // `"WWW.EXAMPLE.COM"` -> `"www.example.com"`, `"*.EXAMPLE.COM"` ->
7714 // `"*.example.com"`). A `/`-delimited regex segment is the one part
7715 // that is NOT normalised — see `tree_hostname_to_ascii` and
7716 // sozu#1377 — and is compiled case-insensitively instead, so an
7717 // uppercase literal inside it still meets the normalised key.
7718 // `Router::lookup` used to walk the
7719 // trie and the pre/post lists with the client's raw bytes, so the two
7720 // sides disagreed and no configuration could make an uppercase
7721 // `Host:` route. The tests below pin the restored invariant: add and
7722 // lookup normalise identically.
7723
7724 /// To SEE THIS RED: in `Router::lookup`, replace
7725 /// `let normalized_hostname = normalize_hostname(hostname);` with
7726 /// `let normalized_hostname = Cow::Borrowed(hostname);`. Every
7727 /// uppercase and mixed-case assertion below then fails with
7728 /// `left: None, right: Some("CASE")` — the byte-exact trie walk the
7729 /// fix removed.
7730 #[test]
7731 fn an_uppercase_host_reaches_a_lowercase_tree_frontend() {
7732 let mut router = Router::new();
7733 assert!(router.add_tree_rule(
7734 b"www.example.com",
7735 &PathRule::Prefix("/".to_owned()),
7736 &MethodRule::new(Some("GET".to_owned())),
7737 &Route::ClusterId("CASE".into()),
7738 ));
7739
7740 let resolve = |hostname: &str| {
7741 router
7742 .lookup(hostname, "/", &Method::Get)
7743 .ok()
7744 .and_then(|result| result.cluster_id)
7745 };
7746
7747 for host in [
7748 "www.example.com",
7749 "WWW.EXAMPLE.COM",
7750 "WwW.ExAmPlE.cOm",
7751 "www.EXAMPLE.com",
7752 ] {
7753 assert_eq!(
7754 resolve(host).as_deref(),
7755 Some("CASE"),
7756 "Host {host:?} names the same frontend as its lowercase form",
7757 );
7758 }
7759 }
7760
7761 /// The other half of the issue: declaring the frontend in uppercase
7762 /// never was a workaround, because the add path lowercases it on the
7763 /// way in. Both spellings of the frontend must route both spellings
7764 /// of the Host.
7765 ///
7766 /// To SEE THIS RED: same mutation as
7767 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7768 #[test]
7769 fn an_uppercase_tree_frontend_is_stored_and_looked_up_lowercase() {
7770 let mut router = Router::new();
7771 assert!(router.add_tree_rule(
7772 b"WWW.EXAMPLE.COM",
7773 &PathRule::Prefix("/".to_owned()),
7774 &MethodRule::new(Some("GET".to_owned())),
7775 &Route::ClusterId("CASE".into()),
7776 ));
7777
7778 for host in ["www.example.com", "WWW.EXAMPLE.COM"] {
7779 assert_eq!(
7780 router
7781 .lookup(host, "/", &Method::Get)
7782 .ok()
7783 .and_then(|result| result.cluster_id)
7784 .as_deref(),
7785 Some("CASE"),
7786 "an uppercase frontend is stored lowercase, so {host:?} must resolve",
7787 );
7788 }
7789 }
7790
7791 /// Wildcard and regex trie segments walk the same normalised key.
7792 /// The regex segment is the interesting one: its source is stored
7793 /// VERBATIM (`tree_hostname_to_ascii` normalises the literal labels
7794 /// only, because folding the source inverted `\D` into `\d` —
7795 /// sozu#1377), so an uppercase literal inside it meets the
7796 /// lowercased lookup key only because
7797 /// `pattern_trie::compiled_segment` compiles it case-insensitively.
7798 ///
7799 /// To SEE THIS RED: same mutation as
7800 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`. Dropping
7801 /// `.case_insensitive(true)` from `compiled_segment` reddens the
7802 /// `API7` assertion alone, which is the half this test also guards.
7803 #[test]
7804 fn an_uppercase_host_reaches_wildcard_and_regex_tree_segments() {
7805 let mut router = Router::new();
7806 assert!(router.add_tree_rule(
7807 b"*.wild.example.com",
7808 &PathRule::Prefix("/".to_owned()),
7809 &MethodRule::new(Some("GET".to_owned())),
7810 &Route::ClusterId("WILDCARD".into()),
7811 ));
7812 assert!(router.add_tree_rule(
7813 b"/API[0-9]/.rx.example.com",
7814 &PathRule::Prefix("/".to_owned()),
7815 &MethodRule::new(Some("GET".to_owned())),
7816 &Route::ClusterId("REGEX".into()),
7817 ));
7818
7819 let resolve = |hostname: &str| {
7820 router
7821 .lookup(hostname, "/", &Method::Get)
7822 .ok()
7823 .and_then(|result| result.cluster_id)
7824 };
7825
7826 assert_eq!(resolve("FOO.WILD.EXAMPLE.COM").as_deref(), Some("WILDCARD"));
7827 assert_eq!(resolve("FoO.wild.EXAMPLE.com").as_deref(), Some("WILDCARD"));
7828 assert_eq!(
7829 resolve("API7.RX.EXAMPLE.COM").as_deref(),
7830 Some("REGEX"),
7831 "the operator's `/API[0-9]/` is stored verbatim and compiled \
7832 case-insensitively, so the normalised key matches it",
7833 );
7834 assert_eq!(resolve("api7.rx.example.com").as_deref(), Some("REGEX"));
7835 }
7836
7837 /// Pre and post rules carry a `DomainRule` rather than a trie node.
7838 /// `Exact` and `Wildcard` are lowercased by `DomainRule::from_str`
7839 /// (again `idna::domain_to_ascii`) and compared byte-exact, so they
7840 /// had exactly the tree's asymmetry.
7841 ///
7842 /// To SEE THIS RED: same mutation as
7843 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7844 #[test]
7845 fn an_uppercase_host_reaches_exact_and_wildcard_pre_post_rules() {
7846 let mut router = Router::new();
7847 let exact = "pre.example.com"
7848 .parse::<DomainRule>()
7849 .expect("an exact domain rule must parse");
7850 let wildcard = "*.post.example.com"
7851 .parse::<DomainRule>()
7852 .expect("a wildcard domain rule must parse");
7853 assert!(router.add_pre_rule(
7854 &exact,
7855 &PathRule::Prefix("/".to_owned()),
7856 &MethodRule::new(None),
7857 &Route::ClusterId("PRE".into()),
7858 ));
7859 assert!(router.add_post_rule(
7860 &wildcard,
7861 &PathRule::Prefix("/".to_owned()),
7862 &MethodRule::new(None),
7863 &Route::ClusterId("POST".into()),
7864 ));
7865
7866 let resolve = |hostname: &str| {
7867 router
7868 .lookup(hostname, "/", &Method::Get)
7869 .ok()
7870 .and_then(|result| result.cluster_id)
7871 };
7872
7873 assert_eq!(resolve("PRE.EXAMPLE.COM").as_deref(), Some("PRE"));
7874 assert_eq!(resolve("PrE.ExAmPlE.cOm").as_deref(), Some("PRE"));
7875 assert_eq!(resolve("FOO.POST.EXAMPLE.COM").as_deref(), Some("POST"));
7876 assert_eq!(resolve("foo.post.example.com").as_deref(), Some("POST"));
7877 }
7878
7879 /// The interaction that makes normalising the lookup key dangerous on
7880 /// its own: a pre/post hostname REGEX keeps the operator's bytes —
7881 /// `convert_regex_domain_rule` copies the segment source verbatim,
7882 /// as the trie now does too (`tree_hostname_to_ascii`, sozu#1377;
7883 /// up to and including 2.2.1 `domain_to_ascii` lowercased it there).
7884 /// Matching a
7885 /// normalised key against a case-sensitive `/API[0-9]/` would have
7886 /// turned a rule that used to match `API7.…` into one that matches
7887 /// nothing. `DomainRule::from_str` therefore compiles hostname
7888 /// regexes case-insensitively, which is also the only reading
7889 /// that agrees with the trie: `pattern_trie::compiled_segment`
7890 /// folds the same way, on the same stored bytes.
7891 ///
7892 /// To SEE THIS RED: in `DomainRule::from_str`, drop the
7893 /// `.case_insensitive(true)` from the `RegexBuilder` and build with
7894 /// `regex::bytes::Regex::new(&s)` again. Both assertions below fail
7895 /// with `left: None, right: Some("RX")`.
7896 #[test]
7897 fn an_uppercase_pre_rule_hostname_regex_still_matches_its_own_host() {
7898 let mut router = Router::new();
7899 let uppercase_regex = "/API[0-9]/.example.com"
7900 .parse::<DomainRule>()
7901 .expect("an uppercase hostname regex must parse");
7902 assert!(router.add_pre_rule(
7903 &uppercase_regex,
7904 &PathRule::Prefix("/".to_owned()),
7905 &MethodRule::new(None),
7906 &Route::ClusterId("RX".into()),
7907 ));
7908
7909 let resolve = |hostname: &str| {
7910 router
7911 .lookup(hostname, "/", &Method::Get)
7912 .ok()
7913 .and_then(|result| result.cluster_id)
7914 };
7915
7916 assert_eq!(
7917 resolve("API7.example.com").as_deref(),
7918 Some("RX"),
7919 "the spelling the operator wrote must keep routing",
7920 );
7921 assert_eq!(
7922 resolve("api7.example.com").as_deref(),
7923 Some("RX"),
7924 "and its canonical lowercase form, which never routed before",
7925 );
7926 }
7927
7928 /// The regression guard for the `.unicode(false)` that
7929 /// [`DomainRule::from_str`] deliberately does NOT set. Setting it
7930 /// would make the case folding ASCII-only — which is what the
7931 /// hostnames ever reaching `matches` are anyway — but it also
7932 /// refuses Unicode-class syntax at compile time, and `\p{L}` matches
7933 /// ASCII letters perfectly well, so a frontend that installs and
7934 /// routes today would start being rejected at add time.
7935 ///
7936 /// To SEE THIS RED: add `.unicode(false)` to the `RegexBuilder` in
7937 /// `DomainRule::from_str`. The parse fails and the `expect` fires.
7938 #[test]
7939 fn a_unicode_class_hostname_regex_still_compiles_and_routes() {
7940 let mut router = Router::new();
7941 let unicode_class = "/\\p{L}+[0-9]/.example.com"
7942 .parse::<DomainRule>()
7943 .expect("a hostname regex using a Unicode class must still compile");
7944 assert!(router.add_pre_rule(
7945 &unicode_class,
7946 &PathRule::Prefix("/".to_owned()),
7947 &MethodRule::new(None),
7948 &Route::ClusterId("UNICODE".into()),
7949 ));
7950
7951 let resolve = |hostname: &str| {
7952 router
7953 .lookup(hostname, "/", &Method::Get)
7954 .ok()
7955 .and_then(|result| result.cluster_id)
7956 };
7957
7958 assert_eq!(
7959 resolve("api7.example.com").as_deref(),
7960 Some("UNICODE"),
7961 "a Unicode letter class covers ASCII letters, so this rule is live, not decorative",
7962 );
7963 assert_eq!(
7964 resolve("API7.example.com").as_deref(),
7965 Some("UNICODE"),
7966 "and it stays live for the uppercase spelling of the same host",
7967 );
7968 }
7969
7970 /// Normalisation and the hostname precedence order of sozu#1351
7971 /// compose in one direction only: the key is normalised first, then
7972 /// the most-specific-first search runs on it, so case never decides
7973 /// WHICH candidate wins. Stated in `doc/configure.md` under
7974 /// "Hostname case", so it needs an assertion.
7975 ///
7976 /// The shape is the one the precedence section uses: an exact name
7977 /// and a regex segment that also claims it. The exact frontend must
7978 /// win for both spellings — a normalisation applied anywhere after
7979 /// the candidate search would let the uppercase spelling miss the
7980 /// exact node and fall through to the regex family.
7981 ///
7982 /// To SEE THIS RED: in `Router::lookup`, replace
7983 /// `let normalized_hostname = normalize_hostname(hostname);` with
7984 /// `let normalized_hostname = Cow::Borrowed(hostname);`. The
7985 /// uppercase spelling stops resolving at all.
7986 #[test]
7987 fn case_normalisation_runs_before_the_hostname_precedence_search() {
7988 let mut router = Router::new();
7989 assert!(router.add_tree_rule(
7990 b"test4.example.com",
7991 &PathRule::Prefix("/".to_owned()),
7992 &MethodRule::new(Some("GET".to_owned())),
7993 &Route::ClusterId("EXACT".into()),
7994 ));
7995 assert!(router.add_tree_rule(
7996 b"/test[0-9]/.example.com",
7997 &PathRule::Prefix("/".to_owned()),
7998 &MethodRule::new(Some("GET".to_owned())),
7999 &Route::ClusterId("REGEX-FAMILY".into()),
8000 ));
8001
8002 let resolve = |hostname: &str| {
8003 router
8004 .lookup(hostname, "/", &Method::Get)
8005 .ok()
8006 .and_then(|result| result.cluster_id)
8007 };
8008
8009 assert_eq!(
8010 resolve("test4.example.com").as_deref(),
8011 Some("EXACT"),
8012 "the exact name outranks a regex segment that also claims it",
8013 );
8014 for spelling in ["TEST4.EXAMPLE.COM", "TeSt4.ExAmPlE.cOm"] {
8015 assert_eq!(
8016 resolve(spelling).as_deref(),
8017 Some("EXACT"),
8018 "{spelling:?} must pick the same tier as its lowercase form, \
8019 not fall through to the regex family",
8020 );
8021 }
8022
8023 // A host only the regex family claims still reaches it in either
8024 // spelling: normalising did not collapse the tiers, it fed them.
8025 assert_eq!(
8026 resolve("test7.example.com").as_deref(),
8027 Some("REGEX-FAMILY")
8028 );
8029 assert_eq!(
8030 resolve("TEST7.EXAMPLE.COM").as_deref(),
8031 Some("REGEX-FAMILY")
8032 );
8033 }
8034
8035 /// `has_hostname` decides whether a removed frontend's cached tags may
8036 /// be dropped. It is fed the CONFIGURED hostname, which the pre/post
8037 /// arms then compared byte-exact against a `DomainRule` the add path
8038 /// had already lowercased — so an uppercase frontend answered `false`
8039 /// while its route was still installed, and the tags went away under a
8040 /// live route.
8041 ///
8042 /// To SEE THIS RED: in `has_hostname`, replace
8043 /// `let normalized_hostname = normalize_hostname(hostname);` with
8044 /// `let normalized_hostname = Cow::Borrowed(hostname);`. The two
8045 /// uppercase assertions fail.
8046 #[test]
8047 fn has_hostname_answers_for_an_uppercase_spelling() {
8048 let mut router = Router::new();
8049 assert!(router.add_tree_rule(
8050 b"tree.example.com",
8051 &PathRule::Prefix("/".to_owned()),
8052 &MethodRule::new(Some("GET".to_owned())),
8053 &Route::ClusterId("TREE".into()),
8054 ));
8055 assert!(
8056 router.add_pre_rule(
8057 &"pre.example.com"
8058 .parse::<DomainRule>()
8059 .expect("an exact domain rule must parse"),
8060 &PathRule::Prefix("/".to_owned()),
8061 &MethodRule::new(None),
8062 &Route::ClusterId("PRE".into()),
8063 )
8064 );
8065
8066 assert!(router.has_hostname("TREE.EXAMPLE.COM"));
8067 assert!(router.has_hostname("PRE.EXAMPLE.COM"));
8068 assert!(!router.has_hostname("ABSENT.EXAMPLE.COM"));
8069 }
8070
8071 /// `$HOST[n]` captures are taken from the key the router matched on,
8072 /// so a rewrite template now emits the normalised host rather than
8073 /// the client's raw bytes. That is the intended reading — the
8074 /// configured frontend is itself stored lowercase — and it is stated
8075 /// in `doc/configure.md`, so it needs an assertion.
8076 ///
8077 /// To SEE THIS RED: same mutation as
8078 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`; the rewrite
8079 /// stops resolving at all (`RouteNotFound`) and the `expect` fires.
8080 #[test]
8081 fn a_host_rewrite_capture_sees_the_normalized_host() {
8082 let mut router = Router::new();
8083 router
8084 .add_http_front(&HttpFrontend {
8085 hostname: "rewrite.example.com".to_owned(),
8086 rewrite_host: Some("$HOST[0].internal".to_owned()),
8087 ..test_http_frontend()
8088 })
8089 .expect("the rewrite frontend must install");
8090
8091 let route = router
8092 .lookup("REWRITE.EXAMPLE.COM", "/", &Method::Get)
8093 .expect("an uppercase Host must resolve");
8094 assert_eq!(
8095 route.rewritten_host.as_deref(),
8096 Some("rewrite.example.com.internal"),
8097 "`$HOST[0]` carries the normalised host, not the client's bytes",
8098 );
8099 }
8100
8101 /// The normalisation must cost a scan and not an allocation on the
8102 /// overwhelmingly common path — every conforming client already sends
8103 /// a lowercase Host, and this runs once per request.
8104 ///
8105 /// To SEE THIS RED: make `normalize_hostname` return
8106 /// `Cow::Owned(hostname.to_ascii_lowercase())` unconditionally. The
8107 /// first assertion fails.
8108 #[test]
8109 fn normalize_hostname_borrows_an_already_lowercase_host() {
8110 assert!(
8111 matches!(normalize_hostname("www.example.com"), Cow::Borrowed(_)),
8112 "a lowercase host must not allocate",
8113 );
8114 assert!(
8115 matches!(normalize_hostname("123.example-host.com"), Cow::Borrowed(_)),
8116 "digits and hyphens are not uppercase and must not allocate",
8117 );
8118 assert_eq!(
8119 normalize_hostname("WwW.ExAmPlE.cOm"),
8120 Cow::Owned::<str>("www.example.com".to_owned()),
8121 );
8122 }
8123
8124 /// Known gap, deliberately NOT closed here: RFC 1034 §3.1 makes
8125 /// `www.example.com.` the absolute form of `www.example.com`, but
8126 /// `idna::domain_to_ascii` keeps the trailing dot on the add path
8127 /// (measured: `"www.example.com."` -> `"www.example.com."`) and the
8128 /// trie then splits it into a trailing empty label that no
8129 /// dot-free frontend carries. The TLS side already strips one
8130 /// trailing dot (`lib/src/https.rs`'s `sni_owned`,
8131 /// `lib/src/protocol/tcp_preread/mod.rs`'s `normalize_sni`); HTTP
8132 /// routing does not. This test records the current answer so the day
8133 /// someone fixes it, it fails here and gets inverted rather than
8134 /// silently changing behaviour.
8135 ///
8136 /// To SEE THIS RED: strip a single trailing `.` inside
8137 /// `normalize_hostname`.
8138 #[test]
8139 fn an_absolute_form_host_does_not_reach_a_relative_frontend_yet() {
8140 let mut router = Router::new();
8141 assert!(router.add_tree_rule(
8142 b"www.example.com",
8143 &PathRule::Prefix("/".to_owned()),
8144 &MethodRule::new(Some("GET".to_owned())),
8145 &Route::ClusterId("CASE".into()),
8146 ));
8147
8148 assert!(
8149 router
8150 .lookup("www.example.com.", "/", &Method::Get)
8151 .is_err(),
8152 "absolute-form Host routing is a separate defect, tracked \
8153 apart from the case asymmetry",
8154 );
8155 assert!(
8156 router
8157 .lookup("WWW.EXAMPLE.COM.", "/", &Method::Get)
8158 .is_err(),
8159 "normalising the case must not accidentally close it either",
8160 );
8161 }
8162
8163 // ── sozu#1377: IDNA folding must not reach a regex segment ─────────
8164 //
8165 // `add_tree_rule`, `remove_tree_rule` and `has_hostname` used to run
8166 // the WHOLE configured hostname through `idna::domain_to_ascii`,
8167 // which ASCII-lowercases (measured: `"/\\D+/.example.com"` ->
8168 // `"/\\d+/.example.com"`, `"/[^\\D]/.example.com"` ->
8169 // `"/[^\\d]/.example.com"`, `"/API[0-9]/.rx.example.com"` ->
8170 // `"/api[0-9]/.rx.example.com"`). Folding an uppercase regex escape
8171 // INVERTS the character class it names -- `\D` is "not a digit",
8172 // `\d` is "a digit"; `\W`/`\w` and `\S`/`\s` invert the same way --
8173 // so the stored rule matched the exact COMPLEMENT of what the
8174 // operator wrote while `sozu query frontends` still echoed the
8175 // original spelling.
8176 //
8177 // `Pre`/`Post` never carried it: `convert_regex_domain_rule` copies a
8178 // segment's source verbatim and `DomainRule::from_str` takes its case
8179 // insensitivity from the `RegexBuilder` instead. The tree now agrees:
8180 // `tree_hostname_to_ascii` normalises only the literal labels and
8181 // `pattern_trie::compiled_segment` folds case at compile time.
8182
8183 /// To SEE THIS RED: in `tree_hostname_to_ascii`, replace the whole
8184 /// body with `::idna::domain_to_ascii(hostname).ok()`, the form the
8185 /// three call sites carried up to 2.2.1. Both assertions then fail
8186 /// INVERTED -- `abc.rx.example.com` resolves to `None` and
8187 /// `777.rx.example.com` to `Some("NOT-A-DIGIT")` -- which is the
8188 /// defect itself: the rule matches the complement of its source --
8189 /// `\D` matching a digit.
8190 #[test]
8191 fn a_tree_hostname_regex_escape_is_not_case_folded() {
8192 let mut router = Router::new();
8193 assert!(router.add_tree_rule(
8194 "/\\D+/.rx.example.com".as_bytes(),
8195 &PathRule::Prefix("/".to_owned()),
8196 &MethodRule::new(None),
8197 &Route::ClusterId("NOT-A-DIGIT".into()),
8198 ));
8199
8200 let resolve = |hostname: &str| {
8201 router
8202 .lookup(hostname, "/", &Method::Get)
8203 .ok()
8204 .and_then(|result| result.cluster_id)
8205 };
8206
8207 assert_eq!(
8208 resolve("777.rx.example.com"),
8209 None,
8210 "`\\D+` must NOT match a run of digits; folded to `\\d+` it matched \
8211 exactly this and nothing else",
8212 );
8213 assert_eq!(
8214 resolve("abc.rx.example.com").as_deref(),
8215 Some("NOT-A-DIGIT"),
8216 "`\\D+` is \"one or more non-digits\" and `abc` is three of them",
8217 );
8218 }
8219
8220 /// The negated-class spelling from the report, and the two other
8221 /// uppercase escapes that invert: `\W` (not a word character) and
8222 /// `\S` (not whitespace). `[^\D]` is "not a non-digit", i.e. a digit;
8223 /// folded to `[^\d]` it becomes its own complement.
8224 ///
8225 /// To SEE THIS RED: same mutation as
8226 /// `a_tree_hostname_regex_escape_is_not_case_folded`. Every
8227 /// `matches` assertion below fails, each with its complement.
8228 #[test]
8229 fn every_inverting_uppercase_regex_escape_survives_a_tree_insert() {
8230 // (segment source, a host label it must match, one it must not)
8231 for (segment, hit, miss) in [
8232 ("[^\\D]+", "777", "abc"),
8233 ("\\W+", "---", "abc"),
8234 ("\\S+", "abc", " "),
8235 ] {
8236 let mut router = Router::new();
8237 let hostname = format!("/{segment}/.rx.example.com");
8238 assert!(
8239 router.add_tree_rule(
8240 hostname.as_bytes(),
8241 &PathRule::Prefix("/".to_owned()),
8242 &MethodRule::new(None),
8243 &Route::ClusterId("ESCAPE".into()),
8244 ),
8245 "{hostname} must install",
8246 );
8247
8248 let resolve = |hostname: &str| {
8249 router
8250 .lookup(hostname, "/", &Method::Get)
8251 .ok()
8252 .and_then(|result| result.cluster_id)
8253 };
8254
8255 assert_eq!(
8256 resolve(&format!("{hit}.rx.example.com")).as_deref(),
8257 Some("ESCAPE"),
8258 "{segment} must match {hit}",
8259 );
8260 assert_eq!(
8261 resolve(&format!("{miss}.rx.example.com")),
8262 None,
8263 "{segment} must not match {miss}",
8264 );
8265 }
8266 }
8267
8268 /// The other half of the split: a LITERAL label is still a domain
8269 /// label and still goes through IDNA, in the very same hostname whose
8270 /// regex segment is copied verbatim. Only a `/`-delimited segment is
8271 /// exempt -- the same split `convert_regex_domain_rule` and the trie's
8272 /// `insert_recursive` already make.
8273 ///
8274 /// `remove_tree_rule` normalises through the same helper, so the rule
8275 /// stays addressable: a second spelling of the stored key would make
8276 /// the frontend permanently un-removable.
8277 ///
8278 /// To SEE THIS RED: same mutation as
8279 /// `a_tree_hostname_regex_escape_is_not_case_folded`. The first
8280 /// assertion fails with `left: None, right: Some("IDN")` -- the
8281 /// punycode is right, the folded `\d+` segment is what misses.
8282 #[test]
8283 fn a_unicode_label_is_punycoded_while_its_sibling_regex_segment_is_not() {
8284 let hostname = "/\\D+/.MÜNCHEN.example.com";
8285 let mut router = Router::new();
8286 assert!(router.add_tree_rule(
8287 hostname.as_bytes(),
8288 &PathRule::Prefix("/".to_owned()),
8289 &MethodRule::new(None),
8290 &Route::ClusterId("IDN".into()),
8291 ));
8292
8293 // `idna::domain_to_ascii("MÜNCHEN")` -> `"xn--mnchen-3ya"`
8294 // (measured, and identical label-by-label and whole-domain).
8295 assert_eq!(
8296 router
8297 .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8298 .ok()
8299 .and_then(|result| result.cluster_id)
8300 .as_deref(),
8301 Some("IDN"),
8302 "the literal label must still be punycoded and lowercased",
8303 );
8304
8305 assert!(
8306 router.remove_tree_rule(
8307 hostname.as_bytes(),
8308 &PathRule::Prefix("/".to_owned()),
8309 &MethodRule::new(None),
8310 ),
8311 "the frontend must still be addressable by the spelling that \
8312 installed it",
8313 );
8314 assert!(
8315 router
8316 .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8317 .is_err(),
8318 "and removing it must actually unroute it",
8319 );
8320 }
8321
8322 // ═══════════════════════════════════════════════════════════════════
8323 // Property-test harness: sozu#1349, sozu#1351, sozu#1356, sozu#1377
8324 // ═══════════════════════════════════════════════════════════════════
8325 //
8326 // Every regression test above pins ONE fixed example. This harness
8327 // generates many, against an ORACLE that is independent of the trie:
8328 // it does not call `pattern_trie::anchored_segment`,
8329 // `compiled_segment`, `TrieNode::lookup*`, `tree_hostname_to_ascii` or
8330 // `convert_regex_domain_rule` — reusing any of those would make the
8331 // oracle agree with the implementation by construction, proving
8332 // nothing. It is written fresh from `doc/configure.md`'s "Hostname
8333 // precedence" and "Regex hostname segments" sections and the RFC text
8334 // those sections cite (RFC 9110 §4.2.3 for case, RFC 9110's own
8335 // grammar for the wildcard), and it uses the `regex` crate only as a
8336 // primitive — exactly as `pattern_trie` does — never the router's own
8337 // wrapping of it.
8338 //
8339 // Scope, stated explicitly because it is a deliberate narrowing: every
8340 // generated rule and every query share the fixed two-label suffix
8341 // `example.com`, one variable FRONT label, and `MethodRule::new(None)`
8342 // (matches every method). This is what puts every rule in a scenario
8343 // on the SAME trie branch, which is where sozu#1351's leak and
8344 // precedence questions live, and it removes METHOD precedence from
8345 // the oracle's job — a separate concern with its own existing tests
8346 // (`case_normalisation_runs_before_the_hostname_precedence_search`
8347 // and neighbours). PATH does vary — see `PATH_POOL` — because sozu#1351's
8348 // actual production symptom needs it: with every rule pinned to the
8349 // SAME path, a literal whose insert would leak onto a regex leaf finds
8350 // that leaf already carrying an identical `(path, method)`, so
8351 // `add_tree_rule`'s append-skip condition is always false and the
8352 // insert is refused before any lookup happens — the leak is real but
8353 // observable only as `add_tree_rule` returning `false`, never as a
8354 // routing mismatch. Varying path is what lets the SAME leak surface the
8355 // way it did in production: an exact rule at one path answering a
8356 // request for a hostname nobody declared, at a DIFFERENT declared path.
8357 //
8358 // The generator is biased, not uniform: `REGEX_CATALOG` is the fixed
8359 // set of segment shapes that actually bit sozu, `LITERAL_POOL`
8360 // deliberately overlaps those shapes' `hits` so an exact rule and a
8361 // regex rule naturally collide on the same host (sozu#1351), case is
8362 // varied on both the declared rule and the query independently
8363 // (sozu#1349), declaration order is an explicit Fisher-Yates shuffle
8364 // driven by the same `Gen` (sozu#1351 again — order must not move an
8365 // exact-vs-regex verdict, and must move a regex-vs-regex one), and
8366 // queries cross every generated hit/miss hostname against every path
8367 // declared anywhere in the scenario, not just the declaring rule's own
8368 // (sozu#1351's leak crosses fronts, so a query pairing has to as well).
8369 //
8370 // Two honesty notes about this harness's own limits, not the router's:
8371 //
8372 // - **No reproducible seed.** `quickcheck` 1.1.0's `Gen::new` seeds
8373 // from OS entropy with no seed exposed to reproduce a specific run,
8374 // unlike the FoundationDB-style simulators `doc/testing.md` §5
8375 // describes (`SOZU_UDP_SIM_SEED` and friends). A failing CI run here
8376 // is reproduced by RE-RUNNING locally with a raised `QUICKCHECK_TESTS`
8377 // (this property's generator is dense enough that a real regression
8378 // reappears quickly), not by replaying the failing seed — there is
8379 // none to replay. Not fixed in this changeset.
8380 // - **The anchoring convention is shared, not external.** This oracle's
8381 // `\A(?:source)\z` case-insensitive anchoring matches
8382 // `doc/configure.md`'s "Regex hostname segments" section — but that
8383 // section's prose was itself ADDED by the sozu#1356 fix this harness
8384 // is supposed to catch a regression of. So for the GROUPING half of
8385 // that anchoring, the oracle and the implementation share a
8386 // convention rather than the oracle checking against an independent
8387 // spec; a regression that un-groups BOTH the doc and the code back to
8388 // their pre-#1356 form would not be caught by prose comparison alone
8389 // — it is caught here because the oracle's grouping is also
8390 // independently reasoned from what `|`'s precedence in a regex
8391 // grammar requires, the same reasoning #1356's own fix commit gives.
8392 // The CASE-FOLDING half (`(?i)`) is genuinely independent, derived
8393 // straight from RFC 9110 §4.2.3, not from anything #1356 or #1377
8394 // added to `doc/configure.md`.
8395
8396 /// One `/regex/` hostname segment source this harness declares, with
8397 /// concrete labels independently reasoned to match or not match it
8398 /// under `\A(?:source)\z` compiled case-insensitively — the anchoring
8399 /// `doc/configure.md` documents and sozu#1356 restored, built fresh
8400 /// here rather than borrowed from `anchored_segment`.
8401 struct RegexCase {
8402 source: &'static str,
8403 /// Whole-label matches for one branch of the pattern.
8404 hits: &'static [&'static str],
8405 /// Labels that must NOT match under the documented semantics, but
8406 /// that a pre-fix implementation matched: a half-anchored
8407 /// alternation branch (sozu#1356 — `axx`, `xxb`, the bare
8408 /// substring `zzbzz` for a 3-way alternation's ungrouped middle
8409 /// branch) or the inverted class an ASCII-lowercase fold produces
8410 /// (sozu#1377 — folding `\D` into `\d` turns "not a digit" into
8411 /// "a digit").
8412 misses: &'static [&'static str],
8413 }
8414
8415 const REGEX_CATALOG: &[RegexCase] = &[
8416 RegexCase {
8417 source: "a|b",
8418 hits: &["a", "b"],
8419 misses: &["axx", "xxb", "xx"],
8420 },
8421 RegexCase {
8422 source: "a|b|c",
8423 // The 3-branch case two branches cannot express: ungrouped,
8424 // the MIDDLE branch of `\Aa|b|c\z` keeps NEITHER anchor and
8425 // matches as a bare substring anywhere in the label.
8426 hits: &["a", "b", "c"],
8427 misses: &["axx", "xxc", "zzbzz", "bzz", "zzb"],
8428 },
8429 RegexCase {
8430 source: "api|admin",
8431 // `doc/configure.md`'s own worked example for sozu#1356.
8432 hits: &["api", "admin"],
8433 misses: &["apifoo", "xadmin"],
8434 },
8435 RegexCase {
8436 source: "\\D+",
8437 // sozu#1377's own measured shape: folded to `\d+`, this
8438 // matched digits and rejected letters — the complement.
8439 hits: &["abc", "xyz"],
8440 misses: &["777", "42"],
8441 },
8442 RegexCase {
8443 source: "\\W+",
8444 hits: &["---", "..."],
8445 misses: &["abc", "42a"],
8446 },
8447 RegexCase {
8448 source: "\\S+",
8449 hits: &["abc", "777"],
8450 // Folded to `\s`, this would match ONLY whitespace; no ASCII
8451 // hostname label the wire ever admits can carry one, but
8452 // `Router::lookup` takes a plain `&str` and does not itself
8453 // re-validate the charset, so the harness can still probe it.
8454 misses: &[" "],
8455 },
8456 RegexCase {
8457 source: "[^\\D]+",
8458 // sozu#1377's negated-class shape from the issue itself:
8459 // "not a non-digit" is a digit; folded it becomes its own
8460 // complement.
8461 hits: &["777", "42"],
8462 misses: &["abc"],
8463 },
8464 RegexCase {
8465 source: "API[0-9]",
8466 // An uppercase literal INSIDE a class: must still meet the
8467 // lowercased lookup key by folding at compile time, not by
8468 // folding the stored source (which would invert the escapes
8469 // above in the very same pass).
8470 hits: &["api7", "API7", "ApI4"],
8471 misses: &["apiZ"],
8472 },
8473 RegexCase {
8474 source: "test[0-9]",
8475 // sozu#1351's own shape: `test4` is also `LITERAL_POOL`'s
8476 // exact-rule text, so a literal `test4` rule and this regex
8477 // rule collide on the same host with decent probability.
8478 hits: &["test4", "TEST7", "Test9"],
8479 misses: &["testA", "test10x"],
8480 },
8481 RegexCase {
8482 source: "[a-z]+[0-9]",
8483 hits: &["test4", "cdn1"],
8484 misses: &["4test", "TEST"],
8485 },
8486 ];
8487
8488 /// Literal exact-rule candidates. Deliberately overlapping with
8489 /// `REGEX_CATALOG`'s `hits` — `test4`, `api`, `admin`, `a`, `b`, `c`
8490 /// all reappear there — so an exact rule and an overlapping regex
8491 /// rule land in the SAME generated scenario often enough to exercise
8492 /// sozu#1351 without hand-injecting a forced pair.
8493 const LITERAL_POOL: &[&str] = &[
8494 "a", "b", "c", "api", "admin", "test4", "test7", "test9", "TEST4", "API7", "abc", "777",
8495 "42", "cdn1", "zz", "apifoo", "xadmin",
8496 ];
8497
8498 /// Declared-path candidates. Small and deliberately NOT elaborate: the
8499 /// production shape of sozu#1351 needs exactly two rules at two
8500 /// different paths (reproduced live against the reverted fix: an exact
8501 /// `test4.example.com` frontend at `/other` leaking onto the
8502 /// `/test[0-9]/.example.com` regex family, answering `test7.example.com`
8503 /// requests for `/other`). `"/"` is a prefix of every other entry here
8504 /// and is weighted to appear often, so a scenario still frequently
8505 /// declares two fronts at the SAME path (exercising the harness's
8506 /// pre-existing hostname-only coverage) as well as at different ones
8507 /// (exercising the path dimension this fixes). Every entry is used only
8508 /// as a `PathRule::Prefix`; `PathRule::Equals`/`PathRule::Regex` are not
8509 /// generated — see the module doc comment for why method stays fixed
8510 /// too, and the same reasoning applies here: sozu#1351 needs two
8511 /// declared paths to differ, not the full path-matching precedence
8512 /// surface, which has its own existing tests.
8513 const PATH_POOL: &[&str] = &["/", "/", "/other", "/api", "/only"];
8514
8515 /// The dot-separated FRONT label of a generated Tree-position
8516 /// hostname rule. Every rule in a [`Scenario`] shares the fixed
8517 /// trailing `example.com` — see the harness's module-level doc
8518 /// comment for why.
8519 #[derive(Clone, Copy, Debug)]
8520 enum FrontLabel {
8521 /// A literal label, in whatever case the generator chose.
8522 /// `tree_hostname_to_ascii` case-folds it on insert (sozu#1377).
8523 Literal(&'static str),
8524 /// The leftmost-only wildcard, exactly one label.
8525 Wildcard,
8526 /// A `/regex/` segment, addressed into [`REGEX_CATALOG`].
8527 Regex(usize),
8528 }
8529
8530 /// Dedup identity for a [`FrontLabel`] within one scenario. Two
8531 /// literals that differ only by case must be treated as the SAME
8532 /// front: `add_tree_rule` case-folds them to the same trie node, and
8533 /// generating both would silently drop one rule's cluster id from the
8534 /// harness's own model — a harness bug, not something under test.
8535 #[derive(Clone, PartialEq, Eq, Debug)]
8536 enum FrontKey {
8537 Literal(String),
8538 Wildcard,
8539 Regex(usize),
8540 }
8541
8542 impl From<FrontLabel> for FrontKey {
8543 fn from(front: FrontLabel) -> Self {
8544 match front {
8545 FrontLabel::Literal(text) => FrontKey::Literal(text.to_ascii_lowercase()),
8546 FrontLabel::Wildcard => FrontKey::Wildcard,
8547 FrontLabel::Regex(idx) => FrontKey::Regex(idx),
8548 }
8549 }
8550 }
8551
8552 #[derive(Clone, Debug)]
8553 struct GenRule {
8554 front: FrontLabel,
8555 /// This rule's OWN declared path, from [`PATH_POOL`]. Every front
8556 /// carries exactly one `(path, route)` pair in this harness's
8557 /// model — one `add_tree_rule` call per generated front — so there
8558 /// is no within-one-hostname path-precedence list to model; only
8559 /// whether THIS front's path serves a given query path.
8560 path: &'static str,
8561 cluster: String,
8562 }
8563
8564 /// One generated case: a set of Tree-position rules, in DECLARATION
8565 /// order (already shuffled — see [`Scenario::arbitrary`]), and a set
8566 /// of (hostname, path) queries to look up against them.
8567 #[derive(Clone, Debug)]
8568 struct Scenario {
8569 rules: Vec<GenRule>,
8570 queries: Vec<(String, String)>,
8571 }
8572
8573 fn render_hostname(front: FrontLabel) -> String {
8574 match front {
8575 FrontLabel::Literal(text) => format!("{text}.example.com"),
8576 FrontLabel::Wildcard => "*.example.com".to_owned(),
8577 FrontLabel::Regex(idx) => format!("/{}/.example.com", REGEX_CATALOG[idx].source),
8578 }
8579 }
8580
8581 /// The oracle's OWN anchoring: `\A(?:source)\z`, case-insensitive.
8582 /// Built fresh from `doc/configure.md`'s "Regex hostname segments"
8583 /// section and the sozu#1356 fix's stated invariant, not by calling
8584 /// `pattern_trie::anchored_segment` or `compiled_segment`. Using the
8585 /// `regex` crate as a primitive is not reusing the implementation:
8586 /// regex matching itself is not what any of the four issues broke —
8587 /// Sozu's own anchoring, grouping and case handling around it is.
8588 fn oracle_regex_matches(source: &str, label: &str) -> bool {
8589 let pattern = format!("(?i)\\A(?:{source})\\z");
8590 Regex::new(&pattern)
8591 .unwrap_or_else(|error| {
8592 panic!("the harness's own catalog regex {source:?} must compile: {error}")
8593 })
8594 .is_match(label.as_bytes())
8595 }
8596
8597 fn front_label_matches(front: FrontLabel, query_label: &str) -> bool {
8598 match front {
8599 FrontLabel::Literal(text) => text.eq_ignore_ascii_case(query_label),
8600 FrontLabel::Wildcard => !query_label.is_empty(),
8601 FrontLabel::Regex(idx) => oracle_regex_matches(REGEX_CATALOG[idx].source, query_label),
8602 }
8603 }
8604
8605 /// Whether `front` (this harness's stand-in for one declared Tree
8606 /// rule) matches the full hostname `query` — case-insensitively on
8607 /// the fixed `example.com` suffix (RFC 9110 §4.2.3, sozu#1349) and
8608 /// per [`front_label_matches`] on the one variable label.
8609 fn oracle_hostname_matches(front: FrontLabel, query: &str) -> bool {
8610 let mut labels = query.split('.');
8611 let (Some(first), Some(second), Some(third), None) =
8612 (labels.next(), labels.next(), labels.next(), labels.next())
8613 else {
8614 return false;
8615 };
8616 second.eq_ignore_ascii_case("example")
8617 && third.eq_ignore_ascii_case("com")
8618 && front_label_matches(front, first)
8619 }
8620
8621 /// `PathRule::Prefix` semantics (`PathRule::matches`, `router/mod.rs`):
8622 /// a rule matches iff the query path starts with the declared path's
8623 /// bytes. Every generated front carries exactly one path, so there is
8624 /// no longest-prefix tie-break to model here — see [`GenRule::path`].
8625 fn oracle_path_matches(rule_path: &str, query_path: &str) -> bool {
8626 query_path.starts_with(rule_path)
8627 }
8628
8629 /// The oracle's precedence search, stated in `doc/configure.md` under
8630 /// "Hostname precedence" and restored by sozu#1351: the EXACT name,
8631 /// then a REGEX segment (the first declared among those that match),
8632 /// then the `*` WILDCARD — each candidate accepted only when it ALSO
8633 /// serves this request's path (`Router::lookup` hands the trie the
8634 /// same path-serving predicate it uses on the winner, so a hostname
8635 /// candidate that matches but does not serve this path is skipped
8636 /// rather than ending the search — `doc/configure.md`, "Hostname
8637 /// precedence": "a search, not a filter"). Exact-vs-regex hostname
8638 /// precedence is checked in a pass that does not depend on `rules`'
8639 /// order at all — the documented, order-independent half of the
8640 /// invariant sozu#1351 restored. Regex-vs-regex precedence is "first
8641 /// in `rules`' own order that matches (hostname AND path)" — the half
8642 /// that DOES still depend on declaration order, by design
8643 /// (`doc/configure.md`, "Regex hostname segments").
8644 fn oracle_lookup<'r>(rules: &'r [GenRule], hostname: &str, path: &str) -> Option<&'r str> {
8645 let serves = |rule: &&GenRule| {
8646 oracle_hostname_matches(rule.front, hostname) && oracle_path_matches(rule.path, path)
8647 };
8648 rules
8649 .iter()
8650 .find(|rule| matches!(rule.front, FrontLabel::Literal(_)) && serves(rule))
8651 .or_else(|| {
8652 rules
8653 .iter()
8654 .find(|rule| matches!(rule.front, FrontLabel::Regex(_)) && serves(rule))
8655 })
8656 .or_else(|| {
8657 rules
8658 .iter()
8659 .find(|rule| matches!(rule.front, FrontLabel::Wildcard) && serves(rule))
8660 })
8661 .map(|rule| rule.cluster.as_str())
8662 }
8663
8664 /// Vary the case of `s`: as declared, all-uppercase, all-lowercase, or
8665 /// a per-character random mix — independently of whatever case the
8666 /// OTHER side (rule vs. query) picked, so RFC 9110 §4.2.3
8667 /// case-insensitivity (sozu#1349) is exercised on both sides and not
8668 /// just one.
8669 fn case_variant(g: &mut Gen, s: &str) -> String {
8670 match u8::arbitrary(g) % 4 {
8671 0 => s.to_owned(),
8672 1 => s.to_ascii_uppercase(),
8673 2 => s.to_ascii_lowercase(),
8674 _ => s
8675 .chars()
8676 .map(|c| {
8677 if bool::arbitrary(g) {
8678 c.to_ascii_uppercase()
8679 } else {
8680 c.to_ascii_lowercase()
8681 }
8682 })
8683 .collect(),
8684 }
8685 }
8686
8687 fn push_unique_front(
8688 front: FrontLabel,
8689 path: &'static str,
8690 rules: &mut Vec<GenRule>,
8691 seen: &mut Vec<FrontKey>,
8692 ) {
8693 let key = FrontKey::from(front);
8694 if seen.contains(&key) {
8695 return;
8696 }
8697 seen.push(key);
8698 let cluster = format!("C{}", rules.len());
8699 rules.push(GenRule {
8700 front,
8701 path,
8702 cluster,
8703 });
8704 }
8705
8706 /// One hit and one miss hostname sample per declared front (see
8707 /// [`RegexCase`]'s doc comment for what those pin), plus three
8708 /// universal hostname probes every scenario gets regardless of what it
8709 /// declared: an unrelated host that must match nothing, the bare
8710 /// suffix with the front label dropped entirely (must miss even a
8711 /// declared wildcard, which stands for exactly one label and not
8712 /// zero), and its uppercase spelling.
8713 ///
8714 /// Each hit hostname is queried at EVERY distinct path declared
8715 /// anywhere in the scenario, not just its own front's path — sozu#1351's
8716 /// leak crosses fronts (an exact rule's cluster answering a DIFFERENT
8717 /// hostname, at whatever path THAT hostname's own rule declared), so a
8718 /// query pairing that only ever asked a front's own path could not
8719 /// observe it. Miss hostnames and the universal probes are checked only
8720 /// at their own front's path (or `/`): they exist to confirm a mismatch
8721 /// does not appear where none is expected, which does not need the
8722 /// cross product.
8723 fn push_hit_queries(
8724 g: &mut Gen,
8725 label: &str,
8726 declared_paths: &[&str],
8727 queries: &mut Vec<(String, String)>,
8728 ) {
8729 for &path in declared_paths {
8730 queries.push((
8731 format!("{}.example.com", case_variant(g, label)),
8732 path.to_owned(),
8733 ));
8734 }
8735 }
8736
8737 fn generate_queries(g: &mut Gen, rules: &[GenRule]) -> Vec<(String, String)> {
8738 let mut queries = Vec::new();
8739 let mut declared_paths: Vec<&str> = rules.iter().map(|rule| rule.path).collect();
8740 declared_paths.sort_unstable();
8741 declared_paths.dedup();
8742
8743 for rule in rules {
8744 match rule.front {
8745 FrontLabel::Literal(text) => {
8746 push_hit_queries(g, text, &declared_paths, &mut queries);
8747 }
8748 FrontLabel::Wildcard => {
8749 let label = *g.choose(LITERAL_POOL).unwrap_or(&"zz");
8750 push_hit_queries(g, label, &declared_paths, &mut queries);
8751 }
8752 FrontLabel::Regex(idx) => {
8753 let case = ®EX_CATALOG[idx];
8754 if let Some(&hit) = g.choose(case.hits) {
8755 push_hit_queries(g, hit, &declared_paths, &mut queries);
8756 }
8757 if let Some(miss) = g.choose(case.misses) {
8758 queries.push((
8759 format!("{}.example.com", case_variant(g, miss)),
8760 rule.path.to_owned(),
8761 ));
8762 }
8763 }
8764 }
8765 }
8766
8767 queries.push((
8768 format!("{}.example.com", case_variant(g, "unmatched-host")),
8769 "/".to_owned(),
8770 ));
8771 queries.push(("example.com".to_owned(), "/".to_owned()));
8772 queries.push(("EXAMPLE.COM".to_owned(), "/".to_owned()));
8773
8774 queries
8775 }
8776
8777 impl Arbitrary for Scenario {
8778 fn arbitrary(g: &mut Gen) -> Self {
8779 let mut rules = Vec::new();
8780 let mut seen_keys = Vec::new();
8781
8782 let pick_path = |g: &mut Gen| {
8783 *g.choose(PATH_POOL)
8784 .expect("PATH_POOL is a non-empty const slice")
8785 };
8786
8787 // With even odds, include the wildcard: it participates in
8788 // every precedence chain and costs only one extra rule.
8789 if bool::arbitrary(g) {
8790 let path = pick_path(g);
8791 push_unique_front(FrontLabel::Wildcard, path, &mut rules, &mut seen_keys);
8792 }
8793
8794 // 1..=3 additional fronts, each independently a regex segment
8795 // or a literal drawn from the overlap-biased pool, each with
8796 // its own independently chosen path.
8797 let extra = 1 + (u8::arbitrary(g) % 3);
8798 for _ in 0..extra {
8799 if bool::arbitrary(g) {
8800 let idx = usize::from(u8::arbitrary(g)) % REGEX_CATALOG.len();
8801 let path = pick_path(g);
8802 push_unique_front(FrontLabel::Regex(idx), path, &mut rules, &mut seen_keys);
8803 } else {
8804 let text = *g
8805 .choose(LITERAL_POOL)
8806 .expect("LITERAL_POOL is a non-empty const slice");
8807 let path = pick_path(g);
8808 push_unique_front(FrontLabel::Literal(text), path, &mut rules, &mut seen_keys);
8809 }
8810 }
8811
8812 // Declaration order is itself under test (sozu#1351): shuffle
8813 // with a Fisher-Yates driven by the same `Gen`, independent of
8814 // insertion order above.
8815 for i in (1..rules.len()).rev() {
8816 let j = usize::from(u8::arbitrary(g)) % (i + 1);
8817 rules.swap(i, j);
8818 }
8819
8820 let queries = generate_queries(g, &rules);
8821 Scenario { rules, queries }
8822 }
8823
8824 fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
8825 let mut shrunk = Vec::new();
8826
8827 for i in 0..self.rules.len() {
8828 let mut rules = self.rules.clone();
8829 rules.remove(i);
8830 shrunk.push(Scenario {
8831 rules,
8832 queries: self.queries.clone(),
8833 });
8834 }
8835
8836 for i in 0..self.queries.len() {
8837 let mut queries = self.queries.clone();
8838 queries.remove(i);
8839 shrunk.push(Scenario {
8840 rules: self.rules.clone(),
8841 queries,
8842 });
8843 }
8844
8845 Box::new(shrunk.into_iter())
8846 }
8847 }
8848
8849 /// Every catalog entry's own `hits`/`misses` verdicts, checked against
8850 /// nothing but [`oracle_regex_matches`] — a self-check that the
8851 /// harness's table is internally consistent BEFORE it is trusted to
8852 /// judge the router. This does not touch `Router` or `TrieNode` at
8853 /// all.
8854 #[test]
8855 fn regex_catalog_samples_match_their_own_documented_semantics() {
8856 for case in REGEX_CATALOG {
8857 for hit in case.hits {
8858 assert!(
8859 oracle_regex_matches(case.source, hit),
8860 "{:?} must match {hit:?} under \\A(?:...)\\z case-insensitive",
8861 case.source,
8862 );
8863 }
8864 for miss in case.misses {
8865 assert!(
8866 !oracle_regex_matches(case.source, miss),
8867 "{:?} must NOT match {miss:?} under \\A(?:...)\\z case-insensitive",
8868 case.source,
8869 );
8870 }
8871 }
8872 }
8873
8874 /// Build the rules `scenario` declares into a fresh [`Router`], in
8875 /// its own declaration order, then check every query against both the
8876 /// oracle and the router.
8877 fn oracle_matches_router(scenario: Scenario) -> TestResult {
8878 if scenario.rules.is_empty() {
8879 return TestResult::discard();
8880 }
8881
8882 let mut router = Router::new();
8883 for rule in &scenario.rules {
8884 let hostname = render_hostname(rule.front);
8885 // A refused insert here is not automatically dismissed as a
8886 // harness artifact: sozu#1351's own mechanism (a literal
8887 // resolving through a matching regex segment because
8888 // `TrieNode::lookup_mut`'s guard is gone) can make this EXACT
8889 // call return `false`, when the two fronts' generated paths
8890 // happen to coincide -- see the module doc comment. Every
8891 // front here is still unique (`FrontKey`-deduped) and every
8892 // catalog regex compiles standalone, so a refusal is real
8893 // signal either way: fail loudly and report it rather than
8894 // silently skipping the rule, which would just hide the
8895 // question inside a `None` at lookup time instead of at
8896 // insert time.
8897 if !router.add_tree_rule(
8898 hostname.as_bytes(),
8899 &PathRule::Prefix(rule.path.to_owned()),
8900 &MethodRule::new(None),
8901 &Route::ClusterId(rule.cluster.as_str().into()),
8902 ) {
8903 eprintln!(
8904 "INSERT REFUSED hostname={hostname:?} path={:?} cluster={:?} \
8905 declared-so-far={:?}",
8906 rule.path,
8907 rule.cluster,
8908 scenario
8909 .rules
8910 .iter()
8911 .map(|r| (render_hostname(r.front), r.path))
8912 .collect::<Vec<_>>(),
8913 );
8914 return TestResult::failed();
8915 }
8916 }
8917
8918 for (hostname, path) in &scenario.queries {
8919 let expected = oracle_lookup(&scenario.rules, hostname, path);
8920 let actual = router
8921 .lookup(hostname, path, &Method::Get)
8922 .ok()
8923 .and_then(|result| result.cluster_id);
8924
8925 if expected != actual.as_deref() {
8926 let declared: Vec<(String, &str)> = scenario
8927 .rules
8928 .iter()
8929 .map(|rule| (render_hostname(rule.front), rule.path))
8930 .collect();
8931 eprintln!(
8932 "MISMATCH host={hostname:?} path={path:?} expected={expected:?} \
8933 actual={actual:?} declared (in order, with path)={declared:?}",
8934 );
8935 return TestResult::failed();
8936 }
8937 }
8938
8939 TestResult::passed()
8940 }
8941
8942 /// Request headers for a header-predicate lookup: first occurrence,
8943 /// names compared case-insensitively — the contract
8944 /// `FrontRequestHeaders` (`lib/src/protocol/mux/router.rs`) implements
8945 /// over a real request.
8946 struct TestHeaders<'a>(&'a [(&'a str, &'a str)]);
8947
8948 impl RequestHeaders for TestHeaders<'_> {
8949 fn header(&self, name: &[u8]) -> Option<&[u8]> {
8950 self.0
8951 .iter()
8952 .find(|(key, _)| key.as_bytes().eq_ignore_ascii_case(name))
8953 .map(|(_, value)| value.as_bytes())
8954 }
8955 }
8956
8957 fn predicate(name: &str, kind: HeaderMatchKind, value: Option<&str>) -> HeaderPredicate {
8958 HeaderPredicate {
8959 name: name.to_owned(),
8960 kind: kind as i32,
8961 value: value.map(str::to_owned),
8962 }
8963 }
8964
8965 fn routed_by_headers(router: &Router, path: &str, headers: &[(&str, &str)]) -> Option<String> {
8966 router
8967 .lookup_with_headers("example.com", path, &Method::Get, &TestHeaders(headers))
8968 .ok()
8969 .and_then(|route| route.cluster_id)
8970 .map(|id| id.to_string())
8971 }
8972
8973 /// sozu-proxy/sozu#1253: a canary frontend that differs from its base
8974 /// only by a header predicate serves exactly the requests carrying the
8975 /// header, whichever of the two was declared first and in every rule
8976 /// position. Before predicates were a routing input, the two frontends
8977 /// had one identity, so the second addition was refused.
8978 #[test]
8979 fn a_header_predicate_canary_outranks_its_base_in_any_declaration_order() {
8980 let canary_predicates = vec![predicate(
8981 "X-Canary",
8982 HeaderMatchKind::Exact,
8983 Some("always"),
8984 )];
8985 for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
8986 for path in [
8987 CommandPathRule::prefix("/api".to_owned()),
8988 CommandPathRule::equals("/api".to_owned()),
8989 ] {
8990 for canary_first in [false, true] {
8991 let base = HttpFrontend {
8992 cluster_id: Some("stable".into()),
8993 path: path.clone(),
8994 position,
8995 ..test_http_frontend()
8996 };
8997 let canary = HttpFrontend {
8998 cluster_id: Some("canary".into()),
8999 header_predicates: canary_predicates.clone(),
9000 ..base.clone()
9001 };
9002 let mut router = Router::new();
9003 let order = if canary_first {
9004 [&canary, &base]
9005 } else {
9006 [&base, &canary]
9007 };
9008 for front in order {
9009 router
9010 .add_http_front(front)
9011 .expect("a canary and its base must coexist");
9012 }
9013
9014 let context = format!("{position:?} {path:?} canary_first={canary_first}");
9015 assert_eq!(
9016 routed_by_headers(&router, "/api", &[("x-canary", "always")]).as_deref(),
9017 Some("canary"),
9018 "{context}: a request carrying the header must reach the canary",
9019 );
9020 assert_eq!(
9021 routed_by_headers(&router, "/api", &[]).as_deref(),
9022 Some("stable"),
9023 "{context}: a request without the header must reach the base",
9024 );
9025 assert_eq!(
9026 routed_by_headers(&router, "/api", &[("X-Canary", "Always")]).as_deref(),
9027 Some("stable"),
9028 "{context}: an exact value is compared byte for byte",
9029 );
9030 assert_eq!(
9031 router
9032 .lookup("example.com", "/api", &Method::Get)
9033 .ok()
9034 .and_then(|route| route.cluster_id)
9035 .as_deref(),
9036 Some("stable"),
9037 "{context}: a header-less lookup never matches a predicated rule",
9038 );
9039 }
9040 }
9041 }
9042 }
9043
9044 /// Predicates are a conjunction; names match case-insensitively; the
9045 /// first occurrence of a header answers; a `regex` value matches the
9046 /// WHOLE value, like a `REGEX` path rule; `present` accepts any value,
9047 /// the empty one included.
9048 #[test]
9049 fn header_predicates_hold_together_with_their_documented_semantics() {
9050 let mut router = Router::new();
9051 let front = HttpFrontend {
9052 cluster_id: Some("eu-beta".into()),
9053 header_predicates: vec![
9054 predicate("x-region", HeaderMatchKind::Regex, Some("eu-.*")),
9055 predicate("X-BETA", HeaderMatchKind::Present, None),
9056 ],
9057 ..test_http_frontend()
9058 };
9059 router.add_http_front(&front).expect("valid predicates");
9060 router
9061 .add_http_front(&test_http_frontend())
9062 .expect("the base frontend");
9063
9064 let route = |headers: &[(&str, &str)]| routed_by_headers(&router, "/", headers);
9065 assert_eq!(
9066 route(&[("X-Region", "eu-west"), ("x-beta", "")]).as_deref(),
9067 Some("eu-beta")
9068 );
9069 assert_eq!(
9070 route(&[("X-Region", "eu-west")]).as_deref(),
9071 Some("cluster"),
9072 "every predicate must hold",
9073 );
9074 assert_eq!(
9075 route(&[("X-Region", "us-eu-west"), ("x-beta", "1")]).as_deref(),
9076 Some("cluster"),
9077 "a regex is anchored at the start of the value",
9078 );
9079 assert_eq!(
9080 route(&[
9081 ("X-Region", "us-east"),
9082 ("X-Region", "eu-west"),
9083 ("x-beta", "1")
9084 ])
9085 .as_deref(),
9086 Some("cluster"),
9087 "only the first occurrence of a header answers",
9088 );
9089 }
9090
9091 /// Removing a frontend removes the rule of exactly its predicates: the
9092 /// base survives the canary's removal and the reverse, and a removal
9093 /// that spells the same conjunction in another order or case finds it.
9094 #[test]
9095 fn removing_a_predicated_frontend_leaves_its_base_and_the_reverse() {
9096 for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
9097 let base = HttpFrontend {
9098 cluster_id: Some("stable".into()),
9099 position,
9100 ..test_http_frontend()
9101 };
9102 let canary = HttpFrontend {
9103 cluster_id: Some("canary".into()),
9104 header_predicates: vec![
9105 predicate("X-Canary", HeaderMatchKind::Present, None),
9106 predicate("X-Tenant", HeaderMatchKind::Exact, Some("acme")),
9107 ],
9108 ..base.clone()
9109 };
9110 let canary_respelled = HttpFrontend {
9111 header_predicates: vec![
9112 predicate("x-tenant", HeaderMatchKind::Exact, Some("acme")),
9113 predicate("x-canary", HeaderMatchKind::Present, None),
9114 ],
9115 ..canary.clone()
9116 };
9117 let canary_headers = [("X-Canary", "1"), ("X-Tenant", "acme")];
9118
9119 let mut router = Router::new();
9120 router.add_http_front(&base).expect("base");
9121 router.add_http_front(&canary).expect("canary");
9122 assert!(
9123 router.add_http_front(&canary_respelled).is_err(),
9124 "{position:?}: a respelled conjunction is the same rule",
9125 );
9126 router
9127 .remove_http_front(&canary_respelled)
9128 .expect("a respelled removal must find the canary");
9129 assert_eq!(
9130 routed_by_headers(&router, "/", &canary_headers).as_deref(),
9131 Some("stable"),
9132 "{position:?}: the base must survive the canary's removal",
9133 );
9134
9135 router.add_http_front(&canary).expect("canary again");
9136 router.remove_http_front(&base).expect("base removal");
9137 assert_eq!(
9138 routed_by_headers(&router, "/", &canary_headers).as_deref(),
9139 Some("canary"),
9140 "{position:?}: the canary must survive the base's removal",
9141 );
9142 assert_eq!(
9143 routed_by_headers(&router, "/", &[]),
9144 None,
9145 "{position:?}: without its base, a request lacking the header has no route",
9146 );
9147 }
9148 }
9149
9150 /// A hostname whose only rules are predicated, none holding, hands the
9151 /// request to the next hostname candidate instead of ending the lookup
9152 /// (the sozu#1351 shape): the trie's acceptance predicate sees headers.
9153 #[test]
9154 fn an_unsatisfied_predicated_hostname_falls_through_to_the_wildcard() {
9155 let mut router = Router::new();
9156 router
9157 .add_http_front(&HttpFrontend {
9158 cluster_id: Some("wildcard".into()),
9159 hostname: "*.example.com".to_owned(),
9160 ..test_http_frontend()
9161 })
9162 .expect("wildcard");
9163 router
9164 .add_http_front(&HttpFrontend {
9165 cluster_id: Some("exact".into()),
9166 hostname: "www.example.com".to_owned(),
9167 header_predicates: vec![predicate("X-Canary", HeaderMatchKind::Present, None)],
9168 ..test_http_frontend()
9169 })
9170 .expect("predicated exact host");
9171
9172 let route = |headers: &[(&str, &str)]| {
9173 router
9174 .lookup_with_headers("www.example.com", "/", &Method::Get, &TestHeaders(headers))
9175 .ok()
9176 .and_then(|route| route.cluster_id)
9177 .map(|id| id.to_string())
9178 };
9179 assert_eq!(route(&[("x-canary", "")]).as_deref(), Some("exact"));
9180 assert_eq!(route(&[]).as_deref(), Some("wildcard"));
9181 }
9182
9183 /// A predicate no request could satisfy, or that cannot be compiled, is
9184 /// refused at frontend addition — which `validate_frontend_request`
9185 /// (`bin/src/command/requests.rs`) also runs in the main process — and
9186 /// the error carries the predicate's index, never its name or value.
9187 #[test]
9188 fn unusable_header_predicates_are_refused_without_echoing_them() {
9189 let refused = [
9190 predicate("Host", HeaderMatchKind::Exact, Some("example.com")),
9191 predicate("cookie", HeaderMatchKind::Present, None),
9192 predicate("X Bad", HeaderMatchKind::Present, None),
9193 predicate("", HeaderMatchKind::Present, None),
9194 predicate("X-Debug", HeaderMatchKind::Present, Some("1")),
9195 predicate("X-Tenant", HeaderMatchKind::Exact, None),
9196 predicate("X-Tenant", HeaderMatchKind::Regex, None),
9197 predicate("X-Tenant", HeaderMatchKind::Exact, Some("a\r\nb")),
9198 predicate("X-Tenant", HeaderMatchKind::Regex, Some("a)(b")),
9199 HeaderPredicate {
9200 name: "X-Tenant".to_owned(),
9201 kind: 42,
9202 value: None,
9203 },
9204 ];
9205 for bad in refused {
9206 let mut router = Router::new();
9207 let front = HttpFrontend {
9208 header_predicates: vec![
9209 predicate("X-Valid", HeaderMatchKind::Present, None),
9210 bad.clone(),
9211 ],
9212 ..test_http_frontend()
9213 };
9214 let error = router
9215 .add_http_front(&front)
9216 .expect_err("an unusable predicate must be refused");
9217 assert!(
9218 matches!(error, RouterError::InvalidHeaderPredicate { index: 1, .. }),
9219 "{bad:?}: unexpected {error}",
9220 );
9221 let message = error.to_string();
9222 assert!(
9223 !message.contains("X-Tenant") && !message.contains("example.com"),
9224 "the error must not echo the predicate: {message}",
9225 );
9226 assert!(router.tree.is_empty(), "a refused frontend adds no rule");
9227 }
9228 }
9229
9230 quickcheck! {
9231 fn qc_router_hostname_resolution_matches_the_documented_semantics(scenario: Scenario) -> TestResult {
9232 oracle_matches_router(scenario)
9233 }
9234 }
9235}