#![deny(missing_docs)]
use std::sync::{Arc, Mutex};
pub use osproxy_spi::HttpMethod;
#[derive(Clone, Copy, Debug)]
pub struct CaptureRecord<'a> {
pub request_id: &'a str,
pub method: HttpMethod,
pub path: &'a str,
pub query: Option<&'a str>,
pub headers: &'a [(String, String)],
pub body: &'a [u8],
pub response_status: u16,
pub response_body: &'a [u8],
}
pub trait Capture: Send + Sync {
fn enabled(&self) -> bool {
true
}
fn capture(&self, record: &CaptureRecord<'_>);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct NoCapture;
impl Capture for NoCapture {
fn enabled(&self) -> bool {
false
}
fn capture(&self, _record: &CaptureRecord<'_>) {}
}
#[must_use]
pub fn without_authorization(headers: &[(String, String)]) -> Vec<(String, String)> {
headers
.iter()
.filter(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
.cloned()
.collect()
}
#[derive(Clone, Copy, Debug, Default)]
pub struct RedactingCapture<C> {
inner: C,
}
impl<C> RedactingCapture<C> {
pub fn new(inner: C) -> Self {
Self { inner }
}
}
impl<C: Capture> Capture for RedactingCapture<C> {
fn enabled(&self) -> bool {
self.inner.enabled()
}
fn capture(&self, record: &CaptureRecord<'_>) {
let safe_headers = without_authorization(record.headers);
let redacted = CaptureRecord {
headers: &safe_headers,
..*record
};
self.inner.capture(&redacted);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OwnedCapture {
pub request_id: String,
pub method: HttpMethod,
pub path: String,
pub query: Option<String>,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub response_status: u16,
pub response_body: Vec<u8>,
}
impl OwnedCapture {
#[must_use]
pub fn from_record(record: &CaptureRecord<'_>) -> Self {
Self {
request_id: record.request_id.to_owned(),
method: record.method,
path: record.path.to_owned(),
query: record.query.map(str::to_owned),
headers: record.headers.to_vec(),
body: record.body.to_vec(),
response_status: record.response_status,
response_body: record.response_body.to_vec(),
}
}
}
#[derive(Clone, Default, Debug)]
pub struct MemoryCapture {
records: Arc<Mutex<Vec<OwnedCapture>>>,
}
impl MemoryCapture {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn records(&self) -> Vec<OwnedCapture> {
self.records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl Capture for MemoryCapture {
fn capture(&self, record: &CaptureRecord<'_>) {
self.records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(OwnedCapture::from_record(record));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record(headers: &[(String, String)]) -> CaptureRecord<'_> {
CaptureRecord {
request_id: "r1",
method: HttpMethod::Post,
path: "/orders/_doc",
query: None,
headers,
body: br#"{"tenant_id":"acme"}"#,
response_status: 201,
response_body: b"{}",
}
}
#[test]
fn the_default_capture_is_off() {
assert!(!NoCapture.enabled());
NoCapture.capture(&record(&[])); }
#[test]
fn memory_capture_keeps_full_fidelity_records() {
let cap = MemoryCapture::new();
let headers = vec![("content-type".to_owned(), "application/json".to_owned())];
cap.capture(&record(&headers));
let got = cap.records();
assert_eq!(got.len(), 1);
assert_eq!(got[0].path, "/orders/_doc");
assert_eq!(got[0].body, br#"{"tenant_id":"acme"}"#);
assert_eq!(got[0].response_status, 201);
}
#[test]
fn redacting_capture_strips_only_the_authorization_header() {
let inner = MemoryCapture::new();
let cap = RedactingCapture::new(inner.clone());
let headers = vec![
("Authorization".to_owned(), "Bearer s3cret".to_owned()),
("x-tenant".to_owned(), "acme".to_owned()),
];
cap.capture(&record(&headers));
let got = inner.records();
assert_eq!(got.len(), 1);
assert!(
!got[0]
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"credential redacted: {:?}",
got[0].headers
);
assert_eq!(got[0].body, br#"{"tenant_id":"acme"}"#);
assert!(got[0].headers.iter().any(|(k, _)| k == "x-tenant"));
}
}