Skip to main content

runtimo_core/
session.rs

1//! Session tracking for reliable SSH.
2//!
3//! Sessions group related job executions together, supporting:
4//! - Session resume after disconnect
5//! - Audit trail per session
6//! - Batch undo/rollback
7//!
8//! # Security Note (FINDING #18)
9//!
10//! Session IDs are used for **audit grouping only**, not for authentication
11//! or authorization. They are not security tokens and should not be treated
12//! as such. The current ID generation uses 16 random bytes from `/dev/urandom`
13//! (via `utils::generate_id()`), which provides sufficient collision resistance
14//! for audit purposes.
15//!
16//! If cryptographic uniqueness is required (e.g., for auth tokens), switch to
17//! UUID v4 via the `uuid` crate. For audit grouping, the current approach is
18//! adequate — P(collision) < 10⁻¹⁵ even at 100 sessions/sec for 1 hour.
19//!
20//! # Example
21//!
22//! ```rust,ignore
23//! use runtimo_core::session::{Session, SessionManager};
24//! use std::path::PathBuf;
25//!
26//! let mut mgr = SessionManager::new(PathBuf::from("/tmp/sessions")).unwrap();
27//! let session = mgr.create_session(Some("ssh-import")).unwrap();
28//! println!("Session ID: {}", session.id);
29//! ```
30
31use crate::Result;
32use serde::{Deserialize, Serialize};
33use std::path::PathBuf;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36/// A session groups related jobs for audit and recovery.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[allow(clippy::exhaustive_structs)]
39pub struct Session {
40    /// Unique session identifier.
41    pub id: String,
42    /// Optional human-readable name.
43    pub name: Option<String>,
44    /// Job IDs executed in this session.
45    pub job_ids: Vec<String>,
46    /// Unix timestamp when session was created.
47    pub created_at: u64,
48    /// Unix timestamp of last activity.
49    pub updated_at: u64,
50    /// Session status.
51    pub status: SessionStatus,
52}
53
54/// Session lifecycle status.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57#[allow(clippy::exhaustive_enums)]
58pub enum SessionStatus {
59    /// Session is active and accepting jobs.
60    Active,
61    /// Session has been paused (e.g., disconnect).
62    Paused,
63    /// Session completed normally.
64    Completed,
65    /// Session terminated abnormally.
66    Terminated,
67}
68
69/// Persistent session store backed by the filesystem.
70///
71/// Sessions are stored as individual JSON files under a `sessions/` directory.
72/// Each session tracks its associated job IDs, status, and creation timestamps.
73#[allow(clippy::exhaustive_structs)]
74pub struct SessionManager {
75    sessions_dir: PathBuf,
76}
77
78impl SessionManager {
79    /// Creates a new session manager.
80    ///
81    /// # Errors
82    /// Returns `SessionError` if the sessions directory cannot be created.
83    pub fn new(sessions_dir: PathBuf) -> Result<Self> {
84        std::fs::create_dir_all(&sessions_dir).map_err(|e| {
85            crate::Error::SessionError(format!("Failed to create sessions dir: {}", e))
86        })?;
87        Ok(Self { sessions_dir })
88    }
89
90    /// Creates a new session with optional name.
91    ///
92    /// # Errors
93    /// Returns `SessionError` if the session file cannot be written.
94    pub fn create_session(&mut self, name: Option<&str>) -> Result<Session> {
95        let now = SystemTime::now()
96            .duration_since(UNIX_EPOCH)
97            .unwrap_or_default();
98        let ts = now.as_secs();
99        let id = crate::utils::generate_id();
100
101        let session = Session {
102            id,
103            name: name.map(String::from),
104            job_ids: Vec::new(),
105            created_at: ts,
106            updated_at: ts,
107            status: SessionStatus::Active,
108        };
109
110        self.save_session(&session)?;
111        Ok(session)
112    }
113
114    /// Loads a session from disk by ID.
115    ///
116    /// # Errors
117    /// Returns `SessionError` if the session file cannot be read or parsed.
118    pub fn load_session(&self, session_id: &str) -> Result<Session> {
119        let path = self.session_path(session_id);
120        let content = std::fs::read_to_string(&path).map_err(|e| {
121            crate::Error::SessionError(format!("Session not found {}: {}", session_id, e))
122        })?;
123        serde_json::from_str(&content)
124            .map_err(|e| crate::Error::SessionError(format!("Failed to parse session: {}", e)))
125    }
126
127    /// Adds a job to a session.
128    ///
129    /// # Errors
130    /// Returns `SessionError` if the session cannot be loaded or saved.
131    pub fn add_job(&mut self, session_id: &str, job_id: &str) -> Result<()> {
132        let mut session = self.load_session(session_id)?;
133        session.job_ids.push(job_id.to_string());
134        session.updated_at = SystemTime::now()
135            .duration_since(UNIX_EPOCH)
136            .unwrap_or_default()
137            .as_secs();
138        self.save_session(&session)
139    }
140
141    /// Lists all sessions.
142    ///
143    /// # Errors
144    /// Returns `SessionError` if the sessions directory cannot be read
145    /// or a session file cannot be parsed.
146    pub fn list_sessions(&self) -> Result<Vec<Session>> {
147        let mut sessions = Vec::new();
148        if !self.sessions_dir.exists() {
149            return Ok(sessions);
150        }
151
152        for entry in std::fs::read_dir(&self.sessions_dir)
153            .map_err(|e| crate::Error::SessionError(format!("Failed to read sessions: {}", e)))?
154        {
155            let entry = entry
156                .map_err(|e| crate::Error::SessionError(format!("Failed to read entry: {}", e)))?;
157            let path = entry.path();
158            if path.extension().is_some_and(|ext| ext == "json") {
159                if let Ok(content) = std::fs::read_to_string(&path) {
160                    if let Ok(session) = serde_json::from_str(&content) {
161                        sessions.push(session);
162                    }
163                }
164            }
165        }
166
167        sessions.sort_by_key(|s: &Session| s.updated_at);
168        sessions.reverse();
169        Ok(sessions)
170    }
171
172    fn session_path(&self, session_id: &str) -> PathBuf {
173        self.sessions_dir.join(format!("{}.json", session_id))
174    }
175
176    fn save_session(&self, session: &Session) -> Result<()> {
177        let path = self.session_path(&session.id);
178        let content = serde_json::to_string_pretty(session).map_err(|e| {
179            crate::Error::SessionError(format!("Failed to serialize session: {}", e))
180        })?;
181        std::fs::write(&path, content)
182            .map_err(|e| crate::Error::SessionError(format!("Failed to write session: {}", e)))?;
183        Ok(())
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use std::fs;
191
192    fn tmp_dir(name: &str) -> PathBuf {
193        let dir = std::env::temp_dir().join(format!("runtimo_test_sessions_{}", name));
194        let _ = fs::remove_dir_all(&dir);
195        fs::create_dir_all(&dir).unwrap();
196        dir
197    }
198
199    #[test]
200    fn creates_session() {
201        let dir = tmp_dir("creates");
202        let mut mgr = SessionManager::new(dir).unwrap();
203        let session = mgr.create_session(Some("test")).unwrap();
204        assert!(!session.id.is_empty());
205        assert_eq!(session.name, Some("test".to_string()));
206        assert_eq!(session.job_ids.len(), 0);
207    }
208
209    #[test]
210    fn adds_job_to_session() {
211        let dir = tmp_dir("adds_job");
212        let mut mgr = SessionManager::new(dir).unwrap();
213        let session = mgr.create_session(None).unwrap();
214        mgr.add_job(&session.id, "job-123").unwrap();
215
216        let loaded = mgr.load_session(&session.id).unwrap();
217        assert_eq!(loaded.job_ids.len(), 1);
218        assert_eq!(loaded.job_ids[0], "job-123");
219    }
220
221    #[test]
222    fn lists_sessions() {
223        let dir = tmp_dir("lists");
224        let mut mgr = SessionManager::new(dir).unwrap();
225        let _ = mgr.create_session(Some("first")).unwrap();
226        let _ = mgr.create_session(Some("second")).unwrap();
227
228        let sessions = mgr.list_sessions().unwrap();
229        assert_eq!(sessions.len(), 2);
230    }
231}