Skip to main content

audit_trail/
error.rs

1//! Error and result types for `audit-trail` operations.
2//!
3//! The crate uses a single [`Error`] enum with a small number of broad
4//! categories. The enum is `#[non_exhaustive]` so further variants may be
5//! added in minor releases without breaking callers.
6
7use core::fmt;
8
9use crate::record::RecordId;
10
11/// Convenience [`Result`] type alias used throughout the crate.
12///
13/// # Example
14///
15/// ```
16/// fn do_audit() -> audit_trail::Result<()> {
17///     Ok(())
18/// }
19/// assert!(do_audit().is_ok());
20/// ```
21pub type Result<T> = core::result::Result<T, Error>;
22
23/// Error categories produced by `audit-trail`.
24///
25/// Variants are intentionally coarse-grained. Concrete backends communicate
26/// finer-grained failures via [`SinkError`] wrapped inside [`Error::Sink`].
27///
28/// # Example
29///
30/// ```
31/// use audit_trail::Error;
32///
33/// let err = Error::ChainBroken;
34/// assert_eq!(err.to_string(), "audit hash chain broken");
35/// ```
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum Error {
39    /// A configured sink failed to persist a record.
40    Sink(SinkError),
41    /// The running hash chain failed a generic integrity check.
42    ///
43    /// Verification surfaces more specific variants
44    /// ([`Error::HashMismatch`], [`Error::LinkMismatch`],
45    /// [`Error::IdMismatch`]) when possible.
46    ChainBroken,
47    /// A fixed-size buffer or counter exceeded its capacity
48    /// (for example, the record id counter overflowed).
49    Capacity,
50    /// The configured clock returned a timestamp that violates monotonicity.
51    NonMonotonicClock,
52    /// A record's stored `hash` does not match the digest recomputed from
53    /// its fields. Carries the failing record's id.
54    HashMismatch(RecordId),
55    /// A record's `prev_hash` does not equal the previous record's `hash`.
56    /// Carries the failing record's id.
57    LinkMismatch(RecordId),
58    /// A record's id is not the expected next id in the chain. Carries the
59    /// id that was found.
60    IdMismatch(RecordId),
61    /// Input ended before a complete record could be decoded.
62    Truncated,
63    /// Encoded bytes are present but do not parse as a valid record
64    /// (bad magic, bad version, invalid UTF-8, length-prefix mismatch, …).
65    InvalidFormat,
66    /// Underlying I/O failure (only emitted by `std`-gated readers/sinks).
67    /// Detail is suppressed to keep [`Error`] both `Copy` and `no_std`-safe;
68    /// callers needing the full [`std::io::Error`] should use the
69    /// constructor methods that return [`std::io::Result`] directly.
70    Io,
71}
72
73impl fmt::Display for Error {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            Self::Sink(_) => f.write_str("audit sink failure"),
77            Self::ChainBroken => f.write_str("audit hash chain broken"),
78            Self::Capacity => f.write_str("audit capacity exceeded"),
79            Self::NonMonotonicClock => f.write_str("audit clock not monotonic"),
80            Self::HashMismatch(id) => write!(f, "audit hash mismatch at record {}", id.as_u64()),
81            Self::LinkMismatch(id) => write!(f, "audit link mismatch at record {}", id.as_u64()),
82            Self::IdMismatch(id) => write!(f, "audit id mismatch at record {}", id.as_u64()),
83            Self::Truncated => f.write_str("audit input truncated"),
84            Self::InvalidFormat => f.write_str("audit input invalid format"),
85            Self::Io => f.write_str("audit i/o failure"),
86        }
87    }
88}
89
90#[cfg(feature = "std")]
91impl std::error::Error for Error {
92    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
93        match self {
94            Self::Sink(inner) => Some(inner),
95            _ => None,
96        }
97    }
98}
99
100/// Opaque error returned by [`crate::Sink`] implementations.
101///
102/// Backends map their internal failures to one of a small set of categories.
103/// Categories are deliberately coarse: callers either retry the write or
104/// surface the audit failure upstream.
105///
106/// # Example
107///
108/// ```
109/// use audit_trail::SinkError;
110///
111/// let err = SinkError::Io;
112/// assert_eq!(err.to_string(), "sink i/o failure");
113/// ```
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum SinkError {
117    /// Underlying I/O failure (disk, socket, etc.).
118    Io,
119    /// Sink has reached its capacity and cannot accept more records.
120    Capacity,
121    /// Sink has been closed and will accept no further writes.
122    Closed,
123    /// Sink-specific failure not covered by the other variants.
124    Other,
125}
126
127impl fmt::Display for SinkError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Self::Io => f.write_str("sink i/o failure"),
131            Self::Capacity => f.write_str("sink capacity exceeded"),
132            Self::Closed => f.write_str("sink closed"),
133            Self::Other => f.write_str("sink error"),
134        }
135    }
136}
137
138#[cfg(feature = "std")]
139impl std::error::Error for SinkError {}
140
141impl From<SinkError> for Error {
142    #[inline]
143    fn from(value: SinkError) -> Self {
144        Self::Sink(value)
145    }
146}
147
148#[cfg(all(test, feature = "std"))]
149mod tests {
150    use super::{Error, SinkError};
151    use crate::record::RecordId;
152
153    /// Exercises [`core::fmt::Display`] for every [`Error`] variant. Drives
154    /// the format strings so they cannot rot silently.
155    #[test]
156    fn error_display_covers_all_variants() {
157        let id = RecordId::from_u64(7);
158        assert_eq!(Error::Sink(SinkError::Io).to_string(), "audit sink failure");
159        assert_eq!(Error::ChainBroken.to_string(), "audit hash chain broken");
160        assert_eq!(Error::Capacity.to_string(), "audit capacity exceeded");
161        assert_eq!(
162            Error::NonMonotonicClock.to_string(),
163            "audit clock not monotonic"
164        );
165        assert_eq!(
166            Error::HashMismatch(id).to_string(),
167            "audit hash mismatch at record 7"
168        );
169        assert_eq!(
170            Error::LinkMismatch(id).to_string(),
171            "audit link mismatch at record 7"
172        );
173        assert_eq!(
174            Error::IdMismatch(id).to_string(),
175            "audit id mismatch at record 7"
176        );
177        assert_eq!(Error::Truncated.to_string(), "audit input truncated");
178        assert_eq!(
179            Error::InvalidFormat.to_string(),
180            "audit input invalid format"
181        );
182        assert_eq!(Error::Io.to_string(), "audit i/o failure");
183    }
184
185    /// Exercises [`core::fmt::Display`] for every [`SinkError`] variant.
186    #[test]
187    fn sink_error_display_covers_all_variants() {
188        assert_eq!(SinkError::Io.to_string(), "sink i/o failure");
189        assert_eq!(SinkError::Capacity.to_string(), "sink capacity exceeded");
190        assert_eq!(SinkError::Closed.to_string(), "sink closed");
191        assert_eq!(SinkError::Other.to_string(), "sink error");
192    }
193
194    /// [`Error::source`] points at the wrapped [`SinkError`] when present.
195    #[test]
196    fn error_source_chains_to_sink_error() {
197        use std::error::Error as _;
198        let err = Error::Sink(SinkError::Closed);
199        assert!(err.source().is_some());
200        let other = Error::Truncated;
201        assert!(other.source().is_none());
202    }
203
204    /// `From<SinkError> for Error` wraps the inner value losslessly.
205    #[test]
206    fn from_sink_error_wraps() {
207        let e: Error = SinkError::Capacity.into();
208        assert_eq!(e, Error::Sink(SinkError::Capacity));
209    }
210}