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    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/// A validated static code, never an arbitrary error message or panic payload.
56#[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/// The source owner must provide a schema object, never a user value or URL.
88/// Validation excludes common credential/path injection syntax; it is not a
89/// classifier capable of detecting arbitrary secrets embedded in object names.
90#[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/// Already escaped source-owner locator. Never accepts driver messages or URLs.
125#[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/// Input-document coordinates and a fixed set of logical locators. These are
158/// not Rust source coordinates; zero/unavailable values are preserved honestly.
159#[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/// Why a driver type could not be obtained; never substitute a guessed type.
215#[derive(Debug, Serialize)]
216#[serde(rename_all = "snake_case")]
217pub enum DiagnosticTypeUnavailable {
218    OpaqueSource,
219    MetadataUnavailable,
220    NotApplicable,
221    Redacted,
222}
223
224/// Source-owned type metadata, not an arbitrary driver error message.
225#[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    /// Only pass audited type metadata (e.g. Rust type_name / DB type metadata).
243    /// Unsafe syntax is explicitly redacted. Long safe names retain a prefix.
244    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/// Fixed driver details. Numeric positions are zero-based driver facts, not
268/// JSON input coordinates. Missing positions remain None.
269#[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/// Only classified source facts are accepted. Raw driver Display/Debug is absent.
293#[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/// Retains safe diagnostic facts without native stack capture or resolution.
360/// Native stacks are explicitly deferred, not claimed captured or repaired.
361#[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/// Compatibility handle. No issuer in this release: native stacks are deferred.
381#[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    /// Compatibility projection only; never captures or resolves native frames.
391    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    /// Project a simple fixed capture after leaving a strict request account.
424    /// This preserves the source occurrence and capture site; enriched fixed
425    /// causes are rejected rather than silently losing their facts.
426    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    /// Called by the ONE process hook before unwind. Does not read the panic
482    /// payload. Installing/chaining hooks and scoped context belong to the host.
483    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    /// Add outer context without replacing original occurrence or stack.
500    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    /// Borrow the existing occurrence without capturing a new stack or identity.
512    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    /// Only the existing checked scope formatter can supply this field.
523    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    /// Cleanup is a sibling occurrence, not a cause of the primary failure.
532    pub fn during_cleanup_of(mut self, primary: &Diagnostic) -> Self {
533        self.primary_diagnostic_id = Some(primary.id());
534        self
535    }
536
537    /// Relate cleanup to an existing bounded or legacy occurrence without
538    /// accepting a caller-provided raw ID or duplicating its source details.
539    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}