Skip to main content

apimock_server/
trace.rs

1//! Live match-trace channel — RFC 006 (in-process) + RFC 009 (transport).
2//!
3//! # Architecture
4//!
5//! ```text
6//!  HTTP handler ──► TraceEmitter::emit()
7//!                         │
8//!              tokio::sync::broadcast (bounded, 1024)
9//!                         │
10//!           ┌─────────────┴──────────────┐
11//!      in-process                  TraceTransport::accept_loop
12//!      subscriber                  (UDS on Unix, TCP fallback)
13//!                                        │
14//!                                  up to 4 GUI connections
15//!                                  (newline-delimited JSON)
16//! ```
17//!
18//! # Transport variants
19//!
20//! | `TraceTransportConfig` | Platform | Notes |
21//! |---|---|---|
22//! | `Uds { path }` | Unix/macOS | Default when available |
23//! | `Tcp { addr }` | All | Portable fallback; `addr = "127.0.0.1:0"` assigns ephemeral port |
24//! | `Disabled` | All | No out-of-process forwarding (default) |
25//!
26//! # Back-pressure
27//!
28//! The broadcast channel is bounded by [`TRACE_CHANNEL_CAPACITY`]. When
29//! the channel is full, `emit` drops the event and increments an internal
30//! counter; the count is reported as `dropped_count` on the next event.
31//!
32//! Slow out-of-process subscribers receive a `RecvError::Lagged` from the
33//! broadcast channel; the gap is reported in the next JSON line via
34//! `dropped_count`.
35//!
36//! # Subscriber cap
37//!
38//! At most [`MAX_SUBSCRIBERS`] out-of-process connections are accepted.
39//! A fifth connection receives `{"error":"max_subscribers_reached"}` and
40//! is then closed.
41
42use std::sync::{
43    Arc,
44    atomic::{AtomicU32, AtomicUsize, Ordering},
45};
46use std::time::{Duration, SystemTime, UNIX_EPOCH};
47
48use serde::Serialize;
49use tokio::io::AsyncWriteExt;
50use tokio::sync::broadcast;
51
52/// Capacity of the broadcast channel (events).
53pub const TRACE_CHANNEL_CAPACITY: usize = 1_024;
54/// Maximum concurrent out-of-process subscriber connections.
55pub const MAX_SUBSCRIBERS: usize = 4;
56
57// ── Event schema ──────────────────────────────────────────────────────
58
59/// A single request/response trace event.
60#[derive(Clone, Debug, Serialize)]
61pub struct MatchTraceEvent {
62    /// Monotonically increasing event counter within this server run.
63    pub event_id: u64,
64    /// Schema version — bumped on breaking changes.
65    pub schema_version: u8,
66    /// Unix timestamp (milliseconds) when the request was received.
67    pub received_at_ms: u64,
68    /// Processing time in milliseconds.
69    pub duration_ms: u32,
70    /// Key fields from the incoming request.
71    pub request: RequestSummary,
72    /// What the server decided to do with the request.
73    pub outcome: Outcome,
74    /// Events dropped since the last successfully delivered event.
75    pub dropped_count: u32,
76}
77
78/// Key fields from the incoming HTTP request.
79#[derive(Clone, Debug, Serialize)]
80#[non_exhaustive]
81pub struct RequestSummary {
82    pub method: String,
83    pub url_path: String,
84    /// Request headers, redacted per `TraceConfig`'s policy at capture
85    /// (RFC 040) — not otherwise filtered by name. A redacted header
86    /// keeps its name and carries [`REDACTED_HEADER_VALUE`] instead of
87    /// its real value, so its presence stays visible.
88    pub headers: Vec<(String, String)>,
89    /// Captured JSON body (RFC 023). Present only when `TraceConfig::capture_body`
90    /// is `true` and the request body is valid JSON within the size cap.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub body_json: Option<serde_json::Value>,
93    /// `true` when the body was omitted because it exceeded `max_body_bytes`.
94    #[serde(skip_serializing_if = "std::ops::Not::not")]
95    pub body_truncated: bool,
96    /// Byte length of the request body, if one arrived (RFC 050) —
97    /// presence and size only, **never content**: no bytes, no snippet,
98    /// no preview. Populated for every body, JSON included — required
99    /// 2026-08-17 by review of this RFC, which found the original,
100    /// JSON-excluding version left the *common* case (a JSON body with
101    /// `capture_body` at its default `false`) still indistinguishable
102    /// from no body at all, the exact ambiguity this RFC exists to
103    /// close. So the three states this field distinguishes, together
104    /// with `body_json`, are: both absent (no body); `body_len` present
105    /// and `body_json` present (body present, JSON captured); `body_len`
106    /// present and `body_json` absent (body present, not captured —
107    /// non-JSON, capture disabled, or over `max_body_bytes`; the last of
108    /// those is further distinguished by `body_truncated`).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub body_len: Option<usize>,
111}
112
113impl RequestSummary {
114    /// Construct from a live request's headers, applying `config`'s
115    /// redaction policy (RFC 040). This is the only place header
116    /// redaction happens — do not build `RequestSummary` from live
117    /// request headers any other way; a future formatter or display
118    /// path must not need to know about redaction at all.
119    ///
120    /// `body_len` should be the source request's own `ParsedRequest.body_len`
121    /// (RFC 050) — pass it through unconditionally, JSON body or not;
122    /// `enrich_with_body` populates `body_json` separately for the JSON
123    /// case, and the two fields together carry the distinction.
124    pub fn new(
125        method: String,
126        url_path: String,
127        headers: Vec<(String, String)>,
128        body_len: Option<usize>,
129        config: &TraceConfig,
130    ) -> Self {
131        Self {
132            method,
133            url_path,
134            headers: config.redact_headers(headers),
135            body_json: None,
136            body_truncated: false,
137            body_len,
138        }
139    }
140}
141
142/// Placeholder value substituted for a redacted header (RFC 040 Goal 4).
143/// The header name is kept so a consumer can tell "redacted" from
144/// "the request never sent this header" — only the value differs.
145pub const REDACTED_HEADER_VALUE: &str = "[redacted]";
146
147/// Built-in denylist of well-known credential-bearing request headers,
148/// applied by default (RFC 040 Q1). Compared case-insensitively.
149///
150/// `set-cookie` is a *response* header and can never appear on a
151/// request, so it never matches here — kept anyway because RFC 040's
152/// own example list included it; dropping it silently would read as
153/// deliberately narrowing the list rather than the request-only scope
154/// this RFC already states.
155pub const DEFAULT_HEADER_DENYLIST: &[&str] = &[
156    "authorization",
157    "cookie",
158    "set-cookie",
159    "proxy-authorization",
160    "x-api-key",
161];
162
163/// Which request headers get redacted before a trace event is built
164/// (RFC 040 Q1).
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub enum HeaderRedactionMode {
167    /// Redact headers named in `TraceConfig::header_denylist`; capture
168    /// everything else. Fails open on an unanticipated header name —
169    /// accepted deliberately, see RFC 040's Risks table.
170    Denylist,
171    /// Capture only headers named in `TraceConfig::header_allowlist`;
172    /// redact everything else. Fails closed.
173    Allowlist,
174}
175
176/// Trace-channel behaviour configuration (RFC 023, extended by RFC 040).
177///
178/// Configurable today only at this Rust level — the trace channel has
179/// no config-file or CLI surface yet (RFC 040's own Motivation notes
180/// this; RFC 023's `[trace]` TOML section was never wired to this
181/// struct). `TraceEmitter::new()` always uses `TraceConfig::default()`.
182#[derive(Clone, Debug)]
183#[non_exhaustive]
184pub struct TraceConfig {
185    /// Capture the JSON request body in each event. Default: `false`.
186    pub capture_body: bool,
187    /// Maximum serialised body size in bytes. Bodies larger than this
188    /// are omitted and `body_truncated = true` is set. Default: 8 192.
189    pub max_body_bytes: usize,
190    /// Denylist or allowlist by default. Default: `Denylist`.
191    pub header_redaction: HeaderRedactionMode,
192    /// Header names redacted when `header_redaction` is `Denylist`.
193    /// Compared case-insensitively. Default: [`DEFAULT_HEADER_DENYLIST`].
194    pub header_denylist: Vec<String>,
195    /// Header names captured when `header_redaction` is `Allowlist`;
196    /// every other header is redacted. Compared case-insensitively.
197    /// Default: empty, i.e. allowlist mode redacts everything until
198    /// configured — the safe direction for a fail-closed mode.
199    pub header_allowlist: Vec<String>,
200}
201
202impl Default for TraceConfig {
203    fn default() -> Self {
204        Self {
205            capture_body: false,
206            max_body_bytes: 8_192,
207            header_redaction: HeaderRedactionMode::Denylist,
208            header_denylist: DEFAULT_HEADER_DENYLIST
209                .iter()
210                .map(|s| s.to_string())
211                .collect(),
212            header_allowlist: Vec::new(),
213        }
214    }
215}
216
217impl TraceConfig {
218    /// Whether `name` is redacted under this config's policy
219    /// (case-insensitive). The single definition behind both places a
220    /// request header can leave the process: the trace channel
221    /// (`redact_headers`, below) and verbose console logging
222    /// (`capture_in_log` in `parsed_request.rs`, RFC 051) — one policy,
223    /// shared by reference, not copied.
224    pub(crate) fn is_header_redacted(&self, name: &str) -> bool {
225        match self.header_redaction {
226            HeaderRedactionMode::Denylist => self
227                .header_denylist
228                .iter()
229                .any(|denied| denied.eq_ignore_ascii_case(name)),
230            HeaderRedactionMode::Allowlist => !self
231                .header_allowlist
232                .iter()
233                .any(|allowed| allowed.eq_ignore_ascii_case(name)),
234        }
235    }
236
237    /// Redact header values per this config's policy. Names and order
238    /// are preserved; only a matched entry's value is replaced with
239    /// [`REDACTED_HEADER_VALUE`] (RFC 040 Goal 4 — marked, not omitted).
240    fn redact_headers(&self, headers: Vec<(String, String)>) -> Vec<(String, String)> {
241        headers
242            .into_iter()
243            .map(|(name, value)| {
244                if self.is_header_redacted(&name) {
245                    (name, REDACTED_HEADER_VALUE.to_string())
246                } else {
247                    (name, value)
248                }
249            })
250            .collect()
251    }
252}
253
254/// What the server decided to do with the request.
255#[derive(Clone, Debug, Serialize)]
256#[serde(tag = "type", rename_all = "snake_case")]
257pub enum Outcome {
258    Matched {
259        rule_set_index: usize,
260        rule_index: usize,
261    },
262    Fallback {
263        file_path: String,
264        status: u16,
265    },
266    Miss {
267        status: u16,
268    },
269    Error {
270        kind: String,
271        message: String,
272    },
273}
274
275// ── Emitter ───────────────────────────────────────────────────────────
276
277/// Shared handle to the trace broadcast channel.
278///
279/// Clone freely — each clone refers to the same underlying channel.
280#[derive(Clone)]
281pub struct TraceEmitter {
282    sender: broadcast::Sender<MatchTraceEvent>,
283    event_counter: Arc<AtomicU32>,
284    dropped_counter: Arc<AtomicU32>,
285    /// Behaviour settings (body capture, etc.).
286    pub config: Arc<TraceConfig>,
287}
288
289impl TraceEmitter {
290    pub fn new() -> Self {
291        Self::with_config(TraceConfig::default())
292    }
293
294    pub fn with_config(config: TraceConfig) -> Self {
295        let (sender, _) = broadcast::channel(TRACE_CHANNEL_CAPACITY);
296        Self {
297            sender,
298            event_counter: Arc::new(AtomicU32::new(0)),
299            dropped_counter: Arc::new(AtomicU32::new(0)),
300            config: Arc::new(config),
301        }
302    }
303
304    /// Subscribe to the event stream (in-process).
305    pub fn subscribe(&self) -> broadcast::Receiver<MatchTraceEvent> {
306        self.sender.subscribe()
307    }
308
309    /// Attach body JSON to a `RequestSummary` according to this emitter's
310    /// `TraceConfig`. Call before `emit` when the request body is available.
311    pub fn enrich_with_body(
312        &self,
313        summary: &mut RequestSummary,
314        body_json: Option<&serde_json::Value>,
315    ) {
316        if !self.config.capture_body {
317            return;
318        }
319        match body_json {
320            None => {} // non-JSON or empty body — leave body_json = None
321            Some(v) => {
322                // Check serialised size against the cap.
323                match serde_json::to_string(v) {
324                    Ok(s) if s.len() <= self.config.max_body_bytes => {
325                        summary.body_json = Some(v.clone());
326                    }
327                    Ok(_) => {
328                        summary.body_truncated = true;
329                    }
330                    Err(_) => {} // shouldn't happen for a valid Value
331                }
332            }
333        }
334    }
335
336    /// Emit one event.  If the channel is full, the event is dropped and
337    /// the internal drop counter incremented.
338    pub fn emit(
339        &self,
340        received_at_ms: u64,
341        duration_ms: u32,
342        request: RequestSummary,
343        outcome: Outcome,
344    ) {
345        let event_id = self.event_counter.fetch_add(1, Ordering::Relaxed) as u64;
346        let dropped_count = self.dropped_counter.swap(0, Ordering::Relaxed);
347
348        let event = MatchTraceEvent {
349            event_id,
350            schema_version: 1,
351            received_at_ms,
352            duration_ms,
353            request,
354            outcome,
355            dropped_count,
356        };
357
358        if self.sender.send(event).is_err() {
359            self.dropped_counter.fetch_add(1, Ordering::Relaxed);
360        }
361    }
362
363    /// Returns `true` iff at least one receiver is currently active.
364    pub fn has_subscribers(&self) -> bool {
365        self.sender.receiver_count() > 0
366    }
367}
368
369impl Default for TraceEmitter {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375// ── Transport configuration ───────────────────────────────────────────
376
377/// Configuration for the out-of-process transport layer.
378#[derive(Clone, Debug, Default)]
379pub enum TraceTransportConfig {
380    /// Unix-domain socket at the given path (Unix/macOS only).
381    #[cfg(unix)]
382    Uds { path: String },
383    /// TCP loopback socket (portable fallback).
384    Tcp { addr: String },
385    /// No out-of-process forwarding.
386    #[default]
387    Disabled,
388}
389
390// ── Transport implementation ──────────────────────────────────────────
391
392pub struct TraceTransport;
393
394impl TraceTransport {
395    /// Start accepting out-of-process subscriber connections and forwarding
396    /// events as newline-delimited JSON.
397    ///
398    /// This future runs forever (until the process exits or the socket
399    /// errors fatally). Spawn it with `tokio::spawn`.
400    ///
401    /// # Subscriber cap
402    ///
403    /// At most [`MAX_SUBSCRIBERS`] connections are served simultaneously.
404    /// Connection #`MAX_SUBSCRIBERS + 1` receives a JSON error line and
405    /// is closed.
406    pub async fn accept_loop(config: TraceTransportConfig, emitter: TraceEmitter) {
407        match config {
408            #[cfg(unix)]
409            TraceTransportConfig::Uds { path } => Self::uds_accept_loop(path, emitter).await,
410            TraceTransportConfig::Tcp { addr } => Self::tcp_accept_loop(addr, emitter).await,
411            TraceTransportConfig::Disabled => {
412                // No-op — transport disabled; in-process channel still works.
413            }
414        }
415    }
416
417    // ── TCP accept loop ───────────────────────────────────────────────
418
419    async fn tcp_accept_loop(addr: String, emitter: TraceEmitter) {
420        let listener = match tokio::net::TcpListener::bind(&addr).await {
421            Ok(l) => {
422                let bound = l
423                    .local_addr()
424                    .map(|a| a.to_string())
425                    .unwrap_or_else(|_| addr.clone());
426                log::info!("trace transport: TCP listening on {}", bound);
427                l
428            }
429            Err(e) => {
430                log::error!("trace transport: failed to bind TCP {}: {}", addr, e);
431                return;
432            }
433        };
434
435        let active = Arc::new(AtomicUsize::new(0));
436        loop {
437            match listener.accept().await {
438                Ok((stream, peer)) => {
439                    let count = active.fetch_add(1, Ordering::Relaxed) + 1;
440                    if count > MAX_SUBSCRIBERS {
441                        active.fetch_sub(1, Ordering::Relaxed);
442                        let active_clone = active.clone();
443                        tokio::spawn(async move {
444                            let (_, mut writer) = tokio::io::split(stream);
445                            let _ = writer
446                                .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
447                                .await;
448                            drop(active_clone);
449                        });
450                        continue;
451                    }
452                    log::debug!("trace: TCP subscriber connected from {}", peer);
453                    let rx = emitter.subscribe();
454                    let active_clone = active.clone();
455                    tokio::spawn(async move {
456                        let (_, writer) = tokio::io::split(stream);
457                        Self::forward_events(writer, rx).await;
458                        active_clone.fetch_sub(1, Ordering::Relaxed);
459                        log::debug!("trace: TCP subscriber {} disconnected", peer);
460                    });
461                }
462                Err(e) => {
463                    log::error!("trace: TCP accept error: {}", e);
464                    tokio::time::sleep(Duration::from_millis(100)).await;
465                }
466            }
467        }
468    }
469
470    // ── UDS accept loop (Unix only) ───────────────────────────────────
471
472    #[cfg(unix)]
473    async fn uds_accept_loop(path: String, emitter: TraceEmitter) {
474        // Remove stale socket file from a previous run.
475        let _ = std::fs::remove_file(&path);
476
477        let listener = match tokio::net::UnixListener::bind(&path) {
478            Ok(l) => {
479                log::info!("trace transport: UDS listening at {}", path);
480                l
481            }
482            Err(e) => {
483                log::error!("trace transport: failed to bind UDS {}: {}", path, e);
484                return;
485            }
486        };
487
488        let active = Arc::new(AtomicUsize::new(0));
489        loop {
490            match listener.accept().await {
491                Ok((stream, _)) => {
492                    let count = active.fetch_add(1, Ordering::Relaxed) + 1;
493                    if count > MAX_SUBSCRIBERS {
494                        active.fetch_sub(1, Ordering::Relaxed);
495                        tokio::spawn(async move {
496                            let (_, mut writer) = tokio::io::split(stream);
497                            let _ = writer
498                                .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
499                                .await;
500                        });
501                        continue;
502                    }
503                    log::debug!("trace: UDS subscriber connected");
504                    let rx = emitter.subscribe();
505                    let active_clone = active.clone();
506                    tokio::spawn(async move {
507                        let (_, writer) = tokio::io::split(stream);
508                        Self::forward_events(writer, rx).await;
509                        active_clone.fetch_sub(1, Ordering::Relaxed);
510                        log::debug!("trace: UDS subscriber disconnected");
511                    });
512                }
513                Err(e) => {
514                    log::error!("trace: UDS accept error: {}", e);
515                    tokio::time::sleep(Duration::from_millis(100)).await;
516                }
517            }
518        }
519    }
520
521    // ── Event forwarder (shared by UDS and TCP) ───────────────────────
522
523    /// Read events from `rx` and write each as a JSON line to `writer`
524    /// until the connection closes or the channel is closed.
525    async fn forward_events<W>(mut writer: W, mut rx: broadcast::Receiver<MatchTraceEvent>)
526    where
527        W: tokio::io::AsyncWrite + Unpin,
528    {
529        loop {
530            let event = match rx.recv().await {
531                Ok(e) => e,
532                Err(broadcast::error::RecvError::Lagged(n)) => {
533                    // Receiver was too slow; `n` events were dropped.
534                    // The next event will carry `dropped_count` so the
535                    // subscriber can detect the gap. Continue.
536                    log::debug!("trace: subscriber lagged, {} events dropped", n);
537                    continue;
538                }
539                Err(broadcast::error::RecvError::Closed) => break,
540            };
541
542            let mut line = match serde_json::to_string(&event) {
543                Ok(s) => s,
544                Err(e) => {
545                    log::error!("trace: serialise error: {}", e);
546                    continue;
547                }
548            };
549            line.push('\n');
550
551            if writer.write_all(line.as_bytes()).await.is_err() {
552                break; // subscriber disconnected
553            }
554        }
555    }
556}
557
558// ── Timestamp helper ──────────────────────────────────────────────────
559
560/// Current Unix time in milliseconds.
561pub fn now_ms() -> u64 {
562    SystemTime::now()
563        .duration_since(UNIX_EPOCH)
564        .unwrap_or(Duration::ZERO)
565        .as_millis() as u64
566}
567
568// ── Tests ─────────────────────────────────────────────────────────────
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[tokio::test]
575    async fn emit_received_by_subscriber() {
576        let emitter = TraceEmitter::new();
577        let mut rx = emitter.subscribe();
578
579        emitter.emit(
580            1_000_000,
581            5,
582            RequestSummary {
583                method: "GET".into(),
584                url_path: "/api/test".into(),
585                headers: vec![],
586                body_json: None,
587                body_truncated: false,
588                body_len: None,
589            },
590            Outcome::Miss { status: 404 },
591        );
592
593        let event = rx.try_recv().expect("event in channel");
594        assert_eq!(event.event_id, 0);
595        assert_eq!(event.schema_version, 1);
596        assert_eq!(event.request.method, "GET");
597        assert_eq!(event.duration_ms, 5);
598        assert_eq!(event.dropped_count, 0);
599        assert!(matches!(event.outcome, Outcome::Miss { status: 404 }));
600    }
601
602    #[tokio::test]
603    async fn emit_no_subscriber_increments_dropped() {
604        let emitter = TraceEmitter::new();
605        emitter.emit(
606            0,
607            0,
608            RequestSummary {
609                method: "GET".into(),
610                url_path: "/".into(),
611                headers: vec![],
612                body_json: None,
613                body_truncated: false,
614                body_len: None,
615            },
616            Outcome::Miss { status: 404 },
617        );
618        let mut rx = emitter.subscribe();
619        emitter.emit(
620            0,
621            0,
622            RequestSummary {
623                method: "GET".into(),
624                url_path: "/".into(),
625                headers: vec![],
626                body_json: None,
627                body_truncated: false,
628                body_len: None,
629            },
630            Outcome::Miss { status: 200 },
631        );
632        let event = rx.try_recv().expect("second event visible");
633        assert_eq!(
634            event.dropped_count, 1,
635            "first event should be counted dropped"
636        );
637    }
638
639    #[test]
640    fn has_subscribers_reflects_state() {
641        let emitter = TraceEmitter::new();
642        assert!(!emitter.has_subscribers());
643        let _rx = emitter.subscribe();
644        assert!(emitter.has_subscribers());
645    }
646
647    #[tokio::test]
648    async fn outcome_serialises_correctly() {
649        let event = MatchTraceEvent {
650            event_id: 7,
651            schema_version: 1,
652            received_at_ms: 0,
653            duration_ms: 0,
654            request: RequestSummary {
655                method: "POST".into(),
656                url_path: "/x".into(),
657                headers: vec![],
658                body_json: None,
659                body_truncated: false,
660                body_len: None,
661            },
662            outcome: Outcome::Matched {
663                rule_set_index: 0,
664                rule_index: 2,
665            },
666            dropped_count: 0,
667        };
668        let json = serde_json::to_string(&event).unwrap();
669        assert!(json.contains("\"type\":\"matched\""));
670        assert!(json.contains("\"rule_index\":2"));
671        assert!(json.contains("\"schema_version\":1"));
672    }
673
674    #[tokio::test]
675    async fn tcp_transport_delivers_events() {
676        let emitter = TraceEmitter::new();
677        let emitter_clone = emitter.clone();
678
679        // We need to know the actual bound port before connecting.
680        // Bind the listener ourselves to capture the address, then hand
681        // the address to the transport accept loop via a channel.
682        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
683        let bound_addr = listener.local_addr().unwrap();
684
685        // Spawn a simplified accept loop that uses our pre-bound listener.
686        tokio::spawn(async move {
687            let (stream, _) = listener.accept().await.unwrap();
688            let rx = emitter_clone.subscribe();
689            let (_, writer) = tokio::io::split(stream);
690            TraceTransport::forward_events(writer, rx).await;
691        });
692
693        // Connect a subscriber.
694        let mut client = tokio::net::TcpStream::connect(bound_addr).await.unwrap();
695
696        // Give the subscriber task a moment to subscribe before emitting.
697        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
698
699        emitter.emit(
700            42,
701            3,
702            RequestSummary {
703                method: "GET".into(),
704                url_path: "/ping".into(),
705                headers: vec![],
706                body_json: None,
707                body_truncated: false,
708                body_len: None,
709            },
710            Outcome::Miss { status: 404 },
711        );
712
713        // Read one JSON line from the TCP connection.
714        use tokio::io::AsyncBufReadExt;
715        let mut reader = tokio::io::BufReader::new(&mut client);
716        let mut line = String::new();
717        tokio::time::timeout(
718            std::time::Duration::from_secs(2),
719            reader.read_line(&mut line),
720        )
721        .await
722        .expect("timeout")
723        .expect("read ok");
724
725        let value: serde_json::Value = serde_json::from_str(line.trim()).expect("valid JSON");
726        assert_eq!(value["request"]["url_path"], "/ping");
727        assert_eq!(value["outcome"]["type"], "miss");
728        assert_eq!(value["schema_version"], 1);
729    }
730
731    // ── RFC 023: body capture tests ───────────────────────────────────
732
733    #[test]
734    fn enrich_with_body_disabled_by_default() {
735        let emitter = TraceEmitter::new(); // capture_body = false by default
736        let mut summary = RequestSummary {
737            method: "POST".into(),
738            url_path: "/".into(),
739            headers: vec![],
740            body_json: None,
741            body_truncated: false,
742            body_len: None,
743        };
744        let body = serde_json::json!({"action": "create"});
745        emitter.enrich_with_body(&mut summary, Some(&body));
746        assert!(
747            summary.body_json.is_none(),
748            "body should not be captured when disabled"
749        );
750        assert!(!summary.body_truncated);
751    }
752
753    #[test]
754    fn enrich_with_body_enabled_captures_small_body() {
755        let emitter = TraceEmitter::with_config(TraceConfig {
756            capture_body: true,
757            max_body_bytes: 8_192,
758            ..Default::default()
759        });
760        let mut summary = RequestSummary {
761            method: "POST".into(),
762            url_path: "/".into(),
763            headers: vec![],
764            body_json: None,
765            body_truncated: false,
766            body_len: None,
767        };
768        let body = serde_json::json!({"action": "create", "user_id": 42});
769        emitter.enrich_with_body(&mut summary, Some(&body));
770        assert!(
771            summary.body_json.is_some(),
772            "body should be captured when enabled"
773        );
774        assert_eq!(summary.body_json.unwrap()["action"], "create");
775        assert!(!summary.body_truncated);
776    }
777
778    #[test]
779    fn enrich_with_body_truncates_oversized_body() {
780        let emitter = TraceEmitter::with_config(TraceConfig {
781            capture_body: true,
782            max_body_bytes: 10,
783            ..Default::default()
784        });
785        let mut summary = RequestSummary {
786            method: "POST".into(),
787            url_path: "/".into(),
788            headers: vec![],
789            body_json: None,
790            body_truncated: false,
791            body_len: None,
792        };
793        let body = serde_json::json!({"data": "this is longer than 10 bytes"});
794        emitter.enrich_with_body(&mut summary, Some(&body));
795        assert!(
796            summary.body_json.is_none(),
797            "oversized body should be omitted"
798        );
799        assert!(summary.body_truncated, "body_truncated flag should be set");
800    }
801
802    #[test]
803    fn request_summary_body_json_not_in_serialised_output_when_none() {
804        let summary = RequestSummary {
805            method: "GET".into(),
806            url_path: "/api".into(),
807            headers: vec![],
808            body_json: None,
809            body_truncated: false,
810            body_len: None,
811        };
812        let json = serde_json::to_string(&summary).unwrap();
813        assert!(
814            !json.contains("body_json"),
815            "absent body_json must be skipped"
816        );
817        assert!(
818            !json.contains("body_truncated"),
819            "false body_truncated must be skipped"
820        );
821    }
822
823    // ── RFC 040: header redaction ──────────────────────────────────────
824
825    fn headers_with_credentials() -> Vec<(String, String)> {
826        vec![
827            ("authorization".into(), "Bearer secret-token".into()),
828            ("cookie".into(), "session=abc123".into()),
829            ("x-api-key".into(), "sk-live-very-secret".into()),
830            ("content-type".into(), "application/json".into()),
831        ]
832    }
833
834    /// RFC 040 evidence requirement: with no trace configuration at all —
835    /// `TraceConfig::default()` — none of the three credential values
836    /// appear in the *serialised* event.
837    #[test]
838    fn default_config_redacts_credential_headers_in_serialised_output() {
839        let config = TraceConfig::default();
840        let summary = RequestSummary::new(
841            "POST".into(),
842            "/login".into(),
843            headers_with_credentials(),
844            None,
845            &config,
846        );
847
848        let json = serde_json::to_string(&summary).unwrap();
849        assert!(!json.contains("Bearer secret-token"), "json was: {json}");
850        assert!(!json.contains("session=abc123"), "json was: {json}");
851        assert!(!json.contains("sk-live-very-secret"), "json was: {json}");
852        assert!(
853            json.contains("application/json"),
854            "a non-credential header must survive: {json}"
855        );
856    }
857
858    /// Redacted headers stay present, marked with the placeholder — not
859    /// silently dropped from the list (RFC 040 Goal 4).
860    #[test]
861    fn redacted_headers_are_present_and_marked_not_absent() {
862        let config = TraceConfig::default();
863        let summary = RequestSummary::new(
864            "POST".into(),
865            "/login".into(),
866            headers_with_credentials(),
867            None,
868            &config,
869        );
870
871        assert_eq!(summary.headers.len(), 4, "no header should be dropped");
872        let authorization = summary
873            .headers
874            .iter()
875            .find(|(name, _)| name == "authorization")
876            .expect("authorization header must still be present");
877        assert_eq!(authorization.1, REDACTED_HEADER_VALUE);
878
879        let json = serde_json::to_string(&summary).unwrap();
880        assert!(
881            json.contains("\"authorization\""),
882            "redacted header name must still appear: {json}"
883        );
884        assert!(json.contains(REDACTED_HEADER_VALUE), "json was: {json}");
885    }
886
887    /// Header names are case-insensitive; a denylist compared
888    /// case-sensitively would let a non-lowercase spelling through.
889    #[test]
890    fn denylist_matches_case_insensitively() {
891        let config = TraceConfig::default();
892        let headers = vec![
893            ("Authorization".into(), "Bearer secret-token".into()),
894            ("COOKIE".into(), "session=abc123".into()),
895        ];
896        let summary = RequestSummary::new("GET".into(), "/".into(), headers, None, &config);
897
898        let json = serde_json::to_string(&summary).unwrap();
899        assert!(!json.contains("Bearer secret-token"), "json was: {json}");
900        assert!(!json.contains("session=abc123"), "json was: {json}");
901        assert!(json.contains(REDACTED_HEADER_VALUE), "json was: {json}");
902    }
903
904    /// Allowlist mode fails closed: only the named header survives, and
905    /// an ordinary, non-credential header not on the list is redacted
906    /// too.
907    #[test]
908    fn allowlist_mode_redacts_everything_not_listed() {
909        let config = TraceConfig {
910            header_redaction: HeaderRedactionMode::Allowlist,
911            header_allowlist: vec!["content-type".into()],
912            ..Default::default()
913        };
914        let headers = vec![
915            ("content-type".into(), "application/json".into()),
916            ("authorization".into(), "Bearer secret-token".into()),
917            ("x-request-id".into(), "not-a-credential".into()),
918        ];
919        let summary = RequestSummary::new("GET".into(), "/".into(), headers, None, &config);
920
921        let by_name = |name: &str| {
922            summary
923                .headers
924                .iter()
925                .find(|(n, _)| n == name)
926                .map(|(_, v)| v.as_str())
927        };
928        assert_eq!(by_name("content-type"), Some("application/json"));
929        assert_eq!(by_name("authorization"), Some(REDACTED_HEADER_VALUE));
930        assert_eq!(
931            by_name("x-request-id"),
932            Some(REDACTED_HEADER_VALUE),
933            "an unlisted, non-credential header must still be redacted in allowlist mode"
934        );
935    }
936
937    /// An empty allowlist — the state before anyone configures one —
938    /// redacts every header. That is the safe direction for a
939    /// fail-closed mode, not an oversight.
940    #[test]
941    fn allowlist_mode_with_no_entries_redacts_everything() {
942        let config = TraceConfig {
943            header_redaction: HeaderRedactionMode::Allowlist,
944            ..Default::default()
945        };
946        let summary = RequestSummary::new(
947            "GET".into(),
948            "/".into(),
949            vec![("content-type".into(), "application/json".into())],
950            None,
951            &config,
952        );
953        assert_eq!(summary.headers[0].1, REDACTED_HEADER_VALUE);
954    }
955
956    // ── RFC 050: body presence (never content) ──────────────────────────
957
958    /// The three states RFC 050 exists to distinguish, asserted on the
959    /// *serialised* event — since that is what reaches a consumer.
960    /// `body_len` is populated for every body (RFC 050 review, R-09-
961    /// adjacent fix, 2026-08-17) — including the JSON-captured case,
962    /// which the first version of this RFC omitted, leaving the common
963    /// case (`capture_body`'s own default, `false`) still indistinguishable
964    /// from no body at all.
965    #[test]
966    fn three_body_states_are_distinguishable_in_the_serialised_form() {
967        let config = TraceConfig::default();
968
969        let no_body = RequestSummary::new("GET".into(), "/".into(), vec![], None, &config);
970        let no_body_json = serde_json::to_string(&no_body).unwrap();
971        assert!(!no_body_json.contains("body_json"), "{no_body_json}");
972        assert!(!no_body_json.contains("body_len"), "{no_body_json}");
973
974        let mut json_captured =
975            RequestSummary::new("POST".into(), "/".into(), vec![], Some(11), &config);
976        let emitter = TraceEmitter::with_config(TraceConfig {
977            capture_body: true,
978            ..Default::default()
979        });
980        emitter.enrich_with_body(&mut json_captured, Some(&serde_json::json!({"a": 1})));
981        let json_captured_str = serde_json::to_string(&json_captured).unwrap();
982        assert!(
983            json_captured_str.contains("\"body_json\""),
984            "{json_captured_str}"
985        );
986        assert!(
987            json_captured_str.contains("\"body_len\":11"),
988            "a JSON-captured body must still report its length: {json_captured_str}"
989        );
990
991        let body_present_not_captured =
992            RequestSummary::new("POST".into(), "/".into(), vec![], Some(27), &config);
993        let not_captured_str = serde_json::to_string(&body_present_not_captured).unwrap();
994        assert!(
995            !not_captured_str.contains("body_json"),
996            "{not_captured_str}"
997        );
998        assert!(
999            not_captured_str.contains("\"body_len\":27"),
1000            "{not_captured_str}"
1001        );
1002    }
1003
1004    /// No content, ever — a recognisable string from the original body
1005    /// must not appear anywhere in the serialised event, however it got
1006    /// there.
1007    #[test]
1008    fn non_json_body_reports_length_but_never_content() {
1009        let config = TraceConfig::default();
1010        let summary = RequestSummary::new("POST".into(), "/".into(), vec![], Some(32), &config);
1011        let json = serde_json::to_string(&summary).unwrap();
1012
1013        assert!(json.contains("\"body_len\":32"), "json was: {json}");
1014        assert!(
1015            !json.contains("username") && !json.contains("hunter2"),
1016            "no fragment of a body — captured or not — should appear: {json}"
1017        );
1018    }
1019}