Skip to main content

agent_core/core/
session_journal.rs

1//! Session snapshots with opt-in delta journals and durable checkpoints.
2//!
3//! New journal snapshots carry a storage-only `_journal_generation` UUID;
4//! v2 journals replay ONLY against that exact generation. Publishing a new
5//! snapshot is the logical commit, even if a crash leaves the old journal.
6//! Unmarked legacy snapshots still accept v1 journals. Their pre-migration
7//! crash ambiguity cannot be repaired retrospectively. Old binaries can read
8//! the Session-shaped snapshot but cannot replay v2 deltas; downgrade/export
9//! should first fold the journal with a JSON save using this implementation.
10//!
11//! Normal JSON saves retain the legacy bytes when no journal/generation has
12//! ever been present. Once bound, all snapshot replacements (including JSON
13//! saves) rotate the generation so leftover journals cannot resurrect history.
14//! Metadata is not a Session field: serde exports/mirrors ignore it. Stripping
15//! it loses v2 deltas, rather than applying a potentially unrelated journal.
16//!
17//! Journal appends write O(delta) bytes; a streaming history hash checks the
18//! entire saved prefix for edits (O(history) CPU, no history-sized allocation).
19//! Journal-bound snapshot rotation syncs the snapshot directory before removing
20//! old deltas; JSON-only normal saves retain their prior best-effort behavior.
21//! Durable saves always publish a full snapshot and sync through cleanup.
22//! Errors can follow logical commit;
23//! callers must stop and reload/retry, not assume rollback. All saves of one
24//! session must be externally serialized; no multi-writer transaction is offered.
25//!
26//! Private 0700/0600, no-symlink handle-relative I/O is preserved on Unix.
27//! Normal saves retain the documented non-Unix best effort; durable saves
28//! fail closed there rather than pretend directory fsync is supported.
29
30use crate::core::session::Session;
31use crate::core::stream_types::SharedMessage;
32use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use std::io::Write;
35use std::path::{Path, PathBuf};
36
37/// Journal record schema version (line-level `"v"` field).
38pub const JOURNAL_SCHEMA_VERSION: u8 = 2;
39
40/// Journal size floor before a periodic snapshot is forced.
41pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
42
43/// A snapshot is due when the journal outgrows `snapshot / RATIO` (bounded
44/// write amplification: large sessions stretch the threshold proportionally).
45pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
46
47/// Bounded tail window scanned for the freshest `meta` record.
48const META_TAIL_WINDOW: u64 = 64 * 1024;
49
50/// Hard cap on any single persisted-session artifact read (snapshot or
51/// journal). Reads are bounded on the OPENED handle; an artifact past the
52/// cap is refused rather than slurped.
53pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB
54
55// ─── confined resolution (fix1 I1 + fix2) ───────────────────────────────────
56//
57// STRICT TRUSTED-ROOT SEMANTICS (fix2): the sessions directory path is
58// resolved with EVERY component — ancestors AND the final one — opened
59// handle-relatively from `/` with symlinks refused
60// (`ConfinedDir::{open,create}_absolute_no_symlinks`; Linux uses one atomic
61// `openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS` from the root handle).
62// Nothing on the path is trusted; there is no check-then-open race — a
63// component swapped to a symlink at any moment fails the open itself.
64// Artifacts inside the directory are then opened/written/removed relative
65// to that ONE handle. Operators whose base dir legitimately sits behind
66// ancestor symlinks (e.g. `/home` → `var/home`) must point
67// `SYNAPS_BASE_DIR` at the canonical path.
68//
69// Non-unix keeps the crate's documented best-effort pathname fallback
70// (final-component symlink refusal only).
71
72#[cfg(unix)]
73type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
74
75/// Open the sessions dir strictly. `Ok(None)` when a path component does
76/// not exist; symlinks anywhere are errors.
77#[cfg(unix)]
78fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
79    match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
80        Ok(handle) => Ok(Some(handle)),
81        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
82        Err(e) => Err(e),
83    }
84}
85
86/// Create-or-open the sessions dir strictly (0700 leaf).
87#[cfg(unix)]
88fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
89    crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
90}
91
92/// Open `<name>` inside an already-strictly-opened sessions dir.
93/// `Ok(None)` when the file does not exist; a symlinked artifact errors.
94#[cfg(unix)]
95fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
96    match handle.open_file(&[name.to_string()]) {
97        Ok(file) => Ok(Some(file)),
98        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
99        Err(e) => Err(e),
100    }
101}
102
103/// One-shot strict open of `<name>` inside `dir`.
104#[cfg(unix)]
105pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
106    let Some(handle) = open_sessions_dir(dir)? else {
107        return Ok(None);
108    };
109    open_artifact(&handle, name)
110}
111
112// ── Non-unix best-effort fallbacks (documented, matching `private_fs`) ──
113
114#[cfg(not(unix))]
115struct SessionsDirHandle {
116    dir: std::path::PathBuf,
117}
118
119#[cfg(not(unix))]
120fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
121    if !dir.exists() {
122        return Ok(None);
123    }
124    Ok(Some(SessionsDirHandle {
125        dir: dir.to_path_buf(),
126    }))
127}
128
129#[cfg(not(unix))]
130fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
131    crate::core::private_fs::ensure_private_dir(dir)?;
132    Ok(SessionsDirHandle {
133        dir: dir.to_path_buf(),
134    })
135}
136
137#[cfg(not(unix))]
138impl SessionsDirHandle {
139    fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
140        crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
141            .map_err(std::io::Error::other)
142    }
143    fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
144        crate::core::private_fs::open_private_append(&self.dir.join(name))
145            .map_err(std::io::Error::other)
146    }
147    fn remove_file(&self, name: &str) -> std::io::Result<()> {
148        std::fs::remove_file(self.dir.join(name))
149    }
150}
151
152#[cfg(not(unix))]
153fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
154    let path = handle.dir.join(name);
155    match std::fs::symlink_metadata(&path) {
156        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
157        Ok(meta) if meta.file_type().is_symlink() => {
158            return Err(std::io::Error::other(format!(
159                "refusing symlinked session artifact {name:?}"
160            )))
161        }
162        _ => {}
163    }
164    match std::fs::File::open(&path) {
165        Ok(f) => Ok(Some(f)),
166        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
167        Err(e) => Err(e),
168    }
169}
170
171#[cfg(not(unix))]
172pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
173    let Some(handle) = open_sessions_dir(dir)? else {
174        return Ok(None);
175    };
176    open_artifact(&handle, name)
177}
178
179/// Bounded read of a whole artifact from an already-confined handle.
180fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
181    use std::io::Read;
182    let Some(file) = open_artifact(handle, name)? else {
183        return Ok(None);
184    };
185    let mut buf = Vec::new();
186    file.take(MAX_PERSISTED_READ_BYTES + 1)
187        .read_to_end(&mut buf)?;
188    if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
189        return Err(std::io::Error::other(format!(
190            "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
191        )));
192    }
193    Ok(Some(buf))
194}
195
196/// One strictly-resolved directory entry for session listings.
197#[derive(Debug, Clone)]
198pub struct SessionDirEntry {
199    pub name: String,
200    pub mtime: Option<std::time::SystemTime>,
201    /// Stored bytes for metadata-only accounting. Obtained from the directory
202    /// entry stat; reading it never opens session content.
203    pub byte_len: u64,
204}
205
206/// Enumerate the sessions dir through the SAME strict handle resolution as
207/// every other T35 operation (fix2): handle-relative `readdir` +
208/// `fstatat(AT_SYMLINK_NOFOLLOW)` — a symlinked ancestor refuses, a
209/// missing dir lists empty, and entry mtimes are of the entries
210/// themselves, never symlink targets.
211pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
212    #[cfg(unix)]
213    {
214        let Some(handle) = open_sessions_dir(dir)? else {
215            return Ok(Vec::new());
216        };
217        Ok(handle
218            .entries()?
219            .into_iter()
220            .filter(|e| e.is_file)
221            .map(|e| SessionDirEntry {
222                name: e.name,
223                mtime: e.mtime_unix_ms.map(|ms| {
224                    std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
225                }),
226                byte_len: e.byte_len,
227            })
228            .collect())
229    }
230    #[cfg(not(unix))]
231    {
232        if !dir.exists() {
233            return Ok(Vec::new());
234        }
235        let mut out = Vec::new();
236        for entry in std::fs::read_dir(dir)? {
237            let entry = entry?;
238            if !entry.path().is_file() {
239                continue;
240            }
241            let Ok(name) = entry.file_name().into_string() else {
242                continue;
243            };
244            let byte_len = entry
245                .metadata()
246                .map(|metadata| metadata.len())
247                .unwrap_or_default();
248            out.push(SessionDirEntry {
249                name,
250                mtime: entry.metadata().and_then(|m| m.modified()).ok(),
251                byte_len,
252            });
253        }
254        Ok(out)
255    }
256}
257
258/// Which on-disk persistence strategy `Session::save` uses.
259///
260/// `Json` (the default) writes a full Session-shaped snapshot. `Journal` is
261/// the spec §9.8 opt-in and is only ever selected explicitly via the
262/// `session_persistence = journal` config key.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
264pub enum SessionPersistence {
265    #[default]
266    Json,
267    Journal,
268}
269
270impl SessionPersistence {
271    /// Parse a config value; anything unrecognized yields `None` so the
272    /// caller keeps the safe default and surfaces a typed warning.
273    pub fn parse(val: &str) -> Option<Self> {
274        match val.trim() {
275            "json" => Some(Self::Json),
276            "journal" => Some(Self::Journal),
277            _ => None,
278        }
279    }
280}
281
282/// How a save landed on disk.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum SaveMode {
285    /// Full Session-shaped snapshot written (and, in journal mode, the
286    /// journal reset to a lone `open` record).
287    FullSnapshot,
288    /// Delta append: `messages` new history entries plus one meta record.
289    Append { messages: usize },
290}
291
292/// Machine-readable receipt for benchmarks and delta-proportionality tests.
293#[derive(Debug, Clone, Copy)]
294pub struct SaveReceipt {
295    pub mode: SaveMode,
296    pub bytes_written: u64,
297}
298
299/// Freshest journaled metadata, read from a bounded tail window — lets
300/// session listings stay accurate without parsing the full journal.
301#[derive(Debug, Clone)]
302pub struct JournalMetaTail {
303    pub updated_at: DateTime<Utc>,
304    pub session_cost: f64,
305    /// `None` for meta records written before the count was journaled.
306    pub message_count: Option<usize>,
307}
308
309/// `sessions/<id>.journal` — single-extension name so the session id stays
310/// the file stem (chain-head protection and retention pair on the stem).
311pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
312    dir.join(format!("{id}.journal"))
313}
314
315/// Snapshot threshold: journal bytes ≥ max(256 KiB, snapshot / 4).
316pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
317    journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
318}
319
320// ─── journal records ─────────────────────────────────────────────────────────
321
322#[derive(Serialize, Deserialize)]
323#[serde(tag = "k")]
324enum JournalRecord {
325    /// First line of every journal: the snapshot held `base` messages.
326    #[serde(rename = "open")]
327    Open {
328        v: u8,
329        base: usize,
330        #[serde(default, skip_serializing_if = "Option::is_none")]
331        generation: Option<String>,
332        #[serde(default, skip_serializing_if = "Option::is_none")]
333        history_hash: Option<String>,
334    },
335    /// Message at ABSOLUTE history index `i`.
336    #[serde(rename = "msg")]
337    Msg {
338        v: u8,
339        i: usize,
340        m: serde_json::Value,
341    },
342    /// Full session metadata (the `Session` object minus `api_messages`).
343    #[serde(rename = "meta")]
344    Meta {
345        v: u8,
346        meta: Box<SessionMeta>,
347        #[serde(default, skip_serializing_if = "Option::is_none")]
348        history_hash: Option<String>,
349    },
350}
351
352/// Mirror of [`Session`] WITHOUT `api_messages`, with identical serde
353/// attributes. `deny_unknown_fields` + the `session_meta_stays_in_sync_…`
354/// test make silent drift between the two types impossible.
355#[derive(Clone, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357struct SessionMeta {
358    id: String,
359    title: String,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    name: Option<String>,
362    model: String,
363    thinking_level: String,
364    system_prompt: Option<String>,
365    created_at: DateTime<Utc>,
366    updated_at: DateTime<Utc>,
367    total_input_tokens: u64,
368    total_output_tokens: u64,
369    session_cost: f64,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    message_count: Option<usize>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    abort_context: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    parent_session: Option<String>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    compacted_into: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    env: Option<Vec<(String, String)>>,
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    env_stripped: Vec<String>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    prompt_provenance: Option<crate::prompt::PromptProvenance>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    compaction: Option<crate::core::compaction::CompactionRecord>,
386}
387
388impl SessionMeta {
389    fn of(s: &Session) -> Self {
390        // Belt-and-braces: scrub any secret values from the env on the
391        // write path so the journal on disk NEVER contains a credential,
392        // even if a future client forgets to strip.
393        let env = scrub_secret_env(s.env.as_ref());
394        Self {
395            id: s.id.clone(),
396            title: s.title.clone(),
397            name: s.name.clone(),
398            model: s.model.clone(),
399            thinking_level: s.thinking_level.clone(),
400            system_prompt: s.system_prompt.clone(),
401            created_at: s.created_at,
402            updated_at: s.updated_at,
403            total_input_tokens: s.total_input_tokens,
404            total_output_tokens: s.total_output_tokens,
405            session_cost: s.session_cost,
406            message_count: Some(s.api_messages.len()),
407            abort_context: s.abort_context.clone(),
408            parent_session: s.parent_session.clone(),
409            compacted_into: s.compacted_into.clone(),
410            env,
411            env_stripped: s.env_stripped.clone(),
412            prompt_provenance: s.prompt_provenance.clone(),
413            compaction: s.compaction.clone(),
414        }
415    }
416
417    /// Apply onto a loaded session. Only metadata moves — never history.
418    fn apply(self, s: &mut Session) {
419        s.title = self.title;
420        s.name = self.name;
421        s.model = self.model;
422        s.thinking_level = self.thinking_level;
423        s.system_prompt = self.system_prompt;
424        s.created_at = self.created_at;
425        s.updated_at = self.updated_at;
426        s.total_input_tokens = self.total_input_tokens;
427        s.total_output_tokens = self.total_output_tokens;
428        s.session_cost = self.session_cost;
429        s.abort_context = self.abort_context;
430        s.parent_session = self.parent_session;
431        s.compacted_into = self.compacted_into;
432        s.env = self.env;
433        s.env_stripped = self.env_stripped;
434        s.prompt_provenance = self.prompt_provenance;
435        s.compaction = self.compaction;
436    }
437}
438
439/// A binding is deliberately outside Session/SessionMeta. It is emitted
440/// before `api_messages`, so listings and append checks need only the header.
441#[derive(Default, Deserialize)]
442struct SnapshotBinding {
443    #[serde(default, rename = "_journal_generation")]
444    generation: Option<String>,
445}
446
447fn snapshot_binding(file: std::fs::File) -> std::io::Result<SnapshotBinding> {
448    let header = crate::core::session::read_session_header_from_file(file)
449        .ok_or_else(|| std::io::Error::other("cannot read session snapshot header"))?;
450    serde_json::from_str(&header).map_err(std::io::Error::other)
451}
452
453/// v1 is interpretable only against an unmarked legacy snapshot. A bound
454/// snapshot must never fall back to v1, even for an empty/torn v2 journal.
455fn matching_open(record: &JournalRecord, snapshot_generation: Option<&str>) -> Option<u8> {
456    match record {
457        JournalRecord::Open {
458            v: 1,
459            generation: None,
460            ..
461        } if snapshot_generation.is_none() => Some(1),
462        JournalRecord::Open {
463            v,
464            generation: Some(generation),
465            ..
466        } if *v == JOURNAL_SCHEMA_VERSION
467            && !generation.is_empty()
468            && Some(generation.as_str()) == snapshot_generation =>
469        {
470            Some(*v)
471        }
472        _ => None,
473    }
474}
475
476/// Hash the exact ordered prefix, including all message fields. Unlike the
477/// old last-message tripwire this detects same-length edits anywhere, even
478/// when the journal contains only its open record. Uses the existing sha2 dep.
479fn history_hash(messages: &[SharedMessage]) -> std::io::Result<String> {
480    use sha2::{Digest, Sha256};
481    struct HashWriter(Sha256);
482    impl Write for HashWriter {
483        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
484            self.0.update(bytes);
485            Ok(bytes.len())
486        }
487        fn flush(&mut self) -> std::io::Result<()> {
488            Ok(())
489        }
490    }
491    let mut writer = HashWriter(Sha256::new());
492    serde_json::to_writer(&mut writer, messages).map_err(std::io::Error::other)?;
493    Ok(format!("{:x}", writer.0.finalize()))
494}
495
496/// Only clean, generation-matched v2 journals can be appended to. Legacy
497/// saves migrate through a full snapshot; torn/gapped tails resnapshot too.
498struct JournalState {
499    durable_len: usize,
500    history_hash: String,
501    bytes: u64,
502}
503
504fn read_journal_state(
505    handle: &SessionsDirHandle,
506    id: &str,
507    generation: Option<&str>,
508) -> std::io::Result<Option<JournalState>> {
509    let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
510        return Ok(None);
511    };
512    if !raw.ends_with(b"\n") {
513        return Ok(None); // appending after an unterminated line would hide data
514    }
515    let Ok(text) = std::str::from_utf8(&raw) else {
516        return Ok(None);
517    };
518    let mut lines = text.lines();
519    let Some(open) = lines
520        .next()
521        .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
522    else {
523        return Ok(None);
524    };
525    if matching_open(&open, generation) != Some(JOURNAL_SCHEMA_VERSION) {
526        return Ok(None);
527    }
528    let JournalRecord::Open {
529        base,
530        history_hash: Some(mut hash),
531        ..
532    } = open
533    else {
534        return Ok(None);
535    };
536    let mut durable_len = base;
537    let mut hashed_len = base;
538    for line in lines {
539        match serde_json::from_str::<JournalRecord>(line) {
540            Ok(JournalRecord::Msg { v, i, .. })
541                if v == JOURNAL_SCHEMA_VERSION && i == durable_len =>
542            {
543                durable_len += 1;
544            }
545            Ok(JournalRecord::Meta {
546                v,
547                meta,
548                history_hash: Some(next_hash),
549            }) if v == JOURNAL_SCHEMA_VERSION
550                && meta.id == id
551                && meta.message_count == Some(durable_len) =>
552            {
553                hash = next_hash;
554                hashed_len = durable_len;
555            }
556            _ => return Ok(None),
557        }
558    }
559    if hashed_len != durable_len {
560        return Ok(None); // messages without a complete committing meta record
561    }
562    Ok(Some(JournalState {
563        durable_len,
564        history_hash: hash,
565        bytes: raw.len() as u64,
566    }))
567}
568
569// ─── save ────────────────────────────────────────────────────────────────────
570
571/// Persist under the configured mode. Journal-bound replacement syncs the
572/// snapshot directory before discarding old deltas; normal JSON-only saves do
573/// not promise directory durability. Use [`save_session_durable_in_dir`] for a
574/// full context-head barrier. Snapshot replacements are generation-safe in both.
575pub fn save_session_in_dir(
576    dir: &Path,
577    session: &Session,
578    mode: SessionPersistence,
579) -> std::io::Result<SaveReceipt> {
580    let handle = create_sessions_dir(dir)?;
581    match mode {
582        SessionPersistence::Json => {
583            // Keep virgin JSON-only saves byte-compatible. Once a journal or
584            // generation exists, never publish an unmarked snapshot again.
585            let journal = open_artifact(&handle, &format!("{}.journal", session.id))?;
586            let bound =
587                open_artifact(&handle, &format!("{}.json", session.id))?.is_some_and(|file| {
588                    // An unreadable/oversized header must not prevent repair
589                    // by a full save; conservatively rotate the generation.
590                    snapshot_binding(file).map_or(true, |b| b.generation.is_some())
591                });
592            publish_snapshot(&handle, session, mode, false, journal.is_some() || bound)
593        }
594        SessionPersistence::Journal => save_journal_mode(&handle, session),
595    }
596}
597
598/// Blocking body of `Session::save_durable`: a full generation-bound snapshot
599/// in either mode. Fsync the file, rename it, fsync the directory, then clean
600/// up the journal and fsync the directory again. Snapshot rename is the logical
601/// commit; any later error is returned without rollback. Retry/reload is safe
602/// with stale journals. Concurrent saves of the same session are NOT supported.
603pub fn save_session_durable_in_dir(
604    dir: &Path,
605    session: &Session,
606    mode: SessionPersistence,
607) -> std::io::Result<SaveReceipt> {
608    #[cfg(unix)]
609    {
610        let handle = SessionsDirHandle::create_absolute_no_symlinks_durable(dir)?;
611        publish_snapshot(&handle, session, mode, true, true)
612    }
613    #[cfg(not(unix))]
614    {
615        let _ = (dir, session, mode);
616        Err(std::io::Error::new(
617            std::io::ErrorKind::Unsupported,
618            "durable session checkpoints require Unix directory fsync",
619        ))
620    }
621}
622
623fn save_journal_mode(
624    handle: &SessionsDirHandle,
625    session: &Session,
626) -> std::io::Result<SaveReceipt> {
627    let journal_name = format!("{}.journal", session.id);
628
629    let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
630    let Some(snapshot) = snapshot else {
631        return full_snapshot_reset(handle, session);
632    };
633    let snapshot_bytes = snapshot.metadata()?.len();
634    let Ok(binding) = snapshot_binding(snapshot) else {
635        return full_snapshot_reset(handle, session);
636    };
637    let Some(state) = read_journal_state(handle, &session.id, binding.generation.as_deref())?
638    else {
639        return full_snapshot_reset(handle, session);
640    };
641
642    if session.api_messages.len() < state.durable_len
643        || history_hash(&session.api_messages[..state.durable_len])? != state.history_hash
644    {
645        return full_snapshot_reset(handle, session);
646    }
647
648    // Delta append: new messages (absolute indices) + one meta record.
649    let mut buf = Vec::new();
650    for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
651        let record = JournalRecord::Msg {
652            v: JOURNAL_SCHEMA_VERSION,
653            i: state.durable_len + offset,
654            m: msg.as_ref().clone(),
655        };
656        serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
657        buf.push(b'\n');
658    }
659    let meta = JournalRecord::Meta {
660        v: JOURNAL_SCHEMA_VERSION,
661        meta: Box::new(SessionMeta::of(session)),
662        history_hash: Some(history_hash(&session.api_messages)?),
663    };
664    serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
665    buf.push(b'\n');
666
667    let appended = session.api_messages.len() - state.durable_len;
668    let mut file = handle.append_file(&journal_name)?;
669    file.write_all(&buf)?;
670    file.sync_data()?;
671    drop(file);
672
673    // Periodic snapshot: its fresh generation invalidates the old journal
674    // at publication, before the journal reset can occur.
675    let journal_bytes = state.bytes + buf.len() as u64;
676    if snapshot_due(journal_bytes, snapshot_bytes) {
677        let reset = full_snapshot_reset(handle, session)?;
678        return Ok(SaveReceipt {
679            mode: SaveMode::FullSnapshot,
680            bytes_written: buf.len() as u64 + reset.bytes_written,
681        });
682    }
683
684    Ok(SaveReceipt {
685        mode: SaveMode::Append { messages: appended },
686        bytes_written: buf.len() as u64,
687    })
688}
689
690/// Borrowing mirror of [`Session`] used ONLY for snapshot serialization:
691/// identical field order and serde attributes, with `message_count`
692/// computed from `api_messages.len()` at write time instead of read from
693/// the (non-authoritative) in-memory field. Field order matters —
694/// `message_count` must precede `api_messages` for `read_session_header`.
695/// The `snapshot_json_matches_session_schema` test guards drift.
696#[derive(Serialize)]
697struct SessionSnapshotRef<'a> {
698    // Storage-only metadata, omitted for legacy JSON-only saves. Session
699    // serde deliberately ignores it; it is not journaled as SessionMeta.
700    #[serde(skip_serializing_if = "Option::is_none")]
701    _journal_generation: Option<&'a str>,
702    id: &'a str,
703    title: &'a str,
704    #[serde(skip_serializing_if = "Option::is_none")]
705    name: &'a Option<String>,
706    model: &'a str,
707    thinking_level: &'a str,
708    system_prompt: &'a Option<String>,
709    created_at: &'a DateTime<Utc>,
710    updated_at: &'a DateTime<Utc>,
711    total_input_tokens: u64,
712    total_output_tokens: u64,
713    session_cost: f64,
714    message_count: usize,
715    #[serde(skip_serializing_if = "Option::is_none")]
716    parent_session: &'a Option<String>,
717    #[serde(skip_serializing_if = "Option::is_none")]
718    compacted_into: &'a Option<String>,
719    /// Owned (not borrowed) because secret keys are scrubbed on the write
720    /// path — the `.json` snapshot must never carry a credential (T5).
721    #[serde(skip_serializing_if = "Option::is_none")]
722    env: Option<Vec<(String, String)>>,
723    #[serde(skip_serializing_if = "<[String]>::is_empty")]
724    env_stripped: &'a [String],
725    api_messages: &'a [SharedMessage],
726    #[serde(skip_serializing_if = "Option::is_none")]
727    abort_context: &'a Option<String>,
728    #[serde(skip_serializing_if = "Option::is_none")]
729    prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
730    #[serde(skip_serializing_if = "Option::is_none")]
731    compaction: &'a Option<crate::core::compaction::CompactionRecord>,
732}
733
734impl<'a> SessionSnapshotRef<'a> {
735    fn of(s: &'a Session, generation: Option<&'a str>) -> Self {
736        Self {
737            _journal_generation: generation,
738            id: &s.id,
739            title: &s.title,
740            name: &s.name,
741            model: &s.model,
742            thinking_level: &s.thinking_level,
743            system_prompt: &s.system_prompt,
744            created_at: &s.created_at,
745            updated_at: &s.updated_at,
746            total_input_tokens: s.total_input_tokens,
747            total_output_tokens: s.total_output_tokens,
748            session_cost: s.session_cost,
749            message_count: s.api_messages.len(),
750            parent_session: &s.parent_session,
751            compacted_into: &s.compacted_into,
752            env: scrub_secret_env(s.env.as_ref()),
753            env_stripped: &s.env_stripped,
754            api_messages: &s.api_messages,
755            abort_context: &s.abort_context,
756            prompt_provenance: &s.prompt_provenance,
757            compaction: &s.compaction,
758        }
759    }
760}
761
762/// Full-snapshot JSON with a fresh `message_count`, without cloning.
763/// Drop every pair whose key matches the secret denylist OR whose value
764/// carries credentials (a URL with userinfo). Shared by the `.json` snapshot
765/// and the journal meta tail so neither artifact can carry a credential
766/// value, whatever the client sent.
767fn scrub_secret_env(env: Option<&Vec<(String, String)>>) -> Option<Vec<(String, String)>> {
768    use crate::core::config::is_secret_env;
769    env.map(|pairs| {
770        pairs
771            .iter()
772            .filter(|(k, v)| !is_secret_env(k, v))
773            .cloned()
774            .collect()
775    })
776}
777
778/// Full-snapshot JSON with fresh count and optional storage-only binding.
779fn snapshot_json(session: &Session, generation: Option<&str>) -> std::io::Result<String> {
780    serde_json::to_string(&SessionSnapshotRef::of(session, generation))
781        .map_err(std::io::Error::other)
782}
783
784fn full_snapshot_reset(
785    handle: &SessionsDirHandle,
786    session: &Session,
787) -> std::io::Result<SaveReceipt> {
788    publish_snapshot(handle, session, SessionPersistence::Journal, false, true)
789}
790
791fn publish_snapshot(
792    handle: &SessionsDirHandle,
793    session: &Session,
794    mode: SessionPersistence,
795    durable: bool,
796    bind: bool,
797) -> std::io::Result<SaveReceipt> {
798    let generation = bind.then(|| uuid::Uuid::new_v4().to_string());
799    let json = snapshot_json(session, generation.as_deref())?;
800    // Prepare all serialization BEFORE the logical commit.
801    let mut journal = Vec::new();
802    if mode == SessionPersistence::Journal {
803        let open = JournalRecord::Open {
804            v: JOURNAL_SCHEMA_VERSION,
805            base: session.api_messages.len(),
806            generation,
807            history_hash: Some(history_hash(&session.api_messages)?),
808        };
809        serde_json::to_writer(&mut journal, &open).map_err(std::io::Error::other)?;
810        journal.push(b'\n');
811    }
812    // Preflight journal symlinks in either mode, even JSON cleanup (which
813    // otherwise only unlinks). This does not claim multi-writer isolation.
814    open_artifact(handle, &format!("{}.journal", session.id))?;
815    handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
816    // Before destroying the old journal, the new snapshot must be durable.
817    // Binding prevents new-snapshot/old-journal replay, but without this sync
818    // a power loss could recover old-snapshot/new-journal and lose deltas.
819    #[cfg(unix)]
820    if durable || bind {
821        sync_sessions_dir(handle)?;
822    }
823    if mode == SessionPersistence::Journal {
824        handle.write_atomic(&format!("{}.journal", session.id), &journal)?;
825    } else {
826        remove_artifact_if_exists(handle, &format!("{}.journal", session.id))?;
827    }
828    if durable {
829        sync_sessions_dir(handle)?;
830    }
831    Ok(SaveReceipt {
832        mode: SaveMode::FullSnapshot,
833        bytes_written: json.len() as u64 + journal.len() as u64,
834    })
835}
836
837fn sync_sessions_dir(handle: &SessionsDirHandle) -> std::io::Result<()> {
838    #[cfg(unix)]
839    {
840        handle.sync_all()
841    }
842    #[cfg(not(unix))]
843    {
844        let _ = handle;
845        Err(std::io::Error::new(
846            std::io::ErrorKind::Unsupported,
847            "durable session checkpoints require Unix directory fsync",
848        ))
849    }
850}
851
852// ─── load ────────────────────────────────────────────────────────────────────
853
854/// Load `<id>.json` and, when a journal exists, replay it idempotently.
855/// Old-format sessions (no journal) load exactly as before; a session with
856/// a torn or stale journal recovers to its last consistent state. Both
857/// reads are confined nofollow bounded handle reads (fix1 I1).
858pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
859    // ONE strict resolution per load (fix2); both artifact reads below are
860    // relative to this handle.
861    let handle = open_sessions_dir(dir)?.ok_or_else(|| {
862        std::io::Error::new(
863            std::io::ErrorKind::NotFound,
864            format!("no sessions directory for '{id}'"),
865        )
866    })?;
867    let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
868        std::io::Error::new(
869            std::io::ErrorKind::NotFound,
870            format!("no session snapshot for '{id}'"),
871        )
872    })?;
873    let binding: SnapshotBinding =
874        serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
875    let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
876
877    let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
878        return Ok(session);
879    };
880    let text = String::from_utf8_lossy(&raw);
881    let mut lines = text.lines();
882    let version = match lines
883        .next()
884        .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
885        .and_then(|open| matching_open(&open, binding.generation.as_deref()))
886    {
887        Some(version) => version,
888        None => return Ok(session),
889    };
890    for line in lines {
891        match serde_json::from_str::<JournalRecord>(line) {
892            // fix1 M1: an unknown-version record ends the valid prefix.
893            Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
894                if v != version =>
895            {
896                break;
897            }
898            Ok(JournalRecord::Msg { i, m, .. }) => {
899                if i == session.api_messages.len() {
900                    session.api_messages.push(std::sync::Arc::new(m));
901                } else if i > session.api_messages.len() {
902                    break; // gap — stop at the consistent prefix
903                }
904                // i < len: already in the snapshot — idempotent skip.
905            }
906            Ok(JournalRecord::Meta { meta, .. }) => {
907                if meta.id == session.id && meta.updated_at >= session.updated_at {
908                    meta.apply(&mut session);
909                }
910            }
911            Ok(JournalRecord::Open { .. }) | Err(_) => break, // torn tail
912        }
913    }
914    Ok(session)
915}
916
917/// Freshest `meta` record from a bounded journal tail window, for listing
918/// freshness without a full journal read. `None` when no journal, no
919/// complete supported-version meta record, or a refused (non-confined)
920/// artifact — a symlinked journal discloses nothing.
921pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
922    let handle = open_sessions_dir(dir).ok()??;
923    let binding = snapshot_binding(open_artifact(&handle, &format!("{id}.json")).ok()??).ok()?;
924    journal_meta_tail_from_handle(&handle, id, binding.generation.as_deref())
925}
926
927/// Listings already read a snapshot header: use THAT generation rather than
928/// reopening a possibly replaced snapshot and mixing two checkpoint heads.
929pub(crate) fn journal_meta_tail_for_generation(
930    dir: &Path,
931    id: &str,
932    generation: Option<&str>,
933) -> Option<JournalMetaTail> {
934    let handle = open_sessions_dir(dir).ok()??;
935    journal_meta_tail_from_handle(&handle, id, generation)
936}
937
938fn journal_meta_tail_from_handle(
939    handle: &SessionsDirHandle,
940    id: &str,
941    generation: Option<&str>,
942) -> Option<JournalMetaTail> {
943    use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
944    let mut file = open_artifact(handle, &format!("{id}.journal")).ok()??;
945    let mut first = String::new();
946    BufReader::new((&mut file).take(4096))
947        .read_line(&mut first)
948        .ok()?;
949    let open: JournalRecord = serde_json::from_str(&first).ok()?;
950    let version = matching_open(&open, generation)?;
951    let len = file.metadata().ok()?.len();
952    let start = len.saturating_sub(META_TAIL_WINDOW);
953    file.seek(SeekFrom::Start(start)).ok()?;
954    let mut tail = String::new();
955    file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
956
957    let mut freshest = None;
958    for line in tail.lines() {
959        if let Ok(JournalRecord::Meta { v, meta, .. }) = serde_json::from_str::<JournalRecord>(line)
960        {
961            if v == version && meta.id == id {
962                freshest = Some(JournalMetaTail {
963                    updated_at: meta.updated_at,
964                    session_cost: meta.session_cost,
965                    message_count: meta.message_count,
966                });
967            }
968        }
969    }
970    freshest
971}
972
973// ─── deletion ────────────────────────────────────────────────────────────────
974
975/// Remove a session's snapshot AND journal (compaction rollback, retention).
976/// Idempotent — missing files are not errors.
977pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
978    let Some(handle) = open_sessions_dir(dir)? else {
979        return Ok(()); // no directory — idempotently nothing to delete
980    };
981    remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
982    remove_artifact_if_exists(&handle, &format!("{id}.journal"))
983}
984
985fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
986    match handle.remove_file(name) {
987        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
988        other => other,
989    }
990}
991
992/// Strict-handle snapshot write shared with the legacy
993/// `session::save_json_in_dir` path (fix2): same root resolution and the
994/// same handle-relative atomic private write as every journal operation.
995#[cfg(test)]
996pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
997    let handle = create_sessions_dir(dir)?;
998    handle.write_atomic(&format!("{id}.json"), json)
999}
1000
1001// ─── tests ───────────────────────────────────────────────────────────────────
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006
1007    /// Drift guard: `SessionMeta` must mirror every non-history `Session`
1008    /// field. Serializing a full `Session`, dropping `api_messages`, and
1009    /// parsing the rest as `SessionMeta` (deny_unknown_fields) fails the
1010    /// moment `Session` grows a field this module does not journal.
1011    #[test]
1012    fn snapshot_json_matches_session_schema() {
1013        let mut s = Session::new("model-x", "medium", Some("prompt"));
1014        s.name = Some("named".into());
1015        s.abort_context = Some("ctx".into());
1016        s.parent_session = Some("parent".into());
1017        s.compacted_into = Some("child".into());
1018        s.api_messages.push(std::sync::Arc::new(
1019            serde_json::json!({"role": "user", "content": "hi"}),
1020        ));
1021        s.message_count = 0; // stale in memory — snapshot must not trust it
1022                             // Expected = the old clone-and-refresh path, byte for byte.
1023        let mut expected = s.clone();
1024        expected.message_count = expected.api_messages.len();
1025        let expected = serde_json::to_string(&expected).unwrap();
1026        assert_eq!(snapshot_json(&s, None).unwrap(), expected);
1027        let back: Session = serde_json::from_str(&snapshot_json(&s, None).unwrap()).unwrap();
1028        assert_eq!(back.message_count, 1);
1029        // Same check with every optional absent.
1030        let s = Session::new("model-x", "medium", None);
1031        let mut expected = s.clone();
1032        expected.message_count = 0;
1033        assert_eq!(
1034            snapshot_json(&s, None).unwrap(),
1035            serde_json::to_string(&expected).unwrap()
1036        );
1037    }
1038
1039    #[test]
1040    fn session_meta_stays_in_sync_with_session_schema() {
1041        let mut s = Session::new("model-x", "medium", Some("prompt"));
1042        s.name = Some("named".into());
1043        s.abort_context = Some("ctx".into());
1044        s.parent_session = Some("parent".into());
1045        s.compacted_into = Some("child".into());
1046        let mut value = serde_json::to_value(&s).unwrap();
1047        value.as_object_mut().unwrap().remove("api_messages");
1048        let meta: SessionMeta = serde_json::from_value(value)
1049            .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
1050        assert_eq!(meta.id, s.id);
1051        assert_eq!(meta.name.as_deref(), Some("named"));
1052    }
1053
1054    #[test]
1055    fn meta_roundtrip_applies_every_field() {
1056        let mut a = Session::new("model-a", "high", Some("sys"));
1057        a.title = "t".into();
1058        a.total_input_tokens = 7;
1059        a.session_cost = 0.5;
1060        a.updated_at = chrono::Utc::now();
1061        let mut b = Session::new("model-b", "low", None);
1062        b.id = a.id.clone();
1063        SessionMeta::of(&a).apply(&mut b);
1064        assert_eq!(b.model, "model-a");
1065        assert_eq!(b.title, "t");
1066        assert_eq!(b.total_input_tokens, 7);
1067        assert_eq!(b.session_cost, 0.5);
1068        assert_eq!(b.updated_at, a.updated_at);
1069    }
1070
1071    #[test]
1072    fn env_roundtrip_and_secret_scrub() {
1073        let tmp = tempfile::TempDir::new().unwrap();
1074        let dir = tmp.path().join("sessions");
1075        let mut s = Session::new("m", "medium", None);
1076        // Set env with a normal var and a secret-looking var.
1077        s.env = Some(vec![
1078            ("PATH".into(), "/usr/bin".into()),
1079            ("GH_TOKEN".into(), "ghp_supersecret42".into()),
1080        ]);
1081        s.env_stripped = vec!["GH_TOKEN".into()];
1082        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1083        // Load back.
1084        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1085        // Non-secret survives.
1086        let env = loaded.env.as_ref().expect("env must round-trip");
1087        assert!(
1088            env.iter().any(|(k, v)| k == "PATH" && v == "/usr/bin"),
1089            "non-secret key must survive"
1090        );
1091        // Secret value MUST be absent from the on-disk bytes.
1092        let snap_bytes =
1093            std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
1094        assert!(
1095            !snap_bytes.contains("ghp_supersecret42"),
1096            "secret VALUE must never appear on disk"
1097        );
1098        // …and the non-secret env IS in the `.json` snapshot itself (not only
1099        // the journal meta tail) — the snapshot is what a fresh daemon reads.
1100        assert!(
1101            snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1102            "snapshot must carry the scrubbed env: {snap_bytes}"
1103        );
1104        // Secret name IS present in env_stripped.
1105        assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1106        // The secret KEY should not appear in the env pairs.
1107        assert!(
1108            !env.iter().any(|(k, _)| k == "GH_TOKEN"),
1109            "secret key must be scrubbed from env pairs"
1110        );
1111    }
1112
1113    #[test]
1114    fn credential_url_values_never_reach_disk_whatever_the_key() {
1115        let tmp = tempfile::TempDir::new().unwrap();
1116        let dir = tmp.path().join("sessions");
1117        let mut s = Session::new("m", "medium", None);
1118        s.env = Some(vec![
1119            ("PATH".into(), "/usr/bin".into()),
1120            (
1121                "DATABASE_URL".into(),
1122                "postgres://app:hunter2@db/prod".into(),
1123            ),
1124            (
1125                "SENTRY_DSN".into(),
1126                "https://abc123dsnkey@o1.ingest.sentry.io/42".into(),
1127            ),
1128        ]);
1129        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1130        // Every file the save wrote (snapshot + journal) — none may carry the credential.
1131        for entry in std::fs::read_dir(&dir).unwrap() {
1132            let path = entry.unwrap().path();
1133            let bytes = std::fs::read(&path).unwrap();
1134            let text = String::from_utf8_lossy(&bytes);
1135            assert!(
1136                !text.contains("hunter2"),
1137                "credential URL leaked to {}",
1138                path.display()
1139            );
1140            assert!(
1141                !text.contains("abc123dsnkey"),
1142                "DSN key leaked to {}",
1143                path.display()
1144            );
1145        }
1146        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1147        let env = loaded.env.as_ref().expect("env must round-trip");
1148        assert!(
1149            env.iter().any(|(k, _)| k == "PATH"),
1150            "non-secret key must survive"
1151        );
1152        assert!(!env
1153            .iter()
1154            .any(|(k, _)| k == "DATABASE_URL" || k == "SENTRY_DSN"));
1155    }
1156
1157    fn with_messages(n: usize) -> Session {
1158        let mut s = Session::new("checkpoint-test", "medium", None);
1159        for i in 0..n {
1160            push_message(&mut s, &format!("old {i}"));
1161        }
1162        s
1163    }
1164
1165    fn push_message(s: &mut Session, text: &str) {
1166        s.api_messages.push(std::sync::Arc::new(
1167            serde_json::json!({"role": "user", "content": text}),
1168        ));
1169    }
1170
1171    fn assert_saved(dir: &Path, expected: &Session) {
1172        let loaded = load_session_in_dir(dir, &expected.id).unwrap();
1173        let mut expected = expected.clone();
1174        expected.message_count = expected.api_messages.len();
1175        // message_count is a snapshot hint, not authoritative after replay.
1176        let mut loaded = loaded;
1177        loaded.message_count = loaded.api_messages.len();
1178        assert_eq!(
1179            serde_json::to_value(loaded).unwrap(),
1180            serde_json::to_value(expected).unwrap()
1181        );
1182    }
1183
1184    fn disk_generation(dir: &Path, id: &str) -> String {
1185        let snapshot: serde_json::Value =
1186            serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
1187                .unwrap();
1188        snapshot["_journal_generation"]
1189            .as_str()
1190            .unwrap()
1191            .to_string()
1192    }
1193
1194    #[test]
1195    fn bound_snapshot_is_session_shaped_and_storage_metadata_is_not_mirrored() {
1196        let s = with_messages(2);
1197        let json = snapshot_json(&s, Some("test-generation")).unwrap();
1198        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1199        assert_eq!(
1200            value
1201                .as_object_mut()
1202                .unwrap()
1203                .remove("_journal_generation")
1204                .unwrap(),
1205            "test-generation"
1206        );
1207        let mut expected = s.clone();
1208        expected.message_count = 2;
1209        assert_eq!(value, serde_json::to_value(&expected).unwrap());
1210        let back: Session = serde_json::from_str(&json).unwrap();
1211        assert_eq!(serde_json::to_value(back).unwrap(), value);
1212        value.as_object_mut().unwrap().remove("api_messages");
1213        serde_json::from_value::<SessionMeta>(value).unwrap();
1214    }
1215
1216    /// Recreate the exact two-file state of a crash after snapshot rename but
1217    /// before journal reset/unlink: keep the newly published snapshot and put
1218    /// back the old journal bytes. No clocks, processes, config, or real data.
1219    fn replacement_crash_case(mode: SessionPersistence, durable: bool, shorten: bool) {
1220        let tmp = tempfile::TempDir::new().unwrap();
1221        let dir = tmp.path().canonicalize().unwrap();
1222        let mut s = with_messages(2);
1223        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1224        for _ in 0..4 {
1225            push_message(&mut s, "obsolete journal message");
1226        }
1227        s.title = "obsolete metadata".into();
1228        s.updated_at += chrono::Duration::hours(1);
1229        s.session_cost = 99.0;
1230        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1231        let old = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1232        let old_generation = disk_generation(&dir, &s.id);
1233
1234        // Even same-length edits with an unchanged final message must commit.
1235        s.api_messages[0] =
1236            std::sync::Arc::new(serde_json::json!({"role":"user","content":"new head"}));
1237        if shorten {
1238            s.api_messages.truncate(3);
1239        }
1240        s.title = "candidate".into();
1241        s.updated_at -= chrono::Duration::hours(2); // old metadata would win by timestamp alone
1242        s.session_cost = 1.0;
1243        let receipt = if durable {
1244            save_session_durable_in_dir(&dir, &s, mode).unwrap()
1245        } else {
1246            save_session_in_dir(&dir, &s, mode).unwrap()
1247        };
1248        assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1249        assert_ne!(disk_generation(&dir, &s.id), old_generation);
1250        std::fs::write(journal_path(&dir, &s.id), old).unwrap();
1251        assert_saved(&dir, &s);
1252        assert!(journal_meta_tail(&dir, &s.id).is_none());
1253
1254        // A subsequent normal save must not append into the stale generation.
1255        push_message(&mut s, "post-checkpoint");
1256        let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1257        assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1258        assert_saved(&dir, &s);
1259        push_message(&mut s, "new generation delta");
1260        assert_eq!(
1261            save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1262                .unwrap()
1263                .mode,
1264            SaveMode::Append { messages: 1 }
1265        );
1266        assert_saved(&dir, &s);
1267    }
1268
1269    #[test]
1270    fn normal_replacements_ignore_stale_journal_after_snapshot_publication() {
1271        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1272            for shorten in [false, true] {
1273                replacement_crash_case(mode, false, shorten);
1274            }
1275        }
1276    }
1277
1278    #[cfg(unix)]
1279    #[test]
1280    fn durable_replacements_ignore_stale_journal_after_snapshot_publication() {
1281        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1282            for shorten in [false, true] {
1283                replacement_crash_case(mode, true, shorten);
1284            }
1285        }
1286    }
1287
1288    #[test]
1289    fn legacy_v1_reads_then_migrates_and_cannot_replay_over_replacement() {
1290        let tmp = tempfile::TempDir::new().unwrap();
1291        let dir = tmp.path().canonicalize().unwrap();
1292        let mut s = with_messages(1);
1293        std::fs::write(
1294            dir.join(format!("{}.json", s.id)),
1295            snapshot_json(&s, None).unwrap(),
1296        )
1297        .unwrap();
1298        let mut legacy = vec![JournalRecord::Open {
1299            v: 1,
1300            base: 1,
1301            generation: None,
1302            history_hash: None,
1303        }];
1304        for i in 1..5 {
1305            push_message(&mut s, "legacy delta");
1306            legacy.push(JournalRecord::Msg {
1307                v: 1,
1308                i,
1309                m: s.api_messages[i].as_ref().clone(),
1310            });
1311        }
1312        s.title = "legacy title".into();
1313        s.updated_at += chrono::Duration::hours(1);
1314        legacy.push(JournalRecord::Meta {
1315            v: 1,
1316            meta: Box::new(SessionMeta::of(&s)),
1317            history_hash: None,
1318        });
1319        let mut bytes = Vec::new();
1320        for record in legacy {
1321            serde_json::to_writer(&mut bytes, &record).unwrap();
1322            bytes.push(b'\n');
1323        }
1324        std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1325        assert_saved(&dir, &s);
1326        assert_eq!(
1327            journal_meta_tail(&dir, &s.id).unwrap().message_count,
1328            Some(5)
1329        );
1330        let migrated = load_session_in_dir(&dir, &s.id).unwrap();
1331        assert_eq!(
1332            save_session_in_dir(&dir, &migrated, SessionPersistence::Journal)
1333                .unwrap()
1334                .mode,
1335            SaveMode::FullSnapshot
1336        );
1337        assert_saved(&dir, &s);
1338        assert!(!disk_generation(&dir, &s.id).is_empty());
1339
1340        s.api_messages.truncate(2);
1341        s.title = "new window".into();
1342        s.updated_at -= chrono::Duration::hours(2);
1343        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1344        std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1345        assert_saved(&dir, &s);
1346        assert!(journal_meta_tail(&dir, &s.id).is_none());
1347    }
1348
1349    #[test]
1350    fn same_length_edits_in_snapshot_or_earlier_journal_message_resnapshot() {
1351        for appended in [false, true] {
1352            let tmp = tempfile::TempDir::new().unwrap();
1353            let dir = tmp.path().canonicalize().unwrap();
1354            let mut s = with_messages(3);
1355            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1356            let edited = if appended {
1357                push_message(&mut s, "editable");
1358                push_message(&mut s, "unchanged last message");
1359                save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1360                3
1361            } else {
1362                0
1363            };
1364            let old_generation = disk_generation(&dir, &s.id);
1365            std::sync::Arc::make_mut(&mut s.api_messages[edited])["content"] = "replaced".into();
1366            assert_eq!(
1367                save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1368                    .unwrap()
1369                    .mode,
1370                SaveMode::FullSnapshot
1371            );
1372            assert_ne!(disk_generation(&dir, &s.id), old_generation);
1373            assert_saved(&dir, &s);
1374        }
1375    }
1376
1377    #[test]
1378    fn v2_never_replays_without_binding_or_across_record_versions() {
1379        let tmp = tempfile::TempDir::new().unwrap();
1380        let dir = tmp.path().canonicalize().unwrap();
1381        let mut s = with_messages(1);
1382        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1383        let first = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1384        push_message(&mut s, "v2 delta");
1385        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1386        let snapshot: Session =
1387            serde_json::from_slice(&std::fs::read(dir.join(format!("{}.json", s.id))).unwrap())
1388                .unwrap();
1389        // A generic Session serde mirror drops only the storage metadata.
1390        std::fs::write(
1391            dir.join(format!("{}.json", s.id)),
1392            serde_json::to_vec(&snapshot).unwrap(),
1393        )
1394        .unwrap();
1395        assert_saved(&dir, &snapshot); // never apply an unbound v2 delta
1396        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1397        let bound_snapshot = s.clone();
1398        let mut journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1399        journal.extend_from_slice(
1400            b"{\"v\":1,\"k\":\"msg\",\"i\":2,\"m\":{\"content\":\"wrong version\"}}\n",
1401        );
1402        std::fs::write(journal_path(&dir, &s.id), &journal).unwrap();
1403        assert_saved(&dir, &bound_snapshot);
1404        assert_eq!(
1405            save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1406                .unwrap()
1407                .mode,
1408            SaveMode::FullSnapshot
1409        );
1410        // Restoring an older *v2* open also invalidates the whole overlay.
1411        std::fs::write(journal_path(&dir, &s.id), first).unwrap();
1412        assert_saved(&dir, &s);
1413    }
1414
1415    #[test]
1416    fn json_saves_keep_binding_even_after_journal_deletion() {
1417        let tmp = tempfile::TempDir::new().unwrap();
1418        let dir = tmp.path().canonicalize().unwrap();
1419        let mut s = with_messages(2);
1420        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1421        push_message(&mut s, "old delta");
1422        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1423        let journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1424        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1425        let generation = disk_generation(&dir, &s.id);
1426        s.api_messages.truncate(2);
1427        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1428        assert_ne!(generation, disk_generation(&dir, &s.id));
1429        std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
1430        assert_saved(&dir, &s);
1431    }
1432
1433    #[cfg(unix)]
1434    #[test]
1435    fn durable_error_after_rename_keeps_candidate_and_retry_is_safe() {
1436        let tmp = tempfile::TempDir::new().unwrap();
1437        let dir = tmp.path().canonicalize().unwrap();
1438        let mut s = with_messages(2);
1439        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1440        push_message(&mut s, "old delta");
1441        push_message(&mut s, "more old history");
1442        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1443        let old_journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1444        let old_generation = disk_generation(&dir, &s.id);
1445        s.api_messages.truncate(2);
1446        s.title = "durable candidate".into();
1447        // Snapshot fsync/rename/dir fsync succeeds; journal temp cleanup fails.
1448        let blocker = dir.join(format!("{}.journal.tmp", s.id));
1449        std::fs::create_dir(&blocker).unwrap();
1450        let error = save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap_err();
1451        assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
1452        assert_ne!(old_generation, disk_generation(&dir, &s.id));
1453        assert_eq!(
1454            std::fs::read(journal_path(&dir, &s.id)).unwrap(),
1455            old_journal
1456        );
1457        assert_saved(&dir, &s); // Err did NOT roll back the published head
1458        std::fs::remove_dir(blocker).unwrap();
1459        save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1460        assert_saved(&dir, &s);
1461    }
1462
1463    /// Private modes (spec §5.4): journal-mode saves keep the 0700 dir and
1464    /// 0600 files under a permissive umask, exactly like the legacy path.
1465    #[cfg(unix)]
1466    mod private_modes {
1467        use super::*;
1468        use crate::core::private_fs::test_support::UmaskGuard;
1469        use serial_test::serial;
1470        use std::os::unix::fs::PermissionsExt;
1471
1472        fn mode_of(path: &Path) -> u32 {
1473            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1474        }
1475
1476        #[test]
1477        #[serial(umask)]
1478        fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
1479            let _umask = UmaskGuard::set(0);
1480            let tmp = tempfile::TempDir::new().unwrap();
1481            let dir = tmp.path().join("sessions");
1482            let mut s = Session::new("m", "medium", None);
1483            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1484            s.api_messages.push(std::sync::Arc::new(
1485                serde_json::json!({"role":"user","content":"x"}),
1486            ));
1487            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1488
1489            assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1490            assert_eq!(
1491                mode_of(&dir.join(format!("{}.json", s.id))),
1492                0o600,
1493                "snapshot must be 0600"
1494            );
1495            assert_eq!(
1496                mode_of(&journal_path(&dir, &s.id)),
1497                0o600,
1498                "journal must be 0600"
1499            );
1500        }
1501
1502        #[test]
1503        #[serial(umask)]
1504        fn durable_files_are_private_in_both_modes_and_repair_leaf_modes() {
1505            let _umask = UmaskGuard::set(0);
1506            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1507                let tmp = tempfile::TempDir::new().unwrap();
1508                let dir = tmp
1509                    .path()
1510                    .canonicalize()
1511                    .unwrap()
1512                    .join("new/profile/sessions");
1513                let s = with_messages(2);
1514                save_session_durable_in_dir(&dir, &s, mode).unwrap();
1515                assert_eq!(mode_of(dir.parent().unwrap()), 0o700);
1516                std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1517                let snapshot = dir.join(format!("{}.json", s.id));
1518                std::fs::set_permissions(&snapshot, std::fs::Permissions::from_mode(0o666))
1519                    .unwrap();
1520                save_session_durable_in_dir(&dir, &s, mode).unwrap();
1521                assert_eq!(mode_of(&dir), 0o700);
1522                assert_eq!(mode_of(&snapshot), 0o600);
1523                if mode == SessionPersistence::Journal {
1524                    assert_eq!(mode_of(&journal_path(&dir, &s.id)), 0o600);
1525                }
1526                assert_saved(&dir, &s);
1527            }
1528        }
1529
1530        #[test]
1531        fn durable_saves_refuse_artifact_and_ancestor_symlinks_in_both_modes() {
1532            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1533                for extension in ["json", "journal"] {
1534                    let tmp = tempfile::TempDir::new().unwrap();
1535                    let root = tmp.path().canonicalize().unwrap();
1536                    let dir = root.join("sessions");
1537                    std::fs::create_dir(&dir).unwrap();
1538                    let victim = root.join("victim");
1539                    std::fs::write(&victim, "original").unwrap();
1540                    let s = with_messages(1);
1541                    std::os::unix::fs::symlink(
1542                        &victim,
1543                        dir.join(format!("{}.{}", s.id, extension)),
1544                    )
1545                    .unwrap();
1546                    assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1547                    assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1548                }
1549                let tmp = tempfile::TempDir::new().unwrap();
1550                let root = tmp.path().canonicalize().unwrap();
1551                let victim_dir = root.join("victim-dir");
1552                std::fs::create_dir(&victim_dir).unwrap();
1553                let link = root.join("link");
1554                std::os::unix::fs::symlink(&victim_dir, &link).unwrap();
1555                let s = with_messages(1);
1556                for dir in [link.clone(), link.join("profile/sessions")] {
1557                    assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1558                }
1559                assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
1560            }
1561        }
1562
1563        #[test]
1564        fn journal_append_refuses_symlink_target() {
1565            let tmp = tempfile::TempDir::new().unwrap();
1566            let dir = tmp.path().join("sessions");
1567            std::fs::create_dir_all(&dir).unwrap();
1568            let mut s = Session::new("m", "medium", None);
1569            let victim = tmp.path().join("victim");
1570            std::fs::write(&victim, "original").unwrap();
1571            std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
1572            // Preflight refuses the journal before publishing a snapshot.
1573            s.api_messages.push(std::sync::Arc::new(
1574                serde_json::json!({"role":"user","content":"x"}),
1575            ));
1576            let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
1577            assert!(res.is_err(), "journal write onto a symlink must fail");
1578            assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1579        }
1580    }
1581}