1use crate::core::session::Session;
22use crate::core::stream_types::SharedMessage;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28pub const JOURNAL_SCHEMA_VERSION: u8 = 1;
30
31pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
33
34pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
37
38const META_TAIL_WINDOW: u64 = 64 * 1024;
40
41pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; #[cfg(unix)]
64type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
65
66#[cfg(unix)]
69fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
70 match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
71 Ok(handle) => Ok(Some(handle)),
72 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
73 Err(e) => Err(e),
74 }
75}
76
77#[cfg(unix)]
79fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
80 crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
81}
82
83#[cfg(unix)]
86fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
87 match handle.open_file(&[name.to_string()]) {
88 Ok(file) => Ok(Some(file)),
89 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
90 Err(e) => Err(e),
91 }
92}
93
94#[cfg(unix)]
96pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
97 let Some(handle) = open_sessions_dir(dir)? else {
98 return Ok(None);
99 };
100 open_artifact(&handle, name)
101}
102
103#[cfg(not(unix))]
106struct SessionsDirHandle {
107 dir: std::path::PathBuf,
108}
109
110#[cfg(not(unix))]
111fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
112 if !dir.exists() {
113 return Ok(None);
114 }
115 Ok(Some(SessionsDirHandle {
116 dir: dir.to_path_buf(),
117 }))
118}
119
120#[cfg(not(unix))]
121fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
122 crate::core::private_fs::ensure_private_dir(dir)?;
123 Ok(SessionsDirHandle {
124 dir: dir.to_path_buf(),
125 })
126}
127
128#[cfg(not(unix))]
129impl SessionsDirHandle {
130 fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
131 crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
132 .map_err(std::io::Error::other)
133 }
134 fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
135 crate::core::private_fs::open_private_append(&self.dir.join(name))
136 .map_err(std::io::Error::other)
137 }
138 fn remove_file(&self, name: &str) -> std::io::Result<()> {
139 std::fs::remove_file(self.dir.join(name))
140 }
141}
142
143#[cfg(not(unix))]
144fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
145 let path = handle.dir.join(name);
146 match std::fs::symlink_metadata(&path) {
147 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
148 Ok(meta) if meta.file_type().is_symlink() => {
149 return Err(std::io::Error::other(format!(
150 "refusing symlinked session artifact {name:?}"
151 )))
152 }
153 _ => {}
154 }
155 match std::fs::File::open(&path) {
156 Ok(f) => Ok(Some(f)),
157 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
158 Err(e) => Err(e),
159 }
160}
161
162#[cfg(not(unix))]
163pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
164 let Some(handle) = open_sessions_dir(dir)? else {
165 return Ok(None);
166 };
167 open_artifact(&handle, name)
168}
169
170fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
172 use std::io::Read;
173 let Some(file) = open_artifact(handle, name)? else {
174 return Ok(None);
175 };
176 let mut buf = Vec::new();
177 file.take(MAX_PERSISTED_READ_BYTES + 1)
178 .read_to_end(&mut buf)?;
179 if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
180 return Err(std::io::Error::other(format!(
181 "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
182 )));
183 }
184 Ok(Some(buf))
185}
186
187#[derive(Debug, Clone)]
189pub struct SessionDirEntry {
190 pub name: String,
191 pub mtime: Option<std::time::SystemTime>,
192 pub byte_len: u64,
195}
196
197pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
203 #[cfg(unix)]
204 {
205 let Some(handle) = open_sessions_dir(dir)? else {
206 return Ok(Vec::new());
207 };
208 Ok(handle
209 .entries()?
210 .into_iter()
211 .filter(|e| e.is_file)
212 .map(|e| SessionDirEntry {
213 name: e.name,
214 mtime: e.mtime_unix_ms.map(|ms| {
215 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
216 }),
217 byte_len: e.byte_len,
218 })
219 .collect())
220 }
221 #[cfg(not(unix))]
222 {
223 if !dir.exists() {
224 return Ok(Vec::new());
225 }
226 let mut out = Vec::new();
227 for entry in std::fs::read_dir(dir)? {
228 let entry = entry?;
229 if !entry.path().is_file() {
230 continue;
231 }
232 let Ok(name) = entry.file_name().into_string() else {
233 continue;
234 };
235 let byte_len = entry
236 .metadata()
237 .map(|metadata| metadata.len())
238 .unwrap_or_default();
239 out.push(SessionDirEntry {
240 name,
241 mtime: entry.metadata().and_then(|m| m.modified()).ok(),
242 byte_len,
243 });
244 }
245 Ok(out)
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
255pub enum SessionPersistence {
256 #[default]
257 Json,
258 Journal,
259}
260
261impl SessionPersistence {
262 pub fn parse(val: &str) -> Option<Self> {
265 match val.trim() {
266 "json" => Some(Self::Json),
267 "journal" => Some(Self::Journal),
268 _ => None,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum SaveMode {
276 FullSnapshot,
279 Append { messages: usize },
281}
282
283#[derive(Debug, Clone, Copy)]
285pub struct SaveReceipt {
286 pub mode: SaveMode,
287 pub bytes_written: u64,
288}
289
290#[derive(Debug, Clone)]
293pub struct JournalMetaTail {
294 pub updated_at: DateTime<Utc>,
295 pub session_cost: f64,
296 pub message_count: Option<usize>,
298}
299
300pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
303 dir.join(format!("{id}.journal"))
304}
305
306pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
308 journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
309}
310
311#[derive(Serialize, Deserialize)]
314#[serde(tag = "k")]
315enum JournalRecord {
316 #[serde(rename = "open")]
318 Open { v: u8, base: usize },
319 #[serde(rename = "msg")]
321 Msg {
322 v: u8,
323 i: usize,
324 m: serde_json::Value,
325 },
326 #[serde(rename = "meta")]
328 Meta { v: u8, meta: Box<SessionMeta> },
329}
330
331#[derive(Clone, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336struct SessionMeta {
337 id: String,
338 title: String,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 name: Option<String>,
341 model: String,
342 thinking_level: String,
343 system_prompt: Option<String>,
344 created_at: DateTime<Utc>,
345 updated_at: DateTime<Utc>,
346 total_input_tokens: u64,
347 total_output_tokens: u64,
348 session_cost: f64,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 message_count: Option<usize>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 abort_context: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 parent_session: Option<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 compacted_into: Option<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 prompt_provenance: Option<crate::prompt::PromptProvenance>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 compaction: Option<crate::core::compaction::CompactionRecord>,
361}
362
363impl SessionMeta {
364 fn of(s: &Session) -> Self {
365 Self {
366 id: s.id.clone(),
367 title: s.title.clone(),
368 name: s.name.clone(),
369 model: s.model.clone(),
370 thinking_level: s.thinking_level.clone(),
371 system_prompt: s.system_prompt.clone(),
372 created_at: s.created_at,
373 updated_at: s.updated_at,
374 total_input_tokens: s.total_input_tokens,
375 total_output_tokens: s.total_output_tokens,
376 session_cost: s.session_cost,
377 message_count: Some(s.api_messages.len()),
378 abort_context: s.abort_context.clone(),
379 parent_session: s.parent_session.clone(),
380 compacted_into: s.compacted_into.clone(),
381 prompt_provenance: s.prompt_provenance.clone(),
382 compaction: s.compaction.clone(),
383 }
384 }
385
386 fn apply(self, s: &mut Session) {
388 s.title = self.title;
389 s.name = self.name;
390 s.model = self.model;
391 s.thinking_level = self.thinking_level;
392 s.system_prompt = self.system_prompt;
393 s.created_at = self.created_at;
394 s.updated_at = self.updated_at;
395 s.total_input_tokens = self.total_input_tokens;
396 s.total_output_tokens = self.total_output_tokens;
397 s.session_cost = self.session_cost;
398 s.abort_context = self.abort_context;
399 s.parent_session = self.parent_session;
400 s.compacted_into = self.compacted_into;
401 s.prompt_provenance = self.prompt_provenance;
402 s.compaction = self.compaction;
403 }
404}
405
406struct JournalState {
411 durable_len: usize,
412 last_msg: Option<(usize, serde_json::Value)>,
413 bytes: u64,
414 clean: bool,
415}
416
417fn read_journal_state(
418 handle: &SessionsDirHandle,
419 id: &str,
420) -> std::io::Result<Option<JournalState>> {
421 let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
422 return Ok(None);
423 };
424 let bytes = raw.len() as u64;
425 let text = String::from_utf8_lossy(&raw);
426 let mut lines = text.lines();
427
428 let Some(first) = lines.next() else {
429 return Ok(Some(JournalState {
430 durable_len: 0,
431 last_msg: None,
432 bytes,
433 clean: false,
434 }));
435 };
436 let base = match serde_json::from_str::<JournalRecord>(first) {
437 Ok(JournalRecord::Open { v, base }) if v == JOURNAL_SCHEMA_VERSION => base,
442 _ => {
443 return Ok(Some(JournalState {
446 durable_len: 0,
447 last_msg: None,
448 bytes,
449 clean: false,
450 }));
451 }
452 };
453
454 let mut durable_len = base;
455 let mut last_msg = None;
456 let mut clean = true;
457 for line in lines {
458 match serde_json::from_str::<JournalRecord>(line) {
459 Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
462 if v != JOURNAL_SCHEMA_VERSION =>
463 {
464 clean = false;
465 break;
466 }
467 Ok(JournalRecord::Msg { i, m, .. }) => {
468 if i == durable_len {
469 durable_len += 1;
470 last_msg = Some((i, m));
471 } else if i > durable_len {
472 clean = false; break;
474 }
475 }
477 Ok(JournalRecord::Meta { .. }) => {}
478 Ok(JournalRecord::Open { .. }) | Err(_) => {
479 clean = false; break;
481 }
482 }
483 }
484 Ok(Some(JournalState {
485 durable_len,
486 last_msg,
487 bytes,
488 clean,
489 }))
490}
491
492pub fn save_session_in_dir(
505 dir: &Path,
506 session: &Session,
507 mode: SessionPersistence,
508) -> std::io::Result<SaveReceipt> {
509 let handle = create_sessions_dir(dir)?;
512 match mode {
516 SessionPersistence::Json => {
517 let json = snapshot_json(session)?;
518 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
519 remove_artifact_if_exists(&handle, &format!("{}.journal", session.id))?;
522 Ok(SaveReceipt {
523 mode: SaveMode::FullSnapshot,
524 bytes_written: json.len() as u64,
525 })
526 }
527 SessionPersistence::Journal => save_journal_mode(&handle, session),
528 }
529}
530
531fn save_journal_mode(
532 handle: &SessionsDirHandle,
533 session: &Session,
534) -> std::io::Result<SaveReceipt> {
535 let journal_name = format!("{}.journal", session.id);
536
537 let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
538 let state = match read_journal_state(handle, &session.id)? {
539 Some(state) if snapshot.is_some() => state,
540 _ => return full_snapshot_reset(handle, session),
543 };
544
545 let rewrite_needed = !state.clean
548 || session.api_messages.len() < state.durable_len
549 || state.last_msg.as_ref().is_some_and(|(i, m)| {
550 session
551 .api_messages
552 .get(*i)
553 .map_or(true, |live| live.as_ref() != m)
554 });
555 if rewrite_needed {
556 return full_snapshot_reset(handle, session);
557 }
558
559 let mut buf = Vec::new();
561 for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
562 let record = JournalRecord::Msg {
563 v: JOURNAL_SCHEMA_VERSION,
564 i: state.durable_len + offset,
565 m: msg.as_ref().clone(),
566 };
567 serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
568 buf.push(b'\n');
569 }
570 let meta = JournalRecord::Meta {
571 v: JOURNAL_SCHEMA_VERSION,
572 meta: Box::new(SessionMeta::of(session)),
573 };
574 serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
575 buf.push(b'\n');
576
577 let appended = session.api_messages.len() - state.durable_len;
578 let mut file = handle.append_file(&journal_name)?;
579 file.write_all(&buf)?;
580 file.sync_data()?;
581 drop(file);
582
583 let journal_bytes = state.bytes + buf.len() as u64;
587 let snapshot_bytes = snapshot
588 .and_then(|f| f.metadata().ok())
589 .map(|m| m.len())
590 .unwrap_or(0);
591 if snapshot_due(journal_bytes, snapshot_bytes) {
592 let reset = full_snapshot_reset(handle, session)?;
593 return Ok(SaveReceipt {
594 mode: SaveMode::FullSnapshot,
595 bytes_written: buf.len() as u64 + reset.bytes_written,
596 });
597 }
598
599 Ok(SaveReceipt {
600 mode: SaveMode::Append { messages: appended },
601 bytes_written: buf.len() as u64,
602 })
603}
604
605#[derive(Serialize)]
615struct SessionSnapshotRef<'a> {
616 id: &'a str,
617 title: &'a str,
618 #[serde(skip_serializing_if = "Option::is_none")]
619 name: &'a Option<String>,
620 model: &'a str,
621 thinking_level: &'a str,
622 system_prompt: &'a Option<String>,
623 created_at: &'a DateTime<Utc>,
624 updated_at: &'a DateTime<Utc>,
625 total_input_tokens: u64,
626 total_output_tokens: u64,
627 session_cost: f64,
628 message_count: usize,
629 api_messages: &'a [SharedMessage],
630 #[serde(skip_serializing_if = "Option::is_none")]
631 abort_context: &'a Option<String>,
632 #[serde(skip_serializing_if = "Option::is_none")]
633 parent_session: &'a Option<String>,
634 #[serde(skip_serializing_if = "Option::is_none")]
635 compacted_into: &'a Option<String>,
636 #[serde(skip_serializing_if = "Option::is_none")]
637 prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
638 #[serde(skip_serializing_if = "Option::is_none")]
639 compaction: &'a Option<crate::core::compaction::CompactionRecord>,
640}
641
642impl<'a> SessionSnapshotRef<'a> {
643 fn of(s: &'a Session) -> Self {
644 Self {
645 id: &s.id,
646 title: &s.title,
647 name: &s.name,
648 model: &s.model,
649 thinking_level: &s.thinking_level,
650 system_prompt: &s.system_prompt,
651 created_at: &s.created_at,
652 updated_at: &s.updated_at,
653 total_input_tokens: s.total_input_tokens,
654 total_output_tokens: s.total_output_tokens,
655 session_cost: s.session_cost,
656 message_count: s.api_messages.len(),
657 api_messages: &s.api_messages,
658 abort_context: &s.abort_context,
659 parent_session: &s.parent_session,
660 compacted_into: &s.compacted_into,
661 prompt_provenance: &s.prompt_provenance,
662 compaction: &s.compaction,
663 }
664 }
665}
666
667fn snapshot_json(session: &Session) -> std::io::Result<String> {
669 serde_json::to_string(&SessionSnapshotRef::of(session)).map_err(std::io::Error::other)
670}
671
672fn full_snapshot_reset(
673 handle: &SessionsDirHandle,
674 session: &Session,
675) -> std::io::Result<SaveReceipt> {
676 let json = snapshot_json(session)?;
677 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
678
679 let open = JournalRecord::Open {
680 v: JOURNAL_SCHEMA_VERSION,
681 base: session.api_messages.len(),
682 };
683 let mut line = serde_json::to_vec(&open).map_err(std::io::Error::other)?;
684 line.push(b'\n');
685 handle.write_atomic(&format!("{}.journal", session.id), &line)?;
686
687 Ok(SaveReceipt {
688 mode: SaveMode::FullSnapshot,
689 bytes_written: json.len() as u64 + line.len() as u64,
690 })
691}
692
693pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
700 let handle = open_sessions_dir(dir)?.ok_or_else(|| {
703 std::io::Error::new(
704 std::io::ErrorKind::NotFound,
705 format!("no sessions directory for '{id}'"),
706 )
707 })?;
708 let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
709 std::io::Error::new(
710 std::io::ErrorKind::NotFound,
711 format!("no session snapshot for '{id}'"),
712 )
713 })?;
714 let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
715
716 let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
717 return Ok(session);
718 };
719 let text = String::from_utf8_lossy(&raw);
720 let mut lines = text.lines();
721 match lines.next().map(serde_json::from_str::<JournalRecord>) {
725 Some(Ok(JournalRecord::Open { v, .. })) if v == JOURNAL_SCHEMA_VERSION => {}
726 _ => return Ok(session),
727 }
728 for line in lines {
729 match serde_json::from_str::<JournalRecord>(line) {
730 Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
732 if v != JOURNAL_SCHEMA_VERSION =>
733 {
734 break;
735 }
736 Ok(JournalRecord::Msg { i, m, .. }) => {
737 if i == session.api_messages.len() {
738 session.api_messages.push(std::sync::Arc::new(m));
739 } else if i > session.api_messages.len() {
740 break; }
742 }
744 Ok(JournalRecord::Meta { meta, .. }) => {
745 if meta.id == session.id && meta.updated_at >= session.updated_at {
746 meta.apply(&mut session);
747 }
748 }
749 Ok(JournalRecord::Open { .. }) | Err(_) => break, }
751 }
752 Ok(session)
753}
754
755pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
760 use std::io::{Read, Seek, SeekFrom};
761 let mut file = confined_open(dir, &format!("{id}.journal")).ok()??;
762 let len = file.metadata().ok()?.len();
763 let start = len.saturating_sub(META_TAIL_WINDOW);
764 file.seek(SeekFrom::Start(start)).ok()?;
765 let mut tail = String::new();
766 file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
767
768 let mut freshest = None;
769 for line in tail.lines() {
770 if let Ok(JournalRecord::Meta { v, meta }) = serde_json::from_str::<JournalRecord>(line) {
771 if v == JOURNAL_SCHEMA_VERSION {
772 freshest = Some(JournalMetaTail {
773 updated_at: meta.updated_at,
774 session_cost: meta.session_cost,
775 message_count: meta.message_count,
776 });
777 }
778 }
779 }
780 freshest
781}
782
783pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
788 let Some(handle) = open_sessions_dir(dir)? else {
789 return Ok(()); };
791 remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
792 remove_artifact_if_exists(&handle, &format!("{id}.journal"))
793}
794
795fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
796 match handle.remove_file(name) {
797 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
798 other => other,
799 }
800}
801
802#[cfg(test)]
806pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
807 let handle = create_sessions_dir(dir)?;
808 handle.write_atomic(&format!("{id}.json"), json)
809}
810
811#[cfg(test)]
814mod tests {
815 use super::*;
816
817 #[test]
822 fn snapshot_json_matches_session_schema() {
823 let mut s = Session::new("model-x", "medium", Some("prompt"));
824 s.name = Some("named".into());
825 s.abort_context = Some("ctx".into());
826 s.parent_session = Some("parent".into());
827 s.compacted_into = Some("child".into());
828 s.api_messages.push(std::sync::Arc::new(
829 serde_json::json!({"role": "user", "content": "hi"}),
830 ));
831 s.message_count = 0; let mut expected = s.clone();
834 expected.message_count = expected.api_messages.len();
835 let expected = serde_json::to_string(&expected).unwrap();
836 assert_eq!(snapshot_json(&s).unwrap(), expected);
837 let back: Session = serde_json::from_str(&snapshot_json(&s).unwrap()).unwrap();
838 assert_eq!(back.message_count, 1);
839 let s = Session::new("model-x", "medium", None);
841 let mut expected = s.clone();
842 expected.message_count = 0;
843 assert_eq!(
844 snapshot_json(&s).unwrap(),
845 serde_json::to_string(&expected).unwrap()
846 );
847 }
848
849 #[test]
850 fn session_meta_stays_in_sync_with_session_schema() {
851 let mut s = Session::new("model-x", "medium", Some("prompt"));
852 s.name = Some("named".into());
853 s.abort_context = Some("ctx".into());
854 s.parent_session = Some("parent".into());
855 s.compacted_into = Some("child".into());
856 let mut value = serde_json::to_value(&s).unwrap();
857 value.as_object_mut().unwrap().remove("api_messages");
858 let meta: SessionMeta = serde_json::from_value(value)
859 .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
860 assert_eq!(meta.id, s.id);
861 assert_eq!(meta.name.as_deref(), Some("named"));
862 }
863
864 #[test]
865 fn meta_roundtrip_applies_every_field() {
866 let mut a = Session::new("model-a", "high", Some("sys"));
867 a.title = "t".into();
868 a.total_input_tokens = 7;
869 a.session_cost = 0.5;
870 a.updated_at = chrono::Utc::now();
871 let mut b = Session::new("model-b", "low", None);
872 b.id = a.id.clone();
873 SessionMeta::of(&a).apply(&mut b);
874 assert_eq!(b.model, "model-a");
875 assert_eq!(b.title, "t");
876 assert_eq!(b.total_input_tokens, 7);
877 assert_eq!(b.session_cost, 0.5);
878 assert_eq!(b.updated_at, a.updated_at);
879 }
880
881 #[cfg(unix)]
884 mod private_modes {
885 use super::*;
886 use crate::core::private_fs::test_support::UmaskGuard;
887 use serial_test::serial;
888 use std::os::unix::fs::PermissionsExt;
889
890 fn mode_of(path: &Path) -> u32 {
891 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
892 }
893
894 #[test]
895 #[serial(umask)]
896 fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
897 let _umask = UmaskGuard::set(0);
898 let tmp = tempfile::TempDir::new().unwrap();
899 let dir = tmp.path().join("sessions");
900 let mut s = Session::new("m", "medium", None);
901 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
902 s.api_messages.push(std::sync::Arc::new(
903 serde_json::json!({"role":"user","content":"x"}),
904 ));
905 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
906
907 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
908 assert_eq!(
909 mode_of(&dir.join(format!("{}.json", s.id))),
910 0o600,
911 "snapshot must be 0600"
912 );
913 assert_eq!(
914 mode_of(&journal_path(&dir, &s.id)),
915 0o600,
916 "journal must be 0600"
917 );
918 }
919
920 #[test]
921 fn journal_append_refuses_symlink_target() {
922 let tmp = tempfile::TempDir::new().unwrap();
923 let dir = tmp.path().join("sessions");
924 std::fs::create_dir_all(&dir).unwrap();
925 let mut s = Session::new("m", "medium", None);
926 let victim = tmp.path().join("victim");
927 std::fs::write(&victim, "original").unwrap();
928 std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
929 s.api_messages.push(std::sync::Arc::new(
931 serde_json::json!({"role":"user","content":"x"}),
932 ));
933 let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
934 assert!(res.is_err(), "journal write onto a symlink must fail");
935 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
936 }
937 }
938}