ricecoder_images/
audit_logging.rs

1//! Audit logging for image analysis requests.
2//!
3//! This module handles audit logging of image analysis requests, responses,
4//! and cache operations. It integrates with ricecoder-providers' AuditLogger
5//! for consistent security event tracking.
6
7use ricecoder_providers::audit_log::{AuditLogger, AuditLogEntry, AuditEventType};
8use std::path::PathBuf;
9use std::sync::Arc;
10use tracing::{debug, info, warn};
11
12/// Image analysis audit logger.
13///
14/// This logger tracks:
15/// - Image analysis requests (provider, model, image count, size)
16/// - Analysis responses (success/failure, tokens used)
17/// - Cache operations (hits, misses, evictions)
18/// - Security events (path traversal attempts, invalid formats)
19pub struct ImageAuditLogger {
20    audit_logger: Arc<AuditLogger>,
21}
22
23impl ImageAuditLogger {
24    /// Create a new image audit logger.
25    ///
26    /// # Arguments
27    ///
28    /// * `log_path` - Path to the audit log file
29    pub fn new(log_path: PathBuf) -> Self {
30        Self {
31            audit_logger: Arc::new(AuditLogger::new(log_path)),
32        }
33    }
34
35    /// Log an image analysis request.
36    ///
37    /// # Arguments
38    ///
39    /// * `provider` - Provider name (openai, anthropic, etc.)
40    /// * `model` - Model identifier
41    /// * `image_count` - Number of images being analyzed
42    /// * `total_size` - Total size of all images in bytes
43    /// * `image_hashes` - SHA256 hashes of images (for deduplication tracking)
44    pub fn log_analysis_request(
45        &self,
46        provider: &str,
47        model: &str,
48        image_count: usize,
49        total_size: u64,
50        image_hashes: Vec<String>,
51    ) -> Result<(), Box<dyn std::error::Error>> {
52        let details = format!(
53            "Image analysis request: {} images, {} bytes total, hashes: {}",
54            image_count,
55            total_size,
56            image_hashes.join(",")
57        );
58
59        let entry = AuditLogEntry::new(
60            AuditEventType::FileAccessed,
61            "ricecoder-images",
62            "system",
63            &format!("{}/{}", provider, model),
64            "request_sent",
65            &details,
66        );
67
68        self.audit_logger.log(&entry)?;
69
70        info!(
71            provider = provider,
72            model = model,
73            image_count = image_count,
74            total_size = total_size,
75            "Image analysis request logged"
76        );
77
78        Ok(())
79    }
80
81    /// Log a successful image analysis response.
82    ///
83    /// # Arguments
84    ///
85    /// * `provider` - Provider name
86    /// * `model` - Model identifier
87    /// * `image_count` - Number of images analyzed
88    /// * `tokens_used` - Tokens used for the analysis
89    /// * `image_hashes` - SHA256 hashes of analyzed images
90    pub fn log_analysis_success(
91        &self,
92        provider: &str,
93        model: &str,
94        image_count: usize,
95        tokens_used: u32,
96        image_hashes: Vec<String>,
97    ) -> Result<(), Box<dyn std::error::Error>> {
98        let details = format!(
99            "Image analysis successful: {} images, {} tokens used, hashes: {}",
100            image_count,
101            tokens_used,
102            image_hashes.join(",")
103        );
104
105        let entry = AuditLogEntry::new(
106            AuditEventType::FileAccessed,
107            "ricecoder-images",
108            "system",
109            &format!("{}/{}", provider, model),
110            "analysis_success",
111            &details,
112        );
113
114        self.audit_logger.log(&entry)?;
115
116        info!(
117            provider = provider,
118            model = model,
119            image_count = image_count,
120            tokens_used = tokens_used,
121            "Image analysis success logged"
122        );
123
124        Ok(())
125    }
126
127    /// Log a failed image analysis response.
128    ///
129    /// # Arguments
130    ///
131    /// * `provider` - Provider name
132    /// * `model` - Model identifier
133    /// * `image_count` - Number of images that failed
134    /// * `error` - Error message
135    /// * `image_hashes` - SHA256 hashes of images that failed
136    pub fn log_analysis_failure(
137        &self,
138        provider: &str,
139        model: &str,
140        image_count: usize,
141        error: &str,
142        image_hashes: Vec<String>,
143    ) -> Result<(), Box<dyn std::error::Error>> {
144        let details = format!(
145            "Image analysis failed: {} images, error: {}, hashes: {}",
146            image_count,
147            error,
148            image_hashes.join(",")
149        );
150
151        let entry = AuditLogEntry::new(
152            AuditEventType::SecurityError,
153            "ricecoder-images",
154            "system",
155            &format!("{}/{}", provider, model),
156            "analysis_failure",
157            &details,
158        );
159
160        self.audit_logger.log(&entry)?;
161
162        warn!(
163            provider = provider,
164            model = model,
165            image_count = image_count,
166            error = error,
167            "Image analysis failure logged"
168        );
169
170        Ok(())
171    }
172
173    /// Log a cache hit.
174    ///
175    /// # Arguments
176    ///
177    /// * `image_hash` - SHA256 hash of the image
178    /// * `provider` - Provider that originally analyzed the image
179    /// * `age_seconds` - Age of the cached result in seconds
180    pub fn log_cache_hit(
181        &self,
182        image_hash: &str,
183        provider: &str,
184        age_seconds: u64,
185    ) -> Result<(), Box<dyn std::error::Error>> {
186        let details = format!(
187            "Cache hit for image {}: provider={}, age={}s",
188            image_hash, provider, age_seconds
189        );
190
191        let entry = AuditLogEntry::new(
192            AuditEventType::FileAccessed,
193            "ricecoder-images",
194            "system",
195            &format!("cache/{}", image_hash),
196            "cache_hit",
197            &details,
198        );
199
200        self.audit_logger.log(&entry)?;
201
202        debug!(
203            image_hash = image_hash,
204            provider = provider,
205            age_seconds = age_seconds,
206            "Cache hit logged"
207        );
208
209        Ok(())
210    }
211
212    /// Log a cache miss.
213    ///
214    /// # Arguments
215    ///
216    /// * `image_hash` - SHA256 hash of the image
217    pub fn log_cache_miss(
218        &self,
219        image_hash: &str,
220    ) -> Result<(), Box<dyn std::error::Error>> {
221        let details = format!("Cache miss for image {}", image_hash);
222
223        let entry = AuditLogEntry::new(
224            AuditEventType::FileAccessed,
225            "ricecoder-images",
226            "system",
227            &format!("cache/{}", image_hash),
228            "cache_miss",
229            &details,
230        );
231
232        self.audit_logger.log(&entry)?;
233
234        debug!(
235            image_hash = image_hash,
236            "Cache miss logged"
237        );
238
239        Ok(())
240    }
241
242    /// Log a cache eviction.
243    ///
244    /// # Arguments
245    ///
246    /// * `image_hash` - SHA256 hash of the evicted image
247    /// * `reason` - Reason for eviction (e.g., "LRU", "TTL_expired")
248    pub fn log_cache_eviction(
249        &self,
250        image_hash: &str,
251        reason: &str,
252    ) -> Result<(), Box<dyn std::error::Error>> {
253        let details = format!("Cache eviction for image {}: reason={}", image_hash, reason);
254
255        let entry = AuditLogEntry::new(
256            AuditEventType::FileModified,
257            "ricecoder-images",
258            "system",
259            &format!("cache/{}", image_hash),
260            "cache_eviction",
261            &details,
262        );
263
264        self.audit_logger.log(&entry)?;
265
266        info!(
267            image_hash = image_hash,
268            reason = reason,
269            "Cache eviction logged"
270        );
271
272        Ok(())
273    }
274
275    /// Log an invalid image format attempt.
276    ///
277    /// # Arguments
278    ///
279    /// * `file_path` - Path to the file
280    /// * `format` - Format that was attempted
281    pub fn log_invalid_format(
282        &self,
283        file_path: &str,
284        format: &str,
285    ) -> Result<(), Box<dyn std::error::Error>> {
286        let details = format!(
287            "Invalid image format attempt: file={}, format={}",
288            file_path, format
289        );
290
291        let entry = AuditLogEntry::new(
292            AuditEventType::SecurityError,
293            "ricecoder-images",
294            "system",
295            file_path,
296            "invalid_format",
297            &details,
298        );
299
300        self.audit_logger.log(&entry)?;
301
302        warn!(
303            file_path = file_path,
304            format = format,
305            "Invalid format attempt logged"
306        );
307
308        Ok(())
309    }
310
311    /// Log a file size violation.
312    ///
313    /// # Arguments
314    ///
315    /// * `file_path` - Path to the file
316    /// * `size_bytes` - Size of the file in bytes
317    /// * `max_size_bytes` - Maximum allowed size in bytes
318    pub fn log_file_size_violation(
319        &self,
320        file_path: &str,
321        size_bytes: u64,
322        max_size_bytes: u64,
323    ) -> Result<(), Box<dyn std::error::Error>> {
324        let details = format!(
325            "File size violation: file={}, size={} bytes, max={} bytes",
326            file_path, size_bytes, max_size_bytes
327        );
328
329        let entry = AuditLogEntry::new(
330            AuditEventType::SecurityError,
331            "ricecoder-images",
332            "system",
333            file_path,
334            "size_violation",
335            &details,
336        );
337
338        self.audit_logger.log(&entry)?;
339
340        warn!(
341            file_path = file_path,
342            size_bytes = size_bytes,
343            max_size_bytes = max_size_bytes,
344            "File size violation logged"
345        );
346
347        Ok(())
348    }
349
350    /// Log a path traversal attempt.
351    ///
352    /// # Arguments
353    ///
354    /// * `attempted_path` - The path that was attempted
355    pub fn log_path_traversal_attempt(
356        &self,
357        attempted_path: &str,
358    ) -> Result<(), Box<dyn std::error::Error>> {
359        let details = format!("Path traversal attempt detected: {}", attempted_path);
360
361        let entry = AuditLogEntry::new(
362            AuditEventType::SecurityError,
363            "ricecoder-images",
364            "system",
365            attempted_path,
366            "path_traversal_attempt",
367            &details,
368        );
369
370        self.audit_logger.log(&entry)?;
371
372        warn!(
373            attempted_path = attempted_path,
374            "Path traversal attempt logged"
375        );
376
377        Ok(())
378    }
379
380    /// Log an image analysis timeout.
381    ///
382    /// # Arguments
383    ///
384    /// * `provider` - Provider name
385    /// * `model` - Model identifier
386    /// * `timeout_seconds` - Timeout duration in seconds
387    /// * `image_hashes` - SHA256 hashes of images that timed out
388    pub fn log_analysis_timeout(
389        &self,
390        provider: &str,
391        model: &str,
392        timeout_seconds: u64,
393        image_hashes: Vec<String>,
394    ) -> Result<(), Box<dyn std::error::Error>> {
395        let details = format!(
396            "Image analysis timeout: provider={}, model={}, timeout={}s, hashes: {}",
397            provider,
398            model,
399            timeout_seconds,
400            image_hashes.join(",")
401        );
402
403        let entry = AuditLogEntry::new(
404            AuditEventType::SecurityError,
405            "ricecoder-images",
406            "system",
407            &format!("{}/{}", provider, model),
408            "analysis_timeout",
409            &details,
410        );
411
412        self.audit_logger.log(&entry)?;
413
414        warn!(
415            provider = provider,
416            model = model,
417            timeout_seconds = timeout_seconds,
418            "Analysis timeout logged"
419        );
420
421        Ok(())
422    }
423
424    /// Get the underlying audit logger.
425    pub fn audit_logger(&self) -> &AuditLogger {
426        &self.audit_logger
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use tempfile::TempDir;
434
435    fn create_test_logger() -> (ImageAuditLogger, PathBuf, TempDir) {
436        let temp_dir = TempDir::new().unwrap();
437        let log_path = temp_dir.path().join("audit.log");
438        let logger = ImageAuditLogger::new(log_path.clone());
439        (logger, log_path, temp_dir)
440    }
441
442    #[test]
443    fn test_log_analysis_request() {
444        let (logger, log_path, _temp_dir) = create_test_logger();
445
446        let result = logger.log_analysis_request(
447            "openai",
448            "gpt-4-vision",
449            1,
450            1024,
451            vec!["hash1".to_string()],
452        );
453
454        assert!(result.is_ok());
455
456        let content = std::fs::read_to_string(&log_path).unwrap();
457        assert!(content.contains("Image analysis request"));
458        assert!(content.contains("openai"));
459    }
460
461    #[test]
462    fn test_log_analysis_success() {
463        let (logger, log_path, _temp_dir) = create_test_logger();
464
465        let result = logger.log_analysis_success(
466            "openai",
467            "gpt-4-vision",
468            1,
469            100,
470            vec!["hash1".to_string()],
471        );
472
473        assert!(result.is_ok());
474
475        let content = std::fs::read_to_string(&log_path).unwrap();
476        assert!(content.contains("Image analysis successful"));
477        assert!(content.contains("100 tokens"));
478    }
479
480    #[test]
481    fn test_log_analysis_failure() {
482        let (logger, log_path, _temp_dir) = create_test_logger();
483
484        let result = logger.log_analysis_failure(
485            "openai",
486            "gpt-4-vision",
487            1,
488            "Provider error",
489            vec!["hash1".to_string()],
490        );
491
492        assert!(result.is_ok());
493
494        let content = std::fs::read_to_string(&log_path).unwrap();
495        assert!(content.contains("Image analysis failed"));
496        assert!(content.contains("Provider error"));
497    }
498
499    #[test]
500    fn test_log_cache_hit() {
501        let (logger, log_path, _temp_dir) = create_test_logger();
502
503        let result = logger.log_cache_hit("hash1", "openai", 3600);
504
505        assert!(result.is_ok());
506
507        let content = std::fs::read_to_string(&log_path).unwrap();
508        assert!(content.contains("Cache hit"));
509        assert!(content.contains("hash1"));
510    }
511
512    #[test]
513    fn test_log_cache_miss() {
514        let (logger, log_path, _temp_dir) = create_test_logger();
515
516        let result = logger.log_cache_miss("hash1");
517
518        assert!(result.is_ok());
519
520        let content = std::fs::read_to_string(&log_path).unwrap();
521        assert!(content.contains("Cache miss"));
522        assert!(content.contains("hash1"));
523    }
524
525    #[test]
526    fn test_log_cache_eviction() {
527        let (logger, log_path, _temp_dir) = create_test_logger();
528
529        let result = logger.log_cache_eviction("hash1", "LRU");
530
531        assert!(result.is_ok());
532
533        let content = std::fs::read_to_string(&log_path).unwrap();
534        assert!(content.contains("Cache eviction"));
535        assert!(content.contains("LRU"));
536    }
537
538    #[test]
539    fn test_log_invalid_format() {
540        let (logger, log_path, _temp_dir) = create_test_logger();
541
542        let result = logger.log_invalid_format("/path/to/file.bmp", "bmp");
543
544        assert!(result.is_ok());
545
546        let content = std::fs::read_to_string(&log_path).unwrap();
547        assert!(content.contains("Invalid image format"));
548        assert!(content.contains("bmp"));
549    }
550
551    #[test]
552    fn test_log_file_size_violation() {
553        let (logger, log_path, _temp_dir) = create_test_logger();
554
555        let result = logger.log_file_size_violation("/path/to/file.png", 20 * 1024 * 1024, 10 * 1024 * 1024);
556
557        assert!(result.is_ok());
558
559        let content = std::fs::read_to_string(&log_path).unwrap();
560        assert!(content.contains("File size violation"));
561    }
562
563    #[test]
564    fn test_log_path_traversal_attempt() {
565        let (logger, log_path, _temp_dir) = create_test_logger();
566
567        let result = logger.log_path_traversal_attempt("../../etc/passwd");
568
569        assert!(result.is_ok());
570
571        let content = std::fs::read_to_string(&log_path).unwrap();
572        assert!(content.contains("Path traversal attempt"));
573        assert!(content.contains("../../etc/passwd"));
574    }
575
576    #[test]
577    fn test_log_analysis_timeout() {
578        let (logger, log_path, _temp_dir) = create_test_logger();
579
580        let result = logger.log_analysis_timeout(
581            "openai",
582            "gpt-4-vision",
583            10,
584            vec!["hash1".to_string()],
585        );
586
587        assert!(result.is_ok());
588
589        let content = std::fs::read_to_string(&log_path).unwrap();
590        assert!(content.contains("Image analysis timeout"));
591        assert!(content.contains("10s"));
592    }
593
594    #[test]
595    fn test_multiple_audit_entries() {
596        let (logger, log_path, _temp_dir) = create_test_logger();
597
598        logger.log_analysis_request("openai", "gpt-4-vision", 1, 1024, vec!["hash1".to_string()]).unwrap();
599        logger.log_cache_hit("hash1", "openai", 3600).unwrap();
600        logger.log_analysis_success("openai", "gpt-4-vision", 1, 100, vec!["hash1".to_string()]).unwrap();
601
602        let content = std::fs::read_to_string(&log_path).unwrap();
603        let lines: Vec<&str> = content.lines().collect();
604        assert_eq!(lines.len(), 3);
605    }
606}