Skip to main content

ipfrs_storage/
audit_trail.rs

1//! Immutable audit trail for storage operations.
2//!
3//! Provides tamper-evident logging of all storage mutations and access events
4//! with FNV-1a integrity checksums, configurable retention, and flexible querying.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Types
10// ---------------------------------------------------------------------------
11
12/// Audit event types
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum AuditEventType {
15    BlockPut,
16    BlockGet,
17    BlockDelete,
18    BlockPin,
19    BlockUnpin,
20    GarbageCollect,
21    Compaction,
22    Migration,
23    Replication,
24    AccessDenied,
25}
26
27impl AuditEventType {
28    fn as_str(&self) -> &'static str {
29        match self {
30            Self::BlockPut => "BlockPut",
31            Self::BlockGet => "BlockGet",
32            Self::BlockDelete => "BlockDelete",
33            Self::BlockPin => "BlockPin",
34            Self::BlockUnpin => "BlockUnpin",
35            Self::GarbageCollect => "GarbageCollect",
36            Self::Compaction => "Compaction",
37            Self::Migration => "Migration",
38            Self::Replication => "Replication",
39            Self::AccessDenied => "AccessDenied",
40        }
41    }
42}
43
44/// Single audit entry
45#[derive(Debug, Clone)]
46pub struct AuditEntry {
47    pub sequence: u64,
48    pub timestamp: u64,
49    pub event_type: AuditEventType,
50    pub cid: String,
51    pub actor: String,
52    pub details: String,
53    pub bytes_affected: u64,
54    pub success: bool,
55    pub checksum: u64,
56}
57
58/// Audit trail configuration
59#[derive(Debug, Clone)]
60pub struct AuditTrailConfig {
61    pub max_entries: usize,
62    pub retention_seconds: u64,
63    pub enable_checksums: bool,
64    pub log_reads: bool,
65}
66
67impl Default for AuditTrailConfig {
68    fn default() -> Self {
69        Self {
70            max_entries: 10_000,
71            retention_seconds: 86_400, // 24 hours
72            enable_checksums: true,
73            log_reads: false,
74        }
75    }
76}
77
78/// Query filter for audit trail
79#[derive(Debug, Clone, Default)]
80pub struct AuditFilter {
81    pub event_type: Option<AuditEventType>,
82    pub cid: Option<String>,
83    pub actor: Option<String>,
84    pub since: Option<u64>,
85    pub until: Option<u64>,
86    pub success_only: bool,
87}
88
89/// Aggregate statistics for the audit trail
90#[derive(Debug, Clone, Default)]
91pub struct AuditTrailStats {
92    pub total_entries: u64,
93    pub entries_pruned: u64,
94    pub integrity_failures: u64,
95    pub events_by_type: HashMap<String, u64>,
96}
97
98/// Audit trail manager
99pub struct AuditTrail {
100    config: AuditTrailConfig,
101    entries: Vec<AuditEntry>,
102    next_sequence: u64,
103    stats: AuditTrailStats,
104}
105
106// ---------------------------------------------------------------------------
107// FNV-1a helper
108// ---------------------------------------------------------------------------
109
110/// Compute an FNV-1a 64-bit hash over the semantic fields of an [`AuditEntry`].
111///
112/// The checksum covers `sequence`, `timestamp`, `event_type`, `cid`, `actor`,
113/// `details`, `bytes_affected`, and `success` — but **not** the `checksum`
114/// field itself.
115pub fn compute_checksum(entry: &AuditEntry) -> u64 {
116    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
117    const FNV_PRIME: u64 = 0x0100_0000_01b3;
118
119    let mut hash = FNV_OFFSET;
120
121    // Helper closure — feed bytes into the running hash.
122    let mut feed = |bytes: &[u8]| {
123        for &b in bytes {
124            hash ^= u64::from(b);
125            hash = hash.wrapping_mul(FNV_PRIME);
126        }
127    };
128
129    feed(&entry.sequence.to_le_bytes());
130    feed(&entry.timestamp.to_le_bytes());
131    feed(entry.event_type.as_str().as_bytes());
132    feed(entry.cid.as_bytes());
133    feed(entry.actor.as_bytes());
134    feed(entry.details.as_bytes());
135    feed(&entry.bytes_affected.to_le_bytes());
136    feed(&[u8::from(entry.success)]);
137
138    hash
139}
140
141// ---------------------------------------------------------------------------
142// Current-time helper (seconds since UNIX epoch)
143// ---------------------------------------------------------------------------
144
145fn now_secs() -> u64 {
146    std::time::SystemTime::now()
147        .duration_since(std::time::UNIX_EPOCH)
148        .unwrap_or_default()
149        .as_secs()
150}
151
152// ---------------------------------------------------------------------------
153// AuditTrail implementation
154// ---------------------------------------------------------------------------
155
156impl AuditTrail {
157    /// Create a new audit trail with the given configuration.
158    pub fn new(config: AuditTrailConfig) -> Self {
159        Self {
160            config,
161            entries: Vec::new(),
162            next_sequence: 1,
163            stats: AuditTrailStats::default(),
164        }
165    }
166
167    /// Record an event and return its sequence number.
168    ///
169    /// If `config.log_reads` is `false`, `BlockGet` events are silently
170    /// dropped and the returned sequence number is `0`.
171    pub fn log_event(
172        &mut self,
173        event_type: AuditEventType,
174        cid: &str,
175        actor: &str,
176        details: &str,
177        bytes: u64,
178        success: bool,
179    ) -> u64 {
180        // Honour the log_reads flag.
181        if event_type == AuditEventType::BlockGet && !self.config.log_reads {
182            return 0;
183        }
184
185        let seq = self.next_sequence;
186        self.next_sequence = self.next_sequence.wrapping_add(1);
187
188        let mut entry = AuditEntry {
189            sequence: seq,
190            timestamp: now_secs(),
191            event_type: event_type.clone(),
192            cid: cid.to_string(),
193            actor: actor.to_string(),
194            details: details.to_string(),
195            bytes_affected: bytes,
196            success,
197            checksum: 0,
198        };
199
200        if self.config.enable_checksums {
201            entry.checksum = compute_checksum(&entry);
202        }
203
204        // Update stats.
205        self.stats.total_entries += 1;
206        *self
207            .stats
208            .events_by_type
209            .entry(event_type.as_str().to_string())
210            .or_insert(0) += 1;
211
212        self.entries.push(entry);
213
214        seq
215    }
216
217    /// Return entries matching every predicate in `filter`.
218    pub fn query(&self, filter: &AuditFilter) -> Vec<&AuditEntry> {
219        self.entries
220            .iter()
221            .filter(|e| {
222                if let Some(ref et) = filter.event_type {
223                    if e.event_type != *et {
224                        return false;
225                    }
226                }
227                if let Some(ref c) = filter.cid {
228                    if e.cid != *c {
229                        return false;
230                    }
231                }
232                if let Some(ref a) = filter.actor {
233                    if e.actor != *a {
234                        return false;
235                    }
236                }
237                if let Some(since) = filter.since {
238                    if e.timestamp < since {
239                        return false;
240                    }
241                }
242                if let Some(until) = filter.until {
243                    if e.timestamp > until {
244                        return false;
245                    }
246                }
247                if filter.success_only && !e.success {
248                    return false;
249                }
250                true
251            })
252            .collect()
253    }
254
255    /// Look up an entry by its sequence number.
256    pub fn get_entry(&self, sequence: u64) -> Option<&AuditEntry> {
257        self.entries.iter().find(|e| e.sequence == sequence)
258    }
259
260    /// Verify the integrity checksum of every entry.
261    ///
262    /// Returns `Ok(())` when all entries pass, or `Err(bad_seqs)` with
263    /// the sequence numbers that failed.
264    pub fn verify_integrity(&self) -> Result<(), Vec<u64>> {
265        let bad: Vec<u64> = self
266            .entries
267            .iter()
268            .filter(|e| compute_checksum(e) != e.checksum)
269            .map(|e| e.sequence)
270            .collect();
271
272        if bad.is_empty() {
273            Ok(())
274        } else {
275            Err(bad)
276        }
277    }
278
279    /// Remove all entries whose timestamp is strictly before `timestamp`.
280    ///
281    /// Returns the number of entries removed.
282    pub fn prune_before(&mut self, timestamp: u64) -> usize {
283        let before = self.entries.len();
284        self.entries.retain(|e| e.timestamp >= timestamp);
285        let pruned = before - self.entries.len();
286        self.stats.entries_pruned += pruned as u64;
287        pruned
288    }
289
290    /// Trim the trail to `config.max_entries`, removing the oldest entries
291    /// first. Returns the number of entries removed.
292    pub fn prune_to_capacity(&mut self) -> usize {
293        let max = self.config.max_entries;
294        if self.entries.len() <= max {
295            return 0;
296        }
297        let excess = self.entries.len() - max;
298        self.entries.drain(..excess);
299        self.stats.entries_pruned += excess as u64;
300        excess
301    }
302
303    /// Number of entries currently held.
304    pub fn entry_count(&self) -> usize {
305        self.entries.len()
306    }
307
308    /// The most recently appended entry, if any.
309    pub fn last_entry(&self) -> Option<&AuditEntry> {
310        self.entries.last()
311    }
312
313    /// Current aggregate statistics.
314    pub fn stats(&self) -> &AuditTrailStats {
315        &self.stats
316    }
317
318    /// All events that reference a particular CID.
319    pub fn events_for_cid(&self, cid: &str) -> Vec<&AuditEntry> {
320        self.entries.iter().filter(|e| e.cid == cid).collect()
321    }
322
323    /// The most recent `count` entries where `success == false`, newest first.
324    pub fn recent_failures(&self, count: usize) -> Vec<&AuditEntry> {
325        self.entries
326            .iter()
327            .rev()
328            .filter(|e| !e.success)
329            .take(count)
330            .collect()
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------------------
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn default_trail() -> AuditTrail {
343        AuditTrail::new(AuditTrailConfig {
344            max_entries: 100,
345            retention_seconds: 3600,
346            enable_checksums: true,
347            log_reads: true,
348        })
349    }
350
351    // -- basic logging -------------------------------------------------------
352
353    #[test]
354    fn test_log_block_put() {
355        let mut trail = default_trail();
356        let seq = trail.log_event(
357            AuditEventType::BlockPut,
358            "cid1",
359            "local",
360            "put block",
361            1024,
362            true,
363        );
364        assert_eq!(seq, 1);
365        assert_eq!(trail.entry_count(), 1);
366    }
367
368    #[test]
369    fn test_log_block_get() {
370        let mut trail = default_trail();
371        let seq = trail.log_event(
372            AuditEventType::BlockGet,
373            "cid2",
374            "peer1",
375            "get block",
376            512,
377            true,
378        );
379        assert!(seq > 0);
380        assert_eq!(trail.entry_count(), 1);
381    }
382
383    #[test]
384    fn test_log_block_delete() {
385        let mut trail = default_trail();
386        let seq = trail.log_event(
387            AuditEventType::BlockDelete,
388            "cid3",
389            "local",
390            "delete",
391            256,
392            true,
393        );
394        assert_eq!(seq, 1);
395        let entry = trail.get_entry(seq).expect("entry should exist");
396        assert_eq!(entry.event_type, AuditEventType::BlockDelete);
397    }
398
399    #[test]
400    fn test_log_block_pin() {
401        let mut trail = default_trail();
402        let seq = trail.log_event(AuditEventType::BlockPin, "cid4", "local", "pin", 0, true);
403        assert_eq!(
404            trail.get_entry(seq).expect("entry").event_type,
405            AuditEventType::BlockPin
406        );
407    }
408
409    #[test]
410    fn test_log_block_unpin() {
411        let mut trail = default_trail();
412        let seq = trail.log_event(
413            AuditEventType::BlockUnpin,
414            "cid5",
415            "local",
416            "unpin",
417            0,
418            true,
419        );
420        assert_eq!(
421            trail.get_entry(seq).expect("entry").event_type,
422            AuditEventType::BlockUnpin
423        );
424    }
425
426    #[test]
427    fn test_log_garbage_collect() {
428        let mut trail = default_trail();
429        let seq = trail.log_event(
430            AuditEventType::GarbageCollect,
431            "",
432            "local",
433            "gc round",
434            4096,
435            true,
436        );
437        assert_eq!(
438            trail.get_entry(seq).expect("entry").event_type,
439            AuditEventType::GarbageCollect
440        );
441    }
442
443    #[test]
444    fn test_log_compaction() {
445        let mut trail = default_trail();
446        let seq = trail.log_event(
447            AuditEventType::Compaction,
448            "",
449            "local",
450            "compact",
451            8192,
452            true,
453        );
454        assert_eq!(
455            trail.get_entry(seq).expect("entry").event_type,
456            AuditEventType::Compaction
457        );
458    }
459
460    #[test]
461    fn test_log_migration() {
462        let mut trail = default_trail();
463        let seq = trail.log_event(
464            AuditEventType::Migration,
465            "cid6",
466            "local",
467            "migrate",
468            2048,
469            true,
470        );
471        assert_eq!(
472            trail.get_entry(seq).expect("entry").event_type,
473            AuditEventType::Migration
474        );
475    }
476
477    #[test]
478    fn test_log_replication() {
479        let mut trail = default_trail();
480        let seq = trail.log_event(
481            AuditEventType::Replication,
482            "cid7",
483            "peer2",
484            "replicate",
485            1024,
486            true,
487        );
488        assert_eq!(
489            trail.get_entry(seq).expect("entry").event_type,
490            AuditEventType::Replication
491        );
492    }
493
494    #[test]
495    fn test_log_access_denied() {
496        let mut trail = default_trail();
497        let seq = trail.log_event(
498            AuditEventType::AccessDenied,
499            "cid8",
500            "peer3",
501            "denied",
502            0,
503            false,
504        );
505        let entry = trail.get_entry(seq).expect("entry");
506        assert_eq!(entry.event_type, AuditEventType::AccessDenied);
507        assert!(!entry.success);
508    }
509
510    // -- log_reads config ----------------------------------------------------
511
512    #[test]
513    fn test_log_reads_disabled() {
514        let mut trail = AuditTrail::new(AuditTrailConfig {
515            log_reads: false,
516            ..AuditTrailConfig::default()
517        });
518        let seq = trail.log_event(AuditEventType::BlockGet, "cid", "local", "", 0, true);
519        assert_eq!(seq, 0);
520        assert_eq!(trail.entry_count(), 0);
521    }
522
523    // -- sequence numbering --------------------------------------------------
524
525    #[test]
526    fn test_sequence_numbering() {
527        let mut trail = default_trail();
528        let s1 = trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
529        let s2 = trail.log_event(AuditEventType::BlockPut, "b", "local", "", 0, true);
530        let s3 = trail.log_event(AuditEventType::BlockDelete, "c", "local", "", 0, true);
531        assert_eq!(s1, 1);
532        assert_eq!(s2, 2);
533        assert_eq!(s3, 3);
534    }
535
536    // -- query filters -------------------------------------------------------
537
538    #[test]
539    fn test_query_by_event_type() {
540        let mut trail = default_trail();
541        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
542        trail.log_event(AuditEventType::BlockGet, "b", "local", "", 0, true);
543        trail.log_event(AuditEventType::BlockPut, "c", "local", "", 0, true);
544
545        let filter = AuditFilter {
546            event_type: Some(AuditEventType::BlockPut),
547            ..Default::default()
548        };
549        let results = trail.query(&filter);
550        assert_eq!(results.len(), 2);
551    }
552
553    #[test]
554    fn test_query_by_cid() {
555        let mut trail = default_trail();
556        trail.log_event(AuditEventType::BlockPut, "cid_x", "local", "", 0, true);
557        trail.log_event(AuditEventType::BlockPut, "cid_y", "local", "", 0, true);
558
559        let filter = AuditFilter {
560            cid: Some("cid_x".to_string()),
561            ..Default::default()
562        };
563        assert_eq!(trail.query(&filter).len(), 1);
564    }
565
566    #[test]
567    fn test_query_by_actor() {
568        let mut trail = default_trail();
569        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
570        trail.log_event(AuditEventType::BlockPut, "b", "peer1", "", 0, true);
571
572        let filter = AuditFilter {
573            actor: Some("peer1".to_string()),
574            ..Default::default()
575        };
576        assert_eq!(trail.query(&filter).len(), 1);
577    }
578
579    #[test]
580    fn test_query_by_since() {
581        let mut trail = default_trail();
582        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
583
584        let filter = AuditFilter {
585            since: Some(0),
586            ..Default::default()
587        };
588        assert_eq!(trail.query(&filter).len(), 1);
589
590        // Far-future timestamp should yield nothing.
591        let filter2 = AuditFilter {
592            since: Some(u64::MAX),
593            ..Default::default()
594        };
595        assert_eq!(trail.query(&filter2).len(), 0);
596    }
597
598    #[test]
599    fn test_query_by_until() {
600        let mut trail = default_trail();
601        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
602
603        let filter = AuditFilter {
604            until: Some(u64::MAX),
605            ..Default::default()
606        };
607        assert_eq!(trail.query(&filter).len(), 1);
608
609        let filter2 = AuditFilter {
610            until: Some(0),
611            ..Default::default()
612        };
613        assert_eq!(trail.query(&filter2).len(), 0);
614    }
615
616    #[test]
617    fn test_query_success_only() {
618        let mut trail = default_trail();
619        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
620        trail.log_event(AuditEventType::BlockPut, "b", "local", "", 0, false);
621
622        let filter = AuditFilter {
623            success_only: true,
624            ..Default::default()
625        };
626        assert_eq!(trail.query(&filter).len(), 1);
627    }
628
629    #[test]
630    fn test_query_combined_filters() {
631        let mut trail = default_trail();
632        trail.log_event(AuditEventType::BlockPut, "cid_a", "local", "", 10, true);
633        trail.log_event(AuditEventType::BlockPut, "cid_a", "peer1", "", 20, true);
634        trail.log_event(AuditEventType::BlockGet, "cid_a", "local", "", 10, true);
635        trail.log_event(AuditEventType::BlockPut, "cid_b", "local", "", 10, false);
636
637        let filter = AuditFilter {
638            event_type: Some(AuditEventType::BlockPut),
639            cid: Some("cid_a".to_string()),
640            actor: Some("local".to_string()),
641            success_only: true,
642            ..Default::default()
643        };
644        let results = trail.query(&filter);
645        assert_eq!(results.len(), 1);
646        assert_eq!(results[0].cid, "cid_a");
647        assert_eq!(results[0].actor, "local");
648    }
649
650    // -- integrity -----------------------------------------------------------
651
652    #[test]
653    fn test_integrity_valid() {
654        let mut trail = default_trail();
655        trail.log_event(AuditEventType::BlockPut, "a", "local", "ok", 100, true);
656        trail.log_event(AuditEventType::BlockDelete, "b", "peer1", "del", 200, true);
657        assert!(trail.verify_integrity().is_ok());
658    }
659
660    #[test]
661    fn test_integrity_tampered() {
662        let mut trail = default_trail();
663        trail.log_event(AuditEventType::BlockPut, "a", "local", "ok", 100, true);
664        trail.log_event(AuditEventType::BlockPut, "b", "local", "ok", 200, true);
665
666        // Tamper with the first entry.
667        trail.entries[0].bytes_affected = 999_999;
668
669        let result = trail.verify_integrity();
670        assert!(result.is_err());
671        let bad = result.expect_err("should have bad entries");
672        assert_eq!(bad.len(), 1);
673        assert_eq!(bad[0], 1);
674    }
675
676    #[test]
677    fn test_integrity_multiple_tampered() {
678        let mut trail = default_trail();
679        for i in 0..5 {
680            trail.log_event(
681                AuditEventType::BlockPut,
682                &format!("c{i}"),
683                "local",
684                "",
685                0,
686                true,
687            );
688        }
689        trail.entries[1].cid = "TAMPERED".to_string();
690        trail.entries[3].actor = "EVIL".to_string();
691
692        let bad = trail.verify_integrity().expect_err("should fail");
693        assert_eq!(bad.len(), 2);
694        assert!(bad.contains(&2));
695        assert!(bad.contains(&4));
696    }
697
698    // -- checksum consistency ------------------------------------------------
699
700    #[test]
701    fn test_checksum_deterministic() {
702        let entry = AuditEntry {
703            sequence: 42,
704            timestamp: 1_700_000_000,
705            event_type: AuditEventType::BlockPut,
706            cid: "bafy123".to_string(),
707            actor: "local".to_string(),
708            details: "test".to_string(),
709            bytes_affected: 1024,
710            success: true,
711            checksum: 0,
712        };
713        let c1 = compute_checksum(&entry);
714        let c2 = compute_checksum(&entry);
715        assert_eq!(c1, c2);
716        assert_ne!(c1, 0);
717    }
718
719    #[test]
720    fn test_checksum_changes_with_fields() {
721        let mut entry = AuditEntry {
722            sequence: 1,
723            timestamp: 100,
724            event_type: AuditEventType::BlockPut,
725            cid: "abc".to_string(),
726            actor: "local".to_string(),
727            details: "".to_string(),
728            bytes_affected: 0,
729            success: true,
730            checksum: 0,
731        };
732        let c1 = compute_checksum(&entry);
733        entry.cid = "xyz".to_string();
734        let c2 = compute_checksum(&entry);
735        assert_ne!(c1, c2);
736    }
737
738    // -- pruning -------------------------------------------------------------
739
740    #[test]
741    fn test_prune_before() {
742        let mut trail = default_trail();
743        // Insert entries and manually set timestamps.
744        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
745        trail.log_event(AuditEventType::BlockPut, "b", "local", "", 0, true);
746        trail.log_event(AuditEventType::BlockPut, "c", "local", "", 0, true);
747        trail.entries[0].timestamp = 100;
748        trail.entries[1].timestamp = 200;
749        trail.entries[2].timestamp = 300;
750        // Recompute checksums after mutation.
751        for e in &mut trail.entries {
752            e.checksum = compute_checksum(e);
753        }
754
755        let removed = trail.prune_before(200);
756        assert_eq!(removed, 1);
757        assert_eq!(trail.entry_count(), 2);
758        assert!(trail.verify_integrity().is_ok());
759    }
760
761    #[test]
762    fn test_prune_before_all() {
763        let mut trail = default_trail();
764        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
765        trail.log_event(AuditEventType::BlockPut, "b", "local", "", 0, true);
766
767        let removed = trail.prune_before(u64::MAX);
768        assert_eq!(removed, 2);
769        assert_eq!(trail.entry_count(), 0);
770    }
771
772    #[test]
773    fn test_prune_to_capacity() {
774        let mut trail = AuditTrail::new(AuditTrailConfig {
775            max_entries: 3,
776            enable_checksums: true,
777            log_reads: true,
778            ..AuditTrailConfig::default()
779        });
780
781        for i in 0..5 {
782            trail.log_event(
783                AuditEventType::BlockPut,
784                &format!("c{i}"),
785                "local",
786                "",
787                0,
788                true,
789            );
790        }
791        assert_eq!(trail.entry_count(), 5);
792
793        let pruned = trail.prune_to_capacity();
794        assert_eq!(pruned, 2);
795        assert_eq!(trail.entry_count(), 3);
796        // Oldest entries (seq 1, 2) should be gone.
797        assert!(trail.get_entry(1).is_none());
798        assert!(trail.get_entry(2).is_none());
799        assert!(trail.get_entry(3).is_some());
800    }
801
802    #[test]
803    fn test_prune_to_capacity_no_op() {
804        let mut trail = default_trail();
805        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
806        assert_eq!(trail.prune_to_capacity(), 0);
807    }
808
809    // -- stats ---------------------------------------------------------------
810
811    #[test]
812    fn test_stats_by_event_type() {
813        let mut trail = default_trail();
814        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
815        trail.log_event(AuditEventType::BlockPut, "b", "local", "", 0, true);
816        trail.log_event(AuditEventType::BlockDelete, "c", "local", "", 0, true);
817
818        let s = trail.stats();
819        assert_eq!(s.total_entries, 3);
820        assert_eq!(*s.events_by_type.get("BlockPut").unwrap_or(&0), 2);
821        assert_eq!(*s.events_by_type.get("BlockDelete").unwrap_or(&0), 1);
822    }
823
824    #[test]
825    fn test_stats_pruned_count() {
826        let mut trail = AuditTrail::new(AuditTrailConfig {
827            max_entries: 2,
828            enable_checksums: true,
829            log_reads: true,
830            ..AuditTrailConfig::default()
831        });
832        for i in 0..5 {
833            trail.log_event(
834                AuditEventType::BlockPut,
835                &format!("c{i}"),
836                "local",
837                "",
838                0,
839                true,
840            );
841        }
842        trail.prune_to_capacity();
843        assert_eq!(trail.stats().entries_pruned, 3);
844    }
845
846    // -- events_for_cid ------------------------------------------------------
847
848    #[test]
849    fn test_events_for_cid() {
850        let mut trail = default_trail();
851        trail.log_event(AuditEventType::BlockPut, "target", "local", "", 100, true);
852        trail.log_event(AuditEventType::BlockGet, "target", "peer1", "", 100, true);
853        trail.log_event(AuditEventType::BlockPut, "other", "local", "", 50, true);
854
855        let events = trail.events_for_cid("target");
856        assert_eq!(events.len(), 2);
857        for e in &events {
858            assert_eq!(e.cid, "target");
859        }
860    }
861
862    #[test]
863    fn test_events_for_cid_empty() {
864        let trail = default_trail();
865        assert!(trail.events_for_cid("nonexistent").is_empty());
866    }
867
868    // -- recent_failures -----------------------------------------------------
869
870    #[test]
871    fn test_recent_failures() {
872        let mut trail = default_trail();
873        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, false);
874        trail.log_event(AuditEventType::BlockPut, "b", "local", "", 0, true);
875        trail.log_event(AuditEventType::BlockPut, "c", "local", "", 0, false);
876        trail.log_event(AuditEventType::BlockDelete, "d", "local", "", 0, false);
877
878        let failures = trail.recent_failures(2);
879        assert_eq!(failures.len(), 2);
880        // Newest first.
881        assert_eq!(failures[0].cid, "d");
882        assert_eq!(failures[1].cid, "c");
883    }
884
885    #[test]
886    fn test_recent_failures_none() {
887        let mut trail = default_trail();
888        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
889        assert!(trail.recent_failures(10).is_empty());
890    }
891
892    // -- edge cases ----------------------------------------------------------
893
894    #[test]
895    fn test_empty_trail_query() {
896        let trail = default_trail();
897        let filter = AuditFilter::default();
898        assert!(trail.query(&filter).is_empty());
899    }
900
901    #[test]
902    fn test_empty_trail_last_entry() {
903        let trail = default_trail();
904        assert!(trail.last_entry().is_none());
905    }
906
907    #[test]
908    fn test_empty_trail_verify_integrity() {
909        let trail = default_trail();
910        assert!(trail.verify_integrity().is_ok());
911    }
912
913    #[test]
914    fn test_empty_trail_prune() {
915        let mut trail = default_trail();
916        assert_eq!(trail.prune_before(100), 0);
917        assert_eq!(trail.prune_to_capacity(), 0);
918    }
919
920    #[test]
921    fn test_get_entry_missing() {
922        let trail = default_trail();
923        assert!(trail.get_entry(999).is_none());
924    }
925
926    #[test]
927    fn test_last_entry() {
928        let mut trail = default_trail();
929        trail.log_event(AuditEventType::BlockPut, "first", "local", "", 0, true);
930        trail.log_event(AuditEventType::BlockDelete, "last", "local", "", 0, true);
931        let last = trail.last_entry().expect("should have last");
932        assert_eq!(last.cid, "last");
933    }
934
935    // -- large trail ---------------------------------------------------------
936
937    #[test]
938    fn test_large_audit_trail() {
939        let mut trail = AuditTrail::new(AuditTrailConfig {
940            max_entries: 5000,
941            enable_checksums: true,
942            log_reads: true,
943            ..AuditTrailConfig::default()
944        });
945
946        for i in 0..1200u64 {
947            let event = if i % 3 == 0 {
948                AuditEventType::BlockPut
949            } else if i % 3 == 1 {
950                AuditEventType::BlockGet
951            } else {
952                AuditEventType::BlockDelete
953            };
954            trail.log_event(event, &format!("cid_{i}"), "local", "", i * 10, true);
955        }
956
957        assert_eq!(trail.entry_count(), 1200);
958        assert!(trail.verify_integrity().is_ok());
959        assert_eq!(trail.stats().total_entries, 1200);
960
961        // Query subset.
962        let filter = AuditFilter {
963            event_type: Some(AuditEventType::BlockDelete),
964            ..Default::default()
965        };
966        assert_eq!(trail.query(&filter).len(), 400);
967    }
968
969    // -- retention period enforcement ----------------------------------------
970
971    #[test]
972    fn test_retention_period_enforcement() {
973        let mut trail = default_trail();
974        trail.log_event(AuditEventType::BlockPut, "old", "local", "", 0, true);
975        trail.log_event(AuditEventType::BlockPut, "new", "local", "", 0, true);
976
977        // Manually age the first entry.
978        trail.entries[0].timestamp = 1_000;
979        trail.entries[0].checksum = compute_checksum(&trail.entries[0]);
980        trail.entries[1].timestamp = 2_000;
981        trail.entries[1].checksum = compute_checksum(&trail.entries[1]);
982
983        let cutoff = trail.entries[1]
984            .timestamp
985            .saturating_sub(trail.config.retention_seconds);
986        // With retention_seconds = 3600, cutoff = 0 so nothing gets pruned.
987        // Use a tighter retention to actually prune.
988        trail.config.retention_seconds = 500;
989        let cutoff2 = trail.entries[1]
990            .timestamp
991            .saturating_sub(trail.config.retention_seconds);
992        let pruned = trail.prune_before(cutoff2);
993        assert_eq!(pruned, 1);
994        assert_eq!(trail.entry_count(), 1);
995        assert_eq!(trail.entries[0].cid, "new");
996
997        // Sanity — cutoff with the original wide window keeps everything.
998        let _ = cutoff; // used above for reasoning
999    }
1000
1001    // -- checksums disabled --------------------------------------------------
1002
1003    #[test]
1004    fn test_checksums_disabled() {
1005        let mut trail = AuditTrail::new(AuditTrailConfig {
1006            enable_checksums: false,
1007            log_reads: true,
1008            ..AuditTrailConfig::default()
1009        });
1010        trail.log_event(AuditEventType::BlockPut, "a", "local", "", 0, true);
1011        let entry = trail.last_entry().expect("entry");
1012        assert_eq!(entry.checksum, 0);
1013    }
1014
1015    // -- entry fields --------------------------------------------------------
1016
1017    #[test]
1018    fn test_entry_fields_populated() {
1019        let mut trail = default_trail();
1020        trail.log_event(
1021            AuditEventType::Replication,
1022            "bafy_cid",
1023            "peer42",
1024            "replicated to zone-b",
1025            4096,
1026            true,
1027        );
1028        let e = trail.last_entry().expect("entry");
1029        assert_eq!(e.event_type, AuditEventType::Replication);
1030        assert_eq!(e.cid, "bafy_cid");
1031        assert_eq!(e.actor, "peer42");
1032        assert_eq!(e.details, "replicated to zone-b");
1033        assert_eq!(e.bytes_affected, 4096);
1034        assert!(e.success);
1035        assert_ne!(e.checksum, 0);
1036    }
1037}