1use 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
37pub const JOURNAL_SCHEMA_VERSION: u8 = 2;
39
40pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
42
43pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
46
47const META_TAIL_WINDOW: u64 = 64 * 1024;
49
50pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; #[cfg(unix)]
73type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
74
75#[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#[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#[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#[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#[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
179fn 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#[derive(Debug, Clone)]
198pub struct SessionDirEntry {
199 pub name: String,
200 pub mtime: Option<std::time::SystemTime>,
201 pub byte_len: u64,
204}
205
206pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
264pub enum SessionPersistence {
265 #[default]
266 Json,
267 Journal,
268}
269
270impl SessionPersistence {
271 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum SaveMode {
285 FullSnapshot,
288 Append { messages: usize },
290}
291
292#[derive(Debug, Clone, Copy)]
294pub struct SaveReceipt {
295 pub mode: SaveMode,
296 pub bytes_written: u64,
297}
298
299#[derive(Debug, Clone)]
302pub struct JournalMetaTail {
303 pub updated_at: DateTime<Utc>,
304 pub session_cost: f64,
305 pub message_count: Option<usize>,
307}
308
309pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
312 dir.join(format!("{id}.journal"))
313}
314
315pub 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#[derive(Serialize, Deserialize)]
323#[serde(tag = "k")]
324enum JournalRecord {
325 #[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 #[serde(rename = "msg")]
337 Msg {
338 v: u8,
339 i: usize,
340 m: serde_json::Value,
341 },
342 #[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#[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 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 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#[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
453fn 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
476fn 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
496struct 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); }
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); }
562 Ok(Some(JournalState {
563 durable_len,
564 history_hash: hash,
565 bytes: raw.len() as u64,
566 }))
567}
568
569pub 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 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 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
598pub 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 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 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#[derive(Serialize)]
697struct SessionSnapshotRef<'a> {
698 #[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 #[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
762fn 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
778fn 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 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 open_artifact(handle, &format!("{}.journal", session.id))?;
815 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
816 #[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
852pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
859 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 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; }
904 }
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, }
913 }
914 Ok(session)
915}
916
917pub 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
927pub(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
973pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
979 let Some(handle) = open_sessions_dir(dir)? else {
980 return Ok(()); };
982 remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
983 remove_artifact_if_exists(&handle, &format!("{id}.journal"))?;
984 remove_artifact_if_exists(&handle, &format!("{id}.turn"))
985}
986
987fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
988 match handle.remove_file(name) {
989 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
990 other => other,
991 }
992}
993
994#[cfg(test)]
998pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
999 let handle = create_sessions_dir(dir)?;
1000 handle.write_atomic(&format!("{id}.json"), json)
1001}
1002
1003#[cfg(test)]
1006mod tests {
1007 use super::*;
1008
1009 #[test]
1014 fn snapshot_json_matches_session_schema() {
1015 let mut s = Session::new("model-x", "medium", Some("prompt"));
1016 s.name = Some("named".into());
1017 s.abort_context = Some("ctx".into());
1018 s.parent_session = Some("parent".into());
1019 s.compacted_into = Some("child".into());
1020 s.api_messages.push(std::sync::Arc::new(
1021 serde_json::json!({"role": "user", "content": "hi"}),
1022 ));
1023 s.message_count = 0; let mut expected = s.clone();
1026 expected.message_count = expected.api_messages.len();
1027 let expected = serde_json::to_string(&expected).unwrap();
1028 assert_eq!(snapshot_json(&s, None).unwrap(), expected);
1029 let back: Session = serde_json::from_str(&snapshot_json(&s, None).unwrap()).unwrap();
1030 assert_eq!(back.message_count, 1);
1031 let s = Session::new("model-x", "medium", None);
1033 let mut expected = s.clone();
1034 expected.message_count = 0;
1035 assert_eq!(
1036 snapshot_json(&s, None).unwrap(),
1037 serde_json::to_string(&expected).unwrap()
1038 );
1039 }
1040
1041 #[test]
1042 fn session_meta_stays_in_sync_with_session_schema() {
1043 let mut s = Session::new("model-x", "medium", Some("prompt"));
1044 s.name = Some("named".into());
1045 s.abort_context = Some("ctx".into());
1046 s.parent_session = Some("parent".into());
1047 s.compacted_into = Some("child".into());
1048 let mut value = serde_json::to_value(&s).unwrap();
1049 value.as_object_mut().unwrap().remove("api_messages");
1050 let meta: SessionMeta = serde_json::from_value(value)
1051 .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
1052 assert_eq!(meta.id, s.id);
1053 assert_eq!(meta.name.as_deref(), Some("named"));
1054 }
1055
1056 #[test]
1057 fn meta_roundtrip_applies_every_field() {
1058 let mut a = Session::new("model-a", "high", Some("sys"));
1059 a.title = "t".into();
1060 a.total_input_tokens = 7;
1061 a.session_cost = 0.5;
1062 a.updated_at = chrono::Utc::now();
1063 let mut b = Session::new("model-b", "low", None);
1064 b.id = a.id.clone();
1065 SessionMeta::of(&a).apply(&mut b);
1066 assert_eq!(b.model, "model-a");
1067 assert_eq!(b.title, "t");
1068 assert_eq!(b.total_input_tokens, 7);
1069 assert_eq!(b.session_cost, 0.5);
1070 assert_eq!(b.updated_at, a.updated_at);
1071 }
1072
1073 #[test]
1074 fn env_roundtrip_and_secret_scrub() {
1075 let tmp = tempfile::TempDir::new().unwrap();
1076 let dir = tmp.path().join("sessions");
1077 let mut s = Session::new("m", "medium", None);
1078 s.env = Some(vec![
1080 ("PATH".into(), "/usr/bin".into()),
1081 ("GH_TOKEN".into(), "ghp_supersecret42".into()),
1082 ]);
1083 s.env_stripped = vec!["GH_TOKEN".into()];
1084 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1085 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1087 let env = loaded.env.as_ref().expect("env must round-trip");
1089 assert!(
1090 env.iter().any(|(k, v)| k == "PATH" && v == "/usr/bin"),
1091 "non-secret key must survive"
1092 );
1093 let snap_bytes =
1095 std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
1096 assert!(
1097 !snap_bytes.contains("ghp_supersecret42"),
1098 "secret VALUE must never appear on disk"
1099 );
1100 assert!(
1103 snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1104 "snapshot must carry the scrubbed env: {snap_bytes}"
1105 );
1106 assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1108 assert!(
1110 !env.iter().any(|(k, _)| k == "GH_TOKEN"),
1111 "secret key must be scrubbed from env pairs"
1112 );
1113 }
1114
1115 #[test]
1116 fn credential_url_values_never_reach_disk_whatever_the_key() {
1117 let tmp = tempfile::TempDir::new().unwrap();
1118 let dir = tmp.path().join("sessions");
1119 let mut s = Session::new("m", "medium", None);
1120 s.env = Some(vec![
1121 ("PATH".into(), "/usr/bin".into()),
1122 (
1123 "DATABASE_URL".into(),
1124 "postgres://app:hunter2@db/prod".into(),
1125 ),
1126 (
1127 "SENTRY_DSN".into(),
1128 "https://abc123dsnkey@o1.ingest.sentry.io/42".into(),
1129 ),
1130 ]);
1131 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1132 for entry in std::fs::read_dir(&dir).unwrap() {
1134 let path = entry.unwrap().path();
1135 let bytes = std::fs::read(&path).unwrap();
1136 let text = String::from_utf8_lossy(&bytes);
1137 assert!(
1138 !text.contains("hunter2"),
1139 "credential URL leaked to {}",
1140 path.display()
1141 );
1142 assert!(
1143 !text.contains("abc123dsnkey"),
1144 "DSN key leaked to {}",
1145 path.display()
1146 );
1147 }
1148 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1149 let env = loaded.env.as_ref().expect("env must round-trip");
1150 assert!(
1151 env.iter().any(|(k, _)| k == "PATH"),
1152 "non-secret key must survive"
1153 );
1154 assert!(!env
1155 .iter()
1156 .any(|(k, _)| k == "DATABASE_URL" || k == "SENTRY_DSN"));
1157 }
1158
1159 fn with_messages(n: usize) -> Session {
1160 let mut s = Session::new("checkpoint-test", "medium", None);
1161 for i in 0..n {
1162 push_message(&mut s, &format!("old {i}"));
1163 }
1164 s
1165 }
1166
1167 fn push_message(s: &mut Session, text: &str) {
1168 s.api_messages.push(std::sync::Arc::new(
1169 serde_json::json!({"role": "user", "content": text}),
1170 ));
1171 }
1172
1173 fn assert_saved(dir: &Path, expected: &Session) {
1174 let loaded = load_session_in_dir(dir, &expected.id).unwrap();
1175 let mut expected = expected.clone();
1176 expected.message_count = expected.api_messages.len();
1177 let mut loaded = loaded;
1179 loaded.message_count = loaded.api_messages.len();
1180 assert_eq!(
1181 serde_json::to_value(loaded).unwrap(),
1182 serde_json::to_value(expected).unwrap()
1183 );
1184 }
1185
1186 fn disk_generation(dir: &Path, id: &str) -> String {
1187 let snapshot: serde_json::Value =
1188 serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
1189 .unwrap();
1190 snapshot["_journal_generation"]
1191 .as_str()
1192 .unwrap()
1193 .to_string()
1194 }
1195
1196 #[test]
1197 fn bound_snapshot_is_session_shaped_and_storage_metadata_is_not_mirrored() {
1198 let s = with_messages(2);
1199 let json = snapshot_json(&s, Some("test-generation")).unwrap();
1200 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1201 assert_eq!(
1202 value
1203 .as_object_mut()
1204 .unwrap()
1205 .remove("_journal_generation")
1206 .unwrap(),
1207 "test-generation"
1208 );
1209 let mut expected = s.clone();
1210 expected.message_count = 2;
1211 assert_eq!(value, serde_json::to_value(&expected).unwrap());
1212 let back: Session = serde_json::from_str(&json).unwrap();
1213 assert_eq!(serde_json::to_value(back).unwrap(), value);
1214 value.as_object_mut().unwrap().remove("api_messages");
1215 serde_json::from_value::<SessionMeta>(value).unwrap();
1216 }
1217
1218 fn replacement_crash_case(mode: SessionPersistence, durable: bool, shorten: bool) {
1222 let tmp = tempfile::TempDir::new().unwrap();
1223 let dir = tmp.path().canonicalize().unwrap();
1224 let mut s = with_messages(2);
1225 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1226 for _ in 0..4 {
1227 push_message(&mut s, "obsolete journal message");
1228 }
1229 s.title = "obsolete metadata".into();
1230 s.updated_at += chrono::Duration::hours(1);
1231 s.session_cost = 99.0;
1232 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1233 let old = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1234 let old_generation = disk_generation(&dir, &s.id);
1235
1236 s.api_messages[0] =
1238 std::sync::Arc::new(serde_json::json!({"role":"user","content":"new head"}));
1239 if shorten {
1240 s.api_messages.truncate(3);
1241 }
1242 s.title = "candidate".into();
1243 s.updated_at -= chrono::Duration::hours(2); s.session_cost = 1.0;
1245 let receipt = if durable {
1246 save_session_durable_in_dir(&dir, &s, mode).unwrap()
1247 } else {
1248 save_session_in_dir(&dir, &s, mode).unwrap()
1249 };
1250 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1251 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1252 std::fs::write(journal_path(&dir, &s.id), old).unwrap();
1253 assert_saved(&dir, &s);
1254 assert!(journal_meta_tail(&dir, &s.id).is_none());
1255
1256 push_message(&mut s, "post-checkpoint");
1258 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1259 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1260 assert_saved(&dir, &s);
1261 push_message(&mut s, "new generation delta");
1262 assert_eq!(
1263 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1264 .unwrap()
1265 .mode,
1266 SaveMode::Append { messages: 1 }
1267 );
1268 assert_saved(&dir, &s);
1269 }
1270
1271 #[test]
1272 fn normal_replacements_ignore_stale_journal_after_snapshot_publication() {
1273 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1274 for shorten in [false, true] {
1275 replacement_crash_case(mode, false, shorten);
1276 }
1277 }
1278 }
1279
1280 #[cfg(unix)]
1281 #[test]
1282 fn durable_replacements_ignore_stale_journal_after_snapshot_publication() {
1283 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1284 for shorten in [false, true] {
1285 replacement_crash_case(mode, true, shorten);
1286 }
1287 }
1288 }
1289
1290 #[test]
1291 fn legacy_v1_reads_then_migrates_and_cannot_replay_over_replacement() {
1292 let tmp = tempfile::TempDir::new().unwrap();
1293 let dir = tmp.path().canonicalize().unwrap();
1294 let mut s = with_messages(1);
1295 std::fs::write(
1296 dir.join(format!("{}.json", s.id)),
1297 snapshot_json(&s, None).unwrap(),
1298 )
1299 .unwrap();
1300 let mut legacy = vec![JournalRecord::Open {
1301 v: 1,
1302 base: 1,
1303 generation: None,
1304 history_hash: None,
1305 }];
1306 for i in 1..5 {
1307 push_message(&mut s, "legacy delta");
1308 legacy.push(JournalRecord::Msg {
1309 v: 1,
1310 i,
1311 m: s.api_messages[i].as_ref().clone(),
1312 });
1313 }
1314 s.title = "legacy title".into();
1315 s.updated_at += chrono::Duration::hours(1);
1316 legacy.push(JournalRecord::Meta {
1317 v: 1,
1318 meta: Box::new(SessionMeta::of(&s)),
1319 history_hash: None,
1320 });
1321 let mut bytes = Vec::new();
1322 for record in legacy {
1323 serde_json::to_writer(&mut bytes, &record).unwrap();
1324 bytes.push(b'\n');
1325 }
1326 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1327 assert_saved(&dir, &s);
1328 assert_eq!(
1329 journal_meta_tail(&dir, &s.id).unwrap().message_count,
1330 Some(5)
1331 );
1332 let migrated = load_session_in_dir(&dir, &s.id).unwrap();
1333 assert_eq!(
1334 save_session_in_dir(&dir, &migrated, SessionPersistence::Journal)
1335 .unwrap()
1336 .mode,
1337 SaveMode::FullSnapshot
1338 );
1339 assert_saved(&dir, &s);
1340 assert!(!disk_generation(&dir, &s.id).is_empty());
1341
1342 s.api_messages.truncate(2);
1343 s.title = "new window".into();
1344 s.updated_at -= chrono::Duration::hours(2);
1345 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1346 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1347 assert_saved(&dir, &s);
1348 assert!(journal_meta_tail(&dir, &s.id).is_none());
1349 }
1350
1351 #[test]
1352 fn same_length_edits_in_snapshot_or_earlier_journal_message_resnapshot() {
1353 for appended in [false, true] {
1354 let tmp = tempfile::TempDir::new().unwrap();
1355 let dir = tmp.path().canonicalize().unwrap();
1356 let mut s = with_messages(3);
1357 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1358 let edited = if appended {
1359 push_message(&mut s, "editable");
1360 push_message(&mut s, "unchanged last message");
1361 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1362 3
1363 } else {
1364 0
1365 };
1366 let old_generation = disk_generation(&dir, &s.id);
1367 std::sync::Arc::make_mut(&mut s.api_messages[edited])["content"] = "replaced".into();
1368 assert_eq!(
1369 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1370 .unwrap()
1371 .mode,
1372 SaveMode::FullSnapshot
1373 );
1374 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1375 assert_saved(&dir, &s);
1376 }
1377 }
1378
1379 #[test]
1380 fn v2_never_replays_without_binding_or_across_record_versions() {
1381 let tmp = tempfile::TempDir::new().unwrap();
1382 let dir = tmp.path().canonicalize().unwrap();
1383 let mut s = with_messages(1);
1384 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1385 let first = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1386 push_message(&mut s, "v2 delta");
1387 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1388 let snapshot: Session =
1389 serde_json::from_slice(&std::fs::read(dir.join(format!("{}.json", s.id))).unwrap())
1390 .unwrap();
1391 std::fs::write(
1393 dir.join(format!("{}.json", s.id)),
1394 serde_json::to_vec(&snapshot).unwrap(),
1395 )
1396 .unwrap();
1397 assert_saved(&dir, &snapshot); save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1399 let bound_snapshot = s.clone();
1400 let mut journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1401 journal.extend_from_slice(
1402 b"{\"v\":1,\"k\":\"msg\",\"i\":2,\"m\":{\"content\":\"wrong version\"}}\n",
1403 );
1404 std::fs::write(journal_path(&dir, &s.id), &journal).unwrap();
1405 assert_saved(&dir, &bound_snapshot);
1406 assert_eq!(
1407 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1408 .unwrap()
1409 .mode,
1410 SaveMode::FullSnapshot
1411 );
1412 std::fs::write(journal_path(&dir, &s.id), first).unwrap();
1414 assert_saved(&dir, &s);
1415 }
1416
1417 #[test]
1418 fn json_saves_keep_binding_even_after_journal_deletion() {
1419 let tmp = tempfile::TempDir::new().unwrap();
1420 let dir = tmp.path().canonicalize().unwrap();
1421 let mut s = with_messages(2);
1422 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1423 push_message(&mut s, "old delta");
1424 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1425 let journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1426 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1427 let generation = disk_generation(&dir, &s.id);
1428 s.api_messages.truncate(2);
1429 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1430 assert_ne!(generation, disk_generation(&dir, &s.id));
1431 std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
1432 assert_saved(&dir, &s);
1433 }
1434
1435 #[cfg(unix)]
1436 #[test]
1437 fn durable_error_after_rename_keeps_candidate_and_retry_is_safe() {
1438 let tmp = tempfile::TempDir::new().unwrap();
1439 let dir = tmp.path().canonicalize().unwrap();
1440 let mut s = with_messages(2);
1441 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1442 push_message(&mut s, "old delta");
1443 push_message(&mut s, "more old history");
1444 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1445 let old_journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1446 let old_generation = disk_generation(&dir, &s.id);
1447 s.api_messages.truncate(2);
1448 s.title = "durable candidate".into();
1449 let blocker = dir.join(format!("{}.journal.tmp", s.id));
1451 std::fs::create_dir(&blocker).unwrap();
1452 let error = save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap_err();
1453 assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
1454 assert_ne!(old_generation, disk_generation(&dir, &s.id));
1455 assert_eq!(
1456 std::fs::read(journal_path(&dir, &s.id)).unwrap(),
1457 old_journal
1458 );
1459 assert_saved(&dir, &s); std::fs::remove_dir(blocker).unwrap();
1461 save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1462 assert_saved(&dir, &s);
1463 }
1464
1465 #[cfg(unix)]
1468 mod private_modes {
1469 use super::*;
1470 use crate::core::private_fs::test_support::UmaskGuard;
1471 use serial_test::serial;
1472 use std::os::unix::fs::PermissionsExt;
1473
1474 fn mode_of(path: &Path) -> u32 {
1475 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1476 }
1477
1478 #[test]
1479 #[serial(umask)]
1480 fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
1481 let _umask = UmaskGuard::set(0);
1482 let tmp = tempfile::TempDir::new().unwrap();
1483 let dir = tmp.path().join("sessions");
1484 let mut s = Session::new("m", "medium", None);
1485 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1486 s.api_messages.push(std::sync::Arc::new(
1487 serde_json::json!({"role":"user","content":"x"}),
1488 ));
1489 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1490
1491 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1492 assert_eq!(
1493 mode_of(&dir.join(format!("{}.json", s.id))),
1494 0o600,
1495 "snapshot must be 0600"
1496 );
1497 assert_eq!(
1498 mode_of(&journal_path(&dir, &s.id)),
1499 0o600,
1500 "journal must be 0600"
1501 );
1502 }
1503
1504 #[test]
1505 #[serial(umask)]
1506 fn durable_files_are_private_in_both_modes_and_repair_leaf_modes() {
1507 let _umask = UmaskGuard::set(0);
1508 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1509 let tmp = tempfile::TempDir::new().unwrap();
1510 let dir = tmp
1511 .path()
1512 .canonicalize()
1513 .unwrap()
1514 .join("new/profile/sessions");
1515 let s = with_messages(2);
1516 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1517 assert_eq!(mode_of(dir.parent().unwrap()), 0o700);
1518 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1519 let snapshot = dir.join(format!("{}.json", s.id));
1520 std::fs::set_permissions(&snapshot, std::fs::Permissions::from_mode(0o666))
1521 .unwrap();
1522 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1523 assert_eq!(mode_of(&dir), 0o700);
1524 assert_eq!(mode_of(&snapshot), 0o600);
1525 if mode == SessionPersistence::Journal {
1526 assert_eq!(mode_of(&journal_path(&dir, &s.id)), 0o600);
1527 }
1528 assert_saved(&dir, &s);
1529 }
1530 }
1531
1532 #[test]
1533 fn durable_saves_refuse_artifact_and_ancestor_symlinks_in_both_modes() {
1534 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1535 for extension in ["json", "journal"] {
1536 let tmp = tempfile::TempDir::new().unwrap();
1537 let root = tmp.path().canonicalize().unwrap();
1538 let dir = root.join("sessions");
1539 std::fs::create_dir(&dir).unwrap();
1540 let victim = root.join("victim");
1541 std::fs::write(&victim, "original").unwrap();
1542 let s = with_messages(1);
1543 std::os::unix::fs::symlink(
1544 &victim,
1545 dir.join(format!("{}.{}", s.id, extension)),
1546 )
1547 .unwrap();
1548 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1549 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1550 }
1551 let tmp = tempfile::TempDir::new().unwrap();
1552 let root = tmp.path().canonicalize().unwrap();
1553 let victim_dir = root.join("victim-dir");
1554 std::fs::create_dir(&victim_dir).unwrap();
1555 let link = root.join("link");
1556 std::os::unix::fs::symlink(&victim_dir, &link).unwrap();
1557 let s = with_messages(1);
1558 for dir in [link.clone(), link.join("profile/sessions")] {
1559 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1560 }
1561 assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
1562 }
1563 }
1564
1565 #[test]
1566 fn journal_append_refuses_symlink_target() {
1567 let tmp = tempfile::TempDir::new().unwrap();
1568 let dir = tmp.path().join("sessions");
1569 std::fs::create_dir_all(&dir).unwrap();
1570 let mut s = Session::new("m", "medium", None);
1571 let victim = tmp.path().join("victim");
1572 std::fs::write(&victim, "original").unwrap();
1573 std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
1574 s.api_messages.push(std::sync::Arc::new(
1576 serde_json::json!({"role":"user","content":"x"}),
1577 ));
1578 let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
1579 assert!(res.is_err(), "journal write onto a symlink must fail");
1580 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1581 }
1582 }
1583}
1584
1585#[cfg(test)]
1591mod save_cost_bench {
1592 use super::*;
1593 use std::sync::Arc;
1594 use std::time::{Duration, Instant};
1595
1596 fn session(bytes: usize) -> Session {
1597 let mut s = Session::new("claude-sonnet-4-5", "low", None);
1598 let chunk = "x".repeat(4000);
1601 let n = bytes / 4096;
1602 s.api_messages = (0..n)
1603 .map(|i| {
1604 let role = if i % 2 == 0 { "user" } else { "assistant" };
1605 Arc::new(serde_json::json!({"role": role, "content": format!("{i} {chunk}")}))
1606 })
1607 .collect();
1608 s
1609 }
1610
1611 fn pct(sorted: &[Duration], p: f64) -> Duration {
1612 sorted[((sorted.len() - 1) as f64 * p).round() as usize]
1613 }
1614
1615 #[test]
1616 #[ignore = "benchmark: needs SYNAPS_SAVE_BENCH_DIR on a real disk"]
1617 fn save_cost_bench() {
1618 let Ok(root) = std::env::var("SYNAPS_SAVE_BENCH_DIR") else {
1619 eprintln!("SYNAPS_SAVE_BENCH_DIR not set; skipping");
1620 return;
1621 };
1622 let dir = tempfile::tempdir_in(root).unwrap();
1623 for (label, bytes) in [("200 KB", 200_000), ("2.3 MB", 2_300_000), ("10 MB", 10_000_000)] {
1624 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1625 let mut s = session(bytes);
1626 save_session_in_dir(dir.path(), &s, mode).unwrap(); let mut samples = Vec::new();
1628 for round in 0..40 {
1629 for role in ["assistant", "user"] {
1631 s.api_messages.push(Arc::new(
1632 serde_json::json!({"role": role, "content": format!("round {round}")}),
1633 ));
1634 }
1635 let t = Instant::now();
1636 save_session_in_dir(dir.path(), &s, mode).unwrap();
1637 samples.push(t.elapsed());
1638 }
1639 samples.sort();
1640 eprintln!(
1641 "{label:>7} {mode:?}: p50 {:?} p95 {:?} max {:?}",
1642 pct(&samples, 0.5),
1643 pct(&samples, 0.95),
1644 samples[samples.len() - 1]
1645 );
1646 let _ = delete_session_files_in_dir(dir.path(), &s.id);
1647 }
1648 }
1649 }
1650}