Skip to main content

waf_capture/
ring.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! A bounded, TTL'd in-memory ring of [`CapturedRequest`]s keyed by `request_id`.
5//!
6//! The capture module ([`crate::CaptureModule`]) inserts on the request path (the hot side); the
7//! verdict-join `tracing::Layer` (C2) takes on the decision path (the rare side — only denied
8//! requests). Everything not taken ages out, so the ring holds only a few seconds of recent traffic.
9//!
10//! Three independent bounds keep memory flat under any load: **entry count** (`capacity`), **total
11//! bytes** (`max_bytes`), and **age** (`ttl`). Insert evicts expired-then-oldest; the newest capture
12//! is always kept (a single oversized capture is never self-evicted). The lock is poison-tolerant:
13//! a panicked holder must not wedge the datapath.
14
15use std::collections::{HashMap, VecDeque};
16use std::sync::{Mutex, MutexGuard};
17use std::time::{Duration, Instant};
18
19use crate::CapturedRequest;
20
21struct Entry {
22    req: CapturedRequest,
23    inserted: Instant,
24    bytes: usize,
25}
26
27#[derive(Default)]
28struct RingInner {
29    map: HashMap<String, Entry>,
30    /// Insertion order (FIFO) of live keys — `take` keeps it consistent by removing the key.
31    order: VecDeque<String>,
32    total_bytes: usize,
33}
34
35/// Bounded ring buffer of captures. Cheap to clone the `Arc` around it; interior-mutable so the
36/// capture module can insert through a shared `&self`.
37pub struct CaptureRing {
38    inner: Mutex<RingInner>,
39    capacity: usize,
40    max_bytes: usize,
41    ttl: Duration,
42}
43
44impl CaptureRing {
45    /// Create a ring bounded by `capacity` entries, `max_bytes` total, and `ttl` age.
46    pub fn new(capacity: usize, max_bytes: usize, ttl: Duration) -> Self {
47        Self {
48            inner: Mutex::new(RingInner::default()),
49            capacity: capacity.max(1),
50            max_bytes,
51            ttl,
52        }
53    }
54
55    /// Poison-tolerant lock: a panicked holder must never wedge the request path.
56    fn lock(&self) -> MutexGuard<'_, RingInner> {
57        self.inner.lock().unwrap_or_else(|e| e.into_inner())
58    }
59
60    /// Insert (or replace) the capture for its `request_id`. Evicts expired entries first, then the
61    /// oldest while over the count/byte budget — but never the just-inserted newest.
62    pub fn insert(&self, req: CapturedRequest) {
63        let bytes = req.estimated_bytes();
64        let key = req.request_id.clone();
65        let mut g = self.lock();
66        self.evict_expired(&mut g);
67
68        // Replace a same-id entry (retry / duplicate) without leaving a stale order slot.
69        if let Some(old) = g.map.remove(&key) {
70            g.total_bytes -= old.bytes;
71            g.order.retain(|k| k != &key);
72        }
73
74        g.total_bytes += bytes;
75        g.map.insert(key.clone(), Entry { req, inserted: Instant::now(), bytes });
76        g.order.push_back(key);
77
78        while g.map.len() > self.capacity || (g.total_bytes > self.max_bytes && g.map.len() > 1) {
79            match g.order.pop_front() {
80                Some(oldest) => {
81                    if let Some(e) = g.map.remove(&oldest) {
82                        g.total_bytes -= e.bytes;
83                    }
84                }
85                None => break,
86            }
87        }
88    }
89
90    /// Remove and return the capture for `request_id`, if still present (not yet evicted). Called by
91    /// the verdict-join on a denied request.
92    pub fn take(&self, request_id: &str) -> Option<CapturedRequest> {
93        let mut g = self.lock();
94        self.evict_expired(&mut g);
95        let e = g.map.remove(request_id)?;
96        g.total_bytes -= e.bytes;
97        g.order.retain(|k| k != request_id);
98        Some(e.req)
99    }
100
101    /// Number of live captures currently held (test/observability).
102    pub fn len(&self) -> usize {
103        self.lock().map.len()
104    }
105
106    /// Whether the ring currently holds no captures.
107    pub fn is_empty(&self) -> bool {
108        self.len() == 0
109    }
110
111    /// Pop expired entries from the front (oldest). `order` is FIFO by insert time, so once the front
112    /// is within `ttl` nothing behind it can be older — stop.
113    fn evict_expired(&self, g: &mut RingInner) {
114        while let Some(front) = g.order.front().cloned() {
115            match g.map.get(&front) {
116                Some(e) if e.inserted.elapsed() > self.ttl => {
117                    let bytes = e.bytes;
118                    g.map.remove(&front);
119                    g.total_bytes -= bytes;
120                    g.order.pop_front();
121                }
122                _ => break,
123            }
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn cap(id: &str, body_len: usize) -> CapturedRequest {
133        CapturedRequest {
134            request_id: id.to_string(),
135            method: "GET".to_string(),
136            path: "/".to_string(),
137            query: None,
138            http_version: "HTTP/1.1".to_string(),
139            headers: vec![],
140            cookies: vec![],
141            body_b64: "A".repeat(body_len),
142            body_size: body_len,
143            body_truncated: false,
144            redacted: vec![],
145        }
146    }
147
148    #[test]
149    fn insert_then_take_roundtrips_and_removes() {
150        let ring = CaptureRing::new(8, 1 << 20, Duration::from_secs(5));
151        ring.insert(cap("r1", 10));
152        assert_eq!(ring.len(), 1);
153        let got = ring.take("r1").expect("present");
154        assert_eq!(got.request_id, "r1");
155        assert!(ring.take("r1").is_none(), "taken once");
156        assert!(ring.is_empty());
157    }
158
159    #[test]
160    fn evicts_oldest_over_capacity_keeping_newest() {
161        let ring = CaptureRing::new(2, 1 << 20, Duration::from_secs(5));
162        ring.insert(cap("r1", 1));
163        ring.insert(cap("r2", 1));
164        ring.insert(cap("r3", 1)); // evicts r1
165        assert_eq!(ring.len(), 2);
166        assert!(ring.take("r1").is_none(), "oldest evicted");
167        assert!(ring.take("r2").is_some());
168        assert!(ring.take("r3").is_some(), "newest kept");
169    }
170
171    #[test]
172    fn evicts_over_byte_budget_but_keeps_the_newest() {
173        // Budget only fits ~one 100-byte capture.
174        let ring = CaptureRing::new(100, 120, Duration::from_secs(5));
175        ring.insert(cap("r1", 100));
176        ring.insert(cap("r2", 100)); // pushes total over budget → r1 evicted
177        assert_eq!(ring.len(), 1);
178        assert!(ring.take("r1").is_none());
179        assert!(ring.take("r2").is_some(), "newest always survives");
180    }
181
182    #[test]
183    fn expired_entries_are_evicted() {
184        let ring = CaptureRing::new(8, 1 << 20, Duration::from_millis(1));
185        ring.insert(cap("r1", 10));
186        std::thread::sleep(Duration::from_millis(5));
187        // Any operation triggers expiry sweep.
188        assert!(ring.take("r1").is_none(), "expired before take");
189        assert!(ring.is_empty());
190    }
191
192    #[test]
193    fn reinsert_same_id_does_not_double_count_bytes() {
194        let ring = CaptureRing::new(8, 1 << 20, Duration::from_secs(5));
195        ring.insert(cap("r1", 10));
196        ring.insert(cap("r1", 20)); // replaces
197        assert_eq!(ring.len(), 1);
198        let got = ring.take("r1").unwrap();
199        assert_eq!(got.body_b64.len(), 20, "latest value wins");
200    }
201}