#![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 chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const CAPSULE_FORMAT_VERSION: u32 = 2;
#[derive(Debug)]
pub enum CapsuleError {
Io(std::io::Error),
Malformed(serde_json::Error),
VersionMismatch {
found: u32,
expected: u32,
},
}
impl std::fmt::Display for CapsuleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "failed to read capsule: {error}"),
Self::Malformed(error) => write!(f, "capsule is not a valid capsule document: {error}"),
Self::VersionMismatch { found, expected } => write!(
f,
"capsule format version {found} is not supported by this build \
(expected {expected}); re-record the capsule with a matching Autumn build"
),
}
}
}
impl std::error::Error for CapsuleError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capsule {
pub format_version: u32,
pub id: String,
pub captured_at: DateTime<Utc>,
pub autumn_version: String,
#[serde(default)]
pub app: AppInfo,
pub request: CapsuleRequest,
pub outcome: CapsuleOutcome,
#[serde(default)]
pub clock: Vec<DateTime<Utc>>,
#[serde(default)]
pub clock_monotonic_us: Vec<u64>,
#[serde(default)]
pub db: Option<CapsuleDb>,
#[serde(default)]
pub db_roles: Vec<String>,
#[serde(default)]
pub truncated: bool,
#[serde(default)]
pub notes: Vec<String>,
}
impl Capsule {
pub fn from_json(json: &str) -> Result<Self, CapsuleError> {
let capsule: Self = serde_json::from_str(json).map_err(CapsuleError::Malformed)?;
if capsule.format_version == CAPSULE_FORMAT_VERSION {
Ok(capsule)
} else {
Err(CapsuleError::VersionMismatch {
found: capsule.format_version,
expected: CAPSULE_FORMAT_VERSION,
})
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppInfo {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub debug_assertions: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleRequest {
pub method: String,
pub uri: String,
#[serde(default)]
pub route: Option<String>,
pub http_version: String,
pub headers: Vec<(String, String)>,
#[serde(default)]
pub binary_headers: Vec<(String, String)>,
pub body: CapsuleBody,
#[serde(default)]
pub redacted_keys: Vec<String>,
#[serde(default)]
pub peer_addr: Option<std::net::SocketAddr>,
#[serde(default)]
pub client_addr: Option<std::net::IpAddr>,
#[serde(default)]
pub client_host: Option<String>,
#[serde(default)]
pub client_scheme: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapsuleBody {
Absent,
Text(String),
Base64(String),
Skipped {
#[serde(default)]
declared_len: Option<usize>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapsuleOutcome {
Status {
code: u16,
message: String,
#[serde(default)]
problem_type: Option<String>,
},
Panic {
status: u16,
payload: String,
#[serde(default)]
backtrace: Option<String>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleDb {
pub connections: Vec<ConnectionTape>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConnectionTape {
pub id: u64,
#[serde(default = "default_tape_role")]
pub role: String,
#[serde(default)]
pub prologue: Vec<Exchange>,
#[serde(default)]
pub statements: Vec<Exchange>,
#[serde(default)]
pub catalog: Vec<Exchange>,
#[serde(default)]
pub exchanges: Vec<Exchange>,
}
fn default_tape_role() -> String {
"primary".to_owned()
}
pub const TAPE_ROLE_REPLICA: &str = "replica";
pub const TAPE_ROLE_PRIMARY: &str = "primary";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExchangeProtocol {
Simple,
Extended,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Exchange {
pub protocol: ExchangeProtocol,
pub sql: String,
#[serde(default)]
pub binds: Vec<BindValue>,
#[serde(default, with = "b64")]
pub response: Vec<u8>,
#[serde(default)]
pub row_count: usize,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BindValue {
Null,
Value(#[serde(with = "b64")] Vec<u8>),
Masked,
}
pub(crate) mod b64 {
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize as _, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let encoded = String::deserialize(deserializer)?;
STANDARD
.decode(encoded.as_bytes())
.map_err(serde::de::Error::custom)
}
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use super::{
AppInfo, BindValue, CAPSULE_FORMAT_VERSION, Capsule, CapsuleBody, CapsuleDb,
CapsuleOutcome, CapsuleRequest, ConnectionTape, Exchange, ExchangeProtocol,
};
#[must_use]
pub fn exchange(sql: &str, binds: Vec<BindValue>, response: Vec<u8>) -> Exchange {
Exchange {
protocol: ExchangeProtocol::Extended,
sql: sql.to_owned(),
binds,
response,
row_count: 0,
error: None,
}
}
#[must_use]
pub fn simple_exchange(sql: &str, response: Vec<u8>) -> Exchange {
Exchange {
protocol: ExchangeProtocol::Simple,
sql: sql.to_owned(),
binds: Vec::new(),
response,
row_count: 0,
error: None,
}
}
#[must_use]
pub const fn connection_tape(id: u64, exchanges: Vec<Exchange>) -> ConnectionTape {
ConnectionTape {
id,
role: String::new(),
prologue: Vec::new(),
statements: Vec::new(),
catalog: Vec::new(),
exchanges,
}
}
#[must_use]
pub fn request(method: &str, uri: &str) -> CapsuleRequest {
CapsuleRequest {
method: method.to_owned(),
uri: uri.to_owned(),
route: None,
http_version: "HTTP/1.1".to_owned(),
headers: Vec::new(),
binary_headers: Vec::new(),
body: CapsuleBody::Absent,
redacted_keys: Vec::new(),
peer_addr: None,
client_addr: None,
client_host: None,
client_scheme: None,
}
}
#[must_use]
pub fn capsule(request: CapsuleRequest, outcome: CapsuleOutcome) -> Capsule {
Capsule {
format_version: CAPSULE_FORMAT_VERSION,
id: "fixture".to_owned(),
captured_at: chrono::Utc::now(),
autumn_version: env!("CARGO_PKG_VERSION").to_owned(),
app: AppInfo::default(),
request,
outcome,
clock: Vec::new(),
clock_monotonic_us: Vec::new(),
db: None,
db_roles: Vec::new(),
truncated: false,
notes: Vec::new(),
}
}
#[must_use]
pub fn with_connections(mut capsule: Capsule, connections: Vec<ConnectionTape>) -> Capsule {
capsule.db = Some(CapsuleDb { connections });
capsule
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Capsule {
let mut capsule = test_support::capsule(
test_support::request("POST", "/orders?page=2"),
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: Some("https://autumn.dev/problems/internal".to_owned()),
},
);
capsule.id = "req-1".to_owned();
capsule.request.headers = vec![
("content-type".to_owned(), "application/json".to_owned()),
("authorization".to_owned(), "[FILTERED]".to_owned()),
];
capsule.request.body = CapsuleBody::Text("{\"a\":1}".to_owned());
capsule.request.redacted_keys = vec!["header:authorization".to_owned()];
capsule.clock = vec![Utc::now()];
capsule.notes = vec!["db capture unavailable".to_owned()];
capsule = test_support::with_connections(
capsule,
vec![test_support::connection_tape(
1,
vec![test_support::exchange(
"SELECT 1",
vec![
BindValue::Null,
BindValue::Value(vec![0xDE, 0xAD, 0xBE, 0xEF]),
BindValue::Masked,
],
vec![b'Z', 0, 0, 0, 5, b'I'],
)],
)],
);
capsule
}
#[test]
fn capsule_json_roundtrips_v1() {
let capsule = sample();
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let parsed = Capsule::from_json(&json).expect("capsule round-trips");
assert_eq!(parsed.format_version, CAPSULE_FORMAT_VERSION);
assert_eq!(parsed.id, "req-1");
assert_eq!(parsed.request, capsule.request);
assert_eq!(parsed.outcome, capsule.outcome);
assert_eq!(parsed.clock, capsule.clock);
assert_eq!(parsed.db, capsule.db);
assert_eq!(parsed.notes, capsule.notes);
let db = parsed.db.expect("db tape present");
let exchange = db
.connections
.first()
.and_then(|tape| tape.exchanges.first())
.expect("one exchange");
assert_eq!(exchange.response, vec![b'Z', 0, 0, 0, 5, b'I']);
assert_eq!(
exchange.binds,
vec![
BindValue::Null,
BindValue::Value(vec![0xDE, 0xAD, 0xBE, 0xEF]),
BindValue::Masked,
]
);
}
#[test]
fn capsule_with_unknown_future_field_still_loads() {
let json = serde_json::to_value(sample()).expect("capsule serializes");
let mut object = match json {
serde_json::Value::Object(map) => map,
other => panic!("capsule must serialize to an object, got {other}"),
};
object.insert("future_knob".to_owned(), serde_json::json!({"a": [1, 2]}));
let json = serde_json::Value::Object(object).to_string();
let parsed = Capsule::from_json(&json)
.expect("a capsule carrying an unknown field must still load (forward compatibility)");
assert_eq!(parsed.id, "req-1");
}
#[test]
fn load_rejects_format_version_mismatch() {
let mut capsule = sample();
capsule.format_version = CAPSULE_FORMAT_VERSION + 1;
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let error = Capsule::from_json(&json)
.expect_err("a future format version must be rejected, not silently read");
match error {
CapsuleError::VersionMismatch { found, expected } => {
assert_eq!(found, CAPSULE_FORMAT_VERSION + 1);
assert_eq!(expected, CAPSULE_FORMAT_VERSION);
}
other => panic!("expected a version mismatch, got {other}"),
}
assert!(
CapsuleError::VersionMismatch {
found: 99,
expected: CAPSULE_FORMAT_VERSION,
}
.to_string()
.contains("format version 99"),
"the mismatch message must name the offending version"
);
}
}