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/// Only classified source facts are accepted. Raw driver Display/Debug is absent.
213#[derive(Debug, Serialize)]
214pub struct DiagnosticCause {
215    stage: DiagnosticStage,
216    code: DiagnosticCode,
217    io_kind: Option<&'static str>,
218    os_code: Option<i32>,
219    db_code: Option<u32>,
220    sqlstate: Option<String>,
221    object: Option<DiagnosticObject>,
222    input_location: Option<DiagnosticInputLocation>,
223}
224impl DiagnosticCause {
225    pub fn new(stage: DiagnosticStage, code: DiagnosticCode) -> Self {
226        Self {
227            stage,
228            code,
229            io_kind: None,
230            os_code: None,
231            db_code: None,
232            sqlstate: None,
233            object: None,
234            input_location: None,
235        }
236    }
237    pub fn with_object(mut self, object: DiagnosticObject) -> Self {
238        self.object = Some(object);
239        self
240    }
241    pub fn with_input_location(mut self, location: DiagnosticInputLocation) -> Self {
242        self.input_location = Some(location);
243        self
244    }
245    pub fn with_io(mut self, error: &std::io::Error) -> Self {
246        self.io_kind = Some(match error.kind() {
247            std::io::ErrorKind::NotFound => "not_found",
248            std::io::ErrorKind::PermissionDenied => "permission_denied",
249            std::io::ErrorKind::ConnectionRefused => "connection_refused",
250            std::io::ErrorKind::ConnectionReset => "connection_reset",
251            std::io::ErrorKind::TimedOut => "timed_out",
252            std::io::ErrorKind::WouldBlock => "would_block",
253            std::io::ErrorKind::BrokenPipe => "broken_pipe",
254            std::io::ErrorKind::InvalidData => "invalid_data",
255            _ => "other",
256        });
257        self.os_code = error.raw_os_error();
258        self
259    }
260    pub fn with_database_code(mut self, code: u32, sqlstate: Option<&str>) -> Self {
261        self.db_code = Some(code);
262        self.sqlstate = sqlstate
263            .filter(|s| {
264                s.len() == 5
265                    && s.bytes()
266                        .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
267            })
268            .map(str::to_owned);
269        self
270    }
271}
272
273/// Captures an available synchronous stack once. Capture/symbolization itself
274/// is not strictly resource bounded; only retained output is bounded.
275#[derive(Serialize)]
276pub struct Diagnostic {
277    schema_version: u8,
278    diagnostic_id: u64,
279    primary_diagnostic_id: Option<u64>,
280    task: Option<DiagnosticCode>,
281    scope: Option<crate::DbScopeLogFields>,
282    category: DiagnosticCategory,
283    capture_site: CaptureSite,
284    origin: DiagnosticLocation,
285    causes: Vec<DiagnosticCause>,
286    omitted_causes: u64,
287    stack_status: &'static str,
288    stack: String,
289    stack_truncated: bool,
290}
291
292struct StackText {
293    text: String,
294    truncated: bool,
295}
296impl Write for StackText {
297    fn write_str(&mut self, value: &str) -> fmt::Result {
298        let remaining = MAX_STACK_BYTES.saturating_sub(self.text.len());
299        let mut end = remaining.min(value.len());
300        while !value.is_char_boundary(end) {
301            end -= 1;
302        }
303        self.text.push_str(&value[..end]);
304        self.truncated |= end < value.len();
305        if self.truncated {
306            Err(fmt::Error)
307        } else {
308            Ok(())
309        }
310    }
311}
312impl Diagnostic {
313    #[track_caller]
314    pub fn capture(
315        category: DiagnosticCategory,
316        site: CaptureSite,
317        cause: DiagnosticCause,
318    ) -> Self {
319        let location = Location::caller();
320        let mut result = Self {
321            schema_version: 1,
322            diagnostic_id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
323            primary_diagnostic_id: None,
324            task: None,
325            scope: None,
326            category,
327            capture_site: site,
328            origin: DiagnosticLocation {
329                file: safe_file(location.file()),
330                line: location.line(),
331                column: location.column(),
332            },
333            causes: vec![cause],
334            omitted_causes: 0,
335            stack_status: "not_requested_expected",
336            stack: String::new(),
337            stack_truncated: false,
338        };
339        if !matches!(category, DiagnosticCategory::ExpectedRejection) {
340            if CAPTURING
341                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
342                .is_err()
343            {
344                result.stack_status = "suppressed_concurrent_or_reentrant";
345                return result;
346            }
347            struct CaptureGuard;
348            impl Drop for CaptureGuard {
349                fn drop(&mut self) {
350                    CAPTURING.store(false, Ordering::Release);
351                }
352            }
353            let _capture = CaptureGuard;
354            let trace = Backtrace::force_capture();
355            result.stack_status = match trace.status() {
356                BacktraceStatus::Captured => "captured",
357                BacktraceStatus::Disabled => "disabled",
358                _ => "unsupported",
359            };
360            let mut output = StackText {
361                text: String::new(),
362                truncated: false,
363            };
364            let _ = write!(output, "{trace}");
365            // Source path lines are metadata, not user/home absolute paths.
366            result.stack = output
367                .text
368                .lines()
369                .map(|line| {
370                    if let Some((_, path)) = line.split_once(" at ") {
371                        format!(" at {}", safe_file(path))
372                    } else {
373                        line.to_owned()
374                    }
375                })
376                .collect::<Vec<_>>()
377                .join("\n");
378            result.stack_truncated = output.truncated;
379        }
380        result
381    }
382    /// Called by the ONE process hook before unwind. Does not read the panic
383    /// payload. Installing/chaining hooks and scoped context belong to the host.
384    pub fn capture_panic(info: &std::panic::PanicHookInfo<'_>, stage: DiagnosticStage) -> Self {
385        let mut result = Self::capture(
386            DiagnosticCategory::Panic,
387            CaptureSite::FirstObserved,
388            DiagnosticCause::new(stage, DiagnosticCode("runtime.panic")),
389        );
390        if let Some(location) = info.location() {
391            result.capture_site = CaptureSite::Origin;
392            result.origin = DiagnosticLocation {
393                file: safe_file(location.file()),
394                line: location.line(),
395                column: location.column(),
396            };
397        }
398        result
399    }
400    /// Add outer context without replacing original occurrence or stack.
401    pub fn wrap(mut self, cause: DiagnosticCause) -> Self {
402        if self.causes.len() < MAX_CAUSES {
403            self.causes.insert(0, cause);
404        } else {
405            self.omitted_causes = self.omitted_causes.saturating_add(1);
406        }
407        self
408    }
409    pub const fn id(&self) -> u64 {
410        self.diagnostic_id
411    }
412    pub fn with_task(mut self, registered_task: DiagnosticCode) -> Self {
413        self.task = Some(registered_task);
414        self
415    }
416    /// Only the existing checked scope formatter can supply this field.
417    pub fn with_scope(mut self, scope: crate::DbScopeLogFields) -> Self {
418        self.scope = Some(scope);
419        self
420    }
421    pub const fn category(&self) -> DiagnosticCategory {
422        self.category
423    }
424
425    /// Cleanup is a sibling occurrence, not a cause of the primary failure.
426    pub fn during_cleanup_of(mut self, primary: &Diagnostic) -> Self {
427        self.primary_diagnostic_id = Some(primary.id());
428        self
429    }
430}
431impl fmt::Display for Diagnostic {
432    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433        write!(
434            f,
435            "diagnostic={} primary={:?} {:?} {:?} at {}:{} stack={}",
436            self.diagnostic_id,
437            self.primary_diagnostic_id,
438            self.category,
439            self.capture_site,
440            self.origin.file,
441            self.origin.line,
442            self.stack_status
443        )?;
444        for cause in &self.causes {
445            write!(
446                f,
447                " <- {:?}/{} io={:?} os={:?} db={:?} sqlstate={:?} object={:?} input_location={:?}",
448                cause.stage,
449                cause.code.0,
450                cause.io_kind,
451                cause.os_code,
452                cause.db_code,
453                cause.sqlstate,
454                cause.object,
455                cause.input_location
456            )?;
457        }
458        write!(
459            f,
460            " omitted_causes={} stack_truncated={}\n{}",
461            self.omitted_causes, self.stack_truncated, self.stack
462        )
463    }
464}
465impl fmt::Debug for Diagnostic {
466    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467        fmt::Display::fmt(self, f)
468    }
469}
470impl Error for Diagnostic {}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    fn cause() -> DiagnosticCause {
476        DiagnosticCause::new(
477            DiagnosticStage::RequestDb,
478            DiagnosticCode::new("db.connect_failed").unwrap(),
479        )
480    }
481    #[test]
482    fn diagnostic_capture_wrap_and_cleanup_preserve_origin() {
483        let original = Diagnostic::capture(
484            DiagnosticCategory::UnexpectedError,
485            CaptureSite::FirstObserved,
486            cause(),
487        );
488        let before = serde_json::to_value(&original).unwrap();
489        assert_ne!(before["stack_status"], "not_requested_expected");
490        let wrapped = original.wrap(cause());
491        let after = serde_json::to_value(&wrapped).unwrap();
492        assert_eq!(before["origin"], after["origin"]);
493        assert_eq!(before["stack"], after["stack"]);
494        assert_eq!(before["diagnostic_id"], after["diagnostic_id"]);
495        let cleanup = Diagnostic::capture(
496            DiagnosticCategory::UnexpectedError,
497            CaptureSite::FirstObserved,
498            cause(),
499        )
500        .during_cleanup_of(&wrapped);
501        assert_eq!(
502            serde_json::to_value(cleanup).unwrap()["primary_diagnostic_id"],
503            after["diagnostic_id"]
504        );
505    }
506    #[test]
507    fn diagnostic_safe_projection_and_bounds() {
508        let io = std::io::Error::other("SECRET_DRIVER_PAYLOAD");
509        let mut diagnostic = Diagnostic::capture(
510            DiagnosticCategory::ExpectedRejection,
511            CaptureSite::Origin,
512            cause().with_io(&io).with_database_code(1045, Some("28000")),
513        );
514        for _ in 0..20 {
515            diagnostic = diagnostic.wrap(cause());
516        }
517        let value = serde_json::to_value(&diagnostic).unwrap();
518        assert_eq!(value["causes"].as_array().unwrap().len(), 8);
519        assert_eq!(value["omitted_causes"], 13);
520        assert_eq!(value["stack_status"], "not_requested_expected");
521        let error =
522            crate::SaddleError::new(crate::ErrorKind::Internal, "internal", "SECRET_MESSAGE")
523                .with_diagnostic(diagnostic);
524        for output in [error.to_string(), format!("{error:?}"), value.to_string()] {
525            assert!(!output.contains("SECRET_"));
526        }
527        assert!(error.source().is_some());
528        assert!(
529            DiagnosticObject::new(
530                DiagnosticObjectKind::TargetAlias,
531                "https://user:password@host"
532            )
533            .is_none()
534        );
535        assert!(DiagnosticObject::new(DiagnosticObjectKind::MappingFile, "../secret").is_none());
536        assert!(
537            DiagnosticObject::new(
538                DiagnosticObjectKind::LogPath,
539                "/srv/logs/saddle.emergency.log"
540            )
541            .is_some()
542        );
543        let mut output = StackText {
544            text: String::new(),
545            truncated: false,
546        };
547        assert!(output.write_str(&"界".repeat(MAX_STACK_BYTES)).is_err());
548        assert!(output.text.len() <= MAX_STACK_BYTES && output.truncated);
549    }
550    #[test]
551    fn diagnostic_input_location_is_distinct_bounded_and_safe() {
552        let location = DiagnosticInputLocation::new(Some(17), Some(0))
553            .with_file(DiagnosticLocator::from_projection("mapping.json", false, false).unwrap())
554            .with_table(DiagnosticLocator::from_projection("order\\u000a", true, false).unwrap())
555            .with_column(DiagnosticLocator::from_projection("SECRET@value", false, true).unwrap())
556            .with_config_key(
557                DiagnosticLocator::from_projection("database.mappingDir", false, false).unwrap(),
558            );
559        let diagnostic = Diagnostic::capture(
560            DiagnosticCategory::ExpectedRejection,
561            CaptureSite::FirstObserved,
562            cause().with_input_location(location),
563        );
564        let value = serde_json::to_value(&diagnostic).unwrap();
565        let input = &value["causes"][0]["input_location"];
566        assert_eq!(input["json_line"], 17);
567        assert_eq!(input["json_column"], 0);
568        assert_eq!(input["file"]["value"], "mapping.json");
569        assert_eq!(input["table"]["value"], "order\\u000a");
570        assert_eq!(input["column"]["value"], "[redacted]");
571        assert_eq!(input["locator_truncated"], true);
572        assert_eq!(input["locator_redacted"], true);
573        assert_ne!(value["origin"]["line"], input["json_line"]);
574        for text in [
575            value.to_string(),
576            format!("{diagnostic}"),
577            format!("{diagnostic:?}"),
578        ] {
579            assert!(!text.contains("SECRET"));
580        }
581        assert!(DiagnosticLocator::from_projection("https://secret", false, false).is_none());
582        assert!(DiagnosticLocator::from_projection("raw\ncontrol", false, false).is_none());
583        assert!(DiagnosticLocator::from_projection(&"界".repeat(65), false, false).is_none());
584        let absent = serde_json::to_value(
585            DiagnosticInputLocation::new(None, None).with_locator_status(true, true),
586        )
587        .unwrap();
588        assert!(absent["json_line"].is_null());
589        assert_eq!(absent["locator_redacted"], true);
590    }
591    #[test]
592    fn diagnostic_panic_origin_subprocess() {
593        const CHILD: &str = "SADDLE_DIAGNOSTIC_PANIC_CHILD";
594        if std::env::var_os(CHILD).is_some() {
595            let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
596            let hook_capture = captured.clone();
597            std::panic::set_hook(Box::new(move |info| {
598                *hook_capture.lock().unwrap() = Some(Diagnostic::capture_panic(
599                    info,
600                    DiagnosticStage::RequestHandler,
601                ));
602            }));
603            let panic_line = line!() + 1;
604            let result = std::panic::catch_unwind(|| panic!("SENSITIVE_PANIC_PAYLOAD"));
605            assert!(result.is_err());
606            let diagnostic = captured.lock().unwrap().take().unwrap();
607            assert_eq!(diagnostic.origin.line, panic_line);
608            assert!(matches!(diagnostic.capture_site, CaptureSite::Origin));
609            assert_eq!(diagnostic.stack_status, "captured");
610            assert!(!diagnostic.stack.is_empty());
611            assert!(!format!("{diagnostic:?}").contains("SENSITIVE_PANIC_PAYLOAD"));
612            return;
613        }
614        let output = std::process::Command::new(std::env::current_exe().unwrap())
615            .args([
616                "--exact",
617                "diagnostic::tests::diagnostic_panic_origin_subprocess",
618                "--nocapture",
619            ])
620            .env(CHILD, "1")
621            .output()
622            .unwrap();
623        assert!(
624            output.status.success(),
625            "{}",
626            String::from_utf8_lossy(&output.stderr)
627        );
628    }
629}