use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock as StdRwLock};
use tokio::fs::{self, File, OpenOptions};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex;
use zeph_common::anchor::{Anchor, AnchorStore, AnchorSubsystem};
use zeph_common::hash_chain::{
ChainError, ChainHash, ChainKeyRing, ChainStreamVerifier, KeyResolution, chain_next, genesis,
};
use crate::error::SessionError;
use crate::event::{SessionEvent, SessionEventEnvelope};
const EVENTS_FILE_NAME: &str = "events.jsonl";
#[cfg(unix)]
const LOCK_FILE_NAME: &str = "events.jsonl.lock";
pub const CHAIN_DOMAIN: &str = "zeph-session log v1";
static HISTORY_INTEGRITY: StdRwLock<Option<Arc<ChainKeyRing>>> = StdRwLock::new(None);
pub fn configure_history_integrity(ring: Option<Arc<ChainKeyRing>>) {
if let Ok(mut guard) = HISTORY_INTEGRITY.write() {
*guard = ring;
}
}
fn history_integrity() -> Option<Arc<ChainKeyRing>> {
HISTORY_INTEGRITY.read().ok().and_then(|g| g.clone())
}
static ANCHOR_STORE: StdRwLock<Option<Arc<dyn AnchorStore>>> = StdRwLock::new(None);
pub fn configure_anchor_store(store: Option<Arc<dyn AnchorStore>>) {
if let Ok(mut guard) = ANCHOR_STORE.write() {
*guard = store;
}
}
fn anchor_store() -> Option<Arc<dyn AnchorStore>> {
ANCHOR_STORE.read().ok().and_then(|g| g.clone())
}
const REPLAY_CHUNK_SIZE: usize = 100;
const ANCHOR_GET_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
struct SessionWriteState {
file: File,
prev: Option<ChainHash>,
count: u64,
}
pub struct SessionEventLog {
events_path: PathBuf,
writer: Mutex<SessionWriteState>,
next_seq: AtomicU64,
file_identity: Vec<u8>,
ring: Option<Arc<ChainKeyRing>>,
allow_unverified: bool,
anchor: Option<Anchor>,
#[allow(dead_code)] lock: Option<AdvisoryLock>,
}
fn file_identity(session_dir: &Path) -> Vec<u8> {
session_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
.into_bytes()
}
impl SessionEventLog {
pub async fn open(session_dir: &Path) -> Result<Self, SessionError> {
Self::open_with_lock(session_dir, None, false).await
}
pub async fn open_allow_unverified(session_dir: &Path) -> Result<Self, SessionError> {
Self::open_with_lock(session_dir, None, true).await
}
pub async fn open_exclusive(session_dir: &Path) -> Result<Self, SessionError> {
fs::create_dir_all(session_dir).await?;
let lock = AdvisoryLock::acquire(session_dir)?;
Self::open_with_lock(session_dir, Some(lock), false).await
}
pub async fn open_exclusive_allow_unverified(session_dir: &Path) -> Result<Self, SessionError> {
fs::create_dir_all(session_dir).await?;
let lock = AdvisoryLock::acquire(session_dir)?;
Self::open_with_lock(session_dir, Some(lock), true).await
}
async fn open_with_lock(
session_dir: &Path,
lock: Option<AdvisoryLock>,
allow_unverified: bool,
) -> Result<Self, SessionError> {
fs::create_dir_all(session_dir).await?;
set_permissions(session_dir, 0o700).await?;
let events_path = session_dir.join(EVENTS_FILE_NAME);
let ring = history_integrity();
let identity = file_identity(session_dir);
let anchor = match anchor_store() {
Some(store) => tokio::time::timeout(
ANCHOR_GET_TIMEOUT,
store.get(AnchorSubsystem::SessionLog, &identity),
)
.await
.map_err(|_| {
SessionError::Integrity(format!(
"vault anchor lookup for session '{}' timed out after {:?} — failing \
closed rather than opening unverified",
session_dir.display(),
ANCHOR_GET_TIMEOUT
))
})?
.map_err(|e| SessionError::Integrity(format!("anchor lookup failed: {e}")))?,
None => None,
};
let (_, max_seq, chain_head) = read_events(
&events_path,
lock.is_some(),
ring.as_deref(),
&identity,
allow_unverified,
anchor.as_ref(),
)
.await?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&events_path)
.await?;
set_permissions(&events_path, 0o600).await?;
let next_seq = max_seq.map_or(0, |seq| seq + 1);
let count = max_seq.map_or(0, |seq| seq + 1);
Ok(Self {
events_path,
writer: Mutex::new(SessionWriteState {
file,
prev: chain_head,
count,
}),
next_seq: AtomicU64::new(next_seq),
file_identity: identity,
ring,
allow_unverified,
anchor,
lock,
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.events_path
}
#[must_use]
pub fn last_seq(&self) -> Option<u64> {
let next = self.next_seq.load(Ordering::SeqCst);
next.checked_sub(1)
}
#[tracing::instrument(name = "session.log.append", skip_all, level = "debug")]
pub async fn append(
&self,
turn_id: Option<u64>,
parent_seq: Option<u64>,
kind: SessionEvent,
) -> Result<SessionEventEnvelope, SessionError> {
let mut state = self.writer.lock().await;
let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
let mut envelope = SessionEventEnvelope::new(seq, turn_id, parent_seq, kind);
let new_head = if let Some(ring) = self.ring.as_deref() {
let content = serde_json::to_vec(&envelope)?;
let base = state.prev.unwrap_or_else(|| {
genesis(
&ring.current_key(),
CHAIN_DOMAIN,
&self.file_identity,
ring.current_epoch(),
)
});
let h = chain_next(&ring.current_key(), &base, &content);
envelope.chain = Some(h.to_hex());
Some(h)
} else {
None
};
let mut line = serde_json::to_vec(&envelope)?;
line.push(b'\n');
state.file.write_all(&line).await?;
state.file.sync_all().await?;
if let Some(h) = new_head {
state.prev = Some(h);
}
state.count += 1;
Ok(envelope)
}
pub async fn finalize(&self) -> Result<(), SessionError> {
let Some(store) = anchor_store() else {
return Ok(());
};
let (head, count) = {
let state = self.writer.lock().await;
let Some(head) = state.prev else {
return Ok(());
};
(head, state.count)
};
let epoch = self.ring.as_ref().map_or(0, |r| r.current_epoch());
let anchor = Anchor::new(epoch, count, head);
store
.put(AnchorSubsystem::SessionLog, &self.file_identity, anchor)
.await
.map_err(|e| SessionError::Integrity(format!("anchor put failed: {e}")))
}
#[tracing::instrument(name = "session.log.read_all", skip_all, level = "debug")]
pub async fn read_all(&self) -> Result<Vec<SessionEventEnvelope>, SessionError> {
let (events, _, _) = read_events(
&self.events_path,
self.lock.is_some(),
self.ring.as_deref(),
&self.file_identity,
self.allow_unverified,
self.anchor.as_ref(),
)
.await?;
Ok(events)
}
#[tracing::instrument(name = "session.log.read_chunked", skip_all, level = "debug")]
pub(crate) async fn read_chunked(
&self,
on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
) -> Result<(), SessionError> {
read_events_chunked(
&self.events_path,
self.lock.is_some(),
self.ring.as_deref(),
&self.file_identity,
self.allow_unverified,
self.anchor.as_ref(),
on_chunk,
)
.await
}
}
struct SessionChainTracker<'a> {
path: &'a Path,
ring: Option<&'a ChainKeyRing>,
file_identity: &'a [u8],
verifier: Option<ChainStreamVerifier>,
chain_started: bool,
allow_unverified: bool,
anchor: Option<&'a Anchor>,
physical_index: u64,
anchor_checkpoint_head: Option<ChainHash>,
}
impl<'a> SessionChainTracker<'a> {
fn new(
path: &'a Path,
ring: Option<&'a ChainKeyRing>,
file_identity: &'a [u8],
allow_unverified: bool,
anchor: Option<&'a Anchor>,
) -> Self {
Self {
path,
ring,
file_identity,
verifier: None,
chain_started: false,
allow_unverified,
anchor,
physical_index: 0,
anchor_checkpoint_head: None,
}
}
fn feed(&mut self, event: &SessionEventEnvelope) -> Result<(), SessionError> {
if self.allow_unverified {
return Ok(());
}
let Some(hex) = event.chain.as_deref() else {
return if self.chain_started {
Err(SessionError::Integrity(format!(
"session log '{}' has an event missing its chain field while earlier \
events in this log are chained — partial strip detected, TAMPER DETECTED",
self.path.display()
)))
} else {
self.physical_index += 1;
Ok(())
};
};
self.chain_started = true;
let stored = ChainHash::from_hex(hex).map_err(|_| {
SessionError::Integrity(format!(
"session log '{}' has a malformed chain hash",
self.path.display()
))
})?;
if self.verifier.is_none() {
let ring = self.ring.ok_or_else(|| {
SessionError::Integrity(format!(
"session log '{}' carries chain metadata but no history-integrity key is \
configured for this process — refusing to trust it unverified (NFR-004)",
self.path.display()
))
})?;
self.verifier = Some(ChainStreamVerifier::new(
ring,
CHAIN_DOMAIN,
self.file_identity.to_vec(),
));
}
let mut stripped = event.clone();
stripped.chain = None;
let content = serde_json::to_vec(&stripped)?;
self.verifier
.as_mut()
.expect("verifier initialized above")
.verify_next(&content, &stored)
.map_err(|e| describe_chain_error(self.path, &e))?;
self.physical_index += 1;
if let Some(anchor) = self.anchor
&& self.physical_index == anchor.count
{
self.anchor_checkpoint_head =
self.verifier.as_ref().and_then(ChainStreamVerifier::head);
}
Ok(())
}
fn finish(self) -> Result<Option<ChainHash>, SessionError> {
if self.allow_unverified {
return Ok(None);
}
if let Some(KeyResolution::Rekeyed(epoch)) = self
.verifier
.as_ref()
.and_then(ChainStreamVerifier::resolution)
{
tracing::info!(
path = %self.path.display(),
epoch,
"session log verified under a previous key epoch (re-keyed, not tampered)"
);
}
if !self.chain_started && self.ring.is_some() {
warn_legacy_under_active_key_once(self.path);
}
if let Some(anchor) = self.anchor {
if !self.chain_started {
tracing::error!(
audit_event = "history_integrity_tamper",
subsystem = "session_log",
reason = "whole_strip_legacy_with_anchor",
path = %self.path.display(),
anchored_count = anchor.count,
"TAMPER DETECTED: session log is legacy-looking but a vault anchor exists for \
it (issue #6449)"
);
return Err(SessionError::Integrity(format!(
"TAMPER DETECTED in session log '{}': log has no chain metadata \
(legacy-looking) but a vault anchor exists for it (anchored at count={}) — \
this log was previously chained and its chain fields have been stripped",
self.path.display(),
anchor.count
)));
}
if self.physical_index < anchor.count {
tracing::error!(
audit_event = "history_integrity_tamper",
subsystem = "session_log",
reason = "truncated_below_anchor_count",
path = %self.path.display(),
on_disk_count = self.physical_index,
anchored_count = anchor.count,
"TAMPER DETECTED: session log truncated below its anchored count (issue #6449)"
);
return Err(SessionError::Integrity(format!(
"TAMPER DETECTED in session log '{}': on-disk event count ({}) is below the \
anchored count ({}) — the log was truncated after being anchored",
self.path.display(),
self.physical_index,
anchor.count
)));
}
let anchor_head = anchor.head().map_err(|e| {
SessionError::Integrity(format!(
"session log '{}' anchor is malformed: {e}",
self.path.display()
))
})?;
match self.anchor_checkpoint_head {
Some(h) if h == anchor_head => {}
_ => {
tracing::error!(
audit_event = "history_integrity_tamper",
subsystem = "session_log",
reason = "anchor_head_mismatch",
path = %self.path.display(),
anchored_count = anchor.count,
"TAMPER DETECTED: session log chain head at the anchored count does not \
match the stored vault anchor (issue #6449)"
);
return Err(SessionError::Integrity(format!(
"TAMPER DETECTED in session log '{}': chain head at the anchored count \
({}) does not match the stored vault anchor",
self.path.display(),
anchor.count
)));
}
}
}
Ok(self.verifier.and_then(|v| v.head()))
}
}
static WARNED_LEGACY_UNDER_KEY: std::sync::LazyLock<StdRwLock<std::collections::HashSet<PathBuf>>> =
std::sync::LazyLock::new(|| StdRwLock::new(std::collections::HashSet::new()));
fn warn_legacy_under_active_key_once(path: &Path) {
let already_warned = WARNED_LEGACY_UNDER_KEY
.read()
.is_ok_and(|set| set.contains(path));
if already_warned {
return;
}
if let Ok(mut set) = WARNED_LEGACY_UNDER_KEY.write()
&& !set.insert(path.to_path_buf())
{
return; }
tracing::warn!(
path = %path.display(),
"history-chain integrity: session log classifies as legacy (no chain field anywhere) \
while a history-integrity key IS configured for this process — this is expected for \
genuine pre-upgrade content, but is also the signature of a full chain-strip downgrade \
attack (issue #6449, the vault-anchor gap); accepted per FR-006, flagged for operator \
visibility"
);
}
fn describe_chain_error(path: &Path, err: &ChainError) -> SessionError {
match err {
ChainError::Unverifiable => SessionError::Integrity(format!(
"session log '{}' is unverifiable: no known key epoch (current or previous \
rotation window) produces a valid chain — possibly re-keyed past the rotation \
window, or tampered; this is fail-closed by design (NFR-004) and cannot be \
auto-recovered",
path.display()
)),
ChainError::Mismatch { index } => SessionError::Integrity(format!(
"TAMPER DETECTED in session log '{}': chain hash mismatch at chained-entry index \
{index} — content was modified, reordered, or deleted after being written",
path.display()
)),
other => SessionError::Integrity(format!(
"session log '{}' failed chain verification: {other}",
path.display()
)),
}
}
enum LineOutcome {
Eof,
Blank,
Event(Box<SessionEventEnvelope>),
Torn,
}
struct EventLineReader {
reader: BufReader<File>,
line: String,
offset: u64,
valid_len: u64,
}
impl EventLineReader {
async fn open(path: &Path) -> Result<Option<Self>, SessionError> {
let file = match File::open(path).await {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
Ok(Some(Self {
reader: BufReader::new(file),
line: String::new(),
offset: 0,
valid_len: 0,
}))
}
async fn next_line(&mut self) -> Result<LineOutcome, SessionError> {
self.line.clear();
let bytes_read = self.reader.read_line(&mut self.line).await? as u64;
if bytes_read == 0 {
return Ok(LineOutcome::Eof);
}
let is_terminated = self.line.ends_with('\n');
let trimmed = self.line.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
self.offset += bytes_read;
if is_terminated {
self.valid_len = self.offset;
}
return Ok(LineOutcome::Blank);
}
match serde_json::from_str::<SessionEventEnvelope>(trimmed) {
Ok(envelope) if is_terminated => {
self.offset += bytes_read;
self.valid_len = self.offset;
Ok(LineOutcome::Event(Box::new(envelope)))
}
_ => Ok(LineOutcome::Torn),
}
}
}
async fn repair_torn_tail(path: &Path, valid_len: u64) -> Result<(), SessionError> {
let actual_len = fs::metadata(path).await?.len();
if valid_len < actual_len {
let file = OpenOptions::new().write(true).open(path).await?;
file.set_len(valid_len).await?;
}
Ok(())
}
async fn finish_torn_tail(
path: &Path,
valid_len: u64,
repair: bool,
torn: bool,
) -> Result<(), SessionError> {
if torn {
tracing::warn!(
path = %path.display(),
valid_len,
repair,
"dropped torn tail in session event log (INV-SP-2)"
);
}
if repair {
repair_torn_tail(path, valid_len).await?;
}
Ok(())
}
async fn read_events(
path: &Path,
repair: bool,
ring: Option<&ChainKeyRing>,
file_identity: &[u8],
allow_unverified: bool,
anchor: Option<&Anchor>,
) -> Result<(Vec<SessionEventEnvelope>, Option<u64>, Option<ChainHash>), SessionError> {
let Some(mut lines) = EventLineReader::open(path).await? else {
return Ok((Vec::new(), None, None));
};
let mut events = Vec::new();
let mut max_seq = None;
let mut torn = false;
let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor);
loop {
match lines.next_line().await? {
LineOutcome::Eof => break,
LineOutcome::Blank => {}
LineOutcome::Event(envelope) => {
chain.feed(&envelope)?;
max_seq = Some(max_seq.map_or(envelope.seq, |m: u64| m.max(envelope.seq)));
events.push(*envelope);
}
LineOutcome::Torn => {
torn = peek_confirms_trailing_torn(&mut lines, path).await?;
break;
}
}
}
let valid_len = lines.valid_len;
drop(lines);
let chain_head = chain.finish()?;
finish_torn_tail(path, valid_len, repair, torn).await?;
Ok((events, max_seq, chain_head))
}
async fn read_events_chunked(
path: &Path,
repair: bool,
ring: Option<&ChainKeyRing>,
file_identity: &[u8],
allow_unverified: bool,
anchor: Option<&Anchor>,
mut on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
) -> Result<(), SessionError> {
let Some(mut lines) = EventLineReader::open(path).await? else {
return Ok(());
};
let mut chunk = Vec::with_capacity(REPLAY_CHUNK_SIZE);
let mut torn = false;
let mut broke_early = false;
let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor);
loop {
match lines.next_line().await? {
LineOutcome::Eof => break,
LineOutcome::Blank => {}
LineOutcome::Event(envelope) => {
chain.feed(&envelope)?;
chunk.push(*envelope);
if chunk.len() >= REPLAY_CHUNK_SIZE {
let flushed =
std::mem::replace(&mut chunk, Vec::with_capacity(REPLAY_CHUNK_SIZE));
if on_chunk(flushed).is_break() {
broke_early = true;
break;
}
}
}
LineOutcome::Torn => {
torn = peek_confirms_trailing_torn(&mut lines, path).await?;
break;
}
}
}
if !broke_early && !chunk.is_empty() && on_chunk(chunk).is_break() {
broke_early = true;
}
if broke_early {
return Ok(());
}
let valid_len = lines.valid_len;
drop(lines);
let _chain_head = chain.finish()?;
finish_torn_tail(path, valid_len, repair, torn).await?;
Ok(())
}
async fn peek_confirms_trailing_torn(
lines: &mut EventLineReader,
path: &Path,
) -> Result<bool, SessionError> {
match lines.next_line().await? {
LineOutcome::Eof => Ok(true),
_ => Err(SessionError::Integrity(format!(
"internal malformed line in '{}' is not the file's physical last line — refusing \
to treat it as a torn crash-recovery tail (TAMPER DETECTED or mid-file corruption)",
path.display()
))),
}
}
#[cfg(unix)]
pub(crate) async fn set_permissions(path: &Path, mode: u32) -> Result<(), SessionError> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).await?;
Ok(())
}
#[cfg(unix)]
struct AdvisoryLock(#[allow(dead_code)] rustix::fd::OwnedFd);
#[cfg(unix)]
impl AdvisoryLock {
fn acquire(session_dir: &Path) -> Result<Self, SessionError> {
use rustix::fs::{FlockOperation, Mode, OFlags};
let lock_path = session_dir.join(LOCK_FILE_NAME);
let fd = rustix::fs::open(
&lock_path,
OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)
.map_err(std::io::Error::from)?;
rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
if e == rustix::io::Errno::WOULDBLOCK {
let pid = zeph_common::pidfile::read_pid_lenient(&lock_path);
let pid_alive = pid.map(zeph_common::pidfile::is_process_alive);
SessionError::AlreadyLocked {
path: lock_path.display().to_string(),
pid,
pid_alive,
}
} else {
SessionError::Io(e.into())
}
})?;
rustix::fs::ftruncate(&fd, 0).map_err(std::io::Error::from)?;
rustix::io::write(&fd, std::process::id().to_string().as_bytes())
.map_err(std::io::Error::from)?;
Ok(Self(fd))
}
}
#[cfg(not(unix))]
struct AdvisoryLock;
#[cfg(not(unix))]
impl AdvisoryLock {
fn acquire(_session_dir: &Path) -> Result<Self, SessionError> {
Ok(Self)
}
}
#[cfg(not(unix))]
pub(crate) async fn set_permissions(_path: &Path, _mode: u32) -> Result<(), SessionError> {
Ok(())
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::pin::Pin;
use super::*;
#[tokio::test]
async fn test_append_and_read_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..5u64 {
log.append(
Some(i),
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
assert_eq!(log.last_seq(), Some(4));
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 5);
for (i, envelope) in events.iter().enumerate() {
assert_eq!(envelope.seq, i as u64);
}
}
#[tokio::test]
async fn test_reopen_resumes_seq() {
let dir = tempfile::tempdir().unwrap();
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
}
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), Some(0));
let appended = log
.append(
None,
None,
SessionEvent::SessionEnded { reason: "y".into() },
)
.await
.unwrap();
assert_eq!(appended.seq, 1);
}
#[tokio::test]
async fn test_torn_write_truncation() {
let dir = tempfile::tempdir().unwrap();
let path;
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..3u64 {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
path = log.path().to_path_buf();
}
let full = tokio::fs::read(&path).await.unwrap();
let cut = full.len() - 5;
tokio::fs::write(&path, &full[..cut]).await.unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(
log.last_seq(),
Some(1),
"torn last line must be dropped cleanly"
);
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 2);
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_does_not_physically_truncate_torn_tail() {
let dir = tempfile::tempdir().unwrap();
let path;
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..3u64 {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
path = log.path().to_path_buf();
}
let full = tokio::fs::read(&path).await.unwrap();
let cut = full.len() - 5;
tokio::fs::write(&path, &full[..cut]).await.unwrap();
let torn_len = tokio::fs::metadata(&path).await.unwrap().len();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), Some(1));
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(
tokio::fs::metadata(&path).await.unwrap().len(),
torn_len,
"open()/read_all() must never physically truncate the file"
);
drop(log);
let log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), Some(1));
let repaired_len = tokio::fs::metadata(&path).await.unwrap().len();
assert!(
repaired_len < torn_len,
"open_exclusive() must physically truncate the torn tail"
);
}
#[tokio::test]
async fn test_torn_write_truncation_various_offsets() {
for cut_from_end in [1usize, 3, 10, 20] {
let dir = tempfile::tempdir().unwrap();
let path;
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..4u64 {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("event-number-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
path = log.path().to_path_buf();
}
let full = tokio::fs::read(&path).await.unwrap();
let cut = full.len().saturating_sub(cut_from_end);
tokio::fs::write(&path, &full[..cut]).await.unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert!(events.len() <= 4);
}
}
#[tokio::test]
async fn test_empty_log_read_all() {
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), None);
assert!(log.read_all().await.unwrap().is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn test_file_permissions_are_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
let meta = tokio::fs::metadata(log.path()).await.unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
#[tokio::test]
async fn test_max_seq_survives_out_of_order_physical_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(EVENTS_FILE_NAME);
let make_line = |seq: u64| {
let envelope = SessionEventEnvelope::new(
seq,
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
);
let mut line = serde_json::to_vec(&envelope).unwrap();
line.push(b'\n');
line
};
let mut contents = make_line(7);
contents.extend(make_line(6));
tokio::fs::write(&path, &contents).await.unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(
log.last_seq(),
Some(7),
"next_seq must be derived from the true max seq, not the last physical line"
);
let appended = log
.append(
None,
None,
SessionEvent::SessionEnded { reason: "z".into() },
)
.await
.unwrap();
assert_eq!(
appended.seq, 8,
"must not reuse a seq already present earlier in the file"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_exclusive_writes_own_pid_into_lock_file() {
let dir = tempfile::tempdir().unwrap();
let _log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
let lock_path = dir.path().join(LOCK_FILE_NAME);
let contents = tokio::fs::read_to_string(&lock_path).await.unwrap();
let pid: u32 = contents.trim().parse().unwrap_or_else(|e| {
panic!("lock file contents {contents:?} did not parse as a PID: {e}")
});
assert_eq!(pid, std::process::id());
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_exclusive_rejects_second_writer() {
let dir = tempfile::tempdir().unwrap();
let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
match SessionEventLog::open_exclusive(dir.path()).await {
Err(SessionError::AlreadyLocked { pid, pid_alive, .. }) => {
assert_eq!(pid, Some(std::process::id()));
assert_eq!(pid_alive, Some(true));
}
Err(e) => panic!("expected AlreadyLocked, got different error: {e}"),
Ok(_) => panic!("expected AlreadyLocked, but second open_exclusive succeeded"),
}
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_exclusive_allows_reacquire_after_drop() {
let dir = tempfile::tempdir().unwrap();
{
let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
}
let _second = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_is_not_blocked_by_open_exclusive() {
let dir = tempfile::tempdir().unwrap();
let _writer = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
let _reader = SessionEventLog::open(dir.path()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn test_concurrent_append_preserves_seq_order() {
const N: u64 = 100;
let dir = tempfile::tempdir().unwrap();
let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
let mut tasks = tokio::task::JoinSet::new();
for i in 0..N {
let log = log.clone();
tasks.spawn(async move {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap()
.seq
});
}
let mut assigned_seqs: Vec<u64> = tasks.join_all().await;
assigned_seqs.sort_unstable();
assert_eq!(
assigned_seqs,
(0..N).collect::<Vec<_>>(),
"every seq in 0..{N} must be assigned exactly once, with no gaps or duplicates"
);
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), usize::try_from(N).unwrap());
for (i, envelope) in events.iter().enumerate() {
assert_eq!(
envelope.seq, i as u64,
"physical line {i} must carry seq {i}; seq and write order diverged"
);
}
}
#[tokio::test]
async fn test_read_chunked_bounds_memory_and_matches_whole_file_read() {
const N: u64 = 733;
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..N {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
let (whole_file_events, _, _) =
read_events(log.path(), false, None, b"test-session", false, None)
.await
.unwrap();
assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap());
let mut chunked_events = Vec::new();
let mut chunk_sizes = Vec::new();
read_events_chunked(log.path(), false, None, b"test-session", false, None, |chunk| {
assert!(
chunk.len() <= REPLAY_CHUNK_SIZE,
"a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}",
chunk.len()
);
chunk_sizes.push(chunk.len());
chunked_events.extend(chunk);
ControlFlow::Continue(())
})
.await
.unwrap();
assert_eq!(
chunked_events.len(),
whole_file_events.len(),
"chunked read must yield the same total event count as the whole-file read"
);
for (whole, chunked) in whole_file_events.iter().zip(chunked_events.iter()) {
assert_eq!(whole.seq, chunked.seq);
}
assert!(
chunk_sizes.len() > 1,
"expected multiple chunks for N={N} events with REPLAY_CHUNK_SIZE={REPLAY_CHUNK_SIZE}"
);
}
fn test_ring(epoch: u32, byte: u8) -> Arc<ChainKeyRing> {
Arc::new(ChainKeyRing::new(
epoch,
zeph_common::hash_chain::ChainKey::new([byte; 32]),
))
}
#[tokio::test]
async fn chained_log_roundtrip() {
configure_history_integrity(Some(test_ring(0, 20)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "hello".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
drop(log);
let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
.await
.unwrap();
assert!(
raw.lines().all(|l| l.contains("\"chain\":")),
"every line must carry a chain field once integrity is configured"
);
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 2);
configure_history_integrity(None);
}
#[tokio::test]
async fn tamper_in_place_edit_is_detected() {
configure_history_integrity(Some(test_ring(0, 21)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded {
reason: "untouched".into(),
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "original".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
drop(log);
let path = dir.path().join(EVENTS_FILE_NAME);
let raw = tokio::fs::read_to_string(&path).await.unwrap();
let tampered = raw.replace("original", "forged-approval");
assert_ne!(raw, tampered);
tokio::fs::write(&path, tampered).await.unwrap();
let result = SessionEventLog::open(dir.path()).await;
assert!(matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("TAMPER")));
configure_history_integrity(None);
}
#[tokio::test]
async fn legacy_log_is_auto_trusted_once_when_integrity_configured_later() {
configure_history_integrity(None);
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "pre-feature message".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
drop(log);
let raw = tokio::fs::read_to_string(dir.path().join(EVENTS_FILE_NAME))
.await
.unwrap();
assert!(!raw.contains("\"chain\":"));
configure_history_integrity(Some(test_ring(0, 22)));
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert_eq!(
events.len(),
1,
"legacy content must be auto-trusted, not rejected"
);
let events_path = dir.path().join(EVENTS_FILE_NAME);
assert!(
WARNED_LEGACY_UNDER_KEY
.read()
.unwrap()
.contains(&events_path),
"path must be recorded as warned after the first legacy-under-active-key read"
);
let warned_count_before = WARNED_LEGACY_UNDER_KEY.read().unwrap().len();
let _ = log.read_all().await.unwrap();
assert_eq!(
WARNED_LEGACY_UNDER_KEY.read().unwrap().len(),
warned_count_before,
"a second read of the same path must not add a second warned-set entry"
);
configure_history_integrity(None);
}
#[tokio::test]
async fn partial_strip_of_chain_field_is_detected_as_tamper() {
configure_history_integrity(Some(test_ring(0, 23)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "one".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "two".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
drop(log);
let path = dir.path().join(EVENTS_FILE_NAME);
let raw = tokio::fs::read_to_string(&path).await.unwrap();
let lines: Vec<&str> = raw.lines().collect();
assert_eq!(lines.len(), 2);
let mut second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
second.as_object_mut().unwrap().remove("chain");
let stripped = format!("{}\n{}\n", lines[0], second);
tokio::fs::write(&path, stripped).await.unwrap();
let result = SessionEventLog::open(dir.path()).await;
assert!(
matches!(result, Err(SessionError::Integrity(ref m)) if m.contains("partial strip"))
);
configure_history_integrity(None);
}
#[tokio::test]
async fn key_unavailable_on_chained_log_fails_closed_not_legacy() {
configure_history_integrity(Some(test_ring(0, 24)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
drop(log);
configure_history_integrity(None);
let result = SessionEventLog::open(dir.path()).await;
assert!(matches!(result, Err(SessionError::Integrity(_))));
}
#[tokio::test]
async fn allow_unverified_bypasses_tamper_detection_for_the_whole_handle() {
configure_history_integrity(Some(test_ring(0, 40)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded {
reason: "untouched".into(),
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "original".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
drop(log);
let path = dir.path().join(EVENTS_FILE_NAME);
let raw = tokio::fs::read_to_string(&path).await.unwrap();
let tampered = raw.replace("original", "forged-approval");
assert_ne!(raw, tampered);
tokio::fs::write(&path, tampered).await.unwrap();
let result = SessionEventLog::open_exclusive(dir.path()).await;
assert!(matches!(result, Err(SessionError::Integrity(_))));
let log = SessionEventLog::open_exclusive_allow_unverified(dir.path())
.await
.unwrap();
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 2);
configure_history_integrity(None);
}
#[tokio::test]
async fn rotated_key_epoch_verifies_as_rekeyed_not_tampered() {
let old_key_byte = 25u8;
configure_history_integrity(Some(test_ring(0, old_key_byte)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
drop(log);
let ring = Arc::new(
ChainKeyRing::new(1, zeph_common::hash_chain::ChainKey::new([30u8; 32])).with_previous(
0,
zeph_common::hash_chain::ChainKey::new([old_key_byte; 32]),
),
);
configure_history_integrity(Some(ring));
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 1);
configure_history_integrity(None);
}
#[tokio::test]
async fn internal_malformed_line_is_never_treated_as_torn_tail() {
configure_history_integrity(None);
let dir = tempfile::tempdir().unwrap();
let path;
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..3u64 {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
path = log.path().to_path_buf();
}
let content = tokio::fs::read_to_string(&path).await.unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3);
let corrupted = format!("{}\nnot valid json at all\n{}\n", lines[0], lines[2]);
tokio::fs::write(&path, corrupted).await.unwrap();
let result = SessionEventLog::open_exclusive(dir.path()).await;
assert!(matches!(result, Err(SessionError::Integrity(_))));
let after = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(
after.lines().count(),
3,
"file must not have been truncated"
);
configure_history_integrity(None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_append_preserves_chain_order() {
const N: u64 = 60;
configure_history_integrity(Some(test_ring(0, 26)));
let dir = tempfile::tempdir().unwrap();
let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
let mut tasks = tokio::task::JoinSet::new();
for i in 0..N {
let log = log.clone();
tasks.spawn(async move {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
});
}
while tasks.join_next().await.is_some() {}
drop(log);
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), usize::try_from(N).unwrap());
configure_history_integrity(None);
}
#[tokio::test]
async fn chunked_read_verifies_chain_and_matches_whole_file_read() {
const N: u64 = 250; configure_history_integrity(Some(test_ring(0, 27)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..N {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
let whole = log.read_all().await.unwrap();
assert_eq!(whole.len(), usize::try_from(N).unwrap());
let mut chunked = Vec::new();
log.read_chunked(|chunk| {
chunked.extend(chunk);
ControlFlow::Continue(())
})
.await
.unwrap();
assert_eq!(chunked.len(), whole.len());
configure_history_integrity(None);
}
#[tokio::test]
async fn chunked_read_detects_tamper_in_a_later_chunk() {
const N: u64 = 150;
configure_history_integrity(Some(test_ring(0, 28)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..N {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
let path = log.path().to_path_buf();
drop(log);
let raw = tokio::fs::read_to_string(&path).await.unwrap();
let tampered = raw.replacen("msg-120", "forged-120", 1);
assert_ne!(raw, tampered);
tokio::fs::write(&path, tampered).await.unwrap();
configure_history_integrity(Some(test_ring(0, 28)));
let log = SessionEventLog::open(dir.path()).await;
match log {
Err(SessionError::Integrity(_)) => {}
Ok(log) => {
let mut seen = Vec::new();
let result = log
.read_chunked(|chunk| {
seen.extend(chunk);
ControlFlow::Continue(())
})
.await;
assert!(matches!(result, Err(SessionError::Integrity(_))));
}
Err(other) => panic!("expected Integrity error, got {other:?}"),
}
configure_history_integrity(None);
}
#[derive(Default)]
struct MockAnchorStore {
map: std::sync::Mutex<std::collections::HashMap<String, Anchor>>,
}
impl AnchorStore for MockAnchorStore {
fn get(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Pin<
Box<
dyn Future<Output = Result<Option<Anchor>, zeph_common::anchor::AnchorError>>
+ Send
+ '_,
>,
> {
let result = self.get_sync(subsystem, file_id);
Box::pin(async move { result })
}
fn get_sync(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Result<Option<Anchor>, zeph_common::anchor::AnchorError> {
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
Ok(self.map.lock().unwrap().get(&key).cloned())
}
fn put(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
anchor: Anchor,
) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
{
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
self.map.lock().unwrap().insert(key, anchor);
Box::pin(async { Ok(()) })
}
fn delete(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Pin<Box<dyn Future<Output = Result<(), zeph_common::anchor::AnchorError>> + Send + '_>>
{
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
self.map.lock().unwrap().remove(&key);
Box::pin(async { Ok(()) })
}
}
#[tokio::test]
async fn pre_anchor_chained_log_still_opens_with_anchor_store_online() {
configure_history_integrity(Some(test_ring(0, 40)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "pre-anchor".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
drop(log);
configure_anchor_store(Some(Arc::new(MockAnchorStore::default())));
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert_eq!(
events.len(),
1,
"absent anchor must never brick a legacy-chained log"
);
configure_anchor_store(None);
configure_history_integrity(None);
}
#[tokio::test]
async fn whole_strip_of_anchored_session_is_tamper() {
configure_history_integrity(Some(test_ring(0, 41)));
let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
configure_anchor_store(Some(Arc::clone(&store)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "one".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
log.finalize().await.unwrap();
drop(log);
assert!(SessionEventLog::open(dir.path()).await.is_ok());
let path = dir.path().join(EVENTS_FILE_NAME);
let raw = tokio::fs::read_to_string(&path).await.unwrap();
let stripped: String = raw
.lines()
.map(|line| {
let mut value: serde_json::Value = serde_json::from_str(line).unwrap();
value.as_object_mut().unwrap().remove("chain");
value.to_string()
})
.collect::<Vec<_>>()
.join("\n")
+ "\n";
tokio::fs::write(&path, stripped).await.unwrap();
match SessionEventLog::open(dir.path()).await {
Err(SessionError::Integrity(m)) => {
assert!(m.contains("TAMPER") && m.contains("vault anchor"), "{m}");
}
other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
}
configure_anchor_store(None);
configure_history_integrity(None);
}
#[tokio::test]
async fn truncation_below_anchored_session_count_is_tamper() {
configure_history_integrity(Some(test_ring(0, 42)));
let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
configure_anchor_store(Some(Arc::clone(&store)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "one".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
log.finalize().await.unwrap();
drop(log);
let path = dir.path().join(EVENTS_FILE_NAME);
let raw = tokio::fs::read_to_string(&path).await.unwrap();
let first_line = raw.lines().next().unwrap();
tokio::fs::write(&path, format!("{first_line}\n"))
.await
.unwrap();
match SessionEventLog::open(dir.path()).await {
Err(SessionError::Integrity(m)) => {
assert!(m.contains("TAMPER") && m.contains("truncated"), "{m}");
}
other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()),
}
configure_anchor_store(None);
configure_history_integrity(None);
}
#[tokio::test]
async fn growth_after_anchor_with_matching_prefix_is_ok() {
configure_history_integrity(Some(test_ring(0, 43)));
let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
configure_anchor_store(Some(Arc::clone(&store)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::UserMessage {
text: "one".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
log.finalize().await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
drop(log);
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert_eq!(
events.len(),
2,
"post-anchor growth with a matching prefix must open OK"
);
configure_anchor_store(None);
configure_history_integrity(None);
}
#[tokio::test]
async fn finalize_is_noop_without_anchor_store_or_without_chaining() {
configure_history_integrity(Some(test_ring(0, 44)));
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
log.finalize().await.unwrap();
configure_history_integrity(None);
let store: Arc<dyn AnchorStore> = Arc::new(MockAnchorStore::default());
configure_anchor_store(Some(Arc::clone(&store)));
let dir2 = tempfile::tempdir().unwrap();
let log2 = SessionEventLog::open(dir2.path()).await.unwrap();
log2.append(
None,
None,
SessionEvent::SessionEnded {
reason: "legacy".into(),
},
)
.await
.unwrap();
log2.finalize().await.unwrap();
let identity = file_identity(dir2.path());
assert!(
store
.get_sync(AnchorSubsystem::SessionLog, &identity)
.unwrap()
.is_none(),
"no anchor should be written for an unchained handle"
);
configure_anchor_store(None);
}
}