Skip to main content

shadowforge_lib/interface/
runner.rs

1//! CLI runner — dispatches parsed commands to application services.
2
3use std::io::{self, Read, Write};
4use std::path::{Path, PathBuf};
5
6/// Maximum payload size accepted from stdin (256 MiB).
7const MAX_STDIN_PAYLOAD: u64 = 256 * 1024 * 1024;
8
9use clap::Parser;
10
11use crate::application::services::{
12    AnalyseService, AppError, ArchiveService, CipherService, CorpusService, EmbedService,
13    ExtractService, KeyGenService, ScrubService,
14};
15use crate::domain::errors::{CanaryError, OpsecError, StegoError};
16use crate::domain::ports::{EmbedTechnique, ExtractTechnique, MediaLoader};
17use crate::domain::types::{CoverMedia, CoverMediaKind, Payload, Signature, StegoTechnique};
18
19use super::cli::{self, Cli, Commands};
20
21/// Run the CLI. Returns `Ok(())` on success.
22///
23/// # Errors
24/// Returns [`AppError`] on any service failure.
25pub fn run() -> Result<(), AppError> {
26    let cli = Cli::parse();
27    dispatch(cli)
28}
29
30/// Dispatch a parsed CLI to the appropriate handler.
31///
32/// # Errors
33/// Returns [`AppError`] on any service failure.
34pub fn dispatch(cli: Cli) -> Result<(), AppError> {
35    match cli.command {
36        Commands::Version => {
37            print_version();
38            Ok(())
39        }
40        Commands::Keygen(args) => cmd_keygen(&args),
41        Commands::Embed(args) => cmd_embed(&args),
42        Commands::Extract(args) => cmd_extract(&args),
43        Commands::EmbedDistributed(args) => cmd_embed_distributed(&args),
44        Commands::ExtractDistributed(args) => cmd_extract_distributed(&args),
45        Commands::Analyse(args) => cmd_analyse(&args),
46        Commands::Archive(args) => cmd_archive(&args),
47        Commands::Scrub(args) => cmd_scrub(&args),
48        Commands::DeadDrop(args) => cmd_dead_drop(&args),
49        Commands::TimeLock(args) => cmd_time_lock(&args),
50        Commands::Watermark(args) => cmd_watermark(&args),
51        Commands::Corpus(args) => cmd_corpus(&args),
52        Commands::Panic(args) => cmd_panic(&args),
53        Commands::Completions(args) => cmd_completions(&args),
54        Commands::Cipher(args) => cmd_cipher(&args),
55    }
56}
57
58fn print_version() {
59    let version = env!("CARGO_PKG_VERSION");
60    let sha = option_env!("VERGEN_GIT_SHA").unwrap_or("unknown");
61    println!("shadowforge {version} ({sha})");
62}
63
64// ─── Keygen ───────────────────────────────────────────────────────────────────
65
66fn cmd_keygen(args: &cli::KeygenArgs) -> Result<(), AppError> {
67    match &args.subcmd {
68        Some(cli::KeygenSubcommand::Sign {
69            input,
70            secret_key,
71            output,
72        }) => cmd_keygen_sign(input, secret_key, output),
73        Some(cli::KeygenSubcommand::Verify {
74            input,
75            public_key,
76            signature,
77        }) => cmd_keygen_verify(input, public_key, signature),
78        None => cmd_keygen_generate(args),
79    }
80}
81
82fn cmd_keygen_generate(args: &cli::KeygenArgs) -> Result<(), AppError> {
83    let Some(dir) = args.output.as_ref() else {
84        return Err(AppError::Stego(StegoError::MalformedCoverData {
85            reason: "--output is required when no keygen subcommand is used".to_string(),
86        }));
87    };
88    let Some(algorithm) = args.algorithm else {
89        return Err(AppError::Stego(StegoError::MalformedCoverData {
90            reason: "--algorithm is required when no keygen subcommand is used".to_string(),
91        }));
92    };
93
94    fs_create_dir_all(dir)?;
95
96    match algorithm {
97        cli::Algorithm::Kyber1024 => {
98            let enc = crate::adapters::crypto::MlKemEncryptor;
99            let kp = KeyGenService::generate_keypair(&enc)?;
100            fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
101            fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
102        }
103        cli::Algorithm::Dilithium3 => {
104            let signer = crate::adapters::crypto::MlDsaSigner;
105            let kp = KeyGenService::generate_signing_keypair(&signer)?;
106            fs_write(&dir.join("public.key"), kp.public_key.as_ref())?;
107            fs_write(&dir.join("secret.key"), kp.secret_key.as_ref())?;
108        }
109    }
110    eprintln!("Keys written to {}", dir.display());
111    Ok(())
112}
113
114fn cmd_keygen_sign(input: &Path, secret_key: &Path, output: &Path) -> Result<(), AppError> {
115    let signer = crate::adapters::crypto::MlDsaSigner;
116    let message = fs_read(input)?;
117    let sk = fs_read(secret_key)?;
118    let signature = KeyGenService::sign(&signer, &sk, &message)?;
119    fs_write(output, signature.0.as_ref())?;
120    eprintln!("Signature written to {}", output.display());
121    Ok(())
122}
123
124fn cmd_keygen_verify(input: &Path, public_key: &Path, signature: &Path) -> Result<(), AppError> {
125    let signer = crate::adapters::crypto::MlDsaSigner;
126    let message = fs_read(input)?;
127    let pk = fs_read(public_key)?;
128    let sig = Signature(bytes::Bytes::from(fs_read(signature)?));
129    let valid = KeyGenService::verify(&signer, &pk, &message, &sig)?;
130    if valid {
131        eprintln!("Signature verification: ok");
132        Ok(())
133    } else {
134        Err(AppError::Crypto(
135            crate::domain::errors::CryptoError::VerificationFailed {
136                reason: "invalid signature".to_string(),
137            },
138        ))
139    }
140}
141
142// ─── Embed ────────────────────────────────────────────────────────────────────
143
144fn cmd_embed(args: &cli::EmbedArgs) -> Result<(), AppError> {
145    let technique = resolve_technique(args.technique);
146    let embedder = build_embedder(technique);
147
148    let payload_bytes = fs_read(&args.input)?;
149    let mut payload = Payload::from_bytes(payload_bytes);
150
151    if args.scrub_style {
152        let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
153        let profile = crate::domain::types::StyloProfile {
154            normalize_punctuation: true,
155            target_avg_sentence_len: 15.0,
156            target_vocab_size: 1000,
157        };
158        let text = String::from_utf8_lossy(payload.as_bytes());
159        let result = ScrubService::scrub(&text, &profile, &scrubber)?;
160        payload = Payload::from_bytes(result.into_bytes());
161    }
162
163    if args.amnesia {
164        let pipeline = crate::adapters::opsec::AmnesiaPipelineImpl::new();
165        let mut payload_cursor = io::Cursor::new(payload.as_bytes().to_vec());
166        let mut cover_input = io::stdin().lock();
167        let mut output = io::stdout().lock();
168        crate::application::services::AmnesiaPipelineService::embed_in_memory(
169            &mut payload_cursor,
170            &mut cover_input,
171            &mut output,
172            embedder.as_ref(),
173            &pipeline,
174        )?;
175    } else if args.deniable {
176        let cover_path = args.cover.as_ref().ok_or_else(|| {
177            crate::domain::errors::DeniableError::EmbedFailed {
178                reason: "--cover is required for deniable embedding".into(),
179            }
180        })?;
181        let cover = load_cover_from_path(cover_path, technique)?;
182
183        let decoy_path = args.decoy_payload.as_ref().ok_or_else(|| {
184            crate::domain::errors::DeniableError::EmbedFailed {
185                reason: "--decoy-payload is required for deniable embedding".into(),
186            }
187        })?;
188        let decoy_bytes = fs_read(decoy_path)?;
189        let primary_key = match &args.key {
190            Some(p) => fs_read(p)?,
191            None => vec![0u8; 32],
192        };
193        let decoy_key = match &args.decoy_key {
194            Some(p) => fs_read(p)?,
195            None => vec![1u8; 32],
196        };
197        let pair = crate::domain::types::DeniablePayloadPair {
198            real_payload: payload.as_bytes().to_vec(),
199            decoy_payload: decoy_bytes,
200        };
201        let keys = crate::domain::types::DeniableKeySet {
202            primary_key,
203            decoy_key,
204        };
205        let deniable = crate::adapters::stego::DualPayloadEmbedder;
206        let stego = crate::application::services::DeniableEmbedService::embed_dual(
207            cover,
208            &pair,
209            &keys,
210            embedder.as_ref(),
211            &deniable,
212        )?;
213        save_cover_to_path(&stego, &args.output)?;
214        eprintln!("Deniable embedding complete");
215    } else {
216        // Note: profile and platform only apply to distributed embedding.
217        // Single-cover embedding uses the technique directly without profile constraints.
218        if matches!(args.profile, cli::Profile::Standard) {
219            // Standard profile: silent
220        } else {
221            eprintln!(
222                "Note: --profile {:?} only applies to distributed embedding; \
223                 single-cover uses technique directly",
224                args.profile
225            );
226        }
227
228        let cover_path = args.cover.as_ref().ok_or_else(|| {
229            crate::domain::errors::StegoError::MalformedCoverData {
230                reason: "--cover is required when not using --amnesia".into(),
231            }
232        })?;
233        let cover = load_cover_from_path(cover_path, technique)?;
234        let stego = EmbedService::embed(cover, &payload, embedder.as_ref())?;
235        save_cover_to_path(&stego, &args.output)?;
236        eprintln!("Embedded {} bytes", payload.len());
237    }
238    Ok(())
239}
240
241// ─── Extract ──────────────────────────────────────────────────────────────────
242
243fn cmd_extract(args: &cli::ExtractArgs) -> Result<(), AppError> {
244    let technique = resolve_technique(args.technique);
245    let extractor = build_extractor(technique);
246    let deniable_key = match &args.key {
247        Some(path) => Some(fs_read(path)?),
248        None => None,
249    };
250
251    if args.amnesia {
252        let mut buf = Vec::new();
253        io::stdin()
254            .lock()
255            .take(MAX_STDIN_PAYLOAD)
256            .read_to_end(&mut buf)
257            .map_err(|e| crate::domain::errors::StegoError::MalformedCoverData {
258                reason: format!("stdin read: {e}"),
259            })?;
260        let cover = load_cover(technique, &buf);
261        let payload = if let Some(key) = deniable_key.as_deref() {
262            let deniable = crate::adapters::stego::DualPayloadEmbedder;
263            crate::application::services::DeniableEmbedService::extract_with_key(
264                &cover,
265                key,
266                extractor.as_ref(),
267                &deniable,
268            )?
269        } else {
270            ExtractService::extract(&cover, extractor.as_ref())?
271        };
272        io::stdout().write_all(payload.as_bytes()).map_err(|e| {
273            crate::domain::errors::StegoError::MalformedCoverData {
274                reason: format!("stdout write: {e}"),
275            }
276        })?;
277    } else {
278        let stego = load_cover_from_path(&args.input, technique)?;
279        let payload = if let Some(key) = deniable_key.as_deref() {
280            let deniable = crate::adapters::stego::DualPayloadEmbedder;
281            crate::application::services::DeniableEmbedService::extract_with_key(
282                &stego,
283                key,
284                extractor.as_ref(),
285                &deniable,
286            )?
287        } else {
288            ExtractService::extract(&stego, extractor.as_ref())?
289        };
290        fs_write(&args.output, payload.as_bytes())?;
291        eprintln!("Extracted {} bytes", payload.len());
292    }
293    Ok(())
294}
295
296// ─── Embed-distributed ────────────────────────────────────────────────────────
297
298fn cmd_embed_distributed(args: &cli::EmbedDistributedArgs) -> Result<(), AppError> {
299    let technique = resolve_technique(args.technique);
300    let embedder = build_embedder(technique);
301
302    let payload_bytes = fs_read(&args.input)?;
303    let payload = Payload::from_bytes(payload_bytes);
304
305    let cover_paths = collect_cover_paths(&args.covers)?;
306    let covers: Result<Vec<CoverMedia>, AppError> = cover_paths
307        .iter()
308        .map(|path| load_cover_from_path(path, technique))
309        .collect();
310    let covers = covers?;
311
312    let profile = resolve_profile(args.profile, args.platform);
313    let (mut stego_covers, generated_hmac_key) =
314        distribute_covers(args, &payload, covers, &profile, embedder.as_ref())?;
315
316    // Embed canary shard if requested
317    let canary_metadata = if args.canary {
318        let canary_impl = crate::adapters::canary::CanaryServiceImpl::new(64, 5);
319        let (covers_with_canary, shard) =
320            crate::application::services::CanaryShardService::embed_canary(
321                stego_covers,
322                embedder.as_ref(),
323                &canary_impl,
324            )?;
325        stego_covers = covers_with_canary;
326        Some(shard)
327    } else {
328        None
329    };
330
331    let files: Vec<(String, Vec<u8>)> = stego_covers
332        .iter()
333        .enumerate()
334        .map(|(i, cover)| {
335            Ok((
336                format!("shard_{i:04}.{}", cover_file_extension(cover.kind)),
337                serialise_cover_to_bytes(cover)?,
338            ))
339        })
340        .collect::<Result<_, AppError>>()?;
341    let file_refs: Vec<(&str, &[u8])> = files
342        .iter()
343        .map(|(n, d)| (n.as_str(), d.as_slice()))
344        .collect();
345    let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
346    let archive = ArchiveService::pack(
347        &file_refs,
348        crate::domain::types::ArchiveFormat::Zip,
349        &handler,
350    )?;
351    fs_write(&args.output_archive, &archive)?;
352
353    // Persist HMAC key so extract-distributed can use it
354    if let Some(hmac_key) = generated_hmac_key {
355        let key_path = args.output_archive.with_extension("hmac");
356        fs_write(&key_path, &hmac_key)?;
357        eprintln!("HMAC key written to {}", key_path.display());
358    }
359
360    // Persist canary metadata if embedded
361    if let Some(canary_shard) = canary_metadata {
362        let canary_path = args.output_archive.with_extension("canary");
363        let canary_json = serde_json::to_string_pretty(&canary_shard).map_err(|e| {
364            let stego_err = StegoError::MalformedCoverData {
365                reason: format!("Failed to serialize canary metadata: {e}"),
366            };
367            AppError::Canary(CanaryError::EmbedFailed { source: stego_err })
368        })?;
369        fs_write(&canary_path, canary_json.as_bytes())?;
370        eprintln!("Canary metadata written to {}", canary_path.display());
371    }
372
373    eprintln!(
374        "Distributed into {} shards → {}",
375        stego_covers.len(),
376        args.output_archive.display()
377    );
378    Ok(())
379}
380
381fn distribute_covers(
382    args: &cli::EmbedDistributedArgs,
383    payload: &Payload,
384    covers: Vec<CoverMedia>,
385    profile: &crate::domain::types::EmbeddingProfile,
386    embedder: &dyn EmbedTechnique,
387) -> Result<(Vec<CoverMedia>, Option<Vec<u8>>), AppError> {
388    if let Some(manifest_path) = &args.geo_manifest {
389        let manifest = load_geographic_manifest(manifest_path)?;
390        let geo_distributor = crate::adapters::opsec::GeographicDistributorImpl::new();
391        let stego_covers =
392            crate::application::services::DistributeService::distribute_with_geographic_manifest(
393                payload,
394                covers,
395                &manifest,
396                embedder,
397                &geo_distributor,
398            )?;
399        return Ok((stego_covers, None));
400    }
401
402    let hmac_key = if let Some(p) = &args.hmac_key {
403        fs_read(p)?
404    } else {
405        crate::adapters::distribution::DistributorImpl::generate_hmac_key()
406    };
407    let generated_hmac_key = if args.hmac_key.is_none() {
408        Some(hmac_key.clone())
409    } else {
410        None
411    };
412    let corrector_for_dist: Box<dyn crate::domain::ports::ErrorCorrector> = Box::new(
413        crate::adapters::correction::RsErrorCorrector::new(hmac_key.clone()),
414    );
415    let distributor = crate::adapters::distribution::DistributorImpl::new_with_shard_config(
416        hmac_key,
417        args.data_shards,
418        args.parity_shards,
419        corrector_for_dist,
420    );
421    let (matcher, optimiser, compressor) = crate::adapters::adaptive::build_adaptive_profile_deps();
422
423    let stego_covers =
424        crate::application::services::DistributeService::distribute_with_profile_hardening(
425            payload,
426            covers,
427            profile,
428            &distributor,
429            embedder,
430            &crate::application::services::AdaptiveProfileDeps {
431                matcher: &matcher,
432                optimiser: &optimiser,
433                compressor: &compressor,
434            },
435        )?;
436    Ok((stego_covers, generated_hmac_key))
437}
438
439fn load_geographic_manifest(
440    manifest_path: &Path,
441) -> Result<crate::domain::types::GeographicManifest, AppError> {
442    let manifest_raw = fs_read(manifest_path)?;
443    let manifest_str = String::from_utf8(manifest_raw).map_err(|e| OpsecError::ManifestError {
444        reason: format!("manifest is not valid UTF-8: {e}"),
445    })?;
446    toml::from_str(&manifest_str).map_err(|e| {
447        OpsecError::ManifestError {
448            reason: format!("manifest parse failed: {e}"),
449        }
450        .into()
451    })
452}
453
454// ─── Extract-distributed ──────────────────────────────────────────────────────
455
456fn cmd_extract_distributed(args: &cli::ExtractDistributedArgs) -> Result<(), AppError> {
457    let technique = resolve_technique(args.technique);
458    let extractor = build_extractor(technique);
459
460    let archive_bytes = fs_read(&args.input_archive)?;
461    let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
462    let entries = ArchiveService::unpack(
463        &archive_bytes,
464        crate::domain::types::ArchiveFormat::Zip,
465        &handler,
466    )?;
467
468    let covers: Vec<CoverMedia> = entries
469        .iter()
470        .map(|(name, data)| load_cover_from_named_bytes(name, technique, data))
471        .collect::<Result<_, AppError>>()?;
472
473    let hmac_key = if let Some(p) = &args.hmac_key {
474        fs_read(p)?
475    } else {
476        // Try the default location next to the archive
477        let default_path = args.input_archive.with_extension("hmac");
478        fs_read(&default_path)?
479    };
480    let corrector_for_recon: Box<dyn crate::domain::ports::ErrorCorrector> = Box::new(
481        crate::adapters::correction::RsErrorCorrector::new(hmac_key.clone()),
482    );
483    let reconstructor = crate::adapters::reconstruction::ReconstructorImpl::new(
484        args.data_shards,
485        args.parity_shards,
486        0,
487        corrector_for_recon,
488    );
489    let payload = crate::application::services::ReconstructService::reconstruct(
490        covers,
491        extractor.as_ref(),
492        &reconstructor,
493        &|done, total| eprintln!("Reconstructing: {done}/{total}"),
494    )?;
495    fs_write(&args.output, payload.as_bytes())?;
496    eprintln!("Reconstructed {} bytes", payload.len());
497    Ok(())
498}
499
500// ─── Analyse ──────────────────────────────────────────────────────────────────
501
502/// Format an `AnalysisReport` as a human-readable string.
503/// Exposed for unit testing.
504pub(crate) fn format_analysis_report(report: &crate::domain::types::AnalysisReport) -> String {
505    use std::fmt::Write as _;
506    let mut out = String::new();
507    let _ = writeln!(out, "Technique:     {:?}", report.technique);
508    let _ = writeln!(out, "Capacity:      {} bytes", report.cover_capacity.bytes);
509    let _ = writeln!(out, "Chi-square:    {:.2} dB", report.chi_square_score);
510    let _ = writeln!(out, "Risk:          {:?}", report.detectability_risk);
511    let _ = writeln!(
512        out,
513        "Recommended:   {} bytes",
514        report.recommended_max_payload_bytes
515    );
516    if let Some(ai) = &report.ai_watermark {
517        let _ = writeln!(out, "--- AI Watermark Detection ---");
518        let _ = writeln!(
519            out,
520            "  Detected:             {}",
521            if ai.detected { "yes" } else { "no" }
522        );
523        if let Some(model_id) = &ai.model_id {
524            let _ = writeln!(out, "  Model:                {model_id}");
525        }
526        if ai.total_strong_bins > 0 {
527            let _ = writeln!(out, "  Confidence:           {:.4}", ai.confidence);
528            let _ = writeln!(
529                out,
530                "  Matched strong bins:  {}/{}",
531                ai.matched_strong_bins, ai.total_strong_bins
532            );
533        } else {
534            let _ = writeln!(out, "  Status:               no known profile match");
535        }
536    }
537    if let Some(s) = &report.spectral_score {
538        let _ = writeln!(out, "--- Spectral Detectability ---");
539        let _ = writeln!(out, "  Phase coherence drop: {:.4}", s.phase_coherence_drop);
540        let _ = writeln!(
541            out,
542            "  Carrier SNR drop:     {:.2} dB",
543            s.carrier_snr_drop_db
544        );
545        let _ = writeln!(
546            out,
547            "  Sample-pair asymmetry:{:.4}",
548            s.sample_pair_asymmetry
549        );
550        let _ = writeln!(out, "  Spectral risk:        {:?}", s.combined_risk);
551    }
552    out
553}
554
555fn cmd_analyse(args: &cli::AnalyseArgs) -> Result<(), AppError> {
556    let technique = resolve_technique(args.technique);
557    let cover = load_cover_from_path(&args.cover, technique)?;
558    let analyser = crate::adapters::analysis::CapacityAnalyserImpl::new();
559    let report = AnalyseService::analyse(&cover, technique, &analyser)?;
560
561    if args.json {
562        let json = serde_json::to_string_pretty(&report).map_err(|e| {
563            crate::domain::errors::AnalysisError::ComputationFailed {
564                reason: format!("json serialisation: {e}"),
565            }
566        })?;
567        println!("{json}");
568    } else {
569        print!("{}", format_analysis_report(&report));
570    }
571    Ok(())
572}
573
574// ─── Archive ──────────────────────────────────────────────────────────────────
575
576fn cmd_archive(args: &cli::ArchiveArgs) -> Result<(), AppError> {
577    match &args.subcmd {
578        cli::ArchiveSubcommand::Pack {
579            files,
580            format,
581            output,
582        } => {
583            let file_data: Result<Vec<(String, Vec<u8>)>, AppError> = files
584                .iter()
585                .map(|p| {
586                    let name = p.file_name().map_or_else(
587                        || p.display().to_string(),
588                        |n| n.to_string_lossy().into_owned(),
589                    );
590                    let data = fs_read(p)?;
591                    Ok((name, data))
592                })
593                .collect();
594            let file_data = file_data?;
595            let refs: Vec<(&str, &[u8])> = file_data
596                .iter()
597                .map(|(n, d)| (n.as_str(), d.as_slice()))
598                .collect();
599            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
600            let fmt = resolve_archive_format(*format);
601            let packed = ArchiveService::pack(&refs, fmt, &handler)?;
602            fs_write(output, &packed)?;
603            eprintln!("Packed {} files → {}", files.len(), output.display());
604        }
605        cli::ArchiveSubcommand::Unpack {
606            input,
607            format,
608            output_dir,
609        } => {
610            let data = fs_read(input)?;
611            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
612            let fmt = resolve_archive_format(*format);
613            let entries = ArchiveService::unpack(&data, fmt, &handler)?;
614            fs_create_dir_all(output_dir)?;
615            for (name, content) in &entries {
616                let path = output_dir.join(name);
617                if let Some(parent) = path.parent() {
618                    fs_create_dir_all(parent)?;
619                }
620                fs_write(&path, content.as_ref())?;
621            }
622            eprintln!(
623                "Unpacked {} entries → {}",
624                entries.len(),
625                output_dir.display()
626            );
627        }
628    }
629    Ok(())
630}
631
632// ─── Scrub ────────────────────────────────────────────────────────────────────
633
634fn cmd_scrub(args: &cli::ScrubArgs) -> Result<(), AppError> {
635    let text = std::fs::read_to_string(&args.input).map_err(|e| {
636        crate::domain::errors::ScrubberError::InvalidUtf8 {
637            reason: format!("read: {e}"),
638        }
639    })?;
640    let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
641    let profile = crate::domain::types::StyloProfile {
642        normalize_punctuation: true,
643        target_avg_sentence_len: f64::from(args.avg_sentence_len),
644        target_vocab_size: args.vocab_size,
645    };
646    let result = ScrubService::scrub(&text, &profile, &scrubber)?;
647    fs_write(&args.output, result.as_bytes())?;
648    eprintln!("Scrubbed text → {}", args.output.display());
649    Ok(())
650}
651
652// ─── Dead Drop ────────────────────────────────────────────────────────────────
653
654fn cmd_dead_drop(args: &cli::DeadDropArgs) -> Result<(), AppError> {
655    let technique = resolve_technique(args.technique);
656    let embedder = build_embedder(technique);
657    let cover = load_cover_from_path(&args.cover, technique)?;
658    let payload_bytes = fs_read(&args.input)?;
659    let payload = Payload::from_bytes(payload_bytes);
660    let platform = resolve_platform(args.platform);
661    let encoder = crate::adapters::deadrop::DeadDropEncoderImpl::new();
662    let stego = crate::application::services::DeadDropService::encode(
663        cover,
664        &payload,
665        &platform,
666        embedder.as_ref(),
667        &encoder,
668    )?;
669    save_cover_to_path(&stego, &args.output)?;
670    eprintln!("Dead drop encoded for {platform:?}");
671    Ok(())
672}
673
674// ─── Time Lock ────────────────────────────────────────────────────────────────
675
676fn cmd_time_lock(args: &cli::TimeLockArgs) -> Result<(), AppError> {
677    let service = crate::adapters::timelock::TimeLockServiceImpl::default();
678    match &args.subcmd {
679        cli::TimeLockSubcommand::Lock {
680            input,
681            unlock_at,
682            output_puzzle,
683        } => {
684            let data = fs_read(input)?;
685            let payload = Payload::from_bytes(data);
686            let ts = chrono::DateTime::parse_from_rfc3339(unlock_at)
687                .map_err(
688                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
689                        reason: format!("invalid RFC 3339 timestamp: {e}"),
690                    },
691                )?
692                .with_timezone(&chrono::Utc);
693            let puzzle =
694                crate::application::services::TimeLockServiceApp::lock(&payload, ts, &service)?;
695            let encoded = serde_json::to_vec_pretty(&puzzle).map_err(|e| {
696                crate::domain::errors::TimeLockError::ComputationFailed {
697                    reason: format!("serialize puzzle: {e}"),
698                }
699            })?;
700            fs_write(output_puzzle, &encoded)?;
701            eprintln!("Puzzle → {}", output_puzzle.display());
702        }
703        cli::TimeLockSubcommand::Unlock {
704            puzzle: puzzle_path,
705            output,
706        } => {
707            let data = fs_read(puzzle_path)?;
708            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
709                .map_err(
710                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
711                        reason: format!("deserialize puzzle: {e}"),
712                    },
713                )?;
714            let payload =
715                crate::application::services::TimeLockServiceApp::unlock(&puzzle, &service)?;
716            fs_write(output, payload.as_bytes())?;
717            eprintln!("Unlocked {} bytes", payload.len());
718        }
719        cli::TimeLockSubcommand::TryUnlock {
720            puzzle: puzzle_path,
721        } => {
722            let data = fs_read(puzzle_path)?;
723            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
724                .map_err(
725                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
726                        reason: format!("deserialize puzzle: {e}"),
727                    },
728                )?;
729            match crate::application::services::TimeLockServiceApp::try_unlock(&puzzle, &service)? {
730                Some(p) => eprintln!("Puzzle solved: {} bytes", p.len()),
731                None => eprintln!("Puzzle not yet solvable"),
732            }
733        }
734    }
735    Ok(())
736}
737
738// ─── Watermark ────────────────────────────────────────────────────────────────
739
740fn cmd_watermark(args: &cli::WatermarkArgs) -> Result<(), AppError> {
741    match &args.subcmd {
742        cli::WatermarkSubcommand::EmbedTripwire {
743            cover,
744            output,
745            recipient_id,
746        } => {
747            let cover_media =
748                load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
749            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
750            let rid = uuid::Uuid::parse_str(recipient_id).map_err(|e| {
751                crate::domain::errors::OpsecError::WatermarkError {
752                    reason: format!("invalid UUID: {e}"),
753                }
754            })?;
755            let tag = crate::domain::types::WatermarkTripwireTag {
756                recipient_id: rid,
757                embedding_seed: recipient_id.as_bytes().to_vec(),
758            };
759            let stego = crate::application::services::ForensicService::embed_tripwire(
760                cover_media,
761                &tag,
762                &watermarker,
763            )?;
764            save_cover_to_path(&stego, output)?;
765            eprintln!("Tripwire embedded for {recipient_id}");
766        }
767        cli::WatermarkSubcommand::Identify { cover, tags } => {
768            let cover_media =
769                load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
770            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
771            let tag_list = load_watermark_tags(tags)?;
772            let receipt = crate::application::services::ForensicService::identify_recipient(
773                &cover_media,
774                &tag_list,
775                &watermarker,
776            )?;
777            match receipt {
778                Some(r) => println!("Identified recipient: {r}"),
779                None => println!("No matching watermark found"),
780            }
781        }
782    }
783    Ok(())
784}
785
786// ─── Corpus ───────────────────────────────────────────────────────────────────
787
788fn cmd_corpus(args: &cli::CorpusArgs) -> Result<(), AppError> {
789    let index = crate::adapters::corpus::CorpusIndexImpl::new();
790    match &args.subcmd {
791        cli::CorpusSubcommand::Build { dir } => {
792            let count = CorpusService::build_index(&index, dir)?;
793            eprintln!("Indexed {count} images from {}", dir.display());
794        }
795        cli::CorpusSubcommand::Search {
796            input,
797            technique,
798            top,
799            model,
800            resolution,
801        } => {
802            let data = fs_read(input)?;
803            let payload = Payload::from_bytes(data);
804            let tech = resolve_technique(*technique);
805
806            // If --model is provided, use model-aware search.  --resolution
807            // is required in that case; an absent or unparseable value returns
808            // a clear error rather than silently falling back to (0, 0).
809            let results = if let Some(model_id) = model {
810                let res = resolution
811                    .as_deref()
812                    .and_then(|s| {
813                        let mut parts = s.splitn(2, 'x');
814                        let w = parts.next()?.parse::<u32>().ok()?;
815                        let h = parts.next()?.parse::<u32>().ok()?;
816                        Some((w, h))
817                    })
818                    .ok_or_else(|| {
819                        AppError::Stego(StegoError::MalformedCoverData {
820                            reason: "--model requires --resolution in WIDTHxHEIGHT format \
821                                     (e.g. --resolution 1024x1024)"
822                                .to_string(),
823                        })
824                    })?;
825                CorpusService::search_for_model(&index, &payload, model_id, res, *top)?
826            } else {
827                CorpusService::search(&index, &payload, tech, *top)?
828            };
829
830            for entry in &results {
831                println!("{}", entry.path);
832            }
833        }
834    }
835    Ok(())
836}
837
838// ─── Panic ────────────────────────────────────────────────────────────────────
839
840fn cmd_panic(args: &cli::PanicArgs) -> Result<(), AppError> {
841    let config = crate::domain::types::PanicWipeConfig {
842        key_paths: args.key_paths.iter().map(PathBuf::from).collect(),
843        config_paths: Vec::new(),
844        temp_dirs: Vec::new(),
845    };
846    let wiper = crate::adapters::opsec::SecurePanicWiper::new();
847    crate::application::services::PanicWipeService::wipe(&config, &wiper)?;
848    Ok(())
849}
850
851// ─── Completions ──────────────────────────────────────────────────────────────
852
853fn cmd_completions(args: &cli::CompletionsArgs) -> Result<(), AppError> {
854    use clap::CommandFactory;
855    use clap_complete::generate;
856
857    let mut cmd = Cli::command();
858    match &args.output {
859        Some(path) => {
860            let mut file = std::fs::File::create(path).map_err(|e| {
861                AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
862                    reason: format!("write {}: {e}", path.display()),
863                })
864            })?;
865            generate(args.shell, &mut cmd, "shadowforge", &mut file);
866        }
867        None => {
868            generate(args.shell, &mut cmd, "shadowforge", &mut io::stdout());
869        }
870    }
871    Ok(())
872}
873
874// ─── Cipher ───────────────────────────────────────────────────────────────────
875
876/// AES-256-GCM nonce length in bytes.
877const AES_GCM_NONCE_LEN: usize = 12;
878
879fn cmd_cipher(args: &cli::CipherArgs) -> Result<(), AppError> {
880    use rand_core::Rng as _;
881
882    let cipher = crate::adapters::crypto::Aes256GcmCipher;
883    match &args.subcmd {
884        cli::CipherSubcommand::Encrypt { input, key, output } => {
885            let plaintext = fs_read(input)?;
886            let key_bytes = fs_read(key)?;
887            let mut nonce = [0u8; AES_GCM_NONCE_LEN];
888            rand::rng().fill_bytes(&mut nonce);
889            let ciphertext = CipherService::encrypt(&cipher, &key_bytes, &nonce, &plaintext)?;
890            let mut out = Vec::with_capacity(AES_GCM_NONCE_LEN.strict_add(ciphertext.len()));
891            out.extend_from_slice(&nonce);
892            out.extend_from_slice(&ciphertext);
893            fs_write(output, &out)?;
894            eprintln!(
895                "Encrypted {} bytes -> {}",
896                plaintext.len(),
897                output.display()
898            );
899        }
900        cli::CipherSubcommand::Decrypt { input, key, output } => {
901            let data = fs_read(input)?;
902            let key_bytes = fs_read(key)?;
903            if data.len() < AES_GCM_NONCE_LEN {
904                return Err(AppError::Crypto(
905                    crate::domain::errors::CryptoError::InvalidNonceLength {
906                        expected: AES_GCM_NONCE_LEN,
907                        got: data.len(),
908                    },
909                ));
910            }
911            let (nonce, ciphertext) = data.split_at(AES_GCM_NONCE_LEN);
912            let plaintext = CipherService::decrypt(&cipher, &key_bytes, nonce, ciphertext)?;
913            fs_write(output, &plaintext)?;
914            eprintln!(
915                "Decrypted {} bytes -> {}",
916                plaintext.len(),
917                output.display()
918            );
919        }
920    }
921    Ok(())
922}
923
924// ═══════════════════════════════════════════════════════════════════════════════
925// Helpers
926// ═══════════════════════════════════════════════════════════════════════════════
927
928fn fs_read(path: &Path) -> Result<Vec<u8>, AppError> {
929    std::fs::read(path).map_err(|e| {
930        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
931            reason: format!("read {}: {e}", path.display()),
932        })
933    })
934}
935
936fn fs_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
937    if let Some(parent) = path.parent() {
938        fs_create_dir_all(parent)?;
939    }
940    std::fs::write(path, data).map_err(|e| {
941        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
942            reason: format!("write {}: {e}", path.display()),
943        })
944    })
945}
946
947fn fs_create_dir_all(path: &Path) -> Result<(), AppError> {
948    std::fs::create_dir_all(path).map_err(|e| {
949        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
950            reason: format!("mkdir {}: {e}", path.display()),
951        })
952    })
953}
954
955fn map_media_error(error: crate::domain::errors::MediaError) -> AppError {
956    match error {
957        crate::domain::errors::MediaError::UnsupportedFormat { extension } => {
958            AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
959                reason: format!("unsupported cover format: {extension}"),
960            })
961        }
962        crate::domain::errors::MediaError::DecodeFailed { reason }
963        | crate::domain::errors::MediaError::EncodeFailed { reason }
964        | crate::domain::errors::MediaError::IoError { reason } => {
965            AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
966        }
967    }
968}
969
970#[cfg(feature = "pdf")]
971fn map_pdf_runner_error(error: crate::domain::errors::PdfError) -> AppError {
972    match error {
973        crate::domain::errors::PdfError::Encrypted => {
974            AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
975                reason: "encrypted PDF documents are not supported".to_string(),
976            })
977        }
978        crate::domain::errors::PdfError::ParseFailed { reason }
979        | crate::domain::errors::PdfError::RenderFailed { reason, .. }
980        | crate::domain::errors::PdfError::RebuildFailed { reason }
981        | crate::domain::errors::PdfError::EmbedFailed { reason }
982        | crate::domain::errors::PdfError::ExtractFailed { reason }
983        | crate::domain::errors::PdfError::IoError { reason } => {
984            AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
985        }
986    }
987}
988
989fn load_cover_from_path(path: &Path, technique: StegoTechnique) -> Result<CoverMedia, AppError> {
990    match technique {
991        StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
992            let loader = crate::adapters::media::AudioMediaLoader;
993            loader.load(path).map_err(map_media_error)
994        }
995        StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => load_pdf_cover(path),
996        StegoTechnique::ZeroWidthText => {
997            let data = fs_read(path)?;
998            Ok(load_cover(technique, &data))
999        }
1000        StegoTechnique::LsbImage
1001        | StegoTechnique::DctJpeg
1002        | StegoTechnique::Palette
1003        | StegoTechnique::CorpusSelection
1004        | StegoTechnique::DualPayload => {
1005            let loader = crate::adapters::media::ImageMediaLoader;
1006            loader.load(path).map_err(map_media_error)
1007        }
1008    }
1009}
1010
1011#[cfg(feature = "pdf")]
1012fn load_pdf_cover(path: &Path) -> Result<CoverMedia, AppError> {
1013    use crate::domain::ports::PdfProcessor;
1014
1015    let processor = crate::adapters::pdf::PdfProcessorImpl::default();
1016    processor.load_pdf(path).map_err(map_pdf_runner_error)
1017}
1018
1019#[cfg(not(feature = "pdf"))]
1020fn load_pdf_cover(_path: &Path) -> Result<CoverMedia, AppError> {
1021    Err(AppError::Stego(
1022        crate::domain::errors::StegoError::UnsupportedCoverType {
1023            reason: "PDF support is not enabled in this build".to_string(),
1024        },
1025    ))
1026}
1027
1028fn save_cover_to_path(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
1029    if let Some(parent) = path.parent() {
1030        fs_create_dir_all(parent)?;
1031    }
1032
1033    match media.kind {
1034        CoverMediaKind::PngImage
1035        | CoverMediaKind::BmpImage
1036        | CoverMediaKind::JpegImage
1037        | CoverMediaKind::GifImage => {
1038            let loader = crate::adapters::media::ImageMediaLoader;
1039            loader.save(media, path).map_err(map_media_error)
1040        }
1041        CoverMediaKind::WavAudio => {
1042            let loader = crate::adapters::media::AudioMediaLoader;
1043            loader.save(media, path).map_err(map_media_error)
1044        }
1045        CoverMediaKind::PdfDocument => save_pdf_cover(media, path),
1046        CoverMediaKind::PlainText => fs_write(path, media.data.as_ref()),
1047    }
1048}
1049
1050#[cfg(feature = "pdf")]
1051fn save_pdf_cover(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
1052    use crate::domain::ports::PdfProcessor;
1053
1054    let processor = crate::adapters::pdf::PdfProcessorImpl::default();
1055    processor
1056        .save_pdf(media, path)
1057        .map_err(map_pdf_runner_error)
1058}
1059
1060#[cfg(not(feature = "pdf"))]
1061fn save_pdf_cover(_media: &CoverMedia, _path: &Path) -> Result<(), AppError> {
1062    Err(AppError::Stego(
1063        crate::domain::errors::StegoError::UnsupportedCoverType {
1064            reason: "PDF support is not enabled in this build".to_string(),
1065        },
1066    ))
1067}
1068
1069fn serialise_cover_to_bytes(media: &CoverMedia) -> Result<Vec<u8>, AppError> {
1070    match media.kind {
1071        CoverMediaKind::PdfDocument | CoverMediaKind::PlainText => Ok(media.data.to_vec()),
1072        _ => {
1073            let temp_path = std::env::temp_dir().join(format!(
1074                "shadowforge-{}.{}",
1075                uuid::Uuid::new_v4(),
1076                cover_file_extension(media.kind)
1077            ));
1078            let result = (|| {
1079                save_cover_to_path(media, &temp_path)?;
1080                fs_read(&temp_path)
1081            })();
1082            let _ = std::fs::remove_file(&temp_path);
1083            result
1084        }
1085    }
1086}
1087
1088fn load_cover_from_named_bytes(
1089    name: &str,
1090    technique: StegoTechnique,
1091    data: &[u8],
1092) -> Result<CoverMedia, AppError> {
1093    if technique == StegoTechnique::ZeroWidthText {
1094        return Ok(load_cover(technique, data));
1095    }
1096
1097    let extension = Path::new(name)
1098        .extension()
1099        .and_then(|value| value.to_str())
1100        .unwrap_or_else(|| technique_file_extension(technique));
1101    let temp_path = std::env::temp_dir().join(format!(
1102        "shadowforge-{}.{}",
1103        uuid::Uuid::new_v4(),
1104        extension
1105    ));
1106    let result = (|| {
1107        fs_write(&temp_path, data)?;
1108        load_cover_from_path(&temp_path, technique)
1109    })();
1110    let _ = std::fs::remove_file(&temp_path);
1111    result
1112}
1113
1114const fn cover_file_extension(kind: CoverMediaKind) -> &'static str {
1115    match kind {
1116        CoverMediaKind::PngImage => "png",
1117        CoverMediaKind::BmpImage => "bmp",
1118        CoverMediaKind::JpegImage => "jpg",
1119        CoverMediaKind::GifImage => "gif",
1120        CoverMediaKind::WavAudio => "wav",
1121        CoverMediaKind::PdfDocument => "pdf",
1122        CoverMediaKind::PlainText => "txt",
1123    }
1124}
1125
1126const fn technique_file_extension(technique: StegoTechnique) -> &'static str {
1127    match technique {
1128        StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
1129            "wav"
1130        }
1131        StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => "pdf",
1132        StegoTechnique::ZeroWidthText => "txt",
1133        StegoTechnique::DctJpeg => "jpg",
1134        StegoTechnique::LsbImage
1135        | StegoTechnique::Palette
1136        | StegoTechnique::CorpusSelection
1137        | StegoTechnique::DualPayload => "png",
1138    }
1139}
1140
1141fn load_cover(technique: crate::domain::types::StegoTechnique, data: &[u8]) -> CoverMedia {
1142    let kind = match technique {
1143        crate::domain::types::StegoTechnique::LsbAudio
1144        | crate::domain::types::StegoTechnique::PhaseEncoding
1145        | crate::domain::types::StegoTechnique::EchoHiding => CoverMediaKind::WavAudio,
1146        crate::domain::types::StegoTechnique::ZeroWidthText => CoverMediaKind::PlainText,
1147        crate::domain::types::StegoTechnique::PdfContentStream
1148        | crate::domain::types::StegoTechnique::PdfMetadata => CoverMediaKind::PdfDocument,
1149        crate::domain::types::StegoTechnique::DctJpeg => CoverMediaKind::JpegImage,
1150        crate::domain::types::StegoTechnique::LsbImage
1151        | crate::domain::types::StegoTechnique::Palette
1152        | crate::domain::types::StegoTechnique::CorpusSelection
1153        | crate::domain::types::StegoTechnique::DualPayload => CoverMediaKind::PngImage,
1154    };
1155    CoverMedia {
1156        kind,
1157        data: bytes::Bytes::from(data.to_vec()),
1158        metadata: std::collections::HashMap::new(),
1159    }
1160}
1161
1162/// Build an embedder for the given technique.
1163fn build_embedder(technique: crate::domain::types::StegoTechnique) -> Box<dyn EmbedTechnique> {
1164    use crate::domain::types::StegoTechnique;
1165    match technique {
1166        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
1167        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
1168        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
1169        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
1170        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
1171        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1172        StegoTechnique::PdfContentStream => build_pdf_content_stream_embedder(),
1173        StegoTechnique::PdfMetadata => build_pdf_metadata_embedder(),
1174        StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1175            StegoTechnique::CorpusSelection,
1176            "corpus selection must use the corpus workflow, not generic embed",
1177        )),
1178        StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1179            StegoTechnique::DualPayload,
1180            "dual-payload embedding must use the deniable embed workflow",
1181        )),
1182        StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1183    }
1184}
1185
1186/// Build an extractor for the given technique.
1187fn build_extractor(technique: crate::domain::types::StegoTechnique) -> Box<dyn ExtractTechnique> {
1188    use crate::domain::types::StegoTechnique;
1189    match technique {
1190        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
1191        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
1192        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
1193        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
1194        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
1195        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1196        StegoTechnique::PdfContentStream => build_pdf_content_stream_extractor(),
1197        StegoTechnique::PdfMetadata => build_pdf_metadata_extractor(),
1198        StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1199            StegoTechnique::CorpusSelection,
1200            "corpus selection must use the corpus workflow, not generic extract",
1201        )),
1202        StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1203            StegoTechnique::DualPayload,
1204            "dual-payload extraction must use the deniable extraction workflow",
1205        )),
1206        StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1207    }
1208}
1209
1210#[cfg(feature = "pdf")]
1211fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1212    Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1213}
1214
1215#[cfg(not(feature = "pdf"))]
1216fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1217    Box::new(UnsupportedTechnique::new(
1218        crate::domain::types::StegoTechnique::PdfContentStream,
1219        "PDF support is not enabled in this build",
1220    ))
1221}
1222
1223#[cfg(feature = "pdf")]
1224fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1225    Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1226}
1227
1228#[cfg(not(feature = "pdf"))]
1229fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1230    Box::new(UnsupportedTechnique::new(
1231        crate::domain::types::StegoTechnique::PdfMetadata,
1232        "PDF support is not enabled in this build",
1233    ))
1234}
1235
1236#[cfg(feature = "pdf")]
1237fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1238    Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1239}
1240
1241#[cfg(not(feature = "pdf"))]
1242fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1243    Box::new(UnsupportedTechnique::new(
1244        crate::domain::types::StegoTechnique::PdfContentStream,
1245        "PDF support is not enabled in this build",
1246    ))
1247}
1248
1249#[cfg(feature = "pdf")]
1250fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1251    Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1252}
1253
1254#[cfg(not(feature = "pdf"))]
1255fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1256    Box::new(UnsupportedTechnique::new(
1257        crate::domain::types::StegoTechnique::PdfMetadata,
1258        "PDF support is not enabled in this build",
1259    ))
1260}
1261
1262#[derive(Debug)]
1263struct UnsupportedTechnique {
1264    technique: crate::domain::types::StegoTechnique,
1265    reason: &'static str,
1266}
1267
1268impl UnsupportedTechnique {
1269    const fn new(technique: crate::domain::types::StegoTechnique, reason: &'static str) -> Self {
1270        Self { technique, reason }
1271    }
1272}
1273
1274impl EmbedTechnique for UnsupportedTechnique {
1275    fn technique(&self) -> crate::domain::types::StegoTechnique {
1276        self.technique
1277    }
1278
1279    fn capacity(&self, _cover: &CoverMedia) -> Result<crate::domain::types::Capacity, StegoError> {
1280        Err(StegoError::UnsupportedCoverType {
1281            reason: self.reason.to_string(),
1282        })
1283    }
1284
1285    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
1286        Err(StegoError::UnsupportedCoverType {
1287            reason: self.reason.to_string(),
1288        })
1289    }
1290}
1291
1292impl ExtractTechnique for UnsupportedTechnique {
1293    fn technique(&self) -> crate::domain::types::StegoTechnique {
1294        self.technique
1295    }
1296
1297    fn extract(&self, _stego: &CoverMedia) -> Result<Payload, StegoError> {
1298        Err(StegoError::UnsupportedCoverType {
1299            reason: self.reason.to_string(),
1300        })
1301    }
1302}
1303
1304const fn resolve_technique(t: cli::Technique) -> crate::domain::types::StegoTechnique {
1305    match t {
1306        cli::Technique::Lsb => crate::domain::types::StegoTechnique::LsbImage,
1307        cli::Technique::Dct => crate::domain::types::StegoTechnique::DctJpeg,
1308        cli::Technique::Palette => crate::domain::types::StegoTechnique::Palette,
1309        cli::Technique::LsbAudio => crate::domain::types::StegoTechnique::LsbAudio,
1310        cli::Technique::Phase => crate::domain::types::StegoTechnique::PhaseEncoding,
1311        cli::Technique::Echo => crate::domain::types::StegoTechnique::EchoHiding,
1312        cli::Technique::ZeroWidth => crate::domain::types::StegoTechnique::ZeroWidthText,
1313        cli::Technique::PdfStream => crate::domain::types::StegoTechnique::PdfContentStream,
1314        cli::Technique::PdfMeta => crate::domain::types::StegoTechnique::PdfMetadata,
1315        cli::Technique::Corpus => crate::domain::types::StegoTechnique::CorpusSelection,
1316    }
1317}
1318
1319fn resolve_profile(
1320    profile: cli::Profile,
1321    platform: Option<cli::Platform>,
1322) -> crate::domain::types::EmbeddingProfile {
1323    match profile {
1324        cli::Profile::Standard => crate::domain::types::EmbeddingProfile::Standard,
1325        cli::Profile::Adaptive => crate::domain::types::EmbeddingProfile::default_adaptive(),
1326        cli::Profile::Survivable => {
1327            let p = platform.map_or(crate::domain::types::PlatformProfile::Instagram, |pl| {
1328                resolve_platform(pl)
1329            });
1330            crate::domain::types::EmbeddingProfile::CompressionSurvivable { platform: p }
1331        }
1332    }
1333}
1334
1335const fn resolve_platform(p: cli::Platform) -> crate::domain::types::PlatformProfile {
1336    match p {
1337        cli::Platform::Instagram => crate::domain::types::PlatformProfile::Instagram,
1338        cli::Platform::Twitter => crate::domain::types::PlatformProfile::Twitter,
1339        cli::Platform::Whatsapp => crate::domain::types::PlatformProfile::WhatsApp,
1340        cli::Platform::Telegram => crate::domain::types::PlatformProfile::Telegram,
1341        cli::Platform::Imgur => crate::domain::types::PlatformProfile::Imgur,
1342    }
1343}
1344
1345const fn resolve_archive_format(f: cli::ArchiveFormat) -> crate::domain::types::ArchiveFormat {
1346    match f {
1347        cli::ArchiveFormat::Zip => crate::domain::types::ArchiveFormat::Zip,
1348        cli::ArchiveFormat::Tar => crate::domain::types::ArchiveFormat::Tar,
1349        cli::ArchiveFormat::TarGz => crate::domain::types::ArchiveFormat::TarGz,
1350    }
1351}
1352
1353/// Collect cover file paths — if the argument is a glob pattern expand it,
1354/// otherwise treat it as a directory and list its files.
1355fn collect_cover_paths(pattern: &str) -> Result<Vec<PathBuf>, AppError> {
1356    let path = PathBuf::from(pattern);
1357    let paths: Vec<PathBuf> = if path.is_dir() {
1358        std::fs::read_dir(&path)
1359            .map_err(
1360                |_| crate::domain::errors::DistributionError::InsufficientCovers {
1361                    needed: 1,
1362                    got: 0,
1363                },
1364            )?
1365            .filter_map(Result::ok)
1366            .map(|e| e.path())
1367            .filter(|p| p.is_file())
1368            .collect()
1369    } else if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
1370        glob::glob(pattern)
1371            .map_err(
1372                |_| crate::domain::errors::DistributionError::InsufficientCovers {
1373                    needed: 1,
1374                    got: 0,
1375                },
1376            )?
1377            .filter_map(Result::ok)
1378            .filter(|p| p.is_file())
1379            .collect()
1380    } else {
1381        // Treat as a literal file list (single file)
1382        vec![path]
1383    };
1384
1385    if paths.is_empty() {
1386        return Err(
1387            crate::domain::errors::DistributionError::InsufficientCovers { needed: 1, got: 0 }
1388                .into(),
1389        );
1390    }
1391    Ok(paths)
1392}
1393
1394fn load_watermark_tags(
1395    dir: &Path,
1396) -> Result<Vec<crate::domain::types::WatermarkTripwireTag>, AppError> {
1397    let mut tags = Vec::new();
1398    if dir.is_dir() {
1399        let entries = std::fs::read_dir(dir).map_err(|e| {
1400            crate::domain::errors::OpsecError::WatermarkError {
1401                reason: format!("read dir: {e}"),
1402            }
1403        })?;
1404        for entry in entries {
1405            let entry = entry.map_err(|e: std::io::Error| {
1406                crate::domain::errors::OpsecError::WatermarkError {
1407                    reason: format!("dir entry: {e}"),
1408                }
1409            })?;
1410            let path = entry.path();
1411            if path.is_file() {
1412                // Parse tag from file: content is a recipient UUID string, used as seed too
1413                if let Ok(content) = std::fs::read_to_string(&path) {
1414                    let id_str = content.trim();
1415                    let rid = uuid::Uuid::parse_str(id_str).map_err(|e| {
1416                        crate::domain::errors::OpsecError::WatermarkError {
1417                            reason: format!("invalid UUID in {}: {e}", path.display()),
1418                        }
1419                    })?;
1420                    tags.push(crate::domain::types::WatermarkTripwireTag {
1421                        recipient_id: rid,
1422                        embedding_seed: id_str.as_bytes().to_vec(),
1423                    });
1424                }
1425            }
1426        }
1427    }
1428    Ok(tags)
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use super::{
1434        build_embedder, build_extractor, cmd_extract, cmd_keygen, load_cover, load_cover_from_path,
1435        save_cover_to_path,
1436    };
1437    use crate::application::services::{AppError, DeniableEmbedService};
1438    use crate::domain::errors::CryptoError;
1439    use crate::domain::types::{CoverMediaKind, StegoTechnique};
1440    use crate::interface::cli;
1441    use image::{DynamicImage, Rgba, RgbaImage};
1442    use std::fs;
1443    use tempfile::tempdir;
1444
1445    type TestResult = Result<(), Box<dyn std::error::Error>>;
1446
1447    #[test]
1448    fn dct_cover_is_classified_as_jpeg() {
1449        let cover = load_cover(StegoTechnique::DctJpeg, b"jpeg");
1450        assert_eq!(cover.kind, CoverMediaKind::JpegImage);
1451    }
1452
1453    #[test]
1454    fn pdf_cover_is_classified_as_pdf() {
1455        let cover = load_cover(StegoTechnique::PdfContentStream, b"%PDF-1.7");
1456        assert_eq!(cover.kind, CoverMediaKind::PdfDocument);
1457    }
1458
1459    #[test]
1460    fn pdf_content_stream_embedder_uses_pdf_technique() {
1461        let embedder = build_embedder(StegoTechnique::PdfContentStream);
1462        assert_eq!(embedder.technique(), StegoTechnique::PdfContentStream);
1463    }
1464
1465    #[test]
1466    fn pdf_metadata_extractor_uses_pdf_technique() {
1467        let extractor = build_extractor(StegoTechnique::PdfMetadata);
1468        assert_eq!(extractor.technique(), StegoTechnique::PdfMetadata);
1469    }
1470
1471    #[test]
1472    fn corpus_embedder_is_not_rewritten_as_lsb_image() {
1473        let embedder = build_embedder(StegoTechnique::CorpusSelection);
1474        assert_eq!(embedder.technique(), StegoTechnique::CorpusSelection);
1475    }
1476
1477    #[test]
1478    fn file_backed_image_covers_use_media_loader() -> TestResult {
1479        let dir = tempdir()?;
1480        let input_path = dir.path().join("cover.png");
1481        let output_path = dir.path().join("roundtrip.png");
1482
1483        let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 2, Rgba([1, 2, 3, 255])));
1484        image.save(&input_path)?;
1485
1486        let cover = load_cover_from_path(&input_path, StegoTechnique::LsbImage)?;
1487        assert_eq!(cover.kind, CoverMediaKind::PngImage);
1488        assert_eq!(cover.metadata.get("width"), Some(&"3".to_string()));
1489        assert_eq!(cover.metadata.get("height"), Some(&"2".to_string()));
1490
1491        save_cover_to_path(&cover, &output_path)?;
1492        let written = fs::read(output_path)?;
1493        assert!(!written.is_empty());
1494        Ok(())
1495    }
1496
1497    #[test]
1498    fn extract_uses_deniable_key_path_when_provided() -> TestResult {
1499        let dir = tempdir()?;
1500        let cover_path = dir.path().join("cover.png");
1501        let input_path = dir.path().join("input.png");
1502        let output_path = dir.path().join("output.bin");
1503        let key_path = dir.path().join("primary.key");
1504
1505        let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(40, 40, Rgba([0, 0, 0, 255])));
1506        image.save(&cover_path)?;
1507
1508        let primary_key = vec![7u8; 32];
1509        let decoy_key = vec![9u8; 32];
1510        let pair = crate::domain::types::DeniablePayloadPair {
1511            real_payload: b"real secret".to_vec(),
1512            decoy_payload: b"decoy".to_vec(),
1513        };
1514        let keys = crate::domain::types::DeniableKeySet {
1515            primary_key: primary_key.clone(),
1516            decoy_key,
1517        };
1518        let cover = load_cover_from_path(&cover_path, StegoTechnique::LsbImage)?;
1519        let deniable = crate::adapters::stego::DualPayloadEmbedder;
1520        let embedder = build_embedder(StegoTechnique::LsbImage);
1521        let stego =
1522            DeniableEmbedService::embed_dual(cover, &pair, &keys, embedder.as_ref(), &deniable)?;
1523
1524        save_cover_to_path(&stego, &input_path)?;
1525        fs::write(&key_path, &primary_key)?;
1526
1527        let args = cli::ExtractArgs {
1528            input: input_path,
1529            output: output_path.clone(),
1530            technique: cli::Technique::Lsb,
1531            key: Some(key_path),
1532            amnesia: false,
1533        };
1534
1535        cmd_extract(&args)?;
1536
1537        let extracted = fs::read(output_path)?;
1538        assert_eq!(extracted, b"real secret");
1539        Ok(())
1540    }
1541
1542    #[test]
1543    fn keygen_sign_produces_signature_artifact() -> TestResult {
1544        let dir = tempdir()?;
1545        let keys_dir = dir.path().join("keys");
1546        let msg_path = dir.path().join("message.bin");
1547        let sig_path = dir.path().join("message.sig");
1548
1549        let generate = cli::KeygenArgs {
1550            subcmd: None,
1551            algorithm: Some(cli::Algorithm::Dilithium3),
1552            output: Some(keys_dir.clone()),
1553        };
1554        cmd_keygen(&generate)?;
1555
1556        fs::write(&msg_path, b"hello signer")?;
1557        let sign = cli::KeygenArgs {
1558            subcmd: Some(cli::KeygenSubcommand::Sign {
1559                input: msg_path,
1560                secret_key: keys_dir.join("secret.key"),
1561                output: sig_path.clone(),
1562            }),
1563            algorithm: None,
1564            output: None,
1565        };
1566        cmd_keygen(&sign)?;
1567
1568        let sig = fs::read(sig_path)?;
1569        assert!(!sig.is_empty());
1570        Ok(())
1571    }
1572
1573    #[test]
1574    fn keygen_verify_reports_success_and_failure_status() -> TestResult {
1575        let dir = tempdir()?;
1576        let keys_dir = dir.path().join("keys");
1577        let msg_path = dir.path().join("message.bin");
1578        let tampered_path = dir.path().join("message_tampered.bin");
1579        let sig_path = dir.path().join("message.sig");
1580
1581        let generate = cli::KeygenArgs {
1582            subcmd: None,
1583            algorithm: Some(cli::Algorithm::Dilithium3),
1584            output: Some(keys_dir.clone()),
1585        };
1586        cmd_keygen(&generate)?;
1587
1588        fs::write(&msg_path, b"signed message")?;
1589        fs::write(&tampered_path, b"signed message with tamper")?;
1590
1591        let sign = cli::KeygenArgs {
1592            subcmd: Some(cli::KeygenSubcommand::Sign {
1593                input: msg_path.clone(),
1594                secret_key: keys_dir.join("secret.key"),
1595                output: sig_path.clone(),
1596            }),
1597            algorithm: None,
1598            output: None,
1599        };
1600        cmd_keygen(&sign)?;
1601
1602        let verify_ok = cli::KeygenArgs {
1603            subcmd: Some(cli::KeygenSubcommand::Verify {
1604                input: msg_path,
1605                public_key: keys_dir.join("public.key"),
1606                signature: sig_path.clone(),
1607            }),
1608            algorithm: None,
1609            output: None,
1610        };
1611        assert!(cmd_keygen(&verify_ok).is_ok());
1612
1613        let verify_fail = cli::KeygenArgs {
1614            subcmd: Some(cli::KeygenSubcommand::Verify {
1615                input: tampered_path,
1616                public_key: keys_dir.join("public.key"),
1617                signature: sig_path,
1618            }),
1619            algorithm: None,
1620            output: None,
1621        };
1622        let err = cmd_keygen(&verify_fail).err();
1623        assert!(matches!(
1624            err,
1625            Some(AppError::Crypto(CryptoError::VerificationFailed { .. }))
1626        ));
1627        Ok(())
1628    }
1629
1630    // ─── format_analysis_report ───────────────────────────────────────────
1631
1632    use super::format_analysis_report;
1633    use crate::domain::types::{
1634        AiWatermarkAssessment, AnalysisReport, Capacity, DetectabilityRisk, SpectralScore,
1635        StegoTechnique as ST,
1636    };
1637
1638    fn base_report() -> AnalysisReport {
1639        AnalysisReport {
1640            technique: ST::LsbImage,
1641            cover_capacity: Capacity {
1642                bytes: 1024,
1643                technique: ST::LsbImage,
1644            },
1645            chi_square_score: std::f64::consts::PI,
1646            detectability_risk: DetectabilityRisk::Low,
1647            recommended_max_payload_bytes: 512,
1648            ai_watermark: None,
1649            spectral_score: None,
1650        }
1651    }
1652
1653    #[test]
1654    fn format_report_contains_core_fields() {
1655        let report = base_report();
1656        let out = format_analysis_report(&report);
1657        assert!(out.contains("LsbImage"), "technique should appear");
1658        assert!(out.contains("1024 bytes"), "capacity should appear");
1659        assert!(out.contains("3.14 dB"), "chi-square should appear");
1660        assert!(out.contains("Low"), "risk should appear");
1661        assert!(out.contains("512 bytes"), "recommended max should appear");
1662    }
1663
1664    #[test]
1665    fn format_report_omits_spectral_section_when_none() {
1666        let report = base_report();
1667        let out = format_analysis_report(&report);
1668        assert!(
1669            !out.contains("Spectral Detectability"),
1670            "spectral section must be absent when score is None"
1671        );
1672    }
1673
1674    #[test]
1675    fn format_report_includes_spectral_section_when_present() {
1676        let mut report = base_report();
1677        report.spectral_score = Some(SpectralScore {
1678            phase_coherence_drop: 0.1234,
1679            carrier_snr_drop_db: -2.5,
1680            sample_pair_asymmetry: 0.0081,
1681            combined_risk: DetectabilityRisk::Medium,
1682        });
1683        let out = format_analysis_report(&report);
1684        assert!(
1685            out.contains("Spectral Detectability"),
1686            "section header must appear"
1687        );
1688        assert!(out.contains("0.1234"), "phase coherence drop should appear");
1689        assert!(out.contains("-2.50 dB"), "SNR drop should appear");
1690        assert!(
1691            out.contains("0.0081"),
1692            "sample-pair asymmetry should appear"
1693        );
1694        assert!(out.contains("Medium"), "spectral risk should appear");
1695    }
1696
1697    #[test]
1698    fn format_report_spectral_does_not_bleed_into_core_output() {
1699        let report = base_report();
1700        let out = format_analysis_report(&report);
1701        // Core fields still present even without spectral score
1702        assert!(out.contains("Technique"));
1703        assert!(out.contains("Chi-square"));
1704        assert!(out.contains("Recommended"));
1705    }
1706
1707    #[test]
1708    fn format_report_includes_ai_watermark_section_when_present() {
1709        let mut report = base_report();
1710        report.ai_watermark = Some(AiWatermarkAssessment {
1711            detected: true,
1712            model_id: Some("gemini".to_string()),
1713            confidence: 0.875,
1714            matched_strong_bins: 7,
1715            total_strong_bins: 8,
1716        });
1717
1718        let out = format_analysis_report(&report);
1719        assert!(out.contains("AI Watermark Detection"));
1720        assert!(out.contains("yes"));
1721        assert!(out.contains("gemini"));
1722        assert!(out.contains("0.8750"));
1723        assert!(out.contains("7/8"));
1724    }
1725}