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