Skip to main content

harn_sqlite/
diagnostic.rs

1//! Safe facts for diagnosing accounting storage without rendering source data.
2use std::error::Error;
3use std::fmt;
4use std::io::ErrorKind;
5
6/// Typed codes only: no error messages, paths, SQL or database contents.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct FailureDiagnostic {
9    pub stage: &'static str,
10    pub sqlite_primary_code: Option<i32>,
11    pub sqlite_extended_code: Option<i32>,
12    pub io_kind: Option<ErrorKind>,
13    pub os_code: Option<i32>,
14}
15
16/// Follow typed error sources without inspecting or rendering their messages.
17///
18/// `stage` must be a caller-owned static operation name. A SQLite CANTOPEN code
19/// describes an open failure, not proof of an operating-system access denial.
20#[must_use]
21pub fn failure_diagnostic(stage: &'static str, error: &(dyn Error + 'static)) -> FailureDiagnostic {
22    let mut result = FailureDiagnostic {
23        stage,
24        sqlite_primary_code: None,
25        sqlite_extended_code: None,
26        io_kind: None,
27        os_code: None,
28    };
29    let mut current = Some(error);
30    // Error is an open trait; a malformed source chain must not hang reporting.
31    for _ in 0..16 {
32        let Some(error) = current else { break };
33        if let Some(rusqlite::Error::SqliteFailure(code, _)) =
34            error.downcast_ref::<rusqlite::Error>()
35        {
36            result
37                .sqlite_primary_code
38                .get_or_insert(code.extended_code & 0xff);
39            result
40                .sqlite_extended_code
41                .get_or_insert(code.extended_code);
42        }
43        if let Some(error) = error.downcast_ref::<std::io::Error>() {
44            result.io_kind.get_or_insert(error.kind());
45            if let Some(code) = error.raw_os_error() {
46                result.os_code.get_or_insert(code);
47            }
48        }
49        current = error.source();
50    }
51    result
52}
53
54impl fmt::Display for FailureDiagnostic {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "stage={}", self.stage)?;
57        if let Some(code) = self.sqlite_primary_code {
58            write!(f, " sqlite_primary_code={code}")?;
59        }
60        if let Some(code) = self.sqlite_extended_code {
61            write!(f, " sqlite_extended_code={code}")?;
62        }
63        if let Some(kind) = self.io_kind {
64            write!(f, " io_kind={kind:?}")?;
65        }
66        if let Some(code) = self.os_code {
67            write!(f, " os_code={code}")?;
68        }
69        Ok(())
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::InitializationError;
77
78    #[test]
79    fn sqlite_codes_remain_distinct_and_do_not_expose_messages() {
80        for code in [
81            rusqlite::ffi::SQLITE_CANTOPEN,
82            rusqlite::ffi::SQLITE_BUSY_RECOVERY,
83            rusqlite::ffi::SQLITE_READONLY_DIRECTORY,
84        ] {
85            let error = rusqlite::Error::SqliteFailure(
86                rusqlite::ffi::Error::new(code),
87                Some("fixture-secret-path-and-sql-value".into()),
88            );
89            let diagnostic = failure_diagnostic("open", &error);
90            assert_eq!(diagnostic.sqlite_primary_code, Some(code & 0xff));
91            assert_eq!(diagnostic.sqlite_extended_code, Some(code));
92            assert_eq!(
93                diagnostic.io_kind, None,
94                "SQLite codes alone do not prove an OS denial"
95            );
96            assert!(!diagnostic.to_string().contains("fixture-secret"));
97        }
98    }
99
100    #[test]
101    fn initialization_preserves_stage_and_underlying_io_kind_without_paths() {
102        let error: InitializationError<rusqlite::Error> =
103            InitializationError::InitializationLockOpen {
104                path: "/fixture-secret-directory/spend.sqlite.harn-init.lock".into(),
105                source: std::io::Error::new(ErrorKind::PermissionDenied, "fixture-secret"),
106            };
107        let diagnostic = failure_diagnostic(error.stage(), &error);
108        assert_eq!(diagnostic.stage, "initialization_lock_open");
109        assert_eq!(diagnostic.io_kind, Some(ErrorKind::PermissionDenied));
110        assert_eq!(diagnostic.sqlite_extended_code, None);
111        assert!(!diagnostic.to_string().contains("fixture-secret"));
112    }
113}