Skip to main content

shadowforge_lib/adapters/
analysis.rs

1//! Adapter implementing the [`CapacityAnalyser`] port.
2
3use crate::domain::analysis::{
4    chi_square_score, classify_risk, estimate_capacity, recommended_payload,
5};
6use crate::domain::errors::AdaptiveError;
7use crate::domain::errors::AnalysisError;
8use crate::domain::ports::CapacityAnalyser;
9use crate::domain::types::{AnalysisReport, Capacity, CoverMedia, StegoTechnique};
10
11/// Concrete [`CapacityAnalyser`] implementation.
12///
13/// This type is `#[non_exhaustive]`; always construct it via [`Self::new`] or
14/// [`Self::from_codebook`] rather than struct-literal syntax.
15#[non_exhaustive]
16pub struct CapacityAnalyserImpl {
17    matcher: crate::adapters::adaptive::CoverProfileMatcherImpl,
18}
19
20impl Default for CapacityAnalyserImpl {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl CapacityAnalyserImpl {
27    /// Create a new analyser.
28    #[must_use]
29    pub fn new() -> Self {
30        Self {
31            matcher: crate::adapters::adaptive::CoverProfileMatcherImpl::with_built_in(),
32        }
33    }
34
35    /// Create an analyser with an explicit AI profile codebook.
36    ///
37    /// # Errors
38    /// Returns [`AdaptiveError::ProfileMatchFailed`] if the codebook is invalid.
39    pub fn from_codebook(codebook_json: &str) -> Result<Self, AdaptiveError> {
40        Ok(Self {
41            matcher: crate::adapters::adaptive::CoverProfileMatcherImpl::from_codebook(
42                codebook_json,
43            )?,
44        })
45    }
46}
47
48impl CapacityAnalyser for CapacityAnalyserImpl {
49    fn analyse(
50        &self,
51        cover: &CoverMedia,
52        technique: StegoTechnique,
53    ) -> Result<AnalysisReport, AnalysisError> {
54        let cap_bytes = estimate_capacity(cover, technique);
55        if cap_bytes == 0 {
56            return Err(AnalysisError::UnsupportedCoverType {
57                reason: format!("{:?} is not compatible with {:?}", cover.kind, technique),
58            });
59        }
60
61        let chi_sq = chi_square_score(&cover.data);
62        let risk = classify_risk(chi_sq);
63        let recommended = recommended_payload(cap_bytes, risk);
64
65        Ok(AnalysisReport {
66            technique,
67            cover_capacity: Capacity {
68                bytes: cap_bytes,
69                technique,
70            },
71            chi_square_score: chi_sq,
72            detectability_risk: risk,
73            recommended_max_payload_bytes: recommended,
74            ai_watermark: self.matcher.assess_ai_watermark(cover),
75            spectral_score: None,
76        })
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::domain::types::{CoverMediaKind, DetectabilityRisk};
84    use bytes::Bytes;
85    use std::collections::HashMap;
86
87    type TestResult = Result<(), Box<dyn std::error::Error>>;
88
89    fn make_cover(kind: CoverMediaKind, data: Vec<u8>) -> CoverMedia {
90        CoverMedia {
91            kind,
92            data: Bytes::from(data),
93            metadata: HashMap::new(),
94        }
95    }
96
97    fn make_image_cover(
98        kind: CoverMediaKind,
99        width: u32,
100        height: u32,
101        data: Vec<u8>,
102    ) -> CoverMedia {
103        let mut metadata = HashMap::new();
104        metadata.insert("width".to_string(), width.to_string());
105        metadata.insert("height".to_string(), height.to_string());
106        CoverMedia {
107            kind,
108            data: Bytes::from(data),
109            metadata,
110        }
111    }
112
113    #[test]
114    fn analyse_png_lsb_low_risk_for_uniform() -> TestResult {
115        let analyser = CapacityAnalyserImpl::new();
116        // Uniform-ish data for low chi-square
117        let data: Vec<u8> = (0..=255).cycle().take(256 * 40).collect();
118        let cover = make_image_cover(CoverMediaKind::PngImage, 64, 40, data);
119        let report = analyser.analyse(&cover, StegoTechnique::LsbImage)?;
120
121        assert!(report.cover_capacity.bytes > 0);
122        assert_eq!(report.detectability_risk, DetectabilityRisk::Low);
123        assert!(report.recommended_max_payload_bytes > 0);
124        assert!(report.ai_watermark.is_some());
125        Ok(())
126    }
127
128    #[test]
129    fn analyse_returns_error_for_incompatible_type() {
130        let analyser = CapacityAnalyserImpl::new();
131        let cover = make_cover(CoverMediaKind::WavAudio, vec![0u8; 1000]);
132        let result = analyser.analyse(&cover, StegoTechnique::LsbImage);
133        assert!(result.is_err());
134    }
135
136    #[test]
137    fn analyse_pdf_content_stream() -> TestResult {
138        let analyser = CapacityAnalyserImpl::new();
139        let cover = make_cover(CoverMediaKind::PdfDocument, vec![0u8; 50_000]);
140        let report = analyser.analyse(&cover, StegoTechnique::PdfContentStream)?;
141        assert!(report.cover_capacity.bytes > 0);
142        Ok(())
143    }
144
145    #[test]
146    fn analyse_corpus_selection_low_risk() -> TestResult {
147        let analyser = CapacityAnalyserImpl::new();
148        let data: Vec<u8> = (0..=255).cycle().take(256 * 32).collect();
149        let cover = make_image_cover(CoverMediaKind::PngImage, 64, 32, data);
150        let report = analyser.analyse(&cover, StegoTechnique::CorpusSelection)?;
151        // Corpus selection should always be low risk if the cover data is uniform
152        assert_eq!(report.detectability_risk, DetectabilityRisk::Low);
153        Ok(())
154    }
155
156    #[test]
157    fn analyse_reports_ai_watermark_match_for_matching_cover() -> TestResult {
158        let analyser = CapacityAnalyserImpl::from_codebook(
159            r#"{"profiles":[{"model_id":"test-ai","channel_weights":[1.0,1.0,1.0],"carrier_map":{"8x8":[{"freq":[0,0],"phase":0.0,"coherence":1.0}]}}]}"#,
160        )?;
161        let cover = make_image_cover(CoverMediaKind::PngImage, 8, 8, vec![128u8; 8 * 8 * 4]);
162
163        let report = analyser.analyse(&cover, StegoTechnique::LsbImage)?;
164        assert!(
165            report.ai_watermark.is_some(),
166            "image analysis should include ai watermark assessment"
167        );
168        let Some(ai_watermark) = report.ai_watermark else {
169            return Ok(());
170        };
171        assert!(ai_watermark.detected);
172        assert_eq!(ai_watermark.model_id.as_deref(), Some("test-ai"));
173        assert_eq!(ai_watermark.matched_strong_bins, 1);
174        assert_eq!(ai_watermark.total_strong_bins, 1);
175        Ok(())
176    }
177
178    #[test]
179    fn report_serialises_to_json() -> TestResult {
180        let analyser = CapacityAnalyserImpl::new();
181        let data: Vec<u8> = (0..=255).cycle().take(8192).collect();
182        let cover = make_cover(CoverMediaKind::PngImage, data);
183        let report = analyser.analyse(&cover, StegoTechnique::LsbImage)?;
184        let json = serde_json::to_string(&report)?;
185        assert!(json.contains("\"technique\""));
186        assert!(json.contains("\"chi_square_score\""));
187        Ok(())
188    }
189}