Skip to main content

runtimo_core/
session.rs

1//! Session tracking for reliable SSH.
2//!
3//! Sessions group related job executions together, enabling:
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/// Manages session persistence and retrieval.
70#[allow(clippy::exhaustive_structs)]
71pub struct SessionManager {
72    sessions_dir: PathBuf,
73}
74
75impl SessionManager {
76    /// Creates a new session manager.
77    ///
78    /// # Errors
79    /// Returns `SessionError` if the sessions directory cannot be created.
80    pub fn new(sessions_dir: PathBuf) -> Result<Self> {
81        std::fs::create_dir_all(&sessions_dir).map_err(|e| {
82            crate::Error::SessionError(format!("Failed to create sessions dir: {}", e))
83        })?;
84        Ok(Self { sessions_dir })
85    }
86
87    /// Creates a new session with optional name.
88    ///
89    /// # Errors
90    /// Returns `SessionError` if the session file cannot be written.
91    pub fn create_session(&mut self, name: Option<&str>) -> Result<Session> {
92        let now = SystemTime::now()
93            .duration_since(UNIX_EPOCH)
94            .unwrap_or_default();
95        let ts = now.as_secs();
96        let id = crate::utils::generate_id();
97
98        let session = Session {
99            id,
100            name: name.map(String::from),
101            job_ids: Vec::new(),
102            created_at: ts,
103            updated_at: ts,
104            status: SessionStatus::Active,
105        };
106
107        self.save_session(&session)?;
108        Ok(session)
109    }
110
111    /// Loads a session by ID.
112    /// Loads a session from disk by ID.
113    ///
114    /// # Errors
115    /// Returns `SessionError` if the session file cannot be read or parsed.
116    pub fn load_session(&self, session_id: &str) -> Result<Session> {
117        let path = self.session_path(session_id);
118        let content = std::fs::read_to_string(&path)
119            .map_err(|e| crate::Error::SessionError(format!("Session not found {}: {}", session_id, e)))?;
120        serde_json::from_str(&content)
121            .map_err(|e| crate::Error::SessionError(format!("Failed to parse session: {}", e)))
122    }
123
124    /// Adds a job to a session.
125    ///
126    /// # Errors
127    /// Returns `SessionError` if the session cannot be loaded or saved.
128    pub fn add_job(&mut self, session_id: &str, job_id: &str) -> Result<()> {
129        let mut session = self.load_session(session_id)?;
130        session.job_ids.push(job_id.to_string());
131        session.updated_at = SystemTime::now()
132            .duration_since(UNIX_EPOCH)
133            .unwrap_or_default()
134            .as_secs();
135        self.save_session(&session)
136    }
137
138    /// Lists all sessions.
139    ///
140    /// # Errors
141    /// Returns `SessionError` if the sessions directory cannot be read
142    /// or a session file cannot be parsed.
143    pub fn list_sessions(&self) -> Result<Vec<Session>> {
144        let mut sessions = Vec::new();
145        if !self.sessions_dir.exists() {
146            return Ok(sessions);
147        }
148
149        for entry in std::fs::read_dir(&self.sessions_dir)
150            .map_err(|e| crate::Error::SessionError(format!("Failed to read sessions: {}", e)))?
151        {
152            let entry = entry
153                .map_err(|e| crate::Error::SessionError(format!("Failed to read entry: {}", e)))?;
154            let path = entry.path();
155            if path.extension().is_some_and(|ext| ext == "json") {
156                if let Ok(content) = std::fs::read_to_string(&path) {
157                    if let Ok(session) = serde_json::from_str(&content) {
158                        sessions.push(session);
159                    }
160                }
161            }
162        }
163
164        sessions.sort_by_key(|s: &Session| s.updated_at);
165        sessions.reverse();
166        Ok(sessions)
167    }
168
169    fn session_path(&self, session_id: &str) -> PathBuf {
170        self.sessions_dir.join(format!("{}.json", session_id))
171    }
172
173    fn save_session(&self, session: &Session) -> Result<()> {
174        let path = self.session_path(&session.id);
175        let content = serde_json::to_string_pretty(session).map_err(|e| {
176            crate::Error::SessionError(format!("Failed to serialize session: {}", e))
177        })?;
178        std::fs::write(&path, content)
179            .map_err(|e| crate::Error::SessionError(format!("Failed to write session: {}", e)))?;
180        Ok(())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::fs;
188
189    fn tmp_dir(name: &str) -> PathBuf {
190        let dir = std::env::temp_dir().join(format!("runtimo_test_sessions_{}", name));
191        let _ = fs::remove_dir_all(&dir);
192        fs::create_dir_all(&dir).unwrap();
193        dir
194    }
195
196    #[test]
197    fn creates_session() {
198        let dir = tmp_dir("creates");
199        let mut mgr = SessionManager::new(dir).unwrap();
200        let session = mgr.create_session(Some("test")).unwrap();
201        assert!(!session.id.is_empty());
202        assert_eq!(session.name, Some("test".to_string()));
203        assert_eq!(session.job_ids.len(), 0);
204    }
205
206    #[test]
207    fn adds_job_to_session() {
208        let dir = tmp_dir("adds_job");
209        let mut mgr = SessionManager::new(dir).unwrap();
210        let session = mgr.create_session(None).unwrap();
211        mgr.add_job(&session.id, "job-123").unwrap();
212
213        let loaded = mgr.load_session(&session.id).unwrap();
214        assert_eq!(loaded.job_ids.len(), 1);
215        assert_eq!(loaded.job_ids[0], "job-123");
216    }
217
218    #[test]
219    fn lists_sessions() {
220        let dir = tmp_dir("lists");
221        let mut mgr = SessionManager::new(dir).unwrap();
222        let _ = mgr.create_session(Some("first")).unwrap();
223        let _ = mgr.create_session(Some("second")).unwrap();
224
225        let sessions = mgr.list_sessions().unwrap();
226        assert_eq!(sessions.len(), 2);
227    }
228}