Skip to main content

shadowforge_lib/adapters/
distribution.rs

1//! Adapter implementing the [`Distributor`] port for all four distribution
2//! patterns: 1:1, 1:N, N:1, N:M.
3
4use crate::domain::distribution::{assign_many_to_many, assign_one_to_many, validate_cover_count};
5use crate::domain::errors::DistributionError;
6use crate::domain::ports::{Distributor, EmbedTechnique};
7use crate::domain::types::{CoverMedia, DistributionPattern, EmbeddingProfile, Payload};
8
9/// Concrete [`Distributor`] implementation.
10pub struct DistributorImpl {
11    /// HMAC key for shard integrity tags.
12    hmac_key: Vec<u8>,
13}
14
15impl Default for DistributorImpl {
16    fn default() -> Self {
17        Self::new(Self::generate_hmac_key())
18    }
19}
20
21impl DistributorImpl {
22    /// Create a new distributor with the given HMAC key.
23    #[must_use]
24    pub const fn new(hmac_key: Vec<u8>) -> Self {
25        Self { hmac_key }
26    }
27
28    /// Generate a random 32-byte HMAC key.
29    #[must_use]
30    pub fn generate_hmac_key() -> Vec<u8> {
31        use rand::Rng;
32        let mut key = vec![0u8; 32];
33        rand::rng().fill_bytes(&mut key);
34        key
35    }
36
37    /// Return a reference to the HMAC key used for shard integrity.
38    #[must_use]
39    pub fn hmac_key(&self) -> &[u8] {
40        &self.hmac_key
41    }
42}
43
44impl Distributor for DistributorImpl {
45    fn distribute(
46        &self,
47        payload: &Payload,
48        profile: &EmbeddingProfile,
49        covers: Vec<CoverMedia>,
50        embedder: &dyn EmbedTechnique,
51    ) -> Result<Vec<CoverMedia>, DistributionError> {
52        let pattern = pattern_from_profile(profile, covers.len());
53        validate_cover_count(&pattern, covers.len())?;
54
55        match pattern {
56            DistributionPattern::OneToOne => distribute_one_to_one(payload, covers, embedder),
57            DistributionPattern::OneToMany {
58                data_shards,
59                parity_shards,
60            } => distribute_one_to_many(
61                payload,
62                covers,
63                embedder,
64                data_shards,
65                parity_shards,
66                &self.hmac_key,
67            ),
68            DistributionPattern::ManyToOne => {
69                // For ManyToOne called via the trait with a single payload,
70                // just embed directly (multi-payload packing is done upstream).
71                distribute_one_to_one(payload, covers, embedder)
72            }
73            DistributionPattern::ManyToMany { mode } => {
74                distribute_many_to_many(payload, covers, embedder, mode)
75            }
76        }
77    }
78}
79
80/// Map an [`EmbeddingProfile`] to a default [`DistributionPattern`].
81///
82/// The adapter infers the distribution pattern from the profile and cover
83/// count rather than forcing the caller to specify both.
84fn pattern_from_profile(profile: &EmbeddingProfile, cover_count: usize) -> DistributionPattern {
85    // Standard profiles default to OneToOne for single cover, OneToMany otherwise
86    match profile {
87        EmbeddingProfile::Standard => {
88            if cover_count <= 1 {
89                DistributionPattern::OneToOne
90            } else {
91                // Default: split evenly across covers with 1 parity shard
92                #[expect(
93                    clippy::cast_possible_truncation,
94                    reason = "cover_count bounded by caller"
95                )]
96                let data = (cover_count.saturating_sub(1)) as u8;
97                let parity = 1u8;
98                DistributionPattern::OneToMany {
99                    data_shards: data.max(1),
100                    parity_shards: parity,
101                }
102            }
103        }
104        _ => DistributionPattern::OneToOne,
105    }
106}
107
108/// 1:1 — embed the payload into the first cover.
109fn distribute_one_to_one(
110    payload: &Payload,
111    mut covers: Vec<CoverMedia>,
112    embedder: &dyn EmbedTechnique,
113) -> Result<Vec<CoverMedia>, DistributionError> {
114    if covers.is_empty() {
115        return Err(DistributionError::InsufficientCovers { needed: 1, got: 0 });
116    }
117    let cover = covers.remove(0);
118    let stego = embedder
119        .embed(cover, payload)
120        .map_err(|source| DistributionError::EmbedFailed { index: 0, source })?;
121    let mut result = vec![stego];
122    result.extend(covers);
123    Ok(result)
124}
125
126/// 1:N — split payload into shards, embed each in a cover.
127fn distribute_one_to_many(
128    payload: &Payload,
129    covers: Vec<CoverMedia>,
130    embedder: &dyn EmbedTechnique,
131    data_shards: u8,
132    parity_shards: u8,
133    hmac_key: &[u8],
134) -> Result<Vec<CoverMedia>, DistributionError> {
135    use crate::domain::correction::encode_shards;
136
137    let shards = encode_shards(payload.as_bytes(), data_shards, parity_shards, hmac_key)
138        .map_err(|source| DistributionError::CorrectionFailed { source })?;
139
140    let assignments = assign_one_to_many(shards.len(), covers.len());
141    let mut result = covers;
142
143    for (shard_idx, cover_idx) in assignments {
144        let shard = shards
145            .get(shard_idx)
146            .ok_or_else(|| DistributionError::InsufficientCovers {
147                needed: shard_idx.strict_add(1),
148                got: shards.len(),
149            })?;
150        let shard_payload = Payload::from_bytes(shard.data.clone());
151        let cover = result.remove(cover_idx);
152        let stego = embedder.embed(cover, &shard_payload).map_err(|source| {
153            DistributionError::EmbedFailed {
154                index: cover_idx,
155                source,
156            }
157        })?;
158        result.insert(cover_idx, stego);
159    }
160
161    Ok(result)
162}
163
164/// M:N — assign shards to covers by mode.
165fn distribute_many_to_many(
166    payload: &Payload,
167    covers: Vec<CoverMedia>,
168    embedder: &dyn EmbedTechnique,
169    mode: crate::domain::types::ManyToManyMode,
170) -> Result<Vec<CoverMedia>, DistributionError> {
171    // For many-to-many, treat the payload as shards spread across covers
172    let cover_count = covers.len();
173    // Simple: split payload into cover_count equal chunks
174    let chunk_size = (payload.len().strict_add(cover_count).strict_sub(1)) / cover_count;
175    let chunks: Vec<Payload> = payload
176        .as_bytes()
177        .chunks(chunk_size)
178        .map(|c| Payload::from_bytes(c.to_vec()))
179        .collect();
180
181    let assignments = assign_many_to_many(mode, chunks.len(), cover_count, 42);
182    let mut result = covers;
183
184    for (shard_idx, cover_indices) in assignments.iter().enumerate() {
185        let Some(chunk) = chunks.get(shard_idx) else {
186            break;
187        };
188        for &cover_idx in cover_indices {
189            let cover = result.remove(cover_idx);
190            let stego =
191                embedder
192                    .embed(cover, chunk)
193                    .map_err(|source| DistributionError::EmbedFailed {
194                        index: cover_idx,
195                        source,
196                    })?;
197            result.insert(cover_idx, stego);
198        }
199    }
200
201    Ok(result)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::domain::distribution::pack_many_payloads;
208    use crate::domain::errors::StegoError;
209    use crate::domain::types::{Capacity, CoverMedia, CoverMediaKind, StegoTechnique};
210    use bytes::Bytes;
211
212    type TestResult = Result<(), Box<dyn std::error::Error>>;
213
214    /// Mock embedder that appends payload bytes to cover data.
215    struct MockEmbedder;
216
217    impl EmbedTechnique for MockEmbedder {
218        fn technique(&self) -> StegoTechnique {
219            StegoTechnique::LsbImage
220        }
221
222        fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError> {
223            Ok(Capacity {
224                bytes: cover.data.len() as u64,
225                technique: StegoTechnique::LsbImage,
226            })
227        }
228
229        fn embed(&self, cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError> {
230            let mut data = cover.data.to_vec();
231            data.extend_from_slice(payload.as_bytes());
232            Ok(CoverMedia {
233                kind: cover.kind,
234                data: Bytes::from(data),
235                metadata: cover.metadata,
236            })
237        }
238    }
239
240    fn make_cover(size: usize) -> CoverMedia {
241        CoverMedia {
242            kind: CoverMediaKind::PngImage,
243            data: Bytes::from(vec![0u8; size]),
244            metadata: std::collections::HashMap::new(),
245        }
246    }
247
248    #[test]
249    fn one_to_one_round_trip() -> TestResult {
250        let distributor = DistributorImpl::new(b"test-hmac-key".to_vec());
251        let payload = Payload::from_bytes(b"secret message".to_vec());
252        let covers = vec![make_cover(128)];
253        let result =
254            distributor.distribute(&payload, &EmbeddingProfile::Standard, covers, &MockEmbedder)?;
255        assert_eq!(result.len(), 1);
256        // Cover data should be larger (128 + 14 bytes of payload)
257        assert_eq!(
258            result.first().ok_or("index out of bounds")?.data.len(),
259            128 + 14
260        );
261        Ok(())
262    }
263
264    #[test]
265    fn one_to_many_produces_correct_shard_count() -> TestResult {
266        let covers: Vec<CoverMedia> = (0..8).map(|_| make_cover(256)).collect();
267        let payload = Payload::from_bytes(vec![0xAB; 64]);
268
269        // Manually use 1:N with 5 data + 3 parity = 8 total
270        let pattern = DistributionPattern::OneToMany {
271            data_shards: 5,
272            parity_shards: 3,
273        };
274        validate_cover_count(&pattern, covers.len())?;
275
276        let result =
277            distribute_one_to_many(&payload, covers, &MockEmbedder, 5, 3, b"test-hmac-key")?;
278        assert_eq!(result.len(), 8);
279        // Each cover should have been modified (data extended)
280        for cover in &result {
281            assert!(cover.data.len() > 256);
282        }
283        Ok(())
284    }
285
286    #[test]
287    fn many_to_one_embed_single_cover() -> TestResult {
288        let distributor = DistributorImpl::new(b"test-hmac-key".to_vec());
289        let payload = Payload::from_bytes(b"combined payload".to_vec());
290        let covers = vec![make_cover(512)];
291        let result =
292            distributor.distribute(&payload, &EmbeddingProfile::Standard, covers, &MockEmbedder)?;
293        assert_eq!(result.len(), 1);
294        assert!(result.first().ok_or("empty result")?.data.len() > 512);
295        Ok(())
296    }
297
298    #[test]
299    fn many_to_many_replicate_mode() -> TestResult {
300        let covers = vec![make_cover(256), make_cover(256), make_cover(256)];
301        let payload = Payload::from_bytes(vec![0xCC; 30]);
302
303        let result = distribute_many_to_many(
304            &payload,
305            covers,
306            &MockEmbedder,
307            crate::domain::types::ManyToManyMode::Replicate,
308        )?;
309        assert_eq!(result.len(), 3);
310        // In replicate mode, each cover gets every chunk — all should be modified
311        for cover in &result {
312            assert!(cover.data.len() > 256);
313        }
314        Ok(())
315    }
316
317    #[test]
318    fn insufficient_covers_returns_error() {
319        let distributor = DistributorImpl::new(b"test-hmac-key".to_vec());
320        let payload = Payload::from_bytes(b"test".to_vec());
321        let covers: Vec<CoverMedia> = vec![];
322        let result =
323            distributor.distribute(&payload, &EmbeddingProfile::Standard, covers, &MockEmbedder);
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn pattern_from_profile_non_standard_returns_one_to_one() {
329        let adaptive = EmbeddingProfile::Adaptive {
330            max_detectability_db: 0.5,
331        };
332        let pattern = pattern_from_profile(&adaptive, 5);
333        assert_eq!(pattern, DistributionPattern::OneToOne);
334
335        let corpus = EmbeddingProfile::CorpusBased;
336        let pattern = pattern_from_profile(&corpus, 10);
337        assert_eq!(pattern, DistributionPattern::OneToOne);
338    }
339
340    #[test]
341    fn distribute_via_trait_many_to_many_replicate() -> TestResult {
342        let covers = vec![make_cover(256), make_cover(256)];
343        let payload = Payload::from_bytes(vec![0xAA; 20]);
344        let result = distribute_many_to_many(
345            &payload,
346            covers,
347            &MockEmbedder,
348            crate::domain::types::ManyToManyMode::Stripe,
349        )?;
350        // Each chunk goes to one cover in stripe mode
351        assert_eq!(result.len(), 2);
352        for cover in &result {
353            assert!(cover.data.len() > 256);
354        }
355        Ok(())
356    }
357
358    /// Embedder that always fails — for testing error propagation.
359    struct FailEmbedder;
360
361    impl EmbedTechnique for FailEmbedder {
362        fn technique(&self) -> StegoTechnique {
363            StegoTechnique::LsbImage
364        }
365
366        fn capacity(&self, _cover: &CoverMedia) -> Result<Capacity, StegoError> {
367            Ok(Capacity {
368                bytes: 0,
369                technique: StegoTechnique::LsbImage,
370            })
371        }
372
373        fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
374            Err(StegoError::PayloadTooLarge {
375                available: 0,
376                needed: 1,
377            })
378        }
379    }
380
381    #[test]
382    fn distribute_one_to_one_embed_failure() {
383        let covers = vec![make_cover(64)];
384        let payload = Payload::from_bytes(b"data".to_vec());
385        let result = distribute_one_to_one(&payload, covers, &FailEmbedder);
386        assert!(result.is_err());
387    }
388
389    #[test]
390    fn distribute_one_to_many_embed_failure() {
391        let covers: Vec<CoverMedia> = (0..4).map(|_| make_cover(256)).collect();
392        let payload = Payload::from_bytes(vec![0xBB; 32]);
393        let result =
394            distribute_one_to_many(&payload, covers, &FailEmbedder, 3, 1, b"test-hmac-key");
395        assert!(result.is_err());
396    }
397
398    #[test]
399    fn distribute_many_to_many_embed_failure() {
400        let covers = vec![make_cover(128), make_cover(128)];
401        let payload = Payload::from_bytes(vec![0xCC; 20]);
402        let result = distribute_many_to_many(
403            &payload,
404            covers,
405            &FailEmbedder,
406            crate::domain::types::ManyToManyMode::Replicate,
407        );
408        assert!(result.is_err());
409    }
410
411    #[test]
412    fn distribute_default_impl() -> TestResult {
413        let distributor = DistributorImpl::default();
414        let payload = Payload::from_bytes(b"hello".to_vec());
415        let covers = vec![make_cover(128)];
416        let result =
417            distributor.distribute(&payload, &EmbeddingProfile::Standard, covers, &MockEmbedder)?;
418        assert_eq!(result.len(), 1);
419        Ok(())
420    }
421
422    #[test]
423    fn pack_unpack_multiple_payloads_for_many_to_one() -> TestResult {
424        let payloads = vec![
425            Payload::from_bytes(b"payload_a".to_vec()),
426            Payload::from_bytes(b"payload_b".to_vec()),
427            Payload::from_bytes(b"payload_c".to_vec()),
428        ];
429        let packed = pack_many_payloads(&payloads);
430        let combined = Payload::from_bytes(packed);
431
432        // Embed combined into single cover
433        let covers = vec![make_cover(1024)];
434        let result = distribute_one_to_one(&combined, covers, &MockEmbedder)?;
435        assert_eq!(result.len(), 1);
436
437        // The stego'd data contains original cover + packed payloads
438        let stego_data = &result.first().ok_or("empty result")?.data;
439        let embedded_portion = stego_data.get(1024..).ok_or("slice out of bounds")?;
440        let unpacked = crate::domain::distribution::unpack_many_payloads(embedded_portion)?;
441        assert_eq!(unpacked.len(), 3);
442        assert_eq!(
443            unpacked.first().ok_or("index out of bounds")?.as_bytes(),
444            b"payload_a"
445        );
446        assert_eq!(
447            unpacked.get(1).ok_or("index out of bounds")?.as_bytes(),
448            b"payload_b"
449        );
450        assert_eq!(
451            unpacked.get(2).ok_or("index out of bounds")?.as_bytes(),
452            b"payload_c"
453        );
454        Ok(())
455    }
456}