made_core/entities/
audit_chain.rs1use crate::value_objects::{AuditChainDefect, AuditChainVerdict, AuditSequence};
9
10use super::AuditRecord;
11
12#[derive(Debug)]
14pub struct AuditChain;
15
16impl AuditChain {
17 #[must_use]
23 pub fn verify(records: &[AuditRecord]) -> AuditChainVerdict {
24 let Some((first, rest)) = records.split_first() else {
25 return AuditChainVerdict::Intact;
26 };
27
28 if !first.sequence().is_first() {
29 return AuditChainVerdict::Broken(AuditChainDefect::DoesNotStartAtTheBeginning {
30 found: first.sequence(),
31 });
32 }
33 if first.previous_record_hash().is_some() {
34 return AuditChainVerdict::Broken(AuditChainDefect::UnexpectedRoot {
35 at: first.sequence(),
36 });
37 }
38 if let Some(verdict) = digest_defect(first) {
39 return verdict;
40 }
41
42 let mut previous = first;
43 for record in rest {
44 if record.ceremony_id() != previous.ceremony_id() {
45 return AuditChainVerdict::Broken(AuditChainDefect::ForeignCeremony {
46 at: record.sequence(),
47 });
48 }
49 if !record.sequence().follows(previous.sequence()) {
50 return AuditChainVerdict::Broken(AuditChainDefect::SequenceBroken {
51 expected: previous.sequence().next(),
52 found: record.sequence(),
53 });
54 }
55 match record.previous_record_hash() {
56 None => {
57 return AuditChainVerdict::Broken(AuditChainDefect::UnexpectedRoot {
58 at: record.sequence(),
59 })
60 }
61 Some(hash) if hash != previous.record_hash() => {
62 return AuditChainVerdict::Broken(AuditChainDefect::LinkBroken {
63 at: record.sequence(),
64 })
65 }
66 Some(_) => {}
67 }
68 if let Some(verdict) = digest_defect(record) {
69 return verdict;
70 }
71 previous = record;
72 }
73
74 AuditChainVerdict::Intact
75 }
76
77 #[must_use]
79 pub fn next_sequence(records: &[AuditRecord]) -> AuditSequence {
80 records
81 .last()
82 .map_or(AuditSequence::FIRST, |record| record.sequence().next())
83 }
84}
85
86fn digest_defect(record: &AuditRecord) -> Option<AuditChainVerdict> {
90 match record.digest_is_intact() {
91 Ok(true) => None,
92 Ok(false) | Err(_) => Some(AuditChainVerdict::Broken(AuditChainDefect::DigestAltered {
93 at: record.sequence(),
94 })),
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::entities::AuditFact;
102 use crate::value_objects::{
103 AuditActor, AuditActorKind, AuditEventType, CeremonyId, CeremonyName, CeremonyVersion,
104 EventId,
105 };
106 use serde_json::Value;
107 use time::macros::datetime;
108
109 fn fact(event_id: &str, ceremony: &str) -> AuditFact {
110 AuditFact {
111 event_id: EventId::new(event_id).unwrap(),
112 event_type: AuditEventType::StepCompleted,
113 ceremony_id: CeremonyId::new(ceremony).unwrap(),
114 definition_name: CeremonyName::new("planning_ceremony").unwrap(),
115 definition_version: CeremonyVersion::v1(),
116 occurred_at: datetime!(2026-07-29 09:00:00 UTC),
117 actor: AuditActor::new("engineer-1", AuditActorKind::Human, None).unwrap(),
118 correlation_id: None,
119 causation_id: None,
120 trace: None,
121 }
122 }
123
124 fn chain() -> Vec<AuditRecord> {
125 let first = AuditRecord::first(fact("e1", "ceremony-1")).unwrap();
126 let second = AuditRecord::following(fact("e2", "ceremony-1"), &first).unwrap();
127 let third = AuditRecord::following(fact("e3", "ceremony-1"), &second).unwrap();
128 vec![first, second, third]
129 }
130
131 fn tampered(record: &AuditRecord, mutate: impl FnOnce(&mut Value)) -> AuditRecord {
132 let mut json = serde_json::to_value(record).unwrap();
133 mutate(&mut json);
134 serde_json::from_value(json).unwrap()
135 }
136
137 #[test]
138 fn an_empty_journal_is_intact() {
139 assert!(AuditChain::verify(&[]).is_intact());
140 assert_eq!(AuditChain::next_sequence(&[]), AuditSequence::FIRST);
141 }
142
143 #[test]
144 fn a_well_formed_journal_is_intact() {
145 let records = chain();
146
147 assert!(AuditChain::verify(&records).is_intact());
148 assert_eq!(AuditChain::next_sequence(&records).value(), 4);
149 }
150
151 #[test]
152 fn a_journal_missing_its_opening_records_is_detected() {
153 let records = chain();
154
155 assert_eq!(
156 AuditChain::verify(&records[1..]).defect(),
157 Some(AuditChainDefect::DoesNotStartAtTheBeginning {
158 found: AuditSequence::new(2).unwrap()
159 })
160 );
161 }
162
163 #[test]
164 fn a_record_removed_from_the_middle_is_detected() {
165 let records = chain();
166 let gapped = vec![records[0].clone(), records[2].clone()];
167
168 assert_eq!(
169 AuditChain::verify(&gapped).defect(),
170 Some(AuditChainDefect::SequenceBroken {
171 expected: AuditSequence::new(2).unwrap(),
172 found: AuditSequence::new(3).unwrap(),
173 })
174 );
175 }
176
177 #[test]
178 fn an_altered_record_is_detected_at_its_own_position() {
179 let mut records = chain();
180 records[1] = tampered(&records[1], |json| {
181 json["actor"]["actor_id"] = "someone-else".into();
182 });
183
184 assert_eq!(
185 AuditChain::verify(&records).defect(),
186 Some(AuditChainDefect::DigestAltered {
187 at: AuditSequence::new(2).unwrap()
188 })
189 );
190 }
191
192 #[test]
193 fn a_substituted_record_breaks_the_link_of_the_one_after_it() {
194 let records = chain();
195 let forged = AuditRecord::following(fact("forged", "ceremony-1"), &records[0]).unwrap();
196 let substituted = vec![records[0].clone(), forged, records[2].clone()];
197
198 assert_eq!(
202 AuditChain::verify(&substituted).defect(),
203 Some(AuditChainDefect::LinkBroken {
204 at: AuditSequence::new(3).unwrap()
205 })
206 );
207 }
208
209 #[test]
210 fn a_grafted_foreign_record_is_detected() {
211 let records = chain();
212 let foreign = AuditRecord::first(fact("f1", "ceremony-2")).unwrap();
213 let grafted = vec![records[0].clone(), foreign];
214
215 assert_eq!(
216 AuditChain::verify(&grafted).defect(),
217 Some(AuditChainDefect::ForeignCeremony {
218 at: AuditSequence::FIRST
219 })
220 );
221 }
222
223 #[test]
224 fn an_opening_record_that_claims_a_predecessor_is_detected() {
225 let records = chain();
226 let rooted = tampered(&records[0], |json| {
227 json["previous_record_hash"] = serde_json::to_value([9_u8; 32]).unwrap();
228 });
229
230 assert_eq!(
231 AuditChain::verify(&[rooted]).defect(),
232 Some(AuditChainDefect::UnexpectedRoot {
233 at: AuditSequence::FIRST
234 })
235 );
236 }
237
238 #[test]
239 fn a_later_record_that_claims_no_predecessor_is_detected() {
240 let mut records = chain();
241 records[1] = tampered(&records[1], |json| {
242 json["previous_record_hash"] = Value::Null;
243 });
244
245 assert_eq!(
246 AuditChain::verify(&records).defect(),
247 Some(AuditChainDefect::UnexpectedRoot {
248 at: AuditSequence::new(2).unwrap()
249 })
250 );
251 }
252
253 #[test]
254 fn verification_stops_at_the_first_defect() {
255 let mut records = chain();
256 records[1] = tampered(&records[1], |json| {
257 json["event_type"] = "step_failed".into();
258 });
259 records[2] = tampered(&records[2], |json| {
260 json["event_type"] = "step_failed".into();
261 });
262
263 assert_eq!(
266 AuditChain::verify(&records)
267 .defect()
268 .map(AuditChainDefect::at),
269 Some(AuditSequence::new(2).unwrap())
270 );
271 }
272}