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