Skip to main content

saddle_core/
diagnostic.rs

1//! Bounded, safe diagnostic facts; not transaction or resource authority.
2use 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/// A validated static code, never an arbitrary error message or panic payload.
55#[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/// The source owner must provide a schema object, never a user value or URL.
87/// Validation excludes common credential/path injection syntax; it is not a
88/// classifier capable of detecting arbitrary secrets embedded in object names.
89#[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/// Already escaped source-owner locator. Never accepts driver messages or URLs.
124#[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/// Input-document coordinates and a fixed set of logical locators. These are
157/// not Rust source coordinates; zero/unavailable values are preserved honestly.
158#[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/// Why a driver type could not be obtained; never substitute a guessed type.
214#[derive(Debug, Serialize)]
215#[serde(rename_all = "snake_case")]
216pub enum DiagnosticTypeUnavailable {
217    OpaqueSource,
218    MetadataUnavailable,
219    NotApplicable,
220    Redacted,
221}
222
223/// Source-owned type metadata, not an arbitrary driver error message.
224#[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    /// Only pass audited type metadata (e.g. Rust type_name / DB type metadata).
242    /// Unsafe syntax is explicitly redacted. Long safe names retain a prefix.
243    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/// Fixed driver details. Numeric positions are zero-based driver facts, not
267/// JSON input coordinates. Missing positions remain None.
268#[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/// Only classified source facts are accepted. Raw driver Display/Debug is absent.
292#[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/// Retains safe diagnostic facts without native stack capture or resolution.
359/// Native stacks are explicitly deferred, not claimed captured or repaired.
360#[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/// Compatibility handle. No issuer in this release: native stacks are deferred.
380#[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    /// Compatibility projection only; never captures or resolves native frames.
390    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    /// Called by the ONE process hook before unwind. Does not read the panic
458    /// payload. Installing/chaining hooks and scoped context belong to the host.
459    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    /// Add outer context without replacing original occurrence or stack.
476    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    /// Borrow the existing occurrence without capturing a new stack or identity.
488    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    /// Only the existing checked scope formatter can supply this field.
499    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    /// Cleanup is a sibling occurrence, not a cause of the primary failure.
508    pub fn during_cleanup_of(mut self, primary: &Diagnostic) -> Self {
509        self.primary_diagnostic_id = Some(primary.id());
510        self
511    }
512
513    /// Relate cleanup to an existing bounded or legacy occurrence without
514    /// accepting a caller-provided raw ID or duplicating its source details.
515    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}