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