Skip to main content

shadowforge_lib/adapters/
deadrop.rs

1//! Dead drop adapter implementing the [`DeadDropEncoder`] port.
2
3use crate::domain::errors::DeadDropError;
4use crate::domain::ports::{DeadDropEncoder, EmbedTechnique};
5use crate::domain::types::{CoverMedia, Payload, PlatformProfile};
6
7/// Adapter implementing platform-aware dead-drop encoding.
8///
9/// Delegates embedding to the provided [`EmbedTechnique`] and applies
10/// platform-specific constraints (e.g. Telegram passes PNG losslessly,
11/// while Instagram recompresses JPEGs).
12pub struct DeadDropEncoderImpl;
13
14impl DeadDropEncoderImpl {
15    /// Create a new dead-drop encoder.
16    #[must_use]
17    pub const fn new() -> Self {
18        Self
19    }
20}
21
22impl Default for DeadDropEncoderImpl {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl DeadDropEncoder for DeadDropEncoderImpl {
29    fn encode_for_platform(
30        &self,
31        cover: CoverMedia,
32        payload: &Payload,
33        platform: &PlatformProfile,
34        technique: &dyn EmbedTechnique,
35    ) -> Result<CoverMedia, DeadDropError> {
36        // Validate that the technique can handle this cover
37        let capacity = technique
38            .capacity(&cover)
39            .map_err(|e| DeadDropError::EncodeFailed {
40                reason: format!("capacity check failed: {e}"),
41            })?;
42
43        if capacity.bytes < payload.as_bytes().len() as u64 {
44            return Err(DeadDropError::EncodeFailed {
45                reason: format!(
46                    "payload ({} bytes) exceeds cover capacity ({} bytes) for platform {platform:?}",
47                    payload.as_bytes().len(),
48                    capacity.bytes,
49                ),
50            });
51        }
52
53        // Embed the payload using the provided technique.
54        // For lossless platforms (Telegram), a simple LSB embed suffices.
55        // For lossy platforms (Instagram, Twitter, etc.), the caller should
56        // provide a compression-survivable embedder (T18) — this adapter
57        // delegates the strategy choice to the caller.
58        let stego_cover =
59            technique
60                .embed(cover, payload)
61                .map_err(|e| DeadDropError::EncodeFailed {
62                    reason: format!("embedding failed for platform {platform:?}: {e}"),
63                })?;
64
65        Ok(stego_cover)
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::domain::errors::StegoError;
73    use crate::domain::types::{Capacity, CoverMediaKind, StegoTechnique};
74    use bytes::Bytes;
75    use std::collections::HashMap;
76
77    type TestResult = Result<(), Box<dyn std::error::Error>>;
78
79    struct MockEmbedder {
80        cap: u64,
81        fail_embed: bool,
82    }
83
84    impl EmbedTechnique for MockEmbedder {
85        fn technique(&self) -> StegoTechnique {
86            StegoTechnique::LsbImage
87        }
88
89        fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
90            Ok(Capacity {
91                bytes: self.cap,
92                technique: StegoTechnique::LsbImage,
93            })
94        }
95
96        fn embed(
97            &self,
98            mut cover: CoverMedia,
99            payload: &Payload,
100        ) -> Result<CoverMedia, StegoError> {
101            if self.fail_embed {
102                return Err(StegoError::MalformedCoverData {
103                    reason: "mock failure".into(),
104                });
105            }
106            let mut data = cover.data.to_vec();
107            data.extend_from_slice(payload.as_bytes());
108            cover.data = Bytes::from(data);
109            Ok(cover)
110        }
111    }
112
113    fn make_cover() -> CoverMedia {
114        CoverMedia {
115            kind: CoverMediaKind::PngImage,
116            data: Bytes::from(vec![0u8; 512]),
117            metadata: HashMap::new(),
118        }
119    }
120
121    #[test]
122    fn encode_for_telegram_succeeds() -> TestResult {
123        let encoder = DeadDropEncoderImpl::new();
124        let cover = make_cover();
125        let payload = Payload::from_bytes(vec![1, 2, 3, 4]);
126        let mock = MockEmbedder {
127            cap: 1024,
128            fail_embed: false,
129        };
130
131        let result =
132            encoder.encode_for_platform(cover, &payload, &PlatformProfile::Telegram, &mock);
133
134        assert!(result.is_ok());
135        let stego = result?;
136        // Stego cover should be larger (payload appended by mock)
137        assert!(stego.data.len() > 512);
138        Ok(())
139    }
140
141    #[test]
142    fn encode_for_instagram_succeeds() {
143        let encoder = DeadDropEncoderImpl::new();
144        let cover = make_cover();
145        let payload = Payload::from_bytes(vec![5, 6, 7]);
146        let mock = MockEmbedder {
147            cap: 1024,
148            fail_embed: false,
149        };
150
151        let result =
152            encoder.encode_for_platform(cover, &payload, &PlatformProfile::Instagram, &mock);
153
154        assert!(result.is_ok());
155    }
156
157    #[test]
158    fn encode_fails_when_payload_exceeds_capacity() {
159        let encoder = DeadDropEncoderImpl::new();
160        let cover = make_cover();
161        let payload = Payload::from_bytes(vec![0u8; 100]);
162        let mock = MockEmbedder {
163            cap: 10, // Too small
164            fail_embed: false,
165        };
166
167        let result = encoder.encode_for_platform(cover, &payload, &PlatformProfile::Twitter, &mock);
168
169        assert!(matches!(result, Err(DeadDropError::EncodeFailed { .. })));
170    }
171
172    #[test]
173    fn encode_fails_when_embedder_fails() {
174        let encoder = DeadDropEncoderImpl::new();
175        let cover = make_cover();
176        let payload = Payload::from_bytes(vec![1, 2, 3]);
177        let mock = MockEmbedder {
178            cap: 1024,
179            fail_embed: true,
180        };
181
182        let result = encoder.encode_for_platform(cover, &payload, &PlatformProfile::Imgur, &mock);
183
184        assert!(matches!(result, Err(DeadDropError::EncodeFailed { .. })));
185    }
186
187    #[test]
188    fn encode_works_with_custom_platform() {
189        let encoder = DeadDropEncoderImpl::new();
190        let cover = make_cover();
191        let payload = Payload::from_bytes(vec![10, 20, 30]);
192        let mock = MockEmbedder {
193            cap: 1024,
194            fail_embed: false,
195        };
196        let platform = PlatformProfile::Custom {
197            quality: 75,
198            subsampling: crate::domain::types::ChromaSubsampling::Yuv422,
199        };
200
201        let result = encoder.encode_for_platform(cover, &payload, &platform, &mock);
202        assert!(result.is_ok());
203    }
204}