#![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::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use chrono::{DateTime, TimeDelta, Utc};
use crate::capsule::capture::CaptureScope;
use crate::capsule::redact::RedactedValues;
use crate::capsule::schema::{
AppInfo, CAPSULE_FORMAT_VERSION, Capsule, CapsuleError, CapsuleOutcome,
};
use crate::log::filter::ParameterFilter;
const PRUNE_GRACE: TimeDelta = TimeDelta::minutes(1);
const IDENTITY_FILTERED_NOTE: &str = "the resolved client identity was suppressed because a header it derives from is in \
`[log] filter_parameters`: this capsule cannot reproduce the identity the handler saw";
fn grace_allowance(keep: usize) -> usize {
keep.max(1)
}
static PINNED_FOR_REPORTING: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<PathBuf, usize>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
#[derive(Debug)]
pub(crate) struct ReportingPin(PathBuf);
impl Drop for ReportingPin {
fn drop(&mut self) {
if let Ok(mut pinned) = PINNED_FOR_REPORTING.lock()
&& let Some(count) = pinned.get_mut(&self.0)
{
*count = count.saturating_sub(1);
if *count == 0 {
pinned.remove(&self.0);
}
}
}
}
pub(crate) fn pin_for_reporting(path: &Path) -> ReportingPin {
if let Ok(mut pinned) = PINNED_FOR_REPORTING.lock() {
let count = pinned.entry(path.to_path_buf()).or_insert(0);
*count = count.saturating_add(1);
}
ReportingPin(path.to_path_buf())
}
fn is_pinned_for_reporting(path: &Path) -> bool {
PINNED_FOR_REPORTING
.lock()
.is_ok_and(|pinned| pinned.contains_key(path))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapsuleRef {
pub id: String,
pub path: PathBuf,
}
#[must_use]
pub fn capsule_dir(dir: &str) -> PathBuf {
PathBuf::from(dir)
}
#[must_use]
pub fn persist(scope: &CaptureScope, outcome: CapsuleOutcome) -> Option<CapsuleRef> {
persist_pinned(scope, outcome).map(|(reference, _pin)| reference)
}
static RETENTION: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[must_use]
pub(crate) fn persist_pinned(
scope: &CaptureScope,
outcome: CapsuleOutcome,
) -> Option<(CapsuleRef, ReportingPin)> {
let capsule = assemble(scope, outcome)?;
let settings = scope.settings();
let dir = capsule_dir(&settings.dir);
let json = match serde_json::to_vec_pretty(&capsule) {
Ok(json) => json,
Err(error) => {
tracing::error!(%error, "failure capsule could not be serialized; dropping it");
return None;
}
};
let path = dir.join(file_name(&capsule));
let _retention = RETENTION.lock();
prune(
&dir,
retained_before_write(settings.max_capsules),
Utc::now(),
);
let pin = pin_for_reporting(&path);
if let Err(error) = write_atomically(&dir, &path, &json) {
tracing::error!(
%error,
path = %path.display(),
"failure capsule could not be written; the failure itself is still reported"
);
return None;
}
Some((
CapsuleRef {
id: capsule.id,
path,
},
pin,
))
}
fn assemble(scope: &CaptureScope, outcome: CapsuleOutcome) -> Option<Capsule> {
let raw = scope.raw_request()?;
let raw_body = scope.captured_body();
if let Some(note) = scope.body_note() {
scope.note(note);
scope.mark_truncated();
}
let uri_scheme = raw.uri.scheme_str().map(ToOwned::to_owned);
let (mut request, redacted, body_notes) =
crate::capsule::redact::redact_request(raw, &raw_body, scope.filter());
request.peer_addr = scope.peer_addr();
if let Some(identity) = scope.client_identity() {
let filter = scope.filter();
let mut suppressed = false;
let addr_from_peer =
identity.addr.is_some() && identity.addr == scope.peer_addr().map(|peer| peer.ip());
if !addr_from_peer && identity_source_is_filtered(filter, &["x-forwarded-for", "x-real-ip"])
{
suppressed |= identity.addr.is_some();
} else {
request.client_addr = identity.addr;
}
if identity_source_is_filtered(filter, &["x-forwarded-host", "host"]) {
suppressed |= identity.host.is_some();
} else {
request.client_host.clone_from(&identity.host);
}
let scheme_from_uri = identity.scheme.is_some() && identity.scheme == uri_scheme;
if !scheme_from_uri && identity_source_is_filtered(filter, &["x-forwarded-proto"]) {
suppressed |= identity.scheme.is_some();
} else {
request.client_scheme.clone_from(&identity.scheme);
}
if suppressed {
scope.note(IDENTITY_FILTERED_NOTE);
scope.mark_truncated();
}
}
for note in body_notes {
scope.note(note);
}
let mut db = scope.db_snapshot();
if let Some(db) = db.as_mut() {
for tape in &mut db.connections {
for exchange in tape
.prologue
.iter_mut()
.chain(tape.statements.iter_mut())
.chain(tape.catalog.iter_mut())
.chain(tape.exchanges.iter_mut())
{
crate::capsule::redact::mask_binds(&mut exchange.binds, &redacted);
if let Some(error) = exchange.error.as_mut() {
*error = crate::capsule::redact::mask_echoes(error, &redacted);
}
}
}
}
let settings = scope.settings();
Some(Capsule {
format_version: CAPSULE_FORMAT_VERSION,
id: scope.id().to_owned(),
captured_at: Utc::now(),
autumn_version: env!("CARGO_PKG_VERSION").to_owned(),
app: AppInfo {
name: settings.app_name.clone(),
profile: settings.profile.clone(),
debug_assertions: Some(cfg!(debug_assertions)),
},
request,
outcome: scrub_outcome(outcome, &redacted),
clock: scope.clock_readings(),
clock_monotonic_us: scope
.monotonic_readings()
.into_iter()
.map(|offset| u64::try_from(offset.as_micros()).unwrap_or(u64::MAX))
.collect(),
db,
db_roles: settings.db_roles.clone(),
truncated: scope.is_truncated(),
notes: scope.notes(),
})
}
fn scrub_outcome(outcome: CapsuleOutcome, redacted: &RedactedValues) -> CapsuleOutcome {
use crate::capsule::redact::mask_echoes;
match outcome {
CapsuleOutcome::Status {
code,
message,
problem_type,
} => CapsuleOutcome::Status {
code,
message: mask_echoes(&message, redacted),
problem_type,
},
CapsuleOutcome::Panic {
status,
payload,
backtrace,
} => CapsuleOutcome::Panic {
status,
payload: mask_echoes(&payload, redacted),
backtrace: backtrace.map(|frames| mask_echoes(&frames, redacted)),
},
}
}
fn file_name(capsule: &Capsule) -> String {
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
let stamp = capsule.captured_at.format("%Y%m%dT%H%M%S%.6f");
let id = sanitize_id(&capsule.id);
format!("{stamp}-{sequence:06}-{id}.json")
}
fn sanitize_id(id: &str) -> String {
let sanitized: String = id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
if sanitized.is_empty() {
"capsule".to_owned()
} else {
sanitized
}
}
fn write_atomically(dir: &Path, path: &Path, json: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
let temp = temp_path(path);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
{
let mut file = options.open(&temp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
file.write_all(json)?;
file.sync_all()?;
}
match std::fs::rename(&temp, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = std::fs::remove_file(&temp);
Err(error)
}
}
}
fn temp_path(path: &Path) -> PathBuf {
let nonce = uuid::Uuid::new_v4().simple().to_string();
path.with_extension(format!("json.{nonce}.tmp"))
}
fn retained_before_write(max_capsules: usize) -> usize {
max_capsules.max(1).saturating_sub(1)
}
fn prune(dir: &Path, keep: usize, now: DateTime<Utc>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut names: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| capsule_stamp(path).is_some())
.collect();
if names.len() <= keep {
return;
}
names.sort();
let excess = names.len().saturating_sub(keep);
let mut allowance = grace_allowance(keep);
for path in names.into_iter().take(excess).rev() {
if is_pinned_for_reporting(&path) {
continue;
}
if allowance > 0 && written_within_grace(&path, now) {
allowance = allowance.saturating_sub(1);
continue;
}
if let Err(error) = std::fs::remove_file(&path) {
tracing::warn!(
%error,
path = %path.display(),
"failure capsule could not be pruned"
);
}
}
}
fn written_within_grace(path: &Path, now: DateTime<Utc>) -> bool {
capsule_stamp(path)
.is_some_and(|written| now.signed_duration_since(written.and_utc()) < PRUNE_GRACE)
}
fn identity_source_is_filtered(filter: &ParameterFilter, sources: &[&str]) -> bool {
sources.iter().any(|source| filter.matches_key(source))
}
fn capsule_stamp(path: &Path) -> Option<chrono::NaiveDateTime> {
let name = path.file_name()?.to_str()?.strip_suffix(".json")?;
let (stamp, rest) = name.split_once('-')?;
let (sequence, id) = rest.split_once('-')?;
if sequence.len() != 6 || !sequence.bytes().all(|b| b.is_ascii_digit()) || id.is_empty() {
return None;
}
chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%dT%H%M%S%.f").ok()
}
pub fn load_capsule(path: &Path) -> Result<Capsule, CapsuleError> {
let json = std::fs::read_to_string(path).map_err(CapsuleError::Io)?;
Capsule::from_json(&json)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pruning_never_deletes_files_the_capsule_writer_did_not_create() {
let dir = tempfile::tempdir().expect("tempdir");
let foreign = [
"state.json",
"aaa.json",
"20200101T000000.000000-boom.json",
"20200101T000000.000000-01-boom.json",
"notastamp-000001-boom.json",
];
for name in foreign {
std::fs::write(dir.path().join(name), b"{}").expect("write");
}
let capsule = dir.path().join("20200101T000000.000000-000001-aaa.json");
std::fs::write(&capsule, b"{}").expect("write");
prune(dir.path(), 0, Utc::now());
assert!(!capsule.exists(), "the real capsule is over the cap");
for name in foreign {
assert!(
dir.path().join(name).exists(),
"{name} was not written by capsule persistence and must survive"
);
}
}
#[test]
fn a_pinned_capsule_survives_pruning_past_the_grace_window() {
let dir = tempfile::tempdir().expect("tempdir");
let pinned = dir.path().join("20200101T000000.000000-000001-aaa.json");
let prunable = dir.path().join("20200101T000001.000000-000002-bbb.json");
std::fs::write(&pinned, b"{}").expect("write");
std::fs::write(&prunable, b"{}").expect("write");
let guard = pin_for_reporting(&pinned);
prune(dir.path(), 0, Utc::now());
assert!(
pinned.exists(),
"a capsule a reporter chain still holds must not be pruned"
);
assert!(
!prunable.exists(),
"an unpinned lapsed capsule prunes as usual"
);
drop(guard);
prune(dir.path(), 0, Utc::now());
assert!(
!pinned.exists(),
"dropping the pin makes the capsule prunable again"
);
}
#[test]
fn a_sub_minute_storm_cannot_grow_the_directory_unbounded() {
let dir = tempfile::tempdir().expect("tempdir");
let now = Utc::now();
let stamp = (now - TimeDelta::seconds(5)).format("%Y%m%dT%H%M%S%.6f");
for sequence in 0..20 {
let name = format!("{stamp}-{sequence:06}-storm.json");
std::fs::write(dir.path().join(name), b"{}").expect("write");
}
let keep = 2;
prune(dir.path(), keep, now);
let survivors = std::fs::read_dir(dir.path())
.expect("read_dir")
.filter_map(Result::ok)
.count();
assert_eq!(
survivors,
keep + grace_allowance(keep),
"the directory must settle at the cap plus the bounded grace \
allowance, not at whatever the storm produced"
);
}
#[test]
fn a_filtered_identity_header_suppresses_the_derived_field() {
use std::sync::Arc;
use crate::capsule::CaptureSettings;
use crate::capsule::CapturedClientIdentity;
use crate::capsule::capture::CaptureScope;
use crate::capsule::redact::RawRequest;
use crate::log::filter::ParameterFilter;
let build = |filtered: &[String]| {
let scope = CaptureScope::new(
"req-identity".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(filtered, &[])),
);
scope.set_request(RawRequest {
method: "GET".to_owned(),
uri: "/boom".parse().expect("uri parses"),
version: axum::http::Version::HTTP_11,
headers: axum::http::HeaderMap::new(),
route: None,
});
scope.set_client_identity(CapturedClientIdentity {
addr: Some("203.0.113.7".parse().expect("addr parses")),
host: Some("private-tenant.example".to_owned()),
scheme: Some("https".to_owned()),
});
assemble(
&scope,
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
)
.expect("capsule assembles")
};
let unfiltered = build(&[]);
assert_eq!(
unfiltered.request.client_host.as_deref(),
Some("private-tenant.example"),
"with nothing filtered the identity is recorded as before"
);
assert!(
!unfiltered.truncated,
"nothing suppressed, nothing to refuse"
);
let filtered = build(&["x-forwarded-host".to_owned()]);
assert_eq!(
filtered.request.client_host, None,
"a filtered identity header must not reappear as `client_host`"
);
assert!(
filtered.truncated,
"a capsule that cannot reproduce the identity must be refused, not replayed"
);
assert_eq!(
filtered.request.client_addr,
Some("203.0.113.7".parse().expect("addr parses")),
"filtering one source must not suppress fields it cannot feed"
);
assert_eq!(
filtered.request.client_scheme.as_deref(),
Some("https"),
"nor the scheme, which `x-forwarded-host` cannot resolve"
);
let forwarded = build(&["forwarded".to_owned()]);
assert_eq!(
forwarded.request.client_host.as_deref(),
Some("private-tenant.example")
);
assert_eq!(
forwarded.request.client_addr,
Some("203.0.113.7".parse().expect("addr parses"))
);
assert_eq!(forwarded.request.client_scheme.as_deref(), Some("https"));
assert!(
!forwarded.truncated,
"a header the resolver never reads must not refuse the capsule"
);
let real_ip = build(&["x-real-ip".to_owned()]);
assert_eq!(real_ip.request.client_addr, None);
assert_eq!(
real_ip.request.client_host.as_deref(),
Some("private-tenant.example"),
"and only the address — it feeds nothing else"
);
}
#[test]
fn filtering_a_source_that_supplied_nothing_does_not_refuse_the_capsule() {
use std::sync::Arc;
use crate::capsule::CaptureSettings;
use crate::capsule::CapturedClientIdentity;
use crate::capsule::capture::CaptureScope;
use crate::capsule::redact::RawRequest;
use crate::log::filter::ParameterFilter;
let scope = CaptureScope::new(
"req-identity".to_owned(),
Arc::new(CaptureSettings::default()),
Arc::new(ParameterFilter::new(&["x-real-ip".to_owned()], &[])),
);
scope.set_request(RawRequest {
method: "GET".to_owned(),
uri: "/boom".parse().expect("uri parses"),
version: axum::http::Version::HTTP_11,
headers: axum::http::HeaderMap::new(),
route: None,
});
scope.set_client_identity(CapturedClientIdentity {
addr: None,
host: Some("private-tenant.example".to_owned()),
scheme: None,
});
let capsule = assemble(
&scope,
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
)
.expect("capsule assembles");
assert!(
!capsule.truncated,
"filtering a source that supplied nothing must not refuse the capsule"
);
assert_eq!(
capsule.request.client_host.as_deref(),
Some("private-tenant.example"),
"and the fields it does not feed are still recorded"
);
}
#[test]
fn persist_pinned_writes_a_file_that_is_already_pinned() {
use std::sync::Arc;
use crate::capsule::CaptureSettings;
use crate::capsule::capture::CaptureScope;
use crate::capsule::redact::RawRequest;
use crate::log::filter::ParameterFilter;
let dir = tempfile::tempdir().expect("tempdir");
let scope = Arc::new(CaptureScope::new(
"req-pinned".to_owned(),
Arc::new(CaptureSettings {
dir: dir.path().to_string_lossy().into_owned(),
max_capsules: 1,
..CaptureSettings::default()
}),
Arc::new(ParameterFilter::new(&[], &[])),
));
scope.set_request(RawRequest {
method: "GET".to_owned(),
uri: "/boom".parse().expect("uri parses"),
version: axum::http::Version::HTTP_11,
headers: axum::http::HeaderMap::new(),
route: None,
});
let (reference, pin) = persist_pinned(
&scope,
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
)
.expect("the capsule is written");
assert!(
is_pinned_for_reporting(&reference.path),
"the file must already be pinned when persist_pinned returns"
);
prune(dir.path(), 0, Utc::now() + TimeDelta::hours(1));
assert!(
reference.path.exists(),
"a pinned capsule survives a concurrent prune even past the grace"
);
drop(pin);
assert!(
!is_pinned_for_reporting(&reference.path),
"dropping the pin releases the file to ordinary retention"
);
}
#[test]
fn capsule_dir_is_project_relative_by_default() {
assert_eq!(
capsule_dir("tmp/autumn-capsules"),
PathBuf::from("tmp/autumn-capsules")
);
}
#[test]
fn retention_always_leaves_room_for_the_capsule_being_written() {
assert_eq!(
retained_before_write(0),
0,
"a zero cap still keeps the new one"
);
assert_eq!(retained_before_write(1), 0);
assert_eq!(retained_before_write(50), 49);
}
#[test]
fn file_age_comes_from_the_name_the_writer_stamped() {
let now = Utc::now();
let capsule = test_capsule();
let fresh = PathBuf::from(file_name(&capsule));
assert!(
written_within_grace(&fresh, now),
"a capsule stamped now is inside the grace window"
);
let old = PathBuf::from(file_name(&Capsule {
captured_at: now - TimeDelta::hours(1),
..test_capsule()
}));
assert!(
!written_within_grace(&old, now),
"an hour-old capsule is prunable"
);
assert!(
!written_within_grace(Path::new("not-a-capsule.json"), now),
"a name this writer did not produce must not be pinned in the directory"
);
}
fn test_capsule() -> Capsule {
crate::capsule::schema::test_support::capsule(
crate::capsule::schema::test_support::request("GET", "/boom"),
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
)
}
#[tokio::test]
async fn persist_on_the_blocking_pool_still_yields_a_capsule_ref() {
use std::sync::Arc;
use crate::capsule::CaptureSettings;
use crate::capsule::capture::CaptureScope;
use crate::capsule::redact::RawRequest;
use crate::log::filter::ParameterFilter;
let dir = tempfile::tempdir().expect("tempdir");
let settings = CaptureSettings {
dir: dir.path().to_string_lossy().into_owned(),
..CaptureSettings::default()
};
let scope = Arc::new(CaptureScope::new(
"req-blocking".to_owned(),
Arc::new(settings),
Arc::new(ParameterFilter::new(&[], &[])),
));
scope.set_request(RawRequest {
method: "GET".to_owned(),
uri: "/boom".parse().expect("uri parses"),
version: axum::http::Version::HTTP_11,
headers: axum::http::HeaderMap::new(),
route: Some("/boom".to_owned()),
});
let written = tokio::task::spawn_blocking(move || {
persist(
&scope,
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
)
})
.await
.expect("the blocking task must join cleanly");
let reference = written.expect("persisting on the blocking pool must still return a ref");
assert_eq!(reference.id, "req-blocking");
assert!(
reference.path.exists(),
"the capsule must be on disk by the time the join handle resolves, so a \
reporter following the reference cannot race the writer"
);
assert_eq!(
load_capsule(&reference.path).expect("loads").request.uri,
"/boom"
);
}
#[tokio::test]
async fn a_backend_error_quoting_a_masked_value_is_scrubbed() {
use std::sync::Arc;
use crate::capsule::CaptureSettings;
use crate::capsule::redact::RawRequest;
use crate::capsule::schema::{BindValue, ConnectionTape, Exchange, ExchangeProtocol};
use crate::log::filter::ParameterFilter;
let dir = tempfile::tempdir().expect("tempdir");
let scope = Arc::new(CaptureScope::new(
"req-error".to_owned(),
Arc::new(CaptureSettings {
dir: dir.path().to_string_lossy().into_owned(),
..CaptureSettings::default()
}),
Arc::new(ParameterFilter::new(&["token".to_owned()], &[])),
));
scope.set_request(RawRequest {
method: "POST".to_owned(),
uri: "/tokens?token=sekrit-token-value"
.parse()
.expect("uri parses"),
version: axum::http::Version::HTTP_11,
headers: axum::http::HeaderMap::new(),
route: Some("/tokens".to_owned()),
});
scope.with_db(|db| {
*db.tape_mut(1) = ConnectionTape {
id: 1,
exchanges: vec![Exchange {
protocol: ExchangeProtocol::Extended,
sql: "INSERT INTO tokens (value) VALUES ($1)".to_owned(),
binds: vec![BindValue::Value(b"sekrit-token-value".to_vec())],
response: Vec::new(),
row_count: 0,
error: Some(
"23505: duplicate key value violates unique constraint \"tokens_value_key\" \
DETAIL: Key (value)=(sekrit-token-value) already exists."
.to_owned(),
),
}],
..ConnectionTape::default()
};
});
let reference = persist(
&scope,
CapsuleOutcome::Status {
code: 500,
message: "insert failed".to_owned(),
problem_type: None,
},
)
.expect("the capsule is written");
let written = std::fs::read_to_string(&reference.path).expect("capsule readable");
assert!(
!written.contains("sekrit-token-value"),
"a masked request value must not survive in the recorded backend error: {written}"
);
let capsule = load_capsule(&reference.path).expect("capsule loads");
let error = capsule
.db
.as_ref()
.and_then(|db| db.connections.first())
.and_then(|tape| tape.exchanges.first())
.and_then(|exchange| exchange.error.clone())
.expect("the exchange kept its error");
assert!(
error.contains("duplicate key") && error.contains("[FILTERED]"),
"the error must stay readable with the value masked, got {error}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn capsules_are_written_owner_only_into_an_owner_only_directory() {
use std::os::unix::fs::PermissionsExt as _;
use std::sync::Arc;
use crate::capsule::CaptureSettings;
use crate::capsule::redact::RawRequest;
use crate::log::filter::ParameterFilter;
let root = tempfile::tempdir().expect("tempdir");
let dir = root.path().join("capsules");
let scope = Arc::new(CaptureScope::new(
"req-perms".to_owned(),
Arc::new(CaptureSettings {
dir: dir.to_string_lossy().into_owned(),
..CaptureSettings::default()
}),
Arc::new(ParameterFilter::new(&[], &[])),
));
scope.set_request(RawRequest {
method: "GET".to_owned(),
uri: "/boom".parse().expect("uri parses"),
version: axum::http::Version::HTTP_11,
headers: axum::http::HeaderMap::new(),
route: None,
});
let reference = persist(
&scope,
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
)
.expect("the capsule is written");
let file_mode = std::fs::metadata(&reference.path)
.expect("capsule metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(
file_mode, 0o600,
"a capsule must be readable only by its owner"
);
let dir_mode = std::fs::metadata(&dir)
.expect("directory metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(
dir_mode, 0o700,
"the capsule directory must not be listable by anyone else"
);
assert!(
!dir.join(format!(
"{}.tmp",
reference
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
))
.exists(),
"the temp file must not be left behind"
);
}
#[test]
fn a_temp_path_is_unpredictable_and_ends_in_tmp() {
let path = Path::new("tmp/capsules/20250101T000000-000000-req.json");
let first = temp_path(path);
let second = temp_path(path);
assert_ne!(
first, second,
"a predictable temp path can be pre-created or symlinked by anyone \
who can write the directory"
);
for candidate in [&first, &second] {
assert!(
candidate.to_string_lossy().ends_with(".tmp"),
"the temp file must not look like a capsule to the pruner: {candidate:?}"
);
}
}
#[test]
fn load_capsule_rejects_a_missing_file() {
let error = load_capsule(Path::new("does/not/exist.json"))
.expect_err("a missing capsule must be an error");
assert!(matches!(error, CapsuleError::Io(_)));
}
#[test]
fn load_capsule_round_trips_a_written_capsule() {
use crate::capsule::schema::test_support;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("capsule.json");
let capsule = test_support::capsule(
test_support::request("GET", "/boom"),
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: None,
},
);
std::fs::write(
&path,
serde_json::to_string(&capsule).expect("capsule serializes"),
)
.expect("fixture writes");
let loaded = load_capsule(&path).expect("a freshly written capsule must load back");
assert_eq!(loaded.request.uri, "/boom");
}
}