1#![forbid(unsafe_code)]
20#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
21
22use std::collections::HashMap;
23use std::sync::{Mutex, MutexGuard};
24
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27
28#[derive(Debug, thiserror::Error)]
34pub enum SessionJournalError {
35 #[error("session journal lock poisoned")]
37 LockPoisoned,
38
39 #[error("session journal record field {field} must be non-empty, unpadded, and control-free")]
41 InvalidRecordField { field: &'static str },
42
43 #[error("hash chain integrity violation at entry {index}: expected {expected}, got {actual}")]
45 IntegrityViolation {
46 index: usize,
47 expected: String,
48 actual: String,
49 },
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct JournalEntry {
62 pub sequence: u64,
64 pub prev_hash: String,
67 pub entry_hash: String,
69 pub timestamp_secs: u64,
71 pub tool_name: String,
73 pub server_id: String,
75 pub agent_id: String,
77 pub bytes_read: u64,
79 pub bytes_written: u64,
81 pub delegation_depth: u32,
83 pub allowed: bool,
85}
86
87const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
89
90fn update_len_prefixed(hasher: &mut Sha256, value: &str) {
91 let bytes = value.as_bytes();
92 hasher.update((bytes.len() as u64).to_le_bytes());
93 hasher.update(bytes);
94}
95
96fn compute_entry_hash(entry: &JournalEntry) -> String {
98 let mut hasher = Sha256::new();
99 hasher.update(entry.sequence.to_le_bytes());
100 update_len_prefixed(&mut hasher, &entry.prev_hash);
101 hasher.update(entry.timestamp_secs.to_le_bytes());
102 update_len_prefixed(&mut hasher, &entry.tool_name);
103 update_len_prefixed(&mut hasher, &entry.server_id);
104 update_len_prefixed(&mut hasher, &entry.agent_id);
105 hasher.update(entry.bytes_read.to_le_bytes());
106 hasher.update(entry.bytes_written.to_le_bytes());
107 hasher.update(entry.delegation_depth.to_le_bytes());
108 hasher.update([u8::from(entry.allowed)]);
109 hex::encode(hasher.finalize())
110}
111
112fn validate_record_field(value: &str, field: &'static str) -> Result<(), SessionJournalError> {
113 if value.trim().is_empty() || value.trim() != value || value.chars().any(char::is_control) {
114 return Err(SessionJournalError::InvalidRecordField { field });
115 }
116 Ok(())
117}
118
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125pub struct CumulativeDataFlow {
126 pub total_bytes_read: u64,
128 pub total_bytes_written: u64,
130 pub total_invocations: u64,
132 pub max_delegation_depth: u32,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct SessionJournalSnapshot {
139 pub session_id: String,
141 pub entry_count: usize,
143 pub head_hash: String,
145 pub data_flow: CumulativeDataFlow,
147 pub tool_sequence: Vec<String>,
149 pub tool_counts: HashMap<String, u64>,
151 pub current_streak_tool: Option<String>,
156 pub current_streak_len: u64,
160}
161
162fn default_journal_entry_cap() -> usize {
174 chio_kernel::MemoryBudgetConfig::defaults().journal_entry_cap
175}
176
177fn default_journal_tool_counts_cap() -> usize {
182 chio_kernel::MemoryBudgetConfig::defaults().journal_tool_counts_cap
183}
184
185fn fold_head_hash(prev: Option<&str>, evicted_entry_hash: &str) -> String {
188 let mut hasher = Sha256::new();
189 update_len_prefixed(&mut hasher, prev.unwrap_or(""));
190 update_len_prefixed(&mut hasher, evicted_entry_hash);
191 hex::encode(hasher.finalize())
192}
193
194#[derive(Debug)]
196struct JournalInner {
197 entries: chio_bounded::Ring<JournalEntry>,
199 evicted_head_hash: Option<String>,
203 next_sequence: u64,
206 data_flow: CumulativeDataFlow,
208 tool_sequence: chio_bounded::Ring<String>,
210 tool_counts: HashMap<String, u64>,
215 tool_counts_cap: usize,
222 current_streak_tool: Option<String>,
229 current_streak_len: u64,
233}
234
235impl JournalInner {
236 fn new(entry_cap: usize, tool_counts_cap: usize) -> Self {
237 Self {
238 entries: chio_bounded::Ring::with_capacity(entry_cap, chio_bounded::SizeGauge::new()),
239 evicted_head_hash: None,
240 next_sequence: 0,
241 data_flow: CumulativeDataFlow::default(),
242 tool_sequence: chio_bounded::Ring::with_capacity(
243 entry_cap,
244 chio_bounded::SizeGauge::new(),
245 ),
246 tool_counts: HashMap::new(),
247 tool_counts_cap,
248 current_streak_tool: None,
249 current_streak_len: 0,
250 }
251 }
252
253 fn last_hash(&self) -> &str {
254 self.entries
255 .iter()
256 .last()
257 .map(|e| e.entry_hash.as_str())
258 .unwrap_or(ZERO_HASH)
259 }
260
261 fn exported_head_hash(&self) -> String {
269 match self.evicted_head_hash.as_deref() {
270 None => self.last_hash().to_string(),
271 Some(evicted) => fold_head_hash(Some(evicted), self.last_hash()),
272 }
273 }
274
275 fn snapshot(&self, session_id: &str) -> SessionJournalSnapshot {
276 SessionJournalSnapshot {
277 session_id: session_id.to_string(),
278 entry_count: self.entries.len(),
279 head_hash: self.exported_head_hash(),
280 data_flow: self.data_flow.clone(),
281 tool_sequence: self.tool_sequence.iter().cloned().collect(),
282 tool_counts: self.tool_counts.clone(),
283 current_streak_tool: self.current_streak_tool.clone(),
284 current_streak_len: self.current_streak_len,
285 }
286 }
287}
288
289pub struct SessionJournal {
298 inner: Mutex<JournalInner>,
299 session_id: String,
300}
301
302impl std::fmt::Debug for SessionJournal {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 f.debug_struct("SessionJournal")
305 .field("session_id", &self.session_id)
306 .finish()
307 }
308}
309
310#[derive(Debug, Clone)]
312pub struct RecordParams {
313 pub tool_name: String,
315 pub server_id: String,
317 pub agent_id: String,
319 pub bytes_read: u64,
321 pub bytes_written: u64,
323 pub delegation_depth: u32,
325 pub allowed: bool,
327}
328
329impl SessionJournal {
330 fn lock_inner(&self) -> Result<MutexGuard<'_, JournalInner>, SessionJournalError> {
331 self.inner
332 .lock()
333 .map_err(|_| SessionJournalError::LockPoisoned)
334 }
335
336 pub fn new(session_id: String) -> Self {
344 Self::with_caps(
345 session_id,
346 default_journal_entry_cap(),
347 default_journal_tool_counts_cap(),
348 )
349 }
350
351 pub fn from_memory_budget(
359 session_id: String,
360 budget: &chio_kernel::MemoryBudgetConfig,
361 ) -> Self {
362 Self::with_caps(
363 session_id,
364 budget.journal_entry_cap,
365 budget.journal_tool_counts_cap,
366 )
367 }
368
369 pub fn with_entry_cap(session_id: String, entry_cap: usize) -> Self {
374 Self::with_caps(session_id, entry_cap, default_journal_tool_counts_cap())
375 }
376
377 pub fn with_caps(session_id: String, entry_cap: usize, tool_counts_cap: usize) -> Self {
382 Self {
383 inner: Mutex::new(JournalInner::new(entry_cap, tool_counts_cap)),
384 session_id,
385 }
386 }
387
388 pub fn session_id(&self) -> &str {
390 &self.session_id
391 }
392
393 pub fn record(&self, params: RecordParams) -> Result<u64, SessionJournalError> {
398 validate_record_field(¶ms.tool_name, "tool_name")?;
399 validate_record_field(¶ms.server_id, "server_id")?;
400 validate_record_field(¶ms.agent_id, "agent_id")?;
401
402 let mut inner = self.lock_inner()?;
403
404 let sequence = inner.next_sequence;
405 inner.next_sequence = inner.next_sequence.saturating_add(1);
406 let prev_hash = inner.last_hash().to_string();
407 let timestamp_secs = std::time::SystemTime::now()
408 .duration_since(std::time::UNIX_EPOCH)
409 .map(|d| d.as_secs())
410 .unwrap_or(0);
411
412 let tool_name = params.tool_name;
413 let mut entry = JournalEntry {
414 sequence,
415 prev_hash,
416 entry_hash: String::new(),
417 timestamp_secs,
418 tool_name: tool_name.clone(),
419 server_id: params.server_id,
420 agent_id: params.agent_id,
421 bytes_read: params.bytes_read,
422 bytes_written: params.bytes_written,
423 delegation_depth: params.delegation_depth,
424 allowed: params.allowed,
425 };
426 entry.entry_hash = compute_entry_hash(&entry);
427
428 inner.data_flow.total_bytes_read = inner
430 .data_flow
431 .total_bytes_read
432 .saturating_add(params.bytes_read);
433 inner.data_flow.total_bytes_written = inner
434 .data_flow
435 .total_bytes_written
436 .saturating_add(params.bytes_written);
437 inner.data_flow.total_invocations = inner.data_flow.total_invocations.saturating_add(1);
438 inner.data_flow.max_delegation_depth = inner
439 .data_flow
440 .max_delegation_depth
441 .max(params.delegation_depth);
442
443 let _ = inner.tool_sequence.push(tool_name.clone());
455
456 if inner.current_streak_tool.as_deref() == Some(tool_name.as_str()) {
463 inner.current_streak_len = inner.current_streak_len.saturating_add(1);
464 } else {
465 inner.current_streak_tool = Some(tool_name.clone());
466 inner.current_streak_len = 1;
467 }
468
469 if inner.tool_counts.contains_key(&tool_name) {
470 if let Some(count) = inner.tool_counts.get_mut(&tool_name) {
471 *count = count.saturating_add(1);
472 }
473 } else if inner.tool_counts.len() < inner.tool_counts_cap {
474 inner.tool_counts.insert(tool_name, 1);
475 }
476
477 if let Some(evicted) = inner.entries.push(entry) {
478 let folded = fold_head_hash(inner.evicted_head_hash.as_deref(), &evicted.entry_hash);
481 inner.evicted_head_hash = Some(folded);
482 }
483
484 Ok(sequence)
485 }
486
487 pub fn data_flow(&self) -> Result<CumulativeDataFlow, SessionJournalError> {
489 let inner = self.lock_inner()?;
490 Ok(inner.data_flow.clone())
491 }
492
493 pub fn snapshot(&self) -> Result<SessionJournalSnapshot, SessionJournalError> {
499 let inner = self.lock_inner()?;
500 Ok(inner.snapshot(&self.session_id))
501 }
502
503 pub fn tool_sequence(&self) -> Result<Vec<String>, SessionJournalError> {
505 let inner = self.lock_inner()?;
506 Ok(inner.tool_sequence.iter().cloned().collect())
507 }
508
509 pub fn tool_counts(&self) -> Result<HashMap<String, u64>, SessionJournalError> {
511 let inner = self.lock_inner()?;
512 Ok(inner.tool_counts.clone())
513 }
514
515 pub fn tool_counts_len(&self) -> Result<usize, SessionJournalError> {
522 let inner = self.lock_inner()?;
523 Ok(inner.tool_counts.len())
524 }
525
526 pub fn len(&self) -> Result<usize, SessionJournalError> {
528 let inner = self.lock_inner()?;
529 Ok(inner.entries.len())
530 }
531
532 pub fn is_empty(&self) -> Result<bool, SessionJournalError> {
534 Ok(self.len()? == 0)
535 }
536
537 pub fn entries(&self) -> Result<Vec<JournalEntry>, SessionJournalError> {
539 let inner = self.lock_inner()?;
540 Ok(inner.entries.iter().cloned().collect())
541 }
542
543 pub fn recent_entries(&self, n: usize) -> Result<Vec<JournalEntry>, SessionJournalError> {
545 let inner = self.lock_inner()?;
546 let all: Vec<JournalEntry> = inner.entries.iter().cloned().collect();
547 let start = all.len().saturating_sub(n);
548 Ok(all[start..].to_vec())
549 }
550
551 pub fn verify_integrity(&self) -> Result<(), SessionJournalError> {
556 let inner = self.lock_inner()?;
557
558 let entries: Vec<&JournalEntry> = inner.entries.iter().collect();
559 for (index, entry) in entries.iter().enumerate() {
560 let expected_prev = if index == 0 {
562 if inner.evicted_head_hash.is_some() {
563 entry.prev_hash.as_str()
568 } else {
569 ZERO_HASH
570 }
571 } else {
572 entries[index - 1].entry_hash.as_str()
573 };
574
575 if entry.prev_hash != expected_prev {
576 return Err(SessionJournalError::IntegrityViolation {
577 index,
578 expected: expected_prev.to_string(),
579 actual: entry.prev_hash.clone(),
580 });
581 }
582
583 let recomputed = compute_entry_hash(entry);
585 if entry.entry_hash != recomputed {
586 return Err(SessionJournalError::IntegrityViolation {
587 index,
588 expected: recomputed,
589 actual: entry.entry_hash.clone(),
590 });
591 }
592 }
593
594 Ok(())
595 }
596
597 pub fn head_hash(&self) -> Result<String, SessionJournalError> {
602 let inner = self.lock_inner()?;
603 Ok(inner.exported_head_hash())
604 }
605}
606
607#[cfg(test)]
612mod tests {
613 use super::*;
614
615 fn test_params(tool: &str) -> RecordParams {
616 RecordParams {
617 tool_name: tool.to_string(),
618 server_id: "srv-1".to_string(),
619 agent_id: "agent-1".to_string(),
620 bytes_read: 100,
621 bytes_written: 50,
622 delegation_depth: 0,
623 allowed: true,
624 }
625 }
626
627 #[test]
628 fn empty_journal() {
629 let journal = SessionJournal::new("sess-1".to_string());
630 assert_eq!(journal.len().unwrap(), 0);
631 assert!(journal.is_empty().unwrap());
632 assert_eq!(journal.head_hash().unwrap(), ZERO_HASH);
633 }
634
635 #[test]
636 fn journal_entries_ring_caps_but_counts_stay_cumulative() {
637 let journal = SessionJournal::with_entry_cap("sess-cap".to_string(), 4);
640 for i in 0..10u32 {
641 journal
642 .record(test_params(&format!("tool-{}", i % 2)))
643 .unwrap();
644 }
645 assert!(
646 journal.entries().unwrap().len() <= 4,
647 "entries ring not capped"
648 );
649 let counts = journal.tool_counts().unwrap();
650 let total: u64 = counts.values().sum();
651 assert_eq!(total, 10, "cumulative counts must survive eviction");
652 assert_eq!(
653 journal.data_flow().unwrap().total_invocations,
654 10,
655 "cumulative invocation count must survive eviction"
656 );
657 let entries = journal.entries().unwrap();
661 assert_eq!(entries.last().map(|e| e.sequence), Some(9));
662 journal.verify_integrity().unwrap();
663 }
664
665 #[test]
666 fn from_memory_budget_honors_lowered_journal_caps() {
667 let budget = chio_kernel::MemoryBudgetConfig {
673 journal_entry_cap: 4,
674 journal_tool_counts_cap: 3,
675 ..chio_kernel::MemoryBudgetConfig::defaults()
676 };
677 let journal = SessionJournal::from_memory_budget("sess-budget".to_string(), &budget);
678
679 for i in 0..100u32 {
682 journal.record(test_params(&format!("tool-{i}"))).unwrap();
683 }
684 assert!(
685 journal.entries().unwrap().len() <= 4,
686 "configured journal_entry_cap did not take effect: {} entries",
687 journal.entries().unwrap().len()
688 );
689 assert!(
690 journal.tool_counts_len().unwrap() <= 3,
691 "configured journal_tool_counts_cap did not take effect: {} keys",
692 journal.tool_counts_len().unwrap()
693 );
694 assert_eq!(
695 journal.data_flow().unwrap().total_invocations,
696 100,
697 "cumulative invocation total must survive eviction"
698 );
699 }
700
701 #[test]
702 fn tool_counts_distinct_keys_are_capped_fail_closed() {
703 let journal = SessionJournal::with_caps("sess-tool-cap".to_string(), 1024, 2);
711
712 journal.record(test_params("setup")).unwrap();
714 journal.record(test_params("helper")).unwrap();
715 assert_eq!(journal.tool_counts_len().unwrap(), 2);
716
717 journal.record(test_params("setup")).unwrap();
719 journal.record(test_params("setup")).unwrap();
720 let counts = journal.tool_counts().unwrap();
721 assert_eq!(
722 counts.get("setup"),
723 Some(&3),
724 "known tool must keep counting"
725 );
726 assert_eq!(counts.get("helper"), Some(&1));
727
728 for i in 0..10_000u32 {
731 journal
732 .record(test_params(&format!("attacker-tool-{i}")))
733 .unwrap();
734 }
735 assert_eq!(
736 journal.tool_counts_len().unwrap(),
737 2,
738 "distinct-key set must stay at the cap under unbounded new tool names"
739 );
740 let counts = journal.tool_counts().unwrap();
741 assert!(
742 !counts.contains_key("attacker-tool-0"),
743 "an overflow tool name must be treated as never-invoked (fail-closed)"
744 );
745
746 assert_eq!(journal.data_flow().unwrap().total_invocations, 10_004);
749 journal.verify_integrity().unwrap();
750 }
751
752 #[test]
753 fn exported_head_hash_commits_to_evicted_prefix() {
754 let journal = SessionJournal::with_entry_cap("sess-head".to_string(), 2);
759
760 journal.record(test_params("t0")).unwrap();
763 journal.record(test_params("t1")).unwrap();
764 let retained_tail_before = journal
765 .entries()
766 .unwrap()
767 .last()
768 .map(|e| e.entry_hash.clone())
769 .unwrap();
770 assert_eq!(
771 journal.head_hash().unwrap(),
772 retained_tail_before,
773 "pre-eviction head must be the most recent entry hash"
774 );
775
776 journal.record(test_params("t2")).unwrap();
778 journal.record(test_params("t3")).unwrap();
779
780 let head_after = journal.head_hash().unwrap();
781 let retained_tail_after = journal
782 .entries()
783 .unwrap()
784 .last()
785 .map(|e| e.entry_hash.clone())
786 .unwrap();
787 assert_ne!(
788 head_after, retained_tail_after,
789 "exported head must fold the evicted prefix, not equal the retained suffix tail"
790 );
791
792 assert_eq!(
794 journal.snapshot().unwrap().head_hash,
795 head_after,
796 "snapshot head hash diverged from head_hash()"
797 );
798 }
799
800 #[test]
801 fn single_entry() {
802 let journal = SessionJournal::new("sess-1".to_string());
803 let seq = journal.record(test_params("read_file")).unwrap();
804 assert_eq!(seq, 0);
805 assert_eq!(journal.len().unwrap(), 1);
806 assert!(!journal.is_empty().unwrap());
807
808 let entries = journal.entries().unwrap();
809 assert_eq!(entries[0].prev_hash, ZERO_HASH);
810 assert!(!entries[0].entry_hash.is_empty());
811 assert_eq!(entries[0].tool_name, "read_file");
812 }
813
814 #[test]
815 fn record_rejects_padded_tool_name() {
816 let journal = SessionJournal::new("sess-1".to_string());
817 let mut params = test_params("read_file");
818 params.tool_name = " read_file".to_string();
819
820 let error = journal.record(params).unwrap_err();
821
822 assert!(matches!(
823 error,
824 SessionJournalError::InvalidRecordField { field: "tool_name" }
825 ));
826 }
827
828 #[test]
829 fn record_rejects_control_characters_in_identity_fields() {
830 for (field, params) in [
831 {
832 let mut params = test_params("read\nfile");
833 params.server_id = "srv-1".to_string();
834 params.agent_id = "agent-1".to_string();
835 ("tool_name", params)
836 },
837 {
838 let mut params = test_params("read_file");
839 params.server_id = "srv\t1".to_string();
840 params.agent_id = "agent-1".to_string();
841 ("server_id", params)
842 },
843 {
844 let mut params = test_params("read_file");
845 params.server_id = "srv-1".to_string();
846 params.agent_id = "agent\r1".to_string();
847 ("agent_id", params)
848 },
849 ] {
850 let journal = SessionJournal::new(format!("sess-control-{field}"));
851
852 let error = journal.record(params).unwrap_err();
853
854 assert!(matches!(
855 error,
856 SessionJournalError::InvalidRecordField { field: actual } if actual == field
857 ));
858 }
859 }
860
861 #[test]
862 fn hash_chain_links() {
863 let journal = SessionJournal::new("sess-chain".to_string());
864 journal.record(test_params("read_file")).unwrap();
865 journal.record(test_params("write_file")).unwrap();
866 journal.record(test_params("bash")).unwrap();
867
868 let entries = journal.entries().unwrap();
869 assert_eq!(entries[0].prev_hash, ZERO_HASH);
870 assert_eq!(entries[1].prev_hash, entries[0].entry_hash);
871 assert_eq!(entries[2].prev_hash, entries[1].entry_hash);
872 }
873
874 #[test]
875 fn integrity_check_passes() {
876 let journal = SessionJournal::new("sess-integrity".to_string());
877 for tool in &["read_file", "write_file", "bash", "http_request"] {
878 journal.record(test_params(tool)).unwrap();
879 }
880 assert!(journal.verify_integrity().is_ok());
881 }
882
883 #[test]
884 fn cumulative_data_flow() {
885 let journal = SessionJournal::new("sess-flow".to_string());
886 journal
887 .record(RecordParams {
888 tool_name: "read_file".to_string(),
889 server_id: "srv".to_string(),
890 agent_id: "agent".to_string(),
891 bytes_read: 200,
892 bytes_written: 0,
893 delegation_depth: 0,
894 allowed: true,
895 })
896 .unwrap();
897 journal
898 .record(RecordParams {
899 tool_name: "write_file".to_string(),
900 server_id: "srv".to_string(),
901 agent_id: "agent".to_string(),
902 bytes_read: 0,
903 bytes_written: 300,
904 delegation_depth: 1,
905 allowed: true,
906 })
907 .unwrap();
908
909 let flow = journal.data_flow().unwrap();
910 assert_eq!(flow.total_bytes_read, 200);
911 assert_eq!(flow.total_bytes_written, 300);
912 assert_eq!(flow.total_invocations, 2);
913 assert_eq!(flow.max_delegation_depth, 1);
914 }
915
916 #[test]
917 fn tool_sequence_tracking() {
918 let journal = SessionJournal::new("sess-seq".to_string());
919 journal.record(test_params("read_file")).unwrap();
920 journal.record(test_params("bash")).unwrap();
921 journal.record(test_params("read_file")).unwrap();
922
923 let seq = journal.tool_sequence().unwrap();
924 assert_eq!(seq, vec!["read_file", "bash", "read_file"]);
925
926 let counts = journal.tool_counts().unwrap();
927 assert_eq!(counts.get("read_file"), Some(&2));
928 assert_eq!(counts.get("bash"), Some(&1));
929 }
930
931 #[test]
932 fn snapshot_captures_guard_state_under_one_read_boundary() {
933 let journal = SessionJournal::new("sess-snapshot".to_string());
934 journal.record(test_params("read_file")).unwrap();
935 journal
936 .record(RecordParams {
937 tool_name: "bash".to_string(),
938 server_id: "srv-1".to_string(),
939 agent_id: "agent-1".to_string(),
940 bytes_read: 25,
941 bytes_written: 10,
942 delegation_depth: 2,
943 allowed: false,
944 })
945 .unwrap();
946
947 let snapshot = journal.snapshot().unwrap();
948
949 assert_eq!(snapshot.session_id, "sess-snapshot");
950 assert_eq!(snapshot.entry_count, 2);
951 assert_eq!(snapshot.head_hash, journal.head_hash().unwrap());
952 assert_eq!(snapshot.data_flow.total_bytes_read, 125);
953 assert_eq!(snapshot.data_flow.total_bytes_written, 60);
954 assert_eq!(snapshot.data_flow.total_invocations, 2);
955 assert_eq!(snapshot.data_flow.max_delegation_depth, 2);
956 assert_eq!(snapshot.tool_sequence, vec!["read_file", "bash"]);
957 assert_eq!(snapshot.tool_counts.get("read_file"), Some(&1));
958 assert_eq!(snapshot.tool_counts.get("bash"), Some(&1));
959 }
960
961 #[test]
962 fn snapshot_is_an_immutable_view_of_capture_time() {
963 let journal = SessionJournal::new("sess-snapshot-stale".to_string());
964 journal.record(test_params("read_file")).unwrap();
965
966 let snapshot = journal.snapshot().unwrap();
967 journal.record(test_params("write_file")).unwrap();
968
969 assert_eq!(snapshot.entry_count, 1);
970 assert_eq!(snapshot.tool_sequence, vec!["read_file"]);
971 assert_eq!(snapshot.tool_counts.get("write_file"), None);
972 assert_eq!(journal.len().unwrap(), 2);
973 }
974
975 #[test]
976 fn recent_entries_subset() {
977 let journal = SessionJournal::new("sess-recent".to_string());
978 for i in 0..10 {
979 journal.record(test_params(&format!("tool_{i}"))).unwrap();
980 }
981
982 let recent = journal.recent_entries(3).unwrap();
983 assert_eq!(recent.len(), 3);
984 assert_eq!(recent[0].tool_name, "tool_7");
985 assert_eq!(recent[1].tool_name, "tool_8");
986 assert_eq!(recent[2].tool_name, "tool_9");
987 }
988
989 #[test]
990 fn recent_entries_all_when_fewer() {
991 let journal = SessionJournal::new("sess-few".to_string());
992 journal.record(test_params("tool_a")).unwrap();
993 journal.record(test_params("tool_b")).unwrap();
994
995 let recent = journal.recent_entries(10).unwrap();
996 assert_eq!(recent.len(), 2);
997 }
998
999 #[test]
1000 fn session_id_accessible() {
1001 let journal = SessionJournal::new("my-session-42".to_string());
1002 assert_eq!(journal.session_id(), "my-session-42");
1003 }
1004
1005 #[test]
1006 fn denied_invocations_tracked() {
1007 let journal = SessionJournal::new("sess-denied".to_string());
1008 journal
1009 .record(RecordParams {
1010 tool_name: "bash".to_string(),
1011 server_id: "srv".to_string(),
1012 agent_id: "agent".to_string(),
1013 bytes_read: 0,
1014 bytes_written: 0,
1015 delegation_depth: 0,
1016 allowed: false,
1017 })
1018 .unwrap();
1019
1020 let entries = journal.entries().unwrap();
1021 assert!(!entries[0].allowed);
1022 let flow = journal.data_flow().unwrap();
1024 assert_eq!(flow.total_invocations, 1);
1025 }
1026
1027 #[test]
1028 fn entry_hash_separates_adjacent_string_fields() {
1029 fn first_hash(tool_name: &str, server_id: &str, agent_id: &str) -> String {
1030 compute_entry_hash(&JournalEntry {
1031 sequence: 0,
1032 prev_hash: ZERO_HASH.to_string(),
1033 entry_hash: String::new(),
1034 timestamp_secs: 42,
1035 tool_name: tool_name.to_string(),
1036 server_id: server_id.to_string(),
1037 agent_id: agent_id.to_string(),
1038 bytes_read: 1,
1039 bytes_written: 2,
1040 delegation_depth: 3,
1041 allowed: true,
1042 })
1043 }
1044
1045 let left = first_hash("ab", "c", "d");
1046 let right = first_hash("a", "bc", "d");
1047
1048 assert_ne!(left, right);
1049 }
1050
1051 #[test]
1052 fn entry_hash_determinism() {
1053 let e1 = JournalEntry {
1055 sequence: 0,
1056 prev_hash: ZERO_HASH.to_string(),
1057 entry_hash: String::new(),
1058 timestamp_secs: 1700000000,
1059 tool_name: "read_file".to_string(),
1060 server_id: "srv".to_string(),
1061 agent_id: "agent".to_string(),
1062 bytes_read: 100,
1063 bytes_written: 0,
1064 delegation_depth: 0,
1065 allowed: true,
1066 };
1067 let e2 = e1.clone();
1068 assert_eq!(compute_entry_hash(&e1), compute_entry_hash(&e2));
1069 }
1070
1071 #[test]
1072 fn entry_hash_changes_with_content() {
1073 let e1 = JournalEntry {
1074 sequence: 0,
1075 prev_hash: ZERO_HASH.to_string(),
1076 entry_hash: String::new(),
1077 timestamp_secs: 1700000000,
1078 tool_name: "read_file".to_string(),
1079 server_id: "srv".to_string(),
1080 agent_id: "agent".to_string(),
1081 bytes_read: 100,
1082 bytes_written: 0,
1083 delegation_depth: 0,
1084 allowed: true,
1085 };
1086 let mut e2 = e1.clone();
1087 e2.bytes_read = 999;
1088 assert_ne!(compute_entry_hash(&e1), compute_entry_hash(&e2));
1089 }
1090
1091 #[test]
1092 fn serde_roundtrip() {
1093 let journal = SessionJournal::new("sess-serde".to_string());
1094 journal.record(test_params("read_file")).unwrap();
1095
1096 let entries = journal.entries().unwrap();
1097 let json = serde_json::to_string_pretty(&entries).unwrap();
1098 let restored: Vec<JournalEntry> = serde_json::from_str(&json).unwrap();
1099 assert_eq!(entries.len(), restored.len());
1100 assert_eq!(entries[0].entry_hash, restored[0].entry_hash);
1101 assert_eq!(entries[0].tool_name, restored[0].tool_name);
1102 }
1103}