Skip to main content

shadowforge_lib/application/
services.rs

1//! Application-layer use-case orchestrators.
2//!
3//! Each service is a thin wrapper that coordinates domain ports.
4//! No file I/O or async runtime — callers provide loaded data.
5
6use std::io::{Read, Write};
7
8use bytes::Bytes;
9use chrono::{DateTime, Utc};
10use thiserror::Error;
11
12use crate::domain::errors::{
13    AdaptiveError, AnalysisError, ArchiveError, CanaryError, CorpusError, CorrectionError,
14    CryptoError, DeadDropError, DeniableError, DistributionError, OpsecError, ReconstructionError,
15    ScrubberError, StegoError, TimeLockError,
16};
17use crate::domain::ports::{
18    AdaptiveOptimiser, AmnesiaPipeline, ArchiveHandler, CanaryService as CanaryServicePort,
19    CapacityAnalyser, CompressionSimulator, CorpusIndex, CoverProfileMatcher, DeadDropEncoder,
20    DeniableEmbedder, Distributor, EmbedTechnique, Encryptor, ExtractTechnique,
21    ForensicWatermarker, GeographicDistributor, PanicWiper, Reconstructor, Signer, StyloScrubber,
22    SymmetricCipher, TimeLockService as TimeLockServicePort,
23};
24use crate::domain::types::{
25    AnalysisReport, ArchiveFormat, CanaryShard, CorpusEntry, CoverMedia, DeniableKeySet,
26    DeniablePayloadPair, EmbeddingProfile, GeographicManifest, KeyPair, PanicWipeConfig, Payload,
27    PlatformProfile, Signature, SpectralKey, StegoTechnique, StyloProfile, TimeLockPuzzle,
28    WatermarkReceipt, WatermarkTripwireTag,
29};
30
31// ─── AppError ─────────────────────────────────────────────────────────────────
32
33/// Unified application error wrapping all domain errors.
34#[derive(Debug, Error)]
35pub enum AppError {
36    /// Crypto subsystem error.
37    #[error("crypto: {0}")]
38    Crypto(#[from] CryptoError),
39    /// Steganography error.
40    #[error("stego: {0}")]
41    Stego(#[from] StegoError),
42    /// Distribution error.
43    #[error("distribution: {0}")]
44    Distribution(#[from] DistributionError),
45    /// Reconstruction error.
46    #[error("reconstruction: {0}")]
47    Reconstruction(#[from] ReconstructionError),
48    /// Error-correction error.
49    #[error("correction: {0}")]
50    Correction(#[from] CorrectionError),
51    /// Analysis error.
52    #[error("analysis: {0}")]
53    Analysis(#[from] AnalysisError),
54    /// Archive error.
55    #[error("archive: {0}")]
56    Archive(#[from] ArchiveError),
57    /// Operational security error.
58    #[error("opsec: {0}")]
59    Opsec(#[from] OpsecError),
60    /// Scrubber error.
61    #[error("scrubber: {0}")]
62    Scrubber(#[from] ScrubberError),
63    /// Adaptive embedding error.
64    #[error("adaptive: {0}")]
65    Adaptive(#[from] AdaptiveError),
66    /// Deniable steganography error.
67    #[error("deniable: {0}")]
68    Deniable(#[from] DeniableError),
69    /// Canary shard error.
70    #[error("canary: {0}")]
71    Canary(#[from] CanaryError),
72    /// Dead drop error.
73    #[error("dead-drop: {0}")]
74    DeadDrop(#[from] DeadDropError),
75    /// Time-lock puzzle error.
76    #[error("time-lock: {0}")]
77    TimeLock(#[from] TimeLockError),
78    /// Corpus selection error.
79    #[error("corpus: {0}")]
80    Corpus(#[from] CorpusError),
81}
82
83// ─── EmbedService ─────────────────────────────────────────────────────────────
84
85/// Embeds a payload into a cover medium.
86pub struct EmbedService;
87
88impl EmbedService {
89    /// Embed `payload` into `cover` using the provided embedder.
90    ///
91    /// # Errors
92    /// Returns [`AppError::Stego`] on embedding failure.
93    pub fn embed(
94        cover: CoverMedia,
95        payload: &Payload,
96        embedder: &dyn EmbedTechnique,
97    ) -> Result<CoverMedia, AppError> {
98        Ok(embedder.embed(cover, payload)?)
99    }
100}
101
102// ─── ExtractService ───────────────────────────────────────────────────────────
103
104/// Extracts a hidden payload from a stego cover.
105pub struct ExtractService;
106
107impl ExtractService {
108    /// Extract payload from `stego`.
109    ///
110    /// # Errors
111    /// Returns [`AppError::Stego`] on extraction failure.
112    pub fn extract(
113        stego: &CoverMedia,
114        extractor: &dyn ExtractTechnique,
115    ) -> Result<Payload, AppError> {
116        Ok(extractor.extract(stego)?)
117    }
118}
119
120// ─── KeyGenService ────────────────────────────────────────────────────────────
121
122/// Key-pair generation orchestrator.
123pub struct KeyGenService;
124
125impl KeyGenService {
126    /// Generate a fresh KEM key pair.
127    ///
128    /// # Errors
129    /// Returns [`AppError::Crypto`] on key-generation failure.
130    pub fn generate_keypair(encryptor: &dyn Encryptor) -> Result<KeyPair, AppError> {
131        Ok(encryptor.generate_keypair()?)
132    }
133
134    /// Generate a signing key pair.
135    ///
136    /// # Errors
137    /// Returns [`AppError::Crypto`] on key-generation failure.
138    pub fn generate_signing_keypair(signer: &dyn Signer) -> Result<KeyPair, AppError> {
139        Ok(signer.generate_keypair()?)
140    }
141
142    /// Sign a message.
143    ///
144    /// # Errors
145    /// Returns [`AppError::Crypto`] on signing failure.
146    pub fn sign(
147        signer: &dyn Signer,
148        secret_key: &[u8],
149        message: &[u8],
150    ) -> Result<Signature, AppError> {
151        Ok(signer.sign(secret_key, message)?)
152    }
153
154    /// Verify a signature.
155    ///
156    /// # Errors
157    /// Returns [`AppError::Crypto`] on verification failure.
158    pub fn verify(
159        signer: &dyn Signer,
160        public_key: &[u8],
161        message: &[u8],
162        signature: &Signature,
163    ) -> Result<bool, AppError> {
164        Ok(signer.verify(public_key, message, signature)?)
165    }
166}
167
168// ─── CipherService ───────────────────────────────────────────────────────────
169
170/// AES-256-GCM encrypt / decrypt orchestrator.
171pub struct CipherService;
172
173impl CipherService {
174    /// Encrypt `plaintext` with `key` and `nonce`.
175    ///
176    /// # Errors
177    /// Returns [`AppError::Crypto`] on encryption failure.
178    pub fn encrypt(
179        cipher: &dyn SymmetricCipher,
180        key: &[u8],
181        nonce: &[u8],
182        plaintext: &[u8],
183    ) -> Result<Bytes, AppError> {
184        Ok(cipher.encrypt(key, nonce, plaintext)?)
185    }
186
187    /// Decrypt and authenticate `ciphertext` with `key` and `nonce`.
188    ///
189    /// # Errors
190    /// Returns [`AppError::Crypto`] on decryption or authentication failure.
191    pub fn decrypt(
192        cipher: &dyn SymmetricCipher,
193        key: &[u8],
194        nonce: &[u8],
195        ciphertext: &[u8],
196    ) -> Result<Bytes, AppError> {
197        Ok(cipher.decrypt(key, nonce, ciphertext)?)
198    }
199}
200
201// ─── DistributeService ────────────────────────────────────────────────────────
202
203/// Dependencies for profile-specific hardening during distribution.
204pub struct AdaptiveProfileDeps<'a> {
205    /// Cover profile matcher dependency.
206    pub matcher: &'a dyn CoverProfileMatcher,
207    /// Adaptive optimiser dependency.
208    pub optimiser: &'a dyn AdaptiveOptimiser,
209    /// Compression simulator dependency.
210    pub compressor: &'a dyn CompressionSimulator,
211}
212
213/// Distribute a payload across multiple covers.
214pub struct DistributeService;
215
216impl DistributeService {
217    /// Distribute `payload` across `covers`.
218    ///
219    /// # Errors
220    /// Returns [`AppError::Distribution`] on failure.
221    pub fn distribute(
222        payload: &Payload,
223        covers: Vec<CoverMedia>,
224        profile: &EmbeddingProfile,
225        distributor: &dyn Distributor,
226        embedder: &dyn EmbedTechnique,
227    ) -> Result<Vec<CoverMedia>, AppError> {
228        Ok(distributor.distribute(payload, profile, covers, embedder)?)
229    }
230
231    /// Distribute `payload` across `covers` with a geographic manifest.
232    ///
233    /// # Errors
234    /// Returns [`AppError::Opsec`] on manifest/distribution failures.
235    pub fn distribute_with_geographic_manifest(
236        payload: &Payload,
237        covers: Vec<CoverMedia>,
238        manifest: &GeographicManifest,
239        embedder: &dyn EmbedTechnique,
240        distributor: &dyn GeographicDistributor,
241    ) -> Result<Vec<CoverMedia>, AppError> {
242        Ok(distributor.distribute_with_manifest(payload, covers, manifest, embedder)?)
243    }
244
245    /// Distribute and apply profile-specific adaptive hardening.
246    ///
247    /// For `Adaptive`, each output cover is optimised against its source cover.
248    /// For `CompressionSurvivable`, each output cover is passed through platform
249    /// recompression simulation.
250    ///
251    /// # Errors
252    /// Returns [`AppError::Distribution`] for distribution failures and
253    /// [`AppError::Adaptive`] for adaptive/profile hardening failures.
254    pub fn distribute_with_profile_hardening(
255        payload: &Payload,
256        covers: Vec<CoverMedia>,
257        profile: &EmbeddingProfile,
258        distributor: &dyn Distributor,
259        embedder: &dyn EmbedTechnique,
260        deps: &AdaptiveProfileDeps<'_>,
261    ) -> Result<Vec<CoverMedia>, AppError> {
262        // Profile matching (FFT on each cover) is only needed for
263        // Adaptive and CompressionSurvivable profiles; skip it for the
264        // cheaper Standard / CorpusBased paths.
265        let prepared_covers: Vec<CoverMedia> = match profile {
266            EmbeddingProfile::Standard | EmbeddingProfile::CorpusBased => covers,
267            _ => covers
268                .into_iter()
269                .map(|cover| {
270                    if let Some(matched) = deps.matcher.profile_for(&cover) {
271                        deps.matcher.apply_profile(cover, &matched)
272                    } else {
273                        Ok(cover)
274                    }
275                })
276                .collect::<Result<_, _>>()?,
277        };
278
279        let original_cover_count = prepared_covers.len();
280        // Clone originals only for the Adaptive branch, which needs to pair each
281        // output cover against its source for detectability scoring.
282        let original_covers_opt: Option<Vec<CoverMedia>> =
283            matches!(profile, EmbeddingProfile::Adaptive { .. }).then(|| prepared_covers.clone());
284        let distributed = distributor.distribute(payload, profile, prepared_covers, embedder)?;
285
286        if distributed.len() != original_cover_count {
287            return Err(AppError::Adaptive(
288                AdaptiveError::DistributionCountMismatch {
289                    got: distributed.len(),
290                    expected: original_cover_count,
291                },
292            ));
293        }
294
295        match profile {
296            EmbeddingProfile::Standard | EmbeddingProfile::CorpusBased => Ok(distributed),
297            EmbeddingProfile::Adaptive {
298                max_detectability_db,
299            } => {
300                let original_covers = original_covers_opt.unwrap_or_default();
301                distributed
302                    .into_iter()
303                    .zip(original_covers)
304                    .map(|(stego, original)| {
305                        deps.optimiser
306                            .optimise(stego, &original, *max_detectability_db)
307                    })
308                    .collect::<Result<Vec<_>, _>>()
309                    .map_err(AppError::from)
310            }
311            EmbeddingProfile::CompressionSurvivable { platform } => distributed
312                .into_iter()
313                .map(|stego| deps.compressor.simulate(stego, platform))
314                .collect::<Result<Vec<_>, _>>()
315                .map_err(AppError::from),
316        }
317    }
318}
319
320// ─── ReconstructService ───────────────────────────────────────────────────────
321
322/// Reconstruct a payload from distributed stego covers.
323pub struct ReconstructService;
324
325impl ReconstructService {
326    /// Reconstruct payload from stego covers.
327    ///
328    /// # Errors
329    /// Returns [`AppError::Reconstruction`] on failure.
330    pub fn reconstruct(
331        stego_covers: Vec<CoverMedia>,
332        extractor: &dyn ExtractTechnique,
333        reconstructor: &dyn Reconstructor,
334        progress_cb: &dyn Fn(usize, usize),
335    ) -> Result<Payload, AppError> {
336        Ok(reconstructor.reconstruct(stego_covers, extractor, progress_cb)?)
337    }
338}
339
340// ─── AnalyseService ───────────────────────────────────────────────────────────
341
342/// Analyse a cover for stego capacity and detectability.
343pub struct AnalyseService;
344
345impl AnalyseService {
346    /// Analyse `cover` for the given `technique`.
347    ///
348    /// # Errors
349    /// Returns [`AppError::Analysis`] on failure.
350    pub fn analyse(
351        cover: &CoverMedia,
352        technique: StegoTechnique,
353        analyser: &dyn CapacityAnalyser,
354    ) -> Result<AnalysisReport, AppError> {
355        Ok(analyser.analyse(cover, technique)?)
356    }
357}
358
359// ─── ScrubService ─────────────────────────────────────────────────────────────
360
361/// Scrub text to remove stylometric fingerprints.
362pub struct ScrubService;
363
364impl ScrubService {
365    /// Scrub text via the provided scrubber port.
366    ///
367    /// # Errors
368    /// Returns [`AppError::Scrubber`] on failure.
369    pub fn scrub(
370        text: &str,
371        profile: &StyloProfile,
372        scrubber: &dyn StyloScrubber,
373    ) -> Result<String, AppError> {
374        Ok(scrubber.scrub(text, profile)?)
375    }
376}
377
378// ─── ArchiveService ───────────────────────────────────────────────────────────
379
380/// Pack and unpack archive bundles.
381pub struct ArchiveService;
382
383impl ArchiveService {
384    /// Pack files into an archive.
385    ///
386    /// # Errors
387    /// Returns [`AppError::Archive`] on failure.
388    pub fn pack(
389        files: &[(&str, &[u8])],
390        format: ArchiveFormat,
391        handler: &dyn ArchiveHandler,
392    ) -> Result<Bytes, AppError> {
393        Ok(handler.pack(files, format)?)
394    }
395
396    /// Unpack an archive into named files.
397    ///
398    /// # Errors
399    /// Returns [`AppError::Archive`] on failure.
400    pub fn unpack(
401        archive: &[u8],
402        format: ArchiveFormat,
403        handler: &dyn ArchiveHandler,
404    ) -> Result<Vec<(String, Bytes)>, AppError> {
405        Ok(handler.unpack(archive, format)?)
406    }
407}
408
409// ─── DeniableEmbedService ─────────────────────────────────────────────────────
410
411/// Dual-payload deniable steganography orchestrator.
412pub struct DeniableEmbedService;
413
414impl DeniableEmbedService {
415    /// Embed both a real and a decoy payload.
416    ///
417    /// # Errors
418    /// Returns [`AppError::Deniable`] on failure.
419    pub fn embed_dual(
420        cover: CoverMedia,
421        pair: &DeniablePayloadPair,
422        keys: &DeniableKeySet,
423        embedder: &dyn EmbedTechnique,
424        deniable: &dyn DeniableEmbedder,
425    ) -> Result<CoverMedia, AppError> {
426        Ok(deniable.embed_dual(cover, pair, keys, embedder)?)
427    }
428
429    /// Extract a payload using the given key.
430    ///
431    /// # Errors
432    /// Returns [`AppError::Deniable`] on failure.
433    pub fn extract_with_key(
434        stego: &CoverMedia,
435        key: &[u8],
436        extractor: &dyn ExtractTechnique,
437        deniable: &dyn DeniableEmbedder,
438    ) -> Result<Payload, AppError> {
439        Ok(deniable.extract_with_key(stego, key, extractor)?)
440    }
441}
442
443// ─── DeadDropService ──────────────────────────────────────────────────────────
444
445/// Platform-aware dead drop orchestrator.
446pub struct DeadDropService;
447
448impl DeadDropService {
449    /// Encode a payload for posting on a public platform.
450    ///
451    /// # Errors
452    /// Returns [`AppError::DeadDrop`] on failure.
453    pub fn encode(
454        cover: CoverMedia,
455        payload: &Payload,
456        platform: &PlatformProfile,
457        embedder: &dyn EmbedTechnique,
458        encoder: &dyn DeadDropEncoder,
459    ) -> Result<CoverMedia, AppError> {
460        Ok(encoder.encode_for_platform(cover, payload, platform, embedder)?)
461    }
462}
463
464// ─── TimeLockServiceApp ───────────────────────────────────────────────────────
465
466/// Time-lock puzzle orchestrator.
467pub struct TimeLockServiceApp;
468
469impl TimeLockServiceApp {
470    /// Wrap a payload in a time-lock puzzle.
471    ///
472    /// # Errors
473    /// Returns [`AppError::TimeLock`] on failure.
474    pub fn lock(
475        payload: &Payload,
476        unlock_at: DateTime<Utc>,
477        service: &dyn TimeLockServicePort,
478    ) -> Result<TimeLockPuzzle, AppError> {
479        Ok(service.lock(payload, unlock_at)?)
480    }
481
482    /// Solve a time-lock puzzle (blocking).
483    ///
484    /// # Errors
485    /// Returns [`AppError::TimeLock`] on failure.
486    pub fn unlock(
487        puzzle: &TimeLockPuzzle,
488        service: &dyn TimeLockServicePort,
489    ) -> Result<Payload, AppError> {
490        Ok(service.unlock(puzzle)?)
491    }
492
493    /// Non-blocking puzzle check.
494    ///
495    /// # Errors
496    /// Returns [`AppError::TimeLock`] on failure.
497    pub fn try_unlock(
498        puzzle: &TimeLockPuzzle,
499        service: &dyn TimeLockServicePort,
500    ) -> Result<Option<Payload>, AppError> {
501        Ok(service.try_unlock(puzzle)?)
502    }
503}
504
505// ─── CanaryShardService ───────────────────────────────────────────────────────
506
507/// Canary shard tripwire orchestrator.
508pub struct CanaryShardService;
509
510impl CanaryShardService {
511    /// Embed a canary shard alongside distributed covers.
512    ///
513    /// # Errors
514    /// Returns [`AppError::Canary`] on failure.
515    pub fn embed_canary(
516        covers: Vec<CoverMedia>,
517        embedder: &dyn EmbedTechnique,
518        canary: &dyn CanaryServicePort,
519    ) -> Result<(Vec<CoverMedia>, CanaryShard), AppError> {
520        Ok(canary.embed_canary(covers, embedder)?)
521    }
522
523    /// Check whether a canary has been accessed.
524    pub fn check_canary(shard: &CanaryShard, canary: &dyn CanaryServicePort) -> bool {
525        canary.check_canary(shard)
526    }
527}
528
529// ─── ForensicService ──────────────────────────────────────────────────────────
530
531/// Forensic watermark tripwire orchestrator.
532pub struct ForensicService;
533
534impl ForensicService {
535    /// Embed a per-recipient watermark into a cover.
536    ///
537    /// # Errors
538    /// Returns [`AppError::Opsec`] on failure.
539    pub fn embed_tripwire(
540        cover: CoverMedia,
541        tag: &WatermarkTripwireTag,
542        watermarker: &dyn ForensicWatermarker,
543    ) -> Result<CoverMedia, AppError> {
544        Ok(watermarker.embed_tripwire(cover, tag)?)
545    }
546
547    /// Identify which recipient leaked a stego cover.
548    ///
549    /// # Errors
550    /// Returns [`AppError::Opsec`] on failure.
551    pub fn identify_recipient(
552        stego: &CoverMedia,
553        tags: &[WatermarkTripwireTag],
554        watermarker: &dyn ForensicWatermarker,
555    ) -> Result<Option<WatermarkReceipt>, AppError> {
556        Ok(watermarker.identify_recipient(stego, tags)?)
557    }
558}
559
560// ─── AmnesiaPipelineService ───────────────────────────────────────────────────
561
562/// Amnesiac in-memory embed/extract orchestrator.
563pub struct AmnesiaPipelineService;
564
565impl AmnesiaPipelineService {
566    /// Embed a payload entirely in memory — no filesystem writes.
567    ///
568    /// # Errors
569    /// Returns [`AppError::Opsec`] on pipeline failure.
570    pub fn embed_in_memory(
571        payload_input: &mut dyn Read,
572        cover_input: &mut dyn Read,
573        output: &mut dyn Write,
574        technique: &dyn EmbedTechnique,
575        pipeline: &dyn AmnesiaPipeline,
576    ) -> Result<(), AppError> {
577        Ok(pipeline.embed_in_memory(payload_input, cover_input, output, technique)?)
578    }
579}
580
581// ─── PanicWipeService ─────────────────────────────────────────────────────────
582
583/// Emergency panic wipe orchestrator.
584pub struct PanicWipeService;
585
586impl PanicWipeService {
587    /// Securely wipe all paths in `config`.
588    ///
589    /// # Errors
590    /// Returns [`AppError::Opsec`] on failure.
591    pub fn wipe(config: &PanicWipeConfig, wiper: &dyn PanicWiper) -> Result<(), AppError> {
592        Ok(wiper.wipe(config)?)
593    }
594}
595
596// ─── CorpusService ───────────────────────────────────────────────────────────
597
598/// Corpus index and cover-selection orchestrator.
599pub struct CorpusService;
600
601impl CorpusService {
602    /// Build the corpus index from a directory tree.
603    ///
604    /// # Errors
605    /// Returns [`AppError::Corpus`] on failure.
606    pub fn build_index(
607        index: &dyn CorpusIndex,
608        corpus_dir: &std::path::Path,
609    ) -> Result<usize, AppError> {
610        Ok(index.build_index(corpus_dir)?)
611    }
612
613    /// Search the index for covers that best encode `payload` using `technique`.
614    ///
615    /// # Errors
616    /// Returns [`AppError::Corpus`] on failure.
617    pub fn search(
618        index: &dyn CorpusIndex,
619        payload: &Payload,
620        technique: StegoTechnique,
621        max_results: usize,
622    ) -> Result<Vec<CorpusEntry>, AppError> {
623        Ok(index.search(payload, technique, max_results)?)
624    }
625
626    /// Search the index restricted to a specific camera model and resolution.
627    ///
628    /// # Errors
629    /// Returns [`AppError::Corpus`] on failure.
630    pub fn search_for_model(
631        index: &dyn CorpusIndex,
632        payload: &Payload,
633        model_id: &str,
634        resolution: (u32, u32),
635        max_results: usize,
636    ) -> Result<Vec<CorpusEntry>, AppError> {
637        Ok(index.search_for_model(payload, model_id, resolution, max_results)?)
638    }
639
640    /// Return per-model/resolution entry counts from the index.
641    pub fn model_stats(index: &dyn CorpusIndex) -> Vec<(SpectralKey, usize)> {
642        index.model_stats()
643    }
644}
645
646// ─── Tests ────────────────────────────────────────────────────────────────────
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use crate::domain::types::{Capacity, CoverMediaKind, GeoShardEntry, Shard};
652    use std::collections::HashMap;
653    use std::sync::Arc;
654    use std::sync::atomic::{AtomicUsize, Ordering};
655    use uuid::Uuid;
656
657    type TestResult = Result<(), Box<dyn std::error::Error>>;
658
659    // ─── Mock Embedder / Extractor ────────────────────────────────────────
660
661    struct MockEmbedder;
662
663    impl EmbedTechnique for MockEmbedder {
664        fn technique(&self) -> StegoTechnique {
665            StegoTechnique::LsbImage
666        }
667
668        fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError> {
669            Ok(Capacity {
670                bytes: cover.data.len() as u64,
671                technique: StegoTechnique::LsbImage,
672            })
673        }
674
675        fn embed(&self, cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError> {
676            let mut data = cover.data.to_vec();
677            #[expect(clippy::cast_possible_truncation, reason = "test data < 4 GiB")]
678            let len = payload.len() as u32;
679            data.extend_from_slice(&len.to_le_bytes());
680            data.extend_from_slice(payload.as_bytes());
681            Ok(CoverMedia {
682                kind: cover.kind,
683                data: Bytes::from(data),
684                metadata: cover.metadata,
685            })
686        }
687    }
688
689    struct MockExtractor {
690        cover_prefix_len: usize,
691    }
692
693    impl ExtractTechnique for MockExtractor {
694        fn technique(&self) -> StegoTechnique {
695            StegoTechnique::LsbImage
696        }
697
698        fn extract(&self, stego: &CoverMedia) -> Result<Payload, StegoError> {
699            let data = &stego.data;
700            if data.len() <= self.cover_prefix_len + 4 {
701                return Err(StegoError::NoPayloadFound);
702            }
703            let offset = self.cover_prefix_len;
704            let len_bytes: [u8; 4] = data
705                .get(offset..offset + 4)
706                .ok_or(StegoError::NoPayloadFound)?
707                .try_into()
708                .map_err(|_| StegoError::NoPayloadFound)?;
709            let len = u32::from_le_bytes(len_bytes) as usize;
710            let start = offset + 4;
711            let payload_data = data
712                .get(start..start + len)
713                .ok_or(StegoError::NoPayloadFound)?;
714            Ok(Payload::from_bytes(payload_data.to_vec()))
715        }
716    }
717
718    fn make_cover(size: usize) -> CoverMedia {
719        CoverMedia {
720            kind: CoverMediaKind::PngImage,
721            data: Bytes::from(vec![0u8; size]),
722            metadata: HashMap::new(),
723        }
724    }
725
726    // ─── Embed + Extract ──────────────────────────────────────────────────
727
728    #[test]
729    fn embed_extract_round_trip() -> TestResult {
730        let cover = make_cover(128);
731        let payload = Payload::from_bytes(b"secret message".to_vec());
732        let embedder = MockEmbedder;
733        let extractor = MockExtractor {
734            cover_prefix_len: 128,
735        };
736
737        let stego = EmbedService::embed(cover, &payload, &embedder)?;
738        let extracted = ExtractService::extract(&stego, &extractor)?;
739        assert_eq!(extracted.as_bytes(), b"secret message");
740        Ok(())
741    }
742
743    // ─── Analyse ──────────────────────────────────────────────────────────
744
745    #[test]
746    fn analyse_returns_report() -> TestResult {
747        let data: Vec<u8> = (0..=255).cycle().take(8192).collect();
748        let cover = CoverMedia {
749            kind: CoverMediaKind::PngImage,
750            data: Bytes::from(data),
751            metadata: HashMap::new(),
752        };
753        let analyser = crate::adapters::analysis::CapacityAnalyserImpl::new();
754        let report = AnalyseService::analyse(&cover, StegoTechnique::LsbImage, &analyser)?;
755        assert!(report.cover_capacity.bytes > 0);
756        Ok(())
757    }
758
759    // ─── Scrub ────────────────────────────────────────────────────────────
760
761    #[test]
762    fn scrub_service_normalises_text() -> TestResult {
763        let stylo_scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
764        let profile = StyloProfile {
765            normalize_punctuation: true,
766            target_avg_sentence_len: 15.0,
767            target_vocab_size: 1000,
768        };
769        let scrubbed = ScrubService::scrub("He  can't   stop!!!", &profile, &stylo_scrubber)?;
770        assert!(!scrubbed.contains("  "));
771        assert!(scrubbed.contains("cannot"));
772        Ok(())
773    }
774
775    // ─── Archive ──────────────────────────────────────────────────────────
776
777    #[test]
778    fn archive_service_round_trip() -> TestResult {
779        let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
780        let files = vec![("test.txt", b"data" as &[u8])];
781        let packed = ArchiveService::pack(&files, ArchiveFormat::Zip, &handler)?;
782        let unpacked = ArchiveService::unpack(&packed, ArchiveFormat::Zip, &handler)?;
783        assert_eq!(unpacked.len(), 1);
784        assert_eq!(
785            unpacked.first().ok_or("index out of bounds")?.1.as_ref(),
786            b"data"
787        );
788        Ok(())
789    }
790
791    // ─── AppError wraps all domain errors ─────────────────────────────────
792
793    #[test]
794    fn app_error_wraps_stego() {
795        let stego_err = StegoError::NoPayloadFound;
796        let app_err = AppError::from(stego_err);
797        assert!(matches!(app_err, AppError::Stego(_)));
798    }
799
800    #[test]
801    fn app_error_wraps_crypto() {
802        let crypto_err = CryptoError::KeyGenFailed {
803            reason: "test".into(),
804        };
805        let app_err = AppError::from(crypto_err);
806        assert!(matches!(app_err, AppError::Crypto(_)));
807    }
808
809    #[test]
810    fn app_error_wraps_distribution() {
811        let dist_err = DistributionError::InsufficientCovers { needed: 3, got: 1 };
812        let app_err = AppError::from(dist_err);
813        assert!(matches!(app_err, AppError::Distribution(_)));
814    }
815
816    #[test]
817    fn app_error_wraps_deniable() {
818        let den_err = DeniableError::InsufficientCapacity;
819        let app_err = AppError::from(den_err);
820        assert!(matches!(app_err, AppError::Deniable(_)));
821    }
822
823    #[test]
824    fn app_error_wraps_time_lock() {
825        let tl_err = TimeLockError::ComputationFailed {
826            reason: "test".into(),
827        };
828        let app_err = AppError::from(tl_err);
829        assert!(matches!(app_err, AppError::TimeLock(_)));
830    }
831
832    #[test]
833    fn app_error_wraps_corpus() {
834        let c_err = CorpusError::IndexError {
835            reason: "test".into(),
836        };
837        let app_err = AppError::from(c_err);
838        assert!(matches!(app_err, AppError::Corpus(_)));
839    }
840
841    // ─── KeyGenService ────────────────────────────────────────────────────
842
843    struct MockEncryptor;
844
845    impl Encryptor for MockEncryptor {
846        fn generate_keypair(&self) -> Result<KeyPair, CryptoError> {
847            Ok(KeyPair {
848                public_key: vec![1u8; 32],
849                secret_key: vec![2u8; 64],
850            })
851        }
852
853        fn encapsulate(&self, _public_key: &[u8]) -> Result<(Bytes, Bytes), CryptoError> {
854            Ok((Bytes::from(vec![3u8; 32]), Bytes::from(vec![4u8; 32])))
855        }
856
857        fn decapsulate(
858            &self,
859            _secret_key: &[u8],
860            _ciphertext: &[u8],
861        ) -> Result<Bytes, CryptoError> {
862            Ok(Bytes::from(vec![4u8; 32]))
863        }
864    }
865
866    struct MockSigner;
867
868    impl Signer for MockSigner {
869        fn generate_keypair(&self) -> Result<KeyPair, CryptoError> {
870            Ok(KeyPair {
871                public_key: vec![5u8; 32],
872                secret_key: vec![6u8; 32],
873            })
874        }
875
876        fn sign(&self, _secret_key: &[u8], _message: &[u8]) -> Result<Signature, CryptoError> {
877            Ok(Signature(Bytes::from(vec![7u8; 64])))
878        }
879
880        fn verify(
881            &self,
882            _public_key: &[u8],
883            _message: &[u8],
884            _signature: &Signature,
885        ) -> Result<bool, CryptoError> {
886            Ok(true)
887        }
888    }
889
890    struct MockSelectiveSigner;
891
892    impl Signer for MockSelectiveSigner {
893        fn generate_keypair(&self) -> Result<KeyPair, CryptoError> {
894            Ok(KeyPair {
895                public_key: vec![5u8; 32],
896                secret_key: vec![6u8; 32],
897            })
898        }
899
900        fn sign(&self, _secret_key: &[u8], message: &[u8]) -> Result<Signature, CryptoError> {
901            let mut sig = b"sig:".to_vec();
902            sig.extend_from_slice(message);
903            Ok(Signature(Bytes::from(sig)))
904        }
905
906        fn verify(
907            &self,
908            _public_key: &[u8],
909            message: &[u8],
910            signature: &Signature,
911        ) -> Result<bool, CryptoError> {
912            let mut expected = b"sig:".to_vec();
913            expected.extend_from_slice(message);
914            Ok(signature.0.as_ref() == expected.as_slice())
915        }
916    }
917
918    #[test]
919    fn keygen_generate_keypair() -> TestResult {
920        let encryptor = MockEncryptor;
921        let kp = KeyGenService::generate_keypair(&encryptor)?;
922        assert_eq!(kp.public_key.len(), 32);
923        assert_eq!(kp.secret_key.len(), 64);
924        Ok(())
925    }
926
927    #[test]
928    fn keygen_generate_signing_keypair() -> TestResult {
929        let signer = MockSigner;
930        let kp = KeyGenService::generate_signing_keypair(&signer)?;
931        assert_eq!(kp.public_key.len(), 32);
932        assert_eq!(kp.secret_key.len(), 32);
933        Ok(())
934    }
935
936    #[test]
937    fn keygen_sign_and_verify() -> TestResult {
938        let signer = MockSigner;
939        let signature = KeyGenService::sign(&signer, &[0u8; 32], b"test message")?;
940        assert_eq!(signature.0.len(), 64);
941        let valid = KeyGenService::verify(&signer, &[0u8; 32], b"test message", &signature)?;
942        assert!(valid);
943        Ok(())
944    }
945
946    #[test]
947    fn keygen_verify_invalid_signature_returns_false() -> TestResult {
948        let signer = MockSelectiveSigner;
949        let invalid_signature = Signature(Bytes::from_static(b"sig:wrong message"));
950        let valid =
951            KeyGenService::verify(&signer, &[0u8; 32], b"test message", &invalid_signature)?;
952        assert!(!valid);
953        Ok(())
954    }
955
956    #[test]
957    fn keygen_verify_tampered_message_returns_false() -> TestResult {
958        let signer = MockSelectiveSigner;
959        let signature = KeyGenService::sign(&signer, &[0u8; 32], b"test message")?;
960        let valid = KeyGenService::verify(&signer, &[0u8; 32], b"tampered message", &signature)?;
961        assert!(!valid);
962        Ok(())
963    }
964
965    // ─── DistributeService ────────────────────────────────────────────────
966
967    struct MockDistributor;
968
969    impl crate::domain::ports::Distributor for MockDistributor {
970        fn distribute(
971            &self,
972            _payload: &Payload,
973            _profile: &EmbeddingProfile,
974            covers: Vec<CoverMedia>,
975            _embedder: &dyn EmbedTechnique,
976        ) -> Result<Vec<CoverMedia>, DistributionError> {
977            Ok(covers)
978        }
979    }
980
981    struct MockGeographicDistributor;
982
983    impl GeographicDistributor for MockGeographicDistributor {
984        fn distribute_with_manifest(
985            &self,
986            _payload: &Payload,
987            covers: Vec<CoverMedia>,
988            _manifest: &GeographicManifest,
989            _embedder: &dyn EmbedTechnique,
990        ) -> Result<Vec<CoverMedia>, OpsecError> {
991            Ok(covers)
992        }
993    }
994
995    #[test]
996    fn distribute_service_returns_covers() -> TestResult {
997        let payload = Payload::from_bytes(b"payload".to_vec());
998        let covers = vec![make_cover(64), make_cover(64)];
999        let profile = EmbeddingProfile::Standard;
1000        let distributor = MockDistributor;
1001        let embedder = MockEmbedder;
1002        let result =
1003            DistributeService::distribute(&payload, covers, &profile, &distributor, &embedder)?;
1004        assert_eq!(result.len(), 2);
1005        Ok(())
1006    }
1007
1008    #[test]
1009    fn distribute_service_geographic_manifest_returns_covers() -> TestResult {
1010        let payload = Payload::from_bytes(b"payload".to_vec());
1011        let covers = vec![make_cover(64), make_cover(64)];
1012        let manifest = GeographicManifest {
1013            shards: vec![GeoShardEntry {
1014                shard_index: 0,
1015                jurisdiction: "US".to_string(),
1016                holder_description: "test holder".to_string(),
1017            }],
1018            minimum_jurisdictions: 1,
1019        };
1020        let distributor = MockGeographicDistributor;
1021        let embedder = MockEmbedder;
1022
1023        let result = DistributeService::distribute_with_geographic_manifest(
1024            &payload,
1025            covers,
1026            &manifest,
1027            &embedder,
1028            &distributor,
1029        )?;
1030        assert_eq!(result.len(), 2);
1031        Ok(())
1032    }
1033
1034    struct MockCoverProfileMatcher {
1035        calls: Arc<AtomicUsize>,
1036    }
1037
1038    impl CoverProfileMatcher for MockCoverProfileMatcher {
1039        fn profile_for(&self, _cover: &CoverMedia) -> Option<crate::domain::ports::CoverProfile> {
1040            self.calls.fetch_add(1, Ordering::Relaxed);
1041            None
1042        }
1043
1044        fn apply_profile(
1045            &self,
1046            cover: CoverMedia,
1047            _profile: &crate::domain::ports::CoverProfile,
1048        ) -> Result<CoverMedia, AdaptiveError> {
1049            Ok(cover)
1050        }
1051    }
1052
1053    struct MockAdaptiveOptimiser {
1054        calls: Arc<AtomicUsize>,
1055    }
1056
1057    impl AdaptiveOptimiser for MockAdaptiveOptimiser {
1058        fn optimise(
1059            &self,
1060            stego: CoverMedia,
1061            _original: &CoverMedia,
1062            _target_db: f64,
1063        ) -> Result<CoverMedia, AdaptiveError> {
1064            self.calls.fetch_add(1, Ordering::Relaxed);
1065            Ok(stego)
1066        }
1067    }
1068
1069    struct MockCompressionSimulator {
1070        simulate_calls: Arc<AtomicUsize>,
1071    }
1072
1073    impl CompressionSimulator for MockCompressionSimulator {
1074        fn simulate(
1075            &self,
1076            cover: CoverMedia,
1077            _platform: &PlatformProfile,
1078        ) -> Result<CoverMedia, AdaptiveError> {
1079            self.simulate_calls.fetch_add(1, Ordering::Relaxed);
1080            Ok(cover)
1081        }
1082
1083        fn survivable_capacity(
1084            &self,
1085            cover: &CoverMedia,
1086            _platform: &PlatformProfile,
1087        ) -> Result<Capacity, AdaptiveError> {
1088            Ok(Capacity {
1089                bytes: cover.data.len() as u64,
1090                technique: StegoTechnique::LsbImage,
1091            })
1092        }
1093    }
1094
1095    #[test]
1096    fn distribute_with_profile_hardening_uses_optimiser_for_adaptive() -> TestResult {
1097        let payload = Payload::from_bytes(b"payload".to_vec());
1098        let covers = vec![make_cover(64), make_cover(64)];
1099        let profile = EmbeddingProfile::Adaptive {
1100            max_detectability_db: -12.0,
1101        };
1102        let distributor = MockDistributor;
1103        let embedder = MockEmbedder;
1104        let matcher_calls = Arc::new(AtomicUsize::new(0));
1105        let optimiser_calls = Arc::new(AtomicUsize::new(0));
1106        let simulator_calls = Arc::new(AtomicUsize::new(0));
1107        let matcher = MockCoverProfileMatcher {
1108            calls: Arc::clone(&matcher_calls),
1109        };
1110        let optimiser = MockAdaptiveOptimiser {
1111            calls: Arc::clone(&optimiser_calls),
1112        };
1113        let compressor = MockCompressionSimulator {
1114            simulate_calls: Arc::clone(&simulator_calls),
1115        };
1116
1117        let result = DistributeService::distribute_with_profile_hardening(
1118            &payload,
1119            covers,
1120            &profile,
1121            &distributor,
1122            &embedder,
1123            &AdaptiveProfileDeps {
1124                matcher: &matcher,
1125                optimiser: &optimiser,
1126                compressor: &compressor,
1127            },
1128        )?;
1129
1130        assert_eq!(result.len(), 2);
1131        assert_eq!(matcher_calls.load(Ordering::Relaxed), 2);
1132        assert_eq!(optimiser_calls.load(Ordering::Relaxed), 2);
1133        assert_eq!(simulator_calls.load(Ordering::Relaxed), 0);
1134        Ok(())
1135    }
1136
1137    #[test]
1138    fn distribute_with_profile_hardening_uses_simulator_for_survivable() -> TestResult {
1139        let payload = Payload::from_bytes(b"payload".to_vec());
1140        let covers = vec![make_cover(64), make_cover(64), make_cover(64)];
1141        let profile = EmbeddingProfile::CompressionSurvivable {
1142            platform: PlatformProfile::Instagram,
1143        };
1144        let distributor = MockDistributor;
1145        let embedder = MockEmbedder;
1146        let matcher_calls = Arc::new(AtomicUsize::new(0));
1147        let optimiser_calls = Arc::new(AtomicUsize::new(0));
1148        let simulator_calls = Arc::new(AtomicUsize::new(0));
1149        let matcher = MockCoverProfileMatcher {
1150            calls: Arc::clone(&matcher_calls),
1151        };
1152        let optimiser = MockAdaptiveOptimiser {
1153            calls: Arc::clone(&optimiser_calls),
1154        };
1155        let compressor = MockCompressionSimulator {
1156            simulate_calls: Arc::clone(&simulator_calls),
1157        };
1158
1159        let result = DistributeService::distribute_with_profile_hardening(
1160            &payload,
1161            covers,
1162            &profile,
1163            &distributor,
1164            &embedder,
1165            &AdaptiveProfileDeps {
1166                matcher: &matcher,
1167                optimiser: &optimiser,
1168                compressor: &compressor,
1169            },
1170        )?;
1171
1172        assert_eq!(result.len(), 3);
1173        assert_eq!(matcher_calls.load(Ordering::Relaxed), 3);
1174        assert_eq!(optimiser_calls.load(Ordering::Relaxed), 0);
1175        assert_eq!(simulator_calls.load(Ordering::Relaxed), 3);
1176        Ok(())
1177    }
1178
1179    // ─── ReconstructService ───────────────────────────────────────────────
1180
1181    struct MockReconstructor;
1182
1183    impl crate::domain::ports::Reconstructor for MockReconstructor {
1184        fn reconstruct(
1185            &self,
1186            _covers: Vec<CoverMedia>,
1187            _extractor: &dyn ExtractTechnique,
1188            _progress_cb: &dyn Fn(usize, usize),
1189        ) -> Result<Payload, ReconstructionError> {
1190            Ok(Payload::from_bytes(b"reconstructed".to_vec()))
1191        }
1192    }
1193
1194    #[test]
1195    fn reconstruct_service_returns_payload() -> TestResult {
1196        let stego = vec![make_cover(128)];
1197        let extractor = MockExtractor {
1198            cover_prefix_len: 128,
1199        };
1200        let reconstructor = MockReconstructor;
1201        let payload =
1202            ReconstructService::reconstruct(stego, &extractor, &reconstructor, &|_, _| {})?;
1203        assert_eq!(payload.as_bytes(), b"reconstructed");
1204        Ok(())
1205    }
1206
1207    // ─── DeniableEmbedService ─────────────────────────────────────────────
1208
1209    struct MockDeniableEmbedder;
1210
1211    impl DeniableEmbedder for MockDeniableEmbedder {
1212        fn embed_dual(
1213            &self,
1214            cover: CoverMedia,
1215            _pair: &DeniablePayloadPair,
1216            _keys: &DeniableKeySet,
1217            _embedder: &dyn EmbedTechnique,
1218        ) -> Result<CoverMedia, crate::domain::errors::DeniableError> {
1219            Ok(cover)
1220        }
1221
1222        fn extract_with_key(
1223            &self,
1224            _stego: &CoverMedia,
1225            _key: &[u8],
1226            _extractor: &dyn ExtractTechnique,
1227        ) -> Result<Payload, crate::domain::errors::DeniableError> {
1228            Ok(Payload::from_bytes(b"deniable".to_vec()))
1229        }
1230    }
1231
1232    #[test]
1233    fn deniable_embed_service_round_trip() -> TestResult {
1234        let cover = make_cover(256);
1235        let pair = DeniablePayloadPair {
1236            real_payload: b"real".to_vec(),
1237            decoy_payload: b"decoy".to_vec(),
1238        };
1239        let keys = DeniableKeySet {
1240            primary_key: vec![1u8; 32],
1241            decoy_key: vec![2u8; 32],
1242        };
1243        let embedder = MockEmbedder;
1244        let deniable = MockDeniableEmbedder;
1245
1246        let stego = DeniableEmbedService::embed_dual(cover, &pair, &keys, &embedder, &deniable)?;
1247        let extracted = DeniableEmbedService::extract_with_key(
1248            &stego,
1249            &[1u8; 32],
1250            &MockExtractor {
1251                cover_prefix_len: 256,
1252            },
1253            &deniable,
1254        )?;
1255        assert_eq!(extracted.as_bytes(), b"deniable");
1256        Ok(())
1257    }
1258
1259    // ─── DeadDropService ──────────────────────────────────────────────────
1260
1261    struct MockDeadDropEncoder;
1262
1263    impl DeadDropEncoder for MockDeadDropEncoder {
1264        fn encode_for_platform(
1265            &self,
1266            cover: CoverMedia,
1267            _payload: &Payload,
1268            _platform: &PlatformProfile,
1269            _embedder: &dyn EmbedTechnique,
1270        ) -> Result<CoverMedia, DeadDropError> {
1271            Ok(cover)
1272        }
1273    }
1274
1275    #[test]
1276    fn dead_drop_service_encode() -> TestResult {
1277        let cover = make_cover(128);
1278        let payload = Payload::from_bytes(b"secret".to_vec());
1279        let platform = PlatformProfile::Instagram;
1280        let embedder = MockEmbedder;
1281        let encoder = MockDeadDropEncoder;
1282        let result = DeadDropService::encode(cover, &payload, &platform, &embedder, &encoder)?;
1283        assert_eq!(result.kind, CoverMediaKind::PngImage);
1284        Ok(())
1285    }
1286
1287    // ─── TimeLockServiceApp ───────────────────────────────────────────────
1288
1289    struct MockTimeLockService;
1290
1291    impl TimeLockServicePort for MockTimeLockService {
1292        fn lock(
1293            &self,
1294            payload: &Payload,
1295            unlock_at: DateTime<Utc>,
1296        ) -> Result<TimeLockPuzzle, TimeLockError> {
1297            Ok(TimeLockPuzzle {
1298                ciphertext: Bytes::from(payload.as_bytes().to_vec()),
1299                modulus: vec![1],
1300                start_value: vec![2],
1301                squarings_required: 100,
1302                created_at: Utc::now(),
1303                unlock_at,
1304            })
1305        }
1306
1307        fn unlock(&self, puzzle: &TimeLockPuzzle) -> Result<Payload, TimeLockError> {
1308            Ok(Payload::from_bytes(puzzle.ciphertext.to_vec()))
1309        }
1310
1311        fn try_unlock(&self, puzzle: &TimeLockPuzzle) -> Result<Option<Payload>, TimeLockError> {
1312            Ok(Some(Payload::from_bytes(puzzle.ciphertext.to_vec())))
1313        }
1314    }
1315
1316    #[test]
1317    fn time_lock_service_lock_and_unlock() -> TestResult {
1318        let payload = Payload::from_bytes(b"time locked".to_vec());
1319        let service = MockTimeLockService;
1320        let puzzle = TimeLockServiceApp::lock(&payload, Utc::now(), &service)?;
1321        let recovered = TimeLockServiceApp::unlock(&puzzle, &service)?;
1322        assert_eq!(recovered.as_bytes(), b"time locked");
1323        Ok(())
1324    }
1325
1326    #[test]
1327    fn time_lock_service_try_unlock() -> TestResult {
1328        let payload = Payload::from_bytes(b"try me".to_vec());
1329        let service = MockTimeLockService;
1330        let puzzle = TimeLockServiceApp::lock(&payload, Utc::now(), &service)?;
1331        let result = TimeLockServiceApp::try_unlock(&puzzle, &service)?;
1332        let recovered = result.ok_or("expected Some")?;
1333        assert_eq!(recovered.as_bytes(), b"try me");
1334        Ok(())
1335    }
1336
1337    // ─── CanaryShardService ───────────────────────────────────────────────
1338
1339    struct MockCanaryService;
1340
1341    impl CanaryServicePort for MockCanaryService {
1342        fn embed_canary(
1343            &self,
1344            covers: Vec<CoverMedia>,
1345            _embedder: &dyn EmbedTechnique,
1346        ) -> Result<(Vec<CoverMedia>, CanaryShard), CanaryError> {
1347            let shard = CanaryShard {
1348                shard: Shard {
1349                    index: 99,
1350                    total: 100,
1351                    data: vec![0u8; 16],
1352                    hmac_tag: [0u8; 32],
1353                },
1354                canary_id: Uuid::new_v4(),
1355                notify_url: Some("https://example.com/canary".into()),
1356            };
1357            Ok((covers, shard))
1358        }
1359
1360        fn check_canary(&self, _shard: &CanaryShard) -> bool {
1361            false
1362        }
1363    }
1364
1365    #[test]
1366    fn canary_shard_service_embed_and_check() -> TestResult {
1367        let covers = vec![make_cover(64)];
1368        let embedder = MockEmbedder;
1369        let canary = MockCanaryService;
1370        let (result_covers, shard) = CanaryShardService::embed_canary(covers, &embedder, &canary)?;
1371        assert_eq!(result_covers.len(), 1);
1372        assert_eq!(shard.shard.index, 99);
1373        assert!(!CanaryShardService::check_canary(&shard, &canary));
1374        Ok(())
1375    }
1376
1377    // ─── ForensicService ──────────────────────────────────────────────────
1378
1379    struct MockForensicWatermarker;
1380
1381    impl ForensicWatermarker for MockForensicWatermarker {
1382        fn embed_tripwire(
1383            &self,
1384            cover: CoverMedia,
1385            _tag: &WatermarkTripwireTag,
1386        ) -> Result<CoverMedia, OpsecError> {
1387            Ok(cover)
1388        }
1389
1390        fn identify_recipient(
1391            &self,
1392            _stego: &CoverMedia,
1393            tags: &[WatermarkTripwireTag],
1394        ) -> Result<Option<WatermarkReceipt>, OpsecError> {
1395            if tags.is_empty() {
1396                return Ok(None);
1397            }
1398            Ok(Some(WatermarkReceipt {
1399                recipient: tags
1400                    .first()
1401                    .map_or_else(String::new, |t| t.recipient_id.to_string()),
1402                algorithm: "lsb".into(),
1403                shards: vec![0],
1404                created_at: Utc::now(),
1405            }))
1406        }
1407    }
1408
1409    #[test]
1410    fn forensic_service_embed_and_identify() -> TestResult {
1411        let cover = make_cover(128);
1412        let tag = WatermarkTripwireTag {
1413            recipient_id: Uuid::new_v4(),
1414            embedding_seed: vec![9u8; 16],
1415        };
1416        let watermarker = MockForensicWatermarker;
1417
1418        let stego = ForensicService::embed_tripwire(cover, &tag, &watermarker)?;
1419        let receipt = ForensicService::identify_recipient(&stego, &[tag], &watermarker)?;
1420        let r = receipt.ok_or("expected Some")?;
1421        assert!(!r.recipient.is_empty());
1422        assert_eq!(r.algorithm, "lsb");
1423        Ok(())
1424    }
1425
1426    #[test]
1427    fn forensic_service_identify_no_tags() -> TestResult {
1428        let cover = make_cover(128);
1429        let watermarker = MockForensicWatermarker;
1430        let receipt = ForensicService::identify_recipient(&cover, &[], &watermarker)?;
1431        assert!(receipt.is_none());
1432        Ok(())
1433    }
1434
1435    // ─── PanicWipeService ─────────────────────────────────────────────────
1436
1437    struct MockPanicWiper;
1438
1439    impl PanicWiper for MockPanicWiper {
1440        fn wipe(&self, _config: &PanicWipeConfig) -> Result<(), OpsecError> {
1441            Ok(())
1442        }
1443    }
1444
1445    #[test]
1446    fn panic_wipe_service_succeeds() -> TestResult {
1447        let wiper = MockPanicWiper;
1448        let config = PanicWipeConfig {
1449            key_paths: vec![],
1450            config_paths: vec![],
1451            temp_dirs: vec![],
1452        };
1453        PanicWipeService::wipe(&config, &wiper)?;
1454        Ok(())
1455    }
1456
1457    // ─── AmnesiaPipelineService ───────────────────────────────────────────
1458
1459    struct MockAmnesiaPipeline;
1460
1461    impl AmnesiaPipeline for MockAmnesiaPipeline {
1462        fn embed_in_memory(
1463            &self,
1464            payload_input: &mut dyn Read,
1465            _cover_input: &mut dyn Read,
1466            output: &mut dyn Write,
1467            _technique: &dyn EmbedTechnique,
1468        ) -> Result<(), OpsecError> {
1469            let mut buf = Vec::new();
1470            payload_input
1471                .read_to_end(&mut buf)
1472                .map_err(|e| OpsecError::PipelineError {
1473                    reason: e.to_string(),
1474                })?;
1475            output
1476                .write_all(&buf)
1477                .map_err(|e| OpsecError::PipelineError {
1478                    reason: e.to_string(),
1479                })?;
1480            Ok(())
1481        }
1482    }
1483
1484    #[test]
1485    fn amnesia_pipeline_service_embed() -> TestResult {
1486        let pipeline = MockAmnesiaPipeline;
1487        let embedder = MockEmbedder;
1488        let mut payload_input = std::io::Cursor::new(b"secret payload");
1489        let mut cover_input = std::io::Cursor::new(vec![0u8; 128]);
1490        let mut output = Vec::new();
1491
1492        AmnesiaPipelineService::embed_in_memory(
1493            &mut payload_input,
1494            &mut cover_input,
1495            &mut output,
1496            &embedder,
1497            &pipeline,
1498        )?;
1499
1500        assert_eq!(output, b"secret payload");
1501        Ok(())
1502    }
1503
1504    // ─── Remaining AppError coverage ──────────────────────────────────────
1505
1506    #[test]
1507    fn app_error_wraps_reconstruction() {
1508        let err = ReconstructionError::InsufficientCovers { needed: 3, got: 1 };
1509        let app_err = AppError::from(err);
1510        assert!(matches!(app_err, AppError::Reconstruction(_)));
1511    }
1512
1513    #[test]
1514    fn app_error_wraps_correction() {
1515        let err = CorrectionError::InsufficientShards {
1516            needed: 3,
1517            available: 1,
1518        };
1519        let app_err = AppError::from(err);
1520        assert!(matches!(app_err, AppError::Correction(_)));
1521    }
1522
1523    #[test]
1524    fn app_error_wraps_analysis() {
1525        let err = AnalysisError::UnsupportedCoverType {
1526            reason: "test".into(),
1527        };
1528        let app_err = AppError::from(err);
1529        assert!(matches!(app_err, AppError::Analysis(_)));
1530    }
1531
1532    #[test]
1533    fn app_error_wraps_archive() {
1534        let err = ArchiveError::PackFailed {
1535            reason: "test".into(),
1536        };
1537        let app_err = AppError::from(err);
1538        assert!(matches!(app_err, AppError::Archive(_)));
1539    }
1540
1541    #[test]
1542    fn app_error_wraps_opsec() {
1543        let err = OpsecError::PipelineError {
1544            reason: "test".into(),
1545        };
1546        let app_err = AppError::from(err);
1547        assert!(matches!(app_err, AppError::Opsec(_)));
1548    }
1549
1550    #[test]
1551    fn app_error_wraps_scrubber() {
1552        let err = ScrubberError::ProfileNotSatisfied {
1553            reason: "test".into(),
1554        };
1555        let app_err = AppError::from(err);
1556        assert!(matches!(app_err, AppError::Scrubber(_)));
1557    }
1558
1559    #[test]
1560    fn app_error_wraps_adaptive() {
1561        let err = AdaptiveError::BudgetNotMet {
1562            achieved_db: -5.0,
1563            target_db: -10.0,
1564        };
1565        let app_err = AppError::from(err);
1566        assert!(matches!(app_err, AppError::Adaptive(_)));
1567    }
1568
1569    #[test]
1570    fn app_error_wraps_canary() {
1571        let err = CanaryError::EmbedFailed {
1572            source: StegoError::NoPayloadFound,
1573        };
1574        let app_err = AppError::from(err);
1575        assert!(matches!(app_err, AppError::Canary(_)));
1576    }
1577
1578    #[test]
1579    fn app_error_wraps_dead_drop() {
1580        let err = DeadDropError::EncodeFailed {
1581            reason: "test".into(),
1582        };
1583        let app_err = AppError::from(err);
1584        assert!(matches!(app_err, AppError::DeadDrop(_)));
1585    }
1586
1587    #[test]
1588    fn app_error_display_formats() {
1589        let err = AppError::Crypto(CryptoError::KeyGenFailed {
1590            reason: "oops".into(),
1591        });
1592        let msg = format!("{err}");
1593        assert!(msg.contains("crypto"));
1594        assert!(msg.contains("oops"));
1595    }
1596
1597    // ─── CorpusService ────────────────────────────────────────────────────
1598
1599    struct MockCorpusIndex {
1600        build_count: usize,
1601        search_entries: Vec<CorpusEntry>,
1602    }
1603
1604    impl MockCorpusIndex {
1605        fn new(build_count: usize, search_entries: Vec<CorpusEntry>) -> Self {
1606            Self {
1607                build_count,
1608                search_entries,
1609            }
1610        }
1611    }
1612
1613    impl crate::domain::ports::CorpusIndex for MockCorpusIndex {
1614        fn build_index(
1615            &self,
1616            _dir: &std::path::Path,
1617        ) -> Result<usize, crate::domain::errors::CorpusError> {
1618            Ok(self.build_count)
1619        }
1620        fn add_to_index(
1621            &self,
1622            path: &std::path::Path,
1623        ) -> Result<CorpusEntry, crate::domain::errors::CorpusError> {
1624            Ok(CorpusEntry {
1625                file_hash: [0u8; 32],
1626                path: path.to_string_lossy().into_owned(),
1627                cover_kind: CoverMediaKind::PngImage,
1628                precomputed_bit_pattern: bytes::Bytes::new(),
1629                spectral_key: None,
1630            })
1631        }
1632        fn search(
1633            &self,
1634            _payload: &Payload,
1635            _technique: StegoTechnique,
1636            _max: usize,
1637        ) -> Result<Vec<CorpusEntry>, crate::domain::errors::CorpusError> {
1638            Ok(self.search_entries.clone())
1639        }
1640        fn search_for_model(
1641            &self,
1642            _payload: &Payload,
1643            _model_id: &str,
1644            _res: (u32, u32),
1645            _max: usize,
1646        ) -> Result<Vec<CorpusEntry>, crate::domain::errors::CorpusError> {
1647            Ok(self.search_entries.clone())
1648        }
1649        fn model_stats(&self) -> Vec<(SpectralKey, usize)> {
1650            vec![(
1651                SpectralKey {
1652                    model_id: "test-model".into(),
1653                    resolution: (1920, 1080),
1654                },
1655                self.build_count,
1656            )]
1657        }
1658    }
1659
1660    fn make_corpus_entry(path: &str) -> CorpusEntry {
1661        CorpusEntry {
1662            file_hash: [0u8; 32],
1663            path: path.to_owned(),
1664            cover_kind: CoverMediaKind::PngImage,
1665            precomputed_bit_pattern: bytes::Bytes::new(),
1666            spectral_key: None,
1667        }
1668    }
1669
1670    #[test]
1671    fn corpus_service_build_index() -> TestResult {
1672        let idx = MockCorpusIndex::new(42, vec![]);
1673        let count = CorpusService::build_index(&idx, std::path::Path::new("/some/dir"))?;
1674        assert_eq!(count, 42);
1675        Ok(())
1676    }
1677
1678    #[test]
1679    fn corpus_service_search() -> TestResult {
1680        let entry = make_corpus_entry("covers/img001.png");
1681        let idx = MockCorpusIndex::new(1, vec![entry.clone()]);
1682        let payload = Payload::from_bytes(bytes::Bytes::from_static(b"hello"));
1683        let results = CorpusService::search(&idx, &payload, StegoTechnique::LsbImage, 5)?;
1684        assert_eq!(results.len(), 1);
1685        assert_eq!(
1686            results.first().map(|it| it.path.as_str()),
1687            Some(entry.path.as_str())
1688        );
1689        Ok(())
1690    }
1691
1692    #[test]
1693    fn corpus_service_search_for_model() -> TestResult {
1694        let entry = make_corpus_entry("covers/gemini001.png");
1695        let idx = MockCorpusIndex::new(1, vec![entry.clone()]);
1696        let payload = Payload::from_bytes(bytes::Bytes::from_static(b"hello"));
1697        let results = CorpusService::search_for_model(&idx, &payload, "gemini", (1920, 1080), 3)?;
1698        assert_eq!(results.len(), 1);
1699        assert_eq!(
1700            results.first().map(|it| it.path.as_str()),
1701            Some(entry.path.as_str())
1702        );
1703        Ok(())
1704    }
1705
1706    #[test]
1707    fn corpus_service_model_stats() {
1708        let idx = MockCorpusIndex::new(7, vec![]);
1709        let stats = CorpusService::model_stats(&idx);
1710        assert_eq!(stats.len(), 1);
1711        assert_eq!(
1712            stats.first().map(|(k, _)| k.model_id.as_str()),
1713            Some("test-model")
1714        );
1715        assert_eq!(stats.first().map(|(_, c)| *c), Some(7));
1716    }
1717
1718    // ─── CipherService ────────────────────────────────────────────────────
1719
1720    #[test]
1721    fn cipher_service_encrypt_decrypt_roundtrip() -> TestResult {
1722        use crate::adapters::crypto::Aes256GcmCipher;
1723        let cipher = Aes256GcmCipher;
1724        let key = vec![0u8; 32];
1725        let nonce = vec![1u8; 12];
1726        let plaintext = b"secret cipher payload";
1727        let ct = CipherService::encrypt(&cipher, &key, &nonce, plaintext)?;
1728        let pt = CipherService::decrypt(&cipher, &key, &nonce, &ct)?;
1729        assert_eq!(pt.as_ref(), plaintext);
1730        Ok(())
1731    }
1732
1733    #[test]
1734    fn cipher_service_decrypt_fails_on_tamper() -> TestResult {
1735        use crate::adapters::crypto::Aes256GcmCipher;
1736        let cipher = Aes256GcmCipher;
1737        let key = vec![0u8; 32];
1738        let nonce = vec![1u8; 12];
1739        let plaintext = b"secret cipher payload";
1740        let mut ct = CipherService::encrypt(&cipher, &key, &nonce, plaintext)?.to_vec();
1741        *ct.get_mut(0).ok_or("empty ciphertext")? ^= 0xFF;
1742        let result = CipherService::decrypt(&cipher, &key, &nonce, &ct);
1743        assert!(result.is_err(), "tampered ciphertext must fail decryption");
1744        Ok(())
1745    }
1746}