Skip to main content

shadowforge_lib/domain/analysis/
mod.rs

1//! Capacity estimation and chi-square detectability analysis, plus
2//! spectral-domain detectability scoring.
3//!
4//! Pure domain logic — no I/O, no file system, no async runtime.
5
6use bytes::Bytes;
7use rustfft::FftPlanner;
8use rustfft::num_complex::Complex;
9
10use crate::domain::ports::AiGenProfile;
11use crate::domain::types::{
12    CoverMedia, CoverMediaKind, DetectabilityRisk, SpectralScore, StegoTechnique,
13};
14
15/// `DetectabilityRisk` thresholds in dB.
16const HIGH_THRESHOLD_DB: f64 = -6.0;
17const MEDIUM_THRESHOLD_DB: f64 = -12.0;
18
19/// Classify detectability risk from a chi-square score in dB.
20#[must_use]
21pub fn classify_risk(chi_square_db: f64) -> DetectabilityRisk {
22    if chi_square_db > HIGH_THRESHOLD_DB {
23        DetectabilityRisk::High
24    } else if chi_square_db > MEDIUM_THRESHOLD_DB {
25        DetectabilityRisk::Medium
26    } else {
27        DetectabilityRisk::Low
28    }
29}
30
31/// Compute recommended max payload bytes for a given capacity and risk.
32#[must_use]
33pub const fn recommended_payload(capacity_bytes: u64, risk: DetectabilityRisk) -> u64 {
34    match risk {
35        DetectabilityRisk::Low => capacity_bytes / 2,
36        DetectabilityRisk::Medium => capacity_bytes / 4,
37        DetectabilityRisk::High => capacity_bytes / 8,
38    }
39}
40
41/// Estimate embedding capacity for a cover/technique pair.
42///
43/// Returns capacity in bytes.
44#[must_use]
45pub fn estimate_capacity(cover: &CoverMedia, technique: StegoTechnique) -> u64 {
46    match technique {
47        StegoTechnique::LsbImage => estimate_image_lsb_capacity(cover),
48        StegoTechnique::DctJpeg => estimate_jpeg_dct_capacity(cover),
49        StegoTechnique::Palette => estimate_palette_capacity(cover),
50        StegoTechnique::LsbAudio => estimate_audio_lsb_capacity(cover),
51        StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
52            // Audio techniques: ~1 bit per segment
53            estimate_audio_lsb_capacity(cover) / 8
54        }
55        StegoTechnique::ZeroWidthText => estimate_text_capacity(cover),
56        StegoTechnique::PdfContentStream => estimate_pdf_content_capacity(cover),
57        StegoTechnique::PdfMetadata => estimate_pdf_metadata_capacity(cover),
58        StegoTechnique::CorpusSelection => {
59            // Corpus reuses LsbImage capacity of the matched cover
60            estimate_image_lsb_capacity(cover)
61        }
62        StegoTechnique::DualPayload => {
63            // Dual payload splits capacity in half
64            estimate_image_lsb_capacity(cover) / 2
65        }
66    }
67}
68
69/// Chi-square statistic on byte value distribution.
70///
71/// Measures how uniformly distributed the LSBs are. A perfectly random
72/// distribution scores low (close to 0 dB below expected).
73#[must_use]
74#[expect(
75    clippy::cast_precision_loss,
76    reason = "byte histogram counts are small enough for f64"
77)]
78pub fn chi_square_score(data: &[u8]) -> f64 {
79    if data.is_empty() {
80        return 0.0;
81    }
82
83    // Build byte histogram (256 bins)
84    let mut histogram = [0u64; 256];
85    for &b in data {
86        // usize::from(u8) is always 0..=255, histogram has 256 entries
87        #[expect(
88            clippy::indexing_slicing,
89            reason = "u8 index into [_; 256] cannot be out of bounds"
90        )]
91        {
92            histogram[usize::from(b)] = histogram[usize::from(b)].strict_add(1);
93        }
94    }
95
96    let expected = data.len() as f64 / 256.0;
97    if expected < f64::EPSILON {
98        return 0.0;
99    }
100
101    let chi_sq: f64 = histogram
102        .iter()
103        .map(|&count| {
104            let diff = count as f64 - expected;
105            (diff * diff) / expected
106        })
107        .sum();
108
109    // Convert to dB scale relative to expected (255 degrees of freedom)
110    let normalised = chi_sq / 255.0;
111    if normalised < f64::EPSILON {
112        -100.0 // Essentially undetectable
113    } else {
114        10.0 * normalised.log10()
115    }
116}
117
118/// Compute a pair-delta chi-square score on `data`.
119///
120/// Builds a 256-bin histogram of consecutive byte differences
121/// `data[i+1].wrapping_sub(data[i])` and measures how far that distribution
122/// deviates from the flat prior expected for independent random bytes.
123/// Lower score = less detectable.
124///
125/// Unlike [`chi_square_score`], this score **is** order-sensitive: swapping
126/// two non-adjacent bytes changes the pairs they participate in and therefore
127/// changes the score.  This makes it suitable as the hill-climb objective in
128/// [`crate::domain::adaptive::permutation_search`].
129#[must_use]
130#[expect(
131    clippy::cast_precision_loss,
132    reason = "pair counts are small enough for f64"
133)]
134pub fn pair_delta_chi_square_score(data: &[u8]) -> f64 {
135    if data.len() < 2 {
136        return 0.0;
137    }
138
139    let mut histogram = [0u64; 256];
140    for pair in data.array_windows::<2>() {
141        let delta = pair[1].wrapping_sub(pair[0]);
142        #[expect(
143            clippy::indexing_slicing,
144            reason = "delta is a u8, always 0..=255, histogram has 256 entries"
145        )]
146        {
147            histogram[usize::from(delta)] = histogram[usize::from(delta)].strict_add(1);
148        }
149    }
150
151    let n_pairs = data.len().strict_sub(1);
152    let expected = n_pairs as f64 / 256.0;
153    if expected < f64::EPSILON {
154        return 0.0;
155    }
156
157    let chi_sq: f64 = histogram
158        .iter()
159        .map(|&count| {
160            let diff = count as f64 - expected;
161            (diff * diff) / expected
162        })
163        .sum();
164
165    let normalised = chi_sq / 255.0;
166    if normalised < f64::EPSILON {
167        -100.0
168    } else {
169        10.0 * normalised.log10()
170    }
171}
172
173// ─── Private capacity estimators ──────────────────────────────────────────────
174
175const fn estimate_image_lsb_capacity(cover: &CoverMedia) -> u64 {
176    match cover.kind {
177        CoverMediaKind::PngImage | CoverMediaKind::BmpImage => {
178            // ~1 bit per colour channel per pixel, 3 channels
179            // Rough estimate: data.len() / 8 (header overhead subtracted)
180            let usable = cover.data.len().saturating_sub(54); // BMP header ~54
181            (usable / 8) as u64
182        }
183        CoverMediaKind::GifImage => (cover.data.len().saturating_sub(128) / 16) as u64,
184        _ => 0,
185    }
186}
187
188fn estimate_jpeg_dct_capacity(cover: &CoverMedia) -> u64 {
189    if cover.kind != CoverMediaKind::JpegImage {
190        return 0;
191    }
192    // ~1 bit per nonzero AC coefficient; rough: data_len / 16
193    (cover.data.len() / 16) as u64
194}
195
196const fn estimate_palette_capacity(cover: &CoverMedia) -> u64 {
197    match cover.kind {
198        CoverMediaKind::GifImage | CoverMediaKind::PngImage => {
199            // ~1 bit per palette entry reorder
200            (cover.data.len().saturating_sub(128) / 32) as u64
201        }
202        _ => 0,
203    }
204}
205
206fn estimate_audio_lsb_capacity(cover: &CoverMedia) -> u64 {
207    if cover.kind != CoverMediaKind::WavAudio {
208        return 0;
209    }
210    // WAV: 1 bit per sample, 16-bit samples -> data/16 bytes
211    let usable = cover.data.len().saturating_sub(44); // WAV header ~44
212    (usable / 16) as u64
213}
214
215use unicode_segmentation::UnicodeSegmentation;
216
217fn estimate_text_capacity(cover: &CoverMedia) -> u64 {
218    if cover.kind != CoverMediaKind::PlainText {
219        return 0;
220    }
221    // ~2 bits per grapheme boundary (ZWJ/ZWNJ)
222    let text = String::from_utf8_lossy(&cover.data);
223    let grapheme_count = text.graphemes(true).count();
224    // 2 bits at each boundary = grapheme_count / 4 bytes
225    (grapheme_count / 4) as u64
226}
227
228fn estimate_pdf_content_capacity(cover: &CoverMedia) -> u64 {
229    if cover.kind != CoverMediaKind::PdfDocument {
230        return 0;
231    }
232    // Rough: 1 bit per content-stream byte, ~10% of PDF is content stream
233    (cover.data.len() / 80) as u64
234}
235
236const fn estimate_pdf_metadata_capacity(_cover: &CoverMedia) -> u64 {
237    // Metadata fields: limited capacity (~256 bytes typical)
238    256
239}
240
241// ─── Spectral detectability scoring ──────────────────────────────────────────
242
243/// Run a spectral-domain detectability analysis comparing `original` to
244/// `stego`.
245///
246/// The score is profile-aware: when an [`AiGenProfile`] is provided, only the
247/// carrier bins with `coherence >= 0.90` are analysed.  Without a profile the
248/// top-16 highest-magnitude bins (excluding DC at `(0, 0)`) are used.
249///
250/// # Panics
251///
252/// Never panics.  Empty and single-pixel inputs are handled gracefully.
253#[must_use]
254pub fn spectral_detectability_score(
255    original: &CoverMedia,
256    stego: &CoverMedia,
257    profile: Option<&AiGenProfile>,
258) -> SpectralScore {
259    let orig_pixels = green_channel_f32(&original.data);
260    let stego_pixels = green_channel_f32(&stego.data);
261
262    let n = orig_pixels.len().min(stego_pixels.len());
263    if n < 4 {
264        return SpectralScore {
265            phase_coherence_drop: 0.0,
266            carrier_snr_drop_db: 0.0,
267            sample_pair_asymmetry: 0.0,
268            combined_risk: DetectabilityRisk::Low,
269        };
270    }
271
272    // Next power-of-two >= n for FFT.
273    let fft_len = n.next_power_of_two();
274    let orig_freq = run_fft(&orig_pixels, fft_len);
275    let stego_freq = run_fft(&stego_pixels, fft_len);
276
277    // Extract actual image dimensions from metadata so the carrier-bin
278    // profile lookup (keyed by "WIDTHxHEIGHT") can find the right entry.
279    // Fall back to treating the data as a 1-D signal if dimensions are absent.
280    let img_width: usize = original
281        .metadata
282        .get("width")
283        .and_then(|v| v.parse::<usize>().ok())
284        .unwrap_or(fft_len);
285    let img_height: usize = original
286        .metadata
287        .get("height")
288        .and_then(|v| v.parse::<usize>().ok())
289        .unwrap_or(1);
290    let width = u32::try_from(img_width).unwrap_or(u32::MAX);
291    let height = u32::try_from(img_height).unwrap_or(u32::MAX);
292
293    // Determine carrier bins to examine.  Convert (row, col) 2-D coordinates
294    // to a flat 1-D FFT index so the helpers can index directly into the
295    // FFT output array.
296    let carrier_bins: Vec<(u32, u32)> = profile.map_or_else(Vec::new, |prof| {
297        prof.carrier_bins_for(width, height)
298            .map(|bins| {
299                bins.iter()
300                    .filter(|b| b.is_strong())
301                    .map(|b| b.freq)
302                    .collect()
303            })
304            .unwrap_or_default()
305    });
306
307    let flat_bins: Vec<usize> = if carrier_bins.is_empty() {
308        // Fall back to top-16 highest magnitude bins, skipping DC.
309        top_magnitude_bins(&orig_freq, 16)
310    } else {
311        carrier_bins
312            .into_iter()
313            .map(|(r, c)| {
314                (r as usize)
315                    .saturating_mul(img_width)
316                    .saturating_add(c as usize)
317            })
318            .collect()
319    };
320
321    // Phase coherence drop: 1 − avg |cos(Δphase)| over carrier bins.
322    let phase_coherence_drop = compute_phase_coherence_drop(&orig_freq, &stego_freq, &flat_bins);
323
324    // SNR drop in dB.
325    let carrier_snr_drop_db = compute_carrier_snr_drop_db(&orig_freq, &stego_freq, &flat_bins);
326
327    // Sample-pair adjacency asymmetry on the raw channel.
328    let sample_pair_asymmetry = match (orig_pixels.get(..n), stego_pixels.get(..n)) {
329        (Some(orig), Some(stego)) => compute_sample_pair_asymmetry(orig, stego),
330        _ => 0.0,
331    };
332
333    let combined_risk = classify_spectral_risk(phase_coherence_drop, carrier_snr_drop_db);
334
335    SpectralScore {
336        phase_coherence_drop,
337        carrier_snr_drop_db,
338        sample_pair_asymmetry,
339        combined_risk,
340    }
341}
342
343// ─── Helpers ─────────────────────────────────────────────────────────────────
344
345/// Extract the green channel as `f32` values from RGBA8-packed bytes.
346/// Bytes are interpreted as RGBA8; if length isn't divisible by 4 the
347/// remainder bytes are treated as green-channel samples directly.
348fn green_channel_f32(data: &Bytes) -> Vec<f32> {
349    if data.len() >= 4 && data.len().is_multiple_of(4) {
350        data.chunks_exact(4)
351            .filter_map(|ch| match ch {
352                [_, g, _, _] => Some(f32::from(*g)),
353                _ => None,
354            })
355            .collect()
356    } else {
357        data.iter().map(|&b| f32::from(b)).collect()
358    }
359}
360
361/// Run a 1-D FFT on `samples`, zero-padded to `fft_len`.
362fn run_fft(samples: &[f32], fft_len: usize) -> Vec<Complex<f32>> {
363    let mut input: Vec<Complex<f32>> = samples.iter().map(|&x| Complex::new(x, 0.0)).collect();
364    input.resize(fft_len, Complex::new(0.0, 0.0));
365
366    let mut planner = FftPlanner::<f32>::new();
367    let fft = planner.plan_fft_forward(fft_len);
368    fft.process(&mut input);
369    input
370}
371
372/// Return flat 1-D FFT bin indices of the top-`n` highest-magnitude bins,
373/// skipping DC (index 0).
374fn top_magnitude_bins(freq: &[Complex<f32>], n: usize) -> Vec<usize> {
375    let mut indexed: Vec<(usize, f64)> = freq
376        .iter()
377        .enumerate()
378        .skip(1)
379        .map(|(i, c)| (i, f64::from(c.norm())))
380        .collect();
381    indexed.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
382    indexed.truncate(n);
383    indexed.into_iter().map(|(i, _)| i).collect()
384}
385
386/// `1 - avg(|cos(phase_stego - phase_orig)|)` over the given flat bin indices.
387fn compute_phase_coherence_drop(
388    orig: &[Complex<f32>],
389    stego: &[Complex<f32>],
390    bins: &[usize],
391) -> f64 {
392    if bins.is_empty() {
393        return 0.0;
394    }
395    let mut sum = 0.0f64;
396    let mut count = 0usize;
397    for &idx in bins {
398        if let (Some(o), Some(s)) = (orig.get(idx), stego.get(idx)) {
399            let phase_diff = f64::from(s.arg() - o.arg());
400            sum += phase_diff.cos().abs();
401            count = count.strict_add(1);
402        }
403    }
404    if count == 0 {
405        return 0.0;
406    }
407    let count_f = match u32::try_from(count) {
408        Ok(v) => f64::from(v),
409        Err(_) => return 0.0,
410    };
411    let avg_coherence = sum / count_f;
412    (1.0 - avg_coherence).clamp(0.0, 1.0)
413}
414
415/// Mean SNR drop in dB: 10·log10(|stego|/|orig|) averaged over carrier bins.
416/// Returns 0.0 when no bins are available or magnitudes are zero.
417fn compute_carrier_snr_drop_db(
418    orig: &[Complex<f32>],
419    stego: &[Complex<f32>],
420    bins: &[usize],
421) -> f64 {
422    if bins.is_empty() {
423        return 0.0;
424    }
425    let mut sum = 0.0f64;
426    let mut count = 0usize;
427    for &idx in bins {
428        if let (Some(o), Some(s)) = (orig.get(idx), stego.get(idx)) {
429            let mag_orig = f64::from(o.norm());
430            let mag_stego = f64::from(s.norm());
431            if mag_orig > 0.0 && mag_stego > 0.0 {
432                sum += 10.0 * (mag_stego / mag_orig).log10();
433                count = count.strict_add(1);
434            }
435        }
436    }
437    if count == 0 {
438        return 0.0;
439    }
440    let count_f = match u32::try_from(count) {
441        Ok(v) => f64::from(v),
442        Err(_) => return 0.0,
443    };
444    let result = sum / count_f;
445    if result.is_nan() { 0.0 } else { result }
446}
447
448/// Adjacent pixel-pair parity asymmetry in the stego channel:
449/// fraction of pairs where even-position sample differs from odd-position
450/// sample in parity (LSB) more than expected.
451fn compute_sample_pair_asymmetry(orig: &[f32], stego: &[f32]) -> f64 {
452    if stego.len() < 2 {
453        return 0.0;
454    }
455
456    let pairs = stego.len() / 2;
457    let asym: usize = stego
458        .chunks_exact(2)
459        .filter(|pair| match pair {
460            [a, b] => sample_is_odd(*a) != sample_is_odd(*b),
461            _ => false,
462        })
463        .count();
464    let orig_asym: usize = orig
465        .chunks_exact(2)
466        .filter(|pair| match pair {
467            [a, b] => sample_is_odd(*a) != sample_is_odd(*b),
468            _ => false,
469        })
470        .count();
471    let pairs_f = match u32::try_from(pairs) {
472        Ok(v) if v > 0 => f64::from(v),
473        _ => return 0.0,
474    };
475    let asym_f = match u32::try_from(asym) {
476        Ok(v) => f64::from(v),
477        Err(_) => return 0.0,
478    };
479    let orig_asym_f = match u32::try_from(orig_asym) {
480        Ok(v) => f64::from(v),
481        Err(_) => return 0.0,
482    };
483    let stego_frac = asym_f / pairs_f;
484    let orig_frac = orig_asym_f / pairs_f;
485    (stego_frac - orig_frac).abs().clamp(0.0, 1.0)
486}
487
488fn sample_is_odd(sample: f32) -> bool {
489    // Samples are originally byte-derived channel values represented as f32.
490    // Using float parity avoids lossy integer casts that violate strict lints.
491    sample.rem_euclid(2.0) >= 1.0
492}
493
494/// Classify spectral risk based on phase coherence drop and SNR drop.
495fn classify_spectral_risk(
496    phase_coherence_drop: f64,
497    carrier_snr_drop_db: f64,
498) -> DetectabilityRisk {
499    if phase_coherence_drop > 0.20 || carrier_snr_drop_db.abs() > 0.15 {
500        DetectabilityRisk::High
501    } else if phase_coherence_drop > 0.05 || carrier_snr_drop_db.abs() > 0.05 {
502        DetectabilityRisk::Medium
503    } else {
504        DetectabilityRisk::Low
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use bytes::Bytes;
512    use std::collections::HashMap;
513
514    fn make_cover(kind: CoverMediaKind, size: usize) -> CoverMedia {
515        CoverMedia {
516            kind,
517            data: Bytes::from(vec![0u8; size]),
518            metadata: HashMap::new(),
519        }
520    }
521
522    #[test]
523    fn classify_risk_thresholds() {
524        assert_eq!(classify_risk(-1.0), DetectabilityRisk::High);
525        assert_eq!(classify_risk(-5.9), DetectabilityRisk::High);
526        assert_eq!(classify_risk(-7.0), DetectabilityRisk::Medium);
527        assert_eq!(classify_risk(-11.9), DetectabilityRisk::Medium);
528        assert_eq!(classify_risk(-13.0), DetectabilityRisk::Low);
529        assert_eq!(classify_risk(-50.0), DetectabilityRisk::Low);
530    }
531
532    #[test]
533    fn recommended_payload_scales_with_risk() {
534        assert_eq!(recommended_payload(1000, DetectabilityRisk::Low), 500);
535        assert_eq!(recommended_payload(1000, DetectabilityRisk::Medium), 250);
536        assert_eq!(recommended_payload(1000, DetectabilityRisk::High), 125);
537    }
538
539    #[test]
540    fn estimate_capacity_png_lsb() {
541        let cover = make_cover(CoverMediaKind::PngImage, 8192);
542        let cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
543        assert!(cap > 0);
544        // (8192 - 54) / 8 = 1017
545        assert_eq!(cap, 1017);
546    }
547
548    #[test]
549    fn estimate_capacity_wav_lsb() {
550        let cover = make_cover(CoverMediaKind::WavAudio, 44100);
551        let cap = estimate_capacity(&cover, StegoTechnique::LsbAudio);
552        assert!(cap > 0);
553    }
554
555    #[test]
556    fn estimate_capacity_wrong_kind_returns_zero() {
557        let cover = make_cover(CoverMediaKind::WavAudio, 1000);
558        assert_eq!(estimate_capacity(&cover, StegoTechnique::LsbImage), 0);
559    }
560
561    #[test]
562    fn chi_square_uniform_data_low_score() {
563        // Uniform distribution: all byte values equally represented
564        let data: Vec<u8> = (0..=255).cycle().take(256 * 100).collect();
565        let score = chi_square_score(&data);
566        assert!(
567            score < HIGH_THRESHOLD_DB,
568            "uniform data should score low: {score}"
569        );
570    }
571
572    #[test]
573    fn chi_square_biased_data_high_score() {
574        // Heavily biased: all zeros
575        let data = vec![0u8; 10000];
576        let score = chi_square_score(&data);
577        assert!(
578            score > HIGH_THRESHOLD_DB,
579            "biased data should score high: {score}"
580        );
581    }
582
583    #[test]
584    fn chi_square_empty_returns_zero() {
585        assert!((chi_square_score(&[]) - 0.0).abs() < f64::EPSILON);
586    }
587
588    #[test]
589    fn corpus_selection_uses_image_capacity() {
590        let cover = make_cover(CoverMediaKind::PngImage, 4096);
591        let lsb_cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
592        let corpus_cap = estimate_capacity(&cover, StegoTechnique::CorpusSelection);
593        assert_eq!(lsb_cap, corpus_cap);
594    }
595
596    #[test]
597    fn pdf_content_stream_has_capacity() {
598        let cover = make_cover(CoverMediaKind::PdfDocument, 100_000);
599        let cap = estimate_capacity(&cover, StegoTechnique::PdfContentStream);
600        assert!(cap > 0);
601    }
602
603    // ─── Additional capacity estimator coverage ───────────────────────────
604
605    #[test]
606    fn jpeg_dct_capacity_for_jpeg() {
607        let cover = make_cover(CoverMediaKind::JpegImage, 16_000);
608        let cap = estimate_capacity(&cover, StegoTechnique::DctJpeg);
609        assert_eq!(cap, 1000); // 16000 / 16
610    }
611
612    #[test]
613    fn jpeg_dct_capacity_wrong_kind_returns_zero() {
614        let cover = make_cover(CoverMediaKind::PngImage, 16_000);
615        assert_eq!(estimate_capacity(&cover, StegoTechnique::DctJpeg), 0);
616    }
617
618    #[test]
619    fn palette_capacity_for_gif() {
620        let cover = make_cover(CoverMediaKind::GifImage, 4096);
621        let cap = estimate_capacity(&cover, StegoTechnique::Palette);
622        assert!(cap > 0);
623        // (4096 - 128) / 32 = 124
624        assert_eq!(cap, 124);
625    }
626
627    #[test]
628    fn palette_capacity_wrong_kind_returns_zero() {
629        let cover = make_cover(CoverMediaKind::WavAudio, 4096);
630        assert_eq!(estimate_capacity(&cover, StegoTechnique::Palette), 0);
631    }
632
633    #[test]
634    fn text_capacity_for_plain_text() {
635        // "hello world" has 11 grapheme clusters -> 11 / 4 = 2
636        let cover = CoverMedia {
637            kind: CoverMediaKind::PlainText,
638            data: Bytes::from(
639                "hello world, this is a test of capacity estimation for zero-width text",
640            ),
641            metadata: HashMap::new(),
642        };
643        let cap = estimate_capacity(&cover, StegoTechnique::ZeroWidthText);
644        assert!(cap > 0);
645    }
646
647    #[test]
648    fn text_capacity_wrong_kind_returns_zero() {
649        let cover = make_cover(CoverMediaKind::PngImage, 1000);
650        assert_eq!(estimate_capacity(&cover, StegoTechnique::ZeroWidthText), 0);
651    }
652
653    #[test]
654    fn pdf_content_capacity_wrong_kind_returns_zero() {
655        let cover = make_cover(CoverMediaKind::PngImage, 100_000);
656        assert_eq!(
657            estimate_capacity(&cover, StegoTechnique::PdfContentStream),
658            0
659        );
660    }
661
662    #[test]
663    fn pdf_metadata_capacity_always_256() {
664        let cover = make_cover(CoverMediaKind::PdfDocument, 1000);
665        assert_eq!(estimate_capacity(&cover, StegoTechnique::PdfMetadata), 256);
666        // Even for non-PDF types, metadata capacity is fixed
667        let cover2 = make_cover(CoverMediaKind::PngImage, 1000);
668        assert_eq!(estimate_capacity(&cover2, StegoTechnique::PdfMetadata), 256);
669    }
670
671    #[test]
672    fn audio_lsb_wrong_kind_returns_zero() {
673        let cover = make_cover(CoverMediaKind::PngImage, 44100);
674        assert_eq!(estimate_capacity(&cover, StegoTechnique::LsbAudio), 0);
675    }
676
677    #[test]
678    fn phase_encoding_is_audio_lsb_div_8() {
679        let cover = make_cover(CoverMediaKind::WavAudio, 44100);
680        let audio_cap = estimate_capacity(&cover, StegoTechnique::LsbAudio);
681        let phase_cap = estimate_capacity(&cover, StegoTechnique::PhaseEncoding);
682        assert_eq!(phase_cap, audio_cap / 8);
683    }
684
685    #[test]
686    fn echo_hiding_same_as_phase_encoding() {
687        let cover = make_cover(CoverMediaKind::WavAudio, 44100);
688        let phase_cap = estimate_capacity(&cover, StegoTechnique::PhaseEncoding);
689        let echo_cap = estimate_capacity(&cover, StegoTechnique::EchoHiding);
690        assert_eq!(phase_cap, echo_cap);
691    }
692
693    #[test]
694    fn dual_payload_is_half_image_lsb() {
695        let cover = make_cover(CoverMediaKind::PngImage, 8192);
696        let lsb_cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
697        let dual_cap = estimate_capacity(&cover, StegoTechnique::DualPayload);
698        assert_eq!(dual_cap, lsb_cap / 2);
699    }
700
701    #[test]
702    fn gif_lsb_image_capacity() {
703        let cover = make_cover(CoverMediaKind::GifImage, 4096);
704        let cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
705        // (4096 - 128) / 16 = 248
706        assert_eq!(cap, 248);
707    }
708
709    #[test]
710    fn bmp_lsb_same_as_png() {
711        let cover_png = make_cover(CoverMediaKind::PngImage, 8192);
712        let cover_bmp = make_cover(CoverMediaKind::BmpImage, 8192);
713        assert_eq!(
714            estimate_capacity(&cover_png, StegoTechnique::LsbImage),
715            estimate_capacity(&cover_bmp, StegoTechnique::LsbImage)
716        );
717    }
718
719    #[test]
720    fn palette_capacity_for_png() {
721        let cover = make_cover(CoverMediaKind::PngImage, 4096);
722        let cap = estimate_capacity(&cover, StegoTechnique::Palette);
723        assert_eq!(cap, 124); // Same formula as GIF
724    }
725
726    // ─── Spectral detectability score tests ──────────────────────────────
727
728    fn make_spectral_cover(data: Vec<u8>) -> CoverMedia {
729        CoverMedia {
730            kind: CoverMediaKind::PngImage,
731            data: Bytes::from(data),
732            metadata: HashMap::new(),
733        }
734    }
735
736    #[test]
737    fn spectral_identical_buffers_low_risk() {
738        let data: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
739        let orig = make_spectral_cover(data.clone());
740        let stego = make_spectral_cover(data);
741        let score = spectral_detectability_score(&orig, &stego, None);
742        assert!(
743            (score.phase_coherence_drop).abs() < 1e-6,
744            "identical buffers: phase_coherence_drop should be ~0"
745        );
746        assert!(
747            (score.carrier_snr_drop_db).abs() < 1e-3,
748            "identical buffers: carrier_snr_drop_db should be ~0"
749        );
750        assert_eq!(score.combined_risk, DetectabilityRisk::Low);
751    }
752
753    #[test]
754    fn spectral_heavily_modified_differs_from_identical() {
755        // Inverted data has a very different spectrum from the original.
756        // The score fields should reflect numeric differences; we only
757        // assert the scoring completes without panic and produces a valid
758        // risk level (the exact threshold depends on the data content).
759        let orig_data: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
760        let stego_data: Vec<u8> = orig_data.iter().map(|&b| b ^ 0xFF).collect();
761        let orig = make_spectral_cover(orig_data);
762        let stego = make_spectral_cover(stego_data);
763        let score = spectral_detectability_score(&orig, &stego, None);
764        // Score fields must be finite and non-negative where applicable.
765        assert!(score.phase_coherence_drop.is_finite());
766        assert!(score.sample_pair_asymmetry >= 0.0);
767        // Risk must be a valid variant — just confirming it doesn't panic.
768        let _ = score.combined_risk;
769    }
770
771    #[test]
772    fn spectral_empty_orig_no_panic() {
773        let orig = make_spectral_cover(vec![]);
774        let stego = make_spectral_cover(vec![0u8; 64]);
775        let score = spectral_detectability_score(&orig, &stego, None);
776        assert_eq!(score.combined_risk, DetectabilityRisk::Low);
777    }
778
779    #[test]
780    fn spectral_single_pixel_no_panic() {
781        let orig = make_spectral_cover(vec![128]);
782        let stego = make_spectral_cover(vec![129]);
783        let score = spectral_detectability_score(&orig, &stego, None);
784        assert_eq!(score.combined_risk, DetectabilityRisk::Low);
785    }
786
787    #[test]
788    fn spectral_with_ai_gen_profile_checks_carrier_bins() {
789        use crate::domain::ports::{AiGenProfile, CarrierBin};
790        // 64-element row; profile carrier bin at (0, 5) — strong.
791        let bins = vec![CarrierBin::new((0, 5), 0.0, 1.0)];
792        let mut carrier_map = HashMap::new();
793        // Key is "64x1" for width=64, height=1.
794        carrier_map.insert("64x1".to_string(), bins);
795        let profile = AiGenProfile {
796            model_id: "test-model".to_string(),
797            channel_weights: [1.0, 1.0, 1.0],
798            carrier_map,
799        };
800        let data: Vec<u8> = (0u8..64).collect();
801        let orig = make_spectral_cover(data.clone());
802        let stego = make_spectral_cover(data);
803        let score = spectral_detectability_score(&orig, &stego, Some(&profile));
804        // Identical data → low risk even with profile bins.
805        assert_eq!(score.combined_risk, DetectabilityRisk::Low);
806    }
807
808    #[test]
809    fn spectral_score_serde_round_trip() {
810        use crate::domain::types::SpectralScore;
811        let score = SpectralScore {
812            phase_coherence_drop: 0.12,
813            carrier_snr_drop_db: -0.08,
814            sample_pair_asymmetry: 0.03,
815            combined_risk: DetectabilityRisk::Medium,
816        };
817        let json = serde_json::to_string(&score);
818        assert!(json.is_ok());
819        let Some(json) = json.ok() else {
820            return;
821        };
822        let back: Result<SpectralScore, _> = serde_json::from_str(&json);
823        assert!(back.is_ok());
824        let Some(back) = back.ok() else {
825            return;
826        };
827        assert!((back.phase_coherence_drop - score.phase_coherence_drop).abs() < 1e-10);
828        assert!((back.carrier_snr_drop_db - score.carrier_snr_drop_db).abs() < 1e-10);
829        assert_eq!(back.combined_risk, score.combined_risk);
830    }
831
832    #[test]
833    fn analysis_report_has_spectral_score_field() {
834        use crate::domain::types::{AnalysisReport, Capacity};
835        let report = AnalysisReport {
836            technique: StegoTechnique::LsbImage,
837            cover_capacity: Capacity {
838                bytes: 100,
839                technique: StegoTechnique::LsbImage,
840            },
841            chi_square_score: -13.5,
842            detectability_risk: DetectabilityRisk::Low,
843            recommended_max_payload_bytes: 50,
844            ai_watermark: None,
845            spectral_score: None,
846        };
847        assert!(report.spectral_score.is_none());
848    }
849}