Skip to main content

chio_http_session/
lib.rs

1//! Per-session journal for the Chio runtime.
2//!
3//! This crate provides an append-only, hash-chained journal that tracks
4//! request history, cumulative data flow (bytes read/written), delegation
5//! depth, and tool invocation sequence within a single session.
6//!
7//! The journal persists across requests within a session and is available
8//! to all guards. Entries are tamper-evident: each entry includes a SHA-256
9//! hash of the previous entry, forming a hash chain.
10//!
11//! # Design
12//!
13//! - **Append-only**: entries can only be added, never modified or removed.
14//! - **Hash-chained**: each entry hashes the previous entry's hash for
15//!   tamper detection.
16//! - **Thread-safe**: the journal is wrapped in a `Mutex` for safe concurrent
17//!   access from multiple guards.
18
19#![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// ---------------------------------------------------------------------------
29// Error types
30// ---------------------------------------------------------------------------
31
32/// Errors produced by the session journal.
33#[derive(Debug, thiserror::Error)]
34pub enum SessionJournalError {
35    /// The journal's internal lock was poisoned.
36    #[error("session journal lock poisoned")]
37    LockPoisoned,
38
39    /// A record field was empty, padded, or contained control characters.
40    #[error("session journal record field {field} must be non-empty, unpadded, and control-free")]
41    InvalidRecordField { field: &'static str },
42
43    /// Hash chain integrity check failed.
44    #[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// ---------------------------------------------------------------------------
53// Journal entry
54// ---------------------------------------------------------------------------
55
56/// A single entry in the session journal.
57///
58/// Each entry records a tool invocation along with data flow metrics and
59/// a hash link to the previous entry for tamper detection.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct JournalEntry {
62    /// Monotonically increasing sequence number within the session (0-based).
63    pub sequence: u64,
64    /// SHA-256 hash of the previous entry's canonical representation.
65    /// The first entry uses the zero hash (64 hex zeros).
66    pub prev_hash: String,
67    /// SHA-256 hash of this entry's canonical representation (computed on append).
68    pub entry_hash: String,
69    /// Unix timestamp (seconds) when this entry was recorded.
70    pub timestamp_secs: u64,
71    /// The tool that was invoked.
72    pub tool_name: String,
73    /// The server that hosted the tool.
74    pub server_id: String,
75    /// The agent that made the invocation.
76    pub agent_id: String,
77    /// Bytes read during this invocation.
78    pub bytes_read: u64,
79    /// Bytes written during this invocation.
80    pub bytes_written: u64,
81    /// Delegation depth at the time of invocation.
82    pub delegation_depth: u32,
83    /// Whether the invocation was allowed or denied.
84    pub allowed: bool,
85}
86
87/// The zero hash used as prev_hash for the first entry.
88const 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
96/// Compute the SHA-256 hash of an entry's canonical fields (excluding entry_hash).
97fn 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// ---------------------------------------------------------------------------
120// Cumulative stats
121// ---------------------------------------------------------------------------
122
123/// Cumulative data flow statistics for a session.
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125pub struct CumulativeDataFlow {
126    /// Total bytes read across all invocations in the session.
127    pub total_bytes_read: u64,
128    /// Total bytes written across all invocations in the session.
129    pub total_bytes_written: u64,
130    /// Total number of tool invocations recorded.
131    pub total_invocations: u64,
132    /// Maximum delegation depth seen in the session.
133    pub max_delegation_depth: u32,
134}
135
136/// Immutable guard-read snapshot captured under one journal lock.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct SessionJournalSnapshot {
139    /// Session identifier for the journal that produced this snapshot.
140    pub session_id: String,
141    /// Number of entries present when the snapshot was captured.
142    pub entry_count: usize,
143    /// Hash of the most recent entry, or the zero hash for an empty journal.
144    pub head_hash: String,
145    /// Cumulative data flow state.
146    pub data_flow: CumulativeDataFlow,
147    /// Ordered tool invocation sequence.
148    pub tool_sequence: Vec<String>,
149    /// Per-tool invocation counts.
150    pub tool_counts: HashMap<String, u64>,
151    /// The tool name of the current consecutive run (the last tool recorded), or
152    /// `None` if nothing has been recorded. Cumulative and O(1): it survives ring
153    /// eviction so a same-tool streak longer than `journal_entry_cap` is still
154    /// counted in full.
155    pub current_streak_tool: Option<String>,
156    /// Length of the current consecutive run of `current_streak_tool`. Resets to
157    /// 1 whenever a different tool is recorded, so it stays bounded semantically
158    /// (a single running count, not a growing collection).
159    pub current_streak_len: u64,
160}
161
162// ---------------------------------------------------------------------------
163// Session journal (inner, not thread-safe)
164// ---------------------------------------------------------------------------
165
166/// Default entry-ring capacity when a journal is created without an explicit
167/// budget, single-sourced from the process memory budget
168/// ([`chio_kernel::MemoryBudgetConfig`]'s `journal_entry_cap`) rather than a
169/// duplicated literal, so lowering the budget lowers this cap. The `entries` and
170/// `tool_sequence` rings are bounded; `data_flow` stays cumulative and
171/// `tool_counts` is cumulative but distinct-key bounded (see
172/// [`default_journal_tool_counts_cap`]).
173fn default_journal_entry_cap() -> usize {
174    chio_kernel::MemoryBudgetConfig::defaults().journal_entry_cap
175}
176
177/// Default distinct-tool-name cap for the cumulative `tool_counts` map,
178/// single-sourced from the process memory budget
179/// ([`chio_kernel::MemoryBudgetConfig`]'s `journal_tool_counts_cap`) so lowering
180/// the budget lowers this cap.
181fn default_journal_tool_counts_cap() -> usize {
182    chio_kernel::MemoryBudgetConfig::defaults().journal_tool_counts_cap
183}
184
185/// Fold an evicted entry's hash into the running head hash so the dropped prefix
186/// stays committed after eviction.
187fn 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/// Inner journal state (not thread-safe -- wrapped by `SessionJournal`).
195#[derive(Debug)]
196struct JournalInner {
197    /// The capacity-bounded ring of retained entries.
198    entries: chio_bounded::Ring<JournalEntry>,
199    /// Running fold over the hashes of entries evicted from the ring, so the
200    /// chain stays committed after the prefix is dropped. `None` until the first
201    /// eviction.
202    evicted_head_hash: Option<String>,
203    /// Monotonic sequence counter, independent of the (bounded) ring length so
204    /// sequence numbers never repeat after eviction.
205    next_sequence: u64,
206    /// Cumulative data flow stats.
207    data_flow: CumulativeDataFlow,
208    /// Tool invocation sequence (tool names in order), bounded to the entry cap.
209    tool_sequence: chio_bounded::Ring<String>,
210    /// Per-tool invocation counts (cumulative). The counts survive entry-ring
211    /// eviction so the behavioral-sequence guard can answer "was this tool ever
212    /// invoked", but the set of DISTINCT keys is bounded fail-closed by
213    /// `tool_counts_cap`. See `record` for the overflow rule.
214    tool_counts: HashMap<String, u64>,
215    /// Maximum number of distinct tool names retained in `tool_counts`. Once
216    /// reached, a previously-unseen tool name is dropped from the cumulative
217    /// counts (fail-closed): a dependent required-predecessor check then treats
218    /// it as never-invoked and denies. Already-seen tools keep
219    /// counting so legitimate (registry-bounded) predecessor checks stay
220    /// correct across ring eviction.
221    tool_counts_cap: usize,
222    /// The tool name of the current consecutive run, or `None` before the first
223    /// record. Together with `current_streak_len` this is an O(1), bounded
224    /// cumulative streak counter (last-tool plus consecutive-count) that survives
225    /// entry-ring eviction, so a `max_consecutive` check can be enforced even when
226    /// the streak is longer than `journal_entry_cap` and the older part of the
227    /// streak has been evicted from `tool_sequence`.
228    current_streak_tool: Option<String>,
229    /// Length of the current consecutive run of `current_streak_tool`. Reset to 1
230    /// when a different tool is recorded, so it never accumulates unboundedly as a
231    /// collection: it is a single running scalar.
232    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    /// The externally visible head hash. Before any eviction this is the most
262    /// recent retained entry hash; once the ring has evicted a prefix, the head
263    /// folds the running `evicted_head_hash` together with the retained tail so
264    /// the exported head still commits to the dropped prefix. This keeps the
265    /// snapshot/`head_hash()` commitment consistent with the full pre-eviction
266    /// sequence, so an audit can detect truncation or tampering of the evicted
267    /// prefix.
268    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
289// ---------------------------------------------------------------------------
290// Session journal (thread-safe public API)
291// ---------------------------------------------------------------------------
292
293/// Thread-safe, append-only, hash-chained session journal.
294///
295/// Create one per session and share it (via `Arc<SessionJournal>`) with all
296/// guards that need session-aware context.
297pub 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/// Parameters for recording a journal entry.
311#[derive(Debug, Clone)]
312pub struct RecordParams {
313    /// The tool that was invoked.
314    pub tool_name: String,
315    /// The server hosting the tool.
316    pub server_id: String,
317    /// The agent making the request.
318    pub agent_id: String,
319    /// Bytes read during this invocation.
320    pub bytes_read: u64,
321    /// Bytes written during this invocation.
322    pub bytes_written: u64,
323    /// Current delegation depth.
324    pub delegation_depth: u32,
325    /// Whether the invocation was allowed.
326    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    /// Create a new empty journal for the given session using the DEFAULT process
337    /// memory budget caps.
338    ///
339    /// Session-aware guards that hold a CONFIGURED process memory budget should
340    /// prefer [`Self::from_memory_budget`] so lowering `journal_entry_cap` (or
341    /// `journal_tool_counts_cap`) actually tightens each per-session journal
342    /// instead of every caller silently retaining the compiled-in default.
343    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    /// Create a new empty journal whose entry-ring and distinct-tool-name caps come
352    /// from a CONFIGURED process memory budget. Threading the operator's
353    /// [`chio_kernel::MemoryBudgetConfig`] (rather than a fresh `defaults()` read
354    /// inside [`Self::new`]) means lowering `journal_entry_cap` /
355    /// `journal_tool_counts_cap` on the process budget actually bounds each
356    /// per-session journal, so a multi-session workload stays within the configured
357    /// budget instead of every session retaining the default 4096 entries.
358    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    /// Create a new empty journal with an explicit entry-ring capacity and the
370    /// default distinct-tool-name cap. The `entries` and `tool_sequence` rings
371    /// are bounded to `entry_cap`; `data_flow` stays cumulative and
372    /// `tool_counts` is cumulative but distinct-key bounded.
373    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    /// Create a new empty journal with explicit entry-ring and distinct-tool-name
378    /// caps. `entry_cap` bounds the `entries` and `tool_sequence` rings;
379    /// `tool_counts_cap` bounds the number of DISTINCT tool names retained in the
380    /// cumulative `tool_counts` map fail-closed.
381    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    /// Return the session identifier.
389    pub fn session_id(&self) -> &str {
390        &self.session_id
391    }
392
393    /// Append a new entry to the journal.
394    ///
395    /// The entry is hash-chained to the previous entry. Returns the
396    /// sequence number of the new entry.
397    pub fn record(&self, params: RecordParams) -> Result<u64, SessionJournalError> {
398        validate_record_field(&params.tool_name, "tool_name")?;
399        validate_record_field(&params.server_id, "server_id")?;
400        validate_record_field(&params.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        // Update cumulative stats.
429        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        // Update tool sequence and counts. tool_sequence is a bounded ring; the
444        // cumulative tool_counts survive ring eviction so the "ever invoked"
445        // predecessor check stays correct. The distinct-key set
446        // is bounded fail-closed by tool_counts_cap: an already-seen tool keeps
447        // counting, but once the cap is reached a previously-unseen tool name is
448        // NOT inserted, so a dependent required-predecessor check treats it as
449        // never-invoked and denies (fail-closed). This bounds the one long-lived
450        // per-session collection a ring cannot bound, without breaking the
451        // cumulative predecessor semantics for the naturally registry-bounded
452        // legitimate tool set. The append-only entry, sequence ring, and
453        // data-flow totals above are unaffected by the overflow.
454        let _ = inner.tool_sequence.push(tool_name.clone());
455
456        // Maintain the cumulative consecutive-run counter (O(1), bounded scalar).
457        // This survives entry-ring eviction so a `max_consecutive` policy is
458        // enforced even when the streak is longer than `journal_entry_cap` and the
459        // older part of the streak has been evicted from `tool_sequence`
460        // (fail-closed): the guard consults this counter, not the truncated ring
461        // tail.
462        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            // Fold the evicted prefix into a running head hash so the chain
479            // remains committed after the prefix is dropped.
480            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    /// Return a snapshot of the cumulative data flow statistics.
488    pub fn data_flow(&self) -> Result<CumulativeDataFlow, SessionJournalError> {
489        let inner = self.lock_inner()?;
490        Ok(inner.data_flow.clone())
491    }
492
493    /// Return a coherent snapshot of guard-facing journal state.
494    ///
495    /// The snapshot captures cumulative data flow, tool sequence, tool counts,
496    /// entry count, and head hash while holding one journal lock. Guards that
497    /// need more than one view should prefer this method over multiple getters.
498    pub fn snapshot(&self) -> Result<SessionJournalSnapshot, SessionJournalError> {
499        let inner = self.lock_inner()?;
500        Ok(inner.snapshot(&self.session_id))
501    }
502
503    /// Return the ordered tool invocation sequence (bounded to the entry cap).
504    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    /// Return per-tool invocation counts.
510    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    /// Number of DISTINCT tool names currently retained in the cumulative
516    /// `tool_counts` map. Bounded above by the journal's `tool_counts_cap`.
517    /// Exposed so a telemetry exporter or the kernel's
518    /// bounded-structure registry can observe how close the per-session
519    /// distinct-key set is to saturation once the journal is wired into a live
520    /// dispatch path.
521    pub fn tool_counts_len(&self) -> Result<usize, SessionJournalError> {
522        let inner = self.lock_inner()?;
523        Ok(inner.tool_counts.len())
524    }
525
526    /// Return the number of entries in the journal.
527    pub fn len(&self) -> Result<usize, SessionJournalError> {
528        let inner = self.lock_inner()?;
529        Ok(inner.entries.len())
530    }
531
532    /// Return whether the journal is empty.
533    pub fn is_empty(&self) -> Result<bool, SessionJournalError> {
534        Ok(self.len()? == 0)
535    }
536
537    /// Return a clone of the retained journal entries (bounded to the entry cap).
538    pub fn entries(&self) -> Result<Vec<JournalEntry>, SessionJournalError> {
539        let inner = self.lock_inner()?;
540        Ok(inner.entries.iter().cloned().collect())
541    }
542
543    /// Return the most recent N entries (or all retained if fewer than N exist).
544    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    /// Verify the integrity of the hash chain.
552    ///
553    /// Returns `Ok(())` if all entries are correctly chained, or an error
554    /// indicating where the chain breaks.
555    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            // Check prev_hash linkage.
561            let expected_prev = if index == 0 {
562                if inner.evicted_head_hash.is_some() {
563                    // The oldest retained entry links into the evicted prefix,
564                    // which we no longer hold; that prefix is committed via
565                    // evicted_head_hash. Accept this boundary link and verify only
566                    // the entry hash.
567                    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            // Recompute entry hash to detect tampering.
584            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    /// Return the exported head hash: the most recent entry hash before any
598    /// eviction, or (once a prefix has been evicted) a fold of the evicted
599    /// prefix and the retained tail, so the head stays committed to the full
600    /// sequence across eviction (or the zero hash if empty).
601    pub fn head_hash(&self) -> Result<String, SessionJournalError> {
602        let inner = self.lock_inner()?;
603        Ok(inner.exported_head_hash())
604    }
605}
606
607// ---------------------------------------------------------------------------
608// Tests
609// ---------------------------------------------------------------------------
610
611#[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        // The entries ring is bounded, but data_flow / tool_counts remain
638        // cumulative, and the retained window stays hash-verifiable.
639        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        // Sequence numbers keep climbing past the cap (monotonic, not len-based),
658        // and the retained window remains internally verifiable across the
659        // eviction boundary.
660        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        // The CONFIGURED process memory budget must reach each per-session
668        // journal. A budget lowering `journal_entry_cap` to 4 and
669        // `journal_tool_counts_cap` to 3 must bound this journal at those caps, not
670        // the compiled-in default of 4096; `from_memory_budget` threads the budget
671        // through where `new` would read `defaults()`.
672        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        // Flood distinct tool names: entries ring stays <= 4, distinct tool-counts
680        // keys stay <= 3, while cumulative invocation totals still survive.
681        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        // tool_counts is cumulative (survives ring eviction) but its DISTINCT-KEY
704        // set must be bounded, or a writer that can influence tool
705        // names (the journal only validates non-empty/trimmed/control-free, not
706        // registry membership) could grow one long-lived per-session map without
707        // bound. Cap the distinct keys fail-closed: already-seen tools keep
708        // counting; a previously-unseen tool name past the cap is dropped so a
709        // dependent predecessor check treats it as never-invoked.
710        let journal = SessionJournal::with_caps("sess-tool-cap".to_string(), 1024, 2);
711
712        // Fill the distinct-key cap with two legitimate tools.
713        journal.record(test_params("setup")).unwrap();
714        journal.record(test_params("helper")).unwrap();
715        assert_eq!(journal.tool_counts_len().unwrap(), 2);
716
717        // Already-seen tools keep counting cumulatively past the cap.
718        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        // Flood with many previously-unseen tool names: none are inserted, so the
729        // distinct-key set never exceeds the cap.
730        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        // The append-only journal itself is unaffected: every record still counts
747        // toward the cumulative invocation total and the sequence ring.
748        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        // Once the ring evicts a prefix, the exported head hash must still commit
755        // to the dropped entries -- it must NOT collapse
756        // to a hash of only the retained suffix, so an audit using the snapshot
757        // can detect truncation or tampering of the evicted prefix.
758        let journal = SessionJournal::with_entry_cap("sess-head".to_string(), 2);
759
760        // Fill exactly to the cap: no eviction yet, so the head is the retained
761        // tail entry hash.
762        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        // Push past the cap: this evicts the prefix (t0, then t1).
777        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        // The snapshot's head hash stays consistent with head_hash().
793        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        // Denied invocations still count toward totals.
1023        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        // Two entries with the same fields should produce the same hash.
1054        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}