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}