1use serde::Serialize;
3#[cfg(test)]
4use std::fmt::Write;
5use std::{
6 error::Error,
7 fmt,
8 panic::Location,
9 sync::atomic::{AtomicU64, Ordering},
10};
11
12const MAX_CAUSES: usize = 8;
13#[cfg(test)]
14const MAX_STACK_BYTES: usize = 16 * 1024;
15pub(crate) static NEXT_ID: AtomicU64 = AtomicU64::new(1);
16
17#[derive(Clone, Copy, Debug, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum DiagnosticCategory {
20 ExpectedRejection,
21 UnexpectedError,
22 Panic,
23 InvariantViolation,
24}
25
26#[derive(Clone, Copy, Debug, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum DiagnosticStage {
29 StartupConfig,
30 StartupLogging,
31 StartupDbMapping,
32 StartupDbConnect,
33 StartupOutbound,
34 StartupListener,
35 RequestDecode,
36 RequestAdmission,
37 RequestHandler,
38 RequestDb,
39 RequestOutbound,
40 RequestResponse,
41 BackgroundTask,
42 ShutdownComponent,
43 ShutdownLogger,
44 FinalizerResource,
45}
46
47#[derive(Clone, Copy, Debug, Serialize)]
48#[serde(rename_all = "snake_case")]
49pub enum CaptureSite {
50 Origin,
51 FirstObserved,
52}
53
54#[derive(Clone, Copy, Debug, Serialize)]
56pub struct DiagnosticCode(&'static str);
57impl DiagnosticCode {
58 pub fn new(code: &'static str) -> Option<Self> {
59 (!code.is_empty()
60 && code.len() <= 128
61 && code
62 .bytes()
63 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)))
64 .then_some(Self(code))
65 }
66}
67
68#[derive(Debug, Serialize)]
69pub struct DiagnosticLocation {
70 file: String,
71 line: u32,
72 column: u32,
73}
74
75#[derive(Debug, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum DiagnosticObjectKind {
78 ConfigKey,
79 MappingFile,
80 LogicalTable,
81 LogicalColumn,
82 TargetAlias,
83 LogPath,
84}
85
86#[derive(Debug, Serialize)]
90pub struct DiagnosticObject {
91 kind: DiagnosticObjectKind,
92 value: String,
93}
94impl DiagnosticObject {
95 pub fn new(kind: DiagnosticObjectKind, value: &str) -> Option<Self> {
96 let path = matches!(
97 kind,
98 DiagnosticObjectKind::MappingFile | DiagnosticObjectKind::LogPath
99 );
100 let valid = !value.is_empty()
101 && value.len() <= 256
102 && value
103 .chars()
104 .all(|c| c.is_alphanumeric() || "_.-".contains(c) || (path && c == '/'))
105 && !value.split('/').any(|part| part == "..")
106 && (!value.starts_with('/') || matches!(kind, DiagnosticObjectKind::LogPath));
107 valid.then(|| Self {
108 kind,
109 value: value.to_owned(),
110 })
111 }
112}
113fn safe_file(file: &str) -> String {
114 let file = file.rsplit("/crates/").next().unwrap_or(file);
115 let file = if file.starts_with('/') || file.contains('\\') {
116 file.rsplit(['/', '\\']).next().unwrap_or("unknown")
117 } else {
118 file
119 };
120 file.chars().filter(|c| !c.is_control()).take(256).collect()
121}
122
123#[derive(Debug, Serialize)]
125pub struct DiagnosticLocator {
126 value: String,
127 truncated: bool,
128 redacted: bool,
129}
130impl DiagnosticLocator {
131 pub fn from_projection(value: &str, truncated: bool, redacted: bool) -> Option<Self> {
132 if redacted {
133 return Some(Self {
134 value: "[redacted]".into(),
135 truncated,
136 redacted: true,
137 });
138 }
139 if value.is_empty()
140 || value.len() > 192
141 || value.chars().any(|c| {
142 c.is_control() || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
143 })
144 || value.contains(['@', '=', '?', '/'])
145 {
146 return None;
147 }
148 Some(Self {
149 value: value.to_owned(),
150 truncated,
151 redacted: false,
152 })
153 }
154}
155
156#[derive(Debug, Serialize)]
159pub struct DiagnosticInputLocation {
160 json_line: Option<u64>,
161 json_column: Option<u64>,
162 config_key: Option<DiagnosticLocator>,
163 file: Option<DiagnosticLocator>,
164 table: Option<DiagnosticLocator>,
165 column: Option<DiagnosticLocator>,
166 locator_truncated: bool,
167 locator_redacted: bool,
168}
169impl DiagnosticInputLocation {
170 pub fn new(json_line: Option<u64>, json_column: Option<u64>) -> Self {
171 Self {
172 json_line,
173 json_column,
174 config_key: None,
175 file: None,
176 table: None,
177 column: None,
178 locator_truncated: false,
179 locator_redacted: false,
180 }
181 }
182 pub fn with_locator_status(mut self, truncated: bool, redacted: bool) -> Self {
183 self.locator_truncated |= truncated;
184 self.locator_redacted |= redacted;
185 self
186 }
187 fn observe(&mut self, locator: &DiagnosticLocator) {
188 self.locator_truncated |= locator.truncated;
189 self.locator_redacted |= locator.redacted;
190 }
191 pub fn with_config_key(mut self, locator: DiagnosticLocator) -> Self {
192 self.observe(&locator);
193 self.config_key = Some(locator);
194 self
195 }
196 pub fn with_file(mut self, locator: DiagnosticLocator) -> Self {
197 self.observe(&locator);
198 self.file = Some(locator);
199 self
200 }
201 pub fn with_table(mut self, locator: DiagnosticLocator) -> Self {
202 self.observe(&locator);
203 self.table = Some(locator);
204 self
205 }
206 pub fn with_column(mut self, locator: DiagnosticLocator) -> Self {
207 self.observe(&locator);
208 self.column = Some(locator);
209 self
210 }
211}
212
213#[derive(Debug, Serialize)]
215#[serde(rename_all = "snake_case")]
216pub enum DiagnosticTypeUnavailable {
217 OpaqueSource,
218 MetadataUnavailable,
219 NotApplicable,
220 Redacted,
221}
222
223#[derive(Debug, Serialize)]
225pub struct DiagnosticTypeName {
226 value: Option<String>,
227 truncated: bool,
228 redacted: bool,
229 unavailable_reason: Option<DiagnosticTypeUnavailable>,
230}
231impl DiagnosticTypeName {
232 pub fn unavailable(reason: DiagnosticTypeUnavailable) -> Self {
233 let redacted = matches!(reason, DiagnosticTypeUnavailable::Redacted);
234 Self {
235 value: None,
236 truncated: false,
237 redacted,
238 unavailable_reason: Some(reason),
239 }
240 }
241 pub fn from_metadata(value: &str) -> Self {
244 if value.is_empty() {
245 return Self::unavailable(DiagnosticTypeUnavailable::MetadataUnavailable);
246 }
247 if !value
248 .chars()
249 .all(|c| c.is_alphanumeric() || "_::<>[],(); &*.-".contains(c))
250 {
251 return Self::unavailable(DiagnosticTypeUnavailable::Redacted);
252 }
253 let mut end = value.len().min(256);
254 while !value.is_char_boundary(end) {
255 end -= 1;
256 }
257 Self {
258 value: Some(value[..end].into()),
259 truncated: end < value.len(),
260 redacted: false,
261 unavailable_reason: None,
262 }
263 }
264}
265
266#[derive(Debug, Serialize)]
269pub struct DiagnosticDriverDetails {
270 column_index: Option<u64>,
271 column_count: Option<u64>,
272 target_rust_type: DiagnosticTypeName,
273 actual_db_type: DiagnosticTypeName,
274}
275impl DiagnosticDriverDetails {
276 pub fn new(
277 column_index: Option<u64>,
278 column_count: Option<u64>,
279 target_rust_type: DiagnosticTypeName,
280 actual_db_type: DiagnosticTypeName,
281 ) -> Self {
282 Self {
283 column_index,
284 column_count,
285 target_rust_type,
286 actual_db_type,
287 }
288 }
289}
290
291#[derive(Debug, Serialize)]
293pub struct DiagnosticCause {
294 stage: DiagnosticStage,
295 code: DiagnosticCode,
296 io_kind: Option<&'static str>,
297 os_code: Option<i32>,
298 db_code: Option<u32>,
299 sqlstate: Option<String>,
300 object: Option<DiagnosticObject>,
301 input_location: Option<DiagnosticInputLocation>,
302 driver_details: Option<DiagnosticDriverDetails>,
303}
304impl DiagnosticCause {
305 pub fn new(stage: DiagnosticStage, code: DiagnosticCode) -> Self {
306 Self {
307 stage,
308 code,
309 io_kind: None,
310 os_code: None,
311 db_code: None,
312 sqlstate: None,
313 object: None,
314 input_location: None,
315 driver_details: None,
316 }
317 }
318 pub fn with_object(mut self, object: DiagnosticObject) -> Self {
319 self.object = Some(object);
320 self
321 }
322 pub fn with_driver_details(mut self, details: DiagnosticDriverDetails) -> Self {
323 self.driver_details = Some(details);
324 self
325 }
326 pub fn with_input_location(mut self, location: DiagnosticInputLocation) -> Self {
327 self.input_location = Some(location);
328 self
329 }
330 pub fn with_io(mut self, error: &std::io::Error) -> Self {
331 self.io_kind = Some(match error.kind() {
332 std::io::ErrorKind::NotFound => "not_found",
333 std::io::ErrorKind::PermissionDenied => "permission_denied",
334 std::io::ErrorKind::ConnectionRefused => "connection_refused",
335 std::io::ErrorKind::ConnectionReset => "connection_reset",
336 std::io::ErrorKind::TimedOut => "timed_out",
337 std::io::ErrorKind::WouldBlock => "would_block",
338 std::io::ErrorKind::BrokenPipe => "broken_pipe",
339 std::io::ErrorKind::InvalidData => "invalid_data",
340 _ => "other",
341 });
342 self.os_code = error.raw_os_error();
343 self
344 }
345 pub fn with_database_code(mut self, code: u32, sqlstate: Option<&str>) -> Self {
346 self.db_code = Some(code);
347 self.sqlstate = sqlstate
348 .filter(|s| {
349 s.len() == 5
350 && s.bytes()
351 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
352 })
353 .map(str::to_owned);
354 self
355 }
356}
357
358#[derive(Serialize)]
361pub struct Diagnostic {
362 schema_version: u8,
363 diagnostic_id: u64,
364 primary_diagnostic_id: Option<u64>,
365 task: Option<DiagnosticCode>,
366 scope: Option<crate::DbScopeLogFields>,
367 category: DiagnosticCategory,
368 capture_site: CaptureSite,
369 origin: DiagnosticLocation,
370 causes: Vec<DiagnosticCause>,
371 omitted_causes: u64,
372 stack_status: &'static str,
373 stack: String,
374 stack_truncated: bool,
375 #[serde(skip)]
376 deferred_stack: Option<DeferredDiagnosticStack>,
377}
378
379#[derive(Clone)]
381pub struct DeferredDiagnosticStack(());
382#[derive(Serialize)]
383pub struct ResolvedDiagnosticStack {
384 pub stack_status: &'static str,
385 pub stack: String,
386 pub stack_truncated: bool,
387}
388impl DeferredDiagnosticStack {
389 pub fn resolve_on_output_worker(&self) -> ResolvedDiagnosticStack {
391 ResolvedDiagnosticStack {
392 stack_status: "unavailable_deferred",
393 stack: String::new(),
394 stack_truncated: false,
395 }
396 }
397}
398
399#[cfg(test)]
400struct StackText {
401 text: String,
402 truncated: bool,
403}
404#[cfg(test)]
405impl Write for StackText {
406 fn write_str(&mut self, value: &str) -> fmt::Result {
407 let remaining = MAX_STACK_BYTES.saturating_sub(self.text.len());
408 let mut end = remaining.min(value.len());
409 while !value.is_char_boundary(end) {
410 end -= 1;
411 }
412 self.text.push_str(&value[..end]);
413 self.truncated |= end < value.len();
414 if self.truncated {
415 Err(fmt::Error)
416 } else {
417 Ok(())
418 }
419 }
420}
421impl Diagnostic {
422 #[track_caller]
423 pub fn capture(
424 category: DiagnosticCategory,
425 site: CaptureSite,
426 cause: DiagnosticCause,
427 ) -> Self {
428 let location = Location::caller();
429 let mut result = Self {
430 schema_version: 1,
431 diagnostic_id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
432 primary_diagnostic_id: None,
433 task: None,
434 scope: None,
435 category,
436 capture_site: site,
437 origin: DiagnosticLocation {
438 file: safe_file(location.file()),
439 line: location.line(),
440 column: location.column(),
441 },
442 causes: vec![cause],
443 omitted_causes: 0,
444 stack_status: "not_requested_expected",
445 stack: String::new(),
446 stack_truncated: false,
447 deferred_stack: None,
448 };
449 if !matches!(category, DiagnosticCategory::ExpectedRejection) {
450 result.stack_status = "unavailable_deferred";
451 }
452 result
453 }
454 pub fn deferred_stack(&self) -> Option<DeferredDiagnosticStack> {
455 self.deferred_stack.clone()
456 }
457 pub fn capture_panic(info: &std::panic::PanicHookInfo<'_>, stage: DiagnosticStage) -> Self {
460 let mut result = Self::capture(
461 DiagnosticCategory::Panic,
462 CaptureSite::FirstObserved,
463 DiagnosticCause::new(stage, DiagnosticCode("runtime.panic")),
464 );
465 if let Some(location) = info.location() {
466 result.capture_site = CaptureSite::Origin;
467 result.origin = DiagnosticLocation {
468 file: safe_file(location.file()),
469 line: location.line(),
470 column: location.column(),
471 };
472 }
473 result
474 }
475 pub fn wrap(mut self, cause: DiagnosticCause) -> Self {
477 if self.causes.len() < MAX_CAUSES {
478 self.causes.insert(0, cause);
479 } else {
480 self.omitted_causes = self.omitted_causes.saturating_add(1);
481 }
482 self
483 }
484 pub const fn id(&self) -> u64 {
485 self.diagnostic_id
486 }
487 pub fn occurrence(&self) -> crate::DiagnosticOccurrence {
489 crate::DiagnosticOccurrence::from_diagnostic(self)
490 }
491 pub(crate) const fn primary_id_for_projection(&self) -> Option<u64> {
492 self.primary_diagnostic_id
493 }
494 pub fn with_task(mut self, registered_task: DiagnosticCode) -> Self {
495 self.task = Some(registered_task);
496 self
497 }
498 pub fn with_scope(mut self, scope: crate::DbScopeLogFields) -> Self {
500 self.scope = Some(scope);
501 self
502 }
503 pub const fn category(&self) -> DiagnosticCategory {
504 self.category
505 }
506
507 pub fn during_cleanup_of(mut self, primary: &Diagnostic) -> Self {
509 self.primary_diagnostic_id = Some(primary.id());
510 self
511 }
512
513 pub fn during_cleanup_of_occurrence(mut self, primary: &crate::DiagnosticOccurrence) -> Self {
516 self.primary_diagnostic_id = Some(primary.source_id());
517 self
518 }
519}
520impl fmt::Display for Diagnostic {
521 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522 write!(
523 f,
524 "diagnostic={} primary={:?} {:?} {:?} at {}:{} stack={}",
525 self.diagnostic_id,
526 self.primary_diagnostic_id,
527 self.category,
528 self.capture_site,
529 self.origin.file,
530 self.origin.line,
531 self.stack_status
532 )?;
533 for cause in &self.causes {
534 write!(
535 f,
536 " <- {:?}/{} io={:?} os={:?} db={:?} sqlstate={:?} object={:?} input_location={:?} driver_details={:?}",
537 cause.stage,
538 cause.code.0,
539 cause.io_kind,
540 cause.os_code,
541 cause.db_code,
542 cause.sqlstate,
543 cause.object,
544 cause.input_location,
545 cause.driver_details
546 )?;
547 }
548 write!(
549 f,
550 " omitted_causes={} stack_truncated={}\n{}",
551 self.omitted_causes, self.stack_truncated, self.stack
552 )
553 }
554}
555impl fmt::Debug for Diagnostic {
556 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557 fmt::Display::fmt(self, f)
558 }
559}
560impl Error for Diagnostic {}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 #[test]
566 fn driver_details_preserve_metadata_and_reject_unsafe_text() {
567 let d = Diagnostic::capture(
568 DiagnosticCategory::ExpectedRejection,
569 CaptureSite::FirstObserved,
570 cause().with_driver_details(DiagnosticDriverDetails::new(
571 Some(0),
572 Some(12),
573 DiagnosticTypeName::from_metadata("core::option::Option<alloc::string::String>"),
574 DiagnosticTypeName::from_metadata("VARCHAR(255)"),
575 )),
576 );
577 let v = serde_json::to_value(&d).unwrap();
578 let fields = &v["causes"][0]["driver_details"];
579 assert_eq!(fields["column_index"], 0);
580 assert_eq!(fields["column_count"], 12);
581 assert_eq!(fields["actual_db_type"]["value"], "VARCHAR(255)");
582 assert!(format!("{d:?}").contains("Option<alloc::string::String>"));
583 for unsafe_value in [
584 "mysql://user:SECRET@host",
585 "enum('SECRET')",
586 "type\nSECRET",
587 "T\u{202e}SECRET",
588 ] {
589 let name = DiagnosticTypeName::from_metadata(unsafe_value);
590 let v = serde_json::to_value(&name).unwrap();
591 assert_eq!(v["redacted"], true);
592 assert!(!format!("{name:?}").contains("SECRET"));
593 }
594 let long =
595 serde_json::to_value(DiagnosticTypeName::from_metadata(&"界".repeat(100))).unwrap();
596 assert_eq!(long["truncated"], true);
597 assert!(long["value"].as_str().unwrap().len() <= 256);
598 let absent = serde_json::to_value(DiagnosticDriverDetails::new(
599 None,
600 None,
601 DiagnosticTypeName::unavailable(DiagnosticTypeUnavailable::OpaqueSource),
602 DiagnosticTypeName::unavailable(DiagnosticTypeUnavailable::MetadataUnavailable),
603 ))
604 .unwrap();
605 assert!(absent["column_index"].is_null());
606 assert_eq!(
607 absent["target_rust_type"]["unavailable_reason"],
608 "opaque_source"
609 );
610 }
611 fn cause() -> DiagnosticCause {
612 DiagnosticCause::new(
613 DiagnosticStage::RequestDb,
614 DiagnosticCode::new("db.connect_failed").unwrap(),
615 )
616 }
617 #[test]
618 fn diagnostic_capture_wrap_and_cleanup_preserve_origin() {
619 let original = Diagnostic::capture(
620 DiagnosticCategory::UnexpectedError,
621 CaptureSite::FirstObserved,
622 cause(),
623 );
624 let before = serde_json::to_value(&original).unwrap();
625 assert_ne!(before["stack_status"], "not_requested_expected");
626 let wrapped = original.wrap(cause());
627 let after = serde_json::to_value(&wrapped).unwrap();
628 assert_eq!(before["origin"], after["origin"]);
629 assert_eq!(before["stack"], after["stack"]);
630 assert_eq!(before["diagnostic_id"], after["diagnostic_id"]);
631 let cleanup = Diagnostic::capture(
632 DiagnosticCategory::UnexpectedError,
633 CaptureSite::FirstObserved,
634 cause(),
635 )
636 .during_cleanup_of(&wrapped);
637 let cleanup_before = serde_json::to_value(&cleanup).unwrap();
638 let reference = cleanup.occurrence();
639 assert_eq!(serde_json::to_value(&cleanup).unwrap(), cleanup_before);
640 let reference = serde_json::to_value(reference).unwrap();
641 assert_eq!(reference["diagnostic_id"], cleanup_before["diagnostic_id"]);
642 assert_eq!(reference["primary_diagnostic_id"], after["diagnostic_id"]);
643 assert_eq!(reference.as_object().unwrap().len(), 2);
644 assert_eq!(
645 serde_json::to_value(wrapped.occurrence()).unwrap()["diagnostic_id"],
646 after["diagnostic_id"]
647 );
648 assert_eq!(
649 serde_json::to_value(cleanup).unwrap()["primary_diagnostic_id"],
650 after["diagnostic_id"]
651 );
652 }
653 #[test]
654 fn diagnostic_safe_projection_and_bounds() {
655 let io = std::io::Error::other("SECRET_DRIVER_PAYLOAD");
656 let mut diagnostic = Diagnostic::capture(
657 DiagnosticCategory::ExpectedRejection,
658 CaptureSite::Origin,
659 cause().with_io(&io).with_database_code(1045, Some("28000")),
660 );
661 for _ in 0..20 {
662 diagnostic = diagnostic.wrap(cause());
663 }
664 let value = serde_json::to_value(&diagnostic).unwrap();
665 assert_eq!(value["causes"].as_array().unwrap().len(), 8);
666 assert_eq!(value["omitted_causes"], 13);
667 assert_eq!(value["stack_status"], "not_requested_expected");
668 let error =
669 crate::SaddleError::new(crate::ErrorKind::Internal, "internal", "SECRET_MESSAGE")
670 .with_diagnostic(diagnostic);
671 for output in [error.to_string(), format!("{error:?}"), value.to_string()] {
672 assert!(!output.contains("SECRET_"));
673 }
674 assert!(error.source().is_some());
675 assert!(
676 DiagnosticObject::new(
677 DiagnosticObjectKind::TargetAlias,
678 "https://user:password@host"
679 )
680 .is_none()
681 );
682 assert!(DiagnosticObject::new(DiagnosticObjectKind::MappingFile, "../secret").is_none());
683 assert!(
684 DiagnosticObject::new(
685 DiagnosticObjectKind::LogPath,
686 "/srv/logs/saddle.emergency.log"
687 )
688 .is_some()
689 );
690 let mut output = StackText {
691 text: String::new(),
692 truncated: false,
693 };
694 assert!(output.write_str(&"界".repeat(MAX_STACK_BYTES)).is_err());
695 assert!(output.text.len() <= MAX_STACK_BYTES && output.truncated);
696 }
697 #[test]
698 fn diagnostic_input_location_is_distinct_bounded_and_safe() {
699 let location = DiagnosticInputLocation::new(Some(17), Some(0))
700 .with_file(DiagnosticLocator::from_projection("mapping.json", false, false).unwrap())
701 .with_table(DiagnosticLocator::from_projection("order\\u000a", true, false).unwrap())
702 .with_column(DiagnosticLocator::from_projection("SECRET@value", false, true).unwrap())
703 .with_config_key(
704 DiagnosticLocator::from_projection("database.mappingDir", false, false).unwrap(),
705 );
706 let diagnostic = Diagnostic::capture(
707 DiagnosticCategory::ExpectedRejection,
708 CaptureSite::FirstObserved,
709 cause().with_input_location(location),
710 );
711 let value = serde_json::to_value(&diagnostic).unwrap();
712 let input = &value["causes"][0]["input_location"];
713 assert_eq!(input["json_line"], 17);
714 assert_eq!(input["json_column"], 0);
715 assert_eq!(input["file"]["value"], "mapping.json");
716 assert_eq!(input["table"]["value"], "order\\u000a");
717 assert_eq!(input["column"]["value"], "[redacted]");
718 assert_eq!(input["locator_truncated"], true);
719 assert_eq!(input["locator_redacted"], true);
720 assert_ne!(value["origin"]["line"], input["json_line"]);
721 for text in [
722 value.to_string(),
723 format!("{diagnostic}"),
724 format!("{diagnostic:?}"),
725 ] {
726 assert!(!text.contains("SECRET"));
727 }
728 assert!(DiagnosticLocator::from_projection("https://secret", false, false).is_none());
729 assert!(DiagnosticLocator::from_projection("raw\ncontrol", false, false).is_none());
730 assert!(DiagnosticLocator::from_projection(&"界".repeat(65), false, false).is_none());
731 let absent = serde_json::to_value(
732 DiagnosticInputLocation::new(None, None).with_locator_status(true, true),
733 )
734 .unwrap();
735 assert!(absent["json_line"].is_null());
736 assert_eq!(absent["locator_redacted"], true);
737 }
738 #[test]
739 fn diagnostic_panic_origin_subprocess() {
740 const CHILD: &str = "SADDLE_DIAGNOSTIC_PANIC_CHILD";
741 if std::env::var_os(CHILD).is_some() {
742 let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
743 let hook_capture = captured.clone();
744 std::panic::set_hook(Box::new(move |info| {
745 *hook_capture.lock().unwrap() = Some(Diagnostic::capture_panic(
746 info,
747 DiagnosticStage::RequestHandler,
748 ));
749 }));
750 let panic_line = line!() + 1;
751 let result = std::panic::catch_unwind(|| panic!("SENSITIVE_PANIC_PAYLOAD"));
752 assert!(result.is_err());
753 let diagnostic = captured.lock().unwrap().take().unwrap();
754 assert_eq!(diagnostic.origin.line, panic_line);
755 assert!(matches!(diagnostic.capture_site, CaptureSite::Origin));
756 assert_eq!(diagnostic.stack_status, "unavailable_deferred");
757 assert!(diagnostic.stack.is_empty());
758 assert!(diagnostic.deferred_stack().is_none());
759 assert!(!format!("{diagnostic:?}").contains("SENSITIVE_PANIC_PAYLOAD"));
760 return;
761 }
762 let output = std::process::Command::new(std::env::current_exe().unwrap())
763 .args([
764 "--exact",
765 "diagnostic::tests::diagnostic_panic_origin_subprocess",
766 "--nocapture",
767 ])
768 .env(CHILD, "1")
769 .output()
770 .unwrap();
771 assert!(
772 output.status.success(),
773 "{}",
774 String::from_utf8_lossy(&output.stderr)
775 );
776 }
777}