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}