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