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}