1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use crate::state_store::StateStore;
12
13pub type HashDigest = String;
15
16pub type AgentId = String;
18
19#[derive(Debug, Clone)]
23pub enum AuditError {
24 ChainBroken {
26 seq: u64,
28 expected: String,
30 found: String,
32 },
33 InvalidTimestamp {
35 seq: u64,
37 },
38 ExportFailed(String),
40}
41
42impl std::fmt::Display for AuditError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 AuditError::ChainBroken {
46 seq,
47 expected,
48 found,
49 } => {
50 write!(
51 f,
52 "chain broken at seq {}: expected hash '{}', found '{}'",
53 seq, expected, found
54 )
55 }
56 AuditError::InvalidTimestamp { seq } => {
57 write!(f, "invalid timestamp at seq {}", seq)
58 }
59 AuditError::ExportFailed(msg) => {
60 write!(f, "export failed: {}", msg)
61 }
62 }
63 }
64}
65
66impl std::error::Error for AuditError {}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(tag = "type", content = "data")]
73pub enum AuditAction {
74 AgentSpawn {
76 task_type: String,
78 },
79 AgentExit {
81 reason: String,
83 },
84 ToolCall {
86 tool: String,
88 args_json: String,
90 },
91 ToolResult {
93 tool: String,
95 success: bool,
97 },
98 MemoryWrite {
100 entry_id: String,
102 },
103 MemoryRead {
105 entry_id: String,
107 },
108 ConfigChange {
110 key: String,
112 },
113 ProgramInstall {
115 program: String,
117 version: String,
119 },
120 CronTrigger {
122 job_id: String,
124 },
125 GitCommit {
127 message: String,
129 },
130 AccessDenied {
132 permission: String,
134 },
135 Other {
137 detail: String,
139 },
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct AuditEntry {
147 pub seq: u64,
149 pub timestamp: DateTime<Utc>,
151 pub actor: AgentId,
153 pub action: AuditAction,
155 pub resource: String,
157 pub prev_hash: HashDigest,
159 pub hash: HashDigest,
161 pub metadata: Option<serde_json::Value>,
163}
164
165fn compute_entry_hash(
170 seq: u64,
171 ts: &DateTime<Utc>,
172 actor: &str,
173 action: &AuditAction,
174 resource: &str,
175 prev: &str,
176) -> HashDigest {
177 use blake3::Hasher;
178
179 let mut h = Hasher::new();
180 h.update(b"oxios-audit-v1");
181 h.update(&seq.to_be_bytes());
182 h.update(ts.to_rfc3339().as_bytes());
183 h.update(actor.as_bytes());
184
185 let action_bytes = serde_json::to_vec(action).unwrap_or_default();
187 h.update(&action_bytes);
188 h.update(prev.as_bytes());
189 h.update(resource.as_bytes());
190
191 h.finalize().to_hex().to_string()
192}
193
194pub struct AuditTrail {
202 entries: parking_lot::RwLock<Vec<AuditEntry>>,
204 seq_counter: AtomicU64,
206 #[allow(dead_code)]
208 chain_hasher: parking_lot::Mutex<blake3::Hasher>,
209 max_entries: usize,
211}
212
213impl AuditTrail {
214 pub fn new(max_entries: usize) -> Self {
216 Self {
217 entries: parking_lot::RwLock::new(Vec::new()),
218 seq_counter: AtomicU64::new(1), chain_hasher: parking_lot::Mutex::new(blake3::Hasher::new()),
220 max_entries,
221 }
222 }
223
224 pub fn len(&self) -> usize {
226 self.entries.read().len()
227 }
228
229 pub fn is_empty(&self) -> bool {
231 self.len() == 0
232 }
233
234 fn last_hash(&self) -> HashDigest {
236 let entries = self.entries.read();
237 entries
238 .last()
239 .map(|e| e.hash.clone())
240 .unwrap_or_else(|| "genesis".to_string())
241 }
242
243 pub fn append(&self, actor: AgentId, action: AuditAction, resource: String) -> HashDigest {
245 self.append_with_meta(actor, action, resource, None)
246 }
247
248 pub fn append_with_meta(
250 &self,
251 actor: AgentId,
252 action: AuditAction,
253 resource: String,
254 metadata: Option<serde_json::Value>,
255 ) -> HashDigest {
256 let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst);
257 let timestamp = Utc::now();
258 let prev_hash = self.last_hash();
259 let hash = compute_entry_hash(seq, ×tamp, &actor, &action, &resource, &prev_hash);
260
261 let entry = AuditEntry {
262 seq,
263 timestamp,
264 actor,
265 action,
266 resource,
267 prev_hash,
268 hash,
269 metadata,
270 };
271
272 let entry_hash = entry.hash.clone();
273
274 {
275 let mut entries = self.entries.write();
276 entries.push(entry);
277
278 if entries.len() > self.max_entries {
280 let excess = entries.len() - self.max_entries;
281 entries.drain(0..excess);
282 if let Some(first) = entries.first_mut() {
287 first.prev_hash = "pruned".to_string();
288 }
289 }
290 }
291
292 entry_hash
293 }
294
295 pub fn verify(&self) -> Result<bool, AuditError> {
302 let entries = self.entries.read();
303 let mut prev_hash = "genesis".to_string();
304
305 for (i, entry) in entries.iter().enumerate() {
306 if entry.seq == 0 {
308 return Err(AuditError::ChainBroken {
309 seq: 0,
310 expected: "non-zero sequence".to_string(),
311 found: "0".to_string(),
312 });
313 }
314
315 if i == 0 && entry.prev_hash == "pruned" {
319 prev_hash = entry.hash.clone();
321 continue;
322 } else if entry.prev_hash != prev_hash {
323 return Err(AuditError::ChainBroken {
324 seq: entry.seq,
325 expected: prev_hash,
326 found: entry.prev_hash.clone(),
327 });
328 }
329
330 let now = Utc::now();
332 if entry.timestamp > now {
333 return Err(AuditError::InvalidTimestamp { seq: entry.seq });
334 }
335
336 let computed = compute_entry_hash(
338 entry.seq,
339 &entry.timestamp,
340 &entry.actor,
341 &entry.action,
342 &entry.resource,
343 &entry.prev_hash,
344 );
345
346 if computed != entry.hash {
347 return Err(AuditError::ChainBroken {
348 seq: entry.seq,
349 expected: computed,
350 found: entry.hash.clone(),
351 });
352 }
353
354 prev_hash = entry.hash.clone();
355 }
356
357 Ok(true)
358 }
359
360 pub fn entries(&self, from_seq: u64, to_seq: u64) -> Vec<AuditEntry> {
362 let entries = self.entries.read();
363 entries
364 .iter()
365 .filter(|e| e.seq >= from_seq && e.seq <= to_seq)
366 .cloned()
367 .collect()
368 }
369
370 pub fn all_entries(&self) -> Vec<AuditEntry> {
372 self.entries.read().clone()
373 }
374
375 pub fn by_agent(&self, agent_id: &str) -> Vec<AuditEntry> {
377 let entries = self.entries.read();
378 entries
379 .iter()
380 .filter(|e| e.actor == agent_id)
381 .cloned()
382 .collect()
383 }
384
385 pub fn by_action(&self, action: &AuditAction) -> Vec<AuditEntry> {
387 let entries = self.entries.read();
388 entries
389 .iter()
390 .filter(|e| &e.action == action)
391 .cloned()
392 .collect()
393 }
394
395 pub fn by_action_type(&self, type_name: &str) -> Vec<AuditEntry> {
397 let entries = self.entries.read();
398 entries
399 .iter()
400 .filter(|e| {
401 let action_name = match &e.action {
402 AuditAction::AgentSpawn { .. } => "AgentSpawn",
403 AuditAction::AgentExit { .. } => "AgentExit",
404 AuditAction::ToolCall { .. } => "ToolCall",
405 AuditAction::ToolResult { .. } => "ToolResult",
406 AuditAction::MemoryWrite { .. } => "MemoryWrite",
407 AuditAction::MemoryRead { .. } => "MemoryRead",
408 AuditAction::ConfigChange { .. } => "ConfigChange",
409 AuditAction::ProgramInstall { .. } => "ProgramInstall",
410 AuditAction::CronTrigger { .. } => "CronTrigger",
411 AuditAction::GitCommit { .. } => "GitCommit",
412 AuditAction::AccessDenied { .. } => "AccessDenied",
413 AuditAction::Other { .. } => "Other",
414 };
415 action_name == type_name
416 })
417 .cloned()
418 .collect()
419 }
420
421 pub fn export_json(&self, from_seq: u64) -> Result<String, AuditError> {
423 let entries = self.entries.read();
424 let filtered: Vec<&AuditEntry> = entries.iter().filter(|e| e.seq >= from_seq).collect();
425
426 serde_json::to_string_pretty(&filtered).map_err(|e| AuditError::ExportFailed(e.to_string()))
427 }
428
429 pub fn export_all_json(&self) -> Result<String, AuditError> {
431 let entries = self.entries.read();
432 serde_json::to_string_pretty(&*entries).map_err(|e| AuditError::ExportFailed(e.to_string()))
433 }
434
435 pub fn flush(&self, state_store: &StateStore) -> Result<(), AuditError> {
437 let entries = self.entries.read();
438 state_store
439 .save_audit_entries(&entries)
440 .map_err(|e| AuditError::ExportFailed(e.to_string()))
441 }
442
443 pub fn restore_from(&self, entries: Vec<AuditEntry>) {
449 if entries.is_empty() {
450 return;
451 }
452
453 let max_seq = entries.iter().map(|e| e.seq).max().unwrap_or(0);
455 self.seq_counter.store(max_seq + 1, Ordering::SeqCst);
456
457 let mut current = self.entries.write();
458 *current = entries;
459
460 if current.len() > self.max_entries {
462 let excess = current.len() - self.max_entries;
463 current.drain(0..excess);
464
465 if let Some(first) = current.first_mut() {
469 first.prev_hash = "pruned".to_string();
470 }
471 }
472
473 tracing::info!(
474 restored = current.len(),
475 next_seq = max_seq + 1,
476 "Audit trail restored from persistence"
477 );
478 }
479}
480
481impl Default for AuditTrail {
482 fn default() -> Self {
483 Self::new(100_000)
484 }
485}
486
487impl std::fmt::Debug for AuditTrail {
488 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 f.debug_struct("AuditTrail")
490 .field("entries", &self.len())
491 .field("seq_counter", &self.seq_counter)
492 .field("max_entries", &self.max_entries)
493 .finish()
494 }
495}
496
497use anyhow::Result;
500
501impl StateStore {
502 pub fn save_audit_entries(&self, entries: &[AuditEntry]) -> Result<()> {
504 let path = self.audit_path();
505 if let Some(parent) = path.parent() {
506 std::fs::create_dir_all(parent)?;
507 }
508 let json = serde_json::to_string_pretty(entries)?;
509 std::fs::write(&path, json)?;
510 Ok(())
511 }
512
513 pub fn load_audit_entries(&self) -> Result<Vec<AuditEntry>> {
515 let path = self.audit_path();
516 if !path.exists() {
517 return Ok(Vec::new());
518 }
519 let json = std::fs::read_to_string(&path)?;
520 let entries: Vec<AuditEntry> = serde_json::from_str(&json)?;
521 Ok(entries)
522 }
523
524 fn audit_path(&self) -> std::path::PathBuf {
526 self.base_path.join("audit").join("trail.json")
527 }
528}
529
530#[cfg(test)]
533mod tests {
534 use super::*;
535
536 fn create_test_trail() -> AuditTrail {
537 AuditTrail::new(1000)
538 }
539
540 #[test]
541 fn test_append_generates_hash() {
542 let trail = create_test_trail();
543 let hash = trail.append(
544 "agent-001".to_string(),
545 AuditAction::AgentSpawn {
546 task_type: "test".to_string(),
547 },
548 "/test/resource".to_string(),
549 );
550
551 assert!(!hash.is_empty());
552 assert_eq!(hash.len(), 64); }
554
555 #[test]
556 fn test_append_increments_seq() {
557 let trail = create_test_trail();
558
559 let h1 = trail.append(
560 "agent-001".to_string(),
561 AuditAction::AgentSpawn {
562 task_type: "test".to_string(),
563 },
564 "/test/resource".to_string(),
565 );
566
567 let h2 = trail.append(
568 "agent-002".to_string(),
569 AuditAction::ToolCall {
570 tool: "bash".to_string(),
571 args_json: "{}".to_string(),
572 },
573 "/test/resource2".to_string(),
574 );
575
576 assert_ne!(h1, h2);
577
578 let entries = trail.all_entries();
579 assert_eq!(entries.len(), 2);
580 assert_eq!(entries[0].seq, 1);
581 assert_eq!(entries[1].seq, 2);
582 }
583
584 #[test]
585 fn test_hash_chain_linked() {
586 let trail = create_test_trail();
587
588 trail.append(
589 "agent-001".to_string(),
590 AuditAction::AgentSpawn {
591 task_type: "test".to_string(),
592 },
593 "/test/resource".to_string(),
594 );
595
596 trail.append(
597 "agent-001".to_string(),
598 AuditAction::AgentExit {
599 reason: "done".to_string(),
600 },
601 "/test/resource".to_string(),
602 );
603
604 let entries = trail.all_entries();
605 assert_eq!(entries[0].prev_hash, "genesis");
606 assert_eq!(entries[1].prev_hash, entries[0].hash);
607 }
608
609 #[test]
610 fn test_verify_passes_clean_chain() {
611 let trail = create_test_trail();
612
613 trail.append(
614 "agent-001".to_string(),
615 AuditAction::AgentSpawn {
616 task_type: "test".to_string(),
617 },
618 "/test/resource".to_string(),
619 );
620
621 trail.append(
622 "agent-001".to_string(),
623 AuditAction::ToolCall {
624 tool: "bash".to_string(),
625 args_json: "{}".to_string(),
626 },
627 "/test/resource".to_string(),
628 );
629
630 trail.append(
631 "agent-001".to_string(),
632 AuditAction::ToolResult {
633 tool: "bash".to_string(),
634 success: true,
635 },
636 "/test/resource".to_string(),
637 );
638
639 assert!(trail.verify().is_ok());
640 }
641
642 #[test]
643 fn test_verify_detects_tampering() {
644 let trail = create_test_trail();
645
646 trail.append(
647 "agent-001".to_string(),
648 AuditAction::AgentSpawn {
649 task_type: "test".to_string(),
650 },
651 "/test/resource".to_string(),
652 );
653
654 trail.append(
655 "agent-001".to_string(),
656 AuditAction::ToolCall {
657 tool: "bash".to_string(),
658 args_json: "{}".to_string(),
659 },
660 "/test/resource".to_string(),
661 );
662
663 {
665 let mut entries = trail.entries.write();
666 entries[0].actor = "hacker-001".to_string();
667 }
668
669 let result = trail.verify();
671 assert!(result.is_err());
672 match result {
673 Err(AuditError::ChainBroken { seq, .. }) => {
674 assert_eq!(seq, 1);
676 }
677 _ => panic!("expected ChainBroken error"),
678 }
679 }
680
681 #[test]
682 fn test_verify_detects_prev_hash_tampering() {
683 let trail = create_test_trail();
684
685 trail.append(
686 "agent-001".to_string(),
687 AuditAction::AgentSpawn {
688 task_type: "test".to_string(),
689 },
690 "/test/resource".to_string(),
691 );
692
693 trail.append(
694 "agent-001".to_string(),
695 AuditAction::ToolCall {
696 tool: "bash".to_string(),
697 args_json: "{}".to_string(),
698 },
699 "/test/resource".to_string(),
700 );
701
702 {
704 let mut entries = trail.entries.write();
705 entries[1].prev_hash = "fake-hash".to_string();
706 }
707
708 let result = trail.verify();
709 assert!(result.is_err());
710 }
711
712 #[test]
713 fn test_export_json_format() {
714 let trail = create_test_trail();
715
716 trail.append(
717 "agent-001".to_string(),
718 AuditAction::AgentSpawn {
719 task_type: "test".to_string(),
720 },
721 "/test/resource".to_string(),
722 );
723
724 let json = trail.export_json(0).unwrap();
725
726 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
728 assert_eq!(parsed.len(), 1);
729
730 let entry = &parsed[0];
732 assert!(entry.get("seq").is_some());
733 assert!(entry.get("timestamp").is_some());
734 assert!(entry.get("actor").is_some());
735 assert!(entry.get("action").is_some());
736 assert!(entry.get("resource").is_some());
737 assert!(entry.get("prev_hash").is_some());
738 assert!(entry.get("hash").is_some());
739 }
740
741 #[test]
742 fn test_by_agent_query() {
743 let trail = create_test_trail();
744
745 trail.append(
746 "agent-001".to_string(),
747 AuditAction::AgentSpawn {
748 task_type: "test".to_string(),
749 },
750 "/test/resource".to_string(),
751 );
752
753 trail.append(
754 "agent-002".to_string(),
755 AuditAction::AgentSpawn {
756 task_type: "test".to_string(),
757 },
758 "/test/resource".to_string(),
759 );
760
761 trail.append(
762 "agent-001".to_string(),
763 AuditAction::AgentExit {
764 reason: "done".to_string(),
765 },
766 "/test/resource".to_string(),
767 );
768
769 let agent_001_entries = trail.by_agent("agent-001");
770 assert_eq!(agent_001_entries.len(), 2);
771
772 let agent_002_entries = trail.by_agent("agent-002");
773 assert_eq!(agent_002_entries.len(), 1);
774 }
775
776 #[test]
777 fn test_by_action_query() {
778 let trail = create_test_trail();
779
780 trail.append(
781 "agent-001".to_string(),
782 AuditAction::AgentSpawn {
783 task_type: "test".to_string(),
784 },
785 "/test/resource".to_string(),
786 );
787
788 trail.append(
789 "agent-001".to_string(),
790 AuditAction::ToolCall {
791 tool: "bash".to_string(),
792 args_json: "{}".to_string(),
793 },
794 "/test/resource".to_string(),
795 );
796
797 trail.append(
798 "agent-001".to_string(),
799 AuditAction::ToolCall {
800 tool: "grep".to_string(),
801 args_json: "{}".to_string(),
802 },
803 "/test/resource".to_string(),
804 );
805
806 let spawn_entries = trail.by_action(&AuditAction::AgentSpawn {
807 task_type: "test".to_string(),
808 });
809 assert_eq!(spawn_entries.len(), 1);
810
811 let tool_calls = trail.by_action_type("ToolCall");
812 assert_eq!(tool_calls.len(), 2);
813 }
814
815 #[test]
816 fn test_entries_range() {
817 let trail = create_test_trail();
818
819 for i in 0..10 {
820 trail.append(
821 "agent-001".to_string(),
822 AuditAction::Other {
823 detail: format!("action-{}", i),
824 },
825 "/test/resource".to_string(),
826 );
827 }
828
829 let range = trail.entries(3, 7);
830 assert_eq!(range.len(), 5);
831 assert_eq!(range[0].seq, 3);
832 assert_eq!(range[4].seq, 7);
833 }
834
835 #[test]
836 fn test_auto_prune() {
837 let trail = AuditTrail::new(5);
838
839 for i in 0..10 {
840 trail.append(
841 "agent-001".to_string(),
842 AuditAction::Other {
843 detail: format!("action-{}", i),
844 },
845 "/test/resource".to_string(),
846 );
847 }
848
849 assert_eq!(trail.len(), 5);
851
852 let entries = trail.all_entries();
853 assert_eq!(entries[0].seq, 6);
855 assert_eq!(entries[4].seq, 10);
856
857 assert!(trail.verify().is_ok(), "Pruned trail should still verify");
859 }
860
861 #[test]
862 fn test_append_with_metadata() {
863 let trail = create_test_trail();
864 let metadata = serde_json::json!({
865 "duration_ms": 150,
866 "memory_mb": 32
867 });
868
869 let hash = trail.append_with_meta(
870 "agent-001".to_string(),
871 AuditAction::MemoryWrite {
872 entry_id: "mem-001".to_string(),
873 },
874 "/memory/entries".to_string(),
875 Some(metadata.clone()),
876 );
877
878 assert!(!hash.is_empty());
879
880 let entries = trail.all_entries();
881 assert!(entries[0].metadata.is_some());
882 assert_eq!(entries[0].metadata.as_ref().unwrap(), &metadata);
883 }
884
885 #[test]
886 fn test_genesis_hash() {
887 let trail = create_test_trail();
888
889 trail.append(
891 "agent-001".to_string(),
892 AuditAction::AgentSpawn {
893 task_type: "test".to_string(),
894 },
895 "/test/resource".to_string(),
896 );
897
898 let entries = trail.all_entries();
899 assert_eq!(entries[0].prev_hash, "genesis");
900 }
901
902 #[test]
903 fn test_deterministic_hash() {
904 let trail1 = create_test_trail();
905 let trail2 = create_test_trail();
906
907 let action = AuditAction::AgentSpawn {
908 task_type: "test".to_string(),
909 };
910
911 trail1.append(
912 "agent-001".to_string(),
913 action.clone(),
914 "/test/resource".to_string(),
915 );
916
917 let hash = compute_entry_hash(
919 1,
920 &trail1.all_entries()[0].timestamp,
921 "agent-001",
922 &action,
923 "/test/resource",
924 "genesis",
925 );
926
927 assert_eq!(hash, trail1.all_entries()[0].hash);
928 }
929
930 #[test]
931 fn test_empty_trail_verify() {
932 let trail = create_test_trail();
933 assert!(trail.verify().is_ok());
934 }
935
936 #[test]
937 fn test_all_action_types() {
938 let trail = create_test_trail();
939
940 let actions = vec![
941 AuditAction::AgentSpawn {
942 task_type: "test".to_string(),
943 },
944 AuditAction::AgentExit {
945 reason: "done".to_string(),
946 },
947 AuditAction::ToolCall {
948 tool: "bash".to_string(),
949 args_json: "{}".to_string(),
950 },
951 AuditAction::ToolResult {
952 tool: "bash".to_string(),
953 success: true,
954 },
955 AuditAction::MemoryWrite {
956 entry_id: "mem-001".to_string(),
957 },
958 AuditAction::MemoryRead {
959 entry_id: "mem-001".to_string(),
960 },
961 AuditAction::ConfigChange {
962 key: "max_agents".to_string(),
963 },
964 AuditAction::ProgramInstall {
965 program: "test-program".to_string(),
966 version: "1.0.0".to_string(),
967 },
968 AuditAction::CronTrigger {
969 job_id: "job-001".to_string(),
970 },
971 AuditAction::GitCommit {
972 message: "test commit".to_string(),
973 },
974 AuditAction::AccessDenied {
975 permission: "write".to_string(),
976 },
977 AuditAction::Other {
978 detail: "misc".to_string(),
979 },
980 ];
981
982 for (i, action) in actions.into_iter().enumerate() {
983 trail.append("agent-001".to_string(), action, format!("/resource/{}", i));
984 }
985
986 assert_eq!(trail.len(), 12);
987 assert!(trail.verify().is_ok());
988 }
989
990 #[test]
991 fn test_hash_different_for_different_inputs() {
992 let ts = Utc::now();
993
994 let hash1 = compute_entry_hash(
995 1,
996 &ts,
997 "agent-001",
998 &AuditAction::AgentSpawn {
999 task_type: "test".to_string(),
1000 },
1001 "/resource",
1002 "genesis",
1003 );
1004
1005 let hash2 = compute_entry_hash(
1006 2,
1007 &ts,
1008 "agent-001",
1009 &AuditAction::AgentSpawn {
1010 task_type: "test".to_string(),
1011 },
1012 "/resource",
1013 "genesis",
1014 );
1015
1016 assert_ne!(hash1, hash2);
1017
1018 let hash3 = compute_entry_hash(
1019 1,
1020 &ts,
1021 "agent-002",
1022 &AuditAction::AgentSpawn {
1023 task_type: "test".to_string(),
1024 },
1025 "/resource",
1026 "genesis",
1027 );
1028
1029 assert_ne!(hash1, hash3);
1030 }
1031
1032 #[test]
1033 fn test_restore_from_empty() {
1034 let trail = create_test_trail();
1035 trail.restore_from(Vec::new());
1036 assert!(trail.is_empty());
1037 assert_eq!(trail.all_entries().len(), 0);
1039 }
1040
1041 #[test]
1042 fn test_restore_from_advances_seq_counter() {
1043 let trail = create_test_trail();
1044
1045 let ts = Utc::now();
1047 let mut entries = Vec::new();
1048 let mut prev = "genesis".to_string();
1049 for i in 1..=5 {
1050 let hash = compute_entry_hash(
1051 i,
1052 &ts,
1053 "agent-001",
1054 &AuditAction::Other {
1055 detail: format!("action-{}", i),
1056 },
1057 "/resource",
1058 &prev,
1059 );
1060 entries.push(AuditEntry {
1061 seq: i,
1062 timestamp: ts,
1063 actor: "agent-001".to_string(),
1064 action: AuditAction::Other {
1065 detail: format!("action-{}", i),
1066 },
1067 resource: "/resource".to_string(),
1068 prev_hash: prev.clone(),
1069 hash: hash.clone(),
1070 metadata: None,
1071 });
1072 prev = hash;
1073 }
1074
1075 trail.restore_from(entries);
1076 assert_eq!(trail.len(), 5);
1077
1078 let new_hash = trail.append(
1080 "agent-001".to_string(),
1081 AuditAction::Other {
1082 detail: "new".to_string(),
1083 },
1084 "/resource".to_string(),
1085 );
1086 assert!(!new_hash.is_empty());
1087 assert_eq!(trail.len(), 6);
1088
1089 let all = trail.all_entries();
1090 assert_eq!(all[5].seq, 6);
1091 }
1092
1093 #[test]
1094 fn test_restore_from_trims_to_max() {
1095 let trail = AuditTrail::new(3);
1096
1097 let ts = Utc::now();
1098 let mut entries = Vec::new();
1099 let mut prev = "genesis".to_string();
1100 for i in 1..=5 {
1101 let hash = compute_entry_hash(
1102 i,
1103 &ts,
1104 "agent-001",
1105 &AuditAction::Other {
1106 detail: format!("action-{}", i),
1107 },
1108 "/resource",
1109 &prev,
1110 );
1111 entries.push(AuditEntry {
1112 seq: i,
1113 timestamp: ts,
1114 actor: "agent-001".to_string(),
1115 action: AuditAction::Other {
1116 detail: format!("action-{}", i),
1117 },
1118 resource: "/resource".to_string(),
1119 prev_hash: prev.clone(),
1120 hash: hash.clone(),
1121 metadata: None,
1122 });
1123 prev = hash;
1124 }
1125
1126 trail.restore_from(entries);
1127 assert_eq!(trail.len(), 3);
1128 let all = trail.all_entries();
1130 assert_eq!(all[0].seq, 3);
1131 assert_eq!(all[2].seq, 5);
1132 assert!(trail.verify().is_ok());
1134 }
1135}