Skip to main content

shadowforge_lib/adapters/
stego.rs

1//! Steganography technique adapters implementing `EmbedTechnique` port.
2
3use crate::domain::errors::{DeniableError, StegoError};
4use crate::domain::ports::{DeniableEmbedder, EmbedTechnique, ExtractTechnique};
5use crate::domain::types::{
6    Capacity, CoverMedia, CoverMediaKind, DeniableKeySet, DeniablePayloadPair, Payload,
7    StegoTechnique,
8};
9use rand::seq::SliceRandom;
10use rand_chacha::ChaCha20Rng;
11use rand_core::SeedableRng;
12use sha2::{Digest, Sha256};
13
14/// LSB image steganography adapter for PNG/BMP.
15///
16/// Embeds payload in the least significant bits of RGB channels only
17/// (alpha channel is untouched). Header encodes 32-bit big-endian payload length.
18#[derive(Debug, Default)]
19pub struct LsbImage;
20
21impl LsbImage {
22    /// Create a new LSB image embedder.
23    #[must_use]
24    pub const fn new() -> Self {
25        Self
26    }
27}
28
29impl EmbedTechnique for LsbImage {
30    fn technique(&self) -> StegoTechnique {
31        StegoTechnique::LsbImage
32    }
33
34    fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError> {
35        // Only PNG and BMP are supported
36        match cover.kind {
37            CoverMediaKind::PngImage | CoverMediaKind::BmpImage => {}
38            _ => {
39                return Err(StegoError::UnsupportedCoverType {
40                    reason: format!("LSB image requires PNG or BMP, got {:?}", cover.kind),
41                });
42            }
43        }
44
45        // Parse dimensions from metadata
46        let width: u32 = cover
47            .metadata
48            .get("width")
49            .ok_or_else(|| StegoError::MalformedCoverData {
50                reason: "missing width metadata".to_string(),
51            })?
52            .parse()
53            .map_err(
54                |e: std::num::ParseIntError| StegoError::MalformedCoverData {
55                    reason: format!("invalid width: {e}"),
56                },
57            )?;
58
59        let height: u32 = cover
60            .metadata
61            .get("height")
62            .ok_or_else(|| StegoError::MalformedCoverData {
63                reason: "missing height metadata".to_string(),
64            })?
65            .parse()
66            .map_err(
67                |e: std::num::ParseIntError| StegoError::MalformedCoverData {
68                    reason: format!("invalid height: {e}"),
69                },
70            )?;
71
72        let pixel_count =
73            width
74                .checked_mul(height)
75                .ok_or_else(|| StegoError::MalformedCoverData {
76                    reason: "pixel count overflow".to_string(),
77                })?;
78
79        // Capacity: 3 bits per pixel (R, G, B), minus 32 bits for header
80        // = (pixel_count * 3 - 32) / 8 bytes
81        let bits = pixel_count
82            .checked_mul(3)
83            .and_then(|b| b.checked_sub(32))
84            .ok_or_else(|| StegoError::MalformedCoverData {
85                reason: "capacity calculation overflow".to_string(),
86            })?;
87
88        let bytes = u64::from(bits / 8);
89
90        Ok(Capacity {
91            bytes,
92            technique: StegoTechnique::LsbImage,
93        })
94    }
95
96    fn embed(&self, mut cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError> {
97        // Check cover type
98        match cover.kind {
99            CoverMediaKind::PngImage | CoverMediaKind::BmpImage => {}
100            _ => {
101                return Err(StegoError::UnsupportedCoverType {
102                    reason: format!("LSB image requires PNG or BMP, got {:?}", cover.kind),
103                });
104            }
105        }
106
107        // Check capacity
108        let cap = self.capacity(&cover)?;
109        let payload_len = payload.as_bytes().len() as u64;
110        if payload_len > cap.bytes {
111            return Err(StegoError::PayloadTooLarge {
112                needed: payload_len,
113                available: cap.bytes,
114            });
115        }
116
117        // Check that payload length fits in 32-bit header
118        if payload_len > u64::from(u32::MAX) {
119            return Err(StegoError::PayloadTooLarge {
120                needed: payload_len,
121                available: u64::from(u32::MAX),
122            });
123        }
124
125        // Get mutable access to pixel data
126        let data = cover.data.to_vec();
127        let mut pixels = data;
128
129        // Embed 32-bit big-endian payload length in first 32 LSBs
130        #[expect(
131            clippy::cast_possible_truncation,
132            reason = "checked above: payload_len <= u32::MAX"
133        )]
134        let len_bytes = (payload_len as u32).to_be_bytes();
135        for (byte_idx, byte) in len_bytes.iter().enumerate() {
136            for bit_idx in 0..8 {
137                let bit = (byte >> (7 - bit_idx)) & 1;
138                let pixel_idx = byte_idx * 8 + bit_idx;
139
140                // RGB only, skip alpha (every 4th byte)
141                let channel_idx = pixel_idx / 3;
142                let rgb_offset = pixel_idx % 3;
143                let byte_pos = channel_idx * 4 + rgb_offset;
144
145                let pixel =
146                    pixels
147                        .get_mut(byte_pos)
148                        .ok_or_else(|| StegoError::MalformedCoverData {
149                            reason: "pixel index out of bounds".to_string(),
150                        })?;
151                *pixel = (*pixel & 0xFE) | bit;
152            }
153        }
154
155        // Embed payload bits starting after header (32 bits)
156        let payload_bytes = payload.as_bytes();
157        for (byte_idx, byte) in payload_bytes.iter().enumerate() {
158            for bit_idx in 0..8 {
159                let bit = (byte >> (7 - bit_idx)) & 1;
160                let pixel_idx = 32 + byte_idx * 8 + bit_idx;
161
162                // RGB only, skip alpha
163                let channel_idx = pixel_idx / 3;
164                let rgb_offset = pixel_idx % 3;
165                let byte_pos = channel_idx * 4 + rgb_offset;
166
167                let pixel =
168                    pixels
169                        .get_mut(byte_pos)
170                        .ok_or_else(|| StegoError::MalformedCoverData {
171                            reason: "pixel index out of bounds".to_string(),
172                        })?;
173                *pixel = (*pixel & 0xFE) | bit;
174            }
175        }
176
177        cover.data = pixels.into();
178        Ok(cover)
179    }
180}
181
182impl ExtractTechnique for LsbImage {
183    fn technique(&self) -> StegoTechnique {
184        StegoTechnique::LsbImage
185    }
186
187    fn extract(&self, cover: &CoverMedia) -> Result<Payload, StegoError> {
188        // Check cover type
189        match cover.kind {
190            CoverMediaKind::PngImage | CoverMediaKind::BmpImage => {}
191            _ => {
192                return Err(StegoError::UnsupportedCoverType {
193                    reason: format!("LSB image requires PNG or BMP, got {:?}", cover.kind),
194                });
195            }
196        }
197
198        let pixels = cover.data.as_ref();
199
200        // Extract 32-bit big-endian payload length from first 32 LSBs
201        let mut len_bytes = [0u8; 4];
202        for (byte_idx, len_byte) in len_bytes.iter_mut().enumerate() {
203            for bit_idx in 0..8 {
204                let pixel_idx = byte_idx * 8 + bit_idx;
205
206                // RGB only, skip alpha
207                let channel_idx = pixel_idx / 3;
208                let rgb_offset = pixel_idx % 3;
209                let byte_pos = channel_idx * 4 + rgb_offset;
210
211                let bit = pixels
212                    .get(byte_pos)
213                    .ok_or_else(|| StegoError::MalformedCoverData {
214                        reason: "pixel index out of bounds".to_string(),
215                    })?
216                    & 1;
217                *len_byte |= bit << (7 - bit_idx);
218            }
219        }
220
221        let payload_len = u32::from_be_bytes(len_bytes) as usize;
222
223        // Extract payload bits
224        let mut payload_bytes = vec![0u8; payload_len];
225        for (byte_idx, payload_byte) in payload_bytes.iter_mut().enumerate() {
226            for bit_idx in 0..8 {
227                let pixel_idx = 32 + byte_idx * 8 + bit_idx;
228
229                // RGB only, skip alpha
230                let channel_idx = pixel_idx / 3;
231                let rgb_offset = pixel_idx % 3;
232                let byte_pos = channel_idx * 4 + rgb_offset;
233
234                let bit = pixels
235                    .get(byte_pos)
236                    .ok_or_else(|| StegoError::MalformedCoverData {
237                        reason: "pixel index out of bounds".to_string(),
238                    })?
239                    & 1;
240                *payload_byte |= bit << (7 - bit_idx);
241            }
242        }
243
244        Ok(Payload::from_bytes(payload_bytes))
245    }
246}
247
248/// DCT-based JPEG steganography adapter (STUB).
249///
250/// **NOT YET IMPLEMENTED**: Requires a pure-Rust JPEG library that exposes
251/// DCT coefficients without unsafe code. Current Rust JPEG libraries either:
252/// - Decode to pixels only (jpeg-decoder, image crate)
253/// - Require unsafe bindings (mozjpeg-sys, libjpeg-turbo-sys)
254///
255/// TODO(T12): Implement DCT coefficient access and modification:
256/// - Parse JPEG to access non-zero AC DCT coefficients
257/// - Embed payload in LSBs of coefficients (skip DC and zeros)
258/// - Preserve quantization and Huffman tables
259/// - Re-encode JPEG with modified coefficients
260#[derive(Debug, Default)]
261pub struct DctJpeg;
262
263impl DctJpeg {
264    /// Create a new DCT JPEG embedder.
265    #[must_use]
266    pub const fn new() -> Self {
267        Self
268    }
269}
270
271impl EmbedTechnique for DctJpeg {
272    fn technique(&self) -> StegoTechnique {
273        StegoTechnique::DctJpeg
274    }
275
276    fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
277        Err(StegoError::UnsupportedCoverType {
278            reason: "DCT JPEG steganography not yet implemented (requires DCT coefficient access)"
279                .to_string(),
280        })
281    }
282
283    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
284        Err(StegoError::UnsupportedCoverType {
285            reason: "DCT JPEG steganography not yet implemented (requires DCT coefficient access)"
286                .to_string(),
287        })
288    }
289}
290
291impl ExtractTechnique for DctJpeg {
292    fn technique(&self) -> StegoTechnique {
293        StegoTechnique::DctJpeg
294    }
295
296    fn extract(&self, _cover: &CoverMedia) -> Result<Payload, StegoError> {
297        Err(StegoError::UnsupportedCoverType {
298            reason: "DCT JPEG steganography not yet implemented (requires DCT coefficient access)"
299                .to_string(),
300        })
301    }
302}
303
304/// Palette-based steganography adapter for GIF/PNG indexed images (STUB).
305///
306/// **NOT YET IMPLEMENTED**: Requires palette extraction from indexed color images.
307/// The `image` crate converts all images to RGBA8, losing original palette data.
308///
309/// TODO(T13): Implement palette steganography:
310/// - Extract palette data from GIF/PNG indexed color images
311/// - Store palette as bytes in `CoverMedia.metadata["palette"]`
312/// - Embed payload in LSBs of palette R/G/B bytes
313/// - Capacity: (`palette_size` * 3) / 8 bytes
314/// - Re-encode image with modified palette (pixel indices unchanged)
315/// - Requires format-specific handling (GIF vs indexed PNG)
316#[derive(Debug, Default)]
317pub struct PaletteStego;
318
319impl PaletteStego {
320    /// Create a new palette steganography embedder.
321    #[must_use]
322    pub const fn new() -> Self {
323        Self
324    }
325}
326
327impl EmbedTechnique for PaletteStego {
328    fn technique(&self) -> StegoTechnique {
329        StegoTechnique::Palette
330    }
331
332    fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
333        Err(StegoError::UnsupportedCoverType {
334            reason: "Palette steganography not yet implemented (requires palette extraction)"
335                .to_string(),
336        })
337    }
338
339    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
340        Err(StegoError::UnsupportedCoverType {
341            reason: "Palette steganography not yet implemented (requires palette extraction)"
342                .to_string(),
343        })
344    }
345}
346
347impl ExtractTechnique for PaletteStego {
348    fn technique(&self) -> StegoTechnique {
349        StegoTechnique::Palette
350    }
351
352    fn extract(&self, _cover: &CoverMedia) -> Result<Payload, StegoError> {
353        Err(StegoError::UnsupportedCoverType {
354            reason: "Palette steganography not yet implemented (requires palette extraction)"
355                .to_string(),
356        })
357    }
358}
359
360/// LSB audio steganography adapter for WAV files.
361///
362/// Embeds payload in the least significant bits of i16 audio samples.
363/// Header encodes 32-bit big-endian payload length in first 32 sample LSBs.
364#[derive(Debug, Default)]
365pub struct LsbAudio;
366
367impl LsbAudio {
368    /// Create a new LSB audio embedder.
369    #[must_use]
370    pub const fn new() -> Self {
371        Self
372    }
373}
374
375impl EmbedTechnique for LsbAudio {
376    fn technique(&self) -> StegoTechnique {
377        StegoTechnique::LsbAudio
378    }
379
380    fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError> {
381        // Only WAV audio is supported
382        if cover.kind != CoverMediaKind::WavAudio {
383            return Err(StegoError::UnsupportedCoverType {
384                reason: format!("LSB audio requires WAV, got {:?}", cover.kind),
385            });
386        }
387
388        // Sample count is data length / 2 (i16 = 2 bytes)
389        let sample_count = cover.data.len() / 2;
390
391        // Need at least 32 samples for header
392        if sample_count < 32 {
393            return Err(StegoError::MalformedCoverData {
394                reason: "audio too short for LSB embedding (need at least 32 samples)".to_string(),
395            });
396        }
397
398        // Capacity: (sample_count - 32) / 8 bytes
399        let capacity_bits =
400            sample_count
401                .checked_sub(32)
402                .ok_or_else(|| StegoError::MalformedCoverData {
403                    reason: "capacity calculation underflow".to_string(),
404                })?;
405
406        let bytes = (capacity_bits / 8) as u64;
407
408        Ok(Capacity {
409            bytes,
410            technique: StegoTechnique::LsbAudio,
411        })
412    }
413
414    fn embed(&self, mut cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError> {
415        // Check cover type
416        if cover.kind != CoverMediaKind::WavAudio {
417            return Err(StegoError::UnsupportedCoverType {
418                reason: format!("LSB audio requires WAV, got {:?}", cover.kind),
419            });
420        }
421
422        // Check capacity
423        let cap = self.capacity(&cover)?;
424        let payload_len = payload.as_bytes().len() as u64;
425        if payload_len > cap.bytes {
426            return Err(StegoError::PayloadTooLarge {
427                needed: payload_len,
428                available: cap.bytes,
429            });
430        }
431
432        // Check that payload length fits in 32-bit header
433        if payload_len > u64::from(u32::MAX) {
434            return Err(StegoError::PayloadTooLarge {
435                needed: payload_len,
436                available: u64::from(u32::MAX),
437            });
438        }
439
440        // Get mutable access to sample data (i16 little-endian)
441        let mut samples = cover.data.to_vec();
442
443        // Embed 32-bit big-endian payload length in first 32 sample LSBs
444        #[expect(
445            clippy::cast_possible_truncation,
446            reason = "checked above: payload_len <= u32::MAX"
447        )]
448        let len_bytes = (payload_len as u32).to_be_bytes();
449        for (byte_idx, byte) in len_bytes.iter().enumerate() {
450            for bit_idx in 0..8 {
451                let bit = (byte >> (7 - bit_idx)) & 1;
452                let sample_idx = byte_idx * 8 + bit_idx;
453
454                // Modify LSB of i16 sample (little-endian)
455                let byte_pos = sample_idx * 2; // i16 = 2 bytes
456                let sample =
457                    samples
458                        .get_mut(byte_pos)
459                        .ok_or_else(|| StegoError::MalformedCoverData {
460                            reason: "sample index out of bounds".to_string(),
461                        })?;
462                *sample = (*sample & 0xFE) | bit;
463            }
464        }
465
466        // Embed payload bits starting after header (32 samples)
467        let payload_bytes = payload.as_bytes();
468        for (byte_idx, byte) in payload_bytes.iter().enumerate() {
469            for bit_idx in 0..8 {
470                let bit = (byte >> (7 - bit_idx)) & 1;
471                let sample_idx = 32 + byte_idx * 8 + bit_idx;
472
473                let byte_pos = sample_idx * 2;
474                let sample =
475                    samples
476                        .get_mut(byte_pos)
477                        .ok_or_else(|| StegoError::MalformedCoverData {
478                            reason: "sample index out of bounds".to_string(),
479                        })?;
480                *sample = (*sample & 0xFE) | bit;
481            }
482        }
483
484        cover.data = samples.into();
485        Ok(cover)
486    }
487}
488
489impl ExtractTechnique for LsbAudio {
490    fn technique(&self) -> StegoTechnique {
491        StegoTechnique::LsbAudio
492    }
493
494    fn extract(&self, cover: &CoverMedia) -> Result<Payload, StegoError> {
495        // Check cover type
496        if cover.kind != CoverMediaKind::WavAudio {
497            return Err(StegoError::UnsupportedCoverType {
498                reason: format!("LSB audio requires WAV, got {:?}", cover.kind),
499            });
500        }
501
502        let samples = cover.data.as_ref();
503
504        // Need at least 32 samples for header
505        if samples.len() < 64 {
506            // 32 samples * 2 bytes
507            return Err(StegoError::MalformedCoverData {
508                reason: "audio too short to extract payload".to_string(),
509            });
510        }
511
512        // Extract 32-bit big-endian payload length from first 32 sample LSBs
513        let mut len_bytes = [0u8; 4];
514        for (byte_idx, len_byte) in len_bytes.iter_mut().enumerate() {
515            for bit_idx in 0..8 {
516                let sample_idx = byte_idx * 8 + bit_idx;
517                let byte_pos = sample_idx * 2;
518
519                let bit = samples
520                    .get(byte_pos)
521                    .ok_or_else(|| StegoError::MalformedCoverData {
522                        reason: "sample index out of bounds".to_string(),
523                    })?
524                    & 1;
525                *len_byte |= bit << (7 - bit_idx);
526            }
527        }
528
529        let payload_len = u32::from_be_bytes(len_bytes) as usize;
530
531        // Sanity check payload length
532        let max_samples = samples.len() / 2;
533        if payload_len > (max_samples.saturating_sub(32)) / 8 {
534            return Err(StegoError::MalformedCoverData {
535                reason: format!("invalid payload length: {payload_len}"),
536            });
537        }
538
539        // Extract payload bits
540        let mut payload_bytes = vec![0u8; payload_len];
541        for (byte_idx, payload_byte) in payload_bytes.iter_mut().enumerate() {
542            for bit_idx in 0..8 {
543                let sample_idx = 32 + byte_idx * 8 + bit_idx;
544                let byte_pos = sample_idx * 2;
545
546                let bit = samples
547                    .get(byte_pos)
548                    .ok_or_else(|| StegoError::MalformedCoverData {
549                        reason: "sample index out of bounds".to_string(),
550                    })?
551                    & 1;
552                *payload_byte |= bit << (7 - bit_idx);
553            }
554        }
555
556        Ok(Payload::from_bytes(payload_bytes))
557    }
558}
559
560/// Phase encoding (DSSS) audio steganography adapter (STUB).
561///
562/// **NOT YET IMPLEMENTED**: Requires FFT/IFFT and phase manipulation.
563///
564/// TODO(T14): Implement phase encoding:
565/// - Segment audio into blocks
566/// - Apply FFT to each segment
567/// - Embed one bit per segment by phase shift
568/// - Adaptive alpha: scale shift by segment energy
569/// - Apply IFFT to reconstruct samples
570/// - Requires audio DSP library (rustfft or similar)
571#[derive(Debug, Default)]
572pub struct PhaseEncoding;
573
574impl PhaseEncoding {
575    /// Create a new phase encoding embedder.
576    #[must_use]
577    pub const fn new() -> Self {
578        Self
579    }
580}
581
582impl EmbedTechnique for PhaseEncoding {
583    fn technique(&self) -> StegoTechnique {
584        StegoTechnique::PhaseEncoding
585    }
586
587    fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
588        Err(StegoError::UnsupportedCoverType {
589            reason: "Phase encoding not yet implemented (requires FFT/phase manipulation)"
590                .to_string(),
591        })
592    }
593
594    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
595        Err(StegoError::UnsupportedCoverType {
596            reason: "Phase encoding not yet implemented (requires FFT/phase manipulation)"
597                .to_string(),
598        })
599    }
600}
601
602impl ExtractTechnique for PhaseEncoding {
603    fn technique(&self) -> StegoTechnique {
604        StegoTechnique::PhaseEncoding
605    }
606
607    fn extract(&self, _cover: &CoverMedia) -> Result<Payload, StegoError> {
608        Err(StegoError::UnsupportedCoverType {
609            reason: "Phase encoding not yet implemented (requires FFT/phase manipulation)"
610                .to_string(),
611        })
612    }
613}
614
615/// Echo hiding audio steganography adapter (STUB).
616///
617/// **NOT YET IMPLEMENTED**: Requires echo synthesis and autocorrelation.
618///
619/// TODO(T14): Implement echo hiding:
620/// - Two echo delays (d0, d1) for bit 0/1
621/// - Embed by adding delayed echo to audio
622/// - Extract via autocorrelation peak detection
623/// - Use `array_windows` for autocorrelation computation
624/// - Requires audio DSP operations
625#[derive(Debug, Default)]
626pub struct EchoHiding;
627
628impl EchoHiding {
629    /// Create a new echo hiding embedder.
630    #[must_use]
631    pub const fn new() -> Self {
632        Self
633    }
634}
635
636impl EmbedTechnique for EchoHiding {
637    fn technique(&self) -> StegoTechnique {
638        StegoTechnique::EchoHiding
639    }
640
641    fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
642        Err(StegoError::UnsupportedCoverType {
643            reason: "Echo hiding not yet implemented (requires echo synthesis and autocorrelation)"
644                .to_string(),
645        })
646    }
647
648    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
649        Err(StegoError::UnsupportedCoverType {
650            reason: "Echo hiding not yet implemented (requires echo synthesis and autocorrelation)"
651                .to_string(),
652        })
653    }
654}
655
656impl ExtractTechnique for EchoHiding {
657    fn technique(&self) -> StegoTechnique {
658        StegoTechnique::EchoHiding
659    }
660
661    fn extract(&self, _cover: &CoverMedia) -> Result<Payload, StegoError> {
662        Err(StegoError::UnsupportedCoverType {
663            reason: "Echo hiding not yet implemented (requires echo synthesis and autocorrelation)"
664                .to_string(),
665        })
666    }
667}
668
669/// Zero-width character text steganography adapter (STUB).
670///
671/// **NOT YET IMPLEMENTED**: Zero-width Unicode characters (ZWSP, ZWNJ, ZWJ, etc.)
672/// have complex grapheme clustering rules that make reliable embedding/extraction
673/// difficult. Format characters can be combined with adjacent characters by the
674/// Unicode grapheme segmentation algorithm in context-dependent ways.
675///
676/// TODO(T15): Implement zero-width text steganography:
677/// - Research Unicode-safe zero-width character pairs that remain separate graphemes
678/// - Consider alternative approaches (variation selectors, combining marks)
679/// - Extensive testing with all Unicode scripts (Arabic, Thai, Devanagari, emoji ZWJ sequences)
680/// - Validate grapheme-cluster safety across all contexts
681#[derive(Debug, Default)]
682pub struct ZeroWidthText;
683
684impl ZeroWidthText {
685    /// Create a new zero-width text embedder.
686    #[must_use]
687    pub const fn new() -> Self {
688        Self
689    }
690}
691
692impl EmbedTechnique for ZeroWidthText {
693    fn technique(&self) -> StegoTechnique {
694        StegoTechnique::ZeroWidthText
695    }
696
697    fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
698        Err(StegoError::UnsupportedCoverType {
699            reason: "Zero-width text steganography not yet implemented (Unicode grapheme segmentation complexity)".to_string(),
700        })
701    }
702
703    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
704        Err(StegoError::UnsupportedCoverType {
705            reason: "Zero-width text steganography not yet implemented (Unicode grapheme segmentation complexity)".to_string(),
706        })
707    }
708}
709
710impl ExtractTechnique for ZeroWidthText {
711    fn technique(&self) -> StegoTechnique {
712        StegoTechnique::ZeroWidthText
713    }
714
715    fn extract(&self, _cover: &CoverMedia) -> Result<Payload, StegoError> {
716        Err(StegoError::UnsupportedCoverType {
717            reason: "Zero-width text steganography not yet implemented (Unicode grapheme segmentation complexity)".to_string(),
718        })
719    }
720}
721
722// ─── Dual-Payload Deniable Steganography ─────────────────────────────────────
723
724/// Dual-payload deniable steganography adapter.
725///
726/// Embeds two independent payloads (real and decoy) into a single cover using
727/// key-derived pseudo-random patterns. Each key produces a deterministic but
728/// non-overlapping set of embedding positions, ensuring that:
729///
730/// - Extracting with the primary key yields the real payload
731/// - Extracting with the decoy key yields the decoy payload
732/// - No observer can prove which payload is "real" vs "decoy"
733///
734/// The implementation uses `ChaCha20` PRNG seeded with SHA-256 hashes of the keys
735/// to generate reproducible embedding patterns.
736pub struct DualPayloadEmbedder;
737
738impl Default for DualPayloadEmbedder {
739    fn default() -> Self {
740        Self
741    }
742}
743
744impl DualPayloadEmbedder {
745    /// Create a new dual-payload embedder.
746    #[must_use]
747    pub const fn new() -> Self {
748        Self
749    }
750
751    /// Derive a 32-byte seed from a key and channel using SHA-256.
752    ///
753    /// The channel tag ensures different seeds for primary (channel 0) and decoy (channel 1),
754    /// preventing pattern overlap.
755    fn derive_seed_with_channel(key: &[u8], channel: u8) -> [u8; 32] {
756        let mut hasher = Sha256::new();
757        hasher.update(key);
758        hasher.update([channel]);
759        hasher.finalize().into()
760    }
761
762    /// Generate a pseudo-random permutation of indices for embedding.
763    ///
764    /// Returns a vector of `count` indices in the range `[0, total)`, shuffled
765    /// deterministically based on the `seed`. The indices are NOT sorted, preserving
766    /// the pseudo-random access pattern for better security.
767    fn generate_pattern(seed: [u8; 32], total: usize, count: usize) -> Vec<usize> {
768        let mut rng = ChaCha20Rng::from_seed(seed);
769        let mut indices: Vec<usize> = (0..total).collect();
770        indices.shuffle(&mut rng);
771        indices.truncate(count);
772        indices
773    }
774
775    /// Embed a single payload into the cover at specified bit positions.
776    fn embed_at_positions(
777        cover_data: &mut [u8],
778        payload: &[u8],
779        positions: &[usize],
780    ) -> Result<(), DeniableError> {
781        let payload_bits = payload.len() * 8;
782
783        if payload_bits > positions.len() {
784            return Err(DeniableError::InsufficientCapacity);
785        }
786
787        // Embed each bit of the payload at its designated position
788        for (bit_idx, &pos) in positions.iter().enumerate().take(payload_bits) {
789            let payload_byte_idx = bit_idx / 8;
790            let payload_bit_idx = 7 - (bit_idx % 8); // MSB-first
791            let payload_byte = payload
792                .get(payload_byte_idx)
793                .ok_or(DeniableError::InsufficientCapacity)?;
794            let payload_bit = (payload_byte >> payload_bit_idx) & 1;
795
796            let cover_byte_idx = pos / 8;
797            let cover_bit_idx = pos % 8;
798
799            // Set the LSB at this position
800            let byte = cover_data
801                .get_mut(cover_byte_idx)
802                .ok_or(DeniableError::InsufficientCapacity)?;
803            if payload_bit == 1 {
804                *byte |= 1 << cover_bit_idx;
805            } else {
806                *byte &= !(1 << cover_bit_idx);
807            }
808        }
809
810        Ok(())
811    }
812
813    /// Extract a payload from the cover at specified bit positions.
814    fn extract_from_positions(
815        cover_data: &[u8],
816        positions: &[usize],
817        payload_len: usize,
818    ) -> Result<Vec<u8>, DeniableError> {
819        let payload_bits = payload_len * 8;
820
821        if payload_bits > positions.len() {
822            return Err(DeniableError::ExtractionFailed {
823                reason: "insufficient embedding positions for expected payload length".to_string(),
824            });
825        }
826
827        let mut payload = vec![0u8; payload_len];
828
829        for (bit_idx, &pos) in positions.iter().enumerate().take(payload_bits) {
830            let cover_byte_idx = pos / 8;
831            let cover_bit_idx = pos % 8;
832            let cover_byte =
833                cover_data
834                    .get(cover_byte_idx)
835                    .ok_or_else(|| DeniableError::ExtractionFailed {
836                        reason: "cover byte index out of bounds".to_string(),
837                    })?;
838            let cover_bit = (cover_byte >> cover_bit_idx) & 1;
839
840            let payload_byte_idx = bit_idx / 8;
841            let payload_bit_idx = 7 - (bit_idx % 8); // MSB-first
842
843            if cover_bit == 1 {
844                let byte = payload.get_mut(payload_byte_idx).ok_or_else(|| {
845                    DeniableError::ExtractionFailed {
846                        reason: "payload byte index out of bounds".to_string(),
847                    }
848                })?;
849                *byte |= 1 << payload_bit_idx;
850            }
851        }
852
853        Ok(payload)
854    }
855}
856
857impl DeniableEmbedder for DualPayloadEmbedder {
858    fn embed_dual(
859        &self,
860        mut cover: CoverMedia,
861        pair: &DeniablePayloadPair,
862        keys: &DeniableKeySet,
863        _embedder: &dyn EmbedTechnique,
864    ) -> Result<CoverMedia, DeniableError> {
865        let cover_bytes = cover.data.len();
866        let cover_bits = cover_bytes * 8;
867
868        // Split cover into two non-overlapping channels to avoid interference
869        // Channel 0: even bit indices (0, 2, 4, ...)
870        // Channel 1: odd bit indices (1, 3, 5, ...)
871        let channel_capacity = cover_bits / 2;
872
873        // Calculate required capacity (both payloads + length headers)
874        // Header: 4 bytes (32 bits) for each payload length
875        let real_total_bits = (pair.real_payload.len() + 4) * 8;
876        let decoy_total_bits = (pair.decoy_payload.len() + 4) * 8;
877
878        if real_total_bits > channel_capacity || decoy_total_bits > channel_capacity {
879            return Err(DeniableError::InsufficientCapacity);
880        }
881
882        // Derive channel-tagged seeds
883        // Primary key uses channel 0 (even bits)
884        // Decoy key uses channel 1 (odd bits)
885        let primary_seed = Self::derive_seed_with_channel(&keys.primary_key, 0);
886        let decoy_seed = Self::derive_seed_with_channel(&keys.decoy_key, 1);
887
888        // Generate patterns within each channel
889        // Channel 0: select from even-indexed bits
890        let primary_positions =
891            Self::generate_pattern(primary_seed, channel_capacity, real_total_bits)
892                .into_iter()
893                .map(|i| i * 2) // Map to even indices
894                .collect::<Vec<_>>();
895
896        // Channel 1: select from odd-indexed bits
897        let decoy_positions =
898            Self::generate_pattern(decoy_seed, channel_capacity, decoy_total_bits)
899                .into_iter()
900                .map(|i| i * 2 + 1) // Map to odd indices
901                .collect::<Vec<_>>();
902
903        // Prepare payloads with length headers (32-bit big-endian)
904        let real_len = pair.real_payload.len();
905        let decoy_len = pair.decoy_payload.len();
906
907        #[expect(
908            clippy::cast_possible_truncation,
909            reason = "payload size checked against u32::MAX in capacity validation"
910        )]
911        let mut real_with_header = (real_len as u32).to_be_bytes().to_vec();
912        real_with_header.extend_from_slice(&pair.real_payload);
913
914        #[expect(
915            clippy::cast_possible_truncation,
916            reason = "payload size checked against u32::MAX in capacity validation"
917        )]
918        let mut decoy_with_header = (decoy_len as u32).to_be_bytes().to_vec();
919        decoy_with_header.extend_from_slice(&pair.decoy_payload);
920
921        // Get mutable access to cover data
922        let mut cover_data = cover.data.to_vec();
923
924        // Embed both payloads (guaranteed non-overlapping due to channel separation)
925        Self::embed_at_positions(&mut cover_data, &real_with_header, &primary_positions).map_err(
926            |e| DeniableError::EmbedFailed {
927                reason: format!("real payload embed failed: {e}"),
928            },
929        )?;
930
931        Self::embed_at_positions(&mut cover_data, &decoy_with_header, &decoy_positions).map_err(
932            |e| DeniableError::EmbedFailed {
933                reason: format!("decoy payload embed failed: {e}"),
934            },
935        )?;
936
937        cover.data = cover_data.into();
938        Ok(cover)
939    }
940
941    fn extract_with_key(
942        &self,
943        stego: &CoverMedia,
944        key: &[u8],
945        _extractor: &dyn ExtractTechnique,
946    ) -> Result<Payload, DeniableError> {
947        let cover_bytes = stego.data.len();
948        let cover_bits = cover_bytes * 8;
949        let channel_capacity = cover_bits / 2;
950
951        // Try both channels - we don't know which one was used for this key
952        // Channel 0: even bits
953        // Channel 1: odd bits
954
955        for channel in 0..2 {
956            let seed = Self::derive_seed_with_channel(key, channel);
957
958            // Generate pattern for header extraction
959            let header_bits = 32;
960            let header_positions = Self::generate_pattern(seed, channel_capacity, header_bits)
961                .into_iter()
962                .map(|i| i * 2 + channel as usize) // Map to channel's bit indices
963                .collect::<Vec<_>>();
964
965            if header_positions.len() < header_bits {
966                continue; // Try next channel
967            }
968
969            // Extract length header
970            let Ok(header_bytes) =
971                Self::extract_from_positions(stego.data.as_ref(), &header_positions, 4)
972            else {
973                continue; // Try next channel
974            };
975
976            let Ok(header_arr) = <[u8; 4]>::try_from(header_bytes.as_slice()) else {
977                continue;
978            };
979            let payload_len = u32::from_be_bytes(header_arr) as usize;
980
981            // Validate payload length is reasonable
982            // Reject 0-length payloads (likely garbage from wrong channel)
983            if payload_len == 0 {
984                continue; // Try next channel
985            }
986
987            let max_payload_len = channel_capacity / 8;
988            if payload_len > max_payload_len {
989                continue; // Try next channel
990            }
991
992            // Generate full pattern including header + payload
993            let total_bits = (payload_len + 4) * 8;
994            if total_bits > channel_capacity {
995                continue; // Try next channel
996            }
997
998            let positions = Self::generate_pattern(seed, channel_capacity, total_bits)
999                .into_iter()
1000                .map(|i| i * 2 + channel as usize)
1001                .collect::<Vec<_>>();
1002
1003            // Extract full payload
1004            let Ok(with_header) =
1005                Self::extract_from_positions(stego.data.as_ref(), &positions, payload_len + 4)
1006            else {
1007                continue; // Try next channel
1008            };
1009
1010            // Verify header matches
1011            let Ok(extracted_arr) = <[u8; 4]>::try_from(with_header.get(..4).unwrap_or_default())
1012            else {
1013                continue;
1014            };
1015            let extracted_header = u32::from_be_bytes(extracted_arr) as usize;
1016
1017            if extracted_header == payload_len {
1018                // Success! Return payload without header
1019                let payload_data = with_header.get(4..).unwrap_or_default();
1020                return Ok(Payload::from_bytes(payload_data.to_vec()));
1021            }
1022        }
1023
1024        // Failed to extract from both channels
1025        Err(DeniableError::ExtractionFailed {
1026            reason: "failed to extract valid payload from either channel".to_string(),
1027        })
1028    }
1029}
1030
1031// TODO(T11): Implement PdfPageStegoService after LsbImage is available
1032// This service will:
1033// - Render PDF pages to PNG images
1034// - RS-encode payload into N shards (N = page count)
1035// - Embed one shard per page using LsbImage
1036// - Rebuild PDF from stego pages
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    type TestResult = Result<(), Box<dyn std::error::Error>>;
1043
1044    #[test]
1045    fn test_lsb_image_roundtrip_256x256() -> TestResult {
1046        let embedder = LsbImage::new();
1047
1048        // Create 256x256 white RGBA image
1049        let width = 256_u32;
1050        let height = 256_u32;
1051        let pixel_count = width * height;
1052        let data = vec![255u8; (pixel_count * 4) as usize]; // RGBA
1053
1054        let mut metadata = std::collections::HashMap::new();
1055        metadata.insert("width".to_string(), width.to_string());
1056        metadata.insert("height".to_string(), height.to_string());
1057
1058        let cover = CoverMedia {
1059            kind: CoverMediaKind::PngImage,
1060            data: data.into(),
1061            metadata,
1062        };
1063
1064        // 64-byte payload
1065        let payload = Payload::from_bytes(vec![0xAB; 64]);
1066
1067        // Embed
1068        let stego = embedder.embed(cover.clone(), &payload)?;
1069
1070        // Verify pixel changes are ±1
1071        let orig_pixels = cover.data.as_ref();
1072        let stego_pixels = stego.data.as_ref();
1073        for (i, (orig, stego_val)) in orig_pixels.iter().zip(stego_pixels.iter()).enumerate() {
1074            let diff = orig.abs_diff(*stego_val);
1075            assert!(
1076                diff <= 1,
1077                "pixel at index {i} changed by more than 1: {orig} -> {stego_val}"
1078            );
1079        }
1080
1081        // Extract
1082        let extracted = embedder.extract(&stego)?;
1083        assert_eq!(extracted.as_bytes(), payload.as_bytes());
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn test_lsb_image_capacity_10x10() -> TestResult {
1089        let embedder = LsbImage::new();
1090
1091        let width = 10_u32;
1092        let height = 10_u32;
1093        let pixel_count = width * height;
1094        let data = vec![0u8; (pixel_count * 4) as usize];
1095
1096        let mut metadata = std::collections::HashMap::new();
1097        metadata.insert("width".to_string(), width.to_string());
1098        metadata.insert("height".to_string(), height.to_string());
1099
1100        let cover = CoverMedia {
1101            kind: CoverMediaKind::PngImage,
1102            data: data.into(),
1103            metadata,
1104        };
1105
1106        let cap = embedder.capacity(&cover)?;
1107
1108        // 10x10 = 100 pixels
1109        // 100 * 3 = 300 bits
1110        // 300 - 32 (header) = 268 bits
1111        // 268 / 8 = 33 bytes
1112        assert_eq!(cap.bytes, 33);
1113        assert_eq!(cap.technique, StegoTechnique::LsbImage);
1114        Ok(())
1115    }
1116
1117    #[test]
1118    fn test_lsb_image_insufficient_capacity() {
1119        let embedder = LsbImage::new();
1120
1121        let width = 10_u32;
1122        let height = 10_u32;
1123        let pixel_count = width * height;
1124        let data = vec![0u8; (pixel_count * 4) as usize];
1125
1126        let mut metadata = std::collections::HashMap::new();
1127        metadata.insert("width".to_string(), width.to_string());
1128        metadata.insert("height".to_string(), height.to_string());
1129
1130        let cover = CoverMedia {
1131            kind: CoverMediaKind::PngImage,
1132            data: data.into(),
1133            metadata,
1134        };
1135
1136        // Try to embed 100 bytes (capacity is only 33)
1137        let payload = Payload::from_bytes(vec![0xAB; 100]);
1138
1139        let result = embedder.embed(cover, &payload);
1140        assert!(matches!(result, Err(StegoError::PayloadTooLarge { .. })));
1141    }
1142
1143    #[test]
1144    fn test_lsb_image_bmp_support() -> TestResult {
1145        let embedder = LsbImage::new();
1146
1147        let width = 100_u32;
1148        let height = 100_u32;
1149        let pixel_count = width * height;
1150        let data = vec![128u8; (pixel_count * 4) as usize];
1151
1152        let mut metadata = std::collections::HashMap::new();
1153        metadata.insert("width".to_string(), width.to_string());
1154        metadata.insert("height".to_string(), height.to_string());
1155
1156        let cover = CoverMedia {
1157            kind: CoverMediaKind::BmpImage,
1158            data: data.into(),
1159            metadata,
1160        };
1161
1162        let payload = Payload::from_bytes(vec![1, 2, 3, 4, 5]);
1163
1164        // Embed
1165        let stego = embedder.embed(cover, &payload)?;
1166
1167        // Extract
1168        let extracted = embedder.extract(&stego)?;
1169        assert_eq!(extracted.as_bytes(), payload.as_bytes());
1170        Ok(())
1171    }
1172
1173    #[test]
1174    fn test_dct_jpeg_stub_returns_not_implemented() {
1175        let embedder = DctJpeg::new();
1176
1177        let cover = CoverMedia {
1178            kind: CoverMediaKind::JpegImage,
1179            data: vec![].into(),
1180            metadata: std::collections::HashMap::new(),
1181        };
1182
1183        let payload = Payload::from_bytes(vec![1, 2, 3]);
1184
1185        // Should return UnsupportedCoverType error indicating not implemented
1186        let result = embedder.embed(cover.clone(), &payload);
1187        assert!(matches!(
1188            result,
1189            Err(StegoError::UnsupportedCoverType { .. })
1190        ));
1191
1192        let result = embedder.extract(&cover);
1193        assert!(matches!(
1194            result,
1195            Err(StegoError::UnsupportedCoverType { .. })
1196        ));
1197
1198        let result = embedder.capacity(&cover);
1199        assert!(matches!(
1200            result,
1201            Err(StegoError::UnsupportedCoverType { .. })
1202        ));
1203    }
1204
1205    #[test]
1206    fn test_palette_stego_stub_returns_not_implemented() {
1207        let embedder = PaletteStego::new();
1208
1209        let cover = CoverMedia {
1210            kind: CoverMediaKind::GifImage,
1211            data: vec![].into(),
1212            metadata: std::collections::HashMap::new(),
1213        };
1214
1215        let payload = Payload::from_bytes(vec![1, 2, 3]);
1216
1217        // Should return UnsupportedCoverType error indicating not implemented
1218        let result = embedder.embed(cover.clone(), &payload);
1219        assert!(matches!(
1220            result,
1221            Err(StegoError::UnsupportedCoverType { .. })
1222        ));
1223
1224        let result = embedder.extract(&cover);
1225        assert!(matches!(
1226            result,
1227            Err(StegoError::UnsupportedCoverType { .. })
1228        ));
1229
1230        let result = embedder.capacity(&cover);
1231        assert!(matches!(
1232            result,
1233            Err(StegoError::UnsupportedCoverType { .. })
1234        ));
1235    }
1236
1237    #[test]
1238    fn test_lsb_audio_roundtrip() -> TestResult {
1239        let embedder = LsbAudio::new();
1240
1241        // Create 1s of 44100 Hz 16-bit mono silence (44100 samples)
1242        let sample_rate = 44100;
1243        let sample_count = sample_rate; // 1 second
1244        let mut data = Vec::new();
1245        for _ in 0..sample_count {
1246            data.extend_from_slice(&0_i16.to_le_bytes());
1247        }
1248
1249        let mut metadata = std::collections::HashMap::new();
1250        metadata.insert("sample_rate".to_string(), sample_rate.to_string());
1251        metadata.insert("channels".to_string(), "1".to_string());
1252        metadata.insert("bits_per_sample".to_string(), "16".to_string());
1253
1254        let cover = CoverMedia {
1255            kind: CoverMediaKind::WavAudio,
1256            data: data.into(),
1257            metadata,
1258        };
1259
1260        // 512-byte payload
1261        let payload = Payload::from_bytes(vec![0xAB; 512]);
1262
1263        // Embed
1264        let stego = embedder.embed(cover, &payload)?;
1265
1266        // Extract
1267        let extracted = embedder.extract(&stego)?;
1268        assert_eq!(extracted.as_bytes(), payload.as_bytes());
1269        Ok(())
1270    }
1271
1272    #[test]
1273    fn test_lsb_audio_capacity() -> TestResult {
1274        let embedder = LsbAudio::new();
1275
1276        // 1000 samples
1277        let sample_count = 1000;
1278        let mut data = Vec::new();
1279        for _ in 0..sample_count {
1280            data.extend_from_slice(&0_i16.to_le_bytes());
1281        }
1282
1283        let mut metadata = std::collections::HashMap::new();
1284        metadata.insert("sample_rate".to_string(), "44100".to_string());
1285        metadata.insert("channels".to_string(), "1".to_string());
1286        metadata.insert("bits_per_sample".to_string(), "16".to_string());
1287
1288        let cover = CoverMedia {
1289            kind: CoverMediaKind::WavAudio,
1290            data: data.into(),
1291            metadata,
1292        };
1293
1294        let cap = embedder.capacity(&cover)?;
1295
1296        // 1000 samples - 32 (header) = 968 bits / 8 = 121 bytes
1297        assert_eq!(cap.bytes, 121);
1298        assert_eq!(cap.technique, StegoTechnique::LsbAudio);
1299        Ok(())
1300    }
1301
1302    #[test]
1303    fn test_lsb_audio_insufficient_capacity() {
1304        let embedder = LsbAudio::new();
1305
1306        // 100 samples (very short audio)
1307        let sample_count = 100;
1308        let mut data = Vec::new();
1309        for _ in 0..sample_count {
1310            data.extend_from_slice(&0_i16.to_le_bytes());
1311        }
1312
1313        let mut metadata = std::collections::HashMap::new();
1314        metadata.insert("sample_rate".to_string(), "44100".to_string());
1315        metadata.insert("channels".to_string(), "1".to_string());
1316        metadata.insert("bits_per_sample".to_string(), "16".to_string());
1317
1318        let cover = CoverMedia {
1319            kind: CoverMediaKind::WavAudio,
1320            data: data.into(),
1321            metadata,
1322        };
1323
1324        // Try to embed 100 bytes (capacity is only 8 bytes)
1325        let payload = Payload::from_bytes(vec![0xAB; 100]);
1326
1327        let result = embedder.embed(cover, &payload);
1328        assert!(matches!(result, Err(StegoError::PayloadTooLarge { .. })));
1329    }
1330
1331    #[test]
1332    fn test_phase_encoding_stub_returns_not_implemented() {
1333        let embedder = PhaseEncoding::new();
1334
1335        let mut metadata = std::collections::HashMap::new();
1336        metadata.insert("sample_rate".to_string(), "44100".to_string());
1337        metadata.insert("channels".to_string(), "1".to_string());
1338        metadata.insert("bits_per_sample".to_string(), "16".to_string());
1339
1340        let cover = CoverMedia {
1341            kind: CoverMediaKind::WavAudio,
1342            data: vec![0; 1000].into(),
1343            metadata,
1344        };
1345
1346        let payload = Payload::from_bytes(vec![1, 2, 3]);
1347
1348        let result = embedder.embed(cover.clone(), &payload);
1349        assert!(matches!(
1350            result,
1351            Err(StegoError::UnsupportedCoverType { .. })
1352        ));
1353
1354        let result = embedder.extract(&cover);
1355        assert!(matches!(
1356            result,
1357            Err(StegoError::UnsupportedCoverType { .. })
1358        ));
1359
1360        let result = embedder.capacity(&cover);
1361        assert!(matches!(
1362            result,
1363            Err(StegoError::UnsupportedCoverType { .. })
1364        ));
1365    }
1366
1367    #[test]
1368    fn test_echo_hiding_stub_returns_not_implemented() {
1369        let embedder = EchoHiding::new();
1370
1371        let mut metadata = std::collections::HashMap::new();
1372        metadata.insert("sample_rate".to_string(), "44100".to_string());
1373        metadata.insert("channels".to_string(), "1".to_string());
1374        metadata.insert("bits_per_sample".to_string(), "16".to_string());
1375
1376        let cover = CoverMedia {
1377            kind: CoverMediaKind::WavAudio,
1378            data: vec![0; 1000].into(),
1379            metadata,
1380        };
1381
1382        let payload = Payload::from_bytes(vec![1, 2, 3]);
1383
1384        let result = embedder.embed(cover.clone(), &payload);
1385        assert!(matches!(
1386            result,
1387            Err(StegoError::UnsupportedCoverType { .. })
1388        ));
1389
1390        let result = embedder.extract(&cover);
1391        assert!(matches!(
1392            result,
1393            Err(StegoError::UnsupportedCoverType { .. })
1394        ));
1395
1396        let result = embedder.capacity(&cover);
1397        assert!(matches!(
1398            result,
1399            Err(StegoError::UnsupportedCoverType { .. })
1400        ));
1401    }
1402
1403    #[test]
1404    fn test_zero_width_text_stub_returns_not_implemented() {
1405        let embedder = ZeroWidthText::new();
1406
1407        let cover = CoverMedia {
1408            kind: CoverMediaKind::PlainText,
1409            data: b"Hello, world!".to_vec().into(),
1410            metadata: std::collections::HashMap::new(),
1411        };
1412
1413        let payload = Payload::from_bytes(vec![1, 2, 3]);
1414
1415        let result = embedder.embed(cover.clone(), &payload);
1416        assert!(matches!(
1417            result,
1418            Err(StegoError::UnsupportedCoverType { .. })
1419        ));
1420
1421        let result = embedder.extract(&cover);
1422        assert!(matches!(
1423            result,
1424            Err(StegoError::UnsupportedCoverType { .. })
1425        ));
1426
1427        let result = embedder.capacity(&cover);
1428        assert!(matches!(
1429            result,
1430            Err(StegoError::UnsupportedCoverType { .. })
1431        ));
1432    }
1433
1434    #[test]
1435    fn test_dual_payload_roundtrip() -> TestResult {
1436        let embedder = DualPayloadEmbedder::new();
1437        let lsb_image = LsbImage::new();
1438
1439        // Create a 100x100 pixel RGB image (30000 bytes)
1440        let width = 100u32;
1441        let height = 100u32;
1442        let pixel_count = (width * height) as usize;
1443        let data = vec![0u8; pixel_count * 3]; // RGB
1444
1445        let mut metadata = std::collections::HashMap::new();
1446        metadata.insert("width".to_string(), width.to_string());
1447        metadata.insert("height".to_string(), height.to_string());
1448        metadata.insert("channels".to_string(), "3".to_string());
1449
1450        let cover = CoverMedia {
1451            kind: CoverMediaKind::PngImage,
1452            data: data.into(),
1453            metadata,
1454        };
1455
1456        // Create payload pair
1457        let real_payload = b"This is the REAL secret message".to_vec();
1458        let decoy_payload = b"This is just a decoy".to_vec();
1459
1460        let pair = DeniablePayloadPair {
1461            real_payload: real_payload.clone(),
1462            decoy_payload: decoy_payload.clone(),
1463        };
1464
1465        // Create key set
1466        let keys = DeniableKeySet {
1467            primary_key: b"primary_key_12345".to_vec(),
1468            decoy_key: b"decoy_key_67890".to_vec(),
1469        };
1470
1471        // Embed dual payloads
1472        let stego = embedder.embed_dual(cover, &pair, &keys, &lsb_image)?;
1473
1474        // Extract with primary key
1475        let extracted_real = embedder.extract_with_key(&stego, &keys.primary_key, &lsb_image)?;
1476        assert_eq!(extracted_real.as_bytes(), &real_payload);
1477
1478        // Extract with decoy key
1479        let extracted_decoy = embedder.extract_with_key(&stego, &keys.decoy_key, &lsb_image)?;
1480        assert_eq!(extracted_decoy.as_bytes(), &decoy_payload);
1481        Ok(())
1482    }
1483
1484    #[test]
1485    fn test_dual_payload_insufficient_capacity() {
1486        let embedder = DualPayloadEmbedder::new();
1487        let lsb_image = LsbImage::new();
1488
1489        // Create a very small cover (10x10 pixels = 300 bytes)
1490        let width = 10u32;
1491        let height = 10u32;
1492        let pixel_count = (width * height) as usize;
1493        let data = vec![0u8; pixel_count * 3];
1494
1495        let mut metadata = std::collections::HashMap::new();
1496        metadata.insert("width".to_string(), width.to_string());
1497        metadata.insert("height".to_string(), height.to_string());
1498        metadata.insert("channels".to_string(), "3".to_string());
1499
1500        let cover = CoverMedia {
1501            kind: CoverMediaKind::PngImage,
1502            data: data.into(),
1503            metadata,
1504        };
1505
1506        // Create large payloads that won't fit
1507        let real_payload = vec![0u8; 200];
1508        let decoy_payload = vec![0u8; 200];
1509
1510        let pair = DeniablePayloadPair {
1511            real_payload,
1512            decoy_payload,
1513        };
1514
1515        let keys = DeniableKeySet {
1516            primary_key: b"primary_key".to_vec(),
1517            decoy_key: b"decoy_key".to_vec(),
1518        };
1519
1520        // Should fail with InsufficientCapacity
1521        let result = embedder.embed_dual(cover, &pair, &keys, &lsb_image);
1522        assert!(matches!(result, Err(DeniableError::InsufficientCapacity)));
1523    }
1524
1525    #[test]
1526    fn test_dual_payload_different_keys_produce_different_results() -> TestResult {
1527        let embedder = DualPayloadEmbedder::new();
1528        let lsb_image = LsbImage::new();
1529
1530        // Create cover
1531        let width = 100u32;
1532        let height = 100u32;
1533        let pixel_count = (width * height) as usize;
1534        let data = vec![0u8; pixel_count * 3];
1535
1536        let mut metadata = std::collections::HashMap::new();
1537        metadata.insert("width".to_string(), width.to_string());
1538        metadata.insert("height".to_string(), height.to_string());
1539        metadata.insert("channels".to_string(), "3".to_string());
1540
1541        let cover = CoverMedia {
1542            kind: CoverMediaKind::PngImage,
1543            data: data.into(),
1544            metadata,
1545        };
1546
1547        let real_payload = b"Real secret".to_vec();
1548        let decoy_payload = b"Fake data".to_vec();
1549
1550        let pair = DeniablePayloadPair {
1551            real_payload: real_payload.clone(),
1552            decoy_payload: decoy_payload.clone(),
1553        };
1554
1555        let keys = DeniableKeySet {
1556            primary_key: b"key1".to_vec(),
1557            decoy_key: b"key2".to_vec(),
1558        };
1559
1560        let stego = embedder.embed_dual(cover, &pair, &keys, &lsb_image)?;
1561
1562        // Extract with primary key
1563        let extracted1 = embedder.extract_with_key(&stego, &keys.primary_key, &lsb_image)?;
1564
1565        // Extract with decoy key
1566        let extracted2 = embedder.extract_with_key(&stego, &keys.decoy_key, &lsb_image)?;
1567
1568        // Should be different
1569        assert_ne!(extracted1.as_bytes(), extracted2.as_bytes());
1570        assert_eq!(extracted1.as_bytes(), &real_payload);
1571        assert_eq!(extracted2.as_bytes(), &decoy_payload);
1572        Ok(())
1573    }
1574
1575    #[test]
1576    fn test_dual_payload_wrong_key_produces_garbage() -> TestResult {
1577        let embedder = DualPayloadEmbedder::new();
1578        let lsb_image = LsbImage::new();
1579
1580        // Create cover
1581        let width = 100u32;
1582        let height = 100u32;
1583        let pixel_count = (width * height) as usize;
1584        let data = vec![0u8; pixel_count * 3];
1585
1586        let mut metadata = std::collections::HashMap::new();
1587        metadata.insert("width".to_string(), width.to_string());
1588        metadata.insert("height".to_string(), height.to_string());
1589        metadata.insert("channels".to_string(), "3".to_string());
1590
1591        let cover = CoverMedia {
1592            kind: CoverMediaKind::PngImage,
1593            data: data.into(),
1594            metadata,
1595        };
1596
1597        let real_payload = b"Real secret message".to_vec();
1598        let decoy_payload = b"Decoy message".to_vec();
1599
1600        let pair = DeniablePayloadPair {
1601            real_payload: real_payload.clone(),
1602            decoy_payload: decoy_payload.clone(),
1603        };
1604
1605        let keys = DeniableKeySet {
1606            primary_key: b"correct_primary".to_vec(),
1607            decoy_key: b"correct_decoy".to_vec(),
1608        };
1609
1610        let stego = embedder.embed_dual(cover, &pair, &keys, &lsb_image)?;
1611
1612        // Try extracting with a wrong key
1613        let wrong_key = b"wrong_key";
1614        let result = embedder.extract_with_key(&stego, wrong_key, &lsb_image);
1615
1616        // Extraction may succeed but produce garbage, or may fail with ExtractionFailed
1617        // depending on what the garbage header decodes to
1618        match result {
1619            Ok(extracted) => {
1620                // If it succeeds, the extracted data should not match either payload
1621                assert_ne!(extracted.as_bytes(), &real_payload);
1622                assert_ne!(extracted.as_bytes(), &decoy_payload);
1623            }
1624            Err(DeniableError::ExtractionFailed { .. }) => {
1625                // This is also acceptable - garbage header caused extraction to fail
1626            }
1627            Err(e) => return Err(e.into()),
1628        }
1629        Ok(())
1630    }
1631
1632    // ─── Additional edge-case stego tests ─────────────────────────────────
1633
1634    #[test]
1635    fn test_lsb_image_wrong_cover_type_embed() {
1636        let embedder = LsbImage::new();
1637        let cover = CoverMedia {
1638            kind: CoverMediaKind::WavAudio,
1639            data: vec![0u8; 100].into(),
1640            metadata: std::collections::HashMap::new(),
1641        };
1642        let payload = Payload::from_bytes(vec![1, 2, 3]);
1643        let result = embedder.embed(cover, &payload);
1644        assert!(matches!(
1645            result,
1646            Err(StegoError::UnsupportedCoverType { .. })
1647        ));
1648    }
1649
1650    #[test]
1651    fn test_lsb_image_wrong_cover_type_extract() {
1652        let embedder = LsbImage::new();
1653        let cover = CoverMedia {
1654            kind: CoverMediaKind::JpegImage,
1655            data: vec![0u8; 100].into(),
1656            metadata: std::collections::HashMap::new(),
1657        };
1658        let result = embedder.extract(&cover);
1659        assert!(matches!(
1660            result,
1661            Err(StegoError::UnsupportedCoverType { .. })
1662        ));
1663    }
1664
1665    #[test]
1666    fn test_lsb_image_missing_width_metadata() {
1667        let embedder = LsbImage::new();
1668        let mut metadata = std::collections::HashMap::new();
1669        metadata.insert("height".to_string(), "100".to_string());
1670        metadata.insert("channels".to_string(), "3".to_string());
1671        let cover = CoverMedia {
1672            kind: CoverMediaKind::PngImage,
1673            data: vec![0u8; 30000].into(),
1674            metadata,
1675        };
1676        let result = embedder.capacity(&cover);
1677        assert!(matches!(result, Err(StegoError::MalformedCoverData { .. })));
1678    }
1679
1680    #[test]
1681    fn test_lsb_image_missing_height_metadata() {
1682        let embedder = LsbImage::new();
1683        let mut metadata = std::collections::HashMap::new();
1684        metadata.insert("width".to_string(), "100".to_string());
1685        metadata.insert("channels".to_string(), "3".to_string());
1686        let cover = CoverMedia {
1687            kind: CoverMediaKind::PngImage,
1688            data: vec![0u8; 30000].into(),
1689            metadata,
1690        };
1691        let result = embedder.capacity(&cover);
1692        assert!(matches!(result, Err(StegoError::MalformedCoverData { .. })));
1693    }
1694
1695    #[test]
1696    fn test_lsb_image_invalid_width_metadata() {
1697        let embedder = LsbImage::new();
1698        let mut metadata = std::collections::HashMap::new();
1699        metadata.insert("width".to_string(), "not_a_number".to_string());
1700        metadata.insert("height".to_string(), "100".to_string());
1701        metadata.insert("channels".to_string(), "3".to_string());
1702        let cover = CoverMedia {
1703            kind: CoverMediaKind::PngImage,
1704            data: vec![0u8; 30000].into(),
1705            metadata,
1706        };
1707        let result = embedder.capacity(&cover);
1708        assert!(matches!(result, Err(StegoError::MalformedCoverData { .. })));
1709    }
1710
1711    #[test]
1712    fn test_lsb_image_wrong_cover_type_capacity() {
1713        let embedder = LsbImage::new();
1714        let cover = CoverMedia {
1715            kind: CoverMediaKind::GifImage,
1716            data: vec![0u8; 100].into(),
1717            metadata: std::collections::HashMap::new(),
1718        };
1719        let result = embedder.capacity(&cover);
1720        assert!(matches!(
1721            result,
1722            Err(StegoError::UnsupportedCoverType { .. })
1723        ));
1724    }
1725
1726    #[test]
1727    fn test_lsb_audio_wrong_cover_type() {
1728        let embedder = LsbAudio::new();
1729        let cover = CoverMedia {
1730            kind: CoverMediaKind::PngImage,
1731            data: vec![0u8; 1000].into(),
1732            metadata: std::collections::HashMap::new(),
1733        };
1734        let payload = Payload::from_bytes(vec![1, 2, 3]);
1735        let result = embedder.embed(cover, &payload);
1736        assert!(matches!(
1737            result,
1738            Err(StegoError::UnsupportedCoverType { .. })
1739        ));
1740    }
1741
1742    #[test]
1743    fn test_lsb_audio_wrong_cover_type_extract() {
1744        let embedder = LsbAudio::new();
1745        let cover = CoverMedia {
1746            kind: CoverMediaKind::PngImage,
1747            data: vec![0u8; 1000].into(),
1748            metadata: std::collections::HashMap::new(),
1749        };
1750        let result = embedder.extract(&cover);
1751        assert!(matches!(
1752            result,
1753            Err(StegoError::UnsupportedCoverType { .. })
1754        ));
1755    }
1756
1757    #[test]
1758    fn test_lsb_audio_wrong_cover_type_capacity() {
1759        let embedder = LsbAudio::new();
1760        let cover = CoverMedia {
1761            kind: CoverMediaKind::PngImage,
1762            data: vec![0u8; 1000].into(),
1763            metadata: std::collections::HashMap::new(),
1764        };
1765        let result = embedder.capacity(&cover);
1766        assert!(matches!(
1767            result,
1768            Err(StegoError::UnsupportedCoverType { .. })
1769        ));
1770    }
1771
1772    // ─── Stub technique tests ─────────────────────────────────────────────
1773
1774    fn dummy_cover() -> CoverMedia {
1775        CoverMedia {
1776            kind: CoverMediaKind::PngImage,
1777            data: vec![0u8; 64].into(),
1778            metadata: std::collections::HashMap::new(),
1779        }
1780    }
1781
1782    #[test]
1783    fn dct_jpeg_stub_capacity_returns_error() {
1784        let dct = DctJpeg::new();
1785        assert!(dct.capacity(&dummy_cover()).is_err());
1786        assert_eq!(EmbedTechnique::technique(&dct), StegoTechnique::DctJpeg);
1787    }
1788
1789    #[test]
1790    fn dct_jpeg_stub_embed_returns_error() {
1791        let dct = DctJpeg::new();
1792        let result = dct.embed(dummy_cover(), &Payload::from_bytes(vec![1]));
1793        assert!(matches!(
1794            result,
1795            Err(StegoError::UnsupportedCoverType { .. })
1796        ));
1797    }
1798
1799    #[test]
1800    fn dct_jpeg_stub_extract_returns_error() {
1801        let dct = DctJpeg::new();
1802        let result = dct.extract(&dummy_cover());
1803        assert!(matches!(
1804            result,
1805            Err(StegoError::UnsupportedCoverType { .. })
1806        ));
1807        assert_eq!(ExtractTechnique::technique(&dct), StegoTechnique::DctJpeg);
1808    }
1809
1810    #[test]
1811    fn palette_stego_stub_capacity_returns_error() {
1812        let pal = PaletteStego::new();
1813        assert!(pal.capacity(&dummy_cover()).is_err());
1814        assert_eq!(EmbedTechnique::technique(&pal), StegoTechnique::Palette);
1815    }
1816
1817    #[test]
1818    fn palette_stego_stub_embed_returns_error() {
1819        let pal = PaletteStego::new();
1820        let result = pal.embed(dummy_cover(), &Payload::from_bytes(vec![1]));
1821        assert!(matches!(
1822            result,
1823            Err(StegoError::UnsupportedCoverType { .. })
1824        ));
1825    }
1826
1827    #[test]
1828    fn palette_stego_stub_extract_returns_error() {
1829        let pal = PaletteStego::new();
1830        let result = pal.extract(&dummy_cover());
1831        assert!(matches!(
1832            result,
1833            Err(StegoError::UnsupportedCoverType { .. })
1834        ));
1835        assert_eq!(ExtractTechnique::technique(&pal), StegoTechnique::Palette);
1836    }
1837
1838    #[test]
1839    fn phase_encoding_stub_capacity_returns_error() {
1840        let pe = PhaseEncoding::new();
1841        assert!(pe.capacity(&dummy_cover()).is_err());
1842        assert_eq!(
1843            EmbedTechnique::technique(&pe),
1844            StegoTechnique::PhaseEncoding
1845        );
1846    }
1847
1848    #[test]
1849    fn phase_encoding_stub_embed_returns_error() {
1850        let pe = PhaseEncoding::new();
1851        let result = pe.embed(dummy_cover(), &Payload::from_bytes(vec![1]));
1852        assert!(matches!(
1853            result,
1854            Err(StegoError::UnsupportedCoverType { .. })
1855        ));
1856    }
1857
1858    #[test]
1859    fn phase_encoding_stub_extract_returns_error() {
1860        let pe = PhaseEncoding::new();
1861        let result = pe.extract(&dummy_cover());
1862        assert!(matches!(
1863            result,
1864            Err(StegoError::UnsupportedCoverType { .. })
1865        ));
1866        assert_eq!(
1867            ExtractTechnique::technique(&pe),
1868            StegoTechnique::PhaseEncoding
1869        );
1870    }
1871
1872    #[test]
1873    fn echo_hiding_stub_capacity_returns_error() {
1874        let eh = EchoHiding::new();
1875        assert!(eh.capacity(&dummy_cover()).is_err());
1876        assert_eq!(EmbedTechnique::technique(&eh), StegoTechnique::EchoHiding);
1877    }
1878
1879    #[test]
1880    fn echo_hiding_stub_embed_returns_error() {
1881        let eh = EchoHiding::new();
1882        let result = eh.embed(dummy_cover(), &Payload::from_bytes(vec![1]));
1883        assert!(matches!(
1884            result,
1885            Err(StegoError::UnsupportedCoverType { .. })
1886        ));
1887    }
1888
1889    #[test]
1890    fn echo_hiding_stub_extract_returns_error() {
1891        let eh = EchoHiding::new();
1892        let result = eh.extract(&dummy_cover());
1893        assert!(matches!(
1894            result,
1895            Err(StegoError::UnsupportedCoverType { .. })
1896        ));
1897        assert_eq!(ExtractTechnique::technique(&eh), StegoTechnique::EchoHiding);
1898    }
1899
1900    #[test]
1901    fn zero_width_text_stub_capacity_returns_error() {
1902        let zwt = ZeroWidthText::new();
1903        assert!(zwt.capacity(&dummy_cover()).is_err());
1904        assert_eq!(
1905            EmbedTechnique::technique(&zwt),
1906            StegoTechnique::ZeroWidthText
1907        );
1908    }
1909
1910    #[test]
1911    fn zero_width_text_stub_embed_returns_error() {
1912        let zwt = ZeroWidthText::new();
1913        let result = zwt.embed(dummy_cover(), &Payload::from_bytes(vec![1]));
1914        assert!(matches!(
1915            result,
1916            Err(StegoError::UnsupportedCoverType { .. })
1917        ));
1918    }
1919
1920    #[test]
1921    fn zero_width_text_stub_extract_returns_error() {
1922        let zwt = ZeroWidthText::new();
1923        let result = zwt.extract(&dummy_cover());
1924        assert!(matches!(
1925            result,
1926            Err(StegoError::UnsupportedCoverType { .. })
1927        ));
1928        assert_eq!(
1929            ExtractTechnique::technique(&zwt),
1930            StegoTechnique::ZeroWidthText
1931        );
1932    }
1933
1934    #[test]
1935    fn lsb_image_insufficient_capacity() {
1936        let embedder = LsbImage::new();
1937        let mut metadata = std::collections::HashMap::new();
1938        metadata.insert("width".to_string(), "2".to_string());
1939        metadata.insert("height".to_string(), "2".to_string());
1940        metadata.insert("channels".to_string(), "3".to_string());
1941        let cover = CoverMedia {
1942            kind: CoverMediaKind::PngImage,
1943            // 12 bytes = 2x2x3 pixels, capacity = 12/8 = 1 byte minus header
1944            data: vec![0u8; 12].into(),
1945            metadata,
1946        };
1947        // Payload larger than capacity
1948        let payload = Payload::from_bytes(vec![0xAB; 100]);
1949        let result = embedder.embed(cover, &payload);
1950        assert!(result.is_err());
1951    }
1952
1953    #[test]
1954    fn lsb_audio_insufficient_capacity() {
1955        let embedder = LsbAudio::new();
1956        let mut metadata = std::collections::HashMap::new();
1957        metadata.insert("bits_per_sample".to_string(), "16".to_string());
1958        let cover = CoverMedia {
1959            kind: CoverMediaKind::WavAudio,
1960            // Very little audio data — only a few samples
1961            data: vec![0u8; 10].into(),
1962            metadata,
1963        };
1964        let payload = Payload::from_bytes(vec![0xAB; 100]);
1965        let result = embedder.embed(cover, &payload);
1966        assert!(result.is_err());
1967    }
1968}