shadowforge_lib/adapters/
canary.rs1use uuid::Uuid;
4
5use crate::domain::canary::{generate_canary_shard, is_canary_shard};
6use crate::domain::errors::CanaryError;
7use crate::domain::ports::{CanaryService, EmbedTechnique};
8use crate::domain::types::{CanaryShard, CoverMedia, Payload};
9
10pub struct CanaryServiceImpl {
16 shard_size: usize,
18 total_shards: u8,
20}
21
22impl CanaryServiceImpl {
23 #[must_use]
25 pub const fn new(shard_size: usize, total_shards: u8) -> Self {
26 Self {
27 shard_size,
28 total_shards,
29 }
30 }
31
32 #[must_use]
34 pub fn verify_canary(shard: &crate::domain::types::Shard, canary_id: Uuid) -> bool {
35 is_canary_shard(shard, canary_id)
36 }
37}
38
39impl CanaryService for CanaryServiceImpl {
40 fn embed_canary(
41 &self,
42 covers: Vec<CoverMedia>,
43 embedder: &dyn EmbedTechnique,
44 ) -> Result<(Vec<CoverMedia>, CanaryShard), CanaryError> {
45 if covers.is_empty() {
46 return Err(CanaryError::NoCovers);
47 }
48
49 let canary_id = Uuid::new_v4();
50 let canary = generate_canary_shard(canary_id, self.shard_size, self.total_shards, None);
51
52 let canary_payload = Payload::from_bytes(canary.shard.data.clone());
54
55 let mut result_covers = Vec::with_capacity(covers.len());
57 let mut placed = false;
58
59 for cover in covers {
60 if !placed
61 && let Ok(cap) = embedder.capacity(&cover)
62 && cap.bytes >= canary_payload.as_bytes().len() as u64
63 {
64 if let Ok(stego_cover) = embedder.embed(cover, &canary_payload) {
65 result_covers.push(stego_cover);
66 placed = true;
67 continue;
68 }
69 continue;
71 }
72 result_covers.push(cover);
73 }
74
75 if !placed {
76 return Err(CanaryError::EmbedFailed {
77 source: crate::domain::errors::StegoError::PayloadTooLarge {
78 available: 0,
79 needed: canary_payload.as_bytes().len() as u64,
80 },
81 });
82 }
83
84 Ok((result_covers, canary))
85 }
86
87 fn check_canary(&self, shard: &CanaryShard) -> bool {
88 let Some(url) = &shard.notify_url else {
90 return false;
91 };
92
93 if !url.starts_with("https://") {
95 return false;
96 }
97
98 let _ = url;
102 false
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::domain::errors::StegoError;
110 use crate::domain::types::{Capacity, CoverMedia, CoverMediaKind, StegoTechnique};
111 use bytes::Bytes;
112 use std::collections::HashMap;
113
114 type TestResult = Result<(), Box<dyn std::error::Error>>;
115
116 struct MockEmbedder {
118 capacity_bytes: u64,
119 should_fail: bool,
120 }
121
122 impl EmbedTechnique for MockEmbedder {
123 fn technique(&self) -> StegoTechnique {
124 StegoTechnique::LsbImage
125 }
126
127 fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
128 Ok(Capacity {
129 bytes: self.capacity_bytes,
130 technique: StegoTechnique::LsbImage,
131 })
132 }
133
134 fn embed(
135 &self,
136 mut cover: CoverMedia,
137 payload: &Payload,
138 ) -> Result<CoverMedia, StegoError> {
139 if self.should_fail {
140 return Err(StegoError::PayloadTooLarge {
141 available: 0,
142 needed: payload.as_bytes().len() as u64,
143 });
144 }
145 let mut new_data = cover.data.to_vec();
147 new_data.extend_from_slice(payload.as_bytes());
148 cover.data = Bytes::from(new_data);
149 Ok(cover)
150 }
151 }
152
153 fn make_cover() -> CoverMedia {
154 CoverMedia {
155 kind: CoverMediaKind::PngImage,
156 data: Bytes::from(vec![0u8; 1024]),
157 metadata: HashMap::new(),
158 }
159 }
160
161 #[test]
162 fn embed_canary_returns_canary_shard() -> TestResult {
163 let service = CanaryServiceImpl::new(64, 5);
164 let embedder = MockEmbedder {
165 capacity_bytes: 1024,
166 should_fail: false,
167 };
168 let covers = vec![make_cover()];
169
170 let (result_covers, canary) = service.embed_canary(covers, &embedder)?;
171
172 assert_eq!(result_covers.len(), 1);
173 assert_eq!(canary.shard.data.len(), 64);
174 assert_eq!(canary.shard.index, 5); Ok(())
176 }
177
178 #[test]
179 fn embed_canary_fails_on_empty_covers() {
180 let service = CanaryServiceImpl::new(64, 5);
181 let embedder = MockEmbedder {
182 capacity_bytes: 1024,
183 should_fail: false,
184 };
185
186 let result = service.embed_canary(vec![], &embedder);
187 assert!(matches!(result, Err(CanaryError::NoCovers)));
188 }
189
190 #[test]
191 fn embed_canary_fails_when_no_cover_has_capacity() {
192 let service = CanaryServiceImpl::new(64, 5);
193 let embedder = MockEmbedder {
194 capacity_bytes: 0,
195 should_fail: false,
196 };
197 let covers = vec![make_cover()];
198
199 let result = service.embed_canary(covers, &embedder);
200 assert!(matches!(result, Err(CanaryError::EmbedFailed { .. })));
201 }
202
203 #[test]
204 fn embed_canary_skips_failing_cover_tries_next() -> TestResult {
205 let service = CanaryServiceImpl::new(64, 5);
206 let embedder = MockEmbedder {
208 capacity_bytes: 1024,
209 should_fail: false,
210 };
211 let covers = vec![make_cover(), make_cover()];
212
213 let (result_covers, canary) = service.embed_canary(covers, &embedder)?;
214
215 assert_eq!(result_covers.len(), 2);
216 assert!(!canary.shard.data.is_empty());
217 Ok(())
218 }
219
220 #[test]
221 fn canary_shard_not_in_original_covers() -> TestResult {
222 let service = CanaryServiceImpl::new(64, 5);
223 let embedder = MockEmbedder {
224 capacity_bytes: 1024,
225 should_fail: false,
226 };
227 let original_cover = make_cover();
228 let original_data = original_cover.data.clone();
229 let covers = vec![original_cover];
230
231 let (result_covers, _canary) = service.embed_canary(covers, &embedder)?;
232
233 assert_ne!(
235 result_covers.first().ok_or("index out of bounds")?.data,
236 original_data
237 );
238 Ok(())
239 }
240
241 #[test]
242 fn check_canary_returns_false_without_notify_url() {
243 let service = CanaryServiceImpl::new(64, 5);
244 let canary = generate_canary_shard(Uuid::new_v4(), 64, 5, None);
245
246 assert!(!service.check_canary(&canary));
247 }
248
249 #[test]
250 fn check_canary_returns_false_for_non_https_url() {
251 let service = CanaryServiceImpl::new(64, 5);
252 let canary = generate_canary_shard(
253 Uuid::new_v4(),
254 64,
255 5,
256 Some("http://insecure.example.com/canary".to_owned()),
257 );
258
259 assert!(!service.check_canary(&canary));
260 }
261
262 #[test]
263 fn check_canary_returns_false_for_unreachable_url() {
264 let service = CanaryServiceImpl::new(64, 5);
265 let canary = generate_canary_shard(
266 Uuid::new_v4(),
267 64,
268 5,
269 Some("https://nonexistent.example.invalid/canary".to_owned()),
270 );
271
272 assert!(!service.check_canary(&canary));
274 }
275
276 #[test]
277 fn verify_canary_detects_canary_shard() {
278 let canary_id = Uuid::new_v4();
279 let canary = generate_canary_shard(canary_id, 64, 5, None);
280
281 assert!(CanaryServiceImpl::verify_canary(&canary.shard, canary_id));
282 }
283
284 #[test]
285 fn verify_canary_rejects_normal_shard() {
286 let canary_id = Uuid::new_v4();
287 let normal = crate::domain::types::Shard {
288 index: 0,
289 total: 5,
290 data: vec![42u8; 64],
291 hmac_tag: [0u8; 32],
292 };
293
294 assert!(!CanaryServiceImpl::verify_canary(&normal, canary_id));
295 }
296}