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` 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` 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}
1725
1726#[derive(PartialEq, Eq)]
1727pub enum PathRuleResult {
1728 Regex,
1729 Prefix(usize),
1730 Equals,
1731 None,
1732}
1733
1734impl PathRule {
1735 pub fn matches(&self, path: &[u8]) -> PathRuleResult {
1736 match self {
1737 PathRule::Prefix(prefix) => {
1738 if path.starts_with(prefix.as_bytes()) {
1739 // The reported prefix length is the matched-byte count
1740 // the router uses for longest-prefix tie-breaking; it
1741 // must equal the prefix and never exceed the path.
1742 debug_assert!(
1743 prefix.len() <= path.len(),
1744 "a matching prefix cannot be longer than the path it matched",
1745 );
1746 PathRuleResult::Prefix(prefix.len())
1747 } else {
1748 PathRuleResult::None
1749 }
1750 }
1751 PathRule::Regex(regex) => {
1752 let start = Instant::now();
1753 let is_a_match = regex.is_match(path);
1754 let now = Instant::now();
1755 time!(
1756 names::event_loop::REGEX_MATCHING_TIME,
1757 (now - start).as_millis()
1758 );
1759
1760 if is_a_match {
1761 PathRuleResult::Regex
1762 } else {
1763 PathRuleResult::None
1764 }
1765 }
1766 PathRule::Equals(pattern) => {
1767 if path == pattern.as_bytes() {
1768 PathRuleResult::Equals
1769 } else {
1770 PathRuleResult::None
1771 }
1772 }
1773 }
1774 }
1775
1776 /// Compile a configured `path_type = "REGEX"` value anchored at both ends,
1777 /// so it matches the WHOLE request path and nothing else — the behaviour
1778 /// `doc/configure.md` has documented since v2.0.0 and the code did not
1779 /// implement (sozu#1350). `regex::bytes::Regex::is_match` is a substring
1780 /// search, so the previous bare `Regex::new(&rule.value)` let `bc` match
1781 /// `/abcd`.
1782 ///
1783 /// `\A(?:` … `)\z` is the form the hostname side uses too, in
1784 /// [`convert_regex_domain_rule`] around each slash-delimited segment and
1785 /// in `pattern_trie.rs`'s `anchored_segment` at segment-insert time
1786 /// (sozu#1356 closed the hostname half; this path half landed first, as
1787 /// sozu#1357).
1788 ///
1789 /// The non-capturing group is NOT decoration: `|` binds looser than
1790 /// concatenation, so the ungrouped `\Aa|b\z` parses as `(\Aa)|(b\z)`
1791 /// and each branch keeps only one anchor — measured, it still matches
1792 /// `axx`. `\A(?:a|b)\z` does not. `(?:` … `)` is non-capturing, so
1793 /// `captures_len` and therefore every `$PATH[n]` rewrite index are
1794 /// unchanged.
1795 ///
1796 /// The configured value is compiled ON ITS OWN first and only a value
1797 /// that compiles is wrapped. The wrapper supplies one `(` and one `)`, so
1798 /// an unbalanced pattern can be balanced BY the wrapping: `a)(b` is
1799 /// rejected by `Regex::new`, yet `\A(?:a)(b)\z` compiles and matches
1800 /// `ab`. Without the first compile, anchoring would silently promote a
1801 /// rule the router rejects today into a live rule matching something the
1802 /// operator never wrote.
1803 ///
1804 /// This closes invalid-becomes-valid only. The opposite direction is NOT
1805 /// closed and is not claimed to be: a pattern that compiles bare can be
1806 /// rejected once wrapped, because the appended `)\z` has to survive
1807 /// whatever the pattern left open. Measured: `(?x)/api/v1 # v1 only`
1808 /// compiles bare and fails wrapped with `unclosed group` — `(?x)` enables
1809 /// `#` comments, and the appended `)` lands inside one — and a 249-deep
1810 /// nested group compiles bare and fails wrapped against the crate's
1811 /// 250-nesting limit. Both land in the `None` arm of `from_config` and
1812 /// surface as [`RouterError::InvalidPathRule`] at frontend registration,
1813 /// which `bin/src/command/requests.rs`'s `validate_frontend_request` runs
1814 /// through this very same `add_http_front` before fanning out: the
1815 /// frontend is refused loudly and identically in main and worker, never
1816 /// silently mis-routed.
1817 ///
1818 /// Anchors the operator wrote by hand are harmless and are left alone:
1819 /// `regex`'s `^`/`$` are `\A`/`\z` in the default (non-multi-line) mode —
1820 /// `$` does not match before a trailing `\n` — so the repeated zero-width
1821 /// assertions in `\A(?:^/abc$)\z` collapse. Measured: that pattern matches
1822 /// `/abc` and not `/xabcd`, exactly as `\A(?:/abc)\z` does.
1823 fn anchored_regex(value: &str) -> Option<Regex> {
1824 anchored_regex(value)
1825 }
1826
1827 pub fn from_config(rule: CommandPathRule) -> Option<Self> {
1828 match PathRuleKind::try_from(rule.kind) {
1829 Ok(PathRuleKind::Prefix) => Some(PathRule::Prefix(rule.value)),
1830 Ok(PathRuleKind::Regex) => Self::anchored_regex(&rule.value).map(PathRule::Regex),
1831 Ok(PathRuleKind::Equals) => Some(PathRule::Equals(rule.value)),
1832 Err(_) => None,
1833 }
1834 }
1835}
1836
1837/// Compile `value` anchored at both ends, refusing a value that does not
1838/// compile on its own. [`PathRule::anchored_regex`] documents why both
1839/// steps are needed; header predicates (sozu-proxy/sozu#1253) reuse it so
1840/// a `regex` header value and a `REGEX` path follow one grammar.
1841fn anchored_regex(value: &str) -> Option<Regex> {
1842 Regex::new(value).ok()?;
1843 Regex::new(&format!("\\A(?:{value})\\z")).ok()
1844}
1845
1846/// `Regex` carries no `PartialEq`, so the comparison is written by hand
1847/// and compares patterns by `as_str()`. Every variant must answer for
1848/// itself: without an `Equals` arm the catch-all made two identical
1849/// `PathRule::Equals` unequal — not even reflexive — and the router's
1850/// bookkeeping (`add_*_rule` dedup, `remove_*_rule` eviction) silently
1851/// lost every `--path-equals` frontend, reporting success either way.
1852impl std::cmp::PartialEq for PathRule {
1853 fn eq(&self, other: &Self) -> bool {
1854 match (self, other) {
1855 (PathRule::Prefix(s1), PathRule::Prefix(s2)) => s1 == s2,
1856 (PathRule::Regex(r1), PathRule::Regex(r2)) => r1.as_str() == r2.as_str(),
1857 (PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2,
1858 _ => false,
1859 }
1860 }
1861}
1862
1863#[derive(Clone, Debug, PartialEq, Eq)]
1864pub struct MethodRule {
1865 pub inner: Option<Method>,
1866}
1867
1868#[derive(PartialEq, Eq)]
1869pub enum MethodRuleResult {
1870 All,
1871 Equals,
1872 None,
1873}
1874
1875impl MethodRule {
1876 pub fn new(method: Option<String>) -> Self {
1877 MethodRule {
1878 inner: method.map(|s| Method::new(s.as_bytes())),
1879 }
1880 }
1881
1882 pub fn matches(&self, method: &Method) -> MethodRuleResult {
1883 match self.inner {
1884 None => MethodRuleResult::All,
1885 Some(ref m) => {
1886 if method == m {
1887 MethodRuleResult::Equals
1888 } else {
1889 MethodRuleResult::None
1890 }
1891 }
1892 }
1893 }
1894}
1895
1896/// What to do with a request that matches a frontend.
1897///
1898/// Three variants coexist today; the legacy two will retire once
1899/// `HttpFrontend` itself carries the rich routing fields:
1900///
1901/// - [`Route::ClusterId`] is the legacy "forward to this cluster" variant
1902/// used by call sites that build routes directly from
1903/// [`HttpFrontend::cluster_id`].
1904/// - [`Route::Deny`] is the legacy "send 401" variant used when a frontend
1905/// has no `cluster_id`.
1906/// - [`Route::Frontend`] carries a richer [`Frontend`] decision (redirect
1907/// policy, rewrite templates, header edits, auth gating). Once
1908/// `HttpFrontend` carries the matching proto fields, `add_http_front`
1909/// will build `Route::Frontend` directly and the two legacy variants
1910/// above can retire.
1911///
1912/// `Eq`/`PartialEq` compare `Frontend` variants by `Rc` pointer identity to
1913/// stay consistent with `Hash`/`Ord` on [`Rc`]; this is sufficient for the
1914/// router's de-duplication (`add_pre_rule`, `add_post_rule`,
1915/// `add_tree_rule`) which only checks against routes created from the same
1916/// configuration call.
1917#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1918pub enum Route {
1919 /// send a 401 default answer
1920 Deny,
1921 /// the cluster to which the frontend belongs
1922 ClusterId(ClusterId),
1923 /// rich routing decision carrying redirect, rewrite, header, and auth
1924 /// configuration; supersedes the two legacy variants once the
1925 /// in-memory frontend wiring is migrated to build `Route::Frontend`
1926 /// directly.
1927 Frontend(Rc<Frontend>),
1928}
1929
1930/// Materialise the listener-default HSTS into a single shareable
1931/// [`HeaderEdit`] when the supplied policy resolves to a non-empty
1932/// `Strict-Transport-Security` header.
1933///
1934/// Returns `None` when the listener has no HSTS configured
1935/// (`new_hsts.is_none()`), when HSTS is explicitly disabled
1936/// (`enabled = Some(false)`), or when the render fails because of a
1937/// missing `max_age` (`enabled = Some(true)` with `max_age = None`,
1938/// the malformed-IPC defense-in-depth gate). Mirrors the gate
1939/// previously embedded in `rebuild_with_listener_hsts`.
1940///
1941/// Used by [`Router::refresh_inheriting_hsts`] to:
1942/// - decide whether promoting a lightweight `Route::ClusterId` /
1943/// `Route::Deny` to `Route::Frontend` is worth doing (`Some` =
1944/// promote + counted; `None` = lightweight route untouched, no
1945/// allocation created just to hold an empty edit), AND
1946/// - **share the same `Rc`-backed `key` / `val` allocation across
1947/// every visited frontend in this patch**. Without sharing, each
1948/// refreshed frontend would allocate a fresh
1949/// `Rc::from(b"strict-transport-security")` and a fresh
1950/// `Rc::from(rendered.into_bytes())` — on a 91 k-frontend
1951/// `cleverapps.io` shared listener × 8 workers, one HSTS-enable
1952/// patch produces ~1.5 M identical-content `Rc` allocations per
1953/// worker. With sharing the cost collapses to one allocation per
1954/// patch plus a refcount bump on each frontend (`HeaderEdit::clone`
1955/// is two `Rc::clone`s + a byte copy).
1956fn build_listener_hsts_edit(new_hsts: Option<&HstsConfig>) -> Option<HeaderEdit> {
1957 let cfg = new_hsts?;
1958 if !matches!(cfg.enabled, Some(true)) {
1959 return None;
1960 }
1961 let rendered = render_hsts(cfg)?;
1962 let mode = if matches!(cfg.force_replace_backend, Some(true)) {
1963 HeaderEditMode::Set
1964 } else {
1965 HeaderEditMode::SetIfAbsent
1966 };
1967 Some(HeaderEdit {
1968 key: Rc::from(&b"strict-transport-security"[..]),
1969 val: rendered.into_bytes().into(),
1970 mode,
1971 })
1972}
1973
1974/// Build a new [`Frontend`] cloned from `frontend`, with its
1975/// `headers_response` re-materialised against `new_edit` — the
1976/// shared listener-default HSTS edit pre-built by
1977/// [`build_listener_hsts_edit`]. Used by
1978/// [`Router::refresh_inheriting_hsts`].
1979///
1980/// Preserves every operator-defined response-header edit (`Append`,
1981/// `Set`, legacy empty-`val`-Append delete) and replaces any existing
1982/// `Strict-Transport-Security` entry with `new_edit`. When `new_edit`
1983/// is `None` (listener-default HSTS resolves to "no HSTS"), the
1984/// function strips the existing STS entry and adds nothing.
1985///
1986/// Preserves the existing `inherits_listener_hsts` marker; callers
1987/// ensure it is `true` before invoking this helper (the function
1988/// uses `..frontend.clone()` so it inherits whatever the input has).
1989fn rebuild_with_listener_hsts(frontend: &Frontend, new_edit: Option<&HeaderEdit>) -> Frontend {
1990 // Strip any existing Strict-Transport-Security entry.
1991 let mut headers_response: Vec<HeaderEdit> = frontend
1992 .headers_response
1993 .iter()
1994 .filter(|edit| !edit.key.eq_ignore_ascii_case(b"strict-transport-security"))
1995 .cloned()
1996 .collect();
1997
1998 // `HeaderEdit::clone` here is two `Rc::clone`s on the shared
1999 // key/val plus a one-byte `mode` copy — no buffer allocation.
2000 if let Some(edit) = new_edit {
2001 headers_response.push(edit.clone());
2002 }
2003
2004 Frontend {
2005 headers_response: headers_response.into(),
2006 // every other field is unchanged
2007 ..frontend.clone()
2008 }
2009}
2010
2011/// Render an [`HstsConfig`] into a canonical RFC 6797 §6.1
2012/// `Strict-Transport-Security` header value: `max-age=N` first, then
2013/// optional `; includeSubDomains`, then optional `; preload`. No
2014/// trailing semicolon. `includeSubDomains` is the RFC §6.1 spelling
2015/// (camelCase); `preload` is lowercase per the de-facto Chrome/HSTS
2016/// preload-list convention (<https://hstspreload.org/>).
2017///
2018/// Returns `None` when the config has no `max_age` (the caller should
2019/// have substituted the default at config-load via
2020/// `command/src/config.rs::FileHstsConfig::to_proto` before reaching
2021/// this site; if it didn't, a `None` here suppresses the emission so a
2022/// malformed wire frame can't leak `max-age=0` accidentally).
2023pub fn render_hsts(cfg: &HstsConfig) -> Option<String> {
2024 let max_age = cfg.max_age?;
2025 let mut s = format!("max-age={max_age}");
2026 if matches!(cfg.include_subdomains, Some(true)) {
2027 s.push_str("; includeSubDomains");
2028 }
2029 if matches!(cfg.preload, Some(true)) {
2030 s.push_str("; preload");
2031 }
2032 Some(s)
2033}
2034
2035/// A single header mutation collected from a [`Frontend`] configuration.
2036///
2037/// `key` and `val` are owned via [`Rc`] so a `Frontend` can be held by many
2038/// routing entries (pre, tree, post) without copying the underlying bytes.
2039///
2040/// `mode` controls how the per-stream `apply_response_header_edits` pass
2041/// emits the entry on the wire — see [`HeaderEditMode`]. Operator-supplied
2042/// `[[...frontends.headers]]` entries default to [`HeaderEditMode::Append`]
2043/// (preserving the legacy empty-val-deletes encoding); typed policies
2044/// (HSTS, future RFC-correct response policies) opt into
2045/// [`HeaderEditMode::SetIfAbsent`].
2046#[derive(Clone, PartialEq, Eq)]
2047pub struct HeaderEdit {
2048 pub key: Rc<[u8]>,
2049 pub val: Rc<[u8]>,
2050 pub mode: HeaderEditMode,
2051}
2052
2053impl Debug for HeaderEdit {
2054 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2055 f.write_fmt(format_args!(
2056 "({:?}, {:?}, {:?})",
2057 String::from_utf8_lossy(&self.key),
2058 String::from_utf8_lossy(&self.val),
2059 self.mode,
2060 ))
2061 }
2062}
2063
2064/// A parsed segment of a rewrite template.
2065///
2066/// `Host(i)` references the `i`-th host capture: index 0 is the full
2067/// hostname; positive indices are regex or wildcard subgroups. `Path(i)`
2068/// references the `i`-th path capture: index 0 is the full path; positive
2069/// indices are regex groups or prefix tails. `String` holds a literal
2070/// segment between captures.
2071#[derive(Debug, Clone, PartialEq, Eq)]
2072enum RewritePart {
2073 String(String),
2074 Host(usize),
2075 Path(usize),
2076}
2077
2078/// A pre-parsed rewrite template, decomposed into `RewritePart`s.
2079///
2080/// `RewriteParts` is built once at frontend registration time
2081/// ([`Frontend::new`]) and then re-applied at lookup time via
2082/// [`RewriteParts::run`] against the captures collected by the router.
2083///
2084/// Grammar:
2085/// - `$HOST[N]` — substitute the N-th host capture
2086/// - `$PATH[N]` — substitute the N-th path capture
2087/// - any other byte sequence — substitute literally
2088///
2089/// Out-of-bounds capture indices substitute to the empty string at run
2090/// time, but [`RewriteParts::parse`] rejects templates that reference
2091/// capture indices the router cannot produce (the `*_cap_cap` arguments).
2092#[derive(Debug, Clone, PartialEq, Eq)]
2093pub struct RewriteParts(Vec<RewritePart>);
2094
2095impl RewriteParts {
2096 /// Parse `template` against the host/path capture caps the router can
2097 /// produce for the matching domain/path rule.
2098 ///
2099 /// `host_cap_cap` and `path_cap_cap` are upper bounds (exclusive) on the
2100 /// host and path capture indices the router can fill at lookup time.
2101 /// `used_index_host` / `used_index_path` are out parameters tracking the
2102 /// highest index actually referenced — callers use them to short-circuit
2103 /// capture extraction when no template references captures.
2104 ///
2105 /// Returns `None` on syntactically malformed templates: dangling `$`,
2106 /// missing closing `]`, non-digit index, or an index ≥ the cap.
2107 pub fn parse(
2108 template: &str,
2109 host_cap_cap: usize,
2110 path_cap_cap: usize,
2111 used_index_host: &mut usize,
2112 used_index_path: &mut usize,
2113 ) -> Option<Self> {
2114 let mut result = Vec::new();
2115 let mut i = 0;
2116 let pattern = template.as_bytes();
2117 while i < pattern.len() {
2118 if pattern[i] == b'$' {
2119 let is_host = if pattern[i..].starts_with(b"$HOST[") {
2120 i += 6;
2121 true
2122 } else if pattern[i..].starts_with(b"$PATH[") {
2123 i += 6;
2124 false
2125 } else {
2126 return None;
2127 };
2128 let mut index = 0usize;
2129 let digits_start = i;
2130 while i < pattern.len() && pattern[i].is_ascii_digit() {
2131 index = index
2132 .checked_mul(10)?
2133 .checked_add((pattern[i] - b'0') as usize)?;
2134 i += 1;
2135 }
2136 if i == digits_start {
2137 // no digits between the `[` and the `]`
2138 return None;
2139 }
2140 if i >= pattern.len() || pattern[i] != b']' {
2141 return None;
2142 }
2143 if is_host {
2144 if index >= host_cap_cap {
2145 return None;
2146 }
2147 if index >= *used_index_host {
2148 *used_index_host = index + 1;
2149 }
2150 result.push(RewritePart::Host(index));
2151 } else {
2152 if index >= path_cap_cap {
2153 return None;
2154 }
2155 if index >= *used_index_path {
2156 *used_index_path = index + 1;
2157 }
2158 result.push(RewritePart::Path(index));
2159 }
2160 i += 1; // consume `]`
2161 } else {
2162 let start = i;
2163 while i < pattern.len() && pattern[i] != b'$' {
2164 i += 1;
2165 }
2166 // `pattern` is `template.as_bytes()` and the split is on
2167 // the ASCII byte `$` (0x24), which is always a single-byte
2168 // UTF-8 character — so `template[start..i]` lies on char
2169 // boundaries and is safe to index directly.
2170 result.push(RewritePart::String(template[start..i].to_owned()));
2171 }
2172 }
2173 // Every capture reference the parser emitted is within the caps it
2174 // was given; out-of-range indices return None above, never a part.
2175 debug_assert!(
2176 result.iter().all(|part| match part {
2177 RewritePart::Host(idx) => *idx < host_cap_cap,
2178 RewritePart::Path(idx) => *idx < path_cap_cap,
2179 RewritePart::String(_) => true,
2180 }),
2181 "a parsed rewrite template must only reference captures within the rule's caps",
2182 );
2183 debug_assert!(
2184 *used_index_host <= host_cap_cap && *used_index_path <= path_cap_cap,
2185 "the highest referenced capture index cannot exceed the cap",
2186 );
2187 Some(Self(result))
2188 }
2189
2190 /// Substitute `host_captures` and `path_captures` into the template.
2191 ///
2192 /// Out-of-bounds captures substitute to an empty string. The result is
2193 /// allocated in one pass with the exact required capacity.
2194 pub fn run(&self, host_captures: &[&str], path_captures: &[&str]) -> String {
2195 let mut cap = 0usize;
2196 for part in &self.0 {
2197 cap += match part {
2198 RewritePart::String(s) => s.len(),
2199 RewritePart::Host(i) => host_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2200 RewritePart::Path(i) => path_captures.get(*i).map(|s| s.len()).unwrap_or(0),
2201 };
2202 }
2203 let mut result = String::with_capacity(cap);
2204 for part in &self.0 {
2205 // String::write_str cannot fail — ignore the formatter result.
2206 let _ = match part {
2207 RewritePart::String(s) => result.write_str(s),
2208 RewritePart::Host(i) => result.write_str(host_captures.get(*i).unwrap_or(&"")),
2209 RewritePart::Path(i) => result.write_str(path_captures.get(*i).unwrap_or(&"")),
2210 };
2211 }
2212 // The capacity pass and the write pass consult the same parts and
2213 // captures, so the single up-front allocation must be exact — the
2214 // result never reallocates.
2215 debug_assert_eq!(
2216 result.len(),
2217 cap,
2218 "rewrite output length must equal the pre-computed one-pass capacity",
2219 );
2220 result
2221 }
2222}
2223
2224/// What to do with the traffic for a routed frontend.
2225///
2226/// Built once at frontend registration time. The expensive work (parsing
2227/// rewrite templates, resolving headers into [`HeaderEdit`]s) happens here
2228/// so [`Router::lookup`] can run cheaply on the hot path.
2229///
2230/// A clusterless frontend with `redirect == FORWARD` is coerced to
2231/// `UNAUTHORIZED` in [`Frontend::new`] to avoid a forward loop with no
2232/// backend; the explicit `UNAUTHORIZED` policy then renders a 401.
2233///
2234/// Tags are wrapped in [`Rc<CachedTags>`] so the same frontend can be
2235/// referenced from multiple routing slots (pre/tree/post) without copying.
2236#[derive(Debug, Clone)]
2237pub struct Frontend {
2238 pub cluster_id: Option<ClusterId>,
2239 pub redirect: RedirectPolicy,
2240 pub redirect_scheme: RedirectScheme,
2241 pub redirect_template: Option<String>,
2242 /// Number of host captures the router will collect for this frontend.
2243 /// Sized from the matching [`DomainRule`]; the router skips capture
2244 /// extraction entirely when this is 0 (no rewrite references `$HOST[…]`).
2245 pub capture_cap_host: usize,
2246 /// Number of path captures the router will collect for this frontend.
2247 /// Sized from the matching [`PathRule`]; the router skips capture
2248 /// extraction entirely when this is 0 (no rewrite references `$PATH[…]`).
2249 pub capture_cap_path: usize,
2250 pub rewrite_host: Option<RewriteParts>,
2251 pub rewrite_path: Option<RewriteParts>,
2252 pub rewrite_port: Option<u16>,
2253 pub headers_request: Rc<[HeaderEdit]>,
2254 pub headers_response: Rc<[HeaderEdit]>,
2255 pub required_auth: bool,
2256 pub tags: Option<Rc<CachedTags>>,
2257 /// `true` when the frontend declared no per-frontend
2258 /// `RequestHttpFrontend.hsts` block, so the HSTS edit (if any) in
2259 /// [`Self::headers_response`] follows the listener-default
2260 /// `HttpsListenerConfig.hsts` — including when that default was
2261 /// absent at add time, so a default set later, or the default of a
2262 /// listener the frontend is copied into, still reaches it
2263 /// (sozu-proxy/sozu#1691). Consulted by
2264 /// [`Router::refresh_inheriting_hsts`] so a
2265 /// `UpdateHttpsListenerConfig.hsts` patch reflows the new default
2266 /// onto inheriting frontends without overwriting explicit
2267 /// per-frontend HSTS overrides.
2268 pub inherits_listener_hsts: bool,
2269}
2270
2271/// Origin of the per-frontend HSTS policy carried by an
2272/// [`HttpFrontend`] when the router materialises it into a
2273/// [`Frontend`]. Tracked separately because the resolved
2274/// `HttpFrontend.hsts` field is the same shape regardless of how it
2275/// was filled in — the inheritance bit lets later listener-default
2276/// patches refresh inheriting frontends without disturbing explicit
2277/// per-frontend overrides.
2278#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2279pub enum HstsOrigin {
2280 /// `front.hsts` came from the per-frontend configuration directly
2281 /// (operator wrote `[clusters.<id>.frontends.hsts]` in TOML or
2282 /// passed `--hsts-*` on the CLI). Listener-default patches do NOT
2283 /// refresh this entry.
2284 Explicit,
2285 /// The frontend declared no `hsts`, and `add_https_frontend` filled
2286 /// `front.hsts` in from the listener-default
2287 /// `HttpsListenerConfig.hsts` — `None` when the listener has no
2288 /// default. A future
2289 /// `UpdateHttpsListenerConfig.hsts` patch will refresh this entry
2290 /// via [`Router::refresh_inheriting_hsts`].
2291 InheritedFromListenerDefault,
2292}
2293
2294impl PartialEq for Frontend {
2295 fn eq(&self, other: &Self) -> bool {
2296 // Frontend instances share the rest of their fields with the
2297 // originating HttpFrontend; equality is decided by the same fields
2298 // the router uses for de-duplication.
2299 self.cluster_id == other.cluster_id
2300 && self.redirect == other.redirect
2301 && self.redirect_scheme == other.redirect_scheme
2302 && self.redirect_template == other.redirect_template
2303 && self.rewrite_host == other.rewrite_host
2304 && self.rewrite_path == other.rewrite_path
2305 && self.rewrite_port == other.rewrite_port
2306 && self.headers_request == other.headers_request
2307 && self.headers_response == other.headers_response
2308 && self.required_auth == other.required_auth
2309 }
2310}
2311
2312impl Eq for Frontend {}
2313
2314impl std::hash::Hash for Frontend {
2315 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
2316 self.cluster_id.hash(state);
2317 // RedirectPolicy / RedirectScheme are i32-backed proto enums; hash
2318 // them as i32 to avoid requiring a Hash impl on the generated enum.
2319 (self.redirect as i32).hash(state);
2320 (self.redirect_scheme as i32).hash(state);
2321 self.redirect_template.hash(state);
2322 self.required_auth.hash(state);
2323 }
2324}
2325
2326impl PartialOrd for Frontend {
2327 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2328 Some(self.cmp(other))
2329 }
2330}
2331
2332impl Ord for Frontend {
2333 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2334 self.cluster_id
2335 .cmp(&other.cluster_id)
2336 .then_with(|| (self.redirect as i32).cmp(&(other.redirect as i32)))
2337 .then_with(|| (self.redirect_scheme as i32).cmp(&(other.redirect_scheme as i32)))
2338 .then_with(|| self.redirect_template.cmp(&other.redirect_template))
2339 .then_with(|| self.required_auth.cmp(&other.required_auth))
2340 }
2341}
2342
2343impl Frontend {
2344 /// Build a [`Frontend`] from a domain/path rule pair and an
2345 /// [`HttpFrontend`] configuration.
2346 ///
2347 /// The richer proto-level fields (`redirect`, `redirect_scheme`,
2348 /// `redirect_template`, `rewrite_*`, `headers`, `required_auth`) are
2349 /// not yet carried on `HttpFrontend`; until they are, this constructor
2350 /// takes them as explicit arguments so the data flow is testable
2351 /// today and the call sites in `add_http_front` only need a one-line
2352 /// update once the fields are plumbed through.
2353 ///
2354 /// Coercions:
2355 /// - `redirect == UNAUTHORIZED` zeroes out rewrite/headers/auth — the
2356 /// request will be rejected with a 401 regardless.
2357 /// - `redirect == FORWARD` on a clusterless frontend (`cluster_id ==
2358 /// None`) is coerced to `UNAUTHORIZED` (logged as a warning) to
2359 /// avoid a forward loop with no backend.
2360 ///
2361 /// Returns [`RouterError::InvalidHostRewrite`] /
2362 /// [`RouterError::InvalidPathRewrite`] when a rewrite template fails to
2363 /// parse against the rule's capture caps.
2364 #[allow(clippy::too_many_arguments)]
2365 pub fn new(
2366 domain_rule: &DomainRule,
2367 path_rule: &PathRule,
2368 front: &HttpFrontend,
2369 redirect: RedirectPolicy,
2370 redirect_scheme: RedirectScheme,
2371 redirect_template: Option<String>,
2372 rewrite_host: Option<String>,
2373 rewrite_path: Option<String>,
2374 rewrite_port: Option<u16>,
2375 headers: &[sozu_command::proto::command::Header],
2376 required_auth: bool,
2377 hsts_origin: HstsOrigin,
2378 ) -> Result<Self, RouterError> {
2379 // HSTS is read from `front.hsts` directly inside the function;
2380 // an explicit parameter would be redundant since `front` is
2381 // already in scope and the field is the single source of truth.
2382 // The `hsts_origin` parameter records whether the frontend declared
2383 // its own `hsts` (whatever the listener default was at add time) so
2384 // [`Router::refresh_inheriting_hsts`] can reflow listener defaults
2385 // without disturbing explicit per-frontend overrides.
2386 let hsts = front.hsts.as_ref();
2387 let inherits_listener_hsts =
2388 matches!(hsts_origin, HstsOrigin::InheritedFromListenerDefault);
2389 let cluster_id = front.cluster_id.clone();
2390 let tags = front
2391 .tags
2392 .clone()
2393 .map(|tags| Rc::new(CachedTags::new(tags)));
2394
2395 // Coerce clusterless FORWARD to UNAUTHORIZED before doing any
2396 // expensive parsing: those routes can never proceed to a backend, so
2397 // emitting a 401 is the safe default. Empty redirect_template is
2398 // treated as None semantics so we never store an empty Rc<[…]>.
2399 let redirect_template = redirect_template.filter(|s| !s.is_empty());
2400 let rewrite_host = rewrite_host.filter(|s| !s.is_empty());
2401 let rewrite_path = rewrite_path.filter(|s| !s.is_empty());
2402
2403 let deny = match (&cluster_id, redirect) {
2404 (_, RedirectPolicy::Unauthorized) => true,
2405 (None, RedirectPolicy::Forward) => {
2406 let (domain_kind, domain_bytes) = match &domain_rule {
2407 DomainRule::Any => ("any", 0),
2408 DomainRule::Exact(value) => ("exact", value.len()),
2409 DomainRule::Wildcard(value) => ("wildcard", value.len()),
2410 DomainRule::Regex(value) => ("regex", value.as_str().len()),
2411 };
2412 let (path_kind, path_bytes) = match &path_rule {
2413 PathRule::Prefix(value) => ("prefix", value.len()),
2414 PathRule::Regex(value) => ("regex", value.as_str().len()),
2415 PathRule::Equals(value) => ("equals", value.len()),
2416 };
2417 warn!(
2418 "{} Frontend[domain_kind={}, domain_bytes={}, path_kind={}, path_bytes={}]: forward on clusterless frontends are unauthorized",
2419 log_module_context!(),
2420 domain_kind,
2421 domain_bytes,
2422 path_kind,
2423 path_bytes,
2424 );
2425 true
2426 }
2427 _ => false,
2428 };
2429 if deny {
2430 // The Unauthorized policy zeroes out request rewrites and
2431 // header injections (the request never reaches a backend),
2432 // but RFC 6797 §8.1 still requires HSTS on the 401 default
2433 // answer when the frontend is on an HTTPS listener. Build
2434 // the HSTS edit (when configured) so the per-stream
2435 // snapshot copy in `mux/router.rs` carries it through to
2436 // `set_default_answer_with_retry_after`.
2437 let mut deny_headers_response: Vec<HeaderEdit> = Vec::new();
2438 if let Some(cfg) = hsts
2439 && matches!(cfg.enabled, Some(true))
2440 && let Some(rendered) = render_hsts(cfg)
2441 {
2442 let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2443 HeaderEditMode::Set
2444 } else {
2445 HeaderEditMode::SetIfAbsent
2446 };
2447 deny_headers_response.push(HeaderEdit {
2448 key: Rc::from(&b"strict-transport-security"[..]),
2449 val: rendered.into_bytes().into(),
2450 mode,
2451 });
2452 crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2453 }
2454
2455 return Ok(Self {
2456 cluster_id,
2457 redirect: RedirectPolicy::Unauthorized,
2458 redirect_scheme,
2459 redirect_template: None,
2460 capture_cap_host: 0,
2461 capture_cap_path: 0,
2462 rewrite_host: None,
2463 rewrite_path: None,
2464 rewrite_port: None,
2465 headers_request: Rc::new([]),
2466 headers_response: deny_headers_response.into(),
2467 required_auth,
2468 tags,
2469 inherits_listener_hsts,
2470 });
2471 }
2472
2473 // Capture caps: the maximum index a `$HOST[N]` / `$PATH[N]`
2474 // template can reference for this rule pair. Index 0 is always the
2475 // full hostname/path; subsequent indices are wildcard tail / regex
2476 // groups.
2477 let mut capture_cap_host = match domain_rule {
2478 DomainRule::Any => 1,
2479 DomainRule::Exact(_) => 1,
2480 DomainRule::Wildcard(_) => 2,
2481 DomainRule::Regex(regex) => regex.captures_len(),
2482 };
2483 let mut capture_cap_path = match path_rule {
2484 PathRule::Equals(_) => 1,
2485 PathRule::Prefix(_) => 2,
2486 PathRule::Regex(regex) => regex.captures_len(),
2487 };
2488 let mut used_capture_host = 0usize;
2489 let mut used_capture_path = 0usize;
2490 let rewrite_host_parts = if let Some(p) = rewrite_host {
2491 Some(
2492 RewriteParts::parse(
2493 &p,
2494 capture_cap_host,
2495 capture_cap_path,
2496 &mut used_capture_host,
2497 &mut used_capture_path,
2498 )
2499 .ok_or(RouterError::InvalidHostRewrite(p))?,
2500 )
2501 } else {
2502 None
2503 };
2504 let rewrite_path_parts = if let Some(p) = rewrite_path {
2505 Some(
2506 RewriteParts::parse(
2507 &p,
2508 capture_cap_host,
2509 capture_cap_path,
2510 &mut used_capture_host,
2511 &mut used_capture_path,
2512 )
2513 .ok_or(RouterError::InvalidPathRewrite(p))?,
2514 )
2515 } else {
2516 None
2517 };
2518 // Skip capture extraction at lookup time when no template references
2519 // a capture for this dimension.
2520 if used_capture_host == 0 {
2521 capture_cap_host = 0;
2522 }
2523 if used_capture_path == 0 {
2524 capture_cap_path = 0;
2525 }
2526
2527 let mut headers_request = Vec::new();
2528 let mut headers_response = Vec::new();
2529 for header in headers {
2530 let edit = HeaderEdit {
2531 key: header.key.as_bytes().into(),
2532 val: header.val.as_bytes().into(),
2533 mode: HeaderEditMode::Append,
2534 };
2535 match header.position() {
2536 HeaderPosition::Request => headers_request.push(edit),
2537 HeaderPosition::Response => headers_response.push(edit),
2538 HeaderPosition::Both => {
2539 headers_request.push(edit.clone());
2540 headers_response.push(edit);
2541 }
2542 // The proto-default-encoded shape (`position: 0`). The TOML
2543 // loader rejects this case in `parse_header_edit` so the
2544 // path is only reachable via a manually-constructed
2545 // `Header { position: 0, … }` from a buggy or older
2546 // client. Drop the edit rather than guessing a position.
2547 HeaderPosition::Unspecified => {
2548 warn!(
2549 "{} dropping {:?} with HEADER_POSITION_UNSPECIFIED",
2550 log_module_context!(),
2551 header,
2552 );
2553 }
2554 }
2555 }
2556
2557 // Materialise HSTS (RFC 6797) into the response-side header
2558 // collection as a single `SetIfAbsent` edit so an upstream-emitted
2559 // `Strict-Transport-Security` survives unchanged (RFC 6797 §6.1
2560 // single-header requirement). `enabled = Some(false)` is the
2561 // explicit-disable signal — emit nothing. The §7.2 "no STS over
2562 // plaintext HTTP" gate is enforced by the runtime snapshot site,
2563 // which only copies `headers_response` for HTTPS-served requests.
2564 if let Some(cfg) = hsts
2565 && matches!(cfg.enabled, Some(true))
2566 {
2567 if let Some(rendered) = render_hsts(cfg) {
2568 // RFC 6797 §6.1 default: PRESERVE backend-supplied STS
2569 // (SetIfAbsent). Operator opts into harden-centrally
2570 // override via `force_replace_backend = true`, which
2571 // selects `Set` (delete-then-insert) so any backend
2572 // STS is replaced with sozu's rendered policy.
2573 let mode = if matches!(cfg.force_replace_backend, Some(true)) {
2574 HeaderEditMode::Set
2575 } else {
2576 HeaderEditMode::SetIfAbsent
2577 };
2578 headers_response.push(HeaderEdit {
2579 key: Rc::from(&b"strict-transport-security"[..]),
2580 val: rendered.into_bytes().into(),
2581 mode,
2582 });
2583 crate::incr!(names::http::HSTS_FRONTEND_ADDED);
2584 } else {
2585 // Both upstream config layers (FileHstsConfig::to_proto and
2586 // build_hsts_from_cli) substitute DEFAULT_HSTS_MAX_AGE when
2587 // enabled = Some(true) && max_age = None, so reaching this
2588 // branch means a programmatic IPC sender produced an
2589 // ill-formed HstsConfig. Surface it loudly rather than
2590 // silently emitting no header — operators inspecting their
2591 // dashboards for http.hsts.unrendered will catch the bug.
2592 warn!(
2593 "{} HSTS enabled = true on frontend cluster_id_bytes={:?} but render_hsts \
2594 returned None (max_age missing). Frontend will not emit \
2595 Strict-Transport-Security; the config layer that built \
2596 this HstsConfig must substitute DEFAULT_HSTS_MAX_AGE.",
2597 log_module_context!(),
2598 cluster_id.as_deref().map(str::len),
2599 );
2600 crate::incr!(names::http::HSTS_UNRENDERED);
2601 }
2602 }
2603
2604 Ok(Frontend {
2605 cluster_id,
2606 redirect,
2607 redirect_scheme,
2608 redirect_template,
2609 capture_cap_host,
2610 capture_cap_path,
2611 rewrite_host: rewrite_host_parts,
2612 rewrite_path: rewrite_path_parts,
2613 rewrite_port,
2614 headers_request: headers_request.into(),
2615 headers_response: headers_response.into(),
2616 required_auth,
2617 tags,
2618 inherits_listener_hsts,
2619 })
2620 }
2621
2622 /// Build a minimal Frontend that simply forwards to `cluster_id`,
2623 /// with no rewrite / header / auth configuration. Equivalent to a
2624 /// `Route::ClusterId(cluster_id)` lookup-wise (lookup short-circuits
2625 /// to `RouteResult::forward(id)` for both shapes), with the added
2626 /// ability to carry response-header edits — used by
2627 /// [`Router::refresh_inheriting_hsts`] to promote a lightweight
2628 /// `Route::ClusterId` to a `Route::Frontend` carrying just the
2629 /// listener-default HSTS edit. The `inherits_listener_hsts = true`
2630 /// marker lets subsequent listener-default patches keep refreshing
2631 /// the promoted entry.
2632 pub(crate) fn minimal_forward(cluster_id: ClusterId) -> Self {
2633 Self {
2634 cluster_id: Some(cluster_id),
2635 redirect: RedirectPolicy::Forward,
2636 redirect_scheme: RedirectScheme::UseSame,
2637 redirect_template: None,
2638 capture_cap_host: 0,
2639 capture_cap_path: 0,
2640 rewrite_host: None,
2641 rewrite_path: None,
2642 rewrite_port: None,
2643 headers_request: Rc::new([]),
2644 headers_response: Rc::new([]),
2645 required_auth: false,
2646 tags: None,
2647 inherits_listener_hsts: true,
2648 }
2649 }
2650
2651 /// Build a minimal clusterless Frontend with the
2652 /// [`RedirectPolicy::Unauthorized`] policy. Equivalent to a
2653 /// `Route::Deny` lookup-wise (both yield a 401 default answer),
2654 /// with the added ability to carry response-header edits — used by
2655 /// [`Router::refresh_inheriting_hsts`] to promote `Route::Deny`
2656 /// entries when the listener-default HSTS becomes enabled, so the
2657 /// 401 default answer carries the `Strict-Transport-Security`
2658 /// header per RFC 6797 §8.1. The `inherits_listener_hsts = true`
2659 /// marker lets subsequent listener-default patches keep refreshing
2660 /// the promoted entry.
2661 pub(crate) fn minimal_deny() -> Self {
2662 Self {
2663 cluster_id: None,
2664 redirect: RedirectPolicy::Unauthorized,
2665 redirect_scheme: RedirectScheme::UseSame,
2666 redirect_template: None,
2667 capture_cap_host: 0,
2668 capture_cap_path: 0,
2669 rewrite_host: None,
2670 rewrite_path: None,
2671 rewrite_port: None,
2672 headers_request: Rc::new([]),
2673 headers_response: Rc::new([]),
2674 required_auth: false,
2675 tags: None,
2676 inherits_listener_hsts: true,
2677 }
2678 }
2679}
2680
2681/// Routing decision returned by [`Router::lookup`] and consumed by the
2682/// session layer.
2683///
2684/// Computed from a matched [`Frontend`] by running [`RewriteParts::run`]
2685/// against the captures collected during routing. Legacy [`Route::ClusterId`]
2686/// and [`Route::Deny`] entries synthesize a minimal `RouteResult` with the
2687/// proto enums set to defaults (`FORWARD` / `UNAUTHORIZED`) so existing
2688/// session code keeps working until the mux layer is updated to read every
2689/// `RouteResult` field directly.
2690///
2691/// Implements `PartialEq` for test parity: existing router tests compare
2692/// `router.lookup(...)` against an expected route. Equality compares every
2693/// public field, including the `Rc<[HeaderEdit]>` slices via pointer-or-content
2694/// equality on the slice contents.
2695#[derive(Debug, Clone, PartialEq)]
2696pub struct RouteResult {
2697 pub cluster_id: Option<ClusterId>,
2698 pub redirect: RedirectPolicy,
2699 pub redirect_scheme: RedirectScheme,
2700 pub redirect_template: Option<String>,
2701 pub rewritten_host: Option<String>,
2702 pub rewritten_path: Option<String>,
2703 pub rewritten_port: Option<u16>,
2704 pub headers_request: Rc<[HeaderEdit]>,
2705 pub headers_response: Rc<[HeaderEdit]>,
2706 pub required_auth: bool,
2707 pub tags: Option<Rc<CachedTags>>,
2708}
2709
2710impl RouteResult {
2711 /// Synthesize a `RouteResult` representing a 401 (Deny) decision.
2712 pub fn deny(cluster_id: Option<ClusterId>) -> Self {
2713 Self {
2714 cluster_id,
2715 redirect: RedirectPolicy::Unauthorized,
2716 redirect_scheme: RedirectScheme::UseSame,
2717 redirect_template: None,
2718 rewritten_host: None,
2719 rewritten_path: None,
2720 rewritten_port: None,
2721 headers_request: Rc::new([]),
2722 headers_response: Rc::new([]),
2723 required_auth: false,
2724 tags: None,
2725 }
2726 }
2727
2728 /// Synthesize a `RouteResult` representing a "forward to this cluster"
2729 /// decision (legacy [`Route::ClusterId`] adapter).
2730 pub fn forward(cluster_id: ClusterId) -> Self {
2731 Self {
2732 cluster_id: Some(cluster_id),
2733 redirect: RedirectPolicy::Forward,
2734 redirect_scheme: RedirectScheme::UseSame,
2735 redirect_template: None,
2736 rewritten_host: None,
2737 rewritten_path: None,
2738 rewritten_port: None,
2739 headers_request: Rc::new([]),
2740 headers_response: Rc::new([]),
2741 required_auth: false,
2742 tags: None,
2743 }
2744 }
2745
2746 /// Build a `RouteResult` from a [`Frontend`] and the captures collected
2747 /// for this lookup.
2748 fn from_frontend(
2749 frontend: &Frontend,
2750 captures_host: Vec<&str>,
2751 path: &[u8],
2752 path_rule: &PathRule,
2753 ) -> Self {
2754 // Unauthorized short-circuit: skip the path-capture extraction
2755 // entirely — the response will not consume any rewrite output.
2756 // `headers_response` IS preserved here (unlike `headers_request`)
2757 // so the per-stream snapshot copy in `mux/router.rs` can still
2758 // pick up the per-frontend HSTS edit and inject it on the 401
2759 // default answer (RFC 6797 §8.1 — HSTS applies to all response
2760 // codes, including the proxy's typed unauthorized answer).
2761 if frontend.redirect == RedirectPolicy::Unauthorized {
2762 return Self {
2763 cluster_id: frontend.cluster_id.clone(),
2764 redirect: RedirectPolicy::Unauthorized,
2765 redirect_scheme: frontend.redirect_scheme,
2766 redirect_template: frontend.redirect_template.clone(),
2767 rewritten_host: None,
2768 rewritten_path: None,
2769 rewritten_port: None,
2770 headers_request: Rc::new([]),
2771 headers_response: frontend.headers_response.clone(),
2772 required_auth: frontend.required_auth,
2773 tags: frontend.tags.clone(),
2774 };
2775 }
2776
2777 let mut captures_path: Vec<&str> = Vec::with_capacity(frontend.capture_cap_path);
2778 if frontend.capture_cap_path > 0 {
2779 captures_path.push(from_utf8(path).unwrap_or_default());
2780 match path_rule {
2781 PathRule::Prefix(prefix) => {
2782 let tail_start = prefix.len().min(path.len());
2783 captures_path.push(from_utf8(&path[tail_start..]).unwrap_or_default());
2784 }
2785 PathRule::Regex(regex) => {
2786 if let Some(caps) = regex.captures(path) {
2787 captures_path.extend(caps.iter().skip(1).map(|c| {
2788 c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2789 .unwrap_or("")
2790 }));
2791 }
2792 }
2793 PathRule::Equals(_) => {}
2794 }
2795 }
2796
2797 Self {
2798 cluster_id: frontend.cluster_id.clone(),
2799 redirect: frontend.redirect,
2800 redirect_scheme: frontend.redirect_scheme,
2801 redirect_template: frontend.redirect_template.clone(),
2802 rewritten_host: frontend
2803 .rewrite_host
2804 .as_ref()
2805 .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2806 rewritten_path: frontend
2807 .rewrite_path
2808 .as_ref()
2809 .map(|rewrite| rewrite.run(&captures_host, &captures_path)),
2810 rewritten_port: frontend.rewrite_port,
2811 headers_request: frontend.headers_request.clone(),
2812 headers_response: frontend.headers_response.clone(),
2813 required_auth: frontend.required_auth,
2814 tags: frontend.tags.clone(),
2815 }
2816 }
2817
2818 /// Build a `RouteResult` for a pre/post rule match.
2819 ///
2820 /// Pre/post rules carry the matched [`DomainRule`] directly so we can
2821 /// extract host captures from it without going through the trie.
2822 fn new_no_trie<'a>(
2823 domain: &'a [u8],
2824 domain_rule: &DomainRule,
2825 path: &'a [u8],
2826 path_rule: &PathRule,
2827 route: &Route,
2828 ) -> Self {
2829 let frontend = match route {
2830 Route::Frontend(f) => f.clone(),
2831 Route::ClusterId(id) => return Self::forward(id.clone()),
2832 Route::Deny => return Self::deny(None),
2833 };
2834 let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2835 if frontend.capture_cap_host > 0 {
2836 captures_host.push(from_utf8(domain).unwrap_or_default());
2837 match domain_rule {
2838 DomainRule::Wildcard(suffix) => {
2839 let head_end = domain.len().saturating_sub(suffix.len().saturating_sub(1));
2840 captures_host.push(from_utf8(&domain[..head_end]).unwrap_or_default());
2841 }
2842 DomainRule::Regex(regex) => {
2843 if let Some(caps) = regex.captures(domain) {
2844 captures_host.extend(caps.iter().skip(1).map(|c| {
2845 c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2846 .unwrap_or("")
2847 }));
2848 }
2849 }
2850 DomainRule::Any | DomainRule::Exact(_) => {}
2851 }
2852 }
2853 Self::from_frontend(&frontend, captures_host, path, path_rule)
2854 }
2855
2856 /// Build a `RouteResult` for a tree-match.
2857 ///
2858 /// Tree matches carry the captures collected by the trie traversal
2859 /// (`InlineTrieMatches`) alongside the matched leaf path rule.
2860 ///
2861 /// The trie records segments in walk order, rightmost label first, so
2862 /// they are read back in reverse: `$HOST[n]` counts left to right, as the
2863 /// whole-host regex of a pre/post rule does (sozu#1595). Each segment's
2864 /// own groups keep their regex order.
2865 fn new_with_trie<'a, 'b>(
2866 domain: &'a [u8],
2867 domain_submatches: &InlineTrieMatches<'a, 'b>,
2868 path: &'a [u8],
2869 path_rule: &PathRule,
2870 route: &Route,
2871 ) -> Self {
2872 let frontend = match route {
2873 Route::Frontend(f) => f.clone(),
2874 Route::ClusterId(id) => return Self::forward(id.clone()),
2875 Route::Deny => return Self::deny(None),
2876 };
2877 let mut captures_host: Vec<&str> = Vec::with_capacity(frontend.capture_cap_host);
2878 if frontend.capture_cap_host > 0 {
2879 captures_host.push(from_utf8(domain).unwrap_or_default());
2880 for submatch in domain_submatches.iter().rev() {
2881 match submatch {
2882 TrieSubMatch::Wildcard(part) => {
2883 captures_host.push(from_utf8(part).unwrap_or_default());
2884 }
2885 TrieSubMatch::Regexp(part, regex) => {
2886 if let Some(caps) = regex.captures(part) {
2887 captures_host.extend(caps.iter().skip(1).map(|c| {
2888 c.map(|m| from_utf8(m.as_bytes()).unwrap_or_default())
2889 .unwrap_or("")
2890 }));
2891 }
2892 }
2893 }
2894 }
2895 }
2896 Self::from_frontend(&frontend, captures_host, path, path_rule)
2897 }
2898}
2899
2900#[cfg(test)]
2901mod tests {
2902 use super::*;
2903 use quickcheck::{Arbitrary, Gen, TestResult, quickcheck};
2904
2905 /// A frontend's `method` is classified by the same case-sensitive
2906 /// [`Method::new`] as the request's (sozu-proxy/sozu#1451), so the
2907 /// operator-facing rule is "declare the method in the exact case the
2908 /// client sends". A frontend declared `method = "get"` is a custom-method
2909 /// rule: it no longer answers a canonical `GET` request, and it answers a
2910 /// literal `get` one instead.
2911 #[test]
2912 fn method_rule_is_case_sensitive() {
2913 let declared_lowercase = MethodRule::new(Some("get".to_owned()));
2914
2915 assert_eq!(
2916 declared_lowercase.inner,
2917 Some(Method::Custom("get".to_owned())),
2918 "a lowercase declaration must not collapse onto Method::Get"
2919 );
2920 assert!(
2921 declared_lowercase.matches(&Method::Get) == MethodRuleResult::None,
2922 "a lowercase declaration must not match a canonical GET request"
2923 );
2924 assert!(
2925 declared_lowercase.matches(&Method::new(b"get")) == MethodRuleResult::Equals,
2926 "a lowercase declaration must match a literal lowercase request"
2927 );
2928
2929 let declared_canonical = MethodRule::new(Some("GET".to_owned()));
2930
2931 assert!(
2932 declared_canonical.matches(&Method::Get) == MethodRuleResult::Equals,
2933 "a canonical declaration must still match a canonical GET request"
2934 );
2935 assert!(
2936 declared_canonical.matches(&Method::new(b"get")) == MethodRuleResult::None,
2937 "a canonical declaration must not match a lowercase request"
2938 );
2939 }
2940
2941 fn test_http_frontend() -> HttpFrontend {
2942 HttpFrontend {
2943 cluster_id: Some("cluster".into()),
2944 address: "127.0.0.1:8080"
2945 .parse()
2946 .expect("test frontend address must parse"),
2947 hostname: "example.com".to_owned(),
2948 path: CommandPathRule::prefix("/".to_owned()),
2949 method: None,
2950 position: RulePosition::Tree,
2951 tags: None,
2952 redirect: None,
2953 redirect_scheme: None,
2954 redirect_template: None,
2955 rewrite_host: None,
2956 rewrite_path: None,
2957 rewrite_port: None,
2958 required_auth: None,
2959 headers: Vec::new(),
2960 hsts: None,
2961 header_predicates: Vec::new(),
2962 }
2963 }
2964
2965 #[test]
2966 fn clusterless_forward_warning_redacts_domain_and_path_rules() {
2967 const DOMAIN_SECRET: &str = "clusterless_domain_secret_sentinel";
2968 const PATH_SECRET: &str = "CLUSTERLESS_PATH_SECRET_SENTINEL";
2969
2970 // The hostname stays below `MAX_HOSTNAME_LENGTH` so it reaches the
2971 // code under test instead of the pre-parse bound; the redaction
2972 // property being asserted is length-independent.
2973 let domain = format!("{DOMAIN_SECRET}{}", "x".repeat(2048));
2974 let path = format!("/{PATH_SECRET}{}", "x".repeat(4096));
2975 let domain_len = domain.len();
2976 let path_len = path.len();
2977 let output = crate::capture_test_logs(move || {
2978 let mut router = Router::new();
2979 let mut front = test_http_frontend();
2980 front.cluster_id = None;
2981 front.hostname = domain;
2982 front.path = CommandPathRule::prefix(path);
2983 front.redirect = Some(RedirectPolicy::Forward as i32);
2984 router
2985 .add_http_front(&front)
2986 .expect("clusterless forward must be coerced to unauthorized");
2987 });
2988
2989 for secret in [DOMAIN_SECRET, PATH_SECRET] {
2990 assert!(
2991 !output.contains(secret),
2992 "clusterless-forward warning leaked rule marker {secret}"
2993 );
2994 }
2995 for metadata in [
2996 "domain_kind=exact".to_owned(),
2997 format!("domain_bytes={domain_len}"),
2998 "path_kind=prefix".to_owned(),
2999 format!("path_bytes={path_len}"),
3000 ] {
3001 assert!(
3002 output.contains(&metadata),
3003 "clusterless-forward warning omitted bounded metadata {metadata}: {output}"
3004 );
3005 }
3006 assert!(
3007 output.len() <= 512,
3008 "clusterless-forward warning is not bounded: {} bytes",
3009 output.len()
3010 );
3011 }
3012
3013 #[test]
3014 fn malformed_hsts_warning_redacts_cluster_id() {
3015 const CLUSTER_SECRET: &str = "MALFORMED_HSTS_CLUSTER_SECRET_SENTINEL";
3016
3017 let cluster_id = format!("{CLUSTER_SECRET}{}", "x".repeat(4096));
3018 let cluster_id_len = cluster_id.len();
3019 let output = crate::capture_test_logs(move || {
3020 let mut router = Router::new();
3021 let mut front = test_http_frontend();
3022 front.cluster_id = Some(cluster_id.into());
3023 front.hsts = Some(HstsConfig {
3024 enabled: Some(true),
3025 max_age: None,
3026 include_subdomains: Some(true),
3027 preload: Some(true),
3028 force_replace_backend: Some(false),
3029 });
3030 router
3031 .add_http_front(&front)
3032 .expect("malformed HSTS must preserve routing and omit the header");
3033 });
3034
3035 assert!(
3036 !output.contains(CLUSTER_SECRET),
3037 "malformed-HSTS warning leaked cluster id {CLUSTER_SECRET}"
3038 );
3039 assert!(
3040 output.contains(&format!("cluster_id_bytes=Some({cluster_id_len})")),
3041 "malformed-HSTS warning omitted the bounded cluster id length: {output}"
3042 );
3043 assert!(
3044 output.len() <= 768,
3045 "malformed-HSTS warning is not bounded: {} bytes",
3046 output.len()
3047 );
3048 }
3049
3050 #[test]
3051 fn router_errors_redact_frontend_rule_and_rewrite_fields() {
3052 const PATH_SECRET: &str = "ROUTER_ERROR_PATH_SECRET_SENTINEL";
3053 const HOSTNAME_SECRET: &str = "ROUTER_ERROR_HOSTNAME_SECRET_SENTINEL";
3054 const HOST_REWRITE_SECRET: &str = "ROUTER_ERROR_HOST_REWRITE_SECRET_SENTINEL";
3055 const PATH_REWRITE_SECRET: &str = "ROUTER_ERROR_PATH_REWRITE_SECRET_SENTINEL";
3056
3057 let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3058 let cases = [
3059 (
3060 "path_bytes",
3061 PATH_SECRET,
3062 long_value(PATH_SECRET).len(),
3063 RouterError::InvalidPathRule(long_value(PATH_SECRET)),
3064 ),
3065 (
3066 "hostname_bytes",
3067 HOSTNAME_SECRET,
3068 long_value(HOSTNAME_SECRET).len(),
3069 RouterError::InvalidDomain {
3070 hostname: long_value(HOSTNAME_SECRET),
3071 },
3072 ),
3073 (
3074 "rewrite_host_bytes",
3075 HOST_REWRITE_SECRET,
3076 long_value(HOST_REWRITE_SECRET).len(),
3077 RouterError::InvalidHostRewrite(long_value(HOST_REWRITE_SECRET)),
3078 ),
3079 (
3080 "rewrite_path_bytes",
3081 PATH_REWRITE_SECRET,
3082 long_value(PATH_REWRITE_SECRET).len(),
3083 RouterError::InvalidPathRewrite(long_value(PATH_REWRITE_SECRET)),
3084 ),
3085 ];
3086
3087 for (length_label, secret, value_len, error) in cases {
3088 for (format_label, output) in [
3089 ("Display", error.to_string()),
3090 ("Debug", format!("{error:?}")),
3091 ] {
3092 assert!(
3093 !output.contains(secret),
3094 "RouterError {format_label} leaked frontend marker {secret}"
3095 );
3096 let metadata = format!("{length_label}={value_len}");
3097 assert!(
3098 output.contains(&metadata),
3099 "RouterError {format_label} omitted bounded metadata {metadata}: {output}"
3100 );
3101 assert!(
3102 output.len() <= 256,
3103 "RouterError {format_label} output is not bounded: {} bytes",
3104 output.len()
3105 );
3106 }
3107 }
3108 }
3109
3110 #[test]
3111 fn route_miss_error_retains_inputs_but_bounds_textual_formatting() {
3112 const HOST_SECRET: &str = "ROUTE_MISS_HOST_SECRET_SENTINEL";
3113 const PATH_SECRET: &str = "ROUTE_MISS_PATH_SECRET_SENTINEL";
3114 const METHOD_SECRET: &str = "ROUTE_MISS_METHOD_SECRET_SENTINEL";
3115
3116 let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
3117 let host = long_value(HOST_SECRET);
3118 let path = long_value(PATH_SECRET);
3119 let method = Method::Custom(long_value(METHOD_SECRET));
3120 let error = match Router::new().lookup(&host, &path, &method) {
3121 Err(error) => error,
3122 Ok(_) => panic!("empty router must return a route miss"),
3123 };
3124
3125 match &error {
3126 RouterError::RouteNotFound {
3127 host: retained_host,
3128 path: retained_path,
3129 method: retained_method,
3130 } => {
3131 assert_eq!(retained_host, &host);
3132 assert_eq!(retained_path, &path);
3133 assert_eq!(retained_method, &method);
3134 }
3135 other => panic!("expected RouterError::RouteNotFound, got {other:?}"),
3136 }
3137
3138 for output in [error.to_string(), format!("{error:?}")] {
3139 for secret in [HOST_SECRET, PATH_SECRET, METHOD_SECRET] {
3140 assert!(
3141 !output.contains(secret),
3142 "route miss formatting leaked {secret}: {output}"
3143 );
3144 }
3145 for metadata in [
3146 format!("host_bytes={}", host.len()),
3147 format!("path_bytes={}", path.len()),
3148 format!("bytes={}", method.as_ref().len()),
3149 ] {
3150 assert!(
3151 output.contains(&metadata),
3152 "route miss formatting omitted {metadata}: {output}"
3153 );
3154 }
3155 assert!(
3156 output.len() <= 256,
3157 "route miss formatting is not bounded: {} bytes",
3158 output.len()
3159 );
3160 }
3161 }
3162
3163 #[test]
3164 fn render_hsts_max_age_only() {
3165 let cfg = HstsConfig {
3166 enabled: Some(true),
3167 max_age: Some(31_536_000),
3168 include_subdomains: None,
3169 preload: None,
3170 force_replace_backend: None,
3171 };
3172 assert_eq!(render_hsts(&cfg), Some("max-age=31536000".to_owned()));
3173 }
3174
3175 #[test]
3176 fn render_hsts_with_include_subdomains() {
3177 let cfg = HstsConfig {
3178 enabled: Some(true),
3179 max_age: Some(31_536_000),
3180 include_subdomains: Some(true),
3181 preload: None,
3182 force_replace_backend: None,
3183 };
3184 assert_eq!(
3185 render_hsts(&cfg),
3186 Some("max-age=31536000; includeSubDomains".to_owned())
3187 );
3188 }
3189
3190 #[test]
3191 fn render_hsts_with_preload_only() {
3192 let cfg = HstsConfig {
3193 enabled: Some(true),
3194 max_age: Some(63_072_000),
3195 include_subdomains: None,
3196 preload: Some(true),
3197 force_replace_backend: None,
3198 };
3199 assert_eq!(
3200 render_hsts(&cfg),
3201 Some("max-age=63072000; preload".to_owned())
3202 );
3203 }
3204
3205 #[test]
3206 fn render_hsts_full() {
3207 let cfg = HstsConfig {
3208 enabled: Some(true),
3209 max_age: Some(31_536_000),
3210 include_subdomains: Some(true),
3211 preload: Some(true),
3212 force_replace_backend: None,
3213 };
3214 assert_eq!(
3215 render_hsts(&cfg),
3216 Some("max-age=31536000; includeSubDomains; preload".to_owned())
3217 );
3218 }
3219
3220 #[test]
3221 fn render_hsts_kill_switch_max_age_zero() {
3222 let cfg = HstsConfig {
3223 enabled: Some(true),
3224 max_age: Some(0),
3225 include_subdomains: Some(true),
3226 preload: None,
3227 force_replace_backend: None,
3228 };
3229 // `max_age = 0` is the RFC 6797 §11.4 kill switch and renders
3230 // verbatim — UA receives it and stops treating the host as a
3231 // Known HSTS Host.
3232 assert_eq!(
3233 render_hsts(&cfg),
3234 Some("max-age=0; includeSubDomains".to_owned())
3235 );
3236 }
3237
3238 #[test]
3239 fn render_hsts_omitted_when_max_age_missing() {
3240 let cfg = HstsConfig {
3241 enabled: Some(true),
3242 max_age: None,
3243 include_subdomains: Some(true),
3244 preload: None,
3245 force_replace_backend: None,
3246 };
3247 // The TOML loader substitutes the default at config-load; if the
3248 // field reaches `render_hsts` as `None`, suppress emission so a
3249 // malformed wire frame can't accidentally render `max-age=`.
3250 assert_eq!(render_hsts(&cfg), None);
3251 }
3252
3253 #[test]
3254 fn rebuild_with_listener_hsts_replaces_existing_entry() {
3255 // An inheriting frontend whose listener-default HSTS changed
3256 // from 1y → 2y must end up with the 2y entry on its
3257 // headers_response, with no leftover 1y entry.
3258 let frontend = Frontend {
3259 cluster_id: Some("api".into()),
3260 redirect: RedirectPolicy::Forward,
3261 redirect_scheme: RedirectScheme::UseSame,
3262 redirect_template: None,
3263 capture_cap_host: 0,
3264 capture_cap_path: 0,
3265 rewrite_host: None,
3266 rewrite_path: None,
3267 rewrite_port: None,
3268 headers_request: Rc::new([]),
3269 headers_response: Rc::from(vec![
3270 HeaderEdit {
3271 key: Rc::from(&b"x-cache"[..]),
3272 val: Rc::from(&b"hit"[..]),
3273 mode: HeaderEditMode::Append,
3274 },
3275 HeaderEdit {
3276 key: Rc::from(&b"strict-transport-security"[..]),
3277 val: Rc::from(&b"max-age=31536000"[..]),
3278 mode: HeaderEditMode::SetIfAbsent,
3279 },
3280 ]),
3281 required_auth: false,
3282 tags: None,
3283 inherits_listener_hsts: true,
3284 };
3285 let new_hsts = HstsConfig {
3286 enabled: Some(true),
3287 max_age: Some(63_072_000),
3288 include_subdomains: Some(true),
3289 preload: None,
3290 force_replace_backend: None,
3291 };
3292 let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3293 let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3294
3295 let response: Vec<_> = rebuilt.headers_response.iter().collect();
3296 assert_eq!(response.len(), 2, "x-cache + new STS, no leftover STS");
3297 assert_eq!(&*response[0].key, b"x-cache");
3298 assert_eq!(&*response[1].key, b"strict-transport-security");
3299 assert_eq!(
3300 &*response[1].val,
3301 b"max-age=63072000; includeSubDomains".as_slice()
3302 );
3303 assert!(rebuilt.inherits_listener_hsts);
3304 }
3305
3306 #[test]
3307 fn rebuild_with_listener_hsts_strips_when_none() {
3308 // Listener-default HSTS removed → strip the existing STS edit
3309 // and add nothing. Operator response headers stay in place.
3310 let frontend = Frontend {
3311 cluster_id: Some("api".into()),
3312 redirect: RedirectPolicy::Forward,
3313 redirect_scheme: RedirectScheme::UseSame,
3314 redirect_template: None,
3315 capture_cap_host: 0,
3316 capture_cap_path: 0,
3317 rewrite_host: None,
3318 rewrite_path: None,
3319 rewrite_port: None,
3320 headers_request: Rc::new([]),
3321 headers_response: Rc::from(vec![
3322 HeaderEdit {
3323 key: Rc::from(&b"x-cache"[..]),
3324 val: Rc::from(&b"hit"[..]),
3325 mode: HeaderEditMode::Append,
3326 },
3327 HeaderEdit {
3328 key: Rc::from(&b"strict-transport-security"[..]),
3329 val: Rc::from(&b"max-age=31536000"[..]),
3330 mode: HeaderEditMode::SetIfAbsent,
3331 },
3332 ]),
3333 required_auth: false,
3334 tags: None,
3335 inherits_listener_hsts: true,
3336 };
3337 let new_edit = build_listener_hsts_edit(None);
3338 let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3339 let response: Vec<_> = rebuilt.headers_response.iter().collect();
3340 assert_eq!(response.len(), 1);
3341 assert_eq!(&*response[0].key, b"x-cache");
3342 }
3343
3344 #[test]
3345 fn rebuild_with_listener_hsts_disabled_strips() {
3346 // `enabled = Some(false)` is the explicit-disable signal; the
3347 // existing STS entry is dropped and no new one is added.
3348 let frontend = Frontend {
3349 cluster_id: Some("api".into()),
3350 redirect: RedirectPolicy::Forward,
3351 redirect_scheme: RedirectScheme::UseSame,
3352 redirect_template: None,
3353 capture_cap_host: 0,
3354 capture_cap_path: 0,
3355 rewrite_host: None,
3356 rewrite_path: None,
3357 rewrite_port: None,
3358 headers_request: Rc::new([]),
3359 headers_response: Rc::from(vec![HeaderEdit {
3360 key: Rc::from(&b"strict-transport-security"[..]),
3361 val: Rc::from(&b"max-age=31536000"[..]),
3362 mode: HeaderEditMode::SetIfAbsent,
3363 }]),
3364 required_auth: false,
3365 tags: None,
3366 inherits_listener_hsts: true,
3367 };
3368 let new_hsts = HstsConfig {
3369 enabled: Some(false),
3370 max_age: None,
3371 include_subdomains: None,
3372 preload: None,
3373 force_replace_backend: None,
3374 };
3375 let new_edit = build_listener_hsts_edit(Some(&new_hsts));
3376 let rebuilt = rebuild_with_listener_hsts(&frontend, new_edit.as_ref());
3377 assert_eq!(rebuilt.headers_response.len(), 0);
3378 }
3379
3380 #[test]
3381 fn refresh_inheriting_hsts_skips_explicit_overrides() {
3382 // Two frontends: one inheriting (gets refreshed), one explicit
3383 // override (must NOT change). `Router::refresh_inheriting_hsts`
3384 // returns the count of refreshed entries.
3385 use crate::router::pattern_trie::TrieNode;
3386 let mut router = Router {
3387 pre: Vec::new(),
3388 tree: TrieNode::root(),
3389 post: Vec::new(),
3390 };
3391 let inheriting = Frontend {
3392 cluster_id: Some("api".into()),
3393 redirect: RedirectPolicy::Forward,
3394 redirect_scheme: RedirectScheme::UseSame,
3395 redirect_template: None,
3396 capture_cap_host: 0,
3397 capture_cap_path: 0,
3398 rewrite_host: None,
3399 rewrite_path: None,
3400 rewrite_port: None,
3401 headers_request: Rc::new([]),
3402 headers_response: Rc::from(vec![HeaderEdit {
3403 key: Rc::from(&b"strict-transport-security"[..]),
3404 val: Rc::from(&b"max-age=31536000"[..]),
3405 mode: HeaderEditMode::SetIfAbsent,
3406 }]),
3407 required_auth: false,
3408 tags: None,
3409 inherits_listener_hsts: true,
3410 };
3411 let explicit = Frontend {
3412 cluster_id: Some("legacy".into()),
3413 redirect: RedirectPolicy::Forward,
3414 redirect_scheme: RedirectScheme::UseSame,
3415 redirect_template: None,
3416 capture_cap_host: 0,
3417 capture_cap_path: 0,
3418 rewrite_host: None,
3419 rewrite_path: None,
3420 rewrite_port: None,
3421 headers_request: Rc::new([]),
3422 headers_response: Rc::from(vec![HeaderEdit {
3423 key: Rc::from(&b"strict-transport-security"[..]),
3424 val: Rc::from(&b"max-age=300"[..]),
3425 mode: HeaderEditMode::SetIfAbsent,
3426 }]),
3427 required_auth: false,
3428 tags: None,
3429 inherits_listener_hsts: false,
3430 };
3431 router.pre.push((
3432 DomainRule::Any,
3433 PathRule::Prefix("/api".to_owned()),
3434 MethodRule::new(None),
3435 HeaderRules::default(),
3436 Route::Frontend(Rc::new(inheriting)),
3437 ));
3438 router.post.push((
3439 DomainRule::Any,
3440 PathRule::Prefix("/legacy".to_owned()),
3441 MethodRule::new(None),
3442 HeaderRules::default(),
3443 Route::Frontend(Rc::new(explicit)),
3444 ));
3445
3446 let new_hsts = HstsConfig {
3447 enabled: Some(true),
3448 max_age: Some(63_072_000),
3449 include_subdomains: Some(true),
3450 preload: None,
3451 force_replace_backend: None,
3452 };
3453 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3454 assert_eq!(count, 1, "only the inheriting frontend should refresh");
3455
3456 if let Route::Frontend(rc) = &router.pre[0].4 {
3457 let response: Vec<_> = rc.headers_response.iter().collect();
3458 assert_eq!(
3459 &*response.last().unwrap().val,
3460 b"max-age=63072000; includeSubDomains".as_slice(),
3461 "inheriting frontend's STS must reflect the new listener default"
3462 );
3463 } else {
3464 panic!("pre[0] should be Route::Frontend");
3465 }
3466 if let Route::Frontend(rc) = &router.post[0].4 {
3467 let response: Vec<_> = rc.headers_response.iter().collect();
3468 assert_eq!(
3469 &*response.last().unwrap().val,
3470 b"max-age=300".as_slice(),
3471 "explicit override must be preserved unchanged"
3472 );
3473 } else {
3474 panic!("post[0] should be Route::Frontend");
3475 }
3476 }
3477
3478 #[test]
3479 fn refresh_inheriting_hsts_promotes_clusterid_on_enable() {
3480 // The "no policy" frontend case observed on cleverapps.io shared
3481 // (91k+ frontends, 99 % stored as `Route::ClusterId` with no
3482 // policy fields). Before the fix, `refresh_inheriting_hsts`
3483 // walked only `Route::Frontend` entries and silently skipped
3484 // these — leaving HSTS unapplied across the entire fleet.
3485 // Now the lightweight route is promoted in place to a
3486 // `Route::Frontend` carrying just the HSTS edit; subsequent
3487 // patches refresh the promoted entry through the normal
3488 // `inherits_listener_hsts == true` path.
3489 use crate::router::pattern_trie::TrieNode;
3490 let mut router = Router {
3491 pre: Vec::new(),
3492 tree: TrieNode::root(),
3493 post: vec![(
3494 DomainRule::Any,
3495 PathRule::Prefix("/".to_owned()),
3496 MethodRule::new(None),
3497 HeaderRules::default(),
3498 Route::ClusterId("api".into()),
3499 )],
3500 };
3501
3502 let new_hsts = HstsConfig {
3503 enabled: Some(true),
3504 max_age: Some(31_536_000),
3505 include_subdomains: Some(true),
3506 preload: None,
3507 force_replace_backend: None,
3508 };
3509 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3510 assert_eq!(count, 1, "the ClusterId entry must be promoted + counted");
3511
3512 let Route::Frontend(rc) = &router.post[0].4 else {
3513 panic!("post[0] should now be Route::Frontend, not the original Route::ClusterId");
3514 };
3515 assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3516 assert_eq!(
3517 rc.redirect,
3518 RedirectPolicy::Forward,
3519 "promoted entry must keep Forward semantics so lookup yields the same backend"
3520 );
3521 assert!(
3522 rc.inherits_listener_hsts,
3523 "promoted entry must mark itself inheriting so the next patch refreshes it"
3524 );
3525 let response: Vec<_> = rc.headers_response.iter().collect();
3526 assert_eq!(
3527 response.len(),
3528 1,
3529 "promoted entry carries exactly one STS edit, no operator headers"
3530 );
3531 assert_eq!(&*response[0].key, b"strict-transport-security");
3532 assert_eq!(
3533 &*response[0].val,
3534 b"max-age=31536000; includeSubDomains".as_slice()
3535 );
3536 }
3537
3538 #[test]
3539 fn refresh_inheriting_hsts_promotes_deny_on_enable() {
3540 // RFC 6797 §8.1: HSTS applies to ALL HTTPS responses, including
3541 // proxy-generated 401s. A `Route::Deny` with no policy field at
3542 // add-time would, before the fix, never get HSTS injected onto
3543 // the 401 default answer even when the listener default
3544 // declared HSTS.
3545 use crate::router::pattern_trie::TrieNode;
3546 let mut router = Router {
3547 pre: Vec::new(),
3548 tree: TrieNode::root(),
3549 post: vec![(
3550 DomainRule::Any,
3551 PathRule::Prefix("/forbidden".to_owned()),
3552 MethodRule::new(None),
3553 HeaderRules::default(),
3554 Route::Deny,
3555 )],
3556 };
3557
3558 let new_hsts = HstsConfig {
3559 enabled: Some(true),
3560 max_age: Some(31_536_000),
3561 include_subdomains: None,
3562 preload: None,
3563 force_replace_backend: None,
3564 };
3565 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3566 assert_eq!(count, 1);
3567
3568 let Route::Frontend(rc) = &router.post[0].4 else {
3569 panic!("post[0] should now be Route::Frontend, not the original Route::Deny");
3570 };
3571 assert_eq!(rc.cluster_id, None, "promoted Deny stays clusterless");
3572 assert_eq!(
3573 rc.redirect,
3574 RedirectPolicy::Unauthorized,
3575 "promoted Deny must keep Unauthorized so lookup yields a 401"
3576 );
3577 assert!(rc.inherits_listener_hsts);
3578 let response: Vec<_> = rc.headers_response.iter().collect();
3579 assert_eq!(response.len(), 1);
3580 assert_eq!(&*response[0].key, b"strict-transport-security");
3581 assert_eq!(&*response[0].val, b"max-age=31536000".as_slice());
3582 }
3583
3584 #[test]
3585 fn refresh_inheriting_hsts_skips_lightweight_on_disable() {
3586 // No HSTS to emit → no allocation of a Route::Frontend just to
3587 // hold an empty headers_response. The lightweight route is
3588 // preserved as-is. Three sub-cases cover the disable surface:
3589 // - new_hsts == None (operator omitted the field — preserve current… but
3590 // this function is called only when the patch DID carry a value, so
3591 // None here represents "block was not present"; lightweight stays).
3592 // - new_hsts == Some(enabled = Some(false)) (explicit kill switch).
3593 // - new_hsts == Some(enabled = Some(true), max_age = None) (malformed
3594 // enable — render_hsts returns None, defense-in-depth gate).
3595 use crate::router::pattern_trie::TrieNode;
3596 let make_router = || Router {
3597 pre: vec![(
3598 DomainRule::Any,
3599 PathRule::Prefix("/".to_owned()),
3600 MethodRule::new(None),
3601 HeaderRules::default(),
3602 Route::ClusterId("api".into()),
3603 )],
3604 tree: TrieNode::root(),
3605 post: vec![(
3606 DomainRule::Any,
3607 PathRule::Prefix("/forbidden".to_owned()),
3608 MethodRule::new(None),
3609 HeaderRules::default(),
3610 Route::Deny,
3611 )],
3612 };
3613
3614 for (label, hsts) in [
3615 ("none", None),
3616 (
3617 "disabled",
3618 Some(HstsConfig {
3619 enabled: Some(false),
3620 max_age: None,
3621 include_subdomains: None,
3622 preload: None,
3623 force_replace_backend: None,
3624 }),
3625 ),
3626 (
3627 "enabled-without-max-age",
3628 Some(HstsConfig {
3629 enabled: Some(true),
3630 max_age: None,
3631 include_subdomains: None,
3632 preload: None,
3633 force_replace_backend: None,
3634 }),
3635 ),
3636 ] {
3637 let mut router = make_router();
3638 let count = router.refresh_inheriting_hsts(hsts.as_ref());
3639 assert_eq!(count, 0, "no promotion expected for {label}");
3640 assert!(
3641 matches!(router.pre[0].4, Route::ClusterId(_)),
3642 "{label}: ClusterId must stay lightweight"
3643 );
3644 assert!(
3645 matches!(router.post[0].4, Route::Deny),
3646 "{label}: Deny must stay lightweight"
3647 );
3648 }
3649 }
3650
3651 #[test]
3652 fn refresh_inheriting_hsts_promoted_entry_refreshes_on_subsequent_patches() {
3653 // First patch promotes ClusterId → Route::Frontend with HSTS.
3654 // Second patch with a different max-age must refresh the
3655 // promoted entry through the normal path-1 branch (no double
3656 // STS edit, no second promotion of an already-promoted entry).
3657 use crate::router::pattern_trie::TrieNode;
3658 let mut router = Router {
3659 pre: Vec::new(),
3660 tree: TrieNode::root(),
3661 post: vec![(
3662 DomainRule::Any,
3663 PathRule::Prefix("/".to_owned()),
3664 MethodRule::new(None),
3665 HeaderRules::default(),
3666 Route::ClusterId("api".into()),
3667 )],
3668 };
3669
3670 let first_patch = HstsConfig {
3671 enabled: Some(true),
3672 max_age: Some(31_536_000),
3673 include_subdomains: None,
3674 preload: None,
3675 force_replace_backend: None,
3676 };
3677 assert_eq!(router.refresh_inheriting_hsts(Some(&first_patch)), 1);
3678
3679 let second_patch = HstsConfig {
3680 enabled: Some(true),
3681 max_age: Some(63_072_000),
3682 include_subdomains: Some(true),
3683 preload: None,
3684 force_replace_backend: None,
3685 };
3686 assert_eq!(
3687 router.refresh_inheriting_hsts(Some(&second_patch)),
3688 1,
3689 "the previously promoted entry must be re-counted via the path-1 branch"
3690 );
3691
3692 let Route::Frontend(rc) = &router.post[0].4 else {
3693 panic!("post[0] should still be Route::Frontend after the second patch");
3694 };
3695 let response: Vec<_> = rc.headers_response.iter().collect();
3696 assert_eq!(
3697 response.len(),
3698 1,
3699 "second patch must REPLACE the existing STS edit, not append a duplicate"
3700 );
3701 assert_eq!(
3702 &*response[0].val,
3703 b"max-age=63072000; includeSubDomains".as_slice()
3704 );
3705 }
3706
3707 #[test]
3708 fn refresh_inheriting_hsts_promoted_entry_loses_hsts_on_disable_patch() {
3709 // After a promotion, a disable patch must strip the STS edit
3710 // through the path-1 branch. The Route::Frontend wrapper stays
3711 // (we don't demote back to Route::ClusterId — the small per-
3712 // request overhead of running through `from_frontend` instead
3713 // of the short-circuit is acceptable, and demotion would
3714 // require carrying additional state).
3715 use crate::router::pattern_trie::TrieNode;
3716 let mut router = Router {
3717 pre: vec![(
3718 DomainRule::Any,
3719 PathRule::Prefix("/".to_owned()),
3720 MethodRule::new(None),
3721 HeaderRules::default(),
3722 Route::ClusterId("api".into()),
3723 )],
3724 tree: TrieNode::root(),
3725 post: Vec::new(),
3726 };
3727
3728 let enable = HstsConfig {
3729 enabled: Some(true),
3730 max_age: Some(31_536_000),
3731 include_subdomains: None,
3732 preload: None,
3733 force_replace_backend: None,
3734 };
3735 assert_eq!(router.refresh_inheriting_hsts(Some(&enable)), 1);
3736
3737 let disable = HstsConfig {
3738 enabled: Some(false),
3739 max_age: None,
3740 include_subdomains: None,
3741 preload: None,
3742 force_replace_backend: None,
3743 };
3744 assert_eq!(
3745 router.refresh_inheriting_hsts(Some(&disable)),
3746 1,
3747 "the promoted entry must still be touched on disable to strip its STS edit"
3748 );
3749
3750 let Route::Frontend(rc) = &router.pre[0].4 else {
3751 panic!("pre[0] should still be Route::Frontend (no demotion)");
3752 };
3753 assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3754 assert_eq!(
3755 rc.headers_response.len(),
3756 0,
3757 "disable patch must strip the STS edit from the promoted entry"
3758 );
3759 }
3760
3761 #[test]
3762 fn refresh_inheriting_hsts_promotes_clusterid_in_trie_on_enable() {
3763 // Trie-leaf coverage for path 2: the existing five tests
3764 // exercise the `pre`/`post` Vecs only, but the same `visit`
3765 // closure runs for tree leaves through
3766 // `tree.for_each_value_mut`. Assert that a `Route::ClusterId`
3767 // sitting in the trie is promoted in place on an enable
3768 // patch, with routing semantics preserved.
3769 use crate::router::pattern_trie::TrieNode;
3770 let mut router = Router {
3771 pre: Vec::new(),
3772 tree: TrieNode::root(),
3773 post: Vec::new(),
3774 };
3775 let path_rule = PathRule::Prefix("/".to_owned());
3776 let method_rule = MethodRule::new(None);
3777 assert!(router.add_tree_rule(
3778 b"example.com",
3779 &path_rule,
3780 &method_rule,
3781 &Route::ClusterId("api".into()),
3782 ));
3783
3784 let new_hsts = HstsConfig {
3785 enabled: Some(true),
3786 max_age: Some(31_536_000),
3787 include_subdomains: Some(true),
3788 preload: None,
3789 force_replace_backend: None,
3790 };
3791 let count = router.refresh_inheriting_hsts(Some(&new_hsts));
3792 assert_eq!(
3793 count, 1,
3794 "trie-resident ClusterId must be promoted + counted"
3795 );
3796
3797 let (_, paths) = router
3798 .tree
3799 .domain_lookup_mut(b"example.com", false)
3800 .expect("trie leaf still present after refresh");
3801 assert_eq!(paths.len(), 1);
3802 let Route::Frontend(rc) = &paths[0].3 else {
3803 panic!("trie leaf should now be Route::Frontend, not Route::ClusterId");
3804 };
3805 assert_eq!(rc.cluster_id.as_deref(), Some("api"));
3806 assert_eq!(rc.redirect, RedirectPolicy::Forward);
3807 assert!(rc.inherits_listener_hsts);
3808 let response: Vec<_> = rc.headers_response.iter().collect();
3809 assert_eq!(response.len(), 1);
3810 assert_eq!(&*response[0].key, b"strict-transport-security");
3811 assert_eq!(
3812 &*response[0].val,
3813 b"max-age=31536000; includeSubDomains".as_slice()
3814 );
3815 }
3816
3817 #[test]
3818 fn convert_regex() {
3819 // Compiled regexes are anchored with `\A` … `\z` so `Regex::is_match`
3820 // (unanchored by default) only succeeds on a full-host match, and each
3821 // slash-delimited regex segment is wrapped in a non-capturing group so
3822 // an alternation inside it cannot split those anchors between its
3823 // branches (sozu#1356). A literal segment is not a pattern and is not
3824 // grouped.
3825 assert_eq!(
3826 convert_regex_domain_rule("www.example.com")
3827 .unwrap()
3828 .as_str(),
3829 "\\Awww\\.example\\.com\\z"
3830 );
3831 // A `*` label reaches this function only when the hostname ALSO
3832 // carries a `/` segment, because `DomainRule::from_str` tests
3833 // `contains('/')` first — these two rows exercise the function
3834 // directly. `*` is a LITERAL label here, not a wildcard, and is
3835 // escaped as one. Unescaped it was a regex quantifier over the
3836 // preceding atom: `\A*` repeats a zero-width assertion, so it
3837 // matched empty at any offset and left the whole pattern unanchored
3838 // at its start.
3839 assert_eq!(
3840 convert_regex_domain_rule("*.example.com").unwrap().as_str(),
3841 "\\A\\*\\.example\\.com\\z"
3842 );
3843 assert_eq!(
3844 convert_regex_domain_rule("test.*.example.com")
3845 .unwrap()
3846 .as_str(),
3847 "\\Atest\\.\\*\\.example\\.com\\z"
3848 );
3849 assert_eq!(
3850 convert_regex_domain_rule("css./cdn[a-z0-9]+/.example.com")
3851 .unwrap()
3852 .as_str(),
3853 "\\Acss\\.(?:cdn[a-z0-9]+)\\.example\\.com\\z"
3854 );
3855
3856 assert_eq!(
3857 convert_regex_domain_rule("css./cdn[a-z0-9]+.example.com"),
3858 None
3859 );
3860 assert_eq!(
3861 convert_regex_domain_rule("css./cdn[a-z0-9]+/a.example.com"),
3862 None
3863 );
3864 }
3865
3866 /// Compiled regex rules must reject suffix / prefix matches. Without
3867 /// `\A` / `\z` anchors, `Regex::is_match` treats the pattern as
3868 /// "match anywhere in the haystack", letting `attacker.example.com.evil.org`
3869 /// reach a frontend that only intends to serve `example.com`.
3870 #[test]
3871 fn regex_domain_rule_rejects_suffix_and_prefix() {
3872 let rule: DomainRule = "/example\\.com/".parse().unwrap();
3873 assert!(rule.matches(b"example.com"));
3874 assert!(!rule.matches(b"attacker.example.com"));
3875 assert!(!rule.matches(b"example.com.evil.org"));
3876 assert!(!rule.matches(b"prefixexample.com"));
3877 assert!(!rule.matches(b"example.commercial"));
3878 }
3879
3880 /// A multi-segment regex hostname (alternating regex and literal
3881 /// subdomains) must keep each segment confined: only the first `/`
3882 /// after the opening delimiter closes a regex segment. A missing
3883 /// `break` collapsed every later `/` into the same segment, swallowing
3884 /// the literal `.` separators between them.
3885 #[test]
3886 fn regex_domain_rule_multi_segment_segments_are_isolated() {
3887 let pattern = convert_regex_domain_rule("/seg1/.foo./seg2/.com")
3888 .expect("multi-segment regex hostname must compile");
3889 assert_eq!(pattern.as_str(), "\\A(?:seg1)\\.foo\\.(?:seg2)\\.com\\z");
3890 }
3891
3892 #[test]
3893 fn parse_domain_rule() {
3894 assert_eq!("*".parse::<DomainRule>().unwrap(), DomainRule::Any);
3895 assert_eq!(
3896 "www.example.com".parse::<DomainRule>().unwrap(),
3897 DomainRule::Exact("www.example.com".to_string())
3898 );
3899 assert_eq!(
3900 "*.example.com".parse::<DomainRule>().unwrap(),
3901 DomainRule::Wildcard("*.example.com".to_string())
3902 );
3903 assert_eq!("test.*.example.com".parse::<DomainRule>(), Err(()));
3904 assert_eq!(
3905 "/cdn[0-9]+/.example.com".parse::<DomainRule>().unwrap(),
3906 DomainRule::Regex(Regex::new("\\A(?:cdn[0-9]+)\\.example\\.com\\z").unwrap())
3907 );
3908 }
3909
3910 #[test]
3911 fn match_domain_rule() {
3912 assert!(DomainRule::Any.matches("www.example.com".as_bytes()));
3913 assert!(
3914 DomainRule::Exact("www.example.com".to_string()).matches("www.example.com".as_bytes())
3915 );
3916 assert!(
3917 DomainRule::Wildcard("*.example.com".to_string()).matches("www.example.com".as_bytes())
3918 );
3919 assert!(
3920 !DomainRule::Wildcard("*.example.com".to_string())
3921 .matches("test.www.example.com".as_bytes())
3922 );
3923 assert!(
3924 "/cdn[0-9]+/.example.com"
3925 .parse::<DomainRule>()
3926 .unwrap()
3927 .matches("cdn1.example.com".as_bytes())
3928 );
3929 assert!(
3930 !"/cdn[0-9]+/.example.com"
3931 .parse::<DomainRule>()
3932 .unwrap()
3933 .matches("www.example.com".as_bytes())
3934 );
3935 assert!(
3936 !"/cdn[0-9]+/.example.com"
3937 .parse::<DomainRule>()
3938 .unwrap()
3939 .matches("cdn10.exampleAcom".as_bytes())
3940 );
3941 }
3942
3943 #[test]
3944 fn match_domain_rule_wildcard_short_hostname_does_not_panic() {
3945 let rule = DomainRule::Wildcard("*.foo.example.com".to_string());
3946
3947 // Regression for issue #1223: an empty hostname must not panic and must not match.
3948 assert!(!rule.matches(b""));
3949
3950 // Hostname strictly shorter than the suffix must not panic and must not match.
3951 assert!(!rule.matches(b"a.b"));
3952 assert!(!rule.matches(b"x"));
3953
3954 // Boundary: hostname equal to the suffix (s without the leading '*') has an empty
3955 // leftmost label. RFC 1035 §3.1 forbids empty labels, so reject.
3956 assert!(!rule.matches(b".foo.example.com"));
3957
3958 // Multi-label leftmost prefix (existing intent — single-label only).
3959 assert!(!rule.matches(b"y.x.foo.example.com"));
3960
3961 // Single-label leftmost prefix — must still match (this is the happy case the
3962 // pre-existing match_domain_rule already covers, repeated here for symmetry).
3963 assert!(rule.matches(b"x.foo.example.com"));
3964 }
3965
3966 #[test]
3967 fn router_lookup_wildcard_pre_rule_short_hostname_does_not_panic() {
3968 let mut router = Router::new();
3969
3970 // Wildcard in a pre-rule routes through DomainRule::Wildcard::matches
3971 // (not the trie). This is the path that panicked on issue #1223.
3972 assert!(router.add_pre_rule(
3973 &"*.foo.example.com".parse::<DomainRule>().unwrap(),
3974 &PathRule::Prefix("/".to_string()),
3975 &MethodRule::new(Some("GET".to_string())),
3976 &Route::ClusterId("wildcard".into()),
3977 ));
3978
3979 let method = Method::new(&b"GET"[..]);
3980
3981 // Issue #1223: short hostnames must not panic Router::lookup.
3982 assert!(router.lookup("", "/", &method).is_err());
3983 assert!(router.lookup("x", "/", &method).is_err());
3984 assert!(router.lookup("a.b", "/", &method).is_err());
3985
3986 // Boundary: hostname equal to the suffix has an empty leftmost label.
3987 assert!(router.lookup(".foo.example.com", "/", &method).is_err());
3988
3989 // Happy case: single-label leftmost matches via the pre-rule path.
3990 assert_eq!(
3991 router.lookup("x.foo.example.com", "/", &method),
3992 Ok(RouteResult::forward("wildcard".into()))
3993 );
3994 }
3995
3996 #[test]
3997 fn match_path_rule() {
3998 assert!(PathRule::Prefix("".to_string()).matches("/".as_bytes()) != PathRuleResult::None);
3999 assert!(
4000 PathRule::Prefix("".to_string()).matches("/hello".as_bytes()) != PathRuleResult::None
4001 );
4002 assert!(
4003 PathRule::Prefix("/hello".to_string()).matches("/hello".as_bytes())
4004 != PathRuleResult::None
4005 );
4006 assert!(
4007 PathRule::Prefix("/hello".to_string()).matches("/hello/world".as_bytes())
4008 != PathRuleResult::None
4009 );
4010 assert!(
4011 PathRule::Prefix("/hello".to_string()).matches("/".as_bytes()) == PathRuleResult::None
4012 );
4013 }
4014
4015 /// [io]
4016 /// \
4017 /// [sozu]
4018 /// \
4019 /// [*] <- this wildcard has multiple children
4020 /// / \
4021 /// (base) (api)
4022 #[test]
4023 fn multiple_children_on_a_wildcard() {
4024 let mut router = Router::new();
4025
4026 assert!(router.add_tree_rule(
4027 b"*.sozu.io",
4028 &PathRule::Prefix("".to_string()),
4029 &MethodRule::new(Some("GET".to_string())),
4030 &Route::ClusterId("base".into())
4031 ));
4032 println!("{:#?}", router.tree);
4033 assert_eq!(
4034 router.lookup("www.sozu.io", "/api", &Method::Get),
4035 Ok(RouteResult::forward("base".into()))
4036 );
4037 assert!(router.add_tree_rule(
4038 b"*.sozu.io",
4039 &PathRule::Prefix("/api".to_string()),
4040 &MethodRule::new(Some("GET".to_string())),
4041 &Route::ClusterId("api".into())
4042 ));
4043 println!("{:#?}", router.tree);
4044 assert_eq!(
4045 router.lookup("www.sozu.io", "/ap", &Method::Get),
4046 Ok(RouteResult::forward("base".into()))
4047 );
4048 assert_eq!(
4049 router.lookup("www.sozu.io", "/api", &Method::Get),
4050 Ok(RouteResult::forward("api".into()))
4051 );
4052 }
4053
4054 /// [io]
4055 /// \
4056 /// [sozu] <- this node has multiple children including a wildcard
4057 /// / \
4058 /// (api) [*] <- this wildcard has multiple children
4059 /// \
4060 /// (base)
4061 #[test]
4062 fn multiple_children_including_one_with_wildcard() {
4063 let mut router = Router::new();
4064
4065 assert!(router.add_tree_rule(
4066 b"*.sozu.io",
4067 &PathRule::Prefix("".to_string()),
4068 &MethodRule::new(Some("GET".to_string())),
4069 &Route::ClusterId("base".into())
4070 ));
4071 println!("{:#?}", router.tree);
4072 assert_eq!(
4073 router.lookup("www.sozu.io", "/api", &Method::Get),
4074 Ok(RouteResult::forward("base".into()))
4075 );
4076 assert!(router.add_tree_rule(
4077 b"api.sozu.io",
4078 &PathRule::Prefix("".to_string()),
4079 &MethodRule::new(Some("GET".to_string())),
4080 &Route::ClusterId("api".into())
4081 ));
4082 println!("{:#?}", router.tree);
4083 assert_eq!(
4084 router.lookup("www.sozu.io", "/api", &Method::Get),
4085 Ok(RouteResult::forward("base".into()))
4086 );
4087 assert_eq!(
4088 router.lookup("api.sozu.io", "/api", &Method::Get),
4089 Ok(RouteResult::forward("api".into()))
4090 );
4091 }
4092
4093 /// A malformed hostname arriving from the control plane
4094 /// (`AddHttpFrontend` over the command socket, or a `LoadState`
4095 /// replay) must be REJECTED, never panic the worker. `TrieNode::insert`
4096 /// used to `assert_ne!` on `InsertResult::Failed`, so a frontend whose
4097 /// hostname ended in `/` killed every worker the master fanned the
4098 /// request out to -- and killed them again on each restart replay.
4099 #[test]
4100 fn add_tree_rule_rejects_malformed_hostnames_without_panicking() {
4101 // Every shape here reached `InsertResult::Failed` (or an empty
4102 // `partial_key`) inside `insert_recursive`.
4103 for hostname in [
4104 &b"example.com/"[..],
4105 b"www.example.com/",
4106 b"foo/",
4107 b"a/*/",
4108 b"/",
4109 b"///",
4110 b"abc/[0-9]+/.example.com",
4111 b"/[/.example.com",
4112 // Named as rejected in `doc/configure.md`'s regex-hostname
4113 // section: a leading `.` before a regex segment is an empty label.
4114 b"./test[0-9]/.example.com",
4115 b".example.com",
4116 b".a.b",
4117 b"..",
4118 ] {
4119 let mut router = Router::new();
4120 assert!(
4121 !router.add_tree_rule(
4122 hostname,
4123 &PathRule::Prefix("/".to_string()),
4124 &MethodRule::new(Some("GET".to_string())),
4125 &Route::ClusterId("cluster".into()),
4126 ),
4127 "{:?} must be rejected, not inserted",
4128 String::from_utf8_lossy(hostname),
4129 );
4130 // A rejected add must leave NO trace: the route table is
4131 // exactly as empty as it was before the attempt.
4132 assert!(
4133 router.tree.is_empty(),
4134 "{:?} was rejected but still mutated the route table",
4135 String::from_utf8_lossy(hostname),
4136 );
4137 }
4138 }
4139
4140 /// The same rejection, one layer up: `add_http_front` must surface a
4141 /// `RouterError::AddRoute` so the worker answers the master with a
4142 /// failure instead of dying.
4143 #[test]
4144 fn add_http_front_surfaces_a_malformed_hostname_as_an_error() {
4145 let mut router = Router::new();
4146 let front = HttpFrontend {
4147 hostname: "example.com/".to_owned(),
4148 ..test_http_frontend()
4149 };
4150 assert!(matches!(
4151 router.add_http_front(&front),
4152 Err(RouterError::AddRoute(_))
4153 ));
4154 assert!(router.tree.is_empty());
4155
4156 // A hostname whose last segment is followed by a bare trailing `.`
4157 // is rejected one layer earlier still, by the unconditional
4158 // `DomainRule` parse -- which used to walk out of bounds on it
4159 // (see `domain_rule_rejects_a_trailing_dot_after_a_regex_segment`).
4160 for hostname in ["/a/.", "a/b/.", "x./y/."] {
4161 let mut router = Router::new();
4162 let front = HttpFrontend {
4163 hostname: (*hostname).to_owned(),
4164 ..test_http_frontend()
4165 };
4166 assert!(
4167 matches!(
4168 router.add_http_front(&front),
4169 Err(RouterError::InvalidDomain { .. })
4170 ),
4171 "{hostname:?} must be rejected as an invalid domain, not panic",
4172 );
4173 assert!(router.tree.is_empty());
4174 }
4175 }
4176
4177 /// `convert_regex_domain_rule` used to walk one byte past the end of a
4178 /// hostname whose last segment is followed by a bare trailing `.`
4179 /// (`/a/.`, `a/b/.`, `x./y/.`): the `.` arm of the loop tail advances
4180 /// `index` to `s.len()` and the next iteration indexed `s[index]` out
4181 /// of bounds -- a release panic (slice bounds checks never compile
4182 /// out) reachable from the control plane through the unconditional
4183 /// `DomainRule` parse in `add_http_front`, sidestepping every trie
4184 /// guard.
4185 #[test]
4186 fn domain_rule_rejects_a_trailing_dot_after_a_regex_segment() {
4187 for hostname in ["/a/.", "a/b/.", "/[/.", "x./y/."] {
4188 assert!(
4189 hostname.parse::<DomainRule>().is_err(),
4190 "{hostname:?} must be rejected, not panic",
4191 );
4192 }
4193 // The guard must not disturb the legitimate `.`-anchored
4194 // regex-segment grammar.
4195 assert!("abc./[0-9]+/.example.com".parse::<DomainRule>().is_ok());
4196 }
4197
4198 /// The trie recurses once per label and the regex-segment grammar
4199 /// compiles control-plane-supplied patterns, so both add and remove
4200 /// bound the hostname to [`MAX_HOSTNAME_LENGTH`] before parsing
4201 /// anything: a ~100k-label hostname otherwise aborts the worker with
4202 /// an uncatchable stack overflow, and regex compilation time grows
4203 /// with the pattern (a 2 MiB hostname stalls the single-threaded
4204 /// worker for hundreds of milliseconds before rejection).
4205 #[test]
4206 fn add_http_front_rejects_an_oversized_hostname() {
4207 // At the bound: accepted.
4208 let mut router = Router::new();
4209 let front = HttpFrontend {
4210 hostname: "a".repeat(MAX_HOSTNAME_LENGTH),
4211 ..test_http_frontend()
4212 };
4213 assert!(router.add_http_front(&front).is_ok());
4214
4215 // One byte over: rejected on add AND on remove, before any parse.
4216 let mut router = Router::new();
4217 let front = HttpFrontend {
4218 hostname: "a".repeat(MAX_HOSTNAME_LENGTH + 1),
4219 ..test_http_frontend()
4220 };
4221 assert!(matches!(
4222 router.add_http_front(&front),
4223 Err(RouterError::InvalidDomain { .. })
4224 ));
4225 assert!(matches!(
4226 router.remove_http_front(&front),
4227 Err(RouterError::InvalidDomain { .. })
4228 ));
4229 assert!(router.tree.is_empty());
4230
4231 // A label-count bomb is caught by the same byte bound.
4232 let front = HttpFrontend {
4233 hostname: "a.".repeat(MAX_HOSTNAME_LENGTH),
4234 ..test_http_frontend()
4235 };
4236 assert!(matches!(
4237 router.add_http_front(&front),
4238 Err(RouterError::InvalidDomain { .. })
4239 ));
4240 }
4241
4242 #[test]
4243 fn router_insert_remove_through_regex() {
4244 let mut router = Router::new();
4245
4246 assert!(router.add_tree_rule(
4247 b"www./.*/.io",
4248 &PathRule::Prefix("".to_string()),
4249 &MethodRule::new(Some("GET".to_string())),
4250 &Route::ClusterId("base".into())
4251 ));
4252 println!("{:#?}", router.tree);
4253 assert!(router.add_tree_rule(
4254 b"www.doc./.*/.io",
4255 &PathRule::Prefix("".to_string()),
4256 &MethodRule::new(Some("GET".to_string())),
4257 &Route::ClusterId("doc".into())
4258 ));
4259 println!("{:#?}", router.tree);
4260 assert_eq!(
4261 router.lookup("www.sozu.io", "/", &Method::Get),
4262 Ok(RouteResult::forward("base".into()))
4263 );
4264 assert_eq!(
4265 router.lookup("www.doc.sozu.io", "/", &Method::Get),
4266 Ok(RouteResult::forward("doc".into()))
4267 );
4268 assert!(router.remove_tree_rule(
4269 b"www./.*/.io",
4270 &PathRule::Prefix("".to_string()),
4271 &MethodRule::new(Some("GET".to_string()))
4272 ));
4273 println!("{:#?}", router.tree);
4274 assert!(router.lookup("www.sozu.io", "/", &Method::Get).is_err());
4275 assert_eq!(
4276 router.lookup("www.doc.sozu.io", "/", &Method::Get),
4277 Ok(RouteResult::forward("doc".into()))
4278 );
4279 }
4280
4281 #[test]
4282 fn match_router() {
4283 let mut router = Router::new();
4284
4285 assert!(router.add_pre_rule(
4286 &"*".parse::<DomainRule>().unwrap(),
4287 &PathRule::Prefix("/.well-known/acme-challenge".to_string()),
4288 &MethodRule::new(Some("GET".to_string())),
4289 &Route::ClusterId("acme".into())
4290 ));
4291 assert!(router.add_tree_rule(
4292 "www.example.com".as_bytes(),
4293 &PathRule::Prefix("/".to_string()),
4294 &MethodRule::new(Some("GET".to_string())),
4295 &Route::ClusterId("example".into())
4296 ));
4297 assert!(router.add_tree_rule(
4298 "*.test.example.com".as_bytes(),
4299 &PathRule::Regex(Regex::new("/hello[A-Z]+/").unwrap()),
4300 &MethodRule::new(Some("GET".to_string())),
4301 &Route::ClusterId("examplewildcard".into())
4302 ));
4303 assert!(router.add_tree_rule(
4304 "/test[0-9]/.example.com".as_bytes(),
4305 &PathRule::Prefix("/".to_string()),
4306 &MethodRule::new(Some("GET".to_string())),
4307 &Route::ClusterId("exampleregex".into())
4308 ));
4309
4310 assert_eq!(
4311 router.lookup("www.example.com", "/helloA", &Method::new(&b"GET"[..])),
4312 Ok(RouteResult::forward("example".into()))
4313 );
4314 assert_eq!(
4315 router.lookup(
4316 "www.example.com",
4317 "/.well-known/acme-challenge",
4318 &Method::new(&b"GET"[..])
4319 ),
4320 Ok(RouteResult::forward("acme".into()))
4321 );
4322 assert!(
4323 router
4324 .lookup("www.test.example.com", "/", &Method::new(&b"GET"[..]))
4325 .is_err()
4326 );
4327 assert_eq!(
4328 router.lookup(
4329 "www.test.example.com",
4330 "/helloAB/",
4331 &Method::new(&b"GET"[..])
4332 ),
4333 Ok(RouteResult::forward("examplewildcard".into()))
4334 );
4335 assert_eq!(
4336 router.lookup("test1.example.com", "/helloAB/", &Method::new(&b"GET"[..])),
4337 Ok(RouteResult::forward("exampleregex".into()))
4338 );
4339 }
4340
4341 #[test]
4342 fn has_hostname_checks_tree_pre_and_post() {
4343 let mut router = Router::new();
4344
4345 // Empty router has no hostnames
4346 assert!(!router.has_hostname("www.example.com"));
4347
4348 // Add a tree rule
4349 assert!(router.add_tree_rule(
4350 b"www.example.com",
4351 &PathRule::Prefix("/".to_string()),
4352 &MethodRule::new(Some("GET".to_string())),
4353 &Route::ClusterId("cluster1".into())
4354 ));
4355 assert!(router.has_hostname("www.example.com"));
4356 assert!(!router.has_hostname("api.example.com"));
4357
4358 // Remove the tree rule — hostname should disappear
4359 assert!(router.remove_tree_rule(
4360 b"www.example.com",
4361 &PathRule::Prefix("/".to_string()),
4362 &MethodRule::new(Some("GET".to_string()))
4363 ));
4364 assert!(!router.has_hostname("www.example.com"));
4365
4366 // Add a pre rule with an exact domain
4367 assert!(router.add_pre_rule(
4368 &DomainRule::Exact("api.example.com".to_string()),
4369 &PathRule::Prefix("/".to_string()),
4370 &MethodRule::new(None),
4371 &Route::ClusterId("cluster2".into())
4372 ));
4373 assert!(router.has_hostname("api.example.com"));
4374 assert!(!router.has_hostname("www.example.com"));
4375
4376 // Add a post rule
4377 assert!(router.add_post_rule(
4378 &DomainRule::Exact("cdn.example.com".to_string()),
4379 &PathRule::Prefix("/".to_string()),
4380 &MethodRule::new(None),
4381 &Route::ClusterId("cluster3".into())
4382 ));
4383 assert!(router.has_hostname("cdn.example.com"));
4384
4385 // Remove pre rule, post rule should still be detected
4386 assert!(router.remove_pre_rule(
4387 &DomainRule::Exact("api.example.com".to_string()),
4388 &PathRule::Prefix("/".to_string()),
4389 &MethodRule::new(None),
4390 ));
4391 assert!(!router.has_hostname("api.example.com"));
4392 assert!(router.has_hostname("cdn.example.com"));
4393 }
4394
4395 #[test]
4396 fn has_hostname_false_after_last_route_removed() {
4397 let mut router = Router::new();
4398
4399 // Add two routes for the same hostname with different paths
4400 assert!(router.add_tree_rule(
4401 b"www.example.com",
4402 &PathRule::Prefix("/".to_string()),
4403 &MethodRule::new(Some("GET".to_string())),
4404 &Route::ClusterId("cluster1".into())
4405 ));
4406 assert!(router.add_tree_rule(
4407 b"www.example.com",
4408 &PathRule::Prefix("/api".to_string()),
4409 &MethodRule::new(Some("GET".to_string())),
4410 &Route::ClusterId("cluster2".into())
4411 ));
4412 assert!(router.has_hostname("www.example.com"));
4413
4414 // Remove first route — hostname should still exist
4415 assert!(router.remove_tree_rule(
4416 b"www.example.com",
4417 &PathRule::Prefix("/".to_string()),
4418 &MethodRule::new(Some("GET".to_string()))
4419 ));
4420 assert!(router.has_hostname("www.example.com"));
4421
4422 // Remove second route — hostname should be gone
4423 assert!(router.remove_tree_rule(
4424 b"www.example.com",
4425 &PathRule::Prefix("/api".to_string()),
4426 &MethodRule::new(Some("GET".to_string()))
4427 ));
4428 assert!(!router.has_hostname("www.example.com"));
4429 }
4430 /// The operator-visible half of the trie's leftmost-regex insert gap.
4431 /// A frontend whose leftmost host segment is a regex
4432 /// (`/test[0-9]/.example.com`) could not be added once a DEEPER
4433 /// frontend sharing that segment (`foo./test[0-9]/.example.com`) had
4434 /// already opened it: `TrieNode::insert_recursive` answered `Existing`
4435 /// for a host it stored nowhere.
4436 ///
4437 /// The two profiles failed differently, which is why this test asserts
4438 /// the STORE rather than the return code alone. In debug the
4439 /// post-insert reachability `debug_assert!` below `domain_insert`
4440 /// killed the worker outright — "a freshly inserted tree domain must
4441 /// resolve to its inserted rule" — on a control-plane `AddHttpFrontend`
4442 /// (and again on every `LoadState` replay). In release that assert is
4443 /// compiled out, so `add_tree_rule` returned `true`, the CLI reported
4444 /// OK and `sozu query frontends` listed the route, while the trie never
4445 /// served it.
4446 ///
4447 /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4448 /// `TrieNode::insert_recursive` (`router/pattern_trie.rs`), replace
4449 /// `return t.1.insert_own_value(key, value);` with
4450 /// `return InsertResult::Existing;`. In release the `lookup` assertion
4451 /// below fails with `Err(route_not_found …)`; in debug the
4452 /// `debug_assert!` in `add_tree_rule` panics first at
4453 /// "a freshly inserted tree domain must resolve to its inserted rule"
4454 /// — comment that `debug_assert!` out and debug lands on the same
4455 /// `lookup` assertion release does.
4456 #[test]
4457 fn a_leftmost_regex_frontend_is_addable_after_a_deeper_sibling() {
4458 let path = PathRule::Prefix("/".to_string());
4459 let method = MethodRule::new(Some("GET".to_string()));
4460
4461 // Order A: deeper frontend first — the order that used to lose the
4462 // leftmost host.
4463 let mut router = Router::new();
4464 assert!(router.add_tree_rule(
4465 b"foo./test[0-9]/.example.com",
4466 &path,
4467 &method,
4468 &Route::ClusterId("deeper".into()),
4469 ));
4470 assert!(
4471 router.add_tree_rule(
4472 b"/test[0-9]/.example.com",
4473 &path,
4474 &method,
4475 &Route::ClusterId("leftmost".into()),
4476 ),
4477 "a leftmost-regex frontend must be accepted after a deeper sibling",
4478 );
4479 assert_eq!(
4480 router.lookup("test4.example.com", "/", &Method::Get),
4481 Ok(RouteResult::forward("leftmost".into())),
4482 "the leftmost-regex frontend must actually route, not just report OK",
4483 );
4484 assert_eq!(
4485 router.lookup("foo.test4.example.com", "/", &Method::Get),
4486 Ok(RouteResult::forward("deeper".into())),
4487 "the deeper frontend must still route",
4488 );
4489
4490 // Order B: leftmost frontend first — the order that already worked.
4491 let mut router = Router::new();
4492 assert!(router.add_tree_rule(
4493 b"/test[0-9]/.example.com",
4494 &path,
4495 &method,
4496 &Route::ClusterId("leftmost".into()),
4497 ));
4498 assert!(router.add_tree_rule(
4499 b"foo./test[0-9]/.example.com",
4500 &path,
4501 &method,
4502 &Route::ClusterId("deeper".into()),
4503 ));
4504 assert_eq!(
4505 router.lookup("test4.example.com", "/", &Method::Get),
4506 Ok(RouteResult::forward("leftmost".into())),
4507 );
4508 assert_eq!(
4509 router.lookup("foo.test4.example.com", "/", &Method::Get),
4510 Ok(RouteResult::forward("deeper".into())),
4511 );
4512
4513 // And removal through the public surface still takes exactly one.
4514 assert!(router.remove_tree_rule(b"/test[0-9]/.example.com", &path, &method));
4515 assert!(
4516 router
4517 .lookup("test4.example.com", "/", &Method::Get)
4518 .is_err()
4519 );
4520 assert_eq!(
4521 router.lookup("foo.test4.example.com", "/", &Method::Get),
4522 Ok(RouteResult::forward("deeper".into())),
4523 );
4524 }
4525
4526 /// `add_tree_rule` returning `true` is a claim that the route table now
4527 /// serves the rule. The trie's leftmost-regex gap broke exactly that
4528 /// contract in RELEASE builds, where the post-insert reachability
4529 /// `debug_assert!` is compiled out: the control plane reported success
4530 /// and the data plane never routed the host.
4531 ///
4532 /// This pins the contract itself, so it fails in a release build too —
4533 /// no `debug_assert` is involved in the assertion. Every shape here is
4534 /// one the trie stores through a different arm (literal, wildcard,
4535 /// leftmost regex, deeper regex, regex opened by a deeper sibling), and
4536 /// each is checked BOTH ways round: a reported success must route, and
4537 /// a reported failure must leave nothing behind.
4538 ///
4539 /// To SEE THIS RED: in the `pos == 0` arm of the dedup loop in
4540 /// `TrieNode::insert_recursive`, replace
4541 /// `return t.1.insert_own_value(key, value);` with
4542 /// `return InsertResult::Existing;`. The `/test[0-9]/.example.com` row
4543 /// added after `foo./test[0-9]/.example.com` then reports `true` while
4544 /// `lookup` answers `Err(route_not_found …)`, failing the
4545 /// "reported success but does not route" assertion in release. In debug
4546 /// the `debug_assert!` in `add_tree_rule` fires first with
4547 /// "a freshly inserted tree domain must resolve to its inserted rule".
4548 #[test]
4549 fn add_tree_rule_never_reports_success_for_a_rule_it_did_not_store() {
4550 let path = PathRule::Prefix("/".to_string());
4551 let method = MethodRule::new(Some("GET".to_string()));
4552
4553 // (hostname to add, hostname that must then route)
4554 let sequence: &[(&[u8], &str)] = &[
4555 (b"www.example.com", "www.example.com"),
4556 (b"*.wild.example.com", "any.wild.example.com"),
4557 (b"foo./test[0-9]/.example.com", "foo.test4.example.com"),
4558 // Opened above by its deeper sibling: the regression.
4559 (b"/test[0-9]/.example.com", "test4.example.com"),
4560 (b"bar./test[0-9]/.example.com", "bar.test7.example.com"),
4561 ];
4562
4563 let mut router = Router::new();
4564 for (index, (hostname, routable)) in sequence.iter().enumerate() {
4565 let cluster = ClusterId::from(format!("cluster{index}"));
4566 let added =
4567 router.add_tree_rule(hostname, &path, &method, &Route::ClusterId(cluster.clone()));
4568 let resolved = router.lookup(routable, "/", &Method::Get);
4569 assert!(
4570 added,
4571 "{:?} must be accepted",
4572 String::from_utf8_lossy(hostname),
4573 );
4574 assert_eq!(
4575 resolved,
4576 Ok(RouteResult::forward(cluster)),
4577 "add_tree_rule reported success for {:?} but it does not route",
4578 String::from_utf8_lossy(hostname),
4579 );
4580 }
4581
4582 // The converse half of the contract: a REJECTED add must not be
4583 // reported as stored either, and must leave no route behind.
4584 for hostname in [
4585 &b"example.com/"[..],
4586 b".example.com",
4587 b"abc/[0-9]+/.example.com",
4588 ] {
4589 let mut router = Router::new();
4590 assert!(
4591 !router.add_tree_rule(
4592 hostname,
4593 &path,
4594 &method,
4595 &Route::ClusterId("rejected".into()),
4596 ),
4597 "{:?} must be rejected",
4598 String::from_utf8_lossy(hostname),
4599 );
4600 assert!(
4601 router.tree.is_empty(),
4602 "{:?} was rejected but still mutated the route table",
4603 String::from_utf8_lossy(hostname),
4604 );
4605 }
4606 }
4607
4608 // ---- Rule-ordering contract (`doc/configure.md`, "When declaration order
4609 // decides"). The path rules of one hostname are a flat `Vec` at the trie
4610 // leaf, scanned once in declaration order: `Regex`/`Equals` RETURN on the
4611 // first match, while `Prefix` accumulates the longest. Nothing pinned any
4612 // of that, and the documented rule has been wrong three times, so each
4613 // ordering below is its own case.
4614
4615 /// Declare one tree rule for `www.example.com`. `method` is a PARAMETER,
4616 /// never a constant: whether a rule carries one decides if it ends the
4617 /// lookup scan early, so holding it fixed hides half the orderings below.
4618 fn add_path_rule(
4619 router: &mut Router,
4620 path: PathRule,
4621 method: Option<&str>,
4622 cluster: &str,
4623 ) -> bool {
4624 router.add_tree_rule(
4625 b"www.example.com",
4626 &path,
4627 &MethodRule::new(method.map(str::to_owned)),
4628 &Route::ClusterId(cluster.into()),
4629 )
4630 }
4631
4632 /// The cluster a `GET` for `path` on `www.example.com` resolves to.
4633 fn routed_cluster(router: &Router, path: &str) -> Option<String> {
4634 router
4635 .lookup("www.example.com", path, &Method::Get)
4636 .ok()
4637 .and_then(|result| result.cluster_id)
4638 .map(|cluster_id| cluster_id.to_string())
4639 }
4640
4641 /// Declare `first` then `second` — each with its own `method` — and resolve
4642 /// `path`. Declaration order is the variable under test, so callers assert
4643 /// both orders.
4644 fn winner_of(
4645 first: (PathRule, Option<&str>, &str),
4646 second: (PathRule, Option<&str>, &str),
4647 path: &str,
4648 ) -> Option<String> {
4649 let mut router = Router::new();
4650 assert!(add_path_rule(&mut router, first.0, first.1, first.2));
4651 assert!(add_path_rule(&mut router, second.0, second.1, second.2));
4652 routed_cluster(&router, path)
4653 }
4654
4655 /// `Some("GET")` — a rule whose method matches the request, so it ends the
4656 /// scan. Spelled out at every call site so the axis stays visible.
4657 const GET: Option<&str> = Some("GET");
4658 /// `None` — a rule with no method. It matches every request but does NOT
4659 /// end the scan.
4660 const NO_METHOD: Option<&str> = None;
4661 /// `Some("POST")` against a `GET` request — a method that is PRESENT and
4662 /// does NOT match, i.e. `MethodRuleResult::None`. The third value of the
4663 /// method axis: such a rule is skipped entirely, whatever its `path_type`.
4664 /// Covering only `GET` and `NO_METHOD` left every claim about this value
4665 /// untested, which is how two false sentences reached the documentation.
4666 const WRONG_METHOD: Option<&str> = Some("POST");
4667
4668 /// Among `PREFIX` path rules the LONGEST match wins, and that is the one
4669 /// genuinely order-independent case in the router.
4670 ///
4671 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4672 /// `PathRuleResult::Prefix(size) => { if size >= prefix_length {` guard
4673 /// with `if matched.is_none() {` so the FIRST matching prefix wins instead
4674 /// of the longest. The `SHORT`-first case below then resolves to `SHORT`.
4675 #[test]
4676 fn the_longest_prefix_wins_whichever_prefix_is_declared_first() {
4677 let short = || PathRule::Prefix("/a".to_owned());
4678 let long = || PathRule::Prefix("/ab".to_owned());
4679
4680 assert_eq!(
4681 winner_of((short(), GET, "SHORT"), (long(), GET, "LONG"), "/abc").as_deref(),
4682 Some("LONG"),
4683 "the longer prefix must win even when the shorter one was declared first",
4684 );
4685 assert_eq!(
4686 winner_of((long(), GET, "LONG"), (short(), GET, "SHORT"), "/abc").as_deref(),
4687 Some("LONG"),
4688 "the longer prefix must win when it was declared first too",
4689 );
4690 }
4691
4692 /// Two `PREFIX` rules can only tie on length by carrying the SAME prefix
4693 /// string and differing on `method` — `add_tree_rule` dedups on
4694 /// `(path, method)`, so that pair coexists. The scan keeps the LAST such
4695 /// rule, because the guard is `size >= prefix_length`, not `>`.
4696 ///
4697 /// This is the subtlest ordering in the router and the easiest to "fix"
4698 /// by accident: tightening `>=` to `>` looks like a harmless no-op that
4699 /// stops a rule overwriting an equally-good one, and silently flips which
4700 /// cluster serves the request.
4701 ///
4702 /// To SEE THIS RED: change `if size >= prefix_length {` to
4703 /// `if size > prefix_length {` in `Router::lookup`'s path-rule loop. Both
4704 /// assertions below then resolve to the FIRST declared rule.
4705 #[test]
4706 fn equal_length_prefixes_differing_only_by_method_resolve_to_the_last_declared() {
4707 let declare = |first_is_get: bool| {
4708 let mut router = Router::new();
4709 let get = (
4710 MethodRule::new(Some("GET".to_owned())),
4711 Route::ClusterId("GET-RULE".into()),
4712 );
4713 let all = (MethodRule::new(None), Route::ClusterId("ALL-RULE".into()));
4714 let (a, b) = if first_is_get { (get, all) } else { (all, get) };
4715 for (method, route) in [a, b] {
4716 assert!(router.add_tree_rule(
4717 b"www.example.com",
4718 &PathRule::Prefix("/ab".to_owned()),
4719 &method,
4720 &route,
4721 ));
4722 }
4723 routed_cluster(&router, "/abc")
4724 };
4725
4726 assert_eq!(
4727 declare(true).as_deref(),
4728 Some("ALL-RULE"),
4729 "the LAST equal-length prefix declared must win (`>=`, not `>`)",
4730 );
4731 assert_eq!(
4732 declare(false).as_deref(),
4733 Some("GET-RULE"),
4734 "the LAST equal-length prefix declared must win in the other order too",
4735 );
4736 }
4737
4738 /// Two overlapping `REGEX` path rules are decided by declaration order:
4739 /// the scan returns on the first match, and nothing weighs one pattern as
4740 /// more specific than another. A wider pattern declared first therefore
4741 /// shadows a narrower one declared later.
4742 ///
4743 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, replace the
4744 /// `MethodRuleResult::Equals => { return Ok(RouteResult::new_with_trie(..)) }`
4745 /// arm under `PathRuleResult::Regex | PathRuleResult::Equals` with the
4746 /// non-returning body its `MethodRuleResult::All` sibling uses
4747 /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). Losing
4748 /// the early return makes the LAST match win, so both assertions flip.
4749 #[test]
4750 fn with_a_matching_method_the_first_declared_regex_path_rule_wins() {
4751 let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4752 let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4753
4754 assert_eq!(
4755 winner_of((wide(), GET, "WIDE"), (narrow(), GET, "NARROW"), "/abc").as_deref(),
4756 Some("WIDE"),
4757 "the first-declared regex must win even though the later one is narrower",
4758 );
4759 assert_eq!(
4760 winner_of((narrow(), GET, "NARROW"), (wide(), GET, "WIDE"), "/abc").as_deref(),
4761 Some("NARROW"),
4762 "reversing the declaration order reverses the winner",
4763 );
4764 }
4765
4766 /// `EQUALS` holds no priority over `REGEX`: they share one match arm and
4767 /// the scan returns on whichever comes first. An exact-match rule does NOT
4768 /// outrank a pattern that was declared before it.
4769 ///
4770 /// To SEE THIS RED: the same mutation as
4771 /// `with_a_matching_method_the_first_declared_regex_path_rule_wins`
4772 /// — drop the early `return` from the `MethodRuleResult::Equals` arm under
4773 /// `PathRuleResult::Regex | PathRuleResult::Equals`. Both assertions flip
4774 /// to the last declared rule.
4775 #[test]
4776 fn with_a_matching_method_neither_equals_nor_regex_outranks_the_other() {
4777 let equals = || PathRule::Equals("/abc".to_owned());
4778 let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4779
4780 assert_eq!(
4781 winner_of((equals(), GET, "EQUALS"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4782 Some("EQUALS"),
4783 "EQUALS declared first must win",
4784 );
4785 assert_eq!(
4786 winner_of((regex(), GET, "REGEX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4787 Some("REGEX"),
4788 "REGEX declared first must win — EQUALS has no inherent priority",
4789 );
4790 }
4791
4792 /// A matching `EQUALS` or `REGEX` beats a `PREFIX` rule in either order,
4793 /// even when the prefix is the longer, more specific pattern: the early
4794 /// return short-circuits the scan before any prefix accumulation is read.
4795 /// This is the one cross-type precedence the router really does have.
4796 ///
4797 /// To SEE THIS RED: the same mutation again — drop the early `return` from
4798 /// the `MethodRuleResult::Equals` arm under
4799 /// `PathRuleResult::Regex | PathRuleResult::Equals`. That arm's
4800 /// replacement sets `prefix_length = path_b.len()`, so the equally-long
4801 /// `PREFIX` rule then satisfies `size >= prefix_length` and overwrites it;
4802 /// the `PREFIX`-declared-second cases below resolve to `PREFIX`.
4803 #[test]
4804 fn with_a_matching_method_equals_or_regex_beats_a_longer_prefix_either_order() {
4805 let prefix = || PathRule::Prefix("/abc".to_owned());
4806 let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4807 let equals = || PathRule::Equals("/abc".to_owned());
4808
4809 assert_eq!(
4810 winner_of((prefix(), GET, "PREFIX"), (regex(), GET, "REGEX"), "/abc").as_deref(),
4811 Some("REGEX"),
4812 "a regex must beat a longer prefix declared before it",
4813 );
4814 assert_eq!(
4815 winner_of((regex(), GET, "REGEX"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4816 Some("REGEX"),
4817 "a regex must beat a longer prefix declared after it",
4818 );
4819 assert_eq!(
4820 winner_of((prefix(), GET, "PREFIX"), (equals(), GET, "EQUALS"), "/abc").as_deref(),
4821 Some("EQUALS"),
4822 "an exact match must beat an equally long prefix declared before it",
4823 );
4824 assert_eq!(
4825 winner_of((equals(), GET, "EQUALS"), (prefix(), GET, "PREFIX"), "/abc").as_deref(),
4826 Some("EQUALS"),
4827 "an exact match must beat an equally long prefix declared after it",
4828 );
4829 }
4830
4831 /// Without a `method`, an `EQUALS` or `REGEX` rule does NOT end the scan:
4832 /// its match is only recorded, and a later match overwrites it. So the
4833 /// ordering is the exact REVERSE of the matching-method case — the LAST
4834 /// declared wins.
4835 ///
4836 /// This is the dimension the first six ordering tests all missed, because
4837 /// their helper hard-coded `Some("GET")`. `HttpFrontend.method` is an
4838 /// `Option<String>` and both production call sites build the rule with
4839 /// `MethodRule::new(front.method.clone())`, so a frontend declared without
4840 /// a method lands here — it is the default shape, not an exotic one.
4841 ///
4842 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, give the
4843 /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4844 /// PathRuleResult::Equals` the early `return` its `MethodRuleResult::Equals`
4845 /// sibling has. Method-less rules then short-circuit too and both
4846 /// assertions flip to the FIRST declared.
4847 #[test]
4848 fn without_a_method_the_last_declared_regex_or_equals_wins_not_the_first() {
4849 let wide = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4850 let narrow = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4851 let equals = || PathRule::Equals("/abc".to_owned());
4852
4853 assert_eq!(
4854 winner_of(
4855 (wide(), NO_METHOD, "WIDE"),
4856 (narrow(), NO_METHOD, "NARROW"),
4857 "/abc"
4858 )
4859 .as_deref(),
4860 Some("NARROW"),
4861 "without a method the LAST declared regex must win",
4862 );
4863 assert_eq!(
4864 winner_of(
4865 (narrow(), NO_METHOD, "NARROW"),
4866 (wide(), NO_METHOD, "WIDE"),
4867 "/abc"
4868 )
4869 .as_deref(),
4870 Some("WIDE"),
4871 "reversing the order reverses the winner — still the last declared",
4872 );
4873 assert_eq!(
4874 winner_of(
4875 (equals(), NO_METHOD, "EQUALS"),
4876 (wide(), NO_METHOD, "REGEX"),
4877 "/abc"
4878 )
4879 .as_deref(),
4880 Some("REGEX"),
4881 "without a method an EQUALS declared first loses to a later REGEX",
4882 );
4883 assert_eq!(
4884 winner_of(
4885 (wide(), NO_METHOD, "REGEX"),
4886 (equals(), NO_METHOD, "EQUALS"),
4887 "/abc"
4888 )
4889 .as_deref(),
4890 Some("EQUALS"),
4891 "and the reverse order reverses that too",
4892 );
4893 }
4894
4895 /// Without a `method`, a matching `EQUALS`/`REGEX` does not reliably outrank
4896 /// a `PREFIX` either — the documented cross-type precedence INVERTS. Because
4897 /// the non-returning arm records `prefix_length = path_b.len()`, a `PREFIX`
4898 /// declared afterwards overwrites it exactly when its prefix covers the
4899 /// WHOLE request path; a shorter prefix cannot clear that bar and leaves the
4900 /// regex standing.
4901 ///
4902 /// "Whole request path" means the request-target as it arrives, QUERY
4903 /// STRING INCLUDED — kawa's `parse_origin_form` hands the router
4904 /// `/index.html?k=v#h` verbatim. So the identical configuration routes the
4905 /// opposite way once the client appends `?x=1`: `/abc` no longer spans
4906 /// `/abc?x=1`, the prefix stops clearing the bar, and the regex wins. The
4907 /// last case below pins that, because the worked example in
4908 /// `doc/configure.md` would otherwise teach `/abc` as "the whole path".
4909 ///
4910 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, change the
4911 /// `MethodRuleResult::All` arm under `PathRuleResult::Regex |
4912 /// PathRuleResult::Equals` from `prefix_length = path_b.len();` to
4913 /// `prefix_length = 0;`. The first two assertions still pass; the third
4914 /// fails first, with `left: Some("SHORT-PREFIX"), right: Some("REGEX")`,
4915 /// because the shorter `/a` prefix now clears the bar. That one mutation
4916 /// also breaks the query-string case below it — delete the third assertion
4917 /// and the fourth fails with `left: Some("PREFIX"), right: Some("REGEX")`,
4918 /// the prefix having overwritten the regex it should no longer span.
4919 #[test]
4920 fn without_a_method_only_a_whole_path_prefix_declared_later_beats_a_regex() {
4921 let regex = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4922 let whole_path = || PathRule::Prefix("/abc".to_owned());
4923 let shorter = || PathRule::Prefix("/a".to_owned());
4924
4925 assert_eq!(
4926 winner_of(
4927 (regex(), NO_METHOD, "REGEX"),
4928 (whole_path(), NO_METHOD, "PREFIX"),
4929 "/abc"
4930 )
4931 .as_deref(),
4932 Some("PREFIX"),
4933 "a whole-path prefix declared after a method-less regex must overwrite it",
4934 );
4935 assert_eq!(
4936 winner_of(
4937 (whole_path(), NO_METHOD, "PREFIX"),
4938 (regex(), NO_METHOD, "REGEX"),
4939 "/abc"
4940 )
4941 .as_deref(),
4942 Some("REGEX"),
4943 "declared before it, the same prefix loses — the last writer wins",
4944 );
4945 assert_eq!(
4946 winner_of(
4947 (regex(), NO_METHOD, "REGEX"),
4948 (shorter(), NO_METHOD, "SHORT-PREFIX"),
4949 "/abc"
4950 )
4951 .as_deref(),
4952 Some("REGEX"),
4953 "a prefix shorter than the request path must NOT displace the regex",
4954 );
4955
4956 // Same rules, same declaration order — only the request gains a query
4957 // string, and the winner inverts. The request path the router matches
4958 // is `/abc?x=1`, which `/abc` no longer covers.
4959 assert_eq!(
4960 winner_of(
4961 (regex(), NO_METHOD, "REGEX"),
4962 (whole_path(), NO_METHOD, "PREFIX"),
4963 "/abc?x=1"
4964 )
4965 .as_deref(),
4966 Some("REGEX"),
4967 "with a query string the prefix no longer spans the request path, so the regex wins",
4968 );
4969 }
4970
4971 /// A rule carrying a matching `method` beats a method-less one in either
4972 /// declaration order: the matching rule returns immediately, and when the
4973 /// method-less rule runs first it has only recorded a candidate that the
4974 /// return then overrides.
4975 ///
4976 /// To SEE THIS RED: drop the early `return` from the
4977 /// `MethodRuleResult::Equals` arm under `PathRuleResult::Regex |
4978 /// PathRuleResult::Equals` in `Router::lookup` (the same mutation the
4979 /// matching-method tests name). Both rules then merely record, so the last
4980 /// declared wins and the first assertion below fails with
4981 /// `left: Some("ANY-REGEX"), right: Some("GET-REGEX")`.
4982 #[test]
4983 fn a_rule_with_a_matching_method_beats_a_method_less_rule_in_either_order() {
4984 let any = || PathRule::Regex(Regex::new("/a.*").expect("test regex must compile"));
4985 let getr = || PathRule::Regex(Regex::new("/ab.*").expect("test regex must compile"));
4986
4987 assert_eq!(
4988 winner_of(
4989 (getr(), GET, "GET-REGEX"),
4990 (any(), NO_METHOD, "ANY-REGEX"),
4991 "/abc"
4992 )
4993 .as_deref(),
4994 Some("GET-REGEX"),
4995 "a matching-method rule declared first must win",
4996 );
4997 assert_eq!(
4998 winner_of(
4999 (any(), NO_METHOD, "ANY-REGEX"),
5000 (getr(), GET, "GET-REGEX"),
5001 "/abc"
5002 )
5003 .as_deref(),
5004 Some("GET-REGEX"),
5005 "a matching-method rule declared second must still win",
5006 );
5007 }
5008
5009 /// A rule whose `method` is PRESENT but does not match the request
5010 /// (`MethodRuleResult::None`) is skipped outright, so it never competes on
5011 /// path length at all. Both consequences contradict the unqualified
5012 /// "longest prefix wins" reading: a LONGER prefix loses to a shorter one
5013 /// when its method mismatches, and an equal-length tie goes to the FIRST
5014 /// declared rather than the last, because the later one is skipped before
5015 /// the `size >= prefix_length` comparison is ever reached.
5016 ///
5017 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5018 /// `MethodRuleResult::None` arm under `PathRuleResult::Prefix(size)`
5019 /// behave like its `MethodRuleResult::All` sibling
5020 /// (`prefix_length = size; matched = Some((rule, route));`). Non-matching
5021 /// methods then compete: the first assertion resolves to `LONG-POST` and
5022 /// the second to `SECOND-POST`.
5023 #[test]
5024 fn a_prefix_whose_method_does_not_match_is_skipped_so_it_cannot_win_on_length() {
5025 assert_eq!(
5026 winner_of(
5027 (PathRule::Prefix("/a".to_owned()), GET, "SHORT-GET"),
5028 (
5029 PathRule::Prefix("/ab".to_owned()),
5030 WRONG_METHOD,
5031 "LONG-POST"
5032 ),
5033 "/abc",
5034 )
5035 .as_deref(),
5036 Some("SHORT-GET"),
5037 "a longer prefix whose method mismatches must not beat a shorter matching one",
5038 );
5039 assert_eq!(
5040 winner_of(
5041 (PathRule::Prefix("/ab".to_owned()), GET, "FIRST-GET"),
5042 (
5043 PathRule::Prefix("/ab".to_owned()),
5044 WRONG_METHOD,
5045 "SECOND-POST"
5046 ),
5047 "/abc",
5048 )
5049 .as_deref(),
5050 Some("FIRST-GET"),
5051 "an equal-length tie goes to the FIRST when the later rule's method mismatches",
5052 );
5053 }
5054
5055 /// The same third method value, one tier up: an `EQUALS`/`REGEX` rule whose
5056 /// method mismatches does not short-circuit and does not even register as a
5057 /// candidate, so a `PREFIX` that would otherwise lose to it wins.
5058 ///
5059 /// To SEE THIS RED: in `Router::lookup`'s path-rule loop, make the
5060 /// `MethodRuleResult::None` arm under
5061 /// `PathRuleResult::Regex | PathRuleResult::Equals` behave like its
5062 /// `MethodRuleResult::All` sibling
5063 /// (`prefix_length = path_b.len(); matched = Some((rule, route));`). The
5064 /// mismatching regex then takes the request and the assertion resolves to
5065 /// `WRONG-METHOD-REGEX`.
5066 #[test]
5067 fn a_regex_whose_method_does_not_match_never_beats_a_matching_prefix() {
5068 assert_eq!(
5069 winner_of(
5070 (
5071 PathRule::Regex(Regex::new("/a.*").expect("test regex must compile")),
5072 WRONG_METHOD,
5073 "WRONG-METHOD-REGEX",
5074 ),
5075 (PathRule::Prefix("/a".to_owned()), GET, "MATCHING-PREFIX"),
5076 "/abc",
5077 )
5078 .as_deref(),
5079 Some("MATCHING-PREFIX"),
5080 "a regex whose method mismatches must not short-circuit the scan",
5081 );
5082 }
5083
5084 /// Declare one regex path rule built the way a configured frontend builds
5085 /// it — through `PathRule::from_config`, which is where the anchoring
5086 /// lives — and resolve `path`.
5087 fn regex_rule_routes(pattern: &str, path: &str) -> Option<String> {
5088 let rule = PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
5089 .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"));
5090 let mut router = Router::new();
5091 assert!(add_path_rule(&mut router, rule, GET, "REGEX"));
5092 routed_cluster(&router, path)
5093 }
5094
5095 /// Resolve a table of `(pattern, request path, must route)` triples
5096 /// through [`regex_rule_routes`]. Each row names itself in the failure
5097 /// message, so a red run says which triple broke rather than which
5098 /// assertion index did.
5099 fn assert_regex_routing(cases: &[(&str, &str, bool)]) {
5100 let verdict = |routes: bool| if routes { "match" } else { "no match" };
5101 for &(pattern, path, must_route) in cases {
5102 let routes = regex_rule_routes(pattern, path).is_some();
5103 assert_eq!(
5104 routes,
5105 must_route,
5106 "path regex {pattern:?} against request path {path:?}: \
5107 expected {}, measured {}",
5108 verdict(must_route),
5109 verdict(routes),
5110 );
5111 }
5112 }
5113
5114 /// `path_type = "REGEX"` is anchored at both ends: the configured pattern
5115 /// must match the WHOLE request path, not a substring of it.
5116 ///
5117 /// This test is the INVERSION of the test formerly named
5118 /// `a_path_regex_is_unanchored_and_matches_anywhere_in_the_request_path`
5119 /// — a name no longer in the tree, this one having replaced it —
5120 /// added by #1352 to pin the opposite. That test was correct about the
5121 /// code and is now deliberately obsolete: sozu#1350 asked whether
5122 /// `doc/configure.md` (which has promised `\A...\z` since v2.0.0) or
5123 /// `PathRule::from_config` (a bare `Regex::new` against the substring
5124 /// search `regex::bytes::Regex::is_match`) was the thing to change, and
5125 /// the answer was the code. The old test's own comment said it existed so
5126 /// that the answer would be "a deliberate change, not a silent one" —
5127 /// this is that deliberate change, kept visible by rewriting the test
5128 /// rather than deleting it.
5129 ///
5130 /// `bc` matching `/abcd` is sozu#1350's own example, so it is the case
5131 /// kept here.
5132 ///
5133 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5134 /// make the body `Regex::new(value).ok()`. The first assertion then fails
5135 /// with `left: Some("REGEX"), right: None`.
5136 #[test]
5137 fn a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path() {
5138 assert_eq!(
5139 regex_rule_routes("bc", "/abcd"),
5140 None,
5141 "an anchored path regex must not match in the middle of the request path",
5142 );
5143 assert_eq!(
5144 regex_rule_routes("/ab[cd]", "/abc").as_deref(),
5145 Some("REGEX"),
5146 "a regex spanning the whole request path must still match",
5147 );
5148 assert_eq!(
5149 regex_rule_routes("/ab[cd]", "/abcd"),
5150 None,
5151 "the trailing `\\z` must reject a longer path with a matching head",
5152 );
5153 assert_eq!(
5154 regex_rule_routes("/ab[cd]", "/x/abc"),
5155 None,
5156 "the leading `\\A` must reject a path with a matching tail",
5157 );
5158
5159 // The matched "request path" is the request-target as it arrives,
5160 // query string included, so anchoring bites a query string the way it
5161 // already bit `path_type = "EQUALS"` — the consequence
5162 // `doc/configure.md` now states.
5163 assert_eq!(
5164 regex_rule_routes("/ab[cd]", "/abc?x=1"),
5165 None,
5166 "an anchored regex must not match a path carrying a query string \
5167 unless the pattern accounts for it",
5168 );
5169 assert_eq!(
5170 regex_rule_routes("/ab[cd].*", "/abc?x=1").as_deref(),
5171 Some("REGEX"),
5172 "appending `.*` is what makes such a rule survive a query string",
5173 );
5174 }
5175
5176 /// The narrowing reaches the degenerate pattern too: an empty `REGEX`
5177 /// value compiles (it always has) and used to match every request path,
5178 /// because an empty match at offset 0 is a match. Anchored, it matches the
5179 /// empty path and nothing else. An operator using `path_type = "REGEX"`
5180 /// with an empty `path` as a catch-all loses every request to it.
5181 ///
5182 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5183 /// make the body `Regex::new(value).ok()`. The assertion then fails with
5184 /// `left: Some("REGEX"), right: None`.
5185 #[test]
5186 fn an_empty_path_regex_no_longer_matches_every_request_path() {
5187 assert!(
5188 Regex::new("").is_ok(),
5189 "the empty pattern must stay a VALID regex — this test is about \
5190 what it matches, not about rejecting it",
5191 );
5192 assert_eq!(
5193 regex_rule_routes("", "/abcd"),
5194 None,
5195 "an anchored empty path regex must not match a non-empty path",
5196 );
5197 // "matched every path", measured against the BARE regex of 2.2.1 — a
5198 // control. An empty match at offset 0 is a match, so `is_match`
5199 // succeeded on every input; that is what made an empty `REGEX` value
5200 // usable as a catch-all in the first place.
5201 let bare_empty = Regex::new("").expect("the empty pattern compiles");
5202 for every_path in ["/anything", "/abcd", "/", ""] {
5203 assert!(
5204 bare_empty.is_match(every_path.as_bytes()),
5205 "up to 2.2.1 the unwrapped empty pattern matched {every_path:?}, \
5206 as it matched every path",
5207 );
5208 }
5209
5210 assert_eq!(
5211 regex_rule_routes("", "").as_deref(),
5212 Some("REGEX"),
5213 "\"matches only the empty path\" is half a claim without the path \
5214 it does still match",
5215 );
5216 }
5217
5218 /// Anchors an operator wrote by hand keep working, and are neither
5219 /// detected nor stripped: `regex`'s `^`/`$` ARE `\A`/`\z` outside
5220 /// multi-line mode, so the repeated zero-width assertions in
5221 /// `\A(?:^/abc$)\z` collapse to one at each end. Measured rather than
5222 /// assumed — that is what the pattern-string assertion below pins.
5223 ///
5224 /// The third case is the one that changes: a HALF-anchored pattern
5225 /// (`^/abc`, start only) used to match `/abcd` and no longer does. An
5226 /// operator who wrote the leading anchor on the strength of the old
5227 /// unanchored behaviour gets the missing end supplied for them.
5228 ///
5229 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5230 /// make the body `Regex::new(value).ok()`. The half-anchored case then
5231 /// fails with `left: Some("REGEX"), right: None`, and the pattern-string
5232 /// assertion fails too. To see ONLY the pattern-string assertion red,
5233 /// strip a leading `^`/`\A` and a trailing `$`/`\z` from `value` before
5234 /// wrapping it.
5235 #[test]
5236 fn a_hand_written_path_anchor_is_kept_and_double_anchoring_is_harmless() {
5237 for hand_anchored in ["^/abc$", "\\A/abc\\z", "^/abc"] {
5238 assert_eq!(
5239 regex_rule_routes(hand_anchored, "/abc").as_deref(),
5240 Some("REGEX"),
5241 "{hand_anchored} must still match the path it was written for",
5242 );
5243 assert_eq!(
5244 regex_rule_routes(hand_anchored, "/abcd"),
5245 None,
5246 "{hand_anchored} must not match a longer path",
5247 );
5248 assert_eq!(
5249 regex_rule_routes(hand_anchored, "/x/abc"),
5250 None,
5251 "{hand_anchored} must not match a path with a matching tail",
5252 );
5253 }
5254
5255 assert_eq!(
5256 PathRule::anchored_regex("^/abc$")
5257 .expect("a hand-anchored pattern must compile")
5258 .as_str(),
5259 "\\A(?:^/abc$)\\z",
5260 "the configured value is wrapped verbatim; no anchor is stripped",
5261 );
5262
5263 // `CHANGELOG.md` and `PathRule::anchored_regex` both quote `/xabcd`
5264 // as the path `\A(?:^/abc$)\z` must not match. It is a positive
5265 // control rather than a reddenable row — `^/abc$` excludes it with or
5266 // without the wrapping — but the quoted example was asserted nowhere.
5267 assert_eq!(
5268 regex_rule_routes("^/abc$", "/xabcd"),
5269 None,
5270 "the example `CHANGELOG.md` quotes verbatim must hold",
5271 );
5272
5273 // The load-bearing half of "`^`/`$` ARE `\A`/`\z`": in the `regex`
5274 // crate's default mode `$` is `\z` and NOT `\Z`, so it does not
5275 // match before a trailing `\n`. Were it `\Z`, the claim that an
5276 // already-anchored `^/abc$` is unchanged by the wrapping would be
5277 // false for exactly one input — a path ending in a newline. Whether a
5278 // client can drive such a request-target past the parser is NOT
5279 // established here, and deliberately so: the wrapping claim has to
5280 // hold on the bytes the router is handed, whatever produced them.
5281 // Measured against the crate directly, so it is a control.
5282 assert!(
5283 !Regex::new("^/abc$")
5284 .expect("the hand-anchored pattern compiles")
5285 .is_match(b"/abc\n"),
5286 "`$` must be `\\z` and not `\\Z`, or wrapping a hand-anchored \
5287 pattern would not be the no-op this test claims",
5288 );
5289 assert_eq!(
5290 regex_rule_routes("^/abc$", "/abc\n"),
5291 None,
5292 "and the wrapped rule must agree with it",
5293 );
5294 }
5295
5296 /// The anchoring wraps the configured value in a NON-CAPTURING GROUP, and
5297 /// that group is load-bearing: `|` binds looser than concatenation, so the
5298 /// ungrouped `\A/a|/b\z` parses as `(\A/a)|(/b\z)` and leaves each branch
5299 /// anchored at one end only. `/a` would still match `/axx`.
5300 ///
5301 /// Two branches only ever exercise the FIRST and LAST branch, so the
5302 /// three-branch case below is what makes `doc/configure.md`'s "every
5303 /// branch […] not just the first and last" a testable sentence: ungrouped,
5304 /// a MIDDLE branch keeps neither anchor and matches as a bare substring
5305 /// anywhere in the path — worse than the half-anchored ends.
5306 ///
5307 /// The hostname sites carried the same gap when this landed and were
5308 /// closed separately by sozu#1356: `convert_regex_domain_rule` now
5309 /// groups each slash-delimited segment, and `pattern_trie.rs`'s
5310 /// `anchored_segment` builds `\A(?:{segment})\z` for the regex fed to
5311 /// `regexp.is_match(segment)`. See
5312 /// `every_branch_of_an_alternating_regex_hostname_rule_is_anchored` and
5313 /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`.
5314 ///
5315 /// To SEE THIS RED: drop the group in `PathRule::anchored_regex` — wrap as
5316 /// `format!("\\A{value}\\z")`, the ungrouped hostname form. The `/axx`
5317 /// assertion then fails with `left: Some("REGEX"), right: None`; with the
5318 /// two-branch rows removed, the three-branch table fails on
5319 /// `"/a|/b|/c"` against `"zzz/bzzz"` with `left: true, right: false`, the
5320 /// middle branch matching in the middle of the path.
5321 #[test]
5322 fn every_branch_of_an_alternating_path_regex_is_anchored() {
5323 for whole in ["/a", "/b"] {
5324 assert_eq!(
5325 regex_rule_routes("/a|/b", whole).as_deref(),
5326 Some("REGEX"),
5327 "{whole} is a whole-path match for one branch and must route",
5328 );
5329 }
5330 assert_eq!(
5331 regex_rule_routes("/a|/b", "/axx"),
5332 None,
5333 "the first branch must be anchored at its END too",
5334 );
5335 assert_eq!(
5336 regex_rule_routes("/a|/b", "/xx/b"),
5337 None,
5338 "the last branch must be anchored at its START too",
5339 );
5340 assert_eq!(
5341 PathRule::anchored_regex("/a|/b")
5342 .expect("an alternation must compile")
5343 .captures_len(),
5344 1,
5345 "the wrapping group must be non-capturing, so `$PATH[n]` indices \
5346 are untouched",
5347 );
5348
5349 // The exact wrapper string, so "grouped" is asserted and not merely
5350 // implied by the three routing verdicts above.
5351 assert_eq!(
5352 PathRule::anchored_regex("/a|/b")
5353 .expect("an alternation must compile")
5354 .as_str(),
5355 "\\A(?:/a|/b)\\z",
5356 "the wrapper must be the NON-capturing group form",
5357 );
5358
5359 // The control for all of the above, built by hand against the `regex`
5360 // crate rather than through `anchored_regex`: the UNGROUPED wrapper —
5361 // the form the hostname sites use — parses as `(\A/a)|(/b\z)` and
5362 // still matches `/axx`. No mutation of `anchored_regex` can move this
5363 // assertion, which is the point: it measures why the group is there.
5364 let ungrouped = Regex::new("\\A/a|/b\\z").expect("the ungrouped wrapper compiles");
5365 assert!(
5366 ungrouped.is_match(b"/axx"),
5367 "{} leaves the first branch anchored at its start only",
5368 ungrouped.as_str(),
5369 );
5370 assert!(
5371 ungrouped.is_match(b"/xx/b"),
5372 "{} leaves the last branch anchored at its end only",
5373 ungrouped.as_str(),
5374 );
5375
5376 // Two branches cannot show what `doc/configure.md` actually claims —
5377 // "every branch of an alternation is anchored, NOT JUST THE FIRST AND
5378 // LAST" — because with two branches there is no other kind. A third
5379 // branch has one, and it is strictly worse than the half-anchored
5380 // ends above: ungrouped, the MIDDLE branch keeps NEITHER anchor, so
5381 // `/b` matches as a bare substring anywhere in the path. Control,
5382 // measured against the crate.
5383 let three = Regex::new("\\A/a|/b|/c\\z").expect("the ungrouped wrapper compiles");
5384 for anywhere in ["zzz/bzzz", "/xx/b/yy", "xx/byy"] {
5385 assert!(
5386 three.is_match(anywhere.as_bytes()),
5387 "{} leaves the MIDDLE branch unanchored at BOTH ends, so it \
5388 matches {anywhere} anywhere in the path",
5389 three.as_str(),
5390 );
5391 }
5392
5393 // The grouped form the router actually builds refuses all three, and
5394 // still matches each branch whole. This half IS production.
5395 assert_regex_routing(&[
5396 ("/a|/b|/c", "/a", true),
5397 ("/a|/b|/c", "/b", true),
5398 ("/a|/b|/c", "/c", true),
5399 ("/a|/b|/c", "zzz/bzzz", false),
5400 ("/a|/b|/c", "/xx/b/yy", false),
5401 ("/a|/b|/c", "xx/byy", false),
5402 ("/a|/b|/c", "/axx", false),
5403 ("/a|/b|/c", "/xx/c", false),
5404 ]);
5405 assert_eq!(
5406 PathRule::anchored_regex("/a|/b|/c")
5407 .expect("a three-branch alternation must compile")
5408 .as_str(),
5409 "\\A(?:/a|/b|/c)\\z",
5410 "a middle branch needs the group as much as the outer two do",
5411 );
5412 }
5413
5414 /// Anchoring must not rescue a pattern the router rejects today. The
5415 /// wrapper supplies one `(` and one `)`, so an unbalanced value can be
5416 /// balanced BY the wrapping — `a)(b` is not a regex, yet `\A(?:a)(b)\z`
5417 /// is, and it matches `ab`. `PathRule::anchored_regex` therefore compiles
5418 /// the configured value on its own before wrapping it.
5419 ///
5420 /// This test covers that one direction. The converse is NOT guarded and
5421 /// is not asserted here: wrapping can reject a pattern that compiles
5422 /// bare, measured on `(?x)/api/v1 # v1 only` (the appended `)` lands in
5423 /// the `#` comment `(?x)` enables) and on a 249-deep nested group (the
5424 /// crate's 250-nesting limit). Both are refused at frontend registration
5425 /// as [`RouterError::InvalidPathRule`], so they cost a loud rejection and
5426 /// not a mis-route — see `PathRule::anchored_regex`.
5427 ///
5428 /// To SEE THIS RED: delete the `Regex::new(value).ok()?;` validation line
5429 /// in `PathRule::anchored_regex`. The `a)(b` assertion then fails — the
5430 /// rule builds, and matches `ab`.
5431 #[test]
5432 fn anchoring_never_turns_a_rejected_path_regex_into_an_accepted_one() {
5433 // The hazard is real, not hypothetical. Both patterns are assembled
5434 // at run time rather than written as literals because
5435 // `clippy::invalid_regex` rejects an invalid literal at a `Regex::new`
5436 // call site — and the invalidity is the whole point here, so the
5437 // literal is what moves, not the lint.
5438 let unbalanced = format!("a{}b", ")(");
5439 assert!(
5440 Regex::new(&unbalanced).is_err(),
5441 "{unbalanced:?} must be an invalid regex for this test to mean anything",
5442 );
5443 let wrapped = Regex::new(&format!("\\A(?:{unbalanced})\\z")).expect(
5444 "the wrapped form of the unbalanced pattern compiles — that balancing \
5445 is what the pre-validation exists to defeat",
5446 );
5447 assert!(
5448 wrapped.is_match(b"ab"),
5449 "{} matches `ab`, which the operator never wrote",
5450 wrapped.as_str(),
5451 );
5452
5453 for invalid in [unbalanced.as_str(), "(", ")", "[a-", "*"] {
5454 assert_eq!(
5455 PathRule::from_config(CommandPathRule::regex(invalid.to_owned())),
5456 None,
5457 "{invalid:?} must stay rejected once the value is anchored",
5458 );
5459 }
5460 }
5461
5462 /// Anchoring narrows `Route::Deny` exactly as it narrows a forward, and
5463 /// that is the dangerous edge of the change: a deny rule written loosely
5464 /// stops covering the family of paths it used to cover. `/admin` denied
5465 /// `/admin`, `/admin/secret` and `/administrator`; anchored it denies
5466 /// `/admin` alone and the rest fall through to whatever else matches.
5467 ///
5468 /// Both rules carry a matching `method`, so the deny is tier 1 in
5469 /// `doc/configure.md`'s precedence list and ends the scan when it matches
5470 /// — the fall-through below is the deny genuinely not matching, not the
5471 /// prefix outranking it.
5472 ///
5473 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5474 /// make the body `Regex::new(value).ok()`. Both fall-through assertions
5475 /// then fail with `left: None, right: Some("FALLBACK")`.
5476 #[test]
5477 fn anchoring_narrows_a_deny_path_regex_to_the_exact_path() {
5478 let mut router = Router::new();
5479 let deny = PathRule::from_config(CommandPathRule::regex("/admin".to_owned()))
5480 .expect("the deny regex path rule must build");
5481 assert!(router.add_tree_rule(
5482 b"www.example.com",
5483 &deny,
5484 &MethodRule::new(Some("GET".to_owned())),
5485 &Route::Deny,
5486 ));
5487 assert!(add_path_rule(
5488 &mut router,
5489 PathRule::Prefix("/".to_owned()),
5490 GET,
5491 "FALLBACK",
5492 ));
5493
5494 let resolve = |path: &str| {
5495 router
5496 .lookup("www.example.com", path, &Method::Get)
5497 .expect("every path below is covered by the `/` prefix rule")
5498 };
5499
5500 let denied = resolve("/admin");
5501 assert_eq!(
5502 denied.redirect,
5503 RedirectPolicy::Unauthorized,
5504 "the exact path the deny regex spells must still be denied",
5505 );
5506 assert_eq!(denied.cluster_id, None);
5507
5508 for still_reaching_the_backend in ["/admin/secret", "/administrator"] {
5509 let result = resolve(still_reaching_the_backend);
5510 assert_eq!(
5511 result.cluster_id.as_deref(),
5512 Some("FALLBACK"),
5513 "BREAKING: the anchored deny no longer covers \
5514 {still_reaching_the_backend}, which now reaches the backend",
5515 );
5516 assert_eq!(result.redirect, RedirectPolicy::Forward);
5517 }
5518 }
5519
5520 /// `doc/configure.md`'s remediation table, rows one and two: a
5521 /// SUBSTRING-shaped or PREFIX-shaped pattern narrows to the exact path it
5522 /// spells, and a TRAILING `.*` is what gives it back — those two shapes
5523 /// are open at the END, so that is the side the wildcard goes on.
5524 ///
5525 /// `bc` against `/abcd` is sozu#1350's own example and is pinned whole in
5526 /// `a_path_regex_is_anchored_at_both_ends_and_must_match_the_whole_request_path`;
5527 /// it is repeated here as the table's first row, beside its remediation.
5528 /// The table answers the prefix-shaped row twice, with `.*` or with
5529 /// `path_type = "PREFIX"`; only the regex answer is a regex claim, so only
5530 /// that one is asserted.
5531 ///
5532 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5533 /// make the body `Regex::new(value).ok()`. Every narrowing row below flips
5534 /// to a match; the first failure is `"bc"` against `"/abcd"`.
5535 #[test]
5536 fn a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells() {
5537 assert_regex_routing(&[
5538 // What the two shapes lose.
5539 ("bc", "/abcd", false),
5540 ("/ab", "/abcd", false),
5541 ("/api", "/api/v1", false),
5542 // …and what they keep: the path they spell whole.
5543 ("/api", "/api", true),
5544 // The table says `bc` now matches "only the path `bc`"; that is
5545 // two claims, and this is the half the narrowing rows do not
5546 // cover.
5547 ("bc", "bc", true),
5548 // The documented remediation, on the side each pattern is open on.
5549 (".*bc.*", "/abcd", true),
5550 ("/api.*", "/api/v1", true),
5551 ("/api.*", "/api", true),
5552 ]);
5553
5554 // The table's "used to match" column, measured. These go through the
5555 // BARE, unwrapped regex — the 2.2.1 code — so they are controls: no
5556 // mutation of `anchored_regex` can move them, and that is exactly
5557 // what makes them evidence for what the release took away. Without
5558 // them the whole first column is prose.
5559 for (pattern, used_to_match) in [("bc", "/abcd"), ("/api", "/api/v1")] {
5560 assert!(
5561 Regex::new(pattern)
5562 .expect("the bare pattern compiles")
5563 .is_match(used_to_match.as_bytes()),
5564 "up to 2.2.1 the unwrapped {pattern:?} matched {used_to_match:?} \
5565 as a substring — that is the behaviour this release removes",
5566 );
5567 }
5568 }
5569
5570 /// **The row that breaks silently.** A SUFFIX-shaped pattern is open at
5571 /// the START, so it needs a LEADING `.*`; the trailing one that answers
5572 /// the substring and prefix shapes does nothing for it. Extension routing
5573 /// is plausibly the commonest real use of a path regex, and it does not
5574 /// narrow — it stops matching.
5575 ///
5576 /// Every verdict below is `doc/configure.md`'s third table row and the two
5577 /// paragraphs under it, measured.
5578 ///
5579 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5580 /// make the body `Regex::new(value).ok()`. `"\.js$"` against
5581 /// `"/static/app.js"` fails first, and every other `false` row above the
5582 /// leading-`.*` ones fails with it.
5583 #[test]
5584 fn a_suffix_shaped_path_regex_needs_a_leading_wildcard_not_a_trailing_one() {
5585 assert_regex_routing(&[
5586 // The three the documentation names, all silently dead.
5587 (r"\.js$", "/static/app.js", false),
5588 (r"\.(js|css)$", "/a/b/style.css", false),
5589 ("/v1$", "/api/v1", false),
5590 // `/v1$` is the less dramatic half: it still matches its own path.
5591 ("/v1$", "/v1", true),
5592 // The trailing `.*` that rescues the other two shapes does NOT
5593 // rescue this one.
5594 (r"\.js.*", "/static/app.js", false),
5595 ("/v1.*", "/api/v1", false),
5596 // The leading one does.
5597 (r".*\.js$", "/static/app.js", true),
5598 (r".*\.(js|css)$", "/a/b/style.css", true),
5599 (".*/v1$", "/api/v1", true),
5600 ]);
5601
5602 // "…matches nothing a client can send": an end-anchored pattern with
5603 // nothing in front of it can only match a path that IS its own
5604 // suffix, which for `\.js$` is the literal path `.js`. An origin-form
5605 // request-target always begins with `/`, so no client can send it.
5606 assert_regex_routing(&[(r"\.js$", ".js", true)]);
5607
5608 // The table's third "used to match" cell, measured against the BARE
5609 // regex of 2.2.1 — a control, like the two in
5610 // `a_substring_or_prefix_shaped_path_regex_narrows_to_the_path_it_spells`.
5611 // This is the cell that matters most: it is the one whose loss is
5612 // silent.
5613 assert!(
5614 Regex::new(r"\.js$")
5615 .expect("the bare suffix pattern compiles")
5616 .is_match(b"/static/app.js"),
5617 "up to 2.2.1 the unwrapped `\\.js$` DID match `/static/app.js` — \
5618 losing that is the silent break this release documents",
5619 );
5620 for origin_form in ["/", "/.js", "/app.js", "/static/app.js"] {
5621 assert!(
5622 regex_rule_routes(r"\.js$", origin_form).is_none(),
5623 "an origin-form request-target begins with `/`, and {origin_form} \
5624 must not match the end-anchored `\\.js$`",
5625 );
5626 }
5627 }
5628
5629 /// **The `$PATH[n]` regression test.** The anchoring group is `(?:` … `)`
5630 /// and not `(` … `)` precisely so it adds no capture. `Router::lookup`
5631 /// collects path captures as `caps.iter().skip(1)` and `Frontend::new`
5632 /// sizes the buffer from `PathRule::Regex(regex) => regex.captures_len()`,
5633 /// so a CAPTURING wrapper would bump that cap and shift every configured
5634 /// `$PATH[n]` by one: a frontend asking for its first group would start
5635 /// receiving the whole request path, and keep serving traffic while doing
5636 /// it. This is the one property here that corrupts silently rather than
5637 /// stops matching.
5638 ///
5639 /// Asserted three ways: `captures_len()` parity against the bare pattern
5640 /// for zero, one and two groups; end to end, that `rewrite_path` of
5641 /// `$PATH[1]` still receives the first group's text; and that the CAP
5642 /// itself did not grow, by pinning that a `$PATH[n]` one past the last
5643 /// group the operator wrote is still refused.
5644 ///
5645 /// To SEE THIS RED: make the wrapper capturing in
5646 /// `PathRule::anchored_regex` — `format!("\\A({value})\\z")`. The parity
5647 /// assertion fails first with `left: 2, right: 1` on the group-less
5648 /// pattern; with that loop deleted the rewrite yields `/api/v2/users`
5649 /// instead of `v2`, and the out-of-range `$PATH[2]` registers instead of
5650 /// being refused.
5651 #[test]
5652 fn the_anchoring_group_is_non_capturing_so_path_rewrite_indices_do_not_shift() {
5653 for pattern in ["/api/v2/.*", "/api/(v[0-9]+)/.*", "/api/(v[0-9]+)/(.*)"] {
5654 let bare = Regex::new(pattern).expect("the bare pattern must compile");
5655 let anchored =
5656 PathRule::anchored_regex(pattern).expect("the anchored pattern must compile");
5657 assert_eq!(
5658 anchored.captures_len(),
5659 bare.captures_len(),
5660 "anchoring {pattern:?} must not add a capture group",
5661 );
5662 }
5663
5664 let rewriting_frontend = |pattern: &str, rewrite: &str| {
5665 let mut front = test_http_frontend();
5666 front.hostname = "www.example.com".to_owned();
5667 front.path = CommandPathRule::regex(pattern.to_owned());
5668 front.rewrite_path = Some(rewrite.to_owned());
5669 front
5670 };
5671 let rewritten = |pattern: &str, rewrite: &str, path: &str| {
5672 let mut router = Router::new();
5673 router
5674 .add_http_front(&rewriting_frontend(pattern, rewrite))
5675 .expect("the rewriting frontend must register");
5676 router
5677 .lookup("www.example.com", path, &Method::Get)
5678 .expect("the request must route")
5679 .rewritten_path
5680 };
5681
5682 assert_eq!(
5683 rewritten("/api/(v[0-9]+)/.*", "$PATH[1]", "/api/v2/users").as_deref(),
5684 Some("v2"),
5685 "`$PATH[1]` must still be the FIRST group the operator wrote, not \
5686 the anchoring wrapper",
5687 );
5688 assert_eq!(
5689 rewritten("/api/(v[0-9]+)/(.*)", "/$PATH[2]", "/api/v2/users").as_deref(),
5690 Some("/users"),
5691 "`$PATH[2]` must still be the SECOND group the operator wrote",
5692 );
5693 assert_eq!(
5694 rewritten("/api/(v[0-9]+)/.*", "$PATH[0]", "/api/v2/users").as_deref(),
5695 Some("/api/v2/users"),
5696 "`$PATH[0]` stays the whole request path",
5697 );
5698
5699 let mut router = Router::new();
5700 assert!(
5701 matches!(
5702 router.add_http_front(&rewriting_frontend("/api/(v[0-9]+)/.*", "$PATH[2]")),
5703 Err(RouterError::InvalidPathRewrite(_)),
5704 ),
5705 "the capture CAP is `captures_len()` too: a `$PATH[n]` past the \
5706 last group the operator wrote must stay refused",
5707 );
5708 }
5709
5710 /// The pre-validation in `PathRule::anchored_regex` closes
5711 /// invalid-becomes-valid only. The opposite direction is real, documented
5712 /// and NOT closed: a pattern that compiles BARE can be refused once
5713 /// wrapped, because the appended `)\z` has to survive whatever the pattern
5714 /// left open.
5715 ///
5716 /// Both halves are asserted for each case — `Regex::new(value).is_ok()`
5717 /// AND `from_config(…).is_none()` — because the second alone would not
5718 /// show the wrapping is what rejected it.
5719 ///
5720 /// The `249` both `CHANGELOG.md` and `doc/configure.md` quote is measured
5721 /// here rather than derived from the crate's documented limit, and it is
5722 /// the SMALLEST depth with this property: on `regex` 1.13.1 depth 248
5723 /// still survives the wrapping, depth 249 does not, and depth 251 no
5724 /// longer compiles bare either.
5725 ///
5726 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5727 /// make the body `Regex::new(value).ok()`. Both `from_config` assertions
5728 /// fail, each rule building instead of being refused. (The `Regex::new`
5729 /// rows are controls measuring the crate directly and do not move.)
5730 #[test]
5731 fn a_path_regex_that_compiles_bare_can_be_refused_once_it_is_anchored() {
5732 // `(?x)` turns on extended mode, in which `#` starts a comment that
5733 // runs to end of line — and the wrapper's `)` lands inside it.
5734 let extended_mode_comment = "(?x)/api/v1 # v1 only".to_owned();
5735 let nested = |depth: usize| format!("{}{}", "(".repeat(depth), ")".repeat(depth));
5736
5737 for (value, expected_error) in [
5738 (extended_mode_comment, "unclosed group"),
5739 (
5740 nested(249),
5741 "exceed the maximum number of nested parentheses/brackets (250)",
5742 ),
5743 ] {
5744 assert!(
5745 Regex::new(&value).is_ok(),
5746 "this case only means something while the BARE pattern compiles",
5747 );
5748 let error = Regex::new(&format!("\\A(?:{value})\\z"))
5749 .expect_err("the WRAPPED pattern is the one that must fail");
5750 assert!(
5751 error.to_string().contains(expected_error),
5752 "the documentation quotes {expected_error:?}; the wrapping \
5753 actually failed with: {error}",
5754 );
5755 assert_eq!(
5756 PathRule::from_config(CommandPathRule::regex(value)),
5757 None,
5758 "a value only the WRAPPING rejects must still be refused",
5759 );
5760 }
5761
5762 // The boundary itself, so the quoted 249 cannot drift unnoticed.
5763 assert!(
5764 Regex::new(&format!("\\A(?:{})\\z", nested(248))).is_ok(),
5765 "depth 248 must still survive the wrapping, or 249 is not the boundary",
5766 );
5767 assert!(
5768 Regex::new(&nested(250)).is_ok() && Regex::new(&nested(251)).is_err(),
5769 "the BARE limit must still sit between 250 and 251, or the wrapper \
5770 is no longer what makes depth 249 fail",
5771 );
5772 }
5773
5774 /// Every pattern `PathRule::from_config` refuses — bare-invalid or
5775 /// invalid-only-once-wrapped — surfaces at the public registration
5776 /// surface as [`RouterError::InvalidPathRule`]. That is the same
5777 /// `Router::add_http_front` the main process runs through
5778 /// `validate_frontend_request` (`bin/src/command/requests.rs`) before
5779 /// fanning a frontend out, which is what makes such a pattern a loudly
5780 /// rejected frontend and never a main/worker divergence.
5781 ///
5782 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5783 /// make the body `Regex::new(value).ok()`. The last two rows then
5784 /// register successfully. To redden the FIRST row instead, delete the
5785 /// `Regex::new(value).ok()?;` validation line: the wrapper balances
5786 /// `a)(b` and the frontend registers.
5787 #[test]
5788 fn a_refused_path_regex_surfaces_as_an_invalid_path_rule_at_registration() {
5789 // Assembled rather than written as a literal: `clippy::invalid_regex`
5790 // rejects an invalid literal at a `Regex::new` call site, and the
5791 // invalidity is the point.
5792 let unbalanced = format!("a{}b", ")(");
5793 for value in [
5794 unbalanced,
5795 "(?x)/api/v1 # v1 only".to_owned(),
5796 format!("{}{}", "(".repeat(249), ")".repeat(249)),
5797 ] {
5798 let mut router = Router::new();
5799 let mut front = test_http_frontend();
5800 front.path = CommandPathRule::regex(value.clone());
5801 assert!(
5802 matches!(
5803 router.add_http_front(&front),
5804 Err(RouterError::InvalidPathRule(_))
5805 ),
5806 "the path regex {value:?} must be refused as an invalid path rule",
5807 );
5808 }
5809 }
5810
5811 /// The FIRST of the two 401 shapes `doc/configure.md` names: a frontend
5812 /// with no `cluster_id`. It narrows exactly as the
5813 /// `redirect = "unauthorized"` shape does, which is the substance of
5814 /// "both answer 401 … through the very same `path`/`path_type` matching".
5815 ///
5816 /// Asserted through `Router::add_http_front`, because that is where the
5817 /// shape becomes a route and the claim is about the FRONTEND, not about
5818 /// `Route::Deny`. The two sub-shapes take different branches and both are
5819 /// covered here: a bare clusterless frontend sets no policy field, so
5820 /// `add_http_front`'s `has_policy` is false and it maps to `Route::Deny`
5821 /// directly; one that spells `redirect = "forward"` flips `has_policy`,
5822 /// runs `Frontend::new`, and is coerced to unauthorized there instead.
5823 /// Nothing in the anchoring cares which — that is the point.
5824 ///
5825 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5826 /// make the body `Regex::new(value).ok()`. The unanchored `/admin` covers
5827 /// the family again and both fall-through assertions fail with
5828 /// `left: None, right: Some("FALLBACK")`.
5829 #[test]
5830 fn a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule() {
5831 for spells_forward in [false, true] {
5832 let mut router = Router::new();
5833 let mut clusterless = test_http_frontend();
5834 clusterless.hostname = "www.example.com".to_owned();
5835 clusterless.cluster_id = None;
5836 clusterless.path = CommandPathRule::regex("/admin".to_owned());
5837 clusterless.method = Some("GET".to_owned());
5838 if spells_forward {
5839 clusterless.redirect = Some(RedirectPolicy::Forward as i32);
5840 }
5841 router
5842 .add_http_front(&clusterless)
5843 .expect("a clusterless frontend must register as a deny");
5844 assert!(add_path_rule(
5845 &mut router,
5846 PathRule::Prefix("/".to_owned()),
5847 GET,
5848 "FALLBACK",
5849 ));
5850
5851 let resolve = |path: &str| {
5852 router
5853 .lookup("www.example.com", path, &Method::Get)
5854 .expect("every path below is covered by the `/` prefix rule")
5855 };
5856
5857 let denied = resolve("/admin");
5858 assert_eq!(
5859 denied.redirect,
5860 RedirectPolicy::Unauthorized,
5861 "a clusterless frontend must still deny the path it spells \
5862 (spells_forward={spells_forward})",
5863 );
5864 assert_eq!(denied.cluster_id, None);
5865
5866 for now_reaching_the_backend in ["/admin/secret", "/administrator"] {
5867 assert_eq!(
5868 resolve(now_reaching_the_backend).cluster_id.as_deref(),
5869 Some("FALLBACK"),
5870 "BREAKING: the anchored clusterless deny no longer covers \
5871 {now_reaching_the_backend} (spells_forward={spells_forward})",
5872 );
5873 }
5874 }
5875 }
5876
5877 /// The documented remediation for the deny narrowing: a deny-shaped
5878 /// `REGEX` meant as a subtree is widened with a trailing `.*` and covers
5879 /// the family again. It is a regex and not a path-segment rule, so
5880 /// `/admin.*` takes `/administrator` back with `/admin/secret` — widening
5881 /// is not surgical, and that is worth seeing.
5882 ///
5883 /// Declared through `Router::add_http_front` carrying
5884 /// `redirect = "unauthorized"`, which is the SECOND of the two 401 shapes
5885 /// `doc/configure.md` names. The first — a frontend with no `cluster_id` —
5886 /// is covered by
5887 /// `a_clusterless_regex_frontend_denies_through_the_same_anchored_path_rule`.
5888 /// `anchoring_narrows_a_deny_path_regex_to_the_exact_path` covers NEITHER
5889 /// frontend shape: it builds `Route::Deny` directly through
5890 /// `add_tree_rule`, which is the route those two shapes resolve TO, and
5891 /// says nothing about either resolving to it.
5892 ///
5893 /// To SEE THIS RED: the anchoring mutation does NOT redden this test, and
5894 /// cannot — `/admin.*` covers these paths anchored or not, which is
5895 /// exactly why it is the remediation. Break the wildcard instead: take the
5896 /// configured value literally in `PathRule::anchored_regex`, as
5897 /// `Regex::new(&format!("\\A(?:{})\\z", regex::escape(value)))`. Every
5898 /// denied path below then falls through to `FALLBACK`.
5899 #[test]
5900 fn widening_a_deny_path_regex_with_a_trailing_wildcard_covers_the_subtree_again() {
5901 let mut router = Router::new();
5902 let mut deny = test_http_frontend();
5903 deny.hostname = "www.example.com".to_owned();
5904 deny.cluster_id = Some("DENIED".into());
5905 deny.path = CommandPathRule::regex("/admin.*".to_owned());
5906 deny.method = Some("GET".to_owned());
5907 deny.redirect = Some(RedirectPolicy::Unauthorized as i32);
5908 router
5909 .add_http_front(&deny)
5910 .expect("the widened deny frontend must register");
5911 assert!(add_path_rule(
5912 &mut router,
5913 PathRule::Prefix("/".to_owned()),
5914 GET,
5915 "FALLBACK",
5916 ));
5917
5918 let resolve = |path: &str| {
5919 router
5920 .lookup("www.example.com", path, &Method::Get)
5921 .expect("every path below is covered by the `/` prefix rule")
5922 };
5923
5924 for denied in ["/admin", "/admin/secret", "/administrator"] {
5925 assert_eq!(
5926 resolve(denied).redirect,
5927 RedirectPolicy::Unauthorized,
5928 "the widened deny must cover {denied} again",
5929 );
5930 }
5931 assert_eq!(
5932 resolve("/other").cluster_id.as_deref(),
5933 Some("FALLBACK"),
5934 "widening the deny must not turn it into a catch-all",
5935 );
5936 assert_eq!(
5937 resolve("/other").redirect,
5938 RedirectPolicy::Forward,
5939 "a path outside the widened family must still be forwarded",
5940 );
5941 }
5942
5943 /// The matched request path is the request-target as it arrives, QUERY
5944 /// STRING INCLUDED, so anchoring bites a query string the way it already
5945 /// bit `path_type = "EQUALS"`. A trailing `.*` answers that only for a
5946 /// pattern open at the END: after a `$` it is dead text, because nothing
5947 /// can follow end-of-text. A suffix rule that must tolerate a query string
5948 /// has to spell it out.
5949 ///
5950 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5951 /// make the body `Regex::new(value).ok()`. `"/abc"` against `"/abc?x=1"`
5952 /// fails first, and `".*\.js([?].*)?"` against `"/static/app.json"` fails
5953 /// with it, the optional group matching empty after a substring `.js`.
5954 #[test]
5955 fn an_anchored_path_regex_must_span_the_query_string_it_is_matched_against() {
5956 assert_regex_routing(&[
5957 ("/abc", "/abc", true),
5958 ("/abc", "/abc?x=1", false),
5959 // Appending `.*` after a `$` does nothing at all.
5960 ("^/abc$.*", "/abc", true),
5961 ("^/abc$.*", "/abc?x=1", false),
5962 // What a suffix rule surviving a query string has to look like.
5963 (r".*\.js([?].*)?", "/static/app.js", true),
5964 (r".*\.js([?].*)?", "/static/app.js?v=2", true),
5965 (r".*\.js([?].*)?", "/static/app.json", false),
5966 ]);
5967
5968 // "not a regression": the plain suffix rule did not match a
5969 // query-string request before the anchoring either. Measured against
5970 // the BARE, unwrapped regex — the 2.2.1 behaviour — so this row is a
5971 // control and does not move when `anchored_regex` does.
5972 assert!(
5973 !Regex::new(r"\.js$")
5974 .expect("the bare suffix pattern compiles")
5975 .is_match(b"/static/app.js?v=2"),
5976 "the unanchored `\\.js$` of 2.2.1 did not match a query-string \
5977 request either, so that half is not a regression",
5978 );
5979 }
5980
5981 /// Precedence between a method-less `REGEX` and a method-less `PREFIX`
5982 /// turns on whether the regex matches the request path AT ALL, and
5983 /// anchoring changes that once a query string is involved.
5984 ///
5985 /// `doc/configure.md`'s third table row is the `/a.*` case: `GET /abc`
5986 /// goes to the prefix, and `GET /abc?x=1` flips to the regex, because
5987 /// `/abc` no longer spans the request path while `/a.*` still does. With
5988 /// the regex `/abc`, which anchored does not match `/abc?x=1` at all, the
5989 /// flip does NOT happen and both requests go to the prefix — the prefix
5990 /// has merely lost its rival. The regex did not lose the contest, it
5991 /// stopped entering it: declared alone it leaves `GET /abc?x=1` unrouted.
5992 /// That distinction is stated in prose and is what these six cases pin.
5993 ///
5994 /// Both rules are method-less and the regex is declared FIRST, the exact
5995 /// shape the documented table measures; the regex is built through
5996 /// `PathRule::from_config`, so the anchoring is in play.
5997 ///
5998 /// To SEE THIS RED: remove the anchoring in `PathRule::anchored_regex` —
5999 /// make the body `Regex::new(value).ok()`. The unanchored `/abc` matches
6000 /// `/abc?x=1` as a substring and becomes a candidate again: the
6001 /// both-go-to-the-prefix case fails with
6002 /// `left: Some("REGEX"), right: Some("PREFIX")` and the declared-alone
6003 /// case fails with `left: Some("REGEX"), right: None`.
6004 #[test]
6005 fn an_anchored_regex_that_cannot_span_the_query_string_stops_being_a_candidate() {
6006 let configured = |pattern: &str| {
6007 PathRule::from_config(CommandPathRule::regex(pattern.to_owned()))
6008 .unwrap_or_else(|| panic!("the regex path rule {pattern:?} must build"))
6009 };
6010 let against_the_whole_path_prefix = |pattern: &str, path: &str| {
6011 winner_of(
6012 (configured(pattern), NO_METHOD, "REGEX"),
6013 (PathRule::Prefix("/abc".to_owned()), NO_METHOD, "PREFIX"),
6014 path,
6015 )
6016 };
6017
6018 // A regex that spans the query string: the documented flip, unchanged.
6019 assert_eq!(
6020 against_the_whole_path_prefix("/a.*", "/abc").as_deref(),
6021 Some("PREFIX"),
6022 "the whole-path prefix declared after the regex must still win",
6023 );
6024 assert_eq!(
6025 against_the_whole_path_prefix("/a.*", "/abc?x=1").as_deref(),
6026 Some("REGEX"),
6027 "with a query string `/abc` no longer spans the request path, so \
6028 a regex that does wins",
6029 );
6030
6031 // A regex that does not span it: the flip disappears entirely.
6032 assert_eq!(
6033 against_the_whole_path_prefix("/abc", "/abc").as_deref(),
6034 Some("PREFIX"),
6035 "the prefix wins here exactly as it does against `/a.*`",
6036 );
6037 assert_eq!(
6038 against_the_whole_path_prefix("/abc", "/abc?x=1").as_deref(),
6039 Some("PREFIX"),
6040 "an anchored `/abc` cannot match `/abc?x=1`, so adding a query \
6041 string changes nothing and the prefix keeps the request",
6042 );
6043
6044 // …and the reason is candidacy, not defeat.
6045 assert_eq!(
6046 regex_rule_routes("/abc", "/abc").as_deref(),
6047 Some("REGEX"),
6048 "declared alone, the regex answers the request it spells",
6049 );
6050 assert_eq!(
6051 regex_rule_routes("/abc", "/abc?x=1"),
6052 None,
6053 "declared alone, it leaves the query-string request UNROUTED — it \
6054 is not a candidate any more",
6055 );
6056 }
6057
6058 /// Every branch of an alternation in a regex hostname SEGMENT is
6059 /// anchored, not just the first and the last.
6060 ///
6061 /// This test is the INVERSION of the test formerly named
6062 /// `an_alternating_regex_hostname_segment_is_still_anchored_at_one_end_only`
6063 /// — a name no longer in the tree, this one having replaced it —
6064 /// which pinned the defect so that closing it would be "a deliberate
6065 /// change, not a silent one" and asked, in its own comment, to be
6066 /// inverted rather than deleted when the fix landed. This is that
6067 /// deliberate change (sozu#1356), and the old name is kept here so the
6068 /// behaviour change leaves a trace.
6069 ///
6070 /// `pattern_trie.rs`'s `anchored_segment` now builds
6071 /// `\A(?:{s})\z`. Ungrouped, `|` binds looser than concatenation, so
6072 /// `\Aa|b\z` parsed as `(\Aa)|(b\z)`: a label merely STARTING with `a`
6073 /// or merely ENDING with `b` reached the frontend.
6074 ///
6075 /// Two branches cannot show what this test claims, because with two
6076 /// branches "first and last" is every branch there is. The three-branch
6077 /// case below has a MIDDLE branch, which ungrouped keeps NEITHER anchor
6078 /// and matches as a bare substring anywhere in the label — strictly
6079 /// worse than the half-anchored ends.
6080 ///
6081 /// To SEE THIS RED: drop the group in `pattern_trie.rs`'s
6082 /// `anchored_segment` — build `format!("\\A{segment}\\z")`, the form
6083 /// this tree carried up to 2.2.1, and relax the `debug_assert!` beside it,
6084 /// which in a debug build fires first. `a.example.com` still resolves,
6085 /// and the `axx.example.com` assertion fails with
6086 /// `left: Some("ALTERNATION"), right: None`.
6087 #[test]
6088 fn an_alternating_regex_hostname_segment_is_anchored_on_every_branch() {
6089 let resolve_with = |pattern: &[u8], hostname: &str| {
6090 let mut router = Router::new();
6091 assert!(router.add_tree_rule(
6092 pattern,
6093 &PathRule::Prefix("/".to_owned()),
6094 &MethodRule::new(Some("GET".to_owned())),
6095 &Route::ClusterId("ALTERNATION".into()),
6096 ));
6097 router
6098 .lookup(hostname, "/", &Method::Get)
6099 .ok()
6100 .and_then(|result| result.cluster_id)
6101 };
6102
6103 // Two branches: each one keeps BOTH anchors now.
6104 assert_eq!(
6105 resolve_with(b"/a|b/.example.com", "a.example.com").as_deref(),
6106 Some("ALTERNATION"),
6107 "the segment must still match what it was written for",
6108 );
6109 assert_eq!(
6110 resolve_with(b"/a|b/.example.com", "b.example.com").as_deref(),
6111 Some("ALTERNATION"),
6112 "and the other branch too",
6113 );
6114 assert_eq!(
6115 resolve_with(b"/a|b/.example.com", "axx.example.com"),
6116 None,
6117 "the first branch must be anchored at its END too",
6118 );
6119 assert_eq!(
6120 resolve_with(b"/a|b/.example.com", "xxb.example.com"),
6121 None,
6122 "the last branch must be anchored at its START too",
6123 );
6124 assert_eq!(
6125 resolve_with(b"/a|b/.example.com", "xx.example.com"),
6126 None,
6127 "a label matching neither branch must still miss",
6128 );
6129
6130 // Three branches. `zzbzz` is the case two branches cannot express:
6131 // ungrouped, the MIDDLE branch carries no anchor at either end, so
6132 // the bare label `b` matches in the middle of any label at all.
6133 for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6134 assert_eq!(
6135 resolve_with(b"/a|b|c/.example.com", whole).as_deref(),
6136 Some("ALTERNATION"),
6137 "{whole} is a whole-label match for one branch and must route",
6138 );
6139 }
6140 for leak in [
6141 "axx.example.com",
6142 "xxc.example.com",
6143 "zzbzz.example.com",
6144 "bzz.example.com",
6145 "zzb.example.com",
6146 ] {
6147 assert_eq!(
6148 resolve_with(b"/a|b|c/.example.com", leak),
6149 None,
6150 "{leak} matches no branch WHOLE and must not route",
6151 );
6152 }
6153
6154 // The control for all of the above, built by hand against the
6155 // `regex` crate rather than through the trie: the UNGROUPED wrapper
6156 // — the form this tree carried up to 2.2.1 — leaves the middle
6157 // branch anchored at neither end. No mutation of `anchored_segment`
6158 // can move this assertion, which is the point: it measures why the
6159 // group is there.
6160 let two = Regex::new("\\Aa|b\\z").expect("the ungrouped wrapper compiles");
6161 for anywhere in ["axx", "xxb"] {
6162 assert!(
6163 two.is_match(anywhere.as_bytes()),
6164 "{} leaks on {anywhere}",
6165 two.as_str(),
6166 );
6167 }
6168 let ungrouped = Regex::new("\\Aa|b|c\\z").expect("the ungrouped wrapper compiles");
6169 for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6170 assert!(
6171 ungrouped.is_match(anywhere.as_bytes()),
6172 "{} leaks on {anywhere}",
6173 ungrouped.as_str(),
6174 );
6175 }
6176 let grouped = Regex::new("\\A(?:a|b|c)\\z").expect("the grouped wrapper compiles");
6177 for anywhere in ["axx", "zzbzz", "bzz", "zzb", "xxc"] {
6178 assert!(
6179 !grouped.is_match(anywhere.as_bytes()),
6180 "{} must refuse {anywhere}",
6181 grouped.as_str(),
6182 );
6183 }
6184 }
6185
6186 /// Declare one regex hostname frontend the way a configured frontend
6187 /// declares it — through `Router::add_http_front`, so the hostname goes
6188 /// through `DomainRule::from_str` and `convert_regex_domain_rule` — and
6189 /// resolve `hostname`.
6190 ///
6191 /// `position` picks the code path, and the two are genuinely different
6192 /// code: `Pre`/`Post` match the WHOLE host against the single regex
6193 /// `convert_regex_domain_rule` assembles, while `Tree` matches label by
6194 /// label against `pattern_trie`'s per-segment regexes.
6195 fn regex_host_routes(position: RulePosition, pattern: &str, hostname: &str) -> Option<String> {
6196 let mut front = test_http_frontend();
6197 front.hostname = pattern.to_owned();
6198 front.position = position;
6199 front.cluster_id = Some("REGEX-HOST".into());
6200 let mut router = Router::new();
6201 router
6202 .add_http_front(&front)
6203 .unwrap_or_else(|error| panic!("the regex hostname {pattern:?} must build: {error}"));
6204 router
6205 .lookup(hostname, "/", &Method::Get)
6206 .ok()
6207 .and_then(|result| result.cluster_id)
6208 .map(|cluster_id| cluster_id.to_string())
6209 }
6210
6211 /// The companion of
6212 /// `an_alternating_regex_hostname_segment_is_anchored_on_every_branch`
6213 /// for the OTHER hostname code path: a pre/post rule never touches the
6214 /// trie, it matches the whole host against the single regex
6215 /// `convert_regex_domain_rule` assembles, and that assembly carried the
6216 /// same ungrouped `\A` … `\z` (sozu#1356).
6217 ///
6218 /// The exact assembled pattern is asserted, so "grouped" is pinned and
6219 /// not merely implied by the routing verdicts.
6220 ///
6221 /// Two branches cannot show what this test claims — with two branches
6222 /// "first and last" is every branch. The three-branch case has a MIDDLE
6223 /// branch, which ungrouped keeps NEITHER anchor: it is a bare `b`,
6224 /// matching any hostname on earth that contains a `b`, including one on
6225 /// a different registrable domain entirely.
6226 ///
6227 /// To SEE THIS RED: drop the group in `convert_regex_domain_rule` —
6228 /// replace the `push_str`/`push` calls in the `/` arm with the single
6229 /// `result.push_str(r)` this tree carried up to 2.2.1. The
6230 /// `axx.example.com` row then fails with
6231 /// `left: Some("REGEX-HOST"), right: None`, and the exact-pattern
6232 /// assertion below it with
6233 /// `left: "\\Aa|b|c\\.example\\.com\\z", right: "\\A(?:a|b|c)\\.example\\.com\\z"`.
6234 #[test]
6235 fn every_branch_of_an_alternating_regex_hostname_rule_is_anchored() {
6236 for position in [RulePosition::Pre, RulePosition::Post] {
6237 for whole in ["a.example.com", "b.example.com", "c.example.com"] {
6238 assert_eq!(
6239 regex_host_routes(position, "/a|b|c/.example.com", whole).as_deref(),
6240 Some("REGEX-HOST"),
6241 "{position:?}: {whole} is a whole-host match for one branch and must route",
6242 );
6243 }
6244 for leak in [
6245 "axx.example.com",
6246 "xxc.example.com",
6247 "zzbzz.example.com",
6248 "b.example.com.evil.org",
6249 "zzbzz.evil.org",
6250 ] {
6251 assert_eq!(
6252 regex_host_routes(position, "/a|b|c/.example.com", leak),
6253 None,
6254 "{position:?}: {leak} matches no branch WHOLE and must not route",
6255 );
6256 }
6257 }
6258
6259 // The exact assembled pattern, so "grouped" is asserted and not
6260 // merely implied by the routing verdicts above.
6261 assert_eq!(
6262 convert_regex_domain_rule("/a|b|c/.example.com")
6263 .expect("an alternating hostname segment must convert"),
6264 "\\A(?:a|b|c)\\.example\\.com\\z",
6265 "each regex segment must be wrapped in a NON-capturing group",
6266 );
6267
6268 // The control, built by hand against the `regex` crate rather than
6269 // through `convert_regex_domain_rule`: the UNGROUPED assembly — the
6270 // form this tree carried up to 2.2.1 — parses as
6271 // `(\Aa)|(b)|(c\.example\.com\z)`. The middle branch is a bare `b`,
6272 // so it matches a host on an unrelated registrable domain. No
6273 // mutation of `convert_regex_domain_rule` can move these
6274 // assertions, which is the point: they measure why the group is
6275 // there.
6276 for (assembly, leaks) in [
6277 (
6278 "\\Aa|b|c\\.example\\.com\\z",
6279 &[
6280 "axx.example.com",
6281 "zzbzz.example.com",
6282 "b.example.com.evil.org",
6283 "zzbzz.evil.org",
6284 ][..],
6285 ),
6286 (
6287 "\\Aapi|admin\\.example\\.com\\z",
6288 &["apifoo.example.com", "xadmin.example.com"][..],
6289 ),
6290 ] {
6291 let ungrouped = Regex::new(assembly).expect("the ungrouped assembly compiles");
6292 for anywhere in leaks {
6293 assert!(
6294 ungrouped.is_match(anywhere.as_bytes()),
6295 "{} leaks on {anywhere}",
6296 ungrouped.as_str(),
6297 );
6298 }
6299 }
6300
6301 // `doc/configure.md`'s own worked example, asserted here because it
6302 // is written there as a measurement: the frontend
6303 // `/api|admin/.example.com` used to also serve `apifoo.example.com`
6304 // and `xadmin.example.com`, and the remediation the same paragraph
6305 // gives has to keep working.
6306 for (pattern, hostname, must_route) in [
6307 ("/api|admin/.example.com", "api.example.com", true),
6308 ("/api|admin/.example.com", "admin.example.com", true),
6309 ("/api|admin/.example.com", "apifoo.example.com", false),
6310 ("/api|admin/.example.com", "xadmin.example.com", false),
6311 ("/api.*|.*admin/.example.com", "api.example.com", true),
6312 ("/api.*|.*admin/.example.com", "admin.example.com", true),
6313 ("/api.*|.*admin/.example.com", "apifoo.example.com", true),
6314 ("/api.*|.*admin/.example.com", "xadmin.example.com", true),
6315 ] {
6316 assert_eq!(
6317 regex_host_routes(RulePosition::Pre, pattern, hostname).is_some(),
6318 must_route,
6319 "hostname regex {pattern:?} against {hostname:?}",
6320 );
6321 }
6322
6323 // `captures_len` must be untouched by the wrapping — see
6324 // `a_host_capture_rewrite_resolves_to_the_operators_own_group` for
6325 // the consequence when it is not.
6326 let compiled = |pattern: &str| {
6327 Regex::new(&convert_regex_domain_rule(pattern).expect("the hostname must convert"))
6328 .expect("the assembled hostname regex must compile")
6329 };
6330 assert_eq!(
6331 compiled("/a|b|c/.example.com").captures_len(),
6332 1,
6333 "a pattern with no operator group must stay at the implicit group 0 alone",
6334 );
6335 assert_eq!(
6336 compiled("/cdn([0-9]+)/.example.com").captures_len(),
6337 2,
6338 "the operator's own group must stay at index 1",
6339 );
6340 }
6341
6342 /// A `*` label in a hostname that ALSO carries a regex segment means two
6343 /// different things depending on the rule position, and
6344 /// `doc/configure.md` § "Regex hostname segments" now carries the table
6345 /// this test asserts.
6346 ///
6347 /// On the trie a `*` label is the ordinary single-label wildcard: the
6348 /// trie splits on `.` and `TrieNode` stores it in its `wildcard` slot,
6349 /// untouched by anything here. On `Pre`/`Post` the whole host is matched
6350 /// by the single pattern `convert_regex_domain_rule` assembles, where the
6351 /// `*` is now a LITERAL — the sozu#1356 escaping. Up to and including
6352 /// 2.2.1 it was neither: an unescaped `*` was a regex quantifier over the
6353 /// preceding atom, which for a leftmost label is the opening `\A`, so the
6354 /// pattern was not anchored at its start and took any host ending in
6355 /// `.x.example.com` at any depth.
6356 ///
6357 /// The remediation the section gives for `Pre`/`Post` is asserted too,
6358 /// because a documented remediation that does not work is worse than
6359 /// none.
6360 ///
6361 /// To SEE THIS RED: drop the escaping in the literal arm of
6362 /// `convert_regex_domain_rule` — push `r` instead of `®ex::escape(r)`
6363 /// at both exits. The `Pre` row for `zz.x.example.com` then routes and
6364 /// fails with `left: true, right: false`. (That mutation also reddens
6365 /// `a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern` and
6366 /// `convert_regex`.)
6367 #[test]
6368 fn a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post() {
6369 // The table in `doc/configure.md`, both rows, all three positions.
6370 for (position, host, must_route) in [
6371 (RulePosition::Tree, "zz.x.example.com", true),
6372 (RulePosition::Tree, "*.x.example.com", true),
6373 (RulePosition::Pre, "zz.x.example.com", false),
6374 (RulePosition::Pre, "*.x.example.com", true),
6375 (RulePosition::Post, "zz.x.example.com", false),
6376 (RulePosition::Post, "*.x.example.com", true),
6377 ] {
6378 assert_eq!(
6379 regex_host_routes(position, "*./x/.example.com", host).is_some(),
6380 must_route,
6381 "{position:?}: `*./x/.example.com` against {host:?}",
6382 );
6383 }
6384
6385 // Common to every position, and the reason the trie row is a
6386 // wildcard and not a free-for-all: `*` is ONE label.
6387 for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
6388 for miss in ["x.example.com", "a.b.x.example.com"] {
6389 assert_eq!(
6390 regex_host_routes(position, "*./x/.example.com", miss),
6391 None,
6392 "{position:?}: a `*` label is exactly one label, so {miss:?} misses",
6393 );
6394 }
6395 }
6396
6397 // What 2.2.1 did on `Pre`/`Post`, built by hand against the crate:
6398 // `\A*` repeats a zero-width assertion, so the pattern was not
6399 // anchored at its start. No mutation of the escaping can move this.
6400 let quantified =
6401 Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6402 for anywhere in [
6403 "evil.attacker.x.example.com",
6404 "anything.at.all.x.example.com",
6405 "zzz.x.example.com",
6406 ] {
6407 assert!(
6408 quantified.is_match(anywhere.as_bytes()),
6409 "{} took {anywhere} at any depth",
6410 quantified.as_str(),
6411 );
6412 }
6413 assert!(
6414 !quantified.is_match(b"x.example.com"),
6415 "{} still required a leading label",
6416 quantified.as_str(),
6417 );
6418
6419 // The remediation `doc/configure.md` gives for `Pre`/`Post`: a regex
6420 // segment spelling the single label out.
6421 for position in [RulePosition::Pre, RulePosition::Post] {
6422 for (host, must_route) in [
6423 ("zz.x.example.com", true),
6424 ("*.x.example.com", true),
6425 ("a.b.x.example.com", false),
6426 ("x.example.com", false),
6427 ] {
6428 assert_eq!(
6429 regex_host_routes(position, "/[^.]*/./x/.example.com", host).is_some(),
6430 must_route,
6431 "{position:?}: the documented `Pre`/`Post` remediation against {host:?}",
6432 );
6433 }
6434 }
6435 }
6436
6437 /// sozu#633: `hostname = "*"` with `position = "POST"` is the catch-all
6438 /// `doc/configure.md` documents — `DomainRule::Any`, which matches every
6439 /// host, an IP literal or a single-label name included — while the same
6440 /// `*` on the trie (the default position) is the one-label wildcard and
6441 /// matches only a single-label host. The last block pins that a `Post`
6442 /// rule is consulted only once the trie matched nothing.
6443 ///
6444 /// To SEE THIS RED: in `DomainRule::from_str`, change the `s == "*"`
6445 /// test to `s == "**"`. A bare `*` then falls through to the literal
6446 /// arm, and the `127.0.0.1` row fails with `left: None, right:
6447 /// Some("REGEX-HOST")`.
6448 #[test]
6449 fn a_bare_star_hostname_is_a_catch_all_on_post_and_one_label_on_the_trie() {
6450 for host in [
6451 "127.0.0.1",
6452 "localhost",
6453 "penguin.linux.test",
6454 "10.0.0.7",
6455 "www.example.com",
6456 ] {
6457 assert_eq!(
6458 regex_host_routes(RulePosition::Post, "*", host).as_deref(),
6459 Some("REGEX-HOST"),
6460 "Post: `*` is the catch-all and must route {host:?}",
6461 );
6462 }
6463
6464 for (host, must_route) in [
6465 ("localhost", true),
6466 ("127.0.0.1", false),
6467 ("penguin.linux.test", false),
6468 ("www.example.com", false),
6469 ] {
6470 assert_eq!(
6471 regex_host_routes(RulePosition::Tree, "*", host).is_some(),
6472 must_route,
6473 "Tree: `*` is exactly one label, against {host:?}",
6474 );
6475 }
6476
6477 let mut router = Router::new();
6478 for (hostname, position, cluster_id) in [
6479 ("*", RulePosition::Post, "CATCH-ALL"),
6480 ("www.example.com", RulePosition::Tree, "EXAMPLE"),
6481 ] {
6482 let mut front = test_http_frontend();
6483 front.hostname = hostname.to_owned();
6484 front.position = position;
6485 front.cluster_id = Some(cluster_id.into());
6486 router
6487 .add_http_front(&front)
6488 .unwrap_or_else(|error| panic!("{hostname:?} must build: {error}"));
6489 }
6490 for (host, expected) in [
6491 ("www.example.com", "EXAMPLE"),
6492 ("127.0.0.1", "CATCH-ALL"),
6493 ("api.example.com", "CATCH-ALL"),
6494 ] {
6495 assert_eq!(
6496 router
6497 .lookup(host, "/", &Method::Get)
6498 .ok()
6499 .and_then(|result| result.cluster_id)
6500 .as_deref(),
6501 Some(expected),
6502 "a `Post` catch-all answers only what the trie did not: {host:?}",
6503 );
6504 }
6505 }
6506
6507 /// A `.*` inside a regex segment does not stop at the label boundary, and
6508 /// where it stops depends on the rule POSITION — the two hostname paths
6509 /// genuinely differ here. A trie rule matches each segment against one
6510 /// label, so `.*` cannot leave it. A `Pre`/`Post` rule is matched by the
6511 /// single whole-host pattern `convert_regex_domain_rule` assembles, in
6512 /// which `.` matches the `.` separator like any other character.
6513 ///
6514 /// Pre-existing and NOT introduced by the anchoring, but
6515 /// `doc/configure.md` walks operators into it: the remediation it gives
6516 /// for the narrowing is exactly a `.*`. The section now names the
6517 /// divergence and recommends `[^.]*` on `Pre`/`Post`, so all three
6518 /// measurements it makes are pinned here.
6519 ///
6520 /// To SEE THIS RED: escape the REGEX-segment arm of
6521 /// `convert_regex_domain_rule` as well as the literal arm — push
6522 /// `®ex::escape(r)` in the `/` arm too. Every `Pre` row then goes
6523 /// false, starting with `api.example.com`, because the segment stops
6524 /// being a pattern at all.
6525 #[test]
6526 fn a_wildcard_in_a_hostname_segment_crosses_labels_on_pre_and_post_only() {
6527 // Trie: one segment, one label. `.*` cannot escape the label.
6528 for (hostname, must_route) in [
6529 ("api.example.com", true),
6530 ("admin.example.com", true),
6531 ("apifoo.example.com", true),
6532 ("api.foo.example.com", false),
6533 ("zz.admin.example.com", false),
6534 ] {
6535 assert_eq!(
6536 regex_host_routes(RulePosition::Tree, "/api.*|.*admin/.example.com", hostname)
6537 .is_some(),
6538 must_route,
6539 "trie rule `/api.*|.*admin/` against {hostname:?}",
6540 );
6541 }
6542
6543 // Pre/Post: one whole-host pattern, in which `.` matches the
6544 // separator. The same rule reaches subdomains the operator did not
6545 // write. This is the clause `doc/configure.md` now carries.
6546 for position in [RulePosition::Pre, RulePosition::Post] {
6547 for hostname in [
6548 "api.example.com",
6549 "admin.example.com",
6550 "apifoo.example.com",
6551 "api.foo.example.com",
6552 "zz.admin.example.com",
6553 ] {
6554 assert!(
6555 regex_host_routes(position, "/api.*|.*admin/.example.com", hostname).is_some(),
6556 "{position:?}: `.*` spans the label separator, so {hostname:?} routes",
6557 );
6558 }
6559 }
6560
6561 // ... and the remediation that section recommends for `Pre`/`Post`.
6562 for position in [RulePosition::Pre, RulePosition::Post] {
6563 for (hostname, must_route) in [
6564 ("api.example.com", true),
6565 ("admin.example.com", true),
6566 ("apifoo.example.com", true),
6567 ("xadmin.example.com", true),
6568 ("api.foo.example.com", false),
6569 ("zz.admin.example.com", false),
6570 ] {
6571 assert_eq!(
6572 regex_host_routes(position, "/api[^.]*|[^.]*admin/.example.com", hostname,)
6573 .is_some(),
6574 must_route,
6575 "{position:?}: `[^.]*` spells the boundary out, {hostname:?}",
6576 );
6577 }
6578 }
6579 }
6580
6581 /// The LITERAL-label arm of `convert_regex_domain_rule` split the anchors
6582 /// exactly as the regex-segment arm did, one `else` away from it: a label
6583 /// the operator never wrapped in slashes was pushed verbatim into the
6584 /// assembly, so a `|` in it was a live alternation. Measured on the tree
6585 /// as it stood, `/a/.x|y.com` assembled to `\A(?:a)\.x|y\.com\z` —
6586 /// `(\A(?:a)\.x)|(y\.com\z)` — and matched `a.xZZZ` and `ZZZy.com`.
6587 ///
6588 /// The fix is `regex::escape`, not a group. A literal label is a literal,
6589 /// so grouping would confine the alternation while leaving `+`, `?`, `*`,
6590 /// `^` and `$` live inside the same arm — `b+c` matching `bbbc` is the
6591 /// same defect with a different operator. Escaping is a no-op in matching
6592 /// behaviour for every label a real hostname can carry: an LDH label is
6593 /// `[A-Za-z0-9-]`, `escape` touches only the `-`, and `\-` is `-` outside
6594 /// a character class. That parity is asserted below rather than asserted
6595 /// of the hostname grammar in the abstract.
6596 ///
6597 /// To SEE THIS RED: drop the escaping in the literal arm of
6598 /// `convert_regex_domain_rule` — push `r` instead of `®ex::escape(r)`
6599 /// at both of its exits. The `a.xZZZ` row then fails with
6600 /// `left: true, right: false`. That mutation reddens THREE tests, not
6601 /// just this one: `convert_regex`'s two `*` rows go back to the
6602 /// unescaped spelling, and
6603 /// `a_star_label_is_a_wildcard_on_the_trie_and_a_literal_on_pre_and_post`
6604 /// loses the `Pre`/`Post` half of its table.
6605 #[test]
6606 fn a_literal_hostname_label_is_escaped_and_cannot_become_a_pattern() {
6607 // The three routing verdicts the defect turned on, through the
6608 // frontend surface that carries it.
6609 for (hostname, must_route) in [("a.x|y.com", true), ("a.xZZZ", false), ("ZZZy.com", false)]
6610 {
6611 assert_eq!(
6612 regex_host_routes(RulePosition::Pre, "/a/.x|y.com", hostname).is_some(),
6613 must_route,
6614 "literal label `x|y` against {hostname:?}",
6615 );
6616 }
6617 assert_eq!(
6618 convert_regex_domain_rule("/a/.x|y.com").expect("the hostname must convert"),
6619 "\\A(?:a)\\.x\\|y\\.com\\z",
6620 "a literal label must be escaped, not grouped and not pushed raw",
6621 );
6622
6623 // The control, built by hand: the UNESCAPED assembly is what leaked.
6624 // No mutation of `convert_regex_domain_rule` can move this.
6625 let unescaped = Regex::new("\\A(?:a)\\.x|y\\.com\\z").expect("the raw assembly compiles");
6626 for anywhere in ["a.xZZZ", "ZZZy.com"] {
6627 assert!(
6628 unescaped.is_match(anywhere.as_bytes()),
6629 "{} leaks on {anywhere}",
6630 unescaped.as_str(),
6631 );
6632 }
6633
6634 // Escaping must be inert for every label a hostname can actually
6635 // carry. `-` is the only LDH byte `regex::escape` touches, and `\-`
6636 // is `-` outside a character class.
6637 assert_eq!(
6638 convert_regex_domain_rule("/a/.my-host01.example.com")
6639 .expect("an LDH hostname must convert"),
6640 "\\A(?:a)\\.my\\-host01\\.example\\.com\\z",
6641 "escape only adds a backslash before the `-`",
6642 );
6643 for (hostname, must_route) in [
6644 ("a.my-host01.example.com", true),
6645 ("a.myXhost01.example.com", false),
6646 ("a.my-host01.example.comZZZ", false),
6647 ] {
6648 assert_eq!(
6649 regex_host_routes(RulePosition::Pre, "/a/.my-host01.example.com", hostname)
6650 .is_some(),
6651 must_route,
6652 "escaped LDH label against {hostname:?}",
6653 );
6654 }
6655
6656 // Grouping instead of escaping would have closed the `|` only. This
6657 // is why the arms are treated differently, measured rather than
6658 // argued: the same three quantifiers stay live under a group.
6659 let grouped_not_escaped =
6660 Regex::new("\\A(?:a)\\.(?:b+c)\\.com\\z").expect("the grouped assembly compiles");
6661 assert!(
6662 grouped_not_escaped.is_match(b"a.bbbc.com"),
6663 "{} still repeats the literal `b`",
6664 grouped_not_escaped.as_str(),
6665 );
6666 for (hostname, must_route) in [("a.b+c.com", true), ("a.bbbc.com", false)] {
6667 assert_eq!(
6668 regex_host_routes(RulePosition::Pre, "/a/.b+c.com", hostname).is_some(),
6669 must_route,
6670 "escaped literal label `b+c` against {hostname:?}",
6671 );
6672 }
6673
6674 // Two deliberate behaviour changes, both narrowing, both stated in
6675 // `convert_regex_domain_rule`'s doc comment.
6676 //
6677 // (1) A literal `*` label was a quantifier over the preceding atom.
6678 // `\A*` repeats a zero-width assertion, which matches empty at any
6679 // offset, so the pattern was not anchored at its start at all.
6680 let star_unescaped =
6681 Regex::new("\\A*\\.(?:x)\\.example\\.com\\z").expect("the raw assembly compiles");
6682 assert!(
6683 star_unescaped.is_match(b"evil.attacker.x.example.com"),
6684 "{} is not anchored at its start",
6685 star_unescaped.as_str(),
6686 );
6687 assert_eq!(
6688 regex_host_routes(
6689 RulePosition::Pre,
6690 "*./x/.example.com",
6691 "evil.attacker.x.example.com",
6692 ),
6693 None,
6694 "a literal `*` label must no longer leave the pattern unanchored",
6695 );
6696 assert_eq!(
6697 regex_host_routes(RulePosition::Pre, "*./x/.example.com", "*.x.example.com").as_deref(),
6698 Some("REGEX-HOST"),
6699 "it matches the literal label the operator wrote, and only that",
6700 );
6701
6702 // (2) A literal label carrying an unbalanced `(` or `[` made the
6703 // assembly fail to compile and the frontend was REJECTED. It is now
6704 // accepted and matches exactly the host that was typed. Assembled at
6705 // run time because the unbalanced literal is the point.
6706 for bracket in ["(", "["] {
6707 let hostname = format!("/a/.b{bracket}c.com");
6708 assert!(
6709 convert_regex_domain_rule(&hostname).is_some(),
6710 "{hostname} must convert",
6711 );
6712 assert_eq!(
6713 regex_host_routes(RulePosition::Pre, &hostname, &format!("a.b{bracket}c.com"))
6714 .as_deref(),
6715 Some("REGEX-HOST"),
6716 "{hostname} must match the literal host it spells",
6717 );
6718 assert_eq!(
6719 regex_host_routes(RulePosition::Pre, &hostname, "a.bc.com"),
6720 None,
6721 "{hostname} must match nothing else",
6722 );
6723 }
6724 }
6725
6726 /// `$HOST[n]` rewrite indices are read from `caps.iter().skip(1)` with
6727 /// the buffer sized by `captures_len()`, so the anchoring wrapper has to
6728 /// be NON-capturing: a capturing `(` … `)` shifts every index by one and
6729 /// silently rewrites traffic to the wrong target — a wrong `Host` header
6730 /// and a wrong backend, with nothing in the configuration to show for it.
6731 ///
6732 /// Both hostname code paths are asserted, because they read the capture
6733 /// from two DIFFERENT regexes that have to agree: a pre/post rule reads
6734 /// the whole-host regex `convert_regex_domain_rule` assembles
6735 /// (`RouteResult::new_no_trie`), a tree rule reads `pattern_trie`'s
6736 /// per-segment regex (`RouteResult::new_with_trie`) — while
6737 /// `capture_cap_host`, which bounds the template, is taken from the
6738 /// whole-host one in BOTH cases.
6739 ///
6740 /// To SEE THIS RED: make either wrapper capturing — `result.push_str("(")`
6741 /// instead of `"(?:"` in `convert_regex_domain_rule`, or
6742 /// `format!("\\A({segment})\\z")` in `pattern_trie.rs`'s
6743 /// `anchored_segment`. `$HOST[1]` then resolves to the whole segment
6744 /// `cdn42` instead of the operator's own group `42`, failing with
6745 /// `left: Some("cdn42.internal"), right: Some("42.internal")`.
6746 #[test]
6747 fn a_host_capture_rewrite_resolves_to_the_operators_own_group() {
6748 let rewritten_host = |position: RulePosition, template: &str, hostname: &str| {
6749 let mut front = test_http_frontend();
6750 front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
6751 front.position = position;
6752 front.rewrite_host = Some(template.to_owned());
6753 let mut router = Router::new();
6754 router
6755 .add_http_front(&front)
6756 .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
6757 router
6758 .lookup(hostname, "/", &Method::Get)
6759 .ok()
6760 .and_then(|result| result.rewritten_host)
6761 };
6762
6763 for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
6764 assert_eq!(
6765 rewritten_host(position, "$HOST[1].internal", "cdn42.example.com").as_deref(),
6766 Some("42.internal"),
6767 "{position:?}: $HOST[1] must be the operator's own group, not the wrapper's",
6768 );
6769 assert_eq!(
6770 rewritten_host(position, "$HOST[0]", "cdn42.example.com").as_deref(),
6771 Some("cdn42.example.com"),
6772 "{position:?}: $HOST[0] is the whole hostname",
6773 );
6774 }
6775
6776 // The cap itself. `captures_len()` is 2, so index 2 does not exist
6777 // and the frontend must be refused loudly at registration rather
6778 // than rewriting to an empty string. A capturing wrapper would make
6779 // this index legal — which is exactly how a shifted index reaches
6780 // production unnoticed.
6781 let mut front = test_http_frontend();
6782 front.hostname = "/cdn([0-9]+)/.example.com".to_owned();
6783 front.rewrite_host = Some("$HOST[2]".to_owned());
6784 assert!(
6785 matches!(
6786 Router::new().add_http_front(&front),
6787 Err(RouterError::InvalidHostRewrite(_)),
6788 ),
6789 "a $HOST index past captures_len must be refused at registration",
6790 );
6791 }
6792
6793 /// A tree lookup allocates nothing of its own: whatever host shape
6794 /// answers, the only heap work is building the [`RouteResult`] itself,
6795 /// which the control reproduces. The cluster id is not part of it: the
6796 /// result carries a handle on the legacy [`Route::ClusterId`] entry's
6797 /// reference-counted id, and so does the control.
6798 ///
6799 /// The three shapes cover every sink push the walk makes: a literal host
6800 /// records no segment, a wildcard host records one on its leaf, a regex
6801 /// host records one on the way down.
6802 ///
6803 /// TO SEE THIS RED: hand `lookup_with_path` a `Vec::with_capacity(16)`
6804 /// again in `Router::lookup`, one allocation per lookup on every shape;
6805 /// or build the legacy arm of `RouteResult::new_with_trie` from
6806 /// `ClusterId::from(&**id)` instead of `id.clone()`, one more.
6807 #[test]
6808 fn a_tree_lookup_allocates_nothing_past_its_route_result() {
6809 use std::hint::black_box;
6810
6811 use crate::test_allocations::allocations;
6812
6813 let mut router = Router::new();
6814 for hostname in [
6815 "literal.example.com",
6816 "*.wildcard.example.com",
6817 "/api[0-9]+/.regex.example.com",
6818 ] {
6819 let mut front = test_http_frontend();
6820 front.hostname = hostname.to_owned();
6821 router
6822 .add_http_front(&front)
6823 .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
6824 }
6825
6826 let cluster = ClusterId::from("cluster");
6827 let control = || {
6828 let before = allocations();
6829 let result = RouteResult::forward(black_box(&cluster).clone());
6830 let allocated = allocations() - before;
6831 drop(black_box(result));
6832 allocated
6833 };
6834 for host in [
6835 "literal.example.com",
6836 "foo.wildcard.example.com",
6837 "api42.regex.example.com",
6838 ] {
6839 // Warm-up: a regex builds its per-thread search cache on its
6840 // first match.
6841 drop(router.lookup(host, "/", &Method::Get));
6842 let before = allocations();
6843 let result = router.lookup(black_box(host), black_box("/"), &Method::Get);
6844 let allocated = allocations() - before;
6845 assert_eq!(
6846 result.ok().and_then(|result| result.cluster_id).as_deref(),
6847 Some("cluster"),
6848 "{host} must route to the test cluster",
6849 );
6850 assert_eq!(
6851 allocated,
6852 control(),
6853 "{host}: a tree lookup must allocate only its RouteResult",
6854 );
6855 }
6856 }
6857
6858 /// A trie walk records every non-literal segment it matched, in walk
6859 /// order, and `RouteResult::new_with_trie` reads that record back in
6860 /// reverse to number the `$HOST[n]` captures. The record keeps its first
6861 /// 16 entries on the stack; this hostname has 20 regex segments, so four
6862 /// spill past them, and every one of the 20 must still reach the
6863 /// template in the same position.
6864 ///
6865 /// The walk runs from the rightmost label, so the spilled entries are
6866 /// the four LEFTMOST segments, `s1` to `s4`, and the reversed read has to
6867 /// cross from the spill into the inline array. The expected order is
6868 /// left to right, the order a pre/post rule reads its whole-host regex
6869 /// in (sozu#1595); `a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule`
6870 /// holds the two positions against each other.
6871 #[test]
6872 fn a_host_capture_past_sixteen_trie_segments_keeps_every_segment() {
6873 const SEGMENTS: usize = 20;
6874 let hostname = format!(
6875 "{}.example.com",
6876 (1..=SEGMENTS)
6877 .map(|_| "/s([0-9]+)/")
6878 .collect::<Vec<_>>()
6879 .join(".")
6880 );
6881 let request = format!(
6882 "{}.example.com",
6883 (1..=SEGMENTS)
6884 .map(|segment| format!("s{segment}"))
6885 .collect::<Vec<_>>()
6886 .join(".")
6887 );
6888 let template = (1..=SEGMENTS)
6889 .map(|index| format!("$HOST[{index}]"))
6890 .collect::<Vec<_>>()
6891 .join("-");
6892
6893 let mut front = test_http_frontend();
6894 front.hostname = hostname;
6895 front.position = RulePosition::Tree;
6896 front.rewrite_host = Some(template);
6897 let mut router = Router::new();
6898 router
6899 .add_http_front(&front)
6900 .unwrap_or_else(|error| panic!("the tree frontend must build: {error}"));
6901
6902 let hostname_order = (1..=SEGMENTS)
6903 .map(|segment| segment.to_string())
6904 .collect::<Vec<_>>()
6905 .join("-");
6906 assert_eq!(
6907 router
6908 .lookup(&request, "/", &Method::Get)
6909 .ok()
6910 .and_then(|result| result.rewritten_host),
6911 Some(hostname_order),
6912 "every trie segment, inline or spilled, must reach $HOST[n] in hostname order",
6913 );
6914 }
6915
6916 /// The same hostname numbers its `$HOST[n]` captures the same way
6917 /// whichever position compiled it (sozu#1595). A pre/post rule reads
6918 /// one whole-host regex, so its groups come left to right; a tree rule
6919 /// matches one regex per label while the trie walks from the rightmost
6920 /// label, and `RouteResult::new_with_trie` renumbers them left to right.
6921 /// The second segment carries two groups, which keep their order inside
6922 /// the segment: reversing the flattened captures instead of the
6923 /// segments would be caught too.
6924 ///
6925 /// TO SEE THIS RED: drop the `.rev()` in `RouteResult::new_with_trie`;
6926 /// the tree rule then yields `x-9-1` where the pre/post rules yield
6927 /// `1-x-9`.
6928 #[test]
6929 fn a_tree_rule_numbers_its_host_captures_like_a_pre_or_post_rule() {
6930 let rewritten_host = |position: RulePosition| {
6931 let mut front = test_http_frontend();
6932 front.hostname = "/a([0-9]+)/./([a-z])([0-9])/.example.com".to_owned();
6933 front.position = position;
6934 front.rewrite_host = Some("$HOST[1]-$HOST[2]-$HOST[3]".to_owned());
6935 let mut router = Router::new();
6936 router
6937 .add_http_front(&front)
6938 .unwrap_or_else(|error| panic!("{position:?} frontend must build: {error}"));
6939 router
6940 .lookup("a1.x9.example.com", "/", &Method::Get)
6941 .ok()
6942 .and_then(|result| result.rewritten_host)
6943 };
6944
6945 for position in [RulePosition::Pre, RulePosition::Post, RulePosition::Tree] {
6946 assert_eq!(
6947 rewritten_host(position).as_deref(),
6948 Some("1-x-9"),
6949 "{position:?}: $HOST[n] must number the captures left to right",
6950 );
6951 }
6952 }
6953
6954 /// Grouping must not rescue a hostname the router rejects today. The
6955 /// wrapper supplies one `(` and one `)`, so an unbalanced segment can be
6956 /// balanced BY the wrapping — `a)(b` is not a regex, yet
6957 /// `\A(?:a)(b)\.example\.com\z` is, and it matches `ab.example.com`.
6958 /// Both hostname sites therefore compile the segment on its own before
6959 /// wrapping it, exactly as `PathRule::anchored_regex` does for a path.
6960 ///
6961 /// This covers that one direction. The converse is NOT guarded and is
6962 /// not asserted here: a segment that compiles bare can be rejected once
6963 /// wrapped. Such a hostname lands in `RouterError::InvalidDomain` at
6964 /// frontend registration, so it costs a loud rejection and not a
6965 /// mis-route.
6966 ///
6967 /// To SEE THIS RED: delete the `Regex::new(r).ok()?;` line in
6968 /// `convert_regex_domain_rule` — the first assertion then fails, the
6969 /// hostname builds, and `ab.example.com` routes. Delete the
6970 /// `Regex::new(segment).ok()?;` line in `pattern_trie.rs`'s
6971 /// `anchored_segment` instead and the `add_tree_rule` assertion fails.
6972 #[test]
6973 fn grouping_never_turns_a_rejected_hostname_regex_into_an_accepted_one() {
6974 // Assembled at run time rather than written as a literal because the
6975 // invalidity is the whole point and a literal invites a lint at the
6976 // `Regex::new` call site.
6977 let unbalanced = format!("/a{}b/.example.com", ")(");
6978
6979 assert_eq!(
6980 convert_regex_domain_rule(&unbalanced),
6981 None,
6982 "{unbalanced} carries a segment that is not a regex on its own",
6983 );
6984 assert_eq!(
6985 unbalanced.parse::<DomainRule>(),
6986 Err(()),
6987 "{unbalanced} must not become a usable DomainRule",
6988 );
6989 assert!(
6990 !Router::new().add_tree_rule(
6991 unbalanced.as_bytes(),
6992 &PathRule::Prefix("/".to_owned()),
6993 &MethodRule::new(Some("GET".to_owned())),
6994 &Route::ClusterId("RESCUED".into()),
6995 ),
6996 "{unbalanced} must not be storable in the trie either",
6997 );
6998
6999 // The hazard is real, not hypothetical: the wrapping alone turns it
7000 // into a live pattern matching a host the operator never wrote.
7001 let rescued = Regex::new(&format!("\\A(?:a{}b)\\.example\\.com\\z", ")("))
7002 .expect("the wrapping balances the segment");
7003 assert!(
7004 rescued.is_match(b"ab.example.com"),
7005 "{} is what the missing pre-validation would have built",
7006 rescued.as_str(),
7007 );
7008 }
7009
7010 /// One level up from the path rules, two overlapping regex HOSTNAME
7011 /// segments follow the same first-declared-wins rule: a trie node holds
7012 /// its regex segments in an ordered list, the lookup takes the first
7013 /// that matches AND serves the request, and a new segment is appended.
7014 /// Both segments below serve this request, so the first declared
7015 /// answers;
7016 /// `a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next`
7017 /// covers the case where it does not.
7018 ///
7019 /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7020 /// (`router/pattern_trie.rs`), iterate the regex segments in reverse —
7021 /// `for (regexp, child) in self.regexps.iter().rev()`. The last declared
7022 /// segment then answers and both assertions below flip.
7023 #[test]
7024 fn the_first_declared_regex_hostname_segment_wins_over_a_later_overlapping_one() {
7025 // Both patterns match the segment `test4`.
7026 let numbered: &[u8] = b"/test[0-9]/.example.com";
7027 let any_suffixed: &[u8] = b"/[a-z]+[0-9]/.example.com";
7028
7029 let declare = |first: &[u8], first_cluster: &str, second: &[u8], second_cluster: &str| {
7030 let mut router = Router::new();
7031 for (hostname, cluster) in [(first, first_cluster), (second, second_cluster)] {
7032 assert!(router.add_tree_rule(
7033 hostname,
7034 &PathRule::Prefix("/".to_owned()),
7035 &MethodRule::new(Some("GET".to_owned())),
7036 &Route::ClusterId(cluster.into()),
7037 ));
7038 }
7039 router
7040 .lookup("test4.example.com", "/", &Method::Get)
7041 .ok()
7042 .and_then(|result| result.cluster_id)
7043 };
7044
7045 assert_eq!(
7046 declare(numbered, "NUMBERED", any_suffixed, "ANY-SUFFIXED").as_deref(),
7047 Some("NUMBERED"),
7048 "the first-declared regex hostname segment must win",
7049 );
7050 assert_eq!(
7051 declare(any_suffixed, "ANY-SUFFIXED", numbered, "NUMBERED").as_deref(),
7052 Some("ANY-SUFFIXED"),
7053 "reversing the declaration order reverses the winner",
7054 );
7055
7056 // The worked example in `doc/configure.md` promises `/test[0-9]/`
7057 // matches `test4` "and not testAB". `[0-9]` is a SINGLE digit and the
7058 // segment is `\A`-`\z` anchored, so `test44` and `xtest4` must miss
7059 // too — the anchoring is pinned generally by
7060 // `segment_regex_rejects_partial_matches`, this pins the doc's own
7061 // example.
7062 let mut router = Router::new();
7063 assert!(router.add_tree_rule(
7064 numbered,
7065 &PathRule::Prefix("/".to_owned()),
7066 &MethodRule::new(Some("GET".to_owned())),
7067 &Route::ClusterId("REGIONAL".into()),
7068 ));
7069 assert_eq!(
7070 router
7071 .lookup("test4.example.com", "/", &Method::Get)
7072 .ok()
7073 .and_then(|result| result.cluster_id)
7074 .as_deref(),
7075 Some("REGIONAL"),
7076 );
7077 for miss in [
7078 "testAB.example.com",
7079 "test44.example.com",
7080 "xtest4.example.com",
7081 ] {
7082 assert!(
7083 router.lookup(miss, "/", &Method::Get).is_err(),
7084 "{miss} must not match the single-digit anchored segment",
7085 );
7086 }
7087 }
7088
7089 /// sozu#1351, shape one — the routing leak. An exact hostname added
7090 /// AFTER a regex segment that matches it used to attach its rule to
7091 /// the REGEX segment's leaf: `add_tree_rule` addresses the leaf
7092 /// through `domain_lookup_mut`, and that resolver walked a literal
7093 /// segment through the first regex segment matching it. The rule the
7094 /// operator wrote for `test4.example.com` was then served for every
7095 /// host `/test[0-9]/` matches, while `sozu query frontends` showed
7096 /// exactly what was typed.
7097 ///
7098 /// `TrieNode::lookup_mut` is key-addressed now: with
7099 /// `accept_wildcard: false` a literal key may not resolve into a
7100 /// non-literal entry (the guard `insert_sni_route` already documented
7101 /// for the wildcard slot, extended to the regex segments), so the add
7102 /// creates `test4.example.com`'s own node.
7103 ///
7104 /// To SEE THIS RED: in `TrieNode::lookup_mut`
7105 /// (`router/pattern_trie.rs`), drop the `accept_wildcard &&` guard in
7106 /// front of the `self.regexps` scan, so a literal segment resolves
7107 /// through a matching regex segment again. `test7` and `test9` then
7108 /// answer with EXACT-SPECIFIC.
7109 #[test]
7110 fn an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family() {
7111 let mut router = Router::new();
7112 assert!(router.add_tree_rule(
7113 b"/test[0-9]/.example.com",
7114 &PathRule::Prefix("/".to_owned()),
7115 &MethodRule::new(None),
7116 &Route::ClusterId("REGEX-FAMILY".into()),
7117 ));
7118 assert!(router.add_tree_rule(
7119 b"test4.example.com",
7120 &PathRule::Prefix("/only-for-test4".to_owned()),
7121 &MethodRule::new(None),
7122 &Route::ClusterId("EXACT-SPECIFIC".into()),
7123 ));
7124
7125 let resolve = |hostname: &str, path: &str| {
7126 router
7127 .lookup(hostname, path, &Method::Get)
7128 .ok()
7129 .and_then(|result| result.cluster_id)
7130 };
7131
7132 assert_eq!(
7133 resolve("test4.example.com", "/only-for-test4").as_deref(),
7134 Some("EXACT-SPECIFIC"),
7135 "the host the rule was written for must get it",
7136 );
7137 for leaked in ["test7.example.com", "test9.example.com"] {
7138 assert_eq!(
7139 resolve(leaked, "/only-for-test4").as_deref(),
7140 Some("REGEX-FAMILY"),
7141 "{leaked} must keep the family route and never reach the \
7142 rule scoped to test4",
7143 );
7144 }
7145 assert_eq!(
7146 resolve("test4.example.com", "/").as_deref(),
7147 Some("REGEX-FAMILY"),
7148 "the exact node carries only /only-for-test4, so every other \
7149 path on that host falls through to the regex segment that \
7150 also claims it",
7151 );
7152 }
7153
7154 /// sozu#1351 — the clearest statement of the same bug, and of its fix:
7155 /// declaring the exact host and the regex segment that matches it in
7156 /// either order must produce the SAME routing table. Before the fix
7157 /// the regex-first order attached the exact rule to the regex leaf
7158 /// (so `test7` answered with it and `test4/` answered at all), while
7159 /// the exact-first order built two separate nodes.
7160 ///
7161 /// To SEE THIS RED: same mutation as
7162 /// `an_exact_host_added_after_a_matching_regex_segment_does_not_leak_to_the_family`
7163 /// — drop the `accept_wildcard &&` guard in front of the
7164 /// `self.regexps` scan of `TrieNode::lookup_mut`. The two arrays then
7165 /// differ on `test4.example.com/` and on `test7.example.com`.
7166 #[test]
7167 fn hostname_routing_does_not_depend_on_declaration_order() {
7168 let exact: (&[u8], &str, &str) =
7169 (b"test4.example.com", "/only-for-test4", "EXACT-SPECIFIC");
7170 let regex: (&[u8], &str, &str) = (b"/test[0-9]/.example.com", "/", "REGEX-FAMILY");
7171
7172 let routes = |declared: [(&[u8], &str, &str); 2]| {
7173 let mut router = Router::new();
7174 for (hostname, path, cluster) in declared {
7175 assert!(router.add_tree_rule(
7176 hostname,
7177 &PathRule::Prefix(path.to_owned()),
7178 &MethodRule::new(None),
7179 &Route::ClusterId(cluster.into()),
7180 ));
7181 }
7182 [
7183 ("test4.example.com", "/only-for-test4"),
7184 ("test4.example.com", "/"),
7185 ("test7.example.com", "/only-for-test4"),
7186 ("test9.example.com", "/"),
7187 ]
7188 .map(|(hostname, path)| {
7189 router
7190 .lookup(hostname, path, &Method::Get)
7191 .ok()
7192 .and_then(|result| result.cluster_id)
7193 })
7194 };
7195
7196 let regex_first = routes([regex, exact]);
7197 let exact_first = routes([exact, regex]);
7198 assert_eq!(
7199 regex_first, exact_first,
7200 "hostname resolution must not depend on the order the \
7201 frontends were added in",
7202 );
7203 assert_eq!(
7204 regex_first[0].as_deref(),
7205 Some("EXACT-SPECIFIC"),
7206 "and the order-independent answer is the exact host's own rule",
7207 );
7208 assert_eq!(
7209 regex_first[2].as_deref(),
7210 Some("REGEX-FAMILY"),
7211 "while a sibling of the regex family never sees it",
7212 );
7213 }
7214
7215 /// Hostname precedence is most-specific-first: an exact name beats a
7216 /// regex segment, a regex segment beats the `*` wildcard. One
7217 /// assertion per transition, since `doc/configure.md` states the order
7218 /// in prose.
7219 ///
7220 /// Regex-over-wildcard is the half that changed: `lookup_with_path`
7221 /// used to consult the wildcard slot BEFORE the regex list whenever
7222 /// the leftmost label was reached, so `*` outranked the narrower
7223 /// pattern.
7224 ///
7225 /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7226 /// (`router/pattern_trie.rs`), move the wildcard block above the
7227 /// `self.regexps` loop. The REGEX-over-WILDCARD assertions then answer
7228 /// WILDCARD.
7229 #[test]
7230 fn hostname_precedence_is_exact_then_regex_then_wildcard() {
7231 let exact: &[u8] = b"test4.example.com";
7232 let regex: &[u8] = b"/test[0-9]/.example.com";
7233 let wildcard: &[u8] = b"*.example.com";
7234
7235 let declare = |hostnames: &[(&[u8], &str)]| {
7236 let mut router = Router::new();
7237 for (hostname, cluster) in hostnames {
7238 assert!(router.add_tree_rule(
7239 hostname,
7240 &PathRule::Prefix("/".to_owned()),
7241 &MethodRule::new(None),
7242 &Route::ClusterId((*cluster).into()),
7243 ));
7244 }
7245 router
7246 .lookup("test4.example.com", "/", &Method::Get)
7247 .ok()
7248 .and_then(|result| result.cluster_id)
7249 };
7250
7251 assert_eq!(
7252 declare(&[(regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7253 Some("EXACT"),
7254 "an exact hostname outranks a regex segment that matches it",
7255 );
7256 assert_eq!(
7257 declare(&[(wildcard, "WILDCARD"), (exact, "EXACT")]).as_deref(),
7258 Some("EXACT"),
7259 "an exact hostname outranks the wildcard",
7260 );
7261 assert_eq!(
7262 declare(&[(wildcard, "WILDCARD"), (regex, "REGEX")]).as_deref(),
7263 Some("REGEX"),
7264 "a regex segment outranks the wildcard",
7265 );
7266 assert_eq!(
7267 declare(&[(regex, "REGEX"), (wildcard, "WILDCARD")]).as_deref(),
7268 Some("REGEX"),
7269 "and it outranks it in the other declaration order too",
7270 );
7271 assert_eq!(
7272 declare(&[(wildcard, "WILDCARD"), (regex, "REGEX"), (exact, "EXACT")]).as_deref(),
7273 Some("EXACT"),
7274 "with all three declared the most specific one answers",
7275 );
7276 assert_eq!(
7277 declare(&[(wildcard, "WILDCARD")]).as_deref(),
7278 Some("WILDCARD"),
7279 "the wildcard still answers when nothing narrower claims the host",
7280 );
7281 }
7282
7283 /// The precedence order above is a search order, not a filter: a more
7284 /// specific hostname candidate that serves no rule for THIS request
7285 /// hands the request to the next one instead of ending the lookup.
7286 /// Without this, splitting a host's paths across an exact frontend and
7287 /// a regex family would make every path the exact frontend does not
7288 /// carry unroutable.
7289 ///
7290 /// To SEE THIS RED: in `TrieNode::lookup_recursive`
7291 /// (`router/pattern_trie.rs`), return
7292 /// `child.lookup_recursive(prefix, accept_wildcard, trace, accept)`
7293 /// directly from the `self.children.get(suffix)` arm instead of
7294 /// falling through on `None` — the pre-fix shape. `/regex-only` and
7295 /// `/served-by-neither` on `test4.example.com` then answer `None`.
7296 #[test]
7297 fn a_hostname_candidate_that_serves_no_rule_falls_through_to_the_next() {
7298 let mut router = Router::new();
7299 for (hostname, path, cluster) in [
7300 (&b"test4.example.com"[..], "/exact-only", "EXACT"),
7301 (&b"/test[0-9]/.example.com"[..], "/regex-only", "REGEX"),
7302 (&b"*.example.com"[..], "/", "WILDCARD"),
7303 ] {
7304 assert!(router.add_tree_rule(
7305 hostname,
7306 &PathRule::Prefix(path.to_owned()),
7307 &MethodRule::new(None),
7308 &Route::ClusterId(cluster.into()),
7309 ));
7310 }
7311
7312 let resolve = |hostname: &str, path: &str| {
7313 router
7314 .lookup(hostname, path, &Method::Get)
7315 .ok()
7316 .and_then(|result| result.cluster_id)
7317 };
7318
7319 assert_eq!(
7320 resolve("test4.example.com", "/exact-only").as_deref(),
7321 Some("EXACT"),
7322 );
7323 assert_eq!(
7324 resolve("test4.example.com", "/regex-only").as_deref(),
7325 Some("REGEX"),
7326 "the exact node carries no rule for this path, so the regex \
7327 segment that also claims the host answers",
7328 );
7329 assert_eq!(
7330 resolve("test4.example.com", "/served-by-neither").as_deref(),
7331 Some("WILDCARD"),
7332 "and when neither serves it the wildcard does",
7333 );
7334 assert_eq!(
7335 resolve("testA.example.com", "/regex-only").as_deref(),
7336 Some("WILDCARD"),
7337 "a host the regex segment does not match never reaches it",
7338 );
7339 }
7340
7341 /// sozu#1351, shape two — the 404. Hostname candidates are chosen
7342 /// without looking at the request method, so the first segment to
7343 /// claim a host used to end the lookup even when its only rule was
7344 /// declared for another method. The second segment, which would have
7345 /// answered, was never consulted.
7346 ///
7347 /// The fallback above covers it: `Router::lookup` hands the trie the
7348 /// same (path, method) selection it will run on the winner, so a
7349 /// candidate that selects nothing is skipped.
7350 ///
7351 /// To SEE THIS RED: in `Router::lookup`, pass `&mut |_| true` as the
7352 /// `accept` predicate of `lookup_with_path` instead of
7353 /// `select_tree_rule(...).is_some()`. The GET below then answers
7354 /// `None` because the POST-only segment was declared first.
7355 #[test]
7356 fn a_hostname_segment_whose_rules_reject_the_method_falls_through_to_the_next() {
7357 let mut router = Router::new();
7358 // Both segments match `test4`; the POST-only one is declared first.
7359 assert!(router.add_tree_rule(
7360 b"/test[0-9]/.example.com",
7361 &PathRule::Prefix("/".to_owned()),
7362 &MethodRule::new(Some("POST".to_owned())),
7363 &Route::ClusterId("POST-ONLY".into()),
7364 ));
7365 assert!(router.add_tree_rule(
7366 b"/[a-z]+[0-9]/.example.com",
7367 &PathRule::Prefix("/".to_owned()),
7368 &MethodRule::new(Some("GET".to_owned())),
7369 &Route::ClusterId("GET-ONLY".into()),
7370 ));
7371
7372 let resolve = |method: &Method| {
7373 router
7374 .lookup("test4.example.com", "/", method)
7375 .ok()
7376 .and_then(|result| result.cluster_id)
7377 };
7378
7379 assert_eq!(
7380 resolve(&Method::Get).as_deref(),
7381 Some("GET-ONLY"),
7382 "the first-declared segment carries no GET rule, so the second \
7383 one answers instead of the request 404ing",
7384 );
7385 assert_eq!(
7386 resolve(&Method::Post).as_deref(),
7387 Some("POST-ONLY"),
7388 "and the first-declared segment still wins whenever it serves \
7389 the request",
7390 );
7391 }
7392
7393 /// `PathRule`'s `PartialEq` carried no `(Equals, Equals)` arm, so two
7394 /// identical `PathRule::Equals` compared unequal — an equality that is
7395 /// not even reflexive. Every router bookkeeping path is written against
7396 /// it: `add_tree_rule` could not see the rule it had just pushed (so the
7397 /// same `--path-equals` frontend was stored again on every re-add
7398 /// instead of being refused), `remove_tree_rule`'s `retain` kept the
7399 /// entry it was asked to drop, and `remove_http_front` still answered
7400 /// `Ok` for a route it had not removed.
7401 ///
7402 /// To SEE THIS RED: drop the
7403 /// `(PathRule::Equals(s1), PathRule::Equals(s2)) => s1 == s2` arm from
7404 /// `impl std::cmp::PartialEq for PathRule` so the catch-all `_ => false`
7405 /// answers again — the reflexivity assertion below is the first statement
7406 /// and fails on `left: Equals("/exact") / right: Equals("/exact")`.
7407 /// Without that assertion a debug build panics one line later, inside
7408 /// `add_http_front`, on "a freshly inserted tree domain must resolve to
7409 /// its inserted rule".
7410 #[test]
7411 fn an_equals_path_frontend_is_deduplicated_and_removable() {
7412 assert_eq!(
7413 PathRule::Equals("/exact".to_owned()),
7414 PathRule::Equals("/exact".to_owned()),
7415 "PathRule equality must be reflexive for the Equals variant",
7416 );
7417
7418 let mut router = Router::new();
7419 let mut front = test_http_frontend();
7420 front.path = CommandPathRule::equals("/exact".to_owned());
7421
7422 router
7423 .add_http_front(&front)
7424 .expect("an Equals frontend must be added");
7425 assert!(
7426 router.lookup("example.com", "/exact", &Method::Get).is_ok(),
7427 "the Equals frontend must resolve once added",
7428 );
7429
7430 // The identical frontend is already stored, so the router must
7431 // refuse it instead of pushing a second, unremovable copy.
7432 assert!(
7433 matches!(router.add_http_front(&front), Err(RouterError::AddRoute(_))),
7434 "re-adding the same Equals frontend must be refused, not duplicated",
7435 );
7436
7437 router
7438 .remove_http_front(&front)
7439 .expect("an Equals frontend must be removable");
7440 assert!(
7441 router
7442 .lookup("example.com", "/exact", &Method::Get)
7443 .is_err(),
7444 "the Equals frontend must no longer resolve once removed",
7445 );
7446 }
7447
7448 // ── RFC 9110 §4.2.3: the Host is case-insensitive ──────────────────
7449 //
7450 // `add_tree_rule` (via `tree_hostname_to_ascii`) and
7451 // `DomainRule::from_str` both normalise a configured hostname
7452 // through `idna::domain_to_ascii`, which ASCII-lowercases (measured:
7453 // `"WWW.EXAMPLE.COM"` -> `"www.example.com"`, `"*.EXAMPLE.COM"` ->
7454 // `"*.example.com"`). A `/`-delimited regex segment is the one part
7455 // that is NOT normalised — see `tree_hostname_to_ascii` and
7456 // sozu#1377 — and is compiled case-insensitively instead, so an
7457 // uppercase literal inside it still meets the normalised key.
7458 // `Router::lookup` used to walk the
7459 // trie and the pre/post lists with the client's raw bytes, so the two
7460 // sides disagreed and no configuration could make an uppercase
7461 // `Host:` route. The tests below pin the restored invariant: add and
7462 // lookup normalise identically.
7463
7464 /// To SEE THIS RED: in `Router::lookup`, replace
7465 /// `let normalized_hostname = normalize_hostname(hostname);` with
7466 /// `let normalized_hostname = Cow::Borrowed(hostname);`. Every
7467 /// uppercase and mixed-case assertion below then fails with
7468 /// `left: None, right: Some("CASE")` — the byte-exact trie walk the
7469 /// fix removed.
7470 #[test]
7471 fn an_uppercase_host_reaches_a_lowercase_tree_frontend() {
7472 let mut router = Router::new();
7473 assert!(router.add_tree_rule(
7474 b"www.example.com",
7475 &PathRule::Prefix("/".to_owned()),
7476 &MethodRule::new(Some("GET".to_owned())),
7477 &Route::ClusterId("CASE".into()),
7478 ));
7479
7480 let resolve = |hostname: &str| {
7481 router
7482 .lookup(hostname, "/", &Method::Get)
7483 .ok()
7484 .and_then(|result| result.cluster_id)
7485 };
7486
7487 for host in [
7488 "www.example.com",
7489 "WWW.EXAMPLE.COM",
7490 "WwW.ExAmPlE.cOm",
7491 "www.EXAMPLE.com",
7492 ] {
7493 assert_eq!(
7494 resolve(host).as_deref(),
7495 Some("CASE"),
7496 "Host {host:?} names the same frontend as its lowercase form",
7497 );
7498 }
7499 }
7500
7501 /// The other half of the issue: declaring the frontend in uppercase
7502 /// never was a workaround, because the add path lowercases it on the
7503 /// way in. Both spellings of the frontend must route both spellings
7504 /// of the Host.
7505 ///
7506 /// To SEE THIS RED: same mutation as
7507 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7508 #[test]
7509 fn an_uppercase_tree_frontend_is_stored_and_looked_up_lowercase() {
7510 let mut router = Router::new();
7511 assert!(router.add_tree_rule(
7512 b"WWW.EXAMPLE.COM",
7513 &PathRule::Prefix("/".to_owned()),
7514 &MethodRule::new(Some("GET".to_owned())),
7515 &Route::ClusterId("CASE".into()),
7516 ));
7517
7518 for host in ["www.example.com", "WWW.EXAMPLE.COM"] {
7519 assert_eq!(
7520 router
7521 .lookup(host, "/", &Method::Get)
7522 .ok()
7523 .and_then(|result| result.cluster_id)
7524 .as_deref(),
7525 Some("CASE"),
7526 "an uppercase frontend is stored lowercase, so {host:?} must resolve",
7527 );
7528 }
7529 }
7530
7531 /// Wildcard and regex trie segments walk the same normalised key.
7532 /// The regex segment is the interesting one: its source is stored
7533 /// VERBATIM (`tree_hostname_to_ascii` normalises the literal labels
7534 /// only, because folding the source inverted `\D` into `\d` —
7535 /// sozu#1377), so an uppercase literal inside it meets the
7536 /// lowercased lookup key only because
7537 /// `pattern_trie::compiled_segment` compiles it case-insensitively.
7538 ///
7539 /// To SEE THIS RED: same mutation as
7540 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`. Dropping
7541 /// `.case_insensitive(true)` from `compiled_segment` reddens the
7542 /// `API7` assertion alone, which is the half this test also guards.
7543 #[test]
7544 fn an_uppercase_host_reaches_wildcard_and_regex_tree_segments() {
7545 let mut router = Router::new();
7546 assert!(router.add_tree_rule(
7547 b"*.wild.example.com",
7548 &PathRule::Prefix("/".to_owned()),
7549 &MethodRule::new(Some("GET".to_owned())),
7550 &Route::ClusterId("WILDCARD".into()),
7551 ));
7552 assert!(router.add_tree_rule(
7553 b"/API[0-9]/.rx.example.com",
7554 &PathRule::Prefix("/".to_owned()),
7555 &MethodRule::new(Some("GET".to_owned())),
7556 &Route::ClusterId("REGEX".into()),
7557 ));
7558
7559 let resolve = |hostname: &str| {
7560 router
7561 .lookup(hostname, "/", &Method::Get)
7562 .ok()
7563 .and_then(|result| result.cluster_id)
7564 };
7565
7566 assert_eq!(resolve("FOO.WILD.EXAMPLE.COM").as_deref(), Some("WILDCARD"));
7567 assert_eq!(resolve("FoO.wild.EXAMPLE.com").as_deref(), Some("WILDCARD"));
7568 assert_eq!(
7569 resolve("API7.RX.EXAMPLE.COM").as_deref(),
7570 Some("REGEX"),
7571 "the operator's `/API[0-9]/` is stored verbatim and compiled \
7572 case-insensitively, so the normalised key matches it",
7573 );
7574 assert_eq!(resolve("api7.rx.example.com").as_deref(), Some("REGEX"));
7575 }
7576
7577 /// Pre and post rules carry a `DomainRule` rather than a trie node.
7578 /// `Exact` and `Wildcard` are lowercased by `DomainRule::from_str`
7579 /// (again `idna::domain_to_ascii`) and compared byte-exact, so they
7580 /// had exactly the tree's asymmetry.
7581 ///
7582 /// To SEE THIS RED: same mutation as
7583 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`.
7584 #[test]
7585 fn an_uppercase_host_reaches_exact_and_wildcard_pre_post_rules() {
7586 let mut router = Router::new();
7587 let exact = "pre.example.com"
7588 .parse::<DomainRule>()
7589 .expect("an exact domain rule must parse");
7590 let wildcard = "*.post.example.com"
7591 .parse::<DomainRule>()
7592 .expect("a wildcard domain rule must parse");
7593 assert!(router.add_pre_rule(
7594 &exact,
7595 &PathRule::Prefix("/".to_owned()),
7596 &MethodRule::new(None),
7597 &Route::ClusterId("PRE".into()),
7598 ));
7599 assert!(router.add_post_rule(
7600 &wildcard,
7601 &PathRule::Prefix("/".to_owned()),
7602 &MethodRule::new(None),
7603 &Route::ClusterId("POST".into()),
7604 ));
7605
7606 let resolve = |hostname: &str| {
7607 router
7608 .lookup(hostname, "/", &Method::Get)
7609 .ok()
7610 .and_then(|result| result.cluster_id)
7611 };
7612
7613 assert_eq!(resolve("PRE.EXAMPLE.COM").as_deref(), Some("PRE"));
7614 assert_eq!(resolve("PrE.ExAmPlE.cOm").as_deref(), Some("PRE"));
7615 assert_eq!(resolve("FOO.POST.EXAMPLE.COM").as_deref(), Some("POST"));
7616 assert_eq!(resolve("foo.post.example.com").as_deref(), Some("POST"));
7617 }
7618
7619 /// The interaction that makes normalising the lookup key dangerous on
7620 /// its own: a pre/post hostname REGEX keeps the operator's bytes —
7621 /// `convert_regex_domain_rule` copies the segment source verbatim,
7622 /// as the trie now does too (`tree_hostname_to_ascii`, sozu#1377;
7623 /// up to and including 2.2.1 `domain_to_ascii` lowercased it there).
7624 /// Matching a
7625 /// normalised key against a case-sensitive `/API[0-9]/` would have
7626 /// turned a rule that used to match `API7.…` into one that matches
7627 /// nothing. `DomainRule::from_str` therefore compiles hostname
7628 /// regexes case-insensitively, which is also the only reading
7629 /// that agrees with the trie: `pattern_trie::compiled_segment`
7630 /// folds the same way, on the same stored bytes.
7631 ///
7632 /// To SEE THIS RED: in `DomainRule::from_str`, drop the
7633 /// `.case_insensitive(true)` from the `RegexBuilder` and build with
7634 /// `regex::bytes::Regex::new(&s)` again. Both assertions below fail
7635 /// with `left: None, right: Some("RX")`.
7636 #[test]
7637 fn an_uppercase_pre_rule_hostname_regex_still_matches_its_own_host() {
7638 let mut router = Router::new();
7639 let uppercase_regex = "/API[0-9]/.example.com"
7640 .parse::<DomainRule>()
7641 .expect("an uppercase hostname regex must parse");
7642 assert!(router.add_pre_rule(
7643 &uppercase_regex,
7644 &PathRule::Prefix("/".to_owned()),
7645 &MethodRule::new(None),
7646 &Route::ClusterId("RX".into()),
7647 ));
7648
7649 let resolve = |hostname: &str| {
7650 router
7651 .lookup(hostname, "/", &Method::Get)
7652 .ok()
7653 .and_then(|result| result.cluster_id)
7654 };
7655
7656 assert_eq!(
7657 resolve("API7.example.com").as_deref(),
7658 Some("RX"),
7659 "the spelling the operator wrote must keep routing",
7660 );
7661 assert_eq!(
7662 resolve("api7.example.com").as_deref(),
7663 Some("RX"),
7664 "and its canonical lowercase form, which never routed before",
7665 );
7666 }
7667
7668 /// The regression guard for the `.unicode(false)` that
7669 /// [`DomainRule::from_str`] deliberately does NOT set. Setting it
7670 /// would make the case folding ASCII-only — which is what the
7671 /// hostnames ever reaching `matches` are anyway — but it also
7672 /// refuses Unicode-class syntax at compile time, and `\p{L}` matches
7673 /// ASCII letters perfectly well, so a frontend that installs and
7674 /// routes today would start being rejected at add time.
7675 ///
7676 /// To SEE THIS RED: add `.unicode(false)` to the `RegexBuilder` in
7677 /// `DomainRule::from_str`. The parse fails and the `expect` fires.
7678 #[test]
7679 fn a_unicode_class_hostname_regex_still_compiles_and_routes() {
7680 let mut router = Router::new();
7681 let unicode_class = "/\\p{L}+[0-9]/.example.com"
7682 .parse::<DomainRule>()
7683 .expect("a hostname regex using a Unicode class must still compile");
7684 assert!(router.add_pre_rule(
7685 &unicode_class,
7686 &PathRule::Prefix("/".to_owned()),
7687 &MethodRule::new(None),
7688 &Route::ClusterId("UNICODE".into()),
7689 ));
7690
7691 let resolve = |hostname: &str| {
7692 router
7693 .lookup(hostname, "/", &Method::Get)
7694 .ok()
7695 .and_then(|result| result.cluster_id)
7696 };
7697
7698 assert_eq!(
7699 resolve("api7.example.com").as_deref(),
7700 Some("UNICODE"),
7701 "a Unicode letter class covers ASCII letters, so this rule is live, not decorative",
7702 );
7703 assert_eq!(
7704 resolve("API7.example.com").as_deref(),
7705 Some("UNICODE"),
7706 "and it stays live for the uppercase spelling of the same host",
7707 );
7708 }
7709
7710 /// Normalisation and the hostname precedence order of sozu#1351
7711 /// compose in one direction only: the key is normalised first, then
7712 /// the most-specific-first search runs on it, so case never decides
7713 /// WHICH candidate wins. Stated in `doc/configure.md` under
7714 /// "Hostname case", so it needs an assertion.
7715 ///
7716 /// The shape is the one the precedence section uses: an exact name
7717 /// and a regex segment that also claims it. The exact frontend must
7718 /// win for both spellings — a normalisation applied anywhere after
7719 /// the candidate search would let the uppercase spelling miss the
7720 /// exact node and fall through to the regex family.
7721 ///
7722 /// To SEE THIS RED: in `Router::lookup`, replace
7723 /// `let normalized_hostname = normalize_hostname(hostname);` with
7724 /// `let normalized_hostname = Cow::Borrowed(hostname);`. The
7725 /// uppercase spelling stops resolving at all.
7726 #[test]
7727 fn case_normalisation_runs_before_the_hostname_precedence_search() {
7728 let mut router = Router::new();
7729 assert!(router.add_tree_rule(
7730 b"test4.example.com",
7731 &PathRule::Prefix("/".to_owned()),
7732 &MethodRule::new(Some("GET".to_owned())),
7733 &Route::ClusterId("EXACT".into()),
7734 ));
7735 assert!(router.add_tree_rule(
7736 b"/test[0-9]/.example.com",
7737 &PathRule::Prefix("/".to_owned()),
7738 &MethodRule::new(Some("GET".to_owned())),
7739 &Route::ClusterId("REGEX-FAMILY".into()),
7740 ));
7741
7742 let resolve = |hostname: &str| {
7743 router
7744 .lookup(hostname, "/", &Method::Get)
7745 .ok()
7746 .and_then(|result| result.cluster_id)
7747 };
7748
7749 assert_eq!(
7750 resolve("test4.example.com").as_deref(),
7751 Some("EXACT"),
7752 "the exact name outranks a regex segment that also claims it",
7753 );
7754 for spelling in ["TEST4.EXAMPLE.COM", "TeSt4.ExAmPlE.cOm"] {
7755 assert_eq!(
7756 resolve(spelling).as_deref(),
7757 Some("EXACT"),
7758 "{spelling:?} must pick the same tier as its lowercase form, \
7759 not fall through to the regex family",
7760 );
7761 }
7762
7763 // A host only the regex family claims still reaches it in either
7764 // spelling: normalising did not collapse the tiers, it fed them.
7765 assert_eq!(
7766 resolve("test7.example.com").as_deref(),
7767 Some("REGEX-FAMILY")
7768 );
7769 assert_eq!(
7770 resolve("TEST7.EXAMPLE.COM").as_deref(),
7771 Some("REGEX-FAMILY")
7772 );
7773 }
7774
7775 /// `has_hostname` decides whether a removed frontend's cached tags may
7776 /// be dropped. It is fed the CONFIGURED hostname, which the pre/post
7777 /// arms then compared byte-exact against a `DomainRule` the add path
7778 /// had already lowercased — so an uppercase frontend answered `false`
7779 /// while its route was still installed, and the tags went away under a
7780 /// live route.
7781 ///
7782 /// To SEE THIS RED: in `has_hostname`, replace
7783 /// `let normalized_hostname = normalize_hostname(hostname);` with
7784 /// `let normalized_hostname = Cow::Borrowed(hostname);`. The two
7785 /// uppercase assertions fail.
7786 #[test]
7787 fn has_hostname_answers_for_an_uppercase_spelling() {
7788 let mut router = Router::new();
7789 assert!(router.add_tree_rule(
7790 b"tree.example.com",
7791 &PathRule::Prefix("/".to_owned()),
7792 &MethodRule::new(Some("GET".to_owned())),
7793 &Route::ClusterId("TREE".into()),
7794 ));
7795 assert!(
7796 router.add_pre_rule(
7797 &"pre.example.com"
7798 .parse::<DomainRule>()
7799 .expect("an exact domain rule must parse"),
7800 &PathRule::Prefix("/".to_owned()),
7801 &MethodRule::new(None),
7802 &Route::ClusterId("PRE".into()),
7803 )
7804 );
7805
7806 assert!(router.has_hostname("TREE.EXAMPLE.COM"));
7807 assert!(router.has_hostname("PRE.EXAMPLE.COM"));
7808 assert!(!router.has_hostname("ABSENT.EXAMPLE.COM"));
7809 }
7810
7811 /// `$HOST[n]` captures are taken from the key the router matched on,
7812 /// so a rewrite template now emits the normalised host rather than
7813 /// the client's raw bytes. That is the intended reading — the
7814 /// configured frontend is itself stored lowercase — and it is stated
7815 /// in `doc/configure.md`, so it needs an assertion.
7816 ///
7817 /// To SEE THIS RED: same mutation as
7818 /// `an_uppercase_host_reaches_a_lowercase_tree_frontend`; the rewrite
7819 /// stops resolving at all (`RouteNotFound`) and the `expect` fires.
7820 #[test]
7821 fn a_host_rewrite_capture_sees_the_normalized_host() {
7822 let mut router = Router::new();
7823 router
7824 .add_http_front(&HttpFrontend {
7825 hostname: "rewrite.example.com".to_owned(),
7826 rewrite_host: Some("$HOST[0].internal".to_owned()),
7827 ..test_http_frontend()
7828 })
7829 .expect("the rewrite frontend must install");
7830
7831 let route = router
7832 .lookup("REWRITE.EXAMPLE.COM", "/", &Method::Get)
7833 .expect("an uppercase Host must resolve");
7834 assert_eq!(
7835 route.rewritten_host.as_deref(),
7836 Some("rewrite.example.com.internal"),
7837 "`$HOST[0]` carries the normalised host, not the client's bytes",
7838 );
7839 }
7840
7841 /// The normalisation must cost a scan and not an allocation on the
7842 /// overwhelmingly common path — every conforming client already sends
7843 /// a lowercase Host, and this runs once per request.
7844 ///
7845 /// To SEE THIS RED: make `normalize_hostname` return
7846 /// `Cow::Owned(hostname.to_ascii_lowercase())` unconditionally. The
7847 /// first assertion fails.
7848 #[test]
7849 fn normalize_hostname_borrows_an_already_lowercase_host() {
7850 assert!(
7851 matches!(normalize_hostname("www.example.com"), Cow::Borrowed(_)),
7852 "a lowercase host must not allocate",
7853 );
7854 assert!(
7855 matches!(normalize_hostname("123.example-host.com"), Cow::Borrowed(_)),
7856 "digits and hyphens are not uppercase and must not allocate",
7857 );
7858 assert_eq!(
7859 normalize_hostname("WwW.ExAmPlE.cOm"),
7860 Cow::Owned::<str>("www.example.com".to_owned()),
7861 );
7862 }
7863
7864 /// Known gap, deliberately NOT closed here: RFC 1034 §3.1 makes
7865 /// `www.example.com.` the absolute form of `www.example.com`, but
7866 /// `idna::domain_to_ascii` keeps the trailing dot on the add path
7867 /// (measured: `"www.example.com."` -> `"www.example.com."`) and the
7868 /// trie then splits it into a trailing empty label that no
7869 /// dot-free frontend carries. The TLS side already strips one
7870 /// trailing dot (`lib/src/https.rs`'s `sni_owned`,
7871 /// `lib/src/protocol/tcp_preread/mod.rs`'s `normalize_sni`); HTTP
7872 /// routing does not. This test records the current answer so the day
7873 /// someone fixes it, it fails here and gets inverted rather than
7874 /// silently changing behaviour.
7875 ///
7876 /// To SEE THIS RED: strip a single trailing `.` inside
7877 /// `normalize_hostname`.
7878 #[test]
7879 fn an_absolute_form_host_does_not_reach_a_relative_frontend_yet() {
7880 let mut router = Router::new();
7881 assert!(router.add_tree_rule(
7882 b"www.example.com",
7883 &PathRule::Prefix("/".to_owned()),
7884 &MethodRule::new(Some("GET".to_owned())),
7885 &Route::ClusterId("CASE".into()),
7886 ));
7887
7888 assert!(
7889 router
7890 .lookup("www.example.com.", "/", &Method::Get)
7891 .is_err(),
7892 "absolute-form Host routing is a separate defect, tracked \
7893 apart from the case asymmetry",
7894 );
7895 assert!(
7896 router
7897 .lookup("WWW.EXAMPLE.COM.", "/", &Method::Get)
7898 .is_err(),
7899 "normalising the case must not accidentally close it either",
7900 );
7901 }
7902
7903 // ── sozu#1377: IDNA folding must not reach a regex segment ─────────
7904 //
7905 // `add_tree_rule`, `remove_tree_rule` and `has_hostname` used to run
7906 // the WHOLE configured hostname through `idna::domain_to_ascii`,
7907 // which ASCII-lowercases (measured: `"/\\D+/.example.com"` ->
7908 // `"/\\d+/.example.com"`, `"/[^\\D]/.example.com"` ->
7909 // `"/[^\\d]/.example.com"`, `"/API[0-9]/.rx.example.com"` ->
7910 // `"/api[0-9]/.rx.example.com"`). Folding an uppercase regex escape
7911 // INVERTS the character class it names -- `\D` is "not a digit",
7912 // `\d` is "a digit"; `\W`/`\w` and `\S`/`\s` invert the same way --
7913 // so the stored rule matched the exact COMPLEMENT of what the
7914 // operator wrote while `sozu query frontends` still echoed the
7915 // original spelling.
7916 //
7917 // `Pre`/`Post` never carried it: `convert_regex_domain_rule` copies a
7918 // segment's source verbatim and `DomainRule::from_str` takes its case
7919 // insensitivity from the `RegexBuilder` instead. The tree now agrees:
7920 // `tree_hostname_to_ascii` normalises only the literal labels and
7921 // `pattern_trie::compiled_segment` folds case at compile time.
7922
7923 /// To SEE THIS RED: in `tree_hostname_to_ascii`, replace the whole
7924 /// body with `::idna::domain_to_ascii(hostname).ok()`, the form the
7925 /// three call sites carried up to 2.2.1. Both assertions then fail
7926 /// INVERTED -- `abc.rx.example.com` resolves to `None` and
7927 /// `777.rx.example.com` to `Some("NOT-A-DIGIT")` -- which is the
7928 /// defect itself: the rule matches the complement of its source --
7929 /// `\D` matching a digit.
7930 #[test]
7931 fn a_tree_hostname_regex_escape_is_not_case_folded() {
7932 let mut router = Router::new();
7933 assert!(router.add_tree_rule(
7934 "/\\D+/.rx.example.com".as_bytes(),
7935 &PathRule::Prefix("/".to_owned()),
7936 &MethodRule::new(None),
7937 &Route::ClusterId("NOT-A-DIGIT".into()),
7938 ));
7939
7940 let resolve = |hostname: &str| {
7941 router
7942 .lookup(hostname, "/", &Method::Get)
7943 .ok()
7944 .and_then(|result| result.cluster_id)
7945 };
7946
7947 assert_eq!(
7948 resolve("777.rx.example.com"),
7949 None,
7950 "`\\D+` must NOT match a run of digits; folded to `\\d+` it matched \
7951 exactly this and nothing else",
7952 );
7953 assert_eq!(
7954 resolve("abc.rx.example.com").as_deref(),
7955 Some("NOT-A-DIGIT"),
7956 "`\\D+` is \"one or more non-digits\" and `abc` is three of them",
7957 );
7958 }
7959
7960 /// The negated-class spelling from the report, and the two other
7961 /// uppercase escapes that invert: `\W` (not a word character) and
7962 /// `\S` (not whitespace). `[^\D]` is "not a non-digit", i.e. a digit;
7963 /// folded to `[^\d]` it becomes its own complement.
7964 ///
7965 /// To SEE THIS RED: same mutation as
7966 /// `a_tree_hostname_regex_escape_is_not_case_folded`. Every
7967 /// `matches` assertion below fails, each with its complement.
7968 #[test]
7969 fn every_inverting_uppercase_regex_escape_survives_a_tree_insert() {
7970 // (segment source, a host label it must match, one it must not)
7971 for (segment, hit, miss) in [
7972 ("[^\\D]+", "777", "abc"),
7973 ("\\W+", "---", "abc"),
7974 ("\\S+", "abc", " "),
7975 ] {
7976 let mut router = Router::new();
7977 let hostname = format!("/{segment}/.rx.example.com");
7978 assert!(
7979 router.add_tree_rule(
7980 hostname.as_bytes(),
7981 &PathRule::Prefix("/".to_owned()),
7982 &MethodRule::new(None),
7983 &Route::ClusterId("ESCAPE".into()),
7984 ),
7985 "{hostname} must install",
7986 );
7987
7988 let resolve = |hostname: &str| {
7989 router
7990 .lookup(hostname, "/", &Method::Get)
7991 .ok()
7992 .and_then(|result| result.cluster_id)
7993 };
7994
7995 assert_eq!(
7996 resolve(&format!("{hit}.rx.example.com")).as_deref(),
7997 Some("ESCAPE"),
7998 "{segment} must match {hit}",
7999 );
8000 assert_eq!(
8001 resolve(&format!("{miss}.rx.example.com")),
8002 None,
8003 "{segment} must not match {miss}",
8004 );
8005 }
8006 }
8007
8008 /// The other half of the split: a LITERAL label is still a domain
8009 /// label and still goes through IDNA, in the very same hostname whose
8010 /// regex segment is copied verbatim. Only a `/`-delimited segment is
8011 /// exempt -- the same split `convert_regex_domain_rule` and the trie's
8012 /// `insert_recursive` already make.
8013 ///
8014 /// `remove_tree_rule` normalises through the same helper, so the rule
8015 /// stays addressable: a second spelling of the stored key would make
8016 /// the frontend permanently un-removable.
8017 ///
8018 /// To SEE THIS RED: same mutation as
8019 /// `a_tree_hostname_regex_escape_is_not_case_folded`. The first
8020 /// assertion fails with `left: None, right: Some("IDN")` -- the
8021 /// punycode is right, the folded `\d+` segment is what misses.
8022 #[test]
8023 fn a_unicode_label_is_punycoded_while_its_sibling_regex_segment_is_not() {
8024 let hostname = "/\\D+/.MÜNCHEN.example.com";
8025 let mut router = Router::new();
8026 assert!(router.add_tree_rule(
8027 hostname.as_bytes(),
8028 &PathRule::Prefix("/".to_owned()),
8029 &MethodRule::new(None),
8030 &Route::ClusterId("IDN".into()),
8031 ));
8032
8033 // `idna::domain_to_ascii("MÜNCHEN")` -> `"xn--mnchen-3ya"`
8034 // (measured, and identical label-by-label and whole-domain).
8035 assert_eq!(
8036 router
8037 .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8038 .ok()
8039 .and_then(|result| result.cluster_id)
8040 .as_deref(),
8041 Some("IDN"),
8042 "the literal label must still be punycoded and lowercased",
8043 );
8044
8045 assert!(
8046 router.remove_tree_rule(
8047 hostname.as_bytes(),
8048 &PathRule::Prefix("/".to_owned()),
8049 &MethodRule::new(None),
8050 ),
8051 "the frontend must still be addressable by the spelling that \
8052 installed it",
8053 );
8054 assert!(
8055 router
8056 .lookup("abc.xn--mnchen-3ya.example.com", "/", &Method::Get)
8057 .is_err(),
8058 "and removing it must actually unroute it",
8059 );
8060 }
8061
8062 // ═══════════════════════════════════════════════════════════════════
8063 // Property-test harness: sozu#1349, sozu#1351, sozu#1356, sozu#1377
8064 // ═══════════════════════════════════════════════════════════════════
8065 //
8066 // Every regression test above pins ONE fixed example. This harness
8067 // generates many, against an ORACLE that is independent of the trie:
8068 // it does not call `pattern_trie::anchored_segment`,
8069 // `compiled_segment`, `TrieNode::lookup*`, `tree_hostname_to_ascii` or
8070 // `convert_regex_domain_rule` — reusing any of those would make the
8071 // oracle agree with the implementation by construction, proving
8072 // nothing. It is written fresh from `doc/configure.md`'s "Hostname
8073 // precedence" and "Regex hostname segments" sections and the RFC text
8074 // those sections cite (RFC 9110 §4.2.3 for case, RFC 9110's own
8075 // grammar for the wildcard), and it uses the `regex` crate only as a
8076 // primitive — exactly as `pattern_trie` does — never the router's own
8077 // wrapping of it.
8078 //
8079 // Scope, stated explicitly because it is a deliberate narrowing: every
8080 // generated rule and every query share the fixed two-label suffix
8081 // `example.com`, one variable FRONT label, and `MethodRule::new(None)`
8082 // (matches every method). This is what puts every rule in a scenario
8083 // on the SAME trie branch, which is where sozu#1351's leak and
8084 // precedence questions live, and it removes METHOD precedence from
8085 // the oracle's job — a separate concern with its own existing tests
8086 // (`case_normalisation_runs_before_the_hostname_precedence_search`
8087 // and neighbours). PATH does vary — see `PATH_POOL` — because sozu#1351's
8088 // actual production symptom needs it: with every rule pinned to the
8089 // SAME path, a literal whose insert would leak onto a regex leaf finds
8090 // that leaf already carrying an identical `(path, method)`, so
8091 // `add_tree_rule`'s append-skip condition is always false and the
8092 // insert is refused before any lookup happens — the leak is real but
8093 // observable only as `add_tree_rule` returning `false`, never as a
8094 // routing mismatch. Varying path is what lets the SAME leak surface the
8095 // way it did in production: an exact rule at one path answering a
8096 // request for a hostname nobody declared, at a DIFFERENT declared path.
8097 //
8098 // The generator is biased, not uniform: `REGEX_CATALOG` is the fixed
8099 // set of segment shapes that actually bit sozu, `LITERAL_POOL`
8100 // deliberately overlaps those shapes' `hits` so an exact rule and a
8101 // regex rule naturally collide on the same host (sozu#1351), case is
8102 // varied on both the declared rule and the query independently
8103 // (sozu#1349), declaration order is an explicit Fisher-Yates shuffle
8104 // driven by the same `Gen` (sozu#1351 again — order must not move an
8105 // exact-vs-regex verdict, and must move a regex-vs-regex one), and
8106 // queries cross every generated hit/miss hostname against every path
8107 // declared anywhere in the scenario, not just the declaring rule's own
8108 // (sozu#1351's leak crosses fronts, so a query pairing has to as well).
8109 //
8110 // Two honesty notes about this harness's own limits, not the router's:
8111 //
8112 // - **No reproducible seed.** `quickcheck` 1.1.0's `Gen::new` seeds
8113 // from OS entropy with no seed exposed to reproduce a specific run,
8114 // unlike the FoundationDB-style simulators `doc/testing.md` §5
8115 // describes (`SOZU_UDP_SIM_SEED` and friends). A failing CI run here
8116 // is reproduced by RE-RUNNING locally with a raised `QUICKCHECK_TESTS`
8117 // (this property's generator is dense enough that a real regression
8118 // reappears quickly), not by replaying the failing seed — there is
8119 // none to replay. Not fixed in this changeset.
8120 // - **The anchoring convention is shared, not external.** This oracle's
8121 // `\A(?:source)\z` case-insensitive anchoring matches
8122 // `doc/configure.md`'s "Regex hostname segments" section — but that
8123 // section's prose was itself ADDED by the sozu#1356 fix this harness
8124 // is supposed to catch a regression of. So for the GROUPING half of
8125 // that anchoring, the oracle and the implementation share a
8126 // convention rather than the oracle checking against an independent
8127 // spec; a regression that un-groups BOTH the doc and the code back to
8128 // their pre-#1356 form would not be caught by prose comparison alone
8129 // — it is caught here because the oracle's grouping is also
8130 // independently reasoned from what `|`'s precedence in a regex
8131 // grammar requires, the same reasoning #1356's own fix commit gives.
8132 // The CASE-FOLDING half (`(?i)`) is genuinely independent, derived
8133 // straight from RFC 9110 §4.2.3, not from anything #1356 or #1377
8134 // added to `doc/configure.md`.
8135
8136 /// One `/regex/` hostname segment source this harness declares, with
8137 /// concrete labels independently reasoned to match or not match it
8138 /// under `\A(?:source)\z` compiled case-insensitively — the anchoring
8139 /// `doc/configure.md` documents and sozu#1356 restored, built fresh
8140 /// here rather than borrowed from `anchored_segment`.
8141 struct RegexCase {
8142 source: &'static str,
8143 /// Whole-label matches for one branch of the pattern.
8144 hits: &'static [&'static str],
8145 /// Labels that must NOT match under the documented semantics, but
8146 /// that a pre-fix implementation matched: a half-anchored
8147 /// alternation branch (sozu#1356 — `axx`, `xxb`, the bare
8148 /// substring `zzbzz` for a 3-way alternation's ungrouped middle
8149 /// branch) or the inverted class an ASCII-lowercase fold produces
8150 /// (sozu#1377 — folding `\D` into `\d` turns "not a digit" into
8151 /// "a digit").
8152 misses: &'static [&'static str],
8153 }
8154
8155 const REGEX_CATALOG: &[RegexCase] = &[
8156 RegexCase {
8157 source: "a|b",
8158 hits: &["a", "b"],
8159 misses: &["axx", "xxb", "xx"],
8160 },
8161 RegexCase {
8162 source: "a|b|c",
8163 // The 3-branch case two branches cannot express: ungrouped,
8164 // the MIDDLE branch of `\Aa|b|c\z` keeps NEITHER anchor and
8165 // matches as a bare substring anywhere in the label.
8166 hits: &["a", "b", "c"],
8167 misses: &["axx", "xxc", "zzbzz", "bzz", "zzb"],
8168 },
8169 RegexCase {
8170 source: "api|admin",
8171 // `doc/configure.md`'s own worked example for sozu#1356.
8172 hits: &["api", "admin"],
8173 misses: &["apifoo", "xadmin"],
8174 },
8175 RegexCase {
8176 source: "\\D+",
8177 // sozu#1377's own measured shape: folded to `\d+`, this
8178 // matched digits and rejected letters — the complement.
8179 hits: &["abc", "xyz"],
8180 misses: &["777", "42"],
8181 },
8182 RegexCase {
8183 source: "\\W+",
8184 hits: &["---", "..."],
8185 misses: &["abc", "42a"],
8186 },
8187 RegexCase {
8188 source: "\\S+",
8189 hits: &["abc", "777"],
8190 // Folded to `\s`, this would match ONLY whitespace; no ASCII
8191 // hostname label the wire ever admits can carry one, but
8192 // `Router::lookup` takes a plain `&str` and does not itself
8193 // re-validate the charset, so the harness can still probe it.
8194 misses: &[" "],
8195 },
8196 RegexCase {
8197 source: "[^\\D]+",
8198 // sozu#1377's negated-class shape from the issue itself:
8199 // "not a non-digit" is a digit; folded it becomes its own
8200 // complement.
8201 hits: &["777", "42"],
8202 misses: &["abc"],
8203 },
8204 RegexCase {
8205 source: "API[0-9]",
8206 // An uppercase literal INSIDE a class: must still meet the
8207 // lowercased lookup key by folding at compile time, not by
8208 // folding the stored source (which would invert the escapes
8209 // above in the very same pass).
8210 hits: &["api7", "API7", "ApI4"],
8211 misses: &["apiZ"],
8212 },
8213 RegexCase {
8214 source: "test[0-9]",
8215 // sozu#1351's own shape: `test4` is also `LITERAL_POOL`'s
8216 // exact-rule text, so a literal `test4` rule and this regex
8217 // rule collide on the same host with decent probability.
8218 hits: &["test4", "TEST7", "Test9"],
8219 misses: &["testA", "test10x"],
8220 },
8221 RegexCase {
8222 source: "[a-z]+[0-9]",
8223 hits: &["test4", "cdn1"],
8224 misses: &["4test", "TEST"],
8225 },
8226 ];
8227
8228 /// Literal exact-rule candidates. Deliberately overlapping with
8229 /// `REGEX_CATALOG`'s `hits` — `test4`, `api`, `admin`, `a`, `b`, `c`
8230 /// all reappear there — so an exact rule and an overlapping regex
8231 /// rule land in the SAME generated scenario often enough to exercise
8232 /// sozu#1351 without hand-injecting a forced pair.
8233 const LITERAL_POOL: &[&str] = &[
8234 "a", "b", "c", "api", "admin", "test4", "test7", "test9", "TEST4", "API7", "abc", "777",
8235 "42", "cdn1", "zz", "apifoo", "xadmin",
8236 ];
8237
8238 /// Declared-path candidates. Small and deliberately NOT elaborate: the
8239 /// production shape of sozu#1351 needs exactly two rules at two
8240 /// different paths (reproduced live against the reverted fix: an exact
8241 /// `test4.example.com` frontend at `/other` leaking onto the
8242 /// `/test[0-9]/.example.com` regex family, answering `test7.example.com`
8243 /// requests for `/other`). `"/"` is a prefix of every other entry here
8244 /// and is weighted to appear often, so a scenario still frequently
8245 /// declares two fronts at the SAME path (exercising the harness's
8246 /// pre-existing hostname-only coverage) as well as at different ones
8247 /// (exercising the path dimension this fixes). Every entry is used only
8248 /// as a `PathRule::Prefix`; `PathRule::Equals`/`PathRule::Regex` are not
8249 /// generated — see the module doc comment for why method stays fixed
8250 /// too, and the same reasoning applies here: sozu#1351 needs two
8251 /// declared paths to differ, not the full path-matching precedence
8252 /// surface, which has its own existing tests.
8253 const PATH_POOL: &[&str] = &["/", "/", "/other", "/api", "/only"];
8254
8255 /// The dot-separated FRONT label of a generated Tree-position
8256 /// hostname rule. Every rule in a [`Scenario`] shares the fixed
8257 /// trailing `example.com` — see the harness's module-level doc
8258 /// comment for why.
8259 #[derive(Clone, Copy, Debug)]
8260 enum FrontLabel {
8261 /// A literal label, in whatever case the generator chose.
8262 /// `tree_hostname_to_ascii` case-folds it on insert (sozu#1377).
8263 Literal(&'static str),
8264 /// The leftmost-only wildcard, exactly one label.
8265 Wildcard,
8266 /// A `/regex/` segment, addressed into [`REGEX_CATALOG`].
8267 Regex(usize),
8268 }
8269
8270 /// Dedup identity for a [`FrontLabel`] within one scenario. Two
8271 /// literals that differ only by case must be treated as the SAME
8272 /// front: `add_tree_rule` case-folds them to the same trie node, and
8273 /// generating both would silently drop one rule's cluster id from the
8274 /// harness's own model — a harness bug, not something under test.
8275 #[derive(Clone, PartialEq, Eq, Debug)]
8276 enum FrontKey {
8277 Literal(String),
8278 Wildcard,
8279 Regex(usize),
8280 }
8281
8282 impl From<FrontLabel> for FrontKey {
8283 fn from(front: FrontLabel) -> Self {
8284 match front {
8285 FrontLabel::Literal(text) => FrontKey::Literal(text.to_ascii_lowercase()),
8286 FrontLabel::Wildcard => FrontKey::Wildcard,
8287 FrontLabel::Regex(idx) => FrontKey::Regex(idx),
8288 }
8289 }
8290 }
8291
8292 #[derive(Clone, Debug)]
8293 struct GenRule {
8294 front: FrontLabel,
8295 /// This rule's OWN declared path, from [`PATH_POOL`]. Every front
8296 /// carries exactly one `(path, route)` pair in this harness's
8297 /// model — one `add_tree_rule` call per generated front — so there
8298 /// is no within-one-hostname path-precedence list to model; only
8299 /// whether THIS front's path serves a given query path.
8300 path: &'static str,
8301 cluster: String,
8302 }
8303
8304 /// One generated case: a set of Tree-position rules, in DECLARATION
8305 /// order (already shuffled — see [`Scenario::arbitrary`]), and a set
8306 /// of (hostname, path) queries to look up against them.
8307 #[derive(Clone, Debug)]
8308 struct Scenario {
8309 rules: Vec<GenRule>,
8310 queries: Vec<(String, String)>,
8311 }
8312
8313 fn render_hostname(front: FrontLabel) -> String {
8314 match front {
8315 FrontLabel::Literal(text) => format!("{text}.example.com"),
8316 FrontLabel::Wildcard => "*.example.com".to_owned(),
8317 FrontLabel::Regex(idx) => format!("/{}/.example.com", REGEX_CATALOG[idx].source),
8318 }
8319 }
8320
8321 /// The oracle's OWN anchoring: `\A(?:source)\z`, case-insensitive.
8322 /// Built fresh from `doc/configure.md`'s "Regex hostname segments"
8323 /// section and the sozu#1356 fix's stated invariant, not by calling
8324 /// `pattern_trie::anchored_segment` or `compiled_segment`. Using the
8325 /// `regex` crate as a primitive is not reusing the implementation:
8326 /// regex matching itself is not what any of the four issues broke —
8327 /// Sozu's own anchoring, grouping and case handling around it is.
8328 fn oracle_regex_matches(source: &str, label: &str) -> bool {
8329 let pattern = format!("(?i)\\A(?:{source})\\z");
8330 Regex::new(&pattern)
8331 .unwrap_or_else(|error| {
8332 panic!("the harness's own catalog regex {source:?} must compile: {error}")
8333 })
8334 .is_match(label.as_bytes())
8335 }
8336
8337 fn front_label_matches(front: FrontLabel, query_label: &str) -> bool {
8338 match front {
8339 FrontLabel::Literal(text) => text.eq_ignore_ascii_case(query_label),
8340 FrontLabel::Wildcard => !query_label.is_empty(),
8341 FrontLabel::Regex(idx) => oracle_regex_matches(REGEX_CATALOG[idx].source, query_label),
8342 }
8343 }
8344
8345 /// Whether `front` (this harness's stand-in for one declared Tree
8346 /// rule) matches the full hostname `query` — case-insensitively on
8347 /// the fixed `example.com` suffix (RFC 9110 §4.2.3, sozu#1349) and
8348 /// per [`front_label_matches`] on the one variable label.
8349 fn oracle_hostname_matches(front: FrontLabel, query: &str) -> bool {
8350 let mut labels = query.split('.');
8351 let (Some(first), Some(second), Some(third), None) =
8352 (labels.next(), labels.next(), labels.next(), labels.next())
8353 else {
8354 return false;
8355 };
8356 second.eq_ignore_ascii_case("example")
8357 && third.eq_ignore_ascii_case("com")
8358 && front_label_matches(front, first)
8359 }
8360
8361 /// `PathRule::Prefix` semantics (`PathRule::matches`, `router/mod.rs`):
8362 /// a rule matches iff the query path starts with the declared path's
8363 /// bytes. Every generated front carries exactly one path, so there is
8364 /// no longest-prefix tie-break to model here — see [`GenRule::path`].
8365 fn oracle_path_matches(rule_path: &str, query_path: &str) -> bool {
8366 query_path.starts_with(rule_path)
8367 }
8368
8369 /// The oracle's precedence search, stated in `doc/configure.md` under
8370 /// "Hostname precedence" and restored by sozu#1351: the EXACT name,
8371 /// then a REGEX segment (the first declared among those that match),
8372 /// then the `*` WILDCARD — each candidate accepted only when it ALSO
8373 /// serves this request's path (`Router::lookup` hands the trie the
8374 /// same path-serving predicate it uses on the winner, so a hostname
8375 /// candidate that matches but does not serve this path is skipped
8376 /// rather than ending the search — `doc/configure.md`, "Hostname
8377 /// precedence": "a search, not a filter"). Exact-vs-regex hostname
8378 /// precedence is checked in a pass that does not depend on `rules`'
8379 /// order at all — the documented, order-independent half of the
8380 /// invariant sozu#1351 restored. Regex-vs-regex precedence is "first
8381 /// in `rules`' own order that matches (hostname AND path)" — the half
8382 /// that DOES still depend on declaration order, by design
8383 /// (`doc/configure.md`, "Regex hostname segments").
8384 fn oracle_lookup<'r>(rules: &'r [GenRule], hostname: &str, path: &str) -> Option<&'r str> {
8385 let serves = |rule: &&GenRule| {
8386 oracle_hostname_matches(rule.front, hostname) && oracle_path_matches(rule.path, path)
8387 };
8388 rules
8389 .iter()
8390 .find(|rule| matches!(rule.front, FrontLabel::Literal(_)) && serves(rule))
8391 .or_else(|| {
8392 rules
8393 .iter()
8394 .find(|rule| matches!(rule.front, FrontLabel::Regex(_)) && serves(rule))
8395 })
8396 .or_else(|| {
8397 rules
8398 .iter()
8399 .find(|rule| matches!(rule.front, FrontLabel::Wildcard) && serves(rule))
8400 })
8401 .map(|rule| rule.cluster.as_str())
8402 }
8403
8404 /// Vary the case of `s`: as declared, all-uppercase, all-lowercase, or
8405 /// a per-character random mix — independently of whatever case the
8406 /// OTHER side (rule vs. query) picked, so RFC 9110 §4.2.3
8407 /// case-insensitivity (sozu#1349) is exercised on both sides and not
8408 /// just one.
8409 fn case_variant(g: &mut Gen, s: &str) -> String {
8410 match u8::arbitrary(g) % 4 {
8411 0 => s.to_owned(),
8412 1 => s.to_ascii_uppercase(),
8413 2 => s.to_ascii_lowercase(),
8414 _ => s
8415 .chars()
8416 .map(|c| {
8417 if bool::arbitrary(g) {
8418 c.to_ascii_uppercase()
8419 } else {
8420 c.to_ascii_lowercase()
8421 }
8422 })
8423 .collect(),
8424 }
8425 }
8426
8427 fn push_unique_front(
8428 front: FrontLabel,
8429 path: &'static str,
8430 rules: &mut Vec<GenRule>,
8431 seen: &mut Vec<FrontKey>,
8432 ) {
8433 let key = FrontKey::from(front);
8434 if seen.contains(&key) {
8435 return;
8436 }
8437 seen.push(key);
8438 let cluster = format!("C{}", rules.len());
8439 rules.push(GenRule {
8440 front,
8441 path,
8442 cluster,
8443 });
8444 }
8445
8446 /// One hit and one miss hostname sample per declared front (see
8447 /// [`RegexCase`]'s doc comment for what those pin), plus three
8448 /// universal hostname probes every scenario gets regardless of what it
8449 /// declared: an unrelated host that must match nothing, the bare
8450 /// suffix with the front label dropped entirely (must miss even a
8451 /// declared wildcard, which stands for exactly one label and not
8452 /// zero), and its uppercase spelling.
8453 ///
8454 /// Each hit hostname is queried at EVERY distinct path declared
8455 /// anywhere in the scenario, not just its own front's path — sozu#1351's
8456 /// leak crosses fronts (an exact rule's cluster answering a DIFFERENT
8457 /// hostname, at whatever path THAT hostname's own rule declared), so a
8458 /// query pairing that only ever asked a front's own path could not
8459 /// observe it. Miss hostnames and the universal probes are checked only
8460 /// at their own front's path (or `/`): they exist to confirm a mismatch
8461 /// does not appear where none is expected, which does not need the
8462 /// cross product.
8463 fn push_hit_queries(
8464 g: &mut Gen,
8465 label: &str,
8466 declared_paths: &[&str],
8467 queries: &mut Vec<(String, String)>,
8468 ) {
8469 for &path in declared_paths {
8470 queries.push((
8471 format!("{}.example.com", case_variant(g, label)),
8472 path.to_owned(),
8473 ));
8474 }
8475 }
8476
8477 fn generate_queries(g: &mut Gen, rules: &[GenRule]) -> Vec<(String, String)> {
8478 let mut queries = Vec::new();
8479 let mut declared_paths: Vec<&str> = rules.iter().map(|rule| rule.path).collect();
8480 declared_paths.sort_unstable();
8481 declared_paths.dedup();
8482
8483 for rule in rules {
8484 match rule.front {
8485 FrontLabel::Literal(text) => {
8486 push_hit_queries(g, text, &declared_paths, &mut queries);
8487 }
8488 FrontLabel::Wildcard => {
8489 let label = *g.choose(LITERAL_POOL).unwrap_or(&"zz");
8490 push_hit_queries(g, label, &declared_paths, &mut queries);
8491 }
8492 FrontLabel::Regex(idx) => {
8493 let case = ®EX_CATALOG[idx];
8494 if let Some(&hit) = g.choose(case.hits) {
8495 push_hit_queries(g, hit, &declared_paths, &mut queries);
8496 }
8497 if let Some(miss) = g.choose(case.misses) {
8498 queries.push((
8499 format!("{}.example.com", case_variant(g, miss)),
8500 rule.path.to_owned(),
8501 ));
8502 }
8503 }
8504 }
8505 }
8506
8507 queries.push((
8508 format!("{}.example.com", case_variant(g, "unmatched-host")),
8509 "/".to_owned(),
8510 ));
8511 queries.push(("example.com".to_owned(), "/".to_owned()));
8512 queries.push(("EXAMPLE.COM".to_owned(), "/".to_owned()));
8513
8514 queries
8515 }
8516
8517 impl Arbitrary for Scenario {
8518 fn arbitrary(g: &mut Gen) -> Self {
8519 let mut rules = Vec::new();
8520 let mut seen_keys = Vec::new();
8521
8522 let pick_path = |g: &mut Gen| {
8523 *g.choose(PATH_POOL)
8524 .expect("PATH_POOL is a non-empty const slice")
8525 };
8526
8527 // With even odds, include the wildcard: it participates in
8528 // every precedence chain and costs only one extra rule.
8529 if bool::arbitrary(g) {
8530 let path = pick_path(g);
8531 push_unique_front(FrontLabel::Wildcard, path, &mut rules, &mut seen_keys);
8532 }
8533
8534 // 1..=3 additional fronts, each independently a regex segment
8535 // or a literal drawn from the overlap-biased pool, each with
8536 // its own independently chosen path.
8537 let extra = 1 + (u8::arbitrary(g) % 3);
8538 for _ in 0..extra {
8539 if bool::arbitrary(g) {
8540 let idx = usize::from(u8::arbitrary(g)) % REGEX_CATALOG.len();
8541 let path = pick_path(g);
8542 push_unique_front(FrontLabel::Regex(idx), path, &mut rules, &mut seen_keys);
8543 } else {
8544 let text = *g
8545 .choose(LITERAL_POOL)
8546 .expect("LITERAL_POOL is a non-empty const slice");
8547 let path = pick_path(g);
8548 push_unique_front(FrontLabel::Literal(text), path, &mut rules, &mut seen_keys);
8549 }
8550 }
8551
8552 // Declaration order is itself under test (sozu#1351): shuffle
8553 // with a Fisher-Yates driven by the same `Gen`, independent of
8554 // insertion order above.
8555 for i in (1..rules.len()).rev() {
8556 let j = usize::from(u8::arbitrary(g)) % (i + 1);
8557 rules.swap(i, j);
8558 }
8559
8560 let queries = generate_queries(g, &rules);
8561 Scenario { rules, queries }
8562 }
8563
8564 fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
8565 let mut shrunk = Vec::new();
8566
8567 for i in 0..self.rules.len() {
8568 let mut rules = self.rules.clone();
8569 rules.remove(i);
8570 shrunk.push(Scenario {
8571 rules,
8572 queries: self.queries.clone(),
8573 });
8574 }
8575
8576 for i in 0..self.queries.len() {
8577 let mut queries = self.queries.clone();
8578 queries.remove(i);
8579 shrunk.push(Scenario {
8580 rules: self.rules.clone(),
8581 queries,
8582 });
8583 }
8584
8585 Box::new(shrunk.into_iter())
8586 }
8587 }
8588
8589 /// Every catalog entry's own `hits`/`misses` verdicts, checked against
8590 /// nothing but [`oracle_regex_matches`] — a self-check that the
8591 /// harness's table is internally consistent BEFORE it is trusted to
8592 /// judge the router. This does not touch `Router` or `TrieNode` at
8593 /// all.
8594 #[test]
8595 fn regex_catalog_samples_match_their_own_documented_semantics() {
8596 for case in REGEX_CATALOG {
8597 for hit in case.hits {
8598 assert!(
8599 oracle_regex_matches(case.source, hit),
8600 "{:?} must match {hit:?} under \\A(?:...)\\z case-insensitive",
8601 case.source,
8602 );
8603 }
8604 for miss in case.misses {
8605 assert!(
8606 !oracle_regex_matches(case.source, miss),
8607 "{:?} must NOT match {miss:?} under \\A(?:...)\\z case-insensitive",
8608 case.source,
8609 );
8610 }
8611 }
8612 }
8613
8614 /// Build the rules `scenario` declares into a fresh [`Router`], in
8615 /// its own declaration order, then check every query against both the
8616 /// oracle and the router.
8617 fn oracle_matches_router(scenario: Scenario) -> TestResult {
8618 if scenario.rules.is_empty() {
8619 return TestResult::discard();
8620 }
8621
8622 let mut router = Router::new();
8623 for rule in &scenario.rules {
8624 let hostname = render_hostname(rule.front);
8625 // A refused insert here is not automatically dismissed as a
8626 // harness artifact: sozu#1351's own mechanism (a literal
8627 // resolving through a matching regex segment because
8628 // `TrieNode::lookup_mut`'s guard is gone) can make this EXACT
8629 // call return `false`, when the two fronts' generated paths
8630 // happen to coincide -- see the module doc comment. Every
8631 // front here is still unique (`FrontKey`-deduped) and every
8632 // catalog regex compiles standalone, so a refusal is real
8633 // signal either way: fail loudly and report it rather than
8634 // silently skipping the rule, which would just hide the
8635 // question inside a `None` at lookup time instead of at
8636 // insert time.
8637 if !router.add_tree_rule(
8638 hostname.as_bytes(),
8639 &PathRule::Prefix(rule.path.to_owned()),
8640 &MethodRule::new(None),
8641 &Route::ClusterId(rule.cluster.as_str().into()),
8642 ) {
8643 eprintln!(
8644 "INSERT REFUSED hostname={hostname:?} path={:?} cluster={:?} \
8645 declared-so-far={:?}",
8646 rule.path,
8647 rule.cluster,
8648 scenario
8649 .rules
8650 .iter()
8651 .map(|r| (render_hostname(r.front), r.path))
8652 .collect::<Vec<_>>(),
8653 );
8654 return TestResult::failed();
8655 }
8656 }
8657
8658 for (hostname, path) in &scenario.queries {
8659 let expected = oracle_lookup(&scenario.rules, hostname, path);
8660 let actual = router
8661 .lookup(hostname, path, &Method::Get)
8662 .ok()
8663 .and_then(|result| result.cluster_id);
8664
8665 if expected != actual.as_deref() {
8666 let declared: Vec<(String, &str)> = scenario
8667 .rules
8668 .iter()
8669 .map(|rule| (render_hostname(rule.front), rule.path))
8670 .collect();
8671 eprintln!(
8672 "MISMATCH host={hostname:?} path={path:?} expected={expected:?} \
8673 actual={actual:?} declared (in order, with path)={declared:?}",
8674 );
8675 return TestResult::failed();
8676 }
8677 }
8678
8679 TestResult::passed()
8680 }
8681
8682 /// Request headers for a header-predicate lookup: first occurrence,
8683 /// names compared case-insensitively — the contract
8684 /// `FrontRequestHeaders` (`lib/src/protocol/mux/router.rs`) implements
8685 /// over a real request.
8686 struct TestHeaders<'a>(&'a [(&'a str, &'a str)]);
8687
8688 impl RequestHeaders for TestHeaders<'_> {
8689 fn header(&self, name: &[u8]) -> Option<&[u8]> {
8690 self.0
8691 .iter()
8692 .find(|(key, _)| key.as_bytes().eq_ignore_ascii_case(name))
8693 .map(|(_, value)| value.as_bytes())
8694 }
8695 }
8696
8697 fn predicate(name: &str, kind: HeaderMatchKind, value: Option<&str>) -> HeaderPredicate {
8698 HeaderPredicate {
8699 name: name.to_owned(),
8700 kind: kind as i32,
8701 value: value.map(str::to_owned),
8702 }
8703 }
8704
8705 fn routed_by_headers(router: &Router, path: &str, headers: &[(&str, &str)]) -> Option<String> {
8706 router
8707 .lookup_with_headers("example.com", path, &Method::Get, &TestHeaders(headers))
8708 .ok()
8709 .and_then(|route| route.cluster_id)
8710 .map(|id| id.to_string())
8711 }
8712
8713 /// sozu-proxy/sozu#1253: a canary frontend that differs from its base
8714 /// only by a header predicate serves exactly the requests carrying the
8715 /// header, whichever of the two was declared first and in every rule
8716 /// position. Before predicates were a routing input, the two frontends
8717 /// had one identity, so the second addition was refused.
8718 #[test]
8719 fn a_header_predicate_canary_outranks_its_base_in_any_declaration_order() {
8720 let canary_predicates = vec![predicate(
8721 "X-Canary",
8722 HeaderMatchKind::Exact,
8723 Some("always"),
8724 )];
8725 for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
8726 for path in [
8727 CommandPathRule::prefix("/api".to_owned()),
8728 CommandPathRule::equals("/api".to_owned()),
8729 ] {
8730 for canary_first in [false, true] {
8731 let base = HttpFrontend {
8732 cluster_id: Some("stable".into()),
8733 path: path.clone(),
8734 position,
8735 ..test_http_frontend()
8736 };
8737 let canary = HttpFrontend {
8738 cluster_id: Some("canary".into()),
8739 header_predicates: canary_predicates.clone(),
8740 ..base.clone()
8741 };
8742 let mut router = Router::new();
8743 let order = if canary_first {
8744 [&canary, &base]
8745 } else {
8746 [&base, &canary]
8747 };
8748 for front in order {
8749 router
8750 .add_http_front(front)
8751 .expect("a canary and its base must coexist");
8752 }
8753
8754 let context = format!("{position:?} {path:?} canary_first={canary_first}");
8755 assert_eq!(
8756 routed_by_headers(&router, "/api", &[("x-canary", "always")]).as_deref(),
8757 Some("canary"),
8758 "{context}: a request carrying the header must reach the canary",
8759 );
8760 assert_eq!(
8761 routed_by_headers(&router, "/api", &[]).as_deref(),
8762 Some("stable"),
8763 "{context}: a request without the header must reach the base",
8764 );
8765 assert_eq!(
8766 routed_by_headers(&router, "/api", &[("X-Canary", "Always")]).as_deref(),
8767 Some("stable"),
8768 "{context}: an exact value is compared byte for byte",
8769 );
8770 assert_eq!(
8771 router
8772 .lookup("example.com", "/api", &Method::Get)
8773 .ok()
8774 .and_then(|route| route.cluster_id)
8775 .as_deref(),
8776 Some("stable"),
8777 "{context}: a header-less lookup never matches a predicated rule",
8778 );
8779 }
8780 }
8781 }
8782 }
8783
8784 /// Predicates are a conjunction; names match case-insensitively; the
8785 /// first occurrence of a header answers; a `regex` value matches the
8786 /// WHOLE value, like a `REGEX` path rule; `present` accepts any value,
8787 /// the empty one included.
8788 #[test]
8789 fn header_predicates_hold_together_with_their_documented_semantics() {
8790 let mut router = Router::new();
8791 let front = HttpFrontend {
8792 cluster_id: Some("eu-beta".into()),
8793 header_predicates: vec![
8794 predicate("x-region", HeaderMatchKind::Regex, Some("eu-.*")),
8795 predicate("X-BETA", HeaderMatchKind::Present, None),
8796 ],
8797 ..test_http_frontend()
8798 };
8799 router.add_http_front(&front).expect("valid predicates");
8800 router
8801 .add_http_front(&test_http_frontend())
8802 .expect("the base frontend");
8803
8804 let route = |headers: &[(&str, &str)]| routed_by_headers(&router, "/", headers);
8805 assert_eq!(
8806 route(&[("X-Region", "eu-west"), ("x-beta", "")]).as_deref(),
8807 Some("eu-beta")
8808 );
8809 assert_eq!(
8810 route(&[("X-Region", "eu-west")]).as_deref(),
8811 Some("cluster"),
8812 "every predicate must hold",
8813 );
8814 assert_eq!(
8815 route(&[("X-Region", "us-eu-west"), ("x-beta", "1")]).as_deref(),
8816 Some("cluster"),
8817 "a regex is anchored at the start of the value",
8818 );
8819 assert_eq!(
8820 route(&[
8821 ("X-Region", "us-east"),
8822 ("X-Region", "eu-west"),
8823 ("x-beta", "1")
8824 ])
8825 .as_deref(),
8826 Some("cluster"),
8827 "only the first occurrence of a header answers",
8828 );
8829 }
8830
8831 /// Removing a frontend removes the rule of exactly its predicates: the
8832 /// base survives the canary's removal and the reverse, and a removal
8833 /// that spells the same conjunction in another order or case finds it.
8834 #[test]
8835 fn removing_a_predicated_frontend_leaves_its_base_and_the_reverse() {
8836 for position in [RulePosition::Tree, RulePosition::Pre, RulePosition::Post] {
8837 let base = HttpFrontend {
8838 cluster_id: Some("stable".into()),
8839 position,
8840 ..test_http_frontend()
8841 };
8842 let canary = HttpFrontend {
8843 cluster_id: Some("canary".into()),
8844 header_predicates: vec![
8845 predicate("X-Canary", HeaderMatchKind::Present, None),
8846 predicate("X-Tenant", HeaderMatchKind::Exact, Some("acme")),
8847 ],
8848 ..base.clone()
8849 };
8850 let canary_respelled = HttpFrontend {
8851 header_predicates: vec![
8852 predicate("x-tenant", HeaderMatchKind::Exact, Some("acme")),
8853 predicate("x-canary", HeaderMatchKind::Present, None),
8854 ],
8855 ..canary.clone()
8856 };
8857 let canary_headers = [("X-Canary", "1"), ("X-Tenant", "acme")];
8858
8859 let mut router = Router::new();
8860 router.add_http_front(&base).expect("base");
8861 router.add_http_front(&canary).expect("canary");
8862 assert!(
8863 router.add_http_front(&canary_respelled).is_err(),
8864 "{position:?}: a respelled conjunction is the same rule",
8865 );
8866 router
8867 .remove_http_front(&canary_respelled)
8868 .expect("a respelled removal must find the canary");
8869 assert_eq!(
8870 routed_by_headers(&router, "/", &canary_headers).as_deref(),
8871 Some("stable"),
8872 "{position:?}: the base must survive the canary's removal",
8873 );
8874
8875 router.add_http_front(&canary).expect("canary again");
8876 router.remove_http_front(&base).expect("base removal");
8877 assert_eq!(
8878 routed_by_headers(&router, "/", &canary_headers).as_deref(),
8879 Some("canary"),
8880 "{position:?}: the canary must survive the base's removal",
8881 );
8882 assert_eq!(
8883 routed_by_headers(&router, "/", &[]),
8884 None,
8885 "{position:?}: without its base, a request lacking the header has no route",
8886 );
8887 }
8888 }
8889
8890 /// A hostname whose only rules are predicated, none holding, hands the
8891 /// request to the next hostname candidate instead of ending the lookup
8892 /// (the sozu#1351 shape): the trie's acceptance predicate sees headers.
8893 #[test]
8894 fn an_unsatisfied_predicated_hostname_falls_through_to_the_wildcard() {
8895 let mut router = Router::new();
8896 router
8897 .add_http_front(&HttpFrontend {
8898 cluster_id: Some("wildcard".into()),
8899 hostname: "*.example.com".to_owned(),
8900 ..test_http_frontend()
8901 })
8902 .expect("wildcard");
8903 router
8904 .add_http_front(&HttpFrontend {
8905 cluster_id: Some("exact".into()),
8906 hostname: "www.example.com".to_owned(),
8907 header_predicates: vec![predicate("X-Canary", HeaderMatchKind::Present, None)],
8908 ..test_http_frontend()
8909 })
8910 .expect("predicated exact host");
8911
8912 let route = |headers: &[(&str, &str)]| {
8913 router
8914 .lookup_with_headers("www.example.com", "/", &Method::Get, &TestHeaders(headers))
8915 .ok()
8916 .and_then(|route| route.cluster_id)
8917 .map(|id| id.to_string())
8918 };
8919 assert_eq!(route(&[("x-canary", "")]).as_deref(), Some("exact"));
8920 assert_eq!(route(&[]).as_deref(), Some("wildcard"));
8921 }
8922
8923 /// A predicate no request could satisfy, or that cannot be compiled, is
8924 /// refused at frontend addition — which `validate_frontend_request`
8925 /// (`bin/src/command/requests.rs`) also runs in the main process — and
8926 /// the error carries the predicate's index, never its name or value.
8927 #[test]
8928 fn unusable_header_predicates_are_refused_without_echoing_them() {
8929 let refused = [
8930 predicate("Host", HeaderMatchKind::Exact, Some("example.com")),
8931 predicate("cookie", HeaderMatchKind::Present, None),
8932 predicate("X Bad", HeaderMatchKind::Present, None),
8933 predicate("", HeaderMatchKind::Present, None),
8934 predicate("X-Debug", HeaderMatchKind::Present, Some("1")),
8935 predicate("X-Tenant", HeaderMatchKind::Exact, None),
8936 predicate("X-Tenant", HeaderMatchKind::Regex, None),
8937 predicate("X-Tenant", HeaderMatchKind::Exact, Some("a\r\nb")),
8938 predicate("X-Tenant", HeaderMatchKind::Regex, Some("a)(b")),
8939 HeaderPredicate {
8940 name: "X-Tenant".to_owned(),
8941 kind: 42,
8942 value: None,
8943 },
8944 ];
8945 for bad in refused {
8946 let mut router = Router::new();
8947 let front = HttpFrontend {
8948 header_predicates: vec![
8949 predicate("X-Valid", HeaderMatchKind::Present, None),
8950 bad.clone(),
8951 ],
8952 ..test_http_frontend()
8953 };
8954 let error = router
8955 .add_http_front(&front)
8956 .expect_err("an unusable predicate must be refused");
8957 assert!(
8958 matches!(error, RouterError::InvalidHeaderPredicate { index: 1, .. }),
8959 "{bad:?}: unexpected {error}",
8960 );
8961 let message = error.to_string();
8962 assert!(
8963 !message.contains("X-Tenant") && !message.contains("example.com"),
8964 "the error must not echo the predicate: {message}",
8965 );
8966 assert!(router.tree.is_empty(), "a refused frontend adds no rule");
8967 }
8968 }
8969
8970 quickcheck! {
8971 fn qc_router_hostname_resolution_matches_the_documented_semantics(scenario: Scenario) -> TestResult {
8972 oracle_matches_router(scenario)
8973 }
8974 }
8975}