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