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)]
1179mod tests {
1180 use std::future::Future;
1181 use std::pin::Pin;
1182
1183 use super::*;
1184
1185 #[tokio::test]
1186 async fn test_append_and_read_roundtrip() {
1187 let dir = tempfile::tempdir().unwrap();
1188 let log = SessionEventLog::open(dir.path()).await.unwrap();
1189
1190 for i in 0..5u64 {
1191 log.append(
1192 Some(i),
1193 None,
1194 SessionEvent::UserMessage {
1195 text: format!("msg-{i}"),
1196 image_refs: vec![],
1197 },
1198 )
1199 .await
1200 .unwrap();
1201 }
1202
1203 assert_eq!(log.last_seq(), Some(4));
1204 let events = log.read_all().await.unwrap();
1205 assert_eq!(events.len(), 5);
1206 for (i, envelope) in events.iter().enumerate() {
1207 assert_eq!(envelope.seq, i as u64);
1208 }
1209 }
1210
1211 #[tokio::test]
1212 async fn test_reopen_resumes_seq() {
1213 let dir = tempfile::tempdir().unwrap();
1214 {
1215 let log = SessionEventLog::open(dir.path()).await.unwrap();
1216 log.append(
1217 None,
1218 None,
1219 SessionEvent::SessionEnded { reason: "x".into() },
1220 )
1221 .await
1222 .unwrap();
1223 }
1224 let log = SessionEventLog::open(dir.path()).await.unwrap();
1225 assert_eq!(log.last_seq(), Some(0));
1226 let appended = log
1227 .append(
1228 None,
1229 None,
1230 SessionEvent::SessionEnded { reason: "y".into() },
1231 )
1232 .await
1233 .unwrap();
1234 assert_eq!(appended.seq, 1);
1235 }
1236
1237 #[tokio::test]
1238 async fn test_torn_write_truncation() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let path;
1241 {
1242 let log = SessionEventLog::open(dir.path()).await.unwrap();
1243 for i in 0..3u64 {
1244 log.append(
1245 None,
1246 None,
1247 SessionEvent::UserMessage {
1248 text: format!("msg-{i}"),
1249 image_refs: vec![],
1250 },
1251 )
1252 .await
1253 .unwrap();
1254 }
1255 path = log.path().to_path_buf();
1256 }
1257
1258 let full = tokio::fs::read(&path).await.unwrap();
1260 let cut = full.len() - 5;
1261 tokio::fs::write(&path, &full[..cut]).await.unwrap();
1262
1263 let log = SessionEventLog::open(dir.path()).await.unwrap();
1264 assert_eq!(
1265 log.last_seq(),
1266 Some(1),
1267 "torn last line must be dropped cleanly"
1268 );
1269 let events = log.read_all().await.unwrap();
1270 assert_eq!(events.len(), 2);
1271 }
1272
1273 #[cfg(unix)]
1278 #[tokio::test]
1279 async fn test_open_does_not_physically_truncate_torn_tail() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let path;
1282 {
1283 let log = SessionEventLog::open(dir.path()).await.unwrap();
1284 for i in 0..3u64 {
1285 log.append(
1286 None,
1287 None,
1288 SessionEvent::UserMessage {
1289 text: format!("msg-{i}"),
1290 image_refs: vec![],
1291 },
1292 )
1293 .await
1294 .unwrap();
1295 }
1296 path = log.path().to_path_buf();
1297 }
1298
1299 let full = tokio::fs::read(&path).await.unwrap();
1300 let cut = full.len() - 5;
1301 tokio::fs::write(&path, &full[..cut]).await.unwrap();
1302 let torn_len = tokio::fs::metadata(&path).await.unwrap().len();
1303
1304 let log = SessionEventLog::open(dir.path()).await.unwrap();
1307 assert_eq!(log.last_seq(), Some(1));
1308 let events = log.read_all().await.unwrap();
1309 assert_eq!(events.len(), 2);
1310 assert_eq!(
1311 tokio::fs::metadata(&path).await.unwrap().len(),
1312 torn_len,
1313 "open()/read_all() must never physically truncate the file"
1314 );
1315 drop(log);
1316
1317 let log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1319 assert_eq!(log.last_seq(), Some(1));
1320 let repaired_len = tokio::fs::metadata(&path).await.unwrap().len();
1321 assert!(
1322 repaired_len < torn_len,
1323 "open_exclusive() must physically truncate the torn tail"
1324 );
1325 }
1326
1327 #[tokio::test]
1328 async fn test_torn_write_truncation_various_offsets() {
1329 for cut_from_end in [1usize, 3, 10, 20] {
1330 let dir = tempfile::tempdir().unwrap();
1331 let path;
1332 {
1333 let log = SessionEventLog::open(dir.path()).await.unwrap();
1334 for i in 0..4u64 {
1335 log.append(
1336 None,
1337 None,
1338 SessionEvent::UserMessage {
1339 text: format!("event-number-{i}"),
1340 image_refs: vec![],
1341 },
1342 )
1343 .await
1344 .unwrap();
1345 }
1346 path = log.path().to_path_buf();
1347 }
1348 let full = tokio::fs::read(&path).await.unwrap();
1349 let cut = full.len().saturating_sub(cut_from_end);
1350 tokio::fs::write(&path, &full[..cut]).await.unwrap();
1351
1352 let log = SessionEventLog::open(dir.path()).await.unwrap();
1354 let events = log.read_all().await.unwrap();
1355 assert!(events.len() <= 4);
1356 }
1357 }
1358
1359 #[tokio::test]
1360 async fn test_empty_log_read_all() {
1361 let dir = tempfile::tempdir().unwrap();
1362 let log = SessionEventLog::open(dir.path()).await.unwrap();
1363 assert_eq!(log.last_seq(), None);
1364 assert!(log.read_all().await.unwrap().is_empty());
1365 }
1366
1367 #[cfg(unix)]
1368 #[tokio::test]
1369 async fn test_file_permissions_are_0600() {
1370 use std::os::unix::fs::PermissionsExt;
1371 let dir = tempfile::tempdir().unwrap();
1372 let log = SessionEventLog::open(dir.path()).await.unwrap();
1373 let meta = tokio::fs::metadata(log.path()).await.unwrap();
1374 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
1375 }
1376
1377 #[tokio::test]
1382 async fn test_max_seq_survives_out_of_order_physical_lines() {
1383 let dir = tempfile::tempdir().unwrap();
1384 let path = dir.path().join(EVENTS_FILE_NAME);
1385
1386 let make_line = |seq: u64| {
1387 let envelope = SessionEventEnvelope::new(
1388 seq,
1389 None,
1390 None,
1391 SessionEvent::SessionEnded { reason: "x".into() },
1392 );
1393 let mut line = serde_json::to_vec(&envelope).unwrap();
1394 line.push(b'\n');
1395 line
1396 };
1397
1398 let mut contents = make_line(7);
1401 contents.extend(make_line(6));
1402 tokio::fs::write(&path, &contents).await.unwrap();
1403
1404 let log = SessionEventLog::open(dir.path()).await.unwrap();
1405 assert_eq!(
1406 log.last_seq(),
1407 Some(7),
1408 "next_seq must be derived from the true max seq, not the last physical line"
1409 );
1410 let appended = log
1411 .append(
1412 None,
1413 None,
1414 SessionEvent::SessionEnded { reason: "z".into() },
1415 )
1416 .await
1417 .unwrap();
1418 assert_eq!(
1419 appended.seq, 8,
1420 "must not reuse a seq already present earlier in the file"
1421 );
1422 }
1423
1424 #[cfg(unix)]
1428 #[tokio::test]
1429 async fn test_open_exclusive_writes_own_pid_into_lock_file() {
1430 let dir = tempfile::tempdir().unwrap();
1431 let _log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1432
1433 let lock_path = dir.path().join(LOCK_FILE_NAME);
1434 let contents = tokio::fs::read_to_string(&lock_path).await.unwrap();
1435 let pid: u32 = contents.trim().parse().unwrap_or_else(|e| {
1436 panic!("lock file contents {contents:?} did not parse as a PID: {e}")
1437 });
1438 assert_eq!(pid, std::process::id());
1439 }
1440
1441 #[cfg(unix)]
1446 #[tokio::test]
1447 async fn test_open_exclusive_rejects_second_writer() {
1448 let dir = tempfile::tempdir().unwrap();
1449 let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1450 match SessionEventLog::open_exclusive(dir.path()).await {
1451 Err(SessionError::AlreadyLocked { pid, pid_alive, .. }) => {
1452 assert_eq!(pid, Some(std::process::id()));
1453 assert_eq!(pid_alive, Some(true));
1454 }
1455 Err(e) => panic!("expected AlreadyLocked, got different error: {e}"),
1456 Ok(_) => panic!("expected AlreadyLocked, but second open_exclusive succeeded"),
1457 }
1458 }
1459
1460 #[cfg(unix)]
1461 #[tokio::test]
1462 async fn test_open_exclusive_allows_reacquire_after_drop() {
1463 let dir = tempfile::tempdir().unwrap();
1464 {
1465 let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1466 }
1467 let _second = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1469 }
1470
1471 #[cfg(unix)]
1472 #[tokio::test]
1473 async fn test_open_is_not_blocked_by_open_exclusive() {
1474 let dir = tempfile::tempdir().unwrap();
1475 let _writer = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
1476 let _reader = SessionEventLog::open(dir.path()).await.unwrap();
1478 }
1479
1480 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1486 async fn test_concurrent_append_preserves_seq_order() {
1487 const N: u64 = 100;
1488
1489 let dir = tempfile::tempdir().unwrap();
1490 let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1491
1492 let mut tasks = tokio::task::JoinSet::new();
1493 for i in 0..N {
1494 let log = log.clone();
1495 tasks.spawn(async move {
1496 log.append(
1497 None,
1498 None,
1499 SessionEvent::UserMessage {
1500 text: format!("msg-{i}"),
1501 image_refs: vec![],
1502 },
1503 )
1504 .await
1505 .unwrap()
1506 .seq
1507 });
1508 }
1509
1510 let mut assigned_seqs: Vec<u64> = tasks.join_all().await;
1511 assigned_seqs.sort_unstable();
1512 assert_eq!(
1513 assigned_seqs,
1514 (0..N).collect::<Vec<_>>(),
1515 "every seq in 0..{N} must be assigned exactly once, with no gaps or duplicates"
1516 );
1517
1518 let events = log.read_all().await.unwrap();
1521 assert_eq!(events.len(), usize::try_from(N).unwrap());
1522 for (i, envelope) in events.iter().enumerate() {
1523 assert_eq!(
1524 envelope.seq, i as u64,
1525 "physical line {i} must carry seq {i}; seq and write order diverged"
1526 );
1527 }
1528 }
1529
1530 #[tokio::test]
1534 async fn test_read_chunked_bounds_memory_and_matches_whole_file_read() {
1535 const N: u64 = 733; let dir = tempfile::tempdir().unwrap();
1538 let log = SessionEventLog::open(dir.path()).await.unwrap();
1539 for i in 0..N {
1540 log.append(
1541 None,
1542 None,
1543 SessionEvent::UserMessage {
1544 text: format!("msg-{i}"),
1545 image_refs: vec![],
1546 },
1547 )
1548 .await
1549 .unwrap();
1550 }
1551
1552 let (whole_file_events, _, _) =
1553 read_events(log.path(), false, None, b"test-session", false, None)
1554 .await
1555 .unwrap();
1556 assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap());
1557
1558 let mut chunked_events = Vec::new();
1559 let mut chunk_sizes = Vec::new();
1560 read_events_chunked(log.path(), false, None, b"test-session", false, None, |chunk| {
1561 assert!(
1562 chunk.len() <= REPLAY_CHUNK_SIZE,
1563 "a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}",
1564 chunk.len()
1565 );
1566 chunk_sizes.push(chunk.len());
1567 chunked_events.extend(chunk);
1568 ControlFlow::Continue(())
1569 })
1570 .await
1571 .unwrap();
1572
1573 assert_eq!(
1574 chunked_events.len(),
1575 whole_file_events.len(),
1576 "chunked read must yield the same total event count as the whole-file read"
1577 );
1578 for (whole, chunked) in whole_file_events.iter().zip(chunked_events.iter()) {
1579 assert_eq!(whole.seq, chunked.seq);
1580 }
1581 assert!(
1582 chunk_sizes.len() > 1,
1583 "expected multiple chunks for N={N} events with REPLAY_CHUNK_SIZE={REPLAY_CHUNK_SIZE}"
1584 );
1585 }
1586
1587 fn test_ring(epoch: u32, byte: u8) -> Arc<ChainKeyRing> {
1594 Arc::new(ChainKeyRing::new(
1595 epoch,
1596 zeph_common::hash_chain::ChainKey::new([byte; 32]),
1597 ))
1598 }
1599
1600 #[tokio::test]
1601 async fn chained_log_roundtrip() {
1602 configure_history_integrity(Some(test_ring(0, 20)));
1603 let dir = tempfile::tempdir().unwrap();
1604 let log = SessionEventLog::open(dir.path()).await.unwrap();
1605 log.append(
1606 None,
1607 None,
1608 SessionEvent::UserMessage {
1609 text: "hello".to_owned(),
1610 image_refs: vec![],
1611 },
1612 )
1613 .await
1614 .unwrap();
1615 log.append(
1616 None,
1617 None,
1618 SessionEvent::SessionEnded { reason: "x".into() },
1619 )
1620 .await
1621 .unwrap();
1622 drop(log);
1623
1624 let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1625 .await
1626 .unwrap();
1627 assert!(
1628 raw.lines().all(|l| l.contains("\"chain\":")),
1629 "every line must carry a chain field once integrity is configured"
1630 );
1631
1632 let log = SessionEventLog::open(dir.path()).await.unwrap();
1633 let events = log.read_all().await.unwrap();
1634 assert_eq!(events.len(), 2);
1635
1636 configure_history_integrity(None);
1637 }
1638
1639 #[tokio::test]
1640 async fn tamper_in_place_edit_is_detected() {
1641 configure_history_integrity(Some(test_ring(0, 21)));
1642 let dir = tempfile::tempdir().unwrap();
1643 let log = SessionEventLog::open(dir.path()).await.unwrap();
1644 log.append(
1649 None,
1650 None,
1651 SessionEvent::SessionEnded {
1652 reason: "untouched".into(),
1653 },
1654 )
1655 .await
1656 .unwrap();
1657 log.append(
1658 None,
1659 None,
1660 SessionEvent::UserMessage {
1661 text: "original".to_owned(),
1662 image_refs: vec![],
1663 },
1664 )
1665 .await
1666 .unwrap();
1667 drop(log);
1668
1669 let path = dir.path().join(EVENTS_FILE_NAME);
1670 let raw = tokio::fs::read_to_string(&path).await.unwrap();
1671 let tampered = raw.replace("original", "forged-approval");
1672 assert_ne!(raw, tampered);
1673 tokio::fs::write(&path, tampered).await.unwrap();
1674
1675 let result = SessionEventLog::open(dir.path()).await;
1676 assert!(matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("TAMPER")));
1677
1678 configure_history_integrity(None);
1679 }
1680
1681 #[tokio::test]
1682 async fn legacy_log_is_auto_trusted_once_when_integrity_configured_later() {
1683 configure_history_integrity(None);
1684 let dir = tempfile::tempdir().unwrap();
1685 let log = SessionEventLog::open(dir.path()).await.unwrap();
1686 log.append(
1687 None,
1688 None,
1689 SessionEvent::UserMessage {
1690 text: "pre-feature message".to_owned(),
1691 image_refs: vec![],
1692 },
1693 )
1694 .await
1695 .unwrap();
1696 drop(log);
1697
1698 let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
1699 .await
1700 .unwrap();
1701 assert!(!raw.contains("\"chain\":"));
1702
1703 configure_history_integrity(Some(test_ring(0, 22)));
1704 let log = SessionEventLog::open(dir.path()).await.unwrap();
1705 let events = log.read_all().await.unwrap();
1706 assert_eq!(
1707 events.len(),
1708 1,
1709 "legacy content must be auto-trusted, not rejected"
1710 );
1711
1712 let events_path = dir.path().join(EVENTS_FILE_NAME);
1715 assert!(
1716 WARNED_LEGACY_UNDER_KEY
1717 .read()
1718 .unwrap()
1719 .contains(&events_path),
1720 "path must be recorded as warned after the first legacy-under-active-key read"
1721 );
1722 let warned_count_before = WARNED_LEGACY_UNDER_KEY.read().unwrap().len();
1723 let _ = log.read_all().await.unwrap();
1724 assert_eq!(
1725 WARNED_LEGACY_UNDER_KEY.read().unwrap().len(),
1726 warned_count_before,
1727 "a second read of the same path must not add a second warned-set entry"
1728 );
1729
1730 configure_history_integrity(None);
1731 }
1732
1733 #[tokio::test]
1734 async fn partial_strip_of_chain_field_is_detected_as_tamper() {
1735 configure_history_integrity(Some(test_ring(0, 23)));
1736 let dir = tempfile::tempdir().unwrap();
1737 let log = SessionEventLog::open(dir.path()).await.unwrap();
1738 log.append(
1739 None,
1740 None,
1741 SessionEvent::UserMessage {
1742 text: "one".to_owned(),
1743 image_refs: vec![],
1744 },
1745 )
1746 .await
1747 .unwrap();
1748 log.append(
1749 None,
1750 None,
1751 SessionEvent::UserMessage {
1752 text: "two".to_owned(),
1753 image_refs: vec![],
1754 },
1755 )
1756 .await
1757 .unwrap();
1758 drop(log);
1759
1760 let path = dir.path().join(EVENTS_FILE_NAME);
1761 let raw = tokio::fs::read_to_string(&path).await.unwrap();
1762 let lines: Vec<&str> = raw.lines().collect();
1763 assert_eq!(lines.len(), 2);
1764 let mut second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1765 second.as_object_mut().unwrap().remove("chain");
1766 let stripped = format!("{}\n{}\n", lines[0], second);
1767 tokio::fs::write(&path, stripped).await.unwrap();
1768
1769 let result = SessionEventLog::open(dir.path()).await;
1770 assert!(
1771 matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("partial strip"))
1772 );
1773
1774 configure_history_integrity(None);
1775 }
1776
1777 #[tokio::test]
1778 async fn key_unavailable_on_chained_log_fails_closed_not_legacy() {
1779 configure_history_integrity(Some(test_ring(0, 24)));
1780 let dir = tempfile::tempdir().unwrap();
1781 let log = SessionEventLog::open(dir.path()).await.unwrap();
1782 log.append(
1783 None,
1784 None,
1785 SessionEvent::SessionEnded { reason: "x".into() },
1786 )
1787 .await
1788 .unwrap();
1789 drop(log);
1790
1791 configure_history_integrity(None);
1792 let result = SessionEventLog::open(dir.path()).await;
1793 assert!(matches!(result, Err(SessionError::Integrity(_))));
1794 }
1795
1796 #[tokio::test]
1800 async fn allow_unverified_bypasses_tamper_detection_for_the_whole_handle() {
1801 configure_history_integrity(Some(test_ring(0, 40)));
1802 let dir = tempfile::tempdir().unwrap();
1803 let log = SessionEventLog::open(dir.path()).await.unwrap();
1804 log.append(
1805 None,
1806 None,
1807 SessionEvent::SessionEnded {
1808 reason: "untouched".into(),
1809 },
1810 )
1811 .await
1812 .unwrap();
1813 log.append(
1814 None,
1815 None,
1816 SessionEvent::UserMessage {
1817 text: "original".to_owned(),
1818 image_refs: vec![],
1819 },
1820 )
1821 .await
1822 .unwrap();
1823 drop(log);
1824
1825 let path = dir.path().join(EVENTS_FILE_NAME);
1826 let raw = tokio::fs::read_to_string(&path).await.unwrap();
1827 let tampered = raw.replace("original", "forged-approval");
1828 assert_ne!(raw, tampered);
1829 tokio::fs::write(&path, tampered).await.unwrap();
1830
1831 let result = SessionEventLog::open_exclusive(dir.path()).await;
1833 assert!(matches!(result, Err(SessionError::Integrity(_))));
1834
1835 let log = SessionEventLog::open_exclusive_allow_unverified(dir.path())
1837 .await
1838 .unwrap();
1839 let events = log.read_all().await.unwrap();
1840 assert_eq!(events.len(), 2);
1841
1842 configure_history_integrity(None);
1843 }
1844
1845 #[tokio::test]
1846 async fn rotated_key_epoch_verifies_as_rekeyed_not_tampered() {
1847 let old_key_byte = 25u8;
1848 configure_history_integrity(Some(test_ring(0, old_key_byte)));
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 { reason: "x".into() },
1855 )
1856 .await
1857 .unwrap();
1858 drop(log);
1859
1860 let ring = Arc::new(
1861 ChainKeyRing::new(1, zeph_common::hash_chain::ChainKey::new([30u8; 32])).with_previous(
1862 0,
1863 zeph_common::hash_chain::ChainKey::new([old_key_byte; 32]),
1864 ),
1865 );
1866 configure_history_integrity(Some(ring));
1867
1868 let log = SessionEventLog::open(dir.path()).await.unwrap();
1869 let events = log.read_all().await.unwrap();
1870 assert_eq!(events.len(), 1);
1871
1872 configure_history_integrity(None);
1873 }
1874
1875 #[tokio::test]
1880 async fn internal_malformed_line_is_never_treated_as_torn_tail() {
1881 configure_history_integrity(None);
1882 let dir = tempfile::tempdir().unwrap();
1883 let path;
1884 {
1885 let log = SessionEventLog::open(dir.path()).await.unwrap();
1886 for i in 0..3u64 {
1887 log.append(
1888 None,
1889 None,
1890 SessionEvent::UserMessage {
1891 text: format!("msg-{i}"),
1892 image_refs: vec![],
1893 },
1894 )
1895 .await
1896 .unwrap();
1897 }
1898 path = log.path().to_path_buf();
1899 }
1900
1901 let content = tokio::fs::read_to_string(&path).await.unwrap();
1905 let lines: Vec<&str> = content.lines().collect();
1906 assert_eq!(lines.len(), 3);
1907 let corrupted = format!("{}\nnot valid json at all\n{}\n", lines[0], lines[2]);
1908 tokio::fs::write(&path, corrupted).await.unwrap();
1909
1910 let result = SessionEventLog::open_exclusive(dir.path()).await;
1914 assert!(matches!(result, Err(SessionError::Integrity(_))));
1915
1916 let after = tokio::fs::read_to_string(&path).await.unwrap();
1918 assert_eq!(
1919 after.lines().count(),
1920 3,
1921 "file must not have been truncated"
1922 );
1923
1924 configure_history_integrity(None);
1925 }
1926
1927 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
1930 async fn concurrent_append_preserves_chain_order() {
1931 const N: u64 = 60;
1932 configure_history_integrity(Some(test_ring(0, 26)));
1933 let dir = tempfile::tempdir().unwrap();
1934 let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
1935
1936 let mut tasks = tokio::task::JoinSet::new();
1937 for i in 0..N {
1938 let log = log.clone();
1939 tasks.spawn(async move {
1940 log.append(
1941 None,
1942 None,
1943 SessionEvent::UserMessage {
1944 text: format!("msg-{i}"),
1945 image_refs: vec![],
1946 },
1947 )
1948 .await
1949 .unwrap();
1950 });
1951 }
1952 while tasks.join_next().await.is_some() {}
1953 drop(log);
1954
1955 let log = SessionEventLog::open(dir.path()).await.unwrap();
1958 let events = log.read_all().await.unwrap();
1959 assert_eq!(events.len(), usize::try_from(N).unwrap());
1960
1961 configure_history_integrity(None);
1962 }
1963
1964 #[tokio::test]
1966 async fn chunked_read_verifies_chain_and_matches_whole_file_read() {
1967 const N: u64 = 250; configure_history_integrity(Some(test_ring(0, 27)));
1969 let dir = tempfile::tempdir().unwrap();
1970 let log = SessionEventLog::open(dir.path()).await.unwrap();
1971 for i in 0..N {
1972 log.append(
1973 None,
1974 None,
1975 SessionEvent::UserMessage {
1976 text: format!("msg-{i}"),
1977 image_refs: vec![],
1978 },
1979 )
1980 .await
1981 .unwrap();
1982 }
1983
1984 let whole = log.read_all().await.unwrap();
1985 assert_eq!(whole.len(), usize::try_from(N).unwrap());
1986
1987 let mut chunked = Vec::new();
1988 log.read_chunked(|chunk| {
1989 chunked.extend(chunk);
1990 ControlFlow::Continue(())
1991 })
1992 .await
1993 .unwrap();
1994 assert_eq!(chunked.len(), whole.len());
1995
1996 configure_history_integrity(None);
1997 }
1998
1999 #[tokio::test]
2002 async fn chunked_read_detects_tamper_in_a_later_chunk() {
2003 const N: u64 = 150;
2004 configure_history_integrity(Some(test_ring(0, 28)));
2005 let dir = tempfile::tempdir().unwrap();
2006 let log = SessionEventLog::open(dir.path()).await.unwrap();
2007 for i in 0..N {
2008 log.append(
2009 None,
2010 None,
2011 SessionEvent::UserMessage {
2012 text: format!("msg-{i}"),
2013 image_refs: vec![],
2014 },
2015 )
2016 .await
2017 .unwrap();
2018 }
2019 let path = log.path().to_path_buf();
2020 drop(log);
2021
2022 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2024 let tampered = raw.replacen("msg-120", "forged-120", 1);
2025 assert_ne!(raw, tampered);
2026 tokio::fs::write(&path, tampered).await.unwrap();
2027
2028 configure_history_integrity(Some(test_ring(0, 28)));
2029 let log = SessionEventLog::open(dir.path()).await;
2030 match log {
2034 Err(SessionError::Integrity(_)) => {}
2035 Ok(log) => {
2036 let mut seen = Vec::new();
2037 let result = log
2038 .read_chunked(|chunk| {
2039 seen.extend(chunk);
2040 ControlFlow::Continue(())
2041 })
2042 .await;
2043 assert!(matches!(result, Err(SessionError::Integrity(_))));
2044 }
2045 Err(other) => panic!("expected Integrity error, got {other:?}"),
2046 }
2047
2048 configure_history_integrity(None);
2049 }
2050
2051 #[derive(Default)]
2056 struct MockAnchorStore {
2057 map: std::sync::Mutex<std::collections::HashMap<String, Anchor>>,
2058 }
2059
2060 impl AnchorStore for MockAnchorStore {
2061 fn get(
2062 &self,
2063 subsystem: AnchorSubsystem,
2064 file_id: &[u8],
2065 ) -> Pin<
2066 Box<
2067 dyn Future<Output = Result<Option<Anchor>, zeph_common::anchor::AnchorError>>
2068 + Send
2069 + '_,
2070 >,
2071 > {
2072 let result = self.get_sync(subsystem, file_id);
2073 Box::pin(async move { result })
2074 }
2075
2076 fn get_sync(
2077 &self,
2078 subsystem: AnchorSubsystem,
2079 file_id: &[u8],
2080 ) -> Result<Option<Anchor>, zeph_common::anchor::AnchorError> {
2081 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2082 Ok(self.map.lock().unwrap().get(&key).cloned())
2083 }
2084
2085 fn put(
2086 &self,
2087 subsystem: AnchorSubsystem,
2088 file_id: &[u8],
2089 anchor: Anchor,
2090 ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2091 {
2092 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2093 self.map.lock().unwrap().insert(key, anchor);
2094 Box::pin(async { Ok(()) })
2095 }
2096
2097 fn delete(
2098 &self,
2099 subsystem: AnchorSubsystem,
2100 file_id: &[u8],
2101 ) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
2102 {
2103 let key = zeph_common::anchor::anchor_key(subsystem, file_id);
2104 self.map.lock().unwrap().remove(&key);
2105 Box::pin(async { Ok(()) })
2106 }
2107 }
2108
2109 #[tokio::test]
2112 async fn pre_anchor_chained_log_still_opens_with_anchor_store_online() {
2113 configure_history_integrity(Some(test_ring(0, 40)));
2114 let dir = tempfile::tempdir().unwrap();
2115 let log = SessionEventLog::open(dir.path()).await.unwrap();
2116 log.append(
2117 None,
2118 None,
2119 SessionEvent::UserMessage {
2120 text: "pre-anchor".to_owned(),
2121 image_refs: vec![],
2122 },
2123 )
2124 .await
2125 .unwrap();
2126 drop(log);
2127
2128 configure_anchor_store(Some(Arc::new(MockAnchorStore::default())));
2129 let log = SessionEventLog::open(dir.path()).await.unwrap();
2130 let events = log.read_all().await.unwrap();
2131 assert_eq!(
2132 events.len(),
2133 1,
2134 "absent anchor must never brick a legacy-chained log"
2135 );
2136
2137 configure_anchor_store(None);
2138 configure_history_integrity(None);
2139 }
2140
2141 #[tokio::test]
2142 async fn whole_strip_of_anchored_session_is_tamper() {
2143 configure_history_integrity(Some(test_ring(0, 41)));
2144 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2145 configure_anchor_store(Some(Arc::clone(&store)));
2146
2147 let dir = tempfile::tempdir().unwrap();
2148 let log = SessionEventLog::open(dir.path()).await.unwrap();
2149 log.append(
2150 None,
2151 None,
2152 SessionEvent::UserMessage {
2153 text: "one".to_owned(),
2154 image_refs: vec![],
2155 },
2156 )
2157 .await
2158 .unwrap();
2159 log.append(
2160 None,
2161 None,
2162 SessionEvent::SessionEnded { reason: "x".into() },
2163 )
2164 .await
2165 .unwrap();
2166 log.finalize().await.unwrap();
2167 drop(log);
2168
2169 assert!(SessionEventLog::open(dir.path()).await.is_ok());
2171
2172 let path = dir.path().join(EVENTS_FILE_NAME);
2173 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2174 let stripped: String = raw
2175 .lines()
2176 .map(|line| {
2177 let mut value: serde_json::Value = serde_json::from_str(line).unwrap();
2178 value.as_object_mut().unwrap().remove("chain");
2179 value.to_string()
2180 })
2181 .collect::<Vec<_>>()
2182 .join("\n")
2183 + "\n";
2184 tokio::fs::write(&path, stripped).await.unwrap();
2185
2186 match SessionEventLog::open(dir.path()).await {
2187 Err(SessionError::Integrity(m)) => {
2188 assert!(m.contains("TAMPER") && m.contains("vault anchor"), "{m}");
2189 }
2190 other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2191 }
2192
2193 configure_anchor_store(None);
2194 configure_history_integrity(None);
2195 }
2196
2197 #[tokio::test]
2198 async fn truncation_below_anchored_session_count_is_tamper() {
2199 configure_history_integrity(Some(test_ring(0, 42)));
2200 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2201 configure_anchor_store(Some(Arc::clone(&store)));
2202
2203 let dir = tempfile::tempdir().unwrap();
2204 let log = SessionEventLog::open(dir.path()).await.unwrap();
2205 log.append(
2206 None,
2207 None,
2208 SessionEvent::UserMessage {
2209 text: "one".to_owned(),
2210 image_refs: vec![],
2211 },
2212 )
2213 .await
2214 .unwrap();
2215 log.append(
2216 None,
2217 None,
2218 SessionEvent::SessionEnded { reason: "x".into() },
2219 )
2220 .await
2221 .unwrap();
2222 log.finalize().await.unwrap();
2223 drop(log);
2224
2225 let path = dir.path().join(EVENTS_FILE_NAME);
2226 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2227 let first_line = raw.lines().next().unwrap();
2228 tokio::fs::write(&path, format!("{first_line}\n"))
2229 .await
2230 .unwrap();
2231
2232 match SessionEventLog::open(dir.path()).await {
2233 Err(SessionError::Integrity(m)) => {
2234 assert!(m.contains("TAMPER") && m.contains("truncated"), "{m}");
2235 }
2236 other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
2237 }
2238
2239 configure_anchor_store(None);
2240 configure_history_integrity(None);
2241 }
2242
2243 #[tokio::test]
2246 async fn growth_after_anchor_with_matching_prefix_is_ok() {
2247 configure_history_integrity(Some(test_ring(0, 43)));
2248 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2249 configure_anchor_store(Some(Arc::clone(&store)));
2250
2251 let dir = tempfile::tempdir().unwrap();
2252 let log = SessionEventLog::open(dir.path()).await.unwrap();
2253 log.append(
2254 None,
2255 None,
2256 SessionEvent::UserMessage {
2257 text: "one".to_owned(),
2258 image_refs: vec![],
2259 },
2260 )
2261 .await
2262 .unwrap();
2263 log.finalize().await.unwrap();
2264
2265 log.append(
2268 None,
2269 None,
2270 SessionEvent::SessionEnded { reason: "x".into() },
2271 )
2272 .await
2273 .unwrap();
2274 drop(log);
2275
2276 let log = SessionEventLog::open(dir.path()).await.unwrap();
2277 let events = log.read_all().await.unwrap();
2278 assert_eq!(
2279 events.len(),
2280 2,
2281 "post-anchor growth with a matching prefix must open OK"
2282 );
2283
2284 configure_anchor_store(None);
2285 configure_history_integrity(None);
2286 }
2287
2288 #[tokio::test]
2289 async fn finalize_is_noop_without_anchor_store_or_without_chaining() {
2290 configure_history_integrity(Some(test_ring(0, 44)));
2291 let dir = tempfile::tempdir().unwrap();
2292 let log = SessionEventLog::open(dir.path()).await.unwrap();
2293 log.append(
2294 None,
2295 None,
2296 SessionEvent::SessionEnded { reason: "x".into() },
2297 )
2298 .await
2299 .unwrap();
2300 log.finalize().await.unwrap();
2301 configure_history_integrity(None);
2302
2303 let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
2304 configure_anchor_store(Some(Arc::clone(&store)));
2305 let dir2 = tempfile::tempdir().unwrap();
2306 let log2 = SessionEventLog::open(dir2.path()).await.unwrap();
2307 log2.append(
2308 None,
2309 None,
2310 SessionEvent::SessionEnded {
2311 reason: "legacy".into(),
2312 },
2313 )
2314 .await
2315 .unwrap();
2316 log2.finalize().await.unwrap();
2317 let identity = file_identity(dir2.path());
2318 assert!(
2319 store
2320 .get_sync(AnchorSubsystem::SessionLog, &identity)
2321 .unwrap()
2322 .is_none(),
2323 "no anchor should be written for an unchained handle"
2324 );
2325
2326 configure_anchor_store(None);
2327 }
2328}