rust_secure_logger/
persistence.rs

1//! Log persistence to disk with rotation and compression
2
3use crate::entry::LogEntry;
4use std::fs::{File, OpenOptions};
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7
8/// Configuration for log file persistence
9#[derive(Debug, Clone)]
10pub struct PersistenceConfig {
11    /// Directory where log files are stored
12    pub log_dir: PathBuf,
13    /// Base name for log files
14    pub file_prefix: String,
15    /// Maximum file size before rotation (bytes)
16    pub max_file_size: u64,
17    /// Maximum number of rotated files to keep
18    pub max_files: usize,
19    /// Whether to compress rotated files
20    pub compress_rotated: bool,
21}
22
23impl Default for PersistenceConfig {
24    fn default() -> Self {
25        Self {
26            log_dir: PathBuf::from("./logs"),
27            file_prefix: "secure".to_string(),
28            max_file_size: 10 * 1024 * 1024, // 10 MB
29            max_files: 10,
30            compress_rotated: false,
31        }
32    }
33}
34
35/// Log file writer with rotation support
36pub struct LogWriter {
37    config: PersistenceConfig,
38    current_file: Option<File>,
39    current_size: u64,
40}
41
42impl LogWriter {
43    /// Create a new log writer
44    pub fn new(config: PersistenceConfig) -> io::Result<Self> {
45        // Create log directory if it doesn't exist
46        std::fs::create_dir_all(&config.log_dir)?;
47
48        Ok(Self {
49            config,
50            current_file: None,
51            current_size: 0,
52        })
53    }
54
55    /// Get current log file path
56    fn current_log_path(&self) -> PathBuf {
57        self.config
58            .log_dir
59            .join(format!("{}.log", self.config.file_prefix))
60    }
61
62    /// Get rotated log file path
63    fn rotated_log_path(&self, index: usize) -> PathBuf {
64        self.config.log_dir.join(format!(
65            "{}.{}.log",
66            self.config.file_prefix, index
67        ))
68    }
69
70    /// Open or create the current log file
71    fn ensure_file(&mut self) -> io::Result<&mut File> {
72        if self.current_file.is_none() {
73            let path = self.current_log_path();
74            let file = OpenOptions::new()
75                .create(true)
76                .append(true)
77                .open(&path)?;
78
79            // Get current file size
80            self.current_size = file.metadata()?.len();
81            self.current_file = Some(file);
82        }
83
84        Ok(self.current_file.as_mut().unwrap())
85    }
86
87    /// Rotate log files
88    fn rotate(&mut self) -> io::Result<()> {
89        // Close current file
90        self.current_file = None;
91
92        // Rotate existing files
93        for i in (1..self.config.max_files).rev() {
94            let old_path = if i == 1 {
95                self.current_log_path()
96            } else {
97                self.rotated_log_path(i - 1)
98            };
99
100            let new_path = self.rotated_log_path(i);
101
102            if old_path.exists() {
103                std::fs::rename(&old_path, &new_path)?;
104            }
105        }
106
107        // Delete oldest file if we exceeded max_files
108        let oldest_path = self.rotated_log_path(self.config.max_files);
109        if oldest_path.exists() {
110            std::fs::remove_file(oldest_path)?;
111        }
112
113        // Reset size counter
114        self.current_size = 0;
115
116        Ok(())
117    }
118
119    /// Write a log entry to disk
120    pub fn write_entry(&mut self, entry: &LogEntry) -> io::Result<()> {
121        let log_line = format!("{}\n", entry.to_log_line());
122        let bytes = log_line.as_bytes();
123
124        // Check if rotation is needed
125        if self.current_size + bytes.len() as u64 > self.config.max_file_size {
126            self.rotate()?;
127        }
128
129        // Write to file
130        let file = self.ensure_file()?;
131        file.write_all(bytes)?;
132        file.flush()?;
133
134        self.current_size += bytes.len() as u64;
135
136        Ok(())
137    }
138
139    /// Write multiple entries
140    pub fn write_entries(&mut self, entries: &[LogEntry]) -> io::Result<()> {
141        for entry in entries {
142            self.write_entry(entry)?;
143        }
144        Ok(())
145    }
146
147    /// Write entry as JSON
148    pub fn write_entry_json(&mut self, entry: &LogEntry) -> io::Result<()> {
149        let json_line = entry
150            .to_json()
151            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
152        let line = format!("{}\n", json_line);
153        let bytes = line.as_bytes();
154
155        if self.current_size + bytes.len() as u64 > self.config.max_file_size {
156            self.rotate()?;
157        }
158
159        let file = self.ensure_file()?;
160        file.write_all(bytes)?;
161        file.flush()?;
162
163        self.current_size += bytes.len() as u64;
164
165        Ok(())
166    }
167
168    /// Flush current file buffer
169    pub fn flush(&mut self) -> io::Result<()> {
170        if let Some(ref mut file) = self.current_file {
171            file.flush()?;
172        }
173        Ok(())
174    }
175
176    /// Get current file size
177    pub fn current_file_size(&self) -> u64 {
178        self.current_size
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::entry::SecurityLevel;
186    use std::fs;
187    use tempfile::TempDir;
188
189    #[test]
190    fn test_log_writer_creation() {
191        let temp_dir = TempDir::new().unwrap();
192        let config = PersistenceConfig {
193            log_dir: temp_dir.path().to_path_buf(),
194            file_prefix: "test".to_string(),
195            max_file_size: 1024,
196            max_files: 5,
197            compress_rotated: false,
198        };
199
200        let writer = LogWriter::new(config);
201        assert!(writer.is_ok());
202    }
203
204    #[test]
205    fn test_write_entry() {
206        let temp_dir = TempDir::new().unwrap();
207        let config = PersistenceConfig {
208            log_dir: temp_dir.path().to_path_buf(),
209            file_prefix: "test".to_string(),
210            max_file_size: 1024 * 1024,
211            max_files: 5,
212            compress_rotated: false,
213        };
214
215        let mut writer = LogWriter::new(config).unwrap();
216        let entry = LogEntry::new(
217            SecurityLevel::Info,
218            "Test message".to_string(),
219            None,
220        );
221
222        let result = writer.write_entry(&entry);
223        assert!(result.is_ok());
224
225        // Verify file exists
226        let log_file = temp_dir.path().join("test.log");
227        assert!(log_file.exists());
228    }
229
230    #[test]
231    fn test_file_rotation() {
232        let temp_dir = TempDir::new().unwrap();
233        let config = PersistenceConfig {
234            log_dir: temp_dir.path().to_path_buf(),
235            file_prefix: "test".to_string(),
236            max_file_size: 100, // Small size to force rotation
237            max_files: 3,
238            compress_rotated: false,
239        };
240
241        let mut writer = LogWriter::new(config).unwrap();
242
243        // Write multiple entries to trigger rotation
244        for i in 0..20 {
245            let entry = LogEntry::new(
246                SecurityLevel::Info,
247                format!("Test message number {}", i),
248                None,
249            );
250            writer.write_entry(&entry).unwrap();
251        }
252
253        // Check that rotation occurred
254        let rotated_file = temp_dir.path().join("test.1.log");
255        assert!(rotated_file.exists());
256    }
257
258    #[test]
259    fn test_json_writing() {
260        let temp_dir = TempDir::new().unwrap();
261        let config = PersistenceConfig {
262            log_dir: temp_dir.path().to_path_buf(),
263            file_prefix: "json_test".to_string(),
264            max_file_size: 1024 * 1024,
265            max_files: 5,
266            compress_rotated: false,
267        };
268
269        let mut writer = LogWriter::new(config).unwrap();
270        let entry = LogEntry::new(
271            SecurityLevel::Audit,
272            "Transaction completed".to_string(),
273            Some(serde_json::json!({"amount": 1000, "currency": "USD"})),
274        );
275
276        let result = writer.write_entry_json(&entry);
277        assert!(result.is_ok());
278
279        let log_file = temp_dir.path().join("json_test.log");
280        let contents = fs::read_to_string(log_file).unwrap();
281        assert!(contents.contains("Transaction completed"));
282        assert!(contents.contains("\"amount\":1000"));
283    }
284}