shadowforge_lib/adapters/
analysis.rs1use 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#[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 #[must_use]
29 pub fn new() -> Self {
30 Self {
31 matcher: crate::adapters::adaptive::CoverProfileMatcherImpl::with_built_in(),
32 }
33 }
34
35 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 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 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}