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}