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::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
65            .log_dir
66            .join(format!("{}.{}.log", self.config.file_prefix, index))
67    }
68
69    /// Open or create the current log file
70    fn ensure_file(&mut self) -> io::Result<&mut File> {
71        if self.current_file.is_none() {
72            let path = self.current_log_path();
73            let file = OpenOptions::new().create(true).append(true).open(&path)?;
74
75            // Get current file size
76            self.current_size = file.metadata()?.len();
77            self.current_file = Some(file);
78        }
79
80        Ok(self.current_file.as_mut().unwrap())
81    }
82
83    /// Rotate log files
84    fn rotate(&mut self) -> io::Result<()> {
85        // Close current file
86        self.current_file = None;
87
88        // Rotate existing files
89        for i in (1..self.config.max_files).rev() {
90            let old_path = if i == 1 {
91                self.current_log_path()
92            } else {
93                self.rotated_log_path(i - 1)
94            };
95
96            let new_path = self.rotated_log_path(i);
97
98            if old_path.exists() {
99                std::fs::rename(&old_path, &new_path)?;
100            }
101        }
102
103        // Delete oldest file if we exceeded max_files
104        let oldest_path = self.rotated_log_path(self.config.max_files);
105        if oldest_path.exists() {
106            std::fs::remove_file(oldest_path)?;
107        }
108
109        // Reset size counter
110        self.current_size = 0;
111
112        Ok(())
113    }
114
115    /// Write a log entry to disk
116    pub fn write_entry(&mut self, entry: &LogEntry) -> io::Result<()> {
117        let log_line = format!("{}\n", entry.to_log_line());
118        let bytes = log_line.as_bytes();
119
120        // Check if rotation is needed
121        if self.current_size + bytes.len() as u64 > self.config.max_file_size {
122            self.rotate()?;
123        }
124
125        // Write to file
126        let file = self.ensure_file()?;
127        file.write_all(bytes)?;
128        file.flush()?;
129
130        self.current_size += bytes.len() as u64;
131
132        Ok(())
133    }
134
135    /// Write multiple entries
136    pub fn write_entries(&mut self, entries: &[LogEntry]) -> io::Result<()> {
137        for entry in entries {
138            self.write_entry(entry)?;
139        }
140        Ok(())
141    }
142
143    /// Write entry as JSON
144    pub fn write_entry_json(&mut self, entry: &LogEntry) -> io::Result<()> {
145        let json_line = entry
146            .to_json()
147            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
148        let line = format!("{}\n", json_line);
149        let bytes = line.as_bytes();
150
151        if self.current_size + bytes.len() as u64 > self.config.max_file_size {
152            self.rotate()?;
153        }
154
155        let file = self.ensure_file()?;
156        file.write_all(bytes)?;
157        file.flush()?;
158
159        self.current_size += bytes.len() as u64;
160
161        Ok(())
162    }
163
164    /// Flush current file buffer
165    pub fn flush(&mut self) -> io::Result<()> {
166        if let Some(ref mut file) = self.current_file {
167            file.flush()?;
168        }
169        Ok(())
170    }
171
172    /// Get current file size
173    pub fn current_file_size(&self) -> u64 {
174        self.current_size
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::entry::SecurityLevel;
182    use std::fs;
183    use tempfile::TempDir;
184
185    #[test]
186    fn test_log_writer_creation() {
187        let temp_dir = TempDir::new().unwrap();
188        let config = PersistenceConfig {
189            log_dir: temp_dir.path().to_path_buf(),
190            file_prefix: "test".to_string(),
191            max_file_size: 1024,
192            max_files: 5,
193            compress_rotated: false,
194        };
195
196        let writer = LogWriter::new(config);
197        assert!(writer.is_ok());
198    }
199
200    #[test]
201    fn test_write_entry() {
202        let temp_dir = TempDir::new().unwrap();
203        let config = PersistenceConfig {
204            log_dir: temp_dir.path().to_path_buf(),
205            file_prefix: "test".to_string(),
206            max_file_size: 1024 * 1024,
207            max_files: 5,
208            compress_rotated: false,
209        };
210
211        let mut writer = LogWriter::new(config).unwrap();
212        let entry = LogEntry::new(SecurityLevel::Info, "Test message".to_string(), None);
213
214        let result = writer.write_entry(&entry);
215        assert!(result.is_ok());
216
217        // Verify file exists
218        let log_file = temp_dir.path().join("test.log");
219        assert!(log_file.exists());
220    }
221
222    #[test]
223    fn test_file_rotation() {
224        let temp_dir = TempDir::new().unwrap();
225        let config = PersistenceConfig {
226            log_dir: temp_dir.path().to_path_buf(),
227            file_prefix: "test".to_string(),
228            max_file_size: 100, // Small size to force rotation
229            max_files: 3,
230            compress_rotated: false,
231        };
232
233        let mut writer = LogWriter::new(config).unwrap();
234
235        // Write multiple entries to trigger rotation
236        for i in 0..20 {
237            let entry = LogEntry::new(
238                SecurityLevel::Info,
239                format!("Test message number {}", i),
240                None,
241            );
242            writer.write_entry(&entry).unwrap();
243        }
244
245        // Check that rotation occurred
246        let rotated_file = temp_dir.path().join("test.1.log");
247        assert!(rotated_file.exists());
248    }
249
250    #[test]
251    fn test_json_writing() {
252        let temp_dir = TempDir::new().unwrap();
253        let config = PersistenceConfig {
254            log_dir: temp_dir.path().to_path_buf(),
255            file_prefix: "json_test".to_string(),
256            max_file_size: 1024 * 1024,
257            max_files: 5,
258            compress_rotated: false,
259        };
260
261        let mut writer = LogWriter::new(config).unwrap();
262        let entry = LogEntry::new(
263            SecurityLevel::Audit,
264            "Transaction completed".to_string(),
265            Some(serde_json::json!({"amount": 1000, "currency": "USD"})),
266        );
267
268        let result = writer.write_entry_json(&entry);
269        assert!(result.is_ok());
270
271        let log_file = temp_dir.path().join("json_test.log");
272        let contents = fs::read_to_string(log_file).unwrap();
273        assert!(contents.contains("Transaction completed"));
274        assert!(contents.contains("\"amount\":1000"));
275    }
276}