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)]
38pub struct Session {
39    /// Unique session identifier.
40    pub id: String,
41    /// Optional human-readable name.
42    pub name: Option<String>,
43    /// Job IDs executed in this session.
44    pub job_ids: Vec<String>,
45    /// Unix timestamp when session was created.
46    pub created_at: u64,
47    /// Unix timestamp of last activity.
48    pub updated_at: u64,
49    /// Session status.
50    pub status: SessionStatus,
51}
52
53/// Session lifecycle status.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum SessionStatus {
57    /// Session is active and accepting jobs.
58    Active,
59    /// Session has been paused (e.g., disconnect).
60    Paused,
61    /// Session completed normally.
62    Completed,
63    /// Session terminated abnormally.
64    Terminated,
65}
66
67/// Manages session persistence and retrieval.
68pub struct SessionManager {
69    sessions_dir: PathBuf,
70}
71
72impl SessionManager {
73    /// Creates a new session manager.
74    pub fn new(sessions_dir: PathBuf) -> Result<Self> {
75        std::fs::create_dir_all(&sessions_dir).map_err(|e| {
76            crate::Error::BackupError(format!("Failed to create sessions dir: {}", e))
77        })?;
78        Ok(Self { sessions_dir })
79    }
80
81    /// Creates a new session with optional name.
82    pub fn create_session(&mut self, name: Option<&str>) -> Result<Session> {
83        let now = SystemTime::now()
84            .duration_since(UNIX_EPOCH)
85            .unwrap_or_default();
86        let ts = now.as_secs();
87        let id = crate::utils::generate_id();
88
89        let session = Session {
90            id: id.clone(),
91            name: name.map(String::from),
92            job_ids: Vec::new(),
93            created_at: ts,
94            updated_at: ts,
95            status: SessionStatus::Active,
96        };
97
98        self.save_session(&session)?;
99        Ok(session)
100    }
101
102    /// Loads a session by ID.
103    pub fn load_session(&self, session_id: &str) -> Result<Session> {
104        let path = self.session_path(session_id);
105        let content = std::fs::read_to_string(&path)
106            .map_err(|_| crate::Error::BackupError(format!("Session not found: {}", session_id)))?;
107        serde_json::from_str(&content)
108            .map_err(|e| crate::Error::BackupError(format!("Failed to parse session: {}", e)))
109    }
110
111    /// Adds a job to a session.
112    pub fn add_job(&mut self, session_id: &str, job_id: &str) -> Result<()> {
113        let mut session = self.load_session(session_id)?;
114        session.job_ids.push(job_id.to_string());
115        session.updated_at = SystemTime::now()
116            .duration_since(UNIX_EPOCH)
117            .unwrap_or_default()
118            .as_secs();
119        self.save_session(&session)
120    }
121
122    /// Lists all sessions.
123    pub fn list_sessions(&self) -> Result<Vec<Session>> {
124        let mut sessions = Vec::new();
125        if !self.sessions_dir.exists() {
126            return Ok(sessions);
127        }
128
129        for entry in std::fs::read_dir(&self.sessions_dir)
130            .map_err(|e| crate::Error::BackupError(format!("Failed to read sessions: {}", e)))?
131        {
132            let entry = entry
133                .map_err(|e| crate::Error::BackupError(format!("Failed to read entry: {}", e)))?;
134            let path = entry.path();
135            if path.extension().is_some_and(|ext| ext == "json") {
136                if let Ok(content) = std::fs::read_to_string(&path) {
137                    if let Ok(session) = serde_json::from_str(&content) {
138                        sessions.push(session);
139                    }
140                }
141            }
142        }
143
144        sessions.sort_by_key(|s: &Session| s.updated_at);
145        sessions.reverse();
146        Ok(sessions)
147    }
148
149    fn session_path(&self, session_id: &str) -> PathBuf {
150        self.sessions_dir.join(format!("{}.json", session_id))
151    }
152
153    fn save_session(&self, session: &Session) -> Result<()> {
154        let path = self.session_path(&session.id);
155        let content = serde_json::to_string_pretty(session).map_err(|e| {
156            crate::Error::BackupError(format!("Failed to serialize session: {}", e))
157        })?;
158        std::fs::write(&path, content)
159            .map_err(|e| crate::Error::BackupError(format!("Failed to write session: {}", e)))?;
160        Ok(())
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::fs;
168
169    fn tmp_dir(name: &str) -> PathBuf {
170        let dir = std::env::temp_dir().join(format!("runtimo_test_sessions_{}", name));
171        let _ = fs::remove_dir_all(&dir);
172        fs::create_dir_all(&dir).unwrap();
173        dir
174    }
175
176    #[test]
177    fn creates_session() {
178        let dir = tmp_dir("creates");
179        let mut mgr = SessionManager::new(dir).unwrap();
180        let session = mgr.create_session(Some("test")).unwrap();
181        assert!(!session.id.is_empty());
182        assert_eq!(session.name, Some("test".to_string()));
183        assert_eq!(session.job_ids.len(), 0);
184    }
185
186    #[test]
187    fn adds_job_to_session() {
188        let dir = tmp_dir("adds_job");
189        let mut mgr = SessionManager::new(dir).unwrap();
190        let session = mgr.create_session(None).unwrap();
191        mgr.add_job(&session.id, "job-123").unwrap();
192
193        let loaded = mgr.load_session(&session.id).unwrap();
194        assert_eq!(loaded.job_ids.len(), 1);
195        assert_eq!(loaded.job_ids[0], "job-123");
196    }
197
198    #[test]
199    fn lists_sessions() {
200        let dir = tmp_dir("lists");
201        let mut mgr = SessionManager::new(dir.clone()).unwrap();
202        let _ = mgr.create_session(Some("first")).unwrap();
203        let _ = mgr.create_session(Some("second")).unwrap();
204
205        let sessions = mgr.list_sessions().unwrap();
206        assert_eq!(sessions.len(), 2);
207    }
208}