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)]
214#[serde(rename_all = "snake_case")]
215pub enum DiagnosticTypeUnavailable {
216 OpaqueSource,
217 MetadataUnavailable,
218 NotApplicable,
219 Redacted,
220}
221
222#[derive(Debug, Serialize)]
224pub struct DiagnosticTypeName {
225 value: Option<String>,
226 truncated: bool,
227 redacted: bool,
228 unavailable_reason: Option<DiagnosticTypeUnavailable>,
229}
230impl DiagnosticTypeName {
231 pub fn unavailable(reason: DiagnosticTypeUnavailable) -> Self {
232 let redacted = matches!(reason, DiagnosticTypeUnavailable::Redacted);
233 Self {
234 value: None,
235 truncated: false,
236 redacted,
237 unavailable_reason: Some(reason),
238 }
239 }
240 pub fn from_metadata(value: &str) -> Self {
243 if value.is_empty() {
244 return Self::unavailable(DiagnosticTypeUnavailable::MetadataUnavailable);
245 }
246 if !value
247 .chars()
248 .all(|c| c.is_alphanumeric() || "_::<>[],(); &*.-".contains(c))
249 {
250 return Self::unavailable(DiagnosticTypeUnavailable::Redacted);
251 }
252 let mut end = value.len().min(256);
253 while !value.is_char_boundary(end) {
254 end -= 1;
255 }
256 Self {
257 value: Some(value[..end].into()),
258 truncated: end < value.len(),
259 redacted: false,
260 unavailable_reason: None,
261 }
262 }
263}
264
265#[derive(Debug, Serialize)]
268pub struct DiagnosticDriverDetails {
269 column_index: Option<u64>,
270 column_count: Option<u64>,
271 target_rust_type: DiagnosticTypeName,
272 actual_db_type: DiagnosticTypeName,
273}
274impl DiagnosticDriverDetails {
275 pub fn new(
276 column_index: Option<u64>,
277 column_count: Option<u64>,
278 target_rust_type: DiagnosticTypeName,
279 actual_db_type: DiagnosticTypeName,
280 ) -> Self {
281 Self {
282 column_index,
283 column_count,
284 target_rust_type,
285 actual_db_type,
286 }
287 }
288}
289
290#[derive(Debug, Serialize)]
292pub struct DiagnosticCause {
293 stage: DiagnosticStage,
294 code: DiagnosticCode,
295 io_kind: Option<&'static str>,
296 os_code: Option<i32>,
297 db_code: Option<u32>,
298 sqlstate: Option<String>,
299 object: Option<DiagnosticObject>,
300 input_location: Option<DiagnosticInputLocation>,
301 driver_details: Option<DiagnosticDriverDetails>,
302}
303impl DiagnosticCause {
304 pub fn new(stage: DiagnosticStage, code: DiagnosticCode) -> Self {
305 Self {
306 stage,
307 code,
308 io_kind: None,
309 os_code: None,
310 db_code: None,
311 sqlstate: None,
312 object: None,
313 input_location: None,
314 driver_details: None,
315 }
316 }
317 pub fn with_object(mut self, object: DiagnosticObject) -> Self {
318 self.object = Some(object);
319 self
320 }
321 pub fn with_driver_details(mut self, details: DiagnosticDriverDetails) -> Self {
322 self.driver_details = Some(details);
323 self
324 }
325 pub fn with_input_location(mut self, location: DiagnosticInputLocation) -> Self {
326 self.input_location = Some(location);
327 self
328 }
329 pub fn with_io(mut self, error: &std::io::Error) -> Self {
330 self.io_kind = Some(match error.kind() {
331 std::io::ErrorKind::NotFound => "not_found",
332 std::io::ErrorKind::PermissionDenied => "permission_denied",
333 std::io::ErrorKind::ConnectionRefused => "connection_refused",
334 std::io::ErrorKind::ConnectionReset => "connection_reset",
335 std::io::ErrorKind::TimedOut => "timed_out",
336 std::io::ErrorKind::WouldBlock => "would_block",
337 std::io::ErrorKind::BrokenPipe => "broken_pipe",
338 std::io::ErrorKind::InvalidData => "invalid_data",
339 _ => "other",
340 });
341 self.os_code = error.raw_os_error();
342 self
343 }
344 pub fn with_database_code(mut self, code: u32, sqlstate: Option<&str>) -> Self {
345 self.db_code = Some(code);
346 self.sqlstate = sqlstate
347 .filter(|s| {
348 s.len() == 5
349 && s.bytes()
350 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
351 })
352 .map(str::to_owned);
353 self
354 }
355}
356
357#[derive(Serialize)]
360pub struct Diagnostic {
361 schema_version: u8,
362 diagnostic_id: u64,
363 primary_diagnostic_id: Option<u64>,
364 task: Option<DiagnosticCode>,
365 scope: Option<crate::DbScopeLogFields>,
366 category: DiagnosticCategory,
367 capture_site: CaptureSite,
368 origin: DiagnosticLocation,
369 causes: Vec<DiagnosticCause>,
370 omitted_causes: u64,
371 stack_status: &'static str,
372 stack: String,
373 stack_truncated: bool,
374}
375
376struct StackText {
377 text: String,
378 truncated: bool,
379}
380impl Write for StackText {
381 fn write_str(&mut self, value: &str) -> fmt::Result {
382 let remaining = MAX_STACK_BYTES.saturating_sub(self.text.len());
383 let mut end = remaining.min(value.len());
384 while !value.is_char_boundary(end) {
385 end -= 1;
386 }
387 self.text.push_str(&value[..end]);
388 self.truncated |= end < value.len();
389 if self.truncated {
390 Err(fmt::Error)
391 } else {
392 Ok(())
393 }
394 }
395}
396impl Diagnostic {
397 #[track_caller]
398 pub fn capture(
399 category: DiagnosticCategory,
400 site: CaptureSite,
401 cause: DiagnosticCause,
402 ) -> Self {
403 let location = Location::caller();
404 let mut result = Self {
405 schema_version: 1,
406 diagnostic_id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
407 primary_diagnostic_id: None,
408 task: None,
409 scope: None,
410 category,
411 capture_site: site,
412 origin: DiagnosticLocation {
413 file: safe_file(location.file()),
414 line: location.line(),
415 column: location.column(),
416 },
417 causes: vec![cause],
418 omitted_causes: 0,
419 stack_status: "not_requested_expected",
420 stack: String::new(),
421 stack_truncated: false,
422 };
423 if !matches!(category, DiagnosticCategory::ExpectedRejection) {
424 if CAPTURING
425 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
426 .is_err()
427 {
428 result.stack_status = "suppressed_concurrent_or_reentrant";
429 return result;
430 }
431 struct CaptureGuard;
432 impl Drop for CaptureGuard {
433 fn drop(&mut self) {
434 CAPTURING.store(false, Ordering::Release);
435 }
436 }
437 let _capture = CaptureGuard;
438 let trace = Backtrace::force_capture();
439 result.stack_status = match trace.status() {
440 BacktraceStatus::Captured => "captured",
441 BacktraceStatus::Disabled => "disabled",
442 _ => "unsupported",
443 };
444 let mut output = StackText {
445 text: String::new(),
446 truncated: false,
447 };
448 let _ = write!(output, "{trace}");
449 result.stack = output
451 .text
452 .lines()
453 .map(|line| {
454 if let Some((_, path)) = line.split_once(" at ") {
455 format!(" at {}", safe_file(path))
456 } else {
457 line.to_owned()
458 }
459 })
460 .collect::<Vec<_>>()
461 .join("\n");
462 result.stack_truncated = output.truncated;
463 }
464 result
465 }
466 pub fn capture_panic(info: &std::panic::PanicHookInfo<'_>, stage: DiagnosticStage) -> Self {
469 let mut result = Self::capture(
470 DiagnosticCategory::Panic,
471 CaptureSite::FirstObserved,
472 DiagnosticCause::new(stage, DiagnosticCode("runtime.panic")),
473 );
474 if let Some(location) = info.location() {
475 result.capture_site = CaptureSite::Origin;
476 result.origin = DiagnosticLocation {
477 file: safe_file(location.file()),
478 line: location.line(),
479 column: location.column(),
480 };
481 }
482 result
483 }
484 pub fn wrap(mut self, cause: DiagnosticCause) -> Self {
486 if self.causes.len() < MAX_CAUSES {
487 self.causes.insert(0, cause);
488 } else {
489 self.omitted_causes = self.omitted_causes.saturating_add(1);
490 }
491 self
492 }
493 pub const fn id(&self) -> u64 {
494 self.diagnostic_id
495 }
496 pub fn with_task(mut self, registered_task: DiagnosticCode) -> Self {
497 self.task = Some(registered_task);
498 self
499 }
500 pub fn with_scope(mut self, scope: crate::DbScopeLogFields) -> Self {
502 self.scope = Some(scope);
503 self
504 }
505 pub const fn category(&self) -> DiagnosticCategory {
506 self.category
507 }
508
509 pub fn during_cleanup_of(mut self, primary: &Diagnostic) -> Self {
511 self.primary_diagnostic_id = Some(primary.id());
512 self
513 }
514}
515impl fmt::Display for Diagnostic {
516 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
517 write!(
518 f,
519 "diagnostic={} primary={:?} {:?} {:?} at {}:{} stack={}",
520 self.diagnostic_id,
521 self.primary_diagnostic_id,
522 self.category,
523 self.capture_site,
524 self.origin.file,
525 self.origin.line,
526 self.stack_status
527 )?;
528 for cause in &self.causes {
529 write!(
530 f,
531 " <- {:?}/{} io={:?} os={:?} db={:?} sqlstate={:?} object={:?} input_location={:?} driver_details={:?}",
532 cause.stage,
533 cause.code.0,
534 cause.io_kind,
535 cause.os_code,
536 cause.db_code,
537 cause.sqlstate,
538 cause.object,
539 cause.input_location,
540 cause.driver_details
541 )?;
542 }
543 write!(
544 f,
545 " omitted_causes={} stack_truncated={}\n{}",
546 self.omitted_causes, self.stack_truncated, self.stack
547 )
548 }
549}
550impl fmt::Debug for Diagnostic {
551 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
552 fmt::Display::fmt(self, f)
553 }
554}
555impl Error for Diagnostic {}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 #[test]
561 fn driver_details_preserve_metadata_and_reject_unsafe_text() {
562 let d = Diagnostic::capture(
563 DiagnosticCategory::ExpectedRejection,
564 CaptureSite::FirstObserved,
565 cause().with_driver_details(DiagnosticDriverDetails::new(
566 Some(0),
567 Some(12),
568 DiagnosticTypeName::from_metadata("core::option::Option<alloc::string::String>"),
569 DiagnosticTypeName::from_metadata("VARCHAR(255)"),
570 )),
571 );
572 let v = serde_json::to_value(&d).unwrap();
573 let fields = &v["causes"][0]["driver_details"];
574 assert_eq!(fields["column_index"], 0);
575 assert_eq!(fields["column_count"], 12);
576 assert_eq!(fields["actual_db_type"]["value"], "VARCHAR(255)");
577 assert!(format!("{d:?}").contains("Option<alloc::string::String>"));
578 for unsafe_value in [
579 "mysql://user:SECRET@host",
580 "enum('SECRET')",
581 "type\nSECRET",
582 "T\u{202e}SECRET",
583 ] {
584 let name = DiagnosticTypeName::from_metadata(unsafe_value);
585 let v = serde_json::to_value(&name).unwrap();
586 assert_eq!(v["redacted"], true);
587 assert!(!format!("{name:?}").contains("SECRET"));
588 }
589 let long =
590 serde_json::to_value(DiagnosticTypeName::from_metadata(&"界".repeat(100))).unwrap();
591 assert_eq!(long["truncated"], true);
592 assert!(long["value"].as_str().unwrap().len() <= 256);
593 let absent = serde_json::to_value(DiagnosticDriverDetails::new(
594 None,
595 None,
596 DiagnosticTypeName::unavailable(DiagnosticTypeUnavailable::OpaqueSource),
597 DiagnosticTypeName::unavailable(DiagnosticTypeUnavailable::MetadataUnavailable),
598 ))
599 .unwrap();
600 assert!(absent["column_index"].is_null());
601 assert_eq!(
602 absent["target_rust_type"]["unavailable_reason"],
603 "opaque_source"
604 );
605 }
606 fn cause() -> DiagnosticCause {
607 DiagnosticCause::new(
608 DiagnosticStage::RequestDb,
609 DiagnosticCode::new("db.connect_failed").unwrap(),
610 )
611 }
612 #[test]
613 fn diagnostic_capture_wrap_and_cleanup_preserve_origin() {
614 let original = Diagnostic::capture(
615 DiagnosticCategory::UnexpectedError,
616 CaptureSite::FirstObserved,
617 cause(),
618 );
619 let before = serde_json::to_value(&original).unwrap();
620 assert_ne!(before["stack_status"], "not_requested_expected");
621 let wrapped = original.wrap(cause());
622 let after = serde_json::to_value(&wrapped).unwrap();
623 assert_eq!(before["origin"], after["origin"]);
624 assert_eq!(before["stack"], after["stack"]);
625 assert_eq!(before["diagnostic_id"], after["diagnostic_id"]);
626 let cleanup = Diagnostic::capture(
627 DiagnosticCategory::UnexpectedError,
628 CaptureSite::FirstObserved,
629 cause(),
630 )
631 .during_cleanup_of(&wrapped);
632 assert_eq!(
633 serde_json::to_value(cleanup).unwrap()["primary_diagnostic_id"],
634 after["diagnostic_id"]
635 );
636 }
637 #[test]
638 fn diagnostic_safe_projection_and_bounds() {
639 let io = std::io::Error::other("SECRET_DRIVER_PAYLOAD");
640 let mut diagnostic = Diagnostic::capture(
641 DiagnosticCategory::ExpectedRejection,
642 CaptureSite::Origin,
643 cause().with_io(&io).with_database_code(1045, Some("28000")),
644 );
645 for _ in 0..20 {
646 diagnostic = diagnostic.wrap(cause());
647 }
648 let value = serde_json::to_value(&diagnostic).unwrap();
649 assert_eq!(value["causes"].as_array().unwrap().len(), 8);
650 assert_eq!(value["omitted_causes"], 13);
651 assert_eq!(value["stack_status"], "not_requested_expected");
652 let error =
653 crate::SaddleError::new(crate::ErrorKind::Internal, "internal", "SECRET_MESSAGE")
654 .with_diagnostic(diagnostic);
655 for output in [error.to_string(), format!("{error:?}"), value.to_string()] {
656 assert!(!output.contains("SECRET_"));
657 }
658 assert!(error.source().is_some());
659 assert!(
660 DiagnosticObject::new(
661 DiagnosticObjectKind::TargetAlias,
662 "https://user:password@host"
663 )
664 .is_none()
665 );
666 assert!(DiagnosticObject::new(DiagnosticObjectKind::MappingFile, "../secret").is_none());
667 assert!(
668 DiagnosticObject::new(
669 DiagnosticObjectKind::LogPath,
670 "/srv/logs/saddle.emergency.log"
671 )
672 .is_some()
673 );
674 let mut output = StackText {
675 text: String::new(),
676 truncated: false,
677 };
678 assert!(output.write_str(&"界".repeat(MAX_STACK_BYTES)).is_err());
679 assert!(output.text.len() <= MAX_STACK_BYTES && output.truncated);
680 }
681 #[test]
682 fn diagnostic_input_location_is_distinct_bounded_and_safe() {
683 let location = DiagnosticInputLocation::new(Some(17), Some(0))
684 .with_file(DiagnosticLocator::from_projection("mapping.json", false, false).unwrap())
685 .with_table(DiagnosticLocator::from_projection("order\\u000a", true, false).unwrap())
686 .with_column(DiagnosticLocator::from_projection("SECRET@value", false, true).unwrap())
687 .with_config_key(
688 DiagnosticLocator::from_projection("database.mappingDir", false, false).unwrap(),
689 );
690 let diagnostic = Diagnostic::capture(
691 DiagnosticCategory::ExpectedRejection,
692 CaptureSite::FirstObserved,
693 cause().with_input_location(location),
694 );
695 let value = serde_json::to_value(&diagnostic).unwrap();
696 let input = &value["causes"][0]["input_location"];
697 assert_eq!(input["json_line"], 17);
698 assert_eq!(input["json_column"], 0);
699 assert_eq!(input["file"]["value"], "mapping.json");
700 assert_eq!(input["table"]["value"], "order\\u000a");
701 assert_eq!(input["column"]["value"], "[redacted]");
702 assert_eq!(input["locator_truncated"], true);
703 assert_eq!(input["locator_redacted"], true);
704 assert_ne!(value["origin"]["line"], input["json_line"]);
705 for text in [
706 value.to_string(),
707 format!("{diagnostic}"),
708 format!("{diagnostic:?}"),
709 ] {
710 assert!(!text.contains("SECRET"));
711 }
712 assert!(DiagnosticLocator::from_projection("https://secret", false, false).is_none());
713 assert!(DiagnosticLocator::from_projection("raw\ncontrol", false, false).is_none());
714 assert!(DiagnosticLocator::from_projection(&"界".repeat(65), false, false).is_none());
715 let absent = serde_json::to_value(
716 DiagnosticInputLocation::new(None, None).with_locator_status(true, true),
717 )
718 .unwrap();
719 assert!(absent["json_line"].is_null());
720 assert_eq!(absent["locator_redacted"], true);
721 }
722 #[test]
723 fn diagnostic_panic_origin_subprocess() {
724 const CHILD: &str = "SADDLE_DIAGNOSTIC_PANIC_CHILD";
725 if std::env::var_os(CHILD).is_some() {
726 let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
727 let hook_capture = captured.clone();
728 std::panic::set_hook(Box::new(move |info| {
729 *hook_capture.lock().unwrap() = Some(Diagnostic::capture_panic(
730 info,
731 DiagnosticStage::RequestHandler,
732 ));
733 }));
734 let panic_line = line!() + 1;
735 let result = std::panic::catch_unwind(|| panic!("SENSITIVE_PANIC_PAYLOAD"));
736 assert!(result.is_err());
737 let diagnostic = captured.lock().unwrap().take().unwrap();
738 assert_eq!(diagnostic.origin.line, panic_line);
739 assert!(matches!(diagnostic.capture_site, CaptureSite::Origin));
740 assert_eq!(diagnostic.stack_status, "captured");
741 assert!(!diagnostic.stack.is_empty());
742 assert!(!format!("{diagnostic:?}").contains("SENSITIVE_PANIC_PAYLOAD"));
743 return;
744 }
745 let output = std::process::Command::new(std::env::current_exe().unwrap())
746 .args([
747 "--exact",
748 "diagnostic::tests::diagnostic_panic_origin_subprocess",
749 "--nocapture",
750 ])
751 .env(CHILD, "1")
752 .output()
753 .unwrap();
754 assert!(
755 output.status.success(),
756 "{}",
757 String::from_utf8_lossy(&output.stderr)
758 );
759 }
760}