Skip to main content

par_term/session/
storage.rs

1//! File I/O for session persistence
2//!
3//! Sessions are stored in `~/.config/par-term/last_session.yaml`
4
5use super::SessionState;
6use anyhow::{Context, Result};
7use par_term_config::Config;
8use std::path::PathBuf;
9
10/// Get the path to the session state file
11pub fn session_path() -> PathBuf {
12    Config::config_dir().join("last_session.yaml")
13}
14
15/// Save session state to the default location
16pub fn save_session(state: &SessionState) -> Result<()> {
17    save_session_to(state, session_path())
18}
19
20/// Save session state to a specific file
21pub fn save_session_to(state: &SessionState, path: PathBuf) -> Result<()> {
22    // QA-010/SEC-016/SEC-021: `save_yaml_atomic` stages the write in a sibling
23    // temp file created with mode 0600 (the session file records working
24    // directories, which reveal filesystem layout), fsyncs it, then renames it
25    // over the target. Session save runs at shutdown, when an abrupt kill is
26    // most likely, and a truncated file reads back as "no saved session".
27    crate::atomic_save::save_yaml_atomic(&path, state)
28        .with_context(|| format!("Failed to write session state to {:?}", path))?;
29
30    log::info!(
31        "Saved session state ({} windows) to {:?}",
32        state.windows.len(),
33        path
34    );
35    Ok(())
36}
37
38/// Load session state from the default location
39///
40/// Returns `None` if the file doesn't exist or is empty.
41/// Returns an error if the file exists but is corrupt.
42pub fn load_session() -> Result<Option<SessionState>> {
43    load_session_from(session_path())
44}
45
46/// Load session state from a specific file
47pub fn load_session_from(path: PathBuf) -> Result<Option<SessionState>> {
48    if !path.exists() {
49        return Ok(None);
50    }
51
52    let contents = std::fs::read_to_string(&path)
53        .with_context(|| format!("Failed to read session state from {:?}", path))?;
54
55    if contents.trim().is_empty() {
56        return Ok(None);
57    }
58
59    // SEC-011: Trust boundary note — this file is loaded from the user's own config
60    // directory (~/.config/par-term/last_session.yaml) with 0o600 permissions.
61    // The trust boundary is the user themselves: only the user (and root) can write
62    // this file, so the deserialization is equivalent to loading any other user config.
63    // `serde_yaml_ng` does not support arbitrary code execution during deserialization,
64    // so there is no remote-code-execution risk from crafted YAML. The worst a modified
65    // file can do is cause a deserialization error (caught above) or produce unexpected
66    // session state.
67    //
68    // Schema validation: serde_yaml_ng validates field types at deserialization time.
69    // Any field with an unexpected type or value outside the Rust type constraints will
70    // return an Err, which is propagated as an anyhow error above.
71    let state: SessionState = serde_yaml_ng::from_str(&contents)
72        .with_context(|| format!("Failed to parse session state from {:?}", path))?;
73
74    log::info!(
75        "Loaded session state ({} windows) from {:?}",
76        state.windows.len(),
77        path
78    );
79    Ok(Some(state))
80}
81
82/// Remove the session state file (e.g., after successful restore)
83pub fn clear_session() -> Result<()> {
84    let path = session_path();
85    if path.exists() {
86        std::fs::remove_file(&path)
87            .with_context(|| format!("Failed to remove session state file {:?}", path))?;
88    }
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::session::{SessionState, SessionTab, SessionWindow};
96    use par_term_config::snapshot_types::TabSnapshot;
97    use tempfile::tempdir;
98
99    fn sample_session() -> SessionState {
100        SessionState {
101            saved_at: "2025-01-01T00:00:00Z".to_string(),
102            windows: vec![SessionWindow {
103                position: (100, 200),
104                size: (800, 600),
105                tabs: vec![SessionTab {
106                    snapshot: TabSnapshot {
107                        cwd: Some("/home/user/work".to_string()),
108                        title: "work".to_string(),
109                        custom_color: None,
110                        user_title: None,
111                        custom_icon: None,
112                    },
113                    pane_layout: None,
114                }],
115                active_tab_index: 0,
116                tmux_session_name: None,
117            }],
118        }
119    }
120
121    #[test]
122    fn test_load_nonexistent_file() {
123        let temp = tempdir().unwrap();
124        let path = temp.path().join("nonexistent.yaml");
125        let result = load_session_from(path).unwrap();
126        assert!(result.is_none());
127    }
128
129    #[test]
130    fn test_load_empty_file() {
131        let temp = tempdir().unwrap();
132        let path = temp.path().join("empty.yaml");
133        std::fs::write(&path, "").unwrap();
134        let result = load_session_from(path).unwrap();
135        assert!(result.is_none());
136    }
137
138    #[test]
139    fn test_load_corrupt_file() {
140        let temp = tempdir().unwrap();
141        let path = temp.path().join("corrupt.yaml");
142        std::fs::write(&path, "not: valid: yaml: [[[").unwrap();
143        let result = load_session_from(path);
144        assert!(result.is_err());
145    }
146
147    #[test]
148    fn test_save_and_load_roundtrip() {
149        let temp = tempdir().unwrap();
150        let path = temp.path().join("session.yaml");
151
152        let state = sample_session();
153        save_session_to(&state, path.clone()).unwrap();
154
155        let loaded = load_session_from(path).unwrap().unwrap();
156        assert_eq!(loaded.windows.len(), 1);
157        assert_eq!(loaded.windows[0].position, (100, 200));
158        assert_eq!(loaded.windows[0].size, (800, 600));
159        assert_eq!(loaded.windows[0].tabs.len(), 1);
160        assert_eq!(
161            loaded.windows[0].tabs[0].snapshot.cwd,
162            Some("/home/user/work".to_string())
163        );
164        assert_eq!(loaded.windows[0].tabs[0].snapshot.title, "work");
165    }
166
167    #[test]
168    fn test_roundtrip_preserves_custom_tab_properties() {
169        let temp = tempdir().unwrap();
170        let path = temp.path().join("session.yaml");
171
172        let state = SessionState {
173            saved_at: "2025-01-01T00:00:00Z".to_string(),
174            windows: vec![SessionWindow {
175                position: (0, 0),
176                size: (1920, 1080),
177                tabs: vec![
178                    SessionTab {
179                        snapshot: TabSnapshot {
180                            cwd: Some("/home/user".to_string()),
181                            title: "My Custom Tab".to_string(),
182                            custom_color: Some([255, 128, 0]),
183                            user_title: Some("My Custom Tab".to_string()),
184                            custom_icon: Some("🔥".to_string()),
185                        },
186                        pane_layout: None,
187                    },
188                    SessionTab {
189                        snapshot: TabSnapshot {
190                            cwd: Some("/tmp".to_string()),
191                            title: "Tab 2".to_string(),
192                            custom_color: None,
193                            user_title: None,
194                            custom_icon: Some("📁".to_string()),
195                        },
196                        pane_layout: None,
197                    },
198                    SessionTab {
199                        snapshot: TabSnapshot {
200                            cwd: None,
201                            title: "Colored Only".to_string(),
202                            custom_color: Some([0, 200, 100]),
203                            user_title: None,
204                            custom_icon: None,
205                        },
206                        pane_layout: None,
207                    },
208                ],
209                active_tab_index: 1,
210                tmux_session_name: None,
211            }],
212        };
213
214        save_session_to(&state, path.clone()).unwrap();
215        let loaded = load_session_from(path).unwrap().unwrap();
216        let tabs = &loaded.windows[0].tabs;
217
218        // Tab 0: all custom properties set
219        assert_eq!(tabs[0].snapshot.custom_color, Some([255, 128, 0]));
220        assert_eq!(
221            tabs[0].snapshot.user_title,
222            Some("My Custom Tab".to_string())
223        );
224        assert_eq!(tabs[0].snapshot.custom_icon, Some("🔥".to_string()));
225
226        // Tab 1: only custom icon
227        assert_eq!(tabs[1].snapshot.custom_color, None);
228        assert_eq!(tabs[1].snapshot.user_title, None);
229        assert_eq!(tabs[1].snapshot.custom_icon, Some("📁".to_string()));
230
231        // Tab 2: only custom color
232        assert_eq!(tabs[2].snapshot.custom_color, Some([0, 200, 100]));
233        assert_eq!(tabs[2].snapshot.user_title, None);
234        assert_eq!(tabs[2].snapshot.custom_icon, None);
235    }
236
237    #[test]
238    fn test_save_creates_parent_directory() {
239        let temp = tempdir().unwrap();
240        let path = temp.path().join("nested").join("dir").join("session.yaml");
241
242        let state = sample_session();
243        save_session_to(&state, path.clone()).unwrap();
244        assert!(path.exists());
245    }
246
247    /// SEC-016/SEC-021: the session file records working directories, so it must
248    /// land at mode 0600 whatever the process umask is.
249    #[cfg(unix)]
250    #[test]
251    fn test_saved_session_is_owner_only() {
252        use std::os::unix::fs::PermissionsExt;
253
254        let temp = tempdir().unwrap();
255        let path = temp.path().join("session.yaml");
256
257        save_session_to(&sample_session(), path.clone()).unwrap();
258
259        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
260        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
261    }
262
263    #[test]
264    fn test_serialization_with_pane_layout() {
265        use crate::pane::SplitDirection;
266        use crate::session::SessionPaneNode;
267
268        let state = SessionState {
269            saved_at: "2025-01-01T00:00:00Z".to_string(),
270            windows: vec![SessionWindow {
271                position: (0, 0),
272                size: (1920, 1080),
273                tabs: vec![SessionTab {
274                    snapshot: TabSnapshot {
275                        cwd: Some("/home/user".to_string()),
276                        title: "dev".to_string(),
277                        custom_color: None,
278                        user_title: None,
279                        custom_icon: None,
280                    },
281                    pane_layout: Some(SessionPaneNode::Split {
282                        direction: SplitDirection::Vertical,
283                        ratio: 0.5,
284                        first: Box::new(SessionPaneNode::Leaf {
285                            cwd: Some("/home/user/code".to_string()),
286                        }),
287                        second: Box::new(SessionPaneNode::Split {
288                            direction: SplitDirection::Horizontal,
289                            ratio: 0.6,
290                            first: Box::new(SessionPaneNode::Leaf {
291                                cwd: Some("/home/user/logs".to_string()),
292                            }),
293                            second: Box::new(SessionPaneNode::Leaf {
294                                cwd: Some("/home/user/tests".to_string()),
295                            }),
296                        }),
297                    }),
298                }],
299                active_tab_index: 0,
300                tmux_session_name: None,
301            }],
302        };
303
304        let temp = tempdir().unwrap();
305        let path = temp.path().join("pane_session.yaml");
306
307        save_session_to(&state, path.clone()).unwrap();
308        let loaded = load_session_from(path).unwrap().unwrap();
309
310        // Verify the nested pane layout survived roundtrip
311        let tab = &loaded.windows[0].tabs[0];
312        assert!(tab.pane_layout.is_some());
313        match tab.pane_layout.as_ref().unwrap() {
314            SessionPaneNode::Split {
315                direction, ratio, ..
316            } => {
317                assert_eq!(*direction, SplitDirection::Vertical);
318                assert!((ratio - 0.5).abs() < f32::EPSILON);
319            }
320            _ => panic!("Expected Split at root"),
321        }
322    }
323
324    #[test]
325    fn test_split_direction_serde() {
326        use crate::pane::SplitDirection;
327
328        let h = SplitDirection::Horizontal;
329        let v = SplitDirection::Vertical;
330
331        let h_yaml = serde_yaml_ng::to_string(&h).unwrap();
332        let v_yaml = serde_yaml_ng::to_string(&v).unwrap();
333
334        let h_back: SplitDirection = serde_yaml_ng::from_str(&h_yaml).unwrap();
335        let v_back: SplitDirection = serde_yaml_ng::from_str(&v_yaml).unwrap();
336
337        assert_eq!(h, h_back);
338        assert_eq!(v, v_back);
339    }
340
341    #[test]
342    fn test_clear_session() {
343        let temp = tempdir().unwrap();
344        let path = temp.path().join("to_clear.yaml");
345        std::fs::write(&path, "test").unwrap();
346        assert!(path.exists());
347
348        // We can't easily test clear_session() since it uses fixed path,
349        // but we can test the file removal logic
350        std::fs::remove_file(&path).unwrap();
351        assert!(!path.exists());
352    }
353}