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//! RFC 073 S-06/D-02: this section used to describe a mechanism
29//! `tokio::sync::broadcast` does not have — `Sender::send` only fails
30//! when there are **no receivers at all**, never because the channel is
31//! "full" (a full channel instead evicts the oldest unread event for
32//! whichever receiver is slowest, which is a *per-receiver* event, not
33//! a send-time one). What's true now, and implemented to match:
34//!
35//! - [`TraceEmitter::emit`] increments a **shared** counter only when
36//!   `send` fails outright (no receiver existed at that moment) — rare,
37//!   and not what "back-pressure" usually means here.
38//! - A slow **out-of-process** subscriber (UDS/TCP, via
39//!   [`TraceTransport::accept_loop`]) gets `RecvError::Lagged(n)` on its
40//!   own [`broadcast::Receiver`] when it falls behind by more than
41//!   [`TRACE_CHANNEL_CAPACITY`] events; `n` is accumulated **per
42//!   subscriber** and added to `dropped_count` on that subscriber's next
43//!   forwarded event — see `forward_events`'s doc comment. Two
44//!   independently-lagging subscribers each see their own true count,
45//!   not each other's.
46//! - A direct **in-process** subscriber (calling [`TraceEmitter::subscribe`]
47//!   itself, bypassing the transport) gets the same `RecvError::Lagged`
48//!   from its own receiver and is responsible for folding it into
49//!   `dropped_count` the same way, since this crate has no way to patch
50//!   an event already broadcast to that caller's receiver — see
51//!   `subscribe`'s own doc comment.
52//!
53//! # Subscriber cap
54//!
55//! At most [`MAX_SUBSCRIBERS`] out-of-process connections are accepted.
56//! A fifth connection receives `{"error":"max_subscribers_reached"}` and
57//! is then closed.
58
59use std::sync::{
60    Arc,
61    atomic::{AtomicU32, AtomicUsize, Ordering},
62};
63use std::time::{Duration, SystemTime, UNIX_EPOCH};
64
65use apimock_routing::util::http::percent_decode_url_path;
66use serde::Serialize;
67use tokio::io::AsyncWriteExt;
68use tokio::sync::broadcast;
69
70/// Capacity of the broadcast channel (events).
71pub const TRACE_CHANNEL_CAPACITY: usize = 1_024;
72/// Maximum concurrent out-of-process subscriber connections.
73pub const MAX_SUBSCRIBERS: usize = 4;
74
75// ── Event schema ──────────────────────────────────────────────────────
76
77/// A single request/response trace event.
78#[derive(Clone, Debug, Serialize)]
79pub struct MatchTraceEvent {
80    /// Monotonically increasing event counter within this server run.
81    pub event_id: u64,
82    /// Schema version — bumped on breaking changes.
83    pub schema_version: u8,
84    /// Unix timestamp (milliseconds) when the request was received.
85    pub received_at_ms: u64,
86    /// Processing time in milliseconds.
87    pub duration_ms: u32,
88    /// Key fields from the incoming request.
89    pub request: RequestSummary,
90    /// What the server decided to do with the request.
91    pub outcome: Outcome,
92    /// Events dropped since the last successfully delivered event.
93    pub dropped_count: u32,
94}
95
96/// Key fields from the incoming HTTP request.
97#[derive(Clone, Debug, Serialize)]
98#[non_exhaustive]
99pub struct RequestSummary {
100    pub method: String,
101    pub url_path: String,
102    /// Request headers, redacted per `TraceConfig`'s policy at capture
103    /// (RFC 040) — not otherwise filtered by name. A redacted header
104    /// keeps its name and carries [`REDACTED_HEADER_VALUE`] instead of
105    /// its real value, so its presence stays visible.
106    pub headers: Vec<(String, String)>,
107    /// Captured JSON body (RFC 023). Present only when `TraceConfig::capture_body`
108    /// is `true` and the request body is valid JSON within the size cap.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub body_json: Option<serde_json::Value>,
111    /// `true` when the body was omitted because it exceeded `max_body_bytes`.
112    #[serde(skip_serializing_if = "std::ops::Not::not")]
113    pub body_truncated: bool,
114    /// Byte length of the request body, if one arrived (RFC 050) —
115    /// presence and size only, **never content**: no bytes, no snippet,
116    /// no preview. Populated for every body, JSON included — required
117    /// 2026-08-17 by review of this RFC, which found the original,
118    /// JSON-excluding version left the *common* case (a JSON body with
119    /// `capture_body` at its default `false`) still indistinguishable
120    /// from no body at all, the exact ambiguity this RFC exists to
121    /// close. So the three states this field distinguishes, together
122    /// with `body_json`, are: both absent (no body); `body_len` present
123    /// and `body_json` present (body present, JSON captured); `body_len`
124    /// present and `body_json` absent (body present, not captured —
125    /// non-JSON, capture disabled, or over `max_body_bytes`; the last of
126    /// those is further distinguished by `body_truncated`).
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub body_len: Option<usize>,
129}
130
131impl RequestSummary {
132    /// Construct from a live request's headers, applying `config`'s
133    /// redaction policy (RFC 040). This is the only place header
134    /// redaction happens — do not build `RequestSummary` from live
135    /// request headers any other way; a future formatter or display
136    /// path must not need to know about redaction at all.
137    ///
138    /// `body_len` should be the source request's own `ParsedRequest.body_len`
139    /// (RFC 050) — pass it through unconditionally, JSON body or not;
140    /// `enrich_with_body` populates `body_json` separately for the JSON
141    /// case, and the two fields together carry the distinction.
142    pub fn new(
143        method: String,
144        url_path: String,
145        headers: Vec<(String, String)>,
146        body_len: Option<usize>,
147        config: &TraceConfig,
148    ) -> Self {
149        Self {
150            method,
151            url_path,
152            headers: config.redact_headers(headers),
153            body_json: None,
154            body_truncated: false,
155            body_len,
156        }
157    }
158}
159
160/// Placeholder value substituted for a redacted header (RFC 040 Goal 4).
161/// The header name is kept so a consumer can tell "redacted" from
162/// "the request never sent this header" — only the value differs.
163pub const REDACTED_HEADER_VALUE: &str = "[redacted]";
164
165/// Built-in denylist of well-known credential-bearing names, applied by
166/// default (RFC 040 Q1). Compared case-insensitively.
167///
168/// # RFC 073 S-05: also the query-string and body-key denylist now
169///
170/// Originally header names only. `TraceConfig::header_denylist` (and
171/// `is_redacted_key`, below) now gate query-string parameter names and
172/// JSON request-body object keys too — one policy, one list, wherever
173/// a name-value pair leaves the process (a header, a query parameter,
174/// or a body field), rather than a second, parallel config surface for
175/// the same idea. The field/const names stay header-branded (renaming
176/// an already-public field is a bigger break than this RFC's fix
177/// needs), but the *scope* is broader than the name suggests — this
178/// comment, and `is_redacted_key`'s, are where that's said plainly.
179/// Entries added for this: `token`, `access_token`, `refresh_token`,
180/// `password`, `secret`, `client_secret`, `api_key` — none of which is
181/// a header name a request would ever send, but all of which are
182/// common query-parameter and body-field names for the same kind of
183/// value `authorization`/`x-api-key` already cover as headers.
184///
185/// `set-cookie` is a *response* header and can never appear on a
186/// request, so it never matches here — kept anyway because RFC 040's
187/// own example list included it; dropping it silently would read as
188/// deliberately narrowing the list rather than the request-only scope
189/// this RFC already states.
190pub const DEFAULT_HEADER_DENYLIST: &[&str] = &[
191    "authorization",
192    "cookie",
193    "set-cookie",
194    "proxy-authorization",
195    "x-api-key",
196    "token",
197    "access_token",
198    "refresh_token",
199    "password",
200    "secret",
201    "client_secret",
202    "api_key",
203];
204
205/// Which names get redacted before a trace event is built, or before a
206/// verbose console log line is printed (RFC 040 Q1, extended by RFC 073
207/// S-05 to query-string parameters and JSON body keys — see
208/// `DEFAULT_HEADER_DENYLIST`'s doc comment).
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub enum HeaderRedactionMode {
211    /// Redact names in `TraceConfig::header_denylist`; capture
212    /// everything else. Fails open on an unanticipated name —
213    /// accepted deliberately, see RFC 040's Risks table.
214    Denylist,
215    /// Capture only names in `TraceConfig::header_allowlist`; redact
216    /// everything else. Fails closed.
217    Allowlist,
218}
219
220/// Trace-channel behaviour configuration (RFC 023, extended by RFC 040).
221///
222/// Configurable today only at this Rust level — the trace channel has
223/// no config-file or CLI surface yet (RFC 040's own Motivation notes
224/// this; RFC 023's `[trace]` TOML section was never wired to this
225/// struct). `TraceEmitter::new()` always uses `TraceConfig::default()`.
226#[derive(Clone, Debug)]
227#[non_exhaustive]
228pub struct TraceConfig {
229    /// Capture the JSON request body in each event. Default: `false`.
230    pub capture_body: bool,
231    /// Maximum serialised body size in bytes. Bodies larger than this
232    /// are omitted and `body_truncated = true` is set. Default: 8 192.
233    pub max_body_bytes: usize,
234    /// Denylist or allowlist by default. Default: `Denylist`. Governs
235    /// headers, query-string parameters, and JSON body keys alike (RFC
236    /// 073 S-05) — see `DEFAULT_HEADER_DENYLIST`'s doc comment.
237    pub header_redaction: HeaderRedactionMode,
238    /// Names redacted when `header_redaction` is `Denylist` — header
239    /// names, query-string parameter names, and JSON body object keys
240    /// alike (RFC 073 S-05). Compared case-insensitively. Default:
241    /// [`DEFAULT_HEADER_DENYLIST`].
242    pub header_denylist: Vec<String>,
243    /// Names captured when `header_redaction` is `Allowlist`; every
244    /// other name (header, query parameter, or body key) is redacted.
245    /// Compared case-insensitively. Default: empty, i.e. allowlist mode
246    /// redacts everything until configured — the safe direction for a
247    /// fail-closed mode.
248    pub header_allowlist: Vec<String>,
249}
250
251impl Default for TraceConfig {
252    fn default() -> Self {
253        Self {
254            capture_body: false,
255            max_body_bytes: 8_192,
256            header_redaction: HeaderRedactionMode::Denylist,
257            header_denylist: DEFAULT_HEADER_DENYLIST
258                .iter()
259                .map(|s| s.to_string())
260                .collect(),
261            header_allowlist: Vec::new(),
262        }
263    }
264}
265
266impl TraceConfig {
267    /// Whether `name` is redacted under this config's policy
268    /// (case-insensitive). The single definition behind every place a
269    /// name-value pair can leave the process: a request header
270    /// (`redact_headers`, below), a query-string parameter
271    /// (`redact_query_string`), a JSON body key (`redact_json_value`),
272    /// and verbose console logging (`render_request_log` in
273    /// `parsed_request.rs`, RFC 051/073) — one policy, shared by
274    /// reference, not copied or reimplemented per call site.
275    ///
276    /// Named for what it does, not for headers specifically (RFC 073
277    /// S-05 extended this from a header-only check) — see
278    /// `DEFAULT_HEADER_DENYLIST`'s doc comment for why the *fields*
279    /// stay header-branded regardless.
280    pub(crate) fn is_redacted_key(&self, name: &str) -> bool {
281        match self.header_redaction {
282            HeaderRedactionMode::Denylist => self
283                .header_denylist
284                .iter()
285                .any(|denied| denied.eq_ignore_ascii_case(name)),
286            HeaderRedactionMode::Allowlist => !self
287                .header_allowlist
288                .iter()
289                .any(|allowed| allowed.eq_ignore_ascii_case(name)),
290        }
291    }
292
293    /// Redact header values per this config's policy. Names and order
294    /// are preserved; only a matched entry's value is replaced with
295    /// [`REDACTED_HEADER_VALUE`] (RFC 040 Goal 4 — marked, not omitted).
296    fn redact_headers(&self, headers: Vec<(String, String)>) -> Vec<(String, String)> {
297        headers
298            .into_iter()
299            .map(|(name, value)| {
300                if self.is_redacted_key(&name) {
301                    (name, REDACTED_HEADER_VALUE.to_string())
302                } else {
303                    (name, value)
304                }
305            })
306            .collect()
307    }
308
309    /// Redact a raw query string per this config's policy (RFC 073
310    /// S-05) — `?token=secret&page=2` becomes `?token=[redacted]&page=2`.
311    /// Parameter names and their order are preserved verbatim in the
312    /// output, including any percent-encoding in the original string
313    /// (this is a display-time transform, not a re-parse of the
314    /// request — nothing here is used for matching); only a matched
315    /// parameter's value is replaced with [`REDACTED_HEADER_VALUE`],
316    /// the same marker header redaction uses. A key with no `=` (a bare
317    /// flag parameter) is left alone — there is no value to redact and
318    /// the key itself is never secret content.
319    ///
320    /// # REVIEW-001 F-01: the *key* is decoded before the denylist
321    /// # check, even though the printed key stays as written
322    ///
323    /// A client can percent-encode ASCII in a parameter name
324    /// (`%74oken` decodes to `token`) — unusual, but not invalid, and
325    /// the whole point of this method is to not depend on an attacker
326    /// (or just an unusual client) spelling the name the way the
327    /// denylist expects. `percent_decode_url_path` (RFC 075,
328    /// `apimock-routing`) decodes *only* the copy used for the
329    /// `is_redacted_key` check; the key actually written to the output
330    /// is the original, unmodified slice, so a legitimately
331    /// percent-encoded name that happens to look like `token` still
332    /// displays as it was sent — only the *value* changes when it
333    /// matches. This mirrors why the ordering matters at all in RFC
334    /// 075 F-03: checking the undecoded form is the same class of
335    /// bypass as never decoding at all.
336    ///
337    /// JSON body keys (`redact_json_value`, below) don't need this:
338    /// JSON keys aren't percent-encoded on the wire — a key is a JSON
339    /// string, not a URI component — so there is no undecoded form to
340    /// bypass through.
341    pub(crate) fn redact_query_string(&self, query: &str) -> String {
342        query
343            .split('&')
344            .map(|pair| match pair.split_once('=') {
345                Some((key, _value)) if self.is_redacted_key(&percent_decode_url_path(key)) => {
346                    format!("{key}={REDACTED_HEADER_VALUE}")
347                }
348                _ => pair.to_string(),
349            })
350            .collect::<Vec<String>>()
351            .join("&")
352    }
353
354    /// Redact a JSON body per this config's policy (RFC 073 S-05),
355    /// recursively — a secret nested inside an object is just as real a
356    /// leak as one at the top level. For each object encountered, a key
357    /// matching the denylist/allowlist has its **value** replaced with
358    /// [`REDACTED_HEADER_VALUE`] (the key itself stays, same
359    /// mark-don't-omit convention as header redaction); a key that
360    /// isn't redacted is recursed into, so a secret nested under a
361    /// non-secret-named parent is still caught. Arrays are walked
362    /// element-wise. A scalar (string/number/bool/null) has nothing to
363    /// redact by itself — only an object's *keys* name what a value is,
364    /// which is what redaction here keys off of.
365    pub(crate) fn redact_json_value(&self, value: &serde_json::Value) -> serde_json::Value {
366        match value {
367            serde_json::Value::Object(map) => serde_json::Value::Object(
368                map.iter()
369                    .map(|(key, val)| {
370                        let redacted_val = if self.is_redacted_key(key) {
371                            serde_json::Value::String(REDACTED_HEADER_VALUE.to_string())
372                        } else {
373                            self.redact_json_value(val)
374                        };
375                        (key.clone(), redacted_val)
376                    })
377                    .collect(),
378            ),
379            serde_json::Value::Array(items) => {
380                serde_json::Value::Array(items.iter().map(|v| self.redact_json_value(v)).collect())
381            }
382            scalar => scalar.clone(),
383        }
384    }
385}
386
387/// What the server decided to do with the request.
388///
389/// # RFC 073 F-08: every variant here must actually be emitted
390///
391/// Before this RFC, `server.rs` emitted `Miss { status: 0 }` for
392/// *every* request regardless of outcome — the correct index was
393/// computed and discarded — and nothing was emitted at all for a
394/// middleware match, a dyn-route fallback file, or a genuine 404.
395/// `Fallback` and `Miss` already existed but were unused outside this
396/// module's own tests; `Middleware` is new, added because no existing
397/// shape fit a middleware match (its own script path, not a rule-set
398/// index, is what identifies which one answered).
399///
400/// # `#[non_exhaustive]` — added in the same change that added `Middleware`
401/// # (REVIEW-001 F-02)
402///
403/// Adding `Middleware` here breaks any consumer with an exhaustive
404/// `match` on `Outcome`, regardless of what the public-API baseline
405/// diff shows — a new-variant addition is "additive" in the sense that
406/// tool tracks (a new pub item exists), not in the sense that matters
407/// to semver (a consumer's exhaustive match stops compiling). `Outcome`
408/// was the one public enum in this crate not already carrying this
409/// attribute — `ReloadHint`, `ServerState`, `ServerError`,
410/// `ServerErrorKind` and `TlsKind` all have it; RFC 052 (which added it
411/// to five *other* types) never considered `Outcome`, so this gap
412/// pre-dates this tranche and simply hadn't been triggered by an actual
413/// variant addition yet. Since this change breaks exhaustive matchers
414/// either way, marking it now means every *future* variant is free —
415/// the same reasoning RFC 052 used for the five types it covered.
416#[derive(Clone, Debug, Serialize)]
417#[serde(tag = "type", rename_all = "snake_case")]
418#[non_exhaustive]
419pub enum Outcome {
420    Matched {
421        rule_set_index: usize,
422        rule_index: usize,
423    },
424    /// A Rhai middleware handled the request. `file_path` is the
425    /// matched middleware script's own path (`MiddlewareHandler::file_path`),
426    /// mirroring `Fallback`'s use of a path over an index for the same
427    /// reason: it identifies *which* handler answered without requiring
428    /// a consumer to also have the server's own middleware list on hand.
429    Middleware {
430        file_path: String,
431        status: u16,
432    },
433    Fallback {
434        file_path: String,
435        status: u16,
436    },
437    Miss {
438        status: u16,
439    },
440    Error {
441        kind: String,
442        message: String,
443    },
444}
445
446// ── Emitter ───────────────────────────────────────────────────────────
447
448/// Shared handle to the trace broadcast channel.
449///
450/// Clone freely — each clone refers to the same underlying channel.
451#[derive(Clone)]
452pub struct TraceEmitter {
453    sender: broadcast::Sender<MatchTraceEvent>,
454    event_counter: Arc<AtomicU32>,
455    dropped_counter: Arc<AtomicU32>,
456    /// Behaviour settings (body capture, etc.).
457    pub config: Arc<TraceConfig>,
458}
459
460impl TraceEmitter {
461    pub fn new() -> Self {
462        Self::with_config(TraceConfig::default())
463    }
464
465    pub fn with_config(config: TraceConfig) -> Self {
466        let (sender, _) = broadcast::channel(TRACE_CHANNEL_CAPACITY);
467        Self {
468            sender,
469            event_counter: Arc::new(AtomicU32::new(0)),
470            dropped_counter: Arc::new(AtomicU32::new(0)),
471            config: Arc::new(config),
472        }
473    }
474
475    /// Subscribe to the event stream (in-process).
476    ///
477    /// # A direct subscriber owns its own lag accounting
478    ///
479    /// This is a plain `tokio::sync::broadcast::Receiver` — if this
480    /// caller's own `recv()` loop falls behind by more than
481    /// [`TRACE_CHANNEL_CAPACITY`] events, it gets `RecvError::Lagged(n)`
482    /// the same way `TraceTransport::forward_events` does internally
483    /// for the UDS/TCP transport. This crate cannot fold that `n` into
484    /// `dropped_count` on this caller's behalf — an event is broadcast
485    /// once and already in flight to every receiver by the time any one
486    /// of them lags, so nothing can retroactively patch the copy this
487    /// receiver eventually reads. A caller that wants an honest
488    /// `dropped_count` (rather than a stale one from a rarer, shared
489    /// counter — see this module's own doc comment on back-pressure)
490    /// should accumulate `n` itself across `Lagged` and account for it
491    /// however it reports events onward, the same way `forward_events`
492    /// does for its own two transports.
493    pub fn subscribe(&self) -> broadcast::Receiver<MatchTraceEvent> {
494        self.sender.subscribe()
495    }
496
497    /// Attach body JSON to a `RequestSummary` according to this emitter's
498    /// `TraceConfig`. Call before `emit` when the request body is available.
499    ///
500    /// # RFC 073 S-05: the captured body is redacted, not raw
501    ///
502    /// This is the trace *channel*'s own body capture — it reaches
503    /// out-of-process subscribers over the UDS/TCP transport, not just
504    /// a local terminal, so leaving it unredacted here would be at
505    /// least as serious a leak as the verbose-console-log one this RFC
506    /// also fixes. Redaction runs before the size check, so the size
507    /// cap applies to what is actually stored (post-redaction), not to
508    /// the original.
509    pub fn enrich_with_body(
510        &self,
511        summary: &mut RequestSummary,
512        body_json: Option<&serde_json::Value>,
513    ) {
514        if !self.config.capture_body {
515            return;
516        }
517        match body_json {
518            None => {} // non-JSON or empty body — leave body_json = None
519            Some(v) => {
520                let redacted = self.config.redact_json_value(v);
521                // Check serialised size against the cap.
522                match serde_json::to_string(&redacted) {
523                    Ok(s) if s.len() <= self.config.max_body_bytes => {
524                        summary.body_json = Some(redacted);
525                    }
526                    Ok(_) => {
527                        summary.body_truncated = true;
528                    }
529                    Err(_) => {} // shouldn't happen for a valid Value
530                }
531            }
532        }
533    }
534
535    /// Emit one event.  If the channel is full, the event is dropped and
536    /// the internal drop counter incremented.
537    pub fn emit(
538        &self,
539        received_at_ms: u64,
540        duration_ms: u32,
541        request: RequestSummary,
542        outcome: Outcome,
543    ) {
544        let event_id = self.event_counter.fetch_add(1, Ordering::Relaxed) as u64;
545        let dropped_count = self.dropped_counter.swap(0, Ordering::Relaxed);
546
547        let event = MatchTraceEvent {
548            event_id,
549            schema_version: 1,
550            received_at_ms,
551            duration_ms,
552            request,
553            outcome,
554            dropped_count,
555        };
556
557        if self.sender.send(event).is_err() {
558            self.dropped_counter.fetch_add(1, Ordering::Relaxed);
559        }
560    }
561
562    /// Returns `true` iff at least one receiver is currently active.
563    pub fn has_subscribers(&self) -> bool {
564        self.sender.receiver_count() > 0
565    }
566}
567
568impl Default for TraceEmitter {
569    fn default() -> Self {
570        Self::new()
571    }
572}
573
574// ── Transport configuration ───────────────────────────────────────────
575
576/// Configuration for the out-of-process transport layer.
577#[derive(Clone, Debug, Default)]
578pub enum TraceTransportConfig {
579    /// Unix-domain socket at the given path (Unix/macOS only).
580    #[cfg(unix)]
581    Uds { path: String },
582    /// TCP loopback socket (portable fallback).
583    Tcp { addr: String },
584    /// No out-of-process forwarding.
585    #[default]
586    Disabled,
587}
588
589// ── Transport implementation ──────────────────────────────────────────
590
591pub struct TraceTransport;
592
593impl TraceTransport {
594    /// Start accepting out-of-process subscriber connections and forwarding
595    /// events as newline-delimited JSON.
596    ///
597    /// This future runs forever (until the process exits or the socket
598    /// errors fatally). Spawn it with `tokio::spawn`.
599    ///
600    /// # Subscriber cap
601    ///
602    /// At most [`MAX_SUBSCRIBERS`] connections are served simultaneously.
603    /// Connection #`MAX_SUBSCRIBERS + 1` receives a JSON error line and
604    /// is closed.
605    pub async fn accept_loop(config: TraceTransportConfig, emitter: TraceEmitter) {
606        match config {
607            #[cfg(unix)]
608            TraceTransportConfig::Uds { path } => Self::uds_accept_loop(path, emitter).await,
609            TraceTransportConfig::Tcp { addr } => Self::tcp_accept_loop(addr, emitter).await,
610            TraceTransportConfig::Disabled => {
611                // No-op — transport disabled; in-process channel still works.
612            }
613        }
614    }
615
616    // ── TCP accept loop ───────────────────────────────────────────────
617
618    /// # RFC 073: this transport has no authentication
619    ///
620    /// Anything that can open a TCP connection to `addr` receives the
621    /// live request trace feed — there is no login, token, or
622    /// allowlist. A non-loopback `addr` is a documentation ask this RFC
623    /// cannot enforce (an operator may have a real reason this process
624    /// doesn't know), so this only warns loudly rather than refusing to
625    /// bind — see `docs/src/reference/threat-model.md`'s trace-transport
626    /// section for the full statement, and prefer the Unix-socket
627    /// transport (restrictive permissions, RFC 073) wherever the
628    /// platform supports it.
629    async fn tcp_accept_loop(addr: String, emitter: TraceEmitter) {
630        let listener = match tokio::net::TcpListener::bind(&addr).await {
631            Ok(l) => {
632                let bound = l
633                    .local_addr()
634                    .map(|a| a.to_string())
635                    .unwrap_or_else(|_| addr.clone());
636                log::info!("trace transport: TCP listening on {}", bound);
637                if !l.local_addr().map(|a| a.ip().is_loopback()).unwrap_or(true) {
638                    log::warn!(
639                        "trace transport: TCP listening on a non-loopback address ({}) — \
640                         this transport has no authentication; anything that can reach it \
641                         receives the live request trace feed",
642                        bound
643                    );
644                }
645                l
646            }
647            Err(e) => {
648                log::error!("trace transport: failed to bind TCP {}: {}", addr, e);
649                return;
650            }
651        };
652
653        let active = Arc::new(AtomicUsize::new(0));
654        loop {
655            match listener.accept().await {
656                Ok((stream, peer)) => {
657                    let count = active.fetch_add(1, Ordering::Relaxed) + 1;
658                    if count > MAX_SUBSCRIBERS {
659                        active.fetch_sub(1, Ordering::Relaxed);
660                        tokio::spawn(async move {
661                            let (_, mut writer) = tokio::io::split(stream);
662                            let _ = writer
663                                .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
664                                .await;
665                        });
666                        continue;
667                    }
668                    log::debug!("trace: TCP subscriber connected from {}", peer);
669                    let rx = emitter.subscribe();
670                    let active_clone = active.clone();
671                    tokio::spawn(async move {
672                        let (_, writer) = tokio::io::split(stream);
673                        Self::forward_events(writer, rx).await;
674                        active_clone.fetch_sub(1, Ordering::Relaxed);
675                        log::debug!("trace: TCP subscriber {} disconnected", peer);
676                    });
677                }
678                Err(e) => {
679                    log::error!("trace: TCP accept error: {}", e);
680                    tokio::time::sleep(Duration::from_millis(100)).await;
681                }
682            }
683        }
684    }
685
686    // ── UDS accept loop (Unix only) ───────────────────────────────────
687
688    /// # RFC 073: restrictive permissions, owner-only
689    ///
690    /// `UnixListener::bind` creates the socket file with permissions
691    /// governed by the process umask — often group/world-readable in a
692    /// default shell configuration, which would let any other local
693    /// user connect and receive the live request trace feed. Set to
694    /// `0600` (owner read/write only) immediately after binding, before
695    /// the accept loop starts, so there is no window where the socket
696    /// exists at its umask-derived permissions. This has no Windows
697    /// equivalent — the UDS transport is `#[cfg(unix)]` only; Windows
698    /// always uses the TCP transport, which this crate cannot restrict
699    /// the same way (see `tcp_accept_loop`'s own doc comment).
700    #[cfg(unix)]
701    async fn uds_accept_loop(path: String, emitter: TraceEmitter) {
702        use std::os::unix::fs::PermissionsExt;
703
704        // Remove stale socket file from a previous run.
705        let _ = std::fs::remove_file(&path);
706
707        let listener = match tokio::net::UnixListener::bind(&path) {
708            Ok(l) => {
709                if let Err(e) =
710                    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
711                {
712                    log::error!(
713                        "trace transport: failed to restrict UDS permissions on {}: {}",
714                        path,
715                        e
716                    );
717                }
718                log::info!("trace transport: UDS listening at {}", path);
719                l
720            }
721            Err(e) => {
722                log::error!("trace transport: failed to bind UDS {}: {}", path, e);
723                return;
724            }
725        };
726
727        let active = Arc::new(AtomicUsize::new(0));
728        loop {
729            match listener.accept().await {
730                Ok((stream, _)) => {
731                    let count = active.fetch_add(1, Ordering::Relaxed) + 1;
732                    if count > MAX_SUBSCRIBERS {
733                        active.fetch_sub(1, Ordering::Relaxed);
734                        tokio::spawn(async move {
735                            let (_, mut writer) = tokio::io::split(stream);
736                            let _ = writer
737                                .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
738                                .await;
739                        });
740                        continue;
741                    }
742                    log::debug!("trace: UDS subscriber connected");
743                    let rx = emitter.subscribe();
744                    let active_clone = active.clone();
745                    tokio::spawn(async move {
746                        let (_, writer) = tokio::io::split(stream);
747                        Self::forward_events(writer, rx).await;
748                        active_clone.fetch_sub(1, Ordering::Relaxed);
749                        log::debug!("trace: UDS subscriber disconnected");
750                    });
751                }
752                Err(e) => {
753                    log::error!("trace: UDS accept error: {}", e);
754                    tokio::time::sleep(Duration::from_millis(100)).await;
755                }
756            }
757        }
758    }
759
760    // ── Event forwarder (shared by UDS and TCP) ───────────────────────
761
762    /// Read events from `rx` and write each as a JSON line to `writer`
763    /// until the connection closes or the channel is closed.
764    ///
765    /// # RFC 073 S-06/D-02: `dropped_count` is patched per subscriber
766    ///
767    /// `event.dropped_count`, as built by `TraceEmitter::emit`, only
768    /// ever reflects the rare shared no-receivers counter — it cannot
769    /// know about *this* lag, since a lag is detected on this
770    /// subscriber's own `Receiver`, after the event was already
771    /// broadcast identically to everyone. `lagged_events` accumulates
772    /// `n` from every `Lagged` this subscriber's own receiver reports,
773    /// and is folded into the next event this loop actually forwards
774    /// (each subscriber gets its own `Clone` of the event from
775    /// `broadcast`, so mutating it here affects only this subscriber's
776    /// own JSON line) — then reset, so a later event isn't charged for
777    /// a gap already reported.
778    async fn forward_events<W>(mut writer: W, mut rx: broadcast::Receiver<MatchTraceEvent>)
779    where
780        W: tokio::io::AsyncWrite + Unpin,
781    {
782        let mut lagged_events: u32 = 0;
783        loop {
784            let mut event = match rx.recv().await {
785                Ok(e) => e,
786                Err(broadcast::error::RecvError::Lagged(n)) => {
787                    lagged_events =
788                        lagged_events.saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
789                    log::debug!("trace: subscriber lagged, {} events dropped", n);
790                    continue;
791                }
792                Err(broadcast::error::RecvError::Closed) => break,
793            };
794
795            event.dropped_count = event.dropped_count.saturating_add(lagged_events);
796            lagged_events = 0;
797
798            let mut line = match serde_json::to_string(&event) {
799                Ok(s) => s,
800                Err(e) => {
801                    log::error!("trace: serialise error: {}", e);
802                    continue;
803                }
804            };
805            line.push('\n');
806
807            if writer.write_all(line.as_bytes()).await.is_err() {
808                break; // subscriber disconnected
809            }
810        }
811    }
812}
813
814// ── Timestamp helper ──────────────────────────────────────────────────
815
816/// Current Unix time in milliseconds.
817pub fn now_ms() -> u64 {
818    SystemTime::now()
819        .duration_since(UNIX_EPOCH)
820        .unwrap_or(Duration::ZERO)
821        .as_millis() as u64
822}
823
824// ── Tests ─────────────────────────────────────────────────────────────
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    #[tokio::test]
831    async fn emit_received_by_subscriber() {
832        let emitter = TraceEmitter::new();
833        let mut rx = emitter.subscribe();
834
835        emitter.emit(
836            1_000_000,
837            5,
838            RequestSummary {
839                method: "GET".into(),
840                url_path: "/api/test".into(),
841                headers: vec![],
842                body_json: None,
843                body_truncated: false,
844                body_len: None,
845            },
846            Outcome::Miss { status: 404 },
847        );
848
849        let event = rx.try_recv().expect("event in channel");
850        assert_eq!(event.event_id, 0);
851        assert_eq!(event.schema_version, 1);
852        assert_eq!(event.request.method, "GET");
853        assert_eq!(event.duration_ms, 5);
854        assert_eq!(event.dropped_count, 0);
855        assert!(matches!(event.outcome, Outcome::Miss { status: 404 }));
856    }
857
858    #[tokio::test]
859    async fn emit_no_subscriber_increments_dropped() {
860        let emitter = TraceEmitter::new();
861        emitter.emit(
862            0,
863            0,
864            RequestSummary {
865                method: "GET".into(),
866                url_path: "/".into(),
867                headers: vec![],
868                body_json: None,
869                body_truncated: false,
870                body_len: None,
871            },
872            Outcome::Miss { status: 404 },
873        );
874        let mut rx = emitter.subscribe();
875        emitter.emit(
876            0,
877            0,
878            RequestSummary {
879                method: "GET".into(),
880                url_path: "/".into(),
881                headers: vec![],
882                body_json: None,
883                body_truncated: false,
884                body_len: None,
885            },
886            Outcome::Miss { status: 200 },
887        );
888        let event = rx.try_recv().expect("second event visible");
889        assert_eq!(
890            event.dropped_count, 1,
891            "first event should be counted dropped"
892        );
893    }
894
895    #[test]
896    fn has_subscribers_reflects_state() {
897        let emitter = TraceEmitter::new();
898        assert!(!emitter.has_subscribers());
899        let _rx = emitter.subscribe();
900        assert!(emitter.has_subscribers());
901    }
902
903    #[tokio::test]
904    async fn outcome_serialises_correctly() {
905        let event = MatchTraceEvent {
906            event_id: 7,
907            schema_version: 1,
908            received_at_ms: 0,
909            duration_ms: 0,
910            request: RequestSummary {
911                method: "POST".into(),
912                url_path: "/x".into(),
913                headers: vec![],
914                body_json: None,
915                body_truncated: false,
916                body_len: None,
917            },
918            outcome: Outcome::Matched {
919                rule_set_index: 0,
920                rule_index: 2,
921            },
922            dropped_count: 0,
923        };
924        let json = serde_json::to_string(&event).unwrap();
925        assert!(json.contains("\"type\":\"matched\""));
926        assert!(json.contains("\"rule_index\":2"));
927        assert!(json.contains("\"schema_version\":1"));
928    }
929
930    #[tokio::test]
931    async fn tcp_transport_delivers_events() {
932        let emitter = TraceEmitter::new();
933        let emitter_clone = emitter.clone();
934
935        // We need to know the actual bound port before connecting.
936        // Bind the listener ourselves to capture the address, then hand
937        // the address to the transport accept loop via a channel.
938        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
939        let bound_addr = listener.local_addr().unwrap();
940
941        // Spawn a simplified accept loop that uses our pre-bound listener.
942        tokio::spawn(async move {
943            let (stream, _) = listener.accept().await.unwrap();
944            let rx = emitter_clone.subscribe();
945            let (_, writer) = tokio::io::split(stream);
946            TraceTransport::forward_events(writer, rx).await;
947        });
948
949        // Connect a subscriber.
950        let mut client = tokio::net::TcpStream::connect(bound_addr).await.unwrap();
951
952        // Give the subscriber task a moment to subscribe before emitting.
953        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
954
955        emitter.emit(
956            42,
957            3,
958            RequestSummary {
959                method: "GET".into(),
960                url_path: "/ping".into(),
961                headers: vec![],
962                body_json: None,
963                body_truncated: false,
964                body_len: None,
965            },
966            Outcome::Miss { status: 404 },
967        );
968
969        // Read one JSON line from the TCP connection.
970        use tokio::io::AsyncBufReadExt;
971        let mut reader = tokio::io::BufReader::new(&mut client);
972        let mut line = String::new();
973        tokio::time::timeout(
974            std::time::Duration::from_secs(2),
975            reader.read_line(&mut line),
976        )
977        .await
978        .expect("timeout")
979        .expect("read ok");
980
981        let value: serde_json::Value = serde_json::from_str(line.trim()).expect("valid JSON");
982        assert_eq!(value["request"]["url_path"], "/ping");
983        assert_eq!(value["outcome"]["type"], "miss");
984        assert_eq!(value["schema_version"], 1);
985    }
986
987    fn dummy_summary() -> RequestSummary {
988        RequestSummary {
989            method: "GET".into(),
990            url_path: "/".into(),
991            headers: vec![],
992            body_json: None,
993            body_truncated: false,
994            body_len: None,
995        }
996    }
997
998    /// RFC 073 S-06/D-02: a subscriber that falls behind by more than
999    /// the channel's capacity gets a nonzero `dropped_count` on the
1000    /// next event it actually receives — the documented back-pressure
1001    /// behaviour, implemented rather than only described. Overflowing
1002    /// the channel before this subscriber ever reads anything, then
1003    /// dropping the emitter (closing the channel) so `forward_events`
1004    /// drains the remaining buffered events and terminates on `Closed`
1005    /// rather than hanging forever waiting for one more.
1006    #[tokio::test]
1007    async fn a_lagging_subscriber_reports_dropped_count_on_its_next_event() {
1008        let emitter = TraceEmitter::new();
1009        let rx = emitter.subscribe();
1010
1011        for _ in 0..(TRACE_CHANNEL_CAPACITY + 10) {
1012            emitter.emit(0, 0, dummy_summary(), Outcome::Miss { status: 404 });
1013        }
1014        drop(emitter);
1015
1016        let mut buf: Vec<u8> = Vec::new();
1017        TraceTransport::forward_events(&mut buf, rx).await;
1018
1019        let text = String::from_utf8(buf).expect("valid utf8");
1020        let first_line = text.lines().next().expect("at least one forwarded event");
1021        let event: serde_json::Value = serde_json::from_str(first_line).expect("valid JSON");
1022        assert!(
1023            event["dropped_count"].as_u64().unwrap_or(0) > 0,
1024            "the first event surviving a lag must report it: {first_line}"
1025        );
1026    }
1027
1028    // ── RFC 023: body capture tests ───────────────────────────────────
1029
1030    #[test]
1031    fn enrich_with_body_disabled_by_default() {
1032        let emitter = TraceEmitter::new(); // capture_body = false by default
1033        let mut summary = RequestSummary {
1034            method: "POST".into(),
1035            url_path: "/".into(),
1036            headers: vec![],
1037            body_json: None,
1038            body_truncated: false,
1039            body_len: None,
1040        };
1041        let body = serde_json::json!({"action": "create"});
1042        emitter.enrich_with_body(&mut summary, Some(&body));
1043        assert!(
1044            summary.body_json.is_none(),
1045            "body should not be captured when disabled"
1046        );
1047        assert!(!summary.body_truncated);
1048    }
1049
1050    #[test]
1051    fn enrich_with_body_enabled_captures_small_body() {
1052        let emitter = TraceEmitter::with_config(TraceConfig {
1053            capture_body: true,
1054            max_body_bytes: 8_192,
1055            ..Default::default()
1056        });
1057        let mut summary = RequestSummary {
1058            method: "POST".into(),
1059            url_path: "/".into(),
1060            headers: vec![],
1061            body_json: None,
1062            body_truncated: false,
1063            body_len: None,
1064        };
1065        let body = serde_json::json!({"action": "create", "user_id": 42});
1066        emitter.enrich_with_body(&mut summary, Some(&body));
1067        assert!(
1068            summary.body_json.is_some(),
1069            "body should be captured when enabled"
1070        );
1071        assert_eq!(summary.body_json.unwrap()["action"], "create");
1072        assert!(!summary.body_truncated);
1073    }
1074
1075    #[test]
1076    fn enrich_with_body_truncates_oversized_body() {
1077        let emitter = TraceEmitter::with_config(TraceConfig {
1078            capture_body: true,
1079            max_body_bytes: 10,
1080            ..Default::default()
1081        });
1082        let mut summary = RequestSummary {
1083            method: "POST".into(),
1084            url_path: "/".into(),
1085            headers: vec![],
1086            body_json: None,
1087            body_truncated: false,
1088            body_len: None,
1089        };
1090        let body = serde_json::json!({"data": "this is longer than 10 bytes"});
1091        emitter.enrich_with_body(&mut summary, Some(&body));
1092        assert!(
1093            summary.body_json.is_none(),
1094            "oversized body should be omitted"
1095        );
1096        assert!(summary.body_truncated, "body_truncated flag should be set");
1097    }
1098
1099    #[test]
1100    fn request_summary_body_json_not_in_serialised_output_when_none() {
1101        let summary = RequestSummary {
1102            method: "GET".into(),
1103            url_path: "/api".into(),
1104            headers: vec![],
1105            body_json: None,
1106            body_truncated: false,
1107            body_len: None,
1108        };
1109        let json = serde_json::to_string(&summary).unwrap();
1110        assert!(
1111            !json.contains("body_json"),
1112            "absent body_json must be skipped"
1113        );
1114        assert!(
1115            !json.contains("body_truncated"),
1116            "false body_truncated must be skipped"
1117        );
1118    }
1119
1120    // ── RFC 040: header redaction ──────────────────────────────────────
1121
1122    fn headers_with_credentials() -> Vec<(String, String)> {
1123        vec![
1124            ("authorization".into(), "Bearer secret-token".into()),
1125            ("cookie".into(), "session=abc123".into()),
1126            ("x-api-key".into(), "sk-live-very-secret".into()),
1127            ("content-type".into(), "application/json".into()),
1128        ]
1129    }
1130
1131    /// RFC 040 evidence requirement: with no trace configuration at all —
1132    /// `TraceConfig::default()` — none of the three credential values
1133    /// appear in the *serialised* event.
1134    #[test]
1135    fn default_config_redacts_credential_headers_in_serialised_output() {
1136        let config = TraceConfig::default();
1137        let summary = RequestSummary::new(
1138            "POST".into(),
1139            "/login".into(),
1140            headers_with_credentials(),
1141            None,
1142            &config,
1143        );
1144
1145        let json = serde_json::to_string(&summary).unwrap();
1146        assert!(!json.contains("Bearer secret-token"), "json was: {json}");
1147        assert!(!json.contains("session=abc123"), "json was: {json}");
1148        assert!(!json.contains("sk-live-very-secret"), "json was: {json}");
1149        assert!(
1150            json.contains("application/json"),
1151            "a non-credential header must survive: {json}"
1152        );
1153    }
1154
1155    /// Redacted headers stay present, marked with the placeholder — not
1156    /// silently dropped from the list (RFC 040 Goal 4).
1157    #[test]
1158    fn redacted_headers_are_present_and_marked_not_absent() {
1159        let config = TraceConfig::default();
1160        let summary = RequestSummary::new(
1161            "POST".into(),
1162            "/login".into(),
1163            headers_with_credentials(),
1164            None,
1165            &config,
1166        );
1167
1168        assert_eq!(summary.headers.len(), 4, "no header should be dropped");
1169        let authorization = summary
1170            .headers
1171            .iter()
1172            .find(|(name, _)| name == "authorization")
1173            .expect("authorization header must still be present");
1174        assert_eq!(authorization.1, REDACTED_HEADER_VALUE);
1175
1176        let json = serde_json::to_string(&summary).unwrap();
1177        assert!(
1178            json.contains("\"authorization\""),
1179            "redacted header name must still appear: {json}"
1180        );
1181        assert!(json.contains(REDACTED_HEADER_VALUE), "json was: {json}");
1182    }
1183
1184    /// Header names are case-insensitive; a denylist compared
1185    /// case-sensitively would let a non-lowercase spelling through.
1186    #[test]
1187    fn denylist_matches_case_insensitively() {
1188        let config = TraceConfig::default();
1189        let headers = vec![
1190            ("Authorization".into(), "Bearer secret-token".into()),
1191            ("COOKIE".into(), "session=abc123".into()),
1192        ];
1193        let summary = RequestSummary::new("GET".into(), "/".into(), headers, None, &config);
1194
1195        let json = serde_json::to_string(&summary).unwrap();
1196        assert!(!json.contains("Bearer secret-token"), "json was: {json}");
1197        assert!(!json.contains("session=abc123"), "json was: {json}");
1198        assert!(json.contains(REDACTED_HEADER_VALUE), "json was: {json}");
1199    }
1200
1201    /// Allowlist mode fails closed: only the named header survives, and
1202    /// an ordinary, non-credential header not on the list is redacted
1203    /// too.
1204    #[test]
1205    fn allowlist_mode_redacts_everything_not_listed() {
1206        let config = TraceConfig {
1207            header_redaction: HeaderRedactionMode::Allowlist,
1208            header_allowlist: vec!["content-type".into()],
1209            ..Default::default()
1210        };
1211        let headers = vec![
1212            ("content-type".into(), "application/json".into()),
1213            ("authorization".into(), "Bearer secret-token".into()),
1214            ("x-request-id".into(), "not-a-credential".into()),
1215        ];
1216        let summary = RequestSummary::new("GET".into(), "/".into(), headers, None, &config);
1217
1218        let by_name = |name: &str| {
1219            summary
1220                .headers
1221                .iter()
1222                .find(|(n, _)| n == name)
1223                .map(|(_, v)| v.as_str())
1224        };
1225        assert_eq!(by_name("content-type"), Some("application/json"));
1226        assert_eq!(by_name("authorization"), Some(REDACTED_HEADER_VALUE));
1227        assert_eq!(
1228            by_name("x-request-id"),
1229            Some(REDACTED_HEADER_VALUE),
1230            "an unlisted, non-credential header must still be redacted in allowlist mode"
1231        );
1232    }
1233
1234    /// An empty allowlist — the state before anyone configures one —
1235    /// redacts every header. That is the safe direction for a
1236    /// fail-closed mode, not an oversight.
1237    #[test]
1238    fn allowlist_mode_with_no_entries_redacts_everything() {
1239        let config = TraceConfig {
1240            header_redaction: HeaderRedactionMode::Allowlist,
1241            ..Default::default()
1242        };
1243        let summary = RequestSummary::new(
1244            "GET".into(),
1245            "/".into(),
1246            vec![("content-type".into(), "application/json".into())],
1247            None,
1248            &config,
1249        );
1250        assert_eq!(summary.headers[0].1, REDACTED_HEADER_VALUE);
1251    }
1252
1253    // ── RFC 050: body presence (never content) ──────────────────────────
1254
1255    /// The three states RFC 050 exists to distinguish, asserted on the
1256    /// *serialised* event — since that is what reaches a consumer.
1257    /// `body_len` is populated for every body (RFC 050 review, R-09-
1258    /// adjacent fix, 2026-08-17) — including the JSON-captured case,
1259    /// which the first version of this RFC omitted, leaving the common
1260    /// case (`capture_body`'s own default, `false`) still indistinguishable
1261    /// from no body at all.
1262    #[test]
1263    fn three_body_states_are_distinguishable_in_the_serialised_form() {
1264        let config = TraceConfig::default();
1265
1266        let no_body = RequestSummary::new("GET".into(), "/".into(), vec![], None, &config);
1267        let no_body_json = serde_json::to_string(&no_body).unwrap();
1268        assert!(!no_body_json.contains("body_json"), "{no_body_json}");
1269        assert!(!no_body_json.contains("body_len"), "{no_body_json}");
1270
1271        let mut json_captured =
1272            RequestSummary::new("POST".into(), "/".into(), vec![], Some(11), &config);
1273        let emitter = TraceEmitter::with_config(TraceConfig {
1274            capture_body: true,
1275            ..Default::default()
1276        });
1277        emitter.enrich_with_body(&mut json_captured, Some(&serde_json::json!({"a": 1})));
1278        let json_captured_str = serde_json::to_string(&json_captured).unwrap();
1279        assert!(
1280            json_captured_str.contains("\"body_json\""),
1281            "{json_captured_str}"
1282        );
1283        assert!(
1284            json_captured_str.contains("\"body_len\":11"),
1285            "a JSON-captured body must still report its length: {json_captured_str}"
1286        );
1287
1288        let body_present_not_captured =
1289            RequestSummary::new("POST".into(), "/".into(), vec![], Some(27), &config);
1290        let not_captured_str = serde_json::to_string(&body_present_not_captured).unwrap();
1291        assert!(
1292            !not_captured_str.contains("body_json"),
1293            "{not_captured_str}"
1294        );
1295        assert!(
1296            not_captured_str.contains("\"body_len\":27"),
1297            "{not_captured_str}"
1298        );
1299    }
1300
1301    /// No content, ever — a recognisable string from the original body
1302    /// must not appear anywhere in the serialised event, however it got
1303    /// there.
1304    #[test]
1305    fn non_json_body_reports_length_but_never_content() {
1306        let config = TraceConfig::default();
1307        let summary = RequestSummary::new("POST".into(), "/".into(), vec![], Some(32), &config);
1308        let json = serde_json::to_string(&summary).unwrap();
1309
1310        assert!(json.contains("\"body_len\":32"), "json was: {json}");
1311        assert!(
1312            !json.contains("username") && !json.contains("hunter2"),
1313            "no fragment of a body — captured or not — should appear: {json}"
1314        );
1315    }
1316
1317    // ── RFC 073 S-05: query-string and body redaction ────────────────
1318
1319    /// The tranche 5 handoff's own acceptance example: a secret in a
1320    /// query parameter is redacted the same way a header is, under the
1321    /// broadened default denylist.
1322    #[test]
1323    fn a_query_string_token_is_redacted_by_default() {
1324        let config = TraceConfig::default();
1325        let redacted = config.redact_query_string("token=secret&page=2");
1326        assert_eq!(redacted, "token=[redacted]&page=2");
1327    }
1328
1329    /// A parameter name not on the denylist survives untouched, and
1330    /// parameter order is preserved.
1331    #[test]
1332    fn a_non_denied_query_parameter_survives() {
1333        let config = TraceConfig::default();
1334        let redacted = config.redact_query_string("page=2&access_token=abc123&sort=asc");
1335        assert_eq!(redacted, "page=2&access_token=[redacted]&sort=asc");
1336    }
1337
1338    /// A bare flag parameter (no `=`) has nothing to redact and is left
1339    /// alone, even if its name happens to match the denylist.
1340    #[test]
1341    fn a_bare_flag_parameter_is_left_alone() {
1342        let config = TraceConfig::default();
1343        let redacted = config.redact_query_string("verbose&token=secret");
1344        assert_eq!(redacted, "verbose&token=[redacted]");
1345    }
1346
1347    /// A denylist match is case-insensitive regardless of how the key
1348    /// arrived.
1349    #[test]
1350    fn a_query_string_key_is_matched_case_insensitively() {
1351        let config = TraceConfig::default();
1352        let redacted = config.redact_query_string("TOKEN=secret");
1353        assert_eq!(redacted, "TOKEN=[redacted]");
1354    }
1355
1356    /// REVIEW-001 F-01: a percent-encoded key must not bypass the
1357    /// denylist. `%74oken` decodes to `token` — the value still gets
1358    /// redacted, but the key is printed exactly as it arrived (not
1359    /// decoded), since only the *value* is ever supposed to change.
1360    #[test]
1361    fn a_percent_encoded_query_key_does_not_bypass_redaction() {
1362        let config = TraceConfig::default();
1363        let redacted = config.redact_query_string("%74oken=secret");
1364        assert_eq!(redacted, "%74oken=[redacted]");
1365    }
1366
1367    /// The tranche 5 handoff's other acceptance example: a secret in a
1368    /// JSON body is redacted, by key, the same way a header is.
1369    #[test]
1370    fn a_top_level_body_secret_is_redacted_by_default() {
1371        let config = TraceConfig::default();
1372        let body = serde_json::json!({"username": "alice", "password": "hunter2"});
1373        let redacted = config.redact_json_value(&body);
1374        assert_eq!(redacted["username"], "alice");
1375        assert_eq!(redacted["password"], REDACTED_HEADER_VALUE);
1376    }
1377
1378    /// A secret nested under a non-secret-named parent is still caught
1379    /// — redaction recurses into objects it doesn't itself redact.
1380    #[test]
1381    fn a_nested_body_secret_is_redacted_too() {
1382        let config = TraceConfig::default();
1383        let body = serde_json::json!({
1384            "user": {"name": "alice", "api_key": "sk-live-very-secret"},
1385            "items": [{"id": 1}, {"token": "should-not-appear"}],
1386        });
1387        let redacted = config.redact_json_value(&body);
1388        assert_eq!(redacted["user"]["name"], "alice");
1389        assert_eq!(redacted["user"]["api_key"], REDACTED_HEADER_VALUE);
1390        assert_eq!(redacted["items"][0]["id"], 1);
1391        assert_eq!(redacted["items"][1]["token"], REDACTED_HEADER_VALUE);
1392
1393        let json = serde_json::to_string(&redacted).unwrap();
1394        assert!(
1395            !json.contains("sk-live-very-secret") && !json.contains("should-not-appear"),
1396            "no redacted value should survive serialisation: {json}"
1397        );
1398    }
1399
1400    /// RFC 073 S-05's actual delivery mechanism for the trace channel:
1401    /// `enrich_with_body` — not just the standalone `redact_json_value`
1402    /// helper — redacts before storing, so a subscriber over the
1403    /// UDS/TCP transport never receives the raw secret either.
1404    #[test]
1405    fn enrich_with_body_redacts_a_captured_body() {
1406        let emitter = TraceEmitter::with_config(TraceConfig {
1407            capture_body: true,
1408            ..Default::default()
1409        });
1410        let mut summary = dummy_summary();
1411        let body = serde_json::json!({"action": "login", "password": "hunter2"});
1412        emitter.enrich_with_body(&mut summary, Some(&body));
1413
1414        let captured = summary.body_json.expect("body should be captured");
1415        assert_eq!(captured["action"], "login");
1416        assert_eq!(captured["password"], REDACTED_HEADER_VALUE);
1417    }
1418
1419    /// `Outcome::Middleware` (RFC 073 F-08) serialises with a
1420    /// discriminated `type` tag like every other variant, carrying the
1421    /// matched middleware script's own path.
1422    #[test]
1423    fn outcome_middleware_serialises_with_file_path_and_status() {
1424        let outcome = Outcome::Middleware {
1425            file_path: "middleware/auth.rhai".into(),
1426            status: 200,
1427        };
1428        let json = serde_json::to_string(&outcome).unwrap();
1429        assert!(json.contains("\"type\":\"middleware\""), "json was: {json}");
1430        assert!(
1431            json.contains("\"file_path\":\"middleware/auth.rhai\""),
1432            "json was: {json}"
1433        );
1434        assert!(json.contains("\"status\":200"), "json was: {json}");
1435    }
1436
1437    /// RFC 073: the UDS socket file is created owner-only (`0600`), not
1438    /// left at whatever the process umask would otherwise produce.
1439    #[cfg(unix)]
1440    #[tokio::test]
1441    async fn uds_socket_is_created_with_owner_only_permissions() {
1442        use std::os::unix::fs::PermissionsExt;
1443
1444        let dir = tempfile::tempdir().expect("tempdir");
1445        let path = dir.path().join("trace.sock").to_str().unwrap().to_owned();
1446        let emitter = TraceEmitter::new();
1447
1448        let accept_loop = tokio::spawn(TraceTransport::accept_loop(
1449            TraceTransportConfig::Uds { path: path.clone() },
1450            emitter,
1451        ));
1452
1453        // The accept loop sets permissions synchronously, right after
1454        // bind, before its first `accept().await` — poll for the file
1455        // to exist rather than a fixed sleep, since scheduling order
1456        // between this test task and the spawned one isn't guaranteed.
1457        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1458        while !std::path::Path::new(&path).exists() {
1459            assert!(
1460                std::time::Instant::now() < deadline,
1461                "socket never appeared"
1462            );
1463            tokio::time::sleep(Duration::from_millis(5)).await;
1464        }
1465
1466        let mode = std::fs::metadata(&path)
1467            .expect("stat socket")
1468            .permissions()
1469            .mode();
1470        assert_eq!(
1471            mode & 0o777,
1472            0o600,
1473            "socket permissions should be owner-only, got {mode:o}"
1474        );
1475
1476        accept_loop.abort();
1477    }
1478}