Skip to main content

sozu_lib/protocol/tcp_preread/
mod.rs

1//! Pure sans-io TCP passthrough SNI-preread core (issue #1279).
2//!
3//! [`SniPrereadCore`] decides, from the raw bytes of an inbound TLS
4//! ClientHello, which cluster a TCP passthrough connection routes to --
5//! WITHOUT terminating TLS. It performs **no I/O**: there is no socket, no
6//! `Instant::now()`, no `rand`, and no `Arc<Mutex>`. Time is injected as
7//! `now: Instant` parameters ([`Input::Bytes`] / [`Input::Timeout`]); routing
8//! config is injected borrowed ([`PrereadConfig`]). It never mutates,
9//! consumes, or re-serializes the caller's buffer -- byte-for-byte replay is
10//! the whole point: once routed, the shell forwards the SAME bytes verbatim
11//! to the backend, starting at [`Output::Routed::content_offset`].
12//!
13//! Mirrors the split already established by [`crate::protocol::udp`]: a
14//! near-stateless core (`decided` latch + `deadline`, see
15//! [`SniPrereadCore`]) driven by [`Input`] / [`Output`] through
16//! [`SniPrereadCore::handle_input`], with the accumulating byte buffer owned
17//! by the I/O shell ([`shell::SniPreread`], wired into the session lifecycle
18//! by `lib/src/tcp.rs`) rather than by the core itself -- every
19//! [`Input::Bytes`] carries the FULL accumulated window from wire offset 0,
20//! not a delta.
21//!
22//! [`parser`] owns the nom-based wire format (TLS record layer, ClientHello,
23//! and extensions); this module owns the PROXY-v2 stripping, the SNI/ALPN
24//! routing decision against [`crate::router::pattern_trie::TrieNode`], and
25//! the decided/deadline state machine.
26
27mod parser;
28pub mod shell;
29
30use std::{
31    collections::BTreeSet,
32    net::SocketAddr,
33    time::{Duration, Instant},
34};
35
36use nom::Err as NomErr;
37use sozu_command::state::ClusterId;
38
39use crate::{protocol::proxy_protocol::parser::parse_v2_header, router::pattern_trie::TrieNode};
40
41/// How a route entry's cluster accepts the client's ALPN offer.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum AlpnMatcher {
44    /// Matches regardless of what the client offered (including a client
45    /// that sent no `alpn` extension at all). The catch-all: fires only
46    /// once no `OneOf` entry matched any client-offered protocol.
47    Any,
48    /// Matches when at least one of the client's offered protocols is a
49    /// member of this set.
50    OneOf(BTreeSet<Vec<u8>>),
51}
52
53/// Routing input, borrowed for the lifetime of one [`SniPrereadCore::handle_input`]
54/// call. `'t` is the lifetime of the route table itself (owned by the
55/// listener config, well outside any single preread attempt).
56#[derive(Debug, Clone, Copy)]
57pub struct PrereadConfig<'t> {
58    /// SNI -> `(AlpnMatcher, ClusterId)` route table, reusing
59    /// [`crate::router::pattern_trie::TrieNode`] (exact hosts, `*.`
60    /// wildcards, regex segments -- same trie the HTTP/HTTPS router uses).
61    pub routes: &'t TrieNode<Vec<(AlpnMatcher, ClusterId)>>,
62    /// Whether a PROXY-v2 header precedes the ClientHello on the wire.
63    pub inbound_proxy: bool,
64    /// Effective byte cap for the preread window, enforced only while the
65    /// decision is still pending: a window that reaches `max_bytes` without
66    /// a complete PROXY header + ClientHello rejects `TooLarge`; a COMPLETE
67    /// hello routes regardless of trailing post-hello bytes. The caller
68    /// guarantees this is `<=` the accumulator buffer's capacity.
69    pub max_bytes: usize,
70    /// Preread deadline, armed once from the first `Input::Bytes`.
71    pub timeout: Duration,
72    /// Whether a `*.` wildcard route may satisfy a lookup (mirrors the
73    /// HTTP router's `accept_wildcard` knob on
74    /// [`TrieNode::domain_lookup`]).
75    pub accept_wildcard: bool,
76}
77
78/// One input fed into [`SniPrereadCore::handle_input`].
79#[derive(Debug)]
80pub enum Input<'a> {
81    /// The FULL accumulated preread window, from wire offset 0, re-fed on
82    /// every call (never a delta). `now` drives the one-time deadline arm.
83    Bytes { buf: &'a [u8], now: Instant },
84    /// The preread deadline fired without a decision.
85    Timeout { now: Instant },
86    /// The frontend socket closed before a decision was reached.
87    FrontClosed,
88}
89
90/// One outcome of [`SniPrereadCore::handle_input`].
91#[derive(Debug, Clone, PartialEq)]
92pub enum Output {
93    /// Not enough bytes yet; `deadline` is the same value for the whole
94    /// lifetime of one [`SniPrereadCore`] (armed once, on the first
95    /// `Bytes` input).
96    NeedMore { deadline: Instant },
97    /// A cluster was chosen. `content_offset` is how many leading bytes of
98    /// the ORIGINAL buffer are the (already-parsed) PROXY-v2 header -- the
99    /// shell must skip exactly that many bytes before forwarding the
100    /// UNTOUCHED remainder to the backend verbatim.
101    Routed {
102        cluster: ClusterId,
103        content_offset: usize,
104        proxy_source: Option<SocketAddr>,
105        sni: String,
106        alpn: Vec<Vec<u8>>,
107        /// The trie KEY that matched -- the route's configured pattern, not
108        /// the client's concrete SNI: for a wildcard route this is
109        /// `*.example.com` even when `sni` is `a.example.com`. Carries the
110        /// matched route's identity so the shell/session can key
111        /// per-frontend state (e.g. access-log tags) without re-running the
112        /// lookup.
113        matched_sni_pattern: String,
114        /// Clone of the winning route entry's [`AlpnMatcher`] -- the other
115        /// half of the matched route's identity.
116        matched_alpn: AlpnMatcher,
117    },
118    /// Terminal, non-routable verdict. See [`RejectReason`].
119    Reject(RejectReason),
120}
121
122/// Why a preread attempt was terminally rejected.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum RejectReason {
125    /// The first TLS record's `ContentType` was not `handshake` (22).
126    NotTls,
127    /// A TLS record's framing is invalid (bad length, or a non-`handshake`
128    /// record interrupting an in-progress ClientHello reassembly).
129    MalformedRecord,
130    /// The known-complete ClientHello body fails ANY of the following
131    /// (`parser.rs`'s single catch-all for the inner body, since none of
132    /// them can be `NeedMore` once the outer handshake framing has already
133    /// proven the body fully present):
134    /// - the handshake message is not a ClientHello (wrong `msg_type`);
135    /// - a length-prefixed field inside the body lies about the bytes
136    ///   available (`session_id`/`cipher_suites`/`compression_methods`/
137    ///   extensions-block lengths, or a nested extension's own declared
138    ///   length);
139    /// - the `server_name` extension's `host_name` is not valid UTF-8;
140    /// - a second `server_name` extension, or a second `alpn` extension, is
141    ///   present (RFC 8446 §4.2 forbids more than one of the same type —
142    ///   Sōzu forwards the original bytes verbatim, so silently resolving a
143    ///   duplicate differently than a tolerant backend would be a routing/
144    ///   forwarding mismatch);
145    /// - a single `server_name` extension's `server_name_list` itself
146    ///   carries more than one `host_name` (type `0`) entry (RFC 6066 §3);
147    /// - the `alpn` extension's `protocol_name_list` contains a
148    ///   zero-length protocol name, or is present but empty (RFC 7301
149    ///   §3.1 declares both `1..2^8-1`-length names and a non-empty list).
150    MalformedHandshake,
151    /// The preread deadline fired before a terminal verdict was reached.
152    Fragmented,
153    /// The accumulated window reached [`PrereadConfig::max_bytes`] while
154    /// the PROXY header / ClientHello was still incomplete -- it can never
155    /// complete within the cap. A COMPLETE hello routes regardless of total
156    /// window length (post-hello trailing bytes are normal in passthrough).
157    TooLarge,
158    /// No usable SNI: the `server_name` extension was absent, empty, or
159    /// its RFC 6066 `host_name` entry was missing.
160    NoSni,
161    /// `encrypted_client_hello` (`0xfe0d`) was present and no usable outer
162    /// `server_name` was available -- distinguished from [`Self::NoSni`]
163    /// because an ECH-hiding client is a different operational signal.
164    EchOuterAbsent,
165    /// The normalized SNI matched no route.
166    SniUnmatched,
167    /// The SNI matched a route, but no entry's [`AlpnMatcher`] accepted the
168    /// client's offered ALPN protocols (and no [`AlpnMatcher::Any`] entry
169    /// was present either).
170    AlpnUnmatched,
171    /// The PROXY-v2 header itself failed to parse.
172    ProxyHeaderInvalid,
173    /// The frontend closed before a decision was reached.
174    FrontClosed,
175}
176
177/// The pure preread core. Deliberately near-stateless: the shell owns the
178/// growing byte accumulator and re-feeds the FULL window on every
179/// [`Input::Bytes`]; this struct only remembers whether a terminal verdict
180/// has already been latched (`decided`) and when the preread deadline was
181/// armed (`deadline`).
182#[derive(Debug, Default)]
183pub struct SniPrereadCore {
184    /// Set exactly once, by [`Self::decide`], to the first terminal
185    /// [`Output::Routed`] / [`Output::Reject`]. Monotonic: once `Some`, it
186    /// never changes and every subsequent [`Self::handle_input`] call
187    /// replays it verbatim without re-parsing anything.
188    decided: Option<Output>,
189    /// Set exactly once, on the first [`Input::Bytes`], to `now +
190    /// cfg.timeout`. Never rewound.
191    deadline: Option<Instant>,
192}
193
194impl SniPrereadCore {
195    /// Construct a fresh, undecided core.
196    pub fn new() -> Self {
197        SniPrereadCore {
198            decided: None,
199            deadline: None,
200        }
201    }
202
203    /// Feed one input. Pure: `now` (inside [`Input`]) and `cfg` are
204    /// injected, never read from ambient state.
205    pub fn handle_input(&mut self, cfg: &PrereadConfig<'_>, input: Input<'_>) -> Output {
206        #[cfg(debug_assertions)]
207        let deadline_before = self.deadline;
208        #[cfg(debug_assertions)]
209        let was_decided = self.decided.is_some();
210        self.debug_assert_invariants();
211
212        let output = match input {
213            Input::Bytes { buf, now } => self.on_bytes(cfg, buf, now),
214            Input::Timeout { now } => self.on_timeout(now),
215            Input::FrontClosed => self.on_front_closed(),
216        };
217
218        // Post-conditions specific to this call: the deadline, once armed,
219        // never rewinds; a core that was already decided must still be
220        // decided (the latch never reverts to undecided).
221        #[cfg(debug_assertions)]
222        {
223            if let Some(before) = deadline_before {
224                debug_assert_eq!(
225                    self.deadline,
226                    Some(before),
227                    "preread deadline must be set at most once and never rewound"
228                );
229            }
230            debug_assert!(
231                !was_decided || self.decided.is_some(),
232                "a decided core must never become undecided (monotonic latch)"
233            );
234        }
235        self.debug_assert_invariants();
236
237        output
238    }
239
240    fn on_bytes(&mut self, cfg: &PrereadConfig<'_>, buf: &[u8], now: Instant) -> Output {
241        if self.deadline.is_none() {
242            self.deadline = Some(now + cfg.timeout);
243        }
244        let deadline = self
245            .deadline
246            .expect("deadline was just armed unconditionally above if it was None");
247
248        if let Some(decided) = &self.decided {
249            return decided.clone();
250        }
251
252        // Defensively enforce the ABSOLUTE deadline here too, not just on
253        // `Input::Timeout`: the shell (`TcpSession::readable` in
254        // `lib/src/tcp.rs`) is expected to stop re-arming the frontend
255        // timeout once undecided and instead let `Input::Timeout` fire, but
256        // `now` is injected deterministically, so the core can and must
257        // reject on its own the moment a `Bytes` input arrives at or after
258        // the deadline it latched -- same terminal verdict `on_timeout`
259        // produces, even for a fragment that happens to complete a
260        // routable ClientHello. Without this, a client trickling one byte
261        // per read forever holds the preread open on a sliding budget
262        // instead of the configured absolute one (sozu-proxy/sozu#1290).
263        if now >= deadline {
264            return self.decide(Output::Reject(RejectReason::Fragmented));
265        }
266
267        // NOTE: the `max_bytes` cap is deliberately NOT checked here. In
268        // passthrough the client keeps sending after its hello, so one read
269        // routinely delivers a complete ClientHello PLUS trailing bytes that
270        // push the window over the cap -- a complete hello must still route
271        // (regression guards: `complete_hello_with_trailing_bytes_over_cap_routes`
272        // below, plus the e2e coalesced-read test
273        // `test_tcp_sni_large_payload_coalesced_with_hello_delivered_intact`).
274        // The cap gates only the would-be-NeedMore paths below, via
275        // [`Self::need_more_or_too_large`].
276        let (content_offset, proxy_source) = if cfg.inbound_proxy {
277            match parse_v2_header(buf) {
278                Ok((rest, header)) => {
279                    let consumed = buf.len() - rest.len();
280                    debug_assert!(
281                        consumed <= buf.len(),
282                        "a parsed PROXY-v2 header cannot consume more than the buffer holds"
283                    );
284                    (consumed, header.addr.source())
285                }
286                Err(NomErr::Incomplete(_)) => {
287                    return self.need_more_or_too_large(cfg, buf, deadline);
288                }
289                Err(_) => return self.decide(Output::Reject(RejectReason::ProxyHeaderInvalid)),
290            }
291        } else {
292            (0, None)
293        };
294
295        match parser::parse_client_hello(&buf[content_offset..]) {
296            parser::ParseOutcome::NeedMore => self.need_more_or_too_large(cfg, buf, deadline),
297            parser::ParseOutcome::Reject(reason) => self.decide(Output::Reject(reason)),
298            parser::ParseOutcome::ClientHello {
299                sni,
300                alpn,
301                ech_present,
302            } => self.route(
303                cfg,
304                buf,
305                content_offset,
306                proxy_source,
307                sni,
308                alpn,
309                ech_present,
310            ),
311        }
312    }
313
314    fn on_timeout(&mut self, _now: Instant) -> Output {
315        // `now` is accepted for signature symmetry with `on_bytes`; once
316        // the deadline has fired, the fragmented-timeout verdict itself is
317        // time-independent.
318        if let Some(decided) = &self.decided {
319            return decided.clone();
320        }
321        self.decide(Output::Reject(RejectReason::Fragmented))
322    }
323
324    fn on_front_closed(&mut self) -> Output {
325        if let Some(decided) = &self.decided {
326            return decided.clone();
327        }
328        self.decide(Output::Reject(RejectReason::FrontClosed))
329    }
330
331    /// The byte cap, applied where the outcome would otherwise be
332    /// [`Output::NeedMore`]: a window that has already reached `max_bytes`
333    /// without yielding a complete PROXY header + ClientHello can never
334    /// grow into one within the cap, so waiting is pointless -- reject
335    /// `TooLarge` now. A COMPLETE hello never reaches this path and routes
336    /// regardless of total window length.
337    fn need_more_or_too_large(
338        &mut self,
339        cfg: &PrereadConfig<'_>,
340        buf: &[u8],
341        deadline: Instant,
342    ) -> Output {
343        if buf.len() >= cfg.max_bytes {
344            return self.decide(Output::Reject(RejectReason::TooLarge));
345        }
346        Output::NeedMore { deadline }
347    }
348
349    /// Normalize the extracted SNI, look it up, and resolve ALPN against
350    /// the matched route's entries.
351    #[allow(clippy::too_many_arguments)]
352    fn route(
353        &mut self,
354        cfg: &PrereadConfig<'_>,
355        buf: &[u8],
356        content_offset: usize,
357        proxy_source: Option<SocketAddr>,
358        sni_raw: Option<String>,
359        alpn: Vec<Vec<u8>>,
360        ech_present: bool,
361    ) -> Output {
362        let sni = sni_raw.map(normalize_sni);
363        let sni = match sni {
364            Some(s) if !s.is_empty() => s,
365            _ => {
366                let reason = if ech_present {
367                    RejectReason::EchOuterAbsent
368                } else {
369                    RejectReason::NoSni
370                };
371                return self.decide(Output::Reject(reason));
372            }
373        };
374
375        let Some((matched_key, entries)) = cfg
376            .routes
377            .domain_lookup(sni.as_bytes(), cfg.accept_wildcard)
378        else {
379            return self.decide(Output::Reject(RejectReason::SniUnmatched));
380        };
381
382        let Some((matched_alpn, cluster)) = resolve_alpn(entries, &alpn) else {
383            return self.decide(Output::Reject(RejectReason::AlpnUnmatched));
384        };
385
386        // Route determinism (pair): re-running the SAME lookup + ALPN
387        // resolution must yield the SAME entry -- routing is a pure
388        // function of (sni, accept_wildcard, alpn) at a given instant, so a
389        // divergence here means the trie or `resolve_alpn` has a hidden
390        // side effect or non-determinism (e.g. iterating a HashMap without
391        // a stable order).
392        #[cfg(debug_assertions)]
393        {
394            let (_, replay_entries) = cfg
395                .routes
396                .domain_lookup(sni.as_bytes(), cfg.accept_wildcard)
397                .expect("a route that just matched must match again");
398            let replay_entry = resolve_alpn(replay_entries, &alpn);
399            debug_assert_eq!(
400                replay_entry,
401                Some((matched_alpn, cluster)),
402                "route lookup + ALPN resolution must be deterministic for the same (sni, alpn)"
403            );
404        }
405        debug_assert!(
406            content_offset <= buf.len(),
407            "content_offset must never exceed the buffer it was derived from"
408        );
409
410        self.decide(Output::Routed {
411            cluster: cluster.to_owned(),
412            content_offset,
413            proxy_source,
414            sni,
415            alpn,
416            // The trie key is the route's configured pattern verbatim
417            // (lowercased at insert); `from_utf8_lossy` is defensive -- keys
418            // originate from config/IPC `String`s, never raw network bytes.
419            matched_sni_pattern: String::from_utf8_lossy(matched_key).into_owned(),
420            matched_alpn: matched_alpn.clone(),
421        })
422    }
423
424    /// Latch the first (and only) terminal verdict. Every subsequent input
425    /// replays it via the `self.decided` checks in `on_*` instead of
426    /// calling this again.
427    fn decide(&mut self, output: Output) -> Output {
428        debug_assert!(
429            self.decided.is_none(),
430            "decide() must be called at most once per lifetime -- the latch is monotonic"
431        );
432        debug_assert!(
433            !matches!(output, Output::NeedMore { .. }),
434            "only a terminal Output (Routed/Reject) may be latched via decide()"
435        );
436        self.decided = Some(output.clone());
437        // Renders the full `Output` (including, for a `Routed` verdict, the
438        // entire client-offered `alpn: Vec<Vec<u8>>`) via `Debug`,
439        // unbounded -- left as-is because `debug!` is compiled out of
440        // release builds entirely unless built with `--features
441        // logs-debug` (see `doc/configure.md`'s feature table), unlike the
442        // `info!` in `shell.rs::handle_output` (always compiled in) that
443        // this same hostile-input concern required bounding.
444        debug!(
445            "{} latched terminal preread decision: {:?}",
446            log_context!(self),
447            output
448        );
449        output
450    }
451
452    /// TigerStyle invariant sweep, run at both the head and tail of
453    /// [`Self::handle_input`] (mirrors `udp/manager.rs`'s
454    /// `check_invariants`, see `lib/src/protocol/udp/manager.rs:686-817`).
455    /// Read-only; must never change behavior.
456    #[cfg(debug_assertions)]
457    fn check_invariants(&self) {
458        // Positive: `NeedMore` is the only non-terminal `Output` variant, so
459        // the decided latch -- which only ever stores a TERMINAL verdict --
460        // must never hold one.
461        debug_assert!(
462            !matches!(self.decided, Some(Output::NeedMore { .. })),
463            "decided latch must never store NeedMore -- it is not a terminal verdict"
464        );
465        // Pair (positive + negative space): a latched SNI is always already
466        // normalized -- lowercase, no trailing dot -- because `route` never
467        // stores the raw wire-form SNI. The matched pattern is the trie KEY
468        // (lowercased at insert): either the concrete SNI itself (exact
469        // route) or a `*.`-prefixed wildcard whose suffix the SNI ends with.
470        if let Some(Output::Routed {
471            sni,
472            matched_sni_pattern,
473            ..
474        }) = &self.decided
475        {
476            debug_assert_eq!(
477                sni,
478                &sni.to_ascii_lowercase(),
479                "latched SNI must already be lowercase-normalized"
480            );
481            debug_assert!(
482                !sni.ends_with('.'),
483                "latched SNI must not carry a trailing dot after normalization"
484            );
485            debug_assert_eq!(
486                matched_sni_pattern,
487                &matched_sni_pattern.to_ascii_lowercase(),
488                "latched matched_sni_pattern must already be lowercase (trie keys are lowercased at insert)"
489            );
490            debug_assert!(
491                matched_sni_pattern == sni
492                    || matched_sni_pattern
493                        .strip_prefix("*.")
494                        .is_some_and(|suffix| sni.ends_with(suffix)),
495                "matched_sni_pattern must be the SNI itself (exact route) or a wildcard the SNI falls under"
496            );
497        }
498    }
499
500    #[inline]
501    fn debug_assert_invariants(&self) {
502        #[cfg(debug_assertions)]
503        self.check_invariants();
504    }
505}
506
507/// Resolve a cluster from a matched route's `(AlpnMatcher, ClusterId)`
508/// entries against the client's offered ALPN protocols (in client
509/// preference order).
510///
511/// Walks the CLIENT's offer first (outer loop), so the first offered
512/// protocol that is a member of ANY entry's [`AlpnMatcher::OneOf`] wins --
513/// client preference order beats route entry order. Only once no offered
514/// protocol matched anything (including a client that offered no ALPN at
515/// all) does an [`AlpnMatcher::Any`] entry, if present, win as the
516/// catch-all.
517fn resolve_alpn<'r>(
518    entries: &'r [(AlpnMatcher, ClusterId)],
519    offered: &[Vec<u8>],
520) -> Option<(&'r AlpnMatcher, &'r ClusterId)> {
521    for protocol in offered {
522        for (matcher, cluster) in entries {
523            if let AlpnMatcher::OneOf(set) = matcher
524                && set.contains(protocol)
525            {
526                return Some((matcher, cluster));
527            }
528        }
529    }
530    entries
531        .iter()
532        .find(|(matcher, _)| matches!(matcher, AlpnMatcher::Any))
533        .map(|(matcher, cluster)| (matcher, cluster))
534}
535
536/// Normalize a wire-form SNI: ASCII-lowercase, then strip AT MOST one
537/// trailing dot (RFC 1034 §3.1 absolute-form: `example.com.` and
538/// `example.com` are the same host). Mirrors `lib/src/https.rs`'s
539/// `upgrade_handshake` SNI normalization so a TCP-preread route decision
540/// and the eventual TLS-terminating one agree on the same host.
541fn normalize_sni(mut sni: String) -> String {
542    sni.make_ascii_lowercase();
543    if sni.ends_with('.') {
544        sni.pop();
545    }
546    sni
547}
548
549/// Logging prefix for the pure TCP-preread core (tag `TCP-SNI`). Mirrors
550/// `protocol/udp/mod.rs`'s `log_context!`; honours the colored flag via
551/// [`ansi_palette`](sozu_command::logging::ansi_palette). The core has no
552/// socket of its own, so the prefix carries the decided/deadline latch
553/// state instead of a session id.
554#[allow(unused_macros)]
555macro_rules! log_context {
556    ($self:expr) => {{
557        let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
558        format!(
559            "[- - - -]\t{open}TCP-SNI{reset}\t{grey}Preread{reset}({gray}decided{reset}={white}{decided}{reset}, {gray}deadline_armed{reset}={white}{deadline}{reset})\t >>>",
560            open = open,
561            reset = reset,
562            grey = grey,
563            gray = gray,
564            white = white,
565            decided = $self.decided.is_some(),
566            deadline = $self.deadline.is_some(),
567        )
568    }};
569}
570
571/// Lightweight per-decision logging prefix (tag `TCP-SNI`), for call sites
572/// that only have the resolved SNI in scope, not a full `&SniPrereadCore`.
573/// Mirrors `protocol/udp/mod.rs`'s `log_context_lite!`; a pure core this
574/// small barely logs, so this macro currently has no call site of its own
575/// (kept for parity with the mirrored module and for future shell-side use).
576#[allow(unused_macros)]
577macro_rules! log_context_lite {
578    ($sni:expr) => {{
579        let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
580        format!(
581            "[- - - -]\t{open}TCP-SNI{reset}\t{grey}Route{reset}({gray}sni{reset}={white}{sni:?}{reset})\t >>>",
582            open = open,
583            reset = reset,
584            grey = grey,
585            gray = gray,
586            white = white,
587            sni = $sni,
588        )
589    }};
590}
591
592#[allow(unused_imports)]
593use {log_context, log_context_lite};
594
595#[cfg(test)]
596mod tests {
597    use std::net::{IpAddr, Ipv4Addr};
598
599    use super::*;
600    use crate::protocol::proxy_protocol::header::{Command, HeaderV2};
601
602    fn cfg<'t>(routes: &'t TrieNode<Vec<(AlpnMatcher, ClusterId)>>) -> PrereadConfig<'t> {
603        PrereadConfig {
604            routes,
605            inbound_proxy: false,
606            max_bytes: 16 * 1024,
607            timeout: Duration::from_secs(3),
608            accept_wildcard: true,
609        }
610    }
611
612    fn one_of(protocols: &[&[u8]]) -> AlpnMatcher {
613        AlpnMatcher::OneOf(protocols.iter().map(|p| p.to_vec()).collect())
614    }
615
616    fn hello(sni: &str, alpn: &[&[u8]]) -> Vec<u8> {
617        parser::build_client_hello_wire(&[
618            parser::encode_sni_extension(sni),
619            parser::encode_alpn_extension(alpn),
620        ])
621    }
622
623    fn hello_no_alpn(sni: &str) -> Vec<u8> {
624        parser::build_client_hello_wire(&[parser::encode_sni_extension(sni)])
625    }
626
627    fn feed(core: &mut SniPrereadCore, cfg: &PrereadConfig<'_>, buf: &[u8]) -> Output {
628        core.handle_input(
629            cfg,
630            Input::Bytes {
631                buf,
632                now: Instant::now(),
633            },
634        )
635    }
636
637    // ---- byte-replay + core-level RejectReason reachability ----------------
638
639    #[test]
640    fn byte_replay_untouched_through_the_core() {
641        let mut routes = TrieNode::root();
642        routes.domain_insert(
643            b"example.com".to_vec(),
644            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
645        );
646        let cfg = cfg(&routes);
647        let wire = hello("example.com", &[]);
648        let before = wire.clone();
649        let mut core = SniPrereadCore::new();
650        let _ = feed(&mut core, &cfg, &wire);
651        assert_eq!(wire, before, "the core must never mutate the fed buffer");
652    }
653
654    /// The genuine `TooLarge` case: an INCOMPLETE hello whose accumulated
655    /// window has grown past the cap can never complete within it.
656    #[test]
657    fn too_large_is_reachable() {
658        let routes = TrieNode::root();
659        let mut small_cfg = cfg(&routes);
660        small_cfg.max_bytes = 8;
661        let wire = hello_no_alpn("example.com");
662        let mut core = SniPrereadCore::new();
663        // 10 bytes of a real hello: valid record header, body incomplete.
664        let out = feed(&mut core, &small_cfg, &wire[..10]);
665        assert_eq!(out, Output::Reject(RejectReason::TooLarge));
666    }
667
668    /// Cap boundary, both sides: a COMPLETE hello whose total length is
669    /// exactly `max_bytes` routes; an INCOMPLETE window that has reached
670    /// exactly `max_bytes` can never complete within the cap -- `TooLarge`.
671    #[test]
672    fn exact_cap_boundary_complete_routes_incomplete_rejects() {
673        let mut routes = TrieNode::root();
674        routes.domain_insert(
675            b"example.com".to_vec(),
676            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
677        );
678        let wire = hello_no_alpn("example.com");
679
680        let mut exact_cfg = cfg(&routes);
681        exact_cfg.max_bytes = wire.len();
682        let mut core = SniPrereadCore::new();
683        match feed(&mut core, &exact_cfg, &wire) {
684            Output::Routed { cluster, .. } => assert_eq!(cluster, "cluster-a"),
685            other => panic!("expected Routed at exactly the cap, got {other:?}"),
686        }
687
688        let mut tight_cfg = cfg(&routes);
689        tight_cfg.max_bytes = wire.len() - 1;
690        let mut core = SniPrereadCore::new();
691        assert_eq!(
692            feed(&mut core, &tight_cfg, &wire[..wire.len() - 1]),
693            Output::Reject(RejectReason::TooLarge),
694            "an incomplete window at exactly the cap must reject TooLarge"
695        );
696    }
697
698    /// Regression, surfaced by the e2e coalesced-read test
699    /// (`test_tcp_sni_large_payload_coalesced_with_hello_delivered_intact`
700    /// in `e2e/src/tests/tcp_sni_tests.rs`): in TLS passthrough the client
701    /// keeps sending after its hello (rest of handshake / early data), so one
702    /// socket read routinely delivers a COMPLETE ClientHello PLUS trailing
703    /// bytes that push the window over `max_bytes`. The cap is a rejection
704    /// criterion ONLY while the hello is still incomplete -- a complete
705    /// hello must route regardless of total window length, and the trailing
706    /// bytes stay in the accumulator for byte-for-byte replay.
707    #[test]
708    fn complete_hello_with_trailing_bytes_over_cap_routes() {
709        let mut routes = TrieNode::root();
710        routes.domain_insert(
711            b"example.com".to_vec(),
712            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
713        );
714        let wire = hello("example.com", &[b"h2"]);
715        let mut over = wire.clone();
716        over.extend_from_slice(&[0xAB; 64]); // post-hello handshake bytes
717
718        let mut small_cfg = cfg(&routes);
719        small_cfg.max_bytes = wire.len() + 16; // cap between hello and total
720        assert!(over.len() > small_cfg.max_bytes, "test setup: total > cap");
721
722        let mut core = SniPrereadCore::new();
723        match feed(&mut core, &small_cfg, &over) {
724            Output::Routed {
725                cluster,
726                content_offset,
727                sni,
728                ..
729            } => {
730                assert_eq!(cluster, "cluster-a");
731                assert_eq!(content_offset, 0);
732                assert_eq!(sni, "example.com");
733            }
734            other => panic!("a complete hello must route despite trailing bytes, got {other:?}"),
735        }
736    }
737
738    /// The PROXY-header-incomplete branch must respect the cap too: a
739    /// partial PROXY-v2 header whose window has already reached `max_bytes`
740    /// can never complete -- `TooLarge`, not an eternal `NeedMore`.
741    #[test]
742    fn proxy_incomplete_at_cap_is_too_large() {
743        let routes = TrieNode::root();
744        let mut proxy_cfg = cfg(&routes);
745        proxy_cfg.inbound_proxy = true;
746        proxy_cfg.max_bytes = 10;
747
748        let src = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 51234);
749        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 443);
750        let proxy_header = HeaderV2::new(Command::Proxy, src, dst).into_bytes();
751
752        let mut core = SniPrereadCore::new();
753        let out = feed(&mut core, &proxy_cfg, &proxy_header[..10]);
754        assert_eq!(out, Output::Reject(RejectReason::TooLarge));
755    }
756
757    #[test]
758    fn empty_buffer_needs_more_and_sets_deadline() {
759        let routes = TrieNode::root();
760        let cfg = cfg(&routes);
761        let mut core = SniPrereadCore::new();
762        match feed(&mut core, &cfg, &[]) {
763            Output::NeedMore { .. } => {}
764            other => panic!("expected NeedMore on empty input, got {other:?}"),
765        }
766    }
767
768    #[test]
769    fn timeout_while_undecided_is_fragmented() {
770        let routes = TrieNode::root();
771        let cfg = cfg(&routes);
772        let mut core = SniPrereadCore::new();
773        // Partial ClientHello: never enough to decide.
774        let wire = hello_no_alpn("example.com");
775        let _ = feed(&mut core, &cfg, &wire[..wire.len() - 1]);
776        let out = core.handle_input(
777            &cfg,
778            Input::Timeout {
779                now: Instant::now(),
780            },
781        );
782        assert_eq!(out, Output::Reject(RejectReason::Fragmented));
783    }
784
785    #[test]
786    fn front_closed_before_decision_is_front_closed() {
787        let routes = TrieNode::root();
788        let cfg = cfg(&routes);
789        let mut core = SniPrereadCore::new();
790        let out = core.handle_input(&cfg, Input::FrontClosed);
791        assert_eq!(out, Output::Reject(RejectReason::FrontClosed));
792    }
793
794    #[test]
795    fn decided_latch_replays_the_same_terminal_forever() {
796        let mut routes = TrieNode::root();
797        routes.domain_insert(
798            b"example.com".to_vec(),
799            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
800        );
801        let cfg = cfg(&routes);
802        let mut core = SniPrereadCore::new();
803        let wire = hello_no_alpn("example.com");
804        let first = feed(&mut core, &cfg, &wire);
805        // Feed again (even with clearly different bytes) -- must replay.
806        let second = feed(&mut core, &cfg, &[0xff; 3]);
807        assert_eq!(first, second);
808        let third = core.handle_input(
809            &cfg,
810            Input::Timeout {
811                now: Instant::now(),
812            },
813        );
814        assert_eq!(first, third);
815    }
816
817    #[test]
818    fn not_tls_is_reachable_through_the_core() {
819        let routes = TrieNode::root();
820        let cfg = cfg(&routes);
821        let mut core = SniPrereadCore::new();
822        let out = feed(&mut core, &cfg, &[0x16 + 1, 0x03, 0x03, 0x00, 0x00]);
823        assert_eq!(out, Output::Reject(RejectReason::NotTls));
824    }
825
826    #[test]
827    fn no_sni_is_reachable() {
828        let mut routes = TrieNode::root();
829        routes.domain_insert(
830            b"example.com".to_vec(),
831            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
832        );
833        let cfg = cfg(&routes);
834        let mut core = SniPrereadCore::new();
835        let wire = parser::build_client_hello_wire(&[]);
836        assert_eq!(
837            feed(&mut core, &cfg, &wire),
838            Output::Reject(RejectReason::NoSni)
839        );
840    }
841
842    #[test]
843    fn ech_outer_absent_is_reachable() {
844        let mut routes = TrieNode::root();
845        routes.domain_insert(
846            b"example.com".to_vec(),
847            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
848        );
849        let cfg = cfg(&routes);
850        let mut core = SniPrereadCore::new();
851        let wire = parser::build_client_hello_wire(&[parser::encode_extension(
852            0xfe0d,
853            &[0x00, 0x01, 0x02],
854        )]);
855        assert_eq!(
856            feed(&mut core, &cfg, &wire),
857            Output::Reject(RejectReason::EchOuterAbsent)
858        );
859    }
860
861    #[test]
862    fn sni_unmatched_is_reachable() {
863        let mut routes = TrieNode::root();
864        routes.domain_insert(
865            b"example.com".to_vec(),
866            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
867        );
868        let cfg = cfg(&routes);
869        let mut core = SniPrereadCore::new();
870        let wire = hello_no_alpn("unknown.example.net");
871        assert_eq!(
872            feed(&mut core, &cfg, &wire),
873            Output::Reject(RejectReason::SniUnmatched)
874        );
875    }
876
877    #[test]
878    fn alpn_unmatched_is_reachable() {
879        let mut routes = TrieNode::root();
880        routes.domain_insert(
881            b"example.com".to_vec(),
882            vec![(one_of(&[b"h2"]), "cluster-a".to_owned())],
883        );
884        let cfg = cfg(&routes);
885        let mut core = SniPrereadCore::new();
886        let wire = hello("example.com", &[b"http/1.1"]);
887        assert_eq!(
888            feed(&mut core, &cfg, &wire),
889            Output::Reject(RejectReason::AlpnUnmatched)
890        );
891    }
892
893    #[test]
894    fn proxy_header_invalid_is_reachable() {
895        let routes = TrieNode::root();
896        let mut proxy_cfg = cfg(&routes);
897        proxy_cfg.inbound_proxy = true;
898        let mut core = SniPrereadCore::new();
899        // 16 garbage bytes: long enough not to be `Incomplete` against the
900        // 12-byte signature tag, but not a valid PROXY-v2 header.
901        let out = feed(&mut core, &proxy_cfg, &[0xAAu8; 16]);
902        assert_eq!(out, Output::Reject(RejectReason::ProxyHeaderInvalid));
903    }
904
905    #[test]
906    fn malformed_record_is_reachable() {
907        let routes = TrieNode::root();
908        let cfg = cfg(&routes);
909        let mut core = SniPrereadCore::new();
910        // ContentType = handshake, declared length far above the 2^14 cap.
911        let record = [0x16, 0x03, 0x03, 0xFF, 0xFF];
912        assert_eq!(
913            feed(&mut core, &cfg, &record),
914            Output::Reject(RejectReason::MalformedRecord)
915        );
916    }
917
918    #[test]
919    fn malformed_handshake_is_reachable() {
920        let routes = TrieNode::root();
921        let cfg = cfg(&routes);
922        let mut core = SniPrereadCore::new();
923        let hs = parser::wrap_handshake(&[]); // msg_type=1, length=0 body -- fine
924        let mut bad_hs = hs;
925        bad_hs[0] = 2; // ServerHello, not ClientHello
926        let record = parser::wrap_record(22, &bad_hs);
927        assert_eq!(
928            feed(&mut core, &cfg, &record),
929            Output::Reject(RejectReason::MalformedHandshake)
930        );
931    }
932
933    // ---- fragmentation through the core ------------------------------------
934
935    #[test]
936    fn drip_feed_needs_more_then_routes() {
937        let mut routes = TrieNode::root();
938        routes.domain_insert(
939            b"example.com".to_vec(),
940            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
941        );
942        let cfg = cfg(&routes);
943        let wire = hello("example.com", &[b"h2"]);
944        let mut core = SniPrereadCore::new();
945        for i in 0..wire.len() {
946            match feed(&mut core, &cfg, &wire[..i]) {
947                Output::NeedMore { .. } => {}
948                other => panic!("prefix {i} of {} must NeedMore, got {other:?}", wire.len()),
949            }
950        }
951        match feed(&mut core, &cfg, &wire) {
952            Output::Routed { cluster, sni, .. } => {
953                assert_eq!(cluster, "cluster-a");
954                assert_eq!(sni, "example.com");
955            }
956            other => panic!("expected Routed at full length, got {other:?}"),
957        }
958    }
959
960    /// Regression from the sozu-proxy/sozu#1290 review: the preread
961    /// deadline is an ABSOLUTE budget armed once on the first `Input::Bytes`
962    /// (see [`SniPrereadCore::on_bytes`]), not a per-fragment idle timer.
963    /// Feed valid, incomplete fragments with `now` stepping right up to
964    /// (but never past) the latched deadline -- each must `NeedMore` with
965    /// the SAME deadline, never a moved one. Then feed a fragment at/after
966    /// the deadline: even though it happens to be a COMPLETE, otherwise-
967    /// routable ClientHello, the core must reject `Fragmented` -- the exact
968    /// same terminal verdict `Input::Timeout` produces -- rather than
969    /// route. The latch must then keep replaying `Fragmented` forever,
970    /// never `Routed`, for any further input.
971    #[test]
972    fn bytes_at_or_after_the_latched_deadline_reject_fragmented_without_moving_it() {
973        let mut routes = TrieNode::root();
974        routes.domain_insert(
975            b"example.com".to_vec(),
976            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
977        );
978        let mut cfg = cfg(&routes);
979        cfg.timeout = Duration::from_secs(5);
980        let wire = hello_no_alpn("example.com");
981        let incomplete = &wire[..wire.len() - 1];
982
983        let mut core = SniPrereadCore::new();
984        let start = Instant::now();
985
986        // First fragment arms the deadline.
987        let deadline = match core.handle_input(
988            &cfg,
989            Input::Bytes {
990                buf: incomplete,
991                now: start,
992            },
993        ) {
994            Output::NeedMore { deadline } => deadline,
995            other => panic!("expected NeedMore on the first incomplete fragment, got {other:?}"),
996        };
997        assert_eq!(deadline, start + cfg.timeout);
998
999        // A later fragment, still incomplete, still well before the
1000        // deadline: NeedMore again, deadline UNCHANGED.
1001        let almost_deadline = deadline - Duration::from_millis(1);
1002        match core.handle_input(
1003            &cfg,
1004            Input::Bytes {
1005                buf: incomplete,
1006                now: almost_deadline,
1007            },
1008        ) {
1009            Output::NeedMore { deadline: d } => {
1010                assert_eq!(d, deadline, "the latched deadline must never move")
1011            }
1012            other => panic!("expected NeedMore just before the deadline, got {other:?}"),
1013        }
1014
1015        // A COMPLETE, otherwise-routable hello arriving exactly AT the
1016        // deadline must still reject Fragmented -- the absolute deadline is
1017        // a hard cutoff, not merely advisory once bytes happen to complete.
1018        assert_eq!(
1019            core.handle_input(
1020                &cfg,
1021                Input::Bytes {
1022                    buf: &wire,
1023                    now: deadline,
1024                },
1025            ),
1026            Output::Reject(RejectReason::Fragmented),
1027            "a complete hello arriving at/after the latched deadline must reject Fragmented, not route"
1028        );
1029
1030        // The latch replays Fragmented forever, even further past the
1031        // deadline, even though `wire` remains a complete, valid,
1032        // routable hello.
1033        assert_eq!(
1034            core.handle_input(
1035                &cfg,
1036                Input::Bytes {
1037                    buf: &wire,
1038                    now: deadline + Duration::from_secs(1),
1039                },
1040            ),
1041            Output::Reject(RejectReason::Fragmented)
1042        );
1043    }
1044
1045    #[test]
1046    fn multi_record_client_hello_routes_through_the_core() {
1047        let mut routes = TrieNode::root();
1048        routes.domain_insert(
1049            b"split.example.com".to_vec(),
1050            vec![(AlpnMatcher::Any, "cluster-split".to_owned())],
1051        );
1052        let cfg = cfg(&routes);
1053        let wire = hello("split.example.com", &[b"h2"]);
1054        let split = parser::split_into_records(&wire, 4);
1055        let mut core = SniPrereadCore::new();
1056        match feed(&mut core, &cfg, &split) {
1057            Output::Routed { cluster, .. } => assert_eq!(cluster, "cluster-split"),
1058            other => panic!("expected Routed for a multi-record ClientHello, got {other:?}"),
1059        }
1060    }
1061
1062    // ---- PROXY-v2 prefixed feeds --------------------------------------------
1063
1064    #[test]
1065    fn proxy_v2_prefix_yields_content_offset_and_source() {
1066        let mut routes = TrieNode::root();
1067        routes.domain_insert(
1068            b"example.com".to_vec(),
1069            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
1070        );
1071        let mut proxy_cfg = cfg(&routes);
1072        proxy_cfg.inbound_proxy = true;
1073
1074        let src = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 51234);
1075        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 443);
1076        let proxy_header = HeaderV2::new(Command::Proxy, src, dst).into_bytes();
1077        // The 28-byte IPv4 v2 header (16-byte fixed prefix + 12-byte IPv4
1078        // address block) precedes the ClientHello on the wire.
1079        assert_eq!(proxy_header.len(), 28);
1080
1081        let mut wire = proxy_header.clone();
1082        wire.extend_from_slice(&hello_no_alpn("example.com"));
1083
1084        let mut core = SniPrereadCore::new();
1085        match feed(&mut core, &proxy_cfg, &wire) {
1086            Output::Routed {
1087                cluster,
1088                content_offset,
1089                proxy_source,
1090                ..
1091            } => {
1092                assert_eq!(cluster, "cluster-a");
1093                assert_eq!(content_offset, proxy_header.len());
1094                assert_eq!(proxy_source, Some(src));
1095            }
1096            other => panic!("expected Routed behind a PROXY-v2 header, got {other:?}"),
1097        }
1098    }
1099
1100    #[test]
1101    fn proxy_v2_prefix_drip_feed_needs_more_until_complete() {
1102        let mut routes = TrieNode::root();
1103        routes.domain_insert(
1104            b"example.com".to_vec(),
1105            vec![(AlpnMatcher::Any, "cluster-a".to_owned())],
1106        );
1107        let mut proxy_cfg = cfg(&routes);
1108        proxy_cfg.inbound_proxy = true;
1109
1110        let src = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 51234);
1111        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 443);
1112        let proxy_header = HeaderV2::new(Command::Proxy, src, dst).into_bytes();
1113        let mut wire = proxy_header.clone();
1114        wire.extend_from_slice(&hello_no_alpn("example.com"));
1115
1116        let mut core = SniPrereadCore::new();
1117        for i in 0..proxy_header.len() {
1118            match feed(&mut core, &proxy_cfg, &wire[..i]) {
1119                Output::NeedMore { .. } => {}
1120                other => panic!("PROXY-header prefix {i} must NeedMore, got {other:?}"),
1121            }
1122        }
1123    }
1124
1125    // ---- route semantics: SNI precedence + ALPN resolution ------------------
1126
1127    #[test]
1128    fn exact_sni_beats_wildcard_before_alpn() {
1129        let mut routes = TrieNode::root();
1130        routes.domain_insert(
1131            b"*.example.com".to_vec(),
1132            vec![(AlpnMatcher::Any, "wildcard-cluster".to_owned())],
1133        );
1134        routes.domain_insert(
1135            b"a.example.com".to_vec(),
1136            vec![(one_of(&[b"h2"]), "exact-cluster".to_owned())],
1137        );
1138        let cfg = cfg(&routes);
1139
1140        // The exact route wins even though its ALPN entry, taken alone,
1141        // would reject an `http/1.1`-only client -- exact-beats-wildcard is
1142        // resolved by the trie BEFORE ALPN is even considered.
1143        let wire = hello("a.example.com", &[b"http/1.1"]);
1144        let mut core = SniPrereadCore::new();
1145        assert_eq!(
1146            feed(&mut core, &cfg, &wire),
1147            Output::Reject(RejectReason::AlpnUnmatched)
1148        );
1149    }
1150
1151    #[test]
1152    fn wildcard_matches_one_level_not_two_not_apex() {
1153        let mut routes = TrieNode::root();
1154        routes.domain_insert(
1155            b"*.example.com".to_vec(),
1156            vec![(AlpnMatcher::Any, "wildcard-cluster".to_owned())],
1157        );
1158        let cfg = cfg(&routes);
1159
1160        let mut core = SniPrereadCore::new();
1161        match feed(&mut core, &cfg, &hello_no_alpn("a.example.com")) {
1162            Output::Routed {
1163                cluster,
1164                sni,
1165                matched_sni_pattern,
1166                matched_alpn,
1167                ..
1168            } => {
1169                assert_eq!(cluster, "wildcard-cluster");
1170                // The matched-route identity is the trie KEY (the
1171                // configured wildcard pattern), NOT the client's concrete
1172                // SNI -- downstream tags keying depends on this.
1173                assert_eq!(sni, "a.example.com");
1174                assert_eq!(matched_sni_pattern, "*.example.com");
1175                assert_eq!(matched_alpn, AlpnMatcher::Any);
1176            }
1177            other => panic!("*.example.com must match a.example.com, got {other:?}"),
1178        }
1179
1180        let mut core = SniPrereadCore::new();
1181        assert_eq!(
1182            feed(&mut core, &cfg, &hello_no_alpn("a.b.example.com")),
1183            Output::Reject(RejectReason::SniUnmatched),
1184            "*.example.com must NOT match a.b.example.com"
1185        );
1186
1187        let mut core = SniPrereadCore::new();
1188        assert_eq!(
1189            feed(&mut core, &cfg, &hello_no_alpn("example.com")),
1190            Output::Reject(RejectReason::SniUnmatched),
1191            "*.example.com must NOT match the apex example.com"
1192        );
1193    }
1194
1195    #[test]
1196    fn alpn_client_preference_order_wins() {
1197        let mut routes = TrieNode::root();
1198        routes.domain_insert(
1199            b"example.com".to_vec(),
1200            vec![
1201                (one_of(&[b"http/1.1"]), "cluster-http11".to_owned()),
1202                (one_of(&[b"h2"]), "cluster-h2".to_owned()),
1203            ],
1204        );
1205        let cfg = cfg(&routes);
1206
1207        // Client prefers h2 first, even though the http/1.1 entry is FIRST
1208        // in the route table -- client order wins.
1209        let mut core = SniPrereadCore::new();
1210        match feed(
1211            &mut core,
1212            &cfg,
1213            &hello("example.com", &[b"h2", b"http/1.1"]),
1214        ) {
1215            Output::Routed { cluster, .. } => assert_eq!(cluster, "cluster-h2"),
1216            other => panic!("expected the client's first preference, got {other:?}"),
1217        }
1218
1219        let mut core = SniPrereadCore::new();
1220        match feed(
1221            &mut core,
1222            &cfg,
1223            &hello("example.com", &[b"http/1.1", b"h2"]),
1224        ) {
1225            Output::Routed { cluster, .. } => assert_eq!(cluster, "cluster-http11"),
1226            other => panic!("expected the client's first preference, got {other:?}"),
1227        }
1228    }
1229
1230    #[test]
1231    fn alpn_any_catch_all_and_no_alpn_client_matches_any_only() {
1232        let mut routes = TrieNode::root();
1233        routes.domain_insert(
1234            b"example.com".to_vec(),
1235            vec![
1236                (one_of(&[b"h2"]), "cluster-h2".to_owned()),
1237                (AlpnMatcher::Any, "cluster-default".to_owned()),
1238            ],
1239        );
1240        let cfg = cfg(&routes);
1241
1242        // Offers something nobody claims via OneOf -> falls through to Any.
1243        let mut core = SniPrereadCore::new();
1244        match feed(&mut core, &cfg, &hello("example.com", &[b"spdy/1"])) {
1245            Output::Routed { cluster, .. } => assert_eq!(cluster, "cluster-default"),
1246            other => panic!("expected the Any catch-all, got {other:?}"),
1247        }
1248
1249        // A client offering NO alpn extension at all matches only Any.
1250        let mut core = SniPrereadCore::new();
1251        match feed(&mut core, &cfg, &hello_no_alpn("example.com")) {
1252            Output::Routed { cluster, alpn, .. } => {
1253                assert_eq!(cluster, "cluster-default");
1254                assert!(alpn.is_empty());
1255            }
1256            other => panic!("expected the Any catch-all for a no-ALPN client, got {other:?}"),
1257        }
1258    }
1259
1260    #[test]
1261    fn no_any_and_no_matching_one_of_is_alpn_unmatched() {
1262        let mut routes = TrieNode::root();
1263        routes.domain_insert(
1264            b"example.com".to_vec(),
1265            vec![(one_of(&[b"h2"]), "cluster-h2".to_owned())],
1266        );
1267        let cfg = cfg(&routes);
1268        let mut core = SniPrereadCore::new();
1269        assert_eq!(
1270            feed(&mut core, &cfg, &hello_no_alpn("example.com")),
1271            Output::Reject(RejectReason::AlpnUnmatched),
1272            "a no-ALPN client with no Any entry must be unmatched, not silently routed"
1273        );
1274    }
1275}