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> =
481        Box::new(crate::adapters::correction::RsErrorCorrector::new(hmac_key));
482    let reconstructor = crate::adapters::reconstruction::ReconstructorImpl::new(
483        args.data_shards,
484        args.parity_shards,
485        0,
486        corrector_for_recon,
487    );
488    let payload = crate::application::services::ReconstructService::reconstruct(
489        covers,
490        extractor.as_ref(),
491        &reconstructor,
492        &|done, total| eprintln!("Reconstructing: {done}/{total}"),
493    )?;
494    fs_write(&args.output, payload.as_bytes())?;
495    eprintln!("Reconstructed {} bytes", payload.len());
496    Ok(())
497}
498
499// ─── Analyse ──────────────────────────────────────────────────────────────────
500
501/// Format an `AnalysisReport` as a human-readable string.
502/// Exposed for unit testing.
503pub(crate) fn format_analysis_report(report: &crate::domain::types::AnalysisReport) -> String {
504    use std::fmt::Write as _;
505    let mut out = String::new();
506    let _ = writeln!(out, "Technique:     {:?}", report.technique);
507    let _ = writeln!(out, "Capacity:      {} bytes", report.cover_capacity.bytes);
508    let _ = writeln!(out, "Chi-square:    {:.2} dB", report.chi_square_score);
509    let _ = writeln!(out, "Risk:          {:?}", report.detectability_risk);
510    let _ = writeln!(
511        out,
512        "Recommended:   {} bytes",
513        report.recommended_max_payload_bytes
514    );
515    if let Some(ai) = &report.ai_watermark {
516        let _ = writeln!(out, "--- AI Watermark Detection ---");
517        let _ = writeln!(
518            out,
519            "  Detected:             {}",
520            if ai.detected { "yes" } else { "no" }
521        );
522        if let Some(model_id) = &ai.model_id {
523            let _ = writeln!(out, "  Model:                {model_id}");
524        }
525        if ai.total_strong_bins > 0 {
526            let _ = writeln!(out, "  Confidence:           {:.4}", ai.confidence);
527            let _ = writeln!(
528                out,
529                "  Matched strong bins:  {}/{}",
530                ai.matched_strong_bins, ai.total_strong_bins
531            );
532        } else {
533            let _ = writeln!(out, "  Status:               no known profile match");
534        }
535    }
536    if let Some(s) = &report.spectral_score {
537        let _ = writeln!(out, "--- Spectral Detectability ---");
538        let _ = writeln!(out, "  Phase coherence drop: {:.4}", s.phase_coherence_drop);
539        let _ = writeln!(
540            out,
541            "  Carrier SNR drop:     {:.2} dB",
542            s.carrier_snr_drop_db
543        );
544        let _ = writeln!(
545            out,
546            "  Sample-pair asymmetry:{:.4}",
547            s.sample_pair_asymmetry
548        );
549        let _ = writeln!(out, "  Spectral risk:        {:?}", s.combined_risk);
550    }
551    out
552}
553
554fn cmd_analyse(args: &cli::AnalyseArgs) -> Result<(), AppError> {
555    let technique = resolve_technique(args.technique);
556    let cover = load_cover_from_path(&args.cover, technique)?;
557    let analyser = crate::adapters::analysis::CapacityAnalyserImpl::new();
558    let report = AnalyseService::analyse(&cover, technique, &analyser)?;
559
560    if args.json {
561        let json = serde_json::to_string_pretty(&report).map_err(|e| {
562            crate::domain::errors::AnalysisError::ComputationFailed {
563                reason: format!("json serialisation: {e}"),
564            }
565        })?;
566        println!("{json}");
567    } else {
568        print!("{}", format_analysis_report(&report));
569    }
570    Ok(())
571}
572
573// ─── Archive ──────────────────────────────────────────────────────────────────
574
575fn cmd_archive(args: &cli::ArchiveArgs) -> Result<(), AppError> {
576    match &args.subcmd {
577        cli::ArchiveSubcommand::Pack {
578            files,
579            format,
580            output,
581        } => {
582            let file_data: Result<Vec<(String, Vec<u8>)>, AppError> = files
583                .iter()
584                .map(|p| {
585                    let name = p.file_name().map_or_else(
586                        || p.display().to_string(),
587                        |n| n.to_string_lossy().into_owned(),
588                    );
589                    let data = fs_read(p)?;
590                    Ok((name, data))
591                })
592                .collect();
593            let file_data = file_data?;
594            let refs: Vec<(&str, &[u8])> = file_data
595                .iter()
596                .map(|(n, d)| (n.as_str(), d.as_slice()))
597                .collect();
598            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
599            let fmt = resolve_archive_format(*format);
600            let packed = ArchiveService::pack(&refs, fmt, &handler)?;
601            fs_write(output, &packed)?;
602            eprintln!("Packed {} files → {}", files.len(), output.display());
603        }
604        cli::ArchiveSubcommand::Unpack {
605            input,
606            format,
607            output_dir,
608        } => {
609            let data = fs_read(input)?;
610            let handler = crate::adapters::archive::ArchiveHandlerImpl::new();
611            let fmt = resolve_archive_format(*format);
612            let entries = ArchiveService::unpack(&data, fmt, &handler)?;
613            fs_create_dir_all(output_dir)?;
614            for (name, content) in &entries {
615                let path = output_dir.join(name);
616                if let Some(parent) = path.parent() {
617                    fs_create_dir_all(parent)?;
618                }
619                fs_write(&path, content.as_ref())?;
620            }
621            eprintln!(
622                "Unpacked {} entries → {}",
623                entries.len(),
624                output_dir.display()
625            );
626        }
627    }
628    Ok(())
629}
630
631// ─── Scrub ────────────────────────────────────────────────────────────────────
632
633fn cmd_scrub(args: &cli::ScrubArgs) -> Result<(), AppError> {
634    let text = std::fs::read_to_string(&args.input).map_err(|e| {
635        crate::domain::errors::ScrubberError::InvalidUtf8 {
636            reason: format!("read: {e}"),
637        }
638    })?;
639    let scrubber = crate::adapters::scrubber::StyloScrubberImpl::new();
640    let profile = crate::domain::types::StyloProfile {
641        normalize_punctuation: true,
642        target_avg_sentence_len: f64::from(args.avg_sentence_len),
643        target_vocab_size: args.vocab_size,
644    };
645    let result = ScrubService::scrub(&text, &profile, &scrubber)?;
646    fs_write(&args.output, result.as_bytes())?;
647    eprintln!("Scrubbed text → {}", args.output.display());
648    Ok(())
649}
650
651// ─── Dead Drop ────────────────────────────────────────────────────────────────
652
653fn cmd_dead_drop(args: &cli::DeadDropArgs) -> Result<(), AppError> {
654    let technique = resolve_technique(args.technique);
655    let embedder = build_embedder(technique);
656    let cover = load_cover_from_path(&args.cover, technique)?;
657    let payload_bytes = fs_read(&args.input)?;
658    let payload = Payload::from_bytes(payload_bytes);
659    let platform = resolve_platform(args.platform);
660    let encoder = crate::adapters::deadrop::DeadDropEncoderImpl::new();
661    let stego = crate::application::services::DeadDropService::encode(
662        cover,
663        &payload,
664        &platform,
665        embedder.as_ref(),
666        &encoder,
667    )?;
668    save_cover_to_path(&stego, &args.output)?;
669    eprintln!("Dead drop encoded for {platform:?}");
670    Ok(())
671}
672
673// ─── Time Lock ────────────────────────────────────────────────────────────────
674
675fn cmd_time_lock(args: &cli::TimeLockArgs) -> Result<(), AppError> {
676    let service = crate::adapters::timelock::TimeLockServiceImpl::default();
677    match &args.subcmd {
678        cli::TimeLockSubcommand::Lock {
679            input,
680            unlock_at,
681            output_puzzle,
682        } => {
683            let data = fs_read(input)?;
684            let payload = Payload::from_bytes(data);
685            let ts = chrono::DateTime::parse_from_rfc3339(unlock_at)
686                .map_err(
687                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
688                        reason: format!("invalid RFC 3339 timestamp: {e}"),
689                    },
690                )?
691                .with_timezone(&chrono::Utc);
692            let puzzle =
693                crate::application::services::TimeLockServiceApp::lock(&payload, ts, &service)?;
694            let encoded = serde_json::to_vec_pretty(&puzzle).map_err(|e| {
695                crate::domain::errors::TimeLockError::ComputationFailed {
696                    reason: format!("serialize puzzle: {e}"),
697                }
698            })?;
699            fs_write(output_puzzle, &encoded)?;
700            eprintln!("Puzzle → {}", output_puzzle.display());
701        }
702        cli::TimeLockSubcommand::Unlock {
703            puzzle: puzzle_path,
704            output,
705        } => {
706            let data = fs_read(puzzle_path)?;
707            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
708                .map_err(
709                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
710                        reason: format!("deserialize puzzle: {e}"),
711                    },
712                )?;
713            let payload =
714                crate::application::services::TimeLockServiceApp::unlock(&puzzle, &service)?;
715            fs_write(output, payload.as_bytes())?;
716            eprintln!("Unlocked {} bytes", payload.len());
717        }
718        cli::TimeLockSubcommand::TryUnlock {
719            puzzle: puzzle_path,
720        } => {
721            let data = fs_read(puzzle_path)?;
722            let puzzle: crate::domain::types::TimeLockPuzzle = serde_json::from_slice(&data)
723                .map_err(
724                    |e| crate::domain::errors::TimeLockError::ComputationFailed {
725                        reason: format!("deserialize puzzle: {e}"),
726                    },
727                )?;
728            match crate::application::services::TimeLockServiceApp::try_unlock(&puzzle, &service)? {
729                Some(p) => eprintln!("Puzzle solved: {} bytes", p.len()),
730                None => eprintln!("Puzzle not yet solvable"),
731            }
732        }
733    }
734    Ok(())
735}
736
737// ─── Watermark ────────────────────────────────────────────────────────────────
738
739fn cmd_watermark(args: &cli::WatermarkArgs) -> Result<(), AppError> {
740    match &args.subcmd {
741        cli::WatermarkSubcommand::EmbedTripwire {
742            cover,
743            output,
744            recipient_id,
745        } => {
746            let cover_media =
747                load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
748            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
749            let rid = uuid::Uuid::parse_str(recipient_id).map_err(|e| {
750                crate::domain::errors::OpsecError::WatermarkError {
751                    reason: format!("invalid UUID: {e}"),
752                }
753            })?;
754            let tag = crate::domain::types::WatermarkTripwireTag {
755                recipient_id: rid,
756                embedding_seed: recipient_id.as_bytes().to_vec(),
757            };
758            let stego = crate::application::services::ForensicService::embed_tripwire(
759                cover_media,
760                &tag,
761                &watermarker,
762            )?;
763            save_cover_to_path(&stego, output)?;
764            eprintln!("Tripwire embedded for {recipient_id}");
765        }
766        cli::WatermarkSubcommand::Identify { cover, tags } => {
767            let cover_media =
768                load_cover_from_path(cover, crate::domain::types::StegoTechnique::LsbImage)?;
769            let watermarker = crate::adapters::opsec::ForensicWatermarkerImpl::new();
770            let tag_list = load_watermark_tags(tags)?;
771            let receipt = crate::application::services::ForensicService::identify_recipient(
772                &cover_media,
773                &tag_list,
774                &watermarker,
775            )?;
776            match receipt {
777                Some(r) => println!("Identified recipient: {r}"),
778                None => println!("No matching watermark found"),
779            }
780        }
781    }
782    Ok(())
783}
784
785// ─── Corpus ───────────────────────────────────────────────────────────────────
786
787fn cmd_corpus(args: &cli::CorpusArgs) -> Result<(), AppError> {
788    let index = crate::adapters::corpus::CorpusIndexImpl::new();
789    match &args.subcmd {
790        cli::CorpusSubcommand::Build { dir } => {
791            let count = CorpusService::build_index(&index, dir)?;
792            eprintln!("Indexed {count} images from {}", dir.display());
793        }
794        cli::CorpusSubcommand::Search {
795            input,
796            technique,
797            top,
798            model,
799            resolution,
800        } => {
801            let data = fs_read(input)?;
802            let payload = Payload::from_bytes(data);
803            let tech = resolve_technique(*technique);
804
805            // If --model is provided, use model-aware search.  --resolution
806            // is required in that case; an absent or unparseable value returns
807            // a clear error rather than silently falling back to (0, 0).
808            let results = if let Some(model_id) = model {
809                let res = resolution
810                    .as_deref()
811                    .and_then(|s| {
812                        let mut parts = s.splitn(2, 'x');
813                        let w = parts.next()?.parse::<u32>().ok()?;
814                        let h = parts.next()?.parse::<u32>().ok()?;
815                        Some((w, h))
816                    })
817                    .ok_or_else(|| {
818                        AppError::Stego(StegoError::MalformedCoverData {
819                            reason: "--model requires --resolution in WIDTHxHEIGHT format \
820                                     (e.g. --resolution 1024x1024)"
821                                .to_string(),
822                        })
823                    })?;
824                CorpusService::search_for_model(&index, &payload, model_id, res, *top)?
825            } else {
826                CorpusService::search(&index, &payload, tech, *top)?
827            };
828
829            for entry in &results {
830                println!("{}", entry.path);
831            }
832        }
833    }
834    Ok(())
835}
836
837// ─── Panic ────────────────────────────────────────────────────────────────────
838
839fn cmd_panic(args: &cli::PanicArgs) -> Result<(), AppError> {
840    let config = crate::domain::types::PanicWipeConfig {
841        key_paths: args.key_paths.iter().map(PathBuf::from).collect(),
842        config_paths: Vec::new(),
843        temp_dirs: Vec::new(),
844    };
845    let wiper = crate::adapters::opsec::SecurePanicWiper::new();
846    crate::application::services::PanicWipeService::wipe(&config, &wiper)?;
847    Ok(())
848}
849
850// ─── Completions ──────────────────────────────────────────────────────────────
851
852fn cmd_completions(args: &cli::CompletionsArgs) -> Result<(), AppError> {
853    use clap::CommandFactory;
854    use clap_complete::generate;
855
856    let mut cmd = Cli::command();
857    match &args.output {
858        Some(path) => {
859            let mut file = std::fs::File::create(path).map_err(|e| {
860                AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
861                    reason: format!("write {}: {e}", path.display()),
862                })
863            })?;
864            generate(args.shell, &mut cmd, "shadowforge", &mut file);
865        }
866        None => {
867            generate(args.shell, &mut cmd, "shadowforge", &mut io::stdout());
868        }
869    }
870    Ok(())
871}
872
873// ─── Cipher ───────────────────────────────────────────────────────────────────
874
875/// AES-256-GCM nonce length in bytes.
876const AES_GCM_NONCE_LEN: usize = 12;
877
878fn cmd_cipher(args: &cli::CipherArgs) -> Result<(), AppError> {
879    use rand_core::Rng as _;
880
881    let cipher = crate::adapters::crypto::Aes256GcmCipher;
882    match &args.subcmd {
883        cli::CipherSubcommand::Encrypt { input, key, output } => {
884            let plaintext = fs_read(input)?;
885            let key_bytes = fs_read(key)?;
886            let mut nonce = [0u8; AES_GCM_NONCE_LEN];
887            rand::rng().fill_bytes(&mut nonce);
888            let ciphertext = CipherService::encrypt(&cipher, &key_bytes, &nonce, &plaintext)?;
889            let mut out = Vec::with_capacity(AES_GCM_NONCE_LEN.strict_add(ciphertext.len()));
890            out.extend_from_slice(&nonce);
891            out.extend_from_slice(&ciphertext);
892            fs_write(output, &out)?;
893            eprintln!(
894                "Encrypted {} bytes -> {}",
895                plaintext.len(),
896                output.display()
897            );
898        }
899        cli::CipherSubcommand::Decrypt { input, key, output } => {
900            let data = fs_read(input)?;
901            let key_bytes = fs_read(key)?;
902            if data.len() < AES_GCM_NONCE_LEN {
903                return Err(AppError::Crypto(
904                    crate::domain::errors::CryptoError::InvalidNonceLength {
905                        expected: AES_GCM_NONCE_LEN,
906                        got: data.len(),
907                    },
908                ));
909            }
910            let (nonce, ciphertext) = data.split_at(AES_GCM_NONCE_LEN);
911            let plaintext = CipherService::decrypt(&cipher, &key_bytes, nonce, ciphertext)?;
912            fs_write(output, &plaintext)?;
913            eprintln!(
914                "Decrypted {} bytes -> {}",
915                plaintext.len(),
916                output.display()
917            );
918        }
919    }
920    Ok(())
921}
922
923// ═══════════════════════════════════════════════════════════════════════════════
924// Helpers
925// ═══════════════════════════════════════════════════════════════════════════════
926
927fn fs_read(path: &Path) -> Result<Vec<u8>, AppError> {
928    std::fs::read(path).map_err(|e| {
929        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
930            reason: format!("read {}: {e}", path.display()),
931        })
932    })
933}
934
935fn fs_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
936    if let Some(parent) = path.parent() {
937        fs_create_dir_all(parent)?;
938    }
939    std::fs::write(path, data).map_err(|e| {
940        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
941            reason: format!("write {}: {e}", path.display()),
942        })
943    })
944}
945
946fn fs_create_dir_all(path: &Path) -> Result<(), AppError> {
947    std::fs::create_dir_all(path).map_err(|e| {
948        AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData {
949            reason: format!("mkdir {}: {e}", path.display()),
950        })
951    })
952}
953
954fn map_media_error(error: crate::domain::errors::MediaError) -> AppError {
955    match error {
956        crate::domain::errors::MediaError::UnsupportedFormat { extension } => {
957            AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
958                reason: format!("unsupported cover format: {extension}"),
959            })
960        }
961        crate::domain::errors::MediaError::DecodeFailed { reason }
962        | crate::domain::errors::MediaError::EncodeFailed { reason }
963        | crate::domain::errors::MediaError::IoError { reason } => {
964            AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
965        }
966    }
967}
968
969#[cfg(feature = "pdf")]
970fn map_pdf_runner_error(error: crate::domain::errors::PdfError) -> AppError {
971    match error {
972        crate::domain::errors::PdfError::Encrypted => {
973            AppError::Stego(crate::domain::errors::StegoError::UnsupportedCoverType {
974                reason: "encrypted PDF documents are not supported".to_string(),
975            })
976        }
977        crate::domain::errors::PdfError::ParseFailed { reason }
978        | crate::domain::errors::PdfError::RenderFailed { reason, .. }
979        | crate::domain::errors::PdfError::RebuildFailed { reason }
980        | crate::domain::errors::PdfError::EmbedFailed { reason }
981        | crate::domain::errors::PdfError::ExtractFailed { reason }
982        | crate::domain::errors::PdfError::IoError { reason } => {
983            AppError::Stego(crate::domain::errors::StegoError::MalformedCoverData { reason })
984        }
985    }
986}
987
988fn load_cover_from_path(path: &Path, technique: StegoTechnique) -> Result<CoverMedia, AppError> {
989    match technique {
990        StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
991            let loader = crate::adapters::media::AudioMediaLoader;
992            loader.load(path).map_err(map_media_error)
993        }
994        StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => load_pdf_cover(path),
995        StegoTechnique::ZeroWidthText => {
996            let data = fs_read(path)?;
997            Ok(load_cover(technique, &data))
998        }
999        StegoTechnique::LsbImage
1000        | StegoTechnique::DctJpeg
1001        | StegoTechnique::Palette
1002        | StegoTechnique::CorpusSelection
1003        | StegoTechnique::DualPayload => {
1004            let loader = crate::adapters::media::ImageMediaLoader;
1005            loader.load(path).map_err(map_media_error)
1006        }
1007    }
1008}
1009
1010#[cfg(feature = "pdf")]
1011fn load_pdf_cover(path: &Path) -> Result<CoverMedia, AppError> {
1012    use crate::domain::ports::PdfProcessor;
1013
1014    let processor = crate::adapters::pdf::PdfProcessorImpl::default();
1015    processor.load_pdf(path).map_err(map_pdf_runner_error)
1016}
1017
1018#[cfg(not(feature = "pdf"))]
1019fn load_pdf_cover(_path: &Path) -> Result<CoverMedia, AppError> {
1020    Err(AppError::Stego(
1021        crate::domain::errors::StegoError::UnsupportedCoverType {
1022            reason: "PDF support is not enabled in this build".to_string(),
1023        },
1024    ))
1025}
1026
1027fn save_cover_to_path(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
1028    if let Some(parent) = path.parent() {
1029        fs_create_dir_all(parent)?;
1030    }
1031
1032    match media.kind {
1033        CoverMediaKind::PngImage
1034        | CoverMediaKind::BmpImage
1035        | CoverMediaKind::JpegImage
1036        | CoverMediaKind::GifImage => {
1037            let loader = crate::adapters::media::ImageMediaLoader;
1038            loader.save(media, path).map_err(map_media_error)
1039        }
1040        CoverMediaKind::WavAudio => {
1041            let loader = crate::adapters::media::AudioMediaLoader;
1042            loader.save(media, path).map_err(map_media_error)
1043        }
1044        CoverMediaKind::PdfDocument => save_pdf_cover(media, path),
1045        CoverMediaKind::PlainText => fs_write(path, media.data.as_ref()),
1046    }
1047}
1048
1049#[cfg(feature = "pdf")]
1050fn save_pdf_cover(media: &CoverMedia, path: &Path) -> Result<(), AppError> {
1051    use crate::domain::ports::PdfProcessor;
1052
1053    let processor = crate::adapters::pdf::PdfProcessorImpl::default();
1054    processor
1055        .save_pdf(media, path)
1056        .map_err(map_pdf_runner_error)
1057}
1058
1059#[cfg(not(feature = "pdf"))]
1060fn save_pdf_cover(_media: &CoverMedia, _path: &Path) -> Result<(), AppError> {
1061    Err(AppError::Stego(
1062        crate::domain::errors::StegoError::UnsupportedCoverType {
1063            reason: "PDF support is not enabled in this build".to_string(),
1064        },
1065    ))
1066}
1067
1068fn serialise_cover_to_bytes(media: &CoverMedia) -> Result<Vec<u8>, AppError> {
1069    match media.kind {
1070        CoverMediaKind::PdfDocument | CoverMediaKind::PlainText => Ok(media.data.to_vec()),
1071        _ => {
1072            let temp_path = std::env::temp_dir().join(format!(
1073                "shadowforge-{}.{}",
1074                uuid::Uuid::new_v4(),
1075                cover_file_extension(media.kind)
1076            ));
1077            let result = (|| {
1078                save_cover_to_path(media, &temp_path)?;
1079                fs_read(&temp_path)
1080            })();
1081            let _ = std::fs::remove_file(&temp_path);
1082            result
1083        }
1084    }
1085}
1086
1087fn load_cover_from_named_bytes(
1088    name: &str,
1089    technique: StegoTechnique,
1090    data: &[u8],
1091) -> Result<CoverMedia, AppError> {
1092    if technique == StegoTechnique::ZeroWidthText {
1093        return Ok(load_cover(technique, data));
1094    }
1095
1096    let extension = Path::new(name)
1097        .extension()
1098        .and_then(|value| value.to_str())
1099        .unwrap_or_else(|| technique_file_extension(technique));
1100    let temp_path = std::env::temp_dir().join(format!(
1101        "shadowforge-{}.{}",
1102        uuid::Uuid::new_v4(),
1103        extension
1104    ));
1105    let result = (|| {
1106        fs_write(&temp_path, data)?;
1107        load_cover_from_path(&temp_path, technique)
1108    })();
1109    let _ = std::fs::remove_file(&temp_path);
1110    result
1111}
1112
1113const fn cover_file_extension(kind: CoverMediaKind) -> &'static str {
1114    match kind {
1115        CoverMediaKind::PngImage => "png",
1116        CoverMediaKind::BmpImage => "bmp",
1117        CoverMediaKind::JpegImage => "jpg",
1118        CoverMediaKind::GifImage => "gif",
1119        CoverMediaKind::WavAudio => "wav",
1120        CoverMediaKind::PdfDocument => "pdf",
1121        CoverMediaKind::PlainText => "txt",
1122    }
1123}
1124
1125const fn technique_file_extension(technique: StegoTechnique) -> &'static str {
1126    match technique {
1127        StegoTechnique::LsbAudio | StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
1128            "wav"
1129        }
1130        StegoTechnique::PdfContentStream | StegoTechnique::PdfMetadata => "pdf",
1131        StegoTechnique::ZeroWidthText => "txt",
1132        StegoTechnique::DctJpeg => "jpg",
1133        StegoTechnique::LsbImage
1134        | StegoTechnique::Palette
1135        | StegoTechnique::CorpusSelection
1136        | StegoTechnique::DualPayload => "png",
1137    }
1138}
1139
1140fn load_cover(technique: crate::domain::types::StegoTechnique, data: &[u8]) -> CoverMedia {
1141    let kind = match technique {
1142        crate::domain::types::StegoTechnique::LsbAudio
1143        | crate::domain::types::StegoTechnique::PhaseEncoding
1144        | crate::domain::types::StegoTechnique::EchoHiding => CoverMediaKind::WavAudio,
1145        crate::domain::types::StegoTechnique::ZeroWidthText => CoverMediaKind::PlainText,
1146        crate::domain::types::StegoTechnique::PdfContentStream
1147        | crate::domain::types::StegoTechnique::PdfMetadata => CoverMediaKind::PdfDocument,
1148        crate::domain::types::StegoTechnique::DctJpeg => CoverMediaKind::JpegImage,
1149        crate::domain::types::StegoTechnique::LsbImage
1150        | crate::domain::types::StegoTechnique::Palette
1151        | crate::domain::types::StegoTechnique::CorpusSelection
1152        | crate::domain::types::StegoTechnique::DualPayload => CoverMediaKind::PngImage,
1153    };
1154    CoverMedia {
1155        kind,
1156        data: bytes::Bytes::from(data.to_vec()),
1157        metadata: std::collections::HashMap::new(),
1158    }
1159}
1160
1161/// Build an embedder for the given technique.
1162fn build_embedder(technique: crate::domain::types::StegoTechnique) -> Box<dyn EmbedTechnique> {
1163    use crate::domain::types::StegoTechnique;
1164    match technique {
1165        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
1166        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
1167        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
1168        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
1169        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
1170        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1171        StegoTechnique::PdfContentStream => build_pdf_content_stream_embedder(),
1172        StegoTechnique::PdfMetadata => build_pdf_metadata_embedder(),
1173        StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1174            StegoTechnique::CorpusSelection,
1175            "corpus selection must use the corpus workflow, not generic embed",
1176        )),
1177        StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1178            StegoTechnique::DualPayload,
1179            "dual-payload embedding must use the deniable embed workflow",
1180        )),
1181        StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1182    }
1183}
1184
1185/// Build an extractor for the given technique.
1186fn build_extractor(technique: crate::domain::types::StegoTechnique) -> Box<dyn ExtractTechnique> {
1187    use crate::domain::types::StegoTechnique;
1188    match technique {
1189        StegoTechnique::DctJpeg => Box::new(crate::adapters::stego::DctJpeg),
1190        StegoTechnique::Palette => Box::new(crate::adapters::stego::PaletteStego),
1191        StegoTechnique::LsbAudio => Box::new(crate::adapters::stego::LsbAudio),
1192        StegoTechnique::PhaseEncoding => Box::new(crate::adapters::stego::PhaseEncoding),
1193        StegoTechnique::EchoHiding => Box::new(crate::adapters::stego::EchoHiding),
1194        StegoTechnique::ZeroWidthText => Box::new(crate::adapters::stego::ZeroWidthText),
1195        StegoTechnique::PdfContentStream => build_pdf_content_stream_extractor(),
1196        StegoTechnique::PdfMetadata => build_pdf_metadata_extractor(),
1197        StegoTechnique::CorpusSelection => Box::new(UnsupportedTechnique::new(
1198            StegoTechnique::CorpusSelection,
1199            "corpus selection must use the corpus workflow, not generic extract",
1200        )),
1201        StegoTechnique::DualPayload => Box::new(UnsupportedTechnique::new(
1202            StegoTechnique::DualPayload,
1203            "dual-payload extraction must use the deniable extraction workflow",
1204        )),
1205        StegoTechnique::LsbImage => Box::new(crate::adapters::stego::LsbImage::new()),
1206    }
1207}
1208
1209#[cfg(feature = "pdf")]
1210fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1211    Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1212}
1213
1214#[cfg(not(feature = "pdf"))]
1215fn build_pdf_content_stream_embedder() -> Box<dyn EmbedTechnique> {
1216    Box::new(UnsupportedTechnique::new(
1217        crate::domain::types::StegoTechnique::PdfContentStream,
1218        "PDF support is not enabled in this build",
1219    ))
1220}
1221
1222#[cfg(feature = "pdf")]
1223fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1224    Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1225}
1226
1227#[cfg(not(feature = "pdf"))]
1228fn build_pdf_metadata_embedder() -> Box<dyn EmbedTechnique> {
1229    Box::new(UnsupportedTechnique::new(
1230        crate::domain::types::StegoTechnique::PdfMetadata,
1231        "PDF support is not enabled in this build",
1232    ))
1233}
1234
1235#[cfg(feature = "pdf")]
1236fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1237    Box::new(crate::adapters::pdf::PdfContentStreamStego::new())
1238}
1239
1240#[cfg(not(feature = "pdf"))]
1241fn build_pdf_content_stream_extractor() -> Box<dyn ExtractTechnique> {
1242    Box::new(UnsupportedTechnique::new(
1243        crate::domain::types::StegoTechnique::PdfContentStream,
1244        "PDF support is not enabled in this build",
1245    ))
1246}
1247
1248#[cfg(feature = "pdf")]
1249fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1250    Box::new(crate::adapters::pdf::PdfMetadataStego::new())
1251}
1252
1253#[cfg(not(feature = "pdf"))]
1254fn build_pdf_metadata_extractor() -> Box<dyn ExtractTechnique> {
1255    Box::new(UnsupportedTechnique::new(
1256        crate::domain::types::StegoTechnique::PdfMetadata,
1257        "PDF support is not enabled in this build",
1258    ))
1259}
1260
1261#[derive(Debug)]
1262struct UnsupportedTechnique {
1263    technique: crate::domain::types::StegoTechnique,
1264    reason: &'static str,
1265}
1266
1267impl UnsupportedTechnique {
1268    const fn new(technique: crate::domain::types::StegoTechnique, reason: &'static str) -> Self {
1269        Self { technique, reason }
1270    }
1271}
1272
1273impl EmbedTechnique for UnsupportedTechnique {
1274    fn technique(&self) -> crate::domain::types::StegoTechnique {
1275        self.technique
1276    }
1277
1278    fn capacity(&self, _cover: &CoverMedia) -> Result<crate::domain::types::Capacity, StegoError> {
1279        Err(StegoError::UnsupportedCoverType {
1280            reason: self.reason.to_string(),
1281        })
1282    }
1283
1284    fn embed(&self, _cover: CoverMedia, _payload: &Payload) -> Result<CoverMedia, StegoError> {
1285        Err(StegoError::UnsupportedCoverType {
1286            reason: self.reason.to_string(),
1287        })
1288    }
1289}
1290
1291impl ExtractTechnique for UnsupportedTechnique {
1292    fn technique(&self) -> crate::domain::types::StegoTechnique {
1293        self.technique
1294    }
1295
1296    fn extract(&self, _stego: &CoverMedia) -> Result<Payload, StegoError> {
1297        Err(StegoError::UnsupportedCoverType {
1298            reason: self.reason.to_string(),
1299        })
1300    }
1301}
1302
1303const fn resolve_technique(t: cli::Technique) -> crate::domain::types::StegoTechnique {
1304    match t {
1305        cli::Technique::Lsb => crate::domain::types::StegoTechnique::LsbImage,
1306        cli::Technique::Dct => crate::domain::types::StegoTechnique::DctJpeg,
1307        cli::Technique::Palette => crate::domain::types::StegoTechnique::Palette,
1308        cli::Technique::LsbAudio => crate::domain::types::StegoTechnique::LsbAudio,
1309        cli::Technique::Phase => crate::domain::types::StegoTechnique::PhaseEncoding,
1310        cli::Technique::Echo => crate::domain::types::StegoTechnique::EchoHiding,
1311        cli::Technique::ZeroWidth => crate::domain::types::StegoTechnique::ZeroWidthText,
1312        cli::Technique::PdfStream => crate::domain::types::StegoTechnique::PdfContentStream,
1313        cli::Technique::PdfMeta => crate::domain::types::StegoTechnique::PdfMetadata,
1314        cli::Technique::Corpus => crate::domain::types::StegoTechnique::CorpusSelection,
1315    }
1316}
1317
1318fn resolve_profile(
1319    profile: cli::Profile,
1320    platform: Option<cli::Platform>,
1321) -> crate::domain::types::EmbeddingProfile {
1322    match profile {
1323        cli::Profile::Standard => crate::domain::types::EmbeddingProfile::Standard,
1324        cli::Profile::Adaptive => crate::domain::types::EmbeddingProfile::default_adaptive(),
1325        cli::Profile::Survivable => {
1326            let p = platform.map_or(crate::domain::types::PlatformProfile::Instagram, |pl| {
1327                resolve_platform(pl)
1328            });
1329            crate::domain::types::EmbeddingProfile::CompressionSurvivable { platform: p }
1330        }
1331    }
1332}
1333
1334const fn resolve_platform(p: cli::Platform) -> crate::domain::types::PlatformProfile {
1335    match p {
1336        cli::Platform::Instagram => crate::domain::types::PlatformProfile::Instagram,
1337        cli::Platform::Twitter => crate::domain::types::PlatformProfile::Twitter,
1338        cli::Platform::Whatsapp => crate::domain::types::PlatformProfile::WhatsApp,
1339        cli::Platform::Telegram => crate::domain::types::PlatformProfile::Telegram,
1340        cli::Platform::Imgur => crate::domain::types::PlatformProfile::Imgur,
1341    }
1342}
1343
1344const fn resolve_archive_format(f: cli::ArchiveFormat) -> crate::domain::types::ArchiveFormat {
1345    match f {
1346        cli::ArchiveFormat::Zip => crate::domain::types::ArchiveFormat::Zip,
1347        cli::ArchiveFormat::Tar => crate::domain::types::ArchiveFormat::Tar,
1348        cli::ArchiveFormat::TarGz => crate::domain::types::ArchiveFormat::TarGz,
1349    }
1350}
1351
1352/// Collect cover file paths — if the argument is a glob pattern expand it,
1353/// otherwise treat it as a directory and list its files.
1354fn collect_cover_paths(pattern: &str) -> Result<Vec<PathBuf>, AppError> {
1355    let path = PathBuf::from(pattern);
1356    let paths: Vec<PathBuf> = if path.is_dir() {
1357        std::fs::read_dir(&path)
1358            .map_err(
1359                |_| crate::domain::errors::DistributionError::InsufficientCovers {
1360                    needed: 1,
1361                    got: 0,
1362                },
1363            )?
1364            .filter_map(Result::ok)
1365            .map(|e| e.path())
1366            .filter(|p| p.is_file())
1367            .collect()
1368    } else if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
1369        glob::glob(pattern)
1370            .map_err(
1371                |_| crate::domain::errors::DistributionError::InsufficientCovers {
1372                    needed: 1,
1373                    got: 0,
1374                },
1375            )?
1376            .filter_map(Result::ok)
1377            .filter(|p| p.is_file())
1378            .collect()
1379    } else {
1380        // Treat as a literal file list (single file)
1381        vec![path]
1382    };
1383
1384    if paths.is_empty() {
1385        return Err(
1386            crate::domain::errors::DistributionError::InsufficientCovers { needed: 1, got: 0 }
1387                .into(),
1388        );
1389    }
1390    Ok(paths)
1391}
1392
1393fn load_watermark_tags(
1394    dir: &Path,
1395) -> Result<Vec<crate::domain::types::WatermarkTripwireTag>, AppError> {
1396    let mut tags = Vec::new();
1397    if dir.is_dir() {
1398        let entries = std::fs::read_dir(dir).map_err(|e| {
1399            crate::domain::errors::OpsecError::WatermarkError {
1400                reason: format!("read dir: {e}"),
1401            }
1402        })?;
1403        for entry in entries {
1404            let entry = entry.map_err(|e: std::io::Error| {
1405                crate::domain::errors::OpsecError::WatermarkError {
1406                    reason: format!("dir entry: {e}"),
1407                }
1408            })?;
1409            let path = entry.path();
1410            if path.is_file() {
1411                // Parse tag from file: content is a recipient UUID string, used as seed too
1412                if let Ok(content) = std::fs::read_to_string(&path) {
1413                    let id_str = content.trim();
1414                    let rid = uuid::Uuid::parse_str(id_str).map_err(|e| {
1415                        crate::domain::errors::OpsecError::WatermarkError {
1416                            reason: format!("invalid UUID in {}: {e}", path.display()),
1417                        }
1418                    })?;
1419                    tags.push(crate::domain::types::WatermarkTripwireTag {
1420                        recipient_id: rid,
1421                        embedding_seed: id_str.as_bytes().to_vec(),
1422                    });
1423                }
1424            }
1425        }
1426    }
1427    Ok(tags)
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432    use super::{
1433        build_embedder, build_extractor, cmd_extract, cmd_keygen, load_cover, load_cover_from_path,
1434        save_cover_to_path,
1435    };
1436    use crate::application::services::{AppError, DeniableEmbedService};
1437    use crate::domain::errors::CryptoError;
1438    use crate::domain::types::{CoverMediaKind, StegoTechnique};
1439    use crate::interface::cli;
1440    use image::{DynamicImage, Rgba, RgbaImage};
1441    use std::fs;
1442    use tempfile::tempdir;
1443
1444    type TestResult = Result<(), Box<dyn std::error::Error>>;
1445
1446    #[test]
1447    fn dct_cover_is_classified_as_jpeg() {
1448        let cover = load_cover(StegoTechnique::DctJpeg, b"jpeg");
1449        assert_eq!(cover.kind, CoverMediaKind::JpegImage);
1450    }
1451
1452    #[test]
1453    fn pdf_cover_is_classified_as_pdf() {
1454        let cover = load_cover(StegoTechnique::PdfContentStream, b"%PDF-1.7");
1455        assert_eq!(cover.kind, CoverMediaKind::PdfDocument);
1456    }
1457
1458    #[test]
1459    fn pdf_content_stream_embedder_uses_pdf_technique() {
1460        let embedder = build_embedder(StegoTechnique::PdfContentStream);
1461        assert_eq!(embedder.technique(), StegoTechnique::PdfContentStream);
1462    }
1463
1464    #[test]
1465    fn pdf_metadata_extractor_uses_pdf_technique() {
1466        let extractor = build_extractor(StegoTechnique::PdfMetadata);
1467        assert_eq!(extractor.technique(), StegoTechnique::PdfMetadata);
1468    }
1469
1470    #[test]
1471    fn corpus_embedder_is_not_rewritten_as_lsb_image() {
1472        let embedder = build_embedder(StegoTechnique::CorpusSelection);
1473        assert_eq!(embedder.technique(), StegoTechnique::CorpusSelection);
1474    }
1475
1476    #[test]
1477    fn file_backed_image_covers_use_media_loader() -> TestResult {
1478        let dir = tempdir()?;
1479        let input_path = dir.path().join("cover.png");
1480        let output_path = dir.path().join("roundtrip.png");
1481
1482        let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 2, Rgba([1, 2, 3, 255])));
1483        image.save(&input_path)?;
1484
1485        let cover = load_cover_from_path(&input_path, StegoTechnique::LsbImage)?;
1486        assert_eq!(cover.kind, CoverMediaKind::PngImage);
1487        assert_eq!(cover.metadata.get("width"), Some(&"3".to_string()));
1488        assert_eq!(cover.metadata.get("height"), Some(&"2".to_string()));
1489
1490        save_cover_to_path(&cover, &output_path)?;
1491        let written = fs::read(output_path)?;
1492        assert!(!written.is_empty());
1493        Ok(())
1494    }
1495
1496    #[test]
1497    fn extract_uses_deniable_key_path_when_provided() -> TestResult {
1498        let dir = tempdir()?;
1499        let cover_path = dir.path().join("cover.png");
1500        let input_path = dir.path().join("input.png");
1501        let output_path = dir.path().join("output.bin");
1502        let key_path = dir.path().join("primary.key");
1503
1504        let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(40, 40, Rgba([0, 0, 0, 255])));
1505        image.save(&cover_path)?;
1506
1507        let primary_key = vec![7u8; 32];
1508        let decoy_key = vec![9u8; 32];
1509        let pair = crate::domain::types::DeniablePayloadPair {
1510            real_payload: b"real secret".to_vec(),
1511            decoy_payload: b"decoy".to_vec(),
1512        };
1513        let keys = crate::domain::types::DeniableKeySet {
1514            primary_key: primary_key.clone(),
1515            decoy_key,
1516        };
1517        let cover = load_cover_from_path(&cover_path, StegoTechnique::LsbImage)?;
1518        let deniable = crate::adapters::stego::DualPayloadEmbedder;
1519        let embedder = build_embedder(StegoTechnique::LsbImage);
1520        let stego =
1521            DeniableEmbedService::embed_dual(cover, &pair, &keys, embedder.as_ref(), &deniable)?;
1522
1523        save_cover_to_path(&stego, &input_path)?;
1524        fs::write(&key_path, &primary_key)?;
1525
1526        let args = cli::ExtractArgs {
1527            input: input_path,
1528            output: output_path.clone(),
1529            technique: cli::Technique::Lsb,
1530            key: Some(key_path),
1531            amnesia: false,
1532        };
1533
1534        cmd_extract(&args)?;
1535
1536        let extracted = fs::read(output_path)?;
1537        assert_eq!(extracted, b"real secret");
1538        Ok(())
1539    }
1540
1541    #[test]
1542    fn keygen_sign_produces_signature_artifact() -> TestResult {
1543        let dir = tempdir()?;
1544        let keys_dir = dir.path().join("keys");
1545        let msg_path = dir.path().join("message.bin");
1546        let sig_path = dir.path().join("message.sig");
1547
1548        let generate = cli::KeygenArgs {
1549            subcmd: None,
1550            algorithm: Some(cli::Algorithm::Dilithium3),
1551            output: Some(keys_dir.clone()),
1552        };
1553        cmd_keygen(&generate)?;
1554
1555        fs::write(&msg_path, b"hello signer")?;
1556        let sign = cli::KeygenArgs {
1557            subcmd: Some(cli::KeygenSubcommand::Sign {
1558                input: msg_path,
1559                secret_key: keys_dir.join("secret.key"),
1560                output: sig_path.clone(),
1561            }),
1562            algorithm: None,
1563            output: None,
1564        };
1565        cmd_keygen(&sign)?;
1566
1567        let sig = fs::read(sig_path)?;
1568        assert!(!sig.is_empty());
1569        Ok(())
1570    }
1571
1572    #[test]
1573    fn keygen_verify_reports_success_and_failure_status() -> TestResult {
1574        let dir = tempdir()?;
1575        let keys_dir = dir.path().join("keys");
1576        let msg_path = dir.path().join("message.bin");
1577        let tampered_path = dir.path().join("message_tampered.bin");
1578        let sig_path = dir.path().join("message.sig");
1579
1580        let generate = cli::KeygenArgs {
1581            subcmd: None,
1582            algorithm: Some(cli::Algorithm::Dilithium3),
1583            output: Some(keys_dir.clone()),
1584        };
1585        cmd_keygen(&generate)?;
1586
1587        fs::write(&msg_path, b"signed message")?;
1588        fs::write(&tampered_path, b"signed message with tamper")?;
1589
1590        let sign = cli::KeygenArgs {
1591            subcmd: Some(cli::KeygenSubcommand::Sign {
1592                input: msg_path.clone(),
1593                secret_key: keys_dir.join("secret.key"),
1594                output: sig_path.clone(),
1595            }),
1596            algorithm: None,
1597            output: None,
1598        };
1599        cmd_keygen(&sign)?;
1600
1601        let verify_ok = cli::KeygenArgs {
1602            subcmd: Some(cli::KeygenSubcommand::Verify {
1603                input: msg_path,
1604                public_key: keys_dir.join("public.key"),
1605                signature: sig_path.clone(),
1606            }),
1607            algorithm: None,
1608            output: None,
1609        };
1610        assert!(cmd_keygen(&verify_ok).is_ok());
1611
1612        let verify_fail = cli::KeygenArgs {
1613            subcmd: Some(cli::KeygenSubcommand::Verify {
1614                input: tampered_path,
1615                public_key: keys_dir.join("public.key"),
1616                signature: sig_path,
1617            }),
1618            algorithm: None,
1619            output: None,
1620        };
1621        let err = cmd_keygen(&verify_fail).err();
1622        assert!(matches!(
1623            err,
1624            Some(AppError::Crypto(CryptoError::VerificationFailed { .. }))
1625        ));
1626        Ok(())
1627    }
1628
1629    // ─── format_analysis_report ───────────────────────────────────────────
1630
1631    use super::format_analysis_report;
1632    use crate::domain::types::{
1633        AiWatermarkAssessment, AnalysisReport, Capacity, DetectabilityRisk, SpectralScore,
1634        StegoTechnique as ST,
1635    };
1636
1637    fn base_report() -> AnalysisReport {
1638        AnalysisReport {
1639            technique: ST::LsbImage,
1640            cover_capacity: Capacity {
1641                bytes: 1024,
1642                technique: ST::LsbImage,
1643            },
1644            chi_square_score: std::f64::consts::PI,
1645            detectability_risk: DetectabilityRisk::Low,
1646            recommended_max_payload_bytes: 512,
1647            ai_watermark: None,
1648            spectral_score: None,
1649        }
1650    }
1651
1652    #[test]
1653    fn format_report_contains_core_fields() {
1654        let report = base_report();
1655        let out = format_analysis_report(&report);
1656        assert!(out.contains("LsbImage"), "technique should appear");
1657        assert!(out.contains("1024 bytes"), "capacity should appear");
1658        assert!(out.contains("3.14 dB"), "chi-square should appear");
1659        assert!(out.contains("Low"), "risk should appear");
1660        assert!(out.contains("512 bytes"), "recommended max should appear");
1661    }
1662
1663    #[test]
1664    fn format_report_omits_spectral_section_when_none() {
1665        let report = base_report();
1666        let out = format_analysis_report(&report);
1667        assert!(
1668            !out.contains("Spectral Detectability"),
1669            "spectral section must be absent when score is None"
1670        );
1671    }
1672
1673    #[test]
1674    fn format_report_includes_spectral_section_when_present() {
1675        let mut report = base_report();
1676        report.spectral_score = Some(SpectralScore {
1677            phase_coherence_drop: 0.1234,
1678            carrier_snr_drop_db: -2.5,
1679            sample_pair_asymmetry: 0.0081,
1680            combined_risk: DetectabilityRisk::Medium,
1681        });
1682        let out = format_analysis_report(&report);
1683        assert!(
1684            out.contains("Spectral Detectability"),
1685            "section header must appear"
1686        );
1687        assert!(out.contains("0.1234"), "phase coherence drop should appear");
1688        assert!(out.contains("-2.50 dB"), "SNR drop should appear");
1689        assert!(
1690            out.contains("0.0081"),
1691            "sample-pair asymmetry should appear"
1692        );
1693        assert!(out.contains("Medium"), "spectral risk should appear");
1694    }
1695
1696    #[test]
1697    fn format_report_spectral_does_not_bleed_into_core_output() {
1698        let report = base_report();
1699        let out = format_analysis_report(&report);
1700        // Core fields still present even without spectral score
1701        assert!(out.contains("Technique"));
1702        assert!(out.contains("Chi-square"));
1703        assert!(out.contains("Recommended"));
1704    }
1705
1706    #[test]
1707    fn format_report_includes_ai_watermark_section_when_present() {
1708        let mut report = base_report();
1709        report.ai_watermark = Some(AiWatermarkAssessment {
1710            detected: true,
1711            model_id: Some("gemini".to_string()),
1712            confidence: 0.875,
1713            matched_strong_bins: 7,
1714            total_strong_bins: 8,
1715        });
1716
1717        let out = format_analysis_report(&report);
1718        assert!(out.contains("AI Watermark Detection"));
1719        assert!(out.contains("yes"));
1720        assert!(out.contains("gemini"));
1721        assert!(out.contains("0.8750"));
1722        assert!(out.contains("7/8"));
1723    }
1724}