Skip to main content

rec/recording/
capture.rs

1use std::fs::{File, OpenOptions};
2use std::io::{BufWriter, Write};
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::models::command::Command;
9use crate::models::session::{SessionFooter, SessionHeader};
10use crate::storage::set_restrictive_permissions;
11
12/// An NDJSON line in the session file.
13///
14/// Each line is tagged with `type` for easy parsing:
15/// - `header`: Session metadata (first line)
16/// - `command`: A captured command (middle lines)
17/// - `footer`: Session summary (last line)
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "lowercase")]
20pub enum NdjsonLine {
21    /// Session header metadata
22    Header(SessionHeader),
23    /// A captured command
24    Command(Command),
25    /// Session footer summary
26    Footer(SessionFooter),
27}
28
29/// Command capture with durable NDJSON append.
30///
31/// Provides static methods for writing NDJSON lines to session files
32/// with `fsync` after each write for crash safety. Each method opens
33/// the file, writes a single NDJSON line, syncs to disk, and closes.
34///
35/// # File Format
36///
37/// ```text
38/// {"type":"header","version":2,"id":"...","name":"...","shell":"bash",...}
39/// {"type":"command","index":0,"command":"echo hello","cwd":"/home",...}
40/// {"type":"command","index":1,"command":"ls -la","cwd":"/home",...}
41/// {"type":"footer","ended_at":1234567890.123,"command_count":2,"status":"completed"}
42/// ```
43pub struct CommandCapture;
44
45impl CommandCapture {
46    /// Write the session header as the first line of the NDJSON file.
47    ///
48    /// Creates the file (or truncates if exists) and writes the header
49    /// line followed by `sync_all()` for durability. Sets restrictive
50    /// permissions (0o600) to prevent other users from reading session data.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if file creation or writing fails.
55    pub fn write_header(path: &Path, header: &SessionHeader) -> Result<()> {
56        let line = NdjsonLine::Header(header.clone());
57        let file = File::create(path)?;
58        let mut writer = BufWriter::new(&file);
59        serde_json::to_writer(&mut writer, &line)?;
60        writeln!(writer)?;
61        writer.flush()?;
62        file.sync_all()?;
63
64        // Set restrictive permissions (0o600) to prevent other users from reading
65        set_restrictive_permissions(path)?;
66
67        Ok(())
68    }
69
70    /// Append a command as an NDJSON line to the session file.
71    ///
72    /// Opens the file in append mode and writes a single command line
73    /// followed by `sync_all()` for durability.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if file opening or writing fails.
78    pub fn append_command(path: &Path, command: &Command) -> Result<()> {
79        let line = NdjsonLine::Command(command.clone());
80        let file = OpenOptions::new().create(true).append(true).open(path)?;
81        let mut writer = BufWriter::new(&file);
82        serde_json::to_writer(&mut writer, &line)?;
83        writeln!(writer)?;
84        writer.flush()?;
85        file.sync_all()?;
86        Ok(())
87    }
88
89    /// Write the session footer as the last line of the NDJSON file.
90    ///
91    /// Opens the file in append mode and writes the footer line
92    /// followed by `sync_all()` for durability.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if file opening or writing fails.
97    pub fn write_footer(path: &Path, footer: &SessionFooter) -> Result<()> {
98        let line = NdjsonLine::Footer(footer.clone());
99        let file = OpenOptions::new().create(true).append(true).open(path)?;
100        let mut writer = BufWriter::new(&file);
101        serde_json::to_writer(&mut writer, &line)?;
102        writeln!(writer)?;
103        writer.flush()?;
104        file.sync_all()?;
105        Ok(())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::models::session::SessionStatus;
113    use std::collections::HashMap;
114    use std::path::PathBuf;
115    use tempfile::TempDir;
116    use uuid::Uuid;
117
118    fn sample_header() -> SessionHeader {
119        SessionHeader {
120            version: 2,
121            id: Uuid::new_v4(),
122            name: "test-session".to_string(),
123            shell: "bash".to_string(),
124            os: "linux".to_string(),
125            hostname: "testhost".to_string(),
126            env: HashMap::new(),
127            tags: vec!["test".to_string()],
128            recovered: None,
129            started_at: 1700000000.123,
130        }
131    }
132
133    fn sample_command(index: u32) -> Command {
134        Command {
135            index,
136            command: format!("echo command-{index}"),
137            cwd: PathBuf::from("/home/user"),
138            started_at: 1700000000.123 + (f64::from(index) * 1.0),
139            ended_at: Some(1700000000.223 + (f64::from(index) * 1.0)),
140            exit_code: Some(0),
141            duration_ms: Some(100),
142        }
143    }
144
145    fn sample_footer(count: u32) -> SessionFooter {
146        SessionFooter {
147            ended_at: 1700000010.456,
148            command_count: count,
149            status: SessionStatus::Completed,
150        }
151    }
152
153    #[test]
154    fn test_write_header() {
155        let tmp = TempDir::new().unwrap();
156        let path = tmp.path().join("session.ndjson");
157        let header = sample_header();
158
159        CommandCapture::write_header(&path, &header).unwrap();
160
161        let content = std::fs::read_to_string(&path).unwrap();
162        let lines: Vec<&str> = content.lines().collect();
163        assert_eq!(lines.len(), 1);
164
165        let parsed: NdjsonLine = serde_json::from_str(lines[0]).unwrap();
166        match parsed {
167            NdjsonLine::Header(h) => {
168                assert_eq!(h.name, "test-session");
169                assert_eq!(h.version, 2);
170                assert_eq!(h.shell, "bash");
171            }
172            _ => panic!("Expected Header line"),
173        }
174    }
175
176    #[test]
177    fn test_append_command() {
178        let tmp = TempDir::new().unwrap();
179        let path = tmp.path().join("session.ndjson");
180        let header = sample_header();
181
182        CommandCapture::write_header(&path, &header).unwrap();
183        CommandCapture::append_command(&path, &sample_command(0)).unwrap();
184
185        let content = std::fs::read_to_string(&path).unwrap();
186        let lines: Vec<&str> = content.lines().collect();
187        assert_eq!(lines.len(), 2);
188
189        let parsed: NdjsonLine = serde_json::from_str(lines[1]).unwrap();
190        match parsed {
191            NdjsonLine::Command(c) => {
192                assert_eq!(c.index, 0);
193                assert_eq!(c.command, "echo command-0");
194                assert_eq!(c.exit_code, Some(0));
195            }
196            _ => panic!("Expected Command line"),
197        }
198    }
199
200    #[test]
201    fn test_write_footer() {
202        let tmp = TempDir::new().unwrap();
203        let path = tmp.path().join("session.ndjson");
204        let header = sample_header();
205
206        CommandCapture::write_header(&path, &header).unwrap();
207        CommandCapture::append_command(&path, &sample_command(0)).unwrap();
208        CommandCapture::write_footer(&path, &sample_footer(1)).unwrap();
209
210        let content = std::fs::read_to_string(&path).unwrap();
211        let lines: Vec<&str> = content.lines().collect();
212        assert_eq!(lines.len(), 3);
213
214        let parsed: NdjsonLine = serde_json::from_str(lines[2]).unwrap();
215        match parsed {
216            NdjsonLine::Footer(f) => {
217                assert_eq!(f.command_count, 1);
218                assert_eq!(f.status, SessionStatus::Completed);
219            }
220            _ => panic!("Expected Footer line"),
221        }
222    }
223
224    #[test]
225    fn test_multiple_commands_preserve_order() {
226        let tmp = TempDir::new().unwrap();
227        let path = tmp.path().join("session.ndjson");
228        let header = sample_header();
229
230        CommandCapture::write_header(&path, &header).unwrap();
231        for i in 0..5 {
232            CommandCapture::append_command(&path, &sample_command(i)).unwrap();
233        }
234        CommandCapture::write_footer(&path, &sample_footer(5)).unwrap();
235
236        let content = std::fs::read_to_string(&path).unwrap();
237        let lines: Vec<&str> = content.lines().collect();
238        assert_eq!(lines.len(), 7); // header + 5 commands + footer
239
240        // Verify order: header, commands 0-4, footer
241        let first: NdjsonLine = serde_json::from_str(lines[0]).unwrap();
242        assert!(matches!(first, NdjsonLine::Header(_)));
243
244        for i in 0..5 {
245            let line: NdjsonLine = serde_json::from_str(lines[i + 1]).unwrap();
246            match line {
247                NdjsonLine::Command(c) => assert_eq!(c.index, i as u32),
248                _ => panic!("Expected Command at line {}", i + 1),
249            }
250        }
251
252        let last: NdjsonLine = serde_json::from_str(lines[6]).unwrap();
253        assert!(matches!(last, NdjsonLine::Footer(_)));
254    }
255
256    #[test]
257    fn test_ndjson_line_serialization_roundtrip() {
258        let header_line = NdjsonLine::Header(sample_header());
259        let json = serde_json::to_string(&header_line).unwrap();
260        assert!(json.contains("\"type\":\"header\""));
261
262        let parsed: NdjsonLine = serde_json::from_str(&json).unwrap();
263        assert!(matches!(parsed, NdjsonLine::Header(_)));
264
265        let cmd_line = NdjsonLine::Command(sample_command(0));
266        let json = serde_json::to_string(&cmd_line).unwrap();
267        assert!(json.contains("\"type\":\"command\""));
268
269        let footer_line = NdjsonLine::Footer(sample_footer(1));
270        let json = serde_json::to_string(&footer_line).unwrap();
271        assert!(json.contains("\"type\":\"footer\""));
272    }
273
274    #[test]
275    fn test_write_header_creates_file() {
276        let tmp = TempDir::new().unwrap();
277        let path = tmp.path().join("new-session.ndjson");
278
279        assert!(!path.exists());
280        CommandCapture::write_header(&path, &sample_header()).unwrap();
281        assert!(path.exists());
282    }
283
284    #[test]
285    fn test_each_line_is_valid_json() {
286        let tmp = TempDir::new().unwrap();
287        let path = tmp.path().join("session.ndjson");
288
289        CommandCapture::write_header(&path, &sample_header()).unwrap();
290        CommandCapture::append_command(&path, &sample_command(0)).unwrap();
291        CommandCapture::write_footer(&path, &sample_footer(1)).unwrap();
292
293        let content = std::fs::read_to_string(&path).unwrap();
294        for (i, line) in content.lines().enumerate() {
295            let parsed: serde_json::Value = serde_json::from_str(line)
296                .unwrap_or_else(|e| panic!("Line {i} is not valid JSON: {e}"));
297            assert!(
298                parsed.get("type").is_some(),
299                "Line {i} missing 'type' field"
300            );
301        }
302    }
303
304    #[test]
305    #[cfg(unix)]
306    fn test_write_header_sets_restrictive_permissions() {
307        use std::os::unix::fs::PermissionsExt;
308
309        let tmp = TempDir::new().unwrap();
310        let path = tmp.path().join("session.ndjson");
311
312        CommandCapture::write_header(&path, &sample_header()).unwrap();
313
314        // Verify permissions are 0o600 (read/write for owner only)
315        let metadata = std::fs::metadata(&path).unwrap();
316        let mode = metadata.permissions().mode();
317
318        // On Unix, mode includes file type bits. We only care about permission bits (lower 9 bits)
319        let permission_bits = mode & 0o777;
320        assert_eq!(
321            permission_bits, 0o600,
322            "Session file should have 0o600 permissions, got 0o{permission_bits:o}"
323        );
324    }
325}