1use std::ops::ControlFlow;
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::{Arc, RwLock as StdRwLock};
34
35use tokio::fs::{self, File, OpenOptions};
36use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
37use tokio::sync::Mutex;
38use zeph_common::anchor::{Anchor, AnchorStore, AnchorSubsystem};
39use zeph_common::hash_chain::{
40 ChainError, ChainHash, ChainKeyRing, ChainStreamVerifier, KeyResolution, chain_next, genesis,
41};
42
43use crate::error::SessionError;
44use crate::event::{SessionEvent, SessionEventEnvelope};
45
46const EVENTS_FILE_NAME: &str = "events.jsonl";
47#[cfg(unix)]
48const LOCK_FILE_NAME: &str = "events.jsonl.lock";
49
50pub const CHAIN_DOMAIN: &str = "zeph-session log v1";
53
54static HISTORY_INTEGRITY: StdRwLock<Option<Arc<ChainKeyRing>>> = StdRwLock::new(None);
62
63pub fn configure_history_integrity(ring: Option<Arc<ChainKeyRing>>) {
82 if let Ok(mut guard) = HISTORY_INTEGRITY.write() {
83 *guard = ring;
84 }
85}
86
87fn history_integrity() -> Option<Arc<ChainKeyRing>> {
88 HISTORY_INTEGRITY.read().ok().and_then(|g| g.clone())
89}
90
91static ANCHOR_STORE: StdRwLock<Option<Arc<dyn AnchorStore>>> = StdRwLock::new(None);
96
97pub fn configure_anchor_store(store: Option<Arc<dyn AnchorStore>>) {
100 if let Ok(mut guard) = ANCHOR_STORE.write() {
101 *guard = store;
102 }
103}
104
105fn anchor_store() -> Option<Arc<dyn AnchorStore>> {
106 ANCHOR_STORE.read().ok().and_then(|g| g.clone())
107}
108
109const REPLAY_CHUNK_SIZE: usize = 100;
112
113const ANCHOR_GET_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
119
120struct SessionWriteState {
140 file: File,
141 prev: Option<ChainHash>,
145 count: u64,
149}
150
151pub struct SessionEventLog {
152 events_path: PathBuf,
153 writer: Mutex<SessionWriteState>,
159 next_seq: AtomicU64,
160 file_identity: Vec<u8>,
161 ring: Option<Arc<ChainKeyRing>>,
164 allow_unverified: bool,
169 anchor: Option<Anchor>,
172 #[allow(dead_code)] lock: Option<AdvisoryLock>,
174}
175
176fn file_identity(session_dir: &Path) -> Vec<u8> {
180 session_dir
181 .file_name()
182 .map(|s| s.to_string_lossy().into_owned())
183 .unwrap_or_default()
184 .into_bytes()
185}
186
187impl SessionEventLog {
188 pub async fn open(session_dir: &Path) -> Result<Self, SessionError> {
207 Self::open_with_lock(session_dir, None, false).await
208 }
209
210 pub async fn open_allow_unverified(session_dir: &Path) -> Result<Self, SessionError> {
221 Self::open_with_lock(session_dir, None, true).await
222 }
223
224 pub async fn open_exclusive(session_dir: &Path) -> Result<Self, SessionError> {
237 fs::create_dir_all(session_dir).await?;
238 let lock = AdvisoryLock::acquire(session_dir)?;
239 Self::open_with_lock(session_dir, Some(lock), false).await
240 }
241
242 pub async fn open_exclusive_allow_unverified(session_dir: &Path) -> Result<Self, SessionError> {
271 fs::create_dir_all(session_dir).await?;
272 let lock = AdvisoryLock::acquire(session_dir)?;
273 Self::open_with_lock(session_dir, Some(lock), true).await
274 }
275
276 async fn open_with_lock(
277 session_dir: &Path,
278 lock: Option<AdvisoryLock>,
279 allow_unverified: bool,
280 ) -> Result<Self, SessionError> {
281 fs::create_dir_all(session_dir).await?;
282 set_permissions(session_dir, 0o700).await?;
283
284 let events_path = session_dir.join(EVENTS_FILE_NAME);
285 let ring = history_integrity();
286 let identity = file_identity(session_dir);
287
288 let anchor = match anchor_store() {
293 Some(store) => tokio::time::timeout(
294 ANCHOR_GET_TIMEOUT,
295 store.get(AnchorSubsystem::SessionLog, &identity),
296 )
297 .await
298 .map_err(|_| {
299 SessionError::Integrity(format!(
300 "vault anchor lookup for session '{}' timed out after {:?} — failing \
301 closed rather than opening unverified",
302 session_dir.display(),
303 ANCHOR_GET_TIMEOUT
304 ))
305 })?
306 .map_err(|e| SessionError::Integrity(format!("anchor lookup failed: {e}")))?,
307 None => None,
308 };
309
310 let (_, max_seq, chain_head) = read_events(
320 &events_path,
321 lock.is_some(),
322 ring.as_deref(),
323 &identity,
324 allow_unverified,
325 anchor.as_ref(),
326 )
327 .await?;
328
329 let file = OpenOptions::new()
330 .create(true)
331 .append(true)
332 .open(&events_path)
333 .await?;
334 set_permissions(&events_path, 0o600).await?;
335
336 let next_seq = max_seq.map_or(0, |seq| seq + 1);
337 let count = max_seq.map_or(0, |seq| seq + 1);
338 Ok(Self {
339 events_path,
340 writer: Mutex::new(SessionWriteState {
341 file,
342 prev: chain_head,
343 count,
344 }),
345 next_seq: AtomicU64::new(next_seq),
346 file_identity: identity,
347 ring,
348 allow_unverified,
349 anchor,
350 lock,
351 })
352 }
353
354 #[must_use]
356 pub fn path(&self) -> &Path {
357 &self.events_path
358 }
359
360 #[must_use]
362 pub fn last_seq(&self) -> Option<u64> {
363 let next = self.next_seq.load(Ordering::SeqCst);
364 next.checked_sub(1)
365 }
366
367 #[tracing::instrument(name = "session.log.append", skip_all, level = "debug")]
383 pub async fn append(
384 &self,
385 turn_id: Option<u64>,
386 parent_seq: Option<u64>,
387 kind: SessionEvent,
388 ) -> Result<SessionEventEnvelope, SessionError> {
389 let mut state = self.writer.lock().await;
390
391 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
396 let mut envelope = SessionEventEnvelope::new(seq, turn_id, parent_seq, kind);
397
398 let new_head = if let Some(ring) = self.ring.as_deref() {
399 let content = serde_json::to_vec(&envelope)?;
400 let base = state.prev.unwrap_or_else(|| {
401 genesis(
402 &ring.current_key(),
403 CHAIN_DOMAIN,
404 &self.file_identity,
405 ring.current_epoch(),
406 )
407 });
408 let h = chain_next(&ring.current_key(), &base, &content);
409 envelope.chain = Some(h.to_hex());
410 Some(h)
411 } else {
412 None
413 };
414
415 let mut line = serde_json::to_vec(&envelope)?;
416 line.push(b'\n');
417
418 state.file.write_all(&line).await?;
419 state.file.sync_all().await?;
420
421 if let Some(h) = new_head {
424 state.prev = Some(h);
425 }
426 state.count += 1;
427
428 Ok(envelope)
429 }
430
431 pub async fn finalize(&self) -> Result<(), SessionError> {
449 let Some(store) = anchor_store() else {
450 return Ok(());
451 };
452 let (head, count) = {
453 let state = self.writer.lock().await;
454 let Some(head) = state.prev else {
455 return Ok(());
456 };
457 (head, state.count)
458 };
459 let epoch = self.ring.as_ref().map_or(0, |r| r.current_epoch());
460 let anchor = Anchor::new(epoch, count, head);
461 store
462 .put(AnchorSubsystem::SessionLog, &self.file_identity, anchor)
463 .await
464 .map_err(|e| SessionError::Integrity(format!("anchor put failed: {e}")))
465 }
466
467 #[tracing::instrument(name = "session.log.read_all", skip_all, level = "debug")]
477 pub async fn read_all(&self) -> Result<Vec<SessionEventEnvelope>, SessionError> {
478 let (events, _, _) = read_events(
484 &self.events_path,
485 self.lock.is_some(),
486 self.ring.as_deref(),
487 &self.file_identity,
488 self.allow_unverified,
489 self.anchor.as_ref(),
490 )
491 .await?;
492 Ok(events)
493 }
494
495 #[tracing::instrument(name = "session.log.read_chunked", skip_all, level = "debug")]
524 pub(crate) async fn read_chunked(
525 &self,
526 on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
527 ) -> Result<(), SessionError> {
528 read_events_chunked(
529 &self.events_path,
530 self.lock.is_some(),
531 self.ring.as_deref(),
532 &self.file_identity,
533 self.allow_unverified,
534 self.anchor.as_ref(),
535 on_chunk,
536 )
537 .await
538 }
539}
540
541struct SessionChainTracker<'a> {
547 path: &'a Path,
548 ring: Option<&'a ChainKeyRing>,
549 file_identity: &'a [u8],
550 verifier: Option<ChainStreamVerifier>,
551 chain_started: bool,
552 allow_unverified: bool,
559 anchor: Option<&'a Anchor>,
563 physical_index: u64,
566 anchor_checkpoint_head: Option<ChainHash>,
568}
569
570impl<'a> SessionChainTracker<'a> {
571 fn new(
572 path: &'a Path,
573 ring: Option<&'a ChainKeyRing>,
574 file_identity: &'a [u8],
575 allow_unverified: bool,
576 anchor: Option<&'a Anchor>,
577 ) -> Self {
578 Self {
579 path,
580 ring,
581 file_identity,
582 verifier: None,
583 chain_started: false,
584 allow_unverified,
585 anchor,
586 physical_index: 0,
587 anchor_checkpoint_head: None,
588 }
589 }
590
591 fn feed(&mut self, event: &SessionEventEnvelope) -> Result<(), SessionError> {
601 if self.allow_unverified {
602 return Ok(());
603 }
604 let Some(hex) = event.chain.as_deref() else {
605 return if self.chain_started {
606 Err(SessionError::Integrity(format!(
607 "session log '{}' has an event missing its chain field while earlier \
608 events in this log are chained — partial strip detected, TAMPER DETECTED",
609 self.path.display()
610 )))
611 } else {
612 self.physical_index += 1;
615 Ok(())
616 };
617 };
618 self.chain_started = true;
619
620 let stored = ChainHash::from_hex(hex).map_err(|_| {
621 SessionError::Integrity(format!(
622 "session log '{}' has a malformed chain hash",
623 self.path.display()
624 ))
625 })?;
626
627 if self.verifier.is_none() {
628 let ring = self.ring.ok_or_else(|| {
629 SessionError::Integrity(format!(
630 "session log '{}' carries chain metadata but no history-integrity key is \
631 configured for this process — refusing to trust it unverified (NFR-004)",
632 self.path.display()
633 ))
634 })?;
635 self.verifier = Some(ChainStreamVerifier::new(
636 ring,
637 CHAIN_DOMAIN,
638 self.file_identity.to_vec(),
639 ));
640 }
641
642 let mut stripped = event.clone();
643 stripped.chain = None;
644 let content = serde_json::to_vec(&stripped)?;
645 self.verifier
647 .as_mut()
648 .expect("verifier initialized above")
649 .verify_next(&content, &stored)
650 .map_err(|e| describe_chain_error(self.path, &e))?;
651
652 self.physical_index += 1;
653 if let Some(anchor) = self.anchor
654 && self.physical_index == anchor.count
655 {
656 self.anchor_checkpoint_head =
657 self.verifier.as_ref().and_then(ChainStreamVerifier::head);
658 }
659 Ok(())
660 }
661
662 fn finish(self) -> Result<Option<ChainHash>, SessionError> {
674 if self.allow_unverified {
675 return Ok(None);
676 }
677 if let Some(KeyResolution::Rekeyed(epoch)) = self
678 .verifier
679 .as_ref()
680 .and_then(ChainStreamVerifier::resolution)
681 {
682 tracing::info!(
683 path = %self.path.display(),
684 epoch,
685 "session log verified under a previous key epoch (re-keyed, not tampered)"
686 );
687 }
688 if !self.chain_started && self.ring.is_some() {
693 warn_legacy_under_active_key_once(self.path);
694 }
695
696 if let Some(anchor) = self.anchor {
697 if !self.chain_started {
698 tracing::error!(
702 audit_event = "history_integrity_tamper",
703 subsystem = "session_log",
704 reason = "whole_strip_legacy_with_anchor",
705 path = %self.path.display(),
706 anchored_count = anchor.count,
707 "TAMPER DETECTED: session log is legacy-looking but a vault anchor exists for \
708 it (issue #6449)"
709 );
710 return Err(SessionError::Integrity(format!(
711 "TAMPER DETECTED in session log '{}': log has no chain metadata \
712 (legacy-looking) but a vault anchor exists for it (anchored at count={}) — \
713 this log was previously chained and its chain fields have been stripped",
714 self.path.display(),
715 anchor.count
716 )));
717 }
718 if self.physical_index < anchor.count {
719 tracing::error!(
720 audit_event = "history_integrity_tamper",
721 subsystem = "session_log",
722 reason = "truncated_below_anchor_count",
723 path = %self.path.display(),
724 on_disk_count = self.physical_index,
725 anchored_count = anchor.count,
726 "TAMPER DETECTED: session log truncated below its anchored count (issue #6449)"
727 );
728 return Err(SessionError::Integrity(format!(
729 "TAMPER DETECTED in session log '{}': on-disk event count ({}) is below the \
730 anchored count ({}) — the log was truncated after being anchored",
731 self.path.display(),
732 self.physical_index,
733 anchor.count
734 )));
735 }
736 let anchor_head = anchor.head().map_err(|e| {
737 SessionError::Integrity(format!(
738 "session log '{}' anchor is malformed: {e}",
739 self.path.display()
740 ))
741 })?;
742 match self.anchor_checkpoint_head {
743 Some(h) if h == anchor_head => {}
744 _ => {
745 tracing::error!(
746 audit_event = "history_integrity_tamper",
747 subsystem = "session_log",
748 reason = "anchor_head_mismatch",
749 path = %self.path.display(),
750 anchored_count = anchor.count,
751 "TAMPER DETECTED: session log chain head at the anchored count does not \
752 match the stored vault anchor (issue #6449)"
753 );
754 return Err(SessionError::Integrity(format!(
755 "TAMPER DETECTED in session log '{}': chain head at the anchored count \
756 ({}) does not match the stored vault anchor",
757 self.path.display(),
758 anchor.count
759 )));
760 }
761 }
762 }
763
764 Ok(self.verifier.and_then(|v| v.head()))
765 }
766}
767
768static WARNED_LEGACY_UNDER_KEY: std::sync::LazyLock<StdRwLock<std::collections::HashSet<PathBuf>>> =
772 std::sync::LazyLock::new(|| StdRwLock::new(std::collections::HashSet::new()));
773
774fn warn_legacy_under_active_key_once(path: &Path) {
779 let already_warned = WARNED_LEGACY_UNDER_KEY
780 .read()
781 .is_ok_and(|set| set.contains(path));
782 if already_warned {
783 return;
784 }
785 if let Ok(mut set) = WARNED_LEGACY_UNDER_KEY.write()
786 && !set.insert(path.to_path_buf())
787 {
788 return; }
790 tracing::warn!(
791 path = %path.display(),
792 "history-chain integrity: session log classifies as legacy (no chain field anywhere) \
793 while a history-integrity key IS configured for this process — this is expected for \
794 genuine pre-upgrade content, but is also the signature of a full chain-strip downgrade \
795 attack (issue #6449, the vault-anchor gap); accepted per FR-006, flagged for operator \
796 visibility"
797 );
798}
799
800fn describe_chain_error(path: &Path, err: &ChainError) -> SessionError {
804 match err {
805 ChainError::Unverifiable => SessionError::Integrity(format!(
806 "session log '{}' is unverifiable: no known key epoch (current or previous \
807 rotation window) produces a valid chain — possibly re-keyed past the rotation \
808 window, or tampered; this is fail-closed by design (NFR-004) and cannot be \
809 auto-recovered",
810 path.display()
811 )),
812 ChainError::Mismatch { index } => SessionError::Integrity(format!(
813 "TAMPER DETECTED in session log '{}': chain hash mismatch at chained-entry index \
814 {index} — content was modified, reordered, or deleted after being written",
815 path.display()
816 )),
817 other => SessionError::Integrity(format!(
818 "session log '{}' failed chain verification: {other}",
819 path.display()
820 )),
821 }
822}
823
824enum LineOutcome {
826 Eof,
828 Blank,
830 Event(Box<SessionEventEnvelope>),
834 Torn,
837}
838
839struct EventLineReader {
843 reader: BufReader<File>,
844 line: String,
845 offset: u64,
846 valid_len: u64,
847}
848
849impl EventLineReader {
850 async fn open(path: &Path) -> Result<Option<Self>, SessionError> {
852 let file = match File::open(path).await {
853 Ok(file) => file,
854 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
855 Err(e) => return Err(e.into()),
856 };
857 Ok(Some(Self {
858 reader: BufReader::new(file),
859 line: String::new(),
860 offset: 0,
861 valid_len: 0,
862 }))
863 }
864
865 async fn next_line(&mut self) -> Result<LineOutcome, SessionError> {
866 self.line.clear();
867 let bytes_read = self.reader.read_line(&mut self.line).await? as u64;
868 if bytes_read == 0 {
869 return Ok(LineOutcome::Eof);
870 }
871
872 let is_terminated = self.line.ends_with('\n');
873 let trimmed = self.line.trim_end_matches(['\n', '\r']);
874 if trimmed.is_empty() {
875 self.offset += bytes_read;
876 if is_terminated {
877 self.valid_len = self.offset;
878 }
879 return Ok(LineOutcome::Blank);
880 }
881
882 match serde_json::from_str::<SessionEventEnvelope>(trimmed) {
883 Ok(envelope) if is_terminated => {
884 self.offset += bytes_read;
885 self.valid_len = self.offset;
886 Ok(LineOutcome::Event(Box::new(envelope)))
887 }
888 _ => Ok(LineOutcome::Torn),
889 }
890 }
891}
892
893async fn repair_torn_tail(path: &Path, valid_len: u64) -> Result<(), SessionError> {
897 let actual_len = fs::metadata(path).await?.len();
898 if valid_len < actual_len {
899 let file = OpenOptions::new().write(true).open(path).await?;
900 file.set_len(valid_len).await?;
901 }
902 Ok(())
903}
904
905async fn finish_torn_tail(
908 path: &Path,
909 valid_len: u64,
910 repair: bool,
911 torn: bool,
912) -> Result<(), SessionError> {
913 if torn {
914 tracing::warn!(
915 path = %path.display(),
916 valid_len,
917 repair,
918 "dropped torn tail in session event log (INV-SP-2)"
919 );
920 }
921
922 if repair {
923 repair_torn_tail(path, valid_len).await?;
924 }
925
926 Ok(())
927}
928
929async fn read_events(
949 path: &Path,
950 repair: bool,
951 ring: Option<&ChainKeyRing>,
952 file_identity: &[u8],
953 allow_unverified: bool,
954 anchor: Option<&Anchor>,
955) -> Result<(Vec<SessionEventEnvelope>, Option<u64>, Option<ChainHash>), SessionError> {
956 let Some(mut lines) = EventLineReader::open(path).await? else {
957 return Ok((Vec::new(), None, None));
958 };
959
960 let mut events = Vec::new();
961 let mut max_seq = None;
962 let mut torn = false;
963 let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor);
964
965 loop {
966 match lines.next_line().await? {
967 LineOutcome::Eof => break,
968 LineOutcome::Blank => {}
969 LineOutcome::Event(envelope) => {
970 chain.feed(&envelope)?;
971 max_seq = Some(max_seq.map_or(envelope.seq, |m: u64| m.max(envelope.seq)));
975 events.push(*envelope);
976 }
977 LineOutcome::Torn => {
978 torn = peek_confirms_trailing_torn(&mut lines, path).await?;
979 break;
980 }
981 }
982 }
983 let valid_len = lines.valid_len;
984 drop(lines);
985
986 let chain_head = chain.finish()?;
990
991 finish_torn_tail(path, valid_len, repair, torn).await?;
992
993 Ok((events, max_seq, chain_head))
994}
995
996async fn read_events_chunked(
1003 path: &Path,
1004 repair: bool,
1005 ring: Option<&ChainKeyRing>,
1006 file_identity: &[u8],
1007 allow_unverified: bool,
1008 anchor: Option<&Anchor>,
1009 mut on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
1010) -> Result<(), SessionError> {
1011 let Some(mut lines) = EventLineReader::open(path).await? else {
1012 return Ok(());
1013 };
1014
1015 let mut chunk = Vec::with_capacity(REPLAY_CHUNK_SIZE);
1016 let mut torn = false;
1017 let mut broke_early = false;
1018 let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor);
1019
1020 loop {
1021 match lines.next_line().await? {
1022 LineOutcome::Eof => break,
1023 LineOutcome::Blank => {}
1024 LineOutcome::Event(envelope) => {
1025 chain.feed(&envelope)?;
1026 chunk.push(*envelope);
1027 if chunk.len() >= REPLAY_CHUNK_SIZE {
1028 let flushed =
1029 std::mem::replace(&mut chunk, Vec::with_capacity(REPLAY_CHUNK_SIZE));
1030 if on_chunk(flushed).is_break() {
1031 broke_early = true;
1032 break;
1033 }
1034 }
1035 }
1036 LineOutcome::Torn => {
1037 torn = peek_confirms_trailing_torn(&mut lines, path).await?;
1038 break;
1039 }
1040 }
1041 }
1042
1043 if !broke_early && !chunk.is_empty() && on_chunk(chunk).is_break() {
1044 broke_early = true;
1045 }
1046
1047 if broke_early {
1050 return Ok(());
1051 }
1052
1053 let valid_len = lines.valid_len;
1054 drop(lines);
1055 let _chain_head = chain.finish()?;
1056
1057 finish_torn_tail(path, valid_len, repair, torn).await?;
1058
1059 Ok(())
1060}
1061
1062async fn peek_confirms_trailing_torn(
1075 lines: &mut EventLineReader,
1076 path: &Path,
1077) -> Result<bool, SessionError> {
1078 match lines.next_line().await? {
1079 LineOutcome::Eof => Ok(true),
1080 _ => Err(SessionError::Integrity(format!(
1081 "internal malformed line in '{}' is not the file's physical last line — refusing \
1082 to treat it as a torn crash-recovery tail (TAMPER DETECTED or mid-file corruption)",
1083 path.display()
1084 ))),
1085 }
1086}
1087
1088#[cfg(unix)]
1092pub(crate) async fn set_permissions(path: &Path, mode: u32) -> Result<(), SessionError> {
1093 use std::os::unix::fs::PermissionsExt;
1094 fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).await?;
1095 Ok(())
1096}
1097
1098#[cfg(unix)]
1113struct AdvisoryLock(#[allow(dead_code)] rustix::fd::OwnedFd);
1114
1115#[cfg(unix)]
1116impl AdvisoryLock {
1117 fn acquire(session_dir: &Path) -> Result<Self, SessionError> {
1118 use rustix::fs::{FlockOperation, Mode, OFlags};
1119
1120 let lock_path = session_dir.join(LOCK_FILE_NAME);
1121 let fd = rustix::fs::open(
1122 &lock_path,
1123 OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
1124 Mode::from_raw_mode(0o600),
1125 )
1126 .map_err(std::io::Error::from)?;
1127
1128 rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
1129 if e == rustix::io::Errno::WOULDBLOCK {
1130 let pid = zeph_common::pidfile::read_pid_lenient(&lock_path);
1136 let pid_alive = pid.map(zeph_common::pidfile::is_process_alive);
1137 SessionError::AlreadyLocked {
1138 path: lock_path.display().to_string(),
1139 pid,
1140 pid_alive,
1141 }
1142 } else {
1143 SessionError::Io(e.into())
1144 }
1145 })?;
1146
1147 rustix::fs::ftruncate(&fd, 0).map_err(std::io::Error::from)?;
1151 rustix::io::write(&fd, std::process::id().to_string().as_bytes())
1155 .map_err(std::io::Error::from)?;
1156
1157 Ok(Self(fd))
1158 }
1159}
1160
1161#[cfg(not(unix))]
1164struct AdvisoryLock;
1165
1166#[cfg(not(unix))]
1167impl AdvisoryLock {
1168 fn acquire(_session_dir: &Path) -> Result<Self, SessionError> {
1169 Ok(Self)
1170 }
1171}
1172
1173#[cfg(not(unix))]
1174pub(crate) async fn set_permissions(_path: &Path, _mode: u32) -> Result<(), SessionError> {
1175 Ok(())
1176}
1177
1178#[cfg(test)]
1190pub(crate) struct IntegrityConfigGuard(());
1191
1192#[cfg(test)]
1193impl IntegrityConfigGuard {
1194 pub(crate) fn new() -> Self {
1195 Self(())
1196 }
1197}
1198
1199#[cfg(test)]
1200impl Drop for IntegrityConfigGuard {
1201 fn drop(&mut self) {
1202 configure_history_integrity(None);
1203 configure_anchor_store(None);
1204 }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use std::future::Future;
1210 use std::pin::Pin;
1211
1212 use super::*;
1213
1214 #[tokio::test]
1215 #[serial_test::serial(session_history_integrity)]
1216 async fn test_append_and_read_roundtrip() {
1217 let dir = tempfile::tempdir().unwrap();
1218 let log = SessionEventLog::open(dir.path()).await.unwrap();
1219
1220 for i in 0..5u64 {
1221 log.append(
1222 Some(i),
1223 None,
1224 SessionEvent::UserMessage {
1225 text: format!("msg-{i}"),
1226 image_refs: vec![],
1227 },
1228 )
1229 .await
1230 .unwrap();
1231 }
1232
1233 assert_eq!(log.last_seq(), Some(4));
1234 let events = log.read_all().await.unwrap();
1235 assert_eq!(events.len(), 5);
1236 for (i, envelope) in events.iter().enumerate() {
1237 assert_eq!(envelope.seq, i as u64);
1238 }
1239 }
1240
1241 #[tokio::test]
1242 #[serial_test::serial(session_history_integrity)]
1243 async fn test_reopen_resumes_seq() {
1244 let dir = tempfile::tempdir().unwrap();
1245 {
1246 let log = SessionEventLog::open(dir.path()).await.unwrap();
1247 log.append(
1248 None,
1249 None,
1250 SessionEvent::SessionEnded { reason: "x".into() },
1251 )
1252 .await
1253 .unwrap();
1254 }
1255 let log = SessionEventLog::open(dir.path()).await.unwrap();
1256 assert_eq!(log.last_seq(), Some(0));
1257 let appended = log
1258 .append(
1259 None,
1260 None,
1261 SessionEvent::SessionEnded { reason: "y".into() },
1262 )
1263 .await
1264 .unwrap();
1265 assert_eq!(appended.seq, 1);
1266 }
1267
1268 #[tokio::test]
1269 #[serial_test::serial(session_history_integrity)]
1270 async fn test_torn_write_truncation() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let path;
1273 {
1274 let log = SessionEventLog::open(dir.path()).await.unwrap();
1275 for i in 0..3u64 {
1276 log.append(
1277 None,
1278 None,
1279 SessionEvent::UserMessage {
1280 text: format!("msg-{i}"),
1281 image_refs: vec![],
1282 },
1283 )
1284 .await
1285 .unwrap();
1286 }
1287 path = log.path().to_path_buf();
1288 }
1289
1290 let full = tokio::fs::read(&path).await.unwrap();
1292 let cut = full.len() - 5;
1293 tokio::fs::write(&path, &full[..cut]).await.unwrap();
1294
1295 let log = SessionEventLog::open(dir.path()).await.unwrap();
1296 assert_eq!(
1297 log.last_seq(),
1298 Some(1),
1299 "torn last line must be dropped cleanly"
1300 );
1301 let events = log.read_all().await.unwrap();
1302 assert_eq!(events.len(), 2);
1303 }
1304
1305 #[cfg(unix)]
1310 #[tokio::test]
1311 #[serial_test::serial(session_history_integrity)]
1312 async fn test_open_does_not_physically_truncate_torn_tail() {
1313 let dir = tempfile::tempdir().unwrap();
1314 let path;
1315 {
1316 let log = SessionEventLog::open(dir.path()).await.unwrap();
1317 for i in 0..3u64 {
1318 log.append(
1319 None,
1320 None,
1321 SessionEvent::UserMessage {
1322 text: format!("msg-{i}"),
1323 image_refs: vec![],
1324 },
1325 )
1326 .await
1327 .unwrap();
1328 }
1329 path = log.path().to_path_buf();
1330 }
1331
1332 let full = tokio::fs::read(&path).await.unwrap();
1333 let cut = full.len() - 5;
1334 tokio::fs::write(&path, &full[..cut]).await.unwrap();
1335 let torn_len = tokio::fs::metadata(&path).await.unwrap().len();
1336
1337 let log = SessionEventLog::open(dir.path()).await.unwrap();
1340 assert_eq!(log.last_seq(), Some(1));
1341 let events = log.read_all().await.unwrap();
1342 assert_eq!(events.len(), 2);
1343 assert_eq!(
1344 tokio::fs::metadata(&path).await.unwrap().len(),
1345 torn_len,
1346 "open()/read_all() must never physically truncate the file"
1347 );
1348 drop(log);
1349
1350 let log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1352 assert_eq!(log.last_seq(), Some(1));
1353 let repaired_len = tokio::fs::metadata(&path).await.unwrap().len();
1354 assert!(
1355 repaired_len < torn_len,
1356 "open_exclusive() must physically truncate the torn tail"
1357 );
1358 }
1359
1360 #[tokio::test]
1361 #[serial_test::serial(session_history_integrity)]
1362 async fn test_torn_write_truncation_various_offsets() {
1363 for cut_from_end in [1usize, 3, 10, 20] {
1364 let dir = tempfile::tempdir().unwrap();
1365 let path;
1366 {
1367 let log = SessionEventLog::open(dir.path()).await.unwrap();
1368 for i in 0..4u64 {
1369 log.append(
1370 None,
1371 None,
1372 SessionEvent::UserMessage {
1373 text: format!("event-number-{i}"),
1374 image_refs: vec![],
1375 },
1376 )
1377 .await
1378 .unwrap();
1379 }
1380 path = log.path().to_path_buf();
1381 }
1382 let full = tokio::fs::read(&path).await.unwrap();
1383 let cut = full.len().saturating_sub(cut_from_end);
1384 tokio::fs::write(&path, &full[..cut]).await.unwrap();
1385
1386 let log = SessionEventLog::open(dir.path()).await.unwrap();
1388 let events = log.read_all().await.unwrap();
1389 assert!(events.len() <= 4);
1390 }
1391 }
1392
1393 #[tokio::test]
1394 #[serial_test::serial(session_history_integrity)]
1395 async fn test_empty_log_read_all() {
1396 let dir = tempfile::tempdir().unwrap();
1397 let log = SessionEventLog::open(dir.path()).await.unwrap();
1398 assert_eq!(log.last_seq(), None);
1399 assert!(log.read_all().await.unwrap().is_empty());
1400 }
1401
1402 #[cfg(unix)]
1403 #[tokio::test]
1404 #[serial_test::serial(session_history_integrity)]
1405 async fn test_file_permissions_are_0600() {
1406 use std::os::unix::fs::PermissionsExt;
1407 let dir = tempfile::tempdir().unwrap();
1408 let log = SessionEventLog::open(dir.path()).await.unwrap();
1409 let meta = tokio::fs::metadata(log.path()).await.unwrap();
1410 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
1411 }
1412
1413 #[tokio::test]
1418 #[serial_test::serial(session_history_integrity)]
1419 async fn test_max_seq_survives_out_of_order_physical_lines() {
1420 let dir = tempfile::tempdir().unwrap();
1421 let path = dir.path().join(EVENTS_FILE_NAME);
1422
1423 let make_line = |seq: u64| {
1424 let envelope = SessionEventEnvelope::new(
1425 seq,
1426 None,
1427 None,
1428 SessionEvent::SessionEnded { reason: "x".into() },
1429 );
1430 let mut line = serde_json::to_vec(&envelope).unwrap();
1431 line.push(b'\n');
1432 line
1433 };
1434
1435 let mut contents = make_line(7);
1438 contents.extend(make_line(6));
1439 tokio::fs::write(&path, &contents).await.unwrap();
1440
1441 let log = SessionEventLog::open(dir.path()).await.unwrap();
1442 assert_eq!(
1443 log.last_seq(),
1444 Some(7),
1445 "next_seq must be derived from the true max seq, not the last physical line"
1446 );
1447 let appended = log
1448 .append(
1449 None,
1450 None,
1451 SessionEvent::SessionEnded { reason: "z".into() },
1452 )
1453 .await
1454 .unwrap();
1455 assert_eq!(
1456 appended.seq, 8,
1457 "must not reuse a seq already present earlier in the file"
1458 );
1459 }
1460
1461 #[cfg(unix)]
1465 #[tokio::test]
1466 #[serial_test::serial(session_history_integrity)]
1467 async fn test_open_exclusive_writes_own_pid_into_lock_file() {
1468 let dir = tempfile::tempdir().unwrap();
1469 let _log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1470
1471 let lock_path = dir.path().join(LOCK_FILE_NAME);
1472 let contents = tokio::fs::read_to_string(&lock_path).await.unwrap();
1473 let pid: u32 = contents.trim().parse().unwrap_or_else(|e| {
1474 panic!("lock file contents {contents:?} did not parse as a PID: {e}")
1475 });
1476 assert_eq!(pid, std::process::id());
1477 }
1478
1479 #[cfg(unix)]
1484 #[tokio::test]
1485 #[serial_test::serial(session_history_integrity)]
1486 async fn test_open_exclusive_rejects_second_writer() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1489 match SessionEventLog::open_exclusive(dir.path()).await {
1490 Err(SessionError::AlreadyLocked { pid, pid_alive, .. }) => {
1491 assert_eq!(pid, Some(std::process::id()));
1492 assert_eq!(pid_alive, Some(true));
1493 }
1494 Err(e) => panic!("expected AlreadyLocked, got different error: {e}"),
1495 Ok(_) => panic!("expected AlreadyLocked, but second open_exclusive succeeded"),
1496 }
1497 }
1498
1499 #[cfg(unix)]
1500 #[tokio::test]
1501 #[serial_test::serial(session_history_integrity)]
1502 async fn test_open_exclusive_allows_reacquire_after_drop() {
1503 let dir = tempfile::tempdir().unwrap();
1504 {
1505 let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1506 }
1507 let _second = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1509 }
1510
1511 #[cfg(unix)]
1512 #[tokio::test]
1513 #[serial_test::serial(session_history_integrity)]
1514 async fn test_open_is_not_blocked_by_open_exclusive() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let _writer = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1517 let _reader = SessionEventLog::open(dir.path()).await.unwrap();
1519 }
1520
1521 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1527 #[serial_test::serial(session_history_integrity)]
1528 async fn test_concurrent_append_preserves_seq_order() {
1529 const N: u64 = 100;
1530
1531 let dir = tempfile::tempdir().unwrap();
1532 let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1533
1534 let mut tasks = tokio::task::JoinSet::new();
1535 for i in 0..N {
1536 let log = log.clone();
1537 tasks.spawn(async move {
1538 log.append(
1539 None,
1540 None,
1541 SessionEvent::UserMessage {
1542 text: format!("msg-{i}"),
1543 image_refs: vec![],
1544 },
1545 )
1546 .await
1547 .unwrap()
1548 .seq
1549 });
1550 }
1551
1552 let mut assigned_seqs: Vec<u64> = tasks.join_all().await;
1553 assigned_seqs.sort_unstable();
1554 assert_eq!(
1555 assigned_seqs,
1556 (0..N).collect::<Vec<_>>(),
1557 "every seq in 0..{N} must be assigned exactly once, with no gaps or duplicates"
1558 );
1559
1560 let events = log.read_all().await.unwrap();
1563 assert_eq!(events.len(), usize::try_from(N).unwrap());
1564 for (i, envelope) in events.iter().enumerate() {
1565 assert_eq!(
1566 envelope.seq, i as u64,
1567 "physical line {i} must carry seq {i}; seq and write order diverged"
1568 );
1569 }
1570 }
1571
1572 #[tokio::test]
1576 #[serial_test::serial(session_history_integrity)]
1577 async fn test_read_chunked_bounds_memory_and_matches_whole_file_read() {
1578 const N: u64 = 733; let dir = tempfile::tempdir().unwrap();
1581 let log = SessionEventLog::open(dir.path()).await.unwrap();
1582 for i in 0..N {
1583 log.append(
1584 None,
1585 None,
1586 SessionEvent::UserMessage {
1587 text: format!("msg-{i}"),
1588 image_refs: vec![],
1589 },
1590 )
1591 .await
1592 .unwrap();
1593 }
1594
1595 let (whole_file_events, _, _) =
1596 read_events(log.path(), false, None, b"test-session", false, None)
1597 .await
1598 .unwrap();
1599 assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap());
1600
1601 let mut chunked_events = Vec::new();
1602 let mut chunk_sizes = Vec::new();
1603 read_events_chunked(log.path(), false, None, b"test-session", false, None, |chunk| {
1604 assert!(
1605 chunk.len() <= REPLAY_CHUNK_SIZE,
1606 "a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}",
1607 chunk.len()
1608 );
1609 chunk_sizes.push(chunk.len());
1610 chunked_events.extend(chunk);
1611 ControlFlow::Continue(())
1612 })
1613 .await
1614 .unwrap();
1615
1616 assert_eq!(
1617 chunked_events.len(),
1618 whole_file_events.len(),
1619 "chunked read must yield the same total event count as the whole-file read"
1620 );
1621 for (whole, chunked) in whole_file_events.iter().zip(chunked_events.iter()) {
1622 assert_eq!(whole.seq, chunked.seq);
1623 }
1624 assert!(
1625 chunk_sizes.len() > 1,
1626 "expected multiple chunks for N={N} events with REPLAY_CHUNK_SIZE={REPLAY_CHUNK_SIZE}"
1627 );
1628 }
1629
1630 fn test_ring(epoch: u32, byte: u8) -> Arc<ChainKeyRing> {
1637 Arc::new(ChainKeyRing::new(
1638 epoch,
1639 zeph_common::hash_chain::ChainKey::new([byte; 32]),
1640 ))
1641 }
1642
1643 #[tokio::test]
1644 #[serial_test::serial(session_history_integrity)]
1645 async fn chained_log_roundtrip() {
1646 let _guard = IntegrityConfigGuard::new();
1647 configure_history_integrity(Some(test_ring(0, 20)));
1648 let dir = tempfile::tempdir().unwrap();
1649 let log = SessionEventLog::open(dir.path()).await.unwrap();
1650 log.append(
1651 None,
1652 None,
1653 SessionEvent::UserMessage {
1654 text: "hello".to_owned(),
1655 image_refs: vec![],
1656 },
1657 )
1658 .await
1659 .unwrap();
1660 log.append(
1661 None,
1662 None,
1663 SessionEvent::SessionEnded { reason: "x".into() },
1664 )
1665 .await
1666 .unwrap();
1667 drop(log);
1668
1669 let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1670 .await
1671 .unwrap();
1672 assert!(
1673 raw.lines().all(|l| l.contains("\"chain\":")),
1674 "every line must carry a chain field once integrity is configured"
1675 );
1676
1677 let log = SessionEventLog::open(dir.path()).await.unwrap();
1678 let events = log.read_all().await.unwrap();
1679 assert_eq!(events.len(), 2);
1680 }
1681
1682 #[tokio::test]
1683 #[serial_test::serial(session_history_integrity)]
1684 async fn tamper_in_place_edit_is_detected() {
1685 let _guard = IntegrityConfigGuard::new();
1686 configure_history_integrity(Some(test_ring(0, 21)));
1687 let dir = tempfile::tempdir().unwrap();
1688 let log = SessionEventLog::open(dir.path()).await.unwrap();
1689 log.append(
1694 None,
1695 None,
1696 SessionEvent::SessionEnded {
1697 reason: "untouched".into(),
1698 },
1699 )
1700 .await
1701 .unwrap();
1702 log.append(
1703 None,
1704 None,
1705 SessionEvent::UserMessage {
1706 text: "original".to_owned(),
1707 image_refs: vec![],
1708 },
1709 )
1710 .await
1711 .unwrap();
1712 drop(log);
1713
1714 let path = dir.path().join(EVENTS_FILE_NAME);
1715 let raw = tokio::fs::read_to_string(&path).await.unwrap();
1716 let tampered = raw.replace("original", "forged-approval");
1717 assert_ne!(raw, tampered);
1718 tokio::fs::write(&path, tampered).await.unwrap();
1719
1720 let result = SessionEventLog::open(dir.path()).await;
1721 assert!(matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("TAMPER")));
1722 }
1723
1724 #[tokio::test]
1725 #[serial_test::serial(session_history_integrity)]
1726 async fn legacy_log_is_auto_trusted_once_when_integrity_configured_later() {
1727 let _guard = IntegrityConfigGuard::new();
1728 configure_history_integrity(None);
1729 let dir = tempfile::tempdir().unwrap();
1730 let log = SessionEventLog::open(dir.path()).await.unwrap();
1731 log.append(
1732 None,
1733 None,
1734 SessionEvent::UserMessage {
1735 text: "pre-feature message".to_owned(),
1736 image_refs: vec![],
1737 },
1738 )
1739 .await
1740 .unwrap();
1741 drop(log);
1742
1743 let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1744 .await
1745 .unwrap();
1746 assert!(!raw.contains("\"chain\":"));
1747
1748 configure_history_integrity(Some(test_ring(0, 22)));
1749 let log = SessionEventLog::open(dir.path()).await.unwrap();
1750 let events = log.read_all().await.unwrap();
1751 assert_eq!(
1752 events.len(),
1753 1,
1754 "legacy content must be auto-trusted, not rejected"
1755 );
1756
1757 let events_path = dir.path().join(EVENTS_FILE_NAME);
1760 assert!(
1761 WARNED_LEGACY_UNDER_KEY
1762 .read()
1763 .unwrap()
1764 .contains(&events_path),
1765 "path must be recorded as warned after the first legacy-under-active-key read"
1766 );
1767 let warned_count_before = WARNED_LEGACY_UNDER_KEY.read().unwrap().len();
1768 let _ = log.read_all().await.unwrap();
1769 assert_eq!(
1770 WARNED_LEGACY_UNDER_KEY.read().unwrap().len(),
1771 warned_count_before,
1772 "a second read of the same path must not add a second warned-set entry"
1773 );
1774 }
1775
1776 #[tokio::test]
1777 #[serial_test::serial(session_history_integrity)]
1778 async fn partial_strip_of_chain_field_is_detected_as_tamper() {
1779 let _guard = IntegrityConfigGuard::new();
1780 configure_history_integrity(Some(test_ring(0, 23)));
1781 let dir = tempfile::tempdir().unwrap();
1782 let log = SessionEventLog::open(dir.path()).await.unwrap();
1783 log.append(
1784 None,
1785 None,
1786 SessionEvent::UserMessage {
1787 text: "one".to_owned(),
1788 image_refs: vec![],
1789 },
1790 )
1791 .await
1792 .unwrap();
1793 log.append(
1794 None,
1795 None,
1796 SessionEvent::UserMessage {
1797 text: "two".to_owned(),
1798 image_refs: vec![],
1799 },
1800 )
1801 .await
1802 .unwrap();
1803 drop(log);
1804
1805 let path = dir.path().join(EVENTS_FILE_NAME);
1806 let raw = tokio::fs::read_to_string(&path).await.unwrap();
1807 let lines: Vec<&str> = raw.lines().collect();
1808 assert_eq!(lines.len(), 2);
1809 let mut second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1810 second.as_object_mut().unwrap().remove("chain");
1811 let stripped = format!("{}\n{}\n", lines[0], second);
1812 tokio::fs::write(&path, stripped).await.unwrap();
1813
1814 let result = SessionEventLog::open(dir.path()).await;
1815 assert!(
1816 matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("partial strip"))
1817 );
1818 }
1819
1820 #[tokio::test]
1821 #[serial_test::serial(session_history_integrity)]
1822 async fn key_unavailable_on_chained_log_fails_closed_not_legacy() {
1823 let _guard = IntegrityConfigGuard::new();
1824 configure_history_integrity(Some(test_ring(0, 24)));
1825 let dir = tempfile::tempdir().unwrap();
1826 let log = SessionEventLog::open(dir.path()).await.unwrap();
1827 log.append(
1828 None,
1829 None,
1830 SessionEvent::SessionEnded { reason: "x".into() },
1831 )
1832 .await
1833 .unwrap();
1834 drop(log);
1835
1836 configure_history_integrity(None);
1837 let result = SessionEventLog::open(dir.path()).await;
1838 assert!(matches!(result, Err(SessionError::Integrity(_))));
1839 }
1840
1841 #[tokio::test]
1845 #[serial_test::serial(session_history_integrity)]
1846 async fn allow_unverified_bypasses_tamper_detection_for_the_whole_handle() {
1847 let _guard = IntegrityConfigGuard::new();
1848 configure_history_integrity(Some(test_ring(0, 40)));
1849 let dir = tempfile::tempdir().unwrap();
1850 let log = SessionEventLog::open(dir.path()).await.unwrap();
1851 log.append(
1852 None,
1853 None,
1854 SessionEvent::SessionEnded {
1855 reason: "untouched".into(),
1856 },
1857 )
1858 .await
1859 .unwrap();
1860 log.append(
1861 None,
1862 None,
1863 SessionEvent::UserMessage {
1864 text: "original".to_owned(),
1865 image_refs: vec![],
1866 },
1867 )
1868 .await
1869 .unwrap();
1870 drop(log);
1871
1872 let path = dir.path().join(EVENTS_FILE_NAME);
1873 let raw = tokio::fs::read_to_string(&path).await.unwrap();
1874 let tampered = raw.replace("original", "forged-approval");
1875 assert_ne!(raw, tampered);
1876 tokio::fs::write(&path, tampered).await.unwrap();
1877
1878 let result = SessionEventLog::open_exclusive(dir.path()).await;
1880 assert!(matches!(result, Err(SessionError::Integrity(_))));
1881
1882 let log = SessionEventLog::open_exclusive_allow_unverified(dir.path())
1884 .await
1885 .unwrap();
1886 let events = log.read_all().await.unwrap();
1887 assert_eq!(events.len(), 2);
1888 }
1889
1890 #[tokio::test]
1891 #[serial_test::serial(session_history_integrity)]
1892 async fn rotated_key_epoch_verifies_as_rekeyed_not_tampered() {
1893 let _guard = IntegrityConfigGuard::new();
1894 let old_key_byte = 25u8;
1895 configure_history_integrity(Some(test_ring(0, old_key_byte)));
1896 let dir = tempfile::tempdir().unwrap();
1897 let log = SessionEventLog::open(dir.path()).await.unwrap();
1898 log.append(
1899 None,
1900 None,
1901 SessionEvent::SessionEnded { reason: "x".into() },
1902 )
1903 .await
1904 .unwrap();
1905 drop(log);
1906
1907 let ring = Arc::new(
1908 ChainKeyRing::new(1, zeph_common::hash_chain::ChainKey::new([30u8; 32])).with_previous(
1909 0,
1910 zeph_common::hash_chain::ChainKey::new([old_key_byte; 32]),
1911 ),
1912 );
1913 configure_history_integrity(Some(ring));
1914
1915 let log = SessionEventLog::open(dir.path()).await.unwrap();
1916 let events = log.read_all().await.unwrap();
1917 assert_eq!(events.len(), 1);
1918 }
1919
1920 #[tokio::test]
1925 #[serial_test::serial(session_history_integrity)]
1926 async fn internal_malformed_line_is_never_treated_as_torn_tail() {
1927 configure_history_integrity(None);
1928 let dir = tempfile::tempdir().unwrap();
1929 let path;
1930 {
1931 let log = SessionEventLog::open(dir.path()).await.unwrap();
1932 for i in 0..3u64 {
1933 log.append(
1934 None,
1935 None,
1936 SessionEvent::UserMessage {
1937 text: format!("msg-{i}"),
1938 image_refs: vec![],
1939 },
1940 )
1941 .await
1942 .unwrap();
1943 }
1944 path = log.path().to_path_buf();
1945 }
1946
1947 let content = tokio::fs::read_to_string(&path).await.unwrap();
1951 let lines: Vec<&str> = content.lines().collect();
1952 assert_eq!(lines.len(), 3);
1953 let corrupted = format!("{}\nnot valid json at all\n{}\n", lines[0], lines[2]);
1954 tokio::fs::write(&path, corrupted).await.unwrap();
1955
1956 let result = SessionEventLog::open_exclusive(dir.path()).await;
1960 assert!(matches!(result, Err(SessionError::Integrity(_))));
1961
1962 let after = tokio::fs::read_to_string(&path).await.unwrap();
1964 assert_eq!(
1965 after.lines().count(),
1966 3,
1967 "file must not have been truncated"
1968 );
1969
1970 configure_history_integrity(None);
1971 }
1972
1973 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1976 #[serial_test::serial(session_history_integrity)]
1977 async fn concurrent_append_preserves_chain_order() {
1978 const N: u64 = 60;
1979 let _guard = IntegrityConfigGuard::new();
1980 configure_history_integrity(Some(test_ring(0, 26)));
1981 let dir = tempfile::tempdir().unwrap();
1982 let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1983
1984 let mut tasks = tokio::task::JoinSet::new();
1985 for i in 0..N {
1986 let log = log.clone();
1987 tasks.spawn(async move {
1988 log.append(
1989 None,
1990 None,
1991 SessionEvent::UserMessage {
1992 text: format!("msg-{i}"),
1993 image_refs: vec![],
1994 },
1995 )
1996 .await
1997 .unwrap();
1998 });
1999 }
2000 while tasks.join_next().await.is_some() {}
2001 drop(log);
2002
2003 let log = SessionEventLog::open(dir.path()).await.unwrap();
2006 let events = log.read_all().await.unwrap();
2007 assert_eq!(events.len(), usize::try_from(N).unwrap());
2008 }
2009
2010 #[tokio::test]
2012 #[serial_test::serial(session_history_integrity)]
2013 async fn chunked_read_verifies_chain_and_matches_whole_file_read() {
2014 const N: u64 = 250; let _guard = IntegrityConfigGuard::new();
2016 configure_history_integrity(Some(test_ring(0, 27)));
2017 let dir = tempfile::tempdir().unwrap();
2018 let log = SessionEventLog::open(dir.path()).await.unwrap();
2019 for i in 0..N {
2020 log.append(
2021 None,
2022 None,
2023 SessionEvent::UserMessage {
2024 text: format!("msg-{i}"),
2025 image_refs: vec![],
2026 },
2027 )
2028 .await
2029 .unwrap();
2030 }
2031
2032 let whole = log.read_all().await.unwrap();
2033 assert_eq!(whole.len(), usize::try_from(N).unwrap());
2034
2035 let mut chunked = Vec::new();
2036 log.read_chunked(|chunk| {
2037 chunked.extend(chunk);
2038 ControlFlow::Continue(())
2039 })
2040 .await
2041 .unwrap();
2042 assert_eq!(chunked.len(), whole.len());
2043 }
2044
2045 #[tokio::test]
2048 #[serial_test::serial(session_history_integrity)]
2049 async fn chunked_read_detects_tamper_in_a_later_chunk() {
2050 const N: u64 = 150;
2051 let _guard = IntegrityConfigGuard::new();
2052 configure_history_integrity(Some(test_ring(0, 28)));
2053 let dir = tempfile::tempdir().unwrap();
2054 let log = SessionEventLog::open(dir.path()).await.unwrap();
2055 for i in 0..N {
2056 log.append(
2057 None,
2058 None,
2059 SessionEvent::UserMessage {
2060 text: format!("msg-{i}"),
2061 image_refs: vec![],
2062 },
2063 )
2064 .await
2065 .unwrap();
2066 }
2067 let path = log.path().to_path_buf();
2068 drop(log);
2069
2070 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2072 let tampered = raw.replacen("msg-120", "forged-120", 1);
2073 assert_ne!(raw, tampered);
2074 tokio::fs::write(&path, tampered).await.unwrap();
2075
2076 configure_history_integrity(Some(test_ring(0, 28)));
2077 let log = SessionEventLog::open(dir.path()).await;
2078 match log {
2082 Err(SessionError::Integrity(_)) => {}
2083 Ok(log) => {
2084 let mut seen = Vec::new();
2085 let result = log
2086 .read_chunked(|chunk| {
2087 seen.extend(chunk);
2088 ControlFlow::Continue(())
2089 })
2090 .await;
2091 assert!(matches!(result, Err(SessionError::Integrity(_))));
2092 }
2093 Err(other) => panic!("expected Integrity error, got {other:?}"),
2094 }
2095 }
2096
2097 #[derive(Default)]
2102 struct MockAnchorStore {
2103 map: std::sync::Mutex<std::collections::HashMap<String, Anchor>>,
2104 }
2105
2106 impl AnchorStore for MockAnchorStore {
2107 fn get(
2108 &self,
2109 subsystem: AnchorSubsystem,
2110 file_id: &[u8],
2111 ) -> Pin<
2112 Box<
2113 dyn Future<Output = Result<Option<Anchor>, zeph_common::anchor::AnchorError>>
2114 + Send
2115 + '_,
2116 >,
2117 > {
2118 let result = self.get_sync(subsystem, file_id);
2119 Box::pin(async move { result })
2120 }
2121
2122 fn get_sync(
2123 &self,
2124 subsystem: AnchorSubsystem,
2125 file_id: &[u8],
2126 ) -> Result<Option<Anchor>, zeph_common::anchor::AnchorError> {
2127 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2128 Ok(self.map.lock().unwrap().get(&key).cloned())
2129 }
2130
2131 fn put(
2132 &self,
2133 subsystem: AnchorSubsystem,
2134 file_id: &[u8],
2135 anchor: Anchor,
2136 ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2137 {
2138 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2139 self.map.lock().unwrap().insert(key, anchor);
2140 Box::pin(async { Ok(()) })
2141 }
2142
2143 fn delete(
2144 &self,
2145 subsystem: AnchorSubsystem,
2146 file_id: &[u8],
2147 ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2148 {
2149 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2150 self.map.lock().unwrap().remove(&key);
2151 Box::pin(async { Ok(()) })
2152 }
2153 }
2154
2155 #[tokio::test]
2158 #[serial_test::serial(session_history_integrity)]
2159 async fn pre_anchor_chained_log_still_opens_with_anchor_store_online() {
2160 let _guard = IntegrityConfigGuard::new();
2161 configure_history_integrity(Some(test_ring(0, 40)));
2162 let dir = tempfile::tempdir().unwrap();
2163 let log = SessionEventLog::open(dir.path()).await.unwrap();
2164 log.append(
2165 None,
2166 None,
2167 SessionEvent::UserMessage {
2168 text: "pre-anchor".to_owned(),
2169 image_refs: vec![],
2170 },
2171 )
2172 .await
2173 .unwrap();
2174 drop(log);
2175
2176 configure_anchor_store(Some(Arc::new(MockAnchorStore::default())));
2177 let log = SessionEventLog::open(dir.path()).await.unwrap();
2178 let events = log.read_all().await.unwrap();
2179 assert_eq!(
2180 events.len(),
2181 1,
2182 "absent anchor must never brick a legacy-chained log"
2183 );
2184 }
2185
2186 #[tokio::test]
2187 #[serial_test::serial(session_history_integrity)]
2188 async fn whole_strip_of_anchored_session_is_tamper() {
2189 let _guard = IntegrityConfigGuard::new();
2190 configure_history_integrity(Some(test_ring(0, 41)));
2191 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2192 configure_anchor_store(Some(Arc::clone(&store)));
2193
2194 let dir = tempfile::tempdir().unwrap();
2195 let log = SessionEventLog::open(dir.path()).await.unwrap();
2196 log.append(
2197 None,
2198 None,
2199 SessionEvent::UserMessage {
2200 text: "one".to_owned(),
2201 image_refs: vec![],
2202 },
2203 )
2204 .await
2205 .unwrap();
2206 log.append(
2207 None,
2208 None,
2209 SessionEvent::SessionEnded { reason: "x".into() },
2210 )
2211 .await
2212 .unwrap();
2213 log.finalize().await.unwrap();
2214 drop(log);
2215
2216 assert!(SessionEventLog::open(dir.path()).await.is_ok());
2218
2219 let path = dir.path().join(EVENTS_FILE_NAME);
2220 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2221 let stripped: String = raw
2222 .lines()
2223 .map(|line| {
2224 let mut value: serde_json::Value = serde_json::from_str(line).unwrap();
2225 value.as_object_mut().unwrap().remove("chain");
2226 value.to_string()
2227 })
2228 .collect::<Vec<_>>()
2229 .join("\n")
2230 + "\n";
2231 tokio::fs::write(&path, stripped).await.unwrap();
2232
2233 match SessionEventLog::open(dir.path()).await {
2234 Err(SessionError::Integrity(m)) => {
2235 assert!(m.contains("TAMPER") && m.contains("vault anchor"), "{m}");
2236 }
2237 other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2238 }
2239 }
2240
2241 #[tokio::test]
2242 #[serial_test::serial(session_history_integrity)]
2243 async fn truncation_below_anchored_session_count_is_tamper() {
2244 let _guard = IntegrityConfigGuard::new();
2245 configure_history_integrity(Some(test_ring(0, 42)));
2246 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2247 configure_anchor_store(Some(Arc::clone(&store)));
2248
2249 let dir = tempfile::tempdir().unwrap();
2250 let log = SessionEventLog::open(dir.path()).await.unwrap();
2251 log.append(
2252 None,
2253 None,
2254 SessionEvent::UserMessage {
2255 text: "one".to_owned(),
2256 image_refs: vec![],
2257 },
2258 )
2259 .await
2260 .unwrap();
2261 log.append(
2262 None,
2263 None,
2264 SessionEvent::SessionEnded { reason: "x".into() },
2265 )
2266 .await
2267 .unwrap();
2268 log.finalize().await.unwrap();
2269 drop(log);
2270
2271 let path = dir.path().join(EVENTS_FILE_NAME);
2272 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2273 let first_line = raw.lines().next().unwrap();
2274 tokio::fs::write(&path, format!("{first_line}\n"))
2275 .await
2276 .unwrap();
2277
2278 match SessionEventLog::open(dir.path()).await {
2279 Err(SessionError::Integrity(m)) => {
2280 assert!(m.contains("TAMPER") && m.contains("truncated"), "{m}");
2281 }
2282 other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2283 }
2284 }
2285
2286 #[tokio::test]
2289 #[serial_test::serial(session_history_integrity)]
2290 async fn growth_after_anchor_with_matching_prefix_is_ok() {
2291 let _guard = IntegrityConfigGuard::new();
2292 configure_history_integrity(Some(test_ring(0, 43)));
2293 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2294 configure_anchor_store(Some(Arc::clone(&store)));
2295
2296 let dir = tempfile::tempdir().unwrap();
2297 let log = SessionEventLog::open(dir.path()).await.unwrap();
2298 log.append(
2299 None,
2300 None,
2301 SessionEvent::UserMessage {
2302 text: "one".to_owned(),
2303 image_refs: vec![],
2304 },
2305 )
2306 .await
2307 .unwrap();
2308 log.finalize().await.unwrap();
2309
2310 log.append(
2313 None,
2314 None,
2315 SessionEvent::SessionEnded { reason: "x".into() },
2316 )
2317 .await
2318 .unwrap();
2319 drop(log);
2320
2321 let log = SessionEventLog::open(dir.path()).await.unwrap();
2322 let events = log.read_all().await.unwrap();
2323 assert_eq!(
2324 events.len(),
2325 2,
2326 "post-anchor growth with a matching prefix must open OK"
2327 );
2328 }
2329
2330 #[tokio::test]
2331 #[serial_test::serial(session_history_integrity)]
2332 async fn finalize_is_noop_without_anchor_store_or_without_chaining() {
2333 let _guard = IntegrityConfigGuard::new();
2334 configure_history_integrity(Some(test_ring(0, 44)));
2335 let dir = tempfile::tempdir().unwrap();
2336 let log = SessionEventLog::open(dir.path()).await.unwrap();
2337 log.append(
2338 None,
2339 None,
2340 SessionEvent::SessionEnded { reason: "x".into() },
2341 )
2342 .await
2343 .unwrap();
2344 log.finalize().await.unwrap();
2345 configure_history_integrity(None);
2346
2347 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2348 configure_anchor_store(Some(Arc::clone(&store)));
2349 let dir2 = tempfile::tempdir().unwrap();
2350 let log2 = SessionEventLog::open(dir2.path()).await.unwrap();
2351 log2.append(
2352 None,
2353 None,
2354 SessionEvent::SessionEnded {
2355 reason: "legacy".into(),
2356 },
2357 )
2358 .await
2359 .unwrap();
2360 log2.finalize().await.unwrap();
2361 let identity = file_identity(dir2.path());
2362 assert!(
2363 store
2364 .get_sync(AnchorSubsystem::SessionLog, &identity)
2365 .unwrap()
2366 .is_none(),
2367 "no anchor should be written for an unchained handle"
2368 );
2369 }
2370}