Skip to main content

saddle_core/
diagnostic.rs

1//! Bounded, safe diagnostic facts; not transaction or resource authority.
2use 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/// A validated static code, never an arbitrary error message or panic payload.
54#[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/// The source owner must provide a schema object, never a user value or URL.
86/// Validation excludes common credential/path injection syntax; it is not a
87/// classifier capable of detecting arbitrary secrets embedded in object names.
88#[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/// Already escaped source-owner locator. Never accepts driver messages or URLs.
123#[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/// Input-document coordinates and a fixed set of logical locators. These are
156/// not Rust source coordinates; zero/unavailable values are preserved honestly.
157#[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/// Why a driver type could not be obtained; never substitute a guessed type.
213#[derive(Debug, Serialize)]
214#[serde(rename_all = "snake_case")]
215pub enum DiagnosticTypeUnavailable {
216    OpaqueSource,
217    MetadataUnavailable,
218    NotApplicable,
219    Redacted,
220}
221
222/// Source-owned type metadata, not an arbitrary driver error message.
223#[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    /// Only pass audited type metadata (e.g. Rust type_name / DB type metadata).
241    /// Unsafe syntax is explicitly redacted. Long safe names retain a prefix.
242    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/// Fixed driver details. Numeric positions are zero-based driver facts, not
266/// JSON input coordinates. Missing positions remain None.
267#[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/// Only classified source facts are accepted. Raw driver Display/Debug is absent.
291#[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/// Captures an available synchronous stack once. Capture/symbolization itself
358/// is not strictly resource bounded; only retained output is bounded.
359#[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            // Source path lines are metadata, not user/home absolute paths.
450            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    /// Called by the ONE process hook before unwind. Does not read the panic
467    /// payload. Installing/chaining hooks and scoped context belong to the host.
468    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    /// Add outer context without replacing original occurrence or stack.
485    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    /// Only the existing checked scope formatter can supply this field.
501    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    /// Cleanup is a sibling occurrence, not a cause of the primary failure.
510    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}