Skip to main content

shadowforge_lib/domain/
ports.rs

1//! Port trait definitions for all bounded contexts.
2//!
3//! Each trait defines a capability boundary between the domain and its
4//! adapters. All traits are **object-safe** — verified by compile-time
5//! assertions below. No I/O, no `async`, no concrete types from external
6//! crates appear in return positions.
7
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::path::Path;
11
12use bytes::Bytes;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use crate::domain::errors::{
17    AdaptiveError, AnalysisError, ArchiveError, CanaryError, CorrectionError, CryptoError,
18    DeadDropError, DeniableError, DistributionError, MediaError, OpsecError, PdfError,
19    ReconstructionError, ScrubberError, StegoError, TimeLockError,
20};
21use crate::domain::types::{
22    AnalysisReport, ArchiveFormat, CanaryShard, Capacity, CoverMedia, DeniableKeySet,
23    DeniablePayloadPair, EmbeddingProfile, GeographicManifest, KeyPair, Payload, PlatformProfile,
24    Shard, Signature, StegoTechnique, StyloProfile, TimeLockPuzzle, WatermarkReceipt,
25    WatermarkTripwireTag,
26};
27
28// ─── Crypto ───────────────────────────────────────────────────────────────────
29
30/// Key encapsulation mechanism (KEM) port — ML-KEM-1024.
31///
32/// All operations are constant-time with respect to secret key material.
33pub trait Encryptor {
34    /// Generate a fresh key pair.
35    ///
36    /// # Errors
37    /// Returns [`CryptoError::KeyGenFailed`] if the RNG or parameter
38    /// validation fails.
39    fn generate_keypair(&self) -> Result<KeyPair, CryptoError>;
40
41    /// Encapsulate a shared secret using the recipient's public key.
42    ///
43    /// Returns `(ciphertext, shared_secret)`.
44    ///
45    /// # Errors
46    /// Returns [`CryptoError::EncapsulationFailed`] on invalid key material.
47    fn encapsulate(&self, public_key: &[u8]) -> Result<(Bytes, Bytes), CryptoError>;
48
49    /// Decapsulate a shared secret using the holder's secret key.
50    ///
51    /// # Errors
52    /// Returns [`CryptoError::DecapsulationFailed`] on invalid key or
53    /// ciphertext.
54    fn decapsulate(&self, secret_key: &[u8], ciphertext: &[u8]) -> Result<Bytes, CryptoError>;
55}
56
57/// Digital signature port — ML-DSA-87.
58///
59/// All comparisons over signature bytes use constant-time equality.
60pub trait Signer {
61    /// Generate a fresh signing key pair.
62    ///
63    /// # Errors
64    /// Returns [`CryptoError::KeyGenFailed`] if key generation fails.
65    fn generate_keypair(&self) -> Result<KeyPair, CryptoError>;
66
67    /// Sign a message with the secret signing key.
68    ///
69    /// # Errors
70    /// Returns [`CryptoError::SigningFailed`] on invalid key material.
71    fn sign(&self, secret_key: &[u8], message: &[u8]) -> Result<Signature, CryptoError>;
72
73    /// Verify a signature against the public key and message.
74    ///
75    /// Returns `true` when the signature is valid.
76    ///
77    /// # Errors
78    /// Returns [`CryptoError::VerificationFailed`] only on implementation
79    /// errors; an invalid signature returns `Ok(false)`.
80    fn verify(
81        &self,
82        public_key: &[u8],
83        message: &[u8],
84        signature: &Signature,
85    ) -> Result<bool, CryptoError>;
86}
87
88/// Symmetric cipher port — AES-256-GCM.
89pub trait SymmetricCipher {
90    /// Encrypt `plaintext` with `key` and `nonce`.
91    ///
92    /// # Errors
93    /// Returns [`CryptoError::InvalidKeyLength`] or
94    /// [`CryptoError::EncryptionFailed`].
95    fn encrypt(&self, key: &[u8], nonce: &[u8], plaintext: &[u8]) -> Result<Bytes, CryptoError>;
96
97    /// Decrypt and authenticate `ciphertext` with `key` and `nonce`.
98    ///
99    /// # Errors
100    /// Returns [`CryptoError::DecryptionFailed`] if authentication fails.
101    fn decrypt(&self, key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> Result<Bytes, CryptoError>;
102}
103
104// ─── Error Correction ─────────────────────────────────────────────────────────
105
106/// Reed-Solomon K-of-N erasure coding port.
107pub trait ErrorCorrector {
108    /// Encode `data` into `data_shards + parity_shards` [`Shard`]s.
109    ///
110    /// Each shard carries an HMAC-SHA-256 tag over `index || total || data`.
111    ///
112    /// # Errors
113    /// Returns [`CorrectionError::InvalidParameters`] or
114    /// [`CorrectionError::ReedSolomonError`].
115    fn encode(
116        &self,
117        data: &[u8],
118        data_shards: u8,
119        parity_shards: u8,
120    ) -> Result<Vec<Shard>, CorrectionError>;
121
122    /// Decode `shards` back to the original bytes.
123    ///
124    /// Accepts partial shard sets (some may be `None`); requires at least
125    /// `data_shards` valid shards with passing HMAC tags.
126    ///
127    /// # Errors
128    /// Returns [`CorrectionError::InsufficientShards`],
129    /// [`CorrectionError::HmacMismatch`], or
130    /// [`CorrectionError::ReedSolomonError`].
131    fn decode(
132        &self,
133        shards: &[Option<Shard>],
134        data_shards: u8,
135        parity_shards: u8,
136    ) -> Result<Bytes, CorrectionError>;
137}
138
139// ─── Steganography ────────────────────────────────────────────────────────────
140
141/// Embedding half of a steganographic technique.
142pub trait EmbedTechnique {
143    /// The technique identifier for this implementation.
144    fn technique(&self) -> StegoTechnique;
145
146    /// Estimate how many payload bytes `cover` can hold.
147    ///
148    /// # Errors
149    /// Returns [`StegoError::UnsupportedCoverType`] if the cover kind is
150    /// incompatible with this technique.
151    fn capacity(&self, cover: &CoverMedia) -> Result<Capacity, StegoError>;
152
153    /// Embed `payload` into `cover`, returning the stego cover.
154    ///
155    /// # Errors
156    /// Returns [`StegoError::PayloadTooLarge`] or
157    /// [`StegoError::MalformedCoverData`].
158    fn embed(&self, cover: CoverMedia, payload: &Payload) -> Result<CoverMedia, StegoError>;
159}
160
161/// Extraction half of a steganographic technique.
162pub trait ExtractTechnique {
163    /// The technique identifier for this implementation.
164    fn technique(&self) -> StegoTechnique;
165
166    /// Extract a hidden payload from `stego`.
167    ///
168    /// # Errors
169    /// Returns [`StegoError::NoPayloadFound`] or
170    /// [`StegoError::IntegrityCheckFailed`].
171    fn extract(&self, stego: &CoverMedia) -> Result<Payload, StegoError>;
172}
173
174// ─── Media ────────────────────────────────────────────────────────────────────
175
176/// Codec adapter port for loading and saving cover media files.
177///
178/// I/O is performed by the adapter; the domain receives decoded pixels/samples.
179pub trait MediaLoader {
180    /// Load a media file from `path` and return decoded [`CoverMedia`].
181    ///
182    /// # Errors
183    /// Returns [`MediaError::UnsupportedFormat`], [`MediaError::DecodeFailed`],
184    /// or [`MediaError::IoError`].
185    fn load(&self, path: &Path) -> Result<CoverMedia, MediaError>;
186
187    /// Encode `media` and write it to `path`.
188    ///
189    /// # Errors
190    /// Returns [`MediaError::EncodeFailed`] or [`MediaError::IoError`].
191    fn save(&self, media: &CoverMedia, path: &Path) -> Result<(), MediaError>;
192}
193
194// ─── PDF ──────────────────────────────────────────────────────────────────────
195
196/// First-class PDF bounded context port.
197///
198/// Covers parsing, page rendering, content-stream LSB, and metadata embedding.
199pub trait PdfProcessor {
200    /// Parse a PDF file from `path` into a [`CoverMedia`].
201    ///
202    /// # Errors
203    /// Returns [`PdfError::ParseFailed`] or [`PdfError::IoError`].
204    fn load_pdf(&self, path: &Path) -> Result<CoverMedia, PdfError>;
205
206    /// Serialise `media` back to a PDF file at `path`.
207    ///
208    /// # Errors
209    /// Returns [`PdfError::RebuildFailed`] or [`PdfError::IoError`].
210    fn save_pdf(&self, media: &CoverMedia, path: &Path) -> Result<(), PdfError>;
211
212    /// Rasterise every page of `pdf` to a PNG [`CoverMedia`].
213    ///
214    /// # Errors
215    /// Returns [`PdfError::RenderFailed`] on any page failure.
216    fn render_pages_to_images(&self, pdf: &CoverMedia) -> Result<Vec<CoverMedia>, PdfError>;
217
218    /// Reconstruct a PDF from rasterised `images`, retaining `original`
219    /// metadata where possible.
220    ///
221    /// # Errors
222    /// Returns [`PdfError::RebuildFailed`].
223    fn rebuild_pdf_from_images(
224        &self,
225        images: Vec<CoverMedia>,
226        original: &CoverMedia,
227    ) -> Result<CoverMedia, PdfError>;
228
229    /// Embed `payload` via content-stream LSB coefficient modification.
230    ///
231    /// # Errors
232    /// Returns [`PdfError::EmbedFailed`].
233    fn embed_in_content_stream(
234        &self,
235        pdf: CoverMedia,
236        payload: &Payload,
237    ) -> Result<CoverMedia, PdfError>;
238
239    /// Extract a payload previously embedded in the content stream.
240    ///
241    /// # Errors
242    /// Returns [`PdfError::ExtractFailed`].
243    fn extract_from_content_stream(&self, pdf: &CoverMedia) -> Result<Payload, PdfError>;
244
245    /// Embed `payload` into XMP / document-level metadata fields.
246    ///
247    /// # Errors
248    /// Returns [`PdfError::EmbedFailed`].
249    fn embed_in_metadata(&self, pdf: CoverMedia, payload: &Payload)
250    -> Result<CoverMedia, PdfError>;
251
252    /// Extract a payload previously embedded in XMP / document-level metadata.
253    ///
254    /// # Errors
255    /// Returns [`PdfError::ExtractFailed`].
256    fn extract_from_metadata(&self, pdf: &CoverMedia) -> Result<Payload, PdfError>;
257}
258
259// ─── Distribution ─────────────────────────────────────────────────────────────
260
261/// Payload distribution port.
262///
263/// Accepts an embedder trait-object so the distribution pattern is decoupled
264/// from any specific steganographic technique.
265pub trait Distributor {
266    /// Distribute `payload` across `covers` according to `profile`.
267    ///
268    /// # Errors
269    /// Returns [`DistributionError::InsufficientCovers`] or
270    /// [`DistributionError::EmbedFailed`].
271    fn distribute(
272        &self,
273        payload: &Payload,
274        profile: &EmbeddingProfile,
275        covers: Vec<CoverMedia>,
276        embedder: &dyn EmbedTechnique,
277    ) -> Result<Vec<CoverMedia>, DistributionError>;
278}
279
280// ─── Reconstruction ───────────────────────────────────────────────────────────
281
282/// K-of-N shard reconstruction port.
283pub trait Reconstructor {
284    /// Reconstruct the original payload from stego `covers`.
285    ///
286    /// `progress_cb` is called with `(completed, total)` after each
287    /// extraction step so callers can display progress.
288    ///
289    /// # Errors
290    /// Returns [`ReconstructionError::InsufficientCovers`],
291    /// [`ReconstructionError::ExtractionFailed`], or
292    /// [`ReconstructionError::CorrectionFailed`].
293    fn reconstruct(
294        &self,
295        covers: Vec<CoverMedia>,
296        extractor: &dyn ExtractTechnique,
297        progress_cb: &dyn Fn(usize, usize),
298    ) -> Result<Payload, ReconstructionError>;
299}
300
301// ─── Analysis ─────────────────────────────────────────────────────────────────
302
303/// Steganalysis and capacity estimation port.
304pub trait CapacityAnalyser {
305    /// Analyse `cover` with `technique` and return an [`AnalysisReport`].
306    ///
307    /// # Errors
308    /// Returns [`AnalysisError::UnsupportedCoverType`] or
309    /// [`AnalysisError::ComputationFailed`].
310    fn analyse(
311        &self,
312        cover: &CoverMedia,
313        technique: StegoTechnique,
314    ) -> Result<AnalysisReport, AnalysisError>;
315}
316
317// ─── Archive ──────────────────────────────────────────────────────────────────
318
319/// Multi-carrier archive port (ZIP / TAR / TAR.GZ).
320pub trait ArchiveHandler {
321    /// Pack `files` (name, bytes) into an archive of `format`.
322    ///
323    /// # Errors
324    /// Returns [`ArchiveError::PackFailed`] or
325    /// [`ArchiveError::UnsupportedFormat`].
326    fn pack(&self, files: &[(&str, &[u8])], format: ArchiveFormat) -> Result<Bytes, ArchiveError>;
327
328    /// Unpack `archive` of `format` into `(name, bytes)` pairs.
329    ///
330    /// # Errors
331    /// Returns [`ArchiveError::UnpackFailed`] or
332    /// [`ArchiveError::UnsupportedFormat`].
333    fn unpack(
334        &self,
335        archive: &[u8],
336        format: ArchiveFormat,
337    ) -> Result<Vec<(String, Bytes)>, ArchiveError>;
338}
339
340// ─── Adaptive Embedding ───────────────────────────────────────────────────────
341
342/// Serde helper for `[u16; 64]` — serde only auto-derives array impls up to
343/// `[T; 32]`, so we provide a thin wrapper using `Vec<u16>` as the wire format.
344mod serde_quant_table {
345    use serde::{Deserialize, Deserializer, Serialize, Serializer};
346
347    pub fn serialize<S: Serializer>(arr: &[u16; 64], s: S) -> Result<S::Ok, S::Error> {
348        arr.as_slice().serialize(s)
349    }
350
351    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u16; 64], D::Error> {
352        let v = Vec::<u16>::deserialize(d)?;
353        v.as_slice().try_into().map_err(|_| {
354            serde::de::Error::invalid_length(v.len(), &"exactly 64 JPEG quantisation coefficients")
355        })
356    }
357}
358
359/// Camera-model statistical fingerprint — used to defeat model-based
360/// steganalysis by matching the cover's noise floor.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct CameraProfile {
363    /// JPEG quantisation table (64 coefficients in zig-zag order).
364    #[serde(with = "serde_quant_table")]
365    pub quantisation_table: [u16; 64],
366    /// Estimated noise floor of the camera sensor (in decibels).
367    pub noise_floor_db: f64,
368    /// Human-readable camera model identifier.
369    pub model_id: String,
370}
371
372/// Spectral fingerprint for a single AI image generator model.
373///
374/// Stores per-resolution carrier frequency maps extracted from reference
375/// images (pure-black / pure-white outputs dominated by the watermark
376/// signal).  The `carrier_map` key is `"WIDTHxHEIGHT"` (e.g. `"1024x1024"`).
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct AiGenProfile {
379    /// Generator identifier, e.g. `"gemini"`, `"midjourney-v7"`.
380    pub model_id: String,
381    /// Per-channel embedding weights `[R, G, B]` (G is reference=1.0).
382    pub channel_weights: [f64; 3],
383    /// Map from `"WxH"` resolution string to carrier bin list.
384    pub carrier_map: HashMap<String, Vec<CarrierBin>>,
385}
386
387impl AiGenProfile {
388    /// Return carrier bins for the given resolution, or `None` if unknown.
389    #[must_use]
390    pub fn carrier_bins_for(&self, width: u32, height: u32) -> Option<&[CarrierBin]> {
391        let key = format!("{width}x{height}");
392        self.carrier_map.get(&key).map(Vec::as_slice)
393    }
394}
395
396/// A single frequency-domain carrier bin occupied by an AI generator's
397/// watermark.
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct CarrierBin {
400    /// `(row_bin, col_bin)` in the 2-D FFT of the green channel.
401    pub freq: (u32, u32),
402    /// Expected phase angle of the carrier (radians).
403    pub phase: f64,
404    /// Measured phase coherence across reference images, clamped to
405    /// `0.0..=1.0` by the constructor.  The custom deserializer enforces
406    /// the same clamp so untrusted profiles cannot bypass it.
407    #[serde(deserialize_with = "de_clamp_coherence")]
408    coherence: f64,
409}
410
411/// Serde deserializer that clamps a `f64` to `0.0..=1.0`.
412fn de_clamp_coherence<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
413    let v = f64::deserialize(d)?;
414    Ok(v.clamp(0.0, 1.0))
415}
416
417impl CarrierBin {
418    /// Construct a `CarrierBin`, clamping `coherence` to `0.0..=1.0`.
419    #[must_use]
420    pub const fn new(freq: (u32, u32), phase: f64, coherence: f64) -> Self {
421        Self {
422            freq,
423            phase,
424            coherence: coherence.clamp(0.0, 1.0),
425        }
426    }
427
428    /// Return the (clamped) phase coherence value.
429    #[must_use]
430    pub const fn coherence(&self) -> f64 {
431        self.coherence
432    }
433
434    /// Return `true` when coherence ≥ 0.90 — a reliable carrier.
435    #[must_use]
436    pub fn is_strong(&self) -> bool {
437        self.coherence >= 0.90
438    }
439}
440
441/// Discriminated union of cover-source fingerprint profiles.
442///
443/// Use `CoverProfile::Camera` for traditional camera-sourced images and
444/// `CoverProfile::AiGenerator` for AI-generated covers (Gemini, Midjourney,
445/// etc.).
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(tag = "kind", content = "data")]
448pub enum CoverProfile {
449    /// Traditional camera-sourced image (JPEG quant table + sensor noise).
450    Camera(CameraProfile),
451    /// AI-generated image with per-resolution spectral carrier bins.
452    AiGenerator(AiGenProfile),
453}
454
455impl CoverProfile {
456    /// Return the human-readable generator / camera model identifier.
457    #[must_use]
458    pub fn model_id(&self) -> &str {
459        match self {
460            Self::Camera(p) => &p.model_id,
461            Self::AiGenerator(p) => &p.model_id,
462        }
463    }
464}
465
466/// Adversarial embedding optimiser port (STC-inspired).
467///
468/// Reorders bit assignments after embedding to minimise chi-square distance
469/// from the unmodified cover's statistical distribution.
470pub trait AdaptiveOptimiser {
471    /// Optimise `stego` to stay within `target_db` of the original's
472    /// statistical distribution.
473    ///
474    /// # Errors
475    /// Returns [`AdaptiveError::BudgetNotMet`] if no permutation achieves
476    /// the target.
477    fn optimise(
478        &self,
479        stego: CoverMedia,
480        original: &CoverMedia,
481        target_db: f64,
482    ) -> Result<CoverMedia, AdaptiveError>;
483}
484
485/// Cover profile matching port — detects whether a cover is AI-generated or
486/// camera-sourced.
487pub trait CoverProfileMatcher {
488    /// Return the best-matching [`CoverProfile`] for `cover`, or `None`
489    /// if no profile is close enough.
490    fn profile_for(&self, cover: &CoverMedia) -> Option<CoverProfile>;
491
492    /// Apply `profile` to `cover` (e.g. adjust JPEG quant tables for a
493    /// camera profile; no-op for AI profiles — we avoid their bins instead).
494    ///
495    /// # Errors
496    /// Returns [`AdaptiveError::ProfileMatchFailed`].
497    fn apply_profile(
498        &self,
499        cover: CoverMedia,
500        profile: &CoverProfile,
501    ) -> Result<CoverMedia, AdaptiveError>;
502}
503
504/// Compression survivability port — social media platform recompression.
505pub trait CompressionSimulator {
506    /// Simulate a target platform's recompression pipeline on `cover`.
507    ///
508    /// # Errors
509    /// Returns [`AdaptiveError::CompressionSimFailed`].
510    fn simulate(
511        &self,
512        cover: CoverMedia,
513        platform: &PlatformProfile,
514    ) -> Result<CoverMedia, AdaptiveError>;
515
516    /// Estimate the embedding capacity that survives `platform`'s pipeline.
517    ///
518    /// # Errors
519    /// Returns [`AdaptiveError::CompressionSimFailed`].
520    fn survivable_capacity(
521        &self,
522        cover: &CoverMedia,
523        platform: &PlatformProfile,
524    ) -> Result<Capacity, AdaptiveError>;
525}
526
527// ─── Deniable Steganography ───────────────────────────────────────────────────
528
529/// Dual-payload deniable steganography port.
530///
531/// The stego cover is indistinguishable regardless of which key is presented.
532pub trait DeniableEmbedder {
533    /// Embed both the real and decoy payload in `cover`.
534    ///
535    /// The resulting cover decrypts to `pair.real_payload` under
536    /// `keys.primary_key` and to `pair.decoy_payload` under `keys.decoy_key`.
537    ///
538    /// # Errors
539    /// Returns [`DeniableError::InsufficientCapacity`] or
540    /// [`DeniableError::EmbedFailed`].
541    fn embed_dual(
542        &self,
543        cover: CoverMedia,
544        pair: &DeniablePayloadPair,
545        keys: &DeniableKeySet,
546        embedder: &dyn EmbedTechnique,
547    ) -> Result<CoverMedia, DeniableError>;
548
549    /// Extract a payload from `stego` using the provided `key`.
550    ///
551    /// Returns the decoy payload when given the decoy key, and the real
552    /// payload when given the primary key — neither party can prove which.
553    ///
554    /// # Errors
555    /// Returns [`DeniableError::ExtractionFailed`].
556    fn extract_with_key(
557        &self,
558        stego: &CoverMedia,
559        key: &[u8],
560        extractor: &dyn ExtractTechnique,
561    ) -> Result<Payload, DeniableError>;
562}
563
564// ─── Operational Security ─────────────────────────────────────────────────────
565
566/// Emergency panic-wipe port.
567///
568/// Synchronous and best-effort: logs failures internally but completes all
569/// wipe steps regardless. Must never propagate an error to the caller under
570/// duress.
571pub trait PanicWiper {
572    /// Securely erase all paths described in `config`.
573    ///
574    /// # Errors
575    /// Returns [`OpsecError::WipeStepFailed`] only when *all* steps have
576    /// been attempted and logging is safe. Under duress this should be
577    /// treated as non-fatal by the caller.
578    fn wipe(&self, config: &crate::domain::types::PanicWipeConfig) -> Result<(), OpsecError>;
579}
580
581/// Forensic watermark tripwire port.
582///
583/// Unique per-recipient watermarks allow identifying which copy of a
584/// distributed set was leaked.
585pub trait ForensicWatermarker {
586    /// Embed a per-recipient tripwire watermark into `cover`.
587    ///
588    /// # Errors
589    /// Returns [`OpsecError::WatermarkError`].
590    fn embed_tripwire(
591        &self,
592        cover: CoverMedia,
593        tag: &WatermarkTripwireTag,
594    ) -> Result<CoverMedia, OpsecError>;
595
596    /// Identify which recipient's watermark is present in `stego`.
597    ///
598    /// Returns the matching [`WatermarkTripwireTag`] from `tags`, or `None`
599    /// if no match is found.
600    ///
601    /// # Errors
602    /// Returns [`OpsecError::WatermarkError`] on implementation error.
603    fn identify_recipient(
604        &self,
605        stego: &CoverMedia,
606        tags: &[WatermarkTripwireTag],
607    ) -> Result<Option<WatermarkReceipt>, OpsecError>;
608}
609
610/// Amnesiac in-memory pipeline port.
611///
612/// The entire embed/extract cycle runs without touching the filesystem.
613/// Uses [`std::io::pipe`] (stable 1.87) internally.
614pub trait AmnesiaPipeline {
615    /// Embed a payload read from `cover_input` and `payload_input` using
616    /// `technique`, writing the stego output to `output`.
617    ///
618    /// No temporary files, logs of sensitive data, or crash dumps are created.
619    ///
620    /// # Errors
621    /// Returns [`OpsecError::PipelineError`].
622    fn embed_in_memory(
623        &self,
624        payload_input: &mut dyn Read,
625        cover_input: &mut dyn Read,
626        output: &mut dyn Write,
627        technique: &dyn EmbedTechnique,
628    ) -> Result<(), OpsecError>;
629}
630
631/// Geographic threshold distribution port.
632///
633/// Annotates shards with jurisdictional metadata, producing a
634/// [`GeographicManifest`] that makes legal compulsion across jurisdictions
635/// impractical.
636pub trait GeographicDistributor {
637    /// Distribute `payload` across `covers` and annotate each shard with
638    /// jurisdictional metadata from `manifest`.
639    ///
640    /// # Errors
641    /// Returns [`OpsecError::ManifestError`].
642    fn distribute_with_manifest(
643        &self,
644        payload: &Payload,
645        covers: Vec<CoverMedia>,
646        manifest: &GeographicManifest,
647        embedder: &dyn EmbedTechnique,
648    ) -> Result<Vec<CoverMedia>, OpsecError>;
649}
650
651// ─── Canary Shards ────────────────────────────────────────────────────────────
652
653/// Canary shard tripwire port.
654pub trait CanaryService {
655    /// Embed an additional canary shard in `covers` alongside the regular
656    /// distribution.
657    ///
658    /// Returns the modified covers and the [`CanaryShard`] to be planted in
659    /// a honeypot location.
660    ///
661    /// # Errors
662    /// Returns [`CanaryError::NoCovers`] or [`CanaryError::EmbedFailed`].
663    fn embed_canary(
664        &self,
665        covers: Vec<CoverMedia>,
666        embedder: &dyn EmbedTechnique,
667    ) -> Result<(Vec<CoverMedia>, CanaryShard), CanaryError>;
668
669    /// Return `true` if the `shard`'s notify URL is reachable, indicating
670    /// the canary has been accessed.
671    ///
672    /// Non-blocking check; returns `false` on network error.
673    fn check_canary(&self, shard: &CanaryShard) -> bool;
674}
675
676// ─── Dead Drop ────────────────────────────────────────────────────────────────
677
678/// Platform-aware dead drop encoder port.
679///
680/// Produces a stego cover optimised for posting publicly on a target
681/// platform. No direct file transfer between parties.
682pub trait DeadDropEncoder {
683    /// Encode `payload` into `cover` for posting on `platform`.
684    ///
685    /// The resulting cover survives the platform's recompression pipeline
686    /// and can be retrieved by the recipient via public URL.
687    ///
688    /// # Errors
689    /// Returns [`DeadDropError::UnsupportedPlatform`] or
690    /// [`DeadDropError::EncodeFailed`].
691    fn encode_for_platform(
692        &self,
693        cover: CoverMedia,
694        payload: &Payload,
695        platform: &PlatformProfile,
696        embedder: &dyn EmbedTechnique,
697    ) -> Result<CoverMedia, DeadDropError>;
698}
699
700// ─── Time-Lock ────────────────────────────────────────────────────────────────
701
702/// Rivest sequential-squaring time-lock puzzle port.
703///
704/// A payload cannot be decrypted before a specified time, even under
705/// compulsion.
706pub trait TimeLockService {
707    /// Wrap `payload` in a time-lock puzzle that cannot be solved before
708    /// `unlock_at`.
709    ///
710    /// # Errors
711    /// Returns [`TimeLockError::ComputationFailed`].
712    fn lock(
713        &self,
714        payload: &Payload,
715        unlock_at: DateTime<Utc>,
716    ) -> Result<TimeLockPuzzle, TimeLockError>;
717
718    /// Solve the `puzzle` by sequential squaring and decrypt the payload.
719    ///
720    /// Blocks until the puzzle is solved; may take significant time.
721    ///
722    /// # Errors
723    /// Returns [`TimeLockError::ComputationFailed`] or
724    /// [`TimeLockError::DecryptFailed`].
725    fn unlock(&self, puzzle: &TimeLockPuzzle) -> Result<Payload, TimeLockError>;
726
727    /// Non-blocking puzzle check. Returns `Ok(Some(payload))` if the puzzle
728    /// is already solved, `Ok(None)` if it cannot yet be solved, or an error
729    /// if the computation itself fails.
730    ///
731    /// # Errors
732    /// Returns [`TimeLockError::ComputationFailed`] or
733    /// [`TimeLockError::DecryptFailed`].
734    fn try_unlock(&self, puzzle: &TimeLockPuzzle) -> Result<Option<Payload>, TimeLockError>;
735}
736
737// ─── Stylometric Scrubber ─────────────────────────────────────────────────────
738
739/// Linguistic stylometric fingerprint scrubbing port.
740///
741/// Normalises text to destroy authorship attribution fingerprints without
742/// changing the semantic content.
743pub trait StyloScrubber {
744    /// Scrub `text` to match `profile`.
745    ///
746    /// # Errors
747    /// Returns [`ScrubberError::InvalidUtf8`] or
748    /// [`ScrubberError::ProfileNotSatisfied`].
749    fn scrub(&self, text: &str, profile: &StyloProfile) -> Result<String, ScrubberError>;
750}
751
752// ─── Corpus Steganography ─────────────────────────────────────────────────────
753
754/// Corpus index and zero-modification cover selection port.
755pub trait CorpusIndex {
756    /// Search the index for covers whose natural bit pattern already encodes
757    /// (or closely encodes) `payload` using `technique`.
758    ///
759    /// Returns up to `max_results` entries sorted by match quality.
760    ///
761    /// # Errors
762    /// Returns [`crate::domain::errors::CorpusError::NoSuitableCover`] or
763    /// [`crate::domain::errors::CorpusError::IndexError`].
764    fn search(
765        &self,
766        payload: &Payload,
767        technique: StegoTechnique,
768        max_results: usize,
769    ) -> Result<Vec<crate::domain::types::CorpusEntry>, crate::domain::errors::CorpusError>;
770
771    /// Add the file at `path` to the index, computing its bit-pattern
772    /// fingerprint.
773    ///
774    /// # Errors
775    /// Returns [`crate::domain::errors::CorpusError::AddFailed`].
776    fn add_to_index(
777        &self,
778        path: &Path,
779    ) -> Result<crate::domain::types::CorpusEntry, crate::domain::errors::CorpusError>;
780
781    /// Scan `corpus_dir` recursively and build the full index.
782    ///
783    /// Returns the number of entries indexed.
784    ///
785    /// # Errors
786    /// Returns [`crate::domain::errors::CorpusError::IndexError`].
787    fn build_index(&self, corpus_dir: &Path) -> Result<usize, crate::domain::errors::CorpusError>;
788
789    /// Search the index for covers that match `payload` and have a
790    /// `spectral_key` whose `model_id` and `resolution` match the supplied
791    /// values.
792    ///
793    /// Returns up to `max_results` entries sorted by match quality.
794    ///
795    /// # Errors
796    /// Returns [`crate::domain::errors::CorpusError::NoSuitableCover`] or
797    /// [`crate::domain::errors::CorpusError::IndexError`].
798    fn search_for_model(
799        &self,
800        payload: &Payload,
801        model_id: &str,
802        resolution: (u32, u32),
803        max_results: usize,
804    ) -> Result<Vec<crate::domain::types::CorpusEntry>, crate::domain::errors::CorpusError>;
805
806    /// Return a sorted list of `(SpectralKey, count)` pairs describing how
807    /// many corpus entries are indexed per model/resolution combination.
808    ///
809    /// Using `Vec` instead of `HashMap` to keep the trait object-safe.
810    fn model_stats(&self) -> Vec<(crate::domain::types::SpectralKey, usize)>;
811}
812
813// ─── Object-Safety Assertions ─────────────────────────────────────────────────
814//
815// These compile-time checks verify that every port trait is object-safe.
816// If any trait is accidentally made non-object-safe (e.g. by adding a
817// method with a generic parameter), this module will fail to compile with
818// a clear error pointing at the offending trait.
819
820#[cfg(test)]
821mod object_safety_tests {
822    use super::*;
823
824    /// Verifies object safety of all port traits.
825    #[test]
826    fn all_port_traits_are_object_safe() {
827        fn assert_object_safe<T: ?Sized>() {}
828
829        assert_object_safe::<dyn Encryptor>();
830        assert_object_safe::<dyn Signer>();
831        assert_object_safe::<dyn SymmetricCipher>();
832        assert_object_safe::<dyn ErrorCorrector>();
833        assert_object_safe::<dyn EmbedTechnique>();
834        assert_object_safe::<dyn ExtractTechnique>();
835        assert_object_safe::<dyn MediaLoader>();
836        assert_object_safe::<dyn PdfProcessor>();
837        assert_object_safe::<dyn Distributor>();
838        assert_object_safe::<dyn Reconstructor>();
839        assert_object_safe::<dyn CapacityAnalyser>();
840        assert_object_safe::<dyn ArchiveHandler>();
841        assert_object_safe::<dyn AdaptiveOptimiser>();
842        assert_object_safe::<dyn CoverProfileMatcher>();
843        assert_object_safe::<dyn CompressionSimulator>();
844        assert_object_safe::<dyn DeniableEmbedder>();
845        assert_object_safe::<dyn PanicWiper>();
846        assert_object_safe::<dyn ForensicWatermarker>();
847        assert_object_safe::<dyn AmnesiaPipeline>();
848        assert_object_safe::<dyn GeographicDistributor>();
849        assert_object_safe::<dyn CanaryService>();
850        assert_object_safe::<dyn DeadDropEncoder>();
851        assert_object_safe::<dyn TimeLockService>();
852        assert_object_safe::<dyn StyloScrubber>();
853        assert_object_safe::<dyn CorpusIndex>();
854    }
855}
856
857#[cfg(test)]
858mod cover_profile_tests {
859    use super::*;
860
861    type TestResult = Result<(), Box<dyn std::error::Error>>;
862
863    #[test]
864    fn camera_profile_model_id_via_cover_profile() {
865        let profile = CoverProfile::Camera(CameraProfile {
866            quantisation_table: [0u16; 64],
867            noise_floor_db: -80.0,
868            model_id: "canon-eos-r6".to_string(),
869        });
870        assert_eq!(profile.model_id(), "canon-eos-r6");
871    }
872
873    #[test]
874    fn ai_gen_profile_model_id_via_cover_profile() {
875        let profile = CoverProfile::AiGenerator(AiGenProfile {
876            model_id: "gemini".to_string(),
877            channel_weights: [0.85, 1.0, 0.70],
878            carrier_map: HashMap::new(),
879        });
880        assert_eq!(profile.model_id(), "gemini");
881    }
882
883    #[test]
884    fn carrier_bin_coherence_clamped_above_one() {
885        let bin = CarrierBin::new((9, 9), 0.0, 1.5);
886        assert!((bin.coherence() - 1.0).abs() < f64::EPSILON);
887    }
888
889    #[test]
890    fn carrier_bin_coherence_clamped_below_zero() {
891        let bin = CarrierBin::new((9, 9), 0.0, -0.5);
892        assert!((bin.coherence() - 0.0).abs() < f64::EPSILON);
893    }
894
895    #[test]
896    fn carrier_bin_is_strong_at_exactly_0_90() {
897        let strong = CarrierBin::new((9, 9), 0.0, 0.90);
898        assert!(strong.is_strong());
899    }
900
901    #[test]
902    fn carrier_bin_not_strong_below_0_90() {
903        let weak = CarrierBin::new((9, 9), 0.0, 0.899_999);
904        assert!(!weak.is_strong());
905    }
906
907    #[test]
908    fn ai_gen_profile_carrier_bins_for_known_resolution() {
909        let bins = vec![CarrierBin::new((9, 9), 0.0, 1.0)];
910        let mut carrier_map = HashMap::new();
911        carrier_map.insert("1024x1024".to_string(), bins);
912        let profile = AiGenProfile {
913            model_id: "gemini".to_string(),
914            channel_weights: [0.85, 1.0, 0.70],
915            carrier_map,
916        };
917        assert!(profile.carrier_bins_for(1024, 1024).is_some());
918        assert!(profile.carrier_bins_for(512, 512).is_none());
919    }
920
921    #[test]
922    fn ai_gen_profile_carrier_bins_count() {
923        let bins = vec![
924            CarrierBin::new((9, 9), 0.0, 1.0),
925            CarrierBin::new((5, 5), 0.0, 1.0),
926        ];
927        let mut carrier_map = HashMap::new();
928        carrier_map.insert("1024x1024".to_string(), bins);
929        let profile = AiGenProfile {
930            model_id: "gemini".to_string(),
931            channel_weights: [0.85, 1.0, 0.70],
932            carrier_map,
933        };
934        assert_eq!(
935            profile.carrier_bins_for(1024, 1024).map(<[_]>::len),
936            Some(2)
937        );
938    }
939
940    #[test]
941    fn cover_profile_round_trips_through_serde_json() -> TestResult {
942        let profile = CoverProfile::AiGenerator(AiGenProfile {
943            model_id: "gemini".to_string(),
944            channel_weights: [0.85, 1.0, 0.70],
945            carrier_map: HashMap::new(),
946        });
947        let json = serde_json::to_string(&profile)?;
948        let decoded: CoverProfile = serde_json::from_str(&json)?;
949        assert_eq!(decoded.model_id(), "gemini");
950        Ok(())
951    }
952
953    #[test]
954    fn camera_cover_profile_round_trips_through_serde_json() -> TestResult {
955        let profile = CoverProfile::Camera(CameraProfile {
956            quantisation_table: [2u16; 64],
957            noise_floor_db: -75.0,
958            model_id: "nikon-z9".to_string(),
959        });
960        let json = serde_json::to_string(&profile)?;
961        let decoded: CoverProfile = serde_json::from_str(&json)?;
962        assert_eq!(decoded.model_id(), "nikon-z9");
963        Ok(())
964    }
965}