use serde::Serialize;
use std::{
backtrace::{Backtrace, BacktraceStatus},
error::Error,
fmt::{self, Write},
panic::Location,
sync::atomic::{AtomicBool, AtomicU64, Ordering},
};
const MAX_CAUSES: usize = 8;
const MAX_STACK_BYTES: usize = 16 * 1024;
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
static CAPTURING: AtomicBool = AtomicBool::new(false);
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticCategory {
ExpectedRejection,
UnexpectedError,
Panic,
InvariantViolation,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticStage {
StartupConfig,
StartupLogging,
StartupDbMapping,
StartupDbConnect,
StartupOutbound,
StartupListener,
RequestDecode,
RequestAdmission,
RequestHandler,
RequestDb,
RequestOutbound,
RequestResponse,
BackgroundTask,
ShutdownComponent,
ShutdownLogger,
FinalizerResource,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CaptureSite {
Origin,
FirstObserved,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct DiagnosticCode(&'static str);
impl DiagnosticCode {
pub fn new(code: &'static str) -> Option<Self> {
(!code.is_empty()
&& code.len() <= 128
&& code
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)))
.then_some(Self(code))
}
}
#[derive(Debug, Serialize)]
pub struct DiagnosticLocation {
file: String,
line: u32,
column: u32,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticObjectKind {
ConfigKey,
MappingFile,
LogicalTable,
LogicalColumn,
TargetAlias,
LogPath,
}
#[derive(Debug, Serialize)]
pub struct DiagnosticObject {
kind: DiagnosticObjectKind,
value: String,
}
impl DiagnosticObject {
pub fn new(kind: DiagnosticObjectKind, value: &str) -> Option<Self> {
let path = matches!(
kind,
DiagnosticObjectKind::MappingFile | DiagnosticObjectKind::LogPath
);
let valid = !value.is_empty()
&& value.len() <= 256
&& value
.chars()
.all(|c| c.is_alphanumeric() || "_.-".contains(c) || (path && c == '/'))
&& !value.split('/').any(|part| part == "..")
&& (!value.starts_with('/') || matches!(kind, DiagnosticObjectKind::LogPath));
valid.then(|| Self {
kind,
value: value.to_owned(),
})
}
}
fn safe_file(file: &str) -> String {
let file = file.rsplit("/crates/").next().unwrap_or(file);
let file = if file.starts_with('/') || file.contains('\\') {
file.rsplit(['/', '\\']).next().unwrap_or("unknown")
} else {
file
};
file.chars().filter(|c| !c.is_control()).take(256).collect()
}
#[derive(Debug, Serialize)]
pub struct DiagnosticLocator {
value: String,
truncated: bool,
redacted: bool,
}
impl DiagnosticLocator {
pub fn from_projection(value: &str, truncated: bool, redacted: bool) -> Option<Self> {
if redacted {
return Some(Self {
value: "[redacted]".into(),
truncated,
redacted: true,
});
}
if value.is_empty()
|| value.len() > 192
|| value.chars().any(|c| {
c.is_control() || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
})
|| value.contains(['@', '=', '?', '/'])
{
return None;
}
Some(Self {
value: value.to_owned(),
truncated,
redacted: false,
})
}
}
#[derive(Debug, Serialize)]
pub struct DiagnosticInputLocation {
json_line: Option<u64>,
json_column: Option<u64>,
config_key: Option<DiagnosticLocator>,
file: Option<DiagnosticLocator>,
table: Option<DiagnosticLocator>,
column: Option<DiagnosticLocator>,
locator_truncated: bool,
locator_redacted: bool,
}
impl DiagnosticInputLocation {
pub fn new(json_line: Option<u64>, json_column: Option<u64>) -> Self {
Self {
json_line,
json_column,
config_key: None,
file: None,
table: None,
column: None,
locator_truncated: false,
locator_redacted: false,
}
}
pub fn with_locator_status(mut self, truncated: bool, redacted: bool) -> Self {
self.locator_truncated |= truncated;
self.locator_redacted |= redacted;
self
}
fn observe(&mut self, locator: &DiagnosticLocator) {
self.locator_truncated |= locator.truncated;
self.locator_redacted |= locator.redacted;
}
pub fn with_config_key(mut self, locator: DiagnosticLocator) -> Self {
self.observe(&locator);
self.config_key = Some(locator);
self
}
pub fn with_file(mut self, locator: DiagnosticLocator) -> Self {
self.observe(&locator);
self.file = Some(locator);
self
}
pub fn with_table(mut self, locator: DiagnosticLocator) -> Self {
self.observe(&locator);
self.table = Some(locator);
self
}
pub fn with_column(mut self, locator: DiagnosticLocator) -> Self {
self.observe(&locator);
self.column = Some(locator);
self
}
}
#[derive(Debug, Serialize)]
pub struct DiagnosticCause {
stage: DiagnosticStage,
code: DiagnosticCode,
io_kind: Option<&'static str>,
os_code: Option<i32>,
db_code: Option<u32>,
sqlstate: Option<String>,
object: Option<DiagnosticObject>,
input_location: Option<DiagnosticInputLocation>,
}
impl DiagnosticCause {
pub fn new(stage: DiagnosticStage, code: DiagnosticCode) -> Self {
Self {
stage,
code,
io_kind: None,
os_code: None,
db_code: None,
sqlstate: None,
object: None,
input_location: None,
}
}
pub fn with_object(mut self, object: DiagnosticObject) -> Self {
self.object = Some(object);
self
}
pub fn with_input_location(mut self, location: DiagnosticInputLocation) -> Self {
self.input_location = Some(location);
self
}
pub fn with_io(mut self, error: &std::io::Error) -> Self {
self.io_kind = Some(match error.kind() {
std::io::ErrorKind::NotFound => "not_found",
std::io::ErrorKind::PermissionDenied => "permission_denied",
std::io::ErrorKind::ConnectionRefused => "connection_refused",
std::io::ErrorKind::ConnectionReset => "connection_reset",
std::io::ErrorKind::TimedOut => "timed_out",
std::io::ErrorKind::WouldBlock => "would_block",
std::io::ErrorKind::BrokenPipe => "broken_pipe",
std::io::ErrorKind::InvalidData => "invalid_data",
_ => "other",
});
self.os_code = error.raw_os_error();
self
}
pub fn with_database_code(mut self, code: u32, sqlstate: Option<&str>) -> Self {
self.db_code = Some(code);
self.sqlstate = sqlstate
.filter(|s| {
s.len() == 5
&& s.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
})
.map(str::to_owned);
self
}
}
#[derive(Serialize)]
pub struct Diagnostic {
schema_version: u8,
diagnostic_id: u64,
primary_diagnostic_id: Option<u64>,
task: Option<DiagnosticCode>,
scope: Option<crate::DbScopeLogFields>,
category: DiagnosticCategory,
capture_site: CaptureSite,
origin: DiagnosticLocation,
causes: Vec<DiagnosticCause>,
omitted_causes: u64,
stack_status: &'static str,
stack: String,
stack_truncated: bool,
}
struct StackText {
text: String,
truncated: bool,
}
impl Write for StackText {
fn write_str(&mut self, value: &str) -> fmt::Result {
let remaining = MAX_STACK_BYTES.saturating_sub(self.text.len());
let mut end = remaining.min(value.len());
while !value.is_char_boundary(end) {
end -= 1;
}
self.text.push_str(&value[..end]);
self.truncated |= end < value.len();
if self.truncated {
Err(fmt::Error)
} else {
Ok(())
}
}
}
impl Diagnostic {
#[track_caller]
pub fn capture(
category: DiagnosticCategory,
site: CaptureSite,
cause: DiagnosticCause,
) -> Self {
let location = Location::caller();
let mut result = Self {
schema_version: 1,
diagnostic_id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
primary_diagnostic_id: None,
task: None,
scope: None,
category,
capture_site: site,
origin: DiagnosticLocation {
file: safe_file(location.file()),
line: location.line(),
column: location.column(),
},
causes: vec![cause],
omitted_causes: 0,
stack_status: "not_requested_expected",
stack: String::new(),
stack_truncated: false,
};
if !matches!(category, DiagnosticCategory::ExpectedRejection) {
if CAPTURING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
result.stack_status = "suppressed_concurrent_or_reentrant";
return result;
}
struct CaptureGuard;
impl Drop for CaptureGuard {
fn drop(&mut self) {
CAPTURING.store(false, Ordering::Release);
}
}
let _capture = CaptureGuard;
let trace = Backtrace::force_capture();
result.stack_status = match trace.status() {
BacktraceStatus::Captured => "captured",
BacktraceStatus::Disabled => "disabled",
_ => "unsupported",
};
let mut output = StackText {
text: String::new(),
truncated: false,
};
let _ = write!(output, "{trace}");
result.stack = output
.text
.lines()
.map(|line| {
if let Some((_, path)) = line.split_once(" at ") {
format!(" at {}", safe_file(path))
} else {
line.to_owned()
}
})
.collect::<Vec<_>>()
.join("\n");
result.stack_truncated = output.truncated;
}
result
}
pub fn capture_panic(info: &std::panic::PanicHookInfo<'_>, stage: DiagnosticStage) -> Self {
let mut result = Self::capture(
DiagnosticCategory::Panic,
CaptureSite::FirstObserved,
DiagnosticCause::new(stage, DiagnosticCode("runtime.panic")),
);
if let Some(location) = info.location() {
result.capture_site = CaptureSite::Origin;
result.origin = DiagnosticLocation {
file: safe_file(location.file()),
line: location.line(),
column: location.column(),
};
}
result
}
pub fn wrap(mut self, cause: DiagnosticCause) -> Self {
if self.causes.len() < MAX_CAUSES {
self.causes.insert(0, cause);
} else {
self.omitted_causes = self.omitted_causes.saturating_add(1);
}
self
}
pub const fn id(&self) -> u64 {
self.diagnostic_id
}
pub fn with_task(mut self, registered_task: DiagnosticCode) -> Self {
self.task = Some(registered_task);
self
}
pub fn with_scope(mut self, scope: crate::DbScopeLogFields) -> Self {
self.scope = Some(scope);
self
}
pub const fn category(&self) -> DiagnosticCategory {
self.category
}
pub fn during_cleanup_of(mut self, primary: &Diagnostic) -> Self {
self.primary_diagnostic_id = Some(primary.id());
self
}
}
impl fmt::Display for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"diagnostic={} primary={:?} {:?} {:?} at {}:{} stack={}",
self.diagnostic_id,
self.primary_diagnostic_id,
self.category,
self.capture_site,
self.origin.file,
self.origin.line,
self.stack_status
)?;
for cause in &self.causes {
write!(
f,
" <- {:?}/{} io={:?} os={:?} db={:?} sqlstate={:?} object={:?} input_location={:?}",
cause.stage,
cause.code.0,
cause.io_kind,
cause.os_code,
cause.db_code,
cause.sqlstate,
cause.object,
cause.input_location
)?;
}
write!(
f,
" omitted_causes={} stack_truncated={}\n{}",
self.omitted_causes, self.stack_truncated, self.stack
)
}
}
impl fmt::Debug for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
impl Error for Diagnostic {}
#[cfg(test)]
mod tests {
use super::*;
fn cause() -> DiagnosticCause {
DiagnosticCause::new(
DiagnosticStage::RequestDb,
DiagnosticCode::new("db.connect_failed").unwrap(),
)
}
#[test]
fn diagnostic_capture_wrap_and_cleanup_preserve_origin() {
let original = Diagnostic::capture(
DiagnosticCategory::UnexpectedError,
CaptureSite::FirstObserved,
cause(),
);
let before = serde_json::to_value(&original).unwrap();
assert_ne!(before["stack_status"], "not_requested_expected");
let wrapped = original.wrap(cause());
let after = serde_json::to_value(&wrapped).unwrap();
assert_eq!(before["origin"], after["origin"]);
assert_eq!(before["stack"], after["stack"]);
assert_eq!(before["diagnostic_id"], after["diagnostic_id"]);
let cleanup = Diagnostic::capture(
DiagnosticCategory::UnexpectedError,
CaptureSite::FirstObserved,
cause(),
)
.during_cleanup_of(&wrapped);
assert_eq!(
serde_json::to_value(cleanup).unwrap()["primary_diagnostic_id"],
after["diagnostic_id"]
);
}
#[test]
fn diagnostic_safe_projection_and_bounds() {
let io = std::io::Error::other("SECRET_DRIVER_PAYLOAD");
let mut diagnostic = Diagnostic::capture(
DiagnosticCategory::ExpectedRejection,
CaptureSite::Origin,
cause().with_io(&io).with_database_code(1045, Some("28000")),
);
for _ in 0..20 {
diagnostic = diagnostic.wrap(cause());
}
let value = serde_json::to_value(&diagnostic).unwrap();
assert_eq!(value["causes"].as_array().unwrap().len(), 8);
assert_eq!(value["omitted_causes"], 13);
assert_eq!(value["stack_status"], "not_requested_expected");
let error =
crate::SaddleError::new(crate::ErrorKind::Internal, "internal", "SECRET_MESSAGE")
.with_diagnostic(diagnostic);
for output in [error.to_string(), format!("{error:?}"), value.to_string()] {
assert!(!output.contains("SECRET_"));
}
assert!(error.source().is_some());
assert!(
DiagnosticObject::new(
DiagnosticObjectKind::TargetAlias,
"https://user:password@host"
)
.is_none()
);
assert!(DiagnosticObject::new(DiagnosticObjectKind::MappingFile, "../secret").is_none());
assert!(
DiagnosticObject::new(
DiagnosticObjectKind::LogPath,
"/srv/logs/saddle.emergency.log"
)
.is_some()
);
let mut output = StackText {
text: String::new(),
truncated: false,
};
assert!(output.write_str(&"界".repeat(MAX_STACK_BYTES)).is_err());
assert!(output.text.len() <= MAX_STACK_BYTES && output.truncated);
}
#[test]
fn diagnostic_input_location_is_distinct_bounded_and_safe() {
let location = DiagnosticInputLocation::new(Some(17), Some(0))
.with_file(DiagnosticLocator::from_projection("mapping.json", false, false).unwrap())
.with_table(DiagnosticLocator::from_projection("order\\u000a", true, false).unwrap())
.with_column(DiagnosticLocator::from_projection("SECRET@value", false, true).unwrap())
.with_config_key(
DiagnosticLocator::from_projection("database.mappingDir", false, false).unwrap(),
);
let diagnostic = Diagnostic::capture(
DiagnosticCategory::ExpectedRejection,
CaptureSite::FirstObserved,
cause().with_input_location(location),
);
let value = serde_json::to_value(&diagnostic).unwrap();
let input = &value["causes"][0]["input_location"];
assert_eq!(input["json_line"], 17);
assert_eq!(input["json_column"], 0);
assert_eq!(input["file"]["value"], "mapping.json");
assert_eq!(input["table"]["value"], "order\\u000a");
assert_eq!(input["column"]["value"], "[redacted]");
assert_eq!(input["locator_truncated"], true);
assert_eq!(input["locator_redacted"], true);
assert_ne!(value["origin"]["line"], input["json_line"]);
for text in [
value.to_string(),
format!("{diagnostic}"),
format!("{diagnostic:?}"),
] {
assert!(!text.contains("SECRET"));
}
assert!(DiagnosticLocator::from_projection("https://secret", false, false).is_none());
assert!(DiagnosticLocator::from_projection("raw\ncontrol", false, false).is_none());
assert!(DiagnosticLocator::from_projection(&"界".repeat(65), false, false).is_none());
let absent = serde_json::to_value(
DiagnosticInputLocation::new(None, None).with_locator_status(true, true),
)
.unwrap();
assert!(absent["json_line"].is_null());
assert_eq!(absent["locator_redacted"], true);
}
#[test]
fn diagnostic_panic_origin_subprocess() {
const CHILD: &str = "SADDLE_DIAGNOSTIC_PANIC_CHILD";
if std::env::var_os(CHILD).is_some() {
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
let hook_capture = captured.clone();
std::panic::set_hook(Box::new(move |info| {
*hook_capture.lock().unwrap() = Some(Diagnostic::capture_panic(
info,
DiagnosticStage::RequestHandler,
));
}));
let panic_line = line!() + 1;
let result = std::panic::catch_unwind(|| panic!("SENSITIVE_PANIC_PAYLOAD"));
assert!(result.is_err());
let diagnostic = captured.lock().unwrap().take().unwrap();
assert_eq!(diagnostic.origin.line, panic_line);
assert!(matches!(diagnostic.capture_site, CaptureSite::Origin));
assert_eq!(diagnostic.stack_status, "captured");
assert!(!diagnostic.stack.is_empty());
assert!(!format!("{diagnostic:?}").contains("SENSITIVE_PANIC_PAYLOAD"));
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"diagnostic::tests::diagnostic_panic_origin_subprocess",
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
}