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<()> {
978 let Some(handle) = open_sessions_dir(dir)? else {
979 return Ok(()); };
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#[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#[cfg(test)]
1004mod tests {
1005 use super::*;
1006
1007 #[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; 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 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 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 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1085 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 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 assert!(
1101 snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1102 "snapshot must carry the scrubbed env: {snap_bytes}"
1103 );
1104 assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1106 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 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 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 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 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); 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 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 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); 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 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 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); 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 #[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 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}