1use serde::Serialize;
3use std::{
4 backtrace::{Backtrace, BacktraceStatus},
5 error::Error,
6 fmt::{self, Write},
7 panic::Location,
8 sync::atomic::{AtomicBool, AtomicU64, Ordering},
9};
10
11const MAX_CAUSES: usize = 8;
12const MAX_STACK_BYTES: usize = 16 * 1024;
13static NEXT_ID: AtomicU64 = AtomicU64::new(1);
14static CAPTURING: AtomicBool = AtomicBool::new(false);
15
16#[derive(Clone, Copy, Debug, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum DiagnosticCategory {
19 ExpectedRejection,
20 UnexpectedError,
21 Panic,
22 InvariantViolation,
23}
24
25#[derive(Clone, Copy, Debug, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum DiagnosticStage {
28 StartupConfig,
29 StartupLogging,
30 StartupDbMapping,
31 StartupDbConnect,
32 StartupOutbound,
33 StartupListener,
34 RequestDecode,
35 RequestAdmission,
36 RequestHandler,
37 RequestDb,
38 RequestOutbound,
39 RequestResponse,
40 BackgroundTask,
41 ShutdownComponent,
42 ShutdownLogger,
43 FinalizerResource,
44}
45
46#[derive(Clone, Copy, Debug, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum CaptureSite {
49 Origin,
50 FirstObserved,
51}
52
53#[derive(Clone, Copy, Debug, Serialize)]
55pub struct DiagnosticCode(&'static str);
56impl DiagnosticCode {
57 pub fn new(code: &'static str) -> Option<Self> {
58 (!code.is_empty()
59 && code.len() <= 128
60 && code
61 .bytes()
62 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)))
63 .then_some(Self(code))
64 }
65}
66
67#[derive(Debug, Serialize)]
68pub struct DiagnosticLocation {
69 file: String,
70 line: u32,
71 column: u32,
72}
73
74#[derive(Debug, Serialize)]
75#[serde(rename_all = "snake_case")]
76pub enum DiagnosticObjectKind {
77 ConfigKey,
78 MappingFile,
79 LogicalTable,
80 LogicalColumn,
81 TargetAlias,
82 LogPath,
83}
84
85#[derive(Debug, Serialize)]
89pub struct DiagnosticObject {
90 kind: DiagnosticObjectKind,
91 value: String,
92}
93impl DiagnosticObject {
94 pub fn new(kind: DiagnosticObjectKind, value: &str) -> Option<Self> {
95 let path = matches!(
96 kind,
97 DiagnosticObjectKind::MappingFile | DiagnosticObjectKind::LogPath
98 );
99 let valid = !value.is_empty()
100 && value.len() <= 256
101 && value
102 .chars()
103 .all(|c| c.is_alphanumeric() || "_.-".contains(c) || (path && c == '/'))
104 && !value.split('/').any(|part| part == "..")
105 && (!value.starts_with('/') || matches!(kind, DiagnosticObjectKind::LogPath));
106 valid.then(|| Self {
107 kind,
108 value: value.to_owned(),
109 })
110 }
111}
112fn safe_file(file: &str) -> String {
113 let file = file.rsplit("/crates/").next().unwrap_or(file);
114 let file = if file.starts_with('/') || file.contains('\\') {
115 file.rsplit(['/', '\\']).next().unwrap_or("unknown")
116 } else {
117 file
118 };
119 file.chars().filter(|c| !c.is_control()).take(256).collect()
120}
121
122#[derive(Debug, Serialize)]
124pub struct DiagnosticLocator {
125 value: String,
126 truncated: bool,
127 redacted: bool,
128}
129impl DiagnosticLocator {
130 pub fn from_projection(value: &str, truncated: bool, redacted: bool) -> Option<Self> {
131 if redacted {
132 return Some(Self {
133 value: "[redacted]".into(),
134 truncated,
135 redacted: true,
136 });
137 }
138 if value.is_empty()
139 || value.len() > 192
140 || value.chars().any(|c| {
141 c.is_control() || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
142 })
143 || value.contains(['@', '=', '?', '/'])
144 {
145 return None;
146 }
147 Some(Self {
148 value: value.to_owned(),
149 truncated,
150 redacted: false,
151 })
152 }
153}
154
155#[derive(Debug, Serialize)]
158pub struct DiagnosticInputLocation {
159 json_line: Option<u64>,
160 json_column: Option<u64>,
161 config_key: Option<DiagnosticLocator>,
162 file: Option<DiagnosticLocator>,
163 table: Option<DiagnosticLocator>,
164 column: Option<DiagnosticLocator>,
165 locator_truncated: bool,
166 locator_redacted: bool,
167}
168impl DiagnosticInputLocation {
169 pub fn new(json_line: Option<u64>, json_column: Option<u64>) -> Self {
170 Self {
171 json_line,
172 json_column,
173 config_key: None,
174 file: None,
175 table: None,
176 column: None,
177 locator_truncated: false,
178 locator_redacted: false,
179 }
180 }
181 pub fn with_locator_status(mut self, truncated: bool, redacted: bool) -> Self {
182 self.locator_truncated |= truncated;
183 self.locator_redacted |= redacted;
184 self
185 }
186 fn observe(&mut self, locator: &DiagnosticLocator) {
187 self.locator_truncated |= locator.truncated;
188 self.locator_redacted |= locator.redacted;
189 }
190 pub fn with_config_key(mut self, locator: DiagnosticLocator) -> Self {
191 self.observe(&locator);
192 self.config_key = Some(locator);
193 self
194 }
195 pub fn with_file(mut self, locator: DiagnosticLocator) -> Self {
196 self.observe(&locator);
197 self.file = Some(locator);
198 self
199 }
200 pub fn with_table(mut self, locator: DiagnosticLocator) -> Self {
201 self.observe(&locator);
202 self.table = Some(locator);
203 self
204 }
205 pub fn with_column(mut self, locator: DiagnosticLocator) -> Self {
206 self.observe(&locator);
207 self.column = Some(locator);
208 self
209 }
210}
211
212#[derive(Debug, Serialize)]
214pub struct DiagnosticCause {
215 stage: DiagnosticStage,
216 code: DiagnosticCode,
217 io_kind: Option<&'static str>,
218 os_code: Option<i32>,
219 db_code: Option<u32>,
220 sqlstate: Option<String>,
221 object: Option<DiagnosticObject>,
222 input_location: Option<DiagnosticInputLocation>,
223}
224impl DiagnosticCause {
225 pub fn new(stage: DiagnosticStage, code: DiagnosticCode) -> Self {
226 Self {
227 stage,
228 code,
229 io_kind: None,
230 os_code: None,
231 db_code: None,
232 sqlstate: None,
233 object: None,
234 input_location: None,
235 }
236 }
237 pub fn with_object(mut self, object: DiagnosticObject) -> Self {
238 self.object = Some(object);
239 self
240 }
241 pub fn with_input_location(mut self, location: DiagnosticInputLocation) -> Self {
242 self.input_location = Some(location);
243 self
244 }
245 pub fn with_io(mut self, error: &std::io::Error) -> Self {
246 self.io_kind = Some(match error.kind() {
247 std::io::ErrorKind::NotFound => "not_found",
248 std::io::ErrorKind::PermissionDenied => "permission_denied",
249 std::io::ErrorKind::ConnectionRefused => "connection_refused",
250 std::io::ErrorKind::ConnectionReset => "connection_reset",
251 std::io::ErrorKind::TimedOut => "timed_out",
252 std::io::ErrorKind::WouldBlock => "would_block",
253 std::io::ErrorKind::BrokenPipe => "broken_pipe",
254 std::io::ErrorKind::InvalidData => "invalid_data",
255 _ => "other",
256 });
257 self.os_code = error.raw_os_error();
258 self
259 }
260 pub fn with_database_code(mut self, code: u32, sqlstate: Option<&str>) -> Self {
261 self.db_code = Some(code);
262 self.sqlstate = sqlstate
263 .filter(|s| {
264 s.len() == 5
265 && s.bytes()
266 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
267 })
268 .map(str::to_owned);
269 self
270 }
271}
272
273#[derive(Serialize)]
276pub struct Diagnostic {
277 schema_version: u8,
278 diagnostic_id: u64,
279 primary_diagnostic_id: Option<u64>,
280 task: Option<DiagnosticCode>,
281 scope: Option<crate::DbScopeLogFields>,
282 category: DiagnosticCategory,
283 capture_site: CaptureSite,
284 origin: DiagnosticLocation,
285 causes: Vec<DiagnosticCause>,
286 omitted_causes: u64,
287 stack_status: &'static str,
288 stack: String,
289 stack_truncated: bool,
290}
291
292struct StackText {
293 text: String,
294 truncated: bool,
295}
296impl Write for StackText {
297 fn write_str(&mut self, value: &str) -> fmt::Result {
298 let remaining = MAX_STACK_BYTES.saturating_sub(self.text.len());
299 let mut end = remaining.min(value.len());
300 while !value.is_char_boundary(end) {
301 end -= 1;
302 }
303 self.text.push_str(&value[..end]);
304 self.truncated |= end < value.len();
305 if self.truncated {
306 Err(fmt::Error)
307 } else {
308 Ok(())
309 }
310 }
311}
312impl Diagnostic {
313 #[track_caller]
314 pub fn capture(
315 category: DiagnosticCategory,
316 site: CaptureSite,
317 cause: DiagnosticCause,
318 ) -> Self {
319 let location = Location::caller();
320 let mut result = Self {
321 schema_version: 1,
322 diagnostic_id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
323 primary_diagnostic_id: None,
324 task: None,
325 scope: None,
326 category,
327 capture_site: site,
328 origin: DiagnosticLocation {
329 file: safe_file(location.file()),
330 line: location.line(),
331 column: location.column(),
332 },
333 causes: vec![cause],
334 omitted_causes: 0,
335 stack_status: "not_requested_expected",
336 stack: String::new(),
337 stack_truncated: false,
338 };
339 if !matches!(category, DiagnosticCategory::ExpectedRejection) {
340 if CAPTURING
341 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
342 .is_err()
343 {
344 result.stack_status = "suppressed_concurrent_or_reentrant";
345 return result;
346 }
347 struct CaptureGuard;
348 impl Drop for CaptureGuard {
349 fn drop(&mut self) {
350 CAPTURING.store(false, Ordering::Release);
351 }
352 }
353 let _capture = CaptureGuard;
354 let trace = Backtrace::force_capture();
355 result.stack_status = match trace.status() {
356 BacktraceStatus::Captured => "captured",
357 BacktraceStatus::Disabled => "disabled",
358 _ => "unsupported",
359 };
360 let mut output = StackText {
361 text: String::new(),
362 truncated: false,
363 };
364 let _ = write!(output, "{trace}");
365 result.stack = output
367 .text
368 .lines()
369 .map(|line| {
370 if let Some((_, path)) = line.split_once(" at ") {
371 format!(" at {}", safe_file(path))
372 } else {
373 line.to_owned()
374 }
375 })
376 .collect::<Vec<_>>()
377 .join("\n");
378 result.stack_truncated = output.truncated;
379 }
380 result
381 }
382 pub fn capture_panic(info: &std::panic::PanicHookInfo<'_>, stage: DiagnosticStage) -> Self {
385 let mut result = Self::capture(
386 DiagnosticCategory::Panic,
387 CaptureSite::FirstObserved,
388 DiagnosticCause::new(stage, DiagnosticCode("runtime.panic")),
389 );
390 if let Some(location) = info.location() {
391 result.capture_site = CaptureSite::Origin;
392 result.origin = DiagnosticLocation {
393 file: safe_file(location.file()),
394 line: location.line(),
395 column: location.column(),
396 };
397 }
398 result
399 }
400 pub fn wrap(mut self, cause: DiagnosticCause) -> Self {
402 if self.causes.len() < MAX_CAUSES {
403 self.causes.insert(0, cause);
404 } else {
405 self.omitted_causes = self.omitted_causes.saturating_add(1);
406 }
407 self
408 }
409 pub const fn id(&self) -> u64 {
410 self.diagnostic_id
411 }
412 pub fn with_task(mut self, registered_task: DiagnosticCode) -> Self {
413 self.task = Some(registered_task);
414 self
415 }
416 pub fn with_scope(mut self, scope: crate::DbScopeLogFields) -> Self {
418 self.scope = Some(scope);
419 self
420 }
421 pub const fn category(&self) -> DiagnosticCategory {
422 self.category
423 }
424
425 pub fn during_cleanup_of(mut self, primary: &Diagnostic) -> Self {
427 self.primary_diagnostic_id = Some(primary.id());
428 self
429 }
430}
431impl fmt::Display for Diagnostic {
432 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433 write!(
434 f,
435 "diagnostic={} primary={:?} {:?} {:?} at {}:{} stack={}",
436 self.diagnostic_id,
437 self.primary_diagnostic_id,
438 self.category,
439 self.capture_site,
440 self.origin.file,
441 self.origin.line,
442 self.stack_status
443 )?;
444 for cause in &self.causes {
445 write!(
446 f,
447 " <- {:?}/{} io={:?} os={:?} db={:?} sqlstate={:?} object={:?} input_location={:?}",
448 cause.stage,
449 cause.code.0,
450 cause.io_kind,
451 cause.os_code,
452 cause.db_code,
453 cause.sqlstate,
454 cause.object,
455 cause.input_location
456 )?;
457 }
458 write!(
459 f,
460 " omitted_causes={} stack_truncated={}\n{}",
461 self.omitted_causes, self.stack_truncated, self.stack
462 )
463 }
464}
465impl fmt::Debug for Diagnostic {
466 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467 fmt::Display::fmt(self, f)
468 }
469}
470impl Error for Diagnostic {}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 fn cause() -> DiagnosticCause {
476 DiagnosticCause::new(
477 DiagnosticStage::RequestDb,
478 DiagnosticCode::new("db.connect_failed").unwrap(),
479 )
480 }
481 #[test]
482 fn diagnostic_capture_wrap_and_cleanup_preserve_origin() {
483 let original = Diagnostic::capture(
484 DiagnosticCategory::UnexpectedError,
485 CaptureSite::FirstObserved,
486 cause(),
487 );
488 let before = serde_json::to_value(&original).unwrap();
489 assert_ne!(before["stack_status"], "not_requested_expected");
490 let wrapped = original.wrap(cause());
491 let after = serde_json::to_value(&wrapped).unwrap();
492 assert_eq!(before["origin"], after["origin"]);
493 assert_eq!(before["stack"], after["stack"]);
494 assert_eq!(before["diagnostic_id"], after["diagnostic_id"]);
495 let cleanup = Diagnostic::capture(
496 DiagnosticCategory::UnexpectedError,
497 CaptureSite::FirstObserved,
498 cause(),
499 )
500 .during_cleanup_of(&wrapped);
501 assert_eq!(
502 serde_json::to_value(cleanup).unwrap()["primary_diagnostic_id"],
503 after["diagnostic_id"]
504 );
505 }
506 #[test]
507 fn diagnostic_safe_projection_and_bounds() {
508 let io = std::io::Error::other("SECRET_DRIVER_PAYLOAD");
509 let mut diagnostic = Diagnostic::capture(
510 DiagnosticCategory::ExpectedRejection,
511 CaptureSite::Origin,
512 cause().with_io(&io).with_database_code(1045, Some("28000")),
513 );
514 for _ in 0..20 {
515 diagnostic = diagnostic.wrap(cause());
516 }
517 let value = serde_json::to_value(&diagnostic).unwrap();
518 assert_eq!(value["causes"].as_array().unwrap().len(), 8);
519 assert_eq!(value["omitted_causes"], 13);
520 assert_eq!(value["stack_status"], "not_requested_expected");
521 let error =
522 crate::SaddleError::new(crate::ErrorKind::Internal, "internal", "SECRET_MESSAGE")
523 .with_diagnostic(diagnostic);
524 for output in [error.to_string(), format!("{error:?}"), value.to_string()] {
525 assert!(!output.contains("SECRET_"));
526 }
527 assert!(error.source().is_some());
528 assert!(
529 DiagnosticObject::new(
530 DiagnosticObjectKind::TargetAlias,
531 "https://user:password@host"
532 )
533 .is_none()
534 );
535 assert!(DiagnosticObject::new(DiagnosticObjectKind::MappingFile, "../secret").is_none());
536 assert!(
537 DiagnosticObject::new(
538 DiagnosticObjectKind::LogPath,
539 "/srv/logs/saddle.emergency.log"
540 )
541 .is_some()
542 );
543 let mut output = StackText {
544 text: String::new(),
545 truncated: false,
546 };
547 assert!(output.write_str(&"界".repeat(MAX_STACK_BYTES)).is_err());
548 assert!(output.text.len() <= MAX_STACK_BYTES && output.truncated);
549 }
550 #[test]
551 fn diagnostic_input_location_is_distinct_bounded_and_safe() {
552 let location = DiagnosticInputLocation::new(Some(17), Some(0))
553 .with_file(DiagnosticLocator::from_projection("mapping.json", false, false).unwrap())
554 .with_table(DiagnosticLocator::from_projection("order\\u000a", true, false).unwrap())
555 .with_column(DiagnosticLocator::from_projection("SECRET@value", false, true).unwrap())
556 .with_config_key(
557 DiagnosticLocator::from_projection("database.mappingDir", false, false).unwrap(),
558 );
559 let diagnostic = Diagnostic::capture(
560 DiagnosticCategory::ExpectedRejection,
561 CaptureSite::FirstObserved,
562 cause().with_input_location(location),
563 );
564 let value = serde_json::to_value(&diagnostic).unwrap();
565 let input = &value["causes"][0]["input_location"];
566 assert_eq!(input["json_line"], 17);
567 assert_eq!(input["json_column"], 0);
568 assert_eq!(input["file"]["value"], "mapping.json");
569 assert_eq!(input["table"]["value"], "order\\u000a");
570 assert_eq!(input["column"]["value"], "[redacted]");
571 assert_eq!(input["locator_truncated"], true);
572 assert_eq!(input["locator_redacted"], true);
573 assert_ne!(value["origin"]["line"], input["json_line"]);
574 for text in [
575 value.to_string(),
576 format!("{diagnostic}"),
577 format!("{diagnostic:?}"),
578 ] {
579 assert!(!text.contains("SECRET"));
580 }
581 assert!(DiagnosticLocator::from_projection("https://secret", false, false).is_none());
582 assert!(DiagnosticLocator::from_projection("raw\ncontrol", false, false).is_none());
583 assert!(DiagnosticLocator::from_projection(&"界".repeat(65), false, false).is_none());
584 let absent = serde_json::to_value(
585 DiagnosticInputLocation::new(None, None).with_locator_status(true, true),
586 )
587 .unwrap();
588 assert!(absent["json_line"].is_null());
589 assert_eq!(absent["locator_redacted"], true);
590 }
591 #[test]
592 fn diagnostic_panic_origin_subprocess() {
593 const CHILD: &str = "SADDLE_DIAGNOSTIC_PANIC_CHILD";
594 if std::env::var_os(CHILD).is_some() {
595 let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
596 let hook_capture = captured.clone();
597 std::panic::set_hook(Box::new(move |info| {
598 *hook_capture.lock().unwrap() = Some(Diagnostic::capture_panic(
599 info,
600 DiagnosticStage::RequestHandler,
601 ));
602 }));
603 let panic_line = line!() + 1;
604 let result = std::panic::catch_unwind(|| panic!("SENSITIVE_PANIC_PAYLOAD"));
605 assert!(result.is_err());
606 let diagnostic = captured.lock().unwrap().take().unwrap();
607 assert_eq!(diagnostic.origin.line, panic_line);
608 assert!(matches!(diagnostic.capture_site, CaptureSite::Origin));
609 assert_eq!(diagnostic.stack_status, "captured");
610 assert!(!diagnostic.stack.is_empty());
611 assert!(!format!("{diagnostic:?}").contains("SENSITIVE_PANIC_PAYLOAD"));
612 return;
613 }
614 let output = std::process::Command::new(std::env::current_exe().unwrap())
615 .args([
616 "--exact",
617 "diagnostic::tests::diagnostic_panic_origin_subprocess",
618 "--nocapture",
619 ])
620 .env(CHILD, "1")
621 .output()
622 .unwrap();
623 assert!(
624 output.status.success(),
625 "{}",
626 String::from_utf8_lossy(&output.stderr)
627 );
628 }
629}