Skip to main content

sozu_lib/protocol/kawa_h1/
editor.rs

1//! H1 request/response header editor.
2//!
3//! Captures method/authority/path on parse, rewrites hop-by-hop and
4//! forwarding headers (`X-Forwarded-*`, `Forwarded`, `Connection`,
5//! WebSocket-upgrade signalling, optional `traceparent`), and surfaces the
6//! canonical `LogContext` used by the access-log envelope. Acts as the
7//! Kawa `ParserCallbacks` implementation for the H1 mux path.
8
9use std::{
10    io::Write as _,
11    net::{IpAddr, SocketAddr},
12    str::from_utf8,
13    sync::Arc,
14};
15
16use rusty_ulid::Ulid;
17use sozu_command_lib::logging::LogContext;
18
19use crate::metrics::names;
20use crate::{
21    Protocol, RetrieveClusterError,
22    pool::Checkout,
23    protocol::{
24        http::{GenericHttpStream, Method, parser::compare_no_case},
25        pipe::WebSocketContext,
26    },
27};
28
29#[cfg(feature = "opentelemetry")]
30fn parse_traceparent(val: &kawa::Store, buf: &[u8]) -> Option<([u8; 32], [u8; 16])> {
31    let val = val.data(buf);
32    let (version, val) = parse_hex::<2>(val)?;
33    if version.as_slice() != b"00" {
34        return None;
35    }
36    let val = skip_separator(val)?;
37    let (trace_id, val) = parse_hex::<32>(val)?;
38    let val = skip_separator(val)?;
39    let (parent_id, val) = parse_hex::<16>(val)?;
40    let val = skip_separator(val)?;
41    let (_, val) = parse_hex::<2>(val)?;
42    val.is_empty().then_some((trace_id, parent_id))
43}
44
45#[cfg(feature = "opentelemetry")]
46fn parse_hex<const N: usize>(buf: &[u8]) -> Option<([u8; N], &[u8])> {
47    let val: [u8; N] = buf.get(..N)?.try_into().unwrap();
48    val.iter()
49        .all(|c| c.is_ascii_hexdigit())
50        .then_some((val, &buf[N..]))
51}
52
53#[cfg(feature = "opentelemetry")]
54fn skip_separator(buf: &[u8]) -> Option<&[u8]> {
55    buf.first().filter(|b| **b == b'-').map(|_| &buf[1..])
56}
57
58#[cfg(feature = "opentelemetry")]
59fn random_id<const N: usize>() -> [u8; N] {
60    use rand::RngExt;
61    const CHARSET: &[u8] = b"0123456789abcdef";
62    let mut rng = rand::rng();
63    let mut buf = [0; N];
64    buf.fill_with(|| {
65        let n = rng.random_range(0..CHARSET.len());
66        CHARSET[n]
67    });
68    buf
69}
70
71#[cfg(feature = "opentelemetry")]
72fn build_traceparent(trace_id: &[u8; 32], parent_id: &[u8; 16]) -> [u8; 55] {
73    // Pre: the ids are hex (they come from a parsed traceparent or our own
74    // hex `random_id`). Hex digits are inherently CR/LF-free, so this also
75    // upholds the anti-injection guarantee for the value we splice into the
76    // `traceparent` header. Inline (not via `is_crlf_free`) so the release
77    // build under `--features opentelemetry` still compiles (HARD RULE 2).
78    debug_assert!(
79        trace_id.iter().all(u8::is_ascii_hexdigit) && parent_id.iter().all(u8::is_ascii_hexdigit),
80        "traceparent ids must be hex (CR/LF-free header value)"
81    );
82    let mut buf = [0; 55];
83    buf[..3].copy_from_slice(b"00-");
84    buf[3..35].copy_from_slice(trace_id);
85    buf[35] = b'-';
86    buf[36..52].copy_from_slice(parent_id);
87    buf[52..55].copy_from_slice(b"-01");
88    // Post: the value is exactly the `00-<trace>-<parent>-01` shape with the
89    // dash separators at the fixed offsets the parser expects.
90    debug_assert!(
91        buf[2] == b'-' && buf[35] == b'-' && buf[52] == b'-',
92        "traceparent must carry dash separators at fixed offsets"
93    );
94    buf
95}
96
97/// `true` when `bytes` contains no CR or LF — the anti-injection
98/// invariant for any header value Sōzu serialises onto the wire. A value
99/// carrying a raw CR/LF could split one header into two (request/response
100/// smuggling, CWE-93/CWE-113).
101///
102/// Compiled in EVERY profile (HARD RULE 2): it is referenced inside plain
103/// `debug_assert!` calls whose arguments still compile with
104/// `debug_assertions` OFF, so a `#[cfg(debug_assertions)]` gate would break
105/// the release build with E0425. In release it is only reached from
106/// optimised-out `debug_assert!` bodies, so the optimiser drops it — hence
107/// `#[allow(dead_code)]` to keep `-D warnings` clean.
108#[allow(dead_code)]
109fn is_crlf_free(bytes: &[u8]) -> bool {
110    !bytes.iter().any(|b| *b == b'\r' || *b == b'\n')
111}
112
113/// Write the ";for=..;by=.." portion of a Forwarded header into `buf`
114/// without heap-allocating a `String`.
115///
116/// RFC 7239 §6 requires IPv6 literals to be enclosed in square brackets
117/// inside a quoted string (the `IP-literal` production is `"[" IPv6address "]"`).
118/// Emitting a bare IPv6 like `for="2001:db8::1:8080"` is ambiguous: the
119/// trailing `:1:8080` could parse as `::1` + port `8080` or as the literal
120/// `:1:8080` with no port. Matches HAProxy's behaviour
121/// (`_7239_print_ip6` in `src/http_ext.c`), which is the de-facto reference
122/// implementation of RFC 7239 in the proxy world. See sozu issue #1254.
123fn write_forwarded_for_by(buf: &mut Vec<u8>, peer_ip: IpAddr, peer_port: u16, public_ip: IpAddr) {
124    let before = buf.len();
125    buf.extend_from_slice(b";for=\"");
126    write_ip_literal(buf, peer_ip);
127    buf.push(b':');
128    let mut port_buf = itoa::Buffer::new();
129    buf.extend_from_slice(port_buf.format(peer_port).as_bytes());
130    buf.extend_from_slice(b"\";by=");
131    match public_ip {
132        IpAddr::V4(_) => {
133            let _ = write!(buf, "{public_ip}");
134        }
135        IpAddr::V6(_) => {
136            buf.push(b'"');
137            write_ip_literal(buf, public_ip);
138            buf.push(b'"');
139        }
140    }
141    // Post: the fragment grew the buffer and the whole appended span is
142    // CR/LF-free — it is spliced verbatim into a `Forwarded` header value,
143    // so a stray CR/LF would let an attacker-influenced peer address split
144    // the header (CWE-93). `peer_ip`/`public_ip` are `IpAddr` and the port
145    // is a `u16`, so no untrusted bytes reach here, but the guard pins the
146    // anti-injection contract against future edits to this fragment.
147    debug_assert!(
148        buf.len() > before,
149        "the Forwarded ;for=;by= fragment must emit bytes"
150    );
151    debug_assert!(
152        is_crlf_free(&buf[before..]),
153        "the Forwarded fragment must be CR/LF-free (anti-injection)"
154    );
155}
156
157/// Write an `IP-literal` per RFC 3986 §3.2.2 / RFC 7239 §6: IPv4 verbatim,
158/// IPv6 wrapped in `[...]`. Caller is responsible for any surrounding quotes
159/// required by the embedding syntax (RFC 7239 mandates them for IPv6).
160fn write_ip_literal(buf: &mut Vec<u8>, ip: IpAddr) {
161    let before = buf.len();
162    match ip {
163        IpAddr::V4(_) => {
164            let _ = write!(buf, "{ip}");
165        }
166        IpAddr::V6(_) => {
167            buf.push(b'[');
168            let _ = write!(buf, "{ip}");
169            buf.push(b']');
170        }
171    }
172    // Post: a non-empty literal was appended (every IpAddr renders to at
173    // least one byte) and the emitted bytes carry no CR/LF — an IpAddr can
174    // only render `[0-9a-fA-F:.]` plus the brackets we add, so this is a
175    // belt-and-suspenders guard on the value we splice into a header.
176    debug_assert!(buf.len() > before, "write_ip_literal must emit bytes");
177    debug_assert!(
178        is_crlf_free(&buf[before..]),
179        "an IP literal spliced into a header must be CR/LF-free"
180    );
181    // IPv6 is bracketed on both ends; IPv4 is bare (RFC 3986 §3.2.2).
182    debug_assert_eq!(
183        matches!(ip, IpAddr::V6(_)),
184        buf[before] == b'[' && buf[buf.len() - 1] == b']',
185        "IPv6 literals are bracketed, IPv4 literals are not"
186    );
187}
188
189/// Write ", proto=<proto>;for=..;by=.." (the suffix appended to an existing Forwarded value).
190fn write_forwarded_suffix(
191    buf: &mut Vec<u8>,
192    proto: &str,
193    peer_ip: IpAddr,
194    peer_port: u16,
195    public_ip: IpAddr,
196) {
197    // Pre: `proto` is the proxy-chosen scheme label ("http"/"https"), never
198    // attacker-controlled — assert it carries no CR/LF before it is spliced
199    // into the `Forwarded` value (anti-injection, CWE-93).
200    debug_assert!(
201        is_crlf_free(proto.as_bytes()),
202        "the proto label spliced into Forwarded must be CR/LF-free"
203    );
204    let before = buf.len();
205    buf.extend_from_slice(b", proto=");
206    buf.extend_from_slice(proto.as_bytes());
207    write_forwarded_for_by(buf, peer_ip, peer_port, public_ip);
208    // Post: the suffix grew the buffer, begins with the `, proto=`
209    // separator (so it appends cleanly to an existing value), and the whole
210    // appended span is CR/LF-free.
211    debug_assert!(
212        buf.len() > before + b", proto=".len(),
213        "the Forwarded suffix must emit the separator plus a value"
214    );
215    debug_assert!(
216        buf[before..].starts_with(b", proto="),
217        "the Forwarded suffix must start with the `, proto=` separator"
218    );
219    debug_assert!(
220        is_crlf_free(&buf[before..]),
221        "the Forwarded suffix must be CR/LF-free (anti-injection)"
222    );
223}
224
225/// This is the container used to store and use information about the session from within a Kawa parser callback
226#[derive(Debug)]
227pub struct HttpContext {
228    // ========== Write only
229    /// set to false if Kawa finds a "Connection" header with a "close" value in the response
230    pub keep_alive_backend: bool,
231    /// set to false if Kawa finds a "Connection" header with a "close" value in the request
232    pub keep_alive_frontend: bool,
233    /// the value of the sticky session cookie in the request
234    pub sticky_session_found: Option<String>,
235    // ---------- Status Line
236    /// the value of the method in the request line
237    pub method: Option<Method>,
238    /// the value of the authority of the request (in the request line of "Host" header)
239    pub authority: Option<String>,
240    /// the value of the path in the request line
241    pub path: Option<String>,
242    /// the value of the status code in the response line
243    pub status: Option<u16>,
244    /// the value of the reason in the response line
245    pub reason: Option<String>,
246    // ---------- Additional optional data
247    pub user_agent: Option<String>,
248    /// Value of the `x-request-id` header observed (if propagated from the
249    /// client/upstream LB) or generated (from `self.id`). Universal correlation
250    /// header — populated unconditionally by `on_request_headers` so the access
251    /// log can record the exact value forwarded to the backend.
252    pub x_request_id: Option<String>,
253    /// Verbatim value of the client-supplied `X-Forwarded-For` header as
254    /// observed before Sōzu appended its own hop. Captured here, not at
255    /// request edit time, so the access log records the upstream-attested
256    /// chain even when Sōzu also appends its own peer to the forwarded
257    /// header. `None` if the request had no `X-Forwarded-For` header.
258    pub xff_chain: Option<String>,
259
260    #[cfg(feature = "opentelemetry")]
261    pub otel: Option<sozu_command::logging::OpenTelemetry>,
262
263    // ========== Read only
264    /// signals wether Kawa should write a "Connection" header with a "close" value (request and response)
265    pub closing: bool,
266    /// Connection/session ULID — stable across all requests multiplexed on this
267    /// TCP or TLS connection. Used as the first slot in the legacy log-context
268    /// bracket `[session req cluster backend]` and emitted into
269    /// `ProtobufAccessLog.session_id`.
270    pub session_id: Ulid,
271    /// the value of the custom header, named "Sozu-Id", that Kawa should write (request and response)
272    pub id: Ulid,
273    pub backend_id: Option<String>,
274    pub cluster_id: Option<String>,
275    /// the value of the protocol Kawa should write in the Forwarded headers of the request
276    pub protocol: Protocol,
277    /// the value of the public address Kawa should write in the Forwarded headers of the request
278    pub public_address: SocketAddr,
279    /// the value of the session address Kawa should write in the Forwarded headers of the request
280    pub session_address: Option<SocketAddr>,
281    /// the name of the cookie Kawa should read from the request to get the sticky session
282    pub sticky_name: String,
283    /// the sticky session that should be used
284    /// used to create a "Set-Cookie" header in the response in case it differs from sticky_session_found
285    pub sticky_session: Option<String>,
286    /// the address of the backend server
287    pub backend_address: Option<SocketAddr>,
288    /// The TLS Server Name Indication (SNI) hostname negotiated at handshake.
289    ///
290    /// Populated for HTTPS listeners when the client sent an SNI extension (see
291    /// `https.rs::upgrade_handshake`). Used by the routing layer to enforce the
292    /// TLS trust boundary against the HTTP `:authority` / `Host` header — without
293    /// this check, an attacker holding a valid certificate for tenant A could
294    /// open TLS with SNI=A then send requests with `:authority=tenantB` and
295    /// reach tenant B's backend (CWE-346 / CWE-444).
296    ///
297    /// `None` when the listener is plaintext HTTP or the client omitted SNI.
298    /// Stored pre-lowercased and without a port for direct exact-match comparison.
299    pub tls_server_name: Option<String>,
300    /// Snapshot of the SAN set of the certificate Sōzu actually served at
301    /// the TLS handshake. Captured once in `https.rs::upgrade_handshake`
302    /// from the resolver and frozen for the connection lifetime so H2
303    /// stream coalescing (RFC 7540 §9.1.1 / RFC 9113 §9.1.1) accepts any
304    /// `:authority` covered by the certificate, with RFC 6125 §6.4.3
305    /// wildcard handling. `None` for plaintext listeners or when SNI was
306    /// absent (router falls back to the legacy exact-match predicate).
307    /// `Some(empty)` when the default cert was served — every
308    /// `:authority` is rejected. `Arc` so the snapshot is shared across
309    /// every per-stream `HttpContext` without re-allocation.
310    pub tls_cert_names: Option<Arc<Vec<String>>>,
311    /// Whether the router must reject this request when `tls_server_name`
312    /// does not exact-match its authority (CWE-346 / CWE-444). Mirrors
313    /// `HttpsListenerConfig::strict_sni_binding`. Set from the mux
314    /// `Context` at stream creation time (see `Context::create_stream`).
315    /// Plaintext listeners still never hit the check because
316    /// `tls_server_name` is `None`.
317    pub strict_sni_binding: bool,
318    /// When `true`, the request-side block walk in `on_request_headers`
319    /// strips any client-supplied `X-Real-IP` header before forwarding
320    /// (anti-spoofing). Mirrors `HttpListenerConfig::elide_x_real_ip` /
321    /// `HttpsListenerConfig::elide_x_real_ip`. Set from the mux `Context`
322    /// at stream creation (see `Context::create_stream`); listener-scoped
323    /// and never reset across keep-alive requests. Independent of
324    /// `send_x_real_ip`. The same flag is plumbed into
325    /// `pkawa::handle_trailer` so trailer HEADERS frames cannot bypass
326    /// the elision.
327    pub elide_x_real_ip: bool,
328    /// When `true`, `on_request_headers` injects a proxy-generated
329    /// `X-Real-IP` header carrying `session_address.ip()` (post-PROXY-v2
330    /// unwrap, i.e. the original client IP). Mirrors
331    /// `HttpListenerConfig::send_x_real_ip` /
332    /// `HttpsListenerConfig::send_x_real_ip`. Set from the mux `Context`
333    /// at stream creation (see `Context::create_stream`); listener-scoped
334    /// and never reset across keep-alive requests. Independent of
335    /// `elide_x_real_ip`. When `session_address` is `None` (raw socket
336    /// without a peer), no header is appended — identical to the
337    /// existing X-Forwarded-For / Forwarded synthesis behaviour.
338    pub send_x_real_ip: bool,
339    /// Negotiated TLS protocol version as a short label (e.g. `"TLSv1.3"`).
340    /// Captured from `rustls_version_label` at handshake completion and
341    /// propagated from the mux `Context`. `None` for plaintext listeners.
342    pub tls_version: Option<&'static str>,
343    /// Negotiated TLS cipher suite as a short label (e.g.
344    /// `"TLS_AES_128_GCM_SHA256"`). Captured from `rustls_ciphersuite_label`
345    /// at handshake completion and propagated from the mux `Context`. `None`
346    /// for plaintext listeners.
347    pub tls_cipher: Option<&'static str>,
348    /// Negotiated ALPN protocol (e.g. `"h2"`, `"http/1.1"`). Captured from
349    /// rustls at handshake completion and propagated from the mux `Context`.
350    /// `None` for plaintext listeners or when no ALPN was negotiated.
351    pub tls_alpn: Option<&'static str>,
352    /// Name of the correlation header Sozu injects into every request and
353    /// response. Defaults to `"Sozu-Id"` via [`L7ListenerHandler::get_sozu_id_header`].
354    /// Populated at stream creation from the listener config's `sozu_id_header`
355    /// knob. Stored as an owned `String` so it survives a listener hot-reload
356    /// that changes the value.
357    pub sozu_id_header: String,
358    /// Resolved `Location` URL stashed by the routing layer when a frontend
359    /// triggers a permanent redirect (`RedirectPolicy::PERMANENT` or the
360    /// legacy `cluster.https_redirect`). Read by the default-answer 301
361    /// path so the response carries the correct target URL — including
362    /// optional `cluster.https_redirect_port` and rewrite-template captures.
363    /// `None` when the request is not redirecting.
364    pub redirect_location: Option<String>,
365    /// `WWW-Authenticate` realm stashed by the routing layer when a
366    /// frontend rejects an unauthenticated request (`required_auth = true`
367    /// without a valid `Authorization: Basic` header, or
368    /// `RedirectPolicy::UNAUTHORIZED`). Read by the default-answer 401
369    /// path so the response carries the cluster's configured
370    /// `www_authenticate` value. `None` falls back to template default
371    /// (header is elided when no realm is configured).
372    pub www_authenticate: Option<String>,
373    /// Original authority captured before a rewrite-host fired; emitted
374    /// back to the backend as `X-Forwarded-Host` so the backend can
375    /// reconstruct the public URL even though `:authority` / `Host` was
376    /// rewritten on the wire. `None` when no host rewrite happened.
377    pub original_authority: Option<String>,
378    /// Per-frontend response-side header edits (set/replace/delete)
379    /// stashed by the routing layer for the emission boundary in
380    /// `mux/h1.rs::writable` and `mux/h2.rs::write_streams` to apply
381    /// before `kawa.prepare(...)`. Empty when the frontend has no
382    /// response-side header policy. An entry with an empty `val`
383    /// deletes the header by name (HAProxy `del-header` parity); a
384    /// non-empty `val` set/replaces.
385    pub headers_response: Vec<HeaderEditSnapshot>,
386    /// Resolved `Retry-After` value (seconds) for an HTTP 429 default
387    /// answer. Computed in `Router::connect` when the per-(cluster,
388    /// source-IP) connection limit is hit, by folding the cluster's
389    /// `retry_after` override over the global default. `None` (or
390    /// `Some(0)`) tells the answer engine to omit the `Retry-After`
391    /// header entirely — `Retry-After: 0` invites an immediate retry
392    /// that defeats the limit. Unused for any other status code.
393    pub retry_after_seconds: Option<u32>,
394    /// Frontend-supplied template body that overrides the listener /
395    /// cluster default `http.301.redirection` for a single
396    /// `RedirectPolicy::PERMANENT` request. Stashed by the routing layer
397    /// from `RouteResult::redirect_template` and consumed by the 301
398    /// branch of `mux::answers::set_default_answer_with_retry_after`,
399    /// which compiles the body via `HttpAnswers::render_inline_301` and
400    /// renders it with the same `(REDIRECT_LOCATION, ROUTE,
401    /// REQUEST_ID)` variable schema as the persistent template chain.
402    /// `None` falls back to the cluster / listener default. Unused for
403    /// any other status code or for the legacy
404    /// `cluster.https_redirect = true` path (which never sets it).
405    pub frontend_redirect_template: Option<String>,
406    /// Resolved redirect status code stashed by the routing layer when
407    /// a frontend's `RedirectPolicy` is one of the redirect variants.
408    /// 301 = `Permanent`, 302 = `Found`, 308 = `PermanentRedirect`.
409    /// `None` falls back to 301 for the legacy
410    /// `cluster.https_redirect = true` path. Closes #1009.
411    pub redirect_status: Option<u16>,
412    /// Stable, structured discriminator surfaced as the access-log
413    /// `message` field when the session terminates on a timeout. Set by
414    /// the timeout handlers in `kawa_h1::timeout` and `MuxState::timeout`
415    /// **before** the default-answer or `forcefully_terminate_answer`
416    /// path consumes it. The vocabulary is operator-visible API once
417    /// shipped — see the access-log section of `doc/configure.md` for
418    /// the full token list (`client_timeout`,
419    /// `client_timeout_during_response`, `backend_timeout`,
420    /// `backend_response_timeout`). `None` for non-timeout sessions, in
421    /// which case the access log emits `message: None` as before.
422    pub access_log_message: Option<&'static str>,
423}
424
425/// How `apply_response_header_edits` should interpret a per-edit value.
426///
427/// The implicit empty-`val` Append → delete encoding is still supported
428/// (so legacy operator-supplied `[[...frontends.headers]]` entries work
429/// unchanged); the explicit modes give typed policies finer control:
430///
431/// - `Append`: drop the legacy delete shortcut for empty `val`, and
432///   append every other entry before the end-of-headers flag.
433/// - `SetIfAbsent`: skip the insert when `kawa.blocks` already carries
434///   a non-elided header with the same name (case-insensitive). HSTS
435///   uses this by default to preserve a backend-supplied
436///   `Strict-Transport-Security` (RFC 6797 §6.1 single-header
437///   requirement).
438/// - `Set`: delete every existing header with the matching name, then
439///   insert the new entry. Use when the operator wants their typed
440///   policy to override any backend-supplied value (the
441///   `force_replace_backend = true` HSTS shape, for example).
442#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
443pub enum HeaderEditMode {
444    /// Append the header before the end-of-headers flag. Empty `val`
445    /// is interpreted as a delete (legacy behaviour preserved).
446    #[default]
447    Append,
448    /// Skip the insert if `kawa.blocks` already contains a non-elided
449    /// header whose name matches `key` case-insensitively. Otherwise
450    /// behave like `Append`.
451    SetIfAbsent,
452    /// Delete every existing header with the matching name, then
453    /// insert the new value. Equivalent to two operator-defined edits
454    /// (delete + append) but safer to express as one typed entry.
455    Set,
456}
457
458/// Owned snapshot of a per-frontend header edit, captured at routing
459/// time so the emission boundary can apply set/replace/delete without
460/// touching the routing layer's `Rc<[HeaderEdit]>` slices.
461///
462/// `mode` chooses between explicit Append/Delete/SetIfAbsent semantics.
463/// For backwards compatibility `mode = Append` paired with an empty
464/// `val` is still treated as a delete by `apply_response_header_edits`
465/// (HAProxy `del-header` parity), so callers that have not yet migrated
466/// to the explicit `HeaderEditMode::Delete` keep working unchanged.
467#[derive(Debug, Clone)]
468pub struct HeaderEditSnapshot {
469    pub key: Vec<u8>,
470    pub val: Vec<u8>,
471    pub mode: HeaderEditMode,
472}
473
474impl kawa::h1::ParserCallbacks<Checkout> for HttpContext {
475    fn on_headers(&mut self, stream: &mut GenericHttpStream) {
476        match stream.kind {
477            kawa::Kind::Request => self.on_request_headers(stream),
478            kawa::Kind::Response => self.on_response_headers(stream),
479        }
480    }
481}
482
483impl HttpContext {
484    /// Creates a new instance
485    #[allow(clippy::too_many_arguments)]
486    pub fn new(
487        session_id: Ulid,
488        request_id: Ulid,
489        protocol: Protocol,
490        public_address: SocketAddr,
491        session_address: Option<SocketAddr>,
492        sticky_name: String,
493        sozu_id_header: String,
494        elide_x_real_ip: bool,
495        send_x_real_ip: bool,
496    ) -> Self {
497        Self {
498            session_id,
499            id: request_id,
500            backend_id: None,
501            cluster_id: None,
502
503            closing: false,
504            keep_alive_backend: true,
505            keep_alive_frontend: true,
506            protocol,
507            public_address,
508            session_address,
509            sticky_name,
510            sticky_session: None,
511            sticky_session_found: None,
512
513            method: None,
514            authority: None,
515            path: None,
516            status: None,
517            reason: None,
518            user_agent: None,
519            x_request_id: None,
520            xff_chain: None,
521
522            #[cfg(feature = "opentelemetry")]
523            otel: Default::default(),
524
525            backend_address: None,
526            tls_server_name: None,
527            tls_cert_names: None,
528            strict_sni_binding: true,
529            elide_x_real_ip,
530            send_x_real_ip,
531            tls_version: None,
532            tls_cipher: None,
533            tls_alpn: None,
534            sozu_id_header,
535            redirect_location: None,
536            www_authenticate: None,
537            original_authority: None,
538            headers_response: Vec::new(),
539            retry_after_seconds: None,
540            frontend_redirect_template: None,
541            redirect_status: None,
542            access_log_message: None,
543        }
544    }
545
546    /// Callback for request:
547    ///
548    /// - edit headers (connection, forwarded, sticky cookie, sozu-id,
549    ///   x-request-id, x-real-ip)
550    /// - save information:
551    ///   - method
552    ///   - authority
553    ///   - path
554    ///   - front keep-alive
555    ///   - sticky cookie
556    ///   - user-agent
557    ///   - x-request-id (preserved if present, else derived from `self.id`)
558    fn on_request_headers(&mut self, request: &mut GenericHttpStream) {
559        // Editing never drops a block — it only elides in place (key set to
560        // Empty, length preserved) or pushes new headers. Snapshot the count
561        // so the postcondition can pin "blocks only grow" for the whole edit.
562        let blocks_at_entry = request.blocks.len();
563
564        // Defense-in-depth against CL.TE request smuggling (CWE-444, RFC 9110 §7.6 /
565        // RFC 9112 §6.1; reopen of #726). kawa never elides the Transfer-Encoding header
566        // and evaluates each TE field line independently, so we must reason about ALL
567        // surviving TE headers, not kawa's aggregate `body_size` (which a leading
568        // `chunked` line can latch while a later non-chunked line rides along). Reject:
569        //   * more than one non-elided TE header  (RFC 9112 §6.1: chunked must be applied
570        //     once and be the final coding; multiple field lines can't be safely
571        //     reconciled here and a Chunked latch would leave a second line forwarded), or
572        //   * a TE header whose raw value does not literally end in `chunked`, or
573        //   * a TE header present while kawa did not adopt chunked framing.
574        //
575        // The literal-suffix check is what keeps OWS-obfuscated codings (`chunked\t`,
576        // `chunked `) and non-final codings (`chunked, gzip`) fail-closed. kawa >=0.7.0
577        // OWS-trims the final coding, so it frames `chunked\t` AS chunked and elides the
578        // Content-Length — but it still forwards the TE field line *verbatim*. A backend
579        // that does not itself trim OWS would then see neither a coding it recognizes nor
580        // a Content-Length, and would read our chunked body bytes as a pipelined request:
581        // a TE.TE desync. Framing the message correctly is kawa's job; refusing to forward
582        // an obfuscated coding we had to normalize to understand is ours.
583        let (te_count, te_all_suffix_chunked) = {
584            const CHUNKED: &[u8] = b"chunked";
585            let buf0 = request.storage.buffer();
586            request
587                .blocks
588                .iter()
589                .filter_map(|block| match block {
590                    kawa::Block::Header(header)
591                        if !header.is_elided()
592                            && compare_no_case(header.key.data(buf0), b"transfer-encoding") =>
593                    {
594                        Some(header.val.data(buf0))
595                    }
596                    _ => None,
597                })
598                .fold((0usize, true), |(count, all_chunked), val| {
599                    let suffix_chunked = val.len() >= CHUNKED.len()
600                        && compare_no_case(&val[val.len() - CHUNKED.len()..], CHUNKED);
601                    (count + 1, all_chunked && suffix_chunked)
602                })
603        };
604        if te_count > 1
605            || (te_count == 1
606                && (!te_all_suffix_chunked || request.body_size != kawa::BodySize::Chunked))
607        {
608            incr!(names::http::FRONTEND_TE_SMUGGLING);
609            warn!(
610                "{} rejecting request: ambiguous Transfer-Encoding framing (possible CL.TE request smuggling)",
611                self.log_context()
612            );
613            request
614                .parsing_phase
615                .error("Transfer-Encoding conflicts with message framing".into());
616            return;
617        }
618
619        let buf = request.storage.mut_buffer();
620
621        // Captures the request line
622        if let kawa::StatusLine::Request {
623            method,
624            authority,
625            path,
626            ..
627        } = &request.detached.status_line
628        {
629            self.method = method.data_opt(buf).map(Method::new);
630            self.authority = authority
631                .data_opt(buf)
632                .and_then(|data| from_utf8(data).ok())
633                .map(ToOwned::to_owned);
634            self.path = path
635                .data_opt(buf)
636                .and_then(|data| from_utf8(data).ok())
637                .map(ToOwned::to_owned);
638        }
639
640        // if self.method == Some(Method::Get) && request.body_size == kawa::BodySize::Empty {
641        //     request.parsing_phase = kawa::ParsingPhase::Terminated;
642        // }
643
644        let public_ip = self.public_address.ip();
645        let public_port = self.public_address.port();
646        let proto = match self.protocol {
647            Protocol::HTTP => "http",
648            Protocol::HTTPS => "https",
649            _ => unreachable!(),
650        };
651
652        // `proto` is the proxy-resolved scheme label, sourced from the
653        // listener protocol (never from request bytes). The match above
654        // already rejects anything but HTTP/HTTPS; pin that it is one of the
655        // two CR/LF-free literals we splice into forwarding headers.
656        debug_assert!(
657            proto == "http" || proto == "https",
658            "proto must be the http/https scheme label"
659        );
660
661        // Find and remove the sticky_name cookie
662        // if found its value is stored in sticky_session_found
663        for cookie in &mut request.detached.jar {
664            let key = cookie.key.data(buf);
665            if key == self.sticky_name.as_bytes() {
666                let val = cookie.val.data(buf);
667                self.sticky_session_found = from_utf8(val).ok().map(ToOwned::to_owned);
668                cookie.elide();
669                // Post: the matched sticky cookie is gone from the forwarded
670                // jar so the backend never sees Sōzu's own session cookie.
671                debug_assert!(
672                    cookie.is_elided(),
673                    "the matched sticky cookie must be elided after capture"
674                );
675            }
676        }
677
678        // If found:
679        // - set Connection to "close" if closing is set
680        // - set keep_alive_frontend to false if Connection is "close"
681        // - update value of X-Forwarded-Proto
682        // - update value of X-Forwarded-Port
683        // - store X-Forwarded-For
684        // - store Forwarded
685        // - store User-Agent
686        let mut x_for = None;
687        let mut forwarded = None;
688        let mut has_x_port = false;
689        let mut has_x_proto = false;
690        let mut has_x_request_id = false;
691        let mut has_connection = false;
692        #[cfg(feature = "opentelemetry")]
693        let mut traceparent: Option<&mut kawa::Pair> = None;
694        #[cfg(feature = "opentelemetry")]
695        let mut tracestate: Option<&mut kawa::Pair> = None;
696        for block in &mut request.blocks {
697            match block {
698                kawa::Block::Header(header) if !header.is_elided() => {
699                    let key = header.key.data(buf);
700                    if compare_no_case(key, b"connection") {
701                        has_connection = true;
702                        if self.closing {
703                            header.val = kawa::Store::Static(b"close");
704                        } else {
705                            let val = header.val.data(buf);
706                            self.keep_alive_frontend &= !compare_no_case(val, b"close");
707                        }
708                    } else if compare_no_case(key, b"X-Forwarded-Proto") {
709                        has_x_proto = true;
710                        // header.val = kawa::Store::Static(proto.as_bytes());
711                        incr!(names::http::TRUSTING_X_PROTO);
712                        let val = header.val.data(buf);
713                        if !compare_no_case(val, proto.as_bytes()) {
714                            incr!(names::http::TRUSTING_X_PROTO_DIFF);
715                            debug!(
716                                "{} Trusting X-Forwarded-Proto for {:?} even though {:?} != {}",
717                                self.log_context(),
718                                self.authority,
719                                val,
720                                proto
721                            );
722                        }
723                    } else if compare_no_case(key, b"X-Forwarded-Port") {
724                        has_x_port = true;
725                        // header.val = kawa::Store::from_string(public_port.to_string());
726                        incr!(names::http::TRUSTING_X_PORT);
727                        let val = header.val.data(buf);
728                        let mut port_buf = itoa::Buffer::new();
729                        let expected = port_buf.format(public_port);
730                        if !compare_no_case(val, expected.as_bytes()) {
731                            incr!(names::http::TRUSTING_X_PORT_DIFF);
732                            debug!(
733                                "{} Trusting X-Forwarded-Port for {:?} even though {:?} != {}",
734                                self.log_context(),
735                                self.authority,
736                                val,
737                                expected
738                            );
739                        }
740                    } else if compare_no_case(key, b"X-Forwarded-For") {
741                        // Snapshot the upstream-attested chain before we
742                        // potentially append our own peer below — the access
743                        // log records the value the client/upstream LB
744                        // forwarded, not the rewritten value Sōzu emits.
745                        self.xff_chain = header
746                            .val
747                            .data_opt(buf)
748                            .and_then(|data| from_utf8(data).ok())
749                            .map(ToOwned::to_owned);
750                        x_for = Some(header);
751                    } else if compare_no_case(key, b"X-Real-IP") && self.elide_x_real_ip {
752                        // Anti-spoofing: a client cannot supply its own
753                        // `X-Real-IP` and have it reach the backend. The
754                        // proxy-injected value (when `send_x_real_ip` is
755                        // also set) is appended after this loop. H2 trailer
756                        // HEADERS frames bypass this callback; they are
757                        // covered by the matching elision in
758                        // `pkawa::handle_trailer`.
759                        debug_assert!(
760                            self.elide_x_real_ip,
761                            "X-Real-IP is only elided when anti-spoofing is enabled"
762                        );
763                        header.elide();
764                        // Post: the spoofable client value is stripped before
765                        // forwarding (anti-spoofing invariant, CWE-348).
766                        debug_assert!(
767                            header.is_elided(),
768                            "client X-Real-IP must be elided when elide_x_real_ip is set"
769                        );
770                    } else if compare_no_case(key, b"Forwarded") {
771                        forwarded = Some(header);
772                    } else if compare_no_case(key, b"User-Agent") {
773                        self.user_agent = header
774                            .val
775                            .data_opt(buf)
776                            .and_then(|data| from_utf8(data).ok())
777                            .map(ToOwned::to_owned);
778                    } else if compare_no_case(key, b"X-Request-Id") {
779                        // RFC: not standardized, but the de-facto correlation
780                        // header used by Envoy/HAProxy/most LBs. Preserve the
781                        // client-supplied value verbatim — overwriting it
782                        // breaks end-to-end request tracing.
783                        has_x_request_id = true;
784                        self.x_request_id = header
785                            .val
786                            .data_opt(buf)
787                            .and_then(|data| from_utf8(data).ok())
788                            .map(ToOwned::to_owned);
789                    } else {
790                        #[cfg(feature = "opentelemetry")]
791                        if compare_no_case(key, b"traceparent") {
792                            if let Some(hdr) = traceparent {
793                                hdr.elide();
794                            }
795                            traceparent = Some(header);
796                        } else if compare_no_case(key, b"tracestate") {
797                            if let Some(hdr) = tracestate {
798                                hdr.elide();
799                            }
800                            tracestate = Some(header);
801                        }
802                    }
803                }
804                _ => {}
805            }
806        }
807
808        #[cfg(feature = "opentelemetry")]
809        let (otel, has_traceparent) = {
810            let mut otel = sozu_command_lib::logging::OpenTelemetry::default();
811            let tp = traceparent
812                .as_ref()
813                .and_then(|hdr| parse_traceparent(&hdr.val, buf))
814                .map(|(trace_id, parent_id)| (trace_id, Some(parent_id)));
815            // Remove tracestate if no traceparent is present
816            if let (None, Some(tracestate)) = (tp, tracestate) {
817                tracestate.elide();
818            }
819            let (trace_id, parent_id) = tp.unwrap_or_else(|| (random_id(), None));
820            otel.trace_id = trace_id;
821            otel.parent_span_id = parent_id;
822            otel.span_id = random_id();
823            // Modify header if present
824            if let Some(id) = &mut traceparent {
825                let new_val = build_traceparent(&otel.trace_id, &otel.span_id);
826                id.val.modify(buf, &new_val);
827            }
828            (otel, traceparent.is_some())
829        };
830
831        // If session_address is set:
832        // - append its ip address to the list of "X-Forwarded-For" if it was found, creates it if not
833        // - append "proto=[PROTO];for=[PEER];by=[PUBLIC]" to the list of "Forwarded" if it was found, creates it if not
834        if let Some(peer_addr) = self.session_address {
835            let peer_ip = peer_addr.ip();
836            let peer_port = peer_addr.port();
837            let has_x_for = x_for.is_some();
838            let has_forwarded = forwarded.is_some();
839
840            // Buffer for building header values — ownership is transferred to Store
841            // via `take`, so each header gets its own allocation.
842            let mut hdr_buf = Vec::with_capacity(128);
843
844            if let Some(header) = x_for {
845                let prior_len = header.val.data(buf).len();
846                hdr_buf.extend_from_slice(header.val.data(buf));
847                let _ = write!(hdr_buf, ", {peer_ip}");
848                // Pre: we only ever extend the client-attested chain — the
849                // existing value stays a prefix and we appended the `, ip`
850                // separator, so the rewritten value is strictly longer and
851                // CR/LF-free (else the appended peer would split the header).
852                debug_assert!(
853                    hdr_buf.len() > prior_len,
854                    "X-Forwarded-For append must grow the value"
855                );
856                debug_assert!(
857                    is_crlf_free(&hdr_buf[prior_len..]),
858                    "the X-Forwarded-For hop we append must be CR/LF-free (anti-injection)"
859                );
860                header.val = kawa::Store::from_vec(std::mem::take(&mut hdr_buf));
861            }
862            if let Some(header) = &mut forwarded {
863                let prior_len = header.val.data(buf).len();
864                hdr_buf.extend_from_slice(header.val.data(buf));
865                write_forwarded_suffix(&mut hdr_buf, proto, peer_ip, peer_port, public_ip);
866                // Pre: same contract for the structured `Forwarded` chain —
867                // the existing value is preserved as a prefix and our suffix
868                // is CR/LF-free (`write_forwarded_suffix` asserts the suffix
869                // span too; this pins it relative to the prior value).
870                debug_assert!(
871                    hdr_buf.len() > prior_len,
872                    "Forwarded append must grow the value"
873                );
874                debug_assert!(
875                    is_crlf_free(&hdr_buf[prior_len..]),
876                    "the Forwarded element we append must be CR/LF-free (anti-injection)"
877                );
878                header.val = kawa::Store::from_vec(std::mem::take(&mut hdr_buf));
879            }
880
881            if !has_x_for {
882                let blocks_before = request.blocks.len();
883                let _ = write!(hdr_buf, "{peer_ip}");
884                debug_assert!(
885                    is_crlf_free(&hdr_buf),
886                    "a synthesised X-Forwarded-For value must be CR/LF-free"
887                );
888                request.push_block(kawa::Block::Header(kawa::Pair {
889                    key: kawa::Store::Static(b"X-Forwarded-For"),
890                    val: kawa::Store::from_vec(std::mem::take(&mut hdr_buf)),
891                }));
892                debug_assert_eq!(
893                    request.blocks.len(),
894                    blocks_before + 1,
895                    "creating X-Forwarded-For must push exactly one block"
896                );
897            }
898            if !has_forwarded {
899                let blocks_before = request.blocks.len();
900                hdr_buf.extend_from_slice(b"proto=");
901                hdr_buf.extend_from_slice(proto.as_bytes());
902                write_forwarded_for_by(&mut hdr_buf, peer_ip, peer_port, public_ip);
903                debug_assert!(
904                    is_crlf_free(&hdr_buf),
905                    "a synthesised Forwarded value must be CR/LF-free"
906                );
907                request.push_block(kawa::Block::Header(kawa::Pair {
908                    key: kawa::Store::Static(b"Forwarded"),
909                    val: kawa::Store::from_vec(std::mem::take(&mut hdr_buf)),
910                }));
911                debug_assert_eq!(
912                    request.blocks.len(),
913                    blocks_before + 1,
914                    "creating Forwarded must push exactly one block"
915                );
916            }
917
918            // Inject a proxy-generated `X-Real-IP` header carrying the
919            // peer IP (post-PROXY-v2 unwrap, so the original client IP
920            // even when the upstream presented PROXY-v2). Folded into the
921            // `if let Some(peer_addr)` arm so missing peers (raw socket,
922            // no PROXY-v2) skip the injection silently — identical to the
923            // X-Forwarded-For / Forwarded synthesis behaviour above. Any
924            // client-supplied `X-Real-IP` was either elided in the block
925            // walk (if `elide_x_real_ip` is on) or passes through; this
926            // header is appended last so order in the resulting block
927            // list is deterministic for tests.
928            if self.send_x_real_ip {
929                let blocks_before = request.blocks.len();
930                debug_assert!(
931                    hdr_buf.is_empty(),
932                    "the header scratch buffer was taken before reuse"
933                );
934                let _ = write!(hdr_buf, "{peer_ip}");
935                // The proxy-generated value is a rendered IpAddr — non-empty
936                // and CR/LF-free, so it cannot inject a second header.
937                debug_assert!(
938                    !hdr_buf.is_empty() && is_crlf_free(&hdr_buf),
939                    "the injected X-Real-IP value must be a CR/LF-free IP"
940                );
941                request.push_block(kawa::Block::Header(kawa::Pair {
942                    key: kawa::Store::Static(b"X-Real-IP"),
943                    val: kawa::Store::from_vec(std::mem::take(&mut hdr_buf)),
944                }));
945                debug_assert_eq!(
946                    request.blocks.len(),
947                    blocks_before + 1,
948                    "injecting X-Real-IP must push exactly one block"
949                );
950            }
951        }
952
953        #[cfg(feature = "opentelemetry")]
954        {
955            if !has_traceparent {
956                let val = build_traceparent(&otel.trace_id, &otel.span_id);
957                request.push_block(kawa::Block::Header(kawa::Pair {
958                    key: kawa::Store::Static(b"traceparent"),
959                    val: kawa::Store::from_slice(&val),
960                }));
961            }
962            self.otel = Some(otel);
963        }
964
965        if !has_x_port {
966            let mut port_buf = itoa::Buffer::new();
967            let port_str = port_buf.format(public_port);
968            request.push_block(kawa::Block::Header(kawa::Pair {
969                key: kawa::Store::Static(b"X-Forwarded-Port"),
970                val: kawa::Store::from_slice(port_str.as_bytes()),
971            }));
972        }
973        if !has_x_proto {
974            request.push_block(kawa::Block::Header(kawa::Pair {
975                key: kawa::Store::Static(b"X-Forwarded-Proto"),
976                val: kawa::Store::Static(proto.as_bytes()),
977            }));
978        }
979        // Create a "Connection" header in case it was not found and closing it set
980        if !has_connection && self.closing {
981            request.push_block(kawa::Block::Header(kawa::Pair {
982                key: kawa::Store::Static(b"Connection"),
983                val: kawa::Store::Static(b"close"),
984            }));
985        }
986        // Inject "X-Request-Id" derived from the request ULID when the client
987        // (or upstream LB) did not already supply one. When already present,
988        // the header is left untouched in the block list — preserving the
989        // client-supplied value end-to-end is the whole point of this header.
990        // Either way, `self.x_request_id` is populated so the access log
991        // records the exact value forwarded to the backend.
992        if has_x_request_id {
993            incr!(names::http::X_REQUEST_ID_PROPAGATED);
994        } else {
995            let value = self.id.to_string();
996            // The generated id is a ULID rendering — Crockford base-32, so
997            // CR/LF-free by construction.
998            debug_assert!(
999                is_crlf_free(value.as_bytes()),
1000                "the generated X-Request-Id must be CR/LF-free"
1001            );
1002            request.push_block(kawa::Block::Header(kawa::Pair {
1003                key: kawa::Store::Static(b"X-Request-Id"),
1004                val: kawa::Store::from_string(value.clone()),
1005            }));
1006            self.x_request_id = Some(value);
1007            incr!(names::http::X_REQUEST_ID_GENERATED);
1008        }
1009        // Either branch leaves the forwarded value recorded for the access
1010        // log so it matches exactly what the backend receives.
1011        debug_assert!(
1012            self.x_request_id.is_some(),
1013            "on_request_headers must record the forwarded X-Request-Id"
1014        );
1015
1016        // Create a custom correlation header (defaults to "Sozu-Id", can be
1017        // renamed via the `sozu_id_header` listener config knob).
1018        let blocks_before_sozu_id = request.blocks.len();
1019        request.push_block(kawa::Block::Header(kawa::Pair {
1020            key: kawa::Store::from_string(self.sozu_id_header.clone()),
1021            val: kawa::Store::from_string(self.id.to_string()),
1022        }));
1023        debug_assert_eq!(
1024            request.blocks.len(),
1025            blocks_before_sozu_id + 1,
1026            "the Sozu-Id correlation header must be pushed exactly once"
1027        );
1028
1029        // Postcondition: the whole edit only ever added or elided headers —
1030        // the block count is monotonically non-decreasing (a removed header
1031        // is elided in place, never popped), so the backend never loses a
1032        // header it should have seen.
1033        debug_assert!(
1034            request.blocks.len() >= blocks_at_entry,
1035            "header editing must never drop a block from the request"
1036        );
1037    }
1038
1039    /// Callback for response:
1040    ///
1041    /// - edit headers (connection, set-cookie, sozu-id)
1042    /// - save information:
1043    ///   - status code
1044    ///   - reason
1045    ///   - back keep-alive
1046    fn on_response_headers(&mut self, response: &mut GenericHttpStream) {
1047        // Like the request path, response editing only adds or elides — pin
1048        // the entry count so the postcondition can assert "blocks only grow".
1049        let blocks_at_entry = response.blocks.len();
1050
1051        let buf = &mut response.storage.mut_buffer();
1052
1053        // Captures the response line
1054        if let kawa::StatusLine::Response { code, reason, .. } = &response.detached.status_line {
1055            self.status = Some(*code);
1056            self.reason = reason
1057                .data_opt(buf)
1058                .and_then(|data| from_utf8(data).ok())
1059                .map(ToOwned::to_owned);
1060        }
1061
1062        if self.method == Some(Method::Head) {
1063            response.parsing_phase = kawa::ParsingPhase::Terminated;
1064        }
1065
1066        // If found:
1067        // - set Connection to "close" if closing is set
1068        // - set keep_alive_backend to false if Connection is "close"
1069        for block in &mut response.blocks {
1070            match block {
1071                kawa::Block::Header(header) if !header.is_elided() => {
1072                    let key = header.key.data(buf);
1073                    if compare_no_case(key, b"connection") {
1074                        if self.closing {
1075                            header.val = kawa::Store::Static(b"close");
1076                        } else {
1077                            let val = header.val.data(buf);
1078                            self.keep_alive_backend &= !compare_no_case(val, b"close");
1079                        }
1080                    }
1081                }
1082                _ => {}
1083            }
1084        }
1085
1086        // If the sticky_session is set and differs from the one found in the request
1087        // create a "Set-Cookie" header to update the sticky_name value
1088        if let Some(sticky_session) = &self.sticky_session
1089            && self.sticky_session != self.sticky_session_found
1090        {
1091            let blocks_before = response.blocks.len();
1092            let mut cookie_buf =
1093                Vec::with_capacity(self.sticky_name.len() + 1 + sticky_session.len() + 8);
1094            cookie_buf.extend_from_slice(self.sticky_name.as_bytes());
1095            cookie_buf.push(b'=');
1096            cookie_buf.extend_from_slice(sticky_session.as_bytes());
1097            cookie_buf.extend_from_slice(b"; Path=/");
1098            // The cookie value is `name=session; Path=/`, built from the
1099            // proxy-controlled sticky name + session id — assert it is
1100            // well-formed (contains the `=` separator, ends with the
1101            // attribute suffix) and CR/LF-free so it cannot inject an
1102            // extra Set-Cookie / split the response (CWE-113).
1103            debug_assert!(
1104                cookie_buf.contains(&b'='),
1105                "the Set-Cookie value must carry the name=value separator"
1106            );
1107            debug_assert!(
1108                cookie_buf.ends_with(b"; Path=/"),
1109                "the Set-Cookie value must end with the Path attribute"
1110            );
1111            debug_assert!(
1112                is_crlf_free(&cookie_buf),
1113                "the synthesised Set-Cookie value must be CR/LF-free (anti-injection)"
1114            );
1115            response.push_block(kawa::Block::Header(kawa::Pair {
1116                key: kawa::Store::Static(b"Set-Cookie"),
1117                val: kawa::Store::from_vec(cookie_buf),
1118            }));
1119            debug_assert_eq!(
1120                response.blocks.len(),
1121                blocks_before + 1,
1122                "synthesising Set-Cookie must push exactly one block"
1123            );
1124        }
1125
1126        // Create a custom correlation header (defaults to "Sozu-Id", can be
1127        // renamed via the `sozu_id_header` listener config knob).
1128        let blocks_before_sozu_id = response.blocks.len();
1129        response.push_block(kawa::Block::Header(kawa::Pair {
1130            key: kawa::Store::from_string(self.sozu_id_header.clone()),
1131            val: kawa::Store::from_string(self.id.to_string()),
1132        }));
1133        debug_assert_eq!(
1134            response.blocks.len(),
1135            blocks_before_sozu_id + 1,
1136            "the Sozu-Id correlation header must be pushed exactly once"
1137        );
1138
1139        // Postcondition: response editing only added or elided headers, so
1140        // the block count never decreased.
1141        debug_assert!(
1142            response.blocks.len() >= blocks_at_entry,
1143            "header editing must never drop a block from the response"
1144        );
1145    }
1146
1147    pub fn reset(&mut self) {
1148        // Snapshot the connection-scoped identity + TLS/listener fields that
1149        // reset() must NOT touch (set once at handshake, reused across every
1150        // keep-alive request). Cheap to copy — all `Copy`. Read only inside
1151        // the postcondition `debug_assert!`s below, so dead code in release.
1152        let session_id_before = self.session_id;
1153        let id_before = self.id;
1154        let strict_sni_before = self.strict_sni_binding;
1155        let elide_before = self.elide_x_real_ip;
1156        let send_before = self.send_x_real_ip;
1157        let tls_version_before = self.tls_version;
1158        let tls_cipher_before = self.tls_cipher;
1159        let tls_alpn_before = self.tls_alpn;
1160
1161        self.keep_alive_backend = true;
1162        self.keep_alive_frontend = true;
1163        self.sticky_session_found = None;
1164        self.method = None;
1165        self.authority = None;
1166        self.path = None;
1167        self.status = None;
1168        self.reason = None;
1169        self.user_agent = None;
1170        self.x_request_id = None;
1171        self.xff_chain = None;
1172        self.redirect_location = None;
1173        self.www_authenticate = None;
1174        self.original_authority = None;
1175        self.headers_response.clear();
1176        // Note: tls_server_name, tls_version, tls_cipher, tls_alpn,
1177        // strict_sni_binding, elide_x_real_ip, send_x_real_ip are
1178        // connection-scoped — set once at handshake completion and reused
1179        // across every keep-alive request, so reset() intentionally leaves
1180        // them in place.
1181
1182        // Post: request-scoped state is fully cleared (a stale value here
1183        // would leak across pipelined requests on the same connection).
1184        debug_assert!(
1185            self.method.is_none()
1186                && self.authority.is_none()
1187                && self.path.is_none()
1188                && self.status.is_none()
1189                && self.x_request_id.is_none()
1190                && self.headers_response.is_empty(),
1191            "reset() must clear all request-scoped state"
1192        );
1193        debug_assert!(
1194            self.keep_alive_backend && self.keep_alive_frontend,
1195            "reset() must restore keep-alive to its optimistic default"
1196        );
1197        // Post: connection-scoped identity + TLS/listener knobs are untouched.
1198        debug_assert_eq!(
1199            self.session_id, session_id_before,
1200            "reset() must preserve the connection session id"
1201        );
1202        debug_assert_eq!(self.id, id_before, "reset() must preserve the request id");
1203        debug_assert!(
1204            self.strict_sni_binding == strict_sni_before
1205                && self.elide_x_real_ip == elide_before
1206                && self.send_x_real_ip == send_before
1207                && self.tls_version == tls_version_before
1208                && self.tls_cipher == tls_cipher_before
1209                && self.tls_alpn == tls_alpn_before,
1210            "reset() must preserve connection-scoped TLS/listener knobs"
1211        );
1212    }
1213
1214    pub fn extract_route(&self) -> Result<(&str, &str, &Method), RetrieveClusterError> {
1215        let given_method = self.method.as_ref().ok_or(RetrieveClusterError::NoMethod)?;
1216        let given_authority = self
1217            .authority
1218            .as_deref()
1219            .ok_or(RetrieveClusterError::NoHost)?;
1220        let given_path = self.path.as_deref().ok_or(RetrieveClusterError::NoPath)?;
1221
1222        // Post: the triple is returned in (authority, path, method) order —
1223        // pin it against a future field-swap regression that would route to
1224        // the wrong cluster. (Both fields can be empty strings on the wire,
1225        // so we assert the mapping, not non-emptiness.)
1226        debug_assert!(
1227            std::ptr::eq(given_authority, self.authority.as_deref().unwrap())
1228                && std::ptr::eq(given_path, self.path.as_deref().unwrap()),
1229            "extract_route must return (authority, path, method) in order"
1230        );
1231        Ok((given_authority, given_path, given_method))
1232    }
1233
1234    pub fn get_route(&self) -> String {
1235        if let Some(method) = &self.method {
1236            if let Some(authority) = &self.authority {
1237                if let Some(path) = &self.path {
1238                    return format!("{method} {authority}{path}");
1239                }
1240                return format!("{method} {authority}");
1241            }
1242            return format!("{method}");
1243        }
1244        String::new()
1245    }
1246
1247    pub fn websocket_context(&self) -> WebSocketContext {
1248        WebSocketContext::Http {
1249            method: self.method.clone(),
1250            authority: self.authority.clone(),
1251            path: self.path.clone(),
1252            reason: self.reason.clone(),
1253            status: self.status,
1254        }
1255    }
1256
1257    pub fn log_context(&self) -> LogContext<'_> {
1258        let ctx = LogContext {
1259            session_id: self.session_id,
1260            request_id: Some(self.id),
1261            cluster_id: self.cluster_id.as_deref(),
1262            backend_id: self.backend_id.as_deref(),
1263        };
1264        // The access-log bracket `[session req cluster backend]` is keyed on
1265        // session + request id; both must always be present so the log line
1266        // is correlatable. cluster/backend are legitimately absent before
1267        // routing, so they are not asserted here.
1268        debug_assert!(
1269            ctx.request_id.is_some(),
1270            "log_context must always carry the request id for correlation"
1271        );
1272        ctx
1273    }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1280
1281    /// Helper to create a minimal HttpContext for testing.
1282    fn make_context() -> HttpContext {
1283        HttpContext::new(
1284            Ulid::generate(),
1285            Ulid::generate(),
1286            Protocol::HTTP,
1287            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
1288            Some(SocketAddr::new(
1289                IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
1290                54321,
1291            )),
1292            "SERVERID".to_owned(),
1293            "Sozu-Id".to_owned(),
1294            false,
1295            false,
1296        )
1297    }
1298
1299    // ── sozu_id_header ──────────────────────────────────────────────────
1300
1301    #[test]
1302    fn test_sozu_id_header_default_name_stored_on_context() {
1303        // The make_context helper uses the documented default "Sozu-Id" to
1304        // match the trait default on `L7ListenerHandler::get_sozu_id_header`.
1305        let ctx = make_context();
1306        assert_eq!(ctx.sozu_id_header, "Sozu-Id");
1307    }
1308
1309    #[test]
1310    fn test_sozu_id_header_custom_name_stored_on_context() {
1311        // Operator-provided rename is carried verbatim onto the HttpContext.
1312        let ctx = HttpContext::new(
1313            Ulid::generate(),
1314            Ulid::generate(),
1315            Protocol::HTTP,
1316            SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
1317            None,
1318            "SERVERID".to_owned(),
1319            "X-Edge-Id".to_owned(),
1320            false,
1321            false,
1322        );
1323        assert_eq!(ctx.sozu_id_header, "X-Edge-Id");
1324    }
1325
1326    // ── extract_route ──────────────────────────────────────────────────
1327
1328    #[test]
1329    fn test_extract_route_all_present() {
1330        let mut ctx = make_context();
1331        ctx.method = Some(Method::Get);
1332        ctx.authority = Some("example.com".to_owned());
1333        ctx.path = Some("/index.html".to_owned());
1334
1335        let (authority, path, method) = ctx.extract_route().unwrap();
1336        assert_eq!(authority, "example.com");
1337        assert_eq!(path, "/index.html");
1338        assert_eq!(method, &Method::Get);
1339    }
1340
1341    #[test]
1342    fn test_extract_route_no_method() {
1343        let mut ctx = make_context();
1344        ctx.authority = Some("example.com".to_owned());
1345        ctx.path = Some("/".to_owned());
1346
1347        let err = ctx.extract_route().unwrap_err();
1348        assert!(matches!(err, RetrieveClusterError::NoMethod));
1349    }
1350
1351    #[test]
1352    fn test_extract_route_no_host() {
1353        let mut ctx = make_context();
1354        ctx.method = Some(Method::Get);
1355        ctx.path = Some("/".to_owned());
1356
1357        let err = ctx.extract_route().unwrap_err();
1358        assert!(matches!(err, RetrieveClusterError::NoHost));
1359    }
1360
1361    #[test]
1362    fn test_extract_route_no_path() {
1363        let mut ctx = make_context();
1364        ctx.method = Some(Method::Get);
1365        ctx.authority = Some("example.com".to_owned());
1366
1367        let err = ctx.extract_route().unwrap_err();
1368        assert!(matches!(err, RetrieveClusterError::NoPath));
1369    }
1370
1371    // ── get_route ──────────────────────────────────────────────────────
1372
1373    #[test]
1374    fn test_get_route_all_present() {
1375        let mut ctx = make_context();
1376        ctx.method = Some(Method::Get);
1377        ctx.authority = Some("example.com".to_owned());
1378        ctx.path = Some("/api/v1".to_owned());
1379
1380        assert_eq!(ctx.get_route(), "GET example.com/api/v1");
1381    }
1382
1383    #[test]
1384    fn test_get_route_method_and_authority_only() {
1385        let mut ctx = make_context();
1386        ctx.method = Some(Method::Post);
1387        ctx.authority = Some("example.com".to_owned());
1388
1389        assert_eq!(ctx.get_route(), "POST example.com");
1390    }
1391
1392    #[test]
1393    fn test_get_route_method_only() {
1394        let mut ctx = make_context();
1395        ctx.method = Some(Method::Delete);
1396
1397        assert_eq!(ctx.get_route(), "DELETE");
1398    }
1399
1400    #[test]
1401    fn test_get_route_empty() {
1402        let ctx = make_context();
1403        assert_eq!(ctx.get_route(), "");
1404    }
1405
1406    // ── reset ──────────────────────────────────────────────────────────
1407
1408    #[test]
1409    fn test_reset_clears_request_response_state() {
1410        let mut ctx = make_context();
1411        ctx.keep_alive_backend = false;
1412        ctx.keep_alive_frontend = false;
1413        ctx.sticky_session_found = Some("abc123".to_owned());
1414        ctx.method = Some(Method::Post);
1415        ctx.authority = Some("example.com".to_owned());
1416        ctx.path = Some("/upload".to_owned());
1417        ctx.status = Some(200);
1418        ctx.reason = Some("OK".to_owned());
1419        ctx.user_agent = Some("curl/7.81".to_owned());
1420        ctx.x_request_id = Some("client-xrid-123".to_owned());
1421        ctx.xff_chain = Some("203.0.113.5, 198.51.100.10".to_owned());
1422        ctx.redirect_location = Some("https://example.com/".to_owned());
1423        ctx.www_authenticate = Some("Basic realm=\"sozu\"".to_owned());
1424        ctx.original_authority = Some("old.example.com".to_owned());
1425        ctx.headers_response.push(HeaderEditSnapshot {
1426            key: b"X-Cache".to_vec(),
1427            val: b"HIT".to_vec(),
1428            mode: HeaderEditMode::Append,
1429        });
1430
1431        ctx.reset();
1432
1433        assert!(ctx.keep_alive_backend);
1434        assert!(ctx.keep_alive_frontend);
1435        assert!(ctx.sticky_session_found.is_none());
1436        assert!(ctx.method.is_none());
1437        assert!(ctx.authority.is_none());
1438        assert!(ctx.path.is_none());
1439        assert!(ctx.status.is_none());
1440        assert!(ctx.reason.is_none());
1441        assert!(ctx.user_agent.is_none());
1442        assert!(ctx.x_request_id.is_none());
1443        assert!(ctx.xff_chain.is_none());
1444        // The four stash slots written by the routing layer must clear
1445        // between pipelined H1 requests; otherwise a future code path that
1446        // emits a 301 / 401 default-answer without re-routing would
1447        // inherit a stale Location / WWW-Authenticate from a prior request,
1448        // or the backend would receive a stale X-Forwarded-Host or a stale
1449        // response-side header edit.
1450        assert!(ctx.redirect_location.is_none());
1451        assert!(ctx.www_authenticate.is_none());
1452        assert!(ctx.original_authority.is_none());
1453        assert!(ctx.headers_response.is_empty());
1454    }
1455
1456    #[test]
1457    fn test_reset_preserves_tls_metadata() {
1458        // TLS metadata is connection-scoped (set once at handshake, reused
1459        // across every keep-alive request) — reset() must leave it intact
1460        // so the access log of the second request still carries it.
1461        let mut ctx = make_context();
1462        ctx.tls_server_name = Some("example.com".to_owned());
1463        ctx.tls_version = Some("TLSv1.3");
1464        ctx.tls_cipher = Some("TLS_AES_128_GCM_SHA256");
1465        ctx.tls_alpn = Some("h2");
1466        ctx.strict_sni_binding = false;
1467
1468        ctx.reset();
1469
1470        assert_eq!(ctx.tls_server_name.as_deref(), Some("example.com"));
1471        assert_eq!(ctx.tls_version, Some("TLSv1.3"));
1472        assert_eq!(ctx.tls_cipher, Some("TLS_AES_128_GCM_SHA256"));
1473        assert_eq!(ctx.tls_alpn, Some("h2"));
1474        assert!(!ctx.strict_sni_binding);
1475    }
1476
1477    #[test]
1478    fn test_reset_preserves_connection_state() {
1479        let mut ctx = make_context();
1480        ctx.closing = true;
1481        ctx.cluster_id = Some("cluster-1".to_owned());
1482        ctx.backend_id = Some("backend-1".to_owned());
1483        ctx.sticky_session = Some("session-abc".to_owned());
1484
1485        let original_id = ctx.id;
1486        let original_protocol = ctx.protocol;
1487        let original_public_address = ctx.public_address;
1488
1489        ctx.reset();
1490
1491        // Connection-level state is preserved
1492        assert!(ctx.closing);
1493        assert_eq!(ctx.cluster_id.as_deref(), Some("cluster-1"));
1494        assert_eq!(ctx.backend_id.as_deref(), Some("backend-1"));
1495        assert_eq!(ctx.sticky_session.as_deref(), Some("session-abc"));
1496        assert_eq!(ctx.id, original_id);
1497        assert_eq!(ctx.protocol, original_protocol);
1498        assert_eq!(ctx.public_address, original_public_address);
1499    }
1500
1501    // ── write_forwarded_for_by (RFC 7239 §6 IP-literal bracketing) ──────
1502    //
1503    // Locks in the IPv6 bracket+quote contract that matches HAProxy
1504    // (`_7239_print_ip6` in `src/http_ext.c`) and prevents regression to
1505    // the ambiguous `for="2001:db8::1:8080"` form flagged in issue #1254.
1506
1507    use std::net::Ipv6Addr;
1508
1509    fn render_for_by(peer: SocketAddr, public_ip: IpAddr) -> String {
1510        let mut buf = Vec::new();
1511        write_forwarded_for_by(&mut buf, peer.ip(), peer.port(), public_ip);
1512        String::from_utf8(buf).expect("forwarded fragment must be ASCII")
1513    }
1514
1515    fn render_suffix(proto: &str, peer: SocketAddr, public_ip: IpAddr) -> String {
1516        let mut buf = Vec::new();
1517        write_forwarded_suffix(&mut buf, proto, peer.ip(), peer.port(), public_ip);
1518        String::from_utf8(buf).expect("forwarded fragment must be ASCII")
1519    }
1520
1521    #[test]
1522    fn test_forwarded_ipv4_peer_ipv4_by_unchanged() {
1523        // IPv4 must keep the pre-fix wire format: unquoted `by=`, bare IPv4 in
1524        // `for=`. Sōzu emits `for=` quoted unconditionally (HAProxy emits it
1525        // quoted only when a port is attached; we always attach a port).
1526        let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 54321);
1527        let public_ip = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
1528        assert_eq!(
1529            render_for_by(peer, public_ip),
1530            r#";for="203.0.113.7:54321";by=198.51.100.1"#,
1531        );
1532    }
1533
1534    #[test]
1535    fn test_forwarded_ipv6_peer_brackets_disambiguate_port() {
1536        // Regression for issue #1254: without brackets, `for="2001:db8::1:8080"`
1537        // is ambiguous (could parse as `::1` + port `8080` or `:1:8080` literal).
1538        let peer = SocketAddr::new(IpAddr::V6("2001:db8::1".parse::<Ipv6Addr>().unwrap()), 8080);
1539        let public_ip = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
1540        assert_eq!(
1541            render_for_by(peer, public_ip),
1542            r#";for="[2001:db8::1]:8080";by=198.51.100.1"#,
1543        );
1544    }
1545
1546    #[test]
1547    fn test_forwarded_ipv6_public_address_bracketed_and_quoted() {
1548        // `by=` carries no port but RFC 7239 §6 still requires the IP-literal
1549        // brackets for IPv6, and the value must be quoted because the literal
1550        // contains `:`.
1551        let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 54321);
1552        let public_ip = IpAddr::V6("2001:db8::2".parse::<Ipv6Addr>().unwrap());
1553        assert_eq!(
1554            render_for_by(peer, public_ip),
1555            r#";for="203.0.113.7:54321";by="[2001:db8::2]""#,
1556        );
1557    }
1558
1559    #[test]
1560    fn test_forwarded_ipv6_peer_and_public_both_bracketed() {
1561        let peer = SocketAddr::new(IpAddr::V6("2001:db8::1".parse::<Ipv6Addr>().unwrap()), 8080);
1562        let public_ip = IpAddr::V6("2001:db8::2".parse::<Ipv6Addr>().unwrap());
1563        assert_eq!(
1564            render_for_by(peer, public_ip),
1565            r#";for="[2001:db8::1]:8080";by="[2001:db8::2]""#,
1566        );
1567    }
1568
1569    #[test]
1570    fn test_forwarded_suffix_prepends_proto_and_brackets_ipv6() {
1571        // The suffix form is what we append when an inbound `Forwarded`
1572        // header already exists. Same bracketing contract must hold.
1573        let peer = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 443);
1574        let public_ip = IpAddr::V6("fe80::1".parse::<Ipv6Addr>().unwrap());
1575        assert_eq!(
1576            render_suffix("https", peer, public_ip),
1577            r#", proto=https;for="[::1]:443";by="[fe80::1]""#,
1578        );
1579    }
1580
1581    // ── traceparent / opentelemetry helpers ─────────────────────────────
1582
1583    #[cfg(feature = "opentelemetry")]
1584    mod otel {
1585        use super::super::*;
1586
1587        #[test]
1588        fn test_parse_hex_valid() {
1589            let (val, rest) = parse_hex::<4>(b"abcd1234").unwrap();
1590            assert_eq!(&val, b"abcd");
1591            assert_eq!(rest, b"1234");
1592        }
1593
1594        #[test]
1595        fn test_parse_hex_exact_length() {
1596            let (val, rest) = parse_hex::<8>(b"01234567").unwrap();
1597            assert_eq!(&val, b"01234567");
1598            assert!(rest.is_empty());
1599        }
1600
1601        #[test]
1602        fn test_parse_hex_too_short() {
1603            assert!(parse_hex::<4>(b"ab").is_none());
1604        }
1605
1606        #[test]
1607        fn test_parse_hex_rejects_non_hex() {
1608            assert!(parse_hex::<4>(b"ghij").is_none());
1609        }
1610
1611        #[test]
1612        fn test_parse_hex_rejects_uppercase_is_ok() {
1613            // Uppercase hex digits are valid
1614            let (val, _) = parse_hex::<4>(b"ABCD").unwrap();
1615            assert_eq!(&val, b"ABCD");
1616        }
1617
1618        #[test]
1619        fn test_skip_separator_valid() {
1620            let rest = skip_separator(b"-hello").unwrap();
1621            assert_eq!(rest, b"hello");
1622        }
1623
1624        #[test]
1625        fn test_skip_separator_wrong_char() {
1626            assert!(skip_separator(b"+hello").is_none());
1627        }
1628
1629        #[test]
1630        fn test_skip_separator_empty() {
1631            assert!(skip_separator(b"").is_none());
1632        }
1633
1634        #[test]
1635        fn test_build_traceparent_format() {
1636            let trace_id: [u8; 32] = *b"4bf92f3577b34da6a3ce929d0e0e4736";
1637            let parent_id: [u8; 16] = *b"00f067aa0ba902b7";
1638
1639            let result = build_traceparent(&trace_id, &parent_id);
1640            assert_eq!(
1641                &result,
1642                b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
1643            );
1644        }
1645
1646        #[test]
1647        fn test_build_traceparent_length() {
1648            let trace_id = [b'a'; 32];
1649            let parent_id = [b'b'; 16];
1650            let result = build_traceparent(&trace_id, &parent_id);
1651            // Format: "00-" (3) + trace_id (32) + "-" (1) + parent_id (16) + "-01" (3) = 55
1652            assert_eq!(result.len(), 55);
1653        }
1654
1655        #[test]
1656        fn test_parse_traceparent_valid() {
1657            let input = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
1658            let store = kawa::Store::Static(input);
1659            let (trace_id, parent_id) = parse_traceparent(&store, input).unwrap();
1660            assert_eq!(&trace_id, b"4bf92f3577b34da6a3ce929d0e0e4736");
1661            assert_eq!(&parent_id, b"00f067aa0ba902b7");
1662        }
1663
1664        #[test]
1665        fn test_parse_traceparent_sampled_flag_zero() {
1666            let input = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00";
1667            let store = kawa::Store::Static(input);
1668            let result = parse_traceparent(&store, input);
1669            assert!(result.is_some());
1670        }
1671
1672        #[test]
1673        fn test_parse_traceparent_wrong_version() {
1674            let input = b"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
1675            let store = kawa::Store::Static(input);
1676            assert!(parse_traceparent(&store, input).is_none());
1677        }
1678
1679        #[test]
1680        fn test_parse_traceparent_too_short() {
1681            let input = b"00-4bf9";
1682            let store = kawa::Store::Static(input);
1683            assert!(parse_traceparent(&store, input).is_none());
1684        }
1685
1686        #[test]
1687        fn test_parse_traceparent_trailing_data() {
1688            // Extra characters after the trace-flags should cause rejection
1689            let input = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra";
1690            let store = kawa::Store::Static(input);
1691            assert!(parse_traceparent(&store, input).is_none());
1692        }
1693
1694        #[test]
1695        fn test_parse_traceparent_missing_separator() {
1696            let input = b"004bf92f3577b34da6a3ce929d0e0e473600f067aa0ba902b701";
1697            let store = kawa::Store::Static(input);
1698            assert!(parse_traceparent(&store, input).is_none());
1699        }
1700
1701        #[test]
1702        fn test_parse_build_roundtrip() {
1703            let trace_id: [u8; 32] = *b"4bf92f3577b34da6a3ce929d0e0e4736";
1704            let parent_id: [u8; 16] = *b"00f067aa0ba902b7";
1705
1706            // Build a traceparent from known IDs
1707            let built = build_traceparent(&trace_id, &parent_id);
1708
1709            // Verify that the built value matches the expected static string,
1710            // then parse that static string back to confirm roundtrip.
1711            let expected: &[u8] = b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
1712            assert_eq!(&built[..], expected);
1713
1714            let store = kawa::Store::Static(expected);
1715            let (parsed_trace_id, parsed_parent_id) = parse_traceparent(&store, expected).unwrap();
1716
1717            assert_eq!(parsed_trace_id, trace_id);
1718            assert_eq!(parsed_parent_id, parent_id);
1719        }
1720    }
1721}