Skip to main content

oximedia_forensics/
lib.rs

1//! Video and Image Forensics and Tampering Detection
2//!
3//! This crate provides comprehensive forensic analysis capabilities for detecting
4//! image and video tampering, including:
5//! - JPEG compression artifact analysis
6//! - Error Level Analysis (ELA)
7//! - Noise pattern analysis and PRNU
8//! - Metadata verification
9//! - Copy-move detection
10//! - Illumination inconsistency detection
11//! - Comprehensive forensic reporting
12
13#![deny(unsafe_code)]
14#![allow(dead_code)]
15
16pub mod authenticity;
17pub mod blocking;
18pub mod chain_of_custody;
19pub mod clone_detection;
20pub mod compression;
21pub mod compression_history;
22pub mod copy_detect;
23pub mod edit_history;
24pub mod ela;
25pub mod ela_analysis;
26pub mod file_integrity;
27pub mod fingerprint;
28pub mod flat_array2;
29pub mod format_forensics;
30pub mod frame_forensics;
31pub mod frequency_forensics;
32pub mod geometric;
33pub mod hash_registry;
34pub mod lighting;
35pub mod metadata;
36pub mod metadata_forensics;
37pub mod noise;
38pub mod noise_analysis;
39pub mod pattern;
40pub mod provenance;
41pub mod report;
42pub mod shadow_analysis;
43pub mod source_camera;
44pub mod splicing;
45pub mod steganalysis;
46pub mod tampering;
47pub mod time_forensics;
48pub mod watermark_detect;
49
50use rayon::prelude::*;
51use serde::{Deserialize, Serialize};
52use std::collections::HashMap;
53use std::path::PathBuf;
54use thiserror::Error;
55
56/// Errors that can occur during forensic analysis
57#[derive(Error, Debug)]
58pub enum ForensicsError {
59    #[error("Invalid image data: {0}")]
60    InvalidImage(String),
61
62    #[error("Analysis failed: {0}")]
63    AnalysisFailed(String),
64
65    #[error("Unsupported format: {0}")]
66    UnsupportedFormat(String),
67
68    #[error("IO error: {0}")]
69    IoError(#[from] std::io::Error),
70
71    #[error("Image processing error: {0}")]
72    ImageError(#[from] image::ImageError),
73}
74
75/// Result type for forensic operations
76pub type ForensicsResult<T> = Result<T, ForensicsError>;
77
78/// Confidence level for tampering detection
79#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
80pub enum ConfidenceLevel {
81    /// Very low confidence (0-20%)
82    VeryLow,
83    /// Low confidence (20-40%)
84    Low,
85    /// Medium confidence (40-60%)
86    Medium,
87    /// High confidence (60-80%)
88    High,
89    /// Very high confidence (80-100%)
90    VeryHigh,
91}
92
93impl ConfidenceLevel {
94    /// Convert from a confidence score (0.0 to 1.0)
95    pub fn from_score(score: f64) -> Self {
96        match score {
97            s if s < 0.2 => ConfidenceLevel::VeryLow,
98            s if s < 0.4 => ConfidenceLevel::Low,
99            s if s < 0.6 => ConfidenceLevel::Medium,
100            s if s < 0.8 => ConfidenceLevel::High,
101            _ => ConfidenceLevel::VeryHigh,
102        }
103    }
104
105    /// Convert to a numeric score (0.0 to 1.0)
106    pub fn to_score(&self) -> f64 {
107        match self {
108            ConfidenceLevel::VeryLow => 0.1,
109            ConfidenceLevel::Low => 0.3,
110            ConfidenceLevel::Medium => 0.5,
111            ConfidenceLevel::High => 0.7,
112            ConfidenceLevel::VeryHigh => 0.9,
113        }
114    }
115}
116
117/// Result of a single forensic test
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ForensicTest {
120    /// Name of the test
121    pub name: String,
122    /// Whether tampering was detected
123    pub tampering_detected: bool,
124    /// Confidence score (0.0 to 1.0)
125    pub confidence: f64,
126    /// Detailed findings
127    pub findings: Vec<String>,
128    /// Anomaly map (if applicable)
129    #[serde(skip)]
130    pub anomaly_map: Option<flat_array2::FlatArray2<f64>>,
131}
132
133impl ForensicTest {
134    /// Create a new forensic test result
135    pub fn new(name: &str) -> Self {
136        Self {
137            name: name.to_string(),
138            tampering_detected: false,
139            confidence: 0.0,
140            findings: Vec::new(),
141            anomaly_map: None,
142        }
143    }
144
145    /// Add a finding to the test
146    pub fn add_finding(&mut self, finding: String) {
147        self.findings.push(finding);
148    }
149
150    /// Set the confidence level
151    pub fn set_confidence(&mut self, confidence: f64) {
152        self.confidence = confidence.clamp(0.0, 1.0);
153    }
154
155    /// Get the confidence level category
156    pub fn confidence_level(&self) -> ConfidenceLevel {
157        ConfidenceLevel::from_score(self.confidence)
158    }
159}
160
161/// Comprehensive tampering report
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct TamperingReport {
164    /// Overall tampering detected flag
165    pub tampering_detected: bool,
166    /// Overall confidence score (0.0 to 1.0)
167    pub overall_confidence: f64,
168    /// Individual test results
169    pub tests: HashMap<String, ForensicTest>,
170    /// Summary of findings
171    pub summary: String,
172    /// Recommended actions
173    pub recommendations: Vec<String>,
174}
175
176impl TamperingReport {
177    /// Create a new tampering report
178    pub fn new() -> Self {
179        Self {
180            tampering_detected: false,
181            overall_confidence: 0.0,
182            tests: HashMap::new(),
183            summary: String::new(),
184            recommendations: Vec::new(),
185        }
186    }
187
188    /// Add a test result
189    pub fn add_test(&mut self, test: ForensicTest) {
190        self.tests.insert(test.name.clone(), test);
191    }
192
193    /// Calculate overall confidence from individual tests using reliability-weighted
194    /// averaging.
195    ///
196    /// Different forensic tests have different reliability characteristics:
197    /// - ELA and compression analysis are strong indicators when positive
198    /// - Noise analysis provides moderate evidence
199    /// - Metadata analysis is supportive but less conclusive
200    /// - Geometric (copy-move) detection is highly reliable when triggered
201    /// - Lighting analysis provides supplementary evidence
202    ///
203    /// Each test's confidence is multiplied by a reliability weight before averaging.
204    /// Tests that detected tampering also receive a slight boost to reflect the
205    /// asymmetry between false positives and false negatives in forensic analysis.
206    pub fn calculate_overall_confidence(&mut self) {
207        if self.tests.is_empty() {
208            self.overall_confidence = 0.0;
209            return;
210        }
211
212        let mut weighted_sum = 0.0_f64;
213        let mut weight_total = 0.0_f64;
214
215        for test in self.tests.values() {
216            let base_weight = test_reliability_weight(&test.name);
217            // Tests that detected tampering get a 20% boost to their weight,
218            // reflecting that a positive detection from a reliable test is
219            // more informative than a negative result.
220            let effective_weight = if test.tampering_detected {
221                base_weight * 1.2
222            } else {
223                base_weight
224            };
225
226            weighted_sum += test.confidence * effective_weight;
227            weight_total += effective_weight;
228        }
229
230        self.overall_confidence = if weight_total > 0.0 {
231            (weighted_sum / weight_total).clamp(0.0, 1.0)
232        } else {
233            0.0
234        };
235
236        // Determine if tampering was detected based on threshold
237        self.tampering_detected = self.overall_confidence > 0.5;
238    }
239
240    /// Calculate overall confidence using simple (unweighted) averaging.
241    ///
242    /// This is the legacy behaviour preserved for callers that want equal
243    /// treatment of every test.
244    pub fn calculate_overall_confidence_unweighted(&mut self) {
245        if self.tests.is_empty() {
246            self.overall_confidence = 0.0;
247            return;
248        }
249
250        let total: f64 = self.tests.values().map(|t| t.confidence).sum();
251        self.overall_confidence = total / self.tests.len() as f64;
252        self.tampering_detected = self.overall_confidence > 0.5;
253    }
254
255    /// Serialize this report to a JSON string.
256    ///
257    /// Returns a pretty-printed JSON string.  Anomaly maps (which are
258    /// `#[serde(skip)]`) are not included in the output.
259    pub fn to_json(&self) -> String {
260        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
261    }
262
263    /// Generate summary
264    pub fn generate_summary(&mut self) {
265        let num_tests = self.tests.len();
266        let num_positive = self.tests.values().filter(|t| t.tampering_detected).count();
267
268        if self.tampering_detected {
269            self.summary = format!(
270                "Tampering detected with {:.1}% confidence. {} out of {} tests indicated manipulation.",
271                self.overall_confidence * 100.0,
272                num_positive,
273                num_tests
274            );
275        } else {
276            self.summary = format!(
277                "No significant tampering detected. {} out of {} tests passed.",
278                num_tests - num_positive,
279                num_tests
280            );
281        }
282    }
283}
284
285impl Default for TamperingReport {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291/// Configuration for forensic analysis
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct ForensicsConfig {
294    /// Enable compression analysis
295    pub enable_compression_analysis: bool,
296    /// Enable ELA
297    pub enable_ela: bool,
298    /// Enable noise analysis
299    pub enable_noise_analysis: bool,
300    /// Enable metadata analysis
301    pub enable_metadata_analysis: bool,
302    /// Enable geometric analysis
303    pub enable_geometric_analysis: bool,
304    /// Enable lighting analysis
305    pub enable_lighting_analysis: bool,
306    /// Minimum confidence threshold for reporting
307    pub min_confidence_threshold: f64,
308}
309
310impl Default for ForensicsConfig {
311    fn default() -> Self {
312        Self {
313            enable_compression_analysis: true,
314            enable_ela: true,
315            enable_noise_analysis: true,
316            enable_metadata_analysis: true,
317            enable_geometric_analysis: true,
318            enable_lighting_analysis: true,
319            min_confidence_threshold: 0.5,
320        }
321    }
322}
323
324/// Per-test reliability weight configuration.
325///
326/// Allows callers to override the default reliability weights used by
327/// [`TamperingReport::calculate_overall_confidence`].  Each field specifies
328/// the weight for the corresponding forensic test category.
329///
330/// Weights are relative — they do not need to sum to 1.0.  Tests whose name
331/// does not match any category receive the `default_weight`.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct TestWeight {
334    /// Weight for ELA / Error Level Analysis tests (default 1.3)
335    pub ela: f64,
336    /// Weight for PRNU / noise analysis tests (default 1.0)
337    pub prnu: f64,
338    /// Weight for copy-move / geometric detection tests (default 1.5)
339    pub copy_move: f64,
340    /// Weight for compression / DCT analysis tests (default 1.2)
341    pub compression: f64,
342    /// Weight for lighting / shadow analysis tests (default 0.9)
343    pub lighting: f64,
344    /// Weight for metadata / EXIF analysis tests (default 0.7)
345    pub metadata: f64,
346    /// Fallback weight for tests not matching any category (default 1.0)
347    pub default_weight: f64,
348}
349
350impl Default for TestWeight {
351    fn default() -> Self {
352        Self {
353            ela: 1.3,
354            prnu: 1.0,
355            copy_move: 1.5,
356            compression: 1.2,
357            lighting: 0.9,
358            metadata: 0.7,
359            default_weight: 1.0,
360        }
361    }
362}
363
364impl TestWeight {
365    /// Look up the weight for a test by its name.
366    #[must_use]
367    pub fn weight_for(&self, test_name: &str) -> f64 {
368        let lower = test_name.to_lowercase();
369        if lower.contains("copy") || lower.contains("geometric") || lower.contains("clone") {
370            self.copy_move
371        } else if lower.contains("ela") || lower.contains("error level") {
372            self.ela
373        } else if lower.contains("compress") || lower.contains("dct") || lower.contains("jpeg") {
374            self.compression
375        } else if lower.contains("noise") || lower.contains("prnu") {
376            self.prnu
377        } else if lower.contains("light") || lower.contains("shadow") || lower.contains("illuminat")
378        {
379            self.lighting
380        } else if lower.contains("metadata") || lower.contains("exif") || lower.contains("software")
381        {
382            self.metadata
383        } else {
384            self.default_weight
385        }
386    }
387}
388
389/// Main forensics analyzer
390pub struct ForensicsAnalyzer {
391    config: ForensicsConfig,
392}
393
394impl ForensicsAnalyzer {
395    /// Create a new forensics analyzer with default configuration
396    pub fn new() -> Self {
397        Self {
398            config: ForensicsConfig::default(),
399        }
400    }
401
402    /// Create a new forensics analyzer with custom configuration
403    pub fn with_config(config: ForensicsConfig) -> Self {
404        Self { config }
405    }
406
407    /// Analyze an image for tampering.
408    ///
409    /// Independent forensic tests are executed concurrently via rayon, so
410    /// multi-core systems will see significant throughput improvements on
411    /// large images.  Metadata analysis runs on the raw image bytes and is
412    /// also dispatched in parallel with the pixel-level tests.
413    pub fn analyze(&self, image_data: &[u8]) -> ForensicsResult<TamperingReport> {
414        // Parse image once — shared across all pixel-level tests.
415        let image = image::load_from_memory(image_data)?;
416        let rgb_image = image.to_rgb8();
417
418        // Collect enabled pixel-level tasks as closures so rayon can run
419        // them in parallel.  Each closure returns ForensicsResult<ForensicTest>.
420        type TaskFn<'a> = Box<dyn Fn() -> ForensicsResult<ForensicTest> + Send + Sync + 'a>;
421
422        let mut tasks: Vec<TaskFn<'_>> = Vec::new();
423
424        if self.config.enable_compression_analysis {
425            tasks.push(Box::new(|| compression::analyze_compression(&rgb_image)));
426        }
427        if self.config.enable_ela {
428            tasks.push(Box::new(|| ela::perform_ela(&rgb_image)));
429        }
430        if self.config.enable_noise_analysis {
431            tasks.push(Box::new(|| noise::analyze_noise(&rgb_image)));
432        }
433        if self.config.enable_geometric_analysis {
434            tasks.push(Box::new(|| geometric::detect_copy_move(&rgb_image)));
435        }
436        if self.config.enable_lighting_analysis {
437            tasks.push(Box::new(|| lighting::analyze_lighting(&rgb_image)));
438        }
439
440        // Run pixel-level tests in parallel.
441        let pixel_results: Vec<ForensicsResult<ForensicTest>> =
442            tasks.par_iter().map(|f| f()).collect();
443
444        // Metadata analysis operates on the raw bytes; run it concurrently
445        // with the pixel tests using a separate par_iter scope.
446        let metadata_result: Option<ForensicsResult<ForensicTest>> =
447            if self.config.enable_metadata_analysis {
448                Some(metadata::analyze_metadata(image_data))
449            } else {
450                None
451            };
452
453        // Collect results into the report, propagating the first error.
454        let mut report = TamperingReport::new();
455
456        for result in pixel_results {
457            report.add_test(result?);
458        }
459        if let Some(result) = metadata_result {
460            report.add_test(result?);
461        }
462
463        // Calculate overall results.
464        report.calculate_overall_confidence();
465        report.generate_summary();
466
467        Ok(report)
468    }
469
470    /// Analyze a batch of image files in parallel using rayon.
471    ///
472    /// Each path is read from disk and analyzed independently.  Files that
473    /// cannot be read or analyzed produce a report with default (clean) values
474    /// and a summary indicating the failure.
475    ///
476    /// # Errors
477    ///
478    /// This method never returns `Err` at the batch level — per-file failures
479    /// are captured in the individual reports via the summary field.
480    pub fn analyze_batch(&self, paths: &[PathBuf]) -> Vec<TamperingReport> {
481        paths
482            .par_iter()
483            .map(|path| {
484                let data = match std::fs::read(path) {
485                    Ok(d) => d,
486                    Err(e) => {
487                        let mut report = TamperingReport::new();
488                        report.summary = format!("Failed to read {}: {}", path.display(), e);
489                        return report;
490                    }
491                };
492                match self.analyze(&data) {
493                    Ok(report) => report,
494                    Err(e) => {
495                        let mut report = TamperingReport::new();
496                        report.summary = format!("Analysis failed for {}: {}", path.display(), e);
497                        report
498                    }
499                }
500            })
501            .collect()
502    }
503
504    /// Get the current configuration
505    pub fn config(&self) -> &ForensicsConfig {
506        &self.config
507    }
508
509    /// Update the configuration
510    pub fn set_config(&mut self, config: ForensicsConfig) {
511        self.config = config;
512    }
513}
514
515impl Default for ForensicsAnalyzer {
516    fn default() -> Self {
517        Self::new()
518    }
519}
520
521/// Return the reliability weight for a forensic test identified by name.
522///
523/// Weights are calibrated based on the empirical false-positive / false-negative
524/// rates of each test methodology:
525///
526/// | Test category        | Weight | Rationale                                    |
527/// |----------------------|--------|----------------------------------------------|
528/// | Copy-move detection  | 1.5    | Highly specific when triggered                |
529/// | ELA                  | 1.3    | Strong but sensitive to JPEG quality           |
530/// | Compression analysis | 1.2    | Reliable double-compression indicator          |
531/// | Noise analysis       | 1.0    | Moderate reliability (baseline)                |
532/// | Lighting analysis    | 0.9    | Useful but geometry-dependent                  |
533/// | Metadata analysis    | 0.7    | Supportive; easily spoofed or stripped          |
534///
535/// Unknown test names receive a baseline weight of `1.0`.
536#[must_use]
537pub fn test_reliability_weight(test_name: &str) -> f64 {
538    let lower = test_name.to_lowercase();
539    if lower.contains("copy") || lower.contains("geometric") || lower.contains("clone") {
540        1.5
541    } else if lower.contains("ela") || lower.contains("error level") {
542        1.3
543    } else if lower.contains("compress") || lower.contains("dct") || lower.contains("jpeg") {
544        1.2
545    } else if lower.contains("noise") || lower.contains("prnu") {
546        1.0
547    } else if lower.contains("light") || lower.contains("shadow") || lower.contains("illuminat") {
548        0.9
549    } else if lower.contains("metadata") || lower.contains("exif") || lower.contains("software") {
550        0.7
551    } else {
552        1.0
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_confidence_level_conversion() {
562        assert_eq!(ConfidenceLevel::from_score(0.1), ConfidenceLevel::VeryLow);
563        assert_eq!(ConfidenceLevel::from_score(0.3), ConfidenceLevel::Low);
564        assert_eq!(ConfidenceLevel::from_score(0.5), ConfidenceLevel::Medium);
565        assert_eq!(ConfidenceLevel::from_score(0.7), ConfidenceLevel::High);
566        assert_eq!(ConfidenceLevel::from_score(0.9), ConfidenceLevel::VeryHigh);
567    }
568
569    #[test]
570    fn test_forensic_test_creation() {
571        let mut test = ForensicTest::new("Test");
572        assert_eq!(test.name, "Test");
573        assert!(!test.tampering_detected);
574        assert_eq!(test.confidence, 0.0);
575
576        test.add_finding("Finding 1".to_string());
577        test.set_confidence(0.75);
578        assert_eq!(test.findings.len(), 1);
579        assert_eq!(test.confidence, 0.75);
580    }
581
582    #[test]
583    fn test_tampering_report_unweighted() {
584        let mut report = TamperingReport::new();
585
586        let mut test1 = ForensicTest::new("Test1");
587        test1.set_confidence(0.8);
588        test1.tampering_detected = true;
589
590        let mut test2 = ForensicTest::new("Test2");
591        test2.set_confidence(0.6);
592        test2.tampering_detected = true;
593
594        report.add_test(test1);
595        report.add_test(test2);
596        report.calculate_overall_confidence_unweighted();
597
598        assert_eq!(report.tests.len(), 2);
599        assert!((report.overall_confidence - 0.7).abs() < 1e-10);
600        assert!(report.tampering_detected);
601    }
602
603    #[test]
604    fn test_tampering_report_weighted_confidence_empty() {
605        let mut report = TamperingReport::new();
606        report.calculate_overall_confidence();
607        assert!((report.overall_confidence).abs() < 1e-10);
608        assert!(!report.tampering_detected);
609    }
610
611    #[test]
612    fn test_tampering_report_weighted_confidence_single_test() {
613        let mut report = TamperingReport::new();
614        let mut test = ForensicTest::new("ELA Analysis");
615        test.set_confidence(0.8);
616        test.tampering_detected = true;
617        report.add_test(test);
618        report.calculate_overall_confidence();
619        // Single test: weighted confidence == confidence (weight cancels)
620        assert!((report.overall_confidence - 0.8).abs() < 1e-10);
621    }
622
623    #[test]
624    fn test_weighted_confidence_strong_test_dominates() {
625        let mut report = TamperingReport::new();
626
627        // High-weight test (copy-move, weight 1.5) with high confidence
628        let mut copy_move = ForensicTest::new("Copy-Move Detection");
629        copy_move.set_confidence(0.9);
630        copy_move.tampering_detected = true;
631
632        // Low-weight test (metadata, weight 0.7) with low confidence
633        let mut metadata = ForensicTest::new("Metadata Analysis");
634        metadata.set_confidence(0.1);
635        metadata.tampering_detected = false;
636
637        report.add_test(copy_move);
638        report.add_test(metadata);
639        report.calculate_overall_confidence();
640
641        // Weighted average should be closer to 0.9 than simple average 0.5
642        assert!(report.overall_confidence > 0.5);
643
644        // Compare with unweighted
645        let mut report2 = TamperingReport::new();
646        let mut cm2 = ForensicTest::new("Copy-Move Detection");
647        cm2.set_confidence(0.9);
648        cm2.tampering_detected = true;
649        let mut md2 = ForensicTest::new("Metadata Analysis");
650        md2.set_confidence(0.1);
651        md2.tampering_detected = false;
652        report2.add_test(cm2);
653        report2.add_test(md2);
654        report2.calculate_overall_confidence_unweighted();
655
656        // Weighted should give higher confidence when strong test dominates
657        assert!(report.overall_confidence > report2.overall_confidence);
658    }
659
660    #[test]
661    fn test_weighted_confidence_detection_boost() {
662        // Two identical-confidence tests, one detected tampering
663        let mut report = TamperingReport::new();
664
665        let mut t1 = ForensicTest::new("Noise Analysis");
666        t1.set_confidence(0.6);
667        t1.tampering_detected = true;
668
669        let mut t2 = ForensicTest::new("Noise Check");
670        t2.set_confidence(0.6);
671        t2.tampering_detected = false;
672
673        report.add_test(t1);
674        report.add_test(t2);
675        report.calculate_overall_confidence();
676
677        // The detecting test gets a 1.2x boost, so the weighted average
678        // should be slightly above the unweighted 0.6
679        assert!(report.overall_confidence > 0.59);
680        assert!(report.overall_confidence < 0.65);
681    }
682
683    #[test]
684    fn test_reliability_weight_categories() {
685        assert!((test_reliability_weight("Copy-Move Detection") - 1.5).abs() < 1e-10);
686        assert!((test_reliability_weight("ELA Analysis") - 1.3).abs() < 1e-10);
687        assert!((test_reliability_weight("JPEG Compression Analysis") - 1.2).abs() < 1e-10);
688        assert!((test_reliability_weight("Noise Analysis") - 1.0).abs() < 1e-10);
689        assert!((test_reliability_weight("Lighting Analysis") - 0.9).abs() < 1e-10);
690        assert!((test_reliability_weight("Metadata Analysis") - 0.7).abs() < 1e-10);
691        assert!((test_reliability_weight("Unknown Test") - 1.0).abs() < 1e-10);
692    }
693
694    #[test]
695    fn test_weighted_confidence_all_below_threshold() {
696        let mut report = TamperingReport::new();
697
698        let mut t1 = ForensicTest::new("ELA");
699        t1.set_confidence(0.2);
700        t1.tampering_detected = false;
701
702        let mut t2 = ForensicTest::new("Metadata");
703        t2.set_confidence(0.3);
704        t2.tampering_detected = false;
705
706        report.add_test(t1);
707        report.add_test(t2);
708        report.calculate_overall_confidence();
709
710        assert!(!report.tampering_detected);
711        assert!(report.overall_confidence < 0.5);
712    }
713
714    #[test]
715    fn test_weighted_confidence_clamped_to_unit() {
716        let mut report = TamperingReport::new();
717        let mut t = ForensicTest::new("ELA");
718        t.set_confidence(1.0);
719        t.tampering_detected = true;
720        report.add_test(t);
721        report.calculate_overall_confidence();
722        assert!(report.overall_confidence <= 1.0);
723    }
724
725    // ── TamperingReport::to_json ───────────────────────────────────────────────
726
727    #[test]
728    fn test_tampering_report_to_json_empty() {
729        let report = TamperingReport::new();
730        let json = report.to_json();
731        assert!(json.contains("overall_confidence"));
732        assert!(json.contains("tampering_detected"));
733        assert!(json.contains("tests"));
734    }
735
736    #[test]
737    fn test_tampering_report_to_json_with_tests() {
738        let mut report = TamperingReport::new();
739        let mut test = ForensicTest::new("ELA Analysis");
740        test.set_confidence(0.75);
741        test.tampering_detected = true;
742        test.add_finding("Suspicious region detected".to_string());
743        report.add_test(test);
744        report.calculate_overall_confidence();
745        report.generate_summary();
746
747        let json = report.to_json();
748        assert!(json.contains("ELA Analysis"));
749        assert!(json.contains("0.75"));
750        // Verify it's valid JSON by checking well-formedness
751        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should be valid JSON");
752        assert!(parsed.get("tampering_detected").is_some());
753    }
754
755    #[test]
756    fn test_tampering_report_to_json_is_valid_json() {
757        let mut report = TamperingReport::new();
758        let mut t1 = ForensicTest::new("Copy-Move Detection");
759        t1.set_confidence(0.9);
760        t1.tampering_detected = true;
761        let mut t2 = ForensicTest::new("Metadata Analysis");
762        t2.set_confidence(0.1);
763        report.add_test(t1);
764        report.add_test(t2);
765        report.calculate_overall_confidence();
766
767        let json = report.to_json();
768        let _parsed: serde_json::Value =
769            serde_json::from_str(&json).expect("to_json must produce valid JSON");
770    }
771
772    // ── ForensicsAnalyzer::analyze_batch ──────────────────────────────────────
773
774    #[test]
775    fn test_analyze_batch_empty_paths() {
776        let analyzer = ForensicsAnalyzer::new();
777        let results = analyzer.analyze_batch(&[]);
778        assert!(results.is_empty());
779    }
780
781    #[test]
782    fn test_analyze_batch_nonexistent_path() {
783        let analyzer = ForensicsAnalyzer::new();
784        let path = std::path::PathBuf::from("/nonexistent/path/image.jpg");
785        let results = analyzer.analyze_batch(&[path]);
786        assert_eq!(results.len(), 1);
787        // Should not panic; result should have an error summary
788        assert!(!results[0].summary.is_empty());
789    }
790
791    #[test]
792    fn test_analyze_batch_valid_images() {
793        use std::io::Cursor;
794        use std::io::Write;
795
796        // Use a 128×128 image — large enough for all forensic analysis kernels
797        // (the smallest kernel requires at least 64 pixels per dimension).
798        let img = image::RgbImage::new(128, 128);
799        let dyn_img = image::DynamicImage::ImageRgb8(img);
800        let mut buf = Cursor::new(Vec::new());
801        dyn_img
802            .write_to(&mut buf, image::ImageFormat::Png)
803            .expect("PNG encoding should work");
804        let png_bytes = buf.into_inner();
805
806        // Write to temp file.
807        let mut tmp = std::env::temp_dir();
808        tmp.push("oximedia_forensics_batch_test.png");
809        {
810            let mut f = std::fs::File::create(&tmp).expect("temp file creation");
811            f.write_all(&png_bytes).expect("write PNG bytes");
812        }
813
814        let analyzer = ForensicsAnalyzer::new();
815        let results = analyzer.analyze_batch(&[tmp.clone()]);
816        assert_eq!(results.len(), 1);
817        // Clean image should produce a valid report.
818        assert!(!results[0].summary.is_empty());
819
820        // Cleanup.
821        let _ = std::fs::remove_file(&tmp);
822    }
823
824    // ── TestWeight ────────────────────────────────────────────────────────────
825
826    #[test]
827    fn test_test_weight_defaults() {
828        let tw = TestWeight::default();
829        assert!((tw.ela - 1.3).abs() < 1e-10);
830        assert!((tw.prnu - 1.0).abs() < 1e-10);
831        assert!((tw.copy_move - 1.5).abs() < 1e-10);
832        assert!((tw.compression - 1.2).abs() < 1e-10);
833        assert!((tw.lighting - 0.9).abs() < 1e-10);
834        assert!((tw.metadata - 0.7).abs() < 1e-10);
835        assert!((tw.default_weight - 1.0).abs() < 1e-10);
836    }
837
838    #[test]
839    fn test_test_weight_lookup() {
840        let tw = TestWeight::default();
841        assert!((tw.weight_for("ELA Analysis") - 1.3).abs() < 1e-10);
842        assert!((tw.weight_for("Copy-Move Detection") - 1.5).abs() < 1e-10);
843        assert!((tw.weight_for("JPEG Compression Analysis") - 1.2).abs() < 1e-10);
844        assert!((tw.weight_for("Noise Analysis PRNU") - 1.0).abs() < 1e-10);
845        assert!((tw.weight_for("Lighting Analysis") - 0.9).abs() < 1e-10);
846        assert!((tw.weight_for("Metadata Analysis") - 0.7).abs() < 1e-10);
847        assert!((tw.weight_for("Unknown Custom Test") - 1.0).abs() < 1e-10);
848    }
849
850    #[test]
851    fn test_test_weight_custom_values() {
852        let tw = TestWeight {
853            ela: 2.0,
854            prnu: 0.5,
855            copy_move: 3.0,
856            compression: 1.0,
857            lighting: 0.5,
858            metadata: 0.3,
859            default_weight: 1.5,
860        };
861        assert!((tw.weight_for("ELA") - 2.0).abs() < 1e-10);
862        assert!((tw.weight_for("Clone Detection") - 3.0).abs() < 1e-10);
863        assert!((tw.weight_for("PRNU Extraction") - 0.5).abs() < 1e-10);
864        assert!((tw.weight_for("Shadow Analysis") - 0.5).abs() < 1e-10);
865        assert!((tw.weight_for("EXIF Check") - 0.3).abs() < 1e-10);
866        assert!((tw.weight_for("Anything Else") - 1.5).abs() < 1e-10);
867    }
868}