Skip to main content

audit_trail/
chain.rs

1//! The [`Chain`] type: the append-only, hash-linked audit log.
2//!
3//! `Chain` wires together a [`Hasher`], a [`Sink`], and a [`Clock`] into a
4//! single append-only structure. Every successful append:
5//!
6//! 1. Asks the clock for the current timestamp (enforcing monotonicity).
7//! 2. Allocates the next [`RecordId`] (checked for overflow).
8//! 3. Computes this record's hash by feeding canonical bytes through the
9//!    hasher together with the previous record's hash.
10//! 4. Writes the [`Record`] to the sink.
11//! 5. Updates the chain's running `last_hash` and `next_id`.
12//!
13//! The path is allocation-free: every field is borrowed or stack-resident.
14
15use crate::canonical;
16use crate::clock::{Clock, Timestamp};
17use crate::error::{Error, Result};
18use crate::hash::{Digest, Hasher};
19use crate::record::{Action, Actor, Outcome, Record, RecordId, Target};
20use crate::sink::Sink;
21
22/// An append-only chain of audit records.
23///
24/// The chain is generic over its three pluggable components:
25///
26/// * `H: Hasher` — the cryptographic hash function used to link records.
27/// * `S: Sink` — the backend that persists each record.
28/// * `C: Clock` — the time source.
29///
30/// `Chain` is `!Sync` by virtue of holding mutable state on `&mut self`.
31/// Concurrent appenders should serialize on the chain or shard their writes
32/// across independent chains.
33///
34/// # Example
35///
36/// ```
37/// use audit_trail::{
38///     Action, Actor, Chain, Clock, Digest, Hasher, Outcome, Record, RecordId,
39///     Sink, SinkError, Target, Timestamp, HASH_LEN,
40/// };
41///
42/// // Minimal (insecure) Hasher: XOR-fold bytes into a 32-byte buffer.
43/// struct XorHasher([u8; HASH_LEN]);
44/// impl Hasher for XorHasher {
45///     fn reset(&mut self) { self.0 = [0u8; HASH_LEN]; }
46///     fn update(&mut self, bytes: &[u8]) {
47///         for (i, b) in bytes.iter().enumerate() { self.0[i % HASH_LEN] ^= *b; }
48///     }
49///     fn finalize(&mut self, out: &mut Digest) { *out = Digest::from_bytes(self.0); }
50/// }
51///
52/// // A clock that ticks one nanosecond per call.
53/// struct TickClock(core::cell::Cell<u64>);
54/// impl Clock for TickClock {
55///     fn now(&self) -> Timestamp {
56///         let v = self.0.get(); self.0.set(v + 1); Timestamp::from_nanos(v)
57///     }
58/// }
59///
60/// // An in-memory sink that records hashes for verification.
61/// #[derive(Default)]
62/// struct VecSink(Vec<Digest>);
63/// impl Sink for VecSink {
64///     fn write(&mut self, r: &Record<'_>) -> Result<(), SinkError> {
65///         self.0.push(r.hash()); Ok(())
66///     }
67/// }
68///
69/// let mut chain = Chain::new(
70///     XorHasher([0u8; HASH_LEN]),
71///     VecSink::default(),
72///     TickClock(core::cell::Cell::new(1)),
73/// );
74///
75/// let id = chain.append(
76///     Actor::new("user-1"),
77///     Action::new("user.login"),
78///     Target::new("session:abc"),
79///     Outcome::Success,
80/// ).expect("append");
81/// assert_eq!(id, RecordId::GENESIS);
82/// ```
83#[derive(Debug)]
84pub struct Chain<H, S, C>
85where
86    H: Hasher,
87    S: Sink,
88    C: Clock,
89{
90    hasher: H,
91    sink: S,
92    clock: C,
93    next_id: u64,
94    last_hash: Digest,
95    last_timestamp: Timestamp,
96}
97
98impl<H, S, C> Chain<H, S, C>
99where
100    H: Hasher,
101    S: Sink,
102    C: Clock,
103{
104    /// Construct a fresh chain starting from genesis.
105    ///
106    /// The chain begins with `next_id = 0` and `last_hash = Digest::ZERO`.
107    /// To resume a previously persisted chain, use [`Chain::resume`].
108    #[inline]
109    pub fn new(hasher: H, sink: S, clock: C) -> Self {
110        Self {
111            hasher,
112            sink,
113            clock,
114            next_id: 0,
115            last_hash: Digest::ZERO,
116            last_timestamp: Timestamp::EPOCH,
117        }
118    }
119
120    /// Resume a chain from a previously persisted tail.
121    ///
122    /// `next_id` is the identifier the next appended record will receive.
123    /// `last_hash` is the hash of the most recently persisted record (or
124    /// [`Digest::ZERO`] if the chain is empty).
125    /// `last_timestamp` is the timestamp of the most recently persisted
126    /// record (or [`Timestamp::EPOCH`] if the chain is empty).
127    #[inline]
128    pub fn resume(
129        hasher: H,
130        sink: S,
131        clock: C,
132        next_id: RecordId,
133        last_hash: Digest,
134        last_timestamp: Timestamp,
135    ) -> Self {
136        Self {
137            hasher,
138            sink,
139            clock,
140            next_id: next_id.as_u64(),
141            last_hash,
142            last_timestamp,
143        }
144    }
145
146    /// Identifier the next appended record will receive.
147    #[inline]
148    pub const fn next_id(&self) -> RecordId {
149        RecordId::from_u64(self.next_id)
150    }
151
152    /// Hash of the most recently appended record (or [`Digest::ZERO`] if
153    /// none).
154    #[inline]
155    pub const fn last_hash(&self) -> Digest {
156        self.last_hash
157    }
158
159    /// Timestamp of the most recently appended record (or
160    /// [`Timestamp::EPOCH`] if none).
161    #[inline]
162    pub const fn last_timestamp(&self) -> Timestamp {
163        self.last_timestamp
164    }
165
166    /// Append a record to the chain.
167    ///
168    /// Returns the new record's [`RecordId`]. On error the chain state is
169    /// left unchanged: the sink is only updated after the hash is computed
170    /// and the timestamp/id checks have passed, and `last_hash` /
171    /// `last_timestamp` / `next_id` are only updated after the sink write
172    /// succeeds.
173    ///
174    /// # Errors
175    ///
176    /// * [`Error::NonMonotonicClock`] — the clock returned a timestamp not
177    ///   greater than the previously stored timestamp.
178    /// * [`Error::Capacity`] — the record id counter would overflow.
179    /// * [`Error::Sink`] — the sink rejected the write.
180    pub fn append(
181        &mut self,
182        actor: Actor<'_>,
183        action: Action<'_>,
184        target: Target<'_>,
185        outcome: Outcome,
186    ) -> Result<RecordId> {
187        let timestamp = self.clock.now();
188        if self.next_id > 0 && timestamp <= self.last_timestamp {
189            return Err(Error::NonMonotonicClock);
190        }
191
192        let id = RecordId::from_u64(self.next_id);
193        let next = self.next_id.checked_add(1).ok_or(Error::Capacity)?;
194        let prev_hash = self.last_hash;
195
196        let draft = Record::new(
197            id,
198            timestamp,
199            actor,
200            action,
201            target,
202            outcome,
203            prev_hash,
204            Digest::ZERO,
205        );
206        let hash = canonical::compute(&mut self.hasher, &draft);
207        let record = draft.with_hash(hash);
208        self.sink.write(&record)?;
209
210        self.next_id = next;
211        self.last_hash = hash;
212        self.last_timestamp = timestamp;
213        Ok(id)
214    }
215
216    /// Consume the chain and return its three pluggable components.
217    #[inline]
218    pub fn into_parts(self) -> (H, S, C) {
219        (self.hasher, self.sink, self.clock)
220    }
221
222    /// Borrow the configured sink.
223    #[inline]
224    pub const fn sink(&self) -> &S {
225        &self.sink
226    }
227
228    /// Mutably borrow the configured sink.
229    #[inline]
230    pub fn sink_mut(&mut self) -> &mut S {
231        &mut self.sink
232    }
233}