#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::collections::{BTreeMap, HashMap};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex, OnceLock, Weak};
use std::task::{Context, Poll};
use axum::body::Body;
use axum::extract::MatchedPath;
use axum::http::{Request, Response};
use chrono::{DateTime, Utc};
use tower::{Layer, Service};
use crate::capsule::redact::{CapturedBody, RawRequest};
use crate::capsule::schema::{CapsuleDb, ConnectionTape};
use crate::log::filter::ParameterFilter;
tokio::task_local! {
pub(crate) static CAPSULE_SCOPE: Arc<CaptureScope>;
}
#[must_use]
pub fn current_scope() -> Option<Arc<CaptureScope>> {
CAPSULE_SCOPE.try_with(Arc::clone).ok()
}
#[cfg(feature = "test-support")]
pub async fn with_capture_scope<F: Future>(scope: Arc<CaptureScope>, future: F) -> F::Output {
CAPSULE_SCOPE.scope(scope, future).await
}
#[derive(Debug, Clone, Default)]
pub struct CapturedClientIdentity {
pub addr: Option<std::net::IpAddr>,
pub host: Option<String>,
pub scheme: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CaptureSettings {
pub dir: String,
pub max_body_bytes: usize,
pub max_capsule_bytes: usize,
pub max_capsules: usize,
pub app_name: Option<String>,
pub profile: Option<String>,
pub db_roles: Vec<String>,
}
impl Default for CaptureSettings {
fn default() -> Self {
Self {
dir: "tmp/autumn-capsules".to_owned(),
max_body_bytes: 65_536,
max_capsule_bytes: 1_048_576,
max_capsules: 50,
app_name: None,
profile: None,
db_roles: Vec::new(),
}
}
}
#[derive(Debug, Default)]
pub struct DbBuffer {
tapes: BTreeMap<u64, ConnectionTape>,
order: Vec<u64>,
bytes: usize,
}
impl DbBuffer {
pub fn tape_mut(&mut self, connection_id: u64) -> &mut ConnectionTape {
match self.tapes.entry(connection_id) {
std::collections::btree_map::Entry::Occupied(tape) => tape.into_mut(),
std::collections::btree_map::Entry::Vacant(slot) => {
self.order.push(connection_id);
slot.insert(ConnectionTape {
id: connection_id,
..ConnectionTape::default()
})
}
}
}
pub const fn charge(&mut self, bytes: usize, budget: usize) -> bool {
self.bytes = self.bytes.saturating_add(bytes);
self.bytes <= budget
}
#[must_use]
pub const fn charged_bytes(&self) -> usize {
self.bytes
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.tapes.is_empty()
}
#[must_use]
pub fn snapshot(&self) -> Option<CapsuleDb> {
if self.tapes.is_empty() {
return None;
}
Some(CapsuleDb {
connections: self
.order
.iter()
.filter_map(|id| self.tapes.get(id))
.cloned()
.collect(),
})
}
}
const MAX_CLOCK_READINGS: usize = 10_000;
#[derive(Debug, Default)]
enum BodyTap {
#[default]
Absent,
Skipped {
declared_len: Option<usize>,
},
Teeing {
declared_len: Option<usize>,
buf: Vec<u8>,
end_stream: bool,
overflowed: bool,
},
}
const BODY_OVERFLOW_NOTE: &str =
"request body exceeded max_body_bytes while streaming; it was not captured";
const BODY_PARTIAL_NOTE: &str =
"request body was not read to its end before the failure; the captured body is incomplete";
#[derive(Debug)]
pub struct CaptureScope {
id: String,
settings: Arc<CaptureSettings>,
filter: Arc<ParameterFilter>,
request: OnceLock<RawRequest>,
body: Mutex<BodyTap>,
clock: Mutex<Vec<DateTime<Utc>>>,
monotonic: Mutex<Vec<std::time::Duration>>,
client_identity: OnceLock<CapturedClientIdentity>,
peer_addr: OnceLock<std::net::SocketAddr>,
db: Mutex<DbBuffer>,
notes: Mutex<Vec<String>>,
truncated: AtomicBool,
closed: AtomicBool,
}
impl CaptureScope {
#[must_use]
pub fn new(id: String, settings: Arc<CaptureSettings>, filter: Arc<ParameterFilter>) -> Self {
Self {
id,
settings,
filter,
request: OnceLock::new(),
body: Mutex::new(BodyTap::Absent),
clock: Mutex::new(Vec::new()),
monotonic: Mutex::new(Vec::new()),
client_identity: OnceLock::new(),
peer_addr: OnceLock::new(),
db: Mutex::new(DbBuffer::default()),
notes: Mutex::new(Vec::new()),
truncated: AtomicBool::new(false),
closed: AtomicBool::new(false),
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn settings(&self) -> &CaptureSettings {
&self.settings
}
#[must_use]
pub fn filter(&self) -> &ParameterFilter {
&self.filter
}
pub fn set_request(&self, request: RawRequest) {
let _ = self.request.set(request);
}
#[must_use]
pub fn raw_request(&self) -> Option<&RawRequest> {
self.request.get()
}
fn arm_body(&self, tap: BodyTap) {
if let Ok(mut current) = self.body.lock() {
*current = tap;
}
}
fn tee_body_chunk(&self, chunk: &[u8]) {
let limit = self.settings.max_body_bytes;
if let Ok(mut tap) = self.body.lock()
&& let BodyTap::Teeing {
buf, overflowed, ..
} = &mut *tap
&& !*overflowed
{
if buf.len().saturating_add(chunk.len()) > limit {
*overflowed = true;
*buf = Vec::new();
} else {
buf.extend_from_slice(chunk);
}
}
}
fn mark_body_end(&self) {
if let Ok(mut tap) = self.body.lock()
&& let BodyTap::Teeing { end_stream, .. } = &mut *tap
{
*end_stream = true;
}
}
#[must_use]
pub fn captured_body(&self) -> CapturedBody {
let Ok(tap) = self.body.lock() else {
self.mark_truncated();
return CapturedBody::Absent;
};
match &*tap {
BodyTap::Absent => CapturedBody::Absent,
BodyTap::Skipped { declared_len }
| BodyTap::Teeing {
declared_len,
overflowed: true,
..
} => CapturedBody::Skipped {
declared_len: *declared_len,
},
BodyTap::Teeing { buf, .. } if buf.is_empty() => CapturedBody::Absent,
BodyTap::Teeing { buf, .. } => CapturedBody::Buffered(bytes::Bytes::from(buf.clone())),
}
}
#[must_use]
pub fn body_note(&self) -> Option<&'static str> {
let tap = self.body.lock().ok()?;
match &*tap {
BodyTap::Teeing {
overflowed: true, ..
} => Some(BODY_OVERFLOW_NOTE),
BodyTap::Teeing {
declared_len: Some(declared),
buf,
end_stream: false,
..
} if buf.len() >= *declared => None,
BodyTap::Teeing {
end_stream: false, ..
} => Some(BODY_PARTIAL_NOTE),
_ => None,
}
}
pub fn set_client_identity(&self, identity: CapturedClientIdentity) {
let _ = self.client_identity.set(identity);
}
#[must_use]
pub fn client_identity(&self) -> Option<&CapturedClientIdentity> {
self.client_identity.get()
}
pub fn set_peer_addr(&self, peer: std::net::SocketAddr) {
let _ = self.peer_addr.set(peer);
}
#[must_use]
pub fn peer_addr(&self) -> Option<std::net::SocketAddr> {
self.peer_addr.get().copied()
}
pub fn record_clock(&self, reading: DateTime<Utc>) {
if let Ok(mut readings) = self.clock.lock() {
if readings.len() >= MAX_CLOCK_READINGS {
self.truncated.store(true, Ordering::Relaxed);
return;
}
readings.push(reading);
}
}
#[must_use]
pub fn clock_readings(&self) -> Vec<DateTime<Utc>> {
self.clock
.lock()
.map(|readings| readings.clone())
.unwrap_or_default()
}
pub fn record_monotonic(&self, since_origin: std::time::Duration) {
if let Ok(mut readings) = self.monotonic.lock() {
if readings.len() >= MAX_CLOCK_READINGS {
self.truncated.store(true, Ordering::Relaxed);
return;
}
readings.push(since_origin);
}
}
#[must_use]
pub fn monotonic_readings(&self) -> Vec<std::time::Duration> {
self.monotonic
.lock()
.map(|readings| readings.clone())
.unwrap_or_default()
}
pub fn with_db<R>(&self, f: impl FnOnce(&mut DbBuffer) -> R) -> Option<R> {
self.db.lock().ok().map(|mut db| f(&mut db))
}
#[must_use]
pub fn db_snapshot(&self) -> Option<CapsuleDb> {
self.db.lock().map_or_else(
|_| {
self.mark_truncated();
None
},
|db| db.snapshot(),
)
}
pub fn note(&self, note: impl Into<String>) {
let note = note.into();
if let Ok(mut notes) = self.notes.lock()
&& !notes.contains(¬e)
{
notes.push(note);
}
}
#[must_use]
pub fn notes(&self) -> Vec<String> {
self.notes
.lock()
.map(|notes| notes.clone())
.unwrap_or_default()
}
pub fn close(&self) {
self.closed.store(true, Ordering::Release);
}
#[must_use]
pub fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
pub fn mark_truncated(&self) {
self.truncated.store(true, Ordering::Relaxed);
}
#[must_use]
pub fn is_truncated(&self) -> bool {
self.truncated.load(Ordering::Relaxed)
}
}
#[derive(Clone, Debug)]
pub struct CaptureHandle(Arc<CaptureScope>);
impl CaptureHandle {
#[must_use]
pub const fn scope(&self) -> &Arc<CaptureScope> {
&self.0
}
}
static REGISTRY: LazyLock<Mutex<HashMap<String, Weak<CaptureScope>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[must_use]
pub fn scope_by_id(id: &str) -> Option<Arc<CaptureScope>> {
REGISTRY
.lock()
.ok()
.and_then(|registry| registry.get(id).and_then(Weak::upgrade))
}
pub(crate) fn register(scope: &Arc<CaptureScope>) {
if let Ok(mut registry) = REGISTRY.lock() {
registry.insert(scope.id().to_owned(), Arc::downgrade(scope));
}
}
fn deregister(id: &str) {
if let Ok(mut registry) = REGISTRY.lock() {
registry.remove(id);
}
}
struct RegistryGuard(Arc<CaptureScope>);
impl Drop for RegistryGuard {
fn drop(&mut self) {
self.0.close();
deregister(self.0.id());
}
}
const MAX_SCOPE_ID_LEN: usize = 64;
#[must_use]
pub fn is_valid_scope_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_SCOPE_ID_LEN
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
#[derive(Clone)]
pub struct CaptureLayer {
settings: Arc<CaptureSettings>,
filter: Arc<ParameterFilter>,
}
impl CaptureLayer {
#[must_use]
pub fn new(settings: CaptureSettings, filter: Arc<ParameterFilter>) -> Self {
Self {
settings: Arc::new(settings),
filter,
}
}
}
impl<S> Layer<S> for CaptureLayer {
type Service = CaptureService<S>;
fn layer(&self, inner: S) -> Self::Service {
CaptureService {
inner,
settings: Arc::clone(&self.settings),
filter: Arc::clone(&self.filter),
}
}
}
#[derive(Clone)]
pub struct CaptureService<S> {
inner: S,
settings: Arc<CaptureSettings>,
filter: Arc<ParameterFilter>,
}
impl<S> Service<Request<Body>> for CaptureService<S>
where
S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Send + 'static,
{
type Response = Response<Body>;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
let cloned = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, cloned);
let settings = Arc::clone(&self.settings);
let filter = Arc::clone(&self.filter);
Box::pin(async move {
let id = scope_id(&req);
let route = req
.extensions()
.get::<MatchedPath>()
.map(|matched| matched.as_str().to_owned());
let scope = Arc::new(CaptureScope::new(id, settings, filter));
if let Some(peer) = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
{
scope.set_peer_addr(peer.0);
}
scope.set_request(RawRequest {
method: req.method().as_str().to_owned(),
uri: req.uri().clone(),
version: req.version(),
headers: req.headers().clone(),
route,
});
let mut req = arm_body_capture(req, &scope);
register(&scope);
let _guard = RegistryGuard(Arc::clone(&scope));
req.extensions_mut()
.insert(CaptureHandle(Arc::clone(&scope)));
CAPSULE_SCOPE
.scope(scope, async move { inner.call(req).await })
.await
})
}
}
fn scope_id(req: &Request<Body>) -> String {
req.extensions()
.get::<crate::middleware::RequestId>()
.map(std::string::ToString::to_string)
.filter(|id| is_valid_scope_id(id))
.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string())
}
fn arm_body_capture(req: Request<Body>, scope: &Arc<CaptureScope>) -> Request<Body> {
let max_body_bytes = scope.settings().max_body_bytes;
let declared_len = body_length(&req);
match declared_len {
Some(0) => {
scope.arm_body(BodyTap::Absent);
req
}
None if !has_undeclared_body(&req) => {
scope.arm_body(BodyTap::Absent);
req
}
Some(len) if len > max_body_bytes => {
scope.arm_body(BodyTap::Skipped { declared_len });
req
}
_ => {
scope.arm_body(BodyTap::Teeing {
declared_len,
buf: Vec::new(),
end_stream: false,
overflowed: false,
});
let (parts, body) = req.into_parts();
let teed = Body::new(TeeBody {
inner: body,
scope: Arc::clone(scope),
});
Request::from_parts(parts, teed)
}
}
}
struct TeeBody {
inner: Body,
scope: Arc<CaptureScope>,
}
impl http_body::Body for TeeBody {
type Data = bytes::Bytes;
type Error = axum::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
let this = self.get_mut();
let polled = Pin::new(&mut this.inner).poll_frame(cx);
match &polled {
Poll::Ready(Some(Ok(frame))) => {
if let Some(data) = frame.data_ref() {
this.scope.tee_body_chunk(data);
}
if http_body::Body::is_end_stream(&this.inner) {
this.scope.mark_body_end();
}
}
Poll::Ready(None) => this.scope.mark_body_end(),
Poll::Ready(Some(Err(_))) | Poll::Pending => {}
}
polled
}
fn is_end_stream(&self) -> bool {
self.inner.is_end_stream()
}
fn size_hint(&self) -> http_body::SizeHint {
http_body::Body::size_hint(&self.inner)
}
}
fn body_length(req: &Request<Body>) -> Option<usize> {
req.headers()
.get(axum::http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok())
.or_else(|| usize::try_from(http_body::Body::size_hint(req.body()).exact()?).ok())
}
fn has_undeclared_body(req: &Request<Body>) -> bool {
!http_body::Body::is_end_stream(req.body())
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use http_body::Frame;
#[derive(Clone, Default)]
struct Trace(Arc<Mutex<Vec<&'static str>>>);
impl Trace {
fn record(&self, what: &'static str) {
if let Ok(mut entries) = self.0.lock() {
entries.push(what);
}
}
fn entries(&self) -> Vec<&'static str> {
self.0
.lock()
.map(|entries| entries.clone())
.unwrap_or_default()
}
}
struct WatchedBody {
trace: Trace,
chunks: Vec<&'static [u8]>,
}
impl http_body::Body for WatchedBody {
type Data = Bytes;
type Error = axum::Error;
fn poll_frame(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let this = self.get_mut();
this.trace.record("body-polled");
if this.chunks.is_empty() {
Poll::Ready(None)
} else {
let chunk = this.chunks.remove(0);
Poll::Ready(Some(Ok(Frame::data(Bytes::from_static(chunk)))))
}
}
}
fn test_layer(settings: CaptureSettings) -> CaptureLayer {
CaptureLayer::new(settings, Arc::new(ParameterFilter::new(&[], &[])))
}
async fn run_capture(
settings: CaptureSettings,
request: Request<Body>,
trace: Trace,
read_body: bool,
) -> Arc<CaptureScope> {
let seen: Arc<Mutex<Option<CaptureHandle>>> = Arc::new(Mutex::new(None));
let inner_seen = Arc::clone(&seen);
let inner_trace = trace.clone();
let inner = tower::service_fn(move |req: Request<Body>| {
let seen = Arc::clone(&inner_seen);
let trace = inner_trace.clone();
async move {
trace.record("inner-called");
if let Some(handle) = req.extensions().get::<CaptureHandle>().cloned()
&& let Ok(mut slot) = seen.lock()
{
*slot = Some(handle);
}
if read_body {
let _ = axum::body::to_bytes(req.into_body(), usize::MAX).await;
trace.record("handler-read-body");
}
Ok::<_, std::convert::Infallible>(Response::new(Body::empty()))
}
});
let mut service = test_layer(settings).layer(inner);
let _response = service
.call(request)
.await
.expect("inner service is infallible");
let handle = seen
.lock()
.expect("handle slot")
.clone()
.expect("the capture layer must publish a handle in the request extensions");
Arc::clone(handle.scope())
}
#[derive(Clone)]
struct SyncProbe {
saw_scope: Arc<Mutex<Option<bool>>>,
}
impl Service<Request<Body>> for SyncProbe {
type Response = Response<Body>;
type Error = std::convert::Infallible;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: Request<Body>) -> Self::Future {
if let Ok(mut slot) = self.saw_scope.lock() {
*slot = Some(current_scope().is_some());
}
Box::pin(async { Ok(Response::new(Body::empty())) })
}
}
#[tokio::test]
async fn an_inner_service_sees_the_scope_from_call_not_only_from_its_future() {
let saw_scope = Arc::new(Mutex::new(None));
let probe = SyncProbe {
saw_scope: Arc::clone(&saw_scope),
};
let mut service = test_layer(CaptureSettings::default()).layer(probe);
let _response = service
.call(
Request::get("/x")
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("probe is infallible");
assert_eq!(
*saw_scope.lock().expect("probe slot"),
Some(true),
"an inner service must see the capture scope from `call`, not only from its future"
);
}
#[tokio::test]
async fn call_does_not_read_the_body_before_the_inner_service_runs() {
let trace = Trace::default();
let request = Request::post("/x")
.header(axum::http::header::CONTENT_LENGTH, "7")
.body(Body::new(WatchedBody {
trace: trace.clone(),
chunks: vec![b"payload"],
}))
.expect("request builds");
let _scope = run_capture(CaptureSettings::default(), request, trace.clone(), true).await;
let entries = trace.entries();
assert_eq!(
entries.first(),
Some(&"inner-called"),
"capture must not touch the request body before the inner service \
(and therefore the request timeout) is running, got {entries:?}"
);
}
#[tokio::test]
async fn teed_body_is_captured_whole_when_the_handler_reads_it() {
let trace = Trace::default();
let request = Request::post("/x")
.header(axum::http::header::CONTENT_LENGTH, "10")
.body(Body::new(WatchedBody {
trace: trace.clone(),
chunks: vec![b"hello", b"world"],
}))
.expect("request builds");
let scope = run_capture(CaptureSettings::default(), request, trace, true).await;
match scope.captured_body() {
CapturedBody::Buffered(bytes) => assert_eq!(&bytes[..], b"helloworld"),
other => panic!("a fully read body must be captured whole, got {other:?}"),
}
assert_eq!(
scope.body_note(),
None,
"a complete body needs no caveat in the capsule"
);
}
#[tokio::test]
async fn body_the_handler_never_reads_leaves_a_note_not_a_capture() {
let trace = Trace::default();
let request = Request::post("/x")
.header(axum::http::header::CONTENT_LENGTH, "7")
.body(Body::new(WatchedBody {
trace: trace.clone(),
chunks: vec![b"payload"],
}))
.expect("request builds");
let scope = run_capture(CaptureSettings::default(), request, trace.clone(), false).await;
assert!(
!trace.entries().contains(&"body-polled"),
"nothing may read a body the handler ignored, got {:?}",
trace.entries()
);
assert!(matches!(scope.captured_body(), CapturedBody::Absent));
assert_eq!(
scope.body_note(),
Some(BODY_PARTIAL_NOTE),
"the capsule must say the body is incomplete rather than imply the \
request had none"
);
}
#[tokio::test]
async fn streamed_body_with_no_length_and_no_transfer_encoding_is_still_teed() {
let trace = Trace::default();
let request = Request::post("/x")
.version(axum::http::Version::HTTP_2)
.body(Body::new(WatchedBody {
trace: trace.clone(),
chunks: vec![b"h2-", b"payload"],
}))
.expect("request builds");
let scope = run_capture(CaptureSettings::default(), request, trace, true).await;
match scope.captured_body() {
CapturedBody::Buffered(bytes) => assert_eq!(&bytes[..], b"h2-payload"),
other => panic!(
"a body with no declared length must be teed, not assumed absent, got {other:?}"
),
}
}
#[tokio::test]
async fn a_request_with_no_body_at_all_is_recorded_as_absent() {
let request = Request::get("/x")
.body(Body::empty())
.expect("request builds");
let scope = run_capture(CaptureSettings::default(), request, Trace::default(), true).await;
assert!(matches!(scope.captured_body(), CapturedBody::Absent));
assert_eq!(
scope.body_note(),
None,
"a request with no body needs no caveat"
);
}
#[tokio::test]
async fn streamed_body_over_the_cap_is_dropped_mid_stream() {
let trace = Trace::default();
let request = Request::post("/x")
.header(axum::http::header::TRANSFER_ENCODING, "chunked")
.body(Body::new(WatchedBody {
trace: trace.clone(),
chunks: vec![b"1234", b"5678", b"9012"],
}))
.expect("request builds");
let settings = CaptureSettings {
max_body_bytes: 6,
..CaptureSettings::default()
};
let scope = run_capture(settings, request, trace, true).await;
assert!(
matches!(
scope.captured_body(),
CapturedBody::Skipped { declared_len: None }
),
"a body that outgrows the cap mid-stream must be dropped, got {:?}",
scope.captured_body()
);
assert_eq!(scope.body_note(), Some(BODY_OVERFLOW_NOTE));
}
#[tokio::test]
async fn body_declared_over_the_cap_is_never_wrapped() {
let trace = Trace::default();
let request = Request::post("/x")
.header(axum::http::header::CONTENT_LENGTH, "4096")
.body(Body::new(WatchedBody {
trace: trace.clone(),
chunks: vec![b"1234"],
}))
.expect("request builds");
let settings = CaptureSettings {
max_body_bytes: 16,
..CaptureSettings::default()
};
let scope = run_capture(settings, request, trace, true).await;
assert!(
matches!(
scope.captured_body(),
CapturedBody::Skipped {
declared_len: Some(4096)
}
),
"an oversized upload must be recorded as skipped, got {:?}",
scope.captured_body()
);
assert_eq!(
scope.body_note(),
None,
"skipping a declared-oversized body is the documented behaviour, \
not a degraded capture"
);
}
#[tokio::test]
async fn the_scope_closes_when_the_request_ends() {
let request = Request::get("/x")
.body(Body::empty())
.expect("request builds");
let scope = run_capture(CaptureSettings::default(), request, Trace::default(), false).await;
assert!(
scope.is_closed(),
"a finished request must stop accepting effects"
);
assert!(
scope_by_id(scope.id()).is_none(),
"and must no longer be reachable by a connection marker"
);
}
#[test]
fn a_body_read_to_its_declared_length_is_not_partial() {
let scope = CaptureScope::new(
"body".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
);
scope.arm_body(BodyTap::Teeing {
declared_len: Some(5),
buf: b"hello".to_vec(),
end_stream: false,
overflowed: false,
});
assert_eq!(
scope.body_note(),
None,
"a body captured up to its declared length is complete"
);
let scope = CaptureScope::new(
"body".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
);
scope.arm_body(BodyTap::Teeing {
declared_len: Some(5),
buf: b"hell".to_vec(),
end_stream: false,
overflowed: false,
});
assert_eq!(scope.body_note(), Some(BODY_PARTIAL_NOTE));
let scope = CaptureScope::new(
"body".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
);
scope.arm_body(BodyTap::Teeing {
declared_len: None,
buf: b"hello".to_vec(),
end_stream: false,
overflowed: false,
});
assert_eq!(scope.body_note(), Some(BODY_PARTIAL_NOTE));
}
struct EagerEndBody {
chunk: Option<&'static [u8]>,
}
impl http_body::Body for EagerEndBody {
type Data = Bytes;
type Error = axum::Error;
fn poll_frame(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let this = self.get_mut();
this.chunk.take().map_or(Poll::Ready(None), |chunk| {
Poll::Ready(Some(Ok(Frame::data(Bytes::from_static(chunk)))))
})
}
fn is_end_stream(&self) -> bool {
self.chunk.is_none()
}
}
#[test]
fn a_body_that_ends_with_its_last_frame_is_not_partial() {
let scope = Arc::new(CaptureScope::new(
"body".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
));
scope.arm_body(BodyTap::Teeing {
declared_len: None,
buf: Vec::new(),
end_stream: false,
overflowed: false,
});
let mut tee = TeeBody {
inner: Body::new(EagerEndBody {
chunk: Some(b"hello"),
}),
scope: Arc::clone(&scope),
};
let waker = std::task::Waker::noop();
let mut cx = Context::from_waker(waker);
let polled = http_body::Body::poll_frame(Pin::new(&mut tee), &mut cx);
assert!(
matches!(polled, Poll::Ready(Some(Ok(_)))),
"the frame is passed through"
);
assert_eq!(
scope.body_note(),
None,
"a body that ended with its last frame is complete, not partial"
);
}
#[test]
fn a_poisoned_buffer_marks_the_capsule_truncated() {
let scope = Arc::new(CaptureScope::new(
"poisoned".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
));
let panicking = Arc::clone(&scope);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
panicking.with_db(|_| panic!("recording interrupted"));
}));
assert!(
scope.db_snapshot().is_none(),
"an unreachable buffer yields no tape"
);
assert!(
scope.is_truncated(),
"and the capsule must say it is incomplete rather than imply the request \
never touched the database"
);
let body_scope = Arc::new(CaptureScope::new(
"poisoned-body".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
));
let panicking = Arc::clone(&body_scope);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = panicking.body.lock();
panic!("body copy interrupted");
}));
assert!(matches!(body_scope.captured_body(), CapturedBody::Absent));
assert!(
body_scope.is_truncated(),
"a body that could not be read back is a truncated capture, not an absent body"
);
}
#[test]
fn scope_ids_are_bounded_and_charset_checked() {
assert!(is_valid_scope_id("018f-4b2c_AB"));
assert!(!is_valid_scope_id(""));
assert!(!is_valid_scope_id("has space"));
assert!(!is_valid_scope_id("quote'; DROP TABLE users; --"));
assert!(!is_valid_scope_id(&"a".repeat(MAX_SCOPE_ID_LEN + 1)));
}
#[test]
fn db_buffer_charges_against_the_budget() {
let mut buffer = DbBuffer::default();
assert!(buffer.charge(400, 1000));
assert!(buffer.charge(600, 1000));
assert!(!buffer.charge(1, 1000), "the budget must eventually stop");
assert_eq!(buffer.charged_bytes(), 1001);
}
#[test]
fn db_buffer_snapshots_tapes_in_first_use_order() {
let mut buffer = DbBuffer::default();
buffer.tape_mut(7);
buffer.tape_mut(2);
buffer.tape_mut(7);
let snapshot = buffer.snapshot().expect("tapes were created");
let ids: Vec<u64> = snapshot.connections.iter().map(|tape| tape.id).collect();
assert_eq!(
ids,
vec![7, 2],
"tapes must be listed in the order the request first used each connection"
);
}
}