1use std::fmt;
20use std::sync::{Arc, OnceLock};
21
22use tracing::field::{Field, Visit};
23use tracing::{Event, Subscriber};
24use tracing_subscriber::layer::{Context, Layer};
25
26use crate::{CaptureRing, CaptureSink};
27
28struct Armed {
31 ring: Arc<CaptureRing>,
32 sink: CaptureSink,
33}
34
35#[derive(Clone, Default)]
38pub struct CaptureHandle {
39 armed: Arc<OnceLock<Armed>>,
40}
41
42impl CaptureHandle {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn arm(&self, ring: Arc<CaptureRing>, sink: CaptureSink) {
51 let _ = self.armed.set(Armed { ring, sink });
52 }
53
54 fn armed(&self) -> Option<&Armed> {
55 self.armed.get()
56 }
57}
58
59pub struct CaptureLayer {
62 handle: CaptureHandle,
63}
64
65impl CaptureLayer {
66 pub fn new(handle: CaptureHandle) -> Self {
68 Self { handle }
69 }
70}
71
72impl<S: Subscriber> Layer<S> for CaptureLayer {
73 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
74 let Some(armed) = self.handle.armed() else {
75 return; };
77 let mut visitor = DecisionVisitor::default();
78 event.record(&mut visitor);
79 if !visitor.is_denial() {
80 return;
81 }
82 let Some(request_id) = visitor.request_id else {
83 return;
84 };
85 if let Some(capture) = armed.ring.take(&request_id) {
87 armed.sink.enqueue(capture);
88 }
89 }
90}
91
92#[derive(Default)]
96struct DecisionVisitor {
97 message: Option<String>,
98 request_id: Option<String>,
99}
100
101impl DecisionVisitor {
102 fn is_denial(&self) -> bool {
103 matches!(self.message.as_deref(), Some("request blocked") | Some("request rejected"))
104 }
105}
106
107impl Visit for DecisionVisitor {
108 fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
109 match field.name() {
110 "message" => self.message = Some(format!("{value:?}")),
111 "request_id" if self.request_id.is_none() => {
112 self.request_id = Some(format!("{value:?}"));
113 }
114 _ => {}
115 }
116 }
117
118 fn record_str(&mut self, field: &Field, value: &str) {
119 if field.name() == "request_id" {
120 self.request_id = Some(value.to_string());
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use std::time::Duration;
129 use tracing_subscriber::layer::SubscriberExt;
130
131 use crate::CapturedRequest;
132
133 fn sample(id: &str) -> CapturedRequest {
134 CapturedRequest {
135 request_id: id.to_string(),
136 method: "POST".to_string(),
137 path: "/x".to_string(),
138 query: None,
139 http_version: "HTTP/1.1".to_string(),
140 headers: vec![],
141 cookies: vec![],
142 body_b64: String::new(),
143 body_size: 0,
144 body_truncated: false,
145 redacted: vec![],
146 }
147 }
148
149 fn armed_layer() -> (CaptureHandle, Arc<CaptureRing>, tokio::sync::mpsc::Receiver<CapturedRequest>) {
150 let ring = Arc::new(CaptureRing::new(8, 1 << 20, Duration::from_secs(5)));
151 let (sink, rx) = CaptureSink::channel(8);
152 let handle = CaptureHandle::new();
153 handle.arm(Arc::clone(&ring), sink);
154 (handle, ring, rx)
155 }
156
157 #[test]
158 fn denial_event_takes_from_ring_and_ships() {
159 let (handle, ring, mut rx) = armed_layer();
160 ring.insert(sample("req-1"));
161 let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
162 tracing::subscriber::with_default(subscriber, || {
163 let rid = "req-1";
164 tracing::warn!(request_id = %rid, rule_id = "sqli", "request blocked");
166 });
167 let got = rx.try_recv().expect("capture shipped");
168 assert_eq!(got.request_id, "req-1");
169 assert!(ring.is_empty(), "capture removed from the ring");
170 }
171
172 #[test]
173 fn rejected_event_also_ships() {
174 let (handle, ring, mut rx) = armed_layer();
175 ring.insert(sample("req-2"));
176 let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
177 tracing::subscriber::with_default(subscriber, || {
178 let rid = "req-2";
179 tracing::warn!(request_id = %rid, status = 429, "request rejected");
180 });
181 assert_eq!(rx.try_recv().expect("shipped").request_id, "req-2");
182 }
183
184 #[test]
185 fn non_denial_event_ships_nothing() {
186 let (handle, ring, mut rx) = armed_layer();
187 ring.insert(sample("req-3"));
188 let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(handle));
189 tracing::subscriber::with_default(subscriber, || {
190 let rid = "req-3";
191 tracing::info!(request_id = %rid, method = "GET", "→ request");
192 });
193 assert!(rx.try_recv().is_err(), "no capture for a non-denial event");
194 assert_eq!(ring.len(), 1, "ring untouched");
195 }
196
197 #[test]
198 fn disarmed_layer_is_inert() {
199 let subscriber = tracing_subscriber::registry().with(CaptureLayer::new(CaptureHandle::new()));
201 tracing::subscriber::with_default(subscriber, || {
202 let rid = "req-x";
203 tracing::warn!(request_id = %rid, "request blocked");
204 });
205 }
206}