#![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::{
CacheEffect, CapsuleBody, CapsuleDb, CapsuleEffects, CapsuleJob, ConnectionTape, HttpEffect,
JobEffect, MailEffect, RandomEffect, TenantEffect,
};
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(any(test, 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_EFFECTS_PER_SEAM: usize = 2_000;
#[derive(Debug, Default)]
pub struct EffectBuffer {
effects: CapsuleEffects,
bytes: usize,
overflowed: bool,
outstanding: usize,
}
impl EffectBuffer {
#[must_use]
pub const fn effects(&self) -> &CapsuleEffects {
&self.effects
}
#[must_use]
pub const fn overflowed(&self) -> bool {
self.overflowed
}
#[must_use]
pub const fn has_unfinished(&self) -> bool {
self.outstanding > 0
}
#[must_use]
pub const fn charged_bytes(&self) -> usize {
self.bytes
}
fn reserve<T>(
&mut self,
seam: fn(&mut CapsuleEffects) -> &mut Vec<T>,
placeholder: T,
budget: usize,
) -> Option<usize> {
let list = seam(&mut self.effects);
if list.len() >= MAX_EFFECTS_PER_SEAM {
self.overflowed = true;
return None;
}
if self.bytes > budget {
self.overflowed = true;
return None;
}
let index = list.len();
list.push(placeholder);
self.outstanding = self.outstanding.saturating_add(1);
Some(index)
}
fn fill<T>(
&mut self,
seam: fn(&mut CapsuleEffects) -> &mut Vec<T>,
index: usize,
entry: T,
weight: usize,
budget: usize,
) {
self.outstanding = self.outstanding.saturating_sub(1);
let charged = self.bytes.saturating_add(weight);
if charged > budget {
self.overflowed = true;
return;
}
self.bytes = charged;
if let Some(slot) = seam(&mut self.effects).get_mut(index) {
*slot = entry;
}
}
fn push<T>(
&mut self,
seam: fn(&mut CapsuleEffects) -> &mut Vec<T>,
entry: T,
weight: usize,
budget: usize,
) {
let list = seam(&mut self.effects);
if list.len() >= MAX_EFFECTS_PER_SEAM {
self.overflowed = true;
return;
}
let charged = self.bytes.saturating_add(weight);
if charged > budget {
self.overflowed = true;
return;
}
self.bytes = charged;
let list = seam(&mut self.effects);
list.push(entry);
}
}
fn json_weight(payload: &serde_json::Value) -> usize {
struct Counter(usize);
impl std::io::Write for Counter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(buf.len());
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut counter = Counter(0);
let _ = serde_json::to_writer(&mut counter, payload);
counter.0
}
const fn body_weight(body: &CapsuleBody) -> usize {
match body {
CapsuleBody::Absent | CapsuleBody::Skipped { .. } => 0,
CapsuleBody::Text(text) => text.len(),
CapsuleBody::Base64(encoded) => encoded.len(),
}
}
fn clamp_body(body: CapsuleBody, max_body_bytes: usize) -> CapsuleBody {
let len = body_weight(&body);
if len <= max_body_bytes {
return body;
}
CapsuleBody::Skipped {
declared_len: Some(len),
}
}
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";
fn headers_weight(headers: &[(String, String)]) -> usize {
headers.iter().fold(0, |total, (name, value)| {
total.saturating_add(name.len()).saturating_add(value.len())
})
}
const HTTP_BODY_SKIPPED_NOTE: &str = "an outbound HTTP body exceeded `[failure_capture] max_body_bytes` and was not captured; \
replaying would drive the handler with an empty body";
const MAIL_BODY_SKIPPED_NOTE: &str = "a mail body exceeded `[failure_capture] max_body_bytes` and was not captured; replay \
cannot tell whether the message contents still match";
const UNFINISHED_EFFECT_NOTE: &str = "an effect was still in flight when the recording ended (a cancelled future — a losing \
`tokio::select!` branch, a timeout), so its outcome was never recorded";
const TENANT_CONFLICT_NOTE: &str = "the run resolved more than one tenant; a capsule records a single tenant context, so this \
one cannot reproduce the tenant the failing run saw";
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>,
job: OnceLock<CapsuleJob>,
peer_addr: OnceLock<std::net::SocketAddr>,
db: Mutex<DbBuffer>,
effects: Mutex<EffectBuffer>,
notes: Mutex<Vec<String>>,
truncated: AtomicBool,
closed: AtomicBool,
state_cache: AtomicBool,
after_commit: std::sync::atomic::AtomicUsize,
}
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(),
job: OnceLock::new(),
peer_addr: OnceLock::new(),
db: Mutex::new(DbBuffer::default()),
effects: Mutex::new(EffectBuffer::default()),
notes: Mutex::new(Vec::new()),
truncated: AtomicBool::new(false),
closed: AtomicBool::new(false),
state_cache: AtomicBool::new(false),
after_commit: std::sync::atomic::AtomicUsize::new(0),
}
}
pub(crate) fn note_state_cache(&self, present: bool) {
if present {
self.state_cache.store(true, Ordering::Relaxed);
}
}
#[must_use]
pub(crate) fn had_state_cache(&self) -> bool {
self.state_cache.load(Ordering::Relaxed)
}
#[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 set_job_entry(&self, job: CapsuleJob) {
let _ = self.job.set(job);
}
#[must_use]
pub fn job_entry(&self) -> Option<CapsuleJob> {
self.job.get().cloned()
}
fn with_effects<R>(&self, f: impl FnOnce(&mut EffectBuffer) -> R) -> Option<R> {
let (result, overflowed) = {
let mut buffer = self.effects.lock().ok()?;
let result = f(&mut buffer);
(result, buffer.overflowed())
};
if overflowed {
self.mark_truncated();
}
Some(result)
}
#[must_use]
pub fn reserve_http(&self) -> Option<usize> {
let budget = self.settings.max_capsule_bytes;
self.with_effects(|buffer| {
buffer.reserve(|effects| &mut effects.http, HttpEffect::pending(), budget)
})
.flatten()
}
pub fn fill_http(&self, index: usize, mut effect: HttpEffect) {
let cap = self.settings.max_body_bytes;
let skipped = |body: &CapsuleBody| {
matches!(body, CapsuleBody::Skipped { .. }) || body_weight(body) > cap
};
let request_over = skipped(&effect.request_body);
let response_over = skipped(&effect.response_body);
effect.request_body = clamp_body(effect.request_body, cap);
effect.response_body = clamp_body(effect.response_body, cap);
if request_over || response_over {
self.note(HTTP_BODY_SKIPPED_NOTE);
self.mark_truncated();
}
let weight = effect
.url
.len()
.saturating_add(effect.final_url.as_ref().map_or(0, String::len))
.saturating_add(headers_weight(&effect.request_headers))
.saturating_add(headers_weight(&effect.response_headers))
.saturating_add(body_weight(&effect.request_body))
.saturating_add(body_weight(&effect.response_body));
let budget = self.settings.max_capsule_bytes;
let _ = self.with_effects(|buffer| {
buffer.fill(|effects| &mut effects.http, index, effect, weight, budget);
});
}
#[must_use]
pub fn reserve_job_enqueue(&self) -> Option<usize> {
let budget = self.settings.max_capsule_bytes;
self.with_effects(|buffer| {
buffer.reserve(|effects| &mut effects.jobs, JobEffect::pending(), budget)
})
.flatten()
}
pub fn fill_job_enqueue(&self, index: usize, effect: JobEffect) {
let weight = effect
.name
.len()
.saturating_add(json_weight(&effect.payload))
.saturating_add(effect.error.as_ref().map_or(0, String::len));
let budget = self.settings.max_capsule_bytes;
let _ = self.with_effects(|buffer| {
buffer.fill(|effects| &mut effects.jobs, index, effect, weight, budget);
});
}
#[must_use]
pub fn reserve_mail(&self) -> Option<usize> {
let budget = self.settings.max_capsule_bytes;
self.with_effects(|buffer| {
buffer.reserve(|effects| &mut effects.mail, MailEffect::pending(), budget)
})
.flatten()
}
pub fn fill_mail(&self, index: usize, mut effect: MailEffect) {
let cap = self.settings.max_body_bytes;
let over = body_weight(&effect.body) > cap || body_weight(&effect.alternate_body) > cap;
effect.body = clamp_body(effect.body, cap);
effect.alternate_body = clamp_body(effect.alternate_body, cap);
if over {
self.note(MAIL_BODY_SKIPPED_NOTE);
self.mark_truncated();
}
let weight = effect
.subject
.len()
.saturating_add(body_weight(&effect.body))
.saturating_add(body_weight(&effect.alternate_body))
.saturating_add(headers_weight(&effect.extra_headers))
.saturating_add(
effect
.to
.iter()
.fold(0, |total: usize, to| total.saturating_add(to.len())),
)
.saturating_add(effect.from.as_ref().map_or(0, String::len))
.saturating_add(effect.reply_to.as_ref().map_or(0, String::len))
.saturating_add(effect.list_unsubscribe.as_ref().map_or(0, String::len))
.saturating_add(effect.error.as_ref().map_or(0, String::len))
.saturating_add(
effect
.attachments
.iter()
.fold(0, |total: usize, attachment| {
total
.saturating_add(attachment.filename.len())
.saturating_add(attachment.content_type.len())
.saturating_add(attachment.sha256.len())
}),
);
let budget = self.settings.max_capsule_bytes;
let _ = self.with_effects(|buffer| {
buffer.fill(|effects| &mut effects.mail, index, effect, weight, budget);
});
}
#[must_use]
pub fn reserve_cache(&self) -> Option<usize> {
let budget = self.settings.max_capsule_bytes;
self.with_effects(|buffer| {
buffer.reserve(|effects| &mut effects.cache, CacheEffect::pending(), budget)
})
.flatten()
}
pub fn fill_cache(&self, index: usize, effect: CacheEffect) {
let weight = cache_weight(&effect);
let budget = self.settings.max_capsule_bytes;
let _ = self.with_effects(|buffer| {
buffer.fill(|effects| &mut effects.cache, index, effect, weight, budget);
});
}
pub fn record_cache(&self, effect: CacheEffect) {
let weight = cache_weight(&effect);
let budget = self.settings.max_capsule_bytes;
let _ = self.with_effects(|buffer| {
buffer.push(|effects| &mut effects.cache, effect, weight, budget);
});
}
pub fn record_tenant(&self, effect: TenantEffect) {
let conflicting = self
.with_effects(|buffer| match &buffer.effects.tenant {
Some(existing) if *existing == effect => false,
Some(_) => true,
None => {
buffer.effects.tenant = Some(effect);
false
}
})
.unwrap_or(false);
if conflicting {
self.note(TENANT_CONFLICT_NOTE);
self.mark_truncated();
}
}
pub fn record_random(&self, bytes: Vec<u8>) {
let weight = bytes.len();
let budget = self.settings.max_capsule_bytes;
let _ = self.with_effects(|buffer| {
buffer.push(
|effects| &mut effects.random,
RandomEffect { bytes },
weight,
budget,
);
});
}
#[must_use]
pub fn effects_snapshot(&self) -> CapsuleEffects {
let (effects, unfinished) = self.effects.lock().map_or_else(
|_| {
self.mark_truncated();
(CapsuleEffects::default(), false)
},
|buffer| (buffer.effects().clone(), buffer.has_unfinished()),
);
if unfinished {
self.note(UNFINISHED_EFFECT_NOTE);
self.mark_truncated();
}
effects
}
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(crate) fn after_commit_registered(&self) {
self.after_commit.fetch_add(1, Ordering::SeqCst);
}
pub(crate) fn after_commit_dropped(&self) {
self.after_commit.fetch_sub(1, Ordering::SeqCst);
}
pub(crate) fn after_commit_pending(&self) -> bool {
self.after_commit.load(Ordering::SeqCst) > 0
}
pub fn mark_truncated(&self) {
self.truncated.store(true, Ordering::Relaxed);
}
#[must_use]
pub fn is_truncated(&self) -> bool {
self.truncated.load(Ordering::Relaxed)
}
}
fn cache_weight(effect: &CacheEffect) -> usize {
effect.key().len().saturating_add(match effect {
CacheEffect::Get { value, .. } => value.as_ref().map_or(0, String::len),
CacheEffect::Insert { value, .. } => value.len(),
CacheEffect::Invalidate { error, .. } | CacheEffect::InvalidateNamespace { error, .. } => {
error.as_ref().map_or(0, |error| error.reason.len())
}
CacheEffect::Clear => 0,
})
}
#[derive(Clone, Debug)]
pub struct CaptureHandle(Arc<CaptureScope>);
impl CaptureHandle {
#[must_use]
pub const fn scope(&self) -> &Arc<CaptureScope> {
&self.0
}
}
#[cfg(feature = "reporting")]
pub async fn capture_job<T>(
name: &str,
payload: &serde_json::Value,
settings: Arc<CaptureSettings>,
filter: Arc<ParameterFilter>,
run: impl Future<Output = T>,
outcome: impl FnOnce(&T) -> Option<crate::capsule::schema::CapsuleOutcome>,
) -> T {
let id = job_scope_id();
let scope = Arc::new(CaptureScope::new(id, settings, filter));
scope.set_job_entry(CapsuleJob {
name: name.to_owned(),
payload: payload.clone(),
});
scope.set_request(RawRequest {
method: "JOB".to_owned(),
uri: format!("/jobs/{name}")
.parse()
.unwrap_or_else(|_| axum::http::Uri::from_static("/jobs")),
version: axum::http::Version::HTTP_11,
headers: axum::http::HeaderMap::new(),
#[allow(
clippy::literal_string_with_formatting_args,
reason = "`{name}` is Autumn's route-template syntax, not an interpolation"
)]
route: Some("/jobs/{name}".to_owned()),
});
register(&scope);
let guard = RegistryGuard(Arc::clone(&scope));
let result = CAPSULE_SCOPE.scope(Arc::clone(&scope), run).await;
drop(guard);
if let Some(outcome) = outcome(&result) {
let scope = Arc::clone(&scope);
let _ = crate::time::spawn_blocking(move || {
let _ = crate::capsule::persist(&scope, outcome);
})
.await;
}
result
}
#[cfg(feature = "reporting")]
fn job_scope_id() -> String {
use crate::entropy::Entropy as _;
format!("job-{}", crate::entropy::OsEntropy.uuid_v4().simple())
}
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>,
state_cache: Option<StateCacheProbe>,
}
type StateCacheProbe = Arc<dyn Fn() -> bool + Send + Sync>;
impl CaptureLayer {
#[must_use]
pub fn new(settings: CaptureSettings, filter: Arc<ParameterFilter>) -> Self {
Self {
settings: Arc::new(settings),
filter,
state_cache: None,
}
}
#[must_use]
pub(crate) fn with_state_cache_probe(mut self, probe: StateCacheProbe) -> Self {
self.state_cache = Some(probe);
self
}
}
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),
state_cache: self.state_cache.clone(),
}
}
}
#[derive(Clone)]
pub struct CaptureService<S> {
inner: S,
settings: Arc<CaptureSettings>,
filter: Arc<ParameterFilter>,
state_cache: Option<StateCacheProbe>,
}
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);
let state_cache = self.state_cache.clone();
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(probe) = state_cache {
scope.note_state_cache(probe());
}
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>()
.filter(|id| !id.is_inbound())
.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())
}
#[tokio::test]
async fn the_capture_layer_records_a_state_cache() {
for present in [true, false] {
let seen: Arc<Mutex<Option<CaptureHandle>>> = Arc::new(Mutex::new(None));
let inner_seen = Arc::clone(&seen);
let inner = tower::service_fn(move |req: Request<Body>| {
let seen = Arc::clone(&inner_seen);
async move {
if let Ok(mut slot) = seen.lock() {
*slot = req.extensions().get::<CaptureHandle>().cloned();
}
Ok::<_, std::convert::Infallible>(Response::new(Body::empty()))
}
});
let mut service = test_layer(CaptureSettings::default())
.with_state_cache_probe(Arc::new(move || present))
.layer(inner);
let _ = service
.call(Request::new(Body::empty()))
.await
.expect("infallible");
let handle = seen.lock().expect("slot").clone().expect("handle");
assert_eq!(handle.scope().had_state_cache(), present);
}
}
#[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_skipped_before_it_reached_the_recorder_still_truncates() {
let scope = CaptureScope::new(
"pre".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
);
let slot = scope.reserve_http().expect("a slot is available");
scope.fill_http(
slot,
HttpEffect {
method: "POST".to_owned(),
url: "https://api.example/upload".to_owned(),
request_body: CapsuleBody::Skipped {
declared_len: Some(50_000_000),
},
status: 200,
..Default::default()
},
);
let _ = scope.effects_snapshot();
assert!(
scope.is_truncated(),
"a capsule that cannot compare its own outbound body must say so"
);
assert!(
scope
.notes()
.iter()
.any(|note| note == HTTP_BODY_SKIPPED_NOTE),
"{:?}",
scope.notes()
);
}
#[test]
fn a_mail_body_too_large_to_record_marks_the_capsule_incomplete() {
let settings = CaptureSettings {
max_body_bytes: 8,
..CaptureSettings::default()
};
let scope = CaptureScope::new(
"mail".to_owned(),
Arc::new(settings),
Arc::new(ParameterFilter::new(&[], &[])),
);
let slot = scope.reserve_mail().expect("a slot is available");
scope.fill_mail(
slot,
MailEffect {
to: vec!["a@example.com".to_owned()],
subject: "Receipt".to_owned(),
body: CapsuleBody::Text("a body well past the cap".to_owned()),
..Default::default()
},
);
let effects = scope.effects_snapshot();
assert!(
matches!(effects.mail[0].body, CapsuleBody::Skipped { .. }),
"the oversized body is not kept: {:?}",
effects.mail[0].body
);
assert!(
scope.is_truncated(),
"and the capsule says so rather than replaying any body clean"
);
assert!(
scope
.notes()
.iter()
.any(|note| note == MAIL_BODY_SKIPPED_NOTE),
"{:?}",
scope.notes()
);
}
#[test]
fn an_oversized_alternate_mail_body_is_skipped_and_truncates() {
let settings = CaptureSettings {
max_body_bytes: 8,
..CaptureSettings::default()
};
let scope = CaptureScope::new(
"mail".to_owned(),
Arc::new(settings),
Arc::new(ParameterFilter::new(&[], &[])),
);
let slot = scope.reserve_mail().expect("a slot is available");
scope.fill_mail(
slot,
MailEffect {
to: vec!["a@example.com".to_owned()],
subject: "Receipt".to_owned(),
body: CapsuleBody::Text("short".to_owned()),
alternate_body: CapsuleBody::Text("<p>an html half past the cap</p>".to_owned()),
..Default::default()
},
);
let effects = scope.effects_snapshot();
assert!(
matches!(effects.mail[0].alternate_body, CapsuleBody::Skipped { .. }),
"{:?}",
effects.mail[0].alternate_body
);
assert!(scope.is_truncated());
}
#[test]
fn the_alternate_mail_body_is_charged_against_the_capsule_budget() {
let settings = CaptureSettings {
max_body_bytes: 1 << 20,
max_capsule_bytes: 256,
..CaptureSettings::default()
};
let scope = CaptureScope::new(
"mail".to_owned(),
Arc::new(settings),
Arc::new(ParameterFilter::new(&[], &[])),
);
let slot = scope.reserve_mail().expect("a slot is available");
scope.fill_mail(
slot,
MailEffect {
to: vec!["a@example.com".to_owned()],
subject: "Receipt".to_owned(),
body: CapsuleBody::Text("short".to_owned()),
alternate_body: CapsuleBody::Text("x".repeat(4096)),
..Default::default()
},
);
assert!(scope.is_truncated());
}
#[test]
fn attachment_metadata_is_charged_against_the_capsule_budget() {
let settings = CaptureSettings {
max_capsule_bytes: 256,
..CaptureSettings::default()
};
let scope = CaptureScope::new(
"mail".to_owned(),
Arc::new(settings),
Arc::new(ParameterFilter::new(&[], &[])),
);
let slot = scope.reserve_mail().expect("a slot is available");
scope.fill_mail(
slot,
MailEffect {
to: vec!["a@example.com".to_owned()],
subject: "Receipt".to_owned(),
body: CapsuleBody::Text("short".to_owned()),
attachments: vec![crate::capsule::schema::MailAttachmentEffect {
filename: "a.pdf".to_owned(),
content_type: "x".repeat(4096),
len: 1,
sha256: String::new(),
}],
..Default::default()
},
);
assert!(scope.is_truncated());
}
#[test]
fn an_enqueue_error_is_charged_against_the_capsule_budget() {
let settings = CaptureSettings {
max_capsule_bytes: 256,
..CaptureSettings::default()
};
let scope = CaptureScope::new(
"job".to_owned(),
Arc::new(settings),
Arc::new(ParameterFilter::new(&[], &[])),
);
let slot = scope.reserve_job_enqueue().expect("a slot is available");
scope.fill_job_enqueue(
slot,
JobEffect {
name: "j".to_owned(),
payload: serde_json::json!({}),
error: Some("x".repeat(4096)),
..Default::default()
},
);
assert!(scope.is_truncated());
}
#[test]
fn an_effect_reserved_but_never_completed_marks_the_capsule_incomplete() {
let scope = CaptureScope::new(
"sel".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
);
let cancelled = scope.reserve_http().expect("a slot is available");
let finished = scope.reserve_http().expect("and a second one");
scope.fill_http(
finished,
HttpEffect {
method: "GET".to_owned(),
url: "https://a.example/one".to_owned(),
request_headers: Vec::new(),
request_body: CapsuleBody::Absent,
status: 200,
response_headers: Vec::new(),
response_body: CapsuleBody::Absent,
error: None,
..Default::default()
},
);
let _ = cancelled;
let effects = scope.effects_snapshot();
assert_eq!(effects.http.len(), 2, "both slots are still on the tape");
assert!(
scope.is_truncated(),
"a recording with an unfinished effect must not present as complete"
);
assert!(
scope
.notes()
.iter()
.any(|note| note == UNFINISHED_EFFECT_NOTE),
"and must say why: {:?}",
scope.notes()
);
}
#[test]
fn effects_that_all_completed_leave_the_capsule_whole() {
let scope = CaptureScope::new(
"ok".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&[], &[])),
);
let slot = scope.reserve_http().expect("a slot is available");
scope.fill_http(
slot,
HttpEffect {
method: "GET".to_owned(),
url: "https://a.example/one".to_owned(),
request_headers: Vec::new(),
request_body: CapsuleBody::Absent,
status: 200,
response_headers: Vec::new(),
response_body: CapsuleBody::Absent,
error: None,
..Default::default()
},
);
let _ = scope.effects_snapshot();
assert!(!scope.is_truncated(), "{:?}", scope.notes());
}
#[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"
);
}
}