Skip to main content

shadowforge_lib/adapters/
canary.rs

1//! Canary shard tripwire adapter implementing the [`CanaryService`] port.
2
3use 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
10/// Adapter implementing canary shard generation and check logic.
11///
12/// Generates an (N+1)th canary shard that cannot participate in real
13/// Reed-Solomon reconstruction. The canary is embedded into the first
14/// cover that has sufficient capacity.
15pub struct CanaryServiceImpl {
16    /// Shard size in bytes (should match the real RS shard size).
17    shard_size: usize,
18    /// Total number of real shards in the distribution (data + parity).
19    total_shards: u8,
20}
21
22impl CanaryServiceImpl {
23    /// Create a new canary service with the given shard parameters.
24    #[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    /// Check whether a shard matches the canary marker for a given ID.
33    #[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        // Serialize canary shard data as the payload to embed
53        let canary_payload = Payload::from_bytes(canary.shard.data.clone());
54
55        // Find first cover with enough capacity and embed
56        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                // embed() consumed cover on failure — it's gone, move on
70                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        // If no notify URL is set, manual check is required
89        let Some(url) = &shard.notify_url else {
90            return false;
91        };
92
93        // Validate the URL to prevent SSRF — only allow https scheme
94        if !url.starts_with("https://") {
95            return false;
96        }
97
98        // TODO(T34): Wire up HTTP HEAD request via a network port trait.
99        // For now, return false (manual check mode).
100        // The CLI should provide a way to manually mark canaries as accessed.
101        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    /// A mock embedder for testing canary embedding.
117    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            // Simulate embedding by appending payload to cover data
146            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); // total_shards = 5, so canary index = 5
175        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        // First cover will fail embedding, second should succeed
207        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        // Modified cover should differ from original
234        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        // Should return false (network port not wired yet)
273        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}